1import { diffSnapshots } from "@effect/api-diff/Diff"
2import type { ApiDiff, ApiEntity, ApiSnapshot, DeclarationModel } from "@effect/api-diff/Model"
3import { renderMarkdownReport } from "@effect/api-diff/Report"
4import { Snapshotter } from "@effect/api-diff/Snapshot"
5import * as NodeServices from "@effect/platform-node/NodeServices"
6import { assert, describe, it } from "@effect/vitest"
7import * as Effect from "effect/Effect"
8import * as FileSystem from "effect/FileSystem"
9import * as Layer from "effect/Layer"
10import { writeFixturePackage } from "./utils.ts"
11
12const MainLayer = Snapshotter.layer.pipe(Layer.provideMerge(NodeServices.layer))
13
14const 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
34const 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
45describe("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})