1import * as Console from "effect/Console"
2import * as Context from "effect/Context"
3import * as Effect from "effect/Effect"
4import * as FileSystem from "effect/FileSystem"
5import * as Layer from "effect/Layer"
6import * as Path from "effect/Path"
7import { loadAnnotations } from "./Annotations.ts"
8import { diffSnapshots } from "./Diff.ts"
9import { ApiDiffError, isApiDiffError } from "./Error.ts"
10import { prettyJson } from "./Json.ts"
11import {
12 extractImportMapSections,
13 markdownSafetyIssues,
14 renderMigrationDocument,
15 renderMissingAnnotations,
16 unannotatedApiIds,
17 unannotatedModuleIds
18} from "./MigrationDoc.ts"
19import { renderMarkdownReport } from "./Report.ts"
20import { Worktrees } from "./Worktrees.ts"
21
22export interface ApiDiffOptions {
23 readonly baseRef: string
24 readonly headRef: string
25 readonly output?: string | undefined
26 readonly writeDoc?: string | undefined
27 readonly check: boolean
28}
29
30export class ApiDiff extends Context.Service<ApiDiff, {
31 readonly run: (options: ApiDiffOptions) => Effect.Effect<void, ApiDiffError>
32}>()("@effect/api-diff/ApiDiff") {
33 static readonly layerNoDependencies = Layer.effect(
34 ApiDiff,
35 Effect.gen(function*() {
36 const fs = yield* FileSystem.FileSystem
37 const path = yield* Path.Path
38 const worktrees = yield* Worktrees
39
40 const absolute = (repoRoot: string, location: string): string =>
41 path.isAbsolute(location) ? location : path.resolve(repoRoot, location)
42
43 const findRepoRoot = Effect.fnUntraced(function*() {
44 let current = path.resolve()
45 while (true) {
46 if (
47 (yield* fs.exists(path.join(current, ".git"))) ||
48 (yield* fs.exists(path.join(current, "pnpm-workspace.yaml")))
49 ) {
50 return current
51 }
52 const parent = path.dirname(current)
53 if (parent === current) {
54 return yield* new ApiDiffError({
55 message: `Could not locate repository root from ${path.resolve()}`
56 })
57 }
58 current = parent
59 }
60 })
61
62 const runInternal = Effect.fnUntraced(function*(options: ApiDiffOptions) {
63 const repoRoot = yield* findRepoRoot()
64 if (options.output === undefined && options.writeDoc === undefined && !options.check) {
65 return yield* new ApiDiffError({ message: "Specify --output, --write-doc, or --check" })
66 }
67
68 const baseSha = yield* worktrees.resolveRef(repoRoot, options.baseRef)
69 const headSha = yield* (options.headRef === "main"
70 ? worktrees.resolveRef(repoRoot, "origin/main").pipe(
71 Effect.catch(() => worktrees.resolveRef(repoRoot, options.headRef))
72 )
73 : worktrees.resolveRef(repoRoot, options.headRef))
74 const toolRoot = path.join(repoRoot, "tmp", "api-diff")
75 const cacheRoot = path.join(toolRoot, "cache")
76 const worktreesRoot = path.join(toolRoot, "worktrees")
77 yield* Console.log(
78 `Base ${options.baseRef}: ${baseSha}\nHead ${options.headRef}: ${headSha}`
79 )
80 const base = yield* worktrees.prepareSnapshot({
81 repoRoot,
82 cacheRoot,
83 worktreesRoot,
84 name: "base",
85 ref: options.baseRef,
86 sha: baseSha
87 })
88 const head = yield* worktrees.prepareSnapshot({
89 repoRoot,
90 cacheRoot,
91 worktreesRoot,
92 name: "head",
93 ref: options.headRef,
94 sha: headSha
95 })
96 const diff = diffSnapshots(base, head)
97
98 if (options.output !== undefined) {
99 const output = absolute(repoRoot, options.output)
100 yield* fs.makeDirectory(output, { recursive: true })
101 yield* fs.writeFileString(path.join(output, "base.snapshot.json"), prettyJson(base))
102 yield* fs.writeFileString(path.join(output, "head.snapshot.json"), prettyJson(head))
103 yield* fs.writeFileString(path.join(output, "diff.json"), prettyJson(diff))
104 yield* fs.writeFileString(path.join(output, "report.md"), renderMarkdownReport(diff))
105 yield* Console.log(`Wrote ${path.relative(repoRoot, output)} (${diff.changes.length} changes)`)
106 }
107
108 if (options.writeDoc !== undefined || options.check) {
109 const annotations = yield* loadAnnotations(path.join(repoRoot, "migration", "annotations")).pipe(
110 Effect.provideService(FileSystem.FileSystem, fs),
111 Effect.provideService(Path.Path, path)
112 )
113 const documentPath = options.writeDoc === undefined
114 ? path.join(repoRoot, "migration", "v3-to-v4.md")
115 : absolute(repoRoot, options.writeDoc)
116 const existing = (yield* fs.exists(documentPath)) ? yield* fs.readFileString(documentPath) : ""
117 const importMapSections = extractImportMapSections(existing)
118 let documentForCheck = existing
119 if (options.writeDoc !== undefined) {
120 const document = renderMigrationDocument(diff, annotations, importMapSections)
121 const unsafeMarkdown = markdownSafetyIssues(document)
122 if (unsafeMarkdown.length > 0) {
123 return yield* new ApiDiffError({
124 message: `Generated migration document has unsafe Markdown:\n${unsafeMarkdown.join("\n")}`
125 })
126 }
127 documentForCheck = document
128 yield* fs.makeDirectory(path.dirname(documentPath), { recursive: true })
129 yield* fs.writeFileString(documentPath, document)
130 yield* Console.log(`Wrote ${path.relative(repoRoot, documentPath)} (${diff.changes.length} changes)`)
131 }
132 if (options.check) {
133 const unsafeMarkdown = markdownSafetyIssues(documentForCheck)
134 if (unsafeMarkdown.length > 0) {
135 return yield* new ApiDiffError({
136 message: `Migration document has unsafe Markdown:\n${unsafeMarkdown.join("\n")}`
137 })
138 }
139 const missingApis = unannotatedApiIds(diff, annotations, importMapSections)
140 const missingModules = unannotatedModuleIds(diff, annotations, importMapSections)
141 yield* Console.log(renderMissingAnnotations(diff, annotations, importMapSections).trimEnd())
142 if (missingApis.size > 0 || missingModules.length > 0) {
143 return yield* new ApiDiffError({
144 message: `${missingApis.size} API groups and ${missingModules.length} modules need migration guidance`
145 })
146 }
147 }
148 }
149 })
150
151 const run = (options: ApiDiffOptions): Effect.Effect<void, ApiDiffError> =>
152 runInternal(options).pipe(
153 Effect.mapError((cause) =>
154 isApiDiffError(cause)
155 ? cause
156 : new ApiDiffError({
157 message: "API diff failed",
158 cause
159 })
160 )
161 )
162
163 return ApiDiff.of({ run })
164 })
165 )
166
167 static readonly layer = this.layerNoDependencies.pipe(
168 Layer.provide(Worktrees.layer)
169 )
170}