1import type { ApiChange, ApiDiff } from "./Model.ts"
2
3const 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
26const 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
32const 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
42const isUnstableChange = (change: ApiChange): boolean => unstableDomain(changeModule(change)) !== undefined
43
44const escapeCell = (value: string): string => value.replaceAll("|", "\\|").replaceAll("\n", " ")
45
46const 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
53const signature = (label: string, source: string | undefined): ReadonlyArray<string> =>
54 source === undefined ? [] : ["", `${label}:`, "", "```ts", source, "```"]
55
56const 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
66const 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
96export 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}