@systemfsoftware/api-extractor is a public-API tool for Effect-heavy TypeScript
libraries. It reduces a package to one versioned JSON API model, diffs two models,
and gives a semver verdict that understands Effect's A/E/R channels and dual
APIs. Every output is byte-identical across locales, time zones and hosts.
The design and its requirements live in
docs/plans/2026-10-10-1146-feat-effect-api-extractor-plan.md.
| Part | State |
|---|---|
| Versioned model, canonical serialiser, diff, rule corpus and verdict | in this repository |
| Front-ends (isolated-declarations fast path, TypeScript 7 compiler fallback, model builder, analysis messages) | in this repository |
.api.md report with a differential test against @microsoft/api-extractor 7.59.1 |
planned |
| Benchmark, CLI, changeset-bump check | planned |
systemfsoftware and stryker-js-effect packages from the flake (.sfs-deps) |
in this repository |
| Lint, typecheck, build and test on systemfsoftware's reusable checks; tsdown, vitest and lint presets | in this repository |
| Releases: changesets and GitHub Releases through pnpm-release-management | in this repository |
Mutation testing on main |
waits on systemfsoftware's reusable mutation workflow (#710) |
import { Effect } from 'effect'
import { Verdict } from '@systemfsoftware/api-extractor'
// `before` and `after` are encoded API models (the JSON this tool writes).
const document = await Effect.runPromise(Verdict.cell.run({ before, after }))
process.stdout.write(document) // canonical JSON: { _tag, schemaVersion, engine, corpusVersion, declarations, bump, findings }bump is none, patch, minor or major; each finding is
{ rule_id, reason_code, bump, next_action, locations }. A model that does not decode
fails the cell with SurfaceRejected; a rule corpus in which two rules match the same
change fails it with RuleCorpusRejected. The decision itself, Verdict.judge, is a
pure effect-cell-types workflow over decoded values.
Rules are data: src/rules/corpus.v1.json
maps each kind of change, its position (in, out, both), its Effect channel and
its direction (widened, narrowed, changed) to a bump. The most specific matching
rule wins. A change no rule covers is reported as major UNCLASSIFIED_CHANGE.
For example, a function returning Effect<User, NotFound> that now returns
Effect<User, NotFound | Timeout> is major E_WIDENED; the same change to a parameter
is minor E_WIDENED_IN_INPUT. Removing a tagged error from a returned error channel is
major E_TAG_REMOVED, because a caller's catchTag for that tag stops compiling.
import { NodeServices } from '@effect/platform-node'
import { Effect, Layer } from 'effect'
import { ApiModel, FrontEnd } from '@systemfsoftware/api-extractor'
const emitters = Layer.mergeAll(FrontEnd.isolatedEmitterLayer, FrontEnd.compilerEmitterLayer).pipe(
Layer.provideMerge(NodeServices.layer),
)
const analysis = await Effect.runPromise(
FrontEnd.readPackage({ packageRoot: 'packages/my-lib' }).pipe(Effect.flatMap(FrontEnd.analyse), Effect.provide(emitters)),
)
process.stdout.write(ApiModel.serialise(analysis.model))readPackage takes each export whose @systemfsoftware/source condition names a
.ts, .tsx, .mts or .cts file as an entry point. A subpath pattern ("./*")
expands to one entry per matching source under Node's exports rules, and an explicit
subpath wins over a pattern. A package with no such export 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.
analyse first emits declarations with oxc-transform's isolated-declarations emitter,
which never type-checks. If any source an entry point reaches needs the checker (an
unannotated export, TS9xxx), or an entry cannot be proven from its own file (its source is
missing, lies outside the package, or shares a declaration path with another entry, or an
import names no file), the package takes the fallback. This package's own TypeScript 7
tsc runs as a process on a config that extends the package's tsconfig with a fixed emit
policy (FrontEnd.compilerEmitPolicy), and route is Fallback with the diagnostics that
sent it there. A tsc that fails refuses the package with CompilerRejected, whose
diagnostics come from the TypeScript 7 API. Only when the API reports no error (a write
failure, say) does the refusal carry one diagnostic with tsc's exit code and printed
output, which a package's own file names and import specifiers can spell, so a consumer
treats that message as untrusted text. A Reported diagnostic is one the compiler
reported, carrying its TSxxxx code; a Note is a refusal reason no compiler reported, so
it carries no code. Either names a file only when that file is inside the package. A
refusal of an entry point names the entry's recorded source, which may lie outside the
package.
Both routes meet at declaration text: one syntactic builder makes the model and one
resolver reports ae-forgotten-export, ae-missing-release-tag,
ae-internal-missing-underscore, ae-incompatible-release-tags and
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).
The builder refuses a package with UnsupportedDeclaration, naming the file and the
construct, when its declarations contain one of these constructs, which model schema
version 2 cannot hold. The compiler fallback refuses the same way when a declaration it
emitted imports a relative path that names no declaration file inside the package.
- a destructuring declaration, a variable without a type,
import x = require(...),export as namespaceandexport defaultof an expression - an entry point that re-exports a name from another package (
export { it } from 'vitest'). The whole package is refused, not just that export. declare globaland module augmentation (declare module 'vitest')export =- an enum member whose declared value is computed rather than a literal, for example a
hand-written
.d.tswithWrite = 1 << 1. Both emitters write constant members as literals, so this happens only with declaration text written by hand.
One construct is not refused: export * from 'effect', or export * from any other
package, adds no items to the model. The names it re-exports are missing from the model,
and a verdict does not see them change.
The fast-path emitter was chosen from a per-phase profile on systemfsoftware's public
packages, docs/profiles/2026-10-12-front-end-phases.md.
To reproduce it, check out systemfsoftware at the commit the profile names, install it
with pnpm install --frozen-lockfile --ignore-scripts, and run:
nix develop --command pnpm --filter api-extractor-profile profile <systemfsoftware checkout> ../../docs/profiles/<date>-front-end-phasesThe @systemfsoftware/* packages install from tarballs that this repository's flake
builds from pinned systemfsoftware and stryker-js-effect commits, never from a registry.
The dev shell copies them into .sfs-deps/; bootstrap installs inside the
pnpm-release-management sandbox:
nix develop --command pnpm run bootstrap
nix develop --command pnpm test
nix develop --command pnpm lint
nix develop --command pnpm typecheckThis repository is MIT-licensed. The behaviour oracle is @microsoft/api-extractor
7.59.1 (microsoft/rushstack apps/api-extractor at
bc4cd29cc4984cef267437565bf681582948285e), whose MIT notice is reproduced in
LICENSE. A file derived from upstream, or from systemfsoftware PR #552,
names its origin (repository, path and commit) in a header comment. No file in the
current tree is derived from either.