Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/brave-lions-report.md
Original file line number Diff line number Diff line change
@@ -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.
38 changes: 25 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions packages/api-extractor/src/ApiModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion packages/api-extractor/src/FrontEnd.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
effect,
fn,
functionItem,
META,
never,
parameter,
ref,
Expand Down Expand Up @@ -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))),
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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))],
Expand Down Expand Up @@ -259,6 +266,7 @@ describe('verdict through local type aliases', (it) => {
name,
typeParameters: [{ name: parameterName, default: defaultType }],
type,
...META,
})

it.prop(
Expand Down Expand Up @@ -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',
Expand Down
5 changes: 5 additions & 0 deletions packages/api-extractor/src/diff/change.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,8 +211,12 @@ const defaultArgument = (
const expandTypeParameters = (parameters: ReadonlyArray<TypeParameter>, scope: Scope): ReadonlyArray<TypeParameter> =>
parameters.map((parameter) => expandTypeParameter(parameter, scope))

const modifierFields = (parameter: TypeParameter): Pick<TypeParameter, 'modifiers'> =>
parameter.modifiers === undefined ? {} : { modifiers: parameter.modifiers }

const expandTypeParameter = (parameter: TypeParameter, scope: Scope): TypeParameter => ({
name: parameter.name,
...modifierFields(parameter),
...constraintOf(parameter, scope),
...defaultOf(parameter, scope),
})
Expand Down Expand Up @@ -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<TypeParameter>): string =>
Expand Down
24 changes: 19 additions & 5 deletions packages/api-extractor/src/front-end/analyse.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,18 @@
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'
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<AnalysisMessage>
}

type Routed = { readonly route: Route; readonly declarations: Declarations }
Expand All @@ -35,16 +34,31 @@ const routed = (source: PackageSource): Effect.Effect<Routed, CompilerRejected |
)
})

const patternEmptyReason = (key: string): CoverageReason => ({
kind: 'pattern-empty',
detail: `the pattern export "${key}" resolves no source file`,
})

export const analyse = (
source: PackageSource,
): Effect.Effect<Analysis, CompilerRejected | PlatformError | UnsupportedDeclaration, IsolatedEmitterId | CompilerEmitterId> =>
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
})
Loading
Loading