From 42b264ef8b920a37d02ae77930e7a359fbb563d9 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Thu, 23 Jul 2026 16:11:59 +1200 Subject: [PATCH 1/6] Add API diff tooling for v3-to-v4 migration - Detect and report module and export changes across worktrees - Generate machine-readable and Markdown v3-to-v4 migration maps --- migration/v3-to-v4.json | 3988 +++++++++++++++++ migration/v3-to-v4.md | 152 +- package.json | 1 + packages/tools/api-diff/README.md | 36 + packages/tools/api-diff/package.json | 25 + packages/tools/api-diff/src/Cli.ts | 159 + packages/tools/api-diff/src/Diff.ts | 470 ++ packages/tools/api-diff/src/Discovery.ts | 213 + packages/tools/api-diff/src/Json.ts | 30 + packages/tools/api-diff/src/Mapping.ts | 262 ++ packages/tools/api-diff/src/Model.ts | 192 + packages/tools/api-diff/src/Report.ts | 157 + packages/tools/api-diff/src/Snapshot.ts | 832 ++++ packages/tools/api-diff/src/Worktrees.ts | 138 + packages/tools/api-diff/src/bin.ts | 9 + packages/tools/api-diff/test/Diff.test.ts | 122 + .../tools/api-diff/test/Discovery.test.ts | 43 + packages/tools/api-diff/test/Mapping.test.ts | 72 + packages/tools/api-diff/test/Snapshot.test.ts | 121 + packages/tools/api-diff/test/utils.ts | 34 + packages/tools/api-diff/tsconfig.json | 8 + packages/tools/api-diff/vitest.config.ts | 6 + plan.md | 374 ++ pnpm-lock.yaml | 18 + tsconfig.json | 2 + tsconfig.packages.json | 1 + 26 files changed, 7382 insertions(+), 83 deletions(-) create mode 100644 migration/v3-to-v4.json create mode 100644 packages/tools/api-diff/README.md create mode 100644 packages/tools/api-diff/package.json create mode 100644 packages/tools/api-diff/src/Cli.ts create mode 100644 packages/tools/api-diff/src/Diff.ts create mode 100644 packages/tools/api-diff/src/Discovery.ts create mode 100644 packages/tools/api-diff/src/Json.ts create mode 100644 packages/tools/api-diff/src/Mapping.ts create mode 100644 packages/tools/api-diff/src/Model.ts create mode 100644 packages/tools/api-diff/src/Report.ts create mode 100644 packages/tools/api-diff/src/Snapshot.ts create mode 100644 packages/tools/api-diff/src/Worktrees.ts create mode 100644 packages/tools/api-diff/src/bin.ts create mode 100644 packages/tools/api-diff/test/Diff.test.ts create mode 100644 packages/tools/api-diff/test/Discovery.test.ts create mode 100644 packages/tools/api-diff/test/Mapping.test.ts create mode 100644 packages/tools/api-diff/test/Snapshot.test.ts create mode 100644 packages/tools/api-diff/test/utils.ts create mode 100644 packages/tools/api-diff/tsconfig.json create mode 100644 packages/tools/api-diff/vitest.config.ts create mode 100644 plan.md diff --git a/migration/v3-to-v4.json b/migration/v3-to-v4.json new file mode 100644 index 00000000000..446f86061b4 --- /dev/null +++ b/migration/v3-to-v4.json @@ -0,0 +1,3988 @@ +{ + "version": 1, + "modules": [ + { + "from": "@effect/platform/ChannelSchema", + "to": [ + "effect/ChannelSchema" + ], + "status": "moved", + "barrels": [ + "effect" + ] + }, + { + "from": "@effect/platform/FileSystem", + "to": [ + "effect/FileSystem" + ], + "status": "moved", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/JSONSchema", + "to": [ + "effect/JsonSchema" + ], + "status": "moved", + "barrels": [ + "effect" + ] + }, + { + "from": "@effect/platform/Path", + "to": [ + "effect/Path" + ], + "status": "moved", + "barrels": [ + "effect" + ] + }, + { + "from": "@effect/platform/Error", + "to": [ + "effect/PlatformError" + ], + "status": "moved", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/Either", + "to": [ + "effect/Result" + ], + "status": "moved", + "barrels": [ + "effect" + ] + }, + { + "from": "@effect/platform/Terminal", + "to": [ + "effect/Terminal" + ], + "status": "moved", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/TDeferred", + "to": [ + "effect/TxDeferred" + ], + "status": "moved", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/TMap", + "to": [ + "effect/TxHashMap" + ], + "status": "moved", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/TSet", + "to": [ + "effect/TxHashSet" + ], + "status": "moved", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/TPriorityQueue", + "to": [ + "effect/TxPriorityQueue" + ], + "status": "moved", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/TPubSub", + "to": [ + "effect/TxPubSub" + ], + "status": "moved", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/TQueue", + "to": [ + "effect/TxQueue" + ], + "status": "moved", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/TReentrantLock", + "to": [ + "effect/TxReentrantLock" + ], + "status": "moved", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/TRef", + "to": [ + "effect/TxRef" + ], + "status": "moved", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/TSemaphore", + "to": [ + "effect/TxSemaphore" + ], + "status": "moved", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/TSubscriptionRef", + "to": [ + "effect/TxSubscriptionRef" + ], + "status": "moved", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/FastCheck", + "to": [ + "effect/testing/FastCheck" + ], + "status": "moved", + "barrels": [ + "effect/testing" + ] + }, + { + "from": "effect/TestClock", + "to": [ + "effect/testing/TestClock" + ], + "status": "moved", + "barrels": [ + "effect/testing" + ] + }, + { + "from": "@effect/cli/Args", + "to": [ + "effect/unstable/cli/Argument" + ], + "status": "moved", + "barrels": [ + "effect/unstable/cli" + ] + }, + { + "from": "@effect/cli/ValidationError", + "to": [ + "effect/unstable/cli/CliError" + ], + "status": "moved", + "barrels": [ + "effect/unstable/cli" + ] + }, + { + "from": "@effect/cli/Command", + "to": [ + "effect/unstable/cli/Command" + ], + "status": "moved", + "barrels": [ + "effect/unstable/cli" + ] + }, + { + "from": "@effect/cli/CommandDescriptor", + "to": [ + "effect/unstable/cli/Completions" + ], + "status": "moved", + "barrels": [ + "effect/unstable/cli" + ] + }, + { + "from": "@effect/cli/Options", + "to": [ + "effect/unstable/cli/Flag" + ], + "status": "moved", + "barrels": [ + "effect/unstable/cli" + ] + }, + { + "from": "@effect/cli/BuiltInOptions", + "to": [ + "effect/unstable/cli/GlobalFlag" + ], + "status": "moved", + "barrels": [ + "effect/unstable/cli" + ] + }, + { + "from": "@effect/cli/HelpDoc", + "to": [ + "effect/unstable/cli/HelpDoc" + ], + "status": "moved", + "barrels": [ + "effect/unstable/cli" + ] + }, + { + "from": "@effect/cli/Primitive", + "to": [ + "effect/unstable/cli/Primitive" + ], + "status": "moved", + "barrels": [ + "effect/unstable/cli" + ] + }, + { + "from": "@effect/cli/Prompt", + "to": [ + "effect/unstable/cli/Prompt" + ], + "status": "moved", + "barrels": [ + "effect/unstable/cli" + ] + }, + { + "from": "@effect/cluster/ClusterCron", + "to": [ + "effect/unstable/cluster/ClusterCron" + ], + "status": "moved", + "barrels": [ + "effect/unstable/cluster" + ] + }, + { + "from": "@effect/cluster/ClusterError", + "to": [ + "effect/unstable/cluster/ClusterError" + ], + "status": "moved", + "barrels": [ + "effect/unstable/cluster" + ] + }, + { + "from": "@effect/cluster/ClusterMetrics", + "to": [ + "effect/unstable/cluster/ClusterMetrics" + ], + "status": "moved", + "barrels": [ + "effect/unstable/cluster" + ] + }, + { + "from": "@effect/cluster/ClusterSchema", + "to": [ + "effect/unstable/cluster/ClusterSchema" + ], + "status": "moved", + "barrels": [ + "effect/unstable/cluster" + ] + }, + { + "from": "@effect/cluster/ClusterWorkflowEngine", + "to": [ + "effect/unstable/cluster/ClusterWorkflowEngine" + ], + "status": "moved", + "barrels": [ + "effect/unstable/cluster" + ] + }, + { + "from": "@effect/cluster/DeliverAt", + "to": [ + "effect/unstable/cluster/DeliverAt" + ], + "status": "moved", + "barrels": [ + "effect/unstable/cluster" + ] + }, + { + "from": "@effect/cluster/Entity", + "to": [ + "effect/unstable/cluster/Entity" + ], + "status": "moved", + "barrels": [ + "effect/unstable/cluster" + ] + }, + { + "from": "@effect/cluster/EntityAddress", + "to": [ + "effect/unstable/cluster/EntityAddress" + ], + "status": "moved", + "barrels": [ + "effect/unstable/cluster" + ] + }, + { + "from": "@effect/cluster/EntityId", + "to": [ + "effect/unstable/cluster/EntityId" + ], + "status": "moved", + "barrels": [ + "effect/unstable/cluster" + ] + }, + { + "from": "@effect/cluster/EntityProxy", + "to": [ + "effect/unstable/cluster/EntityProxy" + ], + "status": "moved", + "barrels": [ + "effect/unstable/cluster" + ] + }, + { + "from": "@effect/cluster/EntityProxyServer", + "to": [ + "effect/unstable/cluster/EntityProxyServer" + ], + "status": "moved", + "barrels": [ + "effect/unstable/cluster" + ] + }, + { + "from": "@effect/cluster/EntityResource", + "to": [ + "effect/unstable/cluster/EntityResource" + ], + "status": "moved", + "barrels": [ + "effect/unstable/cluster" + ] + }, + { + "from": "@effect/cluster/EntityType", + "to": [ + "effect/unstable/cluster/EntityType" + ], + "status": "moved", + "barrels": [ + "effect/unstable/cluster" + ] + }, + { + "from": "@effect/cluster/Envelope", + "to": [ + "effect/unstable/cluster/Envelope" + ], + "status": "moved", + "barrels": [ + "effect/unstable/cluster" + ] + }, + { + "from": "@effect/cluster/HttpRunner", + "to": [ + "effect/unstable/cluster/HttpRunner" + ], + "status": "moved", + "barrels": [ + "effect/unstable/cluster" + ] + }, + { + "from": "@effect/cluster/K8sHttpClient", + "to": [ + "effect/unstable/cluster/K8sHttpClient" + ], + "status": "moved", + "barrels": [ + "effect/unstable/cluster" + ] + }, + { + "from": "@effect/cluster/MachineId", + "to": [ + "effect/unstable/cluster/MachineId" + ], + "status": "moved", + "barrels": [ + "effect/unstable/cluster" + ] + }, + { + "from": "@effect/cluster/Message", + "to": [ + "effect/unstable/cluster/Message" + ], + "status": "moved", + "barrels": [ + "effect/unstable/cluster" + ] + }, + { + "from": "@effect/cluster/MessageStorage", + "to": [ + "effect/unstable/cluster/MessageStorage" + ], + "status": "moved", + "barrels": [ + "effect/unstable/cluster" + ] + }, + { + "from": "@effect/cluster/Reply", + "to": [ + "effect/unstable/cluster/Reply" + ], + "status": "moved", + "barrels": [ + "effect/unstable/cluster" + ] + }, + { + "from": "@effect/cluster/Runner", + "to": [ + "effect/unstable/cluster/Runner" + ], + "status": "moved", + "barrels": [ + "effect/unstable/cluster" + ] + }, + { + "from": "@effect/cluster/RunnerAddress", + "to": [ + "effect/unstable/cluster/RunnerAddress" + ], + "status": "moved", + "barrels": [ + "effect/unstable/cluster" + ] + }, + { + "from": "@effect/cluster/RunnerHealth", + "to": [ + "effect/unstable/cluster/RunnerHealth" + ], + "status": "moved", + "barrels": [ + "effect/unstable/cluster" + ] + }, + { + "from": "@effect/cluster/RunnerServer", + "to": [ + "effect/unstable/cluster/RunnerServer" + ], + "status": "moved", + "barrels": [ + "effect/unstable/cluster" + ] + }, + { + "from": "@effect/cluster/RunnerStorage", + "to": [ + "effect/unstable/cluster/RunnerStorage" + ], + "status": "moved", + "barrels": [ + "effect/unstable/cluster" + ] + }, + { + "from": "@effect/cluster/Runners", + "to": [ + "effect/unstable/cluster/Runners" + ], + "status": "moved", + "barrels": [ + "effect/unstable/cluster" + ] + }, + { + "from": "@effect/cluster/ShardId", + "to": [ + "effect/unstable/cluster/ShardId" + ], + "status": "moved", + "barrels": [ + "effect/unstable/cluster" + ] + }, + { + "from": "@effect/cluster/Sharding", + "to": [ + "effect/unstable/cluster/Sharding" + ], + "status": "moved", + "barrels": [ + "effect/unstable/cluster" + ] + }, + { + "from": "@effect/cluster/ShardingConfig", + "to": [ + "effect/unstable/cluster/ShardingConfig" + ], + "status": "moved", + "barrels": [ + "effect/unstable/cluster" + ] + }, + { + "from": "@effect/cluster/ShardingRegistrationEvent", + "to": [ + "effect/unstable/cluster/ShardingRegistrationEvent" + ], + "status": "moved", + "barrels": [ + "effect/unstable/cluster" + ] + }, + { + "from": "@effect/cluster/SingleRunner", + "to": [ + "effect/unstable/cluster/SingleRunner" + ], + "status": "moved", + "barrels": [ + "effect/unstable/cluster" + ] + }, + { + "from": "@effect/cluster/Singleton", + "to": [ + "effect/unstable/cluster/Singleton" + ], + "status": "moved", + "barrels": [ + "effect/unstable/cluster" + ] + }, + { + "from": "@effect/cluster/SingletonAddress", + "to": [ + "effect/unstable/cluster/SingletonAddress" + ], + "status": "moved", + "barrels": [ + "effect/unstable/cluster" + ] + }, + { + "from": "@effect/cluster/Snowflake", + "to": [ + "effect/unstable/cluster/Snowflake" + ], + "status": "moved", + "barrels": [ + "effect/unstable/cluster" + ] + }, + { + "from": "@effect/cluster/SocketRunner", + "to": [ + "effect/unstable/cluster/SocketRunner" + ], + "status": "moved", + "barrels": [ + "effect/unstable/cluster" + ] + }, + { + "from": "@effect/cluster/SqlMessageStorage", + "to": [ + "effect/unstable/cluster/SqlMessageStorage" + ], + "status": "moved", + "barrels": [ + "effect/unstable/cluster" + ] + }, + { + "from": "@effect/cluster/SqlRunnerStorage", + "to": [ + "effect/unstable/cluster/SqlRunnerStorage" + ], + "status": "moved", + "barrels": [ + "effect/unstable/cluster" + ] + }, + { + "from": "@effect/cluster/TestRunner", + "to": [ + "effect/unstable/cluster/TestRunner" + ], + "status": "moved", + "barrels": [ + "effect/unstable/cluster" + ] + }, + { + "from": "@effect/experimental/DevTools", + "to": [ + "effect/unstable/devtools/DevTools" + ], + "status": "moved", + "barrels": [ + "effect/unstable/devtools" + ] + }, + { + "from": "@effect/experimental/DevTools/Client", + "to": [ + "effect/unstable/devtools/DevToolsClient" + ], + "status": "moved", + "barrels": [ + "effect/unstable/devtools" + ] + }, + { + "from": "@effect/experimental/DevTools/Domain", + "to": [ + "effect/unstable/devtools/DevToolsSchema" + ], + "status": "moved", + "barrels": [ + "effect/unstable/devtools" + ] + }, + { + "from": "@effect/experimental/DevTools/Server", + "to": [ + "effect/unstable/devtools/DevToolsServer" + ], + "status": "moved", + "barrels": [ + "effect/unstable/devtools" + ] + }, + { + "from": "@effect/platform/MsgPack", + "to": [ + "effect/unstable/encoding/Msgpack" + ], + "status": "moved", + "barrels": [ + "effect/unstable/encoding" + ] + }, + { + "from": "@effect/platform/Ndjson", + "to": [ + "effect/unstable/encoding/Ndjson" + ], + "status": "moved", + "barrels": [ + "effect/unstable/encoding" + ] + }, + { + "from": "@effect/experimental/Sse", + "to": [ + "effect/unstable/encoding/Sse" + ], + "status": "moved", + "barrels": [ + "effect/unstable/encoding" + ] + }, + { + "from": "@effect/ai/AiError", + "to": [ + "effect/unstable/ai/AiError" + ], + "status": "moved", + "barrels": [ + "effect/unstable/ai" + ] + }, + { + "from": "@effect/ai/Chat", + "to": [ + "effect/unstable/ai/Chat" + ], + "status": "moved", + "barrels": [ + "effect/unstable/ai" + ] + }, + { + "from": "@effect/ai/EmbeddingModel", + "to": [ + "effect/unstable/ai/EmbeddingModel" + ], + "status": "moved", + "barrels": [ + "effect/unstable/ai" + ] + }, + { + "from": "@effect/ai/IdGenerator", + "to": [ + "effect/unstable/ai/IdGenerator" + ], + "status": "moved", + "barrels": [ + "effect/unstable/ai" + ] + }, + { + "from": "@effect/ai/LanguageModel", + "to": [ + "effect/unstable/ai/LanguageModel" + ], + "status": "moved", + "barrels": [ + "effect/unstable/ai" + ] + }, + { + "from": "@effect/ai/McpSchema", + "to": [ + "effect/unstable/ai/McpSchema" + ], + "status": "moved", + "barrels": [ + "effect/unstable/ai" + ] + }, + { + "from": "@effect/ai/McpServer", + "to": [ + "effect/unstable/ai/McpServer" + ], + "status": "moved", + "barrels": [ + "effect/unstable/ai" + ] + }, + { + "from": "@effect/ai/Model", + "to": [ + "effect/unstable/ai/Model" + ], + "status": "moved", + "barrels": [ + "effect/unstable/ai" + ] + }, + { + "from": "@effect/ai/Prompt", + "to": [ + "effect/unstable/ai/Prompt" + ], + "status": "moved", + "barrels": [ + "effect/unstable/ai" + ] + }, + { + "from": "@effect/ai/Response", + "to": [ + "effect/unstable/ai/Response" + ], + "status": "moved", + "barrels": [ + "effect/unstable/ai" + ] + }, + { + "from": "@effect/ai/Telemetry", + "to": [ + "effect/unstable/ai/Telemetry" + ], + "status": "moved", + "barrels": [ + "effect/unstable/ai" + ] + }, + { + "from": "@effect/ai/Tokenizer", + "to": [ + "effect/unstable/ai/Tokenizer" + ], + "status": "moved", + "barrels": [ + "effect/unstable/ai" + ] + }, + { + "from": "@effect/ai/Tool", + "to": [ + "effect/unstable/ai/Tool" + ], + "status": "moved", + "barrels": [ + "effect/unstable/ai" + ] + }, + { + "from": "@effect/ai/Toolkit", + "to": [ + "effect/unstable/ai/Toolkit" + ], + "status": "moved", + "barrels": [ + "effect/unstable/ai" + ] + }, + { + "from": "@effect/experimental/Event", + "to": [ + "effect/unstable/eventlog/Event" + ], + "status": "moved", + "barrels": [ + "effect/unstable/eventlog" + ] + }, + { + "from": "@effect/experimental/EventGroup", + "to": [ + "effect/unstable/eventlog/EventGroup" + ], + "status": "moved", + "barrels": [ + "effect/unstable/eventlog" + ] + }, + { + "from": "@effect/experimental/EventJournal", + "to": [ + "effect/unstable/eventlog/EventJournal" + ], + "status": "moved", + "barrels": [ + "effect/unstable/eventlog" + ] + }, + { + "from": "@effect/experimental/EventLog", + "to": [ + "effect/unstable/eventlog/EventLog" + ], + "status": "moved", + "barrels": [ + "effect/unstable/eventlog" + ] + }, + { + "from": "@effect/experimental/EventLogEncryption", + "to": [ + "effect/unstable/eventlog/EventLogEncryption" + ], + "status": "moved", + "barrels": [ + "effect/unstable/eventlog" + ] + }, + { + "from": "@effect/experimental/EventLogRemote", + "to": [ + "effect/unstable/eventlog/EventLogMessage", + "effect/unstable/eventlog/EventLogRemote" + ], + "status": "split", + "barrels": [ + "effect/unstable/eventlog" + ] + }, + { + "from": "@effect/experimental/EventLogServer", + "to": [ + "effect/unstable/eventlog/EventLogServer", + "effect/unstable/eventlog/EventLogServerEncrypted" + ], + "status": "split", + "barrels": [ + "effect/unstable/eventlog" + ] + }, + { + "from": "@effect/sql/SqlEventJournal", + "to": [ + "effect/unstable/eventlog/SqlEventJournal" + ], + "status": "moved", + "barrels": [ + "effect/unstable/eventlog" + ] + }, + { + "from": "@effect/sql/SqlEventLogServer", + "to": [ + "effect/unstable/eventlog/SqlEventLogServerEncrypted" + ], + "status": "moved", + "barrels": [ + "effect/unstable/eventlog" + ] + }, + { + "from": "@effect/platform/Cookies", + "to": [ + "effect/unstable/http/Cookies" + ], + "status": "moved", + "barrels": [ + "effect/unstable/http" + ] + }, + { + "from": "@effect/platform/Etag", + "to": [ + "effect/unstable/http/Etag" + ], + "status": "moved", + "barrels": [ + "effect/unstable/http" + ] + }, + { + "from": "@effect/platform/FetchHttpClient", + "to": [ + "effect/unstable/http/FetchHttpClient" + ], + "status": "moved", + "barrels": [ + "effect/unstable/http" + ] + }, + { + "from": "@effect/platform/Headers", + "to": [ + "effect/unstable/http/Headers" + ], + "status": "moved", + "barrels": [ + "effect/unstable/http" + ] + }, + { + "from": "@effect/platform/HttpBody", + "to": [ + "effect/unstable/http/HttpBody" + ], + "status": "moved", + "barrels": [ + "effect/unstable/http" + ] + }, + { + "from": "@effect/platform/HttpClient", + "to": [ + "effect/unstable/http/HttpClient" + ], + "status": "moved", + "barrels": [ + "effect/unstable/http" + ] + }, + { + "from": "@effect/platform/HttpClientError", + "to": [ + "effect/unstable/http/HttpClientError" + ], + "status": "moved", + "barrels": [ + "effect/unstable/http" + ] + }, + { + "from": "@effect/platform/HttpClientRequest", + "to": [ + "effect/unstable/http/HttpClientRequest" + ], + "status": "moved", + "barrels": [ + "effect/unstable/http" + ] + }, + { + "from": "@effect/platform/HttpClientResponse", + "to": [ + "effect/unstable/http/HttpClientResponse" + ], + "status": "moved", + "barrels": [ + "effect/unstable/http" + ] + }, + { + "from": "@effect/platform/HttpApp", + "to": [ + "effect/unstable/http/HttpEffect" + ], + "status": "moved", + "barrels": [ + "effect/unstable/http" + ] + }, + { + "from": "@effect/platform/HttpIncomingMessage", + "to": [ + "effect/unstable/http/HttpIncomingMessage" + ], + "status": "moved", + "barrels": [ + "effect/unstable/http" + ] + }, + { + "from": "@effect/platform/HttpMethod", + "to": [ + "effect/unstable/http/HttpMethod" + ], + "status": "moved", + "barrels": [ + "effect/unstable/http" + ] + }, + { + "from": "@effect/platform/HttpMiddleware", + "to": [ + "effect/unstable/http/HttpMiddleware" + ], + "status": "moved", + "barrels": [ + "effect/unstable/http" + ] + }, + { + "from": "@effect/platform/HttpPlatform", + "to": [ + "effect/unstable/http/HttpPlatform" + ], + "status": "moved", + "barrels": [ + "effect/unstable/http" + ] + }, + { + "from": "@effect/platform/HttpRouter", + "to": [ + "effect/unstable/http/HttpRouter" + ], + "status": "moved", + "barrels": [ + "effect/unstable/http" + ] + }, + { + "from": "@effect/platform/HttpServer", + "to": [ + "effect/unstable/http/HttpServer" + ], + "status": "moved", + "barrels": [ + "effect/unstable/http" + ] + }, + { + "from": "@effect/platform/HttpServerError", + "to": [ + "effect/unstable/http/HttpServerError" + ], + "status": "moved", + "barrels": [ + "effect/unstable/http" + ] + }, + { + "from": "@effect/platform/HttpServerRequest", + "to": [ + "effect/unstable/http/HttpServerRequest" + ], + "status": "moved", + "barrels": [ + "effect/unstable/http" + ] + }, + { + "from": "@effect/platform/HttpServerRespondable", + "to": [ + "effect/unstable/http/HttpServerRespondable" + ], + "status": "moved", + "barrels": [ + "effect/unstable/http" + ] + }, + { + "from": "@effect/platform/HttpServerResponse", + "to": [ + "effect/unstable/http/HttpServerResponse" + ], + "status": "moved", + "barrels": [ + "effect/unstable/http" + ] + }, + { + "from": "@effect/platform/HttpTraceContext", + "to": [ + "effect/unstable/http/HttpTraceContext" + ], + "status": "moved", + "barrels": [ + "effect/unstable/http" + ] + }, + { + "from": "@effect/platform/Multipart", + "to": [ + "effect/unstable/http/Multipart" + ], + "status": "moved", + "barrels": [ + "effect/unstable/http" + ] + }, + { + "from": "@effect/platform/Template", + "to": [ + "effect/unstable/http/Template" + ], + "status": "moved", + "barrels": [ + "effect/unstable/http" + ] + }, + { + "from": "@effect/platform/Url", + "to": [ + "effect/unstable/http/Url" + ], + "status": "moved", + "barrels": [ + "effect/unstable/http" + ] + }, + { + "from": "@effect/platform/UrlParams", + "to": [ + "effect/unstable/http/UrlParams" + ], + "status": "moved", + "barrels": [ + "effect/unstable/http" + ] + }, + { + "from": "@effect/platform/HttpApi", + "to": [ + "effect/unstable/httpapi/HttpApi" + ], + "status": "moved", + "barrels": [ + "effect/unstable/httpapi" + ] + }, + { + "from": "@effect/platform/HttpApiBuilder", + "to": [ + "effect/unstable/httpapi/HttpApiBuilder" + ], + "status": "moved", + "barrels": [ + "effect/unstable/httpapi" + ] + }, + { + "from": "@effect/platform/HttpApiClient", + "to": [ + "effect/unstable/httpapi/HttpApiClient" + ], + "status": "moved", + "barrels": [ + "effect/unstable/httpapi" + ] + }, + { + "from": "@effect/platform/HttpApiEndpoint", + "to": [ + "effect/unstable/httpapi/HttpApiEndpoint" + ], + "status": "moved", + "barrels": [ + "effect/unstable/httpapi" + ] + }, + { + "from": "@effect/platform/HttpApiError", + "to": [ + "effect/unstable/httpapi/HttpApiError" + ], + "status": "moved", + "barrels": [ + "effect/unstable/httpapi" + ] + }, + { + "from": "@effect/platform/HttpApiGroup", + "to": [ + "effect/unstable/httpapi/HttpApiGroup" + ], + "status": "moved", + "barrels": [ + "effect/unstable/httpapi" + ] + }, + { + "from": "@effect/platform/HttpApiMiddleware", + "to": [ + "effect/unstable/httpapi/HttpApiMiddleware" + ], + "status": "moved", + "barrels": [ + "effect/unstable/httpapi" + ] + }, + { + "from": "@effect/platform/HttpApiScalar", + "to": [ + "effect/unstable/httpapi/HttpApiScalar" + ], + "status": "moved", + "barrels": [ + "effect/unstable/httpapi" + ] + }, + { + "from": "@effect/platform/HttpApiSchema", + "to": [ + "effect/unstable/httpapi/HttpApiSchema" + ], + "status": "moved", + "barrels": [ + "effect/unstable/httpapi" + ] + }, + { + "from": "@effect/platform/HttpApiSecurity", + "to": [ + "effect/unstable/httpapi/HttpApiSecurity" + ], + "status": "moved", + "barrels": [ + "effect/unstable/httpapi" + ] + }, + { + "from": "@effect/platform/HttpApiSwagger", + "to": [ + "effect/unstable/httpapi/HttpApiSwagger" + ], + "status": "moved", + "barrels": [ + "effect/unstable/httpapi" + ] + }, + { + "from": "@effect/platform/OpenApi", + "to": [ + "effect/unstable/httpapi/OpenApi" + ], + "status": "moved", + "barrels": [ + "effect/unstable/httpapi" + ] + }, + { + "from": "@effect/opentelemetry/Otlp", + "to": [ + "effect/unstable/observability/Otlp" + ], + "status": "moved", + "barrels": [ + "effect/unstable/observability" + ] + }, + { + "to": [ + "effect/unstable/observability/OtlpExporter" + ], + "status": "added", + "barrels": [ + "effect/unstable/observability" + ], + "note": "The similarly named v3 source file was excluded by the package exports map and was not consumer-visible." + }, + { + "from": "@effect/opentelemetry/OtlpLogger", + "to": [ + "effect/unstable/observability/OtlpLogger" + ], + "status": "moved", + "barrels": [ + "effect/unstable/observability" + ] + }, + { + "from": "@effect/opentelemetry/OtlpMetrics", + "to": [ + "effect/unstable/observability/OtlpMetrics" + ], + "status": "moved", + "barrels": [ + "effect/unstable/observability" + ] + }, + { + "from": "@effect/opentelemetry/OtlpResource", + "to": [ + "effect/unstable/observability/OtlpResource" + ], + "status": "moved", + "barrels": [ + "effect/unstable/observability" + ] + }, + { + "from": "@effect/opentelemetry/OtlpSerialization", + "to": [ + "effect/unstable/observability/OtlpSerialization" + ], + "status": "moved", + "barrels": [ + "effect/unstable/observability" + ] + }, + { + "from": "@effect/opentelemetry/OtlpTracer", + "to": [ + "effect/unstable/observability/OtlpTracer" + ], + "status": "moved", + "barrels": [ + "effect/unstable/observability" + ] + }, + { + "from": "@effect/platform/KeyValueStore", + "to": [ + "effect/unstable/persistence/KeyValueStore" + ], + "status": "moved", + "barrels": [ + "effect/unstable/persistence" + ] + }, + { + "from": "@effect/experimental/Persistence", + "to": [ + "effect/unstable/persistence/Persistable", + "effect/unstable/persistence/Persistence" + ], + "status": "split", + "barrels": [ + "effect/unstable/persistence" + ] + }, + { + "from": "@effect/experimental/PersistedCache", + "to": [ + "effect/unstable/persistence/PersistedCache" + ], + "status": "moved", + "barrels": [ + "effect/unstable/persistence" + ] + }, + { + "from": "@effect/experimental/PersistedQueue", + "to": [ + "effect/unstable/persistence/PersistedQueue" + ], + "status": "moved", + "barrels": [ + "effect/unstable/persistence" + ] + }, + { + "from": "@effect/experimental/RateLimiter", + "to": [ + "effect/unstable/persistence/RateLimiter" + ], + "status": "moved", + "barrels": [ + "effect/unstable/persistence" + ] + }, + { + "from": "@effect/platform/Command", + "to": [ + "effect/unstable/process/ChildProcess" + ], + "status": "moved", + "barrels": [ + "effect/unstable/process" + ] + }, + { + "from": "@effect/platform/CommandExecutor", + "to": [ + "effect/unstable/process/ChildProcessSpawner" + ], + "status": "moved", + "barrels": [ + "effect/unstable/process" + ] + }, + { + "from": "@effect/experimental/Reactivity", + "to": [ + "effect/unstable/reactivity/Reactivity" + ], + "status": "moved", + "barrels": [ + "effect/unstable/reactivity" + ] + }, + { + "from": "@effect/rpc/Rpc", + "to": [ + "effect/unstable/rpc/Rpc" + ], + "status": "moved", + "barrels": [ + "effect/unstable/rpc" + ] + }, + { + "from": "@effect/rpc/RpcClient", + "to": [ + "effect/unstable/rpc/RpcClient" + ], + "status": "moved", + "barrels": [ + "effect/unstable/rpc" + ] + }, + { + "from": "@effect/rpc/RpcClientError", + "to": [ + "effect/unstable/rpc/RpcClientError" + ], + "status": "moved", + "barrels": [ + "effect/unstable/rpc" + ] + }, + { + "from": "@effect/rpc/RpcGroup", + "to": [ + "effect/unstable/rpc/RpcGroup" + ], + "status": "moved", + "barrels": [ + "effect/unstable/rpc" + ] + }, + { + "from": "@effect/rpc/RpcMessage", + "to": [ + "effect/unstable/rpc/RpcMessage" + ], + "status": "moved", + "barrels": [ + "effect/unstable/rpc" + ] + }, + { + "from": "@effect/rpc/RpcMiddleware", + "to": [ + "effect/unstable/rpc/RpcMiddleware" + ], + "status": "moved", + "barrels": [ + "effect/unstable/rpc" + ] + }, + { + "from": "@effect/rpc/RpcSchema", + "to": [ + "effect/unstable/rpc/RpcSchema" + ], + "status": "moved", + "barrels": [ + "effect/unstable/rpc" + ] + }, + { + "from": "@effect/rpc/RpcSerialization", + "to": [ + "effect/unstable/rpc/RpcSerialization" + ], + "status": "moved", + "barrels": [ + "effect/unstable/rpc" + ] + }, + { + "from": "@effect/rpc/RpcServer", + "to": [ + "effect/unstable/rpc/RpcServer" + ], + "status": "moved", + "barrels": [ + "effect/unstable/rpc" + ] + }, + { + "from": "@effect/rpc/RpcTest", + "to": [ + "effect/unstable/rpc/RpcTest" + ], + "status": "moved", + "barrels": [ + "effect/unstable/rpc" + ] + }, + { + "from": "@effect/rpc/RpcWorker", + "to": [ + "effect/unstable/rpc/RpcWorker" + ], + "status": "moved", + "barrels": [ + "effect/unstable/rpc" + ] + }, + { + "from": "@effect/sql/Model", + "to": [ + "effect/unstable/schema/Model", + "effect/unstable/sql/SqlModel" + ], + "status": "split", + "barrels": [ + "effect/unstable/schema", + "effect/unstable/sql" + ] + }, + { + "from": "@effect/experimental/VariantSchema", + "to": [ + "effect/unstable/schema/VariantSchema" + ], + "status": "moved", + "barrels": [ + "effect/unstable/schema" + ] + }, + { + "from": "@effect/platform/Socket", + "to": [ + "effect/unstable/socket/Socket" + ], + "status": "moved", + "barrels": [ + "effect/unstable/socket" + ] + }, + { + "from": "@effect/platform/SocketServer", + "to": [ + "effect/unstable/socket/SocketServer" + ], + "status": "moved", + "barrels": [ + "effect/unstable/socket" + ] + }, + { + "from": "@effect/sql/Migrator", + "to": [ + "effect/unstable/sql/Migrator" + ], + "status": "moved", + "barrels": [ + "effect/unstable/sql" + ] + }, + { + "from": "@effect/sql/SqlClient", + "to": [ + "effect/unstable/sql/SqlClient" + ], + "status": "moved", + "barrels": [ + "effect/unstable/sql" + ] + }, + { + "from": "@effect/sql/SqlConnection", + "to": [ + "effect/unstable/sql/SqlConnection" + ], + "status": "moved", + "barrels": [ + "effect/unstable/sql" + ] + }, + { + "from": "@effect/sql/SqlError", + "to": [ + "effect/unstable/sql/SqlError" + ], + "status": "moved", + "barrels": [ + "effect/unstable/sql" + ] + }, + { + "from": "@effect/sql/SqlResolver", + "to": [ + "effect/unstable/sql/SqlResolver" + ], + "status": "moved", + "barrels": [ + "effect/unstable/sql" + ] + }, + { + "from": "@effect/sql/SqlSchema", + "to": [ + "effect/unstable/sql/SqlSchema" + ], + "status": "moved", + "barrels": [ + "effect/unstable/sql" + ] + }, + { + "from": "@effect/sql/SqlStream", + "to": [ + "effect/unstable/sql/SqlStream" + ], + "status": "moved", + "barrels": [ + "effect/unstable/sql" + ] + }, + { + "from": "@effect/sql/Statement", + "to": [ + "effect/unstable/sql/Statement" + ], + "status": "moved", + "barrels": [ + "effect/unstable/sql" + ] + }, + { + "from": "@effect/platform/Transferable", + "to": [ + "effect/unstable/workers/Transferable" + ], + "status": "moved", + "barrels": [ + "effect/unstable/workers" + ] + }, + { + "from": "@effect/platform/Worker", + "to": [ + "effect/unstable/workers/Worker" + ], + "status": "moved", + "barrels": [ + "effect/unstable/workers" + ] + }, + { + "from": "@effect/platform/WorkerError", + "to": [ + "effect/unstable/workers/WorkerError" + ], + "status": "moved", + "barrels": [ + "effect/unstable/workers" + ] + }, + { + "from": "@effect/platform/WorkerRunner", + "to": [ + "effect/unstable/workers/WorkerRunner" + ], + "status": "moved", + "barrels": [ + "effect/unstable/workers" + ] + }, + { + "from": "@effect/workflow/Activity", + "to": [ + "effect/unstable/workflow/Activity" + ], + "status": "moved", + "barrels": [ + "effect/unstable/workflow" + ] + }, + { + "from": "@effect/workflow/DurableClock", + "to": [ + "effect/unstable/workflow/DurableClock" + ], + "status": "moved", + "barrels": [ + "effect/unstable/workflow" + ] + }, + { + "from": "@effect/workflow/DurableDeferred", + "to": [ + "effect/unstable/workflow/DurableDeferred" + ], + "status": "moved", + "barrels": [ + "effect/unstable/workflow" + ] + }, + { + "from": "@effect/workflow/DurableQueue", + "to": [ + "effect/unstable/workflow/DurableQueue" + ], + "status": "moved", + "barrels": [ + "effect/unstable/workflow" + ] + }, + { + "from": "@effect/workflow/Workflow", + "to": [ + "effect/unstable/workflow/Workflow" + ], + "status": "moved", + "barrels": [ + "effect/unstable/workflow" + ] + }, + { + "from": "@effect/workflow/WorkflowEngine", + "to": [ + "effect/unstable/workflow/WorkflowEngine" + ], + "status": "moved", + "barrels": [ + "effect/unstable/workflow" + ] + }, + { + "from": "@effect/workflow/WorkflowProxy", + "to": [ + "effect/unstable/workflow/WorkflowProxy" + ], + "status": "moved", + "barrels": [ + "effect/unstable/workflow" + ] + }, + { + "from": "@effect/workflow/WorkflowProxyServer", + "to": [ + "effect/unstable/workflow/WorkflowProxyServer" + ], + "status": "moved", + "barrels": [ + "effect/unstable/workflow" + ] + }, + { + "from": "effect/Array", + "to": [ + "effect/Array" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/BigDecimal", + "to": [ + "effect/BigDecimal" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/BigInt", + "to": [ + "effect/BigInt" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/Boolean", + "to": [ + "effect/Boolean" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/Brand", + "to": [ + "effect/Brand" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/Cache", + "to": [ + "effect/Cache" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/Cause", + "to": [ + "effect/Cause" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/Channel", + "to": [ + "effect/Channel" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/Chunk", + "to": [ + "effect/Chunk" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/Clock", + "to": [ + "effect/Clock" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "@effect/typeclass/Semigroup", + "to": [ + "effect/Combiner" + ], + "status": "moved", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/Config", + "to": [ + "effect/Config" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/ConfigProvider", + "to": [ + "effect/ConfigProvider" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/Console", + "to": [ + "effect/Console" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/Context", + "to": [ + "effect/Context" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/Cron", + "to": [ + "effect/Cron" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/Data", + "to": [ + "effect/Data" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/DateTime", + "to": [ + "effect/DateTime" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/Deferred", + "to": [ + "effect/Deferred" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/Differ", + "to": [ + "effect/Differ" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/Duration", + "to": [ + "effect/Duration" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/Effect", + "to": [ + "effect/Effect" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/Effectable", + "to": [ + "effect/Effectable" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/Encoding", + "to": [ + "effect/Encoding" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/Equal", + "to": [ + "effect/Equal" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/Equivalence", + "to": [ + "effect/Equivalence" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/ExecutionPlan", + "to": [ + "effect/ExecutionPlan" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/Exit", + "to": [ + "effect/Exit" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/Fiber", + "to": [ + "effect/Fiber" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/FiberHandle", + "to": [ + "effect/FiberHandle" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/FiberMap", + "to": [ + "effect/FiberMap" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/FiberSet", + "to": [ + "effect/FiberSet" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/Inspectable", + "to": [ + "effect/Formatter", + "effect/Inspectable", + "effect/Redactable" + ], + "status": "split", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/Function", + "to": [ + "effect/Function" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/Graph", + "to": [ + "effect/Graph" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/HKT", + "to": [ + "effect/HKT" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/Hash", + "to": [ + "effect/Hash" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/HashMap", + "to": [ + "effect/HashMap" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/HashRing", + "to": [ + "effect/HashRing" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/HashSet", + "to": [ + "effect/HashSet" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/Iterable", + "to": [ + "effect/Iterable" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/Layer", + "to": [ + "effect/Layer" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/LayerMap", + "to": [ + "effect/LayerMap" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/LogLevel", + "to": [ + "effect/LogLevel" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/Logger", + "to": [ + "effect/Logger" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/ManagedRuntime", + "to": [ + "effect/ManagedRuntime" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/Match", + "to": [ + "effect/Match" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/Metric", + "to": [ + "effect/Metric" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/MutableHashMap", + "to": [ + "effect/MutableHashMap" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/MutableHashSet", + "to": [ + "effect/MutableHashSet" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/MutableList", + "to": [ + "effect/MutableList" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/MutableRef", + "to": [ + "effect/MutableRef" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/NonEmptyIterable", + "to": [ + "effect/NonEmptyIterable" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/Number", + "to": [ + "effect/Number" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/Option", + "to": [ + "effect/Option" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/Order", + "to": [ + "effect/Order" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/Ordering", + "to": [ + "effect/Ordering" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/PartitionedSemaphore", + "to": [ + "effect/PartitionedSemaphore" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/Pipeable", + "to": [ + "effect/Pipeable" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/Pool", + "to": [ + "effect/Pool" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/Predicate", + "to": [ + "effect/Predicate" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/PrimaryKey", + "to": [ + "effect/PrimaryKey" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/PubSub", + "to": [ + "effect/PubSub" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/Queue", + "to": [ + "effect/Queue" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/Random", + "to": [ + "effect/Random" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/RcMap", + "to": [ + "effect/RcMap" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/RcRef", + "to": [ + "effect/RcRef" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/Record", + "to": [ + "effect/Record" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/Redacted", + "to": [ + "effect/Redacted" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "@effect/typeclass/Monoid", + "to": [ + "effect/Reducer" + ], + "status": "moved", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/Ref", + "to": [ + "effect/Ref" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/FiberRef", + "to": [ + "effect/References" + ], + "status": "moved", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/RegExp", + "to": [ + "effect/RegExp" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/Request", + "to": [ + "effect/Request" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/RequestResolver", + "to": [ + "effect/RequestResolver" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/Resource", + "to": [ + "effect/Resource" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/Runtime", + "to": [ + "effect/Runtime" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/Schedule", + "to": [ + "effect/Schedule" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/Scheduler", + "to": [ + "effect/Scheduler" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/Schema", + "to": [ + "effect/Schema", + "effect/SchemaTransformation" + ], + "status": "split", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/SchemaAST", + "to": [ + "effect/SchemaAST" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/ParseResult", + "to": [ + "effect/SchemaIssue", + "effect/SchemaParser" + ], + "status": "split", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/Scope", + "to": [ + "effect/Scope" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/ScopedCache", + "to": [ + "effect/ScopedCache" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/ScopedRef", + "to": [ + "effect/ScopedRef" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/Sink", + "to": [ + "effect/Sink" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/Stream", + "to": [ + "effect/Stream" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/String", + "to": [ + "effect/String" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/Struct", + "to": [ + "effect/Struct" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/SubscriptionRef", + "to": [ + "effect/SubscriptionRef" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/Symbol", + "to": [ + "effect/Symbol" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/SynchronizedRef", + "to": [ + "effect/SynchronizedRef" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/Take", + "to": [ + "effect/Take" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/Tracer", + "to": [ + "effect/Tracer" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/Trie", + "to": [ + "effect/Trie" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/Tuple", + "to": [ + "effect/Tuple" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/Types", + "to": [ + "effect/Types" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/Unify", + "to": [ + "effect/Unify" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "from": "effect/Utils", + "to": [ + "effect/Utils" + ], + "status": "unchanged", + "barrels": [ + "effect" + ] + }, + { + "to": [ + "effect/ErrorReporter" + ], + "status": "added", + "barrels": [ + "effect" + ] + }, + { + "to": [ + "effect/Filter" + ], + "status": "added", + "barrels": [ + "effect" + ] + }, + { + "to": [ + "effect/JsonPatch" + ], + "status": "added", + "barrels": [ + "effect" + ] + }, + { + "to": [ + "effect/JsonPointer" + ], + "status": "added", + "barrels": [ + "effect" + ] + }, + { + "to": [ + "effect/Latch" + ], + "status": "added", + "barrels": [ + "effect" + ] + }, + { + "to": [ + "effect/Newtype" + ], + "status": "added", + "barrels": [ + "effect" + ] + }, + { + "to": [ + "effect/Optic" + ], + "status": "added", + "barrels": [ + "effect" + ] + }, + { + "to": [ + "effect/Pull" + ], + "status": "added", + "barrels": [ + "effect" + ] + }, + { + "to": [ + "effect/SchemaGetter" + ], + "status": "added", + "barrels": [ + "effect" + ] + }, + { + "to": [ + "effect/SchemaRepresentation" + ], + "status": "added", + "barrels": [ + "effect" + ] + }, + { + "to": [ + "effect/SchemaUtils" + ], + "status": "added", + "barrels": [ + "effect" + ] + }, + { + "to": [ + "effect/Semaphore" + ], + "status": "added", + "barrels": [ + "effect" + ] + }, + { + "to": [ + "effect/Stdio" + ], + "status": "added", + "barrels": [ + "effect" + ] + }, + { + "to": [ + "effect/TxChunk" + ], + "status": "added", + "barrels": [ + "effect" + ] + }, + { + "to": [ + "effect/UndefinedOr" + ], + "status": "added", + "barrels": [ + "effect" + ] + }, + { + "to": [ + "effect/testing/TestConsole" + ], + "status": "added", + "barrels": [ + "effect/testing" + ] + }, + { + "to": [ + "effect/testing/TestSchema" + ], + "status": "added", + "barrels": [ + "effect/testing" + ] + }, + { + "to": [ + "effect/unstable/ai/AnthropicStructuredOutput" + ], + "status": "added", + "barrels": [ + "effect/unstable/ai" + ] + }, + { + "to": [ + "effect/unstable/ai/OpenAiStructuredOutput" + ], + "status": "added", + "barrels": [ + "effect/unstable/ai" + ] + }, + { + "to": [ + "effect/unstable/ai/ResponseIdTracker" + ], + "status": "added", + "barrels": [ + "effect/unstable/ai" + ] + }, + { + "to": [ + "effect/unstable/cli/CliOutput" + ], + "status": "added", + "barrels": [ + "effect/unstable/cli" + ] + }, + { + "to": [ + "effect/unstable/cli/Param" + ], + "status": "added", + "barrels": [ + "effect/unstable/cli" + ] + }, + { + "to": [ + "effect/unstable/eventlog/EventLogServerUnencrypted" + ], + "status": "added", + "barrels": [ + "effect/unstable/eventlog" + ] + }, + { + "to": [ + "effect/unstable/eventlog/EventLogSessionAuth" + ], + "status": "added", + "barrels": [ + "effect/unstable/eventlog" + ] + }, + { + "to": [ + "effect/unstable/eventlog/SqlEventLogServerUnencrypted" + ], + "status": "added", + "barrels": [ + "effect/unstable/eventlog" + ] + }, + { + "to": [ + "effect/unstable/http/FindMyWay" + ], + "status": "added", + "barrels": [ + "effect/unstable/http" + ] + }, + { + "to": [ + "effect/unstable/http/HttpStaticServer" + ], + "status": "added", + "barrels": [ + "effect/unstable/http" + ] + }, + { + "to": [ + "effect/unstable/http/Multipasta" + ], + "status": "added", + "barrels": [ + "effect/unstable/http" + ] + }, + { + "to": [ + "effect/unstable/http/Multipasta/HeadersParser" + ], + "status": "added", + "barrels": [ + "effect/unstable/http" + ] + }, + { + "to": [ + "effect/unstable/http/Multipasta/Node" + ], + "status": "added", + "barrels": [ + "effect/unstable/http" + ] + }, + { + "to": [ + "effect/unstable/http/Multipasta/Search" + ], + "status": "added", + "barrels": [ + "effect/unstable/http" + ] + }, + { + "to": [ + "effect/unstable/http/Multipasta/Web" + ], + "status": "added", + "barrels": [ + "effect/unstable/http" + ] + }, + { + "to": [ + "effect/unstable/httpapi/HttpApiTest" + ], + "status": "added", + "barrels": [ + "effect/unstable/httpapi" + ] + }, + { + "to": [ + "effect/unstable/observability/PrometheusMetrics" + ], + "status": "added", + "barrels": [ + "effect/unstable/observability" + ] + }, + { + "to": [ + "effect/unstable/persistence/Redis" + ], + "status": "added", + "barrels": [ + "effect/unstable/persistence" + ] + }, + { + "to": [ + "effect/unstable/reactivity/AsyncResult" + ], + "status": "added", + "barrels": [ + "effect/unstable/reactivity" + ] + }, + { + "to": [ + "effect/unstable/reactivity/Atom" + ], + "status": "added", + "barrels": [ + "effect/unstable/reactivity" + ] + }, + { + "to": [ + "effect/unstable/reactivity/AtomHttpApi" + ], + "status": "added", + "barrels": [ + "effect/unstable/reactivity" + ] + }, + { + "to": [ + "effect/unstable/reactivity/AtomRef" + ], + "status": "added", + "barrels": [ + "effect/unstable/reactivity" + ] + }, + { + "to": [ + "effect/unstable/reactivity/AtomRegistry" + ], + "status": "added", + "barrels": [ + "effect/unstable/reactivity" + ] + }, + { + "to": [ + "effect/unstable/reactivity/AtomRpc" + ], + "status": "added", + "barrels": [ + "effect/unstable/reactivity" + ] + }, + { + "to": [ + "effect/unstable/reactivity/Hydration" + ], + "status": "added", + "barrels": [ + "effect/unstable/reactivity" + ] + }, + { + "to": [ + "effect/unstable/rpc/Utils" + ], + "status": "added", + "barrels": [ + "effect/unstable/rpc" + ] + } + ], + "apis": [ + { + "from": { + "module": "effect/Effect", + "path": [ + "async" + ] + }, + "to": { + "module": "effect/Effect", + "path": [ + "callback" + ] + } + }, + { + "from": { + "module": "effect/Effect", + "path": [ + "zipRight" + ] + }, + "to": { + "module": "effect/Effect", + "path": [ + "andThen" + ] + } + }, + { + "from": { + "module": "effect/Effect", + "path": [ + "zipLeft" + ] + }, + "to": { + "module": "effect/Effect", + "path": [ + "tap" + ] + } + }, + { + "from": { + "module": "effect/Effect", + "path": [ + "either" + ] + }, + "to": { + "module": "effect/Effect", + "path": [ + "result" + ] + } + }, + { + "from": { + "module": "effect/Effect", + "path": [ + "catchAll" + ] + }, + "to": { + "module": "effect/Effect", + "path": [ + "catch" + ] + } + }, + { + "from": { + "module": "effect/Effect", + "path": [ + "catchAllCause" + ] + }, + "to": { + "module": "effect/Effect", + "path": [ + "catchCause" + ] + } + }, + { + "from": { + "module": "effect/Effect", + "path": [ + "catchAllDefect" + ] + }, + "to": { + "module": "effect/Effect", + "path": [ + "catchDefect" + ] + } + }, + { + "from": { + "module": "effect/Effect", + "path": [ + "catchSome" + ] + }, + "to": { + "module": "effect/Effect", + "path": [ + "catchFilter" + ] + }, + "guide": "migration/error-handling.md" + }, + { + "from": { + "module": "effect/Effect", + "path": [ + "catchIf" + ] + }, + "to": { + "module": "effect/Effect", + "path": [ + "catchIf" + ] + } + }, + { + "from": { + "module": "effect/Effect", + "path": [ + "optionFromOptional" + ] + }, + "to": { + "module": "effect/Effect", + "path": [ + "catchNoSuchElement" + ] + } + }, + { + "from": { + "module": "effect/Effect", + "path": [ + "catchSomeCause" + ] + }, + "to": { + "module": "effect/Effect", + "path": [ + "catchCauseFilter" + ] + }, + "guide": "migration/error-handling.md" + }, + { + "from": { + "module": "effect/Effect", + "path": [ + "tapErrorCause" + ] + }, + "to": { + "module": "effect/Effect", + "path": [ + "tapCause" + ] + } + }, + { + "from": { + "module": "effect/Effect", + "path": [ + "ignoreLogged" + ] + }, + "to": { + "module": "effect/Effect", + "path": [ + "ignore" + ] + } + }, + { + "from": { + "module": "effect/Effect", + "path": [ + "unsafeMakeLatch" + ] + }, + "to": { + "module": "effect/Latch", + "path": [ + "makeUnsafe" + ] + } + }, + { + "from": { + "module": "effect/Effect", + "path": [ + "makeLatch" + ] + }, + "to": { + "module": "effect/Latch", + "path": [ + "make" + ] + } + }, + { + "from": { + "module": "effect/Layer", + "path": [ + "scoped" + ] + }, + "to": { + "module": "effect/Layer", + "path": [ + "effect" + ] + } + }, + { + "from": { + "module": "effect/Layer", + "path": [ + "scopedDiscard" + ] + }, + "to": { + "module": "effect/Layer", + "path": [ + "effectDiscard" + ] + } + }, + { + "from": { + "module": "effect/Layer", + "path": [ + "tapErrorCause" + ] + }, + "to": { + "module": "effect/Layer", + "path": [ + "tapCause" + ] + } + }, + { + "from": { + "module": "effect/Mailbox", + "path": [ + "Mailbox" + ], + "bucket": "type" + }, + "to": { + "module": "effect/Queue", + "path": [ + "Queue" + ], + "bucket": "type" + } + }, + { + "from": { + "module": "effect/Mailbox", + "path": [ + "make" + ] + }, + "to": { + "module": "effect/Queue", + "path": [ + "make" + ] + } + }, + { + "from": { + "module": "effect/Either", + "path": [ + "Either" + ], + "bucket": "type" + }, + "to": { + "module": "effect/Result", + "path": [ + "Result" + ], + "bucket": "type" + } + }, + { + "from": { + "module": "effect/Either", + "path": [ + "Either" + ], + "bucket": "value" + }, + "to": { + "module": "effect/Result", + "path": [ + "Result" + ], + "bucket": "value" + } + }, + { + "from": { + "module": "effect/Either", + "path": [ + "right" + ] + }, + "to": { + "module": "effect/Result", + "path": [ + "succeed" + ] + } + }, + { + "from": { + "module": "effect/Either", + "path": [ + "left" + ] + }, + "to": { + "module": "effect/Result", + "path": [ + "fail" + ] + } + }, + { + "from": { + "module": "effect/Scope", + "path": [ + "extend" + ] + }, + "to": { + "module": "effect/Scope", + "path": [ + "provide" + ] + } + }, + { + "from": { + "module": "effect/Effect", + "path": [ + "unsafeMakeSemaphore" + ] + }, + "to": { + "module": "effect/Semaphore", + "path": [ + "makeUnsafe" + ] + } + }, + { + "from": { + "module": "effect/Effect", + "path": [ + "makeSemaphore" + ] + }, + "to": { + "module": "effect/Semaphore", + "path": [ + "make" + ] + } + }, + { + "from": { + "module": "effect/Stream", + "path": [ + "Stream", + "Context" + ] + }, + "to": { + "module": "effect/Stream", + "path": [ + "Services" + ] + } + }, + { + "from": { + "module": "effect/StreamHaltStrategy", + "path": [ + "HaltStrategy" + ] + }, + "to": { + "module": "effect/Stream", + "path": [ + "HaltStrategy" + ] + } + }, + { + "from": { + "module": "effect/Stream", + "path": [ + "repeatEffect" + ] + }, + "to": { + "module": "effect/Stream", + "path": [ + "fromEffectRepeat" + ] + } + }, + { + "from": { + "module": "effect/Stream", + "path": [ + "repeatEffectWithSchedule" + ] + }, + "to": { + "module": "effect/Stream", + "path": [ + "fromEffectSchedule" + ] + } + }, + { + "from": { + "module": "effect/Stream", + "path": [ + "async" + ] + }, + "to": { + "module": "effect/Stream", + "path": [ + "callback" + ] + } + }, + { + "from": { + "module": "effect/Stream", + "path": [ + "asyncEffect" + ] + }, + "to": { + "module": "effect/Stream", + "path": [ + "callback" + ] + } + }, + { + "from": { + "module": "effect/Stream", + "path": [ + "asyncPush" + ] + }, + "to": { + "module": "effect/Stream", + "path": [ + "callback" + ] + } + }, + { + "from": { + "module": "effect/Stream", + "path": [ + "asyncScoped" + ] + }, + "to": { + "module": "effect/Stream", + "path": [ + "callback" + ] + } + }, + { + "from": { + "module": "effect/Stream", + "path": [ + "repeatEffectChunk" + ] + }, + "to": { + "module": "effect/Stream", + "path": [ + "fromIterableEffectRepeat" + ] + } + }, + { + "from": { + "module": "effect/Stream", + "path": [ + "fromChunk" + ] + }, + "to": { + "module": "effect/Stream", + "path": [ + "fromArray" + ] + } + }, + { + "from": { + "module": "effect/Stream", + "path": [ + "fromChunks" + ] + }, + "to": { + "module": "effect/Stream", + "path": [ + "fromArrays" + ] + } + }, + { + "from": { + "module": "effect/Stream", + "path": [ + "mapChunks" + ] + }, + "to": { + "module": "effect/Stream", + "path": [ + "mapArray" + ] + } + }, + { + "from": { + "module": "effect/Stream", + "path": [ + "mapChunksEffect" + ] + }, + "to": { + "module": "effect/Stream", + "path": [ + "mapArrayEffect" + ] + } + }, + { + "from": { + "module": "effect/Stream", + "path": [ + "either" + ] + }, + "to": { + "module": "effect/Stream", + "path": [ + "result" + ] + } + }, + { + "from": { + "module": "effect/Stream", + "path": [ + "flattenChunks" + ] + }, + "to": { + "module": "effect/Stream", + "path": [ + "flattenArray" + ] + } + }, + { + "from": { + "module": "effect/Stream", + "path": [ + "flattenIterables" + ] + }, + "to": { + "module": "effect/Stream", + "path": [ + "flattenIterable" + ] + } + }, + { + "from": { + "module": "effect/Stream", + "path": [ + "mergeEither" + ] + }, + "to": { + "module": "effect/Stream", + "path": [ + "mergeResult" + ] + } + }, + { + "from": { + "module": "effect/Stream", + "path": [ + "zipWithChunks" + ] + }, + "to": { + "module": "effect/Stream", + "path": [ + "zipWithArray" + ] + } + }, + { + "from": { + "module": "effect/Stream", + "path": [ + "bufferChunks" + ] + }, + "to": { + "module": "effect/Stream", + "path": [ + "bufferArray" + ] + } + }, + { + "from": { + "module": "effect/Stream", + "path": [ + "catchAllCause" + ] + }, + "to": { + "module": "effect/Stream", + "path": [ + "catchCause" + ] + } + }, + { + "from": { + "module": "effect/Stream", + "path": [ + "tapErrorCause" + ] + }, + "to": { + "module": "effect/Stream", + "path": [ + "tapCause" + ] + } + }, + { + "from": { + "module": "effect/Stream", + "path": [ + "catchAll" + ] + }, + "to": { + "module": "effect/Stream", + "path": [ + "catch" + ] + } + }, + { + "from": { + "module": "effect/Stream", + "path": [ + "catchSome" + ] + }, + "to": { + "module": "effect/Stream", + "path": [ + "catchIf" + ] + } + }, + { + "from": { + "module": "effect/Stream", + "path": [ + "catchSomeCause" + ] + }, + "to": { + "module": "effect/Stream", + "path": [ + "catchCauseIf" + ] + } + }, + { + "from": { + "module": "effect/Stream", + "path": [ + "combineChunks" + ] + }, + "to": { + "module": "effect/Stream", + "path": [ + "combineArray" + ] + } + }, + { + "from": { + "module": "effect/Stream", + "path": [ + "provideSomeLayer" + ] + }, + "to": { + "module": "effect/Stream", + "path": [ + "provide" + ] + } + }, + { + "from": { + "module": "effect/Stream", + "path": [ + "provideSomeContext" + ] + }, + "to": { + "module": "effect/Stream", + "path": [ + "provide" + ] + } + } + ] +} diff --git a/migration/v3-to-v4.md b/migration/v3-to-v4.md index 5e3f3c7718d..d4a52da4f8c 100644 --- a/migration/v3-to-v4.md +++ b/migration/v3-to-v4.md @@ -1,19 +1,14 @@ # v3 to v4 Import and API Rename Maps -Mapped modules: 290 -No counterpart: 43 -API renames: 53 +Mapped modules: 289 +No counterpart: 44 +API renames: 54 -This file is intended for migration agents. It contains user-facing import -specifier mappings and API rename mappings. - -Use the import map when rewriting import declarations. Use the API renames when -rewriting renamed symbols. +This file is generated from `migration/v3-to-v4.json`. Do not edit it directly. ## Import Map -Each line is `v3 import -> v4 direct module import`. When a grouped v4 barrel -exists, the suggested barrel import is shown in parentheses. +Each line is `v3 import -> v4 direct module import`. Suggested barrels are shown in parentheses. ```text @effect/platform/ChannelSchema -> effect/ChannelSchema (barrel: effect) @@ -108,10 +103,8 @@ effect/TestClock -> effect/testing/TestClock (barrel: effect/testing) @effect/experimental/EventJournal -> effect/unstable/eventlog/EventJournal (barrel: effect/unstable/eventlog) @effect/experimental/EventLog -> effect/unstable/eventlog/EventLog (barrel: effect/unstable/eventlog) @effect/experimental/EventLogEncryption -> effect/unstable/eventlog/EventLogEncryption (barrel: effect/unstable/eventlog) -@effect/experimental/EventLogRemote -> effect/unstable/eventlog/EventLogMessage (barrel: effect/unstable/eventlog) -@effect/experimental/EventLogRemote -> effect/unstable/eventlog/EventLogRemote (barrel: effect/unstable/eventlog) -@effect/experimental/EventLogServer -> effect/unstable/eventlog/EventLogServer (barrel: effect/unstable/eventlog) -@effect/experimental/EventLogServer -> effect/unstable/eventlog/EventLogServerEncrypted (barrel: effect/unstable/eventlog) +@effect/experimental/EventLogRemote -> effect/unstable/eventlog/EventLogMessage, effect/unstable/eventlog/EventLogRemote (barrel: effect/unstable/eventlog) +@effect/experimental/EventLogServer -> effect/unstable/eventlog/EventLogServer, effect/unstable/eventlog/EventLogServerEncrypted (barrel: effect/unstable/eventlog) @effect/sql/SqlEventJournal -> effect/unstable/eventlog/SqlEventJournal (barrel: effect/unstable/eventlog) @effect/sql/SqlEventLogServer -> effect/unstable/eventlog/SqlEventLogServerEncrypted (barrel: effect/unstable/eventlog) @effect/platform/Cookies -> effect/unstable/http/Cookies (barrel: effect/unstable/http) @@ -152,17 +145,15 @@ effect/TestClock -> effect/testing/TestClock (barrel: effect/testing) @effect/platform/HttpApiSwagger -> effect/unstable/httpapi/HttpApiSwagger (barrel: effect/unstable/httpapi) @effect/platform/OpenApi -> effect/unstable/httpapi/OpenApi (barrel: effect/unstable/httpapi) @effect/opentelemetry/Otlp -> effect/unstable/observability/Otlp (barrel: effect/unstable/observability) -@effect/opentelemetry/internal/otlpExporter -> effect/unstable/observability/OtlpExporter (barrel: effect/unstable/observability) @effect/opentelemetry/OtlpLogger -> effect/unstable/observability/OtlpLogger (barrel: effect/unstable/observability) @effect/opentelemetry/OtlpMetrics -> effect/unstable/observability/OtlpMetrics (barrel: effect/unstable/observability) @effect/opentelemetry/OtlpResource -> effect/unstable/observability/OtlpResource (barrel: effect/unstable/observability) @effect/opentelemetry/OtlpSerialization -> effect/unstable/observability/OtlpSerialization (barrel: effect/unstable/observability) @effect/opentelemetry/OtlpTracer -> effect/unstable/observability/OtlpTracer (barrel: effect/unstable/observability) @effect/platform/KeyValueStore -> effect/unstable/persistence/KeyValueStore (barrel: effect/unstable/persistence) -@effect/experimental/Persistence -> effect/unstable/persistence/Persistable (barrel: effect/unstable/persistence) +@effect/experimental/Persistence -> effect/unstable/persistence/Persistable, effect/unstable/persistence/Persistence (barrel: effect/unstable/persistence) @effect/experimental/PersistedCache -> effect/unstable/persistence/PersistedCache (barrel: effect/unstable/persistence) @effect/experimental/PersistedQueue -> effect/unstable/persistence/PersistedQueue (barrel: effect/unstable/persistence) -@effect/experimental/Persistence -> effect/unstable/persistence/Persistence (barrel: effect/unstable/persistence) @effect/experimental/RateLimiter -> effect/unstable/persistence/RateLimiter (barrel: effect/unstable/persistence) @effect/platform/Command -> effect/unstable/process/ChildProcess (barrel: effect/unstable/process) @effect/platform/CommandExecutor -> effect/unstable/process/ChildProcessSpawner (barrel: effect/unstable/process) @@ -178,7 +169,7 @@ effect/TestClock -> effect/testing/TestClock (barrel: effect/testing) @effect/rpc/RpcServer -> effect/unstable/rpc/RpcServer (barrel: effect/unstable/rpc) @effect/rpc/RpcTest -> effect/unstable/rpc/RpcTest (barrel: effect/unstable/rpc) @effect/rpc/RpcWorker -> effect/unstable/rpc/RpcWorker (barrel: effect/unstable/rpc) -@effect/sql/Model -> effect/unstable/schema/Model (barrel: effect/unstable/schema) +@effect/sql/Model -> effect/unstable/schema/Model, effect/unstable/sql/SqlModel (barrel: effect/unstable/schema, effect/unstable/sql) @effect/experimental/VariantSchema -> effect/unstable/schema/VariantSchema (barrel: effect/unstable/schema) @effect/platform/Socket -> effect/unstable/socket/Socket (barrel: effect/unstable/socket) @effect/platform/SocketServer -> effect/unstable/socket/SocketServer (barrel: effect/unstable/socket) @@ -186,7 +177,6 @@ effect/TestClock -> effect/testing/TestClock (barrel: effect/testing) @effect/sql/SqlClient -> effect/unstable/sql/SqlClient (barrel: effect/unstable/sql) @effect/sql/SqlConnection -> effect/unstable/sql/SqlConnection (barrel: effect/unstable/sql) @effect/sql/SqlError -> effect/unstable/sql/SqlError (barrel: effect/unstable/sql) -@effect/sql/Model -> effect/unstable/sql/SqlModel (barrel: effect/unstable/sql) @effect/sql/SqlResolver -> effect/unstable/sql/SqlResolver (barrel: effect/unstable/sql) @effect/sql/SqlSchema -> effect/unstable/sql/SqlSchema (barrel: effect/unstable/sql) @effect/sql/SqlStream -> effect/unstable/sql/SqlStream (barrel: effect/unstable/sql) @@ -235,7 +225,7 @@ effect/Fiber -> effect/Fiber (barrel: effect) effect/FiberHandle -> effect/FiberHandle (barrel: effect) effect/FiberMap -> effect/FiberMap (barrel: effect) effect/FiberSet -> effect/FiberSet (barrel: effect) -effect/Inspectable -> effect/Formatter (barrel: effect) +effect/Inspectable -> effect/Formatter, effect/Inspectable, effect/Redactable (barrel: effect) effect/Function -> effect/Function (barrel: effect) effect/Graph -> effect/Graph (barrel: effect) effect/HKT -> effect/HKT (barrel: effect) @@ -243,7 +233,6 @@ effect/Hash -> effect/Hash (barrel: effect) effect/HashMap -> effect/HashMap (barrel: effect) effect/HashRing -> effect/HashRing (barrel: effect) effect/HashSet -> effect/HashSet (barrel: effect) -effect/Inspectable -> effect/Inspectable (barrel: effect) effect/Iterable -> effect/Iterable (barrel: effect) effect/Layer -> effect/Layer (barrel: effect) effect/LayerMap -> effect/LayerMap (barrel: effect) @@ -272,7 +261,6 @@ effect/Random -> effect/Random (barrel: effect) effect/RcMap -> effect/RcMap (barrel: effect) effect/RcRef -> effect/RcRef (barrel: effect) effect/Record -> effect/Record (barrel: effect) -effect/Inspectable -> effect/Redactable (barrel: effect) effect/Redacted -> effect/Redacted (barrel: effect) @effect/typeclass/Monoid -> effect/Reducer (barrel: effect) effect/Ref -> effect/Ref (barrel: effect) @@ -284,11 +272,9 @@ effect/Resource -> effect/Resource (barrel: effect) effect/Runtime -> effect/Runtime (barrel: effect) effect/Schedule -> effect/Schedule (barrel: effect) effect/Scheduler -> effect/Scheduler (barrel: effect) -effect/Schema -> effect/Schema (barrel: effect) +effect/Schema -> effect/Schema, effect/SchemaTransformation (barrel: effect) effect/SchemaAST -> effect/SchemaAST (barrel: effect) -effect/ParseResult -> effect/SchemaIssue (barrel: effect) -effect/ParseResult -> effect/SchemaParser (barrel: effect) -effect/Schema -> effect/SchemaTransformation (barrel: effect) +effect/ParseResult -> effect/SchemaIssue, effect/SchemaParser (barrel: effect) effect/Scope -> effect/Scope (barrel: effect) effect/ScopedCache -> effect/ScopedCache (barrel: effect) effect/ScopedRef -> effect/ScopedRef (barrel: effect) @@ -310,10 +296,10 @@ effect/Utils -> effect/Utils (barrel: effect) ## No Counterpart Imports -These v4 modules did not have a mapped v3 module. Treat them as v4-only unless a -more specific migration guide says otherwise. +These v4 modules did not have a mapped v3 module. ```text +effect/unstable/observability/OtlpExporter (barrel: effect/unstable/observability) effect/ErrorReporter (barrel: effect) effect/Filter (barrel: effect) effect/JsonPatch (barrel: effect) @@ -361,61 +347,61 @@ effect/unstable/rpc/Utils (barrel: effect/unstable/rpc) ## API Renames -Each line is `v3 API -> v4 API`. Use these mappings when rewriting renamed -symbols from v3 source code to v4. +Each line is `v3 API -> v4 API`. ```text -Effect.async -> Effect.callback -Effect.zipRight -> Effect.andThen -Effect.zipLeft -> Effect.tap -Effect.either -> Effect.result -Effect.catchAll -> Effect.catch -Effect.catchAllCause -> Effect.catchCause -Effect.catchAllDefect -> Effect.catchDefect -Effect.catchSome -> Effect.catchIf -Effect.catchIf -> Effect.catchIf -Effect.optionFromOptional -> Effect.catchNoSuchElement -Effect.catchSomeCause -> Effect.catchCauseIf -Effect.tapErrorCause -> Effect.tapCause -Effect.ignoreLogged -> Effect.ignore -Effect.makeLatchUnsafe -> Latch.makeUnsafe -Effect.makeLatch -> Latch.make -Layer.scoped -> Layer.effect -Layer.scopedDiscard -> Layer.effectDiscard -Layer.tapErrorCause -> Layer.tapCause -Mailbox -> Queue.Queue -Mailbox.make -> Queue.make -Either -> Result.Result -Either.right -> Result.succeed -Either.left -> Result.fail -Scope.extend -> Scope.provide -Effect.makeSemaphoreUnsafe -> Semaphore.makeUnsafe -Effect.makeSemaphore -> Semaphore.make -Stream.Context -> Stream.Services -StreamHaltStrategy.HaltStrategy -> Stream.HaltStrategy -Stream.repeatEffect -> Stream.fromEffectRepeat -Stream.repeatEffectWithSchedule -> Stream.fromEffectSchedule -Stream.async -> Stream.callback -Stream.asyncEffect -> Stream.callback -Stream.asyncPush -> Stream.callback -Stream.asyncScoped -> Stream.callback -Stream.repeatEffectChunk -> Stream.fromIterableEffectRepeat -Stream.fromChunk -> Stream.fromArray -Stream.fromChunks -> Stream.fromArrays -Stream.mapChunks -> Stream.mapArray -Stream.mapChunksEffect -> Stream.mapArrayEffect -Stream.either -> Stream.result -Stream.flattenChunks -> Stream.flattenArray -Stream.flattenIterables -> Stream.flattenIterable -Stream.mergeEither -> Stream.mergeResult -Stream.zipWithChunks -> Stream.zipWithArray -Stream.bufferChunks -> Stream.bufferArray -Stream.catchAllCause -> Stream.catchCause -Stream.tapErrorCause -> Stream.tapCause -Stream.catchAll -> Stream.catch -Stream.catchSome -> Stream.catchIf -Stream.catchSomeCause -> Stream.catchCauseIf -Stream.combineChunks -> Stream.combineArray -provideSomeLayer -> Stream.provide -provideSomeContext -> Stream.provide +effect/Effect#async -> effect/Effect#callback +effect/Effect#zipRight -> effect/Effect#andThen +effect/Effect#zipLeft -> effect/Effect#tap +effect/Effect#either -> effect/Effect#result +effect/Effect#catchAll -> effect/Effect#catch +effect/Effect#catchAllCause -> effect/Effect#catchCause +effect/Effect#catchAllDefect -> effect/Effect#catchDefect +effect/Effect#catchSome -> effect/Effect#catchFilter +effect/Effect#catchIf -> effect/Effect#catchIf +effect/Effect#optionFromOptional -> effect/Effect#catchNoSuchElement +effect/Effect#catchSomeCause -> effect/Effect#catchCauseFilter +effect/Effect#tapErrorCause -> effect/Effect#tapCause +effect/Effect#ignoreLogged -> effect/Effect#ignore +effect/Effect#unsafeMakeLatch -> effect/Latch#makeUnsafe +effect/Effect#makeLatch -> effect/Latch#make +effect/Layer#scoped -> effect/Layer#effect +effect/Layer#scopedDiscard -> effect/Layer#effectDiscard +effect/Layer#tapErrorCause -> effect/Layer#tapCause +effect/Mailbox#Mailbox -> effect/Queue#Queue +effect/Mailbox#make -> effect/Queue#make +effect/Either#Either -> effect/Result#Result +effect/Either#Either -> effect/Result#Result +effect/Either#right -> effect/Result#succeed +effect/Either#left -> effect/Result#fail +effect/Scope#extend -> effect/Scope#provide +effect/Effect#unsafeMakeSemaphore -> effect/Semaphore#makeUnsafe +effect/Effect#makeSemaphore -> effect/Semaphore#make +effect/Stream#Stream.Context -> effect/Stream#Services +effect/StreamHaltStrategy#HaltStrategy -> effect/Stream#HaltStrategy +effect/Stream#repeatEffect -> effect/Stream#fromEffectRepeat +effect/Stream#repeatEffectWithSchedule -> effect/Stream#fromEffectSchedule +effect/Stream#async -> effect/Stream#callback +effect/Stream#asyncEffect -> effect/Stream#callback +effect/Stream#asyncPush -> effect/Stream#callback +effect/Stream#asyncScoped -> effect/Stream#callback +effect/Stream#repeatEffectChunk -> effect/Stream#fromIterableEffectRepeat +effect/Stream#fromChunk -> effect/Stream#fromArray +effect/Stream#fromChunks -> effect/Stream#fromArrays +effect/Stream#mapChunks -> effect/Stream#mapArray +effect/Stream#mapChunksEffect -> effect/Stream#mapArrayEffect +effect/Stream#either -> effect/Stream#result +effect/Stream#flattenChunks -> effect/Stream#flattenArray +effect/Stream#flattenIterables -> effect/Stream#flattenIterable +effect/Stream#mergeEither -> effect/Stream#mergeResult +effect/Stream#zipWithChunks -> effect/Stream#zipWithArray +effect/Stream#bufferChunks -> effect/Stream#bufferArray +effect/Stream#catchAllCause -> effect/Stream#catchCause +effect/Stream#tapErrorCause -> effect/Stream#tapCause +effect/Stream#catchAll -> effect/Stream#catch +effect/Stream#catchSome -> effect/Stream#catchIf +effect/Stream#catchSomeCause -> effect/Stream#catchCauseIf +effect/Stream#combineChunks -> effect/Stream#combineArray +effect/Stream#provideSomeLayer -> effect/Stream#provide +effect/Stream#provideSomeContext -> effect/Stream#provide ``` diff --git a/package.json b/package.json index 7214af7e91a..3af899f924d 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "docgen": "pnpm --recursive --filter \"./packages/**/*\" exec docgen && node scripts/docs.mjs", "ai-docgen": "effect-ai-docgen ai-docs/src -o LLMS.md", "ai-docgen:watch": "pnpm ai-docgen --watch", + "api-diff": "pnpm --dir packages/tools/api-diff exec node src/bin.ts", "test-types": "tstyche --target '>=5.9'", "changeset-version": "changeset version", "changeset-publish": "pnpm codemod && pnpm build && changeset publish" diff --git a/packages/tools/api-diff/README.md b/packages/tools/api-diff/README.md new file mode 100644 index 00000000000..6e1b8a327c9 --- /dev/null +++ b/packages/tools/api-diff/README.md @@ -0,0 +1,36 @@ +# API Diff + +`@effect/api-diff` compares the consumer-visible TypeScript declarations emitted +by two repository revisions. Its JSON output is canonical; the Markdown report +is intended for migration review and does not make semantic-version +compatibility claims. + +Run a complete comparison from the repository root: + +```sh +pnpm api-diff \ + --base-ref v3 \ + --head-ref origin/main \ + --mapping migration/v3-to-v4.json \ + --output tmp/api-diff/run +``` + +Both refs are required and are resolved to commit SHAs before work starts. The +tool builds detached disposable worktrees with each branch's native build, +extracts both snapshots with one pinned TypeScript compiler API, and caches +successful snapshots by commit, compiler, and comparison scope. + +The command writes: + +- `base.snapshot.json` +- `head.snapshot.json` +- `diff.json` +- `report.md` + +Regenerate the human-readable migration map after changing its JSON source: + +```sh +pnpm api-diff \ + --mapping migration/v3-to-v4.json \ + --write-mapping-doc migration/v3-to-v4.md +``` diff --git a/packages/tools/api-diff/package.json b/packages/tools/api-diff/package.json new file mode 100644 index 00000000000..bea7149b166 --- /dev/null +++ b/packages/tools/api-diff/package.json @@ -0,0 +1,25 @@ +{ + "name": "@effect/api-diff", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/Cli.ts", + "./*": "./src/*.ts", + "./bin": null + }, + "bin": { + "effect-api-diff": "./src/bin.ts" + }, + "scripts": { + "check": "tsc -b tsconfig.json", + "test": "vitest" + }, + "devDependencies": { + "@effect/vitest": "workspace:^", + "@types/node": "^26.1.1", + "typescript": "^7.0.2", + "typescript-compiler": "npm:typescript@6.0.3", + "vitest": "4.1.10" + } +} diff --git a/packages/tools/api-diff/src/Cli.ts b/packages/tools/api-diff/src/Cli.ts new file mode 100644 index 00000000000..c686857632b --- /dev/null +++ b/packages/tools/api-diff/src/Cli.ts @@ -0,0 +1,159 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs" +import { dirname, isAbsolute, join, relative, resolve } from "node:path" +import { diffSnapshots } from "./Diff.ts" +import { writeJson } from "./Json.ts" +import { mappingModules, parseMigrationMap, renderMigrationMarkdown, validateMigrationMap } from "./Mapping.ts" +import { renderMarkdownReport } from "./Report.ts" +import { prepareSnapshot, resolveRef, writeSnapshotOutput } from "./Worktrees.ts" + +interface CliOptions { + readonly baseRef?: string | undefined + readonly headRef?: string | undefined + readonly mapping?: string | undefined + readonly output?: string | undefined + readonly writeMappingDoc?: string | undefined + readonly help: boolean +} + +const usage = `Usage: + pnpm api-diff --base-ref --head-ref --mapping --output + pnpm api-diff --mapping --write-mapping-doc + +Options: + --base-ref Explicit base Git ref + --head-ref Explicit head Git ref + --mapping Versioned migration map JSON + --output Report output directory + --write-mapping-doc Generate Markdown from the migration map + -h, --help Show this help +` + +const parseArgs = (args: ReadonlyArray): CliOptions => { + let baseRef: string | undefined + let headRef: string | undefined + let mapping: string | undefined + let output: string | undefined + let writeMappingDoc: string | undefined + let help = false + for (let index = 0; index < args.length; index++) { + const argument = args[index]! + if (argument === "--help" || argument === "-h") { + help = true + continue + } + if ( + argument !== "--base-ref" && argument !== "--head-ref" && argument !== "--mapping" && + argument !== "--output" && argument !== "--write-mapping-doc" + ) { + throw new Error(`Unknown option: ${argument}`) + } + const value = args[++index] + if (value === undefined || value.startsWith("-")) { + throw new Error(`Missing value for ${argument}`) + } + if (argument === "--base-ref") { + baseRef = value + } else if (argument === "--head-ref") { + headRef = value + } else if (argument === "--mapping") { + mapping = value + } else if (argument === "--output") { + output = value + } else { + writeMappingDoc = value + } + } + return { baseRef, headRef, mapping, output, writeMappingDoc, help } +} + +const findRepoRoot = (cwd: string): string => { + let current = resolve(cwd) + while (true) { + if (existsSync(join(current, ".git")) || existsSync(join(current, "pnpm-workspace.yaml"))) { + return current + } + const parent = dirname(current) + if (parent === current) { + throw new Error(`Could not locate repository root from ${cwd}`) + } + current = parent + } +} + +const absolute = (repoRoot: string, path: string): string => isAbsolute(path) ? path : resolve(repoRoot, path) + +export const runCli = (args: ReadonlyArray, cwd = process.cwd()): void => { + const options = parseArgs(args) + if (options.help) { + process.stdout.write(usage) + return + } + if (options.mapping === undefined) { + throw new Error("--mapping is required") + } + const repoRoot = findRepoRoot(cwd) + const mappingPath = absolute(repoRoot, options.mapping) + const mapping = parseMigrationMap(mappingPath) + const staticDiagnostics = validateMigrationMap(mapping, { repoRoot }) + if (options.writeMappingDoc !== undefined) { + if (staticDiagnostics.some((diagnostic) => diagnostic.severity === "error")) { + throw new Error(staticDiagnostics.map((diagnostic) => `${diagnostic.code}: ${diagnostic.message}`).join("\n")) + } + const output = absolute(repoRoot, options.writeMappingDoc) + mkdirSync(dirname(output), { recursive: true }) + writeFileSync(output, renderMigrationMarkdown(mapping)) + process.stdout.write(`Generated ${relative(repoRoot, output)}\n`) + return + } + if (options.baseRef === undefined || options.headRef === undefined || options.output === undefined) { + throw new Error("--base-ref, --head-ref, and --output are required for comparison") + } + if (staticDiagnostics.some((diagnostic) => diagnostic.severity === "error")) { + throw new Error(staticDiagnostics.map((diagnostic) => `${diagnostic.code}: ${diagnostic.message}`).join("\n")) + } + + const baseSha = resolveRef(repoRoot, options.baseRef) + const headSha = resolveRef(repoRoot, options.headRef) + const modules = mappingModules(mapping) + const toolRoot = join(repoRoot, "tmp", "api-diff") + const cacheRoot = join(toolRoot, "cache") + const worktreesRoot = join(toolRoot, "worktrees") + process.stdout.write(`Base ${options.baseRef}: ${baseSha}\nHead ${options.headRef}: ${headSha}\n`) + const base = prepareSnapshot({ + repoRoot, + cacheRoot, + worktreesRoot, + name: "base", + ref: options.baseRef, + sha: baseSha, + modules: modules.base + }) + const head = prepareSnapshot({ + repoRoot, + cacheRoot, + worktreesRoot, + name: "head", + ref: options.headRef, + sha: headSha, + modules: modules.head + }) + const mappingDiagnostics = validateMigrationMap(mapping, { base, head, repoRoot }) + const output = absolute(repoRoot, options.output) + mkdirSync(output, { recursive: true }) + writeSnapshotOutput(join(output, "base.snapshot.json"), base) + writeSnapshotOutput(join(output, "head.snapshot.json"), head) + const diff = diffSnapshots(base, head, mapping, mappingDiagnostics) + writeJson(join(output, "diff.json"), diff) + writeFileSync(join(output, "report.md"), renderMarkdownReport(diff)) + + const errors = mappingDiagnostics.filter((diagnostic) => diagnostic.severity === "error") + process.stdout.write(`Wrote ${relative(repoRoot, output)} (${diff.changes.length} changes)\n`) + if (errors.length > 0) { + throw new Error(errors.map((diagnostic) => `${diagnostic.code}: ${diagnostic.message}`).join("\n")) + } + + const report = readFileSync(join(output, "report.md"), "utf8") + if (report.length === 0) { + throw new Error("Generated Markdown report is empty") + } +} diff --git a/packages/tools/api-diff/src/Diff.ts b/packages/tools/api-diff/src/Diff.ts new file mode 100644 index 00000000000..10245792f64 --- /dev/null +++ b/packages/tools/api-diff/src/Diff.ts @@ -0,0 +1,470 @@ +import { fingerprint, stableJson } from "./Json.ts" +import type { + ApiChange, + ApiDiff, + ApiEntity, + ApiMapping, + ApiSnapshot, + ChangeClassification, + DeclarationModel, + MappingDiagnostic, + MigrationMap, + ModuleMapping +} from "./Model.ts" + +interface Match { + readonly base: ApiEntity + readonly head: ApiEntity + readonly confidence: number + readonly authoritative: boolean + readonly mapping?: ApiMapping | ModuleMapping + readonly note?: string +} + +const targetEntities = ( + snapshot: ApiSnapshot, + target: { + readonly module: string + readonly path: ReadonlyArray + readonly bucket?: string | undefined + } +): ReadonlyArray => + snapshot.entities.filter((entity) => + entity.module === target.module && + entity.path.join(".") === target.path.join(".") && + (target.bucket === undefined || entity.bucket === target.bucket) + ) + +const pathName = (entity: ApiEntity): string => entity.path.at(-1) ?? "" + +const levenshtein = (left: string, right: string): number => { + const row = Array.from({ length: right.length + 1 }, (_, index) => index) + for (let leftIndex = 1; leftIndex <= left.length; leftIndex++) { + let previous = row[0]! + row[0] = leftIndex + for (let rightIndex = 1; rightIndex <= right.length; rightIndex++) { + const above = row[rightIndex]! + const diagonal = previous + previous = above + row[rightIndex] = left[leftIndex - 1] === right[rightIndex - 1] + ? diagonal + : Math.min(diagonal, above, row[rightIndex - 1]!) + 1 + } + } + return row[right.length]! +} + +const nameSimilarity = (left: string, right: string): number => { + const length = Math.max(left.length, right.length) + return length === 0 ? 1 : 1 - levenshtein(left.toLowerCase(), right.toLowerCase()) / length +} + +const changeId = ( + classification: ChangeClassification, + base?: ApiEntity, + head?: ApiEntity, + suffix = "" +): string => `change-${fingerprint([classification, base?.id ?? null, head?.id ?? null, suffix]).slice(0, 16)}` + +const makeChange = ( + classification: ChangeClassification, + match: Partial, + delta?: unknown, + suffix = "" +): ApiChange => ({ + id: changeId(classification, match.base, match.head, suffix), + classification, + confidence: match.confidence ?? 1, + baseApiId: match.base?.id, + headApiId: match.head?.id, + before: match.base?.displaySignature, + after: match.head?.displaySignature, + delta, + baseSource: match.base?.source, + headSource: match.head?.source, + mapping: match.mapping, + guide: match.mapping?.guide, + reviewNotes: match.note, + authoritative: match.authoritative ?? true +}) + +const declarationHash = (value: unknown): string => fingerprint(value) + +const memberMap = (declarations: ReadonlyArray): Map => + new Map( + declarations.flatMap((declaration) => declaration.members ?? []) + .map((member) => [`${member.kind}:${member.name}`, member]) + ) + +const classifyStructure = (match: Match): ReadonlyArray => { + const base = match.base + const head = match.head + const changes: Array = [] + if (base.bucket !== head.bucket) { + changes.push(makeChange("bucket-changed", match, { before: base.bucket, after: head.bucket })) + } + if (base.declarationKind !== head.declarationKind) { + changes.push(makeChange("declaration-kind-changed", match, { + before: base.declarationKind, + after: head.declarationKind + })) + } + + const baseDeclarations = base.declarations + const headDeclarations = head.declarations + const baseSignatures = baseDeclarations.filter((declaration) => + declaration.kind === "function" || declaration.kind === "method" + ) + const headSignatures = headDeclarations.filter((declaration) => + declaration.kind === "function" || declaration.kind === "method" + ) + if (headSignatures.length > baseSignatures.length) { + changes.push(makeChange("overload-added", match, { + before: baseSignatures.length, + after: headSignatures.length + })) + } else if (headSignatures.length < baseSignatures.length) { + changes.push(makeChange("overload-removed", match, { + before: baseSignatures.length, + after: headSignatures.length + })) + } else if ( + baseSignatures.length > 1 && + stableJson(baseSignatures.map(declarationHash).sort()) === stableJson(headSignatures.map(declarationHash).sort()) && + stableJson(baseSignatures.map(declarationHash)) !== stableJson(headSignatures.map(declarationHash)) + ) { + changes.push(makeChange("overload-reordered", match)) + } + + const comparedSignatures = Math.min(baseSignatures.length, headSignatures.length) + for (let index = 0; index < comparedSignatures; index++) { + const before = baseSignatures[index]! + const after = headSignatures[index]! + const beforeParameters = before.parameters ?? [] + const afterParameters = after.parameters ?? [] + if (afterParameters.length > beforeParameters.length) { + changes.push(makeChange("parameter-added", match, { + overload: index, + before: beforeParameters, + after: afterParameters + }, String(index))) + } else if (afterParameters.length < beforeParameters.length) { + changes.push(makeChange("parameter-removed", match, { + overload: index, + before: beforeParameters, + after: afterParameters + }, String(index))) + } else if ( + stableJson(beforeParameters.map((parameter) => parameter.name).sort()) === + stableJson(afterParameters.map((parameter) => parameter.name).sort()) && + stableJson(beforeParameters.map((parameter) => parameter.name)) !== + stableJson(afterParameters.map((parameter) => parameter.name)) + ) { + changes.push(makeChange("parameter-reordered", match, { + overload: index, + before: beforeParameters, + after: afterParameters + }, String(index))) + } else if (stableJson(beforeParameters) !== stableJson(afterParameters)) { + changes.push(makeChange("parameter-changed", match, { + overload: index, + before: beforeParameters, + after: afterParameters + }, String(index))) + } + if (stableJson(before.returnType) !== stableJson(after.returnType)) { + changes.push(makeChange("return-type-changed", match, { + overload: index, + before: before.returnType, + after: after.returnType + }, String(index))) + } + if (stableJson(before.typeParameters) !== stableJson(after.typeParameters)) { + changes.push(makeChange("generic-parameter-changed", match, { + overload: index, + before: before.typeParameters, + after: after.typeParameters + }, String(index))) + } + } + + const baseMembers = memberMap(baseDeclarations) + const headMembers = memberMap(headDeclarations) + for (const [key, member] of baseMembers) { + const next = headMembers.get(key) + if (next === undefined) { + changes.push(makeChange("member-removed", match, { member }, key)) + } else if (stableJson(member) !== stableJson(next)) { + changes.push(makeChange("member-changed", match, { before: member, after: next }, key)) + } + } + for (const [key, member] of headMembers) { + if (!baseMembers.has(key)) { + changes.push(makeChange("member-added", match, { member }, key)) + } + } + + const baseHeritage = baseDeclarations.flatMap((declaration) => declaration.heritage ?? []) + const headHeritage = headDeclarations.flatMap((declaration) => declaration.heritage ?? []) + if (stableJson(baseHeritage) !== stableJson(headHeritage)) { + changes.push(makeChange("heritage-changed", match, { before: baseHeritage, after: headHeritage })) + } + + const baseTypes = baseDeclarations.map((declaration) => declaration.type) + const headTypes = headDeclarations.map((declaration) => declaration.type) + const baseKinds = new Set(baseTypes.flatMap((type) => type?.kind === undefined ? [] : [type.kind])) + const headKinds = new Set(headTypes.flatMap((type) => type?.kind === undefined ? [] : [type.kind])) + if ((baseKinds.has("union") || headKinds.has("union")) && stableJson(baseTypes) !== stableJson(headTypes)) { + changes.push(makeChange("union-member-changed", match, { before: baseTypes, after: headTypes })) + } + if ( + (baseKinds.has("intersection") || headKinds.has("intersection")) && + stableJson(baseTypes) !== stableJson(headTypes) + ) { + changes.push(makeChange("intersection-member-changed", match, { before: baseTypes, after: headTypes })) + } + if (stableJson(base.documentation) !== stableJson(head.documentation)) { + changes.push(makeChange("documentation-changed", match, { + before: base.documentation, + after: head.documentation + })) + } + if (base.fingerprint !== head.fingerprint && changes.length === 0) { + changes.push(makeChange("structural-change", match, { + before: base.declarations, + after: head.declarations + })) + } + return changes +} + +const movementChange = (match: Match): ApiChange | undefined => { + const beforeName = pathName(match.base) + const afterName = pathName(match.head) + if (beforeName !== afterName) { + return makeChange("api-renamed", match) + } + if (match.base.module !== match.head.module || match.base.path.join(".") !== match.head.path.join(".")) { + return makeChange("api-moved", match) + } + return undefined +} + +const moduleChanges = ( + base: ApiSnapshot, + head: ApiSnapshot, + mapping: MigrationMap +): ReadonlyArray => { + const changes: Array = [] + const basePackages = new Set(base.packages) + const headPackages = new Set(head.packages) + for (const packageName of basePackages) { + if (!headPackages.has(packageName)) { + changes.push({ + id: `change-${fingerprint(["package-removed", packageName]).slice(0, 16)}`, + classification: "package-removed", + confidence: 1, + delta: { packageName }, + authoritative: true + }) + } + } + for (const packageName of headPackages) { + if (!basePackages.has(packageName)) { + changes.push({ + id: `change-${fingerprint(["package-added", packageName]).slice(0, 16)}`, + classification: "package-added", + confidence: 1, + delta: { packageName }, + authoritative: true + }) + } + } + for (const entry of mapping.modules) { + const classification = entry.status === "added" + ? "module-added" + : entry.status === "removed" + ? "module-removed" + : entry.status === "split" + ? "module-split" + : entry.status === "consolidated" + ? "module-consolidated" + : entry.status === "moved" + ? "module-moved" + : undefined + if (classification !== undefined) { + changes.push({ + id: `change-${fingerprint([classification, entry.from, entry.to]).slice(0, 16)}`, + classification, + confidence: 1, + delta: { from: entry.from, to: entry.to }, + mapping: entry, + guide: entry.guide, + authoritative: true + }) + } + } + return changes +} + +export const diffSnapshots = ( + base: ApiSnapshot, + head: ApiSnapshot, + mapping: MigrationMap, + mappingDiagnostics: ReadonlyArray +): ApiDiff => { + const unmatchedBase = new Map(base.entities.map((entity) => [entity.id, entity])) + const unmatchedHead = new Map(head.entities.map((entity) => [entity.id, entity])) + const matches: Array = [] + + const addMatch = ( + baseEntity: ApiEntity | undefined, + headEntity: ApiEntity | undefined, + details: Omit + ): boolean => { + if ( + baseEntity === undefined || headEntity === undefined || + !unmatchedBase.has(baseEntity.id) || !unmatchedHead.has(headEntity.id) + ) { + return false + } + unmatchedBase.delete(baseEntity.id) + unmatchedHead.delete(headEntity.id) + matches.push({ base: baseEntity, head: headEntity, ...details }) + return true + } + + for (const moduleMapping of mapping.modules) { + if (moduleMapping.from === undefined) { + continue + } + const bases = base.entities.filter((entity) => entity.module === moduleMapping.from) + for (const baseEntity of bases) { + for (const targetModule of moduleMapping.to) { + const headEntity = head.entities.find((entity) => + entity.module === targetModule && + entity.path.join(".") === baseEntity.path.join(".") && + entity.bucket === baseEntity.bucket + ) + if ( + addMatch(baseEntity, headEntity, { + confidence: 1, + authoritative: true, + mapping: moduleMapping + }) + ) { + break + } + } + } + } + + for (const apiMapping of mapping.apis) { + if (apiMapping.to === null) { + continue + } + const bases = targetEntities(base, apiMapping.from) + const heads = targetEntities(head, apiMapping.to) + for (const baseEntity of bases) { + const headEntity = heads.find((candidate) => + apiMapping.from.bucket !== undefined || candidate.bucket === baseEntity.bucket + ) + addMatch(baseEntity, headEntity, { + confidence: 1, + authoritative: true, + mapping: apiMapping + }) + } + } + + for (const moduleMapping of mapping.modules) { + if (moduleMapping.from === undefined) { + continue + } + const bases = [...unmatchedBase.values()].filter((entity) => entity.module === moduleMapping.from) + const heads = [...unmatchedHead.values()].filter((entity) => moduleMapping.to.includes(entity.module)) + for (const baseEntity of bases) { + const candidates = heads.filter((entity) => + entity.bucket === baseEntity.bucket && entity.fingerprint === baseEntity.fingerprint && + unmatchedHead.has(entity.id) + ) + if (candidates.length === 1) { + addMatch(baseEntity, candidates[0], { + confidence: 0.98, + authoritative: true, + mapping: moduleMapping + }) + } + } + } + + for (const moduleMapping of mapping.modules) { + if (moduleMapping.from === undefined) { + continue + } + const bases = [...unmatchedBase.values()].filter((entity) => entity.module === moduleMapping.from) + const heads = [...unmatchedHead.values()].filter((entity) => moduleMapping.to.includes(entity.module)) + for (const baseEntity of bases) { + const ranked = heads + .filter((entity) => entity.bucket === baseEntity.bucket && unmatchedHead.has(entity.id)) + .map((entity) => { + const sameKind = entity.declarationKind === baseEntity.declarationKind ? 0.15 : 0 + const sameFingerprint = entity.fingerprint === baseEntity.fingerprint ? 0.5 : 0 + const score = Math.min( + 0.99, + 0.35 * nameSimilarity(pathName(baseEntity), pathName(entity)) + sameKind + + sameFingerprint + ) + return { entity, score } + }) + .sort((left, right) => right.score - left.score || left.entity.id.localeCompare(right.entity.id)) + const best = ranked[0] + const next = ranked[1] + if (best !== undefined && best.score >= 0.45 && (next === undefined || best.score - next.score >= 0.08)) { + addMatch(baseEntity, best.entity, { + confidence: Number(best.score.toFixed(3)), + authoritative: false, + mapping: moduleMapping, + note: "Suggested match; requires review" + }) + } + } + } + + const changes = [...moduleChanges(base, head, mapping)] + for (const match of matches) { + const movement = movementChange(match) + if (movement !== undefined) { + changes.push(movement) + } + changes.push(...classifyStructure(match)) + } + for (const entity of unmatchedBase.values()) { + changes.push(makeChange("api-removed", { + base: entity, + confidence: 1, + authoritative: true + })) + } + for (const entity of unmatchedHead.values()) { + changes.push(makeChange("api-added", { + head: entity, + confidence: 1, + authoritative: true + })) + } + + return { + version: 1, + base: { ref: base.ref, sha: base.sha }, + head: { ref: head.ref, sha: head.sha }, + mappingVersion: mapping.version, + mappingDiagnostics, + changes: changes.sort((left, right) => + left.classification.localeCompare(right.classification) || + (left.baseApiId ?? "").localeCompare(right.baseApiId ?? "") || + (left.headApiId ?? "").localeCompare(right.headApiId ?? "") || + left.id.localeCompare(right.id) + ) + } +} diff --git a/packages/tools/api-diff/src/Discovery.ts b/packages/tools/api-diff/src/Discovery.ts new file mode 100644 index 00000000000..d36996713fb --- /dev/null +++ b/packages/tools/api-diff/src/Discovery.ts @@ -0,0 +1,213 @@ +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs" +import { dirname, join, relative, resolve, sep } from "node:path" +import type { Entrypoint } from "./Model.ts" + +interface PackageManifest { + readonly name?: string + readonly private?: boolean + readonly exports?: unknown + readonly publishConfig?: { + readonly exports?: unknown + } +} + +interface PackageInfo { + readonly name: string + readonly root: string + readonly manifest: PackageManifest + readonly declarationRoot: string + readonly exports: unknown +} + +const readManifest = (path: string): PackageManifest => JSON.parse(readFileSync(path, "utf8")) + +const walk = (root: string, predicate: (path: string) => boolean): ReadonlyArray => { + const output: Array = [] + const visit = (directory: string): void => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + if (entry.name === "node_modules" || entry.name === ".git") { + continue + } + const path = join(directory, entry.name) + if (entry.isDirectory()) { + visit(path) + } else if (predicate(path)) { + output.push(path) + } + } + } + if (existsSync(root)) { + visit(root) + } + return output.sort() +} + +const packagesIn = (repoRoot: string): ReadonlyArray => + walk(join(repoRoot, "packages"), (path) => path.endsWith(`${sep}package.json`)) + .map((path): PackageInfo | undefined => { + const sourceRoot = dirname(path) + const packedPath = join(sourceRoot, "dist", "package.json") + const packed = existsSync(packedPath) + const manifest = readManifest(packed ? packedPath : path) + if (manifest.name === undefined || manifest.private === true) { + return undefined + } + return { + name: manifest.name, + root: packed ? join(sourceRoot, "dist") : sourceRoot, + declarationRoot: packed ? join(sourceRoot, "dist") : join(sourceRoot, "dist"), + exports: packed ? manifest.exports : (manifest.publishConfig?.exports ?? manifest.exports), + manifest + } + }) + .filter((entry): entry is PackageInfo => entry !== undefined) + .sort((left, right) => right.name.length - left.name.length) + +const exportedTarget = (value: unknown): string | null | undefined => { + if (value === null || typeof value === "string") { + return value + } + if (typeof value !== "object" || value === null) { + return undefined + } + for (const condition of ["types", "import", "default", "node", "browser"]) { + const target = exportedTarget(Reflect.get(value, condition)) + if (target !== undefined) { + return target + } + } + return undefined +} + +const matchPattern = (pattern: string, value: string): string | undefined => { + const star = pattern.indexOf("*") + if (star === -1) { + return pattern === value ? "" : undefined + } + const prefix = pattern.slice(0, star) + const suffix = pattern.slice(star + 1) + return value.startsWith(prefix) && value.endsWith(suffix) + ? value.slice(prefix.length, value.length - suffix.length) + : undefined +} + +const moduleParts = (module: string, packages: ReadonlyArray): { + readonly packageInfo: PackageInfo + readonly key: string +} | undefined => { + const packageInfo = packages.find((entry) => module === entry.name || module.startsWith(`${entry.name}/`)) + if (packageInfo === undefined) { + return undefined + } + return { + packageInfo, + key: module === packageInfo.name ? "." : `.${module.slice(packageInfo.name.length)}` + } +} + +const targetToDeclaration = (packageInfo: PackageInfo, target: string): string => { + const relativeTarget = target.replace(/^\.\//, "") + const declaration = relativeTarget.endsWith(".d.ts") + ? relativeTarget + : relativeTarget.replace(/\.(?:mjs|cjs|js|mts|cts|ts)$/, ".d.ts") + return resolve(packageInfo.root, declaration) +} + +const expandPattern = ( + packageInfo: PackageInfo, + keyPattern: string, + targetPattern: string, + requested: ReadonlySet +): ReadonlyArray => { + const candidates = walk(packageInfo.declarationRoot, (path) => path.endsWith(".d.ts")) + const targetNormalized = targetPattern.endsWith(".d.ts") + ? targetPattern + : targetPattern.replace(/\.(?:mjs|cjs|js|mts|cts|ts)$/, ".d.ts") + const entries: Array = [] + for (const declarationFile of candidates) { + const relativeFile = `./${relative(packageInfo.root, declarationFile).split(sep).join("/")}` + const capture = matchPattern(targetNormalized, relativeFile) + if (capture === undefined) { + continue + } + const key = keyPattern.replace("*", capture) + const module = key === "." ? packageInfo.name : `${packageInfo.name}${key.slice(1)}` + if (requested.has(module)) { + entries.push({ packageName: packageInfo.name, module, declarationFile }) + } + } + return entries +} + +export const discoverEntrypoints = ( + repoRoot: string, + requestedModules: ReadonlyArray +): { readonly entrypoints: ReadonlyArray; readonly missing: ReadonlyArray } => { + const packages = packagesIn(repoRoot) + const requested = new Set(requestedModules) + const output = new Map() + + for (const module of requested) { + const parts = moduleParts(module, packages) + if (parts === undefined || typeof parts.packageInfo.exports !== "object" || parts.packageInfo.exports === null) { + continue + } + const exportsMap = parts.packageInfo.exports as Record + const exact = Object.prototype.hasOwnProperty.call(exportsMap, parts.key) + ? exportedTarget(exportsMap[parts.key]) + : undefined + if (typeof exact === "string") { + const declarationFile = targetToDeclaration(parts.packageInfo, exact) + if (existsSync(declarationFile) && statSync(declarationFile).isFile()) { + output.set(module, { packageName: parts.packageInfo.name, module, declarationFile }) + } + continue + } + if (exact === null) { + continue + } + for (const [keyPattern, rawTarget] of Object.entries(exportsMap)) { + if (!keyPattern.includes("*")) { + continue + } + const capture = matchPattern(keyPattern, parts.key) + const target = exportedTarget(rawTarget) + if (capture === undefined || typeof target !== "string") { + continue + } + const exclusions = Object.entries(exportsMap).some(([excludedKey, excludedTarget]) => + excludedTarget === null && matchPattern(excludedKey, parts.key) !== undefined + ) + if (exclusions) { + continue + } + if (target.includes("*")) { + const declarationFile = targetToDeclaration(parts.packageInfo, target.replace("*", capture)) + if (existsSync(declarationFile)) { + output.set(module, { packageName: parts.packageInfo.name, module, declarationFile }) + } + } + } + } + + for (const packageInfo of packages) { + if (typeof packageInfo.exports !== "object" || packageInfo.exports === null) { + continue + } + for (const [keyPattern, rawTarget] of Object.entries(packageInfo.exports as Record)) { + const target = exportedTarget(rawTarget) + if (keyPattern.includes("*") && typeof target === "string" && target.includes("*")) { + for (const entry of expandPattern(packageInfo, keyPattern, target, requested)) { + if (!output.has(entry.module)) { + output.set(entry.module, entry) + } + } + } + } + } + + return { + entrypoints: [...output.values()].sort((left, right) => left.module.localeCompare(right.module)), + missing: requestedModules.filter((module) => !output.has(module)).sort() + } +} diff --git a/packages/tools/api-diff/src/Json.ts b/packages/tools/api-diff/src/Json.ts new file mode 100644 index 00000000000..de4989cfe4c --- /dev/null +++ b/packages/tools/api-diff/src/Json.ts @@ -0,0 +1,30 @@ +import { createHash } from "node:crypto" +import { mkdirSync, readFileSync, writeFileSync } from "node:fs" +import { dirname } from "node:path" + +export const readJson = (path: string): unknown => JSON.parse(readFileSync(path, "utf8")) + +export const stableJson = (value: unknown): string => { + const visit = (input: unknown): unknown => { + if (Array.isArray(input)) { + return input.map(visit) + } + if (input !== null && typeof input === "object") { + return Object.fromEntries( + Object.entries(input) + .filter(([, entry]) => entry !== undefined) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => [key, visit(entry)]) + ) + } + return input + } + return JSON.stringify(visit(value)) +} + +export const fingerprint = (value: unknown): string => createHash("sha256").update(stableJson(value)).digest("hex") + +export const writeJson = (path: string, value: unknown): void => { + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`) +} diff --git a/packages/tools/api-diff/src/Mapping.ts b/packages/tools/api-diff/src/Mapping.ts new file mode 100644 index 00000000000..ac7b5fca28c --- /dev/null +++ b/packages/tools/api-diff/src/Mapping.ts @@ -0,0 +1,262 @@ +import { existsSync, readFileSync } from "node:fs" +import { resolve } from "node:path" +import { readJson } from "./Json.ts" +import type { ApiMapping, ApiSnapshot, ApiTarget, MappingDiagnostic, MigrationMap, ModuleMapping } from "./Model.ts" + +const isStringArray = (value: unknown): value is ReadonlyArray => + Array.isArray(value) && value.every((entry) => typeof entry === "string") + +const isTarget = (value: unknown): value is ApiTarget => + typeof value === "object" && value !== null && + typeof Reflect.get(value, "module") === "string" && + isStringArray(Reflect.get(value, "path")) && + (Reflect.get(value, "bucket") === undefined || + Reflect.get(value, "bucket") === "type" || + Reflect.get(value, "bucket") === "value") + +export const parseMigrationMap = (path: string): MigrationMap => { + const value = readJson(path) + if (typeof value !== "object" || value === null || Reflect.get(value, "version") !== 1) { + throw new Error(`Unsupported migration map version in ${path}`) + } + const modules = Reflect.get(value, "modules") + const apis = Reflect.get(value, "apis") + if (!Array.isArray(modules) || !Array.isArray(apis)) { + throw new Error(`Migration map must contain modules and apis arrays: ${path}`) + } + for (const [index, module] of modules.entries()) { + if ( + typeof module !== "object" || module === null || + (Reflect.get(module, "from") !== undefined && typeof Reflect.get(module, "from") !== "string") || + !isStringArray(Reflect.get(module, "to")) || + !["added", "moved", "removed", "split", "consolidated", "unchanged"].includes( + String(Reflect.get(module, "status")) + ) + ) { + throw new Error(`Invalid module mapping at modules[${index}]`) + } + } + for (const [index, api] of apis.entries()) { + if ( + typeof api !== "object" || api === null || + !isTarget(Reflect.get(api, "from")) || + !(Reflect.get(api, "to") === null || isTarget(Reflect.get(api, "to"))) + ) { + throw new Error(`Invalid API mapping at apis[${index}]`) + } + } + return value as unknown as MigrationMap +} + +const targetKey = (target: ApiTarget): string => `${target.module}#${target.path.join(".")}#${target.bucket ?? "*"}` + +const entityMatchesTarget = (snapshot: ApiSnapshot, target: ApiTarget): number => + snapshot.entities.filter((entity) => + entity.module === target.module && + entity.path.join(".") === target.path.join(".") && + (target.bucket === undefined || entity.bucket === target.bucket) + ).length + +const linkedGuideMappings = (repoRoot: string, mapping: ApiMapping): ReadonlyArray => { + if (mapping.guide === undefined) { + return [] + } + const path = resolve(repoRoot, mapping.guide) + if (!existsSync(path)) { + return [] + } + const source = readFileSync(path, "utf8") + const fromName = mapping.from.path.at(-1) + if (fromName === undefined) { + return [] + } + const results: Array = [] + const escaped = fromName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + for (const match of source.matchAll(new RegExp(`\\b${escaped}\\b[^\\n|→]*?(?:→|\\|)\\s*\`?([A-Za-z][\\w.]*)`, "g"))) { + if (match[1] !== undefined) { + results.push(match[1]) + } + } + return results +} + +export interface ValidateMappingOptions { + readonly base?: ApiSnapshot + readonly head?: ApiSnapshot + readonly repoRoot?: string +} + +export const validateMigrationMap = ( + mapping: MigrationMap, + options: ValidateMappingOptions = {} +): ReadonlyArray => { + const diagnostics: Array = [] + const moduleSources = new Map() + for (const entry of mapping.modules) { + if (entry.from !== undefined) { + const previous = moduleSources.get(entry.from) + if (previous !== undefined) { + diagnostics.push({ + severity: "error", + code: "duplicate-module-source", + message: `Duplicate module source ${entry.from}` + }) + } else { + moduleSources.set(entry.from, entry) + } + } + if (entry.status === "removed" && entry.to.length !== 0) { + diagnostics.push({ + severity: "error", + code: "contradictory-module-status", + message: `Removed module ${entry.from ?? ""} has targets` + }) + } + if (entry.status !== "removed" && entry.status !== "added" && entry.to.length === 0) { + diagnostics.push({ + severity: "error", + code: "missing-module-target", + message: `Module ${entry.from ?? ""} has no target` + }) + } + } + + const apiSources = new Set() + for (const entry of mapping.apis) { + const key = targetKey(entry.from) + if (apiSources.has(key)) { + diagnostics.push({ + severity: "error", + code: "duplicate-api-source", + message: `Duplicate API source ${key}` + }) + } + apiSources.add(key) + if (options.base !== undefined) { + const count = entityMatchesTarget(options.base, entry.from) + if (count === 0) { + diagnostics.push({ + severity: "error", + code: "missing-api-source", + message: `Mapped API source does not resolve: ${key}` + }) + } else if (count > 1 && entry.from.bucket === undefined) { + diagnostics.push({ + severity: "error", + code: "ambiguous-api-source", + message: `Mapped API source resolves to ${count} facets; specify a bucket: ${key}` + }) + } + } + if (options.head !== undefined && entry.to !== null) { + const count = entityMatchesTarget(options.head, entry.to) + if (count === 0) { + diagnostics.push({ + severity: "error", + code: "missing-api-target", + message: `Mapped API target does not resolve: ${targetKey(entry.to)}` + }) + } else if (count > 1 && entry.to.bucket === undefined) { + diagnostics.push({ + severity: "error", + code: "ambiguous-api-target", + message: `Mapped API target resolves to ${count} facets; specify a bucket: ${targetKey(entry.to)}` + }) + } + } + if (options.repoRoot !== undefined && entry.to !== null) { + const documented = linkedGuideMappings(options.repoRoot, entry) + const expected = entry.to.path.join(".") + for (const actual of documented) { + if (actual !== expected && !actual.endsWith(`.${expected}`)) { + diagnostics.push({ + severity: "error", + code: "contradictory-guide", + message: `${entry.guide} maps ${entry.from.path.join(".")} to ${actual}, not ${expected}` + }) + } + } + } + } + return diagnostics.sort((left, right) => + left.code.localeCompare(right.code) || left.message.localeCompare(right.message) + ) +} + +export const mappingModules = ( + mapping: MigrationMap +): { readonly base: ReadonlyArray; readonly head: ReadonlyArray } => { + const packageName = (module: string): string => { + const parts = module.split("/") + return module.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0]! + } + const sources = [ + ...mapping.modules.flatMap((entry) => entry.from === undefined ? [] : [entry.from]), + ...mapping.apis.map((entry) => entry.from.module) + ] + const targets = [ + ...mapping.modules.flatMap((entry) => [...entry.to, ...(entry.barrels ?? [])]), + ...mapping.apis.flatMap((entry) => entry.to === null ? [] : [entry.to.module]) + ] + return { + base: [...new Set([...sources, ...sources.map(packageName)])].sort(), + head: [...new Set(targets)].sort() + } +} + +export const renderMigrationMarkdown = (mapping: MigrationMap): string => { + const mapped = mapping.modules.filter((entry) => entry.from !== undefined) + const added = mapping.modules.filter((entry) => entry.from === undefined) + const lines = [ + "# v3 to v4 Import and API Rename Maps", + "", + `Mapped modules: ${mapped.reduce((total, entry) => total + entry.to.length, 0)}`, + `No counterpart: ${added.length}`, + `API renames: ${mapping.apis.length}`, + "", + "This file is generated from `migration/v3-to-v4.json`. Do not edit it directly.", + "", + "## Import Map", + "", + "Each line is `v3 import -> v4 direct module import`. Suggested barrels are shown in parentheses.", + "", + "```text", + ...mapped.map((entry) => { + const target = entry.to.join(", ") + const barrel = entry.barrels?.length === undefined || entry.barrels.length === 0 + ? "" + : ` (barrel: ${entry.barrels.join(", ")})` + return `${entry.from} -> ${target}${barrel}` + }), + "```", + "", + "## No Counterpart Imports", + "", + "These v4 modules did not have a mapped v3 module.", + "", + "```text", + ...added.flatMap((entry) => + entry.to.map((target) => { + const barrel = entry.barrels?.length === undefined || entry.barrels.length === 0 + ? "" + : ` (barrel: ${entry.barrels.join(", ")})` + return `${target}${barrel}` + }) + ), + "```", + "", + "## API Renames", + "", + "Each line is `v3 API -> v4 API`.", + "", + "```text", + ...mapping.apis.map((entry) => { + const from = `${entry.from.module}#${entry.from.path.join(".")}` + const to = entry.to === null ? "Removed" : `${entry.to.module}#${entry.to.path.join(".")}` + return `${from} -> ${to}` + }), + "```", + "" + ] + return lines.join("\n") +} diff --git a/packages/tools/api-diff/src/Model.ts b/packages/tools/api-diff/src/Model.ts new file mode 100644 index 00000000000..165c135f20b --- /dev/null +++ b/packages/tools/api-diff/src/Model.ts @@ -0,0 +1,192 @@ +export type Bucket = "type" | "value" + +export interface SourceLocation { + readonly file: string + readonly line: number + readonly column: number +} + +export interface Documentation { + readonly summary?: string | undefined + readonly deprecated?: string | undefined + readonly since?: string | undefined + readonly category?: string | undefined + readonly stability?: "stable" | "unstable" | undefined +} + +export interface TypeModel { + readonly kind: string + readonly [key: string]: unknown +} + +export interface DeclarationModel { + readonly kind: string + readonly name: string + readonly typeParameters?: ReadonlyArray | undefined + readonly parameters?: ReadonlyArray | undefined + readonly returnType?: TypeModel | undefined + readonly type?: TypeModel | undefined + readonly members?: ReadonlyArray | undefined + readonly overloads?: ReadonlyArray | undefined + readonly heritage?: ReadonlyArray | undefined + readonly modifiers?: ReadonlyArray | undefined + readonly value?: string | number | undefined +} + +export interface TypeParameterModel { + readonly id: string + readonly displayName: string + readonly constraint?: TypeModel | undefined + readonly default?: TypeModel | undefined +} + +export interface ParameterModel { + readonly name: string + readonly type: TypeModel + readonly optional: boolean + readonly rest: boolean + readonly modifiers?: ReadonlyArray | undefined +} + +export interface ImportRoute { + readonly module: string + readonly path: ReadonlyArray +} + +export interface ApiEntity { + readonly id: string + readonly packageName: string + readonly module: string + readonly path: ReadonlyArray + readonly bucket: Bucket + readonly declarationKind: string + readonly importRoutes: ReadonlyArray + readonly declarations: ReadonlyArray + readonly displaySignature: string + readonly fingerprint: string + readonly documentation: Documentation + readonly source: SourceLocation +} + +export interface Entrypoint { + readonly packageName: string + readonly module: string + readonly declarationFile: string +} + +export interface SnapshotDiagnostic { + readonly code: string + readonly message: string + readonly module?: string | undefined + readonly path?: ReadonlyArray | undefined + readonly source?: SourceLocation | undefined +} + +export interface ApiSnapshot { + readonly version: 1 + readonly compiler: { + readonly name: "typescript" + readonly version: string + } + readonly ref: string + readonly sha: string + readonly packages: ReadonlyArray + readonly entrypoints: ReadonlyArray + readonly entities: ReadonlyArray + readonly diagnostics: ReadonlyArray +} + +export type ModuleStatus = "added" | "moved" | "removed" | "split" | "consolidated" | "unchanged" + +export interface ModuleMapping { + readonly from?: string | undefined + readonly to: ReadonlyArray + readonly status: ModuleStatus + readonly barrels?: ReadonlyArray | undefined + readonly guide?: string | undefined + readonly note?: string | undefined +} + +export interface ApiTarget { + readonly module: string + readonly path: ReadonlyArray + readonly bucket?: Bucket | undefined +} + +export interface ApiMapping { + readonly from: ApiTarget + readonly to: ApiTarget | null + readonly guide?: string | undefined + readonly note?: string | undefined +} + +export interface MigrationMap { + readonly version: 1 + readonly modules: ReadonlyArray + readonly apis: ReadonlyArray +} + +export interface MappingDiagnostic { + readonly severity: "error" | "warning" + readonly code: string + readonly message: string +} + +export type ChangeClassification = + | "package-added" + | "package-removed" + | "module-added" + | "module-removed" + | "module-moved" + | "module-split" + | "module-consolidated" + | "api-added" + | "api-removed" + | "api-moved" + | "api-renamed" + | "bucket-changed" + | "declaration-kind-changed" + | "overload-added" + | "overload-removed" + | "overload-reordered" + | "parameter-added" + | "parameter-removed" + | "parameter-reordered" + | "parameter-changed" + | "return-type-changed" + | "generic-parameter-changed" + | "member-added" + | "member-removed" + | "member-changed" + | "heritage-changed" + | "union-member-changed" + | "intersection-member-changed" + | "documentation-changed" + | "structural-change" + | "unsupported" + +export interface ApiChange { + readonly id: string + readonly classification: ChangeClassification + readonly confidence: number + readonly baseApiId?: string | undefined + readonly headApiId?: string | undefined + readonly before?: string | undefined + readonly after?: string | undefined + readonly delta?: unknown + readonly baseSource?: SourceLocation | undefined + readonly headSource?: SourceLocation | undefined + readonly mapping?: ApiMapping | ModuleMapping | undefined + readonly guide?: string | undefined + readonly reviewNotes?: string | undefined + readonly authoritative: boolean +} + +export interface ApiDiff { + readonly version: 1 + readonly base: { readonly ref: string; readonly sha: string } + readonly head: { readonly ref: string; readonly sha: string } + readonly mappingVersion: number + readonly mappingDiagnostics: ReadonlyArray + readonly changes: ReadonlyArray +} diff --git a/packages/tools/api-diff/src/Report.ts b/packages/tools/api-diff/src/Report.ts new file mode 100644 index 00000000000..87d28ebd6a7 --- /dev/null +++ b/packages/tools/api-diff/src/Report.ts @@ -0,0 +1,157 @@ +import type { ApiChange, ApiDiff } from "./Model.ts" + +const escapeCell = (value: string): string => value.replaceAll("|", "\\|").replaceAll("\n", " ") + +const changeLabel = (change: ApiChange): string => + change.baseApiId === undefined + ? change.headApiId ?? JSON.stringify(change.delta) + : change.headApiId === undefined + ? change.baseApiId + : `${change.baseApiId} → ${change.headApiId}` + +const signature = (label: string, source: string | undefined): ReadonlyArray => + source === undefined ? [] : ["", `${label}:`, "", "```ts", source, "```"] + +const renderChange = (change: ApiChange, level = 4): ReadonlyArray => [ + `${"#".repeat(level)} ${change.classification}: \`${changeLabel(change)}\``, + "", + `Confidence: ${change.confidence.toFixed(3)} · ${change.authoritative ? "authoritative" : "review required"}`, + ...(change.guide === undefined ? [] : ["", `Migration guide: [${change.guide}](${change.guide})`]), + ...(change.reviewNotes === undefined ? [] : ["", change.reviewNotes]), + ...signature("Before", change.before), + ...signature("After", change.after), + "" +] + +const orderedGroups = [ + ["Renames and moves", new Set(["module-moved", "module-split", "module-consolidated", "api-moved", "api-renamed"])], + ["Removals", new Set(["package-removed", "module-removed", "api-removed"])], + ["Additions", new Set(["package-added", "module-added", "api-added"])], + [ + "Signature and structural changes", + new Set([ + "bucket-changed", + "declaration-kind-changed", + "overload-added", + "overload-removed", + "overload-reordered", + "parameter-added", + "parameter-removed", + "parameter-reordered", + "parameter-changed", + "return-type-changed", + "generic-parameter-changed", + "member-added", + "member-removed", + "member-changed", + "heritage-changed", + "union-member-changed", + "intersection-member-changed", + "structural-change", + "unsupported" + ]) + ], + ["Documentation changes", new Set(["documentation-changed"])] +] as const + +export const renderMarkdownReport = (diff: ApiDiff): string => { + const counts = new Map() + for (const change of diff.changes) { + counts.set(change.classification, (counts.get(change.classification) ?? 0) + 1) + } + const authoritative = diff.changes.filter((change) => change.authoritative) + const suggested = diff.changes.filter((change) => !change.authoritative) + const moduleCounts = new Map() + for (const change of diff.changes) { + const id = change.headApiId ?? change.baseApiId + const mappedModule = change.mapping === undefined + ? undefined + : "status" in change.mapping + ? change.mapping.from ?? change.mapping.to[0] + : change.mapping.from.module + const module = id?.split("#")[0] ?? mappedModule ?? "" + moduleCounts.set(module, (moduleCounts.get(module) ?? 0) + 1) + } + const lines: Array = [ + "# TypeScript API Diff", + "", + `Base: \`${diff.base.ref}\` (\`${diff.base.sha}\`)`, + "", + `Head: \`${diff.head.ref}\` (\`${diff.head.sha}\`)`, + "", + "This report describes structural API changes and review confidence; it is not a semantic-version compatibility claim.", + "", + "## Summary", + "", + "| Classification | Count |", + "| --- | ---: |", + ...[...counts].sort(([left], [right]) => left.localeCompare(right)) + .map(([classification, count]) => `| ${escapeCell(classification)} | ${count} |`), + "", + "## Changes by module", + "", + "| Domain | Module | Count |", + "| --- | --- | ---: |", + ...[...moduleCounts].sort(([left], [right]) => left.localeCompare(right)).map(([module, count]) => { + const unstable = module.match(/^effect\/unstable\/([^/]+)/) + const domain = unstable?.[1] === undefined + ? module.startsWith("@effect/") + ? module.split("/").slice(0, 2).join("/") + : "stable" + : `unstable/${unstable[1]}` + return `| ${escapeCell(domain)} | ${escapeCell(module)} | ${count} |` + }), + "", + "## Mapping validation", + "" + ] + if (diff.mappingDiagnostics.length === 0) { + lines.push("No mapping diagnostics.", "") + } else { + lines.push( + "| Severity | Code | Message |", + "| --- | --- | --- |", + ...diff.mappingDiagnostics.map((diagnostic) => + `| ${diagnostic.severity} | ${diagnostic.code} | ${escapeCell(diagnostic.message)} |` + ), + "" + ) + } + const sections = [ + [ + "Stable API changes", + authoritative.filter((change) => + !(change.baseApiId ?? change.headApiId ?? JSON.stringify(change.delta)).includes("/unstable/") + ) + ], + [ + "Unstable API changes", + authoritative.filter((change) => + (change.baseApiId ?? change.headApiId ?? JSON.stringify(change.delta)).includes("/unstable/") + ) + ] + ] as const + for (const [section, sectionChanges] of sections) { + if (sectionChanges.length === 0) { + continue + } + lines.push(`## ${section}`, "") + for (const [title, classifications] of orderedGroups) { + const changes = sectionChanges.filter((change) => classifications.has(change.classification)) + if (changes.length === 0) { + continue + } + lines.push(`### ${title}`, "") + for (const change of changes) { + lines.push(...renderChange(change)) + } + } + } + if (suggested.length > 0) { + lines.push("## Suggested matches requiring review", "") + for (const change of suggested) { + lines.push(...renderChange(change, 3)) + } + } + return `${lines.join("\n").trim()}\n` +} diff --git a/packages/tools/api-diff/src/Snapshot.ts b/packages/tools/api-diff/src/Snapshot.ts new file mode 100644 index 00000000000..9a369d18d67 --- /dev/null +++ b/packages/tools/api-diff/src/Snapshot.ts @@ -0,0 +1,832 @@ +import { createHash } from "node:crypto" +import { relative, sep } from "node:path" +import ts from "typescript-compiler" +import { discoverEntrypoints } from "./Discovery.ts" +import { fingerprint, stableJson } from "./Json.ts" +import type { + ApiEntity, + ApiSnapshot, + Bucket, + DeclarationModel, + Documentation, + Entrypoint, + ImportRoute, + ParameterModel, + SnapshotDiagnostic, + SourceLocation, + TypeModel, + TypeParameterModel +} from "./Model.ts" + +interface SerializationContext { + readonly checker: ts.TypeChecker + readonly repoRoot: string + readonly typeParameters: ReadonlyMap + readonly publicSymbols: ReadonlyMap +} + +const normalizedPath = (root: string, path: string): string => relative(root, path).split(sep).join("/") + +const sourceLocation = (root: string, node: ts.Node): SourceLocation => { + const source = node.getSourceFile() + const position = source.getLineAndCharacterOfPosition(node.getStart(source, false)) + return { + file: normalizedPath(root, source.fileName), + line: position.line + 1, + column: position.character + 1 + } +} + +const modifiers = (node: ts.Node): ReadonlyArray | undefined => { + if (!ts.canHaveModifiers(node)) { + return undefined + } + const values = ts.getModifiers(node)?.map((modifier) => + ts.tokenToString(modifier.kind) ?? ts.SyntaxKind[modifier.kind] + ) + .filter((modifier) => modifier !== "export" && modifier !== "declare") + .sort() + return values === undefined || values.length === 0 ? undefined : values +} + +const nameText = (name: ts.DeclarationName | ts.BindingName | undefined): string => { + if (name === undefined) { + return "" + } + if (ts.isIdentifier(name) || ts.isPrivateIdentifier(name)) { + return name.text + } + if (ts.isStringLiteral(name) || ts.isNumericLiteral(name)) { + return name.text + } + return name.getText() +} + +const sortModels = (values: ReadonlyArray): ReadonlyArray => + [...values].sort((left, right) => stableJson(left).localeCompare(stableJson(right))) + +const referenceResolution = (name: ts.EntityName, context: SerializationContext): unknown => { + let symbol = context.checker.getSymbolAtLocation(name) + if (symbol === undefined) { + return { unresolved: true } + } + if (symbol !== undefined && (symbol.flags & ts.SymbolFlags.Alias) !== 0) { + symbol = context.checker.getAliasedSymbol(symbol) + } + const declaration = symbol?.declarations?.[0] + if (declaration === undefined) { + return { unresolved: true } + } + const publicApiId = context.publicSymbols.get(symbolIdentity(symbol, "type")) ?? + context.publicSymbols.get(symbolIdentity(symbol, "value")) + if (publicApiId !== undefined) { + return { apiId: publicApiId } + } + const file = declaration.getSourceFile().fileName + const nodeModules = file.lastIndexOf(`${sep}node_modules${sep}`) + if (nodeModules !== -1) { + const remainder = file.slice(nodeModules + `${sep}node_modules${sep}`.length).split(sep) + return { + externalPackage: remainder[0]?.startsWith("@") ? remainder.slice(0, 2).join("/") : remainder[0] + } + } + return { declaration: normalizedPath(context.repoRoot, file) } +} + +const primitiveKinds = new Map([ + [ts.SyntaxKind.AnyKeyword, "any"], + [ts.SyntaxKind.BigIntKeyword, "bigint"], + [ts.SyntaxKind.BooleanKeyword, "boolean"], + [ts.SyntaxKind.IntrinsicKeyword, "intrinsic"], + [ts.SyntaxKind.NeverKeyword, "never"], + [ts.SyntaxKind.NumberKeyword, "number"], + [ts.SyntaxKind.ObjectKeyword, "object"], + [ts.SyntaxKind.StringKeyword, "string"], + [ts.SyntaxKind.SymbolKeyword, "symbol"], + [ts.SyntaxKind.UndefinedKeyword, "undefined"], + [ts.SyntaxKind.UnknownKeyword, "unknown"], + [ts.SyntaxKind.VoidKeyword, "void"] +]) + +const serializeTypeParameters = ( + nodes: ts.NodeArray | undefined, + context: SerializationContext +): { readonly parameters?: ReadonlyArray; readonly context: SerializationContext } => { + if (nodes === undefined || nodes.length === 0) { + return { context } + } + const names = new Map(context.typeParameters) + nodes.forEach((node, index) => names.set(node.name.text, `T${index}`)) + const next = { ...context, typeParameters: names } + return { + context: next, + parameters: nodes.map((node, index) => ({ + id: `T${index}`, + displayName: node.name.text, + constraint: node.constraint === undefined ? undefined : serializeType(node.constraint, next), + default: node.default === undefined ? undefined : serializeType(node.default, next) + })) + } +} + +const serializeParameters = ( + nodes: ts.NodeArray, + context: SerializationContext +): ReadonlyArray => + nodes.map((node) => ({ + name: nameText(node.name), + type: node.type === undefined ? { kind: "unknown" } : serializeType(node.type, context), + optional: node.questionToken !== undefined || node.initializer !== undefined, + rest: node.dotDotDotToken !== undefined, + modifiers: modifiers(node) + })) + +const serializeSignature = ( + node: ts.SignatureDeclarationBase, + context: SerializationContext, + kind: string, + name = "" +): DeclarationModel => { + const generic = serializeTypeParameters(node.typeParameters, context) + return { + kind, + name, + typeParameters: generic.parameters, + parameters: serializeParameters(node.parameters, generic.context), + returnType: node.type === undefined ? { kind: "unknown" } : serializeType(node.type, generic.context), + modifiers: modifiers(node) + } +} + +const serializeTypeMember = (node: ts.TypeElement, context: SerializationContext): DeclarationModel => { + if (ts.isPropertySignature(node)) { + return { + kind: "property", + name: nameText(node.name), + type: node.type === undefined ? { kind: "unknown" } : serializeType(node.type, context), + modifiers: [ + ...(modifiers(node) ?? []), + ...(node.questionToken === undefined ? [] : ["optional"]) + ].sort() + } + } + if (ts.isMethodSignature(node)) { + return { + ...serializeSignature(node, context, "method", nameText(node.name)), + modifiers: [ + ...(modifiers(node) ?? []), + ...(node.questionToken === undefined ? [] : ["optional"]) + ].sort() + } + } + if (ts.isCallSignatureDeclaration(node)) { + return serializeSignature(node, context, "call-signature") + } + if (ts.isConstructSignatureDeclaration(node)) { + return serializeSignature(node, context, "construct-signature") + } + if (ts.isIndexSignatureDeclaration(node)) { + return serializeSignature(node, context, "index-signature") + } + if (ts.isGetAccessorDeclaration(node)) { + return serializeSignature(node, context, "getter", nameText(node.name)) + } + if (ts.isSetAccessorDeclaration(node)) { + return serializeSignature(node, context, "setter", nameText(node.name)) + } + throw new Error(`Unsupported public type member: ${ts.SyntaxKind[node.kind]}`) +} + +const serializeObjectMembers = ( + members: ts.NodeArray, + context: SerializationContext +): ReadonlyArray => + members.map((member) => serializeTypeMember(member, context)) + .sort((left, right) => + `${left.kind}:${left.name}:${stableJson(left)}`.localeCompare( + `${right.kind}:${right.name}:${stableJson(right)}` + ) + ) + +export const serializeType = (node: ts.TypeNode, context: SerializationContext): TypeModel => { + const primitive = primitiveKinds.get(node.kind) + if (primitive !== undefined) { + return { kind: "primitive", name: primitive } + } + if (ts.isParenthesizedTypeNode(node)) { + return serializeType(node.type, context) + } + if (ts.isLiteralTypeNode(node)) { + const literal = node.literal + return { + kind: "literal", + value: literal.kind === ts.SyntaxKind.TrueKeyword + ? true + : literal.kind === ts.SyntaxKind.FalseKeyword + ? false + : ts.isPrefixUnaryExpression(literal) + ? literal.getText() + : Reflect.get(literal, "text") ?? literal.getText() + } + } + if (ts.isTypeReferenceNode(node)) { + const rawName = node.typeName.getText() + const genericId = context.typeParameters.get(rawName) + if (genericId !== undefined) { + return { kind: "type-parameter", id: genericId, displayName: rawName } + } + return { + kind: "reference", + name: rawName, + arguments: node.typeArguments?.map((argument) => serializeType(argument, context)) ?? [], + resolution: referenceResolution(node.typeName, context) + } + } + if (ts.isUnionTypeNode(node) || ts.isIntersectionTypeNode(node)) { + return { + kind: ts.isUnionTypeNode(node) ? "union" : "intersection", + members: sortModels(node.types.map((type) => serializeType(type, context))) + } + } + if (ts.isArrayTypeNode(node)) { + return { kind: "array", element: serializeType(node.elementType, context) } + } + if (ts.isTupleTypeNode(node)) { + return { kind: "tuple", elements: node.elements.map((element) => serializeType(element, context)) } + } + if (ts.isNamedTupleMember(node)) { + return { + kind: "named-tuple-member", + name: node.name.text, + optional: node.questionToken !== undefined, + rest: node.dotDotDotToken !== undefined, + type: serializeType(node.type, context) + } + } + if (ts.isOptionalTypeNode(node)) { + return { kind: "optional", type: serializeType(node.type, context) } + } + if (ts.isRestTypeNode(node)) { + return { kind: "rest", type: serializeType(node.type, context) } + } + if (ts.isFunctionTypeNode(node) || ts.isConstructorTypeNode(node)) { + return { + kind: ts.isFunctionTypeNode(node) ? "function" : "constructor", + signature: serializeSignature(node, context, "signature") + } + } + if (ts.isTypeLiteralNode(node)) { + return { kind: "object", members: serializeObjectMembers(node.members, context) } + } + if (ts.isMappedTypeNode(node)) { + const generic = serializeTypeParameters(ts.factory.createNodeArray([node.typeParameter]), context) + return { + kind: "mapped", + typeParameter: generic.parameters?.[0], + readonly: node.readonlyToken?.kind === ts.SyntaxKind.MinusToken + ? "remove" + : node.readonlyToken === undefined + ? "none" + : "add", + optional: node.questionToken?.kind === ts.SyntaxKind.MinusToken + ? "remove" + : node.questionToken === undefined + ? "none" + : "add", + nameType: node.nameType === undefined ? undefined : serializeType(node.nameType, generic.context), + type: node.type === undefined ? undefined : serializeType(node.type, generic.context) + } + } + if (ts.isConditionalTypeNode(node)) { + return { + kind: "conditional", + check: serializeType(node.checkType, context), + extends: serializeType(node.extendsType, context), + trueType: serializeType(node.trueType, context), + falseType: serializeType(node.falseType, context) + } + } + if (ts.isIndexedAccessTypeNode(node)) { + return { + kind: "indexed-access", + object: serializeType(node.objectType, context), + index: serializeType(node.indexType, context) + } + } + if (ts.isTypeOperatorNode(node)) { + return { + kind: "operator", + operator: ts.tokenToString(node.operator) ?? ts.SyntaxKind[node.operator], + type: serializeType(node.type, context) + } + } + if (ts.isTypeQueryNode(node)) { + return { kind: "type-query", expression: node.exprName.getText() } + } + if (ts.isImportTypeNode(node)) { + const argument = node.argument.getText().replace(/^["']|["']$/g, "") + const parts = argument.split("/") + return { + kind: "import", + argument, + externalPackage: argument.startsWith(".") + ? undefined + : parts[0]?.startsWith("@") + ? parts.slice(0, 2).join("/") + : parts[0], + qualifier: node.qualifier?.getText(), + attributes: node.attributes?.getText(), + arguments: node.typeArguments?.map((argument) => serializeType(argument, context)) ?? [], + typeof: node.isTypeOf + } + } + if (ts.isTemplateLiteralTypeNode(node)) { + return { + kind: "template-literal", + head: node.head.text, + spans: node.templateSpans.map((span) => ({ + type: serializeType(span.type, context), + literal: span.literal.text + })) + } + } + if (ts.isInferTypeNode(node)) { + const generic = serializeTypeParameters(ts.factory.createNodeArray([node.typeParameter]), context) + return { kind: "infer", typeParameter: generic.parameters?.[0] } + } + if (ts.isTypePredicateNode(node)) { + return { + kind: "predicate", + asserts: node.assertsModifier !== undefined, + parameter: node.parameterName.getText(), + type: node.type === undefined ? undefined : serializeType(node.type, context) + } + } + if (ts.isExpressionWithTypeArguments(node)) { + return { + kind: "heritage-reference", + name: node.expression.getText(), + arguments: node.typeArguments?.map((argument) => serializeType(argument, context)) ?? [] + } + } + if (node.kind === ts.SyntaxKind.ThisType) { + return { kind: "this" } + } + throw new Error(`Unsupported public type syntax: ${ts.SyntaxKind[node.kind]}`) +} + +const serializeClassMember = ( + node: ts.ClassElement, + context: SerializationContext +): DeclarationModel | undefined => { + const visibility = modifiers(node) + if (visibility?.includes("private")) { + return undefined + } + if (ts.isPropertyDeclaration(node)) { + return { + kind: "property", + name: nameText(node.name), + type: node.type === undefined ? { kind: "unknown" } : serializeType(node.type, context), + modifiers: visibility + } + } + if (ts.isMethodDeclaration(node)) { + return serializeSignature(node, context, "method", nameText(node.name)) + } + if (ts.isConstructorDeclaration(node)) { + return serializeSignature(node, context, "constructor", "constructor") + } + if (ts.isGetAccessorDeclaration(node)) { + return serializeSignature(node, context, "getter", nameText(node.name)) + } + if (ts.isSetAccessorDeclaration(node)) { + return serializeSignature(node, context, "setter", nameText(node.name)) + } + if (ts.isIndexSignatureDeclaration(node)) { + return serializeSignature(node, context, "index-signature") + } + if (ts.isClassStaticBlockDeclaration(node) || ts.isSemicolonClassElement(node)) { + return undefined + } + throw new Error(`Unsupported public class member: ${ts.SyntaxKind[node.kind]}`) +} + +const serializeHeritage = ( + clauses: ts.NodeArray | undefined, + context: SerializationContext +): ReadonlyArray | undefined => { + if (clauses === undefined) { + return undefined + } + return clauses.flatMap((clause) => + clause.types.map((type) => ({ + ...serializeType(type, context), + clause: ts.tokenToString(clause.token) ?? ts.SyntaxKind[clause.token] + })) + ).sort((left, right) => stableJson(left).localeCompare(stableJson(right))) +} + +const moduleMembers = ( + node: ts.ModuleDeclaration, + context: SerializationContext +): ReadonlyArray => { + let body = node.body + while (body !== undefined && ts.isModuleDeclaration(body)) { + body = body.body + } + if (body === undefined || !ts.isModuleBlock(body)) { + return [] + } + return body.statements.flatMap((statement) => { + if ( + ts.isFunctionDeclaration(statement) || ts.isInterfaceDeclaration(statement) || + ts.isTypeAliasDeclaration(statement) || ts.isClassDeclaration(statement) || + ts.isEnumDeclaration(statement) || ts.isModuleDeclaration(statement) || + ts.isVariableStatement(statement) + ) { + if (ts.isVariableStatement(statement)) { + return statement.declarationList.declarations.map((declaration) => serializeDeclaration(declaration, context)) + } + return [serializeDeclaration(statement, context)] + } + if (ts.isExportDeclaration(statement) || ts.isImportDeclaration(statement)) { + return [] + } + throw new Error(`Unsupported public namespace statement: ${ts.SyntaxKind[statement.kind]}`) + }).sort((left, right) => `${left.kind}:${left.name}`.localeCompare(`${right.kind}:${right.name}`)) +} + +const serializeDeclaration = (node: ts.Declaration, context: SerializationContext): DeclarationModel => { + if (ts.isFunctionDeclaration(node)) { + return serializeSignature(node, context, "function", nameText(node.name)) + } + if (ts.isVariableDeclaration(node)) { + return { + kind: "variable", + name: nameText(node.name), + type: node.type === undefined ? { kind: "unknown" } : serializeType(node.type, context) + } + } + if (ts.isTypeAliasDeclaration(node)) { + const generic = serializeTypeParameters(node.typeParameters, context) + return { + kind: "type-alias", + name: node.name.text, + typeParameters: generic.parameters, + type: serializeType(node.type, generic.context), + modifiers: modifiers(node) + } + } + if (ts.isInterfaceDeclaration(node)) { + const generic = serializeTypeParameters(node.typeParameters, context) + return { + kind: "interface", + name: node.name.text, + typeParameters: generic.parameters, + members: serializeObjectMembers(node.members, generic.context), + heritage: serializeHeritage(node.heritageClauses, generic.context), + modifiers: modifiers(node) + } + } + if (ts.isClassDeclaration(node)) { + const generic = serializeTypeParameters(node.typeParameters, context) + return { + kind: "class", + name: nameText(node.name), + typeParameters: generic.parameters, + members: node.members.flatMap((member) => { + const serialized = serializeClassMember(member, generic.context) + return serialized === undefined ? [] : [serialized] + }).sort((left, right) => + `${left.kind}:${left.name}:${stableJson(left)}`.localeCompare( + `${right.kind}:${right.name}:${stableJson(right)}` + ) + ), + heritage: serializeHeritage(node.heritageClauses, generic.context), + modifiers: modifiers(node) + } + } + if (ts.isEnumDeclaration(node)) { + return { + kind: "enum", + name: node.name.text, + members: node.members.map((member) => ({ + kind: "enum-member", + name: nameText(member.name), + value: member.initializer === undefined + ? undefined + : ts.isStringLiteral(member.initializer) || ts.isNumericLiteral(member.initializer) + ? member.initializer.text + : member.initializer.getText() + })) + } + } + if (ts.isModuleDeclaration(node)) { + return { kind: "namespace", name: nameText(node.name), members: moduleMembers(node, context) } + } + if (ts.isExportAssignment(node)) { + return { kind: "export-assignment", name: "default", value: node.expression.getText() } + } + throw new Error(`Unsupported public declaration: ${ts.SyntaxKind[node.kind]}`) +} + +const declarationKind = (declarations: ReadonlyArray): string => + [...new Set(declarations.map((declaration) => declaration.kind))].sort().join("+") + +const documentation = (symbol: ts.Symbol, checker: ts.TypeChecker, module: string): Documentation => { + const tags = new Map( + symbol.getJsDocTags(checker).map((tag) => [ + tag.name, + tag.text?.map((part) => part.text).join("").trim() ?? "" + ]) + ) + const summary = ts.displayPartsToString(symbol.getDocumentationComment(checker)).trim() + return { + summary: summary === "" ? undefined : summary, + deprecated: tags.get("deprecated"), + since: tags.get("since"), + category: tags.get("category"), + stability: module.includes("/unstable/") ? "unstable" : "stable" + } +} + +const bucketDeclarations = (symbol: ts.Symbol, bucket: Bucket): ReadonlyArray => { + const declarations = symbol.declarations ?? [] + return declarations.filter((declaration) => { + if (bucket === "type") { + return ts.isInterfaceDeclaration(declaration) || ts.isTypeAliasDeclaration(declaration) || + ts.isClassDeclaration(declaration) || ts.isEnumDeclaration(declaration) || + ts.isModuleDeclaration(declaration) + } + return ts.isFunctionDeclaration(declaration) || ts.isVariableDeclaration(declaration) || + ts.isClassDeclaration(declaration) || ts.isEnumDeclaration(declaration) || + ts.isModuleDeclaration(declaration) || ts.isExportAssignment(declaration) + }) +} + +const symbolBuckets = (symbol: ts.Symbol): ReadonlyArray => { + const output: Array = [] + if ((symbol.flags & ts.SymbolFlags.Type) !== 0) { + output.push("type") + } + if ((symbol.flags & ts.SymbolFlags.Value) !== 0 || (symbol.flags & ts.SymbolFlags.Namespace) !== 0) { + output.push("value") + } + return output +} + +const displayDeclarations = (nodes: ReadonlyArray): string => + nodes.map((node) => node.getText().replace(/\/\*\*[\s\S]*?\*\//g, "").replace(/\s+/g, " ").trim()).join("\n") + +const fingerprintDeclarations = (declarations: ReadonlyArray): string => { + const normalize = (value: unknown, parent?: string): unknown => { + if (Array.isArray(value)) { + return value.map((entry) => normalize(entry, parent)) + } + if (value !== null && typeof value === "object") { + const input = value as Record + return Object.fromEntries( + Object.entries(input).flatMap(([key, entry]) => { + if ((parent === "parameters" && key === "name") || key === "displayName") { + return [] + } + return [[key, normalize(entry, key)]] + }) + ) + } + return value + } + return fingerprint( + declarations.map((declaration) => ({ + ...declaration, + name: "", + parameters: declaration.parameters?.map((parameter) => ({ ...parameter, name: "" })) + })).map((declaration) => normalize(declaration)) + ) +} + +interface PendingEntity { + readonly symbol: ts.Symbol + readonly bucket: Bucket + readonly packageName: string + readonly routes: Array + readonly declarations: ReadonlyArray +} + +const symbolIdentity = (symbol: ts.Symbol, bucket: Bucket): string => { + const declarations = symbol.declarations ?? [] + return `${bucket}:${ + declarations.map((node) => `${node.getSourceFile().fileName}:${node.pos}:${node.end}`).sort().join("|") + }` +} + +const collectExports = ( + checker: ts.TypeChecker, + moduleSymbol: ts.Symbol, + entrypoint: Entrypoint, + pending: Map, + path: ReadonlyArray = [], + seenNamespaces: ReadonlySet = new Set() +): void => { + for ( + const exported of checker.getExportsOfModule(moduleSymbol).sort((left, right) => + left.name.localeCompare(right.name) + ) + ) { + let symbol = exported + if ((symbol.flags & ts.SymbolFlags.Alias) !== 0) { + symbol = checker.getAliasedSymbol(symbol) + } + const exportPath = [...path, exported.name] + const declarations = symbol.declarations ?? [] + for (const bucket of symbolBuckets(symbol)) { + const selected = bucketDeclarations(symbol, bucket) + if (selected.length === 0) { + continue + } + const key = symbolIdentity(symbol, bucket) + const existing = pending.get(key) + const route = { module: entrypoint.module, path: exportPath } + if (existing === undefined) { + pending.set(key, { + symbol, + bucket, + packageName: entrypoint.packageName, + routes: [route], + declarations: selected + }) + } else if ( + !existing.routes.some((candidate) => + candidate.module === route.module && candidate.path.join(".") === route.path.join(".") + ) + ) { + existing.routes.push(route) + } + } + const isNamespace = (symbol.flags & ts.SymbolFlags.Module) !== 0 + if (isNamespace && !seenNamespaces.has(symbol) && declarations.length > 0) { + collectExports( + checker, + symbol, + entrypoint, + pending, + exportPath, + new Set([...seenNamespaces, symbol]) + ) + } + } +} + +const canonicalRoute = (routes: ReadonlyArray): ImportRoute => + [...routes].sort((left, right) => + left.path.length - right.path.length || + right.module.split("/").length - left.module.split("/").length || + left.module.localeCompare(right.module) || + left.path.join(".").localeCompare(right.path.join(".")) + )[0]! + +export interface ExtractSnapshotOptions { + readonly repoRoot: string + readonly ref: string + readonly sha: string + readonly modules: ReadonlyArray +} + +export class SnapshotExtractionError extends Error { + readonly diagnostics: ReadonlyArray + + constructor(diagnostics: ReadonlyArray) { + super(diagnostics.map((diagnostic) => `${diagnostic.code}: ${diagnostic.message}`).join("\n")) + this.name = "SnapshotExtractionError" + this.diagnostics = diagnostics + } +} + +export const extractSnapshot = (options: ExtractSnapshotOptions): ApiSnapshot => { + const discovered = discoverEntrypoints(options.repoRoot, options.modules) + const diagnostics: Array = discovered.missing.map((module) => ({ + code: "missing-entrypoint", + message: `Consumer-visible declaration entrypoint not found: ${module}`, + module + })) + if (diagnostics.length > 0) { + throw new SnapshotExtractionError(diagnostics) + } + const program = ts.createProgram({ + rootNames: discovered.entrypoints.map((entrypoint) => entrypoint.declarationFile), + options: { + module: ts.ModuleKind.NodeNext, + moduleResolution: ts.ModuleResolutionKind.NodeNext, + target: ts.ScriptTarget.ESNext, + skipLibCheck: true, + types: [], + noEmit: true + } + }) + // The declarations were already checked by the branch-native build. Re-checking + // them with the pinned extractor compiler would introduce lib-version drift. + const compilerDiagnostics = program.getSyntacticDiagnostics() + .filter((diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error) + if (compilerDiagnostics.length > 0) { + throw new SnapshotExtractionError(compilerDiagnostics.map((diagnostic) => ({ + code: `typescript-${diagnostic.code}`, + message: ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"), + source: diagnostic.file === undefined + ? undefined + : sourceLocation(options.repoRoot, diagnostic.file) + }))) + } + const checker = program.getTypeChecker() + const pending = new Map() + for (const entrypoint of discovered.entrypoints) { + const source = program.getSourceFile(entrypoint.declarationFile) + const symbol = source === undefined ? undefined : checker.getSymbolAtLocation(source) + if (source === undefined || symbol === undefined) { + diagnostics.push({ + code: "missing-module-symbol", + message: `Could not load module symbol for ${entrypoint.module}`, + module: entrypoint.module + }) + continue + } + collectExports(checker, symbol, entrypoint, pending) + } + + const entities: Array = [] + const publicSymbols = new Map() + for (const [key, pendingEntity] of pending) { + const route = canonicalRoute(pendingEntity.routes) + publicSymbols.set(key, `${route.module}#${route.path.join(".")}#${pendingEntity.bucket}`) + } + for (const pendingEntity of pending.values()) { + const route = canonicalRoute(pendingEntity.routes) + const declaration = pendingEntity.declarations[0] + if (declaration === undefined) { + continue + } + let serialized: ReadonlyArray + try { + serialized = pendingEntity.declarations.map((node) => + serializeDeclaration(node, { + checker, + repoRoot: options.repoRoot, + typeParameters: new Map(), + publicSymbols + }) + ) + } catch (error) { + diagnostics.push({ + code: "unsupported-public-declaration", + message: error instanceof Error ? error.message : String(error), + module: route.module, + path: route.path, + source: sourceLocation(options.repoRoot, declaration) + }) + continue + } + const id = `${route.module}#${route.path.join(".")}#${pendingEntity.bucket}` + entities.push({ + id, + packageName: pendingEntity.packageName, + module: route.module, + path: route.path, + bucket: pendingEntity.bucket, + declarationKind: declarationKind(serialized), + importRoutes: [...pendingEntity.routes].sort((left, right) => + left.module.localeCompare(right.module) || left.path.join(".").localeCompare(right.path.join(".")) + ), + declarations: serialized, + displaySignature: displayDeclarations(pendingEntity.declarations), + fingerprint: fingerprintDeclarations(serialized), + documentation: documentation(pendingEntity.symbol, checker, route.module), + source: sourceLocation(options.repoRoot, declaration) + }) + } + if (diagnostics.length > 0) { + throw new SnapshotExtractionError(diagnostics) + } + + const packages = [...new Set(discovered.entrypoints.map((entrypoint) => entrypoint.packageName))].sort() + return { + version: 1, + compiler: { name: "typescript", version: ts.version }, + ref: options.ref, + sha: options.sha, + packages, + entrypoints: discovered.entrypoints.map((entrypoint) => ({ + ...entrypoint, + declarationFile: normalizedPath(options.repoRoot, entrypoint.declarationFile) + })), + entities: entities.sort((left, right) => left.id.localeCompare(right.id)), + diagnostics: [] + } +} + +export const snapshotCacheKey = ( + sha: string, + modules: ReadonlyArray +): string => + createHash("sha256") + .update(`snapshot-v3\0${sha}\0${ts.version}\0${[...modules].sort().join("\0")}`) + .digest("hex") diff --git a/packages/tools/api-diff/src/Worktrees.ts b/packages/tools/api-diff/src/Worktrees.ts new file mode 100644 index 00000000000..e2d5fa768f4 --- /dev/null +++ b/packages/tools/api-diff/src/Worktrees.ts @@ -0,0 +1,138 @@ +import { spawnSync } from "node:child_process" +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { dirname, join } from "node:path" +import { readJson, writeJson } from "./Json.ts" +import type { ApiSnapshot } from "./Model.ts" +import { extractSnapshot, snapshotCacheKey } from "./Snapshot.ts" + +interface CommandResult { + readonly status: number | null + readonly stdout: string + readonly stderr: string +} + +const run = (command: string, args: ReadonlyArray, cwd: string): CommandResult => { + const result = spawnSync(command, args, { + cwd, + encoding: "utf8", + maxBuffer: 100 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"] + }) + if (result.error !== undefined) { + throw result.error + } + return { + status: result.status, + stdout: result.stdout ?? "", + stderr: result.stderr ?? "" + } +} + +const runChecked = (command: string, args: ReadonlyArray, cwd: string): string => { + const result = run(command, args, cwd) + if (result.status !== 0) { + throw new Error( + `Command failed (${command} ${args.join(" ")}):\n${result.stdout}${result.stderr}`.trim() + ) + } + return result.stdout.trim() +} + +export const resolveRef = (repoRoot: string, ref: string): string => + runChecked("git", ["rev-parse", "--verify", `${ref}^{commit}`], repoRoot) + +const enableStripInternal = (worktree: string): void => { + const baseConfig = join(worktree, "tsconfig.base.json") + if (!existsSync(baseConfig)) { + return + } + const source = readFileSync(baseConfig, "utf8") + if (/"stripInternal"\s*:\s*true/.test(source)) { + return + } + const updated = source.replace(/("stripInternal"\s*:\s*)false/, "$1true") + if (updated === source) { + return + } + writeFileSync(baseConfig, updated) +} + +const hasProductionStripInternal = (worktree: string): boolean => { + const candidates = [ + join(worktree, "tsconfig.base.json"), + join(worktree, "tsconfig.build.json"), + join(worktree, "packages", "effect", "tsconfig.build.json") + ] + return candidates.some((path) => existsSync(path) && /"stripInternal"\s*:\s*true/.test(readFileSync(path, "utf8"))) +} + +const createWorktree = (repoRoot: string, parent: string, name: string, sha: string): string => { + const path = join(parent, name) + runChecked("git", ["worktree", "add", "--detach", path, sha], repoRoot) + return path +} + +const removeWorktree = (repoRoot: string, path: string): void => { + const result = run("git", ["worktree", "remove", "--force", path], repoRoot) + if (result.status !== 0) { + process.stderr.write(result.stdout) + process.stderr.write(result.stderr) + } +} + +const buildWorktree = (worktree: string): void => { + enableStripInternal(worktree) + if (!hasProductionStripInternal(worktree)) { + throw new Error(`No production TypeScript configuration enables stripInternal in ${worktree}`) + } + // Native test-only dependencies in old branches may not build on the current + // Node runtime. Declaration emission does not require dependency lifecycle scripts. + runChecked("pnpm", ["install", "--frozen-lockfile", "--ignore-scripts"], worktree) + runChecked("pnpm", ["build"], worktree) +} + +export interface PrepareSnapshotOptions { + readonly repoRoot: string + readonly cacheRoot: string + readonly worktreesRoot: string + readonly name: "base" | "head" + readonly ref: string + readonly sha: string + readonly modules: ReadonlyArray +} + +export const prepareSnapshot = (options: PrepareSnapshotOptions): ApiSnapshot => { + const key = snapshotCacheKey(options.sha, options.modules) + const cachePath = join(options.cacheRoot, key, "snapshot.json") + if (existsSync(cachePath)) { + const cached = readJson(cachePath) as ApiSnapshot + return { ...cached, ref: options.ref, sha: options.sha } + } + mkdirSync(options.worktreesRoot, { recursive: true }) + const runRoot = mkdtempSync(join(options.worktreesRoot, `${options.name}-`)) + const worktree = join(runRoot, "repo") + let added = false + try { + createWorktree(options.repoRoot, runRoot, "repo", options.sha) + added = true + buildWorktree(worktree) + const snapshot = extractSnapshot({ + repoRoot: worktree, + ref: options.ref, + sha: options.sha, + modules: options.modules + }) + writeJson(cachePath, snapshot) + return snapshot + } finally { + if (added) { + removeWorktree(options.repoRoot, worktree) + } + rmSync(runRoot, { recursive: true, force: true }) + } +} + +export const writeSnapshotOutput = (path: string, snapshot: ApiSnapshot): void => { + mkdirSync(dirname(path), { recursive: true }) + writeJson(path, snapshot) +} diff --git a/packages/tools/api-diff/src/bin.ts b/packages/tools/api-diff/src/bin.ts new file mode 100644 index 00000000000..4971437483b --- /dev/null +++ b/packages/tools/api-diff/src/bin.ts @@ -0,0 +1,9 @@ +#!/usr/bin/env node +import { runCli } from "./Cli.ts" + +try { + runCli(process.argv.slice(2)) +} catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`) + process.exitCode = 1 +} diff --git a/packages/tools/api-diff/test/Diff.test.ts b/packages/tools/api-diff/test/Diff.test.ts new file mode 100644 index 00000000000..47b303e377d --- /dev/null +++ b/packages/tools/api-diff/test/Diff.test.ts @@ -0,0 +1,122 @@ +import { diffSnapshots } from "@effect/api-diff/Diff" +import type { ApiEntity, ApiSnapshot, DeclarationModel, MigrationMap } from "@effect/api-diff/Model" +import { renderMarkdownReport } from "@effect/api-diff/Report" +import { assert, describe, it } from "@effect/vitest" + +const entity = ( + module: string, + name: string, + declaration: DeclarationModel, + hash: string +): ApiEntity => ({ + id: `${module}#${name}#value`, + packageName: module.split("/")[0]!, + module, + path: [name], + bucket: "value", + declarationKind: declaration.kind, + importRoutes: [{ module, path: [name] }], + declarations: [declaration], + displaySignature: `declare const ${name}: unknown`, + fingerprint: hash, + documentation: { stability: "stable" }, + source: { file: `${name}.d.ts`, line: 1, column: 1 } +}) + +const snapshot = (ref: string, entities: ReadonlyArray): ApiSnapshot => ({ + version: 1, + compiler: { name: "typescript", version: "fixture" }, + ref, + sha: ref.repeat(40).slice(0, 40), + packages: ["old", "new"], + entrypoints: [], + entities, + diagnostics: [] +}) + +describe("snapshot diff", () => { + it("matches renames, classifies signature changes, and separates suggestions", () => { + const base = snapshot("a", [ + entity("old/A", "renamed", { + kind: "function", + name: "renamed", + parameters: [{ name: "value", type: { kind: "primitive", name: "string" }, optional: false, rest: false }], + returnType: { kind: "primitive", name: "string" } + }, "same"), + entity("old/A", "similarName", { + kind: "variable", + name: "similarName", + type: { kind: "primitive", name: "string" } + }, "x"), + entity( + "old/A", + "removed", + { kind: "variable", name: "removed", type: { kind: "primitive", name: "string" } }, + "r" + ) + ]) + const head = snapshot("b", [ + entity("new/A", "replacement", { + kind: "function", + name: "replacement", + parameters: [ + { name: "value", type: { kind: "primitive", name: "string" }, optional: false, rest: false }, + { name: "count", type: { kind: "primitive", name: "number" }, optional: true, rest: false } + ], + returnType: { kind: "primitive", name: "number" } + }, "different"), + entity("new/A", "similarNames", { + kind: "variable", + name: "similarNames", + type: { kind: "primitive", name: "number" } + }, "y"), + entity("new/A", "added", { kind: "variable", name: "added", type: { kind: "primitive", name: "string" } }, "a") + ]) + const mapping: MigrationMap = { + version: 1, + modules: [{ from: "old/A", to: ["new/A"], status: "moved" }], + apis: [{ + from: { module: "old/A", path: ["renamed"] }, + to: { module: "new/A", path: ["replacement"] } + }] + } + const diff = diffSnapshots(base, head, mapping, []) + assert(diff.changes.some((change) => change.classification === "api-renamed" && change.authoritative)) + assert(diff.changes.some((change) => change.classification === "parameter-added")) + assert(diff.changes.some((change) => change.classification === "return-type-changed")) + assert(diff.changes.some((change) => change.baseApiId?.includes("similarName") && !change.authoritative)) + assert(diff.changes.some((change) => change.classification === "api-removed")) + assert(diff.changes.some((change) => change.classification === "api-added")) + const report = renderMarkdownReport(diff) + assert(report.includes("Suggested matches requiring review")) + assert(report.includes(base.sha)) + assert.deepStrictEqual(diff, diffSnapshots(base, head, mapping, [])) + }) + + it("classifies overload and parameter reordering", () => { + const signature = (name: string, parameters: ReadonlyArray<"left" | "right">): DeclarationModel => ({ + kind: "function", + name, + parameters: parameters.map((parameter) => ({ + name: parameter, + type: { kind: "primitive", name: "string" }, + optional: false, + rest: false + })), + returnType: { kind: "primitive", name: "string" } + }) + const before = entity("old/A", "ordered", signature("ordered", ["left", "right"]), "before") + const after = entity("new/A", "ordered", signature("ordered", ["right", "left"]), "after") + const diff = diffSnapshots( + snapshot("a", [before]), + snapshot("b", [after]), + { + version: 1, + modules: [{ from: "old/A", to: ["new/A"], status: "moved" }], + apis: [] + }, + [] + ) + assert(diff.changes.some((change) => change.classification === "parameter-reordered")) + }) +}) diff --git a/packages/tools/api-diff/test/Discovery.test.ts b/packages/tools/api-diff/test/Discovery.test.ts new file mode 100644 index 00000000000..88810d0ab2f --- /dev/null +++ b/packages/tools/api-diff/test/Discovery.test.ts @@ -0,0 +1,43 @@ +import { discoverEntrypoints } from "@effect/api-diff/Discovery" +import { assert, describe, it } from "@effect/vitest" +import { mkdtempSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { writeFixturePackage } from "./utils.ts" + +describe("entrypoint discovery", () => { + it("expands wildcards and respects null exclusions", () => { + const root = mkdtempSync(join(tmpdir(), "api-diff-discovery-")) + writeFixturePackage(root, { + "index.d.ts": `export * as Foo from "./Foo.js"\n`, + "Foo.d.ts": `export declare const value: string\n`, + "internal/Secret.d.ts": `export declare const secret: string\n` + }) + + const result = discoverEntrypoints(root, [ + "@fixture/sample", + "@fixture/sample/Foo", + "@fixture/sample/internal/Secret" + ]) + assert.deepStrictEqual(result.entrypoints.map((entrypoint) => entrypoint.module), [ + "@fixture/sample", + "@fixture/sample/Foo" + ]) + assert.deepStrictEqual(result.missing, ["@fixture/sample/internal/Secret"]) + }) + + it("supports conditional export targets", () => { + const root = mkdtempSync(join(tmpdir(), "api-diff-discovery-")) + writeFixturePackage(root, { + "Foo.d.ts": `export declare const value: string\n` + }, { + "./Foo": { + types: "./Foo.d.ts", + import: "./Foo.js" + } + }) + const result = discoverEntrypoints(root, ["@fixture/sample/Foo"]) + assert.strictEqual(result.missing.length, 0) + assert.strictEqual(result.entrypoints[0]?.module, "@fixture/sample/Foo") + }) +}) diff --git a/packages/tools/api-diff/test/Mapping.test.ts b/packages/tools/api-diff/test/Mapping.test.ts new file mode 100644 index 00000000000..6533dba657d --- /dev/null +++ b/packages/tools/api-diff/test/Mapping.test.ts @@ -0,0 +1,72 @@ +import { parseMigrationMap, renderMigrationMarkdown, validateMigrationMap } from "@effect/api-diff/Mapping" +import type { MigrationMap } from "@effect/api-diff/Model" +import { assert, describe, it } from "@effect/vitest" +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" + +const mapping: MigrationMap = { + version: 1, + modules: [ + { from: "old/A", to: ["new/A", "new/B"], status: "split", barrels: ["new"] }, + { to: ["new/C"], status: "added" } + ], + apis: [ + { + from: { module: "old/A", path: ["catchSome"] }, + to: { module: "new/A", path: ["catchFilter"] } + } + ] +} + +describe("migration mapping", () => { + it("renders one-to-many and added modules", () => { + const markdown = renderMigrationMarkdown(mapping) + assert(markdown.includes("old/A -> new/A, new/B (barrel: new)")) + assert(markdown.includes("new/C")) + assert(markdown.includes("old/A#catchSome -> new/A#catchFilter")) + }) + + it("rejects duplicate sources and contradictory statuses", () => { + const diagnostics = validateMigrationMap({ + ...mapping, + modules: [ + ...mapping.modules, + { from: "old/A", to: [], status: "removed" }, + { from: "old/Invalid", to: ["new/Invalid"], status: "removed" } + ], + apis: [...mapping.apis, mapping.apis[0]!] + }) + assert(diagnostics.some((diagnostic) => diagnostic.code === "duplicate-module-source")) + assert(diagnostics.some((diagnostic) => diagnostic.code === "contradictory-module-status")) + assert(diagnostics.some((diagnostic) => diagnostic.code === "duplicate-api-source")) + }) + + it("detects the conflicting catchSome guide", () => { + const root = mkdtempSync(join(tmpdir(), "api-diff-mapping-")) + writeFileSync(join(root, "guide.md"), "| `Effect.catchSome` | `Effect.catchIf` |\n") + const diagnostics = validateMigrationMap({ + ...mapping, + apis: [{ + ...mapping.apis[0]!, + guide: "guide.md" + }] + }, { repoRoot: root }) + assert(diagnostics.some((diagnostic) => diagnostic.code === "contradictory-guide")) + }) + + it("keeps the repository Markdown generated from the structured map", () => { + const jsonUrl = new URL("../../../../migration/v3-to-v4.json", import.meta.url) + const markdownUrl = new URL("../../../../migration/v3-to-v4.md", import.meta.url) + const parsed = parseMigrationMap(jsonUrl.pathname) + assert.strictEqual(renderMigrationMarkdown(parsed), readFileSync(markdownUrl, "utf8")) + assert.strictEqual(validateMigrationMap(parsed).length, 0) + const catchSome = parsed.apis.find((entry) => + entry.from.module === "effect/Effect" && entry.from.path.join(".") === "catchSome" + ) + assert.deepStrictEqual(catchSome?.to, { + module: "effect/Effect", + path: ["catchFilter"] + }) + }) +}) diff --git a/packages/tools/api-diff/test/Snapshot.test.ts b/packages/tools/api-diff/test/Snapshot.test.ts new file mode 100644 index 00000000000..20e74e99e82 --- /dev/null +++ b/packages/tools/api-diff/test/Snapshot.test.ts @@ -0,0 +1,121 @@ +import { extractSnapshot, SnapshotExtractionError } from "@effect/api-diff/Snapshot" +import { assert, describe, it } from "@effect/vitest" +import { mkdtempSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { writeFixturePackage } from "./utils.ts" + +const source = ` +export declare function overloaded(value: A): A +export declare function overloaded(value: number, radix?: number): string + +export interface Parent { + readonly parent: string +} + +export interface Service extends Parent { + readonly value?: A + run(input: A): Promise + [key: \`item-\${string}\`]: unknown +} + +export declare class Client implements Service { + static readonly version: string + readonly parent: string + readonly value?: A + constructor(value?: A) + run(input: A): Promise +} + +export type Recursive = A | ReadonlyArray> +export type Conditional = A extends readonly [infer Head, ...infer Tail] ? Head | Tail[number] : never +export type Mapped = { readonly [K in keyof A as \`get\${Capitalize}\`]?: A[K] } +export type Imported = import("node:fs").PathLike +export declare const token: unique symbol + +export declare namespace merged { + const value: string + interface Options { + readonly enabled: boolean + } +} +export declare function merged(value: string): string +` + +describe("canonical snapshot", () => { + it("extracts declarations, overloads, namespaces, re-exports, and canonical types deterministically", () => { + const root = mkdtempSync(join(tmpdir(), "api-diff-snapshot-")) + writeFixturePackage(root, { + "index.d.ts": `export * as Foo from "./Foo.js"\n`, + "Foo.d.ts": source + }) + + const options = { + repoRoot: root, + ref: "fixture", + sha: "0000000000000000000000000000000000000000", + modules: ["@fixture/sample", "@fixture/sample/Foo"] + } + const first = extractSnapshot(options) + const second = extractSnapshot(options) + assert.deepStrictEqual(first, second) + assert(first.entities.some((entity) => + entity.module === "@fixture/sample/Foo" && + entity.path.join(".") === "overloaded" && + entity.declarations.length === 2 + )) + const overloaded = first.entities.find((entity) => entity.id === "@fixture/sample/Foo#overloaded#value") + assert.deepStrictEqual(overloaded?.importRoutes, [ + { module: "@fixture/sample", path: ["Foo", "overloaded"] }, + { module: "@fixture/sample/Foo", path: ["overloaded"] } + ]) + const client = first.entities.filter((entity) => entity.path.join(".") === "Client") + assert.deepStrictEqual(client.map((entity) => entity.bucket), ["type", "value"]) + assert(first.entities.some((entity) => entity.path.join(".") === "merged.Options" && entity.bucket === "type")) + assert(first.entities.some((entity) => JSON.stringify(entity.declarations).includes("\"kind\":\"conditional\""))) + assert(first.entities.some((entity) => JSON.stringify(entity.declarations).includes("\"kind\":\"mapped\""))) + const imported = first.entities.find((entity) => entity.path.join(".") === "Imported") + assert(imported !== undefined, JSON.stringify(first.entities.map((entity) => entity.id))) + assert(JSON.stringify(imported.declarations).includes("\"externalPackage\":\"node:fs\"")) + }) + + it("fails rather than silently omitting unsupported public declarations", () => { + const root = mkdtempSync(join(tmpdir(), "api-diff-snapshot-")) + writeFixturePackage(root, { + "Bad.d.ts": ` +declare namespace Other {} +export declare namespace Bad { + export import Alias = Other +} +` + }) + let failure: unknown + try { + extractSnapshot({ + repoRoot: root, + ref: "fixture", + sha: "0000000000000000000000000000000000000000", + modules: ["@fixture/sample/Bad"] + }) + } catch (error) { + failure = error + } + assert(failure instanceof SnapshotExtractionError) + assert(failure.diagnostics.some((diagnostic) => diagnostic.code === "unsupported-public-declaration")) + }) + + it("normalizes union order in structural fingerprints", () => { + const leftRoot = mkdtempSync(join(tmpdir(), "api-diff-snapshot-")) + const rightRoot = mkdtempSync(join(tmpdir(), "api-diff-snapshot-")) + writeFixturePackage(leftRoot, { "Choice.d.ts": "export type Choice = string | number\n" }) + writeFixturePackage(rightRoot, { "Choice.d.ts": "export type Choice = number | string\n" }) + const extract = (repoRoot: string) => + extractSnapshot({ + repoRoot, + ref: "fixture", + sha: "0000000000000000000000000000000000000000", + modules: ["@fixture/sample/Choice"] + }).entities[0]?.fingerprint + assert.strictEqual(extract(leftRoot), extract(rightRoot)) + }) +}) diff --git a/packages/tools/api-diff/test/utils.ts b/packages/tools/api-diff/test/utils.ts new file mode 100644 index 00000000000..bfc538953ca --- /dev/null +++ b/packages/tools/api-diff/test/utils.ts @@ -0,0 +1,34 @@ +import { mkdirSync, writeFileSync } from "node:fs" +import { join } from "node:path" + +export const writeFixturePackage = ( + repoRoot: string, + files: Readonly>, + exports: Readonly> = { + ".": "./index.js", + "./*": "./*.js", + "./internal/*": null + } +): void => { + const root = join(repoRoot, "packages", "sample", "dist") + mkdirSync(root, { recursive: true }) + writeFileSync( + join(root, "package.json"), + `${ + JSON.stringify( + { + name: "@fixture/sample", + version: "1.0.0", + exports + }, + null, + 2 + ) + }\n` + ) + for (const [name, source] of Object.entries(files)) { + const path = join(root, name) + mkdirSync(join(path, ".."), { recursive: true }) + writeFileSync(path, source) + } +} diff --git a/packages/tools/api-diff/tsconfig.json b/packages/tools/api-diff/tsconfig.json new file mode 100644 index 00000000000..997ff419ca2 --- /dev/null +++ b/packages/tools/api-diff/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "http://json.schemastore.org/tsconfig", + "extends": "../../../tsconfig.base.json", + "include": ["src"], + "compilerOptions": { + "types": ["node"] + } +} diff --git a/packages/tools/api-diff/vitest.config.ts b/packages/tools/api-diff/vitest.config.ts new file mode 100644 index 00000000000..c8a52c1826e --- /dev/null +++ b/packages/tools/api-diff/vitest.config.ts @@ -0,0 +1,6 @@ +import { mergeConfig, type ViteUserConfig } from "vitest/config" +import shared from "../../../vitest.shared.ts" + +const config: ViteUserConfig = {} + +export default mergeConfig(shared, config) diff --git a/plan.md b/plan.md new file mode 100644 index 00000000000..0fc0eaac863 --- /dev/null +++ b/plan.md @@ -0,0 +1,374 @@ +# API Diff Tool Plan + +## Goal + +Build a repository-owned tool that compares the consumer-visible TypeScript APIs of the v3 and main branches. It will produce a high-recall migration inventory for generating migration guides and documentation. + +The first version will report structural API changes and review confidence. It will not claim perfect semantic-version compatibility. + +## Success Criteria + +- Compare all modules in the v3-to-v4 import map. +- Consume production `.d.ts` files with internal declarations stripped. +- Handle package consolidation, relocated modules, wildcard exports, barrels, and re-exports. +- Detect additions, removals, moves, renames, and structural signature changes. +- Represent overloads, generics, interfaces, classes, namespaces, and merged declarations. +- Produce deterministic, versioned JSON and readable Markdown. +- Record exact Git SHAs used for both snapshots. +- Validate every explicit module and API mapping. +- Never silently omit an unsupported public declaration. + +## Architecture + +Create a private workspace package at `packages/tools/api-diff`. + +Use the TypeScript compiler API directly. Do not introduce `ts-morph`. + +The tool will have four logical stages: + +1. Prepare branch artifacts. +2. Extract canonical API snapshots. +3. Match and diff snapshots using migration mappings. +4. Generate JSON and Markdown reports. + +## Command + +Add a root command with an interface similar to: + +```sh +pnpm api-diff \ + --base-ref origin/v3 \ + --head-ref origin/main \ + --mapping migration/v3-to-v4.json \ + --output tmp/api-diff +``` + +The refs must be explicit. The report will include their resolved commit SHAs. + +Outputs: + +```text +tmp/api-diff/base.snapshot.json +tmp/api-diff/head.snapshot.json +tmp/api-diff/diff.json +tmp/api-diff/report.md +``` + +## Branch Preparation + +Reuse the temporary worktree lifecycle from `packages/effect/typeperf/compare.mjs`. + +For each ref: + +- Resolve the ref to a commit before starting. +- Create a detached temporary worktree. +- Run `pnpm install --frozen-lockfile`. +- Build using that branch's native build pipeline. +- Ensure `stripInternal: true`. +- Locate the package artifacts and built package manifests. +- Cache successful snapshots by commit SHA. +- Remove temporary worktrees even when extraction fails. + +For main, follow the existing CI behavior for enabling `stripInternal` inside the disposable worktree. V3 already uses production build configurations with internal stripping. + +## Public Entrypoints + +Discover packages by `package.json.name`, never by repository directory. + +Read the consumer-visible package metadata: + +- V3: packed package manifests such as `dist/package.json`. +- Main: `publishConfig.exports` and emitted `dist` declarations. +- Exclude private packages. +- Expand wildcard exports to concrete declaration entrypoints. +- Respect explicit `null` exclusions. +- Exclude internal entrypoints. +- Include both direct module and barrel import routes. + +The initial comparison scope is every source and target module represented in the structured migration map. + +## Structured Migration Map + +Replace the Markdown-only mapping source with a versioned JSON model such as: + +```json +{ + "version": 1, + "modules": [ + { + "from": "@effect/platform/HttpClient", + "to": ["effect/unstable/http/HttpClient"], + "status": "moved" + } + ], + "apis": [ + { + "from": { + "module": "effect/Effect", + "path": ["catchAll"] + }, + "to": { + "module": "effect/Effect", + "path": ["catch"] + } + } + ] +} +``` + +The schema must support: + +- One-to-one moves. +- One-to-many module splits. +- Removed modules and APIs. +- Explicit renames. +- Suggested barrel routes. +- Links to detailed migration guides. +- Notes requiring manual migration. + +Generate `migration/v3-to-v4.md` from the structured data so JSON is the source of truth. + +Validation must reject missing targets, ambiguous API names, accidental duplicate sources, and contradictory mappings. + +## Canonical API Snapshot + +Represent each public export as a serializable API entity. + +Identity fields: + +- Package name. +- Direct import path. +- Nested export path. +- Value or type bucket. +- Declaration kind. +- Import routes and barrel aliases. + +Declaration fields: + +- Type parameters and constraints. +- Function parameters. +- Optional and rest modifiers. +- Return type. +- Complete overload set. +- Heritage clauses. +- Properties and methods. +- Static and instance members. +- Call, construct, and index signatures. +- Accessibility, `readonly`, and optional modifiers. +- Namespace members. +- Enum members. +- Documentation metadata. +- Source location. + +Merged value/type declarations must be represented as separate facets connected to the same exported name. + +## Type AST + +Serialize TypeScript type nodes into a canonical recursive model instead of relying only on `typeToString()`. + +The type model should cover: + +- Primitive and literal types. +- Type references and arguments. +- Unions and intersections. +- Arrays and tuples. +- Function and constructor types. +- Object and mapped types. +- Conditional and indexed-access types. +- `keyof`, type queries, and import types. +- Template literal types. +- Infer and type-operator nodes. +- Type predicates. + +Normalization rules: + +- Resolve aliases with `TypeChecker.getAliasedSymbol()`. +- Resolve public references to canonical API IDs. +- Preserve unresolved external package references explicitly. +- Alpha-normalize generic parameter identities while retaining display names. +- Sort union and intersection members by structural fingerprint. +- Sort object and interface members where order is not semantic. +- Preserve tuple, parameter, and overload ordering. +- Store parameter names as metadata but exclude simple renames from semantic fingerprints. +- Strip source positions, comments, declaration maps, and formatting. + +Unsupported public syntax must produce a diagnostic and fail snapshot generation. + +## Export Resolution + +For every public entrypoint: + +- Load its source file into a TypeScript `Program`. +- Obtain the module symbol. +- Enumerate exports with `getExportsOfModule()`. +- Resolve re-export aliases. +- Locate the exported symbol's declaration nodes. +- Associate direct and barrel routes with the same entity. +- Avoid recursively reporting barrel namespaces as duplicate declarations. + +Snapshot extraction should use one pinned TypeScript compiler version to parse both branches' emitted declarations. This reduces printer and normalization drift while retaining each branch's native declaration emission. + +## Matching + +Apply matching in this order: + +1. Exact export path under an explicitly mapped module. +2. Explicit API rename or move mapping. +3. Exact structural fingerprint within mapped modules. +4. Name and structural similarity as a suggested match. +5. Remaining base exports become removals. +6. Remaining head exports become additions. + +Suggested matches must include a confidence score and require review. They must never silently become authoritative mappings. + +## Diff Classification + +Report these change categories: + +- Package added or removed. +- Module added, removed, moved, split, or consolidated. +- API added, removed, moved, or renamed. +- Export bucket changed. +- Declaration kind changed. +- Overload added, removed, or reordered. +- Parameter added, removed, reordered, or changed. +- Optionality or rest status changed. +- Return type changed. +- Generic parameter or constraint changed. +- Property or method added, removed, or changed. +- Heritage clause changed. +- Union or intersection member changed. +- Documentation, deprecation, or stability metadata changed. +- Unsupported or inconclusive comparison. + +Each change record will contain: + +- Stable change ID. +- Classification. +- Confidence. +- Base and head API IDs. +- Before and after display signatures. +- Structured AST delta. +- Source locations. +- Related explicit mapping. +- Related migration guide. +- Review notes. + +Do not classify changes as semver-breaking in the first version. Use labels such as `structural-change` and `review-required`. + +## Reporting + +The JSON report is the canonical output. + +The Markdown report should include: + +- Compared refs and SHAs. +- Summary counts. +- Mapping validation diagnostics. +- Changes grouped by domain and module. +- Renames and moves first. +- Removals and additions next. +- Signature changes with before/after declarations. +- Suggested matches in a separate review section. +- Links to existing topical migration guides. +- Stable and unstable API sections. + +Documentation-only changes should not obscure structural API changes. + +## Prototype + +Before implementing the complete extractor, test the design against: + +- `effect/Effect` +- `effect/Schema` +- `effect/Stream` +- `@effect/platform/HttpClient` to `effect/unstable/http/HttpClient` +- One SQL module moved into `effect/unstable/sql` + +The prototype must verify that the canonical model handles: + +- Large overload sets. +- Deep generic and conditional types. +- Namespace exports. +- Package consolidation. +- Cross-module type references. +- Re-exported declarations. + +Evaluate API Extractor and `@api-extractor-tools/change-detector` only as secondary validation during this prototype. Do not make either the source of truth. + +## Tests + +Add synthetic declaration fixtures covering: + +- Function overload changes. +- Generic constraint changes. +- Interfaces and inheritance. +- Classes with static and instance members. +- Type aliases and recursive types. +- Namespaces and declaration merging. +- Value/type name collisions. +- Re-exports and aliases. +- Wildcard package exports. +- Internal export exclusions. +- Union normalization. +- Parameter reordering. +- One-to-many module mappings. +- Unsupported syntax diagnostics. + +Add deterministic snapshot tests for the canonical JSON model. + +Add integration tests for the representative real modules without committing complete branch reports as test fixtures. + +Add mapping validation tests, including the existing conflicting `Effect.catchSome` guidance. + +## Implementation Phases + +1. Implement and evaluate the representative-module prototype. +2. Define the versioned snapshot and mapping schemas. +3. Implement package and entrypoint discovery. +4. Implement semantic export extraction and canonical type AST serialization. +5. Implement mapping validation and exact matching. +6. Implement structural fingerprints and suggested matching. +7. Implement AST diff classification. +8. Implement JSON and Markdown reporters. +9. Add ref/worktree/build orchestration and snapshot caching. +10. Run the complete v3-to-main comparison and tune false positives. +11. Generate an initial migration inventory for manual review. + +## Validation + +Run: + +```sh +pnpm lint-fix +pnpm test packages/tools/api-diff/test +pnpm check +pnpm api-diff \ + --base-ref origin/v3 \ + --head-ref origin/main \ + --mapping migration/v3-to-v4.json \ + --output tmp/api-diff +``` + +Verify: + +- Both refs resolve to the expected SHAs. +- Every mapped module resolves. +- No public declaration is silently skipped. +- A repeated cached run produces identical snapshots and reports. +- Temporary worktrees are removed. +- The report separates authoritative mappings from suggestions. +- Representative report entries agree with the existing migration guides. + +## Future Work + +After the migration inventory is trusted: + +- Add optional assignability checks using generated TypeScript programs. +- Introduce read-only and write-only compatibility policies. +- Compare later main releases for CI API review. +- Publish report artifacts on pull requests. +- Validate changesets against detected public API changes. +- Generate migration guide tables directly from reviewed diff records. + +These are explicitly outside the first implementation. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a42129c20e8..2b6d360d0b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -711,6 +711,24 @@ importers: specifier: ^26.1.1 version: 26.1.1 + packages/tools/api-diff: + devDependencies: + '@effect/vitest': + specifier: workspace:^ + version: link:../../vitest + '@types/node': + specifier: ^26.1.1 + version: 26.1.1 + typescript: + specifier: ^7.0.2 + version: 7.0.2 + typescript-compiler: + specifier: npm:typescript@6.0.3 + version: typescript@6.0.3 + vitest: + specifier: 4.1.10 + version: 4.1.10(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(jsdom@29.1.1)(vite@7.3.6(@types/node@26.1.1)(lightningcss@1.32.0)(terser@5.49.0)(tsx@4.21.0)(yaml@2.9.0)) + packages/tools/bundle: dependencies: '@effect/platform-node': diff --git a/tsconfig.json b/tsconfig.json index b2a9c688e53..d44e31766e6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -87,6 +87,8 @@ "@effect/sql-sqlite-wasm/*": ["./packages/sql/sqlite-wasm/src/*.ts"], "@effect/ai-codegen": ["./packages/tools/ai-codegen/src/index.ts"], "@effect/ai-codegen/*": ["./packages/tools/ai-codegen/src/*.ts"], + "@effect/api-diff": ["./packages/tools/api-diff/src/Cli.ts"], + "@effect/api-diff/*": ["./packages/tools/api-diff/src/*.ts"], "@effect/ai-docgen": ["./packages/tools/ai-docgen/src/index.ts"], "@effect/ai-docgen/*": ["./packages/tools/ai-docgen/src/*.ts"], "@effect/bundle/*": ["./packages/tools/bundle/src/*.ts"], diff --git a/tsconfig.packages.json b/tsconfig.packages.json index 229e31251eb..90ac2148a3d 100644 --- a/tsconfig.packages.json +++ b/tsconfig.packages.json @@ -30,6 +30,7 @@ { "path": "packages/sql/sqlite-react-native" }, { "path": "packages/sql/sqlite-wasm" }, { "path": "packages/tools/ai-codegen" }, + { "path": "packages/tools/api-diff" }, { "path": "packages/tools/bundle" }, { "path": "packages/tools/openapi-generator" }, { "path": "packages/tools/jsdocs" }, From 6f1a7381678c4f1e9c5dca7b7246da82b7f37275 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Thu, 23 Jul 2026 16:18:43 +1200 Subject: [PATCH 2/6] Render API renames with namespaced code spellings - Deduplicate rendered API rename entries - Add coverage for grep-friendly migration mappings --- migration/v3-to-v4.md | 111 +++++++++---------- packages/tools/api-diff/src/Mapping.ts | 25 +++-- packages/tools/api-diff/test/Mapping.test.ts | 26 ++++- 3 files changed, 98 insertions(+), 64 deletions(-) diff --git a/migration/v3-to-v4.md b/migration/v3-to-v4.md index d4a52da4f8c..a899a021e3f 100644 --- a/migration/v3-to-v4.md +++ b/migration/v3-to-v4.md @@ -2,7 +2,7 @@ Mapped modules: 289 No counterpart: 44 -API renames: 54 +API renames: 53 This file is generated from `migration/v3-to-v4.json`. Do not edit it directly. @@ -347,61 +347,60 @@ effect/unstable/rpc/Utils (barrel: effect/unstable/rpc) ## API Renames -Each line is `v3 API -> v4 API`. +Each line is `v3 API -> v4 API`, using the namespaced spelling found in application code. ```text -effect/Effect#async -> effect/Effect#callback -effect/Effect#zipRight -> effect/Effect#andThen -effect/Effect#zipLeft -> effect/Effect#tap -effect/Effect#either -> effect/Effect#result -effect/Effect#catchAll -> effect/Effect#catch -effect/Effect#catchAllCause -> effect/Effect#catchCause -effect/Effect#catchAllDefect -> effect/Effect#catchDefect -effect/Effect#catchSome -> effect/Effect#catchFilter -effect/Effect#catchIf -> effect/Effect#catchIf -effect/Effect#optionFromOptional -> effect/Effect#catchNoSuchElement -effect/Effect#catchSomeCause -> effect/Effect#catchCauseFilter -effect/Effect#tapErrorCause -> effect/Effect#tapCause -effect/Effect#ignoreLogged -> effect/Effect#ignore -effect/Effect#unsafeMakeLatch -> effect/Latch#makeUnsafe -effect/Effect#makeLatch -> effect/Latch#make -effect/Layer#scoped -> effect/Layer#effect -effect/Layer#scopedDiscard -> effect/Layer#effectDiscard -effect/Layer#tapErrorCause -> effect/Layer#tapCause -effect/Mailbox#Mailbox -> effect/Queue#Queue -effect/Mailbox#make -> effect/Queue#make -effect/Either#Either -> effect/Result#Result -effect/Either#Either -> effect/Result#Result -effect/Either#right -> effect/Result#succeed -effect/Either#left -> effect/Result#fail -effect/Scope#extend -> effect/Scope#provide -effect/Effect#unsafeMakeSemaphore -> effect/Semaphore#makeUnsafe -effect/Effect#makeSemaphore -> effect/Semaphore#make -effect/Stream#Stream.Context -> effect/Stream#Services -effect/StreamHaltStrategy#HaltStrategy -> effect/Stream#HaltStrategy -effect/Stream#repeatEffect -> effect/Stream#fromEffectRepeat -effect/Stream#repeatEffectWithSchedule -> effect/Stream#fromEffectSchedule -effect/Stream#async -> effect/Stream#callback -effect/Stream#asyncEffect -> effect/Stream#callback -effect/Stream#asyncPush -> effect/Stream#callback -effect/Stream#asyncScoped -> effect/Stream#callback -effect/Stream#repeatEffectChunk -> effect/Stream#fromIterableEffectRepeat -effect/Stream#fromChunk -> effect/Stream#fromArray -effect/Stream#fromChunks -> effect/Stream#fromArrays -effect/Stream#mapChunks -> effect/Stream#mapArray -effect/Stream#mapChunksEffect -> effect/Stream#mapArrayEffect -effect/Stream#either -> effect/Stream#result -effect/Stream#flattenChunks -> effect/Stream#flattenArray -effect/Stream#flattenIterables -> effect/Stream#flattenIterable -effect/Stream#mergeEither -> effect/Stream#mergeResult -effect/Stream#zipWithChunks -> effect/Stream#zipWithArray -effect/Stream#bufferChunks -> effect/Stream#bufferArray -effect/Stream#catchAllCause -> effect/Stream#catchCause -effect/Stream#tapErrorCause -> effect/Stream#tapCause -effect/Stream#catchAll -> effect/Stream#catch -effect/Stream#catchSome -> effect/Stream#catchIf -effect/Stream#catchSomeCause -> effect/Stream#catchCauseIf -effect/Stream#combineChunks -> effect/Stream#combineArray -effect/Stream#provideSomeLayer -> effect/Stream#provide -effect/Stream#provideSomeContext -> effect/Stream#provide +Effect.async -> Effect.callback +Effect.zipRight -> Effect.andThen +Effect.zipLeft -> Effect.tap +Effect.either -> Effect.result +Effect.catchAll -> Effect.catch +Effect.catchAllCause -> Effect.catchCause +Effect.catchAllDefect -> Effect.catchDefect +Effect.catchSome -> Effect.catchFilter +Effect.catchIf -> Effect.catchIf +Effect.optionFromOptional -> Effect.catchNoSuchElement +Effect.catchSomeCause -> Effect.catchCauseFilter +Effect.tapErrorCause -> Effect.tapCause +Effect.ignoreLogged -> Effect.ignore +Effect.unsafeMakeLatch -> Latch.makeUnsafe +Effect.makeLatch -> Latch.make +Layer.scoped -> Layer.effect +Layer.scopedDiscard -> Layer.effectDiscard +Layer.tapErrorCause -> Layer.tapCause +Mailbox -> Queue.Queue +Mailbox.make -> Queue.make +Either -> Result.Result +Either.right -> Result.succeed +Either.left -> Result.fail +Scope.extend -> Scope.provide +Effect.unsafeMakeSemaphore -> Semaphore.makeUnsafe +Effect.makeSemaphore -> Semaphore.make +Stream.Context -> Stream.Services +StreamHaltStrategy.HaltStrategy -> Stream.HaltStrategy +Stream.repeatEffect -> Stream.fromEffectRepeat +Stream.repeatEffectWithSchedule -> Stream.fromEffectSchedule +Stream.async -> Stream.callback +Stream.asyncEffect -> Stream.callback +Stream.asyncPush -> Stream.callback +Stream.asyncScoped -> Stream.callback +Stream.repeatEffectChunk -> Stream.fromIterableEffectRepeat +Stream.fromChunk -> Stream.fromArray +Stream.fromChunks -> Stream.fromArrays +Stream.mapChunks -> Stream.mapArray +Stream.mapChunksEffect -> Stream.mapArrayEffect +Stream.either -> Stream.result +Stream.flattenChunks -> Stream.flattenArray +Stream.flattenIterables -> Stream.flattenIterable +Stream.mergeEither -> Stream.mergeResult +Stream.zipWithChunks -> Stream.zipWithArray +Stream.bufferChunks -> Stream.bufferArray +Stream.catchAllCause -> Stream.catchCause +Stream.tapErrorCause -> Stream.tapCause +Stream.catchAll -> Stream.catch +Stream.catchSome -> Stream.catchIf +Stream.catchSomeCause -> Stream.catchCauseIf +Stream.combineChunks -> Stream.combineArray +Stream.provideSomeLayer -> Stream.provide +Stream.provideSomeContext -> Stream.provide ``` diff --git a/packages/tools/api-diff/src/Mapping.ts b/packages/tools/api-diff/src/Mapping.ts index ac7b5fca28c..87cf521e932 100644 --- a/packages/tools/api-diff/src/Mapping.ts +++ b/packages/tools/api-diff/src/Mapping.ts @@ -204,15 +204,30 @@ export const mappingModules = ( } } +const renderApiTarget = (target: ApiTarget, source: boolean): string => { + const moduleName = target.module.split("/").at(-1) ?? target.module + if (target.path[0] === moduleName && (target.path.length > 1 || source)) { + return target.path.join(".") + } + return [moduleName, ...target.path].join(".") +} + export const renderMigrationMarkdown = (mapping: MigrationMap): string => { const mapped = mapping.modules.filter((entry) => entry.from !== undefined) const added = mapping.modules.filter((entry) => entry.from === undefined) + const apiRenames = [ + ...new Set(mapping.apis.map((entry) => { + const from = renderApiTarget(entry.from, true) + const to = entry.to === null ? "Removed" : renderApiTarget(entry.to, false) + return `${from} -> ${to}` + })) + ] const lines = [ "# v3 to v4 Import and API Rename Maps", "", `Mapped modules: ${mapped.reduce((total, entry) => total + entry.to.length, 0)}`, `No counterpart: ${added.length}`, - `API renames: ${mapping.apis.length}`, + `API renames: ${apiRenames.length}`, "", "This file is generated from `migration/v3-to-v4.json`. Do not edit it directly.", "", @@ -247,14 +262,10 @@ export const renderMigrationMarkdown = (mapping: MigrationMap): string => { "", "## API Renames", "", - "Each line is `v3 API -> v4 API`.", + "Each line is `v3 API -> v4 API`, using the namespaced spelling found in application code.", "", "```text", - ...mapping.apis.map((entry) => { - const from = `${entry.from.module}#${entry.from.path.join(".")}` - const to = entry.to === null ? "Removed" : `${entry.to.module}#${entry.to.path.join(".")}` - return `${from} -> ${to}` - }), + ...apiRenames, "```", "" ] diff --git a/packages/tools/api-diff/test/Mapping.test.ts b/packages/tools/api-diff/test/Mapping.test.ts index 6533dba657d..8267e58e257 100644 --- a/packages/tools/api-diff/test/Mapping.test.ts +++ b/packages/tools/api-diff/test/Mapping.test.ts @@ -24,7 +24,31 @@ describe("migration mapping", () => { const markdown = renderMigrationMarkdown(mapping) assert(markdown.includes("old/A -> new/A, new/B (barrel: new)")) assert(markdown.includes("new/C")) - assert(markdown.includes("old/A#catchSome -> new/A#catchFilter")) + assert(markdown.includes("A.catchSome -> A.catchFilter")) + }) + + it("renders APIs as grep-friendly code spellings", () => { + const markdown = renderMigrationMarkdown({ + version: 1, + modules: [], + apis: [ + { + from: { module: "effect/Effect", path: ["makeLatch"] }, + to: { module: "effect/Latch", path: ["make"] } + }, + { + from: { module: "effect/Stream", path: ["Stream", "Context"] }, + to: { module: "effect/Stream", path: ["Services"] } + }, + { + from: { module: "effect/Mailbox", path: ["Mailbox"] }, + to: { module: "effect/Queue", path: ["Queue"] } + } + ] + }) + assert(markdown.includes("Effect.makeLatch -> Latch.make")) + assert(markdown.includes("Stream.Context -> Stream.Services")) + assert(markdown.includes("Mailbox -> Queue.Queue")) }) it("rejects duplicate sources and contradictory statuses", () => { From bd15c7034b01283a61ebf3711ecc114504596637 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Thu, 23 Jul 2026 16:36:32 +1200 Subject: [PATCH 3/6] Migrate API diff command to Effect CLI - Run the command with Effect platform services - Add coverage for mapping documentation generation --- .changeset/migrate-api-diff-cli.md | 5 + packages/tools/api-diff/package.json | 4 + packages/tools/api-diff/src/Cli.ts | 149 +++++++++++------------ packages/tools/api-diff/src/bin.ts | 17 +-- packages/tools/api-diff/test/Cli.test.ts | 27 ++++ pnpm-lock.yaml | 7 ++ 6 files changed, 127 insertions(+), 82 deletions(-) create mode 100644 .changeset/migrate-api-diff-cli.md create mode 100644 packages/tools/api-diff/test/Cli.test.ts diff --git a/.changeset/migrate-api-diff-cli.md b/.changeset/migrate-api-diff-cli.md new file mode 100644 index 00000000000..f3ee698e127 --- /dev/null +++ b/.changeset/migrate-api-diff-cli.md @@ -0,0 +1,5 @@ +--- +"@effect/api-diff": patch +--- + +Migrate the API diff command to the Effect CLI modules. diff --git a/packages/tools/api-diff/package.json b/packages/tools/api-diff/package.json index bea7149b166..0651cce912d 100644 --- a/packages/tools/api-diff/package.json +++ b/packages/tools/api-diff/package.json @@ -15,6 +15,10 @@ "check": "tsc -b tsconfig.json", "test": "vitest" }, + "dependencies": { + "@effect/platform-node": "workspace:^", + "effect": "workspace:^" + }, "devDependencies": { "@effect/vitest": "workspace:^", "@types/node": "^26.1.1", diff --git a/packages/tools/api-diff/src/Cli.ts b/packages/tools/api-diff/src/Cli.ts index c686857632b..fbcec925295 100644 --- a/packages/tools/api-diff/src/Cli.ts +++ b/packages/tools/api-diff/src/Cli.ts @@ -1,3 +1,8 @@ +import * as Data from "effect/Data" +import * as Effect from "effect/Effect" +import * as Option from "effect/Option" +import * as Command from "effect/unstable/cli/Command" +import * as Flag from "effect/unstable/cli/Flag" import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs" import { dirname, isAbsolute, join, relative, resolve } from "node:path" import { diffSnapshots } from "./Diff.ts" @@ -6,65 +11,32 @@ import { mappingModules, parseMigrationMap, renderMigrationMarkdown, validateMig import { renderMarkdownReport } from "./Report.ts" import { prepareSnapshot, resolveRef, writeSnapshotOutput } from "./Worktrees.ts" -interface CliOptions { - readonly baseRef?: string | undefined - readonly headRef?: string | undefined - readonly mapping?: string | undefined - readonly output?: string | undefined - readonly writeMappingDoc?: string | undefined - readonly help: boolean -} +const baseRef = Flag.string("base-ref").pipe( + Flag.withDescription("Explicit base Git ref"), + Flag.optional +) -const usage = `Usage: - pnpm api-diff --base-ref --head-ref --mapping --output - pnpm api-diff --mapping --write-mapping-doc +const headRef = Flag.string("head-ref").pipe( + Flag.withDescription("Explicit head Git ref"), + Flag.optional +) -Options: - --base-ref Explicit base Git ref - --head-ref Explicit head Git ref - --mapping Versioned migration map JSON - --output Report output directory - --write-mapping-doc Generate Markdown from the migration map - -h, --help Show this help -` +const mapping = Flag.string("mapping").pipe( + Flag.withMetavar("FILE"), + Flag.withDescription("Versioned migration map JSON") +) -const parseArgs = (args: ReadonlyArray): CliOptions => { - let baseRef: string | undefined - let headRef: string | undefined - let mapping: string | undefined - let output: string | undefined - let writeMappingDoc: string | undefined - let help = false - for (let index = 0; index < args.length; index++) { - const argument = args[index]! - if (argument === "--help" || argument === "-h") { - help = true - continue - } - if ( - argument !== "--base-ref" && argument !== "--head-ref" && argument !== "--mapping" && - argument !== "--output" && argument !== "--write-mapping-doc" - ) { - throw new Error(`Unknown option: ${argument}`) - } - const value = args[++index] - if (value === undefined || value.startsWith("-")) { - throw new Error(`Missing value for ${argument}`) - } - if (argument === "--base-ref") { - baseRef = value - } else if (argument === "--head-ref") { - headRef = value - } else if (argument === "--mapping") { - mapping = value - } else if (argument === "--output") { - output = value - } else { - writeMappingDoc = value - } - } - return { baseRef, headRef, mapping, output, writeMappingDoc, help } -} +const output = Flag.string("output").pipe( + Flag.withMetavar("DIRECTORY"), + Flag.withDescription("Report output directory"), + Flag.optional +) + +const writeMappingDoc = Flag.string("write-mapping-doc").pipe( + Flag.withMetavar("FILE"), + Flag.withDescription("Generate Markdown from the migration map"), + Flag.optional +) const findRepoRoot = (cwd: string): string => { let current = resolve(cwd) @@ -82,49 +54,65 @@ const findRepoRoot = (cwd: string): string => { const absolute = (repoRoot: string, path: string): string => isAbsolute(path) ? path : resolve(repoRoot, path) -export const runCli = (args: ReadonlyArray, cwd = process.cwd()): void => { - const options = parseArgs(args) - if (options.help) { - process.stdout.write(usage) - return - } - if (options.mapping === undefined) { - throw new Error("--mapping is required") - } - const repoRoot = findRepoRoot(cwd) +class ApiDiffError extends Data.TaggedError("ApiDiffError")<{ + readonly message: string + readonly cause: unknown +}> {} + +interface CliOptions { + readonly baseRef: Option.Option + readonly headRef: Option.Option + readonly mapping: string + readonly output: Option.Option + readonly writeMappingDoc: Option.Option +} + +const runApiDiff = Effect.fnUntraced(function*(options: CliOptions) { + yield* Effect.try({ + try: () => runApiDiffSync(options), + catch: (cause) => + new ApiDiffError({ + message: cause instanceof Error ? cause.message : String(cause), + cause + }) + }) +}) + +const runApiDiffSync = (options: CliOptions): void => { + const repoRoot = findRepoRoot(process.cwd()) const mappingPath = absolute(repoRoot, options.mapping) const mapping = parseMigrationMap(mappingPath) const staticDiagnostics = validateMigrationMap(mapping, { repoRoot }) - if (options.writeMappingDoc !== undefined) { + if (Option.isSome(options.writeMappingDoc)) { if (staticDiagnostics.some((diagnostic) => diagnostic.severity === "error")) { throw new Error(staticDiagnostics.map((diagnostic) => `${diagnostic.code}: ${diagnostic.message}`).join("\n")) } - const output = absolute(repoRoot, options.writeMappingDoc) + const output = absolute(repoRoot, options.writeMappingDoc.value) mkdirSync(dirname(output), { recursive: true }) writeFileSync(output, renderMigrationMarkdown(mapping)) process.stdout.write(`Generated ${relative(repoRoot, output)}\n`) return } - if (options.baseRef === undefined || options.headRef === undefined || options.output === undefined) { + if (Option.isNone(options.baseRef) || Option.isNone(options.headRef) || Option.isNone(options.output)) { throw new Error("--base-ref, --head-ref, and --output are required for comparison") } if (staticDiagnostics.some((diagnostic) => diagnostic.severity === "error")) { throw new Error(staticDiagnostics.map((diagnostic) => `${diagnostic.code}: ${diagnostic.message}`).join("\n")) } - const baseSha = resolveRef(repoRoot, options.baseRef) - const headSha = resolveRef(repoRoot, options.headRef) + const baseSha = resolveRef(repoRoot, options.baseRef.value) + const headSha = resolveRef(repoRoot, options.headRef.value) const modules = mappingModules(mapping) const toolRoot = join(repoRoot, "tmp", "api-diff") const cacheRoot = join(toolRoot, "cache") const worktreesRoot = join(toolRoot, "worktrees") - process.stdout.write(`Base ${options.baseRef}: ${baseSha}\nHead ${options.headRef}: ${headSha}\n`) + process.stdout.write(`Base ${options.baseRef.value}: ${baseSha}\nHead ${options.headRef.value}: ${headSha}\n`) const base = prepareSnapshot({ repoRoot, cacheRoot, worktreesRoot, name: "base", - ref: options.baseRef, + ref: options.baseRef.value, sha: baseSha, modules: modules.base }) @@ -133,12 +121,12 @@ export const runCli = (args: ReadonlyArray, cwd = process.cwd()): void = cacheRoot, worktreesRoot, name: "head", - ref: options.headRef, + ref: options.headRef.value, sha: headSha, modules: modules.head }) const mappingDiagnostics = validateMigrationMap(mapping, { base, head, repoRoot }) - const output = absolute(repoRoot, options.output) + const output = absolute(repoRoot, options.output.value) mkdirSync(output, { recursive: true }) writeSnapshotOutput(join(output, "base.snapshot.json"), base) writeSnapshotOutput(join(output, "head.snapshot.json"), head) @@ -157,3 +145,14 @@ export const runCli = (args: ReadonlyArray, cwd = process.cwd()): void = throw new Error("Generated Markdown report is empty") } } + +export const cli = Command.make("api-diff", { + baseRef, + headRef, + mapping, + output, + writeMappingDoc +}).pipe( + Command.withDescription("Compare the consumer-visible TypeScript API of two repository revisions"), + Command.withHandler(runApiDiff) +) diff --git a/packages/tools/api-diff/src/bin.ts b/packages/tools/api-diff/src/bin.ts index 4971437483b..311fbba6024 100644 --- a/packages/tools/api-diff/src/bin.ts +++ b/packages/tools/api-diff/src/bin.ts @@ -1,9 +1,12 @@ #!/usr/bin/env node -import { runCli } from "./Cli.ts" +import * as NodeRuntime from "@effect/platform-node/NodeRuntime" +import * as NodeServices from "@effect/platform-node/NodeServices" +import * as Effect from "effect/Effect" +import * as Command from "effect/unstable/cli/Command" +import PackageJson from "../package.json" with { type: "json" } +import { cli } from "./Cli.ts" -try { - runCli(process.argv.slice(2)) -} catch (error) { - process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`) - process.exitCode = 1 -} +Command.run(cli, { version: PackageJson.version }).pipe( + Effect.provide(NodeServices.layer), + NodeRuntime.runMain +) diff --git a/packages/tools/api-diff/test/Cli.test.ts b/packages/tools/api-diff/test/Cli.test.ts new file mode 100644 index 00000000000..bbc3ad1663b --- /dev/null +++ b/packages/tools/api-diff/test/Cli.test.ts @@ -0,0 +1,27 @@ +import { cli } from "@effect/api-diff" +import { renderMigrationMarkdown } from "@effect/api-diff/Mapping" +import * as NodeServices from "@effect/platform-node/NodeServices" +import { assert, it } from "@effect/vitest" +import * as Effect from "effect/Effect" +import * as Command from "effect/unstable/cli/Command" +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" + +it.effect("generates mapping documentation through the Cli command", () => + Effect.gen(function*() { + const root = mkdtempSync(join(tmpdir(), "api-diff-cli-")) + const mappingPath = join(root, "mapping.json") + const outputPath = join(root, "mapping.md") + const mapping = { version: 1 as const, modules: [], apis: [] } + writeFileSync(mappingPath, JSON.stringify(mapping)) + + yield* Command.runWith(cli, { version: "0.0.0" })([ + "--mapping", + mappingPath, + "--write-mapping-doc", + outputPath + ]).pipe(Effect.provide(NodeServices.layer)) + + assert.strictEqual(readFileSync(outputPath, "utf8"), renderMigrationMarkdown(mapping)) + })) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2b6d360d0b9..974bb828a81 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -712,6 +712,13 @@ importers: version: 26.1.1 packages/tools/api-diff: + dependencies: + '@effect/platform-node': + specifier: workspace:^ + version: link:../../platform-node + effect: + specifier: workspace:^ + version: link:../../effect devDependencies: '@effect/vitest': specifier: workspace:^ From 6f8399f51388b5b6e3a602f51d15a6bef162925e Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Thu, 23 Jul 2026 17:01:06 +1200 Subject: [PATCH 4/6] Migrate API diff tooling to Effect services - Use Effect platform services for discovery, snapshots, worktrees, and CLI execution - Validate manifests, migration maps, and snapshots with Schema --- .changeset/migrate-api-diff-cli.md | 2 +- packages/tools/api-diff/src/ApiDiff.ts | 173 +++++++++ packages/tools/api-diff/src/Cli.ts | 119 +----- packages/tools/api-diff/src/Discovery.ts | 359 ++++++++++-------- packages/tools/api-diff/src/Error.ts | 6 + packages/tools/api-diff/src/Json.ts | 10 +- packages/tools/api-diff/src/Mapping.ts | 71 +--- packages/tools/api-diff/src/Model.ts | 94 +++-- packages/tools/api-diff/src/Snapshot.ts | 108 ++++-- packages/tools/api-diff/src/Worktrees.ts | 341 +++++++++++------ packages/tools/api-diff/src/bin.ts | 8 +- packages/tools/api-diff/test/Cli.test.ts | 27 +- .../tools/api-diff/test/Discovery.test.ts | 83 ++-- packages/tools/api-diff/test/Mapping.test.ts | 46 ++- packages/tools/api-diff/test/Snapshot.test.ts | 143 +++---- packages/tools/api-diff/test/utils.ts | 27 +- 16 files changed, 951 insertions(+), 666 deletions(-) create mode 100644 packages/tools/api-diff/src/ApiDiff.ts create mode 100644 packages/tools/api-diff/src/Error.ts diff --git a/.changeset/migrate-api-diff-cli.md b/.changeset/migrate-api-diff-cli.md index f3ee698e127..c9970147c50 100644 --- a/.changeset/migrate-api-diff-cli.md +++ b/.changeset/migrate-api-diff-cli.md @@ -2,4 +2,4 @@ "@effect/api-diff": patch --- -Migrate the API diff command to the Effect CLI modules. +Migrate the API diff command to Effect services for filesystem, path, process, discovery, and snapshot operations. diff --git a/packages/tools/api-diff/src/ApiDiff.ts b/packages/tools/api-diff/src/ApiDiff.ts new file mode 100644 index 00000000000..1f04056b388 --- /dev/null +++ b/packages/tools/api-diff/src/ApiDiff.ts @@ -0,0 +1,173 @@ +import * as Console from "effect/Console" +import * as Context from "effect/Context" +import * as Effect from "effect/Effect" +import * as FileSystem from "effect/FileSystem" +import * as Layer from "effect/Layer" +import * as Option from "effect/Option" +import * as Path from "effect/Path" +import { diffSnapshots } from "./Diff.ts" +import { ApiDiffError } from "./Error.ts" +import { prettyJson } from "./Json.ts" +import { decodeMigrationMap, mappingModules, renderMigrationMarkdown, validateMigrationMap } from "./Mapping.ts" +import type { MappingDiagnostic, MigrationMap } from "./Model.ts" +import { renderMarkdownReport } from "./Report.ts" +import { Worktrees } from "./Worktrees.ts" + +export interface ApiDiffOptions { + readonly baseRef: Option.Option + readonly headRef: Option.Option + readonly mapping: string + readonly output: Option.Option + readonly writeMappingDoc: Option.Option +} + +const diagnosticMessage = (diagnostics: ReadonlyArray): string => + diagnostics + .filter((diagnostic) => diagnostic.severity === "error") + .map((diagnostic) => `${diagnostic.code}: ${diagnostic.message}`) + .join("\n") + +export class ApiDiff extends Context.Service Effect.Effect +}>()("@effect/api-diff/ApiDiff") { + static readonly layerNoDependencies = Layer.effect( + ApiDiff, + Effect.gen(function*() { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const worktrees = yield* Worktrees + + const absolute = (repoRoot: string, location: string): string => + path.isAbsolute(location) ? location : path.resolve(repoRoot, location) + + const findRepoRoot = Effect.fnUntraced(function*() { + let current = path.resolve() + while (true) { + if ( + (yield* fs.exists(path.join(current, ".git"))) || + (yield* fs.exists(path.join(current, "pnpm-workspace.yaml"))) + ) { + return current + } + const parent = path.dirname(current) + if (parent === current) { + return yield* new ApiDiffError({ + message: `Could not locate repository root from ${path.resolve()}` + }) + } + current = parent + } + }) + + const readGuideSources = Effect.fnUntraced(function*(mapping: MigrationMap, repoRoot: string) { + const sources = new Map() + const guides = new Set(mapping.apis.flatMap((entry) => entry.guide === undefined ? [] : [entry.guide])) + for (const guide of guides) { + const location = absolute(repoRoot, guide) + if (yield* fs.exists(location)) { + sources.set(guide, yield* fs.readFileString(location)) + } + } + return sources + }) + + const failOnDiagnostics = (diagnostics: ReadonlyArray): Effect.Effect => { + const message = diagnosticMessage(diagnostics) + return message.length === 0 ? Effect.void : new ApiDiffError({ message }) + } + + const runInternal = Effect.fnUntraced(function*(options: ApiDiffOptions) { + const repoRoot = yield* findRepoRoot() + const mappingLocation = absolute(repoRoot, options.mapping) + const mappingSource = yield* fs.readFileString(mappingLocation) + const mapping = yield* decodeMigrationMap(mappingSource).pipe( + Effect.mapError((cause) => + new ApiDiffError({ + message: `Invalid migration map ${mappingLocation}`, + cause + }) + ) + ) + const guideSources = yield* readGuideSources(mapping, repoRoot) + const staticDiagnostics = validateMigrationMap(mapping, { guideSources }) + + if (Option.isSome(options.writeMappingDoc)) { + yield* failOnDiagnostics(staticDiagnostics) + const output = absolute(repoRoot, options.writeMappingDoc.value) + yield* fs.makeDirectory(path.dirname(output), { recursive: true }) + yield* fs.writeFileString(output, renderMigrationMarkdown(mapping)) + yield* Console.log(`Generated ${path.relative(repoRoot, output)}`) + return + } + + if (Option.isNone(options.baseRef) || Option.isNone(options.headRef) || Option.isNone(options.output)) { + return yield* new ApiDiffError({ + message: "--base-ref, --head-ref, and --output are required for comparison" + }) + } + yield* failOnDiagnostics(staticDiagnostics) + + const baseSha = yield* worktrees.resolveRef(repoRoot, options.baseRef.value) + const headSha = yield* worktrees.resolveRef(repoRoot, options.headRef.value) + const modules = mappingModules(mapping) + const toolRoot = path.join(repoRoot, "tmp", "api-diff") + const cacheRoot = path.join(toolRoot, "cache") + const worktreesRoot = path.join(toolRoot, "worktrees") + yield* Console.log( + `Base ${options.baseRef.value}: ${baseSha}\nHead ${options.headRef.value}: ${headSha}` + ) + const base = yield* worktrees.prepareSnapshot({ + repoRoot, + cacheRoot, + worktreesRoot, + name: "base", + ref: options.baseRef.value, + sha: baseSha, + modules: modules.base + }) + const head = yield* worktrees.prepareSnapshot({ + repoRoot, + cacheRoot, + worktreesRoot, + name: "head", + ref: options.headRef.value, + sha: headSha, + modules: modules.head + }) + const mappingDiagnostics = validateMigrationMap(mapping, { base, guideSources, head }) + const output = absolute(repoRoot, options.output.value) + const diff = diffSnapshots(base, head, mapping, mappingDiagnostics) + const report = renderMarkdownReport(diff) + + yield* fs.makeDirectory(output, { recursive: true }) + yield* fs.writeFileString(path.join(output, "base.snapshot.json"), prettyJson(base)) + yield* fs.writeFileString(path.join(output, "head.snapshot.json"), prettyJson(head)) + yield* fs.writeFileString(path.join(output, "diff.json"), prettyJson(diff)) + yield* fs.writeFileString(path.join(output, "report.md"), report) + yield* Console.log(`Wrote ${path.relative(repoRoot, output)} (${diff.changes.length} changes)`) + yield* failOnDiagnostics(mappingDiagnostics) + if (report.length === 0) { + return yield* new ApiDiffError({ message: "Generated Markdown report is empty" }) + } + }) + + const run = (options: ApiDiffOptions): Effect.Effect => + runInternal(options).pipe( + Effect.mapError((cause) => + cause instanceof ApiDiffError + ? cause + : new ApiDiffError({ + message: "API diff failed", + cause + }) + ) + ) + + return ApiDiff.of({ run }) + }) + ) + + static readonly layer = this.layerNoDependencies.pipe( + Layer.provide(Worktrees.layer) + ) +} diff --git a/packages/tools/api-diff/src/Cli.ts b/packages/tools/api-diff/src/Cli.ts index fbcec925295..d34a3a55fbb 100644 --- a/packages/tools/api-diff/src/Cli.ts +++ b/packages/tools/api-diff/src/Cli.ts @@ -1,15 +1,7 @@ -import * as Data from "effect/Data" import * as Effect from "effect/Effect" -import * as Option from "effect/Option" import * as Command from "effect/unstable/cli/Command" import * as Flag from "effect/unstable/cli/Flag" -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs" -import { dirname, isAbsolute, join, relative, resolve } from "node:path" -import { diffSnapshots } from "./Diff.ts" -import { writeJson } from "./Json.ts" -import { mappingModules, parseMigrationMap, renderMigrationMarkdown, validateMigrationMap } from "./Mapping.ts" -import { renderMarkdownReport } from "./Report.ts" -import { prepareSnapshot, resolveRef, writeSnapshotOutput } from "./Worktrees.ts" +import { ApiDiff, type ApiDiffOptions } from "./ApiDiff.ts" const baseRef = Flag.string("base-ref").pipe( Flag.withDescription("Explicit base Git ref"), @@ -38,114 +30,11 @@ const writeMappingDoc = Flag.string("write-mapping-doc").pipe( Flag.optional ) -const findRepoRoot = (cwd: string): string => { - let current = resolve(cwd) - while (true) { - if (existsSync(join(current, ".git")) || existsSync(join(current, "pnpm-workspace.yaml"))) { - return current - } - const parent = dirname(current) - if (parent === current) { - throw new Error(`Could not locate repository root from ${cwd}`) - } - current = parent - } -} - -const absolute = (repoRoot: string, path: string): string => isAbsolute(path) ? path : resolve(repoRoot, path) - -class ApiDiffError extends Data.TaggedError("ApiDiffError")<{ - readonly message: string - readonly cause: unknown -}> {} - -interface CliOptions { - readonly baseRef: Option.Option - readonly headRef: Option.Option - readonly mapping: string - readonly output: Option.Option - readonly writeMappingDoc: Option.Option -} - -const runApiDiff = Effect.fnUntraced(function*(options: CliOptions) { - yield* Effect.try({ - try: () => runApiDiffSync(options), - catch: (cause) => - new ApiDiffError({ - message: cause instanceof Error ? cause.message : String(cause), - cause - }) - }) +const runApiDiff = Effect.fnUntraced(function*(options: ApiDiffOptions) { + const apiDiff = yield* ApiDiff + yield* apiDiff.run(options) }) -const runApiDiffSync = (options: CliOptions): void => { - const repoRoot = findRepoRoot(process.cwd()) - const mappingPath = absolute(repoRoot, options.mapping) - const mapping = parseMigrationMap(mappingPath) - const staticDiagnostics = validateMigrationMap(mapping, { repoRoot }) - if (Option.isSome(options.writeMappingDoc)) { - if (staticDiagnostics.some((diagnostic) => diagnostic.severity === "error")) { - throw new Error(staticDiagnostics.map((diagnostic) => `${diagnostic.code}: ${diagnostic.message}`).join("\n")) - } - const output = absolute(repoRoot, options.writeMappingDoc.value) - mkdirSync(dirname(output), { recursive: true }) - writeFileSync(output, renderMigrationMarkdown(mapping)) - process.stdout.write(`Generated ${relative(repoRoot, output)}\n`) - return - } - if (Option.isNone(options.baseRef) || Option.isNone(options.headRef) || Option.isNone(options.output)) { - throw new Error("--base-ref, --head-ref, and --output are required for comparison") - } - if (staticDiagnostics.some((diagnostic) => diagnostic.severity === "error")) { - throw new Error(staticDiagnostics.map((diagnostic) => `${diagnostic.code}: ${diagnostic.message}`).join("\n")) - } - - const baseSha = resolveRef(repoRoot, options.baseRef.value) - const headSha = resolveRef(repoRoot, options.headRef.value) - const modules = mappingModules(mapping) - const toolRoot = join(repoRoot, "tmp", "api-diff") - const cacheRoot = join(toolRoot, "cache") - const worktreesRoot = join(toolRoot, "worktrees") - process.stdout.write(`Base ${options.baseRef.value}: ${baseSha}\nHead ${options.headRef.value}: ${headSha}\n`) - const base = prepareSnapshot({ - repoRoot, - cacheRoot, - worktreesRoot, - name: "base", - ref: options.baseRef.value, - sha: baseSha, - modules: modules.base - }) - const head = prepareSnapshot({ - repoRoot, - cacheRoot, - worktreesRoot, - name: "head", - ref: options.headRef.value, - sha: headSha, - modules: modules.head - }) - const mappingDiagnostics = validateMigrationMap(mapping, { base, head, repoRoot }) - const output = absolute(repoRoot, options.output.value) - mkdirSync(output, { recursive: true }) - writeSnapshotOutput(join(output, "base.snapshot.json"), base) - writeSnapshotOutput(join(output, "head.snapshot.json"), head) - const diff = diffSnapshots(base, head, mapping, mappingDiagnostics) - writeJson(join(output, "diff.json"), diff) - writeFileSync(join(output, "report.md"), renderMarkdownReport(diff)) - - const errors = mappingDiagnostics.filter((diagnostic) => diagnostic.severity === "error") - process.stdout.write(`Wrote ${relative(repoRoot, output)} (${diff.changes.length} changes)\n`) - if (errors.length > 0) { - throw new Error(errors.map((diagnostic) => `${diagnostic.code}: ${diagnostic.message}`).join("\n")) - } - - const report = readFileSync(join(output, "report.md"), "utf8") - if (report.length === 0) { - throw new Error("Generated Markdown report is empty") - } -} - export const cli = Command.make("api-diff", { baseRef, headRef, diff --git a/packages/tools/api-diff/src/Discovery.ts b/packages/tools/api-diff/src/Discovery.ts index d36996713fb..1811eb6a7d0 100644 --- a/packages/tools/api-diff/src/Discovery.ts +++ b/packages/tools/api-diff/src/Discovery.ts @@ -1,15 +1,23 @@ -import { existsSync, readdirSync, readFileSync, statSync } from "node:fs" -import { dirname, join, relative, resolve, sep } from "node:path" +import * as Context from "effect/Context" +import * as Effect from "effect/Effect" +import * as FileSystem from "effect/FileSystem" +import * as Layer from "effect/Layer" +import * as Path from "effect/Path" +import type * as PlatformError from "effect/PlatformError" +import * as Schema from "effect/Schema" +import { ApiDiffError } from "./Error.ts" import type { Entrypoint } from "./Model.ts" -interface PackageManifest { - readonly name?: string - readonly private?: boolean - readonly exports?: unknown - readonly publishConfig?: { - readonly exports?: unknown - } -} +const PackageManifest = Schema.Struct({ + name: Schema.optional(Schema.String), + private: Schema.optional(Schema.Boolean), + exports: Schema.optional(Schema.Unknown), + publishConfig: Schema.optional(Schema.Struct({ + exports: Schema.optional(Schema.Unknown) + })) +}) + +type PackageManifest = typeof PackageManifest.Type interface PackageInfo { readonly name: string @@ -19,49 +27,12 @@ interface PackageInfo { readonly exports: unknown } -const readManifest = (path: string): PackageManifest => JSON.parse(readFileSync(path, "utf8")) - -const walk = (root: string, predicate: (path: string) => boolean): ReadonlyArray => { - const output: Array = [] - const visit = (directory: string): void => { - for (const entry of readdirSync(directory, { withFileTypes: true })) { - if (entry.name === "node_modules" || entry.name === ".git") { - continue - } - const path = join(directory, entry.name) - if (entry.isDirectory()) { - visit(path) - } else if (predicate(path)) { - output.push(path) - } - } - } - if (existsSync(root)) { - visit(root) - } - return output.sort() +export interface DiscoveryResult { + readonly entrypoints: ReadonlyArray + readonly missing: ReadonlyArray } -const packagesIn = (repoRoot: string): ReadonlyArray => - walk(join(repoRoot, "packages"), (path) => path.endsWith(`${sep}package.json`)) - .map((path): PackageInfo | undefined => { - const sourceRoot = dirname(path) - const packedPath = join(sourceRoot, "dist", "package.json") - const packed = existsSync(packedPath) - const manifest = readManifest(packed ? packedPath : path) - if (manifest.name === undefined || manifest.private === true) { - return undefined - } - return { - name: manifest.name, - root: packed ? join(sourceRoot, "dist") : sourceRoot, - declarationRoot: packed ? join(sourceRoot, "dist") : join(sourceRoot, "dist"), - exports: packed ? manifest.exports : (manifest.publishConfig?.exports ?? manifest.exports), - manifest - } - }) - .filter((entry): entry is PackageInfo => entry !== undefined) - .sort((left, right) => right.name.length - left.name.length) +const decodePackageManifest = Schema.decodeUnknownEffect(Schema.fromJsonString(PackageManifest)) const exportedTarget = (value: unknown): string | null | undefined => { if (value === null || typeof value === "string") { @@ -105,109 +76,201 @@ const moduleParts = (module: string, packages: ReadonlyArray): { } } -const targetToDeclaration = (packageInfo: PackageInfo, target: string): string => { - const relativeTarget = target.replace(/^\.\//, "") - const declaration = relativeTarget.endsWith(".d.ts") - ? relativeTarget - : relativeTarget.replace(/\.(?:mjs|cjs|js|mts|cts|ts)$/, ".d.ts") - return resolve(packageInfo.root, declaration) -} +export class Discovery extends Context.Service + ) => Effect.Effect +}>()("@effect/api-diff/Discovery") { + static readonly layer = Layer.effect( + Discovery, + Effect.gen(function*() { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path -const expandPattern = ( - packageInfo: PackageInfo, - keyPattern: string, - targetPattern: string, - requested: ReadonlySet -): ReadonlyArray => { - const candidates = walk(packageInfo.declarationRoot, (path) => path.endsWith(".d.ts")) - const targetNormalized = targetPattern.endsWith(".d.ts") - ? targetPattern - : targetPattern.replace(/\.(?:mjs|cjs|js|mts|cts|ts)$/, ".d.ts") - const entries: Array = [] - for (const declarationFile of candidates) { - const relativeFile = `./${relative(packageInfo.root, declarationFile).split(sep).join("/")}` - const capture = matchPattern(targetNormalized, relativeFile) - if (capture === undefined) { - continue - } - const key = keyPattern.replace("*", capture) - const module = key === "." ? packageInfo.name : `${packageInfo.name}${key.slice(1)}` - if (requested.has(module)) { - entries.push({ packageName: packageInfo.name, module, declarationFile }) - } - } - return entries -} + const readManifest = Effect.fnUntraced(function*(location: string) { + const source = yield* fs.readFileString(location) + return yield* decodePackageManifest(source) + }) -export const discoverEntrypoints = ( - repoRoot: string, - requestedModules: ReadonlyArray -): { readonly entrypoints: ReadonlyArray; readonly missing: ReadonlyArray } => { - const packages = packagesIn(repoRoot) - const requested = new Set(requestedModules) - const output = new Map() - - for (const module of requested) { - const parts = moduleParts(module, packages) - if (parts === undefined || typeof parts.packageInfo.exports !== "object" || parts.packageInfo.exports === null) { - continue - } - const exportsMap = parts.packageInfo.exports as Record - const exact = Object.prototype.hasOwnProperty.call(exportsMap, parts.key) - ? exportedTarget(exportsMap[parts.key]) - : undefined - if (typeof exact === "string") { - const declarationFile = targetToDeclaration(parts.packageInfo, exact) - if (existsSync(declarationFile) && statSync(declarationFile).isFile()) { - output.set(module, { packageName: parts.packageInfo.name, module, declarationFile }) - } - continue - } - if (exact === null) { - continue - } - for (const [keyPattern, rawTarget] of Object.entries(exportsMap)) { - if (!keyPattern.includes("*")) { - continue - } - const capture = matchPattern(keyPattern, parts.key) - const target = exportedTarget(rawTarget) - if (capture === undefined || typeof target !== "string") { - continue - } - const exclusions = Object.entries(exportsMap).some(([excludedKey, excludedTarget]) => - excludedTarget === null && matchPattern(excludedKey, parts.key) !== undefined - ) - if (exclusions) { - continue - } - if (target.includes("*")) { - const declarationFile = targetToDeclaration(parts.packageInfo, target.replace("*", capture)) - if (existsSync(declarationFile)) { - output.set(module, { packageName: parts.packageInfo.name, module, declarationFile }) + const walk = Effect.fnUntraced(function*( + root: string, + predicate: (location: string) => boolean + ) { + const output: Array = [] + const visit: (directory: string) => Effect.Effect = Effect.fnUntraced( + function*(directory: string) { + const entries = yield* fs.readDirectory(directory) + for (const entry of entries) { + if (entry === "node_modules" || entry === ".git") { + continue + } + const location = path.join(directory, entry) + const info = yield* fs.stat(location) + if (info.type === "Directory") { + yield* visit(location) + } else if (predicate(location)) { + output.push(location) + } + } + } + ) + if (yield* fs.exists(root)) { + yield* visit(root) } - } - } - } + return output.sort() + }) - for (const packageInfo of packages) { - if (typeof packageInfo.exports !== "object" || packageInfo.exports === null) { - continue - } - for (const [keyPattern, rawTarget] of Object.entries(packageInfo.exports as Record)) { - const target = exportedTarget(rawTarget) - if (keyPattern.includes("*") && typeof target === "string" && target.includes("*")) { - for (const entry of expandPattern(packageInfo, keyPattern, target, requested)) { - if (!output.has(entry.module)) { - output.set(entry.module, entry) + const packagesIn = Effect.fnUntraced(function*(repoRoot: string) { + const manifestPaths = yield* walk( + path.join(repoRoot, "packages"), + (location) => location.endsWith(`${path.sep}package.json`) + ) + const packages: Array = [] + for (const manifestPath of manifestPaths) { + const sourceRoot = path.dirname(manifestPath) + const packedPath = path.join(sourceRoot, "dist", "package.json") + const packed = yield* fs.exists(packedPath) + const manifest = yield* readManifest(packed ? packedPath : manifestPath) + if (manifest.name === undefined || manifest.private === true) { + continue } + packages.push({ + name: manifest.name, + root: packed ? path.join(sourceRoot, "dist") : sourceRoot, + declarationRoot: packed ? path.join(sourceRoot, "dist") : path.join(sourceRoot, "dist"), + exports: packed ? manifest.exports : (manifest.publishConfig?.exports ?? manifest.exports), + manifest + }) } + return packages.sort((left, right) => right.name.length - left.name.length) + }) + + const targetToDeclaration = (packageInfo: PackageInfo, target: string): string => { + const relativeTarget = target.replace(/^\.\//, "") + const declaration = relativeTarget.endsWith(".d.ts") + ? relativeTarget + : relativeTarget.replace(/\.(?:mjs|cjs|js|mts|cts|ts)$/, ".d.ts") + return path.resolve(packageInfo.root, declaration) } - } - } - return { - entrypoints: [...output.values()].sort((left, right) => left.module.localeCompare(right.module)), - missing: requestedModules.filter((module) => !output.has(module)).sort() - } + const expandPattern = Effect.fnUntraced(function*( + packageInfo: PackageInfo, + keyPattern: string, + targetPattern: string, + requested: ReadonlySet + ) { + const candidates = yield* walk(packageInfo.declarationRoot, (location) => location.endsWith(".d.ts")) + const targetNormalized = targetPattern.endsWith(".d.ts") + ? targetPattern + : targetPattern.replace(/\.(?:mjs|cjs|js|mts|cts|ts)$/, ".d.ts") + const entries: Array = [] + for (const declarationFile of candidates) { + const relativeFile = `./${path.relative(packageInfo.root, declarationFile).split(path.sep).join("/")}` + const capture = matchPattern(targetNormalized, relativeFile) + if (capture === undefined) { + continue + } + const key = keyPattern.replace("*", capture) + const module = key === "." ? packageInfo.name : `${packageInfo.name}${key.slice(1)}` + if (requested.has(module)) { + entries.push({ packageName: packageInfo.name, module, declarationFile }) + } + } + return entries + }) + + const discoverEntrypointsInternal = Effect.fnUntraced(function*( + repoRoot: string, + requestedModules: ReadonlyArray + ) { + const packages = yield* packagesIn(repoRoot) + const requested = new Set(requestedModules) + const output = new Map() + + for (const module of requested) { + const parts = moduleParts(module, packages) + if ( + parts === undefined || + typeof parts.packageInfo.exports !== "object" || + parts.packageInfo.exports === null + ) { + continue + } + const exportsMap = parts.packageInfo.exports as Record + const exact = Object.prototype.hasOwnProperty.call(exportsMap, parts.key) + ? exportedTarget(exportsMap[parts.key]) + : undefined + if (typeof exact === "string") { + const declarationFile = targetToDeclaration(parts.packageInfo, exact) + if ((yield* fs.exists(declarationFile)) && (yield* fs.stat(declarationFile)).type === "File") { + output.set(module, { packageName: parts.packageInfo.name, module, declarationFile }) + } + continue + } + if (exact === null) { + continue + } + for (const [keyPattern, rawTarget] of Object.entries(exportsMap)) { + if (!keyPattern.includes("*")) { + continue + } + const capture = matchPattern(keyPattern, parts.key) + const target = exportedTarget(rawTarget) + if (capture === undefined || typeof target !== "string") { + continue + } + const exclusions = Object.entries(exportsMap).some(([excludedKey, excludedTarget]) => + excludedTarget === null && matchPattern(excludedKey, parts.key) !== undefined + ) + if (exclusions) { + continue + } + if (target.includes("*")) { + const declarationFile = targetToDeclaration(parts.packageInfo, target.replace("*", capture)) + if (yield* fs.exists(declarationFile)) { + output.set(module, { packageName: parts.packageInfo.name, module, declarationFile }) + } + } + } + } + + for (const packageInfo of packages) { + if (typeof packageInfo.exports !== "object" || packageInfo.exports === null) { + continue + } + for (const [keyPattern, rawTarget] of Object.entries(packageInfo.exports as Record)) { + const target = exportedTarget(rawTarget) + if (keyPattern.includes("*") && typeof target === "string" && target.includes("*")) { + for (const entry of yield* expandPattern(packageInfo, keyPattern, target, requested)) { + if (!output.has(entry.module)) { + output.set(entry.module, entry) + } + } + } + } + } + + return { + entrypoints: [...output.values()].sort((left, right) => left.module.localeCompare(right.module)), + missing: requestedModules.filter((module) => !output.has(module)).sort() + } + }) + + const discoverEntrypoints = ( + repoRoot: string, + requestedModules: ReadonlyArray + ): Effect.Effect => + discoverEntrypointsInternal(repoRoot, requestedModules).pipe( + Effect.mapError((cause) => + new ApiDiffError({ + message: `Could not discover API entrypoints in ${repoRoot}`, + cause + }) + ) + ) + + return Discovery.of({ discoverEntrypoints }) + }) + ) } diff --git a/packages/tools/api-diff/src/Error.ts b/packages/tools/api-diff/src/Error.ts new file mode 100644 index 00000000000..48df10c84cd --- /dev/null +++ b/packages/tools/api-diff/src/Error.ts @@ -0,0 +1,6 @@ +import * as Schema from "effect/Schema" + +export class ApiDiffError extends Schema.TaggedErrorClass()("ApiDiffError", { + message: Schema.String, + cause: Schema.optional(Schema.Defect()) +}) {} diff --git a/packages/tools/api-diff/src/Json.ts b/packages/tools/api-diff/src/Json.ts index de4989cfe4c..30588163db1 100644 --- a/packages/tools/api-diff/src/Json.ts +++ b/packages/tools/api-diff/src/Json.ts @@ -1,8 +1,7 @@ +import * as Schema from "effect/Schema" import { createHash } from "node:crypto" -import { mkdirSync, readFileSync, writeFileSync } from "node:fs" -import { dirname } from "node:path" -export const readJson = (path: string): unknown => JSON.parse(readFileSync(path, "utf8")) +export const decodeJson = Schema.decodeUnknownEffect(Schema.UnknownFromJsonString) export const stableJson = (value: unknown): string => { const visit = (input: unknown): unknown => { @@ -24,7 +23,4 @@ export const stableJson = (value: unknown): string => { export const fingerprint = (value: unknown): string => createHash("sha256").update(stableJson(value)).digest("hex") -export const writeJson = (path: string, value: unknown): void => { - mkdirSync(dirname(path), { recursive: true }) - writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`) -} +export const prettyJson = (value: unknown): string => `${JSON.stringify(value, null, 2)}\n` diff --git a/packages/tools/api-diff/src/Mapping.ts b/packages/tools/api-diff/src/Mapping.ts index 87cf521e932..a892b53a83f 100644 --- a/packages/tools/api-diff/src/Mapping.ts +++ b/packages/tools/api-diff/src/Mapping.ts @@ -1,52 +1,8 @@ -import { existsSync, readFileSync } from "node:fs" -import { resolve } from "node:path" -import { readJson } from "./Json.ts" -import type { ApiMapping, ApiSnapshot, ApiTarget, MappingDiagnostic, MigrationMap, ModuleMapping } from "./Model.ts" +import * as Schema from "effect/Schema" +import type { ApiMapping, ApiSnapshot, ApiTarget, MappingDiagnostic, ModuleMapping } from "./Model.ts" +import { MigrationMap } from "./Model.ts" -const isStringArray = (value: unknown): value is ReadonlyArray => - Array.isArray(value) && value.every((entry) => typeof entry === "string") - -const isTarget = (value: unknown): value is ApiTarget => - typeof value === "object" && value !== null && - typeof Reflect.get(value, "module") === "string" && - isStringArray(Reflect.get(value, "path")) && - (Reflect.get(value, "bucket") === undefined || - Reflect.get(value, "bucket") === "type" || - Reflect.get(value, "bucket") === "value") - -export const parseMigrationMap = (path: string): MigrationMap => { - const value = readJson(path) - if (typeof value !== "object" || value === null || Reflect.get(value, "version") !== 1) { - throw new Error(`Unsupported migration map version in ${path}`) - } - const modules = Reflect.get(value, "modules") - const apis = Reflect.get(value, "apis") - if (!Array.isArray(modules) || !Array.isArray(apis)) { - throw new Error(`Migration map must contain modules and apis arrays: ${path}`) - } - for (const [index, module] of modules.entries()) { - if ( - typeof module !== "object" || module === null || - (Reflect.get(module, "from") !== undefined && typeof Reflect.get(module, "from") !== "string") || - !isStringArray(Reflect.get(module, "to")) || - !["added", "moved", "removed", "split", "consolidated", "unchanged"].includes( - String(Reflect.get(module, "status")) - ) - ) { - throw new Error(`Invalid module mapping at modules[${index}]`) - } - } - for (const [index, api] of apis.entries()) { - if ( - typeof api !== "object" || api === null || - !isTarget(Reflect.get(api, "from")) || - !(Reflect.get(api, "to") === null || isTarget(Reflect.get(api, "to"))) - ) { - throw new Error(`Invalid API mapping at apis[${index}]`) - } - } - return value as unknown as MigrationMap -} +export const decodeMigrationMap = Schema.decodeUnknownEffect(Schema.fromJsonString(MigrationMap)) const targetKey = (target: ApiTarget): string => `${target.module}#${target.path.join(".")}#${target.bucket ?? "*"}` @@ -57,15 +13,14 @@ const entityMatchesTarget = (snapshot: ApiSnapshot, target: ApiTarget): number = (target.bucket === undefined || entity.bucket === target.bucket) ).length -const linkedGuideMappings = (repoRoot: string, mapping: ApiMapping): ReadonlyArray => { - if (mapping.guide === undefined) { - return [] - } - const path = resolve(repoRoot, mapping.guide) - if (!existsSync(path)) { +const linkedGuideMappings = ( + guideSources: ReadonlyMap, + mapping: ApiMapping +): ReadonlyArray => { + if (mapping.guide === undefined || !guideSources.has(mapping.guide)) { return [] } - const source = readFileSync(path, "utf8") + const source = guideSources.get(mapping.guide)! const fromName = mapping.from.path.at(-1) if (fromName === undefined) { return [] @@ -83,7 +38,7 @@ const linkedGuideMappings = (repoRoot: string, mapping: ApiMapping): ReadonlyArr export interface ValidateMappingOptions { readonly base?: ApiSnapshot readonly head?: ApiSnapshot - readonly repoRoot?: string + readonly guideSources?: ReadonlyMap } export const validateMigrationMap = ( @@ -164,8 +119,8 @@ export const validateMigrationMap = ( }) } } - if (options.repoRoot !== undefined && entry.to !== null) { - const documented = linkedGuideMappings(options.repoRoot, entry) + if (options.guideSources !== undefined && entry.to !== null) { + const documented = linkedGuideMappings(options.guideSources, entry) const expected = entry.to.path.join(".") for (const actual of documented) { if (actual !== expected && !actual.endsWith(`.${expected}`)) { diff --git a/packages/tools/api-diff/src/Model.ts b/packages/tools/api-diff/src/Model.ts index 165c135f20b..8bb841b23c7 100644 --- a/packages/tools/api-diff/src/Model.ts +++ b/packages/tools/api-diff/src/Model.ts @@ -1,10 +1,16 @@ -export type Bucket = "type" | "value" +import * as Schema from "effect/Schema" -export interface SourceLocation { - readonly file: string - readonly line: number - readonly column: number -} +export const Bucket = Schema.Literals(["type", "value"]) + +export type Bucket = typeof Bucket.Type + +export const SourceLocation = Schema.Struct({ + file: Schema.String, + line: Schema.Number, + column: Schema.Number +}) + +export type SourceLocation = typeof SourceLocation.Type export interface Documentation { readonly summary?: string | undefined @@ -74,13 +80,15 @@ export interface Entrypoint { readonly declarationFile: string } -export interface SnapshotDiagnostic { - readonly code: string - readonly message: string - readonly module?: string | undefined - readonly path?: ReadonlyArray | undefined - readonly source?: SourceLocation | undefined -} +export const SnapshotDiagnostic = Schema.Struct({ + code: Schema.String, + message: Schema.String, + module: Schema.optional(Schema.String), + path: Schema.optional(Schema.Array(Schema.String)), + source: Schema.optional(SourceLocation) +}) + +export type SnapshotDiagnostic = typeof SnapshotDiagnostic.Type export interface ApiSnapshot { readonly version: 1 @@ -96,35 +104,45 @@ export interface ApiSnapshot { readonly diagnostics: ReadonlyArray } -export type ModuleStatus = "added" | "moved" | "removed" | "split" | "consolidated" | "unchanged" +export const ModuleStatus = Schema.Literals(["added", "moved", "removed", "split", "consolidated", "unchanged"]) -export interface ModuleMapping { - readonly from?: string | undefined - readonly to: ReadonlyArray - readonly status: ModuleStatus - readonly barrels?: ReadonlyArray | undefined - readonly guide?: string | undefined - readonly note?: string | undefined -} +export type ModuleStatus = typeof ModuleStatus.Type -export interface ApiTarget { - readonly module: string - readonly path: ReadonlyArray - readonly bucket?: Bucket | undefined -} +export const ModuleMapping = Schema.Struct({ + from: Schema.optional(Schema.String), + to: Schema.Array(Schema.String), + status: ModuleStatus, + barrels: Schema.optional(Schema.Array(Schema.String)), + guide: Schema.optional(Schema.String), + note: Schema.optional(Schema.String) +}) -export interface ApiMapping { - readonly from: ApiTarget - readonly to: ApiTarget | null - readonly guide?: string | undefined - readonly note?: string | undefined -} +export type ModuleMapping = typeof ModuleMapping.Type -export interface MigrationMap { - readonly version: 1 - readonly modules: ReadonlyArray - readonly apis: ReadonlyArray -} +export const ApiTarget = Schema.Struct({ + module: Schema.String, + path: Schema.Array(Schema.String), + bucket: Schema.optional(Bucket) +}) + +export type ApiTarget = typeof ApiTarget.Type + +export const ApiMapping = Schema.Struct({ + from: ApiTarget, + to: Schema.NullOr(ApiTarget), + guide: Schema.optional(Schema.String), + note: Schema.optional(Schema.String) +}) + +export type ApiMapping = typeof ApiMapping.Type + +export const MigrationMap = Schema.Struct({ + version: Schema.Literal(1), + modules: Schema.Array(ModuleMapping), + apis: Schema.Array(ApiMapping) +}) + +export type MigrationMap = typeof MigrationMap.Type export interface MappingDiagnostic { readonly severity: "error" | "warning" diff --git a/packages/tools/api-diff/src/Snapshot.ts b/packages/tools/api-diff/src/Snapshot.ts index 9a369d18d67..ec11e8a0875 100644 --- a/packages/tools/api-diff/src/Snapshot.ts +++ b/packages/tools/api-diff/src/Snapshot.ts @@ -1,8 +1,13 @@ -import { createHash } from "node:crypto" -import { relative, sep } from "node:path" +import * as Context from "effect/Context" +import * as Effect from "effect/Effect" +import * as Layer from "effect/Layer" +import * as Path from "effect/Path" +import * as Schema from "effect/Schema" import ts from "typescript-compiler" -import { discoverEntrypoints } from "./Discovery.ts" +import { Discovery, type DiscoveryResult } from "./Discovery.ts" +import { ApiDiffError } from "./Error.ts" import { fingerprint, stableJson } from "./Json.ts" +import { SnapshotDiagnostic as SnapshotDiagnosticSchema } from "./Model.ts" import type { ApiEntity, ApiSnapshot, @@ -20,18 +25,20 @@ import type { interface SerializationContext { readonly checker: ts.TypeChecker + readonly path: Path.Path readonly repoRoot: string readonly typeParameters: ReadonlyMap readonly publicSymbols: ReadonlyMap } -const normalizedPath = (root: string, path: string): string => relative(root, path).split(sep).join("/") +const normalizedPath = (path: Path.Path, root: string, location: string): string => + path.relative(root, location).split(path.sep).join("/") -const sourceLocation = (root: string, node: ts.Node): SourceLocation => { +const sourceLocation = (path: Path.Path, root: string, node: ts.Node): SourceLocation => { const source = node.getSourceFile() const position = source.getLineAndCharacterOfPosition(node.getStart(source, false)) return { - file: normalizedPath(root, source.fileName), + file: normalizedPath(path, root, source.fileName), line: position.line + 1, column: position.character + 1 } @@ -83,14 +90,15 @@ const referenceResolution = (name: ts.EntityName, context: SerializationContext) return { apiId: publicApiId } } const file = declaration.getSourceFile().fileName - const nodeModules = file.lastIndexOf(`${sep}node_modules${sep}`) + const separator = context.path.sep + const nodeModules = file.lastIndexOf(`${separator}node_modules${separator}`) if (nodeModules !== -1) { - const remainder = file.slice(nodeModules + `${sep}node_modules${sep}`.length).split(sep) + const remainder = file.slice(nodeModules + `${separator}node_modules${separator}`.length).split(separator) return { externalPackage: remainder[0]?.startsWith("@") ? remainder.slice(0, 2).join("/") : remainder[0] } } - return { declaration: normalizedPath(context.repoRoot, file) } + return { declaration: normalizedPath(context.path, context.repoRoot, file) } } const primitiveKinds = new Map([ @@ -693,25 +701,32 @@ export interface ExtractSnapshotOptions { readonly modules: ReadonlyArray } -export class SnapshotExtractionError extends Error { - readonly diagnostics: ReadonlyArray - - constructor(diagnostics: ReadonlyArray) { - super(diagnostics.map((diagnostic) => `${diagnostic.code}: ${diagnostic.message}`).join("\n")) - this.name = "SnapshotExtractionError" - this.diagnostics = diagnostics +export class SnapshotExtractionError extends Schema.TaggedErrorClass()( + "SnapshotExtractionError", + { + message: Schema.String, + diagnostics: Schema.Array(SnapshotDiagnosticSchema) } -} +) {} + +const snapshotExtractionError = (diagnostics: ReadonlyArray): SnapshotExtractionError => + new SnapshotExtractionError({ + message: diagnostics.map((diagnostic) => `${diagnostic.code}: ${diagnostic.message}`).join("\n"), + diagnostics + }) -export const extractSnapshot = (options: ExtractSnapshotOptions): ApiSnapshot => { - const discovered = discoverEntrypoints(options.repoRoot, options.modules) +const extractSnapshot = ( + options: ExtractSnapshotOptions, + discovered: DiscoveryResult, + path: Path.Path +): ApiSnapshot => { const diagnostics: Array = discovered.missing.map((module) => ({ code: "missing-entrypoint", message: `Consumer-visible declaration entrypoint not found: ${module}`, module })) if (diagnostics.length > 0) { - throw new SnapshotExtractionError(diagnostics) + throw snapshotExtractionError(diagnostics) } const program = ts.createProgram({ rootNames: discovered.entrypoints.map((entrypoint) => entrypoint.declarationFile), @@ -729,12 +744,12 @@ export const extractSnapshot = (options: ExtractSnapshotOptions): ApiSnapshot => const compilerDiagnostics = program.getSyntacticDiagnostics() .filter((diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error) if (compilerDiagnostics.length > 0) { - throw new SnapshotExtractionError(compilerDiagnostics.map((diagnostic) => ({ + throw snapshotExtractionError(compilerDiagnostics.map((diagnostic) => ({ code: `typescript-${diagnostic.code}`, message: ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"), source: diagnostic.file === undefined ? undefined - : sourceLocation(options.repoRoot, diagnostic.file) + : sourceLocation(path, options.repoRoot, diagnostic.file) }))) } const checker = program.getTypeChecker() @@ -770,6 +785,7 @@ export const extractSnapshot = (options: ExtractSnapshotOptions): ApiSnapshot => serialized = pendingEntity.declarations.map((node) => serializeDeclaration(node, { checker, + path, repoRoot: options.repoRoot, typeParameters: new Map(), publicSymbols @@ -781,7 +797,7 @@ export const extractSnapshot = (options: ExtractSnapshotOptions): ApiSnapshot => message: error instanceof Error ? error.message : String(error), module: route.module, path: route.path, - source: sourceLocation(options.repoRoot, declaration) + source: sourceLocation(path, options.repoRoot, declaration) }) continue } @@ -800,11 +816,11 @@ export const extractSnapshot = (options: ExtractSnapshotOptions): ApiSnapshot => displaySignature: displayDeclarations(pendingEntity.declarations), fingerprint: fingerprintDeclarations(serialized), documentation: documentation(pendingEntity.symbol, checker, route.module), - source: sourceLocation(options.repoRoot, declaration) + source: sourceLocation(path, options.repoRoot, declaration) }) } if (diagnostics.length > 0) { - throw new SnapshotExtractionError(diagnostics) + throw snapshotExtractionError(diagnostics) } const packages = [...new Set(discovered.entrypoints.map((entrypoint) => entrypoint.packageName))].sort() @@ -816,7 +832,7 @@ export const extractSnapshot = (options: ExtractSnapshotOptions): ApiSnapshot => packages, entrypoints: discovered.entrypoints.map((entrypoint) => ({ ...entrypoint, - declarationFile: normalizedPath(options.repoRoot, entrypoint.declarationFile) + declarationFile: normalizedPath(path, options.repoRoot, entrypoint.declarationFile) })), entities: entities.sort((left, right) => left.id.localeCompare(right.id)), diagnostics: [] @@ -826,7 +842,39 @@ export const extractSnapshot = (options: ExtractSnapshotOptions): ApiSnapshot => export const snapshotCacheKey = ( sha: string, modules: ReadonlyArray -): string => - createHash("sha256") - .update(`snapshot-v3\0${sha}\0${ts.version}\0${[...modules].sort().join("\0")}`) - .digest("hex") +): string => fingerprint(["snapshot-v3", sha, ts.version, [...modules].sort()]) + +export class Snapshotter extends Context.Service Effect.Effect< + ApiSnapshot, + ApiDiffError | SnapshotExtractionError + > +}>()("@effect/api-diff/Snapshotter") { + static readonly layerNoDependencies = Layer.effect( + Snapshotter, + Effect.gen(function*() { + const discovery = yield* Discovery + const path = yield* Path.Path + + const extract = Effect.fnUntraced(function*(options: ExtractSnapshotOptions) { + const discovered = yield* discovery.discoverEntrypoints(options.repoRoot, options.modules) + return yield* Effect.try({ + try: () => extractSnapshot(options, discovered, path), + catch: (cause) => + cause instanceof SnapshotExtractionError + ? cause + : new ApiDiffError({ + message: `Could not extract the API snapshot for ${options.ref}`, + cause + }) + }) + }) + + return Snapshotter.of({ extract }) + }) + ) + + static readonly layer = this.layerNoDependencies.pipe( + Layer.provide(Discovery.layer) + ) +} diff --git a/packages/tools/api-diff/src/Worktrees.ts b/packages/tools/api-diff/src/Worktrees.ts index e2d5fa768f4..fe3dd827474 100644 --- a/packages/tools/api-diff/src/Worktrees.ts +++ b/packages/tools/api-diff/src/Worktrees.ts @@ -1,138 +1,235 @@ -import { spawnSync } from "node:child_process" -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" -import { dirname, join } from "node:path" -import { readJson, writeJson } from "./Json.ts" +import * as Console from "effect/Console" +import * as Context from "effect/Context" +import * as Effect from "effect/Effect" +import * as FileSystem from "effect/FileSystem" +import * as Layer from "effect/Layer" +import * as Path from "effect/Path" +import * as Stream from "effect/Stream" +import * as ChildProcess from "effect/unstable/process/ChildProcess" +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner" +import { ApiDiffError } from "./Error.ts" +import { decodeJson, prettyJson } from "./Json.ts" import type { ApiSnapshot } from "./Model.ts" -import { extractSnapshot, snapshotCacheKey } from "./Snapshot.ts" +import { snapshotCacheKey, Snapshotter } from "./Snapshot.ts" -interface CommandResult { - readonly status: number | null - readonly stdout: string - readonly stderr: string +export interface PrepareSnapshotOptions { + readonly repoRoot: string + readonly cacheRoot: string + readonly worktreesRoot: string + readonly name: "base" | "head" + readonly ref: string + readonly sha: string + readonly modules: ReadonlyArray } -const run = (command: string, args: ReadonlyArray, cwd: string): CommandResult => { - const result = spawnSync(command, args, { - cwd, - encoding: "utf8", - maxBuffer: 100 * 1024 * 1024, - stdio: ["ignore", "pipe", "pipe"] - }) - if (result.error !== undefined) { - throw result.error - } - return { - status: result.status, - stdout: result.stdout ?? "", - stderr: result.stderr ?? "" - } -} +export class Worktrees extends Context.Service Effect.Effect + readonly prepareSnapshot: (options: PrepareSnapshotOptions) => Effect.Effect +}>()("@effect/api-diff/Worktrees") { + static readonly layerNoDependencies = Layer.effect( + Worktrees, + Effect.gen(function*() { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const snapshotter = yield* Snapshotter + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner -const runChecked = (command: string, args: ReadonlyArray, cwd: string): string => { - const result = run(command, args, cwd) - if (result.status !== 0) { - throw new Error( - `Command failed (${command} ${args.join(" ")}):\n${result.stdout}${result.stderr}`.trim() - ) - } - return result.stdout.trim() -} + const runCommandScoped = Effect.fnUntraced(function*( + command: string, + args: ReadonlyArray, + cwd: string + ) { + const display = `${command} ${args.join(" ")}` + const handle = yield* spawner.spawn( + ChildProcess.make(command, args, { cwd }) + ).pipe( + Effect.mapError((cause) => + new ApiDiffError({ + message: `Could not start command (${display})`, + cause + }) + ) + ) + const [output, exitCode] = yield* Effect.all([ + Stream.mkString(Stream.decodeText(handle.all)), + handle.exitCode + ], { concurrency: "unbounded" }).pipe( + Effect.mapError((cause) => + new ApiDiffError({ + message: `Could not run command (${display})`, + cause + }) + ) + ) + return { display, exitCode, output } + }) -export const resolveRef = (repoRoot: string, ref: string): string => - runChecked("git", ["rev-parse", "--verify", `${ref}^{commit}`], repoRoot) + const runCommand = ( + command: string, + args: ReadonlyArray, + cwd: string + ): Effect.Effect<{ + readonly display: string + readonly exitCode: ChildProcessSpawner.ExitCode + readonly output: string + }, ApiDiffError> => Effect.scoped(runCommandScoped(command, args, cwd)) -const enableStripInternal = (worktree: string): void => { - const baseConfig = join(worktree, "tsconfig.base.json") - if (!existsSync(baseConfig)) { - return - } - const source = readFileSync(baseConfig, "utf8") - if (/"stripInternal"\s*:\s*true/.test(source)) { - return - } - const updated = source.replace(/("stripInternal"\s*:\s*)false/, "$1true") - if (updated === source) { - return - } - writeFileSync(baseConfig, updated) -} + const runChecked = Effect.fnUntraced(function*( + command: string, + args: ReadonlyArray, + cwd: string + ) { + const result = yield* runCommand(command, args, cwd) + if (result.exitCode !== ChildProcessSpawner.ExitCode(0)) { + return yield* new ApiDiffError({ + message: `Command failed (${result.display}):\n${result.output}`.trim() + }) + } + return result.output.trim() + }) -const hasProductionStripInternal = (worktree: string): boolean => { - const candidates = [ - join(worktree, "tsconfig.base.json"), - join(worktree, "tsconfig.build.json"), - join(worktree, "packages", "effect", "tsconfig.build.json") - ] - return candidates.some((path) => existsSync(path) && /"stripInternal"\s*:\s*true/.test(readFileSync(path, "utf8"))) -} + const enableStripInternal = Effect.fnUntraced(function*(worktree: string) { + const baseConfig = path.join(worktree, "tsconfig.base.json") + if (!(yield* fs.exists(baseConfig))) { + return + } + const source = yield* fs.readFileString(baseConfig) + if (/"stripInternal"\s*:\s*true/.test(source)) { + return + } + const updated = source.replace(/("stripInternal"\s*:\s*)false/, "$1true") + if (updated !== source) { + yield* fs.writeFileString(baseConfig, updated) + } + }) -const createWorktree = (repoRoot: string, parent: string, name: string, sha: string): string => { - const path = join(parent, name) - runChecked("git", ["worktree", "add", "--detach", path, sha], repoRoot) - return path -} + const hasProductionStripInternal = Effect.fnUntraced(function*(worktree: string) { + const candidates = [ + path.join(worktree, "tsconfig.base.json"), + path.join(worktree, "tsconfig.build.json"), + path.join(worktree, "packages", "effect", "tsconfig.build.json") + ] + for (const candidate of candidates) { + if ((yield* fs.exists(candidate)) && /"stripInternal"\s*:\s*true/.test(yield* fs.readFileString(candidate))) { + return true + } + } + return false + }) -const removeWorktree = (repoRoot: string, path: string): void => { - const result = run("git", ["worktree", "remove", "--force", path], repoRoot) - if (result.status !== 0) { - process.stderr.write(result.stdout) - process.stderr.write(result.stderr) - } -} + const buildWorktree = Effect.fnUntraced(function*(worktree: string) { + yield* enableStripInternal(worktree) + if (!(yield* hasProductionStripInternal(worktree))) { + return yield* new ApiDiffError({ + message: `No production TypeScript configuration enables stripInternal in ${worktree}` + }) + } + // Native test-only dependencies in old branches may not build on the + // current Node runtime. Declaration emission does not require dependency lifecycle scripts. + yield* runChecked("pnpm", ["install", "--frozen-lockfile", "--ignore-scripts"], worktree) + yield* runChecked("pnpm", ["build"], worktree) + }) -const buildWorktree = (worktree: string): void => { - enableStripInternal(worktree) - if (!hasProductionStripInternal(worktree)) { - throw new Error(`No production TypeScript configuration enables stripInternal in ${worktree}`) - } - // Native test-only dependencies in old branches may not build on the current - // Node runtime. Declaration emission does not require dependency lifecycle scripts. - runChecked("pnpm", ["install", "--frozen-lockfile", "--ignore-scripts"], worktree) - runChecked("pnpm", ["build"], worktree) -} + const removeWorktree = (repoRoot: string, worktree: string): Effect.Effect => + runCommand("git", ["worktree", "remove", "--force", worktree], repoRoot).pipe( + Effect.flatMap((result) => + result.exitCode === ChildProcessSpawner.ExitCode(0) + ? Effect.void + : Console.error(result.output) + ), + Effect.catch((error) => Console.error(error.message)) + ) -export interface PrepareSnapshotOptions { - readonly repoRoot: string - readonly cacheRoot: string - readonly worktreesRoot: string - readonly name: "base" | "head" - readonly ref: string - readonly sha: string - readonly modules: ReadonlyArray -} + const resolveRef = Effect.fnUntraced(function*(repoRoot: string, ref: string) { + return yield* runChecked("git", ["rev-parse", "--verify", `${ref}^{commit}`], repoRoot) + }) + + const prepareSnapshotInternal = Effect.fnUntraced(function*(options: PrepareSnapshotOptions) { + const key = snapshotCacheKey(options.sha, options.modules) + const cacheLocation = path.join(options.cacheRoot, key, "snapshot.json") + if (yield* fs.exists(cacheLocation)) { + const source = yield* fs.readFileString(cacheLocation).pipe( + Effect.mapError((cause) => + new ApiDiffError({ + message: `Could not read cached snapshot ${cacheLocation}`, + cause + }) + ) + ) + const cached = (yield* decodeJson(source).pipe( + Effect.mapError((cause) => + new ApiDiffError({ + message: `Could not parse cached snapshot ${cacheLocation}`, + cause + }) + ) + )) as ApiSnapshot + return { ...cached, ref: options.ref, sha: options.sha } + } -export const prepareSnapshot = (options: PrepareSnapshotOptions): ApiSnapshot => { - const key = snapshotCacheKey(options.sha, options.modules) - const cachePath = join(options.cacheRoot, key, "snapshot.json") - if (existsSync(cachePath)) { - const cached = readJson(cachePath) as ApiSnapshot - return { ...cached, ref: options.ref, sha: options.sha } - } - mkdirSync(options.worktreesRoot, { recursive: true }) - const runRoot = mkdtempSync(join(options.worktreesRoot, `${options.name}-`)) - const worktree = join(runRoot, "repo") - let added = false - try { - createWorktree(options.repoRoot, runRoot, "repo", options.sha) - added = true - buildWorktree(worktree) - const snapshot = extractSnapshot({ - repoRoot: worktree, - ref: options.ref, - sha: options.sha, - modules: options.modules + yield* fs.makeDirectory(options.worktreesRoot, { recursive: true }) + return yield* Effect.scoped(Effect.gen(function*() { + const runRoot = yield* fs.makeTempDirectoryScoped({ + directory: options.worktreesRoot, + prefix: `${options.name}-` + }) + const worktree = path.join(runRoot, "repo") + return yield* Effect.acquireUseRelease( + runChecked("git", ["worktree", "add", "--detach", worktree, options.sha], options.repoRoot), + () => + Effect.gen(function*() { + yield* buildWorktree(worktree) + const snapshot = yield* snapshotter.extract({ + repoRoot: worktree, + ref: options.ref, + sha: options.sha, + modules: options.modules + }).pipe( + Effect.mapError((cause) => + cause instanceof ApiDiffError + ? cause + : new ApiDiffError({ + message: `Could not extract the ${options.name} API snapshot`, + cause + }) + ) + ) + yield* fs.makeDirectory(path.dirname(cacheLocation), { recursive: true }) + yield* fs.writeFileString(cacheLocation, prettyJson(snapshot)).pipe( + Effect.mapError((cause) => + new ApiDiffError({ + message: `Could not cache the ${options.name} API snapshot`, + cause + }) + ) + ) + return snapshot + }), + () => removeWorktree(options.repoRoot, worktree) + ) + })) + }) + + const prepareSnapshot = (options: PrepareSnapshotOptions): Effect.Effect => + prepareSnapshotInternal(options).pipe( + Effect.mapError((cause) => + cause instanceof ApiDiffError + ? cause + : new ApiDiffError({ + message: `Could not prepare the ${options.name} API snapshot`, + cause + }) + ) + ) + + return Worktrees.of({ + prepareSnapshot, + resolveRef + }) }) - writeJson(cachePath, snapshot) - return snapshot - } finally { - if (added) { - removeWorktree(options.repoRoot, worktree) - } - rmSync(runRoot, { recursive: true, force: true }) - } -} + ) -export const writeSnapshotOutput = (path: string, snapshot: ApiSnapshot): void => { - mkdirSync(dirname(path), { recursive: true }) - writeJson(path, snapshot) + static readonly layer = this.layerNoDependencies.pipe( + Layer.provide(Snapshotter.layer) + ) } diff --git a/packages/tools/api-diff/src/bin.ts b/packages/tools/api-diff/src/bin.ts index 311fbba6024..4455c3f21fb 100644 --- a/packages/tools/api-diff/src/bin.ts +++ b/packages/tools/api-diff/src/bin.ts @@ -2,11 +2,17 @@ import * as NodeRuntime from "@effect/platform-node/NodeRuntime" import * as NodeServices from "@effect/platform-node/NodeServices" import * as Effect from "effect/Effect" +import * as Layer from "effect/Layer" import * as Command from "effect/unstable/cli/Command" import PackageJson from "../package.json" with { type: "json" } +import { ApiDiff } from "./ApiDiff.ts" import { cli } from "./Cli.ts" +const MainLayer = ApiDiff.layer.pipe( + Layer.provideMerge(NodeServices.layer) +) + Command.run(cli, { version: PackageJson.version }).pipe( - Effect.provide(NodeServices.layer), + Effect.provide(MainLayer), NodeRuntime.runMain ) diff --git a/packages/tools/api-diff/test/Cli.test.ts b/packages/tools/api-diff/test/Cli.test.ts index bbc3ad1663b..8cb99e2f595 100644 --- a/packages/tools/api-diff/test/Cli.test.ts +++ b/packages/tools/api-diff/test/Cli.test.ts @@ -1,27 +1,34 @@ import { cli } from "@effect/api-diff" +import { ApiDiff } from "@effect/api-diff/ApiDiff" import { renderMigrationMarkdown } from "@effect/api-diff/Mapping" import * as NodeServices from "@effect/platform-node/NodeServices" import { assert, it } from "@effect/vitest" import * as Effect from "effect/Effect" +import * as FileSystem from "effect/FileSystem" +import * as Layer from "effect/Layer" +import * as Path from "effect/Path" import * as Command from "effect/unstable/cli/Command" -import { mkdtempSync, readFileSync, writeFileSync } from "node:fs" -import { tmpdir } from "node:os" -import { join } from "node:path" + +const MainLayer = ApiDiff.layer.pipe( + Layer.provideMerge(NodeServices.layer) +) it.effect("generates mapping documentation through the Cli command", () => Effect.gen(function*() { - const root = mkdtempSync(join(tmpdir(), "api-diff-cli-")) - const mappingPath = join(root, "mapping.json") - const outputPath = join(root, "mapping.md") + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const root = yield* fs.makeTempDirectoryScoped({ prefix: "api-diff-cli-" }) + const mappingPath = path.join(root, "mapping.json") + const outputPath = path.join(root, "mapping.md") const mapping = { version: 1 as const, modules: [], apis: [] } - writeFileSync(mappingPath, JSON.stringify(mapping)) + yield* fs.writeFileString(mappingPath, JSON.stringify(mapping)) yield* Command.runWith(cli, { version: "0.0.0" })([ "--mapping", mappingPath, "--write-mapping-doc", outputPath - ]).pipe(Effect.provide(NodeServices.layer)) + ]) - assert.strictEqual(readFileSync(outputPath, "utf8"), renderMigrationMarkdown(mapping)) - })) + assert.strictEqual(yield* fs.readFileString(outputPath), renderMigrationMarkdown(mapping)) + }).pipe(Effect.provide(MainLayer))) diff --git a/packages/tools/api-diff/test/Discovery.test.ts b/packages/tools/api-diff/test/Discovery.test.ts index 88810d0ab2f..ca2683660db 100644 --- a/packages/tools/api-diff/test/Discovery.test.ts +++ b/packages/tools/api-diff/test/Discovery.test.ts @@ -1,43 +1,54 @@ -import { discoverEntrypoints } from "@effect/api-diff/Discovery" +import { Discovery } from "@effect/api-diff/Discovery" +import * as NodeServices from "@effect/platform-node/NodeServices" import { assert, describe, it } from "@effect/vitest" -import { mkdtempSync } from "node:fs" -import { tmpdir } from "node:os" -import { join } from "node:path" +import * as Effect from "effect/Effect" +import * as FileSystem from "effect/FileSystem" +import * as Layer from "effect/Layer" import { writeFixturePackage } from "./utils.ts" +const MainLayer = Discovery.layer.pipe( + Layer.provideMerge(NodeServices.layer) +) + describe("entrypoint discovery", () => { - it("expands wildcards and respects null exclusions", () => { - const root = mkdtempSync(join(tmpdir(), "api-diff-discovery-")) - writeFixturePackage(root, { - "index.d.ts": `export * as Foo from "./Foo.js"\n`, - "Foo.d.ts": `export declare const value: string\n`, - "internal/Secret.d.ts": `export declare const secret: string\n` - }) + it.effect("expands wildcards and respects null exclusions", () => + Effect.gen(function*() { + const fs = yield* FileSystem.FileSystem + const discovery = yield* Discovery + const root = yield* fs.makeTempDirectoryScoped({ prefix: "api-diff-discovery-" }) + yield* writeFixturePackage(root, { + "index.d.ts": `export * as Foo from "./Foo.js"\n`, + "Foo.d.ts": `export declare const value: string\n`, + "internal/Secret.d.ts": `export declare const secret: string\n` + }) - const result = discoverEntrypoints(root, [ - "@fixture/sample", - "@fixture/sample/Foo", - "@fixture/sample/internal/Secret" - ]) - assert.deepStrictEqual(result.entrypoints.map((entrypoint) => entrypoint.module), [ - "@fixture/sample", - "@fixture/sample/Foo" - ]) - assert.deepStrictEqual(result.missing, ["@fixture/sample/internal/Secret"]) - }) + const result = yield* discovery.discoverEntrypoints(root, [ + "@fixture/sample", + "@fixture/sample/Foo", + "@fixture/sample/internal/Secret" + ]) + assert.deepStrictEqual(result.entrypoints.map((entrypoint) => entrypoint.module), [ + "@fixture/sample", + "@fixture/sample/Foo" + ]) + assert.deepStrictEqual(result.missing, ["@fixture/sample/internal/Secret"]) + }).pipe(Effect.provide(MainLayer))) - it("supports conditional export targets", () => { - const root = mkdtempSync(join(tmpdir(), "api-diff-discovery-")) - writeFixturePackage(root, { - "Foo.d.ts": `export declare const value: string\n` - }, { - "./Foo": { - types: "./Foo.d.ts", - import: "./Foo.js" - } - }) - const result = discoverEntrypoints(root, ["@fixture/sample/Foo"]) - assert.strictEqual(result.missing.length, 0) - assert.strictEqual(result.entrypoints[0]?.module, "@fixture/sample/Foo") - }) + it.effect("supports conditional export targets", () => + Effect.gen(function*() { + const fs = yield* FileSystem.FileSystem + const discovery = yield* Discovery + const root = yield* fs.makeTempDirectoryScoped({ prefix: "api-diff-discovery-" }) + yield* writeFixturePackage(root, { + "Foo.d.ts": `export declare const value: string\n` + }, { + "./Foo": { + types: "./Foo.d.ts", + import: "./Foo.js" + } + }) + const result = yield* discovery.discoverEntrypoints(root, ["@fixture/sample/Foo"]) + assert.strictEqual(result.missing.length, 0) + assert.strictEqual(result.entrypoints[0]?.module, "@fixture/sample/Foo") + }).pipe(Effect.provide(MainLayer))) }) diff --git a/packages/tools/api-diff/test/Mapping.test.ts b/packages/tools/api-diff/test/Mapping.test.ts index 8267e58e257..4c3d4108216 100644 --- a/packages/tools/api-diff/test/Mapping.test.ts +++ b/packages/tools/api-diff/test/Mapping.test.ts @@ -1,9 +1,10 @@ -import { parseMigrationMap, renderMigrationMarkdown, validateMigrationMap } from "@effect/api-diff/Mapping" +import { decodeMigrationMap, renderMigrationMarkdown, validateMigrationMap } from "@effect/api-diff/Mapping" import type { MigrationMap } from "@effect/api-diff/Model" +import * as NodeServices from "@effect/platform-node/NodeServices" import { assert, describe, it } from "@effect/vitest" -import { mkdtempSync, readFileSync, writeFileSync } from "node:fs" -import { tmpdir } from "node:os" -import { join } from "node:path" +import * as Effect from "effect/Effect" +import * as FileSystem from "effect/FileSystem" +import * as Path from "effect/Path" const mapping: MigrationMap = { version: 1, @@ -67,30 +68,33 @@ describe("migration mapping", () => { }) it("detects the conflicting catchSome guide", () => { - const root = mkdtempSync(join(tmpdir(), "api-diff-mapping-")) - writeFileSync(join(root, "guide.md"), "| `Effect.catchSome` | `Effect.catchIf` |\n") const diagnostics = validateMigrationMap({ ...mapping, apis: [{ ...mapping.apis[0]!, guide: "guide.md" }] - }, { repoRoot: root }) + }, { + guideSources: new Map([["guide.md", "| `Effect.catchSome` | `Effect.catchIf` |\n"]]) + }) assert(diagnostics.some((diagnostic) => diagnostic.code === "contradictory-guide")) }) - it("keeps the repository Markdown generated from the structured map", () => { - const jsonUrl = new URL("../../../../migration/v3-to-v4.json", import.meta.url) - const markdownUrl = new URL("../../../../migration/v3-to-v4.md", import.meta.url) - const parsed = parseMigrationMap(jsonUrl.pathname) - assert.strictEqual(renderMigrationMarkdown(parsed), readFileSync(markdownUrl, "utf8")) - assert.strictEqual(validateMigrationMap(parsed).length, 0) - const catchSome = parsed.apis.find((entry) => - entry.from.module === "effect/Effect" && entry.from.path.join(".") === "catchSome" - ) - assert.deepStrictEqual(catchSome?.to, { - module: "effect/Effect", - path: ["catchFilter"] - }) - }) + it.effect("keeps the repository Markdown generated from the structured map", () => + Effect.gen(function*() { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const jsonPath = yield* path.fromFileUrl(new URL("../../../../migration/v3-to-v4.json", import.meta.url)) + const markdownPath = yield* path.fromFileUrl(new URL("../../../../migration/v3-to-v4.md", import.meta.url)) + const parsed = yield* decodeMigrationMap(yield* fs.readFileString(jsonPath)) + assert.strictEqual(renderMigrationMarkdown(parsed), yield* fs.readFileString(markdownPath)) + assert.strictEqual(validateMigrationMap(parsed).length, 0) + const catchSome = parsed.apis.find((entry) => + entry.from.module === "effect/Effect" && entry.from.path.join(".") === "catchSome" + ) + assert.deepStrictEqual(catchSome?.to, { + module: "effect/Effect", + path: ["catchFilter"] + }) + }).pipe(Effect.provide(NodeServices.layer))) }) diff --git a/packages/tools/api-diff/test/Snapshot.test.ts b/packages/tools/api-diff/test/Snapshot.test.ts index 20e74e99e82..4ba61294108 100644 --- a/packages/tools/api-diff/test/Snapshot.test.ts +++ b/packages/tools/api-diff/test/Snapshot.test.ts @@ -1,10 +1,15 @@ -import { extractSnapshot, SnapshotExtractionError } from "@effect/api-diff/Snapshot" +import { SnapshotExtractionError, Snapshotter } from "@effect/api-diff/Snapshot" +import * as NodeServices from "@effect/platform-node/NodeServices" import { assert, describe, it } from "@effect/vitest" -import { mkdtempSync } from "node:fs" -import { tmpdir } from "node:os" -import { join } from "node:path" +import * as Effect from "effect/Effect" +import * as FileSystem from "effect/FileSystem" +import * as Layer from "effect/Layer" import { writeFixturePackage } from "./utils.ts" +const MainLayer = Snapshotter.layer.pipe( + Layer.provideMerge(NodeServices.layer) +) + const source = ` export declare function overloaded(value: A): A export declare function overloaded(value: number, radix?: number): string @@ -43,79 +48,83 @@ export declare function merged(value: string): string ` describe("canonical snapshot", () => { - it("extracts declarations, overloads, namespaces, re-exports, and canonical types deterministically", () => { - const root = mkdtempSync(join(tmpdir(), "api-diff-snapshot-")) - writeFixturePackage(root, { - "index.d.ts": `export * as Foo from "./Foo.js"\n`, - "Foo.d.ts": source - }) + it.effect("extracts declarations, overloads, namespaces, re-exports, and canonical types deterministically", () => + Effect.gen(function*() { + const fs = yield* FileSystem.FileSystem + const snapshotter = yield* Snapshotter + const root = yield* fs.makeTempDirectoryScoped({ prefix: "api-diff-snapshot-" }) + yield* writeFixturePackage(root, { + "index.d.ts": `export * as Foo from "./Foo.js"\n`, + "Foo.d.ts": source + }) - const options = { - repoRoot: root, - ref: "fixture", - sha: "0000000000000000000000000000000000000000", - modules: ["@fixture/sample", "@fixture/sample/Foo"] - } - const first = extractSnapshot(options) - const second = extractSnapshot(options) - assert.deepStrictEqual(first, second) - assert(first.entities.some((entity) => - entity.module === "@fixture/sample/Foo" && - entity.path.join(".") === "overloaded" && - entity.declarations.length === 2 - )) - const overloaded = first.entities.find((entity) => entity.id === "@fixture/sample/Foo#overloaded#value") - assert.deepStrictEqual(overloaded?.importRoutes, [ - { module: "@fixture/sample", path: ["Foo", "overloaded"] }, - { module: "@fixture/sample/Foo", path: ["overloaded"] } - ]) - const client = first.entities.filter((entity) => entity.path.join(".") === "Client") - assert.deepStrictEqual(client.map((entity) => entity.bucket), ["type", "value"]) - assert(first.entities.some((entity) => entity.path.join(".") === "merged.Options" && entity.bucket === "type")) - assert(first.entities.some((entity) => JSON.stringify(entity.declarations).includes("\"kind\":\"conditional\""))) - assert(first.entities.some((entity) => JSON.stringify(entity.declarations).includes("\"kind\":\"mapped\""))) - const imported = first.entities.find((entity) => entity.path.join(".") === "Imported") - assert(imported !== undefined, JSON.stringify(first.entities.map((entity) => entity.id))) - assert(JSON.stringify(imported.declarations).includes("\"externalPackage\":\"node:fs\"")) - }) + const options = { + repoRoot: root, + ref: "fixture", + sha: "0000000000000000000000000000000000000000", + modules: ["@fixture/sample", "@fixture/sample/Foo"] + } + const first = yield* snapshotter.extract(options) + const second = yield* snapshotter.extract(options) + assert.deepStrictEqual(first, second) + assert(first.entities.some((entity) => + entity.module === "@fixture/sample/Foo" && + entity.path.join(".") === "overloaded" && + entity.declarations.length === 2 + )) + const overloaded = first.entities.find((entity) => entity.id === "@fixture/sample/Foo#overloaded#value") + assert.deepStrictEqual(overloaded?.importRoutes, [ + { module: "@fixture/sample", path: ["Foo", "overloaded"] }, + { module: "@fixture/sample/Foo", path: ["overloaded"] } + ]) + const client = first.entities.filter((entity) => entity.path.join(".") === "Client") + assert.deepStrictEqual(client.map((entity) => entity.bucket), ["type", "value"]) + assert(first.entities.some((entity) => entity.path.join(".") === "merged.Options" && entity.bucket === "type")) + assert(first.entities.some((entity) => JSON.stringify(entity.declarations).includes("\"kind\":\"conditional\""))) + assert(first.entities.some((entity) => JSON.stringify(entity.declarations).includes("\"kind\":\"mapped\""))) + const imported = first.entities.find((entity) => entity.path.join(".") === "Imported") + assert(imported !== undefined, JSON.stringify(first.entities.map((entity) => entity.id))) + assert(JSON.stringify(imported.declarations).includes("\"externalPackage\":\"node:fs\"")) + }).pipe(Effect.provide(MainLayer))) - it("fails rather than silently omitting unsupported public declarations", () => { - const root = mkdtempSync(join(tmpdir(), "api-diff-snapshot-")) - writeFixturePackage(root, { - "Bad.d.ts": ` + it.effect("fails rather than silently omitting unsupported public declarations", () => + Effect.gen(function*() { + const fs = yield* FileSystem.FileSystem + const snapshotter = yield* Snapshotter + const root = yield* fs.makeTempDirectoryScoped({ prefix: "api-diff-snapshot-" }) + yield* writeFixturePackage(root, { + "Bad.d.ts": ` declare namespace Other {} export declare namespace Bad { export import Alias = Other } ` - }) - let failure: unknown - try { - extractSnapshot({ + }) + const failure = yield* Effect.flip(snapshotter.extract({ repoRoot: root, ref: "fixture", sha: "0000000000000000000000000000000000000000", modules: ["@fixture/sample/Bad"] - }) - } catch (error) { - failure = error - } - assert(failure instanceof SnapshotExtractionError) - assert(failure.diagnostics.some((diagnostic) => diagnostic.code === "unsupported-public-declaration")) - }) + })) + assert(failure instanceof SnapshotExtractionError) + assert(failure.diagnostics.some((diagnostic) => diagnostic.code === "unsupported-public-declaration")) + }).pipe(Effect.provide(MainLayer))) - it("normalizes union order in structural fingerprints", () => { - const leftRoot = mkdtempSync(join(tmpdir(), "api-diff-snapshot-")) - const rightRoot = mkdtempSync(join(tmpdir(), "api-diff-snapshot-")) - writeFixturePackage(leftRoot, { "Choice.d.ts": "export type Choice = string | number\n" }) - writeFixturePackage(rightRoot, { "Choice.d.ts": "export type Choice = number | string\n" }) - const extract = (repoRoot: string) => - extractSnapshot({ - repoRoot, - ref: "fixture", - sha: "0000000000000000000000000000000000000000", - modules: ["@fixture/sample/Choice"] - }).entities[0]?.fingerprint - assert.strictEqual(extract(leftRoot), extract(rightRoot)) - }) + it.effect("normalizes union order in structural fingerprints", () => + Effect.gen(function*() { + const fs = yield* FileSystem.FileSystem + const snapshotter = yield* Snapshotter + const leftRoot = yield* fs.makeTempDirectoryScoped({ prefix: "api-diff-snapshot-" }) + const rightRoot = yield* fs.makeTempDirectoryScoped({ prefix: "api-diff-snapshot-" }) + yield* writeFixturePackage(leftRoot, { "Choice.d.ts": "export type Choice = string | number\n" }) + yield* writeFixturePackage(rightRoot, { "Choice.d.ts": "export type Choice = number | string\n" }) + const extract = (repoRoot: string) => + snapshotter.extract({ + repoRoot, + ref: "fixture", + sha: "0000000000000000000000000000000000000000", + modules: ["@fixture/sample/Choice"] + }).pipe(Effect.map((snapshot) => snapshot.entities[0]?.fingerprint)) + assert.strictEqual(yield* extract(leftRoot), yield* extract(rightRoot)) + }).pipe(Effect.provide(MainLayer))) }) diff --git a/packages/tools/api-diff/test/utils.ts b/packages/tools/api-diff/test/utils.ts index bfc538953ca..c296e4d89de 100644 --- a/packages/tools/api-diff/test/utils.ts +++ b/packages/tools/api-diff/test/utils.ts @@ -1,7 +1,8 @@ -import { mkdirSync, writeFileSync } from "node:fs" -import { join } from "node:path" +import * as Effect from "effect/Effect" +import * as FileSystem from "effect/FileSystem" +import * as Path from "effect/Path" -export const writeFixturePackage = ( +export const writeFixturePackage = Effect.fnUntraced(function*( repoRoot: string, files: Readonly>, exports: Readonly> = { @@ -9,11 +10,13 @@ export const writeFixturePackage = ( "./*": "./*.js", "./internal/*": null } -): void => { - const root = join(repoRoot, "packages", "sample", "dist") - mkdirSync(root, { recursive: true }) - writeFileSync( - join(root, "package.json"), +) { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const root = path.join(repoRoot, "packages", "sample", "dist") + yield* fs.makeDirectory(root, { recursive: true }) + yield* fs.writeFileString( + path.join(root, "package.json"), `${ JSON.stringify( { @@ -27,8 +30,8 @@ export const writeFixturePackage = ( }\n` ) for (const [name, source] of Object.entries(files)) { - const path = join(root, name) - mkdirSync(join(path, ".."), { recursive: true }) - writeFileSync(path, source) + const location = path.join(root, name) + yield* fs.makeDirectory(path.dirname(location), { recursive: true }) + yield* fs.writeFileString(location, source) } -} +}) From 8ed44c3224b5083583a0f23514c10dcac044fca7 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Thu, 23 Jul 2026 17:21:24 +1200 Subject: [PATCH 5/6] Detect API removals and suggest likely replacements - Report unmatched base and head APIs as removals and additions - Suggest cross-module replacements while preserving class facets --- .changeset/detect-api-removals.md | 5 + packages/tools/api-diff/README.md | 4 + packages/tools/api-diff/src/Diff.ts | 188 +++++++++++++++++++--- packages/tools/api-diff/src/Report.ts | 2 +- packages/tools/api-diff/test/Diff.test.ts | 99 +++++++++++- 5 files changed, 270 insertions(+), 28 deletions(-) create mode 100644 .changeset/detect-api-removals.md diff --git a/.changeset/detect-api-removals.md b/.changeset/detect-api-removals.md new file mode 100644 index 00000000000..bef45d7678b --- /dev/null +++ b/.changeset/detect-api-removals.md @@ -0,0 +1,5 @@ +--- +"@effect/api-diff": patch +--- + +Report removed and newly added APIs while suggesting likely v4 replacements. diff --git a/packages/tools/api-diff/README.md b/packages/tools/api-diff/README.md index 6e1b8a327c9..f83b77bc907 100644 --- a/packages/tools/api-diff/README.md +++ b/packages/tools/api-diff/README.md @@ -27,6 +27,10 @@ The command writes: - `diff.json` - `report.md` +Unmatched APIs are reported as removals from the base and additions in the +head. Likely replacements are reported separately for review, including +cross-module moves and the paired type/value facets of class-style APIs. + Regenerate the human-readable migration map after changing its JSON source: ```sh diff --git a/packages/tools/api-diff/src/Diff.ts b/packages/tools/api-diff/src/Diff.ts index 10245792f64..355bff74d81 100644 --- a/packages/tools/api-diff/src/Diff.ts +++ b/packages/tools/api-diff/src/Diff.ts @@ -21,6 +21,12 @@ interface Match { readonly note?: string } +interface ApiGroup { + readonly module: string + readonly path: ReadonlyArray + readonly entities: ReadonlyArray +} + const targetEntities = ( snapshot: ApiSnapshot, target: { @@ -59,6 +65,119 @@ const nameSimilarity = (left: string, right: string): number => { return length === 0 ? 1 : 1 - levenshtein(left.toLowerCase(), right.toLowerCase()) / length } +const entityFeatureCache = new WeakMap>() + +const entityFeatures = (entity: ApiEntity): ReadonlySet => { + const cached = entityFeatureCache.get(entity) + if (cached !== undefined) { + return cached + } + const features = new Set() + for (const declaration of entity.declarations) { + features.add(`declaration:${declaration.kind}`) + for (const member of declaration.members ?? []) { + features.add(`member:${member.kind}:${member.name}`) + } + } + entityFeatureCache.set(entity, features) + return features +} + +const setSimilarity = (left: ReadonlySet, right: ReadonlySet): number => { + if (left.size === 0 && right.size === 0) { + return 1 + } + let shared = 0 + for (const value of left) { + if (right.has(value)) { + shared++ + } + } + return shared / (left.size + right.size - shared) +} + +const documentationTokenCache = new WeakMap>() + +const documentationTokens = (entity: ApiEntity): ReadonlySet => { + const cached = documentationTokenCache.get(entity) + if (cached !== undefined) { + return cached + } + const tokens = new Set( + (entity.documentation.summary ?? "") + .toLowerCase() + .match(/[a-z][a-z0-9]+/g) + ?.filter((token) => token.length >= 4) ?? [] + ) + documentationTokenCache.set(entity, tokens) + return tokens +} + +const groupEntities = (entities: Iterable): ReadonlyArray => { + const groups = new Map>() + for (const entity of entities) { + const key = `${entity.module}#${entity.path.join(".")}` + const group = groups.get(key) + if (group === undefined) { + groups.set(key, [entity]) + } else { + group.push(entity) + } + } + return [...groups.values()].map((group) => ({ + module: group[0]!.module, + path: group[0]!.path, + entities: group.sort((left, right) => left.bucket.localeCompare(right.bucket)) + })) +} + +const groupName = (group: ApiGroup): string => group.path.at(-1) ?? "" + +const groupSimilarity = ( + base: ApiGroup, + head: ApiGroup, + mappedModules: ReadonlySet +): number => { + const facets = base.entities.flatMap((baseEntity) => { + const headEntity = head.entities.find((candidate) => candidate.bucket === baseEntity.bucket) + return headEntity === undefined ? [] : [{ base: baseEntity, head: headEntity }] + }) + if (facets.length === 0) { + return 0 + } + const sameKind = facets.filter(({ base, head }) => base.declarationKind === head.declarationKind).length / + facets.length + const structure = facets.reduce( + (total, { base, head }) => total + setSimilarity(entityFeatures(base), entityFeatures(head)), + 0 + ) / facets.length + const documentation = facets.reduce( + (total, { base, head }) => total + setSimilarity(documentationTokens(base), documentationTokens(head)), + 0 + ) / facets.length + const sameCategory = facets.some(({ base, head }) => + base.documentation.category !== undefined && + base.documentation.category === head.documentation.category + ) + const headModuleName = head.module.split("/").at(-1)?.toLowerCase() + const mentionsHeadModule = headModuleName !== undefined && + facets.some(({ base }) => documentationTokens(base).has(headModuleName)) + const moduleScore = base.module === head.module ? 0.25 : mappedModules.has(head.module) ? 0.15 : 0 + const packageScore = base.entities[0]!.packageName === head.entities[0]!.packageName ? 0.05 : 0 + return Math.min( + 0.99, + 0.2 * nameSimilarity(groupName(base), groupName(head)) + + (groupName(base) === groupName(head) ? 0.15 : 0) + + moduleScore + + packageScore + + 0.15 * sameKind + + 0.15 * structure + + 0.025 * documentation + + (sameCategory ? 0.05 : 0) + + (mentionsHeadModule ? 0.075 : 0) + ) +} + const changeId = ( classification: ChangeClassification, base?: ApiEntity, @@ -316,6 +435,7 @@ export const diffSnapshots = ( const unmatchedBase = new Map(base.entities.map((entity) => [entity.id, entity])) const unmatchedHead = new Map(head.entities.map((entity) => [entity.id, entity])) const matches: Array = [] + const suggestedMatches: Array = [] const addMatch = ( baseEntity: ApiEntity | undefined, @@ -398,34 +518,48 @@ export const diffSnapshots = ( } } - for (const moduleMapping of mapping.modules) { - if (moduleMapping.from === undefined) { + const explicitlyRemoved = new Set( + mapping.apis + .filter((entry) => entry.to === null) + .flatMap((entry) => targetEntities(base, entry.from).map((entity) => entity.id)) + ) + const headGroups = groupEntities(unmatchedHead.values()) + for (const baseGroup of groupEntities(unmatchedBase.values())) { + if (baseGroup.entities.every((entity) => explicitlyRemoved.has(entity.id))) { continue } - const bases = [...unmatchedBase.values()].filter((entity) => entity.module === moduleMapping.from) - const heads = [...unmatchedHead.values()].filter((entity) => moduleMapping.to.includes(entity.module)) - for (const baseEntity of bases) { - const ranked = heads - .filter((entity) => entity.bucket === baseEntity.bucket && unmatchedHead.has(entity.id)) - .map((entity) => { - const sameKind = entity.declarationKind === baseEntity.declarationKind ? 0.15 : 0 - const sameFingerprint = entity.fingerprint === baseEntity.fingerprint ? 0.5 : 0 - const score = Math.min( - 0.99, - 0.35 * nameSimilarity(pathName(baseEntity), pathName(entity)) + sameKind + - sameFingerprint - ) - return { entity, score } - }) - .sort((left, right) => right.score - left.score || left.entity.id.localeCompare(right.entity.id)) - const best = ranked[0] - const next = ranked[1] - if (best !== undefined && best.score >= 0.45 && (next === undefined || best.score - next.score >= 0.08)) { - addMatch(baseEntity, best.entity, { + const mappedModules = new Set( + mapping.modules + .filter((entry) => entry.from === baseGroup.module) + .flatMap((entry) => entry.to) + ) + const ranked = headGroups + .filter((group) => + groupName(group) === groupName(baseGroup) || + group.module === baseGroup.module || + mappedModules.has(group.module) + ) + .map((group) => ({ group, score: groupSimilarity(baseGroup, group, mappedModules) })) + .filter(({ score }) => score > 0) + .sort((left, right) => + right.score - left.score || + left.group.module.localeCompare(right.group.module) || + left.group.path.join(".").localeCompare(right.group.path.join(".")) + ) + const best = ranked[0] + const next = ranked[1] + if (best === undefined || best.score < 0.5 || (next !== undefined && best.score - next.score < 0.05)) { + continue + } + for (const baseEntity of baseGroup.entities) { + const headEntity = best.group.entities.find((candidate) => candidate.bucket === baseEntity.bucket) + if (headEntity !== undefined && !explicitlyRemoved.has(baseEntity.id)) { + suggestedMatches.push({ + base: baseEntity, + head: headEntity, confidence: Number(best.score.toFixed(3)), authoritative: false, - mapping: moduleMapping, - note: "Suggested match; requires review" + note: "Suggested replacement for removed API; requires review" }) } } @@ -439,6 +573,12 @@ export const diffSnapshots = ( } changes.push(...classifyStructure(match)) } + for (const match of suggestedMatches) { + const movement = movementChange(match) + if (movement !== undefined) { + changes.push(movement) + } + } for (const entity of unmatchedBase.values()) { changes.push(makeChange("api-removed", { base: entity, diff --git a/packages/tools/api-diff/src/Report.ts b/packages/tools/api-diff/src/Report.ts index 87d28ebd6a7..a5972838504 100644 --- a/packages/tools/api-diff/src/Report.ts +++ b/packages/tools/api-diff/src/Report.ts @@ -148,7 +148,7 @@ export const renderMarkdownReport = (diff: ApiDiff): string => { } } if (suggested.length > 0) { - lines.push("## Suggested matches requiring review", "") + lines.push("## Suggested replacements for removed APIs", "") for (const change of suggested) { lines.push(...renderChange(change, 3)) } diff --git a/packages/tools/api-diff/test/Diff.test.ts b/packages/tools/api-diff/test/Diff.test.ts index 47b303e377d..2b28f19be9b 100644 --- a/packages/tools/api-diff/test/Diff.test.ts +++ b/packages/tools/api-diff/test/Diff.test.ts @@ -85,14 +85,107 @@ describe("snapshot diff", () => { assert(diff.changes.some((change) => change.classification === "parameter-added")) assert(diff.changes.some((change) => change.classification === "return-type-changed")) assert(diff.changes.some((change) => change.baseApiId?.includes("similarName") && !change.authoritative)) - assert(diff.changes.some((change) => change.classification === "api-removed")) - assert(diff.changes.some((change) => change.classification === "api-added")) + assert( + diff.changes.some((change) => + change.classification === "api-removed" && change.baseApiId?.includes("similarName") + ) + ) + assert( + diff.changes.some((change) => change.classification === "api-added" && change.headApiId?.includes("similarNames")) + ) const report = renderMarkdownReport(diff) - assert(report.includes("Suggested matches requiring review")) + assert(report.includes("Suggested replacements for removed APIs")) assert(report.includes(base.sha)) assert.deepStrictEqual(diff, diffSnapshots(base, head, mapping, [])) }) + it("suggests replacements across modules and preserves class facets", () => { + const variable = (name: string): DeclarationModel => ({ + kind: "variable", + name, + type: { kind: "primitive", name: "unknown" } + }) + const serviceInterface = (name: string, extraMember?: string): DeclarationModel => ({ + kind: "interface", + name, + members: [ + { + kind: "method", + name: "context", + parameters: [], + returnType: { kind: "primitive", name: "unknown" } + }, + { + kind: "method", + name: "of", + parameters: [], + returnType: { kind: "primitive", name: "unknown" } + }, + ...(extraMember === undefined + ? [] + : [{ + kind: "method", + name: extraMember, + parameters: [], + returnType: { kind: "primitive", name: "unknown" } + }]) + ] + }) + const withBucket = (api: ApiEntity, bucket: ApiEntity["bucket"]): ApiEntity => ({ + ...api, + id: `${api.module}#${api.path.join(".")}#${bucket}`, + bucket + }) + const effectService = { + ...entity("effect/Effect", "Service", variable("Service"), "effect-service"), + documentation: { + summary: "Creates a Context Tag and Layer for a service.", + stability: "stable" as const + } + } + const contextTagValue = entity("effect/Context", "Tag", variable("Tag"), "tag-value") + const contextTagType = withBucket( + entity("effect/Context", "Tag", serviceInterface("Tag"), "tag-type"), + "type" + ) + const contextServiceValue = entity("effect/Context", "Service", variable("Service"), "service-value") + const contextServiceType = withBucket( + entity("effect/Context", "Service", serviceInterface("Service", "use"), "service-type"), + "type" + ) + const layerMapService = entity("effect/LayerMap", "Service", variable("Service"), "layer-map-service") + const diff = diffSnapshots( + snapshot("a", [effectService, contextTagType, contextTagValue]), + snapshot("b", [contextServiceType, contextServiceValue, layerMapService]), + { + version: 1, + modules: [ + { from: "effect/Effect", to: ["effect/Effect"], status: "unchanged" }, + { from: "effect/Context", to: ["effect/Context"], status: "unchanged" }, + { from: "effect/LayerMap", to: ["effect/LayerMap"], status: "unchanged" } + ], + apis: [] + }, + [] + ) + const suggestions = diff.changes.filter((change) => !change.authoritative) + assert(suggestions.some((change) => + change.baseApiId === "effect/Effect#Service#value" && + change.headApiId === "effect/Context#Service#value" + )) + assert(suggestions.some((change) => + change.baseApiId === "effect/Context#Tag#type" && + change.headApiId === "effect/Context#Service#type" + )) + assert(suggestions.some((change) => + change.baseApiId === "effect/Context#Tag#value" && + change.headApiId === "effect/Context#Service#value" + )) + assert(!suggestions.some((change) => change.headApiId === "effect/LayerMap#Service#value")) + assert.strictEqual(diff.changes.filter((change) => change.classification === "api-removed").length, 3) + assert.strictEqual(diff.changes.filter((change) => change.classification === "api-added").length, 3) + }) + it("classifies overload and parameter reordering", () => { const signature = (name: string, parameters: ReadonlyArray<"left" | "right">): DeclarationModel => ({ kind: "function", From 20842fe2e7f99321e65ea696f01bffcf09f83495 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Thu, 23 Jul 2026 21:52:46 +1200 Subject: [PATCH 6/6] Discover API changes without a migration map - Discover public entrypoints independently for each revision - Detect module additions, removals, and likely API moves from snapshots --- .changeset/detect-api-removals.md | 3 +- packages/tools/api-diff/README.md | 14 +- packages/tools/api-diff/src/ApiDiff.ts | 60 +---- packages/tools/api-diff/src/Cli.ts | 15 +- packages/tools/api-diff/src/Diff.ts | 182 +++----------- packages/tools/api-diff/src/Discovery.ts | 127 ++++++---- packages/tools/api-diff/src/Mapping.ts | 228 ------------------ packages/tools/api-diff/src/Model.ts | 50 ---- packages/tools/api-diff/src/Report.ts | 22 +- packages/tools/api-diff/src/Snapshot.ts | 6 +- packages/tools/api-diff/src/Worktrees.ts | 4 +- packages/tools/api-diff/test/Cli.test.ts | 46 +++- packages/tools/api-diff/test/Diff.test.ts | 51 +--- .../tools/api-diff/test/Discovery.test.ts | 8 + packages/tools/api-diff/test/Mapping.test.ts | 100 -------- plan.md | 81 ++----- 16 files changed, 203 insertions(+), 794 deletions(-) delete mode 100644 packages/tools/api-diff/src/Mapping.ts delete mode 100644 packages/tools/api-diff/test/Mapping.test.ts diff --git a/.changeset/detect-api-removals.md b/.changeset/detect-api-removals.md index bef45d7678b..e3453b03256 100644 --- a/.changeset/detect-api-removals.md +++ b/.changeset/detect-api-removals.md @@ -2,4 +2,5 @@ "@effect/api-diff": patch --- -Report removed and newly added APIs while suggesting likely v4 replacements. +Discover both revisions independently, report removed and newly added APIs, and +suggest likely v4 replacements without a migration map. diff --git a/packages/tools/api-diff/README.md b/packages/tools/api-diff/README.md index f83b77bc907..ce8b5aeae2b 100644 --- a/packages/tools/api-diff/README.md +++ b/packages/tools/api-diff/README.md @@ -11,14 +11,14 @@ Run a complete comparison from the repository root: pnpm api-diff \ --base-ref v3 \ --head-ref origin/main \ - --mapping migration/v3-to-v4.json \ --output tmp/api-diff/run ``` Both refs are required and are resolved to commit SHAs before work starts. The tool builds detached disposable worktrees with each branch's native build, -extracts both snapshots with one pinned TypeScript compiler API, and caches -successful snapshots by commit, compiler, and comparison scope. +discovers every public package entrypoint independently, extracts both snapshots +with one pinned TypeScript compiler API, and caches successful snapshots by +commit and compiler. The command writes: @@ -30,11 +30,3 @@ The command writes: Unmatched APIs are reported as removals from the base and additions in the head. Likely replacements are reported separately for review, including cross-module moves and the paired type/value facets of class-style APIs. - -Regenerate the human-readable migration map after changing its JSON source: - -```sh -pnpm api-diff \ - --mapping migration/v3-to-v4.json \ - --write-mapping-doc migration/v3-to-v4.md -``` diff --git a/packages/tools/api-diff/src/ApiDiff.ts b/packages/tools/api-diff/src/ApiDiff.ts index 1f04056b388..afd673d2818 100644 --- a/packages/tools/api-diff/src/ApiDiff.ts +++ b/packages/tools/api-diff/src/ApiDiff.ts @@ -8,25 +8,15 @@ import * as Path from "effect/Path" import { diffSnapshots } from "./Diff.ts" import { ApiDiffError } from "./Error.ts" import { prettyJson } from "./Json.ts" -import { decodeMigrationMap, mappingModules, renderMigrationMarkdown, validateMigrationMap } from "./Mapping.ts" -import type { MappingDiagnostic, MigrationMap } from "./Model.ts" import { renderMarkdownReport } from "./Report.ts" import { Worktrees } from "./Worktrees.ts" export interface ApiDiffOptions { readonly baseRef: Option.Option readonly headRef: Option.Option - readonly mapping: string readonly output: Option.Option - readonly writeMappingDoc: Option.Option } -const diagnosticMessage = (diagnostics: ReadonlyArray): string => - diagnostics - .filter((diagnostic) => diagnostic.severity === "error") - .map((diagnostic) => `${diagnostic.code}: ${diagnostic.message}`) - .join("\n") - export class ApiDiff extends Context.Service Effect.Effect }>()("@effect/api-diff/ApiDiff") { @@ -59,57 +49,17 @@ export class ApiDiff extends Context.Service() - const guides = new Set(mapping.apis.flatMap((entry) => entry.guide === undefined ? [] : [entry.guide])) - for (const guide of guides) { - const location = absolute(repoRoot, guide) - if (yield* fs.exists(location)) { - sources.set(guide, yield* fs.readFileString(location)) - } - } - return sources - }) - - const failOnDiagnostics = (diagnostics: ReadonlyArray): Effect.Effect => { - const message = diagnosticMessage(diagnostics) - return message.length === 0 ? Effect.void : new ApiDiffError({ message }) - } - const runInternal = Effect.fnUntraced(function*(options: ApiDiffOptions) { const repoRoot = yield* findRepoRoot() - const mappingLocation = absolute(repoRoot, options.mapping) - const mappingSource = yield* fs.readFileString(mappingLocation) - const mapping = yield* decodeMigrationMap(mappingSource).pipe( - Effect.mapError((cause) => - new ApiDiffError({ - message: `Invalid migration map ${mappingLocation}`, - cause - }) - ) - ) - const guideSources = yield* readGuideSources(mapping, repoRoot) - const staticDiagnostics = validateMigrationMap(mapping, { guideSources }) - - if (Option.isSome(options.writeMappingDoc)) { - yield* failOnDiagnostics(staticDiagnostics) - const output = absolute(repoRoot, options.writeMappingDoc.value) - yield* fs.makeDirectory(path.dirname(output), { recursive: true }) - yield* fs.writeFileString(output, renderMigrationMarkdown(mapping)) - yield* Console.log(`Generated ${path.relative(repoRoot, output)}`) - return - } if (Option.isNone(options.baseRef) || Option.isNone(options.headRef) || Option.isNone(options.output)) { return yield* new ApiDiffError({ message: "--base-ref, --head-ref, and --output are required for comparison" }) } - yield* failOnDiagnostics(staticDiagnostics) const baseSha = yield* worktrees.resolveRef(repoRoot, options.baseRef.value) const headSha = yield* worktrees.resolveRef(repoRoot, options.headRef.value) - const modules = mappingModules(mapping) const toolRoot = path.join(repoRoot, "tmp", "api-diff") const cacheRoot = path.join(toolRoot, "cache") const worktreesRoot = path.join(toolRoot, "worktrees") @@ -122,8 +72,7 @@ export class ApiDiff extends Context.Service } -const targetEntities = ( - snapshot: ApiSnapshot, - target: { - readonly module: string - readonly path: ReadonlyArray - readonly bucket?: string | undefined - } -): ReadonlyArray => - snapshot.entities.filter((entity) => - entity.module === target.module && - entity.path.join(".") === target.path.join(".") && - (target.bucket === undefined || entity.bucket === target.bucket) - ) - const pathName = (entity: ApiEntity): string => entity.path.at(-1) ?? "" const levenshtein = (left: string, right: string): number => { @@ -135,8 +109,7 @@ const groupName = (group: ApiGroup): string => group.path.at(-1) ?? "" const groupSimilarity = ( base: ApiGroup, - head: ApiGroup, - mappedModules: ReadonlySet + head: ApiGroup ): number => { const facets = base.entities.flatMap((baseEntity) => { const headEntity = head.entities.find((candidate) => candidate.bucket === baseEntity.bucket) @@ -147,6 +120,8 @@ const groupSimilarity = ( } const sameKind = facets.filter(({ base, head }) => base.declarationKind === head.declarationKind).length / facets.length + const sameFingerprint = facets.filter(({ base, head }) => base.fingerprint === head.fingerprint).length / + facets.length const structure = facets.reduce( (total, { base, head }) => total + setSimilarity(entityFeatures(base), entityFeatures(head)), 0 @@ -162,7 +137,7 @@ const groupSimilarity = ( const headModuleName = head.module.split("/").at(-1)?.toLowerCase() const mentionsHeadModule = headModuleName !== undefined && facets.some(({ base }) => documentationTokens(base).has(headModuleName)) - const moduleScore = base.module === head.module ? 0.25 : mappedModules.has(head.module) ? 0.15 : 0 + const moduleScore = base.module === head.module ? 0.25 : 0 const packageScore = base.entities[0]!.packageName === head.entities[0]!.packageName ? 0.05 : 0 return Math.min( 0.99, @@ -170,6 +145,7 @@ const groupSimilarity = ( (groupName(base) === groupName(head) ? 0.15 : 0) + moduleScore + packageScore + + 0.4 * sameFingerprint + 0.15 * sameKind + 0.15 * structure + 0.025 * documentation + @@ -201,8 +177,6 @@ const makeChange = ( delta, baseSource: match.base?.source, headSource: match.head?.source, - mapping: match.mapping, - guide: match.mapping?.guide, reviewNotes: match.note, authoritative: match.authoritative ?? true }) @@ -369,11 +343,7 @@ const movementChange = (match: Match): ApiChange | undefined => { return undefined } -const moduleChanges = ( - base: ApiSnapshot, - head: ApiSnapshot, - mapping: MigrationMap -): ReadonlyArray => { +const moduleChanges = (base: ApiSnapshot, head: ApiSnapshot): ReadonlyArray => { const changes: Array = [] const basePackages = new Set(base.packages) const headPackages = new Set(head.packages) @@ -399,26 +369,26 @@ const moduleChanges = ( }) } } - for (const entry of mapping.modules) { - const classification = entry.status === "added" - ? "module-added" - : entry.status === "removed" - ? "module-removed" - : entry.status === "split" - ? "module-split" - : entry.status === "consolidated" - ? "module-consolidated" - : entry.status === "moved" - ? "module-moved" - : undefined - if (classification !== undefined) { + const baseModules = new Set(base.entrypoints.map((entrypoint) => entrypoint.module)) + const headModules = new Set(head.entrypoints.map((entrypoint) => entrypoint.module)) + for (const module of baseModules) { + if (!headModules.has(module)) { + changes.push({ + id: `change-${fingerprint(["module-removed", module]).slice(0, 16)}`, + classification: "module-removed", + confidence: 1, + delta: { from: module, to: [] }, + authoritative: true + }) + } + } + for (const module of headModules) { + if (!baseModules.has(module)) { changes.push({ - id: `change-${fingerprint([classification, entry.from, entry.to]).slice(0, 16)}`, - classification, + id: `change-${fingerprint(["module-added", module]).slice(0, 16)}`, + classification: "module-added", confidence: 1, - delta: { from: entry.from, to: entry.to }, - mapping: entry, - guide: entry.guide, + delta: { to: [module] }, authoritative: true }) } @@ -426,12 +396,7 @@ const moduleChanges = ( return changes } -export const diffSnapshots = ( - base: ApiSnapshot, - head: ApiSnapshot, - mapping: MigrationMap, - mappingDiagnostics: ReadonlyArray -): ApiDiff => { +export const diffSnapshots = (base: ApiSnapshot, head: ApiSnapshot): ApiDiff => { const unmatchedBase = new Map(base.entities.map((entity) => [entity.id, entity])) const unmatchedHead = new Map(head.entities.map((entity) => [entity.id, entity])) const matches: Array = [] @@ -454,92 +419,27 @@ export const diffSnapshots = ( return true } - for (const moduleMapping of mapping.modules) { - if (moduleMapping.from === undefined) { - continue - } - const bases = base.entities.filter((entity) => entity.module === moduleMapping.from) - for (const baseEntity of bases) { - for (const targetModule of moduleMapping.to) { - const headEntity = head.entities.find((entity) => - entity.module === targetModule && - entity.path.join(".") === baseEntity.path.join(".") && - entity.bucket === baseEntity.bucket - ) - if ( - addMatch(baseEntity, headEntity, { - confidence: 1, - authoritative: true, - mapping: moduleMapping - }) - ) { - break - } - } - } - } - - for (const apiMapping of mapping.apis) { - if (apiMapping.to === null) { - continue - } - const bases = targetEntities(base, apiMapping.from) - const heads = targetEntities(head, apiMapping.to) - for (const baseEntity of bases) { - const headEntity = heads.find((candidate) => - apiMapping.from.bucket !== undefined || candidate.bucket === baseEntity.bucket - ) - addMatch(baseEntity, headEntity, { - confidence: 1, - authoritative: true, - mapping: apiMapping - }) - } - } - - for (const moduleMapping of mapping.modules) { - if (moduleMapping.from === undefined) { - continue - } - const bases = [...unmatchedBase.values()].filter((entity) => entity.module === moduleMapping.from) - const heads = [...unmatchedHead.values()].filter((entity) => moduleMapping.to.includes(entity.module)) - for (const baseEntity of bases) { - const candidates = heads.filter((entity) => - entity.bucket === baseEntity.bucket && entity.fingerprint === baseEntity.fingerprint && - unmatchedHead.has(entity.id) - ) - if (candidates.length === 1) { - addMatch(baseEntity, candidates[0], { - confidence: 0.98, - authoritative: true, - mapping: moduleMapping - }) - } - } + for (const baseEntity of unmatchedBase.values()) { + addMatch(baseEntity, unmatchedHead.get(baseEntity.id), { + confidence: 1, + authoritative: true + }) } - const explicitlyRemoved = new Set( - mapping.apis - .filter((entry) => entry.to === null) - .flatMap((entry) => targetEntities(base, entry.from).map((entity) => entity.id)) - ) const headGroups = groupEntities(unmatchedHead.values()) for (const baseGroup of groupEntities(unmatchedBase.values())) { - if (baseGroup.entities.every((entity) => explicitlyRemoved.has(entity.id))) { - continue - } - const mappedModules = new Set( - mapping.modules - .filter((entry) => entry.from === baseGroup.module) - .flatMap((entry) => entry.to) - ) const ranked = headGroups .filter((group) => groupName(group) === groupName(baseGroup) || group.module === baseGroup.module || - mappedModules.has(group.module) + baseGroup.entities.some((baseEntity) => + group.entities.some((headEntity) => + baseEntity.bucket === headEntity.bucket && + baseEntity.fingerprint === headEntity.fingerprint + ) + ) ) - .map((group) => ({ group, score: groupSimilarity(baseGroup, group, mappedModules) })) + .map((group) => ({ group, score: groupSimilarity(baseGroup, group) })) .filter(({ score }) => score > 0) .sort((left, right) => right.score - left.score || @@ -553,7 +453,7 @@ export const diffSnapshots = ( } for (const baseEntity of baseGroup.entities) { const headEntity = best.group.entities.find((candidate) => candidate.bucket === baseEntity.bucket) - if (headEntity !== undefined && !explicitlyRemoved.has(baseEntity.id)) { + if (headEntity !== undefined) { suggestedMatches.push({ base: baseEntity, head: headEntity, @@ -565,7 +465,7 @@ export const diffSnapshots = ( } } - const changes = [...moduleChanges(base, head, mapping)] + const changes = [...moduleChanges(base, head)] for (const match of matches) { const movement = movementChange(match) if (movement !== undefined) { @@ -598,8 +498,6 @@ export const diffSnapshots = ( version: 1, base: { ref: base.ref, sha: base.sha }, head: { ref: head.ref, sha: head.sha }, - mappingVersion: mapping.version, - mappingDiagnostics, changes: changes.sort((left, right) => left.classification.localeCompare(right.classification) || (left.baseApiId ?? "").localeCompare(right.baseApiId ?? "") || diff --git a/packages/tools/api-diff/src/Discovery.ts b/packages/tools/api-diff/src/Discovery.ts index 1811eb6a7d0..0ceeb759f1f 100644 --- a/packages/tools/api-diff/src/Discovery.ts +++ b/packages/tools/api-diff/src/Discovery.ts @@ -23,7 +23,6 @@ interface PackageInfo { readonly name: string readonly root: string readonly manifest: PackageManifest - readonly declarationRoot: string readonly exports: unknown } @@ -79,7 +78,7 @@ const moduleParts = (module: string, packages: ReadonlyArray): { export class Discovery extends Context.Service + requestedModules?: ReadonlyArray ) => Effect.Effect }>()("@effect/api-diff/Discovery") { static readonly layer = Layer.effect( @@ -138,7 +137,6 @@ export class Discovery extends Context.Service right.name.length - left.name.length) }) - const targetToDeclaration = (packageInfo: PackageInfo, target: string): string => { + const targetToDeclaration = (packageInfo: PackageInfo, target: string): string | undefined => { const relativeTarget = target.replace(/^\.\//, "") const declaration = relativeTarget.endsWith(".d.ts") ? relativeTarget : relativeTarget.replace(/\.(?:mjs|cjs|js|mts|cts|ts)$/, ".d.ts") - return path.resolve(packageInfo.root, declaration) + return declaration.endsWith(".d.ts") ? path.resolve(packageInfo.root, declaration) : undefined } const expandPattern = Effect.fnUntraced(function*( packageInfo: PackageInfo, keyPattern: string, targetPattern: string, - requested: ReadonlySet + requested: ReadonlySet | undefined, + exportsMap: Readonly> ) { - const candidates = yield* walk(packageInfo.declarationRoot, (location) => location.endsWith(".d.ts")) + const candidates = yield* walk(packageInfo.root, (location) => location.endsWith(".d.ts")) const targetNormalized = targetPattern.endsWith(".d.ts") ? targetPattern : targetPattern.replace(/\.(?:mjs|cjs|js|mts|cts|ts)$/, ".d.ts") @@ -173,7 +172,10 @@ export class Discovery extends Context.Service + excludedTarget === null && matchPattern(excludedKey, key) !== undefined + ) + if (!excluded && (requested === undefined || requested.has(module))) { entries.push({ packageName: packageInfo.name, module, declarationFile }) } } @@ -182,54 +184,60 @@ export class Discovery extends Context.Service + requestedModules?: ReadonlyArray ) { const packages = yield* packagesIn(repoRoot) - const requested = new Set(requestedModules) + const requested = requestedModules === undefined ? undefined : new Set(requestedModules) const output = new Map() - for (const module of requested) { - const parts = moduleParts(module, packages) - if ( - parts === undefined || - typeof parts.packageInfo.exports !== "object" || - parts.packageInfo.exports === null - ) { - continue - } - const exportsMap = parts.packageInfo.exports as Record - const exact = Object.prototype.hasOwnProperty.call(exportsMap, parts.key) - ? exportedTarget(exportsMap[parts.key]) - : undefined - if (typeof exact === "string") { - const declarationFile = targetToDeclaration(parts.packageInfo, exact) - if ((yield* fs.exists(declarationFile)) && (yield* fs.stat(declarationFile)).type === "File") { - output.set(module, { packageName: parts.packageInfo.name, module, declarationFile }) - } - continue - } - if (exact === null) { - continue - } - for (const [keyPattern, rawTarget] of Object.entries(exportsMap)) { - if (!keyPattern.includes("*")) { + if (requested !== undefined) { + for (const module of requested) { + const parts = moduleParts(module, packages) + if ( + parts === undefined || + typeof parts.packageInfo.exports !== "object" || + parts.packageInfo.exports === null + ) { continue } - const capture = matchPattern(keyPattern, parts.key) - const target = exportedTarget(rawTarget) - if (capture === undefined || typeof target !== "string") { + const exportsMap = parts.packageInfo.exports as Record + const exact = Object.prototype.hasOwnProperty.call(exportsMap, parts.key) + ? exportedTarget(exportsMap[parts.key]) + : undefined + if (typeof exact === "string") { + const declarationFile = targetToDeclaration(parts.packageInfo, exact) + if ( + declarationFile !== undefined && + (yield* fs.exists(declarationFile)) && + (yield* fs.stat(declarationFile)).type === "File" + ) { + output.set(module, { packageName: parts.packageInfo.name, module, declarationFile }) + } continue } - const exclusions = Object.entries(exportsMap).some(([excludedKey, excludedTarget]) => - excludedTarget === null && matchPattern(excludedKey, parts.key) !== undefined - ) - if (exclusions) { + if (exact === null) { continue } - if (target.includes("*")) { - const declarationFile = targetToDeclaration(parts.packageInfo, target.replace("*", capture)) - if (yield* fs.exists(declarationFile)) { - output.set(module, { packageName: parts.packageInfo.name, module, declarationFile }) + for (const [keyPattern, rawTarget] of Object.entries(exportsMap)) { + if (!keyPattern.includes("*")) { + continue + } + const capture = matchPattern(keyPattern, parts.key) + const target = exportedTarget(rawTarget) + if (capture === undefined || typeof target !== "string") { + continue + } + const exclusions = Object.entries(exportsMap).some(([excludedKey, excludedTarget]) => + excludedTarget === null && matchPattern(excludedKey, parts.key) !== undefined + ) + if (exclusions) { + continue + } + if (target.includes("*")) { + const declarationFile = targetToDeclaration(parts.packageInfo, target.replace("*", capture)) + if (declarationFile !== undefined && (yield* fs.exists(declarationFile))) { + output.set(module, { packageName: parts.packageInfo.name, module, declarationFile }) + } } } } @@ -239,10 +247,29 @@ export class Discovery extends Context.Service)) { + const exportsMap = packageInfo.exports as Record + for (const [keyPattern, rawTarget] of Object.entries(exportsMap)) { const target = exportedTarget(rawTarget) + if ( + !keyPattern.includes("*") && + typeof target === "string" && + (requested === undefined || requested.has( + keyPattern === "." ? packageInfo.name : `${packageInfo.name}${keyPattern.slice(1)}` + )) + ) { + const module = keyPattern === "." ? packageInfo.name : `${packageInfo.name}${keyPattern.slice(1)}` + const declarationFile = targetToDeclaration(packageInfo, target) + if ( + declarationFile !== undefined && + !output.has(module) && + (yield* fs.exists(declarationFile)) && + (yield* fs.stat(declarationFile)).type === "File" + ) { + output.set(module, { packageName: packageInfo.name, module, declarationFile }) + } + } if (keyPattern.includes("*") && typeof target === "string" && target.includes("*")) { - for (const entry of yield* expandPattern(packageInfo, keyPattern, target, requested)) { + for (const entry of yield* expandPattern(packageInfo, keyPattern, target, requested, exportsMap)) { if (!output.has(entry.module)) { output.set(entry.module, entry) } @@ -253,13 +280,13 @@ export class Discovery extends Context.Service left.module.localeCompare(right.module)), - missing: requestedModules.filter((module) => !output.has(module)).sort() + missing: requestedModules?.filter((module) => !output.has(module)).sort() ?? [] } }) const discoverEntrypoints = ( repoRoot: string, - requestedModules: ReadonlyArray + requestedModules?: ReadonlyArray ): Effect.Effect => discoverEntrypointsInternal(repoRoot, requestedModules).pipe( Effect.mapError((cause) => diff --git a/packages/tools/api-diff/src/Mapping.ts b/packages/tools/api-diff/src/Mapping.ts deleted file mode 100644 index a892b53a83f..00000000000 --- a/packages/tools/api-diff/src/Mapping.ts +++ /dev/null @@ -1,228 +0,0 @@ -import * as Schema from "effect/Schema" -import type { ApiMapping, ApiSnapshot, ApiTarget, MappingDiagnostic, ModuleMapping } from "./Model.ts" -import { MigrationMap } from "./Model.ts" - -export const decodeMigrationMap = Schema.decodeUnknownEffect(Schema.fromJsonString(MigrationMap)) - -const targetKey = (target: ApiTarget): string => `${target.module}#${target.path.join(".")}#${target.bucket ?? "*"}` - -const entityMatchesTarget = (snapshot: ApiSnapshot, target: ApiTarget): number => - snapshot.entities.filter((entity) => - entity.module === target.module && - entity.path.join(".") === target.path.join(".") && - (target.bucket === undefined || entity.bucket === target.bucket) - ).length - -const linkedGuideMappings = ( - guideSources: ReadonlyMap, - mapping: ApiMapping -): ReadonlyArray => { - if (mapping.guide === undefined || !guideSources.has(mapping.guide)) { - return [] - } - const source = guideSources.get(mapping.guide)! - const fromName = mapping.from.path.at(-1) - if (fromName === undefined) { - return [] - } - const results: Array = [] - const escaped = fromName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") - for (const match of source.matchAll(new RegExp(`\\b${escaped}\\b[^\\n|→]*?(?:→|\\|)\\s*\`?([A-Za-z][\\w.]*)`, "g"))) { - if (match[1] !== undefined) { - results.push(match[1]) - } - } - return results -} - -export interface ValidateMappingOptions { - readonly base?: ApiSnapshot - readonly head?: ApiSnapshot - readonly guideSources?: ReadonlyMap -} - -export const validateMigrationMap = ( - mapping: MigrationMap, - options: ValidateMappingOptions = {} -): ReadonlyArray => { - const diagnostics: Array = [] - const moduleSources = new Map() - for (const entry of mapping.modules) { - if (entry.from !== undefined) { - const previous = moduleSources.get(entry.from) - if (previous !== undefined) { - diagnostics.push({ - severity: "error", - code: "duplicate-module-source", - message: `Duplicate module source ${entry.from}` - }) - } else { - moduleSources.set(entry.from, entry) - } - } - if (entry.status === "removed" && entry.to.length !== 0) { - diagnostics.push({ - severity: "error", - code: "contradictory-module-status", - message: `Removed module ${entry.from ?? ""} has targets` - }) - } - if (entry.status !== "removed" && entry.status !== "added" && entry.to.length === 0) { - diagnostics.push({ - severity: "error", - code: "missing-module-target", - message: `Module ${entry.from ?? ""} has no target` - }) - } - } - - const apiSources = new Set() - for (const entry of mapping.apis) { - const key = targetKey(entry.from) - if (apiSources.has(key)) { - diagnostics.push({ - severity: "error", - code: "duplicate-api-source", - message: `Duplicate API source ${key}` - }) - } - apiSources.add(key) - if (options.base !== undefined) { - const count = entityMatchesTarget(options.base, entry.from) - if (count === 0) { - diagnostics.push({ - severity: "error", - code: "missing-api-source", - message: `Mapped API source does not resolve: ${key}` - }) - } else if (count > 1 && entry.from.bucket === undefined) { - diagnostics.push({ - severity: "error", - code: "ambiguous-api-source", - message: `Mapped API source resolves to ${count} facets; specify a bucket: ${key}` - }) - } - } - if (options.head !== undefined && entry.to !== null) { - const count = entityMatchesTarget(options.head, entry.to) - if (count === 0) { - diagnostics.push({ - severity: "error", - code: "missing-api-target", - message: `Mapped API target does not resolve: ${targetKey(entry.to)}` - }) - } else if (count > 1 && entry.to.bucket === undefined) { - diagnostics.push({ - severity: "error", - code: "ambiguous-api-target", - message: `Mapped API target resolves to ${count} facets; specify a bucket: ${targetKey(entry.to)}` - }) - } - } - if (options.guideSources !== undefined && entry.to !== null) { - const documented = linkedGuideMappings(options.guideSources, entry) - const expected = entry.to.path.join(".") - for (const actual of documented) { - if (actual !== expected && !actual.endsWith(`.${expected}`)) { - diagnostics.push({ - severity: "error", - code: "contradictory-guide", - message: `${entry.guide} maps ${entry.from.path.join(".")} to ${actual}, not ${expected}` - }) - } - } - } - } - return diagnostics.sort((left, right) => - left.code.localeCompare(right.code) || left.message.localeCompare(right.message) - ) -} - -export const mappingModules = ( - mapping: MigrationMap -): { readonly base: ReadonlyArray; readonly head: ReadonlyArray } => { - const packageName = (module: string): string => { - const parts = module.split("/") - return module.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0]! - } - const sources = [ - ...mapping.modules.flatMap((entry) => entry.from === undefined ? [] : [entry.from]), - ...mapping.apis.map((entry) => entry.from.module) - ] - const targets = [ - ...mapping.modules.flatMap((entry) => [...entry.to, ...(entry.barrels ?? [])]), - ...mapping.apis.flatMap((entry) => entry.to === null ? [] : [entry.to.module]) - ] - return { - base: [...new Set([...sources, ...sources.map(packageName)])].sort(), - head: [...new Set(targets)].sort() - } -} - -const renderApiTarget = (target: ApiTarget, source: boolean): string => { - const moduleName = target.module.split("/").at(-1) ?? target.module - if (target.path[0] === moduleName && (target.path.length > 1 || source)) { - return target.path.join(".") - } - return [moduleName, ...target.path].join(".") -} - -export const renderMigrationMarkdown = (mapping: MigrationMap): string => { - const mapped = mapping.modules.filter((entry) => entry.from !== undefined) - const added = mapping.modules.filter((entry) => entry.from === undefined) - const apiRenames = [ - ...new Set(mapping.apis.map((entry) => { - const from = renderApiTarget(entry.from, true) - const to = entry.to === null ? "Removed" : renderApiTarget(entry.to, false) - return `${from} -> ${to}` - })) - ] - const lines = [ - "# v3 to v4 Import and API Rename Maps", - "", - `Mapped modules: ${mapped.reduce((total, entry) => total + entry.to.length, 0)}`, - `No counterpart: ${added.length}`, - `API renames: ${apiRenames.length}`, - "", - "This file is generated from `migration/v3-to-v4.json`. Do not edit it directly.", - "", - "## Import Map", - "", - "Each line is `v3 import -> v4 direct module import`. Suggested barrels are shown in parentheses.", - "", - "```text", - ...mapped.map((entry) => { - const target = entry.to.join(", ") - const barrel = entry.barrels?.length === undefined || entry.barrels.length === 0 - ? "" - : ` (barrel: ${entry.barrels.join(", ")})` - return `${entry.from} -> ${target}${barrel}` - }), - "```", - "", - "## No Counterpart Imports", - "", - "These v4 modules did not have a mapped v3 module.", - "", - "```text", - ...added.flatMap((entry) => - entry.to.map((target) => { - const barrel = entry.barrels?.length === undefined || entry.barrels.length === 0 - ? "" - : ` (barrel: ${entry.barrels.join(", ")})` - return `${target}${barrel}` - }) - ), - "```", - "", - "## API Renames", - "", - "Each line is `v3 API -> v4 API`, using the namespaced spelling found in application code.", - "", - "```text", - ...apiRenames, - "```", - "" - ] - return lines.join("\n") -} diff --git a/packages/tools/api-diff/src/Model.ts b/packages/tools/api-diff/src/Model.ts index 8bb841b23c7..bb71385edfc 100644 --- a/packages/tools/api-diff/src/Model.ts +++ b/packages/tools/api-diff/src/Model.ts @@ -104,52 +104,6 @@ export interface ApiSnapshot { readonly diagnostics: ReadonlyArray } -export const ModuleStatus = Schema.Literals(["added", "moved", "removed", "split", "consolidated", "unchanged"]) - -export type ModuleStatus = typeof ModuleStatus.Type - -export const ModuleMapping = Schema.Struct({ - from: Schema.optional(Schema.String), - to: Schema.Array(Schema.String), - status: ModuleStatus, - barrels: Schema.optional(Schema.Array(Schema.String)), - guide: Schema.optional(Schema.String), - note: Schema.optional(Schema.String) -}) - -export type ModuleMapping = typeof ModuleMapping.Type - -export const ApiTarget = Schema.Struct({ - module: Schema.String, - path: Schema.Array(Schema.String), - bucket: Schema.optional(Bucket) -}) - -export type ApiTarget = typeof ApiTarget.Type - -export const ApiMapping = Schema.Struct({ - from: ApiTarget, - to: Schema.NullOr(ApiTarget), - guide: Schema.optional(Schema.String), - note: Schema.optional(Schema.String) -}) - -export type ApiMapping = typeof ApiMapping.Type - -export const MigrationMap = Schema.Struct({ - version: Schema.Literal(1), - modules: Schema.Array(ModuleMapping), - apis: Schema.Array(ApiMapping) -}) - -export type MigrationMap = typeof MigrationMap.Type - -export interface MappingDiagnostic { - readonly severity: "error" | "warning" - readonly code: string - readonly message: string -} - export type ChangeClassification = | "package-added" | "package-removed" @@ -194,8 +148,6 @@ export interface ApiChange { readonly delta?: unknown readonly baseSource?: SourceLocation | undefined readonly headSource?: SourceLocation | undefined - readonly mapping?: ApiMapping | ModuleMapping | undefined - readonly guide?: string | undefined readonly reviewNotes?: string | undefined readonly authoritative: boolean } @@ -204,7 +156,5 @@ export interface ApiDiff { readonly version: 1 readonly base: { readonly ref: string; readonly sha: string } readonly head: { readonly ref: string; readonly sha: string } - readonly mappingVersion: number - readonly mappingDiagnostics: ReadonlyArray readonly changes: ReadonlyArray } diff --git a/packages/tools/api-diff/src/Report.ts b/packages/tools/api-diff/src/Report.ts index a5972838504..06efd131a89 100644 --- a/packages/tools/api-diff/src/Report.ts +++ b/packages/tools/api-diff/src/Report.ts @@ -16,7 +16,6 @@ const renderChange = (change: ApiChange, level = 4): ReadonlyArray => [ `${"#".repeat(level)} ${change.classification}: \`${changeLabel(change)}\``, "", `Confidence: ${change.confidence.toFixed(3)} · ${change.authoritative ? "authoritative" : "review required"}`, - ...(change.guide === undefined ? [] : ["", `Migration guide: [${change.guide}](${change.guide})`]), ...(change.reviewNotes === undefined ? [] : ["", change.reviewNotes]), ...signature("Before", change.before), ...signature("After", change.after), @@ -64,12 +63,7 @@ export const renderMarkdownReport = (diff: ApiDiff): string => { const moduleCounts = new Map() for (const change of diff.changes) { const id = change.headApiId ?? change.baseApiId - const mappedModule = change.mapping === undefined - ? undefined - : "status" in change.mapping - ? change.mapping.from ?? change.mapping.to[0] - : change.mapping.from.module - const module = id?.split("#")[0] ?? mappedModule ?? "" + const module = id?.split("#")[0] ?? "" moduleCounts.set(module, (moduleCounts.get(module) ?? 0) + 1) } const lines: Array = [ @@ -101,22 +95,8 @@ export const renderMarkdownReport = (diff: ApiDiff): string => { : `unstable/${unstable[1]}` return `| ${escapeCell(domain)} | ${escapeCell(module)} | ${count} |` }), - "", - "## Mapping validation", "" ] - if (diff.mappingDiagnostics.length === 0) { - lines.push("No mapping diagnostics.", "") - } else { - lines.push( - "| Severity | Code | Message |", - "| --- | --- | --- |", - ...diff.mappingDiagnostics.map((diagnostic) => - `| ${diagnostic.severity} | ${diagnostic.code} | ${escapeCell(diagnostic.message)} |` - ), - "" - ) - } const sections = [ [ "Stable API changes", diff --git a/packages/tools/api-diff/src/Snapshot.ts b/packages/tools/api-diff/src/Snapshot.ts index ec11e8a0875..bf303ca6d6a 100644 --- a/packages/tools/api-diff/src/Snapshot.ts +++ b/packages/tools/api-diff/src/Snapshot.ts @@ -698,7 +698,7 @@ export interface ExtractSnapshotOptions { readonly repoRoot: string readonly ref: string readonly sha: string - readonly modules: ReadonlyArray + readonly modules?: ReadonlyArray } export class SnapshotExtractionError extends Schema.TaggedErrorClass()( @@ -841,8 +841,8 @@ const extractSnapshot = ( export const snapshotCacheKey = ( sha: string, - modules: ReadonlyArray -): string => fingerprint(["snapshot-v3", sha, ts.version, [...modules].sort()]) + modules?: ReadonlyArray +): string => fingerprint(["snapshot-v4", sha, ts.version, modules === undefined ? "all" : [...modules].sort()]) export class Snapshotter extends Context.Service Effect.Effect< diff --git a/packages/tools/api-diff/src/Worktrees.ts b/packages/tools/api-diff/src/Worktrees.ts index fe3dd827474..5d22bf6b7f9 100644 --- a/packages/tools/api-diff/src/Worktrees.ts +++ b/packages/tools/api-diff/src/Worktrees.ts @@ -19,7 +19,7 @@ export interface PrepareSnapshotOptions { readonly name: "base" | "head" readonly ref: string readonly sha: string - readonly modules: ReadonlyArray + readonly modules?: ReadonlyArray } export class Worktrees extends Context.Service cause instanceof ApiDiffError diff --git a/packages/tools/api-diff/test/Cli.test.ts b/packages/tools/api-diff/test/Cli.test.ts index 8cb99e2f595..e04141841f7 100644 --- a/packages/tools/api-diff/test/Cli.test.ts +++ b/packages/tools/api-diff/test/Cli.test.ts @@ -1,6 +1,7 @@ import { cli } from "@effect/api-diff" import { ApiDiff } from "@effect/api-diff/ApiDiff" -import { renderMigrationMarkdown } from "@effect/api-diff/Mapping" +import type { ApiSnapshot } from "@effect/api-diff/Model" +import { Worktrees } from "@effect/api-diff/Worktrees" import * as NodeServices from "@effect/platform-node/NodeServices" import { assert, it } from "@effect/vitest" import * as Effect from "effect/Effect" @@ -9,26 +10,47 @@ import * as Layer from "effect/Layer" import * as Path from "effect/Path" import * as Command from "effect/unstable/cli/Command" -const MainLayer = ApiDiff.layer.pipe( +const snapshot = (ref: string, sha: string): ApiSnapshot => ({ + version: 1, + compiler: { name: "typescript", version: "fixture" }, + ref, + sha, + packages: [], + entrypoints: [], + entities: [], + diagnostics: [] +}) + +const WorktreesTest = Layer.succeed( + Worktrees, + Worktrees.of({ + resolveRef: (_repoRoot, ref) => Effect.succeed(ref.repeat(40).slice(0, 40)), + prepareSnapshot: (options) => Effect.succeed(snapshot(options.ref, options.sha)) + }) +) + +const MainLayer = ApiDiff.layerNoDependencies.pipe( + Layer.provide(WorktreesTest), Layer.provideMerge(NodeServices.layer) ) -it.effect("generates mapping documentation through the Cli command", () => +it.effect("compares refs without a migration map", () => Effect.gen(function*() { const fs = yield* FileSystem.FileSystem const path = yield* Path.Path const root = yield* fs.makeTempDirectoryScoped({ prefix: "api-diff-cli-" }) - const mappingPath = path.join(root, "mapping.json") - const outputPath = path.join(root, "mapping.md") - const mapping = { version: 1 as const, modules: [], apis: [] } - yield* fs.writeFileString(mappingPath, JSON.stringify(mapping)) + const output = path.join(root, "output") yield* Command.runWith(cli, { version: "0.0.0" })([ - "--mapping", - mappingPath, - "--write-mapping-doc", - outputPath + "--base-ref", + "a", + "--head-ref", + "b", + "--output", + output ]) - assert.strictEqual(yield* fs.readFileString(outputPath), renderMigrationMarkdown(mapping)) + const diff = JSON.parse(yield* fs.readFileString(path.join(output, "diff.json"))) + assert.deepStrictEqual(diff.base, { ref: "a", sha: "a".repeat(40) }) + assert.deepStrictEqual(diff.head, { ref: "b", sha: "b".repeat(40) }) }).pipe(Effect.provide(MainLayer))) diff --git a/packages/tools/api-diff/test/Diff.test.ts b/packages/tools/api-diff/test/Diff.test.ts index 2b28f19be9b..edf9472e505 100644 --- a/packages/tools/api-diff/test/Diff.test.ts +++ b/packages/tools/api-diff/test/Diff.test.ts @@ -1,5 +1,5 @@ import { diffSnapshots } from "@effect/api-diff/Diff" -import type { ApiEntity, ApiSnapshot, DeclarationModel, MigrationMap } from "@effect/api-diff/Model" +import type { ApiEntity, ApiSnapshot, DeclarationModel } from "@effect/api-diff/Model" import { renderMarkdownReport } from "@effect/api-diff/Report" import { assert, describe, it } from "@effect/vitest" @@ -37,12 +37,12 @@ const snapshot = (ref: string, entities: ReadonlyArray): ApiSnapshot describe("snapshot diff", () => { it("matches renames, classifies signature changes, and separates suggestions", () => { const base = snapshot("a", [ - entity("old/A", "renamed", { + entity("old/A", "changed", { kind: "function", - name: "renamed", + name: "changed", parameters: [{ name: "value", type: { kind: "primitive", name: "string" }, optional: false, rest: false }], returnType: { kind: "primitive", name: "string" } - }, "same"), + }, "before"), entity("old/A", "similarName", { kind: "variable", name: "similarName", @@ -56,32 +56,23 @@ describe("snapshot diff", () => { ) ]) const head = snapshot("b", [ - entity("new/A", "replacement", { + entity("old/A", "changed", { kind: "function", - name: "replacement", + name: "changed", parameters: [ { name: "value", type: { kind: "primitive", name: "string" }, optional: false, rest: false }, { name: "count", type: { kind: "primitive", name: "number" }, optional: true, rest: false } ], returnType: { kind: "primitive", name: "number" } }, "different"), - entity("new/A", "similarNames", { + entity("old/A", "similarNames", { kind: "variable", name: "similarNames", type: { kind: "primitive", name: "number" } }, "y"), - entity("new/A", "added", { kind: "variable", name: "added", type: { kind: "primitive", name: "string" } }, "a") + entity("old/A", "added", { kind: "variable", name: "added", type: { kind: "primitive", name: "string" } }, "a") ]) - const mapping: MigrationMap = { - version: 1, - modules: [{ from: "old/A", to: ["new/A"], status: "moved" }], - apis: [{ - from: { module: "old/A", path: ["renamed"] }, - to: { module: "new/A", path: ["replacement"] } - }] - } - const diff = diffSnapshots(base, head, mapping, []) - assert(diff.changes.some((change) => change.classification === "api-renamed" && change.authoritative)) + const diff = diffSnapshots(base, head) assert(diff.changes.some((change) => change.classification === "parameter-added")) assert(diff.changes.some((change) => change.classification === "return-type-changed")) assert(diff.changes.some((change) => change.baseApiId?.includes("similarName") && !change.authoritative)) @@ -96,7 +87,7 @@ describe("snapshot diff", () => { const report = renderMarkdownReport(diff) assert(report.includes("Suggested replacements for removed APIs")) assert(report.includes(base.sha)) - assert.deepStrictEqual(diff, diffSnapshots(base, head, mapping, [])) + assert.deepStrictEqual(diff, diffSnapshots(base, head)) }) it("suggests replacements across modules and preserves class facets", () => { @@ -156,17 +147,7 @@ describe("snapshot diff", () => { const layerMapService = entity("effect/LayerMap", "Service", variable("Service"), "layer-map-service") const diff = diffSnapshots( snapshot("a", [effectService, contextTagType, contextTagValue]), - snapshot("b", [contextServiceType, contextServiceValue, layerMapService]), - { - version: 1, - modules: [ - { from: "effect/Effect", to: ["effect/Effect"], status: "unchanged" }, - { from: "effect/Context", to: ["effect/Context"], status: "unchanged" }, - { from: "effect/LayerMap", to: ["effect/LayerMap"], status: "unchanged" } - ], - apis: [] - }, - [] + snapshot("b", [contextServiceType, contextServiceValue, layerMapService]) ) const suggestions = diff.changes.filter((change) => !change.authoritative) assert(suggestions.some((change) => @@ -199,16 +180,10 @@ describe("snapshot diff", () => { returnType: { kind: "primitive", name: "string" } }) const before = entity("old/A", "ordered", signature("ordered", ["left", "right"]), "before") - const after = entity("new/A", "ordered", signature("ordered", ["right", "left"]), "after") + const after = entity("old/A", "ordered", signature("ordered", ["right", "left"]), "after") const diff = diffSnapshots( snapshot("a", [before]), - snapshot("b", [after]), - { - version: 1, - modules: [{ from: "old/A", to: ["new/A"], status: "moved" }], - apis: [] - }, - [] + snapshot("b", [after]) ) assert(diff.changes.some((change) => change.classification === "parameter-reordered")) }) diff --git a/packages/tools/api-diff/test/Discovery.test.ts b/packages/tools/api-diff/test/Discovery.test.ts index ca2683660db..0586fa66bb9 100644 --- a/packages/tools/api-diff/test/Discovery.test.ts +++ b/packages/tools/api-diff/test/Discovery.test.ts @@ -32,6 +32,14 @@ describe("entrypoint discovery", () => { "@fixture/sample/Foo" ]) assert.deepStrictEqual(result.missing, ["@fixture/sample/internal/Secret"]) + + const all = yield* discovery.discoverEntrypoints(root) + assert.deepStrictEqual(all.entrypoints.map((entrypoint) => entrypoint.module), [ + "@fixture/sample", + "@fixture/sample/Foo", + "@fixture/sample/index" + ]) + assert.deepStrictEqual(all.missing, []) }).pipe(Effect.provide(MainLayer))) it.effect("supports conditional export targets", () => diff --git a/packages/tools/api-diff/test/Mapping.test.ts b/packages/tools/api-diff/test/Mapping.test.ts deleted file mode 100644 index 4c3d4108216..00000000000 --- a/packages/tools/api-diff/test/Mapping.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { decodeMigrationMap, renderMigrationMarkdown, validateMigrationMap } from "@effect/api-diff/Mapping" -import type { MigrationMap } from "@effect/api-diff/Model" -import * as NodeServices from "@effect/platform-node/NodeServices" -import { assert, describe, it } from "@effect/vitest" -import * as Effect from "effect/Effect" -import * as FileSystem from "effect/FileSystem" -import * as Path from "effect/Path" - -const mapping: MigrationMap = { - version: 1, - modules: [ - { from: "old/A", to: ["new/A", "new/B"], status: "split", barrels: ["new"] }, - { to: ["new/C"], status: "added" } - ], - apis: [ - { - from: { module: "old/A", path: ["catchSome"] }, - to: { module: "new/A", path: ["catchFilter"] } - } - ] -} - -describe("migration mapping", () => { - it("renders one-to-many and added modules", () => { - const markdown = renderMigrationMarkdown(mapping) - assert(markdown.includes("old/A -> new/A, new/B (barrel: new)")) - assert(markdown.includes("new/C")) - assert(markdown.includes("A.catchSome -> A.catchFilter")) - }) - - it("renders APIs as grep-friendly code spellings", () => { - const markdown = renderMigrationMarkdown({ - version: 1, - modules: [], - apis: [ - { - from: { module: "effect/Effect", path: ["makeLatch"] }, - to: { module: "effect/Latch", path: ["make"] } - }, - { - from: { module: "effect/Stream", path: ["Stream", "Context"] }, - to: { module: "effect/Stream", path: ["Services"] } - }, - { - from: { module: "effect/Mailbox", path: ["Mailbox"] }, - to: { module: "effect/Queue", path: ["Queue"] } - } - ] - }) - assert(markdown.includes("Effect.makeLatch -> Latch.make")) - assert(markdown.includes("Stream.Context -> Stream.Services")) - assert(markdown.includes("Mailbox -> Queue.Queue")) - }) - - it("rejects duplicate sources and contradictory statuses", () => { - const diagnostics = validateMigrationMap({ - ...mapping, - modules: [ - ...mapping.modules, - { from: "old/A", to: [], status: "removed" }, - { from: "old/Invalid", to: ["new/Invalid"], status: "removed" } - ], - apis: [...mapping.apis, mapping.apis[0]!] - }) - assert(diagnostics.some((diagnostic) => diagnostic.code === "duplicate-module-source")) - assert(diagnostics.some((diagnostic) => diagnostic.code === "contradictory-module-status")) - assert(diagnostics.some((diagnostic) => diagnostic.code === "duplicate-api-source")) - }) - - it("detects the conflicting catchSome guide", () => { - const diagnostics = validateMigrationMap({ - ...mapping, - apis: [{ - ...mapping.apis[0]!, - guide: "guide.md" - }] - }, { - guideSources: new Map([["guide.md", "| `Effect.catchSome` | `Effect.catchIf` |\n"]]) - }) - assert(diagnostics.some((diagnostic) => diagnostic.code === "contradictory-guide")) - }) - - it.effect("keeps the repository Markdown generated from the structured map", () => - Effect.gen(function*() { - const fs = yield* FileSystem.FileSystem - const path = yield* Path.Path - const jsonPath = yield* path.fromFileUrl(new URL("../../../../migration/v3-to-v4.json", import.meta.url)) - const markdownPath = yield* path.fromFileUrl(new URL("../../../../migration/v3-to-v4.md", import.meta.url)) - const parsed = yield* decodeMigrationMap(yield* fs.readFileString(jsonPath)) - assert.strictEqual(renderMigrationMarkdown(parsed), yield* fs.readFileString(markdownPath)) - assert.strictEqual(validateMigrationMap(parsed).length, 0) - const catchSome = parsed.apis.find((entry) => - entry.from.module === "effect/Effect" && entry.from.path.join(".") === "catchSome" - ) - assert.deepStrictEqual(catchSome?.to, { - module: "effect/Effect", - path: ["catchFilter"] - }) - }).pipe(Effect.provide(NodeServices.layer))) -}) diff --git a/plan.md b/plan.md index 0fc0eaac863..eb20168e6ef 100644 --- a/plan.md +++ b/plan.md @@ -8,14 +8,13 @@ The first version will report structural API changes and review confidence. It w ## Success Criteria -- Compare all modules in the v3-to-v4 import map. +- Compare all public modules discovered in both revisions. - Consume production `.d.ts` files with internal declarations stripped. - Handle package consolidation, relocated modules, wildcard exports, barrels, and re-exports. - Detect additions, removals, moves, renames, and structural signature changes. - Represent overloads, generics, interfaces, classes, namespaces, and merged declarations. - Produce deterministic, versioned JSON and readable Markdown. - Record exact Git SHAs used for both snapshots. -- Validate every explicit module and API mapping. - Never silently omit an unsupported public declaration. ## Architecture @@ -28,7 +27,7 @@ The tool will have four logical stages: 1. Prepare branch artifacts. 2. Extract canonical API snapshots. -3. Match and diff snapshots using migration mappings. +3. Match and diff the independently discovered snapshots. 4. Generate JSON and Markdown reports. ## Command @@ -39,7 +38,6 @@ Add a root command with an interface similar to: pnpm api-diff \ --base-ref origin/v3 \ --head-ref origin/main \ - --mapping migration/v3-to-v4.json \ --output tmp/api-diff ``` @@ -85,50 +83,13 @@ Read the consumer-visible package metadata: - Exclude internal entrypoints. - Include both direct module and barrel import routes. -The initial comparison scope is every source and target module represented in the structured migration map. - -## Structured Migration Map - -Replace the Markdown-only mapping source with a versioned JSON model such as: - -```json -{ - "version": 1, - "modules": [ - { - "from": "@effect/platform/HttpClient", - "to": ["effect/unstable/http/HttpClient"], - "status": "moved" - } - ], - "apis": [ - { - "from": { - "module": "effect/Effect", - "path": ["catchAll"] - }, - "to": { - "module": "effect/Effect", - "path": ["catch"] - } - } - ] -} -``` - -The schema must support: +The comparison scope is every consumer-visible entrypoint discovered in each revision. -- One-to-one moves. -- One-to-many module splits. -- Removed modules and APIs. -- Explicit renames. -- Suggested barrel routes. -- Links to detailed migration guides. -- Notes requiring manual migration. +## Independent Discovery -Generate `migration/v3-to-v4.md` from the structured data so JSON is the source of truth. - -Validation must reject missing targets, ambiguous API names, accidental duplicate sources, and contradictory mappings. +Discover the complete public module set separately in each revision. The diff +must not depend on a pre-existing migration inventory: removed modules and APIs, +new v4 modules and APIs, and likely replacements are outputs of the comparison. ## Canonical API Snapshot @@ -212,14 +173,13 @@ Snapshot extraction should use one pinned TypeScript compiler version to parse b Apply matching in this order: -1. Exact export path under an explicitly mapped module. -2. Explicit API rename or move mapping. -3. Exact structural fingerprint within mapped modules. -4. Name and structural similarity as a suggested match. -5. Remaining base exports become removals. -6. Remaining head exports become additions. +1. Exact API identity. +2. Exact structural fingerprints across public modules. +3. Name and structural similarity as a suggested match. +4. Remaining base exports become removals. +5. Remaining head exports become additions. -Suggested matches must include a confidence score and require review. They must never silently become authoritative mappings. +Suggested matches must include a confidence score and require review. ## Diff Classification @@ -250,8 +210,6 @@ Each change record will contain: - Before and after display signatures. - Structured AST delta. - Source locations. -- Related explicit mapping. -- Related migration guide. - Review notes. Do not classify changes as semver-breaking in the first version. Use labels such as `structural-change` and `review-required`. @@ -264,13 +222,11 @@ The Markdown report should include: - Compared refs and SHAs. - Summary counts. -- Mapping validation diagnostics. - Changes grouped by domain and module. - Renames and moves first. - Removals and additions next. - Signature changes with before/after declarations. - Suggested matches in a separate review section. -- Links to existing topical migration guides. - Stable and unstable API sections. Documentation-only changes should not obscure structural API changes. @@ -312,22 +268,19 @@ Add synthetic declaration fixtures covering: - Internal export exclusions. - Union normalization. - Parameter reordering. -- One-to-many module mappings. - Unsupported syntax diagnostics. Add deterministic snapshot tests for the canonical JSON model. Add integration tests for the representative real modules without committing complete branch reports as test fixtures. -Add mapping validation tests, including the existing conflicting `Effect.catchSome` guidance. - ## Implementation Phases 1. Implement and evaluate the representative-module prototype. -2. Define the versioned snapshot and mapping schemas. +2. Define the versioned snapshot schema. 3. Implement package and entrypoint discovery. 4. Implement semantic export extraction and canonical type AST serialization. -5. Implement mapping validation and exact matching. +5. Implement exact identity matching. 6. Implement structural fingerprints and suggested matching. 7. Implement AST diff classification. 8. Implement JSON and Markdown reporters. @@ -346,18 +299,16 @@ pnpm check pnpm api-diff \ --base-ref origin/v3 \ --head-ref origin/main \ - --mapping migration/v3-to-v4.json \ --output tmp/api-diff ``` Verify: - Both refs resolve to the expected SHAs. -- Every mapped module resolves. - No public declaration is silently skipped. - A repeated cached run produces identical snapshots and reports. - Temporary worktrees are removed. -- The report separates authoritative mappings from suggestions. +- The report separates exact matches from suggestions. - Representative report entries agree with the existing migration guides. ## Future Work