From 9de34e489786323c7e259e370dee0005844792f8 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Tue, 28 Jul 2026 10:21:49 +0000 Subject: [PATCH 01/23] feat: add whole-app React proof engine prototype --- packages/prover/.gitignore | 1 + packages/prover/README.md | 71 + packages/prover/package.json | 38 + packages/prover/playwright.config.ts | 18 + packages/prover/research-log.md | 649 ++++++ .../prover/scripts/smoke-built-package.mjs | 17 + .../src/analyze-async-effect-ownership.ts | 55 + .../prover/src/analyze-boundary-coverage.ts | 397 ++++ .../prover/src/analyze-component-identity.ts | 46 + .../src/analyze-component-invocation.ts | 71 + .../prover/src/analyze-context-topology.ts | 106 + packages/prover/src/analyze-effect-cleanup.ts | 316 +++ .../prover/src/analyze-effect-dependencies.ts | 99 + .../prover/src/analyze-effect-event-usage.ts | 229 ++ .../src/analyze-effect-state-updates.ts | 253 +++ .../src/analyze-external-store-consistency.ts | 536 +++++ packages/prover/src/analyze-hook-order.ts | 158 ++ packages/prover/src/analyze-hook-ownership.ts | 13 + .../prover/src/analyze-memo-dependencies.ts | 102 + packages/prover/src/analyze-react-unit.ts | 126 ++ .../src/analyze-reconciliation-identity.ts | 396 ++++ packages/prover/src/analyze-reducer-purity.ts | 94 + packages/prover/src/analyze-ref-access.ts | 52 + packages/prover/src/analyze-render-purity.ts | 286 +++ .../prover/src/build-react-semantic-graph.ts | 1500 +++++++++++++ .../prover/src/check-react-proof-report.ts | 645 ++++++ .../collect-async-effect-task-descriptors.ts | 502 +++++ .../prover/src/collect-binding-identifiers.ts | 13 + .../src/collect-callable-target-functions.ts | 20 + .../src/collect-callback-state-writes.ts | 28 + .../prover/src/collect-direct-hook-calls.ts | 23 + packages/prover/src/collect-effect-calls.ts | 9 + .../src/collect-effect-cleanup-functions.ts | 31 + .../src/collect-effect-event-bindings.ts | 44 + .../src/collect-event-callback-functions.ts | 30 + ...ollect-external-store-protocol-variants.ts | 260 +++ packages/prover/src/collect-hook-bindings.ts | 70 + packages/prover/src/collect-hook-calls.ts | 21 + .../src/collect-project-soundness-evidence.ts | 88 + .../prover/src/collect-reachable-functions.ts | 457 ++++ packages/prover/src/collect-react-units.ts | 94 + .../prover/src/collect-reactive-captures.ts | 49 + packages/prover/src/constants.ts | 133 ++ packages/prover/src/contains-jsx.ts | 18 + .../src/create-component-callback-flow.ts | 502 +++++ packages/prover/src/create-evidence.ts | 14 + packages/prover/src/create-obligation.ts | 14 + .../prover/src/create-typescript-project.ts | 92 + .../src/extract-react-compiler-graph.ts | 189 ++ .../prover/src/find-function-by-location.ts | 32 + packages/prover/src/find-semantic-unit.ts | 18 + packages/prover/src/get-call-name.ts | 21 + .../prover/src/get-canonical-hook-name.ts | 47 + .../src/get-canonical-react-api-name.ts | 67 + .../prover/src/get-component-prop-name.ts | 59 + packages/prover/src/get-effect-callback.ts | 35 + .../src/get-for-of-binding-descriptor.ts | 93 + packages/prover/src/get-function-name.ts | 36 + packages/prover/src/get-node-location.ts | 13 + packages/prover/src/get-root-identifier.ts | 9 + .../prover/src/get-static-boolean-value.ts | 16 + packages/prover/src/index.ts | 53 + .../src/is-component-prop-expression.ts | 24 + packages/prover/src/is-function-boundary.ts | 10 + .../prover/src/is-guaranteed-state-change.ts | 69 + .../prover/src/is-identifier-reference.ts | 25 + packages/prover/src/is-node-within.ts | 6 + .../prover/src/is-react-context-expression.ts | 14 + packages/prover/src/is-react-hook-name.ts | 2 + packages/prover/src/prove-react-app.ts | 74 + packages/prover/src/prove-react-program.ts | 164 ++ .../prover/src/resolve-callable-expression.ts | 760 +++++++ packages/prover/src/resolve-function.ts | 53 + .../prover/src/summarize-function-returns.ts | 306 +++ packages/prover/src/types.ts | 421 ++++ .../src/unwrap-typescript-expression.ts | 15 + .../prover/src/utils/collect-symbol-writes.ts | 71 + .../fixtures/aliased-stale-effect/src/app.tsx | 12 + .../aliased-stale-effect/tsconfig.json | 4 + .../anonymous-hook-callback/src/app.tsx | 12 + .../anonymous-hook-callback/tsconfig.json | 4 + .../src/app.tsx | 15 + .../tsconfig.json | 4 + .../async-effect-opaque-guard/src/app.tsx | 21 + .../async-effect-opaque-guard/tsconfig.json | 4 + .../src/app.tsx | 26 + .../tsconfig.json | 4 + .../src/app.tsx | 19 + .../tsconfig.json | 4 + .../async-effect-promise-chain/src/app.tsx | 16 + .../async-effect-promise-chain/tsconfig.json | 4 + .../async-effect-stale-write/src/app.tsx | 20 + .../async-effect-stale-write/tsconfig.json | 4 + .../src/app.tsx | 10 + .../tsconfig.json | 4 + .../src/app.tsx | 13 + .../tsconfig.json | 4 + .../src/app.tsx | 14 + .../tsconfig.json | 4 + .../fixtures/class-component/src/app.tsx | 11 + .../fixtures/class-component/tsconfig.json | 4 + .../fixtures/cleanup-mismatch/src/app.tsx | 11 + .../fixtures/cleanup-mismatch/tsconfig.json | 4 + .../fixtures/compiler-bailout/src/app.tsx | 7 + .../fixtures/compiler-bailout/tsconfig.json | 4 + .../src/app.tsx | 26 + .../tsconfig.json | 4 + .../fixtures/conditional-hook/src/app.tsx | 11 + .../fixtures/conditional-hook/tsconfig.json | 4 + .../fixtures/conditional-use/src/app.tsx | 12 + .../fixtures/conditional-use/tsconfig.json | 4 + .../src/app.tsx | 14 + .../tsconfig.json | 4 + .../coreui-listener-leak/src/sidebar.tsx | 18 + .../coreui-listener-leak/tsconfig.json | 4 + .../datepicker-loop-index-key/src/app.tsx | 12 + .../datepicker-loop-index-key/tsconfig.json | 4 + .../direct-component-call/src/app.tsx | 3 + .../direct-component-call/tsconfig.json | 4 + .../fixtures/duplicate-list-key/src/app.tsx | 1 + .../fixtures/duplicate-list-key/tsconfig.json | 4 + .../effect-event-dependency/src/app.tsx | 11 + .../effect-event-dependency/tsconfig.json | 4 + .../effect-event-hook-escape/src/app.tsx | 14 + .../effect-event-hook-escape/tsconfig.json | 4 + .../effect-event-memo-context/src/app.tsx | 16 + .../effect-event-memo-context/tsconfig.json | 4 + .../src/app.tsx | 15 + .../tsconfig.json | 4 + .../effect-event-prop-escape/src/app.tsx | 12 + .../effect-event-prop-escape/tsconfig.json | 4 + .../effect-event-render-call/src/app.tsx | 7 + .../effect-event-render-call/tsconfig.json | 4 + .../effect-event-shared-helper/src/app.tsx | 16 + .../effect-event-shared-helper/tsconfig.json | 4 + .../fixtures/effect-self-cycle/src/app.tsx | 11 + .../fixtures/effect-self-cycle/tsconfig.json | 4 + .../fixtures/effect-state-update/src/app.tsx | 11 + .../effect-state-update/tsconfig.json | 4 + .../event-handler-boundary/src/app.tsx | 15 + .../event-handler-boundary/tsconfig.json | 4 + .../fixtures/external-context/src/app.tsx | 7 + .../external-context/src/theme-library.d.ts | 5 + .../fixtures/external-context/tsconfig.json | 4 + .../src/app.tsx | 14 + .../tsconfig.json | 4 + .../src/app.tsx | 20 + .../tsconfig.json | 4 + .../src/app.tsx | 13 + .../tsconfig.json | 4 + .../src/app.tsx | 16 + .../tsconfig.json | 4 + .../src/app.tsx | 14 + .../tsconfig.json | 4 + .../src/app.tsx | 16 + .../tsconfig.json | 4 + .../src/app.tsx | 22 + .../tsconfig.json | 4 + .../fresh-external-store-snapshot/src/app.tsx | 8 + .../tsconfig.json | 4 + .../helper-aliased-prop-mutation/src/app.tsx | 15 + .../tsconfig.json | 4 + .../helper-effect-listener-leak/src/app.tsx | 15 + .../helper-effect-listener-leak/tsconfig.json | 4 + .../helper-effect-state-update/src/app.tsx | 14 + .../helper-effect-state-update/tsconfig.json | 4 + .../tests/fixtures/impure-reducer/src/app.tsx | 8 + .../fixtures/impure-reducer/tsconfig.json | 4 + .../tests/fixtures/impure-render/src/app.tsx | 4 + .../fixtures/impure-render/tsconfig.json | 4 + .../src/app.tsx | 24 + .../tsconfig.json | 4 + .../src/app.tsx | 24 + .../tsconfig.json | 4 + .../src/app.tsx | 22 + .../tsconfig.json | 4 + .../src/app.tsx | 19 + .../tsconfig.json | 4 + .../src/app.tsx | 19 + .../tsconfig.json | 4 + .../src/app.tsx | 18 + .../tsconfig.json | 4 + .../incomplete-event-prop-spread/src/app.tsx | 11 + .../tsconfig.json | 4 + .../src/app.tsx | 40 + .../tsconfig.json | 4 + .../src/app.tsx | 23 + .../tsconfig.json | 4 + .../src/app.tsx | 53 + .../tsconfig.json | 4 + .../src/app.tsx | 42 + .../tsconfig.json | 4 + .../src/app.tsx | 25 + .../tsconfig.json | 4 + .../src/app.tsx | 23 + .../tsconfig.json | 4 + .../src/app.tsx | 24 + .../tsconfig.json | 4 + .../src/app.tsx | 25 + .../tsconfig.json | 4 + .../src/app.tsx | 25 + .../tsconfig.json | 4 + .../src/app.tsx | 17 + .../tsconfig.json | 4 + .../src/app.tsx | 17 + .../tsconfig.json | 4 + .../src/app.tsx | 14 + .../tsconfig.json | 4 + .../src/app.tsx | 16 + .../tsconfig.json | 4 + .../src/app.tsx | 13 + .../tsconfig.json | 4 + .../src/app.tsx | 19 + .../tsconfig.json | 4 + .../src/app.tsx | 29 + .../tsconfig.json | 4 + .../src/app.tsx | 27 + .../tsconfig.json | 4 + .../src/app.tsx | 20 + .../tsconfig.json | 4 + .../src/app.tsx | 27 + .../tsconfig.json | 4 + .../tests/fixtures/index-list-key/src/app.tsx | 11 + .../fixtures/index-list-key/tsconfig.json | 4 + .../fixtures/invalid-hook-helper/src/app.tsx | 8 + .../invalid-hook-helper/tsconfig.json | 4 + .../fixtures/mapped-event-handler/src/app.tsx | 22 + .../mapped-event-handler/tsconfig.json | 4 + .../tests/fixtures/memo-callback/src/app.tsx | 7 + .../fixtures/memo-callback/tsconfig.json | 4 + .../method-effect-listener-leak/src/app.tsx | 16 + .../method-effect-listener-leak/tsconfig.json | 4 + .../src/app.tsx | 30 + .../tsconfig.json | 4 + .../src/app.tsx | 55 + .../tsconfig.json | 4 + .../src/app.tsx | 49 + .../tsconfig.json | 4 + .../mismatched-server-snapshot/src/app.tsx | 12 + .../mismatched-server-snapshot/tsconfig.json | 4 + .../fixtures/missing-list-key/src/app.tsx | 11 + .../fixtures/missing-list-key/tsconfig.json | 4 + .../fixtures/module-hook-call/src/app.tsx | 5 + .../fixtures/module-hook-call/tsconfig.json | 4 + .../named-memo-impure-helper/src/app.tsx | 12 + .../named-memo-impure-helper/tsconfig.json | 4 + .../fixtures/nested-component/src/app.tsx | 8 + .../fixtures/nested-component/tsconfig.json | 4 + .../src/app.tsx | 16 + .../tsconfig.json | 4 + .../fixtures/opaque-render-call/src/app.tsx | 6 + .../fixtures/opaque-render-call/tsconfig.json | 4 + .../path-dependent-cleanup/src/app.tsx | 17 + .../path-dependent-cleanup/tsconfig.json | 4 + .../tests/fixtures/prop-mutation/src/app.tsx | 8 + .../fixtures/prop-mutation/tsconfig.json | 4 + .../fixtures/proved-aliased-hook/src/app.tsx | 6 + .../proved-aliased-hook/tsconfig.json | 4 + .../proved-branch-effect-cleanup/src/app.tsx | 20 + .../tsconfig.json | 4 + .../fixtures/proved-cfg/src/status-badge.tsx | 8 + .../tests/fixtures/proved-cfg/tsconfig.json | 4 + .../tests/fixtures/proved-chat/src/app.tsx | 21 + .../tests/fixtures/proved-chat/tsconfig.json | 12 + .../proved-cleanup-callback-prop/src/app.tsx | 15 + .../tsconfig.json | 4 + .../src/app.tsx | 19 + .../tsconfig.json | 4 + .../proved-context-identity/src/app.tsx | 15 + .../proved-context-identity/tsconfig.json | 4 + .../proved-context-topology/src/app.tsx | 15 + .../src/theme-context.ts | 3 + .../src/theme-label.tsx | 9 + .../proved-context-topology/tsconfig.json | 4 + .../tests/fixtures/proved-context/src/app.tsx | 8 + .../fixtures/proved-context/tsconfig.json | 4 + .../fixtures/proved-custom-hook/src/app.tsx | 6 + .../proved-custom-hook/src/use-counter.ts | 6 + .../fixtures/proved-custom-hook/tsconfig.json | 4 + .../proved-default-component/src/app.tsx | 1 + .../proved-default-component/tsconfig.json | 4 + .../proved-effect-callback-prop/src/app.tsx | 17 + .../proved-effect-callback-prop/tsconfig.json | 4 + .../fixtures/proved-effect-event/src/app.tsx | 23 + .../proved-effect-event/tsconfig.json | 4 + .../src/app.tsx | 14 + .../tsconfig.json | 4 + .../proved-event-prop-flow/src/app.tsx | 22 + .../proved-event-prop-flow/tsconfig.json | 4 + .../proved-event-prop-wrapper/src/app.tsx | 17 + .../proved-event-prop-wrapper/tsconfig.json | 4 + .../src/app.tsx | 26 + .../tsconfig.json | 4 + .../src/app.tsx | 61 + .../tsconfig.json | 4 + .../src/app.tsx | 51 + .../tsconfig.json | 4 + .../src/app.tsx | 60 + .../tsconfig.json | 4 + .../proved-external-store/src/app.tsx | 21 + .../proved-external-store/tsconfig.json | 4 + .../src/app.tsx | 13 + .../tsconfig.json | 4 + .../proved-for-of-handler-factory/src/app.tsx | 27 + .../tsconfig.json | 4 + .../src/app.tsx | 15 + .../tsconfig.json | 4 + .../src/app.tsx | 27 + .../tsconfig.json | 4 + .../src/app.tsx | 30 + .../tsconfig.json | 4 + .../src/app.tsx | 27 + .../tsconfig.json | 4 + .../proved-forwarded-event-prop/src/app.tsx | 20 + .../proved-forwarded-event-prop/tsconfig.json | 4 + .../proved-helper-effect-cleanup/src/app.tsx | 22 + .../tsconfig.json | 4 + .../proved-helper-local-rebinding/src/app.tsx | 10 + .../tsconfig.json | 4 + .../fixtures/proved-local-graph/src/app.tsx | 7 + .../proved-local-graph/src/format-title.ts | 1 + .../proved-local-graph/src/header.tsx | 7 + .../fixtures/proved-local-graph/tsconfig.json | 4 + .../proved-local-object-callback/src/app.tsx | 18 + .../tsconfig.json | 4 + .../tests/fixtures/proved-memo/src/app.tsx | 16 + .../tests/fixtures/proved-memo/tsconfig.json | 4 + .../src/app.tsx | 22 + .../tsconfig.json | 4 + .../proved-mount-state-update/src/app.tsx | 11 + .../proved-mount-state-update/tsconfig.json | 4 + .../proved-null-component/src/app.tsx | 1 + .../proved-null-component/tsconfig.json | 4 + .../proved-object-callback-flow/src/app.tsx | 18 + .../proved-object-callback-flow/tsconfig.json | 4 + .../tests/fixtures/proved-reducer/src/app.tsx | 17 + .../fixtures/proved-reducer/tsconfig.json | 4 + .../proved-returned-event-handler/src/app.tsx | 20 + .../tsconfig.json | 4 + .../src/app.tsx | 13 + .../tsconfig.json | 4 + .../proved-shared-event-handler/src/app.tsx | 13 + .../proved-shared-event-handler/tsconfig.json | 4 + .../proved-static-list-keys/src/app.tsx | 1 + .../proved-static-list-keys/tsconfig.json | 4 + .../proved-switch-handler-factory/src/app.tsx | 27 + .../tsconfig.json | 4 + .../tests/fixtures/proved-timer/src/app.tsx | 12 + .../tests/fixtures/proved-timer/tsconfig.json | 4 + .../src/app.tsx | 31 + .../tsconfig.json | 4 + .../src/app.tsx | 24 + .../tsconfig.json | 4 + .../proved-while-handler-factory/src/app.tsx | 23 + .../tsconfig.json | 4 + .../proved-wrapped-component/src/app.tsx | 6 + .../proved-wrapped-component/tsconfig.json | 4 + .../prover/tests/fixtures/react-shim.d.ts | 72 + .../src/app.tsx | 11 + .../tsconfig.json | 4 + .../fixtures/render-ref-access/src/app.tsx | 7 + .../fixtures/render-ref-access/tsconfig.json | 4 + .../src/app.tsx | 9 + .../tsconfig.json | 4 + .../src/app.tsx | 46 + .../tsconfig.json | 4 + .../silent-external-store-write/src/app.tsx | 18 + .../silent-external-store-write/tsconfig.json | 4 + .../tests/fixtures/stale-effect/src/app.tsx | 12 + .../tests/fixtures/stale-effect/tsconfig.json | 4 + .../state-update-in-render/src/app.tsx | 7 + .../state-update-in-render/tsconfig.json | 4 + .../src/app.tsx | 18 + .../tsconfig.json | 4 + .../tests/fixtures/timer-leak/src/app.tsx | 9 + .../tests/fixtures/timer-leak/tsconfig.json | 4 + .../transitive-impure-helper/src/app.tsx | 6 + .../transitive-impure-helper/src/create-id.ts | 1 + .../transitive-impure-helper/tsconfig.json | 4 + .../src/app.tsx | 13 + .../tsconfig.json | 4 + .../tests/fixtures/unsafe-types/src/app.tsx | 8 + .../tests/fixtures/unsafe-types/tsconfig.json | 4 + .../tests/fixtures/use-in-try/src/app.tsx | 14 + .../tests/fixtures/use-in-try/tsconfig.json | 4 + .../src/app.tsx | 14 + .../tsconfig.json | 4 + packages/prover/tests/prove-react-app.test.ts | 1848 +++++++++++++++++ .../async-effect-ownership-oracle.spec.ts | 21 + packages/prover/tests/runtime/constants.ts | 6 + .../tests/runtime/context-oracle.spec.ts | 27 + .../tests/runtime/effect-event-oracle.spec.ts | 34 + .../runtime/external-store-oracle.spec.ts | 58 + packages/prover/tests/runtime/index.html | 12 + .../tests/runtime/listener-oracle.spec.ts | 17 + packages/prover/tests/runtime/main.tsx | 434 ++++ .../runtime/reconciliation-oracle.spec.ts | 24 + packages/prover/tests/runtime/vite.config.ts | 9 + .../tests/summarize-function-returns.test.ts | 446 ++++ packages/prover/tsconfig.json | 10 + packages/prover/vite.config.ts | 30 + pnpm-lock.yaml | 276 ++- 402 files changed, 18085 insertions(+), 7 deletions(-) create mode 100644 packages/prover/.gitignore create mode 100644 packages/prover/README.md create mode 100644 packages/prover/package.json create mode 100644 packages/prover/playwright.config.ts create mode 100644 packages/prover/research-log.md create mode 100644 packages/prover/scripts/smoke-built-package.mjs create mode 100644 packages/prover/src/analyze-async-effect-ownership.ts create mode 100644 packages/prover/src/analyze-boundary-coverage.ts create mode 100644 packages/prover/src/analyze-component-identity.ts create mode 100644 packages/prover/src/analyze-component-invocation.ts create mode 100644 packages/prover/src/analyze-context-topology.ts create mode 100644 packages/prover/src/analyze-effect-cleanup.ts create mode 100644 packages/prover/src/analyze-effect-dependencies.ts create mode 100644 packages/prover/src/analyze-effect-event-usage.ts create mode 100644 packages/prover/src/analyze-effect-state-updates.ts create mode 100644 packages/prover/src/analyze-external-store-consistency.ts create mode 100644 packages/prover/src/analyze-hook-order.ts create mode 100644 packages/prover/src/analyze-hook-ownership.ts create mode 100644 packages/prover/src/analyze-memo-dependencies.ts create mode 100644 packages/prover/src/analyze-react-unit.ts create mode 100644 packages/prover/src/analyze-reconciliation-identity.ts create mode 100644 packages/prover/src/analyze-reducer-purity.ts create mode 100644 packages/prover/src/analyze-ref-access.ts create mode 100644 packages/prover/src/analyze-render-purity.ts create mode 100644 packages/prover/src/build-react-semantic-graph.ts create mode 100644 packages/prover/src/check-react-proof-report.ts create mode 100644 packages/prover/src/collect-async-effect-task-descriptors.ts create mode 100644 packages/prover/src/collect-binding-identifiers.ts create mode 100644 packages/prover/src/collect-callable-target-functions.ts create mode 100644 packages/prover/src/collect-callback-state-writes.ts create mode 100644 packages/prover/src/collect-direct-hook-calls.ts create mode 100644 packages/prover/src/collect-effect-calls.ts create mode 100644 packages/prover/src/collect-effect-cleanup-functions.ts create mode 100644 packages/prover/src/collect-effect-event-bindings.ts create mode 100644 packages/prover/src/collect-event-callback-functions.ts create mode 100644 packages/prover/src/collect-external-store-protocol-variants.ts create mode 100644 packages/prover/src/collect-hook-bindings.ts create mode 100644 packages/prover/src/collect-hook-calls.ts create mode 100644 packages/prover/src/collect-project-soundness-evidence.ts create mode 100644 packages/prover/src/collect-reachable-functions.ts create mode 100644 packages/prover/src/collect-react-units.ts create mode 100644 packages/prover/src/collect-reactive-captures.ts create mode 100644 packages/prover/src/constants.ts create mode 100644 packages/prover/src/contains-jsx.ts create mode 100644 packages/prover/src/create-component-callback-flow.ts create mode 100644 packages/prover/src/create-evidence.ts create mode 100644 packages/prover/src/create-obligation.ts create mode 100644 packages/prover/src/create-typescript-project.ts create mode 100644 packages/prover/src/extract-react-compiler-graph.ts create mode 100644 packages/prover/src/find-function-by-location.ts create mode 100644 packages/prover/src/find-semantic-unit.ts create mode 100644 packages/prover/src/get-call-name.ts create mode 100644 packages/prover/src/get-canonical-hook-name.ts create mode 100644 packages/prover/src/get-canonical-react-api-name.ts create mode 100644 packages/prover/src/get-component-prop-name.ts create mode 100644 packages/prover/src/get-effect-callback.ts create mode 100644 packages/prover/src/get-for-of-binding-descriptor.ts create mode 100644 packages/prover/src/get-function-name.ts create mode 100644 packages/prover/src/get-node-location.ts create mode 100644 packages/prover/src/get-root-identifier.ts create mode 100644 packages/prover/src/get-static-boolean-value.ts create mode 100644 packages/prover/src/index.ts create mode 100644 packages/prover/src/is-component-prop-expression.ts create mode 100644 packages/prover/src/is-function-boundary.ts create mode 100644 packages/prover/src/is-guaranteed-state-change.ts create mode 100644 packages/prover/src/is-identifier-reference.ts create mode 100644 packages/prover/src/is-node-within.ts create mode 100644 packages/prover/src/is-react-context-expression.ts create mode 100644 packages/prover/src/is-react-hook-name.ts create mode 100644 packages/prover/src/prove-react-app.ts create mode 100644 packages/prover/src/prove-react-program.ts create mode 100644 packages/prover/src/resolve-callable-expression.ts create mode 100644 packages/prover/src/resolve-function.ts create mode 100644 packages/prover/src/summarize-function-returns.ts create mode 100644 packages/prover/src/types.ts create mode 100644 packages/prover/src/unwrap-typescript-expression.ts create mode 100644 packages/prover/src/utils/collect-symbol-writes.ts create mode 100644 packages/prover/tests/fixtures/aliased-stale-effect/src/app.tsx create mode 100644 packages/prover/tests/fixtures/aliased-stale-effect/tsconfig.json create mode 100644 packages/prover/tests/fixtures/anonymous-hook-callback/src/app.tsx create mode 100644 packages/prover/tests/fixtures/anonymous-hook-callback/tsconfig.json create mode 100644 packages/prover/tests/fixtures/async-effect-opaque-continuation/src/app.tsx create mode 100644 packages/prover/tests/fixtures/async-effect-opaque-continuation/tsconfig.json create mode 100644 packages/prover/tests/fixtures/async-effect-opaque-guard/src/app.tsx create mode 100644 packages/prover/tests/fixtures/async-effect-opaque-guard/tsconfig.json create mode 100644 packages/prover/tests/fixtures/async-effect-path-dependent-invalidation/src/app.tsx create mode 100644 packages/prover/tests/fixtures/async-effect-path-dependent-invalidation/tsconfig.json create mode 100644 packages/prover/tests/fixtures/async-effect-post-await-mutation/src/app.tsx create mode 100644 packages/prover/tests/fixtures/async-effect-post-await-mutation/tsconfig.json create mode 100644 packages/prover/tests/fixtures/async-effect-promise-chain/src/app.tsx create mode 100644 packages/prover/tests/fixtures/async-effect-promise-chain/tsconfig.json create mode 100644 packages/prover/tests/fixtures/async-effect-stale-write/src/app.tsx create mode 100644 packages/prover/tests/fixtures/async-effect-stale-write/tsconfig.json create mode 100644 packages/prover/tests/fixtures/branch-returned-render-impurity/src/app.tsx create mode 100644 packages/prover/tests/fixtures/branch-returned-render-impurity/tsconfig.json create mode 100644 packages/prover/tests/fixtures/callback-parameter-effect-listener-leak/src/app.tsx create mode 100644 packages/prover/tests/fixtures/callback-parameter-effect-listener-leak/tsconfig.json create mode 100644 packages/prover/tests/fixtures/callback-parameter-opaque-registration/src/app.tsx create mode 100644 packages/prover/tests/fixtures/callback-parameter-opaque-registration/tsconfig.json create mode 100644 packages/prover/tests/fixtures/class-component/src/app.tsx create mode 100644 packages/prover/tests/fixtures/class-component/tsconfig.json create mode 100644 packages/prover/tests/fixtures/cleanup-mismatch/src/app.tsx create mode 100644 packages/prover/tests/fixtures/cleanup-mismatch/tsconfig.json create mode 100644 packages/prover/tests/fixtures/compiler-bailout/src/app.tsx create mode 100644 packages/prover/tests/fixtures/compiler-bailout/tsconfig.json create mode 100644 packages/prover/tests/fixtures/conditional-helper-effect-cleanup/src/app.tsx create mode 100644 packages/prover/tests/fixtures/conditional-helper-effect-cleanup/tsconfig.json create mode 100644 packages/prover/tests/fixtures/conditional-hook/src/app.tsx create mode 100644 packages/prover/tests/fixtures/conditional-hook/tsconfig.json create mode 100644 packages/prover/tests/fixtures/conditional-use/src/app.tsx create mode 100644 packages/prover/tests/fixtures/conditional-use/tsconfig.json create mode 100644 packages/prover/tests/fixtures/context-provider-missing-value/src/app.tsx create mode 100644 packages/prover/tests/fixtures/context-provider-missing-value/tsconfig.json create mode 100644 packages/prover/tests/fixtures/coreui-listener-leak/src/sidebar.tsx create mode 100644 packages/prover/tests/fixtures/coreui-listener-leak/tsconfig.json create mode 100644 packages/prover/tests/fixtures/datepicker-loop-index-key/src/app.tsx create mode 100644 packages/prover/tests/fixtures/datepicker-loop-index-key/tsconfig.json create mode 100644 packages/prover/tests/fixtures/direct-component-call/src/app.tsx create mode 100644 packages/prover/tests/fixtures/direct-component-call/tsconfig.json create mode 100644 packages/prover/tests/fixtures/duplicate-list-key/src/app.tsx create mode 100644 packages/prover/tests/fixtures/duplicate-list-key/tsconfig.json create mode 100644 packages/prover/tests/fixtures/effect-event-dependency/src/app.tsx create mode 100644 packages/prover/tests/fixtures/effect-event-dependency/tsconfig.json create mode 100644 packages/prover/tests/fixtures/effect-event-hook-escape/src/app.tsx create mode 100644 packages/prover/tests/fixtures/effect-event-hook-escape/tsconfig.json create mode 100644 packages/prover/tests/fixtures/effect-event-memo-context/src/app.tsx create mode 100644 packages/prover/tests/fixtures/effect-event-memo-context/tsconfig.json create mode 100644 packages/prover/tests/fixtures/effect-event-opaque-registration/src/app.tsx create mode 100644 packages/prover/tests/fixtures/effect-event-opaque-registration/tsconfig.json create mode 100644 packages/prover/tests/fixtures/effect-event-prop-escape/src/app.tsx create mode 100644 packages/prover/tests/fixtures/effect-event-prop-escape/tsconfig.json create mode 100644 packages/prover/tests/fixtures/effect-event-render-call/src/app.tsx create mode 100644 packages/prover/tests/fixtures/effect-event-render-call/tsconfig.json create mode 100644 packages/prover/tests/fixtures/effect-event-shared-helper/src/app.tsx create mode 100644 packages/prover/tests/fixtures/effect-event-shared-helper/tsconfig.json create mode 100644 packages/prover/tests/fixtures/effect-self-cycle/src/app.tsx create mode 100644 packages/prover/tests/fixtures/effect-self-cycle/tsconfig.json create mode 100644 packages/prover/tests/fixtures/effect-state-update/src/app.tsx create mode 100644 packages/prover/tests/fixtures/effect-state-update/tsconfig.json create mode 100644 packages/prover/tests/fixtures/event-handler-boundary/src/app.tsx create mode 100644 packages/prover/tests/fixtures/event-handler-boundary/tsconfig.json create mode 100644 packages/prover/tests/fixtures/external-context/src/app.tsx create mode 100644 packages/prover/tests/fixtures/external-context/src/theme-library.d.ts create mode 100644 packages/prover/tests/fixtures/external-context/tsconfig.json create mode 100644 packages/prover/tests/fixtures/external-store-cleanup-mismatch/src/app.tsx create mode 100644 packages/prover/tests/fixtures/external-store-cleanup-mismatch/tsconfig.json create mode 100644 packages/prover/tests/fixtures/external-store-helper-boundary/src/app.tsx create mode 100644 packages/prover/tests/fixtures/external-store-helper-boundary/tsconfig.json create mode 100644 packages/prover/tests/fixtures/finally-returned-render-impurity/src/app.tsx create mode 100644 packages/prover/tests/fixtures/finally-returned-render-impurity/tsconfig.json create mode 100644 packages/prover/tests/fixtures/for-of-destructured-render-impurity/src/app.tsx create mode 100644 packages/prover/tests/fixtures/for-of-destructured-render-impurity/tsconfig.json create mode 100644 packages/prover/tests/fixtures/for-of-invoked-render-impurity/src/app.tsx create mode 100644 packages/prover/tests/fixtures/for-of-invoked-render-impurity/tsconfig.json create mode 100644 packages/prover/tests/fixtures/for-of-returned-render-impurity/src/app.tsx create mode 100644 packages/prover/tests/fixtures/for-of-returned-render-impurity/tsconfig.json create mode 100644 packages/prover/tests/fixtures/fresh-external-store-callback-prop-snapshot/src/app.tsx create mode 100644 packages/prover/tests/fixtures/fresh-external-store-callback-prop-snapshot/tsconfig.json create mode 100644 packages/prover/tests/fixtures/fresh-external-store-snapshot/src/app.tsx create mode 100644 packages/prover/tests/fixtures/fresh-external-store-snapshot/tsconfig.json create mode 100644 packages/prover/tests/fixtures/helper-aliased-prop-mutation/src/app.tsx create mode 100644 packages/prover/tests/fixtures/helper-aliased-prop-mutation/tsconfig.json create mode 100644 packages/prover/tests/fixtures/helper-effect-listener-leak/src/app.tsx create mode 100644 packages/prover/tests/fixtures/helper-effect-listener-leak/tsconfig.json create mode 100644 packages/prover/tests/fixtures/helper-effect-state-update/src/app.tsx create mode 100644 packages/prover/tests/fixtures/helper-effect-state-update/tsconfig.json create mode 100644 packages/prover/tests/fixtures/impure-reducer/src/app.tsx create mode 100644 packages/prover/tests/fixtures/impure-reducer/tsconfig.json create mode 100644 packages/prover/tests/fixtures/impure-render/src/app.tsx create mode 100644 packages/prover/tests/fixtures/impure-render/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-async-effect-abort-contract/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-async-effect-abort-contract/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-async-effect-ignore-contract/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-async-effect-ignore-contract/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-async-effect-promise-ignore-contract/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-async-effect-promise-ignore-contract/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-computed-event-prop-wrapper/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-computed-event-prop-wrapper/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-defaulted-event-prop-wrapper/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-defaulted-event-prop-wrapper/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-effect-callback-prop-state-cycle/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-effect-callback-prop-state-cycle/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-event-prop-spread/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-event-prop-spread/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-external-store-callback-prop-conditional-join/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-external-store-callback-prop-conditional-join/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-external-store-callback-prop-spread/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-external-store-callback-prop-spread/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-external-store-conditional-factory/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-external-store-conditional-factory/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-external-store-mutated-conditional-props/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-external-store-mutated-conditional-props/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-for-of-computed-binding-handler/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-for-of-computed-binding-handler/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-for-of-defaulted-handler/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-for-of-defaulted-handler/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-for-of-mutable-handler/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-for-of-mutable-handler/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-for-of-rest-binding-handler/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-for-of-rest-binding-handler/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-for-of-spread-handler-factory/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-for-of-spread-handler-factory/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-local-object-callback-spread/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-local-object-callback-spread/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-logical-callback-alias/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-logical-callback-alias/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-mutable-object-callback/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-mutable-object-callback/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-object-callback-spread/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-object-callback-spread/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-partial-handler-factory/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-partial-handler-factory/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-ref-backed-event-callback/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-ref-backed-event-callback/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-switch-fallthrough-handler-factory/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-switch-fallthrough-handler-factory/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-switch-uncovered-handler-factory/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-switch-uncovered-handler-factory/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-try-catch-handler-factory/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-try-catch-handler-factory/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-while-handler-factory/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-while-handler-factory/tsconfig.json create mode 100644 packages/prover/tests/fixtures/index-list-key/src/app.tsx create mode 100644 packages/prover/tests/fixtures/index-list-key/tsconfig.json create mode 100644 packages/prover/tests/fixtures/invalid-hook-helper/src/app.tsx create mode 100644 packages/prover/tests/fixtures/invalid-hook-helper/tsconfig.json create mode 100644 packages/prover/tests/fixtures/mapped-event-handler/src/app.tsx create mode 100644 packages/prover/tests/fixtures/mapped-event-handler/tsconfig.json create mode 100644 packages/prover/tests/fixtures/memo-callback/src/app.tsx create mode 100644 packages/prover/tests/fixtures/memo-callback/tsconfig.json create mode 100644 packages/prover/tests/fixtures/method-effect-listener-leak/src/app.tsx create mode 100644 packages/prover/tests/fixtures/method-effect-listener-leak/tsconfig.json create mode 100644 packages/prover/tests/fixtures/mismatched-external-store-callback-prop-server-snapshot/src/app.tsx create mode 100644 packages/prover/tests/fixtures/mismatched-external-store-callback-prop-server-snapshot/tsconfig.json create mode 100644 packages/prover/tests/fixtures/mismatched-external-store-conditional-factory/src/app.tsx create mode 100644 packages/prover/tests/fixtures/mismatched-external-store-conditional-factory/tsconfig.json create mode 100644 packages/prover/tests/fixtures/mismatched-external-store-conditional-props/src/app.tsx create mode 100644 packages/prover/tests/fixtures/mismatched-external-store-conditional-props/tsconfig.json create mode 100644 packages/prover/tests/fixtures/mismatched-server-snapshot/src/app.tsx create mode 100644 packages/prover/tests/fixtures/mismatched-server-snapshot/tsconfig.json create mode 100644 packages/prover/tests/fixtures/missing-list-key/src/app.tsx create mode 100644 packages/prover/tests/fixtures/missing-list-key/tsconfig.json create mode 100644 packages/prover/tests/fixtures/module-hook-call/src/app.tsx create mode 100644 packages/prover/tests/fixtures/module-hook-call/tsconfig.json create mode 100644 packages/prover/tests/fixtures/named-memo-impure-helper/src/app.tsx create mode 100644 packages/prover/tests/fixtures/named-memo-impure-helper/tsconfig.json create mode 100644 packages/prover/tests/fixtures/nested-component/src/app.tsx create mode 100644 packages/prover/tests/fixtures/nested-component/tsconfig.json create mode 100644 packages/prover/tests/fixtures/object-callback-effect-listener-leak/src/app.tsx create mode 100644 packages/prover/tests/fixtures/object-callback-effect-listener-leak/tsconfig.json create mode 100644 packages/prover/tests/fixtures/opaque-render-call/src/app.tsx create mode 100644 packages/prover/tests/fixtures/opaque-render-call/tsconfig.json create mode 100644 packages/prover/tests/fixtures/path-dependent-cleanup/src/app.tsx create mode 100644 packages/prover/tests/fixtures/path-dependent-cleanup/tsconfig.json create mode 100644 packages/prover/tests/fixtures/prop-mutation/src/app.tsx create mode 100644 packages/prover/tests/fixtures/prop-mutation/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-aliased-hook/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-aliased-hook/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-branch-effect-cleanup/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-branch-effect-cleanup/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-cfg/src/status-badge.tsx create mode 100644 packages/prover/tests/fixtures/proved-cfg/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-chat/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-chat/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-cleanup-callback-prop/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-cleanup-callback-prop/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-conditional-handler-factory/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-conditional-handler-factory/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-context-identity/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-context-identity/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-context-topology/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-context-topology/src/theme-context.ts create mode 100644 packages/prover/tests/fixtures/proved-context-topology/src/theme-label.tsx create mode 100644 packages/prover/tests/fixtures/proved-context-topology/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-context/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-context/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-custom-hook/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-custom-hook/src/use-counter.ts create mode 100644 packages/prover/tests/fixtures/proved-custom-hook/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-default-component/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-default-component/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-effect-callback-prop/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-effect-callback-prop/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-effect-event/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-effect-event/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-event-callback-parameter/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-event-callback-parameter/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-event-prop-flow/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-event-prop-flow/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-event-prop-wrapper/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-event-prop-wrapper/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-external-store-callback-props/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-external-store-callback-props/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-external-store-conditional-factory/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-external-store-conditional-factory/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-external-store-conditional-props/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-external-store-conditional-props/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-external-store-render-branch-props/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-external-store-render-branch-props/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-external-store/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-external-store/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-finally-overrides-handler/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-finally-overrides-handler/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-for-of-handler-factory/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-for-of-handler-factory/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-for-of-invoked-handlers/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-for-of-invoked-handlers/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-for-of-nested-binding-handler/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-for-of-nested-binding-handler/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-for-of-object-binding-handler/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-for-of-object-binding-handler/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-for-of-tuple-binding-handler/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-for-of-tuple-binding-handler/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-forwarded-event-prop/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-forwarded-event-prop/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-helper-effect-cleanup/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-helper-effect-cleanup/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-helper-local-rebinding/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-helper-local-rebinding/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-local-graph/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-local-graph/src/format-title.ts create mode 100644 packages/prover/tests/fixtures/proved-local-graph/src/header.tsx create mode 100644 packages/prover/tests/fixtures/proved-local-graph/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-local-object-callback/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-local-object-callback/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-memo/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-memo/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-mixed-phase-callback-prop/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-mixed-phase-callback-prop/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-mount-state-update/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-mount-state-update/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-null-component/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-null-component/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-object-callback-flow/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-object-callback-flow/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-reducer/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-reducer/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-returned-event-handler/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-returned-event-handler/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-returned-use-callback-hook/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-returned-use-callback-hook/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-shared-event-handler/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-shared-event-handler/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-static-list-keys/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-static-list-keys/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-switch-handler-factory/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-switch-handler-factory/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-timer/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-timer/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-transitive-event-prop-wrapper/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-transitive-event-prop-wrapper/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-try-catch-handler-factory/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-try-catch-handler-factory/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-while-handler-factory/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-while-handler-factory/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-wrapped-component/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-wrapped-component/tsconfig.json create mode 100644 packages/prover/tests/fixtures/react-shim.d.ts create mode 100644 packages/prover/tests/fixtures/render-callback-parameter-impurity/src/app.tsx create mode 100644 packages/prover/tests/fixtures/render-callback-parameter-impurity/tsconfig.json create mode 100644 packages/prover/tests/fixtures/render-ref-access/src/app.tsx create mode 100644 packages/prover/tests/fixtures/render-ref-access/tsconfig.json create mode 100644 packages/prover/tests/fixtures/render-returned-callback-impurity/src/app.tsx create mode 100644 packages/prover/tests/fixtures/render-returned-callback-impurity/tsconfig.json create mode 100644 packages/prover/tests/fixtures/silent-external-store-render-branch-props/src/app.tsx create mode 100644 packages/prover/tests/fixtures/silent-external-store-render-branch-props/tsconfig.json create mode 100644 packages/prover/tests/fixtures/silent-external-store-write/src/app.tsx create mode 100644 packages/prover/tests/fixtures/silent-external-store-write/tsconfig.json create mode 100644 packages/prover/tests/fixtures/stale-effect/src/app.tsx create mode 100644 packages/prover/tests/fixtures/stale-effect/tsconfig.json create mode 100644 packages/prover/tests/fixtures/state-update-in-render/src/app.tsx create mode 100644 packages/prover/tests/fixtures/state-update-in-render/tsconfig.json create mode 100644 packages/prover/tests/fixtures/switch-returned-render-impurity/src/app.tsx create mode 100644 packages/prover/tests/fixtures/switch-returned-render-impurity/tsconfig.json create mode 100644 packages/prover/tests/fixtures/timer-leak/src/app.tsx create mode 100644 packages/prover/tests/fixtures/timer-leak/tsconfig.json create mode 100644 packages/prover/tests/fixtures/transitive-impure-helper/src/app.tsx create mode 100644 packages/prover/tests/fixtures/transitive-impure-helper/src/create-id.ts create mode 100644 packages/prover/tests/fixtures/transitive-impure-helper/tsconfig.json create mode 100644 packages/prover/tests/fixtures/try-catch-returned-render-impurity/src/app.tsx create mode 100644 packages/prover/tests/fixtures/try-catch-returned-render-impurity/tsconfig.json create mode 100644 packages/prover/tests/fixtures/unsafe-types/src/app.tsx create mode 100644 packages/prover/tests/fixtures/unsafe-types/tsconfig.json create mode 100644 packages/prover/tests/fixtures/use-in-try/src/app.tsx create mode 100644 packages/prover/tests/fixtures/use-in-try/tsconfig.json create mode 100644 packages/prover/tests/fixtures/while-returned-render-impurity/src/app.tsx create mode 100644 packages/prover/tests/fixtures/while-returned-render-impurity/tsconfig.json create mode 100644 packages/prover/tests/prove-react-app.test.ts create mode 100644 packages/prover/tests/runtime/async-effect-ownership-oracle.spec.ts create mode 100644 packages/prover/tests/runtime/constants.ts create mode 100644 packages/prover/tests/runtime/context-oracle.spec.ts create mode 100644 packages/prover/tests/runtime/effect-event-oracle.spec.ts create mode 100644 packages/prover/tests/runtime/external-store-oracle.spec.ts create mode 100644 packages/prover/tests/runtime/index.html create mode 100644 packages/prover/tests/runtime/listener-oracle.spec.ts create mode 100644 packages/prover/tests/runtime/main.tsx create mode 100644 packages/prover/tests/runtime/reconciliation-oracle.spec.ts create mode 100644 packages/prover/tests/runtime/vite.config.ts create mode 100644 packages/prover/tests/summarize-function-returns.test.ts create mode 100644 packages/prover/tsconfig.json create mode 100644 packages/prover/vite.config.ts diff --git a/packages/prover/.gitignore b/packages/prover/.gitignore new file mode 100644 index 0000000000..51511d1f8f --- /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 0000000000..01678a6152 --- /dev/null +++ b/packages/prover/README.md @@ -0,0 +1,71 @@ +# 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, with each use tied to its exact execution phase and JSX render + site; finite symbol-identified path guards preserve correlated ternary alternatives without + relying on source order, including immutable identifier guards substituted through source + callback factories; +- 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. Source-derived block invariants and lifecycle transition certificates remain future +work. + +## 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 0000000000..385d599142 --- /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 0000000000..d7607e9da6 --- /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 0000000000..c1f209dbf8 --- /dev/null +++ b/packages/prover/research-log.md @@ -0,0 +1,649 @@ +# 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. +- [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. + +### 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. Schema version 14 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. + +Schema version 14 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, mutable callable properties, and +ref-backed indirection remain explicit failed proofs. 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 spread, +computed expression, missing render site, imported component, or cycle leaves the channel +incomplete. 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. + +`useSyncExternalStore` arguments use the same project callback lattice but terminate in three +distinct protocol channels: subscription lifetime, client render snapshot, and server-render +snapshot. Schema version 14 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, JSX spreads, 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 +- Class components, which are discovered but currently force `incomplete` + +Each function unit receives these obligations: + +| Claim | Current evidence | +| ---------------------------- | ------------------------------------------------------------------------------------------- | +| `async-effect-ownership` | Post-`await` and Promise-continuation commits, cleanup invalidation, abort guards | +| `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 | +| `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 | +| `memo-dependencies` | `useMemo` and `useCallback` captures versus inline dependency tuples | +| `reconciliation-identity` | Missing, duplicate, index-derived, and unconstrained dynamic list keys | +| `reducer-purity` | Reducer and reducer-initializer transition purity | +| `ref-access` | Render-phase access to refs created by `useRef` | +| `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-conditional-props` +- `proved-external-store-conditional-factory` +- `proved-external-store-render-branch-props` +- `proved-effect-event` +- `event-handler-boundary` +- `proved-helper-effect-cleanup` +- `proved-shared-event-handler` +- `proved-event-callback-parameter` +- `proved-event-prop-flow` +- `proved-forwarded-event-prop` +- `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` + +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` + +Incomplete: + +- `opaque-render-call` +- `effect-state-update` +- `unsafe-types` +- `path-dependent-cleanup` +- `class-component` +- `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` +- `conditional-helper-effect-cleanup` +- `external-store-helper-boundary` +- `incomplete-external-store-callback-prop-spread` +- `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-event-prop-spread` +- `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-mutable-object-callback` +- 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 JSX spreads, 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 +- Ref-backed callback freshness and assignment across render/commit/event phases +- Async phase/lifetime transforms for timers, promises, schedulers, and subscription registries +- 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 +- Transitions, deferred values, optimistic state, and Actions +- 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 component lifecycle methods +- 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, JSX 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 + 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 + +- 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. diff --git a/packages/prover/scripts/smoke-built-package.mjs b/packages/prover/scripts/smoke-built-package.mjs new file mode 100644 index 0000000000..87cce7bed9 --- /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-async-effect-ownership.ts b/packages/prover/src/analyze-async-effect-ownership.ts new file mode 100644 index 0000000000..4c80d41b47 --- /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 0000000000..ecb4784575 --- /dev/null +++ b/packages/prover/src/analyze-boundary-coverage.ts @@ -0,0 +1,397 @@ +import * as path from "node:path"; +import ts from "typescript"; +import { + REACT_EVENT_PROP_PATTERN, + REACT_RUNTIME_MODULE_NAMES, + REACT_UNMODELED_HOOK_NAMES, +} from "./constants.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 { doesTypeContainCallable } from "./resolve-callable-expression.js"; +import { findSemanticUnit } from "./find-semantic-unit.js"; +import { + ReactExecutionPhase, + ReactObligationStatus, + ReactProofClaim, + ReactUnitKind, +} from "./types.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] !== ".." + ); + }), + ); +}; + +const isCallableRefInvocation = ( + callExpression: ts.CallExpression, + typeChecker: ts.TypeChecker, +): boolean => { + if ( + !ts.isPropertyAccessExpression(callExpression.expression) || + callExpression.expression.name.text !== "current" + ) { + return false; + } + const rootIdentifier = getRootIdentifier(callExpression.expression); + const rootSymbol = rootIdentifier ? typeChecker.getSymbolAtLocation(rootIdentifier) : null; + return Boolean( + rootSymbol?.declarations?.some( + (declaration) => + ts.isVariableDeclaration(declaration) && + declaration.initializer && + ts.isCallExpression(declaration.initializer) && + getCanonicalReactApiName(declaration.initializer.expression, typeChecker) === "useRef", + ), + ); +}; + +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 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.JsxAttribute): boolean => { + const location = getNodeLocation(attribute, context.rootDirectory); + return Boolean( + context.graph && + (context.graph.eventBindings.some( + (eventBinding) => + eventBinding.ownerId === semanticOwnerId && + 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.phase === ReactExecutionPhase.Event && + propFlow.complete && + propFlow.location.filePath === location.filePath && + propFlow.location.line === location.line && + propFlow.location.column === location.column, + )), + ); + }; + 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 executionRoots = new Set([functionNode]); + const collectExecutionRoots = (node: ts.Node): void => { + if (isFunctionBoundary(node)) executionRoots.add(node); + node.forEachChild(collectExecutionRoots); + }; + functionNode.forEachChild(collectExecutionRoots); + const unmodeledCallableUseLocations = new Set(); + for (const executionRoot of executionRoots) { + const reachabilityGraph = collectReachableFunctionGraph(executionRoot, context.typeChecker); + for (const unmodeledUse of reachabilityGraph.unmodeledCallableUses) { + 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 finalCallName = getCanonicalHookName(node, context.typeChecker); + const isModeledContextRead = + finalCallName === "use" && + Boolean( + node.arguments[0] && isReactContextExpression(node.arguments[0], context.typeChecker), + ); + if (finalCallName && REACT_UNMODELED_HOOK_NAMES.has(finalCallName) && !isModeledContextRead) { + unknownEvidence.push( + createEvidence( + node, + context.rootDirectory, + `${finalCallName} does not yet have a complete lifecycle model`, + ["render", finalCallName, "unmodeled React primitive"], + ), + ); + } + 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 (isCallableRefInvocation(node, context.typeChecker)) { + 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 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) { + 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) && + node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && + node.operatorToken.kind <= ts.SyntaxKind.LastAssignment && + ts.isPropertyAccessExpression(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 + ) { + if (!isCompleteEventFlow(node)) { + 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"], + ), + ); + } + } + node.forEachChild(visit); + }; + functionNode.forEachChild(visit); + + 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-component-identity.ts b/packages/prover/src/analyze-component-identity.ts new file mode 100644 index 0000000000..84d97c604e --- /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 0000000000..7ddbbf7cec --- /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 0000000000..be7d5b4df2 --- /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 0000000000..7e00207efc --- /dev/null +++ b/packages/prover/src/analyze-effect-cleanup.ts @@ -0,0 +1,316 @@ +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 { createEvidence } from "./create-evidence.js"; +import { createObligation } from "./create-obligation.js"; +import { getCallName } from "./get-call-name.js"; +import { getEffectCallback } from "./get-effect-callback.js"; +import { isFunctionBoundary } from "./is-function-boundary.js"; +import { ReactObligationStatus, ReactProofClaim } from "./types.js"; +import type { ReactAnalysisContext, ReactProofEvidence, ReactProofObligation } from "./types.js"; + +interface ResourceAcquisition { + node: ts.Node; + description: string; + cleanupNames: ReadonlyArray; + isConditionallyReached: boolean; + ownerFunction: ts.FunctionLikeDeclaration; + eventListener?: EventListenerIdentity; +} + +interface EventListenerIdentity { + targetText: string; + eventText: string; + handler: ts.Expression; + optionsText: string; +} + +const getAssignedName = (expression: ts.Expression): string | null => { + const parentNode = expression.parent; + if ( + ts.isVariableDeclaration(parentNode) && + parentNode.initializer === expression && + ts.isIdentifier(parentNode.name) + ) { + return parentNode.name.text; + } + if ( + ts.isBinaryExpression(parentNode) && + parentNode.right === expression && + parentNode.operatorToken.kind === ts.SyntaxKind.EqualsToken && + ts.isIdentifier(parentNode.left) + ) { + return parentNode.left.text; + } + return null; +}; + +const collectCalls = ( + 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; +}; + +const getEventListenerIdentity = ( + callExpression: ts.CallExpression, +): EventListenerIdentity | null => { + if ( + !ts.isPropertyAccessExpression(callExpression.expression) || + callExpression.expression.name.text !== "addEventListener" + ) { + return null; + } + const eventExpression = callExpression.arguments[0]; + const handlerExpression = callExpression.arguments[1]; + if (!eventExpression || !handlerExpression) return null; + return { + targetText: callExpression.expression.expression.getText(), + eventText: eventExpression.getText(), + handler: handlerExpression, + optionsText: callExpression.arguments[2]?.getText() ?? "", + }; +}; + +const getCanonicalCall = (callExpression: ts.CallExpression): string => { + const callName = getCallName(callExpression) ?? callExpression.expression.getText(); + const argumentsText = callExpression.arguments.map((argument) => argument.getText()).join(","); + return `${callName}(${argumentsText})`; +}; + +const collectResourceAcquisitions = ( + effectCallback: ts.FunctionLikeDeclaration, + typeChecker: ts.TypeChecker, +): ReadonlyArray => { + const acquisitions: ResourceAcquisition[] = []; + for (const reachableFunction of collectReachableFunctions(effectCallback, typeChecker)) { + const visit = (node: ts.Node): void => { + if (node !== reachableFunction.functionNode && isFunctionBoundary(node)) return; + if (ts.isCallExpression(node)) { + const callName = getCallName(node); + const eventListener = getEventListenerIdentity(node); + if (eventListener) { + acquisitions.push({ + node, + description: `${callName ?? "addEventListener"} registration`, + cleanupNames: [ + `${eventListener.targetText}.removeEventListener(${eventListener.eventText}, same handler and options)`, + ], + isConditionallyReached: reachableFunction.isConditionallyReached, + ownerFunction: reachableFunction.functionNode, + eventListener, + }); + } else if ( + callName === "setInterval" || + callName === "setTimeout" || + callName === "requestAnimationFrame" + ) { + const assignedName = getAssignedName(node); + let cleanupFunctionName = "cancelAnimationFrame"; + if (callName === "setInterval") cleanupFunctionName = "clearInterval"; + if (callName === "setTimeout") cleanupFunctionName = "clearTimeout"; + acquisitions.push({ + node, + description: `${callName} registration`, + cleanupNames: assignedName ? [`${cleanupFunctionName}(${assignedName})`] : [], + isConditionallyReached: reachableFunction.isConditionallyReached, + ownerFunction: reachableFunction.functionNode, + }); + } else if ( + ts.isPropertyAccessExpression(node.expression) && + node.expression.name.text === "subscribe" + ) { + const assignedName = getAssignedName(node); + acquisitions.push({ + node, + description: `${callName ?? "subscribe"} subscription`, + cleanupNames: assignedName + ? [`${assignedName}()`, `${assignedName}.unsubscribe()`] + : [], + isConditionallyReached: reachableFunction.isConditionallyReached, + ownerFunction: reachableFunction.functionNode, + }); + } + } + if (ts.isNewExpression(node)) { + const constructorName = node.expression.getText(); + if ( + constructorName === "IntersectionObserver" || + constructorName === "MutationObserver" || + constructorName === "ResizeObserver" || + constructorName === "EventSource" || + constructorName === "WebSocket" + ) { + const assignedName = getAssignedName(node); + const cleanupMethod = constructorName.endsWith("Observer") ? "disconnect" : "close"; + acquisitions.push({ + node, + description: `${constructorName} resource`, + cleanupNames: assignedName ? [`${assignedName}.${cleanupMethod}()`] : [], + isConditionallyReached: reachableFunction.isConditionallyReached, + ownerFunction: reachableFunction.functionNode, + }); + } + } + node.forEachChild(visit); + }; + reachableFunction.functionNode.forEachChild(visit); + } + return acquisitions; +}; + +const isSameExpressionIdentity = ( + leftExpression: ts.Expression, + rightExpression: ts.Expression, + typeChecker: ts.TypeChecker, +): boolean => { + if (leftExpression === rightExpression) return true; + if (!ts.isIdentifier(leftExpression) || !ts.isIdentifier(rightExpression)) return false; + const leftSymbol = typeChecker.getSymbolAtLocation(leftExpression); + const rightSymbol = typeChecker.getSymbolAtLocation(rightExpression); + return Boolean(leftSymbol && leftSymbol === rightSymbol); +}; + +const hasMatchingEventListenerCleanup = ( + eventListener: EventListenerIdentity, + cleanupCalls: ReadonlyArray, + typeChecker: ts.TypeChecker, +): boolean => + cleanupCalls.some((cleanupCall) => { + if ( + !ts.isPropertyAccessExpression(cleanupCall.expression) || + cleanupCall.expression.name.text !== "removeEventListener" || + cleanupCall.expression.expression.getText() !== eventListener.targetText + ) { + return false; + } + const cleanupEvent = cleanupCall.arguments[0]; + const cleanupHandler = cleanupCall.arguments[1]; + if (!cleanupEvent || !cleanupHandler) return false; + return ( + cleanupEvent.getText() === eventListener.eventText && + isSameExpressionIdentity(eventListener.handler, cleanupHandler, typeChecker) && + (cleanupCall.arguments[2]?.getText() ?? "") === eventListener.optionsText + ); + }); + +const hasConditionalAncestor = (node: ts.Node, owner: ts.FunctionLikeDeclaration): boolean => { + let currentNode = node; + while (currentNode !== owner) { + 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) + ) { + return true; + } + currentNode = parentNode; + } + return false; +}; + +export const analyzeEffectCleanup = ( + functionNode: ts.FunctionLikeDeclaration, + context: ReactAnalysisContext, +): ReactProofObligation => { + 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 for lifecycle analysis", + ["effect setup", "opaque callback", "cleanup"], + ), + ); + continue; + } + const cleanupFunctions = collectEffectCleanupFunctions(effectCallback, context.typeChecker); + const cleanupCallExpressions = cleanupFunctions.flatMap((cleanupFunction) => + collectCalls(cleanupFunction, context.typeChecker), + ); + const cleanupCalls = new Set(cleanupCallExpressions.map(getCanonicalCall)); + for (const acquisition of collectResourceAcquisitions(effectCallback, context.typeChecker)) { + if ( + acquisition.isConditionallyReached || + hasConditionalAncestor(acquisition.node, acquisition.ownerFunction) + ) { + unknownEvidence.push( + createEvidence( + acquisition.node, + context.rootDirectory, + `${acquisition.description} is path-dependent`, + ["effect setup branch", acquisition.description, "cleanup branch"], + ), + ); + continue; + } + const hasMatchingCleanup = acquisition.eventListener + ? hasMatchingEventListenerCleanup( + acquisition.eventListener, + cleanupCallExpressions, + context.typeChecker, + ) + : acquisition.cleanupNames.some((cleanupName) => cleanupCalls.has(cleanupName)); + if (!hasMatchingCleanup) { + violations.push( + createEvidence( + acquisition.node, + context.rootDirectory, + `${acquisition.description} has no cleanup with the same resource identity`, + [ + "effect setup", + acquisition.description, + acquisition.cleanupNames.join(" or ") || "unresolvable resource identity", + "effect cleanup", + ], + ), + ); + } + } + } + + if (violations.length > 0) { + return createObligation( + ReactProofClaim.EffectCleanup, + ReactObligationStatus.Violated, + "An effect can retain a resource after cleanup or unmount", + violations, + ); + } + if (unknownEvidence.length > 0) { + return createObligation( + ReactProofClaim.EffectCleanup, + ReactObligationStatus.Unknown, + "Effect resource symmetry could not be proved on every path", + unknownEvidence, + ); + } + return createObligation( + ReactProofClaim.EffectCleanup, + ReactObligationStatus.Proved, + "Every modeled effect resource has symmetric cleanup", + ); +}; diff --git a/packages/prover/src/analyze-effect-dependencies.ts b/packages/prover/src/analyze-effect-dependencies.ts new file mode 100644 index 0000000000..27fec9be2c --- /dev/null +++ b/packages/prover/src/analyze-effect-dependencies.ts @@ -0,0 +1,99 @@ +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 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 = new Set( + dependenciesExpression.elements.map((dependency) => dependency.getText()), + ); + const captures = collectReactiveCaptures( + effectCallback, + functionNode, + context.typeChecker, + stableSymbols, + ); + for (const { key: captureKey, node: captureNode } of captures) { + const isDeclared = [...declaredDependencies].some( + (dependency) => + dependency === captureKey || + captureKey.startsWith(`${dependency}.`) || + dependency.startsWith(`${captureKey}.`), + ); + if (isDeclared) 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 0000000000..1bef6675ef --- /dev/null +++ b/packages/prover/src/analyze-effect-event-usage.ts @@ -0,0 +1,229 @@ +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 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; +}; + +const getContainingFunction = (node: ts.Node): ts.FunctionLikeDeclaration | null => { + let currentNode = node.parent; + while (currentNode) { + if (isFunctionBoundary(currentNode)) return currentNode; + currentNode = currentNode.parent; + } + return null; +}; + +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 = getContainingFunction(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 0000000000..89c60911e4 --- /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-external-store-consistency.ts b/packages/prover/src/analyze-external-store-consistency.ts new file mode 100644 index 0000000000..ce0b7b62fc --- /dev/null +++ b/packages/prover/src/analyze-external-store-consistency.ts @@ -0,0 +1,536 @@ +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 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 ( + unwrappedExpression.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && + unwrappedExpression.operatorToken.kind <= ts.SyntaxKind.LastAssignment + ) { + 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 getContainingFunction = (node: ts.Node): ts.FunctionLikeDeclaration | null => { + let currentNode = node.parent; + while (currentNode) { + if (isFunctionBoundary(currentNode)) return currentNode; + currentNode = currentNode.parent; + } + return null; +}; + +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-hook-order.ts b/packages/prover/src/analyze-hook-order.ts new file mode 100644 index 0000000000..d5db57ef96 --- /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 0000000000..864def2d6a --- /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-memo-dependencies.ts b/packages/prover/src/analyze-memo-dependencies.ts new file mode 100644 index 0000000000..b5f1f0a671 --- /dev/null +++ b/packages/prover/src/analyze-memo-dependencies.ts @@ -0,0 +1,102 @@ +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 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 = new Set( + dependencyExpression.elements.map((dependency) => dependency.getText()), + ); + const captures = collectReactiveCaptures( + callback, + functionNode, + context.typeChecker, + stableSymbols, + ); + for (const capture of captures) { + const isDeclared = [...declaredDependencies].some( + (dependency) => + dependency === capture.key || + capture.key.startsWith(`${dependency}.`) || + dependency.startsWith(`${capture.key}.`), + ); + if (isDeclared) 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-react-unit.ts b/packages/prover/src/analyze-react-unit.ts new file mode 100644 index 0000000000..a0c5554eed --- /dev/null +++ b/packages/prover/src/analyze-react-unit.ts @@ -0,0 +1,126 @@ +import { analyzeBoundaryCoverage } from "./analyze-boundary-coverage.js"; +import { analyzeAsyncEffectOwnership } from "./analyze-async-effect-ownership.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 { analyzeExternalStoreConsistency } from "./analyze-external-store-consistency.js"; +import { analyzeHookOrder } from "./analyze-hook-order.js"; +import { analyzeHookOwnership } from "./analyze-hook-ownership.js"; +import { analyzeMemoDependencies } from "./analyze-memo-dependencies.js"; +import { analyzeRefAccess } from "./analyze-ref-access.js"; +import { analyzeReducerPurity } from "./analyze-reducer-purity.js"; +import { analyzeReconciliationIdentity } from "./analyze-reconciliation-identity.js"; +import { analyzeRenderPurity } from "./analyze-render-purity.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.AsyncEffectOwnership, + ReactProofClaim.BoundaryCoverage, + ReactProofClaim.ComponentIdentity, + ReactProofClaim.ComponentInvocation, + ReactProofClaim.ContextTopology, + ReactProofClaim.EffectCleanup, + ReactProofClaim.EffectDependencies, + ReactProofClaim.EffectEventUsage, + ReactProofClaim.EffectStateUpdates, + ReactProofClaim.ExternalStoreConsistency, + ReactProofClaim.HookOrder, + ReactProofClaim.HookOwnership, + ReactProofClaim.MemoDependencies, + ReactProofClaim.ReconciliationIdentity, + ReactProofClaim.ReducerPurity, + ReactProofClaim.RefAccess, + ReactProofClaim.RenderPurity, +]; + +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.kind === ReactUnitKind.ClassComponent || !unit.functionNode) { + const evidence = [ + createEvidence( + unit.node, + context.rootDirectory, + "Class component lifecycle semantics are not modeled yet", + ["class component", "React lifecycle", "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, + "Class component proof is incomplete", + evidence, + ), + ), + }; + } + + return { + name: unit.name, + kind: unit.kind, + location: getNodeLocation(unit.node, context.rootDirectory), + obligations: [ + analyzeAsyncEffectOwnership(unit.functionNode, context), + analyzeBoundaryCoverage(unit, context), + analyzeComponentIdentity(unit.functionNode, context), + analyzeComponentInvocation(unit.functionNode, context), + analyzeContextTopology(unit, context), + analyzeEffectCleanup(unit.functionNode, context), + analyzeEffectDependencies(unit.functionNode, context), + analyzeEffectEventUsage(unit.functionNode, context), + analyzeEffectStateUpdates(unit, context), + analyzeExternalStoreConsistency(unit, context), + analyzeHookOrder(unit.functionNode, context), + analyzeHookOwnership(unit.functionNode), + analyzeMemoDependencies(unit.functionNode, context), + analyzeReconciliationIdentity(unit.functionNode, context), + analyzeReducerPurity(unit.functionNode, context), + analyzeRefAccess(unit.functionNode, context), + analyzeRenderPurity(unit.functionNode, context), + ], + }; +}; diff --git a/packages/prover/src/analyze-reconciliation-identity.ts b/packages/prover/src/analyze-reconciliation-identity.ts new file mode 100644 index 0000000000..4a8f363e53 --- /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 0000000000..6491f5d324 --- /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-ref-access.ts b/packages/prover/src/analyze-ref-access.ts new file mode 100644 index 0000000000..99e986e478 --- /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 0000000000..0b74c4574c --- /dev/null +++ b/packages/prover/src/analyze-render-purity.ts @@ -0,0 +1,286 @@ +import ts from "typescript"; +import { + KNOWN_IMPURE_RENDER_CALLS, + KNOWN_PURE_GLOBAL_CALLS, + KNOWN_PURE_METHOD_NAMES, + MUTATING_METHOD_NAMES, + REACT_MODELED_HOOK_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 { 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 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 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 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) && + node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && + node.operatorToken.kind <= ts.SyntaxKind.LastAssignment + ) { + 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 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) + ) { + 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 ( + (callName && KNOWN_PURE_GLOBAL_CALLS.has(callName)) || + (ts.isPropertyAccessExpression(node.expression) && + KNOWN_PURE_METHOD_NAMES.has(node.expression.name.text)) + ) { + 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/build-react-semantic-graph.ts b/packages/prover/src/build-react-semantic-graph.ts new file mode 100644 index 0000000000..ddd9967416 --- /dev/null +++ b/packages/prover/src/build-react-semantic-graph.ts @@ -0,0 +1,1500 @@ +import ts from "typescript"; +import { collectAsyncEffectTaskDescriptors } from "./collect-async-effect-task-descriptors.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 { collectDirectHookCalls } from "./collect-direct-hook-calls.js"; +import { collectEffectEventBindings } from "./collect-effect-event-bindings.js"; +import { collectEffectCleanupFunctions } from "./collect-effect-cleanup-functions.js"; +import { collectEffectCalls } from "./collect-effect-calls.js"; +import { collectHookBindings } from "./collect-hook-bindings.js"; +import { collectHookCalls } from "./collect-hook-calls.js"; +import { collectReactiveCaptures } from "./collect-reactive-captures.js"; +import { collectReachableFunctionGraph } from "./collect-reachable-functions.js"; +import { + REACT_EXTERNAL_STORE_HOOK_NAMES, + REACT_MEMO_HOOK_NAMES, + REACT_REDUCER_HOOK_NAMES, + REACT_CONTEXT_DEFAULT_SOURCE_ID, + REACT_SEMANTIC_GRAPH_SCHEMA_VERSION, +} from "./constants.js"; +import { getCanonicalReactApiName } from "./get-canonical-react-api-name.js"; +import { getCanonicalHookName } from "./get-canonical-hook-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 { isReactContextExpression } from "./is-react-context-expression.js"; +import { resolveFunction } from "./resolve-function.js"; +import { mergeCallableBindings } from "./resolve-callable-expression.js"; +import { + ReactEffectDependencyMode, + ReactExecutionPhase, + ReactIdentityStability, + ReactSemanticCallbackKind, + ReactSemanticEdgeKind, + ReactUnitKind, +} from "./types.js"; +import type { + ReactAnalysisContext, + ReactSemanticCallback, + ReactSemanticAsyncTask, + ReactSemanticContext, + ReactSemanticContextConsumer, + ReactSemanticContextProvider, + ReactSemanticEdge, + ReactSemanticEffect, + ReactSemanticEffectEvent, + ReactSemanticEventBinding, + ReactSemanticCallbackPropAlternative, + ReactSemanticCallbackPropFlow, + ReactSemanticExternalStore, + ReactSemanticFunctionCall, + ReactSemanticGraph, + ReactSemanticHookCall, + ReactSemanticReachableFunction, + ReactSemanticRender, + ReactSemanticUnit, + ReactUnitDescriptor, +} from "./types.js"; +import type { ResolvedCallableValueDescriptor } from "./resolve-callable-expression.js"; + +interface UnitGraphIdentity { + descriptor: ReactUnitDescriptor; + semanticUnit: ReactSemanticUnit; +} + +interface EffectGraphFacts { + effects: 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 CallbackPropReachabilityDescriptor { + callbackDescriptor: ComponentCallbackDescriptor; + callbackFact: ReactSemanticCallback; + identity: UnitGraphIdentity; +} + +interface ReachabilityGraphFacts { + reachableFunctions: ReadonlyArray; + functionCalls: ReadonlyArray; +} + +interface ReducerCallbackDescriptor { + argumentIndex: number; + kind: ReactSemanticCallbackKind; + name: string; +} + +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 RenderGraphFacts { + edges: ReadonlyArray; + renders: ReadonlyArray; +} + +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}`; +}; + +const getDeclarationNameNode = (descriptor: ReactUnitDescriptor): ts.Node | null => { + 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; + return functionNode; +}; + +const resolveAliasedSymbol = (symbol: ts.Symbol, typeChecker: ts.TypeChecker): ts.Symbol => + symbol.flags & ts.SymbolFlags.Alias ? typeChecker.getAliasedSymbol(symbol) : symbol; + +const getExpressionSymbol = ( + expression: ts.Expression | ts.JsxTagNameExpression, + typeChecker: ts.TypeChecker, +): ts.Symbol | null => { + const symbol = typeChecker.getSymbolAtLocation(expression); + return symbol ? resolveAliasedSymbol(symbol, typeChecker) : null; +}; + +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, +): ReadonlyArray => { + const providerIds: string[] = []; + let currentNode: ts.Node | undefined = node.parent; + while (currentNode) { + if (ts.isJsxElement(currentNode)) { + const provider = providersByOpeningNode.get(currentNode.openingElement); + if (provider) providerIds.unshift(provider.id); + } + currentNode = currentNode.parent; + } + return providerIds; +}; + +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 collectRenderEdges = ( + identity: UnitGraphIdentity, + unitIdsBySymbol: ReadonlyMap, + providersByOpeningNode: ReadonlyMap, + context: ReactAnalysisContext, +): RenderGraphFacts => { + const functionNode = identity.descriptor.functionNode; + if (!functionNode) return { edges: [], renders: [] }; + const edges: ReactSemanticEdge[] = []; + const renders: ReactSemanticRender[] = []; + const visit = (node: ts.Node): void => { + if (node !== functionNode && isFunctionBoundary(node)) { + return; + } + const tagName = ts.isJsxOpeningElement(node) + ? node.tagName + : ts.isJsxSelfClosingElement(node) + ? node.tagName + : null; + if (tagName && ts.isIdentifier(tagName) && /^[A-Z]/.test(tagName.text)) { + const targetId = resolveUnitTarget(tagName, unitIdsBySymbol, context.typeChecker); + if (targetId) { + const location = getNodeLocation(tagName, context.rootDirectory); + edges.push({ + kind: ReactSemanticEdgeKind.RendersComponent, + sourceId: identity.semanticUnit.id, + targetId, + location, + }); + renders.push({ + id: createSemanticId("render", targetId, tagName, context), + ownerId: identity.semanticUnit.id, + targetId, + location, + activeContextProviderIds: collectActiveContextProviderIds( + tagName, + providersByOpeningNode, + ), + }); + } + } + node.forEachChild(visit); + }; + functionNode.forEachChild(visit); + return { edges, renders }; +}; + +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 getEffectDependencyFacts = ( + effectCall: ts.CallExpression, +): { mode: ReactEffectDependencyMode; dependencies: ReadonlyArray } => { + const dependencyExpression = effectCall.arguments[1]; + 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, + context: ReactAnalysisContext, + componentFlow: ComponentCallbackFlowDescriptor, +): EffectGraphFacts => { + const functionNode = identity.descriptor.functionNode; + if (!functionNode || identity.descriptor.kind === ReactUnitKind.InvalidHookOwner) { + return { effects: [], callbacks: [], reachableFunctions: [], functionCalls: [] }; + } + const hookBindings = collectHookBindings(functionNode, context.typeChecker); + const stableSymbols = new Set([ + ...hookBindings.effectEvents, + ...hookBindings.refs, + ...hookBindings.stateSetters, + ]); + const effects: ReactSemanticEffect[] = []; + const callbacks: ReactSemanticCallback[] = []; + const reachableFunctions: ReactSemanticReachableFunction[] = []; + const functionCalls: ReactSemanticFunctionCall[] = []; + for (const effectCall of collectEffectCalls(functionNode, context.typeChecker)) { + const hookName = getCanonicalHookName(effectCall, context.typeChecker) ?? "unknown-effect"; + const effectCallback = getEffectCallback(effectCall, context.typeChecker); + const dependencyFacts = getEffectDependencyFacts(effectCall); + 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); + } + } + effects.push({ + id: createSemanticId("effect", hookName, effectCall, context), + ownerId: identity.semanticUnit.id, + 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), + }); + } + return { effects, 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 collectReducerCallbacks = ( + identity: UnitGraphIdentity, + context: ReactAnalysisContext, +): CallbackGraphFacts => { + const functionNode = identity.descriptor.functionNode; + if (!functionNode) return { callbacks: [], reachableFunctions: [], functionCalls: [] }; + const callbacks: ReactSemanticCallback[] = []; + const reachableFunctions: ReactSemanticReachableFunction[] = []; + const functionCalls: ReactSemanticFunctionCall[] = []; + const callbackDescriptors: ReadonlyArray = [ + { + argumentIndex: 0, + kind: ReactSemanticCallbackKind.Reducer, + name: "reducer", + }, + { + argumentIndex: 2, + kind: ReactSemanticCallbackKind.ReducerInitializer, + name: "reducer-initializer", + }, + ]; + for (const hookCall of collectHookCalls( + functionNode, + REACT_REDUCER_HOOK_NAMES, + context.typeChecker, + )) { + for (const descriptor of callbackDescriptors) { + const callbackExpression = hookCall.arguments[descriptor.argumentIndex]; + const callback = callbackExpression + ? resolveFunction(callbackExpression, context.typeChecker) + : null; + if (!callback) continue; + const callbackFact = createCallbackFact( + identity, + callback, + functionNode, + new Set(), + descriptor.kind, + ReactExecutionPhase.StateTransition, + descriptor.name, + context, + ); + callbacks.push(callbackFact); + const reachabilityFacts = collectReachabilityGraphFacts( + identity, + callback, + callbackFact, + context, + ); + reachableFunctions.push(...reachabilityFacts.reachableFunctions); + functionCalls.push(...reachabilityFacts.functionCalls); + } + } + return { 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 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, + 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 incomingUnitIds = new Set([ + ...renders.map((render) => render.targetId), + ...customHookEdges.map((edge) => edge.targetId), + ]); + const rootUnitIds = units.map((unit) => unit.id).filter((unitId) => !incomingUnitIds.has(unitId)); + const providersById = new Map(providers.map((provider) => [provider.id, provider])); + 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) { + 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; + } + } + } + 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, + ), + usesDefaultValue: sourceIds.includes(REACT_CONTEXT_DEFAULT_SOURCE_ID), + topologyComplete: sourceIds.length > 0, + }; + }); +}; + +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, + location: getNodeLocation(descriptor.node, context.rootDirectory), + }, + }), + ); + 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 contextGraph = collectContextGraph(identities, sourceFiles, context); + const edges: ReactSemanticEdge[] = []; + const renders: ReactSemanticRender[] = []; + const hookCalls: ReactSemanticHookCall[] = []; + const effects: ReactSemanticEffect[] = []; + const effectEvents: ReactSemanticEffectEvent[] = []; + const externalStores: ReactSemanticExternalStore[] = []; + const asyncTasks: ReactSemanticAsyncTask[] = []; + const callbacks: ReactSemanticCallback[] = []; + const reachableFunctions: ReactSemanticReachableFunction[] = []; + const functionCalls: ReactSemanticFunctionCall[] = []; + const componentFlow = createComponentCallbackFlow( + [...unitIdentitiesByFunction.keys()], + new Map( + [...unitIdentitiesBySymbol].flatMap( + ([symbol, identity]): ReadonlyArray<[ts.Symbol, ts.FunctionLikeDeclaration]> => + identity.descriptor.functionNode ? [[symbol, identity.descriptor.functionNode]] : [], + ), + ), + context.typeChecker, + ); + const eventGraph = collectEventGraph(identities, context, componentFlow); + callbacks.push(...eventGraph.callbacks); + reachableFunctions.push(...eventGraph.reachableFunctions); + functionCalls.push(...eventGraph.functionCalls); + for (const identity of identities) { + const functionNode = identity.descriptor.functionNode; + if ( + functionNode && + (identity.descriptor.kind === ReactUnitKind.Component || + identity.descriptor.kind === 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 hookGraph = collectHookGraph(identity, unitIdsBySymbol, context); + edges.push(...hookGraph.edges); + hookCalls.push(...hookGraph.hookCalls); + const renderGraph = collectRenderEdges( + identity, + unitIdsBySymbol, + contextGraph.providersByOpeningNode, + context, + ); + edges.push(...renderGraph.edges); + renders.push(...renderGraph.renders); + const effectGraph = collectEffectGraph(identity, context, componentFlow); + effects.push(...effectGraph.effects); + 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 = collectReducerCallbacks(identity, context); + 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 callbackPropGraph = collectCallbackPropGraph(identities, context, componentFlow, callbacks); + callbacks.push(...callbackPropGraph.callbacks); + reachableFunctions.push(...callbackPropGraph.reachableFunctions); + functionCalls.push(...callbackPropGraph.functionCalls); + const contextConsumers = resolveContextConsumers( + identities.map((identity) => identity.semanticUnit), + edges, + renders, + contextGraph.contexts, + contextGraph.contextProviders, + contextGraph.contextConsumers, + ); + return { + schemaVersion: REACT_SEMANTIC_GRAPH_SCHEMA_VERSION, + units: identities.map((identity) => identity.semanticUnit), + edges, + hookCalls, + effects, + effectEvents, + externalStores, + asyncTasks, + contexts: contextGraph.contexts, + contextProviders: contextGraph.contextProviders, + contextConsumers, + renders, + callbacks, + reachableFunctions, + functionCalls, + eventBindings: eventGraph.eventBindings, + callbackPropFlows: callbackPropGraph.callbackPropFlows, + 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 0000000000..b742f164c5 --- /dev/null +++ b/packages/prover/src/check-react-proof-report.ts @@ -0,0 +1,645 @@ +import { REACT_PROOF_SCHEMA_VERSION, REACT_SEMANTIC_GRAPH_SCHEMA_VERSION } from "./constants.js"; +import { + ReactAppProofStatus, + ReactAsyncOwnershipStatus, + ReactExecutionPhase, + ReactObligationStatus, + ReactProofCertificateStatus, + ReactProofClaim, + ReactSemanticCallbackKind, + ReactSemanticEdgeKind, + ReactSemanticFunctionCallKind, + ReactUnitKind, +} from "./types.js"; +import type { + ReactAppProofReport, + ReactProofCertificateCheck, + ReactProofCertificateFailure, + ReactSemanticUnit, +} from "./types.js"; + +const addFailure = ( + failures: ReactProofCertificateFailure[], + subjectId: string, + description: string, +): void => { + failures.push({ description, subjectId }); +}; + +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.kind === ReactUnitKind.ClassComponent || 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 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 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 checkGraphReferences = ( + report: ReactAppProofReport, + failures: ReactProofCertificateFailure[], +): void => { + const unitIds = new Set(report.graph.units.map((unit) => unit.id)); + const effectIds = new Set(report.graph.effects.map((effect) => effect.id)); + const callbackIds = new Set(report.graph.callbacks.map((callback) => callback.id)); + const callbacksById = new Map(report.graph.callbacks.map((callback) => [callback.id, callback])); + 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)); + + 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"); + } + } + 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 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 ( + 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 hasResolvedSource = consumer.sourceProviderIds.length > 0 || consumer.usesDefaultValue; + if (consumer.topologyComplete !== Boolean(consumer.contextId && hasResolvedSource)) { + addFailure( + failures, + consumer.id, + "A context consumer has an inconsistent 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, + "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, + "callback prop flows", + report.graph.callbackPropFlows.map((propFlow) => propFlow.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-async-effect-task-descriptors.ts b/packages/prover/src/collect-async-effect-task-descriptors.ts new file mode 100644 index 0000000000..8edaadb83e --- /dev/null +++ b/packages/prover/src/collect-async-effect-task-descriptors.ts @@ -0,0 +1,502 @@ +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 { getEffectCallback } from "./get-effect-callback.js"; +import { isFunctionBoundary } from "./is-function-boundary.js"; +import { resolveFunction } from "./resolve-function.js"; +import { summarizeFunctionReturns } from "./summarize-function-returns.js"; +import { ReactAsyncOwnershipStatus } from "./types.js"; +import { unwrapTypescriptExpression } from "./unwrap-typescript-expression.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 PROMISE_CONTINUATION_METHODS = new Set(["catch", "finally", "then"]); + +const containsAwait = (node: ts.Node, owner: ts.FunctionLikeDeclaration): boolean => { + let didFindAwait = false; + const visit = (currentNode: ts.Node): void => { + if (didFindAwait || (currentNode !== owner && isFunctionBoundary(currentNode))) return; + if (ts.isAwaitExpression(currentNode)) { + didFindAwait = true; + return; + } + currentNode.forEachChild(visit); + }; + node.forEachChild(visit); + return didFindAwait; +}; + +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 (containsAwait(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) => containsAwait(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 hasGuaranteedCleanupReturn = ( + 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)), + ) + ); +}; + +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 (!hasGuaranteedCleanupReturn(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 && containsAwait(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_METHODS.has(node.expression.name.text) + ) + ) { + unknownOperation ??= node; + } + } + if ( + !unknownOperation && + ts.isBinaryExpression(node) && + node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && + node.operatorToken.kind <= ts.SyntaxKind.LastAssignment && + (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_METHODS.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 0000000000..f07aeb216b --- /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-target-functions.ts b/packages/prover/src/collect-callable-target-functions.ts new file mode 100644 index 0000000000..ff12fcf826 --- /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 0000000000..83ea8635fe --- /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-direct-hook-calls.ts b/packages/prover/src/collect-direct-hook-calls.ts new file mode 100644 index 0000000000..d9d7365504 --- /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 0000000000..a9196b6049 --- /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 0000000000..1add2c3d0f --- /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 0000000000..c83eb6f359 --- /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-event-callback-functions.ts b/packages/prover/src/collect-event-callback-functions.ts new file mode 100644 index 0000000000..b2da7e82af --- /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 0000000000..0e17424742 --- /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-hook-bindings.ts b/packages/prover/src/collect-hook-bindings.ts new file mode 100644 index 0000000000..ce8f140003 --- /dev/null +++ b/packages/prover/src/collect-hook-bindings.ts @@ -0,0 +1,70 @@ +import ts from "typescript"; +import { collectEffectEventBindings } from "./collect-effect-event-bindings.js"; +import { getCanonicalHookName } from "./get-canonical-hook-name.js"; +import { isFunctionBoundary } from "./is-function-boundary.js"; + +export interface HookBindings { + effectEvents: ReadonlySet; + refs: ReadonlySet; + stateSetters: ReadonlySet; + stateValueBySetter: ReadonlyMap; + stateValues: 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 refs = new Set(); + const stateSetters = new Set(); + const stateValueBySetter = new Map(); + const stateValues = 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 = getCanonicalHookName(node.initializer, 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); + } + } + node.forEachChild(visit); + }; + functionNode.forEachChild(visit); + return { effectEvents, refs, stateSetters, stateValueBySetter, stateValues }; +}; diff --git a/packages/prover/src/collect-hook-calls.ts b/packages/prover/src/collect-hook-calls.ts new file mode 100644 index 0000000000..65470830b2 --- /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-project-soundness-evidence.ts b/packages/prover/src/collect-project-soundness-evidence.ts new file mode 100644 index 0000000000..d68583ebb4 --- /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 0000000000..d01d0be84c --- /dev/null +++ b/packages/prover/src/collect-reachable-functions.ts @@ -0,0 +1,457 @@ +import ts from "typescript"; +import { collectCallableTargetFunctions } from "./collect-callable-target-functions.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 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 isConditionallyExecuted = ( + 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; +}; + +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 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); + 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 || isConditionallyExecuted(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 0000000000..2f6046bb6d --- /dev/null +++ b/packages/prover/src/collect-react-units.ts @@ -0,0 +1,94 @@ +import ts from "typescript"; +import * as path from "node:path"; +import { collectDirectHookCalls } from "./collect-direct-hook-calls.js"; +import { getFunctionName } from "./get-function-name.js"; +import { isReactHookName } from "./is-react-hook-name.js"; +import { isFunctionBoundary } from "./is-function-boundary.js"; +import { ReactUnitKind } from "./types.js"; +import type { ReactUnitDescriptor } from "./types.js"; + +const isReactComponentName = (name: string): boolean => /^[A-Z]/.test(name); + +const isReactComponentClass = (classNode: ts.ClassDeclaration): boolean => + Boolean( + classNode.heritageClauses?.some((heritageClause) => + heritageClause.types.some((heritageType) => { + const heritageName = heritageType.expression.getText(); + return heritageName === "Component" || heritageName.endsWith(".Component"); + }), + ), + ); + +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, + } + : null; + } + if (isReactHookName(functionName)) { + return { + name: functionName, + kind: ReactUnitKind.Hook, + node: functionNode, + functionNode, + }; + } + if (isReactComponentName(functionName)) { + return { + name: functionName, + kind: ReactUnitKind.Component, + node: functionNode, + functionNode, + }; + } + if (directHookCalls.length === 0) return null; + return { + name: functionName, + kind: ReactUnitKind.InvalidHookOwner, + node: functionNode, + functionNode, + invalidHookCalls: directHookCalls, + }; +}; + +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, + }); + } + const visit = (node: ts.Node): void => { + if (isFunctionBoundary(node)) { + const functionUnit = collectFunctionUnit(node, typeChecker); + if (functionUnit) units.push(functionUnit); + } else if (ts.isClassDeclaration(node) && isReactComponentClass(node)) { + units.push({ + name: node.name?.text ?? "DefaultComponent", + kind: ReactUnitKind.ClassComponent, + node, + }); + } + 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 0000000000..cfd48bc53a --- /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/constants.ts b/packages/prover/src/constants.ts new file mode 100644 index 0000000000..b475304eae --- /dev/null +++ b/packages/prover/src/constants.ts @@ -0,0 +1,133 @@ +export const REACT_PROOF_SCHEMA_VERSION = 8; +export const REACT_SEMANTIC_GRAPH_SCHEMA_VERSION = 14; +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 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_EFFECT_HOOK_NAMES = new Set([ + "useEffect", + "useInsertionEffect", + "useLayoutEffect", +]); + +export const REACT_MEMO_HOOK_NAMES = new Set(["useCallback", "useMemo"]); +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 REACT_MODELED_HOOK_NAMES = new Set([ + "useCallback", + "useContext", + "useEffect", + "useEffectEvent", + "useId", + "useInsertionEffect", + "useLayoutEffect", + "useMemo", + "useRef", + "useReducer", + "useState", + "useSyncExternalStore", +]); + +export const REACT_UNMODELED_HOOK_NAMES = new Set([ + "use", + "useActionState", + "useDeferredValue", + "useImperativeHandle", + "useOptimistic", + "useTransition", +]); + +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 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 REACT_RUNTIME_MODULE_NAMES = new Set([ + "react", + "react-dom", + "react-dom/client", + "react/jsx-dev-runtime", + "react/jsx-runtime", +]); + +export const REACT_EVENT_PROP_PATTERN = /^on[A-Z]/; diff --git a/packages/prover/src/contains-jsx.ts b/packages/prover/src/contains-jsx.ts new file mode 100644 index 0000000000..605bee6733 --- /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 0000000000..2661d3e7b1 --- /dev/null +++ b/packages/prover/src/create-component-callback-flow.ts @@ -0,0 +1,502 @@ +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 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.JsxAttribute; + ownerFunction: ts.FunctionLikeDeclaration; +} + +export interface ComponentCallbackPropFlowDescriptor { + callbacks: ReadonlyArray; + isComplete: boolean; + node: ts.JsxAttribute; + 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; +} + +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.JsxAttribute; + renderNode: ts.JsxOpeningLikeElement; + renderOwnerFunction: ts.FunctionLikeDeclaration; + sourceChannel: ComponentPropChannel | null; + targetChannel: ComponentPropChannel; + targetFunction: ts.FunctionLikeDeclaration; +} + +interface CallbackSource { + callbacks: ReadonlyArray; + channel: ComponentPropChannel | null; + isComplete: boolean; +} + +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 getTargetFunction = ( + 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; +}; + +const isIntrinsicElement = (openingElement: ts.JsxOpeningLikeElement): boolean => + ts.isIdentifier(openingElement.tagName) && /^[a-z]/.test(openingElement.tagName.text); + +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, + }; +}; + +export const createComponentCallbackFlow = ( + componentFunctions: ReadonlyArray, + unitFunctionsBySymbol: ReadonlyMap, + typeChecker: ts.TypeChecker, +): ComponentCallbackFlowDescriptor => { + const propBindingsByChannel = new Map(); + const eventSources: Array<{ + eventName: string; + node: ts.JsxAttribute; + ownerFunction: ts.FunctionLikeDeclaration; + source: CallbackSource; + }> = []; + 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 = getTargetFunction( + openingElement, + unitFunctionsBySymbol, + typeChecker, + ); + for (const attribute of openingElement.attributes.properties) { + if (!ts.isJsxAttribute(attribute)) continue; + const expression = getJsxAttributeExpression(attribute); + if (!expression) continue; + const source = createExpressionCallbackSource(expression, ownerFunction, typeChecker); + const propName = attribute.name.getText(); + if (isIntrinsicElement(openingElement) && REACT_EVENT_PROP_PATTERN.test(propName)) { + eventSources.push({ + eventName: propName, + node: attribute, + ownerFunction, + source, + }); + } + if (targetFunction) { + const targetChannel = { functionNode: targetFunction, propName }; + const channelIdentity = getChannelIdentity(targetChannel); + const bindings = propBindingsByChannel.get(channelIdentity) ?? []; + bindings.push({ + callbacks: source.callbacks, + isComplete: source.isComplete, + node: attribute, + renderNode: openingElement, + renderOwnerFunction: ownerFunction, + sourceChannel: source.channel, + targetChannel, + targetFunction, + }); + 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, + ), + }; +}; diff --git a/packages/prover/src/create-evidence.ts b/packages/prover/src/create-evidence.ts new file mode 100644 index 0000000000..5fd41c9957 --- /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 0000000000..bbed0cee4a --- /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 0000000000..4add6b4e2a --- /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 0000000000..dd2a8eb063 --- /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 0000000000..74e63654fe --- /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 0000000000..ad0d2065f7 --- /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 0000000000..6be151d795 --- /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 0000000000..d81df6a98e --- /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 0000000000..f0872558ef --- /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 0000000000..3a4d1529e3 --- /dev/null +++ b/packages/prover/src/get-component-prop-name.ts @@ -0,0 +1,59 @@ +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.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; + return functionNode.parameters.some( + (parameter) => + ts.isIdentifier(parameter.name) && + typeChecker.getSymbolAtLocation(parameter.name) === expressionSymbol, + ) + ? expression.name.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)) { + 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 0000000000..d11155cb8a --- /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 0000000000..b451886760 --- /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 0000000000..af00ca287a --- /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 0000000000..36466e4fef --- /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 0000000000..c098dc03a9 --- /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 0000000000..1591e526a5 --- /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 0000000000..ca699cf95d --- /dev/null +++ b/packages/prover/src/index.ts @@ -0,0 +1,53 @@ +export { proveReactApp } from "./prove-react-app.js"; +export { checkReactProofReport } from "./check-react-proof-report.js"; +export { + ReactAppProofStatus, + ReactAsyncOwnershipStatus, + ReactCompilerFactStatus, + ReactEffectDependencyMode, + ReactExecutionPhase, + ReactIdentityStability, + ReactObligationStatus, + ReactProofCertificateStatus, + ReactProofClaim, + ReactSemanticEdgeKind, + ReactSemanticCallbackKind, + ReactSemanticFunctionCallKind, + ReactUnitKind, +} from "./types.js"; +export type { + ProveReactAppInput, + ReactAppProofReport, + ReactCompilerBlockFact, + ReactCompilerFailure, + ReactCompilerFunctionFact, + ReactCompilerGraph, + ReactCompilerInstructionFact, + ReactProofEvidence, + ReactProofCertificateCheck, + ReactProofCertificateFailure, + ReactProofLocation, + ReactProofObligation, + ReactProofSummary, + ReactSemanticEdge, + ReactSemanticContext, + ReactSemanticContextConsumer, + ReactSemanticContextProvider, + ReactSemanticEffect, + ReactSemanticEffectEvent, + ReactSemanticEventBinding, + ReactSemanticCallbackGuard, + ReactSemanticCallbackPropAlternative, + ReactSemanticCallbackPropFlow, + ReactSemanticExternalStore, + ReactSemanticCallback, + ReactSemanticAsyncTask, + ReactSemanticGraph, + ReactSemanticFunctionCall, + ReactSemanticHookCall, + ReactSemanticReachableFunction, + ReactSemanticRender, + ReactSemanticUnit, + 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 0000000000..e06c1cb680 --- /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 0000000000..e1b223e123 --- /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 0000000000..4edd39b70e --- /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 0000000000..d71e456cc2 --- /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 0000000000..cf387253eb --- /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 0000000000..e4cc3c873c --- /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 0000000000..eaf6ae9ead --- /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 0000000000..42cf21d0fd --- /dev/null +++ b/packages/prover/src/prove-react-app.ts @@ -0,0 +1,74 @@ +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, + units: [], + edges: [], + hookCalls: [], + effects: [], + effectEvents: [], + externalStores: [], + asyncTasks: [], + contexts: [], + contextProviders: [], + contextConsumers: [], + renders: [], + callbacks: [], + reachableFunctions: [], + functionCalls: [], + eventBindings: [], + callbackPropFlows: [], + 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 0000000000..b92ef6dee9 --- /dev/null +++ b/packages/prover/src/prove-react-program.ts @@ -0,0 +1,164 @@ +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 projectEvidence = [ + ...initialEvidence, + ...collectProjectSoundnessEvidence(program, sourceFiles, rootDirectory), + ...diagnosticEvidence, + ...compilerEvidence, + ]; + 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 0000000000..ea59be2a8c --- /dev/null +++ b/packages/prover/src/resolve-callable-expression.ts @@ -0,0 +1,760 @@ +import ts from "typescript"; +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)) { + properties.set( + property.name.text, + resolveCallableExpressionWithState(property.name, typeChecker, bindings, state), + ); + 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 => { + if (getCanonicalReactApiName(callExpression.expression, typeChecker) === "useCallback") { + const callbackExpression = callExpression.arguments[0]; + return callbackExpression + ? resolveCallableExpressionWithState(callbackExpression, typeChecker, bindings, state) + : createEmptyCallableValue(false); + } + 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 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 0000000000..2181ef2403 --- /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 0000000000..88d55d96b7 --- /dev/null +++ b/packages/prover/src/summarize-function-returns.ts @@ -0,0 +1,306 @@ +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; + 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, expressions: [], isComplete: false }; + } + if (!ts.isBlock(functionNode.body)) { + return { + canFallThrough: false, + expressions: [{ expression: functionNode.body, isConditionallyReached: false }], + isComplete: true, + }; + } + const summary = summarizeStatements(functionNode.body.statements, false, typeChecker); + return { + canFallThrough: summary.doesAnyPathFallThrough, + 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 0000000000..f05c8d01f0 --- /dev/null +++ b/packages/prover/src/types.ts @@ -0,0 +1,421 @@ +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 { + BoundaryCoverage = "boundary-coverage", + ComponentIdentity = "component-identity", + ComponentInvocation = "component-invocation", + ContextTopology = "context-topology", + AsyncEffectOwnership = "async-effect-ownership", + EffectCleanup = "effect-cleanup", + EffectDependencies = "effect-dependencies", + EffectEventUsage = "effect-event-usage", + EffectStateUpdates = "effect-state-updates", + ExternalStoreConsistency = "external-store-consistency", + HookOrder = "hook-order", + HookOwnership = "hook-ownership", + MemoDependencies = "memo-dependencies", + ReconciliationIdentity = "reconciliation-identity", + ReducerPurity = "reducer-purity", + RefAccess = "ref-access", + RenderPurity = "render-purity", +} + +export enum ReactUnitKind { + ClassComponent = "class-component", + Component = "component", + Hook = "hook", + InvalidHookOwner = "invalid-hook-owner", +} + +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 { + Deferred = "deferred", + EffectCleanup = "effect-cleanup", + EffectEvent = "effect-event", + EffectSetup = "effect-setup", + Event = "event", + ExternalStoreSubscription = "external-store-subscription", + Render = "render", + ServerRender = "server-render", + StateTransition = "state-transition", +} + +export enum ReactSemanticCallbackKind { + ComponentRender = "component-render", + EffectCleanup = "effect-cleanup", + EffectEvent = "effect-event", + EffectSetup = "effect-setup", + EventHandler = "event-handler", + ExternalStoreSnapshot = "external-store-snapshot", + ExternalStoreSubscribe = "external-store-subscribe", + MemoFactory = "memo-factory", + MemoizedCallback = "memoized-callback", + Reducer = "reducer", + ReducerInitializer = "reducer-initializer", + ServerSnapshot = "server-snapshot", +} + +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; +} + +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 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; + activeContextProviderIds: ReadonlyArray; +} + +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 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; + units: ReadonlyArray; + edges: ReadonlyArray; + hookCalls: ReadonlyArray; + effects: ReadonlyArray; + effectEvents: ReadonlyArray; + externalStores: ReadonlyArray; + asyncTasks: ReadonlyArray; + contexts: ReadonlyArray; + contextProviders: ReadonlyArray; + contextConsumers: ReadonlyArray; + renders: ReadonlyArray; + callbacks: ReadonlyArray; + reachableFunctions: ReadonlyArray; + functionCalls: ReadonlyArray; + eventBindings: ReadonlyArray; + callbackPropFlows: 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; + functionNode?: ts.FunctionLikeDeclaration; + invalidHookCalls?: ReadonlyArray; +} + +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 0000000000..32f8a40efa --- /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/collect-symbol-writes.ts b/packages/prover/src/utils/collect-symbol-writes.ts new file mode 100644 index 0000000000..d68dda7617 --- /dev/null +++ b/packages/prover/src/utils/collect-symbol-writes.ts @@ -0,0 +1,71 @@ +import ts from "typescript"; + +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.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) && + node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && + node.operatorToken.kind <= ts.SyntaxKind.LastAssignment && + 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.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/tests/fixtures/aliased-stale-effect/src/app.tsx b/packages/prover/tests/fixtures/aliased-stale-effect/src/app.tsx new file mode 100644 index 0000000000..5256ee0fc4 --- /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 0000000000..d2a921ed01 --- /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 0000000000..d4e42bbd7b --- /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 0000000000..d2a921ed01 --- /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 0000000000..41537239df --- /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 0000000000..d2a921ed01 --- /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 0000000000..e52f8d3f43 --- /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 0000000000..d2a921ed01 --- /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 0000000000..20f25bf6da --- /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 0000000000..d2a921ed01 --- /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 0000000000..f3752bbfb3 --- /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 0000000000..d2a921ed01 --- /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 0000000000..9a4d700aa4 --- /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 0000000000..d2a921ed01 --- /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 0000000000..bc91eeb923 --- /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 0000000000..d2a921ed01 --- /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 0000000000..5fe40c869f --- /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 0000000000..d2a921ed01 --- /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 0000000000..3d6a34936d --- /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 0000000000..d2a921ed01 --- /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 0000000000..ab0ef2b887 --- /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 0000000000..d2a921ed01 --- /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 0000000000..16dc65b634 --- /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 0000000000..d2a921ed01 --- /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/cleanup-mismatch/src/app.tsx b/packages/prover/tests/fixtures/cleanup-mismatch/src/app.tsx new file mode 100644 index 0000000000..009b118c3e --- /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 0000000000..d2a921ed01 --- /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 0000000000..d0e59467e5 --- /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 0000000000..d2a921ed01 --- /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-helper-effect-cleanup/src/app.tsx b/packages/prover/tests/fixtures/conditional-helper-effect-cleanup/src/app.tsx new file mode 100644 index 0000000000..9701daf52b --- /dev/null +++ b/packages/prover/tests/fixtures/conditional-helper-effect-cleanup/src/app.tsx @@ -0,0 +1,26 @@ +import { useEffect } from "react"; + +const handleResize = () => {}; + +const installResizeListener = () => { + window.addEventListener("resize", handleResize); +}; + +const removeResizeListener = () => { + window.removeEventListener("resize", handleResize); +}; + +interface SidebarProperties { + isEnabled: boolean; +} + +export const Sidebar = ({ isEnabled }: SidebarProperties) => { + useEffect(() => { + if (isEnabled) installResizeListener(); + return () => { + removeResizeListener(); + }; + }, [isEnabled]); + + return null; +}; diff --git a/packages/prover/tests/fixtures/conditional-helper-effect-cleanup/tsconfig.json b/packages/prover/tests/fixtures/conditional-helper-effect-cleanup/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/conditional-helper-effect-cleanup/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 0000000000..f937ce97ff --- /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 0000000000..d2a921ed01 --- /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 0000000000..9d76801f3f --- /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 0000000000..d2a921ed01 --- /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 0000000000..8af3486e9c --- /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 0000000000..d2a921ed01 --- /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 0000000000..a2a0a7348e --- /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 0000000000..d2a921ed01 --- /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 0000000000..56acc2e4e2 --- /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 0000000000..d2a921ed01 --- /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 0000000000..4df6f6fe1a --- /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 0000000000..d2a921ed01 --- /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 0000000000..d0c936ed8f --- /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 0000000000..d2a921ed01 --- /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 0000000000..e3d0dba7bc --- /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 0000000000..d2a921ed01 --- /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 0000000000..f91ae4a3a3 --- /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 0000000000..d2a921ed01 --- /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 0000000000..7f3b0c7c91 --- /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 0000000000..d2a921ed01 --- /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 0000000000..cd8b21554b --- /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 0000000000..d2a921ed01 --- /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 0000000000..5ab109177c --- /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 0000000000..d2a921ed01 --- /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 0000000000..c7dbce1f6f --- /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 0000000000..d2a921ed01 --- /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 0000000000..c2411cc652 --- /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 0000000000..d2a921ed01 --- /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 0000000000..a9d99b6b7c --- /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 0000000000..d2a921ed01 --- /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 0000000000..d9af8ec486 --- /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 0000000000..d2a921ed01 --- /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 0000000000..3d6d7f3dc1 --- /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 0000000000..d2a921ed01 --- /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 0000000000..26054de1f3 --- /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 0000000000..bc4d8b0c6b --- /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 0000000000..d2a921ed01 --- /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 0000000000..9dd0b8eb5e --- /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 0000000000..d2a921ed01 --- /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 0000000000..cfd7666c9e --- /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 0000000000..d2a921ed01 --- /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 0000000000..21f73c2432 --- /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 0000000000..d2a921ed01 --- /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 0000000000..d942c4abdc --- /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 0000000000..d2a921ed01 --- /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 0000000000..af85a6e378 --- /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 0000000000..d2a921ed01 --- /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 0000000000..c2f6082ff7 --- /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 0000000000..d2a921ed01 --- /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 0000000000..7ea15d83a0 --- /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 0000000000..d2a921ed01 --- /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 0000000000..3b84d5f060 --- /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 0000000000..d2a921ed01 --- /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 0000000000..f24de168fc --- /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 0000000000..d2a921ed01 --- /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 0000000000..ecc9e6cadc --- /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 0000000000..d2a921ed01 --- /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 0000000000..2022e25544 --- /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 0000000000..d2a921ed01 --- /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 0000000000..fa370fddba --- /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 0000000000..d2a921ed01 --- /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 0000000000..131bd2fbaf --- /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 0000000000..d2a921ed01 --- /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-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 0000000000..6675af546f --- /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 0000000000..d2a921ed01 --- /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 0000000000..6e60d48740 --- /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 0000000000..d2a921ed01 --- /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 0000000000..a17918e7f0 --- /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 0000000000..d2a921ed01 --- /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-computed-event-prop-wrapper/src/app.tsx b/packages/prover/tests/fixtures/incomplete-computed-event-prop-wrapper/src/app.tsx new file mode 100644 index 0000000000..6d73e7721e --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-computed-event-prop-wrapper/src/app.tsx @@ -0,0 +1,19 @@ +interface ActionButtonProperties { + onActivate: () => void; +} + +const callbackKey = "onActivate" as const; + +const ActionButton = ({ [callbackKey]: activate }: ActionButtonProperties) => { + const handleClick = () => activate(); + return ( + + ); +}; + +export const Application = () => { + const recordActivation = () => undefined; + return ; +}; diff --git a/packages/prover/tests/fixtures/incomplete-computed-event-prop-wrapper/tsconfig.json b/packages/prover/tests/fixtures/incomplete-computed-event-prop-wrapper/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-computed-event-prop-wrapper/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-defaulted-event-prop-wrapper/src/app.tsx b/packages/prover/tests/fixtures/incomplete-defaulted-event-prop-wrapper/src/app.tsx new file mode 100644 index 0000000000..5c6b4ca87e --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-defaulted-event-prop-wrapper/src/app.tsx @@ -0,0 +1,19 @@ +interface ActionButtonProperties { + onActivate?: () => void; +} + +const fallbackActivation = () => undefined; + +const ActionButton = ({ onActivate = fallbackActivation }: ActionButtonProperties) => { + const handleClick = () => onActivate(); + return ( + + ); +}; + +export const Application = () => { + const recordActivation = () => undefined; + return ; +}; diff --git a/packages/prover/tests/fixtures/incomplete-defaulted-event-prop-wrapper/tsconfig.json b/packages/prover/tests/fixtures/incomplete-defaulted-event-prop-wrapper/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-defaulted-event-prop-wrapper/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-effect-callback-prop-state-cycle/src/app.tsx b/packages/prover/tests/fixtures/incomplete-effect-callback-prop-state-cycle/src/app.tsx new file mode 100644 index 0000000000..76184dc54c --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-effect-callback-prop-state-cycle/src/app.tsx @@ -0,0 +1,18 @@ +import { useEffect, useState } from "react"; + +interface SynchronizerProperties { + synchronize: () => void; +} + +const Synchronizer = ({ synchronize }: SynchronizerProperties) => { + useEffect(() => { + synchronize(); + }, [synchronize]); + return null; +}; + +export const Application = () => { + const [, setRevision] = useState(0); + const synchronize = () => setRevision((revision) => revision + 1); + return ; +}; diff --git a/packages/prover/tests/fixtures/incomplete-effect-callback-prop-state-cycle/tsconfig.json b/packages/prover/tests/fixtures/incomplete-effect-callback-prop-state-cycle/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-effect-callback-prop-state-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/incomplete-event-prop-spread/src/app.tsx b/packages/prover/tests/fixtures/incomplete-event-prop-spread/src/app.tsx new file mode 100644 index 0000000000..dc9232b246 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-event-prop-spread/src/app.tsx @@ -0,0 +1,11 @@ +interface ActionButtonProperties { + onActivate: () => void; +} + +const ActionButton = ({ onActivate }: ActionButtonProperties) => ( + +); + +export const Toolbar = (properties: ActionButtonProperties) => ; diff --git a/packages/prover/tests/fixtures/incomplete-event-prop-spread/tsconfig.json b/packages/prover/tests/fixtures/incomplete-event-prop-spread/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-event-prop-spread/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-external-store-callback-prop-conditional-join/src/app.tsx b/packages/prover/tests/fixtures/incomplete-external-store-callback-prop-conditional-join/src/app.tsx new file mode 100644 index 0000000000..753e999790 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-external-store-callback-prop-conditional-join/src/app.tsx @@ -0,0 +1,40 @@ +import { useSyncExternalStore } from "react"; + +const primaryListeners = new Set<() => void>(); +const secondaryListeners = new Set<() => void>(); +let primaryVersion = 0; +let secondaryVersion = 0; + +interface StoreReaderProperties { + subscribe: (listener: () => void) => () => void; + getSnapshot: () => number; +} + +interface ApplicationProperties { + useSecondarySnapshot: boolean; + useSecondaryStore: boolean; +} + +const StoreReader = ({ subscribe, getSnapshot }: StoreReaderProperties) => { + const version = useSyncExternalStore(subscribe, getSnapshot); + return {version}; +}; + +export const Application = ({ useSecondarySnapshot, useSecondaryStore }: ApplicationProperties) => { + const subscribeToPrimary = (listener: () => void) => { + primaryListeners.add(listener); + return () => primaryListeners.delete(listener); + }; + const subscribeToSecondary = (listener: () => void) => { + secondaryListeners.add(listener); + return () => secondaryListeners.delete(listener); + }; + const getPrimarySnapshot = () => primaryVersion; + const getSecondarySnapshot = () => secondaryVersion; + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/incomplete-external-store-callback-prop-conditional-join/tsconfig.json b/packages/prover/tests/fixtures/incomplete-external-store-callback-prop-conditional-join/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-external-store-callback-prop-conditional-join/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-external-store-callback-prop-spread/src/app.tsx b/packages/prover/tests/fixtures/incomplete-external-store-callback-prop-spread/src/app.tsx new file mode 100644 index 0000000000..933bdd0272 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-external-store-callback-prop-spread/src/app.tsx @@ -0,0 +1,23 @@ +import { useSyncExternalStore } from "react"; + +const listeners = new Set<() => void>(); + +interface StoreReaderProperties { + subscribe: (listener: () => void) => () => void; + getSnapshot: () => number; +} + +const StoreReader = ({ subscribe, getSnapshot }: StoreReaderProperties) => { + const version = useSyncExternalStore(subscribe, getSnapshot); + return {version}; +}; + +export const Application = () => { + const subscribe = (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }; + const getSnapshot = () => 0; + const storeProperties = { subscribe, getSnapshot }; + return ; +}; diff --git a/packages/prover/tests/fixtures/incomplete-external-store-callback-prop-spread/tsconfig.json b/packages/prover/tests/fixtures/incomplete-external-store-callback-prop-spread/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-external-store-callback-prop-spread/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-external-store-conditional-factory/src/app.tsx b/packages/prover/tests/fixtures/incomplete-external-store-conditional-factory/src/app.tsx new file mode 100644 index 0000000000..1e99936687 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-external-store-conditional-factory/src/app.tsx @@ -0,0 +1,53 @@ +import { useSyncExternalStore } from "react"; + +let primaryVersion = 0; +let secondaryVersion = 0; +const primaryListeners = new Set<() => void>(); +const secondaryListeners = new Set<() => void>(); + +interface StoreReaderProperties { + subscribe: (listener: () => void) => () => void; + getSnapshot: () => number; +} + +interface ApplicationProperties { + readSecondaryStore: boolean; + subscribeToSecondaryStore: boolean; +} + +const selectCallback = ( + condition: boolean, + whenTrue: Callback, + whenFalse: Callback, +): Callback => (condition ? whenTrue : whenFalse); + +const StoreReader = ({ subscribe, getSnapshot }: StoreReaderProperties) => { + const version = useSyncExternalStore(subscribe, getSnapshot); + return {version}; +}; + +export const Application = ({ + readSecondaryStore, + subscribeToSecondaryStore, +}: ApplicationProperties) => { + const subscribeToPrimary = (listener: () => void) => { + primaryListeners.add(listener); + return () => primaryListeners.delete(listener); + }; + const subscribeToSecondary = (listener: () => void) => { + secondaryListeners.add(listener); + return () => secondaryListeners.delete(listener); + }; + const getPrimarySnapshot = () => primaryVersion; + const getSecondarySnapshot = () => secondaryVersion; + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/incomplete-external-store-conditional-factory/tsconfig.json b/packages/prover/tests/fixtures/incomplete-external-store-conditional-factory/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-external-store-conditional-factory/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-external-store-mutated-conditional-props/src/app.tsx b/packages/prover/tests/fixtures/incomplete-external-store-mutated-conditional-props/src/app.tsx new file mode 100644 index 0000000000..bbad9d3a20 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-external-store-mutated-conditional-props/src/app.tsx @@ -0,0 +1,42 @@ +import { useSyncExternalStore } from "react"; + +let primaryVersion = 0; +let secondaryVersion = 0; +const primaryListeners = new Set<() => void>(); +const secondaryListeners = new Set<() => void>(); + +interface StoreReaderProperties { + ignored: boolean; + subscribe: (listener: () => void) => () => void; + getSnapshot: () => number; +} + +interface ApplicationProperties { + useSecondaryStore: boolean; +} + +const StoreReader = ({ ignored, subscribe, getSnapshot }: StoreReaderProperties) => { + const version = useSyncExternalStore(subscribe, getSnapshot); + return {version}; +}; + +export const Application = ({ useSecondaryStore }: ApplicationProperties) => { + let selectedStoreIsSecondary = useSecondaryStore; + const subscribeToPrimary = (listener: () => void) => { + primaryListeners.add(listener); + return () => primaryListeners.delete(listener); + }; + const subscribeToSecondary = (listener: () => void) => { + secondaryListeners.add(listener); + return () => secondaryListeners.delete(listener); + }; + const getPrimarySnapshot = () => primaryVersion; + const getSecondarySnapshot = () => secondaryVersion; + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/incomplete-external-store-mutated-conditional-props/tsconfig.json b/packages/prover/tests/fixtures/incomplete-external-store-mutated-conditional-props/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-external-store-mutated-conditional-props/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-for-of-computed-binding-handler/src/app.tsx b/packages/prover/tests/fixtures/incomplete-for-of-computed-binding-handler/src/app.tsx new file mode 100644 index 0000000000..dc992739aa --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-for-of-computed-binding-handler/src/app.tsx @@ -0,0 +1,25 @@ +interface HandlerOptions { + fallbackHandler: () => void; + primaryHandler: () => void; +} + +const handlerKey = "handler" as const; + +const chooseHandler = (options: HandlerOptions) => { + for (const { [handlerKey]: handler } of [{ handler: options.primaryHandler }]) { + return handler; + } + return options.fallbackHandler; +}; + +export const Application = () => { + const handleClick = chooseHandler({ + fallbackHandler: () => undefined, + primaryHandler: () => undefined, + }); + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/incomplete-for-of-computed-binding-handler/tsconfig.json b/packages/prover/tests/fixtures/incomplete-for-of-computed-binding-handler/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-for-of-computed-binding-handler/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-for-of-defaulted-handler/src/app.tsx b/packages/prover/tests/fixtures/incomplete-for-of-defaulted-handler/src/app.tsx new file mode 100644 index 0000000000..4c72462f6f --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-for-of-defaulted-handler/src/app.tsx @@ -0,0 +1,23 @@ +interface HandlerOptions { + fallbackHandler: () => void; + primaryHandler: () => void; +} + +const chooseHandler = (options: HandlerOptions) => { + for (const { handler = options.fallbackHandler } of [{ handler: options.primaryHandler }]) { + return handler; + } + return options.fallbackHandler; +}; + +export const Application = () => { + const handleClick = chooseHandler({ + fallbackHandler: () => undefined, + primaryHandler: () => undefined, + }); + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/incomplete-for-of-defaulted-handler/tsconfig.json b/packages/prover/tests/fixtures/incomplete-for-of-defaulted-handler/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-for-of-defaulted-handler/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-for-of-mutable-handler/src/app.tsx b/packages/prover/tests/fixtures/incomplete-for-of-mutable-handler/src/app.tsx new file mode 100644 index 0000000000..eb5a93914c --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-for-of-mutable-handler/src/app.tsx @@ -0,0 +1,24 @@ +interface HandlerOptions { + primaryHandler: () => void; + secondaryHandler: () => void; +} + +const chooseHandler = (options: HandlerOptions) => { + for (let handler of [options.primaryHandler]) { + handler = options.secondaryHandler; + return handler; + } + return options.primaryHandler; +}; + +export const Application = () => { + const handleClick = chooseHandler({ + primaryHandler: () => undefined, + secondaryHandler: () => undefined, + }); + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/incomplete-for-of-mutable-handler/tsconfig.json b/packages/prover/tests/fixtures/incomplete-for-of-mutable-handler/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-for-of-mutable-handler/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-for-of-rest-binding-handler/src/app.tsx b/packages/prover/tests/fixtures/incomplete-for-of-rest-binding-handler/src/app.tsx new file mode 100644 index 0000000000..4d8d2d5dd2 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-for-of-rest-binding-handler/src/app.tsx @@ -0,0 +1,25 @@ +interface HandlerOptions { + fallbackHandler: () => void; + primaryHandler: () => void; +} + +const chooseHandler = (options: HandlerOptions) => { + for (const [handler, ...remainingHandlers] of [ + [options.primaryHandler, options.fallbackHandler], + ]) { + if (remainingHandlers.length > 0) return handler; + } + return options.fallbackHandler; +}; + +export const Application = () => { + const handleClick = chooseHandler({ + fallbackHandler: () => undefined, + primaryHandler: () => undefined, + }); + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/incomplete-for-of-rest-binding-handler/tsconfig.json b/packages/prover/tests/fixtures/incomplete-for-of-rest-binding-handler/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-for-of-rest-binding-handler/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-for-of-spread-handler-factory/src/app.tsx b/packages/prover/tests/fixtures/incomplete-for-of-spread-handler-factory/src/app.tsx new file mode 100644 index 0000000000..593c570d67 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-for-of-spread-handler-factory/src/app.tsx @@ -0,0 +1,25 @@ +interface HandlerOptions { + handlers: ReadonlyArray<() => void>; + primaryHandler: () => void; + secondaryHandler: () => void; +} + +const chooseHandler = (options: HandlerOptions) => { + for (const handler of [...options.handlers]) { + return handler; + } + return options.secondaryHandler; +}; + +export const Application = () => { + const handleClick = chooseHandler({ + handlers: [() => undefined], + primaryHandler: () => undefined, + secondaryHandler: () => undefined, + }); + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/incomplete-for-of-spread-handler-factory/tsconfig.json b/packages/prover/tests/fixtures/incomplete-for-of-spread-handler-factory/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-for-of-spread-handler-factory/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-local-object-callback-spread/src/app.tsx b/packages/prover/tests/fixtures/incomplete-local-object-callback-spread/src/app.tsx new file mode 100644 index 0000000000..6c2e5b0719 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-local-object-callback-spread/src/app.tsx @@ -0,0 +1,17 @@ +interface ApplicationProperties { + fallbackCallbacks: { + callback?: () => void; + }; +} + +export const Application = ({ fallbackCallbacks }: ApplicationProperties) => { + const callbacks = { + callback: () => undefined, + ...fallbackCallbacks, + }; + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/incomplete-local-object-callback-spread/tsconfig.json b/packages/prover/tests/fixtures/incomplete-local-object-callback-spread/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-local-object-callback-spread/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-logical-callback-alias/src/app.tsx b/packages/prover/tests/fixtures/incomplete-logical-callback-alias/src/app.tsx new file mode 100644 index 0000000000..9e79778eed --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-logical-callback-alias/src/app.tsx @@ -0,0 +1,17 @@ +const callbackRegistry = new Map void>(); + +const registerCallback = (callback: (() => void) | undefined) => { + const selectedCallback = callback || (() => undefined); + callbackRegistry.set("activate", selectedCallback); +}; + +export const Application = () => { + const handleClick = () => { + registerCallback(() => undefined); + }; + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/incomplete-logical-callback-alias/tsconfig.json b/packages/prover/tests/fixtures/incomplete-logical-callback-alias/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-logical-callback-alias/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-mutable-object-callback/src/app.tsx b/packages/prover/tests/fixtures/incomplete-mutable-object-callback/src/app.tsx new file mode 100644 index 0000000000..0b1c00d42f --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-mutable-object-callback/src/app.tsx @@ -0,0 +1,14 @@ +export const Application = () => { + const firstCallback = () => undefined; + const secondCallback = () => undefined; + const handleClick = () => { + const callbacks = { activate: firstCallback }; + callbacks.activate = secondCallback; + callbacks.activate(); + }; + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/incomplete-mutable-object-callback/tsconfig.json b/packages/prover/tests/fixtures/incomplete-mutable-object-callback/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-mutable-object-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-object-callback-spread/src/app.tsx b/packages/prover/tests/fixtures/incomplete-object-callback-spread/src/app.tsx new file mode 100644 index 0000000000..1bec9d5abc --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-object-callback-spread/src/app.tsx @@ -0,0 +1,16 @@ +interface CallbackOptions { + callback: () => void; +} + +const invokeCallback = (options: CallbackOptions) => options.callback(); + +export const Application = () => { + const recordActivation = () => undefined; + const callbackOptions = { callback: recordActivation }; + const handleClick = () => invokeCallback({ ...callbackOptions }); + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/incomplete-object-callback-spread/tsconfig.json b/packages/prover/tests/fixtures/incomplete-object-callback-spread/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-object-callback-spread/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-partial-handler-factory/src/app.tsx b/packages/prover/tests/fixtures/incomplete-partial-handler-factory/src/app.tsx new file mode 100644 index 0000000000..2eba778b69 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-partial-handler-factory/src/app.tsx @@ -0,0 +1,13 @@ +const chooseHandler = (isEnabled: boolean, handler: () => void) => { + if (isEnabled) return handler; +}; + +export const Application = () => { + const activate = () => undefined; + const handleClick = chooseHandler(true, activate); + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/incomplete-partial-handler-factory/tsconfig.json b/packages/prover/tests/fixtures/incomplete-partial-handler-factory/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-partial-handler-factory/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-ref-backed-event-callback/src/app.tsx b/packages/prover/tests/fixtures/incomplete-ref-backed-event-callback/src/app.tsx new file mode 100644 index 0000000000..d0aa6f3452 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-ref-backed-event-callback/src/app.tsx @@ -0,0 +1,19 @@ +import { useCallback, useEffect, useRef } from "react"; + +const useEventCallback = (callback: () => void) => { + const callbackRef = useRef(callback); + useEffect(() => { + callbackRef.current = callback; + }, [callback]); + return useCallback(() => callbackRef.current(), []); +}; + +export const Application = () => { + const recordActivation = () => undefined; + const handleClick = useEventCallback(recordActivation); + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/incomplete-ref-backed-event-callback/tsconfig.json b/packages/prover/tests/fixtures/incomplete-ref-backed-event-callback/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-ref-backed-event-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-switch-fallthrough-handler-factory/src/app.tsx b/packages/prover/tests/fixtures/incomplete-switch-fallthrough-handler-factory/src/app.tsx new file mode 100644 index 0000000000..a8a67be6c9 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-switch-fallthrough-handler-factory/src/app.tsx @@ -0,0 +1,29 @@ +interface HandlerOptions { + mode: "primary" | "secondary"; + shouldUsePrimary: boolean; + primaryHandler: () => void; + secondaryHandler: () => void; +} + +const chooseHandler = (options: HandlerOptions) => { + switch (options.mode) { + case "primary": + if (options.shouldUsePrimary) return options.primaryHandler; + case "secondary": + return options.secondaryHandler; + } +}; + +export const Application = () => { + const handleClick = chooseHandler({ + mode: "primary", + shouldUsePrimary: true, + primaryHandler: () => undefined, + secondaryHandler: () => undefined, + }); + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/incomplete-switch-fallthrough-handler-factory/tsconfig.json b/packages/prover/tests/fixtures/incomplete-switch-fallthrough-handler-factory/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-switch-fallthrough-handler-factory/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-switch-uncovered-handler-factory/src/app.tsx b/packages/prover/tests/fixtures/incomplete-switch-uncovered-handler-factory/src/app.tsx new file mode 100644 index 0000000000..8e37d933f4 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-switch-uncovered-handler-factory/src/app.tsx @@ -0,0 +1,27 @@ +interface HandlerOptions { + mode: "primary" | "secondary" | "disabled"; + primaryHandler: () => void; + secondaryHandler: () => void; +} + +const chooseHandler = (options: HandlerOptions) => { + switch (options.mode) { + case "primary": + return options.primaryHandler; + case "secondary": + return options.secondaryHandler; + } +}; + +export const Application = () => { + const handleClick = chooseHandler({ + mode: "primary", + primaryHandler: () => undefined, + secondaryHandler: () => undefined, + }); + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/incomplete-switch-uncovered-handler-factory/tsconfig.json b/packages/prover/tests/fixtures/incomplete-switch-uncovered-handler-factory/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-switch-uncovered-handler-factory/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-try-catch-handler-factory/src/app.tsx b/packages/prover/tests/fixtures/incomplete-try-catch-handler-factory/src/app.tsx new file mode 100644 index 0000000000..c7fe7165fb --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-try-catch-handler-factory/src/app.tsx @@ -0,0 +1,20 @@ +interface HandlerOptions { + primaryHandler: () => void; +} + +const chooseHandler = (options: HandlerOptions) => { + try { + return options.primaryHandler; + } catch {} +}; + +export const Application = () => { + const handleClick = chooseHandler({ + primaryHandler: () => undefined, + }); + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/incomplete-try-catch-handler-factory/tsconfig.json b/packages/prover/tests/fixtures/incomplete-try-catch-handler-factory/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-try-catch-handler-factory/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-while-handler-factory/src/app.tsx b/packages/prover/tests/fixtures/incomplete-while-handler-factory/src/app.tsx new file mode 100644 index 0000000000..ce8dc69783 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-while-handler-factory/src/app.tsx @@ -0,0 +1,27 @@ +interface HandlerOptions { + keepSearching: boolean; + preferPrimary: boolean; + primaryHandler: () => void; + secondaryHandler: () => void; +} + +const chooseHandler = (options: HandlerOptions) => { + while (options.keepSearching) { + if (options.preferPrimary) return options.primaryHandler; + } + return options.secondaryHandler; +}; + +export const Application = () => { + const handleClick = chooseHandler({ + keepSearching: false, + preferPrimary: false, + primaryHandler: () => undefined, + secondaryHandler: () => undefined, + }); + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/incomplete-while-handler-factory/tsconfig.json b/packages/prover/tests/fixtures/incomplete-while-handler-factory/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-while-handler-factory/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/index-list-key/src/app.tsx b/packages/prover/tests/fixtures/index-list-key/src/app.tsx new file mode 100644 index 0000000000..bdf06ce1b5 --- /dev/null +++ b/packages/prover/tests/fixtures/index-list-key/src/app.tsx @@ -0,0 +1,11 @@ +interface ListProperties { + items: ReadonlyArray; +} + +export const List = ({ items }: ListProperties) => ( +
    + {items.map((item, itemIndex) => ( +
  • {item}
  • + ))} +
+); diff --git a/packages/prover/tests/fixtures/index-list-key/tsconfig.json b/packages/prover/tests/fixtures/index-list-key/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/index-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/invalid-hook-helper/src/app.tsx b/packages/prover/tests/fixtures/invalid-hook-helper/src/app.tsx new file mode 100644 index 0000000000..8baa3e24b3 --- /dev/null +++ b/packages/prover/tests/fixtures/invalid-hook-helper/src/app.tsx @@ -0,0 +1,8 @@ +import { useState } from "react"; + +const readCounter = () => { + const [count] = useState(0); + return count; +}; + +export const Counter = () =>

{readCounter()}

; diff --git a/packages/prover/tests/fixtures/invalid-hook-helper/tsconfig.json b/packages/prover/tests/fixtures/invalid-hook-helper/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/invalid-hook-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/mapped-event-handler/src/app.tsx b/packages/prover/tests/fixtures/mapped-event-handler/src/app.tsx new file mode 100644 index 0000000000..d9ef878973 --- /dev/null +++ b/packages/prover/tests/fixtures/mapped-event-handler/src/app.tsx @@ -0,0 +1,22 @@ +import { useState } from "react"; + +const items = [ + { id: "first", label: "First" }, + { id: "second", label: "Second" }, +]; + +const selectItem = (itemId: string) => itemId; + +export const ItemList = () => { + const [selectedItemId, setSelectedItemId] = useState(""); + return ( +
+

{selectedItemId}

+ {items.map((item) => ( + + ))} +
+ ); +}; diff --git a/packages/prover/tests/fixtures/mapped-event-handler/tsconfig.json b/packages/prover/tests/fixtures/mapped-event-handler/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/mapped-event-handler/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/memo-callback/src/app.tsx b/packages/prover/tests/fixtures/memo-callback/src/app.tsx new file mode 100644 index 0000000000..f2d33f46d3 --- /dev/null +++ b/packages/prover/tests/fixtures/memo-callback/src/app.tsx @@ -0,0 +1,7 @@ +import { useCallback, useState } from "react"; + +export const Counter = () => { + const [count, setCount] = useState(0); + const increment = useCallback(() => setCount(count + 1), []); + return

{increment.name}

; +}; diff --git a/packages/prover/tests/fixtures/memo-callback/tsconfig.json b/packages/prover/tests/fixtures/memo-callback/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/memo-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/method-effect-listener-leak/src/app.tsx b/packages/prover/tests/fixtures/method-effect-listener-leak/src/app.tsx new file mode 100644 index 0000000000..c96cc6720e --- /dev/null +++ b/packages/prover/tests/fixtures/method-effect-listener-leak/src/app.tsx @@ -0,0 +1,16 @@ +import { useEffect } from "react"; + +const handleResize = () => {}; +const resizeLifecycle = { + install() { + window.addEventListener("resize", handleResize); + }, +}; + +export const Sidebar = () => { + useEffect(() => { + resizeLifecycle.install(); + }, []); + + return null; +}; diff --git a/packages/prover/tests/fixtures/method-effect-listener-leak/tsconfig.json b/packages/prover/tests/fixtures/method-effect-listener-leak/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/method-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/mismatched-external-store-callback-prop-server-snapshot/src/app.tsx b/packages/prover/tests/fixtures/mismatched-external-store-callback-prop-server-snapshot/src/app.tsx new file mode 100644 index 0000000000..293cfb7068 --- /dev/null +++ b/packages/prover/tests/fixtures/mismatched-external-store-callback-prop-server-snapshot/src/app.tsx @@ -0,0 +1,30 @@ +import { useSyncExternalStore } from "react"; + +const listeners = new Set<() => void>(); + +interface StoreReaderProperties { + subscribe: (listener: () => void) => () => void; + getSnapshot: () => boolean; + getServerSnapshot: () => boolean; +} + +const StoreReader = ({ subscribe, getSnapshot, getServerSnapshot }: StoreReaderProperties) => { + const isReady = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + return {isReady ? "ready" : "waiting"}; +}; + +export const Application = () => { + const subscribe = (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }; + const getSnapshot = () => true; + const getServerSnapshot = () => false; + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/mismatched-external-store-callback-prop-server-snapshot/tsconfig.json b/packages/prover/tests/fixtures/mismatched-external-store-callback-prop-server-snapshot/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/mismatched-external-store-callback-prop-server-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/mismatched-external-store-conditional-factory/src/app.tsx b/packages/prover/tests/fixtures/mismatched-external-store-conditional-factory/src/app.tsx new file mode 100644 index 0000000000..ec22005346 --- /dev/null +++ b/packages/prover/tests/fixtures/mismatched-external-store-conditional-factory/src/app.tsx @@ -0,0 +1,55 @@ +import { useSyncExternalStore } from "react"; + +let primaryVersion = 0; +let secondaryVersion = 0; +const primaryListeners = new Set<() => void>(); +const secondaryListeners = new Set<() => void>(); + +export const updatePrimaryStore = () => { + primaryVersion += 1; + for (const listener of primaryListeners) listener(); +}; + +export const updateSecondaryStore = () => { + secondaryVersion += 1; + for (const listener of secondaryListeners) listener(); +}; + +interface StoreReaderProperties { + subscribe: (listener: () => void) => () => void; + getSnapshot: () => number; +} + +interface ApplicationProperties { + useSecondaryStore: boolean; +} + +const selectCallback = ( + condition: boolean, + whenTrue: Callback, + whenFalse: Callback, +): Callback => (condition ? whenTrue : whenFalse); + +const StoreReader = ({ subscribe, getSnapshot }: StoreReaderProperties) => { + const version = useSyncExternalStore(subscribe, getSnapshot); + return {version}; +}; + +export const Application = ({ useSecondaryStore }: ApplicationProperties) => { + const subscribeToPrimary = (listener: () => void) => { + primaryListeners.add(listener); + return () => primaryListeners.delete(listener); + }; + const subscribeToSecondary = (listener: () => void) => { + secondaryListeners.add(listener); + return () => secondaryListeners.delete(listener); + }; + const getPrimarySnapshot = () => primaryVersion; + const getSecondarySnapshot = () => secondaryVersion; + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/mismatched-external-store-conditional-factory/tsconfig.json b/packages/prover/tests/fixtures/mismatched-external-store-conditional-factory/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/mismatched-external-store-conditional-factory/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/mismatched-external-store-conditional-props/src/app.tsx b/packages/prover/tests/fixtures/mismatched-external-store-conditional-props/src/app.tsx new file mode 100644 index 0000000000..498e583ec4 --- /dev/null +++ b/packages/prover/tests/fixtures/mismatched-external-store-conditional-props/src/app.tsx @@ -0,0 +1,49 @@ +import { useSyncExternalStore } from "react"; + +let primaryVersion = 0; +let secondaryVersion = 0; +const primaryListeners = new Set<() => void>(); +const secondaryListeners = new Set<() => void>(); + +export const updatePrimaryStore = () => { + primaryVersion += 1; + for (const listener of primaryListeners) listener(); +}; + +export const updateSecondaryStore = () => { + secondaryVersion += 1; + for (const listener of secondaryListeners) listener(); +}; + +interface StoreReaderProperties { + subscribe: (listener: () => void) => () => void; + getSnapshot: () => number; +} + +interface ApplicationProperties { + useSecondaryStore: boolean; +} + +const StoreReader = ({ subscribe, getSnapshot }: StoreReaderProperties) => { + const version = useSyncExternalStore(subscribe, getSnapshot); + return {version}; +}; + +export const Application = ({ useSecondaryStore }: ApplicationProperties) => { + const subscribeToPrimary = (listener: () => void) => { + primaryListeners.add(listener); + return () => primaryListeners.delete(listener); + }; + const subscribeToSecondary = (listener: () => void) => { + secondaryListeners.add(listener); + return () => secondaryListeners.delete(listener); + }; + const getPrimarySnapshot = () => primaryVersion; + const getSecondarySnapshot = () => secondaryVersion; + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/mismatched-external-store-conditional-props/tsconfig.json b/packages/prover/tests/fixtures/mismatched-external-store-conditional-props/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/mismatched-external-store-conditional-props/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/mismatched-server-snapshot/src/app.tsx b/packages/prover/tests/fixtures/mismatched-server-snapshot/src/app.tsx new file mode 100644 index 0000000000..c27ac88734 --- /dev/null +++ b/packages/prover/tests/fixtures/mismatched-server-snapshot/src/app.tsx @@ -0,0 +1,12 @@ +import { useSyncExternalStore } from "react"; + +const subscribe = (_listener: () => void) => () => undefined; + +export const Connection = () => { + const isOnline = useSyncExternalStore( + subscribe, + () => true, + () => false, + ); + return

{isOnline ? "online" : "offline"}

; +}; diff --git a/packages/prover/tests/fixtures/mismatched-server-snapshot/tsconfig.json b/packages/prover/tests/fixtures/mismatched-server-snapshot/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/mismatched-server-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/missing-list-key/src/app.tsx b/packages/prover/tests/fixtures/missing-list-key/src/app.tsx new file mode 100644 index 0000000000..9b1d41f161 --- /dev/null +++ b/packages/prover/tests/fixtures/missing-list-key/src/app.tsx @@ -0,0 +1,11 @@ +interface ListProperties { + items: ReadonlyArray; +} + +export const List = ({ items }: ListProperties) => ( +
    + {items.map((item) => ( +
  • {item}
  • + ))} +
+); diff --git a/packages/prover/tests/fixtures/missing-list-key/tsconfig.json b/packages/prover/tests/fixtures/missing-list-key/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/missing-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/module-hook-call/src/app.tsx b/packages/prover/tests/fixtures/module-hook-call/src/app.tsx new file mode 100644 index 0000000000..84fa4bb0de --- /dev/null +++ b/packages/prover/tests/fixtures/module-hook-call/src/app.tsx @@ -0,0 +1,5 @@ +import { useState } from "react"; + +const [count] = useState(0); + +export const Counter = () =>

{count}

; diff --git a/packages/prover/tests/fixtures/module-hook-call/tsconfig.json b/packages/prover/tests/fixtures/module-hook-call/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/module-hook-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/named-memo-impure-helper/src/app.tsx b/packages/prover/tests/fixtures/named-memo-impure-helper/src/app.tsx new file mode 100644 index 0000000000..acf85d2fc2 --- /dev/null +++ b/packages/prover/tests/fixtures/named-memo-impure-helper/src/app.tsx @@ -0,0 +1,12 @@ +import { useMemo } from "react"; + +const readCurrentValue = () => { + console.log("reading value"); + return 1; +}; + +export const App = () => { + const computeValue = () => readCurrentValue(); + const value = useMemo(computeValue, []); + return

{value}

; +}; diff --git a/packages/prover/tests/fixtures/named-memo-impure-helper/tsconfig.json b/packages/prover/tests/fixtures/named-memo-impure-helper/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/named-memo-impure-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/nested-component/src/app.tsx b/packages/prover/tests/fixtures/nested-component/src/app.tsx new file mode 100644 index 0000000000..89ad3b97d7 --- /dev/null +++ b/packages/prover/tests/fixtures/nested-component/src/app.tsx @@ -0,0 +1,8 @@ +interface DashboardProperties { + title: string; +} + +export const Dashboard = ({ title }: DashboardProperties) => { + const Header = () =>

{title}

; + return
; +}; diff --git a/packages/prover/tests/fixtures/nested-component/tsconfig.json b/packages/prover/tests/fixtures/nested-component/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/nested-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/object-callback-effect-listener-leak/src/app.tsx b/packages/prover/tests/fixtures/object-callback-effect-listener-leak/src/app.tsx new file mode 100644 index 0000000000..d7022e1637 --- /dev/null +++ b/packages/prover/tests/fixtures/object-callback-effect-listener-leak/src/app.tsx @@ -0,0 +1,16 @@ +import { useEffect } from "react"; + +interface CallbackOptions { + callback: () => void; +} + +const invokeCallback = (options: CallbackOptions) => options.callback(); + +export const Listener = () => { + useEffect(() => { + invokeCallback({ + callback: () => window.addEventListener("resize", () => undefined), + }); + }, []); + return null; +}; diff --git a/packages/prover/tests/fixtures/object-callback-effect-listener-leak/tsconfig.json b/packages/prover/tests/fixtures/object-callback-effect-listener-leak/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/object-callback-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/opaque-render-call/src/app.tsx b/packages/prover/tests/fixtures/opaque-render-call/src/app.tsx new file mode 100644 index 0000000000..e64818e55f --- /dev/null +++ b/packages/prover/tests/fixtures/opaque-render-call/src/app.tsx @@ -0,0 +1,6 @@ +declare const readExperimentAssignment: () => string; + +export const Experiment = () => { + const assignment = readExperimentAssignment(); + return

{assignment}

; +}; diff --git a/packages/prover/tests/fixtures/opaque-render-call/tsconfig.json b/packages/prover/tests/fixtures/opaque-render-call/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/opaque-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/path-dependent-cleanup/src/app.tsx b/packages/prover/tests/fixtures/path-dependent-cleanup/src/app.tsx new file mode 100644 index 0000000000..9612211696 --- /dev/null +++ b/packages/prover/tests/fixtures/path-dependent-cleanup/src/app.tsx @@ -0,0 +1,17 @@ +import { useEffect } from "react"; + +interface VisibilityListenerProperties { + enabled: boolean; +} + +export const VisibilityListener = ({ enabled }: VisibilityListenerProperties) => { + useEffect(() => { + if (enabled) { + const handleVisibility = () => undefined; + document.addEventListener("visibilitychange", handleVisibility); + return () => document.removeEventListener("visibilitychange", handleVisibility); + } + }, [enabled]); + + return

{enabled ? "enabled" : "disabled"}

; +}; diff --git a/packages/prover/tests/fixtures/path-dependent-cleanup/tsconfig.json b/packages/prover/tests/fixtures/path-dependent-cleanup/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/path-dependent-cleanup/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/prop-mutation/src/app.tsx b/packages/prover/tests/fixtures/prop-mutation/src/app.tsx new file mode 100644 index 0000000000..1bf48a9035 --- /dev/null +++ b/packages/prover/tests/fixtures/prop-mutation/src/app.tsx @@ -0,0 +1,8 @@ +interface ListProperties { + items: string[]; +} + +export const List = ({ items }: ListProperties) => { + items.sort(); + return

{items.join(", ")}

; +}; diff --git a/packages/prover/tests/fixtures/prop-mutation/tsconfig.json b/packages/prover/tests/fixtures/prop-mutation/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/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/proved-aliased-hook/src/app.tsx b/packages/prover/tests/fixtures/proved-aliased-hook/src/app.tsx new file mode 100644 index 0000000000..1d3287e23a --- /dev/null +++ b/packages/prover/tests/fixtures/proved-aliased-hook/src/app.tsx @@ -0,0 +1,6 @@ +import { useState as stateHook } from "react"; + +export const Counter = () => { + const [count] = stateHook(0); + return

{count}

; +}; diff --git a/packages/prover/tests/fixtures/proved-aliased-hook/tsconfig.json b/packages/prover/tests/fixtures/proved-aliased-hook/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-aliased-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/proved-branch-effect-cleanup/src/app.tsx b/packages/prover/tests/fixtures/proved-branch-effect-cleanup/src/app.tsx new file mode 100644 index 0000000000..319c257459 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-branch-effect-cleanup/src/app.tsx @@ -0,0 +1,20 @@ +import { useEffect } from "react"; + +const handleResize = () => undefined; + +const getServerKey = (): string | Promise | undefined => "local"; + +export const Application = () => { + useEffect(() => { + const result = getServerKey(); + window.addEventListener("resize", handleResize); + const dispose = () => { + window.removeEventListener("resize", handleResize); + }; + if (!result) return dispose; + if (result instanceof Promise) return dispose; + return dispose; + }, []); + + return null; +}; diff --git a/packages/prover/tests/fixtures/proved-branch-effect-cleanup/tsconfig.json b/packages/prover/tests/fixtures/proved-branch-effect-cleanup/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-branch-effect-cleanup/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-cfg/src/status-badge.tsx b/packages/prover/tests/fixtures/proved-cfg/src/status-badge.tsx new file mode 100644 index 0000000000..7ef8d3cadf --- /dev/null +++ b/packages/prover/tests/fixtures/proved-cfg/src/status-badge.tsx @@ -0,0 +1,8 @@ +interface StatusBadgeProperties { + isOnline: boolean; +} + +export const StatusBadge = ({ isOnline }: StatusBadgeProperties) => { + const label = isOnline ? "online" : "offline"; + return {label}; +}; diff --git a/packages/prover/tests/fixtures/proved-cfg/tsconfig.json b/packages/prover/tests/fixtures/proved-cfg/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-cfg/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-chat/src/app.tsx b/packages/prover/tests/fixtures/proved-chat/src/app.tsx new file mode 100644 index 0000000000..eea751af48 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-chat/src/app.tsx @@ -0,0 +1,21 @@ +import { useEffect, useState } from "react"; + +interface ChatRoomProperties { + roomId: string; +} + +export const ChatRoom = ({ roomId }: ChatRoomProperties) => { + const [connectionState, setConnectionState] = useState("online"); + + useEffect(() => { + const handleOnline = () => setConnectionState("online"); + window.addEventListener("online", handleOnline); + return () => window.removeEventListener("online", handleOnline); + }, []); + + return ( +

+ {roomId}: {connectionState} +

+ ); +}; diff --git a/packages/prover/tests/fixtures/proved-chat/tsconfig.json b/packages/prover/tests/fixtures/proved-chat/tsconfig.json new file mode 100644 index 0000000000..6aa7dd406e --- /dev/null +++ b/packages/prover/tests/fixtures/proved-chat/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "strict": true, + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "noEmit": true, + "lib": ["ESNext", "DOM"] + }, + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-cleanup-callback-prop/src/app.tsx b/packages/prover/tests/fixtures/proved-cleanup-callback-prop/src/app.tsx new file mode 100644 index 0000000000..7ee6bb5e68 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-cleanup-callback-prop/src/app.tsx @@ -0,0 +1,15 @@ +import { useEffect } from "react"; + +interface SubscriptionProperties { + unsubscribe: () => void; +} + +const Subscription = ({ unsubscribe }: SubscriptionProperties) => { + useEffect(() => () => unsubscribe(), [unsubscribe]); + return null; +}; + +export const Application = () => { + const unsubscribe = () => undefined; + return ; +}; diff --git a/packages/prover/tests/fixtures/proved-cleanup-callback-prop/tsconfig.json b/packages/prover/tests/fixtures/proved-cleanup-callback-prop/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-cleanup-callback-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/proved-conditional-handler-factory/src/app.tsx b/packages/prover/tests/fixtures/proved-conditional-handler-factory/src/app.tsx new file mode 100644 index 0000000000..04d1ec797b --- /dev/null +++ b/packages/prover/tests/fixtures/proved-conditional-handler-factory/src/app.tsx @@ -0,0 +1,19 @@ +const chooseHandler = ( + isPrimary: boolean, + primaryHandler: () => void, + secondaryHandler: () => void, +) => { + if (isPrimary) return primaryHandler; + return secondaryHandler; +}; + +export const Application = () => { + const primaryHandler = () => undefined; + const secondaryHandler = () => undefined; + const handleClick = chooseHandler(true, primaryHandler, secondaryHandler); + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/proved-conditional-handler-factory/tsconfig.json b/packages/prover/tests/fixtures/proved-conditional-handler-factory/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-conditional-handler-factory/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-context-identity/src/app.tsx b/packages/prover/tests/fixtures/proved-context-identity/src/app.tsx new file mode 100644 index 0000000000..6031a33d4a --- /dev/null +++ b/packages/prover/tests/fixtures/proved-context-identity/src/app.tsx @@ -0,0 +1,15 @@ +import { createContext, useContext } from "react"; + +const ProviderContext = createContext("provider-default"); +const ConsumerContext = createContext("consumer-default"); + +const Consumer = () => { + const value = useContext(ConsumerContext); + return {value}; +}; + +export const App = () => ( + + + +); diff --git a/packages/prover/tests/fixtures/proved-context-identity/tsconfig.json b/packages/prover/tests/fixtures/proved-context-identity/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-context-identity/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-context-topology/src/app.tsx b/packages/prover/tests/fixtures/proved-context-topology/src/app.tsx new file mode 100644 index 0000000000..93dd2de4b6 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-context-topology/src/app.tsx @@ -0,0 +1,15 @@ +import { ThemeContext } from "./theme-context.js"; +import { ThemeLabel } from "./theme-label.js"; + +const InnerTheme = () => ( + + + +); + +export const App = () => ( + + + + +); diff --git a/packages/prover/tests/fixtures/proved-context-topology/src/theme-context.ts b/packages/prover/tests/fixtures/proved-context-topology/src/theme-context.ts new file mode 100644 index 0000000000..bb7fe07406 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-context-topology/src/theme-context.ts @@ -0,0 +1,3 @@ +import { createContext as createReactContext } from "react"; + +export const ThemeContext = createReactContext("default"); diff --git a/packages/prover/tests/fixtures/proved-context-topology/src/theme-label.tsx b/packages/prover/tests/fixtures/proved-context-topology/src/theme-label.tsx new file mode 100644 index 0000000000..c2ce3ac617 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-context-topology/src/theme-label.tsx @@ -0,0 +1,9 @@ +import { use as readReactValue } from "react"; +import { ThemeContext } from "./theme-context.js"; + +export const useTheme = () => readReactValue(ThemeContext); + +export const ThemeLabel = () => { + const theme = useTheme(); + return {theme}; +}; diff --git a/packages/prover/tests/fixtures/proved-context-topology/tsconfig.json b/packages/prover/tests/fixtures/proved-context-topology/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-context-topology/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-context/src/app.tsx b/packages/prover/tests/fixtures/proved-context/src/app.tsx new file mode 100644 index 0000000000..443825ee01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-context/src/app.tsx @@ -0,0 +1,8 @@ +import { createContext, useContext } from "react"; + +const ThemeContext = createContext("system"); + +export const ThemeLabel = () => { + const theme = useContext(ThemeContext); + return

{theme}

; +}; diff --git a/packages/prover/tests/fixtures/proved-context/tsconfig.json b/packages/prover/tests/fixtures/proved-context/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-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/proved-custom-hook/src/app.tsx b/packages/prover/tests/fixtures/proved-custom-hook/src/app.tsx new file mode 100644 index 0000000000..b79db74d98 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-custom-hook/src/app.tsx @@ -0,0 +1,6 @@ +import { useCounter } from "./use-counter.js"; + +export const Counter = () => { + const count = useCounter(); + return

{count}

; +}; diff --git a/packages/prover/tests/fixtures/proved-custom-hook/src/use-counter.ts b/packages/prover/tests/fixtures/proved-custom-hook/src/use-counter.ts new file mode 100644 index 0000000000..9156486ef8 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-custom-hook/src/use-counter.ts @@ -0,0 +1,6 @@ +import { useState } from "react"; + +export const useCounter = () => { + const [count] = useState(0); + return count; +}; diff --git a/packages/prover/tests/fixtures/proved-custom-hook/tsconfig.json b/packages/prover/tests/fixtures/proved-custom-hook/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-custom-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/proved-default-component/src/app.tsx b/packages/prover/tests/fixtures/proved-default-component/src/app.tsx new file mode 100644 index 0000000000..0f8d7a1f1e --- /dev/null +++ b/packages/prover/tests/fixtures/proved-default-component/src/app.tsx @@ -0,0 +1 @@ +export default () =>
Ready
; diff --git a/packages/prover/tests/fixtures/proved-default-component/tsconfig.json b/packages/prover/tests/fixtures/proved-default-component/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-default-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/proved-effect-callback-prop/src/app.tsx b/packages/prover/tests/fixtures/proved-effect-callback-prop/src/app.tsx new file mode 100644 index 0000000000..ce92484241 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-effect-callback-prop/src/app.tsx @@ -0,0 +1,17 @@ +import { useEffect } from "react"; + +interface SynchronizerProperties { + synchronize: () => void; +} + +const Synchronizer = ({ synchronize }: SynchronizerProperties) => { + useEffect(() => { + synchronize(); + }, [synchronize]); + return null; +}; + +export const Application = () => { + const synchronize = () => undefined; + return ; +}; diff --git a/packages/prover/tests/fixtures/proved-effect-callback-prop/tsconfig.json b/packages/prover/tests/fixtures/proved-effect-callback-prop/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-effect-callback-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/proved-effect-event/src/app.tsx b/packages/prover/tests/fixtures/proved-effect-event/src/app.tsx new file mode 100644 index 0000000000..37d41b7eae --- /dev/null +++ b/packages/prover/tests/fixtures/proved-effect-event/src/app.tsx @@ -0,0 +1,23 @@ +import { useEffect, useEffectEvent, useState } from "react"; + +const normalizePosition = (position: number) => Math.max(0, position); + +export const PointerTracker = () => { + const [canMove, setCanMove] = useState(true); + const [position, setPosition] = useState(0); + const onMove = useEffectEvent((event: PointerEvent) => { + if (canMove) setPosition(normalizePosition(event.clientX)); + }); + + useEffect(() => { + const installPointerListener = () => window.addEventListener("pointermove", onMove); + installPointerListener(); + return () => window.removeEventListener("pointermove", onMove); + }, []); + + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/proved-effect-event/tsconfig.json b/packages/prover/tests/fixtures/proved-effect-event/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-effect-event/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-event-callback-parameter/src/app.tsx b/packages/prover/tests/fixtures/proved-event-callback-parameter/src/app.tsx new file mode 100644 index 0000000000..5aa1e09e7f --- /dev/null +++ b/packages/prover/tests/fixtures/proved-event-callback-parameter/src/app.tsx @@ -0,0 +1,14 @@ +import { useState } from "react"; + +const invokeCallback = (callback: () => void) => callback(); + +export const Counter = () => { + const [count, setCount] = useState(0); + const updateCount = () => setCount((previousCount) => previousCount + 1); + const handleClick = () => invokeCallback(updateCount); + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/proved-event-callback-parameter/tsconfig.json b/packages/prover/tests/fixtures/proved-event-callback-parameter/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-event-callback-parameter/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-event-prop-flow/src/app.tsx b/packages/prover/tests/fixtures/proved-event-prop-flow/src/app.tsx new file mode 100644 index 0000000000..27c543ca67 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-event-prop-flow/src/app.tsx @@ -0,0 +1,22 @@ +import { useState } from "react"; + +interface ActionButtonProperties { + onActivate: () => void; +} + +const ActionButton = ({ onActivate: handleActivate }: ActionButtonProperties) => ( + +); + +export const Counter = () => { + const [count, setCount] = useState(0); + const increment = () => setCount((previousCount) => previousCount + 1); + return ( +
+ {count} + +
+ ); +}; diff --git a/packages/prover/tests/fixtures/proved-event-prop-flow/tsconfig.json b/packages/prover/tests/fixtures/proved-event-prop-flow/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-event-prop-flow/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-event-prop-wrapper/src/app.tsx b/packages/prover/tests/fixtures/proved-event-prop-wrapper/src/app.tsx new file mode 100644 index 0000000000..81c6e31661 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-event-prop-wrapper/src/app.tsx @@ -0,0 +1,17 @@ +interface ActionButtonProperties { + onActivate: () => void; +} + +const ActionButton = ({ onActivate }: ActionButtonProperties) => { + const handleClick = () => onActivate(); + return ( + + ); +}; + +export const Application = () => { + const recordActivation = () => undefined; + return ; +}; diff --git a/packages/prover/tests/fixtures/proved-event-prop-wrapper/tsconfig.json b/packages/prover/tests/fixtures/proved-event-prop-wrapper/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-event-prop-wrapper/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-external-store-callback-props/src/app.tsx b/packages/prover/tests/fixtures/proved-external-store-callback-props/src/app.tsx new file mode 100644 index 0000000000..9bf0dc53ff --- /dev/null +++ b/packages/prover/tests/fixtures/proved-external-store-callback-props/src/app.tsx @@ -0,0 +1,26 @@ +import { useSyncExternalStore } from "react"; + +let version = 0; +const listeners = new Set<() => void>(); + +interface StoreReaderProperties { + subscribe: (listener: () => void) => () => void; + getSnapshot: () => number; + getServerSnapshot: () => number; +} + +const StoreReader = ({ subscribe, getSnapshot, getServerSnapshot }: StoreReaderProperties) => { + const currentVersion = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + return {currentVersion}; +}; + +export const Application = () => { + const subscribe = (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }; + const getSnapshot = () => version; + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/proved-external-store-callback-props/tsconfig.json b/packages/prover/tests/fixtures/proved-external-store-callback-props/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-external-store-callback-props/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-external-store-conditional-factory/src/app.tsx b/packages/prover/tests/fixtures/proved-external-store-conditional-factory/src/app.tsx new file mode 100644 index 0000000000..56bf577a96 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-external-store-conditional-factory/src/app.tsx @@ -0,0 +1,61 @@ +import { useSyncExternalStore } from "react"; + +let primaryVersion = 0; +let secondaryVersion = 0; +const primaryListeners = new Set<() => void>(); +const secondaryListeners = new Set<() => void>(); + +export const updatePrimaryStore = () => { + primaryVersion += 1; + for (const listener of primaryListeners) listener(); +}; + +export const updateSecondaryStore = () => { + secondaryVersion += 1; + for (const listener of secondaryListeners) listener(); +}; + +interface StoreReaderProperties { + subscribe: (listener: () => void) => () => void; + getSnapshot: () => number; + getServerSnapshot: () => number; +} + +interface ApplicationProperties { + useSecondaryStore: boolean; +} + +const selectCallback = ( + condition: boolean, + whenTrue: Callback, + whenFalse: Callback, +): Callback => (condition ? whenTrue : whenFalse); + +const StoreReader = ({ subscribe, getSnapshot, getServerSnapshot }: StoreReaderProperties) => { + const version = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + return {version}; +}; + +export const Application = ({ useSecondaryStore }: ApplicationProperties) => { + const subscribeToPrimary = (listener: () => void) => { + primaryListeners.add(listener); + return () => primaryListeners.delete(listener); + }; + const subscribeToSecondary = (listener: () => void) => { + secondaryListeners.add(listener); + return () => secondaryListeners.delete(listener); + }; + const getPrimarySnapshot = () => primaryVersion; + const getSecondarySnapshot = () => secondaryVersion; + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/proved-external-store-conditional-factory/tsconfig.json b/packages/prover/tests/fixtures/proved-external-store-conditional-factory/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-external-store-conditional-factory/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-external-store-conditional-props/src/app.tsx b/packages/prover/tests/fixtures/proved-external-store-conditional-props/src/app.tsx new file mode 100644 index 0000000000..ddb3d09260 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-external-store-conditional-props/src/app.tsx @@ -0,0 +1,51 @@ +import { useSyncExternalStore } from "react"; + +let primaryVersion = 0; +let secondaryVersion = 0; +const primaryListeners = new Set<() => void>(); +const secondaryListeners = new Set<() => void>(); + +export const updatePrimaryStore = () => { + primaryVersion += 1; + for (const listener of primaryListeners) listener(); +}; + +export const updateSecondaryStore = () => { + secondaryVersion += 1; + for (const listener of secondaryListeners) listener(); +}; + +interface StoreReaderProperties { + subscribe: (listener: () => void) => () => void; + getSnapshot: () => number; + getServerSnapshot: () => number; +} + +interface ApplicationProperties { + useSecondaryStore: boolean; +} + +const StoreReader = ({ subscribe, getSnapshot, getServerSnapshot }: StoreReaderProperties) => { + const version = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + return {version}; +}; + +export const Application = ({ useSecondaryStore }: ApplicationProperties) => { + const subscribeToPrimary = (listener: () => void) => { + primaryListeners.add(listener); + return () => primaryListeners.delete(listener); + }; + const subscribeToSecondary = (listener: () => void) => { + secondaryListeners.add(listener); + return () => secondaryListeners.delete(listener); + }; + const getPrimarySnapshot = () => primaryVersion; + const getSecondarySnapshot = () => secondaryVersion; + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/proved-external-store-conditional-props/tsconfig.json b/packages/prover/tests/fixtures/proved-external-store-conditional-props/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-external-store-conditional-props/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-external-store-render-branch-props/src/app.tsx b/packages/prover/tests/fixtures/proved-external-store-render-branch-props/src/app.tsx new file mode 100644 index 0000000000..c657e7bee8 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-external-store-render-branch-props/src/app.tsx @@ -0,0 +1,60 @@ +import { useSyncExternalStore } from "react"; + +let primaryVersion = 0; +let secondaryVersion = 0; +const primaryListeners = new Set<() => void>(); +const secondaryListeners = new Set<() => void>(); + +export const updatePrimaryStore = () => { + primaryVersion += 1; + for (const listener of primaryListeners) listener(); +}; + +export const updateSecondaryStore = () => { + secondaryVersion += 1; + for (const listener of secondaryListeners) listener(); +}; + +interface StoreReaderProperties { + subscribe: (listener: () => void) => () => void; + getSnapshot: () => number; + getServerSnapshot: () => number; +} + +interface ApplicationProperties { + useSecondaryStore: boolean; +} + +const StoreReader = ({ subscribe, getSnapshot, getServerSnapshot }: StoreReaderProperties) => { + const version = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + return {version}; +}; + +export const Application = ({ useSecondaryStore }: ApplicationProperties) => { + const subscribeToPrimary = (listener: () => void) => { + primaryListeners.add(listener); + return () => primaryListeners.delete(listener); + }; + const subscribeToSecondary = (listener: () => void) => { + secondaryListeners.add(listener); + return () => secondaryListeners.delete(listener); + }; + const getPrimarySnapshot = () => primaryVersion; + const getSecondarySnapshot = () => secondaryVersion; + if (useSecondaryStore) { + return ( + + ); + } + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/proved-external-store-render-branch-props/tsconfig.json b/packages/prover/tests/fixtures/proved-external-store-render-branch-props/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-external-store-render-branch-props/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-external-store/src/app.tsx b/packages/prover/tests/fixtures/proved-external-store/src/app.tsx new file mode 100644 index 0000000000..23fe7b55d2 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-external-store/src/app.tsx @@ -0,0 +1,21 @@ +import { useSyncExternalStore } from "react"; + +let language = "en"; +const listeners = new Set<() => void>(); + +export const setLanguage = (nextLanguage: string) => { + language = nextLanguage; + for (const listener of listeners) listener(); +}; + +const subscribe = (listener: () => void) => { + listeners.add(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/proved-external-store/tsconfig.json b/packages/prover/tests/fixtures/proved-external-store/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-external-store/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-finally-overrides-handler/src/app.tsx b/packages/prover/tests/fixtures/proved-finally-overrides-handler/src/app.tsx new file mode 100644 index 0000000000..b0b33b0d82 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-finally-overrides-handler/src/app.tsx @@ -0,0 +1,13 @@ +const chooseRenderWork = () => { + try { + return () => console.log("overridden"); + } finally { + return () => undefined; + } +}; + +export const Application = () => { + const runRenderWork = chooseRenderWork(); + runRenderWork(); + return
Application
; +}; diff --git a/packages/prover/tests/fixtures/proved-finally-overrides-handler/tsconfig.json b/packages/prover/tests/fixtures/proved-finally-overrides-handler/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-finally-overrides-handler/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-for-of-handler-factory/src/app.tsx b/packages/prover/tests/fixtures/proved-for-of-handler-factory/src/app.tsx new file mode 100644 index 0000000000..81bef97143 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-for-of-handler-factory/src/app.tsx @@ -0,0 +1,27 @@ +interface HandlerOptions { + mode: "primary" | "secondary"; + primaryHandler: () => void; + secondaryHandler: () => void; +} + +const chooseHandler = (options: HandlerOptions) => { + for (const handler of [options.primaryHandler, options.secondaryHandler]) { + const selectedHandler = + options.mode === "primary" ? options.primaryHandler : options.secondaryHandler; + if (handler === selectedHandler) return handler; + } + return options.secondaryHandler; +}; + +export const Application = () => { + const handleClick = chooseHandler({ + mode: "primary", + primaryHandler: () => undefined, + secondaryHandler: () => undefined, + }); + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/proved-for-of-handler-factory/tsconfig.json b/packages/prover/tests/fixtures/proved-for-of-handler-factory/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-for-of-handler-factory/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-for-of-invoked-handlers/src/app.tsx b/packages/prover/tests/fixtures/proved-for-of-invoked-handlers/src/app.tsx new file mode 100644 index 0000000000..bf65c17611 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-for-of-invoked-handlers/src/app.tsx @@ -0,0 +1,15 @@ +const firstHandler = () => undefined; +const secondHandler = () => undefined; + +export const Application = () => { + const handleClick = () => { + for (const handler of [firstHandler, secondHandler]) { + handler(); + } + }; + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/proved-for-of-invoked-handlers/tsconfig.json b/packages/prover/tests/fixtures/proved-for-of-invoked-handlers/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-for-of-invoked-handlers/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-for-of-nested-binding-handler/src/app.tsx b/packages/prover/tests/fixtures/proved-for-of-nested-binding-handler/src/app.tsx new file mode 100644 index 0000000000..558aeb4b2a --- /dev/null +++ b/packages/prover/tests/fixtures/proved-for-of-nested-binding-handler/src/app.tsx @@ -0,0 +1,27 @@ +interface HandlerOptions { + mode: "primary" | "secondary"; + primaryHandler: () => void; + secondaryHandler: () => void; +} + +const chooseHandler = (options: HandlerOptions) => { + for (const { + callbacks: [primaryHandler, secondaryHandler], + } of [{ callbacks: [options.primaryHandler, options.secondaryHandler] }]) { + return options.mode === "primary" ? primaryHandler : secondaryHandler; + } + throw new Error("A fresh nonempty array must produce one iteration"); +}; + +export const Application = () => { + const handleClick = chooseHandler({ + mode: "primary", + primaryHandler: () => undefined, + secondaryHandler: () => undefined, + }); + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/proved-for-of-nested-binding-handler/tsconfig.json b/packages/prover/tests/fixtures/proved-for-of-nested-binding-handler/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-for-of-nested-binding-handler/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-for-of-object-binding-handler/src/app.tsx b/packages/prover/tests/fixtures/proved-for-of-object-binding-handler/src/app.tsx new file mode 100644 index 0000000000..2528093b9a --- /dev/null +++ b/packages/prover/tests/fixtures/proved-for-of-object-binding-handler/src/app.tsx @@ -0,0 +1,30 @@ +interface HandlerOptions { + mode: "primary" | "secondary"; + primaryHandler: () => void; + secondaryHandler: () => void; +} + +const chooseHandler = (options: HandlerOptions) => { + for (const { handler } of [ + { handler: options.primaryHandler }, + { handler: options.secondaryHandler }, + ]) { + const selectedHandler = + options.mode === "primary" ? options.primaryHandler : options.secondaryHandler; + if (handler === selectedHandler) return handler; + } + return options.secondaryHandler; +}; + +export const Application = () => { + const handleClick = chooseHandler({ + mode: "primary", + primaryHandler: () => undefined, + secondaryHandler: () => undefined, + }); + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/proved-for-of-object-binding-handler/tsconfig.json b/packages/prover/tests/fixtures/proved-for-of-object-binding-handler/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-for-of-object-binding-handler/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-for-of-tuple-binding-handler/src/app.tsx b/packages/prover/tests/fixtures/proved-for-of-tuple-binding-handler/src/app.tsx new file mode 100644 index 0000000000..d7c2c8ce69 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-for-of-tuple-binding-handler/src/app.tsx @@ -0,0 +1,27 @@ +interface HandlerOptions { + mode: "primary" | "secondary"; + primaryHandler: () => void; + secondaryHandler: () => void; +} + +const chooseHandler = (options: HandlerOptions) => { + for (const [primaryHandler, secondaryHandler] of [ + [options.primaryHandler, options.secondaryHandler], + ]) { + return options.mode === "primary" ? primaryHandler : secondaryHandler; + } + throw new Error("A fresh nonempty array must produce one iteration"); +}; + +export const Application = () => { + const handleClick = chooseHandler({ + mode: "primary", + primaryHandler: () => undefined, + secondaryHandler: () => undefined, + }); + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/proved-for-of-tuple-binding-handler/tsconfig.json b/packages/prover/tests/fixtures/proved-for-of-tuple-binding-handler/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-for-of-tuple-binding-handler/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-forwarded-event-prop/src/app.tsx b/packages/prover/tests/fixtures/proved-forwarded-event-prop/src/app.tsx new file mode 100644 index 0000000000..e20bd3b0c1 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-forwarded-event-prop/src/app.tsx @@ -0,0 +1,20 @@ +interface ActionButtonProperties { + action: () => void; +} + +const ActionButton = (properties: ActionButtonProperties) => ( + +); + +interface ToolbarProperties { + onRun: () => void; +} + +const Toolbar = ({ onRun }: ToolbarProperties) => ; + +export const Application = () => { + const recordRun = () => undefined; + return ; +}; diff --git a/packages/prover/tests/fixtures/proved-forwarded-event-prop/tsconfig.json b/packages/prover/tests/fixtures/proved-forwarded-event-prop/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-forwarded-event-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/proved-helper-effect-cleanup/src/app.tsx b/packages/prover/tests/fixtures/proved-helper-effect-cleanup/src/app.tsx new file mode 100644 index 0000000000..cea911305d --- /dev/null +++ b/packages/prover/tests/fixtures/proved-helper-effect-cleanup/src/app.tsx @@ -0,0 +1,22 @@ +import { useEffect } from "react"; + +const handleResize = () => {}; + +const installResizeListener = () => { + window.addEventListener("resize", handleResize); +}; + +const removeResizeListener = () => { + window.removeEventListener("resize", handleResize); +}; + +export const Sidebar = () => { + useEffect(() => { + installResizeListener(); + return () => { + removeResizeListener(); + }; + }, []); + + return null; +}; diff --git a/packages/prover/tests/fixtures/proved-helper-effect-cleanup/tsconfig.json b/packages/prover/tests/fixtures/proved-helper-effect-cleanup/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-helper-effect-cleanup/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-helper-local-rebinding/src/app.tsx b/packages/prover/tests/fixtures/proved-helper-local-rebinding/src/app.tsx new file mode 100644 index 0000000000..36e6995385 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-helper-local-rebinding/src/app.tsx @@ -0,0 +1,10 @@ +const computeRevision = () => { + let revision = 0; + revision += 1; + return revision; +}; + +export const Application = () => { + const revision = computeRevision(); + return
Revision {revision}
; +}; diff --git a/packages/prover/tests/fixtures/proved-helper-local-rebinding/tsconfig.json b/packages/prover/tests/fixtures/proved-helper-local-rebinding/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-helper-local-rebinding/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-local-graph/src/app.tsx b/packages/prover/tests/fixtures/proved-local-graph/src/app.tsx new file mode 100644 index 0000000000..a932bb14ab --- /dev/null +++ b/packages/prover/tests/fixtures/proved-local-graph/src/app.tsx @@ -0,0 +1,7 @@ +import { Header } from "./header.js"; + +export const App = () => ( +
+
+
+); diff --git a/packages/prover/tests/fixtures/proved-local-graph/src/format-title.ts b/packages/prover/tests/fixtures/proved-local-graph/src/format-title.ts new file mode 100644 index 0000000000..65b0ff7018 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-local-graph/src/format-title.ts @@ -0,0 +1 @@ +export const formatTitle = (title: string) => title.trim().toUpperCase(); diff --git a/packages/prover/tests/fixtures/proved-local-graph/src/header.tsx b/packages/prover/tests/fixtures/proved-local-graph/src/header.tsx new file mode 100644 index 0000000000..953950f45b --- /dev/null +++ b/packages/prover/tests/fixtures/proved-local-graph/src/header.tsx @@ -0,0 +1,7 @@ +import { formatTitle } from "./format-title.js"; + +interface HeaderProperties { + title: string; +} + +export const Header = ({ title }: HeaderProperties) =>

{formatTitle(title)}

; diff --git a/packages/prover/tests/fixtures/proved-local-graph/tsconfig.json b/packages/prover/tests/fixtures/proved-local-graph/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-local-graph/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-local-object-callback/src/app.tsx b/packages/prover/tests/fixtures/proved-local-object-callback/src/app.tsx new file mode 100644 index 0000000000..785ed22649 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-local-object-callback/src/app.tsx @@ -0,0 +1,18 @@ +interface CallbackRegistry { + activate: () => void; +} + +const invokeRegisteredCallback = (registry: CallbackRegistry) => registry.activate(); + +export const Application = () => { + const recordActivation = () => undefined; + const handleClick = () => { + const registry = { activate: recordActivation }; + invokeRegisteredCallback(registry); + }; + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/proved-local-object-callback/tsconfig.json b/packages/prover/tests/fixtures/proved-local-object-callback/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-local-object-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/proved-memo/src/app.tsx b/packages/prover/tests/fixtures/proved-memo/src/app.tsx new file mode 100644 index 0000000000..2488a9315e --- /dev/null +++ b/packages/prover/tests/fixtures/proved-memo/src/app.tsx @@ -0,0 +1,16 @@ +import { useCallback, useMemo, useState } from "react"; + +const doubleCount = (count: number) => count * 2; + +const getNextCount = (count: number) => count + 1; + +export const Counter = () => { + const [count, setCount] = useState(0); + const displayCount = useMemo(() => doubleCount(count), [count]); + const increment = useCallback(() => setCount(getNextCount(count)), [count]); + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/proved-memo/tsconfig.json b/packages/prover/tests/fixtures/proved-memo/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-memo/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-mixed-phase-callback-prop/src/app.tsx b/packages/prover/tests/fixtures/proved-mixed-phase-callback-prop/src/app.tsx new file mode 100644 index 0000000000..3a6bc87100 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-mixed-phase-callback-prop/src/app.tsx @@ -0,0 +1,22 @@ +import { useEffect } from "react"; + +interface ActionButtonProperties { + onActivate: () => void; +} + +const ActionButton = ({ onActivate }: ActionButtonProperties) => { + useEffect(() => { + onActivate(); + }, [onActivate]); + const handleClick = () => onActivate(); + return ( + + ); +}; + +export const Application = () => { + const recordActivation = () => undefined; + return ; +}; diff --git a/packages/prover/tests/fixtures/proved-mixed-phase-callback-prop/tsconfig.json b/packages/prover/tests/fixtures/proved-mixed-phase-callback-prop/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-mixed-phase-callback-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/proved-mount-state-update/src/app.tsx b/packages/prover/tests/fixtures/proved-mount-state-update/src/app.tsx new file mode 100644 index 0000000000..5b7f6125ae --- /dev/null +++ b/packages/prover/tests/fixtures/proved-mount-state-update/src/app.tsx @@ -0,0 +1,11 @@ +import { useEffect, useState } from "react"; + +export const Status = () => { + const [ready, setReady] = useState(false); + + useEffect(() => { + setReady(true); + }, []); + + return

{ready ? "ready" : "starting"}

; +}; diff --git a/packages/prover/tests/fixtures/proved-mount-state-update/tsconfig.json b/packages/prover/tests/fixtures/proved-mount-state-update/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-mount-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/proved-null-component/src/app.tsx b/packages/prover/tests/fixtures/proved-null-component/src/app.tsx new file mode 100644 index 0000000000..bfde9a10bd --- /dev/null +++ b/packages/prover/tests/fixtures/proved-null-component/src/app.tsx @@ -0,0 +1 @@ +export const EmptyState = () => null; diff --git a/packages/prover/tests/fixtures/proved-null-component/tsconfig.json b/packages/prover/tests/fixtures/proved-null-component/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-null-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/proved-object-callback-flow/src/app.tsx b/packages/prover/tests/fixtures/proved-object-callback-flow/src/app.tsx new file mode 100644 index 0000000000..8c99bcc239 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-object-callback-flow/src/app.tsx @@ -0,0 +1,18 @@ +import { useState } from "react"; + +interface CallbackOptions { + callback: () => void; +} + +const invokeCallback = (options: CallbackOptions) => options.callback(); + +export const Counter = () => { + const [count, setCount] = useState(0); + const increment = () => setCount((previousCount) => previousCount + 1); + const handleClick = () => invokeCallback({ callback: increment }); + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/proved-object-callback-flow/tsconfig.json b/packages/prover/tests/fixtures/proved-object-callback-flow/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-object-callback-flow/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-reducer/src/app.tsx b/packages/prover/tests/fixtures/proved-reducer/src/app.tsx new file mode 100644 index 0000000000..b65680e296 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-reducer/src/app.tsx @@ -0,0 +1,17 @@ +import { useReducer } from "react"; + +const incrementCount = (count: number) => count + 1; + +const reduceCount = (count: number, action: "increment" | "reset") => { + if (action === "increment") return incrementCount(count); + return 0; +}; + +export const Counter = () => { + const [count, dispatch] = useReducer(reduceCount, 0); + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/proved-reducer/tsconfig.json b/packages/prover/tests/fixtures/proved-reducer/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-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/proved-returned-event-handler/src/app.tsx b/packages/prover/tests/fixtures/proved-returned-event-handler/src/app.tsx new file mode 100644 index 0000000000..24b564983c --- /dev/null +++ b/packages/prover/tests/fixtures/proved-returned-event-handler/src/app.tsx @@ -0,0 +1,20 @@ +import { useState } from "react"; + +interface KeyboardEventLike { + key: string; +} + +const whenEnter = (callback: () => void) => (event: KeyboardEventLike) => { + if (event.key === "Enter") callback(); +}; + +export const Counter = () => { + const [count, setCount] = useState(0); + const increment = () => setCount((previousCount) => previousCount + 1); + const handleKeyDown = whenEnter(increment); + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/proved-returned-event-handler/tsconfig.json b/packages/prover/tests/fixtures/proved-returned-event-handler/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-returned-event-handler/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-returned-use-callback-hook/src/app.tsx b/packages/prover/tests/fixtures/proved-returned-use-callback-hook/src/app.tsx new file mode 100644 index 0000000000..163c1bdb97 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-returned-use-callback-hook/src/app.tsx @@ -0,0 +1,13 @@ +import { useCallback } from "react"; + +const useGuardedCallback = (callback: () => void) => useCallback(() => callback(), [callback]); + +export const Application = () => { + const recordActivation = () => undefined; + const handleClick = useGuardedCallback(recordActivation); + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/proved-returned-use-callback-hook/tsconfig.json b/packages/prover/tests/fixtures/proved-returned-use-callback-hook/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-returned-use-callback-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/proved-shared-event-handler/src/app.tsx b/packages/prover/tests/fixtures/proved-shared-event-handler/src/app.tsx new file mode 100644 index 0000000000..e0a5c650ef --- /dev/null +++ b/packages/prover/tests/fixtures/proved-shared-event-handler/src/app.tsx @@ -0,0 +1,13 @@ +const handleClick = () => {}; + +export const PrimaryButton = () => ( + +); + +export const SecondaryButton = () => ( + +); diff --git a/packages/prover/tests/fixtures/proved-shared-event-handler/tsconfig.json b/packages/prover/tests/fixtures/proved-shared-event-handler/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-shared-event-handler/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-static-list-keys/src/app.tsx b/packages/prover/tests/fixtures/proved-static-list-keys/src/app.tsx new file mode 100644 index 0000000000..0ffd72b5c8 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-static-list-keys/src/app.tsx @@ -0,0 +1 @@ +export const List = () =>
    {[
  • First
  • ,
  • Second
  • ]}
; diff --git a/packages/prover/tests/fixtures/proved-static-list-keys/tsconfig.json b/packages/prover/tests/fixtures/proved-static-list-keys/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-static-list-keys/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-switch-handler-factory/src/app.tsx b/packages/prover/tests/fixtures/proved-switch-handler-factory/src/app.tsx new file mode 100644 index 0000000000..8f13a5b0bc --- /dev/null +++ b/packages/prover/tests/fixtures/proved-switch-handler-factory/src/app.tsx @@ -0,0 +1,27 @@ +interface HandlerOptions { + mode: "primary" | "secondary"; + primaryHandler: () => void; + secondaryHandler: () => void; +} + +const chooseHandler = (options: HandlerOptions) => { + switch (options.mode) { + case "primary": + return options.primaryHandler; + case "secondary": + return options.secondaryHandler; + } +}; + +export const Application = () => { + const handleClick = chooseHandler({ + mode: "primary", + primaryHandler: () => undefined, + secondaryHandler: () => undefined, + }); + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/proved-switch-handler-factory/tsconfig.json b/packages/prover/tests/fixtures/proved-switch-handler-factory/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-switch-handler-factory/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-timer/src/app.tsx b/packages/prover/tests/fixtures/proved-timer/src/app.tsx new file mode 100644 index 0000000000..664dc75fe3 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-timer/src/app.tsx @@ -0,0 +1,12 @@ +import { useEffect, useState } from "react"; + +export const Clock = () => { + const [ticks, setTicks] = useState(0); + + useEffect(() => { + const timerId = setInterval(() => setTicks((previousTicks) => previousTicks + 1), 1000); + return () => clearInterval(timerId); + }, []); + + return

{ticks}

; +}; diff --git a/packages/prover/tests/fixtures/proved-timer/tsconfig.json b/packages/prover/tests/fixtures/proved-timer/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-timer/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-transitive-event-prop-wrapper/src/app.tsx b/packages/prover/tests/fixtures/proved-transitive-event-prop-wrapper/src/app.tsx new file mode 100644 index 0000000000..f5cf7370d8 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-transitive-event-prop-wrapper/src/app.tsx @@ -0,0 +1,31 @@ +import { useState } from "react"; + +interface ActionButtonProperties { + onActivate: () => void; +} + +const ActionButton = (properties: ActionButtonProperties) => { + const invokeAction = () => properties.onActivate(); + const handleClick = () => invokeAction(); + return ( + + ); +}; + +interface ApplicationProperties { + usePrimaryAction: boolean; +} + +export const Application = ({ usePrimaryAction }: ApplicationProperties) => { + const [activationCount, setActivationCount] = useState(0); + const recordPrimaryAction = () => setActivationCount((previousCount) => previousCount + 1); + const recordSecondaryAction = () => setActivationCount(0); + return ( +
+ {activationCount} + +
+ ); +}; diff --git a/packages/prover/tests/fixtures/proved-transitive-event-prop-wrapper/tsconfig.json b/packages/prover/tests/fixtures/proved-transitive-event-prop-wrapper/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-transitive-event-prop-wrapper/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-try-catch-handler-factory/src/app.tsx b/packages/prover/tests/fixtures/proved-try-catch-handler-factory/src/app.tsx new file mode 100644 index 0000000000..59a5b781cc --- /dev/null +++ b/packages/prover/tests/fixtures/proved-try-catch-handler-factory/src/app.tsx @@ -0,0 +1,24 @@ +interface HandlerOptions { + primaryHandler: () => void; + fallbackHandler: () => void; +} + +const chooseHandler = (options: HandlerOptions) => { + try { + return options.primaryHandler; + } catch { + return options.fallbackHandler; + } +}; + +export const Application = () => { + const handleClick = chooseHandler({ + primaryHandler: () => undefined, + fallbackHandler: () => undefined, + }); + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/proved-try-catch-handler-factory/tsconfig.json b/packages/prover/tests/fixtures/proved-try-catch-handler-factory/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-try-catch-handler-factory/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-while-handler-factory/src/app.tsx b/packages/prover/tests/fixtures/proved-while-handler-factory/src/app.tsx new file mode 100644 index 0000000000..06a42d39d1 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-while-handler-factory/src/app.tsx @@ -0,0 +1,23 @@ +interface HandlerOptions { + preferPrimary: boolean; + primaryHandler: () => void; + secondaryHandler: () => void; +} + +const chooseHandler = (options: HandlerOptions) => { + while (options.preferPrimary) return options.primaryHandler; + return options.secondaryHandler; +}; + +export const Application = () => { + const handleClick = chooseHandler({ + preferPrimary: false, + primaryHandler: () => undefined, + secondaryHandler: () => undefined, + }); + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/proved-while-handler-factory/tsconfig.json b/packages/prover/tests/fixtures/proved-while-handler-factory/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-while-handler-factory/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-wrapped-component/src/app.tsx b/packages/prover/tests/fixtures/proved-wrapped-component/src/app.tsx new file mode 100644 index 0000000000..4a4c1c6b94 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-wrapped-component/src/app.tsx @@ -0,0 +1,6 @@ +import { memo, useState } from "react"; + +export const MemoCounter = memo(() => { + const [count] = useState(0); + return

{count}

; +}); diff --git a/packages/prover/tests/fixtures/proved-wrapped-component/tsconfig.json b/packages/prover/tests/fixtures/proved-wrapped-component/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-wrapped-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/react-shim.d.ts b/packages/prover/tests/fixtures/react-shim.d.ts new file mode 100644 index 0000000000..b3db838115 --- /dev/null +++ b/packages/prover/tests/fixtures/react-shim.d.ts @@ -0,0 +1,72 @@ +declare module "react" { + export interface ChangeEvent { + currentTarget: Target; + } + + export interface Context { + Provider: (properties: { value?: Value; children?: unknown }) => null; + } + + export interface MutableRefObject { + current: Value; + } + + export const useCallback: ) => unknown>( + callback: Value, + dependencies: ReadonlyArray, + ) => Value; + export interface Use { + (usable: PromiseLike): Value; + (usable: Context): Value; + } + + export const use: Use; + export const createContext: (defaultValue: Value) => Context; + export const memo: (component: Component) => Component; + export const useEffect: ( + setup: () => void | (() => void), + dependencies?: ReadonlyArray, + ) => void; + export const useEffectEvent: unknown>( + callback: Callback, + ) => Callback; + export const useLayoutEffect: typeof useEffect; + export const useMemo: ( + factory: () => Value, + dependencies: ReadonlyArray, + ) => Value; + export const useRef: (initialValue: Value) => MutableRefObject; + export const useContext: (context: Context) => Value; + export const useReducer: ( + reducer: (state: State, action: Action) => State, + initialState: State, + ) => [State, (action: Action) => void]; + export const useState: ( + initialValue: Value | (() => Value), + ) => [Value, (nextValue: Value | ((previousValue: Value) => Value)) => void]; + export const useSyncExternalStore: ( + subscribe: (onStoreChange: () => void) => () => void, + getSnapshot: () => Snapshot, + getServerSnapshot?: () => Snapshot, + ) => Snapshot; + + export class Component> { + props: Properties; + } +} + +declare module "react/jsx-runtime" { + export const Fragment: unknown; + export const jsx: (...argumentsList: ReadonlyArray) => unknown; + export const jsxs: (...argumentsList: ReadonlyArray) => unknown; +} + +declare namespace JSX { + interface IntrinsicAttributes { + key?: string | number; + } + + interface IntrinsicElements { + [elementName: string]: Record; + } +} diff --git a/packages/prover/tests/fixtures/render-callback-parameter-impurity/src/app.tsx b/packages/prover/tests/fixtures/render-callback-parameter-impurity/src/app.tsx new file mode 100644 index 0000000000..03e805ee04 --- /dev/null +++ b/packages/prover/tests/fixtures/render-callback-parameter-impurity/src/app.tsx @@ -0,0 +1,11 @@ +const invokeCallback = (callback: () => number) => callback(); + +const readCurrentValue = () => { + console.log("reading current value"); + return 1; +}; + +export const App = () => { + const value = invokeCallback(readCurrentValue); + return

{value}

; +}; diff --git a/packages/prover/tests/fixtures/render-callback-parameter-impurity/tsconfig.json b/packages/prover/tests/fixtures/render-callback-parameter-impurity/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/render-callback-parameter-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/render-ref-access/src/app.tsx b/packages/prover/tests/fixtures/render-ref-access/src/app.tsx new file mode 100644 index 0000000000..99d92a6366 --- /dev/null +++ b/packages/prover/tests/fixtures/render-ref-access/src/app.tsx @@ -0,0 +1,7 @@ +import { useRef } from "react"; + +export const Counter = () => { + const renderCount = useRef(0); + renderCount.current += 1; + return

{renderCount.current}

; +}; diff --git a/packages/prover/tests/fixtures/render-ref-access/tsconfig.json b/packages/prover/tests/fixtures/render-ref-access/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/render-ref-access/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/render-returned-callback-impurity/src/app.tsx b/packages/prover/tests/fixtures/render-returned-callback-impurity/src/app.tsx new file mode 100644 index 0000000000..e0de6bee5b --- /dev/null +++ b/packages/prover/tests/fixtures/render-returned-callback-impurity/src/app.tsx @@ -0,0 +1,9 @@ +const createRenderWork = (callback: () => void) => () => callback(); + +const recordCurrentValue = () => console.log("render"); + +export const Application = () => { + const runRenderWork = createRenderWork(recordCurrentValue); + runRenderWork(); + return
Application
; +}; diff --git a/packages/prover/tests/fixtures/render-returned-callback-impurity/tsconfig.json b/packages/prover/tests/fixtures/render-returned-callback-impurity/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/render-returned-callback-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/silent-external-store-render-branch-props/src/app.tsx b/packages/prover/tests/fixtures/silent-external-store-render-branch-props/src/app.tsx new file mode 100644 index 0000000000..3ecfe466fa --- /dev/null +++ b/packages/prover/tests/fixtures/silent-external-store-render-branch-props/src/app.tsx @@ -0,0 +1,46 @@ +import { useSyncExternalStore } from "react"; + +let primaryVersion = 0; +let secondaryVersion = 0; +const primaryListeners = new Set<() => void>(); +const secondaryListeners = new Set<() => void>(); + +export const updatePrimaryStore = () => { + primaryVersion += 1; + for (const listener of primaryListeners) listener(); +}; + +export const updateSecondaryStore = () => { + secondaryVersion += 1; +}; + +interface StoreReaderProperties { + subscribe: (listener: () => void) => () => void; + getSnapshot: () => number; +} + +interface ApplicationProperties { + useSecondaryStore: boolean; +} + +const StoreReader = ({ subscribe, getSnapshot }: StoreReaderProperties) => { + const version = useSyncExternalStore(subscribe, getSnapshot); + return {version}; +}; + +export const Application = ({ useSecondaryStore }: ApplicationProperties) => { + const subscribeToPrimary = (listener: () => void) => { + primaryListeners.add(listener); + return () => primaryListeners.delete(listener); + }; + const subscribeToSecondary = (listener: () => void) => { + secondaryListeners.add(listener); + return () => secondaryListeners.delete(listener); + }; + const getPrimarySnapshot = () => primaryVersion; + const getSecondarySnapshot = () => secondaryVersion; + if (useSecondaryStore) { + return ; + } + return ; +}; diff --git a/packages/prover/tests/fixtures/silent-external-store-render-branch-props/tsconfig.json b/packages/prover/tests/fixtures/silent-external-store-render-branch-props/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/silent-external-store-render-branch-props/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/silent-external-store-write/src/app.tsx b/packages/prover/tests/fixtures/silent-external-store-write/src/app.tsx new file mode 100644 index 0000000000..e3bbbfb907 --- /dev/null +++ b/packages/prover/tests/fixtures/silent-external-store-write/src/app.tsx @@ -0,0 +1,18 @@ +import { useSyncExternalStore } from "react"; + +let language = "en"; +const listeners = new Set<() => void>(); + +export const setLanguage = (nextLanguage: string) => { + language = nextLanguage; +}; + +const subscribe = (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); +}; + +export const Language = () => { + const currentLanguage = useSyncExternalStore(subscribe, () => language); + return

{currentLanguage}

; +}; diff --git a/packages/prover/tests/fixtures/silent-external-store-write/tsconfig.json b/packages/prover/tests/fixtures/silent-external-store-write/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/silent-external-store-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/stale-effect/src/app.tsx b/packages/prover/tests/fixtures/stale-effect/src/app.tsx new file mode 100644 index 0000000000..8efc01dd9c --- /dev/null +++ b/packages/prover/tests/fixtures/stale-effect/src/app.tsx @@ -0,0 +1,12 @@ +import { useEffect } from "react"; + +interface DocumentTitleProperties { + title: string; +} + +export const DocumentTitle = ({ title }: DocumentTitleProperties) => { + useEffect(() => { + document.title = title; + }, []); + return

{title}

; +}; diff --git a/packages/prover/tests/fixtures/stale-effect/tsconfig.json b/packages/prover/tests/fixtures/stale-effect/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/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/state-update-in-render/src/app.tsx b/packages/prover/tests/fixtures/state-update-in-render/src/app.tsx new file mode 100644 index 0000000000..c6c6c960b9 --- /dev/null +++ b/packages/prover/tests/fixtures/state-update-in-render/src/app.tsx @@ -0,0 +1,7 @@ +import { useState } from "react"; + +export const Counter = () => { + const [count, setCount] = useState(0); + setCount(count + 1); + return

{count}

; +}; diff --git a/packages/prover/tests/fixtures/state-update-in-render/tsconfig.json b/packages/prover/tests/fixtures/state-update-in-render/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/state-update-in-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/switch-returned-render-impurity/src/app.tsx b/packages/prover/tests/fixtures/switch-returned-render-impurity/src/app.tsx new file mode 100644 index 0000000000..6fb077b550 --- /dev/null +++ b/packages/prover/tests/fixtures/switch-returned-render-impurity/src/app.tsx @@ -0,0 +1,18 @@ +interface RenderWorkOptions { + mode: "safe" | "impure"; +} + +const chooseRenderWork = (options: RenderWorkOptions) => { + switch (options.mode) { + case "safe": + return () => undefined; + case "impure": + return () => console.log("render"); + } +}; + +export const Application = () => { + const runRenderWork = chooseRenderWork({ mode: "safe" }); + runRenderWork(); + return
Application
; +}; diff --git a/packages/prover/tests/fixtures/switch-returned-render-impurity/tsconfig.json b/packages/prover/tests/fixtures/switch-returned-render-impurity/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/switch-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/timer-leak/src/app.tsx b/packages/prover/tests/fixtures/timer-leak/src/app.tsx new file mode 100644 index 0000000000..c75759697a --- /dev/null +++ b/packages/prover/tests/fixtures/timer-leak/src/app.tsx @@ -0,0 +1,9 @@ +import { useEffect } from "react"; + +export const Poller = () => { + useEffect(() => { + setInterval(() => undefined, 1000); + }, []); + + return

Polling

; +}; diff --git a/packages/prover/tests/fixtures/timer-leak/tsconfig.json b/packages/prover/tests/fixtures/timer-leak/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/timer-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/transitive-impure-helper/src/app.tsx b/packages/prover/tests/fixtures/transitive-impure-helper/src/app.tsx new file mode 100644 index 0000000000..00ffb45066 --- /dev/null +++ b/packages/prover/tests/fixtures/transitive-impure-helper/src/app.tsx @@ -0,0 +1,6 @@ +import { createId } from "./create-id.js"; + +export const Form = () => { + const formId = createId(); + return
; +}; diff --git a/packages/prover/tests/fixtures/transitive-impure-helper/src/create-id.ts b/packages/prover/tests/fixtures/transitive-impure-helper/src/create-id.ts new file mode 100644 index 0000000000..136be6ad35 --- /dev/null +++ b/packages/prover/tests/fixtures/transitive-impure-helper/src/create-id.ts @@ -0,0 +1 @@ +export const createId = () => crypto.randomUUID(); diff --git a/packages/prover/tests/fixtures/transitive-impure-helper/tsconfig.json b/packages/prover/tests/fixtures/transitive-impure-helper/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/transitive-impure-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/try-catch-returned-render-impurity/src/app.tsx b/packages/prover/tests/fixtures/try-catch-returned-render-impurity/src/app.tsx new file mode 100644 index 0000000000..8b920924c4 --- /dev/null +++ b/packages/prover/tests/fixtures/try-catch-returned-render-impurity/src/app.tsx @@ -0,0 +1,13 @@ +const chooseRenderWork = () => { + try { + return () => undefined; + } catch { + return () => console.log("render"); + } +}; + +export const Application = () => { + const runRenderWork = chooseRenderWork(); + runRenderWork(); + return
Application
; +}; diff --git a/packages/prover/tests/fixtures/try-catch-returned-render-impurity/tsconfig.json b/packages/prover/tests/fixtures/try-catch-returned-render-impurity/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/try-catch-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/unsafe-types/src/app.tsx b/packages/prover/tests/fixtures/unsafe-types/src/app.tsx new file mode 100644 index 0000000000..277e42446f --- /dev/null +++ b/packages/prover/tests/fixtures/unsafe-types/src/app.tsx @@ -0,0 +1,8 @@ +interface GreetingProperties { + value: unknown; +} + +export const Greeting = ({ value }: GreetingProperties) => { + const name = value as string; + return

Hello {name}

; +}; diff --git a/packages/prover/tests/fixtures/unsafe-types/tsconfig.json b/packages/prover/tests/fixtures/unsafe-types/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/unsafe-types/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/use-in-try/src/app.tsx b/packages/prover/tests/fixtures/use-in-try/src/app.tsx new file mode 100644 index 0000000000..19405d490b --- /dev/null +++ b/packages/prover/tests/fixtures/use-in-try/src/app.tsx @@ -0,0 +1,14 @@ +import { use } from "react"; + +interface MessageProperties { + resource: PromiseLike; +} + +export const Message = ({ resource }: MessageProperties) => { + try { + const message = use(resource); + return

{message}

; + } catch { + return null; + } +}; diff --git a/packages/prover/tests/fixtures/use-in-try/tsconfig.json b/packages/prover/tests/fixtures/use-in-try/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/use-in-try/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/while-returned-render-impurity/src/app.tsx b/packages/prover/tests/fixtures/while-returned-render-impurity/src/app.tsx new file mode 100644 index 0000000000..0331735f06 --- /dev/null +++ b/packages/prover/tests/fixtures/while-returned-render-impurity/src/app.tsx @@ -0,0 +1,14 @@ +interface ApplicationProps { + useImpureWork: boolean; +} + +const chooseRenderWork = (useImpureWork: boolean) => { + while (useImpureWork) return () => console.log("render"); + return () => undefined; +}; + +export const Application = ({ useImpureWork }: ApplicationProps) => { + const runRenderWork = chooseRenderWork(useImpureWork); + runRenderWork(); + return
Application
; +}; diff --git a/packages/prover/tests/fixtures/while-returned-render-impurity/tsconfig.json b/packages/prover/tests/fixtures/while-returned-render-impurity/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/while-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/prove-react-app.test.ts b/packages/prover/tests/prove-react-app.test.ts new file mode 100644 index 0000000000..79d1b66823 --- /dev/null +++ b/packages/prover/tests/prove-react-app.test.ts @@ -0,0 +1,1848 @@ +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vite-plus/test"; +import { + checkReactProofReport, + proveReactApp, + ReactAppProofStatus, + ReactAsyncOwnershipStatus, + ReactCompilerFactStatus, + ReactEffectDependencyMode, + ReactExecutionPhase, + ReactIdentityStability, + ReactObligationStatus, + ReactProofClaim, + ReactProofCertificateStatus, + ReactSemanticEdgeKind, + ReactSemanticCallbackKind, + ReactSemanticFunctionCallKind, +} from "../src/index.js"; + +interface RefutedFixtureExpectation { + fixtureName: string; + claim: ReactProofClaim; + evidencePattern: RegExp; +} + +const fixturesDirectory = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures"); + +const proveFixture = (fixtureName: string) => + proveReactApp({ + rootDirectory: path.join(fixturesDirectory, fixtureName), + }); + +const REFUTED_FIXTURES: ReadonlyArray = [ + { + fixtureName: "conditional-hook", + claim: ReactProofClaim.HookOrder, + evidencePattern: /invariant hook position/, + }, + { + fixtureName: "stale-effect", + claim: ReactProofClaim.EffectDependencies, + evidencePattern: /absent from the effect dependency list/, + }, + { + fixtureName: "impure-render", + claim: ReactProofClaim.RenderPurity, + evidencePattern: /not pure during render/, + }, + { + fixtureName: "cleanup-mismatch", + claim: ReactProofClaim.EffectCleanup, + evidencePattern: /same resource identity/, + }, + { + fixtureName: "nested-component", + claim: ReactProofClaim.ComponentIdentity, + evidencePattern: /recreated as a component type/, + }, + { + fixtureName: "direct-component-call", + claim: ReactProofClaim.ComponentInvocation, + evidencePattern: /called as a regular function/, + }, + { + fixtureName: "render-ref-access", + claim: ReactProofClaim.RefAccess, + evidencePattern: /accessed during render/, + }, + { + fixtureName: "coreui-listener-leak", + claim: ReactProofClaim.EffectCleanup, + evidencePattern: /same resource identity/, + }, + { + fixtureName: "state-update-in-render", + claim: ReactProofClaim.RenderPurity, + evidencePattern: /updates state during render/, + }, + { + fixtureName: "prop-mutation", + claim: ReactProofClaim.RenderPurity, + evidencePattern: /mutates an input during render/, + }, + { + fixtureName: "helper-aliased-prop-mutation", + claim: ReactProofClaim.RenderPurity, + evidencePattern: /mutated during render/, + }, + { + fixtureName: "transitive-impure-helper", + claim: ReactProofClaim.RenderPurity, + evidencePattern: /not pure during render/, + }, + { + fixtureName: "render-callback-parameter-impurity", + claim: ReactProofClaim.RenderPurity, + evidencePattern: /not pure during render/, + }, + { + fixtureName: "render-returned-callback-impurity", + claim: ReactProofClaim.RenderPurity, + evidencePattern: /not pure during render/, + }, + { + fixtureName: "timer-leak", + claim: ReactProofClaim.EffectCleanup, + evidencePattern: /same resource identity/, + }, + { + fixtureName: "invalid-hook-helper", + claim: ReactProofClaim.HookOwnership, + evidencePattern: /outside a component or custom hook/, + }, + { + fixtureName: "module-hook-call", + claim: ReactProofClaim.HookOwnership, + evidencePattern: /outside a component or custom hook/, + }, + { + fixtureName: "anonymous-hook-callback", + claim: ReactProofClaim.HookOwnership, + evidencePattern: /outside a component or custom hook/, + }, + { + fixtureName: "memo-callback", + claim: ReactProofClaim.MemoDependencies, + evidencePattern: /absent from the useCallback dependency list/, + }, + { + fixtureName: "impure-reducer", + claim: ReactProofClaim.ReducerPurity, + evidencePattern: /not pure during render/, + }, + { + fixtureName: "named-memo-impure-helper", + claim: ReactProofClaim.RenderPurity, + evidencePattern: /not pure during render/, + }, + { + fixtureName: "aliased-stale-effect", + claim: ReactProofClaim.EffectDependencies, + evidencePattern: /absent from the effect dependency list/, + }, + { + fixtureName: "use-in-try", + claim: ReactProofClaim.HookOrder, + evidencePattern: /cannot be called from a try or catch block/, + }, + { + fixtureName: "missing-list-key", + claim: ReactProofClaim.ReconciliationIdentity, + evidencePattern: /no reconciliation key/, + }, + { + fixtureName: "duplicate-list-key", + claim: ReactProofClaim.ReconciliationIdentity, + evidencePattern: /duplicated/, + }, + { + fixtureName: "effect-self-cycle", + claim: ReactProofClaim.EffectStateUpdates, + evidencePattern: /necessarily changes a dependency/, + }, + { + fixtureName: "fresh-external-store-snapshot", + claim: ReactProofClaim.ExternalStoreConsistency, + evidencePattern: /fresh or missing value/, + }, + { + fixtureName: "silent-external-store-write", + claim: ReactProofClaim.ExternalStoreConsistency, + evidencePattern: /without notifying/, + }, + { + fixtureName: "mismatched-server-snapshot", + claim: ReactProofClaim.ExternalStoreConsistency, + evidencePattern: /different initial data/, + }, + { + fixtureName: "external-store-cleanup-mismatch", + claim: ReactProofClaim.ExternalStoreConsistency, + evidencePattern: /without symmetric deletion/, + }, + { + fixtureName: "effect-event-dependency", + claim: ReactProofClaim.EffectEventUsage, + evidencePattern: /intentionally unstable identity/, + }, + { + fixtureName: "effect-event-render-call", + claim: ReactProofClaim.EffectEventUsage, + evidencePattern: /outside an Effect or Effect Event/, + }, + { + fixtureName: "effect-event-prop-escape", + claim: ReactProofClaim.EffectEventUsage, + evidencePattern: /outside an Effect or Effect Event/, + }, + { + fixtureName: "effect-event-hook-escape", + claim: ReactProofClaim.EffectEventUsage, + evidencePattern: /outside an Effect or Effect Event/, + }, + { + fixtureName: "effect-event-shared-helper", + claim: ReactProofClaim.EffectEventUsage, + evidencePattern: /outside an Effect or Effect Event/, + }, + { + fixtureName: "context-provider-missing-value", + claim: ReactProofClaim.ContextTopology, + evidencePattern: /without a value/, + }, + { + fixtureName: "async-effect-stale-write", + claim: ReactProofClaim.AsyncEffectOwnership, + evidencePattern: /superseded/, + }, + { + fixtureName: "async-effect-promise-chain", + claim: ReactProofClaim.AsyncEffectOwnership, + evidencePattern: /superseded/, + }, + { + fixtureName: "helper-effect-listener-leak", + claim: ReactProofClaim.EffectCleanup, + evidencePattern: /has no cleanup/, + }, + { + fixtureName: "method-effect-listener-leak", + claim: ReactProofClaim.EffectCleanup, + evidencePattern: /has no cleanup/, + }, + { + fixtureName: "callback-parameter-effect-listener-leak", + claim: ReactProofClaim.EffectCleanup, + evidencePattern: /has no cleanup/, + }, + { + fixtureName: "object-callback-effect-listener-leak", + claim: ReactProofClaim.EffectCleanup, + evidencePattern: /has no cleanup/, + }, + { + fixtureName: "branch-returned-render-impurity", + claim: ReactProofClaim.RenderPurity, + evidencePattern: /not pure during render/, + }, + { + fixtureName: "switch-returned-render-impurity", + claim: ReactProofClaim.RenderPurity, + evidencePattern: /not pure during render/, + }, + { + fixtureName: "try-catch-returned-render-impurity", + claim: ReactProofClaim.RenderPurity, + evidencePattern: /not pure during render/, + }, + { + fixtureName: "finally-returned-render-impurity", + claim: ReactProofClaim.RenderPurity, + evidencePattern: /not pure during render/, + }, + { + fixtureName: "while-returned-render-impurity", + claim: ReactProofClaim.RenderPurity, + evidencePattern: /not pure during render/, + }, + { + fixtureName: "for-of-returned-render-impurity", + claim: ReactProofClaim.RenderPurity, + evidencePattern: /not pure during render/, + }, + { + fixtureName: "for-of-invoked-render-impurity", + claim: ReactProofClaim.RenderPurity, + evidencePattern: /not pure during render/, + }, + { + fixtureName: "for-of-destructured-render-impurity", + claim: ReactProofClaim.RenderPurity, + evidencePattern: /not pure during render/, + }, +]; + +describe("proveReactApp", () => { + it("proves a closed React app with complete hook, render, and effect evidence", () => { + const report = proveFixture("proved-chat"); + + expect(report.status).toBe(ReactAppProofStatus.Proved); + expect(report.projectEvidence).toEqual([]); + expect(report.summary.units).toBe(1); + expect(report.summary.violated).toBe(0); + expect(report.summary.unknown).toBe(0); + expect( + report.units[0]?.obligations.every( + (obligation) => obligation.status === ReactObligationStatus.Proved, + ), + ).toBe(true); + }); + + it.each([ + "proved-local-graph", + "proved-timer", + "proved-custom-hook", + "proved-cfg", + "proved-memo", + "proved-reducer", + "proved-context", + "proved-wrapped-component", + "proved-null-component", + "proved-default-component", + "proved-aliased-hook", + "proved-static-list-keys", + "proved-mount-state-update", + "proved-external-store", + "proved-effect-event", + "proved-helper-effect-cleanup", + "proved-shared-event-handler", + "proved-event-callback-parameter", + "proved-event-prop-flow", + "proved-forwarded-event-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", + ])("proves the complete %s application graph", (fixtureName) => { + const report = proveFixture(fixtureName); + + expect(report.status).toBe(ReactAppProofStatus.Proved); + expect(report.graph.compiler.status).toBe(ReactCompilerFactStatus.Complete); + expect(report.summary.violated).toBe(0); + expect(report.summary.unknown).toBe(0); + }); + + it("links component rendering across module imports", () => { + const report = proveFixture("proved-local-graph"); + const appUnit = report.graph.units.find((unit) => unit.name === "App"); + const headerUnit = report.graph.units.find((unit) => unit.name === "Header"); + + expect( + report.graph.edges.some( + (edge) => + edge.kind === ReactSemanticEdgeKind.RendersComponent && + edge.sourceId === appUnit?.id && + edge.targetId === headerUnit?.id, + ), + ).toBe(true); + }); + + it("links custom hooks while preserving React builtin hook targets", () => { + const report = proveFixture("proved-custom-hook"); + const counterUnit = report.graph.units.find((unit) => unit.name === "Counter"); + const counterHookUnit = report.graph.units.find((unit) => unit.name === "useCounter"); + + expect( + report.graph.hookCalls.some( + (hookCall) => + hookCall.ownerId === counterUnit?.id && + hookCall.name === "useCounter" && + hookCall.targetId === counterHookUnit?.id, + ), + ).toBe(true); + expect( + report.graph.hookCalls.some( + (hookCall) => + hookCall.ownerId === counterHookUnit?.id && + hookCall.name === "useState" && + hookCall.targetId === "react:useState", + ), + ).toBe(true); + }); + + it("records the canonical React API behind an imported alias", () => { + const report = proveFixture("proved-aliased-hook"); + + expect(report.graph.hookCalls[0]?.name).toBe("useState"); + expect(report.graph.hookCalls[0]?.targetId).toBe("react:useState"); + }); + + it("extracts effect dependency, capture, and cleanup facts", () => { + const report = proveFixture("proved-chat"); + const effect = report.graph.effects[0]; + + expect(report.graph.schemaVersion).toBe(14); + expect(effect?.hookName).toBe("useEffect"); + expect(effect?.callbackResolved).toBe(true); + expect(effect?.dependencyMode).toBe(ReactEffectDependencyMode.Inline); + expect(effect?.dependencies).toEqual([]); + expect(effect?.captures).toEqual([]); + expect(effect?.hasCleanup).toBe(true); + expect( + report.graph.callbacks.find((callback) => callback.id === effect?.setupCallbackId)?.phase, + ).toBe(ReactExecutionPhase.EffectSetup); + expect( + report.graph.callbacks.find((callback) => callback.id === effect?.cleanupCallbackIds[0]) + ?.phase, + ).toBe(ReactExecutionPhase.EffectCleanup); + }); + + it("assigns memo, event, and reducer callbacks to execution phases", () => { + const memoReport = proveFixture("proved-memo"); + const reducerReport = proveFixture("proved-reducer"); + + expect( + memoReport.graph.callbacks.some( + (callback) => + callback.kind === ReactSemanticCallbackKind.MemoizedCallback && + callback.phase === ReactExecutionPhase.Deferred, + ), + ).toBe(true); + expect( + memoReport.graph.callbacks.some( + (callback) => + callback.kind === ReactSemanticCallbackKind.EventHandler && + callback.phase === ReactExecutionPhase.Event, + ), + ).toBe(true); + expect( + reducerReport.graph.callbacks.some( + (callback) => + callback.kind === ReactSemanticCallbackKind.Reducer && + callback.phase === ReactExecutionPhase.StateTransition, + ), + ).toBe(true); + }); + + it("propagates React execution phases through project helpers", () => { + const memoReport = proveFixture("proved-memo"); + const reducerReport = proveFixture("proved-reducer"); + const effectEventReport = proveFixture("proved-effect-event"); + const memoHelpers = memoReport.graph.reachableFunctions.filter( + (reachableFunction) => reachableFunction.name === "doubleCount", + ); + const deferredHelpers = memoReport.graph.reachableFunctions.filter( + (reachableFunction) => reachableFunction.name === "getNextCount", + ); + const reducerHelper = reducerReport.graph.reachableFunctions.find( + (reachableFunction) => reachableFunction.name === "incrementCount", + ); + const effectEventHelper = effectEventReport.graph.reachableFunctions.find( + (reachableFunction) => reachableFunction.name === "installPointerListener", + ); + const effectEventCallbackHelper = effectEventReport.graph.reachableFunctions.find( + (reachableFunction) => reachableFunction.name === "normalizePosition", + ); + + expect(memoHelpers.some((helper) => helper.phase === ReactExecutionPhase.Render)).toBe(true); + expect(deferredHelpers.some((helper) => helper.phase === ReactExecutionPhase.Deferred)).toBe( + true, + ); + expect(deferredHelpers.some((helper) => helper.phase === ReactExecutionPhase.Event)).toBe(true); + expect(reducerHelper?.phase).toBe(ReactExecutionPhase.StateTransition); + expect(effectEventHelper?.phase).toBe(ReactExecutionPhase.EffectSetup); + expect(effectEventCallbackHelper?.phase).toBe(ReactExecutionPhase.EffectEvent); + }); + + it("links external-store callbacks to render, server-render, and subscription phases", () => { + const report = proveFixture("proved-external-store"); + const hydrationReport = proveFixture("mismatched-server-snapshot"); + const externalStore = report.graph.externalStores[0]; + const hydrationStore = hydrationReport.graph.externalStores[0]; + + expect(externalStore).toBeDefined(); + expect( + report.graph.callbacks.find( + (callback) => callback.id === externalStore?.subscribeCallbackIds[0], + )?.phase, + ).toBe(ReactExecutionPhase.ExternalStoreSubscription); + expect( + report.graph.callbacks.find( + (callback) => callback.id === externalStore?.snapshotCallbackIds[0], + )?.phase, + ).toBe(ReactExecutionPhase.Render); + expect(externalStore?.serverSnapshotCallbackIds).toEqual([]); + expect(externalStore?.serverSnapshotProvided).toBe(false); + expect( + hydrationReport.graph.callbacks.find( + (callback) => callback.id === hydrationStore?.serverSnapshotCallbackIds[0], + )?.phase, + ).toBe(ReactExecutionPhase.ServerRender); + }); + + it("proves external-store callback props across all protocol phases", () => { + const report = proveFixture("proved-external-store-callback-props"); + const externalStore = report.graph.externalStores[0]; + const propFlowPhases = report.graph.callbackPropFlows.map((propFlow) => propFlow.phase); + + expect(report.status).toBe(ReactAppProofStatus.Proved); + expect(externalStore?.subscribeComplete).toBe(true); + expect(externalStore?.snapshotComplete).toBe(true); + expect(externalStore?.serverSnapshotComplete).toBe(true); + expect(externalStore?.subscribeCallbackIds).toHaveLength(1); + expect(externalStore?.snapshotCallbackIds).toHaveLength(1); + expect(externalStore?.serverSnapshotCallbackIds).toHaveLength(1); + expect(propFlowPhases).toEqual( + expect.arrayContaining([ + ReactExecutionPhase.ExternalStoreSubscription, + ReactExecutionPhase.Render, + ReactExecutionPhase.ServerRender, + ]), + ); + expect(checkReactProofReport(report).status).toBe(ReactProofCertificateStatus.Valid); + }); + + it.each([ + ["fresh-external-store-callback-prop-snapshot", /fresh or missing value/], + ["mismatched-external-store-callback-prop-server-snapshot", /different initial data/], + ])("refutes an invalid external-store callback-prop protocol in %s", (fixtureName, evidence) => { + const report = proveFixture(fixtureName); + const consistencyProof = report.units + .flatMap((unit) => unit.obligations) + .find( + (obligation) => + obligation.claim === ReactProofClaim.ExternalStoreConsistency && + obligation.status === ReactObligationStatus.Violated, + ); + + expect(report.status).toBe(ReactAppProofStatus.Refuted); + expect( + consistencyProof?.evidence.some((proofEvidence) => evidence.test(proofEvidence.description)), + ).toBe(true); + }); + + it("fails closed when external-store callback props cross a JSX spread", () => { + const report = proveFixture("incomplete-external-store-callback-prop-spread"); + const externalStore = report.graph.externalStores[0]; + + expect(report.status).toBe(ReactAppProofStatus.Incomplete); + expect(externalStore?.subscribeComplete).toBe(false); + expect(externalStore?.snapshotComplete).toBe(false); + }); + + it("fails closed when intra-attribute external-store callbacks use different guards", () => { + const report = proveFixture("incomplete-external-store-callback-prop-conditional-join"); + const externalStore = report.graph.externalStores[0]; + const consistencyProof = report.units + .flatMap((unit) => unit.obligations) + .find((obligation) => obligation.claim === ReactProofClaim.ExternalStoreConsistency); + + expect(report.status).toBe(ReactAppProofStatus.Incomplete); + expect(externalStore?.subscribeComplete).toBe(true); + expect(externalStore?.subscribeCallbackIds).toHaveLength(2); + expect(consistencyProof?.status).toBe(ReactObligationStatus.Unknown); + }); + + it("fails closed when a callback factory receives different scalar guards", () => { + const report = proveFixture("incomplete-external-store-conditional-factory"); + const consistencyProof = report.units + .flatMap((unit) => unit.obligations) + .find((obligation) => obligation.claim === ReactProofClaim.ExternalStoreConsistency); + const guardIds = new Set( + report.graph.callbackPropFlows.flatMap((propFlow) => + propFlow.alternatives.flatMap((alternative) => alternative.guards.map((guard) => guard.id)), + ), + ); + + expect(report.status).toBe(ReactAppProofStatus.Incomplete); + expect(consistencyProof?.status).toBe(ReactObligationStatus.Unknown); + expect( + report.graph.callbackPropFlows + .flatMap((propFlow) => propFlow.alternatives) + .every((alternative) => alternative.guards.length === 1), + ).toBe(true); + expect(guardIds.size).toBe(2); + }); + + it("fails closed when a callback guard is written between JSX attributes", () => { + const report = proveFixture("incomplete-external-store-mutated-conditional-props"); + const consistencyProof = report.units + .flatMap((unit) => unit.obligations) + .find((obligation) => obligation.claim === ReactProofClaim.ExternalStoreConsistency); + + expect(report.status).toBe(ReactAppProofStatus.Incomplete); + expect(consistencyProof?.status).toBe(ReactObligationStatus.Unknown); + expect( + report.graph.callbackPropFlows + .flatMap((propFlow) => propFlow.alternatives) + .every((alternative) => alternative.guards.length === 0), + ).toBe(true); + }); + + it("proves a callback factory whose scalar guard is substituted from one caller symbol", () => { + const report = proveFixture("proved-external-store-conditional-factory"); + const guardIds = new Set( + report.graph.callbackPropFlows.flatMap((propFlow) => + propFlow.alternatives.flatMap((alternative) => alternative.guards.map((guard) => guard.id)), + ), + ); + + expect(report.status).toBe(ReactAppProofStatus.Proved); + expect(guardIds.size).toBe(1); + expect(checkReactProofReport(report).status).toBe(ReactProofCertificateStatus.Valid); + }); + + it("refutes crossed callback-factory results under one substituted scalar guard", () => { + const report = proveFixture("mismatched-external-store-conditional-factory"); + const consistencyProof = report.units + .flatMap((unit) => unit.obligations) + .find( + (obligation) => + obligation.claim === ReactProofClaim.ExternalStoreConsistency && + obligation.status === ReactObligationStatus.Violated, + ); + + expect(report.status).toBe(ReactAppProofStatus.Refuted); + expect( + consistencyProof?.evidence.some((evidence) => + evidence.description.includes("changes without notifying"), + ), + ).toBe(true); + }); + + it("proves intra-attribute external-store callbacks selected by the same guard", () => { + const report = proveFixture("proved-external-store-conditional-props"); + const guardedFlows = report.graph.callbackPropFlows.filter((propFlow) => + propFlow.alternatives.some((alternative) => alternative.guards.length > 0), + ); + const guardIds = new Set( + guardedFlows.flatMap((propFlow) => + propFlow.alternatives.flatMap((alternative) => alternative.guards.map((guard) => guard.id)), + ), + ); + + expect(report.status).toBe(ReactAppProofStatus.Proved); + expect(guardedFlows).toHaveLength(3); + expect(guardedFlows.every((propFlow) => propFlow.alternatives.length === 2)).toBe(true); + expect(guardIds.size).toBe(1); + expect(checkReactProofReport(report).status).toBe(ReactProofCertificateStatus.Valid); + }); + + it("refutes crossed external-store callbacks selected by the same guard", () => { + const report = proveFixture("mismatched-external-store-conditional-props"); + const consistencyProof = report.units + .flatMap((unit) => unit.obligations) + .find( + (obligation) => + obligation.claim === ReactProofClaim.ExternalStoreConsistency && + obligation.status === ReactObligationStatus.Violated, + ); + + expect(report.status).toBe(ReactAppProofStatus.Refuted); + expect( + consistencyProof?.evidence.some((evidence) => + evidence.description.includes("changes without notifying"), + ), + ).toBe(true); + }); + + it("proves separately correlated external-store render branches", () => { + const report = proveFixture("proved-external-store-render-branch-props"); + const externalStoreFlows = report.graph.callbackPropFlows.filter( + (propFlow) => + propFlow.phase === ReactExecutionPhase.ExternalStoreSubscription || + propFlow.phase === ReactExecutionPhase.Render || + propFlow.phase === ReactExecutionPhase.ServerRender, + ); + const renderIds = new Set(externalStoreFlows.map((propFlow) => propFlow.renderId)); + + expect(report.status).toBe(ReactAppProofStatus.Proved); + expect(renderIds.size).toBe(2); + expect(externalStoreFlows).toHaveLength(6); + expect(externalStoreFlows.every((propFlow) => propFlow.complete)).toBe(true); + }); + + it("refutes a silent store write in one correlated render branch", () => { + const report = proveFixture("silent-external-store-render-branch-props"); + const consistencyProof = report.units + .flatMap((unit) => unit.obligations) + .find( + (obligation) => + obligation.claim === ReactProofClaim.ExternalStoreConsistency && + obligation.status === ReactObligationStatus.Violated, + ); + + expect(report.status).toBe(ReactAppProofStatus.Refuted); + expect( + consistencyProof?.evidence.some((evidence) => + evidence.description.includes("secondaryVersion changes without notifying"), + ), + ).toBe(true); + }); + + it("resolves context sources through exact identity and nearest-provider render paths", () => { + const report = proveFixture("proved-context-topology"); + const context = report.graph.contexts[0]; + const outerProvider = report.graph.contextProviders.find( + (provider) => provider.valueText === '"outer"', + ); + const innerProvider = report.graph.contextProviders.find( + (provider) => provider.valueText === '"inner"', + ); + const consumerSources = report.graph.contextConsumers.flatMap( + (consumer) => consumer.sourceProviderIds, + ); + + expect(report.status).toBe(ReactAppProofStatus.Proved); + expect(context?.defaultValueText).toBe('"default"'); + expect(consumerSources).toContain(outerProvider?.id); + expect(consumerSources).toContain(innerProvider?.id); + expect(report.graph.contextConsumers.every((consumer) => consumer.topologyComplete)).toBe(true); + }); + + it("keeps distinct createContext calls as distinct runtime identities", () => { + const report = proveFixture("proved-context-identity"); + const consumer = report.graph.contextConsumers[0]; + + expect(report.status).toBe(ReactAppProofStatus.Proved); + expect(report.graph.contexts).toHaveLength(2); + expect(consumer?.usesDefaultValue).toBe(true); + expect(consumer?.sourceProviderIds).toEqual([]); + }); + + it("fails closed when a library context has no project proof contract", () => { + const report = proveFixture("external-context"); + const contextProof = report.units + .flatMap((unit) => unit.obligations) + .find((obligation) => obligation.claim === ReactProofClaim.ContextTopology); + + expect(report.status).toBe(ReactAppProofStatus.Incomplete); + expect(contextProof?.status).toBe(ReactObligationStatus.Unknown); + expect(contextProof?.evidence[0]?.description).toMatch(/could not be resolved/); + }); + + it("fails closed when an async Effect uses an opaque latest-task guard", () => { + const report = proveFixture("async-effect-opaque-guard"); + const ownershipProof = report.units + .flatMap((unit) => unit.obligations) + .find((obligation) => obligation.claim === ReactProofClaim.AsyncEffectOwnership); + + expect(report.status).toBe(ReactAppProofStatus.Incomplete); + expect(ownershipProof?.status).toBe(ReactObligationStatus.Unknown); + expect(ownershipProof?.evidence[0]?.description).toMatch(/unmodeled ownership guard/); + }); + + it("fails closed when a Promise continuation callback has no project summary", () => { + const report = proveFixture("async-effect-opaque-continuation"); + const ownershipProof = report.units + .flatMap((unit) => unit.obligations) + .find((obligation) => obligation.claim === ReactProofClaim.AsyncEffectOwnership); + + expect(report.status).toBe(ReactAppProofStatus.Incomplete); + expect(ownershipProof?.status).toBe(ReactObligationStatus.Unknown); + expect(ownershipProof?.evidence[0]?.description).toMatch(/no checked React ownership summary/); + }); + + it("fails closed for an unclassified mutation after an async suspension", () => { + const report = proveFixture("async-effect-post-await-mutation"); + const ownershipProof = report.units + .flatMap((unit) => unit.obligations) + .find((obligation) => obligation.claim === ReactProofClaim.AsyncEffectOwnership); + + expect(report.status).toBe(ReactAppProofStatus.Incomplete); + expect(ownershipProof?.status).toBe(ReactObligationStatus.Unknown); + expect(ownershipProof?.evidence[0]?.description).toMatch(/no checked React ownership summary/); + }); + + it("requires every cleanup path to invalidate an async task", () => { + const report = proveFixture("async-effect-path-dependent-invalidation"); + const ownershipProof = report.units + .flatMap((unit) => unit.obligations) + .find((obligation) => obligation.claim === ReactProofClaim.AsyncEffectOwnership); + + expect(report.status).toBe(ReactAppProofStatus.Incomplete); + expect(ownershipProof?.status).toBe(ReactObligationStatus.Unknown); + expect(ownershipProof?.evidence[0]?.description).toMatch(/unmodeled ownership guard/); + }); + + it("follows project helpers when checking Effect state transitions", () => { + const report = proveFixture("helper-effect-state-update"); + const stateUpdateProof = report.units + .flatMap((unit) => unit.obligations) + .find((obligation) => obligation.claim === ReactProofClaim.EffectStateUpdates); + + expect(report.status).toBe(ReactAppProofStatus.Incomplete); + expect(stateUpdateProof?.status).toBe(ReactObligationStatus.Unknown); + expect(stateUpdateProof?.evidence[0]?.description).toMatch(/fixpoint proof/); + }); + + it("records async Effect ownership and links the task to its source Effect", () => { + const safeReport = proveFixture("incomplete-async-effect-ignore-contract"); + const unsafeReport = proveFixture("async-effect-stale-write"); + const safeTask = safeReport.graph.asyncTasks[0]; + const unsafeTask = unsafeReport.graph.asyncTasks[0]; + + expect(safeTask?.ownershipStatus).toBe(ReactAsyncOwnershipStatus.Guarded); + expect(safeTask?.stateWrites).toEqual(["setResult"]); + expect(safeReport.graph.effects.some((effect) => effect.id === safeTask?.effectId)).toBe(true); + expect(unsafeTask?.ownershipStatus).toBe(ReactAsyncOwnershipStatus.Unguarded); + }); + + it.each([ + "incomplete-async-effect-ignore-contract", + "incomplete-async-effect-abort-contract", + "incomplete-async-effect-promise-ignore-contract", + ])("keeps guarded async ownership separate from the opaque %s loader contract", (fixtureName) => { + const report = proveFixture(fixtureName); + const ownershipProof = report.units + .flatMap((unit) => unit.obligations) + .find((obligation) => obligation.claim === ReactProofClaim.AsyncEffectOwnership); + const boundaryProof = report.units + .flatMap((unit) => unit.obligations) + .find((obligation) => obligation.claim === ReactProofClaim.BoundaryCoverage); + + expect(report.status).toBe(ReactAppProofStatus.Incomplete); + expect(ownershipProof?.status).toBe(ReactObligationStatus.Proved); + expect(boundaryProof?.status).toBe(ReactObligationStatus.Unknown); + }); + + it("records project helpers under their React execution phases", () => { + const report = proveFixture("proved-helper-effect-cleanup"); + const setupHelper = report.graph.reachableFunctions.find( + (reachableFunction) => reachableFunction.name === "installResizeListener", + ); + const cleanupHelper = report.graph.reachableFunctions.find( + (reachableFunction) => reachableFunction.name === "removeResizeListener", + ); + + expect(setupHelper?.phase).toBe(ReactExecutionPhase.EffectSetup); + expect(cleanupHelper?.phase).toBe(ReactExecutionPhase.EffectCleanup); + expect(setupHelper?.isConditionallyReached).toBe(false); + expect( + report.graph.callbacks.some((callback) => callback.id === setupHelper?.rootCallbackId), + ).toBe(true); + }); + + it("records conditional reachability through an Effect helper call", () => { + const report = proveFixture("conditional-helper-effect-cleanup"); + const setupHelper = report.graph.reachableFunctions.find( + (reachableFunction) => reachableFunction.name === "installResizeListener", + ); + const cleanupProof = report.units + .flatMap((unit) => unit.obligations) + .find((obligation) => obligation.claim === ReactProofClaim.EffectCleanup); + + expect(report.status).toBe(ReactAppProofStatus.Incomplete); + expect(setupHelper?.isConditionallyReached).toBe(true); + expect(cleanupProof?.status).toBe(ReactObligationStatus.Unknown); + }); + + it("keeps the strongest reachability fact when a helper has multiple paths", () => { + const report = proveFixture("event-handler-boundary"); + const incrementHelper = report.graph.reachableFunctions.find( + (reachableFunction) => + reachableFunction.name === "increment" && + reachableFunction.phase === ReactExecutionPhase.Event, + ); + + expect(incrementHelper?.isConditionallyReached).toBe(false); + }); + + it("scopes a shared callback identity to each owning React unit", () => { + const report = proveFixture("proved-shared-event-handler"); + const callbackIds = report.graph.callbacks.map((callback) => callback.id); + const eventCallbacks = report.graph.callbacks.filter( + (callback) => callback.kind === ReactSemanticCallbackKind.EventHandler, + ); + + expect(report.status).toBe(ReactAppProofStatus.Proved); + expect(eventCallbacks).toHaveLength(2); + expect(new Set(callbackIds).size).toBe(callbackIds.length); + }); + + it("propagates render and event phases through a synchronous list callback", () => { + const report = proveFixture("mapped-event-handler"); + const renderCallback = report.graph.callbacks.find( + (callback) => callback.kind === ReactSemanticCallbackKind.ComponentRender, + ); + const renderIteration = report.graph.reachableFunctions.find( + (reachableFunction) => + reachableFunction.rootCallbackId === renderCallback?.id && + reachableFunction.phase === ReactExecutionPhase.Render, + ); + const eventHelper = report.graph.reachableFunctions.find( + (reachableFunction) => + reachableFunction.name === "selectItem" && + reachableFunction.phase === ReactExecutionPhase.Event, + ); + + expect(report.status).toBe(ReactAppProofStatus.Incomplete); + expect(renderCallback?.phase).toBe(ReactExecutionPhase.Render); + expect(renderIteration?.isConditionallyReached).toBe(true); + expect(eventHelper).toBeDefined(); + expect( + report.graph.functionCalls.some( + (functionCall) => + functionCall.kind === ReactSemanticFunctionCallKind.SynchronousCallback && + functionCall.phase === ReactExecutionPhase.Render, + ), + ).toBe(true); + }); + + it("binds a source callback argument to an invoked helper parameter", () => { + const report = proveFixture("proved-event-callback-parameter"); + const updateCount = report.graph.reachableFunctions.find( + (reachableFunction) => + reachableFunction.name === "updateCount" && + reachableFunction.phase === ReactExecutionPhase.Event, + ); + const parameterCall = report.graph.functionCalls.find( + (functionCall) => + functionCall.targetFunctionId === updateCount?.id && + functionCall.kind === ReactSemanticFunctionCallKind.Parameter, + ); + + expect(report.status).toBe(ReactAppProofStatus.Proved); + expect(parameterCall?.phase).toBe(ReactExecutionPhase.Event); + expect(parameterCall?.sourceParameterIndex).toBe(0); + expect(parameterCall?.callArgumentIndex).toBeNull(); + }); + + it("propagates event phase through component callback props", () => { + const report = proveFixture("proved-forwarded-event-prop"); + const eventCallback = report.graph.callbacks.find( + (callback) => + callback.kind === ReactSemanticCallbackKind.EventHandler && + callback.name === "event handler", + ); + + expect(report.status).toBe(ReactAppProofStatus.Proved); + expect(eventCallback?.phase).toBe(ReactExecutionPhase.Event); + expect(report.graph.eventBindings).toHaveLength(1); + expect(report.graph.eventBindings[0]?.complete).toBe(true); + expect(report.graph.eventBindings[0]?.callbackIds).toEqual([eventCallback?.id]); + expect(report.graph.callbackPropFlows).toHaveLength(2); + expect(report.graph.callbackPropFlows.every((propFlow) => propFlow.complete)).toBe(true); + }); + + it("retains captured callback bindings in a returned event handler", () => { + const report = proveFixture("proved-returned-event-handler"); + const increment = report.graph.reachableFunctions.find( + (reachableFunction) => + reachableFunction.name === "increment" && + reachableFunction.phase === ReactExecutionPhase.Event, + ); + const capturedCall = report.graph.functionCalls.find( + (functionCall) => + functionCall.kind === ReactSemanticFunctionCallKind.Captured && + functionCall.targetFunctionId === increment?.id, + ); + + expect(report.status).toBe(ReactAppProofStatus.Proved); + expect(capturedCall?.phase).toBe(ReactExecutionPhase.Event); + expect(capturedCall?.sourceParameterIndex).toBeNull(); + expect(capturedCall?.sourcePropertyPath).toEqual([]); + }); + + it("propagates callback values through object properties", () => { + const report = proveFixture("proved-object-callback-flow"); + const increment = report.graph.reachableFunctions.find( + (reachableFunction) => + reachableFunction.name === "increment" && + reachableFunction.phase === ReactExecutionPhase.Event, + ); + const propertyCall = report.graph.functionCalls.find( + (functionCall) => + functionCall.kind === ReactSemanticFunctionCallKind.Property && + functionCall.targetFunctionId === increment?.id, + ); + + expect(report.status).toBe(ReactAppProofStatus.Proved); + expect(propertyCall?.sourceParameterIndex).toBe(0); + expect(propertyCall?.sourcePropertyPath).toEqual(["callback"]); + }); + + it("resolves every exhaustive path through a callback factory", () => { + const report = proveFixture("proved-conditional-handler-factory"); + const eventBinding = report.graph.eventBindings[0]; + + expect(report.status).toBe(ReactAppProofStatus.Proved); + expect(eventBinding?.complete).toBe(true); + expect(eventBinding?.callbackIds).toHaveLength(2); + }); + + it("uses literal-union coverage to resolve every switch-selected callback", () => { + const report = proveFixture("proved-switch-handler-factory"); + const eventBinding = report.graph.eventBindings[0]; + + expect(report.status).toBe(ReactAppProofStatus.Proved); + expect(eventBinding?.complete).toBe(true); + expect(eventBinding?.callbackIds).toHaveLength(2); + }); + + it("resolves both normal and exceptional callback factory returns", () => { + const report = proveFixture("proved-try-catch-handler-factory"); + const eventBinding = report.graph.eventBindings[0]; + + expect(report.status).toBe(ReactAppProofStatus.Proved); + expect(eventBinding?.complete).toBe(true); + expect(eventBinding?.callbackIds).toHaveLength(2); + }); + + it("removes a protected callback when finally always overrides its return", () => { + const report = proveFixture("proved-finally-overrides-handler"); + + expect(report.status).toBe(ReactAppProofStatus.Proved); + expect( + report.units + .flatMap((unit) => unit.obligations) + .find((obligation) => obligation.claim === ReactProofClaim.RenderPurity)?.status, + ).toBe(ReactObligationStatus.Proved); + }); + + it.each(["proved-while-handler-factory", "proved-for-of-handler-factory"])( + "resolves every termination-proved loop return in %s", + (fixtureName) => { + const report = proveFixture(fixtureName); + const eventBinding = report.graph.eventBindings[0]; + + expect(report.status).toBe(ReactAppProofStatus.Proved); + expect(eventBinding?.complete).toBe(true); + expect(eventBinding?.callbackIds).toHaveLength(2); + }, + ); + + it("joins literal iteration values before invoking event callbacks", () => { + const report = proveFixture("proved-for-of-invoked-handlers"); + const invokedNames = report.graph.functionCalls + .filter((functionCall) => functionCall.kind === ReactSemanticFunctionCallKind.Captured) + .map( + (functionCall) => + report.graph.reachableFunctions.find( + (reachableFunction) => reachableFunction.id === functionCall.targetFunctionId, + )?.name, + ); + + expect(report.status).toBe(ReactAppProofStatus.Proved); + expect(invokedNames).toEqual(expect.arrayContaining(["firstHandler", "secondHandler"])); + }); + + it("carries conditional callback props through transitive event wrappers", () => { + const report = proveFixture("proved-transitive-event-prop-wrapper"); + const eventPropFlow = report.graph.callbackPropFlows[0]; + const invokedNames = report.graph.functionCalls + .filter((functionCall) => functionCall.phase === ReactExecutionPhase.Event) + .map( + (functionCall) => + report.graph.reachableFunctions.find( + (reachableFunction) => reachableFunction.id === functionCall.targetFunctionId, + )?.name, + ); + + expect(report.status).toBe(ReactAppProofStatus.Proved); + expect(report.graph.eventBindings[0]?.complete).toBe(true); + expect(eventPropFlow?.complete).toBe(true); + expect(eventPropFlow?.callbackIds).toHaveLength(2); + expect(invokedNames).toEqual( + expect.arrayContaining(["invokeAction", "recordPrimaryAction", "recordSecondaryAction"]), + ); + }); + + it.each([ + "proved-for-of-object-binding-handler", + "proved-for-of-tuple-binding-handler", + "proved-for-of-nested-binding-handler", + ])("projects finite iteration values through destructured bindings in %s", (fixtureName) => { + const report = proveFixture(fixtureName); + const eventBinding = report.graph.eventBindings[0]; + + expect(report.status).toBe(ReactAppProofStatus.Proved); + expect(eventBinding?.complete).toBe(true); + expect(eventBinding?.callbackIds).toHaveLength(2); + }); + + it("allows helper-local rebinding while leaving mutable iteration flow incomplete", () => { + const report = proveFixture("incomplete-for-of-mutable-handler"); + const purityProof = report.units + .flatMap((unit) => unit.obligations) + .find((obligation) => obligation.claim === ReactProofClaim.RenderPurity); + const boundaryProof = report.units + .flatMap((unit) => unit.obligations) + .find((obligation) => obligation.claim === ReactProofClaim.BoundaryCoverage); + + expect(report.status).toBe(ReactAppProofStatus.Incomplete); + expect(purityProof?.status).toBe(ReactObligationStatus.Proved); + expect(boundaryProof?.status).toBe(ReactObligationStatus.Unknown); + }); + + it("rejects a certificate that drops a branch-selected event callback", () => { + const report = proveFixture("proved-conditional-handler-factory"); + const certificate = checkReactProofReport({ + ...report, + graph: { + ...report.graph, + eventBindings: report.graph.eventBindings.map((eventBinding) => ({ + ...eventBinding, + callbackIds: eventBinding.callbackIds.slice(0, 1), + })), + }, + }); + + expect(certificate.status).toBe(ReactProofCertificateStatus.Invalid); + expect( + certificate.failures.some((failure) => + failure.description.includes("not referenced by an event channel"), + ), + ).toBe(true); + }); + + it.each([ + "incomplete-async-effect-ignore-contract", + "incomplete-async-effect-abort-contract", + "incomplete-async-effect-promise-ignore-contract", + "async-effect-stale-write", + "async-effect-promise-chain", + "async-effect-opaque-guard", + "async-effect-opaque-continuation", + "async-effect-post-await-mutation", + "async-effect-path-dependent-invalidation", + "helper-effect-state-update", + "conditional-helper-effect-cleanup", + "invalid-hook-helper", + "class-component", + "named-memo-impure-helper", + "external-store-helper-boundary", + "proved-external-store-callback-props", + "proved-external-store-conditional-props", + "proved-external-store-conditional-factory", + "proved-external-store-render-branch-props", + "fresh-external-store-callback-prop-snapshot", + "silent-external-store-render-branch-props", + "mismatched-external-store-callback-prop-server-snapshot", + "mismatched-external-store-conditional-props", + "mismatched-external-store-conditional-factory", + "incomplete-external-store-callback-prop-spread", + "incomplete-external-store-callback-prop-conditional-join", + "incomplete-external-store-conditional-factory", + "incomplete-external-store-mutated-conditional-props", + "effect-event-shared-helper", + "proved-shared-event-handler", + "mapped-event-handler", + "render-callback-parameter-impurity", + "proved-event-callback-parameter", + "callback-parameter-effect-listener-leak", + "callback-parameter-opaque-registration", + "proved-event-prop-flow", + "proved-forwarded-event-prop", + "incomplete-event-prop-spread", + "proved-effect-callback-prop", + "proved-mixed-phase-callback-prop", + "proved-cleanup-callback-prop", + "incomplete-defaulted-event-prop-wrapper", + "incomplete-computed-event-prop-wrapper", + "incomplete-local-object-callback-spread", + "proved-event-prop-wrapper", + "proved-transitive-event-prop-wrapper", + "proved-returned-event-handler", + "render-returned-callback-impurity", + "proved-object-callback-flow", + "object-callback-effect-listener-leak", + "incomplete-object-callback-spread", + "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", + "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", + "proved-returned-use-callback-hook", + "proved-local-object-callback", + "incomplete-ref-backed-event-callback", + "incomplete-mutable-object-callback", + ])("independently checks the %s proof certificate", (fixtureName) => { + const certificate = checkReactProofReport(proveFixture(fixtureName)); + + expect(certificate.status).toBe(ReactProofCertificateStatus.Valid); + expect(certificate.failures).toEqual([]); + }); + + it("rejects a report whose global verdict contradicts its obligations", () => { + const report = proveFixture("async-effect-stale-write"); + const certificate = checkReactProofReport({ + ...report, + status: ReactAppProofStatus.Proved, + }); + + expect(certificate.status).toBe(ReactProofCertificateStatus.Invalid); + expect(certificate.failures.some((failure) => failure.subjectId === "report-status")).toBe( + true, + ); + }); + + it("rejects a report whose async fact contradicts its unit theorem", () => { + const report = proveFixture("incomplete-async-effect-ignore-contract"); + const firstTask = report.graph.asyncTasks[0]; + const certificate = checkReactProofReport({ + ...report, + graph: { + ...report.graph, + asyncTasks: firstTask + ? [ + { + ...firstTask, + ownershipStatus: ReactAsyncOwnershipStatus.Unguarded, + }, + ...report.graph.asyncTasks.slice(1), + ] + : [], + }, + }); + + expect(certificate.status).toBe(ReactProofCertificateStatus.Invalid); + expect( + certificate.failures.some((failure) => + failure.description.includes("Async Effect ownership facts require"), + ), + ).toBe(true); + }); + + it("rejects a reachable function whose phase contradicts its root callback", () => { + const report = proveFixture("proved-helper-effect-cleanup"); + const firstReachableFunction = report.graph.reachableFunctions[0]; + const certificate = checkReactProofReport({ + ...report, + graph: { + ...report.graph, + reachableFunctions: firstReachableFunction + ? [ + { + ...firstReachableFunction, + phase: ReactExecutionPhase.Event, + }, + ...report.graph.reachableFunctions.slice(1), + ] + : [], + }, + }); + + expect(certificate.status).toBe(ReactProofCertificateStatus.Invalid); + expect( + certificate.failures.some((failure) => + failure.description.includes("different execution phases"), + ), + ).toBe(true); + }); + + it("rejects a parameter call with an impossible flow index shape", () => { + const report = proveFixture("proved-event-callback-parameter"); + const parameterCall = report.graph.functionCalls.find( + (functionCall) => functionCall.kind === ReactSemanticFunctionCallKind.Parameter, + ); + const certificate = checkReactProofReport({ + ...report, + graph: { + ...report.graph, + functionCalls: parameterCall + ? report.graph.functionCalls.map((functionCall) => + functionCall.id === parameterCall.id + ? { + ...functionCall, + sourceParameterIndex: null, + } + : functionCall, + ) + : [], + }, + }); + + expect(certificate.status).toBe(ReactProofCertificateStatus.Invalid); + expect( + certificate.failures.some((failure) => + failure.description.includes("indexes inconsistent with its flow kind"), + ), + ).toBe(true); + }); + + it("rejects a complete event prop flow without a source callback", () => { + const report = proveFixture("proved-event-prop-flow"); + const firstPropFlow = report.graph.callbackPropFlows[0]; + const certificate = checkReactProofReport({ + ...report, + graph: { + ...report.graph, + callbackPropFlows: firstPropFlow + ? [ + { + ...firstPropFlow, + callbackIds: [], + }, + ...report.graph.callbackPropFlows.slice(1), + ] + : [], + }, + }); + + expect(certificate.status).toBe(ReactProofCertificateStatus.Invalid); + expect( + certificate.failures.some((failure) => + failure.description.includes("complete callback prop flow has no source callback"), + ), + ).toBe(true); + }); + + it("rejects a callback prop flow whose source has a different phase", () => { + const report = proveFixture("proved-effect-callback-prop"); + const firstPropFlow = report.graph.callbackPropFlows[0]; + const certificate = checkReactProofReport({ + ...report, + graph: { + ...report.graph, + callbackPropFlows: firstPropFlow + ? [ + { + ...firstPropFlow, + phase: ReactExecutionPhase.Event, + }, + ...report.graph.callbackPropFlows.slice(1), + ] + : [], + }, + }); + + expect(certificate.status).toBe(ReactProofCertificateStatus.Invalid); + expect( + certificate.failures.some((failure) => + failure.description.includes("mismatched execution phase"), + ), + ).toBe(true); + }); + + it("rejects duplicate guard identities in a callback prop alternative", () => { + const report = proveFixture("proved-external-store-conditional-props"); + const guardedPropFlow = report.graph.callbackPropFlows.find((propFlow) => + propFlow.alternatives.some((alternative) => alternative.guards.length > 0), + ); + const guardedAlternative = guardedPropFlow?.alternatives.find( + (alternative) => alternative.guards.length > 0, + ); + const firstGuard = guardedAlternative?.guards[0]; + const certificate = checkReactProofReport({ + ...report, + graph: { + ...report.graph, + callbackPropFlows: + guardedPropFlow && guardedAlternative && firstGuard + ? report.graph.callbackPropFlows.map((propFlow) => + propFlow.id === guardedPropFlow.id + ? { + ...propFlow, + alternatives: propFlow.alternatives.map((alternative) => + alternative === guardedAlternative + ? { + ...alternative, + guards: [...alternative.guards, firstGuard], + } + : alternative, + ), + } + : propFlow, + ) + : [], + }, + }); + + expect(certificate.status).toBe(ReactProofCertificateStatus.Invalid); + expect( + certificate.failures.some((failure) => + failure.description.includes("invalid guard identities"), + ), + ).toBe(true); + }); + + it("rejects a complete external-store channel without its callback", () => { + const report = proveFixture("proved-external-store"); + const externalStore = report.graph.externalStores[0]; + const certificate = checkReactProofReport({ + ...report, + graph: { + ...report.graph, + externalStores: externalStore + ? [ + { + ...externalStore, + snapshotCallbackIds: [], + }, + ...report.graph.externalStores.slice(1), + ] + : [], + }, + }); + + expect(certificate.status).toBe(ReactProofCertificateStatus.Invalid); + expect( + certificate.failures.some((failure) => + failure.description.includes("complete external-store snapshot has no callback"), + ), + ).toBe(true); + }); + + it("rejects an external-store callback prop without its owner channel", () => { + const report = proveFixture("proved-external-store-callback-props"); + const certificate = checkReactProofReport({ + ...report, + graph: { + ...report.graph, + callbackPropFlows: [], + }, + }); + + expect(certificate.status).toBe(ReactProofCertificateStatus.Invalid); + expect( + certificate.failures.some((failure) => + failure.description.includes("no certified owner channel"), + ), + ).toBe(true); + }); + + it("rejects a callback prop flow with an unknown render site", () => { + const report = proveFixture("proved-external-store-render-branch-props"); + const firstPropFlow = report.graph.callbackPropFlows[0]; + const certificate = checkReactProofReport({ + ...report, + graph: { + ...report.graph, + callbackPropFlows: firstPropFlow + ? [ + { + ...firstPropFlow, + renderId: "unknown-render-site", + }, + ...report.graph.callbackPropFlows.slice(1), + ] + : [], + }, + }); + + expect(certificate.status).toBe(ReactProofCertificateStatus.Invalid); + expect( + certificate.failures.some((failure) => failure.description.includes("unknown render site")), + ).toBe(true); + }); + + it("rejects a property call without a property path", () => { + const report = proveFixture("proved-object-callback-flow"); + const propertyCall = report.graph.functionCalls.find( + (functionCall) => functionCall.kind === ReactSemanticFunctionCallKind.Property, + ); + const certificate = checkReactProofReport({ + ...report, + graph: { + ...report.graph, + functionCalls: propertyCall + ? report.graph.functionCalls.map((functionCall) => + functionCall.id === propertyCall.id + ? { + ...functionCall, + sourcePropertyPath: [], + } + : functionCall, + ) + : [], + }, + }); + + expect(certificate.status).toBe(ReactProofCertificateStatus.Invalid); + expect( + certificate.failures.some((failure) => + failure.description.includes("indexes inconsistent with its flow kind"), + ), + ).toBe(true); + }); + + it("records Effect Event captures, execution phase, and intentionally unstable identity", () => { + const report = proveFixture("proved-effect-event"); + const effectEvent = report.graph.effectEvents[0]; + const callback = report.graph.callbacks.find( + (candidateCallback) => candidateCallback.id === effectEvent?.callbackId, + ); + + expect(effectEvent?.identityStability).toBe(ReactIdentityStability.Unstable); + expect(callback?.kind).toBe(ReactSemanticCallbackKind.EffectEvent); + expect(callback?.phase).toBe(ReactExecutionPhase.EffectEvent); + expect(callback?.captures).toContain("canMove"); + }); + + it("allows an Effect helper to register an Effect Event", () => { + const report = proveFixture("proved-effect-event"); + const effectEventProof = report.units + .flatMap((unit) => unit.obligations) + .find((obligation) => obligation.claim === ReactProofClaim.EffectEventUsage); + + expect(report.status).toBe(ReactAppProofStatus.Proved); + expect(effectEventProof?.status).toBe(ReactObligationStatus.Proved); + }); + + it("normalizes React Compiler HIR into a control-flow graph", () => { + const report = proveFixture("proved-cfg"); + const compilerFunction = report.graph.compiler.functions.find( + (functionFact) => functionFact.blocks.length > 1, + ); + + expect(report.graph.compiler.status).toBe(ReactCompilerFactStatus.Complete); + expect(report.graph.compiler.version).toBe("babel-plugin-react-compiler@1.0.0"); + expect(report.graph.compiler.phase).toBe("InferReactivePlaces"); + expect(compilerFunction).toBeDefined(); + expect( + compilerFunction?.blocks.some( + (block) => block.successors.length > 1 || block.predecessors.length > 1, + ), + ).toBe(true); + expect(compilerFunction?.blocks.flatMap((block) => block.instructions).length).toBeGreaterThan( + 0, + ); + }); + + it.each(REFUTED_FIXTURES)( + "refutes $fixtureName with a source-level $claim counterexample", + ({ fixtureName, claim, evidencePattern }) => { + const report = proveFixture(fixtureName); + const violatedObligation = report.units + .flatMap((unit) => unit.obligations) + .find( + (obligation) => + obligation.claim === claim && obligation.status === ReactObligationStatus.Violated, + ); + + expect(report.status).toBe(ReactAppProofStatus.Refuted); + expect(violatedObligation).toBeDefined(); + expect(violatedObligation?.evidence[0]?.description).toMatch(evidencePattern); + expect(violatedObligation?.evidence[0]?.trace.length).toBeGreaterThan(0); + expect(violatedObligation?.evidence[0]?.location.line).toBeGreaterThan(0); + }, + ); + + it("fails closed when render purity depends on an opaque call", () => { + const report = proveFixture("opaque-render-call"); + const purityProof = report.units + .flatMap((unit) => unit.obligations) + .find((obligation) => obligation.claim === ReactProofClaim.RenderPurity); + + expect(report.status).toBe(ReactAppProofStatus.Incomplete); + expect(purityProof?.status).toBe(ReactObligationStatus.Unknown); + expect(purityProof?.evidence[0]?.description).toMatch(/no render-purity contract/); + }); + + it("proves a locally resolved state-update event callback", () => { + const report = proveFixture("event-handler-boundary"); + const boundaryProof = report.units + .flatMap((unit) => unit.obligations) + .find((obligation) => obligation.claim === ReactProofClaim.BoundaryCoverage); + + expect(report.status).toBe(ReactAppProofStatus.Proved); + expect(boundaryProof?.status).toBe(ReactObligationStatus.Proved); + }); + + it("fails closed when effect state updates need a rerender fixpoint proof", () => { + const report = proveFixture("effect-state-update"); + const stateUpdateProof = report.units + .flatMap((unit) => unit.obligations) + .find((obligation) => obligation.claim === ReactProofClaim.EffectStateUpdates); + + expect(report.status).toBe(ReactAppProofStatus.Incomplete); + expect(stateUpdateProof?.status).toBe(ReactObligationStatus.Unknown); + expect(stateUpdateProof?.evidence[0]?.description).toMatch(/fixpoint proof/); + }); + + it("fails closed for the pinned memo-context Effect Event runtime gap", () => { + const report = proveFixture("effect-event-memo-context"); + const effectEventProof = report.units + .flatMap((unit) => unit.obligations) + .find((obligation) => obligation.claim === ReactProofClaim.EffectEventUsage); + + expect(report.status).toBe(ReactAppProofStatus.Incomplete); + expect(effectEventProof?.status).toBe(ReactObligationStatus.Unknown); + expect(effectEventProof?.evidence[0]?.description).toMatch(/stale context capture/); + }); + + it("fails closed when an Effect Event crosses an opaque registration contract", () => { + const report = proveFixture("effect-event-opaque-registration"); + const effectEventProof = report.units + .flatMap((unit) => unit.obligations) + .find((obligation) => obligation.claim === ReactProofClaim.EffectEventUsage); + + expect(report.status).toBe(ReactAppProofStatus.Incomplete); + expect(effectEventProof?.status).toBe(ReactObligationStatus.Unknown); + expect(effectEventProof?.evidence[0]?.description).toMatch(/unmodeled registration/); + }); + + it("fails closed when a source callback crosses an opaque registration boundary", () => { + const report = proveFixture("callback-parameter-opaque-registration"); + const boundaryProof = report.units + .flatMap((unit) => unit.obligations) + .find((obligation) => obligation.claim === ReactProofClaim.BoundaryCoverage); + + expect(report.status).toBe(ReactAppProofStatus.Incomplete); + expect(boundaryProof?.status).toBe(ReactObligationStatus.Unknown); + expect(boundaryProof?.evidence[0]?.description).toMatch(/callable-value boundary/); + }); + + it("fails closed when a callback prop crosses an object spread", () => { + const report = proveFixture("incomplete-event-prop-spread"); + const boundaryProof = report.units + .flatMap((unit) => unit.obligations) + .find( + (obligation) => + obligation.claim === ReactProofClaim.BoundaryCoverage && + obligation.status === ReactObligationStatus.Unknown, + ); + + expect(report.status).toBe(ReactAppProofStatus.Incomplete); + expect(report.graph.eventBindings[0]?.complete).toBe(false); + expect(boundaryProof?.evidence[0]?.description).toMatch(/does not resolve/); + }); + + it.each([ + "incomplete-object-callback-spread", + "incomplete-local-object-callback-spread", + "incomplete-defaulted-event-prop-wrapper", + "incomplete-computed-event-prop-wrapper", + "incomplete-ref-backed-event-callback", + "incomplete-mutable-object-callback", + "incomplete-logical-callback-alias", + "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", + ])("fails closed for unsupported callable value flow in %s", (fixtureName) => { + const report = proveFixture(fixtureName); + const boundaryProof = report.units + .flatMap((unit) => unit.obligations) + .find( + (obligation) => + obligation.claim === ReactProofClaim.BoundaryCoverage && + obligation.status === ReactObligationStatus.Unknown, + ); + + expect(report.status).toBe(ReactAppProofStatus.Incomplete); + expect(boundaryProof).toBeDefined(); + }); + + it("requires a temporal proof before invoking a ref-backed callback wrapper", () => { + const report = proveFixture("incomplete-ref-backed-event-callback"); + const boundaryProof = report.units + .flatMap((unit) => unit.obligations) + .find( + (obligation) => + obligation.claim === ReactProofClaim.BoundaryCoverage && + obligation.evidence.some((evidence) => /temporal freshness/.test(evidence.description)), + ); + + expect(report.status).toBe(ReactAppProofStatus.Incomplete); + expect(boundaryProof).toBeDefined(); + }); + + it("requires SSA evidence after mutating a callable object property", () => { + const report = proveFixture("incomplete-mutable-object-callback"); + const boundaryProof = report.units + .flatMap((unit) => unit.obligations) + .find( + (obligation) => + obligation.claim === ReactProofClaim.BoundaryCoverage && + obligation.evidence.some((evidence) => /SSA value proof/.test(evidence.description)), + ); + + expect(report.status).toBe(ReactAppProofStatus.Incomplete); + expect(boundaryProof).toBeDefined(); + }); + + it("tracks a callback through a logical alias into an opaque registry", () => { + const report = proveFixture("incomplete-logical-callback-alias"); + const boundaryProof = report.units + .flatMap((unit) => unit.obligations) + .find( + (obligation) => + obligation.claim === ReactProofClaim.BoundaryCoverage && + obligation.evidence.some((evidence) => + /unmodeled callable-value boundary/.test(evidence.description), + ), + ); + + expect(report.status).toBe(ReactAppProofStatus.Incomplete); + expect(boundaryProof).toBeDefined(); + }); + + it.each([ + ["proved-effect-callback-prop", ReactExecutionPhase.EffectSetup], + ["proved-mixed-phase-callback-prop", ReactExecutionPhase.EffectSetup], + ["proved-cleanup-callback-prop", ReactExecutionPhase.EffectCleanup], + ])("proves the callback prop channel used by %s", (fixtureName, expectedPhase) => { + const report = proveFixture(fixtureName); + const callbackPropFlow = report.graph.callbackPropFlows.find( + (propFlow) => propFlow.phase === expectedPhase, + ); + const phaseCall = report.graph.functionCalls.find( + (functionCall) => + functionCall.phase === expectedPhase && + callbackPropFlow?.targetOwnerId === functionCall.ownerId, + ); + const boundaryProofs = report.units.flatMap((unit) => + unit.obligations.filter( + (obligation) => obligation.claim === ReactProofClaim.BoundaryCoverage, + ), + ); + + expect(report.status).toBe(ReactAppProofStatus.Proved); + expect(callbackPropFlow?.complete).toBe(true); + expect(callbackPropFlow?.callbackIds).toHaveLength(1); + expect(phaseCall).toBeDefined(); + expect( + boundaryProofs.every((obligation) => obligation.status === ReactObligationStatus.Proved), + ).toBe(true); + }); + + it("does not prove a callback-prop Effect cycle through parent state", () => { + const report = proveFixture("incomplete-effect-callback-prop-state-cycle"); + const transitionProof = report.units + .flatMap((unit) => unit.obligations) + .find( + (obligation) => + obligation.claim === ReactProofClaim.EffectStateUpdates && + obligation.evidence.some((evidence) => + evidence.description.includes("cross-component rerender fixpoint proof"), + ), + ); + + expect(report.status).toBe(ReactAppProofStatus.Incomplete); + expect(transitionProof?.status).toBe(ReactObligationStatus.Unknown); + }); + + it("records external-store helpers while failing closed on an unmodeled protocol", () => { + const report = proveFixture("external-store-helper-boundary"); + const subscriptionHelper = report.graph.reachableFunctions.find( + (reachableFunction) => reachableFunction.name === "addListener", + ); + const consistencyProof = report.units + .flatMap((unit) => unit.obligations) + .find((obligation) => obligation.claim === ReactProofClaim.ExternalStoreConsistency); + + expect(report.status).toBe(ReactAppProofStatus.Incomplete); + expect(subscriptionHelper?.phase).toBe(ReactExecutionPhase.ExternalStoreSubscription); + expect(consistencyProof?.status).toBe(ReactObligationStatus.Unknown); + }); + + it("allows conditional use while keeping its lifecycle model incomplete", () => { + const report = proveFixture("conditional-use"); + const hookOrderProof = report.units + .flatMap((unit) => unit.obligations) + .find((obligation) => obligation.claim === ReactProofClaim.HookOrder); + + expect(report.status).toBe(ReactAppProofStatus.Incomplete); + expect(hookOrderProof?.status).toBe(ReactObligationStatus.Proved); + }); + + it("fails closed when an index key cannot preserve state across reordering", () => { + const report = proveFixture("index-list-key"); + const reconciliationProof = report.units + .flatMap((unit) => unit.obligations) + .find((obligation) => obligation.claim === ReactProofClaim.ReconciliationIdentity); + + expect(report.status).toBe(ReactAppProofStatus.Incomplete); + expect(reconciliationProof?.status).toBe(ReactObligationStatus.Unknown); + expect(reconciliationProof?.evidence[0]?.description).toMatch(/index key/); + }); + + it("recognizes the React Datepicker loop-index key pattern", () => { + const report = proveFixture("datepicker-loop-index-key"); + const reconciliationProof = report.units + .flatMap((unit) => unit.obligations) + .find((obligation) => obligation.claim === ReactProofClaim.ReconciliationIdentity); + + expect(report.status).toBe(ReactAppProofStatus.Incomplete); + expect(reconciliationProof?.status).toBe(ReactObligationStatus.Unknown); + expect(reconciliationProof?.evidence[0]?.description).toMatch(/loop-index-derived key/); + }); + + it("fails closed instead of path-insensitively approving conditional cleanup", () => { + const report = proveFixture("path-dependent-cleanup"); + const cleanupProof = report.units + .flatMap((unit) => unit.obligations) + .find((obligation) => obligation.claim === ReactProofClaim.EffectCleanup); + + expect(report.status).toBe(ReactAppProofStatus.Incomplete); + expect(cleanupProof?.status).toBe(ReactObligationStatus.Unknown); + expect(cleanupProof?.evidence[0]?.description).toMatch(/path-dependent/); + }); + + it("fails closed for class component lifecycle semantics", () => { + const report = proveFixture("class-component"); + + expect(report.status).toBe(ReactAppProofStatus.Incomplete); + expect(report.summary.unknown).toBeGreaterThan(0); + expect(report.units[0]?.obligations[0]?.evidence[0]?.description).toMatch( + /Class component lifecycle/, + ); + }); + + it("fails closed when TypeScript assertions can forge proof facts", () => { + const report = proveFixture("unsafe-types"); + + expect(report.status).toBe(ReactAppProofStatus.Incomplete); + expect(report.projectEvidence[0]?.description).toMatch(/unchecked type assertion/); + }); + + it("fails closed when React Compiler cannot produce proof facts", () => { + const report = proveFixture("compiler-bailout"); + + expect(report.status).toBe(ReactAppProofStatus.Incomplete); + expect(report.graph.compiler.status).toBe(ReactCompilerFactStatus.Incomplete); + expect(report.projectEvidence[0]?.description).toMatch(/React Compiler/); + expect(report.graph.compiler.failures[0]?.description).toBeTruthy(); + }); + + it("returns an incomplete report instead of throwing when project discovery fails", () => { + const report = proveReactApp({ + rootDirectory: path.join(fixturesDirectory, "missing-project"), + }); + + expect(report.status).toBe(ReactAppProofStatus.Incomplete); + expect(report.units).toEqual([]); + expect(report.projectEvidence[0]?.description).toMatch(/No tsconfig/); + }); +}); diff --git a/packages/prover/tests/runtime/async-effect-ownership-oracle.spec.ts b/packages/prover/tests/runtime/async-effect-ownership-oracle.spec.ts new file mode 100644 index 0000000000..05145b3a83 --- /dev/null +++ b/packages/prover/tests/runtime/async-effect-ownership-oracle.spec.ts @@ -0,0 +1,21 @@ +import { expect, test } from "@playwright/test"; +import { LATE_QUERY_SETTLE_WAIT_MS } from "./constants.js"; + +test("an unowned async Effect completion overwrites the newer result", async ({ page }) => { + await page.goto("/?oracle=async-effect-ownership&mode=unsafe"); + await page.getByRole("button", { name: "load beta" }).click(); + + await expect(page.getByTestId("async-query-result")).toHaveText("beta"); + await expect(page.getByTestId("async-query-result")).toHaveText("alpha"); +}); + +test("cleanup invalidation prevents a superseded async Effect from committing", async ({ + page, +}) => { + await page.goto("/?oracle=async-effect-ownership&mode=safe"); + await page.getByRole("button", { name: "load beta" }).click(); + + await expect(page.getByTestId("async-query-result")).toHaveText("beta"); + await page.waitForTimeout(LATE_QUERY_SETTLE_WAIT_MS); + await expect(page.getByTestId("async-query-result")).toHaveText("beta"); +}); diff --git a/packages/prover/tests/runtime/constants.ts b/packages/prover/tests/runtime/constants.ts new file mode 100644 index 0000000000..63d6e8fdad --- /dev/null +++ b/packages/prover/tests/runtime/constants.ts @@ -0,0 +1,6 @@ +export const FAST_QUERY_DELAY_MS = 20; +export const LATE_QUERY_SETTLE_WAIT_MS = 250; +export const PRIMARY_STORE_INITIAL_VERSION = 0; +export const SECONDARY_STORE_INITIAL_VERSION = 100; +export const SLOW_QUERY_DELAY_MS = 200; +export const STORE_VERSION_INCREMENT = 1; diff --git a/packages/prover/tests/runtime/context-oracle.spec.ts b/packages/prover/tests/runtime/context-oracle.spec.ts new file mode 100644 index 0000000000..23330e638a --- /dev/null +++ b/packages/prover/tests/runtime/context-oracle.spec.ts @@ -0,0 +1,27 @@ +import { expect, test } from "@playwright/test"; + +test("a consumer receives a provider value only from the same context object", async ({ page }) => { + await page.goto("/?oracle=context-identity&mode=same"); + + await expect(page.getByTestId("context-identity-value")).toHaveText("provided"); +}); + +test("a structurally identical context object still reads its own default", async ({ page }) => { + await page.goto("/?oracle=context-identity&mode=duplicate"); + + await expect(page.getByTestId("context-identity-value")).toHaveText("consumer-default"); +}); + +test("the nearest nested provider isolates its consumer from the parent", async ({ page }) => { + await page.goto("/?oracle=nested-context&mode=nested"); + + await expect(page.getByTestId("outer-context-value")).toHaveText("outer"); + await expect(page.getByTestId("inner-context-value")).toHaveText("inner"); +}); + +test("without a nested provider the inner consumer inherits the parent", async ({ page }) => { + await page.goto("/?oracle=nested-context&mode=missing"); + + await expect(page.getByTestId("outer-context-value")).toHaveText("outer"); + await expect(page.getByTestId("inner-context-value")).toHaveText("outer"); +}); diff --git a/packages/prover/tests/runtime/effect-event-oracle.spec.ts b/packages/prover/tests/runtime/effect-event-oracle.spec.ts new file mode 100644 index 0000000000..5217c02157 --- /dev/null +++ b/packages/prover/tests/runtime/effect-event-oracle.spec.ts @@ -0,0 +1,34 @@ +import { expect, test } from "@playwright/test"; + +test("confirms that an Effect Event reads the latest committed value", async ({ page }) => { + await page.goto("/?oracle=effect-event&mode=safe"); + await page.getByRole("button", { name: "disable" }).click(); + await page.getByRole("button", { name: "dispatch" }).click(); + + await expect(page.getByTestId("effect-event-hits")).toHaveText("0"); +}); + +test("confirms the equivalent ordinary closure observes a stale value", async ({ page }) => { + await page.goto("/?oracle=effect-event&mode=stale"); + await page.getByRole("button", { name: "disable" }).click(); + await page.getByRole("button", { name: "dispatch" }).click(); + + await expect(page.getByTestId("effect-event-hits")).toHaveText("1"); +}); + +test("confirms that Effect Event identity changes after a render", async ({ page }) => { + await page.goto("/?oracle=effect-event-identity"); + await expect.poll(() => page.evaluate(() => window.effectEventSetupRuns)).toBe(1); + await page.getByRole("button", { name: "rerender 0" }).click(); + + await expect.poll(() => page.evaluate(() => window.effectEventSetupRuns)).toBe(2); +}); + +test("reproduces the stale-context Effect Event bug through memo", async ({ page }) => { + await page.goto("/?oracle=effect-event-memo-context"); + await page.getByRole("button", { name: "navigate" }).click(); + await expect(page.getByTestId("rendered-navigation")).toHaveText("REPLACE"); + await page.getByRole("button", { name: "inspect" }).click(); + + await expect(page.getByTestId("observed-navigation")).toHaveText("POP"); +}); diff --git a/packages/prover/tests/runtime/external-store-oracle.spec.ts b/packages/prover/tests/runtime/external-store-oracle.spec.ts new file mode 100644 index 0000000000..e29fb8ecf6 --- /dev/null +++ b/packages/prover/tests/runtime/external-store-oracle.spec.ts @@ -0,0 +1,58 @@ +import { expect, test } from "@playwright/test"; + +test("confirms that a fresh external-store snapshot triggers React's cache invariant", async ({ + page, +}) => { + const runtimeMessages: string[] = []; + page.on("console", (message) => runtimeMessages.push(message.text())); + page.on("pageerror", (error) => runtimeMessages.push(error.message)); + + await page.goto("/?oracle=external-store&mode=fresh"); + + await expect + .poll(() => runtimeMessages.join("\n")) + .toMatch(/getSnapshot should be cached|Maximum update depth/); +}); + +test("confirms that a cached external-store snapshot remains stable", async ({ page }) => { + const runtimeMessages: string[] = []; + page.on("console", (message) => runtimeMessages.push(message.text())); + page.on("pageerror", (error) => runtimeMessages.push(error.message)); + + await page.goto("/?oracle=external-store&mode=safe"); + + await expect(page.getByTestId("connection")).toHaveText("online"); + expect(runtimeMessages.join("\n")).not.toMatch( + /getSnapshot should be cached|Maximum update depth/, + ); +}); + +test("switches between independently correlated external-store render branches", async ({ + page, +}) => { + await page.goto("/?oracle=external-store-branches"); + + await expect(page.getByTestId("store-version")).toHaveText("0"); + await page.getByRole("button", { name: "update primary" }).click(); + await expect(page.getByTestId("store-version")).toHaveText("1"); + await page.getByRole("button", { name: "switch store" }).click(); + await expect(page.getByTestId("store-version")).toHaveText("100"); + await page.getByRole("button", { name: "update primary" }).click(); + await expect(page.getByTestId("store-version")).toHaveText("100"); + await page.getByRole("button", { name: "update secondary" }).click(); + await expect(page.getByTestId("store-version")).toHaveText("101"); +}); + +test("switches callback channels selected by one intra-attribute guard", async ({ page }) => { + await page.goto("/?oracle=external-store-conditional"); + + await expect(page.getByTestId("store-version")).toHaveText("0"); + await page.getByRole("button", { name: "update primary" }).click(); + await expect(page.getByTestId("store-version")).toHaveText("1"); + await page.getByRole("button", { name: "switch store" }).click(); + await expect(page.getByTestId("store-version")).toHaveText("100"); + await page.getByRole("button", { name: "update primary" }).click(); + await expect(page.getByTestId("store-version")).toHaveText("100"); + await page.getByRole("button", { name: "update secondary" }).click(); + await expect(page.getByTestId("store-version")).toHaveText("101"); +}); diff --git a/packages/prover/tests/runtime/index.html b/packages/prover/tests/runtime/index.html new file mode 100644 index 0000000000..bf6a42629f --- /dev/null +++ b/packages/prover/tests/runtime/index.html @@ -0,0 +1,12 @@ + + + + + + React Prover Runtime Oracle + + +
+ + + diff --git a/packages/prover/tests/runtime/listener-oracle.spec.ts b/packages/prover/tests/runtime/listener-oracle.spec.ts new file mode 100644 index 0000000000..c66108e212 --- /dev/null +++ b/packages/prover/tests/runtime/listener-oracle.spec.ts @@ -0,0 +1,17 @@ +import { expect, test } from "@playwright/test"; + +test("confirms that a mismatched listener identity survives unmount", async ({ page }) => { + await page.goto("/?mode=leaky"); + await page.getByRole("button", { name: "unmount" }).click(); + await page.getByRole("button", { name: "dispatch" }).click(); + + await expect.poll(() => page.evaluate(() => window.listenerHits)).toBe(1); +}); + +test("confirms that a symmetric listener cleanup removes the resource", async ({ page }) => { + await page.goto("/?mode=safe"); + await page.getByRole("button", { name: "unmount" }).click(); + await page.getByRole("button", { name: "dispatch" }).click(); + + await expect.poll(() => page.evaluate(() => window.listenerHits)).toBe(0); +}); diff --git a/packages/prover/tests/runtime/main.tsx b/packages/prover/tests/runtime/main.tsx new file mode 100644 index 0000000000..b9a0c99057 --- /dev/null +++ b/packages/prover/tests/runtime/main.tsx @@ -0,0 +1,434 @@ +import { + createContext, + memo, + useContext, + useEffect, + useEffectEvent, + useState, + useSyncExternalStore, +} from "react"; +import type { ChangeEvent } from "react"; +import { createRoot } from "react-dom/client"; +import { + FAST_QUERY_DELAY_MS, + PRIMARY_STORE_INITIAL_VERSION, + SECONDARY_STORE_INITIAL_VERSION, + SLOW_QUERY_DELAY_MS, + STORE_VERSION_INCREMENT, +} from "./constants.js"; + +declare global { + interface Window { + effectEventSetupRuns: number; + listenerHits: number; + } +} + +window.effectEventSetupRuns = 0; +window.listenerHits = 0; + +const LeakyListener = () => { + useEffect(() => { + window.addEventListener("prover-resize", () => { + window.listenerHits += 1; + }); + return () => { + window.removeEventListener("prover-resize", () => { + window.listenerHits += 1; + }); + }; + }, []); + return null; +}; + +const SafeListener = () => { + useEffect(() => { + const handleResize = () => { + window.listenerHits += 1; + }; + window.addEventListener("prover-resize", handleResize); + return () => window.removeEventListener("prover-resize", handleResize); + }, []); + return null; +}; + +const ListenerOracle = () => { + const [isMounted, setIsMounted] = useState(true); + const isSafeMode = new URLSearchParams(window.location.search).get("mode") === "safe"; + const Listener = isSafeMode ? SafeListener : LeakyListener; + return ( +
+ + + {window.listenerHits} + {isMounted ? : null} +
+ ); +}; + +interface KeyedItem { + id: string; + label: string; +} + +interface StatefulItemProperties { + item: KeyedItem; +} + +const StatefulItem = ({ item }: StatefulItemProperties) => { + const [draft, setDraft] = useState(""); + return ( +
  • + +
  • + ); +}; + +const ReconciliationOracle = () => { + const [items, setItems] = useState>([ + { id: "alpha", label: "Alpha" }, + { id: "beta", label: "Beta" }, + ]); + const isIndexMode = new URLSearchParams(window.location.search).get("mode") === "index"; + return ( +
    + +
      + {items.map((item, itemIndex) => ( + + ))} +
    +
    + ); +}; + +interface ConnectionSnapshot { + online: boolean; +} + +const CACHED_CONNECTION_SNAPSHOT: ConnectionSnapshot = { online: true }; +const subscribeToStaticStore = (_listener: () => void) => () => undefined; + +interface ExternalStoreReaderProperties { + subscribe: (listener: () => void) => () => void; + getSnapshot: () => ConnectionSnapshot; +} + +const ExternalStoreReader = ({ subscribe, getSnapshot }: ExternalStoreReaderProperties) => { + const connection = useSyncExternalStore(subscribe, getSnapshot); + return {connection.online ? "online" : "offline"}; +}; + +const ExternalStoreOracle = () => { + const isSafeMode = new URLSearchParams(window.location.search).get("mode") === "safe"; + const getSnapshot = isSafeMode ? () => CACHED_CONNECTION_SNAPSHOT : () => ({ online: true }); + return ; +}; + +const EffectEventLatestValueOracle = () => { + const [isEnabled, setIsEnabled] = useState(true); + const [eventHits, setEventHits] = useState(0); + const isSafeMode = new URLSearchParams(window.location.search).get("mode") === "safe"; + const onRuntimeEvent = useEffectEvent(() => { + if (isEnabled) setEventHits((currentHits) => currentHits + 1); + }); + + useEffect(() => { + const staleHandler = () => { + if (isEnabled) setEventHits((currentHits) => currentHits + 1); + }; + const handler = isSafeMode ? onRuntimeEvent : staleHandler; + window.addEventListener("prover-effect-event", handler); + return () => window.removeEventListener("prover-effect-event", handler); + }, []); + + return ( +
    + + + {eventHits} +
    + ); +}; + +let primaryStoreVersion = PRIMARY_STORE_INITIAL_VERSION; +let secondaryStoreVersion = SECONDARY_STORE_INITIAL_VERSION; +const primaryStoreListeners = new Set<() => void>(); +const secondaryStoreListeners = new Set<() => void>(); + +const subscribeToPrimaryStore = (listener: () => void) => { + primaryStoreListeners.add(listener); + return () => primaryStoreListeners.delete(listener); +}; + +const subscribeToSecondaryStore = (listener: () => void) => { + secondaryStoreListeners.add(listener); + return () => secondaryStoreListeners.delete(listener); +}; + +const getPrimaryStoreSnapshot = () => primaryStoreVersion; +const getSecondaryStoreSnapshot = () => secondaryStoreVersion; + +interface ExternalStoreVersionReaderProperties { + subscribe: (listener: () => void) => () => void; + getSnapshot: () => number; +} + +const ExternalStoreVersionReader = ({ + subscribe, + getSnapshot, +}: ExternalStoreVersionReaderProperties) => { + const version = useSyncExternalStore(subscribe, getSnapshot); + return {version}; +}; + +const CorrelatedExternalStoreOracle = () => { + const [useSecondaryStore, setUseSecondaryStore] = useState(false); + const updatePrimaryStore = () => { + primaryStoreVersion += STORE_VERSION_INCREMENT; + for (const listener of primaryStoreListeners) listener(); + }; + const updateSecondaryStore = () => { + secondaryStoreVersion += STORE_VERSION_INCREMENT; + for (const listener of secondaryStoreListeners) listener(); + }; + return ( +
    + + + + {useSecondaryStore ? ( + + ) : ( + + )} +
    + ); +}; + +const GuardedExternalStoreOracle = () => { + const [useSecondaryStore, setUseSecondaryStore] = useState(false); + const updatePrimaryStore = () => { + primaryStoreVersion += STORE_VERSION_INCREMENT; + for (const listener of primaryStoreListeners) listener(); + }; + const updateSecondaryStore = () => { + secondaryStoreVersion += STORE_VERSION_INCREMENT; + for (const listener of secondaryStoreListeners) listener(); + }; + return ( +
    + + + + +
    + ); +}; + +const EffectEventIdentityOracle = () => { + const [revision, setRevision] = useState(0); + const onEffectEvent = useEffectEvent(() => undefined); + useEffect(() => { + window.effectEventSetupRuns += 1; + }, [onEffectEvent]); + + return ( + + ); +}; + +const NavigationContext = createContext("POP"); + +const MemoizedNavigationReader = memo(() => { + const navigationType = useContext(NavigationContext); + const [observedNavigation, setObservedNavigation] = useState("unobserved"); + const onReadNavigation = useEffectEvent(() => setObservedNavigation(navigationType)); + useEffect(() => { + window.addEventListener("prover-read-navigation", onReadNavigation); + return () => window.removeEventListener("prover-read-navigation", onReadNavigation); + }, []); + return ( + <> + {navigationType} + {observedNavigation} + + ); +}); + +const EffectEventMemoContextOracle = () => { + const [navigationType, setNavigationType] = useState("POP"); + return ( + + + + + + ); +}; + +const RuntimeProviderContext = createContext("provider-default"); +const RuntimeConsumerContext = createContext("consumer-default"); + +interface ContextIdentityConsumerProperties { + useProviderIdentity: boolean; +} + +const ContextIdentityConsumer = ({ useProviderIdentity }: ContextIdentityConsumerProperties) => { + const value = useContext(useProviderIdentity ? RuntimeProviderContext : RuntimeConsumerContext); + return {value}; +}; + +const ContextIdentityOracle = () => { + const useProviderIdentity = new URLSearchParams(window.location.search).get("mode") === "same"; + return ( + + + + ); +}; + +const TabsContext = createContext("outside"); + +interface TabValueProperties { + testId: string; +} + +const TabValue = ({ testId }: TabValueProperties) => { + const value = useContext(TabsContext); + return {value}; +}; + +const NestedContextOracle = () => { + const hasNestedProvider = new URLSearchParams(window.location.search).get("mode") === "nested"; + return ( + + + {hasNestedProvider ? ( + + + + ) : ( + + )} + + ); +}; + +const loadRuntimeQuery = (query: string): Promise => + new Promise((resolve) => { + const delay = query === "alpha" ? SLOW_QUERY_DELAY_MS : FAST_QUERY_DELAY_MS; + setTimeout(() => resolve(query), delay); + }); + +const AsyncEffectOwnershipOracle = () => { + const [query, setQuery] = useState("alpha"); + const [result, setResult] = useState("pending"); + const isSafeMode = new URLSearchParams(window.location.search).get("mode") === "safe"; + + useEffect(() => { + let didLoseOwnership = false; + const loadResult = async () => { + const nextResult = await loadRuntimeQuery(query); + if (!isSafeMode || !didLoseOwnership) setResult(nextResult); + }; + void loadResult(); + return () => { + didLoseOwnership = true; + }; + }, [isSafeMode, query]); + + return ( +
    + + {result} +
    + ); +}; + +const RuntimeOracle = () => { + const oracle = new URLSearchParams(window.location.search).get("oracle"); + if (oracle === "keys") { + return ; + } + if (oracle === "external-store") { + return ; + } + if (oracle === "external-store-branches") { + return ; + } + if (oracle === "external-store-conditional") { + return ; + } + if (oracle === "effect-event") { + return ; + } + if (oracle === "effect-event-identity") { + return ; + } + if (oracle === "effect-event-memo-context") { + return ; + } + if (oracle === "context-identity") { + return ; + } + if (oracle === "nested-context") { + return ; + } + if (oracle === "async-effect-ownership") { + return ; + } + return ; +}; + +const rootElement = document.getElementById("root"); +if (!rootElement) throw new Error("Missing runtime oracle root"); +createRoot(rootElement).render(); diff --git a/packages/prover/tests/runtime/reconciliation-oracle.spec.ts b/packages/prover/tests/runtime/reconciliation-oracle.spec.ts new file mode 100644 index 0000000000..9f82742d07 --- /dev/null +++ b/packages/prover/tests/runtime/reconciliation-oracle.spec.ts @@ -0,0 +1,24 @@ +import { expect, test } from "@playwright/test"; + +test("confirms that an index key transfers state to a different item", async ({ page }) => { + await page.goto("/?oracle=keys&mode=index"); + await page.getByRole("listitem").filter({ hasText: "Alpha" }).getByRole("textbox").fill("typed"); + await page.getByRole("button", { name: "reverse" }).click(); + + await expect( + page.getByRole("listitem").filter({ hasText: "Beta" }).getByRole("textbox"), + ).toHaveValue("typed"); +}); + +test("confirms that a semantic key preserves state with its item", async ({ page }) => { + await page.goto("/?oracle=keys&mode=semantic"); + await page.getByRole("listitem").filter({ hasText: "Alpha" }).getByRole("textbox").fill("typed"); + await page.getByRole("button", { name: "reverse" }).click(); + + await expect( + page.getByRole("listitem").filter({ hasText: "Alpha" }).getByRole("textbox"), + ).toHaveValue("typed"); + await expect( + page.getByRole("listitem").filter({ hasText: "Beta" }).getByRole("textbox"), + ).toHaveValue(""); +}); diff --git a/packages/prover/tests/runtime/vite.config.ts b/packages/prover/tests/runtime/vite.config.ts new file mode 100644 index 0000000000..920d7a8519 --- /dev/null +++ b/packages/prover/tests/runtime/vite.config.ts @@ -0,0 +1,9 @@ +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vite"; + +const runtimeRoot = path.dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + root: runtimeRoot, +}); diff --git a/packages/prover/tests/summarize-function-returns.test.ts b/packages/prover/tests/summarize-function-returns.test.ts new file mode 100644 index 0000000000..9b865bb845 --- /dev/null +++ b/packages/prover/tests/summarize-function-returns.test.ts @@ -0,0 +1,446 @@ +import ts from "typescript"; +import { describe, expect, it } from "vite-plus/test"; +import { summarizeFunctionReturns } from "../src/summarize-function-returns.js"; + +const parseArrowFunction = (sourceText: string): ts.ArrowFunction => { + const sourceFile = ts.createSourceFile( + "factory.ts", + sourceText, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ); + let arrowFunction: ts.ArrowFunction | null = null; + const visit = (node: ts.Node): void => { + if (arrowFunction) return; + if (ts.isArrowFunction(node)) { + arrowFunction = node; + return; + } + node.forEachChild(visit); + }; + sourceFile.forEachChild(visit); + if (!arrowFunction) throw new Error("The test source has no arrow function"); + return arrowFunction; +}; + +describe("summarizeFunctionReturns", () => { + it("recognizes an expression body as one unconditional return", () => { + const summary = summarizeFunctionReturns(parseArrowFunction("const factory = () => handler;")); + + expect(summary.isComplete).toBe(true); + expect(summary.canFallThrough).toBe(false); + expect(summary.expressions).toHaveLength(1); + expect(summary.expressions[0]?.isConditionallyReached).toBe(false); + }); + + it("covers an early return and final return as conditional alternatives", () => { + const summary = summarizeFunctionReturns( + parseArrowFunction(` + const factory = (condition: boolean) => { + if (condition) return primaryHandler; + return secondaryHandler; + }; + `), + ); + + expect(summary.isComplete).toBe(true); + expect(summary.canFallThrough).toBe(false); + expect(summary.expressions).toHaveLength(2); + expect(summary.expressions.every((expression) => expression.isConditionallyReached)).toBe(true); + }); + + it("covers nested exhaustive branches without a fallthrough path", () => { + const summary = summarizeFunctionReturns( + parseArrowFunction(` + const factory = (outer: boolean, inner: boolean) => { + if (outer) { + if (inner) return firstHandler; + return secondHandler; + } else { + return thirdHandler; + } + }; + `), + ); + + expect(summary.isComplete).toBe(true); + expect(summary.canFallThrough).toBe(false); + expect(summary.expressions).toHaveLength(3); + }); + + it("keeps a partial branch incomplete through a later conditional return", () => { + const summary = summarizeFunctionReturns( + parseArrowFunction(` + const factory = (outer: boolean, inner: boolean) => { + if (outer && inner) return firstHandler; + }; + `), + ); + + expect(summary.isComplete).toBe(true); + expect(summary.canFallThrough).toBe(true); + expect(summary.expressions).toHaveLength(1); + }); + + it("covers every terminating clause when a switch has a default", () => { + const summary = summarizeFunctionReturns( + parseArrowFunction(` + const factory = () => { + switch (mode) { + case "primary": + return firstHandler; + default: + return secondHandler; + } + }; + `), + ); + + expect(summary.isComplete).toBe(true); + expect(summary.canFallThrough).toBe(false); + expect(summary.expressions).toHaveLength(2); + expect(summary.expressions.every((expression) => expression.isConditionallyReached)).toBe(true); + }); + + it("keeps a switch without a checked exhaustive type open", () => { + const summary = summarizeFunctionReturns( + parseArrowFunction(` + const factory = () => { + switch (mode) { + case "primary": + return firstHandler; + case "secondary": + return secondHandler; + } + }; + `), + ); + + expect(summary.isComplete).toBe(true); + expect(summary.canFallThrough).toBe(true); + expect(summary.expressions).toHaveLength(2); + }); + + it("fails closed when a switch clause can fall through", () => { + const summary = summarizeFunctionReturns( + parseArrowFunction(` + const factory = () => { + switch (mode) { + case "primary": + if (condition) return firstHandler; + default: + return secondHandler; + } + }; + `), + ); + + expect(summary.isComplete).toBe(false); + expect(summary.canFallThrough).toBe(true); + }); + + it("covers return alternatives from both try and catch paths", () => { + const summary = summarizeFunctionReturns( + parseArrowFunction(` + const factory = () => { + try { + return firstHandler; + } catch { + return secondHandler; + } + }; + `), + ); + + expect(summary.isComplete).toBe(true); + expect(summary.canFallThrough).toBe(false); + expect(summary.expressions).toHaveLength(2); + expect(summary.expressions.every((expression) => expression.isConditionallyReached)).toBe(true); + }); + + it("discharges an explicit throw through a returning catch", () => { + const summary = summarizeFunctionReturns( + parseArrowFunction(` + const factory = () => { + try { + throw new Error("fallback"); + } catch { + return fallbackHandler; + } + }; + `), + ); + + expect(summary.isComplete).toBe(true); + expect(summary.canFallThrough).toBe(false); + expect(summary.expressions).toHaveLength(1); + }); + + it("keeps a catch fallthrough path open", () => { + const summary = summarizeFunctionReturns( + parseArrowFunction(` + const factory = () => { + try { + return firstHandler; + } catch {} + }; + `), + ); + + expect(summary.isComplete).toBe(true); + expect(summary.canFallThrough).toBe(true); + expect(summary.expressions).toHaveLength(1); + }); + + it("fails closed when a catch rethrows", () => { + const summary = summarizeFunctionReturns( + parseArrowFunction(` + const factory = () => { + try { + return firstHandler; + } catch (error) { + throw error; + } + }; + `), + ); + + expect(summary.isComplete).toBe(false); + expect(summary.canFallThrough).toBe(false); + expect(summary.expressions).toHaveLength(1); + }); + + it("preserves protected returns through a normally completing finally", () => { + const summary = summarizeFunctionReturns( + parseArrowFunction(` + const factory = () => { + try { + return firstHandler; + } catch { + return secondHandler; + } finally { + completed = true; + } + }; + `), + ); + + expect(summary.isComplete).toBe(true); + expect(summary.canFallThrough).toBe(false); + expect(summary.expressions).toHaveLength(2); + }); + + it("replaces protected returns with an unconditional finally return", () => { + const summary = summarizeFunctionReturns( + parseArrowFunction(` + const factory = () => { + try { + return firstHandler; + } finally { + return finalHandler; + } + }; + `), + ); + + expect(summary.isComplete).toBe(true); + expect(summary.canFallThrough).toBe(false); + expect(summary.expressions).toHaveLength(1); + expect(summary.expressions[0]?.expression.getText()).toBe("finalHandler"); + }); + + it("combines protected and conditional finally returns", () => { + const summary = summarizeFunctionReturns( + parseArrowFunction(` + const factory = () => { + try { + return firstHandler; + } finally { + if (useFinalHandler) return finalHandler; + } + }; + `), + ); + + expect(summary.isComplete).toBe(true); + expect(summary.canFallThrough).toBe(false); + expect(summary.expressions).toHaveLength(2); + }); + + it("fails closed for an uncaught finally throw", () => { + const summary = summarizeFunctionReturns( + parseArrowFunction(` + const factory = () => { + try { + return firstHandler; + } finally { + throw new Error("failed"); + } + }; + `), + ); + + expect(summary.isComplete).toBe(false); + expect(summary.canFallThrough).toBe(false); + expect(summary.expressions).toHaveLength(0); + }); + + it("joins zero-iteration and first-iteration returns from a terminating while body", () => { + const summary = summarizeFunctionReturns( + parseArrowFunction(` + const factory = () => { + while (useFirstHandler) return firstHandler; + return secondHandler; + }; + `), + ); + + expect(summary.isComplete).toBe(true); + expect(summary.canFallThrough).toBe(false); + expect(summary.expressions).toHaveLength(2); + }); + + it("recognizes a terminal body in an unconditional while loop", () => { + const summary = summarizeFunctionReturns( + parseArrowFunction(` + const factory = () => { + while (true) { + return firstHandler; + } + }; + `), + ); + + expect(summary.isComplete).toBe(true); + expect(summary.canFallThrough).toBe(false); + expect(summary.expressions).toHaveLength(1); + }); + + it("ignores an unreachable body in a literal-false while loop", () => { + const summary = summarizeFunctionReturns( + parseArrowFunction(` + const factory = () => { + while (false) return firstHandler; + return secondHandler; + }; + `), + ); + + expect(summary.isComplete).toBe(true); + expect(summary.canFallThrough).toBe(false); + expect(summary.expressions).toHaveLength(1); + expect(summary.expressions[0]?.expression.getText()).toBe("secondHandler"); + }); + + it("proves a one-pass do-while-false branch join", () => { + const summary = summarizeFunctionReturns( + parseArrowFunction(` + const factory = () => { + do { + if (useFirstHandler) return firstHandler; + } while (false); + return secondHandler; + }; + `), + ); + + expect(summary.isComplete).toBe(true); + expect(summary.canFallThrough).toBe(false); + expect(summary.expressions).toHaveLength(2); + }); + + it("recognizes a terminal body in an unconditional for loop", () => { + const summary = summarizeFunctionReturns( + parseArrowFunction(` + const factory = () => { + for (;;) return firstHandler; + }; + `), + ); + + expect(summary.isComplete).toBe(true); + expect(summary.canFallThrough).toBe(false); + expect(summary.expressions).toHaveLength(1); + }); + + it("proves finite iteration over a fresh array literal", () => { + const summary = summarizeFunctionReturns( + parseArrowFunction(` + const factory = () => { + for (const mode of ["primary", "secondary"]) { + if (mode === selectedMode) return firstHandler; + } + return secondHandler; + }; + `), + ); + + expect(summary.isComplete).toBe(true); + expect(summary.canFallThrough).toBe(false); + expect(summary.expressions).toHaveLength(2); + }); + + it.each([ + `const factory = () => { + while (condition) { + if (otherCondition) return firstHandler; + } + return secondHandler; + };`, + `const factory = () => { + do { + if (condition) return firstHandler; + } while (otherCondition); + return secondHandler; + };`, + `const factory = () => { + for (const mode of [...modes]) { + if (mode === "primary") return firstHandler; + } + return secondHandler; + };`, + `const factory = () => { + for (const mode of modes) { + if (mode === "primary") return firstHandler; + } + return secondHandler; + };`, + `const factory = () => { + for (const mode of ["primary"]) { + if (mode === selectedMode) break; + } + return secondHandler; + };`, + ])("fails closed when loop termination is unproved in %s", (sourceText) => { + const summary = summarizeFunctionReturns(parseArrowFunction(sourceText)); + + expect(summary.isComplete).toBe(false); + }); + + it.each([ + "const factory = () => { return; };", + "const factory = () => { throw new Error('no handler'); };", + ])("fails closed when an exit cannot produce a callable in %s", (sourceText) => { + const summary = summarizeFunctionReturns(parseArrowFunction(sourceText)); + + expect(summary.isComplete).toBe(false); + expect(summary.canFallThrough).toBe(false); + expect(summary.expressions).toHaveLength(0); + }); + + it("ignores unreachable returns after a terminating statement", () => { + const summary = summarizeFunctionReturns( + parseArrowFunction(` + const factory = () => { + return firstHandler; + return secondHandler; + }; + `), + ); + + expect(summary.isComplete).toBe(true); + expect(summary.expressions).toHaveLength(1); + expect(summary.expressions[0]?.expression.getText()).toBe("firstHandler"); + }); +}); diff --git a/packages/prover/tsconfig.json b/packages/prover/tsconfig.json new file mode 100644 index 0000000000..9734bb300b --- /dev/null +++ b/packages/prover/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx", + "lib": ["ESNext", "DOM"], + "noEmit": true, + "types": ["node"] + }, + "include": ["src", "tests", "vite.config.ts"] +} diff --git a/packages/prover/vite.config.ts b/packages/prover/vite.config.ts new file mode 100644 index 0000000000..63fa539619 --- /dev/null +++ b/packages/prover/vite.config.ts @@ -0,0 +1,30 @@ +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vite-plus"; + +const packageRoot = path.dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + pack: [ + { + entry: { index: "./src/index.ts" }, + deps: { + neverBundle: ["@babel/core", "babel-plugin-react-compiler", "typescript"], + }, + dts: true, + target: "node22", + platform: "node", + fixedExtension: false, + }, + ], + test: { + alias: [ + { + find: /^@react-doctor\/prover$/, + replacement: path.join(packageRoot, "src/index.ts"), + }, + ], + include: ["tests/**/*.test.ts"], + testTimeout: 30_000, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dbb7baf5f0..705f7ce291 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -244,6 +244,37 @@ importers: specifier: ^25.6.0 version: 25.6.0 + packages/prover: + dependencies: + '@babel/core': + specifier: 8.0.1 + version: 8.0.1 + babel-plugin-react-compiler: + specifier: 1.0.0 + version: 1.0.0 + typescript: + specifier: '>=5.0.4 <7' + version: 6.0.3 + devDependencies: + '@playwright/test': + specifier: 1.61.1 + version: 1.61.1 + '@types/node': + specifier: ^25.6.0 + version: 25.6.0 + '@types/react': + specifier: 19.2.14 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.14) + react: + specifier: 19.2.5 + version: 19.2.5 + react-dom: + specifier: 19.2.5 + version: 19.2.5(react@19.2.5) + packages/react-doctor: dependencies: '@babel/code-frame': @@ -457,26 +488,50 @@ packages: resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} + '@babel/code-frame@8.0.0': + resolution: {integrity: sha512-dYYg153EyN2Ekbqw2zAsbd6/JR+9N2SEoC7YV2GyyqMM7x9bLDTjBD6XBhSMLH0wtIVyJj03jWNriQhaN+eoCw==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/compat-data@7.29.0': resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} engines: {node: '>=6.9.0'} + '@babel/compat-data@8.0.0': + resolution: {integrity: sha512-DOjnob/cXOUgDOozCDeq/aK2p5y8dUIVdf6tNhEV1HQRd6I8aQ4f4fbtHRVEvb6lP3BGomrKHiS8ICAASSVQSw==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/core@7.29.0': resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} engines: {node: '>=6.9.0'} + '@babel/core@8.0.1': + resolution: {integrity: sha512-5FgxM4dLQpMJHSiVATk8foW263dVHQHBVpXYiimNECVWG01f4nFyEbQixeT6Mwvg7TayREJ2gpKl3o2RoMdnqw==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/generator@7.29.1': resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} engines: {node: '>=6.9.0'} + '@babel/generator@8.0.0': + resolution: {integrity: sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-compilation-targets@7.28.6': resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} engines: {node: '>=6.9.0'} + '@babel/helper-compilation-targets@8.0.0': + resolution: {integrity: sha512-JwculLABZvyPvyLBpwU/E/IbH2uM3mnxNtIJpxnIfb24y1PrdVxK5Dqjle4DpgqpGRnwgC7G8IkzPdSXZrO1Ew==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-globals@7.28.0': resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} engines: {node: '>=6.9.0'} + '@babel/helper-globals@8.0.0': + resolution: {integrity: sha512-lLozHOM6sWWlxNo8CYqHy4MBZeTvHXNgVPBfPOGsjPKUzHC2Az9QwB6gxdQmpwHl6GlQtbGgS+lj5887guDiLw==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-module-imports@7.28.6': resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} engines: {node: '>=6.9.0'} @@ -495,6 +550,10 @@ packages: resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@8.0.0': + resolution: {integrity: sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-validator-identifier@7.28.5': resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} @@ -503,14 +562,26 @@ packages: resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@8.0.4': + resolution: {integrity: sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-validator-option@7.27.1': resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@8.0.0': + resolution: {integrity: sha512-U4Dybxh4WESWHt5XhBeExi4DrY0/DNK1aHpQbsrQXCUbFHuMweT0TpLEWKvaraV2Y6fS+ZXunsZ8zIuZIgvF2Q==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helpers@7.28.6': resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==} engines: {node: '>=6.9.0'} + '@babel/helpers@8.0.0': + resolution: {integrity: sha512-wfbi91pM3py96oIiJEz7qIpyXDytgr9zQC1HEWwlGNVRAEmItuU/0a41ZUKu1sJGyhhOIpc4t5vk4PYzt8wpsg==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/parser@7.29.0': resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} engines: {node: '>=6.0.0'} @@ -521,6 +592,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@8.0.4': + resolution: {integrity: sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g==} + engines: {node: ^22.18.0 || >=24.11.0} + hasBin: true + '@babel/runtime@7.29.2': resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} engines: {node: '>=6.9.0'} @@ -529,10 +605,18 @@ packages: resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} + '@babel/template@8.0.0': + resolution: {integrity: sha512-eAD0QW/AlbamBbw0FeGiwasbCVPq5ncW0HNVyLP3B9czqLyh4gvw+5JTSNt6le9+ziAU7mqDZsKTHf3jTb4chQ==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/traverse@7.29.0': resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} engines: {node: '>=6.9.0'} + '@babel/traverse@8.0.4': + resolution: {integrity: sha512-bZnmqzGG8UZneG1lLxBoWIH0G6Gr1D846Yu4/3XnY6FhCndMR49u26nTY08u/dAxWmLWF9vGQOuC+84FfIUoeg==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/types@7.29.0': resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} @@ -541,6 +625,10 @@ packages: resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} + '@babel/types@8.0.4': + resolution: {integrity: sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==} + engines: {node: ^22.18.0 || >=24.11.0} + '@changesets/apply-release-plan@7.1.1': resolution: {integrity: sha512-9qPCm/rLx/xoOFXIHGB229+4GOL76S4MC+7tyOuTsR6+1jYlfFDQORdvwR5hDA6y4FL2BPt3qpbcQIS+dW85LA==} @@ -2137,6 +2225,11 @@ packages: cpu: [x64] os: [win32] + '@playwright/test@1.61.1': + resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==} + engines: {node: '>=18'} + hasBin: true + '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -2485,6 +2578,12 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/gensync@1.0.5': + resolution: {integrity: sha512-MbsRCT7mTikHwKZ0X+LVUTLRrZZRLipTuXEO9qOYO+zmjMVk81axyClMROf6uoPD9MRVu46bx8zoR0Ad9q3NAg==} + + '@types/jsesc@2.5.1': + resolution: {integrity: sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -2503,6 +2602,11 @@ packages: '@types/prompts@2.4.9': resolution: {integrity: sha512-qTxFi6Buiu8+50/+3DGIWLHM6QuWsEKugJnnP6iv2Mc4ncxE4A/OJkjuVOA+5X0X1S/nq5VJRa8Lu+nwcvbrKA==} + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} @@ -2788,6 +2892,9 @@ packages: axios@1.18.1: resolution: {integrity: sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==} + babel-plugin-react-compiler@1.0.0: + resolution: {integrity: sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -3010,6 +3117,10 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + empathic@2.0.1: + resolution: {integrity: sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==} + engines: {node: '>=14'} + enquirer@2.4.1: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} @@ -3239,6 +3350,11 @@ packages: resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} engines: {node: '>=6 <7 || >=8'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -3347,6 +3463,9 @@ packages: resolution: {integrity: sha512-pYkiyXVL2Mf3pozdlDGV6NAObxQx13Ae8knZk1UJRJ6uRW/ZRmTGHlQYtrsSl7ubuE5F8CD1z+s1n4RHNuTtuA==} engines: {node: '>=18'} + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} @@ -3444,6 +3563,9 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -3598,6 +3720,10 @@ packages: long@5.3.2: resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -3834,6 +3960,16 @@ packages: resolution: {integrity: sha512-xhcb4yHu9sM/G7foGzoLtXYcC0zHEaOXXjRKhGup0fw78Nf2Tkiapv4EQyMzrbcmQPsllAI7DbFY2UT7PlI9Pg==} hasBin: true + playwright-core@1.61.1: + resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.61.1: + resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} + engines: {node: '>=18'} + hasBin: true + pngjs@7.0.0: resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} engines: {node: '>=14.19.0'} @@ -3883,6 +4019,11 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + react-dom@19.2.5: + resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==} + peerDependencies: + react: ^19.2.5 + react-reconciler@0.33.0: resolution: {integrity: sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==} engines: {node: '>=0.10.0'} @@ -4597,8 +4738,15 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 + '@babel/code-frame@8.0.0': + dependencies: + '@babel/helper-validator-identifier': 8.0.4 + js-tokens: 10.0.0 + '@babel/compat-data@7.29.0': {} + '@babel/compat-data@8.0.0': {} + '@babel/core@7.29.0': dependencies: '@babel/code-frame': 7.29.0 @@ -4609,7 +4757,7 @@ snapshots: '@babel/parser': 7.29.0 '@babel/template': 7.28.6 '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 debug: 4.4.3 @@ -4619,12 +4767,40 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/core@8.0.1': + dependencies: + '@babel/code-frame': 8.0.0 + '@babel/generator': 8.0.0 + '@babel/helper-compilation-targets': 8.0.0 + '@babel/helpers': 8.0.0 + '@babel/parser': 8.0.4 + '@babel/template': 8.0.0 + '@babel/traverse': 8.0.4 + '@babel/types': 8.0.4 + '@types/gensync': 1.0.5 + convert-source-map: 2.0.0 + empathic: 2.0.1 + gensync: 1.0.0-beta.2 + import-meta-resolve: 4.2.0 + json5: 2.2.3 + obug: 2.1.1 + semver: 7.7.4 + '@babel/generator@7.29.1': dependencies: '@babel/parser': 7.29.0 - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/generator@8.0.0': + dependencies: + '@babel/parser': 8.0.4 + '@babel/types': 8.0.4 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 + '@types/jsesc': 2.5.1 jsesc: 3.1.0 '@babel/helper-compilation-targets@7.28.6': @@ -4635,12 +4811,22 @@ snapshots: lru-cache: 5.1.1 semver: 6.3.1 + '@babel/helper-compilation-targets@8.0.0': + dependencies: + '@babel/compat-data': 8.0.0 + '@babel/helper-validator-option': 8.0.0 + browserslist: 4.28.1 + lru-cache: 11.5.2 + semver: 7.7.4 + '@babel/helper-globals@7.28.0': {} + '@babel/helper-globals@8.0.0': {} + '@babel/helper-module-imports@7.28.6': dependencies: '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -4657,32 +4843,53 @@ snapshots: '@babel/helper-string-parser@7.29.7': {} + '@babel/helper-string-parser@8.0.0': {} + '@babel/helper-validator-identifier@7.28.5': {} '@babel/helper-validator-identifier@7.29.7': {} + '@babel/helper-validator-identifier@8.0.4': {} + '@babel/helper-validator-option@7.27.1': {} + '@babel/helper-validator-option@8.0.0': {} + '@babel/helpers@7.28.6': dependencies: '@babel/template': 7.28.6 - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 + + '@babel/helpers@8.0.0': + dependencies: + '@babel/template': 8.0.0 + '@babel/types': 8.0.4 '@babel/parser@7.29.0': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@babel/parser@7.29.7': dependencies: '@babel/types': 7.29.7 + '@babel/parser@8.0.4': + dependencies: + '@babel/types': 8.0.4 + '@babel/runtime@7.29.2': {} '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.29.0 '@babel/parser': 7.29.0 - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 + + '@babel/template@8.0.0': + dependencies: + '@babel/code-frame': 8.0.0 + '@babel/parser': 8.0.4 + '@babel/types': 8.0.4 '@babel/traverse@7.29.0': dependencies: @@ -4691,11 +4898,21 @@ snapshots: '@babel/helper-globals': 7.28.0 '@babel/parser': 7.29.0 '@babel/template': 7.28.6 - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 debug: 4.4.3 transitivePeerDependencies: - supports-color + '@babel/traverse@8.0.4': + dependencies: + '@babel/code-frame': 8.0.0 + '@babel/generator': 8.0.0 + '@babel/helper-globals': 8.0.0 + '@babel/parser': 8.0.4 + '@babel/template': 8.0.0 + '@babel/types': 8.0.4 + obug: 2.1.1 + '@babel/types@7.29.0': dependencies: '@babel/helper-string-parser': 7.27.1 @@ -4706,6 +4923,11 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@babel/types@8.0.4': + dependencies: + '@babel/helper-string-parser': 8.0.0 + '@babel/helper-validator-identifier': 8.0.4 + '@changesets/apply-release-plan@7.1.1': dependencies: '@changesets/config': 3.1.4 @@ -5993,6 +6215,10 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.74.0': optional: true + '@playwright/test@1.61.1': + dependencies: + playwright: 1.61.1 + '@polka/url@1.0.0-next.29': {} '@protobufjs/aspromise@1.1.2': {} @@ -6250,6 +6476,10 @@ snapshots: '@types/estree@1.0.8': {} + '@types/gensync@1.0.5': {} + + '@types/jsesc@2.5.1': {} + '@types/json-schema@7.0.15': {} '@types/minimatch@5.1.2': {} @@ -6267,6 +6497,10 @@ snapshots: '@types/node': 25.6.0 kleur: 3.0.3 + '@types/react-dom@19.2.3(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + '@types/react@19.2.14': dependencies: csstype: 3.2.3 @@ -6560,6 +6794,10 @@ snapshots: - debug - supports-color + babel-plugin-react-compiler@1.0.0: + dependencies: + '@babel/types': 7.29.7 + balanced-match@1.0.2: {} balanced-match@4.0.4: {} @@ -6765,6 +7003,8 @@ snapshots: emoji-regex@8.0.0: {} + empathic@2.0.1: {} + enquirer@2.4.1: dependencies: ansi-colors: 4.1.3 @@ -7075,6 +7315,9 @@ snapshots: jsonfile: 4.0.0 universalify: 0.1.2 + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -7180,6 +7423,8 @@ snapshots: cjs-module-lexer: 2.2.0 module-details-from-path: 1.0.4 + import-meta-resolve@4.2.0: {} + imurmurhash@0.1.4: {} indent-string@5.0.0: {} @@ -7266,6 +7511,8 @@ snapshots: jiti@2.7.0: {} + js-tokens@10.0.0: {} + js-tokens@4.0.0: {} js-yaml@3.14.2: @@ -7380,6 +7627,8 @@ snapshots: long@5.3.2: {} + lru-cache@11.5.2: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -7713,6 +7962,14 @@ snapshots: dependencies: pngjs: 7.0.0 + playwright-core@1.61.1: {} + + playwright@1.61.1: + dependencies: + playwright-core: 1.61.1 + optionalDependencies: + fsevents: 2.3.2 + pngjs@7.0.0: {} postcss@8.5.6: @@ -7758,6 +8015,11 @@ snapshots: queue-microtask@1.2.3: {} + react-dom@19.2.5(react@19.2.5): + dependencies: + react: 19.2.5 + scheduler: 0.27.0 + react-reconciler@0.33.0(react@19.2.5): dependencies: react: 19.2.5 From 4475d1448ee34e9c3d66a5d5b953b3e8cdcc6a4e Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Tue, 28 Jul 2026 11:14:24 +0000 Subject: [PATCH 02/23] feat(prover): prove finite JSX prop spreads --- packages/prover/README.md | 10 +- packages/prover/research-log.md | 59 ++++-- .../prover/src/analyze-boundary-coverage.ts | 51 ++++- .../src/create-component-callback-flow.ts | 176 ++++++++++++++---- .../prover/src/resolve-callable-expression.ts | 5 +- .../utils/collect-jsx-spread-properties.ts | 48 +++++ .../prover/src/utils/collect-symbol-writes.ts | 6 + .../is-direct-component-properties-object.ts | 28 +++ .../utils/is-effective-jsx-property-source.ts | 25 +++ .../src/utils/is-intrinsic-jsx-element.ts | 4 + .../utils/is-jsx-spread-source-complete.ts | 89 +++++++++ .../incomplete-event-prop-spread/src/app.tsx | 11 -- .../src/app.tsx | 16 ++ .../tsconfig.json | 0 .../src/app.tsx | 13 ++ .../tsconfig.json | 0 .../src/app.tsx | 12 ++ .../tsconfig.json | 4 + .../proved-event-prop-spread/src/app.tsx | 16 ++ .../proved-event-prop-spread/tsconfig.json | 4 + .../src/app.tsx | 0 .../tsconfig.json | 4 + .../src/app.tsx | 14 ++ .../tsconfig.json | 4 + .../src/app.tsx | 11 ++ .../tsconfig.json | 4 + .../src/app.tsx | 15 ++ .../tsconfig.json | 4 + .../proved-rest-event-prop-spread/src/app.tsx | 25 +++ .../tsconfig.json | 4 + packages/prover/tests/prove-react-app.test.ts | 84 ++++++++- .../runtime/jsx-spread-order-oracle.spec.ts | 15 ++ packages/prover/tests/runtime/main.tsx | 31 +++ 33 files changed, 713 insertions(+), 79 deletions(-) create mode 100644 packages/prover/src/utils/collect-jsx-spread-properties.ts create mode 100644 packages/prover/src/utils/is-direct-component-properties-object.ts create mode 100644 packages/prover/src/utils/is-effective-jsx-property-source.ts create mode 100644 packages/prover/src/utils/is-intrinsic-jsx-element.ts create mode 100644 packages/prover/src/utils/is-jsx-spread-source-complete.ts delete mode 100644 packages/prover/tests/fixtures/incomplete-event-prop-spread/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-jsx-spread-leading-explicit-event/src/app.tsx rename packages/prover/tests/fixtures/{incomplete-event-prop-spread => incomplete-jsx-spread-leading-explicit-event}/tsconfig.json (100%) create mode 100644 packages/prover/tests/fixtures/incomplete-jsx-spread-mutated-object/src/app.tsx rename packages/prover/tests/fixtures/{incomplete-external-store-callback-prop-spread => incomplete-jsx-spread-mutated-object}/tsconfig.json (100%) create mode 100644 packages/prover/tests/fixtures/incomplete-jsx-spread-open-ended-event/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-jsx-spread-open-ended-event/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-event-prop-spread/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-event-prop-spread/tsconfig.json rename packages/prover/tests/fixtures/{incomplete-external-store-callback-prop-spread => proved-external-store-callback-prop-spread}/src/app.tsx (100%) create mode 100644 packages/prover/tests/fixtures/proved-external-store-callback-prop-spread/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-intrinsic-event-prop-spread/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-intrinsic-event-prop-spread/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-jsx-spread-trailing-explicit-event/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-jsx-spread-trailing-explicit-event/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-jsx-spread-trailing-spread-event/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-jsx-spread-trailing-spread-event/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-rest-event-prop-spread/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-rest-event-prop-spread/tsconfig.json create mode 100644 packages/prover/tests/runtime/jsx-spread-order-oracle.spec.ts diff --git a/packages/prover/README.md b/packages/prover/README.md index 01678a6152..738e0d8022 100644 --- a/packages/prover/README.md +++ b/packages/prover/README.md @@ -34,10 +34,12 @@ The report includes: 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, with each use tied to its exact execution phase and JSX render - site; finite symbol-identified path guards preserve correlated ternary alternatives without - relying on source order, including immutable identifier guards substituted through source - callback factories; + 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; - 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. diff --git a/packages/prover/research-log.md b/packages/prover/research-log.md index c1f209dbf8..066cd38634 100644 --- a/packages/prover/research-log.md +++ b/packages/prover/research-log.md @@ -219,6 +219,22 @@ model and must not become the whole-app proof substrate. 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. ### Current proof model @@ -289,11 +305,13 @@ destructured props, renamed bindings, object-parameter property reads, prop-name 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 spread, -computed expression, missing render site, imported component, or cycle leaves the channel -incomplete. 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. +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 @@ -318,9 +336,9 @@ identity, survive aliases and component-prop forwarding, and are serialized as g 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, JSX spreads, 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. +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, @@ -431,6 +449,7 @@ Proved: - `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` @@ -441,6 +460,11 @@ Proved: - `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` @@ -542,13 +566,14 @@ Incomplete: - `helper-effect-state-update` - `conditional-helper-effect-cleanup` - `external-store-helper-boundary` -- `incomplete-external-store-callback-prop-spread` - `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-event-prop-spread` +- `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` @@ -581,8 +606,9 @@ 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 JSX spreads, computed/defaulted prop expressions, mutated/computed object - fields, logical aliases crossing opaque registries, grouped switch cases, fallthrough clauses, +- 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, @@ -614,9 +640,9 @@ components and hooks, including hooks hidden in incorrectly named helper functio ### Next architecture 1. Add callable SSA joins for ranked loops, mutable/defaulted/rest/computed iteration bindings, - switch fallthrough and grouped cases, property writes, JSX spreads, and checked library - contracts, including synchronous throw summaries, Promise continuations, and user-defined - registration APIs. + 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, @@ -647,3 +673,6 @@ components and hooks, including hooks hidden in incorrectly named helper functio 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. diff --git a/packages/prover/src/analyze-boundary-coverage.ts b/packages/prover/src/analyze-boundary-coverage.ts index ecb4784575..d827b7a6a8 100644 --- a/packages/prover/src/analyze-boundary-coverage.ts +++ b/packages/prover/src/analyze-boundary-coverage.ts @@ -25,6 +25,10 @@ import { ReactProofClaim, ReactUnitKind, } from "./types.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 { isJsxSpreadSourceComplete } from "./utils/is-jsx-spread-source-complete.js"; import type { ReactAnalysisContext, ReactProofEvidence, @@ -118,13 +122,14 @@ export const analyzeBoundaryCoverage = ( ), ); }; - const isCompleteEventFlow = (attribute: ts.JsxAttribute): boolean => { + 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 && @@ -133,6 +138,7 @@ export const analyzeBoundaryCoverage = ( context.graph.callbackPropFlows.some( (propFlow) => propFlow.renderOwnerId === semanticOwnerId && + propFlow.propName === eventName && propFlow.phase === ReactExecutionPhase.Event && propFlow.complete && propFlow.location.filePath === location.filePath && @@ -364,9 +370,10 @@ export const analyzeBoundaryCoverage = ( if ( ts.isJsxAttribute(node) && REACT_EVENT_PROP_PATTERN.test(node.name.getText()) && - node.initializer + node.initializer && + isEffectiveJsxPropertySource(node, node.name.getText(), context.typeChecker) ) { - if (!isCompleteEventFlow(node)) { + if (!isCompleteEventFlow(node, node.name.getText())) { unknownEvidence.push( createEvidence( node, @@ -377,6 +384,44 @@ export const analyzeBoundaryCoverage = ( ); } } + 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"], + ), + ); + } + } node.forEachChild(visit); }; functionNode.forEachChild(visit); diff --git a/packages/prover/src/create-component-callback-flow.ts b/packages/prover/src/create-component-callback-flow.ts index 2661d3e7b1..c548b68be2 100644 --- a/packages/prover/src/create-component-callback-flow.ts +++ b/packages/prover/src/create-component-callback-flow.ts @@ -8,6 +8,11 @@ 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 { 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, @@ -24,14 +29,14 @@ export interface ComponentEventBindingDescriptor { callbacks: ReadonlyArray; eventName: string; isComplete: boolean; - node: ts.JsxAttribute; + node: ts.JsxAttributeLike; ownerFunction: ts.FunctionLikeDeclaration; } export interface ComponentCallbackPropFlowDescriptor { callbacks: ReadonlyArray; isComplete: boolean; - node: ts.JsxAttribute; + node: ts.JsxAttributeLike; phase: ReactExecutionPhase; propName: string; renderNode: ts.JsxOpeningLikeElement; @@ -71,7 +76,7 @@ interface ComponentPropChannel { interface ComponentPropBinding { callbacks: ReadonlyArray; isComplete: boolean; - node: ts.JsxAttribute; + node: ts.JsxAttributeLike; renderNode: ts.JsxOpeningLikeElement; renderOwnerFunction: ts.FunctionLikeDeclaration; sourceChannel: ComponentPropChannel | null; @@ -85,6 +90,13 @@ interface CallbackSource { isComplete: boolean; } +interface ComponentEventSource { + eventName: string; + node: ts.JsxAttributeLike; + ownerFunction: ts.FunctionLikeDeclaration; + source: CallbackSource; +} + interface ResolvedCallbackSource { callbacks: ReadonlyArray; isComplete: boolean; @@ -143,9 +155,6 @@ const getTargetFunction = ( return unitFunctionsBySymbol.get(targetSymbol) ?? null; }; -const isIntrinsicElement = (openingElement: ts.JsxOpeningLikeElement): boolean => - ts.isIdentifier(openingElement.tagName) && /^[a-z]/.test(openingElement.tagName.text); - const deduplicateCallbacks = ( callbacks: ReadonlyArray, ): ReadonlyArray => { @@ -258,18 +267,44 @@ const createExpressionCallbackSource = ( }; }; +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: Array<{ - eventName: string; - node: ts.JsxAttribute; - ownerFunction: ts.FunctionLikeDeclaration; - source: CallbackSource; - }> = []; + const eventSources: ComponentEventSource[] = []; const componentPropReferencesByCallback = new Map< string, ReadonlyArray @@ -300,36 +335,107 @@ export const createComponentCallbackFlow = ( unitFunctionsBySymbol, typeChecker, ); - for (const attribute of openingElement.attributes.properties) { - if (!ts.isJsxAttribute(attribute)) continue; - const expression = getJsxAttributeExpression(attribute); - if (!expression) continue; - const source = createExpressionCallbackSource(expression, ownerFunction, typeChecker); - const propName = attribute.name.getText(); - if (isIntrinsicElement(openingElement) && REACT_EVENT_PROP_PATTERN.test(propName)) { - eventSources.push({ + 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: attribute, + node: sourceNode, ownerFunction, source, }); } - if (targetFunction) { - const targetChannel = { functionNode: targetFunction, propName }; - const channelIdentity = getChannelIdentity(targetChannel); - const bindings = propBindingsByChannel.get(channelIdentity) ?? []; - bindings.push({ - callbacks: source.callbacks, - isComplete: source.isComplete, - node: attribute, - renderNode: openingElement, - renderOwnerFunction: ownerFunction, - sourceChannel: source.channel, - targetChannel, - targetFunction, + 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, }); - propBindingsByChannel.set(channelIdentity, bindings); } + }; + 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); }; diff --git a/packages/prover/src/resolve-callable-expression.ts b/packages/prover/src/resolve-callable-expression.ts index ea59be2a8c..b8109fa33f 100644 --- a/packages/prover/src/resolve-callable-expression.ts +++ b/packages/prover/src/resolve-callable-expression.ts @@ -342,9 +342,12 @@ const resolveObjectLiteral = ( continue; } if (ts.isShorthandPropertyAssignment(property)) { + const valueSymbol = typeChecker.getShorthandAssignmentValueSymbol(property); properties.set( property.name.text, - resolveCallableExpressionWithState(property.name, typeChecker, bindings, state), + valueSymbol + ? resolveSymbolValue(valueSymbol, typeChecker, bindings, state) + : createEmptyCallableValue(false), ); continue; } 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 0000000000..e1421a1e61 --- /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-symbol-writes.ts b/packages/prover/src/utils/collect-symbol-writes.ts index d68dda7617..53d2ca54c9 100644 --- a/packages/prover/src/utils/collect-symbol-writes.ts +++ b/packages/prover/src/utils/collect-symbol-writes.ts @@ -8,6 +8,9 @@ export const collectSymbolWrites = ( 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) || @@ -57,6 +60,9 @@ export const collectSymbolWrites = ( ) { writes.push(node); } + if (ts.isDeleteExpression(node) && isSymbolWriteTarget(node.expression)) { + writes.push(node); + } if ( (ts.isForInStatement(node) || ts.isForOfStatement(node)) && !ts.isVariableDeclarationList(node.initializer) && 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 0000000000..6a7ee41123 --- /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 0000000000..14ac5aa5e7 --- /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-intrinsic-jsx-element.ts b/packages/prover/src/utils/is-intrinsic-jsx-element.ts new file mode 100644 index 0000000000..3f55837652 --- /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 0000000000..5c4843fbc9 --- /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/tests/fixtures/incomplete-event-prop-spread/src/app.tsx b/packages/prover/tests/fixtures/incomplete-event-prop-spread/src/app.tsx deleted file mode 100644 index dc9232b246..0000000000 --- a/packages/prover/tests/fixtures/incomplete-event-prop-spread/src/app.tsx +++ /dev/null @@ -1,11 +0,0 @@ -interface ActionButtonProperties { - onActivate: () => void; -} - -const ActionButton = ({ onActivate }: ActionButtonProperties) => ( - -); - -export const Toolbar = (properties: ActionButtonProperties) => ; diff --git a/packages/prover/tests/fixtures/incomplete-jsx-spread-leading-explicit-event/src/app.tsx b/packages/prover/tests/fixtures/incomplete-jsx-spread-leading-explicit-event/src/app.tsx new file mode 100644 index 0000000000..5fc9b88995 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-jsx-spread-leading-explicit-event/src/app.tsx @@ -0,0 +1,16 @@ +interface ButtonProperties { + onClick?: () => void; +} + +interface ApplicationProperties { + fallbackProperties: ButtonProperties; +} + +export const Application = ({ fallbackProperties }: ApplicationProperties) => { + const handleClick = () => undefined; + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/incomplete-event-prop-spread/tsconfig.json b/packages/prover/tests/fixtures/incomplete-jsx-spread-leading-explicit-event/tsconfig.json similarity index 100% rename from packages/prover/tests/fixtures/incomplete-event-prop-spread/tsconfig.json rename to packages/prover/tests/fixtures/incomplete-jsx-spread-leading-explicit-event/tsconfig.json diff --git a/packages/prover/tests/fixtures/incomplete-jsx-spread-mutated-object/src/app.tsx b/packages/prover/tests/fixtures/incomplete-jsx-spread-mutated-object/src/app.tsx new file mode 100644 index 0000000000..a3e8319bda --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-jsx-spread-mutated-object/src/app.tsx @@ -0,0 +1,13 @@ +export const Application = () => { + const firstHandler = () => undefined; + const secondHandler = () => undefined; + const buttonProperties = { + onClick: firstHandler, + }; + buttonProperties.onClick = secondHandler; + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/incomplete-external-store-callback-prop-spread/tsconfig.json b/packages/prover/tests/fixtures/incomplete-jsx-spread-mutated-object/tsconfig.json similarity index 100% rename from packages/prover/tests/fixtures/incomplete-external-store-callback-prop-spread/tsconfig.json rename to packages/prover/tests/fixtures/incomplete-jsx-spread-mutated-object/tsconfig.json diff --git a/packages/prover/tests/fixtures/incomplete-jsx-spread-open-ended-event/src/app.tsx b/packages/prover/tests/fixtures/incomplete-jsx-spread-open-ended-event/src/app.tsx new file mode 100644 index 0000000000..4576826467 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-jsx-spread-open-ended-event/src/app.tsx @@ -0,0 +1,12 @@ +interface ApplicationProperties { + fallbackProperties: Record; +} + +export const Application = ({ fallbackProperties }: ApplicationProperties) => { + const handleClick = () => undefined; + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/incomplete-jsx-spread-open-ended-event/tsconfig.json b/packages/prover/tests/fixtures/incomplete-jsx-spread-open-ended-event/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-jsx-spread-open-ended-event/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-event-prop-spread/src/app.tsx b/packages/prover/tests/fixtures/proved-event-prop-spread/src/app.tsx new file mode 100644 index 0000000000..58ac26aa6c --- /dev/null +++ b/packages/prover/tests/fixtures/proved-event-prop-spread/src/app.tsx @@ -0,0 +1,16 @@ +interface ActionButtonProperties { + onActivate: () => void; +} + +const ActionButton = ({ onActivate }: ActionButtonProperties) => ( + +); + +const Toolbar = (properties: ActionButtonProperties) => ; + +export const Application = () => { + const handleActivate = () => undefined; + return ; +}; diff --git a/packages/prover/tests/fixtures/proved-event-prop-spread/tsconfig.json b/packages/prover/tests/fixtures/proved-event-prop-spread/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-event-prop-spread/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-external-store-callback-prop-spread/src/app.tsx b/packages/prover/tests/fixtures/proved-external-store-callback-prop-spread/src/app.tsx similarity index 100% rename from packages/prover/tests/fixtures/incomplete-external-store-callback-prop-spread/src/app.tsx rename to packages/prover/tests/fixtures/proved-external-store-callback-prop-spread/src/app.tsx diff --git a/packages/prover/tests/fixtures/proved-external-store-callback-prop-spread/tsconfig.json b/packages/prover/tests/fixtures/proved-external-store-callback-prop-spread/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-external-store-callback-prop-spread/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-intrinsic-event-prop-spread/src/app.tsx b/packages/prover/tests/fixtures/proved-intrinsic-event-prop-spread/src/app.tsx new file mode 100644 index 0000000000..d4df7d2c58 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-intrinsic-event-prop-spread/src/app.tsx @@ -0,0 +1,14 @@ +interface ButtonProperties { + onClick: () => void; +} + +const Button = (properties: ButtonProperties) => ( + +); + +export const Application = () => { + const handleClick = () => undefined; + return + ); +}; diff --git a/packages/prover/tests/fixtures/proved-jsx-spread-trailing-explicit-event/tsconfig.json b/packages/prover/tests/fixtures/proved-jsx-spread-trailing-explicit-event/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-jsx-spread-trailing-explicit-event/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-jsx-spread-trailing-spread-event/src/app.tsx b/packages/prover/tests/fixtures/proved-jsx-spread-trailing-spread-event/src/app.tsx new file mode 100644 index 0000000000..9f55343185 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-jsx-spread-trailing-spread-event/src/app.tsx @@ -0,0 +1,15 @@ +interface ButtonProperties { + onClick?: () => void; +} + +export const Application = () => { + const handleExplicitClick = () => undefined; + const spreadProperties: ButtonProperties = { + onClick: () => undefined, + }; + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/proved-jsx-spread-trailing-spread-event/tsconfig.json b/packages/prover/tests/fixtures/proved-jsx-spread-trailing-spread-event/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-jsx-spread-trailing-spread-event/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-rest-event-prop-spread/src/app.tsx b/packages/prover/tests/fixtures/proved-rest-event-prop-spread/src/app.tsx new file mode 100644 index 0000000000..9b06bdfcc6 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-rest-event-prop-spread/src/app.tsx @@ -0,0 +1,25 @@ +interface MenuItemProperties { + __scopeContextMenu?: string; + label: string; + onSelect: () => void; +} + +interface PrimitiveItemProperties { + label: string; + onSelect: () => void; +} + +const PrimitiveItem = ({ label, onSelect }: PrimitiveItemProperties) => ( + +); + +const ContextMenuItem = ({ __scopeContextMenu: _scope, ...itemProperties }: MenuItemProperties) => ( + +); + +export const Application = () => { + const selectItem = () => undefined; + return ; +}; diff --git a/packages/prover/tests/fixtures/proved-rest-event-prop-spread/tsconfig.json b/packages/prover/tests/fixtures/proved-rest-event-prop-spread/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-rest-event-prop-spread/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/prove-react-app.test.ts b/packages/prover/tests/prove-react-app.test.ts index 79d1b66823..d3559a29a6 100644 --- a/packages/prover/tests/prove-react-app.test.ts +++ b/packages/prover/tests/prove-react-app.test.ts @@ -537,13 +537,14 @@ describe("proveReactApp", () => { ).toBe(true); }); - it("fails closed when external-store callback props cross a JSX spread", () => { - const report = proveFixture("incomplete-external-store-callback-prop-spread"); + it("proves external-store callback props forwarded through a finite JSX spread", () => { + const report = proveFixture("proved-external-store-callback-prop-spread"); const externalStore = report.graph.externalStores[0]; - expect(report.status).toBe(ReactAppProofStatus.Incomplete); - expect(externalStore?.subscribeComplete).toBe(false); - expect(externalStore?.snapshotComplete).toBe(false); + expect(report.status).toBe(ReactAppProofStatus.Proved); + expect(externalStore?.subscribeComplete).toBe(true); + expect(externalStore?.snapshotComplete).toBe(true); + expect(report.graph.callbackPropFlows.every((propFlow) => propFlow.complete)).toBe(true); }); it("fails closed when intra-attribute external-store callbacks use different guards", () => { @@ -1137,7 +1138,7 @@ describe("proveReactApp", () => { "mismatched-external-store-callback-prop-server-snapshot", "mismatched-external-store-conditional-props", "mismatched-external-store-conditional-factory", - "incomplete-external-store-callback-prop-spread", + "proved-external-store-callback-prop-spread", "incomplete-external-store-callback-prop-conditional-join", "incomplete-external-store-conditional-factory", "incomplete-external-store-mutated-conditional-props", @@ -1150,7 +1151,14 @@ describe("proveReactApp", () => { "callback-parameter-opaque-registration", "proved-event-prop-flow", "proved-forwarded-event-prop", - "incomplete-event-prop-spread", + "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", + "incomplete-jsx-spread-leading-explicit-event", + "incomplete-jsx-spread-open-ended-event", + "incomplete-jsx-spread-mutated-object", "proved-effect-callback-prop", "proved-mixed-phase-callback-prop", "proved-cleanup-callback-prop", @@ -1617,8 +1625,41 @@ describe("proveReactApp", () => { expect(boundaryProof?.evidence[0]?.description).toMatch(/callable-value boundary/); }); - it("fails closed when a callback prop crosses an object spread", () => { - const report = proveFixture("incomplete-event-prop-spread"); + it.each([ + "proved-event-prop-spread", + "proved-rest-event-prop-spread", + "proved-intrinsic-event-prop-spread", + ])("proves finite callback forwarding through JSX spreads in %s", (fixtureName) => { + const report = proveFixture(fixtureName); + + expect(report.status).toBe(ReactAppProofStatus.Proved); + expect(report.graph.eventBindings[0]?.complete).toBe(true); + expect(report.graph.eventBindings[0]?.callbackIds).toHaveLength(1); + }); + + it("applies later JSX attributes as callback-prop overrides", () => { + const trailingExplicitReport = proveFixture("proved-jsx-spread-trailing-explicit-event"); + const trailingSpreadReport = proveFixture("proved-jsx-spread-trailing-spread-event"); + const unresolvedSpreadReport = proveFixture("incomplete-jsx-spread-leading-explicit-event"); + const boundaryProof = unresolvedSpreadReport.units + .flatMap((unit) => unit.obligations) + .find( + (obligation) => + obligation.claim === ReactProofClaim.BoundaryCoverage && + obligation.status === ReactObligationStatus.Unknown, + ); + + expect(trailingExplicitReport.status).toBe(ReactAppProofStatus.Proved); + expect(trailingExplicitReport.graph.eventBindings[0]?.complete).toBe(true); + expect(trailingSpreadReport.status).toBe(ReactAppProofStatus.Proved); + expect(trailingSpreadReport.graph.eventBindings[0]?.complete).toBe(true); + expect(unresolvedSpreadReport.status).toBe(ReactAppProofStatus.Incomplete); + expect(unresolvedSpreadReport.graph.eventBindings[0]?.complete).toBe(false); + expect(boundaryProof?.evidence[0]?.description).toMatch(/JSX spread/); + }); + + it("fails closed for an open-ended intrinsic JSX spread", () => { + const report = proveFixture("incomplete-jsx-spread-open-ended-event"); const boundaryProof = report.units .flatMap((unit) => unit.obligations) .find( @@ -1629,7 +1670,30 @@ describe("proveReactApp", () => { expect(report.status).toBe(ReactAppProofStatus.Incomplete); expect(report.graph.eventBindings[0]?.complete).toBe(false); - expect(boundaryProof?.evidence[0]?.description).toMatch(/does not resolve/); + expect( + boundaryProof?.evidence.some((evidence) => + evidence.description.includes("open-ended property set"), + ), + ).toBe(true); + }); + + it("fails closed when a source-resolved JSX spread object is mutated", () => { + const report = proveFixture("incomplete-jsx-spread-mutated-object"); + const boundaryProof = report.units + .flatMap((unit) => unit.obligations) + .find( + (obligation) => + obligation.claim === ReactProofClaim.BoundaryCoverage && + obligation.status === ReactObligationStatus.Unknown, + ); + + expect(report.status).toBe(ReactAppProofStatus.Incomplete); + expect(report.graph.eventBindings[0]?.complete).toBe(false); + expect( + boundaryProof?.evidence.some((evidence) => + evidence.description.includes("immutable finite object proof"), + ), + ).toBe(true); }); it.each([ diff --git a/packages/prover/tests/runtime/jsx-spread-order-oracle.spec.ts b/packages/prover/tests/runtime/jsx-spread-order-oracle.spec.ts new file mode 100644 index 0000000000..0c0695c6f2 --- /dev/null +++ b/packages/prover/tests/runtime/jsx-spread-order-oracle.spec.ts @@ -0,0 +1,15 @@ +import { expect, test } from "@playwright/test"; + +test("confirms that an explicit callback after a spread wins", async ({ page }) => { + await page.goto("/?oracle=jsx-spread-order&mode=explicit-last"); + await page.getByRole("button", { name: "activate" }).click(); + + await expect(page.getByTestId("last-handler")).toHaveText("explicit"); +}); + +test("confirms that a spread callback after an explicit prop wins", async ({ page }) => { + await page.goto("/?oracle=jsx-spread-order&mode=spread-last"); + await page.getByRole("button", { name: "activate" }).click(); + + await expect(page.getByTestId("last-handler")).toHaveText("spread"); +}); diff --git a/packages/prover/tests/runtime/main.tsx b/packages/prover/tests/runtime/main.tsx index b9a0c99057..49fffcebe7 100644 --- a/packages/prover/tests/runtime/main.tsx +++ b/packages/prover/tests/runtime/main.tsx @@ -394,6 +394,34 @@ const AsyncEffectOwnershipOracle = () => { ); }; +interface JsxSpreadOrderProperties { + onClick?: () => void; +} + +const JsxSpreadOrderOracle = () => { + const [lastHandler, setLastHandler] = useState("none"); + const isExplicitLast = + new URLSearchParams(window.location.search).get("mode") === "explicit-last"; + const spreadProperties: JsxSpreadOrderProperties = { + onClick: () => setLastHandler("spread"), + }; + const handleExplicitClick = () => setLastHandler("explicit"); + return ( +
    + {isExplicitLast ? ( + + ) : ( + + )} + {lastHandler} +
    + ); +}; + const RuntimeOracle = () => { const oracle = new URLSearchParams(window.location.search).get("oracle"); if (oracle === "keys") { @@ -426,6 +454,9 @@ const RuntimeOracle = () => { if (oracle === "async-effect-ownership") { return ; } + if (oracle === "jsx-spread-order") { + return ; + } return ; }; From 3c2dd6f2a4b83b7b62f8ba390f6a1915841bd366 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Tue, 28 Jul 2026 11:58:49 +0000 Subject: [PATCH 03/23] feat(prover): certify callable ref freshness --- packages/prover/README.md | 9 +- packages/prover/research-log.md | 53 +++- .../prover/src/analyze-boundary-coverage.ts | 47 ++-- .../src/analyze-callable-ref-freshness.ts | 64 +++++ packages/prover/src/analyze-react-unit.ts | 5 +- .../prover/src/build-react-semantic-graph.ts | 65 +++++ .../prover/src/check-react-proof-report.ts | 131 ++++++++++ .../src/collect-callable-ref-protocols.ts | 243 ++++++++++++++++++ .../prover/src/collect-reachable-functions.ts | 15 +- packages/prover/src/constants.ts | 4 +- packages/prover/src/index.ts | 2 + packages/prover/src/prove-react-app.ts | 1 + .../prover/src/resolve-callable-expression.ts | 54 +++- packages/prover/src/types.ts | 23 ++ .../src/utils/are-proof-locations-equal.ts | 7 + .../src/app.tsx | 26 ++ .../tsconfig.json | 4 + .../src/app.tsx | 22 ++ .../tsconfig.json | 4 + .../src/app.tsx | 19 ++ .../tsconfig.json | 4 + .../src/app.tsx | 19 ++ .../tsconfig.json | 4 + .../src/app.tsx | 19 ++ .../tsconfig.json | 4 + packages/prover/tests/prove-react-app.test.ts | 117 ++++++++- .../runtime/callable-ref-phase-oracle.spec.ts | 15 ++ packages/prover/tests/runtime/constants.ts | 3 + packages/prover/tests/runtime/main.tsx | 72 ++++++ 29 files changed, 1015 insertions(+), 40 deletions(-) create mode 100644 packages/prover/src/analyze-callable-ref-freshness.ts create mode 100644 packages/prover/src/collect-callable-ref-protocols.ts create mode 100644 packages/prover/src/utils/are-proof-locations-equal.ts create mode 100644 packages/prover/tests/fixtures/incomplete-layout-ref-escaped-event-callback/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-layout-ref-escaped-event-callback/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-layout-ref-multiple-write-event-callback/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-layout-ref-multiple-write-event-callback/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-layout-ref-backed-event-callback/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-layout-ref-backed-event-callback/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-layout-ref-backed-memo-event-callback/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-layout-ref-backed-memo-event-callback/tsconfig.json create mode 100644 packages/prover/tests/fixtures/refuted-layout-ref-missing-dependency/src/app.tsx create mode 100644 packages/prover/tests/fixtures/refuted-layout-ref-missing-dependency/tsconfig.json create mode 100644 packages/prover/tests/runtime/callable-ref-phase-oracle.spec.ts diff --git a/packages/prover/README.md b/packages/prover/README.md index 738e0d8022..4bf65ad4e3 100644 --- a/packages/prover/README.md +++ b/packages/prover/README.md @@ -40,6 +40,10 @@ The report includes: 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; - 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. @@ -55,8 +59,9 @@ obligations. It also rejects function-call edges that cross owners, callback roo 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. Source-derived block invariants and lifecycle transition certificates remain future -work. +alternatives. Callable refs additionally require a source-complete `useLayoutEffect` update, a +concrete event callback, and a `ref.current` call edge. Source-derived block invariants and broader +lifecycle transition certificates remain future work. ## Verification diff --git a/packages/prover/research-log.md b/packages/prover/research-log.md index 066cd38634..55fa971752 100644 --- a/packages/prover/research-log.md +++ b/packages/prover/research-log.md @@ -235,6 +235,26 @@ model and must not become the whole-app proof substrate. 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. ### Current proof model @@ -249,7 +269,7 @@ builtin-hook calls, cross-module JSX render edges, and effect dependency, captur 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. Schema version 14 also records every source-resolved project helper reachable from +ownership. Schema version 15 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 @@ -259,7 +279,7 @@ obligations and graph extraction share the symbol-resolved collectors. A React C therefore replace individual fact producers without changing the report contract or proof consumers. -Schema version 14 records the call edges that justify helper reachability. Direct source calls, +Schema version 15 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 @@ -285,8 +305,10 @@ returns while a normally completing `finally` preserves them. Loop summaries pro 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, mutable callable properties, and -ref-backed indirection remain explicit failed proofs. A `const` binding iterating a nonempty fresh +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 @@ -320,9 +342,19 @@ and project-helper state writes. The current model fails this case closed pendin 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. + `useSyncExternalStore` arguments use the same project callback lattice but terminate in three distinct protocol channels: subscription lifetime, client render snapshot, and server-render -snapshot. Schema version 14 stores callback sets and completeness independently for all three and +snapshot. Schema version 15 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 @@ -402,6 +434,7 @@ Each function 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 | | `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 | | `context-topology` | Exact object identity, defaults, provider values, nested overrides, render/hook propagation | @@ -486,6 +519,8 @@ Proved: - `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` Refuted: @@ -544,6 +579,7 @@ Refuted: - `for-of-returned-render-impurity` - `for-of-invoked-render-impurity` - `for-of-destructured-render-impurity` +- `refuted-layout-ref-missing-dependency` Incomplete: @@ -593,6 +629,8 @@ Incomplete: - `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` - missing project configuration @@ -616,7 +654,7 @@ Known regions that must force `incomplete` until modeled: - 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 -- Ref-backed callback freshness and assignment across render/commit/event phases +- Passive, multiply written, escaping, imported-wrapper, or non-event callable-ref protocols - Async phase/lifetime transforms for timers, promises, schedulers, and subscription registries - Phase-polymorphic callbacks crossing opaque library or Promise registration contracts - Context propagation through opaque library components, portals, and externally mounted exports @@ -676,3 +714,6 @@ components and hooks, including hooks hidden in incorrectly named helper functio - 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. diff --git a/packages/prover/src/analyze-boundary-coverage.ts b/packages/prover/src/analyze-boundary-coverage.ts index d827b7a6a8..25a65c4211 100644 --- a/packages/prover/src/analyze-boundary-coverage.ts +++ b/packages/prover/src/analyze-boundary-coverage.ts @@ -5,15 +5,14 @@ import { REACT_RUNTIME_MODULE_NAMES, REACT_UNMODELED_HOOK_NAMES, } from "./constants.js"; +import { getCallableRefProtocolForCurrentAccess } from "./collect-callable-ref-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"; @@ -25,6 +24,7 @@ import { 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"; @@ -62,29 +62,6 @@ const isProjectModule = ( ); }; -const isCallableRefInvocation = ( - callExpression: ts.CallExpression, - typeChecker: ts.TypeChecker, -): boolean => { - if ( - !ts.isPropertyAccessExpression(callExpression.expression) || - callExpression.expression.name.text !== "current" - ) { - return false; - } - const rootIdentifier = getRootIdentifier(callExpression.expression); - const rootSymbol = rootIdentifier ? typeChecker.getSymbolAtLocation(rootIdentifier) : null; - return Boolean( - rootSymbol?.declarations?.some( - (declaration) => - ts.isVariableDeclaration(declaration) && - declaration.initializer && - ts.isCallExpression(declaration.initializer) && - getCanonicalReactApiName(declaration.initializer.expression, typeChecker) === "useRef", - ), - ); -}; - export const analyzeBoundaryCoverage = ( unit: ReactUnitDescriptor, context: ReactAnalysisContext, @@ -101,6 +78,19 @@ export const analyzeBoundaryCoverage = ( 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, @@ -281,7 +271,11 @@ export const analyzeBoundaryCoverage = ( ), ); } - if (isCallableRefInvocation(node, context.typeChecker)) { + if ( + ts.isPropertyAccessExpression(node.expression) && + getCallableRefProtocolForCurrentAccess(node.expression, context.typeChecker) && + !isCompleteCallableRefAccess(node.expression) + ) { unknownEvidence.push( createEvidence( node, @@ -356,6 +350,7 @@ export const analyzeBoundaryCoverage = ( node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && node.operatorToken.kind <= ts.SyntaxKind.LastAssignment && ts.isPropertyAccessExpression(node.left) && + !isCompleteCallableRefAccess(node.left) && doesTypeContainCallable(context.typeChecker.getTypeAtLocation(node.left), context.typeChecker) ) { unknownEvidence.push( 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 0000000000..0d195763fb --- /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-react-unit.ts b/packages/prover/src/analyze-react-unit.ts index a0c5554eed..8a7cb42345 100644 --- a/packages/prover/src/analyze-react-unit.ts +++ b/packages/prover/src/analyze-react-unit.ts @@ -1,5 +1,6 @@ -import { analyzeBoundaryCoverage } from "./analyze-boundary-coverage.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 { analyzeComponentIdentity } from "./analyze-component-identity.js"; import { analyzeComponentInvocation } from "./analyze-component-invocation.js"; import { analyzeContextTopology } from "./analyze-context-topology.js"; @@ -24,6 +25,7 @@ import type { ReactAnalysisContext, ReactUnitDescriptor, ReactUnitProof } from " const ALL_REACT_PROOF_CLAIMS: ReadonlyArray = [ ReactProofClaim.AsyncEffectOwnership, ReactProofClaim.BoundaryCoverage, + ReactProofClaim.CallableRefFreshness, ReactProofClaim.ComponentIdentity, ReactProofClaim.ComponentInvocation, ReactProofClaim.ContextTopology, @@ -106,6 +108,7 @@ export const analyzeReactUnit = ( obligations: [ analyzeAsyncEffectOwnership(unit.functionNode, context), analyzeBoundaryCoverage(unit, context), + analyzeCallableRefFreshness(unit, context), analyzeComponentIdentity(unit.functionNode, context), analyzeComponentInvocation(unit.functionNode, context), analyzeContextTopology(unit, context), diff --git a/packages/prover/src/build-react-semantic-graph.ts b/packages/prover/src/build-react-semantic-graph.ts index ddd9967416..258c576ed1 100644 --- a/packages/prover/src/build-react-semantic-graph.ts +++ b/packages/prover/src/build-react-semantic-graph.ts @@ -1,5 +1,6 @@ import ts from "typescript"; import { collectAsyncEffectTaskDescriptors } from "./collect-async-effect-task-descriptors.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 { @@ -32,6 +33,7 @@ import { isReactContextExpression } from "./is-react-context-expression.js"; import { resolveFunction } from "./resolve-function.js"; import { mergeCallableBindings } from "./resolve-callable-expression.js"; import { + ReactCallableRefFreshness, ReactEffectDependencyMode, ReactExecutionPhase, ReactIdentityStability, @@ -52,6 +54,7 @@ import type { ReactSemanticEventBinding, ReactSemanticCallbackPropAlternative, ReactSemanticCallbackPropFlow, + ReactSemanticCallableRef, ReactSemanticExternalStore, ReactSemanticFunctionCall, ReactSemanticGraph, @@ -61,6 +64,7 @@ import type { ReactSemanticUnit, ReactUnitDescriptor, } from "./types.js"; +import { areProofLocationsEqual } from "./utils/are-proof-locations-equal.js"; import type { ResolvedCallableValueDescriptor } from "./resolve-callable-expression.js"; interface UnitGraphIdentity { @@ -149,6 +153,65 @@ interface ContextGraphFacts { contextIdsBySymbol: 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; renders: ReadonlyArray; @@ -1477,6 +1540,7 @@ export const buildReactSemanticGraph = ( contextGraph.contextProviders, contextGraph.contextConsumers, ); + const callableRefs = collectCallableRefGraph(identities, callbacks, functionCalls, context); return { schemaVersion: REACT_SEMANTIC_GRAPH_SCHEMA_VERSION, units: identities.map((identity) => identity.semanticUnit), @@ -1495,6 +1559,7 @@ export const buildReactSemanticGraph = ( functionCalls, eventBindings: eventGraph.eventBindings, callbackPropFlows: callbackPropGraph.callbackPropFlows, + callableRefs, 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 index b742f164c5..63dd5b78c3 100644 --- a/packages/prover/src/check-react-proof-report.ts +++ b/packages/prover/src/check-react-proof-report.ts @@ -2,6 +2,7 @@ import { REACT_PROOF_SCHEMA_VERSION, REACT_SEMANTIC_GRAPH_SCHEMA_VERSION } from import { ReactAppProofStatus, ReactAsyncOwnershipStatus, + ReactCallableRefFreshness, ReactExecutionPhase, ReactObligationStatus, ReactProofCertificateStatus, @@ -11,6 +12,7 @@ import { ReactSemanticFunctionCallKind, ReactUnitKind, } from "./types.js"; +import { areProofLocationsEqual } from "./utils/are-proof-locations-equal.js"; import type { ReactAppProofReport, ReactProofCertificateCheck, @@ -57,6 +59,20 @@ const expectedAsyncOwnershipStatus = ( return ReactObligationStatus.Proved; }; +const expectedCallableRefFreshnessStatus = ( + unit: ReactSemanticUnit, + report: ReactAppProofReport, +): ReactObligationStatus => { + if (unit.kind === ReactUnitKind.ClassComponent || 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 checkClaimCoverage = ( report: ReactAppProofReport, failures: ReactProofCertificateFailure[], @@ -93,6 +109,17 @@ const checkClaimCoverage = ( `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}`, + ); + } } }; @@ -104,6 +131,9 @@ const checkGraphReferences = ( const effectIds = new Set(report.graph.effects.map((effect) => effect.id)); 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) => [ @@ -266,6 +296,102 @@ const checkGraphReferences = ( 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 reachableFunction of report.graph.reachableFunctions) { if (!unitIds.has(reachableFunction.ownerId)) { addFailure(failures, reachableFunction.id, "A reachable function has an unknown owner unit"); @@ -617,6 +743,11 @@ export const checkReactProofReport = (report: ReactAppProofReport): ReactProofCe "callback prop flows", report.graph.callbackPropFlows.map((propFlow) => propFlow.id), ); + checkUniqueIds( + failures, + "callable refs", + report.graph.callableRefs.map((callableRef) => callableRef.id), + ); checkUniqueIds( failures, "contexts", 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 0000000000..3e3cdf2a50 --- /dev/null +++ b/packages/prover/src/collect-callable-ref-protocols.ts @@ -0,0 +1,243 @@ +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"; + +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 getEnclosingFunction = (node: ts.Node): ts.FunctionLikeDeclaration | null => { + let currentNode = node.parent; + while (currentNode) { + if (isFunctionBoundary(currentNode)) return currentNode; + currentNode = currentNode.parent; + } + return null; +}; + +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-reachable-functions.ts b/packages/prover/src/collect-reachable-functions.ts index d01d0be84c..fcc369585f 100644 --- a/packages/prover/src/collect-reachable-functions.ts +++ b/packages/prover/src/collect-reachable-functions.ts @@ -1,5 +1,6 @@ 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"; @@ -154,6 +155,17 @@ const isReactDependencyArrayElement = ( ); }; +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 ( @@ -325,7 +337,8 @@ export const collectReachableFunctionGraph = ( .isComplete, ) || isModeledObjectArgument(node, typeChecker) || - isReactDependencyArrayElement(node, typeChecker); + isReactDependencyArrayElement(node, typeChecker) || + isModeledCallableRefInitializer(node, typeChecker); if ( !isDirectInvocation && !isForwardedArgument && diff --git a/packages/prover/src/constants.ts b/packages/prover/src/constants.ts index b475304eae..c50aace047 100644 --- a/packages/prover/src/constants.ts +++ b/packages/prover/src/constants.ts @@ -1,5 +1,5 @@ -export const REACT_PROOF_SCHEMA_VERSION = 8; -export const REACT_SEMANTIC_GRAPH_SCHEMA_VERSION = 14; +export const REACT_PROOF_SCHEMA_VERSION = 9; +export const REACT_SEMANTIC_GRAPH_SCHEMA_VERSION = 15; 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; diff --git a/packages/prover/src/index.ts b/packages/prover/src/index.ts index ca699cf95d..d5e85862dc 100644 --- a/packages/prover/src/index.ts +++ b/packages/prover/src/index.ts @@ -3,6 +3,7 @@ export { checkReactProofReport } from "./check-react-proof-report.js"; export { ReactAppProofStatus, ReactAsyncOwnershipStatus, + ReactCallableRefFreshness, ReactCompilerFactStatus, ReactEffectDependencyMode, ReactExecutionPhase, @@ -39,6 +40,7 @@ export type { ReactSemanticCallbackGuard, ReactSemanticCallbackPropAlternative, ReactSemanticCallbackPropFlow, + ReactSemanticCallableRef, ReactSemanticExternalStore, ReactSemanticCallback, ReactSemanticAsyncTask, diff --git a/packages/prover/src/prove-react-app.ts b/packages/prover/src/prove-react-app.ts index 42cf21d0fd..be563492aa 100644 --- a/packages/prover/src/prove-react-app.ts +++ b/packages/prover/src/prove-react-app.ts @@ -40,6 +40,7 @@ export const proveReactApp = (input: ProveReactAppInput): ReactAppProofReport => functionCalls: [], eventBindings: [], callbackPropFlows: [], + callableRefs: [], compiler: { version: REACT_COMPILER_VERSION, phase: REACT_COMPILER_FACT_PHASE, diff --git a/packages/prover/src/resolve-callable-expression.ts b/packages/prover/src/resolve-callable-expression.ts index b8109fa33f..52401cc655 100644 --- a/packages/prover/src/resolve-callable-expression.ts +++ b/packages/prover/src/resolve-callable-expression.ts @@ -1,4 +1,5 @@ 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"; @@ -500,12 +501,43 @@ const resolveCallResult = ( bindings: ReadonlyMap, state: CallableResolutionState, ): ResolvedCallableValueDescriptor => { - if (getCanonicalReactApiName(callExpression.expression, typeChecker) === "useCallback") { + 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); @@ -731,6 +763,26 @@ const resolveCallableExpressionWithState = ( }; } 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, diff --git a/packages/prover/src/types.ts b/packages/prover/src/types.ts index f05c8d01f0..9819c7040d 100644 --- a/packages/prover/src/types.ts +++ b/packages/prover/src/types.ts @@ -14,6 +14,7 @@ export enum ReactObligationStatus { export enum ReactProofClaim { BoundaryCoverage = "boundary-coverage", + CallableRefFreshness = "callable-ref-freshness", ComponentIdentity = "component-identity", ComponentInvocation = "component-invocation", ContextTopology = "context-topology", @@ -158,6 +159,12 @@ export enum ReactIdentityStability { Unknown = "unknown", } +export enum ReactCallableRefFreshness { + EventSynchronized = "event-synchronized", + PassiveLag = "passive-lag", + Unknown = "unknown", +} + export enum ReactAsyncOwnershipStatus { Guarded = "guarded", Unguarded = "unguarded", @@ -322,6 +329,21 @@ export interface ReactSemanticCallbackPropFlow { 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 interface ReactCompilerInstructionFact { id: string; valueKind: string; @@ -379,6 +401,7 @@ export interface ReactSemanticGraph { functionCalls: ReadonlyArray; eventBindings: ReadonlyArray; callbackPropFlows: ReadonlyArray; + callableRefs: ReadonlyArray; compiler: ReactCompilerGraph; } 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 0000000000..915d3ff0e2 --- /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/tests/fixtures/incomplete-layout-ref-escaped-event-callback/src/app.tsx b/packages/prover/tests/fixtures/incomplete-layout-ref-escaped-event-callback/src/app.tsx new file mode 100644 index 0000000000..4b48602b1a --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-layout-ref-escaped-event-callback/src/app.tsx @@ -0,0 +1,26 @@ +import { useCallback, useLayoutEffect, useRef } from "react"; + +interface CallbackRef { + current: () => void; +} + +const inspectCallbackRef = (callbackRef: CallbackRef) => Boolean(callbackRef.current); + +const useEventCallback = (callback: () => void) => { + const callbackRef = useRef(callback); + useLayoutEffect(() => { + callbackRef.current = callback; + }, [callback]); + inspectCallbackRef(callbackRef); + return useCallback(() => callbackRef.current(), []); +}; + +export const Application = () => { + const recordActivation = () => undefined; + const handleClick = useEventCallback(recordActivation); + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/incomplete-layout-ref-escaped-event-callback/tsconfig.json b/packages/prover/tests/fixtures/incomplete-layout-ref-escaped-event-callback/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-layout-ref-escaped-event-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-layout-ref-multiple-write-event-callback/src/app.tsx b/packages/prover/tests/fixtures/incomplete-layout-ref-multiple-write-event-callback/src/app.tsx new file mode 100644 index 0000000000..0fa7749b41 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-layout-ref-multiple-write-event-callback/src/app.tsx @@ -0,0 +1,22 @@ +import { useCallback, useEffect, useLayoutEffect, useRef } from "react"; + +const useEventCallback = (callback: () => void) => { + const callbackRef = useRef(callback); + useLayoutEffect(() => { + callbackRef.current = callback; + }, [callback]); + useEffect(() => { + callbackRef.current = callback; + }, [callback]); + return useCallback(() => callbackRef.current(), []); +}; + +export const Application = () => { + const recordActivation = () => undefined; + const handleClick = useEventCallback(recordActivation); + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/incomplete-layout-ref-multiple-write-event-callback/tsconfig.json b/packages/prover/tests/fixtures/incomplete-layout-ref-multiple-write-event-callback/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-layout-ref-multiple-write-event-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/proved-layout-ref-backed-event-callback/src/app.tsx b/packages/prover/tests/fixtures/proved-layout-ref-backed-event-callback/src/app.tsx new file mode 100644 index 0000000000..f75d75a4fe --- /dev/null +++ b/packages/prover/tests/fixtures/proved-layout-ref-backed-event-callback/src/app.tsx @@ -0,0 +1,19 @@ +import { useCallback, useLayoutEffect, useRef } from "react"; + +const useEventCallback = (callback: () => void) => { + const callbackRef = useRef(callback); + useLayoutEffect(() => { + callbackRef.current = callback; + }, [callback]); + return useCallback(() => callbackRef.current(), []); +}; + +export const Application = () => { + const recordActivation = () => undefined; + const handleClick = useEventCallback(recordActivation); + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/proved-layout-ref-backed-event-callback/tsconfig.json b/packages/prover/tests/fixtures/proved-layout-ref-backed-event-callback/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-layout-ref-backed-event-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/proved-layout-ref-backed-memo-event-callback/src/app.tsx b/packages/prover/tests/fixtures/proved-layout-ref-backed-memo-event-callback/src/app.tsx new file mode 100644 index 0000000000..8b64efe053 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-layout-ref-backed-memo-event-callback/src/app.tsx @@ -0,0 +1,19 @@ +import { useLayoutEffect, useMemo, useRef } from "react"; + +const useEventCallback = (callback: () => void) => { + const callbackRef = useRef(callback); + useLayoutEffect(() => { + callbackRef.current = callback; + }, [callback]); + return useMemo(() => () => callbackRef.current(), []); +}; + +export const Application = () => { + const recordActivation = () => undefined; + const handleClick = useEventCallback(recordActivation); + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/proved-layout-ref-backed-memo-event-callback/tsconfig.json b/packages/prover/tests/fixtures/proved-layout-ref-backed-memo-event-callback/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-layout-ref-backed-memo-event-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/refuted-layout-ref-missing-dependency/src/app.tsx b/packages/prover/tests/fixtures/refuted-layout-ref-missing-dependency/src/app.tsx new file mode 100644 index 0000000000..cc91c90aff --- /dev/null +++ b/packages/prover/tests/fixtures/refuted-layout-ref-missing-dependency/src/app.tsx @@ -0,0 +1,19 @@ +import { useCallback, useLayoutEffect, useRef } from "react"; + +const useEventCallback = (callback: () => void) => { + const callbackRef = useRef(callback); + useLayoutEffect(() => { + callbackRef.current = callback; + }, []); + return useCallback(() => callbackRef.current(), []); +}; + +export const Application = () => { + const recordActivation = () => undefined; + const handleClick = useEventCallback(recordActivation); + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/refuted-layout-ref-missing-dependency/tsconfig.json b/packages/prover/tests/fixtures/refuted-layout-ref-missing-dependency/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/refuted-layout-ref-missing-dependency/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/prove-react-app.test.ts b/packages/prover/tests/prove-react-app.test.ts index d3559a29a6..c1f86c32bf 100644 --- a/packages/prover/tests/prove-react-app.test.ts +++ b/packages/prover/tests/prove-react-app.test.ts @@ -6,6 +6,7 @@ import { proveReactApp, ReactAppProofStatus, ReactAsyncOwnershipStatus, + ReactCallableRefFreshness, ReactCompilerFactStatus, ReactEffectDependencyMode, ReactExecutionPhase, @@ -42,6 +43,11 @@ const REFUTED_FIXTURES: ReadonlyArray = [ claim: ReactProofClaim.EffectDependencies, evidencePattern: /absent from the effect dependency list/, }, + { + fixtureName: "refuted-layout-ref-missing-dependency", + claim: ReactProofClaim.EffectDependencies, + evidencePattern: /absent from the effect dependency list/, + }, { fixtureName: "impure-render", claim: ReactProofClaim.RenderPurity, @@ -397,7 +403,7 @@ describe("proveReactApp", () => { const report = proveFixture("proved-chat"); const effect = report.graph.effects[0]; - expect(report.graph.schemaVersion).toBe(14); + expect(report.graph.schemaVersion).toBe(15); expect(effect?.hookName).toBe("useEffect"); expect(effect?.callbackResolved).toBe(true); expect(effect?.dependencyMode).toBe(ReactEffectDependencyMode.Inline); @@ -1742,6 +1748,115 @@ describe("proveReactApp", () => { expect(boundaryProof).toBeDefined(); }); + it("proves a non-escaping callable ref synchronized before an intrinsic event", () => { + const report = proveFixture("proved-layout-ref-backed-event-callback"); + const callableRef = report.graph.callableRefs[0]; + const refCall = report.graph.functionCalls.find( + (functionCall) => + functionCall.phase === ReactExecutionPhase.Event && + functionCall.sourcePropertyPath.at(-1) === "current", + ); + + expect(report.status).toBe(ReactAppProofStatus.Proved); + expect(callableRef?.freshness).toBe(ReactCallableRefFreshness.EventSynchronized); + expect(callableRef?.complete).toBe(true); + expect(callableRef?.invocationCallIds).toHaveLength(1); + expect(callableRef?.invocationCallbackIds).toHaveLength(1); + expect(callableRef?.invocationLocations).toHaveLength(1); + expect(refCall).toBeDefined(); + }); + + it("rejects a callable-ref certificate without its layout synchronization fact", () => { + const report = proveFixture("proved-layout-ref-backed-event-callback"); + const certificate = checkReactProofReport({ + ...report, + graph: { + ...report.graph, + callableRefs: report.graph.callableRefs.map((callableRef) => ({ + ...callableRef, + updateHookName: "useEffect", + })), + }, + }); + + expect(certificate.status).toBe(ReactProofCertificateStatus.Invalid); + expect( + certificate.failures.some((failure) => + failure.description.includes("layout-synchronized event certificate"), + ), + ).toBe(true); + }); + + it("rejects a callable-ref certificate without its invocation call edge", () => { + const report = proveFixture("proved-layout-ref-backed-event-callback"); + const certificate = checkReactProofReport({ + ...report, + graph: { + ...report.graph, + callableRefs: report.graph.callableRefs.map((callableRef) => ({ + ...callableRef, + invocationCallIds: ["missing-call"], + })), + }, + }); + + expect(certificate.status).toBe(ReactProofCertificateStatus.Invalid); + expect( + certificate.failures.some((failure) => + failure.description.includes("unknown invocation call"), + ), + ).toBe(true); + }); + + it("records the post-commit lag of a passive callable-ref update", () => { + const report = proveFixture("incomplete-ref-backed-event-callback"); + const callableRef = report.graph.callableRefs[0]; + const freshnessProof = report.units + .flatMap((unit) => unit.obligations) + .find( + (obligation) => + obligation.claim === ReactProofClaim.CallableRefFreshness && + obligation.status === ReactObligationStatus.Unknown, + ); + + expect(report.status).toBe(ReactAppProofStatus.Incomplete); + expect(callableRef?.freshness).toBe(ReactCallableRefFreshness.PassiveLag); + expect(callableRef?.complete).toBe(false); + expect(freshnessProof?.evidence[0]?.description).toMatch(/passive Effect/); + }); + + it("proves the layout-synchronized useMemo wrapper used by component libraries", () => { + const report = proveFixture("proved-layout-ref-backed-memo-event-callback"); + + expect(report.status).toBe(ReactAppProofStatus.Proved); + expect(report.graph.callableRefs[0]?.complete).toBe(true); + expect( + report.graph.functionCalls.some( + (functionCall) => + functionCall.phase === ReactExecutionPhase.Event && + functionCall.sourcePropertyPath.at(-1) === "current", + ), + ).toBe(true); + }); + + it.each([ + "incomplete-layout-ref-escaped-event-callback", + "incomplete-layout-ref-multiple-write-event-callback", + ])("fails closed when the callable ref protocol is not exclusive in %s", (fixtureName) => { + const report = proveFixture(fixtureName); + const freshnessProof = report.units + .flatMap((unit) => unit.obligations) + .find( + (obligation) => + obligation.claim === ReactProofClaim.CallableRefFreshness && + obligation.status === ReactObligationStatus.Unknown, + ); + + expect(report.status).toBe(ReactAppProofStatus.Incomplete); + expect(report.graph.callableRefs[0]?.sourceComplete).toBe(false); + expect(freshnessProof).toBeDefined(); + }); + it("requires SSA evidence after mutating a callable object property", () => { const report = proveFixture("incomplete-mutable-object-callback"); const boundaryProof = report.units diff --git a/packages/prover/tests/runtime/callable-ref-phase-oracle.spec.ts b/packages/prover/tests/runtime/callable-ref-phase-oracle.spec.ts new file mode 100644 index 0000000000..b231e226d3 --- /dev/null +++ b/packages/prover/tests/runtime/callable-ref-phase-oracle.spec.ts @@ -0,0 +1,15 @@ +import { expect, test } from "@playwright/test"; + +test("a layout-synchronized callable ref observes the current commit", async ({ page }) => { + await page.goto("/?oracle=callable-ref-phase&mode=layout"); + await page.getByRole("button", { name: "advance revision" }).click(); + + await expect(page.getByTestId("observed-callback-revision")).toHaveText("1"); +}); + +test("a passive callable-ref update can lag behind an observable commit", async ({ page }) => { + await page.goto("/?oracle=callable-ref-phase&mode=passive"); + await page.getByRole("button", { name: "advance revision" }).click(); + + await expect(page.getByTestId("observed-callback-revision")).toHaveText("0"); +}); diff --git a/packages/prover/tests/runtime/constants.ts b/packages/prover/tests/runtime/constants.ts index 63d6e8fdad..7eb204a078 100644 --- a/packages/prover/tests/runtime/constants.ts +++ b/packages/prover/tests/runtime/constants.ts @@ -1,6 +1,9 @@ export const FAST_QUERY_DELAY_MS = 20; +export const INITIAL_CALLBACK_REVISION = 0; export const LATE_QUERY_SETTLE_WAIT_MS = 250; +export const NEXT_CALLBACK_REVISION = 1; export const PRIMARY_STORE_INITIAL_VERSION = 0; export const SECONDARY_STORE_INITIAL_VERSION = 100; export const SLOW_QUERY_DELAY_MS = 200; export const STORE_VERSION_INCREMENT = 1; +export const UNOBSERVED_CALLBACK_REVISION = -1; diff --git a/packages/prover/tests/runtime/main.tsx b/packages/prover/tests/runtime/main.tsx index 49fffcebe7..e2a02c3ff4 100644 --- a/packages/prover/tests/runtime/main.tsx +++ b/packages/prover/tests/runtime/main.tsx @@ -1,9 +1,12 @@ import { createContext, memo, + useCallback, useContext, useEffect, useEffectEvent, + useLayoutEffect, + useRef, useState, useSyncExternalStore, } from "react"; @@ -11,10 +14,13 @@ import type { ChangeEvent } from "react"; import { createRoot } from "react-dom/client"; import { FAST_QUERY_DELAY_MS, + INITIAL_CALLBACK_REVISION, + NEXT_CALLBACK_REVISION, PRIMARY_STORE_INITIAL_VERSION, SECONDARY_STORE_INITIAL_VERSION, SLOW_QUERY_DELAY_MS, STORE_VERSION_INCREMENT, + UNOBSERVED_CALLBACK_REVISION, } from "./constants.js"; declare global { @@ -422,6 +428,69 @@ const JsxSpreadOrderOracle = () => { ); }; +interface CallableRefProbeProperties { + onObservedRevision: (revision: number) => void; + revision: number; +} + +const useLayoutSynchronizedCallback = (callback: () => void) => { + const callbackRef = useRef(callback); + useLayoutEffect(() => { + callbackRef.current = callback; + }, [callback]); + return useCallback(() => callbackRef.current(), []); +}; + +const usePassiveSynchronizedCallback = (callback: () => void) => { + const callbackRef = useRef(callback); + useEffect(() => { + callbackRef.current = callback; + }, [callback]); + return useCallback(() => callbackRef.current(), []); +}; + +const LayoutCallableRefProbe = ({ onObservedRevision, revision }: CallableRefProbeProperties) => { + const buttonRef = useRef(null); + const handleProbe = useLayoutSynchronizedCallback(() => onObservedRevision(revision)); + useLayoutEffect(() => { + if (revision === NEXT_CALLBACK_REVISION) buttonRef.current?.click(); + }, [revision]); + return ( + + ); +}; + +const PassiveCallableRefProbe = ({ onObservedRevision, revision }: CallableRefProbeProperties) => { + const buttonRef = useRef(null); + const handleProbe = usePassiveSynchronizedCallback(() => onObservedRevision(revision)); + useLayoutEffect(() => { + if (revision === NEXT_CALLBACK_REVISION) buttonRef.current?.click(); + }, [revision]); + return ( + + ); +}; + +const CallableRefPhaseOracle = () => { + const [revision, setRevision] = useState(INITIAL_CALLBACK_REVISION); + const [observedRevision, setObservedRevision] = useState(UNOBSERVED_CALLBACK_REVISION); + const isLayoutMode = new URLSearchParams(window.location.search).get("mode") === "layout"; + const Probe = isLayoutMode ? LayoutCallableRefProbe : PassiveCallableRefProbe; + return ( +
    + + + {observedRevision} +
    + ); +}; + const RuntimeOracle = () => { const oracle = new URLSearchParams(window.location.search).get("oracle"); if (oracle === "keys") { @@ -457,6 +526,9 @@ const RuntimeOracle = () => { if (oracle === "jsx-spread-order") { return ; } + if (oracle === "callable-ref-phase") { + return ; + } return ; }; From a7bf812d66aa35c7c21fb7bc1fa8d25031581f0f Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Tue, 28 Jul 2026 12:50:55 +0000 Subject: [PATCH 04/23] feat(prover): certify effect scheduler lifetimes --- packages/prover/README.md | 10 +- packages/prover/research-log.md | 92 +++-- .../prover/src/analyze-boundary-coverage.ts | 19 + packages/prover/src/analyze-effect-cleanup.ts | 35 +- .../prover/src/analyze-effect-event-usage.ts | 12 +- packages/prover/src/analyze-react-unit.ts | 3 + .../analyze-scheduled-callback-lifetime.ts | 77 ++++ .../prover/src/build-react-semantic-graph.ts | 159 +++++++- .../prover/src/check-react-proof-report.ts | 85 ++++ .../collect-async-effect-task-descriptors.ts | 47 +-- .../src/collect-callable-ref-protocols.ts | 10 +- .../src/collect-effect-scheduler-protocols.ts | 362 ++++++++++++++++++ packages/prover/src/constants.ts | 5 +- packages/prover/src/index.ts | 3 + packages/prover/src/prove-react-app.ts | 1 + packages/prover/src/types.ts | 34 ++ .../collect-reachable-call-expressions.ts | 19 + .../contains-await-outside-nested-function.ts | 21 + .../src/utils/get-enclosing-function.ts | 11 + .../utils/has-guaranteed-effect-cleanup.ts | 18 + .../src/app.tsx | 18 + .../tsconfig.json | 4 + .../src/app.tsx | 19 + .../tsconfig.json | 4 + .../incomplete-effect-microtask/src/app.tsx | 12 + .../incomplete-effect-microtask/tsconfig.json | 4 + .../incomplete-event-timeout/src/app.tsx | 14 + .../incomplete-event-timeout/tsconfig.json | 4 + .../src/app.tsx | 12 + .../tsconfig.json | 4 + .../incomplete-nested-timeout/src/app.tsx | 14 + .../incomplete-nested-timeout/tsconfig.json | 4 + .../src/app.tsx | 15 + .../tsconfig.json | 4 + .../src/app.tsx | 14 + .../tsconfig.json | 4 + .../proved-aliased-window-timeout/src/app.tsx | 13 + .../tsconfig.json | 4 + .../proved-animation-frame/src/app.tsx | 12 + .../proved-animation-frame/tsconfig.json | 4 + .../proved-shadowed-timeout/src/app.tsx | 14 + .../proved-shadowed-timeout/tsconfig.json | 4 + .../proved-window-timeout/src/app.tsx | 12 + .../proved-window-timeout/tsconfig.json | 4 + .../refuted-timer-partial-cleanup/src/app.tsx | 17 + .../tsconfig.json | 4 + packages/prover/tests/prove-react-app.test.ts | 150 +++++++- packages/prover/tests/runtime/constants.ts | 2 + packages/prover/tests/runtime/main.tsx | 34 ++ .../runtime/scheduler-lifetime-oracle.spec.ts | 18 + 50 files changed, 1337 insertions(+), 128 deletions(-) create mode 100644 packages/prover/src/analyze-scheduled-callback-lifetime.ts create mode 100644 packages/prover/src/collect-effect-scheduler-protocols.ts create mode 100644 packages/prover/src/utils/collect-reachable-call-expressions.ts create mode 100644 packages/prover/src/utils/contains-await-outside-nested-function.ts create mode 100644 packages/prover/src/utils/get-enclosing-function.ts create mode 100644 packages/prover/src/utils/has-guaranteed-effect-cleanup.ts create mode 100644 packages/prover/tests/fixtures/incomplete-conditional-timer-cancellation/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-conditional-timer-cancellation/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-early-return-timer-cleanup/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-early-return-timer-cleanup/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-effect-microtask/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-effect-microtask/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-event-timeout/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-event-timeout/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-mutable-timer-handle/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-mutable-timer-handle/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-nested-timeout/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-nested-timeout/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-timer-async-continuation/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-timer-async-continuation/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-timer-floating-promise/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-timer-floating-promise/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-aliased-window-timeout/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-aliased-window-timeout/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-animation-frame/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-animation-frame/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-shadowed-timeout/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-shadowed-timeout/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-window-timeout/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-window-timeout/tsconfig.json create mode 100644 packages/prover/tests/fixtures/refuted-timer-partial-cleanup/src/app.tsx create mode 100644 packages/prover/tests/fixtures/refuted-timer-partial-cleanup/tsconfig.json create mode 100644 packages/prover/tests/runtime/scheduler-lifetime-oracle.spec.ts diff --git a/packages/prover/README.md b/packages/prover/README.md index 4bf65ad4e3..a12aa82089 100644 --- a/packages/prover/README.md +++ b/packages/prover/README.md @@ -44,6 +44,10 @@ The report includes: 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; +- scheduler lifetime facts that tie a platform timer, animation frame, idle callback, immediate, + or microtask registration to its owning Effect, deferred callback set, exact handle, and cleanup + cancellation paths; only source-resolved synchronous callbacks with entry-dominating cleanup + cancellation are complete; - 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. @@ -60,8 +64,10 @@ phases, and flow kinds whose parameter/argument indexes are inconsistent. This i 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. Source-derived block invariants and broader -lifecycle transition certificates remain future work. +concrete event callback, and a `ref.current` call edge. Scheduler certificates require a real +Effect setup callback, deferred callback facts, exact cancellation evidence, and internally +consistent completeness. Source-derived block invariants and broader lifecycle transition +certificates remain future work. ## Verification diff --git a/packages/prover/research-log.md b/packages/prover/research-log.md index 55fa971752..108919a820 100644 --- a/packages/prover/research-log.md +++ b/packages/prover/research-log.md @@ -58,6 +58,11 @@ closed-world coverage remains too low for representative applications. 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 @@ -255,6 +260,16 @@ model and must not become the whole-app proof substrate. 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 @@ -269,7 +284,7 @@ builtin-hook calls, cross-module JSX render edges, and effect dependency, captur 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. Schema version 15 also records every source-resolved project helper reachable from +ownership. Schema version 16 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 @@ -279,7 +294,7 @@ obligations and graph extraction share the symbol-resolved collectors. A React C therefore replace individual fact producers without changing the report contract or proof consumers. -Schema version 15 records the call edges that justify helper reachability. Direct source calls, +Schema version 16 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 @@ -352,9 +367,21 @@ non-event invocation channels remain `unknown`. The independent checker requires 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. Schema version 15 stores callback sets and completeness independently for all three and +snapshot. Schema version 16 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 @@ -431,26 +458,27 @@ Discovered React units currently include: Each function 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 | -| `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 | -| `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 | -| `memo-dependencies` | `useMemo` and `useCallback` captures versus inline dependency tuples | -| `reconciliation-identity` | Missing, duplicate, index-derived, and unconstrained dynamic list keys | -| `reducer-purity` | Reducer and reducer-initializer transition purity | -| `ref-access` | Render-phase access to refs created by `useRef` | -| `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 | +| 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 | +| `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 | +| `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 | +| `memo-dependencies` | `useMemo` and `useCallback` captures versus inline dependency tuples | +| `reconciliation-identity` | Missing, duplicate, index-derived, and unconstrained dynamic list keys | +| `reducer-purity` | Reducer and reducer-initializer transition purity | +| `ref-access` | Render-phase access to refs created by `useRef` | +| `scheduled-callback-lifetime` | Effect ownership, deferred callback resolution, exact handles, guaranteed cancellation | +| `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: @@ -521,6 +549,10 @@ Proved: - `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` Refuted: @@ -580,6 +612,7 @@ Refuted: - `for-of-invoked-render-impurity` - `for-of-destructured-render-impurity` - `refuted-layout-ref-missing-dependency` +- `refuted-timer-partial-cleanup` Incomplete: @@ -632,6 +665,14 @@ Incomplete: - `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 @@ -655,7 +696,8 @@ Known regions that must force `incomplete` until modeled: 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 -- Async phase/lifetime transforms for timers, promises, schedulers, and subscription registries +- 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, @@ -717,3 +759,5 @@ components and hooks, including hooks hidden in incorrectly named helper functio - 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. diff --git a/packages/prover/src/analyze-boundary-coverage.ts b/packages/prover/src/analyze-boundary-coverage.ts index 25a65c4211..2d3ce472fc 100644 --- a/packages/prover/src/analyze-boundary-coverage.ts +++ b/packages/prover/src/analyze-boundary-coverage.ts @@ -6,6 +6,7 @@ import { 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"; @@ -245,6 +246,24 @@ export const analyzeBoundaryCoverage = ( 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 finalCallName = getCanonicalHookName(node, context.typeChecker); const isModeledContextRead = finalCallName === "use" && diff --git a/packages/prover/src/analyze-effect-cleanup.ts b/packages/prover/src/analyze-effect-cleanup.ts index 7e00207efc..9396011f84 100644 --- a/packages/prover/src/analyze-effect-cleanup.ts +++ b/packages/prover/src/analyze-effect-cleanup.ts @@ -8,6 +8,7 @@ import { getCallName } from "./get-call-name.js"; import { getEffectCallback } from "./get-effect-callback.js"; import { isFunctionBoundary } from "./is-function-boundary.js"; import { ReactObligationStatus, ReactProofClaim } from "./types.js"; +import { collectReachableCallExpressions } from "./utils/collect-reachable-call-expressions.js"; import type { ReactAnalysisContext, ReactProofEvidence, ReactProofObligation } from "./types.js"; interface ResourceAcquisition { @@ -46,22 +47,6 @@ const getAssignedName = (expression: ts.Expression): string | null => { return null; }; -const collectCalls = ( - 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; -}; - const getEventListenerIdentity = ( callExpression: ts.CallExpression, ): EventListenerIdentity | null => { @@ -110,22 +95,6 @@ const collectResourceAcquisitions = ( ownerFunction: reachableFunction.functionNode, eventListener, }); - } else if ( - callName === "setInterval" || - callName === "setTimeout" || - callName === "requestAnimationFrame" - ) { - const assignedName = getAssignedName(node); - let cleanupFunctionName = "cancelAnimationFrame"; - if (callName === "setInterval") cleanupFunctionName = "clearInterval"; - if (callName === "setTimeout") cleanupFunctionName = "clearTimeout"; - acquisitions.push({ - node, - description: `${callName} registration`, - cleanupNames: assignedName ? [`${cleanupFunctionName}(${assignedName})`] : [], - isConditionallyReached: reachableFunction.isConditionallyReached, - ownerFunction: reachableFunction.functionNode, - }); } else if ( ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === "subscribe" @@ -249,7 +218,7 @@ export const analyzeEffectCleanup = ( } const cleanupFunctions = collectEffectCleanupFunctions(effectCallback, context.typeChecker); const cleanupCallExpressions = cleanupFunctions.flatMap((cleanupFunction) => - collectCalls(cleanupFunction, context.typeChecker), + collectReachableCallExpressions(cleanupFunction, context.typeChecker), ); const cleanupCalls = new Set(cleanupCallExpressions.map(getCanonicalCall)); for (const acquisition of collectResourceAcquisitions(effectCallback, context.typeChecker)) { diff --git a/packages/prover/src/analyze-effect-event-usage.ts b/packages/prover/src/analyze-effect-event-usage.ts index 1bef6675ef..c399ef5ee2 100644 --- a/packages/prover/src/analyze-effect-event-usage.ts +++ b/packages/prover/src/analyze-effect-event-usage.ts @@ -14,6 +14,7 @@ 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 = ( @@ -73,15 +74,6 @@ const collectContextValueSymbols = ( return contextValueSymbols; }; -const getContainingFunction = (node: ts.Node): ts.FunctionLikeDeclaration | null => { - let currentNode = node.parent; - while (currentNode) { - if (isFunctionBoundary(currentNode)) return currentNode; - currentNode = currentNode.parent; - } - return null; -}; - export const analyzeEffectEventUsage = ( functionNode: ts.FunctionLikeDeclaration, context: ReactAnalysisContext, @@ -170,7 +162,7 @@ export const analyzeEffectEventUsage = ( ); return; } - const containingFunction = getContainingFunction(node); + const containingFunction = getEnclosingFunction(node); const isAllowedOwner = Boolean(containingFunction && allowedOwners.has(containingFunction)); const isEventOwner = Boolean(containingFunction && eventOwners.has(containingFunction)); if ( diff --git a/packages/prover/src/analyze-react-unit.ts b/packages/prover/src/analyze-react-unit.ts index 8a7cb42345..f3d2acefb4 100644 --- a/packages/prover/src/analyze-react-unit.ts +++ b/packages/prover/src/analyze-react-unit.ts @@ -16,6 +16,7 @@ import { analyzeRefAccess } from "./analyze-ref-access.js"; import { analyzeReducerPurity } from "./analyze-reducer-purity.js"; import { analyzeReconciliationIdentity } from "./analyze-reconciliation-identity.js"; import { analyzeRenderPurity } from "./analyze-render-purity.js"; +import { analyzeScheduledCallbackLifetime } from "./analyze-scheduled-callback-lifetime.js"; import { createEvidence } from "./create-evidence.js"; import { createObligation } from "./create-obligation.js"; import { getNodeLocation } from "./get-node-location.js"; @@ -41,6 +42,7 @@ const ALL_REACT_PROOF_CLAIMS: ReadonlyArray = [ ReactProofClaim.ReducerPurity, ReactProofClaim.RefAccess, ReactProofClaim.RenderPurity, + ReactProofClaim.ScheduledCallbackLifetime, ]; export const analyzeReactUnit = ( @@ -124,6 +126,7 @@ export const analyzeReactUnit = ( analyzeReducerPurity(unit.functionNode, context), analyzeRefAccess(unit.functionNode, context), analyzeRenderPurity(unit.functionNode, context), + analyzeScheduledCallbackLifetime(unit, context), ], }; }; 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 0000000000..f1afe0893c --- /dev/null +++ b/packages/prover/src/analyze-scheduled-callback-lifetime.ts @@ -0,0 +1,77 @@ +import { collectEffectSchedulerProtocols } from "./collect-effect-scheduler-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 { + ReactObligationStatus, + ReactProofClaim, + ReactSchedulerCancellationStatus, +} from "./types.js"; +import { areProofLocationsEqual } from "./utils/are-proof-locations-equal.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 protocol of collectEffectSchedulerProtocols(functionNode, context)) { + const registrationLocation = getNodeLocation(protocol.registrationCall, context.rootDirectory); + const schedulerFact = schedulerFacts.find((scheduler) => + areProofLocationsEqual(scheduler.location, registrationLocation), + ); + const evidence = createEvidence( + protocol.registrationCall, + context.rootDirectory, + protocol.cancellationStatus === ReactSchedulerCancellationStatus.Missing + ? `${protocol.kind} can remain active after its Effect loses ownership` + : `${protocol.kind} has no complete deferred callback and cancellation certificate`, + ["effect setup", protocol.kind, "deferred callback", "effect cleanup or replacement"], + ); + if (protocol.cancellationStatus === ReactSchedulerCancellationStatus.Missing) { + violations.push(evidence); + } else if (!schedulerFact?.complete) { + unknownEvidence.push(evidence); + } + } + if (violations.length > 0) { + return createObligation( + ReactProofClaim.ScheduledCallbackLifetime, + ReactObligationStatus.Violated, + "An Effect 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 Effect scheduler has a deferred callback and guaranteed cancellation", + ); +}; diff --git a/packages/prover/src/build-react-semantic-graph.ts b/packages/prover/src/build-react-semantic-graph.ts index 258c576ed1..b7cae0db19 100644 --- a/packages/prover/src/build-react-semantic-graph.ts +++ b/packages/prover/src/build-react-semantic-graph.ts @@ -11,15 +11,23 @@ import { collectDirectHookCalls } from "./collect-direct-hook-calls.js"; import { collectEffectEventBindings } from "./collect-effect-event-bindings.js"; import { collectEffectCleanupFunctions } from "./collect-effect-cleanup-functions.js"; import { collectEffectCalls } from "./collect-effect-calls.js"; +import { + collectEffectSchedulerProtocols, + getPlatformSchedulerKind, +} from "./collect-effect-scheduler-protocols.js"; import { collectHookBindings } from "./collect-hook-bindings.js"; import { collectHookCalls } from "./collect-hook-calls.js"; import { collectReactiveCaptures } from "./collect-reactive-captures.js"; -import { collectReachableFunctionGraph } from "./collect-reachable-functions.js"; +import { + collectReachableFunctionGraph, + collectReachableFunctions, +} from "./collect-reachable-functions.js"; import { REACT_EXTERNAL_STORE_HOOK_NAMES, REACT_MEMO_HOOK_NAMES, REACT_REDUCER_HOOK_NAMES, REACT_CONTEXT_DEFAULT_SOURCE_ID, + PROMISE_CONTINUATION_METHOD_NAMES, REACT_SEMANTIC_GRAPH_SCHEMA_VERSION, } from "./constants.js"; import { getCanonicalReactApiName } from "./get-canonical-react-api-name.js"; @@ -32,6 +40,7 @@ import { isFunctionBoundary } from "./is-function-boundary.js"; import { isReactContextExpression } from "./is-react-context-expression.js"; import { resolveFunction } from "./resolve-function.js"; import { mergeCallableBindings } from "./resolve-callable-expression.js"; +import type { ResolvedCallableValueDescriptor } from "./resolve-callable-expression.js"; import { ReactCallableRefFreshness, ReactEffectDependencyMode, @@ -61,11 +70,13 @@ import type { ReactSemanticHookCall, ReactSemanticReachableFunction, ReactSemanticRender, + ReactSemanticScheduler, ReactSemanticUnit, ReactUnitDescriptor, } from "./types.js"; import { areProofLocationsEqual } from "./utils/are-proof-locations-equal.js"; -import type { ResolvedCallableValueDescriptor } from "./resolve-callable-expression.js"; +import { collectReachableCallExpressions } from "./utils/collect-reachable-call-expressions.js"; +import { containsAwaitOutsideNestedFunction } from "./utils/contains-await-outside-nested-function.js"; interface UnitGraphIdentity { descriptor: ReactUnitDescriptor; @@ -74,6 +85,7 @@ interface UnitGraphIdentity { interface EffectGraphFacts { effects: ReadonlyArray; + schedulers: ReadonlyArray; callbacks: ReadonlyArray; reachableFunctions: ReadonlyArray; functionCalls: ReadonlyArray; @@ -558,6 +570,34 @@ const createCallbackFact = ( stateWrites: collectCallbackStateWrites(callback, owner, context.typeChecker), }); +const containsThenableType = (type: ts.Type, typeChecker: ts.TypeChecker): boolean => + Boolean(typeChecker.getPropertyOfType(type, "then")) || + (type.isUnionOrIntersection() && + type.types.some((memberType) => containsThenableType(memberType, typeChecker))); + +const isScheduledCallbackSynchronous = ( + 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)), + ); + const createCallbackPropAlternative = ( callbackId: string, callbackDescriptor: ComponentCallbackDescriptor, @@ -639,12 +679,19 @@ const collectReachabilityGraphFacts = ( 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: [], callbacks: [], reachableFunctions: [], functionCalls: [] }; + return { + effects: [], + schedulers: [], + callbacks: [], + reachableFunctions: [], + functionCalls: [], + }; } const hookBindings = collectHookBindings(functionNode, context.typeChecker); const stableSymbols = new Set([ @@ -653,9 +700,11 @@ const collectEffectGraph = ( ...hookBindings.stateSetters, ]); const effects: ReactSemanticEffect[] = []; + const schedulers: ReactSemanticScheduler[] = []; const callbacks: ReactSemanticCallback[] = []; const reachableFunctions: ReactSemanticReachableFunction[] = []; const functionCalls: ReactSemanticFunctionCall[] = []; + const schedulerProtocols = collectEffectSchedulerProtocols(functionNode, context); for (const effectCall of collectEffectCalls(functionNode, context.typeChecker)) { const hookName = getCanonicalHookName(effectCall, context.typeChecker) ?? "unknown-effect"; const effectCallback = getEffectCallback(effectCall, context.typeChecker); @@ -732,7 +781,7 @@ const collectEffectGraph = ( functionCalls.push(...reachabilityFacts.functionCalls); } } - effects.push({ + const effectFact: ReactSemanticEffect = { id: createSemanticId("effect", hookName, effectCall, context), ownerId: identity.semanticUnit.id, hookName, @@ -744,9 +793,97 @@ const collectEffectGraph = ( 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) => + isScheduledCallbackSynchronous(callbackDescriptor.callbackFunction, context), + ), + ); + schedulers.push({ + id: schedulerId, + ownerId: identity.semanticUnit.id, + 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), + }); + } } - return { effects, callbacks, reachableFunctions, functionCalls }; + return { effects, schedulers, callbacks, reachableFunctions, functionCalls }; }; const collectAsyncTaskGraph = ( @@ -1440,6 +1577,7 @@ export const buildReactSemanticGraph = ( const renders: ReactSemanticRender[] = []; const hookCalls: ReactSemanticHookCall[] = []; const effects: ReactSemanticEffect[] = []; + const schedulers: ReactSemanticScheduler[] = []; const effectEvents: ReactSemanticEffectEvent[] = []; const externalStores: ReactSemanticExternalStore[] = []; const asyncTasks: ReactSemanticAsyncTask[] = []; @@ -1498,8 +1636,14 @@ export const buildReactSemanticGraph = ( ); edges.push(...renderGraph.edges); renders.push(...renderGraph.renders); - const effectGraph = collectEffectGraph(identity, context, componentFlow); + const effectGraph = collectEffectGraph( + identity, + unitIdentitiesByFunction, + context, + componentFlow, + ); effects.push(...effectGraph.effects); + schedulers.push(...effectGraph.schedulers); callbacks.push(...effectGraph.callbacks); reachableFunctions.push(...effectGraph.reachableFunctions); functionCalls.push(...effectGraph.functionCalls); @@ -1560,6 +1704,7 @@ export const buildReactSemanticGraph = ( eventBindings: eventGraph.eventBindings, callbackPropFlows: callbackPropGraph.callbackPropFlows, callableRefs, + schedulers, 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 index 63dd5b78c3..e4743db1a1 100644 --- a/packages/prover/src/check-react-proof-report.ts +++ b/packages/prover/src/check-react-proof-report.ts @@ -7,6 +7,7 @@ import { ReactObligationStatus, ReactProofCertificateStatus, ReactProofClaim, + ReactSchedulerCancellationStatus, ReactSemanticCallbackKind, ReactSemanticEdgeKind, ReactSemanticFunctionCallKind, @@ -73,6 +74,26 @@ const expectedCallableRefFreshnessStatus = ( : ReactObligationStatus.Proved; }; +const expectedScheduledCallbackLifetimeStatus = ( + unit: ReactSemanticUnit, + report: ReactAppProofReport, +): ReactObligationStatus => { + if (unit.kind === ReactUnitKind.ClassComponent || 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; + } + return schedulers.some((scheduler) => !scheduler.complete) + ? ReactObligationStatus.Unknown + : ReactObligationStatus.Proved; +}; + const checkClaimCoverage = ( report: ReactAppProofReport, failures: ReactProofCertificateFailure[], @@ -120,6 +141,17 @@ const checkClaimCoverage = ( `Callable ref facts require ${expectedCallableRefStatus}, not ${callableRefFreshness.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}`, + ); + } } }; @@ -129,6 +161,7 @@ const checkGraphReferences = ( ): void => { const unitIds = new Set(report.graph.units.map((unit) => unit.id)); 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( @@ -392,6 +425,53 @@ const checkGraphReferences = ( ); } } + for (const scheduler of report.graph.schedulers) { + if (!unitIds.has(scheduler.ownerId)) { + addFailure(failures, scheduler.id, "A scheduler has an unknown owner unit"); + } + 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", + ); + } + 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 reachableFunction of report.graph.reachableFunctions) { if (!unitIds.has(reachableFunction.ownerId)) { addFailure(failures, reachableFunction.id, "A reachable function has an unknown owner unit"); @@ -698,6 +778,11 @@ export const checkReactProofReport = (report: ReactAppProofReport): ReactProofCe "units", report.graph.units.map((unit) => unit.id), ); + checkUniqueIds( + failures, + "schedulers", + report.graph.schedulers.map((scheduler) => scheduler.id), + ); checkUniqueIds( failures, "effects", diff --git a/packages/prover/src/collect-async-effect-task-descriptors.ts b/packages/prover/src/collect-async-effect-task-descriptors.ts index 8edaadb83e..92b05cd09f 100644 --- a/packages/prover/src/collect-async-effect-task-descriptors.ts +++ b/packages/prover/src/collect-async-effect-task-descriptors.ts @@ -2,12 +2,14 @@ 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 { summarizeFunctionReturns } from "./summarize-function-returns.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 type { ReactAnalysisContext, ReactAsyncEffectTaskDescriptor } from "./types.js"; interface AsyncStateWrite { @@ -27,22 +29,6 @@ interface EffectInvalidationGuards { invalidatedBooleanSymbols: ReadonlySet; } -const PROMISE_CONTINUATION_METHODS = new Set(["catch", "finally", "then"]); - -const containsAwait = (node: ts.Node, owner: ts.FunctionLikeDeclaration): boolean => { - let didFindAwait = false; - const visit = (currentNode: ts.Node): void => { - if (didFindAwait || (currentNode !== owner && isFunctionBoundary(currentNode))) return; - if (ts.isAwaitExpression(currentNode)) { - didFindAwait = true; - return; - } - currentNode.forEachChild(visit); - }; - node.forEachChild(visit); - return didFindAwait; -}; - const getDirectStatement = (node: ts.Node, block: ts.Block): ts.Statement | null => { let currentNode = node; while (currentNode.parent !== block) { @@ -57,13 +43,13 @@ const hasSequentialAwaitBefore = ( taskFunction: ts.FunctionLikeDeclaration, ): boolean => { if (!taskFunction.body || !ts.isBlock(taskFunction.body)) return false; - if (containsAwait(operationNode, taskFunction)) return true; + 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) => containsAwait(statement, taskFunction)); + .some((statement) => containsAwaitOutsideNestedFunction(statement, taskFunction)); }; const getIdentifierSymbol = ( @@ -195,21 +181,6 @@ const hasGuardingEarlyReturn = ( return false; }; -const hasGuaranteedCleanupReturn = ( - 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)), - ) - ); -}; - const intersectSymbols = ( symbolSets: ReadonlyArray>, ): ReadonlySet => { @@ -269,7 +240,7 @@ const collectInvalidationGuards = ( cleanupFunctions: ReadonlyArray, typeChecker: ts.TypeChecker, ): EffectInvalidationGuards => { - if (!hasGuaranteedCleanupReturn(effectCallback, typeChecker)) { + if (!hasGuaranteedEffectCleanup(effectCallback, typeChecker)) { return { abortedControllerSymbols: new Set(), invalidatedBooleanSymbols: new Set(), @@ -297,7 +268,7 @@ const collectInvokedAsyncFunctions = ( if (node !== effectCallback && isFunctionBoundary(node)) return; if (ts.isCallExpression(node)) { const taskFunction = resolveFunction(node.expression, typeChecker); - if (taskFunction && containsAwait(taskFunction, taskFunction)) { + if (taskFunction && containsAwaitOutsideNestedFunction(taskFunction, taskFunction)) { taskFunctions.add(taskFunction); } } @@ -334,7 +305,7 @@ const collectAsyncTaskOperations = ( isAfterSuspension && !( ts.isPropertyAccessExpression(node.expression) && - PROMISE_CONTINUATION_METHODS.has(node.expression.name.text) + PROMISE_CONTINUATION_METHOD_NAMES.has(node.expression.name.text) ) ) { unknownOperation ??= node; @@ -402,7 +373,7 @@ const createTaskDescriptor = ( const isPromiseContinuationCall = (node: ts.Node): node is ts.CallExpression => ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && - PROMISE_CONTINUATION_METHODS.has(node.expression.name.text); + PROMISE_CONTINUATION_METHOD_NAMES.has(node.expression.name.text); const collectPromiseContinuationDescriptors = ( ownerFunction: ts.FunctionLikeDeclaration, diff --git a/packages/prover/src/collect-callable-ref-protocols.ts b/packages/prover/src/collect-callable-ref-protocols.ts index 3e3cdf2a50..946e69431b 100644 --- a/packages/prover/src/collect-callable-ref-protocols.ts +++ b/packages/prover/src/collect-callable-ref-protocols.ts @@ -7,6 +7,7 @@ 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; @@ -24,15 +25,6 @@ export interface CallableRefProtocolDescriptor { const protocolCache = new WeakMap(); -const getEnclosingFunction = (node: ts.Node): ts.FunctionLikeDeclaration | null => { - let currentNode = node.parent; - while (currentNode) { - if (isFunctionBoundary(currentNode)) return currentNode; - currentNode = currentNode.parent; - } - return null; -}; - const getRefDeclaration = ( symbol: ts.Symbol, typeChecker: ts.TypeChecker, 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 0000000000..5284994765 --- /dev/null +++ b/packages/prover/src/collect-effect-scheduler-protocols.ts @@ -0,0 +1,362 @@ +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 { getRootIdentifier } from "./get-root-identifier.js"; +import { ReactSchedulerCancellationStatus, ReactSchedulerKind } from "./types.js"; +import type { ReactAnalysisContext } from "./types.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 { hasGuaranteedEffectCleanup } from "./utils/has-guaranteed-effect-cleanup.js"; +import { unwrapTypescriptExpression } from "./unwrap-typescript-expression.js"; + +export interface EffectSchedulerProtocolDescriptor { + callbackExpression: ts.Expression | null; + cancellationCalls: ReadonlyArray; + cancellationStatus: ReactSchedulerCancellationStatus; + effectCall: ts.CallExpression; + isSourceComplete: boolean; + kind: ReactSchedulerKind; + registrationCall: ts.CallExpression; +} + +interface SchedulerApiDescriptor { + cancellationName: string | null; + kind: ReactSchedulerKind; +} + +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 PLATFORM_GLOBAL_NAMES = new Set(["globalThis", "self", "window"]); + +const getResolvedSymbol = (node: ts.Node, typeChecker: ts.TypeChecker): ts.Symbol | null => { + const symbol = typeChecker.getSymbolAtLocation(node); + if (!symbol) return null; + return symbol.flags & ts.SymbolFlags.Alias ? typeChecker.getAliasedSymbol(symbol) : symbol; +}; + +const isPlatformDeclaration = (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/") + ); + }), + ); + +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 (isPlatformDeclaration(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)) continue; + const rootIdentifier = getRootIdentifier(unwrappedExpression.expression); + if ( + rootIdentifier && + PLATFORM_GLOBAL_NAMES.has(rootIdentifier.text) && + isPlatformDeclaration(getResolvedSymbol(rootIdentifier, 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): ts.Identifier | null => { + const declaration = ts.isVariableDeclaration(registrationCall.parent) + ? registrationCall.parent + : null; + if ( + !declaration || + declaration.initializer !== registrationCall || + !ts.isIdentifier(declaration.name) || + !ts.isVariableDeclarationList(declaration.parent) || + !(declaration.parent.flags & ts.NodeFlags.Const) + ) { + return null; + } + return declaration.name; +}; + +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; +}; + +const isEntryCancellation = ( + callExpression: ts.CallExpression, + cleanupFunction: ts.FunctionLikeDeclaration, +): boolean => { + if ( + getEnclosingFunction(callExpression) !== cleanupFunction || + hasConditionalAncestor(callExpression, cleanupFunction) + ) { + return false; + } + const functionBody = cleanupFunction.body; + if (!functionBody) return false; + if (!ts.isBlock(functionBody)) return functionBody === callExpression; + const firstStatement = functionBody.statements[0]; + return Boolean( + firstStatement && + ((ts.isExpressionStatement(firstStatement) && firstStatement.expression === callExpression) || + (ts.isReturnStatement(firstStatement) && firstStatement.expression === callExpression)), + ); +}; + +const isMatchingCancellation = ( + callExpression: ts.CallExpression, + cancellationName: string, + handleSymbol: ts.Symbol, + typeChecker: ts.TypeChecker, +): boolean => { + if (getPlatformCallName(callExpression, typeChecker) !== cancellationName) { + return false; + } + const handleArgument = callExpression.arguments[0]; + return Boolean( + handleArgument && + ts.isIdentifier(handleArgument) && + typeChecker.getSymbolAtLocation(handleArgument) === handleSymbol, + ); +}; + +const collectCancellation = ( + effectCallback: ts.FunctionLikeDeclaration, + registrationCall: ts.CallExpression, + cancellationName: string | null, + typeChecker: ts.TypeChecker, +): { + calls: ReadonlyArray; + status: ReactSchedulerCancellationStatus; +} => { + if (!cancellationName) { + return { calls: [], status: ReactSchedulerCancellationStatus.Unknown }; + } + const handle = getImmutableHandle(registrationCall); + const handleSymbol = handle ? typeChecker.getSymbolAtLocation(handle) : null; + if (!handleSymbol) { + 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, + }; + } + const cleanupFunctions = collectEffectCleanupFunctions(effectCallback, typeChecker); + if (cleanupFunctions.length === 0 || !hasGuaranteedEffectCleanup(effectCallback, typeChecker)) { + 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, handleSymbol, typeChecker), + ); + if (cleanupMatchingCalls.length === 0) { + return { + calls: matchingCalls, + status: + cleanupCalls.length === 0 + ? ReactSchedulerCancellationStatus.Missing + : ReactSchedulerCancellationStatus.Unknown, + }; + } + const entryCancellation = cleanupMatchingCalls.find((cleanupCall) => + isEntryCancellation(cleanupCall, cleanupFunction), + ); + if (!entryCancellation) { + return { + calls: [...matchingCalls, ...cleanupMatchingCalls], + status: ReactSchedulerCancellationStatus.Unknown, + }; + } + matchingCalls.push(entryCancellation); + } + return { + calls: matchingCalls, + status: ReactSchedulerCancellationStatus.Guaranteed, + }; +}; + +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 reachableFunctions = collectReachableFunctions(effectCallback, context.typeChecker); + for (const registrationCall of collectReachableCallExpressions( + effectCallback, + 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( + effectCallback, + 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, + effectCall, + isSourceComplete: + Boolean(callbackExpression) && + !isRegistrationConditional && + cancellation.status === ReactSchedulerCancellationStatus.Guaranteed, + kind: schedulerApi.kind, + registrationCall, + }); + } + } + return protocols; +}; diff --git a/packages/prover/src/constants.ts b/packages/prover/src/constants.ts index c50aace047..5ea392ab4b 100644 --- a/packages/prover/src/constants.ts +++ b/packages/prover/src/constants.ts @@ -1,5 +1,5 @@ -export const REACT_PROOF_SCHEMA_VERSION = 9; -export const REACT_SEMANTIC_GRAPH_SCHEMA_VERSION = 15; +export const REACT_PROOF_SCHEMA_VERSION = 10; +export const REACT_SEMANTIC_GRAPH_SCHEMA_VERSION = 16; 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; @@ -27,6 +27,7 @@ export const EFFECT_EVENT_REGISTRATION_CALL_NAMES = new Set([ "setTimeout", "subscribe", ]); +export const PROMISE_CONTINUATION_METHOD_NAMES = new Set(["catch", "finally", "then"]); export const REACT_MODELED_HOOK_NAMES = new Set([ "useCallback", diff --git a/packages/prover/src/index.ts b/packages/prover/src/index.ts index d5e85862dc..b18811aa12 100644 --- a/packages/prover/src/index.ts +++ b/packages/prover/src/index.ts @@ -11,6 +11,8 @@ export { ReactObligationStatus, ReactProofCertificateStatus, ReactProofClaim, + ReactSchedulerCancellationStatus, + ReactSchedulerKind, ReactSemanticEdgeKind, ReactSemanticCallbackKind, ReactSemanticFunctionCallKind, @@ -49,6 +51,7 @@ export type { ReactSemanticHookCall, ReactSemanticReachableFunction, ReactSemanticRender, + ReactSemanticScheduler, ReactSemanticUnit, ReactAsyncEffectTaskDescriptor, ReactUnitProof, diff --git a/packages/prover/src/prove-react-app.ts b/packages/prover/src/prove-react-app.ts index be563492aa..8edda85d81 100644 --- a/packages/prover/src/prove-react-app.ts +++ b/packages/prover/src/prove-react-app.ts @@ -41,6 +41,7 @@ export const proveReactApp = (input: ProveReactAppInput): ReactAppProofReport => eventBindings: [], callbackPropFlows: [], callableRefs: [], + schedulers: [], compiler: { version: REACT_COMPILER_VERSION, phase: REACT_COMPILER_FACT_PHASE, diff --git a/packages/prover/src/types.ts b/packages/prover/src/types.ts index 9819c7040d..6559193680 100644 --- a/packages/prover/src/types.ts +++ b/packages/prover/src/types.ts @@ -31,6 +31,7 @@ export enum ReactProofClaim { ReducerPurity = "reducer-purity", RefAccess = "ref-access", RenderPurity = "render-purity", + ScheduledCallbackLifetime = "scheduled-callback-lifetime", } export enum ReactUnitKind { @@ -80,6 +81,7 @@ export enum ReactSemanticCallbackKind { MemoizedCallback = "memoized-callback", Reducer = "reducer", ReducerInitializer = "reducer-initializer", + ScheduledCallback = "scheduled-callback", ServerSnapshot = "server-snapshot", } @@ -165,6 +167,21 @@ export enum ReactCallableRefFreshness { 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 ReactAsyncOwnershipStatus { Guarded = "guarded", Unguarded = "unguarded", @@ -344,6 +361,22 @@ export interface ReactSemanticCallableRef { complete: boolean; } +export interface ReactSemanticScheduler { + id: string; + ownerId: string; + effectId: string; + registrationCallbackId: string; + kind: ReactSchedulerKind; + phase: ReactExecutionPhase; + location: ReactProofLocation; + callbackIds: ReadonlyArray; + callbackComplete: boolean; + cancellationStatus: ReactSchedulerCancellationStatus; + cancellationLocations: ReadonlyArray; + sourceComplete: boolean; + complete: boolean; +} + export interface ReactCompilerInstructionFact { id: string; valueKind: string; @@ -402,6 +435,7 @@ export interface ReactSemanticGraph { eventBindings: ReadonlyArray; callbackPropFlows: ReadonlyArray; callableRefs: ReadonlyArray; + schedulers: ReadonlyArray; compiler: ReactCompilerGraph; } 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 0000000000..dacef0ba33 --- /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/contains-await-outside-nested-function.ts b/packages/prover/src/utils/contains-await-outside-nested-function.ts new file mode 100644 index 0000000000..550782a83c --- /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/get-enclosing-function.ts b/packages/prover/src/utils/get-enclosing-function.ts new file mode 100644 index 0000000000..de8e4415c5 --- /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/has-guaranteed-effect-cleanup.ts b/packages/prover/src/utils/has-guaranteed-effect-cleanup.ts new file mode 100644 index 0000000000..10dbd06b3a --- /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/tests/fixtures/incomplete-conditional-timer-cancellation/src/app.tsx b/packages/prover/tests/fixtures/incomplete-conditional-timer-cancellation/src/app.tsx new file mode 100644 index 0000000000..e3877aab2e --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-conditional-timer-cancellation/src/app.tsx @@ -0,0 +1,18 @@ +import { useEffect, useState } from "react"; + +interface TimerProperties { + shouldCancel: boolean; +} + +export const ConditionalCancellationTimer = ({ shouldCancel }: TimerProperties) => { + const [ticks, setTicks] = useState(0); + + useEffect(() => { + const timerId = window.setInterval(() => setTicks((previousTicks) => previousTicks + 1), 100); + return () => { + if (shouldCancel) window.clearInterval(timerId); + }; + }, [shouldCancel]); + + return {ticks}; +}; diff --git a/packages/prover/tests/fixtures/incomplete-conditional-timer-cancellation/tsconfig.json b/packages/prover/tests/fixtures/incomplete-conditional-timer-cancellation/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-conditional-timer-cancellation/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-early-return-timer-cleanup/src/app.tsx b/packages/prover/tests/fixtures/incomplete-early-return-timer-cleanup/src/app.tsx new file mode 100644 index 0000000000..03d18f2e64 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-early-return-timer-cleanup/src/app.tsx @@ -0,0 +1,19 @@ +import { useEffect, useState } from "react"; + +interface TimerProperties { + shouldKeepAlive: boolean; +} + +export const EarlyReturnTimer = ({ shouldKeepAlive }: TimerProperties) => { + const [ticks, setTicks] = useState(0); + + useEffect(() => { + const timerId = window.setInterval(() => setTicks((previousTicks) => previousTicks + 1), 100); + return () => { + if (shouldKeepAlive) return; + window.clearInterval(timerId); + }; + }, [shouldKeepAlive]); + + return {ticks}; +}; diff --git a/packages/prover/tests/fixtures/incomplete-early-return-timer-cleanup/tsconfig.json b/packages/prover/tests/fixtures/incomplete-early-return-timer-cleanup/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-early-return-timer-cleanup/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-effect-microtask/src/app.tsx b/packages/prover/tests/fixtures/incomplete-effect-microtask/src/app.tsx new file mode 100644 index 0000000000..615da07ca0 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-effect-microtask/src/app.tsx @@ -0,0 +1,12 @@ +import { useEffect, useState } from "react"; + +export const MicrotaskStatus = () => { + const [status, setStatus] = useState("pending"); + + useEffect(() => { + window.queueMicrotask(() => setStatus("ready")); + return () => undefined; + }, []); + + return {status}; +}; diff --git a/packages/prover/tests/fixtures/incomplete-effect-microtask/tsconfig.json b/packages/prover/tests/fixtures/incomplete-effect-microtask/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-effect-microtask/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-event-timeout/src/app.tsx b/packages/prover/tests/fixtures/incomplete-event-timeout/src/app.tsx new file mode 100644 index 0000000000..bc069bc2b0 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-event-timeout/src/app.tsx @@ -0,0 +1,14 @@ +import { useState } from "react"; + +export const DeferredSelection = () => { + const [selection, setSelection] = useState("none"); + const selectLater = () => { + window.setTimeout(() => setSelection("blueberry"), 100); + }; + + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/incomplete-event-timeout/tsconfig.json b/packages/prover/tests/fixtures/incomplete-event-timeout/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-event-timeout/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-mutable-timer-handle/src/app.tsx b/packages/prover/tests/fixtures/incomplete-mutable-timer-handle/src/app.tsx new file mode 100644 index 0000000000..fe06d919ee --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-mutable-timer-handle/src/app.tsx @@ -0,0 +1,12 @@ +import { useEffect, useState } from "react"; + +export const MutableTimer = () => { + const [ticks, setTicks] = useState(0); + + useEffect(() => { + let timerId = window.setInterval(() => setTicks((previousTicks) => previousTicks + 1), 100); + return () => window.clearInterval(timerId); + }, []); + + return {ticks}; +}; diff --git a/packages/prover/tests/fixtures/incomplete-mutable-timer-handle/tsconfig.json b/packages/prover/tests/fixtures/incomplete-mutable-timer-handle/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-mutable-timer-handle/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-nested-timeout/src/app.tsx b/packages/prover/tests/fixtures/incomplete-nested-timeout/src/app.tsx new file mode 100644 index 0000000000..bc11329037 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-nested-timeout/src/app.tsx @@ -0,0 +1,14 @@ +import { useEffect, useState } from "react"; + +export const NestedTimer = () => { + const [status, setStatus] = useState("pending"); + + useEffect(() => { + const outerTimeoutId = window.setTimeout(() => { + window.setTimeout(() => setStatus("ready"), 100); + }, 100); + return () => window.clearTimeout(outerTimeoutId); + }, []); + + return {status}; +}; diff --git a/packages/prover/tests/fixtures/incomplete-nested-timeout/tsconfig.json b/packages/prover/tests/fixtures/incomplete-nested-timeout/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-nested-timeout/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-timer-async-continuation/src/app.tsx b/packages/prover/tests/fixtures/incomplete-timer-async-continuation/src/app.tsx new file mode 100644 index 0000000000..2b82c726c0 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-timer-async-continuation/src/app.tsx @@ -0,0 +1,15 @@ +import { useEffect, useState } from "react"; + +export const AsyncTimer = () => { + const [status, setStatus] = useState("pending"); + + useEffect(() => { + const timeoutId = window.setTimeout(async () => { + await Promise.resolve(); + setStatus("ready"); + }, 100); + return () => window.clearTimeout(timeoutId); + }, []); + + return {status}; +}; diff --git a/packages/prover/tests/fixtures/incomplete-timer-async-continuation/tsconfig.json b/packages/prover/tests/fixtures/incomplete-timer-async-continuation/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-timer-async-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/incomplete-timer-floating-promise/src/app.tsx b/packages/prover/tests/fixtures/incomplete-timer-floating-promise/src/app.tsx new file mode 100644 index 0000000000..48d1e1f2d0 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-timer-floating-promise/src/app.tsx @@ -0,0 +1,14 @@ +import { useEffect, useState } from "react"; + +export const FloatingPromiseTimer = () => { + const [status, setStatus] = useState("pending"); + + useEffect(() => { + const timeoutId = window.setTimeout(() => { + void fetch("/status").then(() => setStatus("ready")); + }, 100); + return () => window.clearTimeout(timeoutId); + }, []); + + return {status}; +}; diff --git a/packages/prover/tests/fixtures/incomplete-timer-floating-promise/tsconfig.json b/packages/prover/tests/fixtures/incomplete-timer-floating-promise/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-timer-floating-promise/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-aliased-window-timeout/src/app.tsx b/packages/prover/tests/fixtures/proved-aliased-window-timeout/src/app.tsx new file mode 100644 index 0000000000..714f9a52bd --- /dev/null +++ b/packages/prover/tests/fixtures/proved-aliased-window-timeout/src/app.tsx @@ -0,0 +1,13 @@ +import { useEffect } from "react"; + +const scheduleTimeout = window.setTimeout; +const cancelTimeout = window.clearTimeout; + +export const AliasedWindowTimeout = () => { + useEffect(() => { + const timeoutId = scheduleTimeout(() => undefined, 100); + return () => cancelTimeout(timeoutId); + }, []); + + return waiting; +}; diff --git a/packages/prover/tests/fixtures/proved-aliased-window-timeout/tsconfig.json b/packages/prover/tests/fixtures/proved-aliased-window-timeout/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-aliased-window-timeout/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-animation-frame/src/app.tsx b/packages/prover/tests/fixtures/proved-animation-frame/src/app.tsx new file mode 100644 index 0000000000..f2839b7220 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-animation-frame/src/app.tsx @@ -0,0 +1,12 @@ +import { useEffect, useState } from "react"; + +export const FrameStatus = () => { + const [frameTime, setFrameTime] = useState(0); + + useEffect(() => { + const frameId = window.requestAnimationFrame((timestamp) => setFrameTime(timestamp)); + return () => window.cancelAnimationFrame(frameId); + }, []); + + return {frameTime}; +}; diff --git a/packages/prover/tests/fixtures/proved-animation-frame/tsconfig.json b/packages/prover/tests/fixtures/proved-animation-frame/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-animation-frame/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-shadowed-timeout/src/app.tsx b/packages/prover/tests/fixtures/proved-shadowed-timeout/src/app.tsx new file mode 100644 index 0000000000..980fb1d7d3 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-shadowed-timeout/src/app.tsx @@ -0,0 +1,14 @@ +import { useEffect } from "react"; + +const setTimeout = (callback: () => void) => { + callback(); + return 1; +}; + +export const ShadowedTimeout = () => { + useEffect(() => { + setTimeout(() => undefined); + }, []); + + return complete; +}; diff --git a/packages/prover/tests/fixtures/proved-shadowed-timeout/tsconfig.json b/packages/prover/tests/fixtures/proved-shadowed-timeout/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-shadowed-timeout/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-window-timeout/src/app.tsx b/packages/prover/tests/fixtures/proved-window-timeout/src/app.tsx new file mode 100644 index 0000000000..954d057103 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-window-timeout/src/app.tsx @@ -0,0 +1,12 @@ +import { useEffect, useState } from "react"; + +export const DelayedStatus = () => { + const [status, setStatus] = useState("pending"); + + useEffect(() => { + const timeoutId = window.setTimeout(() => setStatus("ready"), 100); + return () => window.clearTimeout(timeoutId); + }, []); + + return {status}; +}; diff --git a/packages/prover/tests/fixtures/proved-window-timeout/tsconfig.json b/packages/prover/tests/fixtures/proved-window-timeout/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-window-timeout/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/refuted-timer-partial-cleanup/src/app.tsx b/packages/prover/tests/fixtures/refuted-timer-partial-cleanup/src/app.tsx new file mode 100644 index 0000000000..206d9dbb99 --- /dev/null +++ b/packages/prover/tests/fixtures/refuted-timer-partial-cleanup/src/app.tsx @@ -0,0 +1,17 @@ +import { useEffect, useState } from "react"; + +interface TimerProperties { + shouldCancel: boolean; +} + +export const PartialCleanupTimer = ({ shouldCancel }: TimerProperties) => { + const [ticks, setTicks] = useState(0); + + useEffect(() => { + const timerId = window.setInterval(() => setTicks((previousTicks) => previousTicks + 1), 100); + if (shouldCancel) return () => window.clearInterval(timerId); + return () => undefined; + }, [shouldCancel]); + + return {ticks}; +}; diff --git a/packages/prover/tests/fixtures/refuted-timer-partial-cleanup/tsconfig.json b/packages/prover/tests/fixtures/refuted-timer-partial-cleanup/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/refuted-timer-partial-cleanup/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/prove-react-app.test.ts b/packages/prover/tests/prove-react-app.test.ts index c1f86c32bf..af39bbbc10 100644 --- a/packages/prover/tests/prove-react-app.test.ts +++ b/packages/prover/tests/prove-react-app.test.ts @@ -13,6 +13,8 @@ import { ReactIdentityStability, ReactObligationStatus, ReactProofClaim, + ReactSchedulerCancellationStatus, + ReactSchedulerKind, ReactProofCertificateStatus, ReactSemanticEdgeKind, ReactSemanticCallbackKind, @@ -48,6 +50,11 @@ const REFUTED_FIXTURES: ReadonlyArray = [ claim: ReactProofClaim.EffectDependencies, evidencePattern: /absent from the effect dependency list/, }, + { + fixtureName: "refuted-timer-partial-cleanup", + claim: ReactProofClaim.ScheduledCallbackLifetime, + evidencePattern: /remain active/, + }, { fixtureName: "impure-render", claim: ReactProofClaim.RenderPurity, @@ -110,8 +117,8 @@ const REFUTED_FIXTURES: ReadonlyArray = [ }, { fixtureName: "timer-leak", - claim: ReactProofClaim.EffectCleanup, - evidencePattern: /same resource identity/, + claim: ReactProofClaim.ScheduledCallbackLifetime, + evidencePattern: /remain active/, }, { fixtureName: "invalid-hook-helper", @@ -309,6 +316,10 @@ describe("proveReactApp", () => { it.each([ "proved-local-graph", "proved-timer", + "proved-window-timeout", + "proved-animation-frame", + "proved-aliased-window-timeout", + "proved-shadowed-timeout", "proved-custom-hook", "proved-cfg", "proved-memo", @@ -403,7 +414,7 @@ describe("proveReactApp", () => { const report = proveFixture("proved-chat"); const effect = report.graph.effects[0]; - expect(report.graph.schemaVersion).toBe(15); + expect(report.graph.schemaVersion).toBe(16); expect(effect?.hookName).toBe("useEffect"); expect(effect?.callbackResolved).toBe(true); expect(effect?.dependencyMode).toBe(ReactEffectDependencyMode.Inline); @@ -419,6 +430,126 @@ describe("proveReactApp", () => { ).toBe(ReactExecutionPhase.EffectCleanup); }); + it("certifies an interval callback in the deferred phase with guaranteed cancellation", () => { + const report = proveFixture("proved-timer"); + const scheduler = report.graph.schedulers[0]; + const callback = report.graph.callbacks.find( + (candidate) => candidate.id === scheduler?.callbackIds[0], + ); + + expect(report.status).toBe(ReactAppProofStatus.Proved); + expect(scheduler?.kind).toBe(ReactSchedulerKind.Interval); + expect(scheduler?.phase).toBe(ReactExecutionPhase.Deferred); + expect(scheduler?.cancellationStatus).toBe(ReactSchedulerCancellationStatus.Guaranteed); + expect(scheduler?.complete).toBe(true); + expect(callback?.kind).toBe(ReactSemanticCallbackKind.ScheduledCallback); + expect(callback?.phase).toBe(ReactExecutionPhase.Deferred); + }); + + it("certifies platform timeout and animation-frame scheduler identities", () => { + const timeoutReport = proveFixture("proved-window-timeout"); + const animationFrameReport = proveFixture("proved-animation-frame"); + + expect(timeoutReport.status).toBe(ReactAppProofStatus.Proved); + expect(timeoutReport.graph.schedulers[0]?.kind).toBe(ReactSchedulerKind.Timeout); + expect(animationFrameReport.status).toBe(ReactAppProofStatus.Proved); + expect(animationFrameReport.graph.schedulers[0]?.kind).toBe(ReactSchedulerKind.AnimationFrame); + }); + + it("resolves immutable platform aliases without trusting a shadowed scheduler name", () => { + const aliasedReport = proveFixture("proved-aliased-window-timeout"); + const shadowedReport = proveFixture("proved-shadowed-timeout"); + + expect(aliasedReport.status).toBe(ReactAppProofStatus.Proved); + expect(aliasedReport.graph.schedulers[0]?.kind).toBe(ReactSchedulerKind.Timeout); + expect(shadowedReport.status).toBe(ReactAppProofStatus.Proved); + expect(shadowedReport.graph.schedulers).toEqual([]); + }); + + it("refutes a scheduler canceled by only one cleanup return alternative", () => { + const report = proveFixture("refuted-timer-partial-cleanup"); + const schedulerProof = report.units + .flatMap((unit) => unit.obligations) + .find((obligation) => obligation.claim === ReactProofClaim.ScheduledCallbackLifetime); + + expect(report.status).toBe(ReactAppProofStatus.Refuted); + expect(report.graph.schedulers[0]?.cancellationStatus).toBe( + ReactSchedulerCancellationStatus.Missing, + ); + expect(schedulerProof?.status).toBe(ReactObligationStatus.Violated); + }); + + it.each([ + "incomplete-mutable-timer-handle", + "incomplete-conditional-timer-cancellation", + "incomplete-early-return-timer-cleanup", + ])("fails closed on an unproved scheduler handle or cleanup path in %s", (fixtureName) => { + const report = proveFixture(fixtureName); + const schedulerProof = report.units + .flatMap((unit) => unit.obligations) + .find((obligation) => obligation.claim === ReactProofClaim.ScheduledCallbackLifetime); + + expect(report.status).toBe(ReactAppProofStatus.Incomplete); + expect(report.graph.schedulers[0]?.complete).toBe(false); + expect(schedulerProof?.status).toBe(ReactObligationStatus.Unknown); + }); + + it("fails closed on a scheduler registered outside an Effect lifecycle", () => { + const report = proveFixture("incomplete-event-timeout"); + const boundaryProof = report.units + .flatMap((unit) => unit.obligations) + .find((obligation) => obligation.claim === ReactProofClaim.BoundaryCoverage); + + expect(report.status).toBe(ReactAppProofStatus.Incomplete); + expect(report.graph.schedulers).toEqual([]); + expect(boundaryProof?.status).toBe(ReactObligationStatus.Unknown); + expect(boundaryProof?.evidence[0]?.description).toMatch(/deferred callback/); + }); + + it.each([ + "incomplete-timer-async-continuation", + "incomplete-timer-floating-promise", + "incomplete-nested-timeout", + ])( + "fails closed when a scheduled callback creates transitive async work in %s", + (fixtureName) => { + const report = proveFixture(fixtureName); + + expect(report.status).toBe(ReactAppProofStatus.Incomplete); + expect(report.graph.schedulers[0]?.callbackComplete).toBe(false); + expect(report.graph.schedulers[0]?.complete).toBe(false); + }, + ); + + it("records an uncancellable microtask without granting lifecycle ownership", () => { + const report = proveFixture("incomplete-effect-microtask"); + const scheduler = report.graph.schedulers[0]; + + expect(report.status).toBe(ReactAppProofStatus.Incomplete); + expect(scheduler?.kind).toBe(ReactSchedulerKind.Microtask); + expect(scheduler?.cancellationStatus).toBe(ReactSchedulerCancellationStatus.Unknown); + expect(scheduler?.complete).toBe(false); + }); + + it("rejects a scheduler certificate with contradictory cancellation facts", () => { + const report = proveFixture("proved-window-timeout"); + const certificate = checkReactProofReport({ + ...report, + graph: { + ...report.graph, + schedulers: report.graph.schedulers.map((scheduler) => ({ + ...scheduler, + cancellationStatus: ReactSchedulerCancellationStatus.Missing, + })), + }, + }); + + expect(certificate.status).toBe(ReactProofCertificateStatus.Invalid); + expect( + certificate.failures.some((failure) => failure.description.includes("completeness flag")), + ).toBe(true); + }); + it("assigns memo, event, and reducer callbacks to execution phases", () => { const memoReport = proveFixture("proved-memo"); const reducerReport = proveFixture("proved-reducer"); @@ -1204,6 +1335,19 @@ describe("proveReactApp", () => { "proved-local-object-callback", "incomplete-ref-backed-event-callback", "incomplete-mutable-object-callback", + "proved-window-timeout", + "proved-animation-frame", + "proved-aliased-window-timeout", + "proved-shadowed-timeout", + "incomplete-event-timeout", + "incomplete-mutable-timer-handle", + "refuted-timer-partial-cleanup", + "incomplete-conditional-timer-cancellation", + "incomplete-early-return-timer-cleanup", + "incomplete-timer-async-continuation", + "incomplete-timer-floating-promise", + "incomplete-effect-microtask", + "incomplete-nested-timeout", ])("independently checks the %s proof certificate", (fixtureName) => { const certificate = checkReactProofReport(proveFixture(fixtureName)); diff --git a/packages/prover/tests/runtime/constants.ts b/packages/prover/tests/runtime/constants.ts index 7eb204a078..1f358e0a87 100644 --- a/packages/prover/tests/runtime/constants.ts +++ b/packages/prover/tests/runtime/constants.ts @@ -4,6 +4,8 @@ export const LATE_QUERY_SETTLE_WAIT_MS = 250; export const NEXT_CALLBACK_REVISION = 1; export const PRIMARY_STORE_INITIAL_VERSION = 0; export const SECONDARY_STORE_INITIAL_VERSION = 100; +export const SCHEDULER_CALLBACK_DELAY_MS = 80; +export const SCHEDULER_SETTLE_WAIT_MS = 140; export const SLOW_QUERY_DELAY_MS = 200; export const STORE_VERSION_INCREMENT = 1; export const UNOBSERVED_CALLBACK_REVISION = -1; diff --git a/packages/prover/tests/runtime/main.tsx b/packages/prover/tests/runtime/main.tsx index e2a02c3ff4..702caa9445 100644 --- a/packages/prover/tests/runtime/main.tsx +++ b/packages/prover/tests/runtime/main.tsx @@ -17,6 +17,7 @@ import { INITIAL_CALLBACK_REVISION, NEXT_CALLBACK_REVISION, PRIMARY_STORE_INITIAL_VERSION, + SCHEDULER_CALLBACK_DELAY_MS, SECONDARY_STORE_INITIAL_VERSION, SLOW_QUERY_DELAY_MS, STORE_VERSION_INCREMENT, @@ -27,11 +28,13 @@ declare global { interface Window { effectEventSetupRuns: number; listenerHits: number; + schedulerHits: number; } } window.effectEventSetupRuns = 0; window.listenerHits = 0; +window.schedulerHits = 0; const LeakyListener = () => { useEffect(() => { @@ -76,6 +79,34 @@ const ListenerOracle = () => { ); }; +interface SchedulerProbeProperties { + shouldCancel: boolean; +} + +const SchedulerProbe = ({ shouldCancel }: SchedulerProbeProperties) => { + useEffect(() => { + const timeoutId = window.setTimeout(() => { + window.schedulerHits += 1; + }, SCHEDULER_CALLBACK_DELAY_MS); + if (shouldCancel) return () => window.clearTimeout(timeoutId); + return undefined; + }, [shouldCancel]); + return null; +}; + +const SchedulerLifetimeOracle = () => { + const [isMounted, setIsMounted] = useState(true); + const shouldCancel = new URLSearchParams(window.location.search).get("mode") === "cancel"; + return ( +
    + + {isMounted ? : null} +
    + ); +}; + interface KeyedItem { id: string; label: string; @@ -529,6 +560,9 @@ const RuntimeOracle = () => { if (oracle === "callable-ref-phase") { return ; } + if (oracle === "scheduler-lifetime") { + return ; + } return ; }; diff --git a/packages/prover/tests/runtime/scheduler-lifetime-oracle.spec.ts b/packages/prover/tests/runtime/scheduler-lifetime-oracle.spec.ts new file mode 100644 index 0000000000..4698d39d9b --- /dev/null +++ b/packages/prover/tests/runtime/scheduler-lifetime-oracle.spec.ts @@ -0,0 +1,18 @@ +import { expect, test } from "@playwright/test"; +import { SCHEDULER_SETTLE_WAIT_MS } from "./constants.js"; + +test("clearing a timeout during Effect cleanup prevents post-unmount work", async ({ page }) => { + await page.goto("/?oracle=scheduler-lifetime&mode=cancel"); + await page.getByRole("button", { name: "unmount scheduler" }).click(); + await page.waitForTimeout(SCHEDULER_SETTLE_WAIT_MS); + + expect(await page.evaluate(() => window.schedulerHits)).toBe(0); +}); + +test("an uncanceled timeout remains observable after Effect cleanup", async ({ page }) => { + await page.goto("/?oracle=scheduler-lifetime&mode=leak"); + await page.getByRole("button", { name: "unmount scheduler" }).click(); + await page.waitForTimeout(SCHEDULER_SETTLE_WAIT_MS); + + expect(await page.evaluate(() => window.schedulerHits)).toBe(1); +}); From 3a1836b18b05c042f7ac5708789090d9c91df91c Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Tue, 28 Jul 2026 14:05:31 +0000 Subject: [PATCH 05/23] feat(prover): certify effect resource lifetimes --- packages/prover/README.md | 8 +- packages/prover/research-log.md | 83 ++- .../prover/src/analyze-boundary-coverage.ts | 22 + packages/prover/src/analyze-effect-cleanup.ts | 311 ++--------- packages/prover/src/analyze-react-unit.ts | 2 +- .../prover/src/build-react-semantic-graph.ts | 191 +++++-- .../prover/src/check-react-proof-report.ts | 141 +++++ .../src/collect-effect-resource-protocols.ts | 508 ++++++++++++++++++ .../src/collect-effect-scheduler-protocols.ts | 87 +-- .../prover/src/collect-reachable-functions.ts | 33 +- packages/prover/src/constants.ts | 12 +- packages/prover/src/index.ts | 3 + packages/prover/src/prove-react-app.ts | 1 + packages/prover/src/types.ts | 33 ++ .../are-immutable-expressions-identical.ts | 92 ++++ .../get-platform-effect-resource-kind.ts | 58 ++ .../prover/src/utils/get-resolved-symbol.ts | 7 + .../src/utils/get-static-property-name.ts | 14 + .../src/utils/has-conditional-ancestor.ts | 31 ++ .../utils/is-deferred-callback-synchronous.ts | 35 ++ .../src/utils/is-entry-dominating-node.ts | 23 + .../utils/is-platform-declaration-symbol.ts | 13 + .../src/utils/is-platform-resource-value.ts | 54 ++ .../abort-signal-listener-leak/src/app.tsx | 14 + .../tsconfig.json | 0 .../src/app.tsx | 21 + .../tsconfig.json | 4 + .../src/app.tsx | 14 + .../tsconfig.json | 4 + .../src/app.tsx | 12 + .../tsconfig.json | 4 + .../src/app.tsx | 16 + .../tsconfig.json | 4 + .../incomplete-ref-event-target/src/app.tsx | 15 + .../incomplete-ref-event-target/tsconfig.json | 4 + .../src/app.tsx | 6 + .../tsconfig.json | 4 + .../src/app.tsx | 31 ++ .../tsconfig.json | 4 + .../listener-conditional-disposal/src/app.tsx | 18 + .../tsconfig.json | 4 + .../listener-partial-cleanup/src/app.tsx | 19 + .../listener-partial-cleanup/tsconfig.json | 4 + .../src/app.tsx | 17 + .../tsconfig.json | 4 + .../mutation-observer-leak/src/app.tsx | 12 + .../mutation-observer-leak/tsconfig.json | 4 + .../proved-abort-signal-listener/src/app.tsx | 16 + .../tsconfig.json | 4 + .../src/app.tsx | 0 .../tsconfig.json | 4 + .../proved-intersection-observer/src/app.tsx | 13 + .../tsconfig.json | 4 + .../src/app.tsx | 19 + .../tsconfig.json | 4 + .../src/app.tsx | 14 + .../tsconfig.json | 4 + .../proved-mutation-observer/src/app.tsx | 13 + .../proved-mutation-observer/tsconfig.json | 4 + .../src/app.tsx | 11 + .../tsconfig.json | 4 + .../proved-resize-observer/src/app.tsx | 13 + .../proved-resize-observer/tsconfig.json | 4 + .../src/app.tsx | 15 + .../tsconfig.json | 4 + .../shadowed-event-target/src/app.tsx | 19 + .../shadowed-event-target/tsconfig.json | 4 + packages/prover/tests/prove-react-app.test.ts | 204 ++++++- packages/prover/tests/runtime/constants.ts | 1 + packages/prover/tests/runtime/main.tsx | 34 ++ .../runtime/observer-lifetime-oracle.spec.ts | 24 + 71 files changed, 2018 insertions(+), 419 deletions(-) create mode 100644 packages/prover/src/collect-effect-resource-protocols.ts create mode 100644 packages/prover/src/utils/are-immutable-expressions-identical.ts create mode 100644 packages/prover/src/utils/get-platform-effect-resource-kind.ts create mode 100644 packages/prover/src/utils/get-resolved-symbol.ts create mode 100644 packages/prover/src/utils/get-static-property-name.ts create mode 100644 packages/prover/src/utils/has-conditional-ancestor.ts create mode 100644 packages/prover/src/utils/is-deferred-callback-synchronous.ts create mode 100644 packages/prover/src/utils/is-entry-dominating-node.ts create mode 100644 packages/prover/src/utils/is-platform-declaration-symbol.ts create mode 100644 packages/prover/src/utils/is-platform-resource-value.ts create mode 100644 packages/prover/tests/fixtures/abort-signal-listener-leak/src/app.tsx rename packages/prover/tests/fixtures/{conditional-helper-effect-cleanup => abort-signal-listener-leak}/tsconfig.json (100%) create mode 100644 packages/prover/tests/fixtures/incomplete-accessor-listener-capture/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-accessor-listener-capture/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-ambiguous-observer-kind/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-ambiguous-observer-kind/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-async-listener-callback/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-async-listener-callback/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-dynamic-listener-capture/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-dynamic-listener-capture/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-ref-event-target/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-ref-event-target/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-render-resource-registration/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-render-resource-registration/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-structural-event-target/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-structural-event-target/tsconfig.json create mode 100644 packages/prover/tests/fixtures/listener-conditional-disposal/src/app.tsx create mode 100644 packages/prover/tests/fixtures/listener-conditional-disposal/tsconfig.json create mode 100644 packages/prover/tests/fixtures/listener-partial-cleanup/src/app.tsx create mode 100644 packages/prover/tests/fixtures/listener-partial-cleanup/tsconfig.json create mode 100644 packages/prover/tests/fixtures/mixed-opaque-effect-and-listener-leak/src/app.tsx create mode 100644 packages/prover/tests/fixtures/mixed-opaque-effect-and-listener-leak/tsconfig.json create mode 100644 packages/prover/tests/fixtures/mutation-observer-leak/src/app.tsx create mode 100644 packages/prover/tests/fixtures/mutation-observer-leak/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-abort-signal-listener/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-abort-signal-listener/tsconfig.json rename packages/prover/tests/fixtures/{conditional-helper-effect-cleanup => proved-conditional-helper-effect-cleanup}/src/app.tsx (100%) create mode 100644 packages/prover/tests/fixtures/proved-conditional-helper-effect-cleanup/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-intersection-observer/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-intersection-observer/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-listener-capture-semantics/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-listener-capture-semantics/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-multi-target-mutation-observer/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-multi-target-mutation-observer/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-mutation-observer/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-mutation-observer/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-observer-constructor-only/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-observer-constructor-only/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-resize-observer/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-resize-observer/tsconfig.json create mode 100644 packages/prover/tests/fixtures/quoted-capture-cleanup-mismatch/src/app.tsx create mode 100644 packages/prover/tests/fixtures/quoted-capture-cleanup-mismatch/tsconfig.json create mode 100644 packages/prover/tests/fixtures/shadowed-event-target/src/app.tsx create mode 100644 packages/prover/tests/fixtures/shadowed-event-target/tsconfig.json create mode 100644 packages/prover/tests/runtime/observer-lifetime-oracle.spec.ts diff --git a/packages/prover/README.md b/packages/prover/README.md index a12aa82089..267f6e2fe1 100644 --- a/packages/prover/README.md +++ b/packages/prover/README.md @@ -48,6 +48,10 @@ The report includes: or microtask registration to its owning Effect, deferred callback set, exact handle, and cleanup cancellation paths; only source-resolved synchronous callbacks with entry-dominating cleanup cancellation are complete; +- Effect resource lifetime facts for platform event listeners and activated mutation, resize, and + intersection observers; listener disposal follows the DOM's type/callback/capture identity rule + or an exact `AbortController`, observers record every `observe()` activation, and every returned + cleanup alternative must reach exact-object disposal; - 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. @@ -67,7 +71,9 @@ alternatives. Callable refs additionally require a source-complete `useLayoutEff concrete event callback, and a `ref.current` call edge. Scheduler certificates require a real Effect setup callback, deferred callback facts, exact cancellation evidence, and internally consistent completeness. Source-derived block invariants and broader lifecycle transition -certificates remain future work. +certificates remain future work. Resource certificates additionally require a real Effect setup, +platform-declaration identity, deferred or Effect Event callback facts, nonempty activation and +disposal evidence, and a completeness flag derived exactly from those facts. ## Verification diff --git a/packages/prover/research-log.md b/packages/prover/research-log.md index 108919a820..98bc9d7f0e 100644 --- a/packages/prover/research-log.md +++ b/packages/prover/research-log.md @@ -517,6 +517,7 @@ Proved: - `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` @@ -633,7 +634,6 @@ Incomplete: - `async-effect-post-await-mutation` - `async-effect-path-dependent-invalidation` - `helper-effect-state-update` -- `conditional-helper-effect-cleanup` - `external-store-helper-boundary` - `incomplete-external-store-callback-prop-conditional-join` - `incomplete-external-store-conditional-factory` @@ -736,6 +736,9 @@ components and hooks, including hooks hidden in incorrectly named helper functio ### Test stack +Current checkpoint: 205 TypeScript fixture projects, 382 static tests, and 26 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. @@ -761,3 +764,81 @@ components and hooks, including hooks hidden in incorrectly named helper functio 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. + +## 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` diff --git a/packages/prover/src/analyze-boundary-coverage.ts b/packages/prover/src/analyze-boundary-coverage.ts index 2d3ce472fc..d27c2b56f1 100644 --- a/packages/prover/src/analyze-boundary-coverage.ts +++ b/packages/prover/src/analyze-boundary-coverage.ts @@ -30,6 +30,7 @@ import { collectJsxSpreadProperties } from "./utils/collect-jsx-spread-propertie import { isEffectiveJsxPropertySource } from "./utils/is-effective-jsx-property-source.js"; import { isIntrinsicJsxElement } from "./utils/is-intrinsic-jsx-element.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, @@ -264,6 +265,27 @@ export const analyzeBoundaryCoverage = ( ); } } + 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 finalCallName = getCanonicalHookName(node, context.typeChecker); const isModeledContextRead = finalCallName === "use" && diff --git a/packages/prover/src/analyze-effect-cleanup.ts b/packages/prover/src/analyze-effect-cleanup.ts index 9396011f84..b7491b3210 100644 --- a/packages/prover/src/analyze-effect-cleanup.ts +++ b/packages/prover/src/analyze-effect-cleanup.ts @@ -1,271 +1,72 @@ -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 { collectEffectResourceProtocols } from "./collect-effect-resource-protocols.js"; import { createEvidence } from "./create-evidence.js"; import { createObligation } from "./create-obligation.js"; -import { getCallName } from "./get-call-name.js"; -import { getEffectCallback } from "./get-effect-callback.js"; -import { isFunctionBoundary } from "./is-function-boundary.js"; -import { ReactObligationStatus, ReactProofClaim } from "./types.js"; -import { collectReachableCallExpressions } from "./utils/collect-reachable-call-expressions.js"; -import type { ReactAnalysisContext, ReactProofEvidence, ReactProofObligation } from "./types.js"; - -interface ResourceAcquisition { - node: ts.Node; - description: string; - cleanupNames: ReadonlyArray; - isConditionallyReached: boolean; - ownerFunction: ts.FunctionLikeDeclaration; - eventListener?: EventListenerIdentity; -} - -interface EventListenerIdentity { - targetText: string; - eventText: string; - handler: ts.Expression; - optionsText: string; -} - -const getAssignedName = (expression: ts.Expression): string | null => { - const parentNode = expression.parent; - if ( - ts.isVariableDeclaration(parentNode) && - parentNode.initializer === expression && - ts.isIdentifier(parentNode.name) - ) { - return parentNode.name.text; - } - if ( - ts.isBinaryExpression(parentNode) && - parentNode.right === expression && - parentNode.operatorToken.kind === ts.SyntaxKind.EqualsToken && - ts.isIdentifier(parentNode.left) - ) { - return parentNode.left.text; - } - return null; -}; - -const getEventListenerIdentity = ( - callExpression: ts.CallExpression, -): EventListenerIdentity | null => { - if ( - !ts.isPropertyAccessExpression(callExpression.expression) || - callExpression.expression.name.text !== "addEventListener" - ) { - return null; - } - const eventExpression = callExpression.arguments[0]; - const handlerExpression = callExpression.arguments[1]; - if (!eventExpression || !handlerExpression) return null; - return { - targetText: callExpression.expression.expression.getText(), - eventText: eventExpression.getText(), - handler: handlerExpression, - optionsText: callExpression.arguments[2]?.getText() ?? "", - }; -}; - -const getCanonicalCall = (callExpression: ts.CallExpression): string => { - const callName = getCallName(callExpression) ?? callExpression.expression.getText(); - const argumentsText = callExpression.arguments.map((argument) => argument.getText()).join(","); - return `${callName}(${argumentsText})`; -}; - -const collectResourceAcquisitions = ( - effectCallback: ts.FunctionLikeDeclaration, - typeChecker: ts.TypeChecker, -): ReadonlyArray => { - const acquisitions: ResourceAcquisition[] = []; - for (const reachableFunction of collectReachableFunctions(effectCallback, typeChecker)) { - const visit = (node: ts.Node): void => { - if (node !== reachableFunction.functionNode && isFunctionBoundary(node)) return; - if (ts.isCallExpression(node)) { - const callName = getCallName(node); - const eventListener = getEventListenerIdentity(node); - if (eventListener) { - acquisitions.push({ - node, - description: `${callName ?? "addEventListener"} registration`, - cleanupNames: [ - `${eventListener.targetText}.removeEventListener(${eventListener.eventText}, same handler and options)`, - ], - isConditionallyReached: reachableFunction.isConditionallyReached, - ownerFunction: reachableFunction.functionNode, - eventListener, - }); - } else if ( - ts.isPropertyAccessExpression(node.expression) && - node.expression.name.text === "subscribe" - ) { - const assignedName = getAssignedName(node); - acquisitions.push({ - node, - description: `${callName ?? "subscribe"} subscription`, - cleanupNames: assignedName - ? [`${assignedName}()`, `${assignedName}.unsubscribe()`] - : [], - isConditionallyReached: reachableFunction.isConditionallyReached, - ownerFunction: reachableFunction.functionNode, - }); - } - } - if (ts.isNewExpression(node)) { - const constructorName = node.expression.getText(); - if ( - constructorName === "IntersectionObserver" || - constructorName === "MutationObserver" || - constructorName === "ResizeObserver" || - constructorName === "EventSource" || - constructorName === "WebSocket" - ) { - const assignedName = getAssignedName(node); - const cleanupMethod = constructorName.endsWith("Observer") ? "disconnect" : "close"; - acquisitions.push({ - node, - description: `${constructorName} resource`, - cleanupNames: assignedName ? [`${assignedName}.${cleanupMethod}()`] : [], - isConditionallyReached: reachableFunction.isConditionallyReached, - ownerFunction: reachableFunction.functionNode, - }); - } - } - node.forEachChild(visit); - }; - reachableFunction.functionNode.forEachChild(visit); - } - return acquisitions; -}; - -const isSameExpressionIdentity = ( - leftExpression: ts.Expression, - rightExpression: ts.Expression, - typeChecker: ts.TypeChecker, -): boolean => { - if (leftExpression === rightExpression) return true; - if (!ts.isIdentifier(leftExpression) || !ts.isIdentifier(rightExpression)) return false; - const leftSymbol = typeChecker.getSymbolAtLocation(leftExpression); - const rightSymbol = typeChecker.getSymbolAtLocation(rightExpression); - return Boolean(leftSymbol && leftSymbol === rightSymbol); -}; - -const hasMatchingEventListenerCleanup = ( - eventListener: EventListenerIdentity, - cleanupCalls: ReadonlyArray, - typeChecker: ts.TypeChecker, -): boolean => - cleanupCalls.some((cleanupCall) => { - if ( - !ts.isPropertyAccessExpression(cleanupCall.expression) || - cleanupCall.expression.name.text !== "removeEventListener" || - cleanupCall.expression.expression.getText() !== eventListener.targetText - ) { - return false; - } - const cleanupEvent = cleanupCall.arguments[0]; - const cleanupHandler = cleanupCall.arguments[1]; - if (!cleanupEvent || !cleanupHandler) return false; - return ( - cleanupEvent.getText() === eventListener.eventText && - isSameExpressionIdentity(eventListener.handler, cleanupHandler, typeChecker) && - (cleanupCall.arguments[2]?.getText() ?? "") === eventListener.optionsText - ); - }); - -const hasConditionalAncestor = (node: ts.Node, owner: ts.FunctionLikeDeclaration): boolean => { - let currentNode = node; - while (currentNode !== owner) { - 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) - ) { - return true; - } - currentNode = parentNode; - } - return false; -}; +import { findSemanticUnit } from "./find-semantic-unit.js"; +import { getNodeLocation } from "./get-node-location.js"; +import { + ReactEffectResourceDisposalStatus, + 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 analyzeEffectCleanup = ( - functionNode: ts.FunctionLikeDeclaration, + 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 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 for lifecycle analysis", - ["effect setup", "opaque callback", "cleanup"], - ), - ); - continue; - } - const cleanupFunctions = collectEffectCleanupFunctions(effectCallback, context.typeChecker); - const cleanupCallExpressions = cleanupFunctions.flatMap((cleanupFunction) => - collectReachableCallExpressions(cleanupFunction, context.typeChecker), + 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 protocol of collectEffectResourceProtocols(functionNode, context)) { + const acquisitionLocation = getNodeLocation(protocol.acquisitionNode, context.rootDirectory); + const resource = resources.find((candidate) => + areProofLocationsEqual(candidate.location, acquisitionLocation), ); - const cleanupCalls = new Set(cleanupCallExpressions.map(getCanonicalCall)); - for (const acquisition of collectResourceAcquisitions(effectCallback, context.typeChecker)) { - if ( - acquisition.isConditionallyReached || - hasConditionalAncestor(acquisition.node, acquisition.ownerFunction) - ) { - unknownEvidence.push( - createEvidence( - acquisition.node, - context.rootDirectory, - `${acquisition.description} is path-dependent`, - ["effect setup branch", acquisition.description, "cleanup branch"], - ), - ); - continue; - } - const hasMatchingCleanup = acquisition.eventListener - ? hasMatchingEventListenerCleanup( - acquisition.eventListener, - cleanupCallExpressions, - context.typeChecker, - ) - : acquisition.cleanupNames.some((cleanupName) => cleanupCalls.has(cleanupName)); - if (!hasMatchingCleanup) { - violations.push( - createEvidence( - acquisition.node, - context.rootDirectory, - `${acquisition.description} has no cleanup with the same resource identity`, - [ - "effect setup", - acquisition.description, - acquisition.cleanupNames.join(" or ") || "unresolvable resource identity", - "effect cleanup", - ], - ), - ); - } + const evidence = createEvidence( + protocol.acquisitionNode, + context.rootDirectory, + protocol.disposalStatus === ReactEffectResourceDisposalStatus.Missing + ? `${protocol.kind} has no cleanup with the same resource identity` + : `${protocol.kind} is path-dependent or has no complete callback and disposal certificate`, + ["effect setup", protocol.kind, "deferred callback", "effect cleanup or replacement"], + ); + if (protocol.disposalStatus === ReactEffectResourceDisposalStatus.Missing) { + violations.push(evidence); + } else if (!resource?.complete) { + unknownEvidence.push(evidence); } } - if (violations.length > 0) { return createObligation( ReactProofClaim.EffectCleanup, ReactObligationStatus.Violated, - "An effect can retain a resource after cleanup or unmount", + "An Effect resource can remain active after cleanup or unmount", violations, ); } @@ -273,13 +74,13 @@ export const analyzeEffectCleanup = ( return createObligation( ReactProofClaim.EffectCleanup, ReactObligationStatus.Unknown, - "Effect resource symmetry could not be proved on every path", + "An Effect resource callback or disposal path could not be proved", unknownEvidence, ); } return createObligation( ReactProofClaim.EffectCleanup, ReactObligationStatus.Proved, - "Every modeled effect resource has symmetric cleanup", + "Every modeled Effect resource has a deferred callback and guaranteed disposal", ); }; diff --git a/packages/prover/src/analyze-react-unit.ts b/packages/prover/src/analyze-react-unit.ts index f3d2acefb4..ae6b79ce0e 100644 --- a/packages/prover/src/analyze-react-unit.ts +++ b/packages/prover/src/analyze-react-unit.ts @@ -114,7 +114,7 @@ export const analyzeReactUnit = ( analyzeComponentIdentity(unit.functionNode, context), analyzeComponentInvocation(unit.functionNode, context), analyzeContextTopology(unit, context), - analyzeEffectCleanup(unit.functionNode, context), + analyzeEffectCleanup(unit, context), analyzeEffectDependencies(unit.functionNode, context), analyzeEffectEventUsage(unit.functionNode, context), analyzeEffectStateUpdates(unit, context), diff --git a/packages/prover/src/build-react-semantic-graph.ts b/packages/prover/src/build-react-semantic-graph.ts index b7cae0db19..74e26351d0 100644 --- a/packages/prover/src/build-react-semantic-graph.ts +++ b/packages/prover/src/build-react-semantic-graph.ts @@ -11,23 +11,17 @@ import { collectDirectHookCalls } from "./collect-direct-hook-calls.js"; import { collectEffectEventBindings } from "./collect-effect-event-bindings.js"; import { collectEffectCleanupFunctions } from "./collect-effect-cleanup-functions.js"; import { collectEffectCalls } from "./collect-effect-calls.js"; -import { - collectEffectSchedulerProtocols, - getPlatformSchedulerKind, -} from "./collect-effect-scheduler-protocols.js"; +import { collectEffectSchedulerProtocols } from "./collect-effect-scheduler-protocols.js"; +import { collectEffectResourceProtocols } from "./collect-effect-resource-protocols.js"; import { collectHookBindings } from "./collect-hook-bindings.js"; import { collectHookCalls } from "./collect-hook-calls.js"; import { collectReactiveCaptures } from "./collect-reactive-captures.js"; -import { - collectReachableFunctionGraph, - collectReachableFunctions, -} from "./collect-reachable-functions.js"; +import { collectReachableFunctionGraph } from "./collect-reachable-functions.js"; import { REACT_EXTERNAL_STORE_HOOK_NAMES, REACT_MEMO_HOOK_NAMES, REACT_REDUCER_HOOK_NAMES, REACT_CONTEXT_DEFAULT_SOURCE_ID, - PROMISE_CONTINUATION_METHOD_NAMES, REACT_SEMANTIC_GRAPH_SCHEMA_VERSION, } from "./constants.js"; import { getCanonicalReactApiName } from "./get-canonical-react-api-name.js"; @@ -70,13 +64,13 @@ import type { ReactSemanticHookCall, ReactSemanticReachableFunction, ReactSemanticRender, + ReactSemanticEffectResource, ReactSemanticScheduler, ReactSemanticUnit, ReactUnitDescriptor, } from "./types.js"; import { areProofLocationsEqual } from "./utils/are-proof-locations-equal.js"; -import { collectReachableCallExpressions } from "./utils/collect-reachable-call-expressions.js"; -import { containsAwaitOutsideNestedFunction } from "./utils/contains-await-outside-nested-function.js"; +import { isDeferredCallbackSynchronous } from "./utils/is-deferred-callback-synchronous.js"; interface UnitGraphIdentity { descriptor: ReactUnitDescriptor; @@ -86,6 +80,7 @@ interface UnitGraphIdentity { interface EffectGraphFacts { effects: ReadonlyArray; schedulers: ReadonlyArray; + resources: ReadonlyArray; callbacks: ReadonlyArray; reachableFunctions: ReadonlyArray; functionCalls: ReadonlyArray; @@ -570,34 +565,6 @@ const createCallbackFact = ( stateWrites: collectCallbackStateWrites(callback, owner, context.typeChecker), }); -const containsThenableType = (type: ts.Type, typeChecker: ts.TypeChecker): boolean => - Boolean(typeChecker.getPropertyOfType(type, "then")) || - (type.isUnionOrIntersection() && - type.types.some((memberType) => containsThenableType(memberType, typeChecker))); - -const isScheduledCallbackSynchronous = ( - 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)), - ); - const createCallbackPropAlternative = ( callbackId: string, callbackDescriptor: ComponentCallbackDescriptor, @@ -688,6 +655,7 @@ const collectEffectGraph = ( return { effects: [], schedulers: [], + resources: [], callbacks: [], reachableFunctions: [], functionCalls: [], @@ -701,10 +669,13 @@ const collectEffectGraph = ( ]); 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); @@ -783,7 +754,7 @@ const collectEffectGraph = ( } const effectFact: ReactSemanticEffect = { id: createSemanticId("effect", hookName, effectCall, context), - ownerId: identity.semanticUnit.id, + ownerId: semanticOwnerId, hookName, location: getNodeLocation(effectCall, context.rootDirectory), callbackResolved: Boolean(effectCallback), @@ -860,12 +831,12 @@ const collectEffectGraph = ( callbackResolution?.isComplete && schedulerCallbacks.length > 0 && callbackResolution.callbacks.every((callbackDescriptor) => - isScheduledCallbackSynchronous(callbackDescriptor.callbackFunction, context), + isDeferredCallbackSynchronous(callbackDescriptor.callbackFunction, context), ), ); schedulers.push({ id: schedulerId, - ownerId: identity.semanticUnit.id, + ownerId: semanticOwnerId, effectId: effectFact.id, registrationCallbackId: effectFact.setupCallbackId ?? "", kind: protocol.kind, @@ -882,8 +853,139 @@ const collectEffectGraph = ( 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, callbacks, reachableFunctions, functionCalls }; + return { effects, schedulers, resources, callbacks, reachableFunctions, functionCalls }; }; const collectAsyncTaskGraph = ( @@ -1578,6 +1680,7 @@ export const buildReactSemanticGraph = ( const hookCalls: ReactSemanticHookCall[] = []; const effects: ReactSemanticEffect[] = []; const schedulers: ReactSemanticScheduler[] = []; + const resources: ReactSemanticEffectResource[] = []; const effectEvents: ReactSemanticEffectEvent[] = []; const externalStores: ReactSemanticExternalStore[] = []; const asyncTasks: ReactSemanticAsyncTask[] = []; @@ -1644,6 +1747,7 @@ export const buildReactSemanticGraph = ( ); effects.push(...effectGraph.effects); schedulers.push(...effectGraph.schedulers); + resources.push(...effectGraph.resources); callbacks.push(...effectGraph.callbacks); reachableFunctions.push(...effectGraph.reachableFunctions); functionCalls.push(...effectGraph.functionCalls); @@ -1705,6 +1809,7 @@ export const buildReactSemanticGraph = ( callbackPropFlows: callbackPropGraph.callbackPropFlows, callableRefs, schedulers, + resources, 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 index e4743db1a1..b7c7309a32 100644 --- a/packages/prover/src/check-react-proof-report.ts +++ b/packages/prover/src/check-react-proof-report.ts @@ -3,6 +3,8 @@ import { ReactAppProofStatus, ReactAsyncOwnershipStatus, ReactCallableRefFreshness, + ReactEffectResourceDisposalStatus, + ReactEffectResourceKind, ReactExecutionPhase, ReactObligationStatus, ReactProofCertificateStatus, @@ -94,6 +96,31 @@ const expectedScheduledCallbackLifetimeStatus = ( : ReactObligationStatus.Proved; }; +const expectedEffectCleanupStatus = ( + unit: ReactSemanticUnit, + report: ReactAppProofReport, +): ReactObligationStatus => { + if (unit.kind === ReactUnitKind.ClassComponent || 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 ( + 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[], @@ -152,6 +179,17 @@ const checkClaimCoverage = ( `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}`, + ); + } } }; @@ -472,6 +510,101 @@ const checkGraphReferences = ( 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"); + } + const effect = effectsById.get(resource.effectId); + if (!effect || effect.ownerId !== resource.ownerId) { + addFailure(failures, resource.id, "An Effect resource has an unknown or cross-owner Effect"); + } else if (effect.setupCallbackId !== resource.acquisitionCallbackId) { + addFailure( + failures, + resource.id, + "An Effect resource acquisition is not linked to its setup 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"); + } + } for (const reachableFunction of report.graph.reachableFunctions) { if (!unitIds.has(reachableFunction.ownerId)) { addFailure(failures, reachableFunction.id, "A reachable function has an unknown owner unit"); @@ -690,6 +823,9 @@ const checkGraphReferences = ( .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) @@ -783,6 +919,11 @@ export const checkReactProofReport = (report: ReactAppProofReport): ReactProofCe "schedulers", report.graph.schedulers.map((scheduler) => scheduler.id), ); + checkUniqueIds( + failures, + "Effect resources", + report.graph.resources.map((resource) => resource.id), + ); checkUniqueIds( failures, "effects", 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 0000000000..54316b58be --- /dev/null +++ b/packages/prover/src/collect-effect-resource-protocols.ts @@ -0,0 +1,508 @@ +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 EffectResourceProtocolDescriptor { + acquisitionNode: ts.Node; + acquisitionNodes: ReadonlyArray; + callbackExpression: ts.Expression | null; + disposalCalls: ReadonlyArray; + disposalStatus: ReactEffectResourceDisposalStatus; + effectCall: ts.CallExpression; + isSourceComplete: boolean; + kind: ReactEffectResourceKind; +} + +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 = ( + effectCallback: ts.FunctionLikeDeclaration, + isAcquisitionConditional: boolean, + isMatchingDisposal: (callExpression: ts.CallExpression) => boolean, + isDefinitelyMismatchedDisposal: (callExpression: ts.CallExpression) => boolean, + typeChecker: ts.TypeChecker, +): EffectResourceDisposal => { + const cleanupFunctions = collectEffectCleanupFunctions(effectCallback, typeChecker); + if (cleanupFunctions.length === 0 || !hasGuaranteedEffectCleanup(effectCallback, typeChecker)) { + 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 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 reachableFunctions = collectReachableFunctions(effectCallback, context.typeChecker); + for (const registrationCall of collectReachableCallExpressions( + effectCallback, + 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( + effectCallback, + isAcquisitionConditional, + (cleanupCall) => + isMatchingEventRemoval(cleanupCall, listener, context.typeChecker) || + isMatchingAbort(cleanupCall, listener, context.typeChecker), + (cleanupCall) => + isDefinitelyMismatchedEventRemoval(cleanupCall, listener, context.typeChecker), + context.typeChecker, + ); + const hasCompleteIdentity = listener.capture !== null; + protocols.push({ + acquisitionNode: registrationCall, + acquisitionNodes: [registrationCall], + callbackExpression: listener.handlerExpression, + disposalCalls: disposal.calls, + disposalStatus: disposal.status, + effectCall, + isSourceComplete: + hasCompleteIdentity && disposal.status === ReactEffectResourceDisposalStatus.Guaranteed, + kind: ReactEffectResourceKind.EventListener, + }); + } + for (const observer of collectObservers(effectCallback, 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( + effectCallback, + 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, + effectCall, + isSourceComplete: + Boolean(observer.callbackExpression) && + disposal.status === ReactEffectResourceDisposalStatus.Guaranteed, + kind: observer.kind, + }); + } + } + return protocols; +}; diff --git a/packages/prover/src/collect-effect-scheduler-protocols.ts b/packages/prover/src/collect-effect-scheduler-protocols.ts index 5284994765..c9ba496299 100644 --- a/packages/prover/src/collect-effect-scheduler-protocols.ts +++ b/packages/prover/src/collect-effect-scheduler-protocols.ts @@ -3,13 +3,16 @@ import { collectEffectCleanupFunctions } from "./collect-effect-cleanup-function import { collectEffectCalls } from "./collect-effect-calls.js"; import { collectReachableFunctions } from "./collect-reachable-functions.js"; import { getEffectCallback } from "./get-effect-callback.js"; -import { getRootIdentifier } from "./get-root-identifier.js"; import { ReactSchedulerCancellationStatus, ReactSchedulerKind } from "./types.js"; import type { ReactAnalysisContext } from "./types.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 EffectSchedulerProtocolDescriptor { @@ -72,26 +75,6 @@ const SCHEDULER_APIS = new Map([ ], ]); -const PLATFORM_GLOBAL_NAMES = new Set(["globalThis", "self", "window"]); - -const getResolvedSymbol = (node: ts.Node, typeChecker: ts.TypeChecker): ts.Symbol | null => { - const symbol = typeChecker.getSymbolAtLocation(node); - if (!symbol) return null; - return symbol.flags & ts.SymbolFlags.Alias ? typeChecker.getAliasedSymbol(symbol) : symbol; -}; - -const isPlatformDeclaration = (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/") - ); - }), - ); - const getPlatformExpressionName = ( expression: ts.Expression, typeChecker: ts.TypeChecker, @@ -104,7 +87,7 @@ const getPlatformExpressionName = ( const unwrappedExpression = unwrapTypescriptExpression(pendingExpression); if (ts.isIdentifier(unwrappedExpression)) { const symbol = getResolvedSymbol(unwrappedExpression, typeChecker); - if (isPlatformDeclaration(symbol)) return symbol?.getName() ?? null; + if (isPlatformDeclarationSymbol(symbol)) return symbol?.getName() ?? null; if (!symbol || visitedSymbols.has(symbol)) continue; visitedSymbols.add(symbol); for (const declaration of symbol.declarations ?? []) { @@ -120,12 +103,9 @@ const getPlatformExpressionName = ( } continue; } - if (!ts.isPropertyAccessExpression(unwrappedExpression)) continue; - const rootIdentifier = getRootIdentifier(unwrappedExpression.expression); if ( - rootIdentifier && - PLATFORM_GLOBAL_NAMES.has(rootIdentifier.text) && - isPlatformDeclaration(getResolvedSymbol(rootIdentifier, typeChecker)) + ts.isPropertyAccessExpression(unwrappedExpression) && + isPlatformDeclarationSymbol(getResolvedSymbol(unwrappedExpression.name, typeChecker)) ) { return unwrappedExpression.name.text; } @@ -167,57 +147,6 @@ const getImmutableHandle = (registrationCall: ts.CallExpression): ts.Identifier return declaration.name; }; -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; -}; - -const isEntryCancellation = ( - callExpression: ts.CallExpression, - cleanupFunction: ts.FunctionLikeDeclaration, -): boolean => { - if ( - getEnclosingFunction(callExpression) !== cleanupFunction || - hasConditionalAncestor(callExpression, cleanupFunction) - ) { - return false; - } - const functionBody = cleanupFunction.body; - if (!functionBody) return false; - if (!ts.isBlock(functionBody)) return functionBody === callExpression; - const firstStatement = functionBody.statements[0]; - return Boolean( - firstStatement && - ((ts.isExpressionStatement(firstStatement) && firstStatement.expression === callExpression) || - (ts.isReturnStatement(firstStatement) && firstStatement.expression === callExpression)), - ); -}; - const isMatchingCancellation = ( callExpression: ts.CallExpression, cancellationName: string, @@ -295,7 +224,7 @@ const collectCancellation = ( }; } const entryCancellation = cleanupMatchingCalls.find((cleanupCall) => - isEntryCancellation(cleanupCall, cleanupFunction), + isEntryDominatingNode(cleanupCall, cleanupFunction), ); if (!entryCancellation) { return { diff --git a/packages/prover/src/collect-reachable-functions.ts b/packages/prover/src/collect-reachable-functions.ts index fcc369585f..a4d9f8a7a7 100644 --- a/packages/prover/src/collect-reachable-functions.ts +++ b/packages/prover/src/collect-reachable-functions.ts @@ -15,6 +15,7 @@ import { } 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 { @@ -45,36 +46,6 @@ export interface UnmodeledCallableUseDescriptor { parameterIndex: number | null; } -const isConditionallyExecuted = ( - 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; -}; - const getParameterSymbol = ( functionNode: ts.FunctionLikeDeclaration, parameterIndex: number, @@ -355,7 +326,7 @@ export const collectReachableFunctionGraph = ( } if (ts.isCallExpression(node)) { const callIsConditional = - currentIsConditional || isConditionallyExecuted(node, currentFunction); + currentIsConditional || hasConditionalAncestor(node, currentFunction); const directTarget = resolveFunction(node.expression, typeChecker); if (directTarget) { const argumentBindings = resolveCallableArgumentBindings( diff --git a/packages/prover/src/constants.ts b/packages/prover/src/constants.ts index 5ea392ab4b..75980ade16 100644 --- a/packages/prover/src/constants.ts +++ b/packages/prover/src/constants.ts @@ -1,5 +1,7 @@ -export const REACT_PROOF_SCHEMA_VERSION = 10; -export const REACT_SEMANTIC_GRAPH_SCHEMA_VERSION = 16; +import { ReactEffectResourceKind } from "./types.js"; + +export const REACT_PROOF_SCHEMA_VERSION = 11; +export const REACT_SEMANTIC_GRAPH_SCHEMA_VERSION = 17; 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; @@ -14,6 +16,12 @@ export const REACT_EFFECT_HOOK_NAMES = new Set([ "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_REDUCER_HOOK_NAMES = new Set(["useReducer"]); export const REACT_EXTERNAL_STORE_HOOK_NAMES = new Set(["useSyncExternalStore"]); diff --git a/packages/prover/src/index.ts b/packages/prover/src/index.ts index b18811aa12..e382863768 100644 --- a/packages/prover/src/index.ts +++ b/packages/prover/src/index.ts @@ -6,6 +6,8 @@ export { ReactCallableRefFreshness, ReactCompilerFactStatus, ReactEffectDependencyMode, + ReactEffectResourceDisposalStatus, + ReactEffectResourceKind, ReactExecutionPhase, ReactIdentityStability, ReactObligationStatus, @@ -38,6 +40,7 @@ export type { ReactSemanticContextProvider, ReactSemanticEffect, ReactSemanticEffectEvent, + ReactSemanticEffectResource, ReactSemanticEventBinding, ReactSemanticCallbackGuard, ReactSemanticCallbackPropAlternative, diff --git a/packages/prover/src/prove-react-app.ts b/packages/prover/src/prove-react-app.ts index 8edda85d81..7b3fe14ed3 100644 --- a/packages/prover/src/prove-react-app.ts +++ b/packages/prover/src/prove-react-app.ts @@ -42,6 +42,7 @@ export const proveReactApp = (input: ProveReactAppInput): ReactAppProofReport => callbackPropFlows: [], callableRefs: [], schedulers: [], + resources: [], compiler: { version: REACT_COMPILER_VERSION, phase: REACT_COMPILER_FACT_PHASE, diff --git a/packages/prover/src/types.ts b/packages/prover/src/types.ts index 6559193680..cfad81403e 100644 --- a/packages/prover/src/types.ts +++ b/packages/prover/src/types.ts @@ -81,6 +81,7 @@ export enum ReactSemanticCallbackKind { MemoizedCallback = "memoized-callback", Reducer = "reducer", ReducerInitializer = "reducer-initializer", + ResourceCallback = "resource-callback", ScheduledCallback = "scheduled-callback", ServerSnapshot = "server-snapshot", } @@ -182,6 +183,20 @@ export enum ReactSchedulerCancellationStatus { 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", @@ -377,6 +392,23 @@ export interface ReactSemanticScheduler { complete: boolean; } +export interface ReactSemanticEffectResource { + id: string; + ownerId: string; + effectId: string; + acquisitionCallbackId: string; + kind: ReactEffectResourceKind; + phase: ReactExecutionPhase; + location: ReactProofLocation; + activationLocations: ReadonlyArray; + callbackIds: ReadonlyArray; + callbackComplete: boolean; + disposalStatus: ReactEffectResourceDisposalStatus; + disposalLocations: ReadonlyArray; + sourceComplete: boolean; + complete: boolean; +} + export interface ReactCompilerInstructionFact { id: string; valueKind: string; @@ -436,6 +468,7 @@ export interface ReactSemanticGraph { callbackPropFlows: ReadonlyArray; callableRefs: ReadonlyArray; schedulers: ReadonlyArray; + resources: ReadonlyArray; compiler: ReactCompilerGraph; } 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 0000000000..614da0d249 --- /dev/null +++ b/packages/prover/src/utils/are-immutable-expressions-identical.ts @@ -0,0 +1,92 @@ +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"; + +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 (!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/get-platform-effect-resource-kind.ts b/packages/prover/src/utils/get-platform-effect-resource-kind.ts new file mode 100644 index 0000000000..76b56311db --- /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 0000000000..9397e3d7df --- /dev/null +++ b/packages/prover/src/utils/get-resolved-symbol.ts @@ -0,0 +1,7 @@ +import ts from "typescript"; + +export const getResolvedSymbol = (node: ts.Node, typeChecker: ts.TypeChecker): ts.Symbol | null => { + const symbol = 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-property-name.ts b/packages/prover/src/utils/get-static-property-name.ts new file mode 100644 index 0000000000..cb1efc8d3b --- /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 0000000000..ac1c7375cf --- /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/is-deferred-callback-synchronous.ts b/packages/prover/src/utils/is-deferred-callback-synchronous.ts new file mode 100644 index 0000000000..09ca677934 --- /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-entry-dominating-node.ts b/packages/prover/src/utils/is-entry-dominating-node.ts new file mode 100644 index 0000000000..f98f09164f --- /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-platform-declaration-symbol.ts b/packages/prover/src/utils/is-platform-declaration-symbol.ts new file mode 100644 index 0000000000..3b46c87456 --- /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 0000000000..0bb14d8efd --- /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/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 0000000000..d0574445bb --- /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/conditional-helper-effect-cleanup/tsconfig.json b/packages/prover/tests/fixtures/abort-signal-listener-leak/tsconfig.json similarity index 100% rename from packages/prover/tests/fixtures/conditional-helper-effect-cleanup/tsconfig.json rename to packages/prover/tests/fixtures/abort-signal-listener-leak/tsconfig.json 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 0000000000..cdb588f0e0 --- /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 0000000000..d2a921ed01 --- /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-ambiguous-observer-kind/src/app.tsx b/packages/prover/tests/fixtures/incomplete-ambiguous-observer-kind/src/app.tsx new file mode 100644 index 0000000000..06ffd52959 --- /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 0000000000..d2a921ed01 --- /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-listener-callback/src/app.tsx b/packages/prover/tests/fixtures/incomplete-async-listener-callback/src/app.tsx new file mode 100644 index 0000000000..15eeb1264a --- /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 0000000000..d2a921ed01 --- /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-dynamic-listener-capture/src/app.tsx b/packages/prover/tests/fixtures/incomplete-dynamic-listener-capture/src/app.tsx new file mode 100644 index 0000000000..e140b07d6b --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-dynamic-listener-capture/src/app.tsx @@ -0,0 +1,16 @@ +import { useEffect } from "react"; + +const handleClick = () => undefined; + +interface CaptureListenerProperties { + capture: boolean; +} + +export const CaptureListener = ({ capture }: CaptureListenerProperties) => { + useEffect(() => { + window.addEventListener("click", handleClick, { capture }); + return () => window.removeEventListener("click", handleClick, { capture }); + }, [capture]); + + return null; +}; diff --git a/packages/prover/tests/fixtures/incomplete-dynamic-listener-capture/tsconfig.json b/packages/prover/tests/fixtures/incomplete-dynamic-listener-capture/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-dynamic-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-ref-event-target/src/app.tsx b/packages/prover/tests/fixtures/incomplete-ref-event-target/src/app.tsx new file mode 100644 index 0000000000..4e6fc56823 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-ref-event-target/src/app.tsx @@ -0,0 +1,15 @@ +import { useEffect, useRef } from "react"; + +const handleClick = () => undefined; + +export const RefListener = () => { + const elementRef = useRef(null); + useEffect(() => { + const element = elementRef.current; + if (!element) return; + element.addEventListener("click", handleClick); + return () => element.removeEventListener("click", handleClick); + }, []); + + return
    ; +}; diff --git a/packages/prover/tests/fixtures/incomplete-ref-event-target/tsconfig.json b/packages/prover/tests/fixtures/incomplete-ref-event-target/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-ref-event-target/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-render-resource-registration/src/app.tsx b/packages/prover/tests/fixtures/incomplete-render-resource-registration/src/app.tsx new file mode 100644 index 0000000000..cf8fed1794 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-render-resource-registration/src/app.tsx @@ -0,0 +1,6 @@ +const handleResize = () => undefined; + +export const ResizeReader = () => { + window.addEventListener("resize", handleResize); + return null; +}; diff --git a/packages/prover/tests/fixtures/incomplete-render-resource-registration/tsconfig.json b/packages/prover/tests/fixtures/incomplete-render-resource-registration/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-render-resource-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/incomplete-structural-event-target/src/app.tsx b/packages/prover/tests/fixtures/incomplete-structural-event-target/src/app.tsx new file mode 100644 index 0000000000..02474af1ab --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-structural-event-target/src/app.tsx @@ -0,0 +1,31 @@ +import { useEffect } from "react"; + +class FakeEventTarget implements EventTarget { + addEventListener( + _type: string, + _callback: EventListenerOrEventListenerObject | null, + _options?: AddEventListenerOptions | boolean, + ) {} + + dispatchEvent(_event: Event) { + return true; + } + + removeEventListener( + _type: string, + _callback: EventListenerOrEventListenerObject | null, + _options?: EventListenerOptions | boolean, + ) {} +} + +const target: EventTarget = new FakeEventTarget(); +const handleChange = () => undefined; + +export const StructuralListener = () => { + useEffect(() => { + target.addEventListener("change", handleChange); + return () => target.removeEventListener("change", handleChange); + }, []); + + return null; +}; diff --git a/packages/prover/tests/fixtures/incomplete-structural-event-target/tsconfig.json b/packages/prover/tests/fixtures/incomplete-structural-event-target/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-structural-event-target/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/listener-conditional-disposal/src/app.tsx b/packages/prover/tests/fixtures/listener-conditional-disposal/src/app.tsx new file mode 100644 index 0000000000..0a99328743 --- /dev/null +++ b/packages/prover/tests/fixtures/listener-conditional-disposal/src/app.tsx @@ -0,0 +1,18 @@ +import { useEffect } from "react"; + +const handleResize = () => undefined; + +interface ResizeListenerProperties { + shouldRemove: boolean; +} + +export const ResizeListener = ({ shouldRemove }: ResizeListenerProperties) => { + useEffect(() => { + window.addEventListener("resize", handleResize); + return () => { + if (shouldRemove) window.removeEventListener("resize", handleResize); + }; + }, [shouldRemove]); + + return null; +}; diff --git a/packages/prover/tests/fixtures/listener-conditional-disposal/tsconfig.json b/packages/prover/tests/fixtures/listener-conditional-disposal/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/listener-conditional-disposal/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/listener-partial-cleanup/src/app.tsx b/packages/prover/tests/fixtures/listener-partial-cleanup/src/app.tsx new file mode 100644 index 0000000000..8258baebd2 --- /dev/null +++ b/packages/prover/tests/fixtures/listener-partial-cleanup/src/app.tsx @@ -0,0 +1,19 @@ +import { useEffect } from "react"; + +const handleResize = () => undefined; + +interface ResizeListenerProperties { + shouldRemove: boolean; +} + +export const ResizeListener = ({ shouldRemove }: ResizeListenerProperties) => { + useEffect(() => { + window.addEventListener("resize", handleResize); + if (shouldRemove) { + return () => window.removeEventListener("resize", handleResize); + } + return () => undefined; + }, [shouldRemove]); + + return null; +}; diff --git a/packages/prover/tests/fixtures/listener-partial-cleanup/tsconfig.json b/packages/prover/tests/fixtures/listener-partial-cleanup/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/listener-partial-cleanup/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/mixed-opaque-effect-and-listener-leak/src/app.tsx b/packages/prover/tests/fixtures/mixed-opaque-effect-and-listener-leak/src/app.tsx new file mode 100644 index 0000000000..63e170bde8 --- /dev/null +++ b/packages/prover/tests/fixtures/mixed-opaque-effect-and-listener-leak/src/app.tsx @@ -0,0 +1,17 @@ +import { useEffect } from "react"; + +const handleResize = () => undefined; + +interface MixedEffectProperties { + usePrimaryCallback: boolean; +} + +export const MixedEffect = ({ usePrimaryCallback }: MixedEffectProperties) => { + const effectCallback = usePrimaryCallback ? () => undefined : () => undefined; + useEffect(effectCallback, [effectCallback]); + useEffect(() => { + window.addEventListener("resize", handleResize); + }, []); + + return null; +}; diff --git a/packages/prover/tests/fixtures/mixed-opaque-effect-and-listener-leak/tsconfig.json b/packages/prover/tests/fixtures/mixed-opaque-effect-and-listener-leak/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/mixed-opaque-effect-and-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/mutation-observer-leak/src/app.tsx b/packages/prover/tests/fixtures/mutation-observer-leak/src/app.tsx new file mode 100644 index 0000000000..2d0d0f451e --- /dev/null +++ b/packages/prover/tests/fixtures/mutation-observer-leak/src/app.tsx @@ -0,0 +1,12 @@ +import { useEffect } from "react"; + +const handleMutations = () => undefined; + +export const MutationTracker = () => { + useEffect(() => { + const observer = new MutationObserver(handleMutations); + observer.observe(document.body, { subtree: true }); + }, []); + + return null; +}; diff --git a/packages/prover/tests/fixtures/mutation-observer-leak/tsconfig.json b/packages/prover/tests/fixtures/mutation-observer-leak/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/mutation-observer-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/proved-abort-signal-listener/src/app.tsx b/packages/prover/tests/fixtures/proved-abort-signal-listener/src/app.tsx new file mode 100644 index 0000000000..d7d64e050e --- /dev/null +++ b/packages/prover/tests/fixtures/proved-abort-signal-listener/src/app.tsx @@ -0,0 +1,16 @@ +import { useEffect } from "react"; + +const handleScroll = () => undefined; + +export const ScrollTracker = () => { + useEffect(() => { + const controller = new AbortController(); + window.addEventListener("scroll", handleScroll, { + passive: true, + signal: controller.signal, + }); + return () => controller.abort(); + }, []); + + return null; +}; diff --git a/packages/prover/tests/fixtures/proved-abort-signal-listener/tsconfig.json b/packages/prover/tests/fixtures/proved-abort-signal-listener/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-abort-signal-listener/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-helper-effect-cleanup/src/app.tsx b/packages/prover/tests/fixtures/proved-conditional-helper-effect-cleanup/src/app.tsx similarity index 100% rename from packages/prover/tests/fixtures/conditional-helper-effect-cleanup/src/app.tsx rename to packages/prover/tests/fixtures/proved-conditional-helper-effect-cleanup/src/app.tsx diff --git a/packages/prover/tests/fixtures/proved-conditional-helper-effect-cleanup/tsconfig.json b/packages/prover/tests/fixtures/proved-conditional-helper-effect-cleanup/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-conditional-helper-effect-cleanup/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-intersection-observer/src/app.tsx b/packages/prover/tests/fixtures/proved-intersection-observer/src/app.tsx new file mode 100644 index 0000000000..28fa22043d --- /dev/null +++ b/packages/prover/tests/fixtures/proved-intersection-observer/src/app.tsx @@ -0,0 +1,13 @@ +import { useEffect } from "react"; + +const handleIntersection = () => undefined; + +export const IntersectionTracker = () => { + useEffect(() => { + const observer = new IntersectionObserver(handleIntersection); + observer.observe(document.body); + return () => observer.disconnect(); + }, []); + + return null; +}; diff --git a/packages/prover/tests/fixtures/proved-intersection-observer/tsconfig.json b/packages/prover/tests/fixtures/proved-intersection-observer/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-intersection-observer/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-listener-capture-semantics/src/app.tsx b/packages/prover/tests/fixtures/proved-listener-capture-semantics/src/app.tsx new file mode 100644 index 0000000000..7cdc129ba2 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-listener-capture-semantics/src/app.tsx @@ -0,0 +1,19 @@ +import { useEffect } from "react"; + +const handleVisibilityChange = () => undefined; + +export const VisibilityTracker = () => { + useEffect(() => { + document.addEventListener("visibilitychange", handleVisibilityChange, { + capture: false, + once: true, + passive: true, + }); + return () => + document.removeEventListener("visibilitychange", handleVisibilityChange, { + capture: false, + }); + }, []); + + return null; +}; diff --git a/packages/prover/tests/fixtures/proved-listener-capture-semantics/tsconfig.json b/packages/prover/tests/fixtures/proved-listener-capture-semantics/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-listener-capture-semantics/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-multi-target-mutation-observer/src/app.tsx b/packages/prover/tests/fixtures/proved-multi-target-mutation-observer/src/app.tsx new file mode 100644 index 0000000000..5793dd2817 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-multi-target-mutation-observer/src/app.tsx @@ -0,0 +1,14 @@ +import { useEffect } from "react"; + +const handleMutations = () => undefined; + +export const MutationTracker = () => { + useEffect(() => { + const observer = new MutationObserver(handleMutations); + observer.observe(document.body, { childList: true }); + observer.observe(document.documentElement, { attributes: true }); + return () => observer.disconnect(); + }, []); + + return null; +}; diff --git a/packages/prover/tests/fixtures/proved-multi-target-mutation-observer/tsconfig.json b/packages/prover/tests/fixtures/proved-multi-target-mutation-observer/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-multi-target-mutation-observer/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-mutation-observer/src/app.tsx b/packages/prover/tests/fixtures/proved-mutation-observer/src/app.tsx new file mode 100644 index 0000000000..8b8706a2bd --- /dev/null +++ b/packages/prover/tests/fixtures/proved-mutation-observer/src/app.tsx @@ -0,0 +1,13 @@ +import { useEffect } from "react"; + +const handleMutations = () => undefined; + +export const MutationTracker = () => { + useEffect(() => { + const observer = new MutationObserver(handleMutations); + observer.observe(document.body, { childList: true }); + return () => observer.disconnect(); + }, []); + + return null; +}; diff --git a/packages/prover/tests/fixtures/proved-mutation-observer/tsconfig.json b/packages/prover/tests/fixtures/proved-mutation-observer/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-mutation-observer/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-observer-constructor-only/src/app.tsx b/packages/prover/tests/fixtures/proved-observer-constructor-only/src/app.tsx new file mode 100644 index 0000000000..3872b09d4f --- /dev/null +++ b/packages/prover/tests/fixtures/proved-observer-constructor-only/src/app.tsx @@ -0,0 +1,11 @@ +import { useEffect } from "react"; + +const handleMutations = () => undefined; + +export const DormantObserver = () => { + useEffect(() => { + new MutationObserver(handleMutations); + }, []); + + return null; +}; diff --git a/packages/prover/tests/fixtures/proved-observer-constructor-only/tsconfig.json b/packages/prover/tests/fixtures/proved-observer-constructor-only/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-observer-constructor-only/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-resize-observer/src/app.tsx b/packages/prover/tests/fixtures/proved-resize-observer/src/app.tsx new file mode 100644 index 0000000000..b11f5b9f4a --- /dev/null +++ b/packages/prover/tests/fixtures/proved-resize-observer/src/app.tsx @@ -0,0 +1,13 @@ +import { useEffect } from "react"; + +const handleResize = () => undefined; + +export const ResizeTracker = () => { + useEffect(() => { + const observer = new ResizeObserver(handleResize); + observer.observe(document.body); + return () => observer.disconnect(); + }, []); + + return null; +}; diff --git a/packages/prover/tests/fixtures/proved-resize-observer/tsconfig.json b/packages/prover/tests/fixtures/proved-resize-observer/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-resize-observer/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/quoted-capture-cleanup-mismatch/src/app.tsx b/packages/prover/tests/fixtures/quoted-capture-cleanup-mismatch/src/app.tsx new file mode 100644 index 0000000000..f64ae160f7 --- /dev/null +++ b/packages/prover/tests/fixtures/quoted-capture-cleanup-mismatch/src/app.tsx @@ -0,0 +1,15 @@ +import { useEffect } from "react"; + +const handleResize = () => undefined; + +export const ResizeStatus = () => { + useEffect(() => { + window.addEventListener("resize", handleResize, { capture: true }); + return () => + window.removeEventListener("resize", handleResize, { + ["capture"]: false, + }); + }, []); + + return null; +}; diff --git a/packages/prover/tests/fixtures/quoted-capture-cleanup-mismatch/tsconfig.json b/packages/prover/tests/fixtures/quoted-capture-cleanup-mismatch/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/quoted-capture-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/shadowed-event-target/src/app.tsx b/packages/prover/tests/fixtures/shadowed-event-target/src/app.tsx new file mode 100644 index 0000000000..cf31fc116c --- /dev/null +++ b/packages/prover/tests/fixtures/shadowed-event-target/src/app.tsx @@ -0,0 +1,19 @@ +import { useEffect } from "react"; + +class EventRegistry { + addEventListener(_eventName: string, _callback: () => void) {} + + removeEventListener(_eventName: string, _callback: () => void) {} +} + +const registry = new EventRegistry(); +const handleChange = () => undefined; + +export const RegistryConsumer = () => { + useEffect(() => { + registry.addEventListener("change", handleChange); + return () => registry.removeEventListener("change", handleChange); + }, []); + + return null; +}; diff --git a/packages/prover/tests/fixtures/shadowed-event-target/tsconfig.json b/packages/prover/tests/fixtures/shadowed-event-target/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/shadowed-event-target/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/prove-react-app.test.ts b/packages/prover/tests/prove-react-app.test.ts index af39bbbc10..f459d67cd2 100644 --- a/packages/prover/tests/prove-react-app.test.ts +++ b/packages/prover/tests/prove-react-app.test.ts @@ -9,6 +9,8 @@ import { ReactCallableRefFreshness, ReactCompilerFactStatus, ReactEffectDependencyMode, + ReactEffectResourceDisposalStatus, + ReactEffectResourceKind, ReactExecutionPhase, ReactIdentityStability, ReactObligationStatus, @@ -65,6 +67,36 @@ const REFUTED_FIXTURES: ReadonlyArray = [ claim: ReactProofClaim.EffectCleanup, evidencePattern: /same resource identity/, }, + { + fixtureName: "quoted-capture-cleanup-mismatch", + claim: ReactProofClaim.EffectCleanup, + evidencePattern: /same resource identity/, + }, + { + fixtureName: "mutation-observer-leak", + claim: ReactProofClaim.EffectCleanup, + evidencePattern: /same resource identity/, + }, + { + fixtureName: "abort-signal-listener-leak", + claim: ReactProofClaim.EffectCleanup, + evidencePattern: /same resource identity/, + }, + { + fixtureName: "listener-partial-cleanup", + claim: ReactProofClaim.EffectCleanup, + evidencePattern: /same resource identity/, + }, + { + fixtureName: "listener-conditional-disposal", + claim: ReactProofClaim.EffectCleanup, + evidencePattern: /same resource identity/, + }, + { + fixtureName: "mixed-opaque-effect-and-listener-leak", + claim: ReactProofClaim.EffectCleanup, + evidencePattern: /same resource identity/, + }, { fixtureName: "nested-component", claim: ReactProofClaim.ComponentIdentity, @@ -334,6 +366,14 @@ describe("proveReactApp", () => { "proved-external-store", "proved-effect-event", "proved-helper-effect-cleanup", + "proved-conditional-helper-effect-cleanup", + "proved-listener-capture-semantics", + "proved-mutation-observer", + "proved-observer-constructor-only", + "proved-abort-signal-listener", + "proved-resize-observer", + "proved-intersection-observer", + "proved-multi-target-mutation-observer", "proved-shared-event-handler", "proved-event-callback-parameter", "proved-event-prop-flow", @@ -414,7 +454,7 @@ describe("proveReactApp", () => { const report = proveFixture("proved-chat"); const effect = report.graph.effects[0]; - expect(report.graph.schemaVersion).toBe(16); + expect(report.graph.schemaVersion).toBe(17); expect(effect?.hookName).toBe("useEffect"); expect(effect?.callbackResolved).toBe(true); expect(effect?.dependencyMode).toBe(ReactEffectDependencyMode.Inline); @@ -430,6 +470,99 @@ describe("proveReactApp", () => { ).toBe(ReactExecutionPhase.EffectCleanup); }); + it("certifies DOM listener identity using callback, event type, and capture", () => { + const report = proveFixture("proved-listener-capture-semantics"); + const resource = report.graph.resources[0]; + const callback = report.graph.callbacks.find( + (candidate) => candidate.id === resource?.callbackIds[0], + ); + + expect(resource?.kind).toBe(ReactEffectResourceKind.EventListener); + expect(resource?.disposalStatus).toBe(ReactEffectResourceDisposalStatus.Guaranteed); + expect(resource?.disposalLocations).toHaveLength(1); + expect(resource?.callbackComplete).toBe(true); + expect(resource?.complete).toBe(true); + expect(callback?.kind).toBe(ReactSemanticCallbackKind.ResourceCallback); + expect(callback?.phase).toBe(ReactExecutionPhase.Deferred); + }); + + it("certifies AbortSignal listener disposal through the exact controller", () => { + const report = proveFixture("proved-abort-signal-listener"); + const resource = report.graph.resources[0]; + + expect(resource?.kind).toBe(ReactEffectResourceKind.EventListener); + expect(resource?.disposalStatus).toBe(ReactEffectResourceDisposalStatus.Guaranteed); + expect(resource?.disposalLocations).toHaveLength(1); + expect(resource?.complete).toBe(true); + }); + + it("certifies an activated observer and ignores an unactivated constructor", () => { + const activeReport = proveFixture("proved-mutation-observer"); + const resizeReport = proveFixture("proved-resize-observer"); + const intersectionReport = proveFixture("proved-intersection-observer"); + const multiTargetReport = proveFixture("proved-multi-target-mutation-observer"); + const dormantReport = proveFixture("proved-observer-constructor-only"); + const resource = activeReport.graph.resources[0]; + + expect(resource?.kind).toBe(ReactEffectResourceKind.MutationObserver); + expect(resource?.disposalStatus).toBe(ReactEffectResourceDisposalStatus.Guaranteed); + expect(resource?.complete).toBe(true); + expect(resizeReport.graph.resources[0]?.kind).toBe(ReactEffectResourceKind.ResizeObserver); + expect(intersectionReport.graph.resources[0]?.kind).toBe( + ReactEffectResourceKind.IntersectionObserver, + ); + expect(multiTargetReport.graph.resources).toHaveLength(1); + expect(multiTargetReport.graph.resources[0]?.activationLocations).toHaveLength(2); + expect(dormantReport.graph.resources).toHaveLength(0); + }); + + it("fails closed for dynamic listener capture and async resource callbacks", () => { + const dynamicCaptureReport = proveFixture("incomplete-dynamic-listener-capture"); + const accessorCaptureReport = proveFixture("incomplete-accessor-listener-capture"); + const asyncCallbackReport = proveFixture("incomplete-async-listener-callback"); + const dynamicResource = dynamicCaptureReport.graph.resources[0]; + const accessorResource = accessorCaptureReport.graph.resources[0]; + const asyncResource = asyncCallbackReport.graph.resources[0]; + + expect(dynamicResource?.disposalStatus).toBe(ReactEffectResourceDisposalStatus.Unknown); + expect(dynamicResource?.complete).toBe(false); + expect(accessorResource?.disposalStatus).toBe(ReactEffectResourceDisposalStatus.Unknown); + expect(accessorResource?.complete).toBe(false); + expect(asyncResource?.disposalStatus).toBe(ReactEffectResourceDisposalStatus.Guaranteed); + expect(asyncResource?.callbackComplete).toBe(false); + expect(asyncResource?.complete).toBe(false); + }); + + it("rejects user-defined EventTarget lookalikes as platform resource evidence", () => { + const report = proveFixture("shadowed-event-target"); + const structuralReport = proveFixture("incomplete-structural-event-target"); + const refReport = proveFixture("incomplete-ref-event-target"); + const cleanupProof = report.units + .flatMap((unit) => unit.obligations) + .find((obligation) => obligation.claim === ReactProofClaim.EffectCleanup); + const structuralBoundaryProof = structuralReport.units + .flatMap((unit) => unit.obligations) + .find((obligation) => obligation.claim === ReactProofClaim.BoundaryCoverage); + + expect(report.graph.resources).toHaveLength(0); + expect(cleanupProof?.status).toBe(ReactObligationStatus.Proved); + expect(structuralReport.graph.resources).toHaveLength(0); + expect(structuralBoundaryProof?.status).toBe(ReactObligationStatus.Unknown); + expect(refReport.graph.resources).toHaveLength(0); + }); + + it("fails closed on a platform resource registration outside an Effect", () => { + const report = proveFixture("incomplete-render-resource-registration"); + const boundaryProof = report.units + .flatMap((unit) => unit.obligations) + .find((obligation) => obligation.claim === ReactProofClaim.BoundaryCoverage); + + expect(report.graph.resources).toHaveLength(0); + expect(report.status).toBe(ReactAppProofStatus.Incomplete); + expect(boundaryProof?.status).toBe(ReactObligationStatus.Unknown); + expect(boundaryProof?.evidence[0]?.description).toMatch(/unproved callback or disposal/); + }); + it("certifies an interval callback in the deferred phase with guaranteed cancellation", () => { const report = proveFixture("proved-timer"); const scheduler = report.graph.schedulers[0]; @@ -550,6 +683,59 @@ describe("proveReactApp", () => { ).toBe(true); }); + it("rejects an Effect resource certificate with contradictory disposal facts", () => { + const report = proveFixture("proved-mutation-observer"); + const certificate = checkReactProofReport({ + ...report, + graph: { + ...report.graph, + resources: report.graph.resources.map((resource) => ({ + ...resource, + disposalStatus: ReactEffectResourceDisposalStatus.Missing, + })), + }, + }); + + expect(certificate.status).toBe(ReactProofCertificateStatus.Invalid); + expect( + certificate.failures.some( + (failure) => + failure.description.includes("lifetime certificate") || + failure.description.includes("Effect resource facts require"), + ), + ).toBe(true); + }); + + it("rejects an Effect resource callback without a certified owner channel", () => { + const report = proveFixture("proved-mutation-observer"); + const resourceCallbackIds = new Set( + report.graph.resources.flatMap((resource) => resource.callbackIds), + ); + const certificate = checkReactProofReport({ + ...report, + graph: { + ...report.graph, + callbacks: report.graph.callbacks.map((callback) => + resourceCallbackIds.has(callback.id) + ? { + ...callback, + ownerId: "unknown-resource-owner", + } + : callback, + ), + }, + }); + + expect(certificate.status).toBe(ReactProofCertificateStatus.Invalid); + expect( + certificate.failures.some( + (failure) => + failure.description.includes("no certified owner channel") || + failure.description.includes("unknown owner unit"), + ), + ).toBe(true); + }); + it("assigns memo, event, and reducer callbacks to execution phases", () => { const memoReport = proveFixture("proved-memo"); const reducerReport = proveFixture("proved-reducer"); @@ -977,8 +1163,8 @@ describe("proveReactApp", () => { ).toBe(true); }); - it("records conditional reachability through an Effect helper call", () => { - const report = proveFixture("conditional-helper-effect-cleanup"); + it("proves unconditional disposal for a conditional Effect acquisition", () => { + const report = proveFixture("proved-conditional-helper-effect-cleanup"); const setupHelper = report.graph.reachableFunctions.find( (reachableFunction) => reachableFunction.name === "installResizeListener", ); @@ -986,9 +1172,9 @@ describe("proveReactApp", () => { .flatMap((unit) => unit.obligations) .find((obligation) => obligation.claim === ReactProofClaim.EffectCleanup); - expect(report.status).toBe(ReactAppProofStatus.Incomplete); + expect(report.status).toBe(ReactAppProofStatus.Proved); expect(setupHelper?.isConditionallyReached).toBe(true); - expect(cleanupProof?.status).toBe(ReactObligationStatus.Unknown); + expect(cleanupProof?.status).toBe(ReactObligationStatus.Proved); }); it("keeps the strongest reachability fact when a helper has multiple paths", () => { @@ -1261,7 +1447,13 @@ describe("proveReactApp", () => { "async-effect-post-await-mutation", "async-effect-path-dependent-invalidation", "helper-effect-state-update", - "conditional-helper-effect-cleanup", + "incomplete-dynamic-listener-capture", + "incomplete-accessor-listener-capture", + "incomplete-async-listener-callback", + "incomplete-render-resource-registration", + "incomplete-structural-event-target", + "incomplete-ref-event-target", + "incomplete-ambiguous-observer-kind", "invalid-hook-helper", "class-component", "named-memo-impure-helper", diff --git a/packages/prover/tests/runtime/constants.ts b/packages/prover/tests/runtime/constants.ts index 1f358e0a87..85c706323f 100644 --- a/packages/prover/tests/runtime/constants.ts +++ b/packages/prover/tests/runtime/constants.ts @@ -2,6 +2,7 @@ export const FAST_QUERY_DELAY_MS = 20; export const INITIAL_CALLBACK_REVISION = 0; export const LATE_QUERY_SETTLE_WAIT_MS = 250; export const NEXT_CALLBACK_REVISION = 1; +export const OBSERVER_DELIVERY_WAIT_MS = 50; export const PRIMARY_STORE_INITIAL_VERSION = 0; export const SECONDARY_STORE_INITIAL_VERSION = 100; export const SCHEDULER_CALLBACK_DELAY_MS = 80; diff --git a/packages/prover/tests/runtime/main.tsx b/packages/prover/tests/runtime/main.tsx index 702caa9445..2998c7368a 100644 --- a/packages/prover/tests/runtime/main.tsx +++ b/packages/prover/tests/runtime/main.tsx @@ -28,12 +28,14 @@ declare global { interface Window { effectEventSetupRuns: number; listenerHits: number; + observerHits: number; schedulerHits: number; } } window.effectEventSetupRuns = 0; window.listenerHits = 0; +window.observerHits = 0; window.schedulerHits = 0; const LeakyListener = () => { @@ -107,6 +109,35 @@ const SchedulerLifetimeOracle = () => { ); }; +interface ObserverProbeProperties { + shouldDisconnect: boolean; +} + +const ObserverProbe = ({ shouldDisconnect }: ObserverProbeProperties) => { + useEffect(() => { + const observer = new MutationObserver(() => { + window.observerHits += 1; + }); + observer.observe(document.body, { childList: true }); + if (shouldDisconnect) return () => observer.disconnect(); + return undefined; + }, [shouldDisconnect]); + return null; +}; + +const ObserverLifetimeOracle = () => { + const [isMounted, setIsMounted] = useState(true); + const shouldDisconnect = new URLSearchParams(window.location.search).get("mode") === "disconnect"; + return ( +
    + + {isMounted ? : null} +
    + ); +}; + interface KeyedItem { id: string; label: string; @@ -563,6 +594,9 @@ const RuntimeOracle = () => { if (oracle === "scheduler-lifetime") { return ; } + if (oracle === "observer-lifetime") { + return ; + } return ; }; diff --git a/packages/prover/tests/runtime/observer-lifetime-oracle.spec.ts b/packages/prover/tests/runtime/observer-lifetime-oracle.spec.ts new file mode 100644 index 0000000000..5c1e8e0fce --- /dev/null +++ b/packages/prover/tests/runtime/observer-lifetime-oracle.spec.ts @@ -0,0 +1,24 @@ +import { expect, test } from "@playwright/test"; +import { OBSERVER_DELIVERY_WAIT_MS } from "./constants.js"; + +const mutateObservedBody = async (page: import("@playwright/test").Page) => { + await page.evaluate(() => { + window.observerHits = 0; + document.body.append(document.createElement("aside")); + }); + await page.waitForTimeout(OBSERVER_DELIVERY_WAIT_MS); +}; + +test("disconnect prevents observer delivery after unmount", async ({ page }) => { + await page.goto("/?oracle=observer-lifetime&mode=disconnect"); + await page.getByRole("button", { name: "unmount observer" }).click(); + await mutateObservedBody(page); + expect(await page.evaluate(() => window.observerHits)).toBe(0); +}); + +test("missing disconnect permits observer delivery after unmount", async ({ page }) => { + await page.goto("/?oracle=observer-lifetime&mode=leak"); + await page.getByRole("button", { name: "unmount observer" }).click(); + await mutateObservedBody(page); + await expect.poll(() => page.evaluate(() => window.observerHits)).toBe(1); +}); From 5dd94eb04bd3547e3459e551a55c44c0316177e4 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Tue, 28 Jul 2026 15:04:06 +0000 Subject: [PATCH 06/23] feat(prover): certify class component lifecycles --- packages/prover/README.md | 30 +- packages/prover/research-log.md | 63 +++- .../prover/src/analyze-boundary-coverage.ts | 27 +- packages/prover/src/analyze-effect-cleanup.ts | 52 +-- packages/prover/src/analyze-react-unit.ts | 16 +- .../analyze-scheduled-callback-lifetime.ts | 50 +-- .../prover/src/build-react-semantic-graph.ts | 310 +++++++++++++++++- .../prover/src/check-react-proof-report.ts | 195 +++++++++-- .../src/collect-effect-resource-protocols.ts | 196 ++++++----- .../src/collect-effect-scheduler-protocols.ts | 199 +++++++---- packages/prover/src/collect-react-units.ts | 76 ++++- packages/prover/src/constants.ts | 4 +- packages/prover/src/index.ts | 1 + packages/prover/src/prove-react-app.ts | 1 + packages/prover/src/types.ts | 24 +- .../are-immutable-expressions-identical.ts | 34 ++ .../utils/collect-property-symbol-writes.ts | 59 ++++ .../src/utils/get-class-method-declaration.ts | 21 ++ .../src/app.tsx | 17 + .../tsconfig.json | 4 + .../fixtures/class-listener-leak/src/app.tsx | 13 + .../class-listener-leak/tsconfig.json | 4 + .../class-render-impurity/src/app.tsx | 7 + .../class-render-impurity/tsconfig.json | 4 + .../fixtures/class-timeout-leak/src/app.tsx | 15 + .../fixtures/class-timeout-leak/tsconfig.json | 4 + .../incomplete-class-field/src/app.tsx | 9 + .../incomplete-class-field/tsconfig.json | 4 + .../src/app.tsx | 25 ++ .../tsconfig.json | 4 + .../incomplete-class-lifecycle/src/app.tsx | 9 + .../incomplete-class-lifecycle/tsconfig.json | 4 + .../src/app.tsx | 18 + .../tsconfig.json | 4 + .../src/app.tsx | 20 ++ .../tsconfig.json | 4 + .../proved-class-listener/src/app.tsx | 17 + .../proved-class-listener/tsconfig.json | 4 + .../fixtures/proved-class-timeout/src/app.tsx | 19 ++ .../proved-class-timeout/tsconfig.json | 4 + .../proved-pure-class-render/src/app.tsx | 11 + .../proved-pure-class-render/tsconfig.json | 4 + .../prover/tests/fixtures/react-shim.d.ts | 1 + .../shadowed-component-class/src/app.tsx | 7 + .../shadowed-component-class/tsconfig.json | 4 + packages/prover/tests/prove-react-app.test.ts | 189 ++++++++++- .../class-listener-lifecycle-oracle.spec.ts | 23 ++ .../class-scheduler-lifecycle-oracle.spec.ts | 22 ++ packages/prover/tests/runtime/main.tsx | 141 +++++++- 49 files changed, 1724 insertions(+), 249 deletions(-) create mode 100644 packages/prover/src/utils/collect-property-symbol-writes.ts create mode 100644 packages/prover/src/utils/get-class-method-declaration.ts create mode 100644 packages/prover/tests/fixtures/class-listener-capture-mismatch/src/app.tsx create mode 100644 packages/prover/tests/fixtures/class-listener-capture-mismatch/tsconfig.json create mode 100644 packages/prover/tests/fixtures/class-listener-leak/src/app.tsx create mode 100644 packages/prover/tests/fixtures/class-listener-leak/tsconfig.json create mode 100644 packages/prover/tests/fixtures/class-render-impurity/src/app.tsx create mode 100644 packages/prover/tests/fixtures/class-render-impurity/tsconfig.json create mode 100644 packages/prover/tests/fixtures/class-timeout-leak/src/app.tsx create mode 100644 packages/prover/tests/fixtures/class-timeout-leak/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-class-field/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-class-field/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-class-helper-lifecycle/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-class-helper-lifecycle/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-class-lifecycle/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-class-lifecycle/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-class-listener-method-reassigned/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-class-listener-method-reassigned/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-class-timeout-reassigned/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-class-timeout-reassigned/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-class-listener/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-class-listener/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-class-timeout/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-class-timeout/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-pure-class-render/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-pure-class-render/tsconfig.json create mode 100644 packages/prover/tests/fixtures/shadowed-component-class/src/app.tsx create mode 100644 packages/prover/tests/fixtures/shadowed-component-class/tsconfig.json create mode 100644 packages/prover/tests/runtime/class-listener-lifecycle-oracle.spec.ts create mode 100644 packages/prover/tests/runtime/class-scheduler-lifecycle-oracle.spec.ts diff --git a/packages/prover/README.md b/packages/prover/README.md index 267f6e2fe1..7ec65f9865 100644 --- a/packages/prover/README.md +++ b/packages/prover/README.md @@ -45,13 +45,16 @@ The report includes: layout-synchronized, non-escaping refs used only by modeled events can be proved, while passive, multiply written, escaping, and unresolved protocols fail closed; - scheduler lifetime facts that tie a platform timer, animation frame, idle callback, immediate, - or microtask registration to its owning Effect, deferred callback set, exact handle, and cleanup - cancellation paths; only source-resolved synchronous callbacks with entry-dominating cleanup - cancellation are complete; -- Effect resource lifetime facts for platform event listeners and activated mutation, resize, and - intersection observers; listener disposal follows the DOM's type/callback/capture identity rule - or an exact `AbortController`, observers record every `observe()` activation, and every returned - cleanup alternative must reach exact-object disposal; + 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 lifecycle facts that certify symbol-resolved `Component` and `PureComponent` inheritance, + pure render callbacks, direct `componentDidMount`/`componentWillUnmount` ownership transitions, + exact stable method identities, and immutable primitive scheduler-handle fields; - 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. @@ -69,11 +72,14 @@ proof certificate today. Callback-prop channels are also checked for known owner 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 callback, deferred callback facts, exact cancellation evidence, and internally -consistent completeness. Source-derived block invariants and broader lifecycle transition -certificates remain future work. Resource certificates additionally require a real Effect setup, -platform-declaration identity, deferred or Effect Event callback facts, nonempty activation and -disposal evidence, and a completeness flag derived exactly from those facts. +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 and unmount callbacks, reciprocal resource and scheduler links, and a +completeness flag derived exactly from every owned lifetime fact. Source-derived block invariants +and broader lifecycle transition certificates remain future work. 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. ## Verification diff --git a/packages/prover/research-log.md b/packages/prover/research-log.md index 98bc9d7f0e..7b5b51bc2b 100644 --- a/packages/prover/research-log.md +++ b/packages/prover/research-log.md @@ -554,6 +554,10 @@ Proved: - `proved-animation-frame` - `proved-aliased-window-timeout` - `proved-shadowed-timeout` +- `class-component` +- `proved-pure-class-render` +- `proved-class-listener` +- `proved-class-timeout` Refuted: @@ -614,6 +618,10 @@ Refuted: - `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: @@ -621,7 +629,6 @@ Incomplete: - `effect-state-update` - `unsafe-types` - `path-dependent-cleanup` -- `class-component` - `conditional-use` - `index-list-key` - `datepicker-loop-index-key` @@ -709,7 +716,8 @@ Known regions that must force `incomplete` until modeled: - 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 component lifecycle methods +- Class constructors, derived state, update lifecycles, snapshots, error boundaries, refs, and + state-transition fixpoints outside direct render and mount/unmount ownership - Effect transition fixpoints beyond mount-bounded writes and unconditional boolean/fresh-reference self-cycles - Library hooks without semantic summaries @@ -736,7 +744,7 @@ components and hooks, including hooks hidden in incorrectly named helper functio ### Test stack -Current checkpoint: 205 TypeScript fixture projects, 382 static tests, and 26 Chromium runtime +Current checkpoint: 210 TypeScript fixture projects, 399 static tests, and 30 Chromium runtime oracles. - Vite Plus supplies package build and Vitest-compatible static tests. @@ -766,6 +774,9 @@ oracles. 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. ## Effect resource lifetime certificates @@ -842,3 +853,49 @@ Added corpus: `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` 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`, and + `proved-class-timeout` +- refuted: `class-render-impurity`, `class-listener-leak`, + `class-listener-capture-mismatch`, and `class-timeout-leak` +- incomplete: `incomplete-class-field`, `incomplete-class-lifecycle`, + `incomplete-class-helper-lifecycle`, `incomplete-class-listener-method-reassigned`, and + `incomplete-class-timeout-reassigned` +- declaration guard: `shadowed-component-class` diff --git a/packages/prover/src/analyze-boundary-coverage.ts b/packages/prover/src/analyze-boundary-coverage.ts index d27c2b56f1..02f93aa951 100644 --- a/packages/prover/src/analyze-boundary-coverage.ts +++ b/packages/prover/src/analyze-boundary-coverage.ts @@ -208,12 +208,17 @@ export const analyzeBoundaryCoverage = ( } } - const executionRoots = new Set([functionNode]); + 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); }; - functionNode.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); @@ -460,7 +465,23 @@ export const analyzeBoundaryCoverage = ( } node.forEachChild(visit); }; - functionNode.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( diff --git a/packages/prover/src/analyze-effect-cleanup.ts b/packages/prover/src/analyze-effect-cleanup.ts index b7491b3210..89741b8bc9 100644 --- a/packages/prover/src/analyze-effect-cleanup.ts +++ b/packages/prover/src/analyze-effect-cleanup.ts @@ -1,14 +1,10 @@ -import { collectEffectResourceProtocols } from "./collect-effect-resource-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 { ReactEffectResourceDisposalStatus, ReactObligationStatus, ReactProofClaim, } from "./types.js"; -import { areProofLocationsEqual } from "./utils/are-proof-locations-equal.js"; import type { ReactAnalysisContext, ReactProofEvidence, @@ -43,30 +39,42 @@ export const analyzeEffectCleanup = ( trace: ["effect setup", "opaque callback", "effect cleanup"], }); } - for (const protocol of collectEffectResourceProtocols(functionNode, context)) { - const acquisitionLocation = getNodeLocation(protocol.acquisitionNode, context.rootDirectory); - const resource = resources.find((candidate) => - areProofLocationsEqual(candidate.location, acquisitionLocation), - ); - const evidence = createEvidence( - protocol.acquisitionNode, - context.rootDirectory, - protocol.disposalStatus === ReactEffectResourceDisposalStatus.Missing - ? `${protocol.kind} has no cleanup with the same resource identity` - : `${protocol.kind} is path-dependent or has no complete callback and disposal certificate`, - ["effect setup", protocol.kind, "deferred callback", "effect cleanup or replacement"], - ); - if (protocol.disposalStatus === ReactEffectResourceDisposalStatus.Missing) { + 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) { + } 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, - "An Effect resource can remain active after cleanup or unmount", + "A lifecycle resource can remain active after cleanup or unmount", violations, ); } @@ -74,13 +82,13 @@ export const analyzeEffectCleanup = ( return createObligation( ReactProofClaim.EffectCleanup, ReactObligationStatus.Unknown, - "An Effect resource callback or disposal path could not be proved", + "A lifecycle resource callback or disposal path could not be proved", unknownEvidence, ); } return createObligation( ReactProofClaim.EffectCleanup, ReactObligationStatus.Proved, - "Every modeled Effect resource has a deferred callback and guaranteed disposal", + "Every modeled lifecycle resource has a deferred callback and guaranteed disposal", ); }; diff --git a/packages/prover/src/analyze-react-unit.ts b/packages/prover/src/analyze-react-unit.ts index ae6b79ce0e..31b24a2415 100644 --- a/packages/prover/src/analyze-react-unit.ts +++ b/packages/prover/src/analyze-react-unit.ts @@ -79,13 +79,19 @@ export const analyzeReactUnit = ( ), }; } - if (unit.kind === ReactUnitKind.ClassComponent || !unit.functionNode) { + if (!unit.functionNode || !unit.sourceComplete) { const evidence = [ createEvidence( unit.node, context.rootDirectory, - "Class component lifecycle semantics are not modeled yet", - ["class component", "React lifecycle", "unsupported proof model"], + 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 { @@ -96,7 +102,9 @@ export const analyzeReactUnit = ( createObligation( claim, ReactObligationStatus.Unknown, - "Class component proof is incomplete", + unit.kind === ReactUnitKind.ClassComponent + ? "Class component execution coverage is incomplete" + : "React unit proof is incomplete", evidence, ), ), diff --git a/packages/prover/src/analyze-scheduled-callback-lifetime.ts b/packages/prover/src/analyze-scheduled-callback-lifetime.ts index f1afe0893c..8813e127f0 100644 --- a/packages/prover/src/analyze-scheduled-callback-lifetime.ts +++ b/packages/prover/src/analyze-scheduled-callback-lifetime.ts @@ -1,14 +1,10 @@ -import { collectEffectSchedulerProtocols } from "./collect-effect-scheduler-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 { ReactObligationStatus, ReactProofClaim, ReactSchedulerCancellationStatus, } from "./types.js"; -import { areProofLocationsEqual } from "./utils/are-proof-locations-equal.js"; import type { ReactAnalysisContext, ReactProofEvidence, @@ -34,30 +30,42 @@ export const analyzeScheduledCallbackLifetime = ( ); const violations: ReactProofEvidence[] = []; const unknownEvidence: ReactProofEvidence[] = []; - for (const protocol of collectEffectSchedulerProtocols(functionNode, context)) { - const registrationLocation = getNodeLocation(protocol.registrationCall, context.rootDirectory); - const schedulerFact = schedulerFacts.find((scheduler) => - areProofLocationsEqual(scheduler.location, registrationLocation), - ); - const evidence = createEvidence( - protocol.registrationCall, - context.rootDirectory, - protocol.cancellationStatus === ReactSchedulerCancellationStatus.Missing - ? `${protocol.kind} can remain active after its Effect loses ownership` - : `${protocol.kind} has no complete deferred callback and cancellation certificate`, - ["effect setup", protocol.kind, "deferred callback", "effect cleanup or replacement"], - ); - if (protocol.cancellationStatus === ReactSchedulerCancellationStatus.Missing) { + 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 (!schedulerFact?.complete) { + } 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, - "An Effect scheduler can invoke work after losing lifecycle ownership", + "A scheduler can invoke work after losing lifecycle ownership", violations, ); } @@ -72,6 +80,6 @@ export const analyzeScheduledCallbackLifetime = ( return createObligation( ReactProofClaim.ScheduledCallbackLifetime, ReactObligationStatus.Proved, - "Every modeled Effect scheduler has a deferred callback and guaranteed cancellation", + "Every modeled lifecycle scheduler has a deferred callback and guaranteed cancellation", ); }; diff --git a/packages/prover/src/build-react-semantic-graph.ts b/packages/prover/src/build-react-semantic-graph.ts index 74e26351d0..5e3ada7fc2 100644 --- a/packages/prover/src/build-react-semantic-graph.ts +++ b/packages/prover/src/build-react-semantic-graph.ts @@ -11,8 +11,14 @@ import { collectDirectHookCalls } from "./collect-direct-hook-calls.js"; import { collectEffectEventBindings } from "./collect-effect-event-bindings.js"; import { collectEffectCleanupFunctions } from "./collect-effect-cleanup-functions.js"; import { collectEffectCalls } from "./collect-effect-calls.js"; -import { collectEffectSchedulerProtocols } from "./collect-effect-scheduler-protocols.js"; -import { collectEffectResourceProtocols } from "./collect-effect-resource-protocols.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 { collectReactiveCaptures } from "./collect-reactive-captures.js"; @@ -58,6 +64,7 @@ import type { ReactSemanticCallbackPropAlternative, ReactSemanticCallbackPropFlow, ReactSemanticCallableRef, + ReactSemanticClassLifecycle, ReactSemanticExternalStore, ReactSemanticFunctionCall, ReactSemanticGraph, @@ -70,6 +77,8 @@ import type { ReactUnitDescriptor, } from "./types.js"; import { areProofLocationsEqual } from "./utils/are-proof-locations-equal.js"; +import { collectReachableCallExpressions } from "./utils/collect-reachable-call-expressions.js"; +import { getClassMethodDeclaration } from "./utils/get-class-method-declaration.js"; import { isDeferredCallbackSynchronous } from "./utils/is-deferred-callback-synchronous.js"; interface UnitGraphIdentity { @@ -86,6 +95,15 @@ interface EffectGraphFacts { functionCalls: ReadonlyArray; } +interface ClassLifecycleGraphFacts { + lifecycle: ReactSemanticClassLifecycle | null; + schedulers: ReadonlyArray; + resources: ReadonlyArray; + callbacks: ReadonlyArray; + reachableFunctions: ReadonlyArray; + functionCalls: ReadonlyArray; +} + interface EffectEventGraphFacts { effectEvents: ReadonlyArray; callbacks: ReadonlyArray; @@ -235,6 +253,7 @@ const createSemanticId = ( }; 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; @@ -988,6 +1007,280 @@ const collectEffectGraph = ( 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 { + lifecycle: null, + schedulers: [], + resources: [], + callbacks: [], + reachableFunctions: [], + functionCalls: [], + }; + } + const mountMethod = getClassMethodDeclaration(classNode, "componentDidMount"); + const unmountMethod = getClassMethodDeclaration(classNode, "componentWillUnmount"); + 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 resourceProtocols = mountMethod + ? collectLifecycleResourceProtocols( + mountMethod, + unmountMethod ? [unmountMethod] : [], + Boolean(unmountMethod), + context, + ) + : []; + const schedulerProtocols = mountMethod + ? collectLifecycleSchedulerProtocols( + mountMethod, + unmountMethod ? [unmountMethod] : [], + Boolean(unmountMethod), + context, + ) + : []; + 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) { + 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) { + 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 representedLifecycleCalls = new Set([ + ...resourceProtocols.flatMap((protocol) => [ + ...protocol.acquisitionNodes.filter(ts.isCallExpression), + ...protocol.disposalCalls, + ]), + ...schedulerProtocols.flatMap((protocol) => [ + protocol.registrationCall, + ...protocol.cancellationCalls, + ]), + ]); + const lifecycleCalls = [ + ...(mountMethod ? collectReachableCallExpressions(mountMethod, context.typeChecker) : []), + ...(unmountMethod ? collectReachableCallExpressions(unmountMethod, context.typeChecker) : []), + ]; + const representedClassMembers = new Set([ + renderMethod, + ...(mountMethod ? [mountMethod] : []), + ...(unmountMethod ? [unmountMethod] : []), + ...[...resourceCallbackFunctions].filter(ts.isMethodDeclaration), + ...[...schedulerCallbackFunctions].filter(ts.isMethodDeclaration), + ...schedulerProtocols.flatMap((protocol) => + protocol.handleDeclaration ? [protocol.handleDeclaration] : [], + ), + ]); + const sourceComplete = + identity.descriptor.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 { + lifecycle: { + id: lifecycleId, + ownerId: identity.semanticUnit.id, + location: getNodeLocation(classNode, context.rootDirectory), + mountCallbackId: mountCallback?.id ?? null, + unmountCallbackId: unmountCallback?.id ?? null, + resourceIds: resources.map((resource) => resource.id), + schedulerIds: schedulers.map((scheduler) => scheduler.id), + sourceComplete, + complete: + sourceComplete && + resources.every((resource) => resource.complete) && + schedulers.every((scheduler) => scheduler.complete), + }, + schedulers, + resources, + callbacks, + reachableFunctions, + functionCalls, + }; +}; + const collectAsyncTaskGraph = ( identity: UnitGraphIdentity, context: ReactAnalysisContext, @@ -1661,6 +1954,7 @@ export const buildReactSemanticGraph = ( name: descriptor.name, kind: descriptor.kind, location: getNodeLocation(descriptor.node, context.rootDirectory), + sourceComplete: descriptor.sourceComplete, }, }), ); @@ -1681,6 +1975,7 @@ export const buildReactSemanticGraph = ( const effects: ReactSemanticEffect[] = []; const schedulers: ReactSemanticScheduler[] = []; const resources: ReactSemanticEffectResource[] = []; + const classLifecycles: ReactSemanticClassLifecycle[] = []; const effectEvents: ReactSemanticEffectEvent[] = []; const externalStores: ReactSemanticExternalStore[] = []; const asyncTasks: ReactSemanticAsyncTask[] = []; @@ -1706,6 +2001,7 @@ export const buildReactSemanticGraph = ( if ( functionNode && (identity.descriptor.kind === ReactUnitKind.Component || + identity.descriptor.kind === ReactUnitKind.ClassComponent || identity.descriptor.kind === ReactUnitKind.Hook) ) { const renderCallback = createCallbackFact( @@ -1728,6 +2024,15 @@ export const buildReactSemanticGraph = ( reachableFunctions.push(...reachabilityFacts.reachableFunctions); functionCalls.push(...reachabilityFacts.functionCalls); } + const classLifecycleGraph = collectClassLifecycleGraph(identity, context); + if (classLifecycleGraph.lifecycle) { + classLifecycles.push(classLifecycleGraph.lifecycle); + } + 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); @@ -1810,6 +2115,7 @@ export const buildReactSemanticGraph = ( callableRefs, schedulers, resources, + classLifecycles, 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 index b7c7309a32..177d81387e 100644 --- a/packages/prover/src/check-react-proof-report.ts +++ b/packages/prover/src/check-react-proof-report.ts @@ -49,7 +49,7 @@ const expectedAsyncOwnershipStatus = ( unit: ReactSemanticUnit, report: ReactAppProofReport, ): ReactObligationStatus => { - if (unit.kind === ReactUnitKind.ClassComponent || unit.kind === ReactUnitKind.InvalidHookOwner) { + if (!unit.sourceComplete || unit.kind === ReactUnitKind.InvalidHookOwner) { return ReactObligationStatus.Unknown; } const tasks = report.graph.asyncTasks.filter((task) => task.ownerId === unit.id); @@ -66,7 +66,7 @@ const expectedCallableRefFreshnessStatus = ( unit: ReactSemanticUnit, report: ReactAppProofReport, ): ReactObligationStatus => { - if (unit.kind === ReactUnitKind.ClassComponent || unit.kind === ReactUnitKind.InvalidHookOwner) { + if (!unit.sourceComplete || unit.kind === ReactUnitKind.InvalidHookOwner) { return ReactObligationStatus.Unknown; } return report.graph.callableRefs @@ -80,7 +80,7 @@ const expectedScheduledCallbackLifetimeStatus = ( unit: ReactSemanticUnit, report: ReactAppProofReport, ): ReactObligationStatus => { - if (unit.kind === ReactUnitKind.ClassComponent || unit.kind === ReactUnitKind.InvalidHookOwner) { + if (!unit.sourceComplete || unit.kind === ReactUnitKind.InvalidHookOwner) { return ReactObligationStatus.Unknown; } const schedulers = report.graph.schedulers.filter((scheduler) => scheduler.ownerId === unit.id); @@ -91,6 +91,12 @@ const expectedScheduledCallbackLifetimeStatus = ( ) { 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; @@ -100,7 +106,7 @@ const expectedEffectCleanupStatus = ( unit: ReactSemanticUnit, report: ReactAppProofReport, ): ReactObligationStatus => { - if (unit.kind === ReactUnitKind.ClassComponent || unit.kind === ReactUnitKind.InvalidHookOwner) { + if (!unit.sourceComplete || unit.kind === ReactUnitKind.InvalidHookOwner) { return ReactObligationStatus.Unknown; } const resources = report.graph.resources.filter((resource) => resource.ownerId === unit.id); @@ -111,6 +117,12 @@ const expectedEffectCleanupStatus = ( ) { 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) ) { @@ -198,6 +210,7 @@ const checkGraphReferences = ( 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)); @@ -467,15 +480,28 @@ const checkGraphReferences = ( if (!unitIds.has(scheduler.ownerId)) { addFailure(failures, scheduler.id, "A scheduler has an unknown owner unit"); } - 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", - ); + 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); @@ -514,15 +540,36 @@ const checkGraphReferences = ( if (!unitIds.has(resource.ownerId)) { addFailure(failures, resource.id, "An Effect resource has an unknown owner unit"); } - const effect = effectsById.get(resource.effectId); - if (!effect || effect.ownerId !== resource.ownerId) { - addFailure(failures, resource.id, "An Effect resource has an unknown or cross-owner Effect"); - } else if (effect.setupCallbackId !== resource.acquisitionCallbackId) { - addFailure( - failures, - resource.id, - "An Effect resource acquisition is not linked to its setup callback", - ); + 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); @@ -605,6 +652,105 @@ const checkGraphReferences = ( 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 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 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 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 expectedComplete = + lifecycle.sourceComplete && + lifecycleResources.length === lifecycle.resourceIds.length && + lifecycleResources.every((resource) => resource.complete) && + lifecycleSchedulers.length === lifecycle.schedulerIds.length && + lifecycleSchedulers.every((scheduler) => scheduler.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"); + } + } + 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 reachableFunction of report.graph.reachableFunctions) { if (!unitIds.has(reachableFunction.ownerId)) { addFailure(failures, reachableFunction.id, "A reachable function has an unknown owner unit"); @@ -924,6 +1070,11 @@ export const checkReactProofReport = (report: ReactAppProofReport): ReactProofCe "Effect resources", report.graph.resources.map((resource) => resource.id), ); + checkUniqueIds( + failures, + "Class lifecycles", + report.graph.classLifecycles.map((lifecycle) => lifecycle.id), + ); checkUniqueIds( failures, "effects", diff --git a/packages/prover/src/collect-effect-resource-protocols.ts b/packages/prover/src/collect-effect-resource-protocols.ts index 54316b58be..6e274e8b34 100644 --- a/packages/prover/src/collect-effect-resource-protocols.ts +++ b/packages/prover/src/collect-effect-resource-protocols.ts @@ -22,17 +22,20 @@ import { isPlatformDeclarationSymbol } from "./utils/is-platform-declaration-sym import { isPlatformResourceValue } from "./utils/is-platform-resource-value.js"; import { unwrapTypescriptExpression } from "./unwrap-typescript-expression.js"; -export interface EffectResourceProtocolDescriptor { +export interface LifecycleResourceProtocolDescriptor { acquisitionNode: ts.Node; acquisitionNodes: ReadonlyArray; callbackExpression: ts.Expression | null; disposalCalls: ReadonlyArray; disposalStatus: ReactEffectResourceDisposalStatus; - effectCall: ts.CallExpression; isSourceComplete: boolean; kind: ReactEffectResourceKind; } +export interface EffectResourceProtocolDescriptor extends LifecycleResourceProtocolDescriptor { + effectCall: ts.CallExpression; +} + interface EventListenerDescriptor { eventExpression: ts.Expression; handlerExpression: ts.Expression; @@ -368,14 +371,14 @@ const isGuaranteedCleanupCall = ( }; const getDisposalStatus = ( - effectCallback: ts.FunctionLikeDeclaration, + cleanupFunctions: ReadonlyArray, + hasGuaranteedCleanup: boolean, isAcquisitionConditional: boolean, isMatchingDisposal: (callExpression: ts.CallExpression) => boolean, isDefinitelyMismatchedDisposal: (callExpression: ts.CallExpression) => boolean, typeChecker: ts.TypeChecker, ): EffectResourceDisposal => { - const cleanupFunctions = collectEffectCleanupFunctions(effectCallback, typeChecker); - if (cleanupFunctions.length === 0 || !hasGuaranteedEffectCleanup(effectCallback, typeChecker)) { + if (cleanupFunctions.length === 0 || !hasGuaranteedCleanup) { return { calls: [], status: isAcquisitionConditional @@ -412,6 +415,99 @@ const getDisposalStatus = ( }; }; +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, @@ -420,89 +516,15 @@ export const collectEffectResourceProtocols = ( for (const effectCall of collectEffectCalls(functionNode, context.typeChecker)) { const effectCallback = getEffectCallback(effectCall, context.typeChecker); if (!effectCallback) continue; - const reachableFunctions = collectReachableFunctions(effectCallback, context.typeChecker); - for (const registrationCall of collectReachableCallExpressions( - effectCallback, - 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( + const cleanupFunctions = collectEffectCleanupFunctions(effectCallback, context.typeChecker); + protocols.push( + ...collectLifecycleResourceProtocols( effectCallback, - isAcquisitionConditional, - (cleanupCall) => - isMatchingEventRemoval(cleanupCall, listener, context.typeChecker) || - isMatchingAbort(cleanupCall, listener, context.typeChecker), - (cleanupCall) => - isDefinitelyMismatchedEventRemoval(cleanupCall, listener, context.typeChecker), - context.typeChecker, - ); - const hasCompleteIdentity = listener.capture !== null; - protocols.push({ - acquisitionNode: registrationCall, - acquisitionNodes: [registrationCall], - callbackExpression: listener.handlerExpression, - disposalCalls: disposal.calls, - disposalStatus: disposal.status, - effectCall, - isSourceComplete: - hasCompleteIdentity && disposal.status === ReactEffectResourceDisposalStatus.Guaranteed, - kind: ReactEffectResourceKind.EventListener, - }); - } - for (const observer of collectObservers(effectCallback, 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( - effectCallback, - 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, - effectCall, - isSourceComplete: - Boolean(observer.callbackExpression) && - disposal.status === ReactEffectResourceDisposalStatus.Guaranteed, - kind: observer.kind, - }); - } + 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 index c9ba496299..3b37f0c7de 100644 --- a/packages/prover/src/collect-effect-scheduler-protocols.ts +++ b/packages/prover/src/collect-effect-scheduler-protocols.ts @@ -5,6 +5,7 @@ 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"; @@ -15,21 +16,31 @@ 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 EffectSchedulerProtocolDescriptor { +export interface LifecycleSchedulerProtocolDescriptor { callbackExpression: ts.Expression | null; cancellationCalls: ReadonlyArray; cancellationStatus: ReactSchedulerCancellationStatus; - effectCall: ts.CallExpression; + 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", @@ -131,41 +142,96 @@ export const getPlatformSchedulerKind = ( context: ReactAnalysisContext, ): ReactSchedulerKind | null => getSchedulerApi(callExpression, context.typeChecker)?.kind ?? null; -const getImmutableHandle = (registrationCall: ts.CallExpression): ts.Identifier | 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) || - !(declaration.parent.flags & ts.NodeFlags.Const) + 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 declaration.name; + return { + expression: assignment.left, + symbol, + propertyDeclaration, + }; }; const isMatchingCancellation = ( callExpression: ts.CallExpression, cancellationName: string, - handleSymbol: ts.Symbol, + 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( - handleArgument && - ts.isIdentifier(handleArgument) && - typeChecker.getSymbolAtLocation(handleArgument) === handleSymbol, + 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 = ( - effectCallback: ts.FunctionLikeDeclaration, + cleanupFunctions: ReadonlyArray, + hasGuaranteedCleanup: boolean, registrationCall: ts.CallExpression, cancellationName: string | null, typeChecker: ts.TypeChecker, @@ -176,9 +242,8 @@ const collectCancellation = ( if (!cancellationName) { return { calls: [], status: ReactSchedulerCancellationStatus.Unknown }; } - const handle = getImmutableHandle(registrationCall); - const handleSymbol = handle ? typeChecker.getSymbolAtLocation(handle) : null; - if (!handleSymbol) { + const handle = getImmutableHandle(registrationCall, typeChecker); + if (!handle) { const immediateCancellation = ts.isCallExpression(registrationCall.parent) && registrationCall.parent.arguments[0] === registrationCall && @@ -204,15 +269,14 @@ const collectCancellation = ( : ReactSchedulerCancellationStatus.Missing, }; } - const cleanupFunctions = collectEffectCleanupFunctions(effectCallback, typeChecker); - if (cleanupFunctions.length === 0 || !hasGuaranteedEffectCleanup(effectCallback, typeChecker)) { + 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, handleSymbol, typeChecker), + isMatchingCancellation(cleanupCall, cancellationName, handle, typeChecker), ); if (cleanupMatchingCalls.length === 0) { return { @@ -240,6 +304,56 @@ const collectCancellation = ( }; }; +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, @@ -248,44 +362,15 @@ export const collectEffectSchedulerProtocols = ( for (const effectCall of collectEffectCalls(functionNode, context.typeChecker)) { const effectCallback = getEffectCallback(effectCall, context.typeChecker); if (!effectCallback) continue; - const reachableFunctions = collectReachableFunctions(effectCallback, context.typeChecker); - for (const registrationCall of collectReachableCallExpressions( - effectCallback, - 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( + const cleanupFunctions = collectEffectCleanupFunctions(effectCallback, context.typeChecker); + protocols.push( + ...collectLifecycleSchedulerProtocols( effectCallback, - 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, - effectCall, - isSourceComplete: - Boolean(callbackExpression) && - !isRegistrationConditional && - cancellation.status === ReactSchedulerCancellationStatus.Guaranteed, - kind: schedulerApi.kind, - registrationCall, - }); - } + cleanupFunctions, + hasGuaranteedEffectCleanup(effectCallback, context.typeChecker), + context, + ).map((protocol) => ({ ...protocol, effectCall })), + ); } return protocols; }; diff --git a/packages/prover/src/collect-react-units.ts b/packages/prover/src/collect-react-units.ts index 2f6046bb6d..917bd53a77 100644 --- a/packages/prover/src/collect-react-units.ts +++ b/packages/prover/src/collect-react-units.ts @@ -1,24 +1,79 @@ 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 { ReactUnitKind } from "./types.js"; +import { getClassMethodDeclaration } from "./utils/get-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 isReactComponentClass = (classNode: ts.ClassDeclaration): boolean => +const REACT_CLASS_BASE_NAMES = new Set(["Component", "PureComponent"]); + +const isReactComponentClass = ( + classNode: ts.ClassDeclaration, + typeChecker: ts.TypeChecker, +): boolean => Boolean( classNode.heritageClauses?.some((heritageClause) => - heritageClause.types.some((heritageType) => { - const heritageName = heritageType.expression.getText(); - return heritageName === "Component" || heritageName.endsWith(".Component"); - }), + heritageClause.types.some((heritageType) => + REACT_CLASS_BASE_NAMES.has( + getCanonicalReactApiName(heritageType.expression, typeChecker) ?? "", + ), + ), ), ); +const SUPPORTED_CLASS_LIFECYCLE_NAMES = new Set([ + "componentDidMount", + "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.isPropertyDeclaration(member)) { + const propertyName = getStaticPropertyName(member.name); + const initializer = member.initializer; + return Boolean( + propertyName && + !member.modifiers?.some( + (modifier) => + modifier.kind === ts.SyntaxKind.StaticKeyword || + modifier.kind === ts.SyntaxKind.AccessorKeyword, + ) && + (!initializer || + ts.isNumericLiteral(initializer) || + initializer.kind === ts.SyntaxKind.NullKeyword || + (ts.isIdentifier(initializer) && initializer.text === "undefined")), + ); + } + if (!ts.isMethodDeclaration(member)) return false; + const methodName = getStaticPropertyName(member.name); + if (!methodName || getClassMethodDeclaration(classNode, methodName) !== member) return false; + if (isReservedClassLifecycleName(methodName)) { + return ( + SUPPORTED_CLASS_LIFECYCLE_NAMES.has(methodName) && + (methodName === "render" || member.parameters.length === 0) + ); + } + return true; + }); + const collectFunctionUnit = ( functionNode: ts.FunctionLikeDeclaration, typeChecker: ts.TypeChecker, @@ -33,6 +88,7 @@ const collectFunctionUnit = ( node: functionNode, functionNode, invalidHookCalls: directHookCalls, + sourceComplete: false, } : null; } @@ -42,6 +98,7 @@ const collectFunctionUnit = ( kind: ReactUnitKind.Hook, node: functionNode, functionNode, + sourceComplete: true, }; } if (isReactComponentName(functionName)) { @@ -50,6 +107,7 @@ const collectFunctionUnit = ( kind: ReactUnitKind.Component, node: functionNode, functionNode, + sourceComplete: true, }; } if (directHookCalls.length === 0) return null; @@ -59,6 +117,7 @@ const collectFunctionUnit = ( node: functionNode, functionNode, invalidHookCalls: directHookCalls, + sourceComplete: false, }; }; @@ -74,17 +133,22 @@ export const collectReactUnits = ( 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) && isReactComponentClass(node)) { + } else if (ts.isClassDeclaration(node) && isReactComponentClass(node, typeChecker)) { + const renderMethod = getClassMethodDeclaration(node, "render"); units.push({ name: node.name?.text ?? "DefaultComponent", kind: ReactUnitKind.ClassComponent, node, + classNode: node, + functionNode: renderMethod ?? undefined, + sourceComplete: Boolean(renderMethod && hasSupportedClassSyntax(node, renderMethod)), }); } node.forEachChild(visit); diff --git a/packages/prover/src/constants.ts b/packages/prover/src/constants.ts index 75980ade16..335c24faac 100644 --- a/packages/prover/src/constants.ts +++ b/packages/prover/src/constants.ts @@ -1,7 +1,7 @@ import { ReactEffectResourceKind } from "./types.js"; -export const REACT_PROOF_SCHEMA_VERSION = 11; -export const REACT_SEMANTIC_GRAPH_SCHEMA_VERSION = 17; +export const REACT_PROOF_SCHEMA_VERSION = 13; +export const REACT_SEMANTIC_GRAPH_SCHEMA_VERSION = 19; 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; diff --git a/packages/prover/src/index.ts b/packages/prover/src/index.ts index e382863768..b18ceabfc6 100644 --- a/packages/prover/src/index.ts +++ b/packages/prover/src/index.ts @@ -46,6 +46,7 @@ export type { ReactSemanticCallbackPropAlternative, ReactSemanticCallbackPropFlow, ReactSemanticCallableRef, + ReactSemanticClassLifecycle, ReactSemanticExternalStore, ReactSemanticCallback, ReactSemanticAsyncTask, diff --git a/packages/prover/src/prove-react-app.ts b/packages/prover/src/prove-react-app.ts index 7b3fe14ed3..13d48eb784 100644 --- a/packages/prover/src/prove-react-app.ts +++ b/packages/prover/src/prove-react-app.ts @@ -43,6 +43,7 @@ export const proveReactApp = (input: ProveReactAppInput): ReactAppProofReport => callableRefs: [], schedulers: [], resources: [], + classLifecycles: [], compiler: { version: REACT_COMPILER_VERSION, phase: REACT_COMPILER_FACT_PHASE, diff --git a/packages/prover/src/types.ts b/packages/prover/src/types.ts index cfad81403e..148e16523f 100644 --- a/packages/prover/src/types.ts +++ b/packages/prover/src/types.ts @@ -58,6 +58,8 @@ export enum ReactCompilerFactStatus { } export enum ReactExecutionPhase { + ClassMount = "class-mount", + ClassUnmount = "class-unmount", Deferred = "deferred", EffectCleanup = "effect-cleanup", EffectEvent = "effect-event", @@ -70,6 +72,8 @@ export enum ReactExecutionPhase { } export enum ReactSemanticCallbackKind { + ClassMount = "class-mount", + ClassUnmount = "class-unmount", ComponentRender = "component-render", EffectCleanup = "effect-cleanup", EffectEvent = "effect-event", @@ -125,6 +129,7 @@ export interface ReactSemanticUnit { name: string; kind: ReactUnitKind; location: ReactProofLocation; + sourceComplete: boolean; } export interface ReactSemanticEdge { @@ -379,7 +384,7 @@ export interface ReactSemanticCallableRef { export interface ReactSemanticScheduler { id: string; ownerId: string; - effectId: string; + effectId: string | null; registrationCallbackId: string; kind: ReactSchedulerKind; phase: ReactExecutionPhase; @@ -395,7 +400,7 @@ export interface ReactSemanticScheduler { export interface ReactSemanticEffectResource { id: string; ownerId: string; - effectId: string; + effectId: string | null; acquisitionCallbackId: string; kind: ReactEffectResourceKind; phase: ReactExecutionPhase; @@ -409,6 +414,18 @@ export interface ReactSemanticEffectResource { complete: boolean; } +export interface ReactSemanticClassLifecycle { + id: string; + ownerId: string; + location: ReactProofLocation; + mountCallbackId: string | null; + unmountCallbackId: string | null; + resourceIds: ReadonlyArray; + schedulerIds: ReadonlyArray; + sourceComplete: boolean; + complete: boolean; +} + export interface ReactCompilerInstructionFact { id: string; valueKind: string; @@ -469,6 +486,7 @@ export interface ReactSemanticGraph { callableRefs: ReadonlyArray; schedulers: ReadonlyArray; resources: ReadonlyArray; + classLifecycles: ReadonlyArray; compiler: ReactCompilerGraph; } @@ -499,8 +517,10 @@ export interface ReactUnitDescriptor { name: string; kind: ReactUnitKind; node: ts.Node; + classNode?: ts.ClassDeclaration; functionNode?: ts.FunctionLikeDeclaration; invalidHookCalls?: ReadonlyArray; + sourceComplete: boolean; } export interface ReactAnalysisContext { diff --git a/packages/prover/src/utils/are-immutable-expressions-identical.ts b/packages/prover/src/utils/are-immutable-expressions-identical.ts index 614da0d249..4b44831bf9 100644 --- a/packages/prover/src/utils/are-immutable-expressions-identical.ts +++ b/packages/prover/src/utils/are-immutable-expressions-identical.ts @@ -2,6 +2,7 @@ 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 = ( @@ -50,6 +51,39 @@ export const areImmutableExpressionsIdentical = ( 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); 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 0000000000..5d464fda91 --- /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"; + +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) && + node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && + node.operatorToken.kind <= ts.SyntaxKind.LastAssignment && + 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/get-class-method-declaration.ts b/packages/prover/src/utils/get-class-method-declaration.ts new file mode 100644 index 0000000000..d8274e21ff --- /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/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 0000000000..4f9a59efef --- /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 0000000000..d2a921ed01 --- /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 0000000000..416774981b --- /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 0000000000..d2a921ed01 --- /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 0000000000..988963ef80 --- /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 0000000000..d2a921ed01 --- /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-timeout-leak/src/app.tsx b/packages/prover/tests/fixtures/class-timeout-leak/src/app.tsx new file mode 100644 index 0000000000..7d9cd4ae7b --- /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 0000000000..d2a921ed01 --- /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/incomplete-class-field/src/app.tsx b/packages/prover/tests/fixtures/incomplete-class-field/src/app.tsx new file mode 100644 index 0000000000..083beba9ea --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-class-field/src/app.tsx @@ -0,0 +1,9 @@ +import { Component } from "react"; + +export class Counter extends Component { + state = { count: 0 }; + + render() { + return

    {this.state.count}

    ; + } +} diff --git a/packages/prover/tests/fixtures/incomplete-class-field/tsconfig.json b/packages/prover/tests/fixtures/incomplete-class-field/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-class-field/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-class-helper-lifecycle/src/app.tsx b/packages/prover/tests/fixtures/incomplete-class-helper-lifecycle/src/app.tsx new file mode 100644 index 0000000000..fb58ef89bf --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-class-helper-lifecycle/src/app.tsx @@ -0,0 +1,25 @@ +import { Component } from "react"; + +export class ResizeListener extends Component { + handleResize() {} + + attach() { + window.addEventListener("resize", this.handleResize); + } + + detach() { + window.removeEventListener("resize", this.handleResize); + } + + componentDidMount() { + this.attach(); + } + + componentWillUnmount() { + this.detach(); + } + + render() { + return null; + } +} diff --git a/packages/prover/tests/fixtures/incomplete-class-helper-lifecycle/tsconfig.json b/packages/prover/tests/fixtures/incomplete-class-helper-lifecycle/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-class-helper-lifecycle/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-class-lifecycle/src/app.tsx b/packages/prover/tests/fixtures/incomplete-class-lifecycle/src/app.tsx new file mode 100644 index 0000000000..f59ed82965 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-class-lifecycle/src/app.tsx @@ -0,0 +1,9 @@ +import { Component } from "react"; + +export class UpdatingComponent extends Component { + componentDidUpdate() {} + + render() { + return null; + } +} diff --git a/packages/prover/tests/fixtures/incomplete-class-lifecycle/tsconfig.json b/packages/prover/tests/fixtures/incomplete-class-lifecycle/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-class-lifecycle/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-class-listener-method-reassigned/src/app.tsx b/packages/prover/tests/fixtures/incomplete-class-listener-method-reassigned/src/app.tsx new file mode 100644 index 0000000000..9f2de83f4b --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-class-listener-method-reassigned/src/app.tsx @@ -0,0 +1,18 @@ +import { Component } from "react"; + +export class ResizeListener extends Component { + handleResize() {} + + componentDidMount() { + window.addEventListener("resize", this.handleResize); + this.handleResize = () => {}; + } + + componentWillUnmount() { + window.removeEventListener("resize", this.handleResize); + } + + render() { + return null; + } +} diff --git a/packages/prover/tests/fixtures/incomplete-class-listener-method-reassigned/tsconfig.json b/packages/prover/tests/fixtures/incomplete-class-listener-method-reassigned/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-class-listener-method-reassigned/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-class-timeout-reassigned/src/app.tsx b/packages/prover/tests/fixtures/incomplete-class-timeout-reassigned/src/app.tsx new file mode 100644 index 0000000000..b61db1acd8 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-class-timeout-reassigned/src/app.tsx @@ -0,0 +1,20 @@ +import { Component } from "react"; + +export class DelayedUpdate extends Component { + timeoutId = 0; + + handleTimeout() {} + + componentDidMount() { + this.timeoutId = window.setTimeout(this.handleTimeout, 80); + this.timeoutId = 1; + } + + componentWillUnmount() { + window.clearTimeout(this.timeoutId); + } + + render() { + return null; + } +} diff --git a/packages/prover/tests/fixtures/incomplete-class-timeout-reassigned/tsconfig.json b/packages/prover/tests/fixtures/incomplete-class-timeout-reassigned/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-class-timeout-reassigned/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-class-listener/src/app.tsx b/packages/prover/tests/fixtures/proved-class-listener/src/app.tsx new file mode 100644 index 0000000000..b403875221 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-class-listener/src/app.tsx @@ -0,0 +1,17 @@ +import { Component } from "react"; + +export class ResizeListener extends Component { + handleResize() {} + + componentDidMount() { + window.addEventListener("resize", this.handleResize, { passive: true }); + } + + componentWillUnmount() { + window.removeEventListener("resize", this.handleResize); + } + + render() { + return null; + } +} diff --git a/packages/prover/tests/fixtures/proved-class-listener/tsconfig.json b/packages/prover/tests/fixtures/proved-class-listener/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-class-listener/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-class-timeout/src/app.tsx b/packages/prover/tests/fixtures/proved-class-timeout/src/app.tsx new file mode 100644 index 0000000000..d473aa91ff --- /dev/null +++ b/packages/prover/tests/fixtures/proved-class-timeout/src/app.tsx @@ -0,0 +1,19 @@ +import { Component } from "react"; + +export class DelayedUpdate extends Component { + timeoutId = 0; + + handleTimeout() {} + + componentDidMount() { + this.timeoutId = window.setTimeout(this.handleTimeout, 80); + } + + componentWillUnmount() { + window.clearTimeout(this.timeoutId); + } + + render() { + return null; + } +} diff --git a/packages/prover/tests/fixtures/proved-class-timeout/tsconfig.json b/packages/prover/tests/fixtures/proved-class-timeout/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-class-timeout/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-pure-class-render/src/app.tsx b/packages/prover/tests/fixtures/proved-pure-class-render/src/app.tsx new file mode 100644 index 0000000000..94a5d96397 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-pure-class-render/src/app.tsx @@ -0,0 +1,11 @@ +import { Component as ReactView } from "react"; + +interface GreetingProperties { + name: string; +} + +export class Greeting extends ReactView { + render() { + return

    Hello {this.props.name}

    ; + } +} diff --git a/packages/prover/tests/fixtures/proved-pure-class-render/tsconfig.json b/packages/prover/tests/fixtures/proved-pure-class-render/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-pure-class-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/react-shim.d.ts b/packages/prover/tests/fixtures/react-shim.d.ts index b3db838115..efa21a3916 100644 --- a/packages/prover/tests/fixtures/react-shim.d.ts +++ b/packages/prover/tests/fixtures/react-shim.d.ts @@ -23,6 +23,7 @@ declare module "react" { export const use: Use; export const createContext: (defaultValue: Value) => Context; export const memo: (component: Component) => Component; + export const StrictMode: (properties: { children?: unknown }) => unknown; export const useEffect: ( setup: () => void | (() => void), dependencies?: ReadonlyArray, diff --git a/packages/prover/tests/fixtures/shadowed-component-class/src/app.tsx b/packages/prover/tests/fixtures/shadowed-component-class/src/app.tsx new file mode 100644 index 0000000000..134f3c3d27 --- /dev/null +++ b/packages/prover/tests/fixtures/shadowed-component-class/src/app.tsx @@ -0,0 +1,7 @@ +class Component {} + +export class NotReact extends Component { + render() { + return

    Not React-owned

    ; + } +} diff --git a/packages/prover/tests/fixtures/shadowed-component-class/tsconfig.json b/packages/prover/tests/fixtures/shadowed-component-class/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/shadowed-component-class/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/prove-react-app.test.ts b/packages/prover/tests/prove-react-app.test.ts index f459d67cd2..d588f82605 100644 --- a/packages/prover/tests/prove-react-app.test.ts +++ b/packages/prover/tests/prove-react-app.test.ts @@ -97,6 +97,21 @@ const REFUTED_FIXTURES: ReadonlyArray = [ claim: ReactProofClaim.EffectCleanup, evidencePattern: /same resource identity/, }, + { + fixtureName: "class-listener-leak", + claim: ReactProofClaim.EffectCleanup, + evidencePattern: /same resource identity/, + }, + { + fixtureName: "class-listener-capture-mismatch", + claim: ReactProofClaim.EffectCleanup, + evidencePattern: /same resource identity/, + }, + { + fixtureName: "class-timeout-leak", + claim: ReactProofClaim.ScheduledCallbackLifetime, + evidencePattern: /remain active after its lifecycle loses ownership/, + }, { fixtureName: "nested-component", claim: ReactProofClaim.ComponentIdentity, @@ -396,6 +411,10 @@ describe("proveReactApp", () => { "proved-for-of-nested-binding-handler", "proved-helper-local-rebinding", "proved-branch-effect-cleanup", + "class-component", + "proved-pure-class-render", + "proved-class-listener", + "proved-class-timeout", ])("proves the complete %s application graph", (fixtureName) => { const report = proveFixture(fixtureName); @@ -454,7 +473,7 @@ describe("proveReactApp", () => { const report = proveFixture("proved-chat"); const effect = report.graph.effects[0]; - expect(report.graph.schemaVersion).toBe(17); + expect(report.graph.schemaVersion).toBe(19); expect(effect?.hookName).toBe("useEffect"); expect(effect?.callbackResolved).toBe(true); expect(effect?.dependencyMode).toBe(ReactEffectDependencyMode.Inline); @@ -2325,16 +2344,176 @@ describe("proveReactApp", () => { expect(cleanupProof?.evidence[0]?.description).toMatch(/path-dependent/); }); - it("fails closed for class component lifecycle semantics", () => { + it("proves a render-only class through symbol-resolved React inheritance", () => { const report = proveFixture("class-component"); + const renderCallback = report.graph.callbacks.find( + (callback) => callback.kind === ReactSemanticCallbackKind.ComponentRender, + ); + + expect(report.status).toBe(ReactAppProofStatus.Proved); + expect(report.graph.units[0]?.kind).toBe("class-component"); + expect(report.graph.units[0]?.sourceComplete).toBe(true); + expect(renderCallback?.phase).toBe(ReactExecutionPhase.Render); + expect(checkReactProofReport(report).status).toBe(ReactProofCertificateStatus.Valid); + }); + + it("certifies a class mount-listener-unmount lifecycle with exact method identity", () => { + const report = proveFixture("proved-class-listener"); + const lifecycle = report.graph.classLifecycles[0]; + const resource = report.graph.resources[0]; + const mountCallback = report.graph.callbacks.find( + (callback) => callback.id === lifecycle?.mountCallbackId, + ); + const unmountCallback = report.graph.callbacks.find( + (callback) => callback.id === lifecycle?.unmountCallbackId, + ); + + expect(report.status).toBe(ReactAppProofStatus.Proved); + expect(lifecycle?.sourceComplete).toBe(true); + expect(lifecycle?.complete).toBe(true); + expect(lifecycle?.resourceIds).toEqual([resource?.id]); + expect(mountCallback?.kind).toBe(ReactSemanticCallbackKind.ClassMount); + expect(mountCallback?.phase).toBe(ReactExecutionPhase.ClassMount); + expect(unmountCallback?.kind).toBe(ReactSemanticCallbackKind.ClassUnmount); + expect(unmountCallback?.phase).toBe(ReactExecutionPhase.ClassUnmount); + expect(resource?.effectId).toBeNull(); + expect(resource?.acquisitionCallbackId).toBe(mountCallback?.id); + expect(resource?.disposalStatus).toBe(ReactEffectResourceDisposalStatus.Guaranteed); + expect(resource?.complete).toBe(true); + }); + + it("fails closed on mutable class-method identity and unmodeled lifecycle helpers", () => { + const mutableMethodReport = proveFixture("incomplete-class-listener-method-reassigned"); + const helperReport = proveFixture("incomplete-class-helper-lifecycle"); + + expect(mutableMethodReport.status).toBe(ReactAppProofStatus.Incomplete); + expect(mutableMethodReport.graph.resources[0]?.complete).toBe(false); + expect(mutableMethodReport.graph.classLifecycles[0]?.sourceComplete).toBe(false); + expect(helperReport.status).toBe(ReactAppProofStatus.Incomplete); + expect(helperReport.graph.resources[0]?.complete).toBe(true); + expect(helperReport.graph.classLifecycles[0]?.sourceComplete).toBe(false); + }); + + it("rejects a class lifecycle certificate with a forged resource link", () => { + const report = proveFixture("proved-class-listener"); + const certificate = checkReactProofReport({ + ...report, + graph: { + ...report.graph, + classLifecycles: report.graph.classLifecycles.map((lifecycle) => ({ + ...lifecycle, + resourceIds: ["forged-resource"], + })), + }, + }); + + expect(certificate.status).toBe(ReactProofCertificateStatus.Invalid); + expect( + certificate.failures.some((failure) => failure.description.includes("invalid resource link")), + ).toBe(true); + }); + + it("certifies a class mount-timeout-unmount lifecycle with an exact handle", () => { + const report = proveFixture("proved-class-timeout"); + const lifecycle = report.graph.classLifecycles[0]; + const scheduler = report.graph.schedulers[0]; + const mountCallback = report.graph.callbacks.find( + (callback) => callback.id === lifecycle?.mountCallbackId, + ); + + expect(report.status).toBe(ReactAppProofStatus.Proved); + expect(lifecycle?.sourceComplete).toBe(true); + expect(lifecycle?.complete).toBe(true); + expect(lifecycle?.schedulerIds).toEqual([scheduler?.id]); + expect(scheduler?.effectId).toBeNull(); + expect(scheduler?.registrationCallbackId).toBe(mountCallback?.id); + expect(scheduler?.kind).toBe(ReactSchedulerKind.Timeout); + expect(scheduler?.cancellationStatus).toBe(ReactSchedulerCancellationStatus.Guaranteed); + expect(scheduler?.complete).toBe(true); + expect(checkReactProofReport(report).status).toBe(ReactProofCertificateStatus.Valid); + }); + + it("fails closed when a class scheduler handle is reassigned", () => { + const report = proveFixture("incomplete-class-timeout-reassigned"); expect(report.status).toBe(ReactAppProofStatus.Incomplete); - expect(report.summary.unknown).toBeGreaterThan(0); - expect(report.units[0]?.obligations[0]?.evidence[0]?.description).toMatch( - /Class component lifecycle/, + expect(report.graph.schedulers[0]?.complete).toBe(false); + expect(report.graph.schedulers[0]?.cancellationStatus).toBe( + ReactSchedulerCancellationStatus.Unknown, + ); + expect(report.graph.classLifecycles[0]?.sourceComplete).toBe(false); + expect(checkReactProofReport(report).status).toBe(ReactProofCertificateStatus.Valid); + }); + + it("rejects a class lifecycle certificate with a forged scheduler link", () => { + const report = proveFixture("proved-class-timeout"); + const certificate = checkReactProofReport({ + ...report, + graph: { + ...report.graph, + classLifecycles: report.graph.classLifecycles.map((lifecycle) => ({ + ...lifecycle, + schedulerIds: ["forged-scheduler"], + })), + }, + }); + + expect(certificate.status).toBe(ReactProofCertificateStatus.Invalid); + expect( + certificate.failures.some((failure) => + failure.description.includes("invalid scheduler link"), + ), + ).toBe(true); + }); + + it("rejects a class unit with its lifecycle certificate removed", () => { + const report = proveFixture("class-component"); + const certificate = checkReactProofReport({ + ...report, + graph: { + ...report.graph, + classLifecycles: [], + }, + }); + + expect(certificate.status).toBe(ReactProofCertificateStatus.Invalid); + expect( + certificate.failures.some((failure) => + failure.description.includes("no lifecycle certificate"), + ), + ).toBe(true); + }); + + it("refutes an impure class render instead of trusting the class boundary", () => { + const report = proveFixture("class-render-impurity"); + const renderProof = report.units[0]?.obligations.find( + (obligation) => obligation.claim === ReactProofClaim.RenderPurity, + ); + + expect(report.status).toBe(ReactAppProofStatus.Refuted); + expect(renderProof?.status).toBe(ReactObligationStatus.Violated); + expect(renderProof?.evidence[0]?.description).toMatch(/not pure during render/); + }); + + it("fails closed for class fields and lifecycle methods until their phases are certified", () => { + const fieldReport = proveFixture("incomplete-class-field"); + const lifecycleReport = proveFixture("incomplete-class-lifecycle"); + + expect(fieldReport.status).toBe(ReactAppProofStatus.Incomplete); + expect(fieldReport.graph.units[0]?.sourceComplete).toBe(false); + expect(lifecycleReport.status).toBe(ReactAppProofStatus.Incomplete); + expect(lifecycleReport.graph.units[0]?.sourceComplete).toBe(false); + expect(lifecycleReport.units[0]?.obligations[0]?.evidence[0]?.description).toMatch( + /constructor, field, lifecycle, ref, or custom method/, ); }); + it("does not mistake a shadowed Component base for React inheritance", () => { + const report = proveFixture("shadowed-component-class"); + + expect(report.graph.units).toEqual([]); + }); + it("fails closed when TypeScript assertions can forge proof facts", () => { const report = proveFixture("unsafe-types"); diff --git a/packages/prover/tests/runtime/class-listener-lifecycle-oracle.spec.ts b/packages/prover/tests/runtime/class-listener-lifecycle-oracle.spec.ts new file mode 100644 index 0000000000..f476898a1f --- /dev/null +++ b/packages/prover/tests/runtime/class-listener-lifecycle-oracle.spec.ts @@ -0,0 +1,23 @@ +import { expect, test } from "@playwright/test"; + +test("class unmount removes the exact listener through the Strict Mode lifecycle", async ({ + page, +}) => { + await page.goto("/?oracle=class-listener&mode=safe"); + await page.getByRole("button", { name: "unmount class listener" }).click(); + await page.getByRole("button", { name: "dispatch class event" }).click(); + + await expect.poll(() => page.evaluate(() => window.classMounts)).toBe(2); + await expect.poll(() => page.evaluate(() => window.classUnmounts)).toBe(2); + await expect.poll(() => page.evaluate(() => window.classListenerHits)).toBe(0); +}); + +test("missing class teardown leaves the listener live after unmount", async ({ page }) => { + await page.goto("/?oracle=class-listener&mode=leaky"); + await page.getByRole("button", { name: "unmount class listener" }).click(); + await page.getByRole("button", { name: "dispatch class event" }).click(); + + await expect.poll(() => page.evaluate(() => window.classMounts)).toBe(2); + await expect.poll(() => page.evaluate(() => window.classUnmounts)).toBe(2); + await expect.poll(() => page.evaluate(() => window.classListenerHits)).toBe(1); +}); diff --git a/packages/prover/tests/runtime/class-scheduler-lifecycle-oracle.spec.ts b/packages/prover/tests/runtime/class-scheduler-lifecycle-oracle.spec.ts new file mode 100644 index 0000000000..1c763f6a13 --- /dev/null +++ b/packages/prover/tests/runtime/class-scheduler-lifecycle-oracle.spec.ts @@ -0,0 +1,22 @@ +import { expect, test } from "@playwright/test"; +import { SCHEDULER_SETTLE_WAIT_MS } from "./constants.js"; + +test("class unmount cancels every Strict Mode timeout generation", async ({ page }) => { + await page.goto("/?oracle=class-scheduler&mode=safe"); + await page.getByRole("button", { name: "unmount class scheduler" }).click(); + await page.waitForTimeout(SCHEDULER_SETTLE_WAIT_MS); + + expect(await page.evaluate(() => window.classMounts)).toBe(2); + expect(await page.evaluate(() => window.classUnmounts)).toBe(2); + expect(await page.evaluate(() => window.classSchedulerHits)).toBe(0); +}); + +test("missing class cancellation leaves both Strict Mode timeouts live", async ({ page }) => { + await page.goto("/?oracle=class-scheduler&mode=leaky"); + await page.getByRole("button", { name: "unmount class scheduler" }).click(); + await page.waitForTimeout(SCHEDULER_SETTLE_WAIT_MS); + + expect(await page.evaluate(() => window.classMounts)).toBe(2); + expect(await page.evaluate(() => window.classUnmounts)).toBe(2); + expect(await page.evaluate(() => window.classSchedulerHits)).toBe(2); +}); diff --git a/packages/prover/tests/runtime/main.tsx b/packages/prover/tests/runtime/main.tsx index 2998c7368a..8d2c055bc3 100644 --- a/packages/prover/tests/runtime/main.tsx +++ b/packages/prover/tests/runtime/main.tsx @@ -1,4 +1,6 @@ import { + Component, + StrictMode, createContext, memo, useCallback, @@ -27,6 +29,10 @@ import { declare global { interface Window { effectEventSetupRuns: number; + classListenerHits: number; + classMounts: number; + classSchedulerHits: number; + classUnmounts: number; listenerHits: number; observerHits: number; schedulerHits: number; @@ -34,6 +40,10 @@ declare global { } window.effectEventSetupRuns = 0; +window.classListenerHits = 0; +window.classMounts = 0; +window.classSchedulerHits = 0; +window.classUnmounts = 0; window.listenerHits = 0; window.observerHits = 0; window.schedulerHits = 0; @@ -81,6 +91,119 @@ const ListenerOracle = () => { ); }; +class SafeClassListener extends Component { + handleResize() { + window.classListenerHits += 1; + } + + componentDidMount() { + window.classMounts += 1; + window.addEventListener("prover-class-resize", this.handleResize); + } + + componentWillUnmount() { + window.classUnmounts += 1; + window.removeEventListener("prover-class-resize", this.handleResize); + } + + render() { + return null; + } +} + +class LeakyClassListener extends Component { + handleResize() { + window.classListenerHits += 1; + } + + componentDidMount() { + window.classMounts += 1; + window.addEventListener("prover-class-resize", this.handleResize); + } + + componentWillUnmount() { + window.classUnmounts += 1; + } + + render() { + return null; + } +} + +const ClassListenerOracle = () => { + const [isMounted, setIsMounted] = useState(true); + const isSafeMode = new URLSearchParams(window.location.search).get("mode") === "safe"; + const Listener = isSafeMode ? SafeClassListener : LeakyClassListener; + return ( +
    + + + {isMounted ? : null} +
    + ); +}; + +class SafeClassScheduler extends Component { + timeoutId = 0; + + handleTimeout() { + window.classSchedulerHits += 1; + } + + componentDidMount() { + window.classMounts += 1; + this.timeoutId = window.setTimeout(this.handleTimeout, SCHEDULER_CALLBACK_DELAY_MS); + } + + componentWillUnmount() { + window.classUnmounts += 1; + window.clearTimeout(this.timeoutId); + } + + render() { + return null; + } +} + +class LeakyClassScheduler extends Component { + timeoutId = 0; + + handleTimeout() { + window.classSchedulerHits += 1; + } + + componentDidMount() { + window.classMounts += 1; + this.timeoutId = window.setTimeout(this.handleTimeout, SCHEDULER_CALLBACK_DELAY_MS); + } + + componentWillUnmount() { + window.classUnmounts += 1; + } + + render() { + return null; + } +} + +const ClassSchedulerOracle = () => { + const [isMounted, setIsMounted] = useState(true); + const isSafeMode = new URLSearchParams(window.location.search).get("mode") === "safe"; + const Scheduler = isSafeMode ? SafeClassScheduler : LeakyClassScheduler; + return ( +
    + + {isMounted ? : null} +
    + ); +}; + interface SchedulerProbeProperties { shouldCancel: boolean; } @@ -597,9 +720,25 @@ const RuntimeOracle = () => { if (oracle === "observer-lifetime") { return ; } + if (oracle === "class-listener") { + return ; + } + if (oracle === "class-scheduler") { + return ; + } return ; }; const rootElement = document.getElementById("root"); if (!rootElement) throw new Error("Missing runtime oracle root"); -createRoot(rootElement).render(); +const oracle = new URLSearchParams(window.location.search).get("oracle"); +const isClassLifecycleOracle = oracle === "class-listener" || oracle === "class-scheduler"; +createRoot(rootElement).render( + isClassLifecycleOracle ? ( + + + + ) : ( + + ), +); From d96f85e07596c88e17d3a5c53d0a7135e0aaef6c Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Tue, 28 Jul 2026 15:52:52 +0000 Subject: [PATCH 07/23] feat(prover): certify class state transitions --- packages/prover/README.md | 14 +- packages/prover/research-log.md | 75 +++- .../src/analyze-class-state-transitions.ts | 106 ++++++ packages/prover/src/analyze-react-unit.ts | 3 + .../prover/src/build-react-semantic-graph.ts | 106 +++++- .../prover/src/check-react-proof-report.ts | 174 ++++++++- .../src/collect-class-state-transitions.ts | 331 ++++++++++++++++++ packages/prover/src/collect-react-units.ts | 62 ++-- packages/prover/src/constants.ts | 4 +- packages/prover/src/index.ts | 4 + packages/prover/src/prove-react-app.ts | 1 + packages/prover/src/types.ts | 44 +++ .../class-impure-state-updater/src/app.tsx | 18 + .../class-impure-state-updater/tsconfig.json | 4 + .../fixtures/class-update-loop/src/app.tsx | 15 + .../fixtures/class-update-loop/tsconfig.json | 4 + .../src/app.tsx | 21 ++ .../tsconfig.json | 4 + .../src/app.tsx | 25 ++ .../tsconfig.json | 4 + .../src/app.tsx | 21 ++ .../tsconfig.json | 4 + .../src/app.tsx | 17 + .../tsconfig.json | 4 + .../src/app.tsx | 17 + .../tsconfig.json | 4 + .../src/app.tsx | 15 + .../tsconfig.json | 4 + .../src/app.tsx | 25 ++ .../tsconfig.json | 4 + .../src/app.tsx | 21 ++ .../tsconfig.json | 4 + .../proved-class-prop-transition/src/app.tsx | 21 ++ .../tsconfig.json | 4 + .../src/app.tsx | 15 + .../tsconfig.json | 4 + .../prover/tests/fixtures/react-shim.d.ts | 19 +- packages/prover/tests/prove-react-app.test.ts | 175 ++++++++- .../class-state-transition-oracle.spec.ts | 23 ++ packages/prover/tests/runtime/constants.ts | 2 + packages/prover/tests/runtime/main.tsx | 83 ++++- 41 files changed, 1447 insertions(+), 58 deletions(-) create mode 100644 packages/prover/src/analyze-class-state-transitions.ts create mode 100644 packages/prover/src/collect-class-state-transitions.ts create mode 100644 packages/prover/tests/fixtures/class-impure-state-updater/src/app.tsx create mode 100644 packages/prover/tests/fixtures/class-impure-state-updater/tsconfig.json create mode 100644 packages/prover/tests/fixtures/class-update-loop/src/app.tsx create mode 100644 packages/prover/tests/fixtures/class-update-loop/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-class-destructured-prop-transition/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-class-destructured-prop-transition/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-class-nested-prop-transition/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-class-nested-prop-transition/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-class-number-prop-transition/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-class-number-prop-transition/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-class-opaque-state-updater/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-class-opaque-state-updater/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-class-update-callback/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-class-update-callback/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-pure-component-update/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-pure-component-update/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-class-compound-prop-transition/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-class-compound-prop-transition/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-class-number-literal-prop-transition/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-class-number-literal-prop-transition/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-class-prop-transition/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-class-prop-transition/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-class-pure-state-updater/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-class-pure-state-updater/tsconfig.json create mode 100644 packages/prover/tests/runtime/class-state-transition-oracle.spec.ts diff --git a/packages/prover/README.md b/packages/prover/README.md index 7ec65f9865..c2694b6924 100644 --- a/packages/prover/README.md +++ b/packages/prover/README.md @@ -53,8 +53,10 @@ The report includes: 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 lifecycle facts that certify symbol-resolved `Component` and `PureComponent` inheritance, - pure render callbacks, direct `componentDidMount`/`componentWillUnmount` ownership transitions, - exact stable method identities, and immutable primitive scheduler-handle fields; + pure render callbacks, direct `componentDidMount`/`componentDidUpdate`/ + `componentWillUnmount` ownership transitions, exact stable method identities, immutable + primitive scheduler-handle fields, pure `setState` updaters, and bounded prop-history update + guards; - 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. @@ -74,9 +76,11 @@ alternatives. Callable refs additionally require a source-complete `useLayoutEff 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 and unmount callbacks, reciprocal resource and scheduler links, and a -completeness flag derived exactly from every owned lifetime fact. Source-derived block invariants -and broader lifecycle transition certificates remain future work. Resource certificates +owner, phase-correct mount, update, and unmount callbacks, reciprocal resource, scheduler, and +state-transition links, and a completeness flag derived exactly from every owned fact. State +transition certificates independently check updater callback phase, guard evidence, convergence +classification, and exact completeness. Broader source-derived block invariants remain future +work. 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. diff --git a/packages/prover/research-log.md b/packages/prover/research-log.md index 7b5b51bc2b..54f7328271 100644 --- a/packages/prover/research-log.md +++ b/packages/prover/research-log.md @@ -716,8 +716,9 @@ Known regions that must force `incomplete` until modeled: - 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, update lifecycles, snapshots, error boundaries, refs, and - state-transition fixpoints outside direct render and mount/unmount ownership +- 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 @@ -738,13 +739,13 @@ components and hooks, including hooks hidden in incorrectly named helper functio 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 - lifecycle transition certificates. + 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: 210 TypeScript fixture projects, 399 static tests, and 30 Chromium runtime +Current checkpoint: 222 TypeScript fixture projects, 411 static tests, and 32 Chromium runtime oracles. - Vite Plus supplies package build and Vitest-compatible static tests. @@ -777,6 +778,9 @@ oracles. - 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. ## Effect resource lifetime certificates @@ -872,9 +876,9 @@ Added corpus: 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` and `componentWillUnmount` methods, -stable callback methods, and primitive scheduler-handle properties whose sole write is the -certified registration assignment. +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 @@ -891,11 +895,58 @@ lifecycle rule corpus and checked against the official React semantics above. Added corpus: -- proved: `class-component`, `proved-pure-class-render`, `proved-class-listener`, and - `proved-class-timeout` +- 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-lifecycle`, - `incomplete-class-helper-lifecycle`, `incomplete-class-listener-method-reassigned`, and - `incomplete-class-timeout-reassigned` +- 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` 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 0000000000..3149a52bec --- /dev/null +++ b/packages/prover/src/analyze-class-state-transitions.ts @@ -0,0 +1,106 @@ +import { createObligation } from "./create-obligation.js"; +import { findSemanticUnit } from "./find-semantic-unit.js"; +import { + ReactClassStateUpdaterStatus, + 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 violations: ReactProofEvidence[] = []; + const unknownEvidence: ReactProofEvidence[] = []; + 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, + "A class state transition violates updater purity or update convergence", + violations, + ); + } + if (!lifecycle?.sourceComplete || unknownEvidence.length > 0) { + return createObligation( + ReactProofClaim.ClassStateTransitions, + ReactObligationStatus.Unknown, + "Class state transition purity or convergence could not be proved", + unknownEvidence, + ); + } + return createObligation( + ReactProofClaim.ClassStateTransitions, + ReactObligationStatus.Proved, + "Every modeled class state updater is pure and every update transition is bounded", + ); +}; diff --git a/packages/prover/src/analyze-react-unit.ts b/packages/prover/src/analyze-react-unit.ts index 31b24a2415..6cc240782f 100644 --- a/packages/prover/src/analyze-react-unit.ts +++ b/packages/prover/src/analyze-react-unit.ts @@ -1,6 +1,7 @@ import { analyzeAsyncEffectOwnership } from "./analyze-async-effect-ownership.js"; import { analyzeBoundaryCoverage } from "./analyze-boundary-coverage.js"; import { analyzeCallableRefFreshness } from "./analyze-callable-ref-freshness.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"; @@ -27,6 +28,7 @@ const ALL_REACT_PROOF_CLAIMS: ReadonlyArray = [ ReactProofClaim.AsyncEffectOwnership, ReactProofClaim.BoundaryCoverage, ReactProofClaim.CallableRefFreshness, + ReactProofClaim.ClassStateTransitions, ReactProofClaim.ComponentIdentity, ReactProofClaim.ComponentInvocation, ReactProofClaim.ContextTopology, @@ -119,6 +121,7 @@ export const analyzeReactUnit = ( analyzeAsyncEffectOwnership(unit.functionNode, context), analyzeBoundaryCoverage(unit, context), analyzeCallableRefFreshness(unit, context), + analyzeClassStateTransitions(unit, context), analyzeComponentIdentity(unit.functionNode, context), analyzeComponentInvocation(unit.functionNode, context), analyzeContextTopology(unit, context), diff --git a/packages/prover/src/build-react-semantic-graph.ts b/packages/prover/src/build-react-semantic-graph.ts index 5e3ada7fc2..20e38ea486 100644 --- a/packages/prover/src/build-react-semantic-graph.ts +++ b/packages/prover/src/build-react-semantic-graph.ts @@ -1,5 +1,6 @@ import ts from "typescript"; import { collectAsyncEffectTaskDescriptors } from "./collect-async-effect-task-descriptors.js"; +import { collectClassStateTransitions } from "./collect-class-state-transitions.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"; @@ -43,6 +44,8 @@ import { mergeCallableBindings } from "./resolve-callable-expression.js"; import type { ResolvedCallableValueDescriptor } from "./resolve-callable-expression.js"; import { ReactCallableRefFreshness, + ReactClassStateUpdaterStatus, + ReactClassUpdateCycleStatus, ReactEffectDependencyMode, ReactExecutionPhase, ReactIdentityStability, @@ -65,6 +68,7 @@ import type { ReactSemanticCallbackPropFlow, ReactSemanticCallableRef, ReactSemanticClassLifecycle, + ReactSemanticClassStateTransition, ReactSemanticExternalStore, ReactSemanticFunctionCall, ReactSemanticGraph, @@ -97,6 +101,7 @@ interface EffectGraphFacts { interface ClassLifecycleGraphFacts { lifecycle: ReactSemanticClassLifecycle | null; + transitions: ReadonlyArray; schedulers: ReadonlyArray; resources: ReadonlyArray; callbacks: ReadonlyArray; @@ -1016,6 +1021,7 @@ const collectClassLifecycleGraph = ( if (identity.descriptor.kind !== ReactUnitKind.ClassComponent || !classNode || !renderMethod) { return { lifecycle: null, + transitions: [], schedulers: [], resources: [], callbacks: [], @@ -1025,6 +1031,7 @@ const collectClassLifecycleGraph = ( } const mountMethod = getClassMethodDeclaration(classNode, "componentDidMount"); const unmountMethod = getClassMethodDeclaration(classNode, "componentWillUnmount"); + const updateMethod = getClassMethodDeclaration(classNode, "componentDidUpdate"); const callbacks: ReactSemanticCallback[] = []; const reachableFunctions: ReactSemanticReachableFunction[] = []; const functionCalls: ReactSemanticFunctionCall[] = []; @@ -1063,6 +1070,12 @@ const collectClassLifecycleGraph = ( ReactExecutionPhase.ClassUnmount, "componentWillUnmount", ); + const updateCallback = createLifecycleCallback( + updateMethod, + ReactSemanticCallbackKind.ClassUpdate, + ReactExecutionPhase.ClassUpdate, + "componentDidUpdate", + ); const resourceProtocols = mountMethod ? collectLifecycleResourceProtocols( mountMethod, @@ -1079,6 +1092,85 @@ const collectClassLifecycleGraph = ( 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) { + 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) { @@ -1233,17 +1325,21 @@ const collectClassLifecycleGraph = ( 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, ...(mountMethod ? [mountMethod] : []), ...(unmountMethod ? [unmountMethod] : []), + ...(updateMethod ? [updateMethod] : []), ...[...resourceCallbackFunctions].filter(ts.isMethodDeclaration), ...[...schedulerCallbackFunctions].filter(ts.isMethodDeclaration), + ...[...transitionUpdaterFunctions].filter(ts.isMethodDeclaration), ...schedulerProtocols.flatMap((protocol) => protocol.handleDeclaration ? [protocol.handleDeclaration] : [], ), @@ -1265,14 +1361,18 @@ const collectClassLifecycleGraph = ( location: getNodeLocation(classNode, context.rootDirectory), mountCallbackId: mountCallback?.id ?? null, unmountCallbackId: unmountCallback?.id ?? null, + updateCallbackId: updateCallback?.id ?? null, resourceIds: resources.map((resource) => resource.id), schedulerIds: schedulers.map((scheduler) => scheduler.id), + transitionIds: transitions.map((transition) => transition.id), sourceComplete, complete: sourceComplete && resources.every((resource) => resource.complete) && - schedulers.every((scheduler) => scheduler.complete), + schedulers.every((scheduler) => scheduler.complete) && + transitions.every((transition) => transition.complete), }, + transitions, schedulers, resources, callbacks, @@ -1953,6 +2053,7 @@ export const buildReactSemanticGraph = ( id: createSemanticId("unit", descriptor.name, descriptor.node, context), name: descriptor.name, kind: descriptor.kind, + classComponentBase: descriptor.classComponentBase ?? null, location: getNodeLocation(descriptor.node, context.rootDirectory), sourceComplete: descriptor.sourceComplete, }, @@ -1976,6 +2077,7 @@ export const buildReactSemanticGraph = ( const schedulers: ReactSemanticScheduler[] = []; const resources: ReactSemanticEffectResource[] = []; const classLifecycles: ReactSemanticClassLifecycle[] = []; + const classStateTransitions: ReactSemanticClassStateTransition[] = []; const effectEvents: ReactSemanticEffectEvent[] = []; const externalStores: ReactSemanticExternalStore[] = []; const asyncTasks: ReactSemanticAsyncTask[] = []; @@ -2028,6 +2130,7 @@ export const buildReactSemanticGraph = ( if (classLifecycleGraph.lifecycle) { classLifecycles.push(classLifecycleGraph.lifecycle); } + classStateTransitions.push(...classLifecycleGraph.transitions); schedulers.push(...classLifecycleGraph.schedulers); resources.push(...classLifecycleGraph.resources); callbacks.push(...classLifecycleGraph.callbacks); @@ -2116,6 +2219,7 @@ export const buildReactSemanticGraph = ( schedulers, resources, classLifecycles, + classStateTransitions, 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 index 177d81387e..745d0fad3d 100644 --- a/packages/prover/src/check-react-proof-report.ts +++ b/packages/prover/src/check-react-proof-report.ts @@ -3,6 +3,9 @@ import { ReactAppProofStatus, ReactAsyncOwnershipStatus, ReactCallableRefFreshness, + ReactClassComponentBase, + ReactClassStateUpdaterStatus, + ReactClassUpdateCycleStatus, ReactEffectResourceDisposalStatus, ReactEffectResourceKind, ReactExecutionPhase, @@ -76,6 +79,34 @@ const expectedCallableRefFreshnessStatus = ( : 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, + ); + 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 || transitions.some((transition) => !transition.complete) + ? ReactObligationStatus.Unknown + : ReactObligationStatus.Proved; +}; + const expectedScheduledCallbackLifetimeStatus = ( unit: ReactSemanticUnit, report: ReactAppProofReport, @@ -180,6 +211,17 @@ const checkClaimCoverage = ( `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 scheduledCallbackLifetime = unitProof.obligations.find( (obligation) => obligation.claim === ReactProofClaim.ScheduledCallbackLifetime, ); @@ -227,6 +269,18 @@ const checkGraphReferences = ( ); const contextIds = new Set(report.graph.contexts.map((context) => context.id)); const providerIds = new Set(report.graph.contextProviders.map((provider) => provider.id)); + for (const unit of report.graph.units) { + 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 edge of report.graph.edges) { if (!unitIds.has(edge.sourceId)) { @@ -656,6 +710,80 @@ const checkGraphReferences = ( 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]), + ); + 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); @@ -688,6 +816,17 @@ const checkGraphReferences = ( ) { 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) { @@ -710,12 +849,29 @@ const checkGraphReferences = ( 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 expectedComplete = lifecycle.sourceComplete && lifecycleResources.length === lifecycle.resourceIds.length && lifecycleResources.every((resource) => resource.complete) && lifecycleSchedulers.length === lifecycle.schedulerIds.length && - lifecycleSchedulers.every((scheduler) => scheduler.complete); + lifecycleSchedulers.every((scheduler) => scheduler.complete) && + lifecycleTransitions.length === lifecycle.transitionIds.length && + lifecycleTransitions.every((transition) => transition.complete); if (lifecycle.complete !== expectedComplete) { addFailure( failures, @@ -751,6 +907,17 @@ const checkGraphReferences = ( 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 reachableFunction of report.graph.reachableFunctions) { if (!unitIds.has(reachableFunction.ownerId)) { addFailure(failures, reachableFunction.id, "A reachable function has an unknown owner unit"); @@ -1075,6 +1242,11 @@ export const checkReactProofReport = (report: ReactAppProofReport): ReactProofCe "Class lifecycles", report.graph.classLifecycles.map((lifecycle) => lifecycle.id), ); + checkUniqueIds( + failures, + "Class state transitions", + report.graph.classStateTransitions.map((transition) => transition.id), + ); checkUniqueIds( failures, "effects", 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 0000000000..6b65b46a76 --- /dev/null +++ b/packages/prover/src/collect-class-state-transitions.ts @@ -0,0 +1,331 @@ +import ts from "typescript"; +import { analyzeRenderPurity } from "./analyze-render-purity.js"; +import { isFunctionBoundary } from "./is-function-boundary.js"; +import { isNodeWithin } from "./is-node-within.js"; +import { resolveFunction } from "./resolve-function.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 { isEntryDominatingNode } from "./utils/is-entry-dominating-node.js"; +import { isDeferredCallbackSynchronous } from "./utils/is-deferred-callback-synchronous.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 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; +}; + +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, + ); + }), + ); +}; + +const getAccessMemberName = ( + 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; +}; + +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 = getAccessMemberName(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 updaterFunction = resolveFunction(unwrappedUpdater, context.typeChecker); + if (!updaterFunction) { + return { + updaterFunction: null, + updaterStatus: ReactClassStateUpdaterStatus.Unknown, + }; + } + if (updaterFunction.asteriskToken || !isDeferredCallbackSynchronous(updaterFunction, context)) { + return { + updaterFunction, + updaterStatus: ReactClassStateUpdaterStatus.Unknown, + }; + } + const purityProof = analyzeRenderPurity(updaterFunction, context); + let updaterStatus = ReactClassStateUpdaterStatus.Unknown; + if (purityProof.status === ReactObligationStatus.Proved) { + updaterStatus = ReactClassStateUpdaterStatus.Pure; + } else if (purityProof.status === ReactObligationStatus.Violated) { + updaterStatus = ReactClassStateUpdaterStatus.Impure; + } + return { + 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-react-units.ts b/packages/prover/src/collect-react-units.ts index 917bd53a77..bd3d284590 100644 --- a/packages/prover/src/collect-react-units.ts +++ b/packages/prover/src/collect-react-units.ts @@ -5,31 +5,34 @@ 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 { ReactUnitKind } from "./types.js"; +import { ReactClassComponentBase, ReactUnitKind } from "./types.js"; import { getClassMethodDeclaration } from "./utils/get-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 REACT_CLASS_BASE_NAMES = new Set(["Component", "PureComponent"]); - -const isReactComponentClass = ( +const getReactClassComponentBase = ( classNode: ts.ClassDeclaration, typeChecker: ts.TypeChecker, -): boolean => - Boolean( - classNode.heritageClauses?.some((heritageClause) => - heritageClause.types.some((heritageType) => - REACT_CLASS_BASE_NAMES.has( - getCanonicalReactApiName(heritageType.expression, 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([ "componentDidMount", + "componentDidUpdate", "componentWillUnmount", "render", ]); @@ -66,10 +69,9 @@ const hasSupportedClassSyntax = ( const methodName = getStaticPropertyName(member.name); if (!methodName || getClassMethodDeclaration(classNode, methodName) !== member) return false; if (isReservedClassLifecycleName(methodName)) { - return ( - SUPPORTED_CLASS_LIFECYCLE_NAMES.has(methodName) && - (methodName === "render" || member.parameters.length === 0) - ); + if (!SUPPORTED_CLASS_LIFECYCLE_NAMES.has(methodName)) return false; + if (methodName === "componentDidUpdate") return member.parameters.length <= 2; + return member.parameters.length === 0; } return true; }); @@ -140,16 +142,20 @@ export const collectReactUnits = ( if (isFunctionBoundary(node)) { const functionUnit = collectFunctionUnit(node, typeChecker); if (functionUnit) units.push(functionUnit); - } else if (ts.isClassDeclaration(node) && isReactComponentClass(node, typeChecker)) { - const renderMethod = getClassMethodDeclaration(node, "render"); - units.push({ - name: node.name?.text ?? "DefaultComponent", - kind: ReactUnitKind.ClassComponent, - node, - classNode: node, - functionNode: renderMethod ?? undefined, - sourceComplete: Boolean(renderMethod && hasSupportedClassSyntax(node, renderMethod)), - }); + } 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); }; diff --git a/packages/prover/src/constants.ts b/packages/prover/src/constants.ts index 335c24faac..e46c0d2158 100644 --- a/packages/prover/src/constants.ts +++ b/packages/prover/src/constants.ts @@ -1,7 +1,7 @@ import { ReactEffectResourceKind } from "./types.js"; -export const REACT_PROOF_SCHEMA_VERSION = 13; -export const REACT_SEMANTIC_GRAPH_SCHEMA_VERSION = 19; +export const REACT_PROOF_SCHEMA_VERSION = 14; +export const REACT_SEMANTIC_GRAPH_SCHEMA_VERSION = 20; 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; diff --git a/packages/prover/src/index.ts b/packages/prover/src/index.ts index b18ceabfc6..18c70297e9 100644 --- a/packages/prover/src/index.ts +++ b/packages/prover/src/index.ts @@ -4,6 +4,9 @@ export { ReactAppProofStatus, ReactAsyncOwnershipStatus, ReactCallableRefFreshness, + ReactClassComponentBase, + ReactClassStateUpdaterStatus, + ReactClassUpdateCycleStatus, ReactCompilerFactStatus, ReactEffectDependencyMode, ReactEffectResourceDisposalStatus, @@ -47,6 +50,7 @@ export type { ReactSemanticCallbackPropFlow, ReactSemanticCallableRef, ReactSemanticClassLifecycle, + ReactSemanticClassStateTransition, ReactSemanticExternalStore, ReactSemanticCallback, ReactSemanticAsyncTask, diff --git a/packages/prover/src/prove-react-app.ts b/packages/prover/src/prove-react-app.ts index 13d48eb784..baddd697cb 100644 --- a/packages/prover/src/prove-react-app.ts +++ b/packages/prover/src/prove-react-app.ts @@ -44,6 +44,7 @@ export const proveReactApp = (input: ProveReactAppInput): ReactAppProofReport => schedulers: [], resources: [], classLifecycles: [], + classStateTransitions: [], compiler: { version: REACT_COMPILER_VERSION, phase: REACT_COMPILER_FACT_PHASE, diff --git a/packages/prover/src/types.ts b/packages/prover/src/types.ts index 148e16523f..dd2a0eb94c 100644 --- a/packages/prover/src/types.ts +++ b/packages/prover/src/types.ts @@ -15,6 +15,7 @@ export enum ReactObligationStatus { export enum ReactProofClaim { BoundaryCoverage = "boundary-coverage", CallableRefFreshness = "callable-ref-freshness", + ClassStateTransitions = "class-state-transitions", ComponentIdentity = "component-identity", ComponentInvocation = "component-invocation", ContextTopology = "context-topology", @@ -41,6 +42,11 @@ export enum ReactUnitKind { InvalidHookOwner = "invalid-hook-owner", } +export enum ReactClassComponentBase { + Component = "Component", + PureComponent = "PureComponent", +} + export enum ReactSemanticEdgeKind { CallsHook = "calls-hook", RendersComponent = "renders-component", @@ -60,6 +66,7 @@ export enum ReactCompilerFactStatus { export enum ReactExecutionPhase { ClassMount = "class-mount", ClassUnmount = "class-unmount", + ClassUpdate = "class-update", Deferred = "deferred", EffectCleanup = "effect-cleanup", EffectEvent = "effect-event", @@ -73,7 +80,9 @@ export enum ReactExecutionPhase { export enum ReactSemanticCallbackKind { ClassMount = "class-mount", + ClassStateUpdater = "class-state-updater", ClassUnmount = "class-unmount", + ClassUpdate = "class-update", ComponentRender = "component-render", EffectCleanup = "effect-cleanup", EffectEvent = "effect-event", @@ -129,6 +138,7 @@ export interface ReactSemanticUnit { name: string; kind: ReactUnitKind; location: ReactProofLocation; + classComponentBase: ReactClassComponentBase | null; sourceComplete: boolean; } @@ -420,8 +430,40 @@ export interface ReactSemanticClassLifecycle { location: ReactProofLocation; mountCallbackId: string | null; unmountCallbackId: string | null; + updateCallbackId: string | null; resourceIds: ReadonlyArray; schedulerIds: ReadonlyArray; + transitionIds: ReadonlyArray; + 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 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; } @@ -487,6 +529,7 @@ export interface ReactSemanticGraph { schedulers: ReadonlyArray; resources: ReadonlyArray; classLifecycles: ReadonlyArray; + classStateTransitions: ReadonlyArray; compiler: ReactCompilerGraph; } @@ -518,6 +561,7 @@ export interface ReactUnitDescriptor { kind: ReactUnitKind; node: ts.Node; classNode?: ts.ClassDeclaration; + classComponentBase?: ReactClassComponentBase; functionNode?: ts.FunctionLikeDeclaration; invalidHookCalls?: ReadonlyArray; sourceComplete: boolean; 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 0000000000..726e7a0657 --- /dev/null +++ b/packages/prover/tests/fixtures/class-impure-state-updater/src/app.tsx @@ -0,0 +1,18 @@ +import { Component } from "react"; + +interface CounterState { + count: number; +} + +export class Counter extends Component, CounterState> { + 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 0000000000..d2a921ed01 --- /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-update-loop/src/app.tsx b/packages/prover/tests/fixtures/class-update-loop/src/app.tsx new file mode 100644 index 0000000000..8ff5916ccd --- /dev/null +++ b/packages/prover/tests/fixtures/class-update-loop/src/app.tsx @@ -0,0 +1,15 @@ +import { Component } from "react"; + +interface RevisionState { + revision: number; +} + +export class RevisionTracker extends Component, RevisionState> { + 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 0000000000..d2a921ed01 --- /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/incomplete-class-destructured-prop-transition/src/app.tsx b/packages/prover/tests/fixtures/incomplete-class-destructured-prop-transition/src/app.tsx new file mode 100644 index 0000000000..ea20a4722c --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-class-destructured-prop-transition/src/app.tsx @@ -0,0 +1,21 @@ +import { Component } from "react"; + +interface DraftProperties { + value: string; +} + +interface DraftState { + draft: string; +} + +export class DraftEditor extends Component { + componentDidUpdate({ value: previousValue }: DraftProperties) { + if (previousValue !== this.props.value) { + this.setState({ draft: this.props.value }); + } + } + + render() { + return null; + } +} diff --git a/packages/prover/tests/fixtures/incomplete-class-destructured-prop-transition/tsconfig.json b/packages/prover/tests/fixtures/incomplete-class-destructured-prop-transition/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-class-destructured-prop-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-class-nested-prop-transition/src/app.tsx b/packages/prover/tests/fixtures/incomplete-class-nested-prop-transition/src/app.tsx new file mode 100644 index 0000000000..2590ece058 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-class-nested-prop-transition/src/app.tsx @@ -0,0 +1,25 @@ +import { Component } from "react"; + +interface Model { + version: number; +} + +interface ModelProperties { + model: Model; +} + +interface ModelState { + observedVersion: number; +} + +export class ModelObserver extends Component { + componentDidUpdate(previousProperties: ModelProperties) { + if (previousProperties.model.version !== this.props.model.version) { + this.setState({ observedVersion: this.props.model.version }); + } + } + + render() { + return null; + } +} diff --git a/packages/prover/tests/fixtures/incomplete-class-nested-prop-transition/tsconfig.json b/packages/prover/tests/fixtures/incomplete-class-nested-prop-transition/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-class-nested-prop-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-class-number-prop-transition/src/app.tsx b/packages/prover/tests/fixtures/incomplete-class-number-prop-transition/src/app.tsx new file mode 100644 index 0000000000..6fc0d7da0a --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-class-number-prop-transition/src/app.tsx @@ -0,0 +1,21 @@ +import { Component } from "react"; + +interface GaugeProperties { + reading: number; +} + +interface GaugeState { + observedReading: number; +} + +export class Gauge extends Component { + componentDidUpdate(previousProperties: GaugeProperties) { + if (previousProperties.reading !== this.props.reading) { + this.setState({ observedReading: this.props.reading }); + } + } + + render() { + return null; + } +} diff --git a/packages/prover/tests/fixtures/incomplete-class-number-prop-transition/tsconfig.json b/packages/prover/tests/fixtures/incomplete-class-number-prop-transition/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-class-number-prop-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-class-opaque-state-updater/src/app.tsx b/packages/prover/tests/fixtures/incomplete-class-opaque-state-updater/src/app.tsx new file mode 100644 index 0000000000..448b0c790a --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-class-opaque-state-updater/src/app.tsx @@ -0,0 +1,17 @@ +import { Component } from "react"; + +interface CounterState { + count: number; +} + +declare const normalizeCounter: (state: Readonly) => CounterState; + +export class Counter extends Component, CounterState> { + componentDidMount() { + this.setState((previousState) => normalizeCounter(previousState)); + } + + render() { + return null; + } +} diff --git a/packages/prover/tests/fixtures/incomplete-class-opaque-state-updater/tsconfig.json b/packages/prover/tests/fixtures/incomplete-class-opaque-state-updater/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-class-opaque-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/incomplete-class-update-callback/src/app.tsx b/packages/prover/tests/fixtures/incomplete-class-update-callback/src/app.tsx new file mode 100644 index 0000000000..f92c3e82e2 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-class-update-callback/src/app.tsx @@ -0,0 +1,17 @@ +import { Component } from "react"; + +interface ReadyState { + ready: boolean; +} + +export class ReadyIndicator extends Component, ReadyState> { + componentDidMount() { + this.setState({ ready: true }, () => { + window.dispatchEvent(new Event("ready")); + }); + } + + render() { + return null; + } +} diff --git a/packages/prover/tests/fixtures/incomplete-class-update-callback/tsconfig.json b/packages/prover/tests/fixtures/incomplete-class-update-callback/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-class-update-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-pure-component-update/src/app.tsx b/packages/prover/tests/fixtures/incomplete-pure-component-update/src/app.tsx new file mode 100644 index 0000000000..91fb40b6a7 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-pure-component-update/src/app.tsx @@ -0,0 +1,15 @@ +import { PureComponent } from "react"; + +interface RevisionState { + revision: number; +} + +export class RevisionTracker extends PureComponent, RevisionState> { + componentDidUpdate() { + this.setState({ revision: 1 }); + } + + render() { + return null; + } +} diff --git a/packages/prover/tests/fixtures/incomplete-pure-component-update/tsconfig.json b/packages/prover/tests/fixtures/incomplete-pure-component-update/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-pure-component-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/proved-class-compound-prop-transition/src/app.tsx b/packages/prover/tests/fixtures/proved-class-compound-prop-transition/src/app.tsx new file mode 100644 index 0000000000..e9397770e1 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-class-compound-prop-transition/src/app.tsx @@ -0,0 +1,25 @@ +import { Component } from "react"; + +interface SearchProperties { + query: string; + scope: string; +} + +interface SearchState { + revision: number; +} + +export class SearchResults extends Component { + componentDidUpdate(previousProperties: SearchProperties) { + if ( + previousProperties.query !== this.props.query || + previousProperties.scope !== this.props.scope + ) { + this.setState({ revision: 1 }); + } + } + + render() { + return null; + } +} diff --git a/packages/prover/tests/fixtures/proved-class-compound-prop-transition/tsconfig.json b/packages/prover/tests/fixtures/proved-class-compound-prop-transition/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-class-compound-prop-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/proved-class-number-literal-prop-transition/src/app.tsx b/packages/prover/tests/fixtures/proved-class-number-literal-prop-transition/src/app.tsx new file mode 100644 index 0000000000..59ba8c6a2e --- /dev/null +++ b/packages/prover/tests/fixtures/proved-class-number-literal-prop-transition/src/app.tsx @@ -0,0 +1,21 @@ +import { Component } from "react"; + +interface StepProperties { + step: 0 | 1; +} + +interface StepState { + observedStep: 0 | 1; +} + +export class StepObserver extends Component { + componentDidUpdate(previousProperties: StepProperties) { + if (previousProperties.step !== this.props.step) { + this.setState({ observedStep: this.props.step }); + } + } + + render() { + return null; + } +} diff --git a/packages/prover/tests/fixtures/proved-class-number-literal-prop-transition/tsconfig.json b/packages/prover/tests/fixtures/proved-class-number-literal-prop-transition/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-class-number-literal-prop-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/proved-class-prop-transition/src/app.tsx b/packages/prover/tests/fixtures/proved-class-prop-transition/src/app.tsx new file mode 100644 index 0000000000..1463473c89 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-class-prop-transition/src/app.tsx @@ -0,0 +1,21 @@ +import { Component } from "react"; + +interface DraftProperties { + value: string; +} + +interface DraftState { + draft: string; +} + +export class DraftEditor extends Component { + componentDidUpdate(previousProperties: DraftProperties) { + if (previousProperties.value !== this.props.value) { + this.setState({ draft: this.props.value }); + } + } + + render() { + return {this.props.value}; + } +} diff --git a/packages/prover/tests/fixtures/proved-class-prop-transition/tsconfig.json b/packages/prover/tests/fixtures/proved-class-prop-transition/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-class-prop-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/proved-class-pure-state-updater/src/app.tsx b/packages/prover/tests/fixtures/proved-class-pure-state-updater/src/app.tsx new file mode 100644 index 0000000000..6da3459e34 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-class-pure-state-updater/src/app.tsx @@ -0,0 +1,15 @@ +import { Component } from "react"; + +interface CounterState { + count: number; +} + +export class Counter extends Component, CounterState> { + componentDidMount() { + this.setState((previousState) => ({ count: previousState.count + 1 })); + } + + render() { + return null; + } +} diff --git a/packages/prover/tests/fixtures/proved-class-pure-state-updater/tsconfig.json b/packages/prover/tests/fixtures/proved-class-pure-state-updater/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-class-pure-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/react-shim.d.ts b/packages/prover/tests/fixtures/react-shim.d.ts index efa21a3916..a23f035a15 100644 --- a/packages/prover/tests/fixtures/react-shim.d.ts +++ b/packages/prover/tests/fixtures/react-shim.d.ts @@ -51,9 +51,26 @@ declare module "react" { getServerSnapshot?: () => Snapshot, ) => Snapshot; - export class Component> { + export class Component, State = Record> { + constructor(properties: Properties); props: Properties; + state: State; + setState( + nextState: + | Partial + | null + | (( + previousState: Readonly, + properties: Readonly, + ) => Partial | State | null), + callback?: () => void, + ): void; } + + export class PureComponent< + Properties = Record, + State = Record, + > extends Component {} } declare module "react/jsx-runtime" { diff --git a/packages/prover/tests/prove-react-app.test.ts b/packages/prover/tests/prove-react-app.test.ts index d588f82605..bac4a11bfc 100644 --- a/packages/prover/tests/prove-react-app.test.ts +++ b/packages/prover/tests/prove-react-app.test.ts @@ -7,6 +7,9 @@ import { ReactAppProofStatus, ReactAsyncOwnershipStatus, ReactCallableRefFreshness, + ReactClassComponentBase, + ReactClassStateUpdaterStatus, + ReactClassUpdateCycleStatus, ReactCompilerFactStatus, ReactEffectDependencyMode, ReactEffectResourceDisposalStatus, @@ -37,6 +40,16 @@ const proveFixture = (fixtureName: string) => }); const REFUTED_FIXTURES: ReadonlyArray = [ + { + fixtureName: "class-update-loop", + claim: ReactProofClaim.ClassStateTransitions, + evidencePattern: /guarantees another update/, + }, + { + fixtureName: "class-impure-state-updater", + claim: ReactProofClaim.ClassStateTransitions, + evidencePattern: /observable side effect/, + }, { fixtureName: "conditional-hook", claim: ReactProofClaim.HookOrder, @@ -415,6 +428,10 @@ describe("proveReactApp", () => { "proved-pure-class-render", "proved-class-listener", "proved-class-timeout", + "proved-class-prop-transition", + "proved-class-pure-state-updater", + "proved-class-compound-prop-transition", + "proved-class-number-literal-prop-transition", ])("proves the complete %s application graph", (fixtureName) => { const report = proveFixture(fixtureName); @@ -473,7 +490,8 @@ describe("proveReactApp", () => { const report = proveFixture("proved-chat"); const effect = report.graph.effects[0]; - expect(report.graph.schemaVersion).toBe(19); + expect(report.schemaVersion).toBe(14); + expect(report.graph.schemaVersion).toBe(20); expect(effect?.hookName).toBe("useEffect"); expect(effect?.callbackResolved).toBe(true); expect(effect?.dependencyMode).toBe(ReactEffectDependencyMode.Inline); @@ -2352,11 +2370,151 @@ describe("proveReactApp", () => { expect(report.status).toBe(ReactAppProofStatus.Proved); expect(report.graph.units[0]?.kind).toBe("class-component"); + expect(report.graph.units[0]?.classComponentBase).toBe(ReactClassComponentBase.Component); expect(report.graph.units[0]?.sourceComplete).toBe(true); expect(renderCallback?.phase).toBe(ReactExecutionPhase.Render); expect(checkReactProofReport(report).status).toBe(ReactProofCertificateStatus.Valid); }); + it("certifies a prop-history guard as a bounded class update transition", () => { + const report = proveFixture("proved-class-prop-transition"); + const lifecycle = report.graph.classLifecycles[0]; + const transition = report.graph.classStateTransitions[0]; + const updateCallback = report.graph.callbacks.find( + (callback) => callback.id === lifecycle?.updateCallbackId, + ); + const transitionProof = report.units[0]?.obligations.find( + (obligation) => obligation.claim === ReactProofClaim.ClassStateTransitions, + ); + + expect(report.status).toBe(ReactAppProofStatus.Proved); + expect(transition?.phase).toBe(ReactExecutionPhase.ClassUpdate); + expect(transition?.updaterStatus).toBe(ReactClassStateUpdaterStatus.Object); + expect(transition?.cycleStatus).toBe(ReactClassUpdateCycleStatus.Bounded); + expect(transition?.guardLocations).toHaveLength(1); + expect(transition?.complete).toBe(true); + expect(lifecycle?.transitionIds).toEqual([transition?.id]); + expect(updateCallback?.kind).toBe(ReactSemanticCallbackKind.ClassUpdate); + expect(updateCallback?.phase).toBe(ReactExecutionPhase.ClassUpdate); + expect(transitionProof?.status).toBe(ReactObligationStatus.Proved); + expect(checkReactProofReport(report).status).toBe(ReactProofCertificateStatus.Valid); + }); + + it("certifies a pure setState updater in the state-transition phase", () => { + const report = proveFixture("proved-class-pure-state-updater"); + const transition = report.graph.classStateTransitions[0]; + const updaterCallback = report.graph.callbacks.find( + (callback) => callback.id === transition?.updaterCallbackId, + ); + + expect(report.status).toBe(ReactAppProofStatus.Proved); + expect(transition?.phase).toBe(ReactExecutionPhase.ClassMount); + expect(transition?.updaterStatus).toBe(ReactClassStateUpdaterStatus.Pure); + expect(transition?.cycleStatus).toBe(ReactClassUpdateCycleStatus.None); + expect(updaterCallback?.kind).toBe(ReactSemanticCallbackKind.ClassStateUpdater); + expect(updaterCallback?.phase).toBe(ReactExecutionPhase.StateTransition); + expect(checkReactProofReport(report).status).toBe(ReactProofCertificateStatus.Valid); + }); + + it("fails closed on PureComponent convergence, commit callbacks, and destructured guards", () => { + const pureComponentReport = proveFixture("incomplete-pure-component-update"); + const commitCallbackReport = proveFixture("incomplete-class-update-callback"); + const destructuredGuardReport = proveFixture("incomplete-class-destructured-prop-transition"); + const nestedGuardReport = proveFixture("incomplete-class-nested-prop-transition"); + const numberGuardReport = proveFixture("incomplete-class-number-prop-transition"); + const opaqueUpdaterReport = proveFixture("incomplete-class-opaque-state-updater"); + + expect(pureComponentReport.status).toBe(ReactAppProofStatus.Incomplete); + expect(pureComponentReport.graph.units[0]?.classComponentBase).toBe( + ReactClassComponentBase.PureComponent, + ); + expect(pureComponentReport.graph.classStateTransitions[0]?.cycleStatus).toBe( + ReactClassUpdateCycleStatus.Unknown, + ); + expect(commitCallbackReport.status).toBe(ReactAppProofStatus.Incomplete); + expect(commitCallbackReport.graph.classStateTransitions[0]?.commitCallbackProvided).toBe(true); + expect(commitCallbackReport.graph.classStateTransitions[0]?.sourceComplete).toBe(false); + expect(destructuredGuardReport.status).toBe(ReactAppProofStatus.Incomplete); + expect(destructuredGuardReport.graph.classStateTransitions[0]?.cycleStatus).toBe( + ReactClassUpdateCycleStatus.Unknown, + ); + expect(nestedGuardReport.status).toBe(ReactAppProofStatus.Incomplete); + expect(nestedGuardReport.graph.classStateTransitions[0]?.cycleStatus).toBe( + ReactClassUpdateCycleStatus.Unknown, + ); + expect(numberGuardReport.status).toBe(ReactAppProofStatus.Incomplete); + expect(numberGuardReport.graph.classStateTransitions[0]?.cycleStatus).toBe( + ReactClassUpdateCycleStatus.Unknown, + ); + expect(opaqueUpdaterReport.status).toBe(ReactAppProofStatus.Incomplete); + expect(opaqueUpdaterReport.graph.classStateTransitions[0]?.updaterStatus).toBe( + ReactClassStateUpdaterStatus.Unknown, + ); + expect(opaqueUpdaterReport.graph.classStateTransitions[0]?.updaterCallbackId).not.toBeNull(); + expect(checkReactProofReport(opaqueUpdaterReport).status).toBe( + ReactProofCertificateStatus.Valid, + ); + }); + + it("rejects a class transition certificate with a forged lifecycle link", () => { + const report = proveFixture("proved-class-prop-transition"); + const certificate = checkReactProofReport({ + ...report, + graph: { + ...report.graph, + classLifecycles: report.graph.classLifecycles.map((lifecycle) => ({ + ...lifecycle, + transitionIds: ["forged-transition"], + })), + }, + }); + + expect(certificate.status).toBe(ReactProofCertificateStatus.Invalid); + expect( + certificate.failures.some((failure) => + failure.description.includes("invalid state transition link"), + ), + ).toBe(true); + }); + + it("rejects a class transition certificate without its pure updater callback", () => { + const report = proveFixture("proved-class-pure-state-updater"); + const certificate = checkReactProofReport({ + ...report, + graph: { + ...report.graph, + classStateTransitions: report.graph.classStateTransitions.map((transition) => ({ + ...transition, + updaterCallbackId: null, + })), + }, + }); + + expect(certificate.status).toBe(ReactProofCertificateStatus.Invalid); + expect( + certificate.failures.some((failure) => failure.description.includes("invalid updater")), + ).toBe(true); + }); + + it("rejects a class transition certificate with contradictory guard facts", () => { + const report = proveFixture("proved-class-prop-transition"); + const certificate = checkReactProofReport({ + ...report, + graph: { + ...report.graph, + classStateTransitions: report.graph.classStateTransitions.map((transition) => ({ + ...transition, + cycleStatus: ReactClassUpdateCycleStatus.None, + })), + }, + }); + + expect(certificate.status).toBe(ReactProofCertificateStatus.Invalid); + expect( + certificate.failures.some((failure) => failure.description.includes("invalid guards")), + ).toBe(true); + }); + it("certifies a class mount-listener-unmount lifecycle with exact method identity", () => { const report = proveFixture("proved-class-listener"); const lifecycle = report.graph.classLifecycles[0]; @@ -2495,17 +2653,20 @@ describe("proveReactApp", () => { expect(renderProof?.evidence[0]?.description).toMatch(/not pure during render/); }); - it("fails closed for class fields and lifecycle methods until their phases are certified", () => { + it("fails closed for class fields while certifying an empty update lifecycle", () => { const fieldReport = proveFixture("incomplete-class-field"); const lifecycleReport = proveFixture("incomplete-class-lifecycle"); + const transitionProof = lifecycleReport.units[0]?.obligations.find( + (obligation) => obligation.claim === ReactProofClaim.ClassStateTransitions, + ); expect(fieldReport.status).toBe(ReactAppProofStatus.Incomplete); expect(fieldReport.graph.units[0]?.sourceComplete).toBe(false); - expect(lifecycleReport.status).toBe(ReactAppProofStatus.Incomplete); - expect(lifecycleReport.graph.units[0]?.sourceComplete).toBe(false); - expect(lifecycleReport.units[0]?.obligations[0]?.evidence[0]?.description).toMatch( - /constructor, field, lifecycle, ref, or custom method/, - ); + expect(lifecycleReport.status).toBe(ReactAppProofStatus.Proved); + expect(lifecycleReport.graph.units[0]?.sourceComplete).toBe(true); + expect(lifecycleReport.graph.classLifecycles[0]?.updateCallbackId).not.toBeNull(); + expect(lifecycleReport.graph.classStateTransitions).toEqual([]); + expect(transitionProof?.status).toBe(ReactObligationStatus.Proved); }); it("does not mistake a shadowed Component base for React inheritance", () => { diff --git a/packages/prover/tests/runtime/class-state-transition-oracle.spec.ts b/packages/prover/tests/runtime/class-state-transition-oracle.spec.ts new file mode 100644 index 0000000000..37ef3861d3 --- /dev/null +++ b/packages/prover/tests/runtime/class-state-transition-oracle.spec.ts @@ -0,0 +1,23 @@ +import { expect, test } from "@playwright/test"; + +test("a prop-history guard converges after one class state write", async ({ page }) => { + await page.goto("/?oracle=class-state-transition&mode=guarded"); + await page.getByRole("button", { name: "update class prop" }).click(); + + await expect(page.getByTestId("class-draft")).toHaveText("beta"); + await expect.poll(() => page.evaluate(() => window.classStateWrites)).toBe(1); + await expect.poll(() => page.evaluate(() => window.classStateUpdates)).toBe(2); +}); + +test("an unguarded componentDidUpdate state write reaches React's update-depth failure", async ({ + page, +}) => { + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.goto("/?oracle=class-state-transition&mode=loop"); + await page.getByRole("button", { name: "trigger class loop" }).click(); + + await expect + .poll(() => errors.some((error) => error.includes("Maximum update depth"))) + .toBe(true); +}); diff --git a/packages/prover/tests/runtime/constants.ts b/packages/prover/tests/runtime/constants.ts index 85c706323f..d614f1abf7 100644 --- a/packages/prover/tests/runtime/constants.ts +++ b/packages/prover/tests/runtime/constants.ts @@ -1,4 +1,6 @@ export const FAST_QUERY_DELAY_MS = 20; +export const CLASS_UPDATE_INITIAL_REVISION = 0; +export const CLASS_UPDATE_NEXT_REVISION = 1; export const INITIAL_CALLBACK_REVISION = 0; export const LATE_QUERY_SETTLE_WAIT_MS = 250; export const NEXT_CALLBACK_REVISION = 1; diff --git a/packages/prover/tests/runtime/main.tsx b/packages/prover/tests/runtime/main.tsx index 8d2c055bc3..6353cceb58 100644 --- a/packages/prover/tests/runtime/main.tsx +++ b/packages/prover/tests/runtime/main.tsx @@ -16,6 +16,8 @@ import type { ChangeEvent } from "react"; import { createRoot } from "react-dom/client"; import { FAST_QUERY_DELAY_MS, + CLASS_UPDATE_INITIAL_REVISION, + CLASS_UPDATE_NEXT_REVISION, INITIAL_CALLBACK_REVISION, NEXT_CALLBACK_REVISION, PRIMARY_STORE_INITIAL_VERSION, @@ -32,6 +34,8 @@ declare global { classListenerHits: number; classMounts: number; classSchedulerHits: number; + classStateUpdates: number; + classStateWrites: number; classUnmounts: number; listenerHits: number; observerHits: number; @@ -43,6 +47,8 @@ window.effectEventSetupRuns = 0; window.classListenerHits = 0; window.classMounts = 0; window.classSchedulerHits = 0; +window.classStateUpdates = 0; +window.classStateWrites = 0; window.classUnmounts = 0; window.listenerHits = 0; window.observerHits = 0; @@ -204,6 +210,75 @@ const ClassSchedulerOracle = () => { ); }; +interface ClassDraftProperties { + value: string; +} + +interface ClassDraftState { + draft: string; +} + +class GuardedClassDraft extends Component { + state = { draft: "alpha" }; + + componentDidUpdate(previousProperties: ClassDraftProperties) { + window.classStateUpdates += 1; + if (previousProperties.value !== this.props.value) { + window.classStateWrites += 1; + this.setState({ draft: this.props.value }); + } + } + + render() { + return {this.state.draft}; + } +} + +interface LoopState { + revision: number; +} + +interface LoopProperties { + triggerRevision: number; +} + +class UnguardedClassUpdate extends Component { + state = { revision: CLASS_UPDATE_INITIAL_REVISION }; + + componentDidUpdate() { + window.classStateUpdates += 1; + this.setState({ revision: CLASS_UPDATE_NEXT_REVISION }); + } + + render() { + return {this.state.revision}; + } +} + +const ClassStateTransitionOracle = () => { + const [value, setValue] = useState("alpha"); + const [loopRevision, setLoopRevision] = useState(CLASS_UPDATE_INITIAL_REVISION); + const isGuardedMode = new URLSearchParams(window.location.search).get("mode") === "guarded"; + if (isGuardedMode) { + return ( +
    + + +
    + ); + } + return ( +
    + + +
    + ); +}; + interface SchedulerProbeProperties { shouldCancel: boolean; } @@ -726,13 +801,19 @@ const RuntimeOracle = () => { if (oracle === "class-scheduler") { return ; } + if (oracle === "class-state-transition") { + return ; + } return ; }; const rootElement = document.getElementById("root"); if (!rootElement) throw new Error("Missing runtime oracle root"); const oracle = new URLSearchParams(window.location.search).get("oracle"); -const isClassLifecycleOracle = oracle === "class-listener" || oracle === "class-scheduler"; +const isClassLifecycleOracle = + oracle === "class-listener" || + oracle === "class-scheduler" || + oracle === "class-state-transition"; createRoot(rootElement).render( isClassLifecycleOracle ? ( From 7b3a20334121fcf359fd6f62632de28f00b27d9c Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Tue, 28 Jul 2026 16:26:30 +0000 Subject: [PATCH 08/23] feat(prover): certify class state ownership --- packages/prover/README.md | 15 +- packages/prover/research-log.md | 79 ++++- .../prover/src/analyze-boundary-coverage.ts | 4 +- .../src/analyze-class-state-transitions.ts | 26 +- .../src/analyze-external-store-consistency.ts | 6 +- packages/prover/src/analyze-render-purity.ts | 7 +- .../prover/src/build-react-semantic-graph.ts | 68 +++++ .../prover/src/check-react-proof-report.ts | 134 ++++++++- .../collect-async-effect-task-descriptors.ts | 4 +- .../src/collect-class-state-transitions.ts | 13 +- .../prover/src/collect-class-state-writes.ts | 284 ++++++++++++++++++ packages/prover/src/constants.ts | 12 +- packages/prover/src/index.ts | 3 + packages/prover/src/prove-react-app.ts | 1 + packages/prover/src/types.ts | 32 ++ .../utils/collect-property-symbol-writes.ts | 4 +- .../prover/src/utils/collect-symbol-writes.ts | 4 +- .../utils/get-static-access-member-name.ts | 11 + .../src/utils/is-assignment-operator.ts | 4 + .../class-deferred-state-mutation/src/app.tsx | 23 ++ .../tsconfig.json | 4 + .../class-direct-state-mutation/src/app.tsx | 15 + .../class-direct-state-mutation/tsconfig.json | 4 + .../class-state-mutating-call/src/app.tsx | 15 + .../class-state-mutating-call/tsconfig.json | 4 + .../class-state-mutation-forms/src/app.tsx | 23 ++ .../class-state-mutation-forms/tsconfig.json | 4 + .../class-unmount-state-mutation/src/app.tsx | 15 + .../tsconfig.json | 4 + .../src/app.tsx | 20 ++ .../tsconfig.json | 4 + .../incomplete-class-custom-push/src/app.tsx | 19 ++ .../tsconfig.json | 4 + .../incomplete-class-state-alias/src/app.tsx | 16 + .../tsconfig.json | 4 + .../src/app.tsx | 16 + .../tsconfig.json | 4 + .../src/app.tsx | 16 + .../tsconfig.json | 4 + packages/prover/tests/prove-react-app.test.ts | 130 +++++++- .../class-state-ownership-oracle.spec.ts | 13 + packages/prover/tests/runtime/main.tsx | 32 ++ 42 files changed, 1061 insertions(+), 43 deletions(-) create mode 100644 packages/prover/src/collect-class-state-writes.ts create mode 100644 packages/prover/src/utils/get-static-access-member-name.ts create mode 100644 packages/prover/src/utils/is-assignment-operator.ts create mode 100644 packages/prover/tests/fixtures/class-deferred-state-mutation/src/app.tsx create mode 100644 packages/prover/tests/fixtures/class-deferred-state-mutation/tsconfig.json create mode 100644 packages/prover/tests/fixtures/class-direct-state-mutation/src/app.tsx create mode 100644 packages/prover/tests/fixtures/class-direct-state-mutation/tsconfig.json create mode 100644 packages/prover/tests/fixtures/class-state-mutating-call/src/app.tsx create mode 100644 packages/prover/tests/fixtures/class-state-mutating-call/tsconfig.json create mode 100644 packages/prover/tests/fixtures/class-state-mutation-forms/src/app.tsx create mode 100644 packages/prover/tests/fixtures/class-state-mutation-forms/tsconfig.json create mode 100644 packages/prover/tests/fixtures/class-unmount-state-mutation/src/app.tsx create mode 100644 packages/prover/tests/fixtures/class-unmount-state-mutation/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-class-conditional-state-alias/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-class-conditional-state-alias/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-class-custom-push/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-class-custom-push/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-class-state-alias/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-class-state-alias/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-class-primitive-state-read/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-class-primitive-state-read/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-class-state-computed-key-read/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-class-state-computed-key-read/tsconfig.json create mode 100644 packages/prover/tests/runtime/class-state-ownership-oracle.spec.ts diff --git a/packages/prover/README.md b/packages/prover/README.md index c2694b6924..14ca8e636f 100644 --- a/packages/prover/README.md +++ b/packages/prover/README.md @@ -55,8 +55,10 @@ The report includes: - 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, pure `setState` updaters, and bounded prop-history update - guards; + 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; - 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. @@ -77,10 +79,11 @@ concrete event callback, and a `ref.current` call edge. Scheduler certificates r 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-transition links, and a completeness flag derived exactly from every owned fact. State -transition certificates independently check updater callback phase, guard evidence, convergence -classification, and exact completeness. Broader source-derived block invariants remain future -work. Resource certificates +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. 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. diff --git a/packages/prover/research-log.md b/packages/prover/research-log.md index 54f7328271..369479cc84 100644 --- a/packages/prover/research-log.md +++ b/packages/prover/research-log.md @@ -745,7 +745,7 @@ components and hooks, including hooks hidden in incorrectly named helper functio ### Test stack -Current checkpoint: 222 TypeScript fixture projects, 411 static tests, and 32 Chromium runtime +Current checkpoint: 232 TypeScript fixture projects, 423 static tests, and 33 Chromium runtime oracles. - Vite Plus supplies package build and Vitest-compatible static tests. @@ -950,3 +950,80 @@ Added corpus: `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. diff --git a/packages/prover/src/analyze-boundary-coverage.ts b/packages/prover/src/analyze-boundary-coverage.ts index 02f93aa951..e63a84eea6 100644 --- a/packages/prover/src/analyze-boundary-coverage.ts +++ b/packages/prover/src/analyze-boundary-coverage.ts @@ -29,6 +29,7 @@ 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 { @@ -393,8 +394,7 @@ export const analyzeBoundaryCoverage = ( } if ( ts.isBinaryExpression(node) && - node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && - node.operatorToken.kind <= ts.SyntaxKind.LastAssignment && + isAssignmentOperator(node.operatorToken.kind) && ts.isPropertyAccessExpression(node.left) && !isCompleteCallableRefAccess(node.left) && doesTypeContainCallable(context.typeChecker.getTypeAtLocation(node.left), context.typeChecker) diff --git a/packages/prover/src/analyze-class-state-transitions.ts b/packages/prover/src/analyze-class-state-transitions.ts index 3149a52bec..7f30c6c6ff 100644 --- a/packages/prover/src/analyze-class-state-transitions.ts +++ b/packages/prover/src/analyze-class-state-transitions.ts @@ -2,6 +2,7 @@ import { createObligation } from "./create-obligation.js"; import { findSemanticUnit } from "./find-semantic-unit.js"; import { ReactClassStateUpdaterStatus, + ReactClassStateWriteStatus, ReactClassUpdateCycleStatus, ReactObligationStatus, ReactProofClaim, @@ -39,8 +40,27 @@ export const analyzeClassStateTransitions = ( 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, @@ -86,7 +106,7 @@ export const analyzeClassStateTransitions = ( return createObligation( ReactProofClaim.ClassStateTransitions, ReactObligationStatus.Violated, - "A class state transition violates updater purity or update convergence", + "Class state ownership, updater purity, or update convergence is violated", violations, ); } @@ -94,13 +114,13 @@ export const analyzeClassStateTransitions = ( return createObligation( ReactProofClaim.ClassStateTransitions, ReactObligationStatus.Unknown, - "Class state transition purity or convergence could not be proved", + "Class state ownership, transition purity, or convergence could not be proved", unknownEvidence, ); } return createObligation( ReactProofClaim.ClassStateTransitions, ReactObligationStatus.Proved, - "Every modeled class state updater is pure and every update transition is bounded", + "Class state is React-owned, every updater is pure, and every update transition is bounded", ); }; diff --git a/packages/prover/src/analyze-external-store-consistency.ts b/packages/prover/src/analyze-external-store-consistency.ts index ce0b7b62fc..3f1ed0fc0a 100644 --- a/packages/prover/src/analyze-external-store-consistency.ts +++ b/packages/prover/src/analyze-external-store-consistency.ts @@ -12,6 +12,7 @@ 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 { isAssignmentOperator } from "./utils/is-assignment-operator.js"; import type { ReactAnalysisContext, ReactProofEvidence, @@ -212,10 +213,7 @@ const isStablePrimitiveExpression = ( return isStablePrimitiveExpression(unwrappedExpression.operand, typeChecker); } if (ts.isBinaryExpression(unwrappedExpression)) { - if ( - unwrappedExpression.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && - unwrappedExpression.operatorToken.kind <= ts.SyntaxKind.LastAssignment - ) { + if (isAssignmentOperator(unwrappedExpression.operatorToken.kind)) { return false; } return ( diff --git a/packages/prover/src/analyze-render-purity.ts b/packages/prover/src/analyze-render-purity.ts index 0b74c4574c..4ea77d44df 100644 --- a/packages/prover/src/analyze-render-purity.ts +++ b/packages/prover/src/analyze-render-purity.ts @@ -20,6 +20,7 @@ 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([ @@ -110,11 +111,7 @@ export const analyzeRenderPurity = ( if (node !== currentFunction && isFunctionBoundary(node)) { return; } - if ( - ts.isBinaryExpression(node) && - node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && - node.operatorToken.kind <= ts.SyntaxKind.LastAssignment - ) { + if (ts.isBinaryExpression(node) && isAssignmentOperator(node.operatorToken.kind)) { if (isProtectedMutation(node.left, currentFunction, context, protectedSymbols)) { violations.push( createEvidence( diff --git a/packages/prover/src/build-react-semantic-graph.ts b/packages/prover/src/build-react-semantic-graph.ts index 20e38ea486..6342996049 100644 --- a/packages/prover/src/build-react-semantic-graph.ts +++ b/packages/prover/src/build-react-semantic-graph.ts @@ -1,6 +1,8 @@ import ts from "typescript"; import { collectAsyncEffectTaskDescriptors } from "./collect-async-effect-task-descriptors.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"; @@ -45,6 +47,7 @@ import type { ResolvedCallableValueDescriptor } from "./resolve-callable-express import { ReactCallableRefFreshness, ReactClassStateUpdaterStatus, + ReactClassStateWriteStatus, ReactClassUpdateCycleStatus, ReactEffectDependencyMode, ReactExecutionPhase, @@ -68,6 +71,7 @@ import type { ReactSemanticCallbackPropFlow, ReactSemanticCallableRef, ReactSemanticClassLifecycle, + ReactSemanticClassStateWrite, ReactSemanticClassStateTransition, ReactSemanticExternalStore, ReactSemanticFunctionCall, @@ -101,6 +105,7 @@ interface EffectGraphFacts { interface ClassLifecycleGraphFacts { lifecycle: ReactSemanticClassLifecycle | null; + stateWrites: ReadonlyArray; transitions: ReadonlyArray; schedulers: ReadonlyArray; resources: ReadonlyArray; @@ -1021,6 +1026,7 @@ const collectClassLifecycleGraph = ( if (identity.descriptor.kind !== ReactUnitKind.ClassComponent || !classNode || !renderMethod) { return { lifecycle: null, + stateWrites: [], transitions: [], schedulers: [], resources: [], @@ -1076,6 +1082,28 @@ const collectClassLifecycleGraph = ( 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, @@ -1134,6 +1162,11 @@ const collectClassLifecycleGraph = ( } : 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( @@ -1209,6 +1242,11 @@ const collectClassLifecycleGraph = ( } : null; if (identifiedResourceCallback && callbackFunction) { + stateWriteRoots.push({ + callbackId: identifiedResourceCallback.id, + functionNode: callbackFunction, + phase: ReactExecutionPhase.Deferred, + }); callbacks.push(identifiedResourceCallback); const reachabilityFacts = collectReachabilityGraphFacts( identity, @@ -1283,6 +1321,11 @@ const collectClassLifecycleGraph = ( } : null; if (identifiedSchedulerCallback && callbackFunction) { + stateWriteRoots.push({ + callbackId: identifiedSchedulerCallback.id, + functionNode: callbackFunction, + phase: ReactExecutionPhase.Deferred, + }); callbacks.push(identifiedSchedulerCallback); const reachabilityFacts = collectReachabilityGraphFacts( identity, @@ -1316,6 +1359,25 @@ const collectClassLifecycleGraph = ( 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), @@ -1364,14 +1426,17 @@ const collectClassLifecycleGraph = ( 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 && resources.every((resource) => resource.complete) && schedulers.every((scheduler) => scheduler.complete) && + stateWrites.every((stateWrite) => stateWrite.complete) && transitions.every((transition) => transition.complete), }, + stateWrites, transitions, schedulers, resources, @@ -2077,6 +2142,7 @@ export const buildReactSemanticGraph = ( const schedulers: ReactSemanticScheduler[] = []; const resources: ReactSemanticEffectResource[] = []; const classLifecycles: ReactSemanticClassLifecycle[] = []; + const classStateWrites: ReactSemanticClassStateWrite[] = []; const classStateTransitions: ReactSemanticClassStateTransition[] = []; const effectEvents: ReactSemanticEffectEvent[] = []; const externalStores: ReactSemanticExternalStore[] = []; @@ -2130,6 +2196,7 @@ export const buildReactSemanticGraph = ( if (classLifecycleGraph.lifecycle) { classLifecycles.push(classLifecycleGraph.lifecycle); } + classStateWrites.push(...classLifecycleGraph.stateWrites); classStateTransitions.push(...classLifecycleGraph.transitions); schedulers.push(...classLifecycleGraph.schedulers); resources.push(...classLifecycleGraph.resources); @@ -2219,6 +2286,7 @@ export const buildReactSemanticGraph = ( schedulers, resources, classLifecycles, + classStateWrites, classStateTransitions, 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 index 745d0fad3d..1e8298735b 100644 --- a/packages/prover/src/check-react-proof-report.ts +++ b/packages/prover/src/check-react-proof-report.ts @@ -5,6 +5,8 @@ import { ReactCallableRefFreshness, ReactClassComponentBase, ReactClassStateUpdaterStatus, + ReactClassStateWriteKind, + ReactClassStateWriteStatus, ReactClassUpdateCycleStatus, ReactEffectResourceDisposalStatus, ReactEffectResourceKind, @@ -92,6 +94,14 @@ const expectedClassStateTransitionStatus = ( 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) => @@ -102,7 +112,9 @@ const expectedClassStateTransitionStatus = ( return ReactObligationStatus.Violated; } const lifecycle = report.graph.classLifecycles.find((candidate) => candidate.ownerId === unit.id); - return !lifecycle?.sourceComplete || transitions.some((transition) => !transition.complete) + return !lifecycle?.sourceComplete || + stateWrites.some((stateWrite) => !stateWrite.complete) || + transitions.some((transition) => !transition.complete) ? ReactObligationStatus.Unknown : ReactObligationStatus.Proved; }; @@ -713,6 +725,97 @@ const checkGraphReferences = ( const transitionsById = new Map( report.graph.classStateTransitions.map((transition) => [transition.id, transition]), ); + const stateWritesById = new Map( + report.graph.classStateWrites.map((stateWrite) => [stateWrite.id, stateWrite]), + ); + 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) { @@ -864,12 +967,25 @@ const checkGraphReferences = ( 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 && 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) { @@ -918,6 +1034,17 @@ const checkGraphReferences = ( 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 reachableFunction of report.graph.reachableFunctions) { if (!unitIds.has(reachableFunction.ownerId)) { addFailure(failures, reachableFunction.id, "A reachable function has an unknown owner unit"); @@ -1247,6 +1374,11 @@ export const checkReactProofReport = (report: ReactAppProofReport): ReactProofCe "Class state transitions", report.graph.classStateTransitions.map((transition) => transition.id), ); + checkUniqueIds( + failures, + "Class state writes", + report.graph.classStateWrites.map((stateWrite) => stateWrite.id), + ); checkUniqueIds( failures, "effects", diff --git a/packages/prover/src/collect-async-effect-task-descriptors.ts b/packages/prover/src/collect-async-effect-task-descriptors.ts index 92b05cd09f..f0b9a9db82 100644 --- a/packages/prover/src/collect-async-effect-task-descriptors.ts +++ b/packages/prover/src/collect-async-effect-task-descriptors.ts @@ -10,6 +10,7 @@ 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 { @@ -314,8 +315,7 @@ const collectAsyncTaskOperations = ( if ( !unknownOperation && ts.isBinaryExpression(node) && - node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && - node.operatorToken.kind <= ts.SyntaxKind.LastAssignment && + isAssignmentOperator(node.operatorToken.kind) && (startsAfterSuspension || hasSequentialAwaitBefore(node, taskFunction)) ) { unknownOperation = node; diff --git a/packages/prover/src/collect-class-state-transitions.ts b/packages/prover/src/collect-class-state-transitions.ts index 6b65b46a76..4685aa8027 100644 --- a/packages/prover/src/collect-class-state-transitions.ts +++ b/packages/prover/src/collect-class-state-transitions.ts @@ -13,6 +13,7 @@ import { 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 { isDeferredCallbackSynchronous } from "./utils/is-deferred-callback-synchronous.js"; @@ -67,16 +68,6 @@ const isReactSetStateCall = ( ); }; -const getAccessMemberName = ( - 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; -}; - const getStateSourcePath = ( expression: ts.Expression, previousPropsSymbol: ts.Symbol, @@ -88,7 +79,7 @@ const getStateSourcePath = ( ts.isPropertyAccessExpression(currentExpression) || ts.isElementAccessExpression(currentExpression) ) { - const memberName = getAccessMemberName(currentExpression); + const memberName = getStaticAccessMemberName(currentExpression); if (!memberName) return null; members.unshift(memberName); currentExpression = unwrapTypescriptExpression(currentExpression.expression); 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 0000000000..5b82a849a0 --- /dev/null +++ b/packages/prover/src/collect-class-state-writes.ts @@ -0,0 +1,284 @@ +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 { getStaticAccessMemberName } from "./utils/get-static-access-member-name.js"; +import { getStaticPropertyName } from "./utils/get-static-property-name.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 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"; +}; + +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/constants.ts b/packages/prover/src/constants.ts index e46c0d2158..7577643a34 100644 --- a/packages/prover/src/constants.ts +++ b/packages/prover/src/constants.ts @@ -1,7 +1,7 @@ import { ReactEffectResourceKind } from "./types.js"; -export const REACT_PROOF_SCHEMA_VERSION = 14; -export const REACT_SEMANTIC_GRAPH_SCHEMA_VERSION = 20; +export const REACT_PROOF_SCHEMA_VERSION = 15; +export const REACT_SEMANTIC_GRAPH_SCHEMA_VERSION = 21; 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; @@ -131,6 +131,14 @@ export const MUTATING_METHOD_NAMES = new Set([ "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", diff --git a/packages/prover/src/index.ts b/packages/prover/src/index.ts index 18c70297e9..5b227d8236 100644 --- a/packages/prover/src/index.ts +++ b/packages/prover/src/index.ts @@ -6,6 +6,8 @@ export { ReactCallableRefFreshness, ReactClassComponentBase, ReactClassStateUpdaterStatus, + ReactClassStateWriteKind, + ReactClassStateWriteStatus, ReactClassUpdateCycleStatus, ReactCompilerFactStatus, ReactEffectDependencyMode, @@ -50,6 +52,7 @@ export type { ReactSemanticCallbackPropFlow, ReactSemanticCallableRef, ReactSemanticClassLifecycle, + ReactSemanticClassStateWrite, ReactSemanticClassStateTransition, ReactSemanticExternalStore, ReactSemanticCallback, diff --git a/packages/prover/src/prove-react-app.ts b/packages/prover/src/prove-react-app.ts index baddd697cb..4b3d52d681 100644 --- a/packages/prover/src/prove-react-app.ts +++ b/packages/prover/src/prove-react-app.ts @@ -44,6 +44,7 @@ export const proveReactApp = (input: ProveReactAppInput): ReactAppProofReport => schedulers: [], resources: [], classLifecycles: [], + classStateWrites: [], classStateTransitions: [], compiler: { version: REACT_COMPILER_VERSION, diff --git a/packages/prover/src/types.ts b/packages/prover/src/types.ts index dd2a0eb94c..39f485ac85 100644 --- a/packages/prover/src/types.ts +++ b/packages/prover/src/types.ts @@ -433,6 +433,7 @@ export interface ReactSemanticClassLifecycle { updateCallbackId: string | null; resourceIds: ReadonlyArray; schedulerIds: ReadonlyArray; + stateWriteIds: ReadonlyArray; transitionIds: ReadonlyArray; sourceComplete: boolean; complete: boolean; @@ -453,6 +454,36 @@ export enum ReactClassUpdateCycleStatus { 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; @@ -529,6 +560,7 @@ export interface ReactSemanticGraph { schedulers: ReadonlyArray; resources: ReadonlyArray; classLifecycles: ReadonlyArray; + classStateWrites: ReadonlyArray; classStateTransitions: ReadonlyArray; compiler: ReactCompilerGraph; } diff --git a/packages/prover/src/utils/collect-property-symbol-writes.ts b/packages/prover/src/utils/collect-property-symbol-writes.ts index 5d464fda91..cad5fcc895 100644 --- a/packages/prover/src/utils/collect-property-symbol-writes.ts +++ b/packages/prover/src/utils/collect-property-symbol-writes.ts @@ -1,5 +1,6 @@ import ts from "typescript"; import { getResolvedSymbol } from "./get-resolved-symbol.js"; +import { isAssignmentOperator } from "./is-assignment-operator.js"; export const collectPropertySymbolWrites = ( symbol: ts.Symbol, @@ -28,8 +29,7 @@ export const collectPropertySymbolWrites = ( const visit = (node: ts.Node): void => { if ( ts.isBinaryExpression(node) && - node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && - node.operatorToken.kind <= ts.SyntaxKind.LastAssignment && + isAssignmentOperator(node.operatorToken.kind) && isPropertyTarget(node.left) ) { writes.push(node); diff --git a/packages/prover/src/utils/collect-symbol-writes.ts b/packages/prover/src/utils/collect-symbol-writes.ts index 53d2ca54c9..94c9ee231c 100644 --- a/packages/prover/src/utils/collect-symbol-writes.ts +++ b/packages/prover/src/utils/collect-symbol-writes.ts @@ -1,4 +1,5 @@ import ts from "typescript"; +import { isAssignmentOperator } from "./is-assignment-operator.js"; export const collectSymbolWrites = ( symbol: ts.Symbol, @@ -46,8 +47,7 @@ export const collectSymbolWrites = ( const visit = (node: ts.Node): void => { if ( ts.isBinaryExpression(node) && - node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && - node.operatorToken.kind <= ts.SyntaxKind.LastAssignment && + isAssignmentOperator(node.operatorToken.kind) && isSymbolWriteTarget(node.left) ) { writes.push(node); 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 0000000000..b213850080 --- /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/is-assignment-operator.ts b/packages/prover/src/utils/is-assignment-operator.ts new file mode 100644 index 0000000000..027bbb1b90 --- /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/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 0000000000..4f5a174275 --- /dev/null +++ b/packages/prover/tests/fixtures/class-deferred-state-mutation/src/app.tsx @@ -0,0 +1,23 @@ +import { Component } from "react"; + +interface ListenerState { + resizeCount: number; +} + +export class ResizeListener extends Component, ListenerState> { + 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 0000000000..d2a921ed01 --- /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 0000000000..3443cab584 --- /dev/null +++ b/packages/prover/tests/fixtures/class-direct-state-mutation/src/app.tsx @@ -0,0 +1,15 @@ +import { Component } from "react"; + +interface CounterState { + count: number; +} + +export class Counter extends Component, CounterState> { + 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 0000000000..d2a921ed01 --- /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-state-mutating-call/src/app.tsx b/packages/prover/tests/fixtures/class-state-mutating-call/src/app.tsx new file mode 100644 index 0000000000..693866325a --- /dev/null +++ b/packages/prover/tests/fixtures/class-state-mutating-call/src/app.tsx @@ -0,0 +1,15 @@ +import { Component } from "react"; + +interface QueueState { + items: string[]; +} + +export class Queue extends Component, QueueState> { + 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 0000000000..d2a921ed01 --- /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 0000000000..a2217c183e --- /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 0000000000..d2a921ed01 --- /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-unmount-state-mutation/src/app.tsx b/packages/prover/tests/fixtures/class-unmount-state-mutation/src/app.tsx new file mode 100644 index 0000000000..485a581b8a --- /dev/null +++ b/packages/prover/tests/fixtures/class-unmount-state-mutation/src/app.tsx @@ -0,0 +1,15 @@ +import { Component } from "react"; + +interface ConnectionState { + connected: boolean; +} + +export class Connection extends Component, ConnectionState> { + 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 0000000000..d2a921ed01 --- /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/incomplete-class-conditional-state-alias/src/app.tsx b/packages/prover/tests/fixtures/incomplete-class-conditional-state-alias/src/app.tsx new file mode 100644 index 0000000000..1680a3f417 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-class-conditional-state-alias/src/app.tsx @@ -0,0 +1,20 @@ +import { Component } from "react"; + +interface CounterProperties { + enabled: boolean; +} + +interface CounterState { + count: number; +} + +export class Counter extends Component { + componentDidMount() { + const stateAlias = this.props.enabled ? this.state : { count: 0 }; + stateAlias.count = 1; + } + + render() { + return {this.state.count}; + } +} diff --git a/packages/prover/tests/fixtures/incomplete-class-conditional-state-alias/tsconfig.json b/packages/prover/tests/fixtures/incomplete-class-conditional-state-alias/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-class-conditional-state-alias/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-class-custom-push/src/app.tsx b/packages/prover/tests/fixtures/incomplete-class-custom-push/src/app.tsx new file mode 100644 index 0000000000..33de05a073 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-class-custom-push/src/app.tsx @@ -0,0 +1,19 @@ +import { Component } from "react"; + +interface PersistentQueue { + push(value: string): PersistentQueue; +} + +interface QueueState { + queue: PersistentQueue; +} + +export class Queue extends Component, QueueState> { + componentDidMount() { + this.state.queue.push("queued"); + } + + render() { + return null; + } +} diff --git a/packages/prover/tests/fixtures/incomplete-class-custom-push/tsconfig.json b/packages/prover/tests/fixtures/incomplete-class-custom-push/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-class-custom-push/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-class-state-alias/src/app.tsx b/packages/prover/tests/fixtures/incomplete-class-state-alias/src/app.tsx new file mode 100644 index 0000000000..66b77ee75a --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-class-state-alias/src/app.tsx @@ -0,0 +1,16 @@ +import { Component } from "react"; + +interface CounterState { + count: number; +} + +export class Counter extends Component, CounterState> { + componentDidMount() { + const stateAlias = this.state; + stateAlias.count = 1; + } + + render() { + return {this.state.count}; + } +} diff --git a/packages/prover/tests/fixtures/incomplete-class-state-alias/tsconfig.json b/packages/prover/tests/fixtures/incomplete-class-state-alias/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-class-state-alias/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-class-primitive-state-read/src/app.tsx b/packages/prover/tests/fixtures/proved-class-primitive-state-read/src/app.tsx new file mode 100644 index 0000000000..d35a7a1051 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-class-primitive-state-read/src/app.tsx @@ -0,0 +1,16 @@ +import { Component } from "react"; + +interface CounterState { + count: number; +} + +export class Counter extends Component, CounterState> { + componentDidMount() { + const count = this.state.count; + if (count < 0) this.setState(null); + } + + render() { + return {this.state.count}; + } +} diff --git a/packages/prover/tests/fixtures/proved-class-primitive-state-read/tsconfig.json b/packages/prover/tests/fixtures/proved-class-primitive-state-read/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-class-primitive-state-read/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-class-state-computed-key-read/src/app.tsx b/packages/prover/tests/fixtures/proved-class-state-computed-key-read/src/app.tsx new file mode 100644 index 0000000000..f904887b4f --- /dev/null +++ b/packages/prover/tests/fixtures/proved-class-state-computed-key-read/src/app.tsx @@ -0,0 +1,16 @@ +import { Component } from "react"; + +interface LookupState { + key: string; +} + +export class Lookup extends Component, LookupState> { + componentDidMount() { + const values: Record = {}; + values[this.state.key] = 1; + } + + render() { + return null; + } +} diff --git a/packages/prover/tests/fixtures/proved-class-state-computed-key-read/tsconfig.json b/packages/prover/tests/fixtures/proved-class-state-computed-key-read/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-class-state-computed-key-read/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/prove-react-app.test.ts b/packages/prover/tests/prove-react-app.test.ts index bac4a11bfc..9f75ff7436 100644 --- a/packages/prover/tests/prove-react-app.test.ts +++ b/packages/prover/tests/prove-react-app.test.ts @@ -9,6 +9,8 @@ import { ReactCallableRefFreshness, ReactClassComponentBase, ReactClassStateUpdaterStatus, + ReactClassStateWriteKind, + ReactClassStateWriteStatus, ReactClassUpdateCycleStatus, ReactCompilerFactStatus, ReactEffectDependencyMode, @@ -40,6 +42,21 @@ const proveFixture = (fixtureName: string) => }); const REFUTED_FIXTURES: ReadonlyArray = [ + { + fixtureName: "class-direct-state-mutation", + claim: ReactProofClaim.ClassStateTransitions, + evidencePattern: /mutated directly outside construction/, + }, + { + fixtureName: "class-state-mutating-call", + claim: ReactProofClaim.ClassStateTransitions, + evidencePattern: /mutated directly outside construction/, + }, + { + fixtureName: "class-unmount-state-mutation", + claim: ReactProofClaim.ClassStateTransitions, + evidencePattern: /mutated directly outside construction/, + }, { fixtureName: "class-update-loop", claim: ReactProofClaim.ClassStateTransitions, @@ -430,6 +447,8 @@ describe("proveReactApp", () => { "proved-class-timeout", "proved-class-prop-transition", "proved-class-pure-state-updater", + "proved-class-primitive-state-read", + "proved-class-state-computed-key-read", "proved-class-compound-prop-transition", "proved-class-number-literal-prop-transition", ])("proves the complete %s application graph", (fixtureName) => { @@ -490,8 +509,8 @@ describe("proveReactApp", () => { const report = proveFixture("proved-chat"); const effect = report.graph.effects[0]; - expect(report.schemaVersion).toBe(14); - expect(report.graph.schemaVersion).toBe(20); + expect(report.schemaVersion).toBe(15); + expect(report.graph.schemaVersion).toBe(21); expect(effect?.hookName).toBe("useEffect"); expect(effect?.callbackResolved).toBe(true); expect(effect?.dependencyMode).toBe(ReactEffectDependencyMode.Inline); @@ -2416,6 +2435,113 @@ describe("proveReactApp", () => { expect(checkReactProofReport(report).status).toBe(ReactProofCertificateStatus.Valid); }); + it("refutes direct class state mutation in every commit lifecycle phase", () => { + const mountReport = proveFixture("class-direct-state-mutation"); + const updateReport = proveFixture("class-state-mutating-call"); + const unmountReport = proveFixture("class-unmount-state-mutation"); + const mountWrite = mountReport.graph.classStateWrites[0]; + const updateWrite = updateReport.graph.classStateWrites[0]; + const unmountWrite = unmountReport.graph.classStateWrites[0]; + + expect(mountWrite?.phase).toBe(ReactExecutionPhase.ClassMount); + expect(mountWrite?.kind).toBe(ReactClassStateWriteKind.Assignment); + expect(updateWrite?.phase).toBe(ReactExecutionPhase.ClassUpdate); + expect(updateWrite?.kind).toBe(ReactClassStateWriteKind.MutatingCall); + expect(unmountWrite?.phase).toBe(ReactExecutionPhase.ClassUnmount); + expect(unmountWrite?.kind).toBe(ReactClassStateWriteKind.Assignment); + for (const [report, stateWrite] of [ + [mountReport, mountWrite], + [updateReport, updateWrite], + [unmountReport, unmountWrite], + ] as const) { + expect(report.status).toBe(ReactAppProofStatus.Refuted); + expect(stateWrite?.status).toBe(ReactClassStateWriteStatus.Forbidden); + expect(stateWrite?.sourceComplete).toBe(true); + expect(stateWrite?.complete).toBe(false); + expect(report.graph.classLifecycles[0]?.stateWriteIds).toEqual([stateWrite?.id]); + expect(checkReactProofReport(report).status).toBe(ReactProofCertificateStatus.Valid); + } + }); + + it.each(["incomplete-class-state-alias", "incomplete-class-conditional-state-alias"])( + "fails closed when an object-valued class state reference escapes in %s", + (fixtureName) => { + const report = proveFixture(fixtureName); + const stateWrite = report.graph.classStateWrites[0]; + const transitionProof = report.units[0]?.obligations.find( + (obligation) => obligation.claim === ReactProofClaim.ClassStateTransitions, + ); + + expect(report.status).toBe(ReactAppProofStatus.Incomplete); + expect(stateWrite?.kind).toBe(ReactClassStateWriteKind.ReferenceEscape); + expect(stateWrite?.status).toBe(ReactClassStateWriteStatus.Unknown); + expect(stateWrite?.sourceComplete).toBe(false); + expect(transitionProof?.status).toBe(ReactObligationStatus.Unknown); + expect(transitionProof?.evidence[0]?.description).toMatch(/ownership boundary/); + expect(checkReactProofReport(report).status).toBe(ReactProofCertificateStatus.Valid); + }, + ); + + it("classifies assignment, update, delete, and platform mutator state writes", () => { + const report = proveFixture("class-state-mutation-forms"); + + expect(report.status).toBe(ReactAppProofStatus.Refuted); + expect(report.graph.classStateWrites.map((stateWrite) => stateWrite.kind)).toEqual([ + ReactClassStateWriteKind.Assignment, + ReactClassStateWriteKind.Update, + ReactClassStateWriteKind.Delete, + ReactClassStateWriteKind.MutatingCall, + ReactClassStateWriteKind.MutatingCall, + ReactClassStateWriteKind.MutatingCall, + ]); + expect(checkReactProofReport(report).status).toBe(ReactProofCertificateStatus.Valid); + }); + + it("refutes a direct state write reached through a certified deferred class callback", () => { + const report = proveFixture("class-deferred-state-mutation"); + const stateWrite = report.graph.classStateWrites[0]; + const callback = report.graph.callbacks.find( + (candidate) => candidate.id === stateWrite?.callbackId, + ); + + expect(report.status).toBe(ReactAppProofStatus.Refuted); + expect(stateWrite?.phase).toBe(ReactExecutionPhase.Deferred); + expect(stateWrite?.kind).toBe(ReactClassStateWriteKind.Assignment); + expect(callback?.kind).toBe(ReactSemanticCallbackKind.ResourceCallback); + expect(report.graph.classLifecycles[0]?.stateWriteIds).toEqual([stateWrite?.id]); + expect(checkReactProofReport(report).status).toBe(ReactProofCertificateStatus.Valid); + }); + + it("does not call a user-defined persistent push method a direct mutation", () => { + const report = proveFixture("incomplete-class-custom-push"); + + expect(report.status).toBe(ReactAppProofStatus.Incomplete); + expect(report.graph.classStateWrites).toEqual([]); + expect(report.graph.classLifecycles[0]?.sourceComplete).toBe(false); + expect(checkReactProofReport(report).status).toBe(ReactProofCertificateStatus.Valid); + }); + + it("rejects a class state ownership certificate that marks a forbidden write complete", () => { + const report = proveFixture("class-direct-state-mutation"); + const certificate = checkReactProofReport({ + ...report, + graph: { + ...report.graph, + classStateWrites: report.graph.classStateWrites.map((stateWrite) => ({ + ...stateWrite, + complete: true, + })), + }, + }); + + expect(certificate.status).toBe(ReactProofCertificateStatus.Invalid); + expect( + certificate.failures.some((failure) => + failure.description.includes("class state write is complete"), + ), + ).toBe(true); + }); + it("fails closed on PureComponent convergence, commit callbacks, and destructured guards", () => { const pureComponentReport = proveFixture("incomplete-pure-component-update"); const commitCallbackReport = proveFixture("incomplete-class-update-callback"); diff --git a/packages/prover/tests/runtime/class-state-ownership-oracle.spec.ts b/packages/prover/tests/runtime/class-state-ownership-oracle.spec.ts new file mode 100644 index 0000000000..9664882e23 --- /dev/null +++ b/packages/prover/tests/runtime/class-state-ownership-oracle.spec.ts @@ -0,0 +1,13 @@ +import { expect, test } from "@playwright/test"; + +test("direct class state mutation changes the object without scheduling a render", async ({ + page, +}) => { + await page.goto("/?oracle=class-state-ownership"); + await expect(page.getByTestId("direct-class-state")).toHaveText("0"); + + await page.getByRole("button", { name: "mutate class state directly" }).click(); + + await expect.poll(() => page.evaluate(() => window.classDirectStateValue)).toBe(1); + await expect(page.getByTestId("direct-class-state")).toHaveText("0"); +}); diff --git a/packages/prover/tests/runtime/main.tsx b/packages/prover/tests/runtime/main.tsx index 6353cceb58..3731489457 100644 --- a/packages/prover/tests/runtime/main.tsx +++ b/packages/prover/tests/runtime/main.tsx @@ -36,6 +36,7 @@ declare global { classSchedulerHits: number; classStateUpdates: number; classStateWrites: number; + classDirectStateValue: number; classUnmounts: number; listenerHits: number; observerHits: number; @@ -49,6 +50,7 @@ window.classMounts = 0; window.classSchedulerHits = 0; window.classStateUpdates = 0; window.classStateWrites = 0; +window.classDirectStateValue = CLASS_UPDATE_INITIAL_REVISION; window.classUnmounts = 0; window.listenerHits = 0; window.observerHits = 0; @@ -279,6 +281,32 @@ const ClassStateTransitionOracle = () => { ); }; +interface DirectStateMutationState { + count: number; +} + +class DirectStateMutation extends Component, DirectStateMutationState> { + state = { count: CLASS_UPDATE_INITIAL_REVISION }; + + mutateState = () => { + this.state.count = CLASS_UPDATE_NEXT_REVISION; + window.classDirectStateValue = this.state.count; + }; + + render() { + return ( +
    + + {this.state.count} +
    + ); + } +} + +const ClassStateOwnershipOracle = () => ; + interface SchedulerProbeProperties { shouldCancel: boolean; } @@ -804,6 +832,9 @@ const RuntimeOracle = () => { if (oracle === "class-state-transition") { return ; } + if (oracle === "class-state-ownership") { + return ; + } return ; }; @@ -813,6 +844,7 @@ const oracle = new URLSearchParams(window.location.search).get("oracle"); const isClassLifecycleOracle = oracle === "class-listener" || oracle === "class-scheduler" || + oracle === "class-state-ownership" || oracle === "class-state-transition"; createRoot(rootElement).render( isClassLifecycleOracle ? ( From 9212d5a807cb4c5e7bf0500c3f3f254daaa626ed Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Tue, 28 Jul 2026 17:47:59 +0000 Subject: [PATCH 09/23] feat(prover): certify class construction --- packages/prover/README.md | 10 +- packages/prover/research-log.md | 101 ++- .../prover/src/analyze-class-construction.ts | 106 +++ packages/prover/src/analyze-react-unit.ts | 3 + .../prover/src/build-react-semantic-graph.ts | 56 ++ .../prover/src/check-react-proof-report.ts | 212 +++++ .../prover/src/collect-class-construction.ts | 731 ++++++++++++++++++ .../src/collect-class-state-transitions.ts | 36 +- .../prover/src/collect-class-state-writes.ts | 17 +- packages/prover/src/collect-react-units.ts | 10 +- packages/prover/src/constants.ts | 4 +- packages/prover/src/index.ts | 7 + packages/prover/src/prove-react-app.ts | 1 + packages/prover/src/types.ts | 60 ++ .../src/utils/is-react-set-state-call.ts | 40 + .../src/utils/is-this-state-expression.ts | 18 + .../class-deferred-state-mutation/src/app.tsx | 2 + .../class-direct-state-mutation/src/app.tsx | 2 + .../class-impure-state-updater/src/app.tsx | 2 + .../class-state-mutating-call/src/app.tsx | 2 + .../class-unmount-state-mutation/src/app.tsx | 2 + .../fixtures/class-update-loop/src/app.tsx | 2 + .../src/app.tsx | 9 + .../tsconfig.json | 0 .../src/app.tsx | 2 + .../src/app.tsx | 22 + .../tsconfig.json | 4 + .../incomplete-class-custom-push/src/app.tsx | 8 + .../src/app.tsx | 16 + .../tsconfig.json | 4 + .../src/app.tsx | 18 + .../tsconfig.json | 4 + .../src/app.tsx | 15 + .../tsconfig.json | 4 + .../incomplete-class-state-alias/src/app.tsx | 2 + .../src/app.tsx | 2 + .../src/app.tsx | 21 + .../tsconfig.json | 4 + .../src/app.tsx | 20 + .../tsconfig.json | 4 + .../proved-class-field-from-props/src/app.tsx | 18 + .../tsconfig.json | 4 + .../src/app.tsx | 2 + .../src/app.tsx | 2 + .../src/app.tsx | 2 + .../src/app.tsx | 0 .../proved-class-state-field/tsconfig.json | 4 + .../src/app.tsx | 17 + .../tsconfig.json | 4 + .../src/app.tsx | 16 + .../tsconfig.json | 4 + .../src/app.tsx | 12 + .../tsconfig.json | 4 + .../src/app.tsx | 12 + .../tsconfig.json | 4 + .../src/app.tsx | 14 + .../tsconfig.json | 4 + .../refuted-class-invalid-state/src/app.tsx | 9 + .../refuted-class-invalid-state/tsconfig.json | 4 + .../refuted-class-missing-state/src/app.tsx | 11 + .../refuted-class-missing-state/tsconfig.json | 4 + .../src/app.tsx | 15 + .../tsconfig.json | 4 + packages/prover/tests/prove-react-app.test.ts | 221 +++++- .../runtime/class-construction-oracle.spec.ts | 19 + packages/prover/tests/runtime/constants.ts | 1 + packages/prover/tests/runtime/main.tsx | 48 ++ 67 files changed, 1975 insertions(+), 67 deletions(-) create mode 100644 packages/prover/src/analyze-class-construction.ts create mode 100644 packages/prover/src/collect-class-construction.ts create mode 100644 packages/prover/src/utils/is-react-set-state-call.ts create mode 100644 packages/prover/src/utils/is-this-state-expression.ts create mode 100644 packages/prover/tests/fixtures/incomplete-class-accessor-field/src/app.tsx rename packages/prover/tests/fixtures/{incomplete-class-field => incomplete-class-accessor-field}/tsconfig.json (100%) create mode 100644 packages/prover/tests/fixtures/incomplete-class-conditional-state-initializer/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-class-conditional-state-initializer/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-class-custom-subscription-lookalike/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-class-custom-subscription-lookalike/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-class-multiple-state-initializers/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-class-multiple-state-initializers/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-class-opaque-state-initializer/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-class-opaque-state-initializer/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-class-constructor-binding/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-class-constructor-binding/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-class-constructor-state/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-class-constructor-state/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-class-field-from-props/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-class-field-from-props/tsconfig.json rename packages/prover/tests/fixtures/{incomplete-class-field => proved-class-state-field}/src/app.tsx (100%) create mode 100644 packages/prover/tests/fixtures/proved-class-state-field/tsconfig.json create mode 100644 packages/prover/tests/fixtures/refuted-class-constructor-order/src/app.tsx create mode 100644 packages/prover/tests/fixtures/refuted-class-constructor-order/tsconfig.json create mode 100644 packages/prover/tests/fixtures/refuted-class-constructor-set-state/src/app.tsx create mode 100644 packages/prover/tests/fixtures/refuted-class-constructor-set-state/tsconfig.json create mode 100644 packages/prover/tests/fixtures/refuted-class-constructor-side-effect/src/app.tsx create mode 100644 packages/prover/tests/fixtures/refuted-class-constructor-side-effect/tsconfig.json create mode 100644 packages/prover/tests/fixtures/refuted-class-constructor-subscription/src/app.tsx create mode 100644 packages/prover/tests/fixtures/refuted-class-constructor-subscription/tsconfig.json create mode 100644 packages/prover/tests/fixtures/refuted-class-field-side-effect/src/app.tsx create mode 100644 packages/prover/tests/fixtures/refuted-class-field-side-effect/tsconfig.json create mode 100644 packages/prover/tests/fixtures/refuted-class-invalid-state/src/app.tsx create mode 100644 packages/prover/tests/fixtures/refuted-class-invalid-state/tsconfig.json create mode 100644 packages/prover/tests/fixtures/refuted-class-missing-state/src/app.tsx create mode 100644 packages/prover/tests/fixtures/refuted-class-missing-state/tsconfig.json create mode 100644 packages/prover/tests/fixtures/refuted-class-missing-updater-state/src/app.tsx create mode 100644 packages/prover/tests/fixtures/refuted-class-missing-updater-state/tsconfig.json create mode 100644 packages/prover/tests/runtime/class-construction-oracle.spec.ts diff --git a/packages/prover/README.md b/packages/prover/README.md index 14ca8e636f..6c46e2890a 100644 --- a/packages/prover/README.md +++ b/packages/prover/README.md @@ -52,6 +52,11 @@ The report includes: 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 @@ -83,7 +88,10 @@ state-write and state-transition links, and a completeness flag derived exactly 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. Resource certificates +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. diff --git a/packages/prover/research-log.md b/packages/prover/research-log.md index 369479cc84..1eee05379a 100644 --- a/packages/prover/research-log.md +++ b/packages/prover/research-log.md @@ -745,7 +745,7 @@ components and hooks, including hooks hidden in incorrectly named helper functio ### Test stack -Current checkpoint: 232 TypeScript fixture projects, 423 static tests, and 33 Chromium runtime +Current checkpoint: 257 TypeScript fixture projects, 444 static tests, and 34 Chromium runtime oracles. - Vite Plus supplies package build and Vitest-compatible static tests. @@ -1027,3 +1027,102 @@ 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. diff --git a/packages/prover/src/analyze-class-construction.ts b/packages/prover/src/analyze-class-construction.ts new file mode 100644 index 0000000000..c28fccdcfc --- /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-react-unit.ts b/packages/prover/src/analyze-react-unit.ts index 6cc240782f..d2f0d1807e 100644 --- a/packages/prover/src/analyze-react-unit.ts +++ b/packages/prover/src/analyze-react-unit.ts @@ -1,6 +1,7 @@ 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"; @@ -28,6 +29,7 @@ const ALL_REACT_PROOF_CLAIMS: ReadonlyArray = [ ReactProofClaim.AsyncEffectOwnership, ReactProofClaim.BoundaryCoverage, ReactProofClaim.CallableRefFreshness, + ReactProofClaim.ClassConstruction, ReactProofClaim.ClassStateTransitions, ReactProofClaim.ComponentIdentity, ReactProofClaim.ComponentInvocation, @@ -121,6 +123,7 @@ export const analyzeReactUnit = ( analyzeAsyncEffectOwnership(unit.functionNode, context), analyzeBoundaryCoverage(unit, context), analyzeCallableRefFreshness(unit, context), + analyzeClassConstruction(unit, context), analyzeClassStateTransitions(unit, context), analyzeComponentIdentity(unit.functionNode, context), analyzeComponentInvocation(unit.functionNode, context), diff --git a/packages/prover/src/build-react-semantic-graph.ts b/packages/prover/src/build-react-semantic-graph.ts index 6342996049..6ea4777d30 100644 --- a/packages/prover/src/build-react-semantic-graph.ts +++ b/packages/prover/src/build-react-semantic-graph.ts @@ -1,5 +1,6 @@ import ts from "typescript"; 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"; @@ -46,6 +47,8 @@ import { mergeCallableBindings } from "./resolve-callable-expression.js"; import type { ResolvedCallableValueDescriptor } from "./resolve-callable-expression.js"; import { ReactCallableRefFreshness, + ReactClassConstructionIssueStatus, + ReactClassConstructionStatus, ReactClassStateUpdaterStatus, ReactClassStateWriteStatus, ReactClassUpdateCycleStatus, @@ -70,6 +73,7 @@ import type { ReactSemanticCallbackPropAlternative, ReactSemanticCallbackPropFlow, ReactSemanticCallableRef, + ReactSemanticClassConstruction, ReactSemanticClassLifecycle, ReactSemanticClassStateWrite, ReactSemanticClassStateTransition, @@ -104,6 +108,7 @@ interface EffectGraphFacts { } interface ClassLifecycleGraphFacts { + construction: ReactSemanticClassConstruction | null; lifecycle: ReactSemanticClassLifecycle | null; stateWrites: ReadonlyArray; transitions: ReadonlyArray; @@ -1025,6 +1030,7 @@ const collectClassLifecycleGraph = ( const renderMethod = classNode ? getClassMethodDeclaration(classNode, "render") : null; if (identity.descriptor.kind !== ReactUnitKind.ClassComponent || !classNode || !renderMethod) { return { + construction: null, lifecycle: null, stateWrites: [], transitions: [], @@ -1035,6 +1041,46 @@ const collectClassLifecycleGraph = ( 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"); @@ -1396,6 +1442,7 @@ const collectClassLifecycleGraph = ( ]; const representedClassMembers = new Set([ renderMethod, + ...constructionDescriptor.representedMembers, ...(mountMethod ? [mountMethod] : []), ...(unmountMethod ? [unmountMethod] : []), ...(updateMethod ? [updateMethod] : []), @@ -1408,6 +1455,7 @@ const collectClassLifecycleGraph = ( ]); const sourceComplete = identity.descriptor.sourceComplete && + construction.sourceComplete && classNode.members.every((member) => representedClassMembers.has(member)) && lifecycleCalls.every((callExpression) => representedLifecycleCalls.has(callExpression)); const lifecycleId = createSemanticId( @@ -1417,10 +1465,12 @@ const collectClassLifecycleGraph = ( 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, @@ -1431,6 +1481,7 @@ const collectClassLifecycleGraph = ( sourceComplete, complete: sourceComplete && + construction.complete && resources.every((resource) => resource.complete) && schedulers.every((scheduler) => scheduler.complete) && stateWrites.every((stateWrite) => stateWrite.complete) && @@ -2141,6 +2192,7 @@ export const buildReactSemanticGraph = ( const effects: ReactSemanticEffect[] = []; const schedulers: ReactSemanticScheduler[] = []; const resources: ReactSemanticEffectResource[] = []; + const classConstructions: ReactSemanticClassConstruction[] = []; const classLifecycles: ReactSemanticClassLifecycle[] = []; const classStateWrites: ReactSemanticClassStateWrite[] = []; const classStateTransitions: ReactSemanticClassStateTransition[] = []; @@ -2193,6 +2245,9 @@ export const buildReactSemanticGraph = ( functionCalls.push(...reachabilityFacts.functionCalls); } const classLifecycleGraph = collectClassLifecycleGraph(identity, context); + if (classLifecycleGraph.construction) { + classConstructions.push(classLifecycleGraph.construction); + } if (classLifecycleGraph.lifecycle) { classLifecycles.push(classLifecycleGraph.lifecycle); } @@ -2285,6 +2340,7 @@ export const buildReactSemanticGraph = ( callableRefs, schedulers, resources, + classConstructions, classLifecycles, classStateWrites, classStateTransitions, diff --git a/packages/prover/src/check-react-proof-report.ts b/packages/prover/src/check-react-proof-report.ts index 1e8298735b..a71db5c2e9 100644 --- a/packages/prover/src/check-react-proof-report.ts +++ b/packages/prover/src/check-react-proof-report.ts @@ -4,6 +4,11 @@ import { ReactAsyncOwnershipStatus, ReactCallableRefFreshness, ReactClassComponentBase, + ReactClassConstructionIssueKind, + ReactClassConstructionIssueStatus, + ReactClassConstructionStatus, + ReactClassStateInitializationKind, + ReactClassStateInitializationRequirement, ReactClassStateUpdaterStatus, ReactClassStateWriteKind, ReactClassStateWriteStatus, @@ -119,6 +124,27 @@ const expectedClassStateTransitionStatus = ( : 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 expectedScheduledCallbackLifetimeStatus = ( unit: ReactSemanticUnit, report: ReactAppProofReport, @@ -234,6 +260,17 @@ const checkClaimCoverage = ( `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 scheduledCallbackLifetime = unitProof.obligations.find( (obligation) => obligation.claim === ReactProofClaim.ScheduledCallbackLifetime, ); @@ -728,6 +765,150 @@ const checkGraphReferences = ( 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"); @@ -897,6 +1078,17 @@ const checkGraphReferences = ( 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; @@ -980,6 +1172,7 @@ const checkGraphReferences = ( } const expectedComplete = lifecycle.sourceComplete && + Boolean(construction?.complete) && lifecycleResources.length === lifecycle.resourceIds.length && lifecycleResources.every((resource) => resource.complete) && lifecycleSchedulers.length === lifecycle.schedulerIds.length && @@ -1000,6 +1193,9 @@ const checkGraphReferences = ( 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 ( @@ -1045,6 +1241,17 @@ const checkGraphReferences = ( 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"); @@ -1369,6 +1576,11 @@ export const checkReactProofReport = (report: ReactAppProofReport): ReactProofCe "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", diff --git a/packages/prover/src/collect-class-construction.ts b/packages/prover/src/collect-class-construction.ts new file mode 100644 index 0000000000..7409129e83 --- /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 index 4685aa8027..23beeeffad 100644 --- a/packages/prover/src/collect-class-state-transitions.ts +++ b/packages/prover/src/collect-class-state-transitions.ts @@ -16,6 +16,7 @@ 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 { isDeferredCallbackSynchronous } from "./utils/is-deferred-callback-synchronous.js"; +import { isReactSetStateCall } from "./utils/is-react-set-state-call.js"; export interface ClassStateTransitionDescriptor { callExpression: ts.CallExpression; @@ -33,41 +34,6 @@ interface ClassStateSourcePath { source: "current-props" | "previous-props"; } -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; -}; - -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, - ); - }), - ); -}; - const getStateSourcePath = ( expression: ts.Expression, previousPropsSymbol: ts.Symbol, diff --git a/packages/prover/src/collect-class-state-writes.ts b/packages/prover/src/collect-class-state-writes.ts index 5b82a849a0..a37a83de89 100644 --- a/packages/prover/src/collect-class-state-writes.ts +++ b/packages/prover/src/collect-class-state-writes.ts @@ -9,8 +9,8 @@ import { 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 { 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"; @@ -38,21 +38,6 @@ export interface ClassStateWriteRootDescriptor { | ReactExecutionPhase.StateTransition; } -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"; -}; - const isThisStateAssignmentTarget = (node: ts.Node): boolean => { if (ts.isExpression(node) && isThisStateExpression(node)) return true; if (ts.isParenthesizedExpression(node)) return isThisStateAssignmentTarget(node.expression); diff --git a/packages/prover/src/collect-react-units.ts b/packages/prover/src/collect-react-units.ts index bd3d284590..a3f7b43fc4 100644 --- a/packages/prover/src/collect-react-units.ts +++ b/packages/prover/src/collect-react-units.ts @@ -49,20 +49,18 @@ const hasSupportedClassSyntax = ( ): 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); - const initializer = member.initializer; return Boolean( propertyName && !member.modifiers?.some( (modifier) => modifier.kind === ts.SyntaxKind.StaticKeyword || modifier.kind === ts.SyntaxKind.AccessorKeyword, - ) && - (!initializer || - ts.isNumericLiteral(initializer) || - initializer.kind === ts.SyntaxKind.NullKeyword || - (ts.isIdentifier(initializer) && initializer.text === "undefined")), + ), ); } if (!ts.isMethodDeclaration(member)) return false; diff --git a/packages/prover/src/constants.ts b/packages/prover/src/constants.ts index 7577643a34..4cd3563008 100644 --- a/packages/prover/src/constants.ts +++ b/packages/prover/src/constants.ts @@ -1,7 +1,7 @@ import { ReactEffectResourceKind } from "./types.js"; -export const REACT_PROOF_SCHEMA_VERSION = 15; -export const REACT_SEMANTIC_GRAPH_SCHEMA_VERSION = 21; +export const REACT_PROOF_SCHEMA_VERSION = 16; +export const REACT_SEMANTIC_GRAPH_SCHEMA_VERSION = 22; 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; diff --git a/packages/prover/src/index.ts b/packages/prover/src/index.ts index 5b227d8236..128e6914f0 100644 --- a/packages/prover/src/index.ts +++ b/packages/prover/src/index.ts @@ -5,6 +5,11 @@ export { ReactAsyncOwnershipStatus, ReactCallableRefFreshness, ReactClassComponentBase, + ReactClassConstructionIssueKind, + ReactClassConstructionIssueStatus, + ReactClassConstructionStatus, + ReactClassStateInitializationKind, + ReactClassStateInitializationRequirement, ReactClassStateUpdaterStatus, ReactClassStateWriteKind, ReactClassStateWriteStatus, @@ -51,6 +56,8 @@ export type { ReactSemanticCallbackPropAlternative, ReactSemanticCallbackPropFlow, ReactSemanticCallableRef, + ReactSemanticClassConstruction, + ReactSemanticClassConstructionIssue, ReactSemanticClassLifecycle, ReactSemanticClassStateWrite, ReactSemanticClassStateTransition, diff --git a/packages/prover/src/prove-react-app.ts b/packages/prover/src/prove-react-app.ts index 4b3d52d681..c0d2d7b26d 100644 --- a/packages/prover/src/prove-react-app.ts +++ b/packages/prover/src/prove-react-app.ts @@ -43,6 +43,7 @@ export const proveReactApp = (input: ProveReactAppInput): ReactAppProofReport => callableRefs: [], schedulers: [], resources: [], + classConstructions: [], classLifecycles: [], classStateWrites: [], classStateTransitions: [], diff --git a/packages/prover/src/types.ts b/packages/prover/src/types.ts index 39f485ac85..76c8007e03 100644 --- a/packages/prover/src/types.ts +++ b/packages/prover/src/types.ts @@ -15,6 +15,7 @@ export enum ReactObligationStatus { export enum ReactProofClaim { BoundaryCoverage = "boundary-coverage", CallableRefFreshness = "callable-ref-freshness", + ClassConstruction = "class-construction", ClassStateTransitions = "class-state-transitions", ComponentIdentity = "component-identity", ComponentInvocation = "component-invocation", @@ -64,6 +65,7 @@ export enum ReactCompilerFactStatus { } export enum ReactExecutionPhase { + ClassConstruction = "class-construction", ClassMount = "class-mount", ClassUnmount = "class-unmount", ClassUpdate = "class-update", @@ -428,6 +430,7 @@ export interface ReactSemanticClassLifecycle { id: string; ownerId: string; location: ReactProofLocation; + constructionId: string; mountCallbackId: string | null; unmountCallbackId: string | null; updateCallbackId: string | null; @@ -439,6 +442,62 @@ export interface ReactSemanticClassLifecycle { 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", @@ -559,6 +618,7 @@ export interface ReactSemanticGraph { callableRefs: ReadonlyArray; schedulers: ReadonlyArray; resources: ReadonlyArray; + classConstructions: ReadonlyArray; classLifecycles: ReadonlyArray; classStateWrites: ReadonlyArray; classStateTransitions: ReadonlyArray; 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 0000000000..2103881663 --- /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-this-state-expression.ts b/packages/prover/src/utils/is-this-state-expression.ts new file mode 100644 index 0000000000..e7e739d72e --- /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/tests/fixtures/class-deferred-state-mutation/src/app.tsx b/packages/prover/tests/fixtures/class-deferred-state-mutation/src/app.tsx index 4f5a174275..a184669ec0 100644 --- a/packages/prover/tests/fixtures/class-deferred-state-mutation/src/app.tsx +++ b/packages/prover/tests/fixtures/class-deferred-state-mutation/src/app.tsx @@ -5,6 +5,8 @@ interface ListenerState { } export class ResizeListener extends Component, ListenerState> { + state = { resizeCount: 0 }; + handleResize() { this.state.resizeCount += 1; } 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 index 3443cab584..f65e381e0c 100644 --- a/packages/prover/tests/fixtures/class-direct-state-mutation/src/app.tsx +++ b/packages/prover/tests/fixtures/class-direct-state-mutation/src/app.tsx @@ -5,6 +5,8 @@ interface CounterState { } export class Counter extends Component, CounterState> { + state = { count: 0 }; + componentDidMount() { this.state.count = 1; } 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 index 726e7a0657..91f093e9ab 100644 --- a/packages/prover/tests/fixtures/class-impure-state-updater/src/app.tsx +++ b/packages/prover/tests/fixtures/class-impure-state-updater/src/app.tsx @@ -5,6 +5,8 @@ interface CounterState { } export class Counter extends Component, CounterState> { + state = { count: 0 }; + componentDidMount() { this.setState((previousState) => { console.log(previousState.count); 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 index 693866325a..8b9769b9ee 100644 --- a/packages/prover/tests/fixtures/class-state-mutating-call/src/app.tsx +++ b/packages/prover/tests/fixtures/class-state-mutating-call/src/app.tsx @@ -5,6 +5,8 @@ interface QueueState { } export class Queue extends Component, QueueState> { + state: QueueState = { items: [] }; + componentDidUpdate() { this.state.items.push("queued"); } 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 index 485a581b8a..c1e9363aea 100644 --- a/packages/prover/tests/fixtures/class-unmount-state-mutation/src/app.tsx +++ b/packages/prover/tests/fixtures/class-unmount-state-mutation/src/app.tsx @@ -5,6 +5,8 @@ interface ConnectionState { } export class Connection extends Component, ConnectionState> { + state = { connected: true }; + componentWillUnmount() { this.state.connected = false; } diff --git a/packages/prover/tests/fixtures/class-update-loop/src/app.tsx b/packages/prover/tests/fixtures/class-update-loop/src/app.tsx index 8ff5916ccd..e2eaf98102 100644 --- a/packages/prover/tests/fixtures/class-update-loop/src/app.tsx +++ b/packages/prover/tests/fixtures/class-update-loop/src/app.tsx @@ -5,6 +5,8 @@ interface RevisionState { } export class RevisionTracker extends Component, RevisionState> { + state = { revision: 0 }; + componentDidUpdate() { this.setState({ revision: 1 }); } diff --git a/packages/prover/tests/fixtures/incomplete-class-accessor-field/src/app.tsx b/packages/prover/tests/fixtures/incomplete-class-accessor-field/src/app.tsx new file mode 100644 index 0000000000..e8d5d67629 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-class-accessor-field/src/app.tsx @@ -0,0 +1,9 @@ +import { Component } from "react"; + +export class Counter extends Component { + accessor count = 0; + + render() { + return {this.count}; + } +} diff --git a/packages/prover/tests/fixtures/incomplete-class-field/tsconfig.json b/packages/prover/tests/fixtures/incomplete-class-accessor-field/tsconfig.json similarity index 100% rename from packages/prover/tests/fixtures/incomplete-class-field/tsconfig.json rename to packages/prover/tests/fixtures/incomplete-class-accessor-field/tsconfig.json diff --git a/packages/prover/tests/fixtures/incomplete-class-conditional-state-alias/src/app.tsx b/packages/prover/tests/fixtures/incomplete-class-conditional-state-alias/src/app.tsx index 1680a3f417..6f8bcf94e9 100644 --- a/packages/prover/tests/fixtures/incomplete-class-conditional-state-alias/src/app.tsx +++ b/packages/prover/tests/fixtures/incomplete-class-conditional-state-alias/src/app.tsx @@ -9,6 +9,8 @@ interface CounterState { } export class Counter extends Component { + state = { count: 0 }; + componentDidMount() { const stateAlias = this.props.enabled ? this.state : { count: 0 }; stateAlias.count = 1; diff --git a/packages/prover/tests/fixtures/incomplete-class-conditional-state-initializer/src/app.tsx b/packages/prover/tests/fixtures/incomplete-class-conditional-state-initializer/src/app.tsx new file mode 100644 index 0000000000..534d73a78b --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-class-conditional-state-initializer/src/app.tsx @@ -0,0 +1,22 @@ +import { Component } from "react"; + +interface CounterProperties { + enabled: boolean; +} + +interface CounterState { + count: number; +} + +export class Counter extends Component { + constructor(properties: CounterProperties) { + super(properties); + if (properties.enabled) { + this.state = { count: 0 }; + } + } + + render() { + return {this.state.count}; + } +} diff --git a/packages/prover/tests/fixtures/incomplete-class-conditional-state-initializer/tsconfig.json b/packages/prover/tests/fixtures/incomplete-class-conditional-state-initializer/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-class-conditional-state-initializer/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-class-custom-push/src/app.tsx b/packages/prover/tests/fixtures/incomplete-class-custom-push/src/app.tsx index 33de05a073..67cc1b736e 100644 --- a/packages/prover/tests/fixtures/incomplete-class-custom-push/src/app.tsx +++ b/packages/prover/tests/fixtures/incomplete-class-custom-push/src/app.tsx @@ -9,6 +9,14 @@ interface QueueState { } export class Queue extends Component, QueueState> { + state: QueueState = { + queue: { + push() { + return this; + }, + }, + }; + componentDidMount() { this.state.queue.push("queued"); } diff --git a/packages/prover/tests/fixtures/incomplete-class-custom-subscription-lookalike/src/app.tsx b/packages/prover/tests/fixtures/incomplete-class-custom-subscription-lookalike/src/app.tsx new file mode 100644 index 0000000000..27d098d826 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-class-custom-subscription-lookalike/src/app.tsx @@ -0,0 +1,16 @@ +import { Component } from "react"; + +const customTarget = { + addEventListener(_eventName: string, _callback: () => void) {}, +}; + +export class ResizeListener extends Component { + constructor(properties: Record) { + super(properties); + customTarget.addEventListener("resize", () => {}); + } + + render() { + return null; + } +} diff --git a/packages/prover/tests/fixtures/incomplete-class-custom-subscription-lookalike/tsconfig.json b/packages/prover/tests/fixtures/incomplete-class-custom-subscription-lookalike/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-class-custom-subscription-lookalike/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-class-multiple-state-initializers/src/app.tsx b/packages/prover/tests/fixtures/incomplete-class-multiple-state-initializers/src/app.tsx new file mode 100644 index 0000000000..3840d6a0ac --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-class-multiple-state-initializers/src/app.tsx @@ -0,0 +1,18 @@ +import { Component } from "react"; + +interface CounterState { + count: number; +} + +export class Counter extends Component, CounterState> { + state = { count: 0 }; + + constructor(properties: Record) { + super(properties); + this.state = { count: 1 }; + } + + render() { + return {this.state.count}; + } +} diff --git a/packages/prover/tests/fixtures/incomplete-class-multiple-state-initializers/tsconfig.json b/packages/prover/tests/fixtures/incomplete-class-multiple-state-initializers/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-class-multiple-state-initializers/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-class-opaque-state-initializer/src/app.tsx b/packages/prover/tests/fixtures/incomplete-class-opaque-state-initializer/src/app.tsx new file mode 100644 index 0000000000..453be95887 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-class-opaque-state-initializer/src/app.tsx @@ -0,0 +1,15 @@ +import { Component } from "react"; + +interface CounterState { + count: number; +} + +declare const createInitialState: () => CounterState; + +export class Counter extends Component, CounterState> { + state = createInitialState(); + + render() { + return {this.state.count}; + } +} diff --git a/packages/prover/tests/fixtures/incomplete-class-opaque-state-initializer/tsconfig.json b/packages/prover/tests/fixtures/incomplete-class-opaque-state-initializer/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-class-opaque-state-initializer/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-class-state-alias/src/app.tsx b/packages/prover/tests/fixtures/incomplete-class-state-alias/src/app.tsx index 66b77ee75a..b17468a6a0 100644 --- a/packages/prover/tests/fixtures/incomplete-class-state-alias/src/app.tsx +++ b/packages/prover/tests/fixtures/incomplete-class-state-alias/src/app.tsx @@ -5,6 +5,8 @@ interface CounterState { } export class Counter extends Component, CounterState> { + state = { count: 0 }; + componentDidMount() { const stateAlias = this.state; stateAlias.count = 1; diff --git a/packages/prover/tests/fixtures/incomplete-pure-component-update/src/app.tsx b/packages/prover/tests/fixtures/incomplete-pure-component-update/src/app.tsx index 91fb40b6a7..f5b587c671 100644 --- a/packages/prover/tests/fixtures/incomplete-pure-component-update/src/app.tsx +++ b/packages/prover/tests/fixtures/incomplete-pure-component-update/src/app.tsx @@ -5,6 +5,8 @@ interface RevisionState { } export class RevisionTracker extends PureComponent, RevisionState> { + state = { revision: 0 }; + componentDidUpdate() { this.setState({ revision: 1 }); } diff --git a/packages/prover/tests/fixtures/proved-class-constructor-binding/src/app.tsx b/packages/prover/tests/fixtures/proved-class-constructor-binding/src/app.tsx new file mode 100644 index 0000000000..48e4b46e0d --- /dev/null +++ b/packages/prover/tests/fixtures/proved-class-constructor-binding/src/app.tsx @@ -0,0 +1,21 @@ +import { Component } from "react"; + +interface CounterState { + count: number; +} + +export class Counter extends Component, CounterState> { + constructor(properties: Record) { + super(properties); + this.state = { count: 0 }; + this.handleClick = this.handleClick.bind(this); + } + + handleClick() { + this.setState({ count: this.state.count + 1 }); + } + + render() { + return ; + } +} diff --git a/packages/prover/tests/fixtures/proved-class-constructor-binding/tsconfig.json b/packages/prover/tests/fixtures/proved-class-constructor-binding/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-class-constructor-binding/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-class-constructor-state/src/app.tsx b/packages/prover/tests/fixtures/proved-class-constructor-state/src/app.tsx new file mode 100644 index 0000000000..fa30033b92 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-class-constructor-state/src/app.tsx @@ -0,0 +1,20 @@ +import { Component } from "react"; + +interface CounterProperties { + initialCount: number; +} + +interface CounterState { + count: number; +} + +export class Counter extends Component { + constructor(properties: CounterProperties) { + super(properties); + this.state = { count: properties.initialCount }; + } + + render() { + return {this.state.count}; + } +} diff --git a/packages/prover/tests/fixtures/proved-class-constructor-state/tsconfig.json b/packages/prover/tests/fixtures/proved-class-constructor-state/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-class-constructor-state/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-class-field-from-props/src/app.tsx b/packages/prover/tests/fixtures/proved-class-field-from-props/src/app.tsx new file mode 100644 index 0000000000..c3c80d0a16 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-class-field-from-props/src/app.tsx @@ -0,0 +1,18 @@ +import { Component } from "react"; + +interface CounterProperties { + initialCount: number; +} + +interface CounterState { + count: number; +} + +export class Counter extends Component { + initialCount = this.props.initialCount; + state = { count: this.props.initialCount }; + + render() { + return {this.initialCount + this.state.count}; + } +} diff --git a/packages/prover/tests/fixtures/proved-class-field-from-props/tsconfig.json b/packages/prover/tests/fixtures/proved-class-field-from-props/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-class-field-from-props/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-class-primitive-state-read/src/app.tsx b/packages/prover/tests/fixtures/proved-class-primitive-state-read/src/app.tsx index d35a7a1051..263484e6c4 100644 --- a/packages/prover/tests/fixtures/proved-class-primitive-state-read/src/app.tsx +++ b/packages/prover/tests/fixtures/proved-class-primitive-state-read/src/app.tsx @@ -5,6 +5,8 @@ interface CounterState { } export class Counter extends Component, CounterState> { + state = { count: 0 }; + componentDidMount() { const count = this.state.count; if (count < 0) this.setState(null); diff --git a/packages/prover/tests/fixtures/proved-class-pure-state-updater/src/app.tsx b/packages/prover/tests/fixtures/proved-class-pure-state-updater/src/app.tsx index 6da3459e34..3331f7fcaa 100644 --- a/packages/prover/tests/fixtures/proved-class-pure-state-updater/src/app.tsx +++ b/packages/prover/tests/fixtures/proved-class-pure-state-updater/src/app.tsx @@ -5,6 +5,8 @@ interface CounterState { } export class Counter extends Component, CounterState> { + state = { count: 0 }; + componentDidMount() { this.setState((previousState) => ({ count: previousState.count + 1 })); } diff --git a/packages/prover/tests/fixtures/proved-class-state-computed-key-read/src/app.tsx b/packages/prover/tests/fixtures/proved-class-state-computed-key-read/src/app.tsx index f904887b4f..5cbafa43da 100644 --- a/packages/prover/tests/fixtures/proved-class-state-computed-key-read/src/app.tsx +++ b/packages/prover/tests/fixtures/proved-class-state-computed-key-read/src/app.tsx @@ -5,6 +5,8 @@ interface LookupState { } export class Lookup extends Component, LookupState> { + state = { key: "count" }; + componentDidMount() { const values: Record = {}; values[this.state.key] = 1; diff --git a/packages/prover/tests/fixtures/incomplete-class-field/src/app.tsx b/packages/prover/tests/fixtures/proved-class-state-field/src/app.tsx similarity index 100% rename from packages/prover/tests/fixtures/incomplete-class-field/src/app.tsx rename to packages/prover/tests/fixtures/proved-class-state-field/src/app.tsx diff --git a/packages/prover/tests/fixtures/proved-class-state-field/tsconfig.json b/packages/prover/tests/fixtures/proved-class-state-field/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-class-state-field/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/refuted-class-constructor-order/src/app.tsx b/packages/prover/tests/fixtures/refuted-class-constructor-order/src/app.tsx new file mode 100644 index 0000000000..fc535e0389 --- /dev/null +++ b/packages/prover/tests/fixtures/refuted-class-constructor-order/src/app.tsx @@ -0,0 +1,17 @@ +import { Component } from "react"; + +interface CounterState { + count: number; +} + +export class Counter extends Component, CounterState> { + constructor(properties: Record) { + const initialCount = 0; + super(properties); + this.state = { count: initialCount }; + } + + render() { + return {this.state.count}; + } +} diff --git a/packages/prover/tests/fixtures/refuted-class-constructor-order/tsconfig.json b/packages/prover/tests/fixtures/refuted-class-constructor-order/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/refuted-class-constructor-order/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/refuted-class-constructor-set-state/src/app.tsx b/packages/prover/tests/fixtures/refuted-class-constructor-set-state/src/app.tsx new file mode 100644 index 0000000000..d2c0cbb01c --- /dev/null +++ b/packages/prover/tests/fixtures/refuted-class-constructor-set-state/src/app.tsx @@ -0,0 +1,16 @@ +import { Component } from "react"; + +interface CounterState { + count: number; +} + +export class Counter extends Component, CounterState> { + constructor(properties: Record) { + super(properties); + this.setState({ count: 0 }); + } + + render() { + return null; + } +} diff --git a/packages/prover/tests/fixtures/refuted-class-constructor-set-state/tsconfig.json b/packages/prover/tests/fixtures/refuted-class-constructor-set-state/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/refuted-class-constructor-set-state/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/refuted-class-constructor-side-effect/src/app.tsx b/packages/prover/tests/fixtures/refuted-class-constructor-side-effect/src/app.tsx new file mode 100644 index 0000000000..579b587f7b --- /dev/null +++ b/packages/prover/tests/fixtures/refuted-class-constructor-side-effect/src/app.tsx @@ -0,0 +1,12 @@ +import { Component } from "react"; + +export class Counter extends Component { + constructor(properties: Record) { + super(properties); + console.log("constructed"); + } + + render() { + return null; + } +} diff --git a/packages/prover/tests/fixtures/refuted-class-constructor-side-effect/tsconfig.json b/packages/prover/tests/fixtures/refuted-class-constructor-side-effect/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/refuted-class-constructor-side-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/refuted-class-constructor-subscription/src/app.tsx b/packages/prover/tests/fixtures/refuted-class-constructor-subscription/src/app.tsx new file mode 100644 index 0000000000..0276b55cb5 --- /dev/null +++ b/packages/prover/tests/fixtures/refuted-class-constructor-subscription/src/app.tsx @@ -0,0 +1,12 @@ +import { Component } from "react"; + +export class ResizeListener extends Component { + constructor(properties: Record) { + super(properties); + window.addEventListener("resize", () => {}); + } + + render() { + return null; + } +} diff --git a/packages/prover/tests/fixtures/refuted-class-constructor-subscription/tsconfig.json b/packages/prover/tests/fixtures/refuted-class-constructor-subscription/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/refuted-class-constructor-subscription/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/refuted-class-field-side-effect/src/app.tsx b/packages/prover/tests/fixtures/refuted-class-field-side-effect/src/app.tsx new file mode 100644 index 0000000000..81c156a202 --- /dev/null +++ b/packages/prover/tests/fixtures/refuted-class-field-side-effect/src/app.tsx @@ -0,0 +1,14 @@ +import { Component } from "react"; + +interface ClockState { + initializedAt: number; +} + +export class Clock extends Component, ClockState> { + state = { initializedAt: 0 }; + initializedAt = Date.now(); + + render() { + return {this.initializedAt + this.state.initializedAt}; + } +} diff --git a/packages/prover/tests/fixtures/refuted-class-field-side-effect/tsconfig.json b/packages/prover/tests/fixtures/refuted-class-field-side-effect/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/refuted-class-field-side-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/refuted-class-invalid-state/src/app.tsx b/packages/prover/tests/fixtures/refuted-class-invalid-state/src/app.tsx new file mode 100644 index 0000000000..911a90affd --- /dev/null +++ b/packages/prover/tests/fixtures/refuted-class-invalid-state/src/app.tsx @@ -0,0 +1,9 @@ +import { Component } from "react"; + +export class Counter extends Component, number> { + state = 0; + + render() { + return {this.state}; + } +} diff --git a/packages/prover/tests/fixtures/refuted-class-invalid-state/tsconfig.json b/packages/prover/tests/fixtures/refuted-class-invalid-state/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/refuted-class-invalid-state/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/refuted-class-missing-state/src/app.tsx b/packages/prover/tests/fixtures/refuted-class-missing-state/src/app.tsx new file mode 100644 index 0000000000..80de537c88 --- /dev/null +++ b/packages/prover/tests/fixtures/refuted-class-missing-state/src/app.tsx @@ -0,0 +1,11 @@ +import { Component } from "react"; + +interface CounterState { + count: number; +} + +export class Counter extends Component, CounterState> { + render() { + return {this.state.count}; + } +} diff --git a/packages/prover/tests/fixtures/refuted-class-missing-state/tsconfig.json b/packages/prover/tests/fixtures/refuted-class-missing-state/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/refuted-class-missing-state/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/refuted-class-missing-updater-state/src/app.tsx b/packages/prover/tests/fixtures/refuted-class-missing-updater-state/src/app.tsx new file mode 100644 index 0000000000..6da3459e34 --- /dev/null +++ b/packages/prover/tests/fixtures/refuted-class-missing-updater-state/src/app.tsx @@ -0,0 +1,15 @@ +import { Component } from "react"; + +interface CounterState { + count: number; +} + +export class Counter extends Component, CounterState> { + componentDidMount() { + this.setState((previousState) => ({ count: previousState.count + 1 })); + } + + render() { + return null; + } +} diff --git a/packages/prover/tests/fixtures/refuted-class-missing-updater-state/tsconfig.json b/packages/prover/tests/fixtures/refuted-class-missing-updater-state/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/refuted-class-missing-updater-state/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/prove-react-app.test.ts b/packages/prover/tests/prove-react-app.test.ts index 9f75ff7436..3c19badb4b 100644 --- a/packages/prover/tests/prove-react-app.test.ts +++ b/packages/prover/tests/prove-react-app.test.ts @@ -8,6 +8,10 @@ import { ReactAsyncOwnershipStatus, ReactCallableRefFreshness, ReactClassComponentBase, + ReactClassConstructionIssueKind, + ReactClassConstructionStatus, + ReactClassStateInitializationKind, + ReactClassStateInitializationRequirement, ReactClassStateUpdaterStatus, ReactClassStateWriteKind, ReactClassStateWriteStatus, @@ -34,6 +38,11 @@ interface RefutedFixtureExpectation { evidencePattern: RegExp; } +interface ClassConstructionIssueExpectation { + fixtureName: string; + issueKind: ReactClassConstructionIssueKind; +} + const fixturesDirectory = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures"); const proveFixture = (fixtureName: string) => @@ -42,6 +51,46 @@ const proveFixture = (fixtureName: string) => }); const REFUTED_FIXTURES: ReadonlyArray = [ + { + fixtureName: "refuted-class-invalid-state", + claim: ReactProofClaim.ClassConstruction, + evidencePattern: /not an object/, + }, + { + fixtureName: "refuted-class-constructor-side-effect", + claim: ReactProofClaim.ClassConstruction, + evidencePattern: /observable or non-idempotent/, + }, + { + fixtureName: "refuted-class-constructor-subscription", + claim: ReactProofClaim.ClassConstruction, + evidencePattern: /observable or non-idempotent/, + }, + { + fixtureName: "refuted-class-field-side-effect", + claim: ReactProofClaim.ClassConstruction, + evidencePattern: /observable or non-idempotent/, + }, + { + fixtureName: "refuted-class-constructor-order", + claim: ReactProofClaim.ClassConstruction, + evidencePattern: /super with its props before/, + }, + { + fixtureName: "refuted-class-constructor-set-state", + claim: ReactProofClaim.ClassConstruction, + evidencePattern: /calls setState/, + }, + { + fixtureName: "refuted-class-missing-state", + claim: ReactProofClaim.ClassConstruction, + evidencePattern: /without a proved initialization/, + }, + { + fixtureName: "refuted-class-missing-updater-state", + claim: ReactProofClaim.ClassConstruction, + evidencePattern: /without a proved initialization/, + }, { fixtureName: "class-direct-state-mutation", claim: ReactProofClaim.ClassStateTransitions, @@ -449,6 +498,10 @@ describe("proveReactApp", () => { "proved-class-pure-state-updater", "proved-class-primitive-state-read", "proved-class-state-computed-key-read", + "proved-class-state-field", + "proved-class-field-from-props", + "proved-class-constructor-state", + "proved-class-constructor-binding", "proved-class-compound-prop-transition", "proved-class-number-literal-prop-transition", ])("proves the complete %s application graph", (fixtureName) => { @@ -509,8 +562,8 @@ describe("proveReactApp", () => { const report = proveFixture("proved-chat"); const effect = report.graph.effects[0]; - expect(report.schemaVersion).toBe(15); - expect(report.graph.schemaVersion).toBe(21); + expect(report.schemaVersion).toBe(16); + expect(report.graph.schemaVersion).toBe(22); expect(effect?.hookName).toBe("useEffect"); expect(effect?.callbackResolved).toBe(true); expect(effect?.dependencyMode).toBe(ReactEffectDependencyMode.Inline); @@ -2779,15 +2832,24 @@ describe("proveReactApp", () => { expect(renderProof?.evidence[0]?.description).toMatch(/not pure during render/); }); - it("fails closed for class fields while certifying an empty update lifecycle", () => { - const fieldReport = proveFixture("incomplete-class-field"); + it("certifies public-field state construction and an empty update lifecycle", () => { + const fieldReport = proveFixture("proved-class-state-field"); const lifecycleReport = proveFixture("incomplete-class-lifecycle"); const transitionProof = lifecycleReport.units[0]?.obligations.find( (obligation) => obligation.claim === ReactProofClaim.ClassStateTransitions, ); - expect(fieldReport.status).toBe(ReactAppProofStatus.Incomplete); - expect(fieldReport.graph.units[0]?.sourceComplete).toBe(false); + expect(fieldReport.status).toBe(ReactAppProofStatus.Proved); + expect(fieldReport.graph.units[0]?.sourceComplete).toBe(true); + expect(fieldReport.graph.classConstructions[0]?.initializationKind).toBe( + ReactClassStateInitializationKind.PublicField, + ); + expect(fieldReport.graph.classConstructions[0]?.stateRequirement).toBe( + ReactClassStateInitializationRequirement.Required, + ); + expect(fieldReport.graph.classConstructions[0]?.status).toBe( + ReactClassConstructionStatus.Valid, + ); expect(lifecycleReport.status).toBe(ReactAppProofStatus.Proved); expect(lifecycleReport.graph.units[0]?.sourceComplete).toBe(true); expect(lifecycleReport.graph.classLifecycles[0]?.updateCallbackId).not.toBeNull(); @@ -2795,6 +2857,153 @@ describe("proveReactApp", () => { expect(transitionProof?.status).toBe(ReactObligationStatus.Proved); }); + it.each(["proved-class-constructor-state", "proved-class-constructor-binding"])( + "certifies canonical constructor state and method binding in %s", + (fixtureName) => { + const report = proveFixture(fixtureName); + const construction = report.graph.classConstructions[0]; + const constructionProof = report.units[0]?.obligations.find( + (obligation) => obligation.claim === ReactProofClaim.ClassConstruction, + ); + + expect(report.status).toBe(ReactAppProofStatus.Proved); + expect(construction?.phase).toBe(ReactExecutionPhase.ClassConstruction); + expect(construction?.constructorLocation).not.toBeNull(); + expect(construction?.initializationKind).toBe( + ReactClassStateInitializationKind.ConstructorAssignment, + ); + expect(construction?.stateRequirement).toBe( + ReactClassStateInitializationRequirement.Required, + ); + expect(construction?.issues).toEqual([]); + expect(construction?.status).toBe(ReactClassConstructionStatus.Valid); + expect(construction?.sourceComplete).toBe(true); + expect(construction?.complete).toBe(true); + expect(report.graph.classLifecycles[0]?.constructionId).toBe(construction?.id); + expect(constructionProof?.status).toBe(ReactObligationStatus.Proved); + expect(checkReactProofReport(report).status).toBe(ReactProofCertificateStatus.Valid); + }, + ); + + it("records concrete invalid state, constructor side-effect, setState, and missing-state issues", () => { + const expectations: ReadonlyArray = [ + { + fixtureName: "refuted-class-invalid-state", + issueKind: ReactClassConstructionIssueKind.InvalidStateValue, + }, + { + fixtureName: "refuted-class-constructor-side-effect", + issueKind: ReactClassConstructionIssueKind.SideEffect, + }, + { + fixtureName: "refuted-class-constructor-subscription", + issueKind: ReactClassConstructionIssueKind.SideEffect, + }, + { + fixtureName: "refuted-class-field-side-effect", + issueKind: ReactClassConstructionIssueKind.SideEffect, + }, + { + fixtureName: "refuted-class-constructor-order", + issueKind: ReactClassConstructionIssueKind.InvalidSuperCall, + }, + { + fixtureName: "refuted-class-constructor-set-state", + issueKind: ReactClassConstructionIssueKind.SetStateCall, + }, + { + fixtureName: "refuted-class-missing-state", + issueKind: ReactClassConstructionIssueKind.MissingStateInitialization, + }, + { + fixtureName: "refuted-class-missing-updater-state", + issueKind: ReactClassConstructionIssueKind.MissingStateInitialization, + }, + ]; + + for (const expectation of expectations) { + const report = proveFixture(expectation.fixtureName); + const construction = report.graph.classConstructions[0]; + + expect(report.status).toBe(ReactAppProofStatus.Refuted); + expect(construction?.issues.some((issue) => issue.kind === expectation.issueKind)).toBe(true); + expect(construction?.status).toBe(ReactClassConstructionStatus.Invalid); + expect(construction?.sourceComplete).toBe(true); + expect(construction?.complete).toBe(false); + expect(checkReactProofReport(report).status).toBe(ReactProofCertificateStatus.Valid); + } + }); + + it.each([ + [ + "incomplete-class-opaque-state-initializer", + ReactClassConstructionIssueKind.UnsupportedInitializer, + ], + [ + "incomplete-class-multiple-state-initializers", + ReactClassConstructionIssueKind.MultipleStateInitializations, + ], + [ + "incomplete-class-conditional-state-initializer", + ReactClassConstructionIssueKind.UnsupportedConstructorStatement, + ], + [ + "incomplete-class-custom-subscription-lookalike", + ReactClassConstructionIssueKind.UnsupportedInitializer, + ], + ])("fails closed for unresolved class construction in %s", (fixtureName, issueKind) => { + const report = proveFixture(fixtureName); + const construction = report.graph.classConstructions[0]; + const constructionProof = report.units[0]?.obligations.find( + (obligation) => obligation.claim === ReactProofClaim.ClassConstruction, + ); + + expect(report.status).toBe(ReactAppProofStatus.Incomplete); + expect(construction?.issues.some((issue) => issue.kind === issueKind)).toBe(true); + expect(construction?.status).toBe(ReactClassConstructionStatus.Unknown); + expect(construction?.sourceComplete).toBe(false); + expect(construction?.complete).toBe(false); + expect(constructionProof?.status).toBe(ReactObligationStatus.Unknown); + expect(checkReactProofReport(report).status).toBe(ReactProofCertificateStatus.Valid); + }); + + it("leaves unsupported class field syntax incomplete after proving its initializer", () => { + const report = proveFixture("incomplete-class-accessor-field"); + const construction = report.graph.classConstructions[0]; + const constructionProof = report.units[0]?.obligations.find( + (obligation) => obligation.claim === ReactProofClaim.ClassConstruction, + ); + + expect(report.status).toBe(ReactAppProofStatus.Incomplete); + expect(construction?.issues.map((issue) => issue.kind)).toEqual([ + ReactClassConstructionIssueKind.UnsupportedInitializer, + ]); + expect(construction?.status).toBe(ReactClassConstructionStatus.Unknown); + expect(constructionProof?.status).toBe(ReactObligationStatus.Unknown); + expect(checkReactProofReport(report).status).toBe(ReactProofCertificateStatus.Valid); + }); + + it("rejects a forged class construction status", () => { + const report = proveFixture("proved-class-state-field"); + const certificate = checkReactProofReport({ + ...report, + graph: { + ...report.graph, + classConstructions: report.graph.classConstructions.map((construction) => ({ + ...construction, + status: ReactClassConstructionStatus.Invalid, + })), + }, + }); + + expect(certificate.status).toBe(ReactProofCertificateStatus.Invalid); + expect( + certificate.failures.some((failure) => + failure.description.includes("status does not match its issues"), + ), + ).toBe(true); + }); + it("does not mistake a shadowed Component base for React inheritance", () => { const report = proveFixture("shadowed-component-class"); diff --git a/packages/prover/tests/runtime/class-construction-oracle.spec.ts b/packages/prover/tests/runtime/class-construction-oracle.spec.ts new file mode 100644 index 0000000000..c61d5333f9 --- /dev/null +++ b/packages/prover/tests/runtime/class-construction-oracle.spec.ts @@ -0,0 +1,19 @@ +import { expect, test } from "@playwright/test"; +import { STRICT_MODE_CONSTRUCTION_RUNS } from "./constants.js"; + +test("Strict Mode constructs constructor and public-field state twice", async ({ page }) => { + await page.goto("/?oracle=class-construction"); + + await expect + .poll(() => page.evaluate(() => window.classConstructorRuns)) + .toBe(STRICT_MODE_CONSTRUCTION_RUNS); + await expect + .poll(() => page.evaluate(() => window.classFieldInitializerRuns)) + .toBe(STRICT_MODE_CONSTRUCTION_RUNS); + await expect(page.getByTestId("constructor-run")).toHaveText( + String(STRICT_MODE_CONSTRUCTION_RUNS), + ); + await expect(page.getByTestId("field-initializer-run")).toHaveText( + String(STRICT_MODE_CONSTRUCTION_RUNS), + ); +}); diff --git a/packages/prover/tests/runtime/constants.ts b/packages/prover/tests/runtime/constants.ts index d614f1abf7..3365e43b28 100644 --- a/packages/prover/tests/runtime/constants.ts +++ b/packages/prover/tests/runtime/constants.ts @@ -11,4 +11,5 @@ export const SCHEDULER_CALLBACK_DELAY_MS = 80; export const SCHEDULER_SETTLE_WAIT_MS = 140; export const SLOW_QUERY_DELAY_MS = 200; export const STORE_VERSION_INCREMENT = 1; +export const STRICT_MODE_CONSTRUCTION_RUNS = 2; export const UNOBSERVED_CALLBACK_REVISION = -1; diff --git a/packages/prover/tests/runtime/main.tsx b/packages/prover/tests/runtime/main.tsx index 3731489457..09c496a24b 100644 --- a/packages/prover/tests/runtime/main.tsx +++ b/packages/prover/tests/runtime/main.tsx @@ -25,6 +25,7 @@ import { SECONDARY_STORE_INITIAL_VERSION, SLOW_QUERY_DELAY_MS, STORE_VERSION_INCREMENT, + STRICT_MODE_CONSTRUCTION_RUNS, UNOBSERVED_CALLBACK_REVISION, } from "./constants.js"; @@ -37,6 +38,8 @@ declare global { classStateUpdates: number; classStateWrites: number; classDirectStateValue: number; + classConstructorRuns: number; + classFieldInitializerRuns: number; classUnmounts: number; listenerHits: number; observerHits: number; @@ -51,6 +54,8 @@ window.classSchedulerHits = 0; window.classStateUpdates = 0; window.classStateWrites = 0; window.classDirectStateValue = CLASS_UPDATE_INITIAL_REVISION; +window.classConstructorRuns = CLASS_UPDATE_INITIAL_REVISION; +window.classFieldInitializerRuns = CLASS_UPDATE_INITIAL_REVISION; window.classUnmounts = 0; window.listenerHits = 0; window.observerHits = 0; @@ -307,6 +312,45 @@ class DirectStateMutation extends Component, DirectStateMu const ClassStateOwnershipOracle = () => ; +interface ConstructionProbeState { + run: number; +} + +class ConstructorConstructionProbe extends Component< + Record, + ConstructionProbeState +> { + constructor(properties: Record) { + super(properties); + window.classConstructorRuns += CLASS_UPDATE_NEXT_REVISION; + this.state = { run: window.classConstructorRuns }; + } + + render() { + return {this.state.run}; + } +} + +const initializeFieldConstructionState = (): ConstructionProbeState => { + window.classFieldInitializerRuns += CLASS_UPDATE_NEXT_REVISION; + return { run: window.classFieldInitializerRuns }; +}; + +class FieldConstructionProbe extends Component, ConstructionProbeState> { + state = initializeFieldConstructionState(); + + render() { + return {this.state.run}; + } +} + +const ClassConstructionOracle = () => ( +
    + + +
    +); + interface SchedulerProbeProperties { shouldCancel: boolean; } @@ -835,6 +879,9 @@ const RuntimeOracle = () => { if (oracle === "class-state-ownership") { return ; } + if (oracle === "class-construction") { + return ; + } return ; }; @@ -844,6 +891,7 @@ const oracle = new URLSearchParams(window.location.search).get("oracle"); const isClassLifecycleOracle = oracle === "class-listener" || oracle === "class-scheduler" || + oracle === "class-construction" || oracle === "class-state-ownership" || oracle === "class-state-transition"; createRoot(rootElement).render( From 0c2d1f48c44891872be672f6a86a9fb99a00757f Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Tue, 28 Jul 2026 18:32:00 +0000 Subject: [PATCH 10/23] feat(prover): certify hook state transitions --- packages/prover/README.md | 10 ++ packages/prover/research-log.md | 96 ++++++++++- .../src/analyze-external-store-consistency.ts | 10 +- .../src/analyze-hook-state-transitions.ts | 87 ++++++++++ packages/prover/src/analyze-react-unit.ts | 3 + packages/prover/src/analyze-render-purity.ts | 33 +++- .../prover/src/analyze-updater-function.ts | 27 +++ .../prover/src/build-react-semantic-graph.ts | 137 +++++++++++++++ .../prover/src/check-react-proof-report.ts | 122 +++++++++++++ .../src/collect-class-state-transitions.ts | 25 +-- .../src/collect-hook-state-transitions.ts | 162 ++++++++++++++++++ packages/prover/src/constants.ts | 7 +- packages/prover/src/index.ts | 2 + packages/prover/src/prove-react-app.ts | 1 + packages/prover/src/types.ts | 24 +++ .../src/utils/get-containing-function.ts | 11 ++ .../src/app.tsx | 15 ++ .../tsconfig.json | 4 + .../src/app.tsx | 13 ++ .../tsconfig.json | 4 + .../src/app.tsx | 15 ++ .../tsconfig.json | 4 + .../src/app.tsx | 11 ++ .../tsconfig.json | 4 + .../src/app.tsx | 21 +++ .../tsconfig.json | 4 + .../src/app.tsx | 20 +++ .../tsconfig.json | 4 + .../src/app.tsx | 23 +++ .../tsconfig.json | 4 + .../src/app.tsx | 19 ++ .../tsconfig.json | 4 + packages/prover/tests/prove-react-app.test.ts | 118 ++++++++++++- packages/prover/tests/runtime/constants.ts | 4 + .../hook-state-transition-oracle.spec.ts | 24 +++ packages/prover/tests/runtime/main.tsx | 39 ++++- 36 files changed, 1071 insertions(+), 40 deletions(-) create mode 100644 packages/prover/src/analyze-hook-state-transitions.ts create mode 100644 packages/prover/src/analyze-updater-function.ts create mode 100644 packages/prover/src/collect-hook-state-transitions.ts create mode 100644 packages/prover/src/utils/get-containing-function.ts create mode 100644 packages/prover/tests/fixtures/incomplete-hook-setter-in-reducer/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-hook-setter-in-reducer/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-hook-state-setter-escape/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-hook-state-setter-escape/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-opaque-hook-state-updater/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-opaque-hook-state-updater/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-effect-functional-updater/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-effect-functional-updater/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-hook-direct-state-value/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-hook-direct-state-value/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-hook-functional-updater/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-hook-functional-updater/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-state-setter-lookalikes/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-state-setter-lookalikes/tsconfig.json create mode 100644 packages/prover/tests/fixtures/refuted-impure-hook-state-updater/src/app.tsx create mode 100644 packages/prover/tests/fixtures/refuted-impure-hook-state-updater/tsconfig.json create mode 100644 packages/prover/tests/runtime/hook-state-transition-oracle.spec.ts diff --git a/packages/prover/README.md b/packages/prover/README.md index 6c46e2890a..8d253135e8 100644 --- a/packages/prover/README.md +++ b/packages/prover/README.md @@ -64,6 +64,12 @@ The report includes: 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; - 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. @@ -95,6 +101,10 @@ 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. ## Verification diff --git a/packages/prover/research-log.md b/packages/prover/research-log.md index 1eee05379a..a911c7366f 100644 --- a/packages/prover/research-log.md +++ b/packages/prover/research-log.md @@ -711,7 +711,7 @@ Known regions that must force `incomplete` until modeled: and emitter `on`/`once` contracts - Mutable-object external-store snapshots requiring cache summaries, selectors, or third-party store contracts -- Transitions, deferred values, optimistic state, and Actions +- Transitions, deferred values, optimistic state, and Actions beyond certified `useState` updates - 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 @@ -745,7 +745,7 @@ components and hooks, including hooks hidden in incorrectly named helper functio ### Test stack -Current checkpoint: 257 TypeScript fixture projects, 444 static tests, and 34 Chromium runtime +Current checkpoint: 265 TypeScript fixture projects, 456 static tests, and 35 Chromium runtime oracles. - Vite Plus supplies package build and Vitest-compatible static tests. @@ -781,6 +781,8 @@ oracles. - 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 @@ -1126,3 +1128,93 @@ 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. diff --git a/packages/prover/src/analyze-external-store-consistency.ts b/packages/prover/src/analyze-external-store-consistency.ts index 3f1ed0fc0a..8e24915c04 100644 --- a/packages/prover/src/analyze-external-store-consistency.ts +++ b/packages/prover/src/analyze-external-store-consistency.ts @@ -12,6 +12,7 @@ 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, @@ -241,15 +242,6 @@ const isStaticPrimitiveExpression = (expression: ts.Expression): boolean => { ); }; -const getContainingFunction = (node: ts.Node): ts.FunctionLikeDeclaration | null => { - let currentNode = node.parent; - while (currentNode) { - if (isFunctionBoundary(currentNode)) return currentNode; - currentNode = currentNode.parent; - } - return null; -}; - const hasRegistryNotificationAfterWrite = ( write: ts.Node, registry: SubscriptionRegistry, 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 0000000000..4356ead3a9 --- /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-react-unit.ts b/packages/prover/src/analyze-react-unit.ts index d2f0d1807e..e71d4b64d3 100644 --- a/packages/prover/src/analyze-react-unit.ts +++ b/packages/prover/src/analyze-react-unit.ts @@ -13,6 +13,7 @@ import { analyzeEffectStateUpdates } from "./analyze-effect-state-updates.js"; import { analyzeExternalStoreConsistency } from "./analyze-external-store-consistency.js"; import { analyzeHookOrder } from "./analyze-hook-order.js"; import { analyzeHookOwnership } from "./analyze-hook-ownership.js"; +import { analyzeHookStateTransitions } from "./analyze-hook-state-transitions.js"; import { analyzeMemoDependencies } from "./analyze-memo-dependencies.js"; import { analyzeRefAccess } from "./analyze-ref-access.js"; import { analyzeReducerPurity } from "./analyze-reducer-purity.js"; @@ -41,6 +42,7 @@ const ALL_REACT_PROOF_CLAIMS: ReadonlyArray = [ ReactProofClaim.ExternalStoreConsistency, ReactProofClaim.HookOrder, ReactProofClaim.HookOwnership, + ReactProofClaim.HookStateTransitions, ReactProofClaim.MemoDependencies, ReactProofClaim.ReconciliationIdentity, ReactProofClaim.ReducerPurity, @@ -135,6 +137,7 @@ export const analyzeReactUnit = ( analyzeExternalStoreConsistency(unit, context), analyzeHookOrder(unit.functionNode, context), analyzeHookOwnership(unit.functionNode), + analyzeHookStateTransitions(unit, context), analyzeMemoDependencies(unit.functionNode, context), analyzeReconciliationIdentity(unit.functionNode, context), analyzeReducerPurity(unit.functionNode, context), diff --git a/packages/prover/src/analyze-render-purity.ts b/packages/prover/src/analyze-render-purity.ts index 4ea77d44df..633abe09c3 100644 --- a/packages/prover/src/analyze-render-purity.ts +++ b/packages/prover/src/analyze-render-purity.ts @@ -1,8 +1,10 @@ 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_UNMODELED_HOOK_NAMES, @@ -39,6 +41,23 @@ const KNOWN_RENDER_SIDE_EFFECT_CALLS = new Set([ "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, @@ -67,6 +86,14 @@ const isFreshLocalMutation = ( 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); @@ -178,7 +205,8 @@ export const analyzeRenderPurity = ( } if ( ts.isPropertyAccessExpression(node.expression) && - MUTATING_METHOD_NAMES.has(node.expression.name.text) + (MUTATING_METHOD_NAMES.has(node.expression.name.text) || + isStandardLibraryMethodCall(node, KNOWN_MUTATING_STANDARD_METHOD_NAMES, context)) ) { if ( isProtectedMutation( @@ -222,7 +250,8 @@ export const analyzeRenderPurity = ( if ( (callName && KNOWN_PURE_GLOBAL_CALLS.has(callName)) || (ts.isPropertyAccessExpression(node.expression) && - KNOWN_PURE_METHOD_NAMES.has(node.expression.name.text)) + 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)) { diff --git a/packages/prover/src/analyze-updater-function.ts b/packages/prover/src/analyze-updater-function.ts new file mode 100644 index 0000000000..eb8e81127a --- /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/build-react-semantic-graph.ts b/packages/prover/src/build-react-semantic-graph.ts index 6ea4777d30..eb43adacfc 100644 --- a/packages/prover/src/build-react-semantic-graph.ts +++ b/packages/prover/src/build-react-semantic-graph.ts @@ -25,6 +25,7 @@ import { } from "./collect-effect-resource-protocols.js"; import { collectHookBindings } from "./collect-hook-bindings.js"; import { collectHookCalls } from "./collect-hook-calls.js"; +import { collectHookStateTransitions } from "./collect-hook-state-transitions.js"; import { collectReactiveCaptures } from "./collect-reactive-captures.js"; import { collectReachableFunctionGraph } from "./collect-reachable-functions.js"; import { @@ -54,6 +55,7 @@ import { ReactClassUpdateCycleStatus, ReactEffectDependencyMode, ReactExecutionPhase, + ReactHookStateUpdaterStatus, ReactIdentityStability, ReactSemanticCallbackKind, ReactSemanticEdgeKind, @@ -81,6 +83,7 @@ import type { ReactSemanticFunctionCall, ReactSemanticGraph, ReactSemanticHookCall, + ReactSemanticHookStateTransition, ReactSemanticReachableFunction, ReactSemanticRender, ReactSemanticEffectResource, @@ -91,6 +94,7 @@ import type { import { areProofLocationsEqual } from "./utils/are-proof-locations-equal.js"; import { collectReachableCallExpressions } from "./utils/collect-reachable-call-expressions.js"; import { getClassMethodDeclaration } from "./utils/get-class-method-declaration.js"; +import { getContainingFunction } from "./utils/get-containing-function.js"; import { isDeferredCallbackSynchronous } from "./utils/is-deferred-callback-synchronous.js"; interface UnitGraphIdentity { @@ -158,6 +162,10 @@ interface CallbackPropGraphFacts extends CallbackGraphFacts { callbackPropFlows: ReadonlyArray; } +interface HookStateTransitionGraphFacts extends CallbackGraphFacts { + transitions: ReadonlyArray; +} + interface CallbackPropReachabilityDescriptor { callbackDescriptor: ComponentCallbackDescriptor; callbackFact: ReactSemanticCallback; @@ -1859,6 +1867,121 @@ const collectReducerCallbacks = ( return { callbacks, reachableFunctions, functionCalls }; }; +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 containingFunction = descriptor.callExpression + ? getContainingFunction(descriptor.callExpression) + : null; + const containingLocation = containingFunction + ? getNodeLocation(containingFunction, context.rootDirectory) + : null; + const executionCallbackIds = containingLocation + ? [ + ...new Set([ + ...existingCallbacks.flatMap((callback) => + callback.ownerId === identity.semanticUnit.id && + areProofLocationsEqual(callback.location, containingLocation) + ? [callback.id] + : [], + ), + ...existingReachableFunctions.flatMap((reachableFunction) => + reachableFunction.ownerId === identity.semanticUnit.id && + areProofLocationsEqual(reachableFunction.location, containingLocation) + ? [reachableFunction.rootCallbackId] + : [], + ), + ]), + ] + : []; + 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 collectExternalStoreGraph = ( identity: UnitGraphIdentity, identitiesByFunction: ReadonlyMap, @@ -2196,6 +2319,7 @@ export const buildReactSemanticGraph = ( const classLifecycles: ReactSemanticClassLifecycle[] = []; const classStateWrites: ReactSemanticClassStateWrite[] = []; const classStateTransitions: ReactSemanticClassStateTransition[] = []; + const hookStateTransitions: ReactSemanticHookStateTransition[] = []; const effectEvents: ReactSemanticEffectEvent[] = []; const externalStores: ReactSemanticExternalStore[] = []; const asyncTasks: ReactSemanticAsyncTask[] = []; @@ -2310,6 +2434,18 @@ export const buildReactSemanticGraph = ( callbacks.push(...callbackPropGraph.callbacks); reachableFunctions.push(...callbackPropGraph.reachableFunctions); functionCalls.push(...callbackPropGraph.functionCalls); + 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 contextConsumers = resolveContextConsumers( identities.map((identity) => identity.semanticUnit), edges, @@ -2344,6 +2480,7 @@ export const buildReactSemanticGraph = ( classLifecycles, classStateWrites, classStateTransitions, + hookStateTransitions, 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 index a71db5c2e9..47a7e43e78 100644 --- a/packages/prover/src/check-react-proof-report.ts +++ b/packages/prover/src/check-react-proof-report.ts @@ -16,6 +16,7 @@ import { ReactEffectResourceDisposalStatus, ReactEffectResourceKind, ReactExecutionPhase, + ReactHookStateUpdaterStatus, ReactObligationStatus, ReactProofCertificateStatus, ReactProofClaim, @@ -33,6 +34,8 @@ import type { ReactSemanticUnit, } from "./types.js"; +const HOOK_STATE_UPDATER_STATUSES = new Set(Object.values(ReactHookStateUpdaterStatus)); + const addFailure = ( failures: ReactProofCertificateFailure[], subjectId: string, @@ -145,6 +148,31 @@ const expectedClassConstructionStatus = ( : 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 expectedScheduledCallbackLifetimeStatus = ( unit: ReactSemanticUnit, report: ReactAppProofReport, @@ -271,6 +299,17 @@ const checkClaimCoverage = ( `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 scheduledCallbackLifetime = unitProof.obligations.find( (obligation) => obligation.claim === ReactProofClaim.ScheduledCallbackLifetime, ); @@ -762,6 +801,84 @@ const checkGraphReferences = ( const transitionsById = new Map( report.graph.classStateTransitions.map((transition) => [transition.id, transition]), ); + 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 stateWritesById = new Map( report.graph.classStateWrites.map((stateWrite) => [stateWrite.id, stateWrite]), ); @@ -1586,6 +1703,11 @@ export const checkReactProofReport = (report: ReactAppProofReport): ReactProofCe "Class state transitions", report.graph.classStateTransitions.map((transition) => transition.id), ); + checkUniqueIds( + failures, + "Hook state transitions", + report.graph.hookStateTransitions.map((transition) => transition.id), + ); checkUniqueIds( failures, "Class state writes", diff --git a/packages/prover/src/collect-class-state-transitions.ts b/packages/prover/src/collect-class-state-transitions.ts index 23beeeffad..836726ac52 100644 --- a/packages/prover/src/collect-class-state-transitions.ts +++ b/packages/prover/src/collect-class-state-transitions.ts @@ -1,8 +1,7 @@ import ts from "typescript"; -import { analyzeRenderPurity } from "./analyze-render-purity.js"; +import { analyzeUpdaterFunction } from "./analyze-updater-function.js"; import { isFunctionBoundary } from "./is-function-boundary.js"; import { isNodeWithin } from "./is-node-within.js"; -import { resolveFunction } from "./resolve-function.js"; import { ReactClassComponentBase, ReactClassStateUpdaterStatus, @@ -15,7 +14,6 @@ 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 { isDeferredCallbackSynchronous } from "./utils/is-deferred-callback-synchronous.js"; import { isReactSetStateCall } from "./utils/is-react-set-state-call.js"; export interface ClassStateTransitionDescriptor { @@ -188,28 +186,15 @@ const analyzeUpdater = ( updaterStatus: ReactClassStateUpdaterStatus.Object, }; } - const updaterFunction = resolveFunction(unwrappedUpdater, context.typeChecker); - if (!updaterFunction) { - return { - updaterFunction: null, - updaterStatus: ReactClassStateUpdaterStatus.Unknown, - }; - } - if (updaterFunction.asteriskToken || !isDeferredCallbackSynchronous(updaterFunction, context)) { - return { - updaterFunction, - updaterStatus: ReactClassStateUpdaterStatus.Unknown, - }; - } - const purityProof = analyzeRenderPurity(updaterFunction, context); + const updaterAnalysis = analyzeUpdaterFunction(unwrappedUpdater, context); let updaterStatus = ReactClassStateUpdaterStatus.Unknown; - if (purityProof.status === ReactObligationStatus.Proved) { + if (updaterAnalysis.status === ReactObligationStatus.Proved) { updaterStatus = ReactClassStateUpdaterStatus.Pure; - } else if (purityProof.status === ReactObligationStatus.Violated) { + } else if (updaterAnalysis.status === ReactObligationStatus.Violated) { updaterStatus = ReactClassStateUpdaterStatus.Impure; } return { - updaterFunction, + updaterFunction: updaterAnalysis.updaterFunction, updaterStatus, }; }; 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 0000000000..e070d250a1 --- /dev/null +++ b/packages/prover/src/collect-hook-state-transitions.ts @@ -0,0 +1,162 @@ +import ts from "typescript"; +import { analyzeUpdaterFunction } from "./analyze-updater-function.js"; +import { collectHookBindings } from "./collect-hook-bindings.js"; +import { getCanonicalHookName } from "./get-canonical-hook-name.js"; +import { isIdentifierReference } from "./is-identifier-reference.js"; +import { isNodeWithin } from "./is-node-within.js"; +import { ReactHookStateUpdaterStatus, 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"; + +export interface HookStateTransitionDescriptor { + callExpression: ts.CallExpression | null; + evidenceNode: ts.Node; + setterName: string; + stateName: string; + updaterFunction: ts.FunctionLikeDeclaration | null; + updaterStatus: ReactHookStateUpdaterStatus; +} + +const doesTypeIncludeCallable = (type: ts.Type): boolean => + type.getCallSignatures().length > 0 || + (type.isUnionOrIntersection() && type.types.some(doesTypeIncludeCallable)); + +const isHookDependencyReference = ( + identifier: ts.Identifier, + context: ReactAnalysisContext, +): 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; + const dependencyIndex = hookCall.arguments.indexOf(dependencyArray); + return dependencyIndex > 0 && Boolean(getCanonicalHookName(hookCall, context.typeChecker)); +}; + +const getUpdaterStatus = ( + updaterExpression: ts.Expression, + context: ReactAnalysisContext, +): { + updaterFunction: ts.FunctionLikeDeclaration | null; + updaterStatus: ReactHookStateUpdaterStatus; +} => { + 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) || + doesTypeIncludeCallable(updaterType) + ) { + return { + updaterFunction: null, + updaterStatus: ReactHookStateUpdaterStatus.Unknown, + }; + } + return { + updaterFunction: null, + updaterStatus: ReactHookStateUpdaterStatus.DirectValue, + }; +}; + +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 + ? getUpdaterStatus(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 && + !isHookDependencyReference(node, context) && + !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/constants.ts b/packages/prover/src/constants.ts index 4cd3563008..0b78bf5841 100644 --- a/packages/prover/src/constants.ts +++ b/packages/prover/src/constants.ts @@ -1,7 +1,7 @@ import { ReactEffectResourceKind } from "./types.js"; -export const REACT_PROOF_SCHEMA_VERSION = 16; -export const REACT_SEMANTIC_GRAPH_SCHEMA_VERSION = 22; +export const REACT_PROOF_SCHEMA_VERSION = 17; +export const REACT_SEMANTIC_GRAPH_SCHEMA_VERSION = 23; 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; @@ -106,6 +106,9 @@ export const KNOWN_PURE_METHOD_NAMES = new Set([ "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", diff --git a/packages/prover/src/index.ts b/packages/prover/src/index.ts index 128e6914f0..7cb0bbaa3c 100644 --- a/packages/prover/src/index.ts +++ b/packages/prover/src/index.ts @@ -19,6 +19,7 @@ export { ReactEffectResourceDisposalStatus, ReactEffectResourceKind, ReactExecutionPhase, + ReactHookStateUpdaterStatus, ReactIdentityStability, ReactObligationStatus, ReactProofCertificateStatus, @@ -67,6 +68,7 @@ export type { ReactSemanticGraph, ReactSemanticFunctionCall, ReactSemanticHookCall, + ReactSemanticHookStateTransition, ReactSemanticReachableFunction, ReactSemanticRender, ReactSemanticScheduler, diff --git a/packages/prover/src/prove-react-app.ts b/packages/prover/src/prove-react-app.ts index c0d2d7b26d..be43afdd19 100644 --- a/packages/prover/src/prove-react-app.ts +++ b/packages/prover/src/prove-react-app.ts @@ -47,6 +47,7 @@ export const proveReactApp = (input: ProveReactAppInput): ReactAppProofReport => classLifecycles: [], classStateWrites: [], classStateTransitions: [], + hookStateTransitions: [], compiler: { version: REACT_COMPILER_VERSION, phase: REACT_COMPILER_FACT_PHASE, diff --git a/packages/prover/src/types.ts b/packages/prover/src/types.ts index 76c8007e03..489abfe685 100644 --- a/packages/prover/src/types.ts +++ b/packages/prover/src/types.ts @@ -28,6 +28,7 @@ export enum ReactProofClaim { ExternalStoreConsistency = "external-store-consistency", HookOrder = "hook-order", HookOwnership = "hook-ownership", + HookStateTransitions = "hook-state-transitions", MemoDependencies = "memo-dependencies", ReconciliationIdentity = "reconciliation-identity", ReducerPurity = "reducer-purity", @@ -92,6 +93,7 @@ export enum ReactSemanticCallbackKind { EventHandler = "event-handler", ExternalStoreSnapshot = "external-store-snapshot", ExternalStoreSubscribe = "external-store-subscribe", + HookStateUpdater = "hook-state-updater", MemoFactory = "memo-factory", MemoizedCallback = "memoized-callback", Reducer = "reducer", @@ -558,6 +560,27 @@ export interface ReactSemanticClassStateTransition { 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 interface ReactCompilerInstructionFact { id: string; valueKind: string; @@ -622,6 +645,7 @@ export interface ReactSemanticGraph { classLifecycles: ReadonlyArray; classStateWrites: ReadonlyArray; classStateTransitions: ReadonlyArray; + hookStateTransitions: ReadonlyArray; compiler: ReactCompilerGraph; } 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 0000000000..e56ab39201 --- /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/tests/fixtures/incomplete-hook-setter-in-reducer/src/app.tsx b/packages/prover/tests/fixtures/incomplete-hook-setter-in-reducer/src/app.tsx new file mode 100644 index 0000000000..c3309ea8b7 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-hook-setter-in-reducer/src/app.tsx @@ -0,0 +1,15 @@ +import { useReducer, useState } from "react"; + +export const Counter = () => { + const [count, setCount] = useState(0); + const [reducedCount, dispatch] = useReducer((previousCount: number) => { + setCount((previousState) => previousState + 1); + return previousCount + 1; + }, 0); + + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/incomplete-hook-setter-in-reducer/tsconfig.json b/packages/prover/tests/fixtures/incomplete-hook-setter-in-reducer/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-hook-setter-in-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/incomplete-hook-state-setter-escape/src/app.tsx b/packages/prover/tests/fixtures/incomplete-hook-state-setter-escape/src/app.tsx new file mode 100644 index 0000000000..6db4467fd1 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-hook-state-setter-escape/src/app.tsx @@ -0,0 +1,13 @@ +import { useEffect, useState } from "react"; + +declare const registerStateSetter: (setter: (nextValue: number) => void) => void; + +export const Counter = () => { + const [count, setCount] = useState(0); + + useEffect(() => { + registerStateSetter(setCount); + }, [setCount]); + + return {count}; +}; diff --git a/packages/prover/tests/fixtures/incomplete-hook-state-setter-escape/tsconfig.json b/packages/prover/tests/fixtures/incomplete-hook-state-setter-escape/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-hook-state-setter-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-opaque-hook-state-updater/src/app.tsx b/packages/prover/tests/fixtures/incomplete-opaque-hook-state-updater/src/app.tsx new file mode 100644 index 0000000000..1db675809c --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-opaque-hook-state-updater/src/app.tsx @@ -0,0 +1,15 @@ +import { useState } from "react"; + +interface CounterProperties { + updateCount: (previousCount: number) => number; +} + +export const Counter = ({ updateCount }: CounterProperties) => { + const [count, setCount] = useState(0); + + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/incomplete-opaque-hook-state-updater/tsconfig.json b/packages/prover/tests/fixtures/incomplete-opaque-hook-state-updater/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-opaque-hook-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/proved-effect-functional-updater/src/app.tsx b/packages/prover/tests/fixtures/proved-effect-functional-updater/src/app.tsx new file mode 100644 index 0000000000..e99fcd3d16 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-effect-functional-updater/src/app.tsx @@ -0,0 +1,11 @@ +import { useEffect, useState } from "react"; + +export const RequestCounter = () => { + const [requestCount, setRequestCount] = useState(0); + + useEffect(() => { + setRequestCount((previousCount) => previousCount + 1); + }, []); + + return {requestCount}; +}; diff --git a/packages/prover/tests/fixtures/proved-effect-functional-updater/tsconfig.json b/packages/prover/tests/fixtures/proved-effect-functional-updater/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-effect-functional-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/proved-hook-direct-state-value/src/app.tsx b/packages/prover/tests/fixtures/proved-hook-direct-state-value/src/app.tsx new file mode 100644 index 0000000000..3fe0c4333a --- /dev/null +++ b/packages/prover/tests/fixtures/proved-hook-direct-state-value/src/app.tsx @@ -0,0 +1,21 @@ +import { useState } from "react"; + +export const Disclosure = () => { + const [isOpen, setIsOpen] = useState(false); + const [formatter, setFormatter] = useState({ + format: (value: string) => value, + }); + + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/proved-hook-direct-state-value/tsconfig.json b/packages/prover/tests/fixtures/proved-hook-direct-state-value/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-hook-direct-state-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/proved-hook-functional-updater/src/app.tsx b/packages/prover/tests/fixtures/proved-hook-functional-updater/src/app.tsx new file mode 100644 index 0000000000..52e80dd091 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-hook-functional-updater/src/app.tsx @@ -0,0 +1,20 @@ +import { useState } from "react"; + +export const FileSelection = () => { + const [openPaths, setOpenPaths] = useState>(() => new Set()); + + const togglePath = (path: string) => { + setOpenPaths((previousPaths) => { + const nextPaths = new Set(previousPaths); + if (nextPaths.has(path)) nextPaths.delete(path); + else nextPaths.add(path); + return nextPaths; + }); + }; + + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/proved-hook-functional-updater/tsconfig.json b/packages/prover/tests/fixtures/proved-hook-functional-updater/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-hook-functional-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/proved-state-setter-lookalikes/src/app.tsx b/packages/prover/tests/fixtures/proved-state-setter-lookalikes/src/app.tsx new file mode 100644 index 0000000000..b9451ac8de --- /dev/null +++ b/packages/prover/tests/fixtures/proved-state-setter-lookalikes/src/app.tsx @@ -0,0 +1,23 @@ +import { useReducer, useState } from "react"; + +const setCount = (count: number) => count + 1; + +export const Counter = () => { + const [count, updateCount] = useState(0); + const [reducedCount, dispatch] = useReducer( + (previousCount: number, _update: (value: number) => number) => previousCount + 1, + 0, + ); + + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/proved-state-setter-lookalikes/tsconfig.json b/packages/prover/tests/fixtures/proved-state-setter-lookalikes/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-state-setter-lookalikes/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/refuted-impure-hook-state-updater/src/app.tsx b/packages/prover/tests/fixtures/refuted-impure-hook-state-updater/src/app.tsx new file mode 100644 index 0000000000..925cf757f5 --- /dev/null +++ b/packages/prover/tests/fixtures/refuted-impure-hook-state-updater/src/app.tsx @@ -0,0 +1,19 @@ +import { useState } from "react"; + +export const Counter = () => { + const [count, setCount] = useState(0); + + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/refuted-impure-hook-state-updater/tsconfig.json b/packages/prover/tests/fixtures/refuted-impure-hook-state-updater/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/refuted-impure-hook-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/prove-react-app.test.ts b/packages/prover/tests/prove-react-app.test.ts index 3c19badb4b..628458122e 100644 --- a/packages/prover/tests/prove-react-app.test.ts +++ b/packages/prover/tests/prove-react-app.test.ts @@ -21,6 +21,7 @@ import { ReactEffectResourceDisposalStatus, ReactEffectResourceKind, ReactExecutionPhase, + ReactHookStateUpdaterStatus, ReactIdentityStability, ReactObligationStatus, ReactProofClaim, @@ -51,6 +52,11 @@ const proveFixture = (fixtureName: string) => }); const REFUTED_FIXTURES: ReadonlyArray = [ + { + fixtureName: "refuted-impure-hook-state-updater", + claim: ReactProofClaim.HookStateTransitions, + evidencePattern: /observable side effect/, + }, { fixtureName: "refuted-class-invalid-state", claim: ReactProofClaim.ClassConstruction, @@ -457,6 +463,10 @@ describe("proveReactApp", () => { "proved-aliased-hook", "proved-static-list-keys", "proved-mount-state-update", + "proved-hook-functional-updater", + "proved-hook-direct-state-value", + "proved-effect-functional-updater", + "proved-state-setter-lookalikes", "proved-external-store", "proved-effect-event", "proved-helper-effect-cleanup", @@ -562,8 +572,8 @@ describe("proveReactApp", () => { const report = proveFixture("proved-chat"); const effect = report.graph.effects[0]; - expect(report.schemaVersion).toBe(16); - expect(report.graph.schemaVersion).toBe(22); + expect(report.schemaVersion).toBe(17); + expect(report.graph.schemaVersion).toBe(23); expect(effect?.hookName).toBe("useEffect"); expect(effect?.callbackResolved).toBe(true); expect(effect?.dependencyMode).toBe(ReactEffectDependencyMode.Inline); @@ -2885,6 +2895,110 @@ describe("proveReactApp", () => { }, ); + it("certifies a realistic event updater through its reachable helper", () => { + const report = proveFixture("proved-hook-functional-updater"); + const transition = report.graph.hookStateTransitions[0]; + const updaterCallback = report.graph.callbacks.find( + (callback) => callback.id === transition?.updaterCallbackId, + ); + const executionCallbacks = report.graph.callbacks.filter((callback) => + transition?.executionCallbackIds.includes(callback.id), + ); + const transitionProof = report.units[0]?.obligations.find( + (obligation) => obligation.claim === ReactProofClaim.HookStateTransitions, + ); + + expect(report.status).toBe(ReactAppProofStatus.Proved); + expect(transition?.stateName).toBe("openPaths"); + expect(transition?.setterName).toBe("setOpenPaths"); + expect(transition?.updaterStatus).toBe(ReactHookStateUpdaterStatus.Pure); + expect(transition?.sourceComplete).toBe(true); + expect(transition?.complete).toBe(true); + expect( + executionCallbacks.some((callback) => callback.phase === ReactExecutionPhase.Event), + ).toBe(true); + expect(updaterCallback?.kind).toBe(ReactSemanticCallbackKind.HookStateUpdater); + expect(updaterCallback?.phase).toBe(ReactExecutionPhase.StateTransition); + expect(transitionProof?.status).toBe(ReactObligationStatus.Proved); + expect(checkReactProofReport(report).status).toBe(ReactProofCertificateStatus.Valid); + }); + + it("separates direct state values and reducer actions from functional state updaters", () => { + const directReport = proveFixture("proved-hook-direct-state-value"); + const lookalikeReport = proveFixture("proved-state-setter-lookalikes"); + + expect(directReport.graph.hookStateTransitions).toHaveLength(2); + expect( + directReport.graph.hookStateTransitions.every( + (transition) => transition.updaterStatus === ReactHookStateUpdaterStatus.DirectValue, + ), + ).toBe(true); + expect( + directReport.graph.hookStateTransitions.every( + (transition) => transition.updaterCallbackId === null, + ), + ).toBe(true); + expect(lookalikeReport.graph.hookStateTransitions).toHaveLength(1); + expect(lookalikeReport.graph.hookStateTransitions[0]?.setterName).toBe("updateCount"); + expect(lookalikeReport.graph.hookStateTransitions[0]?.updaterStatus).toBe( + ReactHookStateUpdaterStatus.DirectValue, + ); + }); + + it("roots a functional state updater in an Effect setup callback", () => { + const report = proveFixture("proved-effect-functional-updater"); + const transition = report.graph.hookStateTransitions[0]; + const executionCallbacks = report.graph.callbacks.filter((callback) => + transition?.executionCallbackIds.includes(callback.id), + ); + + expect(report.status).toBe(ReactAppProofStatus.Proved); + expect(transition?.updaterStatus).toBe(ReactHookStateUpdaterStatus.Pure); + expect( + executionCallbacks.some((callback) => callback.phase === ReactExecutionPhase.EffectSetup), + ).toBe(true); + }); + + it.each([ + ["incomplete-opaque-hook-state-updater", ReactHookStateUpdaterStatus.Unknown], + ["incomplete-hook-state-setter-escape", ReactHookStateUpdaterStatus.SetterEscape], + ["incomplete-hook-setter-in-reducer", ReactHookStateUpdaterStatus.Pure], + ])("fails closed for unresolved Hook state flow in %s", (fixtureName, updaterStatus) => { + const report = proveFixture(fixtureName); + const transition = report.graph.hookStateTransitions.find( + (candidate) => candidate.updaterStatus === updaterStatus, + ); + const transitionProof = report.units[0]?.obligations.find( + (obligation) => obligation.claim === ReactProofClaim.HookStateTransitions, + ); + + expect(report.status).toBe(ReactAppProofStatus.Incomplete); + expect(transition?.sourceComplete).toBe(false); + expect(transition?.complete).toBe(false); + expect(transitionProof?.status).toBe(ReactObligationStatus.Unknown); + expect(checkReactProofReport(report).status).toBe(ReactProofCertificateStatus.Valid); + }); + + it("rejects a forged Hook state transition certificate", () => { + const report = proveFixture("incomplete-opaque-hook-state-updater"); + const certificate = checkReactProofReport({ + ...report, + graph: { + ...report.graph, + hookStateTransitions: report.graph.hookStateTransitions.map((transition) => ({ + ...transition, + sourceComplete: true, + complete: true, + })), + }, + }); + + expect(certificate.status).toBe(ReactProofCertificateStatus.Invalid); + expect( + certificate.failures.some((failure) => failure.description.includes("Hook state transition")), + ).toBe(true); + }); + it("records concrete invalid state, constructor side-effect, setState, and missing-state issues", () => { const expectations: ReadonlyArray = [ { diff --git a/packages/prover/tests/runtime/constants.ts b/packages/prover/tests/runtime/constants.ts index 3365e43b28..6f53655eca 100644 --- a/packages/prover/tests/runtime/constants.ts +++ b/packages/prover/tests/runtime/constants.ts @@ -1,4 +1,7 @@ export const FAST_QUERY_DELAY_MS = 20; +export const HOOK_STATE_INCREMENT = 1; +export const HOOK_STATE_INITIAL_COUNT = 0; +export const HOOK_STATE_UPDATER_INITIAL_RUNS = 0; export const CLASS_UPDATE_INITIAL_REVISION = 0; export const CLASS_UPDATE_NEXT_REVISION = 1; export const INITIAL_CALLBACK_REVISION = 0; @@ -12,4 +15,5 @@ export const SCHEDULER_SETTLE_WAIT_MS = 140; export const SLOW_QUERY_DELAY_MS = 200; export const STORE_VERSION_INCREMENT = 1; export const STRICT_MODE_CONSTRUCTION_RUNS = 2; +export const STRICT_MODE_HOOK_UPDATER_RUNS = 2; export const UNOBSERVED_CALLBACK_REVISION = -1; diff --git a/packages/prover/tests/runtime/hook-state-transition-oracle.spec.ts b/packages/prover/tests/runtime/hook-state-transition-oracle.spec.ts new file mode 100644 index 0000000000..a3ef1db160 --- /dev/null +++ b/packages/prover/tests/runtime/hook-state-transition-oracle.spec.ts @@ -0,0 +1,24 @@ +import { expect, test } from "@playwright/test"; +import { + HOOK_STATE_INCREMENT, + HOOK_STATE_INITIAL_COUNT, + STRICT_MODE_HOOK_UPDATER_RUNS, +} from "./constants.js"; + +test("Strict Mode invokes a Hook state updater twice while committing one transition", async ({ + page, +}) => { + await page.goto("/?oracle=hook-state-transition"); + + await page.getByRole("button", { name: "increment" }).click(); + + await expect(page.getByTestId("hook-state-count")).toHaveText( + String(HOOK_STATE_INITIAL_COUNT + HOOK_STATE_INCREMENT), + ); + await expect + .poll(() => page.evaluate(() => window.hookStateUpdaterRuns)) + .toBe(STRICT_MODE_HOOK_UPDATER_RUNS); + await expect(page.getByTestId("expected-hook-state-updater-runs")).toHaveText( + String(STRICT_MODE_HOOK_UPDATER_RUNS), + ); +}); diff --git a/packages/prover/tests/runtime/main.tsx b/packages/prover/tests/runtime/main.tsx index 09c496a24b..df3ea575aa 100644 --- a/packages/prover/tests/runtime/main.tsx +++ b/packages/prover/tests/runtime/main.tsx @@ -16,6 +16,9 @@ import type { ChangeEvent } from "react"; import { createRoot } from "react-dom/client"; import { FAST_QUERY_DELAY_MS, + HOOK_STATE_INCREMENT, + HOOK_STATE_INITIAL_COUNT, + HOOK_STATE_UPDATER_INITIAL_RUNS, CLASS_UPDATE_INITIAL_REVISION, CLASS_UPDATE_NEXT_REVISION, INITIAL_CALLBACK_REVISION, @@ -26,6 +29,7 @@ import { SLOW_QUERY_DELAY_MS, STORE_VERSION_INCREMENT, STRICT_MODE_CONSTRUCTION_RUNS, + STRICT_MODE_HOOK_UPDATER_RUNS, UNOBSERVED_CALLBACK_REVISION, } from "./constants.js"; @@ -44,6 +48,7 @@ declare global { listenerHits: number; observerHits: number; schedulerHits: number; + hookStateUpdaterRuns: number; } } @@ -60,6 +65,30 @@ window.classUnmounts = 0; window.listenerHits = 0; window.observerHits = 0; window.schedulerHits = 0; +window.hookStateUpdaterRuns = HOOK_STATE_UPDATER_INITIAL_RUNS; + +const HookStateTransitionOracle = () => { + const [count, setCount] = useState(HOOK_STATE_INITIAL_COUNT); + return ( +
    + + {count} + + {STRICT_MODE_HOOK_UPDATER_RUNS} + +
    + ); +}; const LeakyListener = () => { useEffect(() => { @@ -882,20 +911,24 @@ const RuntimeOracle = () => { if (oracle === "class-construction") { return ; } + if (oracle === "hook-state-transition") { + return ; + } return ; }; const rootElement = document.getElementById("root"); if (!rootElement) throw new Error("Missing runtime oracle root"); const oracle = new URLSearchParams(window.location.search).get("oracle"); -const isClassLifecycleOracle = +const isStrictModeOracle = oracle === "class-listener" || oracle === "class-scheduler" || oracle === "class-construction" || oracle === "class-state-ownership" || - oracle === "class-state-transition"; + oracle === "class-state-transition" || + oracle === "hook-state-transition"; createRoot(rootElement).render( - isClassLifecycleOracle ? ( + isStrictModeOracle ? ( From 1605fd725505e5f3aee345edb7aa78cdc87cae3f Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Tue, 28 Jul 2026 19:28:02 +0000 Subject: [PATCH 11/23] feat(prover): certify Transition Actions --- packages/prover/README.md | 10 + packages/prover/research-log.md | 124 ++++++- .../prover/src/analyze-boundary-coverage.ts | 74 ++++- packages/prover/src/analyze-react-unit.ts | 3 + .../prover/src/analyze-transition-actions.ts | 84 +++++ .../prover/src/build-react-semantic-graph.ts | 163 +++++++++ .../prover/src/check-react-proof-report.ts | 139 ++++++++ packages/prover/src/collect-hook-bindings.ts | 23 +- .../src/collect-hook-state-transitions.ts | 24 +- .../prover/src/collect-transition-actions.ts | 309 ++++++++++++++++++ packages/prover/src/constants.ts | 7 +- packages/prover/src/index.ts | 3 + packages/prover/src/prove-react-app.ts | 1 + packages/prover/src/types.ts | 32 ++ .../prover/src/utils/get-resolved-symbol.ts | 6 +- .../is-react-hook-dependency-reference.ts | 25 ++ .../src/app.tsx | 21 ++ .../tsconfig.json | 4 + .../src/app.tsx | 11 + .../tsconfig.json | 4 + .../src/app.tsx | 20 ++ .../tsconfig.json | 4 + .../src/app.tsx | 8 + .../tsconfig.json | 4 + .../src/app.tsx | 12 + .../tsconfig.json | 4 + .../proved-transition-lookalike/src/app.tsx | 15 + .../proved-transition-lookalike/tsconfig.json | 4 + .../proved-transition-tabs/src/app.tsx | 30 ++ .../proved-transition-tabs/tsconfig.json | 4 + .../proved-use-transition-action/src/app.tsx | 18 + .../tsconfig.json | 4 + .../prover/tests/fixtures/react-shim.d.ts | 2 + .../src/app.tsx | 14 + .../tsconfig.json | 4 + .../src/app.tsx | 15 + .../tsconfig.json | 4 + packages/prover/tests/prove-react-app.test.ts | 164 +++++++++- packages/prover/tests/runtime/constants.ts | 3 + packages/prover/tests/runtime/main.tsx | 34 +- .../runtime/transition-action-oracle.spec.ts | 17 + 41 files changed, 1408 insertions(+), 43 deletions(-) create mode 100644 packages/prover/src/analyze-transition-actions.ts create mode 100644 packages/prover/src/collect-transition-actions.ts create mode 100644 packages/prover/src/utils/is-react-hook-dependency-reference.ts create mode 100644 packages/prover/tests/fixtures/incomplete-async-transition-action/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-async-transition-action/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-opaque-transition-action/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-opaque-transition-action/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-transition-control-prop/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-transition-control-prop/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-transition-starter-escape/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-transition-starter-escape/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-use-transition-tuple/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-use-transition-tuple/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-transition-lookalike/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-transition-lookalike/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-transition-tabs/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-transition-tabs/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-use-transition-action/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-use-transition-action/tsconfig.json create mode 100644 packages/prover/tests/fixtures/refuted-transition-controlled-input/src/app.tsx create mode 100644 packages/prover/tests/fixtures/refuted-transition-controlled-input/tsconfig.json create mode 100644 packages/prover/tests/fixtures/refuted-transition-derived-controlled-input/src/app.tsx create mode 100644 packages/prover/tests/fixtures/refuted-transition-derived-controlled-input/tsconfig.json create mode 100644 packages/prover/tests/runtime/transition-action-oracle.spec.ts diff --git a/packages/prover/README.md b/packages/prover/README.md index 8d253135e8..d79cb5afa7 100644 --- a/packages/prover/README.md +++ b/packages/prover/README.md @@ -70,6 +70,12 @@ The report includes: 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; +- 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; - 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. @@ -105,6 +111,10 @@ Hook state-transition certificates additionally require a non-class owner, phase 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. +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. ## Verification diff --git a/packages/prover/research-log.md b/packages/prover/research-log.md index a911c7366f..52a0325c21 100644 --- a/packages/prover/research-log.md +++ b/packages/prover/research-log.md @@ -284,7 +284,7 @@ builtin-hook calls, cross-module JSX render edges, and effect dependency, captur 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. Schema version 16 also records every source-resolved project helper reachable from +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 @@ -294,7 +294,7 @@ obligations and graph extraction share the symbol-resolved collectors. A React C therefore replace individual fact producers without changing the report contract or proof consumers. -Schema version 16 records the call edges that justify helper reachability. Direct source calls, +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 @@ -381,7 +381,7 @@ 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. Schema version 16 stores callback sets and completeness independently for all three and +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 @@ -454,16 +454,20 @@ 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 -- Class components, which are discovered but currently force `incomplete` +- Symbol-resolved `Component` and `PureComponent` classes with conservative construction, + lifecycle, state-transition, and state-ownership certificates -Each function unit receives these obligations: +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 | @@ -476,6 +480,7 @@ Each function unit receives these obligations: | `reducer-purity` | Reducer and reducer-initializer transition purity | | `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 | @@ -711,7 +716,8 @@ Known regions that must force `incomplete` until modeled: and emitter `on`/`once` contracts - Mutable-object external-store snapshots requiring cache summaries, selectors, or third-party store contracts -- Transitions, deferred values, optimistic state, and Actions beyond certified `useState` updates +- 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 @@ -745,7 +751,7 @@ components and hooks, including hooks hidden in incorrectly named helper functio ### Test stack -Current checkpoint: 265 TypeScript fixture projects, 456 static tests, and 35 Chromium runtime +Current checkpoint: 275 TypeScript fixture projects, 472 static tests, and 36 Chromium runtime oracles. - Vite Plus supplies package build and Vitest-compatible static tests. @@ -1218,3 +1224,107 @@ 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`, `useOptimistic`, `useActionState`, form or +Server Actions, Suspense fallback preservation, or whole-application transition state machines. + +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. diff --git a/packages/prover/src/analyze-boundary-coverage.ts b/packages/prover/src/analyze-boundary-coverage.ts index e63a84eea6..f5617be3ae 100644 --- a/packages/prover/src/analyze-boundary-coverage.ts +++ b/packages/prover/src/analyze-boundary-coverage.ts @@ -3,6 +3,7 @@ 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"; @@ -11,12 +12,14 @@ 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 { 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 { @@ -140,6 +143,50 @@ export const analyzeBoundaryCoverage = ( )), ); }; + 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, @@ -224,6 +271,7 @@ export const analyzeBoundaryCoverage = ( for (const executionRoot of executionRoots) { const reachabilityGraph = collectReachableFunctionGraph(executionRoot, context.typeChecker); for (const unmodeledUse of reachabilityGraph.unmodeledCallableUses) { + if (getModeledTransitionAction(unmodeledUse.node)?.sourceComplete) continue; const location = unmodeledUse.node.getStart(); const locationKey = `${unmodeledUse.node.getSourceFile().fileName}:${location}`; if (unmodeledCallableUseLocations.has(locationKey)) continue; @@ -292,19 +340,35 @@ export const analyzeBoundaryCoverage = ( ); } } - const finalCallName = getCanonicalHookName(node, context.typeChecker); + const canonicalReactApiName = getCanonicalReactApiName(node.expression, context.typeChecker); const isModeledContextRead = - finalCallName === "use" && + canonicalReactApiName === "use" && Boolean( node.arguments[0] && isReactContextExpression(node.arguments[0], context.typeChecker), ); - if (finalCallName && REACT_UNMODELED_HOOK_NAMES.has(finalCallName) && !isModeledContextRead) { + if ( + canonicalReactApiName && + REACT_UNMODELED_HOOK_NAMES.has(canonicalReactApiName) && + !isModeledContextRead && + !(canonicalReactApiName === "useTransition" && isModeledUseTransitionCall(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, - `${finalCallName} does not yet have a complete lifecycle model`, - ["render", finalCallName, "unmodeled React primitive"], + "A Transition Action crosses an unproved callback, async, or state-priority boundary", + ["Transition Action", transitionAction.status, "incomplete execution model"], ), ); } diff --git a/packages/prover/src/analyze-react-unit.ts b/packages/prover/src/analyze-react-unit.ts index e71d4b64d3..64430800a7 100644 --- a/packages/prover/src/analyze-react-unit.ts +++ b/packages/prover/src/analyze-react-unit.ts @@ -20,6 +20,7 @@ import { analyzeReducerPurity } from "./analyze-reducer-purity.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 { createEvidence } from "./create-evidence.js"; import { createObligation } from "./create-obligation.js"; import { getNodeLocation } from "./get-node-location.js"; @@ -49,6 +50,7 @@ const ALL_REACT_PROOF_CLAIMS: ReadonlyArray = [ ReactProofClaim.RefAccess, ReactProofClaim.RenderPurity, ReactProofClaim.ScheduledCallbackLifetime, + ReactProofClaim.TransitionActions, ]; export const analyzeReactUnit = ( @@ -144,6 +146,7 @@ export const analyzeReactUnit = ( analyzeRefAccess(unit.functionNode, context), analyzeRenderPurity(unit.functionNode, context), analyzeScheduledCallbackLifetime(unit, context), + analyzeTransitionActions(unit, context), ], }; }; diff --git a/packages/prover/src/analyze-transition-actions.ts b/packages/prover/src/analyze-transition-actions.ts new file mode 100644 index 0000000000..0c66d40fe1 --- /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/build-react-semantic-graph.ts b/packages/prover/src/build-react-semantic-graph.ts index eb43adacfc..90fca7b232 100644 --- a/packages/prover/src/build-react-semantic-graph.ts +++ b/packages/prover/src/build-react-semantic-graph.ts @@ -26,6 +26,8 @@ import { import { collectHookBindings } from "./collect-hook-bindings.js"; import { collectHookCalls } from "./collect-hook-calls.js"; import { collectHookStateTransitions } from "./collect-hook-state-transitions.js"; +import { collectTransitionActions } from "./collect-transition-actions.js"; +import type { TransitionActionDescriptor } from "./collect-transition-actions.js"; import { collectReactiveCaptures } from "./collect-reactive-captures.js"; import { collectReachableFunctionGraph } from "./collect-reachable-functions.js"; import { @@ -59,6 +61,7 @@ import { ReactIdentityStability, ReactSemanticCallbackKind, ReactSemanticEdgeKind, + ReactTransitionActionStatus, ReactUnitKind, } from "./types.js"; import type { @@ -84,6 +87,7 @@ import type { ReactSemanticGraph, ReactSemanticHookCall, ReactSemanticHookStateTransition, + ReactSemanticTransitionAction, ReactSemanticReachableFunction, ReactSemanticRender, ReactSemanticEffectResource, @@ -166,6 +170,16 @@ interface HookStateTransitionGraphFacts extends CallbackGraphFacts { transitions: ReadonlyArray; } +interface TransitionActionGraphFacts extends CallbackGraphFacts { + actions: ReadonlyArray; +} + +interface TransitionActionGraphIdentity { + actionCallback: ReactSemanticCallback | null; + actionId: string; + descriptor: TransitionActionDescriptor; +} + interface CallbackPropReachabilityDescriptor { callbackDescriptor: ComponentCallbackDescriptor; callbackFact: ReactSemanticCallback; @@ -1867,6 +1881,141 @@ const collectReducerCallbacks = ( return { 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.ClassMount, + ReactExecutionPhase.ClassUpdate, + ReactExecutionPhase.Deferred, + ReactExecutionPhase.EffectCleanup, + ReactExecutionPhase.EffectEvent, + ReactExecutionPhase.EffectSetup, + ReactExecutionPhase.Event, + ReactExecutionPhase.ExternalStoreSubscription, + ReactExecutionPhase.TransitionAction, + ]); + const actions = actionIdentities.map( + ({ actionCallback, actionId, descriptor }): ReactSemanticTransitionAction => { + const containingFunction = descriptor.callExpression + ? getContainingFunction(descriptor.callExpression) + : null; + const containingLocation = containingFunction + ? getNodeLocation(containingFunction, context.rootDirectory) + : null; + const executionCallbackIds = containingLocation + ? [ + ...new Set([ + ...allCallbacks.flatMap((callback) => + callback.ownerId === identity.semanticUnit.id && + areProofLocationsEqual(callback.location, containingLocation) + ? [callback.id] + : [], + ), + ...allReachableFunctions.flatMap((reachableFunction) => + reachableFunction.ownerId === identity.semanticUnit.id && + areProofLocationsEqual(reachableFunction.location, containingLocation) + ? [reachableFunction.rootCallbackId] + : [], + ), + ]), + ] + : []; + 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 collectHookStateTransitionGraph = ( identity: UnitGraphIdentity, existingCallbacks: ReadonlyArray, @@ -2320,6 +2469,7 @@ export const buildReactSemanticGraph = ( const classStateWrites: ReactSemanticClassStateWrite[] = []; const classStateTransitions: ReactSemanticClassStateTransition[] = []; const hookStateTransitions: ReactSemanticHookStateTransition[] = []; + const transitionActions: ReactSemanticTransitionAction[] = []; const effectEvents: ReactSemanticEffectEvent[] = []; const externalStores: ReactSemanticExternalStore[] = []; const asyncTasks: ReactSemanticAsyncTask[] = []; @@ -2430,6 +2580,18 @@ export const buildReactSemanticGraph = ( reachableFunctions.push(...externalStoreGraph.reachableFunctions); functionCalls.push(...externalStoreGraph.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); @@ -2481,6 +2643,7 @@ export const buildReactSemanticGraph = ( classStateWrites, classStateTransitions, hookStateTransitions, + 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 index 47a7e43e78..3cbcf5bc10 100644 --- a/packages/prover/src/check-react-proof-report.ts +++ b/packages/prover/src/check-react-proof-report.ts @@ -24,6 +24,8 @@ import { ReactSemanticCallbackKind, ReactSemanticEdgeKind, ReactSemanticFunctionCallKind, + ReactTransitionActionStatus, + ReactTransitionStarterKind, ReactUnitKind, } from "./types.js"; import { areProofLocationsEqual } from "./utils/are-proof-locations-equal.js"; @@ -35,6 +37,19 @@ import type { } from "./types.js"; const HOOK_STATE_UPDATER_STATUSES = new Set(Object.values(ReactHookStateUpdaterStatus)); +const TRANSITION_ACTION_STATUSES = new Set(Object.values(ReactTransitionActionStatus)); +const TRANSITION_STARTER_KINDS = new Set(Object.values(ReactTransitionStarterKind)); +const TRANSITION_ACTION_ORIGIN_PHASES = new Set([ + ReactExecutionPhase.ClassMount, + ReactExecutionPhase.ClassUpdate, + ReactExecutionPhase.Deferred, + ReactExecutionPhase.EffectCleanup, + ReactExecutionPhase.EffectEvent, + ReactExecutionPhase.EffectSetup, + ReactExecutionPhase.Event, + ReactExecutionPhase.ExternalStoreSubscription, + ReactExecutionPhase.TransitionAction, +]); const addFailure = ( failures: ReactProofCertificateFailure[], @@ -173,6 +188,22 @@ const expectedHookStateTransitionStatus = ( : 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 expectedScheduledCallbackLifetimeStatus = ( unit: ReactSemanticUnit, report: ReactAppProofReport, @@ -310,6 +341,17 @@ const checkClaimCoverage = ( `Hook state transition facts require ${expectedHookStateStatus}, not ${hookStateTransitions.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 scheduledCallbackLifetime = unitProof.obligations.find( (obligation) => obligation.claim === ReactProofClaim.ScheduledCallbackLifetime, ); @@ -879,6 +921,98 @@ const checkGraphReferences = ( ); } } + 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]), ); @@ -1708,6 +1842,11 @@ export const checkReactProofReport = (report: ReactAppProofReport): ReactProofCe "Hook state transitions", report.graph.hookStateTransitions.map((transition) => transition.id), ); + checkUniqueIds( + failures, + "Transition Actions", + report.graph.transitionActions.map((action) => action.id), + ); checkUniqueIds( failures, "Class state writes", diff --git a/packages/prover/src/collect-hook-bindings.ts b/packages/prover/src/collect-hook-bindings.ts index ce8f140003..33a69d9676 100644 --- a/packages/prover/src/collect-hook-bindings.ts +++ b/packages/prover/src/collect-hook-bindings.ts @@ -1,6 +1,7 @@ import ts from "typescript"; import { collectEffectEventBindings } from "./collect-effect-event-bindings.js"; -import { getCanonicalHookName } from "./get-canonical-hook-name.js"; +import { 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 HookBindings { @@ -9,6 +10,7 @@ export interface HookBindings { stateSetters: ReadonlySet; stateValueBySetter: ReadonlyMap; stateValues: ReadonlySet; + transitionStarters: ReadonlySet; } const getBindingSymbol = ( @@ -30,6 +32,7 @@ export const collectHookBindings = ( 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; @@ -39,7 +42,7 @@ export const collectHookBindings = ( node.initializer && ts.isCallExpression(node.initializer) ) { - const callName = getCanonicalHookName(node.initializer, typeChecker); + const callName = getCanonicalReactApiName(node.initializer.expression, typeChecker); if ( (callName === "useState" || callName === "useReducer") && ts.isArrayBindingPattern(node.name) @@ -62,9 +65,23 @@ export const collectHookBindings = ( const refSymbol = getBindingSymbol(node.name, typeChecker); if (refSymbol) refs.add(refSymbol); } + 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 { effectEvents, refs, stateSetters, stateValueBySetter, stateValues }; + return { + effectEvents, + refs, + stateSetters, + stateValueBySetter, + stateValues, + transitionStarters, + }; }; diff --git a/packages/prover/src/collect-hook-state-transitions.ts b/packages/prover/src/collect-hook-state-transitions.ts index e070d250a1..4ad0375a6f 100644 --- a/packages/prover/src/collect-hook-state-transitions.ts +++ b/packages/prover/src/collect-hook-state-transitions.ts @@ -1,13 +1,13 @@ import ts from "typescript"; import { analyzeUpdaterFunction } from "./analyze-updater-function.js"; import { collectHookBindings } from "./collect-hook-bindings.js"; -import { getCanonicalHookName } from "./get-canonical-hook-name.js"; import { isIdentifierReference } from "./is-identifier-reference.js"; import { isNodeWithin } from "./is-node-within.js"; import { ReactHookStateUpdaterStatus, 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 { isReactHookDependencyReference } from "./utils/is-react-hook-dependency-reference.js"; export interface HookStateTransitionDescriptor { callExpression: ts.CallExpression | null; @@ -22,26 +22,6 @@ const doesTypeIncludeCallable = (type: ts.Type): boolean => type.getCallSignatures().length > 0 || (type.isUnionOrIntersection() && type.types.some(doesTypeIncludeCallable)); -const isHookDependencyReference = ( - identifier: ts.Identifier, - context: ReactAnalysisContext, -): 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; - const dependencyIndex = hookCall.arguments.indexOf(dependencyArray); - return dependencyIndex > 0 && Boolean(getCanonicalHookName(hookCall, context.typeChecker)); -}; - const getUpdaterStatus = ( updaterExpression: ts.Expression, context: ReactAnalysisContext, @@ -139,7 +119,7 @@ export const collectHookStateTransitions = ( if ( setterSymbol && stateName && - !isHookDependencyReference(node, context) && + !isReactHookDependencyReference(node, context.typeChecker) && !transitions.some( (transition) => transition.callExpression && isNodeWithin(node, transition.callExpression.expression), diff --git a/packages/prover/src/collect-transition-actions.ts b/packages/prover/src/collect-transition-actions.ts new file mode 100644 index 0000000000..56f8b3b4f9 --- /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/constants.ts b/packages/prover/src/constants.ts index 0b78bf5841..2e314d0fde 100644 --- a/packages/prover/src/constants.ts +++ b/packages/prover/src/constants.ts @@ -1,7 +1,7 @@ import { ReactEffectResourceKind } from "./types.js"; -export const REACT_PROOF_SCHEMA_VERSION = 17; -export const REACT_SEMANTIC_GRAPH_SCHEMA_VERSION = 23; +export const REACT_PROOF_SCHEMA_VERSION = 18; +export const REACT_SEMANTIC_GRAPH_SCHEMA_VERSION = 24; 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; @@ -9,6 +9,9 @@ export const FIRST_SOURCE_COLUMN = 1; 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_TRANSITION_ACTION_INDEX = 0; +export const REACT_TRANSITION_STARTER_INDEX = 1; +export const REACT_USE_TRANSITION_TUPLE_LENGTH = 2; export const REACT_EFFECT_HOOK_NAMES = new Set([ "useEffect", diff --git a/packages/prover/src/index.ts b/packages/prover/src/index.ts index 7cb0bbaa3c..02b255e1b1 100644 --- a/packages/prover/src/index.ts +++ b/packages/prover/src/index.ts @@ -29,6 +29,8 @@ export { ReactSemanticEdgeKind, ReactSemanticCallbackKind, ReactSemanticFunctionCallKind, + ReactTransitionActionStatus, + ReactTransitionStarterKind, ReactUnitKind, } from "./types.js"; export type { @@ -69,6 +71,7 @@ export type { ReactSemanticFunctionCall, ReactSemanticHookCall, ReactSemanticHookStateTransition, + ReactSemanticTransitionAction, ReactSemanticReachableFunction, ReactSemanticRender, ReactSemanticScheduler, diff --git a/packages/prover/src/prove-react-app.ts b/packages/prover/src/prove-react-app.ts index be43afdd19..41a7fd3c54 100644 --- a/packages/prover/src/prove-react-app.ts +++ b/packages/prover/src/prove-react-app.ts @@ -48,6 +48,7 @@ export const proveReactApp = (input: ProveReactAppInput): ReactAppProofReport => classStateWrites: [], classStateTransitions: [], hookStateTransitions: [], + transitionActions: [], compiler: { version: REACT_COMPILER_VERSION, phase: REACT_COMPILER_FACT_PHASE, diff --git a/packages/prover/src/types.ts b/packages/prover/src/types.ts index 489abfe685..c9cef9cc08 100644 --- a/packages/prover/src/types.ts +++ b/packages/prover/src/types.ts @@ -35,6 +35,7 @@ export enum ReactProofClaim { RefAccess = "ref-access", RenderPurity = "render-purity", ScheduledCallbackLifetime = "scheduled-callback-lifetime", + TransitionActions = "transition-actions", } export enum ReactUnitKind { @@ -79,6 +80,7 @@ export enum ReactExecutionPhase { Render = "render", ServerRender = "server-render", StateTransition = "state-transition", + TransitionAction = "transition-action", } export enum ReactSemanticCallbackKind { @@ -101,6 +103,7 @@ export enum ReactSemanticCallbackKind { ResourceCallback = "resource-callback", ScheduledCallback = "scheduled-callback", ServerSnapshot = "server-snapshot", + TransitionAction = "transition-action", } export enum ReactSemanticFunctionCallKind { @@ -581,6 +584,34 @@ export interface ReactSemanticHookStateTransition { 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; @@ -646,6 +677,7 @@ export interface ReactSemanticGraph { classStateWrites: ReadonlyArray; classStateTransitions: ReadonlyArray; hookStateTransitions: ReadonlyArray; + transitionActions: ReadonlyArray; compiler: ReactCompilerGraph; } diff --git a/packages/prover/src/utils/get-resolved-symbol.ts b/packages/prover/src/utils/get-resolved-symbol.ts index 9397e3d7df..ee232b80f5 100644 --- a/packages/prover/src/utils/get-resolved-symbol.ts +++ b/packages/prover/src/utils/get-resolved-symbol.ts @@ -1,7 +1,11 @@ import ts from "typescript"; export const getResolvedSymbol = (node: ts.Node, typeChecker: ts.TypeChecker): ts.Symbol | null => { - const symbol = typeChecker.getSymbolAtLocation(node); + 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/is-react-hook-dependency-reference.ts b/packages/prover/src/utils/is-react-hook-dependency-reference.ts new file mode 100644 index 0000000000..e12e1914de --- /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/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 0000000000..832a0a2a5d --- /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 0000000000..d2a921ed01 --- /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-opaque-transition-action/src/app.tsx b/packages/prover/tests/fixtures/incomplete-opaque-transition-action/src/app.tsx new file mode 100644 index 0000000000..501d617ed8 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-opaque-transition-action/src/app.tsx @@ -0,0 +1,11 @@ +import { startTransition } from "react"; + +interface ActionButtonProperties { + action: () => void; +} + +export const ActionButton = ({ action }: ActionButtonProperties) => ( + +); diff --git a/packages/prover/tests/fixtures/incomplete-opaque-transition-action/tsconfig.json b/packages/prover/tests/fixtures/incomplete-opaque-transition-action/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-opaque-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-transition-control-prop/src/app.tsx b/packages/prover/tests/fixtures/incomplete-transition-control-prop/src/app.tsx new file mode 100644 index 0000000000..72fdd64e39 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-transition-control-prop/src/app.tsx @@ -0,0 +1,20 @@ +import { startTransition, useState } from "react"; + +interface SearchFieldProperties { + value: string; +} + +const SearchField = ({ value }: SearchFieldProperties) => ; + +export const Search = () => { + const [query, setQuery] = useState(""); + + return ( +
    + + +
    + ); +}; diff --git a/packages/prover/tests/fixtures/incomplete-transition-control-prop/tsconfig.json b/packages/prover/tests/fixtures/incomplete-transition-control-prop/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-transition-control-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-transition-starter-escape/src/app.tsx b/packages/prover/tests/fixtures/incomplete-transition-starter-escape/src/app.tsx new file mode 100644 index 0000000000..1dd8a6d0e3 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-transition-starter-escape/src/app.tsx @@ -0,0 +1,8 @@ +import { useTransition } from "react"; + +export const TransitionProvider = () => { + const [, startTransition] = useTransition(); + const controls = { startTransition }; + + return
    {Boolean(controls)}
    ; +}; diff --git a/packages/prover/tests/fixtures/incomplete-transition-starter-escape/tsconfig.json b/packages/prover/tests/fixtures/incomplete-transition-starter-escape/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-transition-starter-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-use-transition-tuple/src/app.tsx b/packages/prover/tests/fixtures/incomplete-use-transition-tuple/src/app.tsx new file mode 100644 index 0000000000..84f34603df --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-use-transition-tuple/src/app.tsx @@ -0,0 +1,12 @@ +import { useState, useTransition } from "react"; + +export const Panel = () => { + const [panel, setPanel] = useState("overview"); + const transition = useTransition(); + + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/incomplete-use-transition-tuple/tsconfig.json b/packages/prover/tests/fixtures/incomplete-use-transition-tuple/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-use-transition-tuple/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-transition-lookalike/src/app.tsx b/packages/prover/tests/fixtures/proved-transition-lookalike/src/app.tsx new file mode 100644 index 0000000000..10519c103d --- /dev/null +++ b/packages/prover/tests/fixtures/proved-transition-lookalike/src/app.tsx @@ -0,0 +1,15 @@ +import { useState } from "react"; + +const startTransition = (action: () => void) => { + action(); +}; + +export const Counter = () => { + const [count, setCount] = useState(0); + + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/proved-transition-lookalike/tsconfig.json b/packages/prover/tests/fixtures/proved-transition-lookalike/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-transition-lookalike/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-transition-tabs/src/app.tsx b/packages/prover/tests/fixtures/proved-transition-tabs/src/app.tsx new file mode 100644 index 0000000000..8f43ba53df --- /dev/null +++ b/packages/prover/tests/fixtures/proved-transition-tabs/src/app.tsx @@ -0,0 +1,30 @@ +import { startTransition, useState } from "react"; + +interface Tab { + id: string; + label: string; +} + +const initialTabs: ReadonlyArray = [ + { id: "files", label: "Files" }, + { id: "settings", label: "Settings" }, +]; + +export const Tabs = () => { + const [tabs, setTabs] = useState(initialTabs); + + const removeTab = (tabId: string) => { + startTransition(() => { + setTabs((previousTabs) => previousTabs.filter((tab) => tab.id !== tabId)); + }); + }; + + return ( +
    + +

    {tabs.length} tabs

    +
    + ); +}; diff --git a/packages/prover/tests/fixtures/proved-transition-tabs/tsconfig.json b/packages/prover/tests/fixtures/proved-transition-tabs/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-transition-tabs/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-use-transition-action/src/app.tsx b/packages/prover/tests/fixtures/proved-use-transition-action/src/app.tsx new file mode 100644 index 0000000000..2f0e86dba5 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-use-transition-action/src/app.tsx @@ -0,0 +1,18 @@ +import { useCallback, useState, useTransition } from "react"; + +export const Panel = () => { + const [panel, setPanel] = useState("overview"); + const [isPending, startPanelTransition] = useTransition(); + const showActivity = useCallback(() => { + startPanelTransition(() => setPanel("activity")); + }, [startPanelTransition]); + + return ( +
    + +

    {panel}

    +
    + ); +}; diff --git a/packages/prover/tests/fixtures/proved-use-transition-action/tsconfig.json b/packages/prover/tests/fixtures/proved-use-transition-action/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-use-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/react-shim.d.ts b/packages/prover/tests/fixtures/react-shim.d.ts index a23f035a15..6a6eca5b0a 100644 --- a/packages/prover/tests/fixtures/react-shim.d.ts +++ b/packages/prover/tests/fixtures/react-shim.d.ts @@ -24,6 +24,7 @@ declare module "react" { export const createContext: (defaultValue: Value) => Context; export const memo: (component: Component) => Component; export const StrictMode: (properties: { children?: unknown }) => unknown; + export const startTransition: (action: () => void | Promise) => void; export const useEffect: ( setup: () => void | (() => void), dependencies?: ReadonlyArray, @@ -50,6 +51,7 @@ declare module "react" { getSnapshot: () => Snapshot, getServerSnapshot?: () => Snapshot, ) => Snapshot; + export const useTransition: () => [boolean, typeof startTransition]; export class Component, State = Record> { constructor(properties: Properties); diff --git a/packages/prover/tests/fixtures/refuted-transition-controlled-input/src/app.tsx b/packages/prover/tests/fixtures/refuted-transition-controlled-input/src/app.tsx new file mode 100644 index 0000000000..4cd19d3f1e --- /dev/null +++ b/packages/prover/tests/fixtures/refuted-transition-controlled-input/src/app.tsx @@ -0,0 +1,14 @@ +import { startTransition, useState } from "react"; +import type { ChangeEvent } from "react"; + +export const Search = () => { + const [query, setQuery] = useState(""); + + const updateQuery = (event: ChangeEvent) => { + startTransition(() => { + setQuery(event.currentTarget.value); + }); + }; + + return ; +}; diff --git a/packages/prover/tests/fixtures/refuted-transition-controlled-input/tsconfig.json b/packages/prover/tests/fixtures/refuted-transition-controlled-input/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/refuted-transition-controlled-input/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/refuted-transition-derived-controlled-input/src/app.tsx b/packages/prover/tests/fixtures/refuted-transition-derived-controlled-input/src/app.tsx new file mode 100644 index 0000000000..d24c9cfcb3 --- /dev/null +++ b/packages/prover/tests/fixtures/refuted-transition-derived-controlled-input/src/app.tsx @@ -0,0 +1,15 @@ +import { startTransition, useState } from "react"; + +export const Search = () => { + const [query, setQuery] = useState(""); + const displayedQuery = query.trim(); + + return ( +
    + + +
    + ); +}; diff --git a/packages/prover/tests/fixtures/refuted-transition-derived-controlled-input/tsconfig.json b/packages/prover/tests/fixtures/refuted-transition-derived-controlled-input/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/refuted-transition-derived-controlled-input/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/prove-react-app.test.ts b/packages/prover/tests/prove-react-app.test.ts index 628458122e..d19440e419 100644 --- a/packages/prover/tests/prove-react-app.test.ts +++ b/packages/prover/tests/prove-react-app.test.ts @@ -31,6 +31,8 @@ import { ReactSemanticEdgeKind, ReactSemanticCallbackKind, ReactSemanticFunctionCallKind, + ReactTransitionActionStatus, + ReactTransitionStarterKind, } from "../src/index.js"; interface RefutedFixtureExpectation { @@ -52,6 +54,16 @@ const proveFixture = (fixtureName: string) => }); const REFUTED_FIXTURES: ReadonlyArray = [ + { + fixtureName: "refuted-transition-controlled-input", + claim: ReactProofClaim.TransitionActions, + evidencePattern: /updates controlled input state/, + }, + { + fixtureName: "refuted-transition-derived-controlled-input", + claim: ReactProofClaim.TransitionActions, + evidencePattern: /updates controlled input state/, + }, { fixtureName: "refuted-impure-hook-state-updater", claim: ReactProofClaim.HookStateTransitions, @@ -467,6 +479,9 @@ describe("proveReactApp", () => { "proved-hook-direct-state-value", "proved-effect-functional-updater", "proved-state-setter-lookalikes", + "proved-transition-tabs", + "proved-use-transition-action", + "proved-transition-lookalike", "proved-external-store", "proved-effect-event", "proved-helper-effect-cleanup", @@ -572,8 +587,8 @@ describe("proveReactApp", () => { const report = proveFixture("proved-chat"); const effect = report.graph.effects[0]; - expect(report.schemaVersion).toBe(17); - expect(report.graph.schemaVersion).toBe(23); + expect(report.schemaVersion).toBe(18); + expect(report.graph.schemaVersion).toBe(24); expect(effect?.hookName).toBe("useEffect"); expect(effect?.callbackResolved).toBe(true); expect(effect?.dependencyMode).toBe(ReactEffectDependencyMode.Inline); @@ -2999,6 +3014,151 @@ describe("proveReactApp", () => { ).toBe(true); }); + it("certifies a realistic global Transition Action and its state update", () => { + const report = proveFixture("proved-transition-tabs"); + const action = report.graph.transitionActions[0]; + const actionCallback = report.graph.callbacks.find( + (callback) => callback.id === action?.actionCallbackId, + ); + const executionCallbacks = report.graph.callbacks.filter((callback) => + action?.executionCallbackIds.includes(callback.id), + ); + const hookTransition = report.graph.hookStateTransitions[0]; + const transitionProof = report.units[0]?.obligations.find( + (obligation) => obligation.claim === ReactProofClaim.TransitionActions, + ); + + expect(report.status).toBe(ReactAppProofStatus.Proved); + expect(action?.starterKind).toBe(ReactTransitionStarterKind.Global); + expect(action?.status).toBe(ReactTransitionActionStatus.Synchronous); + expect(action?.controlledStateNames).toEqual([]); + expect(action?.unknownControlStateNames).toEqual([]); + expect(action?.sourceComplete).toBe(true); + expect(action?.complete).toBe(true); + expect(actionCallback?.kind).toBe(ReactSemanticCallbackKind.TransitionAction); + expect(actionCallback?.phase).toBe(ReactExecutionPhase.TransitionAction); + expect( + executionCallbacks.some((callback) => callback.phase === ReactExecutionPhase.Event), + ).toBe(true); + expect(hookTransition?.executionCallbackIds).toContain(action?.actionCallbackId); + expect(transitionProof?.status).toBe(ReactObligationStatus.Proved); + expect(checkReactProofReport(report).status).toBe(ReactProofCertificateStatus.Valid); + }); + + it("certifies a useTransition starter without confusing a user lookalike", () => { + const hookReport = proveFixture("proved-use-transition-action"); + const lookalikeReport = proveFixture("proved-transition-lookalike"); + + expect(hookReport.status).toBe(ReactAppProofStatus.Proved); + expect(hookReport.graph.transitionActions).toHaveLength(1); + expect(hookReport.graph.transitionActions[0]?.starterKind).toBe( + ReactTransitionStarterKind.Hook, + ); + expect(hookReport.graph.transitionActions[0]?.complete).toBe(true); + expect(lookalikeReport.status).toBe(ReactAppProofStatus.Proved); + expect(lookalikeReport.graph.transitionActions).toEqual([]); + }); + + it("refutes a Transition update to direct controlled input state", () => { + const report = proveFixture("refuted-transition-controlled-input"); + const action = report.graph.transitionActions[0]; + const transitionProof = report.units[0]?.obligations.find( + (obligation) => obligation.claim === ReactProofClaim.TransitionActions, + ); + + expect(report.status).toBe(ReactAppProofStatus.Refuted); + expect(action?.status).toBe(ReactTransitionActionStatus.ControlledInput); + expect(action?.controlledStateNames).toEqual(["query"]); + expect(action?.sourceComplete).toBe(true); + expect(action?.complete).toBe(false); + expect(transitionProof?.status).toBe(ReactObligationStatus.Violated); + expect(checkReactProofReport(report).status).toBe(ReactProofCertificateStatus.Valid); + }); + + it("tracks a controlled input through an immutable derived alias", () => { + const report = proveFixture("refuted-transition-derived-controlled-input"); + + expect(report.status).toBe(ReactAppProofStatus.Refuted); + expect(report.graph.transitionActions[0]?.status).toBe( + ReactTransitionActionStatus.ControlledInput, + ); + expect(report.graph.transitionActions[0]?.controlledStateNames).toEqual(["query"]); + }); + + it.each([ + ["incomplete-async-transition-action", ReactTransitionActionStatus.Async], + ["incomplete-opaque-transition-action", ReactTransitionActionStatus.Opaque], + ["incomplete-transition-starter-escape", ReactTransitionActionStatus.StarterEscape], + ["incomplete-transition-control-prop", ReactTransitionActionStatus.UnknownControl], + ])("fails closed for incomplete Transition semantics in %s", (fixtureName, expectedStatus) => { + const report = proveFixture(fixtureName); + const action = report.graph.transitionActions.find( + (candidate) => candidate.status === expectedStatus, + ); + const actionOwner = report.graph.units.find((unit) => unit.id === action?.ownerId); + const transitionProof = report.units + .find( + (unit) => + unit.name === actionOwner?.name && + unit.location.filePath === actionOwner.location.filePath && + unit.location.line === actionOwner.location.line && + unit.location.column === actionOwner.location.column, + ) + ?.obligations.find((obligation) => obligation.claim === ReactProofClaim.TransitionActions); + + expect(report.status).toBe(ReactAppProofStatus.Incomplete); + expect(action?.sourceComplete).toBe(false); + expect(action?.complete).toBe(false); + expect(transitionProof?.status).toBe(ReactObligationStatus.Unknown); + expect(checkReactProofReport(report).status).toBe(ReactProofCertificateStatus.Valid); + }); + + it("keeps indirect useTransition tuple access outside the modeled boundary", () => { + const report = proveFixture("incomplete-use-transition-tuple"); + const boundaryProof = report.units[0]?.obligations.find( + (obligation) => obligation.claim === ReactProofClaim.BoundaryCoverage, + ); + + expect(report.status).toBe(ReactAppProofStatus.Incomplete); + expect(report.graph.transitionActions).toEqual([]); + expect(boundaryProof?.status).toBe(ReactObligationStatus.Unknown); + }); + + it("separates an async Action from its nested post-await Transition", () => { + const report = proveFixture("incomplete-async-transition-action"); + const outerAction = report.graph.transitionActions.find( + (action) => action.status === ReactTransitionActionStatus.Async, + ); + const nestedAction = report.graph.transitionActions.find( + (action) => action.status === ReactTransitionActionStatus.Synchronous, + ); + + expect(outerAction?.complete).toBe(false); + expect(nestedAction?.complete).toBe(true); + expect(nestedAction?.executionCallbackIds).toContain(outerAction?.actionCallbackId); + }); + + it("rejects a forged Transition Action certificate", () => { + const report = proveFixture("incomplete-async-transition-action"); + const certificate = checkReactProofReport({ + ...report, + graph: { + ...report.graph, + transitionActions: report.graph.transitionActions.map((action) => ({ + ...action, + status: ReactTransitionActionStatus.Synchronous, + sourceComplete: true, + complete: true, + })), + }, + }); + + expect(certificate.status).toBe(ReactProofCertificateStatus.Invalid); + expect( + certificate.failures.some((failure) => failure.description.includes("Transition Action")), + ).toBe(true); + }); + it("records concrete invalid state, constructor side-effect, setState, and missing-state issues", () => { const expectations: ReadonlyArray = [ { diff --git a/packages/prover/tests/runtime/constants.ts b/packages/prover/tests/runtime/constants.ts index 6f53655eca..fe6ff65793 100644 --- a/packages/prover/tests/runtime/constants.ts +++ b/packages/prover/tests/runtime/constants.ts @@ -16,4 +16,7 @@ export const SLOW_QUERY_DELAY_MS = 200; export const STORE_VERSION_INCREMENT = 1; export const STRICT_MODE_CONSTRUCTION_RUNS = 2; export const STRICT_MODE_HOOK_UPDATER_RUNS = 2; +export const TRANSITION_ACTION_DELAY_MS = 200; +export const TRANSITION_ACTION_EXPECTED_RUNS = 1; +export const TRANSITION_ACTION_INITIAL_RUNS = 0; export const UNOBSERVED_CALLBACK_REVISION = -1; diff --git a/packages/prover/tests/runtime/main.tsx b/packages/prover/tests/runtime/main.tsx index df3ea575aa..09ff80d485 100644 --- a/packages/prover/tests/runtime/main.tsx +++ b/packages/prover/tests/runtime/main.tsx @@ -11,6 +11,7 @@ import { useRef, useState, useSyncExternalStore, + useTransition, } from "react"; import type { ChangeEvent } from "react"; import { createRoot } from "react-dom/client"; @@ -30,6 +31,8 @@ import { STORE_VERSION_INCREMENT, STRICT_MODE_CONSTRUCTION_RUNS, STRICT_MODE_HOOK_UPDATER_RUNS, + TRANSITION_ACTION_DELAY_MS, + TRANSITION_ACTION_INITIAL_RUNS, UNOBSERVED_CALLBACK_REVISION, } from "./constants.js"; @@ -49,6 +52,7 @@ declare global { observerHits: number; schedulerHits: number; hookStateUpdaterRuns: number; + transitionActionRuns: number; } } @@ -66,6 +70,30 @@ window.listenerHits = 0; window.observerHits = 0; window.schedulerHits = 0; window.hookStateUpdaterRuns = HOOK_STATE_UPDATER_INITIAL_RUNS; +window.transitionActionRuns = TRANSITION_ACTION_INITIAL_RUNS; + +const TransitionActionOracle = () => { + const [panel, setPanel] = useState("overview"); + const [isPending, startPanelTransition] = useTransition(); + const showActivity = () => { + startPanelTransition(async () => { + window.transitionActionRuns += 1; + await new Promise((resolve) => { + setTimeout(resolve, TRANSITION_ACTION_DELAY_MS); + }); + startPanelTransition(() => setPanel("activity")); + }); + }; + return ( +
    + + {panel} + {String(isPending)} +
    + ); +}; const HookStateTransitionOracle = () => { const [count, setCount] = useState(HOOK_STATE_INITIAL_COUNT); @@ -914,6 +942,9 @@ const RuntimeOracle = () => { if (oracle === "hook-state-transition") { return ; } + if (oracle === "transition-action") { + return ; + } return ; }; @@ -926,7 +957,8 @@ const isStrictModeOracle = oracle === "class-construction" || oracle === "class-state-ownership" || oracle === "class-state-transition" || - oracle === "hook-state-transition"; + oracle === "hook-state-transition" || + oracle === "transition-action"; createRoot(rootElement).render( isStrictModeOracle ? ( diff --git a/packages/prover/tests/runtime/transition-action-oracle.spec.ts b/packages/prover/tests/runtime/transition-action-oracle.spec.ts new file mode 100644 index 0000000000..41f8fd1c90 --- /dev/null +++ b/packages/prover/tests/runtime/transition-action-oracle.spec.ts @@ -0,0 +1,17 @@ +import { expect, test } from "@playwright/test"; +import { TRANSITION_ACTION_EXPECTED_RUNS } from "./constants.js"; + +test("an async Action stays pending and nests its post-await state Transition", async ({ + page, +}) => { + await page.goto("/?oracle=transition-action"); + + await page.getByRole("button", { name: "show activity" }).click(); + + await expect(page.getByTestId("transition-pending")).toHaveText("true"); + await expect + .poll(() => page.evaluate(() => window.transitionActionRuns)) + .toBe(TRANSITION_ACTION_EXPECTED_RUNS); + await expect(page.getByTestId("transition-panel")).toHaveText("activity"); + await expect(page.getByTestId("transition-pending")).toHaveText("false"); +}); From 98037c6c7b7606761295d8cb87fe7c704772abff Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Tue, 28 Jul 2026 20:18:08 +0000 Subject: [PATCH 12/23] feat(prover): certify Form Actions and optimistic state --- packages/prover/README.md | 14 + packages/prover/research-log.md | 123 +++++- .../prover/src/analyze-boundary-coverage.ts | 118 ++++- packages/prover/src/analyze-form-actions.ts | 67 +++ .../prover/src/analyze-optimistic-state.ts | 114 +++++ packages/prover/src/analyze-react-unit.ts | 6 + .../prover/src/build-react-semantic-graph.ts | 412 +++++++++++++++--- .../prover/src/check-react-proof-report.ts | 281 ++++++++++++ packages/prover/src/collect-form-actions.ts | 197 +++++++++ packages/prover/src/collect-hook-bindings.ts | 51 ++- .../src/collect-hook-state-transitions.ts | 47 +- .../prover/src/collect-optimistic-state.ts | 163 +++++++ packages/prover/src/constants.ts | 8 +- .../src/create-component-callback-flow.ts | 29 ++ packages/prover/src/index.ts | 7 + packages/prover/src/prove-react-app.ts | 3 + packages/prover/src/types.ts | 74 ++++ .../utils/analyze-state-update-expression.ts | 45 ++ .../utils/collect-execution-callback-ids.ts | 37 ++ .../utils/does-type-have-call-signature.ts | 5 + .../src/app.tsx | 9 + .../tsconfig.json | 4 + .../src/app.tsx | 15 + .../tsconfig.json | 4 + .../incomplete-form-action-prop/src/app.tsx | 9 + .../incomplete-form-action-prop/tsconfig.json | 4 + .../src/app.tsx | 21 + .../tsconfig.json | 4 + .../src/app.tsx | 8 + .../tsconfig.json | 4 + .../proved-form-action-submitter/src/app.tsx | 15 + .../tsconfig.json | 4 + .../src/app.tsx | 22 + .../tsconfig.json | 4 + .../proved-optimistic-form/src/app.tsx | 20 + .../proved-optimistic-form/tsconfig.json | 4 + .../src/app.tsx | 16 + .../tsconfig.json | 4 + .../prover/tests/fixtures/react-shim.d.ts | 9 + .../src/app.tsx | 10 + .../tsconfig.json | 4 + .../src/app.tsx | 17 + .../tsconfig.json | 4 + .../src/app.tsx | 18 + .../tsconfig.json | 4 + .../src/app.tsx | 14 + .../tsconfig.json | 4 + .../src/app.tsx | 11 + .../tsconfig.json | 4 + .../src/app.tsx | 5 + .../tsconfig.json | 4 + packages/prover/tests/prove-react-app.test.ts | 160 ++++++- packages/prover/tests/runtime/constants.ts | 3 + packages/prover/tests/runtime/main.tsx | 51 ++- .../optimistic-form-action-oracle.spec.ts | 16 + 55 files changed, 2207 insertions(+), 103 deletions(-) create mode 100644 packages/prover/src/analyze-form-actions.ts create mode 100644 packages/prover/src/analyze-optimistic-state.ts create mode 100644 packages/prover/src/collect-form-actions.ts create mode 100644 packages/prover/src/collect-optimistic-state.ts create mode 100644 packages/prover/src/utils/analyze-state-update-expression.ts create mode 100644 packages/prover/src/utils/collect-execution-callback-ids.ts create mode 100644 packages/prover/src/utils/does-type-have-call-signature.ts create mode 100644 packages/prover/tests/fixtures/incomplete-composed-form-action-submitter/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-composed-form-action-submitter/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-dynamic-form-action-control/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-dynamic-form-action-control/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-form-action-prop/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-form-action-prop/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-optimistic-async-transition/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-optimistic-async-transition/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-optimistic-setter-escape/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-optimistic-setter-escape/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-form-action-submitter/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-form-action-submitter/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-helper-spread-form-action/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-helper-spread-form-action/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-optimistic-form/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-optimistic-form/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-optimistic-transition-updater/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-optimistic-transition-updater/tsconfig.json create mode 100644 packages/prover/tests/fixtures/refuted-impure-optimistic-reducer/src/app.tsx create mode 100644 packages/prover/tests/fixtures/refuted-impure-optimistic-reducer/tsconfig.json create mode 100644 packages/prover/tests/fixtures/refuted-impure-optimistic-updater/src/app.tsx create mode 100644 packages/prover/tests/fixtures/refuted-impure-optimistic-updater/tsconfig.json create mode 100644 packages/prover/tests/fixtures/refuted-mixed-optimistic-action-roots/src/app.tsx create mode 100644 packages/prover/tests/fixtures/refuted-mixed-optimistic-action-roots/tsconfig.json create mode 100644 packages/prover/tests/fixtures/refuted-optimistic-outside-action/src/app.tsx create mode 100644 packages/prover/tests/fixtures/refuted-optimistic-outside-action/tsconfig.json create mode 100644 packages/prover/tests/fixtures/refuted-optimistic-render-update/src/app.tsx create mode 100644 packages/prover/tests/fixtures/refuted-optimistic-render-update/tsconfig.json create mode 100644 packages/prover/tests/fixtures/refuted-unsupported-form-action-control/src/app.tsx create mode 100644 packages/prover/tests/fixtures/refuted-unsupported-form-action-control/tsconfig.json create mode 100644 packages/prover/tests/runtime/optimistic-form-action-oracle.spec.ts diff --git a/packages/prover/README.md b/packages/prover/README.md index d79cb5afa7..ce0b3cf0a1 100644 --- a/packages/prover/README.md +++ b/packages/prover/README.md @@ -76,6 +76,15 @@ The report includes: 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; +- 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. @@ -115,6 +124,11 @@ Transition Action certificates require a valid non-render execution root, a symb 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. 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 diff --git a/packages/prover/research-log.md b/packages/prover/research-log.md index 52a0325c21..afd1a76e0c 100644 --- a/packages/prover/research-log.md +++ b/packages/prover/research-log.md @@ -475,7 +475,9 @@ Each discovered unit receives these obligations: | `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 | +| `form-actions` | Intrinsic form/submitter semantics, callback identity, form association, Action phase | | `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 | | `ref-access` | Render-phase access to refs created by `useRef` | @@ -751,7 +753,7 @@ components and hooks, including hooks hidden in incorrectly named helper functio ### Test stack -Current checkpoint: 275 TypeScript fixture projects, 472 static tests, and 36 Chromium runtime +Current checkpoint: 281 TypeScript fixture projects, 491 static tests, and 37 Chromium runtime oracles. - Vite Plus supplies package build and Vitest-compatible static tests. @@ -1271,8 +1273,9 @@ Async or scheduled Actions, opaque callback values, escaped starters, indirect ` 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`, `useOptimistic`, `useActionState`, form or -Server Actions, Suspense fallback preservation, or whole-application transition state machines. +not request ordering, async context, `useDeferredValue`, `useActionState`, Server Actions, Suspense +fallback preservation, or whole-application transition state machines. Form Actions and +`useOptimistic` now have the separate certificates below. 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 @@ -1328,3 +1331,117 @@ 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. diff --git a/packages/prover/src/analyze-boundary-coverage.ts b/packages/prover/src/analyze-boundary-coverage.ts index f5617be3ae..5b74760703 100644 --- a/packages/prover/src/analyze-boundary-coverage.ts +++ b/packages/prover/src/analyze-boundary-coverage.ts @@ -143,6 +143,80 @@ export const analyzeBoundaryCoverage = ( )), ); }; + 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 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 isModeledUseTransitionCall = (callExpression: ts.CallExpression): boolean => { if (callExpression.arguments.length > 0) return false; const declaration = ts.isVariableDeclaration(callExpression.parent) @@ -272,6 +346,8 @@ export const analyzeBoundaryCoverage = ( const reachabilityGraph = collectReachableFunctionGraph(executionRoot, context.typeChecker); for (const unmodeledUse of reachabilityGraph.unmodeledCallableUses) { if (getModeledTransitionAction(unmodeledUse.node)?.sourceComplete) continue; + if (isModeledFormActionCallableUse(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; @@ -350,7 +426,8 @@ export const analyzeBoundaryCoverage = ( canonicalReactApiName && REACT_UNMODELED_HOOK_NAMES.has(canonicalReactApiName) && !isModeledContextRead && - !(canonicalReactApiName === "useTransition" && isModeledUseTransitionCall(node)) + !(canonicalReactApiName === "useTransition" && isModeledUseTransitionCall(node)) && + !(canonicalReactApiName === "useOptimistic" && isModeledOptimisticCall(node)) ) { unknownEvidence.push( createEvidence( @@ -489,6 +566,29 @@ export const analyzeBoundaryCoverage = ( ); } } + 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; @@ -526,6 +626,22 @@ export const analyzeBoundaryCoverage = ( ), ); } + 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); }; diff --git a/packages/prover/src/analyze-form-actions.ts b/packages/prover/src/analyze-form-actions.ts new file mode 100644 index 0000000000..2610452cca --- /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-optimistic-state.ts b/packages/prover/src/analyze-optimistic-state.ts new file mode 100644 index 0000000000..20091cd75d --- /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-unit.ts b/packages/prover/src/analyze-react-unit.ts index 64430800a7..7a475527f3 100644 --- a/packages/prover/src/analyze-react-unit.ts +++ b/packages/prover/src/analyze-react-unit.ts @@ -11,10 +11,12 @@ import { analyzeEffectDependencies } from "./analyze-effect-dependencies.js"; import { analyzeEffectEventUsage } from "./analyze-effect-event-usage.js"; import { analyzeEffectStateUpdates } from "./analyze-effect-state-updates.js"; import { analyzeExternalStoreConsistency } from "./analyze-external-store-consistency.js"; +import { analyzeFormActions } from "./analyze-form-actions.js"; import { analyzeHookOrder } from "./analyze-hook-order.js"; import { analyzeHookOwnership } from "./analyze-hook-ownership.js"; import { analyzeHookStateTransitions } from "./analyze-hook-state-transitions.js"; import { analyzeMemoDependencies } from "./analyze-memo-dependencies.js"; +import { analyzeOptimisticState } from "./analyze-optimistic-state.js"; import { analyzeRefAccess } from "./analyze-ref-access.js"; import { analyzeReducerPurity } from "./analyze-reducer-purity.js"; import { analyzeReconciliationIdentity } from "./analyze-reconciliation-identity.js"; @@ -41,10 +43,12 @@ const ALL_REACT_PROOF_CLAIMS: ReadonlyArray = [ ReactProofClaim.EffectEventUsage, ReactProofClaim.EffectStateUpdates, ReactProofClaim.ExternalStoreConsistency, + ReactProofClaim.FormActions, ReactProofClaim.HookOrder, ReactProofClaim.HookOwnership, ReactProofClaim.HookStateTransitions, ReactProofClaim.MemoDependencies, + ReactProofClaim.OptimisticState, ReactProofClaim.ReconciliationIdentity, ReactProofClaim.ReducerPurity, ReactProofClaim.RefAccess, @@ -137,10 +141,12 @@ export const analyzeReactUnit = ( analyzeEffectEventUsage(unit.functionNode, context), analyzeEffectStateUpdates(unit, context), analyzeExternalStoreConsistency(unit, context), + analyzeFormActions(unit, context), analyzeHookOrder(unit.functionNode, context), analyzeHookOwnership(unit.functionNode), analyzeHookStateTransitions(unit, context), analyzeMemoDependencies(unit.functionNode, context), + analyzeOptimisticState(unit, context), analyzeReconciliationIdentity(unit.functionNode, context), analyzeReducerPurity(unit.functionNode, context), analyzeRefAccess(unit.functionNode, context), diff --git a/packages/prover/src/build-react-semantic-graph.ts b/packages/prover/src/build-react-semantic-graph.ts index 90fca7b232..fb44549f14 100644 --- a/packages/prover/src/build-react-semantic-graph.ts +++ b/packages/prover/src/build-react-semantic-graph.ts @@ -26,6 +26,8 @@ import { import { collectHookBindings } from "./collect-hook-bindings.js"; import { collectHookCalls } from "./collect-hook-calls.js"; import { collectHookStateTransitions } from "./collect-hook-state-transitions.js"; +import { collectFormActions } from "./collect-form-actions.js"; +import { collectOptimisticState } from "./collect-optimistic-state.js"; import { collectTransitionActions } from "./collect-transition-actions.js"; import type { TransitionActionDescriptor } from "./collect-transition-actions.js"; import { collectReactiveCaptures } from "./collect-reactive-captures.js"; @@ -57,8 +59,11 @@ import { ReactClassUpdateCycleStatus, ReactEffectDependencyMode, ReactExecutionPhase, + ReactFormActionStatus, ReactHookStateUpdaterStatus, ReactIdentityStability, + ReactOptimisticActionStatus, + ReactOptimisticReducerStatus, ReactSemanticCallbackKind, ReactSemanticEdgeKind, ReactTransitionActionStatus, @@ -83,10 +88,13 @@ import type { ReactSemanticClassStateWrite, ReactSemanticClassStateTransition, ReactSemanticExternalStore, + ReactSemanticFormAction, ReactSemanticFunctionCall, ReactSemanticGraph, ReactSemanticHookCall, ReactSemanticHookStateTransition, + ReactSemanticOptimisticState, + ReactSemanticOptimisticUpdate, ReactSemanticTransitionAction, ReactSemanticReachableFunction, ReactSemanticRender, @@ -97,8 +105,8 @@ import type { } 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 { getContainingFunction } from "./utils/get-containing-function.js"; import { isDeferredCallbackSynchronous } from "./utils/is-deferred-callback-synchronous.js"; interface UnitGraphIdentity { @@ -166,10 +174,19 @@ interface CallbackPropGraphFacts extends CallbackGraphFacts { callbackPropFlows: ReadonlyArray; } +interface FormActionGraphFacts extends CallbackGraphFacts { + actions: ReadonlyArray; +} + interface HookStateTransitionGraphFacts extends CallbackGraphFacts { transitions: ReadonlyArray; } +interface OptimisticStateGraphFacts extends CallbackGraphFacts { + states: ReadonlyArray; + updates: ReadonlyArray; +} + interface TransitionActionGraphFacts extends CallbackGraphFacts { actions: ReadonlyArray; } @@ -1881,6 +1898,111 @@ const collectReducerCallbacks = ( return { callbacks, reachableFunctions, functionCalls }; }; +const collectFormActionGraph = ( + identities: ReadonlyArray, + context: ReactAnalysisContext, + componentFlow: ComponentCallbackFlowDescriptor, +): 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 actionCallbackIds: string[] = []; + 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 = + 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, @@ -1955,34 +2077,18 @@ const collectTransitionActionGraph = ( ReactExecutionPhase.EffectSetup, ReactExecutionPhase.Event, ReactExecutionPhase.ExternalStoreSubscription, + ReactExecutionPhase.FormAction, ReactExecutionPhase.TransitionAction, ]); const actions = actionIdentities.map( ({ actionCallback, actionId, descriptor }): ReactSemanticTransitionAction => { - const containingFunction = descriptor.callExpression - ? getContainingFunction(descriptor.callExpression) - : null; - const containingLocation = containingFunction - ? getNodeLocation(containingFunction, context.rootDirectory) - : null; - const executionCallbackIds = containingLocation - ? [ - ...new Set([ - ...allCallbacks.flatMap((callback) => - callback.ownerId === identity.semanticUnit.id && - areProofLocationsEqual(callback.location, containingLocation) - ? [callback.id] - : [], - ), - ...allReachableFunctions.flatMap((reachableFunction) => - reachableFunction.ownerId === identity.semanticUnit.id && - areProofLocationsEqual(reachableFunction.location, containingLocation) - ? [reachableFunction.rootCallbackId] - : [], - ), - ]), - ] - : []; + 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) => { @@ -2043,30 +2149,13 @@ const collectHookStateTransitionGraph = ( descriptor.evidenceNode, context, ); - const containingFunction = descriptor.callExpression - ? getContainingFunction(descriptor.callExpression) - : null; - const containingLocation = containingFunction - ? getNodeLocation(containingFunction, context.rootDirectory) - : null; - const executionCallbackIds = containingLocation - ? [ - ...new Set([ - ...existingCallbacks.flatMap((callback) => - callback.ownerId === identity.semanticUnit.id && - areProofLocationsEqual(callback.location, containingLocation) - ? [callback.id] - : [], - ), - ...existingReachableFunctions.flatMap((reachableFunction) => - reachableFunction.ownerId === identity.semanticUnit.id && - areProofLocationsEqual(reachableFunction.location, containingLocation) - ? [reachableFunction.rootCallbackId] - : [], - ), - ]), - ] - : []; + const executionCallbackIds = collectExecutionCallbackIds({ + callbacks: existingCallbacks, + evidenceNode: descriptor.callExpression, + ownerId: identity.semanticUnit.id, + reachableFunctions: existingReachableFunctions, + rootDirectory: context.rootDirectory, + }); const updaterCallback = descriptor.updaterFunction ? createCallbackFact( identity, @@ -2131,6 +2220,206 @@ const collectHookStateTransitionGraph = ( return { transitions, callbacks, reachableFunctions, functionCalls }; }; +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.TransitionAction && + completeTransitionCallbackIds.has(callback.id)), + ) + ) { + actionStatus = ReactOptimisticActionStatus.Action; + } else if ( + executionCallbacks.some( + (callback) => + callback.phase !== ReactExecutionPhase.FormAction && + 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, @@ -2468,7 +2757,10 @@ export const buildReactSemanticGraph = ( const classLifecycles: ReactSemanticClassLifecycle[] = []; const classStateWrites: ReactSemanticClassStateWrite[] = []; const classStateTransitions: ReactSemanticClassStateTransition[] = []; + const formActions: ReactSemanticFormAction[] = []; const hookStateTransitions: ReactSemanticHookStateTransition[] = []; + const optimisticStates: ReactSemanticOptimisticState[] = []; + const optimisticUpdates: ReactSemanticOptimisticUpdate[] = []; const transitionActions: ReactSemanticTransitionAction[] = []; const effectEvents: ReactSemanticEffectEvent[] = []; const externalStores: ReactSemanticExternalStore[] = []; @@ -2490,6 +2782,11 @@ export const buildReactSemanticGraph = ( callbacks.push(...eventGraph.callbacks); reachableFunctions.push(...eventGraph.reachableFunctions); functionCalls.push(...eventGraph.functionCalls); + const formActionGraph = collectFormActionGraph(identities, context, componentFlow); + 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; if ( @@ -2608,6 +2905,20 @@ export const buildReactSemanticGraph = ( reachableFunctions.push(...hookStateTransitionGraph.reachableFunctions); functionCalls.push(...hookStateTransitionGraph.functionCalls); } + 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 contextConsumers = resolveContextConsumers( identities.map((identity) => identity.semanticUnit), edges, @@ -2642,7 +2953,10 @@ export const buildReactSemanticGraph = ( classLifecycles, classStateWrites, classStateTransitions, + formActions, hookStateTransitions, + 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 index 3cbcf5bc10..bf20bc9865 100644 --- a/packages/prover/src/check-react-proof-report.ts +++ b/packages/prover/src/check-react-proof-report.ts @@ -16,8 +16,12 @@ import { ReactEffectResourceDisposalStatus, ReactEffectResourceKind, ReactExecutionPhase, + ReactFormActionKind, + ReactFormActionStatus, ReactHookStateUpdaterStatus, ReactObligationStatus, + ReactOptimisticActionStatus, + ReactOptimisticReducerStatus, ReactProofCertificateStatus, ReactProofClaim, ReactSchedulerCancellationStatus, @@ -37,6 +41,10 @@ import type { } from "./types.js"; const HOOK_STATE_UPDATER_STATUSES = new Set(Object.values(ReactHookStateUpdaterStatus)); +const FORM_ACTION_KINDS = new Set(Object.values(ReactFormActionKind)); +const FORM_ACTION_STATUSES = new Set(Object.values(ReactFormActionStatus)); +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 TRANSITION_ACTION_ORIGIN_PHASES = new Set([ @@ -48,6 +56,7 @@ const TRANSITION_ACTION_ORIGIN_PHASES = new Set([ ReactExecutionPhase.EffectSetup, ReactExecutionPhase.Event, ReactExecutionPhase.ExternalStoreSubscription, + ReactExecutionPhase.FormAction, ReactExecutionPhase.TransitionAction, ]); @@ -204,6 +213,47 @@ const expectedTransitionActionStatus = ( : 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 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 expectedScheduledCallbackLifetimeStatus = ( unit: ReactSemanticUnit, report: ReactAppProofReport, @@ -352,6 +402,28 @@ const checkClaimCoverage = ( `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 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 scheduledCallbackLifetime = unitProof.obligations.find( (obligation) => obligation.claim === ReactProofClaim.ScheduledCallbackLifetime, ); @@ -921,6 +993,200 @@ const checkGraphReferences = ( ); } } + 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, + ); + }); + 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.TransitionAction && + completeTransitionCallbackIds.has(callback.id)), + ) + ) { + expectedActionStatus = ReactOptimisticActionStatus.Action; + } else if ( + executionCallbacks.some( + (callback) => + callback.phase !== ReactExecutionPhase.FormAction && + 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) { @@ -1842,6 +2108,21 @@ export const checkReactProofReport = (report: ReactAppProofReport): ReactProofCe "Hook state transitions", report.graph.hookStateTransitions.map((transition) => transition.id), ); + checkUniqueIds( + failures, + "Form Actions", + report.graph.formActions.map((action) => action.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", diff --git a/packages/prover/src/collect-form-actions.ts b/packages/prover/src/collect-form-actions.ts new file mode 100644 index 0000000000..c01c58a632 --- /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 index 33a69d9676..d66cad3177 100644 --- a/packages/prover/src/collect-hook-bindings.ts +++ b/packages/prover/src/collect-hook-bindings.ts @@ -1,11 +1,29 @@ import ts from "typescript"; import { collectEffectEventBindings } from "./collect-effect-event-bindings.js"; -import { REACT_TRANSITION_STARTER_INDEX } from "./constants.js"; +import { + 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 HookBindings { effectEvents: ReadonlySet; + optimisticBindings: ReadonlyArray; refs: ReadonlySet; stateSetters: ReadonlySet; stateValueBySetter: ReadonlyMap; @@ -29,6 +47,7 @@ export const collectHookBindings = ( collectEffectEventBindings(functionNode, typeChecker).map((binding) => binding.symbol), ); const refs = new Set(); + const optimisticBindings: OptimisticHookBinding[] = []; const stateSetters = new Set(); const stateValueBySetter = new Map(); const stateValues = new Set(); @@ -65,6 +84,35 @@ export const collectHookBindings = ( const refSymbol = getBindingSymbol(node.name, typeChecker); if (refSymbol) refs.add(refSymbol); } + 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 = @@ -78,6 +126,7 @@ export const collectHookBindings = ( functionNode.forEachChild(visit); return { effectEvents, + optimisticBindings, refs, stateSetters, stateValueBySetter, diff --git a/packages/prover/src/collect-hook-state-transitions.ts b/packages/prover/src/collect-hook-state-transitions.ts index 4ad0375a6f..b1ce472db2 100644 --- a/packages/prover/src/collect-hook-state-transitions.ts +++ b/packages/prover/src/collect-hook-state-transitions.ts @@ -1,11 +1,11 @@ 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 } from "./types.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"; @@ -18,47 +18,6 @@ export interface HookStateTransitionDescriptor { updaterStatus: ReactHookStateUpdaterStatus; } -const doesTypeIncludeCallable = (type: ts.Type): boolean => - type.getCallSignatures().length > 0 || - (type.isUnionOrIntersection() && type.types.some(doesTypeIncludeCallable)); - -const getUpdaterStatus = ( - updaterExpression: ts.Expression, - context: ReactAnalysisContext, -): { - updaterFunction: ts.FunctionLikeDeclaration | null; - updaterStatus: ReactHookStateUpdaterStatus; -} => { - 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) || - doesTypeIncludeCallable(updaterType) - ) { - return { - updaterFunction: null, - updaterStatus: ReactHookStateUpdaterStatus.Unknown, - }; - } - return { - updaterFunction: null, - updaterStatus: ReactHookStateUpdaterStatus.DirectValue, - }; -}; - export const collectHookStateTransitions = ( functionNode: ts.FunctionLikeDeclaration, context: ReactAnalysisContext, @@ -80,7 +39,7 @@ export const collectHookStateTransitions = ( if (setterSymbol && stateName) { const updaterExpression = node.arguments[0]; const updaterAnalysis = updaterExpression - ? getUpdaterStatus(updaterExpression, context) + ? analyzeStateUpdateExpression(updaterExpression, context) : { updaterFunction: null, updaterStatus: ReactHookStateUpdaterStatus.Unknown, diff --git a/packages/prover/src/collect-optimistic-state.ts b/packages/prover/src/collect-optimistic-state.ts new file mode 100644 index 0000000000..5724acd2fd --- /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/constants.ts b/packages/prover/src/constants.ts index 2e314d0fde..89d8eb31de 100644 --- a/packages/prover/src/constants.ts +++ b/packages/prover/src/constants.ts @@ -1,7 +1,7 @@ import { ReactEffectResourceKind } from "./types.js"; -export const REACT_PROOF_SCHEMA_VERSION = 18; -export const REACT_SEMANTIC_GRAPH_SCHEMA_VERSION = 24; +export const REACT_PROOF_SCHEMA_VERSION = 19; +export const REACT_SEMANTIC_GRAPH_SCHEMA_VERSION = 25; 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; @@ -9,6 +9,10 @@ export const FIRST_SOURCE_COLUMN = 1; 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_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_TRANSITION_ACTION_INDEX = 0; export const REACT_TRANSITION_STARTER_INDEX = 1; export const REACT_USE_TRANSITION_TUPLE_LENGTH = 2; diff --git a/packages/prover/src/create-component-callback-flow.ts b/packages/prover/src/create-component-callback-flow.ts index c548b68be2..4d2b5d9605 100644 --- a/packages/prover/src/create-component-callback-flow.ts +++ b/packages/prover/src/create-component-callback-flow.ts @@ -57,6 +57,12 @@ export interface ComponentCallbackFlowDescriptor { ownerFunction: ts.FunctionLikeDeclaration, phase: ReactExecutionPhase, ): ComponentCallbackExpressionResolutionDescriptor; + resolveProperty( + expression: ts.Expression, + propertyName: string, + ownerFunction: ts.FunctionLikeDeclaration, + phase: ReactExecutionPhase, + ): ComponentCallbackExpressionResolutionDescriptor; } export interface ComponentCallbackResolutionDescriptor { @@ -604,5 +610,28 @@ export const createComponentCallbackFlow = ( 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/index.ts b/packages/prover/src/index.ts index 02b255e1b1..70edaf0ddc 100644 --- a/packages/prover/src/index.ts +++ b/packages/prover/src/index.ts @@ -19,9 +19,13 @@ export { ReactEffectResourceDisposalStatus, ReactEffectResourceKind, ReactExecutionPhase, + ReactFormActionKind, + ReactFormActionStatus, ReactHookStateUpdaterStatus, ReactIdentityStability, ReactObligationStatus, + ReactOptimisticActionStatus, + ReactOptimisticReducerStatus, ReactProofCertificateStatus, ReactProofClaim, ReactSchedulerCancellationStatus, @@ -65,12 +69,15 @@ export type { ReactSemanticClassStateWrite, ReactSemanticClassStateTransition, ReactSemanticExternalStore, + ReactSemanticFormAction, ReactSemanticCallback, ReactSemanticAsyncTask, ReactSemanticGraph, ReactSemanticFunctionCall, ReactSemanticHookCall, ReactSemanticHookStateTransition, + ReactSemanticOptimisticState, + ReactSemanticOptimisticUpdate, ReactSemanticTransitionAction, ReactSemanticReachableFunction, ReactSemanticRender, diff --git a/packages/prover/src/prove-react-app.ts b/packages/prover/src/prove-react-app.ts index 41a7fd3c54..1997827414 100644 --- a/packages/prover/src/prove-react-app.ts +++ b/packages/prover/src/prove-react-app.ts @@ -47,7 +47,10 @@ export const proveReactApp = (input: ProveReactAppInput): ReactAppProofReport => classLifecycles: [], classStateWrites: [], classStateTransitions: [], + formActions: [], hookStateTransitions: [], + optimisticStates: [], + optimisticUpdates: [], transitionActions: [], compiler: { version: REACT_COMPILER_VERSION, diff --git a/packages/prover/src/types.ts b/packages/prover/src/types.ts index c9cef9cc08..13d7442bff 100644 --- a/packages/prover/src/types.ts +++ b/packages/prover/src/types.ts @@ -26,10 +26,12 @@ export enum ReactProofClaim { EffectEventUsage = "effect-event-usage", EffectStateUpdates = "effect-state-updates", ExternalStoreConsistency = "external-store-consistency", + FormActions = "form-actions", HookOrder = "hook-order", HookOwnership = "hook-ownership", HookStateTransitions = "hook-state-transitions", MemoDependencies = "memo-dependencies", + OptimisticState = "optimistic-state", ReconciliationIdentity = "reconciliation-identity", ReducerPurity = "reducer-purity", RefAccess = "ref-access", @@ -77,6 +79,9 @@ export enum ReactExecutionPhase { EffectSetup = "effect-setup", Event = "event", ExternalStoreSubscription = "external-store-subscription", + FormAction = "form-action", + OptimisticReducer = "optimistic-reducer", + OptimisticUpdater = "optimistic-updater", Render = "render", ServerRender = "server-render", StateTransition = "state-transition", @@ -95,9 +100,12 @@ export enum ReactSemanticCallbackKind { EventHandler = "event-handler", ExternalStoreSnapshot = "external-store-snapshot", ExternalStoreSubscribe = "external-store-subscribe", + FormAction = "form-action", HookStateUpdater = "hook-state-updater", MemoFactory = "memo-factory", MemoizedCallback = "memoized-callback", + OptimisticReducer = "optimistic-reducer", + OptimisticUpdater = "optimistic-updater", Reducer = "reducer", ReducerInitializer = "reducer-initializer", ResourceCallback = "resource-callback", @@ -584,6 +592,69 @@ export interface ReactSemanticHookStateTransition { 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 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", @@ -676,7 +747,10 @@ export interface ReactSemanticGraph { classLifecycles: ReadonlyArray; classStateWrites: ReadonlyArray; classStateTransitions: ReadonlyArray; + formActions: ReadonlyArray; hookStateTransitions: ReadonlyArray; + optimisticStates: ReadonlyArray; + optimisticUpdates: ReadonlyArray; transitionActions: ReadonlyArray; compiler: ReactCompilerGraph; } 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 0000000000..271af6b80f --- /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/collect-execution-callback-ids.ts b/packages/prover/src/utils/collect-execution-callback-ids.ts new file mode 100644 index 0000000000..6fae671c80 --- /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/does-type-have-call-signature.ts b/packages/prover/src/utils/does-type-have-call-signature.ts new file mode 100644 index 0000000000..731921b958 --- /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/tests/fixtures/incomplete-composed-form-action-submitter/src/app.tsx b/packages/prover/tests/fixtures/incomplete-composed-form-action-submitter/src/app.tsx new file mode 100644 index 0000000000..9259c1b294 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-composed-form-action-submitter/src/app.tsx @@ -0,0 +1,9 @@ +export const ComposedSubmitter = () => { + const action = () => {}; + + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/incomplete-composed-form-action-submitter/tsconfig.json b/packages/prover/tests/fixtures/incomplete-composed-form-action-submitter/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-composed-form-action-submitter/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-dynamic-form-action-control/src/app.tsx b/packages/prover/tests/fixtures/incomplete-dynamic-form-action-control/src/app.tsx new file mode 100644 index 0000000000..afc070e5cd --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-dynamic-form-action-control/src/app.tsx @@ -0,0 +1,15 @@ +interface DynamicSubmitterProperties { + buttonType: "button" | "submit"; +} + +export const DynamicSubmitter = ({ buttonType }: DynamicSubmitterProperties) => { + const action = () => {}; + + return ( + + + + ); +}; diff --git a/packages/prover/tests/fixtures/incomplete-dynamic-form-action-control/tsconfig.json b/packages/prover/tests/fixtures/incomplete-dynamic-form-action-control/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-dynamic-form-action-control/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-form-action-prop/src/app.tsx b/packages/prover/tests/fixtures/incomplete-form-action-prop/src/app.tsx new file mode 100644 index 0000000000..be4ee10ec2 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-form-action-prop/src/app.tsx @@ -0,0 +1,9 @@ +interface FormShellProperties { + action: (formData: FormData) => void; +} + +export const FormShell = ({ action }: FormShellProperties) => ( +
    + +
    +); diff --git a/packages/prover/tests/fixtures/incomplete-form-action-prop/tsconfig.json b/packages/prover/tests/fixtures/incomplete-form-action-prop/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-form-action-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-optimistic-async-transition/src/app.tsx b/packages/prover/tests/fixtures/incomplete-optimistic-async-transition/src/app.tsx new file mode 100644 index 0000000000..dc1bf665f0 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-optimistic-async-transition/src/app.tsx @@ -0,0 +1,21 @@ +import { useOptimistic, useTransition } from "react"; + +export const AsyncOptimisticTransition = () => { + const [optimisticCount, setOptimisticCount] = useOptimistic( + 0, + (_pendingCount, nextCount: number) => nextCount, + ); + const [, startTransition] = useTransition(); + const updateCount = () => { + startTransition(async () => { + await Promise.resolve(); + setOptimisticCount(1); + }); + }; + + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/incomplete-optimistic-async-transition/tsconfig.json b/packages/prover/tests/fixtures/incomplete-optimistic-async-transition/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-optimistic-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-optimistic-setter-escape/src/app.tsx b/packages/prover/tests/fixtures/incomplete-optimistic-setter-escape/src/app.tsx new file mode 100644 index 0000000000..72a31a032c --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-optimistic-setter-escape/src/app.tsx @@ -0,0 +1,8 @@ +import { useOptimistic } from "react"; + +export const EscapedOptimisticSetter = () => { + const [optimisticCount, setOptimisticCount] = useOptimistic(0); + const escapedSetter = setOptimisticCount; + + return {optimisticCount}; +}; diff --git a/packages/prover/tests/fixtures/incomplete-optimistic-setter-escape/tsconfig.json b/packages/prover/tests/fixtures/incomplete-optimistic-setter-escape/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-optimistic-setter-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/proved-form-action-submitter/src/app.tsx b/packages/prover/tests/fixtures/proved-form-action-submitter/src/app.tsx new file mode 100644 index 0000000000..d689b99ef7 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-form-action-submitter/src/app.tsx @@ -0,0 +1,15 @@ +import { useState } from "react"; + +export const AccountForm = () => { + const [status, setStatus] = useState("active"); + const deactivateAction = () => setStatus("inactive"); + + return ( +
    + + {status} +
    + ); +}; diff --git a/packages/prover/tests/fixtures/proved-form-action-submitter/tsconfig.json b/packages/prover/tests/fixtures/proved-form-action-submitter/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-form-action-submitter/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-helper-spread-form-action/src/app.tsx b/packages/prover/tests/fixtures/proved-helper-spread-form-action/src/app.tsx new file mode 100644 index 0000000000..c1488c5afe --- /dev/null +++ b/packages/prover/tests/fixtures/proved-helper-spread-form-action/src/app.tsx @@ -0,0 +1,22 @@ +import { useOptimistic, useState } from "react"; + +export const MessageForm = () => { + const [confirmedMessages, setConfirmedMessages] = useState>([]); + const [optimisticMessages, addOptimisticMessage] = useOptimistic( + confirmedMessages, + (pendingMessages, message: string) => [...pendingMessages, message], + ); + const submitAction = () => { + addOptimisticMessage("Sent"); + setConfirmedMessages((previousMessages) => [...previousMessages, "Sent"]); + }; + const formProperties = { action: submitAction }; + const renderForm = () => ( +
    + + {optimisticMessages.length} +
    + ); + + return renderForm(); +}; diff --git a/packages/prover/tests/fixtures/proved-helper-spread-form-action/tsconfig.json b/packages/prover/tests/fixtures/proved-helper-spread-form-action/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-helper-spread-form-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/proved-optimistic-form/src/app.tsx b/packages/prover/tests/fixtures/proved-optimistic-form/src/app.tsx new file mode 100644 index 0000000000..8042bfda3f --- /dev/null +++ b/packages/prover/tests/fixtures/proved-optimistic-form/src/app.tsx @@ -0,0 +1,20 @@ +import { useOptimistic, useState } from "react"; + +export const TodoForm = () => { + const [confirmedTodos, setConfirmedTodos] = useState>(["Read"]); + const [optimisticTodos, addOptimisticTodo] = useOptimistic( + confirmedTodos, + (pendingTodos, todo: string) => [...pendingTodos, todo], + ); + const submitAction = (_formData: FormData) => { + addOptimisticTodo("Write"); + setConfirmedTodos((previousTodos) => [...previousTodos, "Write"]); + }; + + return ( +
    + + {optimisticTodos.join(", ")} +
    + ); +}; diff --git a/packages/prover/tests/fixtures/proved-optimistic-form/tsconfig.json b/packages/prover/tests/fixtures/proved-optimistic-form/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-optimistic-form/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-optimistic-transition-updater/src/app.tsx b/packages/prover/tests/fixtures/proved-optimistic-transition-updater/src/app.tsx new file mode 100644 index 0000000000..790cbd6c2f --- /dev/null +++ b/packages/prover/tests/fixtures/proved-optimistic-transition-updater/src/app.tsx @@ -0,0 +1,16 @@ +import { startTransition, useOptimistic } from "react"; + +export const OptimisticCounter = () => { + const [optimisticCount, updateOptimisticCount] = useOptimistic(0); + const increment = () => { + startTransition(() => { + updateOptimisticCount((pendingCount) => pendingCount + 1); + }); + }; + + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/proved-optimistic-transition-updater/tsconfig.json b/packages/prover/tests/fixtures/proved-optimistic-transition-updater/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-optimistic-transition-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/react-shim.d.ts b/packages/prover/tests/fixtures/react-shim.d.ts index 6a6eca5b0a..e016c10091 100644 --- a/packages/prover/tests/fixtures/react-shim.d.ts +++ b/packages/prover/tests/fixtures/react-shim.d.ts @@ -37,6 +37,15 @@ declare module "react" { factory: () => Value, dependencies: ReadonlyArray, ) => Value; + export const useOptimistic: { + ( + passthrough: State, + ): [State, (updateAction: State | ((pendingState: State) => State)) => void]; + ( + passthrough: State, + reducer: (pendingState: State, action: Action) => State, + ): [State, (action: Action) => void]; + }; export const useRef: (initialValue: Value) => MutableRefObject; export const useContext: (context: Context) => Value; export const useReducer: ( diff --git a/packages/prover/tests/fixtures/refuted-impure-optimistic-reducer/src/app.tsx b/packages/prover/tests/fixtures/refuted-impure-optimistic-reducer/src/app.tsx new file mode 100644 index 0000000000..16e9198312 --- /dev/null +++ b/packages/prover/tests/fixtures/refuted-impure-optimistic-reducer/src/app.tsx @@ -0,0 +1,10 @@ +import { useOptimistic } from "react"; + +export const AuditedOptimisticState = () => { + const [optimisticCount] = useOptimistic(0, (pendingCount, increment: number) => { + console.log("optimistic reducer"); + return pendingCount + increment; + }); + + return {optimisticCount}; +}; diff --git a/packages/prover/tests/fixtures/refuted-impure-optimistic-reducer/tsconfig.json b/packages/prover/tests/fixtures/refuted-impure-optimistic-reducer/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/refuted-impure-optimistic-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/refuted-impure-optimistic-updater/src/app.tsx b/packages/prover/tests/fixtures/refuted-impure-optimistic-updater/src/app.tsx new file mode 100644 index 0000000000..782d55130e --- /dev/null +++ b/packages/prover/tests/fixtures/refuted-impure-optimistic-updater/src/app.tsx @@ -0,0 +1,17 @@ +import { useOptimistic } from "react"; + +export const AuditedOptimisticForm = () => { + const [optimisticCount, updateOptimisticCount] = useOptimistic(0); + const submitAction = () => { + updateOptimisticCount((pendingCount) => { + console.log("optimistic updater"); + return pendingCount + 1; + }); + }; + + return ( +
    + +
    + ); +}; diff --git a/packages/prover/tests/fixtures/refuted-impure-optimistic-updater/tsconfig.json b/packages/prover/tests/fixtures/refuted-impure-optimistic-updater/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/refuted-impure-optimistic-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/refuted-mixed-optimistic-action-roots/src/app.tsx b/packages/prover/tests/fixtures/refuted-mixed-optimistic-action-roots/src/app.tsx new file mode 100644 index 0000000000..dfdbc5da79 --- /dev/null +++ b/packages/prover/tests/fixtures/refuted-mixed-optimistic-action-roots/src/app.tsx @@ -0,0 +1,18 @@ +import { useOptimistic } from "react"; + +export const SharedAction = () => { + const [optimisticCount, addOptimisticCount] = useOptimistic( + 0, + (pendingCount, increment: number) => pendingCount + increment, + ); + const updateCount = () => addOptimisticCount(1); + + return ( +
    + + {optimisticCount} +
    + ); +}; diff --git a/packages/prover/tests/fixtures/refuted-mixed-optimistic-action-roots/tsconfig.json b/packages/prover/tests/fixtures/refuted-mixed-optimistic-action-roots/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/refuted-mixed-optimistic-action-roots/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/refuted-optimistic-outside-action/src/app.tsx b/packages/prover/tests/fixtures/refuted-optimistic-outside-action/src/app.tsx new file mode 100644 index 0000000000..9bb247c3aa --- /dev/null +++ b/packages/prover/tests/fixtures/refuted-optimistic-outside-action/src/app.tsx @@ -0,0 +1,14 @@ +import { useOptimistic } from "react"; + +export const OptimisticButton = () => { + const [optimisticCount, setOptimisticCount] = useOptimistic( + 0, + (pendingCount, nextCount: number) => pendingCount + nextCount, + ); + + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/refuted-optimistic-outside-action/tsconfig.json b/packages/prover/tests/fixtures/refuted-optimistic-outside-action/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/refuted-optimistic-outside-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/refuted-optimistic-render-update/src/app.tsx b/packages/prover/tests/fixtures/refuted-optimistic-render-update/src/app.tsx new file mode 100644 index 0000000000..9ced6f9e95 --- /dev/null +++ b/packages/prover/tests/fixtures/refuted-optimistic-render-update/src/app.tsx @@ -0,0 +1,11 @@ +import { useOptimistic } from "react"; + +export const RenderUpdate = () => { + const [optimisticCount, setOptimisticCount] = useOptimistic( + 0, + (_pendingCount, nextCount: number) => nextCount, + ); + setOptimisticCount(1); + + return {optimisticCount}; +}; diff --git a/packages/prover/tests/fixtures/refuted-optimistic-render-update/tsconfig.json b/packages/prover/tests/fixtures/refuted-optimistic-render-update/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/refuted-optimistic-render-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/refuted-unsupported-form-action-control/src/app.tsx b/packages/prover/tests/fixtures/refuted-unsupported-form-action-control/src/app.tsx new file mode 100644 index 0000000000..1b7dae56e1 --- /dev/null +++ b/packages/prover/tests/fixtures/refuted-unsupported-form-action-control/src/app.tsx @@ -0,0 +1,5 @@ +export const UnsupportedActionControl = () => { + const action = () => {}; + + return
    Cannot submit
    ; +}; diff --git a/packages/prover/tests/fixtures/refuted-unsupported-form-action-control/tsconfig.json b/packages/prover/tests/fixtures/refuted-unsupported-form-action-control/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/refuted-unsupported-form-action-control/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/prove-react-app.test.ts b/packages/prover/tests/prove-react-app.test.ts index d19440e419..28d451dfdd 100644 --- a/packages/prover/tests/prove-react-app.test.ts +++ b/packages/prover/tests/prove-react-app.test.ts @@ -21,9 +21,12 @@ import { ReactEffectResourceDisposalStatus, ReactEffectResourceKind, ReactExecutionPhase, + ReactFormActionStatus, ReactHookStateUpdaterStatus, ReactIdentityStability, ReactObligationStatus, + ReactOptimisticActionStatus, + ReactOptimisticReducerStatus, ReactProofClaim, ReactSchedulerCancellationStatus, ReactSchedulerKind, @@ -54,6 +57,36 @@ const proveFixture = (fixtureName: string) => }); const REFUTED_FIXTURES: ReadonlyArray = [ + { + fixtureName: "refuted-unsupported-form-action-control", + claim: ReactProofClaim.FormActions, + evidencePattern: /cannot invoke/, + }, + { + fixtureName: "refuted-optimistic-outside-action", + claim: ReactProofClaim.OptimisticState, + evidencePattern: /outside a Transition or Form Action/, + }, + { + fixtureName: "refuted-optimistic-render-update", + claim: ReactProofClaim.OptimisticState, + evidencePattern: /during render/, + }, + { + fixtureName: "refuted-impure-optimistic-reducer", + claim: ReactProofClaim.OptimisticState, + evidencePattern: /impure optimistic reducer/, + }, + { + fixtureName: "refuted-impure-optimistic-updater", + claim: ReactProofClaim.OptimisticState, + evidencePattern: /observable side effect/, + }, + { + fixtureName: "refuted-mixed-optimistic-action-roots", + claim: ReactProofClaim.OptimisticState, + evidencePattern: /outside a Transition or Form Action/, + }, { fixtureName: "refuted-transition-controlled-input", claim: ReactProofClaim.TransitionActions, @@ -482,6 +515,10 @@ describe("proveReactApp", () => { "proved-transition-tabs", "proved-use-transition-action", "proved-transition-lookalike", + "proved-optimistic-form", + "proved-form-action-submitter", + "proved-helper-spread-form-action", + "proved-optimistic-transition-updater", "proved-external-store", "proved-effect-event", "proved-helper-effect-cleanup", @@ -587,8 +624,8 @@ describe("proveReactApp", () => { const report = proveFixture("proved-chat"); const effect = report.graph.effects[0]; - expect(report.schemaVersion).toBe(18); - expect(report.graph.schemaVersion).toBe(24); + expect(report.schemaVersion).toBe(19); + expect(report.graph.schemaVersion).toBe(25); expect(effect?.hookName).toBe("useEffect"); expect(effect?.callbackResolved).toBe(true); expect(effect?.dependencyMode).toBe(ReactEffectDependencyMode.Inline); @@ -3159,6 +3196,125 @@ describe("proveReactApp", () => { ).toBe(true); }); + it("certifies a Form Action, pure optimistic reducer, and optimistic update together", () => { + const report = proveFixture("proved-optimistic-form"); + const formAction = report.graph.formActions[0]; + const formCallback = report.graph.callbacks.find( + (callback) => callback.id === formAction?.actionCallbackIds[0], + ); + const optimisticState = report.graph.optimisticStates[0]; + const optimisticUpdate = report.graph.optimisticUpdates[0]; + const reducerCallback = report.graph.callbacks.find( + (callback) => callback.id === optimisticState?.reducerCallbackId, + ); + + expect(report.status).toBe(ReactAppProofStatus.Proved); + expect(formAction?.status).toBe(ReactFormActionStatus.Resolved); + expect(formAction?.complete).toBe(true); + expect(formCallback?.kind).toBe(ReactSemanticCallbackKind.FormAction); + expect(formCallback?.phase).toBe(ReactExecutionPhase.FormAction); + expect(optimisticState?.reducerStatus).toBe(ReactOptimisticReducerStatus.Pure); + expect(optimisticState?.complete).toBe(true); + expect(reducerCallback?.phase).toBe(ReactExecutionPhase.OptimisticReducer); + expect(optimisticUpdate?.actionStatus).toBe(ReactOptimisticActionStatus.Action); + expect(optimisticUpdate?.updaterStatus).toBe(ReactHookStateUpdaterStatus.DirectValue); + expect(optimisticUpdate?.executionCallbackIds).toContain(formAction?.actionCallbackIds[0]); + expect(optimisticUpdate?.complete).toBe(true); + expect(checkReactProofReport(report).status).toBe(ReactProofCertificateStatus.Valid); + }); + + it("certifies nested submitters and helper-rendered spread Actions", () => { + const submitterReport = proveFixture("proved-form-action-submitter"); + const helperReport = proveFixture("proved-helper-spread-form-action"); + + expect(submitterReport.status).toBe(ReactAppProofStatus.Proved); + expect(submitterReport.graph.formActions[0]?.status).toBe(ReactFormActionStatus.Resolved); + expect(helperReport.status).toBe(ReactAppProofStatus.Proved); + expect(helperReport.graph.formActions[0]?.status).toBe(ReactFormActionStatus.Resolved); + expect(helperReport.graph.optimisticUpdates[0]?.actionStatus).toBe( + ReactOptimisticActionStatus.Action, + ); + }); + + it("certifies a pure updater owned by a Transition Action", () => { + const report = proveFixture("proved-optimistic-transition-updater"); + const update = report.graph.optimisticUpdates[0]; + const updaterCallback = report.graph.callbacks.find( + (callback) => callback.id === update?.updaterCallbackId, + ); + + expect(report.status).toBe(ReactAppProofStatus.Proved); + expect(update?.actionStatus).toBe(ReactOptimisticActionStatus.Action); + expect(update?.updaterStatus).toBe(ReactHookStateUpdaterStatus.Pure); + expect(updaterCallback?.phase).toBe(ReactExecutionPhase.OptimisticUpdater); + }); + + it.each([ + ["incomplete-dynamic-form-action-control", ReactFormActionStatus.Opaque], + ["incomplete-composed-form-action-submitter", ReactFormActionStatus.Opaque], + ["incomplete-form-action-prop", ReactFormActionStatus.Opaque], + ])("fails closed for incomplete Form Action semantics in %s", (fixtureName, expectedStatus) => { + const report = proveFixture(fixtureName); + const formAction = report.graph.formActions[0]; + const formProof = report.units[0]?.obligations.find( + (obligation) => obligation.claim === ReactProofClaim.FormActions, + ); + + expect(report.status).toBe(ReactAppProofStatus.Incomplete); + expect(formAction?.status).toBe(expectedStatus); + expect(formAction?.complete).toBe(false); + expect(formProof?.status).toBe(ReactObligationStatus.Unknown); + expect(checkReactProofReport(report).status).toBe(ReactProofCertificateStatus.Valid); + }); + + it("fails closed when an optimistic setter escapes its execution graph", () => { + const report = proveFixture("incomplete-optimistic-setter-escape"); + const update = report.graph.optimisticUpdates.find( + (candidate) => candidate.updaterStatus === ReactHookStateUpdaterStatus.SetterEscape, + ); + const optimisticProof = report.units[0]?.obligations.find( + (obligation) => obligation.claim === ReactProofClaim.OptimisticState, + ); + + expect(report.status).toBe(ReactAppProofStatus.Incomplete); + expect(update?.actionStatus).toBe(ReactOptimisticActionStatus.Unknown); + expect(update?.complete).toBe(false); + expect(optimisticProof?.status).toBe(ReactObligationStatus.Unknown); + expect(checkReactProofReport(report).status).toBe(ReactProofCertificateStatus.Valid); + }); + + it("does not treat an incomplete async Transition root as optimistic Action ownership", () => { + const report = proveFixture("incomplete-optimistic-async-transition"); + const update = report.graph.optimisticUpdates[0]; + + expect(report.status).toBe(ReactAppProofStatus.Incomplete); + expect(update?.actionStatus).toBe(ReactOptimisticActionStatus.Unknown); + expect(update?.sourceComplete).toBe(false); + expect(update?.complete).toBe(false); + expect(checkReactProofReport(report).status).toBe(ReactProofCertificateStatus.Valid); + }); + + it("rejects a forged optimistic Action certificate", () => { + const report = proveFixture("refuted-optimistic-outside-action"); + const certificate = checkReactProofReport({ + ...report, + graph: { + ...report.graph, + optimisticUpdates: report.graph.optimisticUpdates.map((update) => ({ + ...update, + actionStatus: ReactOptimisticActionStatus.Action, + sourceComplete: true, + complete: true, + })), + }, + }); + + expect(certificate.status).toBe(ReactProofCertificateStatus.Invalid); + expect( + certificate.failures.some((failure) => failure.description.includes("optimistic update")), + ).toBe(true); + }); + it("records concrete invalid state, constructor side-effect, setState, and missing-state issues", () => { const expectations: ReadonlyArray = [ { diff --git a/packages/prover/tests/runtime/constants.ts b/packages/prover/tests/runtime/constants.ts index fe6ff65793..61bd822476 100644 --- a/packages/prover/tests/runtime/constants.ts +++ b/packages/prover/tests/runtime/constants.ts @@ -8,6 +8,9 @@ export const INITIAL_CALLBACK_REVISION = 0; export const LATE_QUERY_SETTLE_WAIT_MS = 250; export const NEXT_CALLBACK_REVISION = 1; export const OBSERVER_DELIVERY_WAIT_MS = 50; +export const OPTIMISTIC_ACTION_DELAY_MS = 200; +export const OPTIMISTIC_ACTION_EXPECTED_RUNS = 1; +export const OPTIMISTIC_ACTION_INITIAL_RUNS = 0; export const PRIMARY_STORE_INITIAL_VERSION = 0; export const SECONDARY_STORE_INITIAL_VERSION = 100; export const SCHEDULER_CALLBACK_DELAY_MS = 80; diff --git a/packages/prover/tests/runtime/main.tsx b/packages/prover/tests/runtime/main.tsx index 09ff80d485..a48e81482e 100644 --- a/packages/prover/tests/runtime/main.tsx +++ b/packages/prover/tests/runtime/main.tsx @@ -8,6 +8,7 @@ import { useEffect, useEffectEvent, useLayoutEffect, + useOptimistic, useRef, useState, useSyncExternalStore, @@ -24,6 +25,8 @@ import { CLASS_UPDATE_NEXT_REVISION, INITIAL_CALLBACK_REVISION, NEXT_CALLBACK_REVISION, + OPTIMISTIC_ACTION_DELAY_MS, + OPTIMISTIC_ACTION_INITIAL_RUNS, PRIMARY_STORE_INITIAL_VERSION, SCHEDULER_CALLBACK_DELAY_MS, SECONDARY_STORE_INITIAL_VERSION, @@ -52,6 +55,7 @@ declare global { observerHits: number; schedulerHits: number; hookStateUpdaterRuns: number; + optimisticActionRuns: number; transitionActionRuns: number; } } @@ -70,8 +74,49 @@ window.listenerHits = 0; window.observerHits = 0; window.schedulerHits = 0; window.hookStateUpdaterRuns = HOOK_STATE_UPDATER_INITIAL_RUNS; +window.optimisticActionRuns = OPTIMISTIC_ACTION_INITIAL_RUNS; window.transitionActionRuns = TRANSITION_ACTION_INITIAL_RUNS; +interface OptimisticTodo { + label: string; + isPending: boolean; +} + +const OptimisticFormActionOracle = () => { + const [confirmedTodos, setConfirmedTodos] = useState>([ + { label: "Read", isPending: false }, + ]); + const [optimisticTodos, addOptimisticTodo] = useOptimistic( + confirmedTodos, + (pendingTodos, label: string) => [...pendingTodos, { label, isPending: true }], + ); + const submitAction = async (formData: FormData) => { + window.optimisticActionRuns += 1; + const label = String(formData.get("todo")); + addOptimisticTodo(label); + await new Promise((resolve) => { + setTimeout(resolve, OPTIMISTIC_ACTION_DELAY_MS); + }); + setConfirmedTodos((previousTodos) => [...previousTodos, { label, isPending: false }]); + }; + return ( +
    +
    + + +
    + + {optimisticTodos + .map((todo) => `${todo.label}:${todo.isPending ? "pending" : "confirmed"}`) + .join("|")} + + + {String(optimisticTodos.some((todo) => todo.isPending))} + +
    + ); +}; + const TransitionActionOracle = () => { const [panel, setPanel] = useState("overview"); const [isPending, startPanelTransition] = useTransition(); @@ -945,6 +990,9 @@ const RuntimeOracle = () => { if (oracle === "transition-action") { return ; } + if (oracle === "optimistic-form-action") { + return ; + } return ; }; @@ -958,7 +1006,8 @@ const isStrictModeOracle = oracle === "class-state-ownership" || oracle === "class-state-transition" || oracle === "hook-state-transition" || - oracle === "transition-action"; + oracle === "transition-action" || + oracle === "optimistic-form-action"; createRoot(rootElement).render( isStrictModeOracle ? ( diff --git a/packages/prover/tests/runtime/optimistic-form-action-oracle.spec.ts b/packages/prover/tests/runtime/optimistic-form-action-oracle.spec.ts new file mode 100644 index 0000000000..7d2b905b5c --- /dev/null +++ b/packages/prover/tests/runtime/optimistic-form-action-oracle.spec.ts @@ -0,0 +1,16 @@ +import { expect, test } from "@playwright/test"; +import { OPTIMISTIC_ACTION_EXPECTED_RUNS } from "./constants.js"; + +test("an optimistic update remains visible while its Form Action is pending", async ({ page }) => { + await page.goto("/?oracle=optimistic-form-action"); + + await page.getByRole("button", { name: "add todo" }).click(); + + await expect(page.getByTestId("optimistic-pending")).toHaveText("true"); + await expect(page.getByTestId("optimistic-todos")).toContainText("Write:pending"); + await expect + .poll(() => page.evaluate(() => window.optimisticActionRuns)) + .toBe(OPTIMISTIC_ACTION_EXPECTED_RUNS); + await expect(page.getByTestId("optimistic-pending")).toHaveText("false"); + await expect(page.getByTestId("optimistic-todos")).toContainText("Write:confirmed"); +}); From f989cd7ac07a12ed2c1d95aded276f187d4304cb Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Tue, 28 Jul 2026 21:15:37 +0000 Subject: [PATCH 13/23] feat(prover): certify Action State --- packages/prover/README.md | 10 +- packages/prover/research-log.md | 95 +++++- packages/prover/src/analyze-action-state.ts | 90 ++++++ .../prover/src/analyze-boundary-coverage.ts | 36 +++ packages/prover/src/analyze-react-unit.ts | 3 + .../prover/src/build-react-semantic-graph.ts | 302 +++++++++++++++++- .../prover/src/check-react-proof-report.ts | 194 ++++++++++- packages/prover/src/collect-action-state.ts | 140 ++++++++ packages/prover/src/collect-hook-bindings.ts | 49 +++ packages/prover/src/constants.ts | 10 +- packages/prover/src/index.ts | 5 + packages/prover/src/prove-react-app.ts | 2 + packages/prover/src/types.ts | 48 +++ .../src/app.tsx | 19 ++ .../tsconfig.json | 4 + .../src/app.tsx | 10 + .../tsconfig.json | 4 + .../src/app.tsx | 15 + .../tsconfig.json | 4 + .../proved-action-state-form/src/app.tsx | 45 +++ .../proved-action-state-form/tsconfig.json | 4 + .../src/app.tsx | 29 ++ .../tsconfig.json | 4 + .../prover/tests/fixtures/react-shim.d.ts | 12 + .../src/app.tsx | 14 + .../tsconfig.json | 4 + .../src/app.tsx | 10 + .../tsconfig.json | 4 + packages/prover/tests/prove-react-app.test.ts | 124 ++++++- .../tests/runtime/action-state-oracle.spec.ts | 21 ++ packages/prover/tests/runtime/constants.ts | 3 + packages/prover/tests/runtime/main.tsx | 35 +- 32 files changed, 1329 insertions(+), 20 deletions(-) create mode 100644 packages/prover/src/analyze-action-state.ts create mode 100644 packages/prover/src/collect-action-state.ts create mode 100644 packages/prover/tests/fixtures/incomplete-action-state-async-transition/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-action-state-async-transition/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-action-state-dispatcher-escape/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-action-state-dispatcher-escape/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-action-state-reducer-prop/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-action-state-reducer-prop/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-action-state-form/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-action-state-form/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-action-state-transition/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-action-state-transition/tsconfig.json create mode 100644 packages/prover/tests/fixtures/refuted-action-state-outside-action/src/app.tsx create mode 100644 packages/prover/tests/fixtures/refuted-action-state-outside-action/tsconfig.json create mode 100644 packages/prover/tests/fixtures/refuted-action-state-render-dispatch/src/app.tsx create mode 100644 packages/prover/tests/fixtures/refuted-action-state-render-dispatch/tsconfig.json create mode 100644 packages/prover/tests/runtime/action-state-oracle.spec.ts diff --git a/packages/prover/README.md b/packages/prover/README.md index ce0b3cf0a1..f90388846e 100644 --- a/packages/prover/README.md +++ b/packages/prover/README.md @@ -70,6 +70,10 @@ The report includes: 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; +- 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 @@ -125,7 +129,11 @@ starter, a phase-correct Action callback, coherent controlled-state evidence, an 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. Optimistic +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. +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. diff --git a/packages/prover/research-log.md b/packages/prover/research-log.md index afd1a76e0c..35e8f2cb06 100644 --- a/packages/prover/research-log.md +++ b/packages/prover/research-log.md @@ -475,6 +475,7 @@ Each discovered unit receives these obligations: | `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 | | `memo-dependencies` | `useMemo` and `useCallback` captures versus inline dependency tuples | | `optimistic-state` | Reducer/updater purity, setter identity, render exclusion, Form/Transition Action ownership | @@ -753,7 +754,7 @@ components and hooks, including hooks hidden in incorrectly named helper functio ### Test stack -Current checkpoint: 281 TypeScript fixture projects, 491 static tests, and 37 Chromium runtime +Current checkpoint: 297 TypeScript fixture projects, 499 static tests, and 38 Chromium runtime oracles. - Vite Plus supplies package build and Vitest-compatible static tests. @@ -1273,9 +1274,10 @@ Async or scheduled Actions, opaque callback values, escaped starters, indirect ` 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`, `useActionState`, Server Actions, Suspense +not request ordering, async context, `useDeferredValue`, Server Actions, Suspense fallback preservation, or whole-application transition state machines. Form Actions and -`useOptimistic` now have the separate certificates below. +`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 @@ -1445,3 +1447,90 @@ 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. diff --git a/packages/prover/src/analyze-action-state.ts b/packages/prover/src/analyze-action-state.ts new file mode 100644 index 0000000000..07d9422742 --- /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-boundary-coverage.ts b/packages/prover/src/analyze-boundary-coverage.ts index 5b74760703..4ec66b1f68 100644 --- a/packages/prover/src/analyze-boundary-coverage.ts +++ b/packages/prover/src/analyze-boundary-coverage.ts @@ -164,6 +164,15 @@ export const analyzeBoundaryCoverage = ( ), ); }; + 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 isModeledFormActionCallableUse = (node: ts.Node): boolean => { let currentNode = node; while (currentNode !== functionNode && currentNode.parent) { @@ -217,6 +226,31 @@ export const analyzeBoundaryCoverage = ( } 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) @@ -347,6 +381,7 @@ export const analyzeBoundaryCoverage = ( for (const unmodeledUse of reachabilityGraph.unmodeledCallableUses) { 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}`; @@ -426,6 +461,7 @@ export const analyzeBoundaryCoverage = ( canonicalReactApiName && REACT_UNMODELED_HOOK_NAMES.has(canonicalReactApiName) && !isModeledContextRead && + !(canonicalReactApiName === "useActionState" && isModeledActionStateCall(node)) && !(canonicalReactApiName === "useTransition" && isModeledUseTransitionCall(node)) && !(canonicalReactApiName === "useOptimistic" && isModeledOptimisticCall(node)) ) { diff --git a/packages/prover/src/analyze-react-unit.ts b/packages/prover/src/analyze-react-unit.ts index 7a475527f3..79dc570152 100644 --- a/packages/prover/src/analyze-react-unit.ts +++ b/packages/prover/src/analyze-react-unit.ts @@ -1,3 +1,4 @@ +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"; @@ -30,6 +31,7 @@ import { ReactObligationStatus, ReactProofClaim, ReactUnitKind } from "./types.j import type { ReactAnalysisContext, ReactUnitDescriptor, ReactUnitProof } from "./types.js"; const ALL_REACT_PROOF_CLAIMS: ReadonlyArray = [ + ReactProofClaim.ActionState, ReactProofClaim.AsyncEffectOwnership, ReactProofClaim.BoundaryCoverage, ReactProofClaim.CallableRefFreshness, @@ -128,6 +130,7 @@ export const analyzeReactUnit = ( kind: unit.kind, location: getNodeLocation(unit.node, context.rootDirectory), obligations: [ + analyzeActionState(unit, context), analyzeAsyncEffectOwnership(unit.functionNode, context), analyzeBoundaryCoverage(unit, context), analyzeCallableRefFreshness(unit, context), diff --git a/packages/prover/src/build-react-semantic-graph.ts b/packages/prover/src/build-react-semantic-graph.ts index fb44549f14..8ffeb87e21 100644 --- a/packages/prover/src/build-react-semantic-graph.ts +++ b/packages/prover/src/build-react-semantic-graph.ts @@ -1,4 +1,5 @@ import ts from "typescript"; +import { collectActionState } from "./collect-action-state.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"; @@ -51,6 +52,9 @@ import { resolveFunction } from "./resolve-function.js"; import { mergeCallableBindings } from "./resolve-callable-expression.js"; import type { ResolvedCallableValueDescriptor } from "./resolve-callable-expression.js"; import { + ReactActionStateDispatchKind, + ReactActionStateDispatchStatus, + ReactActionStateReducerStatus, ReactCallableRefFreshness, ReactClassConstructionIssueStatus, ReactClassConstructionStatus, @@ -71,6 +75,8 @@ import { } from "./types.js"; import type { ReactAnalysisContext, + ReactSemanticActionState, + ReactSemanticActionStateDispatch, ReactSemanticCallback, ReactSemanticAsyncTask, ReactSemanticContext, @@ -108,6 +114,8 @@ import { collectReachableCallExpressions } from "./utils/collect-reachable-call- import { collectExecutionCallbackIds } from "./utils/collect-execution-callback-ids.js"; import { getClassMethodDeclaration } from "./utils/get-class-method-declaration.js"; import { isDeferredCallbackSynchronous } from "./utils/is-deferred-callback-synchronous.js"; +import { getResolvedSymbol } from "./utils/get-resolved-symbol.js"; +import { unwrapTypescriptExpression } from "./unwrap-typescript-expression.js"; interface UnitGraphIdentity { descriptor: ReactUnitDescriptor; @@ -178,6 +186,15 @@ interface FormActionGraphFacts extends CallbackGraphFacts { actions: ReadonlyArray; } +interface ActionStateDefinitionGraphFacts extends CallbackGraphFacts { + callbacksByDispatcher: ReadonlyMap; + states: ReadonlyArray; +} + +interface ActionStateDispatchGraphFacts { + dispatches: ReadonlyArray; +} + interface HookStateTransitionGraphFacts extends CallbackGraphFacts { transitions: ReadonlyArray; } @@ -1898,10 +1915,103 @@ const collectReducerCallbacks = ( return { callbacks, reachableFunctions, functionCalls }; }; +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( @@ -1935,7 +2045,16 @@ const collectFormActionGraph = ( functionNode, ReactExecutionPhase.FormAction, ); - const actionCallbackIds: string[] = []; + 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; @@ -1978,9 +2097,10 @@ const collectFormActionGraph = ( functionCalls.push(...reachabilityFacts.functionCalls); } const callbackComplete = - resolution.isComplete && - actionCallbackIds.length > 0 && - actionCallbackIds.length === resolution.callbacks.length; + Boolean(actionStateCallback) || + (resolution.isComplete && + actionCallbackIds.length > 0 && + actionCallbackIds.length === resolution.callbacks.length); let status = descriptor.status; if (status === ReactFormActionStatus.Resolved && !callbackComplete) { status = ReactFormActionStatus.Opaque; @@ -2069,6 +2189,7 @@ const collectTransitionActionGraph = ( 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, @@ -2122,6 +2243,136 @@ const collectTransitionActionGraph = ( 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, @@ -2351,6 +2602,7 @@ const collectOptimisticStateGraph = ( executionCallbacks.every( (callback) => callback.phase === ReactExecutionPhase.FormAction || + callback.phase === ReactExecutionPhase.ActionStateReducer || (callback.phase === ReactExecutionPhase.TransitionAction && completeTransitionCallbackIds.has(callback.id)), ) @@ -2360,6 +2612,7 @@ const collectOptimisticStateGraph = ( executionCallbacks.some( (callback) => callback.phase !== ReactExecutionPhase.FormAction && + callback.phase !== ReactExecutionPhase.ActionStateReducer && callback.phase !== ReactExecutionPhase.TransitionAction, ) ) { @@ -2757,6 +3010,8 @@ export const buildReactSemanticGraph = ( const classLifecycles: ReactSemanticClassLifecycle[] = []; const classStateWrites: ReactSemanticClassStateWrite[] = []; const classStateTransitions: ReactSemanticClassStateTransition[] = []; + const actionStates: ReactSemanticActionState[] = []; + const actionStateDispatches: ReactSemanticActionStateDispatch[] = []; const formActions: ReactSemanticFormAction[] = []; const hookStateTransitions: ReactSemanticHookStateTransition[] = []; const optimisticStates: ReactSemanticOptimisticState[] = []; @@ -2782,18 +3037,35 @@ export const buildReactSemanticGraph = ( callbacks.push(...eventGraph.callbacks); reachableFunctions.push(...eventGraph.reachableFunctions); functionCalls.push(...eventGraph.functionCalls); - const formActionGraph = collectFormActionGraph(identities, context, componentFlow); + 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 && - (identity.descriptor.kind === ReactUnitKind.Component || - identity.descriptor.kind === ReactUnitKind.ClassComponent || - identity.descriptor.kind === ReactUnitKind.Hook) + (unitKind === ReactUnitKind.Component || + unitKind === ReactUnitKind.ClassComponent || + unitKind === ReactUnitKind.Hook) ) { const renderCallback = createCallbackFact( identity, @@ -2893,6 +3165,18 @@ export const buildReactSemanticGraph = ( 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 hookStateTransitionGraph = collectHookStateTransitionGraph( identity, @@ -2930,6 +3214,8 @@ export const buildReactSemanticGraph = ( const callableRefs = collectCallableRefGraph(identities, callbacks, functionCalls, context); return { schemaVersion: REACT_SEMANTIC_GRAPH_SCHEMA_VERSION, + actionStates, + actionStateDispatches, units: identities.map((identity) => identity.semanticUnit), edges, hookCalls, diff --git a/packages/prover/src/check-react-proof-report.ts b/packages/prover/src/check-react-proof-report.ts index bf20bc9865..d10a2631fe 100644 --- a/packages/prover/src/check-react-proof-report.ts +++ b/packages/prover/src/check-react-proof-report.ts @@ -1,5 +1,8 @@ import { REACT_PROOF_SCHEMA_VERSION, REACT_SEMANTIC_GRAPH_SCHEMA_VERSION } from "./constants.js"; import { + ReactActionStateDispatchKind, + ReactActionStateDispatchStatus, + ReactActionStateReducerStatus, ReactAppProofStatus, ReactAsyncOwnershipStatus, ReactCallableRefFreshness, @@ -41,6 +44,9 @@ import type { } 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 OPTIMISTIC_ACTION_STATUSES = new Set(Object.values(ReactOptimisticActionStatus)); @@ -48,6 +54,7 @@ const OPTIMISTIC_REDUCER_STATUSES = new Set(Object.values(ReactOptimisticReducer const TRANSITION_ACTION_STATUSES = new Set(Object.values(ReactTransitionActionStatus)); const TRANSITION_STARTER_KINDS = new Set(Object.values(ReactTransitionStarterKind)); const TRANSITION_ACTION_ORIGIN_PHASES = new Set([ + ReactExecutionPhase.ActionStateReducer, ReactExecutionPhase.ClassMount, ReactExecutionPhase.ClassUpdate, ReactExecutionPhase.Deferred, @@ -213,6 +220,32 @@ const expectedTransitionActionStatus = ( : 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, @@ -336,6 +369,17 @@ const checkClaimCoverage = ( 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, ); @@ -915,6 +959,138 @@ const checkGraphReferences = ( 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 ( @@ -1020,8 +1196,10 @@ const checkGraphReferences = ( const callback = callbacksById.get(callbackId); return Boolean( callback && - callback.kind === ReactSemanticCallbackKind.FormAction && - callback.phase === ReactExecutionPhase.FormAction, + ((callback.kind === ReactSemanticCallbackKind.FormAction && + callback.phase === ReactExecutionPhase.FormAction) || + (callback.kind === ReactSemanticCallbackKind.ActionStateReducer && + callback.phase === ReactExecutionPhase.ActionStateReducer)), ); }); if (!hasValidCallbacks) { @@ -1134,6 +1312,7 @@ const checkGraphReferences = ( executionCallbacks.every( (callback) => callback.phase === ReactExecutionPhase.FormAction || + callback.phase === ReactExecutionPhase.ActionStateReducer || (callback.phase === ReactExecutionPhase.TransitionAction && completeTransitionCallbackIds.has(callback.id)), ) @@ -1143,6 +1322,7 @@ const checkGraphReferences = ( executionCallbacks.some( (callback) => callback.phase !== ReactExecutionPhase.FormAction && + callback.phase !== ReactExecutionPhase.ActionStateReducer && callback.phase !== ReactExecutionPhase.TransitionAction, ) ) { @@ -2078,6 +2258,16 @@ export const checkReactProofReport = (report: ReactAppProofReport): ReactProofCe "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", diff --git a/packages/prover/src/collect-action-state.ts b/packages/prover/src/collect-action-state.ts new file mode 100644 index 0000000000..d099e54882 --- /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-hook-bindings.ts b/packages/prover/src/collect-hook-bindings.ts index d66cad3177..acf905f2c6 100644 --- a/packages/prover/src/collect-hook-bindings.ts +++ b/packages/prover/src/collect-hook-bindings.ts @@ -1,6 +1,10 @@ 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, @@ -21,7 +25,19 @@ 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; @@ -46,6 +62,7 @@ export const collectHookBindings = ( 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(); @@ -84,6 +101,37 @@ export const collectHookBindings = ( 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) && @@ -125,6 +173,7 @@ export const collectHookBindings = ( }; functionNode.forEachChild(visit); return { + actionStateBindings, effectEvents, optimisticBindings, refs, diff --git a/packages/prover/src/constants.ts b/packages/prover/src/constants.ts index 89d8eb31de..d9e08d199b 100644 --- a/packages/prover/src/constants.ts +++ b/packages/prover/src/constants.ts @@ -1,7 +1,7 @@ import { ReactEffectResourceKind } from "./types.js"; -export const REACT_PROOF_SCHEMA_VERSION = 19; -export const REACT_SEMANTIC_GRAPH_SCHEMA_VERSION = 25; +export const REACT_PROOF_SCHEMA_VERSION = 20; +export const REACT_SEMANTIC_GRAPH_SCHEMA_VERSION = 26; 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; @@ -9,6 +9,10 @@ export const FIRST_SOURCE_COLUMN = 1; 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_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; @@ -45,6 +49,7 @@ export const EFFECT_EVENT_REGISTRATION_CALL_NAMES = new Set([ export const PROMISE_CONTINUATION_METHOD_NAMES = new Set(["catch", "finally", "then"]); export const REACT_MODELED_HOOK_NAMES = new Set([ + "useActionState", "useCallback", "useContext", "useEffect", @@ -61,7 +66,6 @@ export const REACT_MODELED_HOOK_NAMES = new Set([ export const REACT_UNMODELED_HOOK_NAMES = new Set([ "use", - "useActionState", "useDeferredValue", "useImperativeHandle", "useOptimistic", diff --git a/packages/prover/src/index.ts b/packages/prover/src/index.ts index 70edaf0ddc..06751ca1e1 100644 --- a/packages/prover/src/index.ts +++ b/packages/prover/src/index.ts @@ -1,6 +1,9 @@ export { proveReactApp } from "./prove-react-app.js"; export { checkReactProofReport } from "./check-react-proof-report.js"; export { + ReactActionStateDispatchKind, + ReactActionStateDispatchStatus, + ReactActionStateReducerStatus, ReactAppProofStatus, ReactAsyncOwnershipStatus, ReactCallableRefFreshness, @@ -52,6 +55,8 @@ export type { ReactProofObligation, ReactProofSummary, ReactSemanticEdge, + ReactSemanticActionState, + ReactSemanticActionStateDispatch, ReactSemanticContext, ReactSemanticContextConsumer, ReactSemanticContextProvider, diff --git a/packages/prover/src/prove-react-app.ts b/packages/prover/src/prove-react-app.ts index 1997827414..c77866c06c 100644 --- a/packages/prover/src/prove-react-app.ts +++ b/packages/prover/src/prove-react-app.ts @@ -24,6 +24,8 @@ export const proveReactApp = (input: ProveReactAppInput): ReactAppProofReport => rootDirectory, graph: { schemaVersion: REACT_SEMANTIC_GRAPH_SCHEMA_VERSION, + actionStates: [], + actionStateDispatches: [], units: [], edges: [], hookCalls: [], diff --git a/packages/prover/src/types.ts b/packages/prover/src/types.ts index 13d7442bff..e84d64ef43 100644 --- a/packages/prover/src/types.ts +++ b/packages/prover/src/types.ts @@ -13,6 +13,7 @@ export enum ReactObligationStatus { } export enum ReactProofClaim { + ActionState = "action-state", BoundaryCoverage = "boundary-coverage", CallableRefFreshness = "callable-ref-freshness", ClassConstruction = "class-construction", @@ -69,6 +70,7 @@ export enum ReactCompilerFactStatus { } export enum ReactExecutionPhase { + ActionStateReducer = "action-state-reducer", ClassConstruction = "class-construction", ClassMount = "class-mount", ClassUnmount = "class-unmount", @@ -89,6 +91,7 @@ export enum ReactExecutionPhase { } export enum ReactSemanticCallbackKind { + ActionStateReducer = "action-state-reducer", ClassMount = "class-mount", ClassStateUpdater = "class-state-updater", ClassUnmount = "class-unmount", @@ -592,6 +595,49 @@ export interface ReactSemanticHookStateTransition { 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", @@ -724,6 +770,8 @@ export interface ReactCompilerGraph { export interface ReactSemanticGraph { schemaVersion: number; + actionStates: ReadonlyArray; + actionStateDispatches: ReadonlyArray; units: ReadonlyArray; edges: ReadonlyArray; hookCalls: ReadonlyArray; 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 0000000000..542b7ba071 --- /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 0000000000..d2a921ed01 --- /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 0000000000..096d96f96d --- /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 0000000000..d2a921ed01 --- /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 0000000000..e136fc4840 --- /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 0000000000..d2a921ed01 --- /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/proved-action-state-form/src/app.tsx b/packages/prover/tests/fixtures/proved-action-state-form/src/app.tsx new file mode 100644 index 0000000000..6de5d6ddba --- /dev/null +++ b/packages/prover/tests/fixtures/proved-action-state-form/src/app.tsx @@ -0,0 +1,45 @@ +import { useActionState, useOptimistic } from "react"; + +interface CartState { + confirmedQuantity: number; + message: string; +} + +const updateCart = async (previousState: CartState, formData: FormData): Promise => { + const quantity = Number(formData.get("quantity")); + await Promise.resolve(); + return { + confirmedQuantity: previousState.confirmedQuantity + quantity, + message: `${quantity} tickets added`, + }; +}; + +export const Checkout = () => { + const [cart, updateCartAction, isPending] = useActionState(updateCart, { + confirmedQuantity: 0, + message: "", + }); + const [optimisticQuantity, setOptimisticQuantity] = useOptimistic( + cart.confirmedQuantity, + (currentQuantity, quantity: number) => currentQuantity + quantity, + ); + const submitCart = (formData: FormData) => { + setOptimisticQuantity(Number(formData.get("quantity"))); + updateCartAction(formData); + }; + + return ( +
    + + + + {optimisticQuantity} +

    {cart.message}

    +
    + ); +}; diff --git a/packages/prover/tests/fixtures/proved-action-state-form/tsconfig.json b/packages/prover/tests/fixtures/proved-action-state-form/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-action-state-form/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-action-state-transition/src/app.tsx b/packages/prover/tests/fixtures/proved-action-state-transition/src/app.tsx new file mode 100644 index 0000000000..7786053b7d --- /dev/null +++ b/packages/prover/tests/fixtures/proved-action-state-transition/src/app.tsx @@ -0,0 +1,29 @@ +import { startTransition, useActionState } from "react"; + +interface SelectionState { + selectedIds: ReadonlyArray; +} + +const selectItem = (previousState: SelectionState, itemId: string): SelectionState => ({ + selectedIds: [...previousState.selectedIds, itemId], +}); + +export const SelectionPanel = () => { + const [selection, dispatchSelection, isPending] = useActionState(selectItem, { + selectedIds: [], + }); + const handleSelect = (itemId: string) => { + startTransition(() => { + dispatchSelection(itemId); + }); + }; + + return ( +
    + + {selection.selectedIds.join(", ")} +
    + ); +}; diff --git a/packages/prover/tests/fixtures/proved-action-state-transition/tsconfig.json b/packages/prover/tests/fixtures/proved-action-state-transition/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-action-state-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/react-shim.d.ts b/packages/prover/tests/fixtures/react-shim.d.ts index e016c10091..5cf0820d2c 100644 --- a/packages/prover/tests/fixtures/react-shim.d.ts +++ b/packages/prover/tests/fixtures/react-shim.d.ts @@ -21,6 +21,18 @@ declare module "react" { } export const use: Use; + export const useActionState: { + ( + reducerAction: (previousState: State) => State | Promise, + initialState: State, + permalink?: string, + ): [State, () => void, boolean]; + ( + reducerAction: (previousState: State, actionPayload: ActionPayload) => State | Promise, + initialState: State, + permalink?: string, + ): [State, (actionPayload: ActionPayload) => void, boolean]; + }; export const createContext: (defaultValue: Value) => Context; export const memo: (component: Component) => Component; export const StrictMode: (properties: { children?: unknown }) => unknown; diff --git a/packages/prover/tests/fixtures/refuted-action-state-outside-action/src/app.tsx b/packages/prover/tests/fixtures/refuted-action-state-outside-action/src/app.tsx new file mode 100644 index 0000000000..328a957265 --- /dev/null +++ b/packages/prover/tests/fixtures/refuted-action-state-outside-action/src/app.tsx @@ -0,0 +1,14 @@ +import { useActionState } from "react"; + +export const Counter = () => { + const [count, dispatchIncrement] = useActionState( + (previousCount: number, increment: number) => previousCount + increment, + 0, + ); + + return ( + + ); +}; diff --git a/packages/prover/tests/fixtures/refuted-action-state-outside-action/tsconfig.json b/packages/prover/tests/fixtures/refuted-action-state-outside-action/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/refuted-action-state-outside-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/refuted-action-state-render-dispatch/src/app.tsx b/packages/prover/tests/fixtures/refuted-action-state-render-dispatch/src/app.tsx new file mode 100644 index 0000000000..c2a89cc4d7 --- /dev/null +++ b/packages/prover/tests/fixtures/refuted-action-state-render-dispatch/src/app.tsx @@ -0,0 +1,10 @@ +import { useActionState } from "react"; + +export const InvalidCounter = () => { + const [count, dispatchIncrement] = useActionState( + (previousCount: number) => previousCount + 1, + 0, + ); + dispatchIncrement(); + return {count}; +}; diff --git a/packages/prover/tests/fixtures/refuted-action-state-render-dispatch/tsconfig.json b/packages/prover/tests/fixtures/refuted-action-state-render-dispatch/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/refuted-action-state-render-dispatch/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/prove-react-app.test.ts b/packages/prover/tests/prove-react-app.test.ts index 28d451dfdd..ca0e416773 100644 --- a/packages/prover/tests/prove-react-app.test.ts +++ b/packages/prover/tests/prove-react-app.test.ts @@ -4,6 +4,9 @@ import { describe, expect, it } from "vite-plus/test"; import { checkReactProofReport, proveReactApp, + ReactActionStateDispatchKind, + ReactActionStateDispatchStatus, + ReactActionStateReducerStatus, ReactAppProofStatus, ReactAsyncOwnershipStatus, ReactCallableRefFreshness, @@ -57,6 +60,16 @@ const proveFixture = (fixtureName: string) => }); const REFUTED_FIXTURES: ReadonlyArray = [ + { + fixtureName: "refuted-action-state-outside-action", + claim: ReactProofClaim.ActionState, + evidencePattern: /outside an Action/, + }, + { + fixtureName: "refuted-action-state-render-dispatch", + claim: ReactProofClaim.ActionState, + evidencePattern: /during render/, + }, { fixtureName: "refuted-unsupported-form-action-control", claim: ReactProofClaim.FormActions, @@ -624,8 +637,8 @@ describe("proveReactApp", () => { const report = proveFixture("proved-chat"); const effect = report.graph.effects[0]; - expect(report.schemaVersion).toBe(19); - expect(report.graph.schemaVersion).toBe(25); + expect(report.schemaVersion).toBe(20); + expect(report.graph.schemaVersion).toBe(26); expect(effect?.hookName).toBe("useEffect"); expect(effect?.callbackResolved).toBe(true); expect(effect?.dependencyMode).toBe(ReactEffectDependencyMode.Inline); @@ -3196,6 +3209,113 @@ describe("proveReactApp", () => { ).toBe(true); }); + it("certifies Action State through direct Form Action and nested Form Action roots", () => { + const report = proveFixture("proved-action-state-form"); + const actionState = report.graph.actionStates[0]; + const reducerCallback = report.graph.callbacks.find( + (callback) => callback.id === actionState?.reducerCallbackId, + ); + const actionStateProof = report.units[0]?.obligations.find( + (obligation) => obligation.claim === ReactProofClaim.ActionState, + ); + + expect(report.status).toBe(ReactAppProofStatus.Proved); + expect(actionState?.reducerStatus).toBe(ReactActionStateReducerStatus.Resolved); + expect(actionState?.complete).toBe(true); + expect(reducerCallback?.kind).toBe(ReactSemanticCallbackKind.ActionStateReducer); + expect(reducerCallback?.phase).toBe(ReactExecutionPhase.ActionStateReducer); + expect(report.graph.actionStateDispatches).toHaveLength(2); + expect( + report.graph.actionStateDispatches.every( + (dispatch) => + dispatch.status === ReactActionStateDispatchStatus.Action && dispatch.complete, + ), + ).toBe(true); + expect( + report.graph.actionStateDispatches.some( + (dispatch) => dispatch.kind === ReactActionStateDispatchKind.ActionProp, + ), + ).toBe(true); + expect( + report.graph.formActions.some( + (formAction) => + formAction.complete && + Boolean( + actionState?.reducerCallbackId && + formAction.actionCallbackIds.includes(actionState.reducerCallbackId), + ), + ), + ).toBe(true); + expect(actionStateProof?.status).toBe(ReactObligationStatus.Proved); + expect(checkReactProofReport(report).status).toBe(ReactProofCertificateStatus.Valid); + }); + + it("certifies a direct Action State dispatch inside a synchronous Transition Action", () => { + const report = proveFixture("proved-action-state-transition"); + const dispatch = report.graph.actionStateDispatches[0]; + const transitionAction = report.graph.transitionActions[0]; + + expect(report.status).toBe(ReactAppProofStatus.Proved); + expect(transitionAction?.complete).toBe(true); + expect(dispatch?.kind).toBe(ReactActionStateDispatchKind.Call); + expect(dispatch?.status).toBe(ReactActionStateDispatchStatus.Action); + expect(dispatch?.executionCallbackIds).toContain(transitionAction?.actionCallbackId); + expect(dispatch?.complete).toBe(true); + expect(checkReactProofReport(report).status).toBe(ReactProofCertificateStatus.Valid); + }); + + it.each([ + ["incomplete-action-state-dispatcher-escape", ReactActionStateDispatchStatus.SetterEscape], + ["incomplete-action-state-async-transition", ReactActionStateDispatchStatus.Unknown], + ])("fails closed for incomplete Action State dispatch ownership in %s", (fixtureName, status) => { + const report = proveFixture(fixtureName); + const dispatch = report.graph.actionStateDispatches[0]; + const actionStateProof = report.units[0]?.obligations.find( + (obligation) => obligation.claim === ReactProofClaim.ActionState, + ); + + expect(report.status).toBe(ReactAppProofStatus.Incomplete); + expect(dispatch?.status).toBe(status); + expect(dispatch?.complete).toBe(false); + expect(actionStateProof?.status).toBe(ReactObligationStatus.Unknown); + expect(checkReactProofReport(report).status).toBe(ReactProofCertificateStatus.Valid); + }); + + it("fails closed when the Action State reducer comes from an unresolved prop", () => { + const report = proveFixture("incomplete-action-state-reducer-prop"); + const actionState = report.graph.actionStates[0]; + const actionStateProof = report.units[0]?.obligations.find( + (obligation) => obligation.claim === ReactProofClaim.ActionState, + ); + + expect(report.status).toBe(ReactAppProofStatus.Incomplete); + expect(actionState?.reducerStatus).toBe(ReactActionStateReducerStatus.Opaque); + expect(actionState?.complete).toBe(false); + expect(actionStateProof?.status).toBe(ReactObligationStatus.Unknown); + expect(checkReactProofReport(report).status).toBe(ReactProofCertificateStatus.Valid); + }); + + it("rejects a forged Action State ownership certificate", () => { + const report = proveFixture("refuted-action-state-outside-action"); + const certificate = checkReactProofReport({ + ...report, + graph: { + ...report.graph, + actionStateDispatches: report.graph.actionStateDispatches.map((dispatch) => ({ + ...dispatch, + status: ReactActionStateDispatchStatus.Action, + sourceComplete: true, + complete: true, + })), + }, + }); + + expect(certificate.status).toBe(ReactProofCertificateStatus.Invalid); + expect( + certificate.failures.some((failure) => failure.description.includes("Action State")), + ).toBe(true); + }); + it("certifies a Form Action, pure optimistic reducer, and optimistic update together", () => { const report = proveFixture("proved-optimistic-form"); const formAction = report.graph.formActions[0]; diff --git a/packages/prover/tests/runtime/action-state-oracle.spec.ts b/packages/prover/tests/runtime/action-state-oracle.spec.ts new file mode 100644 index 0000000000..fd140698fa --- /dev/null +++ b/packages/prover/tests/runtime/action-state-oracle.spec.ts @@ -0,0 +1,21 @@ +import { expect, test } from "@playwright/test"; +import { ACTION_STATE_EXPECTED_RUNS } from "./constants.js"; + +test("Action State queues submissions and preserves pending state under Strict Mode", async ({ + page, +}) => { + await page.goto("/?oracle=action-state"); + + const itemInput = page.getByRole("textbox"); + await itemInput.fill("first"); + await page.getByRole("button", { name: "submit item" }).click(); + await itemInput.fill("second"); + await page.getByRole("button", { name: "submit item" }).click(); + + await expect(page.getByTestId("action-state-pending")).toHaveText("true"); + await expect + .poll(() => page.evaluate(() => window.actionStateRuns)) + .toBe(ACTION_STATE_EXPECTED_RUNS); + await expect(page.getByTestId("action-state-items")).toHaveText("first|second"); + await expect(page.getByTestId("action-state-pending")).toHaveText("false"); +}); diff --git a/packages/prover/tests/runtime/constants.ts b/packages/prover/tests/runtime/constants.ts index 61bd822476..9cd2b70780 100644 --- a/packages/prover/tests/runtime/constants.ts +++ b/packages/prover/tests/runtime/constants.ts @@ -1,3 +1,6 @@ +export const ACTION_STATE_DELAY_MS = 120; +export const ACTION_STATE_EXPECTED_RUNS = 2; +export const ACTION_STATE_INITIAL_RUNS = 0; export const FAST_QUERY_DELAY_MS = 20; export const HOOK_STATE_INCREMENT = 1; export const HOOK_STATE_INITIAL_COUNT = 0; diff --git a/packages/prover/tests/runtime/main.tsx b/packages/prover/tests/runtime/main.tsx index a48e81482e..15aea08584 100644 --- a/packages/prover/tests/runtime/main.tsx +++ b/packages/prover/tests/runtime/main.tsx @@ -3,6 +3,7 @@ import { StrictMode, createContext, memo, + useActionState, useCallback, useContext, useEffect, @@ -17,6 +18,8 @@ import { import type { ChangeEvent } from "react"; import { createRoot } from "react-dom/client"; import { + ACTION_STATE_DELAY_MS, + ACTION_STATE_INITIAL_RUNS, FAST_QUERY_DELAY_MS, HOOK_STATE_INCREMENT, HOOK_STATE_INITIAL_COUNT, @@ -42,6 +45,7 @@ import { declare global { interface Window { effectEventSetupRuns: number; + actionStateRuns: number; classListenerHits: number; classMounts: number; classSchedulerHits: number; @@ -61,6 +65,7 @@ declare global { } window.effectEventSetupRuns = 0; +window.actionStateRuns = ACTION_STATE_INITIAL_RUNS; window.classListenerHits = 0; window.classMounts = 0; window.classSchedulerHits = 0; @@ -82,6 +87,30 @@ interface OptimisticTodo { isPending: boolean; } +const ActionStateOracle = () => { + const [submittedItems, submitItem, isPending] = useActionState( + async (previousItems: ReadonlyArray, formData: FormData) => { + window.actionStateRuns += 1; + const item = String(formData.get("item")); + await new Promise((resolve) => { + setTimeout(resolve, ACTION_STATE_DELAY_MS); + }); + return [...previousItems, item]; + }, + [], + ); + return ( +
    +
    + + +
    + {submittedItems.join("|")} + {String(isPending)} +
    + ); +}; + const OptimisticFormActionOracle = () => { const [confirmedTodos, setConfirmedTodos] = useState>([ { label: "Read", isPending: false }, @@ -993,6 +1022,9 @@ const RuntimeOracle = () => { if (oracle === "optimistic-form-action") { return ; } + if (oracle === "action-state") { + return ; + } return ; }; @@ -1007,7 +1039,8 @@ const isStrictModeOracle = oracle === "class-state-transition" || oracle === "hook-state-transition" || oracle === "transition-action" || - oracle === "optimistic-form-action"; + oracle === "optimistic-form-action" || + oracle === "action-state"; createRoot(rootElement).render( isStrictModeOracle ? ( From 75df6f3568fc01308223b0f019a0bfdfd363a1cc Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Tue, 28 Jul 2026 22:02:00 +0000 Subject: [PATCH 14/23] feat(prover): certify Form Status topology --- packages/prover/README.md | 8 + packages/prover/research-log.md | 109 ++++++++- packages/prover/src/analyze-form-status.ts | 84 +++++++ packages/prover/src/analyze-react-unit.ts | 3 + .../prover/src/build-react-semantic-graph.ts | 226 ++++++++++++++++++ .../prover/src/check-react-proof-report.ts | 205 +++++++++++++++- packages/prover/src/constants.ts | 7 +- packages/prover/src/index.ts | 3 + packages/prover/src/prove-react-app.ts | 2 + packages/prover/src/types.ts | 29 +++ .../src/app.tsx | 18 ++ .../tsconfig.json | 4 + .../src/app.tsx | 24 ++ .../tsconfig.json | 4 + .../proved-form-status-direct/src/app.tsx | 27 +++ .../proved-form-status-direct/tsconfig.json | 4 + .../src/app.tsx | 27 +++ .../tsconfig.json | 4 + .../proved-form-status-transitive/src/app.tsx | 32 +++ .../tsconfig.json | 4 + .../prover/tests/fixtures/react-shim.d.ts | 8 + .../src/app.tsx | 14 ++ .../tsconfig.json | 4 + .../src/app.tsx | 17 ++ .../tsconfig.json | 4 + .../src/app.tsx | 6 + .../tsconfig.json | 4 + .../src/app.tsx | 20 ++ .../tsconfig.json | 4 + packages/prover/tests/prove-react-app.test.ts | 127 +++++++++- packages/prover/tests/runtime/constants.ts | 3 + .../tests/runtime/form-status-oracle.spec.ts | 19 ++ packages/prover/tests/runtime/main.tsx | 54 ++++- 33 files changed, 1101 insertions(+), 7 deletions(-) create mode 100644 packages/prover/src/analyze-form-status.ts create mode 100644 packages/prover/tests/fixtures/incomplete-form-status-composed-form/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-form-status-composed-form/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-form-status-render-callback/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-form-status-render-callback/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-form-status-direct/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-form-status-direct/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-form-status-multiple-forms/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-form-status-multiple-forms/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-form-status-transitive/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-form-status-transitive/tsconfig.json create mode 100644 packages/prover/tests/fixtures/refuted-form-status-exported-child/src/app.tsx create mode 100644 packages/prover/tests/fixtures/refuted-form-status-exported-child/tsconfig.json create mode 100644 packages/prover/tests/fixtures/refuted-form-status-mixed-placement/src/app.tsx create mode 100644 packages/prover/tests/fixtures/refuted-form-status-mixed-placement/tsconfig.json create mode 100644 packages/prover/tests/fixtures/refuted-form-status-outside-form/src/app.tsx create mode 100644 packages/prover/tests/fixtures/refuted-form-status-outside-form/tsconfig.json create mode 100644 packages/prover/tests/fixtures/refuted-form-status-same-component/src/app.tsx create mode 100644 packages/prover/tests/fixtures/refuted-form-status-same-component/tsconfig.json create mode 100644 packages/prover/tests/runtime/form-status-oracle.spec.ts diff --git a/packages/prover/README.md b/packages/prover/README.md index f90388846e..6daad424fa 100644 --- a/packages/prover/README.md +++ b/packages/prover/README.md @@ -84,6 +84,11 @@ The report includes: 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 and custom-Hook paths; a detached, + same-component, exported consumer, or mixed outside-form path is refuted, while + component-composed `children` placement and JSX returned from unmodeled render callbacks remain + unknown; - 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, @@ -133,6 +138,9 @@ kind, nonempty complete callback resolution, and exact source/completeness equat 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, require one fact for every canonical Hook call, validate active-form ownership, and reject +forged outside-form, source, topology-status, and completeness fields. 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 diff --git a/packages/prover/research-log.md b/packages/prover/research-log.md index 35e8f2cb06..6d21e24112 100644 --- a/packages/prover/research-log.md +++ b/packages/prover/research-log.md @@ -477,6 +477,7 @@ Each discovered unit receives these obligations: | `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 | @@ -754,7 +755,7 @@ components and hooks, including hooks hidden in incorrectly named helper functio ### Test stack -Current checkpoint: 297 TypeScript fixture projects, 499 static tests, and 38 Chromium runtime +Current checkpoint: 306 TypeScript fixture projects, 509 static tests, and 39 Chromium runtime oracles. - Vite Plus supplies package build and Vitest-compatible static tests. @@ -1534,3 +1535,109 @@ 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. diff --git a/packages/prover/src/analyze-form-status.ts b/packages/prover/src/analyze-form-status.ts new file mode 100644 index 0000000000..e08704549f --- /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-react-unit.ts b/packages/prover/src/analyze-react-unit.ts index 79dc570152..50a5e4d362 100644 --- a/packages/prover/src/analyze-react-unit.ts +++ b/packages/prover/src/analyze-react-unit.ts @@ -13,6 +13,7 @@ import { analyzeEffectEventUsage } from "./analyze-effect-event-usage.js"; import { analyzeEffectStateUpdates } from "./analyze-effect-state-updates.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"; @@ -46,6 +47,7 @@ const ALL_REACT_PROOF_CLAIMS: ReadonlyArray = [ ReactProofClaim.EffectStateUpdates, ReactProofClaim.ExternalStoreConsistency, ReactProofClaim.FormActions, + ReactProofClaim.FormStatus, ReactProofClaim.HookOrder, ReactProofClaim.HookOwnership, ReactProofClaim.HookStateTransitions, @@ -145,6 +147,7 @@ export const analyzeReactUnit = ( analyzeEffectStateUpdates(unit, context), analyzeExternalStoreConsistency(unit, context), analyzeFormActions(unit, context), + analyzeFormStatus(unit, context), analyzeHookOrder(unit.functionNode, context), analyzeHookOwnership(unit.functionNode), analyzeHookStateTransitions(unit, context), diff --git a/packages/prover/src/build-react-semantic-graph.ts b/packages/prover/src/build-react-semantic-graph.ts index 8ffeb87e21..3c14908d6c 100644 --- a/packages/prover/src/build-react-semantic-graph.ts +++ b/packages/prover/src/build-react-semantic-graph.ts @@ -38,6 +38,8 @@ import { REACT_MEMO_HOOK_NAMES, REACT_REDUCER_HOOK_NAMES, REACT_CONTEXT_DEFAULT_SOURCE_ID, + REACT_FORM_OUTSIDE_SOURCE_ID, + REACT_FORM_UNKNOWN_SOURCE_ID, REACT_SEMANTIC_GRAPH_SCHEMA_VERSION, } from "./constants.js"; import { getCanonicalReactApiName } from "./get-canonical-react-api-name.js"; @@ -64,6 +66,7 @@ import { ReactEffectDependencyMode, ReactExecutionPhase, ReactFormActionStatus, + ReactFormStatusTopologyStatus, ReactHookStateUpdaterStatus, ReactIdentityStability, ReactOptimisticActionStatus, @@ -95,6 +98,8 @@ import type { ReactSemanticClassStateTransition, ReactSemanticExternalStore, ReactSemanticFormAction, + ReactSemanticForm, + ReactSemanticFormStatus, ReactSemanticFunctionCall, ReactSemanticGraph, ReactSemanticHookCall, @@ -115,6 +120,7 @@ import { collectExecutionCallbackIds } from "./utils/collect-execution-callback- import { getClassMethodDeclaration } from "./utils/get-class-method-declaration.js"; import { isDeferredCallbackSynchronous } from "./utils/is-deferred-callback-synchronous.js"; import { getResolvedSymbol } from "./utils/get-resolved-symbol.js"; +import { isIntrinsicJsxElement } from "./utils/is-intrinsic-jsx-element.js"; import { unwrapTypescriptExpression } from "./unwrap-typescript-expression.js"; interface UnitGraphIdentity { @@ -249,6 +255,12 @@ interface ContextGraphFacts { contextIdsBySymbol: ReadonlyMap; } +interface FormTopologyGraphFacts { + forms: ReadonlyArray; + formStatuses: ReadonlyArray; + formsByOpeningNode: ReadonlyMap; +} + const collectCallableRefGraph = ( identities: ReadonlyArray, callbacks: ReadonlyArray, @@ -336,6 +348,38 @@ const getDeclarationNameNode = (descriptor: ReactUnitDescriptor): ts.Node | null const resolveAliasedSymbol = (symbol: ts.Symbol, typeChecker: ts.TypeChecker): ts.Symbol => symbol.flags & ts.SymbolFlags.Alias ? typeChecker.getAliasedSymbol(symbol) : symbol; +const isDescriptorExported = ( + descriptor: ReactUnitDescriptor, + typeChecker: ts.TypeChecker, +): boolean => { + let currentNode: ts.Node | undefined = descriptor.node; + 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 declarationName = getDeclarationNameNode(descriptor); + const declarationSymbol = declarationName + ? typeChecker.getSymbolAtLocation(declarationName) + : undefined; + const moduleSymbol = typeChecker.getSymbolAtLocation(descriptor.node.getSourceFile()); + if (!declarationSymbol || !moduleSymbol) return false; + const resolvedDeclarationSymbol = resolveAliasedSymbol(declarationSymbol, typeChecker); + return typeChecker + .getExportsOfModule(moduleSymbol) + .some( + (exportSymbol) => + resolveAliasedSymbol(exportSymbol, typeChecker) === resolvedDeclarationSymbol, + ); +}; + const getExpressionSymbol = ( expression: ts.Expression | ts.JsxTagNameExpression, typeChecker: ts.TypeChecker, @@ -515,6 +559,81 @@ const collectActiveContextProviderIds = ( 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 collectActiveFormTopology = ( + tagName: ts.JsxTagNameExpression, + formsByOpeningNode: ReadonlyMap, +): { activeFormIds: ReadonlyArray; complete: boolean } => { + const activeFormIds: string[] = []; + let foundNearestForm = false; + let complete = true; + const openingElement = tagName.parent; + let currentNode: ts.Node | undefined = + ts.isJsxOpeningElement(openingElement) && ts.isJsxElement(openingElement.parent) + ? openingElement.parent.parent + : openingElement.parent; + while (currentNode && !isFunctionBoundary(currentNode)) { + if (ts.isJsxElement(currentNode)) { + const openingElement = currentNode.openingElement; + const form = formsByOpeningNode.get(openingElement); + if (form) { + activeFormIds.unshift(form.id); + foundNearestForm = true; + } else if (!foundNearestForm && !isIntrinsicJsxElement(openingElement)) { + complete = false; + } + } + currentNode = currentNode.parent; + } + return { activeFormIds, complete }; +}; + const collectUnitIdentitiesBySymbol = ( identities: ReadonlyArray, context: ReactAnalysisContext, @@ -543,6 +662,7 @@ const collectRenderEdges = ( identity: UnitGraphIdentity, unitIdsBySymbol: ReadonlyMap, providersByOpeningNode: ReadonlyMap, + formsByOpeningNode: ReadonlyMap, context: ReactAnalysisContext, ): RenderGraphFacts => { const functionNode = identity.descriptor.functionNode; @@ -562,6 +682,7 @@ const collectRenderEdges = ( const targetId = resolveUnitTarget(tagName, unitIdsBySymbol, context.typeChecker); if (targetId) { const location = getNodeLocation(tagName, context.rootDirectory); + const formTopology = collectActiveFormTopology(tagName, formsByOpeningNode); edges.push({ kind: ReactSemanticEdgeKind.RendersComponent, sourceId: identity.semanticUnit.id, @@ -577,6 +698,8 @@ const collectRenderEdges = ( tagName, providersByOpeningNode, ), + activeFormIds: formTopology.activeFormIds, + formTopologyComplete: formTopology.complete, }); } } @@ -2971,6 +3094,95 @@ const resolveContextConsumers = ( }); }; +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, + 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.filter((unit) => unit.canBeRenderRoot).map((unit) => unit.id); + 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) { + 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 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, + }; + }); +}; + export const buildReactSemanticGraph = ( descriptors: ReadonlyArray, sourceFiles: ReadonlyArray, @@ -2984,6 +3196,10 @@ export const buildReactSemanticGraph = ( 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, }, @@ -3000,6 +3216,7 @@ export const buildReactSemanticGraph = ( ), ); const contextGraph = collectContextGraph(identities, sourceFiles, context); + const formTopologyGraph = collectFormTopologyGraph(identities, context); const edges: ReactSemanticEdge[] = []; const renders: ReactSemanticRender[] = []; const hookCalls: ReactSemanticHookCall[] = []; @@ -3108,6 +3325,7 @@ export const buildReactSemanticGraph = ( identity, unitIdsBySymbol, contextGraph.providersByOpeningNode, + formTopologyGraph.formsByOpeningNode, context, ); edges.push(...renderGraph.edges); @@ -3211,6 +3429,12 @@ export const buildReactSemanticGraph = ( contextGraph.contextProviders, contextGraph.contextConsumers, ); + const formStatuses = resolveFormStatuses( + identities.map((identity) => identity.semanticUnit), + edges, + renders, + formTopologyGraph.formStatuses, + ); const callableRefs = collectCallableRefGraph(identities, callbacks, functionCalls, context); return { schemaVersion: REACT_SEMANTIC_GRAPH_SCHEMA_VERSION, @@ -3240,6 +3464,8 @@ export const buildReactSemanticGraph = ( classStateWrites, classStateTransitions, formActions, + forms: formTopologyGraph.forms, + formStatuses, hookStateTransitions, optimisticStates, optimisticUpdates, diff --git a/packages/prover/src/check-react-proof-report.ts b/packages/prover/src/check-react-proof-report.ts index d10a2631fe..f18553383c 100644 --- a/packages/prover/src/check-react-proof-report.ts +++ b/packages/prover/src/check-react-proof-report.ts @@ -1,4 +1,9 @@ -import { REACT_PROOF_SCHEMA_VERSION, REACT_SEMANTIC_GRAPH_SCHEMA_VERSION } from "./constants.js"; +import { + REACT_FORM_OUTSIDE_SOURCE_ID, + REACT_FORM_UNKNOWN_SOURCE_ID, + REACT_PROOF_SCHEMA_VERSION, + REACT_SEMANTIC_GRAPH_SCHEMA_VERSION, +} from "./constants.js"; import { ReactActionStateDispatchKind, ReactActionStateDispatchStatus, @@ -21,6 +26,7 @@ import { ReactExecutionPhase, ReactFormActionKind, ReactFormActionStatus, + ReactFormStatusTopologyStatus, ReactHookStateUpdaterStatus, ReactObligationStatus, ReactOptimisticActionStatus, @@ -262,6 +268,28 @@ const expectedFormActionStatus = ( : 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, @@ -457,6 +485,17 @@ const checkClaimCoverage = ( `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, ); @@ -493,6 +532,59 @@ const checkClaimCoverage = ( } }; +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 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) { + 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 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[], @@ -515,7 +607,16 @@ const checkGraphReferences = ( ); const contextIds = new Set(report.graph.contexts.map((context) => context.id)); const providerIds = new Set(report.graph.contextProviders.map((provider) => provider.id)); + const formsById = new Map(report.graph.forms.map((form) => [form.id, form])); + const formSourcesByUnit = deriveFormSourcesByUnit(report); 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 && @@ -540,6 +641,17 @@ const checkGraphReferences = ( 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"); + } + 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 (form.ownerId !== render.ownerId) { + addFailure(failures, render.id, "A render has an active form owned by another unit"); + } + } } for (const effect of report.graph.effects) { if (!unitIds.has(effect.ownerId)) { @@ -2206,6 +2318,87 @@ const checkGraphReferences = ( ); } } + 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 = ( @@ -2303,6 +2496,16 @@ export const checkReactProofReport = (report: ReactAppProofReport): ReactProofCe "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, "Optimistic states", diff --git a/packages/prover/src/constants.ts b/packages/prover/src/constants.ts index d9e08d199b..606dcc45c6 100644 --- a/packages/prover/src/constants.ts +++ b/packages/prover/src/constants.ts @@ -1,7 +1,7 @@ import { ReactEffectResourceKind } from "./types.js"; -export const REACT_PROOF_SCHEMA_VERSION = 20; -export const REACT_SEMANTIC_GRAPH_SCHEMA_VERSION = 26; +export const REACT_PROOF_SCHEMA_VERSION = 21; +export const REACT_SEMANTIC_GRAPH_SCHEMA_VERSION = 27; 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; @@ -9,6 +9,8 @@ export const FIRST_SOURCE_COLUMN = 1; 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_FORM_OUTSIDE_SOURCE_ID = "react:form-outside"; +export const REACT_FORM_UNKNOWN_SOURCE_ID = "react:form-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; @@ -54,6 +56,7 @@ export const REACT_MODELED_HOOK_NAMES = new Set([ "useContext", "useEffect", "useEffectEvent", + "useFormStatus", "useId", "useInsertionEffect", "useLayoutEffect", diff --git a/packages/prover/src/index.ts b/packages/prover/src/index.ts index 06751ca1e1..11e195bab4 100644 --- a/packages/prover/src/index.ts +++ b/packages/prover/src/index.ts @@ -24,6 +24,7 @@ export { ReactExecutionPhase, ReactFormActionKind, ReactFormActionStatus, + ReactFormStatusTopologyStatus, ReactHookStateUpdaterStatus, ReactIdentityStability, ReactObligationStatus, @@ -75,6 +76,8 @@ export type { ReactSemanticClassStateTransition, ReactSemanticExternalStore, ReactSemanticFormAction, + ReactSemanticForm, + ReactSemanticFormStatus, ReactSemanticCallback, ReactSemanticAsyncTask, ReactSemanticGraph, diff --git a/packages/prover/src/prove-react-app.ts b/packages/prover/src/prove-react-app.ts index c77866c06c..18edeec6df 100644 --- a/packages/prover/src/prove-react-app.ts +++ b/packages/prover/src/prove-react-app.ts @@ -50,6 +50,8 @@ export const proveReactApp = (input: ProveReactAppInput): ReactAppProofReport => classStateWrites: [], classStateTransitions: [], formActions: [], + forms: [], + formStatuses: [], hookStateTransitions: [], optimisticStates: [], optimisticUpdates: [], diff --git a/packages/prover/src/types.ts b/packages/prover/src/types.ts index e84d64ef43..8da72039e9 100644 --- a/packages/prover/src/types.ts +++ b/packages/prover/src/types.ts @@ -28,6 +28,7 @@ export enum ReactProofClaim { EffectStateUpdates = "effect-state-updates", ExternalStoreConsistency = "external-store-consistency", FormActions = "form-actions", + FormStatus = "form-status", HookOrder = "hook-order", HookOwnership = "hook-ownership", HookStateTransitions = "hook-state-transitions", @@ -157,6 +158,7 @@ export interface ReactSemanticUnit { kind: ReactUnitKind; location: ReactProofLocation; classComponentBase: ReactClassComponentBase | null; + canBeRenderRoot: boolean; sourceComplete: boolean; } @@ -324,6 +326,8 @@ export interface ReactSemanticRender { targetId: string; location: ReactProofLocation; activeContextProviderIds: ReadonlyArray; + activeFormIds: ReadonlyArray; + formTopologyComplete: boolean; } export interface ReactSemanticCallback { @@ -662,6 +666,29 @@ export interface ReactSemanticFormAction { 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", @@ -796,6 +823,8 @@ export interface ReactSemanticGraph { classStateWrites: ReadonlyArray; classStateTransitions: ReadonlyArray; formActions: ReadonlyArray; + forms: ReadonlyArray; + formStatuses: ReadonlyArray; hookStateTransitions: ReadonlyArray; optimisticStates: ReadonlyArray; optimisticUpdates: ReadonlyArray; diff --git a/packages/prover/tests/fixtures/incomplete-form-status-composed-form/src/app.tsx b/packages/prover/tests/fixtures/incomplete-form-status-composed-form/src/app.tsx new file mode 100644 index 0000000000..eca05b48bd --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-form-status-composed-form/src/app.tsx @@ -0,0 +1,18 @@ +import { useFormStatus } from "react-dom"; + +interface FormShellProperties { + children?: unknown; +} + +const FormShell = ({ children }: FormShellProperties) => {children}; + +const SubmitButton = () => { + const { pending } = useFormStatus(); + return ; +}; + +export const Checkout = () => ( + + + +); diff --git a/packages/prover/tests/fixtures/incomplete-form-status-composed-form/tsconfig.json b/packages/prover/tests/fixtures/incomplete-form-status-composed-form/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-form-status-composed-form/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-form-status-render-callback/src/app.tsx b/packages/prover/tests/fixtures/incomplete-form-status-render-callback/src/app.tsx new file mode 100644 index 0000000000..3d8ecbbeed --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-form-status-render-callback/src/app.tsx @@ -0,0 +1,24 @@ +import { useFormStatus } from "react-dom"; + +interface SubmitButtonProperties { + label: string; +} + +const SubmitButton = ({ label }: SubmitButtonProperties) => { + const { pending } = useFormStatus(); + return ( + + ); +}; + +const submitBatch = () => {}; + +export const BatchForm = () => ( +
    + {["primary", "secondary"].map((label) => ( + + ))} + +); diff --git a/packages/prover/tests/fixtures/incomplete-form-status-render-callback/tsconfig.json b/packages/prover/tests/fixtures/incomplete-form-status-render-callback/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-form-status-render-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/proved-form-status-direct/src/app.tsx b/packages/prover/tests/fixtures/proved-form-status-direct/src/app.tsx new file mode 100644 index 0000000000..d048002e9c --- /dev/null +++ b/packages/prover/tests/fixtures/proved-form-status-direct/src/app.tsx @@ -0,0 +1,27 @@ +import { useFormStatus } from "react-dom"; + +const SubmitOrder = () => { + const { data, method, pending } = useFormStatus(); + return ( + <> + + {data ? `${method}:${String(data.get("sku"))}` : "idle"} + + ); +}; + +const saveOrder = (formData: FormData) => { + String(formData.get("sku")); +}; + +export const Checkout = () => ( +
    + + +
    +); diff --git a/packages/prover/tests/fixtures/proved-form-status-direct/tsconfig.json b/packages/prover/tests/fixtures/proved-form-status-direct/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-form-status-direct/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-form-status-multiple-forms/src/app.tsx b/packages/prover/tests/fixtures/proved-form-status-multiple-forms/src/app.tsx new file mode 100644 index 0000000000..1235fac231 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-form-status-multiple-forms/src/app.tsx @@ -0,0 +1,27 @@ +import * as ReactDOM from "react-dom"; + +const SubmitButton = () => { + const { pending } = ReactDOM.useFormStatus(); + return ( + + ); +}; + +const submitProfile = (formData: FormData) => { + String(formData.get("profile")); +}; + +export const Settings = () => ( + <> +
    + + + +
    + + + + +); diff --git a/packages/prover/tests/fixtures/proved-form-status-multiple-forms/tsconfig.json b/packages/prover/tests/fixtures/proved-form-status-multiple-forms/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-form-status-multiple-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/proved-form-status-transitive/src/app.tsx b/packages/prover/tests/fixtures/proved-form-status-transitive/src/app.tsx new file mode 100644 index 0000000000..d49a5b53c8 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-form-status-transitive/src/app.tsx @@ -0,0 +1,32 @@ +import { useFormStatus as useReactFormStatus } from "react-dom"; + +const useCheckoutFormStatus = () => useReactFormStatus(); + +const CheckoutControls = () => { + const { pending } = useCheckoutFormStatus(); + return ( + + ); +}; + +const CheckoutFields = () => ( +
    + + +
    +); + +const submitCheckout = (formData: FormData) => { + String(formData.get("email")); +}; + +export const Checkout = () => ( +
    + + +); diff --git a/packages/prover/tests/fixtures/proved-form-status-transitive/tsconfig.json b/packages/prover/tests/fixtures/proved-form-status-transitive/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-form-status-transitive/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/react-shim.d.ts b/packages/prover/tests/fixtures/react-shim.d.ts index 5cf0820d2c..43901d73c8 100644 --- a/packages/prover/tests/fixtures/react-shim.d.ts +++ b/packages/prover/tests/fixtures/react-shim.d.ts @@ -1,4 +1,12 @@ declare module "react" { + export type Key = string | number | bigint; + export type ReactNode = unknown; + + export interface ReactPortal { + children: ReactNode; + key: Key | null; + } + export interface ChangeEvent { currentTarget: Target; } diff --git a/packages/prover/tests/fixtures/refuted-form-status-exported-child/src/app.tsx b/packages/prover/tests/fixtures/refuted-form-status-exported-child/src/app.tsx new file mode 100644 index 0000000000..c6f6a1d9d0 --- /dev/null +++ b/packages/prover/tests/fixtures/refuted-form-status-exported-child/src/app.tsx @@ -0,0 +1,14 @@ +import { useFormStatus } from "react-dom"; + +export const SubmitButton = () => { + const { pending } = useFormStatus(); + return ; +}; + +const submitOrder = () => {}; + +export const Checkout = () => ( +
    + + +); diff --git a/packages/prover/tests/fixtures/refuted-form-status-exported-child/tsconfig.json b/packages/prover/tests/fixtures/refuted-form-status-exported-child/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/refuted-form-status-exported-child/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/refuted-form-status-mixed-placement/src/app.tsx b/packages/prover/tests/fixtures/refuted-form-status-mixed-placement/src/app.tsx new file mode 100644 index 0000000000..22f088339e --- /dev/null +++ b/packages/prover/tests/fixtures/refuted-form-status-mixed-placement/src/app.tsx @@ -0,0 +1,17 @@ +import { useFormStatus } from "react-dom"; + +const SubmitButton = () => { + const { pending } = useFormStatus(); + return ; +}; + +const submitOrder = () => {}; + +export const Checkout = () => ( + <> +
    + + + + +); diff --git a/packages/prover/tests/fixtures/refuted-form-status-mixed-placement/tsconfig.json b/packages/prover/tests/fixtures/refuted-form-status-mixed-placement/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/refuted-form-status-mixed-placement/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/refuted-form-status-outside-form/src/app.tsx b/packages/prover/tests/fixtures/refuted-form-status-outside-form/src/app.tsx new file mode 100644 index 0000000000..777d715f4e --- /dev/null +++ b/packages/prover/tests/fixtures/refuted-form-status-outside-form/src/app.tsx @@ -0,0 +1,6 @@ +import { useFormStatus } from "react-dom"; + +export const DetachedSubmit = () => { + const { pending } = useFormStatus(); + return ; +}; diff --git a/packages/prover/tests/fixtures/refuted-form-status-outside-form/tsconfig.json b/packages/prover/tests/fixtures/refuted-form-status-outside-form/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/refuted-form-status-outside-form/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/refuted-form-status-same-component/src/app.tsx b/packages/prover/tests/fixtures/refuted-form-status-same-component/src/app.tsx new file mode 100644 index 0000000000..90c31aef7e --- /dev/null +++ b/packages/prover/tests/fixtures/refuted-form-status-same-component/src/app.tsx @@ -0,0 +1,20 @@ +import { useFormStatus } from "react-dom"; + +const saveProfile = (formData: FormData) => { + String(formData.get("name")); +}; + +export const ProfileForm = () => { + const { pending } = useFormStatus(); + return ( +
    + + +
    + ); +}; diff --git a/packages/prover/tests/fixtures/refuted-form-status-same-component/tsconfig.json b/packages/prover/tests/fixtures/refuted-form-status-same-component/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/refuted-form-status-same-component/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/prove-react-app.test.ts b/packages/prover/tests/prove-react-app.test.ts index ca0e416773..3caad6bfc2 100644 --- a/packages/prover/tests/prove-react-app.test.ts +++ b/packages/prover/tests/prove-react-app.test.ts @@ -25,6 +25,7 @@ import { ReactEffectResourceKind, ReactExecutionPhase, ReactFormActionStatus, + ReactFormStatusTopologyStatus, ReactHookStateUpdaterStatus, ReactIdentityStability, ReactObligationStatus, @@ -75,6 +76,26 @@ const REFUTED_FIXTURES: ReadonlyArray = [ claim: ReactProofClaim.FormActions, evidencePattern: /cannot invoke/, }, + { + fixtureName: "refuted-form-status-outside-form", + claim: ReactProofClaim.FormStatus, + evidencePattern: /without a parent
    /, + }, + { + fixtureName: "refuted-form-status-same-component", + claim: ReactProofClaim.FormStatus, + evidencePattern: /without a parent /, + }, + { + fixtureName: "refuted-form-status-mixed-placement", + claim: ReactProofClaim.FormStatus, + evidencePattern: /without a parent /, + }, + { + fixtureName: "refuted-form-status-exported-child", + claim: ReactProofClaim.FormStatus, + evidencePattern: /without a parent /, + }, { fixtureName: "refuted-optimistic-outside-action", claim: ReactProofClaim.OptimisticState, @@ -637,8 +658,8 @@ describe("proveReactApp", () => { const report = proveFixture("proved-chat"); const effect = report.graph.effects[0]; - expect(report.schemaVersion).toBe(20); - expect(report.graph.schemaVersion).toBe(26); + expect(report.schemaVersion).toBe(21); + expect(report.graph.schemaVersion).toBe(27); expect(effect?.hookName).toBe("useEffect"); expect(effect?.callbackResolved).toBe(true); expect(effect?.dependencyMode).toBe(ReactEffectDependencyMode.Inline); @@ -3316,6 +3337,108 @@ describe("proveReactApp", () => { ).toBe(true); }); + it("certifies a direct Form Status consumer below its parent form", () => { + const report = proveFixture("proved-form-status-direct"); + const formStatus = report.graph.formStatuses[0]; + const formStatusProof = report.units + .flatMap((unit) => unit.obligations) + .find( + (obligation) => + obligation.claim === ReactProofClaim.FormStatus && + obligation.status === ReactObligationStatus.Proved, + ); + + expect(report.status).toBe(ReactAppProofStatus.Proved); + expect(report.graph.forms).toHaveLength(1); + expect(formStatus?.sourceFormIds).toEqual([report.graph.forms[0]?.id]); + expect(formStatus?.outsideForm).toBe(false); + expect(formStatus?.status).toBe(ReactFormStatusTopologyStatus.Resolved); + expect(formStatus?.sourceComplete).toBe(true); + expect(formStatus?.complete).toBe(true); + expect(formStatusProof).toBeDefined(); + expect(checkReactProofReport(report).status).toBe(ReactProofCertificateStatus.Valid); + }); + + it("propagates Form Status ancestry through a component and custom Hook", () => { + const report = proveFixture("proved-form-status-transitive"); + const formStatus = report.graph.formStatuses[0]; + const owner = report.graph.units.find((unit) => unit.id === formStatus?.ownerId); + + expect(report.status).toBe(ReactAppProofStatus.Proved); + expect(owner?.name).toBe("useCheckoutFormStatus"); + expect(formStatus?.sourceFormIds).toEqual([report.graph.forms[0]?.id]); + expect(formStatus?.status).toBe(ReactFormStatusTopologyStatus.Resolved); + expect(formStatus?.complete).toBe(true); + }); + + it("certifies every closed parent form for a shared Form Status consumer", () => { + const report = proveFixture("proved-form-status-multiple-forms"); + const formStatus = report.graph.formStatuses[0]; + + expect(report.status).toBe(ReactAppProofStatus.Proved); + expect(report.graph.forms).toHaveLength(2); + expect(new Set(formStatus?.sourceFormIds)).toEqual( + new Set(report.graph.forms.map((form) => form.id)), + ); + expect(formStatus?.outsideForm).toBe(false); + expect(formStatus?.complete).toBe(true); + }); + + it("fails closed when a component wrapper owns the possible parent form", () => { + const report = proveFixture("incomplete-form-status-composed-form"); + const formStatus = report.graph.formStatuses[0]; + const formStatusProof = report.units + .flatMap((unit) => unit.obligations) + .find( + (obligation) => + obligation.claim === ReactProofClaim.FormStatus && + obligation.status === ReactObligationStatus.Unknown, + ); + + expect(report.status).toBe(ReactAppProofStatus.Incomplete); + expect(formStatus?.sourceFormIds).toEqual([]); + expect(formStatus?.outsideForm).toBe(false); + expect(formStatus?.status).toBe(ReactFormStatusTopologyStatus.Unknown); + expect(formStatus?.sourceComplete).toBe(false); + expect(formStatus?.complete).toBe(false); + expect(formStatusProof?.evidence[0]?.description).toMatch(/cannot be resolved/); + expect(checkReactProofReport(report).status).toBe(ReactProofCertificateStatus.Valid); + }); + + it("fails closed when a synchronous render callback has unmodeled form ancestry", () => { + const report = proveFixture("incomplete-form-status-render-callback"); + const formStatus = report.graph.formStatuses[0]; + + expect(report.status).toBe(ReactAppProofStatus.Incomplete); + expect(formStatus?.sourceFormIds).toEqual([]); + expect(formStatus?.outsideForm).toBe(false); + expect(formStatus?.status).toBe(ReactFormStatusTopologyStatus.Unknown); + expect(formStatus?.complete).toBe(false); + expect(checkReactProofReport(report).status).toBe(ReactProofCertificateStatus.Valid); + }); + + it("rejects a forged Form Status topology certificate", () => { + const report = proveFixture("refuted-form-status-outside-form"); + const certificate = checkReactProofReport({ + ...report, + graph: { + ...report.graph, + formStatuses: report.graph.formStatuses.map((formStatus) => ({ + ...formStatus, + outsideForm: false, + status: ReactFormStatusTopologyStatus.Resolved, + sourceComplete: true, + complete: true, + })), + }, + }); + + expect(certificate.status).toBe(ReactProofCertificateStatus.Invalid); + expect( + certificate.failures.some((failure) => failure.description.includes("Form Status")), + ).toBe(true); + }); + it("certifies a Form Action, pure optimistic reducer, and optimistic update together", () => { const report = proveFixture("proved-optimistic-form"); const formAction = report.graph.formActions[0]; diff --git a/packages/prover/tests/runtime/constants.ts b/packages/prover/tests/runtime/constants.ts index 9cd2b70780..15f9a5e58e 100644 --- a/packages/prover/tests/runtime/constants.ts +++ b/packages/prover/tests/runtime/constants.ts @@ -2,6 +2,9 @@ export const ACTION_STATE_DELAY_MS = 120; export const ACTION_STATE_EXPECTED_RUNS = 2; export const ACTION_STATE_INITIAL_RUNS = 0; export const FAST_QUERY_DELAY_MS = 20; +export const FORM_STATUS_ACTION_DELAY_MS = 160; +export const FORM_STATUS_ACTION_EXPECTED_RUNS = 1; +export const FORM_STATUS_ACTION_INITIAL_RUNS = 0; export const HOOK_STATE_INCREMENT = 1; export const HOOK_STATE_INITIAL_COUNT = 0; export const HOOK_STATE_UPDATER_INITIAL_RUNS = 0; diff --git a/packages/prover/tests/runtime/form-status-oracle.spec.ts b/packages/prover/tests/runtime/form-status-oracle.spec.ts new file mode 100644 index 0000000000..a2e8554082 --- /dev/null +++ b/packages/prover/tests/runtime/form-status-oracle.spec.ts @@ -0,0 +1,19 @@ +import { expect, test } from "@playwright/test"; +import { FORM_STATUS_ACTION_EXPECTED_RUNS } from "./constants.js"; + +test("Form Status observes only a parent form during a Strict Mode Action", async ({ page }) => { + await page.goto("/?oracle=form-status"); + + await page.getByRole("textbox", { name: "Username" }).fill("alice"); + await page.getByRole("button", { name: "request username" }).click(); + + await expect(page.getByTestId("form-status-pending")).toHaveText("true"); + await expect(page.getByTestId("same-component-form-status")).toHaveText("false"); + await expect(page.getByTestId("form-status-data")).toHaveText("alice"); + await expect(page.getByTestId("form-status-method")).toHaveText("get"); + await expect(page.getByTestId("form-status-action")).toHaveText("true"); + await expect + .poll(() => page.evaluate(() => window.formStatusActionRuns)) + .toBe(FORM_STATUS_ACTION_EXPECTED_RUNS); + await expect(page.getByTestId("form-status-pending")).toHaveText("false"); +}); diff --git a/packages/prover/tests/runtime/main.tsx b/packages/prover/tests/runtime/main.tsx index 15aea08584..7ca5302613 100644 --- a/packages/prover/tests/runtime/main.tsx +++ b/packages/prover/tests/runtime/main.tsx @@ -16,11 +16,14 @@ import { useTransition, } from "react"; import type { ChangeEvent } from "react"; +import { useFormStatus } from "react-dom"; import { createRoot } from "react-dom/client"; import { ACTION_STATE_DELAY_MS, ACTION_STATE_INITIAL_RUNS, FAST_QUERY_DELAY_MS, + FORM_STATUS_ACTION_DELAY_MS, + FORM_STATUS_ACTION_INITIAL_RUNS, HOOK_STATE_INCREMENT, HOOK_STATE_INITIAL_COUNT, HOOK_STATE_UPDATER_INITIAL_RUNS, @@ -45,6 +48,7 @@ import { declare global { interface Window { effectEventSetupRuns: number; + formStatusActionRuns: number; actionStateRuns: number; classListenerHits: number; classMounts: number; @@ -65,6 +69,7 @@ declare global { } window.effectEventSetupRuns = 0; +window.formStatusActionRuns = FORM_STATUS_ACTION_INITIAL_RUNS; window.actionStateRuns = ACTION_STATE_INITIAL_RUNS; window.classListenerHits = 0; window.classMounts = 0; @@ -87,6 +92,49 @@ interface OptimisticTodo { isPending: boolean; } +const submitFormStatus = async (formData: FormData) => { + window.formStatusActionRuns += 1; + String(formData.get("username")); + await new Promise((resolve) => { + setTimeout(resolve, FORM_STATUS_ACTION_DELAY_MS); + }); +}; + +const FormStatusDetails = () => { + const status = useFormStatus(); + return ( + <> + + {String(status.pending)} + + {status.data ? String(status.data.get("username")) : "none"} + + {status.method} + {String(status.action === submitFormStatus)} + + ); +}; + +const FormStatusOracle = () => { + const sameComponentStatus = useFormStatus(); + return ( +
    + + + + + + {String(sameComponentStatus.pending)} + +
    + ); +}; + const ActionStateOracle = () => { const [submittedItems, submitItem, isPending] = useActionState( async (previousItems: ReadonlyArray, formData: FormData) => { @@ -1025,6 +1073,9 @@ const RuntimeOracle = () => { if (oracle === "action-state") { return ; } + if (oracle === "form-status") { + return ; + } return ; }; @@ -1040,7 +1091,8 @@ const isStrictModeOracle = oracle === "hook-state-transition" || oracle === "transition-action" || oracle === "optimistic-form-action" || - oracle === "action-state"; + oracle === "action-state" || + oracle === "form-status"; createRoot(rootElement).render( isStrictModeOracle ? ( From d4b24ea83af7186ab864854fc89b3ce3a72535c5 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Tue, 28 Jul 2026 23:04:45 +0000 Subject: [PATCH 15/23] feat(prover): certify ReactNode slot flow --- packages/prover/README.md | 19 +- packages/prover/research-log.md | 121 +++++- .../prover/src/analyze-react-node-flow.ts | 49 +++ packages/prover/src/analyze-react-unit.ts | 3 + packages/prover/src/analyze-render-purity.ts | 4 + .../prover/src/build-react-semantic-graph.ts | 407 ++++++++++++++++-- .../prover/src/check-react-proof-report.ts | 295 ++++++++++++- packages/prover/src/constants.ts | 16 +- .../src/create-component-callback-flow.ts | 17 +- .../prover/src/create-component-slot-flow.ts | 333 ++++++++++++++ .../prover/src/get-component-prop-name.ts | 16 +- packages/prover/src/index.ts | 2 + packages/prover/src/prove-react-app.ts | 1 + packages/prover/src/types.ts | 27 ++ .../get-jsx-component-target-function.ts | 15 + .../get-jsx-opening-element-for-attribute.ts | 10 + .../src/app.tsx | 22 + .../tsconfig.json | 0 .../src/app.tsx | 22 + .../tsconfig.json | 4 + .../src/app.tsx | 23 + .../tsconfig.json | 4 + .../src/app.tsx | 21 + .../tsconfig.json | 4 + .../src/app.tsx | 13 + .../src/external-shell.d.ts | 5 + .../tsconfig.json | 4 + .../src/app.tsx | 12 + .../tsconfig.json | 4 + .../src/app.tsx | 21 + .../tsconfig.json | 4 + .../src/app.tsx | 18 + .../tsconfig.json | 4 + .../src/app.tsx | 15 + .../tsconfig.json | 4 + .../proved-context-provider-slot/src/app.tsx | 23 + .../tsconfig.json | 4 + .../src/app.tsx | 27 ++ .../tsconfig.json | 4 + .../src/app.tsx | 3 +- .../tsconfig.json | 4 + .../src/app.tsx | 19 + .../tsconfig.json | 4 + .../proved-form-status-named-slot/src/app.tsx | 15 + .../tsconfig.json | 4 + .../src/app.tsx | 22 + .../tsconfig.json | 4 + .../src/app.tsx | 21 + .../tsconfig.json | 4 + .../src/app.tsx | 25 ++ .../tsconfig.json | 4 + .../prover/tests/fixtures/react-shim.d.ts | 7 + .../src/app.tsx | 24 ++ .../tsconfig.json | 4 + packages/prover/tests/prove-react-app.test.ts | 177 +++++++- .../tests/runtime/form-status-oracle.spec.ts | 15 + packages/prover/tests/runtime/main.tsx | 30 +- 57 files changed, 1887 insertions(+), 96 deletions(-) create mode 100644 packages/prover/src/analyze-react-node-flow.ts create mode 100644 packages/prover/src/create-component-slot-flow.ts create mode 100644 packages/prover/src/utils/get-jsx-component-target-function.ts create mode 100644 packages/prover/src/utils/get-jsx-opening-element-for-attribute.ts create mode 100644 packages/prover/tests/fixtures/incomplete-react-node-alias-slot/src/app.tsx rename packages/prover/tests/fixtures/{incomplete-form-status-composed-form => incomplete-react-node-alias-slot}/tsconfig.json (100%) create mode 100644 packages/prover/tests/fixtures/incomplete-react-node-children-map/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-react-node-children-map/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-react-node-computed-slot/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-react-node-computed-slot/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-react-node-dropped-slot/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-react-node-dropped-slot/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-react-node-external-slot/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-react-node-external-slot/src/external-shell.d.ts create mode 100644 packages/prover/tests/fixtures/incomplete-react-node-external-slot/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-react-node-non-rendered-prop/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-react-node-non-rendered-prop/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-react-node-props-spread/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-react-node-props-spread/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-react-node-source-alias/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-react-node-source-alias/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-react-node-spread-slot/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-react-node-spread-slot/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-context-provider-slot/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-context-provider-slot/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-context-provider-transitive-slot/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-context-provider-transitive-slot/tsconfig.json rename packages/prover/tests/fixtures/{incomplete-form-status-composed-form => proved-form-status-composed-form}/src/app.tsx (85%) create mode 100644 packages/prover/tests/fixtures/proved-form-status-composed-form/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-form-status-computed-slot/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-form-status-computed-slot/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-form-status-named-slot/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-form-status-named-slot/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-form-status-portal-slot/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-form-status-portal-slot/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-form-status-source-form-slot/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-form-status-source-form-slot/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-form-status-transitive-slot/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-form-status-transitive-slot/tsconfig.json create mode 100644 packages/prover/tests/fixtures/refuted-form-status-mixed-slot-placement/src/app.tsx create mode 100644 packages/prover/tests/fixtures/refuted-form-status-mixed-slot-placement/tsconfig.json diff --git a/packages/prover/README.md b/packages/prover/README.md index 6daad424fa..09a0e54111 100644 --- a/packages/prover/README.md +++ b/packages/prover/README.md @@ -85,10 +85,14 @@ The report includes: 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 and custom-Hook paths; a detached, - same-component, exported consumer, or mixed outside-form path is refuted, while - component-composed `children` placement and JSX returned from unmodeled render callbacks remain - unknown; + 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, @@ -139,8 +143,11 @@ Action State dispatchers link the form fact to their reducer-Action callback. Ac 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, require one fact for every canonical Hook call, validate active-form ownership, and reject -forged outside-form, source, topology-status, and completeness fields. +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. 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 diff --git a/packages/prover/research-log.md b/packages/prover/research-log.md index 6d21e24112..a0fcd3bbef 100644 --- a/packages/prover/research-log.md +++ b/packages/prover/research-log.md @@ -755,7 +755,7 @@ components and hooks, including hooks hidden in incorrectly named helper functio ### Test stack -Current checkpoint: 306 TypeScript fixture projects, 509 static tests, and 39 Chromium runtime +Current checkpoint: 324 TypeScript fixture projects, 527 static tests, and 40 Chromium runtime oracles. - Vite Plus supplies package build and Vitest-compatible static tests. @@ -1641,3 +1641,122 @@ 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. 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 0000000000..8f9da5b84c --- /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 index 50a5e4d362..cf67d599a5 100644 --- a/packages/prover/src/analyze-react-unit.ts +++ b/packages/prover/src/analyze-react-unit.ts @@ -20,6 +20,7 @@ import { analyzeHookStateTransitions } from "./analyze-hook-state-transitions.js import { analyzeMemoDependencies } from "./analyze-memo-dependencies.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 { analyzeReconciliationIdentity } from "./analyze-reconciliation-identity.js"; import { analyzeRenderPurity } from "./analyze-render-purity.js"; @@ -53,6 +54,7 @@ const ALL_REACT_PROOF_CLAIMS: ReadonlyArray = [ ReactProofClaim.HookStateTransitions, ReactProofClaim.MemoDependencies, ReactProofClaim.OptimisticState, + ReactProofClaim.ReactNodeFlow, ReactProofClaim.ReconciliationIdentity, ReactProofClaim.ReducerPurity, ReactProofClaim.RefAccess, @@ -153,6 +155,7 @@ export const analyzeReactUnit = ( analyzeHookStateTransitions(unit, context), analyzeMemoDependencies(unit.functionNode, context), analyzeOptimisticState(unit, context), + analyzeReactNodeFlow(unit, context), analyzeReconciliationIdentity(unit.functionNode, context), analyzeReducerPurity(unit.functionNode, context), analyzeRefAccess(unit.functionNode, context), diff --git a/packages/prover/src/analyze-render-purity.ts b/packages/prover/src/analyze-render-purity.ts index 633abe09c3..c740514b09 100644 --- a/packages/prover/src/analyze-render-purity.ts +++ b/packages/prover/src/analyze-render-purity.ts @@ -7,6 +7,7 @@ import { 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"; @@ -15,6 +16,7 @@ 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"; @@ -178,6 +180,7 @@ export const analyzeRenderPurity = ( 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( @@ -248,6 +251,7 @@ export const analyzeRenderPurity = ( 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)) || diff --git a/packages/prover/src/build-react-semantic-graph.ts b/packages/prover/src/build-react-semantic-graph.ts index 3c14908d6c..19c7e5a2ee 100644 --- a/packages/prover/src/build-react-semantic-graph.ts +++ b/packages/prover/src/build-react-semantic-graph.ts @@ -12,6 +12,8 @@ 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 { collectEffectCleanupFunctions } from "./collect-effect-cleanup-functions.js"; @@ -38,9 +40,11 @@ import { REACT_MEMO_HOOK_NAMES, REACT_REDUCER_HOOK_NAMES, REACT_CONTEXT_DEFAULT_SOURCE_ID, + REACT_CONTEXT_UNKNOWN_SOURCE_ID, REACT_FORM_OUTSIDE_SOURCE_ID, REACT_FORM_UNKNOWN_SOURCE_ID, REACT_SEMANTIC_GRAPH_SCHEMA_VERSION, + REACT_TRANSPARENT_COMPONENT_NAMES, } from "./constants.js"; import { getCanonicalReactApiName } from "./get-canonical-react-api-name.js"; import { getCanonicalHookName } from "./get-canonical-hook-name.js"; @@ -73,6 +77,7 @@ import { ReactOptimisticReducerStatus, ReactSemanticCallbackKind, ReactSemanticEdgeKind, + ReactSemanticRenderKind, ReactTransitionActionStatus, ReactUnitKind, } from "./types.js"; @@ -109,6 +114,7 @@ import type { ReactSemanticTransitionAction, ReactSemanticReachableFunction, ReactSemanticRender, + ReactSemanticSlotFlow, ReactSemanticEffectResource, ReactSemanticScheduler, ReactSemanticUnit, @@ -118,6 +124,7 @@ 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 { getJsxOpeningElementForAttribute } from "./utils/get-jsx-opening-element-for-attribute.js"; import { isDeferredCallbackSynchronous } from "./utils/is-deferred-callback-synchronous.js"; import { getResolvedSymbol } from "./utils/get-resolved-symbol.js"; import { isIntrinsicJsxElement } from "./utils/is-intrinsic-jsx-element.js"; @@ -325,6 +332,18 @@ interface RenderGraphFacts { renders: ReadonlyArray; } +interface RenderSlotBoundary { + complete: boolean; + containerRenderId: string | null; + node: ts.Node; + propName: string | null; +} + +interface SlotGraphFacts { + renders: ReadonlyArray; + slotFlows: ReadonlyArray; +} + const createSemanticId = ( kind: string, name: string, @@ -546,10 +565,11 @@ const collectContextGraph = ( 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) { + while (currentNode && currentNode !== stopNode && !isFunctionBoundary(currentNode)) { if (ts.isJsxElement(currentNode)) { const provider = providersByOpeningNode.get(currentNode.openingElement); if (provider) providerIds.unshift(provider.id); @@ -606,32 +626,21 @@ const collectFormTopologyGraph = ( return { forms, formStatuses, formsByOpeningNode }; }; -const collectActiveFormTopology = ( - tagName: ts.JsxTagNameExpression, +const collectActiveFormIds = ( + node: ts.Node, formsByOpeningNode: ReadonlyMap, -): { activeFormIds: ReadonlyArray; complete: boolean } => { + stopNode: ts.Node | null = null, +): ReadonlyArray => { const activeFormIds: string[] = []; - let foundNearestForm = false; - let complete = true; - const openingElement = tagName.parent; - let currentNode: ts.Node | undefined = - ts.isJsxOpeningElement(openingElement) && ts.isJsxElement(openingElement.parent) - ? openingElement.parent.parent - : openingElement.parent; - while (currentNode && !isFunctionBoundary(currentNode)) { + let currentNode: ts.Node | undefined = node.parent; + while (currentNode && currentNode !== stopNode && !isFunctionBoundary(currentNode)) { if (ts.isJsxElement(currentNode)) { - const openingElement = currentNode.openingElement; - const form = formsByOpeningNode.get(openingElement); - if (form) { - activeFormIds.unshift(form.id); - foundNearestForm = true; - } else if (!foundNearestForm && !isIntrinsicJsxElement(openingElement)) { - complete = false; - } + const form = formsByOpeningNode.get(currentNode.openingElement); + if (form) activeFormIds.unshift(form.id); } currentNode = currentNode.parent; } - return { activeFormIds, complete }; + return activeFormIds; }; const collectUnitIdentitiesBySymbol = ( @@ -658,6 +667,124 @@ const resolveUnitTarget = ( return symbol ? (unitIdsBySymbol.get(symbol) ?? null) : null; }; +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, @@ -673,16 +800,22 @@ const collectRenderEdges = ( if (node !== functionNode && isFunctionBoundary(node)) { return; } - const tagName = ts.isJsxOpeningElement(node) - ? node.tagName - : ts.isJsxSelfClosingElement(node) - ? node.tagName - : null; - if (tagName && ts.isIdentifier(tagName) && /^[A-Z]/.test(tagName.text)) { + 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 formTopology = collectActiveFormTopology(tagName, formsByOpeningNode); + const slotBoundary = getContainingRenderSlotBoundary( + tagName, + unitIdsBySymbol, + providersByOpeningNode, + context, + ); + const topologyKind = slotBoundary + ? ReactSemanticRenderKind.SlotInput + : ReactSemanticRenderKind.Direct; edges.push({ kind: ReactSemanticEdgeKind.RendersComponent, sourceId: identity.semanticUnit.id, @@ -694,12 +827,23 @@ const collectRenderEdges = ( 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, ), - activeFormIds: formTopology.activeFormIds, - formTopologyComplete: formTopology.complete, + contextTopologyComplete: slotBoundary?.complete ?? true, + activeFormIds: collectActiveFormIds( + tagName, + formsByOpeningNode, + slotBoundary?.node ?? null, + ), + formTopologyComplete: slotBoundary?.complete ?? true, }); } } @@ -709,6 +853,121 @@ const collectRenderEdges = ( return { edges, renders }; }; +const collectSlotGraph = ( + renders: ReadonlyArray, + identitiesByFunction: ReadonlyMap, + slotFlow: ComponentSlotFlowDescriptor, + providersByOpeningNode: ReadonlyMap, + formsByOpeningNode: ReadonlyMap, + 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[] = []; + 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); + } + 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 { renders: [...resolvedRenders, ...slotRenders], slotFlows }; +}; + const collectHookGraph = ( identity: UnitGraphIdentity, unitIdsBySymbol: ReadonlyMap, @@ -3022,6 +3281,7 @@ const resolveContextConsumers = ( units: ReadonlyArray, edges: ReadonlyArray, renders: ReadonlyArray, + slotFlows: ReadonlyArray, contexts: ReadonlyArray, providers: ReadonlyArray, consumers: ReadonlyArray, @@ -3030,12 +3290,9 @@ const resolveContextConsumers = ( const customHookEdges = edges.filter( (edge) => edge.kind === ReactSemanticEdgeKind.CallsHook && localUnitIds.has(edge.targetId), ); - const incomingUnitIds = new Set([ - ...renders.map((render) => render.targetId), - ...customHookEdges.map((edge) => edge.targetId), - ]); - const rootUnitIds = units.map((unit) => unit.id).filter((unitId) => !incomingUnitIds.has(unitId)); + 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) { @@ -3048,6 +3305,7 @@ const resolveContextConsumers = ( 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, @@ -3066,6 +3324,29 @@ const resolveContextConsumers = ( 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) { @@ -3086,10 +3367,13 @@ const resolveContextConsumers = ( return { ...consumer, sourceProviderIds: sourceIds.filter( - (sourceId) => sourceId !== REACT_CONTEXT_DEFAULT_SOURCE_ID, + (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, + topologyComplete: + sourceIds.length > 0 && !sourceIds.includes(REACT_CONTEXT_UNKNOWN_SOURCE_ID), }; }); }; @@ -3113,13 +3397,15 @@ 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.filter((unit) => unit.canBeRenderRoot).map((unit) => unit.id); + 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); @@ -3129,6 +3415,7 @@ const resolveFormStatuses = ( while (didSourcesChange) { didSourcesChange = false; for (const render of renders) { + if (render.kind === ReactSemanticRenderKind.SlotInput) continue; const nearestFormId = render.activeFormIds.at(-1); if (nearestFormId) { didSourcesChange = @@ -3149,6 +3436,14 @@ const resolveFormStatuses = ( 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) { @@ -3215,6 +3510,12 @@ export const buildReactSemanticGraph = ( 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 edges: ReactSemanticEdge[] = []; @@ -3242,12 +3543,13 @@ export const buildReactSemanticGraph = ( const functionCalls: ReactSemanticFunctionCall[] = []; const componentFlow = createComponentCallbackFlow( [...unitIdentitiesByFunction.keys()], - new Map( - [...unitIdentitiesBySymbol].flatMap( - ([symbol, identity]): ReadonlyArray<[ts.Symbol, ts.FunctionLikeDeclaration]> => - identity.descriptor.functionNode ? [[symbol, identity.descriptor.functionNode]] : [], - ), - ), + unitFunctionsBySymbol, + context.typeChecker, + ); + const slotFlow = createComponentSlotFlow( + [...unitIdentitiesByFunction.keys()], + unitFunctionsBySymbol, + new Set(contextGraph.providersByOpeningNode.keys()), context.typeChecker, ); const eventGraph = collectEventGraph(identities, context, componentFlow); @@ -3421,10 +3723,19 @@ export const buildReactSemanticGraph = ( reachableFunctions.push(...optimisticStateGraph.reachableFunctions); functionCalls.push(...optimisticStateGraph.functionCalls); } + const slotGraph = collectSlotGraph( + renders, + unitIdentitiesByFunction, + slotFlow, + contextGraph.providersByOpeningNode, + formTopologyGraph.formsByOpeningNode, + context, + ); const contextConsumers = resolveContextConsumers( identities.map((identity) => identity.semanticUnit), edges, - renders, + slotGraph.renders, + slotGraph.slotFlows, contextGraph.contexts, contextGraph.contextProviders, contextGraph.contextConsumers, @@ -3432,7 +3743,8 @@ export const buildReactSemanticGraph = ( const formStatuses = resolveFormStatuses( identities.map((identity) => identity.semanticUnit), edges, - renders, + slotGraph.renders, + slotGraph.slotFlows, formTopologyGraph.formStatuses, ); const callableRefs = collectCallableRefGraph(identities, callbacks, functionCalls, context); @@ -3450,7 +3762,8 @@ export const buildReactSemanticGraph = ( contexts: contextGraph.contexts, contextProviders: contextGraph.contextProviders, contextConsumers, - renders, + renders: slotGraph.renders, + slotFlows: slotGraph.slotFlows, callbacks, reachableFunctions, functionCalls, diff --git a/packages/prover/src/check-react-proof-report.ts b/packages/prover/src/check-react-proof-report.ts index f18553383c..6c7563199f 100644 --- a/packages/prover/src/check-react-proof-report.ts +++ b/packages/prover/src/check-react-proof-report.ts @@ -1,4 +1,6 @@ import { + REACT_CONTEXT_DEFAULT_SOURCE_ID, + REACT_CONTEXT_UNKNOWN_SOURCE_ID, REACT_FORM_OUTSIDE_SOURCE_ID, REACT_FORM_UNKNOWN_SOURCE_ID, REACT_PROOF_SCHEMA_VERSION, @@ -37,6 +39,7 @@ import { ReactSemanticCallbackKind, ReactSemanticEdgeKind, ReactSemanticFunctionCallKind, + ReactSemanticRenderKind, ReactTransitionActionStatus, ReactTransitionStarterKind, ReactUnitKind, @@ -315,6 +318,20 @@ const expectedOptimisticStateStatus = ( : 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, @@ -507,6 +524,17 @@ const checkClaimCoverage = ( `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, ); @@ -532,6 +560,106 @@ const checkClaimCoverage = ( } }; +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> => { @@ -539,6 +667,7 @@ const deriveFormSourcesByUnit = ( 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); @@ -560,6 +689,7 @@ const deriveFormSourcesByUnit = ( 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; @@ -576,6 +706,14 @@ const deriveFormSourcesByUnit = ( 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; @@ -607,7 +745,11 @@ const checkGraphReferences = ( ); 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); for (const unit of report.graph.units) { if ( @@ -644,15 +786,134 @@ const checkGraphReferences = ( 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 (form.ownerId !== render.ownerId) { - addFailure(failures, render.id, "A render has an active form owned by another unit"); + } 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"); @@ -2309,8 +2570,29 @@ const checkGraphReferences = ( addFailure(failures, consumer.id, "A consumer has an unknown source provider"); } } - const hasResolvedSource = consumer.sourceProviderIds.length > 0 || consumer.usesDefaultValue; - if (consumer.topologyComplete !== Boolean(consumer.contextId && hasResolvedSource)) { + 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, @@ -2566,6 +2848,11 @@ export const checkReactProofReport = (report: ReactAppProofReport): ReactProofCe "renders", report.graph.renders.map((render) => render.id), ); + checkUniqueIds( + failures, + "slot flows", + report.graph.slotFlows.map((slotFlow) => slotFlow.id), + ); checkUniqueIds( failures, "callback prop flows", diff --git a/packages/prover/src/constants.ts b/packages/prover/src/constants.ts index 606dcc45c6..508ad9efe0 100644 --- a/packages/prover/src/constants.ts +++ b/packages/prover/src/constants.ts @@ -1,7 +1,7 @@ import { ReactEffectResourceKind } from "./types.js"; -export const REACT_PROOF_SCHEMA_VERSION = 21; -export const REACT_SEMANTIC_GRAPH_SCHEMA_VERSION = 27; +export const REACT_PROOF_SCHEMA_VERSION = 22; +export const REACT_SEMANTIC_GRAPH_SCHEMA_VERSION = 28; 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; @@ -9,6 +9,7 @@ export const FIRST_SOURCE_COLUMN = 1; 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_ACTION_STATE_DISPATCHER_INDEX = 1; @@ -75,6 +76,8 @@ export const REACT_UNMODELED_HOOK_NAMES = new Set([ "useTransition", ]); +export const REACT_PURE_RENDER_API_NAMES = new Set(["createPortal"]); + export const KNOWN_IMPURE_RENDER_CALLS = new Set([ "crypto.randomUUID", "Date.now", @@ -165,3 +168,12 @@ export const REACT_RUNTIME_MODULE_NAMES = new Set([ ]); 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/create-component-callback-flow.ts b/packages/prover/src/create-component-callback-flow.ts index 4d2b5d9605..89890d4c7c 100644 --- a/packages/prover/src/create-component-callback-flow.ts +++ b/packages/prover/src/create-component-callback-flow.ts @@ -10,6 +10,7 @@ import { mergeCallableBindings, resolveCallableExpression } from "./resolve-call 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"; @@ -147,20 +148,6 @@ const getOpeningElement = (node: ts.Node): ts.JsxOpeningLikeElement | null => { return null; }; -const getTargetFunction = ( - 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; -}; - const deduplicateCallbacks = ( callbacks: ReadonlyArray, ): ReadonlyArray => { @@ -336,7 +323,7 @@ export const createComponentCallbackFlow = ( node.forEachChild(visit); return; } - const targetFunction = getTargetFunction( + const targetFunction = getJsxComponentTargetFunction( openingElement, unitFunctionsBySymbol, typeChecker, 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 0000000000..d3f01cdf3e --- /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/get-component-prop-name.ts b/packages/prover/src/get-component-prop-name.ts index 3a4d1529e3..81d68f4d84 100644 --- a/packages/prover/src/get-component-prop-name.ts +++ b/packages/prover/src/get-component-prop-name.ts @@ -28,19 +28,25 @@ const getDestructuredPropName = ( }; const getObjectParameterPropName = ( - expression: ts.PropertyAccessExpression, + 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; - return functionNode.parameters.some( + const isObjectParameter = functionNode.parameters.some( (parameter) => ts.isIdentifier(parameter.name) && typeChecker.getSymbolAtLocation(parameter.name) === expressionSymbol, - ) - ? expression.name.text + ); + 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; }; @@ -52,7 +58,7 @@ export const getComponentPropName = ( if (ts.isIdentifier(expression)) { return getDestructuredPropName(expression, functionNode, typeChecker); } - if (ts.isPropertyAccessExpression(expression)) { + if (ts.isPropertyAccessExpression(expression) || ts.isElementAccessExpression(expression)) { return getObjectParameterPropName(expression, functionNode, typeChecker); } return null; diff --git a/packages/prover/src/index.ts b/packages/prover/src/index.ts index 11e195bab4..7920e32f1f 100644 --- a/packages/prover/src/index.ts +++ b/packages/prover/src/index.ts @@ -34,6 +34,7 @@ export { ReactProofClaim, ReactSchedulerCancellationStatus, ReactSchedulerKind, + ReactSemanticRenderKind, ReactSemanticEdgeKind, ReactSemanticCallbackKind, ReactSemanticFunctionCallKind, @@ -89,6 +90,7 @@ export type { ReactSemanticTransitionAction, ReactSemanticReachableFunction, ReactSemanticRender, + ReactSemanticSlotFlow, ReactSemanticScheduler, ReactSemanticUnit, ReactAsyncEffectTaskDescriptor, diff --git a/packages/prover/src/prove-react-app.ts b/packages/prover/src/prove-react-app.ts index 18edeec6df..f1673afecf 100644 --- a/packages/prover/src/prove-react-app.ts +++ b/packages/prover/src/prove-react-app.ts @@ -37,6 +37,7 @@ export const proveReactApp = (input: ProveReactAppInput): ReactAppProofReport => contextProviders: [], contextConsumers: [], renders: [], + slotFlows: [], callbacks: [], reachableFunctions: [], functionCalls: [], diff --git a/packages/prover/src/types.ts b/packages/prover/src/types.ts index 8da72039e9..515bb5e37a 100644 --- a/packages/prover/src/types.ts +++ b/packages/prover/src/types.ts @@ -34,6 +34,7 @@ export enum ReactProofClaim { HookStateTransitions = "hook-state-transitions", MemoDependencies = "memo-dependencies", OptimisticState = "optimistic-state", + ReactNodeFlow = "react-node-flow", ReconciliationIdentity = "reconciliation-identity", ReducerPurity = "reducer-purity", RefAccess = "ref-access", @@ -325,11 +326,36 @@ export interface ReactSemanticRender { 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 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; @@ -810,6 +836,7 @@ export interface ReactSemanticGraph { contextProviders: ReadonlyArray; contextConsumers: ReadonlyArray; renders: ReadonlyArray; + slotFlows: ReadonlyArray; callbacks: ReadonlyArray; reachableFunctions: ReadonlyArray; functionCalls: ReadonlyArray; 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 0000000000..df86c4b5d8 --- /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 0000000000..4b227296ff --- /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/tests/fixtures/incomplete-react-node-alias-slot/src/app.tsx b/packages/prover/tests/fixtures/incomplete-react-node-alias-slot/src/app.tsx new file mode 100644 index 0000000000..ca55f601c6 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-react-node-alias-slot/src/app.tsx @@ -0,0 +1,22 @@ +import { useFormStatus } from "react-dom"; +import type { ReactNode } from "react"; + +interface FormShellProperties { + children: ReactNode; +} + +const FormShell = ({ children }: FormShellProperties) => { + const content = children; + return
    {content}
    ; +}; + +const SubmitButton = () => { + const { pending } = useFormStatus(); + return ; +}; + +export const Checkout = () => ( + + + +); diff --git a/packages/prover/tests/fixtures/incomplete-form-status-composed-form/tsconfig.json b/packages/prover/tests/fixtures/incomplete-react-node-alias-slot/tsconfig.json similarity index 100% rename from packages/prover/tests/fixtures/incomplete-form-status-composed-form/tsconfig.json rename to packages/prover/tests/fixtures/incomplete-react-node-alias-slot/tsconfig.json diff --git a/packages/prover/tests/fixtures/incomplete-react-node-children-map/src/app.tsx b/packages/prover/tests/fixtures/incomplete-react-node-children-map/src/app.tsx new file mode 100644 index 0000000000..1bdd44ac9d --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-react-node-children-map/src/app.tsx @@ -0,0 +1,22 @@ +import { Children } from "react"; +import { useFormStatus } from "react-dom"; +import type { ReactNode } from "react"; + +interface FormShellProperties { + children: ReactNode; +} + +const FormShell = ({ children }: FormShellProperties) => ( +
    {Children.map(children, (child) => child)}
    +); + +const SubmitButton = () => { + const { pending } = useFormStatus(); + return ; +}; + +export const Checkout = () => ( + + + +); diff --git a/packages/prover/tests/fixtures/incomplete-react-node-children-map/tsconfig.json b/packages/prover/tests/fixtures/incomplete-react-node-children-map/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-react-node-children-map/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-react-node-computed-slot/src/app.tsx b/packages/prover/tests/fixtures/incomplete-react-node-computed-slot/src/app.tsx new file mode 100644 index 0000000000..c0cc66a0dd --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-react-node-computed-slot/src/app.tsx @@ -0,0 +1,23 @@ +import { useFormStatus } from "react-dom"; +import type { ReactNode } from "react"; + +interface FormShellProperties { + children: ReactNode; + slotName: string; + [propName: string]: ReactNode; +} + +const FormShell = (properties: FormShellProperties) => ( +
    {properties[properties.slotName]}
    +); + +const SubmitButton = () => { + const { pending } = useFormStatus(); + return ; +}; + +export const Checkout = () => ( + + + +); diff --git a/packages/prover/tests/fixtures/incomplete-react-node-computed-slot/tsconfig.json b/packages/prover/tests/fixtures/incomplete-react-node-computed-slot/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-react-node-computed-slot/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-react-node-dropped-slot/src/app.tsx b/packages/prover/tests/fixtures/incomplete-react-node-dropped-slot/src/app.tsx new file mode 100644 index 0000000000..7b9819e4d1 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-react-node-dropped-slot/src/app.tsx @@ -0,0 +1,21 @@ +import { useFormStatus } from "react-dom"; +import type { ReactNode } from "react"; + +interface FormShellProperties { + children: ReactNode; +} + +const FormShell = ({ children }: FormShellProperties) => ( +
    {children && Content supplied}
    +); + +const SubmitButton = () => { + const { pending } = useFormStatus(); + return ; +}; + +export const Checkout = () => ( + + + +); diff --git a/packages/prover/tests/fixtures/incomplete-react-node-dropped-slot/tsconfig.json b/packages/prover/tests/fixtures/incomplete-react-node-dropped-slot/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-react-node-dropped-slot/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-react-node-external-slot/src/app.tsx b/packages/prover/tests/fixtures/incomplete-react-node-external-slot/src/app.tsx new file mode 100644 index 0000000000..de79d2d6b6 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-react-node-external-slot/src/app.tsx @@ -0,0 +1,13 @@ +import { ExternalShell } from "external-shell"; +import { useFormStatus } from "react-dom"; + +const SubmitButton = () => { + const { pending } = useFormStatus(); + return ; +}; + +export const Checkout = () => ( + + + +); diff --git a/packages/prover/tests/fixtures/incomplete-react-node-external-slot/src/external-shell.d.ts b/packages/prover/tests/fixtures/incomplete-react-node-external-slot/src/external-shell.d.ts new file mode 100644 index 0000000000..530be0d459 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-react-node-external-slot/src/external-shell.d.ts @@ -0,0 +1,5 @@ +declare module "external-shell" { + import type { ReactNode } from "react"; + + export const ExternalShell: (properties: { children: ReactNode }) => ReactNode; +} diff --git a/packages/prover/tests/fixtures/incomplete-react-node-external-slot/tsconfig.json b/packages/prover/tests/fixtures/incomplete-react-node-external-slot/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-react-node-external-slot/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-react-node-non-rendered-prop/src/app.tsx b/packages/prover/tests/fixtures/incomplete-react-node-non-rendered-prop/src/app.tsx new file mode 100644 index 0000000000..332928962b --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-react-node-non-rendered-prop/src/app.tsx @@ -0,0 +1,12 @@ +import { createContext } from "react"; +import { useFormStatus } from "react-dom"; +import type { ReactNode } from "react"; + +const ContentContext = createContext(null); + +const SubmitButton = () => { + const { pending } = useFormStatus(); + return ; +}; + +export const Checkout = () => } />; diff --git a/packages/prover/tests/fixtures/incomplete-react-node-non-rendered-prop/tsconfig.json b/packages/prover/tests/fixtures/incomplete-react-node-non-rendered-prop/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-react-node-non-rendered-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-react-node-props-spread/src/app.tsx b/packages/prover/tests/fixtures/incomplete-react-node-props-spread/src/app.tsx new file mode 100644 index 0000000000..f268dc4b9f --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-react-node-props-spread/src/app.tsx @@ -0,0 +1,21 @@ +import { useFormStatus } from "react-dom"; +import type { ReactNode } from "react"; + +interface FormShellProperties { + children: ReactNode; +} + +const InnerFormShell = ({ children }: FormShellProperties) =>
    {children}
    ; + +const FormShell = (properties: FormShellProperties) => ; + +const SubmitButton = () => { + const { pending } = useFormStatus(); + return ; +}; + +export const Checkout = () => ( + + + +); diff --git a/packages/prover/tests/fixtures/incomplete-react-node-props-spread/tsconfig.json b/packages/prover/tests/fixtures/incomplete-react-node-props-spread/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-react-node-props-spread/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-react-node-source-alias/src/app.tsx b/packages/prover/tests/fixtures/incomplete-react-node-source-alias/src/app.tsx new file mode 100644 index 0000000000..aac6fecb47 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-react-node-source-alias/src/app.tsx @@ -0,0 +1,18 @@ +import { useFormStatus } from "react-dom"; +import type { ReactNode } from "react"; + +interface FormShellProperties { + children: ReactNode; +} + +const FormShell = ({ children }: FormShellProperties) =>
    {children}
    ; + +const SubmitButton = () => { + const { pending } = useFormStatus(); + return ; +}; + +export const Checkout = () => { + const submitButton = ; + return {submitButton}; +}; diff --git a/packages/prover/tests/fixtures/incomplete-react-node-source-alias/tsconfig.json b/packages/prover/tests/fixtures/incomplete-react-node-source-alias/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-react-node-source-alias/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-react-node-spread-slot/src/app.tsx b/packages/prover/tests/fixtures/incomplete-react-node-spread-slot/src/app.tsx new file mode 100644 index 0000000000..21d36bc50a --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-react-node-spread-slot/src/app.tsx @@ -0,0 +1,15 @@ +import { useFormStatus } from "react-dom"; +import type { ReactNode } from "react"; + +interface FormShellProperties { + children: ReactNode; +} + +const FormShell = ({ children }: FormShellProperties) =>
    {children}
    ; + +const SubmitButton = () => { + const { pending } = useFormStatus(); + return ; +}; + +export const Checkout = () => }} />; diff --git a/packages/prover/tests/fixtures/incomplete-react-node-spread-slot/tsconfig.json b/packages/prover/tests/fixtures/incomplete-react-node-spread-slot/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/incomplete-react-node-spread-slot/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-context-provider-slot/src/app.tsx b/packages/prover/tests/fixtures/proved-context-provider-slot/src/app.tsx new file mode 100644 index 0000000000..64d236d7c8 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-context-provider-slot/src/app.tsx @@ -0,0 +1,23 @@ +import { createContext, useContext } from "react"; +import type { ReactNode } from "react"; + +const ThemeContext = createContext("default"); + +interface ThemeProviderShellProperties { + children: ReactNode; +} + +const ThemeProviderShell = ({ children }: ThemeProviderShellProperties) => ( + {children} +); + +const ThemeLabel = () => { + const theme = useContext(ThemeContext); + return {theme}; +}; + +export const App = () => ( + + + +); diff --git a/packages/prover/tests/fixtures/proved-context-provider-slot/tsconfig.json b/packages/prover/tests/fixtures/proved-context-provider-slot/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-context-provider-slot/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-context-provider-transitive-slot/src/app.tsx b/packages/prover/tests/fixtures/proved-context-provider-transitive-slot/src/app.tsx new file mode 100644 index 0000000000..2bd4959b9f --- /dev/null +++ b/packages/prover/tests/fixtures/proved-context-provider-transitive-slot/src/app.tsx @@ -0,0 +1,27 @@ +import { createContext, useContext } from "react"; +import type { ReactNode } from "react"; + +const ThemeContext = createContext("default"); + +interface ShellProperties { + children: ReactNode; +} + +const InnerShell = ({ children }: ShellProperties) => <>{children}; + +const ThemeShell = ({ children }: ShellProperties) => ( + + {children} + +); + +const ThemeLabel = () => { + const theme = useContext(ThemeContext); + return {theme}; +}; + +export const App = () => ( + + + +); diff --git a/packages/prover/tests/fixtures/proved-context-provider-transitive-slot/tsconfig.json b/packages/prover/tests/fixtures/proved-context-provider-transitive-slot/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-context-provider-transitive-slot/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-form-status-composed-form/src/app.tsx b/packages/prover/tests/fixtures/proved-form-status-composed-form/src/app.tsx similarity index 85% rename from packages/prover/tests/fixtures/incomplete-form-status-composed-form/src/app.tsx rename to packages/prover/tests/fixtures/proved-form-status-composed-form/src/app.tsx index eca05b48bd..78f86b8d84 100644 --- a/packages/prover/tests/fixtures/incomplete-form-status-composed-form/src/app.tsx +++ b/packages/prover/tests/fixtures/proved-form-status-composed-form/src/app.tsx @@ -1,7 +1,8 @@ import { useFormStatus } from "react-dom"; +import type { ReactNode } from "react"; interface FormShellProperties { - children?: unknown; + children?: ReactNode; } const FormShell = ({ children }: FormShellProperties) =>
    {children}
    ; diff --git a/packages/prover/tests/fixtures/proved-form-status-composed-form/tsconfig.json b/packages/prover/tests/fixtures/proved-form-status-composed-form/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-form-status-composed-form/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-form-status-computed-slot/src/app.tsx b/packages/prover/tests/fixtures/proved-form-status-computed-slot/src/app.tsx new file mode 100644 index 0000000000..465bae4f85 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-form-status-computed-slot/src/app.tsx @@ -0,0 +1,19 @@ +import { useFormStatus } from "react-dom"; +import type { ReactNode } from "react"; + +interface FormShellProperties { + children: ReactNode; +} + +const FormShell = (properties: FormShellProperties) =>
    {properties["children"]}
    ; + +const SubmitButton = () => { + const { pending } = useFormStatus(); + return ; +}; + +export const Checkout = () => ( + + + +); diff --git a/packages/prover/tests/fixtures/proved-form-status-computed-slot/tsconfig.json b/packages/prover/tests/fixtures/proved-form-status-computed-slot/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-form-status-computed-slot/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-form-status-named-slot/src/app.tsx b/packages/prover/tests/fixtures/proved-form-status-named-slot/src/app.tsx new file mode 100644 index 0000000000..def0756afe --- /dev/null +++ b/packages/prover/tests/fixtures/proved-form-status-named-slot/src/app.tsx @@ -0,0 +1,15 @@ +import { useFormStatus } from "react-dom"; +import type { ReactNode } from "react"; + +interface FormShellProperties { + controls: ReactNode; +} + +const FormShell = ({ controls }: FormShellProperties) =>
    {controls}
    ; + +const SubmitButton = () => { + const { pending } = useFormStatus(); + return ; +}; + +export const Checkout = () => } />; diff --git a/packages/prover/tests/fixtures/proved-form-status-named-slot/tsconfig.json b/packages/prover/tests/fixtures/proved-form-status-named-slot/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-form-status-named-slot/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-form-status-portal-slot/src/app.tsx b/packages/prover/tests/fixtures/proved-form-status-portal-slot/src/app.tsx new file mode 100644 index 0000000000..bc09676cec --- /dev/null +++ b/packages/prover/tests/fixtures/proved-form-status-portal-slot/src/app.tsx @@ -0,0 +1,22 @@ +import { useFormStatus } from "react-dom"; +import { createPortal } from "react-dom"; +import type { ReactNode } from "react"; + +interface FormShellProperties { + children: ReactNode; +} + +const FormShell = ({ children }: FormShellProperties) => ( +
    {createPortal(children, document.body)}
    +); + +const SubmitButton = () => { + const { pending } = useFormStatus(); + return ; +}; + +export const Checkout = () => ( + + + +); diff --git a/packages/prover/tests/fixtures/proved-form-status-portal-slot/tsconfig.json b/packages/prover/tests/fixtures/proved-form-status-portal-slot/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-form-status-portal-slot/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-form-status-source-form-slot/src/app.tsx b/packages/prover/tests/fixtures/proved-form-status-source-form-slot/src/app.tsx new file mode 100644 index 0000000000..2bc35ee962 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-form-status-source-form-slot/src/app.tsx @@ -0,0 +1,21 @@ +import { useFormStatus } from "react-dom"; +import type { ReactNode } from "react"; + +interface IdentityProperties { + children: ReactNode; +} + +const Identity = ({ children }: IdentityProperties) => <>{children}; + +const SubmitButton = () => { + const { pending } = useFormStatus(); + return ; +}; + +export const Checkout = () => ( + +
    + + +
    +); diff --git a/packages/prover/tests/fixtures/proved-form-status-source-form-slot/tsconfig.json b/packages/prover/tests/fixtures/proved-form-status-source-form-slot/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-form-status-source-form-slot/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/proved-form-status-transitive-slot/src/app.tsx b/packages/prover/tests/fixtures/proved-form-status-transitive-slot/src/app.tsx new file mode 100644 index 0000000000..e4bb5f1a47 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-form-status-transitive-slot/src/app.tsx @@ -0,0 +1,25 @@ +import { useFormStatus } from "react-dom"; +import type { ReactNode } from "react"; + +interface ShellProperties { + children: ReactNode; +} + +const InnerShell = ({ children }: ShellProperties) => <>{children}; + +const OuterFormShell = ({ children }: ShellProperties) => ( +
    + {children} +
    +); + +const SubmitButton = () => { + const { pending } = useFormStatus(); + return ; +}; + +export const Checkout = () => ( + + + +); diff --git a/packages/prover/tests/fixtures/proved-form-status-transitive-slot/tsconfig.json b/packages/prover/tests/fixtures/proved-form-status-transitive-slot/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/proved-form-status-transitive-slot/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/fixtures/react-shim.d.ts b/packages/prover/tests/fixtures/react-shim.d.ts index 43901d73c8..7ddf086b00 100644 --- a/packages/prover/tests/fixtures/react-shim.d.ts +++ b/packages/prover/tests/fixtures/react-shim.d.ts @@ -12,6 +12,7 @@ declare module "react" { } export interface Context { + (properties: { value?: Value; children?: unknown }): null; Provider: (properties: { value?: Value; children?: unknown }) => null; } @@ -42,6 +43,12 @@ declare module "react" { ): [State, (actionPayload: ActionPayload) => void, boolean]; }; export const createContext: (defaultValue: Value) => Context; + export const Children: { + map: ( + children: Child, + transform: (child: Child) => Result, + ) => ReadonlyArray; + }; export const memo: (component: Component) => Component; export const StrictMode: (properties: { children?: unknown }) => unknown; export const startTransition: (action: () => void | Promise) => void; diff --git a/packages/prover/tests/fixtures/refuted-form-status-mixed-slot-placement/src/app.tsx b/packages/prover/tests/fixtures/refuted-form-status-mixed-slot-placement/src/app.tsx new file mode 100644 index 0000000000..0af0e5f0a6 --- /dev/null +++ b/packages/prover/tests/fixtures/refuted-form-status-mixed-slot-placement/src/app.tsx @@ -0,0 +1,24 @@ +import { useFormStatus } from "react-dom"; +import type { ReactNode } from "react"; + +interface FormShellProperties { + children: ReactNode; +} + +const FormShell = ({ children }: FormShellProperties) => ( + <> +
    {children}
    +
    {children}
    + +); + +const SubmitButton = () => { + const { pending } = useFormStatus(); + return ; +}; + +export const Checkout = () => ( + + + +); diff --git a/packages/prover/tests/fixtures/refuted-form-status-mixed-slot-placement/tsconfig.json b/packages/prover/tests/fixtures/refuted-form-status-mixed-slot-placement/tsconfig.json new file mode 100644 index 0000000000..d2a921ed01 --- /dev/null +++ b/packages/prover/tests/fixtures/refuted-form-status-mixed-slot-placement/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../proved-chat/tsconfig.json", + "include": ["src", "../react-shim.d.ts"] +} diff --git a/packages/prover/tests/prove-react-app.test.ts b/packages/prover/tests/prove-react-app.test.ts index 3caad6bfc2..057152a362 100644 --- a/packages/prover/tests/prove-react-app.test.ts +++ b/packages/prover/tests/prove-react-app.test.ts @@ -38,6 +38,7 @@ import { ReactSemanticEdgeKind, ReactSemanticCallbackKind, ReactSemanticFunctionCallKind, + ReactSemanticRenderKind, ReactTransitionActionStatus, ReactTransitionStarterKind, } from "../src/index.js"; @@ -96,6 +97,11 @@ const REFUTED_FIXTURES: ReadonlyArray = [ claim: ReactProofClaim.FormStatus, evidencePattern: /without a parent
    /, }, + { + fixtureName: "refuted-form-status-mixed-slot-placement", + claim: ReactProofClaim.FormStatus, + evidencePattern: /without a parent /, + }, { fixtureName: "refuted-optimistic-outside-action", claim: ReactProofClaim.OptimisticState, @@ -658,8 +664,8 @@ describe("proveReactApp", () => { const report = proveFixture("proved-chat"); const effect = report.graph.effects[0]; - expect(report.schemaVersion).toBe(21); - expect(report.graph.schemaVersion).toBe(27); + expect(report.schemaVersion).toBe(22); + expect(report.graph.schemaVersion).toBe(28); expect(effect?.hookName).toBe("useEffect"); expect(effect?.callbackResolved).toBe(true); expect(effect?.dependencyMode).toBe(ReactEffectDependencyMode.Inline); @@ -3384,27 +3390,172 @@ describe("proveReactApp", () => { expect(formStatus?.complete).toBe(true); }); - it("fails closed when a component wrapper owns the possible parent form", () => { - const report = proveFixture("incomplete-form-status-composed-form"); + it("certifies a Form Status consumer through a component-owned children slot", () => { + const report = proveFixture("proved-form-status-composed-form"); const formStatus = report.graph.formStatuses[0]; - const formStatusProof = report.units + const slotFlowProof = report.units .flatMap((unit) => unit.obligations) .find( (obligation) => - obligation.claim === ReactProofClaim.FormStatus && - obligation.status === ReactObligationStatus.Unknown, + obligation.claim === ReactProofClaim.ReactNodeFlow && + obligation.status === ReactObligationStatus.Proved, ); + const slotInput = report.graph.renders.find( + (render) => render.kind === ReactSemanticRenderKind.SlotInput, + ); + const slotRender = report.graph.renders.find( + (render) => render.kind === ReactSemanticRenderKind.Slot, + ); - expect(report.status).toBe(ReactAppProofStatus.Incomplete); - expect(formStatus?.sourceFormIds).toEqual([]); + expect(report.status).toBe(ReactAppProofStatus.Proved); + expect(formStatus?.sourceFormIds).toEqual([report.graph.forms[0]?.id]); expect(formStatus?.outsideForm).toBe(false); - expect(formStatus?.status).toBe(ReactFormStatusTopologyStatus.Unknown); - expect(formStatus?.sourceComplete).toBe(false); - expect(formStatus?.complete).toBe(false); - expect(formStatusProof?.evidence[0]?.description).toMatch(/cannot be resolved/); + expect(formStatus?.status).toBe(ReactFormStatusTopologyStatus.Resolved); + expect(formStatus?.sourceComplete).toBe(true); + expect(formStatus?.complete).toBe(true); + expect(report.graph.slotFlows[0]?.complete).toBe(true); + expect(slotRender?.sourceRenderId).toBe(slotInput?.id); + expect(slotRender?.activeFormIds).toEqual([report.graph.forms[0]?.id]); + expect(slotFlowProof).toBeDefined(); + expect(checkReactProofReport(report).status).toBe(ReactProofCertificateStatus.Valid); + }); + + it.each([ + "proved-form-status-transitive-slot", + "proved-form-status-named-slot", + "proved-form-status-source-form-slot", + "proved-form-status-computed-slot", + "proved-form-status-portal-slot", + ])("certifies project-local ReactNode topology in %s", (fixtureName) => { + const report = proveFixture(fixtureName); + const formStatus = report.graph.formStatuses[0]; + + expect(report.status).toBe(ReactAppProofStatus.Proved); + expect(report.graph.slotFlows.every((slotFlow) => slotFlow.complete)).toBe(true); + expect(formStatus?.status).toBe(ReactFormStatusTopologyStatus.Resolved); + expect(formStatus?.complete).toBe(true); + expect(checkReactProofReport(report).status).toBe(ReactProofCertificateStatus.Valid); + }); + + it.each(["proved-context-provider-slot", "proved-context-provider-transitive-slot"])( + "carries a context provider through component-owned children slots in %s", + (fixtureName) => { + const report = proveFixture(fixtureName); + const provider = report.graph.contextProviders[0]; + const consumer = report.graph.contextConsumers[0]; + + expect(report.status).toBe(ReactAppProofStatus.Proved); + expect(report.graph.slotFlows[0]?.complete).toBe(true); + expect(consumer?.sourceProviderIds).toEqual([provider?.id]); + expect(consumer?.usesDefaultValue).toBe(false); + expect(consumer?.topologyComplete).toBe(true); + expect(checkReactProofReport(report).status).toBe(ReactProofCertificateStatus.Valid); + }, + ); + + it.each([ + { + fixtureName: "incomplete-react-node-external-slot", + placementComplete: false, + sourceComplete: true, + }, + { + fixtureName: "incomplete-react-node-alias-slot", + placementComplete: false, + sourceComplete: true, + }, + { + fixtureName: "incomplete-react-node-children-map", + placementComplete: false, + sourceComplete: true, + }, + { + fixtureName: "incomplete-react-node-source-alias", + placementComplete: false, + sourceComplete: false, + }, + { + fixtureName: "incomplete-react-node-spread-slot", + placementComplete: false, + sourceComplete: false, + }, + { + fixtureName: "incomplete-react-node-computed-slot", + placementComplete: false, + sourceComplete: true, + }, + { + fixtureName: "incomplete-react-node-props-spread", + placementComplete: false, + sourceComplete: true, + }, + { + fixtureName: "incomplete-react-node-non-rendered-prop", + placementComplete: false, + sourceComplete: false, + }, + ])( + "fails closed for unresolved ReactNode topology in $fixtureName", + ({ fixtureName, placementComplete, sourceComplete }) => { + const report = proveFixture(fixtureName); + const incompleteSlotFlow = report.graph.slotFlows.find((slotFlow) => !slotFlow.complete); + const reactNodeProof = report.units + .flatMap((unit) => unit.obligations) + .find( + (obligation) => + obligation.claim === ReactProofClaim.ReactNodeFlow && + obligation.status === ReactObligationStatus.Unknown, + ); + + expect(report.status).toBe(ReactAppProofStatus.Incomplete); + expect(incompleteSlotFlow?.placementComplete).toBe(placementComplete); + expect(incompleteSlotFlow?.sourceComplete).toBe(sourceComplete); + expect(report.graph.formStatuses[0]?.status).toBe(ReactFormStatusTopologyStatus.Unknown); + expect(reactNodeProof?.evidence[0]?.description).toMatch( + /project-local|unresolved source expression/, + ); + expect(checkReactProofReport(report).status).toBe(ReactProofCertificateStatus.Valid); + }, + ); + + it("does not turn a ReactNode used only as a condition into an effective render", () => { + const report = proveFixture("incomplete-react-node-dropped-slot"); + const slotFlow = report.graph.slotFlows[0]; + const reactNodeProof = report.units + .find((unit) => unit.name === "Checkout") + ?.obligations.find( + (obligation) => + obligation.claim === ReactProofClaim.ReactNodeFlow && + obligation.status === ReactObligationStatus.Proved, + ); + + expect(report.status).toBe(ReactAppProofStatus.Incomplete); + expect(slotFlow?.complete).toBe(true); + expect(slotFlow?.renderIds).toEqual([]); + expect(report.graph.formStatuses[0]?.status).toBe(ReactFormStatusTopologyStatus.Unknown); + expect(reactNodeProof).toBeDefined(); expect(checkReactProofReport(report).status).toBe(ReactProofCertificateStatus.Valid); }); + it("rejects a forged ReactNode slot-flow certificate", () => { + const report = proveFixture("proved-form-status-composed-form"); + const certificate = checkReactProofReport({ + ...report, + graph: { + ...report.graph, + slotFlows: report.graph.slotFlows.map((slotFlow) => ({ + ...slotFlow, + complete: false, + })), + }, + }); + + expect(certificate.status).toBe(ReactProofCertificateStatus.Invalid); + expect(certificate.failures.some((failure) => failure.description.includes("slot flow"))).toBe( + true, + ); + }); + it("fails closed when a synchronous render callback has unmodeled form ancestry", () => { const report = proveFixture("incomplete-form-status-render-callback"); const formStatus = report.graph.formStatuses[0]; diff --git a/packages/prover/tests/runtime/form-status-oracle.spec.ts b/packages/prover/tests/runtime/form-status-oracle.spec.ts index a2e8554082..7f65a64058 100644 --- a/packages/prover/tests/runtime/form-status-oracle.spec.ts +++ b/packages/prover/tests/runtime/form-status-oracle.spec.ts @@ -17,3 +17,18 @@ test("Form Status observes only a parent form during a Strict Mode Action", asyn .toBe(FORM_STATUS_ACTION_EXPECTED_RUNS); await expect(page.getByTestId("form-status-pending")).toHaveText("false"); }); + +test("Form Status observes a parent form introduced by a component slot", async ({ page }) => { + await page.goto("/?oracle=form-status-slot"); + + await page.getByRole("textbox", { name: "Username" }).fill("grace"); + await page.getByRole("button", { name: "request username" }).click(); + + await expect(page.getByTestId("form-status-pending")).toHaveText("true"); + await expect(page.getByTestId("form-status-data")).toHaveText("grace"); + await expect(page.getByTestId("form-status-action")).toHaveText("true"); + await expect + .poll(() => page.evaluate(() => window.formStatusActionRuns)) + .toBe(FORM_STATUS_ACTION_EXPECTED_RUNS); + await expect(page.getByTestId("form-status-pending")).toHaveText("false"); +}); diff --git a/packages/prover/tests/runtime/main.tsx b/packages/prover/tests/runtime/main.tsx index 7ca5302613..809ff4b35f 100644 --- a/packages/prover/tests/runtime/main.tsx +++ b/packages/prover/tests/runtime/main.tsx @@ -15,7 +15,7 @@ import { useSyncExternalStore, useTransition, } from "react"; -import type { ChangeEvent } from "react"; +import type { ChangeEvent, ReactNode } from "react"; import { useFormStatus } from "react-dom"; import { createRoot } from "react-dom/client"; import { @@ -135,6 +135,28 @@ const FormStatusOracle = () => { ); }; +interface FormStatusSlotShellProperties { + children: ReactNode; +} + +const FormStatusSlotShell = ({ children }: FormStatusSlotShellProperties) => ( + + + {children} +
    +); + +const FormStatusSlotOracle = () => ( +
    + + + +
    +); + const ActionStateOracle = () => { const [submittedItems, submitItem, isPending] = useActionState( async (previousItems: ReadonlyArray, formData: FormData) => { @@ -1076,6 +1098,9 @@ const RuntimeOracle = () => { if (oracle === "form-status") { return ; } + if (oracle === "form-status-slot") { + return ; + } return ; }; @@ -1092,7 +1117,8 @@ const isStrictModeOracle = oracle === "transition-action" || oracle === "optimistic-form-action" || oracle === "action-state" || - oracle === "form-status"; + oracle === "form-status" || + oracle === "form-status-slot"; createRoot(rootElement).render( isStrictModeOracle ? ( From 435205faea62c5acb2f414a6f38854ef5c3556ef Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Wed, 29 Jul 2026 00:04:40 +0000 Subject: [PATCH 16/23] feat(prover): certify imperative handle protocols --- packages/prover/README.md | 9 + packages/prover/research-log.md | 103 +++ .../prover/src/analyze-boundary-coverage.ts | 73 +- .../prover/src/analyze-effect-dependencies.ts | 13 +- .../prover/src/analyze-imperative-handle.ts | 76 ++ .../prover/src/analyze-memo-dependencies.ts | 13 +- packages/prover/src/analyze-react-unit.ts | 3 + .../prover/src/build-react-semantic-graph.ts | 698 +++++++++++++++++- .../prover/src/check-react-proof-report.ts | 336 +++++++++ .../prover/src/collect-imperative-handles.ts | 170 +++++ packages/prover/src/constants.ts | 7 +- packages/prover/src/index.ts | 6 + packages/prover/src/prove-react-app.ts | 4 + packages/prover/src/types.ts | 77 ++ .../src/utils/is-reactive-capture-declared.ts | 10 + .../src/app.tsx | 22 + .../tsconfig.json | 4 + .../src/app.tsx | 29 + .../tsconfig.json | 4 + .../src/app.tsx | 31 + .../tsconfig.json | 4 + .../src/app.tsx | 17 + .../tsconfig.json | 4 + .../src/app.tsx | 26 + .../tsconfig.json | 4 + .../src/app.tsx | 26 + .../tsconfig.json | 4 + .../src/app.tsx | 27 + .../tsconfig.json | 4 + .../src/app.tsx | 29 + .../tsconfig.json | 4 + .../proved-imperative-handle/src/app.tsx | 36 + .../proved-imperative-handle/tsconfig.json | 4 + .../prover/tests/fixtures/react-shim.d.ts | 14 + .../src/app.tsx | 29 + .../tsconfig.json | 4 + .../src/app.tsx | 35 + .../tsconfig.json | 4 + packages/prover/tests/prove-react-app.test.ts | 153 +++- .../runtime/imperative-handle-oracle.spec.ts | 17 + packages/prover/tests/runtime/main.tsx | 48 +- 41 files changed, 2152 insertions(+), 29 deletions(-) create mode 100644 packages/prover/src/analyze-imperative-handle.ts create mode 100644 packages/prover/src/collect-imperative-handles.ts create mode 100644 packages/prover/src/utils/is-reactive-capture-declared.ts create mode 100644 packages/prover/tests/fixtures/incomplete-callback-ref-imperative-handle/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-callback-ref-imperative-handle/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-computed-imperative-handle-method/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-computed-imperative-handle-method/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-escaped-imperative-handle-ref/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-escaped-imperative-handle-ref/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-exported-imperative-handle/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-exported-imperative-handle/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-opaque-imperative-handle/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-opaque-imperative-handle/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-reused-imperative-handle-target/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-reused-imperative-handle-target/tsconfig.json create mode 100644 packages/prover/tests/fixtures/incomplete-shared-imperative-handle-ref/src/app.tsx create mode 100644 packages/prover/tests/fixtures/incomplete-shared-imperative-handle-ref/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-forward-ref-imperative-handle/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-forward-ref-imperative-handle/tsconfig.json create mode 100644 packages/prover/tests/fixtures/proved-imperative-handle/src/app.tsx create mode 100644 packages/prover/tests/fixtures/proved-imperative-handle/tsconfig.json create mode 100644 packages/prover/tests/fixtures/refuted-impure-imperative-handle-factory/src/app.tsx create mode 100644 packages/prover/tests/fixtures/refuted-impure-imperative-handle-factory/tsconfig.json create mode 100644 packages/prover/tests/fixtures/refuted-stale-imperative-handle/src/app.tsx create mode 100644 packages/prover/tests/fixtures/refuted-stale-imperative-handle/tsconfig.json create mode 100644 packages/prover/tests/runtime/imperative-handle-oracle.spec.ts diff --git a/packages/prover/README.md b/packages/prover/README.md index 09a0e54111..e92870492d 100644 --- a/packages/prover/README.md +++ b/packages/prover/README.md @@ -44,6 +44,11 @@ The report includes: 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 @@ -148,6 +153,10 @@ validate active-form ownership, and reject forged outside-form, source, topology 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 diff --git a/packages/prover/research-log.md b/packages/prover/research-log.md index a0fcd3bbef..5e45bd38a9 100644 --- a/packages/prover/research-log.md +++ b/packages/prover/research-log.md @@ -1760,3 +1760,106 @@ 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. diff --git a/packages/prover/src/analyze-boundary-coverage.ts b/packages/prover/src/analyze-boundary-coverage.ts index 4ec66b1f68..0de866a322 100644 --- a/packages/prover/src/analyze-boundary-coverage.ts +++ b/packages/prover/src/analyze-boundary-coverage.ts @@ -16,6 +16,7 @@ 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"; @@ -173,6 +174,51 @@ export const analyzeBoundaryCoverage = ( ), ); }; + 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) { @@ -379,6 +425,7 @@ export const analyzeBoundaryCoverage = ( 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; @@ -463,7 +510,8 @@ export const analyzeBoundaryCoverage = ( !isModeledContextRead && !(canonicalReactApiName === "useActionState" && isModeledActionStateCall(node)) && !(canonicalReactApiName === "useTransition" && isModeledUseTransitionCall(node)) && - !(canonicalReactApiName === "useOptimistic" && isModeledOptimisticCall(node)) + !(canonicalReactApiName === "useOptimistic" && isModeledOptimisticCall(node)) && + !(canonicalReactApiName === "useImperativeHandle" && isModeledImperativeHandleCall(node)) ) { unknownEvidence.push( createEvidence( @@ -509,6 +557,28 @@ export const analyzeBoundaryCoverage = ( ), ); } + 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; @@ -540,6 +610,7 @@ export const analyzeBoundaryCoverage = ( ); } for (const argument of node.arguments) { + if (isModeledImperativeHandleUse(argument)) continue; const forwardedCallbackPropName = isComponentUnit ? getComponentPropName(argument, functionNode, context.typeChecker) : null; diff --git a/packages/prover/src/analyze-effect-dependencies.ts b/packages/prover/src/analyze-effect-dependencies.ts index 27fec9be2c..d6f88156ff 100644 --- a/packages/prover/src/analyze-effect-dependencies.ts +++ b/packages/prover/src/analyze-effect-dependencies.ts @@ -6,6 +6,7 @@ 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 = ( @@ -47,8 +48,8 @@ export const analyzeEffectDependencies = ( ); continue; } - const declaredDependencies = new Set( - dependenciesExpression.elements.map((dependency) => dependency.getText()), + const declaredDependencies = dependenciesExpression.elements.map((dependency) => + dependency.getText(), ); const captures = collectReactiveCaptures( effectCallback, @@ -57,13 +58,7 @@ export const analyzeEffectDependencies = ( stableSymbols, ); for (const { key: captureKey, node: captureNode } of captures) { - const isDeclared = [...declaredDependencies].some( - (dependency) => - dependency === captureKey || - captureKey.startsWith(`${dependency}.`) || - dependency.startsWith(`${captureKey}.`), - ); - if (isDeclared) continue; + if (isReactiveCaptureDeclared(captureKey, declaredDependencies)) continue; violations.push( createEvidence( captureNode, diff --git a/packages/prover/src/analyze-imperative-handle.ts b/packages/prover/src/analyze-imperative-handle.ts new file mode 100644 index 0000000000..a21d8b0e1f --- /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-memo-dependencies.ts b/packages/prover/src/analyze-memo-dependencies.ts index b5f1f0a671..c07f9a9c34 100644 --- a/packages/prover/src/analyze-memo-dependencies.ts +++ b/packages/prover/src/analyze-memo-dependencies.ts @@ -8,6 +8,7 @@ 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 = ( @@ -51,8 +52,8 @@ export const analyzeMemoDependencies = ( ); continue; } - const declaredDependencies = new Set( - dependencyExpression.elements.map((dependency) => dependency.getText()), + const declaredDependencies = dependencyExpression.elements.map((dependency) => + dependency.getText(), ); const captures = collectReactiveCaptures( callback, @@ -61,13 +62,7 @@ export const analyzeMemoDependencies = ( stableSymbols, ); for (const capture of captures) { - const isDeclared = [...declaredDependencies].some( - (dependency) => - dependency === capture.key || - capture.key.startsWith(`${dependency}.`) || - dependency.startsWith(`${capture.key}.`), - ); - if (isDeclared) continue; + if (isReactiveCaptureDeclared(capture.key, declaredDependencies)) continue; violations.push( createEvidence( capture.node, diff --git a/packages/prover/src/analyze-react-unit.ts b/packages/prover/src/analyze-react-unit.ts index cf67d599a5..a5e56099c8 100644 --- a/packages/prover/src/analyze-react-unit.ts +++ b/packages/prover/src/analyze-react-unit.ts @@ -17,6 +17,7 @@ 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 { analyzeImperativeHandle } from "./analyze-imperative-handle.js"; import { analyzeMemoDependencies } from "./analyze-memo-dependencies.js"; import { analyzeOptimisticState } from "./analyze-optimistic-state.js"; import { analyzeRefAccess } from "./analyze-ref-access.js"; @@ -52,6 +53,7 @@ const ALL_REACT_PROOF_CLAIMS: ReadonlyArray = [ ReactProofClaim.HookOrder, ReactProofClaim.HookOwnership, ReactProofClaim.HookStateTransitions, + ReactProofClaim.ImperativeHandle, ReactProofClaim.MemoDependencies, ReactProofClaim.OptimisticState, ReactProofClaim.ReactNodeFlow, @@ -153,6 +155,7 @@ export const analyzeReactUnit = ( analyzeHookOrder(unit.functionNode, context), analyzeHookOwnership(unit.functionNode), analyzeHookStateTransitions(unit, context), + analyzeImperativeHandle(unit, context), analyzeMemoDependencies(unit.functionNode, context), analyzeOptimisticState(unit, context), analyzeReactNodeFlow(unit, context), diff --git a/packages/prover/src/build-react-semantic-graph.ts b/packages/prover/src/build-react-semantic-graph.ts index 19c7e5a2ee..ffd6819d19 100644 --- a/packages/prover/src/build-react-semantic-graph.ts +++ b/packages/prover/src/build-react-semantic-graph.ts @@ -1,5 +1,6 @@ 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"; @@ -29,6 +30,11 @@ import { import { collectHookBindings } from "./collect-hook-bindings.js"; import { collectHookCalls } from "./collect-hook-calls.js"; import { collectHookStateTransitions } from "./collect-hook-state-transitions.js"; +import { collectImperativeHandles, ImperativeHandleRefKind } from "./collect-imperative-handles.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 { collectTransitionActions } from "./collect-transition-actions.js"; @@ -48,11 +54,13 @@ import { } 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 { isReactContextExpression } from "./is-react-context-expression.js"; import { resolveFunction } from "./resolve-function.js"; import { mergeCallableBindings } from "./resolve-callable-expression.js"; @@ -73,8 +81,11 @@ import { ReactFormStatusTopologyStatus, ReactHookStateUpdaterStatus, ReactIdentityStability, + ReactImperativeHandleRefKind, + ReactImperativeHandleStatus, ReactOptimisticActionStatus, ReactOptimisticReducerStatus, + ReactObligationStatus, ReactSemanticCallbackKind, ReactSemanticEdgeKind, ReactSemanticRenderKind, @@ -109,6 +120,10 @@ import type { ReactSemanticGraph, ReactSemanticHookCall, ReactSemanticHookStateTransition, + ReactSemanticImperativeHandle, + ReactSemanticImperativeHandleBinding, + ReactSemanticImperativeHandleInvocation, + ReactSemanticImperativeHandleMethod, ReactSemanticOptimisticState, ReactSemanticOptimisticUpdate, ReactSemanticTransitionAction, @@ -125,9 +140,11 @@ import { collectReachableCallExpressions } from "./utils/collect-reachable-call- import { collectExecutionCallbackIds } from "./utils/collect-execution-callback-ids.js"; import { getClassMethodDeclaration } from "./utils/get-class-method-declaration.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 { 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 { @@ -212,6 +229,45 @@ interface HookStateTransitionGraphFacts extends CallbackGraphFacts { transitions: 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; @@ -361,6 +417,12 @@ const getDeclarationNameNode = (descriptor: ReactUnitDescriptor): ts.Node | null 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; }; @@ -999,10 +1061,11 @@ const collectHookGraph = ( return { hookCalls, edges }; }; -const getEffectDependencyFacts = ( - effectCall: ts.CallExpression, +const getHookDependencyFacts = ( + hookCall: ts.CallExpression, + argumentIndex: number, ): { mode: ReactEffectDependencyMode; dependencies: ReadonlyArray } => { - const dependencyExpression = effectCall.arguments[1]; + const dependencyExpression = hookCall.arguments[argumentIndex]; if (!dependencyExpression) { return { mode: ReactEffectDependencyMode.Missing, dependencies: [] }; } @@ -1151,7 +1214,7 @@ const collectEffectGraph = ( for (const effectCall of collectEffectCalls(functionNode, context.typeChecker)) { const hookName = getCanonicalHookName(effectCall, context.typeChecker) ?? "unknown-effect"; const effectCallback = getEffectCallback(effectCall, context.typeChecker); - const dependencyFacts = getEffectDependencyFacts(effectCall); + const dependencyFacts = getHookDependencyFacts(effectCall, 1); const captures = effectCallback ? collectReactiveCaptures( effectCallback, @@ -3246,6 +3309,617 @@ const collectEffectEventGraph = ( 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, @@ -3669,6 +4343,18 @@ export const buildReactSemanticGraph = ( 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, @@ -3770,6 +4456,10 @@ export const buildReactSemanticGraph = ( eventBindings: eventGraph.eventBindings, callbackPropFlows: callbackPropGraph.callbackPropFlows, callableRefs, + imperativeHandles: imperativeHandleGraph.handles, + imperativeHandleMethods: imperativeHandleGraph.methods, + imperativeHandleBindings: imperativeHandleGraph.bindings, + imperativeHandleInvocations: imperativeHandleGraph.invocations, schedulers, resources, classConstructions, diff --git a/packages/prover/src/check-react-proof-report.ts b/packages/prover/src/check-react-proof-report.ts index 6c7563199f..80b4f35bb4 100644 --- a/packages/prover/src/check-react-proof-report.ts +++ b/packages/prover/src/check-react-proof-report.ts @@ -25,11 +25,14 @@ import { ReactClassUpdateCycleStatus, ReactEffectResourceDisposalStatus, ReactEffectResourceKind, + ReactEffectDependencyMode, ReactExecutionPhase, ReactFormActionKind, ReactFormActionStatus, ReactFormStatusTopologyStatus, ReactHookStateUpdaterStatus, + ReactImperativeHandleRefKind, + ReactImperativeHandleStatus, ReactObligationStatus, ReactOptimisticActionStatus, ReactOptimisticReducerStatus, @@ -62,6 +65,9 @@ const OPTIMISTIC_ACTION_STATUSES = new Set(Object.values(ReactOptimisticActionSt 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 OBLIGATION_STATUSES = new Set(Object.values(ReactObligationStatus)); const TRANSITION_ACTION_ORIGIN_PHASES = new Set([ ReactExecutionPhase.ActionStateReducer, ReactExecutionPhase.ClassMount, @@ -213,6 +219,28 @@ const expectedHookStateTransitionStatus = ( : 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, @@ -480,6 +508,17 @@ const checkClaimCoverage = ( `Hook state transition facts require ${expectedHookStateStatus}, not ${hookStateTransitions.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 transitionActions = unitProof.obligations.find( (obligation) => obligation.claim === ReactProofClaim.TransitionActions, ); @@ -751,6 +790,283 @@ const checkGraphReferences = ( const formsById = new Map(report.graph.forms.map((form) => [form.id, form])); const contextSourcesByUnit = deriveContextSourcesByUnit(report); const formSourcesByUnit = deriveFormSourcesByUnit(report); + 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 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 && @@ -2863,6 +3179,26 @@ export const checkReactProofReport = (report: ReactAppProofReport): ReactProofCe "callable refs", report.graph.callableRefs.map((callableRef) => callableRef.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", diff --git a/packages/prover/src/collect-imperative-handles.ts b/packages/prover/src/collect-imperative-handles.ts new file mode 100644 index 0000000000..392e77ea92 --- /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/constants.ts b/packages/prover/src/constants.ts index 508ad9efe0..04135b0aec 100644 --- a/packages/prover/src/constants.ts +++ b/packages/prover/src/constants.ts @@ -1,7 +1,7 @@ import { ReactEffectResourceKind } from "./types.js"; -export const REACT_PROOF_SCHEMA_VERSION = 22; -export const REACT_SEMANTIC_GRAPH_SCHEMA_VERSION = 28; +export const REACT_PROOF_SCHEMA_VERSION = 23; +export const REACT_SEMANTIC_GRAPH_SCHEMA_VERSION = 29; 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; @@ -37,6 +37,7 @@ export const PLATFORM_OBSERVER_KINDS = new Map( ]); 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([ @@ -59,6 +60,7 @@ export const REACT_MODELED_HOOK_NAMES = new Set([ "useEffectEvent", "useFormStatus", "useId", + "useImperativeHandle", "useInsertionEffect", "useLayoutEffect", "useMemo", @@ -71,7 +73,6 @@ export const REACT_MODELED_HOOK_NAMES = new Set([ export const REACT_UNMODELED_HOOK_NAMES = new Set([ "use", "useDeferredValue", - "useImperativeHandle", "useOptimistic", "useTransition", ]); diff --git a/packages/prover/src/index.ts b/packages/prover/src/index.ts index 7920e32f1f..8e18ed56fc 100644 --- a/packages/prover/src/index.ts +++ b/packages/prover/src/index.ts @@ -27,6 +27,8 @@ export { ReactFormStatusTopologyStatus, ReactHookStateUpdaterStatus, ReactIdentityStability, + ReactImperativeHandleRefKind, + ReactImperativeHandleStatus, ReactObligationStatus, ReactOptimisticActionStatus, ReactOptimisticReducerStatus, @@ -85,6 +87,10 @@ export type { ReactSemanticFunctionCall, ReactSemanticHookCall, ReactSemanticHookStateTransition, + ReactSemanticImperativeHandle, + ReactSemanticImperativeHandleBinding, + ReactSemanticImperativeHandleInvocation, + ReactSemanticImperativeHandleMethod, ReactSemanticOptimisticState, ReactSemanticOptimisticUpdate, ReactSemanticTransitionAction, diff --git a/packages/prover/src/prove-react-app.ts b/packages/prover/src/prove-react-app.ts index f1673afecf..151e0e0805 100644 --- a/packages/prover/src/prove-react-app.ts +++ b/packages/prover/src/prove-react-app.ts @@ -44,6 +44,10 @@ export const proveReactApp = (input: ProveReactAppInput): ReactAppProofReport => eventBindings: [], callbackPropFlows: [], callableRefs: [], + imperativeHandles: [], + imperativeHandleMethods: [], + imperativeHandleBindings: [], + imperativeHandleInvocations: [], schedulers: [], resources: [], classConstructions: [], diff --git a/packages/prover/src/types.ts b/packages/prover/src/types.ts index 515bb5e37a..b47ad1b56f 100644 --- a/packages/prover/src/types.ts +++ b/packages/prover/src/types.ts @@ -32,6 +32,7 @@ export enum ReactProofClaim { HookOrder = "hook-order", HookOwnership = "hook-ownership", HookStateTransitions = "hook-state-transitions", + ImperativeHandle = "imperative-handle", MemoDependencies = "memo-dependencies", OptimisticState = "optimistic-state", ReactNodeFlow = "react-node-flow", @@ -84,6 +85,7 @@ export enum ReactExecutionPhase { Event = "event", ExternalStoreSubscription = "external-store-subscription", FormAction = "form-action", + ImperativeHandle = "imperative-handle", OptimisticReducer = "optimistic-reducer", OptimisticUpdater = "optimistic-updater", Render = "render", @@ -107,6 +109,8 @@ export enum ReactSemanticCallbackKind { 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", @@ -439,6 +443,75 @@ export interface ReactSemanticCallableRef { 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; @@ -843,6 +916,10 @@ export interface ReactSemanticGraph { eventBindings: ReadonlyArray; callbackPropFlows: ReadonlyArray; callableRefs: ReadonlyArray; + imperativeHandles: ReadonlyArray; + imperativeHandleMethods: ReadonlyArray; + imperativeHandleBindings: ReadonlyArray; + imperativeHandleInvocations: ReadonlyArray; schedulers: ReadonlyArray; resources: ReadonlyArray; classConstructions: ReadonlyArray; 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 0000000000..325e5d2f41 --- /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/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 0000000000..12a0910561 --- /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