diff --git a/.changeset/detect-api-removals.md b/.changeset/detect-api-removals.md new file mode 100644 index 00000000000..e3453b03256 --- /dev/null +++ b/.changeset/detect-api-removals.md @@ -0,0 +1,6 @@ +--- +"@effect/api-diff": patch +--- + +Discover both revisions independently, report removed and newly added APIs, and +suggest likely v4 replacements without a migration map. diff --git a/.changeset/migrate-api-diff-cli.md b/.changeset/migrate-api-diff-cli.md new file mode 100644 index 00000000000..c9970147c50 --- /dev/null +++ b/.changeset/migrate-api-diff-cli.md @@ -0,0 +1,5 @@ +--- +"@effect/api-diff": patch +--- + +Migrate the API diff command to Effect services for filesystem, path, process, discovery, and snapshot operations. 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..a899a021e3f 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 +Mapped modules: 289 +No counterpart: 44 API renames: 53 -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,8 +347,7 @@ 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`, using the namespaced spelling found in application code. ```text Effect.async -> Effect.callback @@ -372,13 +357,13 @@ Effect.either -> Effect.result Effect.catchAll -> Effect.catch Effect.catchAllCause -> Effect.catchCause Effect.catchAllDefect -> Effect.catchDefect -Effect.catchSome -> Effect.catchIf +Effect.catchSome -> Effect.catchFilter Effect.catchIf -> Effect.catchIf Effect.optionFromOptional -> Effect.catchNoSuchElement -Effect.catchSomeCause -> Effect.catchCauseIf +Effect.catchSomeCause -> Effect.catchCauseFilter Effect.tapErrorCause -> Effect.tapCause Effect.ignoreLogged -> Effect.ignore -Effect.makeLatchUnsafe -> Latch.makeUnsafe +Effect.unsafeMakeLatch -> Latch.makeUnsafe Effect.makeLatch -> Latch.make Layer.scoped -> Layer.effect Layer.scopedDiscard -> Layer.effectDiscard @@ -389,7 +374,7 @@ Either -> Result.Result Either.right -> Result.succeed Either.left -> Result.fail Scope.extend -> Scope.provide -Effect.makeSemaphoreUnsafe -> Semaphore.makeUnsafe +Effect.unsafeMakeSemaphore -> Semaphore.makeUnsafe Effect.makeSemaphore -> Semaphore.make Stream.Context -> Stream.Services StreamHaltStrategy.HaltStrategy -> Stream.HaltStrategy @@ -416,6 +401,6 @@ Stream.catchAll -> Stream.catch Stream.catchSome -> Stream.catchIf Stream.catchSomeCause -> Stream.catchCauseIf Stream.combineChunks -> Stream.combineArray -provideSomeLayer -> Stream.provide -provideSomeContext -> Stream.provide +Stream.provideSomeLayer -> Stream.provide +Stream.provideSomeContext -> 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..ce8b5aeae2b --- /dev/null +++ b/packages/tools/api-diff/README.md @@ -0,0 +1,32 @@ +# 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 \ + --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, +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: + +- `base.snapshot.json` +- `head.snapshot.json` +- `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. diff --git a/packages/tools/api-diff/package.json b/packages/tools/api-diff/package.json new file mode 100644 index 00000000000..0651cce912d --- /dev/null +++ b/packages/tools/api-diff/package.json @@ -0,0 +1,29 @@ +{ + "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" + }, + "dependencies": { + "@effect/platform-node": "workspace:^", + "effect": "workspace:^" + }, + "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/ApiDiff.ts b/packages/tools/api-diff/src/ApiDiff.ts new file mode 100644 index 00000000000..afd673d2818 --- /dev/null +++ b/packages/tools/api-diff/src/ApiDiff.ts @@ -0,0 +1,119 @@ +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 { renderMarkdownReport } from "./Report.ts" +import { Worktrees } from "./Worktrees.ts" + +export interface ApiDiffOptions { + readonly baseRef: Option.Option + readonly headRef: Option.Option + readonly output: Option.Option +} + +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 runInternal = Effect.fnUntraced(function*(options: ApiDiffOptions) { + const repoRoot = yield* findRepoRoot() + + 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" + }) + } + + const baseSha = yield* worktrees.resolveRef(repoRoot, options.baseRef.value) + const headSha = yield* worktrees.resolveRef(repoRoot, options.headRef.value) + 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 + }) + const head = yield* worktrees.prepareSnapshot({ + repoRoot, + cacheRoot, + worktreesRoot, + name: "head", + ref: options.headRef.value, + sha: headSha + }) + const output = absolute(repoRoot, options.output.value) + const diff = diffSnapshots(base, head) + 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)`) + 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 new file mode 100644 index 00000000000..76f695b737e --- /dev/null +++ b/packages/tools/api-diff/src/Cli.ts @@ -0,0 +1,34 @@ +import * as Effect from "effect/Effect" +import * as Command from "effect/unstable/cli/Command" +import * as Flag from "effect/unstable/cli/Flag" +import { ApiDiff, type ApiDiffOptions } from "./ApiDiff.ts" + +const baseRef = Flag.string("base-ref").pipe( + Flag.withDescription("Explicit base Git ref"), + Flag.optional +) + +const headRef = Flag.string("head-ref").pipe( + Flag.withDescription("Explicit head Git ref"), + Flag.optional +) + +const output = Flag.string("output").pipe( + Flag.withMetavar("DIRECTORY"), + Flag.withDescription("Report output directory"), + Flag.optional +) + +const runApiDiff = Effect.fnUntraced(function*(options: ApiDiffOptions) { + const apiDiff = yield* ApiDiff + yield* apiDiff.run(options) +}) + +export const cli = Command.make("api-diff", { + baseRef, + headRef, + output +}).pipe( + Command.withDescription("Compare the consumer-visible TypeScript API of two repository revisions"), + Command.withHandler(runApiDiff) +) diff --git a/packages/tools/api-diff/src/Diff.ts b/packages/tools/api-diff/src/Diff.ts new file mode 100644 index 00000000000..827d2cdb92f --- /dev/null +++ b/packages/tools/api-diff/src/Diff.ts @@ -0,0 +1,508 @@ +import { fingerprint, stableJson } from "./Json.ts" +import type { ApiChange, ApiDiff, ApiEntity, ApiSnapshot, ChangeClassification, DeclarationModel } from "./Model.ts" + +interface Match { + readonly base: ApiEntity + readonly head: ApiEntity + readonly confidence: number + readonly authoritative: boolean + readonly note?: string +} + +interface ApiGroup { + readonly module: string + readonly path: ReadonlyArray + readonly entities: ReadonlyArray +} + +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 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 +): 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 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 + ) / 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 : 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.4 * sameFingerprint + + 0.15 * sameKind + + 0.15 * structure + + 0.025 * documentation + + (sameCategory ? 0.05 : 0) + + (mentionsHeadModule ? 0.075 : 0) + ) +} + +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, + 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): 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 + }) + } + } + 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(["module-added", module]).slice(0, 16)}`, + classification: "module-added", + confidence: 1, + delta: { to: [module] }, + authoritative: true + }) + } + } + return changes +} + +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 = [] + const suggestedMatches: 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 baseEntity of unmatchedBase.values()) { + addMatch(baseEntity, unmatchedHead.get(baseEntity.id), { + confidence: 1, + authoritative: true + }) + } + + const headGroups = groupEntities(unmatchedHead.values()) + for (const baseGroup of groupEntities(unmatchedBase.values())) { + const ranked = headGroups + .filter((group) => + groupName(group) === groupName(baseGroup) || + group.module === baseGroup.module || + baseGroup.entities.some((baseEntity) => + group.entities.some((headEntity) => + baseEntity.bucket === headEntity.bucket && + baseEntity.fingerprint === headEntity.fingerprint + ) + ) + ) + .map((group) => ({ group, score: groupSimilarity(baseGroup, group) })) + .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) { + suggestedMatches.push({ + base: baseEntity, + head: headEntity, + confidence: Number(best.score.toFixed(3)), + authoritative: false, + note: "Suggested replacement for removed API; requires review" + }) + } + } + } + + const changes = [...moduleChanges(base, head)] + for (const match of matches) { + const movement = movementChange(match) + if (movement !== undefined) { + changes.push(movement) + } + 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, + 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 }, + 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..0ceeb759f1f --- /dev/null +++ b/packages/tools/api-diff/src/Discovery.ts @@ -0,0 +1,303 @@ +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" + +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 + readonly root: string + readonly manifest: PackageManifest + readonly exports: unknown +} + +export interface DiscoveryResult { + readonly entrypoints: ReadonlyArray + readonly missing: ReadonlyArray +} + +const decodePackageManifest = Schema.decodeUnknownEffect(Schema.fromJsonString(PackageManifest)) + +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)}` + } +} + +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 readManifest = Effect.fnUntraced(function*(location: string) { + const source = yield* fs.readFileString(location) + return yield* decodePackageManifest(source) + }) + + 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() + }) + + 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, + 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 | undefined => { + const relativeTarget = target.replace(/^\.\//, "") + const declaration = relativeTarget.endsWith(".d.ts") + ? relativeTarget + : relativeTarget.replace(/\.(?:mjs|cjs|js|mts|cts|ts)$/, ".d.ts") + return declaration.endsWith(".d.ts") ? path.resolve(packageInfo.root, declaration) : undefined + } + + const expandPattern = Effect.fnUntraced(function*( + packageInfo: PackageInfo, + keyPattern: string, + targetPattern: string, + requested: ReadonlySet | undefined, + exportsMap: Readonly> + ) { + 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") + 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)}` + const excluded = Object.entries(exportsMap).some(([excludedKey, excludedTarget]) => + excludedTarget === null && matchPattern(excludedKey, key) !== undefined + ) + if (!excluded && (requested === undefined || 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 = requestedModules === undefined ? undefined : new Set(requestedModules) + const output = new Map() + + 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 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 + } + 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 (declarationFile !== undefined && (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 + } + 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, exportsMap)) { + 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 new file mode 100644 index 00000000000..30588163db1 --- /dev/null +++ b/packages/tools/api-diff/src/Json.ts @@ -0,0 +1,26 @@ +import * as Schema from "effect/Schema" +import { createHash } from "node:crypto" + +export const decodeJson = Schema.decodeUnknownEffect(Schema.UnknownFromJsonString) + +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 prettyJson = (value: unknown): string => `${JSON.stringify(value, null, 2)}\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..bb71385edfc --- /dev/null +++ b/packages/tools/api-diff/src/Model.ts @@ -0,0 +1,160 @@ +import * as Schema from "effect/Schema" + +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 + 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 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 + 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 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 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 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..06efd131a89 --- /dev/null +++ b/packages/tools/api-diff/src/Report.ts @@ -0,0 +1,137 @@ +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.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 module = id?.split("#")[0] ?? "" + 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} |` + }), + "" + ] + 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 replacements for removed APIs", "") + 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..bf303ca6d6a --- /dev/null +++ b/packages/tools/api-diff/src/Snapshot.ts @@ -0,0 +1,880 @@ +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 { 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, + Bucket, + DeclarationModel, + Documentation, + Entrypoint, + ImportRoute, + ParameterModel, + SnapshotDiagnostic, + SourceLocation, + TypeModel, + TypeParameterModel +} from "./Model.ts" + +interface SerializationContext { + readonly checker: ts.TypeChecker + readonly path: Path.Path + readonly repoRoot: string + readonly typeParameters: ReadonlyMap + readonly publicSymbols: ReadonlyMap +} + +const normalizedPath = (path: Path.Path, root: string, location: string): string => + path.relative(root, location).split(path.sep).join("/") + +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(path, 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 separator = context.path.sep + const nodeModules = file.lastIndexOf(`${separator}node_modules${separator}`) + if (nodeModules !== -1) { + 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.path, 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 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 + }) + +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 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 snapshotExtractionError(compilerDiagnostics.map((diagnostic) => ({ + code: `typescript-${diagnostic.code}`, + message: ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"), + source: diagnostic.file === undefined + ? undefined + : sourceLocation(path, 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, + path, + 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(path, 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(path, options.repoRoot, declaration) + }) + } + if (diagnostics.length > 0) { + throw 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(path, options.repoRoot, entrypoint.declarationFile) + })), + entities: entities.sort((left, right) => left.id.localeCompare(right.id)), + diagnostics: [] + } +} + +export const snapshotCacheKey = ( + sha: string, + modules?: ReadonlyArray +): string => fingerprint(["snapshot-v4", sha, ts.version, modules === undefined ? "all" : [...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 new file mode 100644 index 00000000000..5d22bf6b7f9 --- /dev/null +++ b/packages/tools/api-diff/src/Worktrees.ts @@ -0,0 +1,235 @@ +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 { snapshotCacheKey, Snapshotter } from "./Snapshot.ts" + +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 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 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 } + }) + + 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 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 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 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 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 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)) + ) + + 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 } + } + + 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, + ...(options.modules === undefined ? {} : { 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 + }) + }) + ) + + 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 new file mode 100644 index 00000000000..4455c3f21fb --- /dev/null +++ b/packages/tools/api-diff/src/bin.ts @@ -0,0 +1,18 @@ +#!/usr/bin/env node +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(MainLayer), + 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..e04141841f7 --- /dev/null +++ b/packages/tools/api-diff/test/Cli.test.ts @@ -0,0 +1,56 @@ +import { cli } from "@effect/api-diff" +import { ApiDiff } from "@effect/api-diff/ApiDiff" +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" +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" + +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("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 output = path.join(root, "output") + + yield* Command.runWith(cli, { version: "0.0.0" })([ + "--base-ref", + "a", + "--head-ref", + "b", + "--output", + output + ]) + + 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 new file mode 100644 index 00000000000..edf9472e505 --- /dev/null +++ b/packages/tools/api-diff/test/Diff.test.ts @@ -0,0 +1,190 @@ +import { diffSnapshots } from "@effect/api-diff/Diff" +import type { ApiEntity, ApiSnapshot, DeclarationModel } 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", "changed", { + kind: "function", + name: "changed", + parameters: [{ name: "value", type: { kind: "primitive", name: "string" }, optional: false, rest: false }], + returnType: { kind: "primitive", name: "string" } + }, "before"), + 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("old/A", "changed", { + kind: "function", + 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("old/A", "similarNames", { + kind: "variable", + name: "similarNames", + type: { kind: "primitive", name: "number" } + }, "y"), + entity("old/A", "added", { kind: "variable", name: "added", type: { kind: "primitive", name: "string" } }, "a") + ]) + 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)) + 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 replacements for removed APIs")) + assert(report.includes(base.sha)) + assert.deepStrictEqual(diff, diffSnapshots(base, head)) + }) + + 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]) + ) + 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", + 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("old/A", "ordered", signature("ordered", ["right", "left"]), "after") + const diff = diffSnapshots( + snapshot("a", [before]), + 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 new file mode 100644 index 00000000000..0586fa66bb9 --- /dev/null +++ b/packages/tools/api-diff/test/Discovery.test.ts @@ -0,0 +1,62 @@ +import { Discovery } from "@effect/api-diff/Discovery" +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 Layer from "effect/Layer" +import { writeFixturePackage } from "./utils.ts" + +const MainLayer = Discovery.layer.pipe( + Layer.provideMerge(NodeServices.layer) +) + +describe("entrypoint discovery", () => { + 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 = 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"]) + + 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", () => + 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/Snapshot.test.ts b/packages/tools/api-diff/test/Snapshot.test.ts new file mode 100644 index 00000000000..4ba61294108 --- /dev/null +++ b/packages/tools/api-diff/test/Snapshot.test.ts @@ -0,0 +1,130 @@ +import { SnapshotExtractionError, Snapshotter } from "@effect/api-diff/Snapshot" +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 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 + +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.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 = 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.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 +} +` + }) + const failure = yield* Effect.flip(snapshotter.extract({ + repoRoot: root, + ref: "fixture", + sha: "0000000000000000000000000000000000000000", + modules: ["@fixture/sample/Bad"] + })) + assert(failure instanceof SnapshotExtractionError) + assert(failure.diagnostics.some((diagnostic) => diagnostic.code === "unsupported-public-declaration")) + }).pipe(Effect.provide(MainLayer))) + + 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 new file mode 100644 index 00000000000..c296e4d89de --- /dev/null +++ b/packages/tools/api-diff/test/utils.ts @@ -0,0 +1,37 @@ +import * as Effect from "effect/Effect" +import * as FileSystem from "effect/FileSystem" +import * as Path from "effect/Path" + +export const writeFixturePackage = Effect.fnUntraced(function*( + repoRoot: string, + files: Readonly>, + exports: Readonly> = { + ".": "./index.js", + "./*": "./*.js", + "./internal/*": null + } +) { + 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( + { + name: "@fixture/sample", + version: "1.0.0", + exports + }, + null, + 2 + ) + }\n` + ) + for (const [name, source] of Object.entries(files)) { + const location = path.join(root, name) + yield* fs.makeDirectory(path.dirname(location), { recursive: true }) + yield* fs.writeFileString(location, 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..eb20168e6ef --- /dev/null +++ b/plan.md @@ -0,0 +1,325 @@ +# 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 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. +- 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 the independently discovered snapshots. +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 \ + --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 comparison scope is every consumer-visible entrypoint discovered in each revision. + +## Independent Discovery + +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 + +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 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. + +## 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. +- 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. +- 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. +- 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. +- 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. + +## Implementation Phases + +1. Implement and evaluate the representative-module prototype. +2. Define the versioned snapshot schema. +3. Implement package and entrypoint discovery. +4. Implement semantic export extraction and canonical type AST serialization. +5. Implement exact identity 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 \ + --output tmp/api-diff +``` + +Verify: + +- Both refs resolve to the expected SHAs. +- No public declaration is silently skipped. +- A repeated cached run produces identical snapshots and reports. +- Temporary worktrees are removed. +- The report separates exact matches 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..974bb828a81 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -711,6 +711,31 @@ importers: specifier: ^26.1.1 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:^ + 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" },