1import * as Context from "effect/Context"
2import * as Effect from "effect/Effect"
3import * as Layer from "effect/Layer"
4import * as Path from "effect/Path"
5import * as Predicate from "effect/Predicate"
6import * as Schema from "effect/Schema"
7import ts from "typescript-compiler"
8import { Discovery, type DiscoveryResult } from "./Discovery.ts"
9import { ApiDiffError } from "./Error.ts"
10import { fingerprint, stableJson } from "./Json.ts"
11import { SnapshotDiagnostic as SnapshotDiagnosticSchema } from "./Model.ts"
12import type {
13 ApiEntity,
14 ApiSnapshot,
15 Bucket,
16 DeclarationModel,
17 Documentation,
18 Entrypoint,
19 ImportRoute,
20 ParameterModel,
21 SnapshotDiagnostic,
22 SourceLocation,
23 TypeModel,
24 TypeParameterModel
25} from "./Model.ts"
26
27interface SerializationContext {
28 readonly checker: ts.TypeChecker
29 readonly path: Path.Path
30 readonly repoRoot: string
31 readonly typeParameters: ReadonlyMap<string, string>
32 readonly publicSymbols: ReadonlyMap<string, string>
33}
34
35const normalizedPath = (path: Path.Path, root: string, location: string): string =>
36 path.relative(root, location).split(path.sep).join("/")
37
38const sourceLocation = (path: Path.Path, root: string, node: ts.Node): SourceLocation => {
39 const source = node.getSourceFile()
40 const position = source.getLineAndCharacterOfPosition(node.getStart(source, false))
41 return {
42 file: normalizedPath(path, root, source.fileName),
43 line: position.line + 1,
44 column: position.character + 1
45 }
46}
47
48const modifiers = (node: ts.Node): ReadonlyArray<string> | undefined => {
49 if (!ts.canHaveModifiers(node)) {
50 return undefined
51 }
52 const values = ts.getModifiers(node)?.map((modifier) =>
53 ts.tokenToString(modifier.kind) ?? ts.SyntaxKind[modifier.kind]
54 )
55 .filter((modifier) => modifier !== "export" && modifier !== "declare")
56 .sort()
57 return values === undefined || values.length === 0 ? undefined : values
58}
59
60const nameText = (name: ts.DeclarationName | ts.BindingName | undefined): string => {
61 if (name === undefined) {
62 return "<anonymous>"
63 }
64 if (ts.isIdentifier(name) || ts.isPrivateIdentifier(name)) {
65 return name.text
66 }
67 if (ts.isStringLiteral(name) || ts.isNumericLiteral(name)) {
68 return name.text
69 }
70 return name.getText()
71}
72
73const sortModels = (values: ReadonlyArray<TypeModel>): ReadonlyArray<TypeModel> =>
74 [...values].sort((left, right) => stableJson(left).localeCompare(stableJson(right)))
75
76const referenceResolution = (name: ts.EntityName, context: SerializationContext): unknown => {
77 let symbol = context.checker.getSymbolAtLocation(name)
78 if (symbol === undefined) {
79 return { unresolved: true }
80 }
81 if ((symbol.flags & ts.SymbolFlags.Alias) !== 0) {
82 symbol = context.checker.getAliasedSymbol(symbol)
83 }
84 const declaration = symbol?.declarations?.[0]
85 if (declaration === undefined) {
86 return { unresolved: true }
87 }
88 const publicApiId = context.publicSymbols.get(symbolIdentity(symbol, "type")) ??
89 context.publicSymbols.get(symbolIdentity(symbol, "value"))
90 if (publicApiId !== undefined) {
91 return { apiId: publicApiId }
92 }
93 const file = declaration.getSourceFile().fileName
94 const separator = context.path.sep
95 const nodeModules = file.lastIndexOf(`${separator}node_modules${separator}`)
96 if (nodeModules !== -1) {
97 const remainder = file.slice(nodeModules + `${separator}node_modules${separator}`.length).split(separator)
98 return {
99 externalPackage: remainder[0]?.startsWith("@") ? remainder.slice(0, 2).join("/") : remainder[0]
100 }
101 }
102 return { declaration: normalizedPath(context.path, context.repoRoot, file) }
103}
104
105const primitiveKinds = new Map<ts.SyntaxKind, string>([
106 [ts.SyntaxKind.AnyKeyword, "any"],
107 [ts.SyntaxKind.BigIntKeyword, "bigint"],
108 [ts.SyntaxKind.BooleanKeyword, "boolean"],
109 [ts.SyntaxKind.IntrinsicKeyword, "intrinsic"],
110 [ts.SyntaxKind.NeverKeyword, "never"],
111 [ts.SyntaxKind.NumberKeyword, "number"],
112 [ts.SyntaxKind.ObjectKeyword, "object"],
113 [ts.SyntaxKind.StringKeyword, "string"],
114 [ts.SyntaxKind.SymbolKeyword, "symbol"],
115 [ts.SyntaxKind.UndefinedKeyword, "undefined"],
116 [ts.SyntaxKind.UnknownKeyword, "unknown"],
117 [ts.SyntaxKind.VoidKeyword, "void"]
118])
119
120const serializeTypeParameters = (
121 nodes: ts.NodeArray<ts.TypeParameterDeclaration> | undefined,
122 context: SerializationContext
123): { readonly parameters?: ReadonlyArray<TypeParameterModel>; readonly context: SerializationContext } => {
124 if (nodes === undefined || nodes.length === 0) {
125 return { context }
126 }
127 const names = new Map(context.typeParameters)
128 nodes.forEach((node, index) => names.set(node.name.text, `T${index}`))
129 const next = { ...context, typeParameters: names }
130 return {
131 context: next,
132 parameters: nodes.map((node, index) => ({
133 id: `T${index}`,
134 displayName: node.name.text,
135 constraint: node.constraint === undefined ? undefined : serializeType(node.constraint, next),
136 default: node.default === undefined ? undefined : serializeType(node.default, next)
137 }))
138 }
139}
140
141const serializeParameters = (
142 nodes: ts.NodeArray<ts.ParameterDeclaration>,
143 context: SerializationContext
144): ReadonlyArray<ParameterModel> =>
145 nodes.map((node) => ({
146 name: nameText(node.name),
147 type: node.type === undefined ? { kind: "unknown" } : serializeType(node.type, context),
148 optional: node.questionToken !== undefined || node.initializer !== undefined,
149 rest: node.dotDotDotToken !== undefined,
150 modifiers: modifiers(node)
151 }))
152
153const serializeSignature = (
154 node: ts.SignatureDeclarationBase,
155 context: SerializationContext,
156 kind: string,
157 name = "<call>"
158): DeclarationModel => {
159 const generic = serializeTypeParameters(node.typeParameters, context)
160 return {
161 kind,
162 name,
163 typeParameters: generic.parameters,
164 parameters: serializeParameters(node.parameters, generic.context),
165 returnType: node.type === undefined ? { kind: "unknown" } : serializeType(node.type, generic.context),
166 modifiers: modifiers(node)
167 }
168}
169
170const serializeTypeMember = (node: ts.TypeElement, context: SerializationContext): DeclarationModel => {
171 if (ts.isPropertySignature(node)) {
172 return {
173 kind: "property",
174 name: nameText(node.name),
175 type: node.type === undefined ? { kind: "unknown" } : serializeType(node.type, context),
176 modifiers: [
177 ...(modifiers(node) ?? []),
178 ...(node.questionToken === undefined ? [] : ["optional"])
179 ].sort()
180 }
181 }
182 if (ts.isMethodSignature(node)) {
183 return {
184 ...serializeSignature(node, context, "method", nameText(node.name)),
185 modifiers: [
186 ...(modifiers(node) ?? []),
187 ...(node.questionToken === undefined ? [] : ["optional"])
188 ].sort()
189 }
190 }
191 if (ts.isCallSignatureDeclaration(node)) {
192 return serializeSignature(node, context, "call-signature")
193 }
194 if (ts.isConstructSignatureDeclaration(node)) {
195 return serializeSignature(node, context, "construct-signature")
196 }
197 if (ts.isIndexSignatureDeclaration(node)) {
198 return serializeSignature(node, context, "index-signature")
199 }
200 if (ts.isGetAccessorDeclaration(node)) {
201 return serializeSignature(node, context, "getter", nameText(node.name))
202 }
203 if (ts.isSetAccessorDeclaration(node)) {
204 return serializeSignature(node, context, "setter", nameText(node.name))
205 }
206 throw new Error(`Unsupported public type member: ${ts.SyntaxKind[node.kind]}`)
207}
208
209const serializeObjectMembers = (
210 members: ts.NodeArray<ts.TypeElement>,
211 context: SerializationContext
212): ReadonlyArray<DeclarationModel> =>
213 members.map((member) => serializeTypeMember(member, context))
214 .sort((left, right) =>
215 `${left.kind}:${left.name}:${stableJson(left)}`.localeCompare(
216 `${right.kind}:${right.name}:${stableJson(right)}`
217 )
218 )
219
220export const serializeType = (node: ts.TypeNode, context: SerializationContext): TypeModel => {
221 const primitive = primitiveKinds.get(node.kind)
222 if (primitive !== undefined) {
223 return { kind: "primitive", name: primitive }
224 }
225 if (ts.isParenthesizedTypeNode(node)) {
226 return serializeType(node.type, context)
227 }
228 if (ts.isLiteralTypeNode(node)) {
229 const literal = node.literal
230 const operand = ts.isPrefixUnaryExpression(literal) ? literal.operand : literal
231 return {
232 kind: "literal",
233 literalKind: ts.isStringLiteralLike(operand)
234 ? "string"
235 : ts.isNumericLiteral(operand)
236 ? "number"
237 : ts.isBigIntLiteral(operand)
238 ? "bigint"
239 : operand.kind === ts.SyntaxKind.NullKeyword
240 ? "null"
241 : "boolean",
242 value: literal.kind === ts.SyntaxKind.TrueKeyword
243 ? true
244 : literal.kind === ts.SyntaxKind.FalseKeyword
245 ? false
246 : ts.isPrefixUnaryExpression(literal)
247 ? literal.getText()
248 : Reflect.get(literal, "text") ?? literal.getText()
249 }
250 }
251 if (ts.isTypeReferenceNode(node)) {
252 const rawName = node.typeName.getText()
253 const genericId = context.typeParameters.get(rawName)
254 if (genericId !== undefined) {
255 return { kind: "type-parameter", id: genericId, displayName: rawName }
256 }
257 return {
258 kind: "reference",
259 name: rawName,
260 arguments: node.typeArguments?.map((argument) => serializeType(argument, context)) ?? [],
261 resolution: referenceResolution(node.typeName, context)
262 }
263 }
264 if (ts.isUnionTypeNode(node) || ts.isIntersectionTypeNode(node)) {
265 return {
266 kind: ts.isUnionTypeNode(node) ? "union" : "intersection",
267 members: sortModels(node.types.map((type) => serializeType(type, context)))
268 }
269 }
270 if (ts.isArrayTypeNode(node)) {
271 return { kind: "array", element: serializeType(node.elementType, context) }
272 }
273 if (ts.isTupleTypeNode(node)) {
274 return { kind: "tuple", elements: node.elements.map((element) => serializeType(element, context)) }
275 }
276 if (ts.isNamedTupleMember(node)) {
277 return {
278 kind: "named-tuple-member",
279 name: node.name.text,
280 optional: node.questionToken !== undefined,
281 rest: node.dotDotDotToken !== undefined,
282 type: serializeType(node.type, context)
283 }
284 }
285 if (ts.isOptionalTypeNode(node)) {
286 return { kind: "optional", type: serializeType(node.type, context) }
287 }
288 if (ts.isRestTypeNode(node)) {
289 return { kind: "rest", type: serializeType(node.type, context) }
290 }
291 if (ts.isFunctionTypeNode(node) || ts.isConstructorTypeNode(node)) {
292 return {
293 kind: ts.isFunctionTypeNode(node) ? "function" : "constructor",
294 signature: serializeSignature(node, context, "signature")
295 }
296 }
297 if (ts.isTypeLiteralNode(node)) {
298 return { kind: "object", members: serializeObjectMembers(node.members, context) }
299 }
300 if (ts.isMappedTypeNode(node)) {
301 const generic = serializeTypeParameters(ts.factory.createNodeArray([node.typeParameter]), context)
302 return {
303 kind: "mapped",
304 typeParameter: generic.parameters?.[0],
305 readonly: node.readonlyToken?.kind === ts.SyntaxKind.MinusToken
306 ? "remove"
307 : node.readonlyToken === undefined
308 ? "none"
309 : "add",
310 optional: node.questionToken?.kind === ts.SyntaxKind.MinusToken
311 ? "remove"
312 : node.questionToken === undefined
313 ? "none"
314 : "add",
315 nameType: node.nameType === undefined ? undefined : serializeType(node.nameType, generic.context),
316 type: node.type === undefined ? undefined : serializeType(node.type, generic.context)
317 }
318 }
319 if (ts.isConditionalTypeNode(node)) {
320 return {
321 kind: "conditional",
322 check: serializeType(node.checkType, context),
323 extends: serializeType(node.extendsType, context),
324 trueType: serializeType(node.trueType, context),
325 falseType: serializeType(node.falseType, context)
326 }
327 }
328 if (ts.isIndexedAccessTypeNode(node)) {
329 return {
330 kind: "indexed-access",
331 object: serializeType(node.objectType, context),
332 index: serializeType(node.indexType, context)
333 }
334 }
335 if (ts.isTypeOperatorNode(node)) {
336 return {
337 kind: "operator",
338 operator: ts.tokenToString(node.operator) ?? ts.SyntaxKind[node.operator],
339 type: serializeType(node.type, context)
340 }
341 }
342 if (ts.isTypeQueryNode(node)) {
343 return { kind: "type-query", expression: node.exprName.getText() }
344 }
345 if (ts.isImportTypeNode(node)) {
346 const argument = node.argument.getText().replace(/^["']|["']$/g, "")
347 const parts = argument.split("/")
348 return {
349 kind: "import",
350 argument,
351 externalPackage: argument.startsWith(".")
352 ? undefined
353 : parts[0]?.startsWith("@")
354 ? parts.slice(0, 2).join("/")
355 : parts[0],
356 qualifier: node.qualifier?.getText(),
357 attributes: node.attributes?.getText(),
358 arguments: node.typeArguments?.map((argument) => serializeType(argument, context)) ?? [],
359 typeof: node.isTypeOf
360 }
361 }
362 if (ts.isTemplateLiteralTypeNode(node)) {
363 return {
364 kind: "template-literal",
365 head: node.head.text,
366 spans: node.templateSpans.map((span) => ({
367 type: serializeType(span.type, context),
368 literal: span.literal.text
369 }))
370 }
371 }
372 if (ts.isInferTypeNode(node)) {
373 const generic = serializeTypeParameters(ts.factory.createNodeArray([node.typeParameter]), context)
374 return { kind: "infer", typeParameter: generic.parameters?.[0] }
375 }
376 if (ts.isTypePredicateNode(node)) {
377 return {
378 kind: "predicate",
379 asserts: node.assertsModifier !== undefined,
380 parameter: node.parameterName.getText(),
381 type: node.type === undefined ? undefined : serializeType(node.type, context)
382 }
383 }
384 if (ts.isExpressionWithTypeArguments(node)) {
385 return {
386 kind: "heritage-reference",
387 name: node.expression.getText(),
388 arguments: node.typeArguments?.map((argument) => serializeType(argument, context)) ?? []
389 }
390 }
391 if (node.kind === ts.SyntaxKind.ThisType) {
392 return { kind: "this" }
393 }
394 throw new Error(`Unsupported public type syntax: ${ts.SyntaxKind[node.kind]}`)
395}
396
397const serializeClassMember = (
398 node: ts.ClassElement,
399 context: SerializationContext
400): DeclarationModel | undefined => {
401 const visibility = modifiers(node)
402 if (visibility?.includes("private")) {
403 return undefined
404 }
405 if (ts.isPropertyDeclaration(node)) {
406 return {
407 kind: "property",
408 name: nameText(node.name),
409 type: node.type === undefined ? { kind: "unknown" } : serializeType(node.type, context),
410 modifiers: node.questionToken === undefined ? visibility : [...(visibility ?? []), "optional"].sort()
411 }
412 }
413 if (ts.isMethodDeclaration(node)) {
414 return {
415 ...serializeSignature(node, context, "method", nameText(node.name)),
416 modifiers: node.questionToken === undefined ? visibility : [...(visibility ?? []), "optional"].sort()
417 }
418 }
419 if (ts.isConstructorDeclaration(node)) {
420 return serializeSignature(node, context, "constructor", "constructor")
421 }
422 if (ts.isGetAccessorDeclaration(node)) {
423 return serializeSignature(node, context, "getter", nameText(node.name))
424 }
425 if (ts.isSetAccessorDeclaration(node)) {
426 return serializeSignature(node, context, "setter", nameText(node.name))
427 }
428 if (ts.isIndexSignatureDeclaration(node)) {
429 return serializeSignature(node, context, "index-signature")
430 }
431 if (ts.isClassStaticBlockDeclaration(node) || ts.isSemicolonClassElement(node)) {
432 return undefined
433 }
434 throw new Error(`Unsupported public class member: ${ts.SyntaxKind[node.kind]}`)
435}
436
437const serializeHeritage = (
438 clauses: ts.NodeArray<ts.HeritageClause> | undefined,
439 context: SerializationContext
440): ReadonlyArray<TypeModel> | undefined => {
441 if (clauses === undefined) {
442 return undefined
443 }
444 return clauses.flatMap((clause) =>
445 clause.types.map((type) => ({
446 ...serializeType(type, context),
447 clause: ts.tokenToString(clause.token) ?? ts.SyntaxKind[clause.token]
448 }))
449 ).sort((left, right) => stableJson(left).localeCompare(stableJson(right)))
450}
451
452const moduleMembers = (
453 node: ts.ModuleDeclaration,
454 context: SerializationContext
455): ReadonlyArray<DeclarationModel> => {
456 let body = node.body
457 while (body !== undefined && ts.isModuleDeclaration(body)) {
458 body = body.body
459 }
460 if (body === undefined || !ts.isModuleBlock(body)) {
461 return []
462 }
463 return body.statements.flatMap((statement) => {
464 if (
465 ts.isFunctionDeclaration(statement) || ts.isInterfaceDeclaration(statement) ||
466 ts.isTypeAliasDeclaration(statement) || ts.isClassDeclaration(statement) ||
467 ts.isEnumDeclaration(statement) || ts.isModuleDeclaration(statement) ||
468 ts.isVariableStatement(statement)
469 ) {
470 if (ts.isVariableStatement(statement)) {
471 return statement.declarationList.declarations.map((declaration) => serializeDeclaration(declaration, context))
472 }
473 return [serializeDeclaration(statement, context)]
474 }
475 if (ts.isExportDeclaration(statement) || ts.isImportDeclaration(statement)) {
476 return []
477 }
478 throw new Error(`Unsupported public namespace statement: ${ts.SyntaxKind[statement.kind]}`)
479 }).sort((left, right) => `${left.kind}:${left.name}`.localeCompare(`${right.kind}:${right.name}`))
480}
481
482const serializeDeclaration = (node: ts.Declaration, context: SerializationContext): DeclarationModel => {
483 if (ts.isFunctionDeclaration(node)) {
484 return serializeSignature(node, context, "function", nameText(node.name))
485 }
486 if (ts.isVariableDeclaration(node)) {
487 return {
488 kind: "variable",
489 name: nameText(node.name),
490 type: node.type === undefined ? { kind: "unknown" } : serializeType(node.type, context)
491 }
492 }
493 if (ts.isTypeAliasDeclaration(node)) {
494 const generic = serializeTypeParameters(node.typeParameters, context)
495 return {
496 kind: "type-alias",
497 name: node.name.text,
498 typeParameters: generic.parameters,
499 type: serializeType(node.type, generic.context),
500 modifiers: modifiers(node)
501 }
502 }
503 if (ts.isInterfaceDeclaration(node)) {
504 const generic = serializeTypeParameters(node.typeParameters, context)
505 return {
506 kind: "interface",
507 name: node.name.text,
508 typeParameters: generic.parameters,
509 members: serializeObjectMembers(node.members, generic.context),
510 heritage: serializeHeritage(node.heritageClauses, generic.context),
511 modifiers: modifiers(node)
512 }
513 }
514 if (ts.isClassDeclaration(node)) {
515 const generic = serializeTypeParameters(node.typeParameters, context)
516 return {
517 kind: "class",
518 name: nameText(node.name),
519 typeParameters: generic.parameters,
520 members: node.members.flatMap((member) => {
521 const serialized = serializeClassMember(member, generic.context)
522 return serialized === undefined ? [] : [serialized]
523 }).sort((left, right) =>
524 `${left.kind}:${left.name}:${stableJson(left)}`.localeCompare(
525 `${right.kind}:${right.name}:${stableJson(right)}`
526 )
527 ),
528 heritage: serializeHeritage(node.heritageClauses, generic.context),
529 modifiers: modifiers(node)
530 }
531 }
532 if (ts.isEnumDeclaration(node)) {
533 return {
534 kind: "enum",
535 name: node.name.text,
536 members: node.members.map((member) => ({
537 kind: "enum-member",
538 name: nameText(member.name),
539 value: member.initializer === undefined
540 ? undefined
541 : ts.isStringLiteral(member.initializer) || ts.isNumericLiteral(member.initializer)
542 ? member.initializer.text
543 : member.initializer.getText()
544 }))
545 }
546 }
547 if (ts.isModuleDeclaration(node)) {
548 return { kind: "namespace", name: nameText(node.name), members: moduleMembers(node, context) }
549 }
550 if (ts.isExportAssignment(node)) {
551 return { kind: "export-assignment", name: "default", value: node.expression.getText() }
552 }
553 throw new Error(`Unsupported public declaration: ${ts.SyntaxKind[node.kind]}`)
554}
555
556const declarationKind = (declarations: ReadonlyArray<DeclarationModel>): string =>
557 [...new Set(declarations.map((declaration) => declaration.kind))].sort().join("+")
558
559const documentation = (symbol: ts.Symbol, checker: ts.TypeChecker): Documentation => {
560 const tags = new Map(
561 symbol.getJsDocTags(checker).map((tag) => [
562 tag.name,
563 tag.text?.map((part) => part.text).join("").trim() ?? ""
564 ])
565 )
566 const summary = ts.displayPartsToString(symbol.getDocumentationComment(checker)).trim()
567 const stability = tags.get("stability")
568 return {
569 summary: summary === "" ? undefined : summary,
570 deprecated: tags.get("deprecated"),
571 since: tags.get("since"),
572 category: tags.get("category"),
573 stability: tags.has("unstable") ? "unstable" : stability === "experimental" || stability === "unstable"
574 ? stability
575 : "stable"
576 }
577}
578
579const bucketDeclarations = (symbol: ts.Symbol, bucket: Bucket): ReadonlyArray<ts.Declaration> => {
580 const declarations = symbol.declarations ?? []
581 return declarations.filter((declaration) => {
582 if (bucket === "type") {
583 return ts.isInterfaceDeclaration(declaration) || ts.isTypeAliasDeclaration(declaration) ||
584 ts.isClassDeclaration(declaration) || ts.isEnumDeclaration(declaration) ||
585 ts.isModuleDeclaration(declaration)
586 }
587 return ts.isFunctionDeclaration(declaration) || ts.isVariableDeclaration(declaration) ||
588 ts.isClassDeclaration(declaration) || ts.isEnumDeclaration(declaration) ||
589 ts.isModuleDeclaration(declaration) || ts.isExportAssignment(declaration)
590 })
591}
592
593const symbolBuckets = (symbol: ts.Symbol): ReadonlyArray<Bucket> => {
594 const output: Array<Bucket> = []
595 if ((symbol.flags & ts.SymbolFlags.Type) !== 0) {
596 output.push("type")
597 }
598 if ((symbol.flags & ts.SymbolFlags.Value) !== 0 || (symbol.flags & ts.SymbolFlags.Namespace) !== 0) {
599 output.push("value")
600 }
601 return output
602}
603
604const displayDeclarations = (nodes: ReadonlyArray<ts.Declaration>): string =>
605 nodes.map((node) => node.getText().replace(/\/\*\*[\s\S]*?\*\//g, "").replace(/\s+/g, " ").trim()).join("\n")
606
607const fingerprintDeclarations = (declarations: ReadonlyArray<DeclarationModel>): string => {
608 const normalize = (value: unknown, parent?: string): unknown => {
609 if (Array.isArray(value)) {
610 return value.map((entry) => normalize(entry, parent))
611 }
612 if (value !== null && typeof value === "object") {
613 const input = value as Record<string, unknown>
614 return Object.fromEntries(
615 Object.entries(input).flatMap(([key, entry]) => {
616 if ((parent === "parameters" && key === "name") || key === "displayName") {
617 return []
618 }
619 return [[key, normalize(entry, key)]]
620 })
621 )
622 }
623 return value
624 }
625 return fingerprint(
626 declarations.map((declaration) => ({
627 ...declaration,
628 name: "<export>",
629 parameters: declaration.parameters?.map((parameter) => ({ ...parameter, name: "<parameter>" }))
630 })).map((declaration) => normalize(declaration))
631 )
632}
633
634interface PendingEntity {
635 readonly symbol: ts.Symbol
636 readonly bucket: Bucket
637 readonly packageName: string
638 readonly routes: Array<ImportRoute>
639 readonly declarations: ReadonlyArray<ts.Declaration>
640}
641
642const symbolIdentity = (symbol: ts.Symbol, bucket: Bucket): string => {
643 const declarations = symbol.declarations ?? []
644 return `${bucket}:${
645 declarations.map((node) => `${node.getSourceFile().fileName}:${node.pos}:${node.end}`).sort().join("|")
646 }`
647}
648
649const collectExports = (
650 checker: ts.TypeChecker,
651 moduleSymbol: ts.Symbol,
652 entrypoint: Entrypoint,
653 pending: Map<string, PendingEntity>,
654 path: ReadonlyArray<string> = [],
655 seenNamespaces: ReadonlySet<ts.Symbol> = new Set()
656): void => {
657 for (
658 const exported of checker.getExportsOfModule(moduleSymbol).sort((left, right) =>
659 left.name.localeCompare(right.name)
660 )
661 ) {
662 let symbol = exported
663 if ((symbol.flags & ts.SymbolFlags.Alias) !== 0) {
664 symbol = checker.getAliasedSymbol(symbol)
665 }
666 const exportPath = [...path, exported.name]
667 const declarations = symbol.declarations ?? []
668 for (const bucket of symbolBuckets(symbol)) {
669 const selected = bucketDeclarations(symbol, bucket)
670 if (selected.length === 0) {
671 continue
672 }
673 const key = symbolIdentity(symbol, bucket)
674 const existing = pending.get(key)
675 const route = { module: entrypoint.module, path: exportPath }
676 if (existing === undefined) {
677 pending.set(key, {
678 symbol,
679 bucket,
680 packageName: entrypoint.packageName,
681 routes: [route],
682 declarations: selected
683 })
684 } else if (
685 !existing.routes.some((candidate) =>
686 candidate.module === route.module && candidate.path.join(".") === route.path.join(".")
687 )
688 ) {
689 existing.routes.push(route)
690 }
691 }
692 const isNamespace = (symbol.flags & ts.SymbolFlags.Module) !== 0
693 if (isNamespace && !seenNamespaces.has(symbol) && declarations.length > 0) {
694 collectExports(
695 checker,
696 symbol,
697 entrypoint,
698 pending,
699 exportPath,
700 new Set([...seenNamespaces, symbol])
701 )
702 }
703 }
704}
705
706const canonicalRoute = (routes: ReadonlyArray<ImportRoute>): ImportRoute =>
707 [...routes].sort((left, right) =>
708 left.path.length - right.path.length ||
709 right.module.split("/").length - left.module.split("/").length ||
710 left.module.localeCompare(right.module) ||
711 left.path.join(".").localeCompare(right.path.join("."))
712 )[0]!
713
714export interface ExtractSnapshotOptions {
715 readonly repoRoot: string
716 readonly ref: string
717 readonly sha: string
718 readonly modules?: ReadonlyArray<string>
719}
720
721export class SnapshotExtractionError extends Schema.TaggedError<SnapshotExtractionError>()(
722 "SnapshotExtractionError",
723 {
724 message: Schema.String,
725 diagnostics: Schema.Array(SnapshotDiagnosticSchema)
726 }
727) {}
728
729export const isSnapshotExtractionError = (u: unknown): u is SnapshotExtractionError =>
730 Predicate.isTagged(u, "SnapshotExtractionError")
731
732const snapshotExtractionError = (diagnostics: ReadonlyArray<SnapshotDiagnostic>): SnapshotExtractionError =>
733 new SnapshotExtractionError({
734 message: diagnostics.map((diagnostic) => `${diagnostic.code}: ${diagnostic.message}`).join("\n"),
735 diagnostics
736 })
737
738const extractSnapshot = (
739 options: ExtractSnapshotOptions,
740 discovered: DiscoveryResult,
741 path: Path.Path
742): ApiSnapshot => {
743 const diagnostics: Array<SnapshotDiagnostic> = discovered.missing.map((module) => ({
744 code: "missing-entrypoint",
745 message: `Consumer-visible declaration entrypoint not found: ${module}`,
746 module
747 }))
748 if (diagnostics.length > 0) {
749 throw snapshotExtractionError(diagnostics)
750 }
751 const program = ts.createProgram({
752 rootNames: discovered.entrypoints.map((entrypoint) => entrypoint.declarationFile),
753 options: {
754 module: ts.ModuleKind.NodeNext,
755 moduleResolution: ts.ModuleResolutionKind.NodeNext,
756 target: ts.ScriptTarget.ESNext,
757 skipLibCheck: true,
758 types: [],
759 noEmit: true
760 }
761 })
762 // The declarations were already checked by the branch-native build. Re-checking
763 // them with the pinned extractor compiler would introduce lib-version drift.
764 const compilerDiagnostics = program.getSyntacticDiagnostics()
765 .filter((diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error)
766 if (compilerDiagnostics.length > 0) {
767 throw snapshotExtractionError(compilerDiagnostics.map((diagnostic) => ({
768 code: `typescript-${diagnostic.code}`,
769 message: ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"),
770 source: diagnostic.file === undefined
771 ? undefined
772 : sourceLocation(path, options.repoRoot, diagnostic.file)
773 })))
774 }
775 const checker = program.getTypeChecker()
776 const pending = new Map<string, PendingEntity>()
777 for (const entrypoint of discovered.entrypoints) {
778 const source = program.getSourceFile(entrypoint.declarationFile)
779 const symbol = source === undefined ? undefined : checker.getSymbolAtLocation(source)
780 if (source === undefined || symbol === undefined) {
781 diagnostics.push({
782 code: "missing-module-symbol",
783 message: `Could not load module symbol for ${entrypoint.module}`,
784 module: entrypoint.module
785 })
786 continue
787 }
788 collectExports(checker, symbol, entrypoint, pending)
789 }
790
791 const entities: Array<ApiEntity> = []
792 const publicSymbols = new Map<string, string>()
793 for (const [key, pendingEntity] of pending) {
794 const route = canonicalRoute(pendingEntity.routes)
795 publicSymbols.set(key, `${route.module}#${route.path.join(".")}#${pendingEntity.bucket}`)
796 }
797 for (const pendingEntity of pending.values()) {
798 const route = canonicalRoute(pendingEntity.routes)
799 const declaration = pendingEntity.declarations[0]
800 if (declaration === undefined) {
801 continue
802 }
803 let serialized: ReadonlyArray<DeclarationModel>
804 try {
805 serialized = pendingEntity.declarations.map((node) =>
806 serializeDeclaration(node, {
807 checker,
808 path,
809 repoRoot: options.repoRoot,
810 typeParameters: new Map(),
811 publicSymbols
812 })
813 )
814 } catch (error) {
815 diagnostics.push({
816 code: "unsupported-public-declaration",
817 message: error instanceof Error ? error.message : String(error),
818 module: route.module,
819 path: route.path,
820 source: sourceLocation(path, options.repoRoot, declaration)
821 })
822 continue
823 }
824 const id = `${route.module}#${route.path.join(".")}#${pendingEntity.bucket}`
825 entities.push({
826 id,
827 packageName: pendingEntity.packageName,
828 module: route.module,
829 path: route.path,
830 bucket: pendingEntity.bucket,
831 declarationKind: declarationKind(serialized),
832 importRoutes: [...pendingEntity.routes].sort((left, right) =>
833 left.module.localeCompare(right.module) || left.path.join(".").localeCompare(right.path.join("."))
834 ),
835 declarations: serialized,
836 displaySignature: displayDeclarations(pendingEntity.declarations),
837 fingerprint: fingerprintDeclarations(serialized),
838 documentation: documentation(pendingEntity.symbol, checker),
839 source: sourceLocation(path, options.repoRoot, declaration)
840 })
841 }
842 if (diagnostics.length > 0) {
843 throw snapshotExtractionError(diagnostics)
844 }
845
846 const packages = [...new Set(discovered.entrypoints.map((entrypoint) => entrypoint.packageName))].sort()
847 return {
848 version: 1,
849 compiler: { name: "typescript", version: ts.version },
850 ref: options.ref,
851 sha: options.sha,
852 packages,
853 entrypoints: discovered.entrypoints.map((entrypoint) => ({
854 ...entrypoint,
855 declarationFile: normalizedPath(path, options.repoRoot, entrypoint.declarationFile)
856 })),
857 entities: entities.sort((left, right) => left.id.localeCompare(right.id)),
858 diagnostics: []
859 }
860}
861
862export const snapshotCacheKey = (
863 sha: string,
864 modules?: ReadonlyArray<string>
865): string =>
866 fingerprint([
867 "snapshot-v4",
868 "class-member-optionality-v1",
869 "literal-type-category-v1",
870 sha,
871 ts.version,
872 modules === undefined ? "all" : [...modules].sort()
873 ])
874
875export class Snapshotter extends Context.Service<Snapshotter, {
876 readonly extract: (options: ExtractSnapshotOptions) => Effect.Effect<
877 ApiSnapshot,
878 ApiDiffError | SnapshotExtractionError
879 >
880}>()("@effect/api-diff/Snapshotter") {
881 static readonly layerNoDependencies = Layer.effect(
882 Snapshotter,
883 Effect.gen(function*() {
884 const discovery = yield* Discovery
885 const path = yield* Path.Path
886
887 const extract = Effect.fnUntraced(function*(options: ExtractSnapshotOptions) {
888 const discovered = yield* discovery.discoverEntrypoints(options.repoRoot, options.modules)
889 return yield* Effect.try({
890 try: () => extractSnapshot(options, discovered, path),
891 catch: (cause) =>
892 isSnapshotExtractionError(cause)
893 ? cause
894 : new ApiDiffError({
895 message: `Could not extract the API snapshot for ${options.ref}`,
896 cause
897 })
898 })
899 })
900
901 return Snapshotter.of({ extract })
902 })
903 )
904
905 static readonly layer = this.layerNoDependencies.pipe(
906 Layer.provide(Discovery.layer)
907 )
908}