| 1 | import type { ApiChange, ApiDiff } from "./Model.ts" |
| 2 | |
| 3 | const unstableDomains = new Set([ |
| 4 | "ai", |
| 5 | "arbitrary", |
| 6 | "cli", |
| 7 | "cluster", |
| 8 | "devtools", |
| 9 | "encoding", |
| 10 | "eventlog", |
| 11 | "http", |
| 12 | "http-api", |
| 13 | "net", |
| 14 | "observability", |
| 15 | "persistence", |
| 16 | "process", |
| 17 | "reactivity", |
| 18 | "rpc", |
| 19 | "schema", |
| 20 | "socket", |
| 21 | "sql", |
| 22 | "workers", |
| 23 | "workflow" |
| 24 | ]) |
| 25 | |
| 26 | const unstableDomain = (module: string): string | undefined => { |
| 27 | const segments = module.split("/") |
| 28 | const domain = segments[1] === "unstable" ? segments[2] : segments[1] |
| 29 | return domain !== undefined && segments[0] === "effect" && unstableDomains.has(domain) ? domain : undefined |
| 30 | } |
| 31 | |
| 32 | const changeModule = (change: ApiChange): string => { |
| 33 | const id = change.headApiId ?? change.baseApiId |
| 34 | const delta = change.delta as { |
| 35 | readonly packageName?: string |
| 36 | readonly from?: string |
| 37 | readonly to?: ReadonlyArray<string> |
| 38 | } | undefined |
| 39 | return id?.split("#")[0] ?? delta?.packageName ?? delta?.from ?? delta?.to?.[0] ?? "<package>" |
| 40 | } |
| 41 | |
| 42 | const isUnstableChange = (change: ApiChange): boolean => unstableDomain(changeModule(change)) !== undefined |
| 43 | |
| 44 | const escapeCell = (value: string): string => value.replaceAll("|", "\\|").replaceAll("\n", " ") |
| 45 | |
| 46 | const changeLabel = (change: ApiChange): string => |
| 47 | change.baseApiId === undefined |
| 48 | ? change.headApiId ?? JSON.stringify(change.delta) |
| 49 | : change.headApiId === undefined |
| 50 | ? change.baseApiId |
| 51 | : `${change.baseApiId} → ${change.headApiId}` |
| 52 | |
| 53 | const signature = (label: string, source: string | undefined): ReadonlyArray<string> => |
| 54 | source === undefined ? [] : ["", `${label}:`, "", "```ts", source, "```"] |
| 55 | |
| 56 | const renderChange = (change: ApiChange, level = 4): ReadonlyArray<string> => [ |
| 57 | `${"#".repeat(level)} ${change.classification}: \`${changeLabel(change)}\``, |
| 58 | "", |
| 59 | `Confidence: ${change.confidence.toFixed(3)} · ${change.authoritative ? "authoritative" : "review required"}`, |
| 60 | ...(change.reviewNotes === undefined ? [] : ["", change.reviewNotes]), |
| 61 | ...signature("Before", change.before), |
| 62 | ...signature("After", change.after), |
| 63 | "" |
| 64 | ] |
| 65 | |
| 66 | const orderedGroups = [ |
| 67 | ["Renames and moves", new Set(["api-moved", "api-renamed"])], |
| 68 | ["Removals", new Set(["package-removed", "module-removed", "api-removed"])], |
| 69 | ["Additions", new Set(["package-added", "module-added", "api-added"])], |
| 70 | [ |
| 71 | "Signature and structural changes", |
| 72 | new Set([ |
| 73 | "bucket-changed", |
| 74 | "declaration-kind-changed", |
| 75 | "overload-added", |
| 76 | "overload-removed", |
| 77 | "overload-reordered", |
| 78 | "parameter-added", |
| 79 | "parameter-removed", |
| 80 | "parameter-reordered", |
| 81 | "parameter-changed", |
| 82 | "return-type-changed", |
| 83 | "generic-parameter-changed", |
| 84 | "member-added", |
| 85 | "member-removed", |
| 86 | "member-changed", |
| 87 | "heritage-changed", |
| 88 | "union-member-changed", |
| 89 | "intersection-member-changed", |
| 90 | "structural-change" |
| 91 | ]) |
| 92 | ], |
| 93 | ["Documentation changes", new Set(["documentation-changed"])] |
| 94 | ] as const |
| 95 | |
| 96 | export const renderMarkdownReport = (diff: ApiDiff): string => { |
| 97 | const counts = new Map<string, number>() |
| 98 | for (const change of diff.changes) { |
| 99 | counts.set(change.classification, (counts.get(change.classification) ?? 0) + 1) |
| 100 | } |
| 101 | const authoritative = diff.changes.filter((change) => change.authoritative) |
| 102 | const suggested = diff.changes.filter((change) => !change.authoritative) |
| 103 | const moduleCounts = new Map<string, number>() |
| 104 | for (const change of diff.changes) { |
| 105 | const module = changeModule(change) |
| 106 | moduleCounts.set(module, (moduleCounts.get(module) ?? 0) + 1) |
| 107 | } |
| 108 | const lines: Array<string> = [ |
| 109 | "# TypeScript API Diff", |
| 110 | "", |
| 111 | `Base: \`${diff.base.ref}\` (\`${diff.base.sha}\`)`, |
| 112 | "", |
| 113 | `Head: \`${diff.head.ref}\` (\`${diff.head.sha}\`)`, |
| 114 | "", |
| 115 | "This report describes structural API changes and review confidence; it is not a semantic-version compatibility claim.", |
| 116 | "", |
| 117 | "## Summary", |
| 118 | "", |
| 119 | "| Classification | Count |", |
| 120 | "| --- | ---: |", |
| 121 | ...[...counts].sort(([left], [right]) => left.localeCompare(right)) |
| 122 | .map(([classification, count]) => `| ${escapeCell(classification)} | ${count} |`), |
| 123 | "", |
| 124 | "## Changes by module", |
| 125 | "", |
| 126 | "| Domain | Module | Count |", |
| 127 | "| --- | --- | ---: |", |
| 128 | ...[...moduleCounts].sort(([left], [right]) => left.localeCompare(right)).map(([module, count]) => { |
| 129 | const unstable = unstableDomain(module) |
| 130 | const domain = unstable === undefined |
| 131 | ? module.startsWith("@effect/") |
| 132 | ? module.split("/").slice(0, 2).join("/") |
| 133 | : "stable" |
| 134 | : `unstable/${unstable}` |
| 135 | return `| ${escapeCell(domain)} | ${escapeCell(module)} | ${count} |` |
| 136 | }), |
| 137 | "" |
| 138 | ] |
| 139 | const sections = [ |
| 140 | [ |
| 141 | "Stable API changes", |
| 142 | authoritative.filter((change) => !isUnstableChange(change)) |
| 143 | ], |
| 144 | [ |
| 145 | "Unstable API changes", |
| 146 | authoritative.filter(isUnstableChange) |
| 147 | ] |
| 148 | ] as const |
| 149 | for (const [section, sectionChanges] of sections) { |
| 150 | if (sectionChanges.length === 0) { |
| 151 | continue |
| 152 | } |
| 153 | lines.push(`## ${section}`, "") |
| 154 | for (const [title, classifications] of orderedGroups) { |
| 155 | const changes = sectionChanges.filter((change) => classifications.has(change.classification)) |
| 156 | if (changes.length === 0) { |
| 157 | continue |
| 158 | } |
| 159 | lines.push(`### ${title}`, "") |
| 160 | for (const change of changes) { |
| 161 | lines.push(...renderChange(change)) |
| 162 | } |
| 163 | } |
| 164 | } |
| 165 | if (suggested.length > 0) { |
| 166 | lines.push("## Suggested replacements for removed APIs", "") |
| 167 | for (const change of suggested) { |
| 168 | lines.push(...renderChange(change, 3)) |
| 169 | } |
| 170 | } |
| 171 | return `${lines.join("\n").trim()}\n` |
| 172 | } |