diff --git a/packages/prover/.gitignore b/packages/prover/.gitignore new file mode 100644 index 000000000..51511d1f8 --- /dev/null +++ b/packages/prover/.gitignore @@ -0,0 +1 @@ +test-results/ diff --git a/packages/prover/README.md b/packages/prover/README.md new file mode 100644 index 000000000..609f93ec4 --- /dev/null +++ b/packages/prover/README.md @@ -0,0 +1,262 @@ +# React Doctor Prover + +`@react-doctor/prover` constructs a whole-project React proof report from a TypeScript program. +It fails closed: + +- `proved` means every discovered unit satisfies every implemented obligation. +- `refuted` means at least one obligation has a source-level counterexample. +- `incomplete` means a compiler error, opaque boundary, or unsupported React behavior prevented a proof. + +The package is private while the React semantics and proof boundary are under active development. +It does not affect the React Doctor score, CLI, or JSON report. + +## API + +```ts +import { checkReactProofReport, proveReactApp } from "@react-doctor/prover"; + +const report = proveReactApp({ + rootDirectory: "/absolute/path/to/app", +}); +const certificate = checkReactProofReport(report); +``` + +The report includes: + +- a versioned React semantic graph with component, render, hook, context, effect, Effect Event, + async-ownership, external-store, reconciliation, identity-stability, and execution-phase + callback facts, including project helpers reachable from render, event, memo, reducer, Effect, + Effect Event, and external-store callbacks; +- source-level direct, formal-parameter, and synchronous higher-order function-call edges; +- callable abstract-value flow through expression returns, exhaustive structured branches, + type-exhaustive or default-covered switches, caught throws, and `finally` return overrides, plus + termination-proved loop exits and finite iteration-variable joins through nested object and tuple + bindings, captured factory parameters, local object properties, and object arguments; +- component-prop flow from source callbacks through project render edges into event handlers, + Effect setup, Effect cleanup, and all three `useSyncExternalStore` callback channels, including + local and transitive wrappers plus finite JSX spreads of whole props, parameter rest props, and + non-escaping local `const` callback objects, with each use tied to its exact execution phase and + JSX render site; JSX sources are resolved in order so later spreads or explicit attributes + replace earlier callbacks, while finite symbol-identified path guards preserve correlated + ternary alternatives without relying on source order, including immutable identifier guards + substituted through source callback factories; +- callable-ref protocol facts that tie a `useRef` initializer, its exclusive effect write, the + write's commit phase, and every concrete invocation channel to the resolved source callback; + layout-synchronized, non-escaping refs used only by modeled events can be proved, while passive, + multiply written, escaping, and unresolved protocols fail closed; +- imperative-handle protocol facts that tie a canonical `useImperativeHandle` factory to a React + 19 `ref` prop or `forwardRef` parameter, every static method in its closed object result, an exact + project-local `useRef` binding, and each `ref.current.method()` invocation phase; stale reactive + captures and impure factories are refuted, while spreads, computed methods, opaque handle + objects, callback refs, shared refs, exports, and unresolved consumers fail closed; +- scheduler lifetime facts that tie a platform timer, animation frame, idle callback, immediate, + or microtask registration to its owning Effect or class mount, deferred callback set, exact + handle, and cleanup or unmount cancellation paths; only source-resolved synchronous callbacks + with entry-dominating cancellation are complete; +- lifecycle resource facts for platform event listeners and activated mutation, resize, and + intersection observers owned by Effects or class mount/unmount pairs; listener disposal follows + the DOM's type/callback/capture identity rule or an exact `AbortController`, observers record + every `observe()` activation, and every cleanup alternative must reach exact-object disposal; +- class construction facts that distinguish public `state` fields, direct constructor assignment, + duplicate initialization, and absent state; the certificate proves every supported instance-field + initializer, object-valued state, first-statement `super(props)`, pure constructor locals, + canonical method binding, and Strict-Mode-safe expressions, while accessor fields, conditional + control flow, and opaque factories fail closed; +- class lifecycle facts that certify symbol-resolved `Component` and `PureComponent` inheritance, + pure render callbacks, direct `componentDidMount`/`componentDidUpdate`/ + `componentWillUnmount` ownership transitions, exact stable method identities, immutable + primitive scheduler-handle fields, React-owned class state, pure `setState` updaters, and + bounded prop-history update guards; direct state assignments, updates, deletes, and + platform-resolved mutator calls are explicit forbidden graph facts, while object-valued state + references that escape the modeled boundary fail closed; +- Hook state-transition facts that identify the exact `useState` setter symbol, distinguish direct + values from functional updaters, link each call to its represented render, event, Effect, or + deferred callback root, and give every resolved updater its own `state-transition` callback; + synchronous pure updaters are certified, observable effects are refuted, and opaque updater + bodies or escaped setters fail closed without confusing `useReducer` dispatch or similarly named + functions with state setters; +- reducer-transition facts that identify each canonical `useReducer` tuple, resolve the reducer and + optional lazy initializer, use TypeScript union types to close exhaustive switch paths, and + classify every dispatcher reference by its React callback phase; pure total transitions and + owned non-render calls are certified, fallthrough, throw, impurity, and render/reducer dispatches + are refuted, while wrappers, unsupported control flow, and escaped dispatchers fail closed; +- lazy-component facts that identify canonical `lazy()` declarations by symbol, require + module-stable identity and a total thenable loader whose resolved default has a component call or + construction signature, and trace each JSX use through direct, transitive component, reachable + render-helper, synchronous-callback, and closed ReactNode-slot paths to symbol-resolved + `` boundaries; malformed loaders, render-local declarations, and known root paths + outside Suspense are refuted, while external components, exported lazy aliases, and unresolved + slots fail closed; +- Error Boundary facts that identify symbol-resolved React class boundaries, require a total pure + `static getDerivedStateFromError` transition and a render guard which reveals fallback UI, and + trace root-reachable explicit render throws through direct, helper, transitive component, and + closed ReactNode-slot paths; uncovered failures and invalid recovery protocols are refuted, + opaque state or topology remains unknown, and event handlers, server rendering, ordinary async + callbacks, and errors thrown by a boundary itself stay outside this theorem; +- `use` resource facts that use TypeScript to distinguish thenables from Context values and invalid + inputs, prove cached identity from module constants or React state with a stable initializer, and + propagate both pending and rejection paths through direct renders, closed ReactNode slots, and + custom Hooks; fresh + render-created Promises, missing Suspense, and missing valid Error Boundaries are refuted, while + opaque factories, props, and topology fail closed; +- host-control facts for intrinsic `input`, `select`, and `textarea` elements that distinguish + text, checkable, file, single-select, and multiple-select protocols; TypeScript value domains and + source-visible `useState` initializers and writes establish whether controlled ownership stays + defined, remains nullish, or can switch, while exact event facts tie editable controlled fields + to an entry-dominating write of `event.target` or `event.currentTarget` data into the same state + binding; uncontrolled defaults and explicit `readOnly` or `disabled` controls are certified, + conflicting controlled/default props, known ownership switches, missing writes, deferred writes, + conditional writes, and transformed values are refuted, while prop contracts, overriding + spreads, dynamic input kinds, file values, and multiple-select transforms fail closed; +- memo-equivalence facts that identify canonical `React.memo` calls, resolve the wrapped component + and custom comparator, collect every statically observed prop path with its TypeScript value + domain, and symbolically enumerate every path on which the comparator returns `true`; default + shallow comparison, a comparator that never skips, and complete equality over every observed + path are certified, while an omitted render value or callback is refuted; opaque boolean helpers, + dynamic prop access, unresolved component targets, and path explosion fail closed; +- hydration-equivalence facts that identify module-executed, source-resolved `hydrateRoot`, + `renderToString`, `renderToPipeableStream`, `renderToReadableStream`, and + `renderToStaticMarkup` calls, resolve their root component and `identifierPrefix`, and propagate + each root through effective project renders, ReactNode slots, and custom Hooks; an exact + interactive server/client pair is certified only when its prefix agrees and every modeled + first-render value is environment-independent, while browser-global output, `typeof window` + render branches, host-default locale formatting, static-markup hydration, and prefix mismatch + are refuted; dynamic roots, options, multiple candidate roots, and open slot topology fail + closed; function-owned and framework-generated entrypoints require adapters, while CSR-only + `createRoot` trees remain outside the theorem; +- Action State facts that identify canonical `useActionState` tuples, resolve each reducer Action + without imposing reducer purity, and classify every dispatcher call or escape; a dispatch is + certified only when every represented root is a Form Action, an Action State reducer, or a + complete Transition Action, while render and ordinary callback roots are refuted; +- Transition Action facts that identify imported or namespace `startTransition` and the second + tuple binding from a canonical `useTransition`, connect each source-resolved Action to its + invoking callback and a dedicated `transition-action` phase, and distinguish synchronous Actions + from async/deferred, opaque, and escaped boundaries; direct updates to state that controls an + intrinsic input are refuted, while derived local aliases are followed and component-prop or + spread control flow fails closed; +- Form Action facts for callable `action` on intrinsic forms and `formAction` on statically nested + submit buttons and inputs; direct and immutable-spread callback sources follow JSX precedence + through reachable helpers, while dynamic control types, composed form association, custom + components, and opaque callback props fail closed; +- Form Status facts that identify canonical `react-dom` `useFormStatus` calls and propagate the + nearest intrinsic parent form through closed component-render, ReactNode-slot, and custom-Hook + paths; a detached, same-component, exported consumer, or mixed outside-form path is refuted; +- ReactNode flow facts that distinguish JSX element construction from an effective render, + certify direct `children` and named-slot placement through transitive project-local + components, string-literal computed props, and portals, and retain every provider/form topology + frame along the path; external components, source or receiver aliases, dynamic computed props, + whole-props spreads, JSX value spreads, non-rendered JSX props, `Children` transforms, cycles, + and unmodeled callbacks fail closed instead of borrowing lexical JSX ancestry; +- optimistic state facts that identify canonical `useOptimistic` tuples, give reducers and + no-reducer functional updaters dedicated execution phases, reuse the updater-purity proof, and + require every setter call to be owned exclusively by Form or Transition Actions; render calls, + ordinary-event calls, mixed Action/event reuse, and observable reducer or updater effects are + refuted, while setter escape and unresolved callback flow remain unknown; +- normalized React Compiler CFG, instruction-effect, and reactive-place facts; +- per-unit proof obligations with `proved`, `violated`, or `unknown` results; +- project evidence for type unsoundness, compiler diagnostics, and opaque boundaries. + +The project must have a `tsconfig.json` at its root. Project discovery does not walk into parent +directories because that would silently enlarge the proof boundary. + +Every production proof is checked before it is returned. The independent checker rejects +unsupported schema versions, duplicate semantic IDs, dangling graph references, missing or +duplicate claim coverage, inconsistent context or async-ownership facts, incorrect summary +counts, helper/root callback phase mismatches, and a global verdict that does not follow from the +obligations. It also rejects function-call edges that cross owners, callback roots, or execution +phases, and flow kinds whose parameter/argument indexes are inconsistent. This is a structural +proof certificate today. Callback-prop channels are also checked for known owners, phase-matched +source callbacks, complete channels with actual sources, and internally consistent guarded +alternatives. Callable refs additionally require a source-complete `useLayoutEffect` update, a +concrete event callback, and a `ref.current` call edge. Scheduler certificates require a real +Effect setup or class mount callback, deferred callback facts, exact cancellation evidence, and +internally consistent completeness. Class lifecycle certificates additionally require one class +owner, phase-correct mount, update, and unmount callbacks, reciprocal resource, scheduler, and +state-write and state-transition links, and a completeness flag derived exactly from every owned +fact. State ownership certificates independently check lifecycle phase, forbidden/unknown +classification, and exact completeness. State transition certificates independently check updater +callback phase, guard evidence, convergence classification, and exact completeness. Broader +source-derived block invariants remain future work. Construction certificates independently check +one fact per class owner, the construction execution phase, initialization kind/location, +state-demand classification, issue/status coherence, reciprocal lifecycle ownership, and exact +source/completeness flags. Resource certificates +additionally require a real Effect setup or class mount, platform-declaration identity, deferred +or Effect Event callback facts, nonempty activation and disposal evidence, and a completeness flag +derived exactly from those facts. +Hook state-transition certificates additionally require a non-class owner, phase-consistent +execution roots, a `state-transition` updater callback for every resolved functional updater, and +source/completeness flags derived from the updater classification. The checker rejects forged +purity, setter-escape, callback ownership, and completeness combinations. +Reducer certificates additionally require phase-correct reducer and initializer callbacks, +type-aware total-return evidence, a symbol-linked state/dispatcher tuple, owned execution roots for +every direct dispatch, non-escape, and exact source/completeness equations. The checker rejects +forged callback, purity, totality, phase, reducer-link, and verdict combinations. +Lazy-component certificates additionally require reciprocal declaration/render links, known +Suspense boundary and render identities, stable declaration and loader-status equations, and +boundary sources independently propagated from every exported render root through effective +component and ReactNode-slot renders. The checker rejects forged loader, topology, outside-boundary, +source, completeness, and claim-verdict combinations. +Error Boundary certificates additionally require reciprocal definition/instance/render links, +phase-correct class ownership, exact recovery-protocol equations, and boundary sources +independently propagated from every exported render root through effective component and +ReactNode-slot renders. The checker rejects forged protocol, topology, outside-boundary, source, +completeness, and claim-verdict combinations. +`use` resource certificates additionally require a canonical non-Context `use` call, a +TypeScript-derived thenable kind, a closed cache-identity origin, independently propagated +Suspense and Error Boundary sources, valid recovery definitions, and the exact conjunction of +type, identity, topology, coverage, and completeness fields. The checker rejects forged resource +kind, identity, source, boundary, status, completeness, and claim-verdict combinations. +Host-control certificates additionally require coherent controlled/default prop presence, the +element-specific `value`/`checked` domain, paired local state and setter identities, event-phase +callbacks, reciprocal direct-value state transitions, and exact status, source, completeness, and +claim-verdict equations. An exact update must have a complete event callback and state-transition +link; the checker rejects forged ownership, mutability, update, callback, transition, and verdict +facts. +Hydration certificates additionally require canonical root API/kind agreement, known unit +targets, coherent prefix facts, one certificate per semantic unit, owner-qualified environment +hazards, and exact root sources independently propagated through render, slot, and custom-Hook +edges. The checker recomputes the server/client/static root partitions, topology uncertainty, +hazard ownership, equivalence status, source completeness, and per-unit claim verdict, rejecting +forged root, hazard, source, prefix, status, and completeness combinations. +Memo-equivalence certificates additionally require canonical comparator kinds, valid owner units, +unique observed and equality paths, exact default-shallow facts, complete symbolic true paths, and +status, source, completeness, and per-unit verdict equations independently recomputed from the +observed paths. The checker rejects forged omitted-prop, universal-equality, path-completeness, and +claim-verdict combinations. +Transition Action certificates require a valid non-render execution root, a symbol-identified +starter, a phase-correct Action callback, coherent controlled-state evidence, and exact +source/completeness equations. The checker rejects forged synchronous, controlled-input, +starter-escape, callback, owner, and execution-phase combinations. +Form Action certificates require phase-correct callback facts, a coherent intrinsic prop/control +kind, nonempty complete callback resolution, and exact source/completeness equations. Direct +Action State dispatchers link the form fact to their reducer-Action callback. Action State +certificates independently validate tuple ownership, reducer callback phase, dispatch kind, +Action-prop association, execution roots, linked state, and exact source/completeness equations. +Form Status certificates independently recompute parent-form sources from render and custom-Hook +edges plus effective ReactNode slot renders, require one fact for every canonical Hook call, +validate active-form ownership, and reject forged outside-form, source, topology-status, and +completeness fields. ReactNode certificates require exactly one slot-flow fact per slot input, +separate source-expression and placement completeness, reciprocal effective-render links, +path-owned provider/form facts, unique semantic IDs, and the exact completeness conjunction. +Imperative-handle certificates independently validate factory dependency captures and purity, +closed method sets, exact ref-to-render bindings, ref exclusivity and escape evidence, reciprocal +method and invocation links, caller-owned execution phases, and the final source/completeness +equations. +Optimistic +certificates independently validate tuple ownership, reducer and updater callback phases, derive +Action ownership from every execution root, and reject forged purity, render/event origin, state +binding, escape, and completeness combinations. + +## Verification + +```sh +nr test +nr test:runtime +nr typecheck +``` + +The Vite Plus test suite checks static proof results over the fixture corpus. The Playwright suite +is a runtime oracle for selected counterexamples. Runtime observations validate fixtures but never +upgrade an incomplete static proof. + +See [research-log.md](./research-log.md) for the soundness ledger and implementation roadmap. diff --git a/packages/prover/package.json b/packages/prover/package.json new file mode 100644 index 000000000..385d59914 --- /dev/null +++ b/packages/prover/package.json @@ -0,0 +1,38 @@ +{ + "name": "@react-doctor/prover", + "version": "0.0.0", + "private": true, + "description": "Whole-application React semantic prover.", + "license": "SEE LICENSE IN LICENSE", + "type": "module", + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && vp pack", + "smoke:build": "node scripts/smoke-built-package.mjs", + "test": "vp test run", + "test:runtime": "playwright test", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@babel/core": "8.0.1", + "babel-plugin-react-compiler": "1.0.0", + "typescript": ">=5.0.4 <7" + }, + "devDependencies": { + "@playwright/test": "1.61.1", + "@types/node": "^25.6.0", + "@types/react": "19.2.14", + "@types/react-dom": "^19.2.3", + "react": "19.2.5", + "react-dom": "19.2.5" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } +} diff --git a/packages/prover/playwright.config.ts b/packages/prover/playwright.config.ts new file mode 100644 index 000000000..d7607e9da --- /dev/null +++ b/packages/prover/playwright.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "@playwright/test"; +import { PROVER_RUNTIME_ORACLE_PORT, PROVER_RUNTIME_ORACLE_TIMEOUT_MS } from "./src/constants.js"; + +const baseUrl = `http://127.0.0.1:${PROVER_RUNTIME_ORACLE_PORT}`; + +export default defineConfig({ + testDir: "tests/runtime", + timeout: PROVER_RUNTIME_ORACLE_TIMEOUT_MS, + use: { + baseURL: baseUrl, + }, + webServer: { + command: `vite --config tests/runtime/vite.config.ts --host 127.0.0.1 --port ${PROVER_RUNTIME_ORACLE_PORT} --strictPort`, + url: baseUrl, + reuseExistingServer: false, + timeout: PROVER_RUNTIME_ORACLE_TIMEOUT_MS, + }, +}); diff --git a/packages/prover/research-log.md b/packages/prover/research-log.md new file mode 100644 index 000000000..140d81bce --- /dev/null +++ b/packages/prover/research-log.md @@ -0,0 +1,2613 @@ +# React prover research log + +## 2026-07-28: initial proof kernel + +### Objective + +Build an exhaustive, whole-application React prover. The terminal theorem is: + +```text +For every execution permitted by the modeled React runtime and every declared external contract, +the application preserves the React safety invariants. +``` + +A proof build has three outcomes: + +- `proved` +- `refuted` +- `incomplete` + +`incomplete` is a failed proof. Partial coverage must never be presented as application correctness. + +### Product boundary + +Job: a React developer needs deterministic evidence that an application obeys React semantics; +today they combine compiler diagnostics, lint rules, tests, and manual review. + +Change: add a private `@react-doctor/prover` package that owns a proof report and fixture corpus. +Do not add a CLI flag, score input, or JSON report field until the proof model survives real-project +evaluation. + +Reuse: + +- React Doctor already contains closure capture, scope, path-coverage, cleanup, and cross-file + dependency analyses. +- Those implementations are coupled to the oxlint ESTree rule runtime. The prover reuses their + contracts and regression ideas, but owns a TypeScript project model and proof verdict rather than + importing private rule internals. +- `truffler` searches for `prove react semantic graph`, `effect closure captured dependency`, + `render purity mutation alias`, and `typescript program source project` found no existing + application prover API. + +Promotion metric: percentage of real applications for which every React-relevant region is either +proved or represented by an explicit contract. Do not promote a public command based only on +fixture pass rate. + +Compatibility: private package, no current user-facing default, no score change, no report-schema +change, and no changeset. + +Kill criterion: do not promote the package if two research iterations fail to produce source-level +counterexamples with materially lower false-positive rates than the existing rule suite, or if +closed-world coverage remains too low for representative applications. + +### Evidence reviewed + +#### React specification surface + +- [Rules of React](https://react.dev/reference/rules) defines purity, immutable props/state/hook + inputs, React-owned component invocation, and hook call restrictions. +- [useEffect](https://react.dev/reference/react/useEffect) defines reactive dependencies and the + setup, cleanup, rerun, unmount, and Strict Mode stress-test lifecycle. +- The HTML Standard defines timers as active handles removed by + [`clearTimeout`/`clearInterval`](https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#dom-cleartimeout) + and animation-frame callbacks as handles removed by + [`cancelAnimationFrame`](https://html.spec.whatwg.org/multipage/imagebitmap-and-animations.html#dom-cancelanimationframe). + These are ownership transitions, not merely paired API names. +- [Lifecycle of Reactive Effects](https://react.dev/learn/lifecycle-of-reactive-effects) frames + each Effect as an independent synchronization process whose setup and cleanup may repeat. +- [StrictMode](https://react.dev/reference/react/StrictMode) deliberately runs an extra + setup-cleanup-setup cycle in development. Async ownership must therefore survive immediate + invalidation even when a dependency tuple is empty. +- The same `useEffect` reference defines an infinite cycle as an effect state update whose resulting + render changes one of that effect's dependencies. Dependency comparison uses `Object.is`; + omitting the tuple reruns after every commit, while `[]` bounds setup to mount lifecycle cycles. +- [rules-of-hooks](https://react.dev/reference/eslint-plugin-react-hooks/lints/rules-of-hooks) + states that hook order must be identical across renders. +- [purity](https://react.dev/reference/eslint-plugin-react-hooks/lints/purity) supplies canonical + non-idempotent render examples including `Math.random()` and `Date.now()`. +- [useEffectEvent](https://react.dev/reference/react/useEffectEvent) defines Effect Events as local + effect logic that reads the latest committed props and state. They may only be used by Effects or + other Effect Events, must not escape through components or Hooks, must not appear in dependency + tuples, and intentionally receive a new identity on every render. +- [useContext](https://react.dev/reference/react/useContext) defines context lookup by the closest + matching provider above the consumer. A provider returned by the same component does not affect + that component's own read, and provider/consumer context objects must be exactly identical. +- [createContext](https://react.dev/reference/react/createContext) defines the default value as a + static fallback used only when no matching provider exists above the consumer. +- [useSyncExternalStore](https://react.dev/reference/react/useSyncExternalStore) requires + `subscribe` to register React's callback and return cleanup, repeated `getSnapshot` calls to + remain `Object.is`-stable until the store changes, and `getServerSnapshot` to return the same + initial data during server rendering and client hydration. + +#### React Compiler + +- `/home/aidenybai/Developer/react/compiler/packages/babel-plugin-react-compiler/docs/passes/README.md` + defines HIR as a control-flow graph in SSA form. +- `/home/aidenybai/Developer/react/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts` + exposes the sequence from HIR lowering through mutation/alias effects and reactive-place + inference. +- `/home/aidenybai/Developer/react/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Options.ts` + exposes `debugLogIRs`, which can support a pinned feasibility adapter. +- The durable integration must be a versioned semantic snapshot, not retained mutable HIR objects + or parsed debug strings. + +#### Existing React Doctor semantics + +- `packages/oxlint-plugin-react-doctor/src/plugin/semantic/control-flow-graph.ts` +- `packages/oxlint-plugin-react-doctor/src/plugin/semantic/closure-captures.ts` +- `packages/oxlint-plugin-react-doctor/src/plugin/semantic/scope-analysis.ts` +- `packages/oxlint-plugin-react-doctor/src/plugin/utils/collect-returned-cleanup-functions.ts` +- `packages/oxlint-plugin-react-doctor/src/plugin/utils/do-nodes-cover-every-path-from-function-entry.ts` + +The current CFG answers targeted guaranteed-execution questions. It is not an SSA or lifecycle +model and must not become the whole-app proof substrate. + +#### Proof-system influences + +- [FreeRange](https://github.com/chenglou/freerange) uses the official TypeScript API, lowers a + constrained subset, propagates abstract values through control flow, and separates `requires`, + `assumes`, `proves`, and `unsupported`. The prover adopts the same rule that unsupported syntax + is a proof failure, never an implicit pass. +- [Making Referential Stability a Type](https://www.jovidecroock.com/blog/referential-stability-types/) + distinguishes stability across unrelated renders from immutability or permanent identity. + A future `Stable` contract should be phantom evidence with explicit invalidation, not a + claim that React can never discard a memoized value. User casts cannot manufacture proof. +- [Foldkit](https://foldkit.dev/) separates immutable model updates, commands, subscriptions, and + managed resource lifetimes. Its useful React-prover contribution is the explicit transition and + ownership boundary, not a replacement UI architecture. +- Pretext's prepare/layout split and browser-calibrated oracle reinforce a broader method: keep a + small deterministic semantic core, then validate selected extracted facts against the real + runtime without confusing an oracle with a proof. + +#### Realistic examples + +- `/home/aidenybai/Developer/react-bench-internal/tasks/fix-react-coreui-coreui-react-470` + contains a CoreUI listener-leak task. Its verifier checks that rerenders do not accumulate + listeners and unmount removes listeners with the same callback identity and registration + options. +- The `coreui-listener-leak` fixture preserves the essential failure: setup and cleanup contain + textually identical inline callbacks that are different function identities. +- The Playwright runtime oracle demonstrates the leak after unmount and the symmetric cleanup + behavior in Chromium. +- `/home/aidenybai/Developer/react-bench-internal/tasks/fix-react-rdh-hacker0x01-react-datepicker-calendar` + contains an imperative month-list loop whose original key was derived from the loop index. Its + repair derives identity from the represented year and month. The `datepicker-loop-index-key` + fixture preserves the original loop-and-push shape rather than reducing it to `array.map`. +- A second Playwright oracle types local state into one list item, reverses the list, and shows the + state moving to the wrong item under index keys while semantic keys preserve the state owner. +- React issue [#34818](https://github.com/facebook/react/issues/34818) is a realistic stale-value + failure crossing `memo`, context, and `useEffectEvent`. The Playwright corpus reproduces the bug + against pinned `react@19.2.5`: the memoized consumer renders the updated context, but the Effect + Event still observes the old value. Static proofs for that topology therefore remain incomplete + even though the source obeys the documented API contract. +- `/home/aidenybai/Developer/react-bench-internal/tasks/write-react-xr843-fojin-775/tests/harness/src/react-i18next-mock.ts` + implements a language store with `useSyncExternalStore`: a module snapshot, listener `Set`, + symmetric cleanup, and notification after every language write. The `proved-external-store` + fixture preserves that protocol. +- The saturated opencode React port uses browser-media callbacks, version stores, toast stores, and + object-method session stores with `useSyncExternalStore`; its media-query hook supplies distinct + subscribe, client snapshot, and server snapshot callbacks. The callback-prop fixtures preserve + the adapter-component variant where those three functions cross a render edge before reaching + React. +- `/home/aidenybai/Developer/react-bench-internal/tasks/write-react-docusaurus-tabs-11733` + requires every tab consumer to bind to its nearest Tabs provider while nested tab sets remain + isolated. The context fixtures preserve cross-file aliases, nested overrides, default fallback, + and distinct context-object identity. Playwright confirms both nearest-provider isolation and + the default-value result when a structurally identical but distinct context is consumed. +- React Bench tasks `write-react-glific-glific-frontend-3981`, + `write-react-eren23-openflipbook-72`, `write-react-tracecathq-tracecat-2879`, and + `fix-react-rdh-sofn-xyz-mailing-settings` all contain async work whose completion can outlive the + Effect instance that started it. They motivate an ownership theorem rather than a special-case + fetch warning. +- `write-react-eren23-openflipbook-72` invokes an optional `onLocalize` callback from a local event + handler, then tracks both synchronous throws and Promise settlement against an AbortController. + `write-react-tombelieber-claude-view-70` routes a `respond` callback through `runRespond` and + several memoized handlers before passing those handlers to child cards. These are realistic + evidence that callable values need argument, prop, return, and async-lifetime flow rather than + name-based handler detection. +- `fix-react-igordanchenko-yet-another-react-lightbox-slideshowcontext` combines a custom + `useEventCallback` wrapper with subscription callbacks. It is the boundary case for the next + layer: a source-level wrapper can be summarized, while an imported wrapper needs an explicit + library proof contract. +- `write-react-radix-context-menu-controlled-open` defines `whenTouchOrPen`, a plain function that + returns an event closure and conditionally invokes its captured handler. The returned-handler + fixture preserves that higher-order shape. +- `write-react-obbyworld-obby-206` returns a `useCallback` closure directly from a custom Hook, and + `write-react-cloudscape-design-components-4612` changes a returned ref getter to a stable + `useCallback`. Together they separate source-level returned closure flow from the stronger + temporal theorem required for ref-backed callback freshness. +- `migrate-react-opencode-solid-to-react-components` implements `useDefaultServerKey` with three + control-flow exits that all return a cleanup closure. The branch-cleanup fixture preserves this + realistic Effect shape and exercises the same structured return summary as callback factories. +- `migrate-react-opencode-solid-to-react-timeline-rendering` implements `renderTimelineRow` as a + switch over the finite `row._tag` discriminant, with each case returning a distinct JSX shape. + `migrate-react-opencode-solid-to-react-dialogs-settings` uses the same pattern for an + `Action["type"]` reducer, while `migrate-react-opencode-solid-to-react-components` has a + default-covered updater-state switch. These examples establish both useful switch proof modes: + checked literal-union coverage and a syntactic default. +- `migrate-react-opencode-solid-to-react-home-layout-sidebar` parses deep links with a value return + from `try` and an empty return from `catch`; the saturated port's `readStoredLocale` has the same + storage/JSON fallback shape. The global-sync queue also uses a `return` in `finally` deliberately + to override earlier exits when paused. Conversely, prompt submission catches, rolls back, and + rethrows. Together these require distinct normal, returned, and thrown completion facts rather + than treating every abrupt exit as equivalent. +- The saturated opencode port's `cachePrune` uses `for (;;)` with two conditional returns and a + cache-size descent argument, while its health check and global-sync queue use `while (true)` with + conditional exits. Those loops are not terminal on their first iteration; proving them requires + a ranking function or an external timeout/cancellation theorem, so they remain incomplete. + Event-bus notification also iterates fresh spread snapshots such as `[...listeners]`; a spread + source needs an iterator contract and is deliberately not treated like a fixed fresh literal. +- The same port's toast, file-tree, view-cache, child-store, and event-bus implementations invoke + callback-valued `listener` iteration bindings. This motivates an SSA join for the binding itself, + not only a loop-exit summary. For a `const` identifier over a nonempty fresh literal, the prover + now joins every element's callable abstract value before resolving `return listener` or + `listener()`. +- The port also contains pervasive tuple iteration such as `for (const [key, item] of entries)` in + `utils/server-scope.ts`, `for (const [, node] of nodes)` in line-comment annotations, and nested + object iteration in generated layout data. The callable lattice now represents array indexes as + properties and projects a finite literal join through nested object/tuple binding paths. Binding + defaults, rest elements, computed keys, mutable declarations, and opaque or spread iterables + still fail closed. +- React Bench sources also contain the ordinary wrapper shape everywhere: the opencode file tree + uses `onClick={() => props.onFileClick?.(node)}`, its list component invokes + `props.onSelect?.(item, index)` from local handlers, and `solid-dnd.tsx` adapts drag event props + through inline closures. A direct prop edge is therefore insufficient. Event proof now resolves + callback props captured by local and transitively called wrapper handlers back through every + project render site, then records the eventual source callback call in the event phase. +- React's [passing props](https://react.dev/learn/passing-props-to-a-component) guide explicitly + teaches whole-object forwarding with ``. TypeScript's + [JSX handbook](https://www.typescriptlang.org/docs/handbook/jsx) type-checks spread operands + against the target attribute type. The React Bench Radix context-menu task repeatedly removes a + scope prop into a parameter rest binding and forwards the remaining object into a primitive. + Those are proof-relevant edges, not decorative syntax. +- JSX property sources are ordered. A later explicit callback replaces a callback from an earlier + spread, while a later spread can replace an explicit callback. The callback graph now computes + one effective source per property and render site. Whole component-props parameters, parameter + rest bindings, finite non-escaping local `const` object literals, and intrinsic event spreads are + modeled. Shorthand object properties resolve through TypeScript's shorthand value symbol rather + than the property declaration symbol. The shared write collector treats direct assignment, + property/element assignment, increments, loop targets, and `delete` as writes. String/number + index signatures, unconstrained type parameters, getters, mutated or escaping objects, + unresolved nested prop objects, and object-literal spread merges still fail closed. A Playwright + oracle confirms both precedence directions in React 19.2.5. +- React's [`useRef`](https://react.dev/reference/react/useRef) contract says the initial value is + ignored after the first render, the ref object is stable, and render-phase reads or writes are + generally forbidden. [`useLayoutEffect`](https://react.dev/reference/react/useLayoutEffect) + runs after commit but before repaint, whereas [`useEffect`](https://react.dev/reference/react/useEffect) + may run after the browser paints. React's own Effect Event implementation updates its callback + payload in the before-mutation or mutation phase + ([hooks](https://github.com/facebook/react/blob/9ceb1e7d9e20bd0302cf6ab31b038c5ec673178d/packages/react-reconciler/src/ReactFiberHooks.js), + [commit](https://github.com/facebook/react/blob/9ceb1e7d9e20bd0302cf6ab31b038c5ec673178d/packages/react-reconciler/src/ReactFiberCommitWork.js)), + which is a stronger primitive than a passive userland ref update. +- Real libraries implement several distinct userland protocols. Yet Another React Lightbox uses a + client layout-effect alias and `useCallback`; MUI uses an enhanced layout effect and a stable + wrapper ref; Radix uses passive `useEffect` plus `useMemo` + ([Lightbox source](https://github.com/igordanchenko/yet-another-react-lightbox/blob/189830b19c0ed95370a485433f754b64aa09df04/src/hooks/useEventCallback.ts), + [MUI source](https://github.com/mui/material-ui/blob/7fb01101f45fb72fdbeb3d826984030583e71ea9/packages/mui-utils/src/useEventCallback/useEventCallback.ts), + [Radix source](https://github.com/radix-ui/primitives/blob/e1646bd74289e9de2ef8506204adec33c820876f/packages/react/use-callback-ref/src/use-callback-ref.tsx)). + The prover does not trust those names. It checks the ref declaration, the sole `.current` write, + dependency coverage, non-escape, wrapper return flow, and the concrete React execution phase. + A Chromium oracle updates a callback and programmatically clicks during the same component's + later layout effect: the layout-synchronized protocol observes revision 1, while the passive + protocol still invokes revision 0. +- `/home/aidenybai/Developer/react-bench-internal/tasks/fix-react-formidablelabs-victory-victory-animation` + guards delayed animation work by generation and clears the exact timeout handle; its unmount + verifier requires the animation not to complete after ownership ends. + `/home/aidenybai/Developer/react-bench-internal/tasks/write-react-tombelieber-claude-view-70` + supplies the ordinary interval setup/cleanup lifecycle. In contrast, + `/home/aidenybai/Developer/react-bench-internal/tasks/fix-react-floating-ui-floating-ui-2914` + schedules a timeout from an event and + `/home/aidenybai/Developer/react-bench-internal/tasks/write-react-radix-context-menu-controlled-open` + stores long-press timeout ownership in a ref across event handlers. Those event-owned protocols + must remain incomplete until the graph models their state machine. + +### Current proof model + +The package creates one `ts.Program` rooted at the requested application `tsconfig.json`. Parent +config discovery is forbidden. Every TypeScript error becomes project evidence and makes the proof +incomplete. Strict mode is required, and `any`, unchecked assertions, non-null assertions, +suppression comments, and JavaScript sources invalidate the proof boundary. + +Every report now carries a versioned `ReactSemanticGraph`. The graph is deliberately independent +of TypeScript AST node classes and records stable source-based IDs for units, custom-hook and +builtin-hook calls, cross-module JSX render edges, and effect dependency, capture, callback, and +cleanup facts. Context definitions, provider instances, consumer reads, and the provider stack +active at each render edge are also explicit graph facts. Async task facts link `await` and Promise +continuations to their owning Effect and record state writes plus guarded, unguarded, or unknown +ownership. The graph also records every source-resolved project helper reachable from +render, event, memo, reducer, Effect setup, Effect cleanup, Effect Event, and external-store +callbacks, together with its root callback, execution phase, and conditional reachability. When a +helper is reachable by both conditional and unconditional paths, the graph retains the stronger +unconditional fact. Effect resource and state-transition obligations traverse the same call graph, +so a helper or object method cannot hide listener acquisition, disposal, or a state write. Proof +obligations and graph extraction share the symbol-resolved collectors. A React Compiler adapter can +therefore replace individual fact producers without changing the report contract or proof +consumers. + +The graph records the call edges that justify helper reachability. Direct source calls, +source callbacks invoked through formal parameters or captured factory parameters, object-property +invocations, and callbacks passed to known synchronous iteration methods are distinct facts with +source and target function IDs, execution phase, conditional reachability, and the relevant +parameter, argument, or property path. Callable arguments can be forwarded through several source +helpers. A callable parameter that is stored or passed to an opaque/async registration boundary +makes `boundary-coverage` unknown instead of inheriting the caller's phase. + +Callable values now form a finite abstract-value lattice. A value contains possible source +functions, captured callable bindings, named object properties, conditionality, and a completeness +bit. The evaluator resolves aliases, object literals, object arguments, local property reads, +destructuring, `useCallback`, and source factories with expression or exhaustive structured branch +returns. Property projection inherits the containing object's completeness, and a destructuring +default retains its known fallback target without claiming that the fallback is always selected. +Returned closures retain the factory environment, allowing a Radix-style handler adapter +or a custom Hook returning `useCallback` to carry its source callback into the eventual React event +phase. A shared return summary proves sequential early returns and nested exhaustive `if/else` +paths while marking each alternative target conditional. It also proves switches only when every +clause terminates without fallthrough and coverage comes from a `default` clause or the TypeScript +checker can enumerate a finite literal union matched by the cases. Exception summaries preserve +normal, returned, and explicitly thrown completions: catch branches are always considered +reachable, caught throws are discharged, rethrows escape, and a `finally` return overrides prior +returns while a normally completing `finally` preserves them. Loop summaries prove literal-false +zero-iteration paths, bodies that terminate on their first entered iteration, one-pass +`do...while (false)`, and finite fresh array literals without spreads. An unranked repeating body, +`break`/`continue`, spread or opaque iterables, grouped or fallthrough switch clauses, +non-exhaustive switches, unresolved callable arguments, and mutable callable properties remain +explicit failed proofs. Layout-synchronized callable refs are the narrow exception: the evaluator +joins their initializer and sole effect-written value and carries that target through +`ref.current()` only when the source protocol is complete. A `const` binding iterating a nonempty fresh +literal is additionally bound to the join of its callable elements. Identifier, object, tuple, and +nested object/tuple paths can therefore carry returned or directly invoked loop callbacks into the +phase graph. Defaults, rest elements, computed keys, mutable declarations, and incomplete +containers reject completeness. + +Render-purity mutation ownership is evaluated relative to each reachable helper, not only the root +component. Rebinding a helper-local variable is unobservable and therefore allowed. Mutating a +parameter, captured value, or local alias whose initializer is not a fresh array, object, or +instance remains an observable input mutation. The mutable-iteration fixture keeps purity proved +while callable flow is unknown; the aliased-prop fixture independently guards the external-alias +counterexample. + +Phase-aware proof follows callback props through project component render edges. An intrinsic event +attribute or a callback-prop invocation from Effect setup or cleanup creates a required channel; +destructured props, renamed bindings, object-parameter property reads, prop-name changes across +several components, and local or transitive wrappers are resolved backward to every source +callback. Captured prop bindings are injected into the wrapper's callable environment, so +subsequent calls retain the requesting phase. The graph records each intrinsic event binding, +every required component prop edge with its phase, and the wrapper-to-source call. A computed +expression, missing render site, imported component, or cycle leaves the channel incomplete. +Finite typed spreads are accepted only for source-resolved whole-props, parameter-rest, or +non-escaping local `const` object values. JSX sources are folded left to right, and only the last +source of each property contributes callbacks. The independent checker rejects complete channels +without a source callback in the same phase. A callback prop invocation is discharged only when a +complete prop channel and a call fact in that phase agree at the exact source location. + +Effect callback props require an additional transition guard. A source callback that writes its +own component state can rerender that source component, create a fresh callback identity, change +the child Effect dependency, and schedule the Effect again. Callback facts therefore record direct +and project-helper state writes. The current model fails this case closed pending an +identity-stability and cross-component rerender fixpoint proof; a source callback with no state +writes can be proved in Effect setup or cleanup. + +Callable refs have a separate temporal certificate. A complete fact requires one local `const` +`useRef` initialized from the same callback symbol written to `.current`, exactly one simple write +inside `useLayoutEffect`, dependency coverage or an omitted dependency tuple, no escape or +non-call read, and at least one concrete event-phase invocation. Generic `useCallback` and +`useMemo` wrappers both preserve the factory environment into the event graph. Passive +`useEffect`, multiple writes, render access, unresolved aliases, imported effect wrappers, and +non-event invocation channels remain `unknown`. The independent checker requires a complete fact +to name the layout update and an event callback whose graph contains the corresponding +`ref.current` call edge. + +Platform schedulers inside Effects have a first-class lifetime certificate. Timer, interval, +animation-frame, idle-callback, immediate, and microtask registrations are symbol-checked against +platform declaration files so project functions that merely share those names are not trusted. +Each fact links the registration to its owning Effect and setup callback, resolves the registered +function into the deferred execution phase, propagates its reachable project calls, and records +the exact cancellation locations. Completeness currently requires an immutable local `const` +handle, unconditional registration, a source-resolved synchronous callback, and every possible +Effect cleanup return to begin with cancellation of that exact handle. Conditional cancellation, +an earlier cleanup return, mutable or property handles, microtasks, nested scheduling, `await`, and +Promise continuations fail closed. Schedulers outside Effects are rejected by boundary coverage +until an event-lifetime or external owner protocol exists. + +`useSyncExternalStore` arguments use the same project callback lattice but terminate in three +distinct protocol channels: subscription lifetime, client render snapshot, and server-render +snapshot. The graph stores callback sets and completeness independently for all three and +links each callback-prop flow to its certified JSX render fact. +External-store consistency resolves the source functions from those certified callback IDs before +checking symmetric cleanup, cached snapshot identity, store-write notification, and hydration +agreement. When separate JSX branches supply different store adapters, callbacks are grouped by +render ID and each protocol variant is checked against its own subscription registry. The +independent checker requires every cross-unit callback ID to be justified by a complete +phase-matched prop flow whose render ID names a real render edge with the same owner and target. +For conditional expressions whose condition is a source-resolved identifier, the callable lattice +adds the condition symbol and branch polarity to every target. Those guards participate in target +identity, survive aliases and component-prop forwarding, and are serialized as guarded callback +alternatives. The external-store proof correlates channels only when every guarded channel exposes +the same finite assignment partition; a singleton unguarded callback may act as a +variant-independent source. Different condition symbols, mixed guarded and unguarded joins, +duplicate assignments within one expression, and opaque conditions remain incomplete. Reversing +callback choices under the same guard does not hide a defect: it creates the real crossed protocol +variants, which are checked and refuted when snapshot writes notify the wrong registry. +At ordinary call-return boundaries, callee-local guards are removed. Identifier arguments are +instead substituted into scalar parameter guards, including composed `!` polarity through nested +source calls. The substitution requires a declaration-backed symbol with no assignment, +increment/decrement, or loop-binding writes in its source file. This proves conditional callback +factories when every channel receives the same caller guard, keeps different caller guards +incomplete, and rejects a guard written between JSX attributes. Other scalar expressions and +property-access conditions remain incomplete. + +That guard exposed three earlier overclaims: the ignore-flag, AbortController, and Promise-chain +fixtures prove ownership of their post-suspension state writes, but each invokes a loader callback +supplied through component props. They are now incomplete application proofs with a separately +proved async-ownership obligation. A valid local lifetime proof cannot stand in for an external +function-effect contract. + +Callbacks carry an explicit execution phase: render, server render, deferred callback, user event, +reducer state transition, effect setup, effect cleanup, Effect Event, or external-store +subscription. Effects link directly to their setup and cleanup callback IDs. Effect Events record +their latest-value callback and intentionally unstable identity. This prevents later lifecycle +rules from applying render constraints to event code or treating cleanup and non-reactive effect +logic as ordinary nested syntax. + +Component and custom-hook entry functions are explicit render callbacks. Source-resolved calls and +synchronous iteration callbacks such as `map`, `filter`, and `reduce` inherit that render phase. +The event collector searches those reachable render functions, so an event handler returned from a +list callback is represented without treating the event body itself as render code. Callback IDs +include their owning React unit because one module-level function can participate in multiple +component lifecycles. + +Effect Event ownership is checked transitively through project helpers. Cleanup callbacks count as +part of the owning Effect lifecycle, while a helper reachable from both Effect logic and a JSX +event remains invalid because one represented execution phase can invoke it outside the Effect. +Named `useMemo` and `useState` factories are resolved before render-purity analysis, closing a gap +where an impure project helper could previously hide behind a callback identifier. The pinned +React Compiler still requires an inline `useMemo` factory, so such source can be statically refuted +by an obligation even while the compiler facts independently remain incomplete. + +React Compiler facts are collected through its public `logger.debugLogIRs` option at the +`InferReactivePlaces` phase. The compiler mutates one HIR object throughout the pipeline, so the +adapter normalizes facts synchronously during the callback. It records basic blocks, +predecessors/successors, terminals, instruction value kinds, lvalue effects, and reactive-place +flags. Compilation uses React Compiler's `infer` mode, so ordinary store and domain functions are +not incorrectly treated as components. Compiler skips and errors for inferred React functions +become project evidence and prevent a `proved` result. A compiler fork is therefore unnecessary +for CFG extraction today; a fork would only be justified if the logger contract disappears or +required facts are never exposed at any named phase. + +The prototype uses Babel 8 to drive the React Compiler plugin because the repository's +no-trust-downgrade policy rejects Babel 7's unattested `semver@6.3.1` dependency. That makes the +private package's current development/runtime floor Node 22.18. This is an explicit prototype +constraint, not a proposed React Doctor CLI requirement. + +Discovered React units currently include: + +- Uppercase, default-exported, and `memo`/`forwardRef`-wrapped function components, including + components that return `null` +- Custom hooks named with the `use` convention +- Symbol-resolved `Component` and `PureComponent` classes with conservative construction, + lifecycle, state-transition, and state-ownership certificates + +Each discovered unit receives these obligations: + +| Claim | Current evidence | +| ----------------------------- | ------------------------------------------------------------------------------------------- | +| `async-effect-ownership` | Post-`await` and Promise-continuation commits, cleanup invalidation, abort guards | +| `callable-ref-freshness` | Initial value, exclusive effect write, commit timing, non-escape, concrete event channels | +| `class-construction` | State initialization, field purity, superclass ordering, repeat-safe construction | +| `class-state-transitions` | State ownership, updater purity, lifecycle phase, bounded update convergence | +| `hook-order` | Conditional, looped, nested, and post-early-return hook positions | +| `hook-ownership` | Module, helper, method, and anonymous-callback hook calls without a valid React owner | +| `hook-state-transitions` | Setter identity, callback ownership, direct values, replay-safe functional updaters | +| `context-topology` | Exact object identity, defaults, provider values, nested overrides, render/hook propagation | +| `render-purity` | State writes, input mutation, known non-idempotence, transitive local helpers, opaque calls | +| `effect-dependencies` | Symbol-resolved reactive captures versus inline dependency tuples | +| `effect-cleanup` | Transitive listener/resource acquisition, identity symmetry, and conditional helper paths | +| `effect-state-updates` | Transitive writes, mount bounds, local-rerender stability, and unknown fixpoints | +| `effect-event-usage` | Local Effect ownership, non-escape, dependency exclusion, intentionally unstable identity | +| `external-store-consistency` | Stable snapshots, symmetric subscriptions, write notification, hydration agreement | +| `action-state` | Reducer Action identity, dispatcher ownership, Form/Transition Action execution roots | +| `form-actions` | Intrinsic form/submitter semantics, callback identity, form association, Action phase | +| `form-status` | Parent-form ancestry, same-component exclusion, mixed render paths, composed uncertainty | +| `memo-dependencies` | `useMemo` and `useCallback` captures versus inline dependency tuples | +| `optimistic-state` | Reducer/updater purity, setter identity, render exclusion, Form/Transition Action ownership | +| `reconciliation-identity` | Missing, duplicate, index-derived, and unconstrained dynamic list keys | +| `reducer-purity` | Reducer and reducer-initializer transition purity | +| `reducer-transitions` | Reducer/initializer totality, tuple identity, dispatch ownership, render exclusion | +| `ref-access` | Render-phase access to refs created by `useRef` | +| `scheduled-callback-lifetime` | Effect ownership, deferred callback resolution, exact handles, guaranteed cancellation | +| `transition-actions` | Starter identity, Action ownership/phase, synchrony, direct controlled-input state | +| `component-identity` | Component definitions created during another render | +| `component-invocation` | Source-resolved component functions called outside reconciliation | +| `boundary-coverage` | Opaque modules, dynamic code, unsupported hooks, and unmodeled event callbacks | + +Application status is derived globally: + +```text +any violated obligation => refuted +otherwise any unknown/project error => incomplete +otherwise => proved +``` + +### Fixture corpus + +Proved: + +- `proved-chat` +- `proved-local-graph` +- `proved-timer` +- `proved-custom-hook` +- `proved-cfg` +- `proved-memo` +- `proved-reducer` +- `proved-context` +- `proved-context-topology` +- `proved-context-identity` +- `proved-wrapped-component` +- `proved-null-component` +- `proved-default-component` +- `proved-aliased-hook` +- `proved-static-list-keys` +- `proved-mount-state-update` +- `proved-external-store` +- `proved-external-store-callback-props` +- `proved-external-store-callback-prop-spread` +- `proved-external-store-conditional-props` +- `proved-external-store-conditional-factory` +- `proved-external-store-render-branch-props` +- `proved-effect-event` +- `event-handler-boundary` +- `proved-helper-effect-cleanup` +- `proved-conditional-helper-effect-cleanup` +- `proved-shared-event-handler` +- `proved-event-callback-parameter` +- `proved-event-prop-flow` +- `proved-forwarded-event-prop` +- `proved-event-prop-spread` +- `proved-rest-event-prop-spread` +- `proved-intrinsic-event-prop-spread` +- `proved-jsx-spread-trailing-explicit-event` +- `proved-jsx-spread-trailing-spread-event` +- `proved-effect-callback-prop` +- `proved-cleanup-callback-prop` +- `proved-mixed-phase-callback-prop` +- `proved-event-prop-wrapper` +- `proved-transitive-event-prop-wrapper` +- `proved-returned-event-handler` +- `proved-object-callback-flow` +- `proved-returned-use-callback-hook` +- `proved-local-object-callback` +- `proved-conditional-handler-factory` +- `proved-switch-handler-factory` +- `proved-try-catch-handler-factory` +- `proved-finally-overrides-handler` +- `proved-while-handler-factory` +- `proved-for-of-handler-factory` +- `proved-for-of-invoked-handlers` +- `proved-for-of-object-binding-handler` +- `proved-for-of-tuple-binding-handler` +- `proved-for-of-nested-binding-handler` +- `proved-helper-local-rebinding` +- `proved-branch-effect-cleanup` +- `proved-layout-ref-backed-event-callback` +- `proved-layout-ref-backed-memo-event-callback` +- `proved-window-timeout` +- `proved-animation-frame` +- `proved-aliased-window-timeout` +- `proved-shadowed-timeout` +- `class-component` +- `proved-pure-class-render` +- `proved-class-listener` +- `proved-class-timeout` + +Refuted: + +- `conditional-hook` +- `stale-effect` +- `impure-render` +- `cleanup-mismatch` +- `coreui-listener-leak` +- `nested-component` +- `direct-component-call` +- `render-ref-access` +- `state-update-in-render` +- `prop-mutation` +- `helper-aliased-prop-mutation` +- `transitive-impure-helper` +- `timer-leak` +- `impure-reducer` +- `aliased-stale-effect` +- `use-in-try` +- `missing-list-key` +- `duplicate-list-key` +- `effect-self-cycle` +- `fresh-external-store-snapshot` +- `fresh-external-store-callback-prop-snapshot` +- `silent-external-store-write` +- `silent-external-store-render-branch-props` +- `mismatched-server-snapshot` +- `mismatched-external-store-callback-prop-server-snapshot` +- `mismatched-external-store-conditional-props` +- `mismatched-external-store-conditional-factory` +- `external-store-cleanup-mismatch` +- `effect-event-dependency` +- `effect-event-render-call` +- `effect-event-prop-escape` +- `effect-event-hook-escape` +- `memo-callback` +- `invalid-hook-helper` +- `module-hook-call` +- `anonymous-hook-callback` +- `context-provider-missing-value` +- `async-effect-stale-write` +- `async-effect-promise-chain` +- `helper-effect-listener-leak` +- `method-effect-listener-leak` +- `named-memo-impure-helper` +- `effect-event-shared-helper` +- `render-callback-parameter-impurity` +- `callback-parameter-effect-listener-leak` +- `render-returned-callback-impurity` +- `object-callback-effect-listener-leak` +- `branch-returned-render-impurity` +- `switch-returned-render-impurity` +- `try-catch-returned-render-impurity` +- `finally-returned-render-impurity` +- `while-returned-render-impurity` +- `for-of-returned-render-impurity` +- `for-of-invoked-render-impurity` +- `for-of-destructured-render-impurity` +- `refuted-layout-ref-missing-dependency` +- `refuted-timer-partial-cleanup` +- `class-render-impurity` +- `class-listener-leak` +- `class-listener-capture-mismatch` +- `class-timeout-leak` + +Incomplete: + +- `opaque-render-call` +- `effect-state-update` +- `unsafe-types` +- `path-dependent-cleanup` +- `conditional-use` +- `index-list-key` +- `datepicker-loop-index-key` +- `compiler-bailout` +- `effect-event-memo-context` +- `effect-event-opaque-registration` +- `external-context` +- `async-effect-opaque-guard` +- `async-effect-opaque-continuation` +- `async-effect-post-await-mutation` +- `async-effect-path-dependent-invalidation` +- `helper-effect-state-update` +- `external-store-helper-boundary` +- `incomplete-external-store-callback-prop-conditional-join` +- `incomplete-external-store-conditional-factory` +- `incomplete-external-store-mutated-conditional-props` +- `mapped-event-handler` +- `callback-parameter-opaque-registration` +- `incomplete-jsx-spread-leading-explicit-event` +- `incomplete-jsx-spread-open-ended-event` +- `incomplete-jsx-spread-mutated-object` +- `incomplete-effect-callback-prop-state-cycle` +- `incomplete-defaulted-event-prop-wrapper` +- `incomplete-computed-event-prop-wrapper` +- `incomplete-local-object-callback-spread` +- `incomplete-async-effect-ignore-contract` +- `incomplete-async-effect-abort-contract` +- `incomplete-async-effect-promise-ignore-contract` +- `incomplete-object-callback-spread` +- `incomplete-partial-handler-factory` +- `incomplete-switch-fallthrough-handler-factory` +- `incomplete-switch-uncovered-handler-factory` +- `incomplete-try-catch-handler-factory` +- `incomplete-while-handler-factory` +- `incomplete-for-of-spread-handler-factory` +- `incomplete-for-of-mutable-handler` +- `incomplete-for-of-defaulted-handler` +- `incomplete-for-of-rest-binding-handler` +- `incomplete-for-of-computed-binding-handler` +- `incomplete-ref-backed-event-callback` +- `incomplete-layout-ref-escaped-event-callback` +- `incomplete-layout-ref-multiple-write-event-callback` +- `incomplete-mutable-object-callback` +- `incomplete-event-timeout` +- `incomplete-mutable-timer-handle` +- `incomplete-conditional-timer-cancellation` +- `incomplete-early-return-timer-cleanup` +- `incomplete-timer-async-continuation` +- `incomplete-timer-floating-promise` +- `incomplete-effect-microtask` +- `incomplete-nested-timeout` +- missing project configuration + +### Soundness ledger + +The current package is a proof-kernel scaffold, not yet the terminal exhaustive React proof. +`proved` currently quantifies over the implemented obligations and supported subset. + +Known regions that must force `incomplete` until modeled: + +- Async work outside directly invoked Effect-local async functions and direct + `.then`/`.catch`/`.finally` continuations +- Async ownership of non-state external side effects without a checked function summary +- Callback flow through open-ended, nested, escaping, module-owned, mutated, getter-backed, or + unresolved JSX spread objects; computed/defaulted prop expressions; mutated/computed object + fields; logical aliases crossing opaque registries; grouped switch cases; fallthrough clauses; + or non-finite switch discriminants +- Callable factories with unranked repeating loops, `break`/`continue`, iterable spreads, or + iterator values that lack a checked finiteness and mutation contract; mutable, defaulted, rest, + and computed iteration bindings also lack an SSA write summary +- Implicit synchronous exceptions from calls and property operations without checked throw + contracts; catch branches are over-approximated, but uncaught expression throws are not yet a + whole-project obligation +- Passive, multiply written, escaping, imported-wrapper, or non-event callable-ref protocols +- Event-owned, ref-owned, custom, and opaque schedulers; scheduler callbacks that create nested, + awaited, or Promise-continuation work; and exception paths between acquisition and cleanup +- Phase-polymorphic callbacks crossing opaque library or Promise registration contracts +- Context propagation through opaque library components, portals, and externally mounted exports +- Effect Event registration APIs beyond directly modeled timers, browser listeners, subscriptions, + and emitter `on`/`once` contracts +- Mutable-object external-store snapshots requiring cache summaries, selectors, or third-party + store contracts +- Async Transition ordering, deferred values, optimistic state, form Actions, and transition state + flow beyond direct local `useState` controls +- Suspense and abandoned render behavior +- Reconciliation outside direct arrays, map callbacks, and imperative `for`-loop list construction +- Component tree position and state preservation outside represented list identities +- Server Components, client boundaries, hydration, and serialization +- Class constructors, derived state, snapshots, error boundaries, refs, `shouldComponentUpdate`, + commit callbacks, helper-mediated state writes, state-to-instance convergence, and + state-transition fixpoints outside direct mount/update ownership and prop-history guards +- Effect transition fixpoints beyond mount-bounded writes and unconditional boolean/fresh-reference + self-cycles +- Library hooks without semantic summaries + +Before accepting a proof, the coverage scanner must also reject React calls outside discovered +components and hooks, including hooks hidden in incorrectly named helper functions. + +### Next architecture + +1. Add callable SSA joins for ranked loops, mutable/defaulted/rest/computed iteration bindings, + switch fallthrough and grouped cases, property writes, open-ended/nested JSX and object spreads, + and checked library contracts, including synchronous throw summaries, Promise continuations, + and user-defined registration APIs. +2. Replace syntax-level hook and path checks with SSA CFG obligations and checked function + summaries. +3. Introduce a formal lifecycle machine for render, commit, effect setup, cleanup, event, + suspension, interruption, and unmount. +4. Add reconciliation state for component type, key, position, hook slots, refs, and effect + instances. +5. Extend the independent structural report checker with source-derived block invariants and + richer lifecycle transition certificates. +6. Evaluate against React Bench workspaces and open-source applications. Every new unsupported + construct becomes explicit corpus coverage, never an implicit pass. + +### Test stack + +Current checkpoint: 324 TypeScript fixture projects, 527 static tests, and 40 Chromium runtime +oracles. + +- Vite Plus supplies package build and Vitest-compatible static tests. +- TypeScript fixture projects exercise real project construction and cross-file symbols. +- Playwright runs selected lifecycle counterexamples in Chromium. +- Runtime oracles validate fixture behavior only. They are not proof certificates and cannot turn + `incomplete` into `proved`. +- The external-store oracle routes subscribe and snapshot functions through an adapter component, + reproduces React's cached-snapshot invariant for a fresh object, and confirms the stable + cached-object control. A second oracle switches between two JSX render branches and confirms that + updates from the inactive store no longer affect the mounted reader. A third performs the same + switch through ternaries in one JSX render site, exercising the guard-correlated protocol. +- Effect Event oracles contrast latest-value reads with an ordinary stale closure, prove identity + changes across renders, and reproduce the pinned-runtime `memo` plus context defect from React + issue #34818. +- Context oracles confirm exact context-object identity, static default fallback, parent + inheritance, and nearest nested-provider isolation. +- The async ownership oracle races a slow superseded request against a fast current request. The + unguarded completion overwrites current state; cleanup invalidation preserves the current owner. +- The JSX spread oracle confirms React's ordered property-copy semantics in both directions: + trailing explicit callbacks replace spread callbacks, and trailing spread callbacks replace + explicit callbacks. +- The callable-ref oracle performs an update and a programmatic click in one commit. The + layout-synchronized ref observes the new callback; the passive ref exposes the previous callback + before its Effect runs. +- The scheduler-lifetime oracle unmounts before a timeout expires. Exact cleanup cancellation + keeps the post-unmount hit count at zero, while the uncanceled control fires once after unmount. +- The observer-lifetime oracle mutates the document after unmount. `disconnect()` suppresses + delivery, while the intentionally leaked observer still receives the mutation. +- Class lifecycle oracles run under root Strict Mode. Exact listener removal survives the synthetic + mount/unmount/remount sequence, while omitted teardown remains observable after final unmount. + Exact timeout cancellation suppresses both timer generations; omitted cancellation fires both. +- The class state-transition oracle confirms that a previous-props guard converges after one state + write and that an unguarded `componentDidUpdate` write reaches React's maximum-update-depth + failure. +- The Hook state-transition oracle confirms that one event commits one state increment while root + Strict Mode invokes the functional updater twice to expose accidental impurity. + +## Effect resource lifetime certificates + +### Product brief + +Job: A React maintainer wants a deterministic answer that an Effect cannot retain a browser +resource or receive callbacks after replacement/unmount; text matching and ordinary lint cannot +establish identity or path coverage. + +Change: Add internal, versioned resource facts to the private proof graph. Each fact links one +platform acquisition to its Effect setup, deferred callback graph, exact disposal calls, and a +fail-closed completeness bit. + +Reuse: The implementation extends the existing Effect, callback reachability, scheduler lifetime, +and report-checker machinery. Broad `truffler` searches for resource lifetime, listener identity, +observer disposal, and guaranteed cleanup found no equivalent symbol. + +Compat: The package remains private at `0.0.0`; graph schema 17 and report schema 11 make stale +certificates explicitly unsupported. No React Doctor JSON surface, telemetry, action input, score, +or published package changes. + +Kill: Remove a protocol if realistic-corpus review finds any false `proved` result. Precision may +stay incomplete, but a certificate may never rely on spelling alone. + +### Platform semantics + +- The [DOM Standard](https://dom.spec.whatwg.org/) defines listener identity for registration and + removal by event type, callback, and capture. `passive`, `once`, and `signal` are not part of the + removal match, so comparing complete option-object text is both unsound and imprecise. +- An Effect still owns a `once` listener until it fires. `once: true` therefore does not discharge + unmount cleanup. +- `MutationObserver`, `ResizeObserver`, and `IntersectionObserver` become active through + `observe()`, not construction alone. A lifetime fact is emitted only for an activated observer, + and exact-object `disconnect()` is its modeled disposal. +- The [WebSocket Standard](https://websockets.spec.whatwg.org/) makes `close()` initiate a closing + handshake rather than synchronously erase every possible callback. WebSocket certification + remains unsupported instead of treating a `.close()` spelling as proof. +- The [server-sent events specification](https://html.spec.whatwg.org/dev/server-sent-events.html) + similarly requires a dedicated EventSource protocol before `close()` can become proof evidence. + +### Realistic corpus evidence + +React Bench cases motivating the listener protocol include: + +- `fix-react-coreui-coreui-react-470`: stable targets, capture symmetry, resize and visibility + listeners, and transition cancellation. +- `fix-react-rdh-catho-quantum-autocomplete`: window click and keydown listener ownership. +- `fix-react-jumperexchange-jumper-exchange-2917`: multiple video event registrations. +- `write-react-trycompai-comp-3248`: document mousemove and mouseup pairs. +- `write-react-azouaoui-med-react-pro-sidebar-267`: media-query change listeners. + +Observer cases include `write-react-cloudscape-design-components-4631` for `MutationObserver` and +`write-react-treely-boemly-277` for `ResizeObserver`. The Victory animation case uses a custom +`timer.subscribe`; it is evidence that a generic `.subscribe()` name must not be granted browser +resource semantics without a checked library contract. + +### Current proof boundary + +The certificate recognizes TypeScript declarations from the platform libraries, immutable +callback/target identity, static event type and capture, exact `AbortController` signal ownership, +platform-value provenance, every returned cleanup alternative, and entry-dominating direct or +helper disposal. Dynamic or accessor-backed capture, ref/prop/structural targets, mutable targets, +opaque disposer helpers, async or thenable callbacks, and path-correlated acquisition/cleanup +remain incomplete. Conditional acquisition is proved when exact disposal is unconditional on +every cleanup alternative. + +Added corpus: + +- proved: `proved-listener-capture-semantics`, `proved-abort-signal-listener`, + `proved-mutation-observer`, `proved-observer-constructor-only`, and + `proved-conditional-helper-effect-cleanup` +- refuted: `abort-signal-listener-leak` and `mutation-observer-leak` +- incomplete: `incomplete-dynamic-listener-capture`, + `incomplete-accessor-listener-capture`, `incomplete-async-listener-callback`, + `incomplete-ref-event-target`, and `incomplete-structural-event-target` +- declaration guard: `shadowed-event-target` + +## Class render and mount/unmount certificates + +### React semantics + +- The official [`Component` reference](https://react.dev/reference/react/Component) requires + `componentDidMount` setup to be mirrored by `componentWillUnmount` cleanup. It also defines + `render` as pure and warns that unguarded `componentDidUpdate` state changes can loop. +- [`StrictMode`](https://react.dev/reference/react/StrictMode) performs an extra development + setup/cleanup cycle when enabled at the root. The runtime oracles therefore test two lifecycle + generations rather than treating one production mount as sufficient evidence. +- `render` may be called and discarded, so class render uses the same render-phase purity and + callback-reachability obligations as a function component. Lifecycle methods are separate + commit-phase callback roots. + +### Proof boundary + +React inheritance is resolved through TypeScript symbols and only canonical React `Component` or +`PureComponent` declarations create class units. A complete class certificate currently permits a +pure ordinary `render`, direct ordinary `componentDidMount`, `componentDidUpdate`, and +`componentWillUnmount` methods, stable callback methods, and primitive scheduler-handle properties +whose sole write is the certified registration assignment. + +Mount/unmount listener facts reuse the DOM identity certificate. Timer facts require an exact +property symbol, one registration write, an entry-dominating matching cancellation, a synchronous +deferred callback, and reciprocal links among the class lifecycle, mount callback, scheduler, and +unmount evidence. Missing disposal or cancellation is a concrete refutation. Reassignment, helper +indirection not represented by the lifecycle summary, and unsupported members force +`incomplete`. + +The React Bench checkout contained no checked-in `componentDidMount`, +`componentWillUnmount`, or `componentDidUpdate` TypeScript/JavaScript sources at this checkpoint; +it is evidence that modern hook code dominates that corpus, not evidence that class behavior can +be ignored. Adversarial class shapes were instead seeded from React Doctor's existing class +lifecycle rule corpus and checked against the official React semantics above. + +Added corpus: + +- proved: `class-component`, `proved-pure-class-render`, `proved-class-listener`, + `proved-class-timeout`, and the empty-update `incomplete-class-lifecycle` characterization +- refuted: `class-render-impurity`, `class-listener-leak`, + `class-listener-capture-mismatch`, and `class-timeout-leak` +- incomplete: `incomplete-class-field`, `incomplete-class-helper-lifecycle`, + `incomplete-class-listener-method-reassigned`, and `incomplete-class-timeout-reassigned` +- declaration guard: `shadowed-component-class` + +## Class state-transition certificates + +### React semantics + +- The official [`Component` reference](https://react.dev/reference/react/Component) defines + `setState` updater functions as pure queued calculations and warns that calling `setState` in + `componentDidUpdate` must be guarded or it can create an infinite loop. +- An object update shallow-merges state and schedules another render. A `null` updater is a no-op. + `PureComponent` may skip an update, so an unguarded object update on `PureComponent` is unknown + rather than a claimed guaranteed cycle. +- A previous-props inequality guard over the same top-level property becomes false after the + state-only update because props did not change. This is the first bounded update invariant. + Conjunctions need one such conjunct; disjunctions require every alternative to have the + invariant. Nested property paths remain unknown because a mutable object or getter can change + without a new top-level prop. Broad number-valued guards also remain unknown because + `NaN !== NaN` stays true across a state-only update; finite numeric-literal unions exclude that + counterexample and can be certified. + +### Proof boundary + +`this.setState` is recognized only through a symbol whose declaration belongs to React's +`Component`; lookalike and overridden methods are not proof evidence. Function updaters reuse the +render-purity analyzer and receive their own `state-transition` callback root. Direct object +updates, pure updater functions, and `null` are modeled. An entry-dominating unguarded object +update in an ordinary `Component` is a concrete refutation. A same-path previous/current props +inequality guard certifies a bounded transition only for the supported top-level reflexive types. + +The semantic graph links each state transition to its mount or update callback, optional updater +callback, source guard locations, updater classification, convergence classification, and exact +completeness flag. The independent checker derives the obligation verdict again from those facts +and rejects forged lifecycle links, callback phases, guard evidence, and completeness. Report +schema 14 and graph schema 20 reject stale certificates. + +Commit callbacks, destructured previous props, nested mutable paths, number-valued inequalities, +opaque or asynchronous updater work, equality-plus-else guards, state-to-instance convergence, +helper-mediated writes, `shouldComponentUpdate`, and ambiguous `PureComponent` convergence remain +`incomplete`. + +Added corpus: + +- proved: `proved-class-prop-transition`, `proved-class-compound-prop-transition`, + `proved-class-number-literal-prop-transition`, and `proved-class-pure-state-updater` +- refuted: `class-update-loop` and `class-impure-state-updater` +- incomplete: `incomplete-pure-component-update`, `incomplete-class-update-callback`, + `incomplete-class-destructured-prop-transition`, + `incomplete-class-nested-prop-transition`, `incomplete-class-number-prop-transition`, and + `incomplete-class-opaque-state-updater` + +## Class state-ownership certificates + +### React semantics + +- The official [`Component` reference](https://react.dev/reference/react/Component) states that + class state must be an object and must not be mutated directly. State changes after + construction go through `setState`. +- The same reference makes the construction boundary exact: a constructor is the only method + where assigning `this.state` directly is valid, and a public `state = { ... }` field is the + modern equivalent. +- The browser oracle demonstrates the observable failure mode: assigning + `this.state.count = nextCount` changes the owned object but does not schedule a render, so the + committed DOM remains stale. + +### Proof boundary + +The earlier state-transition certificate modeled `setState` calls but could incorrectly prove a +lifecycle containing only `this.state.value = nextValue`, because no React transition call existed +to add to the graph. Class lifecycle collection now emits a state-write fact for assignments, +compound assignments, updates, deletes, `Object.assign`, array mutators, and `Map`/`Set` mutators +in mount, update, unmount, deferred resource/scheduler callback, and state-updater phases. + +Mutator calls require both a state-rooted receiver and a TypeScript symbol declared by the +platform collection type. A user-defined persistent method named `push` is not refuted as a +mutation; its unmodeled call keeps the lifecycle incomplete. A state read used only as a primitive +value or computed key remains provable. An object-valued `this.state` path copied into an alias, +argument, return, property, array, or spread is recorded as an unknown reference escape instead of +assuming later writes cannot reach React-owned state. + +Each write fact is linked reciprocally to its exact lifecycle callback and records phase, write +kind, ownership status, source completeness, and certificate completeness. The independent +checker derives a forbidden write as a violation, an escaped reference as unknown, rejects forged +phase/owner/completeness facts, and includes every write in the lifecycle completeness equation. +Report schema 15 and graph schema 21 reject stale certificates. + +Constructor initialization and public object-valued `state` fields remain unmodeled at this +checkpoint, so they still make the class unit incomplete rather than being confused with +post-construction mutation. Alias writes beyond the proved direct receiver are likewise unknown +until the graph carries a complete state-reference flow. + +Added corpus: + +- proved: `proved-class-primitive-state-read` and + `proved-class-state-computed-key-read` +- refuted: `class-direct-state-mutation`, `class-state-mutating-call`, + `class-state-mutation-forms`, `class-unmount-state-mutation`, and + `class-deferred-state-mutation` +- incomplete: `incomplete-class-state-alias`, `incomplete-class-conditional-state-alias`, and + `incomplete-class-custom-push` +- runtime: `class-state-ownership-oracle.spec.ts` + +### Product brief: internal class state-ownership facts + +Job: Prover consumers need a trustworthy answer when class code bypasses React's state scheduler; +previously they received a false proof or had to inspect lifecycle code manually. + +Change: Extend the existing class lifecycle and `class-state-transitions` obligation with the +smallest certificate fact that distinguishes forbidden direct writes from unresolved state +reference escape. + +Reuse: Truffler searches for class state mutation, initialization, assignment, and symbol helpers +found no duplicate prover implementation. The change reuses the existing lifecycle callbacks, +TypeScript symbol resolution, platform declaration identity, transition obligation, and +independent checker rather than adding another public claim. + +Metric: This is a private `0.0.0` proof-kernel package with no CLI telemetry path. Its deterministic +acceptance metric is 100% separation of the direct-mutation fixtures from the primitive-read, +computed-key, and user-defined persistent-method controls. + +Compat: No React Doctor CLI, score, config, Action, or JSON report changes. The private prover +report moves to schema 15 and its semantic graph to schema 21; no Changeset is warranted before +the package has a published contract. + +Kill: If `classStateWrites` produces no verdict distinct from generic lifecycle incompleteness in +the real-world evaluation corpus across two proof-schema releases, fold the facts back into the +transition representation while retaining the direct-mutation refutations. + +## Class construction certificates + +### React semantics + +- The official [`Component` reference](https://react.dev/reference/react/Component) defines class + state as an object, identifies direct constructor assignment and a public `state` field as the + two initialization forms, forbids `setState` in the constructor, and requires `super(props)` + before every other statement. +- The same reference forbids constructor side effects and subscriptions. Root + [`StrictMode`](https://react.dev/reference/react/StrictMode) calls the constructor twice in + development and discards one instance, so construction must be safe when evaluated more than + once. Server rendering also executes construction before render. +- A missing initializer is a concrete failure when `render` or a React lifecycle reads + `this.state`: React's base instance begins with no application state. A read confined to an + unmodeled custom method is not automatically reachable, so that case remains incomplete rather + than becoming a speculative refutation. + +### Proof boundary + +Every class component now owns exactly one `class-construction` graph fact in the +`class-construction` execution phase. The fact records the constructor and initializer locations, +public-field versus constructor-assignment provenance, whether state is required by guaranteed or +conditional execution, typed issue evidence, source completeness, and exact certificate +completeness. Its ID is linked reciprocally from the class lifecycle. + +The first complete subset includes: + +- fresh object-literal state with nested literal, array, object, function, conditional, unary, + binary, template, constructor-parameter, and `this.props` values; +- every statically named, non-static instance field initializer under the same expression-purity + model; +- pure immutable constructor locals used by the state object; +- a first-statement `super()` for a zero-parameter constructor or symbol-identical + `super(properties)` for an explicit properties parameter; +- canonical `this.method = this.method.bind(this)` when `bind` resolves to the platform + declaration; +- classes that do not need application state. + +Known time, randomness, logging, browser storage, network, timer, and scheduling operations are +construction violations. Scalar or null state, constructor `setState`, a missing required +initializer, and a non-leading or mismatched superclass call are also refutations. Opaque calls, +external identifier values, object spreads, dynamic property semantics, nontrivial constructor +control flow, duplicate state sources, and unresolved statements fail closed. This is intentionally +an abstract expression proof rather than trusting the TypeScript state generic: TypeScript permits +`Component`, but React's runtime contract still requires object state. + +The independent checker re-derives construction status from issue statuses, rejects duplicate or +invalid issue kinds, enforces initialization-kind/location and state-demand coherence, checks the +construction phase and class owner, verifies one construction per class and reciprocal lifecycle +ownership, and derives `sourceComplete` and `complete` exactly. Report schema 16 and graph schema +22 reject stale certificates. + +The Chromium oracle mounts constructor-assigned and public-field state under root Strict Mode. +React 19.2.5 evaluates both initialization paths twice and commits the second instance, confirming +that a construction-time observable operation is duplicated even though one instance is discarded. + +Added corpus: + +- proved: `proved-class-state-field`, `proved-class-field-from-props`, + `proved-class-constructor-state`, and `proved-class-constructor-binding` +- refuted: `refuted-class-invalid-state`, `refuted-class-missing-state`, + `refuted-class-missing-updater-state`, + `refuted-class-constructor-side-effect`, `refuted-class-field-side-effect`, + `refuted-class-constructor-subscription`, `refuted-class-constructor-set-state`, and + `refuted-class-constructor-order` +- incomplete: `incomplete-class-opaque-state-initializer`, + `incomplete-class-multiple-state-initializers`, and + `incomplete-class-conditional-state-initializer`, plus + `incomplete-class-custom-subscription-lookalike` as the platform-symbol control and + `incomplete-class-accessor-field` as the unsupported-field-syntax boundary +- runtime: `class-construction-oracle.spec.ts` + +### Product brief: internal class construction facts + +Job: Prover consumers need to know that a class reaches its first render with valid state and that +React may safely repeat construction; previously every real constructor or object-valued state +field was generically incomplete, while some uninitialized state reads were incorrectly proved. + +Change: Add one private construction claim and one versioned construction fact per class, then +link it into the existing lifecycle certificate. + +Reuse: Truffler searches for constructor state initialization, field purity, superclass ordering, +and class object literals found no construction-proof abstraction. The implementation reuses the +class lifecycle owner, source locations, TypeScript symbols, platform-declaration identity, +existing render/state analyses, and independent report checker. The shared `this.state` path +predicate was moved into one utility and reused by post-construction state ownership. + +Metric: The private package has no CLI telemetry path. Its deterministic acceptance metric is +complete separation of the proved, refuted, and incomplete construction fixtures, plus a Chromium +oracle that observes exactly two constructor and field-initializer evaluations in root Strict Mode. + +Compat: No React Doctor CLI, score, config, Action, or JSON report changes. The private +`@react-doctor/prover@0.0.0` report moves to schema 16 and its semantic graph to schema 22. No +Changeset is warranted before publication. + +Kill: If the construction fact cannot distinguish a concrete invalid initialization from an opaque +factory without false `proved` results across two proof-schema releases, remove the dedicated claim +and keep class applications incomplete until a stronger constructor CFG is available. + +## Hook state-transition certificates + +### React semantics + +- The official [`useState` reference](https://react.dev/reference/react/useState) defines a + functional setter argument as an updater queued by React. The updater receives pending state, + must be pure, and returns the next state. +- The same reference states that root Strict Mode may invoke an updater twice in development to + find accidental impurities while ignoring one result. Updater side effects can therefore happen + twice even when React commits only one state transition. +- A non-function setter argument is a direct next-state value. The setter identity is stable, so a + dependency-array reference is not an escaped callback. + +### Proof boundary + +The new `hook-state-transitions` obligation is driven by TypeScript symbols from the second tuple +element of canonical `useState` calls. It does not trust `set` naming conventions, and it does not +reinterpret the second result of `useReducer` as a state updater. Each setter call records its state +and setter names, source location, every represented execution-root callback, optional updater +callback, updater classification, and exact source/completeness flags. + +Direct non-callable values are complete when the call belongs to an existing callback graph. +Resolved synchronous functions reuse the render-purity proof and execute in their own +`state-transition` callback graph. Observable writes, browser storage, logging, time, randomness, +network access, and other known effects refute updater purity. Unknown or callable union values, +asynchronous or generator updaters, and bodies without project source remain unknown. + +Execution ownership is inherited from the semantic graph rather than inferred again: direct +render, intrinsic and forwarded event, Effect setup/cleanup, scheduled, memoized, and reachable +helper functions point back to their already certified root callbacks. A setter reference passed +outside a direct call is a `setter-escape` fact unless it is only a Hook dependency. Escaped setters +and calls with no represented root fail closed. + +The TypeScript standard-library symbols for `Map` and `Set` reads and mutators refine the shared +purity proof. Reading `has`/`get` is pure; mutating a freshly constructed local collection is pure; +mutating prior state or another protected input is a violation. User-defined methods with the same +names receive no platform contract. + +The independent checker re-derives the claim verdict, validates non-class ownership, execution +callback ownership, the updater callback's `state-transition` phase, updater/status coherence, and +the exact source/completeness equations. Report schema 17 and graph schema 23 reject stale or +forged certificates. + +This claim proves transition ownership and updater purity, not application-specific next-state +correctness. Queue ordering across multiple updates, function-valued state wrappers, render-phase +convergence, setter flow through arbitrary libraries, transitions, optimistic state, Actions, +Suspense interruption, and cross-component state-machine invariants remain explicit future proof +work. + +The React Bench checkout supplied realistic shapes for the corpus: event toggles in the gallery and +sidebar harnesses, Effect-owned request counters, and the viewer's immutable `Set` replacement +pattern. The proof fixture keeps that `Set` pattern instead of reducing the milestone to scalar +arithmetic. + +Added corpus: + +- proved: `proved-hook-functional-updater`, `proved-hook-direct-state-value`, + `proved-effect-functional-updater`, and `proved-state-setter-lookalikes` +- refuted: `refuted-impure-hook-state-updater` +- incomplete: `incomplete-opaque-hook-state-updater`, + `incomplete-hook-state-setter-escape`, and `incomplete-hook-setter-in-reducer` +- runtime: `hook-state-transition-oracle.spec.ts` + +### Product brief: internal Hook state-transition facts + +Job: Prover consumers need to know whether React may safely replay a functional state updater and +whether every represented setter invocation remains inside the modeled callback graph. + +Change: Add one private Hook state-transition claim and a versioned fact for each direct setter call +or setter escape. + +Reuse: Truffler searches for Hook state transitions, `useState` setter calls, functional updater +purity, setter symbols, and Hook bindings found no existing transition certificate. The +implementation reuses `collectHookBindings`, callback reachability, TypeScript symbol resolution, +render purity, execution phases, and the independent checker. Class and Hook updaters now share one +updater-function purity entry point. + +Metric: The private package has no CLI telemetry path. Its deterministic acceptance metric is +complete separation of pure event/Effect updaters, direct values, an impure updater, an opaque +updater, a setter escape, and the `useReducer`/name-lookalike controls, plus a Chromium oracle that +observes two updater evaluations and one committed increment. + +Compat: No React Doctor CLI, score, config, Action, or JSON report changes. The private +`@react-doctor/prover@0.0.0` report moves to schema 17 and its semantic graph to schema 23. No +Changeset is warranted before publication. + +Kill: If execution-root matching or updater classification cannot separate the React Bench +controls without false `proved` results across two proof-schema releases, remove the dedicated +claim and keep `useState` applications incomplete until callback SSA provides the missing proof. + +## Transition Action certificates + +### React semantics + +- The official [`startTransition` reference](https://react.dev/reference/react/startTransition) + says React calls the Action immediately and marks state updates scheduled synchronously during + that call as non-blocking Transitions. +- The same reference says timer-owned updates are outside the Transition, post-`await` setters + currently require another `startTransition`, Transition renders are interruptible, and + Transition updates cannot control text inputs. +- The official [`useTransition` reference](https://react.dev/reference/react/useTransition) + defines the second tuple value as the Action starter and keeps `isPending` true until its Actions + complete. It also records request completion ordering as an unsolved concern for custom async + Actions. + +### Proof boundary + +The new `transition-actions` obligation recognizes global `startTransition` by React import symbol, +including aliases and namespace access, and recognizes Hook starters only from the second binding +of a canonical direct `useTransition` tuple. It does not trust a local function named +`startTransition`. Dependency-array references to the stable Hook starter are not escapes; any +other starter reference outside its direct call is an explicit `starter-escape` fact. + +Each direct call records its owner, starter kind, source location, represented invoking callback +roots, optional Action callback, direct controlled-state evidence, and exact source/completeness +flags. A resolved Action gets a dedicated `transition-action` callback and reachable helper graph. +This lets a nested post-`await` `startTransition` use the outer Action as its execution root and lets +existing Hook state-transition facts identify their actual Transition Action owner. + +The first complete subset requires a source-resolved Action whose full reachable source graph is +synchronous: no async function, `await`, thenable call, Promise continuation, or platform +scheduler. Its invocation must be owned by an event, Effect setup/cleanup, Effect Event, deferred +callback, external-store subscription, class mount/update, or another Transition Action. Render, +server-render, constructor, reducer/updater, unmount, and unresolved roots do not certify an Action. + +For direct local `useState` updates, immutable `const` aliases retain their originating state +symbols through expressions and object construction. An intrinsic `input`, `textarea`, or `select` +`value`/`checked` dependency on updated state is a concrete violation. State forwarded through a +component prop, custom-Hook return, or form-control spread is unknown because the graph does not +yet have scalar prop/state SSA across those boundaries. This avoids claiming that a renamed or +wrapped controlled value is safe. + +Async or scheduled Actions, opaque callback values, escaped starters, indirect `useTransition` +tuple access, invalid origin phases, and transitive control flow fail closed. A nested synchronous +Action after `await` can be individually complete while the enclosing async Action and application +remain incomplete. The current fact proves Action ownership and the direct local urgency subset, +not request ordering, async context, `useDeferredValue`, Server Actions, Suspense +fallback preservation, or whole-application transition state machines. Form Actions and +`useOptimistic` have separate certificates below, and `useActionState` has the Action State +certificate after them. + +The independent checker re-derives the obligation verdict, validates starter and Action statuses, +owner and callback phases, unique execution roots, callback/status coherence, controlled and +unknown state-control evidence, and the exact source/completeness equations. Report schema 18 and +graph schema 24 reject stale or forged certificates. + +React Bench supplied the realistic shapes: titlebar and tabs navigation wrap several synchronous +store/router operations; tab removal clones or filters collection state; dialog helpers wrap +context actions; and nested navigation can start another Transition. The proved corpus keeps the +immutable tab filter and reachable event helper rather than reducing the certificate to a direct +scalar setter. + +Added corpus: + +- proved: `proved-transition-tabs`, `proved-use-transition-action`, and + `proved-transition-lookalike` +- refuted: `refuted-transition-controlled-input` and + `refuted-transition-derived-controlled-input` +- incomplete: `incomplete-async-transition-action`, `incomplete-opaque-transition-action`, + `incomplete-transition-starter-escape`, `incomplete-transition-control-prop`, and + `incomplete-use-transition-tuple` +- runtime: `transition-action-oracle.spec.ts` + +The Chromium oracle runs under root Strict Mode. It observes one async Action invocation, an +intermediate pending render, and a final nested post-`await` Transition commit. This validates the +runtime distinction while leaving the async static certificate incomplete. + +### Product brief: internal Transition Action facts + +Job: Prover consumers need to distinguish a synchronous, owned non-blocking update from an opaque, +escaped, delayed, or input-controlling Transition; previously `useTransition` was blanket +unsupported and global `startTransition` had no execution-phase certificate. + +Change: Add one private Transition Action claim and one versioned fact per direct Action call or +starter escape. + +Reuse: Truffler searches for Transition Actions, `startTransition`, `useTransition` bindings, +controlled input state, async Action boundaries, and execution callback roots found no dedicated +certificate. The implementation reuses React API symbol resolution, Hook tuple bindings, callback +reachability, synchronous deferred-callback analysis, source locations, state setter symbols, and +the independent checker. The Hook dependency-reference predicate was extracted and shared with +state setters. + +Metric: The private package has no CLI telemetry path. Its deterministic acceptance metric is +complete separation of both React starters, a user lookalike, direct and derived controlled state, +async and opaque Actions, starter escape, transitive control uncertainty, and tuple indirection, +plus a Chromium oracle for pending and nested post-`await` behavior. + +Compat: No React Doctor CLI, score, config, Action, or JSON report changes. The private +`@react-doctor/prover@0.0.0` report moves to schema 18 and its semantic graph to schema 24. No +Changeset is warranted before publication. + +Kill: If Action origin or state-control evidence produces a false `proved` result across two proof +schema releases, remove the complete Transition status and keep Actions incomplete until scalar +prop SSA and the lifecycle machine can carry the missing evidence. + +## Form Action and optimistic state certificates + +### React semantics + +- The official [`
` reference](https://react.dev/reference/react-dom/components/form) defines + a function-valued `action` as a React Action. React supplies `FormData`, resets uncontrolled + fields after success, and manages async submission through a Transition. +- The same reference permits a submit-capable `button` or `input` to override the form Action with + `formAction`. That behavior depends on submitter type and association with a form, not merely the + presence of a callable JSX prop. +- The official [`useOptimistic` reference](https://react.dev/reference/react/useOptimistic) + requires its reducer to be pure and its setter to run inside an Action or Transition. A setter + call during render is an error; a call outside an Action can briefly show and then revert the + optimistic value. +- Without a reducer, a callable setter argument is a state updater and must be replay-safe. With a + reducer, the same callable value is an Action payload and must not be confused with an updater. +- An ordinary async `startTransition` callback is not enough to prove post-`await` ownership. + React's Transition context limitation still requires a nested Transition. A Form Action has its + own managed async Action lifetime. + +### Form Action boundary + +The `form-actions` obligation recognizes callable `action` and `formAction` only on intrinsic JSX. +It respects effective JSX precedence, resolves direct expressions and immutable finite spreads, +and follows reachable helper rendering and project callback flow. Each resolution creates one or +more dedicated `form-action` callbacks and records the intrinsic property, control kind, callback +set, callback-resolution flag, and exact source/completeness flags. + +The complete subset includes function Actions on intrinsic forms and `formAction` on statically +nested submit-capable buttons and inputs. A statically wrong tag or submitter type is a concrete +violation. Dynamic button types, explicit `form="id"` association, submitters composed through +another component, open spreads, and unresolved callback props remain opaque. This distinction is +important: component composition may establish a valid runtime form owner, so absence of a local +JSX form ancestor cannot be called a violation. + +A Form Action fact is source-complete only when callback resolution is complete, at least one +phase-correct callback is represented, and the control status is not opaque. It is complete only +when that source is complete and the control is resolved. The checker re-derives these equations, +validates property/control coherence, rejects duplicate or invalid callbacks, and re-derives the +per-unit obligation verdict. + +### Optimistic state boundary + +The `optimistic-state` obligation recognizes only a canonical React `useOptimistic` call assigned +to a direct tuple pattern. Either tuple binding may be unused, so reducer purity is still checked +when code reads only the optimistic value. The optimistic value joins the existing render-state +symbol set, while its setter is deliberately excluded from ordinary `useState` transition facts. + +Every reducer gets a dedicated `optimistic-reducer` callback and the shared updater-purity +analysis. Every setter call records the linked optimistic state, execution callback roots, optional +`optimistic-updater` callback, updater classification, and Action classification. With a reducer, +the setter argument is an Action payload even if its type is callable. Without a reducer, a +callable argument is analyzed as an updater; object values that merely contain callable +properties remain direct values. + +Action ownership is conjunctive. Every represented execution root must be either a Form Action or +a Transition Action with its own complete synchronous certificate. A render root is a concrete +render violation. Any ordinary event, Effect, scheduler, or other non-Action root is a concrete +outside-Action violation. A root in an incomplete async Transition remains unknown rather than +being incorrectly promoted or refuted. Reusing one function as both a Form Action and an ordinary +event handler is therefore refuted because React can invoke the optimistic setter outside the +Action path. + +Optimistic state is complete only when the reducer is absent or proved pure. An update is complete +only when its linked state exists, its Action origin is known and exclusive, its setter has not +escaped, and its direct value or updater is proved replay-safe. The independent checker recomputes +reducer/updater callback requirements, linked-state ownership, Action status from callback phases +and complete Transition certificates, source flags, completeness, and the obligation verdict. + +Added corpus: + +- proved: `proved-optimistic-form`, `proved-form-action-submitter`, + `proved-helper-spread-form-action`, and `proved-optimistic-transition-updater` +- refuted: `refuted-optimistic-outside-action`, `refuted-optimistic-render-update`, + `refuted-impure-optimistic-reducer`, `refuted-impure-optimistic-updater`, + `refuted-mixed-optimistic-action-roots`, and `refuted-unsupported-form-action-control` +- incomplete: `incomplete-dynamic-form-action-control`, + `incomplete-composed-form-action-submitter`, `incomplete-form-action-prop`, + `incomplete-optimistic-setter-escape`, and `incomplete-optimistic-async-transition` +- runtime: `optimistic-form-action-oracle.spec.ts` + +The Chromium oracle runs under root Strict Mode. It submits an async Form Action, observes the +optimistic todo while the Action is pending, records one Action invocation, and then observes the +confirmed todo replacing the pending value. The oracle calibrates the fixture against the pinned +React runtime; it does not upgrade the static proof. + +### Product brief: internal Form Action and optimistic facts + +Job: Prover consumers need to know whether optimistic state is pure, replay-safe, and owned by a +real React Action, rather than merely seeing a `useOptimistic` name or callable form prop. + +Change: Add two private claims, three execution phases, versioned Form Action/state/update facts, +and independent checker equations. + +Reuse: Truffler searches found no dedicated certificate. The implementation reuses canonical React +symbol resolution, Hook tuple bindings, JSX precedence and immutable spread analysis, component +callback flow, execution-root matching, updater purity, render-state tracking, and the report +checker. Hook and optimistic functional updates now share one state-update classifier, while +shallow callable-state detection preserves function-containing object values as direct values. + +Metric: The private package has no CLI telemetry path. Its deterministic acceptance metric is +complete separation of direct and spread Form Actions, valid and unresolved submitter association, +pure and impure reducers/updaters, reducer Action payloads, render/event/Form/Transition/mixed +origins, setter escape, and async Transition uncertainty, plus a Chromium pending/reconciliation +oracle. + +Compat: No React Doctor CLI, score, config, Action, or published JSON report changes. The private +`@react-doctor/prover@0.0.0` report moves to schema 19 and its semantic graph to schema 25. No +Changeset is warranted before publication. + +Kill: If form association or Action-root composition produces a false `proved` result across two +proof-schema releases, remove the affected complete status and keep that surface incomplete until +the lifecycle graph can represent the missing topology. + +## Action State certificates + +### React semantics + +- The official [`useActionState` reference](https://react.dev/reference/react/useActionState) + defines a three-value tuple containing current state, a stable dispatcher, and pending state. +- The reducer Action receives previous state before its payload, may be async, may perform side + effects, and is not double-invoked by Strict Mode. Multiple dispatches are queued in order. +- React requires the dispatcher to run inside an Action. A direct function-valued `action` or + `formAction` prop supplies that context; manual dispatch requires `startTransition`. Render + dispatch is forbidden, and ordinary callback dispatch loses pending Action semantics. + +### Proof boundary + +The `action-state` obligation recognizes only a canonical React `useActionState` call assigned to +a direct tuple pattern. It identifies the state and dispatcher by TypeScript symbol and resolves a +project reducer Action from the first Hook argument. The reducer gets a dedicated +`action-state-reducer` callback and execution phase. Unlike `useReducer` and `useOptimistic` +reducers, it is intentionally not checked for purity because React defines side effects as valid +Action State behavior. + +Every direct dispatcher call, direct intrinsic `action` or `formAction` reference, and other +dispatcher reference becomes a versioned dispatch fact. Direct Action props reuse the existing +intrinsic form-control proof and link its Form Action callback set to the Action State reducer. +Manual calls collect every represented execution root. A dispatch is complete only when its +linked reducer is source-resolved and its origin is exclusively a Form Action, an Action State +reducer, or a complete synchronous Transition Action. + +Render dispatch and an ordinary event, Effect, scheduler, or other non-Action root are concrete +violations. An escaped dispatcher, unresolved reducer prop, custom Action component, missing +execution root, or dispatch inside an incomplete async Transition remains unknown. This first +certificate proves reducer identity and dispatch Action ownership. It does not yet prove reducer +return-type semantics beyond TypeScript, progressive-enhancement permalink identity, Server +Function serialization, error-boundary behavior, cancellation, or queue-level application +invariants. + +The independent checker recomputes the obligation verdict, validates linked state and reducer +callbacks, derives direct Action-prop ownership from the matching Form Action fact, derives manual +dispatch status from callback phases and complete Transition certificates, and checks exact +source/completeness equations. Form and optimistic certificates also accept the dedicated Action +State reducer phase as a real Action root. Report schema 20 and graph schema 26 reject stale or +forged certificates. + +React Bench did not contain broad native React 19 Action State usage, so the realistic corpus is +grounded in the official checkout, ordered-cart, form, optimistic-update, and manual-Transition +shapes rather than fabricating prevalence. The benchmark’s existing form and interaction tasks +still informed the multi-button form and collection-state payloads. + +Added corpus: + +- proved: `proved-action-state-form` and `proved-action-state-transition` +- refuted: `refuted-action-state-outside-action` and + `refuted-action-state-render-dispatch` +- incomplete: `incomplete-action-state-dispatcher-escape`, + `incomplete-action-state-reducer-prop`, and `incomplete-action-state-async-transition` +- runtime: `action-state-oracle.spec.ts` + +The Chromium oracle runs under root Strict Mode, submits two values while the first async reducer +Action is pending, observes the pending state, confirms exactly two reducer invocations, and +observes the ordered `first|second` result. It calibrates React's queue and Strict Mode behavior +without upgrading any static proof. + +### Product brief: internal Action State facts + +Job: Prover consumers need to distinguish a dispatcher that participates in React's ordered +Action State queue from the same stable function invoked during render or an ordinary event. + +Change: Add one private claim, one reducer execution phase, versioned state and dispatch facts, +direct Form Action integration, and independent checker equations. + +Reuse: Truffler searches for Action State dispatch, Hook dispatcher bindings, Action execution +roots, and reducer Actions found no existing certificate. The implementation reuses canonical +React symbol resolution, Hook tuple collection, Form and Transition Action facts, callback +reachability, execution-root matching, source locations, and the independent checker. + +Metric: The deterministic acceptance metric separates direct Form Action, nested Form Action, +synchronous Transition, ordinary event, render, escaped, opaque-reducer, and async-Transition +cases, plus a Chromium oracle for pending state, ordered queuing, and Strict Mode invocation count. + +Compat: No React Doctor CLI, score, config, Action, or published JSON report changes. The private +`@react-doctor/prover@0.0.0` report moves to schema 20 and its semantic graph to schema 26. No +Changeset is warranted before publication. + +Kill: If dispatcher origin or direct Action-prop association produces a false `proved` result +across two proof-schema releases, remove the complete dispatch status and keep Action State +incomplete until callback SSA or the lifecycle machine carries the missing evidence. + +## Form Status topology certificates + +### React semantics + +- The official [`useFormStatus` reference](https://react.dev/reference/react-dom/hooks/useFormStatus) + requires the Hook to run in a component rendered inside a parent ``. +- A form returned by the same component is not a parent form. In that documented pitfall, + `pending` never becomes true. A component that can render both below and outside a form therefore + has a concrete invalid execution path rather than an merely opaque one. +- The pending status carries the parent form's `FormData`, declared method, and callable Action. + With no active submission or no parent form, the idle status has null data and Action. +- React DOM implements the Hook through the host-transition status dispatcher. Its source identity + is therefore `react-dom` `useFormStatus`, not an arbitrary custom Hook with the same spelling. + +### Real-project evidence + +React Bench did not contain native React DOM `useFormStatus` usage. A broader source search found +the canonical production shape in Next.js: + +- [`examples/next-forms/app/add-form.tsx`](https://github.com/vercel/next.js/blob/cf1e001f40b311f5a4f19775ec9ea4f1d8bdece9/examples/next-forms/app/add-form.tsx) + and + [`delete-form.tsx`](https://github.com/vercel/next.js/blob/cf1e001f40b311f5a4f19775ec9ea4f1d8bdece9/examples/next-forms/app/delete-form.tsx) + put a dedicated pending button below an Action State form. +- [`examples/with-turso/app/form.tsx`](https://github.com/vercel/next.js/blob/cf1e001f40b311f5a4f19775ec9ea4f1d8bdece9/examples/with-turso/app/form.tsx) + uses the same separate-submit-component topology around a database mutation. +- Next's forms guide explicitly says the loading indicator must be a separate component. Next also + rejects `useFormStatus` in a Server Component module, evidence that future framework proofs need + a client/server module-boundary fact in addition to the parent-form theorem. + +The fixture corpus uses those separate submit-control and Action form shapes, plus component and +custom-Hook propagation, a shared button under two forms, the official same-component pitfall, a +detached consumer, mixed valid/invalid render sites, and a composed form shell whose `children` +placement is not yet modeled. + +### Proof boundary + +The `form-status` obligation recognizes only a symbol-resolved `useFormStatus` imported from the +React DOM runtime. Every intrinsic form receives a semantic identity. Every project component +render records the lexically active intrinsic form stack and whether an intervening component can +change the nearest-form topology. + +Form sources propagate to rendered components and called custom Hooks until a fixed point: + +- a direct intrinsic form ancestor supplies its nearest form identity; +- a render with no local form inherits the caller's parent form; +- a component-composed child with no guaranteed outer form supplies an unknown source rather than + an outside-form counterexample; +- every exported closed component root starts outside a form, while an unreferenced local + component remains unknown because an unmodeled render callback may own its placement; +- multiple render sites join every possible source form; +- one known outside-form path is enough to refute the obligation. + +This proves parent-form presence and identity for the supported closed render subset. It does not +yet model arbitrary `children`/slot ReactNode flow, portals that create another root, framework +Server/Client Component boundaries, JSX returned from synchronous render callbacks or helpers, +renderer-specific host-transition providers, or form association outside the React parent tree. +Those cases remain incomplete rather than borrowing DOM ancestry or source nesting as proof. + +The independent checker separately recomputes the fixed point from semantic render and custom-Hook +edges. It requires exactly one topology fact for every canonical Hook call, validates every active +and source form identity and owner, and re-derives the outside-form flag, source completeness, +topology status, claim verdict, and final completeness. Report schema 21 and graph schema 27 reject +stale certificates. + +Added corpus: + +- proved: `proved-form-status-direct`, `proved-form-status-transitive`, and + `proved-form-status-multiple-forms` +- refuted: `refuted-form-status-outside-form`, `refuted-form-status-same-component`, + `refuted-form-status-mixed-placement`, and `refuted-form-status-exported-child` +- incomplete: `incomplete-form-status-composed-form` and + `incomplete-form-status-render-callback` +- runtime: `form-status-oracle.spec.ts` + +The Chromium oracle runs under root Strict Mode, starts an async function-valued Form Action, and +observes that the descendant status exposes pending state, submitted data, the Action identity, +and the default method while the same-component status remains idle. React 19.2.5 reports the +default declared method as `get` in the pending status even though function-valued form Actions use +POST submission semantics. That calibrated distinction is retained in the oracle instead of +conflating the status field with transport behavior. + +### Product brief: internal Form Status facts + +Job: Prover consumers need to know that a pending indicator is attached to the form it claims to +observe, including every render path, rather than merely seeing a `useFormStatus` call. + +Change: Add one private claim, intrinsic form identities, render-path form ancestry, versioned Form +Status facts, and independent checker equations. + +Reuse: Truffler searches for form ancestry, render topology, and nearest-provider propagation found +no existing Form Status certificate. The implementation extends the existing component-render and +custom-Hook graph and deliberately mirrors the proven context-topology fixed-point shape without +sharing producer equations with the checker. + +Metric: The deterministic acceptance metric separates direct, transitive, multi-form, detached, +same-component, exported-child, mixed-placement, and composed-child cases, plus a Chromium oracle +for pending, data, method, Action identity, and Strict Mode invocation count. + +Compat: No React Doctor CLI, score, config, Action, or published JSON report changes. The private +`@react-doctor/prover@0.0.0` report moves to schema 21 and its semantic graph to schema 27. No +Changeset is warranted before publication. + +Kill: If lexical render topology produces a false `proved` result in a component-composed or +renderer-specific form tree, remove the complete source status and keep Form Status incomplete +until ReactNode slot flow or a renderer contract carries the missing ancestry. + +## ReactNode slot-flow certificates + +### Semantic correction + +JSX syntax constructs React element values. It does not by itself establish that the represented +component is rendered. In ``, the caller creates the `Child` element and +passes it as `Shell`'s `children`; `Shell` controls whether, where, and how often that value enters +the React tree. Treating the lexical nesting as an immediate caller-to-child render edge is +unsound for context, Form Status, execution multiplicity, and any future lifecycle theorem. + +The official React material makes the distinction explicit: + +- [Passing Props to a Component](https://react.dev/learn/passing-props-to-a-component) defines + nested JSX as the `children` prop and describes wrapper components as leaving a hole for their + caller. +- [`Children`](https://react.dev/reference/react/Children) defines `children` as opaque and warns + that traversal does not render or descend through a component's returned JSX. +- [`createPortal`](https://react.dev/reference/react-dom/createPortal) changes physical DOM + placement while retaining React-tree context and event propagation. +- [`cloneElement`](https://react.dev/reference/react/cloneElement) and arbitrary child + transformations are documented as fragile, so they need an explicit value-flow model rather + than a transparent-wrapper assumption. + +This certificate therefore separates three render facts: + +- `direct`: the JSX value reaches the component's returned ReactNode through a supported transparent + expression path; +- `slot-input`: the JSX value is supplied to a component prop or crosses an unresolved source-value + boundary; +- `slot`: one effective project-local placement of that input, linked back to its source and + container render. + +### Closed subset and fail-closed boundary + +The complete subset follows destructured, object-parameter, or string-literal computed props +through direct `children` and named JSX attributes, including transitive local wrappers, portals, +and multiple placements. Each forwarding hop retains its component owner and lexically active +context-provider/form frames. Effective topology is ordered from outer placement frames to the +source-local frame, so the nearest source provider or form remains nearest after insertion. + +Source and placement completeness are separate: + +- `sourceComplete` proves that the JSX element reaches the slot without an alias, object container, + unsupported call, spread, property access, or other value transformation; +- `placementComplete` proves that every use of the receiving prop reaches a terminal JSX or portal + placement through project-local channels; +- `complete` is exactly their conjunction. + +External components, receiver aliases, `Children.map`, unresolved calls, JSX spread slots, source +aliases, cycles, property mutation, callback boundaries, and unknown named values stay incomplete. +A channel may have both known effective placements and an unknown use; the known renders remain in +the graph, while the unknown source is propagated into context and Form Status fixed points. This +preserves useful evidence without turning partial reachability into a proof. + +React Bench supplied realistic shapes rather than a synthetic UI calculus: + +- `viewer/src/components/trial-nav.tsx` uses both direct `children` and a named `content: ReactNode` + slot. +- `viewer/src/components/ui/tooltip.tsx` combines a named slot with `asChild` and a portal, showing + why wrapper identity and physical DOM placement cannot be conflated. +- the migrated OpenCode applications contain many provider shells that forward `props.children`; + floating-ui list boxes similarly place children under a provider. +- the composition guidance in + `brain/vercel-composition-patterns/rules/patterns-children-over-render-props.md` favors form and + layout composition through `children`, making this a core React boundary rather than an exotic + pattern. + +### Certificate checker and corpus + +The independent checker recomputes context and form fixed points using effective renders only. +Every slot input must have exactly one slot-flow certificate. It validates unique IDs, reciprocal +source/effective-render links, source/container/prop agreement, exact render sets, source and +placement completeness equations, topology owners, provider/form ownership, and the resulting +`react-node-flow`, context, Form Status, report-summary, and application verdicts. Report schema 22 +and graph schema 28 reject stale certificates. + +Added or promoted corpus: + +- proved: composed, transitive, named, and source-form Form Status slots plus direct and transitive + context-provider slots; +- refuted: a child placed both under and outside a form; +- incomplete: external wrappers, receiver aliases, `Children.map`, source aliases, JSX spreads, + dynamic computed slots, whole-props forwarding, non-rendered JSX props, and a dropped child used + only as a condition; +- forged: a slot-flow completeness mutation rejected by the checker; +- runtime: a Strict Mode Form Action whose status consumer reaches its parent form only through a + component-owned `children` slot. + +The complete package gates now cover 324 TypeScript fixture projects, 527 static tests, and 40 +Chromium runtime oracles. The new browser oracle observes pending state, submitted `FormData`, and +Action identity through the slot and confirms one Action invocation. Runtime evidence calibrates +React 19.2.5 behavior but does not upgrade an incomplete static channel. + +### Product brief: internal ReactNode flow facts + +Job: Prover consumers need to know where component-valued props actually enter the React tree before +trusting context, Form Status, execution, or lifecycle claims. + +Change: Add one private `react-node-flow` claim, versioned direct/input/effective render facts, +source and placement completeness, transitive topology frames, and independent checker equations. + +Reuse: Truffler searches for ReactNode value flow, JSX child placement, rendered component targets, +and provider/form wrapper topology found no reusable prover symbol. The implementation reuses +TypeScript symbol identity, component-prop extraction, JSX spread utilities, semantic render IDs, +and the existing context/Form Status fixed-point machinery. The shared JSX component-target +resolver was extracted from callback-prop flow instead of duplicated. + +Metric: The deterministic acceptance metric separates direct, named, transitive, duplicated, +provider-bearing, source-form, mixed-placement, external, aliased, transformed, and spread cases, +with every emitted certificate accepted by the checker and the forged certificate rejected. + +Compat: No React Doctor CLI, score, config, Action, or published JSON report changes. The private +`@react-doctor/prover@0.0.0` report moves to schema 22 and its semantic graph to schema 28. No +Changeset is warranted before publication. + +Kill: If a complete slot channel produces a false `proved` topology in two proof-schema releases, +remove complete slot propagation and keep ReactNode inputs unknown until value-level SSA or a +library proof contract carries the missing semantics. + +## Imperative-handle protocol certificates + +### React contract and realistic evidence + +[`useImperativeHandle`](https://react.dev/reference/react/useImperativeHandle) is a commit-phase +escape hatch with three coupled requirements: the exposed ref, a zero-argument handle factory, and +the reactive dependency list for that factory. React compares dependencies with `Object.is`; +omitting the list recreates the handle after every render, while an incomplete list can preserve +methods that close over stale props or state. React 19 also makes `ref` available as a component +prop, while older component APIs use the second parameter of +[`forwardRef`](https://react.dev/reference/react/forwardRef). + +The React Compiler fixture +[`useImperativeHandle-ref-mutate.expect.md`](https://github.com/facebook/react/blob/main/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useImperativeHandle-ref-mutate.expect.md) +preserves the ref, factory, and dependency tuple rather than erasing the protocol. The prover uses +the same source-level boundary and does not infer correctness merely because compiler +transformation succeeded. + +React Bench supplied two materially different application shapes: + +- Ant Design Mobile's `src/components/swipe-action/swipe-action.tsx` exposes inline `show` and + `close` methods from an object-literal factory with an omitted dependency list. +- Mantine's `packages/@mantine/core/src/components/Splitter/Splitter.tsx` exposes an opaque + `splitter` object returned by a custom Hook and lists `[splitter]`. + +The first shape motivates method-level capture analysis. The second is intentionally incomplete: +the dependency list may be correct, but certifying an arbitrary object requires a summary for the +custom Hook's returned protocol rather than trusting its type. + +### Closed subset and fail-closed boundary + +The certificate recognizes canonical React imports and namespace calls in a function component. +Its ref target must be either a React 19 `ref` prop or the exact second parameter of an inline +`forwardRef` callback. The factory must resolve to one function with one object-literal return. +Every callable property must be a source-resolved static method, property callback, or shorthand +callback; spreads, computed names, duplicates, accessors, opaque callable values, multiple returns, +and fallthrough keep the shape incomplete. + +Reactive factory captures reuse the existing dependency and purity analyses. A missing dependency +is a source counterexample because the exposed handle can stay stale. An observable factory side +effect is a source counterexample because React owns factory execution and may repeat it. Handle +methods become their own `imperative-handle-method` callback roots, so their effects and call +phases are not conflated with factory execution. + +For whole-project ownership, the caller must pass one non-escaping local `const` ref created by +canonical `useRef` through a direct project render. Every use of that ref is classified, and every +static `ref.current.method()` call is linked to the exact exposed method and the caller callback +phase. Callback refs, reused refs, ref aliases, mutations, prop forwarding, computed method calls, +external consumers, exported owners, unresolved invocation roots, and unknown ref uses remain +incomplete. A known local call does not close an otherwise open protocol. + +### Certificate checker, corpus, and runtime calibration + +The independent checker validates one handle fact per canonical Hook call; factory capture, +dependency, purity, and status equations; unique static method identities; exact local ref +bindings; render/ref agreement; escape and exclusivity evidence; caller-owned invocation phases; +reciprocal handle, binding, method, callback, and invocation links; and the final completeness +conjunction. Report schema 23 and graph schema 29 reject stale certificates. + +Added corpus: + +- proved: React 19 direct-ref and inline-`forwardRef` handles with closed local callers; +- refuted: a method with a missing reactive dependency and an observably impure factory; +- incomplete: exported owners, opaque returned handle objects, callback refs, computed method + names, escaped caller refs, reused child ref targets, and one ref shared by multiple child + handles; +- forged: a mutated ref-binding completeness field rejected by the checker; +- runtime: `imperative-handle-oracle.spec.ts`. + +The complete package gates now cover 335 TypeScript fixture projects, 543 static tests, and 42 +Chromium runtime oracles. The new browser pair updates a child label from `alpha` to `beta`, then +observes `beta` through a handle declared with `[label]` and stale `alpha` through the otherwise +identical handle declared with `[]`. Runtime evidence calibrates the stale-closure theorem but +does not upgrade an incomplete static protocol. + +### Product brief: internal imperative-handle facts + +Job: Prover consumers need to know that an imperative API exposes current values, does not perform +observable work while React creates it, and is invoked only through a completely owned ref +protocol. + +Change: Add one private `imperative-handle` claim, versioned handle/method/binding/invocation facts, +factory dependency and purity evidence, execution-phase callbacks, and independent checker +equations. + +Reuse: Truffler searches for imperative handles, ref-handle lifecycles, dependency captures, and +forwarded ref props found no existing protocol certificate. The implementation reuses canonical +React API resolution, Hook collection, function-return summaries, component-prop identity, +reactive capture analysis, project render edges, callback-root discovery, and ref-use +classification. + +Metric: The deterministic acceptance metric separates direct-ref, `forwardRef`, stale, +side-effecting, exported, opaque, callback-ref, computed-method, escaped-ref, reused-target, +shared-ref, and forged-certificate cases, plus a Chromium stale-vs-current oracle. + +Compat: No React Doctor CLI, score, config, Action, or published JSON report changes. The private +`@react-doctor/prover@0.0.0` report moves to schema 23 and its semantic graph to schema 29. No +Changeset is warranted before publication. + +Kill: If a complete handle protocol produces a false `proved` result in two proof-schema releases, +remove complete invocation coverage and keep handles unknown until interprocedural ref SSA or an +explicit component proof contract carries the missing ownership. + +## Reducer-transition protocol certificates + +### React contract and realistic evidence + +React's [`useReducer` reference](https://react.dev/reference/react/useReducer) defines a narrow +runtime protocol that is suitable for static proof: the reducer must be pure, receives the current +state and action, and returns the next state; the optional initializer receives `initialArg` and +returns the initial state; `dispatch` has stable identity; and development Strict Mode calls the +reducer and initializer twice to expose accidental impurity. React also documents that a reducer +which falls through produces `undefined`, that a render-phase dispatch causes a rerender loop, and +that identical state is skipped using `Object.is`. + +React Compiler fixtures independently preserve the same boundaries. Its invalid-access fixtures +reject ref reads inside reducer and initializer callbacks, its state-mutation fixture rejects +mutation of the value returned by `useReducer`, and its reactive-scope fixture treats the returned +dispatcher as non-reactive. Compiler acceptance is not used as the theorem: it supplies normalized +CFG evidence and a compatibility oracle, while the prover emits and checks its own reducer facts. + +React Bench supplied representative application shapes: + +- OpenCode's file tree uses `useReducer((tick: number) => tick + 1, 0)` as a closed force-render + reducer. The omitted state binding remains valid and the direct dispatcher can still be tracked. +- OpenCode's server synchronization stores the same force-render dispatcher inside a ref-owned + object. That dispatcher escapes the local callback graph and therefore remains incomplete. +- React Phone Number Input wraps a large reducer with Immer's `produce` and computes initial state + through `getInitialState`. Without an explicit Immer proof summary, the returned reducer identity + is opaque and cannot be promoted from its TypeScript signature alone. + +### Closed subset and fail-closed boundary + +The certificate recognizes canonical imported, aliased, or namespace `useReducer` calls. It records +the exact tuple state and dispatcher symbols when destructured, but still certifies reducer and +initializer functions when either tuple element is intentionally omitted. Resolved callbacks get +dedicated `state-transition` roots and transitive project-helper reachability. + +Purity reuses the render-purity analysis because reducers and initializers share React's +repeatability requirement. Totality reuses the structured return summarizer and TypeScript checker: +expression bodies, terminal branches, caught throws, `finally` overrides, proved loop exits, +default-covered switches, and literal-union-exhaustive switches can close; reachable fallthrough +and uncaught throw paths are counterexamples; unsupported control flow stays opaque. + +Every dispatcher reference is symbol-classified. Direct calls must resolve to at least one modeled +React callback root. Event, Effect, scheduler, Action, subscription, and other non-render roots are +owned. Render and reducer-transition roots are counterexamples. Dependency-array references are +permitted because React guarantees stable dispatcher identity. Object storage, prop passing, +returns, aliases, and every other escape stay incomplete. + +This theorem proves the generic React reducer protocol, not an application's domain transition +specification. A reducer that intentionally increments when the product should decrement can still +satisfy React. Business invariants require a future user-supplied refinement contract over +`State × Action → State`; inventing those invariants from names or examples would not be a proof. + +### Certificate checker, corpus, and runtime calibration + +The independent checker validates unique reducer and dispatch identities; owner and tuple names; +reducer and initializer callback kinds and phases; absent, opaque, and resolved initializer +equations; purity and total-return status domains; reciprocal reducer/dispatch links; exact +execution callback ownership; derived render/reducer/owned/escape status; and final +source/completeness conjunctions. It separately derives both `reducer-purity` and +`reducer-transitions` obligation verdicts. Report schema 24 and graph schema 30 reject stale +certificates. + +Added corpus: + +- proved: a typed exhaustive reducer with a pure lazy initializer and event-owned dispatch; +- refuted: reducer fallthrough, uncaught throw, and render-phase dispatch; +- incomplete: an escaped dispatcher and an opaque higher-order reducer wrapper; +- forged: totality and dispatch-ownership mutations rejected by the checker; +- runtime: `reducer-transition-oracle.spec.ts`. + +The Chromium oracle runs under root Strict Mode. It observes two lazy-initializer executions on +mount and two reducer executions for one event while React commits exactly one increment. The +oracle calibrates replay behavior against React 19.2.5; it does not upgrade a static certificate. +The complete gate now reports 561 static tests and 43 Chromium runtime oracles across 332 +checked-in fixture project configurations. + +### Product brief: internal reducer-transition facts + +Job: Prover consumers need to know that a reducer is replay-safe, returns state on every represented +path, and can only be activated through a completely owned React callback. + +Change: Add one private `reducer-transitions` claim, versioned reducer/dispatch facts, type-aware +return evidence, execution-phase ownership, and independent checker equations. + +Reuse: Truffler searches for reducer transitions, tuple state/dispatcher bindings, total function +returns, and dispatch ownership found no existing complete protocol. The implementation reuses +canonical Hook resolution, render-purity analysis, TypeScript-aware return summaries, semantic +callback roots, project-helper reachability, and stable-Hook dependency recognition. + +Metric: The deterministic acceptance metric separates total, fallthrough, throw, lazy-init, +render-dispatch, event-dispatch, escape, opaque-wrapper, and forged-certificate cases, plus one +Strict Mode replay oracle. + +Compat: No React Doctor CLI, score, config, Action, or published JSON report changes. The private +`@react-doctor/prover@0.0.0` report moves to schema 24 and its semantic graph to schema 30. No +Changeset is warranted before publication. + +Kill: If a complete reducer protocol produces a false `proved` result in two proof-schema releases, +remove total reducer certification and keep the protocol incomplete until a stronger CFG or +library proof summary carries the missing semantics. + +## Lazy-component and Suspense-topology certificates + +### React contract and realistic evidence + +React's [`lazy` reference](https://react.dev/reference/react/lazy) supplies three source-level +requirements. `lazy(load)` should be declared outside components so its identity survives renders; +`load` must return a Promise or thenable which resolves to an object whose `default` property is a +valid component; and rendering the result suspends while the module is pending. React caches both +the thenable and its resolved module. A render-local `lazy()` declaration is therefore not merely +an allocation smell: React documents that it resets descendant state when the parent rerenders. + +The [`Suspense` reference](https://react.dev/reference/react/Suspense) defines the corresponding +topology. A pending lazy component activates its closest parent boundary and renders that +boundary's fallback. A boundary can be in the same component, a transitive parent component, or a +component which places an incoming ReactNode slot. Fetching from an Effect does not activate +Suspense, so the theorem only assigns suspension behavior to React-recognized sources. + +React Bench supplied two realistic OpenCode shapes: + +- `app/src/app.tsx` declares route components with module-level `lazy(() => import(...))` and + renders each route under local `` boundaries. +- `app/src/components/status-popover.tsx` maps named module exports with + `.then((module) => ({ default: module.Named }))`. Some renders are directly nested under + Suspense, while another passes the lazy ReactNode through a body component which places + `children` under the boundary. + +These examples require symbol identity, named-export loader typing, transitive component topology, +and ReactNode-slot placement. A lexical parent check alone would reject real correct code and miss +an unbounded lazy render hidden in a child component. + +### Closed subset and fail-closed boundary + +The certificate recognizes imported, aliased, and namespace React `lazy` calls assigned to a +symbol. It resolves a source loader, uses the structured return summarizer to require every +represented path to return without fallthrough or uncaught throw, asks TypeScript for the awaited +type of every return, and requires a callable or constructible `default` property. Opaque loader +functions remain unknown; a closed return with the wrong shape is a counterexample. + +Every canonical `` receives a graph identity. Effective project render edges record the +boundaries active at their exact JSX placements, including boundaries introduced by a closed +ReactNode slot. A fixed point starts every exported component as outside Suspense and propagates +either the nearest known boundary or its parent's boundary sources through transitive renders. +Each lazy render combines its direct/slot boundary alternatives with inherited owner sources. One +known outside path refutes coverage, a nonempty closed boundary set proves it, and cycles, external +components, unresolved slots, and components with no closed root path remain unknown. + +The first version intentionally does not claim that a dynamic import will succeed, that a fallback +meets product design requirements, or that a rejected loader has an Error Boundary. Those are +separate availability, UX, and error-recovery theorems. It proves the generic React lazy identity, +loader-shape, and pending-state topology protocol. + +### Certificate checker, corpus, and runtime calibration + +The independent checker validates unique boundary, component, and render IDs; owner and reciprocal +render links; declaration and loader domains; exact module-stability/source/completeness +equations; valid direct and propagated boundary identities; root-derived outside and unknown +sources; exact coverage status; and the per-unit `lazy-suspense` verdict. Report schema 25 and graph +schema 31 reject stale or forged certificates. + +Added corpus: + +- proved: direct default import, an async module value, named-export mapping through `.then`, + namespace `lazy` composed with `memo`, a transitive child route, ReactNode placement through a + project-local Suspense shell, a class component, an outer boundary catching an inner boundary's + lazy fallback, an object-property alias, and lazy renders reached through a render helper or + synchronous `map` callback; +- refuted: a root-reachable render outside Suspense, a lazy component inside a boundary's own + fallback, render-local `lazy()`, a non-component default, and a default union with a + non-component alternative; +- incomplete: an opaque declared loader, inline or aliased unrecognized higher-order wrappers, an + exported lazy component, memo alias, or opaque wrapper alias whose external render topology is + open, and a lazy ReactNode crossing an external slot component; +- forged: an outside-boundary render rewritten as covered and rejected by the checker; +- runtime: `lazy-suspense-oracle.spec.ts`. + +The React 19.2.5 Chromium oracle observes the fallback before reveal, one cached loader execution, +the loaded component, and preserved child state after a parent rerender. It calibrates the runtime +contract without upgrading static unknowns. The complete gate now contains 584 static tests and 44 +Chromium runtime oracles across 354 checked-in fixture project configurations. + +### Product brief: internal lazy/Suspense facts + +Job: Prover consumers need to know that code-split React components retain identity, resolve to a +renderable default component, and cannot suspend on a reachable path without a known loading +boundary. + +Change: Add one private `lazy-suspense` claim, versioned lazy-component, lazy-render, and Suspense +boundary facts, whole-project topology propagation, and independent checker equations. + +Reuse: Truffler searches for lazy loaders, Suspense topology, deferred values, and resource +boundaries found no existing theorem. The implementation reuses canonical React API and symbol +resolution, the type-aware function-return summarizer, component render identities, transparent +React wrappers, ReactNode slot placement, and the context/Form Status fixed-point pattern. + +Metric: The deterministic acceptance metric separates direct, async-loader, named-export, class, +object-alias, transitive, slot, nested fallback, render-helper, synchronous-callback, exported-root, +outside-boundary, exported-alias, exported opaque wrapper, fallback, unstable-declaration, +invalid-loader, opaque-loader, external-slot, and forged cases, plus one loader-cache/ +state-preservation Chromium oracle. Opaque-wrapper coverage includes both inline `withRetry(lazy())` +and separately aliased `withRetry(Lazy)` shapes. + +Compat: No React Doctor CLI, score, config, Action, or published JSON report changes. The private +`@react-doctor/prover@0.0.0` report moves to schema 25 and its semantic graph to schema 31. No +telemetry or Changeset is warranted before publication. + +Kill: If a complete lazy/Suspense protocol produces a false `proved` topology in two proof-schema +releases, remove transitive coverage certification and keep non-lexical paths unknown until +ReactNode SSA or an explicit component proof contract carries the missing placement semantics. + +## Error Boundary containment certificates + +### React contract and realistic evidence + +React's +[`Component` reference](https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary) +defines an Error Boundary as a class which implements +[`static getDerivedStateFromError`](https://react.dev/reference/react/Component#static-getderivedstatefromerror) +to display fallback UI and may implement +[`componentDidCatch`](https://react.dev/reference/react/Component#componentdidcatch) for reporting. +It catches rendering failures in descendant components. It does not catch event-handler errors, +server-rendering errors, errors thrown by the boundary itself, or ordinary asynchronous callback +errors. React's [`lazy`](https://react.dev/reference/react/lazy) and +[`use`](https://react.dev/reference/react/use#displaying-an-error-with-an-error-boundary) +references separately confirm that rejected lazy loaders and rejected resources propagate to the +nearest Error Boundary. + +React Bench supplied a representative application boundary in +`migrate-react-opencode-solid-to-react/solution/port/ui/src/storybook/scaffold.tsx`, while the +application's `src/app.tsx` uses a class boundary whose `getDerivedStateFromError` flips a fallback +state key and whose render path supplies application fallback UI. These shapes require class-symbol +identity, state-transition evidence, and whole-project descendant topology. Merely finding a +method named `componentDidCatch` or a lexical JSX ancestor is not enough. + +### Closed subset and fail-closed boundary + +The certificate recognizes classes whose base resolves to React `Component` or `PureComponent`. +A valid first-version recovery protocol has a source-visible static +`getDerivedStateFromError`, returns an object which sets one common state key to `true` on every +represented path, is render-pure, and has a render guard on that exact `this.state` key whose +fallback branch does not return `this.props.children`. `componentDidCatch` is recorded when +present but is not required for recovery. Opaque returned state, unsupported render control flow, +or unresolved class state remains unknown; a missing or non-total recovery transition is a source +counterexample. + +The modeled failure source is an explicit `throw` reachable from a client render root, including +project-local render helpers and synchronous render callbacks. Boundary identity is propagated +through direct and transitive component renders and closed ReactNode-slot placement. A boundary +does not protect its own render or fallback. A known exported-root path without a valid boundary +refutes containment, a nonempty closed boundary set proves it, and opaque component or slot +topology remains unknown. + +This theorem intentionally does not claim that arbitrary calls cannot throw. It also excludes +event handlers, server rendering, Effects, timers, subscriptions, rejected lazy loaders, +rejected `use` resources, and Transition Action failures until those sources have their own +typed or CFG-backed failure summaries. Covering those source families is the next step toward the +full React availability proof; treating every call signature as non-throwing would be unsound. + +### Certificate checker, corpus, and runtime calibration + +The independent checker validates unique definition, instance, render, and failure identities; +React class ownership; reciprocal definition/instance and boundary/render links; exact protocol +status and completeness equations; valid propagated boundary sources; root-derived outside and +unknown sources; exact coverage status; and the per-unit `error-boundary` verdict. Report schema +26 and graph schema 32 reject stale or forged certificates. + +Added corpus: + +- proved: direct descendant, project render-helper, and closed ReactNode-slot failures; +- refuted: a root-reachable failure outside a boundary and a boundary without a valid fallback + transition; +- incomplete: an opaque recovery-state helper; +- excluded: an event-handler throw which is not mislabeled as a render failure; +- forged: outside-boundary coverage rewritten as covered and rejected by the checker; +- runtime: `error-boundary-oracle.spec.ts`. + +The React 19.2.5 Chromium oracle observes a descendant failure reveal its nearest fallback and a +failing inner fallback escape to an outer boundary. Runtime evidence calibrates boundary ownership +but never upgrades a static unknown. The complete gate contains 592 static tests and 46 Chromium +runtime oracles across 361 checked-in fixture project configurations. + +### Product brief: internal Error Boundary facts + +Job: Prover consumers need to know that every modeled client render failure is contained by a +valid recovery boundary, and that an apparent boundary can actually transition to fallback UI. + +Change: Add one private `error-boundary` claim, versioned definition, instance, and render-failure +facts, whole-project topology propagation, and independent checker equations. + +Reuse: Truffler searches for Error Boundary protocols, derived error state, render-failure +topology, and nearest-boundary propagation found no existing theorem. The implementation reuses +React class-symbol resolution, class render collection, structured return summaries, render +purity, reachable helper callbacks, effective component renders, and ReactNode-slot propagation. + +Metric: The deterministic acceptance metric separates direct, helper, slot, uncovered, invalid, +opaque, event-excluded, and forged-certificate cases, plus descendant and own-fallback Chromium +oracles. + +Compat: No React Doctor CLI, score, config, Action, or published JSON report changes. The private +`@react-doctor/prover@0.0.0` report moves to schema 26 and its semantic graph to schema 32. No +telemetry or Changeset is warranted before publication. + +Kill: If a complete Error Boundary protocol produces a false `proved` containment result in two +proof-schema releases, remove transitive containment certification and keep non-lexical paths +unknown until exception effects or an explicit component proof contract carry the missing +semantics. + +## `use` resource identity and dual-boundary certificates + +### React contract and realistic evidence + +React's [`use` reference](https://react.dev/reference/react/use) defines two distinct protocols +behind one API. `use(Context)` reads context and may be conditional. `use(Promise)` suspends at the +nearest parent Suspense boundary while pending and throws to the nearest Error Boundary when +rejected. React explicitly warns that Promises created in Client Component render are recreated on +every render and should instead come from a Suspense-compatible library or Server Component. The +[`error-boundaries` lint](https://react.dev/reference/eslint-plugin-react-hooks/lints/error-boundaries) +also establishes that `try`/`catch` cannot replace a React Error Boundary around child rendering. + +TypeScript provides the proof discriminator React's shared API does not: a usable resource must +have a callable `then`, including every member of a union or a closed type-parameter constraint. +`any`, `unknown`, mixed unions, and unconstrained generics cannot establish this theorem. Context +symbols remain in the existing context-topology theorem and are not double-counted as Promise +resources. + +React Bench's OpenCode port contains the matching application shapes: class Error Boundaries, +Suspense-delimited asynchronous UI, and realistic `try`/`catch` around storage and JSON operations. +Those catches are ordinary synchronous exception scopes; they do not contain React suspension or +descendant render rejection. This distinction is why the resource theorem uses React topology +rather than generic lexical exception syntax. + +### Closed subset and fail-closed boundary + +The certificate recognizes canonical imported, aliased, or namespace `use` calls after excluding +symbol-resolved React Context values. TypeScript classifies the argument as `thenable`, `invalid`, +or `unknown`. Identity is stable for a module-scope `const` Promise, a canonical `useState` value +whose initializer traces to a stable Promise, and closed local aliases of those origins. Direct +`fetch`, `Promise.*`, `new Promise`, source-resolved async calls, Promise-producing `useMemo` +calls, and lazy state initializers which create a Promise during React execution are unstable. An +arbitrary prop, member access, reducer state, or factory remains unknown instead of being assumed +cached. + +Each resource owns two independent topology equations. Suspense sources start every exported +render root outside a boundary and propagate the nearest known boundary through effective render +edges, transparent closed slots, and custom-Hook call edges. Error Boundary sources use the same +whole-project fixed point but additionally require every referenced boundary definition to have a +source-complete and valid recovery protocol. A resource is complete only when its type is thenable, +its identity is stable, both topology sets are closed, the pending path is covered by Suspense, and +every represented rejection path is covered by valid Error Boundary recovery. + +A fresh resource, invalid input, known outside-Suspense path, or known outside-Error-Boundary path +is a counterexample. Opaque type, cache origin, component placement, slot placement, custom-Hook +reachability, or recovery definition remains unknown. The theorem does not claim that a Promise +will settle, that its value meets an application refinement, that fallback UI meets product +requirements, or that an external caching library is correct without a future proof summary. + +### Certificate checker, corpus, and runtime calibration + +The independent checker recomputes Suspense and Error Boundary sources from units, effective +renders, slots, boundary/render links, and custom-Hook edges. It validates unique resource IDs; +owner, kind, and identity domains; exact non-sentinel boundary sets; valid Error Boundary +definitions; outside and topology flags; both coverage statuses; the source-completeness +conjunction; final completeness; and the per-unit `use-resource` verdict. Report schema 27 and +graph schema 33 reject stale or forged certificates. + +Added corpus: + +- proved: a module-cached Promise, the same protocol inside a custom Hook, and a module-cached + Promise retained in React state; +- refuted: a direct render-created Promise, a Promise created by a lazy state initializer, missing + Suspense, missing Error Boundary, mixed valid/invalid Error Boundary paths, and a typed + non-thenable input; +- incomplete: a typed Promise supplied through an opaque component prop; +- regression: conditional `use(Promise)` remains Hook-order-valid while missing boundaries are now + a source-level counterexample rather than an unmodeled lifecycle; +- forged: outside-Suspense facts rewritten as covered and rejected by the checker; +- runtime: `use-resource-oracle.spec.ts`. + +The React 19.2.5 Chromium oracle observes a pending resource reveal Suspense fallback before its +resolved value and observes a rejected resource reveal Error Boundary fallback. Runtime evidence +calibrates the two channels but never upgrades a static unknown. The complete gate contains 603 +static tests and 48 Chromium runtime oracles across 371 checked-in fixture project configurations. + +### Product brief: internal `use` resource facts + +Job: Prover consumers need to know that a React resource has cache-stable thenable identity and +that every known pending and rejection path has React-owned recovery UI. + +Change: Add one private `use-resource` claim, versioned resource facts, dual whole-project topology +propagation, and independent checker equations. + +Reuse: Truffler searches for Promise resource identity, exception effects, thenable classification, +and `use` topology found no existing complete protocol. The implementation reuses canonical React +API and Context resolution, TypeScript types, stable Hook bindings, effective render and ReactNode +slot facts, custom-Hook edges, Suspense identities, and Error Boundary recovery definitions. + +Metric: The deterministic acceptance metric separates module, Hook, state, fresh-Promise, +invalid-type, missing-Suspense, missing-Error-Boundary, opaque-prop, conditional, and forged +certificate cases, plus resolved and rejected Chromium channels. + +Compat: This is an internal-only private package surface, so the product-thinking pass requires no +CLI telemetry, published JSON migration, or Changeset. `@react-doctor/prover@0.0.0` moves to report +schema 27 and graph schema 33 so stale internal certificates fail closed. + +Kill: If a complete `use` resource protocol produces a false `proved` identity or topology result +in two proof-schema releases, remove stable resource certification and keep `use(Promise)` +incomplete until an explicit caching or component-placement proof contract carries the missing +semantics. + +## Intrinsic host-control ownership certificates + +### React contract and realistic evidence + +React's [`input`](https://react.dev/reference/react-dom/components/input), +[`textarea`](https://react.dev/reference/react-dom/components/textarea), and +[`select`](https://react.dev/reference/react-dom/components/select) references define a React +ownership protocol rather than a styling convention. A `value` prop controls text inputs, +textareas, and selects; `checked` controls checkboxes and radios. A control cannot be both +controlled and uncontrolled, cannot switch ownership during its lifetime, and an editable +controlled field must synchronously update its backing value from `onChange`. React otherwise +reverts the browser's edit. `defaultValue` and `defaultChecked` initialize uncontrolled controls, +while `readOnly` and `disabled` make a missing update intentional. + +React Bench's gap lab records controlled-to-uncontrolled input flow through +`value={draft ?? user?.name}` as a high-value React bug class which the ordinary rule suite misses. +The broader corpus also contains the corresponding defensive normalization repeatedly: +`value={value ?? ""}`, `value={field.value ?? ""}`, and explicit `String(value ?? "")` forms. +Those shapes demonstrate why prop presence alone is insufficient. The proof needs the rendered +value domain, state origin, and update channel, and it must distinguish a normalization which is +always defined from a fallback which can still produce `undefined`. + +### Closed subset and fail-closed boundary + +The certificate recognizes intrinsic `input`, `textarea`, and `select` render sites, including +render helpers reachable from a component. Static input `type` selects the text, checkable, file, +or non-editable protocol; static `multiple` distinguishes single and multiple selects. JSX +attributes are interpreted in source order. A later explicit prop closes an earlier spread, while +a spread which may provide a relevant prop leaves that property unresolved. + +TypeScript classifies each explicit controlled value as defined, nullish, or unknown. For an exact +local `useState` value whose declared type is wider than its actual source protocol, the collector +also reads the initializer and every direct setter argument. A known nullish initializer plus a +known defined write is a concrete ownership switch; an uncalled wider union is not refuted merely +because its annotation permits more values. Nullish controlled props and simultaneous +`value`/`defaultValue` or `checked`/`defaultChecked` props are counterexamples. + +An editable controlled field is complete only when its `onChange` expression resolves to a closed +callback set and every callback's entry-dominating operation writes the exact +`event.target.value`, `event.currentTarget.value`, `event.target.checked`, or +`event.currentTarget.checked` into the setter paired with the rendered state value. A missing +handler, conditional write, nested deferred write, or transformed value is a counterexample. +Uncontrolled controls and statically immutable controls need no state transition. + +Prop-controlled library components, callback helpers, dynamic input types, overriding spreads, +controlled file inputs, multiple-select array extraction, class state, destructured event values, +and non-entry-dominating but potentially total callback CFGs remain unknown. Those require +component contracts, general value SSA, or compiler-backed dominance before they can be +certified. The theorem does not attempt domain validation such as whether a select value matches +an option, browser constraint validation, accessibility, or business-level form correctness. + +### Certificate checker, corpus, and runtime calibration + +The independent checker validates unique control identities; owner units; enum domains; +element-specific controlled/default prop names; controlled-prop and value-status coherence; +paired state and setter names; event-phase callbacks; same-owner direct-value state transitions; +the complete callback and transition links required by an exact update; and exact protocol status, +source, completeness, and per-unit verdict equations. Report schema 28 and graph schema 34 reject +stale or forged certificates. + +Added corpus: + +- proved: exact text input, textarea, checkbox, and single-select state echoes; a nullish state + normalized to an always-defined rendered value; uncontrolled defaults; an explicit read-only + input; a disabled textarea; and an uncontrolled file input; +- refuted: a definite `undefined`-to-string ownership switch, controlled/default conflict, missing + update, deferred update, and transformed update; +- incomplete: a prop-owned value/callback contract, an input whose ownership props come from a + spread, a controlled file input, a controlled multiple-select, and a dynamic input type; +- forged: a switching value rewritten as defined and resolved, rejected by the checker; +- runtime: `host-control-oracle.spec.ts`. + +The React 19.2.5 Chromium oracle observes an exact controlled input preserve the typed DOM value +and backing output, and observes React emit its uncontrolled-to-controlled warning when a +source-visible `undefined` state becomes a string. Runtime evidence calibrates the ownership +transition but never upgrades a static unknown. The complete gate contains 617 static tests and 50 +Chromium runtime oracles across 383 checked-in fixture project configurations. + +### Product brief: internal host-control facts + +Job: Prover consumers need evidence that each intrinsic form field has stable React ownership and +that user edits cannot be reverted by an absent, delayed, conditional, or mismatched state write. + +Change: Add one private `host-control` claim, versioned intrinsic-control facts, TypeScript and +Hook-state value-domain analysis, exact event-to-state links, adversarial fixtures, and browser +oracles. + +Reuse: Truffler searches for controlled input protocols, JSX attribute resolution, event-target +state writes, and intrinsic form controls found no existing theorem. The implementation reuses +ordered JSX spread property discovery, canonical Hook binding identity, callable-value +resolution, event callback facts, Hook state-transition facts, source locations, and +entry-dominance checks. + +Metric: The deterministic acceptance metric separates exact text, textarea, checkbox, select, +uncontrolled, immutable, file, ownership-switch, prop-conflict, missing, deferred, transformed, +prop-owned, spread-owned, and forged-certificate cases, plus exact-echo and ownership-warning +Chromium oracles. + +Compat: No React Doctor CLI, score, config, Action, or published JSON report changes. The private +`@react-doctor/prover@0.0.0` report moves to schema 28 and its semantic graph to schema 34. No +telemetry or Changeset is warranted before publication. + +Kill: If a complete host-control protocol produces a false `proved` ownership or update result in +two proof-schema releases, remove exact host-control certification and retain only explicit +counterexamples until component contracts or compiler-backed value SSA closes the missing flow. + +## Whole-tree hydration-equivalence certificates + +### React contract and realistic evidence + +React's [`hydrateRoot`](https://react.dev/reference/react-dom/client/hydrateRoot) contract requires +the first client render to produce output identical to the server HTML. React documents mismatches +as application bugs, not a reconciliation strategy: development warns, recovery may regenerate +the tree, and mismatched event handlers can attach to the wrong elements. The documented common +causes include `typeof window !== "undefined"`, browser-only APIs such as `window.matchMedia`, and +different server/client data. `suppressHydrationWarning` is a one-level escape hatch which does not +patch mismatched text, so it cannot discharge an equivalence proof. + +[`renderToString`](https://react.dev/reference/react-dom/server/renderToString) and the streaming +server APIs produce HTML intended for `hydrateRoot`; their `identifierPrefix` must match the +client option so `useId` identities agree. +[`renderToStaticMarkup`](https://react.dev/reference/react-dom/server/renderToStaticMarkup) +explicitly produces output that cannot be hydrated. +[`createRoot`](https://react.dev/reference/react-dom/client/createRoot) is a client-render root, +not evidence that a component has an SSR first-render obligation. + +React Bench's `/home/aidenybai/Developer/react-bench-internal/docs/RD_FN_FP.md` records two +concrete misses which motivated the theorem: `RD-FN-009`, server/client branching through +browser-global availability, and `RD-FN-062`, +host-locale or time-zone formatting during SSR. The latter is especially important because +`toLocaleString()` can be pure and deterministic within one process while still differing across +the server and browser environments. A render-purity theorem alone cannot prove hydration. The +source-verified `fix-react-rdh-rad-ui-ui-theme` trials place `window.matchMedia` in a `useState` +initializer; the `fix-react-cloudscape-design-components-4461` trials return different JSX behind +`typeof window`; and `fix-react-rdh-sofn-xyz-mailing-settings` moves +`new Date(apiKey.createdAt).toLocaleString()` from a client Effect into rendered memo output. +The fixtures preserve each of those value-flow shapes. + +### Closed subset and fail-closed boundary + +The collector accepts only module-executed canonical imports or aliases from `react-dom/client` +and `react-dom/server`. Calls nested in request handlers or other functions are recorded but remain +unknown until an entrypoint adapter proves that execution root. It records interactive server roots from `renderToString`, +`renderToPipeableStream`, and `renderToReadableStream`, non-hydratable roots from +`renderToStaticMarkup`, and client roots from `hydrateRoot`. A root expression resolves through a +direct project component, a single transparent React wrapper or Fragment child, `createElement`, +or an immutable local alias. Dynamic ReactNode selection remains unresolved. + +Object-literal `identifierPrefix` options are interpreted in source order. A static string, or the +absence of the option, is known; a dynamic options value or overriding spread is unknown. An +equivalence certificate requires one interactive server root and one client root on the +represented path, equal known prefixes, closed render/slot topology, and no modeled environment +hazard. Pairing `hydrateRoot` with `renderToStaticMarkup` or a different prefix is a source-level +counterexample. Multiple candidates and a client root without a source-visible server pair remain +unknown because file co-location does not prove deployment pairing. + +Root reachability begins at the resolved component and is propagated through every effective +direct or ReactNode-slot render and project custom-Hook edge. Incomplete slot placement adds an +unknown source rather than silently dropping the child. Browser globals count only when they flow +into a returned render value or a return-controlling condition; nested event handlers are not +server-render execution. Local immutable values, synchronous render helpers, `useState` +initializers, and output-affecting `if` and `switch` conditions are followed. Source-resolved +zero-argument `toLocaleString`, `toLocaleDateString`, and `toLocaleTimeString`, plus host-default +`Intl` formatter construction, are environment-dependent. + +The current proof does not summarize framework-generated or function-owned server/client entrypoints, dynamic root +registries, multiple deployed root pairs, locale methods with partially explicit options, or +arbitrary library-returned environment data. Those need framework root adapters and a broader +abstract environment domain. A source tree with no canonical `hydrateRoot` is not judged by this +claim; the rest of the prover still applies. + +### Certificate checker, corpus, and runtime calibration + +The semantic graph records canonical root API/kind, target, prefix, location, and completeness; +owner-qualified environment hazards; and exactly one hydration certificate per semantic unit. +The independent checker re-propagates root identities through render, slot, and custom-Hook edges, +repartitions client, interactive-server, and static-server roots, recomputes topology uncertainty, +requires exact hazard ownership, and derives equivalence, mismatch, unknown, and not-hydrated +verdicts without trusting the analyzer's final status. Report schema 29 and graph schema 35 reject +stale certificates. + +Added corpus: + +- proved: equal `renderToString`/`hydrateRoot` roots and prefixes, namespace-imported roots through + immutable `StrictMode` tree aliases, plus a CSR-only browser-global component which receives no + hydration obligation and same-named user functions which are rejected by symbol provenance; +- refuted: `typeof window` output branching, direct `navigator` output, host-default + `toLocaleString`, a `matchMedia`-derived `useState` initializer, unequal prefixes, static-markup + hydration, a browser-global child reached through a transitive ReactNode slot, a browser branch + reached through a custom Hook, and a mismatch hidden by `suppressHydrationWarning`; +- incomplete: dynamic ReactNode root selection, function-owned entrypoints, and a non-literal + client options object; +- forged: a known hazard removed while the unit is rewritten as equivalent, rejected by the + checker; +- runtime: `hydration-equivalence-oracle.spec.ts`. + +The React 19.2.5 Chromium oracle hydrates matching server markup without recovery, then hydrates a +different first client render and observes both DOM regeneration and `onRecoverableError`. This +calibrates the mismatch classification against React itself without treating browser observation +as a proof. The complete gate now contains 635 static tests and 52 Chromium runtime oracles across +399 checked-in fixture project configurations. + +### Product brief: internal hydration facts + +Job: Prover consumers need evidence that every source-visible SSR tree produces the same first +client render and preserves React identity during hydration. + +Change: Add one private `hydration-equivalence` claim, versioned root, environment-hazard, and +per-unit reachability facts, an independently recomputed certificate, realistic fixtures, and a +real React hydration oracle. + +Reuse: Truffler searches for hydration roots, server render topology, environment-dependent render +values, and root component resolution found no existing theorem. The implementation reuses +canonical React import identity, semantic render and ReactNode-slot edges, custom-Hook edges, +TypeScript default-library symbols, reachable render helpers, return summaries, immutable symbol +writes, and proof locations. + +Metric: The deterministic acceptance metric separates interactive/static/CSR roots, matching and +mismatching prefixes, browser values and branches, locale formatting, transitive slots, custom +Hooks, dynamic roots/options, forged certificates, and equivalent/recovering Chromium hydration. + +Compat: No React Doctor CLI, score, config, Action, or published JSON report changes. The private +`@react-doctor/prover@0.0.0` report moves to schema 29 and its semantic graph to schema 35. No +telemetry or Changeset is warranted before publication. + +Kill: If a complete hydration protocol produces a false `proved` environment or root-pair result +in two proof-schema releases, remove equivalence certification and retain only explicit mismatch +counterexamples until framework root adapters or a broader environment abstract domain closes the +missing semantics. + +## `React.memo` bailout-equivalence certificates + +### React contract and realistic evidence + +React's [`memo`](https://react.dev/reference/react/memo) contract makes a custom comparator a +semantic assertion: it may return `true` only when the next props produce the same output and +behavior as the previous props. React explicitly warns that every prop must be compared, including +functions, because an omitted callback can preserve a stale closure. The default comparator checks +every prop with `Object.is`. This is stronger than referential-stability advice: a stable reference +can justify an optimization, but the custom comparator still bears the proof that suppressing a +render is observationally equivalent. + +Jovi De Croock's +[`Stable` exploration](https://www.jovidecroock.com/blog/referential-stability-types/) treats +referential stability as proof-carrying type information and separates that optimization property +from correctness. This theorem applies the same useful separation at the React bailout boundary: +TypeScript supplies the possible-value domain and symbol identity, while control-flow facts must +show that each `true` return path preserves the component's observations. + +React Bench's +`/home/aidenybai/Developer/react-bench-internal/docs/RD_FN_FP.md` records `RD-FN-042`, a +source-verified custom comparator which omitted the rendered `menuGroups` prop and left stale +output. The broader sample review found the realistic shapes this proof must distinguish: +guard-chain comparators with early `false` returns, projected comparisons over a subset of props, +whole-object identity, helper-based comparators, and generic `Object.values` or userland equality +helpers. A name-based missing-prop rule cannot decide those cases; the proof must model the +comparator's boolean paths and the component's actual prop observations. + +### Closed subset and fail-closed boundary + +The collector accepts canonical imported, aliased, or namespace `React.memo` calls and rejects +same-named user functions by symbol provenance. It resolves direct project function components and +one canonical `forwardRef` wrapper. The component observation pass follows destructured +parameters, static property and element access, nested prop paths, local destructuring, callbacks, +and rest usage. TypeScript literal domains mark singleton values as unable to vary; other observed +values remain proof obligations. + +The comparator pass binds previous and next prop symbols, then symbolically enumerates the paths +on which its result is `true`. It supports `===`, `!==`, canonical default-library `Object.is`, +`&&`, `||`, `!`, conditionals, immutable boolean aliases, block returns, and early `if`/return +guards. Each `true` path must imply equality of every varying observed prop path. Equality of +`user` covers an observation of `user.name`; equality of `user.id` does not. Whole-props strict +identity covers every static or open observation. A comparator with no `true` path is safe because +it never suppresses a render. + +A complete path that omits an observed value is a counterexample, even when other paths are safe. +An unresolved helper body, dynamic property access, rest comparator binding, unsupported boolean +operation, mutable alias, unresolved wrapped component, or more than 64 symbolic paths remains +unknown. The theorem proves bailout equivalence only. It does not claim that memoization is +profitable, that prop identities are stable, that a comparator is faster than rendering, or that +the component is correct under props which both renders receive. + +### Certificate checker, corpus, and runtime calibration + +The semantic graph records comparator kind and source, owner identity, observed prop paths and +TypeScript variability, symbolic equality sets for every `true` path, analysis completeness, and +the final equivalence classification. The independent checker validates owner and enum domains, +unique observation and path identities, exact default-shallow facts, path completeness, universal +whole-props equality, omitted-observation classification, source/completeness equations, and the +per-unit memo-equivalence verdict. Report schema 30 and graph schema 36 reject stale or forged +certificates. + +Added corpus: + +- proved: default shallow comparison, complete custom equality, guard-chain early returns, a + comparator which never skips, whole-props identity over a rest observation, namespace imports, + and rejection of a same-named userland helper; +- refuted: omitted rendered arrays, omitted callbacks, an always-`true` comparator, independently + unsafe `a || b` paths, a nested `user.id`/`user.name` mismatch, shared prototype-method identity, + and omitted rest props; +- incomplete: an unavailable comparator helper body, dynamic prop access, and a dynamically + selected wrapped component; +- forged: an omitted-prop fact rewritten as equivalent and complete, rejected by the checker; +- runtime: `memo-equivalence-oracle.spec.ts`. + +The React 19.2.5 Chromium oracle observes a complete comparator expose the next rendered label and +an incomplete comparator preserve the stale label after the parent commits new state. Browser +evidence calibrates the static counterexample but never upgrades an unknown. The complete gate now +contains 660 static tests and 54 Chromium runtime oracles across 416 checked-in fixture project +configurations. + +### Product brief: internal memo-equivalence facts + +Job: Prover consumers need evidence that every source-visible `React.memo` bailout preserves the +component's output and behavior for all prop values admitted by TypeScript. + +Change: Add one private `memo-equivalence` claim, versioned observation and symbolic comparator +facts, an independently recomputed certificate, adversarial fixtures, and a real React stale-output +oracle. + +Reuse: Truffler searches for comparator equivalence, component prop reads, previous/next prop +comparison, memo owner resolution, and property-access parameters found no existing theorem. The +implementation reuses canonical React symbol identity, function resolution, semantic unit IDs, +TypeScript types and default-library symbols, source locations, and proof obligations. + +Metric: The deterministic acceptance metric separates default, exact, early-return, never-skip, +whole-props, namespace, userland lookalike, omitted output, omitted callback, disjunction, nested +path, shared method identity, rest, opaque helper, dynamic read, dynamic owner, and +forged-certificate cases, plus matching and stale Chromium renders. + +Compat: No React Doctor CLI, score, config, Action, or published JSON report changes. The private +`@react-doctor/prover@0.0.0` report moves to schema 30 and its semantic graph to schema 36. No +telemetry or Changeset is warranted before publication. + +Kill: If a complete memo-equivalence protocol produces a false `proved` bailout in two proof-schema +releases, remove custom-comparator certification and retain only concrete omitted-prop refutations +until general interprocedural boolean summaries or component contracts close the missing flow. diff --git a/packages/prover/scripts/smoke-built-package.mjs b/packages/prover/scripts/smoke-built-package.mjs new file mode 100644 index 000000000..87cce7bed --- /dev/null +++ b/packages/prover/scripts/smoke-built-package.mjs @@ -0,0 +1,17 @@ +import * as assert from "node:assert/strict"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + checkReactProofReport, + proveReactApp, + ReactAppProofStatus, + ReactProofCertificateStatus, +} from "../dist/index.js"; + +const packageRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +const fixtureRoot = path.join(packageRoot, "tests/fixtures/proved-returned-event-handler"); +const report = proveReactApp({ rootDirectory: fixtureRoot }); +const certificate = checkReactProofReport(report); + +assert.equal(report.status, ReactAppProofStatus.Proved); +assert.equal(certificate.status, ReactProofCertificateStatus.Valid); diff --git a/packages/prover/src/analyze-action-state.ts b/packages/prover/src/analyze-action-state.ts new file mode 100644 index 000000000..07d942274 --- /dev/null +++ b/packages/prover/src/analyze-action-state.ts @@ -0,0 +1,90 @@ +import { createEvidence } from "./create-evidence.js"; +import { createObligation } from "./create-obligation.js"; +import { findSemanticUnit } from "./find-semantic-unit.js"; +import { ReactActionStateDispatchStatus, ReactObligationStatus, ReactProofClaim } from "./types.js"; +import type { + ReactAnalysisContext, + ReactProofEvidence, + ReactProofObligation, + ReactUnitDescriptor, +} from "./types.js"; + +export const analyzeActionState = ( + unit: ReactUnitDescriptor, + context: ReactAnalysisContext, +): ReactProofObligation => { + const owner = findSemanticUnit(unit, context); + const states = owner + ? (context.graph?.actionStates.filter((state) => state.ownerId === owner.id) ?? []) + : []; + const dispatches = owner + ? (context.graph?.actionStateDispatches.filter((dispatch) => dispatch.ownerId === owner.id) ?? + []) + : []; + const violations: ReactProofEvidence[] = []; + const unknownEvidence: ReactProofEvidence[] = []; + for (const state of states) { + if (!state.complete) { + unknownEvidence.push({ + description: `${state.dispatcherName} has an unresolved reducer Action`, + location: state.location, + trace: ["useActionState", "reducer Action", state.reducerStatus], + }); + } + } + for (const dispatch of dispatches) { + if (dispatch.status === ReactActionStateDispatchStatus.Render) { + violations.push({ + description: "Action state is dispatched during render", + location: dispatch.location, + trace: ["render", "Action State dispatcher", "forbidden update"], + }); + } else if (dispatch.status === ReactActionStateDispatchStatus.OutsideAction) { + violations.push({ + description: "Action state is dispatched outside an Action", + location: dispatch.location, + trace: ["non-Action callback", "Action State dispatcher", "missing Transition"], + }); + } else if (!dispatch.complete) { + unknownEvidence.push({ + description: + dispatch.status === ReactActionStateDispatchStatus.SetterEscape + ? "An Action State dispatcher escapes the modeled execution graph" + : "An Action State dispatch has an unresolved Action origin", + location: dispatch.location, + trace: ["useActionState", dispatch.status, "incomplete Action ownership"], + }); + } + } + if (!owner) { + unknownEvidence.push( + createEvidence( + unit.node, + context.rootDirectory, + "The Action State owner cannot be resolved", + ["React unit", "useActionState", "unknown owner"], + ), + ); + } + if (violations.length > 0) { + return createObligation( + ReactProofClaim.ActionState, + ReactObligationStatus.Violated, + "An Action State dispatcher is invoked outside an Action", + violations, + ); + } + if (unknownEvidence.length > 0) { + return createObligation( + ReactProofClaim.ActionState, + ReactObligationStatus.Unknown, + "Action State reducer identity or dispatcher ownership is incomplete", + unknownEvidence, + ); + } + return createObligation( + ReactProofClaim.ActionState, + ReactObligationStatus.Proved, + "Every Action State reducer is source-resolved and every dispatcher runs inside an Action", + ); +}; diff --git a/packages/prover/src/analyze-async-effect-ownership.ts b/packages/prover/src/analyze-async-effect-ownership.ts new file mode 100644 index 000000000..4c80d41b4 --- /dev/null +++ b/packages/prover/src/analyze-async-effect-ownership.ts @@ -0,0 +1,55 @@ +import ts from "typescript"; +import { collectAsyncEffectTaskDescriptors } from "./collect-async-effect-task-descriptors.js"; +import { createEvidence } from "./create-evidence.js"; +import { createObligation } from "./create-obligation.js"; +import { ReactAsyncOwnershipStatus, ReactObligationStatus, ReactProofClaim } from "./types.js"; +import type { ReactAnalysisContext, ReactProofEvidence, ReactProofObligation } from "./types.js"; + +export const analyzeAsyncEffectOwnership = ( + functionNode: ts.FunctionLikeDeclaration, + context: ReactAnalysisContext, +): ReactProofObligation => { + const tasks = collectAsyncEffectTaskDescriptors(functionNode, context); + const violations: ReactProofEvidence[] = []; + const unknownEvidence: ReactProofEvidence[] = []; + for (const task of tasks) { + if (task.status === ReactAsyncOwnershipStatus.Guarded) continue; + const evidence = createEvidence( + task.evidenceNode, + context.rootDirectory, + task.evidenceDescription, + [ + "effect setup", + "async continuation", + "suspension or deferred callback", + task.status === ReactAsyncOwnershipStatus.Unknown + ? "unclassified ownership" + : "unguarded stale state write", + "effect cleanup or replacement", + ], + ); + if (task.status === ReactAsyncOwnershipStatus.Unknown) unknownEvidence.push(evidence); + else violations.push(evidence); + } + if (violations.length > 0) { + return createObligation( + ReactProofClaim.AsyncEffectOwnership, + ReactObligationStatus.Violated, + "An async Effect task can write state after losing ownership", + violations, + ); + } + if (unknownEvidence.length > 0) { + return createObligation( + ReactProofClaim.AsyncEffectOwnership, + ReactObligationStatus.Unknown, + "Async Effect ownership contains an unclassified continuation", + unknownEvidence, + ); + } + return createObligation( + ReactProofClaim.AsyncEffectOwnership, + ReactObligationStatus.Proved, + "Every modeled async Effect state write is invalidated before replacement", + ); +}; diff --git a/packages/prover/src/analyze-boundary-coverage.ts b/packages/prover/src/analyze-boundary-coverage.ts new file mode 100644 index 000000000..3b433b2fa --- /dev/null +++ b/packages/prover/src/analyze-boundary-coverage.ts @@ -0,0 +1,798 @@ +import * as path from "node:path"; +import ts from "typescript"; +import { + REACT_EVENT_PROP_PATTERN, + REACT_RUNTIME_MODULE_NAMES, + REACT_USE_TRANSITION_TUPLE_LENGTH, + REACT_UNMODELED_HOOK_NAMES, +} from "./constants.js"; +import { getCallableRefProtocolForCurrentAccess } from "./collect-callable-ref-protocols.js"; +import { getPlatformSchedulerKind } from "./collect-effect-scheduler-protocols.js"; +import { collectReachableFunctionGraph } from "./collect-reachable-functions.js"; +import { createEvidence } from "./create-evidence.js"; +import { createObligation } from "./create-obligation.js"; +import { getCanonicalHookName } from "./get-canonical-hook-name.js"; +import { getCanonicalReactApiName } from "./get-canonical-react-api-name.js"; +import { getCallName } from "./get-call-name.js"; +import { getComponentPropName } from "./get-component-prop-name.js"; +import { getNodeLocation } from "./get-node-location.js"; +import { getRootIdentifier } from "./get-root-identifier.js"; +import { isFunctionBoundary } from "./is-function-boundary.js"; +import { isComponentPropExpression } from "./is-component-prop-expression.js"; +import { isReactContextExpression } from "./is-react-context-expression.js"; +import { unwrapTypescriptExpression } from "./unwrap-typescript-expression.js"; +import { doesTypeContainCallable } from "./resolve-callable-expression.js"; +import { findSemanticUnit } from "./find-semantic-unit.js"; +import { + ReactExecutionPhase, + ReactObligationStatus, + ReactProofClaim, + ReactUnitKind, +} from "./types.js"; +import { areProofLocationsEqual } from "./utils/are-proof-locations-equal.js"; +import { collectJsxSpreadProperties } from "./utils/collect-jsx-spread-properties.js"; +import { isEffectiveJsxPropertySource } from "./utils/is-effective-jsx-property-source.js"; +import { isIntrinsicJsxElement } from "./utils/is-intrinsic-jsx-element.js"; +import { isAssignmentOperator } from "./utils/is-assignment-operator.js"; +import { isJsxSpreadSourceComplete } from "./utils/is-jsx-spread-source-complete.js"; +import { getPlatformEffectResourceKind } from "./utils/get-platform-effect-resource-kind.js"; +import type { + ReactAnalysisContext, + ReactProofEvidence, + ReactProofObligation, + ReactUnitDescriptor, +} from "./types.js"; + +const isRuntimeImport = (importDeclaration: ts.ImportDeclaration): boolean => { + const importClause = importDeclaration.importClause; + if (!importClause || importClause.isTypeOnly) return false; + if (importClause.name) return true; + if (!importClause.namedBindings) return false; + if (ts.isNamespaceImport(importClause.namedBindings)) return true; + return importClause.namedBindings.elements.some((element) => !element.isTypeOnly); +}; + +const isProjectModule = ( + moduleSpecifier: ts.StringLiteral, + context: ReactAnalysisContext, +): boolean => { + const moduleSymbol = context.typeChecker.getSymbolAtLocation(moduleSpecifier); + if (!moduleSymbol) return moduleSpecifier.text.startsWith("."); + return Boolean( + moduleSymbol.declarations?.some((declaration) => { + const sourceFileName = declaration.getSourceFile().fileName; + return ( + !sourceFileName.includes(`${path.sep}node_modules${path.sep}`) && + path.relative(context.rootDirectory, sourceFileName).split(path.sep)[0] !== ".." + ); + }), + ); +}; + +export const analyzeBoundaryCoverage = ( + unit: ReactUnitDescriptor, + context: ReactAnalysisContext, +): ReactProofObligation => { + const functionNode = unit.functionNode; + if (!functionNode) { + return createObligation( + ReactProofClaim.BoundaryCoverage, + ReactObligationStatus.Unknown, + "The unit has no function boundary to analyze", + ); + } + const unknownEvidence: ReactProofEvidence[] = []; + const sourceFile = functionNode.getSourceFile(); + const isComponentUnit = unit.kind === ReactUnitKind.Component; + const semanticOwnerId = findSemanticUnit(unit, context)?.id; + const isCompleteCallableRefAccess = (accessExpression: ts.PropertyAccessExpression): boolean => { + const protocol = getCallableRefProtocolForCurrentAccess(accessExpression, context.typeChecker); + if (!protocol) return false; + const protocolLocation = getNodeLocation(protocol.declaration, context.rootDirectory); + return Boolean( + context.graph?.callableRefs.some( + (callableRef) => + callableRef.ownerId === semanticOwnerId && + areProofLocationsEqual(callableRef.location, protocolLocation) && + callableRef.complete, + ), + ); + }; + const isModeledCallbackPropInvocation = ( + callExpression: ts.CallExpression, + propName: string, + ): boolean => { + if (!context.graph || !semanticOwnerId) return false; + const location = getNodeLocation(callExpression, context.rootDirectory); + return context.graph.callbackPropFlows.some( + (propFlow) => + propFlow.targetOwnerId === semanticOwnerId && + propFlow.propName === propName && + propFlow.complete && + context.graph?.functionCalls.some( + (functionCall) => + functionCall.ownerId === semanticOwnerId && + functionCall.phase === propFlow.phase && + functionCall.location.filePath === location.filePath && + functionCall.location.line === location.line && + functionCall.location.column === location.column, + ), + ); + }; + const isCompleteEventFlow = (attribute: ts.JsxAttributeLike, eventName: string): boolean => { + const location = getNodeLocation(attribute, context.rootDirectory); + return Boolean( + context.graph && + (context.graph.eventBindings.some( + (eventBinding) => + eventBinding.ownerId === semanticOwnerId && + eventBinding.eventName === eventName && + eventBinding.complete && + eventBinding.location.filePath === location.filePath && + eventBinding.location.line === location.line && + eventBinding.location.column === location.column, + ) || + context.graph.callbackPropFlows.some( + (propFlow) => + propFlow.renderOwnerId === semanticOwnerId && + propFlow.propName === eventName && + propFlow.phase === ReactExecutionPhase.Event && + propFlow.complete && + propFlow.location.filePath === location.filePath && + propFlow.location.line === location.line && + propFlow.location.column === location.column, + )), + ); + }; + const isModeledFormAction = (attribute: ts.JsxAttributeLike, propName: string): boolean => { + const location = getNodeLocation(attribute, context.rootDirectory); + return Boolean( + context.graph?.formActions.some( + (action) => + action.ownerId === semanticOwnerId && + action.propName === propName && + action.sourceComplete && + areProofLocationsEqual(action.location, location), + ), + ); + }; + const isModeledOptimisticCall = (callExpression: ts.CallExpression): boolean => { + const location = getNodeLocation(callExpression, context.rootDirectory); + return Boolean( + context.graph?.optimisticStates.some( + (state) => + state.ownerId === semanticOwnerId && areProofLocationsEqual(state.location, location), + ), + ); + }; + const isModeledActionStateCall = (callExpression: ts.CallExpression): boolean => { + const location = getNodeLocation(callExpression, context.rootDirectory); + return Boolean( + context.graph?.actionStates.some( + (state) => + state.ownerId === semanticOwnerId && areProofLocationsEqual(state.location, location), + ), + ); + }; + const isModeledImperativeHandleCall = (callExpression: ts.CallExpression): boolean => { + const location = getNodeLocation(callExpression, context.rootDirectory); + return Boolean( + context.graph?.imperativeHandles.some( + (handle) => + handle.ownerId === semanticOwnerId && areProofLocationsEqual(handle.location, location), + ), + ); + }; + const getContainingImperativeHandleCall = (node: ts.Node): ts.CallExpression | null => { + let currentNode = node; + while (currentNode !== functionNode && currentNode.parent) { + const parentNode = currentNode.parent; + if (ts.isCallExpression(parentNode)) { + return getCanonicalReactApiName(parentNode.expression, context.typeChecker) === + "useImperativeHandle" + ? parentNode + : null; + } + if (isFunctionBoundary(parentNode)) return null; + currentNode = parentNode; + } + return null; + }; + const isModeledImperativeHandleUse = (node: ts.Node): boolean => { + const callExpression = getContainingImperativeHandleCall(node); + return Boolean(callExpression && isModeledImperativeHandleCall(callExpression)); + }; + const getImperativeHandleBinding = (callExpression: ts.CallExpression) => { + if (!context.graph || !semanticOwnerId) return null; + const rootIdentifier = getRootIdentifier(callExpression.expression); + const rootSymbol = rootIdentifier + ? context.typeChecker.getSymbolAtLocation(rootIdentifier) + : null; + const refDeclaration = rootSymbol?.declarations?.find(ts.isVariableDeclaration); + if (!refDeclaration) return null; + const refLocation = getNodeLocation(refDeclaration, context.rootDirectory); + return ( + context.graph.imperativeHandleBindings.find( + (binding) => + binding.ownerId === semanticOwnerId && + areProofLocationsEqual(binding.refLocation, refLocation), + ) ?? null + ); + }; + const isModeledFormActionCallableUse = (node: ts.Node): boolean => { + let currentNode = node; + while (currentNode !== functionNode && currentNode.parent) { + if (ts.isJsxAttribute(currentNode)) { + return isModeledFormAction(currentNode, currentNode.name.getText()); + } + if (ts.isJsxSpreadAttribute(currentNode)) { + const location = getNodeLocation(currentNode, context.rootDirectory); + return Boolean( + context.graph?.formActions.some( + (action) => + action.ownerId === semanticOwnerId && + action.sourceComplete && + areProofLocationsEqual(action.location, location), + ), + ); + } + if (isFunctionBoundary(currentNode.parent)) return false; + currentNode = currentNode.parent; + } + return false; + }; + const isModeledOptimisticCallableUse = (node: ts.Node): boolean => { + let currentNode = node; + while (currentNode !== functionNode && currentNode.parent) { + const parentNode = currentNode.parent; + if ( + ts.isCallExpression(parentNode) && + parentNode.arguments.some((argument) => argument === currentNode) + ) { + const location = getNodeLocation(parentNode, context.rootDirectory); + if ( + context.graph?.optimisticStates.some( + (state) => + state.ownerId === semanticOwnerId && + state.sourceComplete && + areProofLocationsEqual(state.location, location), + ) || + context.graph?.optimisticUpdates.some( + (update) => + update.ownerId === semanticOwnerId && + update.sourceComplete && + areProofLocationsEqual(update.location, location), + ) + ) { + return true; + } + } + if (isFunctionBoundary(parentNode)) return false; + currentNode = parentNode; + } + return false; + }; + const isModeledActionStateCallableUse = (node: ts.Node): boolean => { + let currentNode = node; + while (currentNode !== functionNode && currentNode.parent) { + const parentNode = currentNode.parent; + if ( + ts.isCallExpression(parentNode) && + parentNode.arguments.some((argument) => argument === currentNode) + ) { + const location = getNodeLocation(parentNode, context.rootDirectory); + if ( + context.graph?.actionStates.some( + (state) => + state.ownerId === semanticOwnerId && + state.sourceComplete && + areProofLocationsEqual(state.location, location), + ) + ) { + return true; + } + } + if (isFunctionBoundary(parentNode)) return false; + currentNode = parentNode; + } + return false; + }; + const isModeledUseTransitionCall = (callExpression: ts.CallExpression): boolean => { + if (callExpression.arguments.length > 0) return false; + const declaration = ts.isVariableDeclaration(callExpression.parent) + ? callExpression.parent + : null; + if ( + !declaration || + declaration.initializer !== callExpression || + !ts.isArrayBindingPattern(declaration.name) || + declaration.name.elements.length > REACT_USE_TRANSITION_TUPLE_LENGTH + ) { + return false; + } + return declaration.name.elements.every( + (element) => + ts.isOmittedExpression(element) || + (ts.isBindingElement(element) && !element.dotDotDotToken && ts.isIdentifier(element.name)), + ); + }; + const getModeledTransitionAction = (node: ts.Node) => { + let actionExpression: ts.Node = node; + while ( + actionExpression.parent && + ts.isExpression(actionExpression.parent) && + unwrapTypescriptExpression(actionExpression.parent) === node + ) { + actionExpression = actionExpression.parent; + } + let callExpression: ts.CallExpression | null = ts.isCallExpression(node) ? node : null; + if ( + actionExpression.parent && + ts.isCallExpression(actionExpression.parent) && + actionExpression.parent.arguments.some((argument) => argument === actionExpression) + ) { + callExpression = actionExpression.parent; + } + if (!callExpression) return null; + const location = getNodeLocation(callExpression, context.rootDirectory); + return ( + context.graph?.transitionActions.find((action) => + areProofLocationsEqual(action.location, location), + ) ?? null + ); + }; + const isModeledExternalStorePropForwarding = ( + callExpression: ts.CallExpression, + argument: ts.Expression, + propName: string, + ): boolean => { + if ( + !context.graph || + !semanticOwnerId || + getCanonicalHookName(callExpression, context.typeChecker) !== "useSyncExternalStore" + ) { + return false; + } + const location = getNodeLocation(callExpression, context.rootDirectory); + const externalStore = context.graph.externalStores.find( + (store) => + store.ownerId === semanticOwnerId && + store.location.filePath === location.filePath && + store.location.line === location.line && + store.location.column === location.column, + ); + if (!externalStore) return false; + const argumentIndex = callExpression.arguments.indexOf(argument); + let phase: ReactExecutionPhase | null = null; + let callbackIds: ReadonlyArray = []; + let isComplete = false; + if (argumentIndex === 0) { + phase = ReactExecutionPhase.ExternalStoreSubscription; + callbackIds = externalStore.subscribeCallbackIds; + isComplete = externalStore.subscribeComplete; + } else if (argumentIndex === 1) { + phase = ReactExecutionPhase.Render; + callbackIds = externalStore.snapshotCallbackIds; + isComplete = externalStore.snapshotComplete; + } else if (argumentIndex === 2) { + phase = ReactExecutionPhase.ServerRender; + callbackIds = externalStore.serverSnapshotCallbackIds; + isComplete = externalStore.serverSnapshotComplete; + } + if (!phase || !isComplete || callbackIds.length === 0) return false; + return context.graph.callbackPropFlows.some( + (propFlow) => + propFlow.targetOwnerId === semanticOwnerId && + propFlow.propName === propName && + propFlow.phase === phase && + propFlow.complete && + propFlow.callbackIds.length > 0 && + propFlow.callbackIds.every((callbackId) => callbackIds.includes(callbackId)), + ); + }; + for (const statement of sourceFile.statements) { + if ( + ts.isImportDeclaration(statement) && + ts.isStringLiteral(statement.moduleSpecifier) && + isRuntimeImport(statement) && + !REACT_RUNTIME_MODULE_NAMES.has(statement.moduleSpecifier.text) && + !isProjectModule(statement.moduleSpecifier, context) + ) { + const moduleSource = statement.moduleSpecifier.text; + unknownEvidence.push( + createEvidence( + statement, + context.rootDirectory, + `The ${moduleSource} module has no React proof contract`, + ["application", `import ${moduleSource}`, "opaque boundary"], + ), + ); + } + } + + const unitExecutionRoots = unit.classNode + ? unit.classNode.members.filter(ts.isMethodDeclaration) + : [functionNode]; + const executionRoots = new Set(unitExecutionRoots); + const collectExecutionRoots = (node: ts.Node): void => { + if (isFunctionBoundary(node)) executionRoots.add(node); + node.forEachChild(collectExecutionRoots); + }; + for (const executionRoot of unitExecutionRoots) { + executionRoot.forEachChild(collectExecutionRoots); + } + const unmodeledCallableUseLocations = new Set(); + for (const executionRoot of executionRoots) { + const reachabilityGraph = collectReachableFunctionGraph(executionRoot, context.typeChecker); + for (const unmodeledUse of reachabilityGraph.unmodeledCallableUses) { + if (isModeledImperativeHandleUse(unmodeledUse.node)) continue; + if (getModeledTransitionAction(unmodeledUse.node)?.sourceComplete) continue; + if (isModeledFormActionCallableUse(unmodeledUse.node)) continue; + if (isModeledActionStateCallableUse(unmodeledUse.node)) continue; + if (isModeledOptimisticCallableUse(unmodeledUse.node)) continue; + const location = unmodeledUse.node.getStart(); + const locationKey = `${unmodeledUse.node.getSourceFile().fileName}:${location}`; + if (unmodeledCallableUseLocations.has(locationKey)) continue; + unmodeledCallableUseLocations.add(locationKey); + const parameterDescription = + unmodeledUse.parameterIndex === null + ? "A captured callback value" + : `Callback parameter ${unmodeledUse.parameterIndex + 1}`; + unknownEvidence.push( + createEvidence( + unmodeledUse.node, + context.rootDirectory, + `${parameterDescription} crosses an unmodeled callable-value boundary`, + [ + "source callback", + unmodeledUse.parameterIndex === null + ? "captured callable binding" + : `parameter ${unmodeledUse.parameterIndex + 1}`, + unmodeledUse.node.parent.getText(), + "unknown execution phase or lifetime", + ], + ), + ); + } + } + + const visit = (node: ts.Node): void => { + if (ts.isCallExpression(node)) { + const callName = getCallName(node); + const schedulerKind = getPlatformSchedulerKind(node, context); + if (schedulerKind) { + const schedulerLocation = getNodeLocation(node, context.rootDirectory); + const isModeledScheduler = context.graph?.schedulers.some( + (scheduler) => + scheduler.complete && areProofLocationsEqual(scheduler.location, schedulerLocation), + ); + if (!isModeledScheduler) { + unknownEvidence.push( + createEvidence( + node, + context.rootDirectory, + `${schedulerKind} crosses an unproved deferred callback or cancellation boundary`, + [schedulerKind, "deferred callback", "unknown phase or lifetime"], + ), + ); + } + } + const effectResourceKind = getPlatformEffectResourceKind(node, context.typeChecker); + if (effectResourceKind) { + const resourceLocation = getNodeLocation(node, context.rootDirectory); + const isModeledResource = context.graph?.resources.some( + (resource) => + resource.complete && + resource.activationLocations.some((activationLocation) => + areProofLocationsEqual(activationLocation, resourceLocation), + ), + ); + if (!isModeledResource) { + unknownEvidence.push( + createEvidence( + node, + context.rootDirectory, + `${effectResourceKind} crosses an unproved callback or disposal boundary`, + [effectResourceKind, "deferred callback", "unknown phase or lifetime"], + ), + ); + } + } + const canonicalReactApiName = getCanonicalReactApiName(node.expression, context.typeChecker); + const isModeledContextRead = + canonicalReactApiName === "use" && + Boolean( + node.arguments[0] && isReactContextExpression(node.arguments[0], context.typeChecker), + ); + const nodeLocation = getNodeLocation(node, context.rootDirectory); + const isModeledUseResource = + canonicalReactApiName === "use" && + Boolean( + context.graph?.useResources.some( + (resource) => + resource.ownerId === semanticOwnerId && + resource.complete && + areProofLocationsEqual(resource.location, nodeLocation), + ), + ); + if ( + canonicalReactApiName && + REACT_UNMODELED_HOOK_NAMES.has(canonicalReactApiName) && + !isModeledContextRead && + !isModeledUseResource && + !(canonicalReactApiName === "useActionState" && isModeledActionStateCall(node)) && + !(canonicalReactApiName === "useTransition" && isModeledUseTransitionCall(node)) && + !(canonicalReactApiName === "useOptimistic" && isModeledOptimisticCall(node)) && + !(canonicalReactApiName === "useImperativeHandle" && isModeledImperativeHandleCall(node)) + ) { + unknownEvidence.push( + createEvidence( + node, + context.rootDirectory, + `${canonicalReactApiName} does not yet have a complete lifecycle model`, + ["render", canonicalReactApiName, "unmodeled React primitive"], + ), + ); + } + const transitionAction = getModeledTransitionAction(node); + if (transitionAction && !transitionAction.sourceComplete) { + unknownEvidence.push( + createEvidence( + node, + context.rootDirectory, + "A Transition Action crosses an unproved callback, async, or state-priority boundary", + ["Transition Action", transitionAction.status, "incomplete execution model"], + ), + ); + } + if (callName === "eval" || node.expression.kind === ts.SyntaxKind.ImportKeyword) { + unknownEvidence.push( + createEvidence( + node, + context.rootDirectory, + `${callName ?? "dynamic import"} prevents closed-world analysis`, + ["application", callName ?? "dynamic import", "dynamic code boundary"], + ), + ); + } + if ( + ts.isPropertyAccessExpression(node.expression) && + getCallableRefProtocolForCurrentAccess(node.expression, context.typeChecker) && + !isCompleteCallableRefAccess(node.expression) + ) { + unknownEvidence.push( + createEvidence( + node, + context.rootDirectory, + "A callable ref is invoked without a temporal freshness and ownership proof", + ["callable ref", node.getText(), "unknown callback version or lifetime"], + ), + ); + } + const imperativeHandleBinding = getImperativeHandleBinding(node); + if ( + imperativeHandleBinding && + !context.graph?.imperativeHandleInvocations.some( + (invocation) => + invocation.bindingId === imperativeHandleBinding.id && + invocation.complete && + areProofLocationsEqual( + invocation.location, + getNodeLocation(node, context.rootDirectory), + ), + ) + ) { + unknownEvidence.push( + createEvidence( + node, + context.rootDirectory, + "An imperative handle method is invoked without a closed ref and execution-phase protocol", + ["imperative ref", node.getText(), "unknown handle method or lifetime"], + ), + ); + } + const callbackPropName = isComponentUnit + ? getComponentPropName(node.expression, functionNode, context.typeChecker) + : null; + if (callbackPropName && !isModeledCallbackPropInvocation(node, callbackPropName)) { + unknownEvidence.push( + createEvidence( + node, + context.rootDirectory, + `Callback prop ${callbackPropName} is invoked without a modeled React execution-phase channel`, + [ + `component prop ${callbackPropName}`, + node.getText(), + "unknown execution phase or lifetime", + ], + ), + ); + } else if ( + !callbackPropName && + isComponentUnit && + isComponentPropExpression(node.expression, functionNode, context.typeChecker) + ) { + unknownEvidence.push( + createEvidence( + node, + context.rootDirectory, + "A computed callback prop is invoked without a statically named phase channel", + ["component callback prop", node.getText(), "unknown prop identity or lifetime"], + ), + ); + } + for (const argument of node.arguments) { + if (isModeledImperativeHandleUse(argument)) continue; + const forwardedCallbackPropName = isComponentUnit + ? getComponentPropName(argument, functionNode, context.typeChecker) + : null; + if ( + !forwardedCallbackPropName || + !doesTypeContainCallable( + context.typeChecker.getTypeAtLocation(argument), + context.typeChecker, + ) + ) { + continue; + } + if (isModeledExternalStorePropForwarding(node, argument, forwardedCallbackPropName)) { + continue; + } + unknownEvidence.push( + createEvidence( + argument, + context.rootDirectory, + `Callback prop ${forwardedCallbackPropName} is forwarded without a modeled React execution-phase channel`, + [ + `component prop ${forwardedCallbackPropName}`, + node.getText(), + "unknown execution phase or lifetime", + ], + ), + ); + } + } + if ( + ts.isBinaryExpression(node) && + isAssignmentOperator(node.operatorToken.kind) && + ts.isPropertyAccessExpression(node.left) && + !isCompleteCallableRefAccess(node.left) && + doesTypeContainCallable(context.typeChecker.getTypeAtLocation(node.left), context.typeChecker) + ) { + unknownEvidence.push( + createEvidence( + node, + context.rootDirectory, + `Callable property ${node.left.getText()} is mutated without an SSA value proof`, + [node.left.getText(), "callable property write", "unknown subsequent target"], + ), + ); + } + if ( + ts.isJsxAttribute(node) && + REACT_EVENT_PROP_PATTERN.test(node.name.getText()) && + node.initializer && + isEffectiveJsxPropertySource(node, node.name.getText(), context.typeChecker) + ) { + if (!isCompleteEventFlow(node, node.name.getText())) { + unknownEvidence.push( + createEvidence( + node, + context.rootDirectory, + `${node.name.getText()} does not resolve to a project event callback`, + ["committed tree", node.name.getText(), "opaque event callback"], + ), + ); + } + } + if ( + ts.isJsxAttribute(node) && + isIntrinsicJsxElement(node.parent.parent) && + (node.name.getText() === "action" || node.name.getText() === "formAction") && + node.initializer && + ts.isJsxExpression(node.initializer) && + node.initializer.expression && + doesTypeContainCallable( + context.typeChecker.getTypeAtLocation(node.initializer.expression), + context.typeChecker, + ) && + isEffectiveJsxPropertySource(node, node.name.getText(), context.typeChecker) && + !isModeledFormAction(node, node.name.getText()) + ) { + unknownEvidence.push( + createEvidence( + node, + context.rootDirectory, + `${node.name.getText()} does not resolve to a complete intrinsic Form Action`, + ["committed form", node.name.getText(), "opaque Action callback or control"], + ), + ); + } + if (ts.isJsxSpreadAttribute(node)) { + const spreadProperties = collectJsxSpreadProperties(node.expression, context.typeChecker); + const openingElement = node.parent.parent; + if (!isJsxSpreadSourceComplete(node.expression, functionNode, context.typeChecker)) { + unknownEvidence.push( + createEvidence( + node, + context.rootDirectory, + "A JSX spread source does not have an immutable finite object proof", + ["JSX props", node.getText(), "unknown object evaluation or mutation"], + ), + ); + } + if (spreadProperties.hasUnknownProperties && isIntrinsicJsxElement(openingElement)) { + unknownEvidence.push( + createEvidence( + node, + context.rootDirectory, + "A JSX spread has an open-ended property set that can override callback props", + ["JSX props", node.getText(), "unknown callback property or precedence"], + ), + ); + } + for (const eventName of spreadProperties.callablePropertyNames.filter((propertyName) => + REACT_EVENT_PROP_PATTERN.test(propertyName), + )) { + if (!isEffectiveJsxPropertySource(node, eventName, context.typeChecker)) continue; + if (isCompleteEventFlow(node, eventName)) continue; + unknownEvidence.push( + createEvidence( + node, + context.rootDirectory, + `${eventName} from a JSX spread does not resolve to a project event callback`, + ["committed tree", eventName, "opaque spread event callback"], + ), + ); + } + if (isIntrinsicJsxElement(openingElement)) { + for (const actionPropName of spreadProperties.callablePropertyNames.filter( + (propertyName) => propertyName === "action" || propertyName === "formAction", + )) { + if (!isEffectiveJsxPropertySource(node, actionPropName, context.typeChecker)) continue; + if (isModeledFormAction(node, actionPropName)) continue; + unknownEvidence.push( + createEvidence( + node, + context.rootDirectory, + `${actionPropName} from a JSX spread does not resolve to a complete intrinsic Form Action`, + ["committed form", actionPropName, "opaque spread Action callback"], + ), + ); + } + } + } + node.forEachChild(visit); + }; + for (const executionRoot of unitExecutionRoots) { + executionRoot.forEachChild(visit); + } + const semanticUnit = findSemanticUnit(unit, context); + const classLifecycle = semanticUnit + ? context.graph?.classLifecycles.find((lifecycle) => lifecycle.ownerId === semanticUnit.id) + : null; + if (unit.kind === ReactUnitKind.ClassComponent && !classLifecycle?.sourceComplete) { + unknownEvidence.push( + createEvidence( + unit.classNode ?? unit.node, + context.rootDirectory, + "The class lifecycle contains an unmodeled method or ownership transition", + ["class lifecycle", "unmodeled execution", "unknown phase or lifetime"], + ), + ); + } + + if (unknownEvidence.length > 0) { + return createObligation( + ReactProofClaim.BoundaryCoverage, + ReactObligationStatus.Unknown, + "The unit crosses a React or external boundary without a complete proof model", + unknownEvidence, + ); + } + return createObligation( + ReactProofClaim.BoundaryCoverage, + ReactObligationStatus.Proved, + "Every reachable React and module boundary has a proof model", + ); +}; diff --git a/packages/prover/src/analyze-callable-ref-freshness.ts b/packages/prover/src/analyze-callable-ref-freshness.ts new file mode 100644 index 000000000..0d195763f --- /dev/null +++ b/packages/prover/src/analyze-callable-ref-freshness.ts @@ -0,0 +1,64 @@ +import { collectCallableRefProtocols } from "./collect-callable-ref-protocols.js"; +import { createEvidence } from "./create-evidence.js"; +import { createObligation } from "./create-obligation.js"; +import { findSemanticUnit } from "./find-semantic-unit.js"; +import { getNodeLocation } from "./get-node-location.js"; +import { ReactCallableRefFreshness, ReactObligationStatus, ReactProofClaim } from "./types.js"; +import { areProofLocationsEqual } from "./utils/are-proof-locations-equal.js"; +import type { + ReactAnalysisContext, + ReactProofEvidence, + ReactProofObligation, + ReactUnitDescriptor, +} from "./types.js"; + +export const analyzeCallableRefFreshness = ( + unit: ReactUnitDescriptor, + context: ReactAnalysisContext, +): ReactProofObligation => { + const functionNode = unit.functionNode; + const semanticOwnerId = findSemanticUnit(unit, context)?.id; + if (!functionNode || !context.graph || !semanticOwnerId) { + return createObligation( + ReactProofClaim.CallableRefFreshness, + ReactObligationStatus.Unknown, + "Callable ref freshness has no semantic owner", + ); + } + const callableRefs = context.graph.callableRefs.filter( + (callableRef) => callableRef.ownerId === semanticOwnerId, + ); + const unknownEvidence: ReactProofEvidence[] = []; + for (const protocol of collectCallableRefProtocols(functionNode, context.typeChecker)) { + const protocolLocation = getNodeLocation(protocol.declaration, context.rootDirectory); + const callableRef = callableRefs.find((candidate) => + areProofLocationsEqual(candidate.location, protocolLocation), + ); + if (callableRef?.complete) continue; + const description = + callableRef?.freshness === ReactCallableRefFreshness.PassiveLag + ? `${protocol.refName} is updated by a passive Effect after committed UI can become observable` + : `${protocol.refName} does not have a complete render, commit, and event freshness proof`; + unknownEvidence.push( + createEvidence(protocol.declaration, context.rootDirectory, description, [ + `callable ref ${protocol.refName}`, + protocol.updateHookName ?? "unknown write phase", + "callback invocation", + "unknown committed callback version", + ]), + ); + } + if (unknownEvidence.length > 0) { + return createObligation( + ReactProofClaim.CallableRefFreshness, + ReactObligationStatus.Unknown, + "A callable ref may expose an unknown or stale callback version", + unknownEvidence, + ); + } + return createObligation( + ReactProofClaim.CallableRefFreshness, + ReactObligationStatus.Proved, + "Every callable ref is synchronized before its modeled event channels", + ); +}; diff --git a/packages/prover/src/analyze-class-construction.ts b/packages/prover/src/analyze-class-construction.ts new file mode 100644 index 000000000..c28fccdcf --- /dev/null +++ b/packages/prover/src/analyze-class-construction.ts @@ -0,0 +1,106 @@ +import { createObligation } from "./create-obligation.js"; +import { findSemanticUnit } from "./find-semantic-unit.js"; +import { + ReactClassConstructionIssueKind, + ReactClassConstructionIssueStatus, + ReactClassConstructionStatus, + ReactObligationStatus, + ReactProofClaim, + ReactUnitKind, +} from "./types.js"; +import type { + ReactAnalysisContext, + ReactProofEvidence, + ReactProofObligation, + ReactUnitDescriptor, +} from "./types.js"; + +const getIssueDescription = (issueKind: ReactClassConstructionIssueKind): string => { + if (issueKind === ReactClassConstructionIssueKind.InvalidStateValue) { + return "Class state is initialized with a value that is not an object"; + } + if (issueKind === ReactClassConstructionIssueKind.InvalidSuperCall) { + return "The constructor does not call super with its props before every other statement"; + } + if (issueKind === ReactClassConstructionIssueKind.MissingStateInitialization) { + return "The class reads state without a proved initialization"; + } + if (issueKind === ReactClassConstructionIssueKind.MultipleStateInitializations) { + return "Multiple class state initialization paths require an ordering proof"; + } + if (issueKind === ReactClassConstructionIssueKind.SetStateCall) { + return "The constructor calls setState instead of initializing state directly"; + } + if (issueKind === ReactClassConstructionIssueKind.SideEffect) { + return "Class construction contains an observable or non-idempotent operation"; + } + if (issueKind === ReactClassConstructionIssueKind.UnsupportedConstructorStatement) { + return "A constructor statement has no construction proof"; + } + return "A class field initializer contains an expression with no purity proof"; +}; + +export const analyzeClassConstruction = ( + unit: ReactUnitDescriptor, + context: ReactAnalysisContext, +): ReactProofObligation => { + if (unit.kind !== ReactUnitKind.ClassComponent) { + return createObligation( + ReactProofClaim.ClassConstruction, + ReactObligationStatus.Proved, + "Function units have no class construction phase", + ); + } + const semanticOwnerId = findSemanticUnit(unit, context)?.id; + const construction = context.graph?.classConstructions.find( + (candidate) => candidate.ownerId === semanticOwnerId, + ); + if (!construction) { + return createObligation( + ReactProofClaim.ClassConstruction, + ReactObligationStatus.Unknown, + "Class construction has no semantic certificate", + ); + } + const violatedEvidence: ReactProofEvidence[] = []; + const unknownEvidence: ReactProofEvidence[] = []; + for (const issue of construction.issues) { + const evidence = { + description: getIssueDescription(issue.kind), + location: issue.location, + trace: [ + "class construction", + issue.kind, + issue.status === ReactClassConstructionIssueStatus.Violated + ? "React construction invariant violated" + : "construction proof incomplete", + ], + }; + if (issue.status === ReactClassConstructionIssueStatus.Violated) { + violatedEvidence.push(evidence); + } else { + unknownEvidence.push(evidence); + } + } + if (construction.status === ReactClassConstructionStatus.Invalid || violatedEvidence.length > 0) { + return createObligation( + ReactProofClaim.ClassConstruction, + ReactObligationStatus.Violated, + "Class construction violates initialization, purity, or superclass ordering", + violatedEvidence, + ); + } + if (construction.status === ReactClassConstructionStatus.Unknown || unknownEvidence.length > 0) { + return createObligation( + ReactProofClaim.ClassConstruction, + ReactObligationStatus.Unknown, + "Class construction purity or state initialization could not be proved", + unknownEvidence, + ); + } + return createObligation( + ReactProofClaim.ClassConstruction, + ReactObligationStatus.Proved, + "Class construction is pure, ordered, and initializes required state", + ); +}; diff --git a/packages/prover/src/analyze-class-state-transitions.ts b/packages/prover/src/analyze-class-state-transitions.ts new file mode 100644 index 000000000..7f30c6c6f --- /dev/null +++ b/packages/prover/src/analyze-class-state-transitions.ts @@ -0,0 +1,126 @@ +import { createObligation } from "./create-obligation.js"; +import { findSemanticUnit } from "./find-semantic-unit.js"; +import { + ReactClassStateUpdaterStatus, + ReactClassStateWriteStatus, + ReactClassUpdateCycleStatus, + ReactObligationStatus, + ReactProofClaim, + ReactUnitKind, +} from "./types.js"; +import type { + ReactAnalysisContext, + ReactProofEvidence, + ReactProofObligation, + ReactUnitDescriptor, +} from "./types.js"; + +export const analyzeClassStateTransitions = ( + unit: ReactUnitDescriptor, + context: ReactAnalysisContext, +): ReactProofObligation => { + if (unit.kind !== ReactUnitKind.ClassComponent) { + return createObligation( + ReactProofClaim.ClassStateTransitions, + ReactObligationStatus.Proved, + "The React unit has no class state transitions", + ); + } + const semanticOwnerId = findSemanticUnit(unit, context)?.id; + if (!context.graph || !semanticOwnerId) { + return createObligation( + ReactProofClaim.ClassStateTransitions, + ReactObligationStatus.Unknown, + "Class state transitions have no semantic owner", + ); + } + const lifecycle = context.graph.classLifecycles.find( + (candidate) => candidate.ownerId === semanticOwnerId, + ); + const transitions = context.graph.classStateTransitions.filter( + (transition) => transition.ownerId === semanticOwnerId, + ); + const stateWrites = context.graph.classStateWrites.filter( + (stateWrite) => stateWrite.ownerId === semanticOwnerId, + ); + const violations: ReactProofEvidence[] = []; + const unknownEvidence: ReactProofEvidence[] = []; + for (const stateWrite of stateWrites) { + const trace = [stateWrite.phase, "this.state", stateWrite.kind, stateWrite.status]; + if (stateWrite.status === ReactClassStateWriteStatus.Forbidden) { + violations.push({ + description: "Class state is mutated directly outside construction", + location: stateWrite.location, + trace, + }); + } else { + unknownEvidence.push({ + description: "A class state reference escapes the modeled ownership boundary", + location: stateWrite.location, + trace, + }); + } + } + for (const transition of transitions) { + const trace = [ + transition.phase, + "this.setState", + transition.updaterStatus, + transition.cycleStatus, + ]; + if (transition.updaterStatus === ReactClassStateUpdaterStatus.Impure) { + violations.push({ + description: "A setState updater performs an observable side effect", + location: transition.location, + trace, + }); + continue; + } + if (transition.cycleStatus === ReactClassUpdateCycleStatus.Guaranteed) { + violations.push({ + description: "An entry-dominating componentDidUpdate state write guarantees another update", + location: transition.location, + trace, + }); + continue; + } + if (!transition.complete) { + unknownEvidence.push({ + description: + transition.cycleStatus === ReactClassUpdateCycleStatus.Unknown + ? "The componentDidUpdate state transition has no proved convergence guard" + : "The setState updater or commit callback is not completely modeled", + location: transition.location, + trace, + }); + } + } + if (lifecycle && !lifecycle.sourceComplete) { + unknownEvidence.push({ + description: "The class lifecycle contains an unmodeled state transition or method call", + location: lifecycle.location, + trace: ["class lifecycle", "unmodeled execution", "state transition completeness unknown"], + }); + } + if (violations.length > 0) { + return createObligation( + ReactProofClaim.ClassStateTransitions, + ReactObligationStatus.Violated, + "Class state ownership, updater purity, or update convergence is violated", + violations, + ); + } + if (!lifecycle?.sourceComplete || unknownEvidence.length > 0) { + return createObligation( + ReactProofClaim.ClassStateTransitions, + ReactObligationStatus.Unknown, + "Class state ownership, transition purity, or convergence could not be proved", + unknownEvidence, + ); + } + return createObligation( + ReactProofClaim.ClassStateTransitions, + ReactObligationStatus.Proved, + "Class state is React-owned, every updater is pure, and every update transition is bounded", + ); +}; diff --git a/packages/prover/src/analyze-component-identity.ts b/packages/prover/src/analyze-component-identity.ts new file mode 100644 index 000000000..84d97c604 --- /dev/null +++ b/packages/prover/src/analyze-component-identity.ts @@ -0,0 +1,46 @@ +import ts from "typescript"; +import { containsJsx } from "./contains-jsx.js"; +import { createEvidence } from "./create-evidence.js"; +import { createObligation } from "./create-obligation.js"; +import { getFunctionName } from "./get-function-name.js"; +import { isFunctionBoundary } from "./is-function-boundary.js"; +import { ReactObligationStatus, ReactProofClaim } from "./types.js"; +import type { ReactAnalysisContext, ReactProofEvidence, ReactProofObligation } from "./types.js"; + +export const analyzeComponentIdentity = ( + functionNode: ts.FunctionLikeDeclaration, + context: ReactAnalysisContext, +): ReactProofObligation => { + const violations: ReactProofEvidence[] = []; + const visit = (node: ts.Node): void => { + if (node !== functionNode && isFunctionBoundary(node)) { + const functionName = getFunctionName(node); + if (functionName && /^[A-Z]/.test(functionName) && containsJsx(node)) { + violations.push( + createEvidence( + node, + context.rootDirectory, + `${functionName} is recreated as a component type during render`, + ["component render", `create component type ${functionName}`, "reconciliation"], + ), + ); + } + return; + } + node.forEachChild(visit); + }; + functionNode.forEachChild(visit); + if (violations.length > 0) { + return createObligation( + ReactProofClaim.ComponentIdentity, + ReactObligationStatus.Violated, + "A component type is created inside another component or hook", + violations, + ); + } + return createObligation( + ReactProofClaim.ComponentIdentity, + ReactObligationStatus.Proved, + "Component type identities are stable across renders", + ); +}; diff --git a/packages/prover/src/analyze-component-invocation.ts b/packages/prover/src/analyze-component-invocation.ts new file mode 100644 index 000000000..7ddbbf7ce --- /dev/null +++ b/packages/prover/src/analyze-component-invocation.ts @@ -0,0 +1,71 @@ +import ts from "typescript"; +import { containsJsx } from "./contains-jsx.js"; +import { createEvidence } from "./create-evidence.js"; +import { createObligation } from "./create-obligation.js"; +import { getCallName } from "./get-call-name.js"; +import { isFunctionBoundary } from "./is-function-boundary.js"; +import { ReactObligationStatus, ReactProofClaim } from "./types.js"; +import type { ReactAnalysisContext, ReactProofEvidence, ReactProofObligation } from "./types.js"; + +export const analyzeComponentInvocation = ( + functionNode: ts.FunctionLikeDeclaration, + context: ReactAnalysisContext, +): ReactProofObligation => { + const violations: ReactProofEvidence[] = []; + const visit = (node: ts.Node): void => { + if (node !== functionNode && isFunctionBoundary(node)) { + return; + } + if (ts.isCallExpression(node)) { + const callName = getCallName(node)?.split(".").at(-1); + const callSymbol = context.typeChecker.getSymbolAtLocation(node.expression); + const resolvedSymbol = + callSymbol && (callSymbol.flags & ts.SymbolFlags.Alias) !== 0 + ? context.typeChecker.getAliasedSymbol(callSymbol) + : callSymbol; + const isComponentCall = Boolean( + callName && + /^[A-Z]/.test(callName) && + resolvedSymbol?.declarations?.some((declaration) => { + if (isFunctionBoundary(declaration)) { + return containsJsx(declaration); + } + if ( + ts.isVariableDeclaration(declaration) && + declaration.initializer && + (ts.isFunctionExpression(declaration.initializer) || + ts.isArrowFunction(declaration.initializer)) + ) { + return containsJsx(declaration.initializer); + } + return false; + }), + ); + if (callName && isComponentCall) { + violations.push( + createEvidence( + node, + context.rootDirectory, + `${callName} is called as a regular function instead of rendered by React`, + ["render", `call ${callName}`, "hook and component ownership bypassed"], + ), + ); + } + } + node.forEachChild(visit); + }; + functionNode.forEachChild(visit); + if (violations.length > 0) { + return createObligation( + ReactProofClaim.ComponentInvocation, + ReactObligationStatus.Violated, + "A component is invoked outside React reconciliation", + violations, + ); + } + return createObligation( + ReactProofClaim.ComponentInvocation, + ReactObligationStatus.Proved, + "No component function is invoked directly", + ); +}; diff --git a/packages/prover/src/analyze-context-topology.ts b/packages/prover/src/analyze-context-topology.ts new file mode 100644 index 000000000..be7d5b4df --- /dev/null +++ b/packages/prover/src/analyze-context-topology.ts @@ -0,0 +1,106 @@ +import { createObligation } from "./create-obligation.js"; +import { findSemanticUnit } from "./find-semantic-unit.js"; +import { ReactObligationStatus, ReactProofClaim } from "./types.js"; +import type { + ReactAnalysisContext, + ReactProofEvidence, + ReactProofLocation, + ReactProofObligation, + ReactUnitDescriptor, +} from "./types.js"; + +const createGraphEvidence = ( + location: ReactProofLocation, + description: string, + trace: ReadonlyArray, +): ReactProofEvidence => ({ description, location, trace }); + +export const analyzeContextTopology = ( + unit: ReactUnitDescriptor, + context: ReactAnalysisContext, +): ReactProofObligation => { + const semanticUnit = findSemanticUnit(unit, context); + if (!context.graph || !semanticUnit) { + return createObligation( + ReactProofClaim.ContextTopology, + ReactObligationStatus.Unknown, + "The semantic graph could not identify this context owner", + [], + ); + } + + const providers = context.graph.contextProviders.filter( + (provider) => provider.ownerId === semanticUnit.id, + ); + const consumers = context.graph.contextConsumers.filter( + (consumer) => consumer.ownerId === semanticUnit.id, + ); + const providersById = new Map( + context.graph.contextProviders.map((provider) => [provider.id, provider]), + ); + const missingValueProviders = providers.filter((provider) => !provider.valueProvided); + const consumersWithMissingValueSources = consumers.filter((consumer) => + consumer.sourceProviderIds.some((providerId) => !providersById.get(providerId)?.valueProvided), + ); + if (missingValueProviders.length > 0 || consumersWithMissingValueSources.length > 0) { + const evidence = [ + ...missingValueProviders.map((provider) => + createGraphEvidence( + provider.location, + "A context provider does not supply its required value", + [ + "context provider", + "missing value prop", + "consumers receive undefined instead of the declared value", + ], + ), + ), + ...consumersWithMissingValueSources.map((consumer) => + createGraphEvidence( + consumer.location, + "A context consumer can resolve to a provider without a value", + [consumer.hookName, "nearest matching provider", "missing provider value"], + ), + ), + ]; + return createObligation( + ReactProofClaim.ContextTopology, + ReactObligationStatus.Violated, + "A context provider-consumer path has no valid value", + evidence, + ); + } + + const unresolvedConsumers = consumers.filter( + (consumer) => !consumer.contextId || !consumer.topologyComplete, + ); + if (unresolvedConsumers.length > 0) { + return createObligation( + ReactProofClaim.ContextTopology, + ReactObligationStatus.Unknown, + "A context consumer has unresolved provider topology", + unresolvedConsumers.map((consumer) => + createGraphEvidence( + consumer.location, + consumer.contextId + ? "No closed render path reaches this context consumer" + : "The context object could not be resolved to a project definition", + [ + consumer.hookName, + "exact context object identity", + "nearest-provider proof is incomplete", + ], + ), + ), + ); + } + + return createObligation( + ReactProofClaim.ContextTopology, + ReactObligationStatus.Proved, + consumers.length > 0 + ? "Every context read resolves through exact object identity and closed render paths" + : "Every context provider is well-formed", + [], + ); +}; diff --git a/packages/prover/src/analyze-effect-cleanup.ts b/packages/prover/src/analyze-effect-cleanup.ts new file mode 100644 index 000000000..89741b8bc --- /dev/null +++ b/packages/prover/src/analyze-effect-cleanup.ts @@ -0,0 +1,94 @@ +import { createObligation } from "./create-obligation.js"; +import { findSemanticUnit } from "./find-semantic-unit.js"; +import { + ReactEffectResourceDisposalStatus, + ReactObligationStatus, + ReactProofClaim, +} from "./types.js"; +import type { + ReactAnalysisContext, + ReactProofEvidence, + ReactProofObligation, + ReactUnitDescriptor, +} from "./types.js"; + +export const analyzeEffectCleanup = ( + unit: ReactUnitDescriptor, + context: ReactAnalysisContext, +): ReactProofObligation => { + const functionNode = unit.functionNode; + const semanticOwnerId = findSemanticUnit(unit, context)?.id; + if (!functionNode || !context.graph || !semanticOwnerId) { + return createObligation( + ReactProofClaim.EffectCleanup, + ReactObligationStatus.Unknown, + "Effect resource ownership has no semantic owner", + ); + } + const effects = context.graph.effects.filter((effect) => effect.ownerId === semanticOwnerId); + const resources = context.graph.resources.filter( + (resource) => resource.ownerId === semanticOwnerId, + ); + const violations: ReactProofEvidence[] = []; + const unknownEvidence: ReactProofEvidence[] = []; + for (const effect of effects) { + if (effect.callbackResolved) continue; + unknownEvidence.push({ + description: "The effect callback cannot be resolved for lifecycle analysis", + location: effect.location, + trace: ["effect setup", "opaque callback", "effect cleanup"], + }); + } + for (const resource of resources) { + const lifecycleKind = resource.effectId ? "effect" : "class lifecycle"; + const evidence: ReactProofEvidence = { + description: + resource.disposalStatus === ReactEffectResourceDisposalStatus.Missing + ? `${resource.kind} has no cleanup with the same resource identity` + : `${resource.kind} is path-dependent or has no complete callback and disposal certificate`, + location: resource.location, + trace: [ + `${lifecycleKind} setup`, + resource.kind, + "deferred callback", + `${lifecycleKind} cleanup or replacement`, + ], + }; + if (resource.disposalStatus === ReactEffectResourceDisposalStatus.Missing) { + violations.push(evidence); + } else if (!resource.complete) { + unknownEvidence.push(evidence); + } + } + const classLifecycle = context.graph.classLifecycles.find( + (lifecycle) => lifecycle.ownerId === semanticOwnerId, + ); + if (classLifecycle && !classLifecycle.sourceComplete) { + unknownEvidence.push({ + description: "The class lifecycle contains an unmodeled method or ownership transition", + location: classLifecycle.location, + trace: ["class lifecycle", "unmodeled execution", "cleanup completeness unknown"], + }); + } + if (violations.length > 0) { + return createObligation( + ReactProofClaim.EffectCleanup, + ReactObligationStatus.Violated, + "A lifecycle resource can remain active after cleanup or unmount", + violations, + ); + } + if (unknownEvidence.length > 0) { + return createObligation( + ReactProofClaim.EffectCleanup, + ReactObligationStatus.Unknown, + "A lifecycle resource callback or disposal path could not be proved", + unknownEvidence, + ); + } + return createObligation( + ReactProofClaim.EffectCleanup, + ReactObligationStatus.Proved, + "Every modeled lifecycle resource has a deferred callback and guaranteed disposal", + ); +}; diff --git a/packages/prover/src/analyze-effect-dependencies.ts b/packages/prover/src/analyze-effect-dependencies.ts new file mode 100644 index 000000000..d6f88156f --- /dev/null +++ b/packages/prover/src/analyze-effect-dependencies.ts @@ -0,0 +1,94 @@ +import ts from "typescript"; +import { collectEffectCalls } from "./collect-effect-calls.js"; +import { collectHookBindings } from "./collect-hook-bindings.js"; +import { collectReactiveCaptures } from "./collect-reactive-captures.js"; +import { createEvidence } from "./create-evidence.js"; +import { createObligation } from "./create-obligation.js"; +import { getEffectCallback } from "./get-effect-callback.js"; +import { ReactObligationStatus, ReactProofClaim } from "./types.js"; +import { isReactiveCaptureDeclared } from "./utils/is-reactive-capture-declared.js"; +import type { ReactAnalysisContext, ReactProofEvidence, ReactProofObligation } from "./types.js"; + +export const analyzeEffectDependencies = ( + functionNode: ts.FunctionLikeDeclaration, + context: ReactAnalysisContext, +): ReactProofObligation => { + const hookBindings = collectHookBindings(functionNode, context.typeChecker); + const stableSymbols = new Set([ + ...hookBindings.effectEvents, + ...hookBindings.refs, + ...hookBindings.stateSetters, + ]); + const violations: ReactProofEvidence[] = []; + const unknownEvidence: ReactProofEvidence[] = []; + + for (const effectCall of collectEffectCalls(functionNode, context.typeChecker)) { + const effectCallback = getEffectCallback(effectCall, context.typeChecker); + if (!effectCallback) { + unknownEvidence.push( + createEvidence( + effectCall, + context.rootDirectory, + "The effect callback cannot be resolved", + ["render", "register effect", "opaque callback"], + ), + ); + continue; + } + const dependenciesExpression = effectCall.arguments[1]; + if (!dependenciesExpression) continue; + if (!ts.isArrayLiteralExpression(dependenciesExpression)) { + unknownEvidence.push( + createEvidence( + dependenciesExpression, + context.rootDirectory, + "The effect dependency list is not an inline tuple", + ["render", "register effect", "dynamic dependency list"], + ), + ); + continue; + } + const declaredDependencies = dependenciesExpression.elements.map((dependency) => + dependency.getText(), + ); + const captures = collectReactiveCaptures( + effectCallback, + functionNode, + context.typeChecker, + stableSymbols, + ); + for (const { key: captureKey, node: captureNode } of captures) { + if (isReactiveCaptureDeclared(captureKey, declaredDependencies)) continue; + violations.push( + createEvidence( + captureNode, + context.rootDirectory, + `${captureKey} is reactive but absent from the effect dependency list`, + ["render capture", captureKey, "effect callback", "stale value"], + ), + ); + } + } + + if (violations.length > 0) { + return createObligation( + ReactProofClaim.EffectDependencies, + ReactObligationStatus.Violated, + "An effect can observe a stale reactive value", + violations, + ); + } + if (unknownEvidence.length > 0) { + return createObligation( + ReactProofClaim.EffectDependencies, + ReactObligationStatus.Unknown, + "Effect closure completeness could not be proved", + unknownEvidence, + ); + } + return createObligation( + ReactProofClaim.EffectDependencies, + ReactObligationStatus.Proved, + "Every effect capture is reactive, stable, or represented by a dependency", + ); +}; diff --git a/packages/prover/src/analyze-effect-event-usage.ts b/packages/prover/src/analyze-effect-event-usage.ts new file mode 100644 index 000000000..c399ef5ee --- /dev/null +++ b/packages/prover/src/analyze-effect-event-usage.ts @@ -0,0 +1,221 @@ +import ts from "typescript"; +import { collectEffectCleanupFunctions } from "./collect-effect-cleanup-functions.js"; +import { collectEffectCalls } from "./collect-effect-calls.js"; +import { collectEffectEventBindings } from "./collect-effect-event-bindings.js"; +import { collectEventCallbackFunctions } from "./collect-event-callback-functions.js"; +import { collectReachableFunctions } from "./collect-reachable-functions.js"; +import { EFFECT_EVENT_REGISTRATION_CALL_NAMES } from "./constants.js"; +import { createEvidence } from "./create-evidence.js"; +import { createObligation } from "./create-obligation.js"; +import { getCallName } from "./get-call-name.js"; +import { getCanonicalHookName } from "./get-canonical-hook-name.js"; +import { getEffectCallback } from "./get-effect-callback.js"; +import { isIdentifierReference } from "./is-identifier-reference.js"; +import { isFunctionBoundary } from "./is-function-boundary.js"; +import { isNodeWithin } from "./is-node-within.js"; +import { ReactObligationStatus, ReactProofClaim } from "./types.js"; +import { getEnclosingFunction } from "./utils/get-enclosing-function.js"; +import type { ReactAnalysisContext, ReactProofEvidence, ReactProofObligation } from "./types.js"; + +const isEffectDependencyReference = ( + identifier: ts.Identifier, + effectCalls: ReadonlyArray, +): boolean => + effectCalls.some((effectCall) => { + const dependencyExpression = effectCall.arguments[1]; + return Boolean(dependencyExpression && isNodeWithin(identifier, dependencyExpression)); + }); + +const isDirectInvocation = (identifier: ts.Identifier): boolean => + ts.isCallExpression(identifier.parent) && identifier.parent.expression === identifier; + +const isRegistrationArgument = (identifier: ts.Identifier): boolean => { + const callExpression = identifier.parent; + if (!ts.isCallExpression(callExpression) || !callExpression.arguments.includes(identifier)) { + return false; + } + const callName = getCallName(callExpression)?.split(".").at(-1); + return Boolean(callName && EFFECT_EVENT_REGISTRATION_CALL_NAMES.has(callName)); +}; + +const getWrapperName = ( + functionNode: ts.FunctionLikeDeclaration, + typeChecker: ts.TypeChecker, +): string | null => { + if (!ts.isCallExpression(functionNode.parent)) return null; + const directSymbol = typeChecker.getSymbolAtLocation(functionNode.parent.expression); + const wrapperSymbol = + directSymbol && (directSymbol.flags & ts.SymbolFlags.Alias) !== 0 + ? typeChecker.getAliasedSymbol(directSymbol) + : directSymbol; + return wrapperSymbol?.name ?? getCallName(functionNode.parent)?.split(".").at(-1) ?? null; +}; + +const collectContextValueSymbols = ( + functionNode: ts.FunctionLikeDeclaration, + typeChecker: ts.TypeChecker, +): ReadonlySet => { + const contextValueSymbols = new Set(); + const visit = (node: ts.Node): void => { + if (node !== functionNode && isFunctionBoundary(node)) return; + if ( + ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + node.initializer && + ts.isCallExpression(node.initializer) && + getCanonicalHookName(node.initializer, typeChecker) === "useContext" + ) { + const contextValueSymbol = typeChecker.getSymbolAtLocation(node.name); + if (contextValueSymbol) contextValueSymbols.add(contextValueSymbol); + } + node.forEachChild(visit); + }; + functionNode.forEachChild(visit); + return contextValueSymbols; +}; + +export const analyzeEffectEventUsage = ( + functionNode: ts.FunctionLikeDeclaration, + context: ReactAnalysisContext, +): ReactProofObligation => { + const bindings = collectEffectEventBindings(functionNode, context.typeChecker); + const violations: ReactProofEvidence[] = []; + const unknownEvidence: ReactProofEvidence[] = []; + const effectCalls = collectEffectCalls(functionNode, context.typeChecker); + const effectCallbacks = effectCalls + .map((effectCall) => getEffectCallback(effectCall, context.typeChecker)) + .filter((callback) => callback !== null); + const effectEventCallbacks = bindings + .map((binding) => binding.callback) + .filter((callback) => callback !== null); + const effectCleanupCallbacks = effectCallbacks.flatMap((callback) => + collectEffectCleanupFunctions(callback, context.typeChecker), + ); + const allowedOwners = new Set( + [...effectCallbacks, ...effectCleanupCallbacks, ...effectEventCallbacks].flatMap((callback) => + collectReachableFunctions(callback, context.typeChecker).map( + (reachableFunction) => reachableFunction.functionNode, + ), + ), + ); + const eventOwners = new Set( + collectEventCallbackFunctions(functionNode, context.typeChecker).flatMap((callback) => + collectReachableFunctions(callback, context.typeChecker).map( + (reachableFunction) => reachableFunction.functionNode, + ), + ), + ); + const wrapperName = getWrapperName(functionNode, context.typeChecker); + const contextValueSymbols = collectContextValueSymbols(functionNode, context.typeChecker); + + for (const binding of bindings) { + if (!binding.callback) { + unknownEvidence.push( + createEvidence( + binding.callExpression, + context.rootDirectory, + `${binding.name} has an opaque Effect Event callback`, + ["render", "useEffectEvent", "opaque callback", "latest committed values"], + ), + ); + } + if ( + binding.callback && + (wrapperName === "memo" || wrapperName === "forwardRef") && + contextValueSymbols.size > 0 + ) { + unknownEvidence.push( + createEvidence( + binding.callback, + context.rootDirectory, + `The pinned React runtime can expose a stale context capture to an Effect Event through ${wrapperName}`, + [ + wrapperName, + "context update", + "component render", + "Effect Event invocation", + "stale committed capture", + ], + ), + ); + } + const visit = (node: ts.Node): void => { + if ( + ts.isIdentifier(node) && + isIdentifierReference(node) && + context.typeChecker.getSymbolAtLocation(node) === binding.symbol + ) { + if (isEffectDependencyReference(node, effectCalls)) { + violations.push( + createEvidence( + node, + context.rootDirectory, + `${binding.name} has intentionally unstable identity and cannot be an Effect dependency`, + [ + "render", + binding.name, + "Effect dependency", + "identity changes", + "effect resynchronization", + ], + ), + ); + return; + } + const containingFunction = getEnclosingFunction(node); + const isAllowedOwner = Boolean(containingFunction && allowedOwners.has(containingFunction)); + const isEventOwner = Boolean(containingFunction && eventOwners.has(containingFunction)); + if ( + isAllowedOwner && + !isEventOwner && + (isDirectInvocation(node) || isRegistrationArgument(node)) + ) { + return; + } + if (isAllowedOwner && !isEventOwner) { + unknownEvidence.push( + createEvidence( + node, + context.rootDirectory, + `${binding.name} crosses an unmodeled registration or alias boundary inside Effect logic`, + ["Effect or Effect Event", binding.name, "opaque invocation lifetime"], + ), + ); + return; + } + violations.push( + createEvidence( + node, + context.rootDirectory, + `${binding.name} is used outside an Effect or Effect Event`, + ["useEffectEvent", binding.name, "invalid execution phase"], + ), + ); + } + node.forEachChild(visit); + }; + functionNode.forEachChild(visit); + } + + if (violations.length > 0) { + return createObligation( + ReactProofClaim.EffectEventUsage, + ReactObligationStatus.Violated, + "An Effect Event is used outside its local effect lifecycle", + violations, + ); + } + if (unknownEvidence.length > 0) { + return createObligation( + ReactProofClaim.EffectEventUsage, + ReactObligationStatus.Unknown, + "Effect Event callback semantics could not be proved", + unknownEvidence, + ); + } + return createObligation( + ReactProofClaim.EffectEventUsage, + ReactObligationStatus.Proved, + "Every Effect Event remains local to Effects and reads the latest committed captures", + ); +}; diff --git a/packages/prover/src/analyze-effect-state-updates.ts b/packages/prover/src/analyze-effect-state-updates.ts new file mode 100644 index 000000000..89c60911e --- /dev/null +++ b/packages/prover/src/analyze-effect-state-updates.ts @@ -0,0 +1,253 @@ +import ts from "typescript"; +import { collectEffectCalls } from "./collect-effect-calls.js"; +import { collectHookBindings } from "./collect-hook-bindings.js"; +import { collectReachableFunctions } from "./collect-reachable-functions.js"; +import { createEvidence } from "./create-evidence.js"; +import { createObligation } from "./create-obligation.js"; +import { getCallName } from "./get-call-name.js"; +import { findSemanticUnit } from "./find-semantic-unit.js"; +import { getEffectCallback } from "./get-effect-callback.js"; +import { isGuaranteedStateChange } from "./is-guaranteed-state-change.js"; +import { isFunctionBoundary } from "./is-function-boundary.js"; +import { isNodeWithin } from "./is-node-within.js"; +import { ReactExecutionPhase, ReactObligationStatus, ReactProofClaim } from "./types.js"; +import type { + ReactAnalysisContext, + ReactProofEvidence, + ReactProofObligation, + ReactUnitDescriptor, +} from "./types.js"; + +const isUnconditionalCallbackCall = ( + callExpression: ts.CallExpression, + callback: ts.FunctionLikeDeclaration, +): boolean => { + if (!callback.body) return false; + if (!ts.isBlock(callback.body)) return callback.body === callExpression; + return ( + callback.body.statements.length === 1 && + ts.isExpressionStatement(callback.body.statements[0]) && + callback.body.statements[0].expression === callExpression + ); +}; + +const getDependencyRootIdentifier = (expression: ts.Expression): ts.Identifier | null => { + if (ts.isIdentifier(expression)) return expression; + if (ts.isPropertyAccessExpression(expression) || ts.isElementAccessExpression(expression)) { + return getDependencyRootIdentifier(expression.expression); + } + return null; +}; + +const isParameterBinding = (declaration: ts.Declaration): boolean => { + let currentNode: ts.Node | undefined = declaration; + while (currentNode) { + if (ts.isParameter(currentNode)) return true; + if (isFunctionBoundary(currentNode)) return false; + currentNode = currentNode.parent; + } + return false; +}; + +const isStableAcrossLocalStateUpdate = ( + dependency: ts.Expression, + functionNode: ts.FunctionLikeDeclaration, + stableSymbols: ReadonlySet, + typeChecker: ts.TypeChecker, +): boolean => { + if ( + ts.isStringLiteral(dependency) || + ts.isNumericLiteral(dependency) || + dependency.kind === ts.SyntaxKind.TrueKeyword || + dependency.kind === ts.SyntaxKind.FalseKeyword || + dependency.kind === ts.SyntaxKind.NullKeyword + ) { + return true; + } + const rootIdentifier = getDependencyRootIdentifier(dependency); + if (!rootIdentifier) return false; + const rootSymbol = typeChecker.getSymbolAtLocation(rootIdentifier); + if (!rootSymbol) return false; + if (stableSymbols.has(rootSymbol)) return true; + const declarations = rootSymbol.declarations ?? []; + return ( + declarations.length > 0 && + declarations.every( + (declaration) => !isNodeWithin(declaration, functionNode) || isParameterBinding(declaration), + ) + ); +}; + +export const analyzeEffectStateUpdates = ( + unit: ReactUnitDescriptor, + context: ReactAnalysisContext, +): ReactProofObligation => { + const functionNode = unit.functionNode; + if (!functionNode) { + return createObligation( + ReactProofClaim.EffectStateUpdates, + ReactObligationStatus.Unknown, + "The unit has no function boundary for an Effect transition proof", + ); + } + const hookBindings = collectHookBindings(functionNode, context.typeChecker); + const stableSymbols = new Set([ + ...hookBindings.refs, + ...hookBindings.stateSetters, + ...hookBindings.stateValues, + ]); + const violations: ReactProofEvidence[] = []; + const unknownEvidence: ReactProofEvidence[] = []; + const semanticOwnerId = findSemanticUnit(unit, context)?.id; + if (context.graph && semanticOwnerId) { + const callbacksById = new Map( + context.graph.callbacks.map((callback) => [callback.id, callback]), + ); + for (const propFlow of context.graph.callbackPropFlows) { + if ( + propFlow.targetOwnerId !== semanticOwnerId || + propFlow.phase !== ReactExecutionPhase.EffectSetup + ) { + continue; + } + const stateWrites = propFlow.callbackIds.flatMap( + (callbackId) => callbacksById.get(callbackId)?.stateWrites ?? [], + ); + if (stateWrites.length === 0) continue; + unknownEvidence.push({ + description: `Effect callback prop ${propFlow.propName} writes source-component state and requires a cross-component rerender fixpoint proof`, + location: propFlow.location, + trace: [ + "effect setup", + `component prop ${propFlow.propName}`, + ...stateWrites, + "source component state update", + "possible callback identity change", + "possible effect rerun", + ], + }); + } + } + for (const effectCall of collectEffectCalls(functionNode, context.typeChecker)) { + const effectCallback = getEffectCallback(effectCall, context.typeChecker); + if (!effectCallback) { + unknownEvidence.push( + createEvidence( + effectCall, + context.rootDirectory, + "The effect callback cannot be checked for state-transition cycles", + ["effect setup", "opaque callback", "unknown state transitions"], + ), + ); + continue; + } + for (const reachableFunction of collectReachableFunctions( + effectCallback, + context.typeChecker, + )) { + const visit = (node: ts.Node): void => { + if (node !== reachableFunction.functionNode && isFunctionBoundary(node)) return; + if (ts.isCallExpression(node)) { + const callSymbol = context.typeChecker.getSymbolAtLocation(node.expression); + if (callSymbol && hookBindings.stateSetters.has(callSymbol)) { + const callName = getCallName(node) ?? "state setter"; + const dependenciesExpression = effectCall.arguments[1]; + if ( + dependenciesExpression && + ts.isArrayLiteralExpression(dependenciesExpression) && + dependenciesExpression.elements.length === 0 + ) { + return; + } + const stateSymbol = hookBindings.stateValueBySetter.get(callSymbol); + const hasDirectStateDependency = + stateSymbol && + dependenciesExpression && + ts.isArrayLiteralExpression(dependenciesExpression) && + dependenciesExpression.elements.some( + (dependency) => context.typeChecker.getSymbolAtLocation(dependency) === stateSymbol, + ); + const hasUnstableDependency = + dependenciesExpression && + ts.isArrayLiteralExpression(dependenciesExpression) && + dependenciesExpression.elements.some( + (dependency) => + !isStableAcrossLocalStateUpdate( + dependency, + functionNode, + stableSymbols, + context.typeChecker, + ), + ); + const canSelfTrigger = + !dependenciesExpression || + !ts.isArrayLiteralExpression(dependenciesExpression) || + Boolean(hasDirectStateDependency) || + Boolean(hasUnstableDependency); + if (!canSelfTrigger) return; + if ( + reachableFunction.functionNode === effectCallback && + !reachableFunction.isConditionallyReached && + stateSymbol && + canSelfTrigger && + isUnconditionalCallbackCall(node, effectCallback) && + isGuaranteedStateChange({ + callExpression: node, + stateSymbol, + typeChecker: context.typeChecker, + }) + ) { + violations.push( + createEvidence( + node, + context.rootDirectory, + `${callName} necessarily changes a dependency that schedules this effect again`, + [ + "effect setup", + callName, + "guaranteed state change", + "dependency changed", + "effect setup", + ], + ), + ); + return; + } + unknownEvidence.push( + createEvidence( + node, + context.rootDirectory, + `${callName} requires a state-transition and rerender fixpoint proof`, + ["effect setup", callName, "state update", "possible effect rerun"], + ), + ); + return; + } + } + node.forEachChild(visit); + }; + reachableFunction.functionNode.forEachChild(visit); + } + } + if (violations.length > 0) { + return createObligation( + ReactProofClaim.EffectStateUpdates, + ReactObligationStatus.Violated, + "An effect contains a guaranteed self-triggering state transition", + violations, + ); + } + if (unknownEvidence.length > 0) { + return createObligation( + ReactProofClaim.EffectStateUpdates, + ReactObligationStatus.Unknown, + "An effect directly updates component state without a transition proof", + unknownEvidence, + ); + } + return createObligation( + ReactProofClaim.EffectStateUpdates, + ReactObligationStatus.Proved, + "Every direct effect state update is absent or bounded to mount", + ); +}; diff --git a/packages/prover/src/analyze-error-boundary.ts b/packages/prover/src/analyze-error-boundary.ts new file mode 100644 index 000000000..c55e4b056 --- /dev/null +++ b/packages/prover/src/analyze-error-boundary.ts @@ -0,0 +1,96 @@ +import { createObligation } from "./create-obligation.js"; +import { findSemanticUnit } from "./find-semantic-unit.js"; +import { + ReactErrorBoundaryCoverageStatus, + ReactErrorBoundaryProtocolStatus, + ReactObligationStatus, + ReactProofClaim, +} from "./types.js"; +import type { + ReactAnalysisContext, + ReactProofEvidence, + ReactProofObligation, + ReactUnitDescriptor, +} from "./types.js"; + +export const analyzeErrorBoundary = ( + unit: ReactUnitDescriptor, + context: ReactAnalysisContext, +): ReactProofObligation => { + const semanticUnit = findSemanticUnit(unit, context); + if (!context.graph || !semanticUnit) { + return createObligation( + ReactProofClaim.ErrorBoundary, + ReactObligationStatus.Unknown, + "The semantic graph could not identify render-error topology", + ); + } + const definitions = context.graph.errorBoundaryDefinitions.filter( + (definition) => definition.ownerId === semanticUnit.id, + ); + const failures = context.graph.renderFailures.filter( + (failure) => failure.ownerId === semanticUnit.id, + ); + const violatedEvidence: ReactProofEvidence[] = []; + const unknownEvidence: ReactProofEvidence[] = []; + for (const definition of definitions) { + if ( + definition.derivedStateStatus === ReactErrorBoundaryProtocolStatus.Invalid || + definition.fallbackRenderStatus === ReactErrorBoundaryProtocolStatus.Invalid + ) { + violatedEvidence.push({ + description: "An Error Boundary cannot deterministically transition to fallback UI", + location: definition.location, + trace: [ + "rendering error", + "invalid getDerivedStateFromError or fallback guard", + "missing recovery UI", + ], + }); + } else if (!definition.complete) { + unknownEvidence.push({ + description: "An Error Boundary recovery protocol crosses opaque state or render flow", + location: definition.location, + trace: ["rendering error", "opaque boundary state transition", "unknown recovery UI"], + }); + } + } + for (const failure of failures) { + if (failure.coverageStatus === ReactErrorBoundaryCoverageStatus.OutsideBoundary) { + violatedEvidence.push({ + description: "A reachable render failure can escape without a valid Error Boundary", + location: failure.location, + trace: ["client render", "explicit throw", "unmounted application UI"], + }); + } else if (failure.coverageStatus === ReactErrorBoundaryCoverageStatus.Unknown) { + unknownEvidence.push({ + description: "A render failure crosses unresolved Error Boundary topology", + location: failure.location, + trace: ["client render", "explicit throw", "opaque component or ReactNode placement"], + }); + } + } + if (violatedEvidence.length > 0) { + return createObligation( + ReactProofClaim.ErrorBoundary, + ReactObligationStatus.Violated, + "Render-error recovery is invalid or incomplete", + violatedEvidence, + ); + } + if (unknownEvidence.length > 0) { + return createObligation( + ReactProofClaim.ErrorBoundary, + ReactObligationStatus.Unknown, + "Render-error recovery crosses an opaque proof boundary", + unknownEvidence, + ); + } + let description = "The unit has no modeled render failure or Error Boundary"; + if (failures.length > 0) { + description = "Every modeled client render failure is contained by a valid Error Boundary"; + } else if (definitions.length > 0) { + description = "Every Error Boundary has a total fallback-state protocol"; + } + return createObligation(ReactProofClaim.ErrorBoundary, ReactObligationStatus.Proved, description); +}; diff --git a/packages/prover/src/analyze-external-store-consistency.ts b/packages/prover/src/analyze-external-store-consistency.ts new file mode 100644 index 000000000..8e24915c0 --- /dev/null +++ b/packages/prover/src/analyze-external-store-consistency.ts @@ -0,0 +1,526 @@ +import ts from "typescript"; +import { collectExternalStoreProtocolVariants } from "./collect-external-store-protocol-variants.js"; +import { collectHookCalls } from "./collect-hook-calls.js"; +import { REACT_EXTERNAL_STORE_HOOK_NAMES } from "./constants.js"; +import { createEvidence } from "./create-evidence.js"; +import { createObligation } from "./create-obligation.js"; +import { findSemanticUnit } from "./find-semantic-unit.js"; +import { getComponentPropName } from "./get-component-prop-name.js"; +import { getNodeLocation } from "./get-node-location.js"; +import { isFunctionBoundary } from "./is-function-boundary.js"; +import { resolveFunction } from "./resolve-function.js"; +import { ReactObligationStatus, ReactProofClaim } from "./types.js"; +import { unwrapTypescriptExpression } from "./unwrap-typescript-expression.js"; +import { collectSymbolWrites } from "./utils/collect-symbol-writes.js"; +import { getContainingFunction } from "./utils/get-containing-function.js"; +import { isAssignmentOperator } from "./utils/is-assignment-operator.js"; +import type { + ReactAnalysisContext, + ReactProofEvidence, + ReactProofObligation, + ReactUnitDescriptor, +} from "./types.js"; + +interface SubscriptionRegistry { + expressionText: string; + symbol: ts.Symbol | null; +} + +interface SubscriptionAnalysis { + registries: ReadonlyArray; + violations: ReadonlyArray; + unknownEvidence: ReadonlyArray; +} + +const collectReturnExpressions = ( + functionNode: ts.FunctionLikeDeclaration, +): ReadonlyArray => { + if (!functionNode.body) return []; + if (!ts.isBlock(functionNode.body)) return [functionNode.body]; + const returnExpressions: ts.Expression[] = []; + const visit = (node: ts.Node): void => { + if (node !== functionNode.body && isFunctionBoundary(node)) return; + if (ts.isReturnStatement(node) && node.expression) { + returnExpressions.push(node.expression); + return; + } + node.forEachChild(visit); + }; + functionNode.body.forEachChild(visit); + return returnExpressions; +}; + +const hasGuaranteedReturn = (functionNode: ts.FunctionLikeDeclaration): boolean => { + if (!functionNode.body) return false; + if (!ts.isBlock(functionNode.body)) return true; + const finalStatement = functionNode.body.statements.at(-1); + return Boolean(finalStatement && ts.isReturnStatement(finalStatement)); +}; + +const collectDirectCalls = ( + functionNode: ts.FunctionLikeDeclaration, +): ReadonlyArray => { + const calls: ts.CallExpression[] = []; + const visit = (node: ts.Node): void => { + if (node !== functionNode && isFunctionBoundary(node)) return; + if (ts.isCallExpression(node)) calls.push(node); + node.forEachChild(visit); + }; + functionNode.forEachChild(visit); + return calls; +}; + +const analyzeSubscription = ( + subscribeFunction: ts.FunctionLikeDeclaration, + context: ReactAnalysisContext, +): SubscriptionAnalysis => { + const violations: ReactProofEvidence[] = []; + const unknownEvidence: ReactProofEvidence[] = []; + const callbackParameter = subscribeFunction.parameters[0]; + const callbackSymbol = + callbackParameter && ts.isIdentifier(callbackParameter.name) + ? context.typeChecker.getSymbolAtLocation(callbackParameter.name) + : undefined; + const cleanupExpressions = collectReturnExpressions(subscribeFunction); + const subscriptionCalls = collectDirectCalls(subscribeFunction); + const cleanupFunctions = cleanupExpressions + .map((expression) => resolveFunction(expression, context.typeChecker)) + .filter((cleanupFunction) => cleanupFunction !== null); + if ( + !callbackSymbol || + !hasGuaranteedReturn(subscribeFunction) || + cleanupExpressions.length === 0 || + cleanupFunctions.length !== cleanupExpressions.length + ) { + violations.push( + createEvidence( + subscribeFunction, + context.rootDirectory, + "The external-store subscribe function does not return cleanup on every normal path", + ["useSyncExternalStore", "subscribe", "missing unsubscribe function", "retained listener"], + ), + ); + } + + const registries: SubscriptionRegistry[] = []; + const modeledSubscriptionCalls = new Set(); + for (const callExpression of subscriptionCalls) { + if ( + !ts.isPropertyAccessExpression(callExpression.expression) || + callExpression.expression.name.text !== "add" + ) { + continue; + } + const registeredCallback = callExpression.arguments[0]; + if ( + !registeredCallback || + context.typeChecker.getSymbolAtLocation(registeredCallback) !== callbackSymbol + ) { + continue; + } + const registryExpression = callExpression.expression.expression; + const registry: SubscriptionRegistry = { + expressionText: registryExpression.getText(), + symbol: context.typeChecker.getSymbolAtLocation(registryExpression) ?? null, + }; + modeledSubscriptionCalls.add(callExpression); + registries.push(registry); + const hasMatchingCleanup = cleanupFunctions.some((cleanupFunction) => + collectDirectCalls(cleanupFunction).some( + (cleanupCall) => + ts.isPropertyAccessExpression(cleanupCall.expression) && + cleanupCall.expression.name.text === "delete" && + cleanupCall.expression.expression.getText() === registry.expressionText && + cleanupCall.arguments[0] !== undefined && + context.typeChecker.getSymbolAtLocation(cleanupCall.arguments[0]) === callbackSymbol, + ), + ); + if (!hasMatchingCleanup) { + violations.push( + createEvidence( + callExpression, + context.rootDirectory, + `${registry.expressionText}.add subscribes the React callback without symmetric deletion`, + [ + "useSyncExternalStore", + `${registry.expressionText}.add(callback)`, + "subscription lifetime", + `${registry.expressionText}.delete(callback)`, + ], + ), + ); + } + } + + if (subscriptionCalls.some((callExpression) => !modeledSubscriptionCalls.has(callExpression))) { + unknownEvidence.push( + createEvidence( + subscribeFunction, + context.rootDirectory, + "The external-store subscription protocol is not a modeled listener registry", + ["useSyncExternalStore", "subscribe", "opaque notification protocol"], + ), + ); + } + return { registries, violations, unknownEvidence }; +}; + +const isFreshSnapshot = (expression: ts.Expression): boolean => { + const unwrappedExpression = unwrapTypescriptExpression(expression); + return ( + ts.isArrayLiteralExpression(unwrappedExpression) || + ts.isObjectLiteralExpression(unwrappedExpression) || + ts.isNewExpression(unwrappedExpression) + ); +}; + +const isPrimitiveType = (type: ts.Type): boolean => { + if (type.isUnion()) return type.types.every(isPrimitiveType); + return Boolean( + type.flags & + (ts.TypeFlags.StringLike | + ts.TypeFlags.NumberLike | + ts.TypeFlags.BooleanLike | + ts.TypeFlags.BigIntLike | + ts.TypeFlags.ESSymbolLike | + ts.TypeFlags.Null | + ts.TypeFlags.Undefined | + ts.TypeFlags.EnumLike), + ); +}; + +const isStablePrimitiveExpression = ( + expression: ts.Expression, + typeChecker: ts.TypeChecker, +): boolean => { + const unwrappedExpression = unwrapTypescriptExpression(expression); + if (!isPrimitiveType(typeChecker.getTypeAtLocation(unwrappedExpression))) return false; + if ( + ts.isIdentifier(unwrappedExpression) || + ts.isLiteralExpression(unwrappedExpression) || + unwrappedExpression.kind === ts.SyntaxKind.TrueKeyword || + unwrappedExpression.kind === ts.SyntaxKind.FalseKeyword || + unwrappedExpression.kind === ts.SyntaxKind.NullKeyword + ) { + return true; + } + if (ts.isPrefixUnaryExpression(unwrappedExpression)) { + if ( + unwrappedExpression.operator === ts.SyntaxKind.PlusPlusToken || + unwrappedExpression.operator === ts.SyntaxKind.MinusMinusToken + ) { + return false; + } + return isStablePrimitiveExpression(unwrappedExpression.operand, typeChecker); + } + if (ts.isBinaryExpression(unwrappedExpression)) { + if (isAssignmentOperator(unwrappedExpression.operatorToken.kind)) { + return false; + } + return ( + isStablePrimitiveExpression(unwrappedExpression.left, typeChecker) && + isStablePrimitiveExpression(unwrappedExpression.right, typeChecker) + ); + } + if (ts.isConditionalExpression(unwrappedExpression)) { + return ( + isStablePrimitiveExpression(unwrappedExpression.condition, typeChecker) && + isStablePrimitiveExpression(unwrappedExpression.whenTrue, typeChecker) && + isStablePrimitiveExpression(unwrappedExpression.whenFalse, typeChecker) + ); + } + return false; +}; + +const isStaticPrimitiveExpression = (expression: ts.Expression): boolean => { + const unwrappedExpression = unwrapTypescriptExpression(expression); + return ( + ts.isLiteralExpression(unwrappedExpression) || + unwrappedExpression.kind === ts.SyntaxKind.TrueKeyword || + unwrappedExpression.kind === ts.SyntaxKind.FalseKeyword || + unwrappedExpression.kind === ts.SyntaxKind.NullKeyword + ); +}; + +const hasRegistryNotificationAfterWrite = ( + write: ts.Node, + registry: SubscriptionRegistry, + context: ReactAnalysisContext, +): boolean => { + const containingFunction = getContainingFunction(write); + if (!containingFunction) return false; + let hasNotification = false; + const visit = (node: ts.Node): void => { + if (hasNotification || (node !== containingFunction && isFunctionBoundary(node))) return; + if ( + ts.isForOfStatement(node) && + node.getStart() > write.getEnd() && + ((registry.symbol && + context.typeChecker.getSymbolAtLocation(node.expression) === registry.symbol) || + node.expression.getText() === registry.expressionText) && + ts.isVariableDeclarationList(node.initializer) + ) { + const listenerDeclaration = node.initializer.declarations[0]; + const listenerSymbol = + listenerDeclaration && ts.isIdentifier(listenerDeclaration.name) + ? context.typeChecker.getSymbolAtLocation(listenerDeclaration.name) + : undefined; + const inspectLoopBody = (loopNode: ts.Node): void => { + if ( + ts.isCallExpression(loopNode) && + listenerSymbol && + context.typeChecker.getSymbolAtLocation(loopNode.expression) === listenerSymbol + ) { + hasNotification = true; + return; + } + loopNode.forEachChild(inspectLoopBody); + }; + node.statement.forEachChild(inspectLoopBody); + } + node.forEachChild(visit); + }; + containingFunction.forEachChild(visit); + return hasNotification; +}; + +const analyzeSnapshotWrites = ( + snapshotExpressions: ReadonlyArray, + registries: ReadonlyArray, + context: ReactAnalysisContext, +): ReadonlyArray => { + const violations: ReactProofEvidence[] = []; + const snapshotSymbols = new Set( + snapshotExpressions + .map((expression) => + context.typeChecker.getSymbolAtLocation(unwrapTypescriptExpression(expression)), + ) + .filter((symbol) => symbol !== undefined), + ); + for (const snapshotSymbol of snapshotSymbols) { + for (const declaration of snapshotSymbol.declarations ?? []) { + const writes = collectSymbolWrites( + snapshotSymbol, + declaration.getSourceFile(), + context.typeChecker, + ); + for (const write of writes) { + if ( + registries.some((registry) => hasRegistryNotificationAfterWrite(write, registry, context)) + ) { + continue; + } + violations.push( + createEvidence( + write, + context.rootDirectory, + `${snapshotSymbol.name} changes without notifying the subscribed React callback`, + [ + "external store write", + snapshotSymbol.name, + "missing listener notification", + "stale rendered snapshot", + ], + ), + ); + } + break; + } + } + return violations; +}; + +export const analyzeExternalStoreConsistency = ( + unit: ReactUnitDescriptor, + context: ReactAnalysisContext, +): ReactProofObligation => { + const functionNode = unit.functionNode; + if (!functionNode) { + return createObligation( + ReactProofClaim.ExternalStoreConsistency, + ReactObligationStatus.Unknown, + "The unit has no function boundary for an external-store proof", + ); + } + const violations: ReactProofEvidence[] = []; + const unknownEvidence: ReactProofEvidence[] = []; + const semanticOwnerId = findSemanticUnit(unit, context)?.id; + for (const hookCall of collectHookCalls( + functionNode, + REACT_EXTERNAL_STORE_HOOK_NAMES, + context.typeChecker, + )) { + const subscribeExpression = hookCall.arguments[0]; + const snapshotExpression = hookCall.arguments[1]; + const serverSnapshotExpression = hookCall.arguments[2]; + const hookLocation = getNodeLocation(hookCall, context.rootDirectory); + const externalStore = context.graph?.externalStores.find( + (store) => + store.ownerId === semanticOwnerId && + store.location.filePath === hookLocation.filePath && + store.location.line === hookLocation.line && + store.location.column === hookLocation.column, + ); + if (!externalStore) { + unknownEvidence.push( + createEvidence( + hookCall, + context.rootDirectory, + "The external-store subscribe or snapshot callback cannot be resolved", + ["useSyncExternalStore", "opaque callback", "external consistency boundary"], + ), + ); + continue; + } + const subscribePropName = subscribeExpression + ? getComponentPropName(subscribeExpression, functionNode, context.typeChecker) + : null; + const snapshotPropName = snapshotExpression + ? getComponentPropName(snapshotExpression, functionNode, context.typeChecker) + : null; + const serverSnapshotPropName = serverSnapshotExpression + ? getComponentPropName(serverSnapshotExpression, functionNode, context.typeChecker) + : null; + const protocolVariants = collectExternalStoreProtocolVariants({ + context, + externalStore, + serverSnapshotPropName, + snapshotPropName, + subscribePropName, + }); + if (protocolVariants.length === 0) { + unknownEvidence.push( + createEvidence( + hookCall, + context.rootDirectory, + "The external-store render variants cannot be correlated", + ["useSyncExternalStore", "callback prop flows", "unknown render source"], + ), + ); + continue; + } + for (const protocolVariant of protocolVariants) { + if ( + !protocolVariant.isComplete || + protocolVariant.subscribeFunctions.length !== 1 || + protocolVariant.snapshotFunctions.length !== 1 || + (serverSnapshotExpression && protocolVariant.serverSnapshotFunctions.length !== 1) + ) { + unknownEvidence.push( + createEvidence( + hookCall, + context.rootDirectory, + "The external-store callbacks within one render variant cannot be resolved", + [ + "useSyncExternalStore", + protocolVariant.renderId ?? "local callback channels", + "opaque or joined callback", + ], + ), + ); + continue; + } + const subscribeFunction = protocolVariant.subscribeFunctions[0]; + const snapshotFunction = protocolVariant.snapshotFunctions[0]; + if (!subscribeFunction || !snapshotFunction) continue; + const subscription = analyzeSubscription(subscribeFunction, context); + violations.push(...subscription.violations); + unknownEvidence.push(...subscription.unknownEvidence); + const snapshotExpressions = collectReturnExpressions(snapshotFunction); + if ( + !hasGuaranteedReturn(snapshotFunction) || + snapshotExpressions.length === 0 || + snapshotExpressions.some(isFreshSnapshot) + ) { + violations.push( + createEvidence( + snapshotFunction, + context.rootDirectory, + "getSnapshot can produce a fresh or missing value without an external-store change", + ["useSyncExternalStore", "getSnapshot", "Object.is changed", "render loop"], + ), + ); + } else if ( + !snapshotExpressions.every((expression) => + isStablePrimitiveExpression(expression, context.typeChecker), + ) + ) { + unknownEvidence.push( + createEvidence( + snapshotFunction, + context.rootDirectory, + "getSnapshot immutability and referential stability could not be proved", + ["useSyncExternalStore", "getSnapshot", "opaque snapshot identity"], + ), + ); + } + violations.push( + ...analyzeSnapshotWrites(snapshotExpressions, subscription.registries, context), + ); + if (serverSnapshotExpression) { + const serverSnapshotFunction = protocolVariant.serverSnapshotFunctions[0]; + const serverExpressions = serverSnapshotFunction + ? collectReturnExpressions(serverSnapshotFunction) + : []; + const hasMatchingServerSnapshot = + serverSnapshotFunction && + hasGuaranteedReturn(serverSnapshotFunction) && + serverExpressions.length === snapshotExpressions.length && + serverExpressions.every( + (expression, expressionIndex) => + isStablePrimitiveExpression(expression, context.typeChecker) && + context.typeChecker.getSymbolAtLocation(unwrapTypescriptExpression(expression)) === + context.typeChecker.getSymbolAtLocation( + unwrapTypescriptExpression(snapshotExpressions[expressionIndex]), + ) && + expression.getText() === snapshotExpressions[expressionIndex]?.getText(), + ); + if (!hasMatchingServerSnapshot) { + const hasStaticMismatch = + serverExpressions.length === snapshotExpressions.length && + serverExpressions.every(isStaticPrimitiveExpression) && + snapshotExpressions.every(isStaticPrimitiveExpression) && + serverExpressions.some( + (expression, expressionIndex) => + expression.getText() !== snapshotExpressions[expressionIndex]?.getText(), + ); + const evidence = createEvidence( + serverSnapshotExpression, + context.rootDirectory, + hasStaticMismatch + ? "getServerSnapshot returns different initial data than getSnapshot" + : "The server snapshot cannot be proved identical during hydration", + ["server render", "getServerSnapshot", "client hydration", "snapshot identity"], + ); + if (hasStaticMismatch) { + violations.push(evidence); + } else { + unknownEvidence.push(evidence); + } + } + } + } + } + + if (violations.length > 0) { + return createObligation( + ReactProofClaim.ExternalStoreConsistency, + ReactObligationStatus.Violated, + "An external store violates subscription or snapshot consistency", + violations, + ); + } + if (unknownEvidence.length > 0) { + return createObligation( + ReactProofClaim.ExternalStoreConsistency, + ReactObligationStatus.Unknown, + "External-store consistency could not be proved", + unknownEvidence, + ); + } + return createObligation( + ReactProofClaim.ExternalStoreConsistency, + ReactObligationStatus.Proved, + "External-store snapshots are stable and every modeled mutation notifies a symmetric subscription", + ); +}; diff --git a/packages/prover/src/analyze-form-actions.ts b/packages/prover/src/analyze-form-actions.ts new file mode 100644 index 000000000..2610452cc --- /dev/null +++ b/packages/prover/src/analyze-form-actions.ts @@ -0,0 +1,67 @@ +import { createEvidence } from "./create-evidence.js"; +import { createObligation } from "./create-obligation.js"; +import { findSemanticUnit } from "./find-semantic-unit.js"; +import { ReactFormActionStatus, ReactObligationStatus, ReactProofClaim } from "./types.js"; +import type { + ReactAnalysisContext, + ReactProofEvidence, + ReactProofObligation, + ReactUnitDescriptor, +} from "./types.js"; + +export const analyzeFormActions = ( + unit: ReactUnitDescriptor, + context: ReactAnalysisContext, +): ReactProofObligation => { + const owner = findSemanticUnit(unit, context); + const actions = owner + ? (context.graph?.formActions.filter((action) => action.ownerId === owner.id) ?? []) + : []; + const violations: ReactProofEvidence[] = []; + const unknownEvidence: ReactProofEvidence[] = []; + for (const action of actions) { + if (action.status === ReactFormActionStatus.UnsupportedControl) { + violations.push({ + description: `${action.propName} is attached to an intrinsic element that cannot invoke that Form Action`, + location: action.location, + trace: ["committed form", action.propName, action.kind, "unsupported submit control"], + }); + } else if (!action.complete) { + unknownEvidence.push({ + description: `${action.propName} does not resolve to a complete project Form Action callback`, + location: action.location, + trace: ["committed form", action.propName, "opaque Action callback"], + }); + } + } + if (!owner) { + unknownEvidence.push( + createEvidence(unit.node, context.rootDirectory, "The Form Action owner cannot be resolved", [ + "React unit", + "Form Action", + "unknown owner", + ]), + ); + } + if (violations.length > 0) { + return createObligation( + ReactProofClaim.FormActions, + ReactObligationStatus.Violated, + "A Form Action is attached to a control that cannot submit it", + violations, + ); + } + if (unknownEvidence.length > 0) { + return createObligation( + ReactProofClaim.FormActions, + ReactObligationStatus.Unknown, + "Form Action callback identity or control semantics are incomplete", + unknownEvidence, + ); + } + return createObligation( + ReactProofClaim.FormActions, + ReactObligationStatus.Proved, + "Every intrinsic Form Action resolves to a modeled Action execution root", + ); +}; diff --git a/packages/prover/src/analyze-form-status.ts b/packages/prover/src/analyze-form-status.ts new file mode 100644 index 000000000..e08704549 --- /dev/null +++ b/packages/prover/src/analyze-form-status.ts @@ -0,0 +1,84 @@ +import { createObligation } from "./create-obligation.js"; +import { findSemanticUnit } from "./find-semantic-unit.js"; +import { ReactFormStatusTopologyStatus, ReactObligationStatus, ReactProofClaim } from "./types.js"; +import type { + ReactAnalysisContext, + ReactProofEvidence, + ReactProofLocation, + ReactProofObligation, + ReactUnitDescriptor, +} from "./types.js"; + +const createGraphEvidence = ( + location: ReactProofLocation, + description: string, + trace: ReadonlyArray, +): ReactProofEvidence => ({ description, location, trace }); + +export const analyzeFormStatus = ( + unit: ReactUnitDescriptor, + context: ReactAnalysisContext, +): ReactProofObligation => { + const semanticUnit = findSemanticUnit(unit, context); + if (!context.graph || !semanticUnit) { + return createObligation( + ReactProofClaim.FormStatus, + ReactObligationStatus.Unknown, + "The semantic graph could not identify this Form Status owner", + [], + ); + } + + const formStatuses = context.graph.formStatuses.filter( + (formStatus) => formStatus.ownerId === semanticUnit.id, + ); + const outsideFormStatuses = formStatuses.filter( + (formStatus) => formStatus.status === ReactFormStatusTopologyStatus.OutsideForm, + ); + if (outsideFormStatuses.length > 0) { + return createObligation( + ReactProofClaim.FormStatus, + ReactObligationStatus.Violated, + "A Form Status consumer can render without a parent form", + outsideFormStatuses.map((formStatus) => + createGraphEvidence( + formStatus.location, + "useFormStatus can render without a parent ", + [ + "useFormStatus", + "closed render path outside a parent form", + "pending status never becomes active", + ], + ), + ), + ); + } + + const unresolvedFormStatuses = formStatuses.filter( + (formStatus) => + formStatus.status === ReactFormStatusTopologyStatus.Unknown || !formStatus.complete, + ); + if (unresolvedFormStatuses.length > 0) { + return createObligation( + ReactProofClaim.FormStatus, + ReactObligationStatus.Unknown, + "A Form Status consumer has unresolved parent-form topology", + unresolvedFormStatuses.map((formStatus) => + createGraphEvidence( + formStatus.location, + "The nearest parent cannot be resolved on every render path", + ["useFormStatus", "component render topology", "unknown parent form"], + ), + ), + ); + } + + return createObligation( + ReactProofClaim.FormStatus, + ReactObligationStatus.Proved, + formStatuses.length > 0 + ? "Every Form Status consumer resolves to a parent form on every render path" + : "The unit has no Form Status consumer", + [], + ); +}; diff --git a/packages/prover/src/analyze-hook-order.ts b/packages/prover/src/analyze-hook-order.ts new file mode 100644 index 000000000..d5db57ef9 --- /dev/null +++ b/packages/prover/src/analyze-hook-order.ts @@ -0,0 +1,158 @@ +import ts from "typescript"; +import { createEvidence } from "./create-evidence.js"; +import { createObligation } from "./create-obligation.js"; +import { getCanonicalHookName } from "./get-canonical-hook-name.js"; +import { isFunctionBoundary } from "./is-function-boundary.js"; +import { isReactHookName } from "./is-react-hook-name.js"; +import { ReactObligationStatus, ReactProofClaim } from "./types.js"; +import type { ReactAnalysisContext, ReactProofEvidence, ReactProofObligation } from "./types.js"; + +const containsReturnOutsideNestedFunction = (node: ts.Node): boolean => { + let didFindReturn = false; + const visit = (currentNode: ts.Node): void => { + if (ts.isReturnStatement(currentNode)) { + didFindReturn = true; + return; + } + if ( + currentNode !== node && + (ts.isFunctionDeclaration(currentNode) || + ts.isFunctionExpression(currentNode) || + ts.isArrowFunction(currentNode)) + ) { + return; + } + currentNode.forEachChild(visit); + }; + visit(node); + return didFindReturn; +}; + +const hasEarlierReturn = ( + callExpression: ts.CallExpression, + functionNode: ts.FunctionLikeDeclaration, +): boolean => { + if (!functionNode.body || !ts.isBlock(functionNode.body)) return false; + const containingStatement = functionNode.body.statements.find( + (statement) => + callExpression.getStart() >= statement.getStart() && + callExpression.getEnd() <= statement.getEnd(), + ); + if (!containingStatement) return false; + const statementIndex = functionNode.body.statements.indexOf(containingStatement); + return functionNode.body.statements + .slice(0, statementIndex) + .some(containsReturnOutsideNestedFunction); +}; + +const hasConditionalAncestor = ( + callExpression: ts.CallExpression, + functionNode: ts.FunctionLikeDeclaration, +): boolean => { + let currentNode: ts.Node = callExpression; + while (currentNode !== functionNode) { + const parentNode = currentNode.parent; + if (!parentNode) return true; + if ( + ts.isIfStatement(parentNode) || + ts.isConditionalExpression(parentNode) || + ts.isForStatement(parentNode) || + ts.isForInStatement(parentNode) || + ts.isForOfStatement(parentNode) || + ts.isWhileStatement(parentNode) || + ts.isDoStatement(parentNode) || + ts.isCaseClause(parentNode) || + ts.isDefaultClause(parentNode) || + ts.isTryStatement(parentNode) || + ts.isCatchClause(parentNode) + ) { + return true; + } + if (ts.isBinaryExpression(parentNode)) { + const operatorKind = parentNode.operatorToken.kind; + if ( + operatorKind === ts.SyntaxKind.AmpersandAmpersandToken || + operatorKind === ts.SyntaxKind.BarBarToken || + operatorKind === ts.SyntaxKind.QuestionQuestionToken + ) { + return true; + } + } + if ( + parentNode !== functionNode && + (ts.isFunctionDeclaration(parentNode) || + ts.isFunctionExpression(parentNode) || + ts.isArrowFunction(parentNode)) + ) { + return true; + } + currentNode = parentNode; + } + return false; +}; + +const hasTryAncestor = ( + callExpression: ts.CallExpression, + functionNode: ts.FunctionLikeDeclaration, +): boolean => { + let currentNode: ts.Node = callExpression; + while (currentNode !== functionNode) { + const parentNode = currentNode.parent; + if (!parentNode) return true; + if (ts.isTryStatement(parentNode) || ts.isCatchClause(parentNode)) return true; + currentNode = parentNode; + } + return false; +}; + +export const analyzeHookOrder = ( + functionNode: ts.FunctionLikeDeclaration, + context: ReactAnalysisContext, +): ReactProofObligation => { + const violations: ReactProofEvidence[] = []; + const visit = (node: ts.Node): void => { + if (node !== functionNode && isFunctionBoundary(node)) { + return; + } + if (ts.isCallExpression(node)) { + const finalCallName = getCanonicalHookName(node, context.typeChecker); + if ( + finalCallName && + isReactHookName(finalCallName) && + (finalCallName === "use" + ? hasTryAncestor(node, functionNode) + : hasConditionalAncestor(node, functionNode) || hasEarlierReturn(node, functionNode)) + ) { + const description = + finalCallName === "use" + ? "use cannot be called from a try or catch block" + : `${finalCallName} does not execute in an invariant hook position`; + violations.push( + createEvidence( + node, + context.rootDirectory, + description, + finalCallName === "use" + ? ["render entry", "try or catch path", "use"] + : ["render entry", "conditional or early-return path", finalCallName], + ), + ); + } + } + node.forEachChild(visit); + }; + functionNode.forEachChild(visit); + if (violations.length > 0) { + return createObligation( + ReactProofClaim.HookOrder, + ReactObligationStatus.Violated, + "Hook order changes across possible renders", + violations, + ); + } + return createObligation( + ReactProofClaim.HookOrder, + ReactObligationStatus.Proved, + "Every discovered hook call has an invariant render position", + ); +}; diff --git a/packages/prover/src/analyze-hook-ownership.ts b/packages/prover/src/analyze-hook-ownership.ts new file mode 100644 index 000000000..864def2d6 --- /dev/null +++ b/packages/prover/src/analyze-hook-ownership.ts @@ -0,0 +1,13 @@ +import type ts from "typescript"; +import { createObligation } from "./create-obligation.js"; +import { ReactObligationStatus, ReactProofClaim } from "./types.js"; +import type { ReactProofObligation } from "./types.js"; + +export const analyzeHookOwnership = ( + _functionNode: ts.FunctionLikeDeclaration, +): ReactProofObligation => + createObligation( + ReactProofClaim.HookOwnership, + ReactObligationStatus.Proved, + "Every direct hook call belongs to a component or custom hook", + ); diff --git a/packages/prover/src/analyze-hook-state-transitions.ts b/packages/prover/src/analyze-hook-state-transitions.ts new file mode 100644 index 000000000..4356ead3a --- /dev/null +++ b/packages/prover/src/analyze-hook-state-transitions.ts @@ -0,0 +1,87 @@ +import { createObligation } from "./create-obligation.js"; +import { findSemanticUnit } from "./find-semantic-unit.js"; +import { + ReactHookStateUpdaterStatus, + ReactObligationStatus, + ReactProofClaim, + ReactUnitKind, +} from "./types.js"; +import type { + ReactAnalysisContext, + ReactProofEvidence, + ReactProofObligation, + ReactUnitDescriptor, +} from "./types.js"; + +export const analyzeHookStateTransitions = ( + unit: ReactUnitDescriptor, + context: ReactAnalysisContext, +): ReactProofObligation => { + if (unit.kind === ReactUnitKind.ClassComponent) { + return createObligation( + ReactProofClaim.HookStateTransitions, + ReactObligationStatus.Proved, + "The class component has no Hook state transitions", + ); + } + const semanticOwnerId = findSemanticUnit(unit, context)?.id; + if (!context.graph || !semanticOwnerId) { + return createObligation( + ReactProofClaim.HookStateTransitions, + ReactObligationStatus.Unknown, + "Hook state transitions have no semantic owner", + ); + } + const transitions = context.graph.hookStateTransitions.filter( + (transition) => transition.ownerId === semanticOwnerId, + ); + const violations: ReactProofEvidence[] = []; + const unknownEvidence: ReactProofEvidence[] = []; + for (const transition of transitions) { + const trace = [ + transition.setterName, + transition.updaterStatus, + transition.sourceComplete ? "modeled callback root" : "unmodeled callback root", + ]; + if (transition.updaterStatus === ReactHookStateUpdaterStatus.Impure) { + violations.push({ + description: `${transition.setterName} receives an updater with an observable side effect`, + location: transition.location, + trace, + }); + } else if (!transition.complete) { + let description = `${transition.setterName} executes outside a proved React callback root`; + if (transition.updaterStatus === ReactHookStateUpdaterStatus.SetterEscape) { + description = `${transition.setterName} escapes the modeled React callback graph`; + } else if (transition.updaterStatus === ReactHookStateUpdaterStatus.Unknown) { + description = `${transition.setterName} receives an updater without a proved body`; + } + unknownEvidence.push({ + description, + location: transition.location, + trace, + }); + } + } + if (violations.length > 0) { + return createObligation( + ReactProofClaim.HookStateTransitions, + ReactObligationStatus.Violated, + "A Hook state updater is impure", + violations, + ); + } + if (unknownEvidence.length > 0) { + return createObligation( + ReactProofClaim.HookStateTransitions, + ReactObligationStatus.Unknown, + "Hook state transition purity or callback ownership could not be proved", + unknownEvidence, + ); + } + return createObligation( + ReactProofClaim.HookStateTransitions, + ReactObligationStatus.Proved, + "Every represented Hook state transition has a proved callback root and pure updater", + ); +}; diff --git a/packages/prover/src/analyze-host-control.ts b/packages/prover/src/analyze-host-control.ts new file mode 100644 index 000000000..04aa6462f --- /dev/null +++ b/packages/prover/src/analyze-host-control.ts @@ -0,0 +1,111 @@ +import { createObligation } from "./create-obligation.js"; +import { findSemanticUnit } from "./find-semantic-unit.js"; +import { + ReactHostControlStatus, + ReactHostControlUpdateStatus, + ReactHostControlValueStatus, + ReactObligationStatus, + ReactProofClaim, +} from "./types.js"; +import type { + ReactAnalysisContext, + ReactProofEvidence, + ReactProofObligation, + ReactUnitDescriptor, +} from "./types.js"; + +export const analyzeHostControl = ( + unit: ReactUnitDescriptor, + context: ReactAnalysisContext, +): ReactProofObligation => { + const semanticUnit = findSemanticUnit(unit, context); + if (!context.graph || !semanticUnit) { + return createObligation( + ReactProofClaim.HostControl, + ReactObligationStatus.Unknown, + "The semantic graph could not identify host control protocols", + ); + } + const controls = context.graph.hostControls.filter( + (control) => control.ownerId === semanticUnit.id, + ); + const violatedEvidence: ReactProofEvidence[] = []; + const unknownEvidence: ReactProofEvidence[] = []; + for (const control of controls) { + if (control.controlledPropPresent && control.defaultPropPresent) { + violatedEvidence.push({ + description: `The host control receives both ${control.controlledPropName} and ${control.defaultPropName}`, + location: control.location, + trace: ["intrinsic form control", "controlled and default props", "conflicting ownership"], + }); + } + if (control.valueStatus === ReactHostControlValueStatus.MaySwitch) { + violatedEvidence.push({ + description: "The host control can switch between controlled and uncontrolled values", + location: control.location, + trace: ["React state", "defined and nullish values", "unstable control ownership"], + }); + } else if (control.valueStatus === ReactHostControlValueStatus.Nullish) { + violatedEvidence.push({ + description: `The host control passes a nullish ${control.controlledPropName} prop`, + location: control.location, + trace: ["intrinsic form control", "nullish controlled prop", "invalid React value"], + }); + } + if (control.updateStatus === ReactHostControlUpdateStatus.Missing) { + violatedEvidence.push({ + description: `An editable controlled field has no synchronous ${control.controlledPropName} update`, + location: control.location, + trace: ["onChange", "missing backing-state write", "read-only or reverted field"], + }); + } else if (control.updateStatus === ReactHostControlUpdateStatus.Conditional) { + violatedEvidence.push({ + description: "A controlled field updates its backing state only on some change paths", + location: control.location, + trace: ["onChange", "conditional state write", "stale controlled value"], + }); + } else if (control.updateStatus === ReactHostControlUpdateStatus.Deferred) { + violatedEvidence.push({ + description: "A controlled field defers its backing-state update", + location: control.location, + trace: ["onChange", "deferred state write", "React reverts the DOM value"], + }); + } else if (control.updateStatus === ReactHostControlUpdateStatus.WrongValue) { + violatedEvidence.push({ + description: "A controlled field does not synchronously echo the changed DOM value", + location: control.location, + trace: ["onChange", "different backing-state value", "selection or caret instability"], + }); + } + if (control.status === ReactHostControlStatus.Unknown) { + unknownEvidence.push({ + description: "The host control crosses an opaque value, prop, or callback boundary", + location: control.location, + trace: ["intrinsic form control", "unresolved protocol", "unknown control ownership"], + }); + } + } + if (violatedEvidence.length > 0) { + return createObligation( + ReactProofClaim.HostControl, + ReactObligationStatus.Violated, + "A host control violates React controlled-value requirements", + violatedEvidence, + ); + } + if (unknownEvidence.length > 0) { + return createObligation( + ReactProofClaim.HostControl, + ReactObligationStatus.Unknown, + "A host control protocol is incomplete", + unknownEvidence, + ); + } + return createObligation( + ReactProofClaim.HostControl, + ReactObligationStatus.Proved, + controls.length > 0 + ? "Every intrinsic form control has stable ownership and a valid update protocol" + : "The unit renders no intrinsic form controls", + ); +}; diff --git a/packages/prover/src/analyze-hydration-equivalence.ts b/packages/prover/src/analyze-hydration-equivalence.ts new file mode 100644 index 000000000..887e8fc13 --- /dev/null +++ b/packages/prover/src/analyze-hydration-equivalence.ts @@ -0,0 +1,94 @@ +import { createObligation } from "./create-obligation.js"; +import { findSemanticUnit } from "./find-semantic-unit.js"; +import { + ReactHydrationRootKind, + ReactHydrationStatus, + ReactObligationStatus, + ReactProofClaim, +} from "./types.js"; +import type { + ReactAnalysisContext, + ReactProofEvidence, + ReactProofObligation, + ReactUnitDescriptor, +} from "./types.js"; + +export const analyzeHydrationEquivalence = ( + unit: ReactUnitDescriptor, + context: ReactAnalysisContext, +): ReactProofObligation => { + const semanticUnit = findSemanticUnit(unit, context); + const hydration = semanticUnit + ? context.graph?.hydrations.find((candidate) => candidate.ownerId === semanticUnit.id) + : null; + if (!context.graph || !semanticUnit || !hydration) { + return createObligation( + ReactProofClaim.HydrationEquivalence, + ReactObligationStatus.Unknown, + "The semantic graph could not establish hydration reachability", + ); + } + const rootsById = new Map(context.graph.hydrationRoots.map((root) => [root.id, root])); + const hazardsById = new Map(context.graph.hydrationHazards.map((hazard) => [hazard.id, hazard])); + if (hydration.status === ReactHydrationStatus.Mismatched) { + const evidence: ReactProofEvidence[] = hydration.hazardIds.flatMap((hazardId) => { + const hazard = hazardsById.get(hazardId); + return hazard + ? [ + { + description: hazard.description, + location: hazard.location, + trace: ["server render", "first client render", "non-equivalent hydration output"], + }, + ] + : []; + }); + if (evidence.length === 0) { + const sourceRootIds = [ + ...hydration.staticServerRootIds, + ...hydration.clientRootIds, + ...hydration.interactiveServerRootIds, + ]; + for (const rootId of sourceRootIds) { + const root = rootsById.get(rootId); + if (!root) continue; + evidence.push({ + description: + root.kind === ReactHydrationRootKind.ServerStatic + ? `${root.apiName} produces markup that React cannot hydrate` + : `${root.apiName} has a different hydration root contract`, + location: root.location, + trace: ["server root", "client hydration root", "incompatible root contract"], + }); + } + } + return createObligation( + ReactProofClaim.HydrationEquivalence, + ReactObligationStatus.Violated, + "The first client render is not equivalent to the server-rendered tree", + evidence, + ); + } + if (hydration.status === ReactHydrationStatus.Unknown) { + const evidence = context.graph.hydrationRoots + .filter((root) => !root.sourceComplete) + .map((root) => ({ + description: `${root.apiName} has an unresolved execution root, component, or identifier prefix`, + location: root.location, + trace: ["React root", "opaque hydration contract", "unproved first render"], + })); + return createObligation( + ReactProofClaim.HydrationEquivalence, + ReactObligationStatus.Unknown, + "The server and client hydration roots cannot be paired completely", + evidence, + ); + } + return createObligation( + ReactProofClaim.HydrationEquivalence, + ReactObligationStatus.Proved, + hydration.status === ReactHydrationStatus.NotHydrated + ? "The unit is outside every source-visible hydration root" + : "The server and first client render have the same modeled environment contract", + ); +}; diff --git a/packages/prover/src/analyze-imperative-handle.ts b/packages/prover/src/analyze-imperative-handle.ts new file mode 100644 index 000000000..a21d8b0e1 --- /dev/null +++ b/packages/prover/src/analyze-imperative-handle.ts @@ -0,0 +1,76 @@ +import { createObligation } from "./create-obligation.js"; +import { findSemanticUnit } from "./find-semantic-unit.js"; +import { ReactImperativeHandleStatus, ReactObligationStatus, ReactProofClaim } from "./types.js"; +import type { + ReactAnalysisContext, + ReactProofEvidence, + ReactProofObligation, + ReactUnitDescriptor, +} from "./types.js"; + +export const analyzeImperativeHandle = ( + unit: ReactUnitDescriptor, + context: ReactAnalysisContext, +): ReactProofObligation => { + const semanticUnit = findSemanticUnit(unit, context); + if (!semanticUnit || !context.graph) { + return createObligation( + ReactProofClaim.ImperativeHandle, + ReactObligationStatus.Unknown, + "Imperative handle ownership has no semantic graph", + ); + } + const handles = context.graph.imperativeHandles.filter( + (handle) => handle.ownerId === semanticUnit.id, + ); + const violations: ReactProofEvidence[] = []; + const unknownEvidence: ReactProofEvidence[] = []; + for (const handle of handles) { + if (handle.status === ReactImperativeHandleStatus.MissingDependency) { + violations.push({ + description: `${handle.refName ?? "The imperative handle"} can expose stale reactive values because its dependency list is incomplete`, + location: handle.location, + trace: ["useImperativeHandle", "reactive factory capture", "stale exposed handle"], + }); + } else if (handle.status === ReactImperativeHandleStatus.ImpureFactory) { + violations.push({ + description: `${handle.refName ?? "The imperative handle"} creates its handle with an observable side effect`, + location: handle.location, + trace: ["layout commit", "createHandle", "non-repeat-safe side effect"], + }); + } else if (!handle.complete) { + unknownEvidence.push({ + description: `${handle.refName ?? "The imperative handle"} crosses an unresolved ref binding, method, invocation, or external render boundary`, + location: handle.location, + trace: [ + "useImperativeHandle", + handle.status, + handle.sourceComplete ? "known source" : "open ref protocol", + ], + }); + } + } + if (violations.length > 0) { + return createObligation( + ReactProofClaim.ImperativeHandle, + ReactObligationStatus.Violated, + "An imperative handle can become stale or repeat an unsafe factory side effect", + violations, + ); + } + if (unknownEvidence.length > 0) { + return createObligation( + ReactProofClaim.ImperativeHandle, + ReactObligationStatus.Unknown, + "Imperative handle ownership or invocation coverage is incomplete", + unknownEvidence, + ); + } + return createObligation( + ReactProofClaim.ImperativeHandle, + ReactObligationStatus.Proved, + handles.length > 0 + ? "Every imperative handle has a closed factory, ref binding, and invocation protocol" + : "The unit exposes no imperative handle", + ); +}; diff --git a/packages/prover/src/analyze-lazy-suspense.ts b/packages/prover/src/analyze-lazy-suspense.ts new file mode 100644 index 000000000..7bc07980e --- /dev/null +++ b/packages/prover/src/analyze-lazy-suspense.ts @@ -0,0 +1,110 @@ +import { createObligation } from "./create-obligation.js"; +import { findSemanticUnit } from "./find-semantic-unit.js"; +import { + ReactLazyDeclarationStatus, + ReactLazyLoaderStatus, + ReactObligationStatus, + ReactProofClaim, + ReactSuspenseCoverageStatus, +} from "./types.js"; +import type { + ReactAnalysisContext, + ReactProofEvidence, + ReactProofObligation, + ReactUnitDescriptor, +} from "./types.js"; + +export const analyzeLazySuspense = ( + unit: ReactUnitDescriptor, + context: ReactAnalysisContext, +): ReactProofObligation => { + const semanticUnit = findSemanticUnit(unit, context); + if (!context.graph || !semanticUnit) { + return createObligation( + ReactProofClaim.LazySuspense, + ReactObligationStatus.Unknown, + "The semantic graph could not identify lazy component topology", + ); + } + const lazyRenders = context.graph.lazyRenders.filter( + (render) => render.ownerId === semanticUnit.id, + ); + const renderedComponentIds = new Set(lazyRenders.map((render) => render.lazyComponentId)); + const lazyComponents = context.graph.lazyComponents.filter( + (component) => + component.declarationOwnerId === semanticUnit.id || + renderedComponentIds.has(component.id) || + (!component.identityResolved && + !component.declarationOwnerId && + semanticUnit.canBeRenderRoot), + ); + const violatedEvidence: ReactProofEvidence[] = []; + const unknownEvidence: ReactProofEvidence[] = []; + for (const component of lazyComponents) { + if (!component.identityResolved) { + unknownEvidence.push({ + description: `${component.name} has no stable symbol identity`, + location: component.location, + trace: ["lazy component", "unsupported declaration shape", "unresolved JSX renders"], + }); + } + if (component.declarationStatus === ReactLazyDeclarationStatus.RenderUnstable) { + violatedEvidence.push({ + description: `${component.name} is redeclared during React execution`, + location: component.location, + trace: ["lazy component", "unstable declaration identity", "state reset"], + }); + } + if (component.loaderStatus === ReactLazyLoaderStatus.Invalid) { + violatedEvidence.push({ + description: `${component.name} does not have a total thenable loader with a callable default export`, + location: component.location, + trace: ["lazy loader", "invalid resolved module", "unsupported component render"], + }); + } else if (component.loaderStatus === ReactLazyLoaderStatus.Opaque) { + unknownEvidence.push({ + description: `${component.name} crosses an unresolved lazy loader boundary`, + location: component.location, + trace: ["lazy loader", "opaque return flow", "unproved component module"], + }); + } + } + for (const render of lazyRenders) { + if (render.coverageStatus === ReactSuspenseCoverageStatus.OutsideBoundary) { + violatedEvidence.push({ + description: "A reachable lazy component render can suspend outside Suspense", + location: render.location, + trace: ["lazy component render", "root-reachable unbounded path", "missing fallback"], + }); + } else if (render.coverageStatus === ReactSuspenseCoverageStatus.Unknown) { + unknownEvidence.push({ + description: "A lazy component render crosses unresolved Suspense topology", + location: render.location, + trace: ["lazy component render", "opaque render or ReactNode slot", "unknown fallback"], + }); + } + } + if (violatedEvidence.length > 0) { + return createObligation( + ReactProofClaim.LazySuspense, + ReactObligationStatus.Violated, + "A lazy component violates declaration, loader, or Suspense coverage requirements", + violatedEvidence, + ); + } + if (unknownEvidence.length > 0) { + return createObligation( + ReactProofClaim.LazySuspense, + ReactObligationStatus.Unknown, + "A lazy component protocol crosses an opaque proof boundary", + unknownEvidence, + ); + } + return createObligation( + ReactProofClaim.LazySuspense, + ReactObligationStatus.Proved, + lazyRenders.length > 0 + ? "Every lazy component has stable loader identity and complete Suspense coverage" + : "The unit renders no lazy component", + ); +}; diff --git a/packages/prover/src/analyze-memo-dependencies.ts b/packages/prover/src/analyze-memo-dependencies.ts new file mode 100644 index 000000000..c07f9a9c3 --- /dev/null +++ b/packages/prover/src/analyze-memo-dependencies.ts @@ -0,0 +1,97 @@ +import ts from "typescript"; +import { collectHookBindings } from "./collect-hook-bindings.js"; +import { collectHookCalls } from "./collect-hook-calls.js"; +import { collectReactiveCaptures } from "./collect-reactive-captures.js"; +import { REACT_MEMO_HOOK_NAMES } from "./constants.js"; +import { createEvidence } from "./create-evidence.js"; +import { createObligation } from "./create-obligation.js"; +import { getCanonicalHookName } from "./get-canonical-hook-name.js"; +import { resolveFunction } from "./resolve-function.js"; +import { ReactObligationStatus, ReactProofClaim } from "./types.js"; +import { isReactiveCaptureDeclared } from "./utils/is-reactive-capture-declared.js"; +import type { ReactAnalysisContext, ReactProofEvidence, ReactProofObligation } from "./types.js"; + +export const analyzeMemoDependencies = ( + functionNode: ts.FunctionLikeDeclaration, + context: ReactAnalysisContext, +): ReactProofObligation => { + const hookBindings = collectHookBindings(functionNode, context.typeChecker); + const stableSymbols = new Set([...hookBindings.refs, ...hookBindings.stateSetters]); + const violations: ReactProofEvidence[] = []; + const unknownEvidence: ReactProofEvidence[] = []; + for (const memoCall of collectHookCalls( + functionNode, + REACT_MEMO_HOOK_NAMES, + context.typeChecker, + )) { + const hookName = getCanonicalHookName(memoCall, context.typeChecker) ?? "memo hook"; + const callbackExpression = memoCall.arguments[0]; + const callback = callbackExpression + ? resolveFunction(callbackExpression, context.typeChecker) + : null; + if (!callback) { + unknownEvidence.push( + createEvidence( + memoCall, + context.rootDirectory, + `The ${hookName} callback cannot be resolved`, + ["render", hookName, "opaque callback"], + ), + ); + continue; + } + const dependencyExpression = memoCall.arguments[1]; + if (!dependencyExpression || !ts.isArrayLiteralExpression(dependencyExpression)) { + unknownEvidence.push( + createEvidence( + dependencyExpression ?? memoCall, + context.rootDirectory, + `${hookName} does not have an inline dependency tuple`, + ["render", hookName, "dynamic dependency list"], + ), + ); + continue; + } + const declaredDependencies = dependencyExpression.elements.map((dependency) => + dependency.getText(), + ); + const captures = collectReactiveCaptures( + callback, + functionNode, + context.typeChecker, + stableSymbols, + ); + for (const capture of captures) { + if (isReactiveCaptureDeclared(capture.key, declaredDependencies)) continue; + violations.push( + createEvidence( + capture.node, + context.rootDirectory, + `${capture.key} is reactive but absent from the ${hookName} dependency list`, + ["render capture", capture.key, hookName, "stale closure"], + ), + ); + } + } + if (violations.length > 0) { + return createObligation( + ReactProofClaim.MemoDependencies, + ReactObligationStatus.Violated, + "A memoized callback or value can observe a stale reactive value", + violations, + ); + } + if (unknownEvidence.length > 0) { + return createObligation( + ReactProofClaim.MemoDependencies, + ReactObligationStatus.Unknown, + "Memo closure completeness could not be proved", + unknownEvidence, + ); + } + return createObligation( + ReactProofClaim.MemoDependencies, + ReactObligationStatus.Proved, + "Every memo closure capture is stable or represented by a dependency", + ); +}; diff --git a/packages/prover/src/analyze-memo-equivalence.ts b/packages/prover/src/analyze-memo-equivalence.ts new file mode 100644 index 000000000..bc3e11071 --- /dev/null +++ b/packages/prover/src/analyze-memo-equivalence.ts @@ -0,0 +1,83 @@ +import { createObligation } from "./create-obligation.js"; +import { findSemanticUnit } from "./find-semantic-unit.js"; +import { ReactMemoComparatorStatus, ReactObligationStatus, ReactProofClaim } from "./types.js"; +import { isMemoObservationCovered } from "./utils/is-memo-observation-covered.js"; +import type { + ReactAnalysisContext, + ReactProofEvidence, + ReactProofObligation, + ReactUnitDescriptor, +} from "./types.js"; + +export const analyzeMemoEquivalence = ( + unit: ReactUnitDescriptor, + context: ReactAnalysisContext, +): ReactProofObligation => { + const semanticUnit = findSemanticUnit(unit, context); + if (!context.graph || !semanticUnit) { + return createObligation( + ReactProofClaim.MemoEquivalence, + ReactObligationStatus.Unknown, + "The semantic graph could not identify the component memoization contract", + ); + } + const comparators = context.graph.memoComparators.filter( + (comparator) => comparator.ownerId === semanticUnit.id, + ); + const violations: ReactProofEvidence[] = []; + const unknownEvidence: ReactProofEvidence[] = []; + for (const comparator of comparators) { + if (comparator.status === ReactMemoComparatorStatus.OmittedObservedProp) { + for (const truePath of comparator.truePaths) { + if (!truePath.sourceComplete) continue; + for (const observation of comparator.observations) { + if ( + !observation.valueCanVary || + isMemoObservationCovered(observation.path, truePath.equalPropPaths) + ) { + continue; + } + violations.push({ + description: `The comparator can skip a render while ${observation.path} changes`, + location: observation.location, + trace: [ + `observed prop ${observation.path}`, + "custom memo comparator returns true", + "stale component output or behavior", + ], + }); + } + } + } else if (!comparator.complete) { + unknownEvidence.push({ + description: + "The memoized component, observed prop paths, or comparator return paths are unresolved", + location: comparator.comparatorLocation ?? comparator.location, + trace: ["React.memo", "opaque component or comparator path", "unknown render equivalence"], + }); + } + } + if (violations.length > 0) { + return createObligation( + ReactProofClaim.MemoEquivalence, + ReactObligationStatus.Violated, + "A custom memo comparator can suppress an observably different render", + violations, + ); + } + if (unknownEvidence.length > 0) { + return createObligation( + ReactProofClaim.MemoEquivalence, + ReactObligationStatus.Unknown, + "Memo bailout equivalence could not be proved", + unknownEvidence, + ); + } + return createObligation( + ReactProofClaim.MemoEquivalence, + ReactObligationStatus.Proved, + comparators.length === 0 + ? "The component has no source-visible React memo bailout" + : "Every modeled memo bailout preserves observed prop behavior", + ); +}; diff --git a/packages/prover/src/analyze-optimistic-state.ts b/packages/prover/src/analyze-optimistic-state.ts new file mode 100644 index 000000000..20091cd75 --- /dev/null +++ b/packages/prover/src/analyze-optimistic-state.ts @@ -0,0 +1,114 @@ +import { createEvidence } from "./create-evidence.js"; +import { createObligation } from "./create-obligation.js"; +import { findSemanticUnit } from "./find-semantic-unit.js"; +import { + ReactHookStateUpdaterStatus, + ReactObligationStatus, + ReactOptimisticActionStatus, + ReactOptimisticReducerStatus, + ReactProofClaim, +} from "./types.js"; +import type { + ReactAnalysisContext, + ReactProofEvidence, + ReactProofObligation, + ReactUnitDescriptor, +} from "./types.js"; + +export const analyzeOptimisticState = ( + unit: ReactUnitDescriptor, + context: ReactAnalysisContext, +): ReactProofObligation => { + const owner = findSemanticUnit(unit, context); + const states = owner + ? (context.graph?.optimisticStates.filter((state) => state.ownerId === owner.id) ?? []) + : []; + const updates = owner + ? (context.graph?.optimisticUpdates.filter((update) => update.ownerId === owner.id) ?? []) + : []; + const violations: ReactProofEvidence[] = []; + const unknownEvidence: ReactProofEvidence[] = []; + for (const state of states) { + if (state.reducerStatus === ReactOptimisticReducerStatus.Impure) { + violations.push({ + description: `${state.setterName} uses an impure optimistic reducer`, + location: state.location, + trace: ["useOptimistic", "reducer", "observable side effect"], + }); + } else if (!state.complete) { + unknownEvidence.push({ + description: `${state.setterName} has an unresolved optimistic reducer`, + location: state.location, + trace: ["useOptimistic", "reducer", state.reducerStatus], + }); + } + } + for (const update of updates) { + if (update.actionStatus === ReactOptimisticActionStatus.Render) { + violations.push({ + description: "Optimistic state is updated during render", + location: update.location, + trace: ["render", "optimistic setter", "forbidden update"], + }); + } else if (update.actionStatus === ReactOptimisticActionStatus.OutsideAction) { + violations.push({ + description: "Optimistic state is updated outside a Transition or Form Action", + location: update.location, + trace: ["non-Action callback", "optimistic setter", "temporary state reverts"], + }); + } + if (update.updaterStatus === ReactHookStateUpdaterStatus.Impure) { + violations.push({ + description: "An optimistic updater performs an observable side effect", + location: update.location, + trace: ["optimistic setter", "updater", "observable side effect"], + }); + } + if ( + !update.complete && + update.actionStatus !== ReactOptimisticActionStatus.Render && + update.actionStatus !== ReactOptimisticActionStatus.OutsideAction && + update.updaterStatus !== ReactHookStateUpdaterStatus.Impure + ) { + unknownEvidence.push({ + description: + update.updaterStatus === ReactHookStateUpdaterStatus.SetterEscape + ? "An optimistic setter escapes the modeled execution graph" + : "An optimistic update has an unresolved Action origin or updater", + location: update.location, + trace: ["useOptimistic", update.actionStatus, update.updaterStatus], + }); + } + } + if (!owner) { + unknownEvidence.push( + createEvidence( + unit.node, + context.rootDirectory, + "The optimistic state owner cannot be resolved", + ["React unit", "useOptimistic", "unknown owner"], + ), + ); + } + if (violations.length > 0) { + return createObligation( + ReactProofClaim.OptimisticState, + ReactObligationStatus.Violated, + "An optimistic update violates Action ownership or updater purity", + violations, + ); + } + if (unknownEvidence.length > 0) { + return createObligation( + ReactProofClaim.OptimisticState, + ReactObligationStatus.Unknown, + "Optimistic state ownership or purity is incomplete", + unknownEvidence, + ); + } + return createObligation( + ReactProofClaim.OptimisticState, + ReactObligationStatus.Proved, + "Every optimistic reducer and updater is pure and every update runs inside an Action", + ); +}; diff --git a/packages/prover/src/analyze-react-node-flow.ts b/packages/prover/src/analyze-react-node-flow.ts new file mode 100644 index 000000000..8f9da5b84 --- /dev/null +++ b/packages/prover/src/analyze-react-node-flow.ts @@ -0,0 +1,49 @@ +import { createObligation } from "./create-obligation.js"; +import { findSemanticUnit } from "./find-semantic-unit.js"; +import { ReactObligationStatus, ReactProofClaim } from "./types.js"; +import type { ReactAnalysisContext, ReactProofObligation, ReactUnitDescriptor } from "./types.js"; + +export const analyzeReactNodeFlow = ( + unit: ReactUnitDescriptor, + context: ReactAnalysisContext, +): ReactProofObligation => { + const semanticUnit = findSemanticUnit(unit, context); + if (!context.graph || !semanticUnit) { + return createObligation( + ReactProofClaim.ReactNodeFlow, + ReactObligationStatus.Unknown, + "The semantic graph could not identify this ReactNode source", + [], + ); + } + const slotFlows = context.graph.slotFlows.filter( + (slotFlow) => slotFlow.ownerId === semanticUnit.id, + ); + const incompleteSlotFlows = slotFlows.filter((slotFlow) => !slotFlow.complete); + if (incompleteSlotFlows.length > 0) { + return createObligation( + ReactProofClaim.ReactNodeFlow, + ReactObligationStatus.Unknown, + "A ReactNode slot crosses an unresolved render boundary", + incompleteSlotFlows.map((slotFlow) => ({ + description: slotFlow.sourceComplete + ? "The ReactNode value has no complete project-local placement path" + : "The ReactNode value reaches its slot through an unresolved source expression", + location: slotFlow.location, + trace: [ + slotFlow.propName ? `${slotFlow.propName} ReactNode` : "ReactNode value", + slotFlow.sourceComplete ? "closed source expression" : "unknown source expression", + slotFlow.placementComplete ? "closed component slot" : "unknown component slot", + ], + })), + ); + } + return createObligation( + ReactProofClaim.ReactNodeFlow, + ReactObligationStatus.Proved, + slotFlows.length > 0 + ? "Every ReactNode value has a closed project-local slot path" + : "The unit has no component ReactNode slot input", + [], + ); +}; diff --git a/packages/prover/src/analyze-react-unit.ts b/packages/prover/src/analyze-react-unit.ts new file mode 100644 index 000000000..ea86a12c8 --- /dev/null +++ b/packages/prover/src/analyze-react-unit.ts @@ -0,0 +1,191 @@ +import { analyzeActionState } from "./analyze-action-state.js"; +import { analyzeAsyncEffectOwnership } from "./analyze-async-effect-ownership.js"; +import { analyzeBoundaryCoverage } from "./analyze-boundary-coverage.js"; +import { analyzeCallableRefFreshness } from "./analyze-callable-ref-freshness.js"; +import { analyzeClassConstruction } from "./analyze-class-construction.js"; +import { analyzeClassStateTransitions } from "./analyze-class-state-transitions.js"; +import { analyzeComponentIdentity } from "./analyze-component-identity.js"; +import { analyzeComponentInvocation } from "./analyze-component-invocation.js"; +import { analyzeContextTopology } from "./analyze-context-topology.js"; +import { analyzeEffectCleanup } from "./analyze-effect-cleanup.js"; +import { analyzeEffectDependencies } from "./analyze-effect-dependencies.js"; +import { analyzeEffectEventUsage } from "./analyze-effect-event-usage.js"; +import { analyzeEffectStateUpdates } from "./analyze-effect-state-updates.js"; +import { analyzeErrorBoundary } from "./analyze-error-boundary.js"; +import { analyzeExternalStoreConsistency } from "./analyze-external-store-consistency.js"; +import { analyzeFormActions } from "./analyze-form-actions.js"; +import { analyzeFormStatus } from "./analyze-form-status.js"; +import { analyzeHookOrder } from "./analyze-hook-order.js"; +import { analyzeHookOwnership } from "./analyze-hook-ownership.js"; +import { analyzeHookStateTransitions } from "./analyze-hook-state-transitions.js"; +import { analyzeHostControl } from "./analyze-host-control.js"; +import { analyzeHydrationEquivalence } from "./analyze-hydration-equivalence.js"; +import { analyzeImperativeHandle } from "./analyze-imperative-handle.js"; +import { analyzeLazySuspense } from "./analyze-lazy-suspense.js"; +import { analyzeMemoDependencies } from "./analyze-memo-dependencies.js"; +import { analyzeMemoEquivalence } from "./analyze-memo-equivalence.js"; +import { analyzeOptimisticState } from "./analyze-optimistic-state.js"; +import { analyzeRefAccess } from "./analyze-ref-access.js"; +import { analyzeReactNodeFlow } from "./analyze-react-node-flow.js"; +import { analyzeReducerPurity } from "./analyze-reducer-purity.js"; +import { analyzeReducerTransitions } from "./analyze-reducer-transitions.js"; +import { analyzeReconciliationIdentity } from "./analyze-reconciliation-identity.js"; +import { analyzeRenderPurity } from "./analyze-render-purity.js"; +import { analyzeScheduledCallbackLifetime } from "./analyze-scheduled-callback-lifetime.js"; +import { analyzeTransitionActions } from "./analyze-transition-actions.js"; +import { analyzeUseResource } from "./analyze-use-resource.js"; +import { createEvidence } from "./create-evidence.js"; +import { createObligation } from "./create-obligation.js"; +import { getNodeLocation } from "./get-node-location.js"; +import { ReactObligationStatus, ReactProofClaim, ReactUnitKind } from "./types.js"; +import type { ReactAnalysisContext, ReactUnitDescriptor, ReactUnitProof } from "./types.js"; + +const ALL_REACT_PROOF_CLAIMS: ReadonlyArray = [ + ReactProofClaim.ActionState, + ReactProofClaim.AsyncEffectOwnership, + ReactProofClaim.BoundaryCoverage, + ReactProofClaim.CallableRefFreshness, + ReactProofClaim.ClassConstruction, + ReactProofClaim.ClassStateTransitions, + ReactProofClaim.ComponentIdentity, + ReactProofClaim.ComponentInvocation, + ReactProofClaim.ContextTopology, + ReactProofClaim.ErrorBoundary, + ReactProofClaim.EffectCleanup, + ReactProofClaim.EffectDependencies, + ReactProofClaim.EffectEventUsage, + ReactProofClaim.EffectStateUpdates, + ReactProofClaim.ExternalStoreConsistency, + ReactProofClaim.FormActions, + ReactProofClaim.FormStatus, + ReactProofClaim.HostControl, + ReactProofClaim.HookOrder, + ReactProofClaim.HookOwnership, + ReactProofClaim.HookStateTransitions, + ReactProofClaim.HydrationEquivalence, + ReactProofClaim.ImperativeHandle, + ReactProofClaim.LazySuspense, + ReactProofClaim.MemoDependencies, + ReactProofClaim.MemoEquivalence, + ReactProofClaim.OptimisticState, + ReactProofClaim.ReactNodeFlow, + ReactProofClaim.ReconciliationIdentity, + ReactProofClaim.ReducerPurity, + ReactProofClaim.ReducerTransitions, + ReactProofClaim.RefAccess, + ReactProofClaim.RenderPurity, + ReactProofClaim.ScheduledCallbackLifetime, + ReactProofClaim.TransitionActions, + ReactProofClaim.UseResource, +]; + +export const analyzeReactUnit = ( + unit: ReactUnitDescriptor, + context: ReactAnalysisContext, +): ReactUnitProof => { + if (unit.kind === ReactUnitKind.InvalidHookOwner) { + const hookEvidence = (unit.invalidHookCalls ?? []).map((hookCall) => + createEvidence( + hookCall, + context.rootDirectory, + `${hookCall.expression.getText()} is called outside a component or custom hook`, + ["React hook", "invalid function or module owner", "hook state has no valid owner"], + ), + ); + return { + name: unit.name, + kind: unit.kind, + location: getNodeLocation(unit.node, context.rootDirectory), + obligations: ALL_REACT_PROOF_CLAIMS.map((claim) => + claim === ReactProofClaim.HookOwnership + ? createObligation( + claim, + ReactObligationStatus.Violated, + "A hook call has no valid React owner", + hookEvidence, + ) + : createObligation( + claim, + ReactObligationStatus.Unknown, + "The invalid hook owner prevents this proof", + hookEvidence, + ), + ), + }; + } + if (!unit.functionNode || !unit.sourceComplete) { + const evidence = [ + createEvidence( + unit.node, + context.rootDirectory, + unit.kind === ReactUnitKind.ClassComponent + ? "The class contains constructor, field, lifecycle, ref, or custom method semantics that are not modeled yet" + : "The React unit has no analyzable execution root", + [ + unit.kind === ReactUnitKind.ClassComponent ? "class component" : "React unit", + "unmodeled execution surface", + "unsupported proof model", + ], + ), + ]; + return { + name: unit.name, + kind: unit.kind, + location: getNodeLocation(unit.node, context.rootDirectory), + obligations: ALL_REACT_PROOF_CLAIMS.map((claim) => + createObligation( + claim, + ReactObligationStatus.Unknown, + unit.kind === ReactUnitKind.ClassComponent + ? "Class component execution coverage is incomplete" + : "React unit proof is incomplete", + evidence, + ), + ), + }; + } + + return { + name: unit.name, + kind: unit.kind, + location: getNodeLocation(unit.node, context.rootDirectory), + obligations: [ + analyzeActionState(unit, context), + analyzeAsyncEffectOwnership(unit.functionNode, context), + analyzeBoundaryCoverage(unit, context), + analyzeCallableRefFreshness(unit, context), + analyzeClassConstruction(unit, context), + analyzeClassStateTransitions(unit, context), + analyzeComponentIdentity(unit.functionNode, context), + analyzeComponentInvocation(unit.functionNode, context), + analyzeContextTopology(unit, context), + analyzeEffectCleanup(unit, context), + analyzeEffectDependencies(unit.functionNode, context), + analyzeEffectEventUsage(unit.functionNode, context), + analyzeEffectStateUpdates(unit, context), + analyzeErrorBoundary(unit, context), + analyzeExternalStoreConsistency(unit, context), + analyzeFormActions(unit, context), + analyzeFormStatus(unit, context), + analyzeHostControl(unit, context), + analyzeHookOrder(unit.functionNode, context), + analyzeHookOwnership(unit.functionNode), + analyzeHookStateTransitions(unit, context), + analyzeHydrationEquivalence(unit, context), + analyzeImperativeHandle(unit, context), + analyzeLazySuspense(unit, context), + analyzeMemoDependencies(unit.functionNode, context), + analyzeMemoEquivalence(unit, context), + analyzeOptimisticState(unit, context), + analyzeReactNodeFlow(unit, context), + analyzeReconciliationIdentity(unit.functionNode, context), + analyzeReducerPurity(unit.functionNode, context), + analyzeReducerTransitions(unit, context), + analyzeRefAccess(unit.functionNode, context), + analyzeRenderPurity(unit.functionNode, context), + analyzeScheduledCallbackLifetime(unit, context), + analyzeTransitionActions(unit, context), + analyzeUseResource(unit, context), + ], + }; +}; diff --git a/packages/prover/src/analyze-reconciliation-identity.ts b/packages/prover/src/analyze-reconciliation-identity.ts new file mode 100644 index 000000000..4a8f363e5 --- /dev/null +++ b/packages/prover/src/analyze-reconciliation-identity.ts @@ -0,0 +1,396 @@ +import ts from "typescript"; +import { collectBindingIdentifiers } from "./collect-binding-identifiers.js"; +import { createEvidence } from "./create-evidence.js"; +import { createObligation } from "./create-obligation.js"; +import { isFunctionBoundary } from "./is-function-boundary.js"; +import { resolveFunction } from "./resolve-function.js"; +import { ReactObligationStatus, ReactProofClaim } from "./types.js"; +import type { ReactAnalysisContext, ReactProofEvidence, ReactProofObligation } from "./types.js"; + +interface JsxKeyFact { + node: ts.JsxElement | ts.JsxSelfClosingElement | ts.JsxFragment; + keyExpression: ts.Expression | null; + staticKey: string | null; +} + +const getDirectJsxNodes = ( + expression: ts.Expression, +): ReadonlyArray => { + if ( + ts.isJsxElement(expression) || + ts.isJsxSelfClosingElement(expression) || + ts.isJsxFragment(expression) + ) { + return [expression]; + } + if ( + ts.isParenthesizedExpression(expression) || + ts.isAsExpression(expression) || + ts.isSatisfiesExpression(expression) || + ts.isNonNullExpression(expression) + ) { + return getDirectJsxNodes(expression.expression); + } + if (ts.isConditionalExpression(expression)) { + return [...getDirectJsxNodes(expression.whenTrue), ...getDirectJsxNodes(expression.whenFalse)]; + } + return []; +}; + +const getJsxKeyFact = ( + node: ts.JsxElement | ts.JsxSelfClosingElement | ts.JsxFragment, +): JsxKeyFact => { + if (ts.isJsxFragment(node)) { + return { node, keyExpression: null, staticKey: null }; + } + const attributes = ts.isJsxElement(node) ? node.openingElement.attributes : node.attributes; + const keyAttribute = attributes.properties.find( + (attribute) => ts.isJsxAttribute(attribute) && attribute.name.getText() === "key", + ); + if (!keyAttribute || !ts.isJsxAttribute(keyAttribute) || !keyAttribute.initializer) { + return { node, keyExpression: null, staticKey: null }; + } + if (ts.isStringLiteral(keyAttribute.initializer)) { + return { + node, + keyExpression: keyAttribute.initializer, + staticKey: keyAttribute.initializer.text, + }; + } + if (ts.isJsxExpression(keyAttribute.initializer) && keyAttribute.initializer.expression) { + const keyExpression = keyAttribute.initializer.expression; + const staticKey = + ts.isStringLiteral(keyExpression) || ts.isNumericLiteral(keyExpression) + ? keyExpression.text + : null; + return { node, keyExpression, staticKey }; + } + return { node, keyExpression: null, staticKey: null }; +}; + +const getReturnedJsxNodes = ( + callback: ts.FunctionLikeDeclaration, +): ReadonlyArray => { + if (callback.body && !ts.isBlock(callback.body)) { + return getDirectJsxNodes(callback.body); + } + const returnedNodes: Array = []; + const visit = (node: ts.Node): void => { + if (node !== callback && isFunctionBoundary(node)) return; + if (ts.isReturnStatement(node) && node.expression) { + returnedNodes.push(...getDirectJsxNodes(node.expression)); + return; + } + node.forEachChild(visit); + }; + callback.body?.forEachChild(visit); + return returnedNodes; +}; + +const referencesSymbol = ( + expression: ts.Expression, + symbol: ts.Symbol, + typeChecker: ts.TypeChecker, +): boolean => { + let didFindSymbol = false; + const visit = (node: ts.Node): void => { + if (ts.isIdentifier(node) && typeChecker.getSymbolAtLocation(node) === symbol) { + didFindSymbol = true; + return; + } + node.forEachChild(visit); + }; + visit(expression); + return didFindSymbol; +}; + +const getContainingForStatement = ( + node: ts.Node, + owner: ts.FunctionLikeDeclaration, +): ts.ForStatement | null => { + let currentNode = node; + while (currentNode !== owner) { + const parentNode = currentNode.parent; + if (!parentNode) return null; + if (ts.isForStatement(parentNode)) return parentNode; + currentNode = parentNode; + } + return null; +}; + +const getForInitializerSymbols = ( + forStatement: ts.ForStatement, + typeChecker: ts.TypeChecker, +): ReadonlySet => { + const symbols = new Set(); + if (forStatement.initializer && ts.isVariableDeclarationList(forStatement.initializer)) { + for (const declaration of forStatement.initializer.declarations) { + for (const identifier of collectBindingIdentifiers(declaration.name)) { + const symbol = typeChecker.getSymbolAtLocation(identifier); + if (symbol) symbols.add(symbol); + } + } + } + return symbols; +}; + +const referencesAnySymbol = ( + expression: ts.Expression, + symbols: ReadonlySet, + typeChecker: ts.TypeChecker, +): boolean => [...symbols].some((symbol) => referencesSymbol(expression, symbol, typeChecker)); + +const dependsOnForInitializer = ( + expression: ts.Expression, + forSymbols: ReadonlySet, + typeChecker: ts.TypeChecker, +): boolean => { + if (referencesAnySymbol(expression, forSymbols, typeChecker)) return true; + let didFindDependency = false; + const visit = (node: ts.Node): void => { + if (!ts.isIdentifier(node)) { + node.forEachChild(visit); + return; + } + const symbol = typeChecker.getSymbolAtLocation(node); + for (const declaration of symbol?.declarations ?? []) { + if ( + ts.isVariableDeclaration(declaration) && + declaration.initializer && + referencesAnySymbol(declaration.initializer, forSymbols, typeChecker) + ) { + didFindDependency = true; + return; + } + } + }; + visit(expression); + return didFindDependency; +}; + +const isCollectionRendered = ( + collection: ts.Expression, + functionNode: ts.FunctionLikeDeclaration, + typeChecker: ts.TypeChecker, +): boolean => { + if (!ts.isIdentifier(collection)) return false; + const collectionSymbol = typeChecker.getSymbolAtLocation(collection); + if (!collectionSymbol) return false; + let didFindRenderedReference = false; + const visit = (node: ts.Node): void => { + if (node !== functionNode && isFunctionBoundary(node)) return; + if ( + ts.isIdentifier(node) && + node !== collection && + typeChecker.getSymbolAtLocation(node) === collectionSymbol + ) { + let currentNode: ts.Node | undefined = node; + while (currentNode && currentNode !== functionNode) { + if (ts.isReturnStatement(currentNode) || ts.isJsxExpression(currentNode)) { + didFindRenderedReference = true; + return; + } + currentNode = currentNode.parent; + } + } + node.forEachChild(visit); + }; + functionNode.forEachChild(visit); + return didFindRenderedReference; +}; + +export const analyzeReconciliationIdentity = ( + functionNode: ts.FunctionLikeDeclaration, + context: ReactAnalysisContext, +): ReactProofObligation => { + const violations: ReactProofEvidence[] = []; + const unknownEvidence: ReactProofEvidence[] = []; + const visit = (node: ts.Node): void => { + if (node !== functionNode && isFunctionBoundary(node)) return; + if (ts.isArrayLiteralExpression(node)) { + const keyFacts = node.elements.flatMap((element) => + ts.isJsxElement(element) || ts.isJsxSelfClosingElement(element) || ts.isJsxFragment(element) + ? [getJsxKeyFact(element)] + : [], + ); + const seenStaticKeys = new Set(); + for (const keyFact of keyFacts) { + if (!keyFact.keyExpression) { + violations.push( + createEvidence( + keyFact.node, + context.rootDirectory, + "A JSX child in an array has no reconciliation key", + ["render list", "unkeyed child", "ambiguous state identity"], + ), + ); + } else if (keyFact.staticKey && seenStaticKeys.has(keyFact.staticKey)) { + violations.push( + createEvidence( + keyFact.keyExpression, + context.rootDirectory, + `The reconciliation key ${keyFact.staticKey} is duplicated`, + ["render list", `key ${keyFact.staticKey}`, "duplicate state identity"], + ), + ); + } else if (!keyFact.staticKey && keyFacts.length > 1) { + unknownEvidence.push( + createEvidence( + keyFact.keyExpression, + context.rootDirectory, + "Dynamic array key uniqueness has no checked contract", + ["render list", keyFact.keyExpression.getText(), "unproved uniqueness"], + ), + ); + } + if (keyFact.staticKey) seenStaticKeys.add(keyFact.staticKey); + } + } + if ( + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + node.expression.name.text === "map" + ) { + const callbackExpression = node.arguments[0]; + const callback = callbackExpression + ? resolveFunction(callbackExpression, context.typeChecker) + : null; + if (!callback) { + unknownEvidence.push( + createEvidence( + node, + context.rootDirectory, + "The list mapping callback cannot be resolved", + ["render list", "opaque mapping callback", "reconciliation"], + ), + ); + } else { + const indexParameter = callback.parameters[1]; + const indexSymbol = + indexParameter && ts.isIdentifier(indexParameter.name) + ? context.typeChecker.getSymbolAtLocation(indexParameter.name) + : null; + for (const returnedNode of getReturnedJsxNodes(callback)) { + const keyFact = getJsxKeyFact(returnedNode); + if (!keyFact.keyExpression) { + violations.push( + createEvidence( + returnedNode, + context.rootDirectory, + "A JSX child returned from map has no reconciliation key", + ["render list", "map callback", "unkeyed child"], + ), + ); + } else if (keyFact.staticKey) { + violations.push( + createEvidence( + keyFact.keyExpression, + context.rootDirectory, + `The constant key ${keyFact.staticKey} is shared by every mapped child`, + ["render list", "map callback", "duplicate key"], + ), + ); + } else if ( + indexSymbol && + referencesSymbol(keyFact.keyExpression, indexSymbol, context.typeChecker) + ) { + unknownEvidence.push( + createEvidence( + keyFact.keyExpression, + context.rootDirectory, + "An index key cannot prove state preservation across insertion or reordering", + ["render list", "index key", "unproved reorder stability"], + ), + ); + } else { + unknownEvidence.push( + createEvidence( + keyFact.keyExpression, + context.rootDirectory, + "Mapped key uniqueness has no checked data contract", + ["render list", keyFact.keyExpression.getText(), "unproved uniqueness"], + ), + ); + } + } + } + } + if ( + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + node.expression.name.text === "push" && + isCollectionRendered(node.expression.expression, functionNode, context.typeChecker) + ) { + const forStatement = getContainingForStatement(node, functionNode); + const pushedExpression = node.arguments[0]; + if (forStatement && pushedExpression) { + const forSymbols = getForInitializerSymbols(forStatement, context.typeChecker); + for (const pushedNode of getDirectJsxNodes(pushedExpression)) { + const keyFact = getJsxKeyFact(pushedNode); + if (!keyFact.keyExpression) { + violations.push( + createEvidence( + pushedNode, + context.rootDirectory, + "A JSX child pushed from a loop has no reconciliation key", + ["render list", "loop push", "unkeyed child"], + ), + ); + } else if (keyFact.staticKey) { + violations.push( + createEvidence( + keyFact.keyExpression, + context.rootDirectory, + `The constant key ${keyFact.staticKey} is shared by every loop iteration`, + ["render list", "loop push", "duplicate key"], + ), + ); + } else if ( + dependsOnForInitializer(keyFact.keyExpression, forSymbols, context.typeChecker) + ) { + unknownEvidence.push( + createEvidence( + keyFact.keyExpression, + context.rootDirectory, + "A loop-index-derived key cannot prove state preservation when positions shift", + ["render list", "loop index key", "unproved semantic identity"], + ), + ); + } else { + unknownEvidence.push( + createEvidence( + keyFact.keyExpression, + context.rootDirectory, + "Loop-generated key uniqueness has no checked data contract", + ["render list", keyFact.keyExpression.getText(), "unproved uniqueness"], + ), + ); + } + } + } + } + node.forEachChild(visit); + }; + functionNode.forEachChild(visit); + if (violations.length > 0) { + return createObligation( + ReactProofClaim.ReconciliationIdentity, + ReactObligationStatus.Violated, + "A rendered list has ambiguous child identity", + violations, + ); + } + if (unknownEvidence.length > 0) { + return createObligation( + ReactProofClaim.ReconciliationIdentity, + ReactObligationStatus.Unknown, + "List state preservation could not be proved", + unknownEvidence, + ); + } + return createObligation( + ReactProofClaim.ReconciliationIdentity, + ReactObligationStatus.Proved, + "Every represented child position has unambiguous reconciliation identity", + ); +}; diff --git a/packages/prover/src/analyze-reducer-purity.ts b/packages/prover/src/analyze-reducer-purity.ts new file mode 100644 index 000000000..6491f5d32 --- /dev/null +++ b/packages/prover/src/analyze-reducer-purity.ts @@ -0,0 +1,94 @@ +import ts from "typescript"; +import { analyzeRenderPurity } from "./analyze-render-purity.js"; +import { collectHookCalls } from "./collect-hook-calls.js"; +import { REACT_REDUCER_HOOK_NAMES } from "./constants.js"; +import { createEvidence } from "./create-evidence.js"; +import { createObligation } from "./create-obligation.js"; +import { resolveFunction } from "./resolve-function.js"; +import { ReactObligationStatus, ReactProofClaim } from "./types.js"; +import type { ReactAnalysisContext, ReactProofEvidence, ReactProofObligation } from "./types.js"; + +const analyzeReducerFunction = ( + expression: ts.Expression | undefined, + label: string, + call: ts.CallExpression, + context: ReactAnalysisContext, +): ReactProofObligation => { + const reducerFunction = expression ? resolveFunction(expression, context.typeChecker) : null; + if (!reducerFunction) { + return createObligation( + ReactProofClaim.ReducerPurity, + ReactObligationStatus.Unknown, + `${label} purity could not be proved`, + [ + createEvidence( + expression ?? call, + context.rootDirectory, + `The ${label} function cannot be resolved`, + ["useReducer", label, "opaque transition"], + ), + ], + ); + } + return analyzeRenderPurity(reducerFunction, context); +}; + +export const analyzeReducerPurity = ( + functionNode: ts.FunctionLikeDeclaration, + context: ReactAnalysisContext, +): ReactProofObligation => { + const violations: ReactProofEvidence[] = []; + const unknownEvidence: ReactProofEvidence[] = []; + for (const reducerCall of collectHookCalls( + functionNode, + REACT_REDUCER_HOOK_NAMES, + context.typeChecker, + )) { + const reducerProof = analyzeReducerFunction( + reducerCall.arguments[0], + "reducer", + reducerCall, + context, + ); + if (reducerProof.status === ReactObligationStatus.Violated) { + violations.push(...reducerProof.evidence); + } else if (reducerProof.status === ReactObligationStatus.Unknown) { + unknownEvidence.push(...reducerProof.evidence); + } + const initializerExpression = reducerCall.arguments[2]; + if (initializerExpression) { + const initializerProof = analyzeReducerFunction( + initializerExpression, + "reducer initializer", + reducerCall, + context, + ); + if (initializerProof.status === ReactObligationStatus.Violated) { + violations.push(...initializerProof.evidence); + } else if (initializerProof.status === ReactObligationStatus.Unknown) { + unknownEvidence.push(...initializerProof.evidence); + } + } + } + if (violations.length > 0) { + return createObligation( + ReactProofClaim.ReducerPurity, + ReactObligationStatus.Violated, + "A reducer transition is not pure", + violations, + ); + } + if (unknownEvidence.length > 0) { + return createObligation( + ReactProofClaim.ReducerPurity, + ReactObligationStatus.Unknown, + "Reducer purity depends on an opaque transition", + unknownEvidence, + ); + } + return createObligation( + ReactProofClaim.ReducerPurity, + ReactObligationStatus.Proved, + "Every reducer and initializer transition is pure", + ); +}; diff --git a/packages/prover/src/analyze-reducer-transitions.ts b/packages/prover/src/analyze-reducer-transitions.ts new file mode 100644 index 000000000..da0ed70c4 --- /dev/null +++ b/packages/prover/src/analyze-reducer-transitions.ts @@ -0,0 +1,127 @@ +import { createObligation } from "./create-obligation.js"; +import { findSemanticUnit } from "./find-semantic-unit.js"; +import { + ReactObligationStatus, + ReactProofClaim, + ReactReducerDispatchStatus, + ReactReducerPurityStatus, + ReactReducerReturnStatus, + ReactUnitKind, +} from "./types.js"; +import type { + ReactAnalysisContext, + ReactProofEvidence, + ReactProofObligation, + ReactUnitDescriptor, +} from "./types.js"; + +export const analyzeReducerTransitions = ( + unit: ReactUnitDescriptor, + context: ReactAnalysisContext, +): ReactProofObligation => { + if (unit.kind === ReactUnitKind.ClassComponent) { + return createObligation( + ReactProofClaim.ReducerTransitions, + ReactObligationStatus.Proved, + "The class component has no reducer transition protocol", + ); + } + const semanticOwnerId = findSemanticUnit(unit, context)?.id; + if (!context.graph || !semanticOwnerId) { + return createObligation( + ReactProofClaim.ReducerTransitions, + ReactObligationStatus.Unknown, + "Reducer transitions have no semantic owner", + ); + } + const reducers = context.graph.reducers.filter((reducer) => reducer.ownerId === semanticOwnerId); + const dispatches = context.graph.reducerDispatches.filter( + (dispatch) => dispatch.ownerId === semanticOwnerId, + ); + const violations: ReactProofEvidence[] = []; + const unknownEvidence: ReactProofEvidence[] = []; + for (const reducer of reducers) { + if ( + reducer.reducerPurity === ReactReducerPurityStatus.Impure || + reducer.initializerPurity === ReactReducerPurityStatus.Impure + ) { + violations.push({ + description: `${reducer.dispatcherName} has an impure reducer or initializer`, + location: reducer.location, + trace: ["useReducer", reducer.dispatcherName, "impure state transition"], + }); + } + if ( + reducer.reducerReturnStatus === ReactReducerReturnStatus.MayFallThrough || + reducer.initializerReturnStatus === ReactReducerReturnStatus.MayFallThrough + ) { + violations.push({ + description: `${reducer.dispatcherName} can complete without returning state`, + location: reducer.location, + trace: ["useReducer", reducer.dispatcherName, "fallthrough path"], + }); + } + if ( + reducer.reducerReturnStatus === ReactReducerReturnStatus.MayThrow || + reducer.initializerReturnStatus === ReactReducerReturnStatus.MayThrow + ) { + violations.push({ + description: `${reducer.dispatcherName} can throw instead of returning state`, + location: reducer.location, + trace: ["useReducer", reducer.dispatcherName, "throw path"], + }); + } + if (!reducer.sourceComplete) { + unknownEvidence.push({ + description: `${reducer.dispatcherName} depends on an opaque reducer, initializer, or control-flow path`, + location: reducer.location, + trace: ["useReducer", reducer.dispatcherName, "incomplete transition definition"], + }); + } + } + for (const dispatch of dispatches) { + if ( + dispatch.status === ReactReducerDispatchStatus.Render || + dispatch.status === ReactReducerDispatchStatus.Reducer + ) { + violations.push({ + description: + dispatch.status === ReactReducerDispatchStatus.Render + ? "A reducer dispatch executes during render" + : "A reducer dispatch executes from a reducer transition", + location: dispatch.location, + trace: ["useReducer dispatch", dispatch.status, "nested render update"], + }); + } else if (!dispatch.complete) { + unknownEvidence.push({ + description: + dispatch.status === ReactReducerDispatchStatus.Escape + ? "A reducer dispatcher escapes the modeled React callback graph" + : "A reducer dispatch has no proved React callback owner", + location: dispatch.location, + trace: ["useReducer dispatch", dispatch.status, "incomplete execution ownership"], + }); + } + } + if (violations.length > 0) { + return createObligation( + ReactProofClaim.ReducerTransitions, + ReactObligationStatus.Violated, + "A reducer transition is impure, non-total, or executes from an invalid phase", + violations, + ); + } + if (unknownEvidence.length > 0) { + return createObligation( + ReactProofClaim.ReducerTransitions, + ReactObligationStatus.Unknown, + "Reducer transition totality or dispatch ownership could not be proved", + unknownEvidence, + ); + } + return createObligation( + ReactProofClaim.ReducerTransitions, + ReactObligationStatus.Proved, + "Every reducer transition is pure and total with owned non-render dispatches", + ); +}; diff --git a/packages/prover/src/analyze-ref-access.ts b/packages/prover/src/analyze-ref-access.ts new file mode 100644 index 000000000..99e986e47 --- /dev/null +++ b/packages/prover/src/analyze-ref-access.ts @@ -0,0 +1,52 @@ +import ts from "typescript"; +import { collectHookBindings } from "./collect-hook-bindings.js"; +import { createEvidence } from "./create-evidence.js"; +import { createObligation } from "./create-obligation.js"; +import { isFunctionBoundary } from "./is-function-boundary.js"; +import { ReactObligationStatus, ReactProofClaim } from "./types.js"; +import type { ReactAnalysisContext, ReactProofEvidence, ReactProofObligation } from "./types.js"; + +export const analyzeRefAccess = ( + functionNode: ts.FunctionLikeDeclaration, + context: ReactAnalysisContext, +): ReactProofObligation => { + const hookBindings = collectHookBindings(functionNode, context.typeChecker); + const violations: ReactProofEvidence[] = []; + const visit = (node: ts.Node): void => { + if (node !== functionNode && isFunctionBoundary(node)) { + return; + } + if ( + ts.isPropertyAccessExpression(node) && + node.name.text === "current" && + ts.isIdentifier(node.expression) + ) { + const refSymbol = context.typeChecker.getSymbolAtLocation(node.expression); + if (refSymbol && hookBindings.refs.has(refSymbol)) { + violations.push( + createEvidence( + node, + context.rootDirectory, + `${node.expression.text}.current is accessed during render`, + ["render", `${node.expression.text}.current`, "phase-sensitive ref access"], + ), + ); + } + } + node.forEachChild(visit); + }; + functionNode.forEachChild(visit); + if (violations.length > 0) { + return createObligation( + ReactProofClaim.RefAccess, + ReactObligationStatus.Violated, + "A component reads or writes a ref during render", + violations, + ); + } + return createObligation( + ReactProofClaim.RefAccess, + ReactObligationStatus.Proved, + "Ref access is confined to non-render phases", + ); +}; diff --git a/packages/prover/src/analyze-render-purity.ts b/packages/prover/src/analyze-render-purity.ts new file mode 100644 index 000000000..c740514b0 --- /dev/null +++ b/packages/prover/src/analyze-render-purity.ts @@ -0,0 +1,316 @@ +import ts from "typescript"; +import { + KNOWN_IMPURE_RENDER_CALLS, + KNOWN_MUTATING_STANDARD_METHOD_NAMES, + KNOWN_PURE_GLOBAL_CALLS, + KNOWN_PURE_METHOD_NAMES, + KNOWN_PURE_STANDARD_METHOD_NAMES, + MUTATING_METHOD_NAMES, + REACT_MODELED_HOOK_NAMES, + REACT_PURE_RENDER_API_NAMES, + REACT_UNMODELED_HOOK_NAMES, +} from "./constants.js"; +import { collectBindingIdentifiers } from "./collect-binding-identifiers.js"; +import { collectHookBindings } from "./collect-hook-bindings.js"; +import { collectReachableFunctionGraph } from "./collect-reachable-functions.js"; +import { createEvidence } from "./create-evidence.js"; +import { createObligation } from "./create-obligation.js"; +import { getCanonicalHookName } from "./get-canonical-hook-name.js"; +import { getCanonicalReactApiName } from "./get-canonical-react-api-name.js"; +import { getCallName } from "./get-call-name.js"; +import { getRootIdentifier } from "./get-root-identifier.js"; +import { isNodeWithin } from "./is-node-within.js"; +import { isFunctionBoundary } from "./is-function-boundary.js"; +import { resolveFunction } from "./resolve-function.js"; +import { ReactObligationStatus, ReactProofClaim } from "./types.js"; +import { unwrapTypescriptExpression } from "./unwrap-typescript-expression.js"; +import { isAssignmentOperator } from "./utils/is-assignment-operator.js"; +import type { ReactAnalysisContext, ReactProofEvidence, ReactProofObligation } from "./types.js"; + +const KNOWN_RENDER_SIDE_EFFECT_CALLS = new Set([ + "alert", + "console.error", + "console.info", + "console.log", + "console.warn", + "document.write", + "fetch", + "localStorage.clear", + "localStorage.removeItem", + "localStorage.setItem", + "sessionStorage.clear", + "sessionStorage.removeItem", + "sessionStorage.setItem", +]); + +const isStandardLibraryMethodCall = ( + callExpression: ts.CallExpression, + methodNames: ReadonlySet, + context: ReactAnalysisContext, +): boolean => { + if (!ts.isPropertyAccessExpression(callExpression.expression)) return false; + const methodName = callExpression.expression.name.text; + if (!methodNames.has(methodName)) return false; + const methodSymbol = context.typeChecker.getSymbolAtLocation(callExpression.expression.name); + return Boolean( + methodSymbol?.declarations?.length && + methodSymbol.declarations.every((declaration) => + context.program.isSourceFileDefaultLibrary(declaration.getSourceFile()), + ), + ); +}; + +const isProtectedMutation = ( + expression: ts.Expression, + functionNode: ts.FunctionLikeDeclaration, + context: ReactAnalysisContext, + protectedSymbols: ReadonlySet, +): boolean => { + const rootIdentifier = getRootIdentifier(expression); + if (!rootIdentifier) return true; + const rootSymbol = context.typeChecker.getSymbolAtLocation(rootIdentifier); + if (!rootSymbol) return true; + const hasLocalBinding = Boolean( + rootSymbol.declarations?.some( + (declaration) => + (ts.isVariableDeclaration(declaration) || ts.isParameter(declaration)) && + isNodeWithin(declaration, functionNode), + ), + ); + if (ts.isIdentifier(unwrapTypescriptExpression(expression)) && hasLocalBinding) return false; + if (protectedSymbols.has(rootSymbol)) return true; + if (!hasLocalBinding) return true; + return !isFreshLocalMutation(expression, functionNode, context); +}; + +const isFreshLocalMutation = ( + expression: ts.Expression, + functionNode: ts.FunctionLikeDeclaration, + context: ReactAnalysisContext, +): boolean => { + const unwrappedExpression = unwrapTypescriptExpression(expression); + if ( + ts.isArrayLiteralExpression(unwrappedExpression) || + ts.isObjectLiteralExpression(unwrappedExpression) || + ts.isNewExpression(unwrappedExpression) + ) { + return true; + } + const rootIdentifier = getRootIdentifier(expression); + if (!rootIdentifier) return false; + const rootSymbol = context.typeChecker.getSymbolAtLocation(rootIdentifier); + return Boolean( + rootSymbol?.declarations?.some( + (declaration) => + ts.isVariableDeclaration(declaration) && + declaration.initializer && + isNodeWithin(declaration, functionNode) && + (ts.isArrayLiteralExpression(unwrapTypescriptExpression(declaration.initializer)) || + ts.isObjectLiteralExpression(unwrapTypescriptExpression(declaration.initializer)) || + ts.isNewExpression(unwrapTypescriptExpression(declaration.initializer))), + ), + ); +}; + +export const analyzeRenderPurity = ( + functionNode: ts.FunctionLikeDeclaration, + context: ReactAnalysisContext, +): ReactProofObligation => { + const hookBindings = collectHookBindings(functionNode, context.typeChecker); + const protectedSymbols = new Set([ + ...hookBindings.refs, + ...hookBindings.stateValues, + ...functionNode.parameters.flatMap((parameter) => + collectBindingIdentifiers(parameter.name).flatMap((identifier) => { + const parameterSymbol = context.typeChecker.getSymbolAtLocation(identifier); + return parameterSymbol ? [parameterSymbol] : []; + }), + ), + ]); + const violations: ReactProofEvidence[] = []; + const unknownEvidence: ReactProofEvidence[] = []; + const visitedFunctions = new Set([functionNode]); + const reachableFunctionGraph = collectReachableFunctionGraph(functionNode, context.typeChecker); + const modeledCallExpressions = new Set( + reachableFunctionGraph.calls.map((functionCall) => functionCall.callExpression), + ); + + const visitFunction = (currentFunction: ts.FunctionLikeDeclaration): void => { + const visit = (node: ts.Node): void => { + if (node !== currentFunction && isFunctionBoundary(node)) { + return; + } + if (ts.isBinaryExpression(node) && isAssignmentOperator(node.operatorToken.kind)) { + if (isProtectedMutation(node.left, currentFunction, context, protectedSymbols)) { + violations.push( + createEvidence( + node, + context.rootDirectory, + `${node.left.getText()} is mutated during render`, + ["render", `write ${node.left.getText()}`, "observable mutation"], + ), + ); + } + } + if ( + (ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) && + (node.operator === ts.SyntaxKind.PlusPlusToken || + node.operator === ts.SyntaxKind.MinusMinusToken) && + isProtectedMutation(node.operand, currentFunction, context, protectedSymbols) + ) { + violations.push( + createEvidence( + node, + context.rootDirectory, + `${node.operand.getText()} is mutated during render`, + ["render", `write ${node.operand.getText()}`, "observable mutation"], + ), + ); + } + if (ts.isNewExpression(node) && node.expression.getText() === "Date") { + violations.push( + createEvidence( + node, + context.rootDirectory, + "new Date() is non-idempotent during render", + ["render", "read current time", "render output"], + ), + ); + } + if (ts.isCallExpression(node)) { + const callName = getCallName(node); + const finalCallName = getCanonicalHookName(node, context.typeChecker); + const reactApiName = getCanonicalReactApiName(node.expression, context.typeChecker); + const callSymbol = context.typeChecker.getSymbolAtLocation(node.expression); + if (callSymbol && hookBindings.stateSetters.has(callSymbol)) { + violations.push( + createEvidence( + node, + context.rootDirectory, + `${callName ?? "state setter"} updates state during render`, + ["render", callName ?? "state setter", "schedule render"], + ), + ); + return; + } + if ( + callName && + (KNOWN_IMPURE_RENDER_CALLS.has(callName) || KNOWN_RENDER_SIDE_EFFECT_CALLS.has(callName)) + ) { + violations.push( + createEvidence(node, context.rootDirectory, `${callName} is not pure during render`, [ + "render", + callName, + "observable result or side effect", + ]), + ); + return; + } + if ( + ts.isPropertyAccessExpression(node.expression) && + (MUTATING_METHOD_NAMES.has(node.expression.name.text) || + isStandardLibraryMethodCall(node, KNOWN_MUTATING_STANDARD_METHOD_NAMES, context)) + ) { + if ( + isProtectedMutation( + node.expression.expression, + currentFunction, + context, + protectedSymbols, + ) + ) { + violations.push( + createEvidence( + node, + context.rootDirectory, + `${callName ?? node.expression.name.text} mutates an input during render`, + ["render", callName ?? node.expression.name.text, "observable mutation"], + ), + ); + return; + } + if (isFreshLocalMutation(node.expression.expression, currentFunction, context)) { + return; + } + } + if ( + finalCallName && + (REACT_MODELED_HOOK_NAMES.has(finalCallName) || + REACT_UNMODELED_HOOK_NAMES.has(finalCallName)) + ) { + if (finalCallName === "useMemo" || finalCallName === "useState") { + const callbackExpression = node.arguments[0]; + const callback = callbackExpression + ? resolveFunction(callbackExpression, context.typeChecker) + : null; + if (callback && !visitedFunctions.has(callback)) { + visitedFunctions.add(callback); + visitFunction(callback); + } + } + return; + } + if ( + (reactApiName && REACT_PURE_RENDER_API_NAMES.has(reactApiName)) || + (callName && KNOWN_PURE_GLOBAL_CALLS.has(callName)) || + (ts.isPropertyAccessExpression(node.expression) && + KNOWN_PURE_METHOD_NAMES.has(node.expression.name.text)) || + isStandardLibraryMethodCall(node, KNOWN_PURE_STANDARD_METHOD_NAMES, context) + ) { + for (const argument of node.arguments) { + if (ts.isFunctionExpression(argument) || ts.isArrowFunction(argument)) { + visitFunction(argument); + } + } + return; + } + const resolvedFunction = resolveFunction(node.expression, context.typeChecker); + if (resolvedFunction && !visitedFunctions.has(resolvedFunction)) { + visitedFunctions.add(resolvedFunction); + visitFunction(resolvedFunction); + return; + } + if (modeledCallExpressions.has(node)) return; + unknownEvidence.push( + createEvidence( + node, + context.rootDirectory, + `${callName ?? node.expression.getText()} has no render-purity contract`, + ["render", callName ?? node.expression.getText(), "opaque call"], + ), + ); + return; + } + node.forEachChild(visit); + }; + currentFunction.forEachChild(visit); + }; + + visitFunction(functionNode); + for (const reachableFunction of reachableFunctionGraph.functions) { + if (visitedFunctions.has(reachableFunction.functionNode)) continue; + visitedFunctions.add(reachableFunction.functionNode); + visitFunction(reachableFunction.functionNode); + } + if (violations.length > 0) { + return createObligation( + ReactProofClaim.RenderPurity, + ReactObligationStatus.Violated, + "Render has an observable mutation, update, or non-idempotent operation", + violations, + ); + } + if (unknownEvidence.length > 0) { + return createObligation( + ReactProofClaim.RenderPurity, + ReactObligationStatus.Unknown, + "Render purity depends on calls without proof contracts", + unknownEvidence, + ); + } + return createObligation( + ReactProofClaim.RenderPurity, + ReactObligationStatus.Proved, + "Render is pure for every represented path", + ); +}; diff --git a/packages/prover/src/analyze-scheduled-callback-lifetime.ts b/packages/prover/src/analyze-scheduled-callback-lifetime.ts new file mode 100644 index 000000000..8813e127f --- /dev/null +++ b/packages/prover/src/analyze-scheduled-callback-lifetime.ts @@ -0,0 +1,85 @@ +import { createObligation } from "./create-obligation.js"; +import { findSemanticUnit } from "./find-semantic-unit.js"; +import { + ReactObligationStatus, + ReactProofClaim, + ReactSchedulerCancellationStatus, +} from "./types.js"; +import type { + ReactAnalysisContext, + ReactProofEvidence, + ReactProofObligation, + ReactUnitDescriptor, +} from "./types.js"; + +export const analyzeScheduledCallbackLifetime = ( + unit: ReactUnitDescriptor, + context: ReactAnalysisContext, +): ReactProofObligation => { + const functionNode = unit.functionNode; + const semanticOwnerId = findSemanticUnit(unit, context)?.id; + if (!functionNode || !context.graph || !semanticOwnerId) { + return createObligation( + ReactProofClaim.ScheduledCallbackLifetime, + ReactObligationStatus.Unknown, + "Scheduled callback lifetime has no semantic owner", + ); + } + const schedulerFacts = context.graph.schedulers.filter( + (scheduler) => scheduler.ownerId === semanticOwnerId, + ); + const violations: ReactProofEvidence[] = []; + const unknownEvidence: ReactProofEvidence[] = []; + for (const scheduler of schedulerFacts) { + const lifecycleKind = scheduler.effectId ? "effect" : "class lifecycle"; + const evidence: ReactProofEvidence = { + description: + scheduler.cancellationStatus === ReactSchedulerCancellationStatus.Missing + ? `${scheduler.kind} can remain active after its lifecycle loses ownership` + : `${scheduler.kind} has no complete deferred callback and cancellation certificate`, + location: scheduler.location, + trace: [ + `${lifecycleKind} setup`, + scheduler.kind, + "deferred callback", + `${lifecycleKind} cleanup or replacement`, + ], + }; + if (scheduler.cancellationStatus === ReactSchedulerCancellationStatus.Missing) { + violations.push(evidence); + } else if (!scheduler.complete) { + unknownEvidence.push(evidence); + } + } + const classLifecycle = context.graph.classLifecycles.find( + (lifecycle) => lifecycle.ownerId === semanticOwnerId, + ); + if (classLifecycle && !classLifecycle.sourceComplete) { + unknownEvidence.push({ + description: "The class lifecycle contains an unmodeled scheduler or ownership transition", + location: classLifecycle.location, + trace: ["class lifecycle", "unmodeled execution", "scheduler lifetime unknown"], + }); + } + if (violations.length > 0) { + return createObligation( + ReactProofClaim.ScheduledCallbackLifetime, + ReactObligationStatus.Violated, + "A scheduler can invoke work after losing lifecycle ownership", + violations, + ); + } + if (unknownEvidence.length > 0) { + return createObligation( + ReactProofClaim.ScheduledCallbackLifetime, + ReactObligationStatus.Unknown, + "A scheduled callback or cancellation path could not be proved", + unknownEvidence, + ); + } + return createObligation( + ReactProofClaim.ScheduledCallbackLifetime, + ReactObligationStatus.Proved, + "Every modeled lifecycle scheduler has a deferred callback and guaranteed cancellation", + ); +}; diff --git a/packages/prover/src/analyze-transition-actions.ts b/packages/prover/src/analyze-transition-actions.ts new file mode 100644 index 000000000..0c66d40fe --- /dev/null +++ b/packages/prover/src/analyze-transition-actions.ts @@ -0,0 +1,84 @@ +import { createEvidence } from "./create-evidence.js"; +import { createObligation } from "./create-obligation.js"; +import { findSemanticUnit } from "./find-semantic-unit.js"; +import { ReactObligationStatus, ReactProofClaim, ReactTransitionActionStatus } from "./types.js"; +import type { + ReactAnalysisContext, + ReactProofEvidence, + ReactProofObligation, + ReactSemanticTransitionAction, + ReactUnitDescriptor, +} from "./types.js"; + +const getIncompleteActionDescription = (action: ReactSemanticTransitionAction): string => { + switch (action.status) { + case ReactTransitionActionStatus.Async: + return "An async Transition Action requires post-await ordering and nested transition proof"; + case ReactTransitionActionStatus.Opaque: + return "A Transition Action callback cannot be resolved"; + case ReactTransitionActionStatus.StarterEscape: + return "A Transition starter escapes the modeled execution graph"; + case ReactTransitionActionStatus.UnknownControl: + return `Transition state may control an input through: ${action.unknownControlStateNames.join(", ")}`; + default: + return "A Transition Action has no valid execution root"; + } +}; + +export const analyzeTransitionActions = ( + unit: ReactUnitDescriptor, + context: ReactAnalysisContext, +): ReactProofObligation => { + const owner = findSemanticUnit(unit, context); + const actions = owner + ? (context.graph?.transitionActions.filter((action) => action.ownerId === owner.id) ?? []) + : []; + const violations: ReactProofEvidence[] = []; + const unknownEvidence: ReactProofEvidence[] = []; + for (const action of actions) { + if (action.status === ReactTransitionActionStatus.ControlledInput) { + violations.push({ + description: `A Transition updates controlled input state: ${action.controlledStateNames.join(", ")}`, + location: action.location, + trace: ["Transition Action", "controlled input state", "non-urgent update"], + }); + } else if (!action.complete) { + unknownEvidence.push({ + description: getIncompleteActionDescription(action), + location: action.location, + trace: ["Transition Action", action.status, "incomplete lifecycle model"], + }); + } + } + if (!owner) { + unknownEvidence.push( + createEvidence( + unit.node, + context.rootDirectory, + "The Transition Action owner cannot be resolved", + ["React unit", "Transition Action", "unknown owner"], + ), + ); + } + if (violations.length > 0) { + return createObligation( + ReactProofClaim.TransitionActions, + ReactObligationStatus.Violated, + "A Transition performs an update that React requires to remain urgent", + violations, + ); + } + if (unknownEvidence.length > 0) { + return createObligation( + ReactProofClaim.TransitionActions, + ReactObligationStatus.Unknown, + "Transition Action ownership or priority is incomplete", + unknownEvidence, + ); + } + return createObligation( + ReactProofClaim.TransitionActions, + ReactObligationStatus.Proved, + "Every Transition Action has a synchronous source and a modeled execution owner", + ); +}; diff --git a/packages/prover/src/analyze-updater-function.ts b/packages/prover/src/analyze-updater-function.ts new file mode 100644 index 000000000..eb8e81127 --- /dev/null +++ b/packages/prover/src/analyze-updater-function.ts @@ -0,0 +1,27 @@ +import ts from "typescript"; +import { analyzeRenderPurity } from "./analyze-render-purity.js"; +import { resolveFunction } from "./resolve-function.js"; +import { ReactObligationStatus } from "./types.js"; +import type { ReactAnalysisContext } from "./types.js"; +import { isDeferredCallbackSynchronous } from "./utils/is-deferred-callback-synchronous.js"; + +export const analyzeUpdaterFunction = ( + expression: ts.Expression, + context: ReactAnalysisContext, +): { + updaterFunction: ts.FunctionLikeDeclaration | null; + status: ReactObligationStatus; +} => { + const updaterFunction = resolveFunction(expression, context.typeChecker); + if ( + !updaterFunction || + updaterFunction.asteriskToken || + !isDeferredCallbackSynchronous(updaterFunction, context) + ) { + return { updaterFunction, status: ReactObligationStatus.Unknown }; + } + return { + updaterFunction, + status: analyzeRenderPurity(updaterFunction, context).status, + }; +}; diff --git a/packages/prover/src/analyze-use-resource.ts b/packages/prover/src/analyze-use-resource.ts new file mode 100644 index 000000000..a0f94d230 --- /dev/null +++ b/packages/prover/src/analyze-use-resource.ts @@ -0,0 +1,112 @@ +import { createObligation } from "./create-obligation.js"; +import { findSemanticUnit } from "./find-semantic-unit.js"; +import { + ReactErrorBoundaryCoverageStatus, + ReactObligationStatus, + ReactProofClaim, + ReactSuspenseCoverageStatus, + ReactUseResourceIdentityStatus, + ReactUseResourceKind, +} from "./types.js"; +import type { + ReactAnalysisContext, + ReactProofEvidence, + ReactProofObligation, + ReactUnitDescriptor, +} from "./types.js"; + +export const analyzeUseResource = ( + unit: ReactUnitDescriptor, + context: ReactAnalysisContext, +): ReactProofObligation => { + const semanticUnit = findSemanticUnit(unit, context); + if (!context.graph || !semanticUnit) { + return createObligation( + ReactProofClaim.UseResource, + ReactObligationStatus.Unknown, + "The semantic graph could not identify use resource topology", + ); + } + const resources = context.graph.useResources.filter( + (resource) => resource.ownerId === semanticUnit.id, + ); + const violatedEvidence: ReactProofEvidence[] = []; + const unknownEvidence: ReactProofEvidence[] = []; + for (const resource of resources) { + if (resource.kind === ReactUseResourceKind.Invalid) { + violatedEvidence.push({ + description: "use receives a value that is not a thenable or React Context", + location: resource.location, + trace: ["use", "invalid resource type", "unsupported render protocol"], + }); + } else if (resource.kind === ReactUseResourceKind.Unknown) { + unknownEvidence.push({ + description: "The type passed to use cannot be proved to be a thenable", + location: resource.location, + trace: ["use", "opaque resource type", "unknown suspension protocol"], + }); + } + if (resource.identityStatus === ReactUseResourceIdentityStatus.Unstable) { + violatedEvidence.push({ + description: "use receives a Promise created during React execution", + location: resource.location, + trace: ["render", "fresh Promise identity", "repeated suspension or uncached resource"], + }); + } else if (resource.identityStatus === ReactUseResourceIdentityStatus.Unknown) { + unknownEvidence.push({ + description: "The resource passed to use has unresolved cache identity", + location: resource.location, + trace: ["use", "opaque Promise origin", "unknown identity stability"], + }); + } + if (resource.suspenseCoverageStatus === ReactSuspenseCoverageStatus.OutsideBoundary) { + violatedEvidence.push({ + description: "A pending resource can suspend outside Suspense", + location: resource.location, + trace: ["use", "pending thenable", "missing Suspense fallback"], + }); + } else if (resource.suspenseCoverageStatus === ReactSuspenseCoverageStatus.Unknown) { + unknownEvidence.push({ + description: "A resource crosses unresolved Suspense topology", + location: resource.location, + trace: ["use", "opaque render or ReactNode placement", "unknown pending fallback"], + }); + } + if (resource.errorCoverageStatus === ReactErrorBoundaryCoverageStatus.OutsideBoundary) { + violatedEvidence.push({ + description: "A rejected resource can escape without a valid Error Boundary", + location: resource.location, + trace: ["use", "rejected thenable", "missing recovery UI"], + }); + } else if (resource.errorCoverageStatus === ReactErrorBoundaryCoverageStatus.Unknown) { + unknownEvidence.push({ + description: "A resource crosses unresolved Error Boundary topology", + location: resource.location, + trace: ["use", "opaque render or ReactNode placement", "unknown rejection recovery"], + }); + } + } + if (violatedEvidence.length > 0) { + return createObligation( + ReactProofClaim.UseResource, + ReactObligationStatus.Violated, + "A use resource violates type, identity, Suspense, or Error Boundary requirements", + violatedEvidence, + ); + } + if (unknownEvidence.length > 0) { + return createObligation( + ReactProofClaim.UseResource, + ReactObligationStatus.Unknown, + "A use resource protocol crosses an opaque proof boundary", + unknownEvidence, + ); + } + return createObligation( + ReactProofClaim.UseResource, + ReactObligationStatus.Proved, + resources.length > 0 + ? "Every use resource has stable thenable identity and complete pending and rejection coverage" + : "The unit reads no Promise resource with use", + ); +}; diff --git a/packages/prover/src/build-react-semantic-graph.ts b/packages/prover/src/build-react-semantic-graph.ts new file mode 100644 index 000000000..7072cc6de --- /dev/null +++ b/packages/prover/src/build-react-semantic-graph.ts @@ -0,0 +1,6212 @@ +import ts from "typescript"; +import { collectActionState } from "./collect-action-state.js"; +import { analyzeRenderPurity } from "./analyze-render-purity.js"; +import { collectAsyncEffectTaskDescriptors } from "./collect-async-effect-task-descriptors.js"; +import { collectClassConstruction } from "./collect-class-construction.js"; +import { collectClassStateTransitions } from "./collect-class-state-transitions.js"; +import { collectClassStateWrites } from "./collect-class-state-writes.js"; +import type { ClassStateWriteRootDescriptor } from "./collect-class-state-writes.js"; +import { collectCallableRefProtocols } from "./collect-callable-ref-protocols.js"; +import { collectCallbackStateWrites } from "./collect-callback-state-writes.js"; +import { createComponentCallbackFlow } from "./create-component-callback-flow.js"; +import type { + ComponentCallbackDescriptor, + ComponentCallbackFlowDescriptor, +} from "./create-component-callback-flow.js"; +import { createComponentSlotFlow } from "./create-component-slot-flow.js"; +import type { ComponentSlotFlowDescriptor } from "./create-component-slot-flow.js"; +import { collectDirectHookCalls } from "./collect-direct-hook-calls.js"; +import { collectEffectEventBindings } from "./collect-effect-event-bindings.js"; +import { collectErrorBoundaryProtocol } from "./collect-error-boundary-protocol.js"; +import { collectEffectCleanupFunctions } from "./collect-effect-cleanup-functions.js"; +import { collectEffectCalls } from "./collect-effect-calls.js"; +import { + collectEffectSchedulerProtocols, + collectLifecycleSchedulerProtocols, +} from "./collect-effect-scheduler-protocols.js"; +import { + collectEffectResourceProtocols, + collectLifecycleResourceProtocols, +} from "./collect-effect-resource-protocols.js"; +import { collectHookBindings } from "./collect-hook-bindings.js"; +import { collectHookCalls } from "./collect-hook-calls.js"; +import { collectHostControlProtocols } from "./collect-host-control-protocols.js"; +import { collectHookStateTransitions } from "./collect-hook-state-transitions.js"; +import { collectHydrationEquivalence } from "./collect-hydration-equivalence.js"; +import { collectImperativeHandles, ImperativeHandleRefKind } from "./collect-imperative-handles.js"; +import { collectMemoEquivalence } from "./collect-memo-equivalence.js"; +import type { + ImperativeHandleDescriptor, + ImperativeHandleMethodDescriptor, +} from "./collect-imperative-handles.js"; +import { collectFormActions } from "./collect-form-actions.js"; +import { collectOptimisticState } from "./collect-optimistic-state.js"; +import { collectReducerTransitions } from "./collect-reducer-transitions.js"; +import { collectTransitionActions } from "./collect-transition-actions.js"; +import type { TransitionActionDescriptor } from "./collect-transition-actions.js"; +import { collectUseResourceProtocols } from "./collect-use-resource-protocols.js"; +import { collectReactiveCaptures } from "./collect-reactive-captures.js"; +import { collectReachableFunctionGraph } from "./collect-reachable-functions.js"; +import type { ReachableFunctionGraphDescriptor } from "./collect-reachable-functions.js"; +import { + REACT_EXTERNAL_STORE_HOOK_NAMES, + REACT_MEMO_HOOK_NAMES, + REACT_CONTEXT_DEFAULT_SOURCE_ID, + REACT_CONTEXT_UNKNOWN_SOURCE_ID, + REACT_ERROR_BOUNDARY_OUTSIDE_SOURCE_ID, + REACT_ERROR_BOUNDARY_UNKNOWN_SOURCE_ID, + REACT_FORM_OUTSIDE_SOURCE_ID, + REACT_FORM_UNKNOWN_SOURCE_ID, + REACT_SEMANTIC_GRAPH_SCHEMA_VERSION, + REACT_SUSPENSE_OWNER_SOURCE_ID, + REACT_SUSPENSE_OUTSIDE_SOURCE_ID, + REACT_SUSPENSE_UNKNOWN_SOURCE_ID, + REACT_TRANSPARENT_COMPONENT_NAMES, +} from "./constants.js"; +import { getCanonicalReactApiName } from "./get-canonical-react-api-name.js"; +import { getCanonicalHookName } from "./get-canonical-hook-name.js"; +import { getComponentPropName } from "./get-component-prop-name.js"; +import { getEffectCallback } from "./get-effect-callback.js"; +import { getFunctionName } from "./get-function-name.js"; +import { getNodeLocation } from "./get-node-location.js"; +import { extractReactCompilerGraph } from "./extract-react-compiler-graph.js"; +import { isFunctionBoundary } from "./is-function-boundary.js"; +import { isIdentifierReference } from "./is-identifier-reference.js"; +import { isNodeWithin } from "./is-node-within.js"; +import { isReactContextExpression } from "./is-react-context-expression.js"; +import { resolveFunction } from "./resolve-function.js"; +import { summarizeFunctionReturns } from "./summarize-function-returns.js"; +import { mergeCallableBindings } from "./resolve-callable-expression.js"; +import type { ResolvedCallableValueDescriptor } from "./resolve-callable-expression.js"; +import { collectPropertySymbolWrites } from "./utils/collect-property-symbol-writes.js"; +import { collectSymbolWrites } from "./utils/collect-symbol-writes.js"; +import { createSemanticId } from "./utils/create-semantic-id.js"; +import { getExpressionSymbol } from "./utils/get-expression-symbol.js"; +import { resolveAliasedSymbol } from "./utils/resolve-aliased-symbol.js"; +import { + ReactActionStateDispatchKind, + ReactActionStateDispatchStatus, + ReactActionStateReducerStatus, + ReactCallableRefFreshness, + ReactClassConstructionIssueStatus, + ReactClassConstructionStatus, + ReactClassStateUpdaterStatus, + ReactClassStateWriteStatus, + ReactClassUpdateCycleStatus, + ReactEffectDependencyMode, + ReactErrorBoundaryCoverageStatus, + ReactErrorBoundaryProtocolStatus, + ReactExecutionPhase, + ReactFormActionStatus, + ReactFormStatusTopologyStatus, + ReactHostControlStatus, + ReactHookStateUpdaterStatus, + ReactIdentityStability, + ReactImperativeHandleRefKind, + ReactImperativeHandleStatus, + ReactLazyDeclarationStatus, + ReactLazyLoaderStatus, + ReactOptimisticActionStatus, + ReactOptimisticReducerStatus, + ReactObligationStatus, + ReactReducerDispatchKind, + ReactReducerDispatchStatus, + ReactReducerPurityStatus, + ReactReducerReturnStatus, + ReactRenderFailureKind, + ReactSemanticCallbackKind, + ReactSemanticEdgeKind, + ReactSemanticRenderKind, + ReactSuspenseCoverageStatus, + ReactTransitionActionStatus, + ReactUnitKind, + ReactUseResourceIdentityStatus, + ReactUseResourceKind, +} from "./types.js"; +import type { + ReactAnalysisContext, + ReactSemanticActionState, + ReactSemanticActionStateDispatch, + ReactSemanticCallback, + ReactSemanticAsyncTask, + ReactSemanticContext, + ReactSemanticContextConsumer, + ReactSemanticContextProvider, + ReactSemanticEdge, + ReactSemanticEffect, + ReactSemanticEffectEvent, + ReactSemanticErrorBoundary, + ReactSemanticErrorBoundaryDefinition, + ReactSemanticEventBinding, + ReactSemanticCallbackPropAlternative, + ReactSemanticCallbackPropFlow, + ReactSemanticCallableRef, + ReactSemanticClassConstruction, + ReactSemanticClassLifecycle, + ReactSemanticClassStateWrite, + ReactSemanticClassStateTransition, + ReactSemanticExternalStore, + ReactSemanticFormAction, + ReactSemanticForm, + ReactSemanticFormStatus, + ReactSemanticFunctionCall, + ReactSemanticGraph, + ReactSemanticHookCall, + ReactSemanticHookStateTransition, + ReactSemanticHostControl, + ReactSemanticImperativeHandle, + ReactSemanticImperativeHandleBinding, + ReactSemanticImperativeHandleInvocation, + ReactSemanticImperativeHandleMethod, + ReactSemanticLazyComponent, + ReactSemanticLazyRender, + ReactSemanticOptimisticState, + ReactSemanticOptimisticUpdate, + ReactSemanticReducer, + ReactSemanticReducerDispatch, + ReactSemanticTransitionAction, + ReactSemanticUseResource, + ReactSemanticReachableFunction, + ReactSemanticRender, + ReactSemanticRenderFailure, + ReactSemanticSlotFlow, + ReactSemanticEffectResource, + ReactSemanticScheduler, + ReactSemanticSuspenseBoundary, + ReactSemanticUnit, + ReactUnitDescriptor, +} from "./types.js"; +import { areProofLocationsEqual } from "./utils/are-proof-locations-equal.js"; +import { collectReachableCallExpressions } from "./utils/collect-reachable-call-expressions.js"; +import { collectExecutionCallbackIds } from "./utils/collect-execution-callback-ids.js"; +import { getClassMethodDeclaration } from "./utils/get-class-method-declaration.js"; +import { getStaticClassMethodDeclaration } from "./utils/get-static-class-method-declaration.js"; +import { getContainingFunction } from "./utils/get-containing-function.js"; +import { getJsxOpeningElementForAttribute } from "./utils/get-jsx-opening-element-for-attribute.js"; +import { getJsxComponentTargetFunction } from "./utils/get-jsx-component-target-function.js"; +import { isDeferredCallbackSynchronous } from "./utils/is-deferred-callback-synchronous.js"; +import { getResolvedSymbol } from "./utils/get-resolved-symbol.js"; +import { getStaticPropertyName } from "./utils/get-static-property-name.js"; +import { isIntrinsicJsxElement } from "./utils/is-intrinsic-jsx-element.js"; +import { isReactiveCaptureDeclared } from "./utils/is-reactive-capture-declared.js"; +import { unwrapTypescriptExpression } from "./unwrap-typescript-expression.js"; + +interface UnitGraphIdentity { + descriptor: ReactUnitDescriptor; + semanticUnit: ReactSemanticUnit; +} + +interface EffectGraphFacts { + effects: ReadonlyArray; + schedulers: ReadonlyArray; + resources: ReadonlyArray; + callbacks: ReadonlyArray; + reachableFunctions: ReadonlyArray; + functionCalls: ReadonlyArray; +} + +interface ClassLifecycleGraphFacts { + construction: ReactSemanticClassConstruction | null; + lifecycle: ReactSemanticClassLifecycle | null; + stateWrites: ReadonlyArray; + transitions: ReadonlyArray; + schedulers: ReadonlyArray; + resources: ReadonlyArray; + callbacks: ReadonlyArray; + reachableFunctions: ReadonlyArray; + functionCalls: ReadonlyArray; +} + +interface EffectEventGraphFacts { + effectEvents: ReadonlyArray; + callbacks: ReadonlyArray; + reachableFunctions: ReadonlyArray; + functionCalls: ReadonlyArray; +} + +interface ExternalStoreGraphFacts { + externalStores: ReadonlyArray; + callbacks: ReadonlyArray; + reachableFunctions: ReadonlyArray; + functionCalls: ReadonlyArray; +} + +interface ExternalStoreCallbackFacts extends CallbackGraphFacts { + callbackIds: ReadonlyArray; + isComplete: boolean; +} + +interface ExternalStoreCallbackDescriptor { + kind: ReactSemanticCallbackKind; + name: string; + phase: ReactExecutionPhase; +} + +interface CallbackGraphFacts { + callbacks: ReadonlyArray; + reachableFunctions: ReadonlyArray; + functionCalls: ReadonlyArray; +} + +interface EventGraphFacts extends CallbackGraphFacts { + eventBindings: ReadonlyArray; +} + +interface CallbackPropGraphFacts extends CallbackGraphFacts { + callbackPropFlows: ReadonlyArray; +} + +interface FormActionGraphFacts extends CallbackGraphFacts { + actions: ReadonlyArray; +} + +interface ActionStateDefinitionGraphFacts extends CallbackGraphFacts { + callbacksByDispatcher: ReadonlyMap; + states: ReadonlyArray; +} + +interface ActionStateDispatchGraphFacts { + dispatches: ReadonlyArray; +} + +interface HookStateTransitionGraphFacts extends CallbackGraphFacts { + transitions: ReadonlyArray; +} + +interface ReducerDefinitionGraphFacts extends CallbackGraphFacts { + reducers: ReadonlyArray; +} + +interface ReducerDispatchGraphFacts { + dispatches: ReadonlyArray; +} + +interface ImperativeHandleGraphFacts extends CallbackGraphFacts { + handles: ReadonlyArray; + methods: ReadonlyArray; + bindings: ReadonlyArray; + invocations: ReadonlyArray; +} + +interface ImperativeHandleIdentity { + descriptor: ImperativeHandleDescriptor; + handleId: string; + identity: UnitGraphIdentity; + methods: ReadonlyArray; + methodsByName: ReadonlyMap; +} + +interface ImperativeHandleMethodIdentity { + descriptor: ImperativeHandleMethodDescriptor; + methodId: string; +} + +interface ImperativeHandleBindingDescriptor { + handleIdentity: ImperativeHandleIdentity; + identity: UnitGraphIdentity; + refAttribute: ts.JsxAttribute; + refDeclaration: ts.VariableDeclaration; + refName: string; + refSymbol: ts.Symbol; + render: ReactSemanticRender | null; + sourceComplete: boolean; +} + +interface ImperativeHandleInvocationDescriptor { + binding: ImperativeHandleBindingDescriptor; + callExpression: ts.CallExpression; + method: ImperativeHandleMethodIdentity | null; + callerCallbackIds: ReadonlyArray; + sourceComplete: boolean; +} + +interface OptimisticStateGraphFacts extends CallbackGraphFacts { + states: ReadonlyArray; + updates: ReadonlyArray; +} + +interface TransitionActionGraphFacts extends CallbackGraphFacts { + actions: ReadonlyArray; +} + +interface TransitionActionGraphIdentity { + actionCallback: ReactSemanticCallback | null; + actionId: string; + descriptor: TransitionActionDescriptor; +} + +interface CallbackPropReachabilityDescriptor { + callbackDescriptor: ComponentCallbackDescriptor; + callbackFact: ReactSemanticCallback; + identity: UnitGraphIdentity; +} + +interface ReachabilityGraphFacts { + reachableFunctions: ReadonlyArray; + functionCalls: ReadonlyArray; +} + +interface ContextDefinitionIdentity { + context: ReactSemanticContext; + symbol: ts.Symbol; +} + +interface ContextProviderIdentity { + provider: ReactSemanticContextProvider; + openingNode: ts.JsxOpeningLikeElement; +} + +interface ContextGraphFacts { + contexts: ReadonlyArray; + contextProviders: ReadonlyArray; + contextConsumers: ReadonlyArray; + providersByOpeningNode: ReadonlyMap; + contextIdsBySymbol: ReadonlyMap; +} + +interface FormTopologyGraphFacts { + forms: ReadonlyArray; + formStatuses: ReadonlyArray; + formsByOpeningNode: ReadonlyMap; +} + +const collectCallableRefGraph = ( + identities: ReadonlyArray, + callbacks: ReadonlyArray, + functionCalls: ReadonlyArray, + context: ReactAnalysisContext, +): ReadonlyArray => { + const callbacksById = new Map(callbacks.map((callback) => [callback.id, callback])); + return identities.flatMap((identity) => { + const functionNode = identity.descriptor.functionNode; + if (!functionNode) return []; + return collectCallableRefProtocols(functionNode, context.typeChecker).map((protocol) => { + const invocationLocations = protocol.invocationExpressions.map((invocationExpression) => + getNodeLocation(invocationExpression, context.rootDirectory), + ); + const invocationCalls = functionCalls.filter( + (functionCall) => + invocationLocations.some((location) => + areProofLocationsEqual(location, functionCall.location), + ) && + callbacksById.get(functionCall.rootCallbackId)?.kind !== + ReactSemanticCallbackKind.MemoizedCallback, + ); + const invocationCallbackIds = [ + ...new Set(invocationCalls.map((functionCall) => functionCall.rootCallbackId)), + ]; + const invocationCallbacks = invocationCallbackIds.flatMap((callbackId) => { + const callback = callbacksById.get(callbackId); + return callback ? [callback] : []; + }); + const isEventSynchronized = + protocol.isSourceComplete && + protocol.updateHookName === "useLayoutEffect" && + invocationCallbacks.length > 0 && + invocationCallbacks.every((callback) => callback.phase === ReactExecutionPhase.Event); + const freshness = isEventSynchronized + ? ReactCallableRefFreshness.EventSynchronized + : protocol.updateHookName === "useEffect" + ? ReactCallableRefFreshness.PassiveLag + : ReactCallableRefFreshness.Unknown; + return { + id: createSemanticId("callable-ref", protocol.refName, protocol.declaration, context), + ownerId: identity.semanticUnit.id, + name: protocol.refName, + location: getNodeLocation(protocol.declaration, context.rootDirectory), + updateHookName: protocol.updateHookName, + updateLocation: protocol.updateHookCall + ? getNodeLocation(protocol.updateHookCall, context.rootDirectory) + : null, + invocationCallIds: invocationCalls.map((functionCall) => functionCall.id), + invocationCallbackIds, + invocationLocations, + freshness, + sourceComplete: protocol.isSourceComplete, + complete: isEventSynchronized, + }; + }); + }); +}; + +interface RenderGraphFacts { + edges: ReadonlyArray; + errorBoundaryIdsByRenderId: ReadonlyMap>; + renders: ReadonlyArray; + suspenseBoundaryIdsByRenderId: ReadonlyMap>; +} + +interface RenderSlotBoundary { + complete: boolean; + containerRenderId: string | null; + node: ts.Node; + propName: string | null; +} + +interface SlotGraphFacts { + errorBoundaryIdsByRenderId: ReadonlyMap>; + renders: ReadonlyArray; + slotFlows: ReadonlyArray; + suspenseBoundaryIdsByRenderId: ReadonlyMap>; +} + +interface SuspenseGraphFacts { + boundaries: ReadonlyArray; + boundariesByOpeningNode: ReadonlyMap; +} + +interface ErrorBoundaryDefinitionIdentity { + definition: ReactSemanticErrorBoundaryDefinition; + identity: UnitGraphIdentity; +} + +interface ErrorBoundaryGraphFacts { + boundaries: ReadonlyArray; + boundariesByOpeningNode: ReadonlyMap; + definitions: ReadonlyArray; + definitionsByUnitId: ReadonlyMap; +} + +interface RenderErrorGraphFacts { + failures: ReadonlyArray; +} + +interface UseResourceGraphFacts { + resources: ReadonlyArray; +} + +interface LazyComponentIdentity { + component: ReactSemanticLazyComponent; + declaration: ts.Node; + symbol: ts.Symbol | null; +} + +interface LazyGraphFacts { + components: ReadonlyArray; + renders: ReadonlyArray; +} + +const getDeclarationNameNode = (descriptor: ReactUnitDescriptor): ts.Node | null => { + if (descriptor.classNode) return descriptor.classNode.name ?? descriptor.classNode; + const functionNode = descriptor.functionNode; + if (!functionNode) return descriptor.node; + if (functionNode.name) return functionNode.name; + if (ts.isVariableDeclaration(functionNode.parent)) return functionNode.parent.name; + if (ts.isPropertyAssignment(functionNode.parent)) return functionNode.parent.name; + if ( + ts.isCallExpression(functionNode.parent) && + ts.isVariableDeclaration(functionNode.parent.parent) + ) { + return functionNode.parent.parent.name; + } + return functionNode; +}; + +const isDeclarationExported = ( + declaration: ts.Node, + declarationSymbol: ts.Symbol | null, + typeChecker: ts.TypeChecker, +): boolean => { + let currentNode: ts.Node | undefined = declaration; + while (currentNode && !ts.isSourceFile(currentNode)) { + if (ts.isExportAssignment(currentNode) && !currentNode.isExportEquals) return true; + if ( + ts.canHaveModifiers(currentNode) && + ts + .getModifiers(currentNode) + ?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) + ) { + return true; + } + currentNode = currentNode.parent; + } + const moduleSymbol = typeChecker.getSymbolAtLocation(declaration.getSourceFile()); + if (!declarationSymbol || !moduleSymbol) return false; + const resolvedDeclarationSymbol = resolveAliasedSymbol(declarationSymbol, typeChecker); + return typeChecker + .getExportsOfModule(moduleSymbol) + .some( + (exportSymbol) => + resolveAliasedSymbol(exportSymbol, typeChecker) === resolvedDeclarationSymbol, + ); +}; + +const isDescriptorExported = ( + descriptor: ReactUnitDescriptor, + typeChecker: ts.TypeChecker, +): boolean => { + const declarationName = getDeclarationNameNode(descriptor); + const declarationSymbol = declarationName + ? (typeChecker.getSymbolAtLocation(declarationName) ?? null) + : null; + return isDeclarationExported(descriptor.node, declarationSymbol, typeChecker); +}; + +const collectContextDefinitions = ( + sourceFiles: ReadonlyArray, + context: ReactAnalysisContext, +): ReadonlyArray => { + const definitions: ContextDefinitionIdentity[] = []; + const visit = (node: ts.Node): void => { + if ( + ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + node.initializer && + ts.isCallExpression(node.initializer) && + getCanonicalReactApiName(node.initializer.expression, context.typeChecker) === "createContext" + ) { + const symbol = context.typeChecker.getSymbolAtLocation(node.name); + if (symbol) { + definitions.push({ + context: { + id: createSemanticId("context", node.name.text, node, context), + name: node.name.text, + location: getNodeLocation(node, context.rootDirectory), + defaultValueText: node.initializer.arguments[0]?.getText() ?? "undefined", + }, + symbol: resolveAliasedSymbol(symbol, context.typeChecker), + }); + } + } + node.forEachChild(visit); + }; + for (const sourceFile of sourceFiles) sourceFile.forEachChild(visit); + return definitions; +}; + +const getContextIdFromExpression = ( + expression: ts.Expression, + contextIdsBySymbol: ReadonlyMap, + typeChecker: ts.TypeChecker, +): string | null => { + const symbol = getExpressionSymbol(expression, typeChecker); + return symbol ? (contextIdsBySymbol.get(symbol) ?? null) : null; +}; + +const getProviderContextId = ( + tagName: ts.JsxTagNameExpression, + contextIdsBySymbol: ReadonlyMap, + typeChecker: ts.TypeChecker, +): string | null => { + if (ts.isIdentifier(tagName)) { + return getContextIdFromExpression(tagName, contextIdsBySymbol, typeChecker); + } + if (ts.isPropertyAccessExpression(tagName) && tagName.name.text === "Provider") { + return getContextIdFromExpression(tagName.expression, contextIdsBySymbol, typeChecker); + } + return null; +}; + +const getProviderValue = ( + openingNode: ts.JsxOpeningLikeElement, +): { valueProvided: boolean; valueText: string | null } => { + const valueAttribute = openingNode.attributes.properties.find( + (attribute): attribute is ts.JsxAttribute => + ts.isJsxAttribute(attribute) && attribute.name.getText() === "value", + ); + if (!valueAttribute) return { valueProvided: false, valueText: null }; + if (!valueAttribute.initializer) return { valueProvided: true, valueText: "true" }; + if (ts.isJsxExpression(valueAttribute.initializer) && valueAttribute.initializer.expression) { + return { valueProvided: true, valueText: valueAttribute.initializer.expression.getText() }; + } + return { valueProvided: true, valueText: valueAttribute.initializer.getText() }; +}; + +const collectContextGraph = ( + identities: ReadonlyArray, + sourceFiles: ReadonlyArray, + context: ReactAnalysisContext, +): ContextGraphFacts => { + const definitions = collectContextDefinitions(sourceFiles, context); + const contextIdsBySymbol = new Map( + definitions.map((definition) => [definition.symbol, definition.context.id]), + ); + const providerIdentities: ContextProviderIdentity[] = []; + const contextConsumers: ReactSemanticContextConsumer[] = []; + + for (const identity of identities) { + const functionNode = identity.descriptor.functionNode; + if (!functionNode) continue; + const visit = (node: ts.Node): void => { + if (node !== functionNode && isFunctionBoundary(node)) return; + const openingNode = ts.isJsxOpeningElement(node) + ? node + : ts.isJsxSelfClosingElement(node) + ? node + : null; + if (openingNode) { + const contextId = getProviderContextId( + openingNode.tagName, + contextIdsBySymbol, + context.typeChecker, + ); + if (contextId) { + const providerValue = getProviderValue(openingNode); + providerIdentities.push({ + openingNode, + provider: { + id: createSemanticId("context-provider", contextId, openingNode, context), + ownerId: identity.semanticUnit.id, + contextId, + location: getNodeLocation(openingNode, context.rootDirectory), + ...providerValue, + }, + }); + } + } + if (ts.isCallExpression(node)) { + const hookName = getCanonicalReactApiName(node.expression, context.typeChecker); + const contextExpression = node.arguments[0]; + if ( + hookName === "useContext" || + (hookName === "use" && + contextExpression && + isReactContextExpression(contextExpression, context.typeChecker)) + ) { + contextConsumers.push({ + id: createSemanticId("context-consumer", hookName, node, context), + ownerId: identity.semanticUnit.id, + contextId: contextExpression + ? getContextIdFromExpression( + contextExpression, + contextIdsBySymbol, + context.typeChecker, + ) + : null, + hookName, + location: getNodeLocation(node, context.rootDirectory), + sourceProviderIds: [], + usesDefaultValue: false, + topologyComplete: false, + }); + } + } + node.forEachChild(visit); + }; + functionNode.forEachChild(visit); + } + + return { + contexts: definitions.map((definition) => definition.context), + contextProviders: providerIdentities.map((identity) => identity.provider), + contextConsumers, + providersByOpeningNode: new Map( + providerIdentities.map((identity) => [identity.openingNode, identity.provider]), + ), + contextIdsBySymbol, + }; +}; + +const collectActiveContextProviderIds = ( + node: ts.Node, + providersByOpeningNode: ReadonlyMap, + stopNode: ts.Node | null = null, +): ReadonlyArray => { + const providerIds: string[] = []; + let currentNode: ts.Node | undefined = node.parent; + while (currentNode && currentNode !== stopNode && !isFunctionBoundary(currentNode)) { + if (ts.isJsxElement(currentNode)) { + const provider = providersByOpeningNode.get(currentNode.openingElement); + if (provider) providerIds.unshift(provider.id); + } + currentNode = currentNode.parent; + } + return providerIds; +}; + +const collectFormTopologyGraph = ( + identities: ReadonlyArray, + context: ReactAnalysisContext, +): FormTopologyGraphFacts => { + const forms: ReactSemanticForm[] = []; + const formStatuses: ReactSemanticFormStatus[] = []; + const formsByOpeningNode = new Map(); + for (const identity of identities) { + const functionNode = identity.descriptor.functionNode; + if (!functionNode) continue; + const visit = (node: ts.Node): void => { + if (node !== functionNode && isFunctionBoundary(node)) return; + if ( + (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) && + ts.isIdentifier(node.tagName) && + node.tagName.text === "form" + ) { + const form: ReactSemanticForm = { + id: createSemanticId("form", "form", node.tagName, context), + ownerId: identity.semanticUnit.id, + location: getNodeLocation(node.tagName, context.rootDirectory), + }; + forms.push(form); + formsByOpeningNode.set(node, form); + } + if ( + ts.isCallExpression(node) && + getCanonicalReactApiName(node.expression, context.typeChecker) === "useFormStatus" + ) { + formStatuses.push({ + id: createSemanticId("form-status", "useFormStatus", node, context), + ownerId: identity.semanticUnit.id, + location: getNodeLocation(node, context.rootDirectory), + sourceFormIds: [], + outsideForm: false, + status: ReactFormStatusTopologyStatus.Unknown, + sourceComplete: false, + complete: false, + }); + } + node.forEachChild(visit); + }; + functionNode.forEachChild(visit); + } + return { forms, formStatuses, formsByOpeningNode }; +}; + +const collectActiveFormIds = ( + node: ts.Node, + formsByOpeningNode: ReadonlyMap, + stopNode: ts.Node | null = null, +): ReadonlyArray => { + const activeFormIds: string[] = []; + let currentNode: ts.Node | undefined = node.parent; + while (currentNode && currentNode !== stopNode && !isFunctionBoundary(currentNode)) { + if (ts.isJsxElement(currentNode)) { + const form = formsByOpeningNode.get(currentNode.openingElement); + if (form) activeFormIds.unshift(form.id); + } + currentNode = currentNode.parent; + } + return activeFormIds; +}; + +const collectSuspenseGraph = ( + identities: ReadonlyArray, + context: ReactAnalysisContext, +): SuspenseGraphFacts => { + const boundaries: ReactSemanticSuspenseBoundary[] = []; + const boundariesByOpeningNode = new Map< + ts.JsxOpeningLikeElement, + ReactSemanticSuspenseBoundary + >(); + for (const identity of identities) { + const functionNode = identity.descriptor.functionNode; + if (!functionNode) continue; + const visit = (node: ts.Node): void => { + if (node !== functionNode && isFunctionBoundary(node)) return; + const openingElement = + ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node) ? node : null; + if ( + openingElement && + !ts.isJsxNamespacedName(openingElement.tagName) && + getCanonicalReactApiName(openingElement.tagName, context.typeChecker) === "Suspense" + ) { + const boundary: ReactSemanticSuspenseBoundary = { + id: createSemanticId("suspense-boundary", "Suspense", openingElement.tagName, context), + ownerId: identity.semanticUnit.id, + location: getNodeLocation(openingElement.tagName, context.rootDirectory), + renderIds: [], + }; + boundaries.push(boundary); + boundariesByOpeningNode.set(openingElement, boundary); + } + node.forEachChild(visit); + }; + functionNode.forEachChild(visit); + } + return { boundaries, boundariesByOpeningNode }; +}; + +const collectActiveSuspenseBoundaryIds = ( + node: ts.Node, + boundariesByOpeningNode: ReadonlyMap, + stopNode: ts.Node | null = null, +): ReadonlyArray => { + const boundaryIds: string[] = []; + let currentNode: ts.Node | undefined = node.parent; + while (currentNode && currentNode !== stopNode && !isFunctionBoundary(currentNode)) { + if (ts.isJsxElement(currentNode)) { + const boundary = boundariesByOpeningNode.get(currentNode.openingElement); + const fallbackAttribute = currentNode.openingElement.attributes.properties.find( + (attribute): attribute is ts.JsxAttribute => + ts.isJsxAttribute(attribute) && attribute.name.getText() === "fallback", + ); + if (boundary && !(fallbackAttribute && isNodeWithin(node, fallbackAttribute))) { + boundaryIds.unshift(boundary.id); + } + } + currentNode = currentNode.parent; + } + return boundaryIds; +}; + +const getContainingSuspenseFallbackElement = ( + node: ts.Node, + boundariesByOpeningNode: ReadonlyMap, +): ts.JsxElement | null => { + let currentNode: ts.Node | undefined = node.parent; + while (currentNode && !isFunctionBoundary(currentNode)) { + if (ts.isJsxAttribute(currentNode) && currentNode.name.getText() === "fallback") { + const openingElement = getJsxOpeningElementForAttribute(currentNode); + if ( + openingElement && + boundariesByOpeningNode.has(openingElement) && + ts.isJsxOpeningElement(openingElement) && + ts.isJsxElement(openingElement.parent) + ) { + return openingElement.parent; + } + } + currentNode = currentNode.parent; + } + return null; +}; + +const collectUnitIdentitiesBySymbol = ( + identities: ReadonlyArray, + context: ReactAnalysisContext, +): ReadonlyMap => { + const unitIdentitiesBySymbol = new Map(); + for (const identity of identities) { + const declarationName = getDeclarationNameNode(identity.descriptor); + if (!declarationName) continue; + const symbol = context.typeChecker.getSymbolAtLocation(declarationName); + if (symbol) + unitIdentitiesBySymbol.set(resolveAliasedSymbol(symbol, context.typeChecker), identity); + } + return unitIdentitiesBySymbol; +}; + +const resolveUnitTarget = ( + expression: ts.Expression | ts.JsxTagNameExpression, + unitIdsBySymbol: ReadonlyMap, + typeChecker: ts.TypeChecker, +): string | null => { + const symbol = getExpressionSymbol(expression, typeChecker); + return symbol ? (unitIdsBySymbol.get(symbol) ?? null) : null; +}; + +const collectErrorBoundaryGraph = ( + identities: ReadonlyArray, + unitIdsBySymbol: ReadonlyMap, + context: ReactAnalysisContext, +): ErrorBoundaryGraphFacts => { + const definitionIdentities: ErrorBoundaryDefinitionIdentity[] = []; + for (const identity of identities) { + const classNode = identity.descriptor.classNode; + const renderMethod = + classNode && identity.descriptor.kind === ReactUnitKind.ClassComponent + ? getClassMethodDeclaration(classNode, "render") + : null; + if (!classNode || !renderMethod) continue; + const protocol = collectErrorBoundaryProtocol(classNode, renderMethod, context); + if (!protocol.isCandidate) continue; + const definition: ReactSemanticErrorBoundaryDefinition = { + id: createSemanticId( + "error-boundary-definition", + identity.descriptor.name, + classNode, + context, + ), + ownerId: identity.semanticUnit.id, + location: getNodeLocation(classNode, context.rootDirectory), + derivedStateLocation: protocol.derivedStateMethod + ? getNodeLocation(protocol.derivedStateMethod, context.rootDirectory) + : null, + componentDidCatchLocation: protocol.componentDidCatchMethod + ? getNodeLocation(protocol.componentDidCatchMethod, context.rootDirectory) + : null, + fallbackStateKey: protocol.fallbackStateKey, + derivedStateStatus: protocol.derivedStateStatus, + fallbackRenderStatus: protocol.fallbackRenderStatus, + instanceIds: [], + sourceComplete: protocol.isSourceComplete, + complete: + protocol.isSourceComplete && + protocol.derivedStateStatus === ReactErrorBoundaryProtocolStatus.Valid && + protocol.fallbackRenderStatus === ReactErrorBoundaryProtocolStatus.Valid, + }; + definitionIdentities.push({ definition, identity }); + } + const definitionsByUnitId = new Map( + definitionIdentities.map(({ definition }) => [definition.ownerId, definition]), + ); + const boundaries: ReactSemanticErrorBoundary[] = []; + const boundariesByOpeningNode = new Map(); + for (const identity of identities) { + const functionNode = identity.descriptor.functionNode; + if (!functionNode) continue; + const visit = (node: ts.Node): void => { + if (node !== functionNode && isFunctionBoundary(node)) return; + const openingElement = + ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node) ? node : null; + if (openingElement && !ts.isJsxNamespacedName(openingElement.tagName)) { + const targetId = resolveUnitTarget( + openingElement.tagName, + unitIdsBySymbol, + context.typeChecker, + ); + const definition = targetId ? definitionsByUnitId.get(targetId) : null; + if (definition) { + const boundary: ReactSemanticErrorBoundary = { + id: createSemanticId("error-boundary", definition.id, openingElement.tagName, context), + ownerId: identity.semanticUnit.id, + definitionId: definition.id, + location: getNodeLocation(openingElement.tagName, context.rootDirectory), + renderIds: [], + }; + boundaries.push(boundary); + boundariesByOpeningNode.set(openingElement, boundary); + } + } + node.forEachChild(visit); + }; + functionNode.forEachChild(visit); + } + const instanceIdsByDefinitionId = new Map(); + for (const boundary of boundaries) { + const instanceIds = instanceIdsByDefinitionId.get(boundary.definitionId) ?? []; + instanceIds.push(boundary.id); + instanceIdsByDefinitionId.set(boundary.definitionId, instanceIds); + } + const definitions = definitionIdentities.map(({ definition }) => ({ + ...definition, + instanceIds: instanceIdsByDefinitionId.get(definition.id) ?? [], + })); + return { + boundaries, + boundariesByOpeningNode, + definitions, + definitionsByUnitId: new Map(definitions.map((definition) => [definition.ownerId, definition])), + }; +}; + +const collectActiveErrorBoundaryIds = ( + node: ts.Node, + boundariesByOpeningNode: ReadonlyMap, + stopNode: ts.Node | null = null, +): ReadonlyArray => { + const boundaryIds: string[] = []; + const originOpeningElement = + ts.isJsxOpeningElement(node.parent) || ts.isJsxSelfClosingElement(node.parent) + ? node.parent + : null; + let currentNode: ts.Node | undefined = node.parent; + while (currentNode && currentNode !== stopNode && !isFunctionBoundary(currentNode)) { + if (ts.isJsxElement(currentNode)) { + const boundary = boundariesByOpeningNode.get(currentNode.openingElement); + if (boundary && currentNode.openingElement !== originOpeningElement) { + boundaryIds.unshift(boundary.id); + } + } + currentNode = currentNode.parent; + } + return boundaryIds; +}; + +const isTransparentSlotOpening = ( + openingElement: ts.JsxOpeningLikeElement, + providersByOpeningNode: ReadonlyMap, + typeChecker: ts.TypeChecker, +): boolean => { + if (isIntrinsicJsxElement(openingElement) || providersByOpeningNode.has(openingElement)) { + return true; + } + const reactComponentName = ts.isJsxNamespacedName(openingElement.tagName) + ? null + : getCanonicalReactApiName(openingElement.tagName, typeChecker); + return Boolean(reactComponentName && REACT_TRANSPARENT_COMPONENT_NAMES.has(reactComponentName)); +}; + +const getContainingRenderSlotBoundary = ( + tagName: ts.JsxTagNameExpression, + unitIdsBySymbol: ReadonlyMap, + providersByOpeningNode: ReadonlyMap, + context: ReactAnalysisContext, +): RenderSlotBoundary | null => { + const ownOpeningElement = tagName.parent; + let complete = true; + let currentNode: ts.Node = + ts.isJsxOpeningElement(ownOpeningElement) && ts.isJsxElement(ownOpeningElement.parent) + ? ownOpeningElement.parent + : ownOpeningElement; + while (!ts.isSourceFile(currentNode) && !isFunctionBoundary(currentNode)) { + const parentNode = currentNode.parent; + if (!parentNode) break; + let openingElement: ts.JsxOpeningLikeElement | null = null; + let propName: string | null = null; + if (ts.isJsxAttribute(parentNode)) { + openingElement = getJsxOpeningElementForAttribute(parentNode); + propName = parentNode.name.getText(); + } else if (ts.isJsxSpreadAttribute(parentNode)) { + openingElement = + ts.isJsxOpeningElement(parentNode.parent) || ts.isJsxSelfClosingElement(parentNode.parent) + ? parentNode.parent + : null; + complete = false; + } else if (ts.isJsxElement(parentNode)) { + openingElement = parentNode.openingElement; + propName = "children"; + } + if ( + openingElement && + !isTransparentSlotOpening(openingElement, providersByOpeningNode, context.typeChecker) + ) { + const targetId = resolveUnitTarget( + openingElement.tagName, + unitIdsBySymbol, + context.typeChecker, + ); + return { + complete, + containerRenderId: targetId + ? createSemanticId("render", targetId, openingElement.tagName, context) + : null, + node: openingElement, + propName, + }; + } + if (openingElement && propName !== null && propName !== "children") { + complete = false; + } + if ( + ts.isCallExpression(parentNode) && + !( + getCanonicalReactApiName(parentNode.expression, context.typeChecker) === "createPortal" && + parentNode.arguments[0] === currentNode + ) + ) { + complete = false; + } else if (ts.isConditionalExpression(parentNode) && parentNode.condition === currentNode) { + complete = false; + } else if (ts.isBinaryExpression(parentNode)) { + const operatorKind = parentNode.operatorToken.kind; + if ( + parentNode.right !== currentNode || + (operatorKind !== ts.SyntaxKind.AmpersandAmpersandToken && + operatorKind !== ts.SyntaxKind.BarBarToken && + operatorKind !== ts.SyntaxKind.QuestionQuestionToken) + ) { + complete = false; + } + } else if ( + ts.isVariableDeclaration(parentNode) || + ts.isPropertyAssignment(parentNode) || + ts.isShorthandPropertyAssignment(parentNode) || + ts.isElementAccessExpression(parentNode) || + ts.isPropertyAccessExpression(parentNode) || + ts.isExpressionStatement(parentNode) + ) { + complete = false; + } + if ( + (ts.isReturnStatement(parentNode) && parentNode.expression === currentNode) || + (ts.isArrowFunction(parentNode) && parentNode.body === currentNode) + ) { + return complete + ? null + : { + complete: false, + containerRenderId: null, + node: currentNode, + propName: null, + }; + } + currentNode = parentNode; + } + return { + complete: false, + containerRenderId: null, + node: currentNode, + propName: null, + }; +}; + +const collectRenderEdges = ( + identity: UnitGraphIdentity, + unitIdsBySymbol: ReadonlyMap, + providersByOpeningNode: ReadonlyMap, + formsByOpeningNode: ReadonlyMap, + errorBoundariesByOpeningNode: ReadonlyMap, + suspenseBoundariesByOpeningNode: ReadonlyMap< + ts.JsxOpeningLikeElement, + ReactSemanticSuspenseBoundary + >, + context: ReactAnalysisContext, +): RenderGraphFacts => { + const functionNode = identity.descriptor.functionNode; + if (!functionNode) { + return { + edges: [], + errorBoundaryIdsByRenderId: new Map(), + renders: [], + suspenseBoundaryIdsByRenderId: new Map(), + }; + } + const edges: ReactSemanticEdge[] = []; + const renders: ReactSemanticRender[] = []; + const errorBoundaryIdsByRenderId = new Map>(); + const suspenseBoundaryIdsByRenderId = new Map>(); + const visit = (node: ts.Node): void => { + if (node !== functionNode && isFunctionBoundary(node)) { + return; + } + const openingElement = + ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node) ? node : null; + const tagName = openingElement?.tagName ?? null; + if (tagName && openingElement && !isIntrinsicJsxElement(openingElement)) { + const targetId = resolveUnitTarget(tagName, unitIdsBySymbol, context.typeChecker); + if (targetId) { + const location = getNodeLocation(tagName, context.rootDirectory); + const slotBoundary = getContainingRenderSlotBoundary( + tagName, + unitIdsBySymbol, + providersByOpeningNode, + context, + ); + const topologyKind = slotBoundary + ? ReactSemanticRenderKind.SlotInput + : ReactSemanticRenderKind.Direct; + edges.push({ + kind: ReactSemanticEdgeKind.RendersComponent, + sourceId: identity.semanticUnit.id, + targetId, + location, + }); + const renderId = createSemanticId("render", targetId, tagName, context); + renders.push({ + id: renderId, + ownerId: identity.semanticUnit.id, + targetId, + location, + kind: topologyKind, + sourceRenderId: null, + containerRenderId: slotBoundary?.containerRenderId ?? null, + slotPropName: slotBoundary?.propName ?? null, + topologyOwnerIds: [identity.semanticUnit.id], + activeContextProviderIds: collectActiveContextProviderIds( + tagName, + providersByOpeningNode, + slotBoundary?.node ?? null, + ), + contextTopologyComplete: slotBoundary?.complete ?? true, + activeFormIds: collectActiveFormIds( + tagName, + formsByOpeningNode, + slotBoundary?.node ?? null, + ), + formTopologyComplete: slotBoundary?.complete ?? true, + }); + suspenseBoundaryIdsByRenderId.set( + renderId, + collectActiveSuspenseBoundaryIds( + tagName, + suspenseBoundariesByOpeningNode, + slotBoundary?.node ?? null, + ), + ); + errorBoundaryIdsByRenderId.set( + renderId, + collectActiveErrorBoundaryIds( + tagName, + errorBoundariesByOpeningNode, + slotBoundary?.node ?? null, + ), + ); + } + } + node.forEachChild(visit); + }; + functionNode.forEachChild(visit); + return { + edges, + errorBoundaryIdsByRenderId, + renders, + suspenseBoundaryIdsByRenderId, + }; +}; + +const collectSlotGraph = ( + renders: ReadonlyArray, + errorBoundaryIdsByRenderId: ReadonlyMap>, + suspenseBoundaryIdsByRenderId: ReadonlyMap>, + identitiesByFunction: ReadonlyMap, + slotFlow: ComponentSlotFlowDescriptor, + providersByOpeningNode: ReadonlyMap, + formsByOpeningNode: ReadonlyMap, + errorBoundariesByOpeningNode: ReadonlyMap, + suspenseBoundariesByOpeningNode: ReadonlyMap< + ts.JsxOpeningLikeElement, + ReactSemanticSuspenseBoundary + >, + context: ReactAnalysisContext, +): SlotGraphFacts => { + const rendersById = new Map(renders.map((render) => [render.id, render])); + const identitiesByUnitId = new Map( + [...identitiesByFunction.values()].map((identity) => [identity.semanticUnit.id, identity]), + ); + const resolvedRenders: ReactSemanticRender[] = []; + const slotRenders: ReactSemanticRender[] = []; + const slotFlows: ReactSemanticSlotFlow[] = []; + const resolvedSuspenseBoundaryIdsByRenderId = new Map(suspenseBoundaryIdsByRenderId); + const resolvedErrorBoundaryIdsByRenderId = new Map(errorBoundaryIdsByRenderId); + for (const render of renders) { + if (render.kind !== ReactSemanticRenderKind.SlotInput) { + resolvedRenders.push(render); + continue; + } + const containerRender = render.containerRenderId + ? rendersById.get(render.containerRenderId) + : null; + const containerIdentity = containerRender + ? identitiesByUnitId.get(containerRender.targetId) + : null; + const containerFunction = containerIdentity?.descriptor.functionNode ?? null; + const resolution = + containerFunction && render.slotPropName + ? slotFlow.resolveSlot(containerFunction, render.slotPropName) + : { complete: false, placements: [] }; + const sourceComplete = render.contextTopologyComplete; + const placementComplete = resolution.complete; + let complete = sourceComplete && placementComplete; + const renderIds: string[] = []; + for (const placement of resolution.placements) { + const placementIdentities = placement.topologyFrames.map((topologyFrame) => + identitiesByFunction.get(topologyFrame.ownerFunction), + ); + const placementIdentity = placementIdentities.at(-1); + if (!placementIdentity || placementIdentities.some((identity) => !identity)) { + complete = false; + continue; + } + const topologyPathIdentity = placement.topologyFrames + .map((topologyFrame) => { + const location = getNodeLocation(topologyFrame.node, context.rootDirectory); + return `${location.filePath}:${location.line}:${location.column}`; + }) + .join(">"); + const slotRender: ReactSemanticRender = { + id: createSemanticId( + "slot-render", + `${render.id}:${topologyPathIdentity}`, + placement.node, + context, + ), + ownerId: placementIdentity.semanticUnit.id, + targetId: render.targetId, + location: getNodeLocation(placement.node, context.rootDirectory), + kind: ReactSemanticRenderKind.Slot, + sourceRenderId: render.id, + containerRenderId: render.containerRenderId, + slotPropName: render.slotPropName, + topologyOwnerIds: [ + ...new Set([ + ...placementIdentities.flatMap((identity) => + identity ? [identity.semanticUnit.id] : [], + ), + render.ownerId, + ]), + ], + activeContextProviderIds: [ + ...new Set([ + ...placement.topologyFrames.flatMap((topologyFrame) => + collectActiveContextProviderIds(topologyFrame.node, providersByOpeningNode), + ), + ...render.activeContextProviderIds, + ]), + ], + contextTopologyComplete: true, + activeFormIds: [ + ...new Set([ + ...placement.topologyFrames.flatMap((topologyFrame) => + collectActiveFormIds(topologyFrame.node, formsByOpeningNode), + ), + ...render.activeFormIds, + ]), + ], + formTopologyComplete: true, + }; + slotRenders.push(slotRender); + renderIds.push(slotRender.id); + resolvedSuspenseBoundaryIdsByRenderId.set(slotRender.id, [ + ...new Set([ + ...(suspenseBoundaryIdsByRenderId.get(render.id) ?? []), + ...placement.topologyFrames.flatMap((topologyFrame) => + collectActiveSuspenseBoundaryIds(topologyFrame.node, suspenseBoundariesByOpeningNode), + ), + ]), + ]); + resolvedErrorBoundaryIdsByRenderId.set(slotRender.id, [ + ...new Set([ + ...(errorBoundaryIdsByRenderId.get(render.id) ?? []), + ...placement.topologyFrames.flatMap((topologyFrame) => + collectActiveErrorBoundaryIds(topologyFrame.node, errorBoundariesByOpeningNode), + ), + ]), + ]); + } + resolvedRenders.push({ + ...render, + contextTopologyComplete: complete, + formTopologyComplete: complete, + }); + slotFlows.push({ + id: `${render.id}:slot-flow:${render.slotPropName ?? "unknown"}`, + ownerId: render.ownerId, + sourceRenderId: render.id, + containerRenderId: render.containerRenderId, + propName: render.slotPropName, + renderIds, + location: render.location, + sourceComplete, + placementComplete, + complete, + }); + } + return { + errorBoundaryIdsByRenderId: resolvedErrorBoundaryIdsByRenderId, + renders: [...resolvedRenders, ...slotRenders], + slotFlows, + suspenseBoundaryIdsByRenderId: resolvedSuspenseBoundaryIdsByRenderId, + }; +}; + +const collectHookGraph = ( + identity: UnitGraphIdentity, + unitIdsBySymbol: ReadonlyMap, + context: ReactAnalysisContext, +): { hookCalls: ReadonlyArray; edges: ReadonlyArray } => { + const ownerNode = identity.descriptor.functionNode ?? identity.descriptor.node; + const hookCalls: ReactSemanticHookCall[] = []; + const edges: ReactSemanticEdge[] = []; + for (const hookCall of collectDirectHookCalls(ownerNode, context.typeChecker)) { + const hookName = getCanonicalHookName(hookCall, context.typeChecker) ?? "unknown-hook"; + const targetId = + resolveUnitTarget(hookCall.expression, unitIdsBySymbol, context.typeChecker) ?? + `react:${hookName}`; + const location = getNodeLocation(hookCall, context.rootDirectory); + hookCalls.push({ + id: createSemanticId("hook-call", hookName, hookCall, context), + ownerId: identity.semanticUnit.id, + name: hookName, + targetId, + location, + }); + edges.push({ + kind: ReactSemanticEdgeKind.CallsHook, + sourceId: identity.semanticUnit.id, + targetId, + location, + }); + } + return { hookCalls, edges }; +}; + +const getHookDependencyFacts = ( + hookCall: ts.CallExpression, + argumentIndex: number, +): { mode: ReactEffectDependencyMode; dependencies: ReadonlyArray } => { + const dependencyExpression = hookCall.arguments[argumentIndex]; + if (!dependencyExpression) { + return { mode: ReactEffectDependencyMode.Missing, dependencies: [] }; + } + if (!ts.isArrayLiteralExpression(dependencyExpression)) { + return { mode: ReactEffectDependencyMode.Opaque, dependencies: [] }; + } + return { + mode: ReactEffectDependencyMode.Inline, + dependencies: dependencyExpression.elements.map((dependency) => dependency.getText()), + }; +}; + +const createCallbackFact = ( + identity: UnitGraphIdentity, + callback: ts.FunctionLikeDeclaration, + owner: ts.FunctionLikeDeclaration, + stableSymbols: ReadonlySet, + kind: ReactSemanticCallbackKind, + phase: ReactExecutionPhase, + name: string, + context: ReactAnalysisContext, +): ReactSemanticCallback => ({ + id: createSemanticId(`${kind}:${identity.semanticUnit.id}`, name, callback, context), + ownerId: identity.semanticUnit.id, + kind, + phase, + name, + location: getNodeLocation(callback, context.rootDirectory), + captures: collectReactiveCaptures(callback, owner, context.typeChecker, stableSymbols).map( + (capture) => capture.key, + ), + stateWrites: collectCallbackStateWrites(callback, owner, context.typeChecker), +}); + +const createCallbackPropAlternative = ( + callbackId: string, + callbackDescriptor: ComponentCallbackDescriptor, + context: ReactAnalysisContext, +): ReactSemanticCallbackPropAlternative => ({ + callbackId, + guards: callbackDescriptor.guards.map((guard) => ({ + id: createSemanticId("callback-guard", "condition", guard.conditionNode, context), + polarity: guard.polarity, + })), +}); + +const collectReachabilityGraphFacts = ( + identity: UnitGraphIdentity, + rootFunction: ts.FunctionLikeDeclaration, + rootCallback: ReactSemanticCallback, + context: ReactAnalysisContext, + initialBindings: ReadonlyMap = new Map(), +): ReachabilityGraphFacts => { + const reachabilityGraph = collectReachableFunctionGraph( + rootFunction, + context.typeChecker, + initialBindings, + ); + const reachableFunctions: ReactSemanticReachableFunction[] = []; + const functionIdsByNode = new Map([ + [rootFunction, rootCallback.id], + ]); + for (const descriptor of reachabilityGraph.functions) { + if (descriptor.functionNode === rootFunction) continue; + const functionName = getFunctionName(descriptor.functionNode) ?? "anonymous helper"; + const reachableFunction: ReactSemanticReachableFunction = { + id: createSemanticId( + `reachable-function:${rootCallback.id}`, + functionName, + descriptor.functionNode, + context, + ), + ownerId: identity.semanticUnit.id, + rootCallbackId: rootCallback.id, + name: functionName, + phase: rootCallback.phase, + location: getNodeLocation(descriptor.functionNode, context.rootDirectory), + isConditionallyReached: descriptor.isConditionallyReached, + }; + reachableFunctions.push(reachableFunction); + functionIdsByNode.set(descriptor.functionNode, reachableFunction.id); + } + const functionCalls = reachabilityGraph.calls.flatMap( + (functionCall): ReadonlyArray => { + const sourceFunctionId = functionIdsByNode.get(functionCall.sourceFunctionNode); + const targetFunctionId = functionIdsByNode.get(functionCall.targetFunctionNode); + if (!sourceFunctionId || !targetFunctionId) return []; + return [ + { + id: createSemanticId( + `function-call:${rootCallback.id}:${sourceFunctionId}:${targetFunctionId}`, + functionCall.kind, + functionCall.callExpression, + context, + ), + ownerId: identity.semanticUnit.id, + rootCallbackId: rootCallback.id, + sourceFunctionId, + targetFunctionId, + kind: functionCall.kind, + phase: rootCallback.phase, + location: getNodeLocation(functionCall.callExpression, context.rootDirectory), + sourceParameterIndex: functionCall.sourceParameterIndex, + callArgumentIndex: functionCall.callArgumentIndex, + sourcePropertyPath: functionCall.sourcePropertyPath, + isConditionallyReached: functionCall.isConditionallyReached, + }, + ]; + }, + ); + return { reachableFunctions, functionCalls }; +}; + +const collectEffectGraph = ( + identity: UnitGraphIdentity, + identitiesByFunction: ReadonlyMap, + context: ReactAnalysisContext, + componentFlow: ComponentCallbackFlowDescriptor, +): EffectGraphFacts => { + const functionNode = identity.descriptor.functionNode; + if (!functionNode || identity.descriptor.kind === ReactUnitKind.InvalidHookOwner) { + return { + effects: [], + schedulers: [], + resources: [], + callbacks: [], + reachableFunctions: [], + functionCalls: [], + }; + } + const hookBindings = collectHookBindings(functionNode, context.typeChecker); + const stableSymbols = new Set([ + ...hookBindings.effectEvents, + ...hookBindings.refs, + ...hookBindings.stateSetters, + ]); + const effects: ReactSemanticEffect[] = []; + const schedulers: ReactSemanticScheduler[] = []; + const resources: ReactSemanticEffectResource[] = []; + const callbacks: ReactSemanticCallback[] = []; + const reachableFunctions: ReactSemanticReachableFunction[] = []; + const functionCalls: ReactSemanticFunctionCall[] = []; + const semanticOwnerId = identity.semanticUnit.id; + const schedulerProtocols = collectEffectSchedulerProtocols(functionNode, context); + const resourceProtocols = collectEffectResourceProtocols(functionNode, context); + for (const effectCall of collectEffectCalls(functionNode, context.typeChecker)) { + const hookName = getCanonicalHookName(effectCall, context.typeChecker) ?? "unknown-effect"; + const effectCallback = getEffectCallback(effectCall, context.typeChecker); + const dependencyFacts = getHookDependencyFacts(effectCall, 1); + const captures = effectCallback + ? collectReactiveCaptures( + effectCallback, + functionNode, + context.typeChecker, + stableSymbols, + ).map((capture) => capture.key) + : []; + const cleanupFunctions = effectCallback + ? collectEffectCleanupFunctions(effectCallback, context.typeChecker) + : []; + const setupCallback = effectCallback + ? createCallbackFact( + identity, + effectCallback, + functionNode, + stableSymbols, + ReactSemanticCallbackKind.EffectSetup, + ReactExecutionPhase.EffectSetup, + hookName, + context, + ) + : null; + const cleanupCallbacks = cleanupFunctions.map((cleanupFunction) => + createCallbackFact( + identity, + cleanupFunction, + functionNode, + stableSymbols, + ReactSemanticCallbackKind.EffectCleanup, + ReactExecutionPhase.EffectCleanup, + hookName, + context, + ), + ); + if (setupCallback) callbacks.push(setupCallback); + callbacks.push(...cleanupCallbacks); + if (effectCallback && setupCallback) { + const callbackResolution = componentFlow.resolveCallback( + effectCallback, + functionNode, + ReactExecutionPhase.EffectSetup, + ); + const reachabilityFacts = collectReachabilityGraphFacts( + identity, + effectCallback, + setupCallback, + context, + callbackResolution.bindings, + ); + reachableFunctions.push(...reachabilityFacts.reachableFunctions); + functionCalls.push(...reachabilityFacts.functionCalls); + } + for (const [cleanupIndex, cleanupFunction] of cleanupFunctions.entries()) { + const cleanupCallback = cleanupCallbacks[cleanupIndex]; + if (cleanupCallback) { + const callbackResolution = componentFlow.resolveCallback( + cleanupFunction, + functionNode, + ReactExecutionPhase.EffectCleanup, + ); + const reachabilityFacts = collectReachabilityGraphFacts( + identity, + cleanupFunction, + cleanupCallback, + context, + callbackResolution.bindings, + ); + reachableFunctions.push(...reachabilityFacts.reachableFunctions); + functionCalls.push(...reachabilityFacts.functionCalls); + } + } + const effectFact: ReactSemanticEffect = { + id: createSemanticId("effect", hookName, effectCall, context), + ownerId: semanticOwnerId, + hookName, + location: getNodeLocation(effectCall, context.rootDirectory), + callbackResolved: Boolean(effectCallback), + dependencyMode: dependencyFacts.mode, + dependencies: dependencyFacts.dependencies, + captures, + hasCleanup: cleanupFunctions.length > 0, + setupCallbackId: setupCallback?.id ?? null, + cleanupCallbackIds: cleanupCallbacks.map((callback) => callback.id), + }; + effects.push(effectFact); + for (const protocol of schedulerProtocols.filter( + (candidate) => candidate.effectCall === effectCall, + )) { + const schedulerId = createSemanticId( + "scheduler", + protocol.kind, + protocol.registrationCall, + context, + ); + const callbackResolution = protocol.callbackExpression + ? componentFlow.resolveExpression( + protocol.callbackExpression, + functionNode, + ReactExecutionPhase.Deferred, + ) + : null; + const schedulerCallbacks = (callbackResolution?.callbacks ?? []).map((callbackDescriptor) => { + const callbackOwner = + identitiesByFunction.get(callbackDescriptor.ownerFunction) ?? identity; + const callbackHookBindings = collectHookBindings( + callbackDescriptor.ownerFunction, + context.typeChecker, + ); + const callbackFact = createCallbackFact( + callbackOwner, + callbackDescriptor.callbackFunction, + callbackDescriptor.ownerFunction, + new Set([...callbackHookBindings.refs, ...callbackHookBindings.stateSetters]), + ReactSemanticCallbackKind.ScheduledCallback, + ReactExecutionPhase.Deferred, + protocol.kind, + context, + ); + return { + ...callbackFact, + id: createSemanticId( + `scheduled-callback:${schedulerId}`, + protocol.kind, + callbackDescriptor.callbackFunction, + context, + ), + }; + }); + callbacks.push(...schedulerCallbacks); + for (const [callbackIndex, callbackDescriptor] of ( + callbackResolution?.callbacks ?? [] + ).entries()) { + const callbackFact = schedulerCallbacks[callbackIndex]; + if (!callbackFact) continue; + const callbackOwner = + identitiesByFunction.get(callbackDescriptor.ownerFunction) ?? identity; + const reachabilityFacts = collectReachabilityGraphFacts( + callbackOwner, + callbackDescriptor.callbackFunction, + callbackFact, + context, + callbackDescriptor.bindings, + ); + reachableFunctions.push(...reachabilityFacts.reachableFunctions); + functionCalls.push(...reachabilityFacts.functionCalls); + } + const callbackComplete = Boolean( + callbackResolution?.isComplete && + schedulerCallbacks.length > 0 && + callbackResolution.callbacks.every((callbackDescriptor) => + isDeferredCallbackSynchronous(callbackDescriptor.callbackFunction, context), + ), + ); + schedulers.push({ + id: schedulerId, + ownerId: semanticOwnerId, + effectId: effectFact.id, + registrationCallbackId: effectFact.setupCallbackId ?? "", + kind: protocol.kind, + phase: ReactExecutionPhase.Deferred, + location: getNodeLocation(protocol.registrationCall, context.rootDirectory), + callbackIds: schedulerCallbacks.map((callback) => callback.id), + callbackComplete, + cancellationStatus: protocol.cancellationStatus, + cancellationLocations: protocol.cancellationCalls.map((cancellationCall) => + getNodeLocation(cancellationCall, context.rootDirectory), + ), + sourceComplete: protocol.isSourceComplete, + complete: + protocol.isSourceComplete && callbackComplete && Boolean(effectFact.setupCallbackId), + }); + } + for (const protocol of resourceProtocols.filter( + (candidate) => candidate.effectCall === effectCall, + )) { + const resourceId = createSemanticId( + "effect-resource", + protocol.kind, + protocol.acquisitionNode, + context, + ); + const callbackResolution = protocol.callbackExpression + ? componentFlow.resolveExpression( + protocol.callbackExpression, + functionNode, + ReactExecutionPhase.Deferred, + ) + : null; + const effectEventBinding = + protocol.callbackExpression && ts.isIdentifier(protocol.callbackExpression) + ? collectEffectEventBindings(functionNode, context.typeChecker).find( + (binding) => + context.typeChecker.getSymbolAtLocation( + protocol.callbackExpression ?? effectCall, + ) === binding.symbol, + ) + : null; + const directCallback = + protocol.callbackExpression && + (resolveFunction(protocol.callbackExpression, context.typeChecker) ?? + effectEventBinding?.callback); + let callbackDescriptors: ReadonlyArray = []; + if (!effectEventBinding && callbackResolution?.callbacks.length) { + callbackDescriptors = callbackResolution.callbacks; + } else if (!effectEventBinding && directCallback) { + callbackDescriptors = [ + { + bindings: new Map(), + callbackFunction: directCallback, + guards: [], + ownerFunction: functionNode, + }, + ]; + } + const resourceCallbacks = callbackDescriptors.map((callbackDescriptor) => { + const callbackOwner = + identitiesByFunction.get(callbackDescriptor.ownerFunction) ?? identity; + const callbackHookBindings = collectHookBindings( + callbackDescriptor.ownerFunction, + context.typeChecker, + ); + const callbackFact = createCallbackFact( + callbackOwner, + callbackDescriptor.callbackFunction, + callbackDescriptor.ownerFunction, + new Set([...callbackHookBindings.refs, ...callbackHookBindings.stateSetters]), + ReactSemanticCallbackKind.ResourceCallback, + ReactExecutionPhase.Deferred, + protocol.kind, + context, + ); + return { + ...callbackFact, + id: createSemanticId( + `resource-callback:${resourceId}`, + protocol.kind, + callbackDescriptor.callbackFunction, + context, + ), + }; + }); + callbacks.push(...resourceCallbacks); + for (const [callbackIndex, callbackDescriptor] of callbackDescriptors.entries()) { + const callbackFact = resourceCallbacks[callbackIndex]; + if (!callbackFact) continue; + const callbackOwner = + identitiesByFunction.get(callbackDescriptor.ownerFunction) ?? identity; + const reachabilityFacts = collectReachabilityGraphFacts( + callbackOwner, + callbackDescriptor.callbackFunction, + callbackFact, + context, + callbackDescriptor.bindings, + ); + reachableFunctions.push(...reachabilityFacts.reachableFunctions); + functionCalls.push(...reachabilityFacts.functionCalls); + } + const effectEventCallback = + effectEventBinding?.callback && + createCallbackFact( + identity, + effectEventBinding.callback, + functionNode, + stableSymbols, + ReactSemanticCallbackKind.EffectEvent, + ReactExecutionPhase.EffectEvent, + effectEventBinding.name, + context, + ); + const callbackIds = effectEventCallback + ? [effectEventCallback.id] + : resourceCallbacks.map((callback) => callback.id); + const callbackComplete = effectEventBinding?.callback + ? isDeferredCallbackSynchronous(effectEventBinding.callback, context) + : Boolean( + (callbackResolution?.isComplete || directCallback) && + resourceCallbacks.length > 0 && + callbackDescriptors.every((callbackDescriptor) => + isDeferredCallbackSynchronous(callbackDescriptor.callbackFunction, context), + ), + ); + resources.push({ + id: resourceId, + ownerId: semanticOwnerId, + effectId: effectFact.id, + acquisitionCallbackId: effectFact.setupCallbackId ?? "", + kind: protocol.kind, + phase: ReactExecutionPhase.Deferred, + location: getNodeLocation(protocol.acquisitionNode, context.rootDirectory), + activationLocations: protocol.acquisitionNodes.map((acquisitionNode) => + getNodeLocation(acquisitionNode, context.rootDirectory), + ), + callbackIds, + callbackComplete, + disposalStatus: protocol.disposalStatus, + disposalLocations: protocol.disposalCalls.map((disposalCall) => + getNodeLocation(disposalCall, context.rootDirectory), + ), + sourceComplete: protocol.isSourceComplete, + complete: + protocol.isSourceComplete && callbackComplete && Boolean(effectFact.setupCallbackId), + }); + } + } + return { effects, schedulers, resources, callbacks, reachableFunctions, functionCalls }; +}; + +const collectClassLifecycleGraph = ( + identity: UnitGraphIdentity, + context: ReactAnalysisContext, +): ClassLifecycleGraphFacts => { + const classNode = identity.descriptor.classNode; + const renderMethod = classNode ? getClassMethodDeclaration(classNode, "render") : null; + if (identity.descriptor.kind !== ReactUnitKind.ClassComponent || !classNode || !renderMethod) { + return { + construction: null, + lifecycle: null, + stateWrites: [], + transitions: [], + schedulers: [], + resources: [], + callbacks: [], + reachableFunctions: [], + functionCalls: [], + }; + } + const constructionDescriptor = collectClassConstruction(classNode, renderMethod, context); + const constructionId = createSemanticId( + "class-construction", + identity.descriptor.name, + classNode, + context, + ); + const constructionIssues = constructionDescriptor.issues.map((issue) => ({ + kind: issue.kind, + location: getNodeLocation(issue.node, context.rootDirectory), + status: issue.status, + })); + let constructionStatus = ReactClassConstructionStatus.Valid; + if ( + constructionIssues.some((issue) => issue.status === ReactClassConstructionIssueStatus.Violated) + ) { + constructionStatus = ReactClassConstructionStatus.Invalid; + } else if ( + constructionIssues.some((issue) => issue.status === ReactClassConstructionIssueStatus.Unknown) + ) { + constructionStatus = ReactClassConstructionStatus.Unknown; + } + const construction: ReactSemanticClassConstruction = { + id: constructionId, + ownerId: identity.semanticUnit.id, + phase: ReactExecutionPhase.ClassConstruction, + location: getNodeLocation(classNode, context.rootDirectory), + constructorLocation: constructionDescriptor.constructorDeclaration + ? getNodeLocation(constructionDescriptor.constructorDeclaration, context.rootDirectory) + : null, + initializationKind: constructionDescriptor.initializationKind, + initializationLocation: constructionDescriptor.initializationNode + ? getNodeLocation(constructionDescriptor.initializationNode, context.rootDirectory) + : null, + stateRequirement: constructionDescriptor.stateRequirement, + issues: constructionIssues, + status: constructionStatus, + sourceComplete: constructionStatus !== ReactClassConstructionStatus.Unknown, + complete: constructionStatus === ReactClassConstructionStatus.Valid, + }; + const mountMethod = getClassMethodDeclaration(classNode, "componentDidMount"); + const unmountMethod = getClassMethodDeclaration(classNode, "componentWillUnmount"); + const updateMethod = getClassMethodDeclaration(classNode, "componentDidUpdate"); + const componentDidCatchMethod = getClassMethodDeclaration(classNode, "componentDidCatch"); + const derivedStateFromErrorMethod = getStaticClassMethodDeclaration( + classNode, + "getDerivedStateFromError", + ); + const callbacks: ReactSemanticCallback[] = []; + const reachableFunctions: ReactSemanticReachableFunction[] = []; + const functionCalls: ReactSemanticFunctionCall[] = []; + const createLifecycleCallback = ( + method: ts.MethodDeclaration | null, + kind: ReactSemanticCallbackKind, + phase: ReactExecutionPhase, + name: string, + ): ReactSemanticCallback | null => { + if (!method) return null; + const callback = createCallbackFact( + identity, + method, + method, + new Set(), + kind, + phase, + name, + context, + ); + callbacks.push(callback); + const reachabilityFacts = collectReachabilityGraphFacts(identity, method, callback, context); + reachableFunctions.push(...reachabilityFacts.reachableFunctions); + functionCalls.push(...reachabilityFacts.functionCalls); + return callback; + }; + const mountCallback = createLifecycleCallback( + mountMethod, + ReactSemanticCallbackKind.ClassMount, + ReactExecutionPhase.ClassMount, + "componentDidMount", + ); + const unmountCallback = createLifecycleCallback( + unmountMethod, + ReactSemanticCallbackKind.ClassUnmount, + ReactExecutionPhase.ClassUnmount, + "componentWillUnmount", + ); + const updateCallback = createLifecycleCallback( + updateMethod, + ReactSemanticCallbackKind.ClassUpdate, + ReactExecutionPhase.ClassUpdate, + "componentDidUpdate", + ); + const stateWriteRoots: ClassStateWriteRootDescriptor[] = []; + if (mountMethod && mountCallback) { + stateWriteRoots.push({ + callbackId: mountCallback.id, + functionNode: mountMethod, + phase: ReactExecutionPhase.ClassMount, + }); + } + if (unmountMethod && unmountCallback) { + stateWriteRoots.push({ + callbackId: unmountCallback.id, + functionNode: unmountMethod, + phase: ReactExecutionPhase.ClassUnmount, + }); + } + if (updateMethod && updateCallback) { + stateWriteRoots.push({ + callbackId: updateCallback.id, + functionNode: updateMethod, + phase: ReactExecutionPhase.ClassUpdate, + }); + } + const resourceProtocols = mountMethod + ? collectLifecycleResourceProtocols( + mountMethod, + unmountMethod ? [unmountMethod] : [], + Boolean(unmountMethod), + context, + ) + : []; + const schedulerProtocols = mountMethod + ? collectLifecycleSchedulerProtocols( + mountMethod, + unmountMethod ? [unmountMethod] : [], + Boolean(unmountMethod), + context, + ) + : []; + const transitionDescriptors = identity.descriptor.classComponentBase + ? collectClassStateTransitions( + mountMethod, + updateMethod, + identity.descriptor.classComponentBase, + context, + ) + : []; + const transitions: ReactSemanticClassStateTransition[] = []; + const transitionUpdaterFunctions = new Set(); + for (const descriptor of transitionDescriptors) { + const transitionId = createSemanticId( + "class-state-transition", + descriptor.phase, + descriptor.callExpression, + context, + ); + const updaterCallback = descriptor.updaterFunction + ? createCallbackFact( + identity, + descriptor.updaterFunction, + descriptor.updaterFunction, + new Set(), + ReactSemanticCallbackKind.ClassStateUpdater, + ReactExecutionPhase.StateTransition, + "class-state-updater", + context, + ) + : null; + const identifiedUpdaterCallback = + updaterCallback && descriptor.updaterFunction + ? { + ...updaterCallback, + id: createSemanticId( + `class-state-updater:${transitionId}`, + "updater", + descriptor.updaterFunction, + context, + ), + } + : null; + if (identifiedUpdaterCallback && descriptor.updaterFunction) { + stateWriteRoots.push({ + callbackId: identifiedUpdaterCallback.id, + functionNode: descriptor.updaterFunction, + phase: ReactExecutionPhase.StateTransition, + }); + transitionUpdaterFunctions.add(descriptor.updaterFunction); + callbacks.push(identifiedUpdaterCallback); + const reachabilityFacts = collectReachabilityGraphFacts( + identity, + descriptor.updaterFunction, + identifiedUpdaterCallback, + context, + ); + reachableFunctions.push(...reachabilityFacts.reachableFunctions); + functionCalls.push(...reachabilityFacts.functionCalls); + } + const lifecycleCallback = + descriptor.phase === ReactExecutionPhase.ClassMount ? mountCallback : updateCallback; + const hasSafeUpdater = + descriptor.updaterStatus !== ReactClassStateUpdaterStatus.Impure && + descriptor.updaterStatus !== ReactClassStateUpdaterStatus.Unknown; + const hasSafeCycle = + descriptor.cycleStatus !== ReactClassUpdateCycleStatus.Guaranteed && + descriptor.cycleStatus !== ReactClassUpdateCycleStatus.Unknown; + transitions.push({ + id: transitionId, + ownerId: identity.semanticUnit.id, + lifecycleCallbackId: lifecycleCallback?.id ?? "", + updaterCallbackId: identifiedUpdaterCallback?.id ?? null, + phase: descriptor.phase, + location: getNodeLocation(descriptor.callExpression, context.rootDirectory), + guardLocations: descriptor.guardNodes.map((guardNode) => + getNodeLocation(guardNode, context.rootDirectory), + ), + updaterStatus: descriptor.updaterStatus, + cycleStatus: descriptor.cycleStatus, + commitCallbackProvided: descriptor.commitCallbackProvided, + sourceComplete: descriptor.isSourceComplete, + complete: + descriptor.isSourceComplete && hasSafeUpdater && hasSafeCycle && Boolean(lifecycleCallback), + }); + } + const resources: ReactSemanticEffectResource[] = []; + const resourceCallbackFunctions = new Set(); + for (const protocol of resourceProtocols) { + const resourceId = createSemanticId( + "class-resource", + protocol.kind, + protocol.acquisitionNode, + context, + ); + const callbackFunction = protocol.callbackExpression + ? resolveFunction(protocol.callbackExpression, context.typeChecker) + : null; + if (callbackFunction) resourceCallbackFunctions.add(callbackFunction); + const resourceCallback = callbackFunction + ? createCallbackFact( + identity, + callbackFunction, + callbackFunction, + new Set(), + ReactSemanticCallbackKind.ResourceCallback, + ReactExecutionPhase.Deferred, + protocol.kind, + context, + ) + : null; + const identifiedResourceCallback = + resourceCallback && callbackFunction + ? { + ...resourceCallback, + id: createSemanticId( + `resource-callback:${resourceId}`, + protocol.kind, + callbackFunction, + context, + ), + } + : null; + if (identifiedResourceCallback && callbackFunction) { + stateWriteRoots.push({ + callbackId: identifiedResourceCallback.id, + functionNode: callbackFunction, + phase: ReactExecutionPhase.Deferred, + }); + callbacks.push(identifiedResourceCallback); + const reachabilityFacts = collectReachabilityGraphFacts( + identity, + callbackFunction, + identifiedResourceCallback, + context, + ); + reachableFunctions.push(...reachabilityFacts.reachableFunctions); + functionCalls.push(...reachabilityFacts.functionCalls); + } + const callbackComplete = Boolean( + callbackFunction && + identifiedResourceCallback && + isDeferredCallbackSynchronous(callbackFunction, context), + ); + resources.push({ + id: resourceId, + ownerId: identity.semanticUnit.id, + effectId: null, + acquisitionCallbackId: mountCallback?.id ?? "", + kind: protocol.kind, + phase: ReactExecutionPhase.Deferred, + location: getNodeLocation(protocol.acquisitionNode, context.rootDirectory), + activationLocations: protocol.acquisitionNodes.map((acquisitionNode) => + getNodeLocation(acquisitionNode, context.rootDirectory), + ), + callbackIds: identifiedResourceCallback ? [identifiedResourceCallback.id] : [], + callbackComplete, + disposalStatus: protocol.disposalStatus, + disposalLocations: protocol.disposalCalls.map((disposalCall) => + getNodeLocation(disposalCall, context.rootDirectory), + ), + sourceComplete: protocol.isSourceComplete, + complete: protocol.isSourceComplete && callbackComplete && Boolean(mountCallback), + }); + } + const schedulers: ReactSemanticScheduler[] = []; + const schedulerCallbackFunctions = new Set(); + for (const protocol of schedulerProtocols) { + const schedulerId = createSemanticId( + "class-scheduler", + protocol.kind, + protocol.registrationCall, + context, + ); + const callbackFunction = protocol.callbackExpression + ? resolveFunction(protocol.callbackExpression, context.typeChecker) + : null; + if (callbackFunction) schedulerCallbackFunctions.add(callbackFunction); + const schedulerCallback = callbackFunction + ? createCallbackFact( + identity, + callbackFunction, + callbackFunction, + new Set(), + ReactSemanticCallbackKind.ScheduledCallback, + ReactExecutionPhase.Deferred, + protocol.kind, + context, + ) + : null; + const identifiedSchedulerCallback = + schedulerCallback && callbackFunction + ? { + ...schedulerCallback, + id: createSemanticId( + `scheduler-callback:${schedulerId}`, + protocol.kind, + callbackFunction, + context, + ), + } + : null; + if (identifiedSchedulerCallback && callbackFunction) { + stateWriteRoots.push({ + callbackId: identifiedSchedulerCallback.id, + functionNode: callbackFunction, + phase: ReactExecutionPhase.Deferred, + }); + callbacks.push(identifiedSchedulerCallback); + const reachabilityFacts = collectReachabilityGraphFacts( + identity, + callbackFunction, + identifiedSchedulerCallback, + context, + ); + reachableFunctions.push(...reachabilityFacts.reachableFunctions); + functionCalls.push(...reachabilityFacts.functionCalls); + } + const callbackComplete = Boolean( + callbackFunction && + identifiedSchedulerCallback && + isDeferredCallbackSynchronous(callbackFunction, context), + ); + schedulers.push({ + id: schedulerId, + ownerId: identity.semanticUnit.id, + effectId: null, + registrationCallbackId: mountCallback?.id ?? "", + kind: protocol.kind, + phase: ReactExecutionPhase.Deferred, + location: getNodeLocation(protocol.registrationCall, context.rootDirectory), + callbackIds: identifiedSchedulerCallback ? [identifiedSchedulerCallback.id] : [], + callbackComplete, + cancellationStatus: protocol.cancellationStatus, + cancellationLocations: protocol.cancellationCalls.map((cancellationCall) => + getNodeLocation(cancellationCall, context.rootDirectory), + ), + sourceComplete: protocol.isSourceComplete, + complete: protocol.isSourceComplete && callbackComplete && Boolean(mountCallback), + }); + } + const stateWrites: ReactSemanticClassStateWrite[] = collectClassStateWrites( + stateWriteRoots, + context, + ).map((descriptor) => ({ + id: createSemanticId( + `class-state-write:${descriptor.callbackId}`, + descriptor.kind, + descriptor.node, + context, + ), + ownerId: identity.semanticUnit.id, + callbackId: descriptor.callbackId, + phase: descriptor.phase, + location: getNodeLocation(descriptor.node, context.rootDirectory), + kind: descriptor.kind, + status: descriptor.status, + sourceComplete: descriptor.status !== ReactClassStateWriteStatus.Unknown, + complete: false, + })); + const representedLifecycleCalls = new Set([ + ...resourceProtocols.flatMap((protocol) => [ + ...protocol.acquisitionNodes.filter(ts.isCallExpression), + ...protocol.disposalCalls, + ]), + ...schedulerProtocols.flatMap((protocol) => [ + protocol.registrationCall, + ...protocol.cancellationCalls, + ]), + ...transitionDescriptors.map((descriptor) => descriptor.callExpression), + ]); + const lifecycleCalls = [ + ...(mountMethod ? collectReachableCallExpressions(mountMethod, context.typeChecker) : []), + ...(unmountMethod ? collectReachableCallExpressions(unmountMethod, context.typeChecker) : []), + ...(updateMethod ? collectReachableCallExpressions(updateMethod, context.typeChecker) : []), + ]; + const representedClassMembers = new Set([ + renderMethod, + ...constructionDescriptor.representedMembers, + ...(mountMethod ? [mountMethod] : []), + ...(unmountMethod ? [unmountMethod] : []), + ...(updateMethod ? [updateMethod] : []), + ...(componentDidCatchMethod ? [componentDidCatchMethod] : []), + ...(derivedStateFromErrorMethod ? [derivedStateFromErrorMethod] : []), + ...[...resourceCallbackFunctions].filter(ts.isMethodDeclaration), + ...[...schedulerCallbackFunctions].filter(ts.isMethodDeclaration), + ...[...transitionUpdaterFunctions].filter(ts.isMethodDeclaration), + ...schedulerProtocols.flatMap((protocol) => + protocol.handleDeclaration ? [protocol.handleDeclaration] : [], + ), + ]); + const sourceComplete = + identity.descriptor.sourceComplete && + construction.sourceComplete && + classNode.members.every((member) => representedClassMembers.has(member)) && + lifecycleCalls.every((callExpression) => representedLifecycleCalls.has(callExpression)); + const lifecycleId = createSemanticId( + "class-lifecycle", + identity.descriptor.name, + classNode, + context, + ); + return { + construction, + lifecycle: { + id: lifecycleId, + ownerId: identity.semanticUnit.id, + location: getNodeLocation(classNode, context.rootDirectory), + constructionId, + mountCallbackId: mountCallback?.id ?? null, + unmountCallbackId: unmountCallback?.id ?? null, + updateCallbackId: updateCallback?.id ?? null, + resourceIds: resources.map((resource) => resource.id), + schedulerIds: schedulers.map((scheduler) => scheduler.id), + stateWriteIds: stateWrites.map((stateWrite) => stateWrite.id), + transitionIds: transitions.map((transition) => transition.id), + sourceComplete, + complete: + sourceComplete && + construction.complete && + resources.every((resource) => resource.complete) && + schedulers.every((scheduler) => scheduler.complete) && + stateWrites.every((stateWrite) => stateWrite.complete) && + transitions.every((transition) => transition.complete), + }, + stateWrites, + transitions, + schedulers, + resources, + callbacks, + reachableFunctions, + functionCalls, + }; +}; + +const collectAsyncTaskGraph = ( + identity: UnitGraphIdentity, + context: ReactAnalysisContext, +): ReadonlyArray => { + const functionNode = identity.descriptor.functionNode; + if (!functionNode || identity.descriptor.kind === ReactUnitKind.InvalidHookOwner) return []; + return collectAsyncEffectTaskDescriptors(functionNode, context).map((task) => { + const hookName = getCanonicalHookName(task.effectCall, context.typeChecker) ?? "unknown-effect"; + return { + id: createSemanticId("async-task", "continuation", task.taskNode, context), + ownerId: identity.semanticUnit.id, + effectId: createSemanticId("effect", hookName, task.effectCall, context), + location: getNodeLocation(task.taskNode, context.rootDirectory), + stateWrites: task.stateWriteNames, + ownershipStatus: task.status, + }; + }); +}; + +const collectEventGraph = ( + identities: ReadonlyArray, + context: ReactAnalysisContext, + eventFlow: ComponentCallbackFlowDescriptor, +): EventGraphFacts => { + const callbacks: ReactSemanticCallback[] = []; + const reachableFunctions: ReactSemanticReachableFunction[] = []; + const functionCalls: ReactSemanticFunctionCall[] = []; + const identitiesByFunction = new Map( + identities.flatMap( + (identity): ReadonlyArray<[ts.FunctionLikeDeclaration, UnitGraphIdentity]> => + identity.descriptor.functionNode ? [[identity.descriptor.functionNode, identity]] : [], + ), + ); + const callbackFactsByOwner = new Map< + ts.FunctionLikeDeclaration, + Map + >(); + for (const callbackDescriptor of eventFlow.bindings.flatMap((binding) => binding.callbacks)) { + const identity = identitiesByFunction.get(callbackDescriptor.ownerFunction); + if (!identity) continue; + const hookBindings = collectHookBindings(callbackDescriptor.ownerFunction, context.typeChecker); + const stableSymbols = new Set([...hookBindings.refs, ...hookBindings.stateSetters]); + const callbackFact = createCallbackFact( + identity, + callbackDescriptor.callbackFunction, + callbackDescriptor.ownerFunction, + stableSymbols, + ReactSemanticCallbackKind.EventHandler, + ReactExecutionPhase.Event, + "event handler", + context, + ); + callbacks.push(callbackFact); + const ownerCallbackFacts = + callbackFactsByOwner.get(callbackDescriptor.ownerFunction) ?? new Map(); + ownerCallbackFacts.set(callbackDescriptor.callbackFunction, callbackFact); + callbackFactsByOwner.set(callbackDescriptor.ownerFunction, ownerCallbackFacts); + const reachabilityFacts = collectReachabilityGraphFacts( + identity, + callbackDescriptor.callbackFunction, + callbackFact, + context, + callbackDescriptor.bindings, + ); + reachableFunctions.push(...reachabilityFacts.reachableFunctions); + functionCalls.push(...reachabilityFacts.functionCalls); + } + const getCallbackIds = ( + callbackDescriptors: ReadonlyArray<{ + callbackFunction: ts.FunctionLikeDeclaration; + ownerFunction: ts.FunctionLikeDeclaration; + }>, + ): ReadonlyArray => + callbackDescriptors.flatMap((callbackDescriptor) => { + const callbackFact = callbackFactsByOwner + .get(callbackDescriptor.ownerFunction) + ?.get(callbackDescriptor.callbackFunction); + return callbackFact ? [callbackFact.id] : []; + }); + const eventBindings = eventFlow.bindings.flatMap( + (binding): ReadonlyArray => { + const identity = identitiesByFunction.get(binding.ownerFunction); + if (!identity) return []; + return [ + { + id: createSemanticId( + `event-binding:${identity.semanticUnit.id}`, + binding.eventName, + binding.node, + context, + ), + ownerId: identity.semanticUnit.id, + eventName: binding.eventName, + location: getNodeLocation(binding.node, context.rootDirectory), + callbackIds: getCallbackIds(binding.callbacks), + complete: binding.isComplete, + }, + ]; + }, + ); + return { + callbacks, + reachableFunctions, + functionCalls, + eventBindings, + }; +}; + +const getCallbackKindForPhase = (phase: ReactExecutionPhase): ReactSemanticCallbackKind | null => { + if (phase === ReactExecutionPhase.Event) return ReactSemanticCallbackKind.EventHandler; + if (phase === ReactExecutionPhase.EffectSetup) { + return ReactSemanticCallbackKind.EffectSetup; + } + if (phase === ReactExecutionPhase.EffectCleanup) { + return ReactSemanticCallbackKind.EffectCleanup; + } + if (phase === ReactExecutionPhase.ExternalStoreSubscription) { + return ReactSemanticCallbackKind.ExternalStoreSubscribe; + } + if (phase === ReactExecutionPhase.ServerRender) { + return ReactSemanticCallbackKind.ServerSnapshot; + } + return null; +}; + +const collectCallbackPropGraph = ( + identities: ReadonlyArray, + context: ReactAnalysisContext, + componentFlow: ComponentCallbackFlowDescriptor, + existingCallbacks: ReadonlyArray, +): CallbackPropGraphFacts => { + const identitiesByFunction = new Map( + identities.flatMap( + (identity): ReadonlyArray<[ts.FunctionLikeDeclaration, UnitGraphIdentity]> => + identity.descriptor.functionNode ? [[identity.descriptor.functionNode, identity]] : [], + ), + ); + const callbacks: ReactSemanticCallback[] = []; + const reachableFunctions: ReactSemanticReachableFunction[] = []; + const functionCalls: ReactSemanticFunctionCall[] = []; + const callbackReachabilityById = new Map(); + const callbackPropFlows = componentFlow + .collectPropFlows() + .flatMap((propFlow): ReadonlyArray => { + const renderOwner = identitiesByFunction.get(propFlow.renderOwnerFunction); + const targetOwner = identitiesByFunction.get(propFlow.targetFunction); + const callbackKind = getCallbackKindForPhase(propFlow.phase); + if (!renderOwner || !targetOwner) return []; + const alternatives = propFlow.callbacks.flatMap( + (callbackDescriptor): ReadonlyArray => { + const identity = identitiesByFunction.get(callbackDescriptor.ownerFunction); + if (!identity) return []; + const callbackLocation = getNodeLocation( + callbackDescriptor.callbackFunction, + context.rootDirectory, + ); + const existingCallback = existingCallbacks.find( + (callback) => + callback.ownerId === identity.semanticUnit.id && + callback.phase === propFlow.phase && + callback.location.filePath === callbackLocation.filePath && + callback.location.line === callbackLocation.line && + callback.location.column === callbackLocation.column, + ); + if (existingCallback) { + return [ + createCallbackPropAlternative(existingCallback.id, callbackDescriptor, context), + ]; + } + const createdCallback = callbacks.find( + (callback) => + callback.ownerId === identity.semanticUnit.id && + callback.phase === propFlow.phase && + callback.location.filePath === callbackLocation.filePath && + callback.location.line === callbackLocation.line && + callback.location.column === callbackLocation.column, + ); + if (createdCallback) { + const reachabilityDescriptor = callbackReachabilityById.get(createdCallback.id); + if (reachabilityDescriptor) { + callbackReachabilityById.set(createdCallback.id, { + ...reachabilityDescriptor, + callbackDescriptor: { + ...reachabilityDescriptor.callbackDescriptor, + bindings: mergeCallableBindings([ + reachabilityDescriptor.callbackDescriptor.bindings, + callbackDescriptor.bindings, + ]), + }, + }); + } + return [createCallbackPropAlternative(createdCallback.id, callbackDescriptor, context)]; + } + if (!callbackKind) return []; + const hookBindings = collectHookBindings( + callbackDescriptor.ownerFunction, + context.typeChecker, + ); + const stableSymbols = new Set([...hookBindings.refs, ...hookBindings.stateSetters]); + const callbackFact = createCallbackFact( + identity, + callbackDescriptor.callbackFunction, + callbackDescriptor.ownerFunction, + stableSymbols, + callbackKind, + propFlow.phase, + `callback prop ${propFlow.propName}`, + context, + ); + callbacks.push(callbackFact); + callbackReachabilityById.set(callbackFact.id, { + callbackDescriptor, + callbackFact, + identity, + }); + return [createCallbackPropAlternative(callbackFact.id, callbackDescriptor, context)]; + }, + ); + const callbackIds = [...new Set(alternatives.map((alternative) => alternative.callbackId))]; + return [ + { + id: createSemanticId( + `callback-prop-flow:${propFlow.phase}:${renderOwner.semanticUnit.id}:${targetOwner.semanticUnit.id}`, + propFlow.propName, + propFlow.node, + context, + ), + renderId: createSemanticId( + "render", + targetOwner.semanticUnit.id, + propFlow.renderNode.tagName, + context, + ), + renderOwnerId: renderOwner.semanticUnit.id, + targetOwnerId: targetOwner.semanticUnit.id, + propName: propFlow.propName, + phase: propFlow.phase, + location: getNodeLocation(propFlow.node, context.rootDirectory), + alternatives, + callbackIds, + complete: propFlow.isComplete && alternatives.length === propFlow.callbacks.length, + }, + ]; + }); + for (const reachabilityDescriptor of callbackReachabilityById.values()) { + const reachabilityFacts = collectReachabilityGraphFacts( + reachabilityDescriptor.identity, + reachabilityDescriptor.callbackDescriptor.callbackFunction, + reachabilityDescriptor.callbackFact, + context, + reachabilityDescriptor.callbackDescriptor.bindings, + ); + reachableFunctions.push(...reachabilityFacts.reachableFunctions); + functionCalls.push(...reachabilityFacts.functionCalls); + } + return { callbacks, reachableFunctions, functionCalls, callbackPropFlows }; +}; + +const collectMemoCallbacks = ( + identity: UnitGraphIdentity, + context: ReactAnalysisContext, +): CallbackGraphFacts => { + const functionNode = identity.descriptor.functionNode; + if (!functionNode) return { callbacks: [], reachableFunctions: [], functionCalls: [] }; + const hookBindings = collectHookBindings(functionNode, context.typeChecker); + const stableSymbols = new Set([...hookBindings.refs, ...hookBindings.stateSetters]); + const callbacks: ReactSemanticCallback[] = []; + const reachableFunctions: ReactSemanticReachableFunction[] = []; + const functionCalls: ReactSemanticFunctionCall[] = []; + for (const hookCall of collectHookCalls( + functionNode, + REACT_MEMO_HOOK_NAMES, + context.typeChecker, + )) { + const hookName = getCanonicalHookName(hookCall, context.typeChecker) ?? "memo-hook"; + const callbackExpression = hookCall.arguments[0]; + const callback = callbackExpression + ? resolveFunction(callbackExpression, context.typeChecker) + : null; + if (!callback) continue; + const isMemoFactory = hookName === "useMemo"; + const callbackFact = createCallbackFact( + identity, + callback, + functionNode, + stableSymbols, + isMemoFactory + ? ReactSemanticCallbackKind.MemoFactory + : ReactSemanticCallbackKind.MemoizedCallback, + isMemoFactory ? ReactExecutionPhase.Render : ReactExecutionPhase.Deferred, + hookName, + context, + ); + callbacks.push(callbackFact); + const reachabilityFacts = collectReachabilityGraphFacts( + identity, + callback, + callbackFact, + context, + ); + reachableFunctions.push(...reachabilityFacts.reachableFunctions); + functionCalls.push(...reachabilityFacts.functionCalls); + } + return { callbacks, reachableFunctions, functionCalls }; +}; + +const getReducerPurityStatus = ( + functionNode: ts.FunctionLikeDeclaration | null, + context: ReactAnalysisContext, +): ReactReducerPurityStatus => { + if (!functionNode) return ReactReducerPurityStatus.Opaque; + const purityProof = analyzeRenderPurity(functionNode, context); + if (purityProof.status === ReactObligationStatus.Violated) { + return ReactReducerPurityStatus.Impure; + } + return purityProof.status === ReactObligationStatus.Proved + ? ReactReducerPurityStatus.Pure + : ReactReducerPurityStatus.Opaque; +}; + +const getReducerReturnStatus = ( + functionNode: ts.FunctionLikeDeclaration | null, + isAbsent: boolean, + context: ReactAnalysisContext, +): ReactReducerReturnStatus => { + if (isAbsent) return ReactReducerReturnStatus.Absent; + if (!functionNode) return ReactReducerReturnStatus.Opaque; + const returnSummary = summarizeFunctionReturns(functionNode, context.typeChecker); + if (returnSummary.canFallThrough) return ReactReducerReturnStatus.MayFallThrough; + if (returnSummary.canThrow) return ReactReducerReturnStatus.MayThrow; + return returnSummary.isComplete + ? ReactReducerReturnStatus.Total + : ReactReducerReturnStatus.Opaque; +}; + +const collectReducerDefinitionGraph = ( + identity: UnitGraphIdentity, + context: ReactAnalysisContext, +): ReducerDefinitionGraphFacts => { + const functionNode = identity.descriptor.functionNode; + if ( + !functionNode || + identity.descriptor.kind === ReactUnitKind.ClassComponent || + identity.descriptor.kind === ReactUnitKind.InvalidHookOwner + ) { + return { + reducers: [], + callbacks: [], + reachableFunctions: [], + functionCalls: [], + }; + } + const callbacks: ReactSemanticCallback[] = []; + const reachableFunctions: ReactSemanticReachableFunction[] = []; + const functionCalls: ReactSemanticFunctionCall[] = []; + const hookBindings = collectHookBindings(functionNode, context.typeChecker); + const stableSymbols = new Set([ + ...hookBindings.refs, + ...hookBindings.stateSetters, + ...hookBindings.transitionStarters, + ]); + const createReducerCallback = ( + reducerId: string, + callbackFunction: ts.FunctionLikeDeclaration | null, + kind: ReactSemanticCallbackKind, + name: string, + ): ReactSemanticCallback | null => { + if (!callbackFunction) return null; + const callback = { + ...createCallbackFact( + identity, + callbackFunction, + functionNode, + stableSymbols, + kind, + ReactExecutionPhase.StateTransition, + name, + context, + ), + id: createSemanticId(`${kind}:${reducerId}`, name, callbackFunction, context), + }; + callbacks.push(callback); + const reachabilityFacts = collectReachabilityGraphFacts( + identity, + callbackFunction, + callback, + context, + ); + reachableFunctions.push(...reachabilityFacts.reachableFunctions); + functionCalls.push(...reachabilityFacts.functionCalls); + return callback; + }; + const reducers = collectReducerTransitions(functionNode, context).reducers.map( + (descriptor): ReactSemanticReducer => { + const reducerId = createSemanticId( + "reducer", + descriptor.dispatcherSymbol?.getName() ?? descriptor.stateSymbol?.getName() ?? "useReducer", + descriptor.callExpression, + context, + ); + const reducerCallback = createReducerCallback( + reducerId, + descriptor.reducerFunction, + ReactSemanticCallbackKind.Reducer, + "reducer", + ); + const initializerCallback = createReducerCallback( + reducerId, + descriptor.initializerFunction, + ReactSemanticCallbackKind.ReducerInitializer, + "reducer-initializer", + ); + const reducerPurity = getReducerPurityStatus(descriptor.reducerFunction, context); + const initializerPurity = descriptor.initializerProvided + ? getReducerPurityStatus(descriptor.initializerFunction, context) + : ReactReducerPurityStatus.Pure; + const reducerReturnStatus = getReducerReturnStatus( + descriptor.reducerFunction, + false, + context, + ); + const initializerReturnStatus = getReducerReturnStatus( + descriptor.initializerFunction, + !descriptor.initializerProvided, + context, + ); + const sourceComplete = + Boolean(reducerCallback) && + reducerPurity !== ReactReducerPurityStatus.Opaque && + reducerReturnStatus !== ReactReducerReturnStatus.Opaque && + (!descriptor.initializerProvided || + (Boolean(initializerCallback) && + initializerPurity !== ReactReducerPurityStatus.Opaque && + initializerReturnStatus !== ReactReducerReturnStatus.Opaque)); + const complete = + sourceComplete && + reducerPurity === ReactReducerPurityStatus.Pure && + initializerPurity === ReactReducerPurityStatus.Pure && + reducerReturnStatus === ReactReducerReturnStatus.Total && + (initializerReturnStatus === ReactReducerReturnStatus.Absent || + initializerReturnStatus === ReactReducerReturnStatus.Total); + return { + id: reducerId, + ownerId: identity.semanticUnit.id, + stateName: descriptor.stateSymbol?.getName() ?? "unused reducer state", + dispatcherName: descriptor.dispatcherSymbol?.getName() ?? "unused reducer dispatcher", + location: getNodeLocation(descriptor.callExpression, context.rootDirectory), + reducerCallbackId: reducerCallback?.id ?? null, + initializerCallbackId: initializerCallback?.id ?? null, + reducerPurity, + initializerPurity, + reducerReturnStatus, + initializerReturnStatus, + sourceComplete, + complete, + }; + }, + ); + return { reducers, callbacks, reachableFunctions, functionCalls }; +}; + +const collectReducerDispatchGraph = ( + identity: UnitGraphIdentity, + existingReducers: ReadonlyArray, + existingCallbacks: ReadonlyArray, + existingReachableFunctions: ReadonlyArray, + context: ReactAnalysisContext, +): ReducerDispatchGraphFacts => { + const functionNode = identity.descriptor.functionNode; + if ( + !functionNode || + identity.descriptor.kind === ReactUnitKind.ClassComponent || + identity.descriptor.kind === ReactUnitKind.InvalidHookOwner + ) { + return { dispatches: [] }; + } + const callbacksById = new Map(existingCallbacks.map((callback) => [callback.id, callback])); + const collection = collectReducerTransitions(functionNode, context); + const reducersByCall = new Map( + collection.reducers.flatMap( + (descriptor): ReadonlyArray<[ts.CallExpression, ReactSemanticReducer]> => { + const reducerId = createSemanticId( + "reducer", + descriptor.dispatcherSymbol?.getName() ?? + descriptor.stateSymbol?.getName() ?? + "useReducer", + descriptor.callExpression, + context, + ); + const reducer = existingReducers.find((candidate) => candidate.id === reducerId); + return reducer ? [[descriptor.callExpression, reducer]] : []; + }, + ), + ); + const dispatches = collection.dispatches.map((descriptor): ReactSemanticReducerDispatch => { + const reducer = reducersByCall.get(descriptor.binding.callExpression); + const executionCallbackIds = descriptor.callExpression + ? collectExecutionCallbackIds({ + callbacks: existingCallbacks, + evidenceNode: descriptor.callExpression, + ownerId: identity.semanticUnit.id, + reachableFunctions: existingReachableFunctions, + rootDirectory: context.rootDirectory, + }) + : []; + const executionCallbacks = executionCallbackIds.flatMap((callbackId) => { + const callback = callbacksById.get(callbackId); + return callback ? [callback] : []; + }); + let status = ReactReducerDispatchStatus.Unknown; + if (!descriptor.callExpression) { + status = ReactReducerDispatchStatus.Escape; + } else if ( + executionCallbacks.some((callback) => callback.phase === ReactExecutionPhase.Render) + ) { + status = ReactReducerDispatchStatus.Render; + } else if ( + executionCallbacks.some((callback) => callback.phase === ReactExecutionPhase.StateTransition) + ) { + status = ReactReducerDispatchStatus.Reducer; + } else if (executionCallbacks.length > 0) { + status = ReactReducerDispatchStatus.Owned; + } + const sourceComplete = + Boolean(reducer?.complete) && + status !== ReactReducerDispatchStatus.Escape && + status !== ReactReducerDispatchStatus.Unknown; + return { + id: createSemanticId( + "reducer-dispatch", + descriptor.binding.dispatcherSymbol.getName(), + descriptor.evidenceNode, + context, + ), + ownerId: identity.semanticUnit.id, + reducerId: reducer?.id ?? "", + kind: descriptor.callExpression + ? ReactReducerDispatchKind.Call + : ReactReducerDispatchKind.Escape, + location: getNodeLocation(descriptor.evidenceNode, context.rootDirectory), + executionCallbackIds, + status, + sourceComplete, + complete: sourceComplete && status === ReactReducerDispatchStatus.Owned, + }; + }); + return { dispatches }; +}; + +const collectActionStateDefinitionGraph = ( + identity: UnitGraphIdentity, + context: ReactAnalysisContext, +): ActionStateDefinitionGraphFacts => { + const functionNode = identity.descriptor.functionNode; + if ( + !functionNode || + identity.descriptor.kind === ReactUnitKind.ClassComponent || + identity.descriptor.kind === ReactUnitKind.InvalidHookOwner + ) { + return { + states: [], + callbacksByDispatcher: new Map(), + callbacks: [], + reachableFunctions: [], + functionCalls: [], + }; + } + const collection = collectActionState(functionNode, context); + const callbacks: ReactSemanticCallback[] = []; + const callbacksByDispatcher = new Map(); + const reachableFunctions: ReactSemanticReachableFunction[] = []; + const functionCalls: ReactSemanticFunctionCall[] = []; + const hookBindings = collectHookBindings(functionNode, context.typeChecker); + const stableSymbols = new Set([ + ...hookBindings.refs, + ...hookBindings.stateSetters, + ...hookBindings.transitionStarters, + ]); + const states = collection.states.map((descriptor): ReactSemanticActionState => { + const stateId = createSemanticId( + "action-state", + descriptor.binding.dispatcherSymbol?.getName() ?? + descriptor.binding.stateSymbol?.getName() ?? + "useActionState", + descriptor.binding.callExpression, + context, + ); + const reducerCallback = descriptor.reducerFunction + ? { + ...createCallbackFact( + identity, + descriptor.reducerFunction, + functionNode, + stableSymbols, + ReactSemanticCallbackKind.ActionStateReducer, + ReactExecutionPhase.ActionStateReducer, + "action-state-reducer", + context, + ), + id: createSemanticId( + `action-state-reducer:${stateId}`, + "reducer", + descriptor.reducerFunction, + context, + ), + } + : null; + if (reducerCallback && descriptor.reducerFunction) { + callbacks.push(reducerCallback); + if (descriptor.binding.dispatcherSymbol) { + callbacksByDispatcher.set(descriptor.binding.dispatcherSymbol, reducerCallback); + } + const reachabilityFacts = collectReachabilityGraphFacts( + identity, + descriptor.reducerFunction, + reducerCallback, + context, + ); + reachableFunctions.push(...reachabilityFacts.reachableFunctions); + functionCalls.push(...reachabilityFacts.functionCalls); + } + const reducerStatus = reducerCallback + ? ReactActionStateReducerStatus.Resolved + : ReactActionStateReducerStatus.Opaque; + const sourceComplete = Boolean(reducerCallback); + return { + id: stateId, + ownerId: identity.semanticUnit.id, + stateName: descriptor.binding.stateSymbol?.getName() ?? "unused Action State", + dispatcherName: + descriptor.binding.dispatcherSymbol?.getName() ?? "unused Action State dispatcher", + location: getNodeLocation(descriptor.binding.callExpression, context.rootDirectory), + reducerCallbackId: reducerCallback?.id ?? null, + reducerStatus, + sourceComplete, + complete: sourceComplete, + }; + }); + return { states, callbacksByDispatcher, callbacks, reachableFunctions, functionCalls }; +}; + +const collectFormActionGraph = ( + identities: ReadonlyArray, + context: ReactAnalysisContext, + componentFlow: ComponentCallbackFlowDescriptor, + actionStateCallbacksByDispatcher: ReadonlyMap, +): FormActionGraphFacts => { + const identitiesByFunction = new Map( + identities.flatMap( + (identity): ReadonlyArray<[ts.FunctionLikeDeclaration, UnitGraphIdentity]> => + identity.descriptor.functionNode ? [[identity.descriptor.functionNode, identity]] : [], + ), + ); + const actions: ReactSemanticFormAction[] = []; + const callbacks: ReactSemanticCallback[] = []; + const reachableFunctions: ReactSemanticReachableFunction[] = []; + const functionCalls: ReactSemanticFunctionCall[] = []; + for (const identity of identities) { + const functionNode = identity.descriptor.functionNode; + if (!functionNode) continue; + for (const descriptor of collectFormActions(identity.descriptor, context.typeChecker)) { + const actionId = createSemanticId( + "form-action", + descriptor.propertyName, + descriptor.evidenceNode, + context, + ); + const resolution = descriptor.isSpread + ? componentFlow.resolveProperty( + descriptor.actionExpression, + descriptor.propertyName, + functionNode, + ReactExecutionPhase.FormAction, + ) + : componentFlow.resolveExpression( + descriptor.actionExpression, + functionNode, + ReactExecutionPhase.FormAction, + ); + const dispatcherSymbol = descriptor.isSpread + ? null + : getResolvedSymbol( + unwrapTypescriptExpression(descriptor.actionExpression), + context.typeChecker, + ); + const actionStateCallback = dispatcherSymbol + ? actionStateCallbacksByDispatcher.get(dispatcherSymbol) + : undefined; + const actionCallbackIds: string[] = actionStateCallback ? [actionStateCallback.id] : []; + for (const callbackDescriptor of resolution.callbacks) { + const callbackIdentity = identitiesByFunction.get(callbackDescriptor.ownerFunction); + if (!callbackIdentity) continue; + const hookBindings = collectHookBindings( + callbackDescriptor.ownerFunction, + context.typeChecker, + ); + const callbackFact = { + ...createCallbackFact( + callbackIdentity, + callbackDescriptor.callbackFunction, + callbackDescriptor.ownerFunction, + new Set([ + ...hookBindings.refs, + ...hookBindings.stateSetters, + ...hookBindings.transitionStarters, + ]), + ReactSemanticCallbackKind.FormAction, + ReactExecutionPhase.FormAction, + descriptor.propertyName, + context, + ), + id: createSemanticId( + `form-action-callback:${actionId}`, + getFunctionName(callbackDescriptor.callbackFunction) ?? descriptor.propertyName, + callbackDescriptor.callbackFunction, + context, + ), + }; + callbacks.push(callbackFact); + actionCallbackIds.push(callbackFact.id); + const reachabilityFacts = collectReachabilityGraphFacts( + callbackIdentity, + callbackDescriptor.callbackFunction, + callbackFact, + context, + callbackDescriptor.bindings, + ); + reachableFunctions.push(...reachabilityFacts.reachableFunctions); + functionCalls.push(...reachabilityFacts.functionCalls); + } + const callbackComplete = + Boolean(actionStateCallback) || + (resolution.isComplete && + actionCallbackIds.length > 0 && + actionCallbackIds.length === resolution.callbacks.length); + let status = descriptor.status; + if (status === ReactFormActionStatus.Resolved && !callbackComplete) { + status = ReactFormActionStatus.Opaque; + } + const sourceComplete = callbackComplete && status !== ReactFormActionStatus.Opaque; + actions.push({ + id: actionId, + ownerId: identity.semanticUnit.id, + kind: descriptor.kind, + propName: descriptor.propertyName, + location: getNodeLocation(descriptor.evidenceNode, context.rootDirectory), + actionCallbackIds, + status, + callbackComplete, + sourceComplete, + complete: sourceComplete && status === ReactFormActionStatus.Resolved, + }); + } + } + return { actions, callbacks, reachableFunctions, functionCalls }; +}; + +const collectTransitionActionGraph = ( + identity: UnitGraphIdentity, + existingCallbacks: ReadonlyArray, + existingReachableFunctions: ReadonlyArray, + context: ReactAnalysisContext, +): TransitionActionGraphFacts => { + const functionNode = identity.descriptor.functionNode; + if (!functionNode || identity.descriptor.kind === ReactUnitKind.InvalidHookOwner) { + return { actions: [], callbacks: [], reachableFunctions: [], functionCalls: [] }; + } + const callbacks: ReactSemanticCallback[] = []; + const reachableFunctions: ReactSemanticReachableFunction[] = []; + const functionCalls: ReactSemanticFunctionCall[] = []; + const hookBindings = collectHookBindings(functionNode, context.typeChecker); + const stableSymbols = new Set([ + ...hookBindings.refs, + ...hookBindings.stateSetters, + ...hookBindings.transitionStarters, + ]); + const actionIdentities: TransitionActionGraphIdentity[] = collectTransitionActions( + identity.descriptor, + context, + ).map((descriptor) => { + const actionId = createSemanticId( + "transition-action", + descriptor.starterKind, + descriptor.evidenceNode, + context, + ); + const actionCallback = descriptor.actionFunction + ? { + ...createCallbackFact( + identity, + descriptor.actionFunction, + functionNode, + stableSymbols, + ReactSemanticCallbackKind.TransitionAction, + ReactExecutionPhase.TransitionAction, + "transition-action", + context, + ), + id: createSemanticId( + `transition-action-callback:${actionId}`, + "action", + descriptor.actionFunction, + context, + ), + } + : null; + if (actionCallback && descriptor.actionFunction) { + callbacks.push(actionCallback); + const reachabilityFacts = collectReachabilityGraphFacts( + identity, + descriptor.actionFunction, + actionCallback, + context, + ); + reachableFunctions.push(...reachabilityFacts.reachableFunctions); + functionCalls.push(...reachabilityFacts.functionCalls); + } + return { actionCallback, actionId, descriptor }; + }); + const allCallbacks = [...existingCallbacks, ...callbacks]; + const allReachableFunctions = [...existingReachableFunctions, ...reachableFunctions]; + const callbacksById = new Map(allCallbacks.map((callback) => [callback.id, callback])); + const validOriginPhases = new Set([ + ReactExecutionPhase.ActionStateReducer, + ReactExecutionPhase.ClassMount, + ReactExecutionPhase.ClassUpdate, + ReactExecutionPhase.Deferred, + ReactExecutionPhase.EffectCleanup, + ReactExecutionPhase.EffectEvent, + ReactExecutionPhase.EffectSetup, + ReactExecutionPhase.Event, + ReactExecutionPhase.ExternalStoreSubscription, + ReactExecutionPhase.FormAction, + ReactExecutionPhase.TransitionAction, + ]); + const actions = actionIdentities.map( + ({ actionCallback, actionId, descriptor }): ReactSemanticTransitionAction => { + const executionCallbackIds = collectExecutionCallbackIds({ + callbacks: allCallbacks, + evidenceNode: descriptor.callExpression, + ownerId: identity.semanticUnit.id, + reachableFunctions: allReachableFunctions, + rootDirectory: context.rootDirectory, + }); + const hasValidExecutionRoot = + executionCallbackIds.length > 0 && + executionCallbackIds.every((callbackId) => { + const callback = callbacksById.get(callbackId); + return Boolean( + callback && + callback.ownerId === identity.semanticUnit.id && + validOriginPhases.has(callback.phase), + ); + }); + const hasCompleteSourceStatus = + descriptor.status === ReactTransitionActionStatus.Synchronous || + descriptor.status === ReactTransitionActionStatus.ControlledInput; + const sourceComplete = + hasValidExecutionRoot && Boolean(actionCallback) && hasCompleteSourceStatus; + return { + id: actionId, + ownerId: identity.semanticUnit.id, + starterKind: descriptor.starterKind, + location: getNodeLocation(descriptor.evidenceNode, context.rootDirectory), + executionCallbackIds, + actionCallbackId: actionCallback?.id ?? null, + controlledStateNames: descriptor.controlledStateNames, + unknownControlStateNames: descriptor.unknownControlStateNames, + status: descriptor.status, + sourceComplete, + complete: sourceComplete && descriptor.status === ReactTransitionActionStatus.Synchronous, + }; + }, + ); + return { actions, callbacks, reachableFunctions, functionCalls }; +}; + +const collectActionStateDispatchGraph = ( + identity: UnitGraphIdentity, + existingStates: ReadonlyArray, + existingFormActions: ReadonlyArray, + existingTransitionActions: ReadonlyArray, + existingCallbacks: ReadonlyArray, + existingReachableFunctions: ReadonlyArray, + context: ReactAnalysisContext, +): ActionStateDispatchGraphFacts => { + const functionNode = identity.descriptor.functionNode; + if ( + !functionNode || + identity.descriptor.kind === ReactUnitKind.ClassComponent || + identity.descriptor.kind === ReactUnitKind.InvalidHookOwner + ) { + return { dispatches: [] }; + } + const collection = collectActionState(functionNode, context); + const callbacksById = new Map(existingCallbacks.map((callback) => [callback.id, callback])); + const statesByDispatcher = new Map( + collection.states.flatMap( + (descriptor): ReadonlyArray<[ts.Symbol, ReactSemanticActionState]> => { + const dispatcherSymbol = descriptor.binding.dispatcherSymbol; + if (!dispatcherSymbol) return []; + const stateId = createSemanticId( + "action-state", + dispatcherSymbol.getName(), + descriptor.binding.callExpression, + context, + ); + const state = existingStates.find((candidate) => candidate.id === stateId); + return state ? [[dispatcherSymbol, state]] : []; + }, + ), + ); + const completeTransitionCallbackIds = new Set( + existingTransitionActions.flatMap((action) => + action.complete && action.actionCallbackId ? [action.actionCallbackId] : [], + ), + ); + return { + dispatches: collection.dispatches.map((descriptor): ReactSemanticActionStateDispatch => { + const actionState = statesByDispatcher.get(descriptor.binding.dispatcherSymbol); + const dispatchId = createSemanticId( + "action-state-dispatch", + descriptor.binding.dispatcherSymbol.getName(), + descriptor.evidenceNode, + context, + ); + const executionCallbackIds = descriptor.callExpression + ? collectExecutionCallbackIds({ + callbacks: existingCallbacks, + evidenceNode: descriptor.callExpression, + ownerId: identity.semanticUnit.id, + reachableFunctions: existingReachableFunctions, + rootDirectory: context.rootDirectory, + }) + : []; + const executionCallbacks = executionCallbackIds.flatMap((callbackId) => { + const callback = callbacksById.get(callbackId); + return callback ? [callback] : []; + }); + let status = ReactActionStateDispatchStatus.Unknown; + if (!descriptor.callExpression && !descriptor.isActionPropReference) { + status = ReactActionStateDispatchStatus.SetterEscape; + } else if (descriptor.isActionPropReference) { + const location = getNodeLocation(descriptor.evidenceNode, context.rootDirectory); + const formAction = existingFormActions.find( + (action) => + action.ownerId === identity.semanticUnit.id && + action.complete && + areProofLocationsEqual(action.location, location), + ); + if ( + formAction && + actionState?.reducerCallbackId && + formAction.actionCallbackIds.includes(actionState.reducerCallbackId) + ) { + status = ReactActionStateDispatchStatus.Action; + } + } else if ( + executionCallbacks.some((callback) => callback.phase === ReactExecutionPhase.Render) + ) { + status = ReactActionStateDispatchStatus.Render; + } else if ( + executionCallbacks.length > 0 && + executionCallbacks.every( + (callback) => + callback.phase === ReactExecutionPhase.FormAction || + callback.phase === ReactExecutionPhase.ActionStateReducer || + (callback.phase === ReactExecutionPhase.TransitionAction && + completeTransitionCallbackIds.has(callback.id)), + ) + ) { + status = ReactActionStateDispatchStatus.Action; + } else if ( + executionCallbacks.some( + (callback) => + callback.phase !== ReactExecutionPhase.FormAction && + callback.phase !== ReactExecutionPhase.ActionStateReducer && + callback.phase !== ReactExecutionPhase.TransitionAction, + ) + ) { + status = ReactActionStateDispatchStatus.OutsideAction; + } + const sourceComplete = + Boolean(actionState?.complete) && + status !== ReactActionStateDispatchStatus.SetterEscape && + status !== ReactActionStateDispatchStatus.Unknown; + let kind = ReactActionStateDispatchKind.Escape; + if (descriptor.callExpression) { + kind = ReactActionStateDispatchKind.Call; + } else if (descriptor.isActionPropReference) { + kind = ReactActionStateDispatchKind.ActionProp; + } + return { + id: dispatchId, + ownerId: identity.semanticUnit.id, + actionStateId: actionState?.id ?? "", + kind, + location: getNodeLocation(descriptor.evidenceNode, context.rootDirectory), + executionCallbackIds, + status, + sourceComplete, + complete: sourceComplete && status === ReactActionStateDispatchStatus.Action, + }; + }), + }; +}; + +const collectHookStateTransitionGraph = ( + identity: UnitGraphIdentity, + existingCallbacks: ReadonlyArray, + existingReachableFunctions: ReadonlyArray, + context: ReactAnalysisContext, +): HookStateTransitionGraphFacts => { + const functionNode = identity.descriptor.functionNode; + if ( + !functionNode || + identity.descriptor.kind === ReactUnitKind.ClassComponent || + identity.descriptor.kind === ReactUnitKind.InvalidHookOwner + ) { + return { transitions: [], callbacks: [], reachableFunctions: [], functionCalls: [] }; + } + const callbacks: ReactSemanticCallback[] = []; + const reachableFunctions: ReactSemanticReachableFunction[] = []; + const functionCalls: ReactSemanticFunctionCall[] = []; + const existingCallbacksById = new Map( + existingCallbacks.map((callback) => [callback.id, callback]), + ); + const transitions = collectHookStateTransitions(functionNode, context).map((descriptor) => { + const transitionId = createSemanticId( + "hook-state-transition", + descriptor.setterName, + descriptor.evidenceNode, + context, + ); + const executionCallbackIds = collectExecutionCallbackIds({ + callbacks: existingCallbacks, + evidenceNode: descriptor.callExpression, + ownerId: identity.semanticUnit.id, + reachableFunctions: existingReachableFunctions, + rootDirectory: context.rootDirectory, + }); + const updaterCallback = descriptor.updaterFunction + ? createCallbackFact( + identity, + descriptor.updaterFunction, + functionNode, + new Set(), + ReactSemanticCallbackKind.HookStateUpdater, + ReactExecutionPhase.StateTransition, + "hook-state-updater", + context, + ) + : null; + const identifiedUpdaterCallback = + updaterCallback && descriptor.updaterFunction + ? { + ...updaterCallback, + id: createSemanticId( + `hook-state-updater:${transitionId}`, + "updater", + descriptor.updaterFunction, + context, + ), + } + : null; + if (identifiedUpdaterCallback && descriptor.updaterFunction) { + callbacks.push(identifiedUpdaterCallback); + const reachabilityFacts = collectReachabilityGraphFacts( + identity, + descriptor.updaterFunction, + identifiedUpdaterCallback, + context, + ); + reachableFunctions.push(...reachabilityFacts.reachableFunctions); + functionCalls.push(...reachabilityFacts.functionCalls); + } + const hasModeledExecutionRoot = + executionCallbackIds.length > 0 && + executionCallbackIds.every( + (callbackId) => + existingCallbacksById.get(callbackId)?.phase !== ReactExecutionPhase.StateTransition, + ); + const sourceComplete = + hasModeledExecutionRoot && + descriptor.updaterStatus !== ReactHookStateUpdaterStatus.SetterEscape && + descriptor.updaterStatus !== ReactHookStateUpdaterStatus.Unknown; + return { + id: transitionId, + ownerId: identity.semanticUnit.id, + stateName: descriptor.stateName, + setterName: descriptor.setterName, + location: getNodeLocation(descriptor.evidenceNode, context.rootDirectory), + executionCallbackIds, + updaterCallbackId: identifiedUpdaterCallback?.id ?? null, + updaterStatus: descriptor.updaterStatus, + sourceComplete, + complete: + sourceComplete && + (descriptor.updaterStatus === ReactHookStateUpdaterStatus.DirectValue || + descriptor.updaterStatus === ReactHookStateUpdaterStatus.Pure), + }; + }); + return { transitions, callbacks, reachableFunctions, functionCalls }; +}; + +const collectHostControlGraph = ( + identities: ReadonlyArray, + eventBindings: ReadonlyArray, + stateTransitions: ReadonlyArray, + context: ReactAnalysisContext, +): ReadonlyArray => + identities.flatMap((identity) => { + const functionNode = identity.descriptor.functionNode; + if (!functionNode || identity.descriptor.kind === ReactUnitKind.InvalidHookOwner) return []; + return collectHostControlProtocols(functionNode, context).map((descriptor) => { + const callbackLocation = descriptor.callbackSourceNode + ? getNodeLocation(descriptor.callbackSourceNode, context.rootDirectory) + : null; + const callbackIds = callbackLocation + ? (eventBindings.find( + (eventBinding) => + eventBinding.ownerId === identity.semanticUnit.id && + eventBinding.eventName === "onChange" && + areProofLocationsEqual(eventBinding.location, callbackLocation), + )?.callbackIds ?? []) + : []; + const transitionIds = descriptor.setterCallExpressions.flatMap((callExpression) => { + const callLocation = getNodeLocation(callExpression, context.rootDirectory); + const transition = stateTransitions.find( + (candidate) => + candidate.ownerId === identity.semanticUnit.id && + areProofLocationsEqual(candidate.location, callLocation), + ); + return transition ? [transition.id] : []; + }); + return { + id: createSemanticId("host-control", descriptor.kind, descriptor.node, context), + ownerId: identity.semanticUnit.id, + location: getNodeLocation(descriptor.node, context.rootDirectory), + kind: descriptor.kind, + controlledPropName: descriptor.controlledPropName, + controlledPropPresent: descriptor.controlledPropPresent, + defaultPropName: descriptor.defaultPropName, + defaultPropPresent: descriptor.defaultPropPresent, + stateName: descriptor.stateName, + setterName: descriptor.setterName, + valueStatus: descriptor.valueStatus, + mutabilityStatus: descriptor.mutabilityStatus, + updateStatus: descriptor.updateStatus, + callbackIds, + transitionIds, + status: descriptor.status, + sourceComplete: descriptor.sourceComplete, + complete: + descriptor.sourceComplete && descriptor.status === ReactHostControlStatus.Resolved, + }; + }); + }); + +const collectOptimisticStateGraph = ( + identity: UnitGraphIdentity, + existingCallbacks: ReadonlyArray, + existingReachableFunctions: ReadonlyArray, + existingTransitionActions: ReadonlyArray, + context: ReactAnalysisContext, +): OptimisticStateGraphFacts => { + const functionNode = identity.descriptor.functionNode; + if ( + !functionNode || + identity.descriptor.kind === ReactUnitKind.ClassComponent || + identity.descriptor.kind === ReactUnitKind.InvalidHookOwner + ) { + return { + states: [], + updates: [], + callbacks: [], + reachableFunctions: [], + functionCalls: [], + }; + } + const collection = collectOptimisticState(functionNode, context); + const callbacks: ReactSemanticCallback[] = []; + const reachableFunctions: ReactSemanticReachableFunction[] = []; + const functionCalls: ReactSemanticFunctionCall[] = []; + const stateIdsBySetter = new Map(); + const states = collection.states.map((descriptor): ReactSemanticOptimisticState => { + const stateId = createSemanticId( + "optimistic-state", + descriptor.binding.setterSymbol?.getName() ?? + descriptor.binding.stateSymbol?.getName() ?? + "useOptimistic", + descriptor.binding.callExpression, + context, + ); + if (descriptor.binding.setterSymbol) { + stateIdsBySetter.set(descriptor.binding.setterSymbol, stateId); + } + const reducerCallback = descriptor.reducerFunction + ? { + ...createCallbackFact( + identity, + descriptor.reducerFunction, + functionNode, + new Set(), + ReactSemanticCallbackKind.OptimisticReducer, + ReactExecutionPhase.OptimisticReducer, + "optimistic-reducer", + context, + ), + id: createSemanticId( + `optimistic-reducer:${stateId}`, + "reducer", + descriptor.reducerFunction, + context, + ), + } + : null; + if (reducerCallback && descriptor.reducerFunction) { + callbacks.push(reducerCallback); + const reachabilityFacts = collectReachabilityGraphFacts( + identity, + descriptor.reducerFunction, + reducerCallback, + context, + ); + reachableFunctions.push(...reachabilityFacts.reachableFunctions); + functionCalls.push(...reachabilityFacts.functionCalls); + } + const sourceComplete = + descriptor.reducerStatus === ReactOptimisticReducerStatus.Absent || + ((descriptor.reducerStatus === ReactOptimisticReducerStatus.Pure || + descriptor.reducerStatus === ReactOptimisticReducerStatus.Impure) && + Boolean(reducerCallback)); + return { + id: stateId, + ownerId: identity.semanticUnit.id, + stateName: descriptor.binding.stateSymbol?.getName() ?? "unused optimistic state", + setterName: descriptor.binding.setterSymbol?.getName() ?? "unused optimistic setter", + location: getNodeLocation(descriptor.binding.callExpression, context.rootDirectory), + reducerCallbackId: reducerCallback?.id ?? null, + reducerStatus: descriptor.reducerStatus, + sourceComplete, + complete: + sourceComplete && + (descriptor.reducerStatus === ReactOptimisticReducerStatus.Absent || + descriptor.reducerStatus === ReactOptimisticReducerStatus.Pure), + }; + }); + const rootCallbacks = [...existingCallbacks, ...callbacks]; + const rootReachableFunctions = [...existingReachableFunctions, ...reachableFunctions]; + const rootCallbacksById = new Map(rootCallbacks.map((callback) => [callback.id, callback])); + const completeTransitionCallbackIds = new Set( + existingTransitionActions.flatMap((action) => + action.complete && action.actionCallbackId ? [action.actionCallbackId] : [], + ), + ); + const updates = collection.updates.map((descriptor): ReactSemanticOptimisticUpdate => { + const optimisticStateId = + stateIdsBySetter.get(descriptor.binding.setterSymbol) ?? + createSemanticId( + "optimistic-state", + descriptor.binding.setterSymbol.getName(), + descriptor.binding.callExpression, + context, + ); + const updateId = createSemanticId( + "optimistic-update", + descriptor.binding.setterSymbol.getName(), + descriptor.evidenceNode, + context, + ); + const executionCallbackIds = collectExecutionCallbackIds({ + callbacks: rootCallbacks, + evidenceNode: descriptor.callExpression, + ownerId: identity.semanticUnit.id, + reachableFunctions: rootReachableFunctions, + rootDirectory: context.rootDirectory, + }); + const executionCallbacks = executionCallbackIds.flatMap((callbackId) => { + const callback = rootCallbacksById.get(callbackId); + return callback ? [callback] : []; + }); + let actionStatus = ReactOptimisticActionStatus.Unknown; + if (executionCallbacks.some((callback) => callback.phase === ReactExecutionPhase.Render)) { + actionStatus = ReactOptimisticActionStatus.Render; + } else if ( + executionCallbacks.length > 0 && + executionCallbacks.every( + (callback) => + callback.phase === ReactExecutionPhase.FormAction || + callback.phase === ReactExecutionPhase.ActionStateReducer || + (callback.phase === ReactExecutionPhase.TransitionAction && + completeTransitionCallbackIds.has(callback.id)), + ) + ) { + actionStatus = ReactOptimisticActionStatus.Action; + } else if ( + executionCallbacks.some( + (callback) => + callback.phase !== ReactExecutionPhase.FormAction && + callback.phase !== ReactExecutionPhase.ActionStateReducer && + callback.phase !== ReactExecutionPhase.TransitionAction, + ) + ) { + actionStatus = ReactOptimisticActionStatus.OutsideAction; + } + const updaterCallback = descriptor.updaterFunction + ? { + ...createCallbackFact( + identity, + descriptor.updaterFunction, + functionNode, + new Set(), + ReactSemanticCallbackKind.OptimisticUpdater, + ReactExecutionPhase.OptimisticUpdater, + "optimistic-updater", + context, + ), + id: createSemanticId( + `optimistic-updater:${updateId}`, + "updater", + descriptor.updaterFunction, + context, + ), + } + : null; + if (updaterCallback && descriptor.updaterFunction) { + callbacks.push(updaterCallback); + const reachabilityFacts = collectReachabilityGraphFacts( + identity, + descriptor.updaterFunction, + updaterCallback, + context, + ); + reachableFunctions.push(...reachabilityFacts.reachableFunctions); + functionCalls.push(...reachabilityFacts.functionCalls); + } + const sourceComplete = + actionStatus !== ReactOptimisticActionStatus.Unknown && + descriptor.updaterStatus !== ReactHookStateUpdaterStatus.SetterEscape && + descriptor.updaterStatus !== ReactHookStateUpdaterStatus.Unknown; + return { + id: updateId, + ownerId: identity.semanticUnit.id, + optimisticStateId, + location: getNodeLocation(descriptor.evidenceNode, context.rootDirectory), + executionCallbackIds, + updaterCallbackId: updaterCallback?.id ?? null, + updaterStatus: descriptor.updaterStatus, + actionStatus, + sourceComplete, + complete: + sourceComplete && + actionStatus === ReactOptimisticActionStatus.Action && + (descriptor.updaterStatus === ReactHookStateUpdaterStatus.DirectValue || + descriptor.updaterStatus === ReactHookStateUpdaterStatus.Pure), + }; + }); + return { states, updates, callbacks, reachableFunctions, functionCalls }; +}; + +const collectExternalStoreGraph = ( + identity: UnitGraphIdentity, + identitiesByFunction: ReadonlyMap, + context: ReactAnalysisContext, + componentFlow: ComponentCallbackFlowDescriptor, +): ExternalStoreGraphFacts => { + const functionNode = identity.descriptor.functionNode; + if (!functionNode || identity.descriptor.kind === ReactUnitKind.InvalidHookOwner) { + return { externalStores: [], callbacks: [], reachableFunctions: [], functionCalls: [] }; + } + const externalStores: ReactSemanticExternalStore[] = []; + const callbacks: ReactSemanticCallback[] = []; + const reachableFunctions: ReactSemanticReachableFunction[] = []; + const functionCalls: ReactSemanticFunctionCall[] = []; + const collectCallbackFacts = ( + expression: ts.Expression | undefined, + descriptor: ExternalStoreCallbackDescriptor, + isOptional: boolean, + ): ExternalStoreCallbackFacts => { + if (!expression) { + return { + callbackIds: [], + callbacks: [], + isComplete: isOptional, + reachableFunctions: [], + functionCalls: [], + }; + } + const resolution = componentFlow.resolveExpression(expression, functionNode, descriptor.phase); + const resolvedCallbacks: ReactSemanticCallback[] = []; + const resolvedReachableFunctions: ReactSemanticReachableFunction[] = []; + const resolvedFunctionCalls: ReactSemanticFunctionCall[] = []; + for (const callbackDescriptor of resolution.callbacks) { + const callbackOwner = identitiesByFunction.get(callbackDescriptor.ownerFunction) ?? identity; + const ownerHookBindings = collectHookBindings( + callbackDescriptor.ownerFunction, + context.typeChecker, + ); + const ownerStableSymbols = new Set([ + ...ownerHookBindings.refs, + ...ownerHookBindings.stateSetters, + ]); + const callbackFact = createCallbackFact( + callbackOwner, + callbackDescriptor.callbackFunction, + callbackDescriptor.ownerFunction, + ownerStableSymbols, + descriptor.kind, + descriptor.phase, + descriptor.name, + context, + ); + resolvedCallbacks.push(callbackFact); + const reachabilityFacts = collectReachabilityGraphFacts( + callbackOwner, + callbackDescriptor.callbackFunction, + callbackFact, + context, + callbackDescriptor.bindings, + ); + resolvedReachableFunctions.push(...reachabilityFacts.reachableFunctions); + resolvedFunctionCalls.push(...reachabilityFacts.functionCalls); + } + return { + callbackIds: resolvedCallbacks.map((callback) => callback.id), + callbacks: resolvedCallbacks, + isComplete: resolution.isComplete && resolvedCallbacks.length > 0, + reachableFunctions: resolvedReachableFunctions, + functionCalls: resolvedFunctionCalls, + }; + }; + for (const hookCall of collectHookCalls( + functionNode, + REACT_EXTERNAL_STORE_HOOK_NAMES, + context.typeChecker, + )) { + const subscribeExpression = hookCall.arguments[0]; + const snapshotExpression = hookCall.arguments[1]; + const serverSnapshotExpression = hookCall.arguments[2]; + const subscribeFacts = collectCallbackFacts( + subscribeExpression, + { + kind: ReactSemanticCallbackKind.ExternalStoreSubscribe, + name: "subscribe", + phase: ReactExecutionPhase.ExternalStoreSubscription, + }, + false, + ); + const snapshotFacts = collectCallbackFacts( + snapshotExpression, + { + kind: ReactSemanticCallbackKind.ExternalStoreSnapshot, + name: "getSnapshot", + phase: ReactExecutionPhase.Render, + }, + false, + ); + const serverSnapshotFacts = collectCallbackFacts( + serverSnapshotExpression, + { + kind: ReactSemanticCallbackKind.ServerSnapshot, + name: "getServerSnapshot", + phase: ReactExecutionPhase.ServerRender, + }, + true, + ); + callbacks.push( + ...subscribeFacts.callbacks, + ...snapshotFacts.callbacks, + ...serverSnapshotFacts.callbacks, + ); + reachableFunctions.push( + ...subscribeFacts.reachableFunctions, + ...snapshotFacts.reachableFunctions, + ...serverSnapshotFacts.reachableFunctions, + ); + functionCalls.push( + ...subscribeFacts.functionCalls, + ...snapshotFacts.functionCalls, + ...serverSnapshotFacts.functionCalls, + ); + externalStores.push({ + id: createSemanticId("external-store", "useSyncExternalStore", hookCall, context), + ownerId: identity.semanticUnit.id, + location: getNodeLocation(hookCall, context.rootDirectory), + subscribeCallbackIds: subscribeFacts.callbackIds, + subscribeComplete: subscribeFacts.isComplete, + snapshotCallbackIds: snapshotFacts.callbackIds, + snapshotComplete: snapshotFacts.isComplete, + serverSnapshotCallbackIds: serverSnapshotFacts.callbackIds, + serverSnapshotComplete: serverSnapshotFacts.isComplete, + serverSnapshotProvided: Boolean(serverSnapshotExpression), + }); + } + return { externalStores, callbacks, reachableFunctions, functionCalls }; +}; + +const collectEffectEventGraph = ( + identity: UnitGraphIdentity, + context: ReactAnalysisContext, +): EffectEventGraphFacts => { + const functionNode = identity.descriptor.functionNode; + if (!functionNode || identity.descriptor.kind === ReactUnitKind.InvalidHookOwner) { + return { effectEvents: [], callbacks: [], reachableFunctions: [], functionCalls: [] }; + } + const hookBindings = collectHookBindings(functionNode, context.typeChecker); + const nonReactiveSymbols = new Set([ + ...hookBindings.effectEvents, + ...hookBindings.refs, + ...hookBindings.stateSetters, + ]); + const effectEvents: ReactSemanticEffectEvent[] = []; + const callbacks: ReactSemanticCallback[] = []; + const reachableFunctions: ReactSemanticReachableFunction[] = []; + const functionCalls: ReactSemanticFunctionCall[] = []; + for (const binding of collectEffectEventBindings(functionNode, context.typeChecker)) { + const callback = binding.callback + ? createCallbackFact( + identity, + binding.callback, + functionNode, + nonReactiveSymbols, + ReactSemanticCallbackKind.EffectEvent, + ReactExecutionPhase.EffectEvent, + binding.name, + context, + ) + : null; + if (callback && binding.callback) { + callbacks.push(callback); + const reachabilityFacts = collectReachabilityGraphFacts( + identity, + binding.callback, + callback, + context, + ); + reachableFunctions.push(...reachabilityFacts.reachableFunctions); + functionCalls.push(...reachabilityFacts.functionCalls); + } + effectEvents.push({ + id: createSemanticId("effect-event", binding.name, binding.callExpression, context), + ownerId: identity.semanticUnit.id, + name: binding.name, + location: getNodeLocation(binding.callExpression, context.rootDirectory), + callbackId: callback?.id ?? null, + identityStability: ReactIdentityStability.Unstable, + }); + } + return { effectEvents, callbacks, reachableFunctions, functionCalls }; +}; + +const getImperativeHandleRefKind = ( + refKind: ImperativeHandleRefKind | null, +): ReactImperativeHandleRefKind | null => { + if (refKind === ImperativeHandleRefKind.ForwardedRef) { + return ReactImperativeHandleRefKind.ForwardedRef; + } + if (refKind === ImperativeHandleRefKind.RefProp) { + return ReactImperativeHandleRefKind.RefProp; + } + return null; +}; + +const getLocalRefDeclarations = ( + functionNode: ts.FunctionLikeDeclaration, + typeChecker: ts.TypeChecker, +): ReadonlyMap => { + const declarations = new Map(); + const visit = (node: ts.Node): void => { + if (node !== functionNode && isFunctionBoundary(node)) return; + if ( + ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + node.initializer && + ts.isCallExpression(node.initializer) && + getCanonicalReactApiName(node.initializer.expression, typeChecker) === "useRef" + ) { + const symbol = typeChecker.getSymbolAtLocation(node.name); + if (symbol) declarations.set(symbol, node); + } + node.forEachChild(visit); + }; + functionNode.forEachChild(visit); + return declarations; +}; + +const getJsxRefExpression = (attribute: ts.JsxAttribute): ts.Expression | null => + attribute.initializer && + ts.isJsxExpression(attribute.initializer) && + attribute.initializer.expression + ? unwrapTypescriptExpression(attribute.initializer.expression) + : null; + +const getImperativeMethodCall = ( + currentAccess: ts.PropertyAccessExpression, +): { + callExpression: ts.CallExpression; + methodName: string; +} | null => { + const methodAccess = currentAccess.parent; + if (!ts.isPropertyAccessExpression(methodAccess) || methodAccess.expression !== currentAccess) { + return null; + } + const callExpression = methodAccess.parent; + return ts.isCallExpression(callExpression) && callExpression.expression === methodAccess + ? { callExpression, methodName: methodAccess.name.text } + : null; +}; + +const isConstVariableDeclaration = (declaration: ts.VariableDeclaration): boolean => + ts.isVariableDeclarationList(declaration.parent) && + (declaration.parent.flags & ts.NodeFlags.Const) !== 0; + +const isHandleTargetReference = ( + node: ts.Node, + descriptor: ImperativeHandleDescriptor, + functionNode: ts.FunctionLikeDeclaration, + typeChecker: ts.TypeChecker, +): boolean => { + const refExpression = descriptor.refExpression; + if (!refExpression) return false; + if (descriptor.refKind === ImperativeHandleRefKind.RefProp) { + if (ts.isIdentifier(refExpression)) { + return ( + ts.isIdentifier(node) && + isIdentifierReference(node) && + typeChecker.getSymbolAtLocation(node) === typeChecker.getSymbolAtLocation(refExpression) + ); + } + return ( + (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) && + getComponentPropName(node, functionNode, typeChecker) === "ref" + ); + } + return ( + ts.isIdentifier(refExpression) && + ts.isIdentifier(node) && + isIdentifierReference(node) && + typeChecker.getSymbolAtLocation(node) === typeChecker.getSymbolAtLocation(refExpression) + ); +}; + +const isHandleTargetExclusive = ( + handleIdentity: ImperativeHandleIdentity, + siblingHandles: ReadonlyArray, + typeChecker: ts.TypeChecker, +): boolean => { + const functionNode = handleIdentity.identity.descriptor.functionNode; + if (!functionNode || !handleIdentity.descriptor.refExpression) return false; + const allowedCalls = new Set(); + for (const candidate of siblingHandles) { + if ( + candidate.descriptor.refName === handleIdentity.descriptor.refName && + candidate.descriptor.refKind === handleIdentity.descriptor.refKind + ) { + allowedCalls.add(candidate.descriptor.callExpression); + } + } + let isExclusive = allowedCalls.size === 1; + const visit = (node: ts.Node): void => { + if (!isExclusive) return; + if (isHandleTargetReference(node, handleIdentity.descriptor, functionNode, typeChecker)) { + let currentNode: ts.Node = node; + while ( + currentNode.parent && + (ts.isPropertyAccessExpression(currentNode.parent) || + ts.isElementAccessExpression(currentNode.parent) || + ts.isParenthesizedExpression(currentNode.parent) || + ts.isAsExpression(currentNode.parent) || + ts.isSatisfiesExpression(currentNode.parent) || + ts.isNonNullExpression(currentNode.parent)) + ) { + currentNode = currentNode.parent; + } + const callExpression = ts.isCallExpression(currentNode.parent) ? currentNode.parent : null; + if ( + !callExpression || + callExpression.arguments[0] !== currentNode || + !allowedCalls.has(callExpression) + ) { + isExclusive = false; + return; + } + } + node.forEachChild(visit); + }; + functionNode.forEachChild(visit); + return isExclusive; +}; + +const collectImperativeHandleGraph = ( + identities: ReadonlyArray, + identitiesByFunction: ReadonlyMap, + unitFunctionsBySymbol: ReadonlyMap, + renders: ReadonlyArray, + existingCallbacks: ReadonlyArray, + existingReachableFunctions: ReadonlyArray, + context: ReactAnalysisContext, +): ImperativeHandleGraphFacts => { + const handleIdentities: ImperativeHandleIdentity[] = identities.flatMap((identity) => { + const functionNode = identity.descriptor.functionNode; + if (!functionNode || identity.descriptor.kind !== ReactUnitKind.Component) return []; + return collectImperativeHandles(functionNode, context.typeChecker).map((descriptor) => { + const handleId = createSemanticId( + "imperative-handle", + descriptor.refName ?? "unknown", + descriptor.callExpression, + context, + ); + const methods = descriptor.methods.map((method) => ({ + descriptor: method, + methodId: createSemanticId( + `imperative-handle-method:${handleId}`, + method.name, + method.functionNode, + context, + ), + })); + return { + descriptor, + handleId, + identity, + methods, + methodsByName: new Map(methods.map((method) => [method.descriptor.name, method])), + }; + }); + }); + const existingCallbacksById = new Map( + existingCallbacks.map((callback) => [callback.id, callback]), + ); + const handlesByFunction = new Map< + ts.FunctionLikeDeclaration, + ReadonlyArray + >(); + for (const handleIdentity of handleIdentities) { + const functionNode = handleIdentity.identity.descriptor.functionNode; + if (!functionNode) continue; + handlesByFunction.set(functionNode, [ + ...(handlesByFunction.get(functionNode) ?? []), + handleIdentity, + ]); + } + const bindings: ImperativeHandleBindingDescriptor[] = []; + const unsupportedHandleIds = new Set(); + for (const identity of identities) { + const functionNode = identity.descriptor.functionNode; + if (!functionNode) continue; + const localRefDeclarations = getLocalRefDeclarations(functionNode, context.typeChecker); + const visit = (node: ts.Node): void => { + if (node !== functionNode && isFunctionBoundary(node)) return; + if (!ts.isJsxOpeningElement(node) && !ts.isJsxSelfClosingElement(node)) { + node.forEachChild(visit); + return; + } + const targetFunction = getJsxComponentTargetFunction( + node, + unitFunctionsBySymbol, + context.typeChecker, + ); + const targetHandles = targetFunction ? (handlesByFunction.get(targetFunction) ?? []) : []; + const refAttributes = node.attributes.properties.filter( + (attribute): attribute is ts.JsxAttribute => + ts.isJsxAttribute(attribute) && attribute.name.getText() === "ref", + ); + if (targetHandles.length === 0) { + node.forEachChild(visit); + return; + } + if (refAttributes.length !== 1) { + if (node.attributes.properties.some(ts.isJsxSpreadAttribute)) { + for (const targetHandle of targetHandles) unsupportedHandleIds.add(targetHandle.handleId); + } + node.forEachChild(visit); + return; + } + const refAttribute = refAttributes[0]; + const refExpression = getJsxRefExpression(refAttribute); + const refSymbol = + refExpression && ts.isIdentifier(refExpression) + ? context.typeChecker.getSymbolAtLocation(refExpression) + : null; + const refDeclaration = refSymbol ? localRefDeclarations.get(refSymbol) : null; + const targetIdentity = targetFunction ? identitiesByFunction.get(targetFunction) : null; + const tagLocation = getNodeLocation(node.tagName, context.rootDirectory); + const render = + targetIdentity && + renders.find( + (candidate) => + candidate.ownerId === identity.semanticUnit.id && + candidate.targetId === targetIdentity.semanticUnit.id && + areProofLocationsEqual(candidate.location, tagLocation), + ); + if ( + !refExpression || + !ts.isIdentifier(refExpression) || + !refSymbol || + !refDeclaration || + !targetIdentity || + targetHandles.length !== 1 + ) { + for (const targetHandle of targetHandles) unsupportedHandleIds.add(targetHandle.handleId); + node.forEachChild(visit); + return; + } + bindings.push({ + handleIdentity: targetHandles[0], + identity, + refAttribute, + refDeclaration, + refName: refExpression.text, + refSymbol, + render: render ?? null, + sourceComplete: Boolean( + render?.kind === ReactSemanticRenderKind.Direct && + isConstVariableDeclaration(refDeclaration), + ), + }); + node.forEachChild(visit); + }; + functionNode.forEachChild(visit); + } + const bindingsByRefSymbol = new Map(); + for (const binding of bindings) { + bindingsByRefSymbol.set(binding.refSymbol, [ + ...(bindingsByRefSymbol.get(binding.refSymbol) ?? []), + binding, + ]); + } + for (const refBindings of bindingsByRefSymbol.values()) { + if (refBindings.length === 1) continue; + for (const binding of refBindings) binding.sourceComplete = false; + } + for (const identity of identities) { + const functionNode = identity.descriptor.functionNode; + if (!functionNode) continue; + const visit = (node: ts.Node): void => { + if (ts.isIdentifier(node) && isIdentifierReference(node)) { + const refSymbol = context.typeChecker.getSymbolAtLocation(node); + const refBindings = refSymbol ? (bindingsByRefSymbol.get(refSymbol) ?? []) : []; + if (refBindings.length > 0) { + const isBindingUse = refBindings.some( + (binding) => getJsxRefExpression(binding.refAttribute) === node, + ); + const currentAccess = + ts.isPropertyAccessExpression(node.parent) && + node.parent.expression === node && + node.parent.name.text === "current" + ? node.parent + : null; + if (!isBindingUse && (!currentAccess || !getImperativeMethodCall(currentAccess))) { + for (const binding of refBindings) binding.sourceComplete = false; + } + } + } + node.forEachChild(visit); + }; + functionNode.forEachChild(visit); + } + const invocationDescriptors: ImperativeHandleInvocationDescriptor[] = []; + for (const identity of identities) { + const functionNode = identity.descriptor.functionNode; + if (!functionNode) continue; + const visit = (node: ts.Node): void => { + if ( + ts.isPropertyAccessExpression(node) && + node.name.text === "current" && + ts.isIdentifier(unwrapTypescriptExpression(node.expression)) + ) { + const refIdentifier = unwrapTypescriptExpression(node.expression); + if (!ts.isIdentifier(refIdentifier)) { + node.forEachChild(visit); + return; + } + const refSymbol = context.typeChecker.getSymbolAtLocation(refIdentifier); + const refBindings = refSymbol ? (bindingsByRefSymbol.get(refSymbol) ?? []) : []; + if (refBindings.length !== 1) { + node.forEachChild(visit); + return; + } + const binding = refBindings[0]; + const methodCall = getImperativeMethodCall(node); + if (!methodCall) { + binding.sourceComplete = false; + node.forEachChild(visit); + return; + } + const method = binding.handleIdentity.methodsByName.get(methodCall.methodName) ?? null; + const callerCallbackIds = collectExecutionCallbackIds({ + callbacks: existingCallbacks, + evidenceNode: methodCall.callExpression, + ownerId: identity.semanticUnit.id, + reachableFunctions: existingReachableFunctions, + rootDirectory: context.rootDirectory, + }); + const callerCallbacks = callerCallbackIds.flatMap((callbackId) => { + const callback = existingCallbacksById.get(callbackId); + return callback ? [callback] : []; + }); + const sourceComplete = Boolean( + method && + binding.sourceComplete && + callerCallbacks.length === callerCallbackIds.length && + callerCallbacks.length > 0 && + callerCallbacks.every((callback) => callback.phase !== ReactExecutionPhase.Render), + ); + if (!sourceComplete) binding.sourceComplete = false; + invocationDescriptors.push({ + binding, + callExpression: methodCall.callExpression, + method, + callerCallbackIds, + sourceComplete, + }); + } + node.forEachChild(visit); + }; + functionNode.forEachChild(visit); + } + const callbacks: ReactSemanticCallback[] = []; + const reachableFunctions: ReactSemanticReachableFunction[] = []; + const functionCalls: ReactSemanticFunctionCall[] = []; + const methodCallbacksByIdentity = new Map(); + const getMethodCallbacks = ( + invocation: ImperativeHandleInvocationDescriptor, + ): ReadonlyArray => { + const method = invocation.method; + if (!method) return []; + const ownerFunction = invocation.binding.handleIdentity.identity.descriptor.functionNode; + if (!ownerFunction) return []; + const hookBindings = collectHookBindings(ownerFunction, context.typeChecker); + const stableSymbols = new Set([...hookBindings.refs, ...hookBindings.stateSetters]); + const phases = [ + ...new Set( + invocation.callerCallbackIds.flatMap((callbackId) => { + const callback = existingCallbacksById.get(callbackId); + return callback ? [callback.phase] : []; + }), + ), + ]; + return phases.map((phase) => { + const callbackIdentity = `${method.methodId}:${phase}`; + const existingCallback = methodCallbacksByIdentity.get(callbackIdentity); + if (existingCallback) return existingCallback; + const callback = createCallbackFact( + invocation.binding.handleIdentity.identity, + method.descriptor.functionNode, + ownerFunction, + stableSymbols, + ReactSemanticCallbackKind.ImperativeHandleMethod, + phase, + `${method.descriptor.name}@${phase}`, + context, + ); + methodCallbacksByIdentity.set(callbackIdentity, callback); + callbacks.push(callback); + const reachabilityFacts = collectReachabilityGraphFacts( + invocation.binding.handleIdentity.identity, + method.descriptor.functionNode, + callback, + context, + ); + reachableFunctions.push(...reachabilityFacts.reachableFunctions); + functionCalls.push(...reachabilityFacts.functionCalls); + return callback; + }); + }; + const semanticInvocations: ReactSemanticImperativeHandleInvocation[] = + invocationDescriptors.flatMap((invocation) => { + if (!invocation.method) return []; + const bindingId = createSemanticId( + `imperative-handle-binding:${invocation.binding.handleIdentity.handleId}`, + invocation.binding.refName, + invocation.binding.refAttribute, + context, + ); + const methodCallbacks = invocation.sourceComplete ? getMethodCallbacks(invocation) : []; + return [ + { + id: createSemanticId( + `imperative-handle-invocation:${bindingId}`, + invocation.method.descriptor.name, + invocation.callExpression, + context, + ), + ownerId: invocation.binding.identity.semanticUnit.id, + handleId: invocation.binding.handleIdentity.handleId, + methodId: invocation.method.methodId, + bindingId, + location: getNodeLocation(invocation.callExpression, context.rootDirectory), + callerCallbackIds: invocation.callerCallbackIds, + methodCallbackIds: methodCallbacks.map((callback) => callback.id), + sourceComplete: invocation.sourceComplete, + complete: + invocation.sourceComplete && + methodCallbacks.length > 0 && + methodCallbacks.length === + new Set(methodCallbacks.map((callback) => callback.phase)).size, + }, + ]; + }); + const semanticBindings: ReactSemanticImperativeHandleBinding[] = bindings.map((binding) => { + const bindingId = createSemanticId( + `imperative-handle-binding:${binding.handleIdentity.handleId}`, + binding.refName, + binding.refAttribute, + context, + ); + const bindingInvocations = semanticInvocations.filter( + (invocation) => invocation.bindingId === bindingId, + ); + return { + id: bindingId, + ownerId: binding.identity.semanticUnit.id, + handleId: binding.handleIdentity.handleId, + renderId: binding.render?.id ?? "unknown", + refName: binding.refName, + refLocation: getNodeLocation(binding.refDeclaration, context.rootDirectory), + location: getNodeLocation(binding.refAttribute, context.rootDirectory), + invocationIds: bindingInvocations.map((invocation) => invocation.id), + referenceComplete: binding.sourceComplete, + sourceComplete: + binding.sourceComplete && + Boolean(binding.render) && + bindingInvocations.every((invocation) => invocation.sourceComplete), + complete: + binding.sourceComplete && + Boolean(binding.render) && + bindingInvocations.every((invocation) => invocation.complete), + }; + }); + const semanticMethods: ReactSemanticImperativeHandleMethod[] = handleIdentities.flatMap( + (handleIdentity) => + handleIdentity.methods.map((method) => ({ + id: method.methodId, + ownerId: handleIdentity.identity.semanticUnit.id, + handleId: handleIdentity.handleId, + name: method.descriptor.name, + location: getNodeLocation(method.descriptor.functionNode, context.rootDirectory), + })), + ); + const factoryCallbacks: ReactSemanticCallback[] = []; + const handles: ReactSemanticImperativeHandle[] = handleIdentities.map((handleIdentity) => { + const functionNode = handleIdentity.identity.descriptor.functionNode; + const descriptor = handleIdentity.descriptor; + const factoryFunction = descriptor.factoryFunction; + const hookBindings = functionNode + ? collectHookBindings(functionNode, context.typeChecker) + : null; + const stableSymbols = new Set([ + ...(hookBindings?.refs ?? []), + ...(hookBindings?.stateSetters ?? []), + ]); + const factoryCallback = + factoryFunction && functionNode + ? createCallbackFact( + handleIdentity.identity, + factoryFunction, + functionNode, + stableSymbols, + ReactSemanticCallbackKind.ImperativeHandleFactory, + ReactExecutionPhase.ImperativeHandle, + "createHandle", + context, + ) + : null; + if (factoryCallback && factoryFunction) { + factoryCallbacks.push(factoryCallback); + const reachabilityFacts = collectReachabilityGraphFacts( + handleIdentity.identity, + factoryFunction, + factoryCallback, + context, + ); + reachableFunctions.push(...reachabilityFacts.reachableFunctions); + functionCalls.push(...reachabilityFacts.functionCalls); + } + const dependencyFacts = getHookDependencyFacts(descriptor.callExpression, 2); + const captures = factoryCallback?.captures ?? []; + const missingDependencies = + dependencyFacts.mode === ReactEffectDependencyMode.Inline + ? captures.filter( + (capture) => !isReactiveCaptureDeclared(capture, dependencyFacts.dependencies), + ) + : []; + const factoryPurity = factoryFunction + ? analyzeRenderPurity(factoryFunction, context).status + : ReactObligationStatus.Unknown; + const handleBindings = semanticBindings.filter( + (binding) => binding.handleId === handleIdentity.handleId, + ); + const targetExclusive = isHandleTargetExclusive( + handleIdentity, + functionNode ? (handlesByFunction.get(functionNode) ?? []) : [], + context.typeChecker, + ); + let status = ReactImperativeHandleStatus.Resolved; + if (factoryPurity === ReactObligationStatus.Violated) { + status = ReactImperativeHandleStatus.ImpureFactory; + } else if (missingDependencies.length > 0) { + status = ReactImperativeHandleStatus.MissingDependency; + } else if ( + factoryPurity === ReactObligationStatus.Unknown || + dependencyFacts.mode === ReactEffectDependencyMode.Opaque || + !descriptor.shapeComplete || + !descriptor.targetComplete || + !targetExclusive || + unsupportedHandleIds.has(handleIdentity.handleId) + ) { + status = ReactImperativeHandleStatus.Opaque; + } + const sourceComplete = + Boolean(factoryCallback) && + descriptor.shapeComplete && + descriptor.targetComplete && + targetExclusive && + handleBindings.length > 0 && + handleBindings.every((binding) => binding.sourceComplete) && + !unsupportedHandleIds.has(handleIdentity.handleId) && + !handleIdentity.identity.semanticUnit.canBeRenderRoot && + dependencyFacts.mode !== ReactEffectDependencyMode.Opaque && + factoryPurity !== ReactObligationStatus.Unknown; + const bindingComplete = + handleBindings.length > 0 && + handleBindings.every((binding) => binding.sourceComplete) && + !unsupportedHandleIds.has(handleIdentity.handleId); + return { + id: handleIdentity.handleId, + ownerId: handleIdentity.identity.semanticUnit.id, + refKind: getImperativeHandleRefKind(descriptor.refKind), + refName: descriptor.refName, + location: getNodeLocation(descriptor.callExpression, context.rootDirectory), + factoryCallbackId: factoryCallback?.id ?? null, + dependencyMode: dependencyFacts.mode, + dependencies: dependencyFacts.dependencies, + captures, + factoryPurity, + methodIds: handleIdentity.methods.map((method) => method.methodId), + bindingIds: handleBindings.map((binding) => binding.id), + factoryComplete: + Boolean(factoryCallback) && + dependencyFacts.mode !== ReactEffectDependencyMode.Opaque && + factoryPurity !== ReactObligationStatus.Unknown, + shapeComplete: descriptor.shapeComplete, + targetComplete: descriptor.targetComplete && targetExclusive, + bindingComplete, + status, + sourceComplete, + complete: sourceComplete && status === ReactImperativeHandleStatus.Resolved, + }; + }); + callbacks.unshift(...factoryCallbacks); + return { + handles, + methods: semanticMethods, + bindings: semanticBindings, + invocations: semanticInvocations, + callbacks, + reachableFunctions, + functionCalls, + }; +}; + +const addContextSource = ( + sourcesByUnit: Map>>, + unitId: string, + contextId: string, + sourceId: string, +): boolean => { + let sourcesByContext = sourcesByUnit.get(unitId); + if (!sourcesByContext) { + sourcesByContext = new Map(); + sourcesByUnit.set(unitId, sourcesByContext); + } + let sources = sourcesByContext.get(contextId); + if (!sources) { + sources = new Set(); + sourcesByContext.set(contextId, sources); + } + const previousSize = sources.size; + sources.add(sourceId); + return sources.size !== previousSize; +}; + +const getNearestProvider = ( + providerIds: ReadonlyArray, + contextId: string, + providersById: ReadonlyMap, +): ReactSemanticContextProvider | null => + providerIds + .toReversed() + .map((providerId) => providersById.get(providerId)) + .find((provider) => provider?.contextId === contextId) ?? null; + +const resolveContextConsumers = ( + units: ReadonlyArray, + edges: ReadonlyArray, + renders: ReadonlyArray, + slotFlows: ReadonlyArray, + contexts: ReadonlyArray, + providers: ReadonlyArray, + consumers: ReadonlyArray, +): ReadonlyArray => { + const localUnitIds = new Set(units.map((unit) => unit.id)); + const customHookEdges = edges.filter( + (edge) => edge.kind === ReactSemanticEdgeKind.CallsHook && localUnitIds.has(edge.targetId), + ); + const rootUnitIds = units.flatMap((unit) => (unit.canBeRenderRoot ? [unit.id] : [])); + const providersById = new Map(providers.map((provider) => [provider.id, provider])); + const rendersById = new Map(renders.map((render) => [render.id, render])); + const sourcesByUnit = new Map>>(); + + for (const rootUnitId of rootUnitIds) { + for (const context of contexts) { + addContextSource(sourcesByUnit, rootUnitId, context.id, REACT_CONTEXT_DEFAULT_SOURCE_ID); + } + } + + let didSourcesChange = true; + while (didSourcesChange) { + didSourcesChange = false; + for (const render of renders) { + if (render.kind === ReactSemanticRenderKind.SlotInput) continue; + for (const context of contexts) { + const nearestProvider = getNearestProvider( + render.activeContextProviderIds, + context.id, + providersById, + ); + if (nearestProvider) { + didSourcesChange = + addContextSource(sourcesByUnit, render.targetId, context.id, nearestProvider.id) || + didSourcesChange; + continue; + } + const parentSources = sourcesByUnit.get(render.ownerId)?.get(context.id) ?? []; + for (const sourceId of parentSources) { + didSourcesChange = + addContextSource(sourcesByUnit, render.targetId, context.id, sourceId) || + didSourcesChange; + } + if (!render.contextTopologyComplete) { + didSourcesChange = + addContextSource( + sourcesByUnit, + render.targetId, + context.id, + REACT_CONTEXT_UNKNOWN_SOURCE_ID, + ) || didSourcesChange; + } + } + } + for (const slotFlow of slotFlows) { + if (slotFlow.complete) continue; + const sourceRender = rendersById.get(slotFlow.sourceRenderId); + if (!sourceRender) continue; + for (const context of contexts) { + didSourcesChange = + addContextSource( + sourcesByUnit, + sourceRender.targetId, + context.id, + REACT_CONTEXT_UNKNOWN_SOURCE_ID, + ) || didSourcesChange; + } + } + for (const hookEdge of customHookEdges) { + for (const context of contexts) { + const ownerSources = sourcesByUnit.get(hookEdge.sourceId)?.get(context.id) ?? []; + for (const sourceId of ownerSources) { + didSourcesChange = + addContextSource(sourcesByUnit, hookEdge.targetId, context.id, sourceId) || + didSourcesChange; + } + } + } + } + + return consumers.map((consumer) => { + if (!consumer.contextId) return consumer; + const sourceIds = [...(sourcesByUnit.get(consumer.ownerId)?.get(consumer.contextId) ?? [])]; + return { + ...consumer, + sourceProviderIds: sourceIds.filter( + (sourceId) => + sourceId !== REACT_CONTEXT_DEFAULT_SOURCE_ID && + sourceId !== REACT_CONTEXT_UNKNOWN_SOURCE_ID, + ), + usesDefaultValue: sourceIds.includes(REACT_CONTEXT_DEFAULT_SOURCE_ID), + topologyComplete: + sourceIds.length > 0 && !sourceIds.includes(REACT_CONTEXT_UNKNOWN_SOURCE_ID), + }; + }); +}; + +const addFormSource = ( + sourcesByUnit: Map>, + unitId: string, + sourceId: string, +): boolean => { + let sources = sourcesByUnit.get(unitId); + if (!sources) { + sources = new Set(); + sourcesByUnit.set(unitId, sources); + } + const previousSize = sources.size; + sources.add(sourceId); + return sources.size !== previousSize; +}; + +const resolveFormStatuses = ( + units: ReadonlyArray, + edges: ReadonlyArray, + renders: ReadonlyArray, + slotFlows: ReadonlyArray, + formStatuses: ReadonlyArray, +): ReadonlyArray => { + const localUnitIds = new Set(units.map((unit) => unit.id)); + const customHookEdges = edges.filter( + (edge) => edge.kind === ReactSemanticEdgeKind.CallsHook && localUnitIds.has(edge.targetId), + ); + const rootUnitIds = units.flatMap((unit) => (unit.canBeRenderRoot ? [unit.id] : [])); + const rendersById = new Map(renders.map((render) => [render.id, render])); + const sourcesByUnit = new Map>(); + for (const rootUnitId of rootUnitIds) { + addFormSource(sourcesByUnit, rootUnitId, REACT_FORM_OUTSIDE_SOURCE_ID); + } + + let didSourcesChange = true; + while (didSourcesChange) { + didSourcesChange = false; + for (const render of renders) { + if (render.kind === ReactSemanticRenderKind.SlotInput) continue; + const nearestFormId = render.activeFormIds.at(-1); + if (nearestFormId) { + didSourcesChange = + addFormSource(sourcesByUnit, render.targetId, nearestFormId) || didSourcesChange; + } else { + const ownerSources = sourcesByUnit.get(render.ownerId) ?? []; + for (const sourceId of ownerSources) { + if (!render.formTopologyComplete && sourceId === REACT_FORM_OUTSIDE_SOURCE_ID) { + continue; + } + didSourcesChange = + addFormSource(sourcesByUnit, render.targetId, sourceId) || didSourcesChange; + } + } + if (!render.formTopologyComplete) { + didSourcesChange = + addFormSource(sourcesByUnit, render.targetId, REACT_FORM_UNKNOWN_SOURCE_ID) || + didSourcesChange; + } + } + for (const slotFlow of slotFlows) { + if (slotFlow.complete) continue; + const sourceRender = rendersById.get(slotFlow.sourceRenderId); + if (!sourceRender) continue; + didSourcesChange = + addFormSource(sourcesByUnit, sourceRender.targetId, REACT_FORM_UNKNOWN_SOURCE_ID) || + didSourcesChange; + } + for (const hookEdge of customHookEdges) { + const ownerSources = sourcesByUnit.get(hookEdge.sourceId) ?? []; + for (const sourceId of ownerSources) { + didSourcesChange = + addFormSource(sourcesByUnit, hookEdge.targetId, sourceId) || didSourcesChange; + } + } + } + + return formStatuses.map((formStatus) => { + const sources = [...(sourcesByUnit.get(formStatus.ownerId) ?? [])]; + const sourceFormIds = sources.filter( + (sourceId) => + sourceId !== REACT_FORM_OUTSIDE_SOURCE_ID && sourceId !== REACT_FORM_UNKNOWN_SOURCE_ID, + ); + const outsideForm = sources.includes(REACT_FORM_OUTSIDE_SOURCE_ID); + const sourceComplete = sources.length > 0 && !sources.includes(REACT_FORM_UNKNOWN_SOURCE_ID); + let status = ReactFormStatusTopologyStatus.Unknown; + if (outsideForm) { + status = ReactFormStatusTopologyStatus.OutsideForm; + } else if (sourceComplete && sourceFormIds.length > 0) { + status = ReactFormStatusTopologyStatus.Resolved; + } + return { + ...formStatus, + sourceFormIds, + outsideForm, + status, + sourceComplete, + complete: status === ReactFormStatusTopologyStatus.Resolved, + }; + }); +}; + +const isValidLazyLoaderReturn = ( + expression: ts.Expression, + isAsyncLoader: boolean, + typeChecker: ts.TypeChecker, +): boolean => { + const returnType = typeChecker.getTypeAtLocation(expression); + if (!isAsyncLoader && !returnType.getProperty("then")) return false; + const resolvedType = typeChecker.getAwaitedType(returnType); + if (!resolvedType) return false; + const defaultSymbol = resolvedType.getProperty("default"); + if (!defaultSymbol) return false; + const defaultType = typeChecker.getTypeOfSymbolAtLocation(defaultSymbol, expression); + return ( + defaultType.getCallSignatures().length > 0 || defaultType.getConstructSignatures().length > 0 + ); +}; + +const getLazyLoaderStatus = ( + callExpression: ts.CallExpression, + context: ReactAnalysisContext, +): { sourceComplete: boolean; status: ReactLazyLoaderStatus } => { + const loaderExpression = callExpression.arguments[0]; + if (!loaderExpression || !ts.isExpression(loaderExpression)) { + return { sourceComplete: true, status: ReactLazyLoaderStatus.Invalid }; + } + const loaderFunction = resolveFunction(loaderExpression, context.typeChecker); + if (!loaderFunction) { + return { sourceComplete: false, status: ReactLazyLoaderStatus.Opaque }; + } + const returnSummary = summarizeFunctionReturns(loaderFunction, context.typeChecker); + if (!returnSummary.isComplete) { + return { sourceComplete: false, status: ReactLazyLoaderStatus.Opaque }; + } + const isAsyncLoader = Boolean( + ts.canHaveModifiers(loaderFunction) && + ts + .getModifiers(loaderFunction) + ?.some((modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword), + ); + const isValid = + loaderFunction.parameters.every( + (parameter) => + Boolean(parameter.dotDotDotToken || parameter.questionToken || parameter.initializer) || + (ts.isIdentifier(parameter.name) && parameter.name.text === "this"), + ) && + !returnSummary.canFallThrough && + (!returnSummary.canThrow || isAsyncLoader) && + returnSummary.expressions.length > 0 && + returnSummary.expressions.every((descriptor) => + isValidLazyLoaderReturn(descriptor.expression, isAsyncLoader, context.typeChecker), + ); + return { + sourceComplete: true, + status: isValid ? ReactLazyLoaderStatus.Valid : ReactLazyLoaderStatus.Invalid, + }; +}; + +const collectLazyComponentIdentities = ( + sourceFiles: ReadonlyArray, + identitiesByFunction: ReadonlyMap, + context: ReactAnalysisContext, +): ReadonlyArray => { + const lazyComponents: LazyComponentIdentity[] = []; + const visit = (node: ts.Node): void => { + if ( + ts.isCallExpression(node) && + getCanonicalReactApiName(node.expression, context.typeChecker) === "lazy" + ) { + const declarationExpression = + ts.isCallExpression(node.parent) && + node.parent.arguments[0] === node && + getCanonicalReactApiName(node.parent.expression, context.typeChecker) === "memo" + ? node.parent + : node; + let declaration: ts.Node = node; + let componentName = "anonymous lazy component"; + let componentSymbol: ts.Symbol | null = null; + if ( + ts.isVariableDeclaration(declarationExpression.parent) && + declarationExpression.parent.initializer === declarationExpression && + ts.isIdentifier(declarationExpression.parent.name) + ) { + declaration = declarationExpression.parent; + componentName = declarationExpression.parent.name.text; + const declarationSymbol = context.typeChecker.getSymbolAtLocation( + declarationExpression.parent.name, + ); + componentSymbol = declarationSymbol + ? resolveAliasedSymbol(declarationSymbol, context.typeChecker) + : null; + } else if ( + ts.isPropertyAssignment(declarationExpression.parent) && + declarationExpression.parent.initializer === declarationExpression + ) { + declaration = declarationExpression.parent; + componentName = getStaticPropertyName(declarationExpression.parent.name) ?? componentName; + const propertySymbol = context.typeChecker.getSymbolAtLocation( + declarationExpression.parent.name, + ); + componentSymbol = propertySymbol + ? resolveAliasedSymbol(propertySymbol, context.typeChecker) + : null; + } else if ( + ts.isExportAssignment(declarationExpression.parent) && + declarationExpression.parent.expression === declarationExpression && + !declarationExpression.parent.isExportEquals + ) { + declaration = declarationExpression.parent; + componentName = "default lazy component"; + const moduleSymbol = context.typeChecker.getSymbolAtLocation(node.getSourceFile()); + const defaultSymbol = moduleSymbol + ? context.typeChecker + .getExportsOfModule(moduleSymbol) + .find((exportSymbol) => exportSymbol.name === "default") + : null; + componentSymbol = defaultSymbol + ? resolveAliasedSymbol(defaultSymbol, context.typeChecker) + : null; + } + const containingFunction = getContainingFunction(declaration); + const ownerIdentity = containingFunction + ? identitiesByFunction.get(containingFunction) + : null; + const loader = getLazyLoaderStatus(node, context); + const identityResolved = Boolean(componentSymbol); + const resolvedComponentSymbol = componentSymbol + ? resolveAliasedSymbol(componentSymbol, context.typeChecker) + : null; + lazyComponents.push({ + declaration, + symbol: resolvedComponentSymbol, + component: { + id: createSemanticId("lazy-component", componentName, declaration, context), + name: componentName, + location: getNodeLocation(declaration, context.rootDirectory), + declarationOwnerId: ownerIdentity?.semanticUnit.id ?? null, + canBeRenderRoot: isDeclarationExported( + declaration, + resolvedComponentSymbol, + context.typeChecker, + ), + identityResolved, + declarationStatus: containingFunction + ? ReactLazyDeclarationStatus.RenderUnstable + : ReactLazyDeclarationStatus.ModuleStable, + loaderStatus: loader.status, + renderIds: [], + sourceComplete: identityResolved && loader.sourceComplete, + complete: false, + }, + }); + } + node.forEachChild(visit); + }; + for (const sourceFile of sourceFiles) sourceFile.forEachChild(visit); + return lazyComponents; +}; + +const addSuspenseSource = ( + sourcesByUnit: Map>, + unitId: string, + sourceId: string, +): boolean => { + let sources = sourcesByUnit.get(unitId); + if (!sources) { + sources = new Set(); + sourcesByUnit.set(unitId, sources); + } + const previousSize = sources.size; + sources.add(sourceId); + return sources.size !== previousSize; +}; + +const deriveSuspenseSourcesByUnit = ( + units: ReadonlyArray, + edges: ReadonlyArray, + renders: ReadonlyArray, + slotFlows: ReadonlyArray, + suspenseBoundaryIdsByRenderId: ReadonlyMap>, +): ReadonlyMap> => { + const sourcesByUnit = new Map>(); + const localUnitIds = new Set(units.map((unit) => unit.id)); + const customHookEdges = edges.filter( + (edge) => edge.kind === ReactSemanticEdgeKind.CallsHook && localUnitIds.has(edge.targetId), + ); + const rendersById = new Map(renders.map((render) => [render.id, render])); + const slotFlowsBySourceRenderId = new Map( + slotFlows.map((slotFlow) => [slotFlow.sourceRenderId, slotFlow]), + ); + for (const unit of units) { + if (unit.canBeRenderRoot) { + addSuspenseSource(sourcesByUnit, unit.id, REACT_SUSPENSE_OUTSIDE_SOURCE_ID); + } + } + let didSourcesChange = true; + while (didSourcesChange) { + didSourcesChange = false; + for (const render of renders) { + const boundaryIds = suspenseBoundaryIdsByRenderId.get(render.id) ?? []; + if (render.kind === ReactSemanticRenderKind.SlotInput && boundaryIds.length === 0) { + const slotFlow = slotFlowsBySourceRenderId.get(render.id); + if (!slotFlow?.complete || slotFlow.renderIds.length > 0) continue; + } + if (boundaryIds.length > 0) { + for (const boundaryId of boundaryIds) { + didSourcesChange = + addSuspenseSource(sourcesByUnit, render.targetId, boundaryId) || didSourcesChange; + } + } else { + for (const sourceId of sourcesByUnit.get(render.ownerId) ?? []) { + didSourcesChange = + addSuspenseSource(sourcesByUnit, render.targetId, sourceId) || didSourcesChange; + } + } + } + for (const slotFlow of slotFlows) { + if (slotFlow.complete) continue; + const sourceRender = rendersById.get(slotFlow.sourceRenderId); + if (sourceRender) { + didSourcesChange = + addSuspenseSource( + sourcesByUnit, + sourceRender.targetId, + REACT_SUSPENSE_UNKNOWN_SOURCE_ID, + ) || didSourcesChange; + } + } + for (const hookEdge of customHookEdges) { + for (const sourceId of sourcesByUnit.get(hookEdge.sourceId) ?? []) { + didSourcesChange = + addSuspenseSource(sourcesByUnit, hookEdge.targetId, sourceId) || didSourcesChange; + } + } + } + return sourcesByUnit; +}; + +const addErrorBoundarySource = ( + sourcesByUnit: Map>, + unitId: string, + sourceId: string, +): boolean => { + const sources = sourcesByUnit.get(unitId) ?? new Set(); + const previousSize = sources.size; + sources.add(sourceId); + sourcesByUnit.set(unitId, sources); + return sources.size !== previousSize; +}; + +const deriveErrorBoundarySourcesByUnit = ( + units: ReadonlyArray, + edges: ReadonlyArray, + renders: ReadonlyArray, + slotFlows: ReadonlyArray, + errorBoundaryIdsByRenderId: ReadonlyMap>, +): ReadonlyMap> => { + const sourcesByUnit = new Map>(); + const localUnitIds = new Set(units.map((unit) => unit.id)); + const customHookEdges = edges.filter( + (edge) => edge.kind === ReactSemanticEdgeKind.CallsHook && localUnitIds.has(edge.targetId), + ); + const rendersById = new Map(renders.map((render) => [render.id, render])); + for (const unit of units) { + if (unit.canBeRenderRoot) { + addErrorBoundarySource(sourcesByUnit, unit.id, REACT_ERROR_BOUNDARY_OUTSIDE_SOURCE_ID); + } + } + let didSourcesChange = true; + while (didSourcesChange) { + didSourcesChange = false; + for (const render of renders) { + const boundaryIds = errorBoundaryIdsByRenderId.get(render.id) ?? []; + if (render.kind === ReactSemanticRenderKind.SlotInput && boundaryIds.length === 0) { + continue; + } + if (boundaryIds.length > 0) { + for (const boundaryId of boundaryIds) { + didSourcesChange = + addErrorBoundarySource(sourcesByUnit, render.targetId, boundaryId) || didSourcesChange; + } + } else { + for (const sourceId of sourcesByUnit.get(render.ownerId) ?? []) { + didSourcesChange = + addErrorBoundarySource(sourcesByUnit, render.targetId, sourceId) || didSourcesChange; + } + } + } + for (const slotFlow of slotFlows) { + if (slotFlow.complete) continue; + const sourceRender = rendersById.get(slotFlow.sourceRenderId); + if (sourceRender && (errorBoundaryIdsByRenderId.get(sourceRender.id)?.length ?? 0) === 0) { + didSourcesChange = + addErrorBoundarySource( + sourcesByUnit, + sourceRender.targetId, + REACT_ERROR_BOUNDARY_UNKNOWN_SOURCE_ID, + ) || didSourcesChange; + } + } + for (const hookEdge of customHookEdges) { + for (const sourceId of sourcesByUnit.get(hookEdge.sourceId) ?? []) { + didSourcesChange = + addErrorBoundarySource(sourcesByUnit, hookEdge.targetId, sourceId) || didSourcesChange; + } + } + } + return sourcesByUnit; +}; + +const findFirstThrowStatement = ( + functionNode: ts.FunctionLikeDeclaration, +): ts.ThrowStatement | null => { + let throwStatement: ts.ThrowStatement | null = null; + const visit = (node: ts.Node): void => { + if (throwStatement || (node !== functionNode && isFunctionBoundary(node))) return; + if (ts.isThrowStatement(node)) { + throwStatement = node; + return; + } + node.forEachChild(visit); + }; + functionNode.forEachChild(visit); + return throwStatement; +}; + +const collectRenderErrorGraph = ( + identities: ReadonlyArray, + edges: ReadonlyArray, + renders: ReadonlyArray, + slotFlows: ReadonlyArray, + errorBoundaryIdsByRenderId: ReadonlyMap>, + errorBoundaries: ReadonlyArray, + errorBoundaryDefinitions: ReadonlyArray, + context: ReactAnalysisContext, +): RenderErrorGraphFacts => { + const sourcesByUnit = deriveErrorBoundarySourcesByUnit( + identities.map((identity) => identity.semanticUnit), + edges, + renders, + slotFlows, + errorBoundaryIdsByRenderId, + ); + const boundariesById = new Map(errorBoundaries.map((boundary) => [boundary.id, boundary])); + const definitionsById = new Map( + errorBoundaryDefinitions.map((definition) => [definition.id, definition]), + ); + const failures: ReactSemanticRenderFailure[] = []; + for (const identity of identities) { + const functionNode = identity.descriptor.functionNode; + if (!functionNode) continue; + const unitId = identity.semanticUnit.id; + const sources = sourcesByUnit.get(unitId) ?? new Set(); + if (sources.size === 0) continue; + const reachableFunctions = collectReachableFunctionGraph( + functionNode, + context.typeChecker, + ).functions.map((descriptor) => descriptor.functionNode); + for (const reachableFunction of new Set([functionNode, ...reachableFunctions])) { + const returnSummary = summarizeFunctionReturns(reachableFunction, context.typeChecker); + if (!returnSummary.canThrow) continue; + const throwStatement = findFirstThrowStatement(reachableFunction); + if (!throwStatement) continue; + const sourceBoundaryIds = [...sources].filter( + (sourceId) => + sourceId !== REACT_ERROR_BOUNDARY_OUTSIDE_SOURCE_ID && + sourceId !== REACT_ERROR_BOUNDARY_UNKNOWN_SOURCE_ID, + ); + const outsideBoundary = sources.has(REACT_ERROR_BOUNDARY_OUTSIDE_SOURCE_ID); + const hasUnknownSource = sources.has(REACT_ERROR_BOUNDARY_UNKNOWN_SOURCE_ID); + const sourceDefinitions = sourceBoundaryIds.flatMap((boundaryId) => { + const boundary = boundariesById.get(boundaryId); + const definition = boundary ? definitionsById.get(boundary.definitionId) : null; + return definition ? [definition] : []; + }); + const topologyComplete = + !hasUnknownSource && + sourceDefinitions.length === sourceBoundaryIds.length && + sourceDefinitions.every((definition) => definition.sourceComplete); + const hasValidBoundary = sourceDefinitions.some((definition) => definition.complete); + let coverageStatus = ReactErrorBoundaryCoverageStatus.Unknown; + if (outsideBoundary || (topologyComplete && !hasValidBoundary)) { + coverageStatus = ReactErrorBoundaryCoverageStatus.OutsideBoundary; + } else if (topologyComplete && hasValidBoundary) { + coverageStatus = ReactErrorBoundaryCoverageStatus.Covered; + } + failures.push({ + id: createSemanticId( + "render-failure", + `${unitId}:${ReactRenderFailureKind.ExplicitThrow}`, + throwStatement, + context, + ), + ownerId: unitId, + location: getNodeLocation(throwStatement, context.rootDirectory), + kind: ReactRenderFailureKind.ExplicitThrow, + sourceBoundaryIds, + outsideBoundary, + topologyComplete, + sourceComplete: topologyComplete, + coverageStatus, + complete: coverageStatus === ReactErrorBoundaryCoverageStatus.Covered, + }); + } + } + return { failures }; +}; + +const collectUseResourceGraph = ( + identities: ReadonlyArray, + edges: ReadonlyArray, + renders: ReadonlyArray, + slotFlows: ReadonlyArray, + suspenseBoundaryIdsByRenderId: ReadonlyMap>, + errorBoundaryIdsByRenderId: ReadonlyMap>, + errorBoundaries: ReadonlyArray, + errorBoundaryDefinitions: ReadonlyArray, + context: ReactAnalysisContext, +): UseResourceGraphFacts => { + const units = identities.map((identity) => identity.semanticUnit); + const suspenseSourcesByUnit = deriveSuspenseSourcesByUnit( + units, + edges, + renders, + slotFlows, + suspenseBoundaryIdsByRenderId, + ); + const errorSourcesByUnit = deriveErrorBoundarySourcesByUnit( + units, + edges, + renders, + slotFlows, + errorBoundaryIdsByRenderId, + ); + const boundariesById = new Map(errorBoundaries.map((boundary) => [boundary.id, boundary])); + const definitionsById = new Map( + errorBoundaryDefinitions.map((definition) => [definition.id, definition]), + ); + const resources: ReactSemanticUseResource[] = []; + for (const identity of identities) { + const functionNode = identity.descriptor.functionNode; + if (!functionNode) continue; + const ownerId = identity.semanticUnit.id; + for (const protocol of collectUseResourceProtocols(functionNode, context.typeChecker)) { + const suspenseSources = suspenseSourcesByUnit.get(ownerId) ?? new Set(); + const sourceSuspenseBoundaryIds = [...suspenseSources].filter( + (sourceId) => + sourceId !== REACT_SUSPENSE_OUTSIDE_SOURCE_ID && + sourceId !== REACT_SUSPENSE_UNKNOWN_SOURCE_ID, + ); + const outsideSuspenseBoundary = suspenseSources.has(REACT_SUSPENSE_OUTSIDE_SOURCE_ID); + const suspenseTopologyComplete = + suspenseSources.size > 0 && !suspenseSources.has(REACT_SUSPENSE_UNKNOWN_SOURCE_ID); + let suspenseCoverageStatus = ReactSuspenseCoverageStatus.Unknown; + if (outsideSuspenseBoundary) { + suspenseCoverageStatus = ReactSuspenseCoverageStatus.OutsideBoundary; + } else if (suspenseTopologyComplete && sourceSuspenseBoundaryIds.length > 0) { + suspenseCoverageStatus = ReactSuspenseCoverageStatus.Covered; + } + + const errorSources = errorSourcesByUnit.get(ownerId) ?? new Set(); + const sourceErrorBoundaryIds = [...errorSources].filter( + (sourceId) => + sourceId !== REACT_ERROR_BOUNDARY_OUTSIDE_SOURCE_ID && + sourceId !== REACT_ERROR_BOUNDARY_UNKNOWN_SOURCE_ID, + ); + const outsideErrorBoundary = errorSources.has(REACT_ERROR_BOUNDARY_OUTSIDE_SOURCE_ID); + const sourceDefinitions = sourceErrorBoundaryIds.flatMap((boundaryId) => { + const boundary = boundariesById.get(boundaryId); + const definition = boundary ? definitionsById.get(boundary.definitionId) : null; + return definition ? [definition] : []; + }); + const errorTopologyComplete = + errorSources.size > 0 && + !errorSources.has(REACT_ERROR_BOUNDARY_UNKNOWN_SOURCE_ID) && + sourceDefinitions.length === sourceErrorBoundaryIds.length && + sourceDefinitions.every((definition) => definition.sourceComplete); + const hasValidErrorBoundary = + sourceDefinitions.length > 0 && + sourceDefinitions.every((definition) => definition.complete); + let errorCoverageStatus = ReactErrorBoundaryCoverageStatus.Unknown; + if (outsideErrorBoundary || (errorTopologyComplete && !hasValidErrorBoundary)) { + errorCoverageStatus = ReactErrorBoundaryCoverageStatus.OutsideBoundary; + } else if (errorTopologyComplete && hasValidErrorBoundary) { + errorCoverageStatus = ReactErrorBoundaryCoverageStatus.Covered; + } + const sourceComplete = + protocol.kind !== ReactUseResourceKind.Unknown && + protocol.identityStatus !== ReactUseResourceIdentityStatus.Unknown && + suspenseTopologyComplete && + errorTopologyComplete; + resources.push({ + id: createSemanticId("use-resource", ownerId, protocol.callExpression, context), + ownerId, + location: getNodeLocation(protocol.callExpression, context.rootDirectory), + kind: protocol.kind, + identityStatus: protocol.identityStatus, + sourceSuspenseBoundaryIds, + outsideSuspenseBoundary, + suspenseTopologyComplete, + suspenseCoverageStatus, + sourceErrorBoundaryIds, + outsideErrorBoundary, + errorTopologyComplete, + errorCoverageStatus, + sourceComplete, + complete: + sourceComplete && + protocol.kind === ReactUseResourceKind.Thenable && + protocol.identityStatus === ReactUseResourceIdentityStatus.Stable && + suspenseCoverageStatus === ReactSuspenseCoverageStatus.Covered && + errorCoverageStatus === ReactErrorBoundaryCoverageStatus.Covered, + }); + } + } + return { resources }; +}; + +const resolveLazyComponentIdentity = ( + expression: ts.Expression | ts.JsxTagNameExpression, + componentsBySymbol: ReadonlyMap, + typeChecker: ts.TypeChecker, + visitedSymbols: Set = new Set(), +): LazyComponentIdentity | null => { + if (ts.isJsxNamespacedName(expression)) return null; + const unwrappedExpression = unwrapTypescriptExpression(expression); + if ( + ts.isCallExpression(unwrappedExpression) && + getCanonicalReactApiName(unwrappedExpression.expression, typeChecker) === "memo" + ) { + const memoTarget = unwrappedExpression.arguments[0]; + if (memoTarget && ts.isExpression(memoTarget)) { + return resolveLazyComponentIdentity( + memoTarget, + componentsBySymbol, + typeChecker, + visitedSymbols, + ); + } + } + const expressionSymbol = getExpressionSymbol(unwrappedExpression, typeChecker); + if (!expressionSymbol || visitedSymbols.has(expressionSymbol)) return null; + const directComponent = componentsBySymbol.get(expressionSymbol); + if (directComponent) return directComponent; + visitedSymbols.add(expressionSymbol); + for (const declaration of expressionSymbol.declarations ?? []) { + const initializer = + (ts.isVariableDeclaration(declaration) || ts.isPropertyAssignment(declaration)) && + declaration.initializer && + ts.isExpression(declaration.initializer) + ? unwrapTypescriptExpression(declaration.initializer) + : null; + if (initializer) { + if ( + ts.isCallExpression(initializer) && + getCanonicalReactApiName(initializer.expression, typeChecker) === "memo" + ) { + const memoTarget = initializer.arguments[0]; + if (memoTarget && ts.isExpression(memoTarget)) { + const memoComponent = resolveLazyComponentIdentity( + memoTarget, + componentsBySymbol, + typeChecker, + visitedSymbols, + ); + if (memoComponent) return memoComponent; + } + } + const aliasedComponent = resolveLazyComponentIdentity( + initializer, + componentsBySymbol, + typeChecker, + visitedSymbols, + ); + if (aliasedComponent) return aliasedComponent; + } + if (ts.isShorthandPropertyAssignment(declaration)) { + const shorthandSymbol = typeChecker.getShorthandAssignmentValueSymbol(declaration); + if (!shorthandSymbol) continue; + const resolvedShorthandSymbol = resolveAliasedSymbol(shorthandSymbol, typeChecker); + const shorthandComponent = componentsBySymbol.get(resolvedShorthandSymbol); + if (shorthandComponent) return shorthandComponent; + } + } + return null; +}; + +const collectReferencedLazyComponents = ( + expression: ts.Expression | ts.JsxTagNameExpression, + componentsBySymbol: ReadonlyMap, + typeChecker: ts.TypeChecker, +): ReadonlyArray => { + const componentsById = new Map(); + const visitedSymbols = new Set(); + const visitSymbol = (symbol: ts.Symbol): void => { + const resolvedSymbol = resolveAliasedSymbol(symbol, typeChecker); + const component = componentsBySymbol.get(resolvedSymbol); + if (component) { + componentsById.set(component.component.id, component); + return; + } + if (visitedSymbols.has(resolvedSymbol)) return; + visitedSymbols.add(resolvedSymbol); + for (const declaration of resolvedSymbol.declarations ?? []) { + if ( + (ts.isVariableDeclaration(declaration) || ts.isPropertyAssignment(declaration)) && + declaration.initializer + ) { + visitNode(declaration.initializer); + } else if (ts.isShorthandPropertyAssignment(declaration)) { + const shorthandSymbol = typeChecker.getShorthandAssignmentValueSymbol(declaration); + if (shorthandSymbol) visitSymbol(shorthandSymbol); + } else if (ts.isBindingElement(declaration)) { + const variableDeclaration = declaration.parent.parent; + if (ts.isVariableDeclaration(variableDeclaration) && variableDeclaration.initializer) { + visitNode(variableDeclaration.initializer); + } + } else if (isFunctionBoundary(declaration) && declaration.body) { + const visitReturn = (returnNode: ts.Node): void => { + if (returnNode !== declaration && isFunctionBoundary(returnNode)) return; + if (ts.isReturnStatement(returnNode) && returnNode.expression) { + visitNode(returnNode.expression); + return; + } + returnNode.forEachChild(visitReturn); + }; + declaration.body.forEachChild(visitReturn); + } + } + for (const sourceFile of new Set( + (resolvedSymbol.declarations ?? []).map((declaration) => declaration.getSourceFile()), + )) { + const writes = [ + ...collectSymbolWrites(resolvedSymbol, sourceFile, typeChecker), + ...collectPropertySymbolWrites(resolvedSymbol, sourceFile, typeChecker), + ]; + for (const write of writes) { + if (ts.isBinaryExpression(write)) visitNode(write.right); + } + } + }; + const visitNode = (node: ts.Node): void => { + if ( + isFunctionBoundary(node) || + ts.isJsxElement(node) || + ts.isJsxSelfClosingElement(node) || + ts.isJsxFragment(node) + ) { + return; + } + if ( + (ts.isExpression(node) || ts.isJsxTagNameExpression(node)) && + !ts.isJsxNamespacedName(node) + ) { + const symbol = getExpressionSymbol(node, typeChecker); + if (symbol) visitSymbol(symbol); + } + node.forEachChild(visitNode); + }; + visitNode(expression); + return [...componentsById.values()]; +}; + +const collectExportedLazyComponentIds = ( + sourceFiles: ReadonlyArray, + componentsBySymbol: ReadonlyMap, + typeChecker: ts.TypeChecker, +): ReadonlySet => { + const componentIds = new Set(); + const collectExposedComponentIds = (node: ts.Node): void => { + if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) { + return; + } + if (isFunctionBoundary(node)) { + if (ts.isArrowFunction(node) && !ts.isBlock(node.body)) { + collectExposedComponentIds(node.body); + return; + } + const visitReturn = (returnNode: ts.Node): void => { + if (returnNode !== node && isFunctionBoundary(returnNode)) return; + if (ts.isReturnStatement(returnNode) && returnNode.expression) { + collectExposedComponentIds(returnNode.expression); + return; + } + returnNode.forEachChild(visitReturn); + }; + node.forEachChild(visitReturn); + return; + } + if (ts.isExpression(node) && !ts.isJsxNamespacedName(node)) { + const component = resolveLazyComponentIdentity(node, componentsBySymbol, typeChecker); + if (component) componentIds.add(component.component.id); + } + node.forEachChild(collectExposedComponentIds); + }; + for (const sourceFile of sourceFiles) { + const moduleSymbol = typeChecker.getSymbolAtLocation(sourceFile); + if (!moduleSymbol) continue; + for (const exportSymbol of typeChecker.getExportsOfModule(moduleSymbol)) { + const resolvedExportSymbol = resolveAliasedSymbol(exportSymbol, typeChecker); + const directComponent = componentsBySymbol.get(resolvedExportSymbol); + if (directComponent) { + componentIds.add(directComponent.component.id); + continue; + } + for (const declaration of resolvedExportSymbol.declarations ?? []) { + let exportExpression: ts.Expression | null = null; + if (ts.isVariableDeclaration(declaration) && declaration.initializer) { + exportExpression = declaration.initializer; + } else if (ts.isExportAssignment(declaration) && ts.isExpression(declaration.expression)) { + exportExpression = declaration.expression; + } + if (!exportExpression) continue; + collectExposedComponentIds(exportExpression); + const component = resolveLazyComponentIdentity( + exportExpression, + componentsBySymbol, + typeChecker, + ); + if (component) componentIds.add(component.component.id); + } + } + } + return componentIds; +}; + +const deriveReachableFunctionSuspenseSources = ( + rootFunction: ts.FunctionLikeDeclaration, + reachabilityGraph: ReachableFunctionGraphDescriptor, + suspenseBoundariesByOpeningNode: ReadonlyMap< + ts.JsxOpeningLikeElement, + ReactSemanticSuspenseBoundary + >, + typeChecker: ts.TypeChecker, +): ReadonlyMap> => { + const sourcesByFunction = new Map>([ + [rootFunction, new Set([REACT_SUSPENSE_OWNER_SOURCE_ID])], + ]); + const addSource = (functionNode: ts.FunctionLikeDeclaration, sourceId: string): boolean => { + const sources = sourcesByFunction.get(functionNode) ?? new Set(); + const previousSize = sources.size; + sources.add(sourceId); + sourcesByFunction.set(functionNode, sources); + return sources.size !== previousSize; + }; + let didSourcesChange = true; + while (didSourcesChange) { + didSourcesChange = false; + for (const call of reachabilityGraph.calls) { + const directBoundaryIds = collectActiveSuspenseBoundaryIds( + call.callExpression, + suspenseBoundariesByOpeningNode, + ); + const sourceIds = + directBoundaryIds.length > 0 + ? directBoundaryIds + : [...(sourcesByFunction.get(call.sourceFunctionNode) ?? [])]; + for (const sourceId of sourceIds) { + didSourcesChange = addSource(call.targetFunctionNode, sourceId) || didSourcesChange; + } + } + } + for (const unmodeledUse of reachabilityGraph.unmodeledCallableUses) { + if (!ts.isExpression(unmodeledUse.node)) continue; + const targetFunction = resolveFunction(unmodeledUse.node, typeChecker); + if (targetFunction && sourcesByFunction.has(targetFunction)) { + addSource(targetFunction, REACT_SUSPENSE_UNKNOWN_SOURCE_ID); + } + } + for (const descriptor of reachabilityGraph.functions) { + if (!sourcesByFunction.has(descriptor.functionNode)) { + addSource(descriptor.functionNode, REACT_SUSPENSE_UNKNOWN_SOURCE_ID); + } + } + return sourcesByFunction; +}; + +const collectLazyGraph = ( + identities: ReadonlyArray, + edges: ReadonlyArray, + sourceFiles: ReadonlyArray, + identitiesByFunction: ReadonlyMap, + unitIdsBySymbol: ReadonlyMap, + renders: ReadonlyArray, + slotFlows: ReadonlyArray, + slotFlow: ComponentSlotFlowDescriptor, + providersByOpeningNode: ReadonlyMap, + suspenseBoundariesByOpeningNode: ReadonlyMap< + ts.JsxOpeningLikeElement, + ReactSemanticSuspenseBoundary + >, + suspenseBoundaryIdsByRenderId: ReadonlyMap>, + context: ReactAnalysisContext, +): LazyGraphFacts => { + const componentIdentities = collectLazyComponentIdentities( + sourceFiles, + identitiesByFunction, + context, + ); + const componentsBySymbol = new Map( + componentIdentities.flatMap( + (identity): ReadonlyArray<[ts.Symbol, LazyComponentIdentity]> => + identity.symbol ? [[identity.symbol, identity]] : [], + ), + ); + const identitiesByUnitId = new Map( + identities.map((identity) => [identity.semanticUnit.id, identity]), + ); + const exportedLazyComponentIds = collectExportedLazyComponentIds( + sourceFiles, + componentsBySymbol, + context.typeChecker, + ); + const rendersById = new Map(renders.map((render) => [render.id, render])); + const lazyRenders: ReactSemanticLazyRender[] = []; + for (const identity of identities) { + const functionNode = identity.descriptor.functionNode; + if (!functionNode) continue; + const reachabilityGraph = collectReachableFunctionGraph(functionNode, context.typeChecker); + const reachableRenderFunctions = reachabilityGraph.functions.map( + (descriptor) => descriptor.functionNode, + ); + const suspenseSourcesByRenderFunction = deriveReachableFunctionSuspenseSources( + functionNode, + reachabilityGraph, + suspenseBoundariesByOpeningNode, + context.typeChecker, + ); + const visit = (node: ts.Node, renderFunction: ts.FunctionLikeDeclaration): void => { + if (node !== functionNode && isFunctionBoundary(node)) return; + const isNestedRenderFunction = renderFunction !== functionNode; + const openingElement = + ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node) ? node : null; + if (!openingElement || ts.isJsxNamespacedName(openingElement.tagName)) { + node.forEachChild((childNode) => visit(childNode, renderFunction)); + return; + } + const resolvedLazyComponent = resolveLazyComponentIdentity( + openingElement.tagName, + componentsBySymbol, + context.typeChecker, + ); + const referencedLazyComponents = resolvedLazyComponent + ? [resolvedLazyComponent] + : collectReferencedLazyComponents( + openingElement.tagName, + componentsBySymbol, + context.typeChecker, + ); + if (referencedLazyComponents.length === 0) { + node.forEachChild((childNode) => visit(childNode, renderFunction)); + return; + } + let slotBoundary = getContainingRenderSlotBoundary( + openingElement.tagName, + unitIdsBySymbol, + providersByOpeningNode, + context, + ); + const suspenseFallbackElement = getContainingSuspenseFallbackElement( + openingElement.tagName, + suspenseBoundariesByOpeningNode, + ); + if ( + slotBoundary && + suspenseFallbackElement && + isNodeWithin(suspenseFallbackElement, slotBoundary.node) + ) { + slotBoundary = null; + } + const topologyBoundaryIds = new Set( + collectActiveSuspenseBoundaryIds( + openingElement.tagName, + suspenseBoundariesByOpeningNode, + slotBoundary?.node ?? null, + ), + ); + let inheritsOwnerBoundary = topologyBoundaryIds.size === 0; + let topologyComplete = Boolean(resolvedLazyComponent) && (slotBoundary?.complete ?? true); + if (isNestedRenderFunction && topologyBoundaryIds.size === 0) { + inheritsOwnerBoundary = false; + const functionSources = suspenseSourcesByRenderFunction.get(renderFunction); + for (const sourceId of functionSources ?? []) { + if (sourceId === REACT_SUSPENSE_OWNER_SOURCE_ID) { + inheritsOwnerBoundary = true; + } else if (sourceId === REACT_SUSPENSE_UNKNOWN_SOURCE_ID) { + topologyComplete = false; + } else { + topologyBoundaryIds.add(sourceId); + } + } + if (!functionSources || functionSources.size === 0) topologyComplete = false; + } + if (slotBoundary) { + inheritsOwnerBoundary = false; + const containerRender = slotBoundary.containerRenderId + ? rendersById.get(slotBoundary.containerRenderId) + : null; + const containerIdentity = containerRender + ? identitiesByUnitId.get(containerRender.targetId) + : null; + const containerFunction = containerIdentity?.descriptor.functionNode ?? null; + const resolution = + containerFunction && slotBoundary.propName + ? slotFlow.resolveSlot(containerFunction, slotBoundary.propName) + : { complete: false, placements: [] }; + topologyComplete = + topologyComplete && resolution.complete && resolution.placements.length > 0; + for (const placement of resolution.placements) { + const placementIdentities = placement.topologyFrames.map((topologyFrame) => + identitiesByFunction.get(topologyFrame.ownerFunction), + ); + if (placementIdentities.some((placementIdentity) => !placementIdentity)) { + topologyComplete = false; + continue; + } + const placementBoundaryIds = new Set([ + ...topologyBoundaryIds, + ...placement.topologyFrames.flatMap((topologyFrame) => + collectActiveSuspenseBoundaryIds(topologyFrame.node, suspenseBoundariesByOpeningNode), + ), + ]); + if (placementBoundaryIds.size === 0) inheritsOwnerBoundary = true; + for (const boundaryId of placementBoundaryIds) { + topologyBoundaryIds.add(boundaryId); + } + } + } + for (const lazyComponent of referencedLazyComponents) { + lazyRenders.push({ + id: createSemanticId( + "lazy-render", + `${identity.semanticUnit.id}:${lazyComponent.component.id}`, + openingElement.tagName, + context, + ), + ownerId: identity.semanticUnit.id, + lazyComponentId: lazyComponent.component.id, + location: getNodeLocation(openingElement.tagName, context.rootDirectory), + topologyBoundaryIds: [...topologyBoundaryIds], + sourceBoundaryIds: [], + inheritsOwnerBoundary, + outsideBoundary: false, + topologyComplete, + sourceComplete: false, + coverageStatus: ReactSuspenseCoverageStatus.Unknown, + complete: false, + }); + } + node.forEachChild((childNode) => visit(childNode, renderFunction)); + }; + functionNode.forEachChild((childNode) => visit(childNode, functionNode)); + for (const reachableRenderFunction of reachableRenderFunctions) { + if (reachableRenderFunction === functionNode) continue; + reachableRenderFunction.forEachChild((childNode) => + visit(childNode, reachableRenderFunction), + ); + } + } + const suspenseSourcesByUnit = deriveSuspenseSourcesByUnit( + identities.map((identity) => identity.semanticUnit), + edges, + renders, + slotFlows, + suspenseBoundaryIdsByRenderId, + ); + const resolvedRenders = lazyRenders.map((render): ReactSemanticLazyRender => { + const sources = new Set(render.topologyBoundaryIds); + if (render.inheritsOwnerBoundary) { + for (const sourceId of suspenseSourcesByUnit.get(render.ownerId) ?? []) { + sources.add(sourceId); + } + } + if (!render.topologyComplete) sources.add(REACT_SUSPENSE_UNKNOWN_SOURCE_ID); + const outsideBoundary = sources.has(REACT_SUSPENSE_OUTSIDE_SOURCE_ID); + const sourceComplete = sources.size > 0 && !sources.has(REACT_SUSPENSE_UNKNOWN_SOURCE_ID); + const sourceBoundaryIds = [...sources].filter( + (sourceId) => + sourceId !== REACT_SUSPENSE_OUTSIDE_SOURCE_ID && + sourceId !== REACT_SUSPENSE_UNKNOWN_SOURCE_ID, + ); + let coverageStatus = ReactSuspenseCoverageStatus.Unknown; + if (outsideBoundary) { + coverageStatus = ReactSuspenseCoverageStatus.OutsideBoundary; + } else if (sourceComplete && sourceBoundaryIds.length > 0) { + coverageStatus = ReactSuspenseCoverageStatus.Covered; + } + return { + ...render, + sourceBoundaryIds, + outsideBoundary, + sourceComplete, + coverageStatus, + complete: coverageStatus === ReactSuspenseCoverageStatus.Covered, + }; + }); + const rendersByComponentId = new Map(); + for (const render of resolvedRenders) { + const componentRenders = rendersByComponentId.get(render.lazyComponentId) ?? []; + componentRenders.push(render); + rendersByComponentId.set(render.lazyComponentId, componentRenders); + } + return { + components: componentIdentities.map(({ component }) => { + const componentRenders = rendersByComponentId.get(component.id) ?? []; + const canBeRenderRoot = + component.canBeRenderRoot || exportedLazyComponentIds.has(component.id); + const complete = + component.identityResolved && + !canBeRenderRoot && + component.declarationStatus === ReactLazyDeclarationStatus.ModuleStable && + component.loaderStatus === ReactLazyLoaderStatus.Valid && + componentRenders.every((render) => render.complete); + return { + ...component, + canBeRenderRoot, + renderIds: componentRenders.map((render) => render.id), + complete, + }; + }), + renders: resolvedRenders, + }; +}; + +export const buildReactSemanticGraph = ( + descriptors: ReadonlyArray, + sourceFiles: ReadonlyArray, + context: ReactAnalysisContext, +): ReactSemanticGraph => { + const identities = descriptors.map( + (descriptor): UnitGraphIdentity => ({ + descriptor, + semanticUnit: { + id: createSemanticId("unit", descriptor.name, descriptor.node, context), + name: descriptor.name, + kind: descriptor.kind, + classComponentBase: descriptor.classComponentBase ?? null, + canBeRenderRoot: + (descriptor.kind === ReactUnitKind.Component || + descriptor.kind === ReactUnitKind.ClassComponent) && + isDescriptorExported(descriptor, context.typeChecker), + location: getNodeLocation(descriptor.node, context.rootDirectory), + sourceComplete: descriptor.sourceComplete, + }, + }), + ); + const unitIdentitiesBySymbol = collectUnitIdentitiesBySymbol(identities, context); + const unitIdsBySymbol = new Map( + [...unitIdentitiesBySymbol].map(([symbol, identity]) => [symbol, identity.semanticUnit.id]), + ); + const unitIdentitiesByFunction = new Map( + identities.flatMap( + (identity): ReadonlyArray<[ts.FunctionLikeDeclaration, UnitGraphIdentity]> => + identity.descriptor.functionNode ? [[identity.descriptor.functionNode, identity]] : [], + ), + ); + const unitFunctionsBySymbol = new Map( + [...unitIdentitiesBySymbol].flatMap( + ([symbol, identity]): ReadonlyArray<[ts.Symbol, ts.FunctionLikeDeclaration]> => + identity.descriptor.functionNode ? [[symbol, identity.descriptor.functionNode]] : [], + ), + ); + const contextGraph = collectContextGraph(identities, sourceFiles, context); + const formTopologyGraph = collectFormTopologyGraph(identities, context); + const suspenseGraph = collectSuspenseGraph(identities, context); + const errorBoundaryGraph = collectErrorBoundaryGraph(identities, unitIdsBySymbol, context); + const edges: ReactSemanticEdge[] = []; + const renders: ReactSemanticRender[] = []; + const errorBoundaryIdsByRenderId = new Map>(); + const suspenseBoundaryIdsByRenderId = new Map>(); + const hookCalls: ReactSemanticHookCall[] = []; + const effects: ReactSemanticEffect[] = []; + const schedulers: ReactSemanticScheduler[] = []; + const resources: ReactSemanticEffectResource[] = []; + const classConstructions: ReactSemanticClassConstruction[] = []; + const classLifecycles: ReactSemanticClassLifecycle[] = []; + const classStateWrites: ReactSemanticClassStateWrite[] = []; + const classStateTransitions: ReactSemanticClassStateTransition[] = []; + const actionStates: ReactSemanticActionState[] = []; + const actionStateDispatches: ReactSemanticActionStateDispatch[] = []; + const formActions: ReactSemanticFormAction[] = []; + const hookStateTransitions: ReactSemanticHookStateTransition[] = []; + const reducers: ReactSemanticReducer[] = []; + const reducerDispatches: ReactSemanticReducerDispatch[] = []; + const optimisticStates: ReactSemanticOptimisticState[] = []; + const optimisticUpdates: ReactSemanticOptimisticUpdate[] = []; + const transitionActions: ReactSemanticTransitionAction[] = []; + const effectEvents: ReactSemanticEffectEvent[] = []; + const externalStores: ReactSemanticExternalStore[] = []; + const asyncTasks: ReactSemanticAsyncTask[] = []; + const callbacks: ReactSemanticCallback[] = []; + const reachableFunctions: ReactSemanticReachableFunction[] = []; + const functionCalls: ReactSemanticFunctionCall[] = []; + const componentFlow = createComponentCallbackFlow( + [...unitIdentitiesByFunction.keys()], + unitFunctionsBySymbol, + context.typeChecker, + ); + const slotFlow = createComponentSlotFlow( + [...unitIdentitiesByFunction.keys()], + unitFunctionsBySymbol, + new Set(contextGraph.providersByOpeningNode.keys()), + context.typeChecker, + ); + const eventGraph = collectEventGraph(identities, context, componentFlow); + callbacks.push(...eventGraph.callbacks); + reachableFunctions.push(...eventGraph.reachableFunctions); + functionCalls.push(...eventGraph.functionCalls); + const actionStateCallbacksByDispatcher = new Map(); + for (const identity of identities) { + const actionStateDefinitionGraph = collectActionStateDefinitionGraph(identity, context); + actionStates.push(...actionStateDefinitionGraph.states); + callbacks.push(...actionStateDefinitionGraph.callbacks); + reachableFunctions.push(...actionStateDefinitionGraph.reachableFunctions); + functionCalls.push(...actionStateDefinitionGraph.functionCalls); + for (const [dispatcherSymbol, callback] of actionStateDefinitionGraph.callbacksByDispatcher) { + actionStateCallbacksByDispatcher.set(dispatcherSymbol, callback); + } + } + const formActionGraph = collectFormActionGraph( + identities, + context, + componentFlow, + actionStateCallbacksByDispatcher, + ); + formActions.push(...formActionGraph.actions); + callbacks.push(...formActionGraph.callbacks); + reachableFunctions.push(...formActionGraph.reachableFunctions); + functionCalls.push(...formActionGraph.functionCalls); + for (const identity of identities) { + const functionNode = identity.descriptor.functionNode; + const unitKind = identity.descriptor.kind; + if ( + functionNode && + (unitKind === ReactUnitKind.Component || + unitKind === ReactUnitKind.ClassComponent || + unitKind === ReactUnitKind.Hook) + ) { + const renderCallback = createCallbackFact( + identity, + functionNode, + functionNode, + new Set(), + ReactSemanticCallbackKind.ComponentRender, + ReactExecutionPhase.Render, + "render", + context, + ); + callbacks.push(renderCallback); + const reachabilityFacts = collectReachabilityGraphFacts( + identity, + functionNode, + renderCallback, + context, + ); + reachableFunctions.push(...reachabilityFacts.reachableFunctions); + functionCalls.push(...reachabilityFacts.functionCalls); + } + const classLifecycleGraph = collectClassLifecycleGraph(identity, context); + if (classLifecycleGraph.construction) { + classConstructions.push(classLifecycleGraph.construction); + } + if (classLifecycleGraph.lifecycle) { + classLifecycles.push(classLifecycleGraph.lifecycle); + } + classStateWrites.push(...classLifecycleGraph.stateWrites); + classStateTransitions.push(...classLifecycleGraph.transitions); + schedulers.push(...classLifecycleGraph.schedulers); + resources.push(...classLifecycleGraph.resources); + callbacks.push(...classLifecycleGraph.callbacks); + reachableFunctions.push(...classLifecycleGraph.reachableFunctions); + functionCalls.push(...classLifecycleGraph.functionCalls); + const hookGraph = collectHookGraph(identity, unitIdsBySymbol, context); + edges.push(...hookGraph.edges); + hookCalls.push(...hookGraph.hookCalls); + const renderGraph = collectRenderEdges( + identity, + unitIdsBySymbol, + contextGraph.providersByOpeningNode, + formTopologyGraph.formsByOpeningNode, + errorBoundaryGraph.boundariesByOpeningNode, + suspenseGraph.boundariesByOpeningNode, + context, + ); + edges.push(...renderGraph.edges); + renders.push(...renderGraph.renders); + for (const [renderId, boundaryIds] of renderGraph.errorBoundaryIdsByRenderId) { + errorBoundaryIdsByRenderId.set(renderId, boundaryIds); + } + for (const [renderId, boundaryIds] of renderGraph.suspenseBoundaryIdsByRenderId) { + suspenseBoundaryIdsByRenderId.set(renderId, boundaryIds); + } + const effectGraph = collectEffectGraph( + identity, + unitIdentitiesByFunction, + context, + componentFlow, + ); + effects.push(...effectGraph.effects); + schedulers.push(...effectGraph.schedulers); + resources.push(...effectGraph.resources); + callbacks.push(...effectGraph.callbacks); + reachableFunctions.push(...effectGraph.reachableFunctions); + functionCalls.push(...effectGraph.functionCalls); + asyncTasks.push(...collectAsyncTaskGraph(identity, context)); + const memoGraph = collectMemoCallbacks(identity, context); + callbacks.push(...memoGraph.callbacks); + reachableFunctions.push(...memoGraph.reachableFunctions); + functionCalls.push(...memoGraph.functionCalls); + const reducerGraph = collectReducerDefinitionGraph(identity, context); + reducers.push(...reducerGraph.reducers); + callbacks.push(...reducerGraph.callbacks); + reachableFunctions.push(...reducerGraph.reachableFunctions); + functionCalls.push(...reducerGraph.functionCalls); + const effectEventGraph = collectEffectEventGraph(identity, context); + effectEvents.push(...effectEventGraph.effectEvents); + callbacks.push(...effectEventGraph.callbacks); + reachableFunctions.push(...effectEventGraph.reachableFunctions); + functionCalls.push(...effectEventGraph.functionCalls); + const externalStoreGraph = collectExternalStoreGraph( + identity, + unitIdentitiesByFunction, + context, + componentFlow, + ); + externalStores.push(...externalStoreGraph.externalStores); + callbacks.push(...externalStoreGraph.callbacks); + reachableFunctions.push(...externalStoreGraph.reachableFunctions); + functionCalls.push(...externalStoreGraph.functionCalls); + } + const imperativeHandleGraph = collectImperativeHandleGraph( + identities, + unitIdentitiesByFunction, + unitFunctionsBySymbol, + renders, + callbacks, + reachableFunctions, + context, + ); + callbacks.push(...imperativeHandleGraph.callbacks); + reachableFunctions.push(...imperativeHandleGraph.reachableFunctions); + functionCalls.push(...imperativeHandleGraph.functionCalls); + for (const identity of identities) { + const transitionActionGraph = collectTransitionActionGraph( + identity, + callbacks, + reachableFunctions, + context, + ); + transitionActions.push(...transitionActionGraph.actions); + callbacks.push(...transitionActionGraph.callbacks); + reachableFunctions.push(...transitionActionGraph.reachableFunctions); + functionCalls.push(...transitionActionGraph.functionCalls); + } + const callbackPropGraph = collectCallbackPropGraph(identities, context, componentFlow, callbacks); + callbacks.push(...callbackPropGraph.callbacks); + reachableFunctions.push(...callbackPropGraph.reachableFunctions); + functionCalls.push(...callbackPropGraph.functionCalls); + for (const identity of identities) { + const actionStateDispatchGraph = collectActionStateDispatchGraph( + identity, + actionStates, + formActions, + transitionActions, + callbacks, + reachableFunctions, + context, + ); + actionStateDispatches.push(...actionStateDispatchGraph.dispatches); + } + for (const identity of identities) { + const reducerDispatchGraph = collectReducerDispatchGraph( + identity, + reducers, + callbacks, + reachableFunctions, + context, + ); + reducerDispatches.push(...reducerDispatchGraph.dispatches); + } + for (const identity of identities) { + const hookStateTransitionGraph = collectHookStateTransitionGraph( + identity, + callbacks, + reachableFunctions, + context, + ); + hookStateTransitions.push(...hookStateTransitionGraph.transitions); + callbacks.push(...hookStateTransitionGraph.callbacks); + reachableFunctions.push(...hookStateTransitionGraph.reachableFunctions); + functionCalls.push(...hookStateTransitionGraph.functionCalls); + } + const hostControls = collectHostControlGraph( + identities, + eventGraph.eventBindings, + hookStateTransitions, + context, + ); + for (const identity of identities) { + const optimisticStateGraph = collectOptimisticStateGraph( + identity, + callbacks, + reachableFunctions, + transitionActions, + context, + ); + optimisticStates.push(...optimisticStateGraph.states); + optimisticUpdates.push(...optimisticStateGraph.updates); + callbacks.push(...optimisticStateGraph.callbacks); + reachableFunctions.push(...optimisticStateGraph.reachableFunctions); + functionCalls.push(...optimisticStateGraph.functionCalls); + } + const slotGraph = collectSlotGraph( + renders, + errorBoundaryIdsByRenderId, + suspenseBoundaryIdsByRenderId, + unitIdentitiesByFunction, + slotFlow, + contextGraph.providersByOpeningNode, + formTopologyGraph.formsByOpeningNode, + errorBoundaryGraph.boundariesByOpeningNode, + suspenseGraph.boundariesByOpeningNode, + context, + ); + const contextConsumers = resolveContextConsumers( + identities.map((identity) => identity.semanticUnit), + edges, + slotGraph.renders, + slotGraph.slotFlows, + contextGraph.contexts, + contextGraph.contextProviders, + contextGraph.contextConsumers, + ); + const formStatuses = resolveFormStatuses( + identities.map((identity) => identity.semanticUnit), + edges, + slotGraph.renders, + slotGraph.slotFlows, + formTopologyGraph.formStatuses, + ); + const lazyGraph = collectLazyGraph( + identities, + edges, + sourceFiles, + unitIdentitiesByFunction, + unitIdsBySymbol, + slotGraph.renders, + slotGraph.slotFlows, + slotFlow, + contextGraph.providersByOpeningNode, + suspenseGraph.boundariesByOpeningNode, + slotGraph.suspenseBoundaryIdsByRenderId, + context, + ); + const renderIdsByErrorBoundaryId = new Map(); + for (const render of slotGraph.renders) { + for (const boundaryId of slotGraph.errorBoundaryIdsByRenderId.get(render.id) ?? []) { + const renderIds = renderIdsByErrorBoundaryId.get(boundaryId) ?? []; + renderIds.push(render.id); + renderIdsByErrorBoundaryId.set(boundaryId, renderIds); + } + } + const errorBoundaries = errorBoundaryGraph.boundaries.map((boundary) => ({ + ...boundary, + renderIds: renderIdsByErrorBoundaryId.get(boundary.id) ?? [], + })); + const renderErrorGraph = collectRenderErrorGraph( + identities, + edges, + slotGraph.renders, + slotGraph.slotFlows, + slotGraph.errorBoundaryIdsByRenderId, + errorBoundaries, + errorBoundaryGraph.definitions, + context, + ); + const useResourceGraph = collectUseResourceGraph( + identities, + edges, + slotGraph.renders, + slotGraph.slotFlows, + slotGraph.suspenseBoundaryIdsByRenderId, + slotGraph.errorBoundaryIdsByRenderId, + errorBoundaries, + errorBoundaryGraph.definitions, + context, + ); + const renderIdsBySuspenseBoundaryId = new Map(); + for (const render of slotGraph.renders) { + for (const boundaryId of slotGraph.suspenseBoundaryIdsByRenderId.get(render.id) ?? []) { + const renderIds = renderIdsBySuspenseBoundaryId.get(boundaryId) ?? []; + renderIds.push(render.id); + renderIdsBySuspenseBoundaryId.set(boundaryId, renderIds); + } + } + const suspenseBoundaries = suspenseGraph.boundaries.map((boundary) => ({ + ...boundary, + renderIds: renderIdsBySuspenseBoundaryId.get(boundary.id) ?? [], + })); + const callableRefs = collectCallableRefGraph(identities, callbacks, functionCalls, context); + const memoComparators = collectMemoEquivalence( + identities.map((identity) => identity.descriptor), + identities.map((identity) => identity.semanticUnit), + sourceFiles, + context, + ); + const hydrationGraph = collectHydrationEquivalence( + identities.map((identity) => identity.descriptor), + identities.map((identity) => identity.semanticUnit), + sourceFiles, + unitIdsBySymbol, + edges, + slotGraph.renders, + slotGraph.slotFlows, + context, + ); + return { + schemaVersion: REACT_SEMANTIC_GRAPH_SCHEMA_VERSION, + actionStates, + actionStateDispatches, + units: identities.map((identity) => identity.semanticUnit), + edges, + hookCalls, + effects, + effectEvents, + externalStores, + asyncTasks, + contexts: contextGraph.contexts, + contextProviders: contextGraph.contextProviders, + contextConsumers, + errorBoundaryDefinitions: errorBoundaryGraph.definitions, + errorBoundaries, + renderFailures: renderErrorGraph.failures, + useResources: useResourceGraph.resources, + hydrationRoots: hydrationGraph.roots, + hydrationHazards: hydrationGraph.hazards, + hydrations: hydrationGraph.hydrations, + hostControls, + suspenseBoundaries, + lazyComponents: lazyGraph.components, + lazyRenders: lazyGraph.renders, + renders: slotGraph.renders, + slotFlows: slotGraph.slotFlows, + callbacks, + reachableFunctions, + functionCalls, + eventBindings: eventGraph.eventBindings, + callbackPropFlows: callbackPropGraph.callbackPropFlows, + callableRefs, + memoComparators, + imperativeHandles: imperativeHandleGraph.handles, + imperativeHandleMethods: imperativeHandleGraph.methods, + imperativeHandleBindings: imperativeHandleGraph.bindings, + imperativeHandleInvocations: imperativeHandleGraph.invocations, + schedulers, + resources, + classConstructions, + classLifecycles, + classStateWrites, + classStateTransitions, + formActions, + forms: formTopologyGraph.forms, + formStatuses, + hookStateTransitions, + reducers, + reducerDispatches, + optimisticStates, + optimisticUpdates, + transitionActions, + compiler: extractReactCompilerGraph(sourceFiles, context.rootDirectory), + }; +}; diff --git a/packages/prover/src/check-react-proof-report.ts b/packages/prover/src/check-react-proof-report.ts new file mode 100644 index 000000000..c0c817f27 --- /dev/null +++ b/packages/prover/src/check-react-proof-report.ts @@ -0,0 +1,4935 @@ +import { + REACT_CONTEXT_DEFAULT_SOURCE_ID, + REACT_CONTEXT_UNKNOWN_SOURCE_ID, + REACT_ERROR_BOUNDARY_OUTSIDE_SOURCE_ID, + REACT_ERROR_BOUNDARY_UNKNOWN_SOURCE_ID, + REACT_FORM_OUTSIDE_SOURCE_ID, + REACT_FORM_UNKNOWN_SOURCE_ID, + REACT_HYDRATABLE_SERVER_API_NAMES, + REACT_PROOF_SCHEMA_VERSION, + REACT_SEMANTIC_GRAPH_SCHEMA_VERSION, + REACT_STATIC_SERVER_API_NAMES, + REACT_SUSPENSE_OUTSIDE_SOURCE_ID, + REACT_SUSPENSE_UNKNOWN_SOURCE_ID, +} from "./constants.js"; +import { + ReactActionStateDispatchKind, + ReactActionStateDispatchStatus, + ReactActionStateReducerStatus, + ReactAppProofStatus, + ReactAsyncOwnershipStatus, + ReactCallableRefFreshness, + ReactClassComponentBase, + ReactClassConstructionIssueKind, + ReactClassConstructionIssueStatus, + ReactClassConstructionStatus, + ReactClassStateInitializationKind, + ReactClassStateInitializationRequirement, + ReactClassStateUpdaterStatus, + ReactClassStateWriteKind, + ReactClassStateWriteStatus, + ReactClassUpdateCycleStatus, + ReactEffectResourceDisposalStatus, + ReactEffectResourceKind, + ReactEffectDependencyMode, + ReactErrorBoundaryCoverageStatus, + ReactErrorBoundaryProtocolStatus, + ReactExecutionPhase, + ReactFormActionKind, + ReactFormActionStatus, + ReactFormStatusTopologyStatus, + ReactHostControlKind, + ReactHostControlMutabilityStatus, + ReactHostControlStatus, + ReactHostControlUpdateStatus, + ReactHostControlValueStatus, + ReactHookStateUpdaterStatus, + ReactHydrationHazardKind, + ReactHydrationPrefixStatus, + ReactHydrationRootKind, + ReactHydrationRootExecutionStatus, + ReactHydrationStatus, + ReactImperativeHandleRefKind, + ReactImperativeHandleStatus, + ReactLazyDeclarationStatus, + ReactLazyLoaderStatus, + ReactMemoComparatorKind, + ReactMemoComparatorStatus, + ReactObligationStatus, + ReactOptimisticActionStatus, + ReactOptimisticReducerStatus, + ReactProofCertificateStatus, + ReactProofClaim, + ReactReducerDispatchKind, + ReactReducerDispatchStatus, + ReactReducerPurityStatus, + ReactReducerReturnStatus, + ReactRenderFailureKind, + ReactSchedulerCancellationStatus, + ReactSemanticCallbackKind, + ReactSemanticEdgeKind, + ReactSemanticFunctionCallKind, + ReactSemanticRenderKind, + ReactSuspenseCoverageStatus, + ReactTransitionActionStatus, + ReactTransitionStarterKind, + ReactUnitKind, + ReactUseResourceIdentityStatus, + ReactUseResourceKind, +} from "./types.js"; +import { areProofLocationsEqual } from "./utils/are-proof-locations-equal.js"; +import type { + ReactAppProofReport, + ReactProofCertificateCheck, + ReactProofCertificateFailure, + ReactSemanticHostControl, + ReactSemanticUnit, +} from "./types.js"; + +const HOOK_STATE_UPDATER_STATUSES = new Set(Object.values(ReactHookStateUpdaterStatus)); +const ACTION_STATE_DISPATCH_STATUSES = new Set(Object.values(ReactActionStateDispatchStatus)); +const ACTION_STATE_DISPATCH_KINDS = new Set(Object.values(ReactActionStateDispatchKind)); +const ACTION_STATE_REDUCER_STATUSES = new Set(Object.values(ReactActionStateReducerStatus)); +const FORM_ACTION_KINDS = new Set(Object.values(ReactFormActionKind)); +const FORM_ACTION_STATUSES = new Set(Object.values(ReactFormActionStatus)); +const HOST_CONTROL_KINDS = new Set(Object.values(ReactHostControlKind)); +const HOST_CONTROL_MUTABILITY_STATUSES = new Set(Object.values(ReactHostControlMutabilityStatus)); +const HOST_CONTROL_STATUSES = new Set(Object.values(ReactHostControlStatus)); +const HOST_CONTROL_UPDATE_STATUSES = new Set(Object.values(ReactHostControlUpdateStatus)); +const HOST_CONTROL_VALUE_STATUSES = new Set(Object.values(ReactHostControlValueStatus)); +const HYDRATION_HAZARD_KINDS = new Set(Object.values(ReactHydrationHazardKind)); +const HYDRATION_PREFIX_STATUSES = new Set(Object.values(ReactHydrationPrefixStatus)); +const HYDRATION_ROOT_KINDS = new Set(Object.values(ReactHydrationRootKind)); +const HYDRATION_ROOT_EXECUTION_STATUSES = new Set(Object.values(ReactHydrationRootExecutionStatus)); +const HYDRATION_STATUSES = new Set(Object.values(ReactHydrationStatus)); +const MEMO_COMPARATOR_KINDS = new Set(Object.values(ReactMemoComparatorKind)); +const MEMO_COMPARATOR_STATUSES = new Set(Object.values(ReactMemoComparatorStatus)); +const OPTIMISTIC_ACTION_STATUSES = new Set(Object.values(ReactOptimisticActionStatus)); +const OPTIMISTIC_REDUCER_STATUSES = new Set(Object.values(ReactOptimisticReducerStatus)); +const TRANSITION_ACTION_STATUSES = new Set(Object.values(ReactTransitionActionStatus)); +const TRANSITION_STARTER_KINDS = new Set(Object.values(ReactTransitionStarterKind)); +const IMPERATIVE_HANDLE_REF_KINDS = new Set(Object.values(ReactImperativeHandleRefKind)); +const IMPERATIVE_HANDLE_STATUSES = new Set(Object.values(ReactImperativeHandleStatus)); +const ERROR_BOUNDARY_COVERAGE_STATUSES = new Set(Object.values(ReactErrorBoundaryCoverageStatus)); +const ERROR_BOUNDARY_PROTOCOL_STATUSES = new Set(Object.values(ReactErrorBoundaryProtocolStatus)); +const RENDER_FAILURE_KINDS = new Set(Object.values(ReactRenderFailureKind)); +const LAZY_DECLARATION_STATUSES = new Set(Object.values(ReactLazyDeclarationStatus)); +const LAZY_LOADER_STATUSES = new Set(Object.values(ReactLazyLoaderStatus)); +const REDUCER_DISPATCH_KINDS = new Set(Object.values(ReactReducerDispatchKind)); +const REDUCER_DISPATCH_STATUSES = new Set(Object.values(ReactReducerDispatchStatus)); +const REDUCER_PURITY_STATUSES = new Set(Object.values(ReactReducerPurityStatus)); +const REDUCER_RETURN_STATUSES = new Set(Object.values(ReactReducerReturnStatus)); +const SUSPENSE_COVERAGE_STATUSES = new Set(Object.values(ReactSuspenseCoverageStatus)); +const USE_RESOURCE_IDENTITY_STATUSES = new Set(Object.values(ReactUseResourceIdentityStatus)); +const USE_RESOURCE_KINDS = new Set(Object.values(ReactUseResourceKind)); +const OBLIGATION_STATUSES = new Set(Object.values(ReactObligationStatus)); +const TRANSITION_ACTION_ORIGIN_PHASES = new Set([ + ReactExecutionPhase.ActionStateReducer, + ReactExecutionPhase.ClassMount, + ReactExecutionPhase.ClassUpdate, + ReactExecutionPhase.Deferred, + ReactExecutionPhase.EffectCleanup, + ReactExecutionPhase.EffectEvent, + ReactExecutionPhase.EffectSetup, + ReactExecutionPhase.Event, + ReactExecutionPhase.ExternalStoreSubscription, + ReactExecutionPhase.FormAction, + ReactExecutionPhase.TransitionAction, +]); + +const addFailure = ( + failures: ReactProofCertificateFailure[], + subjectId: string, + description: string, +): void => { + failures.push({ description, subjectId }); +}; + +interface CheckedHydrationSourceSet { + rootIds: Set; + hasUnknownSource: boolean; +} + +interface ExpectedHydrationProtocol { + clientRootIds: ReadonlyArray; + interactiveServerRootIds: ReadonlyArray; + staticServerRootIds: ReadonlyArray; + hazardIds: ReadonlyArray; + status: ReactHydrationStatus; + sourceComplete: boolean; + complete: boolean; +} + +const addCheckedHydrationSource = ( + sourcesByUnit: Map, + unitId: string, + rootId: string, +): boolean => { + let sources = sourcesByUnit.get(unitId); + if (!sources) { + sources = { rootIds: new Set(), hasUnknownSource: false }; + sourcesByUnit.set(unitId, sources); + } + const previousSize = sources.rootIds.size; + sources.rootIds.add(rootId); + return sources.rootIds.size !== previousSize; +}; + +const addCheckedUnknownHydrationSource = ( + sourcesByUnit: Map, + unitId: string, +): boolean => { + let sources = sourcesByUnit.get(unitId); + if (!sources) { + sources = { rootIds: new Set(), hasUnknownSource: false }; + sourcesByUnit.set(unitId, sources); + } + if (sources.hasUnknownSource) return false; + sources.hasUnknownSource = true; + return true; +}; + +const deriveHydrationSourcesByUnit = ( + report: ReactAppProofReport, +): ReadonlyMap => { + const unitIds = new Set(report.graph.units.map((unit) => unit.id)); + const rendersById = new Map(report.graph.renders.map((render) => [render.id, render])); + const sourcesByUnit = new Map(); + for (const root of report.graph.hydrationRoots) { + if (root.targetId) addCheckedHydrationSource(sourcesByUnit, root.targetId, root.id); + } + let didSourcesChange = true; + while (didSourcesChange) { + didSourcesChange = false; + for (const render of report.graph.renders) { + if (render.kind === ReactSemanticRenderKind.SlotInput) continue; + const ownerSources = sourcesByUnit.get(render.ownerId); + if (!ownerSources) continue; + for (const rootId of ownerSources.rootIds) { + didSourcesChange = + addCheckedHydrationSource(sourcesByUnit, render.targetId, rootId) || didSourcesChange; + } + if (ownerSources.hasUnknownSource) { + didSourcesChange = + addCheckedUnknownHydrationSource(sourcesByUnit, render.targetId) || didSourcesChange; + } + } + for (const edge of report.graph.edges) { + if (edge.kind !== ReactSemanticEdgeKind.CallsHook || !unitIds.has(edge.targetId)) continue; + const ownerSources = sourcesByUnit.get(edge.sourceId); + if (!ownerSources) continue; + for (const rootId of ownerSources.rootIds) { + didSourcesChange = + addCheckedHydrationSource(sourcesByUnit, edge.targetId, rootId) || didSourcesChange; + } + if (ownerSources.hasUnknownSource) { + didSourcesChange = + addCheckedUnknownHydrationSource(sourcesByUnit, edge.targetId) || didSourcesChange; + } + } + for (const slotFlow of report.graph.slotFlows) { + if (slotFlow.complete) continue; + const sourceRender = rendersById.get(slotFlow.sourceRenderId); + if (!sourceRender || !sourcesByUnit.has(sourceRender.ownerId)) continue; + didSourcesChange = + addCheckedUnknownHydrationSource(sourcesByUnit, sourceRender.targetId) || didSourcesChange; + } + } + return sourcesByUnit; +}; + +const expectedHydrationProtocol = ( + unit: ReactSemanticUnit, + report: ReactAppProofReport, + hydrationSourcesByUnit: ReadonlyMap, +): ExpectedHydrationProtocol => { + const rootsById = new Map(report.graph.hydrationRoots.map((root) => [root.id, root])); + const sources = hydrationSourcesByUnit.get(unit.id); + const sourceRoots = [...(sources?.rootIds ?? [])] + .map((rootId) => rootsById.get(rootId)) + .filter((root): root is (typeof report.graph.hydrationRoots)[number] => Boolean(root)); + const clientRoots = sourceRoots.filter((root) => root.kind === ReactHydrationRootKind.Client); + const interactiveServerRoots = sourceRoots.filter( + (root) => root.kind === ReactHydrationRootKind.ServerInteractive, + ); + const staticServerRoots = sourceRoots.filter( + (root) => root.kind === ReactHydrationRootKind.ServerStatic, + ); + const hazardIds = report.graph.hydrationHazards.flatMap((hazard) => + hazard.ownerId === unit.id ? [hazard.id] : [], + ); + const hasIncompleteRoot = report.graph.hydrationRoots.some((root) => !root.sourceComplete); + const hasClientRoot = report.graph.hydrationRoots.some( + (root) => root.kind === ReactHydrationRootKind.Client, + ); + let status = ReactHydrationStatus.Unknown; + if (!hasClientRoot) { + status = ReactHydrationStatus.NotHydrated; + } else if (hasIncompleteRoot) { + status = ReactHydrationStatus.Unknown; + } else if (sourceRoots.length === 0) { + status = ReactHydrationStatus.NotHydrated; + } else if ( + !hasIncompleteRoot && + !sources?.hasUnknownSource && + clientRoots.length === 1 && + interactiveServerRoots.length === 1 && + staticServerRoots.length === 0 + ) { + status = + clientRoots[0]?.identifierPrefix === interactiveServerRoots[0]?.identifierPrefix && + hazardIds.length === 0 + ? ReactHydrationStatus.Equivalent + : ReactHydrationStatus.Mismatched; + } else if ( + !hasIncompleteRoot && + !sources?.hasUnknownSource && + clientRoots.length === 1 && + interactiveServerRoots.length === 0 && + staticServerRoots.length === 1 + ) { + status = ReactHydrationStatus.Mismatched; + } + const sourceComplete = + status === ReactHydrationStatus.NotHydrated || + (!hasIncompleteRoot && !sources?.hasUnknownSource && status !== ReactHydrationStatus.Unknown); + return { + clientRootIds: clientRoots.map((root) => root.id), + interactiveServerRootIds: interactiveServerRoots.map((root) => root.id), + staticServerRootIds: staticServerRoots.map((root) => root.id), + hazardIds, + status, + sourceComplete, + complete: + sourceComplete && + (status === ReactHydrationStatus.Equivalent || status === ReactHydrationStatus.NotHydrated), + }; +}; + +const checkUniqueIds = ( + failures: ReactProofCertificateFailure[], + collectionName: string, + ids: ReadonlyArray, +): void => { + const seenIds = new Set(); + for (const id of ids) { + if (seenIds.has(id)) { + addFailure(failures, id, `${collectionName} contains a duplicate semantic ID`); + } + seenIds.add(id); + } +}; + +const expectedAsyncOwnershipStatus = ( + unit: ReactSemanticUnit, + report: ReactAppProofReport, +): ReactObligationStatus => { + if (!unit.sourceComplete || unit.kind === ReactUnitKind.InvalidHookOwner) { + return ReactObligationStatus.Unknown; + } + const tasks = report.graph.asyncTasks.filter((task) => task.ownerId === unit.id); + if (tasks.some((task) => task.ownershipStatus === ReactAsyncOwnershipStatus.Unguarded)) { + return ReactObligationStatus.Violated; + } + if (tasks.some((task) => task.ownershipStatus === ReactAsyncOwnershipStatus.Unknown)) { + return ReactObligationStatus.Unknown; + } + return ReactObligationStatus.Proved; +}; + +const expectedCallableRefFreshnessStatus = ( + unit: ReactSemanticUnit, + report: ReactAppProofReport, +): ReactObligationStatus => { + if (!unit.sourceComplete || unit.kind === ReactUnitKind.InvalidHookOwner) { + return ReactObligationStatus.Unknown; + } + return report.graph.callableRefs + .filter((callableRef) => callableRef.ownerId === unit.id) + .some((callableRef) => !callableRef.complete) + ? ReactObligationStatus.Unknown + : ReactObligationStatus.Proved; +}; + +const expectedClassStateTransitionStatus = ( + unit: ReactSemanticUnit, + report: ReactAppProofReport, +): ReactObligationStatus => { + if (!unit.sourceComplete || unit.kind === ReactUnitKind.InvalidHookOwner) { + return ReactObligationStatus.Unknown; + } + if (unit.kind !== ReactUnitKind.ClassComponent) { + return ReactObligationStatus.Proved; + } + const transitions = report.graph.classStateTransitions.filter( + (transition) => transition.ownerId === unit.id, + ); + const stateWrites = report.graph.classStateWrites.filter( + (stateWrite) => stateWrite.ownerId === unit.id, + ); + if ( + stateWrites.some((stateWrite) => stateWrite.status === ReactClassStateWriteStatus.Forbidden) + ) { + return ReactObligationStatus.Violated; + } + if ( + transitions.some( + (transition) => + transition.updaterStatus === ReactClassStateUpdaterStatus.Impure || + transition.cycleStatus === ReactClassUpdateCycleStatus.Guaranteed, + ) + ) { + return ReactObligationStatus.Violated; + } + const lifecycle = report.graph.classLifecycles.find((candidate) => candidate.ownerId === unit.id); + return !lifecycle?.sourceComplete || + stateWrites.some((stateWrite) => !stateWrite.complete) || + transitions.some((transition) => !transition.complete) + ? ReactObligationStatus.Unknown + : ReactObligationStatus.Proved; +}; + +const expectedClassConstructionStatus = ( + unit: ReactSemanticUnit, + report: ReactAppProofReport, +): ReactObligationStatus => { + if (unit.kind === ReactUnitKind.InvalidHookOwner) { + return ReactObligationStatus.Unknown; + } + if (unit.kind !== ReactUnitKind.ClassComponent) { + return ReactObligationStatus.Proved; + } + const construction = report.graph.classConstructions.find( + (candidate) => candidate.ownerId === unit.id, + ); + if (construction?.status === ReactClassConstructionStatus.Invalid) { + return ReactObligationStatus.Violated; + } + return !construction || construction.status === ReactClassConstructionStatus.Unknown + ? ReactObligationStatus.Unknown + : ReactObligationStatus.Proved; +}; + +const expectedHookStateTransitionStatus = ( + unit: ReactSemanticUnit, + report: ReactAppProofReport, +): ReactObligationStatus => { + if (!unit.sourceComplete || unit.kind === ReactUnitKind.InvalidHookOwner) { + return ReactObligationStatus.Unknown; + } + if (unit.kind === ReactUnitKind.ClassComponent) { + return ReactObligationStatus.Proved; + } + const transitions = report.graph.hookStateTransitions.filter( + (transition) => transition.ownerId === unit.id, + ); + if ( + transitions.some( + (transition) => transition.updaterStatus === ReactHookStateUpdaterStatus.Impure, + ) + ) { + return ReactObligationStatus.Violated; + } + return transitions.some((transition) => !transition.complete) + ? ReactObligationStatus.Unknown + : ReactObligationStatus.Proved; +}; + +const expectedLazySuspenseStatus = ( + unit: ReactSemanticUnit, + report: ReactAppProofReport, +): ReactObligationStatus => { + if (!unit.sourceComplete || unit.kind === ReactUnitKind.InvalidHookOwner) { + return ReactObligationStatus.Unknown; + } + const lazyRenders = report.graph.lazyRenders.filter((render) => render.ownerId === unit.id); + const renderedComponentIds = new Set(lazyRenders.map((render) => render.lazyComponentId)); + const lazyComponents = report.graph.lazyComponents.filter( + (component) => + component.declarationOwnerId === unit.id || + renderedComponentIds.has(component.id) || + (!component.identityResolved && !component.declarationOwnerId && unit.canBeRenderRoot), + ); + if ( + lazyComponents.some( + (component) => + component.declarationStatus === ReactLazyDeclarationStatus.RenderUnstable || + component.loaderStatus === ReactLazyLoaderStatus.Invalid, + ) || + lazyRenders.some( + (render) => render.coverageStatus === ReactSuspenseCoverageStatus.OutsideBoundary, + ) + ) { + return ReactObligationStatus.Violated; + } + return lazyComponents.some( + (component) => + !component.identityResolved || component.loaderStatus === ReactLazyLoaderStatus.Opaque, + ) || lazyRenders.some((render) => render.coverageStatus === ReactSuspenseCoverageStatus.Unknown) + ? ReactObligationStatus.Unknown + : ReactObligationStatus.Proved; +}; + +const expectedErrorBoundaryStatus = ( + unit: ReactSemanticUnit, + report: ReactAppProofReport, +): ReactObligationStatus => { + if (!unit.sourceComplete || unit.kind === ReactUnitKind.InvalidHookOwner) { + return ReactObligationStatus.Unknown; + } + const definitions = report.graph.errorBoundaryDefinitions.filter( + (definition) => definition.ownerId === unit.id, + ); + const failures = report.graph.renderFailures.filter((failure) => failure.ownerId === unit.id); + if ( + definitions.some( + (definition) => + definition.derivedStateStatus === ReactErrorBoundaryProtocolStatus.Invalid || + definition.fallbackRenderStatus === ReactErrorBoundaryProtocolStatus.Invalid, + ) || + failures.some( + (failure) => failure.coverageStatus === ReactErrorBoundaryCoverageStatus.OutsideBoundary, + ) + ) { + return ReactObligationStatus.Violated; + } + return definitions.some((definition) => !definition.complete) || + failures.some((failure) => failure.coverageStatus === ReactErrorBoundaryCoverageStatus.Unknown) + ? ReactObligationStatus.Unknown + : ReactObligationStatus.Proved; +}; + +const expectedUseResourceStatus = ( + unit: ReactSemanticUnit, + report: ReactAppProofReport, +): ReactObligationStatus => { + if (!unit.sourceComplete || unit.kind === ReactUnitKind.InvalidHookOwner) { + return ReactObligationStatus.Unknown; + } + const resources = report.graph.useResources.filter((resource) => resource.ownerId === unit.id); + if ( + resources.some( + (resource) => + resource.kind === ReactUseResourceKind.Invalid || + resource.identityStatus === ReactUseResourceIdentityStatus.Unstable || + resource.suspenseCoverageStatus === ReactSuspenseCoverageStatus.OutsideBoundary || + resource.errorCoverageStatus === ReactErrorBoundaryCoverageStatus.OutsideBoundary, + ) + ) { + return ReactObligationStatus.Violated; + } + return resources.some((resource) => !resource.complete) + ? ReactObligationStatus.Unknown + : ReactObligationStatus.Proved; +}; + +const expectedHostControlProtocolStatus = ( + control: ReactSemanticHostControl, +): ReactHostControlStatus => { + if ( + control.kind === ReactHostControlKind.Unknown || + control.controlledPropPresent === null || + control.defaultPropPresent === null || + (control.controlledPropPresent && + (control.kind === ReactHostControlKind.FileInput || + control.kind === ReactHostControlKind.SelectMultiple)) + ) { + return ReactHostControlStatus.Unknown; + } + if ( + (control.controlledPropPresent && control.defaultPropPresent) || + control.valueStatus === ReactHostControlValueStatus.MaySwitch || + control.valueStatus === ReactHostControlValueStatus.Nullish + ) { + return ReactHostControlStatus.Invalid; + } + if ( + control.valueStatus === ReactHostControlValueStatus.Unknown || + control.updateStatus === ReactHostControlUpdateStatus.Opaque || + (control.controlledPropPresent && + control.mutabilityStatus === ReactHostControlMutabilityStatus.Unknown && + control.updateStatus !== ReactHostControlUpdateStatus.Exact) + ) { + return ReactHostControlStatus.Unknown; + } + if ( + control.updateStatus === ReactHostControlUpdateStatus.Conditional || + control.updateStatus === ReactHostControlUpdateStatus.Deferred || + control.updateStatus === ReactHostControlUpdateStatus.Missing || + control.updateStatus === ReactHostControlUpdateStatus.WrongValue + ) { + return ReactHostControlStatus.Invalid; + } + return ReactHostControlStatus.Resolved; +}; + +const expectedHostControlStatus = ( + unit: ReactSemanticUnit, + report: ReactAppProofReport, +): ReactObligationStatus => { + if (!unit.sourceComplete || unit.kind === ReactUnitKind.InvalidHookOwner) { + return ReactObligationStatus.Unknown; + } + const controls = report.graph.hostControls.filter((control) => control.ownerId === unit.id); + if (controls.some((control) => control.status === ReactHostControlStatus.Invalid)) { + return ReactObligationStatus.Violated; + } + return controls.some((control) => !control.complete) + ? ReactObligationStatus.Unknown + : ReactObligationStatus.Proved; +}; + +const expectedHydrationStatus = ( + unit: ReactSemanticUnit, + report: ReactAppProofReport, +): ReactObligationStatus => { + if (!unit.sourceComplete || unit.kind === ReactUnitKind.InvalidHookOwner) { + return ReactObligationStatus.Unknown; + } + const hydration = report.graph.hydrations.find((candidate) => candidate.ownerId === unit.id); + if (!hydration || hydration.status === ReactHydrationStatus.Unknown) { + return ReactObligationStatus.Unknown; + } + return hydration.status === ReactHydrationStatus.Mismatched + ? ReactObligationStatus.Violated + : ReactObligationStatus.Proved; +}; + +const isMemoObservationCovered = ( + observationPath: string, + equalPropPaths: ReadonlyArray, +): boolean => + equalPropPaths.some( + (equalPropPath) => + equalPropPath.length === 0 || + equalPropPath === observationPath || + (observationPath !== "*" && observationPath.startsWith(`${equalPropPath}.`)), + ); + +const expectedMemoComparatorStatus = ( + comparator: ReactAppProofReport["graph"]["memoComparators"][number], +): ReactMemoComparatorStatus => { + if (!comparator.ownerId) return ReactMemoComparatorStatus.Unknown; + if (comparator.kind === ReactMemoComparatorKind.DefaultShallow) { + return ReactMemoComparatorStatus.Equivalent; + } + for (const truePath of comparator.truePaths) { + if (!truePath.sourceComplete) continue; + if ( + comparator.observations.some( + (observation) => + observation.valueCanVary && + !isMemoObservationCovered(observation.path, truePath.equalPropPaths), + ) + ) { + return ReactMemoComparatorStatus.OmittedObservedProp; + } + } + const hasUniversalTruePaths = + comparator.truePaths.length > 0 && + comparator.truePaths.every( + (truePath) => truePath.sourceComplete && truePath.equalPropPaths.includes(""), + ); + return comparator.analysisComplete && + comparator.truePaths.every((truePath) => truePath.sourceComplete) && + (comparator.observationComplete || hasUniversalTruePaths) + ? ReactMemoComparatorStatus.Equivalent + : ReactMemoComparatorStatus.Unknown; +}; + +const expectedMemoEquivalenceStatus = ( + unit: ReactSemanticUnit, + report: ReactAppProofReport, +): ReactObligationStatus => { + if (!unit.sourceComplete || unit.kind === ReactUnitKind.InvalidHookOwner) { + return ReactObligationStatus.Unknown; + } + const comparators = report.graph.memoComparators.filter( + (comparator) => comparator.ownerId === unit.id, + ); + if ( + comparators.some( + (comparator) => comparator.status === ReactMemoComparatorStatus.OmittedObservedProp, + ) + ) { + return ReactObligationStatus.Violated; + } + return comparators.some((comparator) => !comparator.complete) + ? ReactObligationStatus.Unknown + : ReactObligationStatus.Proved; +}; + +const expectedReducerPurityStatus = ( + unit: ReactSemanticUnit, + report: ReactAppProofReport, +): ReactObligationStatus => { + if (!unit.sourceComplete || unit.kind === ReactUnitKind.InvalidHookOwner) { + return ReactObligationStatus.Unknown; + } + if (unit.kind === ReactUnitKind.ClassComponent) { + return ReactObligationStatus.Proved; + } + const reducers = report.graph.reducers.filter((reducer) => reducer.ownerId === unit.id); + if ( + reducers.some( + (reducer) => + reducer.reducerPurity === ReactReducerPurityStatus.Impure || + reducer.initializerPurity === ReactReducerPurityStatus.Impure, + ) + ) { + return ReactObligationStatus.Violated; + } + return reducers.some( + (reducer) => + reducer.reducerPurity === ReactReducerPurityStatus.Opaque || + reducer.initializerPurity === ReactReducerPurityStatus.Opaque, + ) + ? ReactObligationStatus.Unknown + : ReactObligationStatus.Proved; +}; + +const expectedReducerTransitionStatus = ( + unit: ReactSemanticUnit, + report: ReactAppProofReport, +): ReactObligationStatus => { + if (!unit.sourceComplete || unit.kind === ReactUnitKind.InvalidHookOwner) { + return ReactObligationStatus.Unknown; + } + if (unit.kind === ReactUnitKind.ClassComponent) { + return ReactObligationStatus.Proved; + } + const reducers = report.graph.reducers.filter((reducer) => reducer.ownerId === unit.id); + const dispatches = report.graph.reducerDispatches.filter( + (dispatch) => dispatch.ownerId === unit.id, + ); + if ( + reducers.some( + (reducer) => + reducer.reducerPurity === ReactReducerPurityStatus.Impure || + reducer.initializerPurity === ReactReducerPurityStatus.Impure || + reducer.reducerReturnStatus === ReactReducerReturnStatus.MayFallThrough || + reducer.reducerReturnStatus === ReactReducerReturnStatus.MayThrow || + reducer.initializerReturnStatus === ReactReducerReturnStatus.MayFallThrough || + reducer.initializerReturnStatus === ReactReducerReturnStatus.MayThrow, + ) || + dispatches.some( + (dispatch) => + dispatch.status === ReactReducerDispatchStatus.Render || + dispatch.status === ReactReducerDispatchStatus.Reducer, + ) + ) { + return ReactObligationStatus.Violated; + } + return reducers.some((reducer) => !reducer.complete) || + dispatches.some((dispatch) => !dispatch.complete) + ? ReactObligationStatus.Unknown + : ReactObligationStatus.Proved; +}; + +const expectedImperativeHandleStatus = ( + unit: ReactSemanticUnit, + report: ReactAppProofReport, +): ReactObligationStatus => { + if (!unit.sourceComplete || unit.kind === ReactUnitKind.InvalidHookOwner) { + return ReactObligationStatus.Unknown; + } + const handles = report.graph.imperativeHandles.filter((handle) => handle.ownerId === unit.id); + if ( + handles.some( + (handle) => + handle.status === ReactImperativeHandleStatus.ImpureFactory || + handle.status === ReactImperativeHandleStatus.MissingDependency, + ) + ) { + return ReactObligationStatus.Violated; + } + return handles.some((handle) => !handle.complete) + ? ReactObligationStatus.Unknown + : ReactObligationStatus.Proved; +}; + +const expectedTransitionActionStatus = ( + unit: ReactSemanticUnit, + report: ReactAppProofReport, +): ReactObligationStatus => { + if (!unit.sourceComplete || unit.kind === ReactUnitKind.InvalidHookOwner) { + return ReactObligationStatus.Unknown; + } + const actions = report.graph.transitionActions.filter((action) => action.ownerId === unit.id); + if (actions.some((action) => action.status === ReactTransitionActionStatus.ControlledInput)) { + return ReactObligationStatus.Violated; + } + return actions.some((action) => !action.complete) + ? ReactObligationStatus.Unknown + : ReactObligationStatus.Proved; +}; + +const expectedActionStateStatus = ( + unit: ReactSemanticUnit, + report: ReactAppProofReport, +): ReactObligationStatus => { + if (!unit.sourceComplete || unit.kind === ReactUnitKind.InvalidHookOwner) { + return ReactObligationStatus.Unknown; + } + const states = report.graph.actionStates.filter((state) => state.ownerId === unit.id); + const dispatches = report.graph.actionStateDispatches.filter( + (dispatch) => dispatch.ownerId === unit.id, + ); + if ( + dispatches.some( + (dispatch) => + dispatch.status === ReactActionStateDispatchStatus.OutsideAction || + dispatch.status === ReactActionStateDispatchStatus.Render, + ) + ) { + return ReactObligationStatus.Violated; + } + return states.some((state) => !state.complete) || + dispatches.some((dispatch) => !dispatch.complete) + ? ReactObligationStatus.Unknown + : ReactObligationStatus.Proved; +}; + +const expectedFormActionStatus = ( + unit: ReactSemanticUnit, + report: ReactAppProofReport, +): ReactObligationStatus => { + if (!unit.sourceComplete || unit.kind === ReactUnitKind.InvalidHookOwner) { + return ReactObligationStatus.Unknown; + } + const actions = report.graph.formActions.filter((action) => action.ownerId === unit.id); + if (actions.some((action) => action.status === ReactFormActionStatus.UnsupportedControl)) { + return ReactObligationStatus.Violated; + } + return actions.some((action) => !action.complete) + ? ReactObligationStatus.Unknown + : ReactObligationStatus.Proved; +}; + +const expectedFormStatusStatus = ( + unit: ReactSemanticUnit, + report: ReactAppProofReport, +): ReactObligationStatus => { + if (!unit.sourceComplete || unit.kind === ReactUnitKind.InvalidHookOwner) { + return ReactObligationStatus.Unknown; + } + const formStatuses = report.graph.formStatuses.filter( + (formStatus) => formStatus.ownerId === unit.id, + ); + if ( + formStatuses.some( + (formStatus) => formStatus.status === ReactFormStatusTopologyStatus.OutsideForm, + ) + ) { + return ReactObligationStatus.Violated; + } + return formStatuses.some((formStatus) => !formStatus.complete) + ? ReactObligationStatus.Unknown + : ReactObligationStatus.Proved; +}; + +const expectedOptimisticStateStatus = ( + unit: ReactSemanticUnit, + report: ReactAppProofReport, +): ReactObligationStatus => { + if (!unit.sourceComplete || unit.kind === ReactUnitKind.InvalidHookOwner) { + return ReactObligationStatus.Unknown; + } + const states = report.graph.optimisticStates.filter((state) => state.ownerId === unit.id); + const updates = report.graph.optimisticUpdates.filter((update) => update.ownerId === unit.id); + if ( + states.some((state) => state.reducerStatus === ReactOptimisticReducerStatus.Impure) || + updates.some( + (update) => + update.actionStatus === ReactOptimisticActionStatus.OutsideAction || + update.actionStatus === ReactOptimisticActionStatus.Render || + update.updaterStatus === ReactHookStateUpdaterStatus.Impure, + ) + ) { + return ReactObligationStatus.Violated; + } + return states.some((state) => !state.complete) || updates.some((update) => !update.complete) + ? ReactObligationStatus.Unknown + : ReactObligationStatus.Proved; +}; + +const expectedReactNodeFlowStatus = ( + unit: ReactSemanticUnit, + report: ReactAppProofReport, +): ReactObligationStatus => { + if (!unit.sourceComplete || unit.kind === ReactUnitKind.InvalidHookOwner) { + return ReactObligationStatus.Unknown; + } + return report.graph.slotFlows + .filter((slotFlow) => slotFlow.ownerId === unit.id) + .some((slotFlow) => !slotFlow.complete) + ? ReactObligationStatus.Unknown + : ReactObligationStatus.Proved; +}; + +const expectedScheduledCallbackLifetimeStatus = ( + unit: ReactSemanticUnit, + report: ReactAppProofReport, +): ReactObligationStatus => { + if (!unit.sourceComplete || unit.kind === ReactUnitKind.InvalidHookOwner) { + return ReactObligationStatus.Unknown; + } + const schedulers = report.graph.schedulers.filter((scheduler) => scheduler.ownerId === unit.id); + if ( + schedulers.some( + (scheduler) => scheduler.cancellationStatus === ReactSchedulerCancellationStatus.Missing, + ) + ) { + return ReactObligationStatus.Violated; + } + if ( + unit.kind === ReactUnitKind.ClassComponent && + !report.graph.classLifecycles.find((lifecycle) => lifecycle.ownerId === unit.id)?.sourceComplete + ) { + return ReactObligationStatus.Unknown; + } + return schedulers.some((scheduler) => !scheduler.complete) + ? ReactObligationStatus.Unknown + : ReactObligationStatus.Proved; +}; + +const expectedEffectCleanupStatus = ( + unit: ReactSemanticUnit, + report: ReactAppProofReport, +): ReactObligationStatus => { + if (!unit.sourceComplete || unit.kind === ReactUnitKind.InvalidHookOwner) { + return ReactObligationStatus.Unknown; + } + const resources = report.graph.resources.filter((resource) => resource.ownerId === unit.id); + if ( + resources.some( + (resource) => resource.disposalStatus === ReactEffectResourceDisposalStatus.Missing, + ) + ) { + return ReactObligationStatus.Violated; + } + if ( + unit.kind === ReactUnitKind.ClassComponent && + !report.graph.classLifecycles.find((lifecycle) => lifecycle.ownerId === unit.id)?.sourceComplete + ) { + return ReactObligationStatus.Unknown; + } + if ( + report.graph.effects.some((effect) => effect.ownerId === unit.id && !effect.callbackResolved) + ) { + return ReactObligationStatus.Unknown; + } + return resources.some((resource) => !resource.complete) + ? ReactObligationStatus.Unknown + : ReactObligationStatus.Proved; +}; + +const checkClaimCoverage = ( + report: ReactAppProofReport, + failures: ReactProofCertificateFailure[], +): void => { + const expectedClaims = Object.values(ReactProofClaim); + for (const semanticUnit of report.graph.units) { + const unitProof = report.units.find( + (unit) => + unit.name === semanticUnit.name && + unit.location.filePath === semanticUnit.location.filePath && + unit.location.line === semanticUnit.location.line && + unit.location.column === semanticUnit.location.column, + ); + if (!unitProof) { + addFailure(failures, semanticUnit.id, "The semantic unit has no proof record"); + continue; + } + for (const claim of expectedClaims) { + const matchingObligations = unitProof.obligations.filter( + (obligation) => obligation.claim === claim, + ); + if (matchingObligations.length !== 1) { + addFailure(failures, semanticUnit.id, `${claim} must have exactly one proof obligation`); + } + } + const actionState = unitProof.obligations.find( + (obligation) => obligation.claim === ReactProofClaim.ActionState, + ); + const expectedActionStatus = expectedActionStateStatus(semanticUnit, report); + if (actionState && actionState.status !== expectedActionStatus) { + addFailure( + failures, + semanticUnit.id, + `Action State facts require ${expectedActionStatus}, not ${actionState.status}`, + ); + } + const asyncOwnership = unitProof.obligations.find( + (obligation) => obligation.claim === ReactProofClaim.AsyncEffectOwnership, + ); + const expectedStatus = expectedAsyncOwnershipStatus(semanticUnit, report); + if (asyncOwnership && asyncOwnership.status !== expectedStatus) { + addFailure( + failures, + semanticUnit.id, + `Async Effect ownership facts require ${expectedStatus}, not ${asyncOwnership.status}`, + ); + } + const callableRefFreshness = unitProof.obligations.find( + (obligation) => obligation.claim === ReactProofClaim.CallableRefFreshness, + ); + const expectedCallableRefStatus = expectedCallableRefFreshnessStatus(semanticUnit, report); + if (callableRefFreshness && callableRefFreshness.status !== expectedCallableRefStatus) { + addFailure( + failures, + semanticUnit.id, + `Callable ref facts require ${expectedCallableRefStatus}, not ${callableRefFreshness.status}`, + ); + } + const classStateTransitions = unitProof.obligations.find( + (obligation) => obligation.claim === ReactProofClaim.ClassStateTransitions, + ); + const expectedClassStateStatus = expectedClassStateTransitionStatus(semanticUnit, report); + if (classStateTransitions && classStateTransitions.status !== expectedClassStateStatus) { + addFailure( + failures, + semanticUnit.id, + `Class state transition facts require ${expectedClassStateStatus}, not ${classStateTransitions.status}`, + ); + } + const classConstruction = unitProof.obligations.find( + (obligation) => obligation.claim === ReactProofClaim.ClassConstruction, + ); + const expectedConstructionStatus = expectedClassConstructionStatus(semanticUnit, report); + if (classConstruction && classConstruction.status !== expectedConstructionStatus) { + addFailure( + failures, + semanticUnit.id, + `Class construction facts require ${expectedConstructionStatus}, not ${classConstruction.status}`, + ); + } + const hookStateTransitions = unitProof.obligations.find( + (obligation) => obligation.claim === ReactProofClaim.HookStateTransitions, + ); + const expectedHookStateStatus = expectedHookStateTransitionStatus(semanticUnit, report); + if (hookStateTransitions && hookStateTransitions.status !== expectedHookStateStatus) { + addFailure( + failures, + semanticUnit.id, + `Hook state transition facts require ${expectedHookStateStatus}, not ${hookStateTransitions.status}`, + ); + } + const reducerPurity = unitProof.obligations.find( + (obligation) => obligation.claim === ReactProofClaim.ReducerPurity, + ); + const expectedPurityStatus = expectedReducerPurityStatus(semanticUnit, report); + if (reducerPurity && reducerPurity.status !== expectedPurityStatus) { + addFailure( + failures, + semanticUnit.id, + `Reducer purity facts require ${expectedPurityStatus}, not ${reducerPurity.status}`, + ); + } + const reducerTransitions = unitProof.obligations.find( + (obligation) => obligation.claim === ReactProofClaim.ReducerTransitions, + ); + const expectedReducerStatus = expectedReducerTransitionStatus(semanticUnit, report); + if (reducerTransitions && reducerTransitions.status !== expectedReducerStatus) { + addFailure( + failures, + semanticUnit.id, + `Reducer transition facts require ${expectedReducerStatus}, not ${reducerTransitions.status}`, + ); + } + const imperativeHandle = unitProof.obligations.find( + (obligation) => obligation.claim === ReactProofClaim.ImperativeHandle, + ); + const expectedHandleStatus = expectedImperativeHandleStatus(semanticUnit, report); + if (imperativeHandle && imperativeHandle.status !== expectedHandleStatus) { + addFailure( + failures, + semanticUnit.id, + `Imperative handle facts require ${expectedHandleStatus}, not ${imperativeHandle.status}`, + ); + } + const lazySuspense = unitProof.obligations.find( + (obligation) => obligation.claim === ReactProofClaim.LazySuspense, + ); + const expectedLazyStatus = expectedLazySuspenseStatus(semanticUnit, report); + if (lazySuspense && lazySuspense.status !== expectedLazyStatus) { + addFailure( + failures, + semanticUnit.id, + `Lazy Suspense facts require ${expectedLazyStatus}, not ${lazySuspense.status}`, + ); + } + const errorBoundary = unitProof.obligations.find( + (obligation) => obligation.claim === ReactProofClaim.ErrorBoundary, + ); + const expectedErrorStatus = expectedErrorBoundaryStatus(semanticUnit, report); + if (errorBoundary && errorBoundary.status !== expectedErrorStatus) { + addFailure( + failures, + semanticUnit.id, + `Error Boundary facts require ${expectedErrorStatus}, not ${errorBoundary.status}`, + ); + } + const useResource = unitProof.obligations.find( + (obligation) => obligation.claim === ReactProofClaim.UseResource, + ); + const expectedResourceStatus = expectedUseResourceStatus(semanticUnit, report); + if (useResource && useResource.status !== expectedResourceStatus) { + addFailure( + failures, + semanticUnit.id, + `use resource facts require ${expectedResourceStatus}, not ${useResource.status}`, + ); + } + const hostControl = unitProof.obligations.find( + (obligation) => obligation.claim === ReactProofClaim.HostControl, + ); + const expectedControlStatus = expectedHostControlStatus(semanticUnit, report); + if (hostControl && hostControl.status !== expectedControlStatus) { + addFailure( + failures, + semanticUnit.id, + `Host control facts require ${expectedControlStatus}, not ${hostControl.status}`, + ); + } + const hydrationEquivalence = unitProof.obligations.find( + (obligation) => obligation.claim === ReactProofClaim.HydrationEquivalence, + ); + const expectedHydrationEquivalenceStatus = expectedHydrationStatus(semanticUnit, report); + if ( + hydrationEquivalence && + hydrationEquivalence.status !== expectedHydrationEquivalenceStatus + ) { + addFailure( + failures, + semanticUnit.id, + `Hydration facts require ${expectedHydrationEquivalenceStatus}, not ${hydrationEquivalence.status}`, + ); + } + const memoEquivalence = unitProof.obligations.find( + (obligation) => obligation.claim === ReactProofClaim.MemoEquivalence, + ); + const expectedMemoStatus = expectedMemoEquivalenceStatus(semanticUnit, report); + if (memoEquivalence && memoEquivalence.status !== expectedMemoStatus) { + addFailure( + failures, + semanticUnit.id, + `Memo comparator facts require ${expectedMemoStatus}, not ${memoEquivalence.status}`, + ); + } + const transitionActions = unitProof.obligations.find( + (obligation) => obligation.claim === ReactProofClaim.TransitionActions, + ); + const expectedTransitionStatus = expectedTransitionActionStatus(semanticUnit, report); + if (transitionActions && transitionActions.status !== expectedTransitionStatus) { + addFailure( + failures, + semanticUnit.id, + `Transition Action facts require ${expectedTransitionStatus}, not ${transitionActions.status}`, + ); + } + const formActions = unitProof.obligations.find( + (obligation) => obligation.claim === ReactProofClaim.FormActions, + ); + const expectedFormStatus = expectedFormActionStatus(semanticUnit, report); + if (formActions && formActions.status !== expectedFormStatus) { + addFailure( + failures, + semanticUnit.id, + `Form Action facts require ${expectedFormStatus}, not ${formActions.status}`, + ); + } + const formStatus = unitProof.obligations.find( + (obligation) => obligation.claim === ReactProofClaim.FormStatus, + ); + const expectedFormTopologyStatus = expectedFormStatusStatus(semanticUnit, report); + if (formStatus && formStatus.status !== expectedFormTopologyStatus) { + addFailure( + failures, + semanticUnit.id, + `Form Status facts require ${expectedFormTopologyStatus}, not ${formStatus.status}`, + ); + } + const optimisticState = unitProof.obligations.find( + (obligation) => obligation.claim === ReactProofClaim.OptimisticState, + ); + const expectedOptimisticStatus = expectedOptimisticStateStatus(semanticUnit, report); + if (optimisticState && optimisticState.status !== expectedOptimisticStatus) { + addFailure( + failures, + semanticUnit.id, + `Optimistic state facts require ${expectedOptimisticStatus}, not ${optimisticState.status}`, + ); + } + const reactNodeFlow = unitProof.obligations.find( + (obligation) => obligation.claim === ReactProofClaim.ReactNodeFlow, + ); + const expectedReactNodeStatus = expectedReactNodeFlowStatus(semanticUnit, report); + if (reactNodeFlow && reactNodeFlow.status !== expectedReactNodeStatus) { + addFailure( + failures, + semanticUnit.id, + `ReactNode slot facts require ${expectedReactNodeStatus}, not ${reactNodeFlow.status}`, + ); + } + const scheduledCallbackLifetime = unitProof.obligations.find( + (obligation) => obligation.claim === ReactProofClaim.ScheduledCallbackLifetime, + ); + const expectedSchedulerStatus = expectedScheduledCallbackLifetimeStatus(semanticUnit, report); + if (scheduledCallbackLifetime && scheduledCallbackLifetime.status !== expectedSchedulerStatus) { + addFailure( + failures, + semanticUnit.id, + `Scheduler facts require ${expectedSchedulerStatus}, not ${scheduledCallbackLifetime.status}`, + ); + } + const effectCleanup = unitProof.obligations.find( + (obligation) => obligation.claim === ReactProofClaim.EffectCleanup, + ); + const expectedCleanupStatus = expectedEffectCleanupStatus(semanticUnit, report); + if (effectCleanup && effectCleanup.status !== expectedCleanupStatus) { + addFailure( + failures, + semanticUnit.id, + `Effect resource facts require ${expectedCleanupStatus}, not ${effectCleanup.status}`, + ); + } + } +}; + +const addContextSource = ( + sourcesByUnit: Map>>, + unitId: string, + contextId: string, + sourceId: string, +): boolean => { + let sourcesByContext = sourcesByUnit.get(unitId); + if (!sourcesByContext) { + sourcesByContext = new Map(); + sourcesByUnit.set(unitId, sourcesByContext); + } + let sources = sourcesByContext.get(contextId); + if (!sources) { + sources = new Set(); + sourcesByContext.set(contextId, sources); + } + const previousSize = sources.size; + sources.add(sourceId); + return sources.size !== previousSize; +}; + +const deriveContextSourcesByUnit = ( + report: ReactAppProofReport, +): ReadonlyMap>> => { + const unitIds = new Set(report.graph.units.map((unit) => unit.id)); + const customHookEdges = report.graph.edges.filter( + (edge) => edge.kind === ReactSemanticEdgeKind.CallsHook && unitIds.has(edge.targetId), + ); + const providersById = new Map( + report.graph.contextProviders.map((provider) => [provider.id, provider]), + ); + const contexts = report.graph.contexts; + const rendersById = new Map(report.graph.renders.map((render) => [render.id, render])); + const sourcesByUnit = new Map>>(); + for (const unit of report.graph.units) { + if (!unit.canBeRenderRoot) continue; + for (const context of contexts) { + addContextSource(sourcesByUnit, unit.id, context.id, REACT_CONTEXT_DEFAULT_SOURCE_ID); + } + } + + let didSourcesChange = true; + while (didSourcesChange) { + didSourcesChange = false; + for (const render of report.graph.renders) { + if (render.kind === ReactSemanticRenderKind.SlotInput) continue; + for (const context of contexts) { + const nearestProvider = render.activeContextProviderIds + .toReversed() + .map((providerId) => providersById.get(providerId)) + .find((provider) => provider?.contextId === context.id); + if (nearestProvider) { + didSourcesChange = + addContextSource(sourcesByUnit, render.targetId, context.id, nearestProvider.id) || + didSourcesChange; + } else { + for (const sourceId of sourcesByUnit.get(render.ownerId)?.get(context.id) ?? []) { + didSourcesChange = + addContextSource(sourcesByUnit, render.targetId, context.id, sourceId) || + didSourcesChange; + } + } + if (!render.contextTopologyComplete) { + didSourcesChange = + addContextSource( + sourcesByUnit, + render.targetId, + context.id, + REACT_CONTEXT_UNKNOWN_SOURCE_ID, + ) || didSourcesChange; + } + } + } + for (const slotFlow of report.graph.slotFlows) { + if (slotFlow.complete) continue; + const sourceRender = rendersById.get(slotFlow.sourceRenderId); + if (!sourceRender) continue; + for (const context of contexts) { + didSourcesChange = + addContextSource( + sourcesByUnit, + sourceRender.targetId, + context.id, + REACT_CONTEXT_UNKNOWN_SOURCE_ID, + ) || didSourcesChange; + } + } + for (const hookEdge of customHookEdges) { + for (const context of contexts) { + for (const sourceId of sourcesByUnit.get(hookEdge.sourceId)?.get(context.id) ?? []) { + didSourcesChange = + addContextSource(sourcesByUnit, hookEdge.targetId, context.id, sourceId) || + didSourcesChange; + } + } + } + } + return sourcesByUnit; +}; + +const deriveFormSourcesByUnit = ( + report: ReactAppProofReport, +): ReadonlyMap> => { + const unitIds = new Set(report.graph.units.map((unit) => unit.id)); + const customHookEdges = report.graph.edges.filter( + (edge) => edge.kind === ReactSemanticEdgeKind.CallsHook && unitIds.has(edge.targetId), + ); + const rendersById = new Map(report.graph.renders.map((render) => [render.id, render])); + const sourcesByUnit = new Map>(); + const addSource = (unitId: string, sourceId: string): boolean => { + let sources = sourcesByUnit.get(unitId); + if (!sources) { + sources = new Set(); + sourcesByUnit.set(unitId, sources); + } + const previousSize = sources.size; + sources.add(sourceId); + return sources.size !== previousSize; + }; + for (const unit of report.graph.units) { + if (unit.canBeRenderRoot) { + addSource(unit.id, REACT_FORM_OUTSIDE_SOURCE_ID); + } + } + + let didSourcesChange = true; + while (didSourcesChange) { + didSourcesChange = false; + for (const render of report.graph.renders) { + if (render.kind === ReactSemanticRenderKind.SlotInput) continue; + const nearestFormId = render.activeFormIds.at(-1); + if (nearestFormId) { + didSourcesChange = addSource(render.targetId, nearestFormId) || didSourcesChange; + } else { + for (const sourceId of sourcesByUnit.get(render.ownerId) ?? []) { + if (!render.formTopologyComplete && sourceId === REACT_FORM_OUTSIDE_SOURCE_ID) { + continue; + } + didSourcesChange = addSource(render.targetId, sourceId) || didSourcesChange; + } + } + if (!render.formTopologyComplete) { + didSourcesChange = + addSource(render.targetId, REACT_FORM_UNKNOWN_SOURCE_ID) || didSourcesChange; + } + } + for (const slotFlow of report.graph.slotFlows) { + if (slotFlow.complete) continue; + const sourceRender = rendersById.get(slotFlow.sourceRenderId); + if (sourceRender) { + didSourcesChange = + addSource(sourceRender.targetId, REACT_FORM_UNKNOWN_SOURCE_ID) || didSourcesChange; + } + } + for (const hookEdge of customHookEdges) { + for (const sourceId of sourcesByUnit.get(hookEdge.sourceId) ?? []) { + didSourcesChange = addSource(hookEdge.targetId, sourceId) || didSourcesChange; + } + } + } + return sourcesByUnit; +}; + +const deriveSuspenseSourcesByUnit = ( + report: ReactAppProofReport, +): ReadonlyMap> => { + const sourcesByUnit = new Map>(); + const rendersById = new Map(report.graph.renders.map((render) => [render.id, render])); + const slotFlowsBySourceRenderId = new Map( + report.graph.slotFlows.map((slotFlow) => [slotFlow.sourceRenderId, slotFlow]), + ); + const boundaryIdsByRenderId = new Map>(); + const unitIds = new Set(report.graph.units.map((unit) => unit.id)); + const customHookEdges = report.graph.edges.filter( + (edge) => edge.kind === ReactSemanticEdgeKind.CallsHook && unitIds.has(edge.targetId), + ); + const addSource = (unitId: string, sourceId: string): boolean => { + let sources = sourcesByUnit.get(unitId); + if (!sources) { + sources = new Set(); + sourcesByUnit.set(unitId, sources); + } + const previousSize = sources.size; + sources.add(sourceId); + return sources.size !== previousSize; + }; + for (const boundary of report.graph.suspenseBoundaries) { + for (const renderId of boundary.renderIds) { + const boundaryIds = boundaryIdsByRenderId.get(renderId) ?? new Set(); + boundaryIds.add(boundary.id); + boundaryIdsByRenderId.set(renderId, boundaryIds); + } + } + for (const unit of report.graph.units) { + if (unit.canBeRenderRoot) addSource(unit.id, REACT_SUSPENSE_OUTSIDE_SOURCE_ID); + } + let didSourcesChange = true; + while (didSourcesChange) { + didSourcesChange = false; + for (const render of report.graph.renders) { + const boundaryIds = boundaryIdsByRenderId.get(render.id); + if ( + render.kind === ReactSemanticRenderKind.SlotInput && + (!boundaryIds || boundaryIds.size === 0) + ) { + const slotFlow = slotFlowsBySourceRenderId.get(render.id); + if (!slotFlow?.complete || slotFlow.renderIds.length > 0) continue; + } + if (boundaryIds && boundaryIds.size > 0) { + for (const boundaryId of boundaryIds) { + didSourcesChange = addSource(render.targetId, boundaryId) || didSourcesChange; + } + } else { + for (const sourceId of sourcesByUnit.get(render.ownerId) ?? []) { + didSourcesChange = addSource(render.targetId, sourceId) || didSourcesChange; + } + } + } + for (const slotFlow of report.graph.slotFlows) { + if (slotFlow.complete) continue; + const sourceRender = rendersById.get(slotFlow.sourceRenderId); + if (sourceRender) { + didSourcesChange = + addSource(sourceRender.targetId, REACT_SUSPENSE_UNKNOWN_SOURCE_ID) || didSourcesChange; + } + } + for (const hookEdge of customHookEdges) { + for (const sourceId of sourcesByUnit.get(hookEdge.sourceId) ?? []) { + didSourcesChange = addSource(hookEdge.targetId, sourceId) || didSourcesChange; + } + } + } + return sourcesByUnit; +}; + +const deriveErrorBoundarySourcesByUnit = ( + report: ReactAppProofReport, +): ReadonlyMap> => { + const sourcesByUnit = new Map>(); + const rendersById = new Map(report.graph.renders.map((render) => [render.id, render])); + const boundaryIdsByRenderId = new Map>(); + const unitIds = new Set(report.graph.units.map((unit) => unit.id)); + const customHookEdges = report.graph.edges.filter( + (edge) => edge.kind === ReactSemanticEdgeKind.CallsHook && unitIds.has(edge.targetId), + ); + const addSource = (unitId: string, sourceId: string): boolean => { + const sources = sourcesByUnit.get(unitId) ?? new Set(); + const previousSize = sources.size; + sources.add(sourceId); + sourcesByUnit.set(unitId, sources); + return sources.size !== previousSize; + }; + for (const boundary of report.graph.errorBoundaries) { + for (const renderId of boundary.renderIds) { + const boundaryIds = boundaryIdsByRenderId.get(renderId) ?? new Set(); + boundaryIds.add(boundary.id); + boundaryIdsByRenderId.set(renderId, boundaryIds); + } + } + for (const unit of report.graph.units) { + if (unit.canBeRenderRoot) { + addSource(unit.id, REACT_ERROR_BOUNDARY_OUTSIDE_SOURCE_ID); + } + } + let didSourcesChange = true; + while (didSourcesChange) { + didSourcesChange = false; + for (const render of report.graph.renders) { + const boundaryIds = boundaryIdsByRenderId.get(render.id); + if ( + render.kind === ReactSemanticRenderKind.SlotInput && + (!boundaryIds || boundaryIds.size === 0) + ) { + continue; + } + if (boundaryIds && boundaryIds.size > 0) { + for (const boundaryId of boundaryIds) { + didSourcesChange = addSource(render.targetId, boundaryId) || didSourcesChange; + } + } else { + for (const sourceId of sourcesByUnit.get(render.ownerId) ?? []) { + didSourcesChange = addSource(render.targetId, sourceId) || didSourcesChange; + } + } + } + for (const slotFlow of report.graph.slotFlows) { + if (slotFlow.complete) continue; + const sourceRender = rendersById.get(slotFlow.sourceRenderId); + if (sourceRender && !boundaryIdsByRenderId.has(sourceRender.id)) { + didSourcesChange = + addSource(sourceRender.targetId, REACT_ERROR_BOUNDARY_UNKNOWN_SOURCE_ID) || + didSourcesChange; + } + } + for (const hookEdge of customHookEdges) { + for (const sourceId of sourcesByUnit.get(hookEdge.sourceId) ?? []) { + didSourcesChange = addSource(hookEdge.targetId, sourceId) || didSourcesChange; + } + } + } + return sourcesByUnit; +}; + +const checkGraphReferences = ( + report: ReactAppProofReport, + failures: ReactProofCertificateFailure[], +): void => { + const unitIds = new Set(report.graph.units.map((unit) => unit.id)); + const unitsById = new Map(report.graph.units.map((unit) => [unit.id, unit])); + const effectIds = new Set(report.graph.effects.map((effect) => effect.id)); + const effectsById = new Map(report.graph.effects.map((effect) => [effect.id, effect])); + const callbackIds = new Set(report.graph.callbacks.map((callback) => callback.id)); + const callbacksById = new Map(report.graph.callbacks.map((callback) => [callback.id, callback])); + const functionCallsById = new Map( + report.graph.functionCalls.map((functionCall) => [functionCall.id, functionCall]), + ); + const rendersById = new Map(report.graph.renders.map((render) => [render.id, render])); + const reachableFunctionsById = new Map( + report.graph.reachableFunctions.map((reachableFunction) => [ + reachableFunction.id, + reachableFunction, + ]), + ); + const contextIds = new Set(report.graph.contexts.map((context) => context.id)); + const providerIds = new Set(report.graph.contextProviders.map((provider) => provider.id)); + const providersById = new Map( + report.graph.contextProviders.map((provider) => [provider.id, provider]), + ); + const formsById = new Map(report.graph.forms.map((form) => [form.id, form])); + const contextSourcesByUnit = deriveContextSourcesByUnit(report); + const formSourcesByUnit = deriveFormSourcesByUnit(report); + const suspenseSourcesByUnit = deriveSuspenseSourcesByUnit(report); + const errorBoundarySourcesByUnit = deriveErrorBoundarySourcesByUnit(report); + const hydrationSourcesByUnit = deriveHydrationSourcesByUnit(report); + const hydrationRootsById = new Map(report.graph.hydrationRoots.map((root) => [root.id, root])); + const hydrationHazardsById = new Map( + report.graph.hydrationHazards.map((hazard) => [hazard.id, hazard]), + ); + const errorBoundaryDefinitionsById = new Map( + report.graph.errorBoundaryDefinitions.map((definition) => [definition.id, definition]), + ); + const errorBoundariesById = new Map( + report.graph.errorBoundaries.map((boundary) => [boundary.id, boundary]), + ); + const errorBoundaryInstanceIdsByDefinitionId = new Map( + report.graph.errorBoundaryDefinitions.map((definition) => [ + definition.id, + new Set(definition.instanceIds), + ]), + ); + const suspenseBoundariesById = new Map( + report.graph.suspenseBoundaries.map((boundary) => [boundary.id, boundary]), + ); + const lazyComponentsById = new Map( + report.graph.lazyComponents.map((component) => [component.id, component]), + ); + const lazyRendersById = new Map(report.graph.lazyRenders.map((render) => [render.id, render])); + const lazyRenderIdsByComponentId = new Map(); + for (const render of report.graph.lazyRenders) { + const renderIds = lazyRenderIdsByComponentId.get(render.lazyComponentId) ?? []; + renderIds.push(render.id); + lazyRenderIdsByComponentId.set(render.lazyComponentId, renderIds); + } + const imperativeHandlesById = new Map( + report.graph.imperativeHandles.map((handle) => [handle.id, handle]), + ); + const imperativeMethodsById = new Map( + report.graph.imperativeHandleMethods.map((method) => [method.id, method]), + ); + const imperativeBindingsById = new Map( + report.graph.imperativeHandleBindings.map((binding) => [binding.id, binding]), + ); + const imperativeInvocationsById = new Map( + report.graph.imperativeHandleInvocations.map((invocation) => [invocation.id, invocation]), + ); + const imperativeMethodIdsByHandleId = new Map( + report.graph.imperativeHandles.map((handle) => [handle.id, new Set(handle.methodIds)]), + ); + const imperativeBindingIdsByHandleId = new Map( + report.graph.imperativeHandles.map((handle) => [handle.id, new Set(handle.bindingIds)]), + ); + const imperativeInvocationIdsByBindingId = new Map( + report.graph.imperativeHandleBindings.map((binding) => [ + binding.id, + new Set(binding.invocationIds), + ]), + ); + for (const definition of report.graph.errorBoundaryDefinitions) { + const owner = unitsById.get(definition.ownerId); + if (!owner || owner.kind !== ReactUnitKind.ClassComponent) { + addFailure( + failures, + definition.id, + "An Error Boundary definition has no class component owner", + ); + } + if ( + !ERROR_BOUNDARY_PROTOCOL_STATUSES.has(definition.derivedStateStatus) || + !ERROR_BOUNDARY_PROTOCOL_STATUSES.has(definition.fallbackRenderStatus) + ) { + addFailure(failures, definition.id, "An Error Boundary has an invalid protocol status"); + } + if (new Set(definition.instanceIds).size !== definition.instanceIds.length) { + addFailure(failures, definition.id, "An Error Boundary definition repeats an instance"); + } + if ( + definition.instanceIds.some( + (instanceId) => errorBoundariesById.get(instanceId)?.definitionId !== definition.id, + ) + ) { + addFailure( + failures, + definition.id, + "An Error Boundary definition has an invalid instance link", + ); + } + const expectedSourceComplete = + definition.derivedStateStatus !== ReactErrorBoundaryProtocolStatus.Unknown && + definition.fallbackRenderStatus !== ReactErrorBoundaryProtocolStatus.Unknown; + const expectedComplete = + expectedSourceComplete && + definition.derivedStateStatus === ReactErrorBoundaryProtocolStatus.Valid && + definition.fallbackRenderStatus === ReactErrorBoundaryProtocolStatus.Valid; + if (definition.sourceComplete !== expectedSourceComplete) { + addFailure(failures, definition.id, "An Error Boundary source certificate is inconsistent"); + } + if (definition.complete !== expectedComplete) { + addFailure(failures, definition.id, "An Error Boundary completeness flag is inconsistent"); + } + if ( + definition.derivedStateStatus === ReactErrorBoundaryProtocolStatus.Valid && + (!definition.derivedStateLocation || !definition.fallbackStateKey) + ) { + addFailure( + failures, + definition.id, + "A valid Error Boundary has no derived-state method or fallback key", + ); + } + } + for (const boundary of report.graph.errorBoundaries) { + const definition = errorBoundaryDefinitionsById.get(boundary.definitionId); + if (!unitIds.has(boundary.ownerId) || !definition) { + addFailure( + failures, + boundary.id, + "An Error Boundary instance has an unknown owner or definition", + ); + } + if (!errorBoundaryInstanceIdsByDefinitionId.get(boundary.definitionId)?.has(boundary.id)) { + addFailure( + failures, + boundary.id, + "An Error Boundary instance is not linked from its definition", + ); + } + if (new Set(boundary.renderIds).size !== boundary.renderIds.length) { + addFailure(failures, boundary.id, "An Error Boundary repeats a protected render"); + } + if (boundary.renderIds.some((renderId) => !rendersById.has(renderId))) { + addFailure(failures, boundary.id, "An Error Boundary references an unknown render"); + } + } + for (const renderFailure of report.graph.renderFailures) { + if (!unitIds.has(renderFailure.ownerId)) { + addFailure(failures, renderFailure.id, "A render failure has an unknown owner unit"); + } + if (!RENDER_FAILURE_KINDS.has(renderFailure.kind)) { + addFailure(failures, renderFailure.id, "A render failure has an invalid kind"); + } + if (!ERROR_BOUNDARY_COVERAGE_STATUSES.has(renderFailure.coverageStatus)) { + addFailure(failures, renderFailure.id, "A render failure has an invalid coverage status"); + } + if (new Set(renderFailure.sourceBoundaryIds).size !== renderFailure.sourceBoundaryIds.length) { + addFailure(failures, renderFailure.id, "A render failure repeats a source boundary"); + } + const expectedSources = errorBoundarySourcesByUnit.get(renderFailure.ownerId) ?? new Set(); + if (expectedSources.size === 0) { + addFailure(failures, renderFailure.id, "An unreachable render failure has a graph fact"); + } + const expectedBoundaryIds = [...expectedSources].filter( + (sourceId) => + sourceId !== REACT_ERROR_BOUNDARY_OUTSIDE_SOURCE_ID && + sourceId !== REACT_ERROR_BOUNDARY_UNKNOWN_SOURCE_ID, + ); + if ( + expectedBoundaryIds.length !== renderFailure.sourceBoundaryIds.length || + expectedBoundaryIds.some( + (boundaryId) => !renderFailure.sourceBoundaryIds.includes(boundaryId), + ) + ) { + addFailure(failures, renderFailure.id, "A render failure has inconsistent boundary sources"); + } + const sourceDefinitions = expectedBoundaryIds.flatMap((boundaryId) => { + const boundary = errorBoundariesById.get(boundaryId); + const definition = boundary ? errorBoundaryDefinitionsById.get(boundary.definitionId) : null; + return definition ? [definition] : []; + }); + const expectedOutsideBoundary = expectedSources.has(REACT_ERROR_BOUNDARY_OUTSIDE_SOURCE_ID); + const expectedTopologyComplete = + !expectedSources.has(REACT_ERROR_BOUNDARY_UNKNOWN_SOURCE_ID) && + sourceDefinitions.length === expectedBoundaryIds.length && + sourceDefinitions.every((definition) => definition.sourceComplete); + const hasValidBoundary = sourceDefinitions.some((definition) => definition.complete); + let expectedCoverageStatus = ReactErrorBoundaryCoverageStatus.Unknown; + if (expectedOutsideBoundary || (expectedTopologyComplete && !hasValidBoundary)) { + expectedCoverageStatus = ReactErrorBoundaryCoverageStatus.OutsideBoundary; + } else if (expectedTopologyComplete && hasValidBoundary) { + expectedCoverageStatus = ReactErrorBoundaryCoverageStatus.Covered; + } + if (renderFailure.outsideBoundary !== expectedOutsideBoundary) { + addFailure(failures, renderFailure.id, "A render failure outside flag is inconsistent"); + } + if ( + renderFailure.topologyComplete !== expectedTopologyComplete || + renderFailure.sourceComplete !== expectedTopologyComplete + ) { + addFailure(failures, renderFailure.id, "A render failure source certificate is inconsistent"); + } + if (renderFailure.coverageStatus !== expectedCoverageStatus) { + addFailure(failures, renderFailure.id, "A render failure coverage status is inconsistent"); + } + if ( + renderFailure.complete !== + (expectedCoverageStatus === ReactErrorBoundaryCoverageStatus.Covered) + ) { + addFailure(failures, renderFailure.id, "A render failure completeness flag is inconsistent"); + } + } + for (const resource of report.graph.useResources) { + if (!unitIds.has(resource.ownerId)) { + addFailure(failures, resource.id, "A use resource has an unknown owner unit"); + } + if (!USE_RESOURCE_KINDS.has(resource.kind)) { + addFailure(failures, resource.id, "A use resource has an invalid type kind"); + } + if (!USE_RESOURCE_IDENTITY_STATUSES.has(resource.identityStatus)) { + addFailure(failures, resource.id, "A use resource has an invalid identity status"); + } + if (!SUSPENSE_COVERAGE_STATUSES.has(resource.suspenseCoverageStatus)) { + addFailure(failures, resource.id, "A use resource has an invalid Suspense status"); + } + if (!ERROR_BOUNDARY_COVERAGE_STATUSES.has(resource.errorCoverageStatus)) { + addFailure(failures, resource.id, "A use resource has an invalid Error Boundary status"); + } + if ( + new Set(resource.sourceSuspenseBoundaryIds).size !== + resource.sourceSuspenseBoundaryIds.length || + resource.sourceSuspenseBoundaryIds.some( + (boundaryId) => !suspenseBoundariesById.has(boundaryId), + ) + ) { + addFailure(failures, resource.id, "A use resource has invalid Suspense sources"); + } + if ( + new Set(resource.sourceErrorBoundaryIds).size !== resource.sourceErrorBoundaryIds.length || + resource.sourceErrorBoundaryIds.some((boundaryId) => !errorBoundariesById.has(boundaryId)) + ) { + addFailure(failures, resource.id, "A use resource has invalid Error Boundary sources"); + } + const expectedSuspenseSources = + suspenseSourcesByUnit.get(resource.ownerId) ?? new Set(); + const expectedSuspenseBoundaryIds = [...expectedSuspenseSources].filter( + (sourceId) => + sourceId !== REACT_SUSPENSE_OUTSIDE_SOURCE_ID && + sourceId !== REACT_SUSPENSE_UNKNOWN_SOURCE_ID, + ); + if ( + expectedSuspenseBoundaryIds.length !== resource.sourceSuspenseBoundaryIds.length || + expectedSuspenseBoundaryIds.some( + (boundaryId) => !resource.sourceSuspenseBoundaryIds.includes(boundaryId), + ) + ) { + addFailure(failures, resource.id, "A use resource has inconsistent Suspense sources"); + } + const expectedOutsideSuspenseBoundary = expectedSuspenseSources.has( + REACT_SUSPENSE_OUTSIDE_SOURCE_ID, + ); + const expectedSuspenseTopologyComplete = + expectedSuspenseSources.size > 0 && + !expectedSuspenseSources.has(REACT_SUSPENSE_UNKNOWN_SOURCE_ID); + let expectedSuspenseCoverageStatus = ReactSuspenseCoverageStatus.Unknown; + if (expectedOutsideSuspenseBoundary) { + expectedSuspenseCoverageStatus = ReactSuspenseCoverageStatus.OutsideBoundary; + } else if (expectedSuspenseTopologyComplete && expectedSuspenseBoundaryIds.length > 0) { + expectedSuspenseCoverageStatus = ReactSuspenseCoverageStatus.Covered; + } + + const expectedErrorSources = + errorBoundarySourcesByUnit.get(resource.ownerId) ?? new Set(); + const expectedErrorBoundaryIds = [...expectedErrorSources].filter( + (sourceId) => + sourceId !== REACT_ERROR_BOUNDARY_OUTSIDE_SOURCE_ID && + sourceId !== REACT_ERROR_BOUNDARY_UNKNOWN_SOURCE_ID, + ); + if ( + expectedErrorBoundaryIds.length !== resource.sourceErrorBoundaryIds.length || + expectedErrorBoundaryIds.some( + (boundaryId) => !resource.sourceErrorBoundaryIds.includes(boundaryId), + ) + ) { + addFailure(failures, resource.id, "A use resource has inconsistent Error Boundary sources"); + } + const sourceDefinitions = expectedErrorBoundaryIds.flatMap((boundaryId) => { + const boundary = errorBoundariesById.get(boundaryId); + const definition = boundary ? errorBoundaryDefinitionsById.get(boundary.definitionId) : null; + return definition ? [definition] : []; + }); + const expectedOutsideErrorBoundary = expectedErrorSources.has( + REACT_ERROR_BOUNDARY_OUTSIDE_SOURCE_ID, + ); + const expectedErrorTopologyComplete = + expectedErrorSources.size > 0 && + !expectedErrorSources.has(REACT_ERROR_BOUNDARY_UNKNOWN_SOURCE_ID) && + sourceDefinitions.length === expectedErrorBoundaryIds.length && + sourceDefinitions.every((definition) => definition.sourceComplete); + const hasValidErrorBoundary = + sourceDefinitions.length > 0 && sourceDefinitions.every((definition) => definition.complete); + let expectedErrorCoverageStatus = ReactErrorBoundaryCoverageStatus.Unknown; + if (expectedOutsideErrorBoundary || (expectedErrorTopologyComplete && !hasValidErrorBoundary)) { + expectedErrorCoverageStatus = ReactErrorBoundaryCoverageStatus.OutsideBoundary; + } else if (expectedErrorTopologyComplete && hasValidErrorBoundary) { + expectedErrorCoverageStatus = ReactErrorBoundaryCoverageStatus.Covered; + } + const expectedSourceComplete = + resource.kind !== ReactUseResourceKind.Unknown && + resource.identityStatus !== ReactUseResourceIdentityStatus.Unknown && + expectedSuspenseTopologyComplete && + expectedErrorTopologyComplete; + const expectedComplete = + expectedSourceComplete && + resource.kind === ReactUseResourceKind.Thenable && + resource.identityStatus === ReactUseResourceIdentityStatus.Stable && + expectedSuspenseCoverageStatus === ReactSuspenseCoverageStatus.Covered && + expectedErrorCoverageStatus === ReactErrorBoundaryCoverageStatus.Covered; + if ( + resource.outsideSuspenseBoundary !== expectedOutsideSuspenseBoundary || + resource.suspenseTopologyComplete !== expectedSuspenseTopologyComplete || + resource.suspenseCoverageStatus !== expectedSuspenseCoverageStatus + ) { + addFailure(failures, resource.id, "A use resource Suspense certificate is inconsistent"); + } + if ( + resource.outsideErrorBoundary !== expectedOutsideErrorBoundary || + resource.errorTopologyComplete !== expectedErrorTopologyComplete || + resource.errorCoverageStatus !== expectedErrorCoverageStatus + ) { + addFailure( + failures, + resource.id, + "A use resource Error Boundary certificate is inconsistent", + ); + } + if (resource.sourceComplete !== expectedSourceComplete) { + addFailure(failures, resource.id, "A use resource source certificate is inconsistent"); + } + if (resource.complete !== expectedComplete) { + addFailure(failures, resource.id, "A use resource completeness flag is inconsistent"); + } + } + for (const boundary of report.graph.suspenseBoundaries) { + if (!unitIds.has(boundary.ownerId)) { + addFailure(failures, boundary.id, "A Suspense boundary has an unknown owner unit"); + } + if (new Set(boundary.renderIds).size !== boundary.renderIds.length) { + addFailure(failures, boundary.id, "A Suspense boundary repeats a covered render"); + } + if (boundary.renderIds.some((renderId) => !rendersById.has(renderId))) { + addFailure(failures, boundary.id, "A Suspense boundary references an unknown render"); + } + } + for (const component of report.graph.lazyComponents) { + if (component.declarationOwnerId && !unitIds.has(component.declarationOwnerId)) { + addFailure(failures, component.id, "A lazy component has an unknown declaration owner"); + } + if (!LAZY_DECLARATION_STATUSES.has(component.declarationStatus)) { + addFailure(failures, component.id, "A lazy component has an invalid declaration status"); + } + if (!LAZY_LOADER_STATUSES.has(component.loaderStatus)) { + addFailure(failures, component.id, "A lazy component has an invalid loader status"); + } + if ( + component.declarationStatus === ReactLazyDeclarationStatus.ModuleStable && + component.declarationOwnerId + ) { + addFailure(failures, component.id, "A module-stable lazy component has a render owner"); + } + if (new Set(component.renderIds).size !== component.renderIds.length) { + addFailure(failures, component.id, "A lazy component repeats a render"); + } + const componentRenders = component.renderIds.flatMap((renderId) => { + const render = lazyRendersById.get(renderId); + if (!render || render.lazyComponentId !== component.id) { + addFailure(failures, component.id, "A lazy component has an invalid render link"); + return []; + } + return [render]; + }); + const reciprocalRenderIds = lazyRenderIdsByComponentId.get(component.id) ?? []; + const componentRenderIds = new Set(component.renderIds); + if ( + reciprocalRenderIds.length !== component.renderIds.length || + reciprocalRenderIds.some((renderId) => !componentRenderIds.has(renderId)) + ) { + addFailure(failures, component.id, "A lazy component render set is not reciprocal"); + } + const expectedSourceComplete = + component.identityResolved && component.loaderStatus !== ReactLazyLoaderStatus.Opaque; + if (component.sourceComplete !== expectedSourceComplete) { + addFailure(failures, component.id, "A lazy component source flag is inconsistent"); + } + const expectedComplete = + component.identityResolved && + !component.canBeRenderRoot && + component.declarationStatus === ReactLazyDeclarationStatus.ModuleStable && + component.loaderStatus === ReactLazyLoaderStatus.Valid && + componentRenders.length === component.renderIds.length && + componentRenders.every((render) => render.complete); + if (component.complete !== expectedComplete) { + addFailure(failures, component.id, "A lazy component completeness flag is inconsistent"); + } + } + for (const render of report.graph.lazyRenders) { + if (!unitIds.has(render.ownerId)) { + addFailure(failures, render.id, "A lazy render has an unknown owner unit"); + } + if (!lazyComponentsById.has(render.lazyComponentId)) { + addFailure(failures, render.id, "A lazy render has an unknown component"); + } + if (!SUSPENSE_COVERAGE_STATUSES.has(render.coverageStatus)) { + addFailure(failures, render.id, "A lazy render has an invalid coverage status"); + } + if ( + new Set(render.topologyBoundaryIds).size !== render.topologyBoundaryIds.length || + render.topologyBoundaryIds.some((boundaryId) => !suspenseBoundariesById.has(boundaryId)) + ) { + addFailure(failures, render.id, "A lazy render has invalid direct Suspense boundaries"); + } + if ( + new Set(render.sourceBoundaryIds).size !== render.sourceBoundaryIds.length || + render.sourceBoundaryIds.some((boundaryId) => !suspenseBoundariesById.has(boundaryId)) + ) { + addFailure(failures, render.id, "A lazy render has invalid source Suspense boundaries"); + } + const expectedSources = new Set(render.topologyBoundaryIds); + if (render.inheritsOwnerBoundary) { + for (const sourceId of suspenseSourcesByUnit.get(render.ownerId) ?? []) { + expectedSources.add(sourceId); + } + } + if (!render.topologyComplete) expectedSources.add(REACT_SUSPENSE_UNKNOWN_SOURCE_ID); + const expectedBoundaryIds = [...expectedSources].filter( + (sourceId) => + sourceId !== REACT_SUSPENSE_OUTSIDE_SOURCE_ID && + sourceId !== REACT_SUSPENSE_UNKNOWN_SOURCE_ID, + ); + if ( + expectedBoundaryIds.length !== render.sourceBoundaryIds.length || + expectedBoundaryIds.some((boundaryId) => !render.sourceBoundaryIds.includes(boundaryId)) + ) { + addFailure(failures, render.id, "A lazy render has an inconsistent boundary source set"); + } + const expectedOutsideBoundary = expectedSources.has(REACT_SUSPENSE_OUTSIDE_SOURCE_ID); + const expectedSourceComplete = + expectedSources.size > 0 && !expectedSources.has(REACT_SUSPENSE_UNKNOWN_SOURCE_ID); + let expectedCoverageStatus = ReactSuspenseCoverageStatus.Unknown; + if (expectedOutsideBoundary) { + expectedCoverageStatus = ReactSuspenseCoverageStatus.OutsideBoundary; + } else if (expectedSourceComplete && expectedBoundaryIds.length > 0) { + expectedCoverageStatus = ReactSuspenseCoverageStatus.Covered; + } + if (render.outsideBoundary !== expectedOutsideBoundary) { + addFailure(failures, render.id, "A lazy render outside-boundary flag is inconsistent"); + } + if (render.sourceComplete !== expectedSourceComplete) { + addFailure(failures, render.id, "A lazy render source flag is inconsistent"); + } + if (render.coverageStatus !== expectedCoverageStatus) { + addFailure(failures, render.id, "A lazy render coverage status is inconsistent"); + } + if (render.complete !== (expectedCoverageStatus === ReactSuspenseCoverageStatus.Covered)) { + addFailure(failures, render.id, "A lazy render completeness flag is inconsistent"); + } + } + for (const method of report.graph.imperativeHandleMethods) { + const handle = imperativeHandlesById.get(method.handleId); + if ( + !handle || + handle.ownerId !== method.ownerId || + !imperativeMethodIdsByHandleId.get(handle.id)?.has(method.id) || + !method.name + ) { + addFailure( + failures, + method.id, + "An imperative handle method has an invalid owner or reciprocal handle link", + ); + } + } + for (const binding of report.graph.imperativeHandleBindings) { + const handle = imperativeHandlesById.get(binding.handleId); + const render = rendersById.get(binding.renderId); + const bindingInvocations = binding.invocationIds.flatMap((invocationId) => { + const invocation = imperativeInvocationsById.get(invocationId); + if ( + !invocation || + invocation.bindingId !== binding.id || + invocation.handleId !== binding.handleId + ) { + addFailure( + failures, + binding.id, + "An imperative handle binding has an invalid invocation link", + ); + return []; + } + return [invocation]; + }); + if ( + !handle || + !imperativeBindingIdsByHandleId.get(handle.id)?.has(binding.id) || + !render || + render.kind !== ReactSemanticRenderKind.Direct || + render.ownerId !== binding.ownerId || + render.targetId !== handle.ownerId || + !binding.refName + ) { + addFailure( + failures, + binding.id, + "An imperative handle binding has an invalid owner, render, or ref identity", + ); + } + if (new Set(binding.invocationIds).size !== binding.invocationIds.length) { + addFailure(failures, binding.id, "An imperative handle binding repeats an invocation"); + } + const expectedSourceComplete = + binding.referenceComplete && + Boolean(render) && + bindingInvocations.length === binding.invocationIds.length && + bindingInvocations.every((invocation) => invocation.sourceComplete); + if (binding.sourceComplete !== expectedSourceComplete) { + addFailure(failures, binding.id, "An imperative handle binding source flag is inconsistent"); + } + const expectedComplete = + expectedSourceComplete && bindingInvocations.every((invocation) => invocation.complete); + if (binding.complete !== expectedComplete) { + addFailure( + failures, + binding.id, + "An imperative handle binding completeness flag is inconsistent", + ); + } + } + for (const invocation of report.graph.imperativeHandleInvocations) { + const handle = imperativeHandlesById.get(invocation.handleId); + const method = imperativeMethodsById.get(invocation.methodId); + const binding = imperativeBindingsById.get(invocation.bindingId); + const callerCallbacks = invocation.callerCallbackIds.flatMap((callbackId) => { + const callback = callbacksById.get(callbackId); + if ( + !callback || + callback.ownerId !== invocation.ownerId || + callback.phase === ReactExecutionPhase.Render + ) { + addFailure( + failures, + invocation.id, + "An imperative handle invocation has an invalid caller callback", + ); + return []; + } + return [callback]; + }); + const methodCallbacks = invocation.methodCallbackIds.flatMap((callbackId) => { + const callback = callbacksById.get(callbackId); + if ( + !callback || + callback.ownerId !== handle?.ownerId || + callback.kind !== ReactSemanticCallbackKind.ImperativeHandleMethod || + !method || + !areProofLocationsEqual(callback.location, method.location) + ) { + addFailure( + failures, + invocation.id, + "An imperative handle invocation has an invalid method callback", + ); + return []; + } + return [callback]; + }); + if ( + !handle || + !method || + method.handleId !== handle.id || + !binding || + binding.handleId !== handle.id || + binding.ownerId !== invocation.ownerId || + !imperativeInvocationIdsByBindingId.get(binding.id)?.has(invocation.id) + ) { + addFailure( + failures, + invocation.id, + "An imperative handle invocation has an invalid handle, method, or binding", + ); + } + if ( + new Set(invocation.callerCallbackIds).size !== invocation.callerCallbackIds.length || + new Set(invocation.methodCallbackIds).size !== invocation.methodCallbackIds.length + ) { + addFailure(failures, invocation.id, "An imperative handle invocation repeats a callback"); + } + const callerPhases = new Set(callerCallbacks.map((callback) => callback.phase)); + const methodPhases = new Set(methodCallbacks.map((callback) => callback.phase)); + const phasesMatch = + callerPhases.size === methodPhases.size && + [...callerPhases].every((phase) => methodPhases.has(phase)); + const expectedSourceComplete = + Boolean(handle && method && binding) && + Boolean(binding?.referenceComplete) && + callerCallbacks.length === invocation.callerCallbackIds.length && + callerCallbacks.length > 0; + if (invocation.sourceComplete !== expectedSourceComplete) { + addFailure( + failures, + invocation.id, + "An imperative handle invocation source flag is inconsistent", + ); + } + const expectedComplete = + expectedSourceComplete && + methodCallbacks.length === invocation.methodCallbackIds.length && + methodCallbacks.length > 0 && + phasesMatch; + if (invocation.complete !== expectedComplete) { + addFailure( + failures, + invocation.id, + "An imperative handle invocation completeness flag is inconsistent", + ); + } + } + for (const handle of report.graph.imperativeHandles) { + const owner = unitsById.get(handle.ownerId); + const factoryCallback = handle.factoryCallbackId + ? callbacksById.get(handle.factoryCallbackId) + : null; + const methods = handle.methodIds.flatMap((methodId) => { + const method = imperativeMethodsById.get(methodId); + return method?.handleId === handle.id ? [method] : []; + }); + const bindings = handle.bindingIds.flatMap((bindingId) => { + const binding = imperativeBindingsById.get(bindingId); + return binding?.handleId === handle.id ? [binding] : []; + }); + if ( + owner?.kind !== ReactUnitKind.Component || + (handle.refKind !== null && !IMPERATIVE_HANDLE_REF_KINDS.has(handle.refKind)) || + !IMPERATIVE_HANDLE_STATUSES.has(handle.status) || + !OBLIGATION_STATUSES.has(handle.factoryPurity) + ) { + addFailure(failures, handle.id, "An imperative handle has an invalid owner or status"); + } + if ( + factoryCallback?.ownerId !== handle.ownerId || + factoryCallback.kind !== ReactSemanticCallbackKind.ImperativeHandleFactory || + factoryCallback.phase !== ReactExecutionPhase.ImperativeHandle + ) { + addFailure(failures, handle.id, "An imperative handle has an invalid factory callback"); + } + if ( + new Set(handle.methodIds).size !== handle.methodIds.length || + methods.length !== handle.methodIds.length + ) { + addFailure(failures, handle.id, "An imperative handle has invalid method links"); + } + if ( + new Set(handle.bindingIds).size !== handle.bindingIds.length || + bindings.length !== handle.bindingIds.length + ) { + addFailure(failures, handle.id, "An imperative handle has invalid binding links"); + } + const hasMissingDependency = + handle.dependencyMode === ReactEffectDependencyMode.Inline && + handle.captures.some( + (capture) => + !handle.dependencies.some( + (dependency) => + dependency === capture || + capture.startsWith(`${dependency}.`) || + dependency.startsWith(`${capture}.`), + ), + ); + if ( + (handle.status === ReactImperativeHandleStatus.ImpureFactory && + handle.factoryPurity !== ReactObligationStatus.Violated) || + (handle.factoryPurity === ReactObligationStatus.Violated && + handle.status !== ReactImperativeHandleStatus.ImpureFactory) + ) { + addFailure(failures, handle.id, "An imperative handle factory purity is inconsistent"); + } + if ( + (handle.status === ReactImperativeHandleStatus.MissingDependency && !hasMissingDependency) || + (handle.status !== ReactImperativeHandleStatus.ImpureFactory && hasMissingDependency) !== + (handle.status === ReactImperativeHandleStatus.MissingDependency) + ) { + addFailure(failures, handle.id, "An imperative handle dependency status is inconsistent"); + } + if ( + handle.status === ReactImperativeHandleStatus.Resolved && + handle.dependencyMode === ReactEffectDependencyMode.Opaque + ) { + addFailure(failures, handle.id, "A resolved imperative handle has an opaque dependency list"); + } + const expectedFactoryComplete = + Boolean(factoryCallback) && + handle.dependencyMode !== ReactEffectDependencyMode.Opaque && + handle.factoryPurity !== ReactObligationStatus.Unknown; + if (handle.factoryComplete !== expectedFactoryComplete) { + addFailure(failures, handle.id, "An imperative handle factory flag is inconsistent"); + } + const expectedSourceComplete = + handle.factoryComplete && + handle.shapeComplete && + handle.targetComplete && + handle.bindingComplete && + !owner?.canBeRenderRoot; + if (handle.sourceComplete !== expectedSourceComplete) { + addFailure(failures, handle.id, "An imperative handle source flag is inconsistent"); + } + const expectedComplete = + expectedSourceComplete && handle.status === ReactImperativeHandleStatus.Resolved; + if (handle.complete !== expectedComplete) { + addFailure(failures, handle.id, "An imperative handle completeness flag is inconsistent"); + } + } + for (const unit of report.graph.units) { + if ( + unit.canBeRenderRoot && + unit.kind !== ReactUnitKind.Component && + unit.kind !== ReactUnitKind.ClassComponent + ) { + addFailure(failures, unit.id, "A non-component unit is marked as a render root"); + } + if ( + unit.kind === ReactUnitKind.ClassComponent && + unit.classComponentBase !== ReactClassComponentBase.Component && + unit.classComponentBase !== ReactClassComponentBase.PureComponent + ) { + addFailure(failures, unit.id, "A class component has no supported React base"); + } + if (unit.kind !== ReactUnitKind.ClassComponent && unit.classComponentBase !== null) { + addFailure(failures, unit.id, "A non-class unit declares a React class base"); + } + } + + for (const root of report.graph.hydrationRoots) { + if (!HYDRATION_ROOT_KINDS.has(root.kind)) { + addFailure(failures, root.id, "A hydration root has an unknown kind"); + } + if (!HYDRATION_PREFIX_STATUSES.has(root.prefixStatus)) { + addFailure(failures, root.id, "A hydration root has an unknown prefix status"); + } + if (!HYDRATION_ROOT_EXECUTION_STATUSES.has(root.executionStatus)) { + addFailure(failures, root.id, "A hydration root has an unknown execution status"); + } + let expectedKind: ReactHydrationRootKind | null = null; + if (root.apiName === "hydrateRoot") { + expectedKind = ReactHydrationRootKind.Client; + } else if (REACT_HYDRATABLE_SERVER_API_NAMES.has(root.apiName)) { + expectedKind = ReactHydrationRootKind.ServerInteractive; + } else if (REACT_STATIC_SERVER_API_NAMES.has(root.apiName)) { + expectedKind = ReactHydrationRootKind.ServerStatic; + } + if (root.kind !== expectedKind) { + addFailure(failures, root.id, "A hydration root API and kind are inconsistent"); + } + if (root.targetId !== null && !unitIds.has(root.targetId)) { + addFailure(failures, root.id, "A hydration root has an unknown target unit"); + } + if ( + (root.prefixStatus === ReactHydrationPrefixStatus.Known && root.identifierPrefix === null) || + (root.prefixStatus === ReactHydrationPrefixStatus.Unknown && root.identifierPrefix !== null) + ) { + addFailure(failures, root.id, "A hydration root prefix certificate is inconsistent"); + } + const expectedSourceComplete = + root.targetId !== null && + root.prefixStatus === ReactHydrationPrefixStatus.Known && + root.executionStatus === ReactHydrationRootExecutionStatus.Module; + if ( + root.sourceComplete !== expectedSourceComplete || + root.complete !== expectedSourceComplete + ) { + addFailure(failures, root.id, "A hydration root completeness flag is inconsistent"); + } + } + for (const hazard of report.graph.hydrationHazards) { + if (!unitIds.has(hazard.ownerId)) { + addFailure(failures, hazard.id, "A hydration hazard has an unknown owner unit"); + } + if (!HYDRATION_HAZARD_KINDS.has(hazard.kind)) { + addFailure(failures, hazard.id, "A hydration hazard has an unknown kind"); + } + } + const hydrationFactsByOwnerId = new Map(); + for (const hydration of report.graph.hydrations) { + const owner = unitsById.get(hydration.ownerId); + if (!owner) { + addFailure(failures, hydration.id, "A hydration certificate has an unknown owner unit"); + continue; + } + if (!HYDRATION_STATUSES.has(hydration.status)) { + addFailure(failures, hydration.id, "A hydration certificate has an unknown status"); + } + const rootCollections = [ + { + ids: hydration.clientRootIds, + kind: ReactHydrationRootKind.Client, + }, + { + ids: hydration.interactiveServerRootIds, + kind: ReactHydrationRootKind.ServerInteractive, + }, + { + ids: hydration.staticServerRootIds, + kind: ReactHydrationRootKind.ServerStatic, + }, + ]; + for (const rootCollection of rootCollections) { + if (new Set(rootCollection.ids).size !== rootCollection.ids.length) { + addFailure(failures, hydration.id, "A hydration certificate repeats a root"); + } + for (const rootId of rootCollection.ids) { + if (hydrationRootsById.get(rootId)?.kind !== rootCollection.kind) { + addFailure(failures, hydration.id, "A hydration certificate has an invalid root kind"); + } + } + } + if (new Set(hydration.hazardIds).size !== hydration.hazardIds.length) { + addFailure(failures, hydration.id, "A hydration certificate repeats a hazard"); + } + if ( + hydration.hazardIds.some( + (hazardId) => hydrationHazardsById.get(hazardId)?.ownerId !== hydration.ownerId, + ) + ) { + addFailure(failures, hydration.id, "A hydration certificate has an invalid hazard"); + } + const expected = expectedHydrationProtocol(owner, report, hydrationSourcesByUnit); + const actualCollections = [ + [hydration.clientRootIds, expected.clientRootIds], + [hydration.interactiveServerRootIds, expected.interactiveServerRootIds], + [hydration.staticServerRootIds, expected.staticServerRootIds], + [hydration.hazardIds, expected.hazardIds], + ]; + if ( + actualCollections.some( + ([actual, expectedIds]) => + actual.length !== expectedIds.length || + expectedIds.some((expectedId) => !actual.includes(expectedId)), + ) + ) { + addFailure(failures, hydration.id, "A hydration certificate has inconsistent sources"); + } + if ( + hydration.status !== expected.status || + hydration.sourceComplete !== expected.sourceComplete || + hydration.complete !== expected.complete + ) { + addFailure(failures, hydration.id, "A hydration certificate has inconsistent verdict facts"); + } + const ownerHydrations = hydrationFactsByOwnerId.get(hydration.ownerId) ?? []; + hydrationFactsByOwnerId.set(hydration.ownerId, [...ownerHydrations, hydration]); + } + for (const unit of report.graph.units) { + if (hydrationFactsByOwnerId.get(unit.id)?.length !== 1) { + addFailure(failures, unit.id, "A semantic unit has no unique hydration certificate"); + } + } + + for (const edge of report.graph.edges) { + if (!unitIds.has(edge.sourceId)) { + addFailure(failures, edge.sourceId, "A semantic edge has an unknown source unit"); + } + if (edge.kind === ReactSemanticEdgeKind.RendersComponent && !unitIds.has(edge.targetId)) { + addFailure(failures, edge.targetId, "A render edge has an unknown target unit"); + } + } + for (const render of report.graph.renders) { + if (!unitIds.has(render.ownerId) || !unitIds.has(render.targetId)) { + addFailure(failures, render.id, "A render has an unknown semantic unit"); + } + if (new Set(render.activeFormIds).size !== render.activeFormIds.length) { + addFailure(failures, render.id, "A render repeats an active form"); + } + if (new Set(render.activeContextProviderIds).size !== render.activeContextProviderIds.length) { + addFailure(failures, render.id, "A render repeats an active context provider"); + } + if ( + render.topologyOwnerIds.length === 0 || + new Set(render.topologyOwnerIds).size !== render.topologyOwnerIds.length || + render.topologyOwnerIds.some((ownerId) => !unitIds.has(ownerId)) + ) { + addFailure(failures, render.id, "A render has inconsistent topology owners"); + } + const sourceRender = render.sourceRenderId ? rendersById.get(render.sourceRenderId) : null; + if ( + render.kind === ReactSemanticRenderKind.Direct && + (render.sourceRenderId !== null || + render.containerRenderId !== null || + render.slotPropName !== null || + !render.contextTopologyComplete || + !render.formTopologyComplete) + ) { + addFailure(failures, render.id, "A direct render has slot-only topology facts"); + } + if ( + render.kind !== ReactSemanticRenderKind.Slot && + (render.topologyOwnerIds.length !== 1 || render.topologyOwnerIds[0] !== render.ownerId) + ) { + addFailure(failures, render.id, "A non-slot render has inconsistent topology ownership"); + } + if (render.kind === ReactSemanticRenderKind.SlotInput && render.sourceRenderId !== null) { + addFailure(failures, render.id, "A slot input has inconsistent source facts"); + } + if (render.kind === ReactSemanticRenderKind.Slot) { + if ( + !sourceRender || + sourceRender.kind !== ReactSemanticRenderKind.SlotInput || + render.slotPropName !== sourceRender.slotPropName || + render.containerRenderId !== sourceRender.containerRenderId || + !render.topologyOwnerIds.includes(render.ownerId) || + !render.topologyOwnerIds.includes(sourceRender.ownerId) + ) { + addFailure(failures, render.id, "A slot render has an inconsistent source render"); + } + } else if (render.sourceRenderId !== null) { + addFailure(failures, render.id, "A non-slot render references a source render"); + } + const allowedTopologyOwnerIds = new Set(render.topologyOwnerIds); + for (const providerId of render.activeContextProviderIds) { + const provider = providersById.get(providerId); + if (!provider) { + addFailure(failures, render.id, "A render has an unknown active context provider"); + } else if (!allowedTopologyOwnerIds.has(provider.ownerId)) { + addFailure( + failures, + render.id, + "A render has an active context provider owned by an unrelated unit", + ); + } + } + for (const formId of render.activeFormIds) { + const form = formsById.get(formId); + if (!form) { + addFailure(failures, render.id, "A render has an unknown active form"); + } else if (!allowedTopologyOwnerIds.has(form.ownerId)) { + addFailure(failures, render.id, "A render has an active form owned by an unrelated unit"); + } + } + } + const slotFlowsBySourceRenderId = new Map(); + for (const slotFlow of report.graph.slotFlows) { + const sourceRender = rendersById.get(slotFlow.sourceRenderId); + const containerRender = slotFlow.containerRenderId + ? rendersById.get(slotFlow.containerRenderId) + : null; + const slotRenders = report.graph.renders.filter( + (render) => render.sourceRenderId === slotFlow.sourceRenderId, + ); + if ( + !sourceRender || + sourceRender.kind !== ReactSemanticRenderKind.SlotInput || + sourceRender.ownerId !== slotFlow.ownerId + ) { + addFailure(failures, slotFlow.id, "A slot flow has an inconsistent source render"); + } + if (slotFlow.containerRenderId && !containerRender) { + addFailure(failures, slotFlow.id, "A slot flow has an unknown container render"); + } + if (slotFlow.placementComplete && (!containerRender || !slotFlow.propName)) { + addFailure(failures, slotFlow.id, "A complete slot flow has no project-local placement"); + } + if ( + sourceRender && + (slotFlow.containerRenderId !== sourceRender.containerRenderId || + slotFlow.propName !== sourceRender.slotPropName || + slotFlow.complete !== (slotFlow.sourceComplete && slotFlow.placementComplete) || + slotFlow.complete !== sourceRender.contextTopologyComplete || + slotFlow.complete !== sourceRender.formTopologyComplete) + ) { + addFailure(failures, slotFlow.id, "A slot flow disagrees with its source certificate"); + } + if (new Set(slotFlow.renderIds).size !== slotFlow.renderIds.length) { + addFailure(failures, slotFlow.id, "A slot flow repeats an effective render"); + } + if ( + slotFlow.renderIds.length !== slotRenders.length || + slotRenders.some((render) => !slotFlow.renderIds.includes(render.id)) + ) { + addFailure(failures, slotFlow.id, "A slot flow has an inconsistent effective render set"); + } + for (const renderId of slotFlow.renderIds) { + const slotRender = rendersById.get(renderId); + if ( + slotRender?.kind !== ReactSemanticRenderKind.Slot || + !slotRender.contextTopologyComplete || + !slotRender.formTopologyComplete + ) { + addFailure(failures, slotFlow.id, "A slot flow references an invalid effective render"); + } + } + const sourceSlotFlows = slotFlowsBySourceRenderId.get(slotFlow.sourceRenderId) ?? []; + slotFlowsBySourceRenderId.set(slotFlow.sourceRenderId, [...sourceSlotFlows, slotFlow]); + } + for (const render of report.graph.renders) { + if ( + render.kind === ReactSemanticRenderKind.SlotInput && + slotFlowsBySourceRenderId.get(render.id)?.length !== 1 + ) { + addFailure(failures, render.id, "A slot input has no unique slot-flow certificate"); + } + } + for (const effect of report.graph.effects) { + if (!unitIds.has(effect.ownerId)) { + addFailure(failures, effect.id, "An Effect has an unknown owner unit"); + } + if (effect.setupCallbackId && !callbackIds.has(effect.setupCallbackId)) { + addFailure(failures, effect.id, "An Effect has an unknown setup callback"); + } + for (const cleanupCallbackId of effect.cleanupCallbackIds) { + if (!callbackIds.has(cleanupCallbackId)) { + addFailure(failures, effect.id, "An Effect has an unknown cleanup callback"); + } + } + } + for (const externalStore of report.graph.externalStores) { + const isCertifiedCallbackSource = (callbackId: string, phase: ReactExecutionPhase): boolean => { + const callback = callbacksById.get(callbackId); + return Boolean( + callback && + (callback.ownerId === externalStore.ownerId || + report.graph.callbackPropFlows.some( + (propFlow) => + propFlow.targetOwnerId === externalStore.ownerId && + propFlow.phase === phase && + propFlow.complete && + propFlow.callbackIds.includes(callbackId), + )), + ); + }; + if (!unitIds.has(externalStore.ownerId)) { + addFailure(failures, externalStore.id, "An external store has an unknown owner unit"); + } + if (externalStore.subscribeComplete && externalStore.subscribeCallbackIds.length === 0) { + addFailure( + failures, + externalStore.id, + "A complete external-store subscription has no callback", + ); + } + if (externalStore.snapshotComplete && externalStore.snapshotCallbackIds.length === 0) { + addFailure(failures, externalStore.id, "A complete external-store snapshot has no callback"); + } + if ( + externalStore.serverSnapshotProvided && + externalStore.serverSnapshotComplete && + externalStore.serverSnapshotCallbackIds.length === 0 + ) { + addFailure( + failures, + externalStore.id, + "A complete external-store server snapshot has no callback", + ); + } + if ( + !externalStore.serverSnapshotProvided && + externalStore.serverSnapshotCallbackIds.length > 0 + ) { + addFailure( + failures, + externalStore.id, + "An omitted external-store server snapshot has callback facts", + ); + } + for (const callbackId of externalStore.subscribeCallbackIds) { + const callback = callbacksById.get(callbackId); + if (!callback) { + addFailure( + failures, + externalStore.id, + "An external-store subscription has an unknown callback", + ); + } else if (callback.phase !== ReactExecutionPhase.ExternalStoreSubscription) { + addFailure( + failures, + externalStore.id, + "An external-store subscription callback has the wrong execution phase", + ); + } else if ( + !isCertifiedCallbackSource(callbackId, ReactExecutionPhase.ExternalStoreSubscription) + ) { + addFailure( + failures, + externalStore.id, + "An external-store subscription callback has no certified owner channel", + ); + } + } + for (const callbackId of externalStore.snapshotCallbackIds) { + const callback = callbacksById.get(callbackId); + if (!callback) { + addFailure( + failures, + externalStore.id, + "An external-store snapshot has an unknown callback", + ); + } else if (callback.phase !== ReactExecutionPhase.Render) { + addFailure( + failures, + externalStore.id, + "An external-store snapshot callback has the wrong execution phase", + ); + } else if (!isCertifiedCallbackSource(callbackId, ReactExecutionPhase.Render)) { + addFailure( + failures, + externalStore.id, + "An external-store snapshot callback has no certified owner channel", + ); + } + } + for (const callbackId of externalStore.serverSnapshotCallbackIds) { + const callback = callbacksById.get(callbackId); + if (!callback) { + addFailure( + failures, + externalStore.id, + "An external-store server snapshot has an unknown callback", + ); + } else if (callback.phase !== ReactExecutionPhase.ServerRender) { + addFailure( + failures, + externalStore.id, + "An external-store server snapshot callback has the wrong execution phase", + ); + } else if (!isCertifiedCallbackSource(callbackId, ReactExecutionPhase.ServerRender)) { + addFailure( + failures, + externalStore.id, + "An external-store server snapshot callback has no certified owner channel", + ); + } + } + } + for (const task of report.graph.asyncTasks) { + if (!unitIds.has(task.ownerId)) { + addFailure(failures, task.id, "An async task has an unknown owner unit"); + } + if (!effectIds.has(task.effectId)) { + addFailure(failures, task.id, "An async task has an unknown source Effect"); + } + } + for (const callableRef of report.graph.callableRefs) { + if (!unitIds.has(callableRef.ownerId)) { + addFailure(failures, callableRef.id, "A callable ref has an unknown owner unit"); + } + const declaredInvocationCallbackIds = new Set(callableRef.invocationCallbackIds); + const invocationCallbackIds = new Set(); + const matchedInvocationLocations = new Set(); + for (const invocationCallId of callableRef.invocationCallIds) { + const functionCall = functionCallsById.get(invocationCallId); + if (!functionCall) { + addFailure(failures, callableRef.id, "A callable ref has an unknown invocation call"); + continue; + } + invocationCallbackIds.add(functionCall.rootCallbackId); + const invocationLocationIndex = callableRef.invocationLocations.findIndex((location) => + areProofLocationsEqual(location, functionCall.location), + ); + if (invocationLocationIndex >= 0) matchedInvocationLocations.add(invocationLocationIndex); + if (invocationLocationIndex < 0 || functionCall.sourcePropertyPath.at(-1) !== "current") { + addFailure( + failures, + callableRef.id, + "A callable ref invocation call does not match its source location and ref-current path", + ); + } + if (!declaredInvocationCallbackIds.has(functionCall.rootCallbackId)) { + addFailure( + failures, + callableRef.id, + "A callable ref invocation call has an undeclared root callback", + ); + } + } + if (matchedInvocationLocations.size !== callableRef.invocationLocations.length) { + addFailure( + failures, + callableRef.id, + "A callable ref invocation location has no serialized call edge", + ); + } + for (const invocationCallbackId of callableRef.invocationCallbackIds) { + const callback = callbacksById.get(invocationCallbackId); + if (!callback) { + addFailure(failures, callableRef.id, "A callable ref has an unknown invocation callback"); + } else if (callableRef.complete && callback.phase !== ReactExecutionPhase.Event) { + addFailure( + failures, + callableRef.id, + "A callable ref invocation is outside the modeled event phase", + ); + } + if (!invocationCallbackIds.has(invocationCallbackId)) { + addFailure( + failures, + callableRef.id, + "A callable ref invocation callback has no ref-current call edge", + ); + } + } + if ( + callableRef.complete && + (!callableRef.sourceComplete || + callableRef.freshness !== ReactCallableRefFreshness.EventSynchronized || + callableRef.updateHookName !== "useLayoutEffect" || + !callableRef.updateLocation || + callableRef.invocationCallIds.length === 0 || + callableRef.invocationCallbackIds.length === 0 || + callableRef.invocationLocations.length === 0) + ) { + addFailure( + failures, + callableRef.id, + "A complete callable ref lacks a layout-synchronized event certificate", + ); + } + if ( + callableRef.freshness === ReactCallableRefFreshness.EventSynchronized && + !callableRef.complete + ) { + addFailure( + failures, + callableRef.id, + "An event-synchronized callable ref is not marked complete", + ); + } + if ( + callableRef.freshness === ReactCallableRefFreshness.PassiveLag && + callableRef.updateHookName !== "useEffect" + ) { + addFailure( + failures, + callableRef.id, + "A passive-lag callable ref is not updated by useEffect", + ); + } + } + for (const scheduler of report.graph.schedulers) { + if (!unitIds.has(scheduler.ownerId)) { + addFailure(failures, scheduler.id, "A scheduler has an unknown owner unit"); + } + if (scheduler.effectId) { + const effect = effectsById.get(scheduler.effectId); + if (!effect || effect.ownerId !== scheduler.ownerId) { + addFailure(failures, scheduler.id, "A scheduler has an unknown or cross-owner Effect"); + } else if (effect.setupCallbackId !== scheduler.registrationCallbackId) { + addFailure( + failures, + scheduler.id, + "A scheduler registration is not linked to its Effect setup callback", + ); + } + } else { + const owner = unitsById.get(scheduler.ownerId); + const registrationCallback = callbacksById.get(scheduler.registrationCallbackId); + if ( + owner?.kind !== ReactUnitKind.ClassComponent || + registrationCallback?.ownerId !== scheduler.ownerId || + registrationCallback.kind !== ReactSemanticCallbackKind.ClassMount || + registrationCallback.phase !== ReactExecutionPhase.ClassMount + ) { + addFailure(failures, scheduler.id, "A class scheduler is not linked to its mount callback"); + } + } + for (const callbackId of scheduler.callbackIds) { + const callback = callbacksById.get(callbackId); + if (!callback) { + addFailure(failures, scheduler.id, "A scheduler has an unknown deferred callback"); + } else if ( + callback.kind !== ReactSemanticCallbackKind.ScheduledCallback || + callback.phase !== ReactExecutionPhase.Deferred + ) { + addFailure( + failures, + scheduler.id, + "A scheduler callback has the wrong kind or execution phase", + ); + } + } + const expectedComplete = + scheduler.sourceComplete && + scheduler.callbackComplete && + scheduler.cancellationStatus === ReactSchedulerCancellationStatus.Guaranteed && + scheduler.cancellationLocations.length > 0 && + scheduler.callbackIds.length > 0 && + scheduler.phase === ReactExecutionPhase.Deferred; + if (scheduler.complete !== expectedComplete) { + addFailure( + failures, + scheduler.id, + "A scheduler completeness flag does not match its deferred lifetime certificate", + ); + } + if (scheduler.callbackComplete && scheduler.callbackIds.length === 0) { + addFailure(failures, scheduler.id, "A complete scheduler callback set is empty"); + } + } + for (const resource of report.graph.resources) { + if (!unitIds.has(resource.ownerId)) { + addFailure(failures, resource.id, "An Effect resource has an unknown owner unit"); + } + if (resource.effectId) { + const effect = effectsById.get(resource.effectId); + if (!effect || effect.ownerId !== resource.ownerId) { + addFailure( + failures, + resource.id, + "A lifecycle resource has an unknown or cross-owner Effect", + ); + } else if (effect.setupCallbackId !== resource.acquisitionCallbackId) { + addFailure( + failures, + resource.id, + "A lifecycle resource acquisition is not linked to its setup callback", + ); + } + } else { + const owner = unitsById.get(resource.ownerId); + const acquisitionCallback = callbacksById.get(resource.acquisitionCallbackId); + if ( + owner?.kind !== ReactUnitKind.ClassComponent || + acquisitionCallback?.ownerId !== resource.ownerId || + acquisitionCallback.kind !== ReactSemanticCallbackKind.ClassMount || + acquisitionCallback.phase !== ReactExecutionPhase.ClassMount + ) { + addFailure( + failures, + resource.id, + "A class resource acquisition is not linked to its mount callback", + ); + } + } + for (const callbackId of resource.callbackIds) { + const callback = callbacksById.get(callbackId); + if (!callback) { + addFailure(failures, resource.id, "An Effect resource has an unknown deferred callback"); + } else if ( + !( + (callback.kind === ReactSemanticCallbackKind.ResourceCallback && + callback.phase === ReactExecutionPhase.Deferred) || + (callback.kind === ReactSemanticCallbackKind.EffectEvent && + callback.phase === ReactExecutionPhase.EffectEvent) + ) + ) { + addFailure( + failures, + resource.id, + "An Effect resource callback has the wrong kind or execution phase", + ); + } + if ( + callback && + callback.ownerId !== resource.ownerId && + !report.graph.callbackPropFlows.some( + (propFlow) => + propFlow.targetOwnerId === resource.ownerId && + propFlow.phase === ReactExecutionPhase.Deferred && + propFlow.complete && + propFlow.callbackIds.includes(callbackId), + ) + ) { + addFailure( + failures, + resource.id, + "An Effect resource callback has no certified owner channel", + ); + } + } + if ( + resource.activationLocations.length === 0 || + !resource.activationLocations.some((activationLocation) => + areProofLocationsEqual(activationLocation, resource.location), + ) + ) { + addFailure( + failures, + resource.id, + "An Effect resource has no activation location matching its primary location", + ); + } + const activationLocationKeys = resource.activationLocations.map( + (location) => `${location.filePath}:${location.line}:${location.column}`, + ); + if (new Set(activationLocationKeys).size !== activationLocationKeys.length) { + addFailure(failures, resource.id, "An Effect resource repeats an activation location"); + } + if (resource.kind === ReactEffectResourceKind.Observer) { + addFailure(failures, resource.id, "An Effect resource has an ambiguous observer kind"); + } + if ( + resource.disposalStatus === ReactEffectResourceDisposalStatus.Guaranteed && + resource.disposalLocations.length === 0 + ) { + addFailure(failures, resource.id, "A guaranteed Effect resource disposal has no evidence"); + } + const expectedComplete = + resource.sourceComplete && + resource.callbackComplete && + resource.disposalStatus === ReactEffectResourceDisposalStatus.Guaranteed && + resource.disposalLocations.length > 0 && + resource.callbackIds.length > 0 && + resource.phase === ReactExecutionPhase.Deferred; + if (resource.complete !== expectedComplete) { + addFailure( + failures, + resource.id, + "An Effect resource completeness flag does not match its lifetime certificate", + ); + } + if (resource.callbackComplete && resource.callbackIds.length === 0) { + addFailure(failures, resource.id, "A complete Effect resource callback set is empty"); + } + } + const schedulersById = new Map( + report.graph.schedulers.map((scheduler) => [scheduler.id, scheduler]), + ); + const resourcesById = new Map(report.graph.resources.map((resource) => [resource.id, resource])); + const transitionsById = new Map( + report.graph.classStateTransitions.map((transition) => [transition.id, transition]), + ); + const actionStatesById = new Map(report.graph.actionStates.map((state) => [state.id, state])); + for (const state of report.graph.actionStates) { + const owner = unitsById.get(state.ownerId); + if ( + !owner || + owner.kind === ReactUnitKind.ClassComponent || + owner.kind === ReactUnitKind.InvalidHookOwner + ) { + addFailure(failures, state.id, "An Action State hook has an unknown or invalid owner"); + } + if (!state.stateName || !state.dispatcherName) { + addFailure(failures, state.id, "An Action State hook has an unnamed tuple binding"); + } + if (!ACTION_STATE_REDUCER_STATUSES.has(state.reducerStatus)) { + addFailure(failures, state.id, "An Action State hook has an invalid reducer status"); + } + const reducerCallback = state.reducerCallbackId + ? callbacksById.get(state.reducerCallbackId) + : null; + if ( + (state.reducerStatus === ReactActionStateReducerStatus.Resolved && + !state.reducerCallbackId) || + (state.reducerStatus === ReactActionStateReducerStatus.Opaque && state.reducerCallbackId) || + (state.reducerCallbackId && + (reducerCallback?.ownerId !== state.ownerId || + reducerCallback.kind !== ReactSemanticCallbackKind.ActionStateReducer || + reducerCallback.phase !== ReactExecutionPhase.ActionStateReducer)) + ) { + addFailure(failures, state.id, "An Action State hook has an invalid reducer Action"); + } + const expectedSourceComplete = + state.reducerStatus === ReactActionStateReducerStatus.Resolved && Boolean(reducerCallback); + if (state.sourceComplete !== expectedSourceComplete) { + addFailure(failures, state.id, "An Action State source flag is inconsistent"); + } + if (state.complete !== expectedSourceComplete) { + addFailure(failures, state.id, "An Action State completeness flag is inconsistent"); + } + } + const completeTransitionCallbackIdsForActionState = new Set( + report.graph.transitionActions.flatMap((action) => + action.complete && action.actionCallbackId ? [action.actionCallbackId] : [], + ), + ); + for (const dispatch of report.graph.actionStateDispatches) { + const owner = unitsById.get(dispatch.ownerId); + const actionState = actionStatesById.get(dispatch.actionStateId); + if ( + !owner || + owner.kind === ReactUnitKind.ClassComponent || + owner.kind === ReactUnitKind.InvalidHookOwner + ) { + addFailure(failures, dispatch.id, "An Action State dispatch has an invalid owner"); + } + if (!actionState || actionState.ownerId !== dispatch.ownerId) { + addFailure(failures, dispatch.id, "An Action State dispatch has an invalid state binding"); + } + if (!ACTION_STATE_DISPATCH_KINDS.has(dispatch.kind)) { + addFailure(failures, dispatch.id, "An Action State dispatch has an invalid kind"); + } + if (!ACTION_STATE_DISPATCH_STATUSES.has(dispatch.status)) { + addFailure(failures, dispatch.id, "An Action State dispatch has an invalid status"); + } + if (new Set(dispatch.executionCallbackIds).size !== dispatch.executionCallbackIds.length) { + addFailure(failures, dispatch.id, "An Action State dispatch repeats an execution callback"); + } + const executionCallbacks = dispatch.executionCallbackIds.flatMap((callbackId) => { + const callback = callbacksById.get(callbackId); + if (!callback || callback.ownerId !== dispatch.ownerId) { + addFailure(failures, dispatch.id, "An Action State dispatch has an invalid callback"); + return []; + } + return [callback]; + }); + let expectedDispatchStatus = ReactActionStateDispatchStatus.Unknown; + if (dispatch.kind === ReactActionStateDispatchKind.Escape) { + expectedDispatchStatus = ReactActionStateDispatchStatus.SetterEscape; + } else if (dispatch.kind === ReactActionStateDispatchKind.ActionProp) { + const matchingFormAction = report.graph.formActions.find( + (formAction) => + formAction.ownerId === dispatch.ownerId && + formAction.complete && + areProofLocationsEqual(formAction.location, dispatch.location), + ); + if ( + matchingFormAction && + actionState?.reducerCallbackId && + matchingFormAction.actionCallbackIds.includes(actionState.reducerCallbackId) + ) { + expectedDispatchStatus = ReactActionStateDispatchStatus.Action; + } + } else if ( + executionCallbacks.some((callback) => callback.phase === ReactExecutionPhase.Render) + ) { + expectedDispatchStatus = ReactActionStateDispatchStatus.Render; + } else if ( + executionCallbacks.length > 0 && + executionCallbacks.every( + (callback) => + callback.phase === ReactExecutionPhase.FormAction || + callback.phase === ReactExecutionPhase.ActionStateReducer || + (callback.phase === ReactExecutionPhase.TransitionAction && + completeTransitionCallbackIdsForActionState.has(callback.id)), + ) + ) { + expectedDispatchStatus = ReactActionStateDispatchStatus.Action; + } else if ( + executionCallbacks.some( + (callback) => + callback.phase !== ReactExecutionPhase.FormAction && + callback.phase !== ReactExecutionPhase.ActionStateReducer && + callback.phase !== ReactExecutionPhase.TransitionAction, + ) + ) { + expectedDispatchStatus = ReactActionStateDispatchStatus.OutsideAction; + } + if (dispatch.status !== expectedDispatchStatus) { + addFailure(failures, dispatch.id, "An Action State dispatch status is inconsistent"); + } + const expectedSourceComplete = + Boolean(actionState?.complete) && + expectedDispatchStatus !== ReactActionStateDispatchStatus.SetterEscape && + expectedDispatchStatus !== ReactActionStateDispatchStatus.Unknown; + if (dispatch.sourceComplete !== expectedSourceComplete) { + addFailure(failures, dispatch.id, "An Action State dispatch source flag is inconsistent"); + } + const expectedComplete = + expectedSourceComplete && expectedDispatchStatus === ReactActionStateDispatchStatus.Action; + if (dispatch.complete !== expectedComplete) { + addFailure(failures, dispatch.id, "An Action State dispatch completeness is inconsistent"); + } + } + for (const transition of report.graph.hookStateTransitions) { + const owner = unitsById.get(transition.ownerId); + if ( + !owner || + owner.kind === ReactUnitKind.ClassComponent || + owner.kind === ReactUnitKind.InvalidHookOwner + ) { + addFailure( + failures, + transition.id, + "A Hook state transition has an unknown or invalid owner", + ); + } + if (!transition.stateName || !transition.setterName) { + addFailure(failures, transition.id, "A Hook state transition has an unnamed binding"); + } + if (!HOOK_STATE_UPDATER_STATUSES.has(transition.updaterStatus)) { + addFailure(failures, transition.id, "A Hook state transition has an invalid updater status"); + } + if (new Set(transition.executionCallbackIds).size !== transition.executionCallbackIds.length) { + addFailure(failures, transition.id, "A Hook state transition repeats an execution callback"); + } + for (const callbackId of transition.executionCallbackIds) { + const executionCallback = callbacksById.get(callbackId); + if (!executionCallback || executionCallback.ownerId !== transition.ownerId) { + addFailure( + failures, + transition.id, + "A Hook state transition has an invalid execution callback", + ); + } + } + const updaterCallback = transition.updaterCallbackId + ? callbacksById.get(transition.updaterCallbackId) + : null; + const updaterRequiresCallback = + transition.updaterStatus === ReactHookStateUpdaterStatus.Pure || + transition.updaterStatus === ReactHookStateUpdaterStatus.Impure; + const updaterForbidsCallback = + transition.updaterStatus === ReactHookStateUpdaterStatus.DirectValue || + transition.updaterStatus === ReactHookStateUpdaterStatus.SetterEscape; + if ( + (updaterRequiresCallback && !transition.updaterCallbackId) || + (updaterForbidsCallback && transition.updaterCallbackId) || + (transition.updaterCallbackId && + (updaterCallback?.ownerId !== transition.ownerId || + updaterCallback.kind !== ReactSemanticCallbackKind.HookStateUpdater || + updaterCallback.phase !== ReactExecutionPhase.StateTransition)) + ) { + addFailure(failures, transition.id, "A Hook state transition has an invalid updater"); + } + const expectedSourceComplete = + transition.executionCallbackIds.length > 0 && + transition.executionCallbackIds.every( + (callbackId) => + callbacksById.get(callbackId)?.phase !== ReactExecutionPhase.StateTransition, + ) && + transition.updaterStatus !== ReactHookStateUpdaterStatus.SetterEscape && + transition.updaterStatus !== ReactHookStateUpdaterStatus.Unknown; + if (transition.sourceComplete !== expectedSourceComplete) { + addFailure( + failures, + transition.id, + "A Hook state transition source flag does not match its modeled surface", + ); + } + const expectedComplete = + transition.sourceComplete && + (transition.updaterStatus === ReactHookStateUpdaterStatus.DirectValue || + transition.updaterStatus === ReactHookStateUpdaterStatus.Pure); + if (transition.complete !== expectedComplete) { + addFailure( + failures, + transition.id, + "A Hook state transition completeness flag does not match its certificate", + ); + } + } + const hookStateTransitionsById = new Map( + report.graph.hookStateTransitions.map((transition) => [transition.id, transition]), + ); + for (const comparator of report.graph.memoComparators) { + const owner = comparator.ownerId ? unitsById.get(comparator.ownerId) : null; + if ( + !comparator.ownerId && + !report.projectEvidence.some( + (evidence) => + evidence.description === "React.memo has an unresolved component target" && + areProofLocationsEqual(evidence.location, comparator.location), + ) + ) { + addFailure( + failures, + comparator.id, + "An unresolved memo comparator lacks project-level evidence", + ); + } + if ( + comparator.ownerId && + (!owner || + owner.kind === ReactUnitKind.ClassComponent || + owner.kind === ReactUnitKind.InvalidHookOwner) + ) { + addFailure(failures, comparator.id, "A memo comparator has an invalid owner unit"); + } + if ( + !MEMO_COMPARATOR_KINDS.has(comparator.kind) || + !MEMO_COMPARATOR_STATUSES.has(comparator.status) + ) { + addFailure(failures, comparator.id, "A memo comparator has an invalid protocol domain"); + } + if ( + (comparator.kind === ReactMemoComparatorKind.DefaultShallow && + comparator.comparatorLocation !== null) || + (comparator.kind === ReactMemoComparatorKind.Custom && comparator.comparatorLocation === null) + ) { + addFailure(failures, comparator.id, "A memo comparator has an inconsistent source kind"); + } + const observationPaths = comparator.observations.map((observation) => observation.path); + if ( + observationPaths.some((observationPath) => observationPath.length === 0) || + new Set(observationPaths).size !== observationPaths.length + ) { + addFailure(failures, comparator.id, "A memo comparator has invalid prop observations"); + } + const truePathIdentities = comparator.truePaths.map((truePath) => { + if ( + truePath.equalPropPaths.some((propPath) => propPath === "*") || + new Set(truePath.equalPropPaths).size !== truePath.equalPropPaths.length + ) { + addFailure(failures, comparator.id, "A memo true path has invalid prop equalities"); + } + return `${String(truePath.sourceComplete)}:${truePath.equalPropPaths.toSorted().join(",")}`; + }); + if (new Set(truePathIdentities).size !== truePathIdentities.length) { + addFailure(failures, comparator.id, "A memo comparator repeats a true return path"); + } + if ( + comparator.analysisComplete && + comparator.truePaths.some((truePath) => !truePath.sourceComplete) + ) { + addFailure(failures, comparator.id, "A memo comparator analysis flag is inconsistent"); + } + if ( + comparator.kind === ReactMemoComparatorKind.DefaultShallow && + (comparator.truePaths.length !== 1 || + comparator.truePaths[0]?.sourceComplete !== true || + comparator.truePaths[0]?.equalPropPaths.length !== 1 || + comparator.truePaths[0]?.equalPropPaths[0] !== "") + ) { + addFailure(failures, comparator.id, "A default memo comparator lacks shallow equality"); + } + const expectedStatus = expectedMemoComparatorStatus(comparator); + if (comparator.status !== expectedStatus) { + addFailure(failures, comparator.id, "A memo comparator status is inconsistent"); + } + const hasUniversalTruePaths = + comparator.truePaths.length > 0 && + comparator.truePaths.every( + (truePath) => truePath.sourceComplete && truePath.equalPropPaths.includes(""), + ); + const expectedSourceComplete = + comparator.ownerId !== null && + comparator.analysisComplete && + comparator.truePaths.every((truePath) => truePath.sourceComplete) && + (comparator.kind === ReactMemoComparatorKind.DefaultShallow || + comparator.observationComplete || + hasUniversalTruePaths); + if (comparator.sourceComplete !== expectedSourceComplete) { + addFailure(failures, comparator.id, "A memo comparator source flag is inconsistent"); + } + const expectedComplete = + expectedSourceComplete && expectedStatus === ReactMemoComparatorStatus.Equivalent; + if (comparator.complete !== expectedComplete) { + addFailure(failures, comparator.id, "A memo comparator completeness flag is inconsistent"); + } + } + for (const control of report.graph.hostControls) { + const owner = unitsById.get(control.ownerId); + if (!owner || owner.kind === ReactUnitKind.InvalidHookOwner) { + addFailure(failures, control.id, "A host control has an unknown or invalid owner"); + } + if ( + !HOST_CONTROL_KINDS.has(control.kind) || + !HOST_CONTROL_MUTABILITY_STATUSES.has(control.mutabilityStatus) || + !HOST_CONTROL_UPDATE_STATUSES.has(control.updateStatus) || + !HOST_CONTROL_VALUE_STATUSES.has(control.valueStatus) || + !HOST_CONTROL_STATUSES.has(control.status) + ) { + addFailure(failures, control.id, "A host control has an invalid protocol domain"); + } + const expectedControlledPropName = + control.kind === ReactHostControlKind.CheckableInput ? "checked" : "value"; + const expectedDefaultPropName = + control.kind === ReactHostControlKind.CheckableInput ? "defaultChecked" : "defaultValue"; + if ( + control.controlledPropName !== expectedControlledPropName || + control.defaultPropName !== expectedDefaultPropName + ) { + addFailure(failures, control.id, "A host control uses the wrong ownership props"); + } + if ( + (control.controlledPropPresent === false && + control.valueStatus !== ReactHostControlValueStatus.Absent) || + (control.controlledPropPresent === true && + control.valueStatus === ReactHostControlValueStatus.Absent) || + (control.controlledPropPresent === null && + control.valueStatus !== ReactHostControlValueStatus.Unknown) + ) { + addFailure(failures, control.id, "A host control value status contradicts prop presence"); + } + if (Boolean(control.stateName) !== Boolean(control.setterName)) { + addFailure(failures, control.id, "A host control has a partial state binding"); + } + if ((!control.controlledPropPresent || !control.stateName) && control.setterName) { + addFailure(failures, control.id, "A host control has a setter without controlled state"); + } + if (new Set(control.callbackIds).size !== control.callbackIds.length) { + addFailure(failures, control.id, "A host control repeats an event callback"); + } + for (const callbackId of control.callbackIds) { + const callback = callbacksById.get(callbackId); + if ( + callback?.ownerId !== control.ownerId || + callback.kind !== ReactSemanticCallbackKind.EventHandler || + callback.phase !== ReactExecutionPhase.Event + ) { + addFailure(failures, control.id, "A host control has an invalid change callback"); + } + } + if (new Set(control.transitionIds).size !== control.transitionIds.length) { + addFailure(failures, control.id, "A host control repeats a state transition"); + } + for (const transitionId of control.transitionIds) { + const transition = hookStateTransitionsById.get(transitionId); + if ( + transition?.ownerId !== control.ownerId || + transition.stateName !== control.stateName || + transition.setterName !== control.setterName || + transition.updaterStatus !== ReactHookStateUpdaterStatus.DirectValue + ) { + addFailure(failures, control.id, "A host control has an invalid backing-state transition"); + } + } + if ( + control.updateStatus === ReactHostControlUpdateStatus.Exact && + (!control.stateName || + control.callbackIds.length === 0 || + control.transitionIds.length === 0 || + control.transitionIds.some( + (transitionId) => !hookStateTransitionsById.get(transitionId)?.complete, + )) + ) { + addFailure(failures, control.id, "An exact host control update lacks a complete event link"); + } + if ( + control.controlledPropPresent === false && + control.updateStatus !== ReactHostControlUpdateStatus.NotRequired + ) { + addFailure(failures, control.id, "An uncontrolled host control requires an update"); + } + const expectedStatus = expectedHostControlProtocolStatus(control); + if (control.status !== expectedStatus) { + addFailure(failures, control.id, "A host control status is inconsistent"); + } + const expectedSourceComplete = + expectedStatus !== ReactHostControlStatus.Unknown && + control.valueStatus !== ReactHostControlValueStatus.Unknown && + control.updateStatus !== ReactHostControlUpdateStatus.Opaque; + if (control.sourceComplete !== expectedSourceComplete) { + addFailure(failures, control.id, "A host control source flag is inconsistent"); + } + const expectedComplete = + expectedSourceComplete && expectedStatus === ReactHostControlStatus.Resolved; + if (control.complete !== expectedComplete) { + addFailure(failures, control.id, "A host control completeness flag is inconsistent"); + } + } + const reducersById = new Map(report.graph.reducers.map((reducer) => [reducer.id, reducer])); + for (const reducer of report.graph.reducers) { + const owner = unitsById.get(reducer.ownerId); + const reducerCallback = reducer.reducerCallbackId + ? callbacksById.get(reducer.reducerCallbackId) + : null; + const initializerCallback = reducer.initializerCallbackId + ? callbacksById.get(reducer.initializerCallbackId) + : null; + if ( + !owner || + owner.kind === ReactUnitKind.ClassComponent || + owner.kind === ReactUnitKind.InvalidHookOwner + ) { + addFailure(failures, reducer.id, "A reducer has an unknown or invalid owner"); + } + if (!reducer.stateName || !reducer.dispatcherName) { + addFailure(failures, reducer.id, "A reducer has an unnamed state or dispatcher binding"); + } + if ( + !REDUCER_PURITY_STATUSES.has(reducer.reducerPurity) || + !REDUCER_PURITY_STATUSES.has(reducer.initializerPurity) || + !REDUCER_RETURN_STATUSES.has(reducer.reducerReturnStatus) || + !REDUCER_RETURN_STATUSES.has(reducer.initializerReturnStatus) + ) { + addFailure(failures, reducer.id, "A reducer has an invalid purity or return status"); + } + const hasValidReducerCallback = + reducerCallback?.ownerId === reducer.ownerId && + reducerCallback.kind === ReactSemanticCallbackKind.Reducer && + reducerCallback.phase === ReactExecutionPhase.StateTransition; + const hasValidInitializerCallback = + initializerCallback?.ownerId === reducer.ownerId && + initializerCallback.kind === ReactSemanticCallbackKind.ReducerInitializer && + initializerCallback.phase === ReactExecutionPhase.StateTransition; + if (reducer.reducerCallbackId && !hasValidReducerCallback) { + addFailure(failures, reducer.id, "A reducer has an invalid transition callback"); + } + if (reducer.initializerCallbackId && !hasValidInitializerCallback) { + addFailure(failures, reducer.id, "A reducer has an invalid initializer callback"); + } + if ( + (!reducer.reducerCallbackId && + (reducer.reducerPurity !== ReactReducerPurityStatus.Opaque || + reducer.reducerReturnStatus !== ReactReducerReturnStatus.Opaque)) || + reducer.reducerReturnStatus === ReactReducerReturnStatus.Absent + ) { + addFailure(failures, reducer.id, "A reducer callback status is inconsistent"); + } + const hasAbsentInitializer = + !reducer.initializerCallbackId && + reducer.initializerPurity === ReactReducerPurityStatus.Pure && + reducer.initializerReturnStatus === ReactReducerReturnStatus.Absent; + const hasOpaqueInitializer = + !reducer.initializerCallbackId && + reducer.initializerPurity === ReactReducerPurityStatus.Opaque && + reducer.initializerReturnStatus === ReactReducerReturnStatus.Opaque; + if (!hasAbsentInitializer && !hasOpaqueInitializer && !hasValidInitializerCallback) { + addFailure(failures, reducer.id, "A reducer initializer status is inconsistent"); + } + if ( + hasValidInitializerCallback && + reducer.initializerReturnStatus === ReactReducerReturnStatus.Absent + ) { + addFailure(failures, reducer.id, "A resolved reducer initializer cannot be absent"); + } + const expectedSourceComplete = + hasValidReducerCallback && + reducer.reducerPurity !== ReactReducerPurityStatus.Opaque && + reducer.reducerReturnStatus !== ReactReducerReturnStatus.Opaque && + (hasAbsentInitializer || + (hasValidInitializerCallback && + reducer.initializerPurity !== ReactReducerPurityStatus.Opaque && + reducer.initializerReturnStatus !== ReactReducerReturnStatus.Opaque)); + if (reducer.sourceComplete !== expectedSourceComplete) { + addFailure(failures, reducer.id, "A reducer source flag is inconsistent"); + } + const expectedComplete = + expectedSourceComplete && + reducer.reducerPurity === ReactReducerPurityStatus.Pure && + reducer.initializerPurity === ReactReducerPurityStatus.Pure && + reducer.reducerReturnStatus === ReactReducerReturnStatus.Total && + (reducer.initializerReturnStatus === ReactReducerReturnStatus.Absent || + reducer.initializerReturnStatus === ReactReducerReturnStatus.Total); + if (reducer.complete !== expectedComplete) { + addFailure(failures, reducer.id, "A reducer completeness flag is inconsistent"); + } + } + for (const dispatch of report.graph.reducerDispatches) { + const reducer = reducersById.get(dispatch.reducerId); + const executionCallbacks = dispatch.executionCallbackIds.flatMap((callbackId) => { + const callback = callbacksById.get(callbackId); + if (!callback || callback.ownerId !== dispatch.ownerId) { + addFailure(failures, dispatch.id, "A reducer dispatch has an invalid execution callback"); + return []; + } + return [callback]; + }); + if (!reducer || reducer.ownerId !== dispatch.ownerId) { + addFailure(failures, dispatch.id, "A reducer dispatch has an invalid reducer owner"); + } + if ( + !REDUCER_DISPATCH_KINDS.has(dispatch.kind) || + !REDUCER_DISPATCH_STATUSES.has(dispatch.status) + ) { + addFailure(failures, dispatch.id, "A reducer dispatch has an invalid kind or status"); + } + if (new Set(dispatch.executionCallbackIds).size !== dispatch.executionCallbackIds.length) { + addFailure(failures, dispatch.id, "A reducer dispatch repeats an execution callback"); + } + let expectedStatus = ReactReducerDispatchStatus.Unknown; + if (dispatch.kind === ReactReducerDispatchKind.Escape) { + expectedStatus = ReactReducerDispatchStatus.Escape; + } else if ( + executionCallbacks.some((callback) => callback.phase === ReactExecutionPhase.Render) + ) { + expectedStatus = ReactReducerDispatchStatus.Render; + } else if ( + executionCallbacks.some((callback) => callback.phase === ReactExecutionPhase.StateTransition) + ) { + expectedStatus = ReactReducerDispatchStatus.Reducer; + } else if (executionCallbacks.length > 0) { + expectedStatus = ReactReducerDispatchStatus.Owned; + } + if ( + dispatch.kind === ReactReducerDispatchKind.Escape && + dispatch.executionCallbackIds.length > 0 + ) { + addFailure(failures, dispatch.id, "An escaping reducer dispatch has execution callbacks"); + } + if (dispatch.status !== expectedStatus) { + addFailure(failures, dispatch.id, "A reducer dispatch status is inconsistent"); + } + const expectedSourceComplete = + Boolean(reducer?.complete) && + expectedStatus !== ReactReducerDispatchStatus.Escape && + expectedStatus !== ReactReducerDispatchStatus.Unknown; + if (dispatch.sourceComplete !== expectedSourceComplete) { + addFailure(failures, dispatch.id, "A reducer dispatch source flag is inconsistent"); + } + const expectedComplete = + expectedSourceComplete && expectedStatus === ReactReducerDispatchStatus.Owned; + if (dispatch.complete !== expectedComplete) { + addFailure(failures, dispatch.id, "A reducer dispatch completeness flag is inconsistent"); + } + } + for (const action of report.graph.formActions) { + const owner = unitsById.get(action.ownerId); + if (!owner || owner.kind === ReactUnitKind.InvalidHookOwner) { + addFailure(failures, action.id, "A Form Action has an unknown or invalid owner"); + } + if (!FORM_ACTION_KINDS.has(action.kind)) { + addFailure(failures, action.id, "A Form Action has an invalid control kind"); + } + if (!FORM_ACTION_STATUSES.has(action.status)) { + addFailure(failures, action.id, "A Form Action has an invalid status"); + } + let expectedKind: ReactFormActionKind | null = null; + if (action.propName === "action") { + expectedKind = ReactFormActionKind.Form; + } else if (action.propName === "formAction") { + expectedKind = ReactFormActionKind.Submitter; + } + if (!expectedKind || action.kind !== expectedKind) { + addFailure(failures, action.id, "A Form Action property contradicts its control kind"); + } + if (new Set(action.actionCallbackIds).size !== action.actionCallbackIds.length) { + addFailure(failures, action.id, "A Form Action repeats an Action callback"); + } + const hasValidCallbacks = action.actionCallbackIds.every((callbackId) => { + const callback = callbacksById.get(callbackId); + return Boolean( + callback && + ((callback.kind === ReactSemanticCallbackKind.FormAction && + callback.phase === ReactExecutionPhase.FormAction) || + (callback.kind === ReactSemanticCallbackKind.ActionStateReducer && + callback.phase === ReactExecutionPhase.ActionStateReducer)), + ); + }); + if (!hasValidCallbacks) { + addFailure(failures, action.id, "A Form Action has an invalid Action callback"); + } + if (action.callbackComplete && action.actionCallbackIds.length === 0) { + addFailure(failures, action.id, "A complete Form Action callback set is empty"); + } + const expectedSourceComplete = + action.callbackComplete && + hasValidCallbacks && + action.status !== ReactFormActionStatus.Opaque; + if (action.sourceComplete !== expectedSourceComplete) { + addFailure(failures, action.id, "A Form Action source flag contradicts its callback model"); + } + const expectedComplete = + action.sourceComplete && action.status === ReactFormActionStatus.Resolved; + if (action.complete !== expectedComplete) { + addFailure(failures, action.id, "A Form Action completeness flag is inconsistent"); + } + } + const optimisticStatesById = new Map( + report.graph.optimisticStates.map((state) => [state.id, state]), + ); + const completeTransitionCallbackIds = new Set( + report.graph.transitionActions.flatMap((action) => + action.complete && action.actionCallbackId ? [action.actionCallbackId] : [], + ), + ); + for (const state of report.graph.optimisticStates) { + const owner = unitsById.get(state.ownerId); + if ( + !owner || + owner.kind === ReactUnitKind.ClassComponent || + owner.kind === ReactUnitKind.InvalidHookOwner + ) { + addFailure(failures, state.id, "An optimistic state has an unknown or invalid owner"); + } + if (!state.stateName || !state.setterName) { + addFailure(failures, state.id, "An optimistic state has an unnamed tuple binding"); + } + if (!OPTIMISTIC_REDUCER_STATUSES.has(state.reducerStatus)) { + addFailure(failures, state.id, "An optimistic state has an invalid reducer status"); + } + const reducerCallback = state.reducerCallbackId + ? callbacksById.get(state.reducerCallbackId) + : null; + const reducerRequiresCallback = + state.reducerStatus === ReactOptimisticReducerStatus.Impure || + state.reducerStatus === ReactOptimisticReducerStatus.Pure; + if ( + (reducerRequiresCallback && !state.reducerCallbackId) || + (state.reducerStatus === ReactOptimisticReducerStatus.Absent && state.reducerCallbackId) || + (state.reducerCallbackId && + (reducerCallback?.ownerId !== state.ownerId || + reducerCallback.kind !== ReactSemanticCallbackKind.OptimisticReducer || + reducerCallback.phase !== ReactExecutionPhase.OptimisticReducer)) + ) { + addFailure(failures, state.id, "An optimistic state has an invalid reducer callback"); + } + const expectedSourceComplete = + state.reducerStatus === ReactOptimisticReducerStatus.Absent || + (reducerRequiresCallback && Boolean(reducerCallback)); + if (state.sourceComplete !== expectedSourceComplete) { + addFailure(failures, state.id, "An optimistic state source flag is inconsistent"); + } + const expectedComplete = + state.sourceComplete && + (state.reducerStatus === ReactOptimisticReducerStatus.Absent || + state.reducerStatus === ReactOptimisticReducerStatus.Pure); + if (state.complete !== expectedComplete) { + addFailure(failures, state.id, "An optimistic state completeness flag is inconsistent"); + } + } + for (const update of report.graph.optimisticUpdates) { + const owner = unitsById.get(update.ownerId); + const optimisticState = optimisticStatesById.get(update.optimisticStateId); + if ( + !owner || + owner.kind === ReactUnitKind.ClassComponent || + owner.kind === ReactUnitKind.InvalidHookOwner + ) { + addFailure(failures, update.id, "An optimistic update has an unknown or invalid owner"); + } + if (!optimisticState || optimisticState.ownerId !== update.ownerId) { + addFailure(failures, update.id, "An optimistic update has an invalid state binding"); + } + if (!HOOK_STATE_UPDATER_STATUSES.has(update.updaterStatus)) { + addFailure(failures, update.id, "An optimistic update has an invalid updater status"); + } + if (!OPTIMISTIC_ACTION_STATUSES.has(update.actionStatus)) { + addFailure(failures, update.id, "An optimistic update has an invalid Action status"); + } + if (new Set(update.executionCallbackIds).size !== update.executionCallbackIds.length) { + addFailure(failures, update.id, "An optimistic update repeats an execution callback"); + } + const executionCallbacks = update.executionCallbackIds.flatMap((callbackId) => { + const callback = callbacksById.get(callbackId); + if (!callback || callback.ownerId !== update.ownerId) { + addFailure(failures, update.id, "An optimistic update has an invalid execution callback"); + return []; + } + return [callback]; + }); + let expectedActionStatus = ReactOptimisticActionStatus.Unknown; + if (executionCallbacks.some((callback) => callback.phase === ReactExecutionPhase.Render)) { + expectedActionStatus = ReactOptimisticActionStatus.Render; + } else if ( + executionCallbacks.length > 0 && + executionCallbacks.every( + (callback) => + callback.phase === ReactExecutionPhase.FormAction || + callback.phase === ReactExecutionPhase.ActionStateReducer || + (callback.phase === ReactExecutionPhase.TransitionAction && + completeTransitionCallbackIds.has(callback.id)), + ) + ) { + expectedActionStatus = ReactOptimisticActionStatus.Action; + } else if ( + executionCallbacks.some( + (callback) => + callback.phase !== ReactExecutionPhase.FormAction && + callback.phase !== ReactExecutionPhase.ActionStateReducer && + callback.phase !== ReactExecutionPhase.TransitionAction, + ) + ) { + expectedActionStatus = ReactOptimisticActionStatus.OutsideAction; + } + if (update.actionStatus !== expectedActionStatus) { + addFailure(failures, update.id, "An optimistic update Action status is inconsistent"); + } + const updaterCallback = update.updaterCallbackId + ? callbacksById.get(update.updaterCallbackId) + : null; + const updaterRequiresCallback = + update.updaterStatus === ReactHookStateUpdaterStatus.Impure || + update.updaterStatus === ReactHookStateUpdaterStatus.Pure; + const updaterForbidsCallback = + update.updaterStatus === ReactHookStateUpdaterStatus.DirectValue || + update.updaterStatus === ReactHookStateUpdaterStatus.SetterEscape; + if ( + (updaterRequiresCallback && !update.updaterCallbackId) || + (updaterForbidsCallback && update.updaterCallbackId) || + (update.updaterCallbackId && + (updaterCallback?.ownerId !== update.ownerId || + updaterCallback.kind !== ReactSemanticCallbackKind.OptimisticUpdater || + updaterCallback.phase !== ReactExecutionPhase.OptimisticUpdater)) + ) { + addFailure(failures, update.id, "An optimistic update has an invalid updater callback"); + } + const expectedSourceComplete = + Boolean(optimisticState) && + expectedActionStatus !== ReactOptimisticActionStatus.Unknown && + update.updaterStatus !== ReactHookStateUpdaterStatus.SetterEscape && + update.updaterStatus !== ReactHookStateUpdaterStatus.Unknown; + if (update.sourceComplete !== expectedSourceComplete) { + addFailure(failures, update.id, "An optimistic update source flag is inconsistent"); + } + const expectedComplete = + update.sourceComplete && + expectedActionStatus === ReactOptimisticActionStatus.Action && + (update.updaterStatus === ReactHookStateUpdaterStatus.DirectValue || + update.updaterStatus === ReactHookStateUpdaterStatus.Pure); + if (update.complete !== expectedComplete) { + addFailure(failures, update.id, "An optimistic update completeness flag is inconsistent"); + } + } + for (const action of report.graph.transitionActions) { + const owner = unitsById.get(action.ownerId); + if (!owner || owner.kind === ReactUnitKind.InvalidHookOwner) { + addFailure(failures, action.id, "A Transition Action has an unknown or invalid owner"); + } + if (!TRANSITION_STARTER_KINDS.has(action.starterKind)) { + addFailure(failures, action.id, "A Transition Action has an invalid starter kind"); + } + if (!TRANSITION_ACTION_STATUSES.has(action.status)) { + addFailure(failures, action.id, "A Transition Action has an invalid status"); + } + if (new Set(action.executionCallbackIds).size !== action.executionCallbackIds.length) { + addFailure(failures, action.id, "A Transition Action repeats an execution callback"); + } + for (const callbackId of action.executionCallbackIds) { + const executionCallback = callbacksById.get(callbackId); + if (!executionCallback || executionCallback.ownerId !== action.ownerId) { + addFailure(failures, action.id, "A Transition Action has an invalid execution callback"); + } + } + const actionCallback = action.actionCallbackId + ? callbacksById.get(action.actionCallbackId) + : null; + const statusRequiresCallback = + action.status !== ReactTransitionActionStatus.Opaque && + action.status !== ReactTransitionActionStatus.StarterEscape; + if ( + (statusRequiresCallback && !action.actionCallbackId) || + (!statusRequiresCallback && action.actionCallbackId) || + (action.actionCallbackId && + (actionCallback?.ownerId !== action.ownerId || + actionCallback.kind !== ReactSemanticCallbackKind.TransitionAction || + actionCallback.phase !== ReactExecutionPhase.TransitionAction)) + ) { + addFailure(failures, action.id, "A Transition Action has an invalid Action callback"); + } + const controlledStateNames = new Set(action.controlledStateNames); + const unknownControlStateNames = new Set(action.unknownControlStateNames); + if ( + controlledStateNames.size !== action.controlledStateNames.length || + unknownControlStateNames.size !== action.unknownControlStateNames.length || + action.controlledStateNames.some((stateName) => !stateName) || + action.unknownControlStateNames.some((stateName) => !stateName) + ) { + addFailure(failures, action.id, "A Transition Action has invalid state-control evidence"); + } + if ( + (action.status === ReactTransitionActionStatus.ControlledInput) !== + action.controlledStateNames.length > 0 || + (action.status === ReactTransitionActionStatus.UnknownControl) !== + action.unknownControlStateNames.length > 0 + ) { + addFailure(failures, action.id, "A Transition Action status contradicts its state controls"); + } + if ( + action.status === ReactTransitionActionStatus.StarterEscape && + action.executionCallbackIds.length > 0 + ) { + addFailure(failures, action.id, "An escaped Transition starter has an execution callback"); + } + const hasValidExecutionRoot = + action.executionCallbackIds.length > 0 && + action.executionCallbackIds.every((callbackId) => { + const callback = callbacksById.get(callbackId); + return Boolean( + callback && + callback.ownerId === action.ownerId && + TRANSITION_ACTION_ORIGIN_PHASES.has(callback.phase), + ); + }); + const hasCompleteSourceStatus = + action.status === ReactTransitionActionStatus.Synchronous || + action.status === ReactTransitionActionStatus.ControlledInput; + const expectedSourceComplete = + hasValidExecutionRoot && Boolean(actionCallback) && hasCompleteSourceStatus; + if (action.sourceComplete !== expectedSourceComplete) { + addFailure( + failures, + action.id, + "A Transition Action source flag does not match its modeled surface", + ); + } + const expectedComplete = + action.sourceComplete && action.status === ReactTransitionActionStatus.Synchronous; + if (action.complete !== expectedComplete) { + addFailure( + failures, + action.id, + "A Transition Action completeness flag does not match its certificate", + ); + } + } + const stateWritesById = new Map( + report.graph.classStateWrites.map((stateWrite) => [stateWrite.id, stateWrite]), + ); + const constructionsById = new Map( + report.graph.classConstructions.map((construction) => [construction.id, construction]), + ); + const constructionOwnerIds = new Set(); + for (const construction of report.graph.classConstructions) { + const owner = unitsById.get(construction.ownerId); + if (owner?.kind !== ReactUnitKind.ClassComponent) { + addFailure( + failures, + construction.id, + "A class construction has an unknown or non-class owner", + ); + } + if (constructionOwnerIds.has(construction.ownerId)) { + addFailure( + failures, + construction.id, + "A class component has multiple construction certificates", + ); + } + constructionOwnerIds.add(construction.ownerId); + if (construction.phase !== ReactExecutionPhase.ClassConstruction) { + addFailure(failures, construction.id, "A class construction has an invalid phase"); + } + if ( + !Object.values(ReactClassStateInitializationKind).includes(construction.initializationKind) + ) { + addFailure( + failures, + construction.id, + "A class construction has an invalid initialization kind", + ); + } + if ( + !Object.values(ReactClassStateInitializationRequirement).includes( + construction.stateRequirement, + ) + ) { + addFailure( + failures, + construction.id, + "A class construction has an invalid state requirement", + ); + } + const hasInitialization = + construction.initializationKind !== ReactClassStateInitializationKind.None; + if (hasInitialization !== Boolean(construction.initializationLocation)) { + addFailure( + failures, + construction.id, + "A class construction initialization kind and location disagree", + ); + } + const hasMissingInitializationIssue = construction.issues.some( + (issue) => issue.kind === ReactClassConstructionIssueKind.MissingStateInitialization, + ); + if ( + (construction.initializationKind === ReactClassStateInitializationKind.None && + construction.stateRequirement !== ReactClassStateInitializationRequirement.None) !== + hasMissingInitializationIssue + ) { + addFailure( + failures, + construction.id, + "A class construction has inconsistent missing-state evidence", + ); + } + const hasMultipleInitializationIssue = construction.issues.some( + (issue) => issue.kind === ReactClassConstructionIssueKind.MultipleStateInitializations, + ); + if ( + (construction.initializationKind === ReactClassStateInitializationKind.Multiple) !== + hasMultipleInitializationIssue + ) { + addFailure( + failures, + construction.id, + "A class construction has inconsistent multiple-initialization evidence", + ); + } + if ( + construction.initializationKind === ReactClassStateInitializationKind.ConstructorAssignment && + !construction.constructorLocation + ) { + addFailure( + failures, + construction.id, + "A constructor state assignment has no constructor location", + ); + } + const issueIdentities = construction.issues.map( + (issue) => + `${issue.kind}:${issue.status}:${issue.location.filePath}:${issue.location.line}:${issue.location.column}`, + ); + if (new Set(issueIdentities).size !== issueIdentities.length) { + addFailure(failures, construction.id, "A class construction repeats an issue"); + } + for (const issue of construction.issues) { + if (!Object.values(ReactClassConstructionIssueKind).includes(issue.kind)) { + addFailure(failures, construction.id, "A class construction has an invalid issue kind"); + } + if (!Object.values(ReactClassConstructionIssueStatus).includes(issue.status)) { + addFailure(failures, construction.id, "A class construction has an invalid issue status"); + } + } + let expectedStatus = ReactClassConstructionStatus.Valid; + if ( + construction.issues.some( + (issue) => issue.status === ReactClassConstructionIssueStatus.Violated, + ) + ) { + expectedStatus = ReactClassConstructionStatus.Invalid; + } else if ( + construction.issues.some( + (issue) => issue.status === ReactClassConstructionIssueStatus.Unknown, + ) + ) { + expectedStatus = ReactClassConstructionStatus.Unknown; + } + if (construction.status !== expectedStatus) { + addFailure( + failures, + construction.id, + "A class construction status does not match its issues", + ); + } + if ( + construction.sourceComplete !== + (construction.status !== ReactClassConstructionStatus.Unknown) + ) { + addFailure( + failures, + construction.id, + "A class construction source flag does not match its status", + ); + } + if (construction.complete !== (construction.status === ReactClassConstructionStatus.Valid)) { + addFailure( + failures, + construction.id, + "A class construction completeness flag does not match its status", + ); + } + } + for (const stateWrite of report.graph.classStateWrites) { + if (!Object.values(ReactClassStateWriteKind).includes(stateWrite.kind)) { + addFailure(failures, stateWrite.id, "A class state write has an invalid write kind"); + } + if (!Object.values(ReactClassStateWriteStatus).includes(stateWrite.status)) { + addFailure(failures, stateWrite.id, "A class state write has an invalid ownership status"); + } + if ( + stateWrite.phase !== ReactExecutionPhase.ClassMount && + stateWrite.phase !== ReactExecutionPhase.ClassUnmount && + stateWrite.phase !== ReactExecutionPhase.ClassUpdate && + stateWrite.phase !== ReactExecutionPhase.Deferred && + stateWrite.phase !== ReactExecutionPhase.StateTransition + ) { + addFailure(failures, stateWrite.id, "A class state write has an invalid execution phase"); + } + const owner = unitsById.get(stateWrite.ownerId); + if (owner?.kind !== ReactUnitKind.ClassComponent) { + addFailure(failures, stateWrite.id, "A class state write has an unknown or non-class owner"); + } + const callback = callbacksById.get(stateWrite.callbackId); + let hasExpectedCallbackKind = false; + if (stateWrite.phase === ReactExecutionPhase.ClassMount) { + hasExpectedCallbackKind = callback?.kind === ReactSemanticCallbackKind.ClassMount; + } else if (stateWrite.phase === ReactExecutionPhase.ClassUnmount) { + hasExpectedCallbackKind = callback?.kind === ReactSemanticCallbackKind.ClassUnmount; + } else if (stateWrite.phase === ReactExecutionPhase.ClassUpdate) { + hasExpectedCallbackKind = callback?.kind === ReactSemanticCallbackKind.ClassUpdate; + } else if (stateWrite.phase === ReactExecutionPhase.StateTransition) { + hasExpectedCallbackKind = callback?.kind === ReactSemanticCallbackKind.ClassStateUpdater; + } else { + hasExpectedCallbackKind = + callback?.kind === ReactSemanticCallbackKind.ResourceCallback || + callback?.kind === ReactSemanticCallbackKind.ScheduledCallback; + } + if ( + callback?.ownerId !== stateWrite.ownerId || + !hasExpectedCallbackKind || + callback.phase !== stateWrite.phase + ) { + addFailure(failures, stateWrite.id, "A class state write has an invalid callback"); + } + const lifecycle = report.graph.classLifecycles.find( + (candidate) => candidate.ownerId === stateWrite.ownerId, + ); + let hasOwnershipLink = false; + if (stateWrite.phase === ReactExecutionPhase.ClassMount) { + hasOwnershipLink = lifecycle?.mountCallbackId === stateWrite.callbackId; + } else if (stateWrite.phase === ReactExecutionPhase.ClassUnmount) { + hasOwnershipLink = lifecycle?.unmountCallbackId === stateWrite.callbackId; + } else if (stateWrite.phase === ReactExecutionPhase.ClassUpdate) { + hasOwnershipLink = lifecycle?.updateCallbackId === stateWrite.callbackId; + } else if (stateWrite.phase === ReactExecutionPhase.StateTransition) { + hasOwnershipLink = Boolean( + lifecycle?.transitionIds.some( + (transitionId) => + transitionsById.get(transitionId)?.updaterCallbackId === stateWrite.callbackId, + ), + ); + } else { + hasOwnershipLink = Boolean( + lifecycle?.resourceIds.some((resourceId) => + resourcesById.get(resourceId)?.callbackIds.includes(stateWrite.callbackId), + ) || + lifecycle?.schedulerIds.some((schedulerId) => + schedulersById.get(schedulerId)?.callbackIds.includes(stateWrite.callbackId), + ), + ); + } + if (!hasOwnershipLink) { + addFailure( + failures, + stateWrite.id, + "A class state write is not linked to its owner callback", + ); + } + const expectedSourceComplete = stateWrite.status !== ReactClassStateWriteStatus.Unknown; + if (stateWrite.sourceComplete !== expectedSourceComplete) { + addFailure( + failures, + stateWrite.id, + "A class state write source flag does not match its ownership status", + ); + } + if (stateWrite.complete) { + addFailure(failures, stateWrite.id, "A forbidden or unknown class state write is complete"); + } + } + for (const transition of report.graph.classStateTransitions) { + const owner = unitsById.get(transition.ownerId); + if (owner?.kind !== ReactUnitKind.ClassComponent) { + addFailure( + failures, + transition.id, + "A class state transition has an unknown or non-class owner", + ); + } + const lifecycleCallback = callbacksById.get(transition.lifecycleCallbackId); + const expectedLifecycleKind = + transition.phase === ReactExecutionPhase.ClassMount + ? ReactSemanticCallbackKind.ClassMount + : ReactSemanticCallbackKind.ClassUpdate; + if ( + lifecycleCallback?.ownerId !== transition.ownerId || + lifecycleCallback.kind !== expectedLifecycleKind || + lifecycleCallback.phase !== transition.phase + ) { + addFailure(failures, transition.id, "A class state transition has an invalid lifecycle"); + } + const updaterCallback = transition.updaterCallbackId + ? callbacksById.get(transition.updaterCallbackId) + : null; + const updaterRequiresCallback = + transition.updaterStatus === ReactClassStateUpdaterStatus.Pure || + transition.updaterStatus === ReactClassStateUpdaterStatus.Impure; + const updaterForbidsCallback = + transition.updaterStatus === ReactClassStateUpdaterStatus.Noop || + transition.updaterStatus === ReactClassStateUpdaterStatus.Object; + if ( + (updaterRequiresCallback && !transition.updaterCallbackId) || + (updaterForbidsCallback && transition.updaterCallbackId) || + (transition.updaterCallbackId && + (updaterCallback?.ownerId !== transition.ownerId || + updaterCallback.kind !== ReactSemanticCallbackKind.ClassStateUpdater || + updaterCallback.phase !== ReactExecutionPhase.StateTransition)) + ) { + addFailure(failures, transition.id, "A class state transition has an invalid updater"); + } + const expectsGuard = transition.cycleStatus === ReactClassUpdateCycleStatus.Bounded; + const hasGuard = transition.guardLocations.length > 0; + if (expectsGuard !== hasGuard) { + addFailure(failures, transition.id, "A bounded class state transition has invalid guards"); + } + const expectedSourceComplete = + transition.updaterStatus !== ReactClassStateUpdaterStatus.Unknown && + !transition.commitCallbackProvided; + if (transition.sourceComplete !== expectedSourceComplete) { + addFailure( + failures, + transition.id, + "A class state transition source flag does not match its modeled surface", + ); + } + const hasSafeUpdater = + transition.updaterStatus !== ReactClassStateUpdaterStatus.Impure && + transition.updaterStatus !== ReactClassStateUpdaterStatus.Unknown; + const hasSafeCycle = + transition.cycleStatus !== ReactClassUpdateCycleStatus.Guaranteed && + transition.cycleStatus !== ReactClassUpdateCycleStatus.Unknown; + const expectedComplete = + transition.sourceComplete && hasSafeUpdater && hasSafeCycle && Boolean(lifecycleCallback); + if (transition.complete !== expectedComplete) { + addFailure( + failures, + transition.id, + "A class state transition completeness flag does not match its certificate", + ); + } + } + const lifecycleOwnerIds = new Set(); + for (const lifecycle of report.graph.classLifecycles) { + const owner = unitsById.get(lifecycle.ownerId); + if (owner?.kind !== ReactUnitKind.ClassComponent) { + addFailure(failures, lifecycle.id, "A class lifecycle has an unknown or non-class owner"); + } + if (lifecycleOwnerIds.has(lifecycle.ownerId)) { + addFailure(failures, lifecycle.id, "A class component has multiple lifecycle certificates"); + } + lifecycleOwnerIds.add(lifecycle.ownerId); + const construction = constructionsById.get(lifecycle.constructionId); + if (!construction || construction.ownerId !== lifecycle.ownerId) { + addFailure(failures, lifecycle.id, "A class lifecycle has an invalid construction link"); + } + if (lifecycle.sourceComplete && !construction?.sourceComplete) { + addFailure( + failures, + lifecycle.id, + "A class lifecycle is source-complete without complete construction source", + ); + } + const mountCallback = lifecycle.mountCallbackId + ? callbacksById.get(lifecycle.mountCallbackId) + : null; + if ( + lifecycle.mountCallbackId && + (mountCallback?.ownerId !== lifecycle.ownerId || + mountCallback.kind !== ReactSemanticCallbackKind.ClassMount || + mountCallback.phase !== ReactExecutionPhase.ClassMount) + ) { + addFailure(failures, lifecycle.id, "A class lifecycle has an invalid mount callback"); + } + const unmountCallback = lifecycle.unmountCallbackId + ? callbacksById.get(lifecycle.unmountCallbackId) + : null; + if ( + lifecycle.unmountCallbackId && + (unmountCallback?.ownerId !== lifecycle.ownerId || + unmountCallback.kind !== ReactSemanticCallbackKind.ClassUnmount || + unmountCallback.phase !== ReactExecutionPhase.ClassUnmount) + ) { + addFailure(failures, lifecycle.id, "A class lifecycle has an invalid unmount callback"); + } + const updateCallback = lifecycle.updateCallbackId + ? callbacksById.get(lifecycle.updateCallbackId) + : null; + if ( + lifecycle.updateCallbackId && + (updateCallback?.ownerId !== lifecycle.ownerId || + updateCallback.kind !== ReactSemanticCallbackKind.ClassUpdate || + updateCallback.phase !== ReactExecutionPhase.ClassUpdate) + ) { + addFailure(failures, lifecycle.id, "A class lifecycle has an invalid update callback"); + } + const lifecycleResources = lifecycle.resourceIds.flatMap((resourceId) => { + const resource = resourcesById.get(resourceId); + if (!resource || resource.ownerId !== lifecycle.ownerId || resource.effectId !== null) { + addFailure(failures, lifecycle.id, "A class lifecycle has an invalid resource link"); + return []; + } + return [resource]; + }); + if (new Set(lifecycle.resourceIds).size !== lifecycle.resourceIds.length) { + addFailure(failures, lifecycle.id, "A class lifecycle repeats a resource link"); + } + const lifecycleSchedulers = lifecycle.schedulerIds.flatMap((schedulerId) => { + const scheduler = schedulersById.get(schedulerId); + if (!scheduler || scheduler.ownerId !== lifecycle.ownerId || scheduler.effectId !== null) { + addFailure(failures, lifecycle.id, "A class lifecycle has an invalid scheduler link"); + return []; + } + return [scheduler]; + }); + if (new Set(lifecycle.schedulerIds).size !== lifecycle.schedulerIds.length) { + addFailure(failures, lifecycle.id, "A class lifecycle repeats a scheduler link"); + } + const lifecycleTransitions = lifecycle.transitionIds.flatMap((transitionId) => { + const transition = transitionsById.get(transitionId); + if (!transition || transition.ownerId !== lifecycle.ownerId) { + addFailure( + failures, + lifecycle.id, + "A class lifecycle has an invalid state transition link", + ); + return []; + } + return [transition]; + }); + if (new Set(lifecycle.transitionIds).size !== lifecycle.transitionIds.length) { + addFailure(failures, lifecycle.id, "A class lifecycle repeats a state transition link"); + } + const lifecycleStateWrites = lifecycle.stateWriteIds.flatMap((stateWriteId) => { + const stateWrite = stateWritesById.get(stateWriteId); + if (!stateWrite || stateWrite.ownerId !== lifecycle.ownerId) { + addFailure(failures, lifecycle.id, "A class lifecycle has an invalid state write link"); + return []; + } + return [stateWrite]; + }); + if (new Set(lifecycle.stateWriteIds).size !== lifecycle.stateWriteIds.length) { + addFailure(failures, lifecycle.id, "A class lifecycle repeats a state write link"); + } + const expectedComplete = + lifecycle.sourceComplete && + Boolean(construction?.complete) && + lifecycleResources.length === lifecycle.resourceIds.length && + lifecycleResources.every((resource) => resource.complete) && + lifecycleSchedulers.length === lifecycle.schedulerIds.length && + lifecycleSchedulers.every((scheduler) => scheduler.complete) && + lifecycleStateWrites.length === lifecycle.stateWriteIds.length && + lifecycleStateWrites.every((stateWrite) => stateWrite.complete) && + lifecycleTransitions.length === lifecycle.transitionIds.length && + lifecycleTransitions.every((transition) => transition.complete); + if (lifecycle.complete !== expectedComplete) { + addFailure( + failures, + lifecycle.id, + "A class lifecycle completeness flag does not match its ownership certificates", + ); + } + } + for (const unit of report.graph.units) { + if (unit.kind === ReactUnitKind.ClassComponent && !lifecycleOwnerIds.has(unit.id)) { + addFailure(failures, unit.id, "A class component has no lifecycle certificate"); + } + if (unit.kind === ReactUnitKind.ClassComponent && !constructionOwnerIds.has(unit.id)) { + addFailure(failures, unit.id, "A class component has no construction certificate"); + } + } + for (const scheduler of report.graph.schedulers) { + if ( + scheduler.effectId === null && + !report.graph.classLifecycles.some( + (lifecycle) => + lifecycle.ownerId === scheduler.ownerId && lifecycle.schedulerIds.includes(scheduler.id), + ) + ) { + addFailure(failures, scheduler.id, "A class scheduler has no lifecycle certificate"); + } + } + for (const resource of report.graph.resources) { + if ( + resource.effectId === null && + !report.graph.classLifecycles.some( + (lifecycle) => + lifecycle.ownerId === resource.ownerId && lifecycle.resourceIds.includes(resource.id), + ) + ) { + addFailure(failures, resource.id, "A class resource has no lifecycle certificate"); + } + } + for (const transition of report.graph.classStateTransitions) { + if ( + !report.graph.classLifecycles.some( + (lifecycle) => + lifecycle.ownerId === transition.ownerId && + lifecycle.transitionIds.includes(transition.id), + ) + ) { + addFailure(failures, transition.id, "A class state transition has no lifecycle certificate"); + } + } + for (const stateWrite of report.graph.classStateWrites) { + if ( + !report.graph.classLifecycles.some( + (lifecycle) => + lifecycle.ownerId === stateWrite.ownerId && + lifecycle.stateWriteIds.includes(stateWrite.id), + ) + ) { + addFailure(failures, stateWrite.id, "A class state write has no lifecycle certificate"); + } + } + for (const construction of report.graph.classConstructions) { + if ( + !report.graph.classLifecycles.some( + (lifecycle) => + lifecycle.ownerId === construction.ownerId && + lifecycle.constructionId === construction.id, + ) + ) { + addFailure(failures, construction.id, "A class construction has no lifecycle certificate"); + } + } + for (const reachableFunction of report.graph.reachableFunctions) { + if (!unitIds.has(reachableFunction.ownerId)) { + addFailure(failures, reachableFunction.id, "A reachable function has an unknown owner unit"); + } + const rootCallback = callbacksById.get(reachableFunction.rootCallbackId); + if (!rootCallback) { + addFailure( + failures, + reachableFunction.id, + "A reachable function has an unknown root callback", + ); + } else { + if (rootCallback.ownerId !== reachableFunction.ownerId) { + addFailure( + failures, + reachableFunction.id, + "A reachable function and root callback have different owners", + ); + } + if (rootCallback.phase !== reachableFunction.phase) { + addFailure( + failures, + reachableFunction.id, + "A reachable function and root callback have different execution phases", + ); + } + } + } + for (const functionCall of report.graph.functionCalls) { + if (!unitIds.has(functionCall.ownerId)) { + addFailure(failures, functionCall.id, "A function call has an unknown owner unit"); + } + const rootCallback = callbacksById.get(functionCall.rootCallbackId); + if (!rootCallback) { + addFailure(failures, functionCall.id, "A function call has an unknown root callback"); + continue; + } + const sourceFunction = + functionCall.sourceFunctionId === rootCallback.id + ? rootCallback + : reachableFunctionsById.get(functionCall.sourceFunctionId); + const targetFunction = + functionCall.targetFunctionId === rootCallback.id + ? rootCallback + : reachableFunctionsById.get(functionCall.targetFunctionId); + if (!sourceFunction || !targetFunction) { + addFailure( + failures, + functionCall.id, + "A function call references a function outside its callback graph", + ); + } + const sourceRootCallbackId = + functionCall.sourceFunctionId === rootCallback.id + ? rootCallback.id + : reachableFunctionsById.get(functionCall.sourceFunctionId)?.rootCallbackId; + const targetRootCallbackId = + functionCall.targetFunctionId === rootCallback.id + ? rootCallback.id + : reachableFunctionsById.get(functionCall.targetFunctionId)?.rootCallbackId; + if (sourceRootCallbackId !== rootCallback.id || targetRootCallbackId !== rootCallback.id) { + addFailure(failures, functionCall.id, "A function call crosses callback graph roots"); + } + if ( + rootCallback.ownerId !== functionCall.ownerId || + sourceFunction?.ownerId !== functionCall.ownerId || + targetFunction?.ownerId !== functionCall.ownerId + ) { + addFailure(failures, functionCall.id, "A function call crosses semantic unit owners"); + } + if ( + rootCallback.phase !== functionCall.phase || + sourceFunction?.phase !== functionCall.phase || + targetFunction?.phase !== functionCall.phase + ) { + addFailure(failures, functionCall.id, "A function call crosses React execution phases"); + } + const hasValidFlowIndexes = + (functionCall.kind === ReactSemanticFunctionCallKind.Direct && + functionCall.sourceParameterIndex === null && + functionCall.callArgumentIndex === null && + functionCall.sourcePropertyPath.length === 0) || + (functionCall.kind === ReactSemanticFunctionCallKind.Parameter && + functionCall.sourceParameterIndex !== null && + functionCall.callArgumentIndex === null && + functionCall.sourcePropertyPath.length === 0) || + (functionCall.kind === ReactSemanticFunctionCallKind.Captured && + functionCall.sourceParameterIndex === null && + functionCall.callArgumentIndex === null && + functionCall.sourcePropertyPath.length === 0) || + (functionCall.kind === ReactSemanticFunctionCallKind.Property && + functionCall.callArgumentIndex === null && + functionCall.sourcePropertyPath.length > 0) || + (functionCall.kind === ReactSemanticFunctionCallKind.SynchronousCallback && + functionCall.sourceParameterIndex === null && + functionCall.callArgumentIndex !== null && + functionCall.sourcePropertyPath.length === 0); + if (!hasValidFlowIndexes) { + addFailure( + failures, + functionCall.id, + "A function call has indexes inconsistent with its flow kind", + ); + } + if ( + (functionCall.sourceParameterIndex !== null && functionCall.sourceParameterIndex < 0) || + (functionCall.callArgumentIndex !== null && functionCall.callArgumentIndex < 0) + ) { + addFailure(failures, functionCall.id, "A function call has a negative flow index"); + } + if (functionCall.sourcePropertyPath.some((propertyName) => propertyName.length === 0)) { + addFailure(failures, functionCall.id, "A function call has an empty property path segment"); + } + if ( + !functionCall.isConditionallyReached && + functionCall.targetFunctionId !== rootCallback.id && + reachableFunctionsById.get(functionCall.targetFunctionId)?.isConditionallyReached + ) { + addFailure( + failures, + functionCall.id, + "An unconditional function call targets a conditionally reachable function", + ); + } + } + for (const eventBinding of report.graph.eventBindings) { + if (!unitIds.has(eventBinding.ownerId)) { + addFailure(failures, eventBinding.id, "An event binding has an unknown owner unit"); + } + if (eventBinding.complete && eventBinding.callbackIds.length === 0) { + addFailure(failures, eventBinding.id, "A complete event binding has no source callback"); + } + for (const callbackId of eventBinding.callbackIds) { + const callback = callbacksById.get(callbackId); + if (!callback) { + addFailure(failures, eventBinding.id, "An event binding has an unknown source callback"); + } else if (callback.phase !== ReactExecutionPhase.Event) { + addFailure(failures, eventBinding.id, "An event binding source is not in the event phase"); + } + } + } + for (const propFlow of report.graph.callbackPropFlows) { + if (!unitIds.has(propFlow.renderOwnerId) || !unitIds.has(propFlow.targetOwnerId)) { + addFailure(failures, propFlow.id, "A callback prop flow crosses an unknown semantic unit"); + } + const render = rendersById.get(propFlow.renderId); + if (!render) { + addFailure(failures, propFlow.id, "A callback prop flow has an unknown render site"); + } else if ( + render.ownerId !== propFlow.renderOwnerId || + render.targetId !== propFlow.targetOwnerId + ) { + addFailure( + failures, + propFlow.id, + "A callback prop flow render site has different semantic units", + ); + } + if (propFlow.complete && propFlow.callbackIds.length === 0) { + addFailure(failures, propFlow.id, "A complete callback prop flow has no source callback"); + } + if (propFlow.complete && propFlow.alternatives.length === 0) { + addFailure(failures, propFlow.id, "A complete callback prop flow has no guarded alternative"); + } + const alternativeCallbackIds = new Set( + propFlow.alternatives.map((alternative) => alternative.callbackId), + ); + if ( + propFlow.callbackIds.some((callbackId) => !alternativeCallbackIds.has(callbackId)) || + propFlow.alternatives.some( + (alternative) => !propFlow.callbackIds.includes(alternative.callbackId), + ) + ) { + addFailure( + failures, + propFlow.id, + "A callback prop flow callback set differs from its guarded alternatives", + ); + } + const alternativeIdentities = new Set(); + for (const alternative of propFlow.alternatives) { + const guardIds = alternative.guards.map((guard) => guard.id); + if (new Set(guardIds).size !== guardIds.length || guardIds.some((guardId) => !guardId)) { + addFailure( + failures, + propFlow.id, + "A callback prop alternative has invalid guard identities", + ); + } + const alternativeIdentity = `${alternative.callbackId}:${alternative.guards + .map((guard) => `${guard.id}=${String(guard.polarity)}`) + .sort() + .join("&")}`; + if (alternativeIdentities.has(alternativeIdentity)) { + addFailure(failures, propFlow.id, "A callback prop flow repeats a guarded alternative"); + } + alternativeIdentities.add(alternativeIdentity); + } + for (const callbackId of propFlow.callbackIds) { + const callback = callbacksById.get(callbackId); + if (!callback) { + addFailure(failures, propFlow.id, "A callback prop flow has an unknown source callback"); + } else if (callback.phase !== propFlow.phase) { + addFailure( + failures, + propFlow.id, + "A callback prop flow source has a mismatched execution phase", + ); + } + } + } + const eventChannelCallbackIds = new Set([ + ...report.graph.eventBindings.flatMap((eventBinding) => eventBinding.callbackIds), + ...report.graph.callbackPropFlows + .filter((propFlow) => propFlow.phase === ReactExecutionPhase.Event) + .flatMap((propFlow) => propFlow.callbackIds), + ]); + for (const callback of report.graph.callbacks) { + if (!unitIds.has(callback.ownerId)) { + addFailure(failures, callback.id, "A callback has an unknown owner unit"); + } + if ( + callback.kind === ReactSemanticCallbackKind.EventHandler && + !eventChannelCallbackIds.has(callback.id) + ) { + addFailure(failures, callback.id, "An event callback is not referenced by an event channel"); + } + } + for (const provider of report.graph.contextProviders) { + if (!unitIds.has(provider.ownerId)) { + addFailure(failures, provider.id, "A context provider has an unknown owner unit"); + } + if (!contextIds.has(provider.contextId)) { + addFailure(failures, provider.id, "A provider references an unknown context"); + } + } + for (const consumer of report.graph.contextConsumers) { + if (!unitIds.has(consumer.ownerId)) { + addFailure(failures, consumer.id, "A context consumer has an unknown owner unit"); + } + if (consumer.contextId && !contextIds.has(consumer.contextId)) { + addFailure(failures, consumer.id, "A consumer references an unknown context"); + } + for (const providerId of consumer.sourceProviderIds) { + if (!providerIds.has(providerId)) { + addFailure(failures, consumer.id, "A consumer has an unknown source provider"); + } + } + const expectedSources = consumer.contextId + ? (contextSourcesByUnit.get(consumer.ownerId)?.get(consumer.contextId) ?? new Set()) + : new Set(); + const expectedProviderIds = [...expectedSources].filter( + (sourceId) => + sourceId !== REACT_CONTEXT_DEFAULT_SOURCE_ID && + sourceId !== REACT_CONTEXT_UNKNOWN_SOURCE_ID, + ); + if ( + consumer.sourceProviderIds.length !== expectedProviderIds.length || + expectedProviderIds.some((providerId) => !consumer.sourceProviderIds.includes(providerId)) + ) { + addFailure(failures, consumer.id, "A context consumer has an inconsistent provider set"); + } + const expectedUsesDefaultValue = expectedSources.has(REACT_CONTEXT_DEFAULT_SOURCE_ID); + const expectedTopologyComplete = + Boolean(consumer.contextId) && + expectedSources.size > 0 && + !expectedSources.has(REACT_CONTEXT_UNKNOWN_SOURCE_ID); + if (consumer.usesDefaultValue !== expectedUsesDefaultValue) { + addFailure(failures, consumer.id, "A context consumer has an inconsistent default source"); + } + if (consumer.topologyComplete !== expectedTopologyComplete) { + addFailure( + failures, + consumer.id, + "A context consumer has an inconsistent topology certificate", + ); + } + } + for (const form of report.graph.forms) { + if (!unitIds.has(form.ownerId)) { + addFailure(failures, form.id, "A form has an unknown owner unit"); + } + } + const formStatusHookCalls = report.graph.hookCalls.filter( + (hookCall) => hookCall.name === "useFormStatus" && hookCall.targetId === "react:useFormStatus", + ); + for (const formStatus of report.graph.formStatuses) { + if (!unitIds.has(formStatus.ownerId)) { + addFailure(failures, formStatus.id, "A Form Status consumer has an unknown owner unit"); + } + const matchingHookCalls = formStatusHookCalls.filter( + (hookCall) => + hookCall.ownerId === formStatus.ownerId && + areProofLocationsEqual(hookCall.location, formStatus.location), + ); + if (matchingHookCalls.length !== 1) { + addFailure( + failures, + formStatus.id, + "A Form Status consumer does not match exactly one canonical Hook call", + ); + } + if (new Set(formStatus.sourceFormIds).size !== formStatus.sourceFormIds.length) { + addFailure(failures, formStatus.id, "A Form Status consumer repeats a source form"); + } + if (formStatus.sourceFormIds.some((formId) => !formsById.has(formId))) { + addFailure(failures, formStatus.id, "A Form Status consumer has an unknown source form"); + } + const expectedSources = formSourcesByUnit.get(formStatus.ownerId) ?? new Set(); + const expectedSourceFormIds = [...expectedSources].filter( + (sourceId) => + sourceId !== REACT_FORM_OUTSIDE_SOURCE_ID && sourceId !== REACT_FORM_UNKNOWN_SOURCE_ID, + ); + if ( + formStatus.sourceFormIds.length !== expectedSourceFormIds.length || + expectedSourceFormIds.some((formId) => !formStatus.sourceFormIds.includes(formId)) + ) { + addFailure( + failures, + formStatus.id, + "A Form Status consumer has an inconsistent parent-form source set", + ); + } + const expectedOutsideForm = expectedSources.has(REACT_FORM_OUTSIDE_SOURCE_ID); + const expectedSourceComplete = + expectedSources.size > 0 && !expectedSources.has(REACT_FORM_UNKNOWN_SOURCE_ID); + let expectedStatus = ReactFormStatusTopologyStatus.Unknown; + if (expectedOutsideForm) { + expectedStatus = ReactFormStatusTopologyStatus.OutsideForm; + } else if (expectedSourceComplete && expectedSourceFormIds.length > 0) { + expectedStatus = ReactFormStatusTopologyStatus.Resolved; + } + if (formStatus.outsideForm !== expectedOutsideForm) { + addFailure(failures, formStatus.id, "A Form Status outside-form flag is inconsistent"); + } + if (formStatus.sourceComplete !== expectedSourceComplete) { + addFailure(failures, formStatus.id, "A Form Status source certificate is inconsistent"); + } + if (formStatus.status !== expectedStatus) { + addFailure(failures, formStatus.id, "A Form Status topology status is inconsistent"); + } + if (formStatus.complete !== (expectedStatus === ReactFormStatusTopologyStatus.Resolved)) { + addFailure(failures, formStatus.id, "A Form Status completeness flag is inconsistent"); + } + } + for (const hookCall of formStatusHookCalls) { + const matchingFormStatuses = report.graph.formStatuses.filter( + (formStatus) => + formStatus.ownerId === hookCall.ownerId && + areProofLocationsEqual(formStatus.location, hookCall.location), + ); + if (matchingFormStatuses.length !== 1) { + addFailure( + failures, + hookCall.id, + "A canonical useFormStatus call has no unique topology certificate", + ); + } + } +}; + +const checkSummaryAndVerdict = ( + report: ReactAppProofReport, + failures: ReactProofCertificateFailure[], +): void => { + const obligations = report.units.flatMap((unit) => unit.obligations); + const proved = obligations.filter( + (obligation) => obligation.status === ReactObligationStatus.Proved, + ).length; + const violated = obligations.filter( + (obligation) => obligation.status === ReactObligationStatus.Violated, + ).length; + const unknown = obligations.filter( + (obligation) => obligation.status === ReactObligationStatus.Unknown, + ).length; + if ( + report.summary.units !== report.units.length || + report.summary.proved !== proved || + report.summary.violated !== violated || + report.summary.unknown !== unknown + ) { + addFailure(failures, "report-summary", "The proof summary does not match its obligations"); + } + const expectedStatus = + violated > 0 + ? ReactAppProofStatus.Refuted + : unknown > 0 || report.projectEvidence.length > 0 + ? ReactAppProofStatus.Incomplete + : ReactAppProofStatus.Proved; + if (report.status !== expectedStatus) { + addFailure( + failures, + "report-status", + `The proof facts require ${expectedStatus}, not ${report.status}`, + ); + } +}; + +export const checkReactProofReport = (report: ReactAppProofReport): ReactProofCertificateCheck => { + const failures: ReactProofCertificateFailure[] = []; + if (report.schemaVersion !== REACT_PROOF_SCHEMA_VERSION) { + addFailure(failures, "report-schema", "The proof report schema version is unsupported"); + } + if (report.graph.schemaVersion !== REACT_SEMANTIC_GRAPH_SCHEMA_VERSION) { + addFailure(failures, "graph-schema", "The semantic graph schema version is unsupported"); + } + checkUniqueIds( + failures, + "units", + report.graph.units.map((unit) => unit.id), + ); + checkUniqueIds( + failures, + "Action states", + report.graph.actionStates.map((state) => state.id), + ); + checkUniqueIds( + failures, + "Action State dispatches", + report.graph.actionStateDispatches.map((dispatch) => dispatch.id), + ); + checkUniqueIds( + failures, + "schedulers", + report.graph.schedulers.map((scheduler) => scheduler.id), + ); + checkUniqueIds( + failures, + "Effect resources", + report.graph.resources.map((resource) => resource.id), + ); + checkUniqueIds( + failures, + "Class lifecycles", + report.graph.classLifecycles.map((lifecycle) => lifecycle.id), + ); + checkUniqueIds( + failures, + "Class constructions", + report.graph.classConstructions.map((construction) => construction.id), + ); + checkUniqueIds( + failures, + "Class state transitions", + report.graph.classStateTransitions.map((transition) => transition.id), + ); + checkUniqueIds( + failures, + "Hook state transitions", + report.graph.hookStateTransitions.map((transition) => transition.id), + ); + checkUniqueIds( + failures, + "reducers", + report.graph.reducers.map((reducer) => reducer.id), + ); + checkUniqueIds( + failures, + "reducer dispatches", + report.graph.reducerDispatches.map((dispatch) => dispatch.id), + ); + checkUniqueIds( + failures, + "Form Actions", + report.graph.formActions.map((action) => action.id), + ); + checkUniqueIds( + failures, + "forms", + report.graph.forms.map((form) => form.id), + ); + checkUniqueIds( + failures, + "Form Status consumers", + report.graph.formStatuses.map((formStatus) => formStatus.id), + ); + checkUniqueIds( + failures, + "Suspense boundaries", + report.graph.suspenseBoundaries.map((boundary) => boundary.id), + ); + checkUniqueIds( + failures, + "Error Boundary definitions", + report.graph.errorBoundaryDefinitions.map((definition) => definition.id), + ); + checkUniqueIds( + failures, + "Error Boundary instances", + report.graph.errorBoundaries.map((boundary) => boundary.id), + ); + checkUniqueIds( + failures, + "render failures", + report.graph.renderFailures.map((renderFailure) => renderFailure.id), + ); + checkUniqueIds( + failures, + "use resources", + report.graph.useResources.map((resource) => resource.id), + ); + checkUniqueIds( + failures, + "host controls", + report.graph.hostControls.map((control) => control.id), + ); + checkUniqueIds( + failures, + "hydration roots", + report.graph.hydrationRoots.map((root) => root.id), + ); + checkUniqueIds( + failures, + "hydration hazards", + report.graph.hydrationHazards.map((hazard) => hazard.id), + ); + checkUniqueIds( + failures, + "hydration certificates", + report.graph.hydrations.map((hydration) => hydration.id), + ); + checkUniqueIds( + failures, + "lazy components", + report.graph.lazyComponents.map((component) => component.id), + ); + checkUniqueIds( + failures, + "lazy renders", + report.graph.lazyRenders.map((render) => render.id), + ); + checkUniqueIds( + failures, + "Optimistic states", + report.graph.optimisticStates.map((state) => state.id), + ); + checkUniqueIds( + failures, + "Optimistic updates", + report.graph.optimisticUpdates.map((update) => update.id), + ); + checkUniqueIds( + failures, + "Transition Actions", + report.graph.transitionActions.map((action) => action.id), + ); + checkUniqueIds( + failures, + "Class state writes", + report.graph.classStateWrites.map((stateWrite) => stateWrite.id), + ); + checkUniqueIds( + failures, + "effects", + report.graph.effects.map((effect) => effect.id), + ); + checkUniqueIds( + failures, + "external stores", + report.graph.externalStores.map((externalStore) => externalStore.id), + ); + checkUniqueIds( + failures, + "async tasks", + report.graph.asyncTasks.map((task) => task.id), + ); + checkUniqueIds( + failures, + "callbacks", + report.graph.callbacks.map((callback) => callback.id), + ); + checkUniqueIds( + failures, + "reachable functions", + report.graph.reachableFunctions.map((reachableFunction) => reachableFunction.id), + ); + checkUniqueIds( + failures, + "function calls", + report.graph.functionCalls.map((functionCall) => functionCall.id), + ); + checkUniqueIds( + failures, + "event bindings", + report.graph.eventBindings.map((eventBinding) => eventBinding.id), + ); + checkUniqueIds( + failures, + "renders", + report.graph.renders.map((render) => render.id), + ); + checkUniqueIds( + failures, + "slot flows", + report.graph.slotFlows.map((slotFlow) => slotFlow.id), + ); + checkUniqueIds( + failures, + "callback prop flows", + report.graph.callbackPropFlows.map((propFlow) => propFlow.id), + ); + checkUniqueIds( + failures, + "callable refs", + report.graph.callableRefs.map((callableRef) => callableRef.id), + ); + checkUniqueIds( + failures, + "memo comparators", + report.graph.memoComparators.map((comparator) => comparator.id), + ); + checkUniqueIds( + failures, + "imperative handles", + report.graph.imperativeHandles.map((handle) => handle.id), + ); + checkUniqueIds( + failures, + "imperative handle methods", + report.graph.imperativeHandleMethods.map((method) => method.id), + ); + checkUniqueIds( + failures, + "imperative handle bindings", + report.graph.imperativeHandleBindings.map((binding) => binding.id), + ); + checkUniqueIds( + failures, + "imperative handle invocations", + report.graph.imperativeHandleInvocations.map((invocation) => invocation.id), + ); + checkUniqueIds( + failures, + "contexts", + report.graph.contexts.map((context) => context.id), + ); + checkUniqueIds( + failures, + "context providers", + report.graph.contextProviders.map((provider) => provider.id), + ); + checkUniqueIds( + failures, + "context consumers", + report.graph.contextConsumers.map((consumer) => consumer.id), + ); + checkGraphReferences(report, failures); + checkClaimCoverage(report, failures); + checkSummaryAndVerdict(report, failures); + return { + status: + failures.length === 0 + ? ReactProofCertificateStatus.Valid + : ReactProofCertificateStatus.Invalid, + failures, + }; +}; diff --git a/packages/prover/src/collect-action-state.ts b/packages/prover/src/collect-action-state.ts new file mode 100644 index 000000000..d099e5488 --- /dev/null +++ b/packages/prover/src/collect-action-state.ts @@ -0,0 +1,140 @@ +import ts from "typescript"; +import { collectHookBindings } from "./collect-hook-bindings.js"; +import { isIdentifierReference } from "./is-identifier-reference.js"; +import { isNodeWithin } from "./is-node-within.js"; +import { resolveFunction } from "./resolve-function.js"; +import { getResolvedSymbol } from "./utils/get-resolved-symbol.js"; +import { isReactHookDependencyReference } from "./utils/is-react-hook-dependency-reference.js"; +import { unwrapTypescriptExpression } from "./unwrap-typescript-expression.js"; +import type { + ActionStateHookBinding, + BoundActionStateHookBinding, +} from "./collect-hook-bindings.js"; +import type { ReactAnalysisContext } from "./types.js"; + +export interface ActionStateDescriptor { + binding: ActionStateHookBinding; + reducerFunction: ts.FunctionLikeDeclaration | null; +} + +export interface ActionStateDispatchDescriptor { + binding: BoundActionStateHookBinding; + callExpression: ts.CallExpression | null; + evidenceNode: ts.Node; + isActionPropReference: boolean; +} + +export interface ActionStateCollection { + dispatches: ReadonlyArray; + states: ReadonlyArray; +} + +const getActionPropAttribute = (node: ts.Node): ts.JsxAttribute | null => { + let currentNode = node; + while (currentNode.parent && !ts.isFunctionLike(currentNode.parent)) { + if (ts.isJsxAttribute(currentNode.parent)) { + const propertyName = currentNode.parent.name.getText(); + return propertyName === "action" || propertyName === "formAction" ? currentNode.parent : null; + } + currentNode = currentNode.parent; + } + return null; +}; + +export const collectActionState = ( + functionNode: ts.FunctionLikeDeclaration, + context: ReactAnalysisContext, +): ActionStateCollection => { + const bindings = collectHookBindings(functionNode, context.typeChecker).actionStateBindings; + const boundBindings = bindings.filter((binding): binding is BoundActionStateHookBinding => + Boolean(binding.dispatcherSymbol), + ); + const bindingsByDispatcher = new Map( + boundBindings.map((binding): [ts.Symbol, BoundActionStateHookBinding] => [ + binding.dispatcherSymbol, + binding, + ]), + ); + const states = bindings.map( + (binding): ActionStateDescriptor => ({ + binding, + reducerFunction: binding.reducerExpression + ? resolveFunction(binding.reducerExpression, context.typeChecker) + : null, + }), + ); + const handledDispatcherReferences = new Set(); + const dispatches: ActionStateDispatchDescriptor[] = []; + const visitCalls = (node: ts.Node): void => { + if (ts.isCallExpression(node)) { + const dispatcherSymbol = getResolvedSymbol( + unwrapTypescriptExpression(node.expression), + context.typeChecker, + ); + const binding = dispatcherSymbol ? bindingsByDispatcher.get(dispatcherSymbol) : undefined; + if (binding) { + dispatches.push({ + binding, + callExpression: node, + evidenceNode: node, + isActionPropReference: false, + }); + const collectHandledReferences = (calleeNode: ts.Node): void => { + if ( + ts.isIdentifier(calleeNode) && + getResolvedSymbol(calleeNode, context.typeChecker) === dispatcherSymbol + ) { + handledDispatcherReferences.add(calleeNode); + } + calleeNode.forEachChild(collectHandledReferences); + }; + collectHandledReferences(node.expression); + } + } + node.forEachChild(visitCalls); + }; + functionNode.forEachChild(visitCalls); + + const handledActionProps = new Set(); + const visitReferences = (node: ts.Node): void => { + if ( + ts.isIdentifier(node) && + isIdentifierReference(node) && + !handledDispatcherReferences.has(node) + ) { + const dispatcherSymbol = getResolvedSymbol(node, context.typeChecker); + const binding = dispatcherSymbol ? bindingsByDispatcher.get(dispatcherSymbol) : undefined; + if ( + binding && + !isReactHookDependencyReference(node, context.typeChecker) && + !dispatches.some( + (dispatch) => + dispatch.callExpression && isNodeWithin(node, dispatch.callExpression.expression), + ) + ) { + const actionPropAttribute = getActionPropAttribute(node); + if (actionPropAttribute) { + if (!handledActionProps.has(actionPropAttribute)) { + handledActionProps.add(actionPropAttribute); + dispatches.push({ + binding, + callExpression: null, + evidenceNode: actionPropAttribute, + isActionPropReference: true, + }); + } + } else { + dispatches.push({ + binding, + callExpression: null, + evidenceNode: node, + isActionPropReference: false, + }); + } + } + } + node.forEachChild(visitReferences); + }; + functionNode.forEachChild(visitReferences); + return { dispatches, states }; +}; diff --git a/packages/prover/src/collect-async-effect-task-descriptors.ts b/packages/prover/src/collect-async-effect-task-descriptors.ts new file mode 100644 index 000000000..f0b9a9db8 --- /dev/null +++ b/packages/prover/src/collect-async-effect-task-descriptors.ts @@ -0,0 +1,473 @@ +import ts from "typescript"; +import { collectEffectCleanupFunctions } from "./collect-effect-cleanup-functions.js"; +import { collectEffectCalls } from "./collect-effect-calls.js"; +import { collectHookBindings } from "./collect-hook-bindings.js"; +import { PROMISE_CONTINUATION_METHOD_NAMES } from "./constants.js"; +import { getEffectCallback } from "./get-effect-callback.js"; +import { isFunctionBoundary } from "./is-function-boundary.js"; +import { resolveFunction } from "./resolve-function.js"; +import { ReactAsyncOwnershipStatus } from "./types.js"; +import { unwrapTypescriptExpression } from "./unwrap-typescript-expression.js"; +import { containsAwaitOutsideNestedFunction } from "./utils/contains-await-outside-nested-function.js"; +import { hasGuaranteedEffectCleanup } from "./utils/has-guaranteed-effect-cleanup.js"; +import { isAssignmentOperator } from "./utils/is-assignment-operator.js"; +import type { ReactAnalysisContext, ReactAsyncEffectTaskDescriptor } from "./types.js"; + +interface AsyncStateWrite { + callExpression: ts.CallExpression; + hasOpaqueGuard: boolean; + isGuarded: boolean; + stateWriteName: string; +} + +interface AsyncTaskOperations { + stateWrites: ReadonlyArray; + unknownOperation: ts.Node | null; +} + +interface EffectInvalidationGuards { + abortedControllerSymbols: ReadonlySet; + invalidatedBooleanSymbols: ReadonlySet; +} + +const getDirectStatement = (node: ts.Node, block: ts.Block): ts.Statement | null => { + let currentNode = node; + while (currentNode.parent !== block) { + if (!currentNode.parent || isFunctionBoundary(currentNode.parent)) return null; + currentNode = currentNode.parent; + } + return ts.isStatement(currentNode) ? currentNode : null; +}; + +const hasSequentialAwaitBefore = ( + operationNode: ts.Node, + taskFunction: ts.FunctionLikeDeclaration, +): boolean => { + if (!taskFunction.body || !ts.isBlock(taskFunction.body)) return false; + if (containsAwaitOutsideNestedFunction(operationNode, taskFunction)) return true; + const containingStatement = getDirectStatement(operationNode, taskFunction.body); + if (!containingStatement) return false; + const statementIndex = taskFunction.body.statements.indexOf(containingStatement); + return taskFunction.body.statements + .slice(0, statementIndex) + .some((statement) => containsAwaitOutsideNestedFunction(statement, taskFunction)); +}; + +const getIdentifierSymbol = ( + expression: ts.Expression, + typeChecker: ts.TypeChecker, +): ts.Symbol | null => { + const unwrappedExpression = unwrapTypescriptExpression(expression); + if (!ts.isIdentifier(unwrappedExpression)) return null; + return typeChecker.getSymbolAtLocation(unwrappedExpression) ?? null; +}; + +const getAbortedControllerSymbol = ( + expression: ts.Expression, + typeChecker: ts.TypeChecker, +): ts.Symbol | null => { + const unwrappedExpression = unwrapTypescriptExpression(expression); + if ( + !ts.isPropertyAccessExpression(unwrappedExpression) || + unwrappedExpression.name.text !== "aborted" || + !ts.isPropertyAccessExpression(unwrappedExpression.expression) || + unwrappedExpression.expression.name.text !== "signal" + ) { + return null; + } + return getIdentifierSymbol(unwrappedExpression.expression.expression, typeChecker); +}; + +const isInvalidatedCondition = ( + expression: ts.Expression, + guards: EffectInvalidationGuards, + typeChecker: ts.TypeChecker, +): boolean => { + const booleanSymbol = getIdentifierSymbol(expression, typeChecker); + if (booleanSymbol && guards.invalidatedBooleanSymbols.has(booleanSymbol)) return true; + const controllerSymbol = getAbortedControllerSymbol(expression, typeChecker); + return Boolean(controllerSymbol && guards.abortedControllerSymbols.has(controllerSymbol)); +}; + +const isValidCondition = ( + expression: ts.Expression, + guards: EffectInvalidationGuards, + typeChecker: ts.TypeChecker, +): boolean => { + const unwrappedExpression = unwrapTypescriptExpression(expression); + return ( + ts.isPrefixUnaryExpression(unwrappedExpression) && + unwrappedExpression.operator === ts.SyntaxKind.ExclamationToken && + isInvalidatedCondition(unwrappedExpression.operand, guards, typeChecker) + ); +}; + +const containsReturn = (statement: ts.Statement): boolean => { + let didFindReturn = false; + const visit = (node: ts.Node): void => { + if (didFindReturn || isFunctionBoundary(node)) return; + if (ts.isReturnStatement(node)) { + didFindReturn = true; + return; + } + node.forEachChild(visit); + }; + statement.forEachChild(visit); + return didFindReturn; +}; + +const hasGuardingAncestor = ( + callExpression: ts.CallExpression, + taskFunction: ts.FunctionLikeDeclaration, + guards: EffectInvalidationGuards, + typeChecker: ts.TypeChecker, +): { hasOpaqueGuard: boolean; isGuarded: boolean } => { + let hasOpaqueGuard = false; + let currentNode: ts.Node = callExpression; + while (currentNode !== taskFunction) { + const parentNode = currentNode.parent; + if (!parentNode) break; + if (ts.isIfStatement(parentNode)) { + const isThenBranch = + currentNode === parentNode.thenStatement || + (currentNode.getStart() >= parentNode.thenStatement.getStart() && + currentNode.getEnd() <= parentNode.thenStatement.getEnd()); + if (isThenBranch && isValidCondition(parentNode.expression, guards, typeChecker)) { + return { hasOpaqueGuard, isGuarded: true }; + } + hasOpaqueGuard = true; + } + if ( + ts.isBinaryExpression(parentNode) && + parentNode.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken && + parentNode.right.getStart() <= currentNode.getStart() && + isValidCondition(parentNode.left, guards, typeChecker) + ) { + return { hasOpaqueGuard, isGuarded: true }; + } + currentNode = parentNode; + } + return { hasOpaqueGuard, isGuarded: false }; +}; + +const hasGuardingEarlyReturn = ( + callExpression: ts.CallExpression, + guards: EffectInvalidationGuards, + typeChecker: ts.TypeChecker, +): boolean => { + let currentNode: ts.Node = callExpression; + while (currentNode.parent) { + const parentNode = currentNode.parent; + if (ts.isBlock(parentNode)) { + const containingStatement = getDirectStatement(callExpression, parentNode); + if (containingStatement) { + const statementIndex = parentNode.statements.indexOf(containingStatement); + if ( + parentNode.statements + .slice(0, statementIndex) + .some( + (statement) => + ts.isIfStatement(statement) && + isInvalidatedCondition(statement.expression, guards, typeChecker) && + containsReturn(statement.thenStatement), + ) + ) { + return true; + } + } + } + if (isFunctionBoundary(parentNode)) break; + currentNode = parentNode; + } + return false; +}; + +const intersectSymbols = ( + symbolSets: ReadonlyArray>, +): ReadonlySet => { + const [firstSymbolSet, ...remainingSymbolSets] = symbolSets; + if (!firstSymbolSet) return new Set(); + return new Set( + [...firstSymbolSet].filter((symbol) => + remainingSymbolSets.every((symbolSet) => symbolSet.has(symbol)), + ), + ); +}; + +const collectGuaranteedCleanupGuards = ( + cleanupFunction: ts.FunctionLikeDeclaration, + typeChecker: ts.TypeChecker, +): EffectInvalidationGuards => { + const invalidatedBooleanSymbols = new Set(); + const abortedControllerSymbols = new Set(); + const expressions: ts.Expression[] = []; + if (cleanupFunction.body && ts.isBlock(cleanupFunction.body)) { + for (const statement of cleanupFunction.body.statements) { + if (ts.isEmptyStatement(statement)) continue; + if (!ts.isExpressionStatement(statement)) break; + expressions.push(statement.expression); + } + } else if (cleanupFunction.body) { + expressions.push(cleanupFunction.body); + } + for (const expression of expressions) { + if ( + ts.isBinaryExpression(expression) && + expression.operatorToken.kind === ts.SyntaxKind.EqualsToken && + expression.right.kind === ts.SyntaxKind.TrueKeyword + ) { + const symbol = getIdentifierSymbol(expression.left, typeChecker); + if (!symbol) break; + invalidatedBooleanSymbols.add(symbol); + continue; + } + if ( + ts.isCallExpression(expression) && + ts.isPropertyAccessExpression(expression.expression) && + expression.expression.name.text === "abort" + ) { + const symbol = getIdentifierSymbol(expression.expression.expression, typeChecker); + if (!symbol) break; + abortedControllerSymbols.add(symbol); + continue; + } + break; + } + return { abortedControllerSymbols, invalidatedBooleanSymbols }; +}; + +const collectInvalidationGuards = ( + effectCallback: ts.FunctionLikeDeclaration, + cleanupFunctions: ReadonlyArray, + typeChecker: ts.TypeChecker, +): EffectInvalidationGuards => { + if (!hasGuaranteedEffectCleanup(effectCallback, typeChecker)) { + return { + abortedControllerSymbols: new Set(), + invalidatedBooleanSymbols: new Set(), + }; + } + const invalidatedBooleanSymbolSets: Set[] = []; + const abortedControllerSymbolSets: Set[] = []; + for (const cleanupFunction of cleanupFunctions) { + const cleanupGuards = collectGuaranteedCleanupGuards(cleanupFunction, typeChecker); + invalidatedBooleanSymbolSets.push(new Set(cleanupGuards.invalidatedBooleanSymbols)); + abortedControllerSymbolSets.push(new Set(cleanupGuards.abortedControllerSymbols)); + } + return { + abortedControllerSymbols: intersectSymbols(abortedControllerSymbolSets), + invalidatedBooleanSymbols: intersectSymbols(invalidatedBooleanSymbolSets), + }; +}; + +const collectInvokedAsyncFunctions = ( + effectCallback: ts.FunctionLikeDeclaration, + typeChecker: ts.TypeChecker, +): ReadonlyArray => { + const taskFunctions = new Set(); + const visit = (node: ts.Node): void => { + if (node !== effectCallback && isFunctionBoundary(node)) return; + if (ts.isCallExpression(node)) { + const taskFunction = resolveFunction(node.expression, typeChecker); + if (taskFunction && containsAwaitOutsideNestedFunction(taskFunction, taskFunction)) { + taskFunctions.add(taskFunction); + } + } + node.forEachChild(visit); + }; + effectCallback.forEachChild(visit); + return [...taskFunctions]; +}; + +const collectAsyncTaskOperations = ( + taskFunction: ts.FunctionLikeDeclaration, + stateSetters: ReadonlySet, + guards: EffectInvalidationGuards, + typeChecker: ts.TypeChecker, + startsAfterSuspension: boolean, +): AsyncTaskOperations => { + const stateWrites: AsyncStateWrite[] = []; + let unknownOperation: ts.Node | null = null; + const visit = (node: ts.Node): void => { + if (node !== taskFunction && isFunctionBoundary(node)) return; + if (ts.isCallExpression(node)) { + const setterSymbol = getIdentifierSymbol(node.expression, typeChecker); + const isAfterSuspension = + startsAfterSuspension || hasSequentialAwaitBefore(node, taskFunction); + if (setterSymbol && stateSetters.has(setterSymbol) && isAfterSuspension) { + const ancestorGuard = hasGuardingAncestor(node, taskFunction, guards, typeChecker); + stateWrites.push({ + callExpression: node, + hasOpaqueGuard: ancestorGuard.hasOpaqueGuard, + isGuarded: ancestorGuard.isGuarded || hasGuardingEarlyReturn(node, guards, typeChecker), + stateWriteName: node.expression.getText(), + }); + } else if ( + isAfterSuspension && + !( + ts.isPropertyAccessExpression(node.expression) && + PROMISE_CONTINUATION_METHOD_NAMES.has(node.expression.name.text) + ) + ) { + unknownOperation ??= node; + } + } + if ( + !unknownOperation && + ts.isBinaryExpression(node) && + isAssignmentOperator(node.operatorToken.kind) && + (startsAfterSuspension || hasSequentialAwaitBefore(node, taskFunction)) + ) { + unknownOperation = node; + } + if ( + !unknownOperation && + (ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) && + (node.operator === ts.SyntaxKind.PlusPlusToken || + node.operator === ts.SyntaxKind.MinusMinusToken) && + (startsAfterSuspension || hasSequentialAwaitBefore(node, taskFunction)) + ) { + unknownOperation = node; + } + node.forEachChild(visit); + }; + taskFunction.forEachChild(visit); + return { stateWrites, unknownOperation }; +}; + +const createTaskDescriptor = ( + effectCall: ts.CallExpression, + taskNode: ts.Node, + operations: AsyncTaskOperations, +): ReactAsyncEffectTaskDescriptor | null => { + const unguardedWrite = operations.stateWrites.find( + (stateWrite) => !stateWrite.isGuarded && !stateWrite.hasOpaqueGuard, + ); + const unknownWrite = operations.stateWrites.find( + (stateWrite) => !stateWrite.isGuarded && stateWrite.hasOpaqueGuard, + ); + const unknownNode = unknownWrite?.callExpression ?? operations.unknownOperation; + if (!unguardedWrite && !unknownNode && operations.stateWrites.length === 0) return null; + return { + effectCall, + evidenceDescription: unguardedWrite + ? "A state write after an async suspension can commit after its Effect was superseded" + : unknownWrite + ? "A state write after an async suspension has an unmodeled ownership guard" + : "An operation after an async suspension has no checked React ownership summary", + evidenceNode: + unguardedWrite?.callExpression ?? + unknownNode ?? + operations.stateWrites[0]?.callExpression ?? + taskNode, + stateWriteNames: operations.stateWrites.map((stateWrite) => stateWrite.stateWriteName), + status: unguardedWrite + ? ReactAsyncOwnershipStatus.Unguarded + : unknownNode + ? ReactAsyncOwnershipStatus.Unknown + : ReactAsyncOwnershipStatus.Guarded, + taskNode, + }; +}; + +const isPromiseContinuationCall = (node: ts.Node): node is ts.CallExpression => + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + PROMISE_CONTINUATION_METHOD_NAMES.has(node.expression.name.text); + +const collectPromiseContinuationDescriptors = ( + ownerFunction: ts.FunctionLikeDeclaration, + effectCall: ts.CallExpression, + guards: EffectInvalidationGuards, + stateSetters: ReadonlySet, + typeChecker: ts.TypeChecker, +): ReadonlyArray => { + const descriptors: ReactAsyncEffectTaskDescriptor[] = []; + const visit = (node: ts.Node): void => { + if (node !== ownerFunction && isFunctionBoundary(node)) return; + if (isPromiseContinuationCall(node)) { + const stateWrites: AsyncStateWrite[] = []; + let unknownOperation: ts.Node | null = null; + for (const callbackExpression of node.arguments) { + const setterSymbol = getIdentifierSymbol(callbackExpression, typeChecker); + if (setterSymbol && stateSetters.has(setterSymbol)) { + stateWrites.push({ + callExpression: node, + hasOpaqueGuard: false, + isGuarded: false, + stateWriteName: callbackExpression.getText(), + }); + continue; + } + const callbackFunction = resolveFunction(callbackExpression, typeChecker); + if (!callbackFunction) { + unknownOperation ??= callbackExpression; + continue; + } + const callbackOperations = collectAsyncTaskOperations( + callbackFunction, + stateSetters, + guards, + typeChecker, + true, + ); + stateWrites.push(...callbackOperations.stateWrites); + unknownOperation ??= callbackOperations.unknownOperation; + } + const descriptor = createTaskDescriptor(effectCall, node, { + stateWrites, + unknownOperation, + }); + if (descriptor) descriptors.push(descriptor); + } + node.forEachChild(visit); + }; + ownerFunction.forEachChild(visit); + return descriptors; +}; + +export const collectAsyncEffectTaskDescriptors = ( + functionNode: ts.FunctionLikeDeclaration, + context: ReactAnalysisContext, +): ReadonlyArray => { + const hookBindings = collectHookBindings(functionNode, context.typeChecker); + const tasks: ReactAsyncEffectTaskDescriptor[] = []; + + for (const effectCall of collectEffectCalls(functionNode, context.typeChecker)) { + const effectCallback = getEffectCallback(effectCall, context.typeChecker); + if (!effectCallback) continue; + const cleanupFunctions = collectEffectCleanupFunctions(effectCallback, context.typeChecker); + const guards = collectInvalidationGuards(effectCallback, cleanupFunctions, context.typeChecker); + const taskFunctions = collectInvokedAsyncFunctions(effectCallback, context.typeChecker); + tasks.push( + ...collectPromiseContinuationDescriptors( + effectCallback, + effectCall, + guards, + hookBindings.stateSetters, + context.typeChecker, + ), + ); + for (const taskFunction of taskFunctions) { + const operations = collectAsyncTaskOperations( + taskFunction, + hookBindings.stateSetters, + guards, + context.typeChecker, + false, + ); + const descriptor = createTaskDescriptor(effectCall, taskFunction, operations); + if (descriptor) tasks.push(descriptor); + tasks.push( + ...collectPromiseContinuationDescriptors( + taskFunction, + effectCall, + guards, + hookBindings.stateSetters, + context.typeChecker, + ), + ); + } + } + return tasks; +}; diff --git a/packages/prover/src/collect-binding-identifiers.ts b/packages/prover/src/collect-binding-identifiers.ts new file mode 100644 index 000000000..f07aeb216 --- /dev/null +++ b/packages/prover/src/collect-binding-identifiers.ts @@ -0,0 +1,13 @@ +import ts from "typescript"; + +export const collectBindingIdentifiers = ( + bindingName: ts.BindingName, +): ReadonlyArray => { + if (ts.isIdentifier(bindingName)) return [bindingName]; + const identifiers: ts.Identifier[] = []; + for (const bindingElement of bindingName.elements) { + if (!ts.isBindingElement(bindingElement)) continue; + identifiers.push(...collectBindingIdentifiers(bindingElement.name)); + } + return identifiers; +}; diff --git a/packages/prover/src/collect-callable-ref-protocols.ts b/packages/prover/src/collect-callable-ref-protocols.ts new file mode 100644 index 000000000..946e69431 --- /dev/null +++ b/packages/prover/src/collect-callable-ref-protocols.ts @@ -0,0 +1,235 @@ +import ts from "typescript"; +import { collectEffectCalls } from "./collect-effect-calls.js"; +import { getCanonicalReactApiName } from "./get-canonical-react-api-name.js"; +import { getEffectCallback } from "./get-effect-callback.js"; +import { getRootIdentifier } from "./get-root-identifier.js"; +import { isFunctionBoundary } from "./is-function-boundary.js"; +import { isNodeWithin } from "./is-node-within.js"; +import { unwrapTypescriptExpression } from "./unwrap-typescript-expression.js"; +import { collectSymbolWrites } from "./utils/collect-symbol-writes.js"; +import { getEnclosingFunction } from "./utils/get-enclosing-function.js"; + +export interface CallableRefProtocolDescriptor { + declaration: ts.VariableDeclaration; + initialValueExpression: ts.Expression; + invocationExpressions: ReadonlyArray; + isSourceComplete: boolean; + ownerFunction: ts.FunctionLikeDeclaration; + refName: string; + refSymbol: ts.Symbol; + updateExpression: ts.Expression | null; + updateHookCall: ts.CallExpression | null; + updateHookName: string | null; + writeExpression: ts.BinaryExpression | null; +} + +const protocolCache = new WeakMap(); + +const getRefDeclaration = ( + symbol: ts.Symbol, + typeChecker: ts.TypeChecker, +): ts.VariableDeclaration | null => { + for (const declaration of symbol.declarations ?? []) { + if ( + ts.isVariableDeclaration(declaration) && + ts.isIdentifier(declaration.name) && + declaration.initializer && + ts.isCallExpression(declaration.initializer) && + getCanonicalReactApiName(declaration.initializer.expression, typeChecker) === "useRef" && + declaration.initializer.arguments[0] + ) { + return declaration; + } + } + return null; +}; + +const getExpressionSymbol = ( + expression: ts.Expression, + typeChecker: ts.TypeChecker, +): ts.Symbol | null => { + const unwrappedExpression = unwrapTypescriptExpression(expression); + if (!ts.isIdentifier(unwrappedExpression)) return null; + return typeChecker.getSymbolAtLocation(unwrappedExpression) ?? null; +}; + +const getRefCurrentAccess = ( + node: ts.Node, + refSymbol: ts.Symbol, + typeChecker: ts.TypeChecker, +): ts.PropertyAccessExpression | null => { + if (!ts.isPropertyAccessExpression(node) || node.name.text !== "current") return null; + const rootIdentifier = getRootIdentifier(node); + return rootIdentifier && typeChecker.getSymbolAtLocation(rootIdentifier) === refSymbol + ? node + : null; +}; + +const hasDependencyForExpression = ( + effectCall: ts.CallExpression, + expression: ts.Expression, + typeChecker: ts.TypeChecker, +): boolean => { + const dependencyExpression = effectCall.arguments[1]; + if (!dependencyExpression) return true; + if (!ts.isArrayLiteralExpression(dependencyExpression)) return false; + const expressionSymbol = getExpressionSymbol(expression, typeChecker); + return Boolean( + expressionSymbol && + dependencyExpression.elements.some( + (dependency) => + getExpressionSymbol(unwrapTypescriptExpression(dependency), typeChecker) === + expressionSymbol, + ), + ); +}; + +const createCallableRefProtocol = ( + declaration: ts.VariableDeclaration, + typeChecker: ts.TypeChecker, +): CallableRefProtocolDescriptor | null => { + if (protocolCache.has(declaration)) return protocolCache.get(declaration) ?? null; + const ownerFunction = getEnclosingFunction(declaration); + const refSymbol = ts.isIdentifier(declaration.name) + ? typeChecker.getSymbolAtLocation(declaration.name) + : null; + const initializer = + declaration.initializer && ts.isCallExpression(declaration.initializer) + ? declaration.initializer + : null; + const initialValueExpression = initializer?.arguments[0]; + if (!ownerFunction || !refSymbol || !initializer || !initialValueExpression) { + protocolCache.set(declaration, null); + return null; + } + const writes = collectSymbolWrites(refSymbol, declaration.getSourceFile(), typeChecker).filter( + (write) => isNodeWithin(write, ownerFunction), + ); + const writeExpression = + writes.length === 1 && + ts.isBinaryExpression(writes[0]) && + writes[0].operatorToken.kind === ts.SyntaxKind.EqualsToken && + Boolean(getRefCurrentAccess(writes[0].left, refSymbol, typeChecker)) + ? writes[0] + : null; + const updateExpression = writeExpression?.right ?? null; + const updateHookCall = + writeExpression && + collectEffectCalls(ownerFunction, typeChecker).find((effectCall) => { + const effectCallback = getEffectCallback(effectCall, typeChecker); + return Boolean(effectCallback && isNodeWithin(writeExpression, effectCallback)); + }); + const updateHookName = updateHookCall + ? getCanonicalReactApiName(updateHookCall.expression, typeChecker) + : null; + const invocationExpressions: ts.CallExpression[] = []; + let hasUnknownUse = false; + const currentAccesses = new Set(); + const visit = (node: ts.Node): void => { + if ( + ts.isIdentifier(node) && + typeChecker.getSymbolAtLocation(node) === refSymbol && + node !== declaration.name + ) { + const currentAccess = getRefCurrentAccess(node.parent, refSymbol, typeChecker); + if (!currentAccess) { + hasUnknownUse = true; + } else { + currentAccesses.add(currentAccess); + } + } + node.forEachChild(visit); + }; + ownerFunction.forEachChild(visit); + for (const currentAccess of currentAccesses) { + if (writeExpression?.left === currentAccess) continue; + if ( + ts.isCallExpression(currentAccess.parent) && + currentAccess.parent.expression === currentAccess + ) { + invocationExpressions.push(currentAccess.parent); + continue; + } + hasUnknownUse = true; + } + const initialValueSymbol = getExpressionSymbol(initialValueExpression, typeChecker); + const updateValueSymbol = updateExpression + ? getExpressionSymbol(updateExpression, typeChecker) + : null; + const isConstDeclaration = + ts.isVariableDeclarationList(declaration.parent) && + Boolean(declaration.parent.flags & ts.NodeFlags.Const); + const isSourceComplete = Boolean( + isConstDeclaration && + writes.length === 1 && + writeExpression && + updateHookCall && + updateExpression && + initialValueSymbol && + initialValueSymbol === updateValueSymbol && + hasDependencyForExpression(updateHookCall, updateExpression, typeChecker) && + invocationExpressions.length > 0 && + !hasUnknownUse, + ); + const protocol: CallableRefProtocolDescriptor = { + declaration, + initialValueExpression, + invocationExpressions, + isSourceComplete, + ownerFunction, + refName: declaration.name.getText(), + refSymbol, + updateExpression, + updateHookCall: updateHookCall ?? null, + updateHookName, + writeExpression, + }; + protocolCache.set(declaration, protocol); + return protocol; +}; + +export const getCallableRefProtocolForCurrentAccess = ( + accessExpression: ts.PropertyAccessExpression, + typeChecker: ts.TypeChecker, +): CallableRefProtocolDescriptor | null => { + if (accessExpression.name.text !== "current") return null; + const rootIdentifier = getRootIdentifier(accessExpression); + const refSymbol = rootIdentifier ? typeChecker.getSymbolAtLocation(rootIdentifier) : null; + const declaration = refSymbol ? getRefDeclaration(refSymbol, typeChecker) : null; + return declaration ? createCallableRefProtocol(declaration, typeChecker) : null; +}; + +export const getCallableRefProtocolForInitializer = ( + callExpression: ts.CallExpression, + typeChecker: ts.TypeChecker, +): CallableRefProtocolDescriptor | null => { + if (getCanonicalReactApiName(callExpression.expression, typeChecker) !== "useRef") return null; + const declaration = ts.isVariableDeclaration(callExpression.parent) + ? callExpression.parent + : null; + return declaration ? createCallableRefProtocol(declaration, typeChecker) : null; +}; + +export const collectCallableRefProtocols = ( + functionNode: ts.FunctionLikeDeclaration, + typeChecker: ts.TypeChecker, +): ReadonlyArray => { + const protocols: CallableRefProtocolDescriptor[] = []; + const visit = (node: ts.Node): void => { + if (node !== functionNode && isFunctionBoundary(node)) return; + if ( + ts.isVariableDeclaration(node) && + node.initializer && + ts.isCallExpression(node.initializer) && + getCanonicalReactApiName(node.initializer.expression, typeChecker) === "useRef" && + node.initializer.arguments[0] && + typeChecker.getTypeAtLocation(node.initializer.arguments[0]).getCallSignatures().length > 0 + ) { + const protocol = createCallableRefProtocol(node, typeChecker); + if (protocol) protocols.push(protocol); + } + node.forEachChild(visit); + }; + functionNode.forEachChild(visit); + return protocols; +}; diff --git a/packages/prover/src/collect-callable-target-functions.ts b/packages/prover/src/collect-callable-target-functions.ts new file mode 100644 index 000000000..ff12fcf82 --- /dev/null +++ b/packages/prover/src/collect-callable-target-functions.ts @@ -0,0 +1,20 @@ +import ts from "typescript"; +import type { ResolvedCallableValueDescriptor } from "./resolve-callable-expression.js"; + +export const collectCallableTargetFunctions = ( + bindings: ReadonlyMap, +): ReadonlySet => { + const functionNodes = new Set(); + const visitedValues = new Set(); + const visitValue = (value: ResolvedCallableValueDescriptor): void => { + if (visitedValues.has(value)) return; + visitedValues.add(value); + for (const target of value.targets) { + functionNodes.add(target.functionNode); + for (const capturedValue of target.bindings.values()) visitValue(capturedValue); + } + for (const propertyValue of value.properties.values()) visitValue(propertyValue); + }; + for (const value of bindings.values()) visitValue(value); + return functionNodes; +}; diff --git a/packages/prover/src/collect-callback-state-writes.ts b/packages/prover/src/collect-callback-state-writes.ts new file mode 100644 index 000000000..83ea8635f --- /dev/null +++ b/packages/prover/src/collect-callback-state-writes.ts @@ -0,0 +1,28 @@ +import ts from "typescript"; +import { collectHookBindings } from "./collect-hook-bindings.js"; +import { collectReachableFunctions } from "./collect-reachable-functions.js"; +import { getCallName } from "./get-call-name.js"; +import { isFunctionBoundary } from "./is-function-boundary.js"; + +export const collectCallbackStateWrites = ( + callbackFunction: ts.FunctionLikeDeclaration, + ownerFunction: ts.FunctionLikeDeclaration, + typeChecker: ts.TypeChecker, +): ReadonlyArray => { + const stateSetters = collectHookBindings(ownerFunction, typeChecker).stateSetters; + const stateWriteNames = new Set(); + for (const reachableFunction of collectReachableFunctions(callbackFunction, typeChecker)) { + const visit = (node: ts.Node): void => { + if (node !== reachableFunction.functionNode && isFunctionBoundary(node)) return; + if (ts.isCallExpression(node)) { + const callSymbol = typeChecker.getSymbolAtLocation(node.expression); + if (callSymbol && stateSetters.has(callSymbol)) { + stateWriteNames.add(getCallName(node) ?? "state setter"); + } + } + node.forEachChild(visit); + }; + reachableFunction.functionNode.forEachChild(visit); + } + return [...stateWriteNames]; +}; diff --git a/packages/prover/src/collect-class-construction.ts b/packages/prover/src/collect-class-construction.ts new file mode 100644 index 000000000..7409129e8 --- /dev/null +++ b/packages/prover/src/collect-class-construction.ts @@ -0,0 +1,731 @@ +import ts from "typescript"; +import { KNOWN_IMPURE_RENDER_CALLS } from "./constants.js"; +import { getCallName } from "./get-call-name.js"; +import { getRootIdentifier } from "./get-root-identifier.js"; +import { isFunctionBoundary } from "./is-function-boundary.js"; +import { resolveFunction } from "./resolve-function.js"; +import { + ReactClassConstructionIssueKind, + ReactClassConstructionIssueStatus, + ReactClassStateInitializationKind, + ReactClassStateInitializationRequirement, +} from "./types.js"; +import { unwrapTypescriptExpression } from "./unwrap-typescript-expression.js"; +import type { ReactAnalysisContext } from "./types.js"; +import { getClassMethodDeclaration } from "./utils/get-class-method-declaration.js"; +import { getResolvedSymbol } from "./utils/get-resolved-symbol.js"; +import { getStaticAccessMemberName } from "./utils/get-static-access-member-name.js"; +import { getStaticPropertyName } from "./utils/get-static-property-name.js"; +import { isAssignmentOperator } from "./utils/is-assignment-operator.js"; +import { isPlatformDeclarationSymbol } from "./utils/is-platform-declaration-symbol.js"; +import { isReactSetStateCall } from "./utils/is-react-set-state-call.js"; + +export interface ClassConstructionIssueDescriptor { + kind: ReactClassConstructionIssueKind; + node: ts.Node; + status: ReactClassConstructionIssueStatus; +} + +export interface ClassConstructionDescriptor { + constructorDeclaration: ts.ConstructorDeclaration | null; + initializationKind: ReactClassStateInitializationKind; + initializationNode: ts.Node | null; + issues: ReadonlyArray; + representedMembers: ReadonlyArray; + stateRequirement: ReactClassStateInitializationRequirement; +} + +const KNOWN_CONSTRUCTION_SIDE_EFFECT_CALLS = new Set([ + "console.error", + "console.info", + "console.log", + "console.warn", + "document.write", +]); + +const KNOWN_CONSTRUCTION_SIDE_EFFECT_CALL_MEMBERS = new Set([ + "addEventListener", + "alert", + "clear", + "dispatchEvent", + "fetch", + "queueMicrotask", + "removeEventListener", + "removeItem", + "requestAnimationFrame", + "setInterval", + "setItem", + "setTimeout", + "write", +]); + +const isKnownConstructionSideEffectCall = ( + callExpression: ts.CallExpression, + context: ReactAnalysisContext, +): boolean => { + const callName = getCallName(callExpression); + const callSymbol = getResolvedSymbol(callExpression.expression, context.typeChecker); + if (!callName || !callSymbol || !isPlatformDeclarationSymbol(callSymbol)) return false; + if ( + KNOWN_IMPURE_RENDER_CALLS.has(callName) || + KNOWN_CONSTRUCTION_SIDE_EFFECT_CALLS.has(callName) + ) { + return true; + } + const finalCallName = callName.split(".").at(-1); + return Boolean(finalCallName && KNOWN_CONSTRUCTION_SIDE_EFFECT_CALL_MEMBERS.has(finalCallName)); +}; + +const isDirectThisStateAccess = (expression: ts.Expression): boolean => { + const unwrappedExpression = unwrapTypescriptExpression(expression); + return Boolean( + (ts.isPropertyAccessExpression(unwrappedExpression) || + ts.isElementAccessExpression(unwrappedExpression)) && + unwrappedExpression.expression.kind === ts.SyntaxKind.ThisKeyword && + getStaticAccessMemberName(unwrappedExpression) === "state", + ); +}; + +const getDirectThisPropertyAccess = ( + expression: ts.Expression, +): ts.PropertyAccessExpression | ts.ElementAccessExpression | null => { + const unwrappedExpression = unwrapTypescriptExpression(expression); + return (ts.isPropertyAccessExpression(unwrappedExpression) || + ts.isElementAccessExpression(unwrappedExpression)) && + unwrappedExpression.expression.kind === ts.SyntaxKind.ThisKeyword + ? unwrappedExpression + : null; +}; + +const isStateRead = (expression: ts.Expression): boolean => { + if (!isDirectThisStateAccess(expression)) return false; + const parentNode = expression.parent; + return !( + ts.isBinaryExpression(parentNode) && + parentNode.left === expression && + parentNode.operatorToken.kind === ts.SyntaxKind.EqualsToken + ); +}; + +const containsStateRead = (rootNode: ts.Node, includeNestedFunctions = false): boolean => { + let hasStateRead = false; + const visit = (node: ts.Node): void => { + if (hasStateRead) return; + if (!includeNestedFunctions && node !== rootNode && isFunctionBoundary(node)) return; + if (ts.isExpression(node) && isStateRead(node)) { + hasStateRead = true; + return; + } + node.forEachChild(visit); + }; + rootNode.forEachChild(visit); + return hasStateRead; +}; + +const hasMountUpdaterStateDereference = ( + classNode: ts.ClassDeclaration, + context: ReactAnalysisContext, +): boolean => { + const mountMethod = getClassMethodDeclaration(classNode, "componentDidMount"); + if (!mountMethod) return false; + let hasStateDereference = false; + const visit = (node: ts.Node): void => { + if (hasStateDereference) return; + if (node !== mountMethod && isFunctionBoundary(node)) return; + if (ts.isCallExpression(node) && isReactSetStateCall(node, context)) { + const updaterExpression = node.arguments[0]; + const updaterFunction = updaterExpression + ? resolveFunction(updaterExpression, context.typeChecker) + : null; + const previousStateParameter = updaterFunction?.parameters[0]; + if (!updaterFunction || !previousStateParameter) return; + if (!ts.isIdentifier(previousStateParameter.name)) { + hasStateDereference = true; + return; + } + const previousStateSymbol = context.typeChecker.getSymbolAtLocation( + previousStateParameter.name, + ); + const visitUpdater = (updaterNode: ts.Node): void => { + if (hasStateDereference) return; + if (updaterNode !== updaterFunction && isFunctionBoundary(updaterNode)) return; + if ( + (ts.isPropertyAccessExpression(updaterNode) || + ts.isElementAccessExpression(updaterNode)) && + context.typeChecker.getSymbolAtLocation(getRootIdentifier(updaterNode) ?? updaterNode) === + previousStateSymbol + ) { + hasStateDereference = true; + return; + } + updaterNode.forEachChild(visitUpdater); + }; + updaterFunction.forEachChild(visitUpdater); + return; + } + node.forEachChild(visit); + }; + mountMethod.forEachChild(visit); + return hasStateDereference; +}; + +const getStateRequirement = ( + classNode: ts.ClassDeclaration, + renderMethod: ts.MethodDeclaration, + context: ReactAnalysisContext, +): ReactClassStateInitializationRequirement => { + if (containsStateRead(renderMethod) || hasMountUpdaterStateDereference(classNode, context)) { + return ReactClassStateInitializationRequirement.Required; + } + const hasRequiredLifecycleRead = classNode.members.some( + (member) => + ts.isMethodDeclaration(member) && + ["componentDidMount", "componentDidUpdate", "componentWillUnmount"].includes( + getStaticPropertyName(member.name) ?? "", + ) && + containsStateRead(member), + ); + if (hasRequiredLifecycleRead) return ReactClassStateInitializationRequirement.Required; + return classNode.members.some( + (member) => member !== renderMethod && containsStateRead(member, true), + ) + ? ReactClassStateInitializationRequirement.Conditional + : ReactClassStateInitializationRequirement.None; +}; + +const addIssue = ( + issues: ClassConstructionIssueDescriptor[], + node: ts.Node, + kind: ReactClassConstructionIssueKind, + status: ReactClassConstructionIssueStatus, +): void => { + issues.push({ kind, node, status }); +}; + +const isParameterReference = (expression: ts.Expression, context: ReactAnalysisContext): boolean => + ts.isIdentifier(expression) && + Boolean( + context.typeChecker + .getSymbolAtLocation(expression) + ?.declarations?.some((declaration) => ts.isParameter(declaration)), + ); + +const isThisPropsExpression = (expression: ts.Expression): boolean => { + let currentExpression = unwrapTypescriptExpression(expression); + const members: string[] = []; + while ( + ts.isPropertyAccessExpression(currentExpression) || + ts.isElementAccessExpression(currentExpression) + ) { + const memberName = getStaticAccessMemberName(currentExpression); + if (!memberName) return false; + members.unshift(memberName); + currentExpression = unwrapTypescriptExpression(currentExpression.expression); + } + return currentExpression.kind === ts.SyntaxKind.ThisKeyword && members[0] === "props"; +}; + +const collectPureExpressionIssues = ( + expression: ts.Expression, + context: ReactAnalysisContext, + pureLocalSymbols: ReadonlySet = new Set(), +): ReadonlyArray => { + const issues: ClassConstructionIssueDescriptor[] = []; + const visit = (currentExpression: ts.Expression): void => { + const unwrappedExpression = unwrapTypescriptExpression(currentExpression); + if ( + ts.isStringLiteralLike(unwrappedExpression) || + ts.isNumericLiteral(unwrappedExpression) || + ts.isBigIntLiteral(unwrappedExpression) || + unwrappedExpression.kind === ts.SyntaxKind.TrueKeyword || + unwrappedExpression.kind === ts.SyntaxKind.FalseKeyword || + unwrappedExpression.kind === ts.SyntaxKind.NullKeyword + ) { + return; + } + if (ts.isIdentifier(unwrappedExpression)) { + const symbol = context.typeChecker.getSymbolAtLocation(unwrappedExpression); + if (symbol && pureLocalSymbols.has(symbol)) return; + if ( + unwrappedExpression.text === "undefined" || + unwrappedExpression.text === "NaN" || + unwrappedExpression.text === "Infinity" + ) { + return; + } + if (isParameterReference(unwrappedExpression, context)) return; + addIssue( + issues, + unwrappedExpression, + ReactClassConstructionIssueKind.UnsupportedInitializer, + ReactClassConstructionIssueStatus.Unknown, + ); + return; + } + if (ts.isPropertyAccessExpression(unwrappedExpression)) { + if (isThisPropsExpression(unwrappedExpression)) return; + const rootIdentifier = getRootIdentifier(unwrappedExpression); + if (rootIdentifier && isParameterReference(rootIdentifier, context)) return; + addIssue( + issues, + unwrappedExpression, + ReactClassConstructionIssueKind.UnsupportedInitializer, + ReactClassConstructionIssueStatus.Unknown, + ); + return; + } + if (ts.isElementAccessExpression(unwrappedExpression)) { + visit(unwrappedExpression.expression); + if (unwrappedExpression.argumentExpression) visit(unwrappedExpression.argumentExpression); + return; + } + if (ts.isObjectLiteralExpression(unwrappedExpression)) { + for (const property of unwrappedExpression.properties) { + if (ts.isPropertyAssignment(property)) { + if (ts.isComputedPropertyName(property.name)) visit(property.name.expression); + visit(property.initializer); + } else if (ts.isShorthandPropertyAssignment(property)) { + visit(property.name); + } else if (ts.isSpreadAssignment(property)) { + addIssue( + issues, + property, + ReactClassConstructionIssueKind.UnsupportedInitializer, + ReactClassConstructionIssueStatus.Unknown, + ); + } else { + addIssue( + issues, + property, + ReactClassConstructionIssueKind.UnsupportedInitializer, + ReactClassConstructionIssueStatus.Unknown, + ); + } + } + return; + } + if (ts.isArrayLiteralExpression(unwrappedExpression)) { + for (const element of unwrappedExpression.elements) { + if (ts.isSpreadElement(element)) { + addIssue( + issues, + element, + ReactClassConstructionIssueKind.UnsupportedInitializer, + ReactClassConstructionIssueStatus.Unknown, + ); + } else { + visit(element); + } + } + return; + } + if (ts.isArrowFunction(unwrappedExpression) || ts.isFunctionExpression(unwrappedExpression)) { + return; + } + if (ts.isTemplateExpression(unwrappedExpression)) { + for (const templateSpan of unwrappedExpression.templateSpans) visit(templateSpan.expression); + return; + } + if (ts.isNoSubstitutionTemplateLiteral(unwrappedExpression)) return; + if (ts.isConditionalExpression(unwrappedExpression)) { + visit(unwrappedExpression.condition); + visit(unwrappedExpression.whenTrue); + visit(unwrappedExpression.whenFalse); + return; + } + if (ts.isBinaryExpression(unwrappedExpression)) { + if (isAssignmentOperator(unwrappedExpression.operatorToken.kind)) { + addIssue( + issues, + unwrappedExpression, + ReactClassConstructionIssueKind.SideEffect, + ReactClassConstructionIssueStatus.Violated, + ); + return; + } + visit(unwrappedExpression.left); + visit(unwrappedExpression.right); + return; + } + if ( + ts.isPrefixUnaryExpression(unwrappedExpression) || + ts.isPostfixUnaryExpression(unwrappedExpression) + ) { + if ( + unwrappedExpression.operator === ts.SyntaxKind.PlusPlusToken || + unwrappedExpression.operator === ts.SyntaxKind.MinusMinusToken + ) { + addIssue( + issues, + unwrappedExpression, + ReactClassConstructionIssueKind.SideEffect, + ReactClassConstructionIssueStatus.Violated, + ); + } else { + visit(unwrappedExpression.operand); + } + return; + } + if (ts.isCallExpression(unwrappedExpression)) { + const isKnownSideEffect = isKnownConstructionSideEffectCall(unwrappedExpression, context); + addIssue( + issues, + unwrappedExpression, + isKnownSideEffect + ? ReactClassConstructionIssueKind.SideEffect + : ReactClassConstructionIssueKind.UnsupportedInitializer, + isKnownSideEffect + ? ReactClassConstructionIssueStatus.Violated + : ReactClassConstructionIssueStatus.Unknown, + ); + return; + } + if (ts.isNewExpression(unwrappedExpression)) { + const constructorSymbol = getResolvedSymbol( + unwrappedExpression.expression, + context.typeChecker, + ); + const isPlatformDate = + constructorSymbol?.getName() === "Date" && isPlatformDeclarationSymbol(constructorSymbol); + addIssue( + issues, + unwrappedExpression, + isPlatformDate + ? ReactClassConstructionIssueKind.SideEffect + : ReactClassConstructionIssueKind.UnsupportedInitializer, + isPlatformDate + ? ReactClassConstructionIssueStatus.Violated + : ReactClassConstructionIssueStatus.Unknown, + ); + return; + } + addIssue( + issues, + unwrappedExpression, + ReactClassConstructionIssueKind.UnsupportedInitializer, + ReactClassConstructionIssueStatus.Unknown, + ); + }; + visit(expression); + return issues; +}; + +const getMethodBindingName = ( + expression: ts.Expression, + context: ReactAnalysisContext, +): string | null => { + const unwrappedExpression = unwrapTypescriptExpression(expression); + if ( + !ts.isBinaryExpression(unwrappedExpression) || + unwrappedExpression.operatorToken.kind !== ts.SyntaxKind.EqualsToken || + !getDirectThisPropertyAccess(unwrappedExpression.left) + ) { + return null; + } + const leftProperty = getDirectThisPropertyAccess(unwrappedExpression.left); + if (!leftProperty) return null; + const rightExpression = unwrapTypescriptExpression(unwrappedExpression.right); + if ( + !ts.isCallExpression(rightExpression) || + rightExpression.arguments.length !== 1 || + rightExpression.arguments[0]?.kind !== ts.SyntaxKind.ThisKeyword + ) { + return null; + } + const callTarget = unwrapTypescriptExpression(rightExpression.expression); + if ( + !ts.isPropertyAccessExpression(callTarget) || + callTarget.name.text !== "bind" || + !getDirectThisPropertyAccess(callTarget.expression) + ) { + return null; + } + const rightProperty = getDirectThisPropertyAccess(callTarget.expression); + if (!rightProperty) return null; + const leftName = getStaticAccessMemberName(leftProperty); + const rightName = getStaticAccessMemberName(rightProperty); + return leftName === rightName && + isPlatformDeclarationSymbol(getResolvedSymbol(callTarget.name, context.typeChecker)) + ? leftName + : null; +}; + +const isSuperCallStatement = (statement: ts.Statement): boolean => + Boolean( + ts.isExpressionStatement(statement) && + ts.isCallExpression(statement.expression) && + statement.expression.expression.kind === ts.SyntaxKind.SuperKeyword, + ); + +const isThisSetStateCall = (expression: ts.Expression, context: ReactAnalysisContext): boolean => { + const unwrappedExpression = unwrapTypescriptExpression(expression); + return ts.isCallExpression(unwrappedExpression) + ? isReactSetStateCall(unwrappedExpression, context) + : false; +}; + +const hasValidSuperCall = ( + constructorDeclaration: ts.ConstructorDeclaration, + context: ReactAnalysisContext, +): boolean => { + const firstStatement = constructorDeclaration.body?.statements[0]; + if ( + !firstStatement || + !ts.isExpressionStatement(firstStatement) || + !isSuperCallStatement(firstStatement) + ) { + return false; + } + const superCall = firstStatement.expression; + if (!ts.isCallExpression(superCall)) return false; + if (constructorDeclaration.parameters.length === 0) return superCall.arguments.length === 0; + const firstParameter = constructorDeclaration.parameters[0]; + return Boolean( + firstParameter && + ts.isIdentifier(firstParameter.name) && + superCall.arguments.length === 1 && + ts.isIdentifier(superCall.arguments[0]) && + context.typeChecker.getSymbolAtLocation(firstParameter.name) === + context.typeChecker.getSymbolAtLocation(superCall.arguments[0]), + ); +}; + +const collectConstructorStateAssignments = ( + constructorDeclaration: ts.ConstructorDeclaration, +): ReadonlyArray => { + const assignments: ts.BinaryExpression[] = []; + const visit = (node: ts.Node): void => { + if (node !== constructorDeclaration && isFunctionBoundary(node)) return; + if ( + ts.isBinaryExpression(node) && + node.operatorToken.kind === ts.SyntaxKind.EqualsToken && + ts.isExpression(node.left) && + isDirectThisStateAccess(node.left) + ) { + assignments.push(node); + return; + } + node.forEachChild(visit); + }; + constructorDeclaration.body?.forEachChild(visit); + return assignments; +}; + +const collectPureConstructorLocalSymbols = ( + constructorDeclaration: ts.ConstructorDeclaration | null, + context: ReactAnalysisContext, +): ReadonlySet => { + const pureLocalSymbols = new Set(); + for (const statement of constructorDeclaration?.body?.statements ?? []) { + if ( + !ts.isVariableStatement(statement) || + !(statement.declarationList.flags & ts.NodeFlags.Const) + ) { + continue; + } + for (const declaration of statement.declarationList.declarations) { + if (!ts.isIdentifier(declaration.name) || !declaration.initializer) continue; + const declarationIssues = collectPureExpressionIssues( + declaration.initializer, + context, + pureLocalSymbols, + ); + const symbol = context.typeChecker.getSymbolAtLocation(declaration.name); + if (declarationIssues.length === 0 && symbol) pureLocalSymbols.add(symbol); + } + } + return pureLocalSymbols; +}; + +export const collectClassConstruction = ( + classNode: ts.ClassDeclaration, + renderMethod: ts.MethodDeclaration, + context: ReactAnalysisContext, +): ClassConstructionDescriptor => { + const issues: ClassConstructionIssueDescriptor[] = []; + const constructorDeclaration = classNode.members.find(ts.isConstructorDeclaration) ?? null; + const instanceFields = classNode.members.filter( + (member): member is ts.PropertyDeclaration => + ts.isPropertyDeclaration(member) && + !member.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.StaticKeyword), + ); + const stateFields = instanceFields.filter( + (member) => getStaticPropertyName(member.name) === "state", + ); + const constructorStateAssignments = constructorDeclaration + ? collectConstructorStateAssignments(constructorDeclaration) + : []; + const constructorStateAssignmentSet = new Set(constructorStateAssignments); + const pureLocalSymbols = collectPureConstructorLocalSymbols(constructorDeclaration, context); + const boundMethodNames = new Set(); + const stateRequirement = getStateRequirement(classNode, renderMethod, context); + const initializationNodes: ts.Node[] = [...stateFields, ...constructorStateAssignments]; + let initializationKind = ReactClassStateInitializationKind.None; + if (stateFields.length === 1 && constructorStateAssignments.length === 0) { + initializationKind = ReactClassStateInitializationKind.PublicField; + } else if (stateFields.length === 0 && constructorStateAssignments.length === 1) { + initializationKind = ReactClassStateInitializationKind.ConstructorAssignment; + } else if (initializationNodes.length > 1) { + initializationKind = ReactClassStateInitializationKind.Multiple; + addIssue( + issues, + initializationNodes[1] ?? classNode, + ReactClassConstructionIssueKind.MultipleStateInitializations, + ReactClassConstructionIssueStatus.Unknown, + ); + } + const initializer = stateFields[0]?.initializer ?? constructorStateAssignments[0]?.right ?? null; + if (initializer) { + const unwrappedInitializer = unwrapTypescriptExpression(initializer); + if (!ts.isObjectLiteralExpression(unwrappedInitializer)) { + const initializerType = context.typeChecker.getTypeAtLocation(initializer); + const isDefinitelyInvalid = Boolean( + initializerType.flags & + (ts.TypeFlags.StringLike | + ts.TypeFlags.NumberLike | + ts.TypeFlags.BooleanLike | + ts.TypeFlags.BigIntLike | + ts.TypeFlags.Null | + ts.TypeFlags.Undefined), + ); + addIssue( + issues, + initializer, + isDefinitelyInvalid + ? ReactClassConstructionIssueKind.InvalidStateValue + : ReactClassConstructionIssueKind.UnsupportedInitializer, + isDefinitelyInvalid + ? ReactClassConstructionIssueStatus.Violated + : ReactClassConstructionIssueStatus.Unknown, + ); + } else { + issues.push(...collectPureExpressionIssues(unwrappedInitializer, context, pureLocalSymbols)); + } + } else if (initializationNodes.length > 0) { + addIssue( + issues, + initializationNodes[0] ?? classNode, + ReactClassConstructionIssueKind.InvalidStateValue, + ReactClassConstructionIssueStatus.Violated, + ); + } else if (stateRequirement !== ReactClassStateInitializationRequirement.None) { + addIssue( + issues, + renderMethod, + ReactClassConstructionIssueKind.MissingStateInitialization, + stateRequirement === ReactClassStateInitializationRequirement.Required + ? ReactClassConstructionIssueStatus.Violated + : ReactClassConstructionIssueStatus.Unknown, + ); + } + for (const instanceField of instanceFields) { + if ( + !getStaticPropertyName(instanceField.name) || + instanceField.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.AccessorKeyword) + ) { + addIssue( + issues, + instanceField, + ReactClassConstructionIssueKind.UnsupportedInitializer, + ReactClassConstructionIssueStatus.Unknown, + ); + } + if (getStaticPropertyName(instanceField.name) === "state" || !instanceField.initializer) { + continue; + } + issues.push( + ...collectPureExpressionIssues(instanceField.initializer, context, pureLocalSymbols), + ); + } + if (constructorDeclaration) { + if (!constructorDeclaration.body || !hasValidSuperCall(constructorDeclaration, context)) { + addIssue( + issues, + constructorDeclaration, + ReactClassConstructionIssueKind.InvalidSuperCall, + ReactClassConstructionIssueStatus.Violated, + ); + } + for (const statement of constructorDeclaration.body?.statements ?? []) { + if (isSuperCallStatement(statement)) continue; + if ( + ts.isExpressionStatement(statement) && + ts.isBinaryExpression(statement.expression) && + constructorStateAssignmentSet.has(statement.expression) + ) { + continue; + } + if (ts.isVariableStatement(statement)) { + const declarationIssues = statement.declarationList.declarations.flatMap((declaration) => + declaration.initializer + ? collectPureExpressionIssues(declaration.initializer, context, pureLocalSymbols) + : [], + ); + if (declarationIssues.length > 0) { + issues.push(...declarationIssues); + continue; + } + } + if ( + ts.isVariableStatement(statement) && + statement.declarationList.declarations.every((declaration) => { + if (!ts.isIdentifier(declaration.name)) return false; + const symbol = context.typeChecker.getSymbolAtLocation(declaration.name); + return Boolean(symbol && pureLocalSymbols.has(symbol)); + }) + ) { + continue; + } + if (ts.isExpressionStatement(statement)) { + const boundMethodName = getMethodBindingName(statement.expression, context); + if (boundMethodName) { + boundMethodNames.add(boundMethodName); + continue; + } + } + if ( + ts.isExpressionStatement(statement) && + isThisSetStateCall(statement.expression, context) + ) { + addIssue( + issues, + statement, + ReactClassConstructionIssueKind.SetStateCall, + ReactClassConstructionIssueStatus.Violated, + ); + continue; + } + if ( + ts.isExpressionStatement(statement) && + (ts.isCallExpression(unwrapTypescriptExpression(statement.expression)) || + ts.isNewExpression(unwrapTypescriptExpression(statement.expression))) + ) { + issues.push( + ...collectPureExpressionIssues(statement.expression, context, pureLocalSymbols), + ); + continue; + } + addIssue( + issues, + statement, + ReactClassConstructionIssueKind.UnsupportedConstructorStatement, + ReactClassConstructionIssueStatus.Unknown, + ); + } + } + return { + constructorDeclaration, + initializationKind, + initializationNode: initializationNodes[0] ?? null, + issues, + representedMembers: [ + ...(constructorDeclaration ? [constructorDeclaration] : []), + ...instanceFields, + ...[...boundMethodNames].flatMap((methodName) => { + const method = getClassMethodDeclaration(classNode, methodName); + return method ? [method] : []; + }), + ], + stateRequirement, + }; +}; diff --git a/packages/prover/src/collect-class-state-transitions.ts b/packages/prover/src/collect-class-state-transitions.ts new file mode 100644 index 000000000..836726ac5 --- /dev/null +++ b/packages/prover/src/collect-class-state-transitions.ts @@ -0,0 +1,273 @@ +import ts from "typescript"; +import { analyzeUpdaterFunction } from "./analyze-updater-function.js"; +import { isFunctionBoundary } from "./is-function-boundary.js"; +import { isNodeWithin } from "./is-node-within.js"; +import { + ReactClassComponentBase, + ReactClassStateUpdaterStatus, + ReactClassUpdateCycleStatus, + ReactExecutionPhase, + ReactObligationStatus, +} from "./types.js"; +import { unwrapTypescriptExpression } from "./unwrap-typescript-expression.js"; +import type { ReactAnalysisContext } from "./types.js"; +import { getResolvedSymbol } from "./utils/get-resolved-symbol.js"; +import { getStaticAccessMemberName } from "./utils/get-static-access-member-name.js"; +import { isEntryDominatingNode } from "./utils/is-entry-dominating-node.js"; +import { isReactSetStateCall } from "./utils/is-react-set-state-call.js"; + +export interface ClassStateTransitionDescriptor { + callExpression: ts.CallExpression; + commitCallbackProvided: boolean; + cycleStatus: ReactClassUpdateCycleStatus; + guardNodes: ReadonlyArray; + isSourceComplete: boolean; + phase: ReactExecutionPhase.ClassMount | ReactExecutionPhase.ClassUpdate; + updaterFunction: ts.FunctionLikeDeclaration | null; + updaterStatus: ReactClassStateUpdaterStatus; +} + +interface ClassStateSourcePath { + members: ReadonlyArray; + source: "current-props" | "previous-props"; +} + +const getStateSourcePath = ( + expression: ts.Expression, + previousPropsSymbol: ts.Symbol, + context: ReactAnalysisContext, +): ClassStateSourcePath | null => { + let currentExpression = unwrapTypescriptExpression(expression); + const members: string[] = []; + while ( + ts.isPropertyAccessExpression(currentExpression) || + ts.isElementAccessExpression(currentExpression) + ) { + const memberName = getStaticAccessMemberName(currentExpression); + if (!memberName) return null; + members.unshift(memberName); + currentExpression = unwrapTypescriptExpression(currentExpression.expression); + } + if (currentExpression.kind === ts.SyntaxKind.ThisKeyword) { + const [domain, ...pathMembers] = members; + return domain === "props" ? { members: pathMembers, source: "current-props" } : null; + } + if ( + ts.isIdentifier(currentExpression) && + getResolvedSymbol(currentExpression, context.typeChecker) === previousPropsSymbol + ) { + return { members, source: "previous-props" }; + } + return null; +}; + +const areMatchingPropPaths = ( + leftPath: ClassStateSourcePath, + rightPath: ClassStateSourcePath, +): boolean => + leftPath.source !== rightPath.source && + leftPath.members.length === 1 && + leftPath.members.length === rightPath.members.length && + leftPath.members.every((member, memberIndex) => member === rightPath.members[memberIndex]); + +const isReflexivePropType = (type: ts.Type): boolean => { + if (type.isUnionOrIntersection()) { + return type.types.length > 0 && type.types.every(isReflexivePropType); + } + if (type.flags & ts.TypeFlags.NumberLiteral) return true; + if ( + type.flags & + (ts.TypeFlags.Any | + ts.TypeFlags.Unknown | + ts.TypeFlags.NumberLike | + ts.TypeFlags.TypeParameter | + ts.TypeFlags.Never) + ) { + return false; + } + return Boolean( + type.flags & + (ts.TypeFlags.StringLike | + ts.TypeFlags.BooleanLike | + ts.TypeFlags.BigIntLike | + ts.TypeFlags.ESSymbolLike | + ts.TypeFlags.Object | + ts.TypeFlags.Null | + ts.TypeFlags.Undefined), + ); +}; + +const isPropTransitionGuard = ( + expression: ts.Expression, + previousPropsSymbol: ts.Symbol, + context: ReactAnalysisContext, +): boolean => { + const unwrappedExpression = unwrapTypescriptExpression(expression); + if (ts.isBinaryExpression(unwrappedExpression)) { + if (unwrappedExpression.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken) { + return ( + isPropTransitionGuard(unwrappedExpression.left, previousPropsSymbol, context) || + isPropTransitionGuard(unwrappedExpression.right, previousPropsSymbol, context) + ); + } + if (unwrappedExpression.operatorToken.kind === ts.SyntaxKind.BarBarToken) { + return ( + isPropTransitionGuard(unwrappedExpression.left, previousPropsSymbol, context) && + isPropTransitionGuard(unwrappedExpression.right, previousPropsSymbol, context) + ); + } + if ( + unwrappedExpression.operatorToken.kind !== ts.SyntaxKind.ExclamationEqualsToken && + unwrappedExpression.operatorToken.kind !== ts.SyntaxKind.ExclamationEqualsEqualsToken + ) { + return false; + } + const leftPath = getStateSourcePath(unwrappedExpression.left, previousPropsSymbol, context); + const rightPath = getStateSourcePath(unwrappedExpression.right, previousPropsSymbol, context); + return Boolean( + leftPath && + rightPath && + areMatchingPropPaths(leftPath, rightPath) && + isReflexivePropType(context.typeChecker.getTypeAtLocation(unwrappedExpression.left)) && + isReflexivePropType(context.typeChecker.getTypeAtLocation(unwrappedExpression.right)), + ); + } + return false; +}; + +const collectPropTransitionGuards = ( + callExpression: ts.CallExpression, + lifecycleMethod: ts.MethodDeclaration, + context: ReactAnalysisContext, +): ReadonlyArray => { + const previousPropsParameter = lifecycleMethod.parameters[0]; + if (!previousPropsParameter || !ts.isIdentifier(previousPropsParameter.name)) return []; + const previousPropsSymbol = getResolvedSymbol(previousPropsParameter.name, context.typeChecker); + if (!previousPropsSymbol) return []; + const guardNodes: ts.Expression[] = []; + let currentNode: ts.Node | undefined = callExpression.parent; + while (currentNode && currentNode !== lifecycleMethod) { + if ( + ts.isIfStatement(currentNode) && + isNodeWithin(callExpression, currentNode.thenStatement) && + isPropTransitionGuard(currentNode.expression, previousPropsSymbol, context) + ) { + guardNodes.push(currentNode.expression); + } + currentNode = currentNode.parent; + } + return guardNodes; +}; + +const analyzeUpdater = ( + callExpression: ts.CallExpression, + context: ReactAnalysisContext, +): { + updaterFunction: ts.FunctionLikeDeclaration | null; + updaterStatus: ReactClassStateUpdaterStatus; +} => { + const updaterExpression = callExpression.arguments[0]; + if (!updaterExpression) { + return { + updaterFunction: null, + updaterStatus: ReactClassStateUpdaterStatus.Unknown, + }; + } + const unwrappedUpdater = unwrapTypescriptExpression(updaterExpression); + if (unwrappedUpdater.kind === ts.SyntaxKind.NullKeyword) { + return { + updaterFunction: null, + updaterStatus: ReactClassStateUpdaterStatus.Noop, + }; + } + if (ts.isObjectLiteralExpression(unwrappedUpdater)) { + return { + updaterFunction: null, + updaterStatus: ReactClassStateUpdaterStatus.Object, + }; + } + const updaterAnalysis = analyzeUpdaterFunction(unwrappedUpdater, context); + let updaterStatus = ReactClassStateUpdaterStatus.Unknown; + if (updaterAnalysis.status === ReactObligationStatus.Proved) { + updaterStatus = ReactClassStateUpdaterStatus.Pure; + } else if (updaterAnalysis.status === ReactObligationStatus.Violated) { + updaterStatus = ReactClassStateUpdaterStatus.Impure; + } + return { + updaterFunction: updaterAnalysis.updaterFunction, + updaterStatus, + }; +}; + +const collectMethodTransitions = ( + lifecycleMethod: ts.MethodDeclaration | null, + phase: ReactExecutionPhase.ClassMount | ReactExecutionPhase.ClassUpdate, + classComponentBase: ReactClassComponentBase, + context: ReactAnalysisContext, +): ReadonlyArray => { + if (!lifecycleMethod) return []; + const transitions: ClassStateTransitionDescriptor[] = []; + const visit = (node: ts.Node): void => { + if (node !== lifecycleMethod && isFunctionBoundary(node)) return; + if (ts.isCallExpression(node) && isReactSetStateCall(node, context)) { + const updater = analyzeUpdater(node, context); + const guardNodes = + phase === ReactExecutionPhase.ClassUpdate + ? collectPropTransitionGuards(node, lifecycleMethod, context) + : []; + let cycleStatus = ReactClassUpdateCycleStatus.None; + if (phase === ReactExecutionPhase.ClassUpdate) { + if (updater.updaterStatus === ReactClassStateUpdaterStatus.Noop) { + cycleStatus = ReactClassUpdateCycleStatus.None; + } else if (guardNodes.length > 0) { + cycleStatus = ReactClassUpdateCycleStatus.Bounded; + } else if ( + updater.updaterStatus === ReactClassStateUpdaterStatus.Object && + classComponentBase === ReactClassComponentBase.Component && + isEntryDominatingNode(node, lifecycleMethod) + ) { + cycleStatus = ReactClassUpdateCycleStatus.Guaranteed; + } else { + cycleStatus = ReactClassUpdateCycleStatus.Unknown; + } + } + const commitCallbackProvided = node.arguments.length > 1; + const isSourceComplete = + updater.updaterStatus !== ReactClassStateUpdaterStatus.Unknown && !commitCallbackProvided; + transitions.push({ + callExpression: node, + commitCallbackProvided, + cycleStatus, + guardNodes, + isSourceComplete, + phase, + updaterFunction: updater.updaterFunction, + updaterStatus: updater.updaterStatus, + }); + return; + } + node.forEachChild(visit); + }; + lifecycleMethod.forEachChild(visit); + return transitions; +}; + +export const collectClassStateTransitions = ( + mountMethod: ts.MethodDeclaration | null, + updateMethod: ts.MethodDeclaration | null, + classComponentBase: ReactClassComponentBase, + context: ReactAnalysisContext, +): ReadonlyArray => [ + ...collectMethodTransitions( + mountMethod, + ReactExecutionPhase.ClassMount, + classComponentBase, + context, + ), + ...collectMethodTransitions( + updateMethod, + ReactExecutionPhase.ClassUpdate, + classComponentBase, + context, + ), +]; diff --git a/packages/prover/src/collect-class-state-writes.ts b/packages/prover/src/collect-class-state-writes.ts new file mode 100644 index 000000000..a37a83de8 --- /dev/null +++ b/packages/prover/src/collect-class-state-writes.ts @@ -0,0 +1,269 @@ +import ts from "typescript"; +import { CLASS_STATE_MUTATING_METHOD_NAMES } from "./constants.js"; +import { isFunctionBoundary } from "./is-function-boundary.js"; +import { + ReactClassStateWriteKind, + ReactClassStateWriteStatus, + ReactExecutionPhase, +} from "./types.js"; +import { unwrapTypescriptExpression } from "./unwrap-typescript-expression.js"; +import type { ReactAnalysisContext } from "./types.js"; +import { getResolvedSymbol } from "./utils/get-resolved-symbol.js"; +import { getStaticPropertyName } from "./utils/get-static-property-name.js"; +import { isThisStateExpression } from "./utils/is-this-state-expression.js"; +import { isPlatformDeclarationSymbol } from "./utils/is-platform-declaration-symbol.js"; +import { isAssignmentOperator } from "./utils/is-assignment-operator.js"; + +export interface ClassStateWriteDescriptor { + callbackId: string; + kind: ReactClassStateWriteKind; + node: ts.Node; + phase: + | ReactExecutionPhase.ClassMount + | ReactExecutionPhase.ClassUnmount + | ReactExecutionPhase.ClassUpdate + | ReactExecutionPhase.Deferred + | ReactExecutionPhase.StateTransition; + status: ReactClassStateWriteStatus; +} + +export interface ClassStateWriteRootDescriptor { + callbackId: string; + functionNode: ts.FunctionLikeDeclaration; + phase: + | ReactExecutionPhase.ClassMount + | ReactExecutionPhase.ClassUnmount + | ReactExecutionPhase.ClassUpdate + | ReactExecutionPhase.Deferred + | ReactExecutionPhase.StateTransition; +} + +const isThisStateAssignmentTarget = (node: ts.Node): boolean => { + if (ts.isExpression(node) && isThisStateExpression(node)) return true; + if (ts.isParenthesizedExpression(node)) return isThisStateAssignmentTarget(node.expression); + if (ts.isArrayLiteralExpression(node)) { + return node.elements.some(isThisStateAssignmentTarget); + } + if (ts.isObjectLiteralExpression(node)) { + return node.properties.some((property) => { + if (ts.isPropertyAssignment(property)) { + return isThisStateAssignmentTarget(property.initializer); + } + if (ts.isSpreadAssignment(property)) { + return isThisStateAssignmentTarget(property.expression); + } + return false; + }); + } + return false; +}; + +const isDefinitelyPrimitiveType = (type: ts.Type): boolean => { + if (type.isUnionOrIntersection()) { + return type.types.length > 0 && type.types.every(isDefinitelyPrimitiveType); + } + return Boolean( + type.flags & + (ts.TypeFlags.StringLike | + ts.TypeFlags.NumberLike | + ts.TypeFlags.BooleanLike | + ts.TypeFlags.BigIntLike | + ts.TypeFlags.ESSymbolLike | + ts.TypeFlags.Null | + ts.TypeFlags.Undefined | + ts.TypeFlags.Void | + ts.TypeFlags.Never), + ); +}; + +const isObjectAssignMutation = ( + callExpression: ts.CallExpression, + context: ReactAnalysisContext, +): boolean => { + const callTarget = unwrapTypescriptExpression(callExpression.expression); + return Boolean( + ts.isPropertyAccessExpression(callTarget) && + ts.isIdentifier(callTarget.expression) && + callTarget.expression.text === "Object" && + callTarget.name.text === "assign" && + isPlatformDeclarationSymbol(getResolvedSymbol(callTarget.name, context.typeChecker)) && + callExpression.arguments[0] && + isThisStateExpression(callExpression.arguments[0]), + ); +}; + +const getMethodOwnerName = (symbol: ts.Symbol): string | null => { + for (const declaration of symbol.declarations ?? []) { + let currentNode: ts.Node | undefined = declaration.parent; + while (currentNode) { + if ( + (ts.isInterfaceDeclaration(currentNode) || ts.isClassDeclaration(currentNode)) && + currentNode.name + ) { + return currentNode.name.text; + } + currentNode = currentNode.parent; + } + } + return null; +}; + +const isKnownMutatingMethod = ( + property: ts.PropertyName, + methodName: string, + context: ReactAnalysisContext, +): boolean => { + const methodSymbol = getResolvedSymbol(property, context.typeChecker); + if (!methodSymbol || !isPlatformDeclarationSymbol(methodSymbol)) return false; + const ownerName = getMethodOwnerName(methodSymbol); + if (!ownerName) return false; + if (methodName === "add") return ownerName === "Set"; + if (methodName === "set") return ownerName === "Map" || ownerName === "WeakMap"; + if (methodName === "clear") return ownerName === "Map" || ownerName === "Set"; + if (methodName === "delete") { + return ( + ownerName === "Map" || + ownerName === "Set" || + ownerName === "WeakMap" || + ownerName === "WeakSet" + ); + } + return ownerName === "Array"; +}; + +const isStateMutatingCall = ( + callExpression: ts.CallExpression, + context: ReactAnalysisContext, +): boolean => { + if (isObjectAssignMutation(callExpression, context)) return true; + const callTarget = unwrapTypescriptExpression(callExpression.expression); + if (!ts.isPropertyAccessExpression(callTarget)) { + return false; + } + const methodName = getStaticPropertyName(callTarget.name); + return Boolean( + methodName && + CLASS_STATE_MUTATING_METHOD_NAMES.has(methodName) && + isThisStateExpression(callTarget.expression) && + isKnownMutatingMethod(callTarget.name, methodName, context), + ); +}; + +const isStateReferenceEscape = ( + expression: ts.Expression, + context: ReactAnalysisContext, +): boolean => { + if (!isThisStateExpression(expression)) return false; + if (isDefinitelyPrimitiveType(context.typeChecker.getTypeAtLocation(expression))) return false; + const parent = expression.parent; + if ( + (ts.isPropertyAccessExpression(parent) || ts.isElementAccessExpression(parent)) && + parent.expression === expression + ) { + return false; + } + if (ts.isBinaryExpression(parent)) { + if (isAssignmentOperator(parent.operatorToken.kind)) return parent.right === expression; + if ( + parent.operatorToken.kind === ts.SyntaxKind.BarBarToken || + parent.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken + ) { + return true; + } + if ( + parent.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken || + parent.operatorToken.kind === ts.SyntaxKind.CommaToken + ) { + return parent.right === expression; + } + return false; + } + if (ts.isConditionalExpression(parent)) return parent.condition !== expression; + if (ts.isAwaitExpression(parent) || ts.isYieldExpression(parent)) return true; + if ( + ts.isPrefixUnaryExpression(parent) || + ts.isPostfixUnaryExpression(parent) || + ts.isDeleteExpression(parent) + ) { + return false; + } + if (ts.isCallExpression(parent)) { + const callTarget = unwrapTypescriptExpression(parent.expression); + return ( + parent.arguments.includes(expression) && + !( + ts.isPropertyAccessExpression(callTarget) && + callTarget.expression.kind === ts.SyntaxKind.ThisKeyword && + callTarget.name.text === "setState" + ) + ); + } + if (ts.isNewExpression(parent)) return parent.arguments?.includes(expression) ?? false; + if (ts.isVariableDeclaration(parent)) return parent.initializer === expression; + if (ts.isReturnStatement(parent)) return parent.expression === expression; + if (ts.isPropertyAssignment(parent)) return parent.initializer === expression; + if (ts.isArrayLiteralExpression(parent)) return parent.elements.includes(expression); + if (ts.isSpreadAssignment(parent) || ts.isSpreadElement(parent)) return true; + return false; +}; + +const collectMethodStateWrites = ( + descriptor: ClassStateWriteRootDescriptor, + context: ReactAnalysisContext, +): ReadonlyArray => { + const writes: ClassStateWriteDescriptor[] = []; + const addWrite = ( + node: ts.Node, + kind: ReactClassStateWriteKind, + status: ReactClassStateWriteStatus, + ): void => { + writes.push({ + callbackId: descriptor.callbackId, + kind, + node, + phase: descriptor.phase, + status, + }); + }; + const visit = (node: ts.Node): void => { + if (node !== descriptor.functionNode && isFunctionBoundary(node)) return; + if ( + ts.isBinaryExpression(node) && + isAssignmentOperator(node.operatorToken.kind) && + isThisStateAssignmentTarget(node.left) + ) { + addWrite(node, ReactClassStateWriteKind.Assignment, ReactClassStateWriteStatus.Forbidden); + return; + } + if ( + (ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) && + (node.operator === ts.SyntaxKind.PlusPlusToken || + node.operator === ts.SyntaxKind.MinusMinusToken) && + isThisStateExpression(node.operand) + ) { + addWrite(node, ReactClassStateWriteKind.Update, ReactClassStateWriteStatus.Forbidden); + return; + } + if (ts.isDeleteExpression(node) && isThisStateExpression(node.expression)) { + addWrite(node, ReactClassStateWriteKind.Delete, ReactClassStateWriteStatus.Forbidden); + return; + } + if (ts.isCallExpression(node) && isStateMutatingCall(node, context)) { + addWrite(node, ReactClassStateWriteKind.MutatingCall, ReactClassStateWriteStatus.Forbidden); + return; + } + if (ts.isExpression(node) && isStateReferenceEscape(node, context)) { + addWrite(node, ReactClassStateWriteKind.ReferenceEscape, ReactClassStateWriteStatus.Unknown); + return; + } + node.forEachChild(visit); + }; + descriptor.functionNode.forEachChild(visit); + return writes; +}; + +export const collectClassStateWrites = ( + roots: ReadonlyArray, + context: ReactAnalysisContext, +): ReadonlyArray => + roots.flatMap((descriptor) => collectMethodStateWrites(descriptor, context)); diff --git a/packages/prover/src/collect-direct-hook-calls.ts b/packages/prover/src/collect-direct-hook-calls.ts new file mode 100644 index 000000000..d9d736550 --- /dev/null +++ b/packages/prover/src/collect-direct-hook-calls.ts @@ -0,0 +1,23 @@ +import ts from "typescript"; +import { getCanonicalHookName } from "./get-canonical-hook-name.js"; +import { isFunctionBoundary } from "./is-function-boundary.js"; +import { isReactHookName } from "./is-react-hook-name.js"; + +export const collectDirectHookCalls = ( + owner: ts.Node, + typeChecker: ts.TypeChecker, +): ReadonlyArray => { + const hookCalls: ts.CallExpression[] = []; + const visit = (node: ts.Node): void => { + if (node !== owner && isFunctionBoundary(node)) { + return; + } + if (ts.isCallExpression(node)) { + const callName = getCanonicalHookName(node, typeChecker); + if (callName && isReactHookName(callName)) hookCalls.push(node); + } + node.forEachChild(visit); + }; + owner.forEachChild(visit); + return hookCalls; +}; diff --git a/packages/prover/src/collect-effect-calls.ts b/packages/prover/src/collect-effect-calls.ts new file mode 100644 index 000000000..a9196b604 --- /dev/null +++ b/packages/prover/src/collect-effect-calls.ts @@ -0,0 +1,9 @@ +import ts from "typescript"; +import { collectHookCalls } from "./collect-hook-calls.js"; +import { REACT_EFFECT_HOOK_NAMES } from "./constants.js"; + +export const collectEffectCalls = ( + functionNode: ts.FunctionLikeDeclaration, + typeChecker: ts.TypeChecker, +): ReadonlyArray => + collectHookCalls(functionNode, REACT_EFFECT_HOOK_NAMES, typeChecker); diff --git a/packages/prover/src/collect-effect-cleanup-functions.ts b/packages/prover/src/collect-effect-cleanup-functions.ts new file mode 100644 index 000000000..1add2c3d0 --- /dev/null +++ b/packages/prover/src/collect-effect-cleanup-functions.ts @@ -0,0 +1,31 @@ +import ts from "typescript"; +import { resolveFunction } from "./resolve-function.js"; +import { isFunctionBoundary } from "./is-function-boundary.js"; + +export const collectEffectCleanupFunctions = ( + effectCallback: ts.FunctionLikeDeclaration, + typeChecker: ts.TypeChecker, +): ReadonlyArray => { + if (!effectCallback.body) return []; + if (!ts.isBlock(effectCallback.body)) { + const cleanupFunction = resolveFunction(effectCallback.body, typeChecker); + return cleanupFunction ? [cleanupFunction] : []; + } + const cleanupFunctions: ts.FunctionLikeDeclaration[] = []; + const cleanupFunctionSet = new Set(); + const visit = (node: ts.Node): void => { + if (node !== effectCallback.body && isFunctionBoundary(node)) { + return; + } + if (ts.isReturnStatement(node) && node.expression) { + const cleanupFunction = resolveFunction(node.expression, typeChecker); + if (cleanupFunction && !cleanupFunctionSet.has(cleanupFunction)) { + cleanupFunctionSet.add(cleanupFunction); + cleanupFunctions.push(cleanupFunction); + } + } + node.forEachChild(visit); + }; + effectCallback.body.forEachChild(visit); + return cleanupFunctions; +}; diff --git a/packages/prover/src/collect-effect-event-bindings.ts b/packages/prover/src/collect-effect-event-bindings.ts new file mode 100644 index 000000000..c83eb6f35 --- /dev/null +++ b/packages/prover/src/collect-effect-event-bindings.ts @@ -0,0 +1,44 @@ +import ts from "typescript"; +import { getCanonicalHookName } from "./get-canonical-hook-name.js"; +import { isFunctionBoundary } from "./is-function-boundary.js"; +import { resolveFunction } from "./resolve-function.js"; + +export interface EffectEventBinding { + callExpression: ts.CallExpression; + callback: ts.FunctionLikeDeclaration | null; + declaration: ts.VariableDeclaration; + name: string; + symbol: ts.Symbol; +} + +export const collectEffectEventBindings = ( + functionNode: ts.FunctionLikeDeclaration, + typeChecker: ts.TypeChecker, +): ReadonlyArray => { + const bindings: EffectEventBinding[] = []; + const visit = (node: ts.Node): void => { + if (node !== functionNode && isFunctionBoundary(node)) return; + if ( + ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + node.initializer && + ts.isCallExpression(node.initializer) && + getCanonicalHookName(node.initializer, typeChecker) === "useEffectEvent" + ) { + const symbol = typeChecker.getSymbolAtLocation(node.name); + const callbackExpression = node.initializer.arguments[0]; + if (symbol) { + bindings.push({ + callExpression: node.initializer, + callback: callbackExpression ? resolveFunction(callbackExpression, typeChecker) : null, + declaration: node, + name: node.name.text, + symbol, + }); + } + } + node.forEachChild(visit); + }; + functionNode.forEachChild(visit); + return bindings; +}; diff --git a/packages/prover/src/collect-effect-resource-protocols.ts b/packages/prover/src/collect-effect-resource-protocols.ts new file mode 100644 index 000000000..6e274e8b3 --- /dev/null +++ b/packages/prover/src/collect-effect-resource-protocols.ts @@ -0,0 +1,530 @@ +import ts from "typescript"; +import { collectEffectCleanupFunctions } from "./collect-effect-cleanup-functions.js"; +import { collectEffectCalls } from "./collect-effect-calls.js"; +import { + collectReachableFunctionGraph, + collectReachableFunctions, +} from "./collect-reachable-functions.js"; +import { PLATFORM_OBSERVER_KINDS } from "./constants.js"; +import { getEffectCallback } from "./get-effect-callback.js"; +import { ReactEffectResourceDisposalStatus, ReactEffectResourceKind } from "./types.js"; +import type { ReactAnalysisContext } from "./types.js"; +import { areImmutableExpressionsIdentical } from "./utils/are-immutable-expressions-identical.js"; +import { collectReachableCallExpressions } from "./utils/collect-reachable-call-expressions.js"; +import { getEnclosingFunction } from "./utils/get-enclosing-function.js"; +import { getPlatformEffectResourceKind } from "./utils/get-platform-effect-resource-kind.js"; +import { getResolvedSymbol } from "./utils/get-resolved-symbol.js"; +import { getStaticPropertyName } from "./utils/get-static-property-name.js"; +import { hasConditionalAncestor } from "./utils/has-conditional-ancestor.js"; +import { hasGuaranteedEffectCleanup } from "./utils/has-guaranteed-effect-cleanup.js"; +import { isEntryDominatingNode } from "./utils/is-entry-dominating-node.js"; +import { isPlatformDeclarationSymbol } from "./utils/is-platform-declaration-symbol.js"; +import { isPlatformResourceValue } from "./utils/is-platform-resource-value.js"; +import { unwrapTypescriptExpression } from "./unwrap-typescript-expression.js"; + +export interface LifecycleResourceProtocolDescriptor { + acquisitionNode: ts.Node; + acquisitionNodes: ReadonlyArray; + callbackExpression: ts.Expression | null; + disposalCalls: ReadonlyArray; + disposalStatus: ReactEffectResourceDisposalStatus; + isSourceComplete: boolean; + kind: ReactEffectResourceKind; +} + +export interface EffectResourceProtocolDescriptor extends LifecycleResourceProtocolDescriptor { + effectCall: ts.CallExpression; +} + +interface EventListenerDescriptor { + eventExpression: ts.Expression; + handlerExpression: ts.Expression; + capture: boolean | null; + signalControllerExpression: ts.Expression | null; + targetExpression: ts.Expression; +} + +interface ObserverDescriptor { + activationCalls: ts.CallExpression[]; + callbackExpression: ts.Expression | null; + kind: ReactEffectResourceKind; + resourceExpression: ts.Expression; +} + +interface EffectResourceDisposal { + calls: ReadonlyArray; + status: ReactEffectResourceDisposalStatus; +} + +const isPlatformMember = ( + node: ts.Node, + expectedName: string, + typeChecker: ts.TypeChecker, +): boolean => { + const symbol = getResolvedSymbol(node, typeChecker); + return Boolean( + symbol && symbol.getName() === expectedName && isPlatformDeclarationSymbol(symbol), + ); +}; + +const getStaticBoolean = ( + expression: ts.Expression, + typeChecker: ts.TypeChecker, + visitedSymbols: ReadonlySet = new Set(), +): boolean | null => { + const unwrappedExpression = unwrapTypescriptExpression(expression); + if (unwrappedExpression.kind === ts.SyntaxKind.TrueKeyword) return true; + if ( + unwrappedExpression.kind === ts.SyntaxKind.FalseKeyword || + unwrappedExpression.kind === ts.SyntaxKind.NullKeyword + ) { + return false; + } + if (!ts.isIdentifier(unwrappedExpression)) return null; + if (unwrappedExpression.text === "undefined") return false; + const symbol = getResolvedSymbol(unwrappedExpression, typeChecker); + if (!symbol || visitedSymbols.has(symbol)) return null; + for (const declaration of symbol.declarations ?? []) { + if ( + ts.isVariableDeclaration(declaration) && + ts.isVariableDeclarationList(declaration.parent) && + Boolean(declaration.parent.flags & ts.NodeFlags.Const) && + declaration.initializer + ) { + return getStaticBoolean( + declaration.initializer, + typeChecker, + new Set([...visitedSymbols, symbol]), + ); + } + } + return null; +}; + +const getListenerCapture = ( + optionsExpression: ts.Expression | undefined, + typeChecker: ts.TypeChecker, +): boolean | null => { + if (!optionsExpression) return false; + const directBoolean = getStaticBoolean(optionsExpression, typeChecker); + if (directBoolean !== null) return directBoolean; + const unwrappedOptions = unwrapTypescriptExpression(optionsExpression); + if (!ts.isObjectLiteralExpression(unwrappedOptions)) return null; + if ( + unwrappedOptions.properties.some( + (property) => + ts.isSpreadAssignment(property) || + (property.name && getStaticPropertyName(property.name) === null), + ) + ) { + return null; + } + const captureProperties = unwrappedOptions.properties.filter( + (property) => property.name && getStaticPropertyName(property.name) === "capture", + ); + if (captureProperties.length === 0) return false; + if (captureProperties.length > 1) return null; + const captureProperty = captureProperties[0]; + if (!captureProperty) return null; + if (ts.isPropertyAssignment(captureProperty)) { + return getStaticBoolean(captureProperty.initializer, typeChecker); + } + return ts.isShorthandPropertyAssignment(captureProperty) + ? getStaticBoolean(captureProperty.name, typeChecker) + : null; +}; + +const getListenerSignalController = ( + optionsExpression: ts.Expression | undefined, + typeChecker: ts.TypeChecker, +): ts.Expression | null => { + if (!optionsExpression) return null; + const unwrappedOptions = unwrapTypescriptExpression(optionsExpression); + if (!ts.isObjectLiteralExpression(unwrappedOptions)) return null; + const signalProperty = unwrappedOptions.properties.find( + (property) => + ts.isPropertyAssignment(property) && getStaticPropertyName(property.name) === "signal", + ); + if ( + !signalProperty || + !ts.isPropertyAssignment(signalProperty) || + !ts.isPropertyAccessExpression(signalProperty.initializer) || + signalProperty.initializer.name.text !== "signal" || + !isPlatformMember(signalProperty.initializer.name, "signal", typeChecker) + ) { + return null; + } + return signalProperty.initializer.expression; +}; + +const getEventListenerDescriptor = ( + callExpression: ts.CallExpression, + typeChecker: ts.TypeChecker, +): EventListenerDescriptor | null => { + if ( + !ts.isPropertyAccessExpression(callExpression.expression) || + getPlatformEffectResourceKind(callExpression, typeChecker) !== + ReactEffectResourceKind.EventListener + ) { + return null; + } + const targetExpression = callExpression.expression.expression; + if (!isPlatformResourceValue(targetExpression, typeChecker)) return null; + const eventExpression = callExpression.arguments[0]; + const handlerExpression = callExpression.arguments[1]; + if (!eventExpression || !handlerExpression) return null; + return { + eventExpression, + handlerExpression, + capture: getListenerCapture(callExpression.arguments[2], typeChecker), + signalControllerExpression: getListenerSignalController( + callExpression.arguments[2], + typeChecker, + ), + targetExpression, + }; +}; + +const isMatchingAbort = ( + cleanupCall: ts.CallExpression, + listener: EventListenerDescriptor, + typeChecker: ts.TypeChecker, +): boolean => + Boolean( + listener.signalControllerExpression && + ts.isPropertyAccessExpression(cleanupCall.expression) && + cleanupCall.expression.name.text === "abort" && + isPlatformMember(cleanupCall.expression.name, "abort", typeChecker) && + areImmutableExpressionsIdentical( + cleanupCall.expression.expression, + listener.signalControllerExpression, + typeChecker, + ), + ); + +const isMatchingEventRemoval = ( + cleanupCall: ts.CallExpression, + listener: EventListenerDescriptor, + typeChecker: ts.TypeChecker, +): boolean => { + if ( + !ts.isPropertyAccessExpression(cleanupCall.expression) || + cleanupCall.expression.name.text !== "removeEventListener" || + !isPlatformMember(cleanupCall.expression.name, "removeEventListener", typeChecker) + ) { + return false; + } + const cleanupEvent = cleanupCall.arguments[0]; + const cleanupHandler = cleanupCall.arguments[1]; + const cleanupCapture = getListenerCapture(cleanupCall.arguments[2], typeChecker); + return Boolean( + cleanupEvent && + cleanupHandler && + listener.capture !== null && + cleanupCapture === listener.capture && + areImmutableExpressionsIdentical( + listener.targetExpression, + cleanupCall.expression.expression, + typeChecker, + ) && + areImmutableExpressionsIdentical(listener.eventExpression, cleanupEvent, typeChecker) && + areImmutableExpressionsIdentical(listener.handlerExpression, cleanupHandler, typeChecker), + ); +}; + +const isDefinitelyMismatchedEventRemoval = ( + cleanupCall: ts.CallExpression, + listener: EventListenerDescriptor, + typeChecker: ts.TypeChecker, +): boolean => { + if ( + !ts.isPropertyAccessExpression(cleanupCall.expression) || + cleanupCall.expression.name.text !== "removeEventListener" || + !isPlatformMember(cleanupCall.expression.name, "removeEventListener", typeChecker) + ) { + return false; + } + const cleanupEvent = cleanupCall.arguments[0]; + const cleanupHandler = cleanupCall.arguments[1]; + if ( + !cleanupEvent || + !cleanupHandler || + !areImmutableExpressionsIdentical( + listener.targetExpression, + cleanupCall.expression.expression, + typeChecker, + ) || + !areImmutableExpressionsIdentical(listener.eventExpression, cleanupEvent, typeChecker) + ) { + return false; + } + const cleanupCapture = getListenerCapture(cleanupCall.arguments[2], typeChecker); + if (listener.capture !== null && cleanupCapture !== null && listener.capture !== cleanupCapture) { + return true; + } + return ( + (ts.isArrowFunction(listener.handlerExpression) || + ts.isFunctionExpression(listener.handlerExpression)) && + (ts.isArrowFunction(cleanupHandler) || ts.isFunctionExpression(cleanupHandler)) && + listener.handlerExpression !== cleanupHandler + ); +}; + +const getImmutableResourceExpression = (newExpression: ts.NewExpression): ts.Expression | null => { + const declaration = ts.isVariableDeclaration(newExpression.parent) ? newExpression.parent : null; + if ( + !declaration || + declaration.initializer !== newExpression || + !ts.isIdentifier(declaration.name) || + !ts.isVariableDeclarationList(declaration.parent) || + !(declaration.parent.flags & ts.NodeFlags.Const) + ) { + return null; + } + return declaration.name; +}; + +const collectObservers = ( + effectCallback: ts.FunctionLikeDeclaration, + typeChecker: ts.TypeChecker, +): ReadonlyArray => { + const observers: ObserverDescriptor[] = []; + const constructorsBySymbol = new Map(); + for (const reachableFunction of collectReachableFunctions(effectCallback, typeChecker)) { + const visit = (node: ts.Node): void => { + if (node !== reachableFunction.functionNode && ts.isFunctionLike(node)) return; + if (ts.isNewExpression(node) && ts.isIdentifier(node.expression)) { + const kind = PLATFORM_OBSERVER_KINDS.get(node.expression.text); + const resourceExpression = kind ? getImmutableResourceExpression(node) : null; + const resourceSymbol = resourceExpression + ? getResolvedSymbol(resourceExpression, typeChecker) + : null; + if ( + kind && + resourceExpression && + resourceSymbol && + isPlatformMember(node.expression, node.expression.text, typeChecker) + ) { + constructorsBySymbol.set(resourceSymbol, { + activationCalls: [], + callbackExpression: node.arguments?.[0] ?? null, + kind, + resourceExpression, + }); + } + } + node.forEachChild(visit); + }; + reachableFunction.functionNode.forEachChild(visit); + } + for (const callExpression of collectReachableCallExpressions(effectCallback, typeChecker)) { + const resourceKind = getPlatformEffectResourceKind(callExpression, typeChecker); + if ( + !ts.isPropertyAccessExpression(callExpression.expression) || + !resourceKind || + !ts.isIdentifier(callExpression.expression.expression) + ) { + continue; + } + const resourceSymbol = getResolvedSymbol(callExpression.expression.expression, typeChecker); + const observer = resourceSymbol ? constructorsBySymbol.get(resourceSymbol) : null; + if (observer?.kind !== resourceKind) continue; + observer.activationCalls.push(callExpression); + if (!observers.includes(observer)) observers.push(observer); + } + return observers; +}; + +const getGuaranteedFunctions = ( + cleanupFunction: ts.FunctionLikeDeclaration, + typeChecker: ts.TypeChecker, +): ReadonlySet => { + const graph = collectReachableFunctionGraph(cleanupFunction, typeChecker); + const guaranteedFunctions = new Set([cleanupFunction]); + let didAddFunction = true; + while (didAddFunction) { + didAddFunction = false; + for (const call of graph.calls) { + if ( + guaranteedFunctions.has(call.sourceFunctionNode) && + !guaranteedFunctions.has(call.targetFunctionNode) && + isEntryDominatingNode(call.callExpression, call.sourceFunctionNode) + ) { + guaranteedFunctions.add(call.targetFunctionNode); + didAddFunction = true; + } + } + } + return guaranteedFunctions; +}; + +const isGuaranteedCleanupCall = ( + callExpression: ts.CallExpression, + guaranteedFunctions: ReadonlySet, +): boolean => { + const ownerFunction = getEnclosingFunction(callExpression); + return Boolean( + ownerFunction && + guaranteedFunctions.has(ownerFunction) && + isEntryDominatingNode(callExpression, ownerFunction), + ); +}; + +const getDisposalStatus = ( + cleanupFunctions: ReadonlyArray, + hasGuaranteedCleanup: boolean, + isAcquisitionConditional: boolean, + isMatchingDisposal: (callExpression: ts.CallExpression) => boolean, + isDefinitelyMismatchedDisposal: (callExpression: ts.CallExpression) => boolean, + typeChecker: ts.TypeChecker, +): EffectResourceDisposal => { + if (cleanupFunctions.length === 0 || !hasGuaranteedCleanup) { + return { + calls: [], + status: isAcquisitionConditional + ? ReactEffectResourceDisposalStatus.Unknown + : ReactEffectResourceDisposalStatus.Missing, + }; + } + const disposalCalls: ts.CallExpression[] = []; + for (const cleanupFunction of cleanupFunctions) { + const guaranteedFunctions = getGuaranteedFunctions(cleanupFunction, typeChecker); + const cleanupCalls = collectReachableCallExpressions(cleanupFunction, typeChecker); + const matchingCalls = cleanupCalls.filter(isMatchingDisposal); + const guaranteedCall = matchingCalls.find((cleanupCall) => + isGuaranteedCleanupCall(cleanupCall, guaranteedFunctions), + ); + if (!guaranteedCall) { + const hasDefiniteMismatch = cleanupCalls.some(isDefinitelyMismatchedDisposal); + const isDefinitelyMissing = + cleanupCalls.length === 0 || + hasDefiniteMismatch || + (matchingCalls.length > 0 && !isAcquisitionConditional); + return { + calls: [...disposalCalls, ...matchingCalls], + status: isDefinitelyMissing + ? ReactEffectResourceDisposalStatus.Missing + : ReactEffectResourceDisposalStatus.Unknown, + }; + } + disposalCalls.push(guaranteedCall); + } + return { + calls: disposalCalls, + status: ReactEffectResourceDisposalStatus.Guaranteed, + }; +}; + +export const collectLifecycleResourceProtocols = ( + setupFunction: ts.FunctionLikeDeclaration, + cleanupFunctions: ReadonlyArray, + hasGuaranteedCleanup: boolean, + context: ReactAnalysisContext, +): ReadonlyArray => { + const protocols: LifecycleResourceProtocolDescriptor[] = []; + const reachableFunctions = collectReachableFunctions(setupFunction, context.typeChecker); + for (const registrationCall of collectReachableCallExpressions( + setupFunction, + context.typeChecker, + )) { + const listener = getEventListenerDescriptor(registrationCall, context.typeChecker); + if (!listener) continue; + const ownerFunction = getEnclosingFunction(registrationCall); + const reachableOwner = ownerFunction + ? reachableFunctions.find( + (reachableFunction) => reachableFunction.functionNode === ownerFunction, + ) + : null; + const isAcquisitionConditional = Boolean( + !ownerFunction || + reachableOwner?.isConditionallyReached || + hasConditionalAncestor(registrationCall, ownerFunction), + ); + const disposal = getDisposalStatus( + cleanupFunctions, + hasGuaranteedCleanup, + isAcquisitionConditional, + (cleanupCall) => + isMatchingEventRemoval(cleanupCall, listener, context.typeChecker) || + isMatchingAbort(cleanupCall, listener, context.typeChecker), + (cleanupCall) => + isDefinitelyMismatchedEventRemoval(cleanupCall, listener, context.typeChecker), + context.typeChecker, + ); + protocols.push({ + acquisitionNode: registrationCall, + acquisitionNodes: [registrationCall], + callbackExpression: listener.handlerExpression, + disposalCalls: disposal.calls, + disposalStatus: disposal.status, + isSourceComplete: + listener.capture !== null && + disposal.status === ReactEffectResourceDisposalStatus.Guaranteed, + kind: ReactEffectResourceKind.EventListener, + }); + } + for (const observer of collectObservers(setupFunction, context.typeChecker)) { + const observerActivation = observer.activationCalls[0]; + if (!observerActivation) continue; + const ownerFunction = getEnclosingFunction(observerActivation); + const reachableOwner = ownerFunction + ? reachableFunctions.find( + (reachableFunction) => reachableFunction.functionNode === ownerFunction, + ) + : null; + const isAcquisitionConditional = Boolean( + !ownerFunction || + reachableOwner?.isConditionallyReached || + hasConditionalAncestor(observerActivation, ownerFunction), + ); + const disposal = getDisposalStatus( + cleanupFunctions, + hasGuaranteedCleanup, + isAcquisitionConditional, + (cleanupCall) => + ts.isPropertyAccessExpression(cleanupCall.expression) && + cleanupCall.expression.name.text === "disconnect" && + isPlatformMember(cleanupCall.expression.name, "disconnect", context.typeChecker) && + areImmutableExpressionsIdentical( + cleanupCall.expression.expression, + observer.resourceExpression, + context.typeChecker, + ), + () => false, + context.typeChecker, + ); + protocols.push({ + acquisitionNode: observerActivation, + acquisitionNodes: observer.activationCalls, + callbackExpression: observer.callbackExpression, + disposalCalls: disposal.calls, + disposalStatus: disposal.status, + isSourceComplete: + Boolean(observer.callbackExpression) && + disposal.status === ReactEffectResourceDisposalStatus.Guaranteed, + kind: observer.kind, + }); + } + return protocols; +}; + +export const collectEffectResourceProtocols = ( + functionNode: ts.FunctionLikeDeclaration, + context: ReactAnalysisContext, +): ReadonlyArray => { + const protocols: EffectResourceProtocolDescriptor[] = []; + for (const effectCall of collectEffectCalls(functionNode, context.typeChecker)) { + const effectCallback = getEffectCallback(effectCall, context.typeChecker); + if (!effectCallback) continue; + const cleanupFunctions = collectEffectCleanupFunctions(effectCallback, context.typeChecker); + protocols.push( + ...collectLifecycleResourceProtocols( + effectCallback, + cleanupFunctions, + hasGuaranteedEffectCleanup(effectCallback, context.typeChecker), + context, + ).map((protocol) => ({ ...protocol, effectCall })), + ); + } + return protocols; +}; diff --git a/packages/prover/src/collect-effect-scheduler-protocols.ts b/packages/prover/src/collect-effect-scheduler-protocols.ts new file mode 100644 index 000000000..3b37f0c7d --- /dev/null +++ b/packages/prover/src/collect-effect-scheduler-protocols.ts @@ -0,0 +1,376 @@ +import ts from "typescript"; +import { collectEffectCleanupFunctions } from "./collect-effect-cleanup-functions.js"; +import { collectEffectCalls } from "./collect-effect-calls.js"; +import { collectReachableFunctions } from "./collect-reachable-functions.js"; +import { getEffectCallback } from "./get-effect-callback.js"; +import { ReactSchedulerCancellationStatus, ReactSchedulerKind } from "./types.js"; +import type { ReactAnalysisContext } from "./types.js"; +import { collectPropertySymbolWrites } from "./utils/collect-property-symbol-writes.js"; +import { collectSymbolWrites } from "./utils/collect-symbol-writes.js"; +import { collectReachableCallExpressions } from "./utils/collect-reachable-call-expressions.js"; +import { getEnclosingFunction } from "./utils/get-enclosing-function.js"; +import { getResolvedSymbol } from "./utils/get-resolved-symbol.js"; +import { hasConditionalAncestor } from "./utils/has-conditional-ancestor.js"; +import { hasGuaranteedEffectCleanup } from "./utils/has-guaranteed-effect-cleanup.js"; +import { isEntryDominatingNode } from "./utils/is-entry-dominating-node.js"; +import { isPlatformDeclarationSymbol } from "./utils/is-platform-declaration-symbol.js"; +import { unwrapTypescriptExpression } from "./unwrap-typescript-expression.js"; + +export interface LifecycleSchedulerProtocolDescriptor { + callbackExpression: ts.Expression | null; + cancellationCalls: ReadonlyArray; + cancellationStatus: ReactSchedulerCancellationStatus; + handleDeclaration: ts.PropertyDeclaration | null; + isSourceComplete: boolean; + kind: ReactSchedulerKind; + registrationCall: ts.CallExpression; +} + +export interface EffectSchedulerProtocolDescriptor extends LifecycleSchedulerProtocolDescriptor { + effectCall: ts.CallExpression; +} + +interface SchedulerApiDescriptor { + cancellationName: string | null; + kind: ReactSchedulerKind; +} + +interface SchedulerHandleDescriptor { + expression: ts.Expression; + symbol: ts.Symbol; + propertyDeclaration: ts.PropertyDeclaration | null; +} + +const SCHEDULER_APIS = new Map([ + [ + "queueMicrotask", + { + cancellationName: null, + kind: ReactSchedulerKind.Microtask, + }, + ], + [ + "requestAnimationFrame", + { + cancellationName: "cancelAnimationFrame", + kind: ReactSchedulerKind.AnimationFrame, + }, + ], + [ + "requestIdleCallback", + { + cancellationName: "cancelIdleCallback", + kind: ReactSchedulerKind.IdleCallback, + }, + ], + [ + "setImmediate", + { + cancellationName: "clearImmediate", + kind: ReactSchedulerKind.Immediate, + }, + ], + [ + "setInterval", + { + cancellationName: "clearInterval", + kind: ReactSchedulerKind.Interval, + }, + ], + [ + "setTimeout", + { + cancellationName: "clearTimeout", + kind: ReactSchedulerKind.Timeout, + }, + ], +]); + +const getPlatformExpressionName = ( + expression: ts.Expression, + typeChecker: ts.TypeChecker, +): string | null => { + const pendingExpressions = [expression]; + const visitedSymbols = new Set(); + while (pendingExpressions.length > 0) { + const pendingExpression = pendingExpressions.pop(); + if (!pendingExpression) continue; + const unwrappedExpression = unwrapTypescriptExpression(pendingExpression); + if (ts.isIdentifier(unwrappedExpression)) { + const symbol = getResolvedSymbol(unwrappedExpression, typeChecker); + if (isPlatformDeclarationSymbol(symbol)) return symbol?.getName() ?? null; + if (!symbol || visitedSymbols.has(symbol)) continue; + visitedSymbols.add(symbol); + for (const declaration of symbol.declarations ?? []) { + if ( + ts.isVariableDeclaration(declaration) && + ts.isVariableDeclarationList(declaration.parent) && + Boolean(declaration.parent.flags & ts.NodeFlags.Const) && + declaration.initializer && + collectSymbolWrites(symbol, declaration.getSourceFile(), typeChecker).length === 0 + ) { + pendingExpressions.push(declaration.initializer); + } + } + continue; + } + if ( + ts.isPropertyAccessExpression(unwrappedExpression) && + isPlatformDeclarationSymbol(getResolvedSymbol(unwrappedExpression.name, typeChecker)) + ) { + return unwrappedExpression.name.text; + } + } + return null; +}; + +const getPlatformCallName = ( + callExpression: ts.CallExpression, + typeChecker: ts.TypeChecker, +): string | null => getPlatformExpressionName(callExpression.expression, typeChecker); + +const getSchedulerApi = ( + callExpression: ts.CallExpression, + typeChecker: ts.TypeChecker, +): SchedulerApiDescriptor | null => { + const callName = getPlatformCallName(callExpression, typeChecker); + return callName ? (SCHEDULER_APIS.get(callName) ?? null) : null; +}; + +export const getPlatformSchedulerKind = ( + callExpression: ts.CallExpression, + context: ReactAnalysisContext, +): ReactSchedulerKind | null => getSchedulerApi(callExpression, context.typeChecker)?.kind ?? null; + +const getImmutableHandle = ( + registrationCall: ts.CallExpression, + typeChecker: ts.TypeChecker, +): SchedulerHandleDescriptor | null => { + const declaration = ts.isVariableDeclaration(registrationCall.parent) + ? registrationCall.parent + : null; + if ( + declaration && + declaration.initializer === registrationCall && + ts.isIdentifier(declaration.name) && + ts.isVariableDeclarationList(declaration.parent) && + Boolean(declaration.parent.flags & ts.NodeFlags.Const) + ) { + const symbol = getResolvedSymbol(declaration.name, typeChecker); + return symbol + ? { + expression: declaration.name, + symbol, + propertyDeclaration: null, + } + : null; + } + const assignment = ts.isBinaryExpression(registrationCall.parent) + ? registrationCall.parent + : null; + if ( + !assignment || + assignment.right !== registrationCall || + assignment.operatorToken.kind !== ts.SyntaxKind.EqualsToken || + !ts.isPropertyAccessExpression(assignment.left) || + assignment.left.expression.kind !== ts.SyntaxKind.ThisKeyword + ) { + return null; + } + const symbol = getResolvedSymbol(assignment.left.name, typeChecker); + const propertyDeclaration = symbol?.declarations?.find(ts.isPropertyDeclaration) ?? null; + const initializer = propertyDeclaration?.initializer; + const propertyWrites = symbol + ? collectPropertySymbolWrites(symbol, registrationCall.getSourceFile(), typeChecker) + : []; + const hasSafeInitializer = Boolean( + propertyDeclaration && + (!initializer || + ts.isNumericLiteral(initializer) || + initializer.kind === ts.SyntaxKind.NullKeyword || + (ts.isIdentifier(initializer) && initializer.text === "undefined")), + ); + if ( + !symbol || + !propertyDeclaration || + !hasSafeInitializer || + propertyWrites.length !== 1 || + propertyWrites[0] !== assignment + ) { + return null; + } + return { + expression: assignment.left, + symbol, + propertyDeclaration, + }; +}; + +const isMatchingCancellation = ( + callExpression: ts.CallExpression, + cancellationName: string, + handle: SchedulerHandleDescriptor, + typeChecker: ts.TypeChecker, +): boolean => { + if (getPlatformCallName(callExpression, typeChecker) !== cancellationName) { + return false; + } + const handleArgument = callExpression.arguments[0]; + if (!handleArgument) return false; + if (ts.isIdentifier(handle.expression) && ts.isIdentifier(handleArgument)) { + return getResolvedSymbol(handleArgument, typeChecker) === handle.symbol; + } + return Boolean( + ts.isPropertyAccessExpression(handle.expression) && + ts.isPropertyAccessExpression(handleArgument) && + handle.expression.expression.kind === ts.SyntaxKind.ThisKeyword && + handleArgument.expression.kind === ts.SyntaxKind.ThisKeyword && + getResolvedSymbol(handleArgument.name, typeChecker) === handle.symbol, + ); +}; + +const collectCancellation = ( + cleanupFunctions: ReadonlyArray, + hasGuaranteedCleanup: boolean, + registrationCall: ts.CallExpression, + cancellationName: string | null, + typeChecker: ts.TypeChecker, +): { + calls: ReadonlyArray; + status: ReactSchedulerCancellationStatus; +} => { + if (!cancellationName) { + return { calls: [], status: ReactSchedulerCancellationStatus.Unknown }; + } + const handle = getImmutableHandle(registrationCall, typeChecker); + if (!handle) { + const immediateCancellation = + ts.isCallExpression(registrationCall.parent) && + registrationCall.parent.arguments[0] === registrationCall && + getPlatformCallName(registrationCall.parent, typeChecker) === cancellationName + ? registrationCall.parent + : null; + if (immediateCancellation) { + return { + calls: [immediateCancellation], + status: ReactSchedulerCancellationStatus.Unknown, + }; + } + const hasAssignedHandle = + (ts.isVariableDeclaration(registrationCall.parent) && + registrationCall.parent.initializer === registrationCall) || + (ts.isBinaryExpression(registrationCall.parent) && + registrationCall.parent.right === registrationCall && + registrationCall.parent.operatorToken.kind === ts.SyntaxKind.EqualsToken); + return { + calls: [], + status: hasAssignedHandle + ? ReactSchedulerCancellationStatus.Unknown + : ReactSchedulerCancellationStatus.Missing, + }; + } + if (cleanupFunctions.length === 0 || !hasGuaranteedCleanup) { + return { calls: [], status: ReactSchedulerCancellationStatus.Missing }; + } + const matchingCalls: ts.CallExpression[] = []; + for (const cleanupFunction of cleanupFunctions) { + const cleanupCalls = collectReachableCallExpressions(cleanupFunction, typeChecker); + const cleanupMatchingCalls = cleanupCalls.filter((cleanupCall) => + isMatchingCancellation(cleanupCall, cancellationName, handle, typeChecker), + ); + if (cleanupMatchingCalls.length === 0) { + return { + calls: matchingCalls, + status: + cleanupCalls.length === 0 + ? ReactSchedulerCancellationStatus.Missing + : ReactSchedulerCancellationStatus.Unknown, + }; + } + const entryCancellation = cleanupMatchingCalls.find((cleanupCall) => + isEntryDominatingNode(cleanupCall, cleanupFunction), + ); + if (!entryCancellation) { + return { + calls: [...matchingCalls, ...cleanupMatchingCalls], + status: ReactSchedulerCancellationStatus.Unknown, + }; + } + matchingCalls.push(entryCancellation); + } + return { + calls: matchingCalls, + status: ReactSchedulerCancellationStatus.Guaranteed, + }; +}; + +export const collectLifecycleSchedulerProtocols = ( + setupFunction: ts.FunctionLikeDeclaration, + cleanupFunctions: ReadonlyArray, + hasGuaranteedCleanup: boolean, + context: ReactAnalysisContext, +): ReadonlyArray => { + const protocols: LifecycleSchedulerProtocolDescriptor[] = []; + const reachableFunctions = collectReachableFunctions(setupFunction, context.typeChecker); + for (const registrationCall of collectReachableCallExpressions( + setupFunction, + context.typeChecker, + )) { + const schedulerApi = getSchedulerApi(registrationCall, context.typeChecker); + if (!schedulerApi) continue; + const registrationOwner = getEnclosingFunction(registrationCall); + const reachableRegistration = registrationOwner + ? reachableFunctions.find( + (reachableFunction) => reachableFunction.functionNode === registrationOwner, + ) + : null; + const cancellation = collectCancellation( + cleanupFunctions, + hasGuaranteedCleanup, + registrationCall, + schedulerApi.cancellationName, + context.typeChecker, + ); + const isRegistrationConditional = Boolean( + !registrationOwner || + reachableRegistration?.isConditionallyReached || + hasConditionalAncestor(registrationCall, registrationOwner), + ); + const callbackExpression = registrationCall.arguments[0] ?? null; + protocols.push({ + callbackExpression, + cancellationCalls: cancellation.calls, + cancellationStatus: cancellation.status, + handleDeclaration: + getImmutableHandle(registrationCall, context.typeChecker)?.propertyDeclaration ?? null, + isSourceComplete: + Boolean(callbackExpression) && + !isRegistrationConditional && + cancellation.status === ReactSchedulerCancellationStatus.Guaranteed, + kind: schedulerApi.kind, + registrationCall, + }); + } + return protocols; +}; + +export const collectEffectSchedulerProtocols = ( + functionNode: ts.FunctionLikeDeclaration, + context: ReactAnalysisContext, +): ReadonlyArray => { + const protocols: EffectSchedulerProtocolDescriptor[] = []; + for (const effectCall of collectEffectCalls(functionNode, context.typeChecker)) { + const effectCallback = getEffectCallback(effectCall, context.typeChecker); + if (!effectCallback) continue; + const cleanupFunctions = collectEffectCleanupFunctions(effectCallback, context.typeChecker); + protocols.push( + ...collectLifecycleSchedulerProtocols( + effectCallback, + cleanupFunctions, + hasGuaranteedEffectCleanup(effectCallback, context.typeChecker), + context, + ).map((protocol) => ({ ...protocol, effectCall })), + ); + } + return protocols; +}; diff --git a/packages/prover/src/collect-error-boundary-protocol.ts b/packages/prover/src/collect-error-boundary-protocol.ts new file mode 100644 index 000000000..9eb454e0e --- /dev/null +++ b/packages/prover/src/collect-error-boundary-protocol.ts @@ -0,0 +1,262 @@ +import ts from "typescript"; +import { analyzeRenderPurity } from "./analyze-render-purity.js"; +import { isFunctionBoundary } from "./is-function-boundary.js"; +import { summarizeFunctionReturns } from "./summarize-function-returns.js"; +import { ReactErrorBoundaryProtocolStatus, ReactObligationStatus } from "./types.js"; +import { unwrapTypescriptExpression } from "./unwrap-typescript-expression.js"; +import type { ReactAnalysisContext } from "./types.js"; +import { getClassMethodDeclaration } from "./utils/get-class-method-declaration.js"; +import { getStaticAccessMemberName } from "./utils/get-static-access-member-name.js"; +import { getStaticClassMethodDeclaration } from "./utils/get-static-class-method-declaration.js"; +import { getStaticPropertyName } from "./utils/get-static-property-name.js"; + +export interface ErrorBoundaryProtocolDescriptor { + componentDidCatchMethod: ts.MethodDeclaration | null; + derivedStateMethod: ts.MethodDeclaration | null; + derivedStateStatus: ReactErrorBoundaryProtocolStatus; + fallbackRenderStatus: ReactErrorBoundaryProtocolStatus; + fallbackStateKey: string | null; + isCandidate: boolean; + isSourceComplete: boolean; +} + +const getObjectPropertyValue = (property: ts.ObjectLiteralElementLike): ts.Expression | null => { + if (ts.isPropertyAssignment(property)) return property.initializer; + if (ts.isShorthandPropertyAssignment(property)) return property.name; + return null; +}; + +const collectTrueStateKeys = (expression: ts.Expression): ReadonlySet | null => { + const unwrappedExpression = unwrapTypescriptExpression(expression); + if (!ts.isObjectLiteralExpression(unwrappedExpression)) return null; + const trueKeys = new Set(); + for (const property of unwrappedExpression.properties) { + const propertyName = + ts.isPropertyAssignment(property) || ts.isShorthandPropertyAssignment(property) + ? getStaticPropertyName(property.name) + : null; + const propertyValue = getObjectPropertyValue(property); + if (!propertyName || !propertyValue) return null; + if (unwrapTypescriptExpression(propertyValue).kind === ts.SyntaxKind.TrueKeyword) { + trueKeys.add(propertyName); + } + } + return trueKeys; +}; + +const getDerivedStateProtocol = ( + derivedStateMethod: ts.MethodDeclaration | null, + context: ReactAnalysisContext, +): { + fallbackStateKey: string | null; + status: ReactErrorBoundaryProtocolStatus; +} => { + if (!derivedStateMethod) { + return { + fallbackStateKey: null, + status: ReactErrorBoundaryProtocolStatus.Invalid, + }; + } + const returnSummary = summarizeFunctionReturns(derivedStateMethod, context.typeChecker); + if ( + returnSummary.canFallThrough || + returnSummary.canThrow || + returnSummary.expressions.length === 0 + ) { + return { + fallbackStateKey: null, + status: ReactErrorBoundaryProtocolStatus.Invalid, + }; + } + if (!returnSummary.isComplete) { + return { + fallbackStateKey: null, + status: ReactErrorBoundaryProtocolStatus.Unknown, + }; + } + const returnStateKeys = returnSummary.expressions.map((descriptor) => + collectTrueStateKeys(descriptor.expression), + ); + if (returnStateKeys.some((stateKeys) => stateKeys === null)) { + return { + fallbackStateKey: null, + status: ReactErrorBoundaryProtocolStatus.Unknown, + }; + } + const firstStateKeys = returnStateKeys[0]; + const fallbackStateKey = firstStateKeys + ? [...firstStateKeys].find((stateKey) => + returnStateKeys.every((stateKeys) => stateKeys?.has(stateKey)), + ) + : null; + if (!fallbackStateKey) { + return { + fallbackStateKey: null, + status: ReactErrorBoundaryProtocolStatus.Invalid, + }; + } + const purity = analyzeRenderPurity(derivedStateMethod, context).status; + if (purity === ReactObligationStatus.Violated) { + return { + fallbackStateKey, + status: ReactErrorBoundaryProtocolStatus.Invalid, + }; + } + return { + fallbackStateKey, + status: + purity === ReactObligationStatus.Proved + ? ReactErrorBoundaryProtocolStatus.Valid + : ReactErrorBoundaryProtocolStatus.Unknown, + }; +}; + +const getThisStateMemberName = (expression: ts.Expression): string | null => { + const unwrappedExpression = unwrapTypescriptExpression(expression); + if ( + !ts.isPropertyAccessExpression(unwrappedExpression) && + !ts.isElementAccessExpression(unwrappedExpression) + ) { + return null; + } + const stateExpression = unwrapTypescriptExpression(unwrappedExpression.expression); + if ( + (!ts.isPropertyAccessExpression(stateExpression) && + !ts.isElementAccessExpression(stateExpression)) || + unwrapTypescriptExpression(stateExpression.expression).kind !== ts.SyntaxKind.ThisKeyword || + getStaticAccessMemberName(stateExpression) !== "state" + ) { + return null; + } + return getStaticAccessMemberName(unwrappedExpression); +}; + +const isFallbackStateGuard = (expression: ts.Expression, fallbackStateKey: string): boolean => { + const unwrappedExpression = unwrapTypescriptExpression(expression); + if (getThisStateMemberName(unwrappedExpression) === fallbackStateKey) return true; + if (!ts.isBinaryExpression(unwrappedExpression)) return false; + const operator = unwrappedExpression.operatorToken.kind; + if ( + operator !== ts.SyntaxKind.EqualsEqualsToken && + operator !== ts.SyntaxKind.EqualsEqualsEqualsToken + ) { + return false; + } + return ( + (getThisStateMemberName(unwrappedExpression.left) === fallbackStateKey && + unwrapTypescriptExpression(unwrappedExpression.right).kind === ts.SyntaxKind.TrueKeyword) || + (getThisStateMemberName(unwrappedExpression.right) === fallbackStateKey && + unwrapTypescriptExpression(unwrappedExpression.left).kind === ts.SyntaxKind.TrueKeyword) + ); +}; + +const collectReturnedExpressions = (node: ts.Node): ReadonlyArray => { + const expressions: ts.Expression[] = []; + const visit = (currentNode: ts.Node): void => { + if (currentNode !== node && isFunctionBoundary(currentNode)) return; + if (ts.isReturnStatement(currentNode) && currentNode.expression) { + expressions.push(currentNode.expression); + return; + } + currentNode.forEachChild(visit); + }; + visit(node); + return expressions; +}; + +const isChildrenExpression = (expression: ts.Expression): boolean => { + let currentExpression = unwrapTypescriptExpression(expression); + const members: string[] = []; + while ( + ts.isPropertyAccessExpression(currentExpression) || + ts.isElementAccessExpression(currentExpression) + ) { + const memberName = getStaticAccessMemberName(currentExpression); + if (!memberName) return false; + members.unshift(memberName); + currentExpression = unwrapTypescriptExpression(currentExpression.expression); + } + if ( + currentExpression.kind === ts.SyntaxKind.ThisKeyword && + members.join(".") === "props.children" + ) { + return true; + } + return false; +}; + +const getFallbackRenderStatus = ( + renderMethod: ts.MethodDeclaration, + fallbackStateKey: string | null, + derivedStateStatus: ReactErrorBoundaryProtocolStatus, +): ReactErrorBoundaryProtocolStatus => { + if (!fallbackStateKey) { + return derivedStateStatus === ReactErrorBoundaryProtocolStatus.Unknown + ? ReactErrorBoundaryProtocolStatus.Unknown + : ReactErrorBoundaryProtocolStatus.Invalid; + } + const fallbackGuards: ts.IfStatement[] = []; + const visit = (node: ts.Node): void => { + if (fallbackGuards.length > 0 || (node !== renderMethod && isFunctionBoundary(node))) return; + if (ts.isIfStatement(node) && isFallbackStateGuard(node.expression, fallbackStateKey)) { + fallbackGuards.push(node); + return; + } + node.forEachChild(visit); + }; + renderMethod.forEachChild(visit); + const fallbackGuard = fallbackGuards[0]; + if (!fallbackGuard) return ReactErrorBoundaryProtocolStatus.Unknown; + const fallbackExpressions = collectReturnedExpressions(fallbackGuard.thenStatement); + if (fallbackExpressions.length === 0) return ReactErrorBoundaryProtocolStatus.Invalid; + if (fallbackExpressions.some(isChildrenExpression)) { + return ReactErrorBoundaryProtocolStatus.Invalid; + } + return ReactErrorBoundaryProtocolStatus.Valid; +}; + +export const collectErrorBoundaryProtocol = ( + classNode: ts.ClassDeclaration, + renderMethod: ts.MethodDeclaration, + context: ReactAnalysisContext, +): ErrorBoundaryProtocolDescriptor => { + const derivedStateMethod = getStaticClassMethodDeclaration(classNode, "getDerivedStateFromError"); + const componentDidCatchMethod = getClassMethodDeclaration(classNode, "componentDidCatch"); + const isCandidate = Boolean( + derivedStateMethod || + componentDidCatchMethod || + classNode.members.some( + (member) => + ts.isMethodDeclaration(member) && + getStaticPropertyName(member.name) === "getDerivedStateFromError", + ), + ); + if (!isCandidate) { + return { + componentDidCatchMethod, + derivedStateMethod, + derivedStateStatus: ReactErrorBoundaryProtocolStatus.Unknown, + fallbackRenderStatus: ReactErrorBoundaryProtocolStatus.Unknown, + fallbackStateKey: null, + isCandidate: false, + isSourceComplete: true, + }; + } + const derivedStateProtocol = getDerivedStateProtocol(derivedStateMethod, context); + const fallbackRenderStatus = getFallbackRenderStatus( + renderMethod, + derivedStateProtocol.fallbackStateKey, + derivedStateProtocol.status, + ); + return { + componentDidCatchMethod, + derivedStateMethod, + derivedStateStatus: derivedStateProtocol.status, + fallbackRenderStatus, + fallbackStateKey: derivedStateProtocol.fallbackStateKey, + isCandidate, + isSourceComplete: + derivedStateProtocol.status !== ReactErrorBoundaryProtocolStatus.Unknown && + fallbackRenderStatus !== ReactErrorBoundaryProtocolStatus.Unknown, + }; +}; diff --git a/packages/prover/src/collect-event-callback-functions.ts b/packages/prover/src/collect-event-callback-functions.ts new file mode 100644 index 000000000..b2da7e82a --- /dev/null +++ b/packages/prover/src/collect-event-callback-functions.ts @@ -0,0 +1,30 @@ +import ts from "typescript"; +import { collectReachableFunctions } from "./collect-reachable-functions.js"; +import { REACT_EVENT_PROP_PATTERN } from "./constants.js"; +import { isFunctionBoundary } from "./is-function-boundary.js"; +import { resolveFunction } from "./resolve-function.js"; + +export const collectEventCallbackFunctions = ( + functionNode: ts.FunctionLikeDeclaration, + typeChecker: ts.TypeChecker, +): ReadonlyArray => { + const callbacks = new Set(); + for (const reachableFunction of collectReachableFunctions(functionNode, typeChecker)) { + const visit = (node: ts.Node): void => { + if (node !== reachableFunction.functionNode && isFunctionBoundary(node)) return; + if ( + ts.isJsxAttribute(node) && + REACT_EVENT_PROP_PATTERN.test(node.name.getText()) && + node.initializer && + ts.isJsxExpression(node.initializer) && + node.initializer.expression + ) { + const callback = resolveFunction(node.initializer.expression, typeChecker); + if (callback) callbacks.add(callback); + } + node.forEachChild(visit); + }; + reachableFunction.functionNode.forEachChild(visit); + } + return [...callbacks]; +}; diff --git a/packages/prover/src/collect-external-store-protocol-variants.ts b/packages/prover/src/collect-external-store-protocol-variants.ts new file mode 100644 index 000000000..0e1742474 --- /dev/null +++ b/packages/prover/src/collect-external-store-protocol-variants.ts @@ -0,0 +1,260 @@ +import ts from "typescript"; +import { findFunctionByLocation } from "./find-function-by-location.js"; +import { ReactExecutionPhase } from "./types.js"; +import type { + ReactAnalysisContext, + ReactSemanticCallbackPropAlternative, + ReactSemanticExternalStore, +} from "./types.js"; + +export interface ExternalStoreProtocolVariant { + isComplete: boolean; + renderId: string | null; + serverSnapshotFunctions: ReadonlyArray; + snapshotFunctions: ReadonlyArray; + subscribeFunctions: ReadonlyArray; +} + +interface ExternalStoreProtocolChannel { + alternatives: ReadonlyArray; + isComplete: boolean; +} + +interface ExternalStoreProtocolChannels { + serverSnapshot: ExternalStoreProtocolChannel; + snapshot: ExternalStoreProtocolChannel; + subscribe: ExternalStoreProtocolChannel; +} + +interface GuardedExternalStoreProtocolChannel { + alternativesByGuard: ReadonlyMap | null; + channel: ExternalStoreProtocolChannel; +} + +interface CollectExternalStoreProtocolVariantsInput { + context: ReactAnalysisContext; + externalStore: ReactSemanticExternalStore; + serverSnapshotPropName: string | null; + snapshotPropName: string | null; + subscribePropName: string | null; +} + +const createUnguardedAlternatives = ( + callbackIds: ReadonlyArray, +): ReadonlyArray => + callbackIds.map((callbackId) => ({ callbackId, guards: [] })); + +const getGuardSignature = (alternative: ReactSemanticCallbackPropAlternative): string => + alternative.guards + .map((guard) => `${guard.id}=${String(guard.polarity)}`) + .sort() + .join("&"); + +const getGuardedAlternatives = ( + channel: ExternalStoreProtocolChannel, +): ReadonlyMap | null => { + const guardedAlternatives = channel.alternatives.filter( + (alternative) => alternative.guards.length > 0, + ); + if (guardedAlternatives.length === 0) { + return channel.alternatives.length <= 1 ? new Map() : null; + } + if (guardedAlternatives.length !== channel.alternatives.length) return null; + const alternativesByGuard = new Map(); + for (const alternative of guardedAlternatives) { + const guardSignature = getGuardSignature(alternative); + if (!guardSignature || alternativesByGuard.has(guardSignature)) return null; + alternativesByGuard.set(guardSignature, alternative); + } + return alternativesByGuard; +}; + +const haveEqualKeys = ( + first: ReadonlyMap, + second: ReadonlyMap, +): boolean => first.size === second.size && [...first.keys()].every((key) => second.has(key)); + +export const collectExternalStoreProtocolVariants = ({ + context, + externalStore, + serverSnapshotPropName, + snapshotPropName, + subscribePropName, +}: CollectExternalStoreProtocolVariantsInput): ReadonlyArray => { + const callbacksById = new Map( + context.graph?.callbacks.map((callback) => [callback.id, callback]) ?? [], + ); + const resolveCallbackFunctions = ( + alternatives: ReadonlyArray, + ): ReadonlyArray => + alternatives.flatMap((alternative) => { + const callback = callbacksById.get(alternative.callbackId); + if (!callback) return []; + const callbackFunction = findFunctionByLocation( + context.program, + context.rootDirectory, + callback.location, + ); + return callbackFunction ? [callbackFunction] : []; + }); + const getChannel = ( + renderId: string, + propName: string | null, + phase: ReactExecutionPhase, + fallbackCallbackIds: ReadonlyArray, + fallbackIsComplete: boolean, + ): ExternalStoreProtocolChannel => { + if (!propName) { + return { + alternatives: createUnguardedAlternatives(fallbackCallbackIds), + isComplete: fallbackIsComplete, + }; + } + const propFlows = + context.graph?.callbackPropFlows.filter( + (propFlow) => + propFlow.renderId === renderId && + propFlow.targetOwnerId === externalStore.ownerId && + propFlow.propName === propName && + propFlow.phase === phase, + ) ?? []; + return { + alternatives: propFlows.flatMap((propFlow) => propFlow.alternatives), + isComplete: propFlows.length > 0 && propFlows.every((propFlow) => propFlow.complete), + }; + }; + const propChannels = [ + { + phase: ReactExecutionPhase.ExternalStoreSubscription, + propName: subscribePropName, + }, + { + phase: ReactExecutionPhase.Render, + propName: snapshotPropName, + }, + { + phase: ReactExecutionPhase.ServerRender, + propName: serverSnapshotPropName, + }, + ]; + const renderIds = new Set( + context.graph?.callbackPropFlows + .filter( + (propFlow) => + propFlow.targetOwnerId === externalStore.ownerId && + propChannels.some( + (channel) => channel.propName === propFlow.propName && channel.phase === propFlow.phase, + ), + ) + .map((propFlow) => propFlow.renderId) ?? [], + ); + if (renderIds.size === 0) { + if (propChannels.some((channel) => channel.propName)) return []; + return [ + { + isComplete: + externalStore.subscribeComplete && + externalStore.snapshotComplete && + externalStore.serverSnapshotComplete, + renderId: null, + subscribeFunctions: resolveCallbackFunctions( + createUnguardedAlternatives(externalStore.subscribeCallbackIds), + ), + snapshotFunctions: resolveCallbackFunctions( + createUnguardedAlternatives(externalStore.snapshotCallbackIds), + ), + serverSnapshotFunctions: resolveCallbackFunctions( + createUnguardedAlternatives(externalStore.serverSnapshotCallbackIds), + ), + }, + ]; + } + return [...renderIds].flatMap((renderId): ReadonlyArray => { + const channels: ExternalStoreProtocolChannels = { + subscribe: getChannel( + renderId, + subscribePropName, + ReactExecutionPhase.ExternalStoreSubscription, + externalStore.subscribeCallbackIds, + externalStore.subscribeComplete, + ), + snapshot: getChannel( + renderId, + snapshotPropName, + ReactExecutionPhase.Render, + externalStore.snapshotCallbackIds, + externalStore.snapshotComplete, + ), + serverSnapshot: getChannel( + renderId, + serverSnapshotPropName, + ReactExecutionPhase.ServerRender, + externalStore.serverSnapshotCallbackIds, + externalStore.serverSnapshotComplete, + ), + }; + const guardedSubscribeChannel: GuardedExternalStoreProtocolChannel = { + alternativesByGuard: getGuardedAlternatives(channels.subscribe), + channel: channels.subscribe, + }; + const guardedSnapshotChannel: GuardedExternalStoreProtocolChannel = { + alternativesByGuard: getGuardedAlternatives(channels.snapshot), + channel: channels.snapshot, + }; + const guardedServerSnapshotChannel: GuardedExternalStoreProtocolChannel = { + alternativesByGuard: getGuardedAlternatives(channels.serverSnapshot), + channel: channels.serverSnapshot, + }; + const guardedChannels = [ + guardedSubscribeChannel, + guardedSnapshotChannel, + guardedServerSnapshotChannel, + ]; + const referenceGuardedChannel = guardedChannels.find((guardedChannel) => + Boolean(guardedChannel.alternativesByGuard?.size), + ); + const arePartitionsCompatible = guardedChannels.every( + (guardedChannel) => + guardedChannel.alternativesByGuard && + (!guardedChannel.alternativesByGuard.size || + !referenceGuardedChannel?.alternativesByGuard || + haveEqualKeys( + guardedChannel.alternativesByGuard, + referenceGuardedChannel.alternativesByGuard, + )), + ); + const areChannelsComplete = guardedChannels.every( + (guardedChannel) => guardedChannel.channel.isComplete, + ); + if (!arePartitionsCompatible || !referenceGuardedChannel?.alternativesByGuard) { + return [ + { + isComplete: areChannelsComplete && arePartitionsCompatible, + renderId, + subscribeFunctions: resolveCallbackFunctions(channels.subscribe.alternatives), + snapshotFunctions: resolveCallbackFunctions(channels.snapshot.alternatives), + serverSnapshotFunctions: resolveCallbackFunctions(channels.serverSnapshot.alternatives), + }, + ]; + } + return [...referenceGuardedChannel.alternativesByGuard.keys()].map( + (guardSignature): ExternalStoreProtocolVariant => { + const selectAlternatives = ( + guardedChannel: GuardedExternalStoreProtocolChannel, + ): ReadonlyArray => { + const guardedAlternative = guardedChannel.alternativesByGuard?.get(guardSignature); + return guardedAlternative ? [guardedAlternative] : guardedChannel.channel.alternatives; + }; + return { + isComplete: areChannelsComplete, + renderId, + subscribeFunctions: resolveCallbackFunctions(selectAlternatives(guardedSubscribeChannel)), + snapshotFunctions: resolveCallbackFunctions(selectAlternatives(guardedSnapshotChannel)), + serverSnapshotFunctions: resolveCallbackFunctions( + selectAlternatives(guardedServerSnapshotChannel), + ), + }; + }, + ); + }); +}; diff --git a/packages/prover/src/collect-form-actions.ts b/packages/prover/src/collect-form-actions.ts new file mode 100644 index 000000000..c01c58a63 --- /dev/null +++ b/packages/prover/src/collect-form-actions.ts @@ -0,0 +1,197 @@ +import ts from "typescript"; +import { collectReachableFunctions } from "./collect-reachable-functions.js"; +import { isFunctionBoundary } from "./is-function-boundary.js"; +import { ReactFormActionKind, ReactFormActionStatus, ReactUnitKind } from "./types.js"; +import { doesTypeContainCallable } from "./resolve-callable-expression.js"; +import { collectJsxSpreadProperties } from "./utils/collect-jsx-spread-properties.js"; +import { isEffectiveJsxPropertySource } from "./utils/is-effective-jsx-property-source.js"; +import { isIntrinsicJsxElement } from "./utils/is-intrinsic-jsx-element.js"; +import type { ReactUnitDescriptor } from "./types.js"; + +export interface FormActionDescriptor { + actionExpression: ts.Expression; + evidenceNode: ts.JsxAttributeLike; + isSpread: boolean; + kind: ReactFormActionKind; + propertyName: string; + status: ReactFormActionStatus; +} + +const getJsxAttributeExpression = (attribute: ts.JsxAttribute): ts.Expression | null => + attribute.initializer && + ts.isJsxExpression(attribute.initializer) && + attribute.initializer.expression + ? attribute.initializer.expression + : null; + +const getStaticAttributeValue = ( + openingElement: ts.JsxOpeningLikeElement, + attributeName: string, +): string | null | undefined => { + const attribute = openingElement.attributes.properties.find( + (property) => ts.isJsxAttribute(property) && property.name.getText() === attributeName, + ); + if (!attribute || !ts.isJsxAttribute(attribute)) return undefined; + if (!attribute.initializer) return ""; + if (ts.isStringLiteral(attribute.initializer)) return attribute.initializer.text; + if ( + ts.isJsxExpression(attribute.initializer) && + attribute.initializer.expression && + ts.isStringLiteralLike(attribute.initializer.expression) + ) { + return attribute.initializer.expression.text; + } + return null; +}; + +const isStaticallyNestedInForm = (openingElement: ts.JsxOpeningLikeElement): boolean => { + let currentNode: ts.Node = openingElement; + while (currentNode.parent) { + currentNode = currentNode.parent; + if ( + ts.isJsxElement(currentNode) && + ts.isIdentifier(currentNode.openingElement.tagName) && + currentNode.openingElement.tagName.text === "form" + ) { + return true; + } + if (isFunctionBoundary(currentNode)) return false; + } + return false; +}; + +const getActionControl = ( + openingElement: ts.JsxOpeningLikeElement, + propertyName: string, +): { + kind: ReactFormActionKind; + status: ReactFormActionStatus; +} => { + if (!ts.isIdentifier(openingElement.tagName)) { + return { + kind: ReactFormActionKind.Form, + status: ReactFormActionStatus.UnsupportedControl, + }; + } + const tagName = openingElement.tagName.text; + if (tagName === "form" && propertyName === "action") { + return { kind: ReactFormActionKind.Form, status: ReactFormActionStatus.Resolved }; + } + if (tagName === "button" && propertyName === "formAction") { + const typeValue = getStaticAttributeValue(openingElement, "type"); + if (typeValue === null) { + return { kind: ReactFormActionKind.Submitter, status: ReactFormActionStatus.Opaque }; + } + if (typeValue !== undefined && typeValue !== "" && typeValue !== "submit") { + return { + kind: ReactFormActionKind.Submitter, + status: ReactFormActionStatus.UnsupportedControl, + }; + } + const formAssociation = getStaticAttributeValue(openingElement, "form"); + return { + kind: ReactFormActionKind.Submitter, + status: + formAssociation === undefined && isStaticallyNestedInForm(openingElement) + ? ReactFormActionStatus.Resolved + : ReactFormActionStatus.Opaque, + }; + } + if (tagName === "input" && propertyName === "formAction") { + const typeValue = getStaticAttributeValue(openingElement, "type"); + if (typeValue === null) { + return { kind: ReactFormActionKind.Submitter, status: ReactFormActionStatus.Opaque }; + } + if (typeValue !== "image" && typeValue !== "submit") { + return { + kind: ReactFormActionKind.Submitter, + status: ReactFormActionStatus.UnsupportedControl, + }; + } + const formAssociation = getStaticAttributeValue(openingElement, "form"); + return { + kind: ReactFormActionKind.Submitter, + status: + formAssociation === undefined && isStaticallyNestedInForm(openingElement) + ? ReactFormActionStatus.Resolved + : ReactFormActionStatus.Opaque, + }; + } + return { + kind: propertyName === "action" ? ReactFormActionKind.Form : ReactFormActionKind.Submitter, + status: ReactFormActionStatus.UnsupportedControl, + }; +}; + +export const collectFormActions = ( + unit: ReactUnitDescriptor, + typeChecker: ts.TypeChecker, +): ReadonlyArray => { + const functionNode = unit.functionNode; + if ( + !functionNode || + unit.kind === ReactUnitKind.ClassComponent || + unit.kind === ReactUnitKind.InvalidHookOwner + ) { + return []; + } + const actions = new Map(); + for (const reachableFunction of collectReachableFunctions(functionNode, typeChecker)) { + const visit = (node: ts.Node): void => { + if (node !== reachableFunction.functionNode && isFunctionBoundary(node)) return; + if ( + (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) && + isIntrinsicJsxElement(node) + ) { + for (const attribute of node.attributes.properties) { + if (ts.isJsxAttribute(attribute)) { + const propertyName = attribute.name.getText(); + if ( + (propertyName !== "action" && propertyName !== "formAction") || + !isEffectiveJsxPropertySource(attribute, propertyName, typeChecker) + ) { + continue; + } + const actionExpression = getJsxAttributeExpression(attribute); + if ( + !actionExpression || + !doesTypeContainCallable(typeChecker.getTypeAtLocation(actionExpression), typeChecker) + ) { + continue; + } + actions.set(`${attribute.getSourceFile().fileName}:${attribute.getStart()}`, { + actionExpression, + evidenceNode: attribute, + isSpread: false, + propertyName, + ...getActionControl(node, propertyName), + }); + continue; + } + const spreadProperties = collectJsxSpreadProperties(attribute.expression, typeChecker); + for (const propertyName of spreadProperties.callablePropertyNames) { + if ( + (propertyName !== "action" && propertyName !== "formAction") || + !isEffectiveJsxPropertySource(attribute, propertyName, typeChecker) + ) { + continue; + } + actions.set( + `${attribute.getSourceFile().fileName}:${attribute.getStart()}:${propertyName}`, + { + actionExpression: attribute.expression, + evidenceNode: attribute, + isSpread: true, + propertyName, + ...getActionControl(node, propertyName), + }, + ); + } + } + } + node.forEachChild(visit); + }; + reachableFunction.functionNode.forEachChild(visit); + } + return [...actions.values()]; +}; diff --git a/packages/prover/src/collect-hook-bindings.ts b/packages/prover/src/collect-hook-bindings.ts new file mode 100644 index 000000000..acf905f2c --- /dev/null +++ b/packages/prover/src/collect-hook-bindings.ts @@ -0,0 +1,185 @@ +import ts from "typescript"; +import { collectEffectEventBindings } from "./collect-effect-event-bindings.js"; +import { + REACT_ACTION_STATE_DISPATCHER_INDEX, + REACT_ACTION_STATE_REDUCER_INDEX, + REACT_ACTION_STATE_STATE_INDEX, + REACT_ACTION_STATE_TUPLE_LENGTH, + REACT_OPTIMISTIC_REDUCER_INDEX, + REACT_OPTIMISTIC_SETTER_INDEX, + REACT_OPTIMISTIC_STATE_INDEX, + REACT_OPTIMISTIC_TUPLE_LENGTH, + REACT_TRANSITION_STARTER_INDEX, +} from "./constants.js"; +import { getCanonicalReactApiName } from "./get-canonical-react-api-name.js"; +import { isFunctionBoundary } from "./is-function-boundary.js"; + +export interface OptimisticHookBinding { + callExpression: ts.CallExpression; + reducerExpression: ts.Expression | null; + setterSymbol: ts.Symbol | null; + stateSymbol: ts.Symbol | null; +} + +export interface BoundOptimisticHookBinding extends OptimisticHookBinding { + setterSymbol: ts.Symbol; +} + +export interface ActionStateHookBinding { + callExpression: ts.CallExpression; + dispatcherSymbol: ts.Symbol | null; + reducerExpression: ts.Expression | null; + stateSymbol: ts.Symbol | null; +} + +export interface BoundActionStateHookBinding extends ActionStateHookBinding { + dispatcherSymbol: ts.Symbol; +} + +export interface HookBindings { + actionStateBindings: ReadonlyArray; + effectEvents: ReadonlySet; + optimisticBindings: ReadonlyArray; + refs: ReadonlySet; + stateSetters: ReadonlySet; + stateValueBySetter: ReadonlyMap; + stateValues: ReadonlySet; + transitionStarters: ReadonlySet; +} + +const getBindingSymbol = ( + bindingName: ts.BindingName | undefined, + typeChecker: ts.TypeChecker, +): ts.Symbol | null => { + if (!bindingName || !ts.isIdentifier(bindingName)) return null; + return typeChecker.getSymbolAtLocation(bindingName) ?? null; +}; + +export const collectHookBindings = ( + functionNode: ts.FunctionLikeDeclaration, + typeChecker: ts.TypeChecker, +): HookBindings => { + const effectEvents = new Set( + collectEffectEventBindings(functionNode, typeChecker).map((binding) => binding.symbol), + ); + const actionStateBindings: ActionStateHookBinding[] = []; + const refs = new Set(); + const optimisticBindings: OptimisticHookBinding[] = []; + const stateSetters = new Set(); + const stateValueBySetter = new Map(); + const stateValues = new Set(); + const transitionStarters = new Set(); + const visit = (node: ts.Node): void => { + if (node !== functionNode && isFunctionBoundary(node)) { + return; + } + if ( + ts.isVariableDeclaration(node) && + node.initializer && + ts.isCallExpression(node.initializer) + ) { + const callName = getCanonicalReactApiName(node.initializer.expression, typeChecker); + if ( + (callName === "useState" || callName === "useReducer") && + ts.isArrayBindingPattern(node.name) + ) { + const stateBinding = node.name.elements[0]; + const setterBinding = node.name.elements[1]; + const stateBindingName = + stateBinding && ts.isBindingElement(stateBinding) ? stateBinding.name : undefined; + const setterBindingName = + setterBinding && ts.isBindingElement(setterBinding) ? setterBinding.name : undefined; + const stateSymbol = getBindingSymbol(stateBindingName, typeChecker); + const setterSymbol = getBindingSymbol(setterBindingName, typeChecker); + if (stateSymbol) stateValues.add(stateSymbol); + if (setterSymbol) stateSetters.add(setterSymbol); + if (callName === "useState" && stateSymbol && setterSymbol) { + stateValueBySetter.set(setterSymbol, stateSymbol); + } + } + if (callName === "useRef" && ts.isIdentifier(node.name)) { + const refSymbol = getBindingSymbol(node.name, typeChecker); + if (refSymbol) refs.add(refSymbol); + } + if ( + callName === "useActionState" && + ts.isArrayBindingPattern(node.name) && + node.name.elements.length > 0 && + node.name.elements.length <= REACT_ACTION_STATE_TUPLE_LENGTH + ) { + const stateBinding = node.name.elements[REACT_ACTION_STATE_STATE_INDEX]; + const dispatcherBinding = node.name.elements[REACT_ACTION_STATE_DISPATCHER_INDEX]; + const stateBindingName = + stateBinding && ts.isBindingElement(stateBinding) && !stateBinding.dotDotDotToken + ? stateBinding.name + : undefined; + const dispatcherBindingName = + dispatcherBinding && + ts.isBindingElement(dispatcherBinding) && + !dispatcherBinding.dotDotDotToken + ? dispatcherBinding.name + : undefined; + const stateSymbol = getBindingSymbol(stateBindingName, typeChecker); + const dispatcherSymbol = getBindingSymbol(dispatcherBindingName, typeChecker); + if (stateSymbol || dispatcherSymbol) { + actionStateBindings.push({ + callExpression: node.initializer, + dispatcherSymbol, + reducerExpression: node.initializer.arguments[REACT_ACTION_STATE_REDUCER_INDEX] ?? null, + stateSymbol, + }); + if (dispatcherSymbol) stateSetters.add(dispatcherSymbol); + if (stateSymbol) stateValues.add(stateSymbol); + } + } + if ( + callName === "useOptimistic" && + ts.isArrayBindingPattern(node.name) && + node.name.elements.length > 0 && + node.name.elements.length <= REACT_OPTIMISTIC_TUPLE_LENGTH + ) { + const stateBinding = node.name.elements[REACT_OPTIMISTIC_STATE_INDEX]; + const setterBinding = node.name.elements[REACT_OPTIMISTIC_SETTER_INDEX]; + const stateBindingName = + stateBinding && ts.isBindingElement(stateBinding) && !stateBinding.dotDotDotToken + ? stateBinding.name + : undefined; + const setterBindingName = + setterBinding && ts.isBindingElement(setterBinding) && !setterBinding.dotDotDotToken + ? setterBinding.name + : undefined; + const stateSymbol = getBindingSymbol(stateBindingName, typeChecker); + const setterSymbol = getBindingSymbol(setterBindingName, typeChecker); + if (stateSymbol || setterSymbol) { + optimisticBindings.push({ + callExpression: node.initializer, + reducerExpression: node.initializer.arguments[REACT_OPTIMISTIC_REDUCER_INDEX] ?? null, + setterSymbol, + stateSymbol, + }); + if (setterSymbol) stateSetters.add(setterSymbol); + if (stateSymbol) stateValues.add(stateSymbol); + } + } + if (callName === "useTransition" && ts.isArrayBindingPattern(node.name)) { + const starterBinding = node.name.elements[REACT_TRANSITION_STARTER_INDEX]; + const starterBindingName = + starterBinding && ts.isBindingElement(starterBinding) ? starterBinding.name : undefined; + const starterSymbol = getBindingSymbol(starterBindingName, typeChecker); + if (starterSymbol) transitionStarters.add(starterSymbol); + } + } + node.forEachChild(visit); + }; + functionNode.forEachChild(visit); + return { + actionStateBindings, + effectEvents, + optimisticBindings, + refs, + stateSetters, + stateValueBySetter, + stateValues, + transitionStarters, + }; +}; diff --git a/packages/prover/src/collect-hook-calls.ts b/packages/prover/src/collect-hook-calls.ts new file mode 100644 index 000000000..65470830b --- /dev/null +++ b/packages/prover/src/collect-hook-calls.ts @@ -0,0 +1,21 @@ +import ts from "typescript"; +import { getCanonicalHookName } from "./get-canonical-hook-name.js"; +import { isFunctionBoundary } from "./is-function-boundary.js"; + +export const collectHookCalls = ( + functionNode: ts.FunctionLikeDeclaration, + hookNames: ReadonlySet, + typeChecker: ts.TypeChecker, +): ReadonlyArray => { + const calls: ts.CallExpression[] = []; + const visit = (node: ts.Node): void => { + if (node !== functionNode && isFunctionBoundary(node)) return; + if (ts.isCallExpression(node)) { + const callName = getCanonicalHookName(node, typeChecker); + if (callName && hookNames.has(callName)) calls.push(node); + } + node.forEachChild(visit); + }; + functionNode.forEachChild(visit); + return calls; +}; diff --git a/packages/prover/src/collect-hook-state-transitions.ts b/packages/prover/src/collect-hook-state-transitions.ts new file mode 100644 index 000000000..b1ce472db --- /dev/null +++ b/packages/prover/src/collect-hook-state-transitions.ts @@ -0,0 +1,101 @@ +import ts from "typescript"; +import { collectHookBindings } from "./collect-hook-bindings.js"; +import { isIdentifierReference } from "./is-identifier-reference.js"; +import { isNodeWithin } from "./is-node-within.js"; +import { ReactHookStateUpdaterStatus } from "./types.js"; +import { unwrapTypescriptExpression } from "./unwrap-typescript-expression.js"; +import type { ReactAnalysisContext } from "./types.js"; +import { analyzeStateUpdateExpression } from "./utils/analyze-state-update-expression.js"; +import { getResolvedSymbol } from "./utils/get-resolved-symbol.js"; +import { isReactHookDependencyReference } from "./utils/is-react-hook-dependency-reference.js"; + +export interface HookStateTransitionDescriptor { + callExpression: ts.CallExpression | null; + evidenceNode: ts.Node; + setterName: string; + stateName: string; + updaterFunction: ts.FunctionLikeDeclaration | null; + updaterStatus: ReactHookStateUpdaterStatus; +} + +export const collectHookStateTransitions = ( + functionNode: ts.FunctionLikeDeclaration, + context: ReactAnalysisContext, +): ReadonlyArray => { + const hookBindings = collectHookBindings(functionNode, context.typeChecker); + const stateNamesBySetter = new Map( + [...hookBindings.stateValueBySetter].map(([setterSymbol, stateSymbol]) => [ + setterSymbol, + stateSymbol.getName(), + ]), + ); + const handledSetterReferences = new Set(); + const transitions: HookStateTransitionDescriptor[] = []; + const visitCalls = (node: ts.Node): void => { + if (ts.isCallExpression(node)) { + const unwrappedCallee = unwrapTypescriptExpression(node.expression); + const setterSymbol = getResolvedSymbol(unwrappedCallee, context.typeChecker); + const stateName = setterSymbol ? stateNamesBySetter.get(setterSymbol) : undefined; + if (setterSymbol && stateName) { + const updaterExpression = node.arguments[0]; + const updaterAnalysis = updaterExpression + ? analyzeStateUpdateExpression(updaterExpression, context) + : { + updaterFunction: null, + updaterStatus: ReactHookStateUpdaterStatus.Unknown, + }; + transitions.push({ + callExpression: node, + evidenceNode: node, + setterName: setterSymbol.getName(), + stateName, + ...updaterAnalysis, + }); + const collectHandledReferences = (calleeNode: ts.Node): void => { + if ( + ts.isIdentifier(calleeNode) && + getResolvedSymbol(calleeNode, context.typeChecker) === setterSymbol + ) { + handledSetterReferences.add(calleeNode); + } + calleeNode.forEachChild(collectHandledReferences); + }; + collectHandledReferences(node.expression); + } + } + node.forEachChild(visitCalls); + }; + functionNode.forEachChild(visitCalls); + + const visitEscapes = (node: ts.Node): void => { + if ( + ts.isIdentifier(node) && + isIdentifierReference(node) && + !handledSetterReferences.has(node) + ) { + const setterSymbol = getResolvedSymbol(node, context.typeChecker); + const stateName = setterSymbol ? stateNamesBySetter.get(setterSymbol) : undefined; + if ( + setterSymbol && + stateName && + !isReactHookDependencyReference(node, context.typeChecker) && + !transitions.some( + (transition) => + transition.callExpression && isNodeWithin(node, transition.callExpression.expression), + ) + ) { + transitions.push({ + callExpression: null, + evidenceNode: node, + setterName: setterSymbol.getName(), + stateName, + updaterFunction: null, + updaterStatus: ReactHookStateUpdaterStatus.SetterEscape, + }); + } + } + node.forEachChild(visitEscapes); + }; + functionNode.forEachChild(visitEscapes); + return transitions; +}; diff --git a/packages/prover/src/collect-host-control-protocols.ts b/packages/prover/src/collect-host-control-protocols.ts new file mode 100644 index 000000000..8df5b45c0 --- /dev/null +++ b/packages/prover/src/collect-host-control-protocols.ts @@ -0,0 +1,710 @@ +import ts from "typescript"; +import { + REACT_HOST_CONTROL_EVENT_PARAMETER_INDEX, + REACT_HOST_CONTROL_SETTER_ARGUMENT_INDEX, + REACT_HOST_CONTROL_STATE_INDEX, +} from "./constants.js"; +import { collectHookBindings } from "./collect-hook-bindings.js"; +import { collectReachableFunctions } from "./collect-reachable-functions.js"; +import { getCanonicalReactApiName } from "./get-canonical-react-api-name.js"; +import { getStaticBooleanValue } from "./get-static-boolean-value.js"; +import { isFunctionBoundary } from "./is-function-boundary.js"; +import { isIdentifierReference } from "./is-identifier-reference.js"; +import { resolveCallableExpression } from "./resolve-callable-expression.js"; +import { + ReactHostControlKind, + ReactHostControlMutabilityStatus, + ReactHostControlStatus, + ReactHostControlUpdateStatus, + ReactHostControlValueStatus, +} from "./types.js"; +import { unwrapTypescriptExpression } from "./unwrap-typescript-expression.js"; +import { collectJsxSpreadProperties } from "./utils/collect-jsx-spread-properties.js"; +import { getEnclosingFunction } from "./utils/get-enclosing-function.js"; +import { getResolvedSymbol } from "./utils/get-resolved-symbol.js"; +import { isEntryDominatingNode } from "./utils/is-entry-dominating-node.js"; +import type { ReactAnalysisContext } from "./types.js"; + +export interface HostControlProtocolDescriptor { + callbackSourceNode: ts.JsxAttributeLike | null; + controlledPropName: string; + controlledPropPresent: boolean | null; + defaultPropName: string; + defaultPropPresent: boolean | null; + kind: ReactHostControlKind; + mutabilityStatus: ReactHostControlMutabilityStatus; + node: ts.JsxOpeningLikeElement; + setterCallExpressions: ReadonlyArray; + setterName: string | null; + sourceComplete: boolean; + stateName: string | null; + status: ReactHostControlStatus; + updateStatus: ReactHostControlUpdateStatus; + valueStatus: ReactHostControlValueStatus; +} + +interface HostControlPropertyResolution { + expression: ts.Expression | null; + isPresent: boolean | null; + sourceNode: ts.JsxAttributeLike | null; +} + +interface HostControlStateBinding { + setterSymbol: ts.Symbol; + stateSymbol: ts.Symbol; +} + +interface HostControlUpdateAnalysis { + setterCallExpressions: ReadonlyArray; + status: ReactHostControlUpdateStatus; +} + +const NON_EDITABLE_INPUT_TYPES = new Set(["button", "hidden", "image", "reset", "submit"]); + +const getJsxAttributeExpression = (attribute: ts.JsxAttribute): ts.Expression | null => { + if (!attribute.initializer) return null; + if (ts.isStringLiteral(attribute.initializer)) return attribute.initializer; + return ts.isJsxExpression(attribute.initializer) && attribute.initializer.expression + ? attribute.initializer.expression + : null; +}; + +const resolveHostControlProperty = ( + openingElement: ts.JsxOpeningLikeElement, + propertyName: string, + typeChecker: ts.TypeChecker, +): HostControlPropertyResolution => { + let resolution: HostControlPropertyResolution = { + expression: null, + isPresent: false, + sourceNode: null, + }; + for (const attribute of openingElement.attributes.properties) { + if (ts.isJsxAttribute(attribute)) { + if (attribute.name.getText() === propertyName) { + resolution = { + expression: getJsxAttributeExpression(attribute), + isPresent: true, + sourceNode: attribute, + }; + } + continue; + } + const spreadProperties = collectJsxSpreadProperties(attribute.expression, typeChecker); + if ( + spreadProperties.hasUnknownProperties || + spreadProperties.propertyNames.includes(propertyName) + ) { + resolution = { + expression: null, + isPresent: null, + sourceNode: attribute, + }; + } + } + return resolution; +}; + +const getStaticStringValue = (resolution: HostControlPropertyResolution): string | null => { + if (resolution.isPresent === false) return ""; + const expression = resolution.expression + ? unwrapTypescriptExpression(resolution.expression) + : null; + return expression && ts.isStringLiteralLike(expression) ? expression.text : null; +}; + +const getStaticBooleanPropertyValue = ( + resolution: HostControlPropertyResolution, +): boolean | null => { + if (resolution.isPresent === false) return false; + if (resolution.isPresent === null) return null; + if (!resolution.expression) return true; + return getStaticBooleanValue(resolution.expression); +}; + +const getInputKind = ( + typeResolution: HostControlPropertyResolution, +): ReactHostControlKind | null => { + const inputType = getStaticStringValue(typeResolution); + if (inputType === null) return ReactHostControlKind.Unknown; + const normalizedInputType = inputType.toLowerCase() || "text"; + if (NON_EDITABLE_INPUT_TYPES.has(normalizedInputType)) return null; + if (normalizedInputType === "checkbox" || normalizedInputType === "radio") { + return ReactHostControlKind.CheckableInput; + } + if (normalizedInputType === "file") return ReactHostControlKind.FileInput; + return ReactHostControlKind.TextInput; +}; + +const getHostControlKind = ( + openingElement: ts.JsxOpeningLikeElement, + typeChecker: ts.TypeChecker, +): ReactHostControlKind | null => { + if (!ts.isIdentifier(openingElement.tagName)) return null; + if (openingElement.tagName.text === "input") { + return getInputKind(resolveHostControlProperty(openingElement, "type", typeChecker)); + } + if (openingElement.tagName.text === "textarea") return ReactHostControlKind.Textarea; + if (openingElement.tagName.text !== "select") return null; + const multipleResolution = resolveHostControlProperty(openingElement, "multiple", typeChecker); + const multipleValue = getStaticBooleanPropertyValue(multipleResolution); + if (multipleValue === null) return ReactHostControlKind.Unknown; + return multipleValue ? ReactHostControlKind.SelectMultiple : ReactHostControlKind.Select; +}; + +const getControlPropertyNames = ( + kind: ReactHostControlKind, +): { controlledPropName: string; defaultPropName: string; eventValueName: string } => { + if (kind === ReactHostControlKind.CheckableInput) { + return { + controlledPropName: "checked", + defaultPropName: "defaultChecked", + eventValueName: "checked", + }; + } + return { + controlledPropName: "value", + defaultPropName: "defaultValue", + eventValueName: "value", + }; +}; + +const getTypeValueStatus = ( + valueType: ts.Type, + typeChecker: ts.TypeChecker, +): ReactHostControlValueStatus => { + if ( + valueType.flags & + (ts.TypeFlags.Any | ts.TypeFlags.Unknown | ts.TypeFlags.Never | ts.TypeFlags.TypeParameter) + ) { + if (valueType.flags & ts.TypeFlags.TypeParameter) { + const constraint = typeChecker.getBaseConstraintOfType(valueType); + return constraint + ? getTypeValueStatus(constraint, typeChecker) + : ReactHostControlValueStatus.Unknown; + } + return ReactHostControlValueStatus.Unknown; + } + if (valueType.isUnion()) { + const memberStatuses = new Set( + valueType.types.map((memberType) => getTypeValueStatus(memberType, typeChecker)), + ); + if (memberStatuses.size === 1 && memberStatuses.has(ReactHostControlValueStatus.Defined)) { + return ReactHostControlValueStatus.Defined; + } + if (memberStatuses.size === 1 && memberStatuses.has(ReactHostControlValueStatus.Nullish)) { + return ReactHostControlValueStatus.Nullish; + } + return ReactHostControlValueStatus.Unknown; + } + return valueType.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined | ts.TypeFlags.Void) + ? ReactHostControlValueStatus.Nullish + : ReactHostControlValueStatus.Defined; +}; + +const getExpressionValueStatus = ( + expression: ts.Expression, + typeChecker: ts.TypeChecker, +): ReactHostControlValueStatus => { + const unwrappedExpression = unwrapTypescriptExpression(expression); + if ( + unwrappedExpression.kind === ts.SyntaxKind.NullKeyword || + ts.isVoidExpression(unwrappedExpression) + ) { + return ReactHostControlValueStatus.Nullish; + } + if ( + ts.isStringLiteralLike(unwrappedExpression) || + ts.isNumericLiteral(unwrappedExpression) || + ts.isBigIntLiteral(unwrappedExpression) || + ts.isRegularExpressionLiteral(unwrappedExpression) || + ts.isNoSubstitutionTemplateLiteral(unwrappedExpression) || + ts.isArrayLiteralExpression(unwrappedExpression) || + ts.isObjectLiteralExpression(unwrappedExpression) || + ts.isArrowFunction(unwrappedExpression) || + ts.isFunctionExpression(unwrappedExpression) || + ts.isClassExpression(unwrappedExpression) || + ts.isNewExpression(unwrappedExpression) || + unwrappedExpression.kind === ts.SyntaxKind.TrueKeyword || + unwrappedExpression.kind === ts.SyntaxKind.FalseKeyword + ) { + return ReactHostControlValueStatus.Defined; + } + if ( + ts.isBinaryExpression(unwrappedExpression) && + unwrappedExpression.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken && + getExpressionValueStatus(unwrappedExpression.right, typeChecker) === + ReactHostControlValueStatus.Defined + ) { + return ReactHostControlValueStatus.Defined; + } + return getTypeValueStatus(typeChecker.getTypeAtLocation(unwrappedExpression), typeChecker); +}; + +const getStateBinding = ( + expression: ts.Expression, + functionNode: ts.FunctionLikeDeclaration, + context: ReactAnalysisContext, +): HostControlStateBinding | null => { + const getExpressionStateSymbol = (candidateExpression: ts.Expression): ts.Symbol | null => { + const unwrappedExpression = unwrapTypescriptExpression(candidateExpression); + if (ts.isIdentifier(unwrappedExpression)) { + return getResolvedSymbol(unwrappedExpression, context.typeChecker); + } + if ( + ts.isBinaryExpression(unwrappedExpression) && + unwrappedExpression.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken && + getExpressionValueStatus(unwrappedExpression.right, context.typeChecker) === + ReactHostControlValueStatus.Defined + ) { + return getExpressionStateSymbol(unwrappedExpression.left); + } + return null; + }; + const stateSymbol = getExpressionStateSymbol(expression); + if (!stateSymbol) return null; + const hookBindings = collectHookBindings(functionNode, context.typeChecker); + for (const [setterSymbol, candidateStateSymbol] of hookBindings.stateValueBySetter) { + if (candidateStateSymbol === stateSymbol) return { setterSymbol, stateSymbol }; + } + return null; +}; + +const getStateInitializer = ( + stateSymbol: ts.Symbol, + context: ReactAnalysisContext, +): ts.Expression | null | undefined => { + for (const declaration of stateSymbol.declarations ?? []) { + if (!ts.isBindingElement(declaration) || !ts.isArrayBindingPattern(declaration.parent)) { + continue; + } + const variableDeclaration = declaration.parent.parent; + if ( + !ts.isVariableDeclaration(variableDeclaration) || + !variableDeclaration.initializer || + !ts.isCallExpression(variableDeclaration.initializer) || + getCanonicalReactApiName(variableDeclaration.initializer.expression, context.typeChecker) !== + "useState" || + declaration.parent.elements[REACT_HOST_CONTROL_STATE_INDEX] !== declaration + ) { + continue; + } + const initializer = variableDeclaration.initializer.arguments[REACT_HOST_CONTROL_STATE_INDEX]; + if (!initializer) return null; + const unwrappedInitializer = unwrapTypescriptExpression(initializer); + if (ts.isArrowFunction(unwrappedInitializer) || ts.isFunctionExpression(unwrappedInitializer)) { + return ts.isBlock(unwrappedInitializer.body) ? undefined : unwrappedInitializer.body; + } + return initializer; + } + return undefined; +}; + +const collectSetterValueStatuses = ( + functionNode: ts.FunctionLikeDeclaration, + setterSymbol: ts.Symbol, + context: ReactAnalysisContext, +): { + hasEscape: boolean; + statuses: ReadonlyArray; +} => { + const statuses: ReactHostControlValueStatus[] = []; + let hasEscape = false; + const visit = (node: ts.Node): void => { + if ( + ts.isIdentifier(node) && + isIdentifierReference(node) && + getResolvedSymbol(node, context.typeChecker) === setterSymbol + ) { + const callExpression = + ts.isCallExpression(node.parent) && + unwrapTypescriptExpression(node.parent.expression) === node + ? node.parent + : null; + if (!callExpression) { + hasEscape = true; + } else { + const argument = callExpression.arguments[REACT_HOST_CONTROL_SETTER_ARGUMENT_INDEX] ?? null; + if ( + !argument || + ts.isArrowFunction(unwrapTypescriptExpression(argument)) || + ts.isFunctionExpression(unwrapTypescriptExpression(argument)) + ) { + statuses.push(ReactHostControlValueStatus.Unknown); + } else { + statuses.push(getExpressionValueStatus(argument, context.typeChecker)); + } + } + } + node.forEachChild(visit); + }; + functionNode.forEachChild(visit); + return { hasEscape, statuses }; +}; + +const getStateValueStatus = ( + binding: HostControlStateBinding, + functionNode: ts.FunctionLikeDeclaration, + context: ReactAnalysisContext, +): ReactHostControlValueStatus => { + const initializer = getStateInitializer(binding.stateSymbol, context); + let initializerStatus = ReactHostControlValueStatus.Unknown; + if (initializer === null) { + initializerStatus = ReactHostControlValueStatus.Nullish; + } else if (initializer) { + initializerStatus = getExpressionValueStatus(initializer, context.typeChecker); + } + const setterValues = collectSetterValueStatuses(functionNode, binding.setterSymbol, context); + const statuses = new Set([initializerStatus, ...setterValues.statuses]); + if ( + statuses.has(ReactHostControlValueStatus.Defined) && + statuses.has(ReactHostControlValueStatus.Nullish) + ) { + return ReactHostControlValueStatus.MaySwitch; + } + if (setterValues.hasEscape || statuses.has(ReactHostControlValueStatus.Unknown)) { + return ReactHostControlValueStatus.Unknown; + } + return statuses.has(ReactHostControlValueStatus.Defined) + ? ReactHostControlValueStatus.Defined + : ReactHostControlValueStatus.Nullish; +}; + +const isExactEventValueExpression = ( + expression: ts.Expression, + callbackFunction: ts.FunctionLikeDeclaration, + eventValueName: string, + typeChecker: ts.TypeChecker, +): boolean => { + const eventParameter = callbackFunction.parameters[REACT_HOST_CONTROL_EVENT_PARAMETER_INDEX]; + if (!eventParameter || !ts.isIdentifier(eventParameter.name)) return false; + const eventSymbol = getResolvedSymbol(eventParameter.name, typeChecker); + const unwrappedExpression = unwrapTypescriptExpression(expression); + if ( + !eventSymbol || + !ts.isPropertyAccessExpression(unwrappedExpression) || + unwrappedExpression.name.text !== eventValueName + ) { + return false; + } + const eventTarget = unwrapTypescriptExpression(unwrappedExpression.expression); + if ( + !ts.isPropertyAccessExpression(eventTarget) || + (eventTarget.name.text !== "target" && eventTarget.name.text !== "currentTarget") + ) { + return false; + } + const eventIdentifier = unwrapTypescriptExpression(eventTarget.expression); + return ( + ts.isIdentifier(eventIdentifier) && + getResolvedSymbol(eventIdentifier, typeChecker) === eventSymbol + ); +}; + +const analyzeCallbackUpdate = ( + callbackFunction: ts.FunctionLikeDeclaration, + setterSymbol: ts.Symbol, + eventValueName: string, + context: ReactAnalysisContext, +): HostControlUpdateAnalysis => { + const directSetterCalls: ts.CallExpression[] = []; + const deferredSetterCalls: ts.CallExpression[] = []; + let hasOpaqueSetterReference = false; + const visit = (node: ts.Node): void => { + if ( + ts.isIdentifier(node) && + isIdentifierReference(node) && + getResolvedSymbol(node, context.typeChecker) === setterSymbol + ) { + const callExpression = + ts.isCallExpression(node.parent) && + unwrapTypescriptExpression(node.parent.expression) === node + ? node.parent + : null; + if (!callExpression) { + hasOpaqueSetterReference = true; + } else if (getEnclosingFunction(callExpression) === callbackFunction) { + directSetterCalls.push(callExpression); + } else { + deferredSetterCalls.push(callExpression); + } + } + node.forEachChild(visit); + }; + callbackFunction.forEachChild(visit); + if (directSetterCalls.length > 0) { + const hasConditionalCall = directSetterCalls.some( + (callExpression) => !isEntryDominatingNode(callExpression, callbackFunction), + ); + if (hasConditionalCall) { + return { + setterCallExpressions: directSetterCalls, + status: ReactHostControlUpdateStatus.Conditional, + }; + } + const hasWrongValue = directSetterCalls.some((callExpression) => { + const argument = callExpression.arguments[REACT_HOST_CONTROL_SETTER_ARGUMENT_INDEX] ?? null; + return ( + !argument || + !isExactEventValueExpression( + argument, + callbackFunction, + eventValueName, + context.typeChecker, + ) + ); + }); + return { + setterCallExpressions: directSetterCalls, + status: hasWrongValue + ? ReactHostControlUpdateStatus.WrongValue + : ReactHostControlUpdateStatus.Exact, + }; + } + if (deferredSetterCalls.length > 0) { + return { + setterCallExpressions: deferredSetterCalls, + status: ReactHostControlUpdateStatus.Deferred, + }; + } + return { + setterCallExpressions: [], + status: hasOpaqueSetterReference + ? ReactHostControlUpdateStatus.Opaque + : ReactHostControlUpdateStatus.Missing, + }; +}; + +const combineUpdateAnalyses = ( + analyses: ReadonlyArray, +): HostControlUpdateAnalysis => { + const setterCallExpressions = analyses.flatMap((analysis) => analysis.setterCallExpressions); + const priority = [ + ReactHostControlUpdateStatus.WrongValue, + ReactHostControlUpdateStatus.Deferred, + ReactHostControlUpdateStatus.Conditional, + ReactHostControlUpdateStatus.Missing, + ReactHostControlUpdateStatus.Opaque, + ]; + const firstFailure = priority.find((status) => + analyses.some((analysis) => analysis.status === status), + ); + return { + setterCallExpressions, + status: firstFailure ?? ReactHostControlUpdateStatus.Exact, + }; +}; + +const getUpdateAnalysis = ( + onChangeResolution: HostControlPropertyResolution, + stateBinding: HostControlStateBinding | null, + mutabilityStatus: ReactHostControlMutabilityStatus, + eventValueName: string, + context: ReactAnalysisContext, +): HostControlUpdateAnalysis => { + if (mutabilityStatus === ReactHostControlMutabilityStatus.Immutable) { + return { + setterCallExpressions: [], + status: ReactHostControlUpdateStatus.NotRequired, + }; + } + if (onChangeResolution.isPresent === false) { + return { + setterCallExpressions: [], + status: + mutabilityStatus === ReactHostControlMutabilityStatus.Unknown + ? ReactHostControlUpdateStatus.Opaque + : ReactHostControlUpdateStatus.Missing, + }; + } + if (!stateBinding || !onChangeResolution.expression) { + return { + setterCallExpressions: [], + status: ReactHostControlUpdateStatus.Opaque, + }; + } + const callableValue = resolveCallableExpression( + onChangeResolution.expression, + context.typeChecker, + ); + if (!callableValue.isComplete || callableValue.targets.length === 0) { + return { + setterCallExpressions: [], + status: ReactHostControlUpdateStatus.Opaque, + }; + } + return combineUpdateAnalyses( + callableValue.targets.map((target) => + analyzeCallbackUpdate( + target.functionNode, + stateBinding.setterSymbol, + eventValueName, + context, + ), + ), + ); +}; + +const getMutabilityStatus = ( + kind: ReactHostControlKind, + readOnlyResolution: HostControlPropertyResolution, + disabledResolution: HostControlPropertyResolution, +): ReactHostControlMutabilityStatus => { + const readOnly = + kind === ReactHostControlKind.Select || kind === ReactHostControlKind.SelectMultiple + ? false + : getStaticBooleanPropertyValue(readOnlyResolution); + const disabled = getStaticBooleanPropertyValue(disabledResolution); + if (readOnly || disabled) return ReactHostControlMutabilityStatus.Immutable; + if (readOnly === null || disabled === null) return ReactHostControlMutabilityStatus.Unknown; + return ReactHostControlMutabilityStatus.Editable; +}; + +const getProtocolStatus = ( + kind: ReactHostControlKind, + controlledPropPresent: boolean | null, + defaultPropPresent: boolean | null, + valueStatus: ReactHostControlValueStatus, + mutabilityStatus: ReactHostControlMutabilityStatus, + updateStatus: ReactHostControlUpdateStatus, +): ReactHostControlStatus => { + if ( + kind === ReactHostControlKind.Unknown || + controlledPropPresent === null || + defaultPropPresent === null || + (controlledPropPresent && + (kind === ReactHostControlKind.FileInput || kind === ReactHostControlKind.SelectMultiple)) + ) { + return ReactHostControlStatus.Unknown; + } + if ( + (controlledPropPresent && defaultPropPresent) || + valueStatus === ReactHostControlValueStatus.MaySwitch || + valueStatus === ReactHostControlValueStatus.Nullish + ) { + return ReactHostControlStatus.Invalid; + } + if ( + valueStatus === ReactHostControlValueStatus.Unknown || + updateStatus === ReactHostControlUpdateStatus.Opaque || + (controlledPropPresent && + mutabilityStatus === ReactHostControlMutabilityStatus.Unknown && + updateStatus !== ReactHostControlUpdateStatus.Exact) + ) { + return ReactHostControlStatus.Unknown; + } + if ( + updateStatus === ReactHostControlUpdateStatus.Conditional || + updateStatus === ReactHostControlUpdateStatus.Deferred || + updateStatus === ReactHostControlUpdateStatus.Missing || + updateStatus === ReactHostControlUpdateStatus.WrongValue + ) { + return ReactHostControlStatus.Invalid; + } + return ReactHostControlStatus.Resolved; +}; + +export const collectHostControlProtocols = ( + functionNode: ts.FunctionLikeDeclaration, + context: ReactAnalysisContext, +): ReadonlyArray => { + const protocols = new Map(); + for (const reachableFunction of collectReachableFunctions(functionNode, context.typeChecker)) { + const visit = (node: ts.Node): void => { + if (node !== reachableFunction.functionNode && isFunctionBoundary(node)) return; + if (!ts.isJsxOpeningElement(node) && !ts.isJsxSelfClosingElement(node)) { + node.forEachChild(visit); + return; + } + const kind = getHostControlKind(node, context.typeChecker); + if (!kind) { + node.forEachChild(visit); + return; + } + const propertyNames = getControlPropertyNames(kind); + const controlledResolution = resolveHostControlProperty( + node, + propertyNames.controlledPropName, + context.typeChecker, + ); + const defaultResolution = resolveHostControlProperty( + node, + propertyNames.defaultPropName, + context.typeChecker, + ); + const onChangeResolution = resolveHostControlProperty(node, "onChange", context.typeChecker); + const readOnlyResolution = resolveHostControlProperty(node, "readOnly", context.typeChecker); + const disabledResolution = resolveHostControlProperty(node, "disabled", context.typeChecker); + const stateBinding = + controlledResolution.expression && controlledResolution.isPresent + ? getStateBinding(controlledResolution.expression, functionNode, context) + : null; + let valueStatus = ReactHostControlValueStatus.Absent; + if (controlledResolution.isPresent === null) { + valueStatus = ReactHostControlValueStatus.Unknown; + } else if (controlledResolution.isPresent) { + valueStatus = controlledResolution.expression + ? getExpressionValueStatus(controlledResolution.expression, context.typeChecker) + : ReactHostControlValueStatus.Defined; + if (valueStatus === ReactHostControlValueStatus.Unknown && stateBinding) { + valueStatus = getStateValueStatus(stateBinding, functionNode, context); + } + } + const mutabilityStatus = getMutabilityStatus(kind, readOnlyResolution, disabledResolution); + let updateAnalysis: HostControlUpdateAnalysis = { + setterCallExpressions: [], + status: ReactHostControlUpdateStatus.NotRequired, + }; + if ( + controlledResolution.isPresent && + (kind === ReactHostControlKind.FileInput || kind === ReactHostControlKind.SelectMultiple) && + mutabilityStatus !== ReactHostControlMutabilityStatus.Immutable + ) { + updateAnalysis = { + setterCallExpressions: [], + status: ReactHostControlUpdateStatus.Opaque, + }; + } else if (controlledResolution.isPresent) { + updateAnalysis = getUpdateAnalysis( + onChangeResolution, + stateBinding, + mutabilityStatus, + propertyNames.eventValueName, + context, + ); + } + const status = getProtocolStatus( + kind, + controlledResolution.isPresent, + defaultResolution.isPresent, + valueStatus, + mutabilityStatus, + updateAnalysis.status, + ); + const sourceComplete = + status !== ReactHostControlStatus.Unknown && + valueStatus !== ReactHostControlValueStatus.Unknown && + updateAnalysis.status !== ReactHostControlUpdateStatus.Opaque; + protocols.set(`${node.getSourceFile().fileName}:${node.getStart()}`, { + callbackSourceNode: onChangeResolution.sourceNode, + controlledPropName: propertyNames.controlledPropName, + controlledPropPresent: controlledResolution.isPresent, + defaultPropName: propertyNames.defaultPropName, + defaultPropPresent: defaultResolution.isPresent, + kind, + mutabilityStatus, + node, + setterCallExpressions: updateAnalysis.setterCallExpressions, + setterName: stateBinding?.setterSymbol.getName() ?? null, + sourceComplete, + stateName: stateBinding?.stateSymbol.getName() ?? null, + status, + updateStatus: updateAnalysis.status, + valueStatus, + }); + node.forEachChild(visit); + }; + reachableFunction.functionNode.forEachChild(visit); + } + return [...protocols.values()]; +}; diff --git a/packages/prover/src/collect-hydration-equivalence.ts b/packages/prover/src/collect-hydration-equivalence.ts new file mode 100644 index 000000000..3cb35692c --- /dev/null +++ b/packages/prover/src/collect-hydration-equivalence.ts @@ -0,0 +1,649 @@ +import ts from "typescript"; +import { + REACT_HYDRATABLE_SERVER_API_NAMES, + REACT_STATIC_SERVER_API_NAMES, + REACT_TRANSPARENT_COMPONENT_NAMES, +} from "./constants.js"; +import { collectReachableFunctionGraph } from "./collect-reachable-functions.js"; +import { getCanonicalReactApiName } from "./get-canonical-react-api-name.js"; +import { getNodeLocation } from "./get-node-location.js"; +import { isFunctionBoundary } from "./is-function-boundary.js"; +import { summarizeFunctionReturns } from "./summarize-function-returns.js"; +import { + ReactHydrationHazardKind, + ReactHydrationPrefixStatus, + ReactHydrationRootExecutionStatus, + ReactHydrationRootKind, + ReactHydrationStatus, + ReactSemanticEdgeKind, + ReactSemanticRenderKind, +} from "./types.js"; +import { unwrapTypescriptExpression } from "./unwrap-typescript-expression.js"; +import { collectSymbolWrites } from "./utils/collect-symbol-writes.js"; +import { createSemanticId } from "./utils/create-semantic-id.js"; +import { getExpressionSymbol } from "./utils/get-expression-symbol.js"; +import { resolveAliasedSymbol } from "./utils/resolve-aliased-symbol.js"; +import type { + ReactAnalysisContext, + ReactSemanticEdge, + ReactSemanticHydration, + ReactSemanticHydrationHazard, + ReactSemanticHydrationRoot, + ReactSemanticRender, + ReactSemanticSlotFlow, + ReactSemanticUnit, + ReactUnitDescriptor, +} from "./types.js"; + +interface HydrationGraph { + roots: ReadonlyArray; + hazards: ReadonlyArray; + hydrations: ReadonlyArray; +} + +interface HydrationPrefix { + status: ReactHydrationPrefixStatus; + value: string | null; +} + +interface HydrationSourceSet { + rootIds: Set; + hasUnknownSource: boolean; +} + +const BROWSER_GLOBAL_NAMES = new Set([ + "document", + "localStorage", + "location", + "matchMedia", + "navigator", + "sessionStorage", + "window", +]); + +const LOCALE_METHOD_NAMES = new Set(["toLocaleDateString", "toLocaleString", "toLocaleTimeString"]); + +const INTL_CONSTRUCTOR_NAMES = new Set([ + "Collator", + "DateTimeFormat", + "DisplayNames", + "ListFormat", + "NumberFormat", + "PluralRules", + "RelativeTimeFormat", + "Segmenter", +]); + +const getJsxChildren = (children: ts.NodeArray): ReadonlyArray => + children.filter((child) => !(ts.isJsxText(child) && child.getText().trim().length === 0)); + +const getCanonicalJsxApiName = ( + tagName: ts.JsxTagNameExpression, + context: ReactAnalysisContext, +): string | null => + ts.isJsxNamespacedName(tagName) ? null : getCanonicalReactApiName(tagName, context.typeChecker); + +const resolveHydrationTarget = ( + expression: ts.Expression, + unitIdsBySymbol: ReadonlyMap, + context: ReactAnalysisContext, + visitedSymbols: Set, +): string | null => { + const unwrappedExpression = unwrapTypescriptExpression(expression); + if (ts.isJsxSelfClosingElement(unwrappedExpression)) { + const reactApiName = getCanonicalJsxApiName(unwrappedExpression.tagName, context); + if (reactApiName && REACT_TRANSPARENT_COMPONENT_NAMES.has(reactApiName)) return null; + const symbol = getExpressionSymbol(unwrappedExpression.tagName, context.typeChecker); + return symbol ? (unitIdsBySymbol.get(symbol) ?? null) : null; + } + if (ts.isJsxElement(unwrappedExpression)) { + const reactApiName = getCanonicalJsxApiName( + unwrappedExpression.openingElement.tagName, + context, + ); + if (reactApiName && REACT_TRANSPARENT_COMPONENT_NAMES.has(reactApiName)) { + const children = getJsxChildren(unwrappedExpression.children); + if (children.length !== 1) return null; + const child = children[0]; + if (!child) return null; + if (ts.isJsxElement(child) || ts.isJsxSelfClosingElement(child)) { + return resolveHydrationTarget(child, unitIdsBySymbol, context, visitedSymbols); + } + if (ts.isJsxExpression(child) && child.expression) { + return resolveHydrationTarget(child.expression, unitIdsBySymbol, context, visitedSymbols); + } + return null; + } + const symbol = getExpressionSymbol( + unwrappedExpression.openingElement.tagName, + context.typeChecker, + ); + return symbol ? (unitIdsBySymbol.get(symbol) ?? null) : null; + } + if (ts.isJsxFragment(unwrappedExpression)) { + const children = getJsxChildren(unwrappedExpression.children); + if (children.length !== 1) return null; + const child = children[0]; + if (!child) return null; + if (ts.isJsxElement(child) || ts.isJsxSelfClosingElement(child)) { + return resolveHydrationTarget(child, unitIdsBySymbol, context, visitedSymbols); + } + if (ts.isJsxExpression(child) && child.expression) { + return resolveHydrationTarget(child.expression, unitIdsBySymbol, context, visitedSymbols); + } + return null; + } + if ( + ts.isCallExpression(unwrappedExpression) && + getCanonicalReactApiName(unwrappedExpression.expression, context.typeChecker) === + "createElement" + ) { + const componentExpression = unwrappedExpression.arguments[0]; + if (!componentExpression) return null; + const reactApiName = getCanonicalReactApiName(componentExpression, context.typeChecker); + if (reactApiName && REACT_TRANSPARENT_COMPONENT_NAMES.has(reactApiName)) { + const childExpression = unwrappedExpression.arguments[2]; + return childExpression + ? resolveHydrationTarget(childExpression, unitIdsBySymbol, context, visitedSymbols) + : null; + } + const symbol = getExpressionSymbol(componentExpression, context.typeChecker); + return symbol ? (unitIdsBySymbol.get(symbol) ?? null) : null; + } + if (!ts.isIdentifier(unwrappedExpression)) return null; + const unresolvedSymbol = context.typeChecker.getSymbolAtLocation(unwrappedExpression); + if (!unresolvedSymbol) return null; + const symbol = resolveAliasedSymbol(unresolvedSymbol, context.typeChecker); + const directTarget = unitIdsBySymbol.get(symbol); + if (directTarget) return directTarget; + if (visitedSymbols.has(symbol)) return null; + visitedSymbols.add(symbol); + for (const declaration of symbol.declarations ?? []) { + if ( + ts.isVariableDeclaration(declaration) && + declaration.initializer && + collectSymbolWrites(symbol, declaration.getSourceFile(), context.typeChecker).length === 0 + ) { + const targetId = resolveHydrationTarget( + declaration.initializer, + unitIdsBySymbol, + context, + visitedSymbols, + ); + if (targetId) return targetId; + } + } + return null; +}; + +const getPropertyName = (name: ts.PropertyName): string | null => { + if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) { + return name.text; + } + if (ts.isComputedPropertyName(name) && ts.isStringLiteral(name.expression)) { + return name.expression.text; + } + return null; +}; + +const readIdentifierPrefix = (optionsExpression: ts.Expression | undefined): HydrationPrefix => { + if (!optionsExpression) { + return { status: ReactHydrationPrefixStatus.Known, value: "" }; + } + const options = unwrapTypescriptExpression(optionsExpression); + if (!ts.isObjectLiteralExpression(options)) { + return { status: ReactHydrationPrefixStatus.Unknown, value: null }; + } + let prefix: HydrationPrefix = { + status: ReactHydrationPrefixStatus.Known, + value: "", + }; + for (const property of options.properties) { + if (ts.isSpreadAssignment(property)) { + prefix = { status: ReactHydrationPrefixStatus.Unknown, value: null }; + continue; + } + if ( + !ts.isPropertyAssignment(property) && + !ts.isShorthandPropertyAssignment(property) && + !ts.isMethodDeclaration(property) + ) { + continue; + } + if (getPropertyName(property.name) !== "identifierPrefix") continue; + const initializer = ts.isPropertyAssignment(property) + ? unwrapTypescriptExpression(property.initializer) + : null; + if (initializer && ts.isStringLiteralLike(initializer)) { + prefix = { + status: ReactHydrationPrefixStatus.Known, + value: initializer.text, + }; + } else { + prefix = { status: ReactHydrationPrefixStatus.Unknown, value: null }; + } + } + return prefix; +}; + +const getHydrationRootKind = (apiName: string): ReactHydrationRootKind | null => { + if (apiName === "hydrateRoot") return ReactHydrationRootKind.Client; + if (REACT_HYDRATABLE_SERVER_API_NAMES.has(apiName)) { + return ReactHydrationRootKind.ServerInteractive; + } + return REACT_STATIC_SERVER_API_NAMES.has(apiName) ? ReactHydrationRootKind.ServerStatic : null; +}; + +const collectHydrationRoots = ( + sourceFiles: ReadonlyArray, + unitIdsBySymbol: ReadonlyMap, + context: ReactAnalysisContext, +): ReadonlyArray => { + const roots: ReactSemanticHydrationRoot[] = []; + for (const sourceFile of sourceFiles) { + const visit = (node: ts.Node): void => { + if (ts.isCallExpression(node)) { + const apiName = getCanonicalReactApiName(node.expression, context.typeChecker); + const kind = apiName ? getHydrationRootKind(apiName) : null; + if (apiName && kind) { + const rootExpression = + kind === ReactHydrationRootKind.Client ? node.arguments[1] : node.arguments[0]; + const optionsExpression = + kind === ReactHydrationRootKind.Client ? node.arguments[2] : node.arguments[1]; + const targetId = rootExpression + ? resolveHydrationTarget(rootExpression, unitIdsBySymbol, context, new Set()) + : null; + const prefix = readIdentifierPrefix(optionsExpression); + let currentParent: ts.Node | undefined = node.parent; + while (currentParent && !ts.isSourceFile(currentParent)) { + if (isFunctionBoundary(currentParent)) break; + currentParent = currentParent.parent; + } + const executionStatus = ts.isSourceFile(currentParent) + ? ReactHydrationRootExecutionStatus.Module + : ReactHydrationRootExecutionStatus.Unknown; + const sourceComplete = + targetId !== null && + prefix.status === ReactHydrationPrefixStatus.Known && + executionStatus === ReactHydrationRootExecutionStatus.Module; + roots.push({ + id: createSemanticId("hydration-root", apiName, node, context), + apiName, + kind, + targetId, + identifierPrefix: prefix.value, + prefixStatus: prefix.status, + executionStatus, + location: getNodeLocation(node, context.rootDirectory), + sourceComplete, + complete: sourceComplete, + }); + } + } + node.forEachChild(visit); + }; + sourceFile.forEachChild(visit); + } + return roots; +}; + +const isDefaultLibrarySymbol = ( + symbol: ts.Symbol | undefined, + context: ReactAnalysisContext, +): boolean => + Boolean( + symbol?.declarations?.length && + symbol.declarations.every((declaration) => + context.program.isSourceFileDefaultLibrary(declaration.getSourceFile()), + ), + ); + +const hasReturnDescendant = (node: ts.Node): boolean => { + let hasReturn = false; + const visit = (child: ts.Node): void => { + if (child !== node && isFunctionBoundary(child)) return; + if (ts.isReturnStatement(child)) { + hasReturn = true; + return; + } + child.forEachChild(visit); + }; + node.forEachChild(visit); + return hasReturn; +}; + +const collectOutputExpressions = ( + functionNode: ts.FunctionLikeDeclaration, + context: ReactAnalysisContext, +): ReadonlyArray => { + const returnSummary = summarizeFunctionReturns(functionNode, context.typeChecker); + const expressions = returnSummary.expressions.map((returned) => returned.expression); + if (!functionNode.body || !ts.isBlock(functionNode.body)) return expressions; + const visit = (node: ts.Node): void => { + if (node !== functionNode && isFunctionBoundary(node)) return; + if ( + ts.isIfStatement(node) && + (hasReturnDescendant(node.thenStatement) || + Boolean(node.elseStatement && hasReturnDescendant(node.elseStatement))) + ) { + expressions.push(node.expression); + } + if (ts.isSwitchStatement(node) && hasReturnDescendant(node.caseBlock)) { + expressions.push(node.expression); + } + node.forEachChild(visit); + }; + functionNode.body.forEachChild(visit); + return expressions; +}; + +const collectHydrationHazardsForUnit = ( + descriptor: ReactUnitDescriptor, + ownerId: string, + context: ReactAnalysisContext, +): ReadonlyArray => { + const functionNode = descriptor.functionNode; + if (!functionNode) return []; + const hazards = new Map(); + const visitedExpressions = new Set(); + const reachableFunctionGraph = collectReachableFunctionGraph(functionNode, context.typeChecker); + const addHazard = (node: ts.Node, kind: ReactHydrationHazardKind, description: string): void => { + const id = createSemanticId("hydration-hazard", `${ownerId}:${kind}`, node, context); + if (hazards.has(id)) return; + hazards.set(id, { + id, + ownerId, + kind, + description, + location: getNodeLocation(node, context.rootDirectory), + }); + }; + const visitExpression = (expression: ts.Expression): void => { + const unwrappedExpression = unwrapTypescriptExpression(expression); + if (visitedExpressions.has(unwrappedExpression)) return; + visitedExpressions.add(unwrappedExpression); + const visit = (node: ts.Node): void => { + if (node !== unwrappedExpression && isFunctionBoundary(node)) return; + if (ts.isIdentifier(node) && BROWSER_GLOBAL_NAMES.has(node.text)) { + const symbol = context.typeChecker.getSymbolAtLocation(node); + if (isDefaultLibrarySymbol(symbol, context)) { + if (ts.isTypeOfExpression(node.parent) && node.parent.expression === node) { + addHazard( + node.parent, + ReactHydrationHazardKind.EnvironmentBranch, + `${node.parent.getText()} can select different server and client output`, + ); + } else { + addHazard( + node, + ReactHydrationHazardKind.BrowserGlobal, + `${node.text} is unavailable or different during server rendering`, + ); + } + } + } + if ( + ts.isCallExpression(node) && + node.arguments.length === 0 && + ts.isPropertyAccessExpression(node.expression) && + LOCALE_METHOD_NAMES.has(node.expression.name.text) && + isDefaultLibrarySymbol( + context.typeChecker.getSymbolAtLocation(node.expression.name), + context, + ) + ) { + addHazard( + node, + ReactHydrationHazardKind.LocaleFormatting, + `${node.expression.name.text}() depends on the host locale or time zone`, + ); + } + if ( + ts.isNewExpression(node) && + (!node.arguments || node.arguments.length === 0) && + ts.isPropertyAccessExpression(node.expression) && + ts.isIdentifier(node.expression.expression) && + node.expression.expression.text === "Intl" && + INTL_CONSTRUCTOR_NAMES.has(node.expression.name.text) && + isDefaultLibrarySymbol( + context.typeChecker.getSymbolAtLocation(node.expression.expression), + context, + ) + ) { + addHazard( + node, + ReactHydrationHazardKind.LocaleFormatting, + `${node.expression.getText()} uses host-default internationalization settings`, + ); + } + if (ts.isIdentifier(node)) { + const symbol = context.typeChecker.getSymbolAtLocation(node); + for (const declaration of symbol?.declarations ?? []) { + if (ts.isVariableDeclaration(declaration) && declaration.initializer) { + const initializer = unwrapTypescriptExpression(declaration.initializer); + if (!ts.isArrowFunction(initializer) && !ts.isFunctionExpression(initializer)) { + visitExpression(initializer); + } + } + if ( + ts.isBindingElement(declaration) && + ts.isArrayBindingPattern(declaration.parent) && + ts.isVariableDeclaration(declaration.parent.parent) && + declaration.parent.parent.initializer && + ts.isCallExpression(unwrapTypescriptExpression(declaration.parent.parent.initializer)) + ) { + const hookCall = unwrapTypescriptExpression(declaration.parent.parent.initializer); + if (!ts.isCallExpression(hookCall)) continue; + const hookName = getCanonicalReactApiName(hookCall.expression, context.typeChecker); + if (hookName !== "useState") continue; + const stateIndex = declaration.parent.elements.indexOf(declaration); + if (stateIndex !== 0) continue; + const initializer = hookCall.arguments[0]; + if (!initializer) continue; + if (ts.isArrowFunction(initializer) || ts.isFunctionExpression(initializer)) { + for (const outputExpression of collectOutputExpressions(initializer, context)) { + visitExpression(outputExpression); + } + } else { + visitExpression(initializer); + } + } + } + } + node.forEachChild(visit); + }; + visit(unwrappedExpression); + }; + for (const reachableFunction of reachableFunctionGraph.functions) { + for (const outputExpression of collectOutputExpressions( + reachableFunction.functionNode, + context, + )) { + visitExpression(outputExpression); + } + } + return [...hazards.values()]; +}; + +const addHydrationSource = ( + sourcesByUnit: Map, + unitId: string, + rootId: string, +): boolean => { + let sources = sourcesByUnit.get(unitId); + if (!sources) { + sources = { rootIds: new Set(), hasUnknownSource: false }; + sourcesByUnit.set(unitId, sources); + } + const previousSize = sources.rootIds.size; + sources.rootIds.add(rootId); + return sources.rootIds.size !== previousSize; +}; + +const addUnknownHydrationSource = ( + sourcesByUnit: Map, + unitId: string, +): boolean => { + let sources = sourcesByUnit.get(unitId); + if (!sources) { + sources = { rootIds: new Set(), hasUnknownSource: false }; + sourcesByUnit.set(unitId, sources); + } + if (sources.hasUnknownSource) return false; + sources.hasUnknownSource = true; + return true; +}; + +const deriveHydrationSourcesByUnit = ( + units: ReadonlyArray, + roots: ReadonlyArray, + edges: ReadonlyArray, + renders: ReadonlyArray, + slotFlows: ReadonlyArray, +): ReadonlyMap => { + const unitIds = new Set(units.map((unit) => unit.id)); + const sourcesByUnit = new Map(); + const rendersById = new Map(renders.map((render) => [render.id, render])); + for (const root of roots) { + if (root.targetId) addHydrationSource(sourcesByUnit, root.targetId, root.id); + } + let didSourcesChange = true; + while (didSourcesChange) { + didSourcesChange = false; + for (const render of renders) { + if (render.kind === ReactSemanticRenderKind.SlotInput) continue; + const ownerSources = sourcesByUnit.get(render.ownerId); + if (!ownerSources) continue; + for (const rootId of ownerSources.rootIds) { + didSourcesChange = + addHydrationSource(sourcesByUnit, render.targetId, rootId) || didSourcesChange; + } + if (ownerSources.hasUnknownSource) { + didSourcesChange = + addUnknownHydrationSource(sourcesByUnit, render.targetId) || didSourcesChange; + } + } + for (const edge of edges) { + if (edge.kind !== ReactSemanticEdgeKind.CallsHook || !unitIds.has(edge.targetId)) continue; + const ownerSources = sourcesByUnit.get(edge.sourceId); + if (!ownerSources) continue; + for (const rootId of ownerSources.rootIds) { + didSourcesChange = + addHydrationSource(sourcesByUnit, edge.targetId, rootId) || didSourcesChange; + } + if (ownerSources.hasUnknownSource) { + didSourcesChange = + addUnknownHydrationSource(sourcesByUnit, edge.targetId) || didSourcesChange; + } + } + for (const slotFlow of slotFlows) { + if (slotFlow.complete) continue; + const sourceRender = rendersById.get(slotFlow.sourceRenderId); + if (!sourceRender || !sourcesByUnit.has(sourceRender.ownerId)) continue; + didSourcesChange = + addUnknownHydrationSource(sourcesByUnit, sourceRender.targetId) || didSourcesChange; + } + } + return sourcesByUnit; +}; + +const collectHydrations = ( + units: ReadonlyArray, + roots: ReadonlyArray, + hazards: ReadonlyArray, + edges: ReadonlyArray, + renders: ReadonlyArray, + slotFlows: ReadonlyArray, +): ReadonlyArray => { + const rootsById = new Map(roots.map((root) => [root.id, root])); + const sourcesByUnit = deriveHydrationSourcesByUnit(units, roots, edges, renders, slotFlows); + const hasIncompleteRoot = roots.some((root) => !root.sourceComplete); + const hasClientRoot = roots.some((root) => root.kind === ReactHydrationRootKind.Client); + return units.map((unit) => { + const sources = sourcesByUnit.get(unit.id); + const sourceRoots = [...(sources?.rootIds ?? [])] + .map((rootId) => rootsById.get(rootId)) + .filter((root): root is ReactSemanticHydrationRoot => Boolean(root)); + const clientRoots = sourceRoots.filter((root) => root.kind === ReactHydrationRootKind.Client); + const interactiveServerRoots = sourceRoots.filter( + (root) => root.kind === ReactHydrationRootKind.ServerInteractive, + ); + const staticServerRoots = sourceRoots.filter( + (root) => root.kind === ReactHydrationRootKind.ServerStatic, + ); + const unitHazards = hazards.filter((hazard) => hazard.ownerId === unit.id); + let status = ReactHydrationStatus.Unknown; + if (!hasClientRoot) { + status = ReactHydrationStatus.NotHydrated; + } else if (hasIncompleteRoot) { + status = ReactHydrationStatus.Unknown; + } else if (sourceRoots.length === 0) { + status = ReactHydrationStatus.NotHydrated; + } else if ( + !hasIncompleteRoot && + !sources?.hasUnknownSource && + clientRoots.length === 1 && + interactiveServerRoots.length === 1 && + staticServerRoots.length === 0 + ) { + const clientPrefix = clientRoots[0]?.identifierPrefix; + const serverPrefix = interactiveServerRoots[0]?.identifierPrefix; + status = + clientPrefix === serverPrefix && unitHazards.length === 0 + ? ReactHydrationStatus.Equivalent + : ReactHydrationStatus.Mismatched; + } else if ( + !hasIncompleteRoot && + !sources?.hasUnknownSource && + clientRoots.length === 1 && + interactiveServerRoots.length === 0 && + staticServerRoots.length === 1 + ) { + status = ReactHydrationStatus.Mismatched; + } + const sourceComplete = + status === ReactHydrationStatus.NotHydrated || + (!hasIncompleteRoot && !sources?.hasUnknownSource && status !== ReactHydrationStatus.Unknown); + return { + id: `${unit.id}:hydration`, + ownerId: unit.id, + clientRootIds: clientRoots.map((root) => root.id), + interactiveServerRootIds: interactiveServerRoots.map((root) => root.id), + staticServerRootIds: staticServerRoots.map((root) => root.id), + hazardIds: unitHazards.map((hazard) => hazard.id), + status, + sourceComplete, + complete: + sourceComplete && + (status === ReactHydrationStatus.Equivalent || status === ReactHydrationStatus.NotHydrated), + }; + }); +}; + +export const collectHydrationEquivalence = ( + descriptors: ReadonlyArray, + units: ReadonlyArray, + sourceFiles: ReadonlyArray, + unitIdsBySymbol: ReadonlyMap, + edges: ReadonlyArray, + renders: ReadonlyArray, + slotFlows: ReadonlyArray, + context: ReactAnalysisContext, +): HydrationGraph => { + const roots = collectHydrationRoots(sourceFiles, unitIdsBySymbol, context); + const unitIdsByDescriptor = new Map( + descriptors.map((descriptor, descriptorIndex) => [descriptor, units[descriptorIndex]?.id]), + ); + const hasClientRoot = roots.some((root) => root.kind === ReactHydrationRootKind.Client); + const hazards = hasClientRoot + ? descriptors.flatMap((descriptor) => { + const ownerId = unitIdsByDescriptor.get(descriptor); + return ownerId ? collectHydrationHazardsForUnit(descriptor, ownerId, context) : []; + }) + : []; + return { + roots, + hazards, + hydrations: collectHydrations(units, roots, hazards, edges, renders, slotFlows), + }; +}; diff --git a/packages/prover/src/collect-imperative-handles.ts b/packages/prover/src/collect-imperative-handles.ts new file mode 100644 index 000000000..392e77ea9 --- /dev/null +++ b/packages/prover/src/collect-imperative-handles.ts @@ -0,0 +1,170 @@ +import ts from "typescript"; +import { collectHookCalls } from "./collect-hook-calls.js"; +import { REACT_IMPERATIVE_HANDLE_HOOK_NAMES } from "./constants.js"; +import { getCanonicalReactApiName } from "./get-canonical-react-api-name.js"; +import { getComponentPropName } from "./get-component-prop-name.js"; +import { resolveFunction } from "./resolve-function.js"; +import { summarizeFunctionReturns } from "./summarize-function-returns.js"; +import { unwrapTypescriptExpression } from "./unwrap-typescript-expression.js"; +import { getStaticPropertyName } from "./utils/get-static-property-name.js"; + +export enum ImperativeHandleRefKind { + ForwardedRef = "forwarded-ref", + RefProp = "ref-prop", +} + +export interface ImperativeHandleMethodDescriptor { + functionNode: ts.FunctionLikeDeclaration; + name: string; +} + +export interface ImperativeHandleDescriptor { + callExpression: ts.CallExpression; + factoryExpression: ts.Expression | null; + factoryFunction: ts.FunctionLikeDeclaration | null; + methods: ReadonlyArray; + refExpression: ts.Expression | null; + refKind: ImperativeHandleRefKind | null; + refName: string | null; + shapeComplete: boolean; + targetComplete: boolean; +} + +const getForwardedRefName = ( + expression: ts.Expression, + functionNode: ts.FunctionLikeDeclaration, + typeChecker: ts.TypeChecker, +): string | null => { + if (!ts.isIdentifier(expression)) return null; + const refParameter = functionNode.parameters[1]; + if (!refParameter || !ts.isIdentifier(refParameter.name)) return null; + const expressionSymbol = typeChecker.getSymbolAtLocation(expression); + const parameterSymbol = typeChecker.getSymbolAtLocation(refParameter.name); + if (!expressionSymbol || expressionSymbol !== parameterSymbol) return null; + const parentCall = ts.isCallExpression(functionNode.parent) ? functionNode.parent : null; + return parentCall && getCanonicalReactApiName(parentCall.expression, typeChecker) === "forwardRef" + ? refParameter.name.text + : null; +}; + +const getRefTarget = ( + expression: ts.Expression | null, + functionNode: ts.FunctionLikeDeclaration, + typeChecker: ts.TypeChecker, +): { + kind: ImperativeHandleRefKind | null; + name: string | null; +} => { + if (!expression) return { kind: null, name: null }; + const propName = getComponentPropName(expression, functionNode, typeChecker); + if (propName === "ref") { + return { kind: ImperativeHandleRefKind.RefProp, name: expression.getText() }; + } + const forwardedRefName = getForwardedRefName(expression, functionNode, typeChecker); + return forwardedRefName + ? { kind: ImperativeHandleRefKind.ForwardedRef, name: forwardedRefName } + : { kind: null, name: null }; +}; + +const collectObjectMethods = ( + objectExpression: ts.ObjectLiteralExpression, + typeChecker: ts.TypeChecker, +): { + methods: ReadonlyArray; + complete: boolean; +} => { + const methodsByName = new Map(); + let complete = true; + for (const property of objectExpression.properties) { + if (ts.isSpreadAssignment(property)) { + complete = false; + continue; + } + const propertyName = getStaticPropertyName(property.name); + if (!propertyName || methodsByName.has(propertyName)) { + complete = false; + continue; + } + if (ts.isMethodDeclaration(property)) { + methodsByName.set(propertyName, { functionNode: property, name: propertyName }); + continue; + } + if (ts.isPropertyAssignment(property)) { + const propertyFunction = resolveFunction(property.initializer, typeChecker); + const propertyType = typeChecker.getTypeAtLocation(property.initializer); + if (propertyFunction) { + methodsByName.set(propertyName, { + functionNode: propertyFunction, + name: propertyName, + }); + } else if (propertyType.getCallSignatures().length > 0) { + complete = false; + } + continue; + } + if (ts.isShorthandPropertyAssignment(property)) { + const propertyFunction = resolveFunction(property.name, typeChecker); + if (propertyFunction) { + methodsByName.set(propertyName, { + functionNode: propertyFunction, + name: propertyName, + }); + } else if (typeChecker.getTypeAtLocation(property.name).getCallSignatures().length > 0) { + complete = false; + } + continue; + } + complete = false; + } + return { methods: [...methodsByName.values()], complete }; +}; + +const collectHandleMethods = ( + factoryFunction: ts.FunctionLikeDeclaration | null, + typeChecker: ts.TypeChecker, +): { + methods: ReadonlyArray; + complete: boolean; +} => { + if (!factoryFunction) return { methods: [], complete: false }; + const returnSummary = summarizeFunctionReturns(factoryFunction, typeChecker); + if ( + !returnSummary.isComplete || + returnSummary.canFallThrough || + returnSummary.expressions.length !== 1 + ) { + return { methods: [], complete: false }; + } + const returnExpression = unwrapTypescriptExpression(returnSummary.expressions[0].expression); + if (!ts.isObjectLiteralExpression(returnExpression)) { + return { methods: [], complete: false }; + } + return collectObjectMethods(returnExpression, typeChecker); +}; + +export const collectImperativeHandles = ( + functionNode: ts.FunctionLikeDeclaration, + typeChecker: ts.TypeChecker, +): ReadonlyArray => + collectHookCalls(functionNode, REACT_IMPERATIVE_HANDLE_HOOK_NAMES, typeChecker).map( + (callExpression) => { + const refExpression = callExpression.arguments[0] ?? null; + const factoryExpression = callExpression.arguments[1] ?? null; + const factoryFunction = factoryExpression + ? resolveFunction(factoryExpression, typeChecker) + : null; + const refTarget = getRefTarget(refExpression, functionNode, typeChecker); + const handleMethods = collectHandleMethods(factoryFunction, typeChecker); + return { + callExpression, + factoryExpression, + factoryFunction, + methods: handleMethods.methods, + refExpression, + refKind: refTarget.kind, + refName: refTarget.name, + shapeComplete: handleMethods.complete, + targetComplete: Boolean(refTarget.kind && refTarget.name), + }; + }, + ); diff --git a/packages/prover/src/collect-memo-equivalence.ts b/packages/prover/src/collect-memo-equivalence.ts new file mode 100644 index 000000000..17a11be13 --- /dev/null +++ b/packages/prover/src/collect-memo-equivalence.ts @@ -0,0 +1,1067 @@ +import ts from "typescript"; +import { MAX_MEMO_COMPARATOR_PATHS } from "./constants.js"; +import { getCanonicalReactApiName } from "./get-canonical-react-api-name.js"; +import { getNodeLocation } from "./get-node-location.js"; +import { isIdentifierReference } from "./is-identifier-reference.js"; +import { resolveFunction } from "./resolve-function.js"; +import { ReactMemoComparatorKind, ReactMemoComparatorStatus } from "./types.js"; +import { unwrapTypescriptExpression } from "./unwrap-typescript-expression.js"; +import { createSemanticId } from "./utils/create-semantic-id.js"; +import { isMemoObservationCovered } from "./utils/is-memo-observation-covered.js"; +import { resolveAliasedSymbol } from "./utils/resolve-aliased-symbol.js"; +import type { + ReactAnalysisContext, + ReactSemanticMemoComparator, + ReactSemanticMemoComparatorTruePath, + ReactSemanticMemoPropObservation, + ReactSemanticUnit, + ReactUnitDescriptor, +} from "./types.js"; + +enum MemoPropSide { + Next = "next", + Previous = "previous", +} + +interface MemoPropBinding { + side: MemoPropSide; + path: string; + isRest: boolean; +} + +interface ComponentPropBinding { + path: string; + isRest: boolean; +} + +interface LogicalComparatorPath { + equalPropPaths: Set; + unequalPropPaths: Set; + sourceComplete: boolean; +} + +interface LogicalPathCollection { + paths: ReadonlyArray; + complete: boolean; +} + +interface BooleanComparatorBranches { + whenTrue: ReadonlyArray; + whenFalse: ReadonlyArray; + complete: boolean; +} + +interface ComparatorStatementResult { + continuingPaths: ReadonlyArray; + truePaths: ReadonlyArray; + complete: boolean; +} + +interface ComparatorAnalysis { + truePaths: ReadonlyArray; + complete: boolean; +} + +interface ComponentObservationAnalysis { + observations: ReadonlyArray; + complete: boolean; +} + +const createLogicalPath = (sourceComplete = true): LogicalComparatorPath => ({ + equalPropPaths: new Set(), + unequalPropPaths: new Set(), + sourceComplete, +}); + +const logicalPathIdentity = (path: LogicalComparatorPath): string => + [ + Array.from(path.equalPropPaths).toSorted().join(","), + Array.from(path.unequalPropPaths).toSorted().join(","), + String(path.sourceComplete), + ].join("|"); + +const limitLogicalPaths = (paths: ReadonlyArray): LogicalPathCollection => { + const uniquePaths = new Map(); + for (const path of paths) { + uniquePaths.set(logicalPathIdentity(path), path); + } + if (uniquePaths.size <= MAX_MEMO_COMPARATOR_PATHS) { + return { paths: [...uniquePaths.values()], complete: true }; + } + return { + paths: [createLogicalPath(false)], + complete: false, + }; +}; + +const mergeLogicalPaths = ( + leftPath: LogicalComparatorPath, + rightPath: LogicalComparatorPath, +): LogicalComparatorPath | null => { + for (const equalPropPath of leftPath.equalPropPaths) { + if (rightPath.unequalPropPaths.has(equalPropPath)) return null; + } + for (const unequalPropPath of leftPath.unequalPropPaths) { + if (rightPath.equalPropPaths.has(unequalPropPath)) return null; + } + return { + equalPropPaths: new Set([...leftPath.equalPropPaths, ...rightPath.equalPropPaths]), + unequalPropPaths: new Set([...leftPath.unequalPropPaths, ...rightPath.unequalPropPaths]), + sourceComplete: leftPath.sourceComplete && rightPath.sourceComplete, + }; +}; + +const combineLogicalPaths = ( + leftPaths: ReadonlyArray, + rightPaths: ReadonlyArray, +): LogicalPathCollection => { + const combinedPaths: LogicalComparatorPath[] = []; + for (const leftPath of leftPaths) { + for (const rightPath of rightPaths) { + const combinedPath = mergeLogicalPaths(leftPath, rightPath); + if (combinedPath) combinedPaths.push(combinedPath); + } + } + return limitLogicalPaths(combinedPaths); +}; + +const getStaticPropertyName = (name: ts.PropertyName): string | null => { + if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) { + return name.text; + } + if ( + ts.isComputedPropertyName(name) && + (ts.isStringLiteral(name.expression) || ts.isNumericLiteral(name.expression)) + ) { + return name.expression.text; + } + return null; +}; + +const appendPropPath = (prefix: string, propertyName: string): string => + prefix.length === 0 ? propertyName : `${prefix}.${propertyName}`; + +const bindComparatorPattern = ( + bindingName: ts.BindingName, + side: MemoPropSide, + prefix: string, + bindings: Map, + typeChecker: ts.TypeChecker, +): boolean => { + if (ts.isIdentifier(bindingName)) { + const symbol = typeChecker.getSymbolAtLocation(bindingName); + if (!symbol) return false; + bindings.set(resolveAliasedSymbol(symbol, typeChecker), { + side, + path: prefix, + isRest: false, + }); + return true; + } + if (!ts.isObjectBindingPattern(bindingName)) return false; + let complete = true; + for (const element of bindingName.elements) { + const propertyName = element.propertyName + ? getStaticPropertyName(element.propertyName) + : ts.isIdentifier(element.name) + ? element.name.text + : null; + if (element.dotDotDotToken) { + if (!ts.isIdentifier(element.name)) { + complete = false; + continue; + } + const symbol = typeChecker.getSymbolAtLocation(element.name); + if (!symbol) { + complete = false; + continue; + } + bindings.set(resolveAliasedSymbol(symbol, typeChecker), { + side, + path: prefix, + isRest: true, + }); + continue; + } + if (!propertyName) { + complete = false; + continue; + } + complete = + bindComparatorPattern( + element.name, + side, + appendPropPath(prefix, propertyName), + bindings, + typeChecker, + ) && complete; + } + return complete; +}; + +const getComparatorPropBinding = ( + expression: ts.Expression, + bindings: ReadonlyMap, + typeChecker: ts.TypeChecker, +): MemoPropBinding | null => { + const unwrappedExpression = unwrapTypescriptExpression(expression); + if (ts.isIdentifier(unwrappedExpression)) { + const symbol = typeChecker.getSymbolAtLocation(unwrappedExpression); + return symbol ? (bindings.get(resolveAliasedSymbol(symbol, typeChecker)) ?? null) : null; + } + if (ts.isPropertyAccessExpression(unwrappedExpression)) { + const parentBinding = getComparatorPropBinding( + unwrappedExpression.expression, + bindings, + typeChecker, + ); + return parentBinding && !parentBinding.isRest + ? { + ...parentBinding, + path: appendPropPath(parentBinding.path, unwrappedExpression.name.text), + } + : null; + } + if (ts.isElementAccessExpression(unwrappedExpression)) { + const argumentExpression = unwrappedExpression.argumentExpression; + if ( + !argumentExpression || + (!ts.isStringLiteral(argumentExpression) && !ts.isNumericLiteral(argumentExpression)) + ) { + return null; + } + const parentBinding = getComparatorPropBinding( + unwrappedExpression.expression, + bindings, + typeChecker, + ); + return parentBinding && !parentBinding.isRest + ? { + ...parentBinding, + path: appendPropPath(parentBinding.path, argumentExpression.text), + } + : null; + } + return null; +}; + +const getComparedPropPath = ( + leftExpression: ts.Expression, + rightExpression: ts.Expression, + bindings: ReadonlyMap, + typeChecker: ts.TypeChecker, +): string | null => { + const leftBinding = getComparatorPropBinding(leftExpression, bindings, typeChecker); + const rightBinding = getComparatorPropBinding(rightExpression, bindings, typeChecker); + if ( + !leftBinding || + !rightBinding || + leftBinding.isRest || + rightBinding.isRest || + leftBinding.path !== rightBinding.path || + leftBinding.side === rightBinding.side + ) { + return null; + } + return leftBinding.path; +}; + +const isDefaultLibrarySymbol = ( + symbol: ts.Symbol | undefined, + context: ReactAnalysisContext, +): boolean => + Boolean( + symbol?.declarations?.length && + symbol.declarations.every((declaration) => + context.program.isSourceFileDefaultLibrary(declaration.getSourceFile()), + ), + ); + +const getObjectIsComparedPropPath = ( + callExpression: ts.CallExpression, + bindings: ReadonlyMap, + context: ReactAnalysisContext, +): string | null => { + if ( + callExpression.arguments.length !== 2 || + !ts.isPropertyAccessExpression(callExpression.expression) || + !ts.isIdentifier(callExpression.expression.expression) || + callExpression.expression.expression.text !== "Object" || + callExpression.expression.name.text !== "is" || + !isDefaultLibrarySymbol( + context.typeChecker.getSymbolAtLocation(callExpression.expression.expression), + context, + ) + ) { + return null; + } + const leftExpression = callExpression.arguments[0]; + const rightExpression = callExpression.arguments[1]; + return leftExpression && rightExpression + ? getComparedPropPath(leftExpression, rightExpression, bindings, context.typeChecker) + : null; +}; + +const equalityBranches = ( + propPath: string, + equalityWhenTrue: boolean, +): BooleanComparatorBranches => { + const equalPath = createLogicalPath(); + equalPath.equalPropPaths.add(propPath); + const unequalPath = createLogicalPath(); + unequalPath.unequalPropPaths.add(propPath); + return equalityWhenTrue + ? { whenTrue: [equalPath], whenFalse: [unequalPath], complete: true } + : { whenTrue: [unequalPath], whenFalse: [equalPath], complete: true }; +}; + +const unknownBooleanBranches = (): BooleanComparatorBranches => ({ + whenTrue: [createLogicalPath(false)], + whenFalse: [createLogicalPath(false)], + complete: false, +}); + +const evaluateBooleanExpression = ( + expression: ts.Expression, + bindings: ReadonlyMap, + context: ReactAnalysisContext, + visitedSymbols: Set, +): BooleanComparatorBranches => { + const unwrappedExpression = unwrapTypescriptExpression(expression); + if (unwrappedExpression.kind === ts.SyntaxKind.TrueKeyword) { + return { + whenTrue: [createLogicalPath()], + whenFalse: [], + complete: true, + }; + } + if (unwrappedExpression.kind === ts.SyntaxKind.FalseKeyword) { + return { + whenTrue: [], + whenFalse: [createLogicalPath()], + complete: true, + }; + } + if ( + ts.isPrefixUnaryExpression(unwrappedExpression) && + unwrappedExpression.operator === ts.SyntaxKind.ExclamationToken + ) { + const operandBranches = evaluateBooleanExpression( + unwrappedExpression.operand, + bindings, + context, + visitedSymbols, + ); + return { + whenTrue: operandBranches.whenFalse, + whenFalse: operandBranches.whenTrue, + complete: operandBranches.complete, + }; + } + if (ts.isBinaryExpression(unwrappedExpression)) { + const operatorKind = unwrappedExpression.operatorToken.kind; + if ( + operatorKind === ts.SyntaxKind.EqualsEqualsEqualsToken || + operatorKind === ts.SyntaxKind.ExclamationEqualsEqualsToken + ) { + const propPath = getComparedPropPath( + unwrappedExpression.left, + unwrappedExpression.right, + bindings, + context.typeChecker, + ); + return propPath === null + ? unknownBooleanBranches() + : equalityBranches(propPath, operatorKind === ts.SyntaxKind.EqualsEqualsEqualsToken); + } + if ( + operatorKind === ts.SyntaxKind.AmpersandAmpersandToken || + operatorKind === ts.SyntaxKind.BarBarToken + ) { + const leftBranches = evaluateBooleanExpression( + unwrappedExpression.left, + bindings, + context, + visitedSymbols, + ); + const rightBranches = evaluateBooleanExpression( + unwrappedExpression.right, + bindings, + context, + visitedSymbols, + ); + if (operatorKind === ts.SyntaxKind.AmpersandAmpersandToken) { + const trueCombination = combineLogicalPaths(leftBranches.whenTrue, rightBranches.whenTrue); + const falseRightCombination = combineLogicalPaths( + leftBranches.whenTrue, + rightBranches.whenFalse, + ); + const falseCombination = limitLogicalPaths([ + ...leftBranches.whenFalse, + ...falseRightCombination.paths, + ]); + return { + whenTrue: trueCombination.paths, + whenFalse: falseCombination.paths, + complete: + leftBranches.complete && + rightBranches.complete && + trueCombination.complete && + falseRightCombination.complete && + falseCombination.complete, + }; + } + const trueRightCombination = combineLogicalPaths( + leftBranches.whenFalse, + rightBranches.whenTrue, + ); + const trueCombination = limitLogicalPaths([ + ...leftBranches.whenTrue, + ...trueRightCombination.paths, + ]); + const falseCombination = combineLogicalPaths(leftBranches.whenFalse, rightBranches.whenFalse); + return { + whenTrue: trueCombination.paths, + whenFalse: falseCombination.paths, + complete: + leftBranches.complete && + rightBranches.complete && + trueRightCombination.complete && + trueCombination.complete && + falseCombination.complete, + }; + } + } + if (ts.isCallExpression(unwrappedExpression)) { + const propPath = getObjectIsComparedPropPath(unwrappedExpression, bindings, context); + return propPath === null ? unknownBooleanBranches() : equalityBranches(propPath, true); + } + if (ts.isConditionalExpression(unwrappedExpression)) { + const conditionBranches = evaluateBooleanExpression( + unwrappedExpression.condition, + bindings, + context, + visitedSymbols, + ); + const trueExpressionBranches = evaluateBooleanExpression( + unwrappedExpression.whenTrue, + bindings, + context, + visitedSymbols, + ); + const falseExpressionBranches = evaluateBooleanExpression( + unwrappedExpression.whenFalse, + bindings, + context, + visitedSymbols, + ); + const trueConditionTrueResult = combineLogicalPaths( + conditionBranches.whenTrue, + trueExpressionBranches.whenTrue, + ); + const falseConditionTrueResult = combineLogicalPaths( + conditionBranches.whenFalse, + falseExpressionBranches.whenTrue, + ); + const truePaths = limitLogicalPaths([ + ...trueConditionTrueResult.paths, + ...falseConditionTrueResult.paths, + ]); + const trueConditionFalseResult = combineLogicalPaths( + conditionBranches.whenTrue, + trueExpressionBranches.whenFalse, + ); + const falseConditionFalseResult = combineLogicalPaths( + conditionBranches.whenFalse, + falseExpressionBranches.whenFalse, + ); + const falsePaths = limitLogicalPaths([ + ...trueConditionFalseResult.paths, + ...falseConditionFalseResult.paths, + ]); + return { + whenTrue: truePaths.paths, + whenFalse: falsePaths.paths, + complete: + conditionBranches.complete && + trueExpressionBranches.complete && + falseExpressionBranches.complete && + trueConditionTrueResult.complete && + falseConditionTrueResult.complete && + trueConditionFalseResult.complete && + falseConditionFalseResult.complete && + truePaths.complete && + falsePaths.complete, + }; + } + if (ts.isIdentifier(unwrappedExpression)) { + const unresolvedSymbol = context.typeChecker.getSymbolAtLocation(unwrappedExpression); + if (!unresolvedSymbol) return unknownBooleanBranches(); + const symbol = resolveAliasedSymbol(unresolvedSymbol, context.typeChecker); + if (visitedSymbols.has(symbol)) return unknownBooleanBranches(); + visitedSymbols.add(symbol); + for (const declaration of symbol.declarations ?? []) { + if ( + ts.isVariableDeclaration(declaration) && + declaration.initializer && + declaration.parent.flags & ts.NodeFlags.Const + ) { + return evaluateBooleanExpression( + declaration.initializer, + bindings, + context, + visitedSymbols, + ); + } + } + } + return unknownBooleanBranches(); +}; + +const markPathsIncomplete = ( + paths: ReadonlyArray, +): ReadonlyArray => + paths.map((path) => ({ + equalPropPaths: new Set(path.equalPropPaths), + unequalPropPaths: new Set(path.unequalPropPaths), + sourceComplete: false, + })); + +const analyzeComparatorStatement = ( + statement: ts.Statement, + activePaths: ReadonlyArray, + bindings: ReadonlyMap, + context: ReactAnalysisContext, +): ComparatorStatementResult => { + if (ts.isBlock(statement)) { + return analyzeComparatorStatements(statement.statements, activePaths, bindings, context); + } + if (ts.isReturnStatement(statement)) { + if (!statement.expression) { + return { continuingPaths: [], truePaths: [], complete: true }; + } + const returnBranches = evaluateBooleanExpression( + statement.expression, + bindings, + context, + new Set(), + ); + const trueCombination = combineLogicalPaths(activePaths, returnBranches.whenTrue); + return { + continuingPaths: [], + truePaths: trueCombination.paths, + complete: returnBranches.complete && trueCombination.complete, + }; + } + if (ts.isIfStatement(statement)) { + const conditionBranches = evaluateBooleanExpression( + statement.expression, + bindings, + context, + new Set(), + ); + const trueCombination = combineLogicalPaths(activePaths, conditionBranches.whenTrue); + const falseCombination = combineLogicalPaths(activePaths, conditionBranches.whenFalse); + const thenResult = analyzeComparatorStatement( + statement.thenStatement, + trueCombination.paths, + bindings, + context, + ); + const elseResult = statement.elseStatement + ? analyzeComparatorStatement( + statement.elseStatement, + falseCombination.paths, + bindings, + context, + ) + : { + continuingPaths: falseCombination.paths, + truePaths: [], + complete: true, + }; + const continuingPaths = limitLogicalPaths([ + ...thenResult.continuingPaths, + ...elseResult.continuingPaths, + ]); + const truePaths = limitLogicalPaths([...thenResult.truePaths, ...elseResult.truePaths]); + return { + continuingPaths: continuingPaths.paths, + truePaths: truePaths.paths, + complete: + conditionBranches.complete && + trueCombination.complete && + falseCombination.complete && + thenResult.complete && + elseResult.complete && + continuingPaths.complete && + truePaths.complete, + }; + } + if (ts.isVariableStatement(statement) || ts.isEmptyStatement(statement)) { + return { continuingPaths: activePaths, truePaths: [], complete: true }; + } + if (ts.isThrowStatement(statement)) { + return { continuingPaths: [], truePaths: [], complete: false }; + } + return { + continuingPaths: markPathsIncomplete(activePaths), + truePaths: [], + complete: false, + }; +}; + +const analyzeComparatorStatements = ( + statements: ReadonlyArray, + initialPaths: ReadonlyArray, + bindings: ReadonlyMap, + context: ReactAnalysisContext, +): ComparatorStatementResult => { + let continuingPaths = initialPaths; + const truePaths: LogicalComparatorPath[] = []; + let complete = true; + for (const statement of statements) { + if (continuingPaths.length === 0) break; + const statementResult = analyzeComparatorStatement( + statement, + continuingPaths, + bindings, + context, + ); + continuingPaths = statementResult.continuingPaths; + truePaths.push(...statementResult.truePaths); + complete = complete && statementResult.complete; + } + const limitedTruePaths = limitLogicalPaths(truePaths); + return { + continuingPaths, + truePaths: limitedTruePaths.paths, + complete: complete && limitedTruePaths.complete, + }; +}; + +const analyzeComparator = ( + comparator: ts.FunctionLikeDeclaration, + context: ReactAnalysisContext, +): ComparatorAnalysis => { + const previousParameter = comparator.parameters[0]; + const nextParameter = comparator.parameters[1]; + if (comparator.parameters.length > 2) { + return { + truePaths: [{ equalPropPaths: [], sourceComplete: false }], + complete: false, + }; + } + const bindings = new Map(); + const bindingsComplete = + Boolean(previousParameter) && + Boolean(nextParameter) && + bindComparatorPattern( + previousParameter.name, + MemoPropSide.Previous, + "", + bindings, + context.typeChecker, + ) && + bindComparatorPattern(nextParameter.name, MemoPropSide.Next, "", bindings, context.typeChecker); + if (!comparator.body) { + return { + truePaths: [{ equalPropPaths: [], sourceComplete: false }], + complete: false, + }; + } + const statementResult = ts.isBlock(comparator.body) + ? analyzeComparatorStatements( + comparator.body.statements, + [createLogicalPath()], + bindings, + context, + ) + : (() => { + const expressionBranches = evaluateBooleanExpression( + comparator.body, + bindings, + context, + new Set(), + ); + return { + continuingPaths: [], + truePaths: expressionBranches.whenTrue, + complete: expressionBranches.complete, + }; + })(); + return { + truePaths: statementResult.truePaths.map((path) => ({ + equalPropPaths: Array.from(path.equalPropPaths).toSorted(), + sourceComplete: path.sourceComplete, + })), + complete: + statementResult.complete && + statementResult.truePaths.every((path) => path.sourceComplete) && + (bindingsComplete || statementResult.truePaths.length === 0), + }; +}; + +const bindComponentPattern = ( + bindingName: ts.BindingName, + prefix: string, + isRest: boolean, + bindings: Map, + typeChecker: ts.TypeChecker, +): boolean => { + if (ts.isIdentifier(bindingName)) { + const symbol = typeChecker.getSymbolAtLocation(bindingName); + if (!symbol) return false; + bindings.set(resolveAliasedSymbol(symbol, typeChecker), { + path: prefix, + isRest, + }); + return true; + } + if (!ts.isObjectBindingPattern(bindingName)) return false; + let complete = true; + for (const element of bindingName.elements) { + if (element.dotDotDotToken) { + complete = + bindComponentPattern(element.name, prefix, true, bindings, typeChecker) && complete; + continue; + } + const propertyName = element.propertyName + ? getStaticPropertyName(element.propertyName) + : ts.isIdentifier(element.name) + ? element.name.text + : null; + if (!propertyName) { + complete = false; + continue; + } + complete = + bindComponentPattern( + element.name, + appendPropPath(prefix, propertyName), + false, + bindings, + typeChecker, + ) && complete; + } + return complete; +}; + +const getComponentPropBinding = ( + expression: ts.Expression, + bindings: ReadonlyMap, + typeChecker: ts.TypeChecker, +): ComponentPropBinding | null => { + const unwrappedExpression = unwrapTypescriptExpression(expression); + if (ts.isIdentifier(unwrappedExpression)) { + const symbol = typeChecker.getSymbolAtLocation(unwrappedExpression); + return symbol ? (bindings.get(resolveAliasedSymbol(symbol, typeChecker)) ?? null) : null; + } + if (ts.isPropertyAccessExpression(unwrappedExpression)) { + const parentBinding = getComponentPropBinding( + unwrappedExpression.expression, + bindings, + typeChecker, + ); + return parentBinding && !parentBinding.isRest + ? { + path: appendPropPath(parentBinding.path, unwrappedExpression.name.text), + isRest: false, + } + : null; + } + if (ts.isElementAccessExpression(unwrappedExpression)) { + const argumentExpression = unwrappedExpression.argumentExpression; + if ( + !argumentExpression || + (!ts.isStringLiteral(argumentExpression) && !ts.isNumericLiteral(argumentExpression)) + ) { + return null; + } + const parentBinding = getComponentPropBinding( + unwrappedExpression.expression, + bindings, + typeChecker, + ); + return parentBinding && !parentBinding.isRest + ? { + path: appendPropPath(parentBinding.path, argumentExpression.text), + isRest: false, + } + : null; + } + return null; +}; + +const canTypeVary = (type: ts.Type): boolean => { + if (type.flags & ts.TypeFlags.Never) return false; + if (type.isUnion()) { + const possibleTypes = type.types.filter( + (possibleType) => !(possibleType.flags & ts.TypeFlags.Never), + ); + if (possibleTypes.length > 1) return true; + const onlyPossibleType = possibleTypes[0]; + return onlyPossibleType ? canTypeVary(onlyPossibleType) : false; + } + return ( + (type.flags & + (ts.TypeFlags.StringLiteral | + ts.TypeFlags.NumberLiteral | + ts.TypeFlags.BigIntLiteral | + ts.TypeFlags.BooleanLiteral | + ts.TypeFlags.Null | + ts.TypeFlags.Undefined | + ts.TypeFlags.Void | + ts.TypeFlags.UniqueESSymbol)) === + 0 + ); +}; + +const isOutermostPropertyExpression = ( + node: ts.PropertyAccessExpression | ts.ElementAccessExpression, +): boolean => { + const parentNode = node.parent; + return !( + (ts.isPropertyAccessExpression(parentNode) || ts.isElementAccessExpression(parentNode)) && + parentNode.expression === node + ); +}; + +const collectComponentPropObservations = ( + component: ts.FunctionLikeDeclaration, + context: ReactAnalysisContext, +): ComponentObservationAnalysis => { + const propsParameter = component.parameters[0]; + if (!propsParameter || !component.body) { + return { observations: [], complete: Boolean(component.body) }; + } + const bindings = new Map(); + let complete = bindComponentPattern( + propsParameter.name, + "", + false, + bindings, + context.typeChecker, + ); + const observationsByPath = new Map(); + const addObservation = (node: ts.Expression, path: string): void => { + const existingObservation = observationsByPath.get(path); + const observation: ReactSemanticMemoPropObservation = { + path, + location: getNodeLocation(node, context.rootDirectory), + valueCanVary: canTypeVary(context.typeChecker.getTypeAtLocation(node)), + }; + if (!existingObservation || (!existingObservation.valueCanVary && observation.valueCanVary)) { + observationsByPath.set(path, observation); + } + }; + const visit = (node: ts.Node): void => { + if (ts.isVariableDeclaration(node) && node.initializer && !ts.isIdentifier(node.name)) { + const initializerBinding = getComponentPropBinding( + node.initializer, + bindings, + context.typeChecker, + ); + if (initializerBinding && !initializerBinding.isRest) { + complete = + bindComponentPattern( + node.name, + initializerBinding.path, + false, + bindings, + context.typeChecker, + ) && complete; + } + } + if ( + (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) && + isOutermostPropertyExpression(node) + ) { + let binding = getComponentPropBinding(node, bindings, context.typeChecker); + const isMethodCall = ts.isCallExpression(node.parent) && node.parent.expression === node; + if (isMethodCall) { + const receiverBinding = getComponentPropBinding( + node.expression, + bindings, + context.typeChecker, + ); + if (receiverBinding?.path) { + binding = receiverBinding; + } + } + if (binding) { + if (binding.isRest) { + complete = false; + } else { + addObservation(node, binding.path); + } + } else if ( + ts.isElementAccessExpression(node) && + getComponentPropBinding(node.expression, bindings, context.typeChecker) + ) { + complete = false; + } + } + if (ts.isIdentifier(node) && isIdentifierReference(node)) { + const symbol = context.typeChecker.getSymbolAtLocation(node); + const binding = symbol + ? bindings.get(resolveAliasedSymbol(symbol, context.typeChecker)) + : null; + const parentNode = node.parent; + const isPropertyRoot = + (ts.isPropertyAccessExpression(parentNode) || ts.isElementAccessExpression(parentNode)) && + parentNode.expression === node; + if (binding && !isPropertyRoot) { + if (binding.isRest || binding.path.length === 0) { + complete = false; + addObservation(node, "*"); + } else { + addObservation(node, binding.path); + } + } + } + node.forEachChild(visit); + }; + component.body.forEachChild(visit); + return { + observations: [...observationsByPath.values()].sort((left, right) => + left.path.localeCompare(right.path), + ), + complete, + }; +}; + +const deriveComparatorStatus = ( + ownerId: string | null, + kind: ReactMemoComparatorKind, + observationAnalysis: ComponentObservationAnalysis, + comparatorAnalysis: ComparatorAnalysis, +): ReactMemoComparatorStatus => { + if (!ownerId) return ReactMemoComparatorStatus.Unknown; + if (kind === ReactMemoComparatorKind.DefaultShallow) { + return ReactMemoComparatorStatus.Equivalent; + } + for (const truePath of comparatorAnalysis.truePaths) { + if (!truePath.sourceComplete) continue; + if ( + observationAnalysis.observations.some( + (observation) => + observation.valueCanVary && + !isMemoObservationCovered(observation.path, truePath.equalPropPaths), + ) + ) { + return ReactMemoComparatorStatus.OmittedObservedProp; + } + } + const hasUniversalTruePaths = + comparatorAnalysis.truePaths.length > 0 && + comparatorAnalysis.truePaths.every( + (truePath) => truePath.sourceComplete && truePath.equalPropPaths.includes(""), + ); + return comparatorAnalysis.complete && (observationAnalysis.complete || hasUniversalTruePaths) + ? ReactMemoComparatorStatus.Equivalent + : ReactMemoComparatorStatus.Unknown; +}; + +const resolveMemoComponent = ( + expression: ts.Expression, + context: ReactAnalysisContext, +): ts.FunctionLikeDeclaration | null => { + const unwrappedExpression = unwrapTypescriptExpression(expression); + if ( + ts.isCallExpression(unwrappedExpression) && + getCanonicalReactApiName(unwrappedExpression.expression, context.typeChecker) === "forwardRef" + ) { + const renderExpression = unwrappedExpression.arguments[0]; + return renderExpression ? resolveFunction(renderExpression, context.typeChecker) : null; + } + return resolveFunction(unwrappedExpression, context.typeChecker); +}; + +export const collectMemoEquivalence = ( + descriptors: ReadonlyArray, + units: ReadonlyArray, + sourceFiles: ReadonlyArray, + context: ReactAnalysisContext, +): ReadonlyArray => { + const unitIdsByFunction = new Map(); + descriptors.forEach((descriptor, descriptorIndex) => { + const unitId = units[descriptorIndex]?.id; + if (descriptor.functionNode && unitId) { + unitIdsByFunction.set(descriptor.functionNode, unitId); + } + }); + const comparators: ReactSemanticMemoComparator[] = []; + for (const sourceFile of sourceFiles) { + const visit = (node: ts.Node): void => { + if ( + ts.isCallExpression(node) && + getCanonicalReactApiName(node.expression, context.typeChecker) === "memo" + ) { + const componentExpression = node.arguments[0]; + const component = componentExpression + ? resolveMemoComponent(componentExpression, context) + : null; + const ownerId = component ? (unitIdsByFunction.get(component) ?? null) : null; + const observationAnalysis = component + ? collectComponentPropObservations(component, context) + : { observations: [], complete: false }; + const comparatorExpression = node.arguments[1]; + const kind = comparatorExpression + ? ReactMemoComparatorKind.Custom + : ReactMemoComparatorKind.DefaultShallow; + const resolvedComparator = comparatorExpression + ? resolveFunction(comparatorExpression, context.typeChecker) + : null; + const comparatorAnalysis: ComparatorAnalysis = + kind === ReactMemoComparatorKind.DefaultShallow + ? { + truePaths: [{ equalPropPaths: [""], sourceComplete: true }], + complete: true, + } + : resolvedComparator + ? analyzeComparator(resolvedComparator, context) + : { + truePaths: [{ equalPropPaths: [], sourceComplete: false }], + complete: false, + }; + const status = deriveComparatorStatus( + ownerId, + kind, + observationAnalysis, + comparatorAnalysis, + ); + const hasUniversalTruePaths = + comparatorAnalysis.truePaths.length > 0 && + comparatorAnalysis.truePaths.every( + (truePath) => truePath.sourceComplete && truePath.equalPropPaths.includes(""), + ); + const sourceComplete = + ownerId !== null && + comparatorAnalysis.complete && + (kind === ReactMemoComparatorKind.DefaultShallow || + observationAnalysis.complete || + hasUniversalTruePaths); + comparators.push({ + id: createSemanticId("memo-comparator", ownerId ?? "unresolved", node, context), + ownerId, + kind, + location: getNodeLocation(node, context.rootDirectory), + comparatorLocation: comparatorExpression + ? getNodeLocation(comparatorExpression, context.rootDirectory) + : null, + observations: observationAnalysis.observations, + truePaths: comparatorAnalysis.truePaths, + observationComplete: observationAnalysis.complete, + analysisComplete: comparatorAnalysis.complete, + status, + sourceComplete, + complete: sourceComplete && status === ReactMemoComparatorStatus.Equivalent, + }); + } + node.forEachChild(visit); + }; + sourceFile.forEachChild(visit); + } + return comparators; +}; diff --git a/packages/prover/src/collect-optimistic-state.ts b/packages/prover/src/collect-optimistic-state.ts new file mode 100644 index 000000000..5724acd2f --- /dev/null +++ b/packages/prover/src/collect-optimistic-state.ts @@ -0,0 +1,163 @@ +import ts from "typescript"; +import { analyzeUpdaterFunction } from "./analyze-updater-function.js"; +import { collectHookBindings } from "./collect-hook-bindings.js"; +import { isIdentifierReference } from "./is-identifier-reference.js"; +import { isNodeWithin } from "./is-node-within.js"; +import { + ReactHookStateUpdaterStatus, + ReactObligationStatus, + ReactOptimisticReducerStatus, +} from "./types.js"; +import { getResolvedSymbol } from "./utils/get-resolved-symbol.js"; +import { analyzeStateUpdateExpression } from "./utils/analyze-state-update-expression.js"; +import { isReactHookDependencyReference } from "./utils/is-react-hook-dependency-reference.js"; +import { unwrapTypescriptExpression } from "./unwrap-typescript-expression.js"; +import type { BoundOptimisticHookBinding, OptimisticHookBinding } from "./collect-hook-bindings.js"; +import type { ReactAnalysisContext } from "./types.js"; +import type { StateUpdateExpressionAnalysis } from "./utils/analyze-state-update-expression.js"; + +export interface OptimisticStateDescriptor { + binding: OptimisticHookBinding; + reducerFunction: ts.FunctionLikeDeclaration | null; + reducerStatus: ReactOptimisticReducerStatus; +} + +export interface OptimisticUpdateDescriptor { + binding: BoundOptimisticHookBinding; + callExpression: ts.CallExpression | null; + evidenceNode: ts.Node; + updaterFunction: ts.FunctionLikeDeclaration | null; + updaterStatus: ReactHookStateUpdaterStatus; +} + +export interface OptimisticStateCollection { + states: ReadonlyArray; + updates: ReadonlyArray; +} + +const analyzeReducer = ( + binding: OptimisticHookBinding, + context: ReactAnalysisContext, +): { + reducerFunction: ts.FunctionLikeDeclaration | null; + reducerStatus: ReactOptimisticReducerStatus; +} => { + if (!binding.reducerExpression) { + return { + reducerFunction: null, + reducerStatus: ReactOptimisticReducerStatus.Absent, + }; + } + const reducerAnalysis = analyzeUpdaterFunction(binding.reducerExpression, context); + if (!reducerAnalysis.updaterFunction) { + return { + reducerFunction: null, + reducerStatus: ReactOptimisticReducerStatus.Unknown, + }; + } + let reducerStatus = ReactOptimisticReducerStatus.Unknown; + if (reducerAnalysis.status === ReactObligationStatus.Proved) { + reducerStatus = ReactOptimisticReducerStatus.Pure; + } else if (reducerAnalysis.status === ReactObligationStatus.Violated) { + reducerStatus = ReactOptimisticReducerStatus.Impure; + } + return { + reducerFunction: reducerAnalysis.updaterFunction, + reducerStatus, + }; +}; + +export const collectOptimisticState = ( + functionNode: ts.FunctionLikeDeclaration, + context: ReactAnalysisContext, +): OptimisticStateCollection => { + const bindings = collectHookBindings(functionNode, context.typeChecker).optimisticBindings; + const boundBindings = bindings.filter((binding): binding is BoundOptimisticHookBinding => + Boolean(binding.setterSymbol), + ); + const bindingsBySetter = new Map( + boundBindings.map((binding): [ts.Symbol, BoundOptimisticHookBinding] => [ + binding.setterSymbol, + binding, + ]), + ); + const states = bindings.map( + (binding): OptimisticStateDescriptor => ({ + binding, + ...analyzeReducer(binding, context), + }), + ); + const handledSetterReferences = new Set(); + const updates: OptimisticUpdateDescriptor[] = []; + const visitCalls = (node: ts.Node): void => { + if (ts.isCallExpression(node)) { + const setterSymbol = getResolvedSymbol( + unwrapTypescriptExpression(node.expression), + context.typeChecker, + ); + const binding = setterSymbol ? bindingsBySetter.get(setterSymbol) : undefined; + if (binding) { + const updateExpression = node.arguments[0]; + let updaterAnalysis: StateUpdateExpressionAnalysis = { + updaterFunction: null, + updaterStatus: ReactHookStateUpdaterStatus.Unknown, + }; + if (updateExpression && binding.reducerExpression) { + updaterAnalysis = { + updaterFunction: null, + updaterStatus: ReactHookStateUpdaterStatus.DirectValue, + }; + } else if (updateExpression) { + updaterAnalysis = analyzeStateUpdateExpression(updateExpression, context); + } + updates.push({ + binding, + callExpression: node, + evidenceNode: node, + ...updaterAnalysis, + }); + const collectHandledReferences = (calleeNode: ts.Node): void => { + if ( + ts.isIdentifier(calleeNode) && + getResolvedSymbol(calleeNode, context.typeChecker) === setterSymbol + ) { + handledSetterReferences.add(calleeNode); + } + calleeNode.forEachChild(collectHandledReferences); + }; + collectHandledReferences(node.expression); + } + } + node.forEachChild(visitCalls); + }; + functionNode.forEachChild(visitCalls); + + const visitEscapes = (node: ts.Node): void => { + if ( + ts.isIdentifier(node) && + isIdentifierReference(node) && + !handledSetterReferences.has(node) + ) { + const setterSymbol = getResolvedSymbol(node, context.typeChecker); + const binding = setterSymbol ? bindingsBySetter.get(setterSymbol) : undefined; + if ( + binding && + !isReactHookDependencyReference(node, context.typeChecker) && + !updates.some( + (update) => update.callExpression && isNodeWithin(node, update.callExpression.expression), + ) + ) { + updates.push({ + binding, + callExpression: null, + evidenceNode: node, + updaterFunction: null, + updaterStatus: ReactHookStateUpdaterStatus.SetterEscape, + }); + } + } + node.forEachChild(visitEscapes); + }; + functionNode.forEachChild(visitEscapes); + return { states, updates }; +}; diff --git a/packages/prover/src/collect-project-soundness-evidence.ts b/packages/prover/src/collect-project-soundness-evidence.ts new file mode 100644 index 000000000..d68583ebb --- /dev/null +++ b/packages/prover/src/collect-project-soundness-evidence.ts @@ -0,0 +1,88 @@ +import * as path from "node:path"; +import ts from "typescript"; +import { FIRST_SOURCE_COLUMN, FIRST_SOURCE_LINE } from "./constants.js"; +import { createEvidence } from "./create-evidence.js"; +import type { ReactProofEvidence } from "./types.js"; + +const TYPESCRIPT_SUPPRESSION_PATTERN = /@ts-(?:check|expect-error|ignore|nocheck)/; + +export const collectProjectSoundnessEvidence = ( + program: ts.Program, + sourceFiles: ReadonlyArray, + rootDirectory: string, +): ReadonlyArray => { + const evidence: ReactProofEvidence[] = []; + const compilerOptions = program.getCompilerOptions(); + if (compilerOptions.strict !== true) { + evidence.push({ + description: "The React proof requires TypeScript strict mode", + location: { + filePath: "tsconfig.json", + line: FIRST_SOURCE_LINE, + column: FIRST_SOURCE_COLUMN, + }, + trace: ["TypeScript configuration", "strict mode disabled", "unsound proof boundary"], + }); + } + for (const sourceFile of sourceFiles) { + const fileExtension = path.extname(sourceFile.fileName); + if (fileExtension === ".js" || fileExtension === ".jsx") { + evidence.push( + createEvidence( + sourceFile, + rootDirectory, + "JavaScript source does not provide the type evidence required for a complete proof", + ["project source", fileExtension, "untyped proof region"], + ), + ); + } + if (TYPESCRIPT_SUPPRESSION_PATTERN.test(sourceFile.getFullText())) { + evidence.push( + createEvidence( + sourceFile, + rootDirectory, + "A TypeScript suppression comment invalidates the proof boundary", + ["project source", "TypeScript suppression", "unchecked program region"], + ), + ); + } + const visit = (node: ts.Node): void => { + if (node.kind === ts.SyntaxKind.AnyKeyword) { + evidence.push( + createEvidence( + node, + rootDirectory, + "The any type erases evidence required by the React proof", + ["TypeScript type", "any", "unknown runtime behavior"], + ), + ); + } + if ( + (ts.isAsExpression(node) && node.type.getText() !== "const") || + ts.isTypeAssertionExpression(node) + ) { + evidence.push( + createEvidence( + node, + rootDirectory, + "An unchecked type assertion can forge a React proof fact", + ["TypeScript expression", "type assertion", "unverified runtime value"], + ), + ); + } + if (ts.isNonNullExpression(node)) { + evidence.push( + createEvidence( + node, + rootDirectory, + "A non-null assertion removes a required runtime possibility", + ["TypeScript expression", "non-null assertion", "unverified runtime value"], + ), + ); + } + node.forEachChild(visit); + }; + sourceFile.forEachChild(visit); + } + return evidence; +}; diff --git a/packages/prover/src/collect-reachable-functions.ts b/packages/prover/src/collect-reachable-functions.ts new file mode 100644 index 000000000..a4d9f8a7a --- /dev/null +++ b/packages/prover/src/collect-reachable-functions.ts @@ -0,0 +1,441 @@ +import ts from "typescript"; +import { collectCallableTargetFunctions } from "./collect-callable-target-functions.js"; +import { getCallableRefProtocolForInitializer } from "./collect-callable-ref-protocols.js"; +import { SYNCHRONOUS_CALLBACK_METHOD_NAMES } from "./constants.js"; +import { getCanonicalReactApiName } from "./get-canonical-react-api-name.js"; +import { isIdentifierReference } from "./is-identifier-reference.js"; +import { isFunctionBoundary } from "./is-function-boundary.js"; +import { getRootIdentifier } from "./get-root-identifier.js"; +import { + getCallableBindingsFingerprint, + markCallableBindingsConditional, + mergeCallableBindings, + resolveCallableArgumentBindings, + resolveCallableExpression, +} from "./resolve-callable-expression.js"; +import { resolveFunction } from "./resolve-function.js"; +import { ReactSemanticFunctionCallKind } from "./types.js"; +import { hasConditionalAncestor } from "./utils/has-conditional-ancestor.js"; +import type { ResolvedCallableValueDescriptor } from "./resolve-callable-expression.js"; + +export interface ReachableFunctionDescriptor { + functionNode: ts.FunctionLikeDeclaration; + isConditionallyReached: boolean; +} + +export interface ReachableFunctionCallDescriptor { + callExpression: ts.CallExpression; + sourceFunctionNode: ts.FunctionLikeDeclaration; + targetFunctionNode: ts.FunctionLikeDeclaration; + kind: ReactSemanticFunctionCallKind; + sourceParameterIndex: number | null; + callArgumentIndex: number | null; + sourcePropertyPath: ReadonlyArray; + isConditionallyReached: boolean; +} + +export interface ReachableFunctionGraphDescriptor { + functions: ReadonlyArray; + calls: ReadonlyArray; + unmodeledCallableUses: ReadonlyArray; +} + +export interface UnmodeledCallableUseDescriptor { + functionNode: ts.FunctionLikeDeclaration; + node: ts.Node; + parameterIndex: number | null; +} + +const getParameterSymbol = ( + functionNode: ts.FunctionLikeDeclaration, + parameterIndex: number, + typeChecker: ts.TypeChecker, +): ts.Symbol | null => { + const parameter = functionNode.parameters[parameterIndex]; + return parameter && ts.isIdentifier(parameter.name) + ? (typeChecker.getSymbolAtLocation(parameter.name) ?? null) + : null; +}; + +const getNodeIdentity = (node: ts.Node): string => + `${node.getSourceFile().fileName}:${node.getStart()}:${node.getEnd()}`; + +const getPropertyPath = (expression: ts.Expression): ReadonlyArray => { + if (!ts.isPropertyAccessExpression(expression)) return []; + return [...getPropertyPath(expression.expression), expression.name.text]; +}; + +const getParameterIndex = ( + functionNode: ts.FunctionLikeDeclaration, + symbol: ts.Symbol | undefined, + typeChecker: ts.TypeChecker, +): number => { + if (!symbol) return -1; + return functionNode.parameters.findIndex( + (_, parameterIndex) => getParameterSymbol(functionNode, parameterIndex, typeChecker) === symbol, + ); +}; + +const getBoundCallKind = ( + sourceParameterIndex: number, + sourcePropertyPath: ReadonlyArray, +): ReactSemanticFunctionCallKind => { + if (sourcePropertyPath.length > 0) return ReactSemanticFunctionCallKind.Property; + if (sourceParameterIndex >= 0) return ReactSemanticFunctionCallKind.Parameter; + return ReactSemanticFunctionCallKind.Captured; +}; + +const isModeledObjectArgument = ( + identifier: ts.Identifier, + typeChecker: ts.TypeChecker, +): boolean => { + let currentNode: ts.Node = identifier; + while ( + ts.isShorthandPropertyAssignment(currentNode.parent) || + ts.isPropertyAssignment(currentNode.parent) || + ts.isObjectLiteralExpression(currentNode.parent) + ) { + currentNode = currentNode.parent; + } + if (!ts.isObjectLiteralExpression(currentNode)) return false; + const parentCall = ts.isCallExpression(currentNode.parent) ? currentNode.parent : null; + if (!parentCall || !parentCall.arguments.includes(currentNode)) return false; + const argumentIndex = parentCall.arguments.indexOf(currentNode); + const directTarget = resolveFunction(parentCall.expression, typeChecker); + return Boolean(directTarget && getParameterSymbol(directTarget, argumentIndex, typeChecker)); +}; + +const isReactDependencyArrayElement = ( + identifier: ts.Identifier, + typeChecker: ts.TypeChecker, +): boolean => { + let currentNode: ts.Node = identifier; + while (currentNode.parent && !ts.isArrayLiteralExpression(currentNode.parent)) { + if (isFunctionBoundary(currentNode.parent)) return false; + currentNode = currentNode.parent; + } + const dependencyArray = ts.isArrayLiteralExpression(currentNode.parent) + ? currentNode.parent + : null; + const hookCall = + dependencyArray && ts.isCallExpression(dependencyArray.parent) ? dependencyArray.parent : null; + return Boolean( + hookCall && + hookCall.arguments[1] === dependencyArray && + getCanonicalReactApiName(hookCall.expression, typeChecker), + ); +}; + +const isModeledCallableRefInitializer = ( + identifier: ts.Identifier, + typeChecker: ts.TypeChecker, +): boolean => { + const callExpression = ts.isCallExpression(identifier.parent) ? identifier.parent : null; + if (!callExpression || callExpression.arguments[0] !== identifier) return false; + return Boolean( + getCallableRefProtocolForInitializer(callExpression, typeChecker)?.isSourceComplete, + ); +}; + +const isSafeCallablePresenceCheck = (identifier: ts.Identifier): boolean => { + const parentNode = identifier.parent; + if ( + (ts.isPrefixUnaryExpression(parentNode) && + parentNode.operator === ts.SyntaxKind.ExclamationToken) || + (ts.isTypeOfExpression(parentNode) && parentNode.expression === identifier) || + (ts.isIfStatement(parentNode) && parentNode.expression === identifier) || + (ts.isConditionalExpression(parentNode) && parentNode.condition === identifier) + ) { + return true; + } + if (!ts.isBinaryExpression(parentNode)) return false; + return ( + parentNode.operatorToken.kind === ts.SyntaxKind.EqualsEqualsToken || + parentNode.operatorToken.kind === ts.SyntaxKind.EqualsEqualsEqualsToken || + parentNode.operatorToken.kind === ts.SyntaxKind.ExclamationEqualsToken || + parentNode.operatorToken.kind === ts.SyntaxKind.ExclamationEqualsEqualsToken || + parentNode.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken || + parentNode.operatorToken.kind === ts.SyntaxKind.BarBarToken || + parentNode.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken + ); +}; + +const getEnclosingCallableTransfer = ( + identifier: ts.Identifier, + ownerFunction: ts.FunctionLikeDeclaration, +): ts.Expression | null => { + let currentNode: ts.Node = identifier; + while (currentNode !== ownerFunction) { + const ownerBody = ownerFunction.body; + if (ownerBody === currentNode && !ts.isBlock(ownerBody)) { + return ownerBody; + } + const parentNode = currentNode.parent; + if (!parentNode || isFunctionBoundary(parentNode)) return null; + if (ts.isReturnStatement(parentNode) && parentNode.expression) return parentNode.expression; + if (ts.isVariableDeclaration(parentNode) && parentNode.initializer) { + return parentNode.initializer; + } + currentNode = parentNode; + } + return null; +}; + +export const collectReachableFunctionGraph = ( + rootFunction: ts.FunctionLikeDeclaration, + typeChecker: ts.TypeChecker, + initialBindings: ReadonlyMap = new Map(), +): ReachableFunctionGraphDescriptor => { + const conditionalReachability = new Map([ + [rootFunction, false], + ]); + const callableBindingsByFunction = new Map< + ts.FunctionLikeDeclaration, + ReadonlyMap + >([[rootFunction, initialBindings]]); + const callDescriptors = new Map(); + const unmodeledCallableUses = new Map(); + const pendingFunctions = [rootFunction]; + const pendingFunctionSet = new Set([rootFunction]); + const scheduleFunction = (functionNode: ts.FunctionLikeDeclaration): void => { + if (pendingFunctionSet.has(functionNode)) return; + pendingFunctionSet.add(functionNode); + pendingFunctions.push(functionNode); + }; + while (pendingFunctions.length > 0) { + const currentFunction = pendingFunctions.shift(); + if (!currentFunction) continue; + pendingFunctionSet.delete(currentFunction); + const currentIsConditional = conditionalReachability.get(currentFunction) ?? true; + const currentCallableBindings = + callableBindingsByFunction.get(currentFunction) ?? + new Map(); + const boundTargetFunctions = collectCallableTargetFunctions(currentCallableBindings); + const mergeFunctionBindings = ( + targetFunction: ts.FunctionLikeDeclaration, + incomingBindings: ReadonlyMap, + ): void => { + const previousBindings = callableBindingsByFunction.get(targetFunction) ?? new Map(); + const mergedBindings = mergeCallableBindings([previousBindings, incomingBindings]); + if ( + getCallableBindingsFingerprint(previousBindings) === + getCallableBindingsFingerprint(mergedBindings) + ) { + return; + } + callableBindingsByFunction.set(targetFunction, mergedBindings); + scheduleFunction(targetFunction); + }; + const enqueueFunction = ( + targetFunction: ts.FunctionLikeDeclaration, + targetBindings: ReadonlyMap, + isConditionallyReached: boolean, + callExpression: ts.CallExpression, + kind: ReactSemanticFunctionCallKind, + sourceParameterIndex: number | null, + callArgumentIndex: number | null, + sourcePropertyPath: ReadonlyArray, + ): void => { + const callIdentity = [ + getNodeIdentity(currentFunction), + getNodeIdentity(targetFunction), + getNodeIdentity(callExpression), + kind, + sourceParameterIndex ?? "none", + callArgumentIndex ?? "none", + sourcePropertyPath.join("."), + ].join(":"); + const previousCall = callDescriptors.get(callIdentity); + if (!previousCall || (previousCall.isConditionallyReached && !isConditionallyReached)) { + callDescriptors.set(callIdentity, { + callExpression, + sourceFunctionNode: currentFunction, + targetFunctionNode: targetFunction, + kind, + sourceParameterIndex, + callArgumentIndex, + sourcePropertyPath, + isConditionallyReached, + }); + } + mergeFunctionBindings(targetFunction, targetBindings); + if (targetFunction === currentFunction || targetFunction === rootFunction) return; + const previousReachability = conditionalReachability.get(targetFunction); + if (previousReachability === undefined || (previousReachability && !isConditionallyReached)) { + conditionalReachability.set(targetFunction, isConditionallyReached); + scheduleFunction(targetFunction); + } + }; + const visit = (node: ts.Node): void => { + if (node !== currentFunction && isFunctionBoundary(node)) return; + if (ts.isIdentifier(node) && isIdentifierReference(node)) { + const identifierSymbol = typeChecker.getSymbolAtLocation(node); + const directlyBoundValue = identifierSymbol + ? currentCallableBindings.get(identifierSymbol) + : null; + const resolvedValue = + directlyBoundValue ?? + (boundTargetFunctions.size > 0 + ? resolveCallableExpression(node, typeChecker, currentCallableBindings) + : null); + const containsBoundTarget = Boolean( + resolvedValue?.targets.some((target) => boundTargetFunctions.has(target.functionNode)), + ); + if ( + resolvedValue && + resolvedValue.targets.length > 0 && + (directlyBoundValue || containsBoundTarget) + ) { + const parameterIndex = getParameterIndex(currentFunction, identifierSymbol, typeChecker); + const parentCall = ts.isCallExpression(node.parent) ? node.parent : null; + const isDirectInvocation = Boolean(parentCall && parentCall.expression === node); + let isForwardedArgument = false; + if (parentCall && parentCall.arguments.includes(node)) { + const argumentIndex = parentCall.arguments.indexOf(node); + const directForwardTarget = resolveFunction(parentCall.expression, typeChecker); + isForwardedArgument = Boolean( + (directForwardTarget && + getParameterSymbol(directForwardTarget, argumentIndex, typeChecker)) || + (ts.isPropertyAccessExpression(parentCall.expression) && + SYNCHRONOUS_CALLBACK_METHOD_NAMES.has(parentCall.expression.name.text)), + ); + } + const transferExpression = getEnclosingCallableTransfer(node, currentFunction); + const isModeledTransfer = + Boolean( + transferExpression && + resolveCallableExpression(transferExpression, typeChecker, currentCallableBindings) + .isComplete, + ) || + isModeledObjectArgument(node, typeChecker) || + isReactDependencyArrayElement(node, typeChecker) || + isModeledCallableRefInitializer(node, typeChecker); + if ( + !isDirectInvocation && + !isForwardedArgument && + !isModeledTransfer && + !isSafeCallablePresenceCheck(node) + ) { + unmodeledCallableUses.set(`${getNodeIdentity(node)}:${parameterIndex}`, { + functionNode: currentFunction, + node, + parameterIndex: parameterIndex >= 0 ? parameterIndex : null, + }); + } + } + } + if (ts.isCallExpression(node)) { + const callIsConditional = + currentIsConditional || hasConditionalAncestor(node, currentFunction); + const directTarget = resolveFunction(node.expression, typeChecker); + if (directTarget) { + const argumentBindings = resolveCallableArgumentBindings( + directTarget, + node, + typeChecker, + currentCallableBindings, + ); + if (!argumentBindings.isComplete) { + unmodeledCallableUses.set(`${getNodeIdentity(node)}:arguments`, { + functionNode: currentFunction, + node, + parameterIndex: null, + }); + } + const targetBindings = mergeCallableBindings([ + currentCallableBindings, + callIsConditional + ? markCallableBindingsConditional(argumentBindings.bindings) + : argumentBindings.bindings, + ]); + enqueueFunction( + directTarget, + targetBindings, + callIsConditional, + node, + ReactSemanticFunctionCallKind.Direct, + null, + null, + [], + ); + } else { + const callableValue = resolveCallableExpression( + node.expression, + typeChecker, + currentCallableBindings, + ); + const rootIdentifier = getRootIdentifier(node.expression); + const rootSymbol = rootIdentifier + ? typeChecker.getSymbolAtLocation(rootIdentifier) + : undefined; + const parameterIndex = getParameterIndex(currentFunction, rootSymbol, typeChecker); + const sourcePropertyPath = getPropertyPath(node.expression); + const callKind = getBoundCallKind(parameterIndex, sourcePropertyPath); + for (const target of callableValue.targets) { + enqueueFunction( + target.functionNode, + target.bindings, + callIsConditional || target.isConditionallyReached, + node, + callKind, + parameterIndex >= 0 ? parameterIndex : null, + null, + sourcePropertyPath, + ); + } + if ( + callableValue.targets.length === 0 && + rootSymbol && + currentCallableBindings.has(rootSymbol) + ) { + unmodeledCallableUses.set(`${getNodeIdentity(node)}:${parameterIndex}`, { + functionNode: currentFunction, + node, + parameterIndex: parameterIndex >= 0 ? parameterIndex : null, + }); + } + } + if ( + ts.isPropertyAccessExpression(node.expression) && + SYNCHRONOUS_CALLBACK_METHOD_NAMES.has(node.expression.name.text) + ) { + for (const [argumentIndex, argument] of node.arguments.entries()) { + const callableValue = resolveCallableExpression( + argument, + typeChecker, + currentCallableBindings, + ); + for (const callableTarget of callableValue.targets) { + enqueueFunction( + callableTarget.functionNode, + callableTarget.bindings, + true, + node, + ReactSemanticFunctionCallKind.SynchronousCallback, + null, + argumentIndex, + [], + ); + } + } + } + } + node.forEachChild(visit); + }; + currentFunction.forEachChild(visit); + } + return { + functions: [...conditionalReachability].map(([functionNode, isConditionallyReached]) => ({ + functionNode, + isConditionallyReached, + })), + calls: [...callDescriptors.values()], + unmodeledCallableUses: [...unmodeledCallableUses.values()], + }; +}; + +export const collectReachableFunctions = ( + rootFunction: ts.FunctionLikeDeclaration, + typeChecker: ts.TypeChecker, +): ReadonlyArray => + collectReachableFunctionGraph(rootFunction, typeChecker).functions; diff --git a/packages/prover/src/collect-react-units.ts b/packages/prover/src/collect-react-units.ts new file mode 100644 index 000000000..b04baa1d8 --- /dev/null +++ b/packages/prover/src/collect-react-units.ts @@ -0,0 +1,174 @@ +import ts from "typescript"; +import * as path from "node:path"; +import { collectDirectHookCalls } from "./collect-direct-hook-calls.js"; +import { getCanonicalReactApiName } from "./get-canonical-react-api-name.js"; +import { getFunctionName } from "./get-function-name.js"; +import { isReactHookName } from "./is-react-hook-name.js"; +import { isFunctionBoundary } from "./is-function-boundary.js"; +import { ReactClassComponentBase, ReactUnitKind } from "./types.js"; +import { getClassMethodDeclaration } from "./utils/get-class-method-declaration.js"; +import { getStaticClassMethodDeclaration } from "./utils/get-static-class-method-declaration.js"; +import { getStaticPropertyName } from "./utils/get-static-property-name.js"; +import type { ReactUnitDescriptor } from "./types.js"; + +const isReactComponentName = (name: string): boolean => /^[A-Z]/.test(name); + +const getReactClassComponentBase = ( + classNode: ts.ClassDeclaration, + typeChecker: ts.TypeChecker, +): ReactClassComponentBase | null => { + for (const heritageClause of classNode.heritageClauses ?? []) { + for (const heritageType of heritageClause.types) { + const baseName = getCanonicalReactApiName(heritageType.expression, typeChecker); + if (baseName === ReactClassComponentBase.Component) { + return ReactClassComponentBase.Component; + } + if (baseName === ReactClassComponentBase.PureComponent) { + return ReactClassComponentBase.PureComponent; + } + } + } + return null; +}; + +const SUPPORTED_CLASS_LIFECYCLE_NAMES = new Set([ + "componentDidCatch", + "componentDidMount", + "componentDidUpdate", + "componentWillUnmount", + "render", +]); + +const isReservedClassLifecycleName = (methodName: string): boolean => + methodName.startsWith("component") || + methodName.startsWith("UNSAFE_") || + methodName === "getSnapshotBeforeUpdate" || + methodName === "shouldComponentUpdate"; + +const hasSupportedClassSyntax = ( + classNode: ts.ClassDeclaration, + renderMethod: ts.MethodDeclaration, +): boolean => + renderMethod.parameters.length === 0 && + classNode.members.every((member) => { + if (ts.isConstructorDeclaration(member)) { + return classNode.members.find(ts.isConstructorDeclaration) === member; + } + if (ts.isPropertyDeclaration(member)) { + const propertyName = getStaticPropertyName(member.name); + return Boolean( + propertyName && + !member.modifiers?.some( + (modifier) => + modifier.kind === ts.SyntaxKind.StaticKeyword || + modifier.kind === ts.SyntaxKind.AccessorKeyword, + ), + ); + } + if (!ts.isMethodDeclaration(member)) return false; + const methodName = getStaticPropertyName(member.name); + if (!methodName) return false; + if (methodName === "getDerivedStateFromError") { + return ( + getStaticClassMethodDeclaration(classNode, methodName) === member && + member.parameters.length === 1 + ); + } + if (getClassMethodDeclaration(classNode, methodName) !== member) return false; + if (isReservedClassLifecycleName(methodName)) { + if (!SUPPORTED_CLASS_LIFECYCLE_NAMES.has(methodName)) return false; + if (methodName === "componentDidCatch") { + return member.parameters.length >= 1 && member.parameters.length <= 2; + } + if (methodName === "componentDidUpdate") return member.parameters.length <= 2; + return member.parameters.length === 0; + } + return true; + }); + +const collectFunctionUnit = ( + functionNode: ts.FunctionLikeDeclaration, + typeChecker: ts.TypeChecker, +): ReactUnitDescriptor | null => { + const functionName = getFunctionName(functionNode); + const directHookCalls = collectDirectHookCalls(functionNode, typeChecker); + if (!functionName) { + return directHookCalls.length > 0 + ? { + name: "anonymous callback", + kind: ReactUnitKind.InvalidHookOwner, + node: functionNode, + functionNode, + invalidHookCalls: directHookCalls, + sourceComplete: false, + } + : null; + } + if (isReactHookName(functionName)) { + return { + name: functionName, + kind: ReactUnitKind.Hook, + node: functionNode, + functionNode, + sourceComplete: true, + }; + } + if (isReactComponentName(functionName)) { + return { + name: functionName, + kind: ReactUnitKind.Component, + node: functionNode, + functionNode, + sourceComplete: true, + }; + } + if (directHookCalls.length === 0) return null; + return { + name: functionName, + kind: ReactUnitKind.InvalidHookOwner, + node: functionNode, + functionNode, + invalidHookCalls: directHookCalls, + sourceComplete: false, + }; +}; + +export const collectReactUnits = ( + sourceFile: ts.SourceFile, + typeChecker: ts.TypeChecker, +): ReadonlyArray => { + const units: ReactUnitDescriptor[] = []; + const moduleHookCalls = collectDirectHookCalls(sourceFile, typeChecker); + if (moduleHookCalls.length > 0) { + units.push({ + name: `${path.basename(sourceFile.fileName)} module`, + kind: ReactUnitKind.InvalidHookOwner, + node: sourceFile, + invalidHookCalls: moduleHookCalls, + sourceComplete: false, + }); + } + const visit = (node: ts.Node): void => { + if (isFunctionBoundary(node)) { + const functionUnit = collectFunctionUnit(node, typeChecker); + if (functionUnit) units.push(functionUnit); + } else if (ts.isClassDeclaration(node)) { + const classComponentBase = getReactClassComponentBase(node, typeChecker); + if (classComponentBase) { + const renderMethod = getClassMethodDeclaration(node, "render"); + units.push({ + name: node.name?.text ?? "DefaultComponent", + kind: ReactUnitKind.ClassComponent, + node, + classNode: node, + classComponentBase, + functionNode: renderMethod ?? undefined, + sourceComplete: Boolean(renderMethod && hasSupportedClassSyntax(node, renderMethod)), + }); + } + } + node.forEachChild(visit); + }; + sourceFile.forEachChild(visit); + return units; +}; diff --git a/packages/prover/src/collect-reactive-captures.ts b/packages/prover/src/collect-reactive-captures.ts new file mode 100644 index 000000000..cfd48bc53 --- /dev/null +++ b/packages/prover/src/collect-reactive-captures.ts @@ -0,0 +1,49 @@ +import ts from "typescript"; +import { isIdentifierReference } from "./is-identifier-reference.js"; +import { isNodeWithin } from "./is-node-within.js"; + +export interface ReactiveCapture { + key: string; + node: ts.Identifier; + symbol: ts.Symbol; +} + +const getCaptureKey = (identifier: ts.Identifier): string => { + let currentNode: ts.Node = identifier; + while ( + ts.isPropertyAccessExpression(currentNode.parent) && + currentNode.parent.expression === currentNode + ) { + currentNode = currentNode.parent; + } + return currentNode.getText(); +}; + +export const collectReactiveCaptures = ( + callback: ts.FunctionLikeDeclaration, + owner: ts.FunctionLikeDeclaration, + typeChecker: ts.TypeChecker, + stableSymbols: ReadonlySet, +): ReadonlyArray => { + const captures = new Map(); + const visit = (node: ts.Node): void => { + if (ts.isIdentifier(node) && isIdentifierReference(node)) { + const identifierSymbol = typeChecker.getSymbolAtLocation(node); + if (identifierSymbol && !stableSymbols.has(identifierSymbol)) { + const isReactiveCapture = Boolean( + identifierSymbol.declarations?.some( + (declaration) => + isNodeWithin(declaration, owner) && !isNodeWithin(declaration, callback), + ), + ); + if (isReactiveCapture) { + const key = getCaptureKey(node); + captures.set(key, { key, node, symbol: identifierSymbol }); + } + } + } + node.forEachChild(visit); + }; + callback.forEachChild(visit); + return [...captures.values()]; +}; diff --git a/packages/prover/src/collect-reducer-transitions.ts b/packages/prover/src/collect-reducer-transitions.ts new file mode 100644 index 000000000..db88a1dc1 --- /dev/null +++ b/packages/prover/src/collect-reducer-transitions.ts @@ -0,0 +1,172 @@ +import ts from "typescript"; +import { + REACT_REDUCER_DISPATCHER_INDEX, + REACT_REDUCER_HOOK_NAMES, + REACT_REDUCER_INITIALIZER_INDEX, + REACT_REDUCER_REDUCER_INDEX, + REACT_REDUCER_STATE_INDEX, + REACT_REDUCER_TUPLE_LENGTH, +} from "./constants.js"; +import { collectHookCalls } from "./collect-hook-calls.js"; +import { isIdentifierReference } from "./is-identifier-reference.js"; +import { isNodeWithin } from "./is-node-within.js"; +import { resolveFunction } from "./resolve-function.js"; +import { unwrapTypescriptExpression } from "./unwrap-typescript-expression.js"; +import { getResolvedSymbol } from "./utils/get-resolved-symbol.js"; +import { isReactHookDependencyReference } from "./utils/is-react-hook-dependency-reference.js"; +import type { ReactAnalysisContext } from "./types.js"; + +export interface ReducerBindingDescriptor { + callExpression: ts.CallExpression; + dispatcherSymbol: ts.Symbol | null; + initializerFunction: ts.FunctionLikeDeclaration | null; + initializerProvided: boolean; + reducerFunction: ts.FunctionLikeDeclaration | null; + stateSymbol: ts.Symbol | null; +} + +export interface BoundReducerBindingDescriptor extends ReducerBindingDescriptor { + dispatcherSymbol: ts.Symbol; +} + +export interface ReducerDispatchDescriptor { + binding: BoundReducerBindingDescriptor; + callExpression: ts.CallExpression | null; + evidenceNode: ts.Node; +} + +export interface ReducerTransitionCollection { + dispatches: ReadonlyArray; + reducers: ReadonlyArray; +} + +const getBindingSymbol = ( + bindingElement: ts.ArrayBindingElement | undefined, + typeChecker: ts.TypeChecker, +): ts.Symbol | null => { + if ( + !bindingElement || + !ts.isBindingElement(bindingElement) || + bindingElement.dotDotDotToken || + !ts.isIdentifier(bindingElement.name) + ) { + return null; + } + return typeChecker.getSymbolAtLocation(bindingElement.name) ?? null; +}; + +const getVariableDeclaration = ( + callExpression: ts.CallExpression, +): ts.VariableDeclaration | null => { + let currentNode: ts.Node = callExpression; + while ( + currentNode.parent && + ts.isExpression(currentNode.parent) && + unwrapTypescriptExpression(currentNode.parent) === callExpression + ) { + currentNode = currentNode.parent; + } + return currentNode.parent && + ts.isVariableDeclaration(currentNode.parent) && + currentNode.parent.initializer === currentNode + ? currentNode.parent + : null; +}; + +export const collectReducerTransitions = ( + functionNode: ts.FunctionLikeDeclaration, + context: ReactAnalysisContext, +): ReducerTransitionCollection => { + const reducers = collectHookCalls( + functionNode, + REACT_REDUCER_HOOK_NAMES, + context.typeChecker, + ).map((callExpression): ReducerBindingDescriptor => { + const declaration = getVariableDeclaration(callExpression); + const bindingPattern = + declaration?.name && + ts.isArrayBindingPattern(declaration.name) && + declaration.name.elements.length <= REACT_REDUCER_TUPLE_LENGTH + ? declaration.name + : null; + const reducerExpression = callExpression.arguments[REACT_REDUCER_REDUCER_INDEX]; + const initializerExpression = callExpression.arguments[REACT_REDUCER_INITIALIZER_INDEX]; + return { + callExpression, + dispatcherSymbol: getBindingSymbol( + bindingPattern?.elements[REACT_REDUCER_DISPATCHER_INDEX], + context.typeChecker, + ), + initializerFunction: initializerExpression + ? resolveFunction(initializerExpression, context.typeChecker) + : null, + initializerProvided: Boolean(initializerExpression), + reducerFunction: reducerExpression + ? resolveFunction(reducerExpression, context.typeChecker) + : null, + stateSymbol: getBindingSymbol( + bindingPattern?.elements[REACT_REDUCER_STATE_INDEX], + context.typeChecker, + ), + }; + }); + const boundReducers = reducers.filter((binding): binding is BoundReducerBindingDescriptor => + Boolean(binding.dispatcherSymbol), + ); + const reducersByDispatcher = new Map( + boundReducers.map((binding): [ts.Symbol, BoundReducerBindingDescriptor] => [ + binding.dispatcherSymbol, + binding, + ]), + ); + const handledDispatcherReferences = new Set(); + const dispatches: ReducerDispatchDescriptor[] = []; + const visitCalls = (node: ts.Node): void => { + if (ts.isCallExpression(node)) { + const dispatcherSymbol = getResolvedSymbol( + unwrapTypescriptExpression(node.expression), + context.typeChecker, + ); + const binding = dispatcherSymbol ? reducersByDispatcher.get(dispatcherSymbol) : undefined; + if (binding) { + dispatches.push({ binding, callExpression: node, evidenceNode: node }); + const collectHandledReferences = (calleeNode: ts.Node): void => { + if ( + ts.isIdentifier(calleeNode) && + getResolvedSymbol(calleeNode, context.typeChecker) === dispatcherSymbol + ) { + handledDispatcherReferences.add(calleeNode); + } + calleeNode.forEachChild(collectHandledReferences); + }; + collectHandledReferences(node.expression); + } + } + node.forEachChild(visitCalls); + }; + functionNode.forEachChild(visitCalls); + + const visitEscapes = (node: ts.Node): void => { + if ( + ts.isIdentifier(node) && + isIdentifierReference(node) && + !handledDispatcherReferences.has(node) + ) { + const dispatcherSymbol = getResolvedSymbol(node, context.typeChecker); + const binding = dispatcherSymbol ? reducersByDispatcher.get(dispatcherSymbol) : undefined; + if ( + binding && + !isReactHookDependencyReference(node, context.typeChecker) && + !dispatches.some( + (dispatch) => + dispatch.callExpression && isNodeWithin(node, dispatch.callExpression.expression), + ) + ) { + dispatches.push({ binding, callExpression: null, evidenceNode: node }); + } + } + node.forEachChild(visitEscapes); + }; + functionNode.forEachChild(visitEscapes); + return { dispatches, reducers }; +}; diff --git a/packages/prover/src/collect-transition-actions.ts b/packages/prover/src/collect-transition-actions.ts new file mode 100644 index 000000000..56f8b3b4f --- /dev/null +++ b/packages/prover/src/collect-transition-actions.ts @@ -0,0 +1,309 @@ +import ts from "typescript"; +import { collectHookBindings } from "./collect-hook-bindings.js"; +import { REACT_TRANSITION_ACTION_INDEX } from "./constants.js"; +import { getCanonicalReactApiName } from "./get-canonical-react-api-name.js"; +import { isIdentifierReference } from "./is-identifier-reference.js"; +import { isNodeWithin } from "./is-node-within.js"; +import { resolveFunction } from "./resolve-function.js"; +import { ReactTransitionActionStatus, ReactTransitionStarterKind, ReactUnitKind } from "./types.js"; +import { unwrapTypescriptExpression } from "./unwrap-typescript-expression.js"; +import type { ReactAnalysisContext, ReactUnitDescriptor } from "./types.js"; +import { collectReachableCallExpressions } from "./utils/collect-reachable-call-expressions.js"; +import { getResolvedSymbol } from "./utils/get-resolved-symbol.js"; +import { isDeferredCallbackSynchronous } from "./utils/is-deferred-callback-synchronous.js"; +import { isIntrinsicJsxElement } from "./utils/is-intrinsic-jsx-element.js"; +import { isReactHookDependencyReference } from "./utils/is-react-hook-dependency-reference.js"; + +export interface TransitionActionDescriptor { + actionFunction: ts.FunctionLikeDeclaration | null; + callExpression: ts.CallExpression | null; + controlledStateNames: ReadonlyArray; + evidenceNode: ts.Node; + starterKind: ReactTransitionStarterKind; + status: ReactTransitionActionStatus; + unknownControlStateNames: ReadonlyArray; +} + +interface StateControlFacts { + controlledStateSymbols: ReadonlySet; + unknownControlStateSymbols: ReadonlySet; +} + +const getTransitionRoots = ( + unit: ReactUnitDescriptor, +): ReadonlyArray => { + if (unit.kind === ReactUnitKind.ClassComponent && unit.classNode) { + return unit.classNode.members.filter(ts.isMethodDeclaration); + } + return unit.functionNode ? [unit.functionNode] : []; +}; + +const collectStateOriginsBySymbol = ( + functionNode: ts.FunctionLikeDeclaration, + stateSymbols: ReadonlySet, + typeChecker: ts.TypeChecker, +): ReadonlyMap> => { + const stateOriginsBySymbol = new Map>( + [...stateSymbols].map((stateSymbol) => [stateSymbol, new Set([stateSymbol])]), + ); + const declarations: ts.VariableDeclaration[] = []; + const visitDeclarations = (node: ts.Node): void => { + if ( + ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + node.initializer && + (node.parent.flags & ts.NodeFlags.Const) !== 0 + ) { + declarations.push(node); + } + node.forEachChild(visitDeclarations); + }; + functionNode.forEachChild(visitDeclarations); + const collectOrigins = (node: ts.Node): ReadonlySet => { + const origins = new Set(); + const visit = (currentNode: ts.Node): void => { + if (ts.isIdentifier(currentNode) && isIdentifierReference(currentNode)) { + const symbol = getResolvedSymbol(currentNode, typeChecker); + for (const origin of (symbol && stateOriginsBySymbol.get(symbol)) ?? []) { + origins.add(origin); + } + } + currentNode.forEachChild(visit); + }; + visit(node); + return origins; + }; + let didAddOrigin = true; + while (didAddOrigin) { + didAddOrigin = false; + for (const declaration of declarations) { + const symbol = getResolvedSymbol(declaration.name, typeChecker); + if (!symbol || !declaration.initializer) continue; + const origins = collectOrigins(declaration.initializer); + if (origins.size === 0) continue; + const existingOrigins = stateOriginsBySymbol.get(symbol) ?? new Set(); + const previousSize = existingOrigins.size; + for (const origin of origins) existingOrigins.add(origin); + stateOriginsBySymbol.set(symbol, existingOrigins); + if (existingOrigins.size !== previousSize) didAddOrigin = true; + } + } + return stateOriginsBySymbol; +}; + +const collectStateOrigins = ( + node: ts.Node, + stateOriginsBySymbol: ReadonlyMap>, + typeChecker: ts.TypeChecker, +): ReadonlySet => { + const origins = new Set(); + const visit = (currentNode: ts.Node): void => { + if (ts.isIdentifier(currentNode) && isIdentifierReference(currentNode)) { + const symbol = getResolvedSymbol(currentNode, typeChecker); + for (const origin of (symbol && stateOriginsBySymbol.get(symbol)) ?? []) { + origins.add(origin); + } + } + currentNode.forEachChild(visit); + }; + visit(node); + return origins; +}; + +const collectStateControlFacts = ( + functionNode: ts.FunctionLikeDeclaration, + stateSymbols: ReadonlySet, + typeChecker: ts.TypeChecker, + unitKind: ReactUnitKind, +): StateControlFacts => { + const stateOriginsBySymbol = collectStateOriginsBySymbol(functionNode, stateSymbols, typeChecker); + const controlledStateSymbols = new Set(); + const unknownControlStateSymbols = new Set(); + const addOrigins = (target: Set, node: ts.Node): void => { + for (const origin of collectStateOrigins(node, stateOriginsBySymbol, typeChecker)) { + target.add(origin); + } + }; + const visit = (node: ts.Node): void => { + let openingElement: ts.JsxOpeningLikeElement | null = null; + if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) { + openingElement = node; + } + if (openingElement) { + const isIntrinsic = isIntrinsicJsxElement(openingElement); + const isFormControl = + isIntrinsic && + ts.isIdentifier(openingElement.tagName) && + (openingElement.tagName.text === "input" || + openingElement.tagName.text === "select" || + openingElement.tagName.text === "textarea"); + for (const property of openingElement.attributes.properties) { + if (ts.isJsxSpreadAttribute(property)) { + if (isFormControl || !isIntrinsic) { + addOrigins(unknownControlStateSymbols, property.expression); + } + continue; + } + const initializer = property.initializer; + const expression = + initializer && ts.isJsxExpression(initializer) ? initializer.expression : null; + if (!expression) continue; + if ( + isFormControl && + (property.name.getText() === "value" || property.name.getText() === "checked") + ) { + addOrigins(controlledStateSymbols, expression); + } else if (!isIntrinsic) { + addOrigins(unknownControlStateSymbols, expression); + } + } + } + if (unitKind === ReactUnitKind.Hook && ts.isReturnStatement(node) && node.expression) { + addOrigins(unknownControlStateSymbols, node.expression); + } + node.forEachChild(visit); + }; + functionNode.forEachChild(visit); + return { controlledStateSymbols, unknownControlStateSymbols }; +}; + +const getStarterKind = ( + callExpression: ts.CallExpression, + transitionStarters: ReadonlySet, + typeChecker: ts.TypeChecker, +): ReactTransitionStarterKind | null => { + const unwrappedCallee = unwrapTypescriptExpression(callExpression.expression); + if (getCanonicalReactApiName(unwrappedCallee, typeChecker) === "startTransition") { + return ReactTransitionStarterKind.Global; + } + const calleeSymbol = getResolvedSymbol(unwrappedCallee, typeChecker); + return calleeSymbol && transitionStarters.has(calleeSymbol) + ? ReactTransitionStarterKind.Hook + : null; +}; + +export const collectTransitionActions = ( + unit: ReactUnitDescriptor, + context: ReactAnalysisContext, +): ReadonlyArray => { + const functionNode = unit.functionNode; + const hookBindings = functionNode ? collectHookBindings(functionNode, context.typeChecker) : null; + const transitionStarters = hookBindings?.transitionStarters ?? new Set(); + const stateValueBySetter = hookBindings?.stateValueBySetter ?? new Map(); + const stateControlFacts = functionNode + ? collectStateControlFacts( + functionNode, + new Set(stateValueBySetter.values()), + context.typeChecker, + unit.kind, + ) + : { + controlledStateSymbols: new Set(), + unknownControlStateSymbols: new Set(), + }; + const handledStarterReferences = new Set(); + const actions: TransitionActionDescriptor[] = []; + const roots = getTransitionRoots(unit); + const visitCalls = (node: ts.Node): void => { + if (ts.isCallExpression(node)) { + const starterKind = getStarterKind(node, transitionStarters, context.typeChecker); + if (starterKind) { + const actionExpression = node.arguments[REACT_TRANSITION_ACTION_INDEX]; + const actionFunction = actionExpression + ? resolveFunction(actionExpression, context.typeChecker) + : null; + let status = ReactTransitionActionStatus.Opaque; + let controlledStateNames: ReadonlyArray = []; + let unknownControlStateNames: ReadonlyArray = []; + if (actionFunction) { + if (!isDeferredCallbackSynchronous(actionFunction, context)) { + status = ReactTransitionActionStatus.Async; + } else { + const updatedStateSymbols = new Set(); + for (const callExpression of collectReachableCallExpressions( + actionFunction, + context.typeChecker, + )) { + const calleeSymbol = getResolvedSymbol( + unwrapTypescriptExpression(callExpression.expression), + context.typeChecker, + ); + const stateSymbol = calleeSymbol ? stateValueBySetter.get(calleeSymbol) : undefined; + if (stateSymbol) updatedStateSymbols.add(stateSymbol); + } + const controlledStateNameList: string[] = []; + const unknownControlStateNameList: string[] = []; + for (const stateSymbol of updatedStateSymbols) { + if (stateControlFacts.controlledStateSymbols.has(stateSymbol)) { + controlledStateNameList.push(stateSymbol.getName()); + } else if (stateControlFacts.unknownControlStateSymbols.has(stateSymbol)) { + unknownControlStateNameList.push(stateSymbol.getName()); + } + } + controlledStateNames = controlledStateNameList.sort(); + unknownControlStateNames = unknownControlStateNameList.sort(); + status = ReactTransitionActionStatus.Synchronous; + if (unknownControlStateNames.length > 0) { + status = ReactTransitionActionStatus.UnknownControl; + } + if (controlledStateNames.length > 0) { + status = ReactTransitionActionStatus.ControlledInput; + } + } + } + actions.push({ + actionFunction, + callExpression: node, + controlledStateNames, + evidenceNode: node, + starterKind, + status, + unknownControlStateNames, + }); + const collectHandledReferences = (calleeNode: ts.Node): void => { + if (ts.isIdentifier(calleeNode)) handledStarterReferences.add(calleeNode); + calleeNode.forEachChild(collectHandledReferences); + }; + collectHandledReferences(node.expression); + } + } + node.forEachChild(visitCalls); + }; + for (const root of roots) root.forEachChild(visitCalls); + + const visitEscapes = (node: ts.Node): void => { + if ( + ts.isIdentifier(node) && + isIdentifierReference(node) && + !handledStarterReferences.has(node) && + !isReactHookDependencyReference(node, context.typeChecker) + ) { + const symbol = getResolvedSymbol(node, context.typeChecker); + let starterKind: ReactTransitionStarterKind | null = null; + if (symbol && transitionStarters.has(symbol)) { + starterKind = ReactTransitionStarterKind.Hook; + } else if (getCanonicalReactApiName(node, context.typeChecker) === "startTransition") { + starterKind = ReactTransitionStarterKind.Global; + } + if ( + starterKind && + !actions.some( + (action) => action.callExpression && isNodeWithin(node, action.callExpression.expression), + ) + ) { + actions.push({ + actionFunction: null, + callExpression: null, + controlledStateNames: [], + evidenceNode: node, + starterKind, + status: ReactTransitionActionStatus.StarterEscape, + unknownControlStateNames: [], + }); + } + } + node.forEachChild(visitEscapes); + }; + for (const root of roots) root.forEachChild(visitEscapes); + return actions; +}; diff --git a/packages/prover/src/collect-use-resource-protocols.ts b/packages/prover/src/collect-use-resource-protocols.ts new file mode 100644 index 000000000..3d9ae4b94 --- /dev/null +++ b/packages/prover/src/collect-use-resource-protocols.ts @@ -0,0 +1,246 @@ +import ts from "typescript"; +import { getCallName } from "./get-call-name.js"; +import { getCanonicalReactApiName } from "./get-canonical-react-api-name.js"; +import { getContainingFunction } from "./utils/get-containing-function.js"; +import { getResolvedSymbol } from "./utils/get-resolved-symbol.js"; +import { getRootIdentifier } from "./get-root-identifier.js"; +import { isFunctionBoundary } from "./is-function-boundary.js"; +import { isPlatformDeclarationSymbol } from "./utils/is-platform-declaration-symbol.js"; +import { isReactContextExpression } from "./is-react-context-expression.js"; +import { resolveFunction } from "./resolve-function.js"; +import { ReactUseResourceIdentityStatus, ReactUseResourceKind } from "./types.js"; +import { unwrapTypescriptExpression } from "./unwrap-typescript-expression.js"; + +export interface UseResourceProtocolDescriptor { + callExpression: ts.CallExpression; + identityStatus: ReactUseResourceIdentityStatus; + kind: ReactUseResourceKind; +} + +const getVariableDeclaration = (declaration: ts.Declaration): ts.VariableDeclaration | null => { + let currentNode: ts.Node | undefined = declaration; + while (currentNode && !isFunctionBoundary(currentNode)) { + if (ts.isVariableDeclaration(currentNode)) return currentNode; + currentNode = currentNode.parent; + } + return null; +}; + +const isConstVariableDeclaration = (declaration: ts.VariableDeclaration): boolean => + ts.isVariableDeclarationList(declaration.parent) && + Boolean(declaration.parent.flags & ts.NodeFlags.Const); + +const getStateInitialExpression = ( + declaration: ts.Declaration, + typeChecker: ts.TypeChecker, +): ts.Expression | null => { + const variableDeclaration = getVariableDeclaration(declaration); + if ( + !variableDeclaration?.initializer || + !ts.isCallExpression(variableDeclaration.initializer) || + !ts.isArrayBindingPattern(variableDeclaration.name) + ) { + return null; + } + const stateBinding = variableDeclaration.name.elements[0]; + if ( + !stateBinding || + !ts.isBindingElement(stateBinding) || + !ts.isIdentifier(stateBinding.name) || + declaration !== stateBinding + ) { + return null; + } + const hookName = getCanonicalReactApiName( + variableDeclaration.initializer.expression, + typeChecker, + ); + if (hookName !== "useState") return null; + const initialValue = variableDeclaration.initializer.arguments[0]; + if (!initialValue) return null; + const unwrappedInitialValue = unwrapTypescriptExpression(initialValue); + if (ts.isArrowFunction(unwrappedInitialValue) || ts.isFunctionExpression(unwrappedInitialValue)) { + return ts.isBlock(unwrappedInitialValue.body) ? null : unwrappedInitialValue.body; + } + return initialValue; +}; + +const isKnownFreshPromiseCall = ( + callExpression: ts.CallExpression, + typeChecker: ts.TypeChecker, +): boolean => { + const reactApiName = getCanonicalReactApiName(callExpression.expression, typeChecker); + if (reactApiName === "useMemo") return true; + const callName = getCallName(callExpression); + const rootIdentifier = getRootIdentifier(callExpression.expression); + const rootSymbol = rootIdentifier ? getResolvedSymbol(rootIdentifier, typeChecker) : null; + if (callName === "fetch" && rootSymbol && isPlatformDeclarationSymbol(rootSymbol)) { + return true; + } + if (callName?.startsWith("Promise.") && rootSymbol && isPlatformDeclarationSymbol(rootSymbol)) { + return true; + } + const resolvedFunction = resolveFunction(callExpression.expression, typeChecker); + return Boolean( + resolvedFunction?.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword), + ); +}; + +const combineIdentityStatuses = ( + statuses: ReadonlyArray, +): ReactUseResourceIdentityStatus => { + if (statuses.length === 0 || statuses.includes(ReactUseResourceIdentityStatus.Unknown)) { + return ReactUseResourceIdentityStatus.Unknown; + } + return statuses.every((status) => status === ReactUseResourceIdentityStatus.Stable) + ? ReactUseResourceIdentityStatus.Stable + : ReactUseResourceIdentityStatus.Unstable; +}; + +const getResourceIdentityStatus = ( + expression: ts.Expression, + ownerFunction: ts.FunctionLikeDeclaration, + typeChecker: ts.TypeChecker, + visitedSymbols: ReadonlySet = new Set(), +): ReactUseResourceIdentityStatus => { + const unwrappedExpression = unwrapTypescriptExpression(expression); + if (ts.isConditionalExpression(unwrappedExpression)) { + return combineIdentityStatuses([ + getResourceIdentityStatus( + unwrappedExpression.whenTrue, + ownerFunction, + typeChecker, + visitedSymbols, + ), + getResourceIdentityStatus( + unwrappedExpression.whenFalse, + ownerFunction, + typeChecker, + visitedSymbols, + ), + ]); + } + if (ts.isCallExpression(unwrappedExpression)) { + return isKnownFreshPromiseCall(unwrappedExpression, typeChecker) + ? ReactUseResourceIdentityStatus.Unstable + : ReactUseResourceIdentityStatus.Unknown; + } + if (ts.isNewExpression(unwrappedExpression)) { + const constructorIdentifier = getRootIdentifier(unwrappedExpression.expression); + const constructorSymbol = constructorIdentifier + ? getResolvedSymbol(constructorIdentifier, typeChecker) + : null; + return constructorSymbol && + constructorIdentifier?.text === "Promise" && + isPlatformDeclarationSymbol(constructorSymbol) + ? ReactUseResourceIdentityStatus.Unstable + : ReactUseResourceIdentityStatus.Unknown; + } + if (!ts.isIdentifier(unwrappedExpression)) { + return ReactUseResourceIdentityStatus.Unknown; + } + const symbol = getResolvedSymbol(unwrappedExpression, typeChecker); + if (!symbol || visitedSymbols.has(symbol)) return ReactUseResourceIdentityStatus.Unknown; + const nextVisitedSymbols = new Set(visitedSymbols).add(symbol); + for (const declaration of symbol.declarations ?? []) { + const stateInitialExpression = getStateInitialExpression(declaration, typeChecker); + if (stateInitialExpression) { + return getResourceIdentityStatus( + stateInitialExpression, + ownerFunction, + typeChecker, + nextVisitedSymbols, + ); + } + const variableDeclaration = getVariableDeclaration(declaration); + if ( + !variableDeclaration || + !isConstVariableDeclaration(variableDeclaration) || + !variableDeclaration.initializer + ) { + continue; + } + if (!getContainingFunction(variableDeclaration)) { + return ReactUseResourceIdentityStatus.Stable; + } + if (getContainingFunction(variableDeclaration) === ownerFunction) { + return getResourceIdentityStatus( + variableDeclaration.initializer, + ownerFunction, + typeChecker, + nextVisitedSymbols, + ); + } + } + return ReactUseResourceIdentityStatus.Unknown; +}; + +const getTypeKind = ( + valueType: ts.Type, + expression: ts.Expression, + typeChecker: ts.TypeChecker, +): ReactUseResourceKind => { + if (valueType.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) { + return ReactUseResourceKind.Unknown; + } + if (valueType.isUnion()) { + const memberKinds = valueType.types.map((memberType) => + getTypeKind(memberType, expression, typeChecker), + ); + if (memberKinds.every((kind) => kind === ReactUseResourceKind.Thenable)) { + return ReactUseResourceKind.Thenable; + } + if (memberKinds.every((kind) => kind === ReactUseResourceKind.Invalid)) { + return ReactUseResourceKind.Invalid; + } + return ReactUseResourceKind.Unknown; + } + if (valueType.flags & ts.TypeFlags.TypeParameter) { + const constraint = typeChecker.getBaseConstraintOfType(valueType); + return constraint + ? getTypeKind(constraint, expression, typeChecker) + : ReactUseResourceKind.Unknown; + } + const thenProperty = typeChecker.getPropertyOfType(valueType, "then"); + if (!thenProperty) return ReactUseResourceKind.Invalid; + const thenType = typeChecker.getTypeOfSymbolAtLocation(thenProperty, expression); + return thenType.getCallSignatures().length > 0 + ? ReactUseResourceKind.Thenable + : ReactUseResourceKind.Invalid; +}; + +export const collectUseResourceProtocols = ( + functionNode: ts.FunctionLikeDeclaration, + typeChecker: ts.TypeChecker, +): ReadonlyArray => { + const protocols: UseResourceProtocolDescriptor[] = []; + const visit = (node: ts.Node): void => { + if (node !== functionNode && isFunctionBoundary(node)) return; + if ( + ts.isCallExpression(node) && + getCanonicalReactApiName(node.expression, typeChecker) === "use" + ) { + const resourceExpression = node.arguments[0]; + if (resourceExpression && isReactContextExpression(resourceExpression, typeChecker)) { + return; + } + protocols.push({ + callExpression: node, + identityStatus: resourceExpression + ? getResourceIdentityStatus(resourceExpression, functionNode, typeChecker) + : ReactUseResourceIdentityStatus.Unknown, + kind: resourceExpression + ? getTypeKind( + typeChecker.getTypeAtLocation(resourceExpression), + resourceExpression, + typeChecker, + ) + : ReactUseResourceKind.Invalid, + }); + return; + } + node.forEachChild(visit); + }; + functionNode.forEachChild(visit); + return protocols; +}; diff --git a/packages/prover/src/constants.ts b/packages/prover/src/constants.ts new file mode 100644 index 000000000..5dc4f47ba --- /dev/null +++ b/packages/prover/src/constants.ts @@ -0,0 +1,203 @@ +import { ReactEffectResourceKind } from "./types.js"; + +export const REACT_PROOF_SCHEMA_VERSION = 30; +export const REACT_SEMANTIC_GRAPH_SCHEMA_VERSION = 36; +export const REACT_COMPILER_VERSION = "babel-plugin-react-compiler@1.0.0"; +export const REACT_COMPILER_FACT_PHASE = "InferReactivePlaces"; +export const FIRST_SOURCE_LINE = 1; +export const FIRST_SOURCE_COLUMN = 1; +export const MAX_MEMO_COMPARATOR_PATHS = 64; +export const PROVER_RUNTIME_ORACLE_PORT = 4178; +export const PROVER_RUNTIME_ORACLE_TIMEOUT_MS = 30_000; +export const REACT_CONTEXT_DEFAULT_SOURCE_ID = "react:context-default"; +export const REACT_CONTEXT_UNKNOWN_SOURCE_ID = "react:context-unknown"; +export const REACT_FORM_OUTSIDE_SOURCE_ID = "react:form-outside"; +export const REACT_FORM_UNKNOWN_SOURCE_ID = "react:form-unknown"; +export const REACT_ERROR_BOUNDARY_OUTSIDE_SOURCE_ID = "react:error-boundary-outside"; +export const REACT_ERROR_BOUNDARY_UNKNOWN_SOURCE_ID = "react:error-boundary-unknown"; +export const REACT_SUSPENSE_OUTSIDE_SOURCE_ID = "react:suspense-outside"; +export const REACT_SUSPENSE_OWNER_SOURCE_ID = "react:suspense-owner"; +export const REACT_SUSPENSE_UNKNOWN_SOURCE_ID = "react:suspense-unknown"; +export const REACT_ACTION_STATE_DISPATCHER_INDEX = 1; +export const REACT_ACTION_STATE_REDUCER_INDEX = 0; +export const REACT_ACTION_STATE_STATE_INDEX = 0; +export const REACT_ACTION_STATE_TUPLE_LENGTH = 3; +export const REACT_OPTIMISTIC_REDUCER_INDEX = 1; +export const REACT_OPTIMISTIC_SETTER_INDEX = 1; +export const REACT_OPTIMISTIC_STATE_INDEX = 0; +export const REACT_OPTIMISTIC_TUPLE_LENGTH = 2; +export const REACT_REDUCER_DISPATCHER_INDEX = 1; +export const REACT_REDUCER_INITIALIZER_INDEX = 2; +export const REACT_REDUCER_REDUCER_INDEX = 0; +export const REACT_REDUCER_STATE_INDEX = 0; +export const REACT_REDUCER_TUPLE_LENGTH = 2; +export const REACT_TRANSITION_ACTION_INDEX = 0; +export const REACT_TRANSITION_STARTER_INDEX = 1; +export const REACT_USE_TRANSITION_TUPLE_LENGTH = 2; +export const REACT_HOST_CONTROL_EVENT_PARAMETER_INDEX = 0; +export const REACT_HOST_CONTROL_SETTER_ARGUMENT_INDEX = 0; +export const REACT_HOST_CONTROL_STATE_INDEX = 0; + +export const REACT_EFFECT_HOOK_NAMES = new Set([ + "useEffect", + "useInsertionEffect", + "useLayoutEffect", +]); + +export const PLATFORM_OBSERVER_KINDS = new Map([ + ["IntersectionObserver", ReactEffectResourceKind.IntersectionObserver], + ["MutationObserver", ReactEffectResourceKind.MutationObserver], + ["ResizeObserver", ReactEffectResourceKind.ResizeObserver], +]); + +export const REACT_MEMO_HOOK_NAMES = new Set(["useCallback", "useMemo"]); +export const REACT_IMPERATIVE_HANDLE_HOOK_NAMES = new Set(["useImperativeHandle"]); +export const REACT_REDUCER_HOOK_NAMES = new Set(["useReducer"]); +export const REACT_EXTERNAL_STORE_HOOK_NAMES = new Set(["useSyncExternalStore"]); +export const EFFECT_EVENT_REGISTRATION_CALL_NAMES = new Set([ + "addEventListener", + "on", + "once", + "removeEventListener", + "requestAnimationFrame", + "setInterval", + "setTimeout", + "subscribe", +]); +export const PROMISE_CONTINUATION_METHOD_NAMES = new Set(["catch", "finally", "then"]); + +export const REACT_MODELED_HOOK_NAMES = new Set([ + "useActionState", + "useCallback", + "useContext", + "useEffect", + "useEffectEvent", + "useFormStatus", + "useId", + "useImperativeHandle", + "useInsertionEffect", + "useLayoutEffect", + "useMemo", + "useRef", + "useReducer", + "useState", + "useSyncExternalStore", +]); + +export const REACT_UNMODELED_HOOK_NAMES = new Set([ + "use", + "useDeferredValue", + "useOptimistic", + "useTransition", +]); + +export const REACT_PURE_RENDER_API_NAMES = new Set(["createPortal"]); + +export const KNOWN_IMPURE_RENDER_CALLS = new Set([ + "crypto.randomUUID", + "Date.now", + "Math.random", + "performance.now", +]); + +export const KNOWN_PURE_GLOBAL_CALLS = new Set([ + "Boolean", + "Number", + "Object.entries", + "Object.is", + "Object.keys", + "Object.values", + "String", +]); + +export const KNOWN_PURE_METHOD_NAMES = new Set([ + "at", + "concat", + "endsWith", + "entries", + "every", + "filter", + "find", + "findIndex", + "flat", + "flatMap", + "includes", + "indexOf", + "join", + "keys", + "map", + "reduce", + "reduceRight", + "slice", + "some", + "startsWith", + "substring", + "toLowerCase", + "toUpperCase", + "trim", + "values", +]); + +export const KNOWN_PURE_STANDARD_METHOD_NAMES = new Set(["get", "has"]); +export const KNOWN_MUTATING_STANDARD_METHOD_NAMES = new Set(["add", "clear", "delete", "set"]); + +export const SYNCHRONOUS_CALLBACK_METHOD_NAMES = new Set([ + "every", + "filter", + "find", + "findIndex", + "flatMap", + "forEach", + "map", + "reduce", + "reduceRight", + "some", +]); + +export const MUTATING_METHOD_NAMES = new Set([ + "copyWithin", + "fill", + "pop", + "push", + "reverse", + "shift", + "sort", + "splice", + "unshift", +]); + +export const CLASS_STATE_MUTATING_METHOD_NAMES = new Set([ + ...MUTATING_METHOD_NAMES, + "add", + "clear", + "delete", + "set", +]); + +export const REACT_RUNTIME_MODULE_NAMES = new Set([ + "react", + "react-dom", + "react-dom/client", + "react-dom/server", + "react/jsx-dev-runtime", + "react/jsx-runtime", +]); + +export const REACT_HYDRATABLE_SERVER_API_NAMES = new Set([ + "renderToPipeableStream", + "renderToReadableStream", + "renderToString", +]); + +export const REACT_STATIC_SERVER_API_NAMES = new Set(["renderToStaticMarkup"]); + +export const REACT_EVENT_PROP_PATTERN = /^on[A-Z]/; + +export const REACT_TRANSPARENT_COMPONENT_NAMES = new Set([ + "Activity", + "Fragment", + "Profiler", + "StrictMode", + "Suspense", + "ViewTransition", +]); diff --git a/packages/prover/src/contains-jsx.ts b/packages/prover/src/contains-jsx.ts new file mode 100644 index 000000000..605bee673 --- /dev/null +++ b/packages/prover/src/contains-jsx.ts @@ -0,0 +1,18 @@ +import ts from "typescript"; + +export const containsJsx = (node: ts.Node): boolean => { + let didFindJsx = false; + const visit = (currentNode: ts.Node): void => { + if ( + ts.isJsxElement(currentNode) || + ts.isJsxFragment(currentNode) || + ts.isJsxSelfClosingElement(currentNode) + ) { + didFindJsx = true; + return; + } + currentNode.forEachChild(visit); + }; + node.forEachChild(visit); + return didFindJsx; +}; diff --git a/packages/prover/src/create-component-callback-flow.ts b/packages/prover/src/create-component-callback-flow.ts new file mode 100644 index 000000000..89890d4c7 --- /dev/null +++ b/packages/prover/src/create-component-callback-flow.ts @@ -0,0 +1,624 @@ +import ts from "typescript"; +import { REACT_EVENT_PROP_PATTERN } from "./constants.js"; +import { collectReachableFunctions } from "./collect-reachable-functions.js"; +import { getComponentPropName } from "./get-component-prop-name.js"; +import { getRootIdentifier } from "./get-root-identifier.js"; +import { isComponentPropExpression } from "./is-component-prop-expression.js"; +import { isIdentifierReference } from "./is-identifier-reference.js"; +import { isFunctionBoundary } from "./is-function-boundary.js"; +import { mergeCallableBindings, resolveCallableExpression } from "./resolve-callable-expression.js"; +import { ReactExecutionPhase } from "./types.js"; +import { unwrapTypescriptExpression } from "./unwrap-typescript-expression.js"; +import { collectJsxSpreadProperties } from "./utils/collect-jsx-spread-properties.js"; +import { getJsxComponentTargetFunction } from "./utils/get-jsx-component-target-function.js"; +import { isDirectComponentPropertiesObject } from "./utils/is-direct-component-properties-object.js"; +import { isIntrinsicJsxElement } from "./utils/is-intrinsic-jsx-element.js"; +import { isJsxSpreadSourceComplete } from "./utils/is-jsx-spread-source-complete.js"; +import type { + ResolvedCallableGuardDescriptor, + ResolvedCallableValueDescriptor, +} from "./resolve-callable-expression.js"; + +export interface ComponentCallbackDescriptor { + bindings: ReadonlyMap; + callbackFunction: ts.FunctionLikeDeclaration; + guards: ReadonlyArray; + ownerFunction: ts.FunctionLikeDeclaration; +} + +export interface ComponentEventBindingDescriptor { + callbacks: ReadonlyArray; + eventName: string; + isComplete: boolean; + node: ts.JsxAttributeLike; + ownerFunction: ts.FunctionLikeDeclaration; +} + +export interface ComponentCallbackPropFlowDescriptor { + callbacks: ReadonlyArray; + isComplete: boolean; + node: ts.JsxAttributeLike; + phase: ReactExecutionPhase; + propName: string; + renderNode: ts.JsxOpeningLikeElement; + renderOwnerFunction: ts.FunctionLikeDeclaration; + targetFunction: ts.FunctionLikeDeclaration; +} + +export interface ComponentCallbackFlowDescriptor { + bindings: ReadonlyArray; + collectPropFlows(): ReadonlyArray; + resolveCallback( + callbackFunction: ts.FunctionLikeDeclaration, + ownerFunction: ts.FunctionLikeDeclaration, + phase: ReactExecutionPhase, + ): ComponentCallbackResolutionDescriptor; + resolveExpression( + expression: ts.Expression, + ownerFunction: ts.FunctionLikeDeclaration, + phase: ReactExecutionPhase, + ): ComponentCallbackExpressionResolutionDescriptor; + resolveProperty( + expression: ts.Expression, + propertyName: string, + ownerFunction: ts.FunctionLikeDeclaration, + phase: ReactExecutionPhase, + ): ComponentCallbackExpressionResolutionDescriptor; +} + +export interface ComponentCallbackResolutionDescriptor { + bindings: ReadonlyMap; +} + +export interface ComponentCallbackExpressionResolutionDescriptor { + callbacks: ReadonlyArray; + isComplete: boolean; +} + +interface ComponentPropChannel { + functionNode: ts.FunctionLikeDeclaration; + propName: string; +} + +interface ComponentPropBinding { + callbacks: ReadonlyArray; + isComplete: boolean; + node: ts.JsxAttributeLike; + renderNode: ts.JsxOpeningLikeElement; + renderOwnerFunction: ts.FunctionLikeDeclaration; + sourceChannel: ComponentPropChannel | null; + targetChannel: ComponentPropChannel; + targetFunction: ts.FunctionLikeDeclaration; +} + +interface CallbackSource { + callbacks: ReadonlyArray; + channel: ComponentPropChannel | null; + isComplete: boolean; +} + +interface ComponentEventSource { + eventName: string; + node: ts.JsxAttributeLike; + ownerFunction: ts.FunctionLikeDeclaration; + source: CallbackSource; +} + +interface ResolvedCallbackSource { + callbacks: ReadonlyArray; + isComplete: boolean; +} + +interface ComponentPropReference { + channel: ComponentPropChannel | null; + isComplete: boolean; + propertyName: string | null; + symbol: ts.Symbol; +} + +const getNodeIdentity = (node: ts.Node): string => + `${node.getSourceFile().fileName}:${node.getStart()}:${node.getEnd()}`; + +const getSymbolIdentity = (symbol: ts.Symbol): string => { + const declaration = symbol.declarations?.[0]; + return declaration ? getNodeIdentity(declaration) : symbol.getName(); +}; + +const getChannelIdentity = (channel: ComponentPropChannel): string => + `${getNodeIdentity(channel.functionNode)}:${channel.propName}`; + +const getJsxAttributeExpression = (attribute: ts.JsxAttribute): ts.Expression | null => + attribute.initializer && + ts.isJsxExpression(attribute.initializer) && + attribute.initializer.expression + ? attribute.initializer.expression + : null; + +const getComponentPropChannel = ( + expression: ts.Expression, + functionNode: ts.FunctionLikeDeclaration, + typeChecker: ts.TypeChecker, +): ComponentPropChannel | null => { + const propName = getComponentPropName(expression, functionNode, typeChecker); + return propName ? { functionNode, propName } : null; +}; + +const getOpeningElement = (node: ts.Node): ts.JsxOpeningLikeElement | null => { + if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) return node; + return null; +}; + +const deduplicateCallbacks = ( + callbacks: ReadonlyArray, +): ReadonlyArray => { + const callbacksByIdentity = new Map(); + for (const callback of callbacks) { + const guardIdentity = callback.guards + .map((guard) => `${guard.conditionIdentity}=${String(guard.polarity)}`) + .sort() + .join("&"); + const callbackIdentity = `${getNodeIdentity(callback.ownerFunction)}:${getNodeIdentity(callback.callbackFunction)}:${guardIdentity}`; + const existingCallback = callbacksByIdentity.get(callbackIdentity); + callbacksByIdentity.set(callbackIdentity, { + ...callback, + bindings: existingCallback + ? mergeCallableBindings([existingCallback.bindings, callback.bindings]) + : callback.bindings, + }); + } + return [...callbacksByIdentity.values()]; +}; + +const collectComponentPropReferences = ( + callbackFunction: ts.FunctionLikeDeclaration, + ownerFunction: ts.FunctionLikeDeclaration, + typeChecker: ts.TypeChecker, +): ReadonlyArray => { + const referencesByIdentity = new Map(); + for (const reachableFunction of collectReachableFunctions(callbackFunction, typeChecker)) { + const visit = (node: ts.Node): void => { + if (node !== reachableFunction.functionNode && isFunctionBoundary(node)) return; + const expression = + ts.isPropertyAccessExpression(node) || + (ts.isIdentifier(node) && + isIdentifierReference(node) && + !( + (ts.isPropertyAccessExpression(node.parent) || + ts.isElementAccessExpression(node.parent)) && + node.parent.expression === node + )) + ? node + : null; + const propName = expression + ? getComponentPropName(expression, ownerFunction, typeChecker) + : null; + const rootIdentifier = expression ? getRootIdentifier(expression) : null; + const symbol = rootIdentifier ? typeChecker.getSymbolAtLocation(rootIdentifier) : null; + if (expression && propName && symbol) { + const propertyName = ts.isPropertyAccessExpression(expression) ? propName : null; + referencesByIdentity.set(`${getSymbolIdentity(symbol)}:${propertyName ?? ""}`, { + channel: { functionNode: ownerFunction, propName }, + isComplete: !symbol.declarations?.some( + (declaration) => + (ts.isBindingElement(declaration) && + Boolean(declaration.initializer || declaration.dotDotDotToken)) || + (ts.isParameter(declaration) && Boolean(declaration.initializer)), + ), + propertyName, + symbol, + }); + } else if ( + expression && + symbol && + isComponentPropExpression(expression, ownerFunction, typeChecker) + ) { + referencesByIdentity.set(`${getSymbolIdentity(symbol)}:unresolved`, { + channel: null, + isComplete: false, + propertyName: null, + symbol, + }); + } + node.forEachChild(visit); + }; + reachableFunction.functionNode.forEachChild(visit); + } + return [...referencesByIdentity.values()]; +}; + +const createCallbackSourceValue = ( + source: ResolvedCallbackSource, +): ResolvedCallableValueDescriptor => ({ + isComplete: source.isComplete, + properties: new Map(), + targets: source.callbacks.map((callback) => ({ + bindings: callback.bindings, + functionNode: callback.callbackFunction, + guards: callback.guards, + isConditionallyReached: source.callbacks.length > 1, + })), +}); + +const createExpressionCallbackSource = ( + expression: ts.Expression, + ownerFunction: ts.FunctionLikeDeclaration, + typeChecker: ts.TypeChecker, +): CallbackSource => { + const callableValue = resolveCallableExpression(expression, typeChecker); + const callbacks = callableValue.targets.map( + (target): ComponentCallbackDescriptor => ({ + bindings: target.bindings, + callbackFunction: target.functionNode, + guards: target.guards, + ownerFunction, + }), + ); + return { + callbacks, + channel: getComponentPropChannel(expression, ownerFunction, typeChecker), + isComplete: callableValue.isComplete && callbacks.length > 0, + }; +}; + +const createSpreadPropertyCallbackSource = ( + propertyName: string, + ownerFunction: ts.FunctionLikeDeclaration, + isDirectPropertiesObject: boolean, + objectValue: ResolvedCallableValueDescriptor | null, +): CallbackSource => { + if (isDirectPropertiesObject) { + return { + callbacks: [], + channel: { functionNode: ownerFunction, propName: propertyName }, + isComplete: true, + }; + } + if (!objectValue) return { callbacks: [], channel: null, isComplete: false }; + const propertyValue = objectValue.properties.get(propertyName); + if (!propertyValue) return { callbacks: [], channel: null, isComplete: false }; + const callbacks = propertyValue.targets.map( + (target): ComponentCallbackDescriptor => ({ + bindings: target.bindings, + callbackFunction: target.functionNode, + guards: target.guards, + ownerFunction, + }), + ); + return { + callbacks, + channel: null, + isComplete: objectValue.isComplete && propertyValue.isComplete && callbacks.length > 0, + }; +}; + +export const createComponentCallbackFlow = ( + componentFunctions: ReadonlyArray, + unitFunctionsBySymbol: ReadonlyMap, + typeChecker: ts.TypeChecker, +): ComponentCallbackFlowDescriptor => { + const propBindingsByChannel = new Map(); + const eventSources: ComponentEventSource[] = []; + const componentPropReferencesByCallback = new Map< + string, + ReadonlyArray + >(); + const getCallbackComponentPropReferences = ( + callbackFunction: ts.FunctionLikeDeclaration, + ownerFunction: ts.FunctionLikeDeclaration, + ): ReadonlyArray => { + const callbackIdentity = `${getNodeIdentity(ownerFunction)}:${getNodeIdentity(callbackFunction)}`; + const existingReferences = componentPropReferencesByCallback.get(callbackIdentity); + if (existingReferences) return existingReferences; + const references = collectComponentPropReferences(callbackFunction, ownerFunction, typeChecker); + componentPropReferencesByCallback.set(callbackIdentity, references); + return references; + }; + + for (const ownerFunction of componentFunctions) { + for (const reachableFunction of collectReachableFunctions(ownerFunction, typeChecker)) { + const visit = (node: ts.Node): void => { + if (node !== reachableFunction.functionNode && isFunctionBoundary(node)) return; + const openingElement = getOpeningElement(node); + if (!openingElement) { + node.forEachChild(visit); + return; + } + const targetFunction = getJsxComponentTargetFunction( + openingElement, + unitFunctionsBySymbol, + typeChecker, + ); + const eventSourcesByName = new Map(); + const propBindingsByName = new Map(); + const assignSource = ( + propName: string, + source: CallbackSource, + sourceNode: ts.JsxAttributeLike, + ): void => { + if (isIntrinsicJsxElement(openingElement) && REACT_EVENT_PROP_PATTERN.test(propName)) { + eventSourcesByName.set(propName, { + eventName: propName, + node: sourceNode, + ownerFunction, + source, + }); + } + if (!targetFunction) return; + propBindingsByName.set(propName, { + callbacks: source.callbacks, + isComplete: source.isComplete, + node: sourceNode, + renderNode: openingElement, + renderOwnerFunction: ownerFunction, + sourceChannel: source.channel, + targetChannel: { functionNode: targetFunction, propName }, + targetFunction, + }); + }; + const invalidateExistingSources = (spreadAttribute: ts.JsxSpreadAttribute): void => { + for (const eventName of eventSourcesByName.keys()) { + eventSourcesByName.set(eventName, { + eventName, + node: spreadAttribute, + ownerFunction, + source: { callbacks: [], channel: null, isComplete: false }, + }); + } + for (const [propName, binding] of propBindingsByName) { + propBindingsByName.set(propName, { + ...binding, + callbacks: [], + isComplete: false, + node: spreadAttribute, + sourceChannel: null, + }); + } + }; + for (const attribute of openingElement.attributes.properties) { + if (ts.isJsxAttribute(attribute)) { + const expression = getJsxAttributeExpression(attribute); + if (!expression) continue; + assignSource( + attribute.name.getText(), + createExpressionCallbackSource(expression, ownerFunction, typeChecker), + attribute, + ); + continue; + } + const spreadProperties = collectJsxSpreadProperties(attribute.expression, typeChecker); + if (spreadProperties.hasUnknownProperties) invalidateExistingSources(attribute); + const callablePropertyNames = new Set(spreadProperties.callablePropertyNames); + const unwrappedExpression = unwrapTypescriptExpression(attribute.expression); + const isDirectPropertiesObject = isDirectComponentPropertiesObject( + unwrappedExpression, + ownerFunction, + typeChecker, + ); + const objectValue = + !isDirectPropertiesObject && + isJsxSpreadSourceComplete(attribute.expression, ownerFunction, typeChecker) + ? resolveCallableExpression(attribute.expression, typeChecker) + : null; + for (const propertyName of spreadProperties.propertyNames) { + if (!callablePropertyNames.has(propertyName)) { + eventSourcesByName.delete(propertyName); + if (targetFunction) { + assignSource( + propertyName, + { callbacks: [], channel: null, isComplete: false }, + attribute, + ); + } + continue; + } + assignSource( + propertyName, + createSpreadPropertyCallbackSource( + propertyName, + ownerFunction, + isDirectPropertiesObject, + objectValue, + ), + attribute, + ); + } + } + eventSources.push(...eventSourcesByName.values()); + for (const binding of propBindingsByName.values()) { + const channelIdentity = getChannelIdentity(binding.targetChannel); + const bindings = propBindingsByChannel.get(channelIdentity) ?? []; + bindings.push(binding); + propBindingsByChannel.set(channelIdentity, bindings); + } + openingElement.forEachChild(visit); + }; + reachableFunction.functionNode.forEachChild(visit); + } + } + + const requiredPhasesByChannel = new Map>(); + const resolveCallbackSource = ( + source: CallbackSource, + resolvingChannelIds: ReadonlySet, + phase: ReactExecutionPhase, + ): ResolvedCallbackSource => { + if (source.callbacks.length > 0) { + let isComplete = source.isComplete; + const callbacks = source.callbacks.map((callback) => { + const capturedBindings = new Map(); + for (const reference of getCallbackComponentPropReferences( + callback.callbackFunction, + callback.ownerFunction, + )) { + if (!reference.isComplete) isComplete = false; + if (!reference.channel) continue; + const capturedSource = resolveCallbackSource( + { + callbacks: [], + channel: reference.channel, + isComplete: false, + }, + resolvingChannelIds, + phase, + ); + const capturedValue = createCallbackSourceValue(capturedSource); + if (!capturedSource.isComplete) isComplete = false; + if (!reference.propertyName) { + capturedBindings.set(reference.symbol, capturedValue); + continue; + } + const existingOwnerValue = capturedBindings.get(reference.symbol); + const ownerValue: ResolvedCallableValueDescriptor = existingOwnerValue ?? { + isComplete: true, + properties: new Map(), + targets: [], + }; + capturedBindings.set(reference.symbol, { + ...ownerValue, + properties: new Map([ + ...ownerValue.properties, + [reference.propertyName, capturedValue], + ]), + }); + } + return { + ...callback, + bindings: mergeCallableBindings([callback.bindings, capturedBindings]), + }; + }); + return { callbacks: deduplicateCallbacks(callbacks), isComplete }; + } + if (!source.channel) return { callbacks: [], isComplete: false }; + + const channel = source.channel; + const channelIdentity = getChannelIdentity(channel); + const requiredPhases = requiredPhasesByChannel.get(channelIdentity) ?? new Set(); + requiredPhases.add(phase); + requiredPhasesByChannel.set(channelIdentity, requiredPhases); + if (resolvingChannelIds.has(channelIdentity)) { + return { callbacks: [], isComplete: false }; + } + const bindings = propBindingsByChannel.get(channelIdentity); + if (!bindings || bindings.length === 0) { + return { callbacks: [], isComplete: false }; + } + const nextResolvingChannelIds = new Set(resolvingChannelIds); + nextResolvingChannelIds.add(channelIdentity); + const callbacks: ComponentCallbackDescriptor[] = []; + let isComplete = true; + for (const binding of bindings) { + const resolvedBinding = resolveCallbackSource( + { + callbacks: binding.callbacks, + channel: binding.sourceChannel, + isComplete: binding.isComplete, + }, + nextResolvingChannelIds, + phase, + ); + callbacks.push(...resolvedBinding.callbacks); + if (!resolvedBinding.isComplete) isComplete = false; + } + return { + callbacks: deduplicateCallbacks(callbacks), + isComplete: isComplete && callbacks.length > 0, + }; + }; + + const bindings = eventSources.map((eventSource): ComponentEventBindingDescriptor => { + const resolvedSource = resolveCallbackSource( + eventSource.source, + new Set(), + ReactExecutionPhase.Event, + ); + return { + callbacks: resolvedSource.callbacks, + eventName: eventSource.eventName, + isComplete: resolvedSource.isComplete, + node: eventSource.node, + ownerFunction: eventSource.ownerFunction, + }; + }); + + return { + bindings, + collectPropFlows: () => + [...propBindingsByChannel].flatMap(([channelIdentity, channelBindings]) => { + const requiredPhases = requiredPhasesByChannel.get(channelIdentity); + if (!requiredPhases) return []; + return [...requiredPhases].flatMap((phase) => + channelBindings.map((binding): ComponentCallbackPropFlowDescriptor => { + const resolvedSource = resolveCallbackSource( + { + callbacks: binding.callbacks, + channel: binding.sourceChannel, + isComplete: binding.isComplete, + }, + new Set(), + phase, + ); + return { + callbacks: resolvedSource.callbacks, + isComplete: resolvedSource.isComplete, + node: binding.node, + phase, + propName: binding.targetChannel.propName, + renderNode: binding.renderNode, + renderOwnerFunction: binding.renderOwnerFunction, + targetFunction: binding.targetFunction, + }; + }), + ); + }), + resolveCallback: (callbackFunction, ownerFunction, phase) => { + const resolvedSource = resolveCallbackSource( + { + callbacks: [ + { + bindings: new Map(), + callbackFunction, + guards: [], + ownerFunction, + }, + ], + channel: null, + isComplete: true, + }, + new Set(), + phase, + ); + return { + bindings: resolvedSource.callbacks[0]?.bindings ?? new Map(), + }; + }, + resolveExpression: (expression, ownerFunction, phase) => + resolveCallbackSource( + createExpressionCallbackSource(expression, ownerFunction, typeChecker), + new Set(), + phase, + ), + resolveProperty: (expression, propertyName, ownerFunction, phase) => { + const unwrappedExpression = unwrapTypescriptExpression(expression); + const isDirectPropertiesObject = isDirectComponentPropertiesObject( + unwrappedExpression, + ownerFunction, + typeChecker, + ); + const objectValue = + !isDirectPropertiesObject && + isJsxSpreadSourceComplete(expression, ownerFunction, typeChecker) + ? resolveCallableExpression(expression, typeChecker) + : null; + return resolveCallbackSource( + createSpreadPropertyCallbackSource( + propertyName, + ownerFunction, + isDirectPropertiesObject, + objectValue, + ), + new Set(), + phase, + ); + }, + }; +}; diff --git a/packages/prover/src/create-component-slot-flow.ts b/packages/prover/src/create-component-slot-flow.ts new file mode 100644 index 000000000..d3f01cdf3 --- /dev/null +++ b/packages/prover/src/create-component-slot-flow.ts @@ -0,0 +1,333 @@ +import ts from "typescript"; +import { REACT_TRANSPARENT_COMPONENT_NAMES } from "./constants.js"; +import { getCanonicalReactApiName } from "./get-canonical-react-api-name.js"; +import { getComponentPropName } from "./get-component-prop-name.js"; +import { isComponentPropExpression } from "./is-component-prop-expression.js"; +import { isIdentifierReference } from "./is-identifier-reference.js"; +import { isFunctionBoundary } from "./is-function-boundary.js"; +import { getJsxComponentTargetFunction } from "./utils/get-jsx-component-target-function.js"; +import { getJsxOpeningElementForAttribute } from "./utils/get-jsx-opening-element-for-attribute.js"; +import { isIntrinsicJsxElement } from "./utils/is-intrinsic-jsx-element.js"; + +export interface ComponentSlotPlacementDescriptor { + node: ts.Expression; + ownerFunction: ts.FunctionLikeDeclaration; + topologyFrames: ReadonlyArray; +} + +export interface ComponentSlotTopologyFrame { + node: ts.Expression; + ownerFunction: ts.FunctionLikeDeclaration; +} + +export interface ComponentSlotResolutionDescriptor { + complete: boolean; + placements: ReadonlyArray; +} + +export interface ComponentSlotFlowDescriptor { + resolveSlot( + functionNode: ts.FunctionLikeDeclaration, + propName: string, + ): ComponentSlotResolutionDescriptor; +} + +interface ComponentSlotChannel { + functionNode: ts.FunctionLikeDeclaration; + propName: string; +} + +interface ComponentSlotReferenceClassification { + complete: boolean; + forwarding: ComponentSlotForwarding | null; + ignored: boolean; + placement: ComponentSlotPlacementDescriptor | null; +} + +interface ComponentSlotForwarding { + channel: ComponentSlotChannel; + topologyFrame: ComponentSlotTopologyFrame; +} + +const getNodeIdentity = (node: ts.Node): string => + `${node.getSourceFile().fileName}:${node.getStart()}:${node.getEnd()}`; + +const getChannelIdentity = (channel: ComponentSlotChannel): string => + `${getNodeIdentity(channel.functionNode)}:${channel.propName}`; + +const isTransparentOpeningElement = ( + openingElement: ts.JsxOpeningLikeElement, + transparentOpeningElements: ReadonlySet, + typeChecker: ts.TypeChecker, +): boolean => { + if (isIntrinsicJsxElement(openingElement) || transparentOpeningElements.has(openingElement)) { + return true; + } + const reactComponentName = ts.isJsxNamespacedName(openingElement.tagName) + ? null + : getCanonicalReactApiName(openingElement.tagName, typeChecker); + return Boolean(reactComponentName && REACT_TRANSPARENT_COMPONENT_NAMES.has(reactComponentName)); +}; + +const createUnknownClassification = (): ComponentSlotReferenceClassification => ({ + complete: false, + forwarding: null, + ignored: false, + placement: null, +}); + +const createIgnoredClassification = (): ComponentSlotReferenceClassification => ({ + complete: true, + forwarding: null, + ignored: true, + placement: null, +}); + +const createPlacementClassification = ( + node: ts.Expression, + ownerFunction: ts.FunctionLikeDeclaration, +): ComponentSlotReferenceClassification => ({ + complete: true, + forwarding: null, + ignored: false, + placement: { + node, + ownerFunction, + topologyFrames: [{ node, ownerFunction }], + }, +}); + +const createForwardedClassification = ( + node: ts.Expression, + ownerFunction: ts.FunctionLikeDeclaration, + functionNode: ts.FunctionLikeDeclaration, + propName: string, +): ComponentSlotReferenceClassification => ({ + complete: true, + forwarding: { + channel: { functionNode, propName }, + topologyFrame: { node, ownerFunction }, + }, + ignored: false, + placement: null, +}); + +const isConditionExpression = (node: ts.Node, parentNode: ts.Node): boolean => + (ts.isIfStatement(parentNode) && parentNode.expression === node) || + (ts.isConditionalExpression(parentNode) && parentNode.condition === node) || + (ts.isWhileStatement(parentNode) && parentNode.expression === node) || + (ts.isDoStatement(parentNode) && parentNode.expression === node) || + (ts.isForStatement(parentNode) && parentNode.condition === node); + +const classifySlotReference = ( + expression: ts.Expression, + ownerFunction: ts.FunctionLikeDeclaration, + unitFunctionsBySymbol: ReadonlyMap, + transparentOpeningElements: ReadonlySet, + typeChecker: ts.TypeChecker, +): ComponentSlotReferenceClassification => { + let currentNode: ts.Node = expression; + while (currentNode !== ownerFunction) { + const parentNode = currentNode.parent; + if (!parentNode) return createUnknownClassification(); + if (isConditionExpression(currentNode, parentNode)) return createIgnoredClassification(); + if (ts.isBinaryExpression(parentNode)) { + if ( + parentNode.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken && + parentNode.left === currentNode + ) { + return createIgnoredClassification(); + } + if ( + parentNode.operatorToken.kind !== ts.SyntaxKind.AmpersandAmpersandToken && + parentNode.operatorToken.kind !== ts.SyntaxKind.BarBarToken && + parentNode.operatorToken.kind !== ts.SyntaxKind.QuestionQuestionToken + ) { + return createUnknownClassification(); + } + } + if (ts.isCallExpression(parentNode)) { + const reactApiName = getCanonicalReactApiName(parentNode.expression, typeChecker); + if (reactApiName === "createPortal" && parentNode.arguments[0] === currentNode) { + currentNode = parentNode; + continue; + } + if ( + (reactApiName === "isValidElement" || parentNode.expression.getText() === "Boolean") && + parentNode.arguments[0] === currentNode + ) { + return createIgnoredClassification(); + } + return createUnknownClassification(); + } + if (ts.isJsxAttribute(parentNode)) { + const openingElement = getJsxOpeningElementForAttribute(parentNode); + if (!openingElement) return createUnknownClassification(); + const propName = parentNode.name.getText(); + if (isTransparentOpeningElement(openingElement, transparentOpeningElements, typeChecker)) { + return propName === "children" + ? createPlacementClassification(expression, ownerFunction) + : createUnknownClassification(); + } + const targetFunction = getJsxComponentTargetFunction( + openingElement, + unitFunctionsBySymbol, + typeChecker, + ); + return targetFunction + ? createForwardedClassification(expression, ownerFunction, targetFunction, propName) + : createUnknownClassification(); + } + if (ts.isJsxElement(parentNode)) { + const openingElement = parentNode.openingElement; + if (!isTransparentOpeningElement(openingElement, transparentOpeningElements, typeChecker)) { + const targetFunction = getJsxComponentTargetFunction( + openingElement, + unitFunctionsBySymbol, + typeChecker, + ); + return targetFunction + ? createForwardedClassification(expression, ownerFunction, targetFunction, "children") + : createUnknownClassification(); + } + } + if (ts.isReturnStatement(parentNode) && parentNode.expression === currentNode) { + return createPlacementClassification(expression, ownerFunction); + } + if ( + ts.isArrowFunction(ownerFunction) && + ownerFunction.body === currentNode && + ts.isExpression(ownerFunction.body) + ) { + return createPlacementClassification(expression, ownerFunction); + } + if ( + ts.isVariableDeclaration(parentNode) || + ts.isPropertyAssignment(parentNode) || + ts.isShorthandPropertyAssignment(parentNode) || + ts.isPropertyAccessExpression(parentNode) || + ts.isElementAccessExpression(parentNode) || + ts.isJsxSpreadAttribute(parentNode) || + ts.isNewExpression(parentNode) || + ts.isPrefixUnaryExpression(parentNode) || + ts.isPostfixUnaryExpression(parentNode) || + ts.isSpreadElement(parentNode) || + ts.isTaggedTemplateExpression(parentNode) || + ts.isTemplateExpression(parentNode) || + ts.isTemplateSpan(parentNode) || + ts.isExpressionStatement(parentNode) || + isFunctionBoundary(parentNode) + ) { + return createUnknownClassification(); + } + currentNode = parentNode; + } + return createUnknownClassification(); +}; + +const deduplicatePlacements = ( + placements: ReadonlyArray, +): ReadonlyArray => { + const placementsByIdentity = new Map(); + for (const placement of placements) { + placementsByIdentity.set( + placement.topologyFrames + .map( + (topologyFrame) => + `${getNodeIdentity(topologyFrame.ownerFunction)}:${getNodeIdentity(topologyFrame.node)}`, + ) + .join(">"), + placement, + ); + } + return [...placementsByIdentity.values()]; +}; + +export const createComponentSlotFlow = ( + componentFunctions: ReadonlyArray, + unitFunctionsBySymbol: ReadonlyMap, + transparentOpeningElements: ReadonlySet, + typeChecker: ts.TypeChecker, +): ComponentSlotFlowDescriptor => { + const placementsByChannel = new Map(); + const forwardingsByChannel = new Map(); + const incompleteChannelIds = new Set(); + const incompleteFunctionIds = new Set(); + + for (const ownerFunction of componentFunctions) { + const visit = (node: ts.Node): void => { + if (node !== ownerFunction && isFunctionBoundary(node)) return; + const expression = + ts.isPropertyAccessExpression(node) || + ts.isElementAccessExpression(node) || + (ts.isIdentifier(node) && + isIdentifierReference(node) && + !( + (ts.isPropertyAccessExpression(node.parent) || + ts.isElementAccessExpression(node.parent)) && + node.parent.expression === node + )) + ? node + : null; + const propName = expression + ? getComponentPropName(expression, ownerFunction, typeChecker) + : null; + if (expression && propName) { + const channel: ComponentSlotChannel = { functionNode: ownerFunction, propName }; + const channelId = getChannelIdentity(channel); + const classification = classifySlotReference( + expression, + ownerFunction, + unitFunctionsBySymbol, + transparentOpeningElements, + typeChecker, + ); + if (!classification.complete) incompleteChannelIds.add(channelId); + if (classification.placement) { + const placements = placementsByChannel.get(channelId) ?? []; + placements.push(classification.placement); + placementsByChannel.set(channelId, placements); + } + if (classification.forwarding) { + const forwardings = forwardingsByChannel.get(channelId) ?? []; + forwardings.push(classification.forwarding); + forwardingsByChannel.set(channelId, forwardings); + } + if (classification.ignored) return; + } else if (expression && isComponentPropExpression(expression, ownerFunction, typeChecker)) { + incompleteFunctionIds.add(getNodeIdentity(ownerFunction)); + } + node.forEachChild(visit); + }; + ownerFunction.forEachChild(visit); + } + + const resolveChannel = ( + channel: ComponentSlotChannel, + resolvingChannelIds: ReadonlySet, + ): ComponentSlotResolutionDescriptor => { + const channelId = getChannelIdentity(channel); + if (resolvingChannelIds.has(channelId)) return { complete: false, placements: [] }; + const nextResolvingChannelIds = new Set(resolvingChannelIds); + nextResolvingChannelIds.add(channelId); + const placements = [...(placementsByChannel.get(channelId) ?? [])]; + let complete = + !incompleteChannelIds.has(channelId) && + !incompleteFunctionIds.has(getNodeIdentity(channel.functionNode)); + for (const forwarding of forwardingsByChannel.get(channelId) ?? []) { + const forwardedResolution = resolveChannel(forwarding.channel, nextResolvingChannelIds); + placements.push( + ...forwardedResolution.placements.map((placement) => ({ + ...placement, + topologyFrames: [forwarding.topologyFrame, ...placement.topologyFrames], + })), + ); + complete = forwardedResolution.complete && complete; + } + return { complete, placements: deduplicatePlacements(placements) }; + }; + + return { + resolveSlot: (functionNode, propName) => resolveChannel({ functionNode, propName }, new Set()), + }; +}; diff --git a/packages/prover/src/create-evidence.ts b/packages/prover/src/create-evidence.ts new file mode 100644 index 000000000..5fd41c995 --- /dev/null +++ b/packages/prover/src/create-evidence.ts @@ -0,0 +1,14 @@ +import type ts from "typescript"; +import { getNodeLocation } from "./get-node-location.js"; +import type { ReactProofEvidence } from "./types.js"; + +export const createEvidence = ( + node: ts.Node, + rootDirectory: string, + description: string, + trace: ReadonlyArray = [], +): ReactProofEvidence => ({ + description, + location: getNodeLocation(node, rootDirectory), + trace, +}); diff --git a/packages/prover/src/create-obligation.ts b/packages/prover/src/create-obligation.ts new file mode 100644 index 000000000..bbed0cee4 --- /dev/null +++ b/packages/prover/src/create-obligation.ts @@ -0,0 +1,14 @@ +import type { ReactProofEvidence, ReactProofObligation } from "./types.js"; +import { ReactObligationStatus, ReactProofClaim } from "./types.js"; + +export const createObligation = ( + claim: ReactProofClaim, + status: ReactObligationStatus, + summary: string, + evidence: ReadonlyArray = [], +): ReactProofObligation => ({ + claim, + status, + summary, + evidence, +}); diff --git a/packages/prover/src/create-typescript-project.ts b/packages/prover/src/create-typescript-project.ts new file mode 100644 index 000000000..4add6b4e2 --- /dev/null +++ b/packages/prover/src/create-typescript-project.ts @@ -0,0 +1,92 @@ +import * as path from "node:path"; +import ts from "typescript"; +import { FIRST_SOURCE_COLUMN, FIRST_SOURCE_LINE } from "./constants.js"; +import type { ReactProofEvidence } from "./types.js"; + +export interface TypeScriptProject { + program?: ts.Program; + evidence: ReadonlyArray; +} + +const createConfigEvidence = ( + rootDirectory: string, + configPath: string, + description: string, +): ReactProofEvidence => ({ + description, + location: { + filePath: path.relative(rootDirectory, configPath) || "tsconfig.json", + line: FIRST_SOURCE_LINE, + column: FIRST_SOURCE_COLUMN, + }, + trace: ["project configuration", "TypeScript program", "React proof"], +}); + +export const createTypeScriptProject = ( + rootDirectory: string, + requestedConfigPath?: string, +): TypeScriptProject => { + const configPath = + requestedConfigPath ?? + (ts.sys.fileExists(path.join(rootDirectory, "tsconfig.json")) + ? path.join(rootDirectory, "tsconfig.json") + : undefined); + if (!configPath) { + return { + evidence: [ + createConfigEvidence( + rootDirectory, + path.join(rootDirectory, "tsconfig.json"), + "No tsconfig.json was found for closed-world analysis", + ), + ], + }; + } + const absoluteConfigPath = path.resolve(rootDirectory, configPath); + const configResult = ts.readConfigFile(absoluteConfigPath, ts.sys.readFile); + if (configResult.error) { + return { + evidence: [ + createConfigEvidence( + rootDirectory, + absoluteConfigPath, + ts.flattenDiagnosticMessageText(configResult.error.messageText, "\n"), + ), + ], + }; + } + const parsedConfig = ts.parseJsonConfigFileContent( + configResult.config, + ts.sys, + path.dirname(absoluteConfigPath), + undefined, + absoluteConfigPath, + ); + const evidence = parsedConfig.errors.map((diagnostic) => + createConfigEvidence( + rootDirectory, + absoluteConfigPath, + ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"), + ), + ); + if (parsedConfig.fileNames.length === 0) { + return { + evidence: [ + ...evidence, + createConfigEvidence( + rootDirectory, + absoluteConfigPath, + "The TypeScript project contains no source files", + ), + ], + }; + } + return { + program: ts.createProgram({ + rootNames: parsedConfig.fileNames, + options: parsedConfig.options, + projectReferences: parsedConfig.projectReferences, + }), + evidence, + }; +}; diff --git a/packages/prover/src/extract-react-compiler-graph.ts b/packages/prover/src/extract-react-compiler-graph.ts new file mode 100644 index 000000000..dd2a8eb06 --- /dev/null +++ b/packages/prover/src/extract-react-compiler-graph.ts @@ -0,0 +1,189 @@ +import * as path from "node:path"; +import { transformSync } from "@babel/core"; +import reactCompiler from "babel-plugin-react-compiler"; +import type { + CompilerPipelineValue, + Logger, + LoggerEvent, + SourceLocation, +} from "babel-plugin-react-compiler"; +import { + FIRST_SOURCE_COLUMN, + FIRST_SOURCE_LINE, + REACT_COMPILER_FACT_PHASE, + REACT_COMPILER_VERSION, +} from "./constants.js"; +import { ReactCompilerFactStatus } from "./types.js"; +import type { + ReactCompilerBlockFact, + ReactCompilerFailure, + ReactCompilerFunctionFact, + ReactCompilerGraph, + ReactCompilerInstructionFact, + ReactProofLocation, +} from "./types.js"; + +const getCompilerLocation = ( + sourceLocation: SourceLocation | null, + filePath: string, +): ReactProofLocation | null => { + if (!sourceLocation || typeof sourceLocation === "symbol") return null; + return { + filePath, + line: sourceLocation.start.line, + column: sourceLocation.start.column + 1, + }; +}; + +const getDefaultLocation = (filePath: string): ReactProofLocation => ({ + filePath, + line: FIRST_SOURCE_LINE, + column: FIRST_SOURCE_COLUMN, +}); + +const normalizeCompilerFunction = ( + pipelineValue: CompilerPipelineValue, + filePath: string, +): ReactCompilerFunctionFact | null => { + if (pipelineValue.kind !== "hir" || pipelineValue.name !== REACT_COMPILER_FACT_PHASE) { + return null; + } + const compilerFunction = pipelineValue.value; + const successorsByBlockId = new Map>(); + for (const compilerBlock of compilerFunction.body.blocks.values()) { + successorsByBlockId.set(String(compilerBlock.id), new Set()); + } + for (const compilerBlock of compilerFunction.body.blocks.values()) { + const blockId = String(compilerBlock.id); + for (const predecessorId of compilerBlock.preds) { + const predecessorSuccessors = successorsByBlockId.get(String(predecessorId)); + predecessorSuccessors?.add(blockId); + } + } + const blocks: ReactCompilerBlockFact[] = []; + for (const compilerBlock of compilerFunction.body.blocks.values()) { + const instructions: ReactCompilerInstructionFact[] = compilerBlock.instructions.map( + (instruction) => ({ + id: String(instruction.id), + valueKind: instruction.value.kind, + lvalueId: String(instruction.lvalue.identifier.id), + effect: instruction.lvalue.effect, + reactive: instruction.lvalue.reactive, + location: getCompilerLocation(instruction.loc, filePath), + }), + ); + blocks.push({ + id: String(compilerBlock.id), + kind: compilerBlock.kind, + predecessors: [...compilerBlock.preds].map(String), + successors: [...(successorsByBlockId.get(String(compilerBlock.id)) ?? [])], + instructions, + terminalKind: compilerBlock.terminal.kind, + }); + } + const location = getCompilerLocation(compilerFunction.loc, filePath); + const start = location + ? `${location.filePath}:${location.line}:${location.column}` + : `${filePath}:generated`; + return { + id: `${start}:react-compiler-function`, + functionType: compilerFunction.fnType, + location, + entryBlockId: String(compilerFunction.body.entry), + blocks, + }; +}; + +const describeCompilerEvent = (event: LoggerEvent): string | null => { + if (event.kind === "CompileError") return event.detail.reason; + if (event.kind === "CompileDiagnostic") return event.detail.reason; + if (event.kind === "CompileSkip") return event.reason; + if (event.kind === "PipelineError") return event.data; + return null; +}; + +const getCompilerEventLocation = (event: LoggerEvent, filePath: string): ReactProofLocation => { + if ( + event.kind === "CompileError" || + event.kind === "CompileDiagnostic" || + event.kind === "CompileSkip" || + event.kind === "CompileSuccess" + ) { + return getCompilerLocation(event.fnLoc, filePath) ?? getDefaultLocation(filePath); + } + return getDefaultLocation(filePath); +}; + +const extractSourceCompilerFacts = ( + sourceText: string, + filePath: string, +): { + functions: ReadonlyArray; + failures: ReadonlyArray; +} => { + const functions: ReactCompilerFunctionFact[] = []; + const failures: ReactCompilerFailure[] = []; + const logger: Logger = { + logEvent: (_filename, event) => { + const description = describeCompilerEvent(event); + if (!description) return; + failures.push({ + description, + location: getCompilerEventLocation(event, filePath), + }); + }, + debugLogIRs: (pipelineValue) => { + const compilerFunction = normalizeCompilerFunction(pipelineValue, filePath); + if (compilerFunction) functions.push(compilerFunction); + }, + }; + try { + transformSync(sourceText, { + filename: filePath, + babelrc: false, + configFile: false, + parserOpts: { + plugins: ["typescript", "jsx"], + }, + plugins: [ + [ + reactCompiler, + { + compilationMode: "infer", + logger, + panicThreshold: "none", + target: "19", + }, + ], + ], + }); + } catch (error) { + failures.push({ + description: error instanceof Error ? error.message : "React Compiler extraction failed", + location: getDefaultLocation(filePath), + }); + } + return { functions, failures }; +}; + +export const extractReactCompilerGraph = ( + sourceFiles: ReadonlyArray<{ fileName: string; text: string }>, + rootDirectory: string, +): ReactCompilerGraph => { + const functions: ReactCompilerFunctionFact[] = []; + const failures: ReactCompilerFailure[] = []; + for (const sourceFile of sourceFiles) { + const filePath = path.relative(rootDirectory, sourceFile.fileName); + const sourceFacts = extractSourceCompilerFacts(sourceFile.text, filePath); + functions.push(...sourceFacts.functions); + failures.push(...sourceFacts.failures); + } + return { + version: REACT_COMPILER_VERSION, + phase: REACT_COMPILER_FACT_PHASE, + status: + failures.length > 0 ? ReactCompilerFactStatus.Incomplete : ReactCompilerFactStatus.Complete, + functions, + failures, + }; +}; diff --git a/packages/prover/src/find-function-by-location.ts b/packages/prover/src/find-function-by-location.ts new file mode 100644 index 000000000..74e63654f --- /dev/null +++ b/packages/prover/src/find-function-by-location.ts @@ -0,0 +1,32 @@ +import ts from "typescript"; +import { getNodeLocation } from "./get-node-location.js"; +import { isFunctionBoundary } from "./is-function-boundary.js"; +import type { ReactProofLocation } from "./types.js"; + +export const findFunctionByLocation = ( + program: ts.Program, + rootDirectory: string, + location: ReactProofLocation, +): ts.FunctionLikeDeclaration | null => { + let matchingFunction: ts.FunctionLikeDeclaration | null = null; + const visit = (node: ts.Node): void => { + if (matchingFunction) return; + if (isFunctionBoundary(node)) { + const nodeLocation = getNodeLocation(node, rootDirectory); + if ( + nodeLocation.filePath === location.filePath && + nodeLocation.line === location.line && + nodeLocation.column === location.column + ) { + matchingFunction = node; + return; + } + } + node.forEachChild(visit); + }; + for (const sourceFile of program.getSourceFiles()) { + sourceFile.forEachChild(visit); + if (matchingFunction) return matchingFunction; + } + return null; +}; diff --git a/packages/prover/src/find-semantic-unit.ts b/packages/prover/src/find-semantic-unit.ts new file mode 100644 index 000000000..ad0d2065f --- /dev/null +++ b/packages/prover/src/find-semantic-unit.ts @@ -0,0 +1,18 @@ +import { getNodeLocation } from "./get-node-location.js"; +import type { ReactAnalysisContext, ReactSemanticUnit, ReactUnitDescriptor } from "./types.js"; + +export const findSemanticUnit = ( + unit: ReactUnitDescriptor, + context: ReactAnalysisContext, +): ReactSemanticUnit | null => { + const unitLocation = getNodeLocation(unit.node, context.rootDirectory); + return ( + context.graph?.units.find( + (semanticUnit) => + semanticUnit.name === unit.name && + semanticUnit.location.filePath === unitLocation.filePath && + semanticUnit.location.line === unitLocation.line && + semanticUnit.location.column === unitLocation.column, + ) ?? null + ); +}; diff --git a/packages/prover/src/get-call-name.ts b/packages/prover/src/get-call-name.ts new file mode 100644 index 000000000..6be151d79 --- /dev/null +++ b/packages/prover/src/get-call-name.ts @@ -0,0 +1,21 @@ +import ts from "typescript"; + +const getExpressionName = (expression: ts.Expression): string | null => { + if (ts.isIdentifier(expression)) return expression.text; + if (ts.isPropertyAccessExpression(expression)) { + const ownerName = getExpressionName(expression.expression); + return ownerName ? `${ownerName}.${expression.name.text}` : expression.name.text; + } + if ( + ts.isElementAccessExpression(expression) && + expression.argumentExpression && + ts.isStringLiteral(expression.argumentExpression) + ) { + const ownerName = getExpressionName(expression.expression); + return ownerName ? `${ownerName}.${expression.argumentExpression.text}` : null; + } + return null; +}; + +export const getCallName = (callExpression: ts.CallExpression): string | null => + getExpressionName(callExpression.expression); diff --git a/packages/prover/src/get-canonical-hook-name.ts b/packages/prover/src/get-canonical-hook-name.ts new file mode 100644 index 000000000..d81df6a98 --- /dev/null +++ b/packages/prover/src/get-canonical-hook-name.ts @@ -0,0 +1,47 @@ +import ts from "typescript"; +import { getCallName } from "./get-call-name.js"; +import { getCanonicalReactApiName } from "./get-canonical-react-api-name.js"; + +const resolveCallableName = ( + expression: ts.Expression, + typeChecker: ts.TypeChecker, + visitedSymbols: Set, +): string | null => { + const directSymbol = typeChecker.getSymbolAtLocation(expression); + const symbol = + directSymbol && (directSymbol.flags & ts.SymbolFlags.Alias) !== 0 + ? typeChecker.getAliasedSymbol(directSymbol) + : directSymbol; + if (!symbol || visitedSymbols.has(symbol)) return null; + visitedSymbols.add(symbol); + if (symbol.name.startsWith("use")) return symbol.name; + for (const declaration of symbol.declarations ?? []) { + if ( + ts.isVariableDeclaration(declaration) && + declaration.initializer && + (ts.isIdentifier(declaration.initializer) || + ts.isPropertyAccessExpression(declaration.initializer) || + ts.isElementAccessExpression(declaration.initializer)) + ) { + const initializerName = resolveCallableName( + declaration.initializer, + typeChecker, + visitedSymbols, + ); + if (initializerName) return initializerName; + } + } + return symbol.name; +}; + +export const getCanonicalHookName = ( + callExpression: ts.CallExpression, + typeChecker: ts.TypeChecker, +): string | null => { + const syntaxName = getCallName(callExpression)?.split(".").at(-1) ?? null; + const resolvedName = + getCanonicalReactApiName(callExpression.expression, typeChecker) ?? + resolveCallableName(callExpression.expression, typeChecker, new Set()); + if (resolvedName?.startsWith("use")) return resolvedName; + return syntaxName; +}; diff --git a/packages/prover/src/get-canonical-react-api-name.ts b/packages/prover/src/get-canonical-react-api-name.ts new file mode 100644 index 000000000..f0872558e --- /dev/null +++ b/packages/prover/src/get-canonical-react-api-name.ts @@ -0,0 +1,67 @@ +import ts from "typescript"; +import { REACT_RUNTIME_MODULE_NAMES } from "./constants.js"; + +const getImportDeclaration = (node: ts.Node): ts.ImportDeclaration | null => { + let currentNode: ts.Node | undefined = node; + while (currentNode) { + if (ts.isImportDeclaration(currentNode)) return currentNode; + currentNode = currentNode.parent; + } + return null; +}; + +const getImportedName = (declaration: ts.Declaration): string | null => { + if (!ts.isImportSpecifier(declaration)) return null; + return (declaration.propertyName ?? declaration.name).text; +}; + +const isReactImport = (declaration: ts.Declaration): boolean => { + const importDeclaration = getImportDeclaration(declaration); + return Boolean( + importDeclaration && + ts.isStringLiteral(importDeclaration.moduleSpecifier) && + REACT_RUNTIME_MODULE_NAMES.has(importDeclaration.moduleSpecifier.text), + ); +}; + +const resolveReactApiName = ( + expression: ts.Expression, + typeChecker: ts.TypeChecker, + visitedSymbols: Set, +): string | null => { + if (ts.isPropertyAccessExpression(expression)) { + const namespaceSymbol = typeChecker.getSymbolAtLocation(expression.expression); + if (namespaceSymbol?.declarations?.some(isReactImport)) return expression.name.text; + } + + const symbol = typeChecker.getSymbolAtLocation(expression); + if (!symbol || visitedSymbols.has(symbol)) return null; + visitedSymbols.add(symbol); + + const importedName = symbol.declarations + ?.filter(isReactImport) + .map(getImportedName) + .find((name): name is string => name !== null); + if (importedName) return importedName; + + for (const declaration of symbol.declarations ?? []) { + if ( + ts.isVariableDeclaration(declaration) && + declaration.initializer && + ts.isExpression(declaration.initializer) + ) { + const initializerName = resolveReactApiName( + declaration.initializer, + typeChecker, + visitedSymbols, + ); + if (initializerName) return initializerName; + } + } + return null; +}; + +export const getCanonicalReactApiName = ( + expression: ts.Expression, + typeChecker: ts.TypeChecker, +): string | null => resolveReactApiName(expression, typeChecker, new Set()); diff --git a/packages/prover/src/get-component-prop-name.ts b/packages/prover/src/get-component-prop-name.ts new file mode 100644 index 000000000..81d68f4d8 --- /dev/null +++ b/packages/prover/src/get-component-prop-name.ts @@ -0,0 +1,65 @@ +import ts from "typescript"; + +const getDestructuredPropName = ( + identifier: ts.Identifier, + functionNode: ts.FunctionLikeDeclaration, + typeChecker: ts.TypeChecker, +): string | null => { + const identifierSymbol = typeChecker.getSymbolAtLocation(identifier); + if (!identifierSymbol) return null; + for (const parameter of functionNode.parameters) { + if (!ts.isObjectBindingPattern(parameter.name)) continue; + for (const bindingElement of parameter.name.elements) { + if ( + !ts.isIdentifier(bindingElement.name) || + typeChecker.getSymbolAtLocation(bindingElement.name) !== identifierSymbol + ) { + continue; + } + const propertyName = bindingElement.propertyName; + if (!propertyName) return bindingElement.name.text; + if (ts.isIdentifier(propertyName) || ts.isStringLiteral(propertyName)) { + return propertyName.text; + } + return null; + } + } + return null; +}; + +const getObjectParameterPropName = ( + expression: ts.ElementAccessExpression | ts.PropertyAccessExpression, + functionNode: ts.FunctionLikeDeclaration, + typeChecker: ts.TypeChecker, +): string | null => { + if (!ts.isIdentifier(expression.expression)) return null; + const expressionSymbol = typeChecker.getSymbolAtLocation(expression.expression); + if (!expressionSymbol) return null; + const isObjectParameter = functionNode.parameters.some( + (parameter) => + ts.isIdentifier(parameter.name) && + typeChecker.getSymbolAtLocation(parameter.name) === expressionSymbol, + ); + if (!isObjectParameter) return null; + if (ts.isPropertyAccessExpression(expression)) return expression.name.text; + const argumentExpression = expression.argumentExpression; + return argumentExpression && + (ts.isStringLiteral(argumentExpression) || + ts.isNoSubstitutionTemplateLiteral(argumentExpression)) + ? argumentExpression.text + : null; +}; + +export const getComponentPropName = ( + expression: ts.Expression, + functionNode: ts.FunctionLikeDeclaration, + typeChecker: ts.TypeChecker, +): string | null => { + if (ts.isIdentifier(expression)) { + return getDestructuredPropName(expression, functionNode, typeChecker); + } + if (ts.isPropertyAccessExpression(expression) || ts.isElementAccessExpression(expression)) { + return getObjectParameterPropName(expression, functionNode, typeChecker); + } + return null; +}; diff --git a/packages/prover/src/get-effect-callback.ts b/packages/prover/src/get-effect-callback.ts new file mode 100644 index 000000000..d11155cb8 --- /dev/null +++ b/packages/prover/src/get-effect-callback.ts @@ -0,0 +1,35 @@ +import ts from "typescript"; + +export const getEffectCallback = ( + callExpression: ts.CallExpression, + typeChecker: ts.TypeChecker, +): ts.FunctionLikeDeclaration | null => { + const callbackExpression = callExpression.arguments[0]; + if (!callbackExpression) return null; + if (ts.isFunctionExpression(callbackExpression) || ts.isArrowFunction(callbackExpression)) { + return callbackExpression; + } + const callbackSymbol = typeChecker.getSymbolAtLocation(callbackExpression); + const resolvedSymbol = + callbackSymbol && (callbackSymbol.flags & ts.SymbolFlags.Alias) !== 0 + ? typeChecker.getAliasedSymbol(callbackSymbol) + : callbackSymbol; + for (const declaration of resolvedSymbol?.declarations ?? []) { + if ( + ts.isFunctionDeclaration(declaration) || + ts.isFunctionExpression(declaration) || + ts.isArrowFunction(declaration) + ) { + return declaration; + } + if ( + ts.isVariableDeclaration(declaration) && + declaration.initializer && + (ts.isFunctionExpression(declaration.initializer) || + ts.isArrowFunction(declaration.initializer)) + ) { + return declaration.initializer; + } + } + return null; +}; diff --git a/packages/prover/src/get-for-of-binding-descriptor.ts b/packages/prover/src/get-for-of-binding-descriptor.ts new file mode 100644 index 000000000..b45188676 --- /dev/null +++ b/packages/prover/src/get-for-of-binding-descriptor.ts @@ -0,0 +1,93 @@ +import ts from "typescript"; + +export interface ForOfBindingDescriptor { + forOfStatement: ts.ForOfStatement; + isComplete: boolean; + propertyPath: ReadonlyArray; + variableDeclaration: ts.VariableDeclaration; +} + +const isBindingPatternComplete = (bindingPattern: ts.BindingPattern): boolean => + bindingPattern.elements.every((bindingElement) => { + if (ts.isOmittedExpression(bindingElement)) return true; + if (bindingElement.dotDotDotToken || bindingElement.initializer) return false; + if ( + ts.isObjectBindingPattern(bindingPattern) && + bindingElement.propertyName && + !ts.isIdentifier(bindingElement.propertyName) && + !ts.isStringLiteral(bindingElement.propertyName) && + !ts.isNumericLiteral(bindingElement.propertyName) + ) { + return false; + } + return true; + }); + +const getObjectBindingPropertyName = (bindingElement: ts.BindingElement): string | null => { + const propertyName = bindingElement.propertyName ?? bindingElement.name; + return ts.isIdentifier(propertyName) || + ts.isStringLiteral(propertyName) || + ts.isNumericLiteral(propertyName) + ? propertyName.text + : null; +}; + +const createForOfBindingDescriptor = ( + variableDeclaration: ts.VariableDeclaration, + propertyPath: ReadonlyArray, + isComplete: boolean, +): ForOfBindingDescriptor | null => { + if ( + !ts.isVariableDeclarationList(variableDeclaration.parent) || + !ts.isForOfStatement(variableDeclaration.parent.parent) + ) { + return null; + } + return { + forOfStatement: variableDeclaration.parent.parent, + isComplete, + propertyPath, + variableDeclaration, + }; +}; + +export const getForOfBindingDescriptor = ( + declaration: ts.Declaration, +): ForOfBindingDescriptor | null => { + if (ts.isVariableDeclaration(declaration)) { + return ts.isIdentifier(declaration.name) + ? createForOfBindingDescriptor(declaration, [], true) + : null; + } + if (!ts.isBindingElement(declaration)) return null; + + const propertyPath: string[] = []; + let isComplete = true; + let currentBindingElement = declaration; + while (true) { + const bindingPattern = currentBindingElement.parent; + isComplete = isComplete && isBindingPatternComplete(bindingPattern); + if (ts.isObjectBindingPattern(bindingPattern)) { + const propertyName = getObjectBindingPropertyName(currentBindingElement); + if (propertyName) { + propertyPath.unshift(propertyName); + } else { + isComplete = false; + } + } else { + const elementIndex = bindingPattern.elements.indexOf(currentBindingElement); + if (elementIndex < 0) { + isComplete = false; + } else { + propertyPath.unshift(String(elementIndex)); + } + } + + const parentDeclaration = bindingPattern.parent; + if (ts.isVariableDeclaration(parentDeclaration)) { + return createForOfBindingDescriptor(parentDeclaration, propertyPath, isComplete); + } + if (!ts.isBindingElement(parentDeclaration)) return null; + currentBindingElement = parentDeclaration; + } +}; diff --git a/packages/prover/src/get-function-name.ts b/packages/prover/src/get-function-name.ts new file mode 100644 index 000000000..af00ca287 --- /dev/null +++ b/packages/prover/src/get-function-name.ts @@ -0,0 +1,36 @@ +import ts from "typescript"; +import { getCallName } from "./get-call-name.js"; + +export const getFunctionName = (functionNode: ts.FunctionLikeDeclaration): string | null => { + if (functionNode.name && ts.isIdentifier(functionNode.name)) return functionNode.name.text; + if ( + ts.isMethodDeclaration(functionNode) && + (ts.isIdentifier(functionNode.name) || ts.isStringLiteral(functionNode.name)) + ) { + return functionNode.name.text; + } + if (ts.isVariableDeclaration(functionNode.parent) && ts.isIdentifier(functionNode.parent.name)) { + return functionNode.parent.name.text; + } + if ( + ts.isPropertyAssignment(functionNode.parent) && + (ts.isIdentifier(functionNode.parent.name) || ts.isStringLiteral(functionNode.parent.name)) + ) { + return functionNode.parent.name.text; + } + if (ts.isCallExpression(functionNode.parent)) { + const wrapperName = getCallName(functionNode.parent)?.split(".").at(-1); + const wrapperOwner = functionNode.parent.parent; + if ( + (wrapperName === "memo" || wrapperName === "forwardRef") && + ts.isVariableDeclaration(wrapperOwner) && + ts.isIdentifier(wrapperOwner.name) + ) { + return wrapperOwner.name.text; + } + } + if (ts.isExportAssignment(functionNode.parent) && !functionNode.parent.isExportEquals) { + return "DefaultComponent"; + } + return null; +}; diff --git a/packages/prover/src/get-node-location.ts b/packages/prover/src/get-node-location.ts new file mode 100644 index 000000000..36466e4fe --- /dev/null +++ b/packages/prover/src/get-node-location.ts @@ -0,0 +1,13 @@ +import * as path from "node:path"; +import type ts from "typescript"; +import type { ReactProofLocation } from "./types.js"; + +export const getNodeLocation = (node: ts.Node, rootDirectory: string): ReactProofLocation => { + const sourceFile = node.getSourceFile(); + const sourcePosition = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)); + return { + filePath: path.relative(rootDirectory, sourceFile.fileName), + line: sourcePosition.line + 1, + column: sourcePosition.character + 1, + }; +}; diff --git a/packages/prover/src/get-root-identifier.ts b/packages/prover/src/get-root-identifier.ts new file mode 100644 index 000000000..c098dc03a --- /dev/null +++ b/packages/prover/src/get-root-identifier.ts @@ -0,0 +1,9 @@ +import ts from "typescript"; + +export const getRootIdentifier = (expression: ts.Expression): ts.Identifier | null => { + if (ts.isIdentifier(expression)) return expression; + if (ts.isPropertyAccessExpression(expression) || ts.isElementAccessExpression(expression)) { + return getRootIdentifier(expression.expression); + } + return null; +}; diff --git a/packages/prover/src/get-static-boolean-value.ts b/packages/prover/src/get-static-boolean-value.ts new file mode 100644 index 000000000..1591e526a --- /dev/null +++ b/packages/prover/src/get-static-boolean-value.ts @@ -0,0 +1,16 @@ +import ts from "typescript"; +import { unwrapTypescriptExpression } from "./unwrap-typescript-expression.js"; + +export const getStaticBooleanValue = (expression: ts.Expression): boolean | null => { + const unwrappedExpression = unwrapTypescriptExpression(expression); + if (unwrappedExpression.kind === ts.SyntaxKind.TrueKeyword) return true; + if (unwrappedExpression.kind === ts.SyntaxKind.FalseKeyword) return false; + if ( + ts.isPrefixUnaryExpression(unwrappedExpression) && + unwrappedExpression.operator === ts.SyntaxKind.ExclamationToken + ) { + const argumentValue = getStaticBooleanValue(unwrappedExpression.operand); + return argumentValue === null ? null : !argumentValue; + } + return null; +}; diff --git a/packages/prover/src/index.ts b/packages/prover/src/index.ts new file mode 100644 index 000000000..985e1e1a1 --- /dev/null +++ b/packages/prover/src/index.ts @@ -0,0 +1,144 @@ +export { proveReactApp } from "./prove-react-app.js"; +export { checkReactProofReport } from "./check-react-proof-report.js"; +export { + ReactActionStateDispatchKind, + ReactActionStateDispatchStatus, + ReactActionStateReducerStatus, + ReactAppProofStatus, + ReactAsyncOwnershipStatus, + ReactCallableRefFreshness, + ReactClassComponentBase, + ReactClassConstructionIssueKind, + ReactClassConstructionIssueStatus, + ReactClassConstructionStatus, + ReactClassStateInitializationKind, + ReactClassStateInitializationRequirement, + ReactClassStateUpdaterStatus, + ReactClassStateWriteKind, + ReactClassStateWriteStatus, + ReactClassUpdateCycleStatus, + ReactCompilerFactStatus, + ReactEffectDependencyMode, + ReactEffectResourceDisposalStatus, + ReactEffectResourceKind, + ReactErrorBoundaryCoverageStatus, + ReactErrorBoundaryProtocolStatus, + ReactExecutionPhase, + ReactFormActionKind, + ReactFormActionStatus, + ReactFormStatusTopologyStatus, + ReactHostControlKind, + ReactHostControlMutabilityStatus, + ReactHostControlStatus, + ReactHostControlUpdateStatus, + ReactHostControlValueStatus, + ReactHookStateUpdaterStatus, + ReactHydrationHazardKind, + ReactHydrationPrefixStatus, + ReactHydrationRootKind, + ReactHydrationRootExecutionStatus, + ReactHydrationStatus, + ReactIdentityStability, + ReactImperativeHandleRefKind, + ReactImperativeHandleStatus, + ReactLazyDeclarationStatus, + ReactLazyLoaderStatus, + ReactMemoComparatorKind, + ReactMemoComparatorStatus, + ReactObligationStatus, + ReactOptimisticActionStatus, + ReactOptimisticReducerStatus, + ReactProofCertificateStatus, + ReactProofClaim, + ReactReducerDispatchKind, + ReactReducerDispatchStatus, + ReactReducerPurityStatus, + ReactReducerReturnStatus, + ReactRenderFailureKind, + ReactSchedulerCancellationStatus, + ReactSchedulerKind, + ReactSemanticRenderKind, + ReactSemanticEdgeKind, + ReactSemanticCallbackKind, + ReactSemanticFunctionCallKind, + ReactSuspenseCoverageStatus, + ReactTransitionActionStatus, + ReactTransitionStarterKind, + ReactUnitKind, + ReactUseResourceIdentityStatus, + ReactUseResourceKind, +} from "./types.js"; +export type { + ProveReactAppInput, + ReactAppProofReport, + ReactCompilerBlockFact, + ReactCompilerFailure, + ReactCompilerFunctionFact, + ReactCompilerGraph, + ReactCompilerInstructionFact, + ReactProofEvidence, + ReactProofCertificateCheck, + ReactProofCertificateFailure, + ReactProofLocation, + ReactProofObligation, + ReactProofSummary, + ReactSemanticEdge, + ReactSemanticActionState, + ReactSemanticActionStateDispatch, + ReactSemanticContext, + ReactSemanticContextConsumer, + ReactSemanticContextProvider, + ReactSemanticEffect, + ReactSemanticEffectEvent, + ReactSemanticEffectResource, + ReactSemanticErrorBoundary, + ReactSemanticErrorBoundaryDefinition, + ReactSemanticEventBinding, + ReactSemanticCallbackGuard, + ReactSemanticCallbackPropAlternative, + ReactSemanticCallbackPropFlow, + ReactSemanticCallableRef, + ReactSemanticClassConstruction, + ReactSemanticClassConstructionIssue, + ReactSemanticClassLifecycle, + ReactSemanticClassStateWrite, + ReactSemanticClassStateTransition, + ReactSemanticExternalStore, + ReactSemanticFormAction, + ReactSemanticForm, + ReactSemanticFormStatus, + ReactSemanticCallback, + ReactSemanticAsyncTask, + ReactSemanticGraph, + ReactSemanticFunctionCall, + ReactSemanticHookCall, + ReactSemanticHookStateTransition, + ReactSemanticHostControl, + ReactSemanticHydration, + ReactSemanticHydrationHazard, + ReactSemanticHydrationRoot, + ReactSemanticImperativeHandle, + ReactSemanticImperativeHandleBinding, + ReactSemanticImperativeHandleInvocation, + ReactSemanticImperativeHandleMethod, + ReactSemanticLazyComponent, + ReactSemanticLazyRender, + ReactSemanticMemoComparator, + ReactSemanticMemoComparatorTruePath, + ReactSemanticMemoPropObservation, + ReactSemanticOptimisticState, + ReactSemanticOptimisticUpdate, + ReactSemanticReducer, + ReactSemanticReducerDispatch, + ReactSemanticTransitionAction, + ReactSemanticReachableFunction, + ReactSemanticRender, + ReactSemanticRenderFailure, + ReactSemanticSlotFlow, + ReactSemanticScheduler, + ReactSemanticSuspenseBoundary, + ReactSemanticUnit, + ReactSemanticUseResource, + ReactAsyncEffectTaskDescriptor, + ReactUnitProof, +} from "./types.js"; diff --git a/packages/prover/src/is-component-prop-expression.ts b/packages/prover/src/is-component-prop-expression.ts new file mode 100644 index 000000000..e06c1cb68 --- /dev/null +++ b/packages/prover/src/is-component-prop-expression.ts @@ -0,0 +1,24 @@ +import ts from "typescript"; +import { getRootIdentifier } from "./get-root-identifier.js"; + +export const isComponentPropExpression = ( + expression: ts.Expression, + functionNode: ts.FunctionLikeDeclaration, + typeChecker: ts.TypeChecker, +): boolean => { + const rootIdentifier = getRootIdentifier(expression); + const symbol = rootIdentifier ? typeChecker.getSymbolAtLocation(rootIdentifier) : null; + return Boolean( + symbol?.declarations?.some((declaration) => { + let currentNode: ts.Node = declaration; + while (currentNode !== functionNode) { + if (ts.isParameter(currentNode)) { + return functionNode.parameters.includes(currentNode); + } + if (!currentNode.parent) return false; + currentNode = currentNode.parent; + } + return false; + }), + ); +}; diff --git a/packages/prover/src/is-function-boundary.ts b/packages/prover/src/is-function-boundary.ts new file mode 100644 index 000000000..e1b223e12 --- /dev/null +++ b/packages/prover/src/is-function-boundary.ts @@ -0,0 +1,10 @@ +import ts from "typescript"; + +export const isFunctionBoundary = (node: ts.Node): node is ts.FunctionLikeDeclaration => + ts.isFunctionDeclaration(node) || + ts.isFunctionExpression(node) || + ts.isArrowFunction(node) || + ts.isMethodDeclaration(node) || + ts.isConstructorDeclaration(node) || + ts.isGetAccessorDeclaration(node) || + ts.isSetAccessorDeclaration(node); diff --git a/packages/prover/src/is-guaranteed-state-change.ts b/packages/prover/src/is-guaranteed-state-change.ts new file mode 100644 index 000000000..4edd39b70 --- /dev/null +++ b/packages/prover/src/is-guaranteed-state-change.ts @@ -0,0 +1,69 @@ +import ts from "typescript"; +import { unwrapTypescriptExpression } from "./unwrap-typescript-expression.js"; + +export interface GuaranteedStateChangeInput { + callExpression: ts.CallExpression; + stateSymbol: ts.Symbol; + typeChecker: ts.TypeChecker; +} + +const getReturnedExpression = ( + functionNode: ts.ArrowFunction | ts.FunctionExpression, +): ts.Expression | null => { + if (!ts.isBlock(functionNode.body)) return functionNode.body; + if ( + functionNode.body.statements.length !== 1 || + !ts.isReturnStatement(functionNode.body.statements[0]) || + !functionNode.body.statements[0].expression + ) { + return null; + } + return functionNode.body.statements[0].expression; +}; + +const isFreshReference = (expression: ts.Expression): boolean => { + const unwrappedExpression = unwrapTypescriptExpression(expression); + return ( + ts.isArrayLiteralExpression(unwrappedExpression) || + ts.isObjectLiteralExpression(unwrappedExpression) || + ts.isNewExpression(unwrappedExpression) + ); +}; + +const isBooleanNegationOfSymbol = ( + expression: ts.Expression, + expectedSymbol: ts.Symbol, + typeChecker: ts.TypeChecker, +): boolean => { + const unwrappedExpression = unwrapTypescriptExpression(expression); + return ( + ts.isPrefixUnaryExpression(unwrappedExpression) && + unwrappedExpression.operator === ts.SyntaxKind.ExclamationToken && + typeChecker.getSymbolAtLocation(unwrapTypescriptExpression(unwrappedExpression.operand)) === + expectedSymbol + ); +}; + +export const isGuaranteedStateChange = ({ + callExpression, + stateSymbol, + typeChecker, +}: GuaranteedStateChangeInput): boolean => { + const updateExpression = callExpression.arguments[0]; + if (!updateExpression) return false; + const unwrappedUpdate = unwrapTypescriptExpression(updateExpression); + if (isFreshReference(unwrappedUpdate)) return true; + if (isBooleanNegationOfSymbol(unwrappedUpdate, stateSymbol, typeChecker)) return true; + if (!ts.isArrowFunction(unwrappedUpdate) && !ts.isFunctionExpression(unwrappedUpdate)) { + return false; + } + const parameter = unwrappedUpdate.parameters[0]; + if (!parameter || !ts.isIdentifier(parameter.name)) return false; + const parameterSymbol = typeChecker.getSymbolAtLocation(parameter.name); + const returnedExpression = getReturnedExpression(unwrappedUpdate); + if (!parameterSymbol || !returnedExpression) return false; + return ( + isFreshReference(returnedExpression) || + isBooleanNegationOfSymbol(returnedExpression, parameterSymbol, typeChecker) + ); +}; diff --git a/packages/prover/src/is-identifier-reference.ts b/packages/prover/src/is-identifier-reference.ts new file mode 100644 index 000000000..d71e456cc --- /dev/null +++ b/packages/prover/src/is-identifier-reference.ts @@ -0,0 +1,25 @@ +import ts from "typescript"; + +export const isIdentifierReference = (identifier: ts.Identifier): boolean => { + const parentNode = identifier.parent; + if ( + (ts.isPropertyAccessExpression(parentNode) && parentNode.name === identifier) || + (ts.isPropertyAssignment(parentNode) && parentNode.name === identifier) || + (ts.isMethodDeclaration(parentNode) && parentNode.name === identifier) || + (ts.isPropertyDeclaration(parentNode) && parentNode.name === identifier) || + (ts.isVariableDeclaration(parentNode) && parentNode.name === identifier) || + (ts.isParameter(parentNode) && parentNode.name === identifier) || + (ts.isFunctionDeclaration(parentNode) && parentNode.name === identifier) || + (ts.isFunctionExpression(parentNode) && parentNode.name === identifier) || + ts.isImportClause(parentNode) || + ts.isImportSpecifier(parentNode) || + ts.isNamespaceImport(parentNode) || + ts.isBindingElement(parentNode) || + ts.isTypeReferenceNode(parentNode) || + ts.isTypeQueryNode(parentNode) || + ts.isJsxAttribute(parentNode) + ) { + return false; + } + return true; +}; diff --git a/packages/prover/src/is-node-within.ts b/packages/prover/src/is-node-within.ts new file mode 100644 index 000000000..cf387253e --- /dev/null +++ b/packages/prover/src/is-node-within.ts @@ -0,0 +1,6 @@ +import type ts from "typescript"; + +export const isNodeWithin = (node: ts.Node, owner: ts.Node): boolean => + node.getSourceFile() === owner.getSourceFile() && + node.getStart() >= owner.getStart() && + node.getEnd() <= owner.getEnd(); diff --git a/packages/prover/src/is-react-context-expression.ts b/packages/prover/src/is-react-context-expression.ts new file mode 100644 index 000000000..e4cc3c873 --- /dev/null +++ b/packages/prover/src/is-react-context-expression.ts @@ -0,0 +1,14 @@ +import ts from "typescript"; + +const isReactContextType = (valueType: ts.Type): boolean => { + if (valueType.isUnionOrIntersection()) { + return valueType.types.every(isReactContextType); + } + const symbol = valueType.aliasSymbol ?? valueType.getSymbol(); + return symbol?.name === "Context"; +}; + +export const isReactContextExpression = ( + expression: ts.Expression, + typeChecker: ts.TypeChecker, +): boolean => isReactContextType(typeChecker.getTypeAtLocation(expression)); diff --git a/packages/prover/src/is-react-hook-name.ts b/packages/prover/src/is-react-hook-name.ts new file mode 100644 index 000000000..eaf6ae9ea --- /dev/null +++ b/packages/prover/src/is-react-hook-name.ts @@ -0,0 +1,2 @@ +export const isReactHookName = (name: string): boolean => + name === "use" || /^use[A-Z0-9]/.test(name); diff --git a/packages/prover/src/prove-react-app.ts b/packages/prover/src/prove-react-app.ts new file mode 100644 index 000000000..98b04922f --- /dev/null +++ b/packages/prover/src/prove-react-app.ts @@ -0,0 +1,109 @@ +import * as path from "node:path"; +import { + FIRST_SOURCE_COLUMN, + FIRST_SOURCE_LINE, + REACT_COMPILER_FACT_PHASE, + REACT_COMPILER_VERSION, + REACT_PROOF_SCHEMA_VERSION, + REACT_SEMANTIC_GRAPH_SCHEMA_VERSION, +} from "./constants.js"; +import { createTypeScriptProject } from "./create-typescript-project.js"; +import { proveReactProgram } from "./prove-react-program.js"; +import { ReactAppProofStatus, ReactCompilerFactStatus } from "./types.js"; +import type { ProveReactAppInput, ReactAppProofReport } from "./types.js"; + +export const proveReactApp = (input: ProveReactAppInput): ReactAppProofReport => { + const rootDirectory = path.resolve(input.rootDirectory); + const project = createTypeScriptProject(rootDirectory, input.tsconfigPath); + if (project.program) { + return proveReactProgram(project.program, rootDirectory, project.evidence); + } + return { + schemaVersion: REACT_PROOF_SCHEMA_VERSION, + status: ReactAppProofStatus.Incomplete, + rootDirectory, + graph: { + schemaVersion: REACT_SEMANTIC_GRAPH_SCHEMA_VERSION, + actionStates: [], + actionStateDispatches: [], + units: [], + edges: [], + hookCalls: [], + effects: [], + effectEvents: [], + externalStores: [], + asyncTasks: [], + contexts: [], + contextProviders: [], + contextConsumers: [], + errorBoundaryDefinitions: [], + errorBoundaries: [], + renderFailures: [], + useResources: [], + hydrationRoots: [], + hydrationHazards: [], + hydrations: [], + hostControls: [], + suspenseBoundaries: [], + lazyComponents: [], + lazyRenders: [], + renders: [], + slotFlows: [], + callbacks: [], + reachableFunctions: [], + functionCalls: [], + eventBindings: [], + callbackPropFlows: [], + callableRefs: [], + memoComparators: [], + imperativeHandles: [], + imperativeHandleMethods: [], + imperativeHandleBindings: [], + imperativeHandleInvocations: [], + schedulers: [], + resources: [], + classConstructions: [], + classLifecycles: [], + classStateWrites: [], + classStateTransitions: [], + formActions: [], + forms: [], + formStatuses: [], + hookStateTransitions: [], + reducers: [], + reducerDispatches: [], + optimisticStates: [], + optimisticUpdates: [], + transitionActions: [], + compiler: { + version: REACT_COMPILER_VERSION, + phase: REACT_COMPILER_FACT_PHASE, + status: ReactCompilerFactStatus.Incomplete, + functions: [], + failures: [], + }, + }, + units: [], + projectEvidence: + project.evidence.length > 0 + ? project.evidence + : [ + { + description: "The TypeScript program could not be constructed", + location: { + filePath: "tsconfig.json", + line: FIRST_SOURCE_LINE, + column: FIRST_SOURCE_COLUMN, + }, + trace: ["project", "TypeScript program", "React proof"], + }, + ], + summary: { + files: 0, + units: 0, + proved: 0, + violated: 0, + unknown: 0, + }, + }; +}; diff --git a/packages/prover/src/prove-react-program.ts b/packages/prover/src/prove-react-program.ts new file mode 100644 index 000000000..bbc5442cd --- /dev/null +++ b/packages/prover/src/prove-react-program.ts @@ -0,0 +1,200 @@ +import * as path from "node:path"; +import ts from "typescript"; +import { FIRST_SOURCE_COLUMN, FIRST_SOURCE_LINE, REACT_PROOF_SCHEMA_VERSION } from "./constants.js"; +import { analyzeReactUnit } from "./analyze-react-unit.js"; +import { buildReactSemanticGraph } from "./build-react-semantic-graph.js"; +import { checkReactProofReport } from "./check-react-proof-report.js"; +import { collectProjectSoundnessEvidence } from "./collect-project-soundness-evidence.js"; +import { collectReactUnits } from "./collect-react-units.js"; +import { + ReactAppProofStatus, + ReactObligationStatus, + ReactProofCertificateStatus, +} from "./types.js"; +import type { + ReactAnalysisContext, + ReactAppProofReport, + ReactProofEvidence, + ReactProofSummary, +} from "./types.js"; + +const isProjectSourceFile = (sourceFile: ts.SourceFile, rootDirectory: string): boolean => { + const relativePath = path.relative(rootDirectory, sourceFile.fileName); + return ( + !sourceFile.isDeclarationFile && + !relativePath.startsWith("..") && + !relativePath.split(path.sep).includes("node_modules") + ); +}; + +const createDiagnosticEvidence = ( + diagnostic: ts.Diagnostic, + rootDirectory: string, +): ReactProofEvidence => { + if (diagnostic.file && diagnostic.start !== undefined) { + const sourcePosition = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start); + return { + description: ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"), + location: { + filePath: path.relative(rootDirectory, diagnostic.file.fileName), + line: sourcePosition.line + 1, + column: sourcePosition.character + 1, + }, + trace: ["TypeScript diagnostic", "incomplete program model", "React proof"], + }; + } + return { + description: ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"), + location: { + filePath: "tsconfig.json", + line: FIRST_SOURCE_LINE, + column: FIRST_SOURCE_COLUMN, + }, + trace: ["TypeScript diagnostic", "incomplete program model", "React proof"], + }; +}; + +const buildSummary = ( + files: number, + obligations: ReadonlyArray<{ status: ReactObligationStatus }>, + unitCount: number, +): ReactProofSummary => ({ + files, + units: unitCount, + proved: obligations.filter((obligation) => obligation.status === ReactObligationStatus.Proved) + .length, + violated: obligations.filter((obligation) => obligation.status === ReactObligationStatus.Violated) + .length, + unknown: obligations.filter((obligation) => obligation.status === ReactObligationStatus.Unknown) + .length, +}); + +const resolveAppProofStatus = ( + obligations: ReadonlyArray<{ status: ReactObligationStatus }>, + projectEvidence: ReadonlyArray, +): ReactAppProofStatus => { + if (obligations.some((obligation) => obligation.status === ReactObligationStatus.Violated)) { + return ReactAppProofStatus.Refuted; + } + if ( + projectEvidence.length > 0 || + obligations.some((obligation) => obligation.status === ReactObligationStatus.Unknown) + ) { + return ReactAppProofStatus.Incomplete; + } + return ReactAppProofStatus.Proved; +}; + +export const proveReactProgram = ( + program: ts.Program, + rootDirectory: string, + initialEvidence: ReadonlyArray = [], +): ReactAppProofReport => { + const typeChecker = program.getTypeChecker(); + const context: ReactAnalysisContext = { program, typeChecker, rootDirectory }; + const sourceFiles = program + .getSourceFiles() + .filter((sourceFile) => isProjectSourceFile(sourceFile, rootDirectory)); + const descriptors = sourceFiles.flatMap((sourceFile) => + collectReactUnits(sourceFile, typeChecker), + ); + const graph = buildReactSemanticGraph(descriptors, sourceFiles, context); + const analysisContext: ReactAnalysisContext = { ...context, graph }; + const units = []; + for (const descriptor of descriptors) { + units.push(analyzeReactUnit(descriptor, analysisContext)); + } + const diagnosticEvidence: ReactProofEvidence[] = []; + for (const diagnostic of ts.getPreEmitDiagnostics(program)) { + if (diagnostic.category !== ts.DiagnosticCategory.Error) continue; + diagnosticEvidence.push(createDiagnosticEvidence(diagnostic, rootDirectory)); + } + const compilerEvidence: ReactProofEvidence[] = graph.compiler.failures.map((failure) => ({ + description: `React Compiler could not produce complete proof facts: ${failure.description}`, + location: failure.location, + trace: ["React source", graph.compiler.phase, "incomplete semantic graph"], + })); + const lazyIdentityEvidence: ReactProofEvidence[] = []; + const exportedLazyEvidence: ReactProofEvidence[] = []; + for (const component of graph.lazyComponents) { + if (!component.identityResolved && !component.declarationOwnerId) { + lazyIdentityEvidence.push({ + description: `React.lazy declaration has no stable symbol identity: ${component.name}`, + location: component.location, + trace: ["React.lazy", "unsupported module declaration", "incomplete lazy render graph"], + }); + } + if (component.canBeRenderRoot) { + exportedLazyEvidence.push({ + description: `Exported lazy component has an open Suspense topology: ${component.name}`, + location: component.location, + trace: ["exported React.lazy", "external render root", "unknown Suspense boundary"], + }); + } + } + const unresolvedMemoEvidence: ReactProofEvidence[] = graph.memoComparators.flatMap((comparator) => + comparator.ownerId + ? [] + : [ + { + description: "React.memo has an unresolved component target", + location: comparator.location, + trace: [ + "React.memo", + "unresolved component identity", + "incomplete bailout equivalence", + ], + }, + ], + ); + const projectEvidence = [ + ...initialEvidence, + ...collectProjectSoundnessEvidence(program, sourceFiles, rootDirectory), + ...diagnosticEvidence, + ...compilerEvidence, + ...lazyIdentityEvidence, + ...exportedLazyEvidence, + ...unresolvedMemoEvidence, + ]; + if (units.length === 0) { + projectEvidence.push({ + description: "No React components or hooks were discovered", + location: { + filePath: "tsconfig.json", + line: FIRST_SOURCE_LINE, + column: FIRST_SOURCE_COLUMN, + }, + trace: ["TypeScript program", "React unit discovery", "empty proof scope"], + }); + } + const obligations = units.flatMap((unit) => unit.obligations); + + const report: ReactAppProofReport = { + schemaVersion: REACT_PROOF_SCHEMA_VERSION, + status: resolveAppProofStatus(obligations, projectEvidence), + rootDirectory, + graph, + units, + projectEvidence, + summary: buildSummary(sourceFiles.length, obligations, units.length), + }; + const certificate = checkReactProofReport(report); + if (certificate.status === ReactProofCertificateStatus.Valid) return report; + const certificateEvidence = certificate.failures.map( + (failure): ReactProofEvidence => ({ + description: `The proof certificate is internally inconsistent: ${failure.description}`, + location: { + filePath: "tsconfig.json", + line: FIRST_SOURCE_LINE, + column: FIRST_SOURCE_COLUMN, + }, + trace: ["proof report", failure.subjectId, "independent certificate checker"], + }), + ); + const checkedProjectEvidence = [...projectEvidence, ...certificateEvidence]; + return { + ...report, + status: resolveAppProofStatus(obligations, checkedProjectEvidence), + projectEvidence: checkedProjectEvidence, + }; +}; diff --git a/packages/prover/src/resolve-callable-expression.ts b/packages/prover/src/resolve-callable-expression.ts new file mode 100644 index 000000000..52401cc65 --- /dev/null +++ b/packages/prover/src/resolve-callable-expression.ts @@ -0,0 +1,815 @@ +import ts from "typescript"; +import { getCallableRefProtocolForCurrentAccess } from "./collect-callable-ref-protocols.js"; +import { getCanonicalReactApiName } from "./get-canonical-react-api-name.js"; +import { getForOfBindingDescriptor } from "./get-for-of-binding-descriptor.js"; +import { resolveFunction } from "./resolve-function.js"; +import { summarizeFunctionReturns } from "./summarize-function-returns.js"; +import { unwrapTypescriptExpression } from "./unwrap-typescript-expression.js"; +import { collectSymbolWrites } from "./utils/collect-symbol-writes.js"; + +export interface ResolvedCallableTargetDescriptor { + bindings: ReadonlyMap; + functionNode: ts.FunctionLikeDeclaration; + guards: ReadonlyArray; + isConditionallyReached: boolean; +} + +export interface ResolvedCallableGuardDescriptor { + conditionIdentity: string; + conditionNode: ts.Node; + isSubstituted: boolean; + polarity: boolean; +} + +export interface ResolvedCallableValueDescriptor { + isComplete: boolean; + properties: ReadonlyMap; + targets: ReadonlyArray; +} + +export interface CallableArgumentBindingsDescriptor { + bindings: ReadonlyMap; + guardBindings: ReadonlyMap; + isComplete: boolean; +} + +interface CallableResolutionState { + guardBindings: ReadonlyMap; + resolvingFunctions: ReadonlySet; + resolvingSymbols: ReadonlySet; +} + +const symbolWriteCache = new WeakMap(); + +const createEmptyCallableValue = (isComplete: boolean): ResolvedCallableValueDescriptor => ({ + isComplete, + properties: new Map(), + targets: [], +}); + +const getNodeIdentity = (node: ts.Node): string => + `${node.getSourceFile().fileName}:${node.getStart()}:${node.getEnd()}`; + +const getSymbolIdentity = (symbol: ts.Symbol): string => { + const declaration = symbol.declarations?.[0]; + return declaration ? getNodeIdentity(declaration) : symbol.getName(); +}; + +const getCallableGuardFingerprint = ( + guards: ReadonlyArray, +): string => + guards + .map( + (guard) => + `${guard.conditionIdentity}=${String(guard.polarity)}:${String(guard.isSubstituted)}`, + ) + .sort() + .join("&"); + +const getCallableBindingsFingerprintWithVisited = ( + bindings: ReadonlyMap, + visitedValues: Set, +): string => + [...bindings] + .map( + ([symbol, value]) => + `${getSymbolIdentity(symbol)}=${getCallableValueFingerprintWithVisited(value, visitedValues)}`, + ) + .sort() + .join(","); + +const getCallableValueFingerprintWithVisited = ( + value: ResolvedCallableValueDescriptor, + visitedValues: Set, +): string => { + if (visitedValues.has(value)) return "recursive"; + visitedValues.add(value); + const properties = [...value.properties] + .map( + ([propertyName, propertyValue]) => + `${propertyName}:${getCallableValueFingerprintWithVisited(propertyValue, visitedValues)}`, + ) + .sort() + .join(","); + const targets = value.targets + .map( + (target) => + `${getNodeIdentity(target.functionNode)}:${String(target.isConditionallyReached)}:${getCallableGuardFingerprint(target.guards)}:{${getCallableBindingsFingerprintWithVisited(target.bindings, visitedValues)}}`, + ) + .sort() + .join(","); + visitedValues.delete(value); + return `${String(value.isComplete)}:[${targets}]:{${properties}}`; +}; + +export const getCallableBindingsFingerprint = ( + bindings: ReadonlyMap, +): string => getCallableBindingsFingerprintWithVisited(bindings, new Set()); + +export const mergeCallableValues = ( + values: ReadonlyArray, +): ResolvedCallableValueDescriptor => { + if (values.length === 0) return createEmptyCallableValue(false); + const propertyNames = new Set(values.flatMap((value) => [...value.properties.keys()])); + const properties = new Map(); + for (const propertyName of propertyNames) { + properties.set( + propertyName, + mergeCallableValues( + values.map( + (value) => value.properties.get(propertyName) ?? createEmptyCallableValue(false), + ), + ), + ); + } + const targetsByFingerprint = new Map(); + for (const target of values.flatMap((value) => value.targets)) { + const guardFingerprint = getCallableGuardFingerprint(target.guards); + const targetFingerprint = `${getNodeIdentity(target.functionNode)}:${String(target.isConditionallyReached)}:${guardFingerprint}:${getCallableBindingsFingerprint(target.bindings)}`; + targetsByFingerprint.set(targetFingerprint, target); + } + return { + isComplete: values.every((value) => value.isComplete), + properties, + targets: [...targetsByFingerprint.values()], + }; +}; + +export const mergeCallableBindings = ( + bindings: ReadonlyArray>, +): ReadonlyMap => { + const valuesBySymbol = new Map(); + for (const binding of bindings) { + for (const [symbol, value] of binding) { + const symbolValues = valuesBySymbol.get(symbol) ?? []; + symbolValues.push(value); + valuesBySymbol.set(symbol, symbolValues); + } + } + return new Map( + [...valuesBySymbol].map(([symbol, values]) => [symbol, mergeCallableValues(values)]), + ); +}; + +export const markCallableValueConditional = ( + value: ResolvedCallableValueDescriptor, +): ResolvedCallableValueDescriptor => ({ + ...value, + properties: new Map( + [...value.properties].map(([propertyName, propertyValue]) => [ + propertyName, + markCallableValueConditional(propertyValue), + ]), + ), + targets: value.targets.map((target) => ({ + ...target, + isConditionallyReached: true, + })), +}); + +export const markCallableBindingsConditional = ( + bindings: ReadonlyMap, +): ReadonlyMap => + new Map([...bindings].map(([symbol, value]) => [symbol, markCallableValueConditional(value)])); + +const addCallableValueGuard = ( + value: ResolvedCallableValueDescriptor, + guard: ResolvedCallableGuardDescriptor, +): ResolvedCallableValueDescriptor => ({ + ...value, + properties: new Map( + [...value.properties].map(([propertyName, propertyValue]) => [ + propertyName, + addCallableValueGuard(propertyValue, guard), + ]), + ), + targets: value.targets.flatMap((target): ReadonlyArray => { + const existingGuard = target.guards.find( + (targetGuard) => targetGuard.conditionIdentity === guard.conditionIdentity, + ); + if (existingGuard && existingGuard.polarity !== guard.polarity) return []; + const guards = existingGuard + ? target.guards.map((targetGuard) => + targetGuard === existingGuard + ? { + ...targetGuard, + isSubstituted: targetGuard.isSubstituted || guard.isSubstituted, + } + : targetGuard, + ) + : [...target.guards, guard]; + return [ + { + ...target, + guards, + }, + ]; + }), +}); + +const removeUnsubstitutedCallableValueGuards = ( + value: ResolvedCallableValueDescriptor, +): ResolvedCallableValueDescriptor => ({ + ...value, + properties: new Map( + [...value.properties].map(([propertyName, propertyValue]) => [ + propertyName, + removeUnsubstitutedCallableValueGuards(propertyValue), + ]), + ), + targets: value.targets.map((target) => ({ + ...target, + guards: target.guards.filter((guard) => guard.isSubstituted), + })), +}); + +const getCallableGuard = ( + condition: ts.Expression, + polarity: boolean, + typeChecker: ts.TypeChecker, + state: CallableResolutionState, +): ResolvedCallableGuardDescriptor | null => { + let unwrappedCondition = unwrapTypescriptExpression(condition); + let resolvedPolarity = polarity; + while ( + ts.isPrefixUnaryExpression(unwrappedCondition) && + unwrappedCondition.operator === ts.SyntaxKind.ExclamationToken + ) { + resolvedPolarity = !resolvedPolarity; + unwrappedCondition = unwrapTypescriptExpression(unwrappedCondition.operand); + } + if (!ts.isIdentifier(unwrappedCondition)) return null; + const conditionSymbol = typeChecker.getSymbolAtLocation(unwrappedCondition); + const conditionDeclaration = conditionSymbol?.declarations?.[0]; + if (!conditionSymbol || !conditionDeclaration) return null; + const hasConditionWrites = + symbolWriteCache.get(conditionSymbol) ?? + collectSymbolWrites(conditionSymbol, conditionDeclaration.getSourceFile(), typeChecker).length > + 0; + symbolWriteCache.set(conditionSymbol, hasConditionWrites); + if (hasConditionWrites) return null; + const guardBinding = state.guardBindings.get(conditionSymbol); + if (guardBinding) { + return { + ...guardBinding, + isSubstituted: true, + polarity: resolvedPolarity === guardBinding.polarity, + }; + } + const resolvedConditionSymbol = + conditionSymbol.flags & ts.SymbolFlags.Alias + ? typeChecker.getAliasedSymbol(conditionSymbol) + : conditionSymbol; + const resolvedConditionDeclaration = resolvedConditionSymbol.declarations?.[0]; + if (!resolvedConditionDeclaration) return null; + const hasResolvedConditionWrites = + symbolWriteCache.get(resolvedConditionSymbol) ?? + collectSymbolWrites( + resolvedConditionSymbol, + resolvedConditionDeclaration.getSourceFile(), + typeChecker, + ).length > 0; + symbolWriteCache.set(resolvedConditionSymbol, hasResolvedConditionWrites); + if (hasResolvedConditionWrites) return null; + return { + conditionIdentity: getSymbolIdentity(resolvedConditionSymbol), + conditionNode: resolvedConditionDeclaration, + isSubstituted: false, + polarity: resolvedPolarity, + }; +}; + +const getObjectPropertyName = (name: ts.PropertyName): string | null => { + if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) { + return name.text; + } + return null; +}; + +const doesTypeContainCallableWithVisited = ( + type: ts.Type, + typeChecker: ts.TypeChecker, + visitedTypes: Set, +): boolean => { + if (visitedTypes.has(type)) return false; + visitedTypes.add(type); + if (type.getCallSignatures().length > 0) return true; + if (type.isUnionOrIntersection()) { + return type.types.some((memberType) => + doesTypeContainCallableWithVisited(memberType, typeChecker, visitedTypes), + ); + } + if (!(type.flags & ts.TypeFlags.Object)) return false; + return type.getProperties().some((propertySymbol) => { + const declaration = propertySymbol.valueDeclaration ?? propertySymbol.declarations?.[0]; + if ( + !declaration || + (!ts.isPropertySignature(declaration) && + !ts.isPropertyDeclaration(declaration) && + !ts.isPropertyAssignment(declaration) && + !ts.isShorthandPropertyAssignment(declaration)) + ) { + return false; + } + return Boolean( + doesTypeContainCallableWithVisited( + typeChecker.getTypeOfSymbolAtLocation(propertySymbol, declaration), + typeChecker, + visitedTypes, + ), + ); + }); +}; + +export const doesTypeContainCallable = (type: ts.Type, typeChecker: ts.TypeChecker): boolean => + doesTypeContainCallableWithVisited(type, typeChecker, new Set()); + +const parameterNeedsCallableBinding = ( + parameter: ts.ParameterDeclaration, + typeChecker: ts.TypeChecker, +): boolean => doesTypeContainCallable(typeChecker.getTypeAtLocation(parameter), typeChecker); + +const resolveObjectLiteral = ( + objectLiteral: ts.ObjectLiteralExpression, + typeChecker: ts.TypeChecker, + bindings: ReadonlyMap, + state: CallableResolutionState, +): ResolvedCallableValueDescriptor => { + const properties = new Map(); + let isComplete = true; + for (const property of objectLiteral.properties) { + if (ts.isSpreadAssignment(property)) { + isComplete = false; + continue; + } + if (ts.isShorthandPropertyAssignment(property)) { + const valueSymbol = typeChecker.getShorthandAssignmentValueSymbol(property); + properties.set( + property.name.text, + valueSymbol + ? resolveSymbolValue(valueSymbol, typeChecker, bindings, state) + : createEmptyCallableValue(false), + ); + continue; + } + if (ts.isPropertyAssignment(property)) { + const propertyName = getObjectPropertyName(property.name); + if (!propertyName) { + isComplete = false; + continue; + } + properties.set( + propertyName, + resolveCallableExpressionWithState(property.initializer, typeChecker, bindings, state), + ); + continue; + } + if (ts.isMethodDeclaration(property)) { + const propertyName = getObjectPropertyName(property.name); + if (!propertyName) { + isComplete = false; + continue; + } + properties.set(propertyName, { + isComplete: true, + properties: new Map(), + targets: [ + { + bindings, + functionNode: property, + guards: [], + isConditionallyReached: false, + }, + ], + }); + continue; + } + isComplete = false; + } + return { isComplete, properties, targets: [] }; +}; + +const resolveArrayLiteral = ( + arrayLiteral: ts.ArrayLiteralExpression, + typeChecker: ts.TypeChecker, + bindings: ReadonlyMap, + state: CallableResolutionState, +): ResolvedCallableValueDescriptor => { + const properties = new Map(); + let isComplete = true; + for (const [elementIndex, element] of arrayLiteral.elements.entries()) { + if (ts.isSpreadElement(element)) { + isComplete = false; + continue; + } + properties.set( + String(elementIndex), + ts.isOmittedExpression(element) + ? createEmptyCallableValue(false) + : resolveCallableExpressionWithState(element, typeChecker, bindings, state), + ); + } + return { isComplete, properties, targets: [] }; +}; + +const bindObjectPattern = ( + bindingPattern: ts.ObjectBindingPattern, + value: ResolvedCallableValueDescriptor, + typeChecker: ts.TypeChecker, + targetBindings: Map, +): boolean => { + let isComplete = value.isComplete; + for (const bindingElement of bindingPattern.elements) { + if (!ts.isIdentifier(bindingElement.name)) { + isComplete = false; + continue; + } + const propertyNameNode = bindingElement.propertyName ?? bindingElement.name; + const propertyName = + ts.isIdentifier(propertyNameNode) || + ts.isStringLiteral(propertyNameNode) || + ts.isNumericLiteral(propertyNameNode) + ? propertyNameNode.text + : null; + const bindingSymbol = typeChecker.getSymbolAtLocation(bindingElement.name); + const propertyValue = propertyName ? value.properties.get(propertyName) : null; + if (!bindingSymbol || !propertyValue) { + isComplete = false; + continue; + } + targetBindings.set(bindingSymbol, propertyValue); + } + return isComplete; +}; + +const resolveCallableArgumentBindingsWithState = ( + targetFunction: ts.FunctionLikeDeclaration, + callExpression: ts.CallExpression, + typeChecker: ts.TypeChecker, + bindings: ReadonlyMap, + state: CallableResolutionState, +): CallableArgumentBindingsDescriptor => { + const targetBindings = new Map(); + const targetGuardBindings = new Map(); + let isComplete = true; + for (const [parameterIndex, parameter] of targetFunction.parameters.entries()) { + const argument = callExpression.arguments[parameterIndex]; + if (!argument) { + if (parameterNeedsCallableBinding(parameter, typeChecker)) isComplete = false; + continue; + } + const value = resolveCallableExpressionWithState(argument, typeChecker, bindings, state); + if (ts.isIdentifier(parameter.name)) { + const parameterSymbol = typeChecker.getSymbolAtLocation(parameter.name); + const argumentGuard = getCallableGuard(argument, true, typeChecker, state); + if (parameterSymbol && argumentGuard) { + targetGuardBindings.set(parameterSymbol, argumentGuard); + } + if (parameterSymbol && (value.targets.length > 0 || value.properties.size > 0)) { + targetBindings.set(parameterSymbol, value); + } else if (parameterNeedsCallableBinding(parameter, typeChecker)) { + isComplete = false; + } + continue; + } + if (ts.isObjectBindingPattern(parameter.name)) { + if (!bindObjectPattern(parameter.name, value, typeChecker, targetBindings)) { + isComplete = false; + } + continue; + } + if (parameterNeedsCallableBinding(parameter, typeChecker)) isComplete = false; + } + return { bindings: targetBindings, guardBindings: targetGuardBindings, isComplete }; +}; + +export const resolveCallableArgumentBindings = ( + targetFunction: ts.FunctionLikeDeclaration, + callExpression: ts.CallExpression, + typeChecker: ts.TypeChecker, + bindings: ReadonlyMap, +): CallableArgumentBindingsDescriptor => + resolveCallableArgumentBindingsWithState(targetFunction, callExpression, typeChecker, bindings, { + guardBindings: new Map(), + resolvingFunctions: new Set(), + resolvingSymbols: new Set(), + }); + +const resolveCallResult = ( + callExpression: ts.CallExpression, + typeChecker: ts.TypeChecker, + bindings: ReadonlyMap, + state: CallableResolutionState, +): ResolvedCallableValueDescriptor => { + const reactApiName = getCanonicalReactApiName(callExpression.expression, typeChecker); + if (reactApiName === "useCallback") { + const callbackExpression = callExpression.arguments[0]; + return callbackExpression + ? resolveCallableExpressionWithState(callbackExpression, typeChecker, bindings, state) + : createEmptyCallableValue(false); + } + if (reactApiName === "useMemo") { + const factoryExpression = callExpression.arguments[0]; + const factoryFunction = factoryExpression + ? resolveFunction(factoryExpression, typeChecker) + : null; + if (!factoryFunction || state.resolvingFunctions.has(factoryFunction)) { + return createEmptyCallableValue(false); + } + const returnSummary = summarizeFunctionReturns(factoryFunction, typeChecker); + const resolvingFunctions = new Set(state.resolvingFunctions); + resolvingFunctions.add(factoryFunction); + const returnValue = mergeCallableValues( + returnSummary.expressions.map((returnExpression) => { + const resolvedValue = resolveCallableExpressionWithState( + returnExpression.expression, + typeChecker, + bindings, + { ...state, resolvingFunctions }, + ); + return returnExpression.isConditionallyReached + ? markCallableValueConditional(resolvedValue) + : resolvedValue; + }), + ); + return { + ...returnValue, + isComplete: + returnSummary.isComplete && !returnSummary.canFallThrough && returnValue.isComplete, + }; + } + const targetFunction = resolveFunction(callExpression.expression, typeChecker); + if (!targetFunction || state.resolvingFunctions.has(targetFunction)) { + return createEmptyCallableValue(false); + } + const returnSummary = summarizeFunctionReturns(targetFunction, typeChecker); + if (returnSummary.expressions.length === 0) return createEmptyCallableValue(false); + const argumentBindings = resolveCallableArgumentBindingsWithState( + targetFunction, + callExpression, + typeChecker, + bindings, + state, + ); + const resolvingFunctions = new Set(state.resolvingFunctions); + resolvingFunctions.add(targetFunction); + const returnBindings = mergeCallableBindings([bindings, argumentBindings.bindings]); + const returnGuardBindings = new Map([...state.guardBindings, ...argumentBindings.guardBindings]); + const returnValue = mergeCallableValues( + returnSummary.expressions.map((returnExpression) => { + const resolvedValue = resolveCallableExpressionWithState( + returnExpression.expression, + typeChecker, + returnBindings, + { ...state, guardBindings: returnGuardBindings, resolvingFunctions }, + ); + return returnExpression.isConditionallyReached + ? markCallableValueConditional(resolvedValue) + : resolvedValue; + }), + ); + const guardedReturnValue = removeUnsubstitutedCallableValueGuards(returnValue); + return { + ...guardedReturnValue, + isComplete: + returnSummary.isComplete && + !returnSummary.canFallThrough && + argumentBindings.isComplete && + guardedReturnValue.isComplete, + }; +}; + +const resolveSymbolValue = ( + symbol: ts.Symbol, + typeChecker: ts.TypeChecker, + bindings: ReadonlyMap, + state: CallableResolutionState, +): ResolvedCallableValueDescriptor => { + const boundValue = bindings.get(symbol); + if (boundValue) return boundValue; + if (state.resolvingSymbols.has(symbol)) return createEmptyCallableValue(false); + const resolvingSymbols = new Set(state.resolvingSymbols); + resolvingSymbols.add(symbol); + for (const declaration of symbol.declarations ?? []) { + const forOfBinding = getForOfBindingDescriptor(declaration); + if (forOfBinding) { + const iterableExpression = unwrapTypescriptExpression(forOfBinding.forOfStatement.expression); + if ( + forOfBinding.isComplete && + ts.isVariableDeclarationList(forOfBinding.variableDeclaration.parent) && + Boolean(forOfBinding.variableDeclaration.parent.flags & ts.NodeFlags.Const) && + !forOfBinding.forOfStatement.awaitModifier && + ts.isArrayLiteralExpression(iterableExpression) && + iterableExpression.elements.length > 0 && + iterableExpression.elements.every((element) => !ts.isSpreadElement(element)) + ) { + let iterationValue = mergeCallableValues( + iterableExpression.elements.map((element) => + resolveCallableExpressionWithState(element, typeChecker, bindings, { + ...state, + resolvingSymbols, + }), + ), + ); + for (const propertyName of forOfBinding.propertyPath) { + const propertyValue = iterationValue.properties.get(propertyName); + if (!propertyValue) return createEmptyCallableValue(false); + iterationValue = { + ...propertyValue, + isComplete: iterationValue.isComplete && propertyValue.isComplete, + }; + } + return iterationValue; + } + return createEmptyCallableValue(false); + } + if (ts.isVariableDeclaration(declaration) && declaration.initializer) { + return resolveCallableExpressionWithState(declaration.initializer, typeChecker, bindings, { + ...state, + resolvingSymbols, + }); + } + if (ts.isBindingElement(declaration) && declaration.initializer) { + const defaultValue = resolveCallableExpressionWithState( + declaration.initializer, + typeChecker, + bindings, + { + ...state, + resolvingSymbols, + }, + ); + return { + ...defaultValue, + isComplete: false, + }; + } + if ( + ts.isBindingElement(declaration) && + ts.isObjectBindingPattern(declaration.parent) && + ts.isVariableDeclaration(declaration.parent.parent) && + declaration.parent.parent.initializer + ) { + const propertyNameNode = declaration.propertyName ?? declaration.name; + const propertyName = + ts.isIdentifier(propertyNameNode) || + ts.isStringLiteral(propertyNameNode) || + ts.isNumericLiteral(propertyNameNode) + ? propertyNameNode.text + : null; + const objectValue = resolveCallableExpressionWithState( + declaration.parent.parent.initializer, + typeChecker, + bindings, + { + ...state, + resolvingSymbols, + }, + ); + return propertyName + ? (objectValue.properties.get(propertyName) ?? createEmptyCallableValue(false)) + : createEmptyCallableValue(false); + } + } + return createEmptyCallableValue(false); +}; + +const resolveCallableExpressionWithState = ( + expression: ts.Expression, + typeChecker: ts.TypeChecker, + bindings: ReadonlyMap, + state: CallableResolutionState, +): ResolvedCallableValueDescriptor => { + const unwrappedExpression = unwrapTypescriptExpression(expression); + if (ts.isFunctionExpression(unwrappedExpression) || ts.isArrowFunction(unwrappedExpression)) { + return { + isComplete: true, + properties: new Map(), + targets: [ + { + bindings, + functionNode: unwrappedExpression, + guards: [], + isConditionallyReached: false, + }, + ], + }; + } + if (ts.isObjectLiteralExpression(unwrappedExpression)) { + return resolveObjectLiteral(unwrappedExpression, typeChecker, bindings, state); + } + if (ts.isArrayLiteralExpression(unwrappedExpression)) { + return resolveArrayLiteral(unwrappedExpression, typeChecker, bindings, state); + } + if (ts.isConditionalExpression(unwrappedExpression)) { + const whenTrueValue = markCallableValueConditional( + resolveCallableExpressionWithState( + unwrappedExpression.whenTrue, + typeChecker, + bindings, + state, + ), + ); + const whenFalseValue = markCallableValueConditional( + resolveCallableExpressionWithState( + unwrappedExpression.whenFalse, + typeChecker, + bindings, + state, + ), + ); + const whenTrueGuard = getCallableGuard(unwrappedExpression.condition, true, typeChecker, state); + const whenFalseGuard = getCallableGuard( + unwrappedExpression.condition, + false, + typeChecker, + state, + ); + return mergeCallableValues([ + whenTrueGuard ? addCallableValueGuard(whenTrueValue, whenTrueGuard) : whenTrueValue, + whenFalseGuard ? addCallableValueGuard(whenFalseValue, whenFalseGuard) : whenFalseValue, + ]); + } + if ( + ts.isBinaryExpression(unwrappedExpression) && + (unwrappedExpression.operatorToken.kind === ts.SyntaxKind.BarBarToken || + unwrappedExpression.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken) + ) { + return mergeCallableValues([ + markCallableValueConditional( + resolveCallableExpressionWithState(unwrappedExpression.left, typeChecker, bindings, state), + ), + markCallableValueConditional( + resolveCallableExpressionWithState(unwrappedExpression.right, typeChecker, bindings, state), + ), + ]); + } + if (ts.isCallExpression(unwrappedExpression)) { + return resolveCallResult(unwrappedExpression, typeChecker, bindings, state); + } + const directFunction = resolveFunction(unwrappedExpression, typeChecker); + if (directFunction) { + return { + isComplete: true, + properties: new Map(), + targets: [ + { + bindings, + functionNode: directFunction, + guards: [], + isConditionallyReached: false, + }, + ], + }; + } + if (ts.isPropertyAccessExpression(unwrappedExpression)) { + const callableRefProtocol = getCallableRefProtocolForCurrentAccess( + unwrappedExpression, + typeChecker, + ); + if (callableRefProtocol?.isSourceComplete && callableRefProtocol.updateExpression) { + return mergeCallableValues([ + resolveCallableExpressionWithState( + callableRefProtocol.initialValueExpression, + typeChecker, + bindings, + state, + ), + resolveCallableExpressionWithState( + callableRefProtocol.updateExpression, + typeChecker, + bindings, + state, + ), + ]); + } + const ownerValue = resolveCallableExpressionWithState( + unwrappedExpression.expression, + typeChecker, + bindings, + state, + ); + const propertyValue = ownerValue.properties.get(unwrappedExpression.name.text); + return propertyValue + ? { + ...propertyValue, + isComplete: ownerValue.isComplete && propertyValue.isComplete, + } + : createEmptyCallableValue(false); + } + const expressionSymbol = typeChecker.getSymbolAtLocation(unwrappedExpression); + return expressionSymbol + ? resolveSymbolValue(expressionSymbol, typeChecker, bindings, state) + : createEmptyCallableValue(false); +}; + +export const resolveCallableExpression = ( + expression: ts.Expression, + typeChecker: ts.TypeChecker, + bindings: ReadonlyMap = new Map(), +): ResolvedCallableValueDescriptor => + resolveCallableExpressionWithState(expression, typeChecker, bindings, { + guardBindings: new Map(), + resolvingFunctions: new Set(), + resolvingSymbols: new Set(), + }); diff --git a/packages/prover/src/resolve-function.ts b/packages/prover/src/resolve-function.ts new file mode 100644 index 000000000..2181ef240 --- /dev/null +++ b/packages/prover/src/resolve-function.ts @@ -0,0 +1,53 @@ +import ts from "typescript"; +import { getCallName } from "./get-call-name.js"; + +const resolveFunctionWithVisitedSymbols = ( + expression: ts.Expression, + typeChecker: ts.TypeChecker, + visitedSymbols: Set, +): ts.FunctionLikeDeclaration | null => { + if (ts.isFunctionExpression(expression) || ts.isArrowFunction(expression)) return expression; + const directSymbol = typeChecker.getSymbolAtLocation(expression); + const expressionSymbol = + directSymbol && (directSymbol.flags & ts.SymbolFlags.Alias) !== 0 + ? typeChecker.getAliasedSymbol(directSymbol) + : directSymbol; + if (expressionSymbol && visitedSymbols.has(expressionSymbol)) return null; + if (expressionSymbol) visitedSymbols.add(expressionSymbol); + for (const declaration of expressionSymbol?.declarations ?? []) { + if ( + (ts.isFunctionDeclaration(declaration) && Boolean(declaration.body)) || + ts.isFunctionExpression(declaration) || + ts.isArrowFunction(declaration) || + (ts.isMethodDeclaration(declaration) && Boolean(declaration.body)) + ) { + return declaration; + } + if ( + ts.isVariableDeclaration(declaration) && + declaration.initializer && + (ts.isFunctionExpression(declaration.initializer) || + ts.isArrowFunction(declaration.initializer)) + ) { + return declaration.initializer; + } + if ( + ts.isVariableDeclaration(declaration) && + declaration.initializer && + ts.isCallExpression(declaration.initializer) && + getCallName(declaration.initializer)?.split(".").at(-1) === "useCallback" + ) { + const callbackExpression = declaration.initializer.arguments[0]; + if (callbackExpression) { + return resolveFunctionWithVisitedSymbols(callbackExpression, typeChecker, visitedSymbols); + } + } + } + return null; +}; + +export const resolveFunction = ( + expression: ts.Expression, + typeChecker: ts.TypeChecker, +): ts.FunctionLikeDeclaration | null => + resolveFunctionWithVisitedSymbols(expression, typeChecker, new Set()); diff --git a/packages/prover/src/summarize-function-returns.ts b/packages/prover/src/summarize-function-returns.ts new file mode 100644 index 000000000..7cac131d8 --- /dev/null +++ b/packages/prover/src/summarize-function-returns.ts @@ -0,0 +1,309 @@ +import ts from "typescript"; +import { getStaticBooleanValue } from "./get-static-boolean-value.js"; +import { unwrapTypescriptExpression } from "./unwrap-typescript-expression.js"; + +export interface FunctionReturnExpressionDescriptor { + expression: ts.Expression; + isConditionallyReached: boolean; +} + +export interface FunctionReturnSummary { + canFallThrough: boolean; + canThrow: boolean; + expressions: ReadonlyArray; + isComplete: boolean; +} + +interface StatementReturnSummary { + doesAnyPathFallThrough: boolean; + doesAnyPathReturn: boolean; + doesAnyPathThrow: boolean; + expressions: ReadonlyArray; + isComplete: boolean; +} + +const createFallThroughSummary = (): StatementReturnSummary => ({ + doesAnyPathFallThrough: true, + doesAnyPathReturn: false, + doesAnyPathThrow: false, + expressions: [], + isComplete: true, +}); + +const isUnsupportedControlFlowStatement = (statement: ts.Statement): boolean => + ts.isBreakStatement(statement) || + ts.isContinueStatement(statement) || + ts.isForInStatement(statement) || + ts.isLabeledStatement(statement) || + ts.isWithStatement(statement); + +const summarizeStatements = ( + statements: ReadonlyArray, + isConditionallyReached: boolean, + typeChecker: ts.TypeChecker | undefined, +): StatementReturnSummary => { + const expressions: FunctionReturnExpressionDescriptor[] = []; + let doesAnyPathFallThrough = true; + let doesAnyPathReturn = false; + let doesAnyPathThrow = false; + let isComplete = true; + for (const statement of statements) { + if (!doesAnyPathFallThrough) break; + const statementSummary = summarizeStatement( + statement, + isConditionallyReached || doesAnyPathReturn || doesAnyPathThrow, + typeChecker, + ); + expressions.push(...statementSummary.expressions); + isComplete = isComplete && statementSummary.isComplete; + doesAnyPathFallThrough = statementSummary.doesAnyPathFallThrough; + doesAnyPathReturn = doesAnyPathReturn || statementSummary.doesAnyPathReturn; + doesAnyPathThrow = doesAnyPathThrow || statementSummary.doesAnyPathThrow; + } + return { + doesAnyPathFallThrough, + doesAnyPathReturn, + doesAnyPathThrow, + expressions, + isComplete, + }; +}; + +const summarizeIfStatement = ( + statement: ts.IfStatement, + typeChecker: ts.TypeChecker | undefined, +): StatementReturnSummary => { + const thenSummary = summarizeStatement(statement.thenStatement, true, typeChecker); + const elseSummary = statement.elseStatement + ? summarizeStatement(statement.elseStatement, true, typeChecker) + : createFallThroughSummary(); + return { + doesAnyPathFallThrough: + thenSummary.doesAnyPathFallThrough || elseSummary.doesAnyPathFallThrough, + doesAnyPathReturn: thenSummary.doesAnyPathReturn || elseSummary.doesAnyPathReturn, + doesAnyPathThrow: thenSummary.doesAnyPathThrow || elseSummary.doesAnyPathThrow, + expressions: [...thenSummary.expressions, ...elseSummary.expressions], + isComplete: thenSummary.isComplete && elseSummary.isComplete, + }; +}; + +const getLiteralTypeKey = (type: ts.Type, typeChecker: ts.TypeChecker): string | null => + type.isLiteral() ? typeChecker.typeToString(type) : null; + +const hasExhaustiveSwitchCoverage = ( + statement: ts.SwitchStatement, + typeChecker: ts.TypeChecker | undefined, +): boolean => { + if (statement.caseBlock.clauses.some(ts.isDefaultClause)) return true; + if (!typeChecker) return false; + const discriminantType = typeChecker.getTypeAtLocation(statement.expression); + const discriminantMembers = discriminantType.isUnion() + ? discriminantType.types + : [discriminantType]; + const discriminantKeys = discriminantMembers.map((member) => + getLiteralTypeKey(member, typeChecker), + ); + if (discriminantKeys.length === 0 || discriminantKeys.some((key) => key === null)) return false; + const caseKeys = new Set(); + for (const clause of statement.caseBlock.clauses) { + if (!ts.isCaseClause(clause)) continue; + const caseType = typeChecker.getTypeAtLocation(clause.expression); + const caseKey = getLiteralTypeKey(caseType, typeChecker); + if (!caseKey) return false; + caseKeys.add(caseKey); + } + return discriminantKeys.every((key) => key !== null && caseKeys.has(key)); +}; + +const summarizeSwitchStatement = ( + statement: ts.SwitchStatement, + typeChecker: ts.TypeChecker | undefined, +): StatementReturnSummary => { + const clauseSummaries = statement.caseBlock.clauses.map((clause) => + summarizeStatements(clause.statements, true, typeChecker), + ); + const isExhaustive = hasExhaustiveSwitchCoverage(statement, typeChecker); + return { + doesAnyPathFallThrough: + !isExhaustive || + clauseSummaries.some((clauseSummary) => clauseSummary.doesAnyPathFallThrough), + doesAnyPathReturn: clauseSummaries.some((clauseSummary) => clauseSummary.doesAnyPathReturn), + doesAnyPathThrow: clauseSummaries.some((clauseSummary) => clauseSummary.doesAnyPathThrow), + expressions: clauseSummaries.flatMap((clauseSummary) => clauseSummary.expressions), + isComplete: + clauseSummaries.length > 0 && + clauseSummaries.every( + (clauseSummary) => clauseSummary.isComplete && !clauseSummary.doesAnyPathFallThrough, + ), + }; +}; + +const summarizeTryStatement = ( + statement: ts.TryStatement, + typeChecker: ts.TypeChecker | undefined, +): StatementReturnSummary => { + const trySummary = summarizeStatements(statement.tryBlock.statements, true, typeChecker); + const catchSummary = statement.catchClause + ? summarizeStatements(statement.catchClause.block.statements, true, typeChecker) + : null; + const protectedSummary: StatementReturnSummary = catchSummary + ? { + doesAnyPathFallThrough: + trySummary.doesAnyPathFallThrough || catchSummary.doesAnyPathFallThrough, + doesAnyPathReturn: trySummary.doesAnyPathReturn || catchSummary.doesAnyPathReturn, + doesAnyPathThrow: catchSummary.doesAnyPathThrow, + expressions: [...trySummary.expressions, ...catchSummary.expressions], + isComplete: trySummary.isComplete && catchSummary.isComplete, + } + : trySummary; + if (!statement.finallyBlock) return protectedSummary; + const finallySummary = summarizeStatements(statement.finallyBlock.statements, true, typeChecker); + return { + doesAnyPathFallThrough: + finallySummary.doesAnyPathFallThrough && protectedSummary.doesAnyPathFallThrough, + doesAnyPathReturn: + finallySummary.doesAnyPathReturn || + (finallySummary.doesAnyPathFallThrough && protectedSummary.doesAnyPathReturn), + doesAnyPathThrow: + finallySummary.doesAnyPathThrow || + (finallySummary.doesAnyPathFallThrough && protectedSummary.doesAnyPathThrow), + expressions: [ + ...(finallySummary.doesAnyPathFallThrough ? protectedSummary.expressions : []), + ...finallySummary.expressions, + ], + isComplete: protectedSummary.isComplete && finallySummary.isComplete, + }; +}; + +const summarizePreTestLoop = ( + statement: ts.Statement, + conditionValue: boolean | null, + typeChecker: ts.TypeChecker | undefined, +): StatementReturnSummary => { + if (conditionValue === false) return createFallThroughSummary(); + const bodySummary = summarizeStatement(statement, true, typeChecker); + if (!bodySummary.doesAnyPathFallThrough) { + return conditionValue === true ? bodySummary : { ...bodySummary, doesAnyPathFallThrough: true }; + } + return { + ...bodySummary, + doesAnyPathFallThrough: conditionValue !== true, + isComplete: false, + }; +}; + +const summarizeDoStatement = ( + statement: ts.DoStatement, + typeChecker: ts.TypeChecker | undefined, +): StatementReturnSummary => { + const bodySummary = summarizeStatement(statement.statement, true, typeChecker); + if (!bodySummary.doesAnyPathFallThrough) return bodySummary; + const conditionValue = getStaticBooleanValue(statement.expression); + if (conditionValue === false) return bodySummary; + return { + ...bodySummary, + doesAnyPathFallThrough: conditionValue !== true, + isComplete: false, + }; +}; + +const summarizeForOfStatement = ( + statement: ts.ForOfStatement, + typeChecker: ts.TypeChecker | undefined, +): StatementReturnSummary => { + const iterableExpression = unwrapTypescriptExpression(statement.expression); + const isFiniteArrayLiteral = + !statement.awaitModifier && + ts.isArrayLiteralExpression(iterableExpression) && + iterableExpression.elements.every((element) => !ts.isSpreadElement(element)); + if (isFiniteArrayLiteral && iterableExpression.elements.length === 0) { + return createFallThroughSummary(); + } + const bodySummary = summarizeStatement(statement.statement, true, typeChecker); + return isFiniteArrayLiteral + ? bodySummary + : { ...bodySummary, doesAnyPathFallThrough: true, isComplete: false }; +}; + +const summarizeStatement = ( + statement: ts.Statement, + isConditionallyReached: boolean, + typeChecker: ts.TypeChecker | undefined, +): StatementReturnSummary => { + if (ts.isReturnStatement(statement)) { + return { + doesAnyPathFallThrough: false, + doesAnyPathReturn: true, + doesAnyPathThrow: false, + expressions: statement.expression + ? [{ expression: statement.expression, isConditionallyReached }] + : [], + isComplete: Boolean(statement.expression), + }; + } + if (ts.isThrowStatement(statement)) { + return { + doesAnyPathFallThrough: false, + doesAnyPathReturn: false, + doesAnyPathThrow: true, + expressions: [], + isComplete: true, + }; + } + if (ts.isBlock(statement)) { + return summarizeStatements(statement.statements, isConditionallyReached, typeChecker); + } + if (ts.isIfStatement(statement)) return summarizeIfStatement(statement, typeChecker); + if (ts.isSwitchStatement(statement)) return summarizeSwitchStatement(statement, typeChecker); + if (ts.isTryStatement(statement)) return summarizeTryStatement(statement, typeChecker); + if (ts.isWhileStatement(statement)) { + return summarizePreTestLoop( + statement.statement, + getStaticBooleanValue(statement.expression), + typeChecker, + ); + } + if (ts.isDoStatement(statement)) return summarizeDoStatement(statement, typeChecker); + if (ts.isForStatement(statement)) { + return summarizePreTestLoop( + statement.statement, + statement.condition ? getStaticBooleanValue(statement.condition) : true, + typeChecker, + ); + } + if (ts.isForOfStatement(statement)) return summarizeForOfStatement(statement, typeChecker); + if (isUnsupportedControlFlowStatement(statement)) { + return { + doesAnyPathFallThrough: true, + doesAnyPathReturn: false, + doesAnyPathThrow: false, + expressions: [], + isComplete: false, + }; + } + return createFallThroughSummary(); +}; + +export const summarizeFunctionReturns = ( + functionNode: ts.FunctionLikeDeclaration, + typeChecker?: ts.TypeChecker, +): FunctionReturnSummary => { + if (!functionNode.body) { + return { canFallThrough: true, canThrow: false, expressions: [], isComplete: false }; + } + if (!ts.isBlock(functionNode.body)) { + return { + canFallThrough: false, + canThrow: false, + expressions: [{ expression: functionNode.body, isConditionallyReached: false }], + isComplete: true, + }; + } + const summary = summarizeStatements(functionNode.body.statements, false, typeChecker); + return { + canFallThrough: summary.doesAnyPathFallThrough, + canThrow: summary.doesAnyPathThrow, + expressions: summary.expressions, + isComplete: summary.isComplete && !summary.doesAnyPathThrow, + }; +}; diff --git a/packages/prover/src/types.ts b/packages/prover/src/types.ts new file mode 100644 index 000000000..bd953af27 --- /dev/null +++ b/packages/prover/src/types.ts @@ -0,0 +1,1348 @@ +import type ts from "typescript"; + +export enum ReactAppProofStatus { + Proved = "proved", + Refuted = "refuted", + Incomplete = "incomplete", +} + +export enum ReactObligationStatus { + Proved = "proved", + Violated = "violated", + Unknown = "unknown", +} + +export enum ReactProofClaim { + ActionState = "action-state", + BoundaryCoverage = "boundary-coverage", + CallableRefFreshness = "callable-ref-freshness", + ClassConstruction = "class-construction", + ClassStateTransitions = "class-state-transitions", + ComponentIdentity = "component-identity", + ComponentInvocation = "component-invocation", + ContextTopology = "context-topology", + ErrorBoundary = "error-boundary", + AsyncEffectOwnership = "async-effect-ownership", + EffectCleanup = "effect-cleanup", + EffectDependencies = "effect-dependencies", + EffectEventUsage = "effect-event-usage", + EffectStateUpdates = "effect-state-updates", + ExternalStoreConsistency = "external-store-consistency", + FormActions = "form-actions", + FormStatus = "form-status", + HostControl = "host-control", + HookOrder = "hook-order", + HookOwnership = "hook-ownership", + HookStateTransitions = "hook-state-transitions", + HydrationEquivalence = "hydration-equivalence", + ImperativeHandle = "imperative-handle", + LazySuspense = "lazy-suspense", + MemoDependencies = "memo-dependencies", + MemoEquivalence = "memo-equivalence", + OptimisticState = "optimistic-state", + ReactNodeFlow = "react-node-flow", + ReconciliationIdentity = "reconciliation-identity", + ReducerPurity = "reducer-purity", + ReducerTransitions = "reducer-transitions", + RefAccess = "ref-access", + RenderPurity = "render-purity", + ScheduledCallbackLifetime = "scheduled-callback-lifetime", + TransitionActions = "transition-actions", + UseResource = "use-resource", +} + +export enum ReactUnitKind { + ClassComponent = "class-component", + Component = "component", + Hook = "hook", + InvalidHookOwner = "invalid-hook-owner", +} + +export enum ReactClassComponentBase { + Component = "Component", + PureComponent = "PureComponent", +} + +export enum ReactSemanticEdgeKind { + CallsHook = "calls-hook", + RendersComponent = "renders-component", +} + +export enum ReactEffectDependencyMode { + Inline = "inline", + Missing = "missing", + Opaque = "opaque", +} + +export enum ReactCompilerFactStatus { + Complete = "complete", + Incomplete = "incomplete", +} + +export enum ReactExecutionPhase { + ActionStateReducer = "action-state-reducer", + ClassConstruction = "class-construction", + ClassMount = "class-mount", + ClassUnmount = "class-unmount", + ClassUpdate = "class-update", + Deferred = "deferred", + EffectCleanup = "effect-cleanup", + EffectEvent = "effect-event", + EffectSetup = "effect-setup", + Event = "event", + ExternalStoreSubscription = "external-store-subscription", + FormAction = "form-action", + ImperativeHandle = "imperative-handle", + OptimisticReducer = "optimistic-reducer", + OptimisticUpdater = "optimistic-updater", + Render = "render", + ServerRender = "server-render", + StateTransition = "state-transition", + TransitionAction = "transition-action", +} + +export enum ReactSemanticCallbackKind { + ActionStateReducer = "action-state-reducer", + ClassMount = "class-mount", + ClassStateUpdater = "class-state-updater", + ClassUnmount = "class-unmount", + ClassUpdate = "class-update", + ComponentRender = "component-render", + EffectCleanup = "effect-cleanup", + EffectEvent = "effect-event", + EffectSetup = "effect-setup", + EventHandler = "event-handler", + ExternalStoreSnapshot = "external-store-snapshot", + ExternalStoreSubscribe = "external-store-subscribe", + FormAction = "form-action", + HookStateUpdater = "hook-state-updater", + ImperativeHandleFactory = "imperative-handle-factory", + ImperativeHandleMethod = "imperative-handle-method", + MemoFactory = "memo-factory", + MemoizedCallback = "memoized-callback", + OptimisticReducer = "optimistic-reducer", + OptimisticUpdater = "optimistic-updater", + Reducer = "reducer", + ReducerInitializer = "reducer-initializer", + ResourceCallback = "resource-callback", + ScheduledCallback = "scheduled-callback", + ServerSnapshot = "server-snapshot", + TransitionAction = "transition-action", +} + +export enum ReactSemanticFunctionCallKind { + Captured = "captured", + Direct = "direct", + Parameter = "parameter", + Property = "property", + SynchronousCallback = "synchronous-callback", +} + +export interface ReactProofLocation { + filePath: string; + line: number; + column: number; +} + +export interface ReactProofEvidence { + description: string; + location: ReactProofLocation; + trace: ReadonlyArray; +} + +export interface ReactProofObligation { + claim: ReactProofClaim; + status: ReactObligationStatus; + summary: string; + evidence: ReadonlyArray; +} + +export interface ReactUnitProof { + name: string; + kind: ReactUnitKind; + location: ReactProofLocation; + obligations: ReadonlyArray; +} + +export interface ReactSemanticUnit { + id: string; + name: string; + kind: ReactUnitKind; + location: ReactProofLocation; + classComponentBase: ReactClassComponentBase | null; + canBeRenderRoot: boolean; + sourceComplete: boolean; +} + +export interface ReactSemanticEdge { + kind: ReactSemanticEdgeKind; + sourceId: string; + targetId: string; + location: ReactProofLocation; +} + +export interface ReactSemanticHookCall { + id: string; + ownerId: string; + name: string; + targetId: string; + location: ReactProofLocation; +} + +export interface ReactSemanticEffect { + id: string; + ownerId: string; + hookName: string; + location: ReactProofLocation; + callbackResolved: boolean; + dependencyMode: ReactEffectDependencyMode; + dependencies: ReadonlyArray; + captures: ReadonlyArray; + hasCleanup: boolean; + setupCallbackId: string | null; + cleanupCallbackIds: ReadonlyArray; +} + +export enum ReactIdentityStability { + Stable = "stable", + Unstable = "unstable", + Unknown = "unknown", +} + +export enum ReactCallableRefFreshness { + EventSynchronized = "event-synchronized", + PassiveLag = "passive-lag", + Unknown = "unknown", +} + +export enum ReactSchedulerKind { + AnimationFrame = "animation-frame", + IdleCallback = "idle-callback", + Immediate = "immediate", + Interval = "interval", + Microtask = "microtask", + Timeout = "timeout", +} + +export enum ReactSchedulerCancellationStatus { + Guaranteed = "guaranteed", + Missing = "missing", + Unknown = "unknown", +} + +export enum ReactEffectResourceKind { + EventListener = "event-listener", + IntersectionObserver = "intersection-observer", + MutationObserver = "mutation-observer", + Observer = "observer", + ResizeObserver = "resize-observer", +} + +export enum ReactEffectResourceDisposalStatus { + Guaranteed = "guaranteed", + Missing = "missing", + Unknown = "unknown", +} + +export enum ReactAsyncOwnershipStatus { + Guarded = "guarded", + Unguarded = "unguarded", + Unknown = "unknown", +} + +export enum ReactProofCertificateStatus { + Invalid = "invalid", + Valid = "valid", +} + +export interface ReactProofCertificateFailure { + description: string; + subjectId: string; +} + +export interface ReactProofCertificateCheck { + status: ReactProofCertificateStatus; + failures: ReadonlyArray; +} + +export interface ReactAsyncEffectTaskDescriptor { + effectCall: ts.CallExpression; + evidenceDescription: string; + evidenceNode: ts.Node; + stateWriteNames: ReadonlyArray; + status: ReactAsyncOwnershipStatus; + taskNode: ts.Node; +} + +export interface ReactSemanticAsyncTask { + id: string; + ownerId: string; + effectId: string; + location: ReactProofLocation; + stateWrites: ReadonlyArray; + ownershipStatus: ReactAsyncOwnershipStatus; +} + +export interface ReactSemanticEffectEvent { + id: string; + ownerId: string; + name: string; + location: ReactProofLocation; + callbackId: string | null; + identityStability: ReactIdentityStability; +} + +export interface ReactSemanticExternalStore { + id: string; + ownerId: string; + location: ReactProofLocation; + subscribeCallbackIds: ReadonlyArray; + subscribeComplete: boolean; + snapshotCallbackIds: ReadonlyArray; + snapshotComplete: boolean; + serverSnapshotCallbackIds: ReadonlyArray; + serverSnapshotComplete: boolean; + serverSnapshotProvided: boolean; +} + +export interface ReactSemanticContext { + id: string; + name: string; + location: ReactProofLocation; + defaultValueText: string; +} + +export interface ReactSemanticContextProvider { + id: string; + ownerId: string; + contextId: string; + location: ReactProofLocation; + valueProvided: boolean; + valueText: string | null; +} + +export interface ReactSemanticContextConsumer { + id: string; + ownerId: string; + contextId: string | null; + hookName: string; + location: ReactProofLocation; + sourceProviderIds: ReadonlyArray; + usesDefaultValue: boolean; + topologyComplete: boolean; +} + +export interface ReactSemanticRender { + id: string; + ownerId: string; + targetId: string; + location: ReactProofLocation; + kind: ReactSemanticRenderKind; + sourceRenderId: string | null; + containerRenderId: string | null; + slotPropName: string | null; + topologyOwnerIds: ReadonlyArray; + activeContextProviderIds: ReadonlyArray; + contextTopologyComplete: boolean; + activeFormIds: ReadonlyArray; + formTopologyComplete: boolean; +} + +export enum ReactSemanticRenderKind { + Direct = "direct", + Slot = "slot", + SlotInput = "slot-input", +} + +export enum ReactLazyDeclarationStatus { + ModuleStable = "module-stable", + RenderUnstable = "render-unstable", +} + +export enum ReactLazyLoaderStatus { + Invalid = "invalid", + Opaque = "opaque", + Valid = "valid", +} + +export enum ReactSuspenseCoverageStatus { + Covered = "covered", + OutsideBoundary = "outside-boundary", + Unknown = "unknown", +} + +export enum ReactErrorBoundaryProtocolStatus { + Invalid = "invalid", + Unknown = "unknown", + Valid = "valid", +} + +export enum ReactErrorBoundaryCoverageStatus { + Covered = "covered", + OutsideBoundary = "outside-boundary", + Unknown = "unknown", +} + +export enum ReactRenderFailureKind { + ExplicitThrow = "explicit-throw", +} + +export enum ReactUseResourceKind { + Invalid = "invalid", + Thenable = "thenable", + Unknown = "unknown", +} + +export enum ReactUseResourceIdentityStatus { + Stable = "stable", + Unstable = "unstable", + Unknown = "unknown", +} + +export interface ReactSemanticSuspenseBoundary { + id: string; + ownerId: string; + location: ReactProofLocation; + renderIds: ReadonlyArray; +} + +export interface ReactSemanticLazyComponent { + id: string; + name: string; + location: ReactProofLocation; + declarationOwnerId: string | null; + canBeRenderRoot: boolean; + identityResolved: boolean; + declarationStatus: ReactLazyDeclarationStatus; + loaderStatus: ReactLazyLoaderStatus; + renderIds: ReadonlyArray; + sourceComplete: boolean; + complete: boolean; +} + +export interface ReactSemanticLazyRender { + id: string; + ownerId: string; + lazyComponentId: string; + location: ReactProofLocation; + topologyBoundaryIds: ReadonlyArray; + sourceBoundaryIds: ReadonlyArray; + inheritsOwnerBoundary: boolean; + outsideBoundary: boolean; + topologyComplete: boolean; + sourceComplete: boolean; + coverageStatus: ReactSuspenseCoverageStatus; + complete: boolean; +} + +export interface ReactSemanticErrorBoundaryDefinition { + id: string; + ownerId: string; + location: ReactProofLocation; + derivedStateLocation: ReactProofLocation | null; + componentDidCatchLocation: ReactProofLocation | null; + fallbackStateKey: string | null; + derivedStateStatus: ReactErrorBoundaryProtocolStatus; + fallbackRenderStatus: ReactErrorBoundaryProtocolStatus; + instanceIds: ReadonlyArray; + sourceComplete: boolean; + complete: boolean; +} + +export interface ReactSemanticErrorBoundary { + id: string; + ownerId: string; + definitionId: string; + location: ReactProofLocation; + renderIds: ReadonlyArray; +} + +export interface ReactSemanticRenderFailure { + id: string; + ownerId: string; + location: ReactProofLocation; + kind: ReactRenderFailureKind; + sourceBoundaryIds: ReadonlyArray; + outsideBoundary: boolean; + topologyComplete: boolean; + sourceComplete: boolean; + coverageStatus: ReactErrorBoundaryCoverageStatus; + complete: boolean; +} + +export interface ReactSemanticUseResource { + id: string; + ownerId: string; + location: ReactProofLocation; + kind: ReactUseResourceKind; + identityStatus: ReactUseResourceIdentityStatus; + sourceSuspenseBoundaryIds: ReadonlyArray; + outsideSuspenseBoundary: boolean; + suspenseTopologyComplete: boolean; + suspenseCoverageStatus: ReactSuspenseCoverageStatus; + sourceErrorBoundaryIds: ReadonlyArray; + outsideErrorBoundary: boolean; + errorTopologyComplete: boolean; + errorCoverageStatus: ReactErrorBoundaryCoverageStatus; + sourceComplete: boolean; + complete: boolean; +} + +export enum ReactHydrationRootKind { + Client = "client", + ServerInteractive = "server-interactive", + ServerStatic = "server-static", +} + +export enum ReactHydrationPrefixStatus { + Known = "known", + Unknown = "unknown", +} + +export enum ReactHydrationRootExecutionStatus { + Module = "module", + Unknown = "unknown", +} + +export enum ReactHydrationHazardKind { + BrowserGlobal = "browser-global", + EnvironmentBranch = "environment-branch", + LocaleFormatting = "locale-formatting", +} + +export enum ReactHydrationStatus { + Equivalent = "equivalent", + Mismatched = "mismatched", + NotHydrated = "not-hydrated", + Unknown = "unknown", +} + +export interface ReactSemanticHydrationRoot { + id: string; + apiName: string; + kind: ReactHydrationRootKind; + targetId: string | null; + identifierPrefix: string | null; + prefixStatus: ReactHydrationPrefixStatus; + executionStatus: ReactHydrationRootExecutionStatus; + location: ReactProofLocation; + sourceComplete: boolean; + complete: boolean; +} + +export interface ReactSemanticHydrationHazard { + id: string; + ownerId: string; + kind: ReactHydrationHazardKind; + description: string; + location: ReactProofLocation; +} + +export interface ReactSemanticHydration { + id: string; + ownerId: string; + clientRootIds: ReadonlyArray; + interactiveServerRootIds: ReadonlyArray; + staticServerRootIds: ReadonlyArray; + hazardIds: ReadonlyArray; + status: ReactHydrationStatus; + sourceComplete: boolean; + complete: boolean; +} + +export enum ReactHostControlKind { + CheckableInput = "checkable-input", + FileInput = "file-input", + Select = "select", + SelectMultiple = "select-multiple", + TextInput = "text-input", + Textarea = "textarea", + Unknown = "unknown", +} + +export enum ReactHostControlValueStatus { + Absent = "absent", + Defined = "defined", + MaySwitch = "may-switch", + Nullish = "nullish", + Unknown = "unknown", +} + +export enum ReactHostControlMutabilityStatus { + Editable = "editable", + Immutable = "immutable", + Unknown = "unknown", +} + +export enum ReactHostControlUpdateStatus { + Conditional = "conditional", + Deferred = "deferred", + Exact = "exact", + Missing = "missing", + NotRequired = "not-required", + Opaque = "opaque", + WrongValue = "wrong-value", +} + +export enum ReactHostControlStatus { + Invalid = "invalid", + Resolved = "resolved", + Unknown = "unknown", +} + +export interface ReactSemanticHostControl { + id: string; + ownerId: string; + location: ReactProofLocation; + kind: ReactHostControlKind; + controlledPropName: string; + controlledPropPresent: boolean | null; + defaultPropName: string; + defaultPropPresent: boolean | null; + stateName: string | null; + setterName: string | null; + valueStatus: ReactHostControlValueStatus; + mutabilityStatus: ReactHostControlMutabilityStatus; + updateStatus: ReactHostControlUpdateStatus; + callbackIds: ReadonlyArray; + transitionIds: ReadonlyArray; + status: ReactHostControlStatus; + sourceComplete: boolean; + complete: boolean; +} + +export interface ReactSemanticSlotFlow { + id: string; + ownerId: string; + sourceRenderId: string; + containerRenderId: string | null; + propName: string | null; + renderIds: ReadonlyArray; + location: ReactProofLocation; + sourceComplete: boolean; + placementComplete: boolean; + complete: boolean; +} + +export interface ReactSemanticCallback { + id: string; + ownerId: string; + kind: ReactSemanticCallbackKind; + phase: ReactExecutionPhase; + name: string; + location: ReactProofLocation; + captures: ReadonlyArray; + stateWrites: ReadonlyArray; +} + +export interface ReactSemanticReachableFunction { + id: string; + ownerId: string; + rootCallbackId: string; + name: string; + phase: ReactExecutionPhase; + location: ReactProofLocation; + isConditionallyReached: boolean; +} + +export interface ReactSemanticFunctionCall { + id: string; + ownerId: string; + rootCallbackId: string; + sourceFunctionId: string; + targetFunctionId: string; + kind: ReactSemanticFunctionCallKind; + phase: ReactExecutionPhase; + location: ReactProofLocation; + sourceParameterIndex: number | null; + callArgumentIndex: number | null; + sourcePropertyPath: ReadonlyArray; + isConditionallyReached: boolean; +} + +export interface ReactSemanticEventBinding { + id: string; + ownerId: string; + eventName: string; + location: ReactProofLocation; + callbackIds: ReadonlyArray; + complete: boolean; +} + +export interface ReactSemanticCallbackGuard { + id: string; + polarity: boolean; +} + +export interface ReactSemanticCallbackPropAlternative { + callbackId: string; + guards: ReadonlyArray; +} + +export interface ReactSemanticCallbackPropFlow { + id: string; + renderId: string; + renderOwnerId: string; + targetOwnerId: string; + propName: string; + phase: ReactExecutionPhase; + location: ReactProofLocation; + alternatives: ReadonlyArray; + callbackIds: ReadonlyArray; + complete: boolean; +} + +export interface ReactSemanticCallableRef { + id: string; + ownerId: string; + name: string; + location: ReactProofLocation; + updateHookName: string | null; + updateLocation: ReactProofLocation | null; + invocationCallIds: ReadonlyArray; + invocationCallbackIds: ReadonlyArray; + invocationLocations: ReadonlyArray; + freshness: ReactCallableRefFreshness; + sourceComplete: boolean; + complete: boolean; +} + +export enum ReactMemoComparatorKind { + Custom = "custom", + DefaultShallow = "default-shallow", +} + +export enum ReactMemoComparatorStatus { + Equivalent = "equivalent", + OmittedObservedProp = "omitted-observed-prop", + Unknown = "unknown", +} + +export interface ReactSemanticMemoPropObservation { + path: string; + location: ReactProofLocation; + valueCanVary: boolean; +} + +export interface ReactSemanticMemoComparatorTruePath { + equalPropPaths: ReadonlyArray; + sourceComplete: boolean; +} + +export interface ReactSemanticMemoComparator { + id: string; + ownerId: string | null; + kind: ReactMemoComparatorKind; + location: ReactProofLocation; + comparatorLocation: ReactProofLocation | null; + observations: ReadonlyArray; + truePaths: ReadonlyArray; + observationComplete: boolean; + analysisComplete: boolean; + status: ReactMemoComparatorStatus; + sourceComplete: boolean; + complete: boolean; +} + +export enum ReactImperativeHandleRefKind { + ForwardedRef = "forwarded-ref", + RefProp = "ref-prop", +} + +export enum ReactImperativeHandleStatus { + ImpureFactory = "impure-factory", + MissingDependency = "missing-dependency", + Opaque = "opaque", + Resolved = "resolved", +} + +export interface ReactSemanticImperativeHandle { + id: string; + ownerId: string; + refKind: ReactImperativeHandleRefKind | null; + refName: string | null; + location: ReactProofLocation; + factoryCallbackId: string | null; + dependencyMode: ReactEffectDependencyMode; + dependencies: ReadonlyArray; + captures: ReadonlyArray; + factoryPurity: ReactObligationStatus; + methodIds: ReadonlyArray; + bindingIds: ReadonlyArray; + factoryComplete: boolean; + shapeComplete: boolean; + targetComplete: boolean; + bindingComplete: boolean; + status: ReactImperativeHandleStatus; + sourceComplete: boolean; + complete: boolean; +} + +export interface ReactSemanticImperativeHandleMethod { + id: string; + ownerId: string; + handleId: string; + name: string; + location: ReactProofLocation; +} + +export interface ReactSemanticImperativeHandleBinding { + id: string; + ownerId: string; + handleId: string; + renderId: string; + refName: string; + refLocation: ReactProofLocation; + location: ReactProofLocation; + invocationIds: ReadonlyArray; + referenceComplete: boolean; + sourceComplete: boolean; + complete: boolean; +} + +export interface ReactSemanticImperativeHandleInvocation { + id: string; + ownerId: string; + handleId: string; + methodId: string; + bindingId: string; + location: ReactProofLocation; + callerCallbackIds: ReadonlyArray; + methodCallbackIds: ReadonlyArray; + sourceComplete: boolean; + complete: boolean; +} + +export interface ReactSemanticScheduler { + id: string; + ownerId: string; + effectId: string | null; + registrationCallbackId: string; + kind: ReactSchedulerKind; + phase: ReactExecutionPhase; + location: ReactProofLocation; + callbackIds: ReadonlyArray; + callbackComplete: boolean; + cancellationStatus: ReactSchedulerCancellationStatus; + cancellationLocations: ReadonlyArray; + sourceComplete: boolean; + complete: boolean; +} + +export interface ReactSemanticEffectResource { + id: string; + ownerId: string; + effectId: string | null; + acquisitionCallbackId: string; + kind: ReactEffectResourceKind; + phase: ReactExecutionPhase; + location: ReactProofLocation; + activationLocations: ReadonlyArray; + callbackIds: ReadonlyArray; + callbackComplete: boolean; + disposalStatus: ReactEffectResourceDisposalStatus; + disposalLocations: ReadonlyArray; + sourceComplete: boolean; + complete: boolean; +} + +export interface ReactSemanticClassLifecycle { + id: string; + ownerId: string; + location: ReactProofLocation; + constructionId: string; + mountCallbackId: string | null; + unmountCallbackId: string | null; + updateCallbackId: string | null; + resourceIds: ReadonlyArray; + schedulerIds: ReadonlyArray; + stateWriteIds: ReadonlyArray; + transitionIds: ReadonlyArray; + sourceComplete: boolean; + complete: boolean; +} + +export enum ReactClassConstructionIssueKind { + InvalidStateValue = "invalid-state-value", + InvalidSuperCall = "invalid-super-call", + MissingStateInitialization = "missing-state-initialization", + MultipleStateInitializations = "multiple-state-initializations", + SetStateCall = "set-state-call", + SideEffect = "side-effect", + UnsupportedConstructorStatement = "unsupported-constructor-statement", + UnsupportedInitializer = "unsupported-initializer", +} + +export enum ReactClassConstructionIssueStatus { + Unknown = "unknown", + Violated = "violated", +} + +export enum ReactClassConstructionStatus { + Invalid = "invalid", + Unknown = "unknown", + Valid = "valid", +} + +export enum ReactClassStateInitializationKind { + ConstructorAssignment = "constructor-assignment", + Multiple = "multiple", + None = "none", + PublicField = "public-field", +} + +export enum ReactClassStateInitializationRequirement { + Conditional = "conditional", + None = "none", + Required = "required", +} + +export interface ReactSemanticClassConstructionIssue { + kind: ReactClassConstructionIssueKind; + location: ReactProofLocation; + status: ReactClassConstructionIssueStatus; +} + +export interface ReactSemanticClassConstruction { + id: string; + ownerId: string; + phase: ReactExecutionPhase.ClassConstruction; + location: ReactProofLocation; + constructorLocation: ReactProofLocation | null; + initializationKind: ReactClassStateInitializationKind; + initializationLocation: ReactProofLocation | null; + stateRequirement: ReactClassStateInitializationRequirement; + issues: ReadonlyArray; + status: ReactClassConstructionStatus; + sourceComplete: boolean; + complete: boolean; +} + +export enum ReactClassStateUpdaterStatus { + Impure = "impure", + Noop = "noop", + Object = "object", + Pure = "pure", + Unknown = "unknown", +} + +export enum ReactClassUpdateCycleStatus { + Bounded = "bounded", + Guaranteed = "guaranteed", + None = "none", + Unknown = "unknown", +} + +export enum ReactClassStateWriteKind { + Assignment = "assignment", + Delete = "delete", + MutatingCall = "mutating-call", + ReferenceEscape = "reference-escape", + Update = "update", +} + +export enum ReactClassStateWriteStatus { + Forbidden = "forbidden", + Unknown = "unknown", +} + +export interface ReactSemanticClassStateWrite { + id: string; + ownerId: string; + callbackId: string; + phase: + | ReactExecutionPhase.ClassMount + | ReactExecutionPhase.ClassUnmount + | ReactExecutionPhase.ClassUpdate + | ReactExecutionPhase.Deferred + | ReactExecutionPhase.StateTransition; + location: ReactProofLocation; + kind: ReactClassStateWriteKind; + status: ReactClassStateWriteStatus; + sourceComplete: boolean; + complete: boolean; +} + +export interface ReactSemanticClassStateTransition { + id: string; + ownerId: string; + lifecycleCallbackId: string; + updaterCallbackId: string | null; + phase: ReactExecutionPhase.ClassMount | ReactExecutionPhase.ClassUpdate; + location: ReactProofLocation; + guardLocations: ReadonlyArray; + updaterStatus: ReactClassStateUpdaterStatus; + cycleStatus: ReactClassUpdateCycleStatus; + commitCallbackProvided: boolean; + sourceComplete: boolean; + complete: boolean; +} + +export enum ReactHookStateUpdaterStatus { + DirectValue = "direct-value", + Impure = "impure", + Pure = "pure", + SetterEscape = "setter-escape", + Unknown = "unknown", +} + +export interface ReactSemanticHookStateTransition { + id: string; + ownerId: string; + stateName: string; + setterName: string; + location: ReactProofLocation; + executionCallbackIds: ReadonlyArray; + updaterCallbackId: string | null; + updaterStatus: ReactHookStateUpdaterStatus; + sourceComplete: boolean; + complete: boolean; +} + +export enum ReactReducerDispatchKind { + Call = "call", + Escape = "escape", +} + +export enum ReactReducerDispatchStatus { + Escape = "escape", + Owned = "owned", + Reducer = "reducer", + Render = "render", + Unknown = "unknown", +} + +export enum ReactReducerPurityStatus { + Impure = "impure", + Opaque = "opaque", + Pure = "pure", +} + +export enum ReactReducerReturnStatus { + Absent = "absent", + MayFallThrough = "may-fall-through", + MayThrow = "may-throw", + Opaque = "opaque", + Total = "total", +} + +export interface ReactSemanticReducer { + id: string; + ownerId: string; + stateName: string; + dispatcherName: string; + location: ReactProofLocation; + reducerCallbackId: string | null; + initializerCallbackId: string | null; + reducerPurity: ReactReducerPurityStatus; + initializerPurity: ReactReducerPurityStatus; + reducerReturnStatus: ReactReducerReturnStatus; + initializerReturnStatus: ReactReducerReturnStatus; + sourceComplete: boolean; + complete: boolean; +} + +export interface ReactSemanticReducerDispatch { + id: string; + ownerId: string; + reducerId: string; + kind: ReactReducerDispatchKind; + location: ReactProofLocation; + executionCallbackIds: ReadonlyArray; + status: ReactReducerDispatchStatus; + sourceComplete: boolean; + complete: boolean; +} + +export enum ReactActionStateReducerStatus { + Opaque = "opaque", + Resolved = "resolved", +} + +export enum ReactActionStateDispatchStatus { + Action = "action", + OutsideAction = "outside-action", + Render = "render", + SetterEscape = "setter-escape", + Unknown = "unknown", +} + +export enum ReactActionStateDispatchKind { + ActionProp = "action-prop", + Call = "call", + Escape = "escape", +} + +export interface ReactSemanticActionState { + id: string; + ownerId: string; + stateName: string; + dispatcherName: string; + location: ReactProofLocation; + reducerCallbackId: string | null; + reducerStatus: ReactActionStateReducerStatus; + sourceComplete: boolean; + complete: boolean; +} + +export interface ReactSemanticActionStateDispatch { + id: string; + ownerId: string; + actionStateId: string; + kind: ReactActionStateDispatchKind; + location: ReactProofLocation; + executionCallbackIds: ReadonlyArray; + status: ReactActionStateDispatchStatus; + sourceComplete: boolean; + complete: boolean; +} + +export enum ReactFormActionKind { + Form = "form", + Submitter = "submitter", +} + +export enum ReactFormActionStatus { + Opaque = "opaque", + Resolved = "resolved", + UnsupportedControl = "unsupported-control", +} + +export interface ReactSemanticFormAction { + id: string; + ownerId: string; + kind: ReactFormActionKind; + propName: string; + location: ReactProofLocation; + actionCallbackIds: ReadonlyArray; + status: ReactFormActionStatus; + callbackComplete: boolean; + sourceComplete: boolean; + complete: boolean; +} + +export enum ReactFormStatusTopologyStatus { + OutsideForm = "outside-form", + Resolved = "resolved", + Unknown = "unknown", +} + +export interface ReactSemanticForm { + id: string; + ownerId: string; + location: ReactProofLocation; +} + +export interface ReactSemanticFormStatus { + id: string; + ownerId: string; + location: ReactProofLocation; + sourceFormIds: ReadonlyArray; + outsideForm: boolean; + status: ReactFormStatusTopologyStatus; + sourceComplete: boolean; + complete: boolean; +} + +export enum ReactOptimisticReducerStatus { + Absent = "absent", + Impure = "impure", + Pure = "pure", + Unknown = "unknown", +} + +export enum ReactOptimisticActionStatus { + Action = "action", + OutsideAction = "outside-action", + Render = "render", + Unknown = "unknown", +} + +export interface ReactSemanticOptimisticState { + id: string; + ownerId: string; + stateName: string; + setterName: string; + location: ReactProofLocation; + reducerCallbackId: string | null; + reducerStatus: ReactOptimisticReducerStatus; + sourceComplete: boolean; + complete: boolean; +} + +export interface ReactSemanticOptimisticUpdate { + id: string; + ownerId: string; + optimisticStateId: string; + location: ReactProofLocation; + executionCallbackIds: ReadonlyArray; + updaterCallbackId: string | null; + updaterStatus: ReactHookStateUpdaterStatus; + actionStatus: ReactOptimisticActionStatus; + sourceComplete: boolean; + complete: boolean; +} + +export enum ReactTransitionStarterKind { + Global = "global", + Hook = "hook", +} + +export enum ReactTransitionActionStatus { + Async = "async", + ControlledInput = "controlled-input", + Opaque = "opaque", + StarterEscape = "starter-escape", + Synchronous = "synchronous", + UnknownControl = "unknown-control", +} + +export interface ReactSemanticTransitionAction { + id: string; + ownerId: string; + starterKind: ReactTransitionStarterKind; + location: ReactProofLocation; + executionCallbackIds: ReadonlyArray; + actionCallbackId: string | null; + controlledStateNames: ReadonlyArray; + unknownControlStateNames: ReadonlyArray; + status: ReactTransitionActionStatus; + sourceComplete: boolean; + complete: boolean; +} + +export interface ReactCompilerInstructionFact { + id: string; + valueKind: string; + lvalueId: string; + effect: string; + reactive: boolean; + location: ReactProofLocation | null; +} + +export interface ReactCompilerBlockFact { + id: string; + kind: string; + predecessors: ReadonlyArray; + successors: ReadonlyArray; + instructions: ReadonlyArray; + terminalKind: string; +} + +export interface ReactCompilerFunctionFact { + id: string; + functionType: string; + location: ReactProofLocation | null; + entryBlockId: string; + blocks: ReadonlyArray; +} + +export interface ReactCompilerFailure { + description: string; + location: ReactProofLocation; +} + +export interface ReactCompilerGraph { + version: string; + phase: string; + status: ReactCompilerFactStatus; + functions: ReadonlyArray; + failures: ReadonlyArray; +} + +export interface ReactSemanticGraph { + schemaVersion: number; + actionStates: ReadonlyArray; + actionStateDispatches: ReadonlyArray; + units: ReadonlyArray; + edges: ReadonlyArray; + hookCalls: ReadonlyArray; + effects: ReadonlyArray; + effectEvents: ReadonlyArray; + externalStores: ReadonlyArray; + asyncTasks: ReadonlyArray; + contexts: ReadonlyArray; + contextProviders: ReadonlyArray; + contextConsumers: ReadonlyArray; + errorBoundaryDefinitions: ReadonlyArray; + errorBoundaries: ReadonlyArray; + renderFailures: ReadonlyArray; + useResources: ReadonlyArray; + hydrationRoots: ReadonlyArray; + hydrationHazards: ReadonlyArray; + hydrations: ReadonlyArray; + hostControls: ReadonlyArray; + suspenseBoundaries: ReadonlyArray; + lazyComponents: ReadonlyArray; + lazyRenders: ReadonlyArray; + renders: ReadonlyArray; + slotFlows: ReadonlyArray; + callbacks: ReadonlyArray; + reachableFunctions: ReadonlyArray; + functionCalls: ReadonlyArray; + eventBindings: ReadonlyArray; + callbackPropFlows: ReadonlyArray; + callableRefs: ReadonlyArray; + memoComparators: ReadonlyArray; + imperativeHandles: ReadonlyArray; + imperativeHandleMethods: ReadonlyArray; + imperativeHandleBindings: ReadonlyArray; + imperativeHandleInvocations: ReadonlyArray; + schedulers: ReadonlyArray; + resources: ReadonlyArray; + classConstructions: ReadonlyArray; + classLifecycles: ReadonlyArray; + classStateWrites: ReadonlyArray; + classStateTransitions: ReadonlyArray; + formActions: ReadonlyArray; + forms: ReadonlyArray; + formStatuses: ReadonlyArray; + hookStateTransitions: ReadonlyArray; + reducers: ReadonlyArray; + reducerDispatches: ReadonlyArray; + optimisticStates: ReadonlyArray; + optimisticUpdates: ReadonlyArray; + transitionActions: ReadonlyArray; + compiler: ReactCompilerGraph; +} + +export interface ReactProofSummary { + files: number; + units: number; + proved: number; + violated: number; + unknown: number; +} + +export interface ReactAppProofReport { + schemaVersion: number; + status: ReactAppProofStatus; + rootDirectory: string; + graph: ReactSemanticGraph; + units: ReadonlyArray; + projectEvidence: ReadonlyArray; + summary: ReactProofSummary; +} + +export interface ProveReactAppInput { + rootDirectory: string; + tsconfigPath?: string; +} + +export interface ReactUnitDescriptor { + name: string; + kind: ReactUnitKind; + node: ts.Node; + classNode?: ts.ClassDeclaration; + classComponentBase?: ReactClassComponentBase; + functionNode?: ts.FunctionLikeDeclaration; + invalidHookCalls?: ReadonlyArray; + sourceComplete: boolean; +} + +export interface ReactAnalysisContext { + program: ts.Program; + typeChecker: ts.TypeChecker; + rootDirectory: string; + graph?: ReactSemanticGraph; +} diff --git a/packages/prover/src/unwrap-typescript-expression.ts b/packages/prover/src/unwrap-typescript-expression.ts new file mode 100644 index 000000000..32f8a40ef --- /dev/null +++ b/packages/prover/src/unwrap-typescript-expression.ts @@ -0,0 +1,15 @@ +import ts from "typescript"; + +export const unwrapTypescriptExpression = (expression: ts.Expression): ts.Expression => { + let currentExpression = expression; + while ( + ts.isParenthesizedExpression(currentExpression) || + ts.isAsExpression(currentExpression) || + ts.isSatisfiesExpression(currentExpression) || + ts.isNonNullExpression(currentExpression) || + ts.isTypeAssertionExpression(currentExpression) + ) { + currentExpression = currentExpression.expression; + } + return currentExpression; +}; diff --git a/packages/prover/src/utils/analyze-state-update-expression.ts b/packages/prover/src/utils/analyze-state-update-expression.ts new file mode 100644 index 000000000..271af6b80 --- /dev/null +++ b/packages/prover/src/utils/analyze-state-update-expression.ts @@ -0,0 +1,45 @@ +import ts from "typescript"; +import { analyzeUpdaterFunction } from "../analyze-updater-function.js"; +import { ReactHookStateUpdaterStatus, ReactObligationStatus } from "../types.js"; +import { unwrapTypescriptExpression } from "../unwrap-typescript-expression.js"; +import { doesTypeHaveCallSignature } from "./does-type-have-call-signature.js"; +import type { ReactAnalysisContext } from "../types.js"; + +export interface StateUpdateExpressionAnalysis { + updaterFunction: ts.FunctionLikeDeclaration | null; + updaterStatus: ReactHookStateUpdaterStatus; +} + +export const analyzeStateUpdateExpression = ( + updaterExpression: ts.Expression, + context: ReactAnalysisContext, +): StateUpdateExpressionAnalysis => { + const unwrappedUpdater = unwrapTypescriptExpression(updaterExpression); + const updaterAnalysis = analyzeUpdaterFunction(unwrappedUpdater, context); + if (updaterAnalysis.updaterFunction) { + let updaterStatus = ReactHookStateUpdaterStatus.Unknown; + if (updaterAnalysis.status === ReactObligationStatus.Proved) { + updaterStatus = ReactHookStateUpdaterStatus.Pure; + } else if (updaterAnalysis.status === ReactObligationStatus.Violated) { + updaterStatus = ReactHookStateUpdaterStatus.Impure; + } + return { + updaterFunction: updaterAnalysis.updaterFunction, + updaterStatus, + }; + } + const updaterType = context.typeChecker.getTypeAtLocation(unwrappedUpdater); + if ( + updaterType.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown) || + doesTypeHaveCallSignature(updaterType) + ) { + return { + updaterFunction: null, + updaterStatus: ReactHookStateUpdaterStatus.Unknown, + }; + } + return { + updaterFunction: null, + updaterStatus: ReactHookStateUpdaterStatus.DirectValue, + }; +}; diff --git a/packages/prover/src/utils/are-immutable-expressions-identical.ts b/packages/prover/src/utils/are-immutable-expressions-identical.ts new file mode 100644 index 000000000..4b44831bf --- /dev/null +++ b/packages/prover/src/utils/are-immutable-expressions-identical.ts @@ -0,0 +1,126 @@ +import ts from "typescript"; +import { unwrapTypescriptExpression } from "../unwrap-typescript-expression.js"; +import { collectSymbolWrites } from "./collect-symbol-writes.js"; +import { getResolvedSymbol } from "./get-resolved-symbol.js"; +import { collectPropertySymbolWrites } from "./collect-property-symbol-writes.js"; +import { isPlatformDeclarationSymbol } from "./is-platform-declaration-symbol.js"; + +const getImmutableInitializer = ( + symbol: ts.Symbol, + typeChecker: ts.TypeChecker, +): ts.Expression | null => { + for (const declaration of symbol.declarations ?? []) { + if ( + ts.isVariableDeclaration(declaration) && + ts.isVariableDeclarationList(declaration.parent) && + Boolean(declaration.parent.flags & ts.NodeFlags.Const) && + declaration.initializer && + collectSymbolWrites(symbol, declaration.getSourceFile(), typeChecker).length === 0 + ) { + return declaration.initializer; + } + } + return null; +}; + +const areLiteralExpressionsEqual = ( + leftExpression: ts.Expression, + rightExpression: ts.Expression, +): boolean => { + if ( + (ts.isStringLiteralLike(leftExpression) && ts.isStringLiteralLike(rightExpression)) || + (ts.isNumericLiteral(leftExpression) && ts.isNumericLiteral(rightExpression)) + ) { + return leftExpression.text === rightExpression.text; + } + return ( + (leftExpression.kind === ts.SyntaxKind.TrueKeyword || + leftExpression.kind === ts.SyntaxKind.FalseKeyword || + leftExpression.kind === ts.SyntaxKind.NullKeyword) && + leftExpression.kind === rightExpression.kind + ); +}; + +export const areImmutableExpressionsIdentical = ( + leftExpression: ts.Expression, + rightExpression: ts.Expression, + typeChecker: ts.TypeChecker, + visitedSymbols: ReadonlySet = new Set(), +): boolean => { + const unwrappedLeft = unwrapTypescriptExpression(leftExpression); + const unwrappedRight = unwrapTypescriptExpression(rightExpression); + if (unwrappedLeft === unwrappedRight) return true; + if (areLiteralExpressionsEqual(unwrappedLeft, unwrappedRight)) return true; + if ( + unwrappedLeft.kind === ts.SyntaxKind.ThisKeyword && + unwrappedRight.kind === ts.SyntaxKind.ThisKeyword + ) { + return true; + } + if ( + ts.isPropertyAccessExpression(unwrappedLeft) && + ts.isPropertyAccessExpression(unwrappedRight) + ) { + const leftPropertySymbol = getResolvedSymbol(unwrappedLeft.name, typeChecker); + const rightPropertySymbol = getResolvedSymbol(unwrappedRight.name, typeChecker); + const isStableProperty = Boolean( + leftPropertySymbol && + leftPropertySymbol === rightPropertySymbol && + (isPlatformDeclarationSymbol(leftPropertySymbol) || + (leftPropertySymbol.declarations?.every(ts.isMethodDeclaration) && + collectPropertySymbolWrites( + leftPropertySymbol, + unwrappedLeft.getSourceFile(), + typeChecker, + ).length === 0)), + ); + return ( + isStableProperty && + areImmutableExpressionsIdentical( + unwrappedLeft.expression, + unwrappedRight.expression, + typeChecker, + visitedSymbols, + ) + ); + } + if (!ts.isIdentifier(unwrappedLeft) || !ts.isIdentifier(unwrappedRight)) return false; + const leftSymbol = getResolvedSymbol(unwrappedLeft, typeChecker); + const rightSymbol = getResolvedSymbol(unwrappedRight, typeChecker); + if (!leftSymbol || !rightSymbol) return false; + if (leftSymbol === rightSymbol) { + return ( + isPlatformDeclarationSymbol(leftSymbol) || + Boolean(getImmutableInitializer(leftSymbol, typeChecker)) || + Boolean( + leftSymbol.declarations?.every( + (declaration) => + ts.isFunctionDeclaration(declaration) || + ts.isMethodDeclaration(declaration) || + ts.isParameter(declaration), + ), + ) + ); + } + if (visitedSymbols.has(leftSymbol) || visitedSymbols.has(rightSymbol)) return false; + const leftInitializer = getImmutableInitializer(leftSymbol, typeChecker); + const rightInitializer = getImmutableInitializer(rightSymbol, typeChecker); + const nextVisitedSymbols = new Set([...visitedSymbols, leftSymbol, rightSymbol]); + if (leftInitializer) { + return areImmutableExpressionsIdentical( + leftInitializer, + rightInitializer ?? unwrappedRight, + typeChecker, + nextVisitedSymbols, + ); + } + return Boolean( + rightInitializer && + areImmutableExpressionsIdentical( + unwrappedLeft, + rightInitializer, + typeChecker, + nextVisitedSymbols, + ), + ); +}; diff --git a/packages/prover/src/utils/are-proof-locations-equal.ts b/packages/prover/src/utils/are-proof-locations-equal.ts new file mode 100644 index 000000000..915d3ff0e --- /dev/null +++ b/packages/prover/src/utils/are-proof-locations-equal.ts @@ -0,0 +1,7 @@ +import type { ReactProofLocation } from "../types.js"; + +export const areProofLocationsEqual = ( + left: ReactProofLocation, + right: ReactProofLocation, +): boolean => + left.filePath === right.filePath && left.line === right.line && left.column === right.column; diff --git a/packages/prover/src/utils/collect-execution-callback-ids.ts b/packages/prover/src/utils/collect-execution-callback-ids.ts new file mode 100644 index 000000000..6fae671c8 --- /dev/null +++ b/packages/prover/src/utils/collect-execution-callback-ids.ts @@ -0,0 +1,37 @@ +import { getNodeLocation } from "../get-node-location.js"; +import { areProofLocationsEqual } from "./are-proof-locations-equal.js"; +import { getContainingFunction } from "./get-containing-function.js"; +import type ts from "typescript"; +import type { ReactSemanticCallback, ReactSemanticReachableFunction } from "../types.js"; + +export interface CollectExecutionCallbackIdsInput { + callbacks: ReadonlyArray; + evidenceNode: ts.Node | null; + ownerId: string; + reachableFunctions: ReadonlyArray; + rootDirectory: string; +} + +export const collectExecutionCallbackIds = ( + input: CollectExecutionCallbackIdsInput, +): ReadonlyArray => { + const containingFunction = input.evidenceNode ? getContainingFunction(input.evidenceNode) : null; + if (!containingFunction) return []; + const containingLocation = getNodeLocation(containingFunction, input.rootDirectory); + return [ + ...new Set([ + ...input.callbacks.flatMap((callback) => + callback.ownerId === input.ownerId && + areProofLocationsEqual(callback.location, containingLocation) + ? [callback.id] + : [], + ), + ...input.reachableFunctions.flatMap((reachableFunction) => + reachableFunction.ownerId === input.ownerId && + areProofLocationsEqual(reachableFunction.location, containingLocation) + ? [reachableFunction.rootCallbackId] + : [], + ), + ]), + ]; +}; diff --git a/packages/prover/src/utils/collect-jsx-spread-properties.ts b/packages/prover/src/utils/collect-jsx-spread-properties.ts new file mode 100644 index 000000000..e1421a1e6 --- /dev/null +++ b/packages/prover/src/utils/collect-jsx-spread-properties.ts @@ -0,0 +1,48 @@ +import ts from "typescript"; +import { doesTypeContainCallable } from "../resolve-callable-expression.js"; + +export interface JsxSpreadPropertiesDescriptor { + callablePropertyNames: ReadonlyArray; + hasUnknownProperties: boolean; + propertyNames: ReadonlyArray; +} + +export const collectJsxSpreadProperties = ( + expression: ts.Expression, + typeChecker: ts.TypeChecker, +): JsxSpreadPropertiesDescriptor => { + const callablePropertyNames = new Set(); + const propertyNames = new Set(); + const visitedTypes = new Set(); + let hasUnknownProperties = false; + + const visitType = (type: ts.Type): void => { + if (visitedTypes.has(type)) return; + visitedTypes.add(type); + if (type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown | ts.TypeFlags.TypeParameter)) { + hasUnknownProperties = true; + } + if (type.getStringIndexType() || type.getNumberIndexType()) { + hasUnknownProperties = true; + } + if (type.isUnionOrIntersection()) { + for (const memberType of type.types) visitType(memberType); + return; + } + for (const propertySymbol of type.getProperties()) { + const propertyName = propertySymbol.getName(); + propertyNames.add(propertyName); + const propertyType = typeChecker.getTypeOfSymbolAtLocation(propertySymbol, expression); + if (doesTypeContainCallable(propertyType, typeChecker)) { + callablePropertyNames.add(propertyName); + } + } + }; + + visitType(typeChecker.getTypeAtLocation(expression)); + return { + callablePropertyNames: [...callablePropertyNames].sort(), + hasUnknownProperties, + propertyNames: [...propertyNames].sort(), + }; +}; diff --git a/packages/prover/src/utils/collect-property-symbol-writes.ts b/packages/prover/src/utils/collect-property-symbol-writes.ts new file mode 100644 index 000000000..cad5fcc89 --- /dev/null +++ b/packages/prover/src/utils/collect-property-symbol-writes.ts @@ -0,0 +1,59 @@ +import ts from "typescript"; +import { getResolvedSymbol } from "./get-resolved-symbol.js"; +import { isAssignmentOperator } from "./is-assignment-operator.js"; + +export const collectPropertySymbolWrites = ( + symbol: ts.Symbol, + sourceFile: ts.SourceFile, + typeChecker: ts.TypeChecker, +): ReadonlyArray => { + const isPropertyTarget = (node: ts.Node): boolean => { + if (ts.isPropertyAccessExpression(node)) { + return getResolvedSymbol(node.name, typeChecker) === symbol; + } + if (ts.isElementAccessExpression(node)) { + return getResolvedSymbol(node, typeChecker) === symbol; + } + if ( + ts.isParenthesizedExpression(node) || + ts.isAsExpression(node) || + ts.isTypeAssertionExpression(node) || + ts.isNonNullExpression(node) || + ts.isSatisfiesExpression(node) + ) { + return isPropertyTarget(node.expression); + } + return false; + }; + const writes: ts.Node[] = []; + const visit = (node: ts.Node): void => { + if ( + ts.isBinaryExpression(node) && + isAssignmentOperator(node.operatorToken.kind) && + isPropertyTarget(node.left) + ) { + writes.push(node); + } + if ( + (ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) && + (node.operator === ts.SyntaxKind.PlusPlusToken || + node.operator === ts.SyntaxKind.MinusMinusToken) && + isPropertyTarget(node.operand) + ) { + writes.push(node); + } + if (ts.isDeleteExpression(node) && isPropertyTarget(node.expression)) { + writes.push(node); + } + if ( + (ts.isForInStatement(node) || ts.isForOfStatement(node)) && + !ts.isVariableDeclarationList(node.initializer) && + isPropertyTarget(node.initializer) + ) { + writes.push(node); + } + node.forEachChild(visit); + }; + sourceFile.forEachChild(visit); + return writes; +}; diff --git a/packages/prover/src/utils/collect-reachable-call-expressions.ts b/packages/prover/src/utils/collect-reachable-call-expressions.ts new file mode 100644 index 000000000..dacef0ba3 --- /dev/null +++ b/packages/prover/src/utils/collect-reachable-call-expressions.ts @@ -0,0 +1,19 @@ +import ts from "typescript"; +import { collectReachableFunctions } from "../collect-reachable-functions.js"; +import { isFunctionBoundary } from "../is-function-boundary.js"; + +export const collectReachableCallExpressions = ( + functionNode: ts.FunctionLikeDeclaration, + typeChecker: ts.TypeChecker, +): ReadonlyArray => { + const calls: ts.CallExpression[] = []; + for (const reachableFunction of collectReachableFunctions(functionNode, typeChecker)) { + const visit = (node: ts.Node): void => { + if (node !== reachableFunction.functionNode && isFunctionBoundary(node)) return; + if (ts.isCallExpression(node)) calls.push(node); + node.forEachChild(visit); + }; + reachableFunction.functionNode.forEachChild(visit); + } + return calls; +}; diff --git a/packages/prover/src/utils/collect-symbol-writes.ts b/packages/prover/src/utils/collect-symbol-writes.ts new file mode 100644 index 000000000..94c9ee231 --- /dev/null +++ b/packages/prover/src/utils/collect-symbol-writes.ts @@ -0,0 +1,77 @@ +import ts from "typescript"; +import { isAssignmentOperator } from "./is-assignment-operator.js"; + +export const collectSymbolWrites = ( + symbol: ts.Symbol, + sourceFile: ts.SourceFile, + typeChecker: ts.TypeChecker, +): ReadonlyArray => { + const writes: ts.Node[] = []; + const isSymbolWriteTarget = (node: ts.Node): boolean => { + if (ts.isIdentifier(node)) return typeChecker.getSymbolAtLocation(node) === symbol; + if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) { + return isSymbolWriteTarget(node.expression); + } + if ( + ts.isParenthesizedExpression(node) || + ts.isAsExpression(node) || + ts.isTypeAssertionExpression(node) || + ts.isNonNullExpression(node) || + ts.isSatisfiesExpression(node) + ) { + return isSymbolWriteTarget(node.expression); + } + if (ts.isArrayLiteralExpression(node)) { + return node.elements.some((element) => + ts.isSpreadElement(element) + ? isSymbolWriteTarget(element.expression) + : isSymbolWriteTarget(element), + ); + } + if (ts.isObjectLiteralExpression(node)) { + return node.properties.some((property) => { + if (ts.isShorthandPropertyAssignment(property)) { + return isSymbolWriteTarget(property.name); + } + if (ts.isPropertyAssignment(property)) { + return isSymbolWriteTarget(property.initializer); + } + if (ts.isSpreadAssignment(property)) { + return isSymbolWriteTarget(property.expression); + } + return false; + }); + } + return false; + }; + const visit = (node: ts.Node): void => { + if ( + ts.isBinaryExpression(node) && + isAssignmentOperator(node.operatorToken.kind) && + isSymbolWriteTarget(node.left) + ) { + writes.push(node); + } + if ( + (ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) && + (node.operator === ts.SyntaxKind.PlusPlusToken || + node.operator === ts.SyntaxKind.MinusMinusToken) && + isSymbolWriteTarget(node.operand) + ) { + writes.push(node); + } + if (ts.isDeleteExpression(node) && isSymbolWriteTarget(node.expression)) { + writes.push(node); + } + if ( + (ts.isForInStatement(node) || ts.isForOfStatement(node)) && + !ts.isVariableDeclarationList(node.initializer) && + isSymbolWriteTarget(node.initializer) + ) { + writes.push(node); + } + node.forEachChild(visit); + }; + sourceFile.forEachChild(visit); + return writes; +}; diff --git a/packages/prover/src/utils/contains-await-outside-nested-function.ts b/packages/prover/src/utils/contains-await-outside-nested-function.ts new file mode 100644 index 000000000..550782a83 --- /dev/null +++ b/packages/prover/src/utils/contains-await-outside-nested-function.ts @@ -0,0 +1,21 @@ +import ts from "typescript"; +import { isFunctionBoundary } from "../is-function-boundary.js"; + +export const containsAwaitOutsideNestedFunction = ( + node: ts.Node, + ownerFunction: ts.FunctionLikeDeclaration, +): boolean => { + let didFindAwait = false; + const visit = (currentNode: ts.Node): void => { + if (didFindAwait || (currentNode !== ownerFunction && isFunctionBoundary(currentNode))) { + return; + } + if (ts.isAwaitExpression(currentNode)) { + didFindAwait = true; + return; + } + currentNode.forEachChild(visit); + }; + node.forEachChild(visit); + return didFindAwait; +}; diff --git a/packages/prover/src/utils/create-semantic-id.ts b/packages/prover/src/utils/create-semantic-id.ts new file mode 100644 index 000000000..670fa00af --- /dev/null +++ b/packages/prover/src/utils/create-semantic-id.ts @@ -0,0 +1,13 @@ +import type ts from "typescript"; +import { getNodeLocation } from "../get-node-location.js"; +import type { ReactAnalysisContext } from "../types.js"; + +export const createSemanticId = ( + kind: string, + name: string, + node: ts.Node, + context: ReactAnalysisContext, +): string => { + const location = getNodeLocation(node, context.rootDirectory); + return `${location.filePath}:${location.line}:${location.column}:${kind}:${name}`; +}; diff --git a/packages/prover/src/utils/does-type-have-call-signature.ts b/packages/prover/src/utils/does-type-have-call-signature.ts new file mode 100644 index 000000000..731921b95 --- /dev/null +++ b/packages/prover/src/utils/does-type-have-call-signature.ts @@ -0,0 +1,5 @@ +import ts from "typescript"; + +export const doesTypeHaveCallSignature = (type: ts.Type): boolean => + type.getCallSignatures().length > 0 || + (type.isUnionOrIntersection() && type.types.some(doesTypeHaveCallSignature)); diff --git a/packages/prover/src/utils/get-class-method-declaration.ts b/packages/prover/src/utils/get-class-method-declaration.ts new file mode 100644 index 000000000..d8274e21f --- /dev/null +++ b/packages/prover/src/utils/get-class-method-declaration.ts @@ -0,0 +1,21 @@ +import ts from "typescript"; +import { getStaticPropertyName } from "./get-static-property-name.js"; + +export const getClassMethodDeclaration = ( + classNode: ts.ClassDeclaration, + methodName: string, +): ts.MethodDeclaration | null => + classNode.members.find( + (member): member is ts.MethodDeclaration => + ts.isMethodDeclaration(member) && + getStaticPropertyName(member.name) === methodName && + !member.modifiers?.some( + (modifier) => + modifier.kind === ts.SyntaxKind.StaticKeyword || + modifier.kind === ts.SyntaxKind.AsyncKeyword || + modifier.kind === ts.SyntaxKind.AbstractKeyword, + ) && + !member.asteriskToken && + !member.questionToken && + Boolean(member.body), + ) ?? null; diff --git a/packages/prover/src/utils/get-containing-function.ts b/packages/prover/src/utils/get-containing-function.ts new file mode 100644 index 000000000..e56ab3920 --- /dev/null +++ b/packages/prover/src/utils/get-containing-function.ts @@ -0,0 +1,11 @@ +import ts from "typescript"; +import { isFunctionBoundary } from "../is-function-boundary.js"; + +export const getContainingFunction = (node: ts.Node): ts.FunctionLikeDeclaration | null => { + let currentNode = node.parent; + while (currentNode) { + if (isFunctionBoundary(currentNode)) return currentNode; + currentNode = currentNode.parent; + } + return null; +}; diff --git a/packages/prover/src/utils/get-enclosing-function.ts b/packages/prover/src/utils/get-enclosing-function.ts new file mode 100644 index 000000000..de8e4415c --- /dev/null +++ b/packages/prover/src/utils/get-enclosing-function.ts @@ -0,0 +1,11 @@ +import ts from "typescript"; +import { isFunctionBoundary } from "../is-function-boundary.js"; + +export const getEnclosingFunction = (node: ts.Node): ts.FunctionLikeDeclaration | null => { + let currentNode = node.parent; + while (currentNode) { + if (isFunctionBoundary(currentNode)) return currentNode; + currentNode = currentNode.parent; + } + return null; +}; diff --git a/packages/prover/src/utils/get-expression-symbol.ts b/packages/prover/src/utils/get-expression-symbol.ts new file mode 100644 index 000000000..1f2dc1db0 --- /dev/null +++ b/packages/prover/src/utils/get-expression-symbol.ts @@ -0,0 +1,10 @@ +import type ts from "typescript"; +import { resolveAliasedSymbol } from "./resolve-aliased-symbol.js"; + +export const getExpressionSymbol = ( + expression: ts.Expression | ts.JsxTagNameExpression, + typeChecker: ts.TypeChecker, +): ts.Symbol | null => { + const symbol = typeChecker.getSymbolAtLocation(expression); + return symbol ? resolveAliasedSymbol(symbol, typeChecker) : null; +}; diff --git a/packages/prover/src/utils/get-jsx-component-target-function.ts b/packages/prover/src/utils/get-jsx-component-target-function.ts new file mode 100644 index 000000000..df86c4b5d --- /dev/null +++ b/packages/prover/src/utils/get-jsx-component-target-function.ts @@ -0,0 +1,15 @@ +import ts from "typescript"; + +export const getJsxComponentTargetFunction = ( + openingElement: ts.JsxOpeningLikeElement, + unitFunctionsBySymbol: ReadonlyMap, + typeChecker: ts.TypeChecker, +): ts.FunctionLikeDeclaration | null => { + const directSymbol = typeChecker.getSymbolAtLocation(openingElement.tagName); + if (!directSymbol) return null; + const targetSymbol = + directSymbol.flags & ts.SymbolFlags.Alias + ? typeChecker.getAliasedSymbol(directSymbol) + : directSymbol; + return unitFunctionsBySymbol.get(targetSymbol) ?? null; +}; diff --git a/packages/prover/src/utils/get-jsx-opening-element-for-attribute.ts b/packages/prover/src/utils/get-jsx-opening-element-for-attribute.ts new file mode 100644 index 000000000..4b227296f --- /dev/null +++ b/packages/prover/src/utils/get-jsx-opening-element-for-attribute.ts @@ -0,0 +1,10 @@ +import ts from "typescript"; + +export const getJsxOpeningElementForAttribute = ( + attribute: ts.JsxAttribute, +): ts.JsxOpeningLikeElement | null => { + const openingElement = attribute.parent.parent; + return ts.isJsxOpeningElement(openingElement) || ts.isJsxSelfClosingElement(openingElement) + ? openingElement + : null; +}; diff --git a/packages/prover/src/utils/get-platform-effect-resource-kind.ts b/packages/prover/src/utils/get-platform-effect-resource-kind.ts new file mode 100644 index 000000000..76b56311d --- /dev/null +++ b/packages/prover/src/utils/get-platform-effect-resource-kind.ts @@ -0,0 +1,58 @@ +import ts from "typescript"; +import { PLATFORM_OBSERVER_KINDS } from "../constants.js"; +import { ReactEffectResourceKind } from "../types.js"; +import { getResolvedSymbol } from "./get-resolved-symbol.js"; +import { isPlatformDeclarationSymbol } from "./is-platform-declaration-symbol.js"; + +const isPlatformMember = ( + node: ts.Node, + expectedName: string, + typeChecker: ts.TypeChecker, +): boolean => { + const symbol = getResolvedSymbol(node, typeChecker); + return Boolean( + symbol && symbol.getName() === expectedName && isPlatformDeclarationSymbol(symbol), + ); +}; + +const getObserverKind = ( + expression: ts.Expression, + typeChecker: ts.TypeChecker, +): ReactEffectResourceKind | null => { + const expressionType = typeChecker.getTypeAtLocation(expression); + const typeNames = expressionType.isUnionOrIntersection() + ? expressionType.types.map((memberType) => memberType.getSymbol()?.getName()) + : [expressionType.getSymbol()?.getName()]; + const kinds = [ + ...new Set( + typeNames.flatMap((typeName) => { + const kind = typeName ? PLATFORM_OBSERVER_KINDS.get(typeName) : null; + return kind ? [kind] : []; + }), + ), + ]; + return kinds.length === 1 ? (kinds[0] ?? null) : null; +}; + +export const getPlatformEffectResourceKind = ( + callExpression: ts.CallExpression, + typeChecker: ts.TypeChecker, +): ReactEffectResourceKind | null => { + if (!ts.isPropertyAccessExpression(callExpression.expression)) return null; + if ( + callExpression.expression.name.text === "addEventListener" && + isPlatformMember(callExpression.expression.name, "addEventListener", typeChecker) + ) { + return ReactEffectResourceKind.EventListener; + } + if ( + callExpression.expression.name.text === "observe" && + isPlatformMember(callExpression.expression.name, "observe", typeChecker) + ) { + return ( + getObserverKind(callExpression.expression.expression, typeChecker) ?? + ReactEffectResourceKind.Observer + ); + } + return null; +}; diff --git a/packages/prover/src/utils/get-resolved-symbol.ts b/packages/prover/src/utils/get-resolved-symbol.ts new file mode 100644 index 000000000..ee232b80f --- /dev/null +++ b/packages/prover/src/utils/get-resolved-symbol.ts @@ -0,0 +1,11 @@ +import ts from "typescript"; + +export const getResolvedSymbol = (node: ts.Node, typeChecker: ts.TypeChecker): ts.Symbol | null => { + const shorthandValueSymbol = + ts.isIdentifier(node) && ts.isShorthandPropertyAssignment(node.parent) + ? typeChecker.getShorthandAssignmentValueSymbol(node.parent) + : undefined; + const symbol = shorthandValueSymbol ?? typeChecker.getSymbolAtLocation(node); + if (!symbol) return null; + return symbol.flags & ts.SymbolFlags.Alias ? typeChecker.getAliasedSymbol(symbol) : symbol; +}; diff --git a/packages/prover/src/utils/get-static-access-member-name.ts b/packages/prover/src/utils/get-static-access-member-name.ts new file mode 100644 index 000000000..b21385008 --- /dev/null +++ b/packages/prover/src/utils/get-static-access-member-name.ts @@ -0,0 +1,11 @@ +import ts from "typescript"; + +export const getStaticAccessMemberName = ( + expression: ts.PropertyAccessExpression | ts.ElementAccessExpression, +): string | null => { + if (ts.isPropertyAccessExpression(expression)) return expression.name.text; + const argument = expression.argumentExpression; + return argument && (ts.isStringLiteralLike(argument) || ts.isNumericLiteral(argument)) + ? argument.text + : null; +}; diff --git a/packages/prover/src/utils/get-static-class-method-declaration.ts b/packages/prover/src/utils/get-static-class-method-declaration.ts new file mode 100644 index 000000000..1f371c53d --- /dev/null +++ b/packages/prover/src/utils/get-static-class-method-declaration.ts @@ -0,0 +1,23 @@ +import ts from "typescript"; +import { getStaticPropertyName } from "./get-static-property-name.js"; + +export const getStaticClassMethodDeclaration = ( + classNode: ts.ClassDeclaration, + methodName: string, +): ts.MethodDeclaration | null => + classNode.members.find( + (member): member is ts.MethodDeclaration => + ts.isMethodDeclaration(member) && + getStaticPropertyName(member.name) === methodName && + Boolean( + member.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.StaticKeyword), + ) && + !member.modifiers?.some( + (modifier) => + modifier.kind === ts.SyntaxKind.AsyncKeyword || + modifier.kind === ts.SyntaxKind.AbstractKeyword, + ) && + !member.asteriskToken && + !member.questionToken && + Boolean(member.body), + ) ?? null; diff --git a/packages/prover/src/utils/get-static-property-name.ts b/packages/prover/src/utils/get-static-property-name.ts new file mode 100644 index 000000000..cb1efc8d3 --- /dev/null +++ b/packages/prover/src/utils/get-static-property-name.ts @@ -0,0 +1,14 @@ +import ts from "typescript"; + +export const getStaticPropertyName = (propertyName: ts.PropertyName): string | null => { + if ( + ts.isIdentifier(propertyName) || + ts.isPrivateIdentifier(propertyName) || + ts.isStringLiteralLike(propertyName) || + ts.isNumericLiteral(propertyName) || + ts.isBigIntLiteral(propertyName) + ) { + return propertyName.text; + } + return ts.isStringLiteralLike(propertyName.expression) ? propertyName.expression.text : null; +}; diff --git a/packages/prover/src/utils/has-conditional-ancestor.ts b/packages/prover/src/utils/has-conditional-ancestor.ts new file mode 100644 index 000000000..ac1c7375c --- /dev/null +++ b/packages/prover/src/utils/has-conditional-ancestor.ts @@ -0,0 +1,31 @@ +import ts from "typescript"; + +export const hasConditionalAncestor = ( + node: ts.Node, + ownerFunction: ts.FunctionLikeDeclaration, +): boolean => { + let currentNode = node; + while (currentNode !== ownerFunction) { + const parentNode = currentNode.parent; + if (!parentNode) return true; + if ( + ts.isIfStatement(parentNode) || + ts.isConditionalExpression(parentNode) || + ts.isSwitchStatement(parentNode) || + ts.isForStatement(parentNode) || + ts.isForInStatement(parentNode) || + ts.isForOfStatement(parentNode) || + ts.isWhileStatement(parentNode) || + ts.isDoStatement(parentNode) || + ts.isTryStatement(parentNode) || + (ts.isBinaryExpression(parentNode) && + (parentNode.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken || + parentNode.operatorToken.kind === ts.SyntaxKind.BarBarToken || + parentNode.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken)) + ) { + return true; + } + currentNode = parentNode; + } + return false; +}; diff --git a/packages/prover/src/utils/has-guaranteed-effect-cleanup.ts b/packages/prover/src/utils/has-guaranteed-effect-cleanup.ts new file mode 100644 index 000000000..10dbd06b3 --- /dev/null +++ b/packages/prover/src/utils/has-guaranteed-effect-cleanup.ts @@ -0,0 +1,18 @@ +import ts from "typescript"; +import { resolveFunction } from "../resolve-function.js"; +import { summarizeFunctionReturns } from "../summarize-function-returns.js"; + +export const hasGuaranteedEffectCleanup = ( + effectCallback: ts.FunctionLikeDeclaration, + typeChecker: ts.TypeChecker, +): boolean => { + const returnSummary = summarizeFunctionReturns(effectCallback, typeChecker); + return ( + returnSummary.isComplete && + !returnSummary.canFallThrough && + returnSummary.expressions.length > 0 && + returnSummary.expressions.every((returnExpression) => + Boolean(resolveFunction(returnExpression.expression, typeChecker)), + ) + ); +}; diff --git a/packages/prover/src/utils/is-assignment-operator.ts b/packages/prover/src/utils/is-assignment-operator.ts new file mode 100644 index 000000000..027bbb1b9 --- /dev/null +++ b/packages/prover/src/utils/is-assignment-operator.ts @@ -0,0 +1,4 @@ +import ts from "typescript"; + +export const isAssignmentOperator = (operator: ts.SyntaxKind): boolean => + operator >= ts.SyntaxKind.FirstAssignment && operator <= ts.SyntaxKind.LastAssignment; diff --git a/packages/prover/src/utils/is-deferred-callback-synchronous.ts b/packages/prover/src/utils/is-deferred-callback-synchronous.ts new file mode 100644 index 000000000..09ca67793 --- /dev/null +++ b/packages/prover/src/utils/is-deferred-callback-synchronous.ts @@ -0,0 +1,35 @@ +import ts from "typescript"; +import { getPlatformSchedulerKind } from "../collect-effect-scheduler-protocols.js"; +import { collectReachableFunctions } from "../collect-reachable-functions.js"; +import { PROMISE_CONTINUATION_METHOD_NAMES } from "../constants.js"; +import type { ReactAnalysisContext } from "../types.js"; +import { collectReachableCallExpressions } from "./collect-reachable-call-expressions.js"; +import { containsAwaitOutsideNestedFunction } from "./contains-await-outside-nested-function.js"; + +const containsThenableType = (type: ts.Type, typeChecker: ts.TypeChecker): boolean => + Boolean(typeChecker.getPropertyOfType(type, "then")) || + (type.isUnionOrIntersection() && + type.types.some((memberType) => containsThenableType(memberType, typeChecker))); + +export const isDeferredCallbackSynchronous = ( + callback: ts.FunctionLikeDeclaration, + context: ReactAnalysisContext, +): boolean => + collectReachableFunctions(callback, context.typeChecker).every( + (reachableFunction) => + !(ts.getCombinedModifierFlags(reachableFunction.functionNode) & ts.ModifierFlags.Async) && + !containsAwaitOutsideNestedFunction( + reachableFunction.functionNode, + reachableFunction.functionNode, + ), + ) && + !collectReachableCallExpressions(callback, context.typeChecker).some( + (callExpression) => + Boolean(getPlatformSchedulerKind(callExpression, context)) || + containsThenableType( + context.typeChecker.getTypeAtLocation(callExpression), + context.typeChecker, + ) || + (ts.isPropertyAccessExpression(callExpression.expression) && + PROMISE_CONTINUATION_METHOD_NAMES.has(callExpression.expression.name.text)), + ); diff --git a/packages/prover/src/utils/is-direct-component-properties-object.ts b/packages/prover/src/utils/is-direct-component-properties-object.ts new file mode 100644 index 000000000..6a7ee4112 --- /dev/null +++ b/packages/prover/src/utils/is-direct-component-properties-object.ts @@ -0,0 +1,28 @@ +import ts from "typescript"; + +export const isDirectComponentPropertiesObject = ( + expression: ts.Expression, + functionNode: ts.FunctionLikeDeclaration, + typeChecker: ts.TypeChecker, +): boolean => { + if (!ts.isIdentifier(expression)) return false; + const expressionSymbol = typeChecker.getSymbolAtLocation(expression); + if (!expressionSymbol) return false; + return functionNode.parameters.some((parameter) => { + if ( + ts.isIdentifier(parameter.name) && + typeChecker.getSymbolAtLocation(parameter.name) === expressionSymbol + ) { + return true; + } + return ( + ts.isObjectBindingPattern(parameter.name) && + parameter.name.elements.some( + (element) => + Boolean(element.dotDotDotToken) && + ts.isIdentifier(element.name) && + typeChecker.getSymbolAtLocation(element.name) === expressionSymbol, + ) + ); + }); +}; diff --git a/packages/prover/src/utils/is-effective-jsx-property-source.ts b/packages/prover/src/utils/is-effective-jsx-property-source.ts new file mode 100644 index 000000000..14ac5aa5e --- /dev/null +++ b/packages/prover/src/utils/is-effective-jsx-property-source.ts @@ -0,0 +1,25 @@ +import ts from "typescript"; +import { collectJsxSpreadProperties } from "./collect-jsx-spread-properties.js"; + +export const isEffectiveJsxPropertySource = ( + attribute: ts.JsxAttributeLike, + propertyName: string, + typeChecker: ts.TypeChecker, +): boolean => { + const attributeIndex = attribute.parent.properties.indexOf(attribute); + for (const laterAttribute of attribute.parent.properties.slice(attributeIndex + 1)) { + if (ts.isJsxAttribute(laterAttribute) && laterAttribute.name.getText() === propertyName) { + return false; + } + if (ts.isJsxSpreadAttribute(laterAttribute)) { + const spreadProperties = collectJsxSpreadProperties(laterAttribute.expression, typeChecker); + if ( + spreadProperties.hasUnknownProperties || + spreadProperties.propertyNames.includes(propertyName) + ) { + return false; + } + } + } + return true; +}; diff --git a/packages/prover/src/utils/is-entry-dominating-node.ts b/packages/prover/src/utils/is-entry-dominating-node.ts new file mode 100644 index 000000000..f98f09164 --- /dev/null +++ b/packages/prover/src/utils/is-entry-dominating-node.ts @@ -0,0 +1,23 @@ +import ts from "typescript"; +import { getEnclosingFunction } from "./get-enclosing-function.js"; +import { hasConditionalAncestor } from "./has-conditional-ancestor.js"; + +export const isEntryDominatingNode = ( + node: ts.Node, + functionNode: ts.FunctionLikeDeclaration, +): boolean => { + if ( + getEnclosingFunction(node) !== functionNode || + hasConditionalAncestor(node, functionNode) || + !functionNode.body + ) { + return false; + } + if (!ts.isBlock(functionNode.body)) return functionNode.body === node; + const firstStatement = functionNode.body.statements[0]; + return Boolean( + firstStatement && + ((ts.isExpressionStatement(firstStatement) && firstStatement.expression === node) || + (ts.isReturnStatement(firstStatement) && firstStatement.expression === node)), + ); +}; diff --git a/packages/prover/src/utils/is-intrinsic-jsx-element.ts b/packages/prover/src/utils/is-intrinsic-jsx-element.ts new file mode 100644 index 000000000..3f5583765 --- /dev/null +++ b/packages/prover/src/utils/is-intrinsic-jsx-element.ts @@ -0,0 +1,4 @@ +import ts from "typescript"; + +export const isIntrinsicJsxElement = (openingElement: ts.JsxOpeningLikeElement): boolean => + ts.isIdentifier(openingElement.tagName) && /^[a-z]/.test(openingElement.tagName.text); diff --git a/packages/prover/src/utils/is-jsx-spread-source-complete.ts b/packages/prover/src/utils/is-jsx-spread-source-complete.ts new file mode 100644 index 000000000..5c4843fbc --- /dev/null +++ b/packages/prover/src/utils/is-jsx-spread-source-complete.ts @@ -0,0 +1,89 @@ +import ts from "typescript"; +import { isNodeWithin } from "../is-node-within.js"; +import { resolveCallableExpression } from "../resolve-callable-expression.js"; +import { unwrapTypescriptExpression } from "../unwrap-typescript-expression.js"; +import { collectSymbolWrites } from "./collect-symbol-writes.js"; +import { isDirectComponentPropertiesObject } from "./is-direct-component-properties-object.js"; + +export const isJsxSpreadSourceComplete = ( + expression: ts.Expression, + ownerFunction: ts.FunctionLikeDeclaration, + typeChecker: ts.TypeChecker, +): boolean => { + const visitedSymbols = new Set(); + const hasOnlyImmutableSymbolUses = ( + symbol: ts.Symbol, + declaration: ts.VariableDeclaration, + currentExpression: ts.Expression, + ): boolean => { + let hasUnknownUse = false; + const visitNode = (node: ts.Node): void => { + if ( + ts.isIdentifier(node) && + typeChecker.getSymbolAtLocation(node) === symbol && + node !== declaration.name && + node !== currentExpression + ) { + const parent = node.parent; + if ( + ts.isJsxSpreadAttribute(parent) && + unwrapTypescriptExpression(parent.expression) === node + ) { + return; + } + if ( + (ts.isPropertyAccessExpression(parent) || ts.isElementAccessExpression(parent)) && + parent.expression === node + ) { + if (ts.isCallExpression(parent.parent) && parent.parent.expression === parent) { + hasUnknownUse = true; + } + return; + } + hasUnknownUse = true; + } + node.forEachChild(visitNode); + }; + declaration.getSourceFile().forEachChild(visitNode); + return !hasUnknownUse; + }; + const visitExpression = (candidateExpression: ts.Expression): boolean => { + const unwrappedExpression = unwrapTypescriptExpression(candidateExpression); + if (isDirectComponentPropertiesObject(unwrappedExpression, ownerFunction, typeChecker)) { + return true; + } + if (ts.isObjectLiteralExpression(unwrappedExpression)) { + return resolveCallableExpression(unwrappedExpression, typeChecker).isComplete; + } + if (!ts.isIdentifier(unwrappedExpression)) return false; + const directSymbol = typeChecker.getSymbolAtLocation(unwrappedExpression); + const symbol = + directSymbol && (directSymbol.flags & ts.SymbolFlags.Alias) !== 0 + ? typeChecker.getAliasedSymbol(directSymbol) + : directSymbol; + if (!symbol || visitedSymbols.has(symbol)) return false; + visitedSymbols.add(symbol); + const declaration = symbol.declarations?.[0]; + if ( + !declaration || + collectSymbolWrites(symbol, declaration.getSourceFile(), typeChecker).length > 0 + ) { + return false; + } + for (const symbolDeclaration of symbol.declarations ?? []) { + if ( + ts.isVariableDeclaration(symbolDeclaration) && + ts.isVariableDeclarationList(symbolDeclaration.parent) && + Boolean(symbolDeclaration.parent.flags & ts.NodeFlags.Const) && + isNodeWithin(symbolDeclaration, ownerFunction) && + symbolDeclaration.initializer && + hasOnlyImmutableSymbolUses(symbol, symbolDeclaration, unwrappedExpression) && + visitExpression(symbolDeclaration.initializer) + ) { + return true; + } + } + return false; + }; + return visitExpression(expression); +}; diff --git a/packages/prover/src/utils/is-memo-observation-covered.ts b/packages/prover/src/utils/is-memo-observation-covered.ts new file mode 100644 index 000000000..d6b53eccc --- /dev/null +++ b/packages/prover/src/utils/is-memo-observation-covered.ts @@ -0,0 +1,10 @@ +export const isMemoObservationCovered = ( + observationPath: string, + equalPropPaths: ReadonlyArray, +): boolean => + equalPropPaths.some( + (equalPropPath) => + equalPropPath.length === 0 || + equalPropPath === observationPath || + (observationPath !== "*" && observationPath.startsWith(`${equalPropPath}.`)), + ); diff --git a/packages/prover/src/utils/is-platform-declaration-symbol.ts b/packages/prover/src/utils/is-platform-declaration-symbol.ts new file mode 100644 index 000000000..3b46c8745 --- /dev/null +++ b/packages/prover/src/utils/is-platform-declaration-symbol.ts @@ -0,0 +1,13 @@ +import type ts from "typescript"; + +export const isPlatformDeclarationSymbol = (symbol: ts.Symbol | null): boolean => + Boolean( + symbol?.declarations?.length && + symbol.declarations.every((declaration) => { + const sourceFileName = declaration.getSourceFile().fileName.replaceAll("\\", "/"); + return ( + sourceFileName.includes("/typescript/lib/lib.") || + sourceFileName.includes("/node_modules/@types/node/") + ); + }), + ); diff --git a/packages/prover/src/utils/is-platform-resource-value.ts b/packages/prover/src/utils/is-platform-resource-value.ts new file mode 100644 index 000000000..0bb14d8ef --- /dev/null +++ b/packages/prover/src/utils/is-platform-resource-value.ts @@ -0,0 +1,54 @@ +import ts from "typescript"; +import { unwrapTypescriptExpression } from "../unwrap-typescript-expression.js"; +import { collectSymbolWrites } from "./collect-symbol-writes.js"; +import { getResolvedSymbol } from "./get-resolved-symbol.js"; +import { isPlatformDeclarationSymbol } from "./is-platform-declaration-symbol.js"; + +export const isPlatformResourceValue = ( + expression: ts.Expression, + typeChecker: ts.TypeChecker, + visitedSymbols: ReadonlySet = new Set(), +): boolean => { + const unwrappedExpression = unwrapTypescriptExpression(expression); + if (ts.isIdentifier(unwrappedExpression)) { + const symbol = getResolvedSymbol(unwrappedExpression, typeChecker); + if (isPlatformDeclarationSymbol(symbol)) return true; + if (!symbol || visitedSymbols.has(symbol)) return false; + for (const declaration of symbol.declarations ?? []) { + if ( + ts.isVariableDeclaration(declaration) && + ts.isVariableDeclarationList(declaration.parent) && + Boolean(declaration.parent.flags & ts.NodeFlags.Const) && + declaration.initializer && + collectSymbolWrites(symbol, declaration.getSourceFile(), typeChecker).length === 0 + ) { + return isPlatformResourceValue( + declaration.initializer, + typeChecker, + new Set([...visitedSymbols, symbol]), + ); + } + } + return false; + } + if (ts.isPropertyAccessExpression(unwrappedExpression)) { + return ( + isPlatformDeclarationSymbol(getResolvedSymbol(unwrappedExpression.name, typeChecker)) && + isPlatformResourceValue(unwrappedExpression.expression, typeChecker, visitedSymbols) + ); + } + if (ts.isCallExpression(unwrappedExpression)) { + const callTarget = unwrappedExpression.expression; + if (ts.isPropertyAccessExpression(callTarget)) { + return ( + isPlatformDeclarationSymbol(getResolvedSymbol(callTarget.name, typeChecker)) && + isPlatformResourceValue(callTarget.expression, typeChecker, visitedSymbols) + ); + } + return isPlatformDeclarationSymbol(getResolvedSymbol(callTarget, typeChecker)); + } + return ( + ts.isNewExpression(unwrappedExpression) && + isPlatformDeclarationSymbol(getResolvedSymbol(unwrappedExpression.expression, typeChecker)) + ); +}; diff --git a/packages/prover/src/utils/is-react-hook-dependency-reference.ts b/packages/prover/src/utils/is-react-hook-dependency-reference.ts new file mode 100644 index 000000000..e12e1914d --- /dev/null +++ b/packages/prover/src/utils/is-react-hook-dependency-reference.ts @@ -0,0 +1,25 @@ +import ts from "typescript"; +import { getCanonicalHookName } from "../get-canonical-hook-name.js"; +import { unwrapTypescriptExpression } from "../unwrap-typescript-expression.js"; + +export const isReactHookDependencyReference = ( + identifier: ts.Identifier, + typeChecker: ts.TypeChecker, +): boolean => { + let currentNode: ts.Node = identifier; + while ( + currentNode.parent && + ts.isExpression(currentNode.parent) && + unwrapTypescriptExpression(currentNode.parent) === identifier + ) { + currentNode = currentNode.parent; + } + if (!currentNode.parent || !ts.isArrayLiteralExpression(currentNode.parent)) return false; + const dependencyArray = currentNode.parent; + const hookCall = dependencyArray.parent; + if (!ts.isCallExpression(hookCall)) return false; + return ( + hookCall.arguments.indexOf(dependencyArray) > 0 && + Boolean(getCanonicalHookName(hookCall, typeChecker)) + ); +}; diff --git a/packages/prover/src/utils/is-react-set-state-call.ts b/packages/prover/src/utils/is-react-set-state-call.ts new file mode 100644 index 000000000..210388166 --- /dev/null +++ b/packages/prover/src/utils/is-react-set-state-call.ts @@ -0,0 +1,40 @@ +import ts from "typescript"; +import { ReactClassComponentBase } from "../types.js"; +import { unwrapTypescriptExpression } from "../unwrap-typescript-expression.js"; +import type { ReactAnalysisContext } from "../types.js"; +import { getResolvedSymbol } from "./get-resolved-symbol.js"; + +const getEnclosingClass = (node: ts.Node): ts.ClassLikeDeclaration | null => { + let currentNode: ts.Node | undefined = node.parent; + while (currentNode) { + if (ts.isClassLike(currentNode)) return currentNode; + currentNode = currentNode.parent; + } + return null; +}; + +export const isReactSetStateCall = ( + callExpression: ts.CallExpression, + context: ReactAnalysisContext, +): boolean => { + const callTarget = unwrapTypescriptExpression(callExpression.expression); + if ( + !ts.isPropertyAccessExpression(callTarget) || + callTarget.expression.kind !== ts.SyntaxKind.ThisKeyword || + callTarget.name.text !== "setState" + ) { + return false; + } + const symbol = getResolvedSymbol(callTarget.name, context.typeChecker); + return Boolean( + symbol?.declarations?.some((declaration) => { + const enclosingClass = getEnclosingClass(declaration); + return Boolean( + declaration.getSourceFile().isDeclarationFile && + enclosingClass?.name && + ts.isIdentifier(enclosingClass.name) && + enclosingClass.name.text === ReactClassComponentBase.Component, + ); + }), + ); +}; diff --git a/packages/prover/src/utils/is-reactive-capture-declared.ts b/packages/prover/src/utils/is-reactive-capture-declared.ts new file mode 100644 index 000000000..325e5d2f4 --- /dev/null +++ b/packages/prover/src/utils/is-reactive-capture-declared.ts @@ -0,0 +1,10 @@ +export const isReactiveCaptureDeclared = ( + capture: string, + dependencies: ReadonlyArray, +): boolean => + dependencies.some( + (dependency) => + dependency === capture || + capture.startsWith(`${dependency}.`) || + dependency.startsWith(`${capture}.`), + ); diff --git a/packages/prover/src/utils/is-this-state-expression.ts b/packages/prover/src/utils/is-this-state-expression.ts new file mode 100644 index 000000000..e7e739d72 --- /dev/null +++ b/packages/prover/src/utils/is-this-state-expression.ts @@ -0,0 +1,18 @@ +import ts from "typescript"; +import { unwrapTypescriptExpression } from "../unwrap-typescript-expression.js"; +import { getStaticAccessMemberName } from "./get-static-access-member-name.js"; + +export const isThisStateExpression = (expression: ts.Expression): boolean => { + let currentExpression = unwrapTypescriptExpression(expression); + const members: string[] = []; + while ( + ts.isPropertyAccessExpression(currentExpression) || + ts.isElementAccessExpression(currentExpression) + ) { + const memberName = getStaticAccessMemberName(currentExpression); + if (!memberName) return false; + members.unshift(memberName); + currentExpression = unwrapTypescriptExpression(currentExpression.expression); + } + return currentExpression.kind === ts.SyntaxKind.ThisKeyword && members[0] === "state"; +}; diff --git a/packages/prover/src/utils/resolve-aliased-symbol.ts b/packages/prover/src/utils/resolve-aliased-symbol.ts new file mode 100644 index 000000000..c266a0b04 --- /dev/null +++ b/packages/prover/src/utils/resolve-aliased-symbol.ts @@ -0,0 +1,4 @@ +import ts from "typescript"; + +export const resolveAliasedSymbol = (symbol: ts.Symbol, typeChecker: ts.TypeChecker): ts.Symbol => + symbol.flags & ts.SymbolFlags.Alias ? typeChecker.getAliasedSymbol(symbol) : symbol; diff --git a/packages/prover/tests/fixtures/abort-signal-listener-leak/src/app.tsx b/packages/prover/tests/fixtures/abort-signal-listener-leak/src/app.tsx new file mode 100644 index 000000000..d0574445b --- /dev/null +++ b/packages/prover/tests/fixtures/abort-signal-listener-leak/src/app.tsx @@ -0,0 +1,14 @@ +import { useEffect } from "react"; + +const handleScroll = () => undefined; + +export const ScrollTracker = () => { + useEffect(() => { + const controller = new AbortController(); + window.addEventListener("scroll", handleScroll, { + signal: controller.signal, + }); + }, []); + + return null; +}; diff --git a/packages/prover/tests/fixtures/abort-signal-listener-leak/tsconfig.json b/packages/prover/tests/fixtures/abort-signal-listener-leak/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/abort-signal-listener-leak/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/aliased-stale-effect/src/app.tsx b/packages/prover/tests/fixtures/aliased-stale-effect/src/app.tsx new file mode 100644 index 000000000..5256ee0fc --- /dev/null +++ b/packages/prover/tests/fixtures/aliased-stale-effect/src/app.tsx @@ -0,0 +1,12 @@ +import { useEffect as scheduleEffect } from "react"; + +interface TitleProperties { + title: string; +} + +export const Title = ({ title }: TitleProperties) => { + scheduleEffect(() => { + document.title = title; + }, []); + return

{title}

; +}; diff --git a/packages/prover/tests/fixtures/aliased-stale-effect/tsconfig.json b/packages/prover/tests/fixtures/aliased-stale-effect/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/aliased-stale-effect/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/anonymous-hook-callback/src/app.tsx b/packages/prover/tests/fixtures/anonymous-hook-callback/src/app.tsx new file mode 100644 index 000000000..d4e42bbd7 --- /dev/null +++ b/packages/prover/tests/fixtures/anonymous-hook-callback/src/app.tsx @@ -0,0 +1,12 @@ +import { useState } from "react"; + +export const App = () => ( + +); diff --git a/packages/prover/tests/fixtures/anonymous-hook-callback/tsconfig.json b/packages/prover/tests/fixtures/anonymous-hook-callback/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/anonymous-hook-callback/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/async-effect-opaque-continuation/src/app.tsx b/packages/prover/tests/fixtures/async-effect-opaque-continuation/src/app.tsx new file mode 100644 index 000000000..41537239d --- /dev/null +++ b/packages/prover/tests/fixtures/async-effect-opaque-continuation/src/app.tsx @@ -0,0 +1,15 @@ +import { useEffect } from "react"; + +interface SearchResultProperties { + commitResult: (result: string) => void; + loadQuery: (query: string) => Promise; + query: string; +} + +export const SearchResult = ({ commitResult, loadQuery, query }: SearchResultProperties) => { + useEffect(() => { + void loadQuery(query).then(commitResult); + }, [commitResult, loadQuery, query]); + + return null; +}; diff --git a/packages/prover/tests/fixtures/async-effect-opaque-continuation/tsconfig.json b/packages/prover/tests/fixtures/async-effect-opaque-continuation/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/async-effect-opaque-continuation/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/async-effect-opaque-guard/src/app.tsx b/packages/prover/tests/fixtures/async-effect-opaque-guard/src/app.tsx new file mode 100644 index 000000000..e52f8d3f4 --- /dev/null +++ b/packages/prover/tests/fixtures/async-effect-opaque-guard/src/app.tsx @@ -0,0 +1,21 @@ +import { useEffect, useState } from "react"; + +interface SearchResultProperties { + isCurrentQuery: (query: string) => boolean; + loadQuery: (query: string) => Promise; + query: string; +} + +export const SearchResult = ({ isCurrentQuery, loadQuery, query }: SearchResultProperties) => { + const [result, setResult] = useState(""); + + useEffect(() => { + const loadResult = async () => { + const nextResult = await loadQuery(query); + if (isCurrentQuery(query)) setResult(nextResult); + }; + void loadResult(); + }, [isCurrentQuery, loadQuery, query]); + + return {result}; +}; diff --git a/packages/prover/tests/fixtures/async-effect-opaque-guard/tsconfig.json b/packages/prover/tests/fixtures/async-effect-opaque-guard/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/async-effect-opaque-guard/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/async-effect-path-dependent-invalidation/src/app.tsx b/packages/prover/tests/fixtures/async-effect-path-dependent-invalidation/src/app.tsx new file mode 100644 index 000000000..20f25bf6d --- /dev/null +++ b/packages/prover/tests/fixtures/async-effect-path-dependent-invalidation/src/app.tsx @@ -0,0 +1,26 @@ +import { useEffect, useState } from "react"; + +interface SearchResultProperties { + loadQuery: (query: string) => Promise; + query: string; + skipInvalidation: boolean; +} + +export const SearchResult = ({ loadQuery, query, skipInvalidation }: SearchResultProperties) => { + const [result, setResult] = useState(""); + + useEffect(() => { + let didLoseOwnership = false; + const loadResult = async () => { + const nextResult = await loadQuery(query); + if (!didLoseOwnership) setResult(nextResult); + }; + void loadResult(); + if (skipInvalidation) return () => {}; + return () => { + didLoseOwnership = true; + }; + }, [loadQuery, query, skipInvalidation]); + + return {result}; +}; diff --git a/packages/prover/tests/fixtures/async-effect-path-dependent-invalidation/tsconfig.json b/packages/prover/tests/fixtures/async-effect-path-dependent-invalidation/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/async-effect-path-dependent-invalidation/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/async-effect-post-await-mutation/src/app.tsx b/packages/prover/tests/fixtures/async-effect-post-await-mutation/src/app.tsx new file mode 100644 index 000000000..f3752bbfb --- /dev/null +++ b/packages/prover/tests/fixtures/async-effect-post-await-mutation/src/app.tsx @@ -0,0 +1,19 @@ +import { useEffect, useRef } from "react"; + +interface SearchResultProperties { + loadQuery: (query: string) => Promise; + query: string; +} + +export const SearchResult = ({ loadQuery, query }: SearchResultProperties) => { + const latestResult = useRef(""); + + useEffect(() => { + const loadResult = async () => { + latestResult.current = await loadQuery(query); + }; + void loadResult(); + }, [loadQuery, query]); + + return null; +}; diff --git a/packages/prover/tests/fixtures/async-effect-post-await-mutation/tsconfig.json b/packages/prover/tests/fixtures/async-effect-post-await-mutation/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/async-effect-post-await-mutation/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/async-effect-promise-chain/src/app.tsx b/packages/prover/tests/fixtures/async-effect-promise-chain/src/app.tsx new file mode 100644 index 000000000..9a4d700aa --- /dev/null +++ b/packages/prover/tests/fixtures/async-effect-promise-chain/src/app.tsx @@ -0,0 +1,16 @@ +import { useEffect, useState } from "react"; + +interface SearchResultProperties { + loadQuery: (query: string) => Promise; + query: string; +} + +export const SearchResult = ({ loadQuery, query }: SearchResultProperties) => { + const [result, setResult] = useState(""); + + useEffect(() => { + void loadQuery(query).then(setResult); + }, [loadQuery, query]); + + return {result}; +}; diff --git a/packages/prover/tests/fixtures/async-effect-promise-chain/tsconfig.json b/packages/prover/tests/fixtures/async-effect-promise-chain/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/async-effect-promise-chain/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/async-effect-stale-write/src/app.tsx b/packages/prover/tests/fixtures/async-effect-stale-write/src/app.tsx new file mode 100644 index 000000000..bc91eeb92 --- /dev/null +++ b/packages/prover/tests/fixtures/async-effect-stale-write/src/app.tsx @@ -0,0 +1,20 @@ +import { useEffect, useState } from "react"; + +interface SearchResultProperties { + loadQuery: (query: string) => Promise; + query: string; +} + +export const SearchResult = ({ loadQuery, query }: SearchResultProperties) => { + const [result, setResult] = useState(""); + + useEffect(() => { + const loadResult = async () => { + const nextResult = await loadQuery(query); + setResult(nextResult); + }; + void loadResult(); + }, [loadQuery, query]); + + return {result}; +}; diff --git a/packages/prover/tests/fixtures/async-effect-stale-write/tsconfig.json b/packages/prover/tests/fixtures/async-effect-stale-write/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/async-effect-stale-write/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/branch-returned-render-impurity/src/app.tsx b/packages/prover/tests/fixtures/branch-returned-render-impurity/src/app.tsx new file mode 100644 index 000000000..5fe40c869 --- /dev/null +++ b/packages/prover/tests/fixtures/branch-returned-render-impurity/src/app.tsx @@ -0,0 +1,10 @@ +const chooseRenderWork = (isPrimary: boolean) => { + if (isPrimary) return () => undefined; + return () => console.log("render"); +}; + +export const Application = () => { + const runRenderWork = chooseRenderWork(true); + runRenderWork(); + return
Application
; +}; diff --git a/packages/prover/tests/fixtures/branch-returned-render-impurity/tsconfig.json b/packages/prover/tests/fixtures/branch-returned-render-impurity/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/branch-returned-render-impurity/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/callback-parameter-effect-listener-leak/src/app.tsx b/packages/prover/tests/fixtures/callback-parameter-effect-listener-leak/src/app.tsx new file mode 100644 index 000000000..3d6a34936 --- /dev/null +++ b/packages/prover/tests/fixtures/callback-parameter-effect-listener-leak/src/app.tsx @@ -0,0 +1,13 @@ +import { useEffect } from "react"; + +const invokeCallback = (callback: () => void) => callback(); + +const handleResize = () => {}; + +export const App = () => { + useEffect(() => { + invokeCallback(() => window.addEventListener("resize", handleResize)); + }, []); + + return

Resize tracker

; +}; diff --git a/packages/prover/tests/fixtures/callback-parameter-effect-listener-leak/tsconfig.json b/packages/prover/tests/fixtures/callback-parameter-effect-listener-leak/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/callback-parameter-effect-listener-leak/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/callback-parameter-opaque-registration/src/app.tsx b/packages/prover/tests/fixtures/callback-parameter-opaque-registration/src/app.tsx new file mode 100644 index 000000000..ab0ef2b88 --- /dev/null +++ b/packages/prover/tests/fixtures/callback-parameter-opaque-registration/src/app.tsx @@ -0,0 +1,14 @@ +const scheduleCallback = (callback: () => void) => { + setTimeout(callback, 0); +}; + +const recordSelection = () => {}; + +export const App = () => { + const handleClick = () => scheduleCallback(recordSelection); + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/callback-parameter-opaque-registration/tsconfig.json b/packages/prover/tests/fixtures/callback-parameter-opaque-registration/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/callback-parameter-opaque-registration/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/class-component/src/app.tsx b/packages/prover/tests/fixtures/class-component/src/app.tsx new file mode 100644 index 000000000..16dc65b63 --- /dev/null +++ b/packages/prover/tests/fixtures/class-component/src/app.tsx @@ -0,0 +1,11 @@ +import { Component } from "react"; + +interface WelcomeProperties { + name: string; +} + +export class Welcome extends Component { + render() { + return

Hello {this.props.name}

; + } +} diff --git a/packages/prover/tests/fixtures/class-component/tsconfig.json b/packages/prover/tests/fixtures/class-component/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/class-component/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/class-deferred-state-mutation/src/app.tsx b/packages/prover/tests/fixtures/class-deferred-state-mutation/src/app.tsx new file mode 100644 index 000000000..a184669ec --- /dev/null +++ b/packages/prover/tests/fixtures/class-deferred-state-mutation/src/app.tsx @@ -0,0 +1,25 @@ +import { Component } from "react"; + +interface ListenerState { + resizeCount: number; +} + +export class ResizeListener extends Component, ListenerState> { + state = { resizeCount: 0 }; + + handleResize() { + this.state.resizeCount += 1; + } + + componentDidMount() { + window.addEventListener("resize", this.handleResize); + } + + componentWillUnmount() { + window.removeEventListener("resize", this.handleResize); + } + + render() { + return null; + } +} diff --git a/packages/prover/tests/fixtures/class-deferred-state-mutation/tsconfig.json b/packages/prover/tests/fixtures/class-deferred-state-mutation/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/class-deferred-state-mutation/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/class-direct-state-mutation/src/app.tsx b/packages/prover/tests/fixtures/class-direct-state-mutation/src/app.tsx new file mode 100644 index 000000000..f65e381e0 --- /dev/null +++ b/packages/prover/tests/fixtures/class-direct-state-mutation/src/app.tsx @@ -0,0 +1,17 @@ +import { Component } from "react"; + +interface CounterState { + count: number; +} + +export class Counter extends Component, CounterState> { + state = { count: 0 }; + + componentDidMount() { + this.state.count = 1; + } + + render() { + return {this.state.count}; + } +} diff --git a/packages/prover/tests/fixtures/class-direct-state-mutation/tsconfig.json b/packages/prover/tests/fixtures/class-direct-state-mutation/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/class-direct-state-mutation/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/class-impure-state-updater/src/app.tsx b/packages/prover/tests/fixtures/class-impure-state-updater/src/app.tsx new file mode 100644 index 000000000..91f093e9a --- /dev/null +++ b/packages/prover/tests/fixtures/class-impure-state-updater/src/app.tsx @@ -0,0 +1,20 @@ +import { Component } from "react"; + +interface CounterState { + count: number; +} + +export class Counter extends Component, CounterState> { + state = { count: 0 }; + + componentDidMount() { + this.setState((previousState) => { + console.log(previousState.count); + return { count: previousState.count + 1 }; + }); + } + + render() { + return null; + } +} diff --git a/packages/prover/tests/fixtures/class-impure-state-updater/tsconfig.json b/packages/prover/tests/fixtures/class-impure-state-updater/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/class-impure-state-updater/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/class-listener-capture-mismatch/src/app.tsx b/packages/prover/tests/fixtures/class-listener-capture-mismatch/src/app.tsx new file mode 100644 index 000000000..4f9a59efe --- /dev/null +++ b/packages/prover/tests/fixtures/class-listener-capture-mismatch/src/app.tsx @@ -0,0 +1,17 @@ +import { Component } from "react"; + +export class ResizeListener extends Component { + handleResize() {} + + componentDidMount() { + window.addEventListener("resize", this.handleResize, true); + } + + componentWillUnmount() { + window.removeEventListener("resize", this.handleResize, false); + } + + render() { + return null; + } +} diff --git a/packages/prover/tests/fixtures/class-listener-capture-mismatch/tsconfig.json b/packages/prover/tests/fixtures/class-listener-capture-mismatch/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/class-listener-capture-mismatch/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/class-listener-leak/src/app.tsx b/packages/prover/tests/fixtures/class-listener-leak/src/app.tsx new file mode 100644 index 000000000..416774981 --- /dev/null +++ b/packages/prover/tests/fixtures/class-listener-leak/src/app.tsx @@ -0,0 +1,13 @@ +import { Component } from "react"; + +export class ResizeListener extends Component { + handleResize() {} + + componentDidMount() { + window.addEventListener("resize", this.handleResize); + } + + render() { + return null; + } +} diff --git a/packages/prover/tests/fixtures/class-listener-leak/tsconfig.json b/packages/prover/tests/fixtures/class-listener-leak/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/class-listener-leak/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/class-render-impurity/src/app.tsx b/packages/prover/tests/fixtures/class-render-impurity/src/app.tsx new file mode 100644 index 000000000..988963ef8 --- /dev/null +++ b/packages/prover/tests/fixtures/class-render-impurity/src/app.tsx @@ -0,0 +1,7 @@ +import { Component } from "react"; + +export class Clock extends Component { + render() { + return ; + } +} diff --git a/packages/prover/tests/fixtures/class-render-impurity/tsconfig.json b/packages/prover/tests/fixtures/class-render-impurity/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/class-render-impurity/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/class-state-mutating-call/src/app.tsx b/packages/prover/tests/fixtures/class-state-mutating-call/src/app.tsx new file mode 100644 index 000000000..8b9769b9e --- /dev/null +++ b/packages/prover/tests/fixtures/class-state-mutating-call/src/app.tsx @@ -0,0 +1,17 @@ +import { Component } from "react"; + +interface QueueState { + items: string[]; +} + +export class Queue extends Component, QueueState> { + state: QueueState = { items: [] }; + + componentDidUpdate() { + this.state.items.push("queued"); + } + + render() { + return {this.state.items.length}; + } +} diff --git a/packages/prover/tests/fixtures/class-state-mutating-call/tsconfig.json b/packages/prover/tests/fixtures/class-state-mutating-call/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/class-state-mutating-call/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/class-state-mutation-forms/src/app.tsx b/packages/prover/tests/fixtures/class-state-mutation-forms/src/app.tsx new file mode 100644 index 000000000..a2217c183 --- /dev/null +++ b/packages/prover/tests/fixtures/class-state-mutation-forms/src/app.tsx @@ -0,0 +1,23 @@ +import { Component } from "react"; + +interface MutationState { + count: number; + items: string[]; + metadata: Map; + optional?: string; +} + +export class MutationForms extends Component, MutationState> { + componentDidMount() { + this.state.count += 1; + this.state.count++; + delete this.state.optional; + this.state.items.splice(0, 1); + this.state.metadata.set("status", "ready"); + Object.assign(this.state, { count: 2 }); + } + + render() { + return null; + } +} diff --git a/packages/prover/tests/fixtures/class-state-mutation-forms/tsconfig.json b/packages/prover/tests/fixtures/class-state-mutation-forms/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/class-state-mutation-forms/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/class-timeout-leak/src/app.tsx b/packages/prover/tests/fixtures/class-timeout-leak/src/app.tsx new file mode 100644 index 000000000..7d9cd4ae7 --- /dev/null +++ b/packages/prover/tests/fixtures/class-timeout-leak/src/app.tsx @@ -0,0 +1,15 @@ +import { Component } from "react"; + +export class DelayedUpdate extends Component { + timeoutId = 0; + + handleTimeout() {} + + componentDidMount() { + this.timeoutId = window.setTimeout(this.handleTimeout, 80); + } + + render() { + return null; + } +} diff --git a/packages/prover/tests/fixtures/class-timeout-leak/tsconfig.json b/packages/prover/tests/fixtures/class-timeout-leak/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/class-timeout-leak/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/class-unmount-state-mutation/src/app.tsx b/packages/prover/tests/fixtures/class-unmount-state-mutation/src/app.tsx new file mode 100644 index 000000000..c1e9363ae --- /dev/null +++ b/packages/prover/tests/fixtures/class-unmount-state-mutation/src/app.tsx @@ -0,0 +1,17 @@ +import { Component } from "react"; + +interface ConnectionState { + connected: boolean; +} + +export class Connection extends Component, ConnectionState> { + state = { connected: true }; + + componentWillUnmount() { + this.state.connected = false; + } + + render() { + return null; + } +} diff --git a/packages/prover/tests/fixtures/class-unmount-state-mutation/tsconfig.json b/packages/prover/tests/fixtures/class-unmount-state-mutation/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/class-unmount-state-mutation/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/class-update-loop/src/app.tsx b/packages/prover/tests/fixtures/class-update-loop/src/app.tsx new file mode 100644 index 000000000..e2eaf9810 --- /dev/null +++ b/packages/prover/tests/fixtures/class-update-loop/src/app.tsx @@ -0,0 +1,17 @@ +import { Component } from "react"; + +interface RevisionState { + revision: number; +} + +export class RevisionTracker extends Component, RevisionState> { + state = { revision: 0 }; + + componentDidUpdate() { + this.setState({ revision: 1 }); + } + + render() { + return null; + } +} diff --git a/packages/prover/tests/fixtures/class-update-loop/tsconfig.json b/packages/prover/tests/fixtures/class-update-loop/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/class-update-loop/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/cleanup-mismatch/src/app.tsx b/packages/prover/tests/fixtures/cleanup-mismatch/src/app.tsx new file mode 100644 index 000000000..009b118c3 --- /dev/null +++ b/packages/prover/tests/fixtures/cleanup-mismatch/src/app.tsx @@ -0,0 +1,11 @@ +import { useEffect } from "react"; + +export const ResizeStatus = () => { + useEffect(() => { + const handleResize = () => undefined; + window.addEventListener("resize", handleResize, true); + return () => window.removeEventListener("resize", handleResize, false); + }, []); + + return

Ready

; +}; diff --git a/packages/prover/tests/fixtures/cleanup-mismatch/tsconfig.json b/packages/prover/tests/fixtures/cleanup-mismatch/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/cleanup-mismatch/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/compiler-bailout/src/app.tsx b/packages/prover/tests/fixtures/compiler-bailout/src/app.tsx new file mode 100644 index 000000000..d0e59467e --- /dev/null +++ b/packages/prover/tests/fixtures/compiler-bailout/src/app.tsx @@ -0,0 +1,7 @@ +export const App = () => { + class LocalModel { + value = "Ready"; + } + const model = new LocalModel(); + return

{model.value}

; +}; diff --git a/packages/prover/tests/fixtures/compiler-bailout/tsconfig.json b/packages/prover/tests/fixtures/compiler-bailout/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/compiler-bailout/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/conditional-hook/src/app.tsx b/packages/prover/tests/fixtures/conditional-hook/src/app.tsx new file mode 100644 index 000000000..f937ce97f --- /dev/null +++ b/packages/prover/tests/fixtures/conditional-hook/src/app.tsx @@ -0,0 +1,11 @@ +import { useState } from "react"; + +interface ProfileProperties { + disabled: boolean; +} + +export const Profile = ({ disabled }: ProfileProperties) => { + if (disabled) return null; + const [name] = useState("Ada"); + return

{name}

; +}; diff --git a/packages/prover/tests/fixtures/conditional-hook/tsconfig.json b/packages/prover/tests/fixtures/conditional-hook/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/conditional-hook/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/conditional-use/src/app.tsx b/packages/prover/tests/fixtures/conditional-use/src/app.tsx new file mode 100644 index 000000000..9d76801f3 --- /dev/null +++ b/packages/prover/tests/fixtures/conditional-use/src/app.tsx @@ -0,0 +1,12 @@ +import { use } from "react"; + +interface MessageProperties { + resource: PromiseLike; + shouldRead: boolean; +} + +export const Message = ({ resource, shouldRead }: MessageProperties) => { + if (!shouldRead) return null; + const message = use(resource); + return

{message}

; +}; diff --git a/packages/prover/tests/fixtures/conditional-use/tsconfig.json b/packages/prover/tests/fixtures/conditional-use/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/conditional-use/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/context-provider-missing-value/src/app.tsx b/packages/prover/tests/fixtures/context-provider-missing-value/src/app.tsx new file mode 100644 index 000000000..8af3486e9 --- /dev/null +++ b/packages/prover/tests/fixtures/context-provider-missing-value/src/app.tsx @@ -0,0 +1,14 @@ +import { createContext, useContext } from "react"; + +const ThemeContext = createContext("default"); + +const ThemeLabel = () => { + const theme = useContext(ThemeContext); + return {theme}; +}; + +export const App = () => ( + + + +); diff --git a/packages/prover/tests/fixtures/context-provider-missing-value/tsconfig.json b/packages/prover/tests/fixtures/context-provider-missing-value/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/context-provider-missing-value/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/coreui-listener-leak/src/sidebar.tsx b/packages/prover/tests/fixtures/coreui-listener-leak/src/sidebar.tsx new file mode 100644 index 000000000..a2a0a7348 --- /dev/null +++ b/packages/prover/tests/fixtures/coreui-listener-leak/src/sidebar.tsx @@ -0,0 +1,18 @@ +import { useEffect, useState } from "react"; + +interface SidebarProperties { + visible: boolean; +} + +export const Sidebar = ({ visible }: SidebarProperties) => { + const [mobile, setMobile] = useState(false); + + useEffect(() => { + window.addEventListener("resize", () => setMobile(window.innerWidth < 768)); + return () => { + window.removeEventListener("resize", () => setMobile(window.innerWidth < 768)); + }; + }, []); + + return ; +}; diff --git a/packages/prover/tests/fixtures/coreui-listener-leak/tsconfig.json b/packages/prover/tests/fixtures/coreui-listener-leak/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/coreui-listener-leak/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/datepicker-loop-index-key/src/app.tsx b/packages/prover/tests/fixtures/datepicker-loop-index-key/src/app.tsx new file mode 100644 index 000000000..56acc2e4e --- /dev/null +++ b/packages/prover/tests/fixtures/datepicker-loop-index-key/src/app.tsx @@ -0,0 +1,12 @@ +interface CalendarProperties { + monthsShown: number; +} + +export const Calendar = ({ monthsShown }: CalendarProperties) => { + const monthList = []; + for (let monthIndex = 0; monthIndex < monthsShown; monthIndex += 1) { + const monthKey = `month-${monthIndex}`; + monthList.push(
Month
); + } + return
{monthList}
; +}; diff --git a/packages/prover/tests/fixtures/datepicker-loop-index-key/tsconfig.json b/packages/prover/tests/fixtures/datepicker-loop-index-key/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/datepicker-loop-index-key/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/direct-component-call/src/app.tsx b/packages/prover/tests/fixtures/direct-component-call/src/app.tsx new file mode 100644 index 000000000..4df6f6fe1 --- /dev/null +++ b/packages/prover/tests/fixtures/direct-component-call/src/app.tsx @@ -0,0 +1,3 @@ +const Avatar = () => Avatar; + +export const Profile = () =>
{Avatar()}
; diff --git a/packages/prover/tests/fixtures/direct-component-call/tsconfig.json b/packages/prover/tests/fixtures/direct-component-call/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/direct-component-call/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/duplicate-list-key/src/app.tsx b/packages/prover/tests/fixtures/duplicate-list-key/src/app.tsx new file mode 100644 index 000000000..d0c936ed8 --- /dev/null +++ b/packages/prover/tests/fixtures/duplicate-list-key/src/app.tsx @@ -0,0 +1 @@ +export const List = () =>
    {[
  • First
  • ,
  • Second
  • ]}
; diff --git a/packages/prover/tests/fixtures/duplicate-list-key/tsconfig.json b/packages/prover/tests/fixtures/duplicate-list-key/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/duplicate-list-key/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/effect-event-dependency/src/app.tsx b/packages/prover/tests/fixtures/effect-event-dependency/src/app.tsx new file mode 100644 index 000000000..e3d0dba7b --- /dev/null +++ b/packages/prover/tests/fixtures/effect-event-dependency/src/app.tsx @@ -0,0 +1,11 @@ +import { useEffect, useEffectEvent } from "react"; + +export const Reporter = () => { + const onReport = useEffectEvent(() => undefined); + + useEffect(() => { + onReport(); + }, [onReport]); + + return null; +}; diff --git a/packages/prover/tests/fixtures/effect-event-dependency/tsconfig.json b/packages/prover/tests/fixtures/effect-event-dependency/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/effect-event-dependency/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/effect-event-hook-escape/src/app.tsx b/packages/prover/tests/fixtures/effect-event-hook-escape/src/app.tsx new file mode 100644 index 000000000..f91ae4a3a --- /dev/null +++ b/packages/prover/tests/fixtures/effect-event-hook-escape/src/app.tsx @@ -0,0 +1,14 @@ +import { useEffect, useEffectEvent } from "react"; + +const useTimer = (callback: () => void) => { + useEffect(() => { + const timer = setInterval(callback, 1000); + return () => clearInterval(timer); + }, [callback]); +}; + +export const Timer = () => { + const onTick = useEffectEvent(() => undefined); + useTimer(onTick); + return null; +}; diff --git a/packages/prover/tests/fixtures/effect-event-hook-escape/tsconfig.json b/packages/prover/tests/fixtures/effect-event-hook-escape/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/effect-event-hook-escape/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/effect-event-memo-context/src/app.tsx b/packages/prover/tests/fixtures/effect-event-memo-context/src/app.tsx new file mode 100644 index 000000000..7f3b0c7c9 --- /dev/null +++ b/packages/prover/tests/fixtures/effect-event-memo-context/src/app.tsx @@ -0,0 +1,16 @@ +import { createContext, memo, useContext, useEffect, useEffectEvent, useState } from "react"; + +const NavigationContext = createContext("POP"); + +export const NavigationReader = memo(() => { + const navigationType = useContext(NavigationContext); + const [observedNavigation, setObservedNavigation] = useState("unobserved"); + const onReadNavigation = useEffectEvent(() => setObservedNavigation(navigationType)); + + useEffect(() => { + window.addEventListener("read-navigation", onReadNavigation); + return () => window.removeEventListener("read-navigation", onReadNavigation); + }, []); + + return

{observedNavigation}

; +}); diff --git a/packages/prover/tests/fixtures/effect-event-memo-context/tsconfig.json b/packages/prover/tests/fixtures/effect-event-memo-context/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/effect-event-memo-context/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/effect-event-opaque-registration/src/app.tsx b/packages/prover/tests/fixtures/effect-event-opaque-registration/src/app.tsx new file mode 100644 index 000000000..cd8b21554 --- /dev/null +++ b/packages/prover/tests/fixtures/effect-event-opaque-registration/src/app.tsx @@ -0,0 +1,15 @@ +import { useEffect, useEffectEvent } from "react"; + +interface Registration { + handler: () => void; +} + +const register = (_registration: Registration) => () => undefined; + +export const Reporter = () => { + const onReport = useEffectEvent(() => undefined); + + useEffect(() => register({ handler: onReport }), []); + + return null; +}; diff --git a/packages/prover/tests/fixtures/effect-event-opaque-registration/tsconfig.json b/packages/prover/tests/fixtures/effect-event-opaque-registration/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/effect-event-opaque-registration/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/effect-event-prop-escape/src/app.tsx b/packages/prover/tests/fixtures/effect-event-prop-escape/src/app.tsx new file mode 100644 index 000000000..5ab109177 --- /dev/null +++ b/packages/prover/tests/fixtures/effect-event-prop-escape/src/app.tsx @@ -0,0 +1,12 @@ +import { useEffectEvent } from "react"; + +interface ChildProperties { + onReport: () => void; +} + +const Child = (_properties: ChildProperties) => null; + +export const Reporter = () => { + const onReport = useEffectEvent(() => undefined); + return ; +}; diff --git a/packages/prover/tests/fixtures/effect-event-prop-escape/tsconfig.json b/packages/prover/tests/fixtures/effect-event-prop-escape/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/effect-event-prop-escape/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/effect-event-render-call/src/app.tsx b/packages/prover/tests/fixtures/effect-event-render-call/src/app.tsx new file mode 100644 index 000000000..c7dbce1f6 --- /dev/null +++ b/packages/prover/tests/fixtures/effect-event-render-call/src/app.tsx @@ -0,0 +1,7 @@ +import { useEffectEvent } from "react"; + +export const Reporter = () => { + const onReport = useEffectEvent(() => undefined); + onReport(); + return null; +}; diff --git a/packages/prover/tests/fixtures/effect-event-render-call/tsconfig.json b/packages/prover/tests/fixtures/effect-event-render-call/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/effect-event-render-call/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/effect-event-shared-helper/src/app.tsx b/packages/prover/tests/fixtures/effect-event-shared-helper/src/app.tsx new file mode 100644 index 000000000..c2411cc65 --- /dev/null +++ b/packages/prover/tests/fixtures/effect-event-shared-helper/src/app.tsx @@ -0,0 +1,16 @@ +import { useEffect, useEffectEvent } from "react"; + +export const App = () => { + const onTick = useEffectEvent(() => {}); + const invokeTick = () => onTick(); + + useEffect(() => { + invokeTick(); + }, [invokeTick]); + + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/effect-event-shared-helper/tsconfig.json b/packages/prover/tests/fixtures/effect-event-shared-helper/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/effect-event-shared-helper/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/effect-self-cycle/src/app.tsx b/packages/prover/tests/fixtures/effect-self-cycle/src/app.tsx new file mode 100644 index 000000000..a9d99b6b7 --- /dev/null +++ b/packages/prover/tests/fixtures/effect-self-cycle/src/app.tsx @@ -0,0 +1,11 @@ +import { useEffect, useState } from "react"; + +export const Toggle = () => { + const [enabled, setEnabled] = useState(false); + + useEffect(() => { + setEnabled(!enabled); + }, [enabled]); + + return

{enabled ? "enabled" : "disabled"}

; +}; diff --git a/packages/prover/tests/fixtures/effect-self-cycle/tsconfig.json b/packages/prover/tests/fixtures/effect-self-cycle/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/effect-self-cycle/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/effect-state-update/src/app.tsx b/packages/prover/tests/fixtures/effect-state-update/src/app.tsx new file mode 100644 index 000000000..d9af8ec48 --- /dev/null +++ b/packages/prover/tests/fixtures/effect-state-update/src/app.tsx @@ -0,0 +1,11 @@ +import { useEffect, useState } from "react"; + +export const Counter = () => { + const [count, setCount] = useState(0); + + useEffect(() => { + setCount(count + 1); + }, [count]); + + return

{count}

; +}; diff --git a/packages/prover/tests/fixtures/effect-state-update/tsconfig.json b/packages/prover/tests/fixtures/effect-state-update/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/effect-state-update/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/event-handler-boundary/src/app.tsx b/packages/prover/tests/fixtures/event-handler-boundary/src/app.tsx new file mode 100644 index 000000000..3d6d7f3dc --- /dev/null +++ b/packages/prover/tests/fixtures/event-handler-boundary/src/app.tsx @@ -0,0 +1,15 @@ +import { useState } from "react"; + +export const Counter = () => { + const [count, setCount] = useState(0); + const increment = () => setCount((previousCount) => previousCount + 1); + const handleClick = () => { + if (count < 0) increment(); + increment(); + }; + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/event-handler-boundary/tsconfig.json b/packages/prover/tests/fixtures/event-handler-boundary/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/event-handler-boundary/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/external-context/src/app.tsx b/packages/prover/tests/fixtures/external-context/src/app.tsx new file mode 100644 index 000000000..26054de1f --- /dev/null +++ b/packages/prover/tests/fixtures/external-context/src/app.tsx @@ -0,0 +1,7 @@ +import { useContext } from "react"; +import { ThemeContext } from "theme-library"; + +export const App = () => { + const theme = useContext(ThemeContext); + return {theme}; +}; diff --git a/packages/prover/tests/fixtures/external-context/src/theme-library.d.ts b/packages/prover/tests/fixtures/external-context/src/theme-library.d.ts new file mode 100644 index 000000000..bc4d8b0c6 --- /dev/null +++ b/packages/prover/tests/fixtures/external-context/src/theme-library.d.ts @@ -0,0 +1,5 @@ +declare module "theme-library" { + import type { Context } from "react"; + + export const ThemeContext: Context; +} diff --git a/packages/prover/tests/fixtures/external-context/tsconfig.json b/packages/prover/tests/fixtures/external-context/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/external-context/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/external-store-cleanup-mismatch/src/app.tsx b/packages/prover/tests/fixtures/external-store-cleanup-mismatch/src/app.tsx new file mode 100644 index 000000000..9dd0b8eb5 --- /dev/null +++ b/packages/prover/tests/fixtures/external-store-cleanup-mismatch/src/app.tsx @@ -0,0 +1,14 @@ +import { useSyncExternalStore } from "react"; + +const activeListeners = new Set<() => void>(); +const unrelatedListeners = new Set<() => void>(); + +const subscribe = (listener: () => void) => { + activeListeners.add(listener); + return () => unrelatedListeners.delete(listener); +}; + +export const Version = () => { + const version = useSyncExternalStore(subscribe, () => 1); + return

{version}

; +}; diff --git a/packages/prover/tests/fixtures/external-store-cleanup-mismatch/tsconfig.json b/packages/prover/tests/fixtures/external-store-cleanup-mismatch/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/external-store-cleanup-mismatch/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/external-store-helper-boundary/src/app.tsx b/packages/prover/tests/fixtures/external-store-helper-boundary/src/app.tsx new file mode 100644 index 000000000..cfd7666c9 --- /dev/null +++ b/packages/prover/tests/fixtures/external-store-helper-boundary/src/app.tsx @@ -0,0 +1,20 @@ +import { useSyncExternalStore } from "react"; + +let language = "en"; +const listeners = new Set<() => void>(); + +const addListener = (listener: () => void) => { + listeners.add(listener); +}; + +const subscribe = (listener: () => void) => { + addListener(listener); + return () => listeners.delete(listener); +}; + +const getSnapshot = () => language; + +export const Language = () => { + const currentLanguage = useSyncExternalStore(subscribe, getSnapshot); + return

{currentLanguage}

; +}; diff --git a/packages/prover/tests/fixtures/external-store-helper-boundary/tsconfig.json b/packages/prover/tests/fixtures/external-store-helper-boundary/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/external-store-helper-boundary/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/finally-returned-render-impurity/src/app.tsx b/packages/prover/tests/fixtures/finally-returned-render-impurity/src/app.tsx new file mode 100644 index 000000000..21f73c243 --- /dev/null +++ b/packages/prover/tests/fixtures/finally-returned-render-impurity/src/app.tsx @@ -0,0 +1,13 @@ +const chooseRenderWork = (useImpureWork: boolean) => { + try { + return () => undefined; + } finally { + if (useImpureWork) return () => console.log("render"); + } +}; + +export const Application = () => { + const runRenderWork = chooseRenderWork(false); + runRenderWork(); + return
Application
; +}; diff --git a/packages/prover/tests/fixtures/finally-returned-render-impurity/tsconfig.json b/packages/prover/tests/fixtures/finally-returned-render-impurity/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/finally-returned-render-impurity/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/for-of-destructured-render-impurity/src/app.tsx b/packages/prover/tests/fixtures/for-of-destructured-render-impurity/src/app.tsx new file mode 100644 index 000000000..d942c4abd --- /dev/null +++ b/packages/prover/tests/fixtures/for-of-destructured-render-impurity/src/app.tsx @@ -0,0 +1,16 @@ +interface ApplicationProps { + useImpureWork: boolean; +} + +const runSelectedWork = (useImpureWork: boolean) => { + for (const { renderWork } of [ + { renderWork: useImpureWork ? () => console.log("render") : () => undefined }, + ]) { + renderWork(); + } +}; + +export const Application = ({ useImpureWork }: ApplicationProps) => { + runSelectedWork(useImpureWork); + return
Application
; +}; diff --git a/packages/prover/tests/fixtures/for-of-destructured-render-impurity/tsconfig.json b/packages/prover/tests/fixtures/for-of-destructured-render-impurity/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/for-of-destructured-render-impurity/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/for-of-invoked-render-impurity/src/app.tsx b/packages/prover/tests/fixtures/for-of-invoked-render-impurity/src/app.tsx new file mode 100644 index 000000000..af85a6e37 --- /dev/null +++ b/packages/prover/tests/fixtures/for-of-invoked-render-impurity/src/app.tsx @@ -0,0 +1,14 @@ +interface ApplicationProps { + useImpureWork: boolean; +} + +const runSelectedWork = (useImpureWork: boolean) => { + for (const renderWork of [useImpureWork ? () => console.log("render") : () => undefined]) { + renderWork(); + } +}; + +export const Application = ({ useImpureWork }: ApplicationProps) => { + runSelectedWork(useImpureWork); + return
Application
; +}; diff --git a/packages/prover/tests/fixtures/for-of-invoked-render-impurity/tsconfig.json b/packages/prover/tests/fixtures/for-of-invoked-render-impurity/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/for-of-invoked-render-impurity/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/for-of-returned-render-impurity/src/app.tsx b/packages/prover/tests/fixtures/for-of-returned-render-impurity/src/app.tsx new file mode 100644 index 000000000..c2f6082ff --- /dev/null +++ b/packages/prover/tests/fixtures/for-of-returned-render-impurity/src/app.tsx @@ -0,0 +1,16 @@ +interface ApplicationProps { + mode: "safe" | "impure"; +} + +const chooseRenderWork = (mode: ApplicationProps["mode"]) => { + for (const renderWork of [mode === "impure" ? () => console.log("render") : () => undefined]) { + return renderWork; + } + throw new Error("A fresh nonempty array must produce one iteration"); +}; + +export const Application = ({ mode }: ApplicationProps) => { + const runRenderWork = chooseRenderWork(mode); + runRenderWork(); + return
Application
; +}; diff --git a/packages/prover/tests/fixtures/for-of-returned-render-impurity/tsconfig.json b/packages/prover/tests/fixtures/for-of-returned-render-impurity/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/for-of-returned-render-impurity/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/fresh-external-store-callback-prop-snapshot/src/app.tsx b/packages/prover/tests/fixtures/fresh-external-store-callback-prop-snapshot/src/app.tsx new file mode 100644 index 000000000..7ea15d83a --- /dev/null +++ b/packages/prover/tests/fixtures/fresh-external-store-callback-prop-snapshot/src/app.tsx @@ -0,0 +1,22 @@ +import { useSyncExternalStore } from "react"; + +const listeners = new Set<() => void>(); + +interface StoreReaderProperties { + subscribe: (listener: () => void) => () => void; + getSnapshot: () => { version: number }; +} + +const StoreReader = ({ subscribe, getSnapshot }: StoreReaderProperties) => { + const snapshot = useSyncExternalStore(subscribe, getSnapshot); + return {snapshot.version}; +}; + +export const Application = () => { + const subscribe = (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }; + const getSnapshot = () => ({ version: 0 }); + return ; +}; diff --git a/packages/prover/tests/fixtures/fresh-external-store-callback-prop-snapshot/tsconfig.json b/packages/prover/tests/fixtures/fresh-external-store-callback-prop-snapshot/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/fresh-external-store-callback-prop-snapshot/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/fresh-external-store-snapshot/src/app.tsx b/packages/prover/tests/fixtures/fresh-external-store-snapshot/src/app.tsx new file mode 100644 index 000000000..3b84d5f06 --- /dev/null +++ b/packages/prover/tests/fixtures/fresh-external-store-snapshot/src/app.tsx @@ -0,0 +1,8 @@ +import { useSyncExternalStore } from "react"; + +const subscribe = (_listener: () => void) => () => undefined; + +export const Status = () => { + const status = useSyncExternalStore(subscribe, () => ({ online: true })); + return

{status.online ? "online" : "offline"}

; +}; diff --git a/packages/prover/tests/fixtures/fresh-external-store-snapshot/tsconfig.json b/packages/prover/tests/fixtures/fresh-external-store-snapshot/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/fresh-external-store-snapshot/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/helper-aliased-prop-mutation/src/app.tsx b/packages/prover/tests/fixtures/helper-aliased-prop-mutation/src/app.tsx new file mode 100644 index 000000000..f24de168f --- /dev/null +++ b/packages/prover/tests/fixtures/helper-aliased-prop-mutation/src/app.tsx @@ -0,0 +1,15 @@ +interface ApplicationProps { + model: { + revision: number; + }; +} + +const updateModel = (model: ApplicationProps["model"]) => { + const modelAlias = model; + modelAlias.revision += 1; +}; + +export const Application = ({ model }: ApplicationProps) => { + updateModel(model); + return
Revision {model.revision}
; +}; diff --git a/packages/prover/tests/fixtures/helper-aliased-prop-mutation/tsconfig.json b/packages/prover/tests/fixtures/helper-aliased-prop-mutation/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/helper-aliased-prop-mutation/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/helper-effect-listener-leak/src/app.tsx b/packages/prover/tests/fixtures/helper-effect-listener-leak/src/app.tsx new file mode 100644 index 000000000..ecc9e6cad --- /dev/null +++ b/packages/prover/tests/fixtures/helper-effect-listener-leak/src/app.tsx @@ -0,0 +1,15 @@ +import { useEffect } from "react"; + +const handleResize = () => {}; + +const installResizeListener = () => { + window.addEventListener("resize", handleResize); +}; + +export const Sidebar = () => { + useEffect(() => { + installResizeListener(); + }, []); + + return null; +}; diff --git a/packages/prover/tests/fixtures/helper-effect-listener-leak/tsconfig.json b/packages/prover/tests/fixtures/helper-effect-listener-leak/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/helper-effect-listener-leak/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/helper-effect-state-update/src/app.tsx b/packages/prover/tests/fixtures/helper-effect-state-update/src/app.tsx new file mode 100644 index 000000000..2022e2554 --- /dev/null +++ b/packages/prover/tests/fixtures/helper-effect-state-update/src/app.tsx @@ -0,0 +1,14 @@ +import { useEffect, useState } from "react"; + +export const Counter = () => { + const [count, setCount] = useState(0); + const updateCount = () => { + setCount(count + 1); + }; + + useEffect(() => { + updateCount(); + }, [count, updateCount]); + + return {count}; +}; diff --git a/packages/prover/tests/fixtures/helper-effect-state-update/tsconfig.json b/packages/prover/tests/fixtures/helper-effect-state-update/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/helper-effect-state-update/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/impure-reducer/src/app.tsx b/packages/prover/tests/fixtures/impure-reducer/src/app.tsx new file mode 100644 index 000000000..fa370fddb --- /dev/null +++ b/packages/prover/tests/fixtures/impure-reducer/src/app.tsx @@ -0,0 +1,8 @@ +import { useReducer } from "react"; + +const reduceTimestamp = (_timestamp: number) => Date.now(); + +export const Timestamp = () => { + const [timestamp] = useReducer(reduceTimestamp, 0); + return

{timestamp}

; +}; diff --git a/packages/prover/tests/fixtures/impure-reducer/tsconfig.json b/packages/prover/tests/fixtures/impure-reducer/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/impure-reducer/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/impure-render/src/app.tsx b/packages/prover/tests/fixtures/impure-render/src/app.tsx new file mode 100644 index 000000000..131bd2fba --- /dev/null +++ b/packages/prover/tests/fixtures/impure-render/src/app.tsx @@ -0,0 +1,4 @@ +export const RandomLabel = () => { + const labelId = Math.random(); + return {labelId}; +}; diff --git a/packages/prover/tests/fixtures/impure-render/tsconfig.json b/packages/prover/tests/fixtures/impure-render/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/impure-render/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/incomplete-accessor-listener-capture/src/app.tsx b/packages/prover/tests/fixtures/incomplete-accessor-listener-capture/src/app.tsx new file mode 100644 index 000000000..cdb588f0e --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-accessor-listener-capture/src/app.tsx @@ -0,0 +1,21 @@ +import { useEffect } from "react"; + +const handleClick = () => undefined; + +interface CaptureListenerProperties { + shouldCapture: boolean; +} + +export const CaptureListener = ({ shouldCapture }: CaptureListenerProperties) => { + useEffect(() => { + const options = { + get capture() { + return shouldCapture; + }, + }; + window.addEventListener("click", handleClick, options); + return () => window.removeEventListener("click", handleClick, false); + }, [shouldCapture]); + + return null; +}; diff --git a/packages/prover/tests/fixtures/incomplete-accessor-listener-capture/tsconfig.json b/packages/prover/tests/fixtures/incomplete-accessor-listener-capture/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-accessor-listener-capture/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/incomplete-action-state-async-transition/src/app.tsx b/packages/prover/tests/fixtures/incomplete-action-state-async-transition/src/app.tsx new file mode 100644 index 000000000..542b7ba07 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-action-state-async-transition/src/app.tsx @@ -0,0 +1,19 @@ +import { startTransition, useActionState } from "react"; + +export const SearchIndex = () => { + const [query, dispatchQuery] = useActionState( + (_previousQuery: string, nextQuery: string) => nextQuery, + "", + ); + const handleSearch = () => { + startTransition(async () => { + await Promise.resolve(); + dispatchQuery("react"); + }); + }; + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/incomplete-action-state-async-transition/tsconfig.json b/packages/prover/tests/fixtures/incomplete-action-state-async-transition/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-action-state-async-transition/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/incomplete-action-state-dispatcher-escape/src/app.tsx b/packages/prover/tests/fixtures/incomplete-action-state-dispatcher-escape/src/app.tsx new file mode 100644 index 000000000..096d96f96 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-action-state-dispatcher-escape/src/app.tsx @@ -0,0 +1,10 @@ +import { useActionState } from "react"; + +export const Cart = () => { + const [quantity, updateQuantity] = useActionState( + (_previousQuantity: number, nextQuantity: number) => nextQuantity, + 1, + ); + const actions = { updateQuantity }; + return {quantity}; +}; diff --git a/packages/prover/tests/fixtures/incomplete-action-state-dispatcher-escape/tsconfig.json b/packages/prover/tests/fixtures/incomplete-action-state-dispatcher-escape/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-action-state-dispatcher-escape/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/incomplete-action-state-reducer-prop/src/app.tsx b/packages/prover/tests/fixtures/incomplete-action-state-reducer-prop/src/app.tsx new file mode 100644 index 000000000..e136fc484 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-action-state-reducer-prop/src/app.tsx @@ -0,0 +1,15 @@ +import { useActionState } from "react"; + +interface ActionPanelProperties { + reducerAction: (previousState: number, payload: number) => number; +} + +export const ActionPanel = ({ reducerAction }: ActionPanelProperties) => { + const [state, dispatchAction] = useActionState(reducerAction, 0); + return ( + dispatchAction(1)}> + + {state} + + ); +}; diff --git a/packages/prover/tests/fixtures/incomplete-action-state-reducer-prop/tsconfig.json b/packages/prover/tests/fixtures/incomplete-action-state-reducer-prop/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-action-state-reducer-prop/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/incomplete-ambiguous-observer-kind/src/app.tsx b/packages/prover/tests/fixtures/incomplete-ambiguous-observer-kind/src/app.tsx new file mode 100644 index 000000000..06ffd5295 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-ambiguous-observer-kind/src/app.tsx @@ -0,0 +1,14 @@ +import { useEffect } from "react"; + +interface ObserverConsumerProperties { + observer: IntersectionObserver | ResizeObserver; +} + +export const ObserverConsumer = ({ observer }: ObserverConsumerProperties) => { + useEffect(() => { + observer.observe(document.body); + return () => observer.disconnect(); + }, [observer]); + + return null; +}; diff --git a/packages/prover/tests/fixtures/incomplete-ambiguous-observer-kind/tsconfig.json b/packages/prover/tests/fixtures/incomplete-ambiguous-observer-kind/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-ambiguous-observer-kind/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/incomplete-async-effect-abort-contract/src/app.tsx b/packages/prover/tests/fixtures/incomplete-async-effect-abort-contract/src/app.tsx new file mode 100644 index 000000000..6675af546 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-async-effect-abort-contract/src/app.tsx @@ -0,0 +1,24 @@ +import { useEffect, useState } from "react"; + +interface SearchResultProperties { + loadQuery: (query: string, signal: AbortSignal) => Promise; + query: string; +} + +export const SearchResult = ({ loadQuery, query }: SearchResultProperties) => { + const [result, setResult] = useState(""); + + useEffect(() => { + const controller = new AbortController(); + const loadResult = async () => { + const nextResult = await loadQuery(query, controller.signal); + if (!controller.signal.aborted) setResult(nextResult); + }; + void loadResult(); + return () => { + controller.abort(); + }; + }, [loadQuery, query]); + + return {result}; +}; diff --git a/packages/prover/tests/fixtures/incomplete-async-effect-abort-contract/tsconfig.json b/packages/prover/tests/fixtures/incomplete-async-effect-abort-contract/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-async-effect-abort-contract/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/incomplete-async-effect-ignore-contract/src/app.tsx b/packages/prover/tests/fixtures/incomplete-async-effect-ignore-contract/src/app.tsx new file mode 100644 index 000000000..6e60d4874 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-async-effect-ignore-contract/src/app.tsx @@ -0,0 +1,24 @@ +import { useEffect, useState } from "react"; + +interface SearchResultProperties { + loadQuery: (query: string) => Promise; + query: string; +} + +export const SearchResult = ({ loadQuery, query }: SearchResultProperties) => { + const [result, setResult] = useState(""); + + useEffect(() => { + let didLoseOwnership = false; + const loadResult = async () => { + const nextResult = await loadQuery(query); + if (!didLoseOwnership) setResult(nextResult); + }; + void loadResult(); + return () => { + didLoseOwnership = true; + }; + }, [loadQuery, query]); + + return {result}; +}; diff --git a/packages/prover/tests/fixtures/incomplete-async-effect-ignore-contract/tsconfig.json b/packages/prover/tests/fixtures/incomplete-async-effect-ignore-contract/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-async-effect-ignore-contract/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/incomplete-async-effect-promise-ignore-contract/src/app.tsx b/packages/prover/tests/fixtures/incomplete-async-effect-promise-ignore-contract/src/app.tsx new file mode 100644 index 000000000..a17918e7f --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-async-effect-promise-ignore-contract/src/app.tsx @@ -0,0 +1,22 @@ +import { useEffect, useState } from "react"; + +interface SearchResultProperties { + loadQuery: (query: string) => Promise; + query: string; +} + +export const SearchResult = ({ loadQuery, query }: SearchResultProperties) => { + const [result, setResult] = useState(""); + + useEffect(() => { + let didLoseOwnership = false; + void loadQuery(query).then((nextResult) => { + if (!didLoseOwnership) setResult(nextResult); + }); + return () => { + didLoseOwnership = true; + }; + }, [loadQuery, query]); + + return {result}; +}; diff --git a/packages/prover/tests/fixtures/incomplete-async-effect-promise-ignore-contract/tsconfig.json b/packages/prover/tests/fixtures/incomplete-async-effect-promise-ignore-contract/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-async-effect-promise-ignore-contract/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/incomplete-async-listener-callback/src/app.tsx b/packages/prover/tests/fixtures/incomplete-async-listener-callback/src/app.tsx new file mode 100644 index 000000000..15eeb1264 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-async-listener-callback/src/app.tsx @@ -0,0 +1,12 @@ +import { useEffect } from "react"; + +const handleMessage = async () => Promise.resolve(); + +export const MessageListener = () => { + useEffect(() => { + window.addEventListener("message", handleMessage); + return () => window.removeEventListener("message", handleMessage); + }, []); + + return null; +}; diff --git a/packages/prover/tests/fixtures/incomplete-async-listener-callback/tsconfig.json b/packages/prover/tests/fixtures/incomplete-async-listener-callback/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-async-listener-callback/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/incomplete-async-transition-action/src/app.tsx b/packages/prover/tests/fixtures/incomplete-async-transition-action/src/app.tsx new file mode 100644 index 000000000..832a0a2a5 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-async-transition-action/src/app.tsx @@ -0,0 +1,21 @@ +import { startTransition, useState } from "react"; + +const loadPanel = async () => Promise.resolve("activity"); + +export const Panel = () => { + const [panel, setPanel] = useState("overview"); + + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/incomplete-async-transition-action/tsconfig.json b/packages/prover/tests/fixtures/incomplete-async-transition-action/tsconfig.json new file mode 100644 index 000000000..d2a921ed0 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-async-transition-action/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/incomplete-callback-ref-imperative-handle/src/app.tsx b/packages/prover/tests/fixtures/incomplete-callback-ref-imperative-handle/src/app.tsx new file mode 100644 index 000000000..12a091056 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-callback-ref-imperative-handle/src/app.tsx @@ -0,0 +1,22 @@ +import { useImperativeHandle, useState } from "react"; +import type { Ref } from "react"; + +interface PanelHandle { + collapse(): void; +} + +interface PanelProperties { + ref?: Ref; +} + +const Panel = ({ ref }: PanelProperties) => { + useImperativeHandle(ref, () => ({ + collapse: () => undefined, + })); + return