| 1 | import type { MigrationAnnotation } from "@effect/api-diff/Annotations" |
| 2 | import { |
| 3 | extractImportMapSections, |
| 4 | markdownSafetyIssues, |
| 5 | renderMigrationDocument, |
| 6 | renderMissingAnnotations |
| 7 | } from "@effect/api-diff/MigrationDoc" |
| 8 | import type { ApiChange, ApiDiff } from "@effect/api-diff/Model" |
| 9 | import { assert, describe, it } from "@effect/vitest" |
| 10 | |
| 11 | const 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 | |
| 18 | const 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 | |
| 61 | const annotations = new Map<string, MigrationAnnotation>([ |
| 62 | ["effect/Alpha#oldName", { |
| 63 | replacement: "Alpha.newName", |
| 64 | note: "Use the renamed function." |
| 65 | }] |
| 66 | ]) |
| 67 | |
| 68 | describe("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 | }) |