1import * as Context from "effect/Context"
2import * as Effect from "effect/Effect"
3import * as FileSystem from "effect/FileSystem"
4import * as Layer from "effect/Layer"
5import * as Path from "effect/Path"
6import type * as PlatformError from "effect/PlatformError"
7import * as Schema from "effect/Schema"
8import { ApiDiffError } from "./Error.ts"
9import type { Entrypoint } from "./Model.ts"
10
11const PackageManifest = Schema.Struct({
12 name: Schema.optional(Schema.String),
13 private: Schema.optional(Schema.Boolean),
14 exports: Schema.optional(Schema.Unknown),
15 publishConfig: Schema.optional(Schema.Struct({
16 exports: Schema.optional(Schema.Unknown)
17 }))
18})
19
20type PackageManifest = typeof PackageManifest.Type
21
22interface PackageInfo {
23 readonly name: string
24 readonly root: string
25 readonly manifest: PackageManifest
26 readonly exports: unknown
27}
28
29export interface DiscoveryResult {
30 readonly entrypoints: ReadonlyArray<Entrypoint>
31 readonly missing: ReadonlyArray<string>
32}
33
34const decodePackageManifest = Schema.decodeUnknownEffect(Schema.fromJsonString(PackageManifest))
35
36const exportedTarget = (value: unknown): string | null | undefined => {
37 if (value === null || typeof value === "string") {
38 return value
39 }
40 if (typeof value !== "object" || value === null) {
41 return undefined
42 }
43 for (const condition of ["types", "import", "default", "node", "browser"]) {
44 const target = exportedTarget(Reflect.get(value, condition))
45 if (target !== undefined) {
46 return target
47 }
48 }
49 return undefined
50}
51
52const matchPattern = (pattern: string, value: string): string | undefined => {
53 const star = pattern.indexOf("*")
54 if (star === -1) {
55 return pattern === value ? "" : undefined
56 }
57 const prefix = pattern.slice(0, star)
58 const suffix = pattern.slice(star + 1)
59 return value.startsWith(prefix) && value.endsWith(suffix)
60 ? value.slice(prefix.length, value.length - suffix.length)
61 : undefined
62}
63
64export class Discovery extends Context.Service<Discovery, {
65 readonly discoverEntrypoints: (
66 repoRoot: string,
67 requestedModules?: ReadonlyArray<string>
68 ) => Effect.Effect<DiscoveryResult, ApiDiffError>
69}>()("@effect/api-diff/Discovery") {
70 static readonly layer = Layer.effect(
71 Discovery,
72 Effect.gen(function*() {
73 const fs = yield* FileSystem.FileSystem
74 const path = yield* Path.Path
75
76 const readManifest = Effect.fnUntraced(function*(location: string) {
77 const source = yield* fs.readFileString(location)
78 return yield* decodePackageManifest(source)
79 })
80
81 const walk = Effect.fnUntraced(function*(
82 root: string,
83 predicate: (location: string) => boolean
84 ) {
85 const output: Array<string> = []
86 const visit: (directory: string) => Effect.Effect<void, PlatformError.PlatformError> = Effect.fnUntraced(
87 function*(directory: string) {
88 const entries = yield* fs.readDirectory(directory)
89 for (const entry of entries) {
90 if (entry === "node_modules" || entry === ".git") {
91 continue
92 }
93 const location = path.join(directory, entry)
94 const info = yield* fs.stat(location)
95 if (info.type === "Directory") {
96 yield* visit(location)
97 } else if (predicate(location)) {
98 output.push(location)
99 }
100 }
101 }
102 )
103 if (yield* fs.exists(root)) {
104 yield* visit(root)
105 }
106 return output.sort()
107 })
108
109 const packagesIn = Effect.fnUntraced(function*(repoRoot: string) {
110 const manifestPaths = yield* walk(
111 path.join(repoRoot, "packages"),
112 (location) => location.endsWith(`${path.sep}package.json`)
113 )
114 const packages: Array<PackageInfo> = []
115 for (const manifestPath of manifestPaths) {
116 const sourceRoot = path.dirname(manifestPath)
117 if (
118 path.basename(sourceRoot) === "dist" &&
119 (yield* fs.exists(path.join(path.dirname(sourceRoot), "package.json")))
120 ) {
121 continue
122 }
123 const packedPath = path.join(sourceRoot, "dist", "package.json")
124 const packed = yield* fs.exists(packedPath)
125 const manifest = yield* readManifest(packed ? packedPath : manifestPath)
126 if (manifest.name === undefined || manifest.private === true) {
127 continue
128 }
129 packages.push({
130 name: manifest.name,
131 root: packed ? path.join(sourceRoot, "dist") : sourceRoot,
132 exports: packed ? manifest.exports : (manifest.publishConfig?.exports ?? manifest.exports),
133 manifest
134 })
135 }
136 return packages.sort((left, right) => right.name.length - left.name.length)
137 })
138
139 const targetToDeclaration = (packageInfo: PackageInfo, target: string): string | undefined => {
140 const relativeTarget = target.replace(/^\.\//, "")
141 const declaration = relativeTarget.endsWith(".d.ts")
142 ? relativeTarget
143 : relativeTarget.replace(/\.(?:mjs|cjs|js|mts|cts|ts)$/, ".d.ts")
144 return declaration.endsWith(".d.ts") ? path.resolve(packageInfo.root, declaration) : undefined
145 }
146
147 const expandPattern = Effect.fnUntraced(function*(
148 packageInfo: PackageInfo,
149 keyPattern: string,
150 targetPattern: string,
151 requested: ReadonlySet<string> | undefined,
152 exportsMap: Readonly<Record<string, unknown>>
153 ) {
154 const candidates = yield* walk(packageInfo.root, (location) => location.endsWith(".d.ts"))
155 const targetNormalized = targetPattern.endsWith(".d.ts")
156 ? targetPattern
157 : targetPattern.replace(/\.(?:mjs|cjs|js|mts|cts|ts)$/, ".d.ts")
158 const entries: Array<Entrypoint> = []
159 for (const declarationFile of candidates) {
160 const relativeFile = `./${path.relative(packageInfo.root, declarationFile).split(path.sep).join("/")}`
161 const capture = matchPattern(targetNormalized, relativeFile)
162 if (capture === undefined) {
163 continue
164 }
165 const key = keyPattern.replace("*", capture)
166 const module = key === "." ? packageInfo.name : `${packageInfo.name}${key.slice(1)}`
167 const excluded = Object.entries(exportsMap).some(([excludedKey, excludedTarget]) =>
168 excludedTarget === null && matchPattern(excludedKey, key) !== undefined
169 )
170 if (!excluded && (requested === undefined || requested.has(module))) {
171 entries.push({ packageName: packageInfo.name, module, declarationFile })
172 }
173 }
174 return entries
175 })
176
177 const discoverEntrypointsInternal = Effect.fnUntraced(function*(
178 repoRoot: string,
179 requestedModules?: ReadonlyArray<string>
180 ) {
181 const packages = yield* packagesIn(repoRoot)
182 const requested = requestedModules === undefined ? undefined : new Set(requestedModules)
183 const output = new Map<string, Entrypoint>()
184
185 for (const packageInfo of packages) {
186 if (typeof packageInfo.exports !== "object" || packageInfo.exports === null) {
187 continue
188 }
189 const exportsMap = packageInfo.exports as Record<string, unknown>
190 for (const [keyPattern, rawTarget] of Object.entries(exportsMap)) {
191 const target = exportedTarget(rawTarget)
192 if (
193 !keyPattern.includes("*") &&
194 typeof target === "string" &&
195 (requested === undefined || requested.has(
196 keyPattern === "." ? packageInfo.name : `${packageInfo.name}${keyPattern.slice(1)}`
197 ))
198 ) {
199 const module = keyPattern === "." ? packageInfo.name : `${packageInfo.name}${keyPattern.slice(1)}`
200 const declarationFile = targetToDeclaration(packageInfo, target)
201 if (
202 declarationFile !== undefined &&
203 !output.has(module) &&
204 (yield* fs.exists(declarationFile)) &&
205 (yield* fs.stat(declarationFile)).type === "File"
206 ) {
207 output.set(module, { packageName: packageInfo.name, module, declarationFile })
208 }
209 }
210 if (keyPattern.includes("*") && typeof target === "string" && target.includes("*")) {
211 for (const entry of yield* expandPattern(packageInfo, keyPattern, target, requested, exportsMap)) {
212 if (!output.has(entry.module)) {
213 output.set(entry.module, entry)
214 }
215 }
216 }
217 }
218 }
219
220 return {
221 entrypoints: [...output.values()].sort((left, right) => left.module.localeCompare(right.module)),
222 missing: requestedModules?.filter((module) => !output.has(module)).sort() ?? []
223 }
224 })
225
226 const discoverEntrypoints = (
227 repoRoot: string,
228 requestedModules?: ReadonlyArray<string>
229 ): Effect.Effect<DiscoveryResult, ApiDiffError> =>
230 discoverEntrypointsInternal(repoRoot, requestedModules).pipe(
231 Effect.mapError((cause) =>
232 new ApiDiffError({
233 message: `Could not discover API entrypoints in ${repoRoot}`,
234 cause
235 })
236 )
237 )
238
239 return Discovery.of({ discoverEntrypoints })
240 })
241 )
242}