diff --git a/.changeset/brave-lions-report.md b/.changeset/brave-lions-report.md new file mode 100644 index 00000000..e764e984 --- /dev/null +++ b/.changeset/brave-lions-report.md @@ -0,0 +1,20 @@ +--- +"@systemfsoftware/api-extractor": minor +--- + +The API model is now schema version 3, a breaking change for stored models: a +model written under version 2 is refused. Every item carries its source anchor +(repository-relative file and 1-based line, or `null`), its release tag +(`public`, `beta`, `alpha`, `internal` or `untagged`) and its `@stability` +value; the model carries the package's `exports` and `imports` maps, the +analysis messages with their anchors, the anchor base and the coverage. + +`readPackage` now resolves each export through the requested condition, then +`types`, `import`, `default` and the first remaining key; a bare-string +`exports` names `.`; a target that is not TypeScript source refuses with the +key and target named; and an `imports` map is recorded as internal surface, so +a package with an imports map and no `exports` analyses to zero entry points. +`FrontEnd.analyse` returns `{ route, model }`, with the messages and coverage +in the model. A source index gives both emitter routes the same anchors +without declaration maps, and type parameters keep their `in`, `out` and +`const` modifiers. diff --git a/README.md b/README.md index faf77566..eeba628b 100644 --- a/README.md +++ b/README.md @@ -65,19 +65,21 @@ const analysis = await Effect.runPromise( process.stdout.write(ApiModel.serialise(analysis.model)) ``` -`readPackage` takes each export whose `@systemfsoftware/source` condition names a -`.ts`, `.tsx`, `.mts` or `.cts` file, or a hand-written `.d.ts`, `.d.mts` or `.d.cts` -file, as an entry point; `readPackage({ packageRoot, -sourceCondition })` reads another condition instead, for example `types` for a package -whose sources are not under the family's condition. A subpath pattern (`"./*"`, one -star) expands to one entry per matching source under Node's `exports` rules: an explicit -subpath wins over a pattern, and an export path belongs to the pattern key Node picks for -it, so it names no entry when that key's target has no source or is `null`. A key or a -target with two stars names no entry. The condition is read where the family writes it, -directly -under an export path, and its value is the source's path; a target that nests it under -another condition, lists targets in an array, or gives the condition anything but a path -names no entry. A package with no such export is refused with `NoSourceEntry`. +`readPackage` resolves every `exports` key to the target a consumer sees: the requested +condition (`@systemfsoftware/source` by default, or `sourceCondition`), then `types`, +`import`, `default`, then the first remaining string target by code unit. An `exports` +string is the `.` entry, and a bare path target resolves as itself. The resolved target +must be a TypeScript source or declaration file (`.ts`, `.tsx`, `.mts`, `.cts`, `.d.ts`); +a key that resolves to anything else is refused with `NoSourceEntry`, naming the key and +the target. Each entry records the manifest key, the condition that selected its target +and the target itself. A subpath pattern (`"./*"`, one star) expands to one entry per +matching source under Node's `exports` rules: an explicit subpath wins over a pattern, +and an export path belongs to the pattern key Node picks for it, so it names no entry +when that key's target has no source or is `null`. A key or a target with two stars names +no entry, and a pattern that resolves no source file adds a `pattern-empty` coverage +reason. The `imports` map (`#x/*`) is decoded and recorded with the same resolution as +internal surface, never as an entry point: a package with an imports map and no `exports` +analyses to zero entry points. A package with neither is refused with `NoSourceEntry`. A package whose `package.json` does not decode — invalid JSON, or a manifest the schema refuses — is refused with `ManifestRejected`, naming the package root. So is a pattern export whose walk lists more than 1024 directories, which symlinked aliases of a @@ -115,6 +117,16 @@ resolver reports `ae-forgotten-export`, `ae-missing-release-tag`, `ae-internal-mixed-release-tag`, so both routes give the same model and messages, apart from the producer stamp (`producer.declarations`), which names the emitter that ran (`FrontEnd.OXC_TRANSFORM_PRODUCER` or `FrontEnd.TYPESCRIPT_PRODUCER`). +A source index parses every source the emitter read with `oxc-parser`, so both routes +give every item the same anchor without declaration maps. Model schema version 3 adds, +to every item, its `anchor` (repository-relative source file and 1-based line, or `null`), +its `releaseTag` (`public`, `beta`, `alpha`, `internal` or `untagged`) and its +`@stability` value; and, to the model, the package's `exports` and `imports` maps, the +analysis messages with their anchors, the anchor base and the coverage. An item the +index cannot map stays listed with `anchor: null` and adds an `unmapped-anchor` coverage +reason, so a partial model is never presented as complete. `FrontEnd.analyse` returns +`{ route, model }`; an anchor, a tag, a message and the coverage all live in the model, +which is the one value the report and the diff read. The builder refuses a package with `UnsupportedDeclaration`, naming the file and the construct, when its declarations contain one of these constructs, which model schema diff --git a/packages/api-extractor/src/ApiModel.ts b/packages/api-extractor/src/ApiModel.ts index d975db42..34e87da8 100644 --- a/packages/api-extractor/src/ApiModel.ts +++ b/packages/api-extractor/src/ApiModel.ts @@ -2,23 +2,31 @@ * The versioned API model: one package's public surface as data. */ export { + AnchorBase, API_MODEL_SCHEMA_VERSION as SCHEMA_VERSION, ApiItem, ApiModel, type ClassItem, + Coverage, + CoverageReason, decodeApiModel as decode, encodeApiModel as encode, type EntryPoint, + EntryPointTarget, type EnumItem, type EnumMember, type FunctionItem, type InterfaceItem, + ManifestEntry, type NamespaceItem, + PackageSurface, type Producer, sortModel as sort, TypeAliasItem, type VariableItem, } from './model/api-model.schema.ts' +export { Anchor, ItemMeta, ReleaseTag } from './model/anchor.schema.ts' +export { AnalysisMessage } from './model/message.schema.ts' export { serialiseApiModel as serialise } from './model/serialise.ts' export { type ArrayType, diff --git a/packages/api-extractor/src/FrontEnd.ts b/packages/api-extractor/src/FrontEnd.ts index e1bcfb2a..3a7bab57 100644 --- a/packages/api-extractor/src/FrontEnd.ts +++ b/packages/api-extractor/src/FrontEnd.ts @@ -1,5 +1,5 @@ export { type Analysis, analyse } from './front-end/analyse.ts' -export { AnalysisMessage } from './front-end/analysis-message.schema.ts' +export { AnalysisMessage } from './model/message.schema.ts' export { buildModel } from './front-end/build-model.ts' export { CompilerEmitter, diff --git a/packages/api-extractor/src/__tests__/judge-surfaces.workflow.property.test.ts b/packages/api-extractor/src/__tests__/judge-surfaces.workflow.property.test.ts index b35584f7..d860bfed 100644 --- a/packages/api-extractor/src/__tests__/judge-surfaces.workflow.property.test.ts +++ b/packages/api-extractor/src/__tests__/judge-surfaces.workflow.property.test.ts @@ -13,6 +13,7 @@ import { effect, fn, functionItem, + META, never, parameter, ref, @@ -73,7 +74,7 @@ describe('verdict on Effect channels', (it) => { const names = [one, `${one}+`, ...others].map((label, index) => `fn${index}:${label}`) const exportsOf = (failure: TypeExpr) => { const signatures = [signature([], effect(value, failure))] - return rootedWith(surrounding, names.map((name): ApiItem => ({ kind: 'function', name, signatures }))) + return rootedWith(surrounding, names.map((name): ApiItem => ({ kind: 'function', name, signatures, ...META }))) } return same( located(judge(exportsOf(smaller), exportsOf(larger))), @@ -151,6 +152,7 @@ describe('verdict on dual APIs', (it) => { name: TARGET, constant: true, type: { kind: 'object', members: forms.map((sig) => ({ kind: 'call', signature: sig })) }, + ...META, }) it.prop( @@ -201,7 +203,12 @@ describe('verdict on exports and parameters', (it) => { '∀x_EntryPoint_≡RemovedAndAdded', { of: [ApiModel], subject: judge }, (judge, [model]) => { - const extra = { path: `${model.entryPoints.map((entry) => entry.path).join('')}/extra`, items: [], locals: [] } + const extra = { + path: `${model.entryPoints.map((entry) => entry.path).join('')}/extra`, + target: { key: './extra', condition: null, path: './src/extra.ts' }, + items: [], + locals: [], + } const withExtra = { ...model, entryPoints: [...model.entryPoints, extra] } return same( [reasons(judge(withExtra, model)), reasons(judge(model, withExtra))], @@ -259,6 +266,7 @@ describe('verdict through local type aliases', (it) => { name, typeParameters: [{ name: parameterName, default: defaultType }], type, + ...META, }) it.prop( @@ -288,8 +296,8 @@ describe('verdict through local type aliases', (it) => { }) describe('verdict on a value and a type sharing a name', (it) => { - const value = (type: TypeExpr): ApiItem => ({ kind: 'variable', name: TARGET, constant: true, type }) - const type = (body: TypeExpr): ApiItem => ({ kind: 'typeAlias', name: TARGET, typeParameters: [], type: body }) + const value = (type: TypeExpr): ApiItem => ({ kind: 'variable', name: TARGET, constant: true, type, ...META }) + const type = (body: TypeExpr): ApiItem => ({ kind: 'typeAlias', name: TARGET, typeParameters: [], type: body, ...META }) it.prop( '∀m_NameValueTypeOrder_≡Unchanged', diff --git a/packages/api-extractor/src/diff/change.schema.ts b/packages/api-extractor/src/diff/change.schema.ts index 3d3a2fb2..c44dd02d 100644 --- a/packages/api-extractor/src/diff/change.schema.ts +++ b/packages/api-extractor/src/diff/change.schema.ts @@ -211,8 +211,12 @@ const defaultArgument = ( const expandTypeParameters = (parameters: ReadonlyArray, scope: Scope): ReadonlyArray => parameters.map((parameter) => expandTypeParameter(parameter, scope)) +const modifierFields = (parameter: TypeParameter): Pick => + parameter.modifiers === undefined ? {} : { modifiers: parameter.modifiers } + const expandTypeParameter = (parameter: TypeParameter, scope: Scope): TypeParameter => ({ name: parameter.name, + ...modifierFields(parameter), ...constraintOf(parameter, scope), ...defaultOf(parameter, scope), }) @@ -434,6 +438,7 @@ const typeParameterKey = (parameter: TypeParameter) => ({ name: parameter.name, constraint: optionalTypeKey(parameter.constraint), default: optionalTypeKey(parameter.default), + modifiers: parameter.modifiers ?? [], }) const typeParametersKey = (parameters: ReadonlyArray): string => diff --git a/packages/api-extractor/src/front-end/analyse.ts b/packages/api-extractor/src/front-end/analyse.ts index 10f26353..30ee451f 100644 --- a/packages/api-extractor/src/front-end/analyse.ts +++ b/packages/api-extractor/src/front-end/analyse.ts @@ -1,7 +1,6 @@ import { Effect } from 'effect' import type { PlatformError } from 'effect/PlatformError' -import type { ApiModel } from '../model/api-model.schema.ts' -import type { AnalysisMessage } from './analysis-message.schema.ts' +import type { ApiModel, CoverageReason } from '../model/api-model.schema.ts' import { modelOfGraph } from './build-model.ts' import { CompilerEmitter, type CompilerEmitterId, IsolatedEmitter, type IsolatedEmitterId } from './declaration-emitter.service.ts' import { declarationGraph } from './declaration-graph.ts' @@ -9,11 +8,11 @@ import type { Declarations, PackageSource } from './declarations.schema.ts' import type { CompilerRejected, NotIsolated, UnsupportedDeclaration } from './front-end-error.schema.ts' import { messagesOfGraph } from './resolve-messages.ts' import type { Route } from './route.schema.ts' +import { sourceIndexOf } from './source-index.ts' export type Analysis = { readonly route: Route readonly model: ApiModel - readonly messages: ReadonlyArray } type Routed = { readonly route: Route; readonly declarations: Declarations } @@ -35,16 +34,31 @@ const routed = (source: PackageSource): Effect.Effect ({ + kind: 'pattern-empty', + detail: `the pattern export "${key}" resolves no source file`, +}) + export const analyse = ( source: PackageSource, ): Effect.Effect => Effect.gen(function* () { const { route, declarations } = yield* routed(source) const graph = yield* declarationGraph(declarations) + const index = sourceIndexOf({ sources: declarations.sources, anchorPrefix: declarations.anchorPrefix }) + const messages = yield* messagesOfGraph(graph, index) + const reasons = [...index.reasons, ...source.patternEmpties.map(patternEmptyReason)] const analysis: Analysis = { route, - model: yield* modelOfGraph({ graph, producer: declarations.producer, package: declarations.package }), - messages: yield* messagesOfGraph(graph), + model: yield* modelOfGraph({ + graph, + producer: declarations.producer, + package: source.manifest, + anchorBase: declarations.anchorBase, + index, + messages, + reasons, + }), } return analysis }) diff --git a/packages/api-extractor/src/front-end/build-model.ts b/packages/api-extractor/src/front-end/build-model.ts index 41154b7c..d9ad263c 100644 --- a/packages/api-extractor/src/front-end/build-model.ts +++ b/packages/api-extractor/src/front-end/build-model.ts @@ -19,24 +19,49 @@ import type { import { API_MODEL_SCHEMA_VERSION, sortModel, + type AnchorBase, type ApiItem, type ApiModel, + type CoverageReason, type EntryPoint, type EnumMember, + type PackageSurface, type TypeAliasItem, } from '../model/api-model.schema.ts' +import type { ItemMeta } from '../model/anchor.schema.ts' +import type { AnalysisMessage } from '../model/message.schema.ts' import type { Member, Signature, TypeExpr, TypeParameter } from '../model/type-expr.schema.ts' import { ENGINE } from '../version.ts' import { literalTextOf, type Naming, type TypeContext, typeParameterNames, TypePrinting } from './canonical-type-text.ts' import { declarationGraph, declaredOf, exportedNames, exportedSymbol, exportOrigin, grouped, namespaceSpacesOf, refuse, statementsIn, type Declaration, type Declared, type DeclarationGraph, type EntryDeclaration } from './declaration-graph.ts' import type { Declarations } from './declarations.schema.ts' import { UnsupportedDeclaration } from './front-end-error.schema.ts' +import { messagesOfGraph } from './resolve-messages.ts' +import { emptySourceIndex, type SourceIndex, type SourceKind } from './source-index.ts' import { TypeExprs } from './type-expr-of.ts' const { withNamesInScope, withNamespaceScope } = TypePrinting const { classMembersOf, heritageOf, membersOf, superClassOf, signatureOf, typeExprOf, typeParametersOf } = TypeExprs +/** The item metadata the source index holds for the declaration this item was built from. */ +const metaOf = (context: TypeContext, name: string, kind: SourceKind): ItemMeta => + context.index.metaOf(context.file, name, kind, [...context.namePath, name].join('.')) + +const inNamespace = (context: TypeContext, name: string): TypeContext => ({ ...context, namePath: [...context.namePath, name] }) + +const named = (name: string | undefined): string => Option.getOrElse(Option.fromNullishOr(name), () => 'default') + +const functionName = (node: FunctionNode): string => named(node.id?.name) + +const classNodeName = (node: Class): string => named(node.id?.name) + +const variableName = (declarator: VariableDeclarator): Option.Option => + declarator.id.type === 'Identifier' ? Option.some(declarator.id.name) : Option.none() + +const namespaceName = (node: TSModuleDeclaration): Option.Option => + node.id.type === 'Identifier' ? Option.some(node.id.name) : Option.none() + type LocalDeclaration = { readonly key: string readonly file: string @@ -87,8 +112,12 @@ const constraintField = (parameter: TypeParameter, rename: HashMap.HashMap): Pick => Option.match(Option.fromNullishOr(parameter.default), { onNone: () => ({}), onSome: (fallback) => ({ default: renameType(fallback, rename) }) }) +const modifierFields = (parameter: TypeParameter): Pick => + parameter.modifiers === undefined ? {} : { modifiers: parameter.modifiers } + const renameTypeParameter = (parameter: TypeParameter, rename: HashMap.HashMap): TypeParameter => ({ name: parameter.name, + ...modifierFields(parameter), ...constraintField(parameter, rename), ...defaultField(parameter, rename), }) @@ -131,7 +160,7 @@ const renameType = (type: TypeExpr, rename: HashMap.HashMap): Ty ) const renameAlias = (alias: TypeAliasItem, rename: HashMap.HashMap): TypeAliasItem => ({ - kind: 'typeAlias', + ...alias, name: Option.getOrElse(HashMap.get(rename, alias.name), () => alias.name), typeParameters: alias.typeParameters.map((parameter) => renameTypeParameter(parameter, rename)), type: renameType(alias.type, rename), @@ -266,7 +295,7 @@ const typeAliasItem = (context: TypeContext, node: TSTypeAliasDeclaration, name: const inner = withNamesInScope(context, typeParameterNames(node.typeParameters)) const typeParameters = yield* typeParametersOf(inner, node.typeParameters) const type = yield* typeExprOf(inner, node.typeAnnotation) - return { kind: 'typeAlias', name, typeParameters, type } + return { kind: 'typeAlias', name, typeParameters, type, ...metaOf(context, node.id.name, 'typeAlias') } }) const declarationSignatureOf = (context: TypeContext, node: FunctionNode) => @@ -285,6 +314,7 @@ const functionItemOf = ( kind: 'function', name, signatures, + ...metaOf(context, functionName(Arr.headNonEmpty(present)), 'function'), })), ), }) @@ -304,7 +334,14 @@ const interfaceItemOf = ( const typeParameters = yield* typeParametersOf(inner, first.typeParameters) const extending = yield* Effect.forEach(present, (node) => Effect.forEach(node.extends, (heritage) => heritageOf(inner, heritage))) const members = yield* Effect.forEach(present, (node) => membersOf(inner, node.body.body)) - const item: ApiItem = { kind: 'interface', name, typeParameters, extends: extending.flat(), members: members.flat() } + const item: ApiItem = { + kind: 'interface', + name, + typeParameters, + extends: extending.flat(), + members: members.flat(), + ...metaOf(context, first.id.name, 'interface'), + } return item }), ), @@ -342,6 +379,7 @@ const classItemOf = ( extends: extended, implements: implemented, members, + ...metaOf(context, classNodeName(node), 'class'), } return item }), @@ -387,7 +425,13 @@ const variableItemOf = ( onEmpty: () => Effect.succeedNone, onNonEmpty: ([variable]) => Effect.asSome( - Effect.map(variableType(context, variable.node), (type): ApiItem => ({ kind: 'variable', name, constant: variable.constant, type })), + Effect.map(variableType(context, variable.node), (type): ApiItem => ({ + kind: 'variable', + name, + constant: variable.constant, + type, + ...metaOf(context, Option.getOrElse(variableName(variable.node), () => name), 'variable'), + })), ), }) @@ -451,7 +495,13 @@ const enumItemOf = ( onEmpty: () => Effect.succeedNone, onNonEmpty: (present) => Effect.asSome( - Effect.map(enumMembersOf(context, present), (members): ApiItem => ({ kind: 'enum', name, const: Arr.headNonEmpty(present).const, members })), + Effect.map(enumMembersOf(context, present), (members): ApiItem => ({ + kind: 'enum', + name, + const: Arr.headNonEmpty(present).const, + members, + ...metaOf(context, Arr.headNonEmpty(present).id.name, 'enum'), + })), ), }) @@ -462,10 +512,17 @@ const namespaceItemOf = ( ): Effect.Effect, UnsupportedDeclaration> => Arr.match(nodes, { onEmpty: () => Effect.succeedNone, - onNonEmpty: (present) => - Effect.asSome( - Effect.map(namespaceBodyItems(context, present.map((node) => node.body)), (items): ApiItem => ({ kind: 'namespace', name, items })), - ), + onNonEmpty: (present) => { + const declared = Option.getOrElse(namespaceName(Arr.headNonEmpty(present)), () => name) + return Effect.asSome( + Effect.map(namespaceBodyItems(inNamespace(context, declared), present.map((node) => node.body)), (items): ApiItem => ({ + kind: 'namespace', + name, + items, + ...metaOf(context, declared, 'namespace'), + })), + ) + }, }) const byGroupName: Order.Order]> = Order.mapInput(Order.String, ([name]) => name) @@ -551,8 +608,13 @@ const itemsForExport = ( Match.value(symbol).pipe( Match.when({ kind: 'external' }, (external) => refuse(external.file, `re-export of "${name}" from "${external.module}"`)), Match.when({ kind: 'local' }, (local) => itemsOfDeclarations(contextFor(local.file), local.declarations, name)), - Match.when({ kind: 'namespace' }, (namespace) => - Effect.map(itemsForFile(graph, contextFor, namespace.file, visited), (items): ReadonlyArray => [{ kind: 'namespace', name, items }])), + Match.when({ kind: 'namespace' }, (namespace) => { + const origin = Option.getOrElse(exportOrigin(graph, entryFile, name), () => entryFile) + const meta = contextFor(origin).index.reExportMetaOf(origin, name) + return Effect.map(itemsForFile(graph, contextFor, namespace.file, visited), (items): ReadonlyArray => [ + { kind: 'namespace', name, items, ...meta }, + ]) + }), Match.exhaustive, ), }) @@ -586,7 +648,7 @@ const buildReachableLocals = ( return drain() } -const buildEntry = (graph: DeclarationGraph, entry: EntryDeclaration): Effect.Effect => +const buildEntry = (graph: DeclarationGraph, sourceIndex: SourceIndex, entry: EntryDeclaration): Effect.Effect => Effect.gen(function* () { const file = entry.declarationFile const exported = exportedNames(graph, file) @@ -594,31 +656,87 @@ const buildEntry = (graph: DeclarationGraph, entry: EntryDeclaration): Effect.Ef const reachable = yield* Ref.make(HashMap.empty()) const built = yield* Ref.make(HashMap.empty()) const naming: Naming = { localNameOf: (at, name, declarations) => localNameOf(reachable, index, at, name, declarations) } - const contextFor = (at: string): TypeContext => ({ graph, file: at, naming, namesInScope: HashSet.empty(), valuesInScope: HashSet.empty() }) + const contextFor = (at: string): TypeContext => ({ + graph, + file: at, + naming, + namesInScope: HashSet.empty(), + valuesInScope: HashSet.empty(), + index: sourceIndex, + namePath: [], + }) const items = yield* Effect.forEach(exported, (name) => itemsForExport(graph, contextFor, file, name, HashSet.empty())) yield* buildReachableLocals(contextFor, reachable, built) const rename = assignNames(exported, yield* Ref.get(reachable)) const locals = Arr.fromIterable(HashMap.values(yield* Ref.get(built))).map((alias) => renameAlias(alias, rename)) - return { path: entry.exportPath, items: items.flat().map((item) => renameItem(item, rename)), locals } + return { + path: entry.exportPath, + target: entry.target ?? { key: entry.exportPath, condition: null, path: entry.declarationFile }, + items: items.flat().map((item) => renameItem(item, rename)), + locals, + } }) +const unmappedReason = (entryPath: string, itemPath: ReadonlyArray, item: ApiItem): ReadonlyArray => + item.anchor === null + ? [{ kind: 'unmapped-anchor', detail: `the entry point "${entryPath}" holds \`${itemPath.join('.')}\` without a source anchor` }] + : [] + +const nestedReasons = (entryPath: string, itemPath: ReadonlyArray, item: ApiItem): ReadonlyArray => + item.kind === 'namespace' ? Arr.flatMap(item.items, (child) => itemReasons(entryPath, itemPath, child)) : [] + +/** The coverage reason an item adds when the source index could not map it back to a declaration. */ +const itemReasons = (entryPath: string, prefix: ReadonlyArray, item: ApiItem): ReadonlyArray => { + const itemPath = [...prefix, item.name] + return [...unmappedReason(entryPath, itemPath, item), ...nestedReasons(entryPath, itemPath, item)] +} + +const entryReasons = (entry: EntryPoint): ReadonlyArray => Arr.flatMap(entry.items, (item) => itemReasons(entry.path, [], item)) + export type ModelOfGraphRequest = { readonly graph: DeclarationGraph readonly producer: Declarations['producer'] - readonly package: Declarations['package'] + readonly package: PackageSurface + readonly anchorBase: AnchorBase + readonly index: SourceIndex + readonly messages: ReadonlyArray + /** Reasons the analysis already knows: parse failures and empty patterns. */ + readonly reasons: ReadonlyArray } export const modelOfGraph = (request: ModelOfGraphRequest): Effect.Effect => - Effect.map(Effect.forEach(request.graph.entryPoints, (entry) => buildEntry(request.graph, entry)), (entryPoints) => - sortModel({ + Effect.map(Effect.forEach(request.graph.entryPoints, (entry) => buildEntry(request.graph, request.index, entry)), (entryPoints) => { + const reasons = [...request.reasons, ...Arr.flatMap(entryPoints, entryReasons)] + return sortModel({ schemaVersion: API_MODEL_SCHEMA_VERSION, producer: { engine: ENGINE, declarations: request.producer }, package: request.package, + anchorBase: request.anchorBase, entryPoints, - }), - ) + messages: request.messages, + coverage: { complete: reasons.length === 0, reasons }, + }) + }) + +/** The surface a model built from declarations alone can name: no manifest facts are available. */ +const declaredSurface = (declarations: Declarations): PackageSurface => ({ + name: declarations.package.name, + version: declarations.package.version, + exports: [], + imports: [], +}) export const buildModel = (declarations: Declarations): Effect.Effect => Effect.flatMap(declarationGraph(declarations), (graph) => - modelOfGraph({ graph, producer: declarations.producer, package: declarations.package }), + Effect.flatMap(messagesOfGraph(graph, emptySourceIndex), (messages) => + modelOfGraph({ + graph, + producer: declarations.producer, + package: declaredSurface(declarations), + anchorBase: 'package', + index: emptySourceIndex, + messages, + reasons: [], + }), + ), ) diff --git a/packages/api-extractor/src/front-end/canonical-type-text.ts b/packages/api-extractor/src/front-end/canonical-type-text.ts index 10239f4c..1d653401 100644 --- a/packages/api-extractor/src/front-end/canonical-type-text.ts +++ b/packages/api-extractor/src/front-end/canonical-type-text.ts @@ -49,6 +49,7 @@ import { resolveName, } from './declaration-graph.ts' import { UnsupportedDeclaration } from './front-end-error.schema.ts' +import type { SourceIndex } from './source-index.ts' export type ReferenceTarget = { readonly name: string @@ -65,6 +66,10 @@ export type TypeContext = { readonly naming: Naming readonly namesInScope: HashSet.HashSet readonly valuesInScope: HashSet.HashSet + /** Source facts for the item the context is building. */ + readonly index: SourceIndex + /** The namespaces the item being built sits in, outermost first. */ + readonly namePath: ReadonlyArray } const withNamesInScope = (context: TypeContext, names: ReadonlyArray): TypeContext => ({ @@ -276,11 +281,20 @@ const printAnnotationOrDefault = ( onSome: (present) => canonicalTypeText(context, present.typeAnnotation), }) +const MODIFIER_PREFIXES = [ + { flag: 'in', text: 'in ' }, + { flag: 'out', text: 'out ' }, + { flag: 'const', text: 'const ' }, +] as const + +const modifierPrefix = (parameter: TSTypeParameter): string => + MODIFIER_PREFIXES.filter(({ flag }) => parameter[flag]).map(({ text }) => text).join('') + const printTypeParameter = (context: TypeContext, parameter: TSTypeParameter): Effect.Effect => Effect.gen(function* () { const constraint = yield* printOptional(context, parameter.constraint, (text) => ` extends ${text}`) const fallback = yield* printOptional(context, parameter.default, (text) => ` = ${text}`) - return `${parameter.name.name}${constraint}${fallback}` + return `${modifierPrefix(parameter)}${parameter.name.name}${constraint}${fallback}` }) const printTypeParameters = (context: TypeContext, declaration: TSTypeParameterDeclaration | null): Effect.Effect => diff --git a/packages/api-extractor/src/front-end/declaration-graph.ts b/packages/api-extractor/src/front-end/declaration-graph.ts index 17e64ce5..b1b50eae 100644 --- a/packages/api-extractor/src/front-end/declaration-graph.ts +++ b/packages/api-extractor/src/front-end/declaration-graph.ts @@ -22,7 +22,8 @@ import { type VariableDeclarator, Visitor, } from 'oxc-parser' -import type { DeclarationFile, Declarations, SourceEntryPoint } from './declarations.schema.ts' +import type { EntryPointTarget } from '../model/api-model.schema.ts' +import type { DeclarationFile, Declarations, SourceEntryPoint, SourceFile } from './declarations.schema.ts' import { UnsupportedDeclaration } from './front-end-error.schema.ts' const RELATIVE_SPECIFIER = /^\.\.?(\/|$)/ @@ -81,13 +82,25 @@ const declarationCandidates: { export const declarationPathOf = (sourceFile: string): string => DECLARATION_EXTENSION.test(sourceFile) ? sourceFile : sourceFile.replace(/\.([mc]?)tsx?$/, (_, flavour: string) => declarationExtensionOf(flavour)) +/** One source per path, ordered by path, so both emitter routes hand the index the same list. */ +export const uniqueSources = (sources: ReadonlyArray): ReadonlyArray => + Arr.sort( + Arr.fromIterable(HashMap.values(HashMap.fromIterable(sources.map((source) => [source.path, source] as const)))), + Order.mapInput(Order.String, (source: SourceFile) => source.path), + ) + export type EntryDeclaration = { readonly exportPath: string readonly declarationFile: string + readonly target?: EntryPointTarget } export const entryPointDeclarations = (entryPoints: ReadonlyArray): ReadonlyArray => - entryPoints.map((entry) => ({ exportPath: entry.exportPath, declarationFile: declarationPathOf(entry.sourceFile) })) + entryPoints.map((entry) => ({ + exportPath: entry.exportPath, + declarationFile: declarationPathOf(entry.sourceFile), + target: { key: entry.key, condition: entry.condition, path: entry.target }, + })) export type SharedDeclaration = { readonly declarationFile: string diff --git a/packages/api-extractor/src/front-end/declarations.schema.ts b/packages/api-extractor/src/front-end/declarations.schema.ts index fc2a5489..f898254f 100644 --- a/packages/api-extractor/src/front-end/declarations.schema.ts +++ b/packages/api-extractor/src/front-end/declarations.schema.ts @@ -1,8 +1,17 @@ import { Schema } from 'effect' +import { AnchorBase, type EntryPointTarget, type PackageSurface } from '../model/api-model.schema.ts' +/** One entry of the package's `exports` map, reduced to the target consumers resolve. */ export type SourceEntryPoint = { readonly exportPath: string + /** The package-relative source path, without a `./` prefix. */ readonly sourceFile: string + /** The manifest key that produced this entry: an explicit key or the pattern key it matched. */ + readonly key: string + /** The condition that selected the target, or null for a bare path. */ + readonly condition: string | null + /** The manifest target as written, e.g. `./src/mod.ts`. */ + readonly target: string } export type PackageSource = { @@ -11,6 +20,14 @@ export type PackageSource = { readonly packageRoot: string readonly workspaceRoot: string readonly tsconfigPath: string + /** Where anchors are rooted: `workspace` when a workspace root exists above the package. */ + readonly anchorBase: AnchorBase + /** The package's path from the anchor root, joined with `/`; empty at the package root. */ + readonly anchorPrefix: string + /** The package's `exports` and `imports` maps, reduced to their resolved targets. */ + readonly manifest: PackageSurface + /** Pattern export keys that resolved a target but no source file. */ + readonly patternEmpties: ReadonlyArray readonly entryPoints: ReadonlyArray } @@ -19,18 +36,42 @@ export type DeclarationFile = { readonly text: string } +/** A source text the emitter read, kept so the source index never re-reads the filesystem. */ +export type SourceFile = { + readonly path: string + readonly text: string +} + export type Declarations = { readonly producer: string readonly package: { readonly name: string; readonly version: string } - readonly entryPoints: ReadonlyArray<{ readonly exportPath: string; readonly declarationFile: string }> + /** Where the source index roots anchors: the workspace root, or the package root. */ + readonly anchorBase: AnchorBase + /** The package's path from the anchor root, joined with `/`; empty at the root. */ + readonly anchorPrefix: string + readonly entryPoints: ReadonlyArray<{ readonly exportPath: string; readonly declarationFile: string; readonly target?: EntryPointTarget }> readonly files: ReadonlyArray + readonly sources: ReadonlyArray } +const TextFile = Schema.Struct({ path: Schema.String, text: Schema.String }) + export const Declarations: Schema.Codec = Schema.Struct({ producer: Schema.String, package: Schema.Struct({ name: Schema.String, version: Schema.String }), - entryPoints: Schema.Array(Schema.Struct({ exportPath: Schema.String, declarationFile: Schema.String })), - files: Schema.Array(Schema.Struct({ path: Schema.String, text: Schema.String })), + anchorBase: AnchorBase, + anchorPrefix: Schema.String, + entryPoints: Schema.Array( + Schema.Struct({ + exportPath: Schema.String, + declarationFile: Schema.String, + target: Schema.optionalKey( + Schema.Struct({ key: Schema.String, condition: Schema.NullOr(Schema.String), path: Schema.String }), + ), + }), + ), + files: Schema.Array(TextFile), + sources: Schema.Array(TextFile), }) const positiveInt: Schema.Codec = Schema.Int.check(Schema.isGreaterThan(0)) diff --git a/packages/api-extractor/src/front-end/front-end-error.schema.ts b/packages/api-extractor/src/front-end/front-end-error.schema.ts index 5bd70149..056c652f 100644 --- a/packages/api-extractor/src/front-end/front-end-error.schema.ts +++ b/packages/api-extractor/src/front-end/front-end-error.schema.ts @@ -19,12 +19,37 @@ const listedLine: (diagnostic: Diagnostic) => string = Match.type(). const listed = (diagnostics: ReadonlyArray): string => diagnostics.map(listedLine).join('\n') -const NoSourceEntryBase: Schema.Class, Cause.YieldableError> = - Schema.TaggedError()('NoSourceEntry', { packageRoot: Schema.String }) +type NoSourceEntryFields = { + readonly packageRoot: Schema.String + readonly exportKey: Schema.optionalKey + readonly target: Schema.optionalKey +} + +type NoSourceEntryDetails = { + readonly packageRoot: string + readonly exportKey?: string + readonly target?: string +} + +const noSourceMessage = (packageRoot: string): string => + `${packageRoot}: no export resolves to a TypeScript source through its source condition` + +const targetMessage = (details: NoSourceEntryDetails & { readonly exportKey: string }): string => + `${details.packageRoot}: the export "${details.exportKey}" resolves to "${details.target ?? ''}", which is not a TypeScript source` + +const sourceEntryMessage = (details: NoSourceEntryDetails): string => + details.exportKey === undefined ? noSourceMessage(details.packageRoot) : targetMessage({ ...details, exportKey: details.exportKey }) + +const NoSourceEntryBase: Schema.Class, Cause.YieldableError> = + Schema.TaggedError()('NoSourceEntry', { + packageRoot: Schema.String, + exportKey: Schema.optionalKey(Schema.String), + target: Schema.optionalKey(Schema.String), + }) export class NoSourceEntry extends NoSourceEntryBase { override get message(): string { - return `${this.packageRoot}: no export resolves to a TypeScript source through its source condition` + return sourceEntryMessage(this) } } diff --git a/packages/api-extractor/src/front-end/oxc-isolated-emitter.ts b/packages/api-extractor/src/front-end/oxc-isolated-emitter.ts index eac6d2c3..407324a7 100644 --- a/packages/api-extractor/src/front-end/oxc-isolated-emitter.ts +++ b/packages/api-extractor/src/front-end/oxc-isolated-emitter.ts @@ -2,9 +2,9 @@ import { Array as Arr, Effect, FileSystem, HashMap, HashSet, Layer, Match, Optio import type { PlatformError } from 'effect/PlatformError' import { isolatedDeclarationSync } from 'oxc-transform' import oxcTransformManifest from 'oxc-transform/package.json' with { type: 'json' } -import { closeDeclarations, declarationPathOf, entryPointDeclarations, missingEntryDeclarations, sharedDeclarations, sourceCandidatesOf, type ClosureResult, type SharedDeclaration, type UnresolvedImport } from './declaration-graph.ts' +import { closeDeclarations, declarationPathOf, entryPointDeclarations, missingEntryDeclarations, sharedDeclarations, sourceCandidatesOf, type ClosureResult, type SharedDeclaration, type UnresolvedImport, uniqueSources } from './declaration-graph.ts' import { type DeclarationEmitter, IsolatedEmitter, type IsolatedEmitterId } from './declaration-emitter.service.ts' -import type { DeclarationFile, Declarations, Diagnostic, PackageSource } from './declarations.schema.ts' +import type { DeclarationFile, Declarations, Diagnostic, PackageSource, SourceFile } from './declarations.schema.ts' import { NotIsolated, type UnsupportedDeclaration } from './front-end-error.schema.ts' import { isDeclarationFile, readPackageFile } from './package-file.ts' @@ -35,6 +35,7 @@ type Emission = { readonly source: PackageSource readonly realRoot: string readonly diagnostics: Ref.Ref> + readonly sources: Ref.Ref> } const declare = (emission: Emission, sourceFile: string, text: string) => @@ -54,7 +55,11 @@ const firstSource = ( Effect.flatMap(readPackageFile({ packageRoot: emission.source.packageRoot, realRoot: emission.realRoot, file: sourceFile }), (text) => Option.match(text, { onNone: () => firstSource(emission, rest), - onSome: (value) => (isDeclarationFile(sourceFile) ? Effect.succeedSome(value) : Effect.asSome(declare(emission, sourceFile, value))), + onSome: (value) => + Effect.andThen( + Ref.update(emission.sources, (known) => [...known, { path: sourceFile, text: value }]), + isDeclarationFile(sourceFile) ? Effect.succeedSome(value) : Effect.asSome(declare(emission, sourceFile, value)), + ), })), }) @@ -109,7 +114,12 @@ const emit = (source: PackageSource) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem const realRoot = yield* fileSystem.realPath(source.packageRoot) - const emission: Emission = { source, realRoot, diagnostics: yield* Ref.make>([]) } + const emission: Emission = { + source, + realRoot, + diagnostics: yield* Ref.make>([]), + sources: yield* Ref.make>([]), + } const entryPoints = entryPointDeclarations(source.entryPoints) const entryDeclarations = entryPoints.map((entry) => entry.declarationFile) yield* Arr.match(sharedDeclarations(entryPoints).map(sharedDeclarationDiagnostic), { @@ -126,8 +136,11 @@ const emit = (source: PackageSource) => const declarations: Declarations = { producer: OXC_TRANSFORM_PRODUCER, package: { name: source.name, version: source.version }, + anchorBase: source.anchorBase, + anchorPrefix: source.anchorPrefix, entryPoints, files, + sources: uniqueSources(yield* Ref.get(emission.sources)), } return declarations }) diff --git a/packages/api-extractor/src/front-end/package-source.schema.ts b/packages/api-extractor/src/front-end/package-source.schema.ts index 8c97d843..650e48fd 100644 --- a/packages/api-extractor/src/front-end/package-source.schema.ts +++ b/packages/api-extractor/src/front-end/package-source.schema.ts @@ -1,4 +1,4 @@ -import { Match, Predicate, Schema, SchemaTransformation } from 'effect' +import { Array as Arr, Function as Fn, Match, Option, Order, Predicate, Schema, SchemaTransformation } from 'effect' type EncodedConditions = { readonly [condition: string]: EncodedExportTarget } @@ -90,11 +90,79 @@ type PackageManifestFields = { readonly name: Schema.String readonly version: Schema.String readonly exports: Schema.optionalKey]>> + readonly imports: Schema.optionalKey> } export const PackageManifest: Schema.Struct = Schema.Struct({ name: Schema.String, version: Schema.String, exports: Schema.optionalKey(Schema.Union([Schema.String, Schema.Record(Schema.String, ExportTargetCodec)])), + imports: Schema.optionalKey(Schema.Record(Schema.String, ExportTargetCodec)), }) export type PackageManifest = typeof PackageManifest.Type + +/** A target reduced to the path a consumer resolves and the condition that selected it. */ +export type ResolvedTarget = { + readonly path: string + readonly condition: string | null +} + +const CONDITION_FALLBACKS = ['types', 'import', 'default'] as const + +const conditionOrder = (preferred: string): ReadonlyArray => [ + preferred, + ...CONDITION_FALLBACKS.filter((condition) => condition !== preferred), +] + +const firstResolved = (options: ReadonlyArray>): Option.Option => + Arr.match(Arr.getSomes(options), { + onEmpty: () => Option.none(), + onNonEmpty: (resolved) => Option.some(Arr.headNonEmpty(resolved)), + }) + +const declaredCondition = (conditions: { readonly [condition: string]: ExportTarget }, condition: string): Option.Option => + Object.hasOwn(conditions, condition) ? Option.fromNullishOr(conditions[condition]) : Option.none() + +const conditionEntries = ( + conditions: { readonly [condition: string]: ExportTarget }, + preferred: string, +): ReadonlyArray => { + const ordered = conditionOrder(preferred) + const named = ordered.flatMap((condition) => + Option.match(declaredCondition(conditions, condition), { + onNone: () => Arr.empty(), + onSome: (target) => [[condition, target] as const], + })) + const rest = Object.entries(conditions) + .filter(([condition]) => !ordered.includes(condition)) + .sort(([left], [right]) => Order.String(left, right)) + return [...named, ...rest] +} + +function targetPathIn(target: ExportTarget, preferred: string): Option.Option { + return Match.value(target).pipe( + Match.tag('Path', ({ path }) => Option.some({ path, condition: null })), + Match.tag('Conditions', ({ conditions }) => + firstResolved( + conditionEntries(conditions, preferred).map(([condition, chosen]) => + Option.map(targetPathIn(chosen, preferred), (resolved) => ({ path: resolved.path, condition })), + ), + )), + Match.tag('Fallbacks', ({ targets }) => firstResolved(targets.map((fallback) => targetPathIn(fallback, preferred)))), + Match.tag('Excluded', () => Option.none()), + Match.exhaustive, + ) +} + +/** + * The target a consumer resolves: the requested condition, then `types`, `import`, + * `default`, then the first remaining key by code unit. A bare path resolves as itself + * with no condition; an array resolves through its first target that yields a path. + */ +export const targetPathOf: { + (target: ExportTarget, preferred: string): Option.Option + (target: ExportTarget): (preferred: string) => Option.Option +} = Fn.dual(2, targetPathIn) + +/** A manifest string target, as a target the resolver understands. */ +export const pathTargetOf = (path: string): ExportTarget => ExportPath.make({ path }) diff --git a/packages/api-extractor/src/front-end/read-package.ts b/packages/api-extractor/src/front-end/read-package.ts index bb16c332..831bfb68 100644 --- a/packages/api-extractor/src/front-end/read-package.ts +++ b/packages/api-extractor/src/front-end/read-package.ts @@ -1,10 +1,11 @@ import { Array as Arr, Effect, FileSystem, HashSet, Match, Option, Order, Path, Schema } from 'effect' import type { PlatformError } from 'effect/PlatformError' +import type { AnchorBase, ManifestEntry, PackageSurface } from '../model/api-model.schema.ts' import type { PackageSource, SourceEntryPoint } from './declarations.schema.ts' import { directoryOf, relativeWithin } from './declaration-graph.ts' import { ManifestRejected, NoSourceEntry } from './front-end-error.schema.ts' import { isAbsent } from './package-file.ts' -import { type ExportTarget, PackageManifest } from './package-source.schema.ts' +import { type ExportTarget, PackageManifest, pathTargetOf, type ResolvedTarget, targetPathOf } from './package-source.schema.ts' export const FAMILY_SOURCE_CONDITION = '@systemfsoftware/source' @@ -21,34 +22,47 @@ const isTypeScriptSource = (value: string): boolean => TYPESCRIPT_SOURCE.test(va const stripRelativePrefix = (value: string): string => value.replace(/^\.\//, '') -const sourcePathOf = (target: ExportTarget): Option.Option => - Match.value(target).pipe( - Match.tag('Path', ({ path }) => Option.some(path)), - Match.orElse(() => Option.none()), - ) - -const conditionValue = (target: ExportTarget, condition: string): Option.Option => - Match.value(target).pipe( - Match.tag('Conditions', ({ conditions }) => Option.flatMap(Option.fromNullishOr(conditions[condition]), sourcePathOf)), - Match.orElse(() => Option.none()), - ) - -const entryPointOf = (condition: string) => - ([exportPath, target]: readonly [string, ExportTarget]): Option.Option => - Option.map(Option.filter(conditionValue(target, condition), isTypeScriptSource), (sourceFile) => ({ - exportPath, - sourceFile: stripRelativePrefix(sourceFile), - })) - const byExportPath: Order.Order = Order.mapInput(Order.String, (entry: SourceEntryPoint) => entry.exportPath) const exportMapOf = (exports: PackageManifest['exports']): ReadonlyArray => Match.value(exports).pipe( - Match.when(Match.string, () => []), - Match.when(Match.undefined, () => []), + Match.when(Match.string, (path) => [['.', pathTargetOf(path)] as const]), + Match.when(Match.undefined, () => Arr.empty()), Match.orElse((record) => Object.entries(record).filter(([exportPath]) => exportPath !== './package.json')), ) +const importMapOf = (imports: PackageManifest['imports']): ReadonlyArray => + Match.value(imports).pipe( + Match.when(Match.undefined, () => Arr.empty()), + Match.orElse((record) => Object.entries(record)), + ) + +const manifestEntryOf = (preferred: string) => + ([key, target]: readonly [string, ExportTarget]): ManifestEntry => + Option.match(targetPathOf(target, preferred), { + onNone: () => ({ key, target: null, condition: null }), + onSome: (resolved) => ({ key, target: resolved.path, condition: resolved.condition }), + }) + +const surfaceOf = (manifest: PackageManifest, preferred: string): PackageSurface => ({ + name: manifest.name, + version: manifest.version, + exports: exportMapOf(manifest.exports).map(manifestEntryOf(preferred)), + imports: importMapOf(manifest.imports).map(manifestEntryOf(preferred)), +}) + +const normalisedTarget = (path: string): string => (path.startsWith('./') ? path : `./${path}`) + +type ResolvedEntry = ResolvedTarget & { readonly key: string } + +const entryPointOf = (entry: ResolvedEntry): SourceEntryPoint => ({ + exportPath: entry.key, + sourceFile: stripRelativePrefix(entry.path), + key: entry.key, + condition: entry.condition, + target: normalisedTarget(entry.path), +}) + const starCountOf = (exportPath: string): number => exportPath.split('*').length - 1 const isPatternKey = (exportPath: string): boolean => starCountOf(exportPath) === 1 @@ -75,10 +89,16 @@ const INVALID_SEGMENT = /^(?:|\.|\.\.|node_modules)$/i const isValidPatternMatch = (substring: string): boolean => !substring.split(/[/\\]/).some((segment) => INVALID_SEGMENT.test(segment)) -const patternEntryOf = (key: string, prefix: string, suffix: string) => +const patternEntryOf = (key: string, prefix: string, suffix: string, condition: string | null) => (candidate: string): Option.Option => Option.map(Option.filter(matchedSubstring(prefix, suffix, candidate), isValidPatternMatch), (substring) => ({ - entry: { exportPath: key.replace('*', () => substring), sourceFile: candidate }, + entry: { + exportPath: key.replace('*', () => substring), + sourceFile: candidate, + key, + condition, + target: normalisedTarget(candidate), + }, key, })) @@ -214,31 +234,71 @@ const winningKeyOf = (patternKeys: ReadonlyArray, exportPath: string): O const isWinner = (patternKeys: ReadonlyArray) => (item: PatternEntry): boolean => Option.exists(winningKeyOf(patternKeys, item.entry.exportPath), (key) => key === item.key) -const expandPattern = (root: string, condition: string, key: string, target: ExportTarget): Effect.Effect, WalkError, Platform> => - Option.match(conditionValue(target, condition), { - onNone: () => Effect.succeed>([]), - onSome: (targetText) => +type PatternExpansion = { + readonly key: string + readonly resolved: boolean + readonly entries: ReadonlyArray +} + +const expandPattern = (root: string, preferred: string, key: string, target: ExportTarget): Effect.Effect => + Option.match(targetPathOf(target, preferred), { + onNone: () => Effect.succeed({ key, resolved: false, entries: [] }), + onSome: ({ path: targetText, condition }) => Option.match(patternPartsOf(targetText), { - onNone: () => Effect.succeed>([]), + onNone: () => Effect.succeed({ key, resolved: true, entries: [] }), onSome: (parts) => { const prefix = stripRelativePrefix(parts.prefix) - return Effect.map(walkDirectory({ root, key, prefix }, directoryOf(prefix)), (candidates) => - Arr.getSomes(candidates.filter(isTypeScriptSource).map(patternEntryOf(key, prefix, parts.suffix)))) + return Effect.map(walkDirectory({ root, key, prefix }, directoryOf(prefix)), (candidates) => ({ + key, + resolved: true, + entries: Arr.getSomes(candidates.filter(isTypeScriptSource).map(patternEntryOf(key, prefix, parts.suffix, condition))), + })) }, }), }) -const entryPointsOf = (root: string, manifest: PackageManifest, condition: string): Effect.Effect, WalkError, Platform> => +const resolvedExplicit = (preferred: string) => + ([key, target]: readonly [string, ExportTarget]): Option.Option => + Option.map(targetPathOf(target, preferred), (resolved) => ({ ...resolved, key })) + +const notSource = (entry: ResolvedEntry): boolean => !isTypeScriptSource(entry.path) + +type ReadEntryPoints = { + readonly entryPoints: ReadonlyArray + readonly patternEmpties: ReadonlyArray +} + +const hasDeclaredSurface = (read: ReadEntryPoints, surface: PackageSurface): boolean => + read.entryPoints.length > 0 || surface.imports.length > 0 + +const preferredConditionOf = (request: ReadPackageRequest): string => request.sourceCondition ?? FAMILY_SOURCE_CONDITION + +const entryPointsOf = ( + root: string, + manifest: PackageManifest, + preferred: string, +): Effect.Effect => Effect.gen(function* () { const entries = exportMapOf(manifest.exports) const explicit = entries.filter(([exportPath]) => isExplicitKey(exportPath)) const patterns = entries.filter(([exportPath]) => isPatternKey(exportPath)) + const resolved = Arr.getSomes(explicit.map(resolvedExplicit(preferred))) + yield* Option.match(Arr.findFirst(resolved, notSource), { + onNone: () => Effect.void, + onSome: (entry) => Effect.fail(new NoSourceEntry({ packageRoot: root, exportKey: entry.key, target: entry.path })), + }) const explicitPaths = HashSet.fromIterable(explicit.map(([exportPath]) => exportPath)) - const expanded = yield* Effect.forEach(patterns, ([key, target]) => expandPattern(root, condition, key, target), { concurrency: 1 }) - const chosen = Arr.flatten(expanded) + const expanded = yield* Effect.forEach(patterns, ([key, target]) => expandPattern(root, preferred, key, target), { concurrency: 1 }) + const chosen = Arr.flatten(expanded.map((expansion) => expansion.entries)) .filter((item) => !HashSet.has(explicitPaths, item.entry.exportPath)) .filter(isWinner(patterns.map(([key]) => key))) - return Arr.sort([...explicit.map(entryPointOf(condition)).flatMap(Option.toArray), ...chosen.map((item) => item.entry)], byExportPath) + const explicitEntries = resolved.filter((entry) => !notSource(entry)).map(entryPointOf) + return { + entryPoints: Arr.sort([...explicitEntries, ...chosen.map((item) => item.entry)], byExportPath), + patternEmpties: expanded + .filter((expansion) => expansion.resolved && expansion.entries.length === 0) + .map((expansion) => expansion.key), + } }) const workspaceRootFrom = (directory: string, packageRoot: string): Effect.Effect => @@ -260,15 +320,35 @@ const tsconfigOf = (root: string): Effect.Effect): Effect.Effect => +type SourceReading = { + readonly root: string + readonly manifest: PackageManifest + readonly surface: PackageSurface + readonly entryPoints: ReadonlyArray + readonly patternEmpties: ReadonlyArray +} + +const anchorBaseOf = (insideWorkspace: boolean): AnchorBase => (insideWorkspace ? 'workspace' : 'package') + +const anchorPrefixOf = (insideWorkspace: boolean, relative: string): string => (insideWorkspace ? relative : '') + +const sourceOf = (reading: SourceReading): Effect.Effect => Effect.gen(function* () { + const path = yield* Path.Path + const workspaceRoot = yield* workspaceRootFrom(reading.root, reading.root) + const insideWorkspace = workspaceRoot !== reading.root + const relative = path.relative(workspaceRoot, reading.root).split(path.sep).join('/') const source: PackageSource = { - name: manifest.name, - version: manifest.version, - packageRoot: root, - workspaceRoot: yield* workspaceRootFrom(root, root), - tsconfigPath: yield* tsconfigOf(root), - entryPoints, + name: reading.manifest.name, + version: reading.manifest.version, + packageRoot: reading.root, + workspaceRoot, + tsconfigPath: yield* tsconfigOf(reading.root), + anchorBase: anchorBaseOf(insideWorkspace), + anchorPrefix: anchorPrefixOf(insideWorkspace, relative), + manifest: reading.surface, + patternEmpties: reading.patternEmpties, + entryPoints: reading.entryPoints, } return source }) @@ -281,9 +361,11 @@ export const readPackage = (request: ReadPackageRequest): Effect.Effect Effect.fail(new ManifestRejected({ packageRoot: root, issue: error.message }))), ) - const entryPoints = yield* entryPointsOf(root, manifest, request.sourceCondition ?? FAMILY_SOURCE_CONDITION) - return yield* Arr.match(entryPoints, { - onEmpty: () => Effect.fail(new NoSourceEntry({ packageRoot: root })), - onNonEmpty: (nonEmpty) => sourceOf(root, manifest, nonEmpty), - }) + const preferred = preferredConditionOf(request) + const surface = surfaceOf(manifest, preferred) + const read = yield* entryPointsOf(root, manifest, preferred) + return yield* Match.value(hasDeclaredSurface(read, surface)).pipe( + Match.when(true, () => sourceOf({ root, manifest, surface, entryPoints: read.entryPoints, patternEmpties: read.patternEmpties })), + Match.orElse(() => Effect.fail(new NoSourceEntry({ packageRoot: root }))), + ) }) diff --git a/packages/api-extractor/src/front-end/resolve-messages.ts b/packages/api-extractor/src/front-end/resolve-messages.ts index b169cee7..156b6392 100644 --- a/packages/api-extractor/src/front-end/resolve-messages.ts +++ b/packages/api-extractor/src/front-end/resolve-messages.ts @@ -1,8 +1,6 @@ -import { type StandardModifierTagSet, TSDocParser } from '@microsoft/tsdoc' -import { Array as Arr, Effect, HashMap, HashSet, Match, Option, Order, Predicate } from 'effect' +import { Array as Arr, Effect, Function as Fn, HashMap, HashSet, Match, Option, Order } from 'effect' import { type Class, - type Comment, type Expression, type Program, type TSClassImplements, @@ -20,45 +18,25 @@ import { type TSTypeReference, Visitor, } from 'oxc-parser' -import type { AnalysisMessage, AnalysisMessageId } from './analysis-message.schema.ts' +import type { Anchor } from '../model/anchor.schema.ts' +import type { AnalysisMessage, AnalysisMessageId } from '../model/message.schema.ts' import { typeParameterNames } from './canonical-type-text.ts' import type { Declaration, DeclarationGraph, EntryDeclaration, FileScope, ResolvedSymbol } from './declaration-graph.ts' import { declarationGraph, exportedNames, exportedSymbol, namespaceSpacesOf, resolveName } from './declaration-graph.ts' import type { Declarations } from './declarations.schema.ts' import type { UnsupportedDeclaration } from './front-end-error.schema.ts' +import { sourceIndexOf, type SourceIndex } from './source-index.ts' +import { docFactsOf, leadingDocComment, precedingEnd, type TSDocReleaseTag } from './tsdoc.ts' -type ReleaseTag = 'internal' | 'alpha' | 'beta' | 'public' +type ReleaseTag = TSDocReleaseTag const RELEASE_TAG_RANK: Record = { internal: 1, alpha: 2, beta: 3, public: 4 } -const releaseTagParser: TSDocParser = new TSDocParser() - -const docCommentText = (comment: string): string => `/*${comment}*/` - -const tagIn = (tags: StandardModifierTagSet): Option.Option => - Match.value(tags).pipe( - Match.when((candidate) => candidate.isPublic(), () => Option.some('public')), - Match.when((candidate) => candidate.isBeta(), () => Option.some('beta')), - Match.when((candidate) => candidate.isAlpha(), () => Option.some('alpha')), - Match.when((candidate) => candidate.isInternal(), () => Option.some('internal')), - Match.orElse(() => Option.none()), - ) - -const tagOfComment = (comment: string): Option.Option => - tagIn(releaseTagParser.parseString(docCommentText(comment)).docComment.modifierTagSet) - -const isDocComment = (comment: Comment): boolean => comment.type === 'Block' && comment.value.startsWith('*') - -const precedingEndOf = (scope: FileScope, statementStart: number): number => - Arr.reduce(scope.program.body, 0, (end, statement) => (statement.end <= statementStart ? Math.max(end, statement.end) : end)) - -const isBetween = (floor: number, ceiling: number) => (comment: Comment): boolean => comment.start >= floor && comment.end <= ceiling - -const leadingCommentOf = (scope: FileScope, statementStart: number): Option.Option => - Arr.findLast(scope.comments, Predicate.and(isDocComment, isBetween(precedingEndOf(scope, statementStart), statementStart))) +const leadingCommentOf = (scope: FileScope, statementStart: number) => + leadingDocComment(scope.comments, precedingEnd(scope.program.body, statementStart), statementStart) const declaredTagOf = (scope: FileScope, declaration: Declaration): Option.Option => - Option.flatMap(leadingCommentOf(scope, declaration.statementStart), (comment) => tagOfComment(comment.value)) + Option.flatMap(leadingCommentOf(scope, declaration.statementStart), (comment) => docFactsOf(comment.value).releaseTag) const tagOf = (graph: DeclarationGraph, file: string, declaration: Declaration): Option.Option => Option.flatMap(HashMap.get(graph.files, file), (scope) => declaredTagOf(scope, declaration)) @@ -380,26 +358,37 @@ const mergeByKey = (items: ReadonlyArray): HashMap.HashMap(), (merged, item) => HashMap.set(merged, itemKey(item.file, item.name), intoItem(HashMap.get(merged, itemKey(item.file, item.name)), item))) -const analysisMessage = (messageId: AnalysisMessageId, entryPoint: string, item: string, symbol: string): AnalysisMessage => ({ +const analysisMessage = (anchor: Anchor | null, messageId: AnalysisMessageId, entryPoint: string, item: string, symbol: string): AnalysisMessage => ({ messageId, entryPoint, item, symbol, + anchor, }) -const untagged = (graph: DeclarationGraph, item: ExportedItem): ReadonlyArray => - Arr.filter(item.declarations, (declaration) => Option.isNone(tagOf(graph, item.file, declaration))) +type ItemContext = { + readonly resolution: Resolution + readonly entry: EntryDeclaration + readonly consumable: HashMap.HashMap + readonly item: ExportedItem + readonly anchor: Anchor | null +} -const missingTagMessages = (graph: DeclarationGraph, entry: EntryDeclaration, item: ExportedItem): ReadonlyArray => - Arr.map(untagged(graph, item), (declaration) => analysisMessage('ae-missing-release-tag', entry.exportPath, item.namePath, declaration.declared.name)) +const untagged = (context: ItemContext): ReadonlyArray => + Arr.filter(context.item.declarations, (declaration) => Option.isNone(tagOf(context.resolution.graph, context.item.file, declaration))) + +const missingTagMessages = (context: ItemContext): ReadonlyArray => + Arr.map(untagged(context), (declaration) => + analysisMessage(context.anchor, 'ae-missing-release-tag', context.entry.exportPath, context.item.namePath, declaration.declared.name)) const needsUnderscore = (maxTag: ReleaseTag, item: ExportedItem): boolean => maxTag === 'internal' && !item.namePath.includes('.') const lacksUnderscore = (name: string): boolean => !name.startsWith('_') -const underscoreMessages = (maxTag: ReleaseTag, entry: EntryDeclaration, item: ExportedItem): ReadonlyArray => - needsUnderscore(maxTag, item) - ? Arr.map(Arr.filter(item.exportNames, lacksUnderscore), (name) => analysisMessage('ae-internal-missing-underscore', entry.exportPath, item.namePath, name)) +const underscoreMessages = (maxTag: ReleaseTag, context: ItemContext): ReadonlyArray => + needsUnderscore(maxTag, context.item) + ? Arr.map(Arr.filter(context.item.exportNames, lacksUnderscore), (name) => + analysisMessage(context.anchor, 'ae-internal-missing-underscore', context.entry.exportPath, context.item.namePath, name)) : [] const allTagged = (maxTag: ReleaseTag, tags: ReadonlyArray): boolean => Arr.every(tags, (tag) => tag === maxTag) @@ -407,8 +396,10 @@ const allTagged = (maxTag: ReleaseTag, tags: ReadonlyArray): boolean const mixedTagged = (maxTag: ReleaseTag, tags: ReadonlyArray): boolean => !allTagged(maxTag, tags) && Arr.some(tags, (tag) => tag === 'internal') -const mixedMessages = (entry: EntryDeclaration, item: ExportedItem, maxTag: ReleaseTag, tags: ReadonlyArray): ReadonlyArray => - mixedTagged(maxTag, tags) ? [analysisMessage('ae-internal-mixed-release-tag', entry.exportPath, item.namePath, item.name)] : [] +const mixedMessages = (maxTag: ReleaseTag, tags: ReadonlyArray, context: ItemContext): ReadonlyArray => + mixedTagged(maxTag, tags) + ? [analysisMessage(context.anchor, 'ae-internal-mixed-release-tag', context.entry.exportPath, context.item.namePath, context.item.name)] + : [] const effectiveTagsOf = (graph: DeclarationGraph, item: ExportedItem): ReadonlyArray => Arr.map(item.declarations, (declaration) => effectiveTagOf(tagOf(graph, item.file, declaration))) @@ -424,96 +415,82 @@ const morePublic = (declaring: ReleaseTag, referenced: ReleaseTag): boolean => R const declaringTag = (graph: DeclarationGraph, file: string, declaration: Declaration): ReleaseTag => effectiveTagOf(tagOf(graph, file, declaration)) const incompatibleMessages = ( - graph: DeclarationGraph, - entry: EntryDeclaration, - item: ExportedItem, + context: ItemContext, declaration: Declaration, referenced: ExportedItem, ): ReadonlyArray => - morePublic(declaringTag(graph, item.file, declaration), maxTagIn(graph, referenced)) - ? [analysisMessage('ae-incompatible-release-tags', entry.exportPath, item.namePath, item.name)] + morePublic(declaringTag(context.resolution.graph, context.item.file, declaration), maxTagIn(context.resolution.graph, referenced)) + ? [analysisMessage(context.anchor, 'ae-incompatible-release-tags', context.entry.exportPath, context.item.namePath, context.item.name)] : [] const localMessages = ( - resolution: Resolution, - entry: EntryDeclaration, - consumable: HashMap.HashMap, - item: ExportedItem, + context: ItemContext, declaration: Declaration, symbol: Extract, ): ReadonlyArray => - Option.match(HashMap.get(consumable, itemKey(symbol.file, symbol.name)), { - onNone: () => [analysisMessage('ae-forgotten-export', entry.exportPath, item.namePath, symbol.name)], - onSome: (referenced) => incompatibleMessages(resolution.graph, entry, item, declaration, referenced), + Option.match(HashMap.get(context.consumable, itemKey(symbol.file, symbol.name)), { + onNone: () => [analysisMessage(context.anchor, 'ae-forgotten-export', context.entry.exportPath, context.item.namePath, symbol.name)], + onSome: (referenced) => incompatibleMessages(context, declaration, referenced), }) const resolvedNameMessages = ( - resolution: Resolution, - entry: EntryDeclaration, - consumable: HashMap.HashMap, - item: ExportedItem, + context: ItemContext, declaration: Declaration, symbol: ResolvedSymbol, ): ReadonlyArray => Match.value(symbol).pipe( - Match.when({ kind: 'local' }, (local) => localMessages(resolution, entry, consumable, item, declaration, local)), + Match.when({ kind: 'local' }, (local) => localMessages(context, declaration, local)), Match.orElse(() => []), ) -const nameMessages = ( - resolution: Resolution, - entry: EntryDeclaration, - consumable: HashMap.HashMap, - item: ExportedItem, - declaration: Declaration, - name: string, -): ReadonlyArray => - Option.match(resolveName(resolution.graph, item.file, name), { +const nameMessages = (context: ItemContext, declaration: Declaration, name: string): ReadonlyArray => + Option.match(resolveName(context.resolution.graph, context.item.file, name), { onNone: () => [], - onSome: (symbol) => resolvedNameMessages(resolution, entry, consumable, item, declaration, symbol), + onSome: (symbol) => resolvedNameMessages(context, declaration, symbol), }) -const declarationMessages = ( - resolution: Resolution, - entry: EntryDeclaration, - consumable: HashMap.HashMap, - item: ExportedItem, - declaration: Declaration, -): ReadonlyArray => - Option.match(HashMap.get(resolution.graph.files, item.file), { +const declarationMessages = (context: ItemContext, declaration: Declaration): ReadonlyArray => + Option.match(HashMap.get(context.resolution.graph.files, context.item.file), { onNone: () => [], onSome: (scope) => - Arr.flatMap(referenceNames(scanFor(resolution, item.file), scope, declaration), (name) => - nameMessages(resolution, entry, consumable, item, declaration, name)), + Arr.flatMap(referenceNames(scanFor(context.resolution, context.item.file), scope, declaration), (name) => + nameMessages(context, declaration, name)), }) -const referenceMessages = ( - resolution: Resolution, - entry: EntryDeclaration, - consumable: HashMap.HashMap, - item: ExportedItem, -): ReadonlyArray => - Arr.flatMap(item.declarations, (declaration) => declarationMessages(resolution, entry, consumable, item, declaration)) +const referenceMessages = (context: ItemContext): ReadonlyArray => + Arr.flatMap(context.item.declarations, (declaration) => declarationMessages(context, declaration)) + +const declaredAnchor = (index: SourceIndex, item: ExportedItem, declaration: Declaration): Option.Option => + Option.fromNullishOr(index.metaOf(item.file, declaration.declared.name, declaration.declared.kind, item.namePath).anchor) + +/** The anchor of the first declaration of an exported item. */ +const itemAnchor = (index: SourceIndex, item: ExportedItem): Anchor | null => + Arr.match(Arr.getSomes(item.declarations.map((declaration) => declaredAnchor(index, item, declaration))), { + onEmpty: () => null, + onNonEmpty: (anchors) => Arr.headNonEmpty(anchors), + }) const itemMessages = ( resolution: Resolution, + index: SourceIndex, entry: EntryDeclaration, consumable: HashMap.HashMap, item: ExportedItem, ): ReadonlyArray => { const tags = effectiveTagsOf(resolution.graph, item) const maxTag = maxTagOf(tags) + const context: ItemContext = { resolution, entry, consumable, item, anchor: itemAnchor(index, item) } return [ - ...missingTagMessages(resolution.graph, entry, item), - ...underscoreMessages(maxTag, entry, item), - ...mixedMessages(entry, item, maxTag, tags), - ...referenceMessages(resolution, entry, consumable, item), + ...missingTagMessages(context), + ...underscoreMessages(maxTag, context), + ...mixedMessages(maxTag, tags, context), + ...referenceMessages(context), ] } -const entryMessages = (resolution: Resolution, entry: EntryDeclaration): ReadonlyArray => { +const entryMessages = (resolution: Resolution, index: SourceIndex, entry: EntryDeclaration): ReadonlyArray => { const consumable = mergeByKey(itemList(resolution.graph, entry.declarationFile)) - return Arr.flatMap(Arr.fromIterable(HashMap.values(consumable)), (item) => itemMessages(resolution, entry, consumable, item)) + return Arr.flatMap(Arr.fromIterable(HashMap.values(consumable)), (item) => itemMessages(resolution, index, entry, consumable, item)) } const messageKey = (message: AnalysisMessage): string => [message.messageId, message.entryPoint, message.item, message.symbol].join('\u0000') @@ -528,13 +505,17 @@ const analysisMessageOrder: Order.Order = Order.combineAll([ Order.mapInput(Order.String, (message: AnalysisMessage) => message.symbol), ]) -const messagesFor = (graph: DeclarationGraph): ReadonlyArray => { +const messagesFor = (graph: DeclarationGraph, index: SourceIndex): ReadonlyArray => { const resolution = resolutionOf(graph) - return Arr.sort(deduped(Arr.flatMap(graph.entryPoints, (entry) => entryMessages(resolution, entry))), analysisMessageOrder) + return Arr.sort(deduped(Arr.flatMap(graph.entryPoints, (entry) => entryMessages(resolution, index, entry))), analysisMessageOrder) } -export const messagesOfGraph = (graph: DeclarationGraph): Effect.Effect, UnsupportedDeclaration> => - Effect.succeed(messagesFor(graph)) +export const messagesOfGraph: { + (graph: DeclarationGraph, index: SourceIndex): Effect.Effect, UnsupportedDeclaration> + (graph: DeclarationGraph): (index: SourceIndex) => Effect.Effect, UnsupportedDeclaration> +} = Fn.dual(2, (graph: DeclarationGraph, index: SourceIndex): Effect.Effect, UnsupportedDeclaration> => + Effect.succeed(messagesFor(graph, index))) export const resolveMessages = (declarations: Declarations): Effect.Effect, UnsupportedDeclaration> => - Effect.map(declarationGraph(declarations), messagesFor) + Effect.map(declarationGraph(declarations), (graph) => + messagesFor(graph, sourceIndexOf({ sources: declarations.sources, anchorPrefix: declarations.anchorPrefix }))) diff --git a/packages/api-extractor/src/front-end/source-index.ts b/packages/api-extractor/src/front-end/source-index.ts new file mode 100644 index 00000000..4adaecea --- /dev/null +++ b/packages/api-extractor/src/front-end/source-index.ts @@ -0,0 +1,446 @@ +/** + * Source-derived facts for the model: anchors, release tags and `@stability`. + * + * The index parses each source text the emitter read with oxc-parser, so both + * emitter routes give the same anchor for the same package without declaration maps. + */ +import { Array as Arr, HashMap, Match, Option, Order } from 'effect' +import { + type Class, + type Comment, + type Directive, + type ExportAllDeclaration, + type ExportDefaultDeclaration, + type ExportNamedDeclaration, + type Function as FunctionNode, + type ModuleExportName, + parseSync, + type Program, + type Statement, + type TSEnumDeclaration, + type TSGlobalDeclaration, + type TSInterfaceDeclaration, + type TSModuleBlock, + type TSModuleDeclaration, + type TSTypeAliasDeclaration, + type VariableDeclaration, +} from 'oxc-parser' +import type { CoverageReason } from '../model/api-model.schema.ts' +import { type Anchor, type ItemMeta, NO_ITEM_META, type ReleaseTag } from '../model/anchor.schema.ts' +import { sourceCandidatesOf } from './declaration-graph.ts' +import type { SourceFile } from './declarations.schema.ts' +import { docFactsOf, leadingDocComment, precedingEnd, type SourceDocComment } from './tsdoc.ts' + +const SOURCE_KINDS = ['function', 'variable', 'typeAlias', 'interface', 'class', 'enum', 'namespace'] as const + +export type SourceKind = (typeof SOURCE_KINDS)[number] + +/** A declaration the source holds, at its anchor and its path inside the file's namespaces. */ +export type SourceDeclaration = { + readonly file: string + readonly line: number + /** The dotted path from the file's top level, e.g. `NamespaceA.list`. */ + readonly path: string + readonly name: string + readonly kind: SourceKind + readonly releaseTag: ReleaseTag + readonly stability: string | null +} + +/** A re-export statement in a source file; `star` marks `export * from`. */ +export type SourceReExport = { + readonly anchor: Anchor + readonly module: string + readonly names: ReadonlyArray + readonly star: boolean + readonly releaseTag: ReleaseTag + readonly stability: string | null +} + +/** An ambient block in a source file. */ +export type SourceAmbient = { + readonly kind: 'global' | 'module' + readonly module: string | null + readonly line: number +} + +export type SourceIndex = { + readonly declarationsOf: (file: string) => ReadonlyArray + readonly reExportsOf: (file: string) => ReadonlyArray + readonly ambientOf: (file: string) => ReadonlyArray + /** + * The metadata of the declaration a declaration file holds, found through its + * source. `path` is the item's dotted path inside the file; the name alone is + * the fallback when the path finds no declaration. + */ + readonly metaOf: (declarationFile: string, name: string, kind: SourceKind, path: string) => ItemMeta + /** The metadata of a `export * as name from '...'` statement in a declaration file. */ + readonly reExportMetaOf: (declarationFile: string, name: string) => ItemMeta + /** Sources the index could not parse. */ + readonly reasons: ReadonlyArray +} + +export type SourceIndexRequest = { + readonly sources: ReadonlyArray + /** The package's path from the anchor root, joined with `/`; empty at the root. */ + readonly anchorPrefix: string +} + +const byPath: Order.Order = Order.mapInput(Order.String, (source: SourceFile) => source.path) + +type LineCursor = { offset: number; line: number } + +/** Declarations are visited in source order, so each line count only scans the text since the last one. */ +const lineAt = (context: Context, offset: number): number => { + context.cursor.line += context.text.slice(context.cursor.offset, offset).split('\n').length - 1 + context.cursor.offset = offset + return context.cursor.line +} + +const DECLARATION_SUFFIXES = ['.d.ts', '.d.mts', '.d.cts'] as const + +const isDeclarationSource = (file: string): boolean => Arr.some(DECLARATION_SUFFIXES, (suffix) => file.endsWith(suffix)) + +const languageOf = (file: string): 'ts' | 'tsx' | 'dts' => + Match.value(file).pipe( + Match.when(isDeclarationSource, () => 'dts' as const), + Match.when((path) => path.endsWith('.tsx'), () => 'tsx' as const), + Match.orElse(() => 'ts' as const), + ) + +const anchorFileOf = (anchorPrefix: string, file: string): string => (anchorPrefix === '' ? file : `${anchorPrefix}/${file}`) + +const named = (name: string | undefined): string => Option.getOrElse(Option.fromNullishOr(name), () => 'default') + +const functionName = (node: FunctionNode): string => named(node.id?.name) + +const className = (node: Class): string => named(node.id?.name) + +const moduleNameOf = (name: ModuleExportName): string => + Match.value(name).pipe( + Match.when({ type: 'Literal' }, (literal) => literal.value), + Match.orElse((other) => other.name), + ) + +type Collected = { + readonly declarations: Array + readonly reExports: Array + readonly ambient: Array +} + +type Context = { + readonly anchorFile: string + readonly text: string + readonly comments: ReadonlyArray + readonly collected: Collected + /** The namespaces the current statement sits in, outermost first. */ + readonly namePath: ReadonlyArray + readonly cursor: LineCursor + /** + * The offset a leading doc comment may start at. A statement's own comments sit + * after its preceding sibling, but the first statement in a block has no sibling, + * so the block's opening brace is the floor there; without it the lookup would + * reach back past the namespace into an unrelated top-level comment. + */ + readonly floor: number +} + +type StatementNode = Directive | Statement + +const factsOf = ( + context: Context, + siblings: ReadonlyArray<{ readonly end: number }>, + at: number, +): { readonly releaseTag: ReleaseTag; readonly stability: string | null } => + Option.match(leadingDocComment(context.comments, Math.max(context.floor, precedingEnd(siblings, at)), at), { + onNone: (): { readonly releaseTag: ReleaseTag; readonly stability: string | null } => ({ releaseTag: 'untagged', stability: null }), + onSome: (comment) => { + const facts = docFactsOf(comment.value) + return { + releaseTag: Option.getOrElse(facts.releaseTag, (): ReleaseTag => 'untagged'), + stability: Option.getOrNull(facts.stability), + } + }, + }) + +const addDeclaration = ( + context: Context, + siblings: ReadonlyArray<{ readonly end: number }>, + name: string, + kind: SourceKind, + at: number, +): void => { + const facts = factsOf(context, siblings, at) + void context.collected.declarations.push({ + file: context.anchorFile, + line: lineAt(context, at), + path: [...context.namePath, name].join('.'), + name, + kind, + releaseTag: facts.releaseTag, + stability: facts.stability, + }) +} + +const recordFunction = (context: Context, siblings: ReadonlyArray<{ readonly end: number }>, node: FunctionNode): void => + addDeclaration(context, siblings, functionName(node), 'function', node.start) + +const recordClass = (context: Context, siblings: ReadonlyArray<{ readonly end: number }>, node: Class): void => + addDeclaration(context, siblings, className(node), 'class', node.start) + +const recordInterface = (context: Context, siblings: ReadonlyArray<{ readonly end: number }>, node: TSInterfaceDeclaration): void => + addDeclaration(context, siblings, node.id.name, 'interface', node.start) + +const recordAlias = (context: Context, siblings: ReadonlyArray<{ readonly end: number }>, node: TSTypeAliasDeclaration): void => + addDeclaration(context, siblings, node.id.name, 'typeAlias', node.start) + +const recordEnum = (context: Context, siblings: ReadonlyArray<{ readonly end: number }>, node: TSEnumDeclaration): void => + addDeclaration(context, siblings, node.id.name, 'enum', node.start) + +const recordVariable = ( + context: Context, + siblings: ReadonlyArray<{ readonly end: number }>, + node: VariableDeclaration, +): void => { + Arr.forEach(node.declarations, (declarator) => { + if (declarator.id.type === 'Identifier') { + addDeclaration(context, siblings, declarator.id.name, 'variable', declarator.start) + } + }) +} + +const recordNamedExport = ( + context: Context, + siblings: ReadonlyArray, + node: ExportNamedDeclaration, +): void => { + Option.match(Option.fromNullishOr(node.declaration), { + onNone: () => + Option.match(Option.fromNullishOr(node.source), { + onNone: () => undefined, + onSome: (source) => { + const facts = factsOf(context, siblings, node.start) + void context.collected.reExports.push({ + anchor: { file: context.anchorFile, line: lineAt(context, node.start) }, + module: source.value, + names: node.specifiers.map((specifier) => moduleNameOf(specifier.exported)), + star: false, + releaseTag: facts.releaseTag, + stability: facts.stability, + }) + }, + }), + onSome: (declaration) => recordStatement(context, siblings, declaration), + }) +} + +const recordStarExport = ( + context: Context, + siblings: ReadonlyArray, + node: ExportAllDeclaration, +): void => { + const names = Option.match(Option.fromNullishOr(node.exported), { + onNone: () => Arr.empty(), + onSome: (exported) => [moduleNameOf(exported)], + }) + const facts = factsOf(context, siblings, node.start) + void context.collected.reExports.push({ + anchor: { file: context.anchorFile, line: lineAt(context, node.start) }, + module: node.source.value, + names, + star: node.exported === null, + releaseTag: facts.releaseTag, + stability: facts.stability, + }) +} + +const recordDefaultExport = ( + context: Context, + siblings: ReadonlyArray, + node: ExportDefaultDeclaration, +): void => + Match.value(node.declaration).pipe( + Match.when( + { type: Match.is('FunctionDeclaration', 'TSDeclareFunction') }, + (declaration: FunctionNode) => recordFunction(context, siblings, declaration), + ), + Match.when({ type: 'ClassDeclaration' }, (declaration: Class) => recordClass(context, siblings, declaration)), + Match.when({ type: 'TSInterfaceDeclaration' }, (declaration: TSInterfaceDeclaration) => recordInterface(context, siblings, declaration)), + Match.orElse(() => undefined), + ) + +const recordAmbient = (context: Context, kind: 'global' | 'module', module: string | null, at: number): void => { + void context.collected.ambient.push({ kind, module, line: lineAt(context, at) }) +} + +const recordBlock = (context: Context, body: TSModuleBlock | TSModuleDeclaration | null): void => { + Match.value(body).pipe( + Match.when({ type: 'TSModuleBlock' }, (block: TSModuleBlock) => { + const scoped: Context = { ...context, floor: block.start } + Arr.forEach(block.body, (statement) => recordStatement(scoped, block.body, statement)) + }), + Match.orElse(() => undefined), + ) +} + +const recordModuleId = ( + context: Context, + siblings: ReadonlyArray, + node: TSModuleDeclaration, +): void => + Match.value(node.id).pipe( + Match.when({ type: 'Identifier' }, (id) => { + addDeclaration(context, siblings, id.name, 'namespace', node.start) + recordBlock({ ...context, namePath: [...context.namePath, id.name] }, node.body) + }), + Match.when({ type: 'Literal' }, (id) => recordAmbient(context, 'module', id.value, node.start)), + Match.orElse(() => undefined), + ) + +const recordModule = ( + context: Context, + siblings: ReadonlyArray, + node: TSModuleDeclaration | TSGlobalDeclaration, +): void => (node.global ? recordAmbient(context, 'global', null, node.start) : recordModuleId(context, siblings, node)) + +const recordStatement = ( + context: Context, + siblings: ReadonlyArray, + statement: StatementNode, +): void => { + void Match.value(statement).pipe( + Match.discriminators('type')({ + ExportNamedDeclaration: (node) => recordNamedExport(context, siblings, node), + ExportAllDeclaration: (node) => recordStarExport(context, siblings, node), + ExportDefaultDeclaration: (node) => recordDefaultExport(context, siblings, node), + VariableDeclaration: (node) => recordVariable(context, siblings, node), + TSModuleDeclaration: (node) => recordModule(context, siblings, node), + FunctionDeclaration: (node) => recordFunction(context, siblings, node), + TSDeclareFunction: (node) => recordFunction(context, siblings, node), + ClassDeclaration: (node) => recordClass(context, siblings, node), + TSInterfaceDeclaration: (node) => recordInterface(context, siblings, node), + TSTypeAliasDeclaration: (node) => recordAlias(context, siblings, node), + TSEnumDeclaration: (node) => recordEnum(context, siblings, node), + }), + Match.orElse(() => undefined), + ) +} + +type ParsedSource = { + readonly path: string + readonly declarations: ReadonlyArray + readonly reExports: ReadonlyArray + readonly ambient: ReadonlyArray + readonly reasons: ReadonlyArray +} + +const parseFailure = (file: string, message: string): CoverageReason => ({ + kind: 'parse', + file, + detail: `the source file could not be parsed (${message})`, +}) + +const collectSource = (file: SourceFile, anchorPrefix: string, program: Program, comments: ReadonlyArray): ParsedSource => { + const collected: Collected = { declarations: [], reExports: [], ambient: [] } + const context: Context = { + anchorFile: anchorFileOf(anchorPrefix, file.path), + text: file.text, + comments: comments.map((comment) => ({ type: comment.type, value: comment.value, start: comment.start, end: comment.end })), + collected, + namePath: [], + cursor: { offset: 0, line: 1 }, + floor: 0, + } + Arr.forEach(program.body, (statement) => recordStatement(context, program.body, statement)) + return { path: file.path, ...collected, reasons: [] } +} + +const indexSource = (file: SourceFile, anchorPrefix: string): ParsedSource => { + const result = parseSync(file.path, file.text, { lang: languageOf(file.path), sourceType: 'module' }) + return Arr.match(result.errors, { + onEmpty: () => collectSource(file, anchorPrefix, result.program, result.comments), + onNonEmpty: (errors) => ({ + path: file.path, + declarations: [], + reExports: [], + ambient: [], + reasons: errors.map((error) => parseFailure(file.path, error.message)), + }), + }) +} + +const sourceFileOf = ( + sources: HashMap.HashMap, + declarationFile: string, +): Option.Option => + Arr.findFirst([declarationFile, ...sourceCandidatesOf(declarationFile)], (candidate) => HashMap.has(sources, candidate)) + +const declarationIn = ( + declarations: ReadonlyArray, + name: string, + kind: SourceKind, + path: string, +): Option.Option => + Option.orElse( + Arr.findFirst(declarations, (declaration) => declaration.kind === kind && declaration.path === path), + () => Option.orElse( + Arr.findFirst(declarations, (declaration) => declaration.path === path), + () => Option.orElse( + Arr.findFirst(declarations, (declaration) => declaration.kind === kind && declaration.name === name), + () => Arr.findFirst(declarations, (declaration) => declaration.name === name), + ), + ), + ) + +/** An index over nothing: every item is unanchored and untagged, as the synthetic builder path declares. */ +export const emptySourceIndex: SourceIndex = { + declarationsOf: () => [], + reExportsOf: () => [], + ambientOf: () => [], + metaOf: () => NO_ITEM_META, + reExportMetaOf: () => NO_ITEM_META, + reasons: [], +} + +export const sourceIndexOf = (request: SourceIndexRequest): SourceIndex => { + const parsed = Arr.sort(request.sources, byPath).map((source) => indexSource(source, request.anchorPrefix)) + const sources = HashMap.fromIterable(parsed.map((source) => [source.path, source] as const)) + const parsedAt = (file: string): Option.Option => HashMap.get(sources, file) + const declarationsOf = (file: string): ReadonlyArray => + Option.match(parsedAt(file), { onNone: () => [], onSome: (source) => source.declarations }) + const reExportsOf = (file: string): ReadonlyArray => + Option.match(parsedAt(file), { onNone: () => [], onSome: (source) => source.reExports }) + const ambientOf = (file: string): ReadonlyArray => + Option.match(parsedAt(file), { onNone: () => [], onSome: (source) => source.ambient }) + const metaOfDeclaration = (declaration: SourceDeclaration): ItemMeta => ({ + anchor: { file: declaration.file, line: declaration.line }, + releaseTag: declaration.releaseTag, + stability: declaration.stability, + }) + const metaOf = (declarationFile: string, name: string, kind: SourceKind, path: string): ItemMeta => + Option.match(Option.flatMap(sourceFileOf(sources, declarationFile), parsedAt), { + onNone: () => NO_ITEM_META, + onSome: (source) => + Option.match(declarationIn(source.declarations, name, kind, path), { + onNone: () => NO_ITEM_META, + onSome: metaOfDeclaration, + }), + }) + const reExportMetaOf = (declarationFile: string, name: string): ItemMeta => + Option.match(Option.flatMap(sourceFileOf(sources, declarationFile), parsedAt), { + onNone: () => NO_ITEM_META, + onSome: (source) => + Option.match(Arr.findFirst(source.reExports, (record) => Arr.some(record.names, (exported) => exported === name)), { + onNone: () => NO_ITEM_META, + onSome: (record) => ({ anchor: record.anchor, releaseTag: record.releaseTag, stability: record.stability }), + }), + }) + return { + declarationsOf, + reExportsOf, + ambientOf, + metaOf, + reExportMetaOf, + reasons: Arr.flatMap(parsed, (source) => source.reasons), + } +} diff --git a/packages/api-extractor/src/front-end/tsdoc.ts b/packages/api-extractor/src/front-end/tsdoc.ts new file mode 100644 index 00000000..d47727e0 --- /dev/null +++ b/packages/api-extractor/src/front-end/tsdoc.ts @@ -0,0 +1,102 @@ +/** + * The TSDoc facts both the source index and the message resolver read: the release + * tag, Effect's `@stability` annotation, and the comment that leads a declaration. + */ +import { + type DocComment, + DocParagraph, + DocPlainText, + type DocNode, + type StandardModifierTagSet, + TSDocConfiguration, + TSDocParser, + TSDocTagDefinition, + TSDocTagSyntaxKind, +} from '@microsoft/tsdoc' +import { Array as Arr, Function as Fn, Match, Option, Predicate } from 'effect' + +/** The release tags TSDoc names. `untagged` is the model's default, not a TSDoc tag. */ +export type TSDocReleaseTag = 'internal' | 'alpha' | 'beta' | 'public' + +export type DocFacts = { + readonly releaseTag: Option.Option + readonly stability: Option.Option +} + +const STABILITY_TAG = '@stability' + +const parserConfiguration: TSDocConfiguration = new TSDocConfiguration() +parserConfiguration.addTagDefinitions([ + new TSDocTagDefinition({ tagName: STABILITY_TAG, syntaxKind: TSDocTagSyntaxKind.BlockTag }), +]) + +const parser: TSDocParser = new TSDocParser(parserConfiguration) + +const docCommentText = (comment: string): string => `/*${comment}*/` + +const parsedComment = (comment: string): DocComment => parser.parseString(docCommentText(comment)).docComment + +const releaseTagIn = (tags: StandardModifierTagSet): Option.Option => + Match.value(tags).pipe( + Match.when((candidate) => candidate.isPublic(), () => Option.some('public')), + Match.when((candidate) => candidate.isBeta(), () => Option.some('beta')), + Match.when((candidate) => candidate.isAlpha(), () => Option.some('alpha')), + Match.when((candidate) => candidate.isInternal(), () => Option.some('internal')), + Match.orElse(() => Option.none()), + ) + +const plainTextOf = (node: DocNode): ReadonlyArray => + node instanceof DocPlainText ? [node.text] : [] + +const paragraphTextOf = (node: DocNode): ReadonlyArray => + node instanceof DocParagraph ? [Arr.flatMap(node.getChildNodes(), plainTextOf).join('').trim()] : [] + +const stabilityOf = (comment: DocComment): Option.Option => + Option.flatMap( + Arr.findFirst(comment.customBlocks, (block) => block.blockTag.tagName === STABILITY_TAG), + (block) => + Option.filter( + Option.some(Arr.flatMap(block.content.nodes, paragraphTextOf).join('').trim()), + (text) => text.length > 0, + ), + ) + +/** The release tag and `@stability` value a doc comment declares, if any. */ +export const docFactsOf = (comment: string): DocFacts => { + const parsed = parsedComment(comment) + return { + releaseTag: releaseTagIn(parsed.modifierTagSet), + stability: stabilityOf(parsed), + } +} + +export type SourceComment = { + readonly type: string + readonly value: string +} + +export type SourceDocComment = SourceComment & { + readonly start: number + readonly end: number +} + +export const isDocComment = (comment: SourceComment): boolean => + comment.type === 'Block' && comment.value.startsWith('*') + +/** The end of the last sibling statement that finishes before `at`; comments start after it. */ +export const precedingEnd: { + (statements: ReadonlyArray<{ readonly end: number }>, at: number): number + (at: number): (statements: ReadonlyArray<{ readonly end: number }>) => number +} = Fn.dual(2, (statements: ReadonlyArray<{ readonly end: number }>, at: number): number => + Arr.reduce(statements, 0, (end, statement) => (statement.end <= at ? Math.max(end, statement.end) : end))) + +const startsAtOrAfter = (floor: number) => (comment: SourceDocComment): boolean => comment.start >= floor + +const endsAtOrBefore = (at: number) => (comment: SourceDocComment): boolean => comment.end <= at + +/** The doc comment that leads the statement starting at `at`, after the sibling at `floor`. */ +export const leadingDocComment: { + (comments: ReadonlyArray, floor: number, at: number): Option.Option + (floor: number, at: number): (comments: ReadonlyArray) => Option.Option +} = Fn.dual(3, (comments: ReadonlyArray, floor: number, at: number): Option.Option => + Arr.findLast(comments, Predicate.and(isDocComment, Predicate.and(startsAtOrAfter(floor), endsAtOrBefore(at))))) diff --git a/packages/api-extractor/src/front-end/type-expr-of.ts b/packages/api-extractor/src/front-end/type-expr-of.ts index 4511d57f..4064326c 100644 --- a/packages/api-extractor/src/front-end/type-expr-of.ts +++ b/packages/api-extractor/src/front-end/type-expr-of.ts @@ -113,11 +113,22 @@ const defaultOf = (context: TypeContext, node: TSType | null): Effect.Effect Effect.map(typeExprOf(context, type), (fallback) => ({ default: fallback })), }) +const MODIFIER_FLAGS = [ + { flag: 'in', modifier: 'in' }, + { flag: 'out', modifier: 'out' }, + { flag: 'const', modifier: 'const' }, +] as const + +const modifierFields = (parameter: TSTypeParameter): Pick => { + const modifiers = MODIFIER_FLAGS.filter(({ flag }) => parameter[flag]).map(({ modifier }) => modifier) + return modifiers.length === 0 ? {} : { modifiers } +} + const typeParameterOf = (context: TypeContext, parameter: TSTypeParameter): Effect.Effect => Effect.gen(function* () { const constraint = yield* constraintOf(context, parameter.constraint) const fallback = yield* defaultOf(context, parameter.default) - return { name: parameter.name.name, ...constraint, ...fallback } + return { name: parameter.name.name, ...modifierFields(parameter), ...constraint, ...fallback } }) const typeParametersOf = ( diff --git a/packages/api-extractor/src/front-end/typescript-7-compiler.ts b/packages/api-extractor/src/front-end/typescript-7-compiler.ts index f39432c3..8d7bbd42 100644 --- a/packages/api-extractor/src/front-end/typescript-7-compiler.ts +++ b/packages/api-extractor/src/front-end/typescript-7-compiler.ts @@ -3,9 +3,9 @@ import type { PlatformError } from 'effect/PlatformError' import { ChildProcess, ChildProcessSpawner } from 'effect/process' import { API, DiagnosticCategory, type Diagnostic as ApiDiagnostic, type Program } from 'typescript/unstable/async' import typescriptManifest from 'typescript/package.json' with { type: 'json' } -import { closeDeclarations, entryPointDeclarations, missingEntryDeclarations, relativeWithin, sharedDeclarations, type EntryDeclaration, type SharedDeclaration, type UnresolvedImport } from './declaration-graph.ts' +import { closeDeclarations, entryPointDeclarations, missingEntryDeclarations, relativeWithin, sharedDeclarations, sourceCandidatesOf, type EntryDeclaration, type SharedDeclaration, type UnresolvedImport, uniqueSources } from './declaration-graph.ts' import { CompilerEmitter, type CompilerEmitterId, type DeclarationEmitter } from './declaration-emitter.service.ts' -import type { Declarations, Diagnostic, PackageSource, SourceEntryPoint } from './declarations.schema.ts' +import type { DeclarationFile, Declarations, Diagnostic, PackageSource, SourceEntryPoint, SourceFile } from './declarations.schema.ts' import { CompilerRejected, UnsupportedDeclaration } from './front-end-error.schema.ts' import { isDeclarationFile, readPackageFile } from './package-file.ts' @@ -265,9 +265,47 @@ const handWrittenOr = (source: PackageSource, realRoot: string, emitted: HashMap onSome: Effect.succeedSome, }) +const readSource = ( + source: PackageSource, + realRoot: string, + candidates: ReadonlyArray, +): Effect.Effect, PlatformError, FileSystem.FileSystem | Path.Path> => + Arr.match(candidates, { + onEmpty: () => Effect.succeedNone, + onNonEmpty: ([candidate, ...rest]) => + Effect.flatMap(readPackageFile({ packageRoot: source.packageRoot, realRoot, file: candidate }), (text) => + Option.match(text, { + onNone: () => readSource(source, realRoot, rest), + onSome: (value) => Effect.succeedSome({ path: candidate, text: value }), + })), + }) + +const sourceFileOf = ( + source: PackageSource, + realRoot: string, + emitted: HashMap.HashMap, + file: DeclarationFile, +): Effect.Effect, PlatformError, FileSystem.FileSystem | Path.Path> => + HashMap.has(emitted, file.path) + ? readSource(source, realRoot, sourceCandidatesOf(file.path)) + : Effect.succeedSome({ path: file.path, text: file.text }) + +const sourcesOf = ( + source: PackageSource, + realRoot: string, + emitted: HashMap.HashMap, + files: ReadonlyArray, +): Effect.Effect, PlatformError, FileSystem.FileSystem | Path.Path> => + Effect.map( + Effect.forEach(files, (file) => sourceFileOf(source, realRoot, emitted, file)), + (sources) => uniqueSources(Arr.getSomes(sources)), + ) + const declarationsOf = ( source: PackageSource, entryPoints: ReadonlyArray, + realRoot: string, + emitted: HashMap.HashMap, load: (declarationFile: string) => Effect.Effect, PlatformError, FileSystem.FileSystem | Path.Path>, ): Effect.Effect => Effect.flatMap( @@ -278,12 +316,15 @@ const declarationsOf = ( (report) => Arr.match(report.unresolvedImports, { onEmpty: (): Effect.Effect => - Effect.succeed({ + Effect.map(sourcesOf(source, realRoot, emitted, report.files), (sources) => ({ producer: TYPESCRIPT_PRODUCER, package: { name: source.name, version: source.version }, + anchorBase: source.anchorBase, + anchorPrefix: source.anchorPrefix, entryPoints, files: report.files, - }), + sources, + })), onNonEmpty: ([unresolved]) => Effect.fail(unsupportedImport(unresolved)), }), ) @@ -312,7 +353,8 @@ const declarationsFrom = (source: PackageSource, run: CompilerRun) => const entryPoints = entryPointDeclarations(source.entryPoints) const emittedEntries = compilerEntryDeclarations(source.entryPoints).map((entry) => entry.declarationFile) return yield* Arr.match(missingEntryDeclarations(HashSet.fromIterable(HashMap.keys(emitted)), emittedEntries), { - onEmpty: () => Effect.flatMap(fileSystem.realPath(source.packageRoot), (realRoot) => declarationsOf(source, entryPoints, handWrittenOr(source, realRoot, emitted))), + onEmpty: () => Effect.flatMap(fileSystem.realPath(source.packageRoot), (realRoot) => + declarationsOf(source, entryPoints, realRoot, emitted, handWrittenOr(source, realRoot, emitted))), onNonEmpty: (missing) => Effect.fail(new CompilerRejected({ diagnostics: missing.map(missingEntryDiagnostic) })), }) }) diff --git a/packages/api-extractor/src/model/anchor.schema.ts b/packages/api-extractor/src/model/anchor.schema.ts new file mode 100644 index 00000000..94c0c921 --- /dev/null +++ b/packages/api-extractor/src/model/anchor.schema.ts @@ -0,0 +1,34 @@ +import { Schema } from 'effect' + +/** A declaration's source location: a repository-relative file and a 1-based line. */ +export type Anchor = { + readonly file: string + readonly line: number +} + +export const Anchor: Schema.Codec = Schema.Struct({ + file: Schema.String, + line: Schema.Int.check(Schema.isGreaterThan(0)), +}) + +/** What the source says about who may rely on an item. */ +const RELEASE_TAGS = ['public', 'beta', 'alpha', 'internal', 'untagged'] as const + +export const ReleaseTag: Schema.Literals = Schema.Literals(RELEASE_TAGS) +export type ReleaseTag = typeof ReleaseTag.Type + +/** The facts every item carries about where it is declared and who may rely on it. */ +export type ItemMeta = { + readonly anchor: Anchor | null + readonly releaseTag: ReleaseTag + readonly stability: string | null +} + +export const ItemMeta: Schema.Codec = Schema.Struct({ + anchor: Schema.NullOr(Anchor), + releaseTag: ReleaseTag, + stability: Schema.NullOr(Schema.String), +}) + +/** The anchor the model gives an item the source index could not map. */ +export const NO_ITEM_META: ItemMeta = { anchor: null, releaseTag: 'untagged', stability: null } diff --git a/packages/api-extractor/src/model/api-model.schema.ts b/packages/api-extractor/src/model/api-model.schema.ts index 30554c40..3e8d5331 100644 --- a/packages/api-extractor/src/model/api-model.schema.ts +++ b/packages/api-extractor/src/model/api-model.schema.ts @@ -1,32 +1,40 @@ import { Array as Arr, Order, Schema } from 'effect' import { canonicalKey, type Json } from '../canonical-json.schema.ts' +import { Anchor, type ItemMeta, ReleaseTag } from './anchor.schema.ts' +import { AnalysisMessage } from './message.schema.ts' import { Member, Signature, TypeExpr, TypeParameter } from './type-expr.schema.ts' /** The model's wire version. Any change to the model schema bumps it. */ -export const ModelVersion: Schema.Literal<2> = Schema.Literal(2) +export const ModelVersion: Schema.Literal<3> = Schema.Literal(3) export type ModelVersion = typeof ModelVersion.Type -export const API_MODEL_SCHEMA_VERSION: ModelVersion = 2 +export const API_MODEL_SCHEMA_VERSION: ModelVersion = 3 + +/** Where anchors are rooted: the workspace root, or the package root when it is its own. */ +const ANCHOR_BASES = ['workspace', 'package'] as const + +export const AnchorBase: Schema.Literals = Schema.Literals(ANCHOR_BASES) +export type AnchorBase = typeof AnchorBase.Type export type FunctionItem = { readonly kind: 'function' readonly name: string readonly signatures: ReadonlyArray -} +} & ItemMeta export type VariableItem = { readonly kind: 'variable' readonly name: string readonly constant: boolean readonly type: TypeExpr -} +} & ItemMeta export type TypeAliasItem = { readonly kind: 'typeAlias' readonly name: string readonly typeParameters: ReadonlyArray readonly type: TypeExpr -} +} & ItemMeta export type InterfaceItem = { readonly kind: 'interface' @@ -34,7 +42,7 @@ export type InterfaceItem = { readonly typeParameters: ReadonlyArray readonly extends: ReadonlyArray readonly members: ReadonlyArray -} +} & ItemMeta export type ClassItem = { readonly kind: 'class' @@ -44,7 +52,7 @@ export type ClassItem = { readonly extends: ReadonlyArray readonly implements: ReadonlyArray readonly members: ReadonlyArray -} +} & ItemMeta export type EnumMember = { readonly name: string @@ -56,13 +64,13 @@ export type EnumItem = { readonly name: string readonly const: boolean readonly members: ReadonlyArray -} +} & ItemMeta export type NamespaceItem = { readonly kind: 'namespace' readonly name: string readonly items: ReadonlyArray -} +} & ItemMeta export type ApiItem = | FunctionItem @@ -73,14 +81,93 @@ export type ApiItem = | EnumItem | NamespaceItem +/** The export-map target an entry point was read from. */ +export type EntryPointTarget = { + readonly key: string + readonly condition: string | null + readonly path: string +} + +export const EntryPointTarget: Schema.Codec = Schema.Struct({ + key: Schema.String, + condition: Schema.NullOr(Schema.String), + path: Schema.String, +}) + export type EntryPoint = { /** The package export path, e.g. `.` or `./schema`. */ readonly path: string + readonly target: EntryPointTarget readonly items: ReadonlyArray /** Type aliases the entry point uses but does not export. */ readonly locals: ReadonlyArray } +/** One key of the package's `exports` or `imports` map and the target it resolved to. */ +export type ManifestEntry = { + readonly key: string + readonly target: string | null + readonly condition: string | null +} + +export const ManifestEntry: Schema.Codec = Schema.Struct({ + key: Schema.String, + target: Schema.NullOr(Schema.String), + condition: Schema.NullOr(Schema.String), +}) + +/** The package the model describes and the maps its consumers resolve. */ +export type PackageSurface = { + readonly name: string + readonly version: string + readonly exports: ReadonlyArray + readonly imports: ReadonlyArray +} + +export const PackageSurface: Schema.Codec = Schema.Struct({ + name: Schema.String, + version: Schema.String, + exports: Schema.Array(ManifestEntry), + imports: Schema.Array(ManifestEntry), +}) + +const COVERAGE_KINDS = [ + 'unsupported', + 'unresolved', + 'unmapped-anchor', + 'pattern-empty', + 'parse', + 'external-star', +] as const + +export const CoverageKind: Schema.Literals = Schema.Literals(COVERAGE_KINDS) +export type CoverageKind = typeof CoverageKind.Type + +/** Why the analysis of a package is not complete. */ +export type CoverageReason = { + readonly kind: CoverageKind + readonly file?: string + readonly construct?: string + readonly detail: string +} + +export const CoverageReason: Schema.Codec = Schema.Struct({ + kind: CoverageKind, + file: Schema.optionalKey(Schema.String), + construct: Schema.optionalKey(Schema.String), + detail: Schema.String, +}) + +export type Coverage = { + readonly complete: boolean + readonly reasons: ReadonlyArray +} + +export const Coverage: Schema.Codec = Schema.Struct({ + complete: Schema.Boolean, + reasons: Schema.Array(CoverageReason), +}) + export type Producer = { /** The engine name and version that built the model. */ readonly engine: string @@ -91,8 +178,11 @@ export type Producer = { export type ApiModel = { readonly schemaVersion: ModelVersion readonly producer: Producer - readonly package: { readonly name: string; readonly version: string } + readonly package: PackageSurface + readonly anchorBase: AnchorBase readonly entryPoints: ReadonlyArray + readonly messages: ReadonlyArray + readonly coverage: Coverage } const uniqueBy = (what: string, key: (value: A) => string) => @@ -103,19 +193,27 @@ const uniqueBy = (what: string, key: (value: A) => string) => const typeParameters = Schema.Array(TypeParameter) +/** The item fields the model shares: spread into every item kind's struct. */ +const itemMetaFields = { + anchor: Schema.NullOr(Anchor), + releaseTag: ReleaseTag, + stability: Schema.NullOr(Schema.String), +} as const + export const TypeAliasItem: Schema.Codec = Schema.Struct({ kind: Schema.Literal('typeAlias'), name: Schema.String, typeParameters, type: TypeExpr, + ...itemMetaFields, }) const apiItem = Schema.suspend((): Schema.Codec => ApiItem) /** The recursion point of the item tree (namespaces nest items); generation stays within the declared budget. */ export const ApiItem: Schema.Codec = Schema.suspend((): Schema.Codec => Schema.Union([ - Schema.Struct({ kind: Schema.Literal('function'), name: Schema.String, signatures: Schema.Array(Signature) }), - Schema.Struct({ kind: Schema.Literal('variable'), name: Schema.String, constant: Schema.Boolean, type: TypeExpr }), + Schema.Struct({ kind: Schema.Literal('function'), name: Schema.String, signatures: Schema.Array(Signature), ...itemMetaFields }), + Schema.Struct({ kind: Schema.Literal('variable'), name: Schema.String, constant: Schema.Boolean, type: TypeExpr, ...itemMetaFields }), TypeAliasItem, Schema.Struct({ kind: Schema.Literal('interface'), @@ -123,6 +221,7 @@ export const ApiItem: Schema.Codec = Schema.suspend((): Schema.Codec = Schema.suspend((): Schema.Codec = Schema.suspend((): Schema.Codec member.name), ), + ...itemMetaFields, }), - Schema.Struct({ kind: Schema.Literal('namespace'), name: Schema.String, items: Schema.Array(apiItem) }), + Schema.Struct({ kind: Schema.Literal('namespace'), name: Schema.String, items: Schema.Array(apiItem), ...itemMetaFields }), ])).annotate({ recursionBudget: { maxDepth: 8, depthSize: 'small' } }) +export const EntryPoint: Schema.Codec = Schema.Struct({ + path: Schema.String, + target: EntryPointTarget, + items: Schema.Array(ApiItem), + locals: Schema.Array(TypeAliasItem), +}) + export const ApiModel: Schema.Codec = Schema.Struct({ schemaVersion: ModelVersion, producer: Schema.Struct({ engine: Schema.String, declarations: Schema.String }), - package: Schema.Struct({ name: Schema.String, version: Schema.String }), - entryPoints: Schema.Array( - Schema.Struct({ path: Schema.String, items: Schema.Array(ApiItem), locals: Schema.Array(TypeAliasItem) }), - ).check(uniqueBy('entry points', (entry: { readonly path: string }) => entry.path)), + package: PackageSurface, + anchorBase: AnchorBase, + entryPoints: Schema.Array(EntryPoint).check(uniqueBy('entry points', (entry: { readonly path: string }) => entry.path)), + messages: Schema.Array(AnalysisMessage), + coverage: Coverage, }) /** Decodes a JSON value (e.g. parsed JSON) into a model; rejects any other `schemaVersion`. */ @@ -173,15 +282,39 @@ const sortItem = (item: ApiItem): ApiItem => const sortEntryPoint = (entryPoint: EntryPoint): EntryPoint => ({ path: entryPoint.path, + target: entryPoint.target, items: byName(entryPoint.items.map(sortItem)), locals: byName(entryPoint.locals), }) -/** Orders entry points, exports and locals by code unit, so equal surfaces serialise to equal bytes. */ +const byManifestKey: Order.Order = Order.mapInput(Order.String, (entry: ManifestEntry) => entry.key) + +const messageOrder: Order.Order = Order.combineAll([ + Order.mapInput(Order.String, (message: AnalysisMessage) => message.messageId), + Order.mapInput(Order.String, (message: AnalysisMessage) => message.entryPoint), + Order.mapInput(Order.String, (message: AnalysisMessage) => message.item), + Order.mapInput(Order.String, (message: AnalysisMessage) => message.symbol), +]) + +const coverageOrder: Order.Order = Order.combineAll([ + Order.mapInput(Order.String, (reason: CoverageReason) => reason.kind), + Order.mapInput(Order.String, (reason: CoverageReason) => reason.file ?? ''), + Order.mapInput(Order.String, (reason: CoverageReason) => reason.construct ?? ''), + Order.mapInput(Order.String, (reason: CoverageReason) => reason.detail), +]) + +/** Orders entry points, manifest keys, messages and coverage reasons by code unit, so equal surfaces serialise to equal bytes. */ export const sortModel = (model: ApiModel): ApiModel => ({ ...model, + package: { + ...model.package, + exports: Arr.sort(model.package.exports, byManifestKey), + imports: Arr.sort(model.package.imports, byManifestKey), + }, entryPoints: Arr.sort( model.entryPoints.map(sortEntryPoint), Order.mapInput(Order.String, (entryPoint: EntryPoint) => entryPoint.path), ), + messages: Arr.sort(model.messages, messageOrder), + coverage: { ...model.coverage, reasons: Arr.sort(model.coverage.reasons, coverageOrder) }, }) diff --git a/packages/api-extractor/src/front-end/analysis-message.schema.ts b/packages/api-extractor/src/model/message.schema.ts similarity index 78% rename from packages/api-extractor/src/front-end/analysis-message.schema.ts rename to packages/api-extractor/src/model/message.schema.ts index d4419c90..7d58f33c 100644 --- a/packages/api-extractor/src/front-end/analysis-message.schema.ts +++ b/packages/api-extractor/src/model/message.schema.ts @@ -1,4 +1,5 @@ import { Schema } from 'effect' +import { Anchor } from './anchor.schema.ts' const ANALYSIS_MESSAGE_IDS = [ 'ae-forgotten-export', @@ -11,11 +12,13 @@ const ANALYSIS_MESSAGE_IDS = [ export const AnalysisMessageId: Schema.Literals = Schema.Literals(ANALYSIS_MESSAGE_IDS) export type AnalysisMessageId = typeof AnalysisMessageId.Type +/** One analysis finding about one item; `anchor` is the declaration the finding is about. */ export type AnalysisMessage = { readonly messageId: AnalysisMessageId readonly entryPoint: string readonly item: string readonly symbol: string + readonly anchor: Anchor | null } export const AnalysisMessage: Schema.Codec = Schema.Struct({ @@ -23,4 +26,5 @@ export const AnalysisMessage: Schema.Codec = Schema.Struct({ entryPoint: Schema.String, item: Schema.String, symbol: Schema.String, + anchor: Schema.NullOr(Anchor), }) diff --git a/packages/api-extractor/src/model/type-expr.schema.ts b/packages/api-extractor/src/model/type-expr.schema.ts index 14e2b861..10b7a2dd 100644 --- a/packages/api-extractor/src/model/type-expr.schema.ts +++ b/packages/api-extractor/src/model/type-expr.schema.ts @@ -89,10 +89,18 @@ export type TypeExpr = | ObjectType | TextType +const TYPE_PARAMETER_MODIFIERS = ['in', 'out', 'const'] as const + +/** A type parameter's variance or const modifier. */ +export const TypeParameterModifier: Schema.Literals = Schema.Literals(TYPE_PARAMETER_MODIFIERS) +export type TypeParameterModifier = typeof TypeParameterModifier.Type + export type TypeParameter = { readonly name: string readonly constraint?: TypeExpr readonly default?: TypeExpr + /** The modifiers written before the name, in source order. */ + readonly modifiers?: ReadonlyArray } export type Parameter = { @@ -138,6 +146,7 @@ export const TypeParameter: Schema.Codec = Schema.Struct({ name: Schema.String, constraint: Schema.optionalKey(typeExpr), default: Schema.optionalKey(typeExpr), + modifiers: Schema.optionalKey(Schema.Array(TypeParameterModifier)), }) export const Parameter: Schema.Codec = Schema.Struct({ @@ -216,8 +225,12 @@ const constraintOf = (parameter: TypeParameter): Pick => parameter.default === undefined ? {} : { default: normaliseType(parameter.default) } +const modifierFields = (parameter: TypeParameter): Pick => + parameter.modifiers === undefined ? {} : { modifiers: parameter.modifiers } + const normaliseTypeParameter = (parameter: TypeParameter): TypeParameter => ({ name: parameter.name, + ...modifierFields(parameter), ...constraintOf(parameter), ...defaultOf(parameter), }) diff --git a/packages/api-extractor/src/schema-laws.test.ts b/packages/api-extractor/src/schema-laws.test.ts new file mode 100644 index 00000000..5f67ca12 --- /dev/null +++ b/packages/api-extractor/src/schema-laws.test.ts @@ -0,0 +1,3 @@ +// Generated at test time by @systemfsoftware/effect-schema-vite from every +// exported schema under src/. Edit the schemas, not this file. +export {} diff --git a/packages/api-extractor/tests/__fixtures__/declarations.ts b/packages/api-extractor/tests/__fixtures__/declarations.ts index d1601a08..f54988e6 100644 --- a/packages/api-extractor/tests/__fixtures__/declarations.ts +++ b/packages/api-extractor/tests/__fixtures__/declarations.ts @@ -10,8 +10,15 @@ export type SourceFiles = ReadonlyArray export const declarations = (files: SourceFiles, ...entryPoints: readonly [] | readonly [SourceFiles]): FrontEnd.Declarations => ({ producer: 'fixture-emitter@0.0.0', package: { name: 'fixture', version: '1.0.0' }, - entryPoints: (entryPoints[0] ?? [['.', 'index.d.ts']] as const).map(([exportPath, declarationFile]) => ({ exportPath, declarationFile })), + anchorBase: 'package', + anchorPrefix: '', + entryPoints: (entryPoints[0] ?? [['.', 'index.d.ts']] as const).map(([exportPath, declarationFile]) => ({ + exportPath, + declarationFile, + target: { key: exportPath, condition: null, path: declarationFile }, + })), files: files.map(([path, text]) => ({ path, text })), + sources: [], }) export const entryAt: { diff --git a/packages/api-extractor/tests/__fixtures__/surfaces.ts b/packages/api-extractor/tests/__fixtures__/surfaces.ts index 5fbb7d34..bd0e77c8 100644 --- a/packages/api-extractor/tests/__fixtures__/surfaces.ts +++ b/packages/api-extractor/tests/__fixtures__/surfaces.ts @@ -8,6 +8,12 @@ type ApiItem = ApiModel.ApiItem type EntryPoint = ApiModel.EntryPoint type TypeAliasItem = ApiModel.TypeAliasItem type Model = ApiModel.ApiModel +type ItemMeta = ApiModel.ItemMeta + +/** The metadata every fixture item carries: unanchored, untagged, no stability. */ +export const META: ItemMeta = { anchor: null, releaseTag: 'untagged', stability: null } + +export const ROOT_TARGET: ApiModel.EntryPointTarget = { key: '.', condition: null, path: 'index.d.ts' } export const ref = (name: string, ...args: ReadonlyArray): TypeExpr => ({ kind: 'reference', name, args }) export const never: TypeExpr = { kind: 'keyword', name: 'never' } @@ -43,17 +49,12 @@ export const fn = (sig: Signature): TypeExpr => ({ kind: 'function', signature: export const TARGET = 'target' -export const functionItem = (...signatures: ReadonlyArray): ApiItem => ({ kind: 'function', name: TARGET, signatures }) +export const functionItem = (...signatures: ReadonlyArray): ApiItem => ({ kind: 'function', name: TARGET, signatures, ...META }) export const withEntryPoints: { (model: Model, entryPoints: ReadonlyArray): Model (entryPoints: ReadonlyArray): (model: Model) => Model -} = Function.dual(2, (model: Model, entryPoints: ReadonlyArray): Model => ({ - schemaVersion: model.schemaVersion, - producer: model.producer, - package: model.package, - entryPoints, -})) +} = Function.dual(2, (model: Model, entryPoints: ReadonlyArray): Model => ({ ...model, entryPoints })) /** The model with `item` exported from `.` (and `locals` added there), keeping everything else. */ export const withExport: { @@ -66,6 +67,7 @@ export const withExport: { const others = model.entryPoints.filter((entry) => entry.path !== '.') const entry: EntryPoint = { path: '.', + target: root?.target ?? ROOT_TARGET, items: [...(root?.items ?? []).filter((existing) => existing.name !== item.name), item], locals: [...(root?.locals ?? []).filter((alias) => !locals.some((local) => local.name === alias.name)), ...locals], } @@ -76,20 +78,28 @@ export const withExport: { export const alias: { (type: TypeExpr): (name: string) => TypeAliasItem (name: string, type: TypeExpr): TypeAliasItem -} = Function.dual(2, (name: string, type: TypeExpr): TypeAliasItem => ({ kind: 'typeAlias', name, typeParameters: [], type })) +} = Function.dual(2, (name: string, type: TypeExpr): TypeAliasItem => ({ kind: 'typeAlias', name, typeParameters: [], type, ...META })) export const rooted = (model: Model): Model => { const entries = model.entryPoints.map( - (entry): EntryPoint => ({ path: entry.path, items: entry.items.filter((item) => item.name !== TARGET), locals: entry.locals }), + (entry): EntryPoint => ({ ...entry, items: entry.items.filter((item) => item.name !== TARGET) }), + ) + return withEntryPoints( + model, + entries.some((entry) => entry.path === '.') + ? entries + : [...entries, { path: '.', target: ROOT_TARGET, items: [], locals: [] }], ) - return withEntryPoints(model, entries.some((entry) => entry.path === '.') ? entries : [...entries, { path: '.', items: [], locals: [] }]) } export const empty: Model = rooted({ schemaVersion: ApiModel.SCHEMA_VERSION, producer: { engine: 'fixture@0.0.0', declarations: 'hand-written' }, - package: { name: 'fixture', version: '1.0.0' }, + package: { name: 'fixture', version: '1.0.0', exports: [], imports: [] }, + anchorBase: 'package', entryPoints: [], + messages: [], + coverage: { complete: true, reasons: [] }, }) export const decodeVerdict = (document: string) => Schema.decodeResult(Schema.fromJsonString(Verdict.Verdict))(document) @@ -100,5 +110,5 @@ export const rootedWith: { } = Function.dual( (args) => args.length >= 2, (model: Model, items: ReadonlyArray, locals: ReadonlyArray = []): Model => - withEntryPoints(model, [...model.entryPoints.filter((entry) => entry.path !== '.'), { path: '.', items, locals }]), + withEntryPoints(model, [...model.entryPoints.filter((entry) => entry.path !== '.'), { path: '.', target: ROOT_TARGET, items, locals }]), ) diff --git a/packages/api-extractor/tests/__fixtures__/upstream-messages.ts b/packages/api-extractor/tests/__fixtures__/upstream-messages.ts index ed8a080a..4865852e 100644 --- a/packages/api-extractor/tests/__fixtures__/upstream-messages.ts +++ b/packages/api-extractor/tests/__fixtures__/upstream-messages.ts @@ -104,6 +104,9 @@ export type DeclarationRequest = { export const declarationsOf = (request: DeclarationRequest): FrontEnd.Declarations => ({ producer: 'fixture', package: { name: 'fixture', version: '1.0.0' }, + anchorBase: 'package', + anchorPrefix: '', entryPoints: [{ exportPath: '.', declarationFile: request.entry }], files: request.files.map(([path, text]) => ({ path: path ?? '', text: text ?? '' })), + sources: [], }) diff --git a/packages/api-extractor/tests/analysis-messages.integration.test.ts b/packages/api-extractor/tests/analysis-messages.integration.test.ts index 5b04439b..be6f7541 100644 --- a/packages/api-extractor/tests/analysis-messages.integration.test.ts +++ b/packages/api-extractor/tests/analysis-messages.integration.test.ts @@ -71,7 +71,7 @@ Feature('Review notes for a package match the ones api-extractor derives') }))), When(SOME_NOTE)('both', (scope) => bothSides(scope.declarations)), Then('the forgotten export names the type at the function name and the external type draws no note')((scope, expect) => - expect({ pairs: reviewed(scope.both.declarations, scope.both.messages), forgotten: forgottenAt(scope.both.messages) }).toEqual({ + expect({ pairs: reviewed(scope.both.declarations, scope.both.messages), forgotten: forgottenAt(scope.both.messages) }).toMatchObject({ pairs: sorted(scope.both.upstream), forgotten: [{ messageId: 'ae-forgotten-export', entryPoint: '.', item: 'makeWidget', symbol: 'Widget' }], })), @@ -207,7 +207,7 @@ Feature('Review notes for a package match the ones api-extractor derives') expect({ notes: reviewed(scope.both.declarations, scope.both.messages), forgotten: forgottenAt(scope.both.messages), - }).toEqual({ + }).toMatchObject({ notes: sorted(scope.both.upstream), forgotten: [{ messageId: 'ae-forgotten-export', entryPoint: '.', item: 'use', symbol: 'A' }], })), @@ -243,7 +243,7 @@ Feature('Review notes for a package match the ones api-extractor derives') expect({ notes: reviewed(scope.both.declarations, scope.both.messages), forgotten: forgottenAt(scope.both.messages), - }).toEqual({ + }).toMatchObject({ notes: sorted(scope.both.upstream), forgotten: [ { messageId: 'ae-forgotten-export', entryPoint: '.', item: 'use', symbol: 'K' }, @@ -279,7 +279,7 @@ Feature('Review notes for a package match the ones api-extractor derives') expect({ notes: scope.both.messages, pairs: reviewed(scope.both.declarations, scope.both.messages), - }).toEqual({ + }).toMatchObject({ notes: [ { messageId: 'ae-forgotten-export', entryPoint: '.', item: 'f', symbol: 'Missing' }, { messageId: 'ae-internal-missing-underscore', entryPoint: '.', item: 'secret', symbol: 'secret' }, @@ -299,6 +299,8 @@ Feature('Review notes for a package match the ones api-extractor derives') Effect.sync((): FrontEnd.Declarations => ({ producer: 'fixture', package: { name: 'fixture', version: '1.0.0' }, + anchorBase: 'package', + anchorPrefix: '', entryPoints: [ { exportPath: '.', declarationFile: 'index.d.ts' }, { exportPath: './util', declarationFile: 'util.d.ts' }, @@ -307,13 +309,14 @@ Feature('Review notes for a package match the ones api-extractor derives') { path: 'index.d.ts', text: '/** @public */\nexport declare const main: string\n' }, { path: 'util.d.ts', text: 'interface Hidden { readonly a: string }\nexport declare function helper(): Hidden\nexport {}\n' }, ], + sources: [], }))), When('the notes for each entry point are derived')('messages', (scope) => FrontEnd.resolveMessages(scope.declarations)), Then('the notes naming the util function are reported under `./util` and none under `.`')((scope, expect) => expect({ messages: scope.messages, underMain: scope.messages.filter((message) => message.entryPoint === '.'), - }).toEqual({ + }).toMatchObject({ messages: [ { messageId: 'ae-forgotten-export', entryPoint: './util', item: 'helper', symbol: 'Hidden' }, { messageId: 'ae-missing-release-tag', entryPoint: './util', item: 'helper', symbol: 'helper' }, @@ -351,7 +354,7 @@ Feature('Review notes for a package match the ones api-extractor derives') expect({ messages: scope.both.messages, pairs: reviewed(scope.both.declarations, scope.both.messages), - }).toEqual({ + }).toMatchObject({ messages: [], pairs: sorted(scope.both.upstream), })), @@ -385,7 +388,7 @@ Feature('Review notes for a package match the ones api-extractor derives') expect({ messages: scope.both.messages, pairs: reviewed(scope.both.declarations, scope.both.messages), - }).toEqual({ + }).toMatchObject({ messages: [ { messageId: 'ae-forgotten-export', entryPoint: '.', item: 'N', symbol: 'A' }, { messageId: 'ae-missing-release-tag', entryPoint: '.', item: 'N', symbol: 'N' }, @@ -424,7 +427,7 @@ Feature('Review notes for a package match the ones api-extractor derives') expect({ messages: scope.both.messages, pairs: reviewed(scope.both.declarations, scope.both.messages), - }).toEqual({ + }).toMatchObject({ messages: [], pairs: sorted(scope.both.upstream), })), @@ -460,7 +463,7 @@ Feature('Review notes for a package match the ones api-extractor derives') expect({ messages: scope.both.messages, pairs: reviewed(scope.both.declarations, scope.both.messages), - }).toEqual({ + }).toMatchObject({ messages: [{ messageId: 'ae-forgotten-export', entryPoint: '.', item: 'N', symbol: 'A' }], pairs: sorted(scope.both.upstream), })), @@ -498,7 +501,7 @@ Feature('Review notes for a package match the ones api-extractor derives') expect({ messages: scope.both.messages, pairs: reviewed(scope.both.declarations, scope.both.messages), - }).toEqual({ + }).toMatchObject({ messages: [], pairs: sorted(scope.both.upstream), })), @@ -540,7 +543,7 @@ Feature('Review notes for a package match the ones api-extractor derives') expect({ messages: scope.both.messages, pairs: reviewed(scope.both.declarations, scope.both.messages), - }).toEqual({ + }).toMatchObject({ messages: [], pairs: sorted(scope.both.upstream), })), diff --git a/packages/api-extractor/tests/change-kinds.integration.test.ts b/packages/api-extractor/tests/change-kinds.integration.test.ts index 7e33d7c6..dc379517 100644 --- a/packages/api-extractor/tests/change-kinds.integration.test.ts +++ b/packages/api-extractor/tests/change-kinds.integration.test.ts @@ -7,6 +7,7 @@ import { empty, fn, functionItem, + META, never, parameter, ref, @@ -36,6 +37,7 @@ const classItem = (overrides: Partial = {}): Item => ({ extends: [], implements: [], members: [], + ...META, ...overrides, }) const property = (name: string, type: TypeExpr, { optional = false, readonly = false } = {}): Member => ({ @@ -47,9 +49,21 @@ const property = (name: string, type: TypeExpr, { optional = false, readonly = f }) const method = (name: string, returns: TypeExpr): Member => ({ kind: 'method', name, optional: false, signatures: [signature([], returns)] }) const object = (...members: ReadonlyArray): TypeExpr => ({ kind: 'object', members }) -const enumItem = (...members: ReadonlyArray): Item => ({ kind: 'enum', name: TARGET, const: false, members: members.map((name) => ({ name })) }) -const valuedEnum = (value: string, isConst = false): Item => ({ kind: 'enum', name: TARGET, const: isConst, members: [{ name: 'A', value }] }) -const variable = (constant: boolean, type: TypeExpr): Item => ({ kind: 'variable', name: TARGET, constant, type }) +const enumItem = (...members: ReadonlyArray): Item => ({ + kind: 'enum', + name: TARGET, + const: false, + members: members.map((name) => ({ name })), + ...META, +}) +const valuedEnum = (value: string, isConst = false): Item => ({ + kind: 'enum', + name: TARGET, + const: isConst, + members: [{ name: 'A', value }], + ...META, +}) +const variable = (constant: boolean, type: TypeExpr): Item => ({ kind: 'variable', name: TARGET, constant, type, ...META }) const returning = (type: TypeExpr) => functionItem(signature([], type)) const taking = (type: TypeExpr) => functionItem(signature([parameter('self', type)], voidType)) const callback = (...parameters: ReadonlyArray) => taking(fn(signature(parameters, voidType))) @@ -88,7 +102,10 @@ const twoArgumentDualForms: readonly [Signature, Signature] = (() => { })() const dual = (...forms: ReadonlyArray): Item => variable(true, object(...forms.map((sig): Member => ({ kind: 'call', signature: sig })))) const servicesLayer = (services: TypeExpr) => returning(ref('Layer', services, never, never)) -const withEntryPoint: Model = withEntryPoints(empty, [...empty.entryPoints, { path: './extra', items: [], locals: [] }]) +const withEntryPoint: Model = withEntryPoints(empty, [ + ...empty.entryPoints, + { path: './extra', target: { key: './extra', condition: null, path: './src/extra.ts' }, items: [], locals: [] }, +]) type Row = readonly [string, Item | Model, Item | Model, Bump, string] const rows: ReadonlyArray = [ diff --git a/packages/api-extractor/tests/front-end-model.integration.test.ts b/packages/api-extractor/tests/front-end-model.integration.test.ts index 577697d0..f321b6d3 100644 --- a/packages/api-extractor/tests/front-end-model.integration.test.ts +++ b/packages/api-extractor/tests/front-end-model.integration.test.ts @@ -243,7 +243,7 @@ Feature('Building a package API model from its declaration text') ) scenario( - 'a built model decodes as schema version two', + 'a built model decodes as schema version three', Gherkin.Do.pipe( Given('an entry with literals, an enum and a namespace')('declarations', () => Effect.succeed( @@ -260,7 +260,7 @@ Feature('Building a package API model from its declaration text') )), When('the model is built and encoded')('roundTrip', (scope) => Effect.map(FrontEnd.buildModel(scope.declarations), (model) => ({ model, decoded: ApiModel.decode(ApiModel.encode(model)) }))), - Then('decoding the bytes restores schema version two')((scope, expect) => + Then('decoding the bytes restores schema version three')((scope, expect) => expect({ built: scope.roundTrip.model.schemaVersion, decoded: scope.roundTrip.decoded.schemaVersion, @@ -270,8 +270,8 @@ Feature('Building a package API model from its declaration text') version: itemOf(scope.roundTrip.decoded, '.', 'version', 'variable'), mode: itemOf(scope.roundTrip.decoded, '.', 'Mode', 'enum'), }).toMatchObject({ - built: 2, - decoded: 2, + built: 3, + decoded: 3, namespaces: ['ns'], version: { constant: true, type: { kind: 'literal', text: '"1.0.0"' } }, mode: { kind: 'enum', const: true, members: [{ name: 'On', value: '"on"' }, { name: 'Off', value: '"off"' }] }, @@ -521,7 +521,7 @@ Feature('Building a package API model from its declaration text') )), When('the model is built')('model', (scope) => builtOf(scope.declarations)), Then('the class carries the static field, the auto-accessor and the accessor pair, in source order, and no member for the private field or the static block')((scope, expect) => - expect(itemOf(scope.model, '.', 'C', 'class')).toEqual({ + expect(itemOf(scope.model, '.', 'C', 'class')).toMatchObject({ kind: 'class', name: 'C', abstract: false, @@ -654,7 +654,7 @@ Feature('Building a package API model from its declaration text') expect({ names: itemNames(scope.model, '.'), default: itemOf(scope.model, '.', 'default', 'variable'), - }).toEqual({ + }).toMatchObject({ names: ['default'], default: { kind: 'variable', name: 'default', constant: true, type: { kind: 'keyword', name: 'number' } }, })), @@ -907,7 +907,7 @@ Feature('Building a package API model from its declaration text') names: itemNames(scope.model, '.'), a: itemOf(scope.model, '.', 'a', 'variable'), b: itemOf(scope.model, '.', 'b', 'variable'), - }).toEqual({ + }).toMatchObject({ names: ['a', 'b'], a: { kind: 'variable', name: 'a', constant: true, type: { kind: 'keyword', name: 'string' } }, b: { kind: 'variable', name: 'b', constant: true, type: { kind: 'keyword', name: 'number' } }, @@ -952,7 +952,7 @@ Feature('Building a package API model from its declaration text') )), When('the model is built')('model', (scope) => builtOf(scope.declarations)), Then("`ns.T` is the module's alias, kept among the entry's locals")((scope, expect) => - expect(entryAt(scope.model, '.').locals).toEqual([ + expect(entryAt(scope.model, '.').locals).toMatchObject([ { kind: 'typeAlias', name: 'T', @@ -1029,7 +1029,7 @@ Feature('Building a package API model from its declaration text') Effect.succeed(declarations([['index.d.ts', 'export declare abstract class C {\n protected p: string\n protected static q: number\n abstract m(): void\n protected abstract get n(): number\n abstract x: string\n abstract accessor a: number\n get w(): number\n protected set w(v: number)\n private static z: number\n public r: string\n protected constructor()\n}\n']]))), When('the model is built')('model', (scope) => builtOf(scope.declarations)), Then('every restricted member is named with its modifiers in written order, `public r` as the plain name, and `w` reads publicly while only a subclass writes it')((scope, expect) => - expect(itemOf(scope.model, '.', 'C', 'class')).toEqual({ + expect(itemOf(scope.model, '.', 'C', 'class')).toMatchObject({ kind: 'class', name: 'C', abstract: true, typeParameters: [], extends: [], implements: [], members: [ { kind: 'property', name: 'protected p', optional: false, readonly: false, type: { kind: 'keyword', name: 'string' } }, diff --git a/packages/api-extractor/tests/front-end-namespaces.integration.test.ts b/packages/api-extractor/tests/front-end-namespaces.integration.test.ts index fdb9c99a..0f12608b 100644 --- a/packages/api-extractor/tests/front-end-namespaces.integration.test.ts +++ b/packages/api-extractor/tests/front-end-namespaces.integration.test.ts @@ -41,7 +41,7 @@ Feature('Building the namespaces of a package API model') )), When('the model is built')('model', (scope) => builtOf(scope.declarations)), Then('the namespace holds both values and the enum both members')((scope, expect) => - expect({ namespace: itemOf(scope.model, '.', 'N', 'namespace'), enumeration: itemOf(scope.model, '.', 'E', 'enum') }).toEqual({ + expect({ namespace: itemOf(scope.model, '.', 'N', 'namespace'), enumeration: itemOf(scope.model, '.', 'E', 'enum') }).toMatchObject({ namespace: { kind: 'namespace', name: 'N', @@ -72,7 +72,7 @@ Feature('Building the namespaces of a package API model') )), When('the model is built')('model', (scope) => builtOf(scope.declarations)), Then('the namespace `N` holds one `M` with both values and one `E` with both members')((scope, expect) => - expect(itemOf(scope.model, '.', 'N', 'namespace')).toEqual({ + expect(itemOf(scope.model, '.', 'N', 'namespace')).toMatchObject({ kind: 'namespace', name: 'N', items: [ @@ -105,7 +105,7 @@ Feature('Building the namespaces of a package API model') )), When('the model is built')('model', (scope) => builtOf(scope.declarations)), Then('the namespace `N` holds one `M` with both values')((scope, expect) => - expect(itemOf(scope.model, '.', 'N', 'namespace')).toEqual({ + expect(itemOf(scope.model, '.', 'N', 'namespace')).toMatchObject({ kind: 'namespace', name: 'N', items: [ @@ -138,7 +138,7 @@ Feature('Building the namespaces of a package API model') )), When('the model is built')('model', (scope) => builtOf(scope.declarations)), Then("`N.f` returns the namespace's own `A`, while `g` keeps the imported one")((scope, expect) => - expect({ n: itemOf(scope.model, '.', 'N', 'namespace'), g: itemOf(scope.model, '.', 'g', 'variable') }).toEqual({ + expect({ n: itemOf(scope.model, '.', 'N', 'namespace'), g: itemOf(scope.model, '.', 'g', 'variable') }).toMatchObject({ n: { kind: 'namespace', name: 'N', @@ -173,8 +173,8 @@ Feature('Building the namespaces of a package API model') Then("`typeof A` and `C`'s base read `N`'s value `A`, while `typeof B` reads the imported value `B`")((scope, expect) => expect(itemOf(scope.model, '.', 'N', 'namespace')).toMatchObject({ items: expect.arrayContaining([ - { kind: 'typeAlias', name: 'OfA', typeParameters: [], type: { kind: 'text', text: 'typeof A' } }, - { kind: 'typeAlias', name: 'OfB', typeParameters: [], type: { kind: 'text', text: 'typeof import("pkg").B' } }, + expect.objectContaining({ kind: 'typeAlias', name: 'OfA', typeParameters: [], type: { kind: 'text', text: 'typeof A' } }), + expect.objectContaining({ kind: 'typeAlias', name: 'OfB', typeParameters: [], type: { kind: 'text', text: 'typeof import("pkg").B' } }), expect.objectContaining({ kind: 'class', name: 'C', extends: [{ kind: 'reference', name: 'A', args: [] }] }), ]), })), @@ -196,10 +196,10 @@ Feature('Building the namespaces of a package API model') Then("every reference reads `N`'s own `C` and `E`, none the imports")((scope, expect) => expect(itemOf(scope.model, '.', 'N', 'namespace')).toMatchObject({ items: expect.arrayContaining([ - { kind: 'typeAlias', name: 'OfC', typeParameters: [], type: { kind: 'reference', name: 'C', args: [] } }, - { kind: 'typeAlias', name: 'QueryC', typeParameters: [], type: { kind: 'text', text: 'typeof C' } }, - { kind: 'typeAlias', name: 'OfE', typeParameters: [], type: { kind: 'reference', name: 'E', args: [] } }, - { kind: 'typeAlias', name: 'QueryE', typeParameters: [], type: { kind: 'text', text: 'typeof E' } }, + expect.objectContaining({ kind: 'typeAlias', name: 'OfC', typeParameters: [], type: { kind: 'reference', name: 'C', args: [] } }), + expect.objectContaining({ kind: 'typeAlias', name: 'QueryC', typeParameters: [], type: { kind: 'text', text: 'typeof C' } }), + expect.objectContaining({ kind: 'typeAlias', name: 'OfE', typeParameters: [], type: { kind: 'reference', name: 'E', args: [] } }), + expect.objectContaining({ kind: 'typeAlias', name: 'QueryE', typeParameters: [], type: { kind: 'text', text: 'typeof E' } }), ]), })), ), @@ -220,7 +220,7 @@ Feature('Building the namespaces of a package API model') )), When('the model is built')('model', (scope) => builtOf(scope.declarations)), Then('`N.f` returns the imported `A`, while the value stays a value')((scope, expect) => - expect(itemOf(scope.model, '.', 'N', 'namespace')).toEqual({ + expect(itemOf(scope.model, '.', 'N', 'namespace')).toMatchObject({ kind: 'namespace', name: 'N', items: [ @@ -246,7 +246,7 @@ Feature('Building the namespaces of a package API model') )), When('the model is built')('model', (scope) => builtOf(scope.declarations)), Then('`X` refers to `C.Inner`')((scope, expect) => - expect(itemOf(scope.model, '.', 'X', 'typeAlias')).toEqual({ + expect(itemOf(scope.model, '.', 'X', 'typeAlias')).toMatchObject({ kind: 'typeAlias', name: 'X', typeParameters: [], diff --git a/packages/api-extractor/tests/front-end-routing.integration.test.ts b/packages/api-extractor/tests/front-end-routing.integration.test.ts index 32e156ca..17f7a1fd 100644 --- a/packages/api-extractor/tests/front-end-routing.integration.test.ts +++ b/packages/api-extractor/tests/front-end-routing.integration.test.ts @@ -22,7 +22,7 @@ const packageOf = (manifest: object, sources: Record) => const sourceManifest = (name: string, entry: string) => ({ name, version: '1.0.0', exports: { '.': { [FrontEnd.FAMILY_SOURCE_CONDITION]: entry } } }) -const builtOnlyPackage = packageOf({ name: 'no-source', version: '1.0.0', exports: { '.': { types: './dist/mod.d.ts', default: './dist/mod.js' } } }, {}) +const builtOnlyPackage = packageOf({ name: 'no-source', version: '1.0.0', exports: { '.': { default: './dist/mod.js' } } }, {}) const destructuredExportPackage = packageOf(sourceManifest('destructured', './src/mod.ts'), { 'mod.ts': 'const pair = { a: 1, b: 2 }\nexport const { a, b } = pair\n', @@ -102,11 +102,11 @@ Feature('A package is routed to the front-end its declarations allow') .live('the emitters read real package files from disk') .body(({ scenario }) => { scenario( - 'this isolatedDeclarations-clean package takes the fast path and yields a version 2 model', + 'this isolatedDeclarations-clean package takes the fast path and yields a version 3 model', Gherkin.Do.pipe( Given('this repository package')('source', () => Effect.flatMap(packageRoot, (root) => FrontEnd.readPackage({ packageRoot: root }))), When('the engine analyses it')('analysis', (scope) => FrontEnd.analyse(scope.source)), - Then('it took the fast path and the model round-trips through the version 2 schema')((scope, expect) => + Then('it took the fast path and the model round-trips through the version 3 schema')((scope, expect) => expect({ route: scope.analysis.route._tag, producer: scope.analysis.model.producer.declarations, @@ -137,7 +137,15 @@ Feature('A package is routed to the front-end its declarations allow') }).toEqual({ route: 'FastPath', exports: ['variable:count'], - count: { kind: 'variable', name: 'count', constant: true, type: { kind: 'keyword', name: 'number' } }, + count: { + kind: 'variable', + name: 'count', + constant: true, + type: { kind: 'keyword', name: 'number' }, + anchor: { file: 'src/mod.ts', line: 1 }, + releaseTag: 'untagged', + stability: null, + }, }) ), ), diff --git a/packages/api-extractor/tests/model-v3.integration.test.ts b/packages/api-extractor/tests/model-v3.integration.test.ts new file mode 100644 index 00000000..5982b073 --- /dev/null +++ b/packages/api-extractor/tests/model-v3.integration.test.ts @@ -0,0 +1,238 @@ +import { NodeServices } from '@effect/platform-node' +import { ApiModel, FrontEnd } from '@systemfsoftware/api-extractor' +import { Gherkin, Given, it, makeFeature, Then, When } from '@systemfsoftware/effect-gherkin-spec' +import { Effect, FileSystem, Layer, Path } from 'effect' +import { builtOf, declarations } from './__fixtures__/declarations.ts' + +const Feature = makeFeature({ it }) + +const emitters = Layer.mergeAll(FrontEnd.isolatedEmitterLayer, FrontEnd.compilerEmitterLayer).pipe(Layer.provideMerge(NodeServices.layer)) + +const packageOf = (manifest: object, files: Record) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem + const path = yield* Path.Path + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: 'model-v3-' }) + yield* fileSystem.writeFileString(path.join(root, 'package.json'), JSON.stringify(manifest)) + yield* Effect.forEach(Object.entries(files), ([file, text]) => + Effect.andThen( + fileSystem.makeDirectory(path.dirname(path.join(root, file)), { recursive: true }), + fileSystem.writeFileString(path.join(root, file), text), + ), { discard: true }) + return root + }) + +const sourceManifest = (name: string, exports: object | string) => ({ name, version: '1.0.0', exports }) + +const analysed = (packageRoot: string) => Effect.flatMap(FrontEnd.readPackage({ packageRoot }), FrontEnd.analyse) + +const nestedItems = (items: ReadonlyArray): ReadonlyArray => + items.flatMap((item) => (item.kind === 'namespace' ? [item, ...nestedItems(item.items)] : [item])) + +const itemNamed = (model: ApiModel.ApiModel, name: string) => + model.entryPoints.flatMap((entry) => nestedItems(entry.items)).find((item) => item.name === name) + +const stringExportsPackage = packageOf(sourceManifest('barrel', './src/mod.ts'), { + 'src/mod.ts': `/** + * @public + * @stability stable + */ +export const version: string = '1' + +/** @beta */ +export interface Options { readonly mode: string } +`, +}) + +const eagerNamespacePackage = packageOf(sourceManifest('eager', './src/mod.ts'), { + 'src/mod.ts': "export * as NamespaceA from './a.js'\nexport const untagged: number = 1\n", + 'src/a.ts': 'export const listed: number = 1\n', +}) + +const importsOnlyPackage = packageOf( + { name: 'app', version: '1.0.0', imports: { '#internal/*': './src/*' } }, + { 'src/queue.ts': 'export const writer: number = 1\n' }, +) + +const taggedNamespacePackage = packageOf(sourceManifest('tagged-namespace', './src/mod.ts'), { + 'src/mod.ts': `/** @beta */ +export namespace Config { + export const load = 1 + /** @internal */ + export const reset = 2 +} + +/** @internal */ +export const previous = 1 +export namespace Later { + export const first = 1 +} +`, +}) + +Feature('The version 3 model carries anchors, release tags and manifest facts') + .withLayer(emitters) + .live('the emitters and the source index read real package files from disk') + .body(({ scenario }) => { + scenario( + 'a bare-string exports field resolves `.`, and every item carries its anchor, release tag and stability', + Gherkin.Do.pipe( + Given('a package whose `exports` is the string `./src/mod.ts`, exporting a `@public @stability stable` constant and a `@beta` interface')( + 'packageRoot', + () => stringExportsPackage, + ), + When('the engine analyses it')('analysis', (scope) => analysed(scope.packageRoot)), + Then('`.` names its target, the manifest records it, and each item carries the source facts')((scope, expect) => + expect({ + version: scope.analysis.model.schemaVersion, + anchorBase: scope.analysis.model.anchorBase, + exports: scope.analysis.model.package.exports, + targets: scope.analysis.model.entryPoints.map((entry) => entry.target), + versionItem: itemNamed(scope.analysis.model, 'version'), + optionsItem: itemNamed(scope.analysis.model, 'Options'), + coverage: scope.analysis.model.coverage, + }).toMatchObject({ + version: 3, + anchorBase: 'package', + exports: [{ key: '.', target: './src/mod.ts', condition: null }], + targets: [{ key: '.', condition: null, path: './src/mod.ts' }], + versionItem: { + kind: 'variable', + name: 'version', + constant: true, + anchor: { file: 'src/mod.ts', line: 5 }, + releaseTag: 'public', + stability: 'stable', + }, + optionsItem: { + kind: 'interface', + name: 'Options', + anchor: { file: 'src/mod.ts', line: 8 }, + releaseTag: 'beta', + stability: null, + }, + coverage: { complete: true, reasons: [] }, + }) + ), + ), + ) + + scenario( + 'a namespace export anchors to its re-export statement, its member anchors to its declaration, and an untagged export carries the missing-tag message at its line', + Gherkin.Do.pipe( + Given('a package whose entry re-exports `./a.js` as `NamespaceA` beside an untagged constant')('packageRoot', () => eagerNamespacePackage), + When('the engine analyses it')('analysis', (scope) => analysed(scope.packageRoot)), + Then('the namespace anchors to the re-export line, its member and the untagged constant to their own lines, and the message points at the constant')( + (scope, expect) => + expect({ + namespace: itemNamed(scope.analysis.model, 'NamespaceA'), + member: itemNamed(scope.analysis.model, 'listed'), + untagged: itemNamed(scope.analysis.model, 'untagged'), + reasonKinds: scope.analysis.model.coverage.reasons.map((reason) => reason.kind), + complete: scope.analysis.model.coverage.complete, + message: scope.analysis.model.messages.find((message) => message.symbol === 'untagged'), + }).toMatchObject({ + namespace: { + kind: 'namespace', + name: 'NamespaceA', + anchor: { file: 'src/mod.ts', line: 1 }, + releaseTag: 'untagged', + stability: null, + }, + member: { + kind: 'variable', + name: 'listed', + anchor: { file: 'src/a.ts', line: 1 }, + releaseTag: 'untagged', + stability: null, + }, + untagged: { + kind: 'variable', + name: 'untagged', + anchor: { file: 'src/mod.ts', line: 2 }, + releaseTag: 'untagged', + stability: null, + }, + reasonKinds: [], + complete: true, + message: { + messageId: 'ae-missing-release-tag', + entryPoint: '.', + item: 'untagged', + symbol: 'untagged', + anchor: { file: 'src/mod.ts', line: 2 }, + }, + }), + ), + ), + ) + + scenario( + 'a model built from declarations alone leaves every item unanchored and untagged, and says so in the coverage', + Gherkin.Do.pipe( + Given('a declaration file exporting one constant')('declarations', () => + Effect.succeed(declarations([['index.d.ts', 'export declare const value: number\n']]))), + When('the model is built from the declarations alone')('model', (scope) => builtOf(scope.declarations)), + Then('the item is unanchored and untagged, and the coverage carries an unmapped-anchor reason')((scope, expect) => + expect({ + item: itemNamed(scope.model, 'value'), + complete: scope.model.coverage.complete, + reasons: scope.model.coverage.reasons.map((reason) => reason.kind), + }).toMatchObject({ + item: { kind: 'variable', name: 'value', anchor: null, releaseTag: 'untagged', stability: null }, + complete: false, + reasons: ['unmapped-anchor'], + })), + ), + ) + + scenario( + 'a package with only an imports map analyses to zero entry points with complete coverage and its imports map recorded', + Gherkin.Do.pipe( + Given('an application package whose only surface is `"imports": { "#internal/*": "./src/*" }`')('packageRoot', () => importsOnlyPackage), + When('the package is read and analysed')('analysis', (scope) => + Effect.flatMap(FrontEnd.readPackage({ packageRoot: scope.packageRoot }), (source) => + Effect.map(FrontEnd.analyse(source), (analysis) => ({ source, analysis })))), + Then('it has no entry point, the imports entry as written and a complete declared surface')((scope, expect) => + expect({ + entryPoints: scope.analysis.source.entryPoints, + imports: scope.analysis.source.manifest.imports, + modelEntryPoints: scope.analysis.analysis.model.entryPoints, + modelImports: scope.analysis.analysis.model.package.imports, + coverage: scope.analysis.analysis.model.coverage, + }).toEqual({ + entryPoints: [], + imports: [{ key: '#internal/*', target: './src/*', condition: null }], + modelEntryPoints: [], + modelImports: [{ key: '#internal/*', target: './src/*', condition: null }], + coverage: { complete: true, reasons: [] }, + }) + ), + ), + ) + + scenario( + 'a namespace member takes its tag from its own doc comment, never from the namespace comment or an earlier declaration', + Gherkin.Do.pipe( + Given('a `@beta` namespace whose first member is untagged and second is `@internal`, followed by an `@internal` constant and a namespace whose first member is untagged')( + 'packageRoot', + () => taggedNamespacePackage, + ), + When('the engine analyses it')('analysis', (scope) => analysed(scope.packageRoot)), + Then('the namespace keeps its tag, its first member is untagged, its second member is internal, and the later namespace member stays untagged')((scope, expect) => + expect({ + namespace: itemNamed(scope.analysis.model, 'Config'), + first: itemNamed(scope.analysis.model, 'load'), + second: itemNamed(scope.analysis.model, 'reset'), + previous: itemNamed(scope.analysis.model, 'previous'), + later: itemNamed(scope.analysis.model, 'first'), + }).toMatchObject({ + namespace: { kind: 'namespace', name: 'Config', releaseTag: 'beta' }, + first: { kind: 'variable', name: 'load', releaseTag: 'untagged' }, + second: { kind: 'variable', name: 'reset', releaseTag: 'internal' }, + previous: { kind: 'variable', name: 'previous', releaseTag: 'internal' }, + later: { kind: 'variable', name: 'first', releaseTag: 'untagged' }, + })), + ), + ) + }) diff --git a/packages/api-extractor/tests/package-reader.integration.test.ts b/packages/api-extractor/tests/package-reader.integration.test.ts index d5114dc8..9184ca24 100644 --- a/packages/api-extractor/tests/package-reader.integration.test.ts +++ b/packages/api-extractor/tests/package-reader.integration.test.ts @@ -7,6 +7,30 @@ const Feature = makeFeature({ it }) const condition = FrontEnd.FAMILY_SOURCE_CONDITION +const sourceEntry = (exportPath: string, sourceFile: string) => ({ + exportPath, + sourceFile, + key: exportPath, + condition, + target: `./${sourceFile}`, +}) + +const patternEntry = (exportPath: string, sourceFile: string, key = './*') => ({ + exportPath, + sourceFile, + key, + condition, + target: `./${sourceFile}`, +}) + +const bareEntry = (exportPath: string, sourceFile: string) => ({ + exportPath, + sourceFile, + key: exportPath, + condition: null, + target: `./${sourceFile}`, +}) + const writeSource = (root: string) => ([file, text]: readonly [string, string]) => Effect.gen(function* () { @@ -88,10 +112,12 @@ const nestedPatternPackage = packageOf( ) const excludedTargetPackage = packageOf( - { '.': { [condition]: './src/mod.ts' }, './internal/*': null, './legacy': ['./dist/legacy.js'], './nested': { [condition]: { import: './src/nested.ts' } } }, + { '.': { [condition]: './src/mod.ts' }, './internal/*': null, './nested': { [condition]: { import: './src/nested.ts' } } }, { 'src/mod.ts': 'export const mod: number = 1\n', 'src/nested.ts': 'export const nested: number = 1\n' }, ) +const builtTargetPackage = packageOf({ '.': { default: './dist/mod.js' } }, {}) + const rootPatternPackage = packageOf( { './*': { [condition]: './*.ts' } }, { 'mod.ts': '', 'node_modules/dep/index.ts': '' }, @@ -190,8 +216,8 @@ Feature("A package's exports name its entry points") When('the package reader reads its entry points')('entryPoints', (scope) => entryPointsOf(scope.packageRoot)), Then('both sources are read, `.` from the `.ts` file and `./legacy` from the `.cts` file')((scope, expect) => expect(scope.entryPoints).toEqual([ - { exportPath: '.', sourceFile: 'src/mod.ts' }, - { exportPath: './legacy', sourceFile: 'src/legacy.cts' }, + sourceEntry('.', 'src/mod.ts'), + sourceEntry('./legacy', 'src/legacy.cts'), ]) ), ), @@ -212,9 +238,9 @@ Feature("A package's exports name its entry points") When('the package reader reads its entry points')('entryPoints', (scope) => entryPointsOf(scope.packageRoot)), Then('each declaration file, `.d.ts` and `.d.mts` alike, is its own entry source in export path order')((scope, expect) => expect(scope.entryPoints).toEqual([ - { exportPath: '.', sourceFile: 'src/index.d.ts' }, - { exportPath: './legacy', sourceFile: 'src/legacy.d.cts' }, - { exportPath: './modern', sourceFile: 'src/modern.d.mts' }, + sourceEntry('.', 'src/index.d.ts'), + sourceEntry('./legacy', 'src/legacy.d.cts'), + sourceEntry('./modern', 'src/modern.d.mts'), ]) ), ), @@ -227,9 +253,9 @@ Feature("A package's exports name its entry points") When('the package reader reads its entry points')('entryPoints', (scope) => entryPointsOf(scope.packageRoot)), Then('one entry is read per matching source, the `*` taking the nested name whole')((scope, expect) => expect(scope.entryPoints).toEqual([ - { exportPath: './a', sourceFile: 'src/a.ts' }, - { exportPath: './b', sourceFile: 'src/b.ts' }, - { exportPath: './nested/c', sourceFile: 'src/nested/c.ts' }, + patternEntry('./a', 'src/a.ts'), + patternEntry('./b', 'src/b.ts'), + patternEntry('./nested/c', 'src/nested/c.ts'), ]) ), ), @@ -241,7 +267,7 @@ Feature("A package's exports name its entry points") Given('a package exporting every `./src/.ts` through `"./*"` where `src/loop` is a symlink back to `src`')('packageRoot', () => loopPatternPackage), When('the package reader reads its entry points')('entryPoints', (scope) => entryPointsOf(scope.packageRoot)), Then('only the real source is read, the symlink loop contributing no entry')((scope, expect) => - expect(scope.entryPoints).toEqual([{ exportPath: './a', sourceFile: 'src/a.ts' }]) + expect(scope.entryPoints).toEqual([patternEntry('./a', 'src/a.ts')]) ), ), ) @@ -252,7 +278,7 @@ Feature("A package's exports name its entry points") Given('a package exporting every `./src/.ts` through `"./*"` where `src/a/b/up` is a symlink back to `src/a`')('packageRoot', () => parentLoopPatternPackage), When('the package reader reads its entry points')('entryPoints', (scope) => entryPointsOf(scope.packageRoot)), Then('only the real source is read, the upward symlink contributing no entry')((scope, expect) => - expect(scope.entryPoints).toEqual([{ exportPath: './a/x', sourceFile: 'src/a/x.ts' }]) + expect(scope.entryPoints).toEqual([patternEntry('./a/x', 'src/a/x.ts')]) ), ), ) @@ -264,8 +290,8 @@ Feature("A package's exports name its entry points") When('the package reader reads its entry points')('entryPoints', (scope) => entryPointsOf(scope.packageRoot)), Then('the sibling directory is walked under both its real name and its alias')((scope, expect) => expect(scope.entryPoints).toEqual([ - { exportPath: './a/x', sourceFile: 'src/a/x.ts' }, - { exportPath: './b/x', sourceFile: 'src/b/x.ts' }, + patternEntry('./a/x', 'src/a/x.ts'), + patternEntry('./b/x', 'src/b/x.ts'), ]) ), ), @@ -278,9 +304,9 @@ Feature("A package's exports name its entry points") When('the package reader reads its entry points')('entryPoints', (scope) => entryPointsOf(scope.packageRoot)), Then('`./special` reads from the explicit source rather than the pattern match of the same export path')((scope, expect) => expect(scope.entryPoints).toEqual([ - { exportPath: './a', sourceFile: 'src/a.ts' }, - { exportPath: './b', sourceFile: 'src/b.ts' }, - { exportPath: './special', sourceFile: 'src/b.ts' }, + patternEntry('./a', 'src/a.ts'), + patternEntry('./b', 'src/b.ts'), + sourceEntry('./special', 'src/b.ts'), ]) ), ), @@ -293,7 +319,7 @@ Feature("A package's exports name its entry points") missingPatternDirectoryPackage), When('the package reader reads its entry points')('entryPoints', (scope) => entryPointsOf(scope.packageRoot)), Then('only the explicit `.` entry is read')((scope, expect) => - expect(scope.entryPoints).toEqual([{ exportPath: '.', sourceFile: 'src/index.ts' }]) + expect(scope.entryPoints).toEqual([sourceEntry('.', 'src/index.ts')]) ), ), ) @@ -304,7 +330,7 @@ Feature("A package's exports name its entry points") Given('a package exporting `./src/.ts` through `"./*"` while `./other/b.ts` also exists')('packageRoot', () => outsidePatternPackage), When('the package reader reads its entry points')('entryPoints', (scope) => entryPointsOf(scope.packageRoot)), Then('only the source under the target directory is read')((scope, expect) => - expect(scope.entryPoints).toEqual([{ exportPath: './a', sourceFile: 'src/a.ts' }]) + expect(scope.entryPoints).toEqual([patternEntry('./a', 'src/a.ts')]) ), ), ) @@ -315,18 +341,32 @@ Feature("A package's exports name its entry points") Given('a package exporting `./*` from `./src/*.ts` and `./x/*` from `./lib/*.ts`')('packageRoot', () => nestedPatternPackage), When('the package reader reads its entry points')('entryPoints', (scope) => entryPointsOf(scope.packageRoot)), Then('the shared `./x/y` entry reads from the longer-prefixed pattern')((scope, expect) => - expect(scope.entryPoints).toEqual([{ exportPath: './x/y', sourceFile: 'lib/y.ts' }]) + expect(scope.entryPoints).toEqual([patternEntry('./x/y', 'lib/y.ts', './x/*')]) ), ), ) scenario( - 'a null, array or non-path export target is skipped, and the package is still read', + 'a null export target is skipped and a nested condition is resolved, and the package is still read', Gherkin.Do.pipe( - Given('a package exporting `./src/mod.ts` at `.`, excluding `./internal/*` with null, listing `./legacy` as a fallback array and nesting conditions under the source condition at `./nested`')('packageRoot', () => excludedTargetPackage), + Given('a package exporting `./src/mod.ts` at `.`, excluding `./internal/*` with null and nesting an `import` condition under the source condition at `./nested`')('packageRoot', () => excludedTargetPackage), When('the package reader reads its entry points')('entryPoints', (scope) => entryPointsOf(scope.packageRoot)), - Then('only the `.` entry is read')((scope, expect) => - expect(scope.entryPoints).toEqual([{ exportPath: '.', sourceFile: 'src/mod.ts' }]) + Then('`.` and `./nested`, whose inner target the source condition selects, are read')((scope, expect) => + expect(scope.entryPoints).toEqual([ + sourceEntry('.', 'src/mod.ts'), + sourceEntry('./nested', 'src/nested.ts'), + ]) + ), + ), + ) + + scenario( + 'an export whose only target is a built JavaScript file is refused, naming the export and its target', + Gherkin.Do.pipe( + Given('a package whose `.` export resolves only to `./dist/mod.js`')('packageRoot', () => builtTargetPackage), + When('the package reader reads it')('refusal', (scope) => refusalOfReading(scope.packageRoot)), + Then('it is refused as having no source entry, naming `.` and `./dist/mod.js`')((scope, expect) => + expect(scope.refusal).toMatchObject({ _tag: 'NoSourceEntry', exportKey: '.', target: './dist/mod.js' }) ), ), ) @@ -340,8 +380,8 @@ Feature("A package's exports name its entry points") Effect.all({ rootPattern: entryPointsOf(scope.rootPattern), suffixless: entryPointsOf(scope.suffixless) })), Then('each reads only its TypeScript sources inside the package')((scope, expect) => expect(scope.entryPoints).toEqual({ - rootPattern: [{ exportPath: './mod', sourceFile: 'mod.ts' }], - suffixless: [{ exportPath: './mod.ts', sourceFile: 'src/mod.ts' }, { exportPath: './sub/x.ts', sourceFile: 'src/sub/x.ts' }], + rootPattern: [patternEntry('./mod', 'mod.ts')], + suffixless: [patternEntry('./mod.ts', 'src/mod.ts'), patternEntry('./sub/x.ts', 'src/sub/x.ts')], }) ), ), @@ -418,7 +458,7 @@ Feature("A package's exports name its entry points") ), When('the package reader reads its entry points')('entryPoints', (scope) => entryPointsOf(scope.packageRoot)), Then('only the package\'s own source is an entry point')((scope, expect) => - expect(scope.entryPoints).toEqual([{ exportPath: './mod', sourceFile: 'mod.ts' }])), + expect(scope.entryPoints).toEqual([patternEntry('./mod', 'mod.ts')])), ), ) @@ -431,7 +471,7 @@ Feature("A package's exports name its entry points") ), When('the package reader reads its entry points')('entryPoints', (scope) => entryPointsOf(scope.packageRoot)), Then('the nested fan-out is pruned and only the package\'s own source is an entry point')((scope, expect) => - expect(scope.entryPoints).toEqual([{ exportPath: './mod', sourceFile: 'src/mod.ts' }])), + expect(scope.entryPoints).toEqual([patternEntry('./mod', 'src/mod.ts')])), ), ) @@ -449,8 +489,8 @@ Feature("A package's exports name its entry points") When('the package reader reads its entry points')('entryPoints', (scope) => entryPointsOf(scope.packageRoot)), Then('`./a/x` and `./internal/z` name no entry, since their winning keys resolve no source, while `./a/y` and `./b` do')((scope, expect) => expect(scope.entryPoints).toEqual([ - { exportPath: './a/y', sourceFile: 'vendor/y.ts' }, - { exportPath: './b', sourceFile: 'src/b.ts' }, + patternEntry('./a/y', 'vendor/y.ts', './a/*'), + patternEntry('./b', 'src/b.ts'), ])), ), ) @@ -465,7 +505,7 @@ Feature("A package's exports name its entry points") )), When('the package reader reads its entry points')('entryPoints', (scope) => entryPointsOf(scope.packageRoot)), Then('only `.` is an entry point, and no export path holds a star')((scope, expect) => - expect(scope.entryPoints).toEqual([{ exportPath: '.', sourceFile: 'src/mod.ts' }])), + expect(scope.entryPoints).toEqual([sourceEntry('.', 'src/mod.ts')])), ), ) @@ -478,12 +518,12 @@ Feature("A package's exports name its entry points") ), When('the package reader reads its entry points')('entryPoints', (scope) => entryPointsOf(scope.packageRoot)), Then('`node_modules` is never walked and `./A` is the one entry point')((scope, expect) => - expect(scope.entryPoints).toEqual([{ exportPath: './A', sourceFile: 'srcA.ts' }])), + expect(scope.entryPoints).toEqual([patternEntry('./A', 'srcA.ts')])), ), ) scenario( - 'a package whose exports is a bare path or absent names no source entry', + 'a package whose exports is a bare path names `.`, while a package with no exports names no source entry', Gherkin.Do.pipe( Given('a package whose `exports` is the string `./src/mod.ts`')('stringExports', () => rawPackageOf(JSON.stringify({ name: 'fixture', version: '1.0.0', exports: './src/mod.ts' })), @@ -492,18 +532,22 @@ Feature("A package's exports name its entry points") Given('a package whose `exports` maps `.` straight to the path `./src/mod.ts`, with no source condition')('bareSubpathTarget', () => rawPackageOf(JSON.stringify({ name: 'fixture', version: '1.0.0', exports: { '.': './src/mod.ts' } })), ), - When('the package reader reads all three')('refusals', (scope) => + When('the package reader reads all three')('reads', (scope) => Effect.all({ - stringExports: refusalOfReading(scope.stringExports), + stringExports: entryPointsOf(scope.stringExports), absentExports: refusalOfReading(scope.absentExports), - bareSubpathTarget: refusalOfReading(scope.bareSubpathTarget), + bareSubpathTarget: entryPointsOf(scope.bareSubpathTarget), }), ), - Then('each is refused as having no source entry, naming its own package root')((scope, expect) => - expect(scope.refusals).toMatchObject({ - stringExports: { _tag: 'NoSourceEntry', packageRoot: scope.stringExports }, - absentExports: { _tag: 'NoSourceEntry', packageRoot: scope.absentExports }, - bareSubpathTarget: { _tag: 'NoSourceEntry', packageRoot: scope.bareSubpathTarget }, + Then('the two bare paths resolve `.` with no condition, and the package with no exports is refused naming its root')((scope, expect) => + expect({ + stringExports: scope.reads.stringExports, + absentExports: scope.reads.absentExports, + bareSubpathTarget: scope.reads.bareSubpathTarget, + }).toEqual({ + stringExports: [bareEntry('.', 'src/mod.ts')], + absentExports: expect.objectContaining({ _tag: 'NoSourceEntry', packageRoot: scope.absentExports }), + bareSubpathTarget: [bareEntry('.', 'src/mod.ts')], }), ), ), @@ -527,8 +571,8 @@ Feature("A package's exports name its entry points") })), Then('each read takes the source its condition names')((scope, expect) => expect(scope.entryPoints).toEqual({ - family: [{ exportPath: '.', sourceFile: 'src/mod.ts' }], - development: [{ exportPath: '.', sourceFile: 'src/other.ts' }], + family: [sourceEntry('.', 'src/mod.ts')], + development: [{ ...sourceEntry('.', 'src/other.ts'), condition: 'development' }], }) ), ), @@ -551,9 +595,9 @@ Feature("A package's exports name its entry points") })), Then('each reads only the entries whose export name is one real path segment')((scope, expect) => expect(scope.entryPoints).toEqual({ - dotSegment: [{ exportPath: '.', sourceFile: 'entry.ts' }, { exportPath: './a', sourceFile: 'src/a.ts' }], - starlessTarget: [{ exportPath: '.', sourceFile: 'entry.ts' }], - twoStarTarget: [{ exportPath: '.', sourceFile: 'entry.ts' }], + dotSegment: [sourceEntry('.', 'entry.ts'), patternEntry('./a', 'src/a.ts')], + starlessTarget: [sourceEntry('.', 'entry.ts')], + twoStarTarget: [sourceEntry('.', 'entry.ts')], }) ), ), @@ -567,8 +611,8 @@ Feature("A package's exports name its entry points") When('the package reader reads its entry points')('entryPoints', (scope) => entryPointsOf(scope.packageRoot)), Then('each export name is the file name, dollar signs and all')((scope, expect) => expect(scope.entryPoints).toEqual([ - { exportPath: './a$$b', sourceFile: 'src/a$$b.ts' }, - { exportPath: './c$&d', sourceFile: 'src/c$&d.ts' }, + patternEntry('./a$$b', 'src/a$$b.ts'), + patternEntry('./c$&d', 'src/c$&d.ts'), ]) ), ), @@ -610,7 +654,16 @@ Feature("A package's exports name its entry points") packageRoot: scope.withBuildConfig.packageRoot, workspaceRoot: scope.withBuildConfig.workspaceRoot, tsconfigPath: scope.withBuildConfig.tsconfigPath, - entryPoints: [{ exportPath: '.', sourceFile: 'src/mod.ts' }], + anchorBase: 'workspace', + anchorPrefix: 'packs/with-build-config', + manifest: { + name: 'fixture', + version: '1.0.0', + exports: [{ key: '.', target: './src/mod.ts', condition }], + imports: [], + }, + patternEmpties: [], + entryPoints: [sourceEntry('.', 'src/mod.ts')], }, withDefaultConfig: { name: 'fixture', @@ -618,7 +671,16 @@ Feature("A package's exports name its entry points") packageRoot: scope.withDefaultConfig.packageRoot, workspaceRoot: scope.withDefaultConfig.workspaceRoot, tsconfigPath: scope.withDefaultConfig.tsconfigPath, - entryPoints: [{ exportPath: '.', sourceFile: 'src/mod.ts' }], + anchorBase: 'workspace', + anchorPrefix: 'packs/with-default-config', + manifest: { + name: 'fixture', + version: '1.0.0', + exports: [{ key: '.', target: './src/mod.ts', condition }], + imports: [], + }, + patternEmpties: [], + entryPoints: [sourceEntry('.', 'src/mod.ts')], }, }) ), diff --git a/packages/api-extractor/tests/serialise.integration.test.ts b/packages/api-extractor/tests/serialise.integration.test.ts index 11d448fa..23a59335 100644 --- a/packages/api-extractor/tests/serialise.integration.test.ts +++ b/packages/api-extractor/tests/serialise.integration.test.ts @@ -1,15 +1,15 @@ import { Gherkin, Given, it, makeFeature, Then, When } from '@systemfsoftware/effect-gherkin-spec' import { ApiModel } from '@systemfsoftware/api-extractor' import { Arbitrary, Effect, Layer, Schema } from 'effect' -import { empty, functionItem, ref, rootedWith, signature } from './__fixtures__/surfaces.ts' +import { empty, functionItem, META, ref, rootedWith, signature } from './__fixtures__/surfaces.ts' type Model = ApiModel.ApiModel type Item = ApiModel.ApiItem const Feature = makeFeature({ it }) -const value: Item = { kind: 'variable', name: 'Shared', constant: true, type: ref('A') } -const type: Item = { kind: 'typeAlias', name: 'Shared', typeParameters: [], type: ref('B') } +const value: Item = { kind: 'variable', name: 'Shared', constant: true, type: ref('A'), ...META } +const type: Item = { kind: 'typeAlias', name: 'Shared', typeParameters: [], type: ref('B'), ...META } const first: Item = functionItem(signature([], ref('A'))) const second: Item = functionItem(signature([], ref('B'))) @@ -34,6 +34,16 @@ const otherVersion: Schema.Json = { entryPoints: [], } +const previousVersion: Schema.Json = { + schemaVersion: 2, + producer: { engine: 'fixture@0.0.0', declarations: 'hand-written' }, + package: { name: 'fixture', version: '1.0.0', exports: [], imports: [] }, + anchorBase: 'package', + entryPoints: [], + messages: [], + coverage: { complete: true, reasons: [] }, +} + Feature('The serialised model is a canonical, versioned form of the surface') .withLayer(Layer.empty) .body(({ scenario }) => { @@ -93,4 +103,17 @@ Feature('The serialised model is a canonical, versioned form of the surface') ), ), ) + + scenario( + 'a model declaring the previous schema version, version 2, is refused', + Gherkin.Do.pipe( + Given('an encoded model declaring schema version 2')('encoded', () => Effect.succeed(previousVersion)), + When('the model is decoded')('refusal', (scope) => + Effect.flip(Effect.try({ try: () => ApiModel.decode(scope.encoded), catch: String })), + ), + Then('the refusal names the schema version')((scope, expect) => + expect(scope.refusal).toSatisfy((message: string) => message.includes('schemaVersion'), 'the refusal names schemaVersion'), + ), + ), + ) }) diff --git a/packages/api-extractor/tests/verdict.integration.test.ts b/packages/api-extractor/tests/verdict.integration.test.ts index dba0bc7d..553c2be0 100644 --- a/packages/api-extractor/tests/verdict.integration.test.ts +++ b/packages/api-extractor/tests/verdict.integration.test.ts @@ -1,7 +1,7 @@ import { Gherkin, Given, it, makeFeature, Then, When } from '@systemfsoftware/effect-gherkin-spec' import { ApiModel, Verdict } from '@systemfsoftware/api-extractor' import { Array as Arr, Effect, Layer, Option, Result } from 'effect' -import { decodeVerdict, effect, empty, functionItem, ref, signature, union, withEntryPoints, withExport } from './__fixtures__/surfaces.ts' +import { decodeVerdict, effect, empty, functionItem, META, ref, signature, union, withEntryPoints, withExport } from './__fixtures__/surfaces.ts' type Model = ApiModel.ApiModel type Item = ApiModel.ApiItem @@ -11,9 +11,15 @@ const Feature = makeFeature({ it }) const before = withExport(empty, functionItem(signature([], effect(ref('User'), ref('NotFound'))))) const after = withExport(empty, functionItem(signature([], effect(ref('User'), union([ref('NotFound'), ref('Timeout')]))))) -const entry: ApiModel.EntryPoint = { path: '.', items: [], locals: [] } +const entry: ApiModel.EntryPoint = { path: '.', target: { key: '.', condition: null, path: './src/mod.ts' }, items: [], locals: [] } const entryRepeated: Model = withEntryPoints(empty, [entry, entry]) -const enumOf = (...names: ReadonlyArray): Item => ({ kind: 'enum', name: 'Mode', const: false, members: names.map((name) => ({ name })) }) +const enumOf = (...names: ReadonlyArray): Item => ({ + kind: 'enum', + name: 'Mode', + const: false, + members: names.map((name) => ({ name })), + ...META, +}) const enumRepeated = withExport(empty, enumOf('On', 'On')) const enumDistinct = withExport(empty, enumOf('On', 'Off')) diff --git a/packages/api-extractor/vitest.config.ts b/packages/api-extractor/vitest.config.ts index a1f9b84e..f176d447 100644 --- a/packages/api-extractor/vitest.config.ts +++ b/packages/api-extractor/vitest.config.ts @@ -1,9 +1,9 @@ -import { recursionBudgetTransform } from '@systemfsoftware/effect-schema-recursion-budget' +import { inlineSchemaTests } from '@systemfsoftware/effect-schema-vite' import { defineConfig, sharedConfig } from '@systemfsoftware/vitest-config' export default defineConfig({ ...sharedConfig, - plugins: [recursionBudgetTransform()], + plugins: [inlineSchemaTests()], test: { ...sharedConfig.test, include: ['src/**/*.test.ts', 'tests/**/*.test.ts'],