| 1 | import { diffSnapshots } from "@effect/api-diff/Diff" |
| 2 | import type { ApiDiff, ApiEntity, ApiSnapshot, DeclarationModel } from "@effect/api-diff/Model" |
| 3 | import { renderMarkdownReport } from "@effect/api-diff/Report" |
| 4 | import { Snapshotter } from "@effect/api-diff/Snapshot" |
| 5 | import * as NodeServices from "@effect/platform-node/NodeServices" |
| 6 | import { assert, describe, it } from "@effect/vitest" |
| 7 | import * as Effect from "effect/Effect" |
| 8 | import * as FileSystem from "effect/FileSystem" |
| 9 | import * as Layer from "effect/Layer" |
| 10 | import { writeFixturePackage } from "./utils.ts" |
| 11 | |
| 12 | const MainLayer = Snapshotter.layer.pipe(Layer.provideMerge(NodeServices.layer)) |
| 13 | |
| 14 | const entity = ( |
| 15 | module: string, |
| 16 | name: string, |
| 17 | declaration: DeclarationModel, |
| 18 | hash: string |
| 19 | ): ApiEntity => ({ |
| 20 | id: `${module}#${name}#value`, |
| 21 | packageName: module.split("/")[0]!, |
| 22 | module, |
| 23 | path: [name], |
| 24 | bucket: "value", |
| 25 | declarationKind: declaration.kind, |
| 26 | importRoutes: [{ module, path: [name] }], |
| 27 | declarations: [declaration], |
| 28 | displaySignature: `declare const ${name}: unknown`, |
| 29 | fingerprint: hash, |
| 30 | documentation: { stability: "stable" }, |
| 31 | source: { file: `${name}.d.ts`, line: 1, column: 1 } |
| 32 | }) |
| 33 | |
| 34 | const snapshot = (ref: string, entities: ReadonlyArray<ApiEntity>): ApiSnapshot => ({ |
| 35 | version: 1, |
| 36 | compiler: { name: "typescript", version: "fixture" }, |
| 37 | ref, |
| 38 | sha: ref.repeat(40).slice(0, 40), |
| 39 | packages: ["old", "new"], |
| 40 | entrypoints: [], |
| 41 | entities, |
| 42 | diagnostics: [] |
| 43 | }) |
| 44 | |
| 45 | describe("snapshot diff", () => { |
| 46 | it.effect("detects optional class member changes", () => |
| 47 | Effect.gen(function*() { |
| 48 | const fs = yield* FileSystem.FileSystem |
| 49 | const snapshotter = yield* Snapshotter |
| 50 | const extract = Effect.fnUntraced(function*(ref: string, source: string) { |
| 51 | const repoRoot = yield* fs.makeTempDirectoryScoped({ prefix: "api-diff-optional-class-" }) |
| 52 | yield* writeFixturePackage(repoRoot, { "Api.d.ts": source }, { "./Api": { types: "./Api.d.ts" } }) |
| 53 | return yield* snapshotter.extract({ |
| 54 | repoRoot, |
| 55 | ref, |
| 56 | sha: "0".repeat(40), |
| 57 | modules: ["@fixture/sample/Api"] |
| 58 | }) |
| 59 | }) |
| 60 | const base = yield* extract("base", "export declare class Client { value: string }\n") |
| 61 | const head = yield* extract("head", "export declare class Client { value?: string }\n") |
| 62 | const diff = diffSnapshots(base, head) |
| 63 | |
| 64 | for (const bucket of ["type", "value"]) { |
| 65 | const id = `@fixture/sample/Api#Client#${bucket}` |
| 66 | const baseEntity = base.entities.find((entity) => entity.id === id) |
| 67 | const headEntity = head.entities.find((entity) => entity.id === id) |
| 68 | assert(baseEntity !== undefined) |
| 69 | assert(headEntity !== undefined) |
| 70 | assert.notStrictEqual(baseEntity.fingerprint, headEntity.fingerprint) |
| 71 | assert(diff.changes.some((change) => |
| 72 | change.classification === "member-changed" && |
| 73 | change.baseApiId === id && |
| 74 | change.headApiId === id |
| 75 | )) |
| 76 | } |
| 77 | }).pipe(Effect.provide(MainLayer))) |
| 78 | |
| 79 | it.effect("detects changes between numeric and string literal types", () => |
| 80 | Effect.gen(function*() { |
| 81 | const fs = yield* FileSystem.FileSystem |
| 82 | const snapshotter = yield* Snapshotter |
| 83 | const extract = (repoRoot: string, ref: string) => |
| 84 | snapshotter.extract({ |
| 85 | repoRoot, |
| 86 | ref, |
| 87 | sha: ref.repeat(40), |
| 88 | modules: ["@fixture/sample/Value"] |
| 89 | }) |
| 90 | const baseRoot = yield* fs.makeTempDirectoryScoped({ prefix: "api-diff-literal-base-" }) |
| 91 | const headRoot = yield* fs.makeTempDirectoryScoped({ prefix: "api-diff-literal-head-" }) |
| 92 | yield* writeFixturePackage(baseRoot, { "Value.d.ts": "export type Value = 1\n" }) |
| 93 | yield* writeFixturePackage(headRoot, { "Value.d.ts": "export type Value = \"1\"\n" }) |
| 94 | |
| 95 | assert.deepStrictEqual( |
| 96 | diffSnapshots(yield* extract(baseRoot, "a"), yield* extract(headRoot, "b")).changes.map((change) => |
| 97 | change.classification |
| 98 | ), |
| 99 | ["structural-change"] |
| 100 | ) |
| 101 | }).pipe(Effect.provide(MainLayer))) |
| 102 | |
| 103 | it("matches renames, classifies signature changes, and separates suggestions", () => { |
| 104 | const base = snapshot("a", [ |
| 105 | entity("old/A", "changed", { |
| 106 | kind: "function", |
| 107 | name: "changed", |
| 108 | parameters: [{ name: "value", type: { kind: "primitive", name: "string" }, optional: false, rest: false }], |
| 109 | returnType: { kind: "primitive", name: "string" } |
| 110 | }, "before"), |
| 111 | entity("old/A", "similarName", { |
| 112 | kind: "variable", |
| 113 | name: "similarName", |
| 114 | type: { kind: "primitive", name: "string" } |
| 115 | }, "x"), |
| 116 | entity( |
| 117 | "old/A", |
| 118 | "removed", |
| 119 | { kind: "variable", name: "removed", type: { kind: "primitive", name: "string" } }, |
| 120 | "r" |
| 121 | ) |
| 122 | ]) |
| 123 | const head = snapshot("b", [ |
| 124 | entity("old/A", "changed", { |
| 125 | kind: "function", |
| 126 | name: "changed", |
| 127 | parameters: [ |
| 128 | { name: "value", type: { kind: "primitive", name: "string" }, optional: false, rest: false }, |
| 129 | { name: "count", type: { kind: "primitive", name: "number" }, optional: true, rest: false } |
| 130 | ], |
| 131 | returnType: { kind: "primitive", name: "number" } |
| 132 | }, "different"), |
| 133 | entity("old/A", "similarNames", { |
| 134 | kind: "variable", |
| 135 | name: "similarNames", |
| 136 | type: { kind: "primitive", name: "number" } |
| 137 | }, "y"), |
| 138 | entity("old/A", "added", { kind: "variable", name: "added", type: { kind: "primitive", name: "string" } }, "a") |
| 139 | ]) |
| 140 | const diff = diffSnapshots(base, head) |
| 141 | assert(diff.changes.some((change) => change.classification === "parameter-added")) |
| 142 | assert(diff.changes.some((change) => change.classification === "return-type-changed")) |
| 143 | assert(diff.changes.some((change) => change.baseApiId?.includes("similarName") && !change.authoritative)) |
| 144 | assert( |
| 145 | diff.changes.some((change) => |
| 146 | change.classification === "api-removed" && change.baseApiId?.includes("similarName") |
| 147 | ) |
| 148 | ) |
| 149 | assert( |
| 150 | diff.changes.some((change) => change.classification === "api-added" && change.headApiId?.includes("similarNames")) |
| 151 | ) |
| 152 | const report = renderMarkdownReport(diff) |
| 153 | assert(report.includes("Suggested replacements for removed APIs")) |
| 154 | assert(report.includes(base.sha)) |
| 155 | assert.deepStrictEqual(diff, diffSnapshots(base, head)) |
| 156 | }) |
| 157 | |
| 158 | it("suggests replacements across modules and preserves class facets", () => { |
| 159 | const variable = (name: string): DeclarationModel => ({ |
| 160 | kind: "variable", |
| 161 | name, |
| 162 | type: { kind: "primitive", name: "unknown" } |
| 163 | }) |
| 164 | const serviceInterface = (name: string, extraMember?: string): DeclarationModel => ({ |
| 165 | kind: "interface", |
| 166 | name, |
| 167 | members: [ |
| 168 | { |
| 169 | kind: "method", |
| 170 | name: "context", |
| 171 | parameters: [], |
| 172 | returnType: { kind: "primitive", name: "unknown" } |
| 173 | }, |
| 174 | { |
| 175 | kind: "method", |
| 176 | name: "of", |
| 177 | parameters: [], |
| 178 | returnType: { kind: "primitive", name: "unknown" } |
| 179 | }, |
| 180 | ...(extraMember === undefined |
| 181 | ? [] |
| 182 | : [{ |
| 183 | kind: "method", |
| 184 | name: extraMember, |
| 185 | parameters: [], |
| 186 | returnType: { kind: "primitive", name: "unknown" } |
| 187 | }]) |
| 188 | ] |
| 189 | }) |
| 190 | const withBucket = (api: ApiEntity, bucket: ApiEntity["bucket"]): ApiEntity => ({ |
| 191 | ...api, |
| 192 | id: `${api.module}#${api.path.join(".")}#${bucket}`, |
| 193 | bucket |
| 194 | }) |
| 195 | const effectService = { |
| 196 | ...entity("effect/Effect", "Service", variable("Service"), "effect-service"), |
| 197 | documentation: { |
| 198 | summary: "Creates a Context Tag and Layer for a service.", |
| 199 | stability: "stable" as const |
| 200 | } |
| 201 | } |
| 202 | const contextTagValue = entity("effect/Context", "Tag", variable("Tag"), "tag-value") |
| 203 | const contextTagType = withBucket( |
| 204 | entity("effect/Context", "Tag", serviceInterface("Tag"), "tag-type"), |
| 205 | "type" |
| 206 | ) |
| 207 | const contextServiceValue = entity("effect/Context", "Service", variable("Service"), "service-value") |
| 208 | const contextServiceType = withBucket( |
| 209 | entity("effect/Context", "Service", serviceInterface("Service", "use"), "service-type"), |
| 210 | "type" |
| 211 | ) |
| 212 | const layerMapService = entity("effect/LayerMap", "Service", variable("Service"), "layer-map-service") |
| 213 | const diff = diffSnapshots( |
| 214 | snapshot("a", [effectService, contextTagType, contextTagValue]), |
| 215 | snapshot("b", [contextServiceType, contextServiceValue, layerMapService]) |
| 216 | ) |
| 217 | const suggestions = diff.changes.filter((change) => !change.authoritative) |
| 218 | assert(suggestions.some((change) => |
| 219 | change.baseApiId === "effect/Effect#Service#value" && |
| 220 | change.headApiId === "effect/Context#Service#value" |
| 221 | )) |
| 222 | assert(suggestions.some((change) => |
| 223 | change.baseApiId === "effect/Context#Tag#type" && |
| 224 | change.headApiId === "effect/Context#Service#type" |
| 225 | )) |
| 226 | assert(suggestions.some((change) => |
| 227 | change.baseApiId === "effect/Context#Tag#value" && |
| 228 | change.headApiId === "effect/Context#Service#value" |
| 229 | )) |
| 230 | assert(!suggestions.some((change) => change.headApiId === "effect/LayerMap#Service#value")) |
| 231 | assert.strictEqual(diff.changes.filter((change) => change.classification === "api-removed").length, 3) |
| 232 | assert.strictEqual(diff.changes.filter((change) => change.classification === "api-added").length, 3) |
| 233 | }) |
| 234 | |
| 235 | it("classifies overload and parameter reordering", () => { |
| 236 | const signature = (name: string, parameters: ReadonlyArray<"left" | "right">): DeclarationModel => ({ |
| 237 | kind: "function", |
| 238 | name, |
| 239 | parameters: parameters.map((parameter) => ({ |
| 240 | name: parameter, |
| 241 | type: { kind: "primitive", name: "string" }, |
| 242 | optional: false, |
| 243 | rest: false |
| 244 | })), |
| 245 | returnType: { kind: "primitive", name: "string" } |
| 246 | }) |
| 247 | const before = entity("old/A", "ordered", signature("ordered", ["left", "right"]), "before") |
| 248 | const after = entity("old/A", "ordered", signature("ordered", ["right", "left"]), "after") |
| 249 | const diff = diffSnapshots( |
| 250 | snapshot("a", [before]), |
| 251 | snapshot("b", [after]) |
| 252 | ) |
| 253 | assert(diff.changes.some((change) => change.classification === "parameter-reordered")) |
| 254 | }) |
| 255 | |
| 256 | it("groups package and module changes by their delta names", () => { |
| 257 | const report = renderMarkdownReport( |
| 258 | { |
| 259 | version: 1, |
| 260 | base: { ref: "a", sha: "a".repeat(40) }, |
| 261 | head: { ref: "b", sha: "b".repeat(40) }, |
| 262 | changes: [ |
| 263 | { |
| 264 | id: "package-removed", |
| 265 | classification: "package-removed", |
| 266 | confidence: 1, |
| 267 | delta: { packageName: "@effect/old" }, |
| 268 | authoritative: true |
| 269 | }, |
| 270 | { |
| 271 | id: "module-added", |
| 272 | classification: "module-added", |
| 273 | confidence: 1, |
| 274 | delta: { to: ["effect/New"] }, |
| 275 | authoritative: true |
| 276 | } |
| 277 | ] |
| 278 | } satisfies ApiDiff |
| 279 | ) |
| 280 | assert(report.includes("| @effect/old | @effect/old | 1 |")) |
| 281 | assert(report.includes("| stable | effect/New | 1 |")) |
| 282 | assert(!report.includes("<package>")) |
| 283 | }) |
| 284 | }) |