From 4b700c8c22204d8efe0066dae60993ec648d8bbf Mon Sep 17 00:00:00 2001 From: systemfsoftware-maker Date: Sat, 24 Oct 2026 15:42:53 -0400 Subject: [PATCH 1/2] feat(model): anchors, release tags and manifest facts in model v3 Every item in the model now carries its source anchor, release tag and `@stability` value, and the model carries the package's `exports` and `imports` maps, the analysis messages with their anchors, the anchor base and the coverage. The schema version moves to 3 and a version 2 model is refused. A source index parses every source both emitter routes read with oxc-parser, so the two routes give the same anchors without declaration maps; the TSDoc helpers are shared with the message resolver. `readPackage` resolves targets through the requested condition, then `types`, `import`, `default` and the first remaining key, accepts a bare-string `exports` and bare path targets, refuses a target that is not TypeScript source, and records an `imports` map as internal surface. `analyse` returns `{ route, model }` with the messages and coverage in the model. Type parameters keep their `in`, `out` and `const` modifiers, closing the PR #7 review's deferred finding. The generated schema-law suite is enabled through `inlineSchemaTests()`. --- .changeset/brave-lions-report.md | 20 + README.md | 38 +- packages/api-extractor/src/ApiModel.ts | 8 + packages/api-extractor/src/FrontEnd.ts | 2 +- .../judge-surfaces.workflow.property.test.ts | 16 +- .../api-extractor/src/diff/change.schema.ts | 5 + .../api-extractor/src/front-end/analyse.ts | 24 +- .../src/front-end/build-model.ts | 158 ++++++- .../src/front-end/canonical-type-text.ts | 16 +- .../src/front-end/declaration-graph.ts | 17 +- .../src/front-end/declarations.schema.ts | 47 +- .../src/front-end/front-end-error.schema.ts | 31 +- .../src/front-end/oxc-isolated-emitter.ts | 21 +- .../src/front-end/package-source.schema.ts | 70 ++- .../src/front-end/read-package.ts | 176 +++++-- .../src/front-end/resolve-messages.ts | 173 ++++--- .../src/front-end/source-index.ts | 436 ++++++++++++++++++ packages/api-extractor/src/front-end/tsdoc.ts | 102 ++++ .../src/front-end/type-expr-of.ts | 13 +- .../src/front-end/typescript-7-compiler.ts | 52 ++- .../api-extractor/src/model/anchor.schema.ts | 34 ++ .../src/model/api-model.schema.ts | 169 ++++++- .../message.schema.ts} | 4 + .../src/model/type-expr.schema.ts | 13 + .../api-extractor/src/schema-laws.test.ts | 3 + .../tests/__fixtures__/declarations.ts | 9 +- .../tests/__fixtures__/surfaces.ts | 34 +- .../tests/__fixtures__/upstream-messages.ts | 3 + .../analysis-messages.integration.test.ts | 25 +- .../tests/change-kinds.integration.test.ts | 25 +- .../tests/front-end-model.integration.test.ts | 18 +- .../front-end-namespaces.integration.test.ts | 24 +- .../front-end-routing.integration.test.ts | 16 +- .../tests/model-v3.integration.test.ts | 197 ++++++++ .../tests/package-reader.integration.test.ts | 160 +++++-- .../tests/serialise.integration.test.ts | 29 +- .../tests/verdict.integration.test.ts | 12 +- packages/api-extractor/vitest.config.ts | 4 +- 38 files changed, 1870 insertions(+), 334 deletions(-) create mode 100644 .changeset/brave-lions-report.md create mode 100644 packages/api-extractor/src/front-end/source-index.ts create mode 100644 packages/api-extractor/src/front-end/tsdoc.ts create mode 100644 packages/api-extractor/src/model/anchor.schema.ts rename packages/api-extractor/src/{front-end/analysis-message.schema.ts => model/message.schema.ts} (78%) create mode 100644 packages/api-extractor/src/schema-laws.test.ts create mode 100644 packages/api-extractor/tests/model-v3.integration.test.ts 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..e86ab9fb --- /dev/null +++ b/packages/api-extractor/src/front-end/source-index.ts @@ -0,0 +1,436 @@ +/** + * 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 +} + +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, 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) => + Arr.forEach(block.body, (statement) => recordStatement(context, 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 }, + } + 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..3dc5ff54 --- /dev/null +++ b/packages/api-extractor/tests/model-v3.integration.test.ts @@ -0,0 +1,197 @@ +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' }, +) + +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: [] }, + }) + ), + ), + ) + }) 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'], From cf3b4d91c3eb3ff9b3f6a6784f569d9b9acf9531 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Sat, 24 Oct 2026 18:27:34 -0400 Subject: [PATCH 2/2] fix(front-end): read a namespace member's tag from its own comment The first statement in a namespace block has no preceding sibling, so precedingEnd returned 0 and the leading-doc-comment lookup reached back past the namespace to an unrelated top-level comment. The first member of a @beta namespace inherited beta, and a member of a later namespace inherited an earlier declaration's internal tag. Carry the block's opening brace as the comment floor in the source index context and use the greater of that floor and the preceding sibling's end. The namespace's own tag still comes from its own leading comment. Adds a model v3 scenario covering a tagged namespace whose first member is untagged and whose second member carries its own tag, plus a later namespace after an internal declaration. --- .../src/front-end/source-index.ts | 16 ++++++-- .../tests/model-v3.integration.test.ts | 41 +++++++++++++++++++ 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/packages/api-extractor/src/front-end/source-index.ts b/packages/api-extractor/src/front-end/source-index.ts index e86ab9fb..4adaecea 100644 --- a/packages/api-extractor/src/front-end/source-index.ts +++ b/packages/api-extractor/src/front-end/source-index.ts @@ -136,6 +136,13 @@ type Context = { /** 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 @@ -145,7 +152,7 @@ const factsOf = ( siblings: ReadonlyArray<{ readonly end: number }>, at: number, ): { readonly releaseTag: ReleaseTag; readonly stability: string | null } => - Option.match(leadingDocComment(context.comments, precedingEnd(siblings, at), at), { + 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) @@ -268,8 +275,10 @@ const recordAmbient = (context: Context, kind: 'global' | 'module', module: stri const recordBlock = (context: Context, body: TSModuleBlock | TSModuleDeclaration | null): void => { Match.value(body).pipe( - Match.when({ type: 'TSModuleBlock' }, (block: TSModuleBlock) => - Arr.forEach(block.body, (statement) => recordStatement(context, block.body, statement))), + 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), ) } @@ -340,6 +349,7 @@ const collectSource = (file: SourceFile, anchorPrefix: string, program: Program, 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: [] } diff --git a/packages/api-extractor/tests/model-v3.integration.test.ts b/packages/api-extractor/tests/model-v3.integration.test.ts index 3dc5ff54..5982b073 100644 --- a/packages/api-extractor/tests/model-v3.integration.test.ts +++ b/packages/api-extractor/tests/model-v3.integration.test.ts @@ -54,6 +54,22 @@ const importsOnlyPackage = packageOf( { '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') @@ -194,4 +210,29 @@ Feature('The version 3 model carries anchors, release tags and manifest facts') ), ), ) + + 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' }, + })), + ), + ) })