1import type { MigrationAnnotation } from "@effect/api-diff/Annotations"
2import {
3 extractImportMapSections,
4 markdownSafetyIssues,
5 renderMigrationDocument,
6 renderMissingAnnotations
7} from "@effect/api-diff/MigrationDoc"
8import type { ApiChange, ApiDiff } from "@effect/api-diff/Model"
9import { assert, describe, it } from "@effect/vitest"
10
11const change = (change: Partial<ApiChange> & Pick<ApiChange, "classification">): ApiChange => ({
12 id: `change-${change.classification}-${change.baseApiId ?? change.headApiId}`,
13 confidence: 1,
14 authoritative: true,
15 ...change
16})
17
18const diff: ApiDiff = {
19 version: 1,
20 base: { ref: "v3", sha: "a".repeat(40) },
21 head: { ref: "main", sha: "b".repeat(40) },
22 changes: [
23 change({
24 classification: "parameter-removed",
25 baseApiId: "effect/Zeta#changed#value",
26 headApiId: "effect/Zeta#changed#value",
27 before: "declare const changed: (value: string) => string",
28 after: "declare const changed: () => string"
29 }),
30 change({
31 classification: "api-renamed",
32 baseApiId: "effect/Alpha#oldName#value",
33 headApiId: "effect/Alpha#newName#value"
34 }),
35 change({ classification: "api-removed", baseApiId: "effect/Alpha#oldName#value" }),
36 change({
37 classification: "api-removed",
38 baseApiId: "effect/Beta#removed#value",
39 before: "declare const removed: string"
40 }),
41 change({
42 classification: "api-moved",
43 baseApiId: "effect/Legacy#keptName#value",
44 headApiId: "effect/Current#keptName#value"
45 }),
46 change({ classification: "api-removed", baseApiId: "effect/Legacy#keptName#value" }),
47 change({ classification: "documentation-changed", baseApiId: "effect/Zeta#documented#value" }),
48 change({
49 classification: "parameter-added",
50 baseApiId: "effect/Zeta#optional#value",
51 headApiId: "effect/Zeta#optional#value",
52 delta: {
53 before: [],
54 after: [{ name: "options", type: { kind: "primitive", name: "string" }, optional: true, rest: false }]
55 }
56 }),
57 change({ classification: "api-added", headApiId: "effect/Zeta#added#value" })
58 ]
59}
60
61const annotations = new Map<string, MigrationAnnotation>([
62 ["effect/Alpha#oldName", {
63 replacement: "Alpha.newName",
64 note: "Use the renamed function."
65 }]
66])
67
68describe("migration document", () => {
69 it("renders the selected API changes deterministically", () => {
70 const document = renderMigrationDocument(
71 diff,
72 annotations,
73 "## Import Map\n\n```text\neffect/Alpha -> effect/Alpha\n```\n"
74 )
75
76 assert.strictEqual(
77 document,
78 [
79 "<!-- dprint-ignore-file -->",
80 "",
81 "# v3 to v4 Migration Reference",
82 "",
83 `Base: \`v3\` (\`${"a".repeat(40)}\`)`,
84 "",
85 `Head: \`main\` (\`${"b".repeat(40)}\`)`,
86 "",
87 "This file is generated from the API diff and `migration/annotations/*.yaml`.",
88 "",
89 "## Import Map",
90 "",
91 "```text",
92 "effect/Alpha -> effect/Alpha",
93 "```",
94 "",
95 "## Removed Modules",
96 "",
97 "No modules were removed.",
98 "",
99 "## API Reference",
100 "",
101 "### `effect/Alpha`",
102 "",
103 "- `Alpha.oldName` -> `Alpha.newName`: Use the renamed function.",
104 "",
105 "### `effect/Beta`",
106 "",
107 "- `Beta.removed`: TODO: needs guidance",
108 "",
109 "### `effect/Zeta`",
110 "",
111 "- `Zeta.changed`: TODO: needs guidance",
112 ""
113 ].join("\n")
114 )
115 assert(!document.includes("keptName"))
116 assert(!document.includes("documented"))
117 assert(!document.includes("optional"))
118 assert(!document.includes("added"))
119 })
120
121 it("lists unannotated ids grouped by module", () => {
122 assert.strictEqual(
123 renderMissingAnnotations(diff, annotations),
124 "effect/Beta:\n - effect/Beta#removed\neffect/Zeta:\n - effect/Zeta#changed\n"
125 )
126 })
127
128 it("renders guidance and examples for detailed changes", () => {
129 const document = renderMigrationDocument(
130 diff,
131 new Map([...annotations, ["effect/Zeta#changed", {
132 replacement: "Zeta.changed()",
133 note: "Call the zero-argument form.",
134 example: "Zeta.changed()"
135 }]]),
136 "## Import Map\n"
137 )
138
139 assert(document.includes("**Replacement:** `Zeta.changed()`"))
140 assert(document.includes("Call the zero-argument form."))
141 assert(document.includes("**Example**\n\n```ts\nZeta.changed()\n```"))
142 assert(!document.includes("**Before**"))
143 assert(!document.includes("**After**"))
144 })
145
146 it.each([
147 { name: "plain", example: "const value = 1", fence: "```" },
148 { name: "empty", example: "", fence: "```" },
149 { name: "single backticks", example: "const value = `text`", fence: "```" },
150 { name: "double backticks", example: "// ``literal``", fence: "```" },
151 { name: "triple backticks", example: "/*\n```text\nliteral\n```\n*/", fence: "````" },
152 { name: "four backticks", example: "/*\n````text\nliteral\n````\n*/", fence: "`````" },
153 { name: "longest run last", example: "const value = `text`\n/*\n```\n`````\n*/", fence: "``````" },
154 { name: "whitespace", example: "\n const value = 1 \n\n", fence: "```" },
155 { name: "many backtick runs", example: "// ` ".repeat(400_000), fence: "```" }
156 ])("preserves example framing: $name", ({ example, fence }) => {
157 const document = renderMigrationDocument(
158 diff,
159 new Map([...annotations, ["effect/Zeta#changed", {
160 replacement: "Zeta.changed()",
161 note: "Call the zero-argument form.",
162 example
163 }]]),
164 "## Import Map\n"
165 )
166
167 assert.strictEqual(document.split("**Example**\n\n")[1], `${fence}ts\n${example}\n${fence}\n`)
168 assert.deepStrictEqual(markdownSafetyIssues(document), [])
169 })
170
171 it.each([
172 { name: "compact", example: undefined },
173 { name: "detailed", example: "Zeta.changed()" }
174 ])("renders replacements with many backtick runs in $name entries", ({ example }) => {
175 const replacement = "// ` ".repeat(400_000)
176 const document = renderMigrationDocument(
177 diff,
178 new Map([...annotations, ["effect/Zeta#changed", {
179 replacement,
180 note: "Use the replacement.",
181 example
182 }]]),
183 "## Import Map\n"
184 )
185
186 assert(document.includes("``" + replacement + "``"))
187 assert.deepStrictEqual(markdownSafetyIssues(document), [])
188 })
189
190 it("escapes annotation prose while preserving inline code and fenced examples", () => {
191 const document = renderMigrationDocument(
192 diff,
193 new Map([...annotations, ["effect/Zeta#changed", {
194 replacement: "Zeta.changed()",
195 note: "Use ~effect/Zeta with Effect<A, E>, *literal*, snake_case, `Context.Context<R>`, and a bare ` marker.",
196 example: "const marker = `~effect/Zeta<*>`"
197 }]]),
198 "## Import Map\n"
199 )
200
201 assert(document.includes(
202 "Use \\~effect/Zeta with Effect\\<A, E\\>, \\*literal\\*, snake\\_case, `Context.Context<R>`, and a bare \\` marker."
203 ))
204 assert(document.includes("```ts\nconst marker = `~effect/Zeta<*>`\n```"))
205 assert.deepStrictEqual(markdownSafetyIssues(document), [])
206 assert.deepStrictEqual(markdownSafetyIssues("Unsafe ~~note and Effect<A>"), [
207 "line 1: unescaped \"~\"",
208 "line 1: unescaped \"~\"",
209 "line 1: unescaped \"<\""
210 ])
211 })
212
213 it("retains annotated removals that also have move suggestions", () => {
214 const serviceDiff: ApiDiff = {
215 ...diff,
216 changes: [
217 change({
218 classification: "api-moved",
219 baseApiId: "effect/Effect#Service#value",
220 headApiId: "effect/Context#Service#value",
221 authoritative: false
222 }),
223 change({
224 classification: "api-removed",
225 baseApiId: "effect/Effect#Service#value",
226 before: "declare const Service: unknown"
227 })
228 ]
229 }
230 const document = renderMigrationDocument(
231 serviceDiff,
232 new Map([["effect/Effect#Service", {
233 replacement: "Context.Service",
234 note: "Use the v4 service constructor."
235 }]]),
236 "## Import Map\n"
237 )
238
239 assert(document.includes("- `Effect.Service` -> `Context.Service`: Use the v4 service constructor."))
240 })
241
242 it("renders removed modules and omits unchanged APIs moved with them", () => {
243 const moduleDiff: ApiDiff = {
244 ...diff,
245 changes: [
246 change({ classification: "module-removed", delta: { from: "effect/Legacy", to: [] } }),
247 change({
248 classification: "api-removed",
249 baseApiId: "effect/Legacy#unchanged#value",
250 before: "declare const unchanged: string"
251 }),
252 change({
253 classification: "api-added",
254 headApiId: "effect/Current#unchanged#value",
255 after: "declare const unchanged: string"
256 }),
257 change({
258 classification: "api-removed",
259 baseApiId: "effect/Legacy#changed#value",
260 before: "declare const changed: string"
261 }),
262 change({
263 classification: "api-added",
264 headApiId: "effect/Current#changed#value",
265 after: "declare const changed: number"
266 })
267 ]
268 }
269 const document = renderMigrationDocument(
270 moduleDiff,
271 new Map([["effect/Legacy#changed", {
272 replacement: "Current.changed",
273 note: "Use the changed API."
274 }]]),
275 "## Import Map\n\neffect/Legacy -> effect/Current\n"
276 )
277
278 assert(document.includes("## Removed Modules\n\n- `effect/Legacy` -> `effect/Current`"))
279 assert(!document.includes("Legacy.unchanged"))
280 assert(document.includes("- `Legacy.changed` -> `Current.changed`: Use the changed API."))
281 assert.strictEqual(
282 extractImportMapSections(document),
283 "## Import Map\n\neffect/Legacy -> effect/Current\n"
284 )
285 })
286
287 it.each(["LF", "CRLF", "mixed"])("preserves %s import-map replacement guidance", (ending) => {
288 const moduleDiff: ApiDiff = {
289 ...diff,
290 changes: [
291 change({ classification: "module-removed", delta: { from: "effect/Legacy", to: [] } }),
292 change({
293 classification: "api-removed",
294 baseApiId: "effect/Legacy#unchanged#value",
295 before: "declare const unchanged: string"
296 }),
297 change({
298 classification: "api-added",
299 headApiId: "effect/Current#unchanged#value",
300 after: "declare const unchanged: string"
301 })
302 ]
303 }
304 const eol = ending === "LF" ? "\n" : "\r\n"
305 const authored = [
306 "## Import Map",
307 "",
308 "```text",
309 "effect/Legacy -> effect/Other",
310 "effect/Legacy -> effect/Current (barrel: effect)",
311 "effect/Legacy -> effect/Current",
312 "```",
313 "",
314 "## No Counterpart Imports",
315 "",
316 "None."
317 ].join(eol)
318 const section = ending === "mixed" ? authored.replace("## Import Map\r\n", "## Import Map\n") : authored
319 const importMap = extractImportMapSections(
320 `# Existing reference${eol}${section}${eol}${eol}## Removed Modules${eol}Old guidance.`
321 )
322 const moduleAnnotations = new Map<string, MigrationAnnotation>()
323 const document = renderMigrationDocument(moduleDiff, moduleAnnotations, importMap)
324
325 assert.strictEqual(importMap, `${section}\n`)
326 assert.strictEqual(extractImportMapSections(document), importMap)
327 assert(document.includes("## Removed Modules\n\n- `effect/Legacy` -> `effect/Other`, `effect/Current`\n"))
328 assert(!document.includes("Legacy.unchanged"))
329 assert.strictEqual(
330 renderMissingAnnotations(moduleDiff, moduleAnnotations, importMap),
331 "All migration APIs and removed modules have guidance.\n"
332 )
333 })
334
335 it("uses exact removed-module guidance for APIs without replacements", () => {
336 const moduleDiff: ApiDiff = {
337 ...diff,
338 changes: [
339 change({ classification: "module-removed", delta: { from: "effect/Legacy", to: [] } }),
340 change({
341 classification: "api-removed",
342 baseApiId: "effect/Legacy#removed#value",
343 before: "declare const removed: string"
344 }),
345 change({
346 classification: "api-removed",
347 baseApiId: "effect/Legacy#replaced#value",
348 before: "declare const replaced: string"
349 }),
350 change({
351 classification: "api-removed",
352 baseApiId: "effect/LegacyExtra#removed#value",
353 before: "declare const removed: string"
354 }),
355 change({
356 classification: "api-removed",
357 baseApiId: "effect/Current#removed#value",
358 before: "declare const removed: string"
359 })
360 ]
361 }
362 const moduleAnnotations = new Map<string, MigrationAnnotation>([
363 ["effect/Legacy", {
364 replacement: "none",
365 note: "The module was removed."
366 }],
367 ["effect/Legacy#replaced", {
368 replacement: "Current.replaced",
369 note: "Use the replacement."
370 }],
371 ["effect/LegacyExtra#removed", {
372 replacement: "none",
373 note: "This similarly prefixed module still exists."
374 }],
375 ["effect/Current#removed", {
376 replacement: "none",
377 note: "This module still exists."
378 }]
379 ])
380 const document = renderMigrationDocument(moduleDiff, moduleAnnotations, "## Import Map\n")
381
382 assert(document.includes("- `effect/Legacy` -> `none`: The module was removed."))
383 assert(!document.includes("Legacy.removed"))
384 assert(document.includes("- `Legacy.replaced` -> `Current.replaced`: Use the replacement."))
385 assert(document.includes("- `LegacyExtra.removed` -> `none`: This similarly prefixed module still exists."))
386 assert(document.includes("- `Current.removed` -> `none`: This module still exists."))
387 assert.strictEqual(
388 renderMissingAnnotations(moduleDiff, moduleAnnotations),
389 "All migration APIs and removed modules have guidance.\n"
390 )
391 })
392
393 it("keeps removals from relocated modules and modules without module guidance", () => {
394 const moduleDiff: ApiDiff = {
395 ...diff,
396 changes: [
397 change({ classification: "module-removed", delta: { from: "effect/Relocated", to: [] } }),
398 change({ classification: "module-removed", delta: { from: "effect/Unguided", to: [] } }),
399 change({ classification: "api-removed", baseApiId: "effect/Relocated#removed#value" }),
400 change({ classification: "api-removed", baseApiId: "effect/Unguided#removed#value" })
401 ]
402 }
403 const moduleAnnotations = new Map<string, MigrationAnnotation>([
404 ["effect/Relocated", {
405 replacement: "effect/Current",
406 note: "The module moved."
407 }],
408 ["effect/Relocated#removed", {
409 replacement: "none",
410 note: "This API was not moved."
411 }],
412 ["effect/Unguided#removed", {
413 replacement: "none",
414 note: "This API was removed."
415 }]
416 ])
417 const document = renderMigrationDocument(moduleDiff, moduleAnnotations, "## Import Map\n")
418
419 assert(document.includes("- `effect/Relocated` -> `effect/Current`: The module moved."))
420 assert(document.includes("- `Relocated.removed` -> `none`: This API was not moved."))
421 assert(document.includes("- `Unguided.removed` -> `none`: This API was removed."))
422 assert.strictEqual(
423 renderMissingAnnotations(moduleDiff, moduleAnnotations),
424 "All migration APIs and removed modules have guidance.\n"
425 )
426 })
427
428 it("uses per-API annotations for split modules and reports missing module guidance", () => {
429 const moduleDiff: ApiDiff = {
430 ...diff,
431 changes: [
432 change({ classification: "module-removed", delta: { from: "effect/Split", to: [] } }),
433 change({ classification: "module-removed", delta: { from: "effect/Unknown", to: [] } }),
434 change({
435 classification: "api-removed",
436 baseApiId: "effect/Split#removed#value",
437 before: "declare const removed: string"
438 })
439 ]
440 }
441 const splitAnnotations = new Map([["effect/Split#removed", {
442 replacement: "Current.removed",
443 note: "Use the split replacement."
444 }]])
445 const document = renderMigrationDocument(moduleDiff, splitAnnotations, "## Import Map\n")
446
447 assert(
448 document.includes("- `effect/Split`: No single module replacement; follow the curated per-API guidance below.")
449 )
450 assert(document.includes("- `effect/Unknown`: TODO: needs module guidance"))
451 assert.strictEqual(
452 renderMissingAnnotations(moduleDiff, splitAnnotations),
453 "Removed modules:\n - effect/Unknown\n"
454 )
455 })
456})