diff --git a/.changeset/old-cobras-punch.md b/.changeset/old-cobras-punch.md new file mode 100644 index 0000000..c03ea22 --- /dev/null +++ b/.changeset/old-cobras-punch.md @@ -0,0 +1,7 @@ +--- +"@systemfsoftware/api-extractor": minor +--- + +Add the `FrontEnd` namespace. It reads a package and emits its declarations two ways behind one emitter port: on the isolated-declarations fast path (oxc-transform), or through the TypeScript 7 compiler when a package's exports are not annotated. One syntactic builder turns those declarations into the versioned API model, and a syntactic resolver reports five api-extractor analysis messages. Both routes produce the same model apart from its producer stamp, which names the emitter that ran. + +The package now installs `typescript` 7, `oxc-parser`, `oxc-transform` and `@microsoft/tsdoc` as runtime dependencies, which the front-ends use. diff --git a/README.md b/README.md index f61b770..b40ebcd 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ The design and its requirements live in | Part | State | |---|---| | Versioned model, canonical serialiser, diff, rule corpus and verdict | in this repository | -| Front-ends (isolated-declarations fast path, compiler fallback) | next | +| 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 | @@ -48,6 +48,114 @@ For example, a function returning `Effect` that now returns 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. +## Front-ends + +```ts +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, 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`. +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 +directory can reach by fanning out. The walk enters only directories the pattern's +prefix can lead to, never a symlink back to an ancestor, and never a `node_modules` +directory no export name could pass through. +A `package.json` +that is missing or cannot be read fails with the file system's `PlatformError`, as every +operation here does on an I/O fault. +`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. The fallback runs TypeScript 7's native compiler, which the `typescript` package +installs for the host platform as an optional dependency. Installed without optional +dependencies, or on a platform TypeScript 7 does not ship for, every package that needs +the fallback is refused with `CompilerRejected`, whose one diagnostic carries `tsc`'s +message naming the missing platform package. `oxc-parser` and `oxc-transform` install +their native bindings the same way, but load them when this package is imported, so +without them the import itself fails with `Cannot find native binding`. +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. This +is the whole list: + +- a destructuring declaration or parameter, a variable without a type or with an + initialiser that is not a literal, `import x = require(...)`, `export as namespace` + and `export default` of 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. +- an export list naming a name that no declaration holds (`export { present, Missing }` with + no `Missing`), which happens only with declaration text written by hand +- `declare global`, module augmentation (`declare module 'vitest'`) and a dotted + namespace (`declare namespace A.B`) +- `export =` +- an import type that names a module without a member (`typeof import('./x.js')`) +- an enum member whose declared value is computed rather than a literal, for example a + hand-written `.d.ts` with `Write = 1 << 1`, and an enum member declared twice across + merged declarations of one enum. Both emitters write constant members as literals and + `tsc` refuses a repeated member, so these happen only with declaration text written by hand. +- an index signature without a parameter, a computed member or method name other than a + literal or a dotted name (`[Symbol.iterator]` is kept), an enum member named by neither + an identifier nor a string, a heritage clause that is not a name (`extends + mixin(Base)`), a type node the printer has no form for, and text that does not parse as + a declaration file + +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`](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: + +```sh +nix develop --command pnpm --filter api-extractor-profile profile ../../docs/profiles/-front-end-phases +``` + ## Development The `@systemfsoftware/*` packages install from tarballs that this repository's flake diff --git a/apps/api-extractor-e2e/tests/front-ends.integration.test.ts b/apps/api-extractor-e2e/tests/front-ends.integration.test.ts new file mode 100644 index 0000000..c6e6fba --- /dev/null +++ b/apps/api-extractor-e2e/tests/front-ends.integration.test.ts @@ -0,0 +1,426 @@ +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, Match, Option, Path } from 'effect' + +const Feature = makeFeature({ it }) + +const emitters = Layer.mergeAll(FrontEnd.isolatedEmitterLayer, FrontEnd.compilerEmitterLayer).pipe(Layer.provideMerge(NodeServices.layer)) + +const STANDALONE_TSCONFIG = { + compilerOptions: { strict: true, target: 'es2022', module: 'nodenext', moduleResolution: 'nodenext', types: [], skipLibCheck: true }, + include: ['src'], +} + +const SOURCE_CONDITION = FrontEnd.FAMILY_SOURCE_CONDITION + +const manifestWith = (name: string, exports: Readonly>): string => + JSON.stringify({ + name, + version: '1.0.0', + type: 'module', + exports: Object.fromEntries(Object.entries(exports).map(([exportPath, source]) => [exportPath, { [SOURCE_CONDITION]: source }])), + }) + +const fallingBackPackage = (name: string, exports: Readonly>, files: Readonly>, tsconfig?: object) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem + const path = yield* Path.Path + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: `front-ends-${name}-` }) + yield* fileSystem.makeDirectory(path.join(root, 'src')) + yield* fileSystem.writeFileString(path.join(root, 'package.json'), manifestWith(name, exports)) + 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))) + yield* Option.match(Option.fromNullishOr(tsconfig), { + onNone: () => Effect.void, + onSome: (config) => fileSystem.writeFileString(path.join(root, 'tsconfig.json'), JSON.stringify(config)), + }) + return yield* FrontEnd.readPackage({ packageRoot: root }) + }) + +const packageWith = (name: string, source: string, compilerOptions: object = {}) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem + const path = yield* Path.Path + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: `front-ends-${name}-` }) + yield* fileSystem.makeDirectory(path.join(root, 'src')) + yield* fileSystem.writeFileString(path.join(root, 'package.json'), JSON.stringify({ name, version: '1.0.0', type: 'module', exports: { '.': { [SOURCE_CONDITION]: './src/mod.ts' } } })) + yield* fileSystem.writeFileString( + path.join(root, 'tsconfig.json'), + JSON.stringify({ ...STANDALONE_TSCONFIG, compilerOptions: { ...STANDALONE_TSCONFIG.compilerOptions, ...compilerOptions } }), + ) + yield* fileSystem.writeFileString(path.join(root, 'src', 'mod.ts'), source) + return yield* FrontEnd.readPackage({ packageRoot: root }) + }) + +const internalExportPackage = packageWith( + 'internal-export', + 'export const shown: number = 1\n/** @internal */\nexport const hidden: number = 2\n', + { stripInternal: true }, +) + +const declarationDirPackage = packageWith('declaration-dir', 'export const placed: number = 1\n', { declarationDir: 'types' }) + +const removeCommentsPackage = packageWith('remove-comments', '/** @public */\nexport const tagged: number = 1\n', { removeComments: true }) + +const declaredTypeRootsPackage = fallingBackPackage( + 'declared-type-roots', + { '.': './src/mod.ts' }, + { + 'src/mod.ts': 'export const greeting: Greeting = { text: "hi" }\n', + 'types/greeting/index.d.ts': 'declare interface Greeting { readonly text: string }\n', + 'types/greeting/package.json': JSON.stringify({ name: 'greeting', types: 'index.d.ts' }), + }, + { ...STANDALONE_TSCONFIG, compilerOptions: { ...STANDALONE_TSCONFIG.compilerOptions, types: ['greeting'], typeRoots: ['./types'] } }, +) + +const nodeModulesTypesPackage = fallingBackPackage( + 'node-modules-types', + { '.': './src/mod.ts' }, + { + 'src/mod.ts': 'export const greeting: Greeting = { text: "hi" }\n', + 'node_modules/greeting/package.json': JSON.stringify({ name: 'greeting', version: '1.0.0', exports: { './globals': { types: './globals.d.ts' } } }), + 'node_modules/greeting/globals.d.ts': 'declare interface Greeting { readonly text: string }\n', + }, + { ...STANDALONE_TSCONFIG, compilerOptions: { ...STANDALONE_TSCONFIG.compilerOptions, types: ['greeting/globals'] } }, +) + +const underNodeModulesPackage = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem + const path = yield* Path.Path + const parent = yield* fileSystem.makeTempDirectoryScoped({ prefix: 'front-ends-under-node-modules-' }) + const packageRoot = path.join(parent, 'node_modules', 'installed') + const files: Readonly> = { + 'node_modules/greeting/package.json': JSON.stringify({ name: 'greeting', version: '1.0.0', exports: { './globals': { types: './globals.d.ts' } } }), + 'node_modules/greeting/globals.d.ts': 'declare interface Greeting { readonly text: string }\n', + 'node_modules/installed/package.json': manifestWith('installed', { '.': './src/mod.ts' }), + 'node_modules/installed/tsconfig.json': JSON.stringify({ ...STANDALONE_TSCONFIG, compilerOptions: { ...STANDALONE_TSCONFIG.compilerOptions, types: ['greeting/globals'] } }), + 'node_modules/installed/src/mod.ts': 'export const greeting: Greeting = { text: "hi" }\n', + } + yield* Effect.forEach(Object.entries(files), ([file, text]) => + Effect.andThen(fileSystem.makeDirectory(path.dirname(path.join(parent, file)), { recursive: true }), fileSystem.writeFileString(path.join(parent, file), text))) + return yield* FrontEnd.readPackage({ packageRoot }) +}) + +const defaultIncludePackage = fallingBackPackage( + 'default-include', + { '.': './src/mod.ts' }, + { 'src/mod.ts': 'export const counted: number = 1\n' }, + { compilerOptions: STANDALONE_TSCONFIG.compilerOptions }, +) + +const handWrittenDeclarationPackage = fallingBackPackage( + 'hand-written-declaration', + { '.': './src/mod.ts' }, + { + 'src/mod.ts': "import type { Shape } from './shape.js'\nexport const unit: Shape = { side: 1 }\n", + 'src/shape.d.ts': 'export interface Shape {\n\treadonly side: number;\n}\n', + }, + STANDALONE_TSCONFIG, +) + +const handWrittenEntryPackage = fallingBackPackage( + 'hand-written-entry', + { '.': './src/index.d.ts' }, + { 'src/index.d.ts': 'export declare const a: string;\n', 'src/index.ts': 'export const a: number = 1\n' }, + STANDALONE_TSCONFIG, +) + +const forgedCodesPackage = packageWith( + 'forged-codes', + [ + 'export const ids = [1, 2].map(String)', + "import 'x\\nerror TS007: spelled by a module specifier'", + "import 'y\\nerror TS1000000000: spelled by a module specifier'", + "import 'z\\nerror TS1234: spelled by a module specifier'", + "import 'w\\nsrc/mod.ts(1,1): error TS4321: spelled by a module specifier\\n'", + '', + ].join('\n'), +) + +const withoutConfigPackage = fallingBackPackage('without-config', { '.': './src/mod.ts' }, { 'src/mod.ts': 'export const ids = [1, 2].map(String)\n' }) + +const excludedEntryPackage = fallingBackPackage( + 'excluded-entry', + { '.': './src/mod.ts' }, + { 'src/mod.ts': 'export const ids = [1, 2].map(String)\n', 'src/other.ts': 'export const other: number = 1\n' }, + { ...STANDALONE_TSCONFIG, include: ['src/other.ts'] }, +) + +const LONG_SOURCE_STEM = 'a'.repeat(251) + +const longNamePackage = fallingBackPackage( + 'long-name', + { '.': './src/mod.ts' }, + { 'src/mod.ts': 'export const ids = [1, 2].map(String)\n', [`src/${LONG_SOURCE_STEM}.ts`]: 'export const long: number = 1\n' }, + STANDALONE_TSCONFIG, +) + +const escapingEntryPackage = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem + const path = yield* Path.Path + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: 'front-ends-escaping-' }) + const packageRoot = path.join(root, 'package') + yield* fileSystem.makeDirectory(path.join(packageRoot, 'src'), { recursive: true }) + yield* fileSystem.makeDirectory(path.join(root, 'shared'), { recursive: true }) + yield* fileSystem.writeFileString(path.join(root, 'shared', 'mod.ts'), 'export const shared: number = 1\n') + yield* fileSystem.writeFileString(path.join(root, 'package', 'src', 'mod.ts'), 'export const own: number = 1\n') + yield* fileSystem.writeFileString(path.join(root, 'package', 'package.json'), manifestWith('escaping-entry', { '.': '../shared/mod.ts' })) + yield* fileSystem.writeFileString(path.join(root, 'package', 'tsconfig.json'), JSON.stringify(STANDALONE_TSCONFIG)) + return yield* FrontEnd.readPackage({ packageRoot }) +}) + +const sharedDeclarationPackage = fallingBackPackage( + 'shared-declaration', + { '.': './src/mod.ts', './mod': './src/mod.tsx' }, + { 'src/mod.ts': 'export const plain: number = 1\n', 'src/mod.tsx': 'export const element: number = 1\n' }, + STANDALONE_TSCONFIG, +) + +const missingEntryPackage = fallingBackPackage('missing-entry', { '.': './src/mod.ts' }, {}, STANDALONE_TSCONFIG) + +type Refused = { readonly message: string; readonly diagnostics?: ReadonlyArray } + +const codeOf = (diagnostic: FrontEnd.Diagnostic): number | undefined => + Match.value(diagnostic).pipe( + Match.tag('Reported', (reported) => reported.code), + Match.tag('Note', () => undefined), + Match.exhaustive, + ) + +const refusalOf = (error: Refused) => { + const diagnostics = error.diagnostics ?? [] + return { + error, + codes: diagnostics.map(codeOf), + files: diagnostics.map((diagnostic) => diagnostic.file), + lines: diagnostics.flatMap((diagnostic) => diagnostic.message.split('\n').filter((line) => /error TS\d+/.test(line))), + } +} + +const routedAs = (analysis: FrontEnd.Analysis) => ({ + route: analysis.route, + declarations: analysis.model.producer.declarations, + items: analysis.model.entryPoints.flatMap((entry) => entry.items), +}) + +const STRING_ARRAY_FALLBACK = { + route: { _tag: 'Fallback', diagnostics: [{ _tag: 'Reported', file: 'src/mod.ts', code: 9010 }] }, + declarations: FrontEnd.TYPESCRIPT_PRODUCER, + items: [{ kind: 'variable', name: 'ids', constant: true, type: { kind: 'array', element: { kind: 'keyword', name: 'string' } } }], +} + +const refusedThrough = (source: FrontEnd.PackageSource) => + Effect.gen(function* () { + const emitter = yield* FrontEnd.CompilerEmitter + return yield* Effect.flip(emitter.emit(source)) + }) + +const writtenInto = (source: FrontEnd.PackageSource, directory: string) => + Effect.flatMap(Path.Path, (path) => Effect.flatMap(FileSystem.FileSystem, (fileSystem) => fileSystem.exists(path.join(source.packageRoot, directory)))) + +const thisPackage = Effect.gen(function* () { + const path = yield* Path.Path + const root = yield* path.fromFileUrl(new URL('../../../packages/api-extractor', import.meta.url)) + return yield* FrontEnd.readPackage({ packageRoot: root }) +}) + +const analysedThrough = (emitter: Effect.Effect, never, Id>, source: FrontEnd.PackageSource) => + Effect.gen(function* () { + const declarations = yield* (yield* emitter).emit(source) + const model = yield* FrontEnd.buildModel({ ...declarations, producer: 'one producer' }) + return { + bytes: ApiModel.serialise(model), + exports: model.entryPoints.flatMap((entry) => entry.items.map((item) => item.name)), + messages: yield* FrontEnd.resolveMessages(declarations), + files: declarations.files.map((file) => file.path), + entries: declarations.entryPoints.map((entry) => ({ exportPath: entry.exportPath, declarationFile: entry.declarationFile })), + } + }) + +const throughBothRoutes = (source: FrontEnd.PackageSource) => + Effect.all({ + isolated: analysedThrough(FrontEnd.IsolatedEmitter, source), + compiled: analysedThrough(FrontEnd.CompilerEmitter, source), + }) + +Feature('Both front-ends reduce a package to one model') + .withLayer(emitters) + .live('the compiler fallback runs the TypeScript 7 tsc of this package as a real child process') + .body(({ scenario }) => { + scenario( + 'the fast path and the compiler fallback give the same model and messages, whatever the package tsconfig says about @internal, comments, declarationDir, type roots, types or included files, wherever the package is installed, and when a source or an entry point names a hand-written declaration file', + Gherkin.Do.pipe( + Given('this isolatedDeclarations-clean package')('source', () => thisPackage), + Given('a package whose tsconfig strips @internal and which exports an @internal constant')('internal', () => internalExportPackage), + Given('a package whose tsconfig removes comments and which exports a constant tagged @public')('removeComments', () => removeCommentsPackage), + Given('a package whose tsconfig sends declarations to `declarationDir: "types"`')('declarationDir', () => declarationDirPackage), + Given('a package whose tsconfig names a global type from its own `typeRoots: ["./types"]`')('typeRoots', () => declaredTypeRootsPackage), + Given('a package whose tsconfig names a global type from a dependency in its own node_modules, `types: ["greeting/globals"]`')('nodeModulesTypes', () => nodeModulesTypesPackage), + Given('a package installed under a `node_modules` directory whose tsconfig names a global type from a sibling dependency')('underNodeModules', () => underNodeModulesPackage), + Given('a package whose tsconfig names no `include`, so every file under it is compiled')('defaultInclude', () => defaultIncludePackage), + Given('a package whose source imports a type from a hand-written `shape.d.ts` beside it')('handWritten', () => handWrittenDeclarationPackage), + Given('a package whose entry point names a hand-written `index.d.ts` beside an `index.ts` the compiler also compiles')('handWrittenEntry', () => handWrittenEntryPackage), + When('each is analysed through the isolated emitter and through the compiler')('analyses', (scope) => + Effect.all({ + ownPackage: throughBothRoutes(scope.source), + internalExport: throughBothRoutes(scope.internal), + removeComments: throughBothRoutes(scope.removeComments), + declarationDir: throughBothRoutes(scope.declarationDir), + typeRoots: throughBothRoutes(scope.typeRoots), + nodeModulesTypes: throughBothRoutes(scope.nodeModulesTypes), + underNodeModules: throughBothRoutes(scope.underNodeModules), + defaultInclude: throughBothRoutes(scope.defaultInclude), + handWritten: throughBothRoutes(scope.handWritten), + handWrittenEntry: throughBothRoutes(scope.handWrittenEntry), + wroteIntoPackage: writtenInto(scope.declarationDir, 'types'), + }) + ), + Then('each pair serialises to the same bytes with the same notes once the producer is set equal, and the compiler writes nothing into the package')((scope, expect) => + expect({ + ownPackage: scope.analyses.ownPackage.compiled, + internalExport: scope.analyses.internalExport.compiled, + internalExports: scope.analyses.internalExport.isolated.exports, + removeComments: scope.analyses.removeComments.compiled, + taggedNotes: scope.analyses.removeComments.isolated.messages, + declarationDir: scope.analyses.declarationDir.compiled, + typeRoots: scope.analyses.typeRoots.compiled, + nodeModulesTypes: scope.analyses.nodeModulesTypes.compiled, + underNodeModules: scope.analyses.underNodeModules.compiled, + defaultInclude: scope.analyses.defaultInclude.compiled, + handWritten: scope.analyses.handWritten.compiled, + handWrittenFiles: scope.analyses.handWritten.compiled.files, + handWrittenEntry: scope.analyses.handWrittenEntry.compiled, + handWrittenEntryEntries: scope.analyses.handWrittenEntry.compiled.entries, + handWrittenEntryExports: scope.analyses.handWrittenEntry.compiled.exports, + wroteIntoPackage: scope.analyses.wroteIntoPackage, + }).toEqual({ + ownPackage: scope.analyses.ownPackage.isolated, + internalExport: scope.analyses.internalExport.isolated, + internalExports: ['hidden', 'shown'], + removeComments: scope.analyses.removeComments.isolated, + taggedNotes: [], + declarationDir: scope.analyses.declarationDir.isolated, + typeRoots: scope.analyses.typeRoots.isolated, + nodeModulesTypes: scope.analyses.nodeModulesTypes.isolated, + underNodeModules: scope.analyses.underNodeModules.isolated, + defaultInclude: scope.analyses.defaultInclude.isolated, + handWritten: scope.analyses.handWritten.isolated, + handWrittenFiles: ['src/mod.d.ts', 'src/shape.d.ts'], + handWrittenEntry: scope.analyses.handWrittenEntry.isolated, + handWrittenEntryEntries: [{ exportPath: '.', declarationFile: 'src/index.d.ts' }], + handWrittenEntryExports: ['a'], + wroteIntoPackage: false, + }) + ), + ), + ) + + scenario( + 'an export with an inferred type routes to the compiler, naming the isolated-declaration diagnostic', + Gherkin.Do.pipe( + Given('a package exporting `const ids = [1, 2].map(String)` with no annotation')('source', () => + packageWith('inferred-export', 'export const ids = [1, 2].map(String)\n')), + Given('the same package whose tsconfig turns on isolatedDeclarations')('declaresIsolated', () => + packageWith('inferred-export-isolated', 'export const ids = [1, 2].map(String)\n', { isolatedDeclarations: true })), + When('the engine analyses each')('analyses', (scope) => + Effect.all({ plain: FrontEnd.analyse(scope.source), declaresIsolated: FrontEnd.analyse(scope.declaresIsolated) })), + Then('both took the fallback for TS9010 on `ids`, stamped by the TypeScript 7 compiler, and modelled `ids` as a string array')((scope, expect) => + expect({ plain: routedAs(scope.analyses.plain), declaresIsolated: routedAs(scope.analyses.declaresIsolated) }).toMatchObject({ + plain: STRING_ARRAY_FALLBACK, + declaresIsolated: STRING_ARRAY_FALLBACK, + }) + ), + ), + ) + + scenario( + 'a package the compiler rejects is refused with the diagnostics tsc reported and no model', + Gherkin.Do.pipe( + Given('a package that needs the fallback, assigns a string to a number and a callback taking a string to one taking a number')('source', () => + packageWith( + 'type-error', + "export const ids = [1, 2].map(String)\nexport const count: number = 'one'\nexport const callback: (x: number) => void = (x: string) => {}\n", + )), + Given('a package that needs the fallback and whose unresolved imports make the compiler print a zero-padded, a ten-digit, a sourceless and a filed line')( + 'forged', + () => forgedCodesPackage, + ), + Given('a package that needs the fallback and has no tsconfig at all')('withoutConfig', () => withoutConfigPackage), + Given('a package that needs the fallback and whose tsconfig leaves the entry source out')('excludedEntry', () => excludedEntryPackage), + Given('a package that needs the fallback and holds a source file whose name leaves no room for its declaration')( + 'longName', + () => longNamePackage, + ), + Given('a package whose entry names a source beside it, outside the package')('escaping', () => escapingEntryPackage), + Given('a package whose two entry sources share one declaration path')('shared', () => sharedDeclarationPackage), + Given('a package whose entry names a source that does not exist')('missing', () => missingEntryPackage), + When('the engine analyses each')('refusals', (scope) => + Effect.all({ + typeError: Effect.map(Effect.flip(FrontEnd.analyse(scope.source)), refusalOf), + forged: Effect.map(Effect.flip(FrontEnd.analyse(scope.forged)), refusalOf), + withoutConfig: Effect.map(Effect.flip(FrontEnd.analyse(scope.withoutConfig)), refusalOf), + excludedEntry: Effect.map(Effect.flip(FrontEnd.analyse(scope.excludedEntry)), refusalOf), + longName: Effect.map(Effect.flip(FrontEnd.analyse(scope.longName)), refusalOf), + escaping: Effect.map(refusedThrough(scope.escaping), refusalOf), + shared: Effect.map(refusedThrough(scope.shared), refusalOf), + missing: Effect.map(Effect.flip(FrontEnd.analyse(scope.missing)), refusalOf), + })), + Then("each is refused with the compiler's own diagnostics, the spelled lines stay inside their message, and the entry outside the package, the shared declaration and the missing source come back without a code")((scope, expect) => + expect({ + typeError: scope.refusals.typeError.error, + forged: scope.refusals.forged.error, + forgedLines: scope.refusals.forged.lines, + withoutConfig: scope.refusals.withoutConfig.error, + excludedEntry: scope.refusals.excludedEntry.error, + longName: scope.refusals.longName.error, + escaping: scope.refusals.escaping.error, + shared: scope.refusals.shared.error, + missing: scope.refusals.missing.error, + files: { longName: scope.refusals.longName.files }, + codes: { + excludedEntry: scope.refusals.excludedEntry.codes, + longName: scope.refusals.longName.codes, + escaping: scope.refusals.escaping.codes, + shared: scope.refusals.shared.codes, + missing: scope.refusals.missing.codes, + }, + }).toMatchObject({ + typeError: { + _tag: 'CompilerRejected', + diagnostics: [ + { _tag: 'Reported', file: 'src/mod.ts', code: 2322 }, + { _tag: 'Reported', file: 'src/mod.ts', code: 2322, message: expect.stringContaining("\n Types of parameters 'x' and 'x' are incompatible.") }, + ], + }, + forged: { + _tag: 'CompilerRejected', + diagnostics: [ + expect.objectContaining({ file: 'src/mod.ts', code: 2882, message: expect.stringContaining("error TS007: spelled by a module specifier'.") }), + expect.objectContaining({ file: 'src/mod.ts', code: 2882, message: expect.stringContaining("error TS1000000000: spelled by a module specifier'.") }), + expect.objectContaining({ file: 'src/mod.ts', code: 2882, message: expect.stringContaining("error TS1234: spelled by a module specifier'.") }), + expect.objectContaining({ file: 'src/mod.ts', code: 2882, message: expect.stringContaining('src/mod.ts(1,1): error TS4321: spelled by a module specifier') }), + ], + }, + forgedLines: [ + "error TS007: spelled by a module specifier'.", + "error TS1000000000: spelled by a module specifier'.", + "error TS1234: spelled by a module specifier'.", + 'src/mod.ts(1,1): error TS4321: spelled by a module specifier', + ], + withoutConfig: { _tag: 'CompilerRejected', diagnostics: expect.arrayContaining([expect.objectContaining({ code: 5083 })]) }, + excludedEntry: { _tag: 'CompilerRejected', diagnostics: [{ _tag: 'Note', file: 'src/mod.d.ts', message: 'the compiler emitted no declaration for this entry point' }] }, + longName: { _tag: 'CompilerRejected', diagnostics: [{ message: expect.stringContaining('error TS5033') }] }, + escaping: { _tag: 'CompilerRejected', diagnostics: [{ _tag: 'Note', file: '../shared/mod.ts', message: expect.stringContaining('resolves outside the package') }] }, + shared: { + _tag: 'CompilerRejected', + diagnostics: [{ _tag: 'Note', file: 'src/mod.d.ts', message: expect.stringContaining('the entry points . and ./mod share the declaration file src/mod.d.ts') }], + }, + missing: { _tag: 'CompilerRejected', diagnostics: [{ _tag: 'Note', file: 'src/mod.ts', message: expect.stringContaining('does not exist') }] }, + files: { longName: [undefined] }, + codes: { excludedEntry: [undefined], longName: [undefined], escaping: [undefined], shared: [undefined], missing: [undefined] }, + }) + ), + ), + ) + }) diff --git a/apps/api-extractor-profile/oxlint.config.ts b/apps/api-extractor-profile/oxlint.config.ts new file mode 100644 index 0000000..50fadb9 --- /dev/null +++ b/apps/api-extractor-profile/oxlint.config.ts @@ -0,0 +1,4 @@ +import recommended from '@systemfsoftware/oxlint-config-recommended' +import { defineConfig } from 'oxlint' + +export default defineConfig({ extends: [recommended] }) diff --git a/apps/api-extractor-profile/package.json b/apps/api-extractor-profile/package.json new file mode 100644 index 0000000..50d03e6 --- /dev/null +++ b/apps/api-extractor-profile/package.json @@ -0,0 +1,29 @@ +{ + "name": "api-extractor-profile", + "version": "0.0.0", + "private": true, + "description": "Per-phase profile of the front-end candidates on a real package corpus", + "type": "module", + "engines": { + "node": ">=24" + }, + "scripts": { + "lint": "f=${OXLINT_FORMAT:-${AGENT:+agent}}; oxlint . --format=${f:-default}", + "typecheck": "tsc --noEmit", + "profile": "node --conditions=@systemfsoftware/source profile.ts" + }, + "devDependencies": { + "@effect/platform-node": "catalog:", + "@systemfsoftware/api-extractor": "workspace:*", + "@systemfsoftware/oxlint-config-recommended": "^4.0.0", + "@systemfsoftware/tsconfig": "^2.0.1", + "@types/node": "catalog:", + "effect": "catalog:", + "oxc-parser": "catalog:", + "oxc-transform": "catalog:", + "oxlint": "catalog:", + "oxlint-tsgolint": "catalog:", + "typescript": "catalog:", + "typescript-5.9": "npm:typescript@5.9.3" + } +} diff --git a/apps/api-extractor-profile/profile.ts b/apps/api-extractor-profile/profile.ts new file mode 100644 index 0000000..bb79331 --- /dev/null +++ b/apps/api-extractor-profile/profile.ts @@ -0,0 +1,445 @@ +import { NodeRuntime, NodeServices } from '@effect/platform-node' +import { type ApiModel, FrontEnd } from '@systemfsoftware/api-extractor' +import { Array as Arr, Duration, Effect, FileSystem, Layer, Option, Order, Path, Result, Schema, Stream } from 'effect' +import type { PlatformError } from 'effect/PlatformError' +import { ChildProcess, ChildProcessSpawner } from 'effect/process' +import { createRequire } from 'node:module' +import os from 'node:os' +import { parseSync } from 'oxc-parser' +import { isolatedDeclarationSync } from 'oxc-transform' +import ts59 from 'typescript-5.9' + +const WARM_RUNS = 5 +const require = createRequire(import.meta.url) + +const TYPESCRIPT_SOURCE = /\.(?:ts|tsx|mts|cts)$/ + +const ConditionMap = Schema.Record(Schema.String, Schema.Json) + +const isConditionMap = Schema.is(ConditionMap) + +const ExportTarget = Schema.Union([Schema.String, Schema.Null, Schema.Array(Schema.Json), ConditionMap]) + +const Manifest = Schema.Struct({ + name: Schema.String, + version: Schema.String, + private: Schema.optionalKey(Schema.Boolean), + exports: Schema.optionalKey(Schema.Record(Schema.String, ExportTarget)), +}) +type Manifest = typeof Manifest.Type + +type ProfileError = PlatformError | Schema.SchemaError | UsageError | GitError + +class UsageError extends Schema.TaggedError()('UsageError', { usage: Schema.String }) {} + +class GitError extends Schema.TaggedError()('GitError', { args: Schema.Array(Schema.String), exitCode: Schema.Int, stderr: Schema.String }) { + override get message(): string { + return `git ${this.args.join(' ')} exited with code ${this.exitCode}: ${this.stderr}` + } +} + +interface Subject { + readonly origin: 'systemfsoftware' | 'api-extractor-effect' + readonly root: string + readonly workspaceRoot: string +} + +interface Timing { + readonly coldMs: number + readonly warmMedianMs: number +} + +interface Emit { + readonly diagnostics: number + readonly outputs: ReadonlyMap +} + +const median = (values: ReadonlyArray): number => { + const sorted = Arr.sort(values, Order.Number) + return sorted[Math.floor(sorted.length / 2)] ?? 0 +} + +const round = (ms: number): number => Math.round(ms * 100) / 100 + +const measure = (effect: Effect.Effect, runs: number = WARM_RUNS) => + Effect.gen(function* () { + const [cold, result] = yield* Effect.timed(effect) + const warm = yield* Effect.forEach(Array.from({ length: runs }), () => Effect.map(Effect.timed(effect), ([duration]) => Duration.toMillis(duration))) + const timing: Timing = { coldMs: round(Duration.toMillis(cold)), warmMedianMs: round(runs === 0 ? Duration.toMillis(cold) : median(warm)) } + return { result, timing } + }) + +const sourceEntryOf = (target: typeof ExportTarget.Type): Option.Option => + Option.flatMap(Option.liftPredicate(target, isConditionMap), (conditions) => + Option.fromNullishOr(conditions[FrontEnd.FAMILY_SOURCE_CONDITION]).pipe(Option.filter((value): value is string => typeof value === 'string' && TYPESCRIPT_SOURCE.test(value)))) + +const entrySources = (manifest: Manifest): ReadonlyArray => + Arr.getSomes(Object.entries(manifest.exports ?? {}).filter(([key]) => key !== './package.json').map(([, target]) => sourceEntryOf(target))) + +const readManifest = (root: string) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem + const path = yield* Path.Path + const text = yield* fileSystem.readFileString(path.join(root, 'package.json')) + return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Manifest))(text) + }) + +const RELATIVE = /^\.\.?\// + +const specifiersOf = (file: string, text: string): ReadonlyArray => { + const { module } = parseSync(file, text) + return [...module.staticImports.map((entry) => entry.moduleRequest.value), ...module.staticExports.flatMap((entry) => entry.entries.flatMap((item) => (item.moduleRequest === null ? [] : [item.moduleRequest.value])))] + .filter((specifier) => RELATIVE.test(specifier)) +} + +const candidatesFor = (base: string): ReadonlyArray => + TYPESCRIPT_SOURCE.test(base) ? [base] : ['.ts', '.tsx', '.mts', '/index.ts'].map((extension) => base + extension) + +const resolveSource = (from: string, specifier: string) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem + const path = yield* Path.Path + const base = path.resolve(path.dirname(from), specifier.replace(/\.(m?js|jsx)$/, '')) + const isOtherFile = !TYPESCRIPT_SOURCE.test(base) && (yield* fileSystem.exists(base)) && (yield* fileSystem.stat(base)).type === 'File' + return isOtherFile ? Option.none() : yield* Effect.findFirst(candidatesFor(base), (candidate) => fileSystem.exists(candidate)) + }) + +const reachableSources = (root: string, entries: ReadonlyArray) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem + const path = yield* Path.Path + const seen = new Map() + const walk = (file: string): Effect.Effect => + seen.has(file) + ? Effect.void + : Effect.gen(function* () { + const text = yield* fileSystem.readFileString(file) + seen.set(file, text) + const next = yield* Effect.forEach(specifiersOf(file, text), (specifier) => resolveSource(file, specifier)) + yield* Effect.forEach(Arr.getSomes(next), walk, { discard: true }) + }) + yield* Effect.forEach(entries.map((entry) => path.resolve(root, entry)), walk, { discard: true }) + return new Map(Arr.sort([...seen], Order.mapInput(Order.String, ([file]: readonly [string, string]) => file))) + }) + +const discover = (subject: Subject) => + Effect.gen(function* () { + const manifest = yield* readManifest(subject.root) + const entries = entrySources(manifest) + const sources = yield* reachableSources(subject.root, entries) + return { manifest, entries, sources } + }) + +const declarationPath = (root: string, file: string, path: Path.Path): string => + path.relative(root, file).replace(/\.([mc]?)tsx?$/, (_, flavour: string) => `.d.${flavour}ts`) + +const emitOxc = (root: string, sources: ReadonlyMap, path: Path.Path): Emit => { + const results = [...sources].map(([file, text]) => [declarationPath(root, file, path), isolatedDeclarationSync(file, text, { stripInternal: false })] as const) + return { diagnostics: results.reduce((sum, [, result]) => sum + result.errors.length, 0), outputs: new Map(results.map(([file, result]) => [file, result.code])) } +} + +const emitTranspileDeclaration = (root: string, sources: ReadonlyMap, path: Path.Path): Emit => { + const results = [...sources].map(([file, text]) => + [declarationPath(root, file, path), ts59.transpileDeclaration(text, { fileName: file, compilerOptions: { isolatedDeclarations: true, declaration: true } })] as const + ) + return { diagnostics: results.reduce((sum, [, result]) => sum + (result.diagnostics?.length ?? 0), 0), outputs: new Map(results.map(([file, result]) => [file, result.outputText])) } +} + +const tsconfigOf = (root: string) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem + const path = yield* Path.Path + const build = path.join(root, 'tsconfig.build.json') + return (yield* fileSystem.exists(build)) ? build : path.join(root, 'tsconfig.json') + }) + +interface CompileRun { + readonly exitCode: number + readonly diagnostics: ReadonlyArray + readonly outDir: string +} + +const compile = (tsc: string, subject: Subject, extra: ReadonlyArray) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner + const outDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: 'front-end-profile-' }) + const tsconfig = yield* tsconfigOf(subject.root) + const derived = yield* FrontEnd.writeDerivedTsconfig({ tsconfigPath: tsconfig, packageRoot: subject.root, workspaceRoot: subject.workspaceRoot, outDir }) + const args = [tsc, '-p', derived, '--pretty', 'false', ...extra] + const handle = yield* spawner.spawn(ChildProcess.make(process.execPath, args, { cwd: subject.root })) + const [stdout] = yield* Effect.all([Stream.decodeText(handle.stdout).pipe(Stream.mkString), Stream.decodeText(handle.stderr).pipe(Stream.mkString)], { concurrency: 2 }) + const exitCode = yield* handle.exitCode + const run: CompileRun = { exitCode, diagnostics: stdout.split('\n').filter((line) => / error TS\d+/.test(line)), outDir } + return run + }) + +const readOutputs = (run: CompileRun, subject: Subject, sources: ReadonlyMap) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem + const path = yield* Path.Path + const pairs = yield* Effect.forEach([...sources.keys()], (file) => { + const relative = declarationPath(subject.root, file, path) + const emitted = path.join(run.outDir, path.relative(subject.workspaceRoot, subject.root), relative) + return Effect.map(Effect.option(fileSystem.readFileString(emitted)), (text) => [relative, text] as const) + }) + return new Map(pairs.flatMap(([file, text]) => (Option.isSome(text) ? [[file, text.value] as const] : []))) + }) + +const parseOxc = (outputs: ReadonlyMap): number => + [...outputs].reduce((count, [file, text]) => count + parseSync(file, text).program.body.length, 0) + +const parseTypeScript = (outputs: ReadonlyMap): number => + [...outputs].reduce((count, [file, text]) => count + ts59.createSourceFile(file, text, ts59.ScriptTarget.Latest, true).statements.length, 0) + +const isolatedErrorsIn = (run: CompileRun, subject: Subject, sources: ReadonlyMap, path: Path.Path): number => { + const files = [...sources.keys()].map((file) => path.relative(subject.root, file)) + return run.diagnostics.filter((line) => / error TS9\d{3}:/.test(line) && files.some((file) => line.startsWith(file))).length +} + +type Divergence = { readonly compared: number; readonly differing: number; readonly missing: number; readonly typescript59ExitCode: number } + +const divergenceOf = (left: ReadonlyMap, right: ReadonlyMap, typescript59ExitCode: number): Divergence => { + const shared = [...left].filter(([file]) => right.has(file)) + return { + compared: shared.length, + differing: shared.filter(([file, text]) => right.get(file) !== text).length, + missing: left.size - shared.length, + typescript59ExitCode, + } +} + +const PHASES = [ + 'discover', + 'emit:oxc-transform', + 'emit:transpileDeclaration', + 'emit:tsc7', + 'emit:tsc5.9.3', + 'parse:oxc-parser', + 'parse:createSourceFile', + 'build:model', + 'resolve:messages', +] as const +type Phase = (typeof PHASES)[number] + +interface Cleanliness { + readonly diagnostics: number + readonly clean: boolean +} + +interface PackageProfile { + readonly name: string + readonly version: string + readonly origin: Subject['origin'] + readonly entryPoints: number + readonly sourceFiles: number + readonly sourceBytes: number + readonly declarationFiles: number + readonly declarationStatements: number + readonly clean: Readonly> + readonly fallback: { readonly exitCode: number; readonly diagnostics: number } + readonly divergence: Divergence + readonly frontEnd: FrontEndOutcome + readonly phases: Readonly> +} + +interface FrontEndOutcome { + readonly route: 'FastPath' | 'Fallback' | 'refused' + readonly outcome: string + readonly items: number + readonly locals: number + readonly messages: number +} + +type Emitters = FrontEnd.IsolatedEmitterId | FrontEnd.CompilerEmitterId + +const routedDeclarations = (source: FrontEnd.PackageSource) => + Effect.gen(function* () { + const isolated = yield* FrontEnd.IsolatedEmitter + const compiler = yield* FrontEnd.CompilerEmitter + return yield* isolated.emit(source).pipe( + Effect.map((declarations) => ({ route: 'FastPath' as const, declarations })), + Effect.catchTag('NotIsolated', () => Effect.map(compiler.emit(source), (declarations) => ({ route: 'Fallback' as const, declarations }))), + ) + }) + +const itemCount = (model: ApiModel.ApiModel): number => model.entryPoints.reduce((sum, entry) => sum + entry.items.length, 0) +const localCount = (model: ApiModel.ApiModel): number => model.entryPoints.reduce((sum, entry) => sum + entry.locals.length, 0) + +const refusalOf = (error: { readonly _tag: string; readonly message: string }): string => `${error._tag}: ${error.message.split('\n')[0] ?? ''}` + +const notRun: Timing = { coldMs: 0, warmMedianMs: 0 } + +const frontEndProfile = (subject: Subject) => + Effect.gen(function* () { + const routed = yield* Effect.result(Effect.flatMap(FrontEnd.readPackage({ packageRoot: subject.root }), routedDeclarations)) + if (Result.isFailure(routed)) { + const outcome: FrontEndOutcome = { route: 'refused', outcome: refusalOf(routed.failure), items: 0, locals: 0, messages: 0 } + return { outcome, build: notRun, resolve: notRun } + } + const { route, declarations } = routed.success + const built = yield* measure(Effect.result(FrontEnd.buildModel(declarations))) + const resolved = yield* measure(Effect.result(FrontEnd.resolveMessages(declarations))) + const model = Result.getOrUndefined(built.result) + const outcome: FrontEndOutcome = { + route, + outcome: Result.isSuccess(built.result) ? 'model' : refusalOf(built.result.failure), + items: model === undefined ? 0 : itemCount(model), + locals: model === undefined ? 0 : localCount(model), + messages: Result.isSuccess(resolved.result) ? resolved.result.success.length : 0, + } + return { outcome, build: built.timing, resolve: resolved.timing } + }) + +const tscBin = (packageName: string, path: Path.Path): string => path.join(path.dirname(require.resolve(`${packageName}/package.json`)), 'bin', 'tsc') + +const profilePackage = (subject: Subject): Effect.Effect => + Effect.scoped(Effect.gen(function* () { + const path = yield* Path.Path + const discovery = yield* measure(discover(subject)) + const { manifest, entries, sources } = discovery.result + const oxc = yield* measure(Effect.sync(() => emitOxc(subject.root, sources, path))) + const transpile = yield* measure(Effect.sync(() => emitTranspileDeclaration(subject.root, sources, path))) + const tsc7 = yield* measure(compile(tscBin('typescript', path), subject, [])) + const isolatedReferee = yield* compile(tscBin('typescript', path), subject, ['--isolatedDeclarations']) + const tsc59 = yield* measure(compile(tscBin('typescript-5.9', path), subject, []), 0) + const outputs7 = yield* readOutputs(tsc7.result, subject, sources) + const outputs59 = yield* readOutputs(tsc59.result, subject, sources) + const parsedOxc = yield* measure(Effect.sync(() => parseOxc(outputs7))) + const parsedTypeScript = yield* measure(Effect.sync(() => parseTypeScript(outputs7))) + const refereeErrors = isolatedErrorsIn(isolatedReferee, subject, sources, path) + const frontEnd = yield* frontEndProfile(subject) + return { + name: manifest.name, + version: manifest.version, + origin: subject.origin, + entryPoints: entries.length, + sourceFiles: sources.size, + sourceBytes: [...sources.values()].reduce((sum, text) => sum + text.length, 0), + declarationFiles: outputs7.size, + declarationStatements: parsedOxc.result, + clean: { + 'oxc-transform': { diagnostics: oxc.result.diagnostics, clean: oxc.result.diagnostics === 0 }, + transpileDeclaration: { diagnostics: transpile.result.diagnostics, clean: transpile.result.diagnostics === 0 }, + 'tsc --isolatedDeclarations': { diagnostics: refereeErrors, clean: refereeErrors === 0 }, + }, + fallback: { exitCode: tsc7.result.exitCode, diagnostics: tsc7.result.diagnostics.length }, + divergence: divergenceOf(outputs7, outputs59, tsc59.result.exitCode), + frontEnd: frontEnd.outcome, + phases: { + discover: discovery.timing, + 'emit:oxc-transform': oxc.timing, + 'emit:transpileDeclaration': transpile.timing, + 'emit:tsc7': tsc7.timing, + 'emit:tsc5.9.3': tsc59.timing, + 'parse:oxc-parser': parsedOxc.timing, + 'parse:createSourceFile': parsedTypeScript.timing, + 'build:model': frontEnd.build, + 'resolve:messages': frontEnd.resolve, + }, + } + })) + +const gitLines = (root: string, args: ReadonlyArray) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner + const handle = yield* spawner.spawn(ChildProcess.make('git', args, { cwd: root })) + const [stdout, stderr] = yield* Effect.all( + [Stream.decodeText(handle.stdout).pipe(Stream.mkString), Stream.decodeText(handle.stderr).pipe(Stream.mkString)], + { concurrency: 2 }, + ) + const exitCode = yield* handle.exitCode + return yield* Option.match(Option.liftPredicate(exitCode, (code) => code === 0), { + onNone: () => Effect.fail(new GitError({ args, exitCode, stderr: stderr.trim() })), + onSome: () => Effect.succeed(stdout.split('\n').filter((line) => line !== '')), + }) + }) + +const corpusSubjects = (corpusRoot: string) => + Effect.gen(function* () { + const path = yield* Path.Path + const manifests = (yield* gitLines(corpusRoot, ['ls-files', 'packages/**/package.json'])) + .filter((file) => !file.includes('/fixtures/') && !file.includes('/__fixtures__/')) + .map((file) => path.join(corpusRoot, path.dirname(file))) + const eligible = yield* Effect.filter(manifests, (root) => + Effect.map(readManifest(root), (manifest) => manifest.private !== true && entrySources(manifest).length > 0) + ) + return Arr.sort(eligible, Order.String).map((root): Subject => ({ origin: 'systemfsoftware', root, workspaceRoot: corpusRoot })) + }) + +const gitHead = (root: string) => Effect.map(gitLines(root, ['rev-parse', 'HEAD']), (lines) => lines[0] ?? '') + +const versionOf = (specifier: string) => + Effect.gen(function* () { + const path = yield* Path.Path + const manifest = yield* readManifest(path.dirname(require.resolve(`${specifier}/package.json`))) + return manifest.version + }) + +interface Report { + readonly corpus: { readonly repository: string; readonly commit: string } + readonly ownPackage: { readonly repository: string; readonly commit: string } + readonly host: { readonly cpu: string; readonly cores: number; readonly platform: string; readonly node: string } + readonly tools: Readonly> + readonly runs: { readonly cold: number; readonly warm: number; readonly 'emit:tsc5.9.3': string } + readonly totals: Readonly> + readonly packages: ReadonlyArray +} + +const total = (packages: ReadonlyArray, phase: Phase, key: keyof Timing): number => + round(packages.reduce((sum, item) => sum + item.phases[phase][key], 0)) + +const markdownOf = (report: Report): string => { + const header = `| Package | Origin | Sources | oxc clean | tD clean | tsc ID clean | ${PHASES.join(' | ')} | TS7≠5.9.3 | no 5.9.3 emit | route | model |` + const rule = `|${'---|'.repeat(PHASES.length + 10)}` + const rows = report.packages.map((item) => + `| ${item.name} | ${item.origin} | ${item.sourceFiles} | ${item.clean['oxc-transform'].diagnostics} | ${item.clean.transpileDeclaration.diagnostics} | ${item.clean['tsc --isolatedDeclarations'].diagnostics} | ${PHASES.map((phase) => item.phases[phase].warmMedianMs).join(' | ')} | ${item.divergence.differing}/${item.divergence.compared} | ${item.divergence.missing} | ${item.frontEnd.route} | ${item.frontEnd.outcome} |` + ) + const totals = `| **total** | | ${report.packages.reduce((sum, item) => sum + item.sourceFiles, 0)} | | | | ${PHASES.map((phase) => report.totals[phase]).join(' | ')} | ${report.packages.reduce((sum, item) => sum + item.divergence.differing, 0)}/${report.packages.reduce((sum, item) => sum + item.divergence.compared, 0)} | ${report.packages.reduce((sum, item) => sum + item.divergence.missing, 0)} | | |` + return [ + '# Front-end phase profile', + '', + 'Generated by `apps/api-extractor-profile/profile.ts`; the JSON beside this file is the record. Times are warm medians in milliseconds;', + 'the clean columns count isolated-declaration diagnostics over the files the entry sources reach.', + '', + '```json', + JSON.stringify(Object.fromEntries(Object.entries(report).filter(([key]) => key !== 'packages')), null, 2), + '```', + '', + header, + rule, + ...rows, + totals, + '', + ].join('\n') +} + +const program = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem + const path = yield* Path.Path + const [corpusArgument, outputArgument] = process.argv.slice(2) + if (corpusArgument === undefined || outputArgument === undefined) { + return yield* new UsageError({ usage: 'profile.ts ' }) + } + const corpusRoot = path.resolve(corpusArgument) + const repositoryRoot = path.resolve(path.dirname(yield* path.fromFileUrl(new URL(import.meta.url))), '..', '..') + const own: Subject = { origin: 'api-extractor-effect', root: path.join(repositoryRoot, 'packages', 'api-extractor'), workspaceRoot: repositoryRoot } + const subjects = [own, ...(yield* corpusSubjects(corpusRoot))] + const packages = yield* Effect.forEach(subjects, (subject) => Effect.tap(profilePackage(subject), (item) => Effect.logInfo(`${item.name}: ${item.sourceFiles} sources`))) + const tools = yield* Effect.forEach(['oxc-transform', 'oxc-parser', 'typescript', 'typescript-5.9'], (name) => Effect.map(versionOf(name), (version) => [name, version] as const)) + const report: Report = { + corpus: { repository: 'https://github.com/systemfsoftware/systemfsoftware', commit: yield* gitHead(corpusRoot) }, + ownPackage: { repository: 'https://github.com/systemfsoftware/api-extractor-effect', commit: yield* gitHead(repositoryRoot) }, + host: { cpu: os.cpus()[0]?.model ?? 'not reported', cores: os.cpus().length, platform: `${process.platform}-${process.arch}`, node: process.version }, + tools: Object.fromEntries(tools), + runs: { cold: 1, warm: WARM_RUNS, 'emit:tsc5.9.3': 'cold only' }, + totals: Object.fromEntries(PHASES.map((phase) => [phase, total(packages, phase, 'warmMedianMs')])), + packages, + } + yield* fileSystem.writeFileString(`${outputArgument}.json`, `${JSON.stringify(report, null, 2)}\n`) + yield* fileSystem.writeFileString(`${outputArgument}.md`, markdownOf(report)) +}) + +const emitters = Layer.mergeAll(FrontEnd.isolatedEmitterLayer, FrontEnd.compilerEmitterLayer).pipe(Layer.provideMerge(NodeServices.layer)) + +NodeRuntime.runMain(program.pipe(Effect.scoped, Effect.provide(emitters))) diff --git a/apps/api-extractor-profile/tsconfig.json b/apps/api-extractor-profile/tsconfig.json new file mode 100644 index 0000000..a3f6a61 --- /dev/null +++ b/apps/api-extractor-profile/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "@systemfsoftware/tsconfig/tsc/no-dom/library", + "compilerOptions": { + "noEmit": true, + "declaration": false, + "sourceMap": false, + "allowImportingTsExtensions": true, + "rewriteRelativeImportExtensions": true, + "erasableSyntaxOnly": true, + "rootDir": ".", + "types": [ + "node" + ], + "customConditions": ["@systemfsoftware/source"], + "noUnusedLocals": true, + "noUnusedParameters": true + }, + "include": [ + "*.ts" + ] +} diff --git a/docs/plans/2026-10-14-0214-feat-front-ends-plan.md b/docs/plans/2026-10-14-0214-feat-front-ends-plan.md new file mode 100644 index 0000000..86fe708 --- /dev/null +++ b/docs/plans/2026-10-14-0214-feat-front-ends-plan.md @@ -0,0 +1,700 @@ +--- +title: API extractor front-ends - Plan +type: feat +date: 2026-10-13 +topic: front-ends +artifact_contract: ce-unified-plan/v1 +product_contract_source: ce-brainstorm +origin: docs/plans/2026-10-10-1146-feat-effect-api-extractor-plan.md +supersedes: docs/plans/2026-10-14-0157-feat-front-ends-plan.md +execution: code +--- + +# API extractor front-ends - Plan + +## Goal Capsule + +- **Objective:** a maintainer points the engine at a TypeScript package's source and gets its versioned API model and analysis messages, without a type-check when the package's exports are annotated and through the compiler otherwise, and both routes give the same model. +- **Means:** one emitter port with an isolated-declarations adapter and a TypeScript 7 `tsc` process adapter, meeting one syntactic builder and resolver at declaration text (KTD1, KTD3, KTD5, KTD6); the fast-path emitter follows the committed profile (KTD2). +- **Product authority:** the operator's U7 / PR3 contract of 2026-10-12, then `docs/plans/2026-10-10-1146-feat-effect-api-extractor-plan.md` (R15, R17, R18; KTD9, KTD11, KTD13; the model and verdict contract and the provenance rules R1, R2, R7), then `AGENTS.md`. The report renderer and differential lane (PR4), the CI benchmark (PR5) and the CLI, bump check and journey (PR6) are not active scope. +- **Open blockers:** none for planning. Two delivery decisions belong to the operator and are held until the PR (Outstanding Questions). +- **Stop conditions:** stop and report if the profile shows neither candidate agreeing with TypeScript 7 on which corpus packages are isolated-clean (KTD2), if real declaration text needs a model shape `schemaVersion` 2 cannot hold, or if a resolver divergence from upstream (R10) traces to reachability no type position in the declaration text spells (alias indirection, conditional or mapped types, generic instantiation): that is a checker-only message for the operator and origin KTD13's fallback routing, not a resolver bug to widen. +- **Execution profile:** branch `feat/front-ends` from `origin/main` `9bd73962`, one PR. The agent implements, reviews report-only, and opens the PR; the operator rules on findings and merges. + +--- + +## Product Contract + +### Summary + +The engine gains its two front-ends. An isolated-declarations fast path and a TypeScript 7 compiler fallback emit declaration text behind one emitter port. One syntactic builder turns that text into the existing versioned model, and one syntactic resolver derives api-extractor's analysis messages from it. A committed per-phase profile on the real corpus picks the fast-path emitter before any code depends on it. + +### Problem Frame + +PR1 and PR2 built the model, the diff and the verdict, but nothing produces a model from a real package: every model so far is hand-built or generated from the schema. The model schema was sized for the verdict and has never met real declaration text (origin U2). The origin plan's speed claim rests on a fast path that never type-checks, yet no isolatedDeclarations-clean package exists to take it, and the repository's own package fails `--isolatedDeclarations` with 52 errors today. TypeScript 7 ships no stable programmatic API, so the compiler can only be reached as a process until 7.1, and the choice between TypeScript's `transpileDeclaration` and `oxc-transform` for the fast path has no measurement behind it (origin R15). + +### Key Decisions + +- **Analysis messages travel beside the model in PR3, not inside it.** The origin's KTD10 moves messages into the model together with declaration spans and TSDoc in PR4, which bumps `schemaVersion` there; adding them now would bump it twice. Governs R3, R11. +- **The route is decided by the fast emitter's own evidence, not by a tsconfig flag.** A package is isolated-clean when its sources emit isolated declarations without a diagnostic; a configuration that claims cleanliness proves nothing (pack: boundary-testing, staged-protocol-evidence.md). Governs R1, R2. +- **The resolver covers the messages computed from the module graph and release tags.** The `{@inheritDoc}` and `{@link}` messages need TSDoc declaration-reference parsing, which arrives with the model's TSDoc in PR4. Governs R9. +- **A failing compile is refused, never modelled.** A model built from declarations the compiler flagged would carry `any` where the source meant something else, and a verdict over it would pass silently. Governs R5. + +### Requirements + +**Routing and emitters** + +- R1. A package whose entry sources and the sources they reach emit isolated declarations without a diagnostic takes the fast path, and no type checker runs for it (origin R17). +- R2. Every other package with a TypeScript source entry takes the compiler fallback, TypeScript 7 `tsc` run as a process, and the result names the isolated-declaration diagnostics that routed it there. +- R3. Both paths give the same model, `schemaVersion` 2, for the same package, built from the same declaration files (those the entry points reach); only `producer.declarations` differs, and it names the emitter and version that produced the declarations (origin R7, R17). +- R4. Both emitters implement one emitter port, so a TypeScript 7.1 API adapter can replace the process adapter with no model schema change (origin R18). +- R5. A fallback compile that reports diagnostics is refused with those diagnostics. +- R16. A package with no TypeScript source entry is refused as having no front-end input, before either emitter runs. + +**Model builder** + +- R6. One syntactic builder turns declaration text into the model on both paths and reads no type checker. +- R7. Declarations that differ only in printing build the same model: quote style, member separators, whitespace, and `import("x").Y` against an imported `Y`. +- R8. A type the model's structure does not decompose is kept as canonical text, so changing it still registers as a change. + +**Analysis messages** + +- R9. A syntactic resolver over the same declaration text reports `ae-forgotten-export`, `ae-missing-release-tag`, `ae-internal-missing-underscore`, `ae-incompatible-release-tags` and `ae-internal-mixed-release-tag` (origin KTD13). +- R10. On a real package, the resolver reports the same message ids and symbols for those five messages as upstream api-extractor 7.59.1 run on the same declarations. +- R11. A message locates itself by entry point and declaration name, never by file offset, so both paths report the same messages for the same package. + +**Profile** + +- R12. A per-phase profile on the origin's KTD11 corpus is committed before the fast-path emitter is chosen. It times source discovery, both fast-path candidates, the TypeScript 7 fallback and declaration parsing per package, and it records how often TypeScript 7 and TypeScript 5.9.3 print a package's declarations differently (origin R15, KTD9). +- R13. The profile is reproducible from a committed harness and states the corpus commit, the toolchain versions and the host it ran on. +- R14. The fast-path emitter is the one the committed profile supports, and the choice cites it. + +**Fast-path corpus** + +- R15. This repository's package is isolatedDeclarations-clean, and its build fails if it stops being clean. + +### Acceptance Examples + +- AE1. **Covers R1, R3.** **Given** this repository's package, **when** the engine analyses it, **then** it takes the fast path; **and when** the fallback is run on the same package, its model serialises to the same bytes once `producer.declarations` is set equal. +- AE2. **Covers R2.** **Given** a package that exports `const ids = [1, 2].map(String)` with no annotation, **when** the engine analyses it, **then** it takes the fallback and the routing reason names the TS9010 diagnostic for `ids`. +- AE3. **Covers R7.** **Given** one declaration file that writes `import("effect").Effect.Effect` and another that imports `Effect` from `effect` and writes `Effect.Effect`, **when** both are built, **then** both give the reference `Effect.Effect` from module `effect`. +- AE4. **Covers R9, R11.** **Given** an exported function whose return type names an interface the entry point does not export, **when** the resolver runs, **then** it reports `ae-forgotten-export` for that interface at the function's name, and a type imported from another package draws no message. +- AE5. **Covers R5.** **Given** a package whose fallback compile reports a type error, **when** the engine analyses it, **then** it refuses with the compiler's diagnostic and builds no model. + +### Scope Boundaries + +- The `.api.md` renderer, the differential lane against upstream and the adversarial corpus are PR4 (origin U8). +- The cold and warm benchmark in CI is PR5 (origin U9). This PR's profile runs locally and is committed as evidence. +- The CLI, the changeset-bump check and the consumer-flake journey are PR6 (origin U10). +- Declaration spans, TSDoc text and messages inside the model, and the `schemaVersion` bump they bring, are PR4 (origin KTD10). +- The `{@inheritDoc}` and `{@link}` messages are PR4. +- The oracle-line TypeScript 5.9.3 adapter that origin KTD9 adds where TypeScript 7 prints differently serves the report path, so it is PR4; this PR measures the divergence it depends on. +- No mutation run, local or otherwise; no copied systemfsoftware tooling or packs. + +### Outstanding Questions + +**Resolve Before Planning:** none. + +**Deferred to the operator (delivery, before the PR):** + +- The change intent's bump for `@systemfsoftware/api-extractor`. `.changeset/README.md` requires a non-`none` intent for any change to `packages/api-extractor` while the package sits at 0.0.0 beside the `v0.0.0` tag, and the first non-`none` bump is the operator's call (`docs/plans/2026-10-10-2113-fix-release-version-surface-plan.md`, Scope Boundaries). +- Whether this branch stacks on PR #5. The operator cut it from `origin/main`; `AGENTS.md` OP13b stacks a branch that shares files with an open PR by the same author, and PR #5 also changes `pnpm-lock.yaml`, `pnpm-workspace.yaml` and `README.md`. + +### Sources + +- Origin plan: `docs/plans/2026-10-10-1146-feat-effect-api-extractor-plan.md` (R7, R15, R17, R18; KTD9, KTD10, KTD11, KTD13; U7). +- Learnings: `docs/solutions/design-patterns/keyed-uniqueness-in-the-api-model-schema.md` (item names are labels that may repeat; entry-point paths and enum member names are identities), `docs/solutions/build-errors/turbo-stale-cache-with-source-condition-dependencies.md` (any new consumer of the package's source depends on `transit`). +- Corpus: systemfsoftware at `732f66a0c85819b2005f9e0930a595bc4de91ee1` (the flake's pin), 43 public packages, 37 with a TypeScript source entry. +- Packs declared in `.compound-engineering/config.yaml` (systemfsoftware `df908ddfe497e53c1c03b92519a96c913c441a58`): cell-architecture, boundary-testing, schema-laws, package-topology. + +--- + +## Planning Contract + +Product Contract preservation: Product Contract unchanged. + +### Key Technical Decisions + +- KTD1. **One emitter port, two service tags, adapters as layers.** `src/front-end/declaration-emitter.service.ts` holds the `DeclarationEmitter` shape (`emit(source) -> Declarations`) and two `Context.Service` tags, `IsolatedEmitter` and `CompilerEmitter`, with no driver imports (pack: cell-architecture, ports-separate-from-layers.md; pack: cell-architecture, service-and-layer-boundaries.md). Each adapter module exports a `layer` that takes no options; the compiler's emit policy is the exported `compilerEmitPolicy` record. No `*Live` identifier exists. A TypeScript 7.1 API adapter provides `CompilerEmitter` the same way; 7.0.2's `typescript/unstable/sync` API has no declaration-emit request, so the process adapter is the only 7.0 route. Governs R4. +- KTD2. **The fast-path emitter is chosen by a pre-registered rule over the committed profile.** Candidates: `oxc-transform` 0.150.0 `isolatedDeclarationSync` and TypeScript 5.9.3 `transpileDeclaration` (5.9.3 is already in the lockfile through `@microsoft/api-extractor`, and TypeScript 7 has no JavaScript emit API). The declaration parser follows the emitter's family: `oxc-parser` 0.150.0 or TypeScript 5.9.3 `createSourceFile`. Rule, in order: (1) the candidate's clean-or-not verdict matches TypeScript 7 `tsc --isolatedDeclarations` on every corpus package; (2) the lower corpus total of median warm emit plus parse time wins; (3) within 10%, the smaller runtime dependency wins. The choice and the numbers it rests on are recorded in a `Decision` section of `docs/profiles/2026-10-12-front-end-phases.md`, in a commit after the one that adds the profile. Governs R12, R14. +- KTD3. **The fallback runs the `tsc` of this package's own `typescript@7.0.2` dependency as a child process.** `typescript` moves from `devDependencies` to `dependencies` so the stamp `typescript@7.0.2` names what ran. The adapter runs `-p --noEmit false --declaration --emitDeclarationOnly --declarationMap false --sourceMap false --incremental false --composite false --tsBuildInfoFile null --rootDir --outDir --listEmittedFiles --pretty false` through effect's `ChildProcessSpawner`. Its `TSFILE:` lines and diagnostics are decoded with schemas (pack: cell-architecture, decode-never-cast.md). Any `error TS` line or non-zero exit is `CompilerRejected` with the diagnostics (R5). The workspace root is the nearest ancestor holding `pnpm-workspace.yaml`, else the package root, because family packages resolve sibling sources through `@systemfsoftware/source` and TypeScript 7 refuses files outside `rootDir`. The adapter keeps only the emitted files under the package root that the entry declarations reach through relative specifiers, the same closure the isolated adapter walks, so both `Declarations` hold the same file set (R3). Governs R2, R3, R4, R5. + - **Amended by U12.** `tsc`'s stdout is package-influenced text: a module specifier or file name containing a newline puts a well-formed `error TS…` or `file(l,c): error TS…` line on stdout (reproduced on 7.0.2, run `20261013-163200-2c130632` #3), so no line grammar can tell a real diagnostic from a forged one. The adapter therefore reads no stdout. It writes one derived config, `{ "extends": , "compilerOptions": }`, where the emit policy is one exported record (`noEmit: false`, `declaration`, `emitDeclarationOnly`, `declarationMap: false`, `sourceMap: false`, `stripInternal: false`, `isolatedDeclarations: false`, `removeComments: false`, `incremental: false`, `composite: false`, `tsBuildInfoFile: null`, `rootDir: `, `outDir` and `declarationDir: `), and runs `tsc -p `. Emitted declarations are the `.d.ts` files found under the scoped directory. A non-zero exit is `CompilerRejected`; its diagnostics come from TypeScript 7.0.2's `typescript/unstable/async` API opened on the same derived config, following `tsc`'s reporting order (config and syntactic; else program and global; else semantic; then declaration), errors only, de-duplicated. The API runs only on a refusal, so a clean package pays nothing for it. If the API reports no error for a failed run, the refusal carries one diagnostic without a code naming the exit status and stderr. `references` are not carried into the derived config: without `--build`, `tsc -p` uses them only to redirect imports to referenced projects' built output, and the model reads only this package's declarations. The profile harness imports the same emit policy (U12). Settings that resolve from the config file's directory (the default `typeRoots` chain, a `types` entry naming a package, the default `include`) would otherwise resolve from a temp directory. The derived config is therefore written into a scoped mirror of the package config's directory, each ancestor carrying a symlink to the real `node_modules` unless the ancestor is itself named `node_modules` (`tsc` never looks inside one), and a mirrored `node_modules` segment renamed so it cannot collide with that symlink; `tsc`'s upward `node_modules` walk then matches the package's own (U13), and `files` is the package config's resolved `fileNames`, read once with the API's `parseConfigFile` (a tsconfig with no `include` otherwise matches `**/*` in the temp directory, TS18003). Each was reproduced on 7.0.2 and has an equality case in the e2e journey. +- KTD4. **The route is a tagged union built from the isolated emitter's outcome.** `Route` is `FastPath` or `Fallback { diagnostics }`; `NotIsolated { diagnostics }` from `IsolatedEmitter` is the only thing that selects the fallback (pack: schema-laws, tagged-unions-over-state-by-presence.md; pack: boundary-testing, staged-protocol-evidence.md). A package without a TypeScript source entry never reaches routing: `readPackage` fails with `NoSourceEntry`, since neither route can produce a model for it (R16). Governs R1, R2, R16. +- KTD5. **`Declarations` is the meeting point of every front-end.** It holds the producer stamp, the declaration files as package-relative POSIX paths with their text in code-unit order, and each entry point's declaration file. Entry points come from `package.json` `exports`, reading each export's `@systemfsoftware/source` condition (the family's convention, an option of the package reader); `./package.json` and non-TypeScript targets are skipped. The tsconfig is `tsconfig.build.json` when present, else `tsconfig.json`. Governs R3, R6. +- KTD6. **The builder walks a syntactic module graph and maps type nodes onto the existing `TypeExpr` AST.** Relative specifiers resolve by swapping `.js`, `.ts`, `.mjs` or no extension for `.d.ts`, then `/index.d.ts`. A reference's `name` is the qualified name as written (`Effect.Effect`), and `module` is the bare specifier its leftmost identifier was imported from; `import("x").Y` becomes the same reference, and a relative `import("./y").Z` becomes a local one (R7). Keywords, literals, references, unions, intersections, arrays, plain tuples, function types, and object literals of properties, methods and call signatures decompose. Every other node becomes `text`, printed from the AST by one canonical printer, so formatting never reaches the model (R8). Items follow the model's kinds: `export * as ns` and `declare namespace` become namespaces, overloads group into one function, `export default` is an item named `default`. `locals` holds every type alias reachable from an export and not exported. Two distinct locals sharing a name take upstream's `_2`, `_3` suffixes in code-unit order of their file paths, and references follow the rename. `export =` and `declare global` are refused with `UnsupportedDeclaration`, naming the construct and file. Governs R6, R7, R8. +- KTD7. **The resolver reads release tags from the declaration's leading `/** */` comment and the module graph from KTD6.** A forgotten export is a declaration in the package's own files that an exported item's type positions, heritage clauses or `typeof` queries reach, and that the entry point does not export under any name or namespace. Type parameters, globals and other packages' types never count. A message is `{ messageId, entryPoint, item, symbol }` in code-unit order, with `item` the exported item's name path (R11). The five ids follow api-extractor 7.59.1's semantics, checked against upstream run in-process with `Extractor.invoke` (R10). Governs R9, R10, R11. +- KTD8. **The public surface is one `FrontEnd` namespace from `mod.ts`.** It exports `analyse`, `readPackage`, `buildModel`, `resolveMessages`, the two tags, the adapter layers, and the `Declarations`, `Analysis`, `AnalysisMessage`, `Route` schemas and error classes every signature reaches (pack: cell-architecture, single-namespace-barrel.md; pack: package-topology, enumerated-entry-modules.md; pack: package-topology, one-access-path.md; pack: package-topology, public-signature-types.md). `analyse` returns `{ route, model, messages }` and needs `IsolatedEmitter | CompilerEmitter` plus the platform services the adapters need. Governs R1-R5, R9. +- KTD9. **Cleanliness is enforced by the build.** `packages/api-extractor/tsconfig.build.json` sets `isolatedDeclarations: true`; tsdown's declaration build reads it, so the CI build lane fails when an export loses its annotation. TypeScript refuses an `extends` expression (TS9021) and a computed class member (TS9038) under `--isolatedDeclarations`. The schema classes `Verdict` and `CompareSurfaces` therefore become exported constants typed `Schema.Class<…>` beside an instance type of the same name, which also keeps `effect-schema-discovery` generating their codec laws (it recognises a const whose type names `Schema`, not a class extending a typed base). The error classes extend typed base constants, and the brand and instrumentation keys move onto the class value and prototype. The flag also moves tsdown's declaration generator (rolldown-plugin-dts) from `tsgo` to `oxc`, so `dist/mod.d.ts` changes in printing: quote style, named field types where the old file inlined them, `override` modifiers. No exported name changes, and the package is unpublished at 0.0.0. `attw` checks the built entry. Verdict output bytes do not change: `apps/api-extractor-e2e/host-digest.ts` keeps printing `e4478f03…6da2`. Governs R15. +- KTD10. **The profile harness is a private workspace app, run locally, its output committed under `docs/profiles/`.** `apps/api-extractor-profile` takes the corpus root and this repository's package; per package it times one cold and five warm runs of each phase and records the medians, the clean-or-not verdict of each candidate and of `tsc --isolatedDeclarations`, and the number of declaration files TypeScript 7 and TypeScript 5.9.3 print differently. TypeScript 5.9.3 is installed as the alias `typescript-5.9`. The first profile (discovery, emit, parse, divergence) is committed before KTD2's choice. A second run after the builder adds the build and resolve phases and each corpus package's build outcome (a model, or the refused construct), which is the real-text check behind the second stop condition. Governs R12, R13. +- KTD11. **Tests follow the test-layer gate.** In-process integration features in `packages/api-extractor/tests/` cover the fast path on this package, the builder's canonical forms and the resolver against upstream. The three scenarios that spawn `tsc` (AE1, AE2, AE5) live in `apps/api-extractor-e2e`, which then holds four of its four allowed journeys, so a later PR retires one before adding another. No unit test targets an internal helper and no emitter is faked (pack: boundary-testing, no-mocks-on-internal-glue.md; pack: boundary-testing, real-system-oracles.md; pack: boundary-testing, fake-and-real-store-laws.md). Governs R1-R3, R5, R7, R10. +- KTD12. **Turbo owns every build, cache and task-graph decision here, and this unit adds none (house law, skill turbo-query-optimization).** Evidence from `turbo query` on turbo 2.10.12, base `9bd73962`: + - `packageGraph`: three packages; `api-extractor-e2e` and `api-extractor-profile` each have one `development` edge to `@systemfsoftware/api-extractor`. No cycle warning appears on stderr. + - `boundaries`: length 0. + - `affectedTasks`: 14 tasks across all three packages, because the unit changes every package. + - Task edges: lint, typecheck and test each depend on `transit` (`turbo.json:9`, `turbo.json:15`, `turbo.json:18`), and `transit` depends on `^transit` (`turbo.json:11-12`). Appending one byte to a source file of `@systemfsoftware/api-extractor` changed the `turbo run --dry=json` hash of every lint, typecheck, test and transit task in all three packages, including `api-extractor-e2e#test`, and restoring the byte restored every hash. Turbo therefore already invalidates the new tests that read the package's source through the source condition. + - The profile is a package script (`apps/api-extractor-profile/package.json:13`) with no `turbo.json` task, because its output is wall-clock timing and is never cacheable. + - The unit's source, harness, tests and workflows contain no home-grown cache, hash, affected-only script or cache gate. + - CI runs `pnpm lint`, `pnpm typecheck` and `pnpm build`, which are `turbo run …` (`package.json:12-15`), through systemfsoftware `reusable-checks.yml@732f66a0` (`.github/workflows/ci.yml:48-50`). That workflow sets Turbo's remote cache only when `turbo-team` is given (`reusable-checks.yml:115-125`, `:147-154`). `ci.yml` sets none, so public CI holds no Turbo credentials. Its Turbo-hash-driven test skip (`dry-run`, `reusable-checks.yml:110-114`, `:189-197`) is unused. + + Carrying `.turbo` through GitHub's cache action, or enabling `dry-run`, is CI speed work that belongs to the CI benchmark unit (PR5), not to U7. + +### High-Level Technical Design + +Directional, not an implementation specification. + +```mermaid +flowchart TB + P[readPackage: package.json exports, tsconfig, workspace root] --> S[PackageSource] + S --> I[IsolatedEmitter] + I -->|Declarations| B + I -->|NotIsolated diagnostics| C[CompilerEmitter: tsc 7 process] + C -->|Declarations| B[buildModel: parse, module graph, items, locals] + C -->|CompilerRejected| X[refusal] + B --> M[ApiModel v2] + B --> R[resolveMessages: release tags, reachability] + R --> A[Analysis: route, model, messages] + M --> A +``` + +The route decision has two outcomes and one input: the isolated emitter's result. The 7.1 adapter replaces only the `CompilerEmitter` box. + +### Assumptions + +- The family's `@systemfsoftware/source` export condition names each entry's source module. A package without it is refused with `NoSourceEntry` (R16). +- `oxc-transform`'s and TypeScript's isolated-declaration diagnostics agree on which packages are clean; the profile checks this before KTD2 relies on it. +- An isolated-clean package's TypeScript 7 declarations differ from its isolated declarations only in printing, which KTD6 normalises. The real equality journey (AE1) is the check. + +### Assumptions challenged (destructive review, Edge-First lens) + +First cycle on this artifact; Edge-First was chosen because the plan's happy path is one well-formed package and the failure modes sit at its edges. + +1. *An isolated emitter that reports no diagnostic proves the package clean.* Two emitters may disagree (Assumptions). The profile records each candidate's verdict beside `tsc --isolatedDeclarations` (KTD10), and KTD2's first rule rejects a candidate that disagrees on any corpus package. +2. *Every real declaration file fits the model.* `export =`, `declare global`, colliding local names and default exports all occur in published packages. KTD6 names the handling of each instead of letting the builder drop one silently. +3. *`tsc` output mirrors the package's source tree.* Family packages resolve sibling sources, which TypeScript 7 refuses outside `rootDir` (probe: TS5011 on `packages/effect-cell-types` without `--rootDir`). KTD3 roots the compile at the workspace and maps emitted files back by relative path. + +Radical alternative considered: skip declaration emit on the fast path and build the model straight from the annotated source, which already contains every type the declaration would. Rejected: two builder inputs break origin approach B's single meeting point and double the surface R3's equality must hold over; the profile shows whether emit cost matters at all. + +### Sequencing + +U1 → U2 (profile committed) → U3 (choice recorded) → U4 → U5 → U6 → U7 → U8 → U9 → U10 → U11 → U12. U4's builder can start on TypeScript 7 output before U3 lands only once KTD2's parser is known, so U3 precedes it. + +--- + +## Implementation Units + +### U1. Make this package isolatedDeclarations-clean + +- **Goal:** the package emits isolated declarations without a diagnostic, enforced by its build. +- **Requirements:** R15; KTD9. +- **Files:** `packages/api-extractor/tsconfig.build.json`, `packages/api-extractor/src/**/*.ts` (annotations), `packages/api-extractor/src/verdict.schema.ts`, `packages/api-extractor/src/verdict.cell.ts`, `packages/api-extractor/src/judge-surfaces.workflow.ts`. +- **Approach:** annotate every exported `const` (schemas with their `Schema.*` types, the workflow and the cell with `effect-cell-types`' made types), give each tagged class an explicitly typed base. No behaviour change. +- **Execution note:** refactor only; the host digest is the byte check. +- **Test expectation:** none - annotations, proven by `tsc -p tsconfig.build.json` and the unchanged suite. +- **Verification:** `tsc -p packages/api-extractor/tsconfig.build.json --noEmit` reports no TS9xxx; `pnpm build` passes; `pnpm test` passes; host digest `e4478f03…6da2`. + +### U2. Profile harness and the first phase profile + +- **Goal:** a reproducible per-phase profile on the real corpus, committed before the emitter choice. +- **Requirements:** R12, R13; KTD10. +- **Files:** `apps/api-extractor-profile/{package.json,tsconfig.json,oxlint.config.ts,profile.ts}`, `pnpm-workspace.yaml` (none expected beyond the workspace glob), `pnpm-lock.yaml`, `docs/profiles/2026-10-12-front-end-phases.json`, `docs/profiles/2026-10-12-front-end-phases.md`. +- **Approach:** corpus = systemfsoftware `732f66a0c8` checked out and installed with `pnpm install --frozen-lockfile --ignore-scripts`, plus `packages/api-extractor`. Phases per package: discovery (manifest and source graph), `oxc-transform` emit, `transpileDeclaration` emit, `tsc` 7 emit, `tsc` 5.9.3 emit (divergence only), `oxc-parser` parse, `createSourceFile` parse, and `tsc --isolatedDeclarations` diagnostics as the cleanliness referee. The JSON records corpus commit, versions, host CPU, Node version and per-phase medians; the markdown tables it. +- **Test expectation:** none - an evidence tool whose output is the committed profile. +- **Verification:** the harness runs end to end on the corpus; the JSON names commit `732f66a0c8` and both packages sets. + +### U3. Emitter port, isolated adapter and the recorded choice + +- **Goal:** the port and the chosen fast-path adapter exist, with the choice citing the profile. +- **Requirements:** R1, R4, R14; KTD1, KTD2, KTD4, KTD5. +- **Files:** `packages/api-extractor/src/front-end/declaration-emitter.service.ts`, `packages/api-extractor/src/front-end/declarations.schema.ts`, `packages/api-extractor/src/front-end/package-source.schema.ts` (reader), the chosen adapter module, `packages/api-extractor/package.json`, this plan's KTD2. +- **Approach:** the adapter emits each entry source, parses the emitted imports, and follows relative specifiers until the reachable set is closed; any diagnostic yields `NotIsolated`. +- **Test scenarios:** covered through U8's fast-path feature (this package emits with no diagnostic; route `FastPath`). +- **Verification:** U8. + +### U4. Syntactic model builder + +- **Goal:** `buildModel(declarations)` gives a schema-valid, sorted `ApiModel` v2. +- **Requirements:** R3, R6, R7, R8; KTD6. +- **Files:** `packages/api-extractor/src/front-end/declaration-graph.ts`, `packages/api-extractor/src/front-end/type-expr-of.ts`, `packages/api-extractor/src/front-end/canonical-type-text.ts`, `packages/api-extractor/src/front-end/build-model.ts`. +- **Approach:** KTD6; the model passes through `sortModel` and is stamped with `ENGINE` and the declarations' producer. +- **Test scenarios:** (`tests/front-end-model.integration.test.ts`) AE3 rows: `import("effect").Effect.Effect` and an imported `Effect.Effect` give one reference; a mapped type printed by `oxc` and by TypeScript gives one `text`; `export * as ns` gives a namespace item; two overloads give one function with two signatures; an unexported alias reached from an export lands in `locals`; `export =` is refused naming the file. +- **Verification:** the feature passes; this package's fast-path model decodes with `ApiModel.decode`. + +### U5. Syntactic resolver + +- **Goal:** `resolveMessages(declarations)` gives the five messages in code-unit order. +- **Requirements:** R9, R10, R11; KTD7. +- **Files:** `packages/api-extractor/src/front-end/analysis-message.schema.ts`, `packages/api-extractor/src/front-end/resolve-messages.ts`, `packages/api-extractor/tests/__fixtures__/upstream-messages.ts`, `packages/api-extractor/tests/analysis-messages.integration.test.ts`. +- **Test scenarios:** this package's fast-path declarations: the resolver's `(messageId, symbol)` set equals upstream 7.59.1's for the five ids. One declaration fixture per id, each checked against upstream rather than a hand-written answer: an unexported interface in a return type (AE4), an export without a release tag, an `@internal` export without `_`, a `@public` export whose type names a `@beta` one, and a merged declaration with two tags. A type from `effect` draws no message. +- **Verification:** the feature passes. + +### U6. TypeScript 7 compiler adapter + +- **Goal:** `CompilerEmitter` emits declarations by running `tsc` 7 and refuses on diagnostics. +- **Requirements:** R2, R4, R5; KTD3. +- **Files:** `packages/api-extractor/src/front-end/typescript-7-compiler.ts`, `packages/api-extractor/package.json` (`typescript` to `dependencies`). +- **Test scenarios:** U8's e2e journeys. +- **Verification:** U8. + +### U7. Router and public surface + +- **Goal:** `FrontEnd.analyse` routes per KTD4 and returns `{ route, model, messages }`. +- **Requirements:** R1, R2, R3; KTD4, KTD8. +- **Files:** `packages/api-extractor/src/front-end/analyse.ts`, `packages/api-extractor/src/FrontEnd.ts`, `packages/api-extractor/src/mod.ts`. +- **Test scenarios:** U8. +- **Verification:** `pnpm build` and `attw`: the `.mjs` and `.d.ts` entries agree on the new namespace (pack: package-topology, condition-branch-agreement.md). + +### U8. Front-end features + +- **Goal:** the Product Contract's acceptance examples run on real inputs. +- **Requirements:** R1, R2, R3, R5, R7, R10, R16; KTD11. +- **Files:** `packages/api-extractor/tests/front-end-model.integration.test.ts` (U4 rows plus: this package analysed in-process takes `FastPath` and its model decodes as `schemaVersion` 2; a package whose exports name no TypeScript source is refused with `NoSourceEntry`), `apps/api-extractor-e2e/tests/front-ends.integration.test.ts`, `apps/api-extractor-e2e/fixtures/inferred-export/`, `apps/api-extractor-e2e/fixtures/type-error/`, `apps/api-extractor-e2e/package.json`. +- **Test scenarios:** (e2e) AE1: this package through `IsolatedEmitter` and through `CompilerEmitter` serialises to the same bytes once `producer.declarations` is set equal, and both give the same messages; AE2: `fixtures/inferred-export` routes to `Fallback` naming TS9010 and builds a model; AE5: `fixtures/type-error` is refused with the compiler's diagnostic. +- **Verification:** `pnpm turbo run test --filter=@systemfsoftware/api-extractor --filter=api-extractor-e2e` passes. + +### U9. Second profile and documentation + +- **Goal:** the committed profile covers every phase, and the README says how front-ends route. +- **Requirements:** R12, R13. +- **Files:** `apps/api-extractor-profile/profile.ts`, `docs/profiles/2026-10-12-front-end-phases.{json,md}`, `README.md`. +- **Approach:** add `build` and `resolve` phases that call `FrontEnd.buildModel` and `FrontEnd.resolveMessages`, and the fallback model count; README gains the front-end section and the profile reproduction steps. +- **Test expectation:** none - evidence and documentation. +- **Verification:** the harness runs on the corpus with every phase present. + +### U10. Review dispositions + +- **Goal:** the operator-accepted findings of the full-path `ce-code-review` (run `20261012-174501-836c437c`, head `fd4ad93`) are fixed, each with a test that fails before the fix and passes after; rejected findings stay rejected. +- **Requirements:** R1, R3, R5, R6, R16; KTD9, KTD12. +- **Files:** `.changeset/*.md`, `packages/api-extractor/src/front-end/{compiler-output.schema,oxc-isolated-emitter,typescript-7-compiler,build-model}.ts`, `packages/api-extractor/tests/front-end-{routing,model}.integration.test.ts`, `apps/api-extractor-e2e/tests/front-ends.integration.test.ts`, `README.md`. +- **Approach:** + - #1: a `minor` change intent for `@systemfsoftware/api-extractor`. The `FrontEnd` namespace is new public surface (pack: package-topology, surface-changes-are-versioned.md). + - #2: `Position` is capped below 1e9 and digit text has one spelling, with no zero padding. Each refinement has a refusal law in its own file (pack: schema-laws, refusals-beside-generated-laws.md). The four committed `Refuted` seeds stay as regression coverage. U11 supersedes the refusal laws and the seeds; the refinements stay. + - #5: the isolated emitter declines with `NotIsolated`, naming the declaration file, when an entry point's source cannot be read. + - #6: the fallback passes `--stripInternal false`, so both routes keep `@internal` declarations. + - #9: an entry point's declarations load from its recorded source path before any sibling candidate. + - Requirements-check advisory: an enum member with a computed declared value is refused with `UnsupportedDeclaration` naming the member. Both emitters print constant members as literals and omit non-constant initializers, so only declaration text written by hand carries one. + - README: the builder's refusals (external re-export, `declare global`, module augmentation, `export =`, computed enum value) are documented as limitations. + - Rejected: #4. tsc 7.0.2 imports a non-exported interface from an import-bearing `.d.ts` (exit 0), and adding `export {}` makes the same import TS2459. The validator rejected #3, #7, #8 and #10-#16. +- **Test expectation:** the routing feature gains a missing-entry scenario and a `.tsx`-beside-`.ts` scenario; the model feature gains a computed-enum refusal; the e2e equality journey gains an `@internal` package without adding a journey (KTD11). Each fails at `fd4ad93` and passes after its fix. Each refusal law fails when its refinement is widened. +- **Verification:** the Verification Contract gates, plus a delta review of the fixes by a named non-Claude cross-model peer (DeepSeek or MiniMax only). + +### U11. Ruling #2's refusal properties under the refusal-properties rule + +- **Goal:** each in-source refusal property ruling #2 added is kept only if no consumer can observe its refusal (pack: schema-laws, refusal-properties-cover-unobservable-boundaries.md, systemfsoftware #761). `main` retired the generated schema laws in #9. +- **Requirements:** R3; KTD11. +- **Files:** `packages/api-extractor/src/front-end/compiler-output.schema.ts`, `packages/api-extractor/src/__property_seeds__/schema-laws.test.ts.jsonl`, `apps/api-extractor-e2e/tests/front-ends.integration.test.ts`. +- **Approach:** + - The digit-spelling refusal (`∀s_SourcelessDiagnosticLineDigitsRefusal_≡CanonicalSpelling`) is observable. A package's own text reaches `tsc`'s stdout: TypeScript 7.0.2 prints an unresolved import specifier raw, so `import 'x\nerror TS007: m'` puts the line `error TS007: m'.` on stdout, and `FrontEnd.analyse` reports through `CompilerRejected` whether that line became a diagnostic. The property goes. The e2e refusal journey proves the refusal there instead, without adding a journey (KTD11). + - The `Position` cap refusal (`∀n_SourcelessDiagnosticFieldsPositionRefusal_≡PrintableByTsc`) is not observable, and no oracle justifies keeping it. Decoding never meets the cap, because `Digits` (`compiler-output.schema.ts:10,26`) admits at most nine digits first. The only other way in is `Schema.is` on the struct itself, which the rule says is not a consumer. Its predicate `printableByTsc` (non-negative safe integer printing in at most nine digits) restates the refinement `[0, 1e9)` rather than coming from an oracle the schema did not produce, and its can-fail evidence was a hand-widened refinement, which REPO-D3 now forbids. The property goes; the cap stays (ruling #2). + - The four `Refuted` seeds belong to generated laws that #9 deleted with `src/schema-laws.test.ts`, the file the seed store is keyed to. Nothing reads them, so the store goes. +- **Test expectation:** the e2e refusal journey gains a package whose import specifiers make `tsc` print `error TS007:` and `error TS1000000000:` lines, and asserts that only `tsc`'s two TS2882 diagnostics come back. It fails at `fd4ad93`, where `Digits` admitted zero padding and returned a forged code 7. Admission (skill test-layer-selection): the refusal is observable only where real `tsc` stdout reaches the public `CompilerEmitter`, a process spawn the in-process layer forbids, so it belongs to the e2e failure-contract journey; it extends that journey rather than adding a fifth (KTD11). +- **Residual risk:** the scenario relies on TypeScript 7.0.2 printing an unresolved specifier raw. A `tsc` that escapes it would print no forged line, and the scenario would pass without exercising the refusal. A `typescript` bump re-checks that the forged lines still appear on stdout. +- **Verification:** the Verification Contract gates. + +--- + + +### U12. Rulings on every open finding (review of `2c13063` and earlier rounds) + +- **Goal:** every finding from the full-path reviews at `fd4ad93`, `a2fe1eb`, `2b2e595` and `2c13063`, the delta passes, the anchor-50 suppressions and the pack-rule rows has a ruling: fixed by a named commit with a test that fails before the fix and passes after, rejected with a reason, or converted to a follow-up with an owner and a trigger. Nothing stays open for the root. +- **Requirements:** R1-R7, R10, R16; KTD3 (amended), KTD5, KTD6, KTD7, KTD11. +- **Rulings, fixes:** + - C1 (`2c13063` #1, P1): a local alias key is a prefix of another (`a.d.ts\0Opt` inside `a.d.ts\0Options`), so `replaceNames` corrupts text types. Local keys gain a start and an end delimiter, so one key can never occur inside another. Builder scenario with a prefix pair. + - C3 (#3, P2): forged diagnostics. KTD3 amendment above; `compiler-output.schema.ts` is deleted. e2e refusal journey: sourceless and filed forged lines stay inside the TS2882 messages. + - C4 (#4) and B7: a synthetic diagnostic has no TypeScript code. `Diagnostic.code` becomes optional and, when present, a positive integer; messages print `TS` only for a real code. + - A10: an import whose specifier names no declaration file is no longer dropped. The fast path declines with `NotIsolated` naming the importing file and the specifier, so `tsc` decides; a compiler-emitted declaration whose import names no declaration is refused with `UnsupportedDeclaration`. + - A11: `.cts` entries are read (`.ts`, `.tsx`, `.mts`, `.cts`). + - A12: a subpath pattern export (`"./*"`) is expanded against the package's files under Node's rules: the `*` matches any substring, explicit subpaths win, and a longer pattern prefix wins. + - D1: an entry's recorded source is authoritative on both routes. The fast path loads an entry declaration only from its recorded source and declines with `NotIsolated` when that file is missing or resolves outside the package (ruling #5 keeps that decline); the compiler adapter refuses the same entry with `CompilerRejected` before `tsc` runs. Neither route models a sibling `.ts` in place of a deleted `.tsx`. The reader does not refuse it, because a refusal there would bypass ruling #5's fast-path decline. + - D2: two entry points whose sources share a declaration path are declined by the fast path and refused by the compiler adapter before `tsc` runs. `tsc` 7.0.2 does not refuse them: it exits 0 and writes one declaration file for both. + - B5: a named optional tuple member keeps its `?` in canonical text. + - C5 and A16: one property-key resolver, shared by the builder and the printer; the private-name case is an explicit decision of each caller. + - C7 and A17: the entry-point mapping lives once in the declaration graph; each emitter keeps only its own refusal. + - C9: the keyword vocabulary is one ordered list. + - C10: `TYPESCRIPT_PRODUCER` is exported from `FrontEnd` beside `OXC_TRANSFORM_PRODUCER`. + - C11: the README and the changeset say both routes give the same model apart from the producer stamp. + - B2 and A6: the profile harness imports the adapter's emit policy instead of copying it, and re-records nothing (its numbers were measured before the choice and stay the record). + - A15 (also the `2c13063` anchor-50 reliability note): the profile harness fails on a failed `git` instead of reading an empty corpus. + - S3 (`2c13063` anchor-50 security note): the fast path compares real paths, so a symlink inside the package that points outside it is not read. + - S1 (`2c13063` anchor-50 adversarial note): a type parameter's name only shadows within its own declaration, so it no longer hides a forgotten export of the same name elsewhere in the file. +- **Rulings, tests for gaps:** + - C6, A13 and B10: the e2e refusal journey covers a non-zero `tsc` exit (a package with no tsconfig), an entry excluded from the tsconfig (`the compiler emitted no declaration`), and the escaping entry through `CompilerEmitter`. + - C8 and A9: builder scenarios for `declare global`, `declare module "x"` and an external re-export refusal. + - A8: a builder scenario asserting an interface item's and a class item's full shape. + - B9: an oracle scenario asserting the full message array, order and de-duplication included. + - A18: the upstream comparison also compares each forgotten export's entry point; upstream exposes no item, so `item` is asserted by the hand-written scenarios (corrected in U14). +- **Rulings, rejected:** + - C2: re-raises ruling #4 (rejected): `tsc` 7.0.2 imports a non-exported interface from an import-bearing `.d.ts`, and adding `export {}` makes the same import TS2459. + - A7 (six extractors repeating one discriminator): the `2b2e595` validator rejected the same claim at four sites. Each extractor is behaviourally correct and the generic replacement adds a type-level dispatch without removing a defect. + - The `2b2e595` anchor-50 notes: compiler fallback without a deadline (ruling #13, rejected); `Declarations` without a version discriminator (an in-process port value, never serialised; the model carries `schemaVersion`). The setter `: any` note is checked by a builder scenario and fixed if it reproduces. + - Rejected rows of earlier rounds keep their validator reasons. +- **Rulings, pack rows:** + - schema-laws/arbitrary-filter-floors (`MessageText` lookahead) and schema-laws/tagged-unions-over-state-by-presence (`UNPARSED_FILED_LINE` sentinel): fixed by deleting `compiler-output.schema.ts` (C3). + - schema-laws/invariants-as-refinements (`Diagnostic.code` as bare `Schema.Finite`): fixed by C4. + - schema-laws/refusals-beside-generated-laws: not violated. The generated laws are retired on `main` (#9), the rule's refusal-law clause is superseded by #761, and after C3 no refined codec remains in the diff. + - ci-wall-clock/flake-outputs-build-once-on-main: pre-existing (`ci.yml` is byte-identical to base) and outside this diff. Follow-up owned by the CI benchmark unit (PR5, origin U9), triggered when PR5 changes the `sfs-deps` job. + - D3: KTD3's argument list now lives in the emit policy above, which includes `stripInternal: false`. +- **Rulings, delta review** (DeepSeek, `openrouter/deepseek/deepseek-v4.1-flash`, over `2c13063..8efc442`): + - P2, D1 contract text: accepted. This plan corrects D1 and D2 to what the adapters do; the earlier text named a reader refusal (`MissingSource`) that would have reversed ruling #5. + - P3, the exit cause is dropped when the API also reports errors: rejected. Every reported diagnostic is a real `tsc` error and the package is refused either way; carrying `tsc`'s printed output in every refusal would put package-influenced text into all of them. A write failure the API cannot see still surfaces alone, through the exit diagnostic. +- **Preserved:** the R2 property-by-property decisions of U11 stand as recorded at `2c13063`; C3 then deletes the codecs they were about. The PR1 counterexample (CI run 38372869767, `∀x_TypeExprNesting_≤MaxDepth1`) is recorded and not re-run. +- **Test expectation:** each fix above names its scenario; each scenario fails at `2c13063` and passes after its fix. e2e cases extend the existing refusal and equality journeys (KTD11 keeps four journeys). +- **Verification:** the Verification Contract gates, a named DeepSeek delta review over the fix commits, and a fresh full-path receipt at the final head. + +### U13. Rulings on the full-path review at `e5ec09d` + +- **Goal:** every finding of the full-path `ce-code-review` at `e5ec09d` (run `20261013-183505-e5ec09d2`: ten reviewer roles, the learnings and ci-wall-clock checks, and the DeepSeek peer `openrouter/deepseek/deepseek-v4.1-flash`) has a ruling, and each fix has a test that fails before it and passes after. +- **Requirements:** R1, R3, R5, R12, R16; KTD3, KTD5, KTD7. +- **Rulings, fixes:** + - #4 (P2, DeepSeek peer): the derived config extends the package's, so a package with `isolatedDeclarations: true` and an unannotated export was declined by the fast path and refused by `tsc` with the same TS9010. The emit policy pins `isolatedDeclarations: false` (`c4202d9`). The e2e TS9010 routing scenario gains the same package with the flag set; it fails at `e5ec09d` with `CompilerRejected`. + - #1 (P2, api-contract): `Diagnostic.file` is optional, like `code`, and present only for a file inside the package; the `''` sentinel is gone (`c4202d9`). The e2e TS5033 refusal asserts no `file`; it fails at `e5ec09d`, which returned `file: ''`. + - #2 (P2, correctness): `null` (excluded subpath) and array (fallback list) export targets are admitted and skipped instead of failing the manifest decode (`c093a03`). Reader scenario; at `e5ec09d` the read fails with `SchemaError`. + - #8 (P2, promoted from the correctness reviewer's anchor-50 note and the adversarial reviewer's residual, reproduced by the orchestrator): a pattern export's expansion passes the same TypeScript-source filter as an explicit entry and drops a match with an empty, `.`, `..` or `node_modules` segment, as Node's pattern resolution does (`c093a03`). Reader scenario; at `e5ec09d` it reads `./node_modules/dep/index`, `./data.json` and the directory `./sub`. + - #5 (P3, maintainability) and #6 (P3, DeepSeek peer): the profile harness maps `.cts` to `.d.cts`, skips `null` and array targets, drains `tsc`'s stderr, and counts a file missing from the 5.9.3 output apart from a printing difference (`432b608`). The rerun, `docs/profiles/2026-10-13-front-end-phases.{json,md}`, records 126 of 585 shared files printed differently and none missing, so the earlier records' figures were printing differences. + - Regression found by that rerun: at `c4202d9` four corpus packages naming `types: ["vitest/globals"]` were refused with TS2688. U12 C3 had moved the derived config into a bare temp directory, outside the package's `node_modules` ancestry. The derived config is now written into a mirror of that ancestry and the explicit `typeRoots` chain is gone (KTD3, `432b608`). The equality journey gains a package naming `types: ["greeting/globals"]` from its own `node_modules` through an `exports` subpath; it fails at `c4202d9` with TS2688. + - Anchor-50 note promoted by reproduction: a mapped-type key or an `infer` variable reported a phantom `ae-forgotten-export` for a same-named local declaration. The resolver now scopes both binders (`d77e2dd`). The oracle feature gains a scenario that matches upstream's notes; it fails at `432b608` with phantom `K` and `U` notes. +- **Rulings, rejected:** + - #3 (P2, testing): a pattern that matches no file yields no entry. Node exposes nothing for it and KTD5 promises one entry per matching source, so no valid package loses an entry (validator). + - #7 (P3, adversarial): `readonly (string[])` is TS1354 in TypeScript, so no valid package reaches the text fallback for a parenthesised `readonly` operand (validator). +- **Rulings, delta review** (DeepSeek, over `e5ec09d..5831e97`): + - P2: a package whose tsconfig lies under a `node_modules` directory failed on the fallback with `AlreadyExists`, because the mirror created that ancestor's `node_modules` as a real directory before its symlink. Fixed in `18c81e0`: a mirrored `node_modules` segment gets its own name and an ancestor named `node_modules` gets no symlink. The equality journey gains a package installed under `node_modules` that names a global type from a sibling dependency; it fails at `5831e97` with `AlreadyExists`. + - P3: the README rule for #1 covered every diagnostic, but an entry-point refusal names the entry's recorded source, which may lie outside the package (`../shared/mod.ts`). The README now limits the rule to diagnostics the compiler reported (`18c81e0`); the code was already right. + - Residuals recorded, no change: a top-level `exports` array is refused by the manifest decode; the mirror's walk also visits the temp directory's own ancestors (`/tmp/node_modules`, `/node_modules`), which a package's walk would not; with `preserveSymlinks: true`, files reached through the mirror keep mirror paths, so their diagnostics carry no `file`; a directory whose name ends in `.ts` passes the lexical pattern filter. +- **Follow-up:** the anchor-50 note that `Diagnostic.code`'s positive-integer refinement has no refusal property. Under the refusal-properties rule (systemfsoftware #761), a refusal is proven where a consumer observes it, and nothing in this repository decodes a `FrontEnd.Diagnostic`. Owner: the report renderer unit (PR4). Trigger: the first decode of `FrontEnd.Diagnostic` in this repository, whose test proves the refusal through it. +- **Preserved:** U12's rulings and the PR1 counterexample record stand. +- **Test expectation:** each fix above names its scenario and the commit it fails at. The e2e cases extend the existing routing, refusal and equality journeys (KTD11 keeps four journeys). +- **Verification:** the Verification Contract gates, a named DeepSeek delta review over `e5ec09d..` the final head, and a fresh full-path receipt at the final head. + +### U14. Rulings on the full-path review at `a98496e`, then `main` at `942ac81` + +- **Goal:** every finding of the full-path `ce-code-review` at `a98496e` (run `20261013-202026-a98496e3`: ten reviewer roles, the learnings and ci-wall-clock checks, the DeepSeek peer `openrouter/deepseek/deepseek-v4.1-flash`), its anchor-50 suppressions, the orchestrator's export-set evidence and both open pack rows has a ruling: fixed with a public-boundary test that fails before the fix, rejected with a reason, or a follow-up with an owner and a trigger. `main` at `942ac814e7` is then merged plainly, and the review evidence is taken again at the merged head. +- **Requirements:** R3, R5, R6, R9, R10, R16; KTD5, KTD6, KTD7, KTD8. +- **The 13:15 decision, already applied in U12:** `2c13063` #1 alias rename (C1, `5a862ac`); #3 forged line (C3, `7520eae`); #4 code-0 rendering (C4, `25d0ef9`); earlier #10 unreadable import (A10, `3e81954`); #11 `.cts` entry and #12 pattern subpath (A11, A12, `fa1f695`); #2 rejected as a re-raise of ruling #4 (C2). Its two pack rows, `arbitrary-filter-floors` and `tagged-unions-over-state-by-presence` in `compiler-output.schema.ts`, were fixed by deleting that file (`7520eae`). +- **Rulings, fixes:** + - E1 (#1, P1, correctness): an anonymous `export default function` or `export default class` is declared under `default`, the name its importer uses. Builder scenario. + - E2 (orchestrator, beside #1): the implicit exports of a module declaration file follow `tsc` 7.0.2. A file exports its non-exported declarations only when it has no export declaration (`export {…}`, `export {…} from`, `export * from`) and no export assignment (`export default `). The local name of `export default class X`, `function X` or `interface X` is never exported (TS2614). Ruling #4 stands: implicit exports exist, and `export {}` stops them. Builder scenarios. + - E3 (#8, P2, DeepSeek peer, promoted by reproduction against upstream): release tags are read with `@microsoft/tsdoc`, the parser upstream uses, so a tag word inside a code span or fence is not a tag. Oracle scenario against upstream 7.59.1. + - E4 (#2, P2, testing): a builder scenario builds an interface with an index, a call and a construct signature. + - E5 (#3, P3, maintainability): `TypeExprs` loses the two members no caller reads. + - E6 (#5, P3, api-contract): an undecodable `package.json` is refused with `ManifestRejected` naming the package root; `readPackage`'s error channel no longer carries `Schema.SchemaError`. Reader scenario. + - E7 (#6, P3, testing): the e2e TS9010 routing scenario asserts the fallback's `TYPESCRIPT_PRODUCER` stamp. + - E8 (DeepSeek anchor-50 note, reproduced): a hand-written `.d.ts` that an emitted declaration imports is read verbatim on both routes, because `tsc` emits nothing for a declaration input. At `a98496e` the fast path declined and the fallback refused the whole package. Equality-journey package. + - E9 (pack row schema-laws/rich-type-over-foreign-encoded): `package.json` export targets keep Node's shape as the Encoded side and decode into a tagged `ExportTarget` (path, conditions, fallbacks, excluded) in `package-source.schema.ts`; `read-package.ts` reads only the decoded union. Existing reader scenarios cover every case. +- **Rulings, rejected:** + - #7 (P3, DeepSeek peer; rejected by the validator): upstream's `ExtractorMessage` has no item field, so the oracle cannot compare one. A18 is corrected here to what landed in `55eb06e`: the oracle compares message id, symbol and the entry point of a forgotten export, and `item` is asserted by the hand-written scenarios. + - Reliability anchor-50 note (an unreadable source aborts the fast path): a source that is present but unreadable is an environment fault, not a package shape. `tsc` cannot read it either, so declining would only trade the `PlatformError` naming the file for a `CompilerRejected` from the fallback. A missing source still declines (ruling #5), and a directory where a source is expected is skipped (orchestrator probe at `a98496e`: `src/a.ts` as a directory, the fast path read `src/a.tsx`). The failure is not reproducible as the root user, so no test can show it. + - Adversarial anchor-50 note (same-stem `.ts` and `.tsx`): `tsc` resolves `./a.js` to `a.ts` before `a.tsx`, and the fast path's candidate order matches. Both routes model `a: number` (orchestrator probe at `a98496e`). + - Project-standards anchor-50 note (profile JSON without `canonicalJson`): AGENTS.md's serialiser rule governs the tool's outputs, whose bytes R5 and R7 make host-independent. The profile is a private app's timing record (KTD10); its numbers differ on every run, and no locale API reaches it. + - Pack row cell-architecture/sandwich-phase-order: not a violation. The pack's cell is the product's decision, `judgeSurfaces`, run by `verdictCell` (`verdict.cell.ts:23-39`, KTD14). `analyse` is a capability operation over the emitter ports: its requirement channel is the two emitter services (`analyse.ts:38-41`, KTD1). It holds no command and no write phase, and its builder is a parser, not a complexity-1 `Workflow`. A front-end result is the Encoded side of the verdict cell's command (`verdict.cell.ts:12-16`). +- **Follow-ups:** + - #4 (P3, maintainability), the profile harness's copies of the declaration-path, tsconfig and export-map rules. Owner: the CI benchmark unit (PR5, origin U9). Trigger: PR5 moving the profile into its benchmark lane, which re-records it. Changing the harness here would stop the three committed profiles reproducing from it (R13). + - ci-wall-clock/one-stuck-job-backstop and flake-outputs-build-once-on-main: pre-existing; `.github`, `flake.nix` and `flake.lock` equal `main`. Owner: PR5. Trigger: PR5 changing a job in `ci.yml`. +- **Then:** plain-merge `origin/main` at `942ac814e7` (#6 changeset coverage from turbo's build hashes; #11 packs repinned to systemfsoftware `0923912eed`), re-run the gates, the delta lens and the full-path receipt at the merged head, with the packs at `0923912eed`. +- **Preserved:** U11-U13 rulings and the PR1 counterexample record (CI run 38372869767, `∀x_TypeExprNesting_≤MaxDepth1`), not re-run. +- **Test expectation:** each fix names its scenario, at the public surface (`FrontEnd.*` or a spawned `tsc`), and fails at `a98496e`. e2e cases extend the existing journeys (KTD11 keeps four). +- **Verification:** the Verification Contract gates, a named DeepSeek delta lens over the fixing commits, and a fresh full-path receipt at the final head. + +### U15. Rulings on the full-path review at the merged head `932ae50` + +- **Goal:** every finding, testing gap and residual of the full-path `ce-code-review` at `932ae50` (run `20261013-215304-932ae50a`: ten reviewer roles, the learnings and ci-wall-clock checks, and the DeepSeek peer `openrouter/deepseek/deepseek-v4.1-flash`) has a ruling: fixed with a named commit, rejected with a reason, or a follow-up with an owner and a trigger. The testing and learnings reviewers and the DeepSeek delta lens died with a session crash and were run again; the delta lens is taken at the final head over every fixing commit since `a98496e`. +- **Requirements:** R3, R5, R6, R16; KTD4, KTD5, KTD6, KTD7. +- **Rulings, fixes** (each scenario fails at `932ae50`, or at `4fec99b` for F7, unless marked as pinning existing behaviour): + - F1 (correctness P2): a reference to an unexported interface, class or other non-alias declaration kept its bare name, so it read as the exported item of that name, and two hidden `Opts` collapsed into one. Every reachable hidden declaration takes a distinct model name through the alias-local name assignment; only aliases are emitted into `locals`, so the schema is unchanged (`4fec99b`). Builder scenario. + - F2 (adversarial P2): a function or constructor type inside text printed `=>: T`; it prints `(x: number) => string` as `tsc` does (`4fec99b`). Builder scenario. + - F3 (adversarial P3): local renames inserted the model name as a replacement pattern, so `T$$` became `T$`; the name is inserted literally (`4fec99b`). Builder scenario. + - F4 (correctness P3, DeepSeek peer P2): an entry whose source condition names a hand-written `.d.ts` mapped to a `.d.d.ts` path; the fast path modelled it and the compiler route refused it. The entry keeps its own path and the compiler route reads it as a hand-written declaration (`1109e48`). Equality-journey package. + - F5 (api-contract P2): the README said a refusal never carries `tsc`'s printed output. Per U13 P3 it does, alone, when the TypeScript 7 API reports no error; the README now says so and calls that text untrusted (`5dd424b`). Removing the output from the code is rejected: it reverses U13 P3, and a write failure the API cannot see would surface as an exit code only. + - F6 (maintainability P2, P3): one shared-declaration rule, `sharedDeclarations` in `declaration-graph.ts`, serves both adapters, and `read-package.ts` reuses `package-file.ts`'s `isAbsent` (`611a024`). Routing, reader and e2e journeys pass unchanged. + - F7 (pack rows schema-laws/tagged-unions-over-state-by-presence and rich-type-over-foreign-encoded, learnings P3): `Diagnostic` is the union of `Reported` (TS code, optional in-package file, message) and `Note` (optional file, message). The adapters turn oxc's text and the TypeScript 7 API's diagnostic into a variant; the refusal text and the fast path's diagnostic order match on the tag (`34206fd`). `file` stays optional in both variants, absent the same way in each (rule 2). The e2e routing and refusal journeys assert the tags. + - F8 (testing P3 and gaps): pinning scenarios, passing before and after: two files re-exporting each other (`4fec99b`); a package with two entry points that do not share a declaration file, and the reader's `sourceCondition` option (`eca9cee`); a refused diagnostic keeps `tsc`'s elaboration lines (`47bf1c3`). +- **Rulings, rejected:** + - Reliability P2 (`catchCause` at `typescript-7-compiler.ts:97,229`): the TypeScript 7 API resolves `parseConfigFile` on a malformed tsconfig (empty `fileNames`) and rejects only when it cannot read the file (orchestrator probe on 7.0.2: "could not read file"); `tsc` then fails on the same `extends` and the package is refused. At `:229` an API failure leaves the refusal in place with the exit diagnostic. Neither turns a refusal into a model. The two gaps beside it (a `tsc` that cannot be spawned, an API rejection) are the tool's own installation, not package input. + - Security gap (recursion budget on the manifest codec): `recursionBudget` budgets arbitrary generation for generated laws, which `main` retired (#9); it does not bound decoding, so there is no behaviour to pin. + - Residuals: the verdict's `declarations` field carries each surface's producer stamp by design (R7); `VerdictTypeId` sits on `Verdict.prototype` since U1 (isolatedDeclarations) and `VerdictTypeId in verdict` holds for a judged, decoded and constructed verdict (orchestrator probe); a `NotIsolated`-only fallback is ruling #5 and the U14 unreadable-source ruling; the oracle's projection is ruling #7 and A18; the native parsers run in-process because parsing package text is the tool's job (KTD17 approved packages); a diagnostic's file outside the package is dropped by `fileFieldOf` (`typescript-7-compiler.ts:173-177`) and its message is `tsc`'s own text (F5). +- **Follow-ups:** + - The profile harness's own discovery, declaration-path and resolution rules, its deadline-free `git`/`tsc` spawns, its abort on one undecodable corpus manifest, its separate timing of `buildModel` and `resolveMessages`, and the unmeasured cost of the TSDoc tag parse (maintainability P3 and gaps, performance, reliability and correctness residuals). Owner: the CI benchmark unit (PR5, origin U9). Trigger: PR5 moving the profile into its benchmark lane, which re-records it; until then the committed profiles must reproduce from this harness (R13). + - Adversarial inputs: a wildcard export whose matched directory is a symlink out of the package, a reported diagnostic whose file lies outside the package, a `tsc` failure the API cannot see, and a multi-entry package checked against upstream's messages (security, api-contract, testing and learnings gaps). Owner: the report and differential unit (PR4), whose adversarial corpus and differential lane (R9) are the arbiter. Trigger: PR4 adding its adversarial corpus. + - Text fidelity of the canonical printer and the fast path's text against `tsc`'s on the real corpus (adversarial residuals, DeepSeek gap). Owner: PR4. Trigger: PR4's renderer comparing the `.api.md` text with upstream 7.59.1. +- **Preserved:** U11-U14 rulings and the PR1 counterexample record (CI run 38372869767, `∀x_TypeExprNesting_≤MaxDepth1`), not re-run. +- **Verification:** the Verification Contract gates at the final head, the DeepSeek delta lens over `a98496e..` the final head, and a fresh full-path receipt at the final head. + +### U16. Rulings on the full-path review at `425c81c` + +- **Goal:** every finding, testing gap and residual of the full-path `ce-code-review` at `425c81c` (run `20261013-225357-425c81cc`: ten reviewer roles, the learnings and ci-wall-clock checks, the DeepSeek peer and the DeepSeek delta lens over `a98496e..425c81c`, both `openrouter/deepseek/deepseek-v4.1-flash`) has a ruling. The learnings reviewer found no contradicted pack rule; the ci-wall-clock pack is not triggered (`.github`, `flake.nix` and `flake.lock` equal `main`). +- **Requirements:** R3, R5, R6, R16; KTD5, KTD6, KTD7. +- **Rulings, fixes** (each scenario fails at the commit named, unless marked as pinning existing behaviour): + - G1 (correctness and adversarial P2, maintainability P3): F1 (`4fec99b`) renamed hidden declarations through a NUL-delimited key but matched only whole reference names, so `C.Inner` through a hidden namespace `C` leaked the key into the model. Structural names are renamed through the same key substitution as text (`8f4617a`). Builder scenario; fails at `425c81c`. + - G2 (correctness P2): a namespace or enum declared twice kept only the first declaration's members; the builder reads every body (`8f4617a`). Builder scenario; fails at `425c81c`. + - G3 (testing residual): after F1 the local-alias drain builds only aliases, so its "not a type alias" refusal was unreachable and is deleted (`8f4617a`). + - G4 (DeepSeek delta P2): an entry naming `src/index.d.ts` beside an `src/index.ts` the tsconfig compiles was modelled from the hand-written file on the fast path and from `tsc`'s emit of `index.ts` on the fallback. The fallback drops `tsc`'s emit for a declaration file an entry names as its own source (`f98127b`). Equality-journey package; fails at `95956df`. + - G5 (DeepSeek peer P3): the fallback's unresolved-import refusal now says "inside the package", as the fast path's does, so a declaration that exists outside the package is not reported as missing (`f98127b`). + - G6 (DeepSeek delta P3): a pattern export inserted the matched file name as a replacement pattern (`src/a$$b.ts` exported as `./a$b`); it is inserted literally (`99b7495`). Reader scenario; fails at `dfd896d`. No other `replace` in `src` takes a dynamic replacement string. + - G7 (api-contract P3 and P3): the README lists every `UnsupportedDeclaration` cause, including the compiler fallback's (`95956df`); KTD1 above no longer says the compiler layer takes options. + - G8 (testing P2, P3 and gaps): pinning scenarios (`dfd896d`): notes for a second entry point carry its own path; the builder's statement-form and variable refusals; negative, bigint, boolean, null and single-quoted literals; a pattern match with a dot segment and a malformed pattern target. +- **Rulings, rejected:** + - Maintainability P3, seven per-kind extractors in `build-model.ts`: U12 A7 kept them. + - Adversarial residual, an array-form export target reads no entry: U13 #2 admits and skips array targets. + - Reliability and adversarial residuals, a native oxc throw or an `UnsupportedDeclaration` from oxc's own output aborts instead of falling back: no input that triggers either was found by three reviewers or the peers; the adversarial corpus follow-up below owns the search. + - Reliability gap, interrupting a fallback run: the `tsc` child and both temp directories are scoped (`Effect.scoped` around `emit`, `makeTempDirectoryScoped`), so their release is Effect's scope guarantee; a test would test the library. + - Correctness gap, an in-process scenario mixing an emitted declaration and a hand-written `.d.ts` reached by import: the e2e equality journey's `hand-written-declaration` package is that shape, through both real emitters. + - Project-standards residual, provenance headers: no file in `src`, `tests` or the profile app names or derives from rushstack or PR #552 (reviewer text search), and the README's Provenance section says so. +- **Follow-ups:** + - Maintainability P2, one exported-surface walk shared by the builder and the resolver (with the external-symbol policy as an argument), and the gap that nothing pins their agreement on a diamond or namespace re-export graph. Owner: PR4. Trigger: PR4's renderer, which reads the model and the messages together and makes a disagreement visible. + - Performance residuals (a declaration file parsed twice, an uncached TSDoc parse per tag lookup, a pattern walk that lists `node_modules` before filtering it) and the profile referee counting a `tsc` run that never checked as clean (DeepSeek peer P3), and profile reproduction after this unit's fixes (DeepSeek delta gap). Owner: PR5. Trigger: PR5 moving the profile into its benchmark lane and re-recording it. + - Security residuals and gaps (a wildcard match through a symlinked directory, unbounded declaration nesting, a package root that is itself a symlink, a tsconfig including files outside the package) and the DeepSeek residual on non-POSIX path separators in the fallback's declaration keys (CI hosts are Linux x86_64 and arm64). Owner: PR4's adversarial corpus for the inputs; PR6 (CLI and consumer journey) for a non-POSIX host lane. Trigger: PR4 adding its corpus; PR6 adding a host to the consumer journey. +- **Preserved:** U11-U15 rulings and the PR1 counterexample record (CI run 38372869767, `∀x_TypeExprNesting_≤MaxDepth1`), not re-run. +- **Verification:** the Verification Contract gates at the final head, a DeepSeek delta lens over `425c81c..` the final head, and a full-path receipt at the final head. + +### U17. Rulings on the full-path review at `a6300c7` + +- **Goal:** every finding, testing gap and residual of the full-path `ce-code-review` at `a6300c7` (run `20261013-231221-a6300c70`: ten reviewer roles, the learnings and ci-wall-clock checks, the DeepSeek peer and the DeepSeek delta lens over `425c81c..a6300c7`, both `openrouter/deepseek/deepseek-v4.1-flash`) has a ruling. The learnings reviewer accounted for all 52 rules of the five enrolled packs (cell-architecture 15, boundary-testing 7, schema-laws 9, package-topology 7, ci-wall-clock 14) and found none contradicted; the ci-wall-clock pack is not triggered (`.github`, `flake.nix` and `flake.lock` equal `main`). Security, project-standards, performance and reliability reported no finding. CI is green at `a6300c7` (runs 38482833614, 38482833730). +- **Requirements:** R3, R6, R7, R8; KTD5, KTD6. +- **Rulings, fixes** (each scenario fails at the commit named, unless marked as pinning existing behaviour): + - H1 (correctness P2): a conditional type's `infer U` was resolved in the true branch like a declaration, so an imported `U` printed `import("pkg").U` and a hidden `V` printed `V_2`. Each file's conditionals record the names their `extends` clause binds, and the true branch prints with them in scope (`2dd44cb`). Builder scenario; fails at `a6300c7`. + - H2 (DeepSeek delta P3): merged enum bodies that repeat a member name built a model `ApiModel.decode` rejects (`Expected enum members unique by key`, probed). The builder refuses it naming the member (`0e1d692`). Builder scenario; fails at `2dd44cb`. + - H3 (probe of the DeepSeek delta residual): a namespace or enum declared twice inside a namespace body stayed two items, while the top level merges them (U16 G2). Namespace bodies merge by name at every depth through the same item builders (`0e1d692`). Builder scenario; fails at `2dd44cb`. Corrected by U18 I7: this package declares no namespace, so its own analysis is no evidence for the rewrite; U18 I1 records the statement form the rewrite dropped. + - H4 (DeepSeek delta P3, api-contract P3): the README's refusal list now names every `UnsupportedDeclaration` cause in `src` (checked against each `refuse` call) and documents `readPackage`'s `sourceCondition` option (`0e1d692`). + - H5 (testing P2, P3 and gap): pinning scenarios for the destructuring-parameter, dotted-namespace, unparseable-text and bare-import-type refusals (`0e1d692`), and for a bare-path or absent `exports` refused with `NoSourceEntry` (`68502ef`). + - H6 (maintainability P3): one `refuse` helper (exported from `declaration-graph.ts`; the preset forbids a non-schema export from `front-end-error.schema.ts`) and one `directoryOf` (`0e1d692`). +- **Rulings, rejected:** + - DeepSeek peer P2, a bare import type (`typeof import('./x.js')`) refuses the whole package. Printing it as text would write a package-relative file path into the model, so moving a file would register as an API change, and schema version 2 has no reference form for a module namespace (KTD6 makes a relative `import("./y").Z` a local reference, which needs a member). No package of the 40-package corpus reaches it (the second profile's refusals are `declare global`, an external re-export and `declare module`). The README lists the refusal (H4), and H5 pins it. + - Maintainability P2, seven per-kind extractors in `build-model.ts`: U12 A7 kept them. + - Testing gap, the derived tsconfig's literal content: asserting the config text tests wiring; its effect is observed through the compiled model and the e2e equality journey. + - Testing gap, `apps/api-extractor-profile/profile.ts` has no test: it is the evidence harness whose output is the committed record; the record is re-recorded, not unit-tested (PR5 follow-up below). + - Performance gaps, a parse count and the directory surface a pattern walk lists: both pin implementation cost, not a consumer-visible result. + - Project-standards residual, the derived tsconfig is written with `JSON.stringify`, not `canonicalJson`: it is a temporary input to `tsc` that never leaves the run, not an output. + - Adversarial residual, `Route.Fallback.diagnostics` mixes source paths (oxc) and declaration paths (missing entry, shared declaration, unresolved import): each names the file the diagnostic is about, and the routing scenarios pin both. + - DeepSeek peer gap, a `.tsx` entry with a same-stem `.ts` needing the fallback: U14 ruled that `tsc` picks the `.ts`. + - DeepSeek delta gap, the hand-written-entry equality journey asserts no modelled text: the journey asserts byte equality of both routes' serialised models, which includes the text. +- **Follow-ups:** + - Api-contract P3, `UnsupportedDeclaration.construct` is prose a caller must string-match. Owner: PR4. Trigger: PR4's report or adversarial corpus grouping refusals by cause, which then adds a closed cause field (a model-independent error-shape change). + - Adversarial P3 and P3, the profile's clean columns count the source walk rather than the emitted declaration closure, and its divergence figures include a failed compile's output; reliability residual, a package the record marks fallback-failing routes FastPath; adversarial residual and gaps, route equality is proven end to end on one real package and fast-path and fallback refusals are never compared on one package. Owner: PR5. Trigger: PR5 moving the profile into its benchmark lane and re-recording it, which also adds an emitted-closure clean column and per-package route equality. + - Security residuals and gaps (native parse of untrusted text, symlinked pattern directories, a symlinked package root, a tsconfig including files outside the package) and the DeepSeek and testing gaps above U16 assigned: unchanged owners and triggers. +- **Preserved:** U11-U16 rulings and the PR1 counterexample record (CI run 38372869767, `∀x_TypeExprNesting_≤MaxDepth1`), not re-run. +- **Verification:** the Verification Contract gates at the final head, a DeepSeek delta lens over `a6300c7..` the final head, and a full-path receipt (merge, validator and report leaves) at the final head. + +### U18. Rulings on the full-path review at `460f429` + +- **Goal:** every finding, testing gap and residual of the full-path `ce-code-review` at `460f429` (run `20261013-233424-460f4299`: ten reviewer roles, the learnings and ci-wall-clock checks, the DeepSeek peer and the DeepSeek delta lens over `a6300c7..460f429`, both `openrouter/deepseek/deepseek-v4.1-flash`) has a ruling. The learnings reviewer accounted for all 52 pack rules and found none contradicted on an unsettled basis; its pre-existing ci-wall-clock rows (job timeouts below the backstop, the changeset check's dev shell, split gate jobs) concern `.github` files this branch does not change and stay with PR5. Project-standards, performance and api-contract reported no finding. +- **Requirements:** R3, R6, R7, R16; KTD5, KTD6. +- **Rulings, fixes** (each scenario fails at the commit named): + - I1 (testing P1, DeepSeek delta P1): `0e1d692` read an export-modified statement in a namespace body through the declaration-node path, which has no namespace arm, so `export namespace M` inside `N` was dropped. The exported declaration takes the same statement path as an unexported one (`e0790d6`). Builder scenario; fails at `460f429`. + - I2 (security residual, probed): the pattern walk used Node's recursive `readdir`, which follows a symlinked directory; on node v24.21.0 `src/loop -> .` listed `loop/a.ts`, `loop/loop/a.ts`, ... (82 names until `ELOOP`), so one loop added about 40 bogus entries and several loops made `readPackage` effectively never return. The walk lists one directory at a time and enters each real path once (`fef7fe8`). Reader scenario; fails at `e0790d6`. + - I3 (DeepSeek peer P3, probed): a reference inside a namespace body resolved against the file scope only, so with `import type { A } from 'pkg'` a function in `N` returning `N`'s own `A` modelled `A` from `pkg`. A body's declared names are in scope for its items, as type parameters are (`1f3d00c`). Builder scenario; fails at `e0790d6`. + - I4 (DeepSeek delta residual): `import X = M.Y` inside a namespace body was dropped silently; it is refused as at the top of a file (`1f3d00c`). Builder scenario; fails without the new arm. + - I5 (maintainability P3): the namespace-body grouping reuses `declaration-graph.ts`'s `grouped` (`1f3d00c`). + - I6 (correctness P3 and gap): the README names hand-written `.d.ts`, `.d.mts` and `.d.cts` entry files, and a reader scenario pins them (`fef7fe8`, existing behaviour). + - I7 (DeepSeek delta P3): U17 H3 cited the host digest, which samples generated models and never runs a front-end. The claim is corrected above. Measured instead: this package's `analyse` output (model and messages) has sha256 `cbcb540d…3b34` at `a6300c7`, at `2dd44cb` and with the U18 fixes; it declares no namespace, so this shows no collateral change only. +- **Rulings, rejected:** + - Maintainability P2, seven per-kind extractors in `build-model.ts`: U12 A7 kept them. + - Reliability P3, the blanket `catchCause` at `typescript-7-compiler.ts:97` and `:230`: U15 rejected it with probe evidence. + - DeepSeek delta residual, an `infer` in the `extends` clause of a conditional nested inside another conditional's `extends` clause is bound by the inner conditional only: TypeScript binds an `infer` declaration to the nearest enclosing conditional whose `extends` clause holds it, which is the inner one. +- **Follow-ups:** + - Adversarial P3 and residuals, and the correctness residual: the profile harness discovers entry sources from the manifest itself instead of `readPackage`, has no `.cts`/`.mts` import candidates, copies `analyse`'s routing, and compares TypeScript 7 with 5.9.3 rather than oxc with `tsc`; reliability residual, it drops an unreadable emitted file from its outputs. Owner: PR5. Trigger: PR5 moving the profile into its benchmark lane and re-recording it on `readPackage` and `analyse`. + - DeepSeek peer residual and gap, the resolver walks only a file's exported names and namespace aliases, so a member of a `declare namespace` body draws no analysis message; api-contract residual, `AnalysisMessage`'s `item` and `symbol` fields mean different things per message id and are not documented; testing gaps, the oracle compares only the first entry point and several refusal constructs (unsupported members, computed names, a qualified namespace name inside a body, a heritage expression, an unmatched oxc message as a `Note`) have no scenario. Owner: PR4. Trigger: PR4's differential lane against upstream 7.59.1 (KTD13's arbiter for resolver gaps), its adversarial corpus, and its renderer, which documents the message fields it reads. + - Security residuals (native parse of untrusted text, a symlinked source escaping the package, a symlinked package root) and the adversarial residual on the fallback's mirrored `node_modules` ancestry: unchanged owners and triggers (U16). +- **Preserved:** U11-U17 rulings and the PR1 counterexample record (CI run 38372869767, `∀x_TypeExprNesting_≤MaxDepth1`), not re-run. +- **Verification:** the Verification Contract gates at the final head, a DeepSeek delta lens over `460f429..` the final head, and a full-path receipt (merge, validator and report leaves) at the final head. + +### U19. Rulings on the full-path review at `3ff23ef` + +- **Goal:** every finding, testing gap and residual of the full-path `ce-code-review` at `3ff23ef` (run `20261013-234958-3ff23efe`: ten reviewer roles, the learnings and ci-wall-clock checks, the DeepSeek peer and the DeepSeek delta lens over `460f429..3ff23ef`, both `openrouter/deepseek/deepseek-v4.1-flash`) has a ruling. The learnings reviewer adjudicated all 52 pack rules (30 honored, 22 not reached, none contradicted); the ci-wall-clock pack is not triggered (`.github`, `flake.nix` and `flake.lock` are byte-identical to `main`) and its pre-existing rows stay with PR5. Security, reliability, performance, project-standards and api-contract reported no finding. +- **Requirements:** R3, R6, R7, R16; KTD5, KTD6. +- **Rulings, fixes** (each scenario fails at the commit named): + - J1 (DeepSeek delta P2; testing and adversarial residuals): `fef7fe8`'s walk-wide visited set listed a sibling's symlinked alias (`src/b -> a`) under one name only, chosen by readdir order, so model bytes depended on the host. The walk skips a directory only when its real path is one of its ancestors' and reads each listing in code-unit order (`9b04f01`). Reader scenario; fails at `3ff23ef`. + - J2 (DeepSeek delta residual): `1f3d00c` put every name a namespace body declares in scope, so a `const A` in `N` shadowed an imported type `A`. Only names declared as an interface, type alias, class, enum or namespace are in scope for type positions (`8881471`). Builder scenario; fails at `3ff23ef`. + - J3 (maintainability P1, P2 and P3): the 1180-line model feature is split into the built-model feature and a refusals feature over one fixture module; three module-private helpers lose their `export`; `TypeContext.typeParameters` is `namesInScope` (`8881471`). + - J4 (api-contract residual and gaps, testing P3 and gaps, DeepSeek delta gaps): pins for a `.d.mts` entry, a plain-string subpath target (`NoSourceEntry`), a missing `package.json` (`PlatformError`), the workspace root and tsconfig choice, a pattern target outside the package (`9b04f01`), and an exported import alias in a namespace body (`8881471`); the README says where the source condition is read and names `PlatformError` (`9b04f01`). +- **Rulings, rejected:** + - Adversarial P2 and correctness P3, a source condition nested under another condition or inside a fallback array names no entry: KTD5 reads the family condition directly under an export path, which is where the family writes it; which nested branch Node takes depends on the consumer's active conditions (`import` or `require`), which the reader has no mode to choose, and U13 #2 already admits and skips array targets. The README now states the rule. + - DeepSeek delta residual, a shadowed reference no longer claims a hidden local's name, so another hidden local's `_N` suffix can change: the suffix now follows the declarations a reference really reaches, which is the intended naming. + - Reliability and security residuals, `tsc`'s stdout and stderr are buffered whole: ruling #13 leaves `tsc` without a deadline and U12 reads neither stream as diagnostics; a hostile package that floods `tsc`'s output is the PR4 adversarial corpus's input. +- **Follow-ups:** + - DeepSeek peer P3 and gaps: the fallback refuses a hand-written declaration's relative import that names no declaration file even when a TypeScript source exists there, while the fast path emits from the source; `.d.mts`/`.d.cts` and non-declaration specifiers are not run through both routes. Adversarial and correctness residuals: route equality is shown on authored fixtures only. Owner: PR4. Trigger: PR4's differential lane and adversarial corpus, which run both routes on unauthored packages. + - Security residual and gap, refusal text carries package-controlled characters to a renderer: owner PR4. Trigger: PR4's report renderer, the first sink that must escape it. + - Performance residuals and gap (a pattern target at the package root walks installed dependencies; per-declaration rescans in the resolver and the rename sweep) and the remaining testing gaps (an empty-output `tsc` exit, resolver comment attribution around `export default`): owner PR5 and PR4 respectively. Trigger: PR5's benchmark lane with a large synthetic package; PR4's differential lane. +- **Preserved:** U11-U18 rulings and the PR1 counterexample record (CI run 38372869767, `∀x_TypeExprNesting_≤MaxDepth1`), not re-run. +- **Verification:** the Verification Contract gates at the final head, a DeepSeek delta lens over `3ff23ef..` the final head, and a full-path receipt (merge, validator and report leaves) at the final head. + +### U20. Rulings on the full-path review at `681efe3` + +- **Goal:** every finding, testing gap and residual of the full-path `ce-code-review` at `681efe3` (run `20261014-000912-681efe30`: ten reviewer roles, the learnings and ci-wall-clock checks, the DeepSeek peer and the DeepSeek delta lens over `3ff23ef..681efe3`, both `openrouter/deepseek/deepseek-v4.1-flash`) has a ruling. The learnings reviewer adjudicated all 52 pack rules (26 honored, 26 not reached, none contradicted); the ci-wall-clock pack is not triggered (`.github`, `flake.nix` and `flake.lock` are byte-identical to `main`). Project-standards, performance and api-contract reported no finding. +- **Requirements:** R1, R3, R6, R7, R9, R16; KTD5, KTD6, KTD7, KTD8, KTD11. +- **Rulings, fixes** (each scenario fails at the commit named, unless marked as pinning existing behaviour): + - K1 (security P1 and gaps): J1 walks every alias of a directory, so nested aliases (`a`, `b -> a` at each level) fan out exponentially. The walk counts the directories it lists per pattern export and refuses past a bound with `ManifestRejected` naming the export (`24e9b67`). A root pattern then met the same fan-out through `node_modules`, where pnpm's symlinks alias like any tree; the walk no longer enters a directory whose name is an invalid export segment lying wholly inside the matched name, and the bound is 1024 (`e8df9af`). Reader scenarios: an alias tree of depth 10 is refused, failing at `681efe3`; a `node_modules` alias tree of depth 13 under a root pattern is read, failing at `3016e54`. The README states the bound. + - K2 (correctness P2 and gap): a type parameter hid a same-named type reached in another file (`f(): ns.T`). A lookup that crosses a file boundary starts with no names in scope (`4ba7034`). Builder scenario; fails at `681efe3`. + - K3 (DeepSeek peer P2): a private constructor was dropped, so making a class unconstructable left the model unchanged. It is a member named `private constructor`, apart from `constructor` (`4ba7034`). Builder scenario; fails at `681efe3`. + - K4 (DeepSeek peer P3): an index signature took its parameter's name, so renaming `k` to `key` changed the model. It is named by its key type alone (`[key: string]`), in members and in text (`4ba7034`). Builder scenario; fails at `681efe3`. + - K5 (testing P3, DeepSeek delta P3): the `declarations` fixture ignored entry-point lists after the first; a second list is now a type error (`4ba7034`). + - K6 (adversarial P2 and gaps): the resolver ignored a namespace body's own declarations, so a reference to the body's type `A` reported the imported `A` as forgotten. `namespaceSpacesOf` gives a body's type-space and value-space names once for the builder and the resolver; type references and interface heritage read the type space, `typeof` and class heritage the value space (`9e220fc`). Analysis scenarios agree with upstream; two fail at `681efe3`. + - K7 (DeepSeek delta P2 and gap): the builder scoped only a body's type names, so with `const A` in `N`, `typeof A` printed the imported `A`. `TypeContext` gains `valuesInScope`; `typeof` and a class's superclass resolve their head in the value space and their type arguments in the type space (`abb3022`). Builder scenario; fails at `9e220fc`. + - K8 (DeepSeek delta gap): no scenario failed without J1's code-unit sort of each listing. Every alias is walked, entry points are re-sorted by export path and the bound counts every listing, so the order reaches no output; the sort goes (`3016e54`). + - K9 (maintainability P3 and gap): the statement classifier existed three times and the entry-point pair four times; `classifyDeclaration` and `EntryDeclaration` in `declaration-graph.ts` are the one copy each (`43a8ee4`). The copies had drifted: a namespace body dropped a destructuring constant that `tsc` 7.0.2 accepts in a `declare namespace` (orchestrator probe, exit 0), so `N.a` vanished; it is refused as at file scope. Refusals scenario; fails at `abb3022`. + - K10 (maintainability P2, testing P2, DeepSeek delta gap, api-contract gap): the 1120-line model feature splits its eight namespace scenarios into a namespaces feature (`db5c70a`); pins for a type-incorrect annotated package taking `FastPath` with `count` modelled as `number` (`db5c70a`), a symlink to an ancestor two levels up walked once, and a manifest without `name` or `version` refused as `ManifestRejected` (`e8df9af`). +- **Rulings, rejected:** + - DeepSeek peer residual, private fields and methods are dropped: upstream 7.59.1 trims every private declaration from its model and report (`ApiModelGenerator.js:117`, `ApiReportGenerator.js:370`). K3 keeps the private constructor alone because it decides who may construct or extend the class. + - Reliability gaps and residuals on `typescript-7-compiler.ts` (`catchCause` at `:97`, `:230`): U15. The "unbounded concurrency" read of emitted declarations is sequential: `Effect.forEach` without a `concurrency` option runs one effect at a time. + - Testing gap, the router's `Fallback` arm has no in-process scenario: the compiler adapter spawns `tsc`, which KTD11 keeps in the e2e journeys, where the TS9010 package pins it. + - DeepSeek peer residual, multi-star pattern targets: the family writes single-star targets under KTD5's condition, and a multi-star target is pinned as naming no entry, so it is never mapped wrongly and a package left without entries is refused with `NoSourceEntry`. + - Maintainability P2 (seven per-kind extractors) and residual (two renderers over one oxc union): U12 A7 and C7. Residual, the typed error bases repeat per file: KTD9 (isolatedDeclarations). + - Project-standards residual, two test fixtures order strings by hand: AGENTS.md's ordering rule and its gate cover `src`. + - Adversarial residual, an aliased directory gives two export paths for one file: J1. +- **Follow-ups:** + - K3 and K4 meet the report: upstream's report omits a private constructor and spells an index signature's parameter as written. Owner: PR4. Trigger: the report renderer, which omits `private constructor` members and takes spellings from the declaration text spans PR4 adds. + - Correctness, adversarial and DeepSeek residuals and gaps on route equality (unauthored packages, parameter properties, a symlinked entry sharing another entry's declaration), the security residuals on symlinks resolving outside the package and native parsing, and the resolver's namespace-member messages: unchanged owners and triggers (U16, U18, U19). + - Maintainability P2 and gap, the profile harness re-implements reader helpers: owner PR5 (U18). Performance residuals (`dedupeWith`, per-declaration rescans, the fallback's whole-output reads): owner PR5. Trigger: PR5's benchmark lane. +- **Preserved:** U11-U19 rulings, except K8, which drops J1's sort as unobservable; the PR1 counterexample record (CI run 38372869767, `∀x_TypeExprNesting_≤MaxDepth1`), not re-run. +- **Verification:** the Verification Contract gates at the final head, a DeepSeek delta lens over `681efe3..` the final head, and a full-path receipt (merge, validator and report leaves) at the final head. + +### U21. Rulings on the full-path review at `5d32c3f` + +- **Goal:** every finding, testing gap and residual of the full-path `ce-code-review` at `5d32c3f` (run `20261014-004546-5d32c3fa`: ten reviewer roles, the learnings and ci-wall-clock checks, the DeepSeek peer and the DeepSeek delta lens over `681efe3..5d32c3f`, both `openrouter/deepseek/deepseek-v4.1-flash`) has a ruling. The learnings reviewer adjudicated all 52 pack rules (14 honored, 38 not reached, none contradicted); the ci-wall-clock pack is not triggered. Correctness, testing, maintainability, performance, reliability, security and project-standards reported no finding. +- **Requirements:** R3, R6, R9, R10, R16; KTD5, KTD6, KTD7. +- **Rulings, fixes** (each scenario fails at `5d32c3f`, unless marked as pinning existing behaviour): + - L1 (adversarial P2, DeepSeek delta P2 and gaps): the resolver scoped a namespace block with its own names only, so with `N` declared twice and an imported `A`, a reference to `A` in the second block was reported as a forgotten export while `tsc` and the builder bind it to `N.A`. One pass per file maps each namespace path (`N`, `N.M`) to all its blocks, and a block is scoped with their union (`aea9874`). Analysis scenarios for a top-level and a nested merge agree with upstream. + - L2 (correctness residual and gap): an export list naming no declaration (`export { present, Missing }` in a hand-written entry) gave a model silently missing `Missing`. It is refused with `UnsupportedDeclaration` naming the file and the export (`e5db940`). Refusals scenario. The README lists the cause. + - L3 (DeepSeek peer P2 and residual): an export path found under a wide key (`./*` -> `./src/*.ts`) kept its entry when a narrower key (`./a/*` -> `./vendor/*.ts`) wins it in Node and names no file. A pattern entry is kept only when its key is the one Node's pattern-key order picks for its export path, which also leaves out a path whose winning key is `null` (`688e85d`). Reader scenario. + - L4 (DeepSeek peer P3): a key with two stars produced export paths holding a literal star; only single-star keys are patterns, and other starred keys name no entry (`688e85d`). Reader scenario. + - L5 (DeepSeek delta P3): a star inside a file name (`./src*.ts`) walked the package root, `node_modules` included, and tripped the walk bound. The walk enters only directories the target prefix can lead to (`688e85d`). Reader scenario. The README states L3-L5. + - L6 (DeepSeek delta P3): U20 K1's `node_modules` scenario at depth 10 passes at `3016e54`, whose bound was 4096; the recorded failure there used depth 13. At the head the committed depth-10 scenario fails once the pruning is removed (2049 listings past 1024). Recorded here; no code change. + - L7 (testing gap, adversarial gap, correctness and maintainability gaps): pins for a merged namespace whose class and enum are read from the other block in both spaces (`c9fc383`) and a `node_modules` fan-out nested under the pattern prefix (`e5db940`). Both pass at `5d32c3f`. + - L8 (api-contract P3): the release note names the new runtime dependencies (`c9fc383`). +- **Rulings, rejected:** + - Adversarial residual, the `node_modules` prune misses `src/node_modules` under a `src/` prefix: refuted by L7's pin, which lists 2049 directories without the prune and is read at the head. + - Adversarial residual, cross-file namespace merging: every declaration file the builder reads is a module, whose namespaces merge only within the file. + - api-contract gaps and residual, a third-party emitter returning repeated export paths: KTD1's planned third adapter is TypeScript 7.1's; the shipped adapters take export paths from the manifest's keys, which are unique. Owner: the unit adding a third adapter. Trigger: that adapter. + - api-contract gap, canonical bytes for models differing only in the producer: R3 sets the producer equal by design and `serialise.integration.test.ts` pins canonical bytes. + - Security gap, a manifest naming a source outside the package: U12 D1 and ruling #5, pinned by the routing and e2e refusals. + - DeepSeek delta gap, a type test for the fixture's entry-list arity: a test of a test helper certifies nothing a consumer sees. + - Correctness residuals (NUL-delimited local keys, the namespace-body statement start, the resolver's unresolved names): C1 keeps keys disjoint; no consumer reads a namespace-body `statementStart`; after L2 a name that resolves nowhere refuses the model before messages are read through `analyse`. + - Maintainability residuals: the pruning rule is now named (`leadsToPrefix`, `isInvalidInsideMatch`); the two export-unwrapping frames are U20 K9's one-classifier ruling. +- **Follow-ups:** + - Testing gap, `tsc`'s non-empty exit text when the API reports no error: owner PR4, with U19's empty-output gap. Trigger: PR4's adversarial corpus, which drives `tsc` write failures. + - Security and performance residuals (closure size, accumulated names, host paths in refusal text, route integrity on unauthored packages): unchanged owners and triggers (U16, U19, U20). + - Learnings residual, `condition-branch-agreement` on built `dist`: the CI `build · attw` lane runs it on every head. +- **Preserved:** U11-U20 rulings and the PR1 counterexample record (CI run 38372869767, `∀x_TypeExprNesting_≤MaxDepth1`), not re-run. +- **Verification:** the Verification Contract gates at the final head, a DeepSeek delta lens over `5d32c3f..` the final head, and a full-path receipt (merge, validator and report leaves) at the final head. + +### U22. Rulings on the full-path review at `9854ba8` + +- **Goal:** every finding, testing gap and residual of the full-path `ce-code-review` at `9854ba8` (run `20261014-011118-9854ba8b`: ten reviewer roles, the learnings and ci-wall-clock checks, the DeepSeek peer and the DeepSeek delta lens over `5d32c3f..9854ba8`, both `openrouter/deepseek/deepseek-v4.1-flash`) has a ruling. The learnings reviewer adjudicated all 52 pack rules (28 honored, 24 not reached, none contradicted); the ci-wall-clock pack is not triggered. Correctness, performance, reliability, security, api-contract, project-standards and the DeepSeek peer reported no finding. +- **Requirements:** R3, R6, R8, R16; KTD1, KTD6, KTD11. +- **Rulings, fixes** (each scenario fails at the named commit, unless marked as pinning existing behaviour): + - M1 (testing P2, then a defect the pin found): a class member's `protected` and `abstract` modifiers were dropped, so `protected p` modelled as a public `p`, an abstract method as a concrete one and a protected constructor as `constructor`; public-to-protected and concrete-to-abstract changes were invisible to the diff. Following K3 and the `static` prefix, a member is named with its modifiers in TypeScript's written order, `protected`, `static`, `abstract` (`1e61902`). Builder scenario; fails at `eec0927`. The rest of the gap is pinned: static fields, auto-accessors and accessor pairs, a private field and a static block holding nothing (`eec0927`). + - M2 (DeepSeek delta P3): an export list reached through `export *` that names no declaration was refused naming the entry, which holds no such statement. The refusal names the file whose export map lists the name (`5c6c2d1`). Refusals scenario; fails at `9854ba8`. + - M3 (adversarial P2): without TypeScript 7's platform package, which `typescript` installs as an optional dependency, every package needing the fallback is refused. The README states it; the refusal's one diagnostic carries `tsc`'s message naming the missing package (`42a57e7`; probed: the shim exits 1 with `Unable to resolve @typescript/typescript-linux-x64`). + - M4 (maintainability P3, two): the builder imports the graph's `statementsIn` instead of a copy (`5c6c2d1`); the resolver records each namespace path once, by start offset, instead of in two passes (`b88de2f`). No behaviour change. + - M5 (testing P2 and gaps): pins for canonical text of template literal, predicate, `asserts`, indexed-access, abstract construct and mixed-signature object types, and for a pattern export whose target directory does not exist (`eec0927`). All pass at `9854ba8`. +- **Rulings, rejected:** + - Correctness gap, `tsc`'s exit diagnostic has no scenario: no package-shaped input reaches it; M3 probes the environment fault that does, and U21's follow-up keeps write failures with PR4. + - Correctness gap, a `Note` in `route.diagnostics`: every decline that carries a `Note` (missing or outside entry, shared declaration, unresolved import) is also refused by the compiler (U12 D1, D2; ruling #5), so no analysis returns a `Fallback` route holding one. + - api-contract gap, decoding `AnalysisMessage` and `Route` with the exported codecs: `AnalysisMessage`'s codec is annotated `Schema.Codec` and `Route`'s type is `typeof Route.Type`, so a codec cannot drift from its type without `pnpm typecheck` failing. + - Project-standards gap, `FIVE_IDS` beside `ANALYSIS_MESSAGE_IDS`: the fixture's list selects which upstream messages the oracle compares, and an expected value must not come from the subject (boundary-testing pack); a sixth id is a public change that brings its own scenario. + - Security gap, a `__proto__` source condition: `conditionValue` keeps only a `Path` target, so a prototype read names no entry. + - DeepSeek peer residual, `resolveMessages` called alone narrows where `analyse` refuses: `analyse` builds the model first (U21 L2), and the public route is `analyse`. +- **Follow-ups:** + - Security and adversarial residuals, entries per directory are not bounded by the walk's 1024-listing bound, and a source symlinked out of the package is reported as unreadable: owner PR4. Trigger: PR4's adversarial corpus. + - Adversarial residual and gap, a workspace root marked other than by `pnpm-workspace.yaml`: owner PR6. Trigger: the consumer journey, which adds a non-pnpm host. + - api-contract residuals, `AnalysisMessage` carries no version and `Verdict` became an interface beside a schema constant: owner PR4, which moves messages into the model under a `schemaVersion` bump. The package is `0.0.0` and unpublished. + - Reliability residual, native parser throws, and DeepSeek residuals on route equality for multi-entry packages and on the layer's TypeScript import: unchanged owners and triggers (U16, U18, U20). + - Performance residuals: owner PR5, as U18 and U20. +- **Preserved:** U11-U21 rulings and the PR1 counterexample record (CI run 38372869767, `∀x_TypeExprNesting_≤MaxDepth1`), not re-run. +- **Verification:** the Verification Contract gates at the final head, a DeepSeek delta lens over `9854ba8..` the final head, and a full-path receipt (merge, validator and report leaves) at the final head. + +### U23. Rulings on the full-path review at `ea58038` + +- **Goal:** every finding, testing gap and residual of the full-path `ce-code-review` at `ea58038` (run `20261014-013421-ea58038c`: ten reviewer roles, the learnings and ci-wall-clock checks, the DeepSeek peer and the DeepSeek delta lens over `9854ba8..ea58038`, both `openrouter/deepseek/deepseek-v4.1-flash`) has a ruling. The learnings reviewer adjudicated all 52 pack rules (27 honored, 25 not reached, none contradicted); the ci-wall-clock pack is not triggered. Correctness, performance, reliability, security, adversarial, project-standards and the DeepSeek peer reported no finding. +- **Requirements:** R6, R16; KTD1, KTD6. +- **Rulings, fixes** (each scenario fails at `ea58038`, unless marked as pinning existing behaviour): + - N1 (DeepSeek delta P3): M2 named the listing file for an export list naming no declaration, but an `export { x } from 'lodash'` reached through the entry's `export *` was still refused naming the entry. Both refusals name the file whose export map lists the name (`8c1bc4e`). Refusals scenario. + - N2 (testing P2, correctness gap): two of M1's three abstract arms had no scenario. The modifier scenario holds an abstract property, an abstract auto-accessor and a private static member (`4ce0980`); it fails when either arm is removed. + - N3 (api-contract P3): U1's isolatedDeclarations refactor dropped `KeywordName`'s doc comment; it is restored (`a40e552`). No other doc comment was removed from `src` outside the front-end (checked over the diff). +- **Rulings, rejected:** + - DeepSeek delta P3, a getter and setter differing in modifier split into two members: TypeScript admits only a public getter with a restricted setter (TS2808 refuses a getter less accessible than its setter, TS2676 mismatched abstractness; probed on 7.0.2). Two members say that exactly: `w` reads publicly and only a subclass writes `protected w`. Merging would publish a public writable `w`. Pinned (`69173f8`). + - DeepSeek delta P3, M3 lacks a scenario and the TypeScript 7 API may load the native compiler at import: probed with `typescript` 7.0.2 copied without its platform packages, `typescript/unstable/async` imports and `new API()` constructs, so the package loads and only the fallback is refused. A committed scenario would remove an optional dependency from the shared store; the fault is an install fault, not package input. + - Maintainability P2, fold `exportOrigin` into the export walk: the walk returns the first star target that resolves a name, the origin the first that lists it; one walk would refuse a package whose first star target lists a name unresolved while a later one resolves it. + - Testing gaps, `static abstract` and `static protected`: TypeScript refuses both (TS1243, TS1029), so the written order is canonical. + - Security gap, the manifest's `recursionBudget`: it bounds generated law values, not decoding. + - Correctness residual, `exportOrigin` through a re-export chain names the entry: the entry holds the `export { X } from` statement, which is the listing the refusal names. +- **Follow-ups:** + - Security residuals, `PackageSource.entryPoints` carries a manifest's path before an emitter confines it, and the fallback reads what the package tsconfig extends: owner the unit adding a third adapter (KTD1), which must confine entries as both shipped adapters do. Trigger: that adapter. + - Adversarial gaps and DeepSeek residuals, unparseable, extends-missing and solution-style tsconfigs, case-mismatched specifiers, the oracle's stand-in tsconfig and its sorted comparison: owner PR4. Trigger: PR4's adversarial corpus and differential lane. + - Adversarial residual, `analyse` requires the compiler layer for a fast-path-only consumer: owner PR6. Trigger: the CLI, which composes the layers. + - api-contract residuals, unversioned `Declarations`, `AnalysisMessage` and `Route`, and `Verdict` no longer extendable as a class: U22's owner, PR4. + - Reliability and performance residuals: unchanged owners and triggers (U20, U22). +- **Preserved:** U11-U22 rulings and the PR1 counterexample record (CI run 38372869767, `∀x_TypeExprNesting_≤MaxDepth1`), not re-run. +- **Verification:** the Verification Contract gates at the final head, a DeepSeek delta lens over `ea58038..` the final head, and a full-path receipt (merge, validator and report leaves) at the final head. + +### U24. Rulings on the full-path review at `fa6bca2` + +- **Goal:** every finding, testing gap and residual of the full-path `ce-code-review` at `fa6bca2` (run `20261014-014547-fa6bca20`: ten reviewer roles, the learnings and ci-wall-clock checks, the DeepSeek peer and the DeepSeek delta lens over `ea58038..fa6bca2`, both `openrouter/deepseek/deepseek-v4.1-flash`) has a ruling. The learnings reviewer adjudicated all 52 pack rules (22 honored, 30 not reached, none contradicted); the ci-wall-clock pack is not triggered. Testing, performance, security, adversarial, api-contract, project-standards and the DeepSeek peer reported no finding. +- **Requirements:** R1, R6, R16; KTD5, KTD6. +- **Rulings, fixes** (each scenario fails at `fa6bca2`, unless marked as pinning existing behaviour): + - O1 (DeepSeek delta P3): N1 named the first star target listing a name, so with `a.d.ts` listing `x` unresolved before `b.d.ts` re-exporting it from `lodash`, the refusal paired `a.d.ts` with a re-export it does not hold. An external symbol carries the file whose binding resolved it, and that refusal names it; the unresolved-name refusal keeps naming the first listing file (`d08f648`). Refusals scenario. + - O2 (testing gap): U1 moved `VerdictTypeId` onto `Verdict.prototype` and only a probe read it. A verdict decoded from the cell's document holds the brand (`84ef8d2`); the scenario fails when the prototype brand is removed. + - O3 (correctness P2, confidence 50): a conditions object under the source condition names no entry. KTD5 reads the condition where the family writes it, with a path for its value; the README states it beside the nested and array forms, and the reader scenario pins it (`ecbf999`). Pins existing behaviour. +- **Rulings, rejected:** + - Reliability P3, an empty compile fails with a raw `NotFound`: not reachable from package input. Probed with a fallback package whose tsconfig names only a hand-written `index.d.ts` that re-exports an unannotated `util.ts`, with and without a `util.d.ts` beside it: `tsc` follows the import, emits `util.d.ts`, and both build a model. + - Maintainability P3, a shared module for the NUL-delimited keys: each key is private to its module and of a different arity, and C1's disjointness holds per key; one module would couple five for one template string. + - Reliability residual, `tsconfig.build.json` sets `stripInternal: true` while the fast path does not: a package the fast path declines takes the fallback by design, and the routing scenario pins this package on the fast path. + - Testing gap, interrupting the compiler adapter: its temp directories and child are released by `Effect.scoped` and `makeTempDirectoryScoped`, so a test would test Effect's scope. + - Project-standards gap, no CI gate for the locale ban: a new gate needs operator approval (GATE1); the Verification Contract runs the `git grep` at each head. + - Testing and api-contract gaps on the encoded shape of `Route` and `AnalysisMessage`: U22's ruling (codecs typed from their schemas). +- **Follow-ups:** + - Maintainability P3, the profile harness copies `analyse`'s routing: owner PR5 with the harness's other copies (U18). Trigger: PR5 moving the profile into its benchmark lane. + - Adversarial residuals, a `Fallback` route's `Reported` diagnostic may be oxc's, not `tsc`'s, and text-changing options reaching the fallback through `extends`: owner PR4. Trigger: the report renderer and the adversarial corpus. + - Correctness gaps, an `@internal` item inside a namespace and an unsupported enum member name; security gap, a pattern walk through a directory symlink out of the package; DeepSeek gap, a hand-written entry reaching further declarations: owner PR4, as U20 and U22. Trigger: PR4's adversarial corpus and differential lane. + - Reliability, performance and security residuals: unchanged owners and triggers (U20, U22, U23). +- **Preserved:** U11-U23 rulings and the PR1 counterexample record (CI run 38372869767, `∀x_TypeExprNesting_≤MaxDepth1`), not re-run. +- **Verification:** the Verification Contract gates at the final head, a DeepSeek delta lens over `fa6bca2..` the final head, and a full-path receipt (merge, validator and report leaves) at the final head. + +### U25. Rulings on the full-path review at `c25bf9a` + +- **Goal:** every finding, testing gap and residual of the full-path `ce-code-review` at `c25bf9a` (run `20261014-020001-c25bf9ae`: ten reviewer roles, the learnings and ci-wall-clock checks, the DeepSeek peer and the DeepSeek delta lens over `fa6bca2..c25bf9a`, both `openrouter/deepseek/deepseek-v4.1-flash`) has a ruling. The learnings reviewer adjudicated all 52 pack rules (26 honored, 26 not reached, none contradicted); the ci-wall-clock pack is not triggered. Correctness, security, api-contract, project-standards and both DeepSeek lenses reported no finding. +- **Requirements:** R1, R6, R16; KTD1, KTD5, KTD6. +- **Rulings, fixes** (each pins existing behaviour unless marked): + - P1 (maintainability P2): the resolver's `typeParameterNamesOf` repeated `typeParameterNames`; it now calls the printer's helper (`5054d04`). Analysis scenarios unchanged. + - P2 (testing P3): the compiler's refusal of a missing entry source had no scenario beside its pinned sibling. The e2e refusal journey analyses a package whose entry names no file: the fast path declines and the compiler refuses with a `Note` naming `src/mod.ts` (`f701dc7`). + - P3 (reliability P2): `oxc-parser` and `oxc-transform` load their native bindings at import (probed: both throw `Cannot find native binding` without them), so importing this package fails without them. The README states it beside the TypeScript 7 platform-package paragraph (`9ae4420`). Moving the imports behind a lazy load is not taken: a front-end without its parser has no route, and the import failure names the cause. + - P4 (maintainability residual): the printer's keyword table is a record keyed by the model's `KeywordName`, and its entries are derived from `KeywordName.literals`, so a keyword the model gains and the printer lacks is a type error instead of a runtime refusal (`ce351e3`; probed: dropping `void` fails `tsc` with TS2339). + - P5 (reliability gap): a `.ts` candidate that is a directory is skipped for the `.tsx` source. The U14 ruling rested on an orchestrator probe; a routing scenario now pins it (`b22c2b5`). + - P6 (correctness residual): a pattern target with two stars names no entry; the README named only the two-star key. The README states both and the reader scenario pins both (`ccd067e`). +- **Rulings, rejected:** + - Adversarial P3 (confidence 50), an unreadable source escapes as `PlatformError`: re-raises the U14 unreadable-source ruling (an environment fault, not a package shape; `tsc` cannot read it either). + - DeepSeek delta gap, the `direct` row of O1's scenario also passed at `fa6bca2`: the scenario's fail-before rests on its `behindUnresolved` row, as U24 records; the `direct` row pins the unchanged case. + - DeepSeek delta residual, the two refusal branches of `itemsForExport` name different files: U24 O1 rules it (the resolving file for an external re-export, the first listing file for a name that names no declaration). +- **Follow-ups:** + - Performance P2, `replaceNames` scans the rename map once per reference: owner PR5 with the other performance residuals (U20); the committed profile puts `build:model` at 274.59 ms over 40 packages. Trigger: PR5's benchmark lane measuring a large synthetic package. + - DeepSeek peer residuals and gaps, a reference through a namespace import (`import * as NS`, `NS.Hidden`) draws no `ae-forgotten-export`, and a package addressing itself through `#imports` or `paths` reads as external: owner PR4 with the resolver's namespace-member residual (U18). Trigger: PR4's differential lane. + - Correctness gaps, route equality on a module with no export statement and hidden-name collisions with nested namespace items; adversarial gap, a closure import whose `.ts` and hand-written `.d.ts` share a stem: owner PR4. Trigger: PR4's adversarial corpus. + - Reliability residual, native bindings executed only on linux-x64-gnu: owner PR6 with the non-POSIX host lane (U16). Trigger: the consumer journey adding a host. + - Security, performance, api-contract and adversarial residuals: unchanged owners and triggers (U20, U22, U23, U24). +- **Preserved:** U11-U24 rulings and the PR1 counterexample record (CI run 38372869767, `∀x_TypeExprNesting_≤MaxDepth1`), not re-run. +- **Verification:** the Verification Contract gates at the final head, a DeepSeek delta lens over `c25bf9a..` the final head, and a full-path receipt (merge, validator and report leaves) at the final head. + +--- + +## Verification Contract + +| Gate | Command or evidence | Proves | +|---|---|---| +| Isolated-clean package | `nix develop --command sh -c 'cd packages/api-extractor && tsc -p tsconfig.build.json --noEmit'` reports no TS9xxx; CI `build · attw` lane | R15 | +| Targeted tests | `nix develop --command pnpm turbo run test --filter=@systemfsoftware/api-extractor --filter=api-extractor-e2e` | R1-R3, R5, R7, R9-R11, R16 | +| Lint and typecheck | `nix develop --command pnpm lint` and `pnpm typecheck` (0 diagnostics) | AGENTS.md preset rule | +| Build | `nix develop --command pnpm build` and `pnpm --filter @systemfsoftware/api-extractor attw` | R4, KTD8 | +| Host digest | `node --conditions=@systemfsoftware/source apps/api-extractor-e2e/host-digest.ts \| sha256sum` gives `e4478f03…6da2` | output bytes unchanged | +| Profile before choice | `git merge-base --is-ancestor ` exits 0, and the two commits differ | R12, R14 | +| Profile reproduction | `node --conditions=@systemfsoftware/source apps/api-extractor-profile/profile.ts ` on the corpus at `732f66a0c8` | R13 | +| No locale APIs | `git grep -nE 'toLocale\|localeCompare\|Intl\\.' -- packages/api-extractor/src` prints nothing | origin R5 | +| CI | `ci.yml` and Changeset Check green on the exact PR head | origin R22 | + +No mutation run, local or in CI (origin R23). + +--- + +## Definition of Done + +- U1-U25 verified; every Verification Contract gate passes locally and CI is green on the exact PR head. +- The commit adding the profile precedes the commit adding its `Decision` section, which records the emitter choice with the profile's numbers. +- Report-only `ce-code-review` findings carry a ruling table, and only operator-accepted fixes are applied. +- The two delivery questions are ruled by the operator before the PR opens. +- No scratch, abandoned-attempt code or throwaway probe remains in the diff. diff --git a/docs/profiles/2026-10-12-front-end-phases-built.json b/docs/profiles/2026-10-12-front-end-phases-built.json new file mode 100644 index 0000000..0a495ba --- /dev/null +++ b/docs/profiles/2026-10-12-front-end-phases-built.json @@ -0,0 +1,3160 @@ +{ + "corpus": { + "repository": "https://github.com/systemfsoftware/systemfsoftware", + "commit": "732f66a0c85819b2005f9e0930a595bc4de91ee1" + }, + "ownPackage": { + "repository": "https://github.com/systemfsoftware/api-extractor-effect", + "commit": "d4d55d3cbbf0d412114d00da3c398dbb7f3938e4" + }, + "host": { + "cpu": "AMD Ryzen 9 9950X 16-Core Processor", + "cores": 32, + "platform": "linux-x64", + "node": "v24.21.0" + }, + "tools": { + "oxc-transform": "0.150.0", + "oxc-parser": "0.150.0", + "typescript": "7.0.2", + "typescript-5.9": "5.9.3" + }, + "runs": { + "cold": 1, + "warm": 5, + "emit:tsc5.9.3": "cold only" + }, + "totals": { + "discover": 420.71, + "emit:oxc-transform": 31.1, + "emit:transpileDeclaration": 1418.48, + "emit:tsc7": 13646.71, + "emit:tsc5.9.3": 93836.87, + "parse:oxc-parser": 68.45, + "parse:createSourceFile": 66.95, + "build:model": 414.23, + "resolve:messages": 190.24 + }, + "packages": [ + { + "name": "@systemfsoftware/api-extractor", + "version": "0.0.0", + "origin": "api-extractor-effect", + "entryPoints": 1, + "sourceFiles": 34, + "sourceBytes": 237788, + "declarationFiles": 34, + "declarationStatements": 344, + "clean": { + "oxc-transform": { + "diagnostics": 0, + "clean": true + }, + "transpileDeclaration": { + "diagnostics": 0, + "clean": true + }, + "tsc --isolatedDeclarations": { + "diagnostics": 0, + "clean": true + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 34, + "differing": 3, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "FastPath", + "outcome": "model", + "items": 3, + "locals": 70, + "messages": 119 + }, + "phases": { + "discover": { + "coldMs": 46.12, + "warmMedianMs": 59.85 + }, + "emit:oxc-transform": { + "coldMs": 13.78, + "warmMedianMs": 1.79 + }, + "emit:transpileDeclaration": { + "coldMs": 213.63, + "warmMedianMs": 97.52 + }, + "emit:tsc7": { + "coldMs": 416.43, + "warmMedianMs": 464.81 + }, + "emit:tsc5.9.3": { + "coldMs": 2996.49, + "warmMedianMs": 2996.49 + }, + "parse:oxc-parser": { + "coldMs": 2.32, + "warmMedianMs": 1.91 + }, + "parse:createSourceFile": { + "coldMs": 5.28, + "warmMedianMs": 4.17 + }, + "build:model": { + "coldMs": 46.14, + "warmMedianMs": 32.27 + }, + "resolve:messages": { + "coldMs": 15.93, + "warmMedianMs": 14.15 + } + } + }, + { + "name": "@systemfsoftware/effect-atom", + "version": "6.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 35, + "sourceBytes": 388509, + "declarationFiles": 35, + "declarationStatements": 667, + "clean": { + "oxc-transform": { + "diagnostics": 145, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 145, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 184, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 35, + "differing": 1, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "Fallback", + "outcome": "UnsupportedDeclaration: src/atom-http-api.service.d.ts: `declare global` is not supported", + "items": 0, + "locals": 0, + "messages": 0 + }, + "phases": { + "discover": { + "coldMs": 57.35, + "warmMedianMs": 22.54 + }, + "emit:oxc-transform": { + "coldMs": 3.95, + "warmMedianMs": 3.95 + }, + "emit:transpileDeclaration": { + "coldMs": 208.66, + "warmMedianMs": 142.47 + }, + "emit:tsc7": { + "coldMs": 545.38, + "warmMedianMs": 599.92 + }, + "emit:tsc5.9.3": { + "coldMs": 3468.25, + "warmMedianMs": 3468.25 + }, + "parse:oxc-parser": { + "coldMs": 11.94, + "warmMedianMs": 7.5 + }, + "parse:createSourceFile": { + "coldMs": 13.62, + "warmMedianMs": 9.23 + }, + "build:model": { + "coldMs": 4.36, + "warmMedianMs": 2.93 + }, + "resolve:messages": { + "coldMs": 7.06, + "warmMedianMs": 3.27 + } + } + }, + { + "name": "@systemfsoftware/effect-atom-react", + "version": "2.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 10, + "sourceBytes": 45423, + "declarationFiles": 10, + "declarationStatements": 86, + "clean": { + "oxc-transform": { + "diagnostics": 20, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 20, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 29, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 10, + "differing": 0, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "Fallback", + "outcome": "model", + "items": 1, + "locals": 5, + "messages": 27 + }, + "phases": { + "discover": { + "coldMs": 4.3, + "warmMedianMs": 3.37 + }, + "emit:oxc-transform": { + "coldMs": 0.74, + "warmMedianMs": 0.41 + }, + "emit:transpileDeclaration": { + "coldMs": 22.96, + "warmMedianMs": 20.95 + }, + "emit:tsc7": { + "coldMs": 679.82, + "warmMedianMs": 673.56 + }, + "emit:tsc5.9.3": { + "coldMs": 5016.37, + "warmMedianMs": 5016.37 + }, + "parse:oxc-parser": { + "coldMs": 1.14, + "warmMedianMs": 0.67 + }, + "parse:createSourceFile": { + "coldMs": 2.36, + "warmMedianMs": 1.57 + }, + "build:model": { + "coldMs": 10.72, + "warmMedianMs": 8.29 + }, + "resolve:messages": { + "coldMs": 2.23, + "warmMedianMs": 2.1 + } + } + }, + { + "name": "@systemfsoftware/effect-daemon-cluster", + "version": "1.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 5, + "sourceBytes": 10261, + "declarationFiles": 5, + "declarationStatements": 35, + "clean": { + "oxc-transform": { + "diagnostics": 1, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 1, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 1, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 5, + "differing": 1, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "Fallback", + "outcome": "model", + "items": 1, + "locals": 6, + "messages": 18 + }, + "phases": { + "discover": { + "coldMs": 4.17, + "warmMedianMs": 2.91 + }, + "emit:oxc-transform": { + "coldMs": 0.49, + "warmMedianMs": 0.16 + }, + "emit:transpileDeclaration": { + "coldMs": 12.68, + "warmMedianMs": 7.59 + }, + "emit:tsc7": { + "coldMs": 375.23, + "warmMedianMs": 374.65 + }, + "emit:tsc5.9.3": { + "coldMs": 2501.52, + "warmMedianMs": 2501.52 + }, + "parse:oxc-parser": { + "coldMs": 0.41, + "warmMedianMs": 0.21 + }, + "parse:createSourceFile": { + "coldMs": 0.61, + "warmMedianMs": 0.14 + }, + "build:model": { + "coldMs": 2.73, + "warmMedianMs": 3.01 + }, + "resolve:messages": { + "coldMs": 1.55, + "warmMedianMs": 1.59 + } + } + }, + { + "name": "@systemfsoftware/effect-daemon-conformance", + "version": "1.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 15, + "sourceBytes": 50561, + "declarationFiles": 15, + "declarationStatements": 146, + "clean": { + "oxc-transform": { + "diagnostics": 47, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 47, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 96, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 15, + "differing": 0, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "Fallback", + "outcome": "model", + "items": 1, + "locals": 38, + "messages": 61 + }, + "phases": { + "discover": { + "coldMs": 8.61, + "warmMedianMs": 5.23 + }, + "emit:oxc-transform": { + "coldMs": 0.87, + "warmMedianMs": 0.72 + }, + "emit:transpileDeclaration": { + "coldMs": 23.11, + "warmMedianMs": 21.78 + }, + "emit:tsc7": { + "coldMs": 261.68, + "warmMedianMs": 290.61 + }, + "emit:tsc5.9.3": { + "coldMs": 2360.78, + "warmMedianMs": 2360.78 + }, + "parse:oxc-parser": { + "coldMs": 2.1, + "warmMedianMs": 1.43 + }, + "parse:createSourceFile": { + "coldMs": 2.59, + "warmMedianMs": 1.27 + }, + "build:model": { + "coldMs": 23.62, + "warmMedianMs": 26.27 + }, + "resolve:messages": { + "coldMs": 7.07, + "warmMedianMs": 6.2 + } + } + }, + { + "name": "@systemfsoftware/effect-daemon-microvm", + "version": "1.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 9, + "sourceBytes": 20513, + "declarationFiles": 9, + "declarationStatements": 71, + "clean": { + "oxc-transform": { + "diagnostics": 21, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 21, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 28, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 9, + "differing": 1, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "Fallback", + "outcome": "model", + "items": 1, + "locals": 4, + "messages": 11 + }, + "phases": { + "discover": { + "coldMs": 7.78, + "warmMedianMs": 9.12 + }, + "emit:oxc-transform": { + "coldMs": 0.67, + "warmMedianMs": 0.28 + }, + "emit:transpileDeclaration": { + "coldMs": 14.08, + "warmMedianMs": 15.99 + }, + "emit:tsc7": { + "coldMs": 621.71, + "warmMedianMs": 536.51 + }, + "emit:tsc5.9.3": { + "coldMs": 3944.73, + "warmMedianMs": 3944.73 + }, + "parse:oxc-parser": { + "coldMs": 0.95, + "warmMedianMs": 0.32 + }, + "parse:createSourceFile": { + "coldMs": 0.63, + "warmMedianMs": 0.32 + }, + "build:model": { + "coldMs": 3.2, + "warmMedianMs": 2.8 + }, + "resolve:messages": { + "coldMs": 1.5, + "warmMedianMs": 1.18 + } + } + }, + { + "name": "@systemfsoftware/effect-daemon-process", + "version": "1.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 9, + "sourceBytes": 19021, + "declarationFiles": 9, + "declarationStatements": 64, + "clean": { + "oxc-transform": { + "diagnostics": 22, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 22, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 29, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 9, + "differing": 1, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "Fallback", + "outcome": "model", + "items": 1, + "locals": 0, + "messages": 7 + }, + "phases": { + "discover": { + "coldMs": 9.37, + "warmMedianMs": 2.24 + }, + "emit:oxc-transform": { + "coldMs": 0.43, + "warmMedianMs": 0.2 + }, + "emit:transpileDeclaration": { + "coldMs": 11.86, + "warmMedianMs": 12.73 + }, + "emit:tsc7": { + "coldMs": 717.17, + "warmMedianMs": 553.31 + }, + "emit:tsc5.9.3": { + "coldMs": 3235.75, + "warmMedianMs": 3235.75 + }, + "parse:oxc-parser": { + "coldMs": 0.98, + "warmMedianMs": 0.34 + }, + "parse:createSourceFile": { + "coldMs": 0.62, + "warmMedianMs": 0.32 + }, + "build:model": { + "coldMs": 1.35, + "warmMedianMs": 0.94 + }, + "resolve:messages": { + "coldMs": 0.57, + "warmMedianMs": 0.59 + } + } + }, + { + "name": "@systemfsoftware/effect-daemon-socket", + "version": "1.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 12, + "sourceBytes": 35576, + "declarationFiles": 12, + "declarationStatements": 101, + "clean": { + "oxc-transform": { + "diagnostics": 19, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 19, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 31, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 12, + "differing": 0, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "Fallback", + "outcome": "model", + "items": 1, + "locals": 2, + "messages": 25 + }, + "phases": { + "discover": { + "coldMs": 38.92, + "warmMedianMs": 14.47 + }, + "emit:oxc-transform": { + "coldMs": 1, + "warmMedianMs": 0.34 + }, + "emit:transpileDeclaration": { + "coldMs": 27.63, + "warmMedianMs": 15.86 + }, + "emit:tsc7": { + "coldMs": 508.4, + "warmMedianMs": 530.3 + }, + "emit:tsc5.9.3": { + "coldMs": 3545.59, + "warmMedianMs": 3545.59 + }, + "parse:oxc-parser": { + "coldMs": 1.57, + "warmMedianMs": 0.65 + }, + "parse:createSourceFile": { + "coldMs": 1.08, + "warmMedianMs": 0.81 + }, + "build:model": { + "coldMs": 5.34, + "warmMedianMs": 7.22 + }, + "resolve:messages": { + "coldMs": 3.12, + "warmMedianMs": 2.51 + } + } + }, + { + "name": "@systemfsoftware/effect-daemon-spec", + "version": "6.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 22, + "sourceBytes": 138120, + "declarationFiles": 22, + "declarationStatements": 400, + "clean": { + "oxc-transform": { + "diagnostics": 138, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 138, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 279, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 22, + "differing": 4, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "Fallback", + "outcome": "model", + "items": 1, + "locals": 41, + "messages": 96 + }, + "phases": { + "discover": { + "coldMs": 51.29, + "warmMedianMs": 27.43 + }, + "emit:oxc-transform": { + "coldMs": 12.58, + "warmMedianMs": 1.56 + }, + "emit:transpileDeclaration": { + "coldMs": 89.69, + "warmMedianMs": 76.72 + }, + "emit:tsc7": { + "coldMs": 657.77, + "warmMedianMs": 373.01 + }, + "emit:tsc5.9.3": { + "coldMs": 2723.85, + "warmMedianMs": 2723.85 + }, + "parse:oxc-parser": { + "coldMs": 9.92, + "warmMedianMs": 10.39 + }, + "parse:createSourceFile": { + "coldMs": 8.38, + "warmMedianMs": 6.75 + }, + "build:model": { + "coldMs": 48.72, + "warmMedianMs": 34.21 + }, + "resolve:messages": { + "coldMs": 43.88, + "warmMedianMs": 36.26 + } + } + }, + { + "name": "@systemfsoftware/discern", + "version": "1.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 40, + "sourceBytes": 255997, + "declarationFiles": 40, + "declarationStatements": 732, + "clean": { + "oxc-transform": { + "diagnostics": 144, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 144, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 355, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 40, + "differing": 14, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "Fallback", + "outcome": "model", + "items": 1, + "locals": 66, + "messages": 266 + }, + "phases": { + "discover": { + "coldMs": 99.17, + "warmMedianMs": 56.66 + }, + "emit:oxc-transform": { + "coldMs": 5.63, + "warmMedianMs": 4.15 + }, + "emit:transpileDeclaration": { + "coldMs": 184.88, + "warmMedianMs": 157.12 + }, + "emit:tsc7": { + "coldMs": 698.98, + "warmMedianMs": 655.53 + }, + "emit:tsc5.9.3": { + "coldMs": 3615.29, + "warmMedianMs": 3615.29 + }, + "parse:oxc-parser": { + "coldMs": 11.22, + "warmMedianMs": 8.76 + }, + "parse:createSourceFile": { + "coldMs": 9.96, + "warmMedianMs": 8.79 + }, + "build:model": { + "coldMs": 86.03, + "warmMedianMs": 73.26 + }, + "resolve:messages": { + "coldMs": 35.47, + "warmMedianMs": 36.27 + } + } + }, + { + "name": "@systemfsoftware/effect-cell-types", + "version": "12.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 7, + "sourceBytes": 68241, + "declarationFiles": 7, + "declarationStatements": 187, + "clean": { + "oxc-transform": { + "diagnostics": 1, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 1, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 2, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 7, + "differing": 1, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "Fallback", + "outcome": "model", + "items": 5, + "locals": 88, + "messages": 141 + }, + "phases": { + "discover": { + "coldMs": 11.59, + "warmMedianMs": 5.98 + }, + "emit:oxc-transform": { + "coldMs": 8.25, + "warmMedianMs": 0.82 + }, + "emit:transpileDeclaration": { + "coldMs": 42.22, + "warmMedianMs": 31.01 + }, + "emit:tsc7": { + "coldMs": 384.28, + "warmMedianMs": 295.72 + }, + "emit:tsc5.9.3": { + "coldMs": 2482.58, + "warmMedianMs": 2482.58 + }, + "parse:oxc-parser": { + "coldMs": 6.96, + "warmMedianMs": 3.37 + }, + "parse:createSourceFile": { + "coldMs": 4, + "warmMedianMs": 4.06 + }, + "build:model": { + "coldMs": 78.22, + "warmMedianMs": 52.07 + }, + "resolve:messages": { + "coldMs": 26.41, + "warmMedianMs": 15.17 + } + } + }, + { + "name": "@systemfsoftware/effect-memfs", + "version": "6.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 19, + "sourceBytes": 63433, + "declarationFiles": 19, + "declarationStatements": 198, + "clean": { + "oxc-transform": { + "diagnostics": 68, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 68, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 104, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 19, + "differing": 1, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "Fallback", + "outcome": "model", + "items": 1, + "locals": 6, + "messages": 23 + }, + "phases": { + "discover": { + "coldMs": 10.64, + "warmMedianMs": 6.92 + }, + "emit:oxc-transform": { + "coldMs": 1.68, + "warmMedianMs": 1.04 + }, + "emit:transpileDeclaration": { + "coldMs": 48.92, + "warmMedianMs": 50.41 + }, + "emit:tsc7": { + "coldMs": 349.77, + "warmMedianMs": 307.14 + }, + "emit:tsc5.9.3": { + "coldMs": 2874.8, + "warmMedianMs": 2874.8 + }, + "parse:oxc-parser": { + "coldMs": 1.85, + "warmMedianMs": 1.32 + }, + "parse:createSourceFile": { + "coldMs": 1.4, + "warmMedianMs": 0.99 + }, + "build:model": { + "coldMs": 4.35, + "warmMedianMs": 11.16 + }, + "resolve:messages": { + "coldMs": 1.98, + "warmMedianMs": 1.96 + } + } + }, + { + "name": "@systemfsoftware/effect-microsandbox", + "version": "5.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 22, + "sourceBytes": 60081, + "declarationFiles": 22, + "declarationStatements": 271, + "clean": { + "oxc-transform": { + "diagnostics": 94, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 93, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 222, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 22, + "differing": 9, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "Fallback", + "outcome": "model", + "items": 1, + "locals": 22, + "messages": 77 + }, + "phases": { + "discover": { + "coldMs": 10.22, + "warmMedianMs": 7.24 + }, + "emit:oxc-transform": { + "coldMs": 1.87, + "warmMedianMs": 1.24 + }, + "emit:transpileDeclaration": { + "coldMs": 43.93, + "warmMedianMs": 42.07 + }, + "emit:tsc7": { + "coldMs": 376.69, + "warmMedianMs": 347.58 + }, + "emit:tsc5.9.3": { + "coldMs": 2526.87, + "warmMedianMs": 2526.87 + }, + "parse:oxc-parser": { + "coldMs": 3.87, + "warmMedianMs": 2.52 + }, + "parse:createSourceFile": { + "coldMs": 2.46, + "warmMedianMs": 2.14 + }, + "build:model": { + "coldMs": 33.51, + "warmMedianMs": 19.23 + }, + "resolve:messages": { + "coldMs": 10.46, + "warmMedianMs": 9.29 + } + } + }, + { + "name": "@systemfsoftware/effect-readiness", + "version": "1.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 19, + "sourceBytes": 29487, + "declarationFiles": 19, + "declarationStatements": 145, + "clean": { + "oxc-transform": { + "diagnostics": 53, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 53, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 83, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 19, + "differing": 1, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "Fallback", + "outcome": "model", + "items": 1, + "locals": 12, + "messages": 43 + }, + "phases": { + "discover": { + "coldMs": 5.56, + "warmMedianMs": 4.43 + }, + "emit:oxc-transform": { + "coldMs": 0.82, + "warmMedianMs": 0.48 + }, + "emit:transpileDeclaration": { + "coldMs": 26.49, + "warmMedianMs": 21.13 + }, + "emit:tsc7": { + "coldMs": 289.99, + "warmMedianMs": 278.2 + }, + "emit:tsc5.9.3": { + "coldMs": 2114.77, + "warmMedianMs": 2114.77 + }, + "parse:oxc-parser": { + "coldMs": 2.18, + "warmMedianMs": 1.6 + }, + "parse:createSourceFile": { + "coldMs": 1.86, + "warmMedianMs": 1.45 + }, + "build:model": { + "coldMs": 7.44, + "warmMedianMs": 7.7 + }, + "resolve:messages": { + "coldMs": 4.13, + "warmMedianMs": 3.83 + } + } + }, + { + "name": "@systemfsoftware/effect-spec-runtime", + "version": "1.0.1", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 6, + "sourceBytes": 33771, + "declarationFiles": 6, + "declarationStatements": 49, + "clean": { + "oxc-transform": { + "diagnostics": 1, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 1, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 2, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 6, + "differing": 0, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "FastPath", + "outcome": "model", + "items": 3, + "locals": 4, + "messages": 21 + }, + "phases": { + "discover": { + "coldMs": 11.96, + "warmMedianMs": 2.63 + }, + "emit:oxc-transform": { + "coldMs": 0.75, + "warmMedianMs": 0.28 + }, + "emit:transpileDeclaration": { + "coldMs": 11.03, + "warmMedianMs": 10.05 + }, + "emit:tsc7": { + "coldMs": 223.69, + "warmMedianMs": 294.08 + }, + "emit:tsc5.9.3": { + "coldMs": 1923.32, + "warmMedianMs": 1923.32 + }, + "parse:oxc-parser": { + "coldMs": 1.15, + "warmMedianMs": 0.47 + }, + "parse:createSourceFile": { + "coldMs": 0.83, + "warmMedianMs": 0.54 + }, + "build:model": { + "coldMs": 15.12, + "warmMedianMs": 15.68 + }, + "resolve:messages": { + "coldMs": 1.56, + "warmMedianMs": 1.63 + } + } + }, + { + "name": "@systemfsoftware/effect-gherkin-spec", + "version": "7.0.1", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 9, + "sourceBytes": 67914, + "declarationFiles": 9, + "declarationStatements": 138, + "clean": { + "oxc-transform": { + "diagnostics": 15, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 19, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 24, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 9, + "differing": 1, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "Fallback", + "outcome": "UnsupportedDeclaration: src/mod.d.ts: re-export of \"it\" from \"@systemfsoftware/vitest\" is not supported", + "items": 0, + "locals": 0, + "messages": 104 + }, + "phases": { + "discover": { + "coldMs": 10.67, + "warmMedianMs": 10.11 + }, + "emit:oxc-transform": { + "coldMs": 2.24, + "warmMedianMs": 0.66 + }, + "emit:transpileDeclaration": { + "coldMs": 33.64, + "warmMedianMs": 30.85 + }, + "emit:tsc7": { + "coldMs": 325.32, + "warmMedianMs": 247.12 + }, + "emit:tsc5.9.3": { + "coldMs": 2148.33, + "warmMedianMs": 2148.33 + }, + "parse:oxc-parser": { + "coldMs": 3.34, + "warmMedianMs": 2.58 + }, + "parse:createSourceFile": { + "coldMs": 2.3, + "warmMedianMs": 2.09 + }, + "build:model": { + "coldMs": 24.85, + "warmMedianMs": 13.01 + }, + "resolve:messages": { + "coldMs": 12.63, + "warmMedianMs": 9.31 + } + } + }, + { + "name": "@systemfsoftware/storybook-gherkin", + "version": "5.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 5, + "sourceBytes": 37687, + "declarationFiles": 5, + "declarationStatements": 92, + "clean": { + "oxc-transform": { + "diagnostics": 12, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 11, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 34, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 5, + "differing": 1, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "Fallback", + "outcome": "model", + "items": 47, + "locals": 7, + "messages": 66 + }, + "phases": { + "discover": { + "coldMs": 6.82, + "warmMedianMs": 5.04 + }, + "emit:oxc-transform": { + "coldMs": 1.05, + "warmMedianMs": 0.74 + }, + "emit:transpileDeclaration": { + "coldMs": 15.32, + "warmMedianMs": 14.19 + }, + "emit:tsc7": { + "coldMs": 174.42, + "warmMedianMs": 166.71 + }, + "emit:tsc5.9.3": { + "coldMs": 1064.4, + "warmMedianMs": 1064.4 + }, + "parse:oxc-parser": { + "coldMs": 1.44, + "warmMedianMs": 0.82 + }, + "parse:createSourceFile": { + "coldMs": 1.06, + "warmMedianMs": 0.72 + }, + "build:model": { + "coldMs": 8.19, + "warmMedianMs": 8.4 + }, + "resolve:messages": { + "coldMs": 3.73, + "warmMedianMs": 3.86 + } + } + }, + { + "name": "@systemfsoftware/npm-package", + "version": "1.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 11, + "sourceBytes": 31182, + "declarationFiles": 11, + "declarationStatements": 74, + "clean": { + "oxc-transform": { + "diagnostics": 29, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 29, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 41, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 11, + "differing": 0, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "Fallback", + "outcome": "model", + "items": 9, + "locals": 1, + "messages": 9 + }, + "phases": { + "discover": { + "coldMs": 11.99, + "warmMedianMs": 4.53 + }, + "emit:oxc-transform": { + "coldMs": 0.95, + "warmMedianMs": 0.57 + }, + "emit:transpileDeclaration": { + "coldMs": 21.54, + "warmMedianMs": 18.5 + }, + "emit:tsc7": { + "coldMs": 431.74, + "warmMedianMs": 227.32 + }, + "emit:tsc5.9.3": { + "coldMs": 1795.68, + "warmMedianMs": 1795.68 + }, + "parse:oxc-parser": { + "coldMs": 1.09, + "warmMedianMs": 0.43 + }, + "parse:createSourceFile": { + "coldMs": 1.25, + "warmMedianMs": 0.54 + }, + "build:model": { + "coldMs": 4.31, + "warmMedianMs": 3.06 + }, + "resolve:messages": { + "coldMs": 0.78, + "warmMedianMs": 0.59 + } + } + }, + { + "name": "@systemfsoftware/oxlint-plugin-cell-architecture", + "version": "5.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 43, + "sourceBytes": 133875, + "declarationFiles": 43, + "declarationStatements": 256, + "clean": { + "oxc-transform": { + "diagnostics": 65, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 65, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 71, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 43, + "differing": 19, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "Fallback", + "outcome": "model", + "items": 2, + "locals": 0, + "messages": 1 + }, + "phases": { + "discover": { + "coldMs": 20.32, + "warmMedianMs": 14.52 + }, + "emit:oxc-transform": { + "coldMs": 1.37, + "warmMedianMs": 1.03 + }, + "emit:transpileDeclaration": { + "coldMs": 73.87, + "warmMedianMs": 58.88 + }, + "emit:tsc7": { + "coldMs": 361.73, + "warmMedianMs": 363.99 + }, + "emit:tsc5.9.3": { + "coldMs": 2209.8, + "warmMedianMs": 2209.8 + }, + "parse:oxc-parser": { + "coldMs": 2.7, + "warmMedianMs": 1.75 + }, + "parse:createSourceFile": { + "coldMs": 2.18, + "warmMedianMs": 1.27 + }, + "build:model": { + "coldMs": 2.01, + "warmMedianMs": 0.98 + }, + "resolve:messages": { + "coldMs": 0.87, + "warmMedianMs": 0.25 + } + } + }, + { + "name": "@systemfsoftware/oxlint-plugin-dmmf-workflow", + "version": "7.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 19, + "sourceBytes": 106678, + "declarationFiles": 19, + "declarationStatements": 161, + "clean": { + "oxc-transform": { + "diagnostics": 65, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 65, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 65, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 19, + "differing": 8, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "Fallback", + "outcome": "model", + "items": 2, + "locals": 0, + "messages": 1 + }, + "phases": { + "discover": { + "coldMs": 8.92, + "warmMedianMs": 6.61 + }, + "emit:oxc-transform": { + "coldMs": 1.72, + "warmMedianMs": 0.64 + }, + "emit:transpileDeclaration": { + "coldMs": 31.54, + "warmMedianMs": 26.39 + }, + "emit:tsc7": { + "coldMs": 254.43, + "warmMedianMs": 290.23 + }, + "emit:tsc5.9.3": { + "coldMs": 1846.85, + "warmMedianMs": 1846.85 + }, + "parse:oxc-parser": { + "coldMs": 1.37, + "warmMedianMs": 0.71 + }, + "parse:createSourceFile": { + "coldMs": 1.31, + "warmMedianMs": 0.78 + }, + "build:model": { + "coldMs": 0.85, + "warmMedianMs": 0.64 + }, + "resolve:messages": { + "coldMs": 0.54, + "warmMedianMs": 0.17 + } + } + }, + { + "name": "@systemfsoftware/oxlint-plugin-effect-platform", + "version": "2.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 21, + "sourceBytes": 56432, + "declarationFiles": 21, + "declarationStatements": 126, + "clean": { + "oxc-transform": { + "diagnostics": 52, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 52, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 62, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 21, + "differing": 10, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "Fallback", + "outcome": "model", + "items": 3, + "locals": 0, + "messages": 2 + }, + "phases": { + "discover": { + "coldMs": 7.15, + "warmMedianMs": 3.83 + }, + "emit:oxc-transform": { + "coldMs": 0.8, + "warmMedianMs": 0.44 + }, + "emit:transpileDeclaration": { + "coldMs": 28.79, + "warmMedianMs": 26.23 + }, + "emit:tsc7": { + "coldMs": 287.95, + "warmMedianMs": 310.64 + }, + "emit:tsc5.9.3": { + "coldMs": 1621.41, + "warmMedianMs": 1621.41 + }, + "parse:oxc-parser": { + "coldMs": 1.01, + "warmMedianMs": 0.65 + }, + "parse:createSourceFile": { + "coldMs": 0.97, + "warmMedianMs": 0.5 + }, + "build:model": { + "coldMs": 1.22, + "warmMedianMs": 0.84 + }, + "resolve:messages": { + "coldMs": 0.41, + "warmMedianMs": 0.29 + } + } + }, + { + "name": "@systemfsoftware/oxlint-plugin-effect-schema", + "version": "6.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 23, + "sourceBytes": 139000, + "declarationFiles": 23, + "declarationStatements": 139, + "clean": { + "oxc-transform": { + "diagnostics": 42, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 42, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 45, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 23, + "differing": 10, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "Fallback", + "outcome": "model", + "items": 2, + "locals": 0, + "messages": 1 + }, + "phases": { + "discover": { + "coldMs": 32.97, + "warmMedianMs": 12.08 + }, + "emit:oxc-transform": { + "coldMs": 2.12, + "warmMedianMs": 0.88 + }, + "emit:transpileDeclaration": { + "coldMs": 42.21, + "warmMedianMs": 40.6 + }, + "emit:tsc7": { + "coldMs": 410.82, + "warmMedianMs": 459.41 + }, + "emit:tsc5.9.3": { + "coldMs": 2165.39, + "warmMedianMs": 2165.39 + }, + "parse:oxc-parser": { + "coldMs": 1.65, + "warmMedianMs": 0.97 + }, + "parse:createSourceFile": { + "coldMs": 7.02, + "warmMedianMs": 0.75 + }, + "build:model": { + "coldMs": 1.15, + "warmMedianMs": 0.66 + }, + "resolve:messages": { + "coldMs": 0.69, + "warmMedianMs": 0.16 + } + } + }, + { + "name": "@systemfsoftware/oxlint-plugin-test-discipline", + "version": "5.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 65, + "sourceBytes": 206741, + "declarationFiles": 65, + "declarationStatements": 343, + "clean": { + "oxc-transform": { + "diagnostics": 147, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 147, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 150, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 65, + "differing": 31, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "Fallback", + "outcome": "model", + "items": 2, + "locals": 0, + "messages": 1 + }, + "phases": { + "discover": { + "coldMs": 89.32, + "warmMedianMs": 43.88 + }, + "emit:oxc-transform": { + "coldMs": 3.79, + "warmMedianMs": 1.47 + }, + "emit:transpileDeclaration": { + "coldMs": 262.07, + "warmMedianMs": 105.85 + }, + "emit:tsc7": { + "coldMs": 342.13, + "warmMedianMs": 264.85 + }, + "emit:tsc5.9.3": { + "coldMs": 1687.84, + "warmMedianMs": 1687.84 + }, + "parse:oxc-parser": { + "coldMs": 3.34, + "warmMedianMs": 3 + }, + "parse:createSourceFile": { + "coldMs": 2.56, + "warmMedianMs": 2.55 + }, + "build:model": { + "coldMs": 2.51, + "warmMedianMs": 2.41 + }, + "resolve:messages": { + "coldMs": 1.87, + "warmMedianMs": 0.83 + } + } + }, + { + "name": "@systemfsoftware/oxlint-config-cell-architecture", + "version": "4.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 1, + "sourceBytes": 928, + "declarationFiles": 1, + "declarationStatements": 7, + "clean": { + "oxc-transform": { + "diagnostics": 0, + "clean": true + }, + "transpileDeclaration": { + "diagnostics": 0, + "clean": true + }, + "tsc --isolatedDeclarations": { + "diagnostics": 0, + "clean": true + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 1, + "differing": 0, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "FastPath", + "outcome": "model", + "items": 6, + "locals": 0, + "messages": 5 + }, + "phases": { + "discover": { + "coldMs": 0.74, + "warmMedianMs": 0.42 + }, + "emit:oxc-transform": { + "coldMs": 0.13, + "warmMedianMs": 0.01 + }, + "emit:transpileDeclaration": { + "coldMs": 2.38, + "warmMedianMs": 0.82 + }, + "emit:tsc7": { + "coldMs": 453.16, + "warmMedianMs": 391.96 + }, + "emit:tsc5.9.3": { + "coldMs": 2099.96, + "warmMedianMs": 2099.96 + }, + "parse:oxc-parser": { + "coldMs": 0.23, + "warmMedianMs": 0.04 + }, + "parse:createSourceFile": { + "coldMs": 0.21, + "warmMedianMs": 0.03 + }, + "build:model": { + "coldMs": 2.16, + "warmMedianMs": 0.76 + }, + "resolve:messages": { + "coldMs": 0.85, + "warmMedianMs": 0.35 + } + } + }, + { + "name": "@systemfsoftware/oxlint-config-dmmf", + "version": "2.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 1, + "sourceBytes": 3230, + "declarationFiles": 1, + "declarationStatements": 8, + "clean": { + "oxc-transform": { + "diagnostics": 0, + "clean": true + }, + "transpileDeclaration": { + "diagnostics": 0, + "clean": true + }, + "tsc --isolatedDeclarations": { + "diagnostics": 0, + "clean": true + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 1, + "differing": 0, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "FastPath", + "outcome": "model", + "items": 7, + "locals": 0, + "messages": 6 + }, + "phases": { + "discover": { + "coldMs": 0.47, + "warmMedianMs": 0.19 + }, + "emit:oxc-transform": { + "coldMs": 0.1, + "warmMedianMs": 0.01 + }, + "emit:transpileDeclaration": { + "coldMs": 1.55, + "warmMedianMs": 0.95 + }, + "emit:tsc7": { + "coldMs": 545.43, + "warmMedianMs": 436.29 + }, + "emit:tsc5.9.3": { + "coldMs": 2102.05, + "warmMedianMs": 2102.05 + }, + "parse:oxc-parser": { + "coldMs": 0.18, + "warmMedianMs": 0.05 + }, + "parse:createSourceFile": { + "coldMs": 0.25, + "warmMedianMs": 0.03 + }, + "build:model": { + "coldMs": 0.96, + "warmMedianMs": 0.66 + }, + "resolve:messages": { + "coldMs": 0.36, + "warmMedianMs": 0.19 + } + } + }, + { + "name": "@systemfsoftware/oxlint-config-recommended", + "version": "4.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 1, + "sourceBytes": 4333, + "declarationFiles": 1, + "declarationStatements": 8, + "clean": { + "oxc-transform": { + "diagnostics": 0, + "clean": true + }, + "transpileDeclaration": { + "diagnostics": 0, + "clean": true + }, + "tsc --isolatedDeclarations": { + "diagnostics": 0, + "clean": true + } + }, + "fallback": { + "exitCode": 2, + "diagnostics": 1 + }, + "divergence": { + "compared": 1, + "differing": 1, + "typescript59ExitCode": 2 + }, + "frontEnd": { + "route": "FastPath", + "outcome": "model", + "items": 7, + "locals": 0, + "messages": 6 + }, + "phases": { + "discover": { + "coldMs": 1.22, + "warmMedianMs": 0.49 + }, + "emit:oxc-transform": { + "coldMs": 0.25, + "warmMedianMs": 0.03 + }, + "emit:transpileDeclaration": { + "coldMs": 2.64, + "warmMedianMs": 1.95 + }, + "emit:tsc7": { + "coldMs": 541.53, + "warmMedianMs": 587.78 + }, + "emit:tsc5.9.3": { + "coldMs": 3769.44, + "warmMedianMs": 3769.44 + }, + "parse:oxc-parser": { + "coldMs": 0.26, + "warmMedianMs": 0.05 + }, + "parse:createSourceFile": { + "coldMs": 0.38, + "warmMedianMs": 0.04 + }, + "build:model": { + "coldMs": 0.94, + "warmMedianMs": 0.51 + }, + "resolve:messages": { + "coldMs": 0.4, + "warmMedianMs": 0.3 + } + } + }, + { + "name": "@systemfsoftware/oxlint-config-rule-authoring", + "version": "1.1.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 1, + "sourceBytes": 1395, + "declarationFiles": 1, + "declarationStatements": 6, + "clean": { + "oxc-transform": { + "diagnostics": 0, + "clean": true + }, + "transpileDeclaration": { + "diagnostics": 0, + "clean": true + }, + "tsc --isolatedDeclarations": { + "diagnostics": 0, + "clean": true + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 1, + "differing": 0, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "FastPath", + "outcome": "model", + "items": 5, + "locals": 0, + "messages": 4 + }, + "phases": { + "discover": { + "coldMs": 0.5, + "warmMedianMs": 0.18 + }, + "emit:oxc-transform": { + "coldMs": 0.1, + "warmMedianMs": 0.01 + }, + "emit:transpileDeclaration": { + "coldMs": 1.46, + "warmMedianMs": 0.83 + }, + "emit:tsc7": { + "coldMs": 82.79, + "warmMedianMs": 88.06 + }, + "emit:tsc5.9.3": { + "coldMs": 521.48, + "warmMedianMs": 521.48 + }, + "parse:oxc-parser": { + "coldMs": 0.24, + "warmMedianMs": 0.04 + }, + "parse:createSourceFile": { + "coldMs": 0.22, + "warmMedianMs": 0.02 + }, + "build:model": { + "coldMs": 0.53, + "warmMedianMs": 0.24 + }, + "resolve:messages": { + "coldMs": 0.22, + "warmMedianMs": 0.12 + } + } + }, + { + "name": "@systemfsoftware/vitest", + "version": "2.0.0", + "origin": "systemfsoftware", + "entryPoints": 5, + "sourceFiles": 32, + "sourceBytes": 292776, + "declarationFiles": 32, + "declarationStatements": 451, + "clean": { + "oxc-transform": { + "diagnostics": 52, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 52, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 126, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 32, + "differing": 1, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "Fallback", + "outcome": "UnsupportedDeclaration: src/mod.d.ts: `declare module \"vitest\"` is not supported", + "items": 0, + "locals": 0, + "messages": 0 + }, + "phases": { + "discover": { + "coldMs": 45.45, + "warmMedianMs": 18.35 + }, + "emit:oxc-transform": { + "coldMs": 5.37, + "warmMedianMs": 2.62 + }, + "emit:transpileDeclaration": { + "coldMs": 137.86, + "warmMedianMs": 136.61 + }, + "emit:tsc7": { + "coldMs": 348.98, + "warmMedianMs": 246.1 + }, + "emit:tsc5.9.3": { + "coldMs": 2071.43, + "warmMedianMs": 2071.43 + }, + "parse:oxc-parser": { + "coldMs": 5.72, + "warmMedianMs": 4.66 + }, + "parse:createSourceFile": { + "coldMs": 5.69, + "warmMedianMs": 5.22 + }, + "build:model": { + "coldMs": 10.74, + "warmMedianMs": 11.89 + }, + "resolve:messages": { + "coldMs": 10.19, + "warmMedianMs": 9.34 + } + } + }, + { + "name": "@systemfsoftware/rx-effect", + "version": "1.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 2, + "sourceBytes": 705, + "declarationFiles": 2, + "declarationStatements": 6, + "clean": { + "oxc-transform": { + "diagnostics": 0, + "clean": true + }, + "transpileDeclaration": { + "diagnostics": 0, + "clean": true + }, + "tsc --isolatedDeclarations": { + "diagnostics": 0, + "clean": true + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 2, + "differing": 0, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "FastPath", + "outcome": "model", + "items": 1, + "locals": 1, + "messages": 2 + }, + "phases": { + "discover": { + "coldMs": 5.55, + "warmMedianMs": 0.89 + }, + "emit:oxc-transform": { + "coldMs": 0.17, + "warmMedianMs": 0.01 + }, + "emit:transpileDeclaration": { + "coldMs": 4.31, + "warmMedianMs": 1.43 + }, + "emit:tsc7": { + "coldMs": 107.53, + "warmMedianMs": 107.75 + }, + "emit:tsc5.9.3": { + "coldMs": 819.86, + "warmMedianMs": 819.86 + }, + "parse:oxc-parser": { + "coldMs": 0.23, + "warmMedianMs": 0.05 + }, + "parse:createSourceFile": { + "coldMs": 0.24, + "warmMedianMs": 0.03 + }, + "build:model": { + "coldMs": 0.5, + "warmMedianMs": 0.4 + }, + "resolve:messages": { + "coldMs": 0.2, + "warmMedianMs": 0.11 + } + } + }, + { + "name": "@systemfsoftware/effect-schema-discovery", + "version": "2.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 2, + "sourceBytes": 8481, + "declarationFiles": 2, + "declarationStatements": 9, + "clean": { + "oxc-transform": { + "diagnostics": 0, + "clean": true + }, + "transpileDeclaration": { + "diagnostics": 0, + "clean": true + }, + "tsc --isolatedDeclarations": { + "diagnostics": 0, + "clean": true + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 2, + "differing": 0, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "FastPath", + "outcome": "model", + "items": 6, + "locals": 0, + "messages": 6 + }, + "phases": { + "discover": { + "coldMs": 5.2, + "warmMedianMs": 0.96 + }, + "emit:oxc-transform": { + "coldMs": 0.2, + "warmMedianMs": 0.05 + }, + "emit:transpileDeclaration": { + "coldMs": 4.03, + "warmMedianMs": 2.66 + }, + "emit:tsc7": { + "coldMs": 103.28, + "warmMedianMs": 111.02 + }, + "emit:tsc5.9.3": { + "coldMs": 711.63, + "warmMedianMs": 711.63 + }, + "parse:oxc-parser": { + "coldMs": 0.28, + "warmMedianMs": 0.07 + }, + "parse:createSourceFile": { + "coldMs": 0.39, + "warmMedianMs": 0.06 + }, + "build:model": { + "coldMs": 0.77, + "warmMedianMs": 0.41 + }, + "resolve:messages": { + "coldMs": 0.35, + "warmMedianMs": 0.21 + } + } + }, + { + "name": "@systemfsoftware/effect-schema-extensions", + "version": "1.0.0", + "origin": "systemfsoftware", + "entryPoints": 2, + "sourceFiles": 2, + "sourceBytes": 88, + "declarationFiles": 2, + "declarationStatements": 2, + "clean": { + "oxc-transform": { + "diagnostics": 0, + "clean": true + }, + "transpileDeclaration": { + "diagnostics": 0, + "clean": true + }, + "tsc --isolatedDeclarations": { + "diagnostics": 0, + "clean": true + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 2, + "differing": 0, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "FastPath", + "outcome": "model", + "items": 0, + "locals": 0, + "messages": 0 + }, + "phases": { + "discover": { + "coldMs": 1.25, + "warmMedianMs": 0.45 + }, + "emit:oxc-transform": { + "coldMs": 0.1, + "warmMedianMs": 0.01 + }, + "emit:transpileDeclaration": { + "coldMs": 1.6, + "warmMedianMs": 0.97 + }, + "emit:tsc7": { + "coldMs": 114.19, + "warmMedianMs": 95.31 + }, + "emit:tsc5.9.3": { + "coldMs": 883.34, + "warmMedianMs": 883.34 + }, + "parse:oxc-parser": { + "coldMs": 0.13, + "warmMedianMs": 0.02 + }, + "parse:createSourceFile": { + "coldMs": 0.16, + "warmMedianMs": 0.01 + }, + "build:model": { + "coldMs": 0.56, + "warmMedianMs": 0.06 + }, + "resolve:messages": { + "coldMs": 0.14, + "warmMedianMs": 0.05 + } + } + }, + { + "name": "@systemfsoftware/effect-schema-law", + "version": "5.0.1", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 3, + "sourceBytes": 26541, + "declarationFiles": 3, + "declarationStatements": 6, + "clean": { + "oxc-transform": { + "diagnostics": 0, + "clean": true + }, + "transpileDeclaration": { + "diagnostics": 0, + "clean": true + }, + "tsc --isolatedDeclarations": { + "diagnostics": 0, + "clean": true + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 3, + "differing": 0, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "FastPath", + "outcome": "model", + "items": 2, + "locals": 0, + "messages": 2 + }, + "phases": { + "discover": { + "coldMs": 2.7, + "warmMedianMs": 1.09 + }, + "emit:oxc-transform": { + "coldMs": 0.48, + "warmMedianMs": 0.27 + }, + "emit:transpileDeclaration": { + "coldMs": 8.11, + "warmMedianMs": 7 + }, + "emit:tsc7": { + "coldMs": 897.69, + "warmMedianMs": 338.68 + }, + "emit:tsc5.9.3": { + "coldMs": 2163.27, + "warmMedianMs": 2163.27 + }, + "parse:oxc-parser": { + "coldMs": 0.52, + "warmMedianMs": 0.06 + }, + "parse:createSourceFile": { + "coldMs": 0.3, + "warmMedianMs": 0.03 + }, + "build:model": { + "coldMs": 1.51, + "warmMedianMs": 0.91 + }, + "resolve:messages": { + "coldMs": 0.38, + "warmMedianMs": 0.49 + } + } + }, + { + "name": "@systemfsoftware/effect-schema-recursion-budget", + "version": "2.0.0", + "origin": "systemfsoftware", + "entryPoints": 2, + "sourceFiles": 3, + "sourceBytes": 13872, + "declarationFiles": 3, + "declarationStatements": 7, + "clean": { + "oxc-transform": { + "diagnostics": 0, + "clean": true + }, + "transpileDeclaration": { + "diagnostics": 0, + "clean": true + }, + "tsc --isolatedDeclarations": { + "diagnostics": 0, + "clean": true + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 3, + "differing": 1, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "FastPath", + "outcome": "model", + "items": 5, + "locals": 0, + "messages": 5 + }, + "phases": { + "discover": { + "coldMs": 2.75, + "warmMedianMs": 1.73 + }, + "emit:oxc-transform": { + "coldMs": 0.35, + "warmMedianMs": 0.11 + }, + "emit:transpileDeclaration": { + "coldMs": 4.86, + "warmMedianMs": 4.63 + }, + "emit:tsc7": { + "coldMs": 245.17, + "warmMedianMs": 218.18 + }, + "emit:tsc5.9.3": { + "coldMs": 1639.17, + "warmMedianMs": 1639.17 + }, + "parse:oxc-parser": { + "coldMs": 0.28, + "warmMedianMs": 0.06 + }, + "parse:createSourceFile": { + "coldMs": 0.22, + "warmMedianMs": 0.04 + }, + "build:model": { + "coldMs": 0.73, + "warmMedianMs": 0.59 + }, + "resolve:messages": { + "coldMs": 0.26, + "warmMedianMs": 0.17 + } + } + }, + { + "name": "@systemfsoftware/effect-schema-vite", + "version": "3.0.1", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 2, + "sourceBytes": 5828, + "declarationFiles": 2, + "declarationStatements": 9, + "clean": { + "oxc-transform": { + "diagnostics": 1, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 1, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 1, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 2, + "differing": 1, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "Fallback", + "outcome": "model", + "items": 4, + "locals": 1, + "messages": 5 + }, + "phases": { + "discover": { + "coldMs": 1.42, + "warmMedianMs": 0.42 + }, + "emit:oxc-transform": { + "coldMs": 0.2, + "warmMedianMs": 0.03 + }, + "emit:transpileDeclaration": { + "coldMs": 3.03, + "warmMedianMs": 1.99 + }, + "emit:tsc7": { + "coldMs": 110.08, + "warmMedianMs": 103.31 + }, + "emit:tsc5.9.3": { + "coldMs": 853.52, + "warmMedianMs": 853.52 + }, + "parse:oxc-parser": { + "coldMs": 0.19, + "warmMedianMs": 0.05 + }, + "parse:createSourceFile": { + "coldMs": 0.25, + "warmMedianMs": 0.04 + }, + "build:model": { + "coldMs": 1.56, + "warmMedianMs": 0.85 + }, + "resolve:messages": { + "coldMs": 0.36, + "warmMedianMs": 0.39 + } + } + }, + { + "name": "@systemfsoftware/hex-schema", + "version": "2.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 7, + "sourceBytes": 3921, + "declarationFiles": 7, + "declarationStatements": 22, + "clean": { + "oxc-transform": { + "diagnostics": 6, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 6, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 6, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 7, + "differing": 0, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "Fallback", + "outcome": "model", + "items": 10, + "locals": 0, + "messages": 6 + }, + "phases": { + "discover": { + "coldMs": 2.86, + "warmMedianMs": 1.89 + }, + "emit:oxc-transform": { + "coldMs": 0.24, + "warmMedianMs": 0.09 + }, + "emit:transpileDeclaration": { + "coldMs": 6.74, + "warmMedianMs": 4.26 + }, + "emit:tsc7": { + "coldMs": 128.76, + "warmMedianMs": 89.21 + }, + "emit:tsc5.9.3": { + "coldMs": 785.18, + "warmMedianMs": 785.18 + }, + "parse:oxc-parser": { + "coldMs": 0.53, + "warmMedianMs": 0.19 + }, + "parse:createSourceFile": { + "coldMs": 0.62, + "warmMedianMs": 0.15 + }, + "build:model": { + "coldMs": 3.43, + "warmMedianMs": 1.82 + }, + "resolve:messages": { + "coldMs": 1.69, + "warmMedianMs": 1.2 + } + } + }, + { + "name": "@systemfsoftware/conformance-spec", + "version": "1.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 8, + "sourceBytes": 48889, + "declarationFiles": 8, + "declarationStatements": 68, + "clean": { + "oxc-transform": { + "diagnostics": 2, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 2, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 4, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 8, + "differing": 1, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "Fallback", + "outcome": "model", + "items": 1, + "locals": 7, + "messages": 39 + }, + "phases": { + "discover": { + "coldMs": 4.74, + "warmMedianMs": 2.11 + }, + "emit:oxc-transform": { + "coldMs": 0.62, + "warmMedianMs": 0.32 + }, + "emit:transpileDeclaration": { + "coldMs": 16.32, + "warmMedianMs": 16.71 + }, + "emit:tsc7": { + "coldMs": 189.48, + "warmMedianMs": 205.07 + }, + "emit:tsc5.9.3": { + "coldMs": 2681.11, + "warmMedianMs": 2681.11 + }, + "parse:oxc-parser": { + "coldMs": 1.61, + "warmMedianMs": 0.98 + }, + "parse:createSourceFile": { + "coldMs": 1.8, + "warmMedianMs": 0.86 + }, + "build:model": { + "coldMs": 29.78, + "warmMedianMs": 6.57 + }, + "resolve:messages": { + "coldMs": 5.15, + "warmMedianMs": 3.48 + } + } + }, + { + "name": "@systemfsoftware/differential-spec", + "version": "1.0.1", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 11, + "sourceBytes": 32396, + "declarationFiles": 11, + "declarationStatements": 85, + "clean": { + "oxc-transform": { + "diagnostics": 21, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 21, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 27, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 11, + "differing": 0, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "Fallback", + "outcome": "model", + "items": 16, + "locals": 0, + "messages": 21 + }, + "phases": { + "discover": { + "coldMs": 18.15, + "warmMedianMs": 3.72 + }, + "emit:oxc-transform": { + "coldMs": 1.27, + "warmMedianMs": 0.4 + }, + "emit:transpileDeclaration": { + "coldMs": 21.97, + "warmMedianMs": 31.97 + }, + "emit:tsc7": { + "coldMs": 712.83, + "warmMedianMs": 482.83 + }, + "emit:tsc5.9.3": { + "coldMs": 2881.73, + "warmMedianMs": 2881.73 + }, + "parse:oxc-parser": { + "coldMs": 2.12, + "warmMedianMs": 1.1 + }, + "parse:createSourceFile": { + "coldMs": 0.92, + "warmMedianMs": 0.52 + }, + "build:model": { + "coldMs": 14.89, + "warmMedianMs": 10.37 + }, + "resolve:messages": { + "coldMs": 3.57, + "warmMedianMs": 2.32 + } + } + }, + { + "name": "@systemfsoftware/effect-sim-kernel", + "version": "1.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 16, + "sourceBytes": 115718, + "declarationFiles": 16, + "declarationStatements": 137, + "clean": { + "oxc-transform": { + "diagnostics": 2, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 2, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 2, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 16, + "differing": 2, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "Fallback", + "outcome": "model", + "items": 1, + "locals": 17, + "messages": 45 + }, + "phases": { + "discover": { + "coldMs": 12.91, + "warmMedianMs": 11.35 + }, + "emit:oxc-transform": { + "coldMs": 2.66, + "warmMedianMs": 1.45 + }, + "emit:transpileDeclaration": { + "coldMs": 43.58, + "warmMedianMs": 36.78 + }, + "emit:tsc7": { + "coldMs": 213.67, + "warmMedianMs": 215.74 + }, + "emit:tsc5.9.3": { + "coldMs": 1958.97, + "warmMedianMs": 1958.97 + }, + "parse:oxc-parser": { + "coldMs": 1.51, + "warmMedianMs": 1.11 + }, + "parse:createSourceFile": { + "coldMs": 1.48, + "warmMedianMs": 1.94 + }, + "build:model": { + "coldMs": 22.85, + "warmMedianMs": 14.5 + }, + "resolve:messages": { + "coldMs": 5.5, + "warmMedianMs": 5.37 + } + } + }, + { + "name": "@systemfsoftware/trace-spec", + "version": "1.0.1", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 31, + "sourceBytes": 123477, + "declarationFiles": 31, + "declarationStatements": 342, + "clean": { + "oxc-transform": { + "diagnostics": 94, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 93, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 235, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 31, + "differing": 1, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "Fallback", + "outcome": "model", + "items": 9, + "locals": 26, + "messages": 102 + }, + "phases": { + "discover": { + "coldMs": 31.95, + "warmMedianMs": 43.48 + }, + "emit:oxc-transform": { + "coldMs": 2.21, + "warmMedianMs": 1.77 + }, + "emit:transpileDeclaration": { + "coldMs": 108.66, + "warmMedianMs": 115.85 + }, + "emit:tsc7": { + "coldMs": 847.82, + "warmMedianMs": 773.01 + }, + "emit:tsc5.9.3": { + "coldMs": 5712.03, + "warmMedianMs": 5712.03 + }, + "parse:oxc-parser": { + "coldMs": 5.66, + "warmMedianMs": 7.37 + }, + "parse:createSourceFile": { + "coldMs": 8.56, + "warmMedianMs": 6.04 + }, + "build:model": { + "coldMs": 38.1, + "warmMedianMs": 33.18 + }, + "resolve:messages": { + "coldMs": 20.55, + "warmMedianMs": 13.28 + } + } + }, + { + "name": "@systemfsoftware/trace-taxonomy", + "version": "1.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 3, + "sourceBytes": 7226, + "declarationFiles": 3, + "declarationStatements": 28, + "clean": { + "oxc-transform": { + "diagnostics": 0, + "clean": true + }, + "transpileDeclaration": { + "diagnostics": 0, + "clean": true + }, + "tsc --isolatedDeclarations": { + "diagnostics": 0, + "clean": true + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 3, + "differing": 0, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "FastPath", + "outcome": "model", + "items": 2, + "locals": 2, + "messages": 18 + }, + "phases": { + "discover": { + "coldMs": 1.23, + "warmMedianMs": 1.37 + }, + "emit:oxc-transform": { + "coldMs": 0.25, + "warmMedianMs": 0.06 + }, + "emit:transpileDeclaration": { + "coldMs": 5.58, + "warmMedianMs": 8.18 + }, + "emit:tsc7": { + "coldMs": 290.08, + "warmMedianMs": 251.21 + }, + "emit:tsc5.9.3": { + "coldMs": 2312.04, + "warmMedianMs": 2312.04 + }, + "parse:oxc-parser": { + "coldMs": 0.57, + "warmMedianMs": 0.18 + }, + "parse:createSourceFile": { + "coldMs": 0.43, + "warmMedianMs": 0.14 + }, + "build:model": { + "coldMs": 2.59, + "warmMedianMs": 3.47 + }, + "resolve:messages": { + "coldMs": 1.95, + "warmMedianMs": 1.41 + } + } + } + ] +} diff --git a/docs/profiles/2026-10-12-front-end-phases-built.md b/docs/profiles/2026-10-12-front-end-phases-built.md new file mode 100644 index 0000000..4279a90 --- /dev/null +++ b/docs/profiles/2026-10-12-front-end-phases-built.md @@ -0,0 +1,102 @@ +# Front-end phase profile + +Generated by `apps/api-extractor-profile/profile.ts`; the JSON beside this file is the record. Times are warm medians in milliseconds; +the clean columns count isolated-declaration diagnostics over the files the entry sources reach. + +```json +{ + "corpus": { + "repository": "https://github.com/systemfsoftware/systemfsoftware", + "commit": "732f66a0c85819b2005f9e0930a595bc4de91ee1" + }, + "ownPackage": { + "repository": "https://github.com/systemfsoftware/api-extractor-effect", + "commit": "d4d55d3cbbf0d412114d00da3c398dbb7f3938e4" + }, + "host": { + "cpu": "AMD Ryzen 9 9950X 16-Core Processor", + "cores": 32, + "platform": "linux-x64", + "node": "v24.21.0" + }, + "tools": { + "oxc-transform": "0.150.0", + "oxc-parser": "0.150.0", + "typescript": "7.0.2", + "typescript-5.9": "5.9.3" + }, + "runs": { + "cold": 1, + "warm": 5, + "emit:tsc5.9.3": "cold only" + }, + "totals": { + "discover": 420.71, + "emit:oxc-transform": 31.1, + "emit:transpileDeclaration": 1418.48, + "emit:tsc7": 13646.71, + "emit:tsc5.9.3": 93836.87, + "parse:oxc-parser": 68.45, + "parse:createSourceFile": 66.95, + "build:model": 414.23, + "resolve:messages": 190.24 + } +} +``` + +| Package | Origin | Sources | oxc clean | tD clean | tsc ID clean | discover | emit:oxc-transform | emit:transpileDeclaration | emit:tsc7 | emit:tsc5.9.3 | parse:oxc-parser | parse:createSourceFile | build:model | resolve:messages | TS7≠5.9.3 | route | model | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| @systemfsoftware/api-extractor | api-extractor-effect | 34 | 0 | 0 | 0 | 59.85 | 1.79 | 97.52 | 464.81 | 2996.49 | 1.91 | 4.17 | 32.27 | 14.15 | 3/34 | FastPath | model | +| @systemfsoftware/effect-atom | systemfsoftware | 35 | 145 | 145 | 184 | 22.54 | 3.95 | 142.47 | 599.92 | 3468.25 | 7.5 | 9.23 | 2.93 | 3.27 | 1/35 | Fallback | UnsupportedDeclaration: src/atom-http-api.service.d.ts: `declare global` is not supported | +| @systemfsoftware/effect-atom-react | systemfsoftware | 10 | 20 | 20 | 29 | 3.37 | 0.41 | 20.95 | 673.56 | 5016.37 | 0.67 | 1.57 | 8.29 | 2.1 | 0/10 | Fallback | model | +| @systemfsoftware/effect-daemon-cluster | systemfsoftware | 5 | 1 | 1 | 1 | 2.91 | 0.16 | 7.59 | 374.65 | 2501.52 | 0.21 | 0.14 | 3.01 | 1.59 | 1/5 | Fallback | model | +| @systemfsoftware/effect-daemon-conformance | systemfsoftware | 15 | 47 | 47 | 96 | 5.23 | 0.72 | 21.78 | 290.61 | 2360.78 | 1.43 | 1.27 | 26.27 | 6.2 | 0/15 | Fallback | model | +| @systemfsoftware/effect-daemon-microvm | systemfsoftware | 9 | 21 | 21 | 28 | 9.12 | 0.28 | 15.99 | 536.51 | 3944.73 | 0.32 | 0.32 | 2.8 | 1.18 | 1/9 | Fallback | model | +| @systemfsoftware/effect-daemon-process | systemfsoftware | 9 | 22 | 22 | 29 | 2.24 | 0.2 | 12.73 | 553.31 | 3235.75 | 0.34 | 0.32 | 0.94 | 0.59 | 1/9 | Fallback | model | +| @systemfsoftware/effect-daemon-socket | systemfsoftware | 12 | 19 | 19 | 31 | 14.47 | 0.34 | 15.86 | 530.3 | 3545.59 | 0.65 | 0.81 | 7.22 | 2.51 | 0/12 | Fallback | model | +| @systemfsoftware/effect-daemon-spec | systemfsoftware | 22 | 138 | 138 | 279 | 27.43 | 1.56 | 76.72 | 373.01 | 2723.85 | 10.39 | 6.75 | 34.21 | 36.26 | 4/22 | Fallback | model | +| @systemfsoftware/discern | systemfsoftware | 40 | 144 | 144 | 355 | 56.66 | 4.15 | 157.12 | 655.53 | 3615.29 | 8.76 | 8.79 | 73.26 | 36.27 | 14/40 | Fallback | model | +| @systemfsoftware/effect-cell-types | systemfsoftware | 7 | 1 | 1 | 2 | 5.98 | 0.82 | 31.01 | 295.72 | 2482.58 | 3.37 | 4.06 | 52.07 | 15.17 | 1/7 | Fallback | model | +| @systemfsoftware/effect-memfs | systemfsoftware | 19 | 68 | 68 | 104 | 6.92 | 1.04 | 50.41 | 307.14 | 2874.8 | 1.32 | 0.99 | 11.16 | 1.96 | 1/19 | Fallback | model | +| @systemfsoftware/effect-microsandbox | systemfsoftware | 22 | 94 | 93 | 222 | 7.24 | 1.24 | 42.07 | 347.58 | 2526.87 | 2.52 | 2.14 | 19.23 | 9.29 | 9/22 | Fallback | model | +| @systemfsoftware/effect-readiness | systemfsoftware | 19 | 53 | 53 | 83 | 4.43 | 0.48 | 21.13 | 278.2 | 2114.77 | 1.6 | 1.45 | 7.7 | 3.83 | 1/19 | Fallback | model | +| @systemfsoftware/effect-spec-runtime | systemfsoftware | 6 | 1 | 1 | 2 | 2.63 | 0.28 | 10.05 | 294.08 | 1923.32 | 0.47 | 0.54 | 15.68 | 1.63 | 0/6 | FastPath | model | +| @systemfsoftware/effect-gherkin-spec | systemfsoftware | 9 | 15 | 19 | 24 | 10.11 | 0.66 | 30.85 | 247.12 | 2148.33 | 2.58 | 2.09 | 13.01 | 9.31 | 1/9 | Fallback | UnsupportedDeclaration: src/mod.d.ts: re-export of "it" from "@systemfsoftware/vitest" is not supported | +| @systemfsoftware/storybook-gherkin | systemfsoftware | 5 | 12 | 11 | 34 | 5.04 | 0.74 | 14.19 | 166.71 | 1064.4 | 0.82 | 0.72 | 8.4 | 3.86 | 1/5 | Fallback | model | +| @systemfsoftware/npm-package | systemfsoftware | 11 | 29 | 29 | 41 | 4.53 | 0.57 | 18.5 | 227.32 | 1795.68 | 0.43 | 0.54 | 3.06 | 0.59 | 0/11 | Fallback | model | +| @systemfsoftware/oxlint-plugin-cell-architecture | systemfsoftware | 43 | 65 | 65 | 71 | 14.52 | 1.03 | 58.88 | 363.99 | 2209.8 | 1.75 | 1.27 | 0.98 | 0.25 | 19/43 | Fallback | model | +| @systemfsoftware/oxlint-plugin-dmmf-workflow | systemfsoftware | 19 | 65 | 65 | 65 | 6.61 | 0.64 | 26.39 | 290.23 | 1846.85 | 0.71 | 0.78 | 0.64 | 0.17 | 8/19 | Fallback | model | +| @systemfsoftware/oxlint-plugin-effect-platform | systemfsoftware | 21 | 52 | 52 | 62 | 3.83 | 0.44 | 26.23 | 310.64 | 1621.41 | 0.65 | 0.5 | 0.84 | 0.29 | 10/21 | Fallback | model | +| @systemfsoftware/oxlint-plugin-effect-schema | systemfsoftware | 23 | 42 | 42 | 45 | 12.08 | 0.88 | 40.6 | 459.41 | 2165.39 | 0.97 | 0.75 | 0.66 | 0.16 | 10/23 | Fallback | model | +| @systemfsoftware/oxlint-plugin-test-discipline | systemfsoftware | 65 | 147 | 147 | 150 | 43.88 | 1.47 | 105.85 | 264.85 | 1687.84 | 3 | 2.55 | 2.41 | 0.83 | 31/65 | Fallback | model | +| @systemfsoftware/oxlint-config-cell-architecture | systemfsoftware | 1 | 0 | 0 | 0 | 0.42 | 0.01 | 0.82 | 391.96 | 2099.96 | 0.04 | 0.03 | 0.76 | 0.35 | 0/1 | FastPath | model | +| @systemfsoftware/oxlint-config-dmmf | systemfsoftware | 1 | 0 | 0 | 0 | 0.19 | 0.01 | 0.95 | 436.29 | 2102.05 | 0.05 | 0.03 | 0.66 | 0.19 | 0/1 | FastPath | model | +| @systemfsoftware/oxlint-config-recommended | systemfsoftware | 1 | 0 | 0 | 0 | 0.49 | 0.03 | 1.95 | 587.78 | 3769.44 | 0.05 | 0.04 | 0.51 | 0.3 | 1/1 | FastPath | model | +| @systemfsoftware/oxlint-config-rule-authoring | systemfsoftware | 1 | 0 | 0 | 0 | 0.18 | 0.01 | 0.83 | 88.06 | 521.48 | 0.04 | 0.02 | 0.24 | 0.12 | 0/1 | FastPath | model | +| @systemfsoftware/vitest | systemfsoftware | 32 | 52 | 52 | 126 | 18.35 | 2.62 | 136.61 | 246.1 | 2071.43 | 4.66 | 5.22 | 11.89 | 9.34 | 1/32 | Fallback | UnsupportedDeclaration: src/mod.d.ts: `declare module "vitest"` is not supported | +| @systemfsoftware/rx-effect | systemfsoftware | 2 | 0 | 0 | 0 | 0.89 | 0.01 | 1.43 | 107.75 | 819.86 | 0.05 | 0.03 | 0.4 | 0.11 | 0/2 | FastPath | model | +| @systemfsoftware/effect-schema-discovery | systemfsoftware | 2 | 0 | 0 | 0 | 0.96 | 0.05 | 2.66 | 111.02 | 711.63 | 0.07 | 0.06 | 0.41 | 0.21 | 0/2 | FastPath | model | +| @systemfsoftware/effect-schema-extensions | systemfsoftware | 2 | 0 | 0 | 0 | 0.45 | 0.01 | 0.97 | 95.31 | 883.34 | 0.02 | 0.01 | 0.06 | 0.05 | 0/2 | FastPath | model | +| @systemfsoftware/effect-schema-law | systemfsoftware | 3 | 0 | 0 | 0 | 1.09 | 0.27 | 7 | 338.68 | 2163.27 | 0.06 | 0.03 | 0.91 | 0.49 | 0/3 | FastPath | model | +| @systemfsoftware/effect-schema-recursion-budget | systemfsoftware | 3 | 0 | 0 | 0 | 1.73 | 0.11 | 4.63 | 218.18 | 1639.17 | 0.06 | 0.04 | 0.59 | 0.17 | 1/3 | FastPath | model | +| @systemfsoftware/effect-schema-vite | systemfsoftware | 2 | 1 | 1 | 1 | 0.42 | 0.03 | 1.99 | 103.31 | 853.52 | 0.05 | 0.04 | 0.85 | 0.39 | 1/2 | Fallback | model | +| @systemfsoftware/hex-schema | systemfsoftware | 7 | 6 | 6 | 6 | 1.89 | 0.09 | 4.26 | 89.21 | 785.18 | 0.19 | 0.15 | 1.82 | 1.2 | 0/7 | Fallback | model | +| @systemfsoftware/conformance-spec | systemfsoftware | 8 | 2 | 2 | 4 | 2.11 | 0.32 | 16.71 | 205.07 | 2681.11 | 0.98 | 0.86 | 6.57 | 3.48 | 1/8 | Fallback | model | +| @systemfsoftware/differential-spec | systemfsoftware | 11 | 21 | 21 | 27 | 3.72 | 0.4 | 31.97 | 482.83 | 2881.73 | 1.1 | 0.52 | 10.37 | 2.32 | 0/11 | Fallback | model | +| @systemfsoftware/effect-sim-kernel | systemfsoftware | 16 | 2 | 2 | 2 | 11.35 | 1.45 | 36.78 | 215.74 | 1958.97 | 1.11 | 1.94 | 14.5 | 5.37 | 2/16 | Fallback | model | +| @systemfsoftware/trace-spec | systemfsoftware | 31 | 94 | 93 | 235 | 43.48 | 1.77 | 115.85 | 773.01 | 5712.03 | 7.37 | 6.04 | 33.18 | 13.28 | 1/31 | Fallback | model | +| @systemfsoftware/trace-taxonomy | systemfsoftware | 3 | 0 | 0 | 0 | 1.37 | 0.06 | 8.18 | 251.21 | 2312.04 | 0.18 | 0.14 | 3.47 | 1.41 | 0/3 | FastPath | model | +| **total** | | 586 | | | | 420.71 | 31.1 | 1418.48 | 13646.71 | 93836.87 | 68.45 | 66.95 | 414.23 | 190.24 | 125/586 | | | + +## Findings + +This second run adds the front-end's own phases (`build:model`, `resolve:messages`) and each package's route and build outcome. The first profile, [`2026-10-12-front-end-phases.md`](2026-10-12-front-end-phases.md), holds the emitter decision and stays as recorded. Absolute times are higher here than in the first run because the host was busier. Compare columns within one file, not across the two files. + +- **Routes.** 12 of 40 packages take the fast path and 28 take the TypeScript 7 fallback. `tsc --isolatedDeclarations` counts 11 packages as clean. The extra one is effect-spec-runtime: its only isolated-declaration diagnostics sit in a source file that the package's declarations never reach (a value-only import), so the fast path is still correct for its public surface. +- **Models.** 37 of 40 packages build a model. Three are refused with `UnsupportedDeclaration`: + - effect-atom has a `declare global` block; + - vitest has `declare module "vitest"` (a module augmentation); + - effect-gherkin-spec re-exports `it` from `@systemfsoftware/vitest`. + + Model `schemaVersion` 2 has no shape for any of these three constructs. +- **Cost.** Building the model takes 414.23 ms over all 40 packages, and resolving messages takes 190.24 ms. Both run once on either route and are small next to the fallback's `tsc` emit (13646.71 ms). diff --git a/docs/profiles/2026-10-12-front-end-phases.json b/docs/profiles/2026-10-12-front-end-phases.json new file mode 100644 index 0000000..dcae55f --- /dev/null +++ b/docs/profiles/2026-10-12-front-end-phases.json @@ -0,0 +1,2558 @@ +{ + "corpus": { + "repository": "https://github.com/systemfsoftware/systemfsoftware", + "commit": "732f66a0c85819b2005f9e0930a595bc4de91ee1" + }, + "ownPackage": { + "repository": "https://github.com/systemfsoftware/api-extractor-effect", + "commit": "925b4ccb9a20298f4b6a2f5d7b130937bd0d3f03" + }, + "host": { + "cpu": "AMD Ryzen 9 9950X 16-Core Processor", + "cores": 32, + "platform": "linux-x64", + "node": "v24.21.0" + }, + "tools": { + "oxc-transform": "0.150.0", + "oxc-parser": "0.150.0", + "typescript": "7.0.2", + "typescript-5.9": "5.9.3" + }, + "runs": { + "cold": 1, + "warm": 5, + "emit:tsc5.9.3": "cold only" + }, + "totals": { + "discover": 181.9, + "emit:oxc-transform": 24.85, + "emit:transpileDeclaration": 892.7, + "emit:tsc7": 9257.41, + "emit:tsc5.9.3": 64652.24, + "parse:oxc-parser": 52.94, + "parse:createSourceFile": 54.33 + }, + "packages": [ + { + "name": "@systemfsoftware/api-extractor", + "version": "0.0.0", + "origin": "api-extractor-effect", + "entryPoints": 1, + "sourceFiles": 17, + "sourceBytes": 81628, + "declarationFiles": 17, + "declarationStatements": 170, + "clean": { + "oxc-transform": { + "diagnostics": 0, + "clean": true + }, + "transpileDeclaration": { + "diagnostics": 0, + "clean": true + }, + "tsc --isolatedDeclarations": { + "diagnostics": 0, + "clean": true + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 17, + "differing": 2, + "typescript59ExitCode": 0 + }, + "phases": { + "discover": { + "coldMs": 11.01, + "warmMedianMs": 5.87 + }, + "emit:oxc-transform": { + "coldMs": 1.9, + "warmMedianMs": 0.67 + }, + "emit:transpileDeclaration": { + "coldMs": 86.24, + "warmMedianMs": 31.45 + }, + "emit:tsc7": { + "coldMs": 145.1, + "warmMedianMs": 117.89 + }, + "emit:tsc5.9.3": { + "coldMs": 984.98, + "warmMedianMs": 984.98 + }, + "parse:oxc-parser": { + "coldMs": 2.35, + "warmMedianMs": 0.9 + }, + "parse:createSourceFile": { + "coldMs": 2.34, + "warmMedianMs": 1.59 + } + } + }, + { + "name": "@systemfsoftware/effect-atom", + "version": "6.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 35, + "sourceBytes": 388509, + "declarationFiles": 35, + "declarationStatements": 667, + "clean": { + "oxc-transform": { + "diagnostics": 145, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 145, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 184, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 35, + "differing": 1, + "typescript59ExitCode": 0 + }, + "phases": { + "discover": { + "coldMs": 36.34, + "warmMedianMs": 22.62 + }, + "emit:oxc-transform": { + "coldMs": 6.45, + "warmMedianMs": 3.67 + }, + "emit:transpileDeclaration": { + "coldMs": 186.2, + "warmMedianMs": 124.16 + }, + "emit:tsc7": { + "coldMs": 405.96, + "warmMedianMs": 380.32 + }, + "emit:tsc5.9.3": { + "coldMs": 2433.07, + "warmMedianMs": 2433.07 + }, + "parse:oxc-parser": { + "coldMs": 8.74, + "warmMedianMs": 6.89 + }, + "parse:createSourceFile": { + "coldMs": 9.12, + "warmMedianMs": 8.01 + } + } + }, + { + "name": "@systemfsoftware/effect-atom-react", + "version": "2.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 10, + "sourceBytes": 45423, + "declarationFiles": 10, + "declarationStatements": 86, + "clean": { + "oxc-transform": { + "diagnostics": 20, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 20, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 29, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 10, + "differing": 0, + "typescript59ExitCode": 0 + }, + "phases": { + "discover": { + "coldMs": 3.97, + "warmMedianMs": 2.87 + }, + "emit:oxc-transform": { + "coldMs": 0.66, + "warmMedianMs": 0.31 + }, + "emit:transpileDeclaration": { + "coldMs": 18.65, + "warmMedianMs": 15.13 + }, + "emit:tsc7": { + "coldMs": 504.27, + "warmMedianMs": 496.58 + }, + "emit:tsc5.9.3": { + "coldMs": 2355.89, + "warmMedianMs": 2355.89 + }, + "parse:oxc-parser": { + "coldMs": 1.24, + "warmMedianMs": 0.65 + }, + "parse:createSourceFile": { + "coldMs": 1.47, + "warmMedianMs": 0.91 + } + } + }, + { + "name": "@systemfsoftware/effect-daemon-cluster", + "version": "1.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 5, + "sourceBytes": 10261, + "declarationFiles": 5, + "declarationStatements": 35, + "clean": { + "oxc-transform": { + "diagnostics": 1, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 1, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 1, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 5, + "differing": 1, + "typescript59ExitCode": 0 + }, + "phases": { + "discover": { + "coldMs": 1.77, + "warmMedianMs": 1.35 + }, + "emit:oxc-transform": { + "coldMs": 0.29, + "warmMedianMs": 0.08 + }, + "emit:transpileDeclaration": { + "coldMs": 6.23, + "warmMedianMs": 3.89 + }, + "emit:tsc7": { + "coldMs": 241.83, + "warmMedianMs": 264.09 + }, + "emit:tsc5.9.3": { + "coldMs": 1848.7, + "warmMedianMs": 1848.7 + }, + "parse:oxc-parser": { + "coldMs": 0.5, + "warmMedianMs": 0.29 + }, + "parse:createSourceFile": { + "coldMs": 0.64, + "warmMedianMs": 0.15 + } + } + }, + { + "name": "@systemfsoftware/effect-daemon-conformance", + "version": "1.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 15, + "sourceBytes": 50561, + "declarationFiles": 15, + "declarationStatements": 146, + "clean": { + "oxc-transform": { + "diagnostics": 47, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 47, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 96, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 15, + "differing": 0, + "typescript59ExitCode": 0 + }, + "phases": { + "discover": { + "coldMs": 5.95, + "warmMedianMs": 5.16 + }, + "emit:oxc-transform": { + "coldMs": 0.84, + "warmMedianMs": 0.46 + }, + "emit:transpileDeclaration": { + "coldMs": 17.85, + "warmMedianMs": 18.13 + }, + "emit:tsc7": { + "coldMs": 294.44, + "warmMedianMs": 246.99 + }, + "emit:tsc5.9.3": { + "coldMs": 1714.23, + "warmMedianMs": 1714.23 + }, + "parse:oxc-parser": { + "coldMs": 1.75, + "warmMedianMs": 1.3 + }, + "parse:createSourceFile": { + "coldMs": 1.92, + "warmMedianMs": 1.17 + } + } + }, + { + "name": "@systemfsoftware/effect-daemon-microvm", + "version": "1.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 9, + "sourceBytes": 20513, + "declarationFiles": 9, + "declarationStatements": 71, + "clean": { + "oxc-transform": { + "diagnostics": 21, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 21, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 28, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 9, + "differing": 1, + "typescript59ExitCode": 0 + }, + "phases": { + "discover": { + "coldMs": 2.27, + "warmMedianMs": 1.74 + }, + "emit:oxc-transform": { + "coldMs": 0.59, + "warmMedianMs": 0.24 + }, + "emit:transpileDeclaration": { + "coldMs": 12.57, + "warmMedianMs": 8.32 + }, + "emit:tsc7": { + "coldMs": 340.12, + "warmMedianMs": 307.8 + }, + "emit:tsc5.9.3": { + "coldMs": 2009.5, + "warmMedianMs": 2009.5 + }, + "parse:oxc-parser": { + "coldMs": 0.57, + "warmMedianMs": 0.27 + }, + "parse:createSourceFile": { + "coldMs": 0.47, + "warmMedianMs": 0.24 + } + } + }, + { + "name": "@systemfsoftware/effect-daemon-process", + "version": "1.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 9, + "sourceBytes": 19021, + "declarationFiles": 9, + "declarationStatements": 64, + "clean": { + "oxc-transform": { + "diagnostics": 22, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 22, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 29, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 9, + "differing": 1, + "typescript59ExitCode": 0 + }, + "phases": { + "discover": { + "coldMs": 1.97, + "warmMedianMs": 1.75 + }, + "emit:oxc-transform": { + "coldMs": 0.44, + "warmMedianMs": 0.21 + }, + "emit:transpileDeclaration": { + "coldMs": 9.78, + "warmMedianMs": 8.5 + }, + "emit:tsc7": { + "coldMs": 220.19, + "warmMedianMs": 214.73 + }, + "emit:tsc5.9.3": { + "coldMs": 1608.7, + "warmMedianMs": 1608.7 + }, + "parse:oxc-parser": { + "coldMs": 0.79, + "warmMedianMs": 0.3 + }, + "parse:createSourceFile": { + "coldMs": 0.77, + "warmMedianMs": 0.3 + } + } + }, + { + "name": "@systemfsoftware/effect-daemon-socket", + "version": "1.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 12, + "sourceBytes": 35576, + "declarationFiles": 12, + "declarationStatements": 101, + "clean": { + "oxc-transform": { + "diagnostics": 19, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 19, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 31, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 12, + "differing": 0, + "typescript59ExitCode": 0 + }, + "phases": { + "discover": { + "coldMs": 3.41, + "warmMedianMs": 2.96 + }, + "emit:oxc-transform": { + "coldMs": 0.9, + "warmMedianMs": 0.35 + }, + "emit:transpileDeclaration": { + "coldMs": 16.32, + "warmMedianMs": 12.01 + }, + "emit:tsc7": { + "coldMs": 227.46, + "warmMedianMs": 235.76 + }, + "emit:tsc5.9.3": { + "coldMs": 1851.78, + "warmMedianMs": 1851.78 + }, + "parse:oxc-parser": { + "coldMs": 0.82, + "warmMedianMs": 0.48 + }, + "parse:createSourceFile": { + "coldMs": 0.78, + "warmMedianMs": 0.48 + } + } + }, + { + "name": "@systemfsoftware/effect-daemon-spec", + "version": "6.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 22, + "sourceBytes": 138120, + "declarationFiles": 22, + "declarationStatements": 400, + "clean": { + "oxc-transform": { + "diagnostics": 138, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 138, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 279, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 22, + "differing": 4, + "typescript59ExitCode": 0 + }, + "phases": { + "discover": { + "coldMs": 10.24, + "warmMedianMs": 12 + }, + "emit:oxc-transform": { + "coldMs": 4.69, + "warmMedianMs": 1.34 + }, + "emit:transpileDeclaration": { + "coldMs": 45.5, + "warmMedianMs": 50.71 + }, + "emit:tsc7": { + "coldMs": 230.53, + "warmMedianMs": 208.02 + }, + "emit:tsc5.9.3": { + "coldMs": 1681.34, + "warmMedianMs": 1681.34 + }, + "parse:oxc-parser": { + "coldMs": 6.83, + "warmMedianMs": 6.61 + }, + "parse:createSourceFile": { + "coldMs": 8.98, + "warmMedianMs": 6.36 + } + } + }, + { + "name": "@systemfsoftware/discern", + "version": "1.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 40, + "sourceBytes": 255997, + "declarationFiles": 40, + "declarationStatements": 732, + "clean": { + "oxc-transform": { + "diagnostics": 144, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 144, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 355, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 40, + "differing": 14, + "typescript59ExitCode": 0 + }, + "phases": { + "discover": { + "coldMs": 17.43, + "warmMedianMs": 17.97 + }, + "emit:oxc-transform": { + "coldMs": 3.07, + "warmMedianMs": 2.37 + }, + "emit:transpileDeclaration": { + "coldMs": 94.58, + "warmMedianMs": 83.71 + }, + "emit:tsc7": { + "coldMs": 275.1, + "warmMedianMs": 282.05 + }, + "emit:tsc5.9.3": { + "coldMs": 1985.17, + "warmMedianMs": 1985.17 + }, + "parse:oxc-parser": { + "coldMs": 7.75, + "warmMedianMs": 6.54 + }, + "parse:createSourceFile": { + "coldMs": 15.81, + "warmMedianMs": 5.95 + } + } + }, + { + "name": "@systemfsoftware/effect-cell-types", + "version": "12.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 7, + "sourceBytes": 68241, + "declarationFiles": 7, + "declarationStatements": 187, + "clean": { + "oxc-transform": { + "diagnostics": 1, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 1, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 2, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 7, + "differing": 1, + "typescript59ExitCode": 0 + }, + "phases": { + "discover": { + "coldMs": 2.68, + "warmMedianMs": 2.24 + }, + "emit:oxc-transform": { + "coldMs": 0.96, + "warmMedianMs": 0.69 + }, + "emit:transpileDeclaration": { + "coldMs": 23.22, + "warmMedianMs": 16.62 + }, + "emit:tsc7": { + "coldMs": 165.21, + "warmMedianMs": 165.88 + }, + "emit:tsc5.9.3": { + "coldMs": 1508.86, + "warmMedianMs": 1508.86 + }, + "parse:oxc-parser": { + "coldMs": 18.7, + "warmMedianMs": 2.45 + }, + "parse:createSourceFile": { + "coldMs": 2.31, + "warmMedianMs": 3.03 + } + } + }, + { + "name": "@systemfsoftware/effect-memfs", + "version": "6.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 19, + "sourceBytes": 63433, + "declarationFiles": 19, + "declarationStatements": 198, + "clean": { + "oxc-transform": { + "diagnostics": 68, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 68, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 104, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 19, + "differing": 1, + "typescript59ExitCode": 0 + }, + "phases": { + "discover": { + "coldMs": 4.58, + "warmMedianMs": 5.14 + }, + "emit:oxc-transform": { + "coldMs": 1.09, + "warmMedianMs": 0.75 + }, + "emit:transpileDeclaration": { + "coldMs": 28.22, + "warmMedianMs": 25.85 + }, + "emit:tsc7": { + "coldMs": 260, + "warmMedianMs": 255.23 + }, + "emit:tsc5.9.3": { + "coldMs": 1918.39, + "warmMedianMs": 1918.39 + }, + "parse:oxc-parser": { + "coldMs": 3.59, + "warmMedianMs": 2.16 + }, + "parse:createSourceFile": { + "coldMs": 1.42, + "warmMedianMs": 1.03 + } + } + }, + { + "name": "@systemfsoftware/effect-microsandbox", + "version": "5.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 22, + "sourceBytes": 60081, + "declarationFiles": 22, + "declarationStatements": 271, + "clean": { + "oxc-transform": { + "diagnostics": 94, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 93, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 222, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 22, + "differing": 9, + "typescript59ExitCode": 0 + }, + "phases": { + "discover": { + "coldMs": 6.11, + "warmMedianMs": 6.28 + }, + "emit:oxc-transform": { + "coldMs": 1.86, + "warmMedianMs": 1.41 + }, + "emit:transpileDeclaration": { + "coldMs": 43.81, + "warmMedianMs": 30.02 + }, + "emit:tsc7": { + "coldMs": 313.43, + "warmMedianMs": 305.93 + }, + "emit:tsc5.9.3": { + "coldMs": 2000.78, + "warmMedianMs": 2000.78 + }, + "parse:oxc-parser": { + "coldMs": 4.2, + "warmMedianMs": 2.43 + }, + "parse:createSourceFile": { + "coldMs": 3.42, + "warmMedianMs": 1.98 + } + } + }, + { + "name": "@systemfsoftware/effect-readiness", + "version": "1.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 19, + "sourceBytes": 29487, + "declarationFiles": 19, + "declarationStatements": 145, + "clean": { + "oxc-transform": { + "diagnostics": 53, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 53, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 83, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 19, + "differing": 1, + "typescript59ExitCode": 0 + }, + "phases": { + "discover": { + "coldMs": 7.35, + "warmMedianMs": 6.41 + }, + "emit:oxc-transform": { + "coldMs": 0.92, + "warmMedianMs": 0.46 + }, + "emit:transpileDeclaration": { + "coldMs": 18.18, + "warmMedianMs": 16.32 + }, + "emit:tsc7": { + "coldMs": 259.63, + "warmMedianMs": 210.04 + }, + "emit:tsc5.9.3": { + "coldMs": 1533.94, + "warmMedianMs": 1533.94 + }, + "parse:oxc-parser": { + "coldMs": 1.45, + "warmMedianMs": 1.04 + }, + "parse:createSourceFile": { + "coldMs": 1.34, + "warmMedianMs": 0.86 + } + } + }, + { + "name": "@systemfsoftware/effect-spec-runtime", + "version": "1.0.1", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 6, + "sourceBytes": 33771, + "declarationFiles": 6, + "declarationStatements": 49, + "clean": { + "oxc-transform": { + "diagnostics": 1, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 1, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 2, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 6, + "differing": 0, + "typescript59ExitCode": 0 + }, + "phases": { + "discover": { + "coldMs": 2.35, + "warmMedianMs": 2.04 + }, + "emit:oxc-transform": { + "coldMs": 0.58, + "warmMedianMs": 0.26 + }, + "emit:transpileDeclaration": { + "coldMs": 11.07, + "warmMedianMs": 8.99 + }, + "emit:tsc7": { + "coldMs": 213.67, + "warmMedianMs": 211.5 + }, + "emit:tsc5.9.3": { + "coldMs": 1572.39, + "warmMedianMs": 1572.39 + }, + "parse:oxc-parser": { + "coldMs": 1.29, + "warmMedianMs": 0.48 + }, + "parse:createSourceFile": { + "coldMs": 0.79, + "warmMedianMs": 0.44 + } + } + }, + { + "name": "@systemfsoftware/effect-gherkin-spec", + "version": "7.0.1", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 9, + "sourceBytes": 67914, + "declarationFiles": 9, + "declarationStatements": 138, + "clean": { + "oxc-transform": { + "diagnostics": 15, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 19, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 24, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 9, + "differing": 1, + "typescript59ExitCode": 0 + }, + "phases": { + "discover": { + "coldMs": 6.41, + "warmMedianMs": 5.47 + }, + "emit:oxc-transform": { + "coldMs": 1.04, + "warmMedianMs": 0.65 + }, + "emit:transpileDeclaration": { + "coldMs": 29.97, + "warmMedianMs": 24.06 + }, + "emit:tsc7": { + "coldMs": 227.83, + "warmMedianMs": 224.19 + }, + "emit:tsc5.9.3": { + "coldMs": 1593.8, + "warmMedianMs": 1593.8 + }, + "parse:oxc-parser": { + "coldMs": 2.41, + "warmMedianMs": 1.59 + }, + "parse:createSourceFile": { + "coldMs": 2.01, + "warmMedianMs": 1.39 + } + } + }, + { + "name": "@systemfsoftware/storybook-gherkin", + "version": "5.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 5, + "sourceBytes": 37687, + "declarationFiles": 5, + "declarationStatements": 92, + "clean": { + "oxc-transform": { + "diagnostics": 12, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 11, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 34, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 5, + "differing": 1, + "typescript59ExitCode": 0 + }, + "phases": { + "discover": { + "coldMs": 4.08, + "warmMedianMs": 3.58 + }, + "emit:oxc-transform": { + "coldMs": 0.77, + "warmMedianMs": 0.42 + }, + "emit:transpileDeclaration": { + "coldMs": 14.73, + "warmMedianMs": 11.95 + }, + "emit:tsc7": { + "coldMs": 154.5, + "warmMedianMs": 155.33 + }, + "emit:tsc5.9.3": { + "coldMs": 973.78, + "warmMedianMs": 973.78 + }, + "parse:oxc-parser": { + "coldMs": 1.54, + "warmMedianMs": 1.03 + }, + "parse:createSourceFile": { + "coldMs": 0.97, + "warmMedianMs": 0.64 + } + } + }, + { + "name": "@systemfsoftware/npm-package", + "version": "1.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 11, + "sourceBytes": 31182, + "declarationFiles": 11, + "declarationStatements": 74, + "clean": { + "oxc-transform": { + "diagnostics": 29, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 29, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 41, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 11, + "differing": 0, + "typescript59ExitCode": 0 + }, + "phases": { + "discover": { + "coldMs": 4.21, + "warmMedianMs": 3.05 + }, + "emit:oxc-transform": { + "coldMs": 0.96, + "warmMedianMs": 0.62 + }, + "emit:transpileDeclaration": { + "coldMs": 20.28, + "warmMedianMs": 12.78 + }, + "emit:tsc7": { + "coldMs": 256.34, + "warmMedianMs": 242.43 + }, + "emit:tsc5.9.3": { + "coldMs": 1915.23, + "warmMedianMs": 1915.23 + }, + "parse:oxc-parser": { + "coldMs": 0.91, + "warmMedianMs": 0.41 + }, + "parse:createSourceFile": { + "coldMs": 1.23, + "warmMedianMs": 0.41 + } + } + }, + { + "name": "@systemfsoftware/oxlint-plugin-cell-architecture", + "version": "5.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 43, + "sourceBytes": 133875, + "declarationFiles": 43, + "declarationStatements": 256, + "clean": { + "oxc-transform": { + "diagnostics": 65, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 65, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 71, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 43, + "differing": 19, + "typescript59ExitCode": 0 + }, + "phases": { + "discover": { + "coldMs": 8.68, + "warmMedianMs": 7.81 + }, + "emit:oxc-transform": { + "coldMs": 1.47, + "warmMedianMs": 1.05 + }, + "emit:transpileDeclaration": { + "coldMs": 63.64, + "warmMedianMs": 53.62 + }, + "emit:tsc7": { + "coldMs": 427.71, + "warmMedianMs": 434.83 + }, + "emit:tsc5.9.3": { + "coldMs": 2566.96, + "warmMedianMs": 2566.96 + }, + "parse:oxc-parser": { + "coldMs": 2.79, + "warmMedianMs": 1.33 + }, + "parse:createSourceFile": { + "coldMs": 1.88, + "warmMedianMs": 1.26 + } + } + }, + { + "name": "@systemfsoftware/oxlint-plugin-dmmf-workflow", + "version": "7.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 19, + "sourceBytes": 106678, + "declarationFiles": 19, + "declarationStatements": 161, + "clean": { + "oxc-transform": { + "diagnostics": 65, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 65, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 65, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 19, + "differing": 8, + "typescript59ExitCode": 0 + }, + "phases": { + "discover": { + "coldMs": 5.81, + "warmMedianMs": 4.23 + }, + "emit:oxc-transform": { + "coldMs": 1.05, + "warmMedianMs": 0.69 + }, + "emit:transpileDeclaration": { + "coldMs": 29.37, + "warmMedianMs": 30.69 + }, + "emit:tsc7": { + "coldMs": 512.17, + "warmMedianMs": 341.26 + }, + "emit:tsc5.9.3": { + "coldMs": 2218.96, + "warmMedianMs": 2218.96 + }, + "parse:oxc-parser": { + "coldMs": 1.44, + "warmMedianMs": 0.83 + }, + "parse:createSourceFile": { + "coldMs": 1.48, + "warmMedianMs": 0.81 + } + } + }, + { + "name": "@systemfsoftware/oxlint-plugin-effect-platform", + "version": "2.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 21, + "sourceBytes": 56432, + "declarationFiles": 21, + "declarationStatements": 126, + "clean": { + "oxc-transform": { + "diagnostics": 52, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 52, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 62, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 21, + "differing": 10, + "typescript59ExitCode": 0 + }, + "phases": { + "discover": { + "coldMs": 6.76, + "warmMedianMs": 3.2 + }, + "emit:oxc-transform": { + "coldMs": 0.83, + "warmMedianMs": 0.46 + }, + "emit:transpileDeclaration": { + "coldMs": 35.69, + "warmMedianMs": 20.11 + }, + "emit:tsc7": { + "coldMs": 238.05, + "warmMedianMs": 238.21 + }, + "emit:tsc5.9.3": { + "coldMs": 1371.96, + "warmMedianMs": 1371.96 + }, + "parse:oxc-parser": { + "coldMs": 0.98, + "warmMedianMs": 0.56 + }, + "parse:createSourceFile": { + "coldMs": 0.82, + "warmMedianMs": 0.47 + } + } + }, + { + "name": "@systemfsoftware/oxlint-plugin-effect-schema", + "version": "6.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 23, + "sourceBytes": 139000, + "declarationFiles": 23, + "declarationStatements": 139, + "clean": { + "oxc-transform": { + "diagnostics": 42, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 42, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 45, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 23, + "differing": 10, + "typescript59ExitCode": 0 + }, + "phases": { + "discover": { + "coldMs": 28.74, + "warmMedianMs": 5.54 + }, + "emit:oxc-transform": { + "coldMs": 1.22, + "warmMedianMs": 0.76 + }, + "emit:transpileDeclaration": { + "coldMs": 28.46, + "warmMedianMs": 26.07 + }, + "emit:tsc7": { + "coldMs": 298.86, + "warmMedianMs": 277.19 + }, + "emit:tsc5.9.3": { + "coldMs": 1646.54, + "warmMedianMs": 1646.54 + }, + "parse:oxc-parser": { + "coldMs": 1.52, + "warmMedianMs": 1.13 + }, + "parse:createSourceFile": { + "coldMs": 1.54, + "warmMedianMs": 1.02 + } + } + }, + { + "name": "@systemfsoftware/oxlint-plugin-test-discipline", + "version": "5.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 65, + "sourceBytes": 206741, + "declarationFiles": 65, + "declarationStatements": 343, + "clean": { + "oxc-transform": { + "diagnostics": 147, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 147, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 150, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 65, + "differing": 31, + "typescript59ExitCode": 0 + }, + "phases": { + "discover": { + "coldMs": 34.69, + "warmMedianMs": 13.13 + }, + "emit:oxc-transform": { + "coldMs": 2.91, + "warmMedianMs": 1.59 + }, + "emit:transpileDeclaration": { + "coldMs": 65.41, + "warmMedianMs": 65.54 + }, + "emit:tsc7": { + "coldMs": 157.41, + "warmMedianMs": 153.32 + }, + "emit:tsc5.9.3": { + "coldMs": 1186.2, + "warmMedianMs": 1186.2 + }, + "parse:oxc-parser": { + "coldMs": 2.43, + "warmMedianMs": 2 + }, + "parse:createSourceFile": { + "coldMs": 2, + "warmMedianMs": 1.62 + } + } + }, + { + "name": "@systemfsoftware/oxlint-config-cell-architecture", + "version": "4.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 1, + "sourceBytes": 928, + "declarationFiles": 1, + "declarationStatements": 7, + "clean": { + "oxc-transform": { + "diagnostics": 0, + "clean": true + }, + "transpileDeclaration": { + "diagnostics": 0, + "clean": true + }, + "tsc --isolatedDeclarations": { + "diagnostics": 0, + "clean": true + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 1, + "differing": 0, + "typescript59ExitCode": 0 + }, + "phases": { + "discover": { + "coldMs": 0.78, + "warmMedianMs": 0.41 + }, + "emit:oxc-transform": { + "coldMs": 0.15, + "warmMedianMs": 0.01 + }, + "emit:transpileDeclaration": { + "coldMs": 1.26, + "warmMedianMs": 0.67 + }, + "emit:tsc7": { + "coldMs": 291.22, + "warmMedianMs": 321.71 + }, + "emit:tsc5.9.3": { + "coldMs": 1664.61, + "warmMedianMs": 1664.61 + }, + "parse:oxc-parser": { + "coldMs": 0.17, + "warmMedianMs": 0.03 + }, + "parse:createSourceFile": { + "coldMs": 0.14, + "warmMedianMs": 0.02 + } + } + }, + { + "name": "@systemfsoftware/oxlint-config-dmmf", + "version": "2.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 1, + "sourceBytes": 3230, + "declarationFiles": 1, + "declarationStatements": 8, + "clean": { + "oxc-transform": { + "diagnostics": 0, + "clean": true + }, + "transpileDeclaration": { + "diagnostics": 0, + "clean": true + }, + "tsc --isolatedDeclarations": { + "diagnostics": 0, + "clean": true + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 1, + "differing": 0, + "typescript59ExitCode": 0 + }, + "phases": { + "discover": { + "coldMs": 0.54, + "warmMedianMs": 0.3 + }, + "emit:oxc-transform": { + "coldMs": 0.12, + "warmMedianMs": 0.02 + }, + "emit:transpileDeclaration": { + "coldMs": 1.23, + "warmMedianMs": 0.88 + }, + "emit:tsc7": { + "coldMs": 303.18, + "warmMedianMs": 304.39 + }, + "emit:tsc5.9.3": { + "coldMs": 1798.17, + "warmMedianMs": 1798.17 + }, + "parse:oxc-parser": { + "coldMs": 0.15, + "warmMedianMs": 0.03 + }, + "parse:createSourceFile": { + "coldMs": 0.14, + "warmMedianMs": 0.02 + } + } + }, + { + "name": "@systemfsoftware/oxlint-config-recommended", + "version": "4.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 1, + "sourceBytes": 4333, + "declarationFiles": 1, + "declarationStatements": 8, + "clean": { + "oxc-transform": { + "diagnostics": 0, + "clean": true + }, + "transpileDeclaration": { + "diagnostics": 0, + "clean": true + }, + "tsc --isolatedDeclarations": { + "diagnostics": 0, + "clean": true + } + }, + "fallback": { + "exitCode": 2, + "diagnostics": 1 + }, + "divergence": { + "compared": 1, + "differing": 1, + "typescript59ExitCode": 2 + }, + "phases": { + "discover": { + "coldMs": 1.03, + "warmMedianMs": 0.24 + }, + "emit:oxc-transform": { + "coldMs": 0.19, + "warmMedianMs": 0.02 + }, + "emit:transpileDeclaration": { + "coldMs": 1.65, + "warmMedianMs": 1.17 + }, + "emit:tsc7": { + "coldMs": 439.25, + "warmMedianMs": 429.27 + }, + "emit:tsc5.9.3": { + "coldMs": 2555.26, + "warmMedianMs": 2555.26 + }, + "parse:oxc-parser": { + "coldMs": 0.16, + "warmMedianMs": 0.03 + }, + "parse:createSourceFile": { + "coldMs": 0.16, + "warmMedianMs": 0.02 + } + } + }, + { + "name": "@systemfsoftware/oxlint-config-rule-authoring", + "version": "1.1.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 1, + "sourceBytes": 1395, + "declarationFiles": 1, + "declarationStatements": 6, + "clean": { + "oxc-transform": { + "diagnostics": 0, + "clean": true + }, + "transpileDeclaration": { + "diagnostics": 0, + "clean": true + }, + "tsc --isolatedDeclarations": { + "diagnostics": 0, + "clean": true + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 1, + "differing": 0, + "typescript59ExitCode": 0 + }, + "phases": { + "discover": { + "coldMs": 0.34, + "warmMedianMs": 0.14 + }, + "emit:oxc-transform": { + "coldMs": 0.09, + "warmMedianMs": 0.01 + }, + "emit:transpileDeclaration": { + "coldMs": 0.86, + "warmMedianMs": 0.47 + }, + "emit:tsc7": { + "coldMs": 52.79, + "warmMedianMs": 57.52 + }, + "emit:tsc5.9.3": { + "coldMs": 336.77, + "warmMedianMs": 336.77 + }, + "parse:oxc-parser": { + "coldMs": 0.14, + "warmMedianMs": 0.02 + }, + "parse:createSourceFile": { + "coldMs": 0.16, + "warmMedianMs": 0.02 + } + } + }, + { + "name": "@systemfsoftware/vitest", + "version": "2.0.0", + "origin": "systemfsoftware", + "entryPoints": 5, + "sourceFiles": 32, + "sourceBytes": 292776, + "declarationFiles": 32, + "declarationStatements": 451, + "clean": { + "oxc-transform": { + "diagnostics": 52, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 52, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 126, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 32, + "differing": 1, + "typescript59ExitCode": 0 + }, + "phases": { + "discover": { + "coldMs": 15.65, + "warmMedianMs": 11.75 + }, + "emit:oxc-transform": { + "coldMs": 2.6, + "warmMedianMs": 1.97 + }, + "emit:transpileDeclaration": { + "coldMs": 84.34, + "warmMedianMs": 68.3 + }, + "emit:tsc7": { + "coldMs": 155.19, + "warmMedianMs": 156.76 + }, + "emit:tsc5.9.3": { + "coldMs": 1251.78, + "warmMedianMs": 1251.78 + }, + "parse:oxc-parser": { + "coldMs": 4.46, + "warmMedianMs": 3.77 + }, + "parse:createSourceFile": { + "coldMs": 5.05, + "warmMedianMs": 7.78 + } + } + }, + { + "name": "@systemfsoftware/rx-effect", + "version": "1.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 2, + "sourceBytes": 705, + "declarationFiles": 2, + "declarationStatements": 6, + "clean": { + "oxc-transform": { + "diagnostics": 0, + "clean": true + }, + "transpileDeclaration": { + "diagnostics": 0, + "clean": true + }, + "tsc --isolatedDeclarations": { + "diagnostics": 0, + "clean": true + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 2, + "differing": 0, + "typescript59ExitCode": 0 + }, + "phases": { + "discover": { + "coldMs": 0.84, + "warmMedianMs": 0.24 + }, + "emit:oxc-transform": { + "coldMs": 0.1, + "warmMedianMs": 0.01 + }, + "emit:transpileDeclaration": { + "coldMs": 1.76, + "warmMedianMs": 1.04 + }, + "emit:tsc7": { + "coldMs": 68.67, + "warmMedianMs": 71.03 + }, + "emit:tsc5.9.3": { + "coldMs": 559.16, + "warmMedianMs": 559.16 + }, + "parse:oxc-parser": { + "coldMs": 0.21, + "warmMedianMs": 0.03 + }, + "parse:createSourceFile": { + "coldMs": 0.19, + "warmMedianMs": 0.02 + } + } + }, + { + "name": "@systemfsoftware/effect-schema-discovery", + "version": "2.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 2, + "sourceBytes": 8481, + "declarationFiles": 2, + "declarationStatements": 9, + "clean": { + "oxc-transform": { + "diagnostics": 0, + "clean": true + }, + "transpileDeclaration": { + "diagnostics": 0, + "clean": true + }, + "tsc --isolatedDeclarations": { + "diagnostics": 0, + "clean": true + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 2, + "differing": 0, + "typescript59ExitCode": 0 + }, + "phases": { + "discover": { + "coldMs": 1.52, + "warmMedianMs": 0.73 + }, + "emit:oxc-transform": { + "coldMs": 0.19, + "warmMedianMs": 0.04 + }, + "emit:transpileDeclaration": { + "coldMs": 3.09, + "warmMedianMs": 1.97 + }, + "emit:tsc7": { + "coldMs": 65.22, + "warmMedianMs": 67.02 + }, + "emit:tsc5.9.3": { + "coldMs": 452.2, + "warmMedianMs": 452.2 + }, + "parse:oxc-parser": { + "coldMs": 0.27, + "warmMedianMs": 0.04 + }, + "parse:createSourceFile": { + "coldMs": 0.33, + "warmMedianMs": 0.04 + } + } + }, + { + "name": "@systemfsoftware/effect-schema-extensions", + "version": "1.0.0", + "origin": "systemfsoftware", + "entryPoints": 2, + "sourceFiles": 2, + "sourceBytes": 88, + "declarationFiles": 2, + "declarationStatements": 2, + "clean": { + "oxc-transform": { + "diagnostics": 0, + "clean": true + }, + "transpileDeclaration": { + "diagnostics": 0, + "clean": true + }, + "tsc --isolatedDeclarations": { + "diagnostics": 0, + "clean": true + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 2, + "differing": 0, + "typescript59ExitCode": 0 + }, + "phases": { + "discover": { + "coldMs": 0.58, + "warmMedianMs": 0.26 + }, + "emit:oxc-transform": { + "coldMs": 0.06, + "warmMedianMs": 0.01 + }, + "emit:transpileDeclaration": { + "coldMs": 1.05, + "warmMedianMs": 0.61 + }, + "emit:tsc7": { + "coldMs": 92.86, + "warmMedianMs": 102.96 + }, + "emit:tsc5.9.3": { + "coldMs": 889.41, + "warmMedianMs": 889.41 + }, + "parse:oxc-parser": { + "coldMs": 0.1, + "warmMedianMs": 0.02 + }, + "parse:createSourceFile": { + "coldMs": 0.07, + "warmMedianMs": 0.01 + } + } + }, + { + "name": "@systemfsoftware/effect-schema-law", + "version": "5.0.1", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 3, + "sourceBytes": 26541, + "declarationFiles": 3, + "declarationStatements": 6, + "clean": { + "oxc-transform": { + "diagnostics": 0, + "clean": true + }, + "transpileDeclaration": { + "diagnostics": 0, + "clean": true + }, + "tsc --isolatedDeclarations": { + "diagnostics": 0, + "clean": true + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 3, + "differing": 0, + "typescript59ExitCode": 0 + }, + "phases": { + "discover": { + "coldMs": 2.08, + "warmMedianMs": 1.09 + }, + "emit:oxc-transform": { + "coldMs": 0.35, + "warmMedianMs": 0.15 + }, + "emit:transpileDeclaration": { + "coldMs": 4.91, + "warmMedianMs": 3.73 + }, + "emit:tsc7": { + "coldMs": 181.33, + "warmMedianMs": 200.41 + }, + "emit:tsc5.9.3": { + "coldMs": 1592.33, + "warmMedianMs": 1592.33 + }, + "parse:oxc-parser": { + "coldMs": 0.27, + "warmMedianMs": 0.05 + }, + "parse:createSourceFile": { + "coldMs": 0.17, + "warmMedianMs": 0.03 + } + } + }, + { + "name": "@systemfsoftware/effect-schema-recursion-budget", + "version": "2.0.0", + "origin": "systemfsoftware", + "entryPoints": 2, + "sourceFiles": 3, + "sourceBytes": 13872, + "declarationFiles": 3, + "declarationStatements": 7, + "clean": { + "oxc-transform": { + "diagnostics": 0, + "clean": true + }, + "transpileDeclaration": { + "diagnostics": 0, + "clean": true + }, + "tsc --isolatedDeclarations": { + "diagnostics": 0, + "clean": true + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 3, + "differing": 1, + "typescript59ExitCode": 0 + }, + "phases": { + "discover": { + "coldMs": 8.38, + "warmMedianMs": 0.65 + }, + "emit:oxc-transform": { + "coldMs": 0.26, + "warmMedianMs": 0.09 + }, + "emit:transpileDeclaration": { + "coldMs": 4.22, + "warmMedianMs": 2.87 + }, + "emit:tsc7": { + "coldMs": 202.46, + "warmMedianMs": 216.73 + }, + "emit:tsc5.9.3": { + "coldMs": 1464.36, + "warmMedianMs": 1464.36 + }, + "parse:oxc-parser": { + "coldMs": 0.26, + "warmMedianMs": 0.06 + }, + "parse:createSourceFile": { + "coldMs": 0.25, + "warmMedianMs": 0.06 + } + } + }, + { + "name": "@systemfsoftware/effect-schema-vite", + "version": "3.0.1", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 2, + "sourceBytes": 5828, + "declarationFiles": 2, + "declarationStatements": 9, + "clean": { + "oxc-transform": { + "diagnostics": 1, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 1, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 1, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 2, + "differing": 1, + "typescript59ExitCode": 0 + }, + "phases": { + "discover": { + "coldMs": 3.94, + "warmMedianMs": 0.4 + }, + "emit:oxc-transform": { + "coldMs": 0.21, + "warmMedianMs": 0.03 + }, + "emit:transpileDeclaration": { + "coldMs": 2.79, + "warmMedianMs": 2.01 + }, + "emit:tsc7": { + "coldMs": 103.52, + "warmMedianMs": 108.36 + }, + "emit:tsc5.9.3": { + "coldMs": 933.31, + "warmMedianMs": 933.31 + }, + "parse:oxc-parser": { + "coldMs": 0.21, + "warmMedianMs": 0.04 + }, + "parse:createSourceFile": { + "coldMs": 0.23, + "warmMedianMs": 0.03 + } + } + }, + { + "name": "@systemfsoftware/hex-schema", + "version": "2.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 7, + "sourceBytes": 3921, + "declarationFiles": 7, + "declarationStatements": 22, + "clean": { + "oxc-transform": { + "diagnostics": 6, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 6, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 6, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 7, + "differing": 0, + "typescript59ExitCode": 0 + }, + "phases": { + "discover": { + "coldMs": 4.85, + "warmMedianMs": 1.39 + }, + "emit:oxc-transform": { + "coldMs": 0.24, + "warmMedianMs": 0.05 + }, + "emit:transpileDeclaration": { + "coldMs": 6.83, + "warmMedianMs": 4.35 + }, + "emit:tsc7": { + "coldMs": 106.83, + "warmMedianMs": 95.19 + }, + "emit:tsc5.9.3": { + "coldMs": 797.26, + "warmMedianMs": 797.26 + }, + "parse:oxc-parser": { + "coldMs": 0.56, + "warmMedianMs": 0.16 + }, + "parse:createSourceFile": { + "coldMs": 0.45, + "warmMedianMs": 0.17 + } + } + }, + { + "name": "@systemfsoftware/conformance-spec", + "version": "1.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 8, + "sourceBytes": 48889, + "declarationFiles": 8, + "declarationStatements": 68, + "clean": { + "oxc-transform": { + "diagnostics": 2, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 2, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 4, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 8, + "differing": 1, + "typescript59ExitCode": 0 + }, + "phases": { + "discover": { + "coldMs": 4.18, + "warmMedianMs": 2.61 + }, + "emit:oxc-transform": { + "coldMs": 0.73, + "warmMedianMs": 0.34 + }, + "emit:transpileDeclaration": { + "coldMs": 12.22, + "warmMedianMs": 11.39 + }, + "emit:tsc7": { + "coldMs": 200.24, + "warmMedianMs": 213.23 + }, + "emit:tsc5.9.3": { + "coldMs": 1951.66, + "warmMedianMs": 1951.66 + }, + "parse:oxc-parser": { + "coldMs": 0.95, + "warmMedianMs": 0.74 + }, + "parse:createSourceFile": { + "coldMs": 1.28, + "warmMedianMs": 0.84 + } + } + }, + { + "name": "@systemfsoftware/differential-spec", + "version": "1.0.1", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 11, + "sourceBytes": 32396, + "declarationFiles": 11, + "declarationStatements": 85, + "clean": { + "oxc-transform": { + "diagnostics": 21, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 21, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 27, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 11, + "differing": 0, + "typescript59ExitCode": 0 + }, + "phases": { + "discover": { + "coldMs": 16.72, + "warmMedianMs": 2.7 + }, + "emit:oxc-transform": { + "coldMs": 0.69, + "warmMedianMs": 0.33 + }, + "emit:transpileDeclaration": { + "coldMs": 17.75, + "warmMedianMs": 12.39 + }, + "emit:tsc7": { + "coldMs": 272.59, + "warmMedianMs": 260.02 + }, + "emit:tsc5.9.3": { + "coldMs": 1678.8, + "warmMedianMs": 1678.8 + }, + "parse:oxc-parser": { + "coldMs": 1.14, + "warmMedianMs": 0.73 + }, + "parse:createSourceFile": { + "coldMs": 0.76, + "warmMedianMs": 0.52 + } + } + }, + { + "name": "@systemfsoftware/effect-sim-kernel", + "version": "1.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 16, + "sourceBytes": 115718, + "declarationFiles": 16, + "declarationStatements": 137, + "clean": { + "oxc-transform": { + "diagnostics": 2, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 2, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 2, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 16, + "differing": 2, + "typescript59ExitCode": 0 + }, + "phases": { + "discover": { + "coldMs": 11.3, + "warmMedianMs": 6.46 + }, + "emit:oxc-transform": { + "coldMs": 1.16, + "warmMedianMs": 0.78 + }, + "emit:transpileDeclaration": { + "coldMs": 29.4, + "warmMedianMs": 20.41 + }, + "emit:tsc7": { + "coldMs": 172.47, + "warmMedianMs": 184.89 + }, + "emit:tsc5.9.3": { + "coldMs": 2038.25, + "warmMedianMs": 2038.25 + }, + "parse:oxc-parser": { + "coldMs": 2.33, + "warmMedianMs": 1.17 + }, + "parse:createSourceFile": { + "coldMs": 1.92, + "warmMedianMs": 1.09 + } + } + }, + { + "name": "@systemfsoftware/trace-spec", + "version": "1.0.1", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 31, + "sourceBytes": 123477, + "declarationFiles": 31, + "declarationStatements": 342, + "clean": { + "oxc-transform": { + "diagnostics": 94, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 93, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 235, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 31, + "differing": 1, + "typescript59ExitCode": 0 + }, + "phases": { + "discover": { + "coldMs": 16.76, + "warmMedianMs": 9.46 + }, + "emit:oxc-transform": { + "coldMs": 2.1, + "warmMedianMs": 1.42 + }, + "emit:transpileDeclaration": { + "coldMs": 59.85, + "warmMedianMs": 56.01 + }, + "emit:tsc7": { + "coldMs": 470.73, + "warmMedianMs": 314.17 + }, + "emit:tsc5.9.3": { + "coldMs": 2637.96, + "warmMedianMs": 2637.96 + }, + "parse:oxc-parser": { + "coldMs": 4.34, + "warmMedianMs": 4.14 + }, + "parse:createSourceFile": { + "coldMs": 4.01, + "warmMedianMs": 3.31 + } + } + }, + { + "name": "@systemfsoftware/trace-taxonomy", + "version": "1.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 3, + "sourceBytes": 7226, + "declarationFiles": 3, + "declarationStatements": 28, + "clean": { + "oxc-transform": { + "diagnostics": 0, + "clean": true + }, + "transpileDeclaration": { + "diagnostics": 0, + "clean": true + }, + "tsc --isolatedDeclarations": { + "diagnostics": 0, + "clean": true + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 3, + "differing": 0, + "typescript59ExitCode": 0 + }, + "phases": { + "discover": { + "coldMs": 1.28, + "warmMedianMs": 0.66 + }, + "emit:oxc-transform": { + "coldMs": 0.26, + "warmMedianMs": 0.06 + }, + "emit:transpileDeclaration": { + "coldMs": 6.88, + "warmMedianMs": 5.8 + }, + "emit:tsc7": { + "coldMs": 194.32, + "warmMedianMs": 184.18 + }, + "emit:tsc5.9.3": { + "coldMs": 1569.8, + "warmMedianMs": 1569.8 + }, + "parse:oxc-parser": { + "coldMs": 0.57, + "warmMedianMs": 0.21 + }, + "parse:createSourceFile": { + "coldMs": 0.75, + "warmMedianMs": 0.23 + } + } + } + ] +} diff --git a/docs/profiles/2026-10-12-front-end-phases.md b/docs/profiles/2026-10-12-front-end-phases.md new file mode 100644 index 0000000..77c94c7 --- /dev/null +++ b/docs/profiles/2026-10-12-front-end-phases.md @@ -0,0 +1,97 @@ +# Front-end phase profile + +Generated by `apps/api-extractor-profile/profile.ts`; the JSON beside this file is the record. Times are warm medians in milliseconds; +the clean columns count isolated-declaration diagnostics over the files the entry sources reach. + +```json +{ + "corpus": { + "repository": "https://github.com/systemfsoftware/systemfsoftware", + "commit": "732f66a0c85819b2005f9e0930a595bc4de91ee1" + }, + "ownPackage": { + "repository": "https://github.com/systemfsoftware/api-extractor-effect", + "commit": "925b4ccb9a20298f4b6a2f5d7b130937bd0d3f03" + }, + "host": { + "cpu": "AMD Ryzen 9 9950X 16-Core Processor", + "cores": 32, + "platform": "linux-x64", + "node": "v24.21.0" + }, + "tools": { + "oxc-transform": "0.150.0", + "oxc-parser": "0.150.0", + "typescript": "7.0.2", + "typescript-5.9": "5.9.3" + }, + "runs": { + "cold": 1, + "warm": 5, + "emit:tsc5.9.3": "cold only" + }, + "totals": { + "discover": 181.9, + "emit:oxc-transform": 24.85, + "emit:transpileDeclaration": 892.7, + "emit:tsc7": 9257.41, + "emit:tsc5.9.3": 64652.24, + "parse:oxc-parser": 52.94, + "parse:createSourceFile": 54.33 + } +} +``` + +| Package | Origin | Sources | oxc clean | tD clean | tsc ID clean | discover | emit:oxc-transform | emit:transpileDeclaration | emit:tsc7 | emit:tsc5.9.3 | parse:oxc-parser | parse:createSourceFile | TS7≠5.9.3 | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| @systemfsoftware/api-extractor | api-extractor-effect | 17 | 0 | 0 | 0 | 5.87 | 0.67 | 31.45 | 117.89 | 984.98 | 0.9 | 1.59 | 2/17 | +| @systemfsoftware/effect-atom | systemfsoftware | 35 | 145 | 145 | 184 | 22.62 | 3.67 | 124.16 | 380.32 | 2433.07 | 6.89 | 8.01 | 1/35 | +| @systemfsoftware/effect-atom-react | systemfsoftware | 10 | 20 | 20 | 29 | 2.87 | 0.31 | 15.13 | 496.58 | 2355.89 | 0.65 | 0.91 | 0/10 | +| @systemfsoftware/effect-daemon-cluster | systemfsoftware | 5 | 1 | 1 | 1 | 1.35 | 0.08 | 3.89 | 264.09 | 1848.7 | 0.29 | 0.15 | 1/5 | +| @systemfsoftware/effect-daemon-conformance | systemfsoftware | 15 | 47 | 47 | 96 | 5.16 | 0.46 | 18.13 | 246.99 | 1714.23 | 1.3 | 1.17 | 0/15 | +| @systemfsoftware/effect-daemon-microvm | systemfsoftware | 9 | 21 | 21 | 28 | 1.74 | 0.24 | 8.32 | 307.8 | 2009.5 | 0.27 | 0.24 | 1/9 | +| @systemfsoftware/effect-daemon-process | systemfsoftware | 9 | 22 | 22 | 29 | 1.75 | 0.21 | 8.5 | 214.73 | 1608.7 | 0.3 | 0.3 | 1/9 | +| @systemfsoftware/effect-daemon-socket | systemfsoftware | 12 | 19 | 19 | 31 | 2.96 | 0.35 | 12.01 | 235.76 | 1851.78 | 0.48 | 0.48 | 0/12 | +| @systemfsoftware/effect-daemon-spec | systemfsoftware | 22 | 138 | 138 | 279 | 12 | 1.34 | 50.71 | 208.02 | 1681.34 | 6.61 | 6.36 | 4/22 | +| @systemfsoftware/discern | systemfsoftware | 40 | 144 | 144 | 355 | 17.97 | 2.37 | 83.71 | 282.05 | 1985.17 | 6.54 | 5.95 | 14/40 | +| @systemfsoftware/effect-cell-types | systemfsoftware | 7 | 1 | 1 | 2 | 2.24 | 0.69 | 16.62 | 165.88 | 1508.86 | 2.45 | 3.03 | 1/7 | +| @systemfsoftware/effect-memfs | systemfsoftware | 19 | 68 | 68 | 104 | 5.14 | 0.75 | 25.85 | 255.23 | 1918.39 | 2.16 | 1.03 | 1/19 | +| @systemfsoftware/effect-microsandbox | systemfsoftware | 22 | 94 | 93 | 222 | 6.28 | 1.41 | 30.02 | 305.93 | 2000.78 | 2.43 | 1.98 | 9/22 | +| @systemfsoftware/effect-readiness | systemfsoftware | 19 | 53 | 53 | 83 | 6.41 | 0.46 | 16.32 | 210.04 | 1533.94 | 1.04 | 0.86 | 1/19 | +| @systemfsoftware/effect-spec-runtime | systemfsoftware | 6 | 1 | 1 | 2 | 2.04 | 0.26 | 8.99 | 211.5 | 1572.39 | 0.48 | 0.44 | 0/6 | +| @systemfsoftware/effect-gherkin-spec | systemfsoftware | 9 | 15 | 19 | 24 | 5.47 | 0.65 | 24.06 | 224.19 | 1593.8 | 1.59 | 1.39 | 1/9 | +| @systemfsoftware/storybook-gherkin | systemfsoftware | 5 | 12 | 11 | 34 | 3.58 | 0.42 | 11.95 | 155.33 | 973.78 | 1.03 | 0.64 | 1/5 | +| @systemfsoftware/npm-package | systemfsoftware | 11 | 29 | 29 | 41 | 3.05 | 0.62 | 12.78 | 242.43 | 1915.23 | 0.41 | 0.41 | 0/11 | +| @systemfsoftware/oxlint-plugin-cell-architecture | systemfsoftware | 43 | 65 | 65 | 71 | 7.81 | 1.05 | 53.62 | 434.83 | 2566.96 | 1.33 | 1.26 | 19/43 | +| @systemfsoftware/oxlint-plugin-dmmf-workflow | systemfsoftware | 19 | 65 | 65 | 65 | 4.23 | 0.69 | 30.69 | 341.26 | 2218.96 | 0.83 | 0.81 | 8/19 | +| @systemfsoftware/oxlint-plugin-effect-platform | systemfsoftware | 21 | 52 | 52 | 62 | 3.2 | 0.46 | 20.11 | 238.21 | 1371.96 | 0.56 | 0.47 | 10/21 | +| @systemfsoftware/oxlint-plugin-effect-schema | systemfsoftware | 23 | 42 | 42 | 45 | 5.54 | 0.76 | 26.07 | 277.19 | 1646.54 | 1.13 | 1.02 | 10/23 | +| @systemfsoftware/oxlint-plugin-test-discipline | systemfsoftware | 65 | 147 | 147 | 150 | 13.13 | 1.59 | 65.54 | 153.32 | 1186.2 | 2 | 1.62 | 31/65 | +| @systemfsoftware/oxlint-config-cell-architecture | systemfsoftware | 1 | 0 | 0 | 0 | 0.41 | 0.01 | 0.67 | 321.71 | 1664.61 | 0.03 | 0.02 | 0/1 | +| @systemfsoftware/oxlint-config-dmmf | systemfsoftware | 1 | 0 | 0 | 0 | 0.3 | 0.02 | 0.88 | 304.39 | 1798.17 | 0.03 | 0.02 | 0/1 | +| @systemfsoftware/oxlint-config-recommended | systemfsoftware | 1 | 0 | 0 | 0 | 0.24 | 0.02 | 1.17 | 429.27 | 2555.26 | 0.03 | 0.02 | 1/1 | +| @systemfsoftware/oxlint-config-rule-authoring | systemfsoftware | 1 | 0 | 0 | 0 | 0.14 | 0.01 | 0.47 | 57.52 | 336.77 | 0.02 | 0.02 | 0/1 | +| @systemfsoftware/vitest | systemfsoftware | 32 | 52 | 52 | 126 | 11.75 | 1.97 | 68.3 | 156.76 | 1251.78 | 3.77 | 7.78 | 1/32 | +| @systemfsoftware/rx-effect | systemfsoftware | 2 | 0 | 0 | 0 | 0.24 | 0.01 | 1.04 | 71.03 | 559.16 | 0.03 | 0.02 | 0/2 | +| @systemfsoftware/effect-schema-discovery | systemfsoftware | 2 | 0 | 0 | 0 | 0.73 | 0.04 | 1.97 | 67.02 | 452.2 | 0.04 | 0.04 | 0/2 | +| @systemfsoftware/effect-schema-extensions | systemfsoftware | 2 | 0 | 0 | 0 | 0.26 | 0.01 | 0.61 | 102.96 | 889.41 | 0.02 | 0.01 | 0/2 | +| @systemfsoftware/effect-schema-law | systemfsoftware | 3 | 0 | 0 | 0 | 1.09 | 0.15 | 3.73 | 200.41 | 1592.33 | 0.05 | 0.03 | 0/3 | +| @systemfsoftware/effect-schema-recursion-budget | systemfsoftware | 3 | 0 | 0 | 0 | 0.65 | 0.09 | 2.87 | 216.73 | 1464.36 | 0.06 | 0.06 | 1/3 | +| @systemfsoftware/effect-schema-vite | systemfsoftware | 2 | 1 | 1 | 1 | 0.4 | 0.03 | 2.01 | 108.36 | 933.31 | 0.04 | 0.03 | 1/2 | +| @systemfsoftware/hex-schema | systemfsoftware | 7 | 6 | 6 | 6 | 1.39 | 0.05 | 4.35 | 95.19 | 797.26 | 0.16 | 0.17 | 0/7 | +| @systemfsoftware/conformance-spec | systemfsoftware | 8 | 2 | 2 | 4 | 2.61 | 0.34 | 11.39 | 213.23 | 1951.66 | 0.74 | 0.84 | 1/8 | +| @systemfsoftware/differential-spec | systemfsoftware | 11 | 21 | 21 | 27 | 2.7 | 0.33 | 12.39 | 260.02 | 1678.8 | 0.73 | 0.52 | 0/11 | +| @systemfsoftware/effect-sim-kernel | systemfsoftware | 16 | 2 | 2 | 2 | 6.46 | 0.78 | 20.41 | 184.89 | 2038.25 | 1.17 | 1.09 | 2/16 | +| @systemfsoftware/trace-spec | systemfsoftware | 31 | 94 | 93 | 235 | 9.46 | 1.42 | 56.01 | 314.17 | 2637.96 | 4.14 | 3.31 | 1/31 | +| @systemfsoftware/trace-taxonomy | systemfsoftware | 3 | 0 | 0 | 0 | 0.66 | 0.06 | 5.8 | 184.18 | 1569.8 | 0.21 | 0.23 | 0/3 | +| **total** | | 569 | | | | 181.9 | 24.85 | 892.7 | 9257.41 | 64652.24 | 52.94 | 54.33 | 124/569 | + +## Decision + +The fast-path emitter is `oxc-transform` 0.150.0 `isolatedDeclarationSync`, and declarations are parsed with `oxc-parser` 0.150.0. The rule is the one the plan registered before this profile ran (KTD2): + +1. **The clean-or-not verdict matches `tsc --isolatedDeclarations` on every corpus package.** Both candidates pass. Of the 40 packages, 11 are clean under all three referees and 29 are not clean under any of them. The diagnostic counts differ on four packages (effect-microsandbox, effect-gherkin-spec, storybook-gherkin, trace-spec), but no package changes side. +2. **The lower corpus total of median warm emit plus parse time wins.** `oxc-transform` plus `oxc-parser` takes 24.85 + 52.94 = 77.79 ms. `transpileDeclaration` plus `createSourceFile` takes 892.7 + 54.33 = 947.03 ms. oxc is about 12 times faster. +3. Rule 3 (within 10%, the smaller runtime dependency) is not reached. + +The TypeScript 7 fallback takes 9257.41 ms over the same 40 packages. That is about 119 times the chosen fast path's emit and parse time, and it bounds the cost of routing a package to the fallback. TypeScript 7 and 5.9.3 print 124 of the 569 declaration files differently, so the builder must not depend on one compiler version's printing. diff --git a/docs/profiles/2026-10-13-front-end-phases.json b/docs/profiles/2026-10-13-front-end-phases.json new file mode 100644 index 0000000..36663c6 --- /dev/null +++ b/docs/profiles/2026-10-13-front-end-phases.json @@ -0,0 +1,3200 @@ +{ + "corpus": { + "repository": "https://github.com/systemfsoftware/systemfsoftware", + "commit": "732f66a0c85819b2005f9e0930a595bc4de91ee1" + }, + "ownPackage": { + "repository": "https://github.com/systemfsoftware/api-extractor-effect", + "commit": "432b608f15cfc21e8b9f3dddf07eeefe92079b8a" + }, + "host": { + "cpu": "AMD Ryzen 9 9950X 16-Core Processor", + "cores": 32, + "platform": "linux-x64", + "node": "v24.21.0" + }, + "tools": { + "oxc-transform": "0.150.0", + "oxc-parser": "0.150.0", + "typescript": "7.0.2", + "typescript-5.9": "5.9.3" + }, + "runs": { + "cold": 1, + "warm": 5, + "emit:tsc5.9.3": "cold only" + }, + "totals": { + "discover": 201.88, + "emit:oxc-transform": 25.74, + "emit:transpileDeclaration": 921.86, + "emit:tsc7": 9992.59, + "emit:tsc5.9.3": 67206.59, + "parse:oxc-parser": 56.57, + "parse:createSourceFile": 66, + "build:model": 274.59, + "resolve:messages": 132.58 + }, + "packages": [ + { + "name": "@systemfsoftware/api-extractor", + "version": "0.0.0", + "origin": "api-extractor-effect", + "entryPoints": 1, + "sourceFiles": 33, + "sourceBytes": 255596, + "declarationFiles": 33, + "declarationStatements": 342, + "clean": { + "oxc-transform": { + "diagnostics": 0, + "clean": true + }, + "transpileDeclaration": { + "diagnostics": 0, + "clean": true + }, + "tsc --isolatedDeclarations": { + "diagnostics": 0, + "clean": true + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 33, + "differing": 4, + "missing": 0, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "FastPath", + "outcome": "model", + "items": 3, + "locals": 73, + "messages": 125 + }, + "phases": { + "discover": { + "coldMs": 20.29, + "warmMedianMs": 17.28 + }, + "emit:oxc-transform": { + "coldMs": 6.92, + "warmMedianMs": 1.67 + }, + "emit:transpileDeclaration": { + "coldMs": 146.95, + "warmMedianMs": 67.01 + }, + "emit:tsc7": { + "coldMs": 306.86, + "warmMedianMs": 289.86 + }, + "emit:tsc5.9.3": { + "coldMs": 2256.55, + "warmMedianMs": 2256.55 + }, + "parse:oxc-parser": { + "coldMs": 2.81, + "warmMedianMs": 1.92 + }, + "parse:createSourceFile": { + "coldMs": 3.6, + "warmMedianMs": 1.99 + }, + "build:model": { + "coldMs": 34.67, + "warmMedianMs": 21.09 + }, + "resolve:messages": { + "coldMs": 11.4, + "warmMedianMs": 7.89 + } + } + }, + { + "name": "@systemfsoftware/effect-atom", + "version": "6.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 35, + "sourceBytes": 388509, + "declarationFiles": 35, + "declarationStatements": 668, + "clean": { + "oxc-transform": { + "diagnostics": 145, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 145, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 184, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 35, + "differing": 1, + "missing": 0, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "Fallback", + "outcome": "UnsupportedDeclaration: src/atom-http-api.service.d.ts: `declare global` is not supported", + "items": 0, + "locals": 0, + "messages": 0 + }, + "phases": { + "discover": { + "coldMs": 71.03, + "warmMedianMs": 17.5 + }, + "emit:oxc-transform": { + "coldMs": 3.95, + "warmMedianMs": 3.09 + }, + "emit:transpileDeclaration": { + "coldMs": 164.28, + "warmMedianMs": 113.54 + }, + "emit:tsc7": { + "coldMs": 429.09, + "warmMedianMs": 401.25 + }, + "emit:tsc5.9.3": { + "coldMs": 2482.43, + "warmMedianMs": 2482.43 + }, + "parse:oxc-parser": { + "coldMs": 8.87, + "warmMedianMs": 7.47 + }, + "parse:createSourceFile": { + "coldMs": 7.63, + "warmMedianMs": 13.44 + }, + "build:model": { + "coldMs": 5.57, + "warmMedianMs": 2.7 + }, + "resolve:messages": { + "coldMs": 2.48, + "warmMedianMs": 2.68 + } + } + }, + { + "name": "@systemfsoftware/effect-atom-react", + "version": "2.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 10, + "sourceBytes": 45423, + "declarationFiles": 10, + "declarationStatements": 86, + "clean": { + "oxc-transform": { + "diagnostics": 20, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 20, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 29, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 10, + "differing": 0, + "missing": 0, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "Fallback", + "outcome": "model", + "items": 1, + "locals": 5, + "messages": 27 + }, + "phases": { + "discover": { + "coldMs": 3.59, + "warmMedianMs": 2.62 + }, + "emit:oxc-transform": { + "coldMs": 0.59, + "warmMedianMs": 0.33 + }, + "emit:transpileDeclaration": { + "coldMs": 18.57, + "warmMedianMs": 13 + }, + "emit:tsc7": { + "coldMs": 488.89, + "warmMedianMs": 465.04 + }, + "emit:tsc5.9.3": { + "coldMs": 2346.68, + "warmMedianMs": 2346.68 + }, + "parse:oxc-parser": { + "coldMs": 1.12, + "warmMedianMs": 0.58 + }, + "parse:createSourceFile": { + "coldMs": 1.46, + "warmMedianMs": 0.95 + }, + "build:model": { + "coldMs": 13.72, + "warmMedianMs": 8.57 + }, + "resolve:messages": { + "coldMs": 1.86, + "warmMedianMs": 1.68 + } + } + }, + { + "name": "@systemfsoftware/effect-daemon-cluster", + "version": "1.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 5, + "sourceBytes": 10261, + "declarationFiles": 5, + "declarationStatements": 35, + "clean": { + "oxc-transform": { + "diagnostics": 1, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 1, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 1, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 5, + "differing": 1, + "missing": 0, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "Fallback", + "outcome": "model", + "items": 1, + "locals": 6, + "messages": 18 + }, + "phases": { + "discover": { + "coldMs": 3.27, + "warmMedianMs": 1.41 + }, + "emit:oxc-transform": { + "coldMs": 0.26, + "warmMedianMs": 0.07 + }, + "emit:transpileDeclaration": { + "coldMs": 5.91, + "warmMedianMs": 3.98 + }, + "emit:tsc7": { + "coldMs": 301.67, + "warmMedianMs": 261.14 + }, + "emit:tsc5.9.3": { + "coldMs": 1981.06, + "warmMedianMs": 1981.06 + }, + "parse:oxc-parser": { + "coldMs": 0.39, + "warmMedianMs": 0.18 + }, + "parse:createSourceFile": { + "coldMs": 0.61, + "warmMedianMs": 0.14 + }, + "build:model": { + "coldMs": 2.67, + "warmMedianMs": 2.11 + }, + "resolve:messages": { + "coldMs": 1.2, + "warmMedianMs": 0.88 + } + } + }, + { + "name": "@systemfsoftware/effect-daemon-conformance", + "version": "1.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 15, + "sourceBytes": 50561, + "declarationFiles": 15, + "declarationStatements": 146, + "clean": { + "oxc-transform": { + "diagnostics": 47, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 47, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 96, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 15, + "differing": 0, + "missing": 0, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "Fallback", + "outcome": "model", + "items": 1, + "locals": 38, + "messages": 61 + }, + "phases": { + "discover": { + "coldMs": 5.92, + "warmMedianMs": 5.73 + }, + "emit:oxc-transform": { + "coldMs": 1.08, + "warmMedianMs": 0.65 + }, + "emit:transpileDeclaration": { + "coldMs": 22.99, + "warmMedianMs": 15.48 + }, + "emit:tsc7": { + "coldMs": 258.03, + "warmMedianMs": 247.62 + }, + "emit:tsc5.9.3": { + "coldMs": 1653.71, + "warmMedianMs": 1653.71 + }, + "parse:oxc-parser": { + "coldMs": 2.22, + "warmMedianMs": 1.2 + }, + "parse:createSourceFile": { + "coldMs": 1.5, + "warmMedianMs": 1.05 + }, + "build:model": { + "coldMs": 14.07, + "warmMedianMs": 11.68 + }, + "resolve:messages": { + "coldMs": 6.9, + "warmMedianMs": 6.04 + } + } + }, + { + "name": "@systemfsoftware/effect-daemon-microvm", + "version": "1.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 9, + "sourceBytes": 20513, + "declarationFiles": 9, + "declarationStatements": 71, + "clean": { + "oxc-transform": { + "diagnostics": 21, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 21, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 28, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 9, + "differing": 1, + "missing": 0, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "Fallback", + "outcome": "model", + "items": 1, + "locals": 4, + "messages": 11 + }, + "phases": { + "discover": { + "coldMs": 2.91, + "warmMedianMs": 2.09 + }, + "emit:oxc-transform": { + "coldMs": 0.52, + "warmMedianMs": 0.26 + }, + "emit:transpileDeclaration": { + "coldMs": 11.77, + "warmMedianMs": 8.6 + }, + "emit:tsc7": { + "coldMs": 321.23, + "warmMedianMs": 335.71 + }, + "emit:tsc5.9.3": { + "coldMs": 2484.41, + "warmMedianMs": 2484.41 + }, + "parse:oxc-parser": { + "coldMs": 0.63, + "warmMedianMs": 0.34 + }, + "parse:createSourceFile": { + "coldMs": 0.71, + "warmMedianMs": 0.27 + }, + "build:model": { + "coldMs": 2.17, + "warmMedianMs": 1.77 + }, + "resolve:messages": { + "coldMs": 0.92, + "warmMedianMs": 0.81 + } + } + }, + { + "name": "@systemfsoftware/effect-daemon-process", + "version": "1.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 9, + "sourceBytes": 19021, + "declarationFiles": 9, + "declarationStatements": 64, + "clean": { + "oxc-transform": { + "diagnostics": 22, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 22, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 29, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 9, + "differing": 1, + "missing": 0, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "Fallback", + "outcome": "model", + "items": 1, + "locals": 0, + "messages": 7 + }, + "phases": { + "discover": { + "coldMs": 6.4, + "warmMedianMs": 1.82 + }, + "emit:oxc-transform": { + "coldMs": 0.49, + "warmMedianMs": 0.21 + }, + "emit:transpileDeclaration": { + "coldMs": 13.41, + "warmMedianMs": 9.41 + }, + "emit:tsc7": { + "coldMs": 287.08, + "warmMedianMs": 318.18 + }, + "emit:tsc5.9.3": { + "coldMs": 2013.84, + "warmMedianMs": 2013.84 + }, + "parse:oxc-parser": { + "coldMs": 0.67, + "warmMedianMs": 0.36 + }, + "parse:createSourceFile": { + "coldMs": 0.67, + "warmMedianMs": 0.31 + }, + "build:model": { + "coldMs": 2.32, + "warmMedianMs": 0.98 + }, + "resolve:messages": { + "coldMs": 0.68, + "warmMedianMs": 0.4 + } + } + }, + { + "name": "@systemfsoftware/effect-daemon-socket", + "version": "1.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 12, + "sourceBytes": 35576, + "declarationFiles": 12, + "declarationStatements": 101, + "clean": { + "oxc-transform": { + "diagnostics": 19, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 19, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 31, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 12, + "differing": 0, + "missing": 0, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "Fallback", + "outcome": "model", + "items": 1, + "locals": 2, + "messages": 25 + }, + "phases": { + "discover": { + "coldMs": 18.72, + "warmMedianMs": 3.59 + }, + "emit:oxc-transform": { + "coldMs": 0.73, + "warmMedianMs": 0.41 + }, + "emit:transpileDeclaration": { + "coldMs": 17.88, + "warmMedianMs": 14.99 + }, + "emit:tsc7": { + "coldMs": 315.86, + "warmMedianMs": 488.75 + }, + "emit:tsc5.9.3": { + "coldMs": 2759.83, + "warmMedianMs": 2759.83 + }, + "parse:oxc-parser": { + "coldMs": 1.14, + "warmMedianMs": 0.87 + }, + "parse:createSourceFile": { + "coldMs": 1.57, + "warmMedianMs": 0.74 + }, + "build:model": { + "coldMs": 4.48, + "warmMedianMs": 3.67 + }, + "resolve:messages": { + "coldMs": 2.33, + "warmMedianMs": 2.01 + } + } + }, + { + "name": "@systemfsoftware/effect-daemon-spec", + "version": "6.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 22, + "sourceBytes": 138120, + "declarationFiles": 22, + "declarationStatements": 400, + "clean": { + "oxc-transform": { + "diagnostics": 138, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 138, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 279, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 22, + "differing": 4, + "missing": 0, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "Fallback", + "outcome": "model", + "items": 1, + "locals": 41, + "messages": 96 + }, + "phases": { + "discover": { + "coldMs": 13.17, + "warmMedianMs": 21.04 + }, + "emit:oxc-transform": { + "coldMs": 1.93, + "warmMedianMs": 1.42 + }, + "emit:transpileDeclaration": { + "coldMs": 55.65, + "warmMedianMs": 46.87 + }, + "emit:tsc7": { + "coldMs": 339.78, + "warmMedianMs": 325.4 + }, + "emit:tsc5.9.3": { + "coldMs": 1873.66, + "warmMedianMs": 1873.66 + }, + "parse:oxc-parser": { + "coldMs": 7.86, + "warmMedianMs": 7.11 + }, + "parse:createSourceFile": { + "coldMs": 7.09, + "warmMedianMs": 6.7 + }, + "build:model": { + "coldMs": 39.98, + "warmMedianMs": 27.39 + }, + "resolve:messages": { + "coldMs": 19.12, + "warmMedianMs": 23.45 + } + } + }, + { + "name": "@systemfsoftware/discern", + "version": "1.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 40, + "sourceBytes": 255997, + "declarationFiles": 40, + "declarationStatements": 732, + "clean": { + "oxc-transform": { + "diagnostics": 144, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 144, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 355, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 40, + "differing": 14, + "missing": 0, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "Fallback", + "outcome": "model", + "items": 1, + "locals": 66, + "messages": 266 + }, + "phases": { + "discover": { + "coldMs": 42.51, + "warmMedianMs": 23.69 + }, + "emit:oxc-transform": { + "coldMs": 3.11, + "warmMedianMs": 2.37 + }, + "emit:transpileDeclaration": { + "coldMs": 105.09, + "warmMedianMs": 89.81 + }, + "emit:tsc7": { + "coldMs": 329.43, + "warmMedianMs": 328.82 + }, + "emit:tsc5.9.3": { + "coldMs": 2144.92, + "warmMedianMs": 2144.92 + }, + "parse:oxc-parser": { + "coldMs": 8.22, + "warmMedianMs": 8.15 + }, + "parse:createSourceFile": { + "coldMs": 13.99, + "warmMedianMs": 10.94 + }, + "build:model": { + "coldMs": 69.13, + "warmMedianMs": 50.86 + }, + "resolve:messages": { + "coldMs": 27.48, + "warmMedianMs": 27.05 + } + } + }, + { + "name": "@systemfsoftware/effect-cell-types", + "version": "12.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 7, + "sourceBytes": 68241, + "declarationFiles": 7, + "declarationStatements": 187, + "clean": { + "oxc-transform": { + "diagnostics": 1, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 1, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 2, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 7, + "differing": 1, + "missing": 0, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "Fallback", + "outcome": "model", + "items": 5, + "locals": 88, + "messages": 142 + }, + "phases": { + "discover": { + "coldMs": 3.05, + "warmMedianMs": 2.49 + }, + "emit:oxc-transform": { + "coldMs": 0.97, + "warmMedianMs": 0.59 + }, + "emit:transpileDeclaration": { + "coldMs": 23.65, + "warmMedianMs": 17 + }, + "emit:tsc7": { + "coldMs": 186.38, + "warmMedianMs": 175.03 + }, + "emit:tsc5.9.3": { + "coldMs": 1425.33, + "warmMedianMs": 1425.33 + }, + "parse:oxc-parser": { + "coldMs": 3.35, + "warmMedianMs": 2.23 + }, + "parse:createSourceFile": { + "coldMs": 2.37, + "warmMedianMs": 2.79 + }, + "build:model": { + "coldMs": 36.88, + "warmMedianMs": 32.37 + }, + "resolve:messages": { + "coldMs": 11.25, + "warmMedianMs": 9.82 + } + } + }, + { + "name": "@systemfsoftware/effect-memfs", + "version": "6.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 19, + "sourceBytes": 63433, + "declarationFiles": 19, + "declarationStatements": 198, + "clean": { + "oxc-transform": { + "diagnostics": 68, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 68, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 104, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 19, + "differing": 1, + "missing": 0, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "Fallback", + "outcome": "model", + "items": 1, + "locals": 6, + "messages": 23 + }, + "phases": { + "discover": { + "coldMs": 24.74, + "warmMedianMs": 3.62 + }, + "emit:oxc-transform": { + "coldMs": 1.11, + "warmMedianMs": 0.71 + }, + "emit:transpileDeclaration": { + "coldMs": 27.96, + "warmMedianMs": 22.57 + }, + "emit:tsc7": { + "coldMs": 304.78, + "warmMedianMs": 240.18 + }, + "emit:tsc5.9.3": { + "coldMs": 1644.17, + "warmMedianMs": 1644.17 + }, + "parse:oxc-parser": { + "coldMs": 2.18, + "warmMedianMs": 1.11 + }, + "parse:createSourceFile": { + "coldMs": 1.72, + "warmMedianMs": 0.89 + }, + "build:model": { + "coldMs": 4.35, + "warmMedianMs": 6.59 + }, + "resolve:messages": { + "coldMs": 1.64, + "warmMedianMs": 1.48 + } + } + }, + { + "name": "@systemfsoftware/effect-microsandbox", + "version": "5.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 22, + "sourceBytes": 60081, + "declarationFiles": 22, + "declarationStatements": 271, + "clean": { + "oxc-transform": { + "diagnostics": 94, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 93, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 222, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 22, + "differing": 9, + "missing": 0, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "Fallback", + "outcome": "model", + "items": 1, + "locals": 22, + "messages": 77 + }, + "phases": { + "discover": { + "coldMs": 6.61, + "warmMedianMs": 6.02 + }, + "emit:oxc-transform": { + "coldMs": 1.24, + "warmMedianMs": 0.87 + }, + "emit:transpileDeclaration": { + "coldMs": 32.68, + "warmMedianMs": 26.38 + }, + "emit:tsc7": { + "coldMs": 258.66, + "warmMedianMs": 248.14 + }, + "emit:tsc5.9.3": { + "coldMs": 1814.17, + "warmMedianMs": 1814.17 + }, + "parse:oxc-parser": { + "coldMs": 3.66, + "warmMedianMs": 3.44 + }, + "parse:createSourceFile": { + "coldMs": 3.71, + "warmMedianMs": 3.05 + }, + "build:model": { + "coldMs": 30.44, + "warmMedianMs": 14.3 + }, + "resolve:messages": { + "coldMs": 6.55, + "warmMedianMs": 7.27 + } + } + }, + { + "name": "@systemfsoftware/effect-readiness", + "version": "1.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 19, + "sourceBytes": 29487, + "declarationFiles": 19, + "declarationStatements": 145, + "clean": { + "oxc-transform": { + "diagnostics": 53, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 53, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 83, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 19, + "differing": 1, + "missing": 0, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "Fallback", + "outcome": "model", + "items": 1, + "locals": 12, + "messages": 43 + }, + "phases": { + "discover": { + "coldMs": 5.91, + "warmMedianMs": 4.62 + }, + "emit:oxc-transform": { + "coldMs": 0.71, + "warmMedianMs": 0.41 + }, + "emit:transpileDeclaration": { + "coldMs": 18.3, + "warmMedianMs": 17.65 + }, + "emit:tsc7": { + "coldMs": 214.3, + "warmMedianMs": 197.83 + }, + "emit:tsc5.9.3": { + "coldMs": 1540.65, + "warmMedianMs": 1540.65 + }, + "parse:oxc-parser": { + "coldMs": 1.56, + "warmMedianMs": 1.08 + }, + "parse:createSourceFile": { + "coldMs": 1.58, + "warmMedianMs": 0.85 + }, + "build:model": { + "coldMs": 5.26, + "warmMedianMs": 10.66 + }, + "resolve:messages": { + "coldMs": 3.25, + "warmMedianMs": 3.09 + } + } + }, + { + "name": "@systemfsoftware/effect-spec-runtime", + "version": "1.0.1", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 6, + "sourceBytes": 33771, + "declarationFiles": 6, + "declarationStatements": 49, + "clean": { + "oxc-transform": { + "diagnostics": 1, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 1, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 2, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 6, + "differing": 0, + "missing": 0, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "FastPath", + "outcome": "model", + "items": 3, + "locals": 4, + "messages": 21 + }, + "phases": { + "discover": { + "coldMs": 3.39, + "warmMedianMs": 1.88 + }, + "emit:oxc-transform": { + "coldMs": 0.55, + "warmMedianMs": 0.25 + }, + "emit:transpileDeclaration": { + "coldMs": 10.63, + "warmMedianMs": 8.21 + }, + "emit:tsc7": { + "coldMs": 232.52, + "warmMedianMs": 199.33 + }, + "emit:tsc5.9.3": { + "coldMs": 1496.97, + "warmMedianMs": 1496.97 + }, + "parse:oxc-parser": { + "coldMs": 0.8, + "warmMedianMs": 0.45 + }, + "parse:createSourceFile": { + "coldMs": 0.72, + "warmMedianMs": 0.33 + }, + "build:model": { + "coldMs": 3.74, + "warmMedianMs": 9.35 + }, + "resolve:messages": { + "coldMs": 1.84, + "warmMedianMs": 1.36 + } + } + }, + { + "name": "@systemfsoftware/effect-gherkin-spec", + "version": "7.0.1", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 9, + "sourceBytes": 67914, + "declarationFiles": 9, + "declarationStatements": 138, + "clean": { + "oxc-transform": { + "diagnostics": 15, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 19, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 24, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 9, + "differing": 1, + "missing": 0, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "Fallback", + "outcome": "UnsupportedDeclaration: src/mod.d.ts: re-export of \"it\" from \"@systemfsoftware/vitest\" is not supported", + "items": 0, + "locals": 0, + "messages": 104 + }, + "phases": { + "discover": { + "coldMs": 7.74, + "warmMedianMs": 4.54 + }, + "emit:oxc-transform": { + "coldMs": 0.95, + "warmMedianMs": 0.52 + }, + "emit:transpileDeclaration": { + "coldMs": 25.41, + "warmMedianMs": 22.23 + }, + "emit:tsc7": { + "coldMs": 240.58, + "warmMedianMs": 215.79 + }, + "emit:tsc5.9.3": { + "coldMs": 1499.4, + "warmMedianMs": 1499.4 + }, + "parse:oxc-parser": { + "coldMs": 2.64, + "warmMedianMs": 1.9 + }, + "parse:createSourceFile": { + "coldMs": 2.37, + "warmMedianMs": 1.43 + }, + "build:model": { + "coldMs": 13.23, + "warmMedianMs": 10.22 + }, + "resolve:messages": { + "coldMs": 7.09, + "warmMedianMs": 5.74 + } + } + }, + { + "name": "@systemfsoftware/storybook-gherkin", + "version": "5.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 5, + "sourceBytes": 37687, + "declarationFiles": 5, + "declarationStatements": 92, + "clean": { + "oxc-transform": { + "diagnostics": 12, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 11, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 34, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 5, + "differing": 1, + "missing": 0, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "Fallback", + "outcome": "model", + "items": 47, + "locals": 7, + "messages": 66 + }, + "phases": { + "discover": { + "coldMs": 6.84, + "warmMedianMs": 2.52 + }, + "emit:oxc-transform": { + "coldMs": 0.68, + "warmMedianMs": 0.33 + }, + "emit:transpileDeclaration": { + "coldMs": 11.44, + "warmMedianMs": 8.63 + }, + "emit:tsc7": { + "coldMs": 240.11, + "warmMedianMs": 122.59 + }, + "emit:tsc5.9.3": { + "coldMs": 894.91, + "warmMedianMs": 894.91 + }, + "parse:oxc-parser": { + "coldMs": 1.11, + "warmMedianMs": 0.71 + }, + "parse:createSourceFile": { + "coldMs": 0.96, + "warmMedianMs": 0.63 + }, + "build:model": { + "coldMs": 15.91, + "warmMedianMs": 7.83 + }, + "resolve:messages": { + "coldMs": 3.18, + "warmMedianMs": 3.1 + } + } + }, + { + "name": "@systemfsoftware/npm-package", + "version": "1.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 11, + "sourceBytes": 31182, + "declarationFiles": 11, + "declarationStatements": 74, + "clean": { + "oxc-transform": { + "diagnostics": 29, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 29, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 41, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 11, + "differing": 0, + "missing": 0, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "Fallback", + "outcome": "model", + "items": 9, + "locals": 1, + "messages": 9 + }, + "phases": { + "discover": { + "coldMs": 20.62, + "warmMedianMs": 2.64 + }, + "emit:oxc-transform": { + "coldMs": 0.65, + "warmMedianMs": 0.36 + }, + "emit:transpileDeclaration": { + "coldMs": 16.61, + "warmMedianMs": 13.02 + }, + "emit:tsc7": { + "coldMs": 202.39, + "warmMedianMs": 225.03 + }, + "emit:tsc5.9.3": { + "coldMs": 1359.36, + "warmMedianMs": 1359.36 + }, + "parse:oxc-parser": { + "coldMs": 0.78, + "warmMedianMs": 0.42 + }, + "parse:createSourceFile": { + "coldMs": 0.7, + "warmMedianMs": 0.39 + }, + "build:model": { + "coldMs": 1.99, + "warmMedianMs": 1.75 + }, + "resolve:messages": { + "coldMs": 0.74, + "warmMedianMs": 0.58 + } + } + }, + { + "name": "@systemfsoftware/oxlint-plugin-cell-architecture", + "version": "5.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 43, + "sourceBytes": 133875, + "declarationFiles": 43, + "declarationStatements": 256, + "clean": { + "oxc-transform": { + "diagnostics": 65, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 65, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 71, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 43, + "differing": 19, + "missing": 0, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "Fallback", + "outcome": "model", + "items": 2, + "locals": 0, + "messages": 1 + }, + "phases": { + "discover": { + "coldMs": 9.84, + "warmMedianMs": 6.71 + }, + "emit:oxc-transform": { + "coldMs": 1.44, + "warmMedianMs": 1.12 + }, + "emit:transpileDeclaration": { + "coldMs": 45.69, + "warmMedianMs": 47.03 + }, + "emit:tsc7": { + "coldMs": 330.67, + "warmMedianMs": 328.61 + }, + "emit:tsc5.9.3": { + "coldMs": 1747.94, + "warmMedianMs": 1747.94 + }, + "parse:oxc-parser": { + "coldMs": 1.66, + "warmMedianMs": 1.46 + }, + "parse:createSourceFile": { + "coldMs": 1.57, + "warmMedianMs": 1.18 + }, + "build:model": { + "coldMs": 1.79, + "warmMedianMs": 1.05 + }, + "resolve:messages": { + "coldMs": 0.81, + "warmMedianMs": 0.41 + } + } + }, + { + "name": "@systemfsoftware/oxlint-plugin-dmmf-workflow", + "version": "7.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 19, + "sourceBytes": 106678, + "declarationFiles": 19, + "declarationStatements": 161, + "clean": { + "oxc-transform": { + "diagnostics": 65, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 65, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 65, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 19, + "differing": 8, + "missing": 0, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "Fallback", + "outcome": "model", + "items": 2, + "locals": 0, + "messages": 1 + }, + "phases": { + "discover": { + "coldMs": 13.09, + "warmMedianMs": 4.27 + }, + "emit:oxc-transform": { + "coldMs": 1.17, + "warmMedianMs": 0.74 + }, + "emit:transpileDeclaration": { + "coldMs": 27.25, + "warmMedianMs": 27.6 + }, + "emit:tsc7": { + "coldMs": 279.3, + "warmMedianMs": 347.53 + }, + "emit:tsc5.9.3": { + "coldMs": 3224.71, + "warmMedianMs": 3224.71 + }, + "parse:oxc-parser": { + "coldMs": 1.33, + "warmMedianMs": 0.81 + }, + "parse:createSourceFile": { + "coldMs": 1.22, + "warmMedianMs": 0.77 + }, + "build:model": { + "coldMs": 1.04, + "warmMedianMs": 0.91 + }, + "resolve:messages": { + "coldMs": 0.41, + "warmMedianMs": 0.18 + } + } + }, + { + "name": "@systemfsoftware/oxlint-plugin-effect-platform", + "version": "2.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 21, + "sourceBytes": 56432, + "declarationFiles": 21, + "declarationStatements": 126, + "clean": { + "oxc-transform": { + "diagnostics": 52, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 52, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 62, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 21, + "differing": 10, + "missing": 0, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "Fallback", + "outcome": "model", + "items": 3, + "locals": 0, + "messages": 2 + }, + "phases": { + "discover": { + "coldMs": 6.16, + "warmMedianMs": 3.97 + }, + "emit:oxc-transform": { + "coldMs": 0.78, + "warmMedianMs": 0.51 + }, + "emit:transpileDeclaration": { + "coldMs": 28.92, + "warmMedianMs": 21.77 + }, + "emit:tsc7": { + "coldMs": 265.55, + "warmMedianMs": 256.37 + }, + "emit:tsc5.9.3": { + "coldMs": 1419.38, + "warmMedianMs": 1419.38 + }, + "parse:oxc-parser": { + "coldMs": 1, + "warmMedianMs": 0.62 + }, + "parse:createSourceFile": { + "coldMs": 0.69, + "warmMedianMs": 0.47 + }, + "build:model": { + "coldMs": 1.47, + "warmMedianMs": 0.63 + }, + "resolve:messages": { + "coldMs": 0.31, + "warmMedianMs": 0.2 + } + } + }, + { + "name": "@systemfsoftware/oxlint-plugin-effect-schema", + "version": "6.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 23, + "sourceBytes": 139000, + "declarationFiles": 23, + "declarationStatements": 139, + "clean": { + "oxc-transform": { + "diagnostics": 42, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 42, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 45, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 23, + "differing": 10, + "missing": 0, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "Fallback", + "outcome": "model", + "items": 2, + "locals": 0, + "messages": 1 + }, + "phases": { + "discover": { + "coldMs": 7.75, + "warmMedianMs": 4.49 + }, + "emit:oxc-transform": { + "coldMs": 1.28, + "warmMedianMs": 0.74 + }, + "emit:transpileDeclaration": { + "coldMs": 25.85, + "warmMedianMs": 23.25 + }, + "emit:tsc7": { + "coldMs": 292.32, + "warmMedianMs": 280.24 + }, + "emit:tsc5.9.3": { + "coldMs": 2007.76, + "warmMedianMs": 2007.76 + }, + "parse:oxc-parser": { + "coldMs": 1.77, + "warmMedianMs": 1.18 + }, + "parse:createSourceFile": { + "coldMs": 2.59, + "warmMedianMs": 1.23 + }, + "build:model": { + "coldMs": 1.24, + "warmMedianMs": 0.77 + }, + "resolve:messages": { + "coldMs": 0.69, + "warmMedianMs": 0.28 + } + } + }, + { + "name": "@systemfsoftware/oxlint-plugin-test-discipline", + "version": "5.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 65, + "sourceBytes": 206741, + "declarationFiles": 65, + "declarationStatements": 343, + "clean": { + "oxc-transform": { + "diagnostics": 147, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 147, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 150, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 65, + "differing": 31, + "missing": 0, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "Fallback", + "outcome": "model", + "items": 2, + "locals": 0, + "messages": 1 + }, + "phases": { + "discover": { + "coldMs": 21.89, + "warmMedianMs": 12.76 + }, + "emit:oxc-transform": { + "coldMs": 7.85, + "warmMedianMs": 2.35 + }, + "emit:transpileDeclaration": { + "coldMs": 85.03, + "warmMedianMs": 66.01 + }, + "emit:tsc7": { + "coldMs": 182.43, + "warmMedianMs": 170.97 + }, + "emit:tsc5.9.3": { + "coldMs": 1337.07, + "warmMedianMs": 1337.07 + }, + "parse:oxc-parser": { + "coldMs": 2.93, + "warmMedianMs": 2.26 + }, + "parse:createSourceFile": { + "coldMs": 1.92, + "warmMedianMs": 1.67 + }, + "build:model": { + "coldMs": 1.37, + "warmMedianMs": 1.01 + }, + "resolve:messages": { + "coldMs": 0.76, + "warmMedianMs": 0.36 + } + } + }, + { + "name": "@systemfsoftware/oxlint-config-cell-architecture", + "version": "4.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 1, + "sourceBytes": 928, + "declarationFiles": 1, + "declarationStatements": 7, + "clean": { + "oxc-transform": { + "diagnostics": 0, + "clean": true + }, + "transpileDeclaration": { + "diagnostics": 0, + "clean": true + }, + "tsc --isolatedDeclarations": { + "diagnostics": 0, + "clean": true + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 1, + "differing": 0, + "missing": 0, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "FastPath", + "outcome": "model", + "items": 6, + "locals": 0, + "messages": 5 + }, + "phases": { + "discover": { + "coldMs": 0.83, + "warmMedianMs": 0.22 + }, + "emit:oxc-transform": { + "coldMs": 0.1, + "warmMedianMs": 0.01 + }, + "emit:transpileDeclaration": { + "coldMs": 1.51, + "warmMedianMs": 0.86 + }, + "emit:tsc7": { + "coldMs": 335.03, + "warmMedianMs": 329.29 + }, + "emit:tsc5.9.3": { + "coldMs": 1895, + "warmMedianMs": 1895 + }, + "parse:oxc-parser": { + "coldMs": 0.18, + "warmMedianMs": 0.03 + }, + "parse:createSourceFile": { + "coldMs": 0.18, + "warmMedianMs": 0.02 + }, + "build:model": { + "coldMs": 0.55, + "warmMedianMs": 0.23 + }, + "resolve:messages": { + "coldMs": 0.19, + "warmMedianMs": 0.38 + } + } + }, + { + "name": "@systemfsoftware/oxlint-config-dmmf", + "version": "2.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 1, + "sourceBytes": 3230, + "declarationFiles": 1, + "declarationStatements": 8, + "clean": { + "oxc-transform": { + "diagnostics": 0, + "clean": true + }, + "transpileDeclaration": { + "diagnostics": 0, + "clean": true + }, + "tsc --isolatedDeclarations": { + "diagnostics": 0, + "clean": true + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 1, + "differing": 0, + "missing": 0, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "FastPath", + "outcome": "model", + "items": 7, + "locals": 0, + "messages": 6 + }, + "phases": { + "discover": { + "coldMs": 0.56, + "warmMedianMs": 0.17 + }, + "emit:oxc-transform": { + "coldMs": 0.17, + "warmMedianMs": 0.03 + }, + "emit:transpileDeclaration": { + "coldMs": 1.47, + "warmMedianMs": 0.87 + }, + "emit:tsc7": { + "coldMs": 374.46, + "warmMedianMs": 356.66 + }, + "emit:tsc5.9.3": { + "coldMs": 2162.2, + "warmMedianMs": 2162.2 + }, + "parse:oxc-parser": { + "coldMs": 0.17, + "warmMedianMs": 0.03 + }, + "parse:createSourceFile": { + "coldMs": 0.16, + "warmMedianMs": 0.03 + }, + "build:model": { + "coldMs": 0.74, + "warmMedianMs": 0.54 + }, + "resolve:messages": { + "coldMs": 0.36, + "warmMedianMs": 0.24 + } + } + }, + { + "name": "@systemfsoftware/oxlint-config-recommended", + "version": "4.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 1, + "sourceBytes": 4333, + "declarationFiles": 1, + "declarationStatements": 8, + "clean": { + "oxc-transform": { + "diagnostics": 0, + "clean": true + }, + "transpileDeclaration": { + "diagnostics": 0, + "clean": true + }, + "tsc --isolatedDeclarations": { + "diagnostics": 0, + "clean": true + } + }, + "fallback": { + "exitCode": 2, + "diagnostics": 1 + }, + "divergence": { + "compared": 1, + "differing": 1, + "missing": 0, + "typescript59ExitCode": 2 + }, + "frontEnd": { + "route": "FastPath", + "outcome": "model", + "items": 7, + "locals": 0, + "messages": 6 + }, + "phases": { + "discover": { + "coldMs": 2.5, + "warmMedianMs": 0.86 + }, + "emit:oxc-transform": { + "coldMs": 0.17, + "warmMedianMs": 0.02 + }, + "emit:transpileDeclaration": { + "coldMs": 2.54, + "warmMedianMs": 2.1 + }, + "emit:tsc7": { + "coldMs": 671.08, + "warmMedianMs": 647.97 + }, + "emit:tsc5.9.3": { + "coldMs": 3860.06, + "warmMedianMs": 3860.06 + }, + "parse:oxc-parser": { + "coldMs": 0.29, + "warmMedianMs": 0.05 + }, + "parse:createSourceFile": { + "coldMs": 0.24, + "warmMedianMs": 0.03 + }, + "build:model": { + "coldMs": 0.73, + "warmMedianMs": 0.77 + }, + "resolve:messages": { + "coldMs": 0.33, + "warmMedianMs": 0.25 + } + } + }, + { + "name": "@systemfsoftware/oxlint-config-rule-authoring", + "version": "1.1.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 1, + "sourceBytes": 1395, + "declarationFiles": 1, + "declarationStatements": 6, + "clean": { + "oxc-transform": { + "diagnostics": 0, + "clean": true + }, + "transpileDeclaration": { + "diagnostics": 0, + "clean": true + }, + "tsc --isolatedDeclarations": { + "diagnostics": 0, + "clean": true + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 1, + "differing": 0, + "missing": 0, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "FastPath", + "outcome": "model", + "items": 5, + "locals": 0, + "messages": 4 + }, + "phases": { + "discover": { + "coldMs": 0.63, + "warmMedianMs": 0.28 + }, + "emit:oxc-transform": { + "coldMs": 0.12, + "warmMedianMs": 0.01 + }, + "emit:transpileDeclaration": { + "coldMs": 1.36, + "warmMedianMs": 0.83 + }, + "emit:tsc7": { + "coldMs": 88.39, + "warmMedianMs": 79.08 + }, + "emit:tsc5.9.3": { + "coldMs": 482.37, + "warmMedianMs": 482.37 + }, + "parse:oxc-parser": { + "coldMs": 0.18, + "warmMedianMs": 0.03 + }, + "parse:createSourceFile": { + "coldMs": 0.2, + "warmMedianMs": 0.02 + }, + "build:model": { + "coldMs": 0.43, + "warmMedianMs": 0.26 + }, + "resolve:messages": { + "coldMs": 0.2, + "warmMedianMs": 0.13 + } + } + }, + { + "name": "@systemfsoftware/vitest", + "version": "2.0.0", + "origin": "systemfsoftware", + "entryPoints": 5, + "sourceFiles": 32, + "sourceBytes": 292776, + "declarationFiles": 32, + "declarationStatements": 451, + "clean": { + "oxc-transform": { + "diagnostics": 52, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 52, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 126, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 32, + "differing": 1, + "missing": 0, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "Fallback", + "outcome": "UnsupportedDeclaration: src/mod.d.ts: `declare module \"vitest\"` is not supported", + "items": 0, + "locals": 0, + "messages": 0 + }, + "phases": { + "discover": { + "coldMs": 59.15, + "warmMedianMs": 17.48 + }, + "emit:oxc-transform": { + "coldMs": 3.47, + "warmMedianMs": 2.54 + }, + "emit:transpileDeclaration": { + "coldMs": 96.25, + "warmMedianMs": 93.3 + }, + "emit:tsc7": { + "coldMs": 225.42, + "warmMedianMs": 233.38 + }, + "emit:tsc5.9.3": { + "coldMs": 1426.82, + "warmMedianMs": 1426.82 + }, + "parse:oxc-parser": { + "coldMs": 5.57, + "warmMedianMs": 3.83 + }, + "parse:createSourceFile": { + "coldMs": 4.8, + "warmMedianMs": 6.63 + }, + "build:model": { + "coldMs": 8.42, + "warmMedianMs": 6.54 + }, + "resolve:messages": { + "coldMs": 6.18, + "warmMedianMs": 6.85 + } + } + }, + { + "name": "@systemfsoftware/rx-effect", + "version": "1.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 2, + "sourceBytes": 705, + "declarationFiles": 2, + "declarationStatements": 6, + "clean": { + "oxc-transform": { + "diagnostics": 0, + "clean": true + }, + "transpileDeclaration": { + "diagnostics": 0, + "clean": true + }, + "tsc --isolatedDeclarations": { + "diagnostics": 0, + "clean": true + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 2, + "differing": 0, + "missing": 0, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "FastPath", + "outcome": "model", + "items": 1, + "locals": 1, + "messages": 2 + }, + "phases": { + "discover": { + "coldMs": 0.97, + "warmMedianMs": 0.35 + }, + "emit:oxc-transform": { + "coldMs": 0.12, + "warmMedianMs": 0.01 + }, + "emit:transpileDeclaration": { + "coldMs": 2.18, + "warmMedianMs": 1.42 + }, + "emit:tsc7": { + "coldMs": 86.47, + "warmMedianMs": 86.1 + }, + "emit:tsc5.9.3": { + "coldMs": 571.9, + "warmMedianMs": 571.9 + }, + "parse:oxc-parser": { + "coldMs": 0.22, + "warmMedianMs": 0.03 + }, + "parse:createSourceFile": { + "coldMs": 0.16, + "warmMedianMs": 0.02 + }, + "build:model": { + "coldMs": 0.36, + "warmMedianMs": 0.2 + }, + "resolve:messages": { + "coldMs": 0.33, + "warmMedianMs": 0.1 + } + } + }, + { + "name": "@systemfsoftware/effect-schema-discovery", + "version": "2.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 2, + "sourceBytes": 8481, + "declarationFiles": 2, + "declarationStatements": 9, + "clean": { + "oxc-transform": { + "diagnostics": 0, + "clean": true + }, + "transpileDeclaration": { + "diagnostics": 0, + "clean": true + }, + "tsc --isolatedDeclarations": { + "diagnostics": 0, + "clean": true + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 2, + "differing": 0, + "missing": 0, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "FastPath", + "outcome": "model", + "items": 6, + "locals": 0, + "messages": 6 + }, + "phases": { + "discover": { + "coldMs": 1.78, + "warmMedianMs": 0.73 + }, + "emit:oxc-transform": { + "coldMs": 0.21, + "warmMedianMs": 0.05 + }, + "emit:transpileDeclaration": { + "coldMs": 3.28, + "warmMedianMs": 2.26 + }, + "emit:tsc7": { + "coldMs": 76.99, + "warmMedianMs": 75.91 + }, + "emit:tsc5.9.3": { + "coldMs": 427.57, + "warmMedianMs": 427.57 + }, + "parse:oxc-parser": { + "coldMs": 0.23, + "warmMedianMs": 0.04 + }, + "parse:createSourceFile": { + "coldMs": 0.31, + "warmMedianMs": 0.05 + }, + "build:model": { + "coldMs": 0.62, + "warmMedianMs": 0.35 + }, + "resolve:messages": { + "coldMs": 0.25, + "warmMedianMs": 0.16 + } + } + }, + { + "name": "@systemfsoftware/effect-schema-extensions", + "version": "1.0.0", + "origin": "systemfsoftware", + "entryPoints": 2, + "sourceFiles": 2, + "sourceBytes": 88, + "declarationFiles": 2, + "declarationStatements": 2, + "clean": { + "oxc-transform": { + "diagnostics": 0, + "clean": true + }, + "transpileDeclaration": { + "diagnostics": 0, + "clean": true + }, + "tsc --isolatedDeclarations": { + "diagnostics": 0, + "clean": true + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 2, + "differing": 0, + "missing": 0, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "FastPath", + "outcome": "model", + "items": 0, + "locals": 0, + "messages": 0 + }, + "phases": { + "discover": { + "coldMs": 0.57, + "warmMedianMs": 0.17 + }, + "emit:oxc-transform": { + "coldMs": 0.04, + "warmMedianMs": 0.01 + }, + "emit:transpileDeclaration": { + "coldMs": 0.85, + "warmMedianMs": 0.47 + }, + "emit:tsc7": { + "coldMs": 82.73, + "warmMedianMs": 96.44 + }, + "emit:tsc5.9.3": { + "coldMs": 674.86, + "warmMedianMs": 674.86 + }, + "parse:oxc-parser": { + "coldMs": 0.1, + "warmMedianMs": 0.01 + }, + "parse:createSourceFile": { + "coldMs": 0.06, + "warmMedianMs": 0 + }, + "build:model": { + "coldMs": 0.25, + "warmMedianMs": 0.03 + }, + "resolve:messages": { + "coldMs": 0.08, + "warmMedianMs": 0.03 + } + } + }, + { + "name": "@systemfsoftware/effect-schema-law", + "version": "5.0.1", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 3, + "sourceBytes": 26541, + "declarationFiles": 3, + "declarationStatements": 6, + "clean": { + "oxc-transform": { + "diagnostics": 0, + "clean": true + }, + "transpileDeclaration": { + "diagnostics": 0, + "clean": true + }, + "tsc --isolatedDeclarations": { + "diagnostics": 0, + "clean": true + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 3, + "differing": 0, + "missing": 0, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "FastPath", + "outcome": "model", + "items": 2, + "locals": 0, + "messages": 2 + }, + "phases": { + "discover": { + "coldMs": 2.17, + "warmMedianMs": 1.01 + }, + "emit:oxc-transform": { + "coldMs": 0.34, + "warmMedianMs": 0.16 + }, + "emit:transpileDeclaration": { + "coldMs": 6.85, + "warmMedianMs": 6.34 + }, + "emit:tsc7": { + "coldMs": 193.34, + "warmMedianMs": 164.14 + }, + "emit:tsc5.9.3": { + "coldMs": 1263.3, + "warmMedianMs": 1263.3 + }, + "parse:oxc-parser": { + "coldMs": 0.46, + "warmMedianMs": 0.06 + }, + "parse:createSourceFile": { + "coldMs": 0.36, + "warmMedianMs": 0.03 + }, + "build:model": { + "coldMs": 1.16, + "warmMedianMs": 0.37 + }, + "resolve:messages": { + "coldMs": 0.23, + "warmMedianMs": 0.17 + } + } + }, + { + "name": "@systemfsoftware/effect-schema-recursion-budget", + "version": "2.0.0", + "origin": "systemfsoftware", + "entryPoints": 2, + "sourceFiles": 3, + "sourceBytes": 13872, + "declarationFiles": 3, + "declarationStatements": 7, + "clean": { + "oxc-transform": { + "diagnostics": 0, + "clean": true + }, + "transpileDeclaration": { + "diagnostics": 0, + "clean": true + }, + "tsc --isolatedDeclarations": { + "diagnostics": 0, + "clean": true + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 3, + "differing": 1, + "missing": 0, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "FastPath", + "outcome": "model", + "items": 5, + "locals": 0, + "messages": 5 + }, + "phases": { + "discover": { + "coldMs": 1.35, + "warmMedianMs": 0.57 + }, + "emit:oxc-transform": { + "coldMs": 0.23, + "warmMedianMs": 0.08 + }, + "emit:transpileDeclaration": { + "coldMs": 3.69, + "warmMedianMs": 3.07 + }, + "emit:tsc7": { + "coldMs": 207.41, + "warmMedianMs": 207.45 + }, + "emit:tsc5.9.3": { + "coldMs": 1527.15, + "warmMedianMs": 1527.15 + }, + "parse:oxc-parser": { + "coldMs": 0.47, + "warmMedianMs": 0.05 + }, + "parse:createSourceFile": { + "coldMs": 0.22, + "warmMedianMs": 0.04 + }, + "build:model": { + "coldMs": 1.1, + "warmMedianMs": 0.55 + }, + "resolve:messages": { + "coldMs": 0.27, + "warmMedianMs": 0.16 + } + } + }, + { + "name": "@systemfsoftware/effect-schema-vite", + "version": "3.0.1", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 2, + "sourceBytes": 5828, + "declarationFiles": 2, + "declarationStatements": 9, + "clean": { + "oxc-transform": { + "diagnostics": 1, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 1, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 1, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 2, + "differing": 1, + "missing": 0, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "Fallback", + "outcome": "model", + "items": 4, + "locals": 1, + "messages": 5 + }, + "phases": { + "discover": { + "coldMs": 1.22, + "warmMedianMs": 0.4 + }, + "emit:oxc-transform": { + "coldMs": 0.23, + "warmMedianMs": 0.03 + }, + "emit:transpileDeclaration": { + "coldMs": 3.51, + "warmMedianMs": 2.06 + }, + "emit:tsc7": { + "coldMs": 110.25, + "warmMedianMs": 100.85 + }, + "emit:tsc5.9.3": { + "coldMs": 718.25, + "warmMedianMs": 718.25 + }, + "parse:oxc-parser": { + "coldMs": 0.19, + "warmMedianMs": 0.04 + }, + "parse:createSourceFile": { + "coldMs": 0.24, + "warmMedianMs": 0.04 + }, + "build:model": { + "coldMs": 1.11, + "warmMedianMs": 0.6 + }, + "resolve:messages": { + "coldMs": 0.43, + "warmMedianMs": 0.21 + } + } + }, + { + "name": "@systemfsoftware/hex-schema", + "version": "2.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 7, + "sourceBytes": 3921, + "declarationFiles": 7, + "declarationStatements": 22, + "clean": { + "oxc-transform": { + "diagnostics": 6, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 6, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 6, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 7, + "differing": 0, + "missing": 0, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "Fallback", + "outcome": "model", + "items": 10, + "locals": 0, + "messages": 6 + }, + "phases": { + "discover": { + "coldMs": 1.23, + "warmMedianMs": 1.01 + }, + "emit:oxc-transform": { + "coldMs": 0.21, + "warmMedianMs": 0.05 + }, + "emit:transpileDeclaration": { + "coldMs": 5.83, + "warmMedianMs": 3.89 + }, + "emit:tsc7": { + "coldMs": 93.46, + "warmMedianMs": 85.2 + }, + "emit:tsc5.9.3": { + "coldMs": 772.08, + "warmMedianMs": 772.08 + }, + "parse:oxc-parser": { + "coldMs": 0.34, + "warmMedianMs": 0.12 + }, + "parse:createSourceFile": { + "coldMs": 0.29, + "warmMedianMs": 0.09 + }, + "build:model": { + "coldMs": 1.96, + "warmMedianMs": 1.42 + }, + "resolve:messages": { + "coldMs": 0.93, + "warmMedianMs": 1.09 + } + } + }, + { + "name": "@systemfsoftware/conformance-spec", + "version": "1.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 8, + "sourceBytes": 48889, + "declarationFiles": 8, + "declarationStatements": 68, + "clean": { + "oxc-transform": { + "diagnostics": 2, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 2, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 4, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 8, + "differing": 1, + "missing": 0, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "Fallback", + "outcome": "model", + "items": 1, + "locals": 7, + "messages": 39 + }, + "phases": { + "discover": { + "coldMs": 3.34, + "warmMedianMs": 2.37 + }, + "emit:oxc-transform": { + "coldMs": 0.61, + "warmMedianMs": 0.36 + }, + "emit:transpileDeclaration": { + "coldMs": 12.65, + "warmMedianMs": 10.73 + }, + "emit:tsc7": { + "coldMs": 209.94, + "warmMedianMs": 172.28 + }, + "emit:tsc5.9.3": { + "coldMs": 1561.22, + "warmMedianMs": 1561.22 + }, + "parse:oxc-parser": { + "coldMs": 1.07, + "warmMedianMs": 0.58 + }, + "parse:createSourceFile": { + "coldMs": 0.87, + "warmMedianMs": 0.53 + }, + "build:model": { + "coldMs": 13.09, + "warmMedianMs": 4.96 + }, + "resolve:messages": { + "coldMs": 3.64, + "warmMedianMs": 2.14 + } + } + }, + { + "name": "@systemfsoftware/differential-spec", + "version": "1.0.1", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 11, + "sourceBytes": 32396, + "declarationFiles": 11, + "declarationStatements": 85, + "clean": { + "oxc-transform": { + "diagnostics": 21, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 21, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 27, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 11, + "differing": 0, + "missing": 0, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "Fallback", + "outcome": "model", + "items": 16, + "locals": 0, + "messages": 21 + }, + "phases": { + "discover": { + "coldMs": 4.42, + "warmMedianMs": 3.07 + }, + "emit:oxc-transform": { + "coldMs": 0.86, + "warmMedianMs": 0.36 + }, + "emit:transpileDeclaration": { + "coldMs": 16.37, + "warmMedianMs": 12.76 + }, + "emit:tsc7": { + "coldMs": 257.95, + "warmMedianMs": 259.04 + }, + "emit:tsc5.9.3": { + "coldMs": 1706.63, + "warmMedianMs": 1706.63 + }, + "parse:oxc-parser": { + "coldMs": 1.47, + "warmMedianMs": 0.63 + }, + "parse:createSourceFile": { + "coldMs": 0.77, + "warmMedianMs": 0.56 + }, + "build:model": { + "coldMs": 3.66, + "warmMedianMs": 3.08 + }, + "resolve:messages": { + "coldMs": 1.48, + "warmMedianMs": 1.13 + } + } + }, + { + "name": "@systemfsoftware/effect-sim-kernel", + "version": "1.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 16, + "sourceBytes": 115718, + "declarationFiles": 16, + "declarationStatements": 137, + "clean": { + "oxc-transform": { + "diagnostics": 2, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 2, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 2, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 16, + "differing": 2, + "missing": 0, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "Fallback", + "outcome": "model", + "items": 1, + "locals": 17, + "messages": 45 + }, + "phases": { + "discover": { + "coldMs": 7.68, + "warmMedianMs": 5.56 + }, + "emit:oxc-transform": { + "coldMs": 1.15, + "warmMedianMs": 0.77 + }, + "emit:transpileDeclaration": { + "coldMs": 28.03, + "warmMedianMs": 22.87 + }, + "emit:tsc7": { + "coldMs": 180.09, + "warmMedianMs": 186.5 + }, + "emit:tsc5.9.3": { + "coldMs": 1567.6, + "warmMedianMs": 1567.6 + }, + "parse:oxc-parser": { + "coldMs": 1.81, + "warmMedianMs": 1.18 + }, + "parse:createSourceFile": { + "coldMs": 1.34, + "warmMedianMs": 1.08 + }, + "build:model": { + "coldMs": 28.15, + "warmMedianMs": 9.88 + }, + "resolve:messages": { + "coldMs": 5.6, + "warmMedianMs": 4.64 + } + } + }, + { + "name": "@systemfsoftware/trace-spec", + "version": "1.0.1", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 31, + "sourceBytes": 123477, + "declarationFiles": 31, + "declarationStatements": 342, + "clean": { + "oxc-transform": { + "diagnostics": 94, + "clean": false + }, + "transpileDeclaration": { + "diagnostics": 93, + "clean": false + }, + "tsc --isolatedDeclarations": { + "diagnostics": 235, + "clean": false + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 31, + "differing": 1, + "missing": 0, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "Fallback", + "outcome": "model", + "items": 9, + "locals": 26, + "messages": 102 + }, + "phases": { + "discover": { + "coldMs": 34.52, + "warmMedianMs": 9.82 + }, + "emit:oxc-transform": { + "coldMs": 1.7, + "warmMedianMs": 1.21 + }, + "emit:transpileDeclaration": { + "coldMs": 51.91, + "warmMedianMs": 50.77 + }, + "emit:tsc7": { + "coldMs": 434.3, + "warmMedianMs": 280.18 + }, + "emit:tsc5.9.3": { + "coldMs": 1879.85, + "warmMedianMs": 1879.85 + }, + "parse:oxc-parser": { + "coldMs": 4.87, + "warmMedianMs": 3.83 + }, + "parse:createSourceFile": { + "coldMs": 4.08, + "warmMedianMs": 4.48 + }, + "build:model": { + "coldMs": 16.62, + "warmMedianMs": 14.72 + }, + "resolve:messages": { + "coldMs": 7.22, + "warmMedianMs": 6.91 + } + } + }, + { + "name": "@systemfsoftware/trace-taxonomy", + "version": "1.0.0", + "origin": "systemfsoftware", + "entryPoints": 1, + "sourceFiles": 3, + "sourceBytes": 7226, + "declarationFiles": 3, + "declarationStatements": 28, + "clean": { + "oxc-transform": { + "diagnostics": 0, + "clean": true + }, + "transpileDeclaration": { + "diagnostics": 0, + "clean": true + }, + "tsc --isolatedDeclarations": { + "diagnostics": 0, + "clean": true + } + }, + "fallback": { + "exitCode": 0, + "diagnostics": 0 + }, + "divergence": { + "compared": 3, + "differing": 0, + "missing": 0, + "typescript59ExitCode": 0 + }, + "frontEnd": { + "route": "FastPath", + "outcome": "model", + "items": 2, + "locals": 2, + "messages": 18 + }, + "phases": { + "discover": { + "coldMs": 0.98, + "warmMedianMs": 0.51 + }, + "emit:oxc-transform": { + "coldMs": 0.22, + "warmMedianMs": 0.06 + }, + "emit:transpileDeclaration": { + "coldMs": 4.93, + "warmMedianMs": 3.22 + }, + "emit:tsc7": { + "coldMs": 164.83, + "warmMedianMs": 162.71 + }, + "emit:tsc5.9.3": { + "coldMs": 1300.82, + "warmMedianMs": 1300.82 + }, + "parse:oxc-parser": { + "coldMs": 0.4, + "warmMedianMs": 0.18 + }, + "parse:createSourceFile": { + "coldMs": 0.41, + "warmMedianMs": 0.14 + }, + "build:model": { + "coldMs": 2.1, + "warmMedianMs": 1.83 + }, + "resolve:messages": { + "coldMs": 1.25, + "warmMedianMs": 1.23 + } + } + } + ] +} diff --git a/docs/profiles/2026-10-13-front-end-phases.md b/docs/profiles/2026-10-13-front-end-phases.md new file mode 100644 index 0000000..8de888b --- /dev/null +++ b/docs/profiles/2026-10-13-front-end-phases.md @@ -0,0 +1,97 @@ +# Front-end phase profile + +Generated by `apps/api-extractor-profile/profile.ts`; the JSON beside this file is the record. Times are warm medians in milliseconds; +the clean columns count isolated-declaration diagnostics over the files the entry sources reach. + +```json +{ + "corpus": { + "repository": "https://github.com/systemfsoftware/systemfsoftware", + "commit": "732f66a0c85819b2005f9e0930a595bc4de91ee1" + }, + "ownPackage": { + "repository": "https://github.com/systemfsoftware/api-extractor-effect", + "commit": "432b608f15cfc21e8b9f3dddf07eeefe92079b8a" + }, + "host": { + "cpu": "AMD Ryzen 9 9950X 16-Core Processor", + "cores": 32, + "platform": "linux-x64", + "node": "v24.21.0" + }, + "tools": { + "oxc-transform": "0.150.0", + "oxc-parser": "0.150.0", + "typescript": "7.0.2", + "typescript-5.9": "5.9.3" + }, + "runs": { + "cold": 1, + "warm": 5, + "emit:tsc5.9.3": "cold only" + }, + "totals": { + "discover": 201.88, + "emit:oxc-transform": 25.74, + "emit:transpileDeclaration": 921.86, + "emit:tsc7": 9992.59, + "emit:tsc5.9.3": 67206.59, + "parse:oxc-parser": 56.57, + "parse:createSourceFile": 66, + "build:model": 274.59, + "resolve:messages": 132.58 + } +} +``` + +| Package | Origin | Sources | oxc clean | tD clean | tsc ID clean | discover | emit:oxc-transform | emit:transpileDeclaration | emit:tsc7 | emit:tsc5.9.3 | parse:oxc-parser | parse:createSourceFile | build:model | resolve:messages | TS7≠5.9.3 | no 5.9.3 emit | route | model | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| @systemfsoftware/api-extractor | api-extractor-effect | 33 | 0 | 0 | 0 | 17.28 | 1.67 | 67.01 | 289.86 | 2256.55 | 1.92 | 1.99 | 21.09 | 7.89 | 4/33 | 0 | FastPath | model | +| @systemfsoftware/effect-atom | systemfsoftware | 35 | 145 | 145 | 184 | 17.5 | 3.09 | 113.54 | 401.25 | 2482.43 | 7.47 | 13.44 | 2.7 | 2.68 | 1/35 | 0 | Fallback | UnsupportedDeclaration: src/atom-http-api.service.d.ts: `declare global` is not supported | +| @systemfsoftware/effect-atom-react | systemfsoftware | 10 | 20 | 20 | 29 | 2.62 | 0.33 | 13 | 465.04 | 2346.68 | 0.58 | 0.95 | 8.57 | 1.68 | 0/10 | 0 | Fallback | model | +| @systemfsoftware/effect-daemon-cluster | systemfsoftware | 5 | 1 | 1 | 1 | 1.41 | 0.07 | 3.98 | 261.14 | 1981.06 | 0.18 | 0.14 | 2.11 | 0.88 | 1/5 | 0 | Fallback | model | +| @systemfsoftware/effect-daemon-conformance | systemfsoftware | 15 | 47 | 47 | 96 | 5.73 | 0.65 | 15.48 | 247.62 | 1653.71 | 1.2 | 1.05 | 11.68 | 6.04 | 0/15 | 0 | Fallback | model | +| @systemfsoftware/effect-daemon-microvm | systemfsoftware | 9 | 21 | 21 | 28 | 2.09 | 0.26 | 8.6 | 335.71 | 2484.41 | 0.34 | 0.27 | 1.77 | 0.81 | 1/9 | 0 | Fallback | model | +| @systemfsoftware/effect-daemon-process | systemfsoftware | 9 | 22 | 22 | 29 | 1.82 | 0.21 | 9.41 | 318.18 | 2013.84 | 0.36 | 0.31 | 0.98 | 0.4 | 1/9 | 0 | Fallback | model | +| @systemfsoftware/effect-daemon-socket | systemfsoftware | 12 | 19 | 19 | 31 | 3.59 | 0.41 | 14.99 | 488.75 | 2759.83 | 0.87 | 0.74 | 3.67 | 2.01 | 0/12 | 0 | Fallback | model | +| @systemfsoftware/effect-daemon-spec | systemfsoftware | 22 | 138 | 138 | 279 | 21.04 | 1.42 | 46.87 | 325.4 | 1873.66 | 7.11 | 6.7 | 27.39 | 23.45 | 4/22 | 0 | Fallback | model | +| @systemfsoftware/discern | systemfsoftware | 40 | 144 | 144 | 355 | 23.69 | 2.37 | 89.81 | 328.82 | 2144.92 | 8.15 | 10.94 | 50.86 | 27.05 | 14/40 | 0 | Fallback | model | +| @systemfsoftware/effect-cell-types | systemfsoftware | 7 | 1 | 1 | 2 | 2.49 | 0.59 | 17 | 175.03 | 1425.33 | 2.23 | 2.79 | 32.37 | 9.82 | 1/7 | 0 | Fallback | model | +| @systemfsoftware/effect-memfs | systemfsoftware | 19 | 68 | 68 | 104 | 3.62 | 0.71 | 22.57 | 240.18 | 1644.17 | 1.11 | 0.89 | 6.59 | 1.48 | 1/19 | 0 | Fallback | model | +| @systemfsoftware/effect-microsandbox | systemfsoftware | 22 | 94 | 93 | 222 | 6.02 | 0.87 | 26.38 | 248.14 | 1814.17 | 3.44 | 3.05 | 14.3 | 7.27 | 9/22 | 0 | Fallback | model | +| @systemfsoftware/effect-readiness | systemfsoftware | 19 | 53 | 53 | 83 | 4.62 | 0.41 | 17.65 | 197.83 | 1540.65 | 1.08 | 0.85 | 10.66 | 3.09 | 1/19 | 0 | Fallback | model | +| @systemfsoftware/effect-spec-runtime | systemfsoftware | 6 | 1 | 1 | 2 | 1.88 | 0.25 | 8.21 | 199.33 | 1496.97 | 0.45 | 0.33 | 9.35 | 1.36 | 0/6 | 0 | FastPath | model | +| @systemfsoftware/effect-gherkin-spec | systemfsoftware | 9 | 15 | 19 | 24 | 4.54 | 0.52 | 22.23 | 215.79 | 1499.4 | 1.9 | 1.43 | 10.22 | 5.74 | 1/9 | 0 | Fallback | UnsupportedDeclaration: src/mod.d.ts: re-export of "it" from "@systemfsoftware/vitest" is not supported | +| @systemfsoftware/storybook-gherkin | systemfsoftware | 5 | 12 | 11 | 34 | 2.52 | 0.33 | 8.63 | 122.59 | 894.91 | 0.71 | 0.63 | 7.83 | 3.1 | 1/5 | 0 | Fallback | model | +| @systemfsoftware/npm-package | systemfsoftware | 11 | 29 | 29 | 41 | 2.64 | 0.36 | 13.02 | 225.03 | 1359.36 | 0.42 | 0.39 | 1.75 | 0.58 | 0/11 | 0 | Fallback | model | +| @systemfsoftware/oxlint-plugin-cell-architecture | systemfsoftware | 43 | 65 | 65 | 71 | 6.71 | 1.12 | 47.03 | 328.61 | 1747.94 | 1.46 | 1.18 | 1.05 | 0.41 | 19/43 | 0 | Fallback | model | +| @systemfsoftware/oxlint-plugin-dmmf-workflow | systemfsoftware | 19 | 65 | 65 | 65 | 4.27 | 0.74 | 27.6 | 347.53 | 3224.71 | 0.81 | 0.77 | 0.91 | 0.18 | 8/19 | 0 | Fallback | model | +| @systemfsoftware/oxlint-plugin-effect-platform | systemfsoftware | 21 | 52 | 52 | 62 | 3.97 | 0.51 | 21.77 | 256.37 | 1419.38 | 0.62 | 0.47 | 0.63 | 0.2 | 10/21 | 0 | Fallback | model | +| @systemfsoftware/oxlint-plugin-effect-schema | systemfsoftware | 23 | 42 | 42 | 45 | 4.49 | 0.74 | 23.25 | 280.24 | 2007.76 | 1.18 | 1.23 | 0.77 | 0.28 | 10/23 | 0 | Fallback | model | +| @systemfsoftware/oxlint-plugin-test-discipline | systemfsoftware | 65 | 147 | 147 | 150 | 12.76 | 2.35 | 66.01 | 170.97 | 1337.07 | 2.26 | 1.67 | 1.01 | 0.36 | 31/65 | 0 | Fallback | model | +| @systemfsoftware/oxlint-config-cell-architecture | systemfsoftware | 1 | 0 | 0 | 0 | 0.22 | 0.01 | 0.86 | 329.29 | 1895 | 0.03 | 0.02 | 0.23 | 0.38 | 0/1 | 0 | FastPath | model | +| @systemfsoftware/oxlint-config-dmmf | systemfsoftware | 1 | 0 | 0 | 0 | 0.17 | 0.03 | 0.87 | 356.66 | 2162.2 | 0.03 | 0.03 | 0.54 | 0.24 | 0/1 | 0 | FastPath | model | +| @systemfsoftware/oxlint-config-recommended | systemfsoftware | 1 | 0 | 0 | 0 | 0.86 | 0.02 | 2.1 | 647.97 | 3860.06 | 0.05 | 0.03 | 0.77 | 0.25 | 1/1 | 0 | FastPath | model | +| @systemfsoftware/oxlint-config-rule-authoring | systemfsoftware | 1 | 0 | 0 | 0 | 0.28 | 0.01 | 0.83 | 79.08 | 482.37 | 0.03 | 0.02 | 0.26 | 0.13 | 0/1 | 0 | FastPath | model | +| @systemfsoftware/vitest | systemfsoftware | 32 | 52 | 52 | 126 | 17.48 | 2.54 | 93.3 | 233.38 | 1426.82 | 3.83 | 6.63 | 6.54 | 6.85 | 1/32 | 0 | Fallback | UnsupportedDeclaration: src/mod.d.ts: `declare module "vitest"` is not supported | +| @systemfsoftware/rx-effect | systemfsoftware | 2 | 0 | 0 | 0 | 0.35 | 0.01 | 1.42 | 86.1 | 571.9 | 0.03 | 0.02 | 0.2 | 0.1 | 0/2 | 0 | FastPath | model | +| @systemfsoftware/effect-schema-discovery | systemfsoftware | 2 | 0 | 0 | 0 | 0.73 | 0.05 | 2.26 | 75.91 | 427.57 | 0.04 | 0.05 | 0.35 | 0.16 | 0/2 | 0 | FastPath | model | +| @systemfsoftware/effect-schema-extensions | systemfsoftware | 2 | 0 | 0 | 0 | 0.17 | 0.01 | 0.47 | 96.44 | 674.86 | 0.01 | 0 | 0.03 | 0.03 | 0/2 | 0 | FastPath | model | +| @systemfsoftware/effect-schema-law | systemfsoftware | 3 | 0 | 0 | 0 | 1.01 | 0.16 | 6.34 | 164.14 | 1263.3 | 0.06 | 0.03 | 0.37 | 0.17 | 0/3 | 0 | FastPath | model | +| @systemfsoftware/effect-schema-recursion-budget | systemfsoftware | 3 | 0 | 0 | 0 | 0.57 | 0.08 | 3.07 | 207.45 | 1527.15 | 0.05 | 0.04 | 0.55 | 0.16 | 1/3 | 0 | FastPath | model | +| @systemfsoftware/effect-schema-vite | systemfsoftware | 2 | 1 | 1 | 1 | 0.4 | 0.03 | 2.06 | 100.85 | 718.25 | 0.04 | 0.04 | 0.6 | 0.21 | 1/2 | 0 | Fallback | model | +| @systemfsoftware/hex-schema | systemfsoftware | 7 | 6 | 6 | 6 | 1.01 | 0.05 | 3.89 | 85.2 | 772.08 | 0.12 | 0.09 | 1.42 | 1.09 | 0/7 | 0 | Fallback | model | +| @systemfsoftware/conformance-spec | systemfsoftware | 8 | 2 | 2 | 4 | 2.37 | 0.36 | 10.73 | 172.28 | 1561.22 | 0.58 | 0.53 | 4.96 | 2.14 | 1/8 | 0 | Fallback | model | +| @systemfsoftware/differential-spec | systemfsoftware | 11 | 21 | 21 | 27 | 3.07 | 0.36 | 12.76 | 259.04 | 1706.63 | 0.63 | 0.56 | 3.08 | 1.13 | 0/11 | 0 | Fallback | model | +| @systemfsoftware/effect-sim-kernel | systemfsoftware | 16 | 2 | 2 | 2 | 5.56 | 0.77 | 22.87 | 186.5 | 1567.6 | 1.18 | 1.08 | 9.88 | 4.64 | 2/16 | 0 | Fallback | model | +| @systemfsoftware/trace-spec | systemfsoftware | 31 | 94 | 93 | 235 | 9.82 | 1.21 | 50.77 | 280.18 | 1879.85 | 3.83 | 4.48 | 14.72 | 6.91 | 1/31 | 0 | Fallback | model | +| @systemfsoftware/trace-taxonomy | systemfsoftware | 3 | 0 | 0 | 0 | 0.51 | 0.06 | 3.22 | 162.71 | 1300.82 | 0.18 | 0.14 | 1.83 | 1.23 | 0/3 | 0 | FastPath | model | +| **total** | | 585 | | | | 201.88 | 25.74 | 921.86 | 9992.59 | 67206.59 | 56.57 | 66 | 274.59 | 132.58 | 126/585 | 0 | | | + +## Findings + +This third run uses the U13 harness, which counts a file missing from the 5.9.3 output apart from a printing difference. TypeScript 7 and 5.9.3 print 126 of the 585 declaration files both emitted differently, and 5.9.3 emitted every file TypeScript 7 did (`missing` is 0 for all 40 packages). The 124 of 569 and 125 of 586 in the two earlier records were therefore printing differences too. + +Every package takes the same route with the same outcome as in [`2026-10-12-front-end-phases-built.md`](2026-10-12-front-end-phases-built.md). A run at `c4202d9`, not committed, refused `effect-daemon-cluster`, `effect-daemon-conformance`, `effect-daemon-process` and `effect-daemon-socket` with TS2688 for `types: ["vitest/globals"]`. The derived tsconfig sat outside the package's `node_modules` ancestry, and `432b608` fixed it (plan U13). + +The emitter decision is unchanged: `oxc-transform` and `transpileDeclaration` agree with `tsc --isolatedDeclarations` on every package, and `emit:oxc-transform` stays the cheapest emit phase. diff --git a/docs/solutions/logic-errors/compiler-fallback-inherits-declaration-emit-options.md b/docs/solutions/logic-errors/compiler-fallback-inherits-declaration-emit-options.md new file mode 100644 index 0000000..4310e9a --- /dev/null +++ b/docs/solutions/logic-errors/compiler-fallback-inherits-declaration-emit-options.md @@ -0,0 +1,163 @@ +--- +title: The compiler fallback inherits the package tsconfig's emit options, so the adapter pins every option that changes declaration text +date: 2026-10-12 +last_updated: 2026-10-13 +category: logic-errors +module: api-extractor front-ends (IsolatedEmitter and CompilerEmitter adapters) +problem_type: logic_error +component: front_end +symptoms: + - "A package whose tsconfig sets `stripInternal: true` has an `@internal` export in its model on the fast path and loses it on the fallback" + - The fast-vs-fallback equality journey stays green on this repository's package while the routes disagree elsewhere +root_cause: config_error +resolution_type: code_fix +severity: high +tags: [front-ends, isolated-declarations, oxc-transform, tsc, strip-internal, model-equality, tsconfig] +--- + +# The compiler fallback inherits the package tsconfig's emit options + +## Problem + +Plan R3 says both routes give one package one model. The two `DeclarationEmitter` adapters +get their emit options from different places: + +- `IsolatedEmitter` (oxc-transform `isolatedDeclarationSync`) passes a fixed option object + that includes `stripInternal: false`, and it never reads the package's tsconfig. +- `CompilerEmitter` runs TypeScript 7 `tsc` on the package's tsconfig, so it takes every + option the package sets unless the adapter overrides that option. + +A package built with `stripInternal: true` therefore loses its `/** @internal */` exports +on the fallback and keeps them on the fast path. The same package gets two models, and which +one depends on the route it took. + +## Failure mechanism + +1. The model is a pure function of declaration text (KTD6): + $\text{model} = \text{build}(\text{text}(\text{emit}(\text{options})))$. +2. Fast-path options are a code constant $O_c$. Fallback options are + $O_c \oplus O_{\text{tsconfig}}$, the tsconfig merged under the command line. +3. For any option $o$ that changes the emitted text and that $O_c$ does not pin, a package + with $O_{\text{tsconfig}}(o) \neq$ the fast path's behaviour produces different text, and + so a different model. +4. The equality journey cannot detect this unless its corpus both sets $o$ and declares + something $o$ acts on. This repository's own `tsconfig.build.json` sets + `stripInternal: true` but declares nothing `@internal`, so the journey stayed green. + +## Solution + +The emit policy is one exported record, `compilerEmitPolicy` in the TypeScript 7 adapter: +`stripInternal: false`, `isolatedDeclarations: false`, `removeComments: false`, `declaration`, `emitDeclarationOnly`, +`declarationMap: false`, `sourceMap: false`, `incremental: false`, `composite: false`, +`tsBuildInfoFile: null`, `rootDir` (workspace root), and `outDir` and `declarationDir` (the +adapter's scoped temp directory). The adapter writes `{ "extends": , +"compilerOptions": }` into that directory and runs `tsc -p` on it. A key in the +extending file's `compilerOptions` overrides the extended file's, so the package no longer +controls those options. (Until U12 the same options were command-line flags. They moved +into a config file because the adapter stopped reading `tsc`'s stdout; see U12 C3 in the +plan.) + +The e2e journey "Both front-ends reduce a package to one model" now also analyses a temp +package whose tsconfig sets `stripInternal: true` and which exports +`/** @internal */ hidden`. It asserts byte equality between the routes and that `hidden` is +present. Without `stripInternal: false` the compiled model lacks `hidden`, and the journey fails. + +The output location is part of the same policy. A tsconfig `declarationDir` overrides +`outDir` for `.d.ts` files (TSConfig reference, `declarationDir`: +https://www.typescriptlang.org/tsconfig/#declarationDir; reproduced on TypeScript 7.0.2, where +the adapter wrote under the package's own `types` directory). Before the policy also set +`declarationDir`, the adapter wrote the package's declarations into that `types` directory, +found nothing under its temp output directory, and refused the package. The same journey now +analyses a package whose tsconfig sets `declarationDir: "types"`. It asserts byte equality +between the routes and that no `types` directory appears in the package. + +`removeComments` is the second text-changing option found the same way. With it set, `tsc` +drops the `/** @public */` comment the message resolver reads its release tag from, so the +fallback reported `ae-missing-release-tag` for an export the fast path saw as tagged. The +adapter sets `removeComments: false`, and the journey analyses a package whose tsconfig +sets `removeComments: true` and asserts both routes give the same notes, and none. + +Moving the policy into a derived config file exposed settings that resolve from the config +file's directory, not the package's. Each was reproduced on TypeScript 7.0.2 with a derived +config in a temp directory: + +- The default `typeRoots`: the `@types` folder of every `node_modules` directory from the + config's directory upward. Extending this package's `tsconfig.build.json` (`types: ["node"]`) + failed with TS2688 `Cannot find type definition file for 'node'`. +- A `types` entry that names a package rather than an `@types` folder. `tsc` looks it up in the + `node_modules` directories from the config's directory upward, honouring the package's + `exports`. Four corpus packages that name `vitest/globals` (`effect-daemon-cluster` and three + siblings) failed with TS2688 `Cannot find type definition file for 'vitest/globals'`, after + an earlier profile had modelled them. +- The default `include` (`**/*`). A package tsconfig that names no `include` or `files` failed + with TS18003 `No inputs were found in config file`, because `**/*` matched the temp + directory. + +The derived config is therefore written into a scoped mirror of the package config's +directory: every ancestor of that directory is recreated under the temp root, each with a +symlink to the real ancestor's `node_modules` where one exists. `tsc`'s upward `node_modules` +walk from the derived config then visits the same directories as from the package's own +config, so default `typeRoots` and `types` lookups resolve as they would from the package. A +declared `typeRoots` resolves against the config that declares it through `extends`. The +derived config sets `files` to the package config's resolved `fileNames`, read once with the +TypeScript 7 API's `parseConfigFile`. An earlier fix carried an explicit `typeRoots` chain +instead. It did not cover `types` packages, and with it in place the mirror still failed +TS2688 for `vitest/globals`. `paths` needed nothing: a package declaring +`paths: { "#lib/*": ["./lib/*"] }` compiled through the derived config. + +The mirror follows `tsc`'s walk exactly, including its one exception: `tsc` never looks for a +`node_modules` inside a directory named `node_modules`. A mirrored ancestor named +`node_modules` therefore gets no symlink, and the mirrored segment is renamed. Without the +rename, a package installed at `/node_modules/` made the mirror create +`/node_modules` as a real directory and then fail to symlink the same path +(`AlreadyExists`, reproduced through `CompilerEmitter`). + +`isolatedDeclarations` changes whether `tsc` refuses, not what it prints. The fast path +declines a package with an unannotated export (`NotIsolated`, TS9010) and routes it to the +fallback. A package whose own tsconfig sets `isolatedDeclarations: true` made the fallback's +`tsc` raise the same TS9010, exit non-zero, and refuse the package with `CompilerRejected`, +so the route meant for that package could never analyse it (reproduced on TypeScript 7.0.2: +the same source gave a model with the flag off and `CompilerRejected` with it on). The +policy pins `isolatedDeclarations: false`, and the e2e TS9010 routing scenario analyses the +same package with the flag set and asserts the same fallback model. + +## Architectural invariant + +**Single emit policy.** The fast path's options are the declaration-emit policy. The +fallback's derived config restates every option in that policy, so a package's tsconfig +cannot make the routes disagree on them. Every other tsconfig option still reaches the +fallback through `extends`. A setting that resolves from the config file's location sees the +package's own `node_modules` ancestry through the mirror, and the root files are carried +explicitly: + +``` +derived at mirror(dirname(packageTsconfig)) = { extends: packageTsconfig, files: packageFileNames, compilerOptions: compilerEmitPolicy } +``` + +## Prevention + +- Change both adapters together. An option added to the fast path's `isolatedDeclarationSync` + call is added to `compilerEmitPolicy` in the same change. +- Give each pinned option an equality case built on a package that sets the opposite value + and declares something the option acts on. A corpus that never trips the option makes the + equality law vacuous. +- Pin every option that moves an emitted file, not only those that change its text. Each + output-location option (`outDir`, `declarationDir`) points at the adapter's scoped temp + directory. Gate: the `wroteIntoPackage: false` assertion of the journey "Both front-ends + reduce a package to one model" fails when a package is written to. +- A setting that resolves from the config file's directory resolves through the mirrored + `node_modules` ancestry, and the root files are carried as `files`. Gates, in the same + journey: the `types: ["greeting/globals"]` case refuses on the fallback when the derived + config sits outside the mirror, the package installed under `node_modules` fails with + `AlreadyExists` when a mirrored `node_modules` segment keeps its name, the + `typeRoots: ["./types"]` case refuses when a default chain replaces the declared one, and + the no-`include` case when `files` is not carried. +- Pin every option that makes `tsc` refuse what the fast path routes to it. Gate: the e2e + scenario "an export with an inferred type routes to the compiler" analyses a package that + sets `isolatedDeclarations: true` and refuses on the fallback when the option is not pinned. + +## Related + +- `docs/plans/2026-10-14-0214-feat-front-ends-plan.md` (R3; U10 disposition #6; U12 emit policy; U13 #4, the mirrored `node_modules` ancestry and its delta ruling) +- `docs/solutions/design-patterns/keyed-uniqueness-in-the-api-model-schema.md`: a law passes + vacuously when its corpus lacks the triggering shape diff --git a/packages/api-extractor/package.json b/packages/api-extractor/package.json index e33e018..e6a2756 100644 --- a/packages/api-extractor/package.json +++ b/packages/api-extractor/package.json @@ -32,10 +32,15 @@ "mutation": "stryker run" }, "dependencies": { + "@microsoft/tsdoc": "catalog:", "@systemfsoftware/effect-cell-types": "^12.0.0", - "effect": "catalog:" + "effect": "catalog:", + "oxc-parser": "catalog:", + "oxc-transform": "catalog:", + "typescript": "catalog:" }, "devDependencies": { + "@effect/platform-node": "catalog:", "@microsoft/api-extractor": "catalog:", "@systemfsoftware/effect-gherkin-spec": "^7.0.1", "@systemfsoftware/effect-schema-law": "^5.0.1", @@ -55,7 +60,6 @@ "oxlint": "catalog:", "oxlint-tsgolint": "catalog:", "tsdown": "catalog:", - "typescript": "catalog:", "vite": "catalog:", "vitest": "catalog:" }, diff --git a/packages/api-extractor/src/FrontEnd.ts b/packages/api-extractor/src/FrontEnd.ts new file mode 100644 index 0000000..e1bcfb2 --- /dev/null +++ b/packages/api-extractor/src/FrontEnd.ts @@ -0,0 +1,25 @@ +export { type Analysis, analyse } from './front-end/analyse.ts' +export { AnalysisMessage } from './front-end/analysis-message.schema.ts' +export { buildModel } from './front-end/build-model.ts' +export { + CompilerEmitter, + type CompilerEmitterId, + type DeclarationEmitter, + IsolatedEmitter, + type IsolatedEmitterId, +} from './front-end/declaration-emitter.service.ts' +export { type DeclarationFile, Declarations, Diagnostic, Note, type PackageSource, Reported, type SourceEntryPoint } from './front-end/declarations.schema.ts' +export { CompilerRejected, ManifestRejected, NoSourceEntry, NotIsolated, UnsupportedDeclaration } from './front-end/front-end-error.schema.ts' +export { layer as isolatedEmitterLayer, OXC_TRANSFORM_PRODUCER } from './front-end/oxc-isolated-emitter.ts' +export { FAMILY_SOURCE_CONDITION, readPackage, type ReadPackageRequest } from './front-end/read-package.ts' +export { resolveMessages } from './front-end/resolve-messages.ts' +export { Fallback, FastPath, Route } from './front-end/route.schema.ts' +export { + compilerEmitPolicy, + type CompilerEmitPolicy, + type CompilerEmitPolicyRequest, + writeDerivedTsconfig, + type DerivedTsconfigRequest, + layer as compilerEmitterLayer, + TYPESCRIPT_PRODUCER, +} from './front-end/typescript-7-compiler.ts' diff --git a/packages/api-extractor/src/canonical-json.schema.ts b/packages/api-extractor/src/canonical-json.schema.ts index 152867c..661141f 100644 --- a/packages/api-extractor/src/canonical-json.schema.ts +++ b/packages/api-extractor/src/canonical-json.schema.ts @@ -6,7 +6,7 @@ import { Match, Order, Predicate, Schema } from 'effect' /** A JSON value: what every output is serialised from. */ -export const Json = Schema.Json +export const Json: typeof Schema.Json = Schema.Json export type Json = Schema.Json type Entry = readonly [string, Json] diff --git a/packages/api-extractor/src/diff/change.schema.ts b/packages/api-extractor/src/diff/change.schema.ts index 736da45..3d3a2fb 100644 --- a/packages/api-extractor/src/diff/change.schema.ts +++ b/packages/api-extractor/src/diff/change.schema.ts @@ -31,7 +31,7 @@ import { import { type Direction, direction } from './direction.schema.ts' import { type ChannelType, compose, flip, type Position } from './position.schema.ts' -export const ChangeKind = Schema.Literals([ +const CHANGE_KINDS = [ 'entry-point-added', 'entry-point-removed', 'export-added', @@ -65,7 +65,9 @@ export const ChangeKind = Schema.Literals([ 'enum-member-value-changed', 'enum-const-changed', 'type-changed', -]) +] as const + +export const ChangeKind: Schema.Literals = Schema.Literals(CHANGE_KINDS) export type ChangeKind = typeof ChangeKind.Type /** One difference between two models, before any rule judges it. */ diff --git a/packages/api-extractor/src/diff/direction.schema.ts b/packages/api-extractor/src/diff/direction.schema.ts index 6eb50bd..f818d8c 100644 --- a/packages/api-extractor/src/diff/direction.schema.ts +++ b/packages/api-extractor/src/diff/direction.schema.ts @@ -3,7 +3,7 @@ import { canonicalKey } from '../canonical-json.schema.ts' import type { KeywordName, TypeExpr } from '../model/type-expr.schema.ts' /** How a type moved: it admits more values, fewer, or neither. */ -export const Direction = Schema.Literals(['widened', 'narrowed', 'changed']) +export const Direction: Schema.Literals = Schema.Literals(['widened', 'narrowed', 'changed']) export type Direction = typeof Direction.Type /** The keyword type each literal spelling belongs to, by the spelling's shape. */ diff --git a/packages/api-extractor/src/diff/position.schema.ts b/packages/api-extractor/src/diff/position.schema.ts index f165b08..e2ea4d8 100644 --- a/packages/api-extractor/src/diff/position.schema.ts +++ b/packages/api-extractor/src/diff/position.schema.ts @@ -2,10 +2,10 @@ import { Function, Schema } from 'effect' import { TypeExpr } from '../model/type-expr.schema.ts' /** Where a type sits relative to the consumer: produced (`out`), consumed (`in`), or both. */ -export const Position = Schema.Literals(['in', 'out', 'both']) +export const Position: Schema.Literals = Schema.Literals(['in', 'out', 'both']) export type Position = typeof Position.Type -export const Variance = Schema.Literals(['in', 'out']) +export const Variance: Schema.Literals = Schema.Literals(['in', 'out']) export type Variance = typeof Variance.Type /** A generic type whose type arguments are Effect-style channels with a declared variance. */ diff --git a/packages/api-extractor/src/front-end/analyse.ts b/packages/api-extractor/src/front-end/analyse.ts new file mode 100644 index 0000000..10f2635 --- /dev/null +++ b/packages/api-extractor/src/front-end/analyse.ts @@ -0,0 +1,50 @@ +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 { 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' + +export type Analysis = { + readonly route: Route + readonly model: ApiModel + readonly messages: ReadonlyArray +} + +type Routed = { readonly route: Route; readonly declarations: Declarations } + +const compiled = (source: PackageSource, refusal: NotIsolated): Effect.Effect => + Effect.gen(function* () { + const compiler = yield* CompilerEmitter + const declarations = yield* compiler.emit(source) + const routed: Routed = { route: { _tag: 'Fallback', diagnostics: refusal.diagnostics }, declarations } + return routed + }) + +const routed = (source: PackageSource): Effect.Effect => + Effect.gen(function* () { + const isolated = yield* IsolatedEmitter + return yield* isolated.emit(source).pipe( + Effect.map((declarations): Routed => ({ route: { _tag: 'FastPath' }, declarations })), + Effect.catchTag('NotIsolated', (refusal) => compiled(source, refusal)), + ) + }) + +export const analyse = ( + source: PackageSource, +): Effect.Effect => + Effect.gen(function* () { + const { route, declarations } = yield* routed(source) + const graph = yield* declarationGraph(declarations) + const analysis: Analysis = { + route, + model: yield* modelOfGraph({ graph, producer: declarations.producer, package: declarations.package }), + messages: yield* messagesOfGraph(graph), + } + return analysis + }) diff --git a/packages/api-extractor/src/front-end/analysis-message.schema.ts b/packages/api-extractor/src/front-end/analysis-message.schema.ts new file mode 100644 index 0000000..d4419c9 --- /dev/null +++ b/packages/api-extractor/src/front-end/analysis-message.schema.ts @@ -0,0 +1,26 @@ +import { Schema } from 'effect' + +const ANALYSIS_MESSAGE_IDS = [ + 'ae-forgotten-export', + 'ae-incompatible-release-tags', + 'ae-internal-missing-underscore', + 'ae-internal-mixed-release-tag', + 'ae-missing-release-tag', +] as const + +export const AnalysisMessageId: Schema.Literals = Schema.Literals(ANALYSIS_MESSAGE_IDS) +export type AnalysisMessageId = typeof AnalysisMessageId.Type + +export type AnalysisMessage = { + readonly messageId: AnalysisMessageId + readonly entryPoint: string + readonly item: string + readonly symbol: string +} + +export const AnalysisMessage: Schema.Codec = Schema.Struct({ + messageId: AnalysisMessageId, + entryPoint: Schema.String, + item: Schema.String, + symbol: Schema.String, +}) diff --git a/packages/api-extractor/src/front-end/build-model.ts b/packages/api-extractor/src/front-end/build-model.ts new file mode 100644 index 0000000..41154b7 --- /dev/null +++ b/packages/api-extractor/src/front-end/build-model.ts @@ -0,0 +1,624 @@ +import { Array as Arr, Effect, HashMap, HashSet, Match, Option, Order, Ref } from 'effect' +import type { + BindingPattern, + Class, + Directive, + Expression, + Function as FunctionNode, + Statement, + TSEnumDeclaration, + TSEnumMember, + TSEnumMemberName, + TSInterfaceDeclaration, + TSModuleBlock, + TSModuleDeclaration, + TSTypeAliasDeclaration, + TSTypeAnnotation, + VariableDeclarator, +} from 'oxc-parser' +import { + API_MODEL_SCHEMA_VERSION, + sortModel, + type ApiItem, + type ApiModel, + type EntryPoint, + type EnumMember, + type TypeAliasItem, +} from '../model/api-model.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 { TypeExprs } from './type-expr-of.ts' + +const { withNamesInScope, withNamespaceScope } = TypePrinting + +const { classMembersOf, heritageOf, membersOf, superClassOf, signatureOf, typeExprOf, typeParametersOf } = TypeExprs + +type LocalDeclaration = { + readonly key: string + readonly file: string + readonly name: string + readonly declarations: ReadonlyArray +} + +type VariableEntry = { readonly constant: boolean; readonly node: VariableDeclarator } + +const KEY_DELIMITER = '\u0000' + +const localKey = (file: string, name: string): string => `${KEY_DELIMITER}${file}${KEY_DELIMITER}${name}${KEY_DELIMITER}` + +const localKeyParts = (key: string): ReadonlyArray => key.split(KEY_DELIMITER) + +const localKeyFile = (key: string): string => Option.getOrElse(Arr.get(localKeyParts(key), 1), () => '') + +const localKeyName = (key: string): string => Option.getOrElse(Arr.get(localKeyParts(key), 2), () => '') + +const keyOrder: Order.Order = Order.combineAll([ + Order.mapInput(Order.String, localKeyFile), + Order.mapInput(Order.String, localKeyName), +]) + +const nameCandidate = (base: string, attempt: number): string => (attempt === 1 ? base : `${base}_${attempt}`) + +const freeName = (base: string, attempt: number, used: HashSet.HashSet): string => + HashSet.has(used, nameCandidate(base, attempt)) ? freeName(base, attempt + 1, used) : nameCandidate(base, attempt) + +type NameAssignment = { readonly assigned: HashMap.HashMap; readonly used: HashSet.HashSet } + +const assignNames = (exported: ReadonlyArray, reachable: HashMap.HashMap): HashMap.HashMap => + Arr.reduce( + Arr.sort(HashMap.keys(reachable), keyOrder), + { assigned: HashMap.empty(), used: HashSet.fromIterable(exported) }, + (state, key) => { + const name = freeName(localKeyName(key), 1, state.used) + return { assigned: HashMap.set(state.assigned, key, name), used: HashSet.add(state.used, name) } + }, + ).assigned + +const replaceNames = (text: string, rename: HashMap.HashMap): string => + Arr.reduce(HashMap.toEntries(rename), text, (accumulated, [key, name]) => accumulated.replaceAll(key, () => name)) + +const constraintField = (parameter: TypeParameter, rename: HashMap.HashMap): Pick => + Option.match(Option.fromNullishOr(parameter.constraint), { onNone: () => ({}), onSome: (constraint) => ({ constraint: renameType(constraint, rename) }) }) + +const defaultField = (parameter: TypeParameter, rename: HashMap.HashMap): Pick => + Option.match(Option.fromNullishOr(parameter.default), { onNone: () => ({}), onSome: (fallback) => ({ default: renameType(fallback, rename) }) }) + +const renameTypeParameter = (parameter: TypeParameter, rename: HashMap.HashMap): TypeParameter => ({ + name: parameter.name, + ...constraintField(parameter, rename), + ...defaultField(parameter, rename), +}) + +const renameSignature = (signature: Signature, rename: HashMap.HashMap): Signature => ({ + typeParameters: signature.typeParameters.map((parameter) => renameTypeParameter(parameter, rename)), + parameters: signature.parameters.map((parameter) => ({ ...parameter, type: renameType(parameter.type, rename) })), + returns: renameType(signature.returns, rename), +}) + +const renameMember = (member: Member, rename: HashMap.HashMap): Member => + Match.value(member).pipe( + Match.when({ kind: 'property' }, (property) => ({ ...property, type: renameType(property.type, rename) })), + Match.when({ kind: 'method' }, (method) => ({ ...method, signatures: method.signatures.map((signature) => renameSignature(signature, rename)) })), + Match.when({ kind: 'call' }, (call) => ({ kind: 'call' as const, signature: renameSignature(call.signature, rename) })), + Match.exhaustive, + ) + +const renameReference = (reference: TypeExpr & { readonly kind: 'reference' }, rename: HashMap.HashMap): TypeExpr => { + const args = reference.args.map((argument) => renameType(argument, rename)) + return Option.match(Option.fromNullishOr(reference.module), { + onSome: () => ({ ...reference, args }), + onNone: () => ({ ...reference, name: replaceNames(reference.name, rename), args }), + }) +} + +const renameType = (type: TypeExpr, rename: HashMap.HashMap): TypeExpr => + Match.value(type).pipe( + Match.when({ kind: 'reference' }, (reference) => renameReference(reference, rename)), + Match.when({ kind: 'union' }, (union) => ({ ...union, members: union.members.map((member) => renameType(member, rename)) })), + Match.when({ kind: 'intersection' }, (intersection) => ({ ...intersection, members: intersection.members.map((member) => renameType(member, rename)) })), + Match.when({ kind: 'array' }, (array) => ({ ...array, element: renameType(array.element, rename) })), + Match.when({ kind: 'tuple' }, (tuple) => ({ ...tuple, elements: tuple.elements.map((element) => renameType(element, rename)) })), + Match.when({ kind: 'function' }, (function_) => ({ kind: 'function' as const, signature: renameSignature(function_.signature, rename) })), + Match.when({ kind: 'object' }, (object) => ({ kind: 'object' as const, members: object.members.map((member) => renameMember(member, rename)) })), + Match.when({ kind: 'text' }, (text) => ({ kind: 'text' as const, text: replaceNames(text.text, rename) })), + Match.when({ kind: 'keyword' }, (keyword) => keyword), + Match.when({ kind: 'literal' }, (literal) => literal), + Match.exhaustive, + ) + +const renameAlias = (alias: TypeAliasItem, rename: HashMap.HashMap): TypeAliasItem => ({ + kind: 'typeAlias', + name: Option.getOrElse(HashMap.get(rename, alias.name), () => alias.name), + typeParameters: alias.typeParameters.map((parameter) => renameTypeParameter(parameter, rename)), + type: renameType(alias.type, rename), +}) + +const renameItem = (item: ApiItem, rename: HashMap.HashMap): ApiItem => + Match.value(item).pipe( + Match.when({ kind: 'namespace' }, (namespace) => ({ ...namespace, items: namespace.items.map((nested) => renameItem(nested, rename)) })), + Match.when({ kind: 'function' }, (function_) => ({ ...function_, signatures: function_.signatures.map((signature) => renameSignature(signature, rename)) })), + Match.when({ kind: 'variable' }, (variable) => ({ ...variable, type: renameType(variable.type, rename) })), + Match.when({ kind: 'typeAlias' }, (alias) => renameAlias(alias, rename)), + Match.when({ kind: 'interface' }, (iface) => ({ + ...iface, + typeParameters: iface.typeParameters.map((parameter) => renameTypeParameter(parameter, rename)), + extends: iface.extends.map((extended) => renameType(extended, rename)), + members: iface.members.map((member) => renameMember(member, rename)), + })), + Match.when({ kind: 'class' }, (class_) => ({ + ...class_, + typeParameters: class_.typeParameters.map((parameter) => renameTypeParameter(parameter, rename)), + extends: class_.extends.map((extended) => renameType(extended, rename)), + implements: class_.implements.map((implemented) => renameType(implemented, rename)), + members: class_.members.map((member) => renameMember(member, rename)), + })), + Match.when({ kind: 'enum' }, (enumeration) => enumeration), + Match.exhaustive, + ) + +const addExportedName = (index: HashMap.HashMap, key: string, name: string): HashMap.HashMap => + HashMap.has(index, key) ? index : HashMap.set(index, key, name) + +const reverseIndex = (graph: DeclarationGraph, file: string, exported: ReadonlyArray): HashMap.HashMap => + Arr.reduce(exported, HashMap.empty(), (index, name) => + Option.match(exportedSymbol(graph, file, name), { + onNone: () => index, + onSome: (symbol) => + Match.value(symbol).pipe( + Match.when({ kind: 'local' }, (local) => addExportedName(index, localKey(local.file, local.name), name)), + Match.orElse(() => index), + ), + })) + +const recordLocal = ( + reachable: Ref.Ref>, + file: string, + name: string, + declarations: ReadonlyArray, +): Effect.Effect => + Effect.as( + Ref.update(reachable, (known): HashMap.HashMap => + HashMap.set(known, localKey(file, name), { key: localKey(file, name), file, name, declarations })), + localKey(file, name), + ) + +const localNameOf = ( + reachable: Ref.Ref>, + exported: HashMap.HashMap, + file: string, + name: string, + declarations: ReadonlyArray, +): Effect.Effect => + Option.match(HashMap.get(exported, localKey(file, name)), { + onNone: () => recordLocal(reachable, file, name, declarations), + onSome: (exportName) => Effect.succeed(exportName), + }) + +const functionsIn = (declarations: ReadonlyArray): ReadonlyArray => + Arr.getSomes( + declarations.map((declaration) => + Match.value(declaration.declared).pipe( + Match.when({ kind: 'function' }, (declared) => Option.some(declared.node)), + Match.orElse(() => Option.none()), + )), + ) + +const interfacesIn = (declarations: ReadonlyArray): ReadonlyArray => + Arr.getSomes( + declarations.map((declaration) => + Match.value(declaration.declared).pipe( + Match.when({ kind: 'interface' }, (declared) => Option.some(declared.node)), + Match.orElse(() => Option.none()), + )), + ) + +const classesIn = (declarations: ReadonlyArray): ReadonlyArray => + Arr.getSomes( + declarations.map((declaration) => + Match.value(declaration.declared).pipe( + Match.when({ kind: 'class' }, (declared) => Option.some(declared.node)), + Match.orElse(() => Option.none()), + )), + ) + +const typeAliasesIn = (declarations: ReadonlyArray): ReadonlyArray => + Arr.getSomes( + declarations.map((declaration) => + Match.value(declaration.declared).pipe( + Match.when({ kind: 'typeAlias' }, (declared) => Option.some(declared.node)), + Match.orElse(() => Option.none()), + )), + ) + +const variablesIn = (declarations: ReadonlyArray): ReadonlyArray => + Arr.getSomes( + declarations.map((declaration) => + Match.value(declaration.declared).pipe( + Match.when({ kind: 'variable' }, (declared) => Option.some({ constant: declared.constant, node: declared.node })), + Match.orElse(() => Option.none()), + )), + ) + +const enumsIn = (declarations: ReadonlyArray): ReadonlyArray => + Arr.getSomes( + declarations.map((declaration) => + Match.value(declaration.declared).pipe( + Match.when({ kind: 'enum' }, (declared) => Option.some(declared.node)), + Match.orElse(() => Option.none()), + )), + ) + +const namespacesIn = (declarations: ReadonlyArray): ReadonlyArray => + Arr.getSomes( + declarations.map((declaration) => + Match.value(declaration.declared).pipe( + Match.when({ kind: 'namespace' }, (declared) => Option.some(declared.node)), + Match.orElse(() => Option.none()), + )), + ) + +const typeAliasItem = (context: TypeContext, node: TSTypeAliasDeclaration, name: string): Effect.Effect => + Effect.gen(function* () { + 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 } + }) + +const declarationSignatureOf = (context: TypeContext, node: FunctionNode) => + signatureOf(context, node.typeParameters, node.params, node.returnType) + +const functionItemOf = ( + context: TypeContext, + nodes: ReadonlyArray, + name: string, +): Effect.Effect, UnsupportedDeclaration> => + Arr.match(nodes, { + onEmpty: () => Effect.succeedNone, + onNonEmpty: (present) => + Effect.asSome( + Effect.map(Effect.forEach(present, (node) => declarationSignatureOf(context, node)), (signatures): ApiItem => ({ + kind: 'function', + name, + signatures, + })), + ), + }) + +const interfaceItemOf = ( + context: TypeContext, + nodes: ReadonlyArray, + name: string, +): Effect.Effect, UnsupportedDeclaration> => + Arr.match(nodes, { + onEmpty: () => Effect.succeedNone, + onNonEmpty: (present) => + Effect.asSome( + Effect.gen(function* () { + const first = Arr.headNonEmpty(present) + const inner = withNamesInScope(context, typeParameterNames(first.typeParameters)) + 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() } + return item + }), + ), + }) + +const implementedClauses = (context: TypeContext, node: Class) => + Effect.forEach(node.implements ?? [], (clause) => heritageOf(context, clause)) + +const extendsOf = (context: TypeContext, node: Class): Effect.Effect, UnsupportedDeclaration> => + Option.match(Option.fromNullishOr(node.superClass), { + onNone: () => Effect.succeed>([]), + onSome: (superClass) => Effect.map(superClassOf(context, superClass, node.superTypeArguments), (extended) => [extended]), + }) + +const classItemOf = ( + context: TypeContext, + nodes: ReadonlyArray, + name: string, +): Effect.Effect, UnsupportedDeclaration> => + Arr.match(nodes, { + onEmpty: () => Effect.succeedNone, + onNonEmpty: ([node]) => + Effect.asSome( + Effect.gen(function* () { + const inner = withNamesInScope(context, typeParameterNames(node.typeParameters)) + const typeParameters = yield* typeParametersOf(inner, node.typeParameters) + const extended = yield* extendsOf(inner, node) + const implemented = yield* implementedClauses(inner, node) + const members = yield* classMembersOf(inner, node.body.body) + const item: ApiItem = { + kind: 'class', + name, + abstract: node.abstract ?? false, + typeParameters, + extends: extended, + implements: implemented, + members, + } + return item + }), + ), + }) + +const typeAliasItemOf = ( + context: TypeContext, + nodes: ReadonlyArray, + name: string, +): Effect.Effect, UnsupportedDeclaration> => + Arr.match(nodes, { + onEmpty: () => Effect.succeedNone, + onNonEmpty: ([node]) => Effect.asSome(typeAliasItem(context, node, name)), + }) + +const declaredAnnotation = (pattern: BindingPattern): Option.Option => + Match.value(pattern).pipe( + Match.when({ type: 'Identifier' }, (identifier) => Option.fromNullishOr(identifier.typeAnnotation)), + Match.orElse(() => Option.none()), + ) + +const variableType = (context: TypeContext, declarator: VariableDeclarator): Effect.Effect => + Option.match(declaredAnnotation(declarator.id), { + onSome: (annotation) => typeExprOf(context, annotation.typeAnnotation), + onNone: () => + Option.match(Option.fromNullishOr(declarator.init), { + onNone: () => refuse(context.file, 'a variable declaration without a type'), + onSome: (value) => + Option.match(literalTextOf(value), { + onNone: () => refuse(context.file, 'a variable initialiser that is not a literal'), + onSome: (text) => Effect.succeed({ kind: 'literal', text }), + }), + }), + }) + +const variableItemOf = ( + context: TypeContext, + entries: ReadonlyArray, + name: string, +): Effect.Effect, UnsupportedDeclaration> => + Arr.match(entries, { + onEmpty: () => Effect.succeedNone, + onNonEmpty: ([variable]) => + Effect.asSome( + Effect.map(variableType(context, variable.node), (type): ApiItem => ({ kind: 'variable', name, constant: variable.constant, type })), + ), + }) + +const enumMemberNameOf = (context: TypeContext, id: TSEnumMemberName): Effect.Effect => + Match.value(id).pipe( + Match.when({ type: 'Identifier' }, (identifier) => Effect.succeed(identifier.name)), + Match.when({ type: 'Literal', value: Match.string }, (literal) => Effect.succeed(literal.value)), + Match.orElse(() => refuse(context.file, `a ${id.type} enum member name`)), + ) + +const memberValue = (context: TypeContext, name: string, initializer: Expression | null): Effect.Effect, UnsupportedDeclaration> => + Option.match(Option.fromNullishOr(initializer), { + onNone: () => Effect.succeed({}), + onSome: (expression) => + Option.match(literalTextOf(expression), { + onNone: () => refuse(context.file, `enum member \`${name}\` with a computed value`), + onSome: (value) => Effect.succeed({ value }), + }), + }) + +const enumMemberOf = (context: TypeContext, member: TSEnumMember): Effect.Effect => + Effect.flatMap(enumMemberNameOf(context, member.id), (name) => + Effect.map(memberValue(context, name, member.initializer), (value): EnumMember => ({ name, ...value }))) + +const duplicateEnumMember = (seen: HashSet.HashSet, member: EnumMember): Option.Option => + Option.liftPredicate(member.name, (name) => HashSet.has(seen, name)) + +type EnumMemberScan = { readonly seen: HashSet.HashSet; readonly duplicate: Option.Option } + +const scanEnumMember = (state: EnumMemberScan, member: EnumMember): EnumMemberScan => + Option.match(state.duplicate, { + onSome: () => state, + onNone: () => Option.match(duplicateEnumMember(state.seen, member), { + onNone: () => ({ seen: HashSet.add(state.seen, member.name), duplicate: Option.none() }), + onSome: (name) => ({ seen: state.seen, duplicate: Option.some(name) }), + }), + }) + +const duplicatedMemberName = (members: ReadonlyArray): Option.Option => + Arr.reduce(members, { seen: HashSet.empty(), duplicate: Option.none() }, scanEnumMember).duplicate + +const enumMembersOf = ( + context: TypeContext, + nodes: ReadonlyArray, +): Effect.Effect, UnsupportedDeclaration> => + Effect.flatMap( + Effect.forEach(nodes.flatMap((node) => node.body.members), (member) => enumMemberOf(context, member)), + (members) => + Option.match(duplicatedMemberName(members), { + onNone: () => Effect.succeed(members), + onSome: (name) => refuse(context.file, `enum member \`${name}\` declared twice`), + }), + ) + +const enumItemOf = ( + context: TypeContext, + nodes: ReadonlyArray, + name: string, +): Effect.Effect, UnsupportedDeclaration> => + Arr.match(nodes, { + onEmpty: () => Effect.succeedNone, + onNonEmpty: (present) => + Effect.asSome( + Effect.map(enumMembersOf(context, present), (members): ApiItem => ({ kind: 'enum', name, const: Arr.headNonEmpty(present).const, members })), + ), + }) + +const namespaceItemOf = ( + context: TypeContext, + nodes: ReadonlyArray, + name: string, +): 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 })), + ), + }) + +const byGroupName: Order.Order]> = Order.mapInput(Order.String, ([name]) => name) + +const itemsOfGroups = ( + context: TypeContext, + groups: HashMap.HashMap>, +): Effect.Effect, UnsupportedDeclaration> => + Effect.map( + Effect.forEach(Arr.sort(HashMap.toEntries(groups), byGroupName), ([name, declarations]) => itemsOfDeclarations(context, declarations, name)), + (items) => items.flat(), + ) + +const namespaceBodyItems = ( + context: TypeContext, + bodies: ReadonlyArray, +): Effect.Effect, UnsupportedDeclaration> => + Effect.flatMap(bodyDeclarations(context, bodies), (groups) => + itemsOfGroups(withNamespaceScope(context, namespaceSpacesOf(bodies)), groups)) + +const bodyDeclarations = ( + context: TypeContext, + bodies: ReadonlyArray, +): Effect.Effect>, UnsupportedDeclaration> => + Effect.map( + Effect.forEach(statementsIn(bodies), (statement) => statementDeclarations(context, statement)), + (all) => grouped(all.flat().map((declaration) => [declaration.declared.name, declaration] as const)), + ) + +const declaredEntry = (declared: Declared, statementStart: number): Declaration => ({ declared, statementStart }) + +const statementDeclarations = ( + context: TypeContext, + statement: Directive | Statement, +): Effect.Effect, UnsupportedDeclaration> => + Match.value(statement).pipe( + Match.when({ type: 'ExportNamedDeclaration' }, (declaration) => + Option.match(Option.fromNullishOr(declaration.declaration), { + onNone: () => Effect.succeed>([]), + onSome: (inner) => statementDeclarations(context, inner), + })), + Match.when({ type: 'TSImportEqualsDeclaration' }, () => refuse(context.file, '`import =`')), + Match.orElse((node) => Effect.map(declaredOf(node, context.file), (declared) => declared.map((item) => declaredEntry(item, node.start)))), + ) + +const itemsOfDeclarations = (context: TypeContext, declarations: ReadonlyArray, name: string): Effect.Effect, UnsupportedDeclaration> => + Effect.map( + Effect.all([ + functionItemOf(context, functionsIn(declarations), name), + interfaceItemOf(context, interfacesIn(declarations), name), + classItemOf(context, classesIn(declarations), name), + typeAliasItemOf(context, typeAliasesIn(declarations), name), + variableItemOf(context, variablesIn(declarations), name), + enumItemOf(context, enumsIn(declarations), name), + namespaceItemOf(context, namespacesIn(declarations), name), + ]), + Arr.getSomes, + ) + +const itemsForFile = ( + graph: DeclarationGraph, + contextFor: (file: string) => TypeContext, + file: string, + visited: HashSet.HashSet, +): Effect.Effect, UnsupportedDeclaration> => + HashSet.has(visited, file) + ? Effect.succeed>([]) + : Effect.map( + Effect.forEach(exportedNames(graph, file), (name) => itemsForExport(graph, contextFor, file, name, HashSet.add(visited, file))), + (items) => items.flat(), + ) + +const itemsForExport = ( + graph: DeclarationGraph, + contextFor: (file: string) => TypeContext, + entryFile: string, + name: string, + visited: HashSet.HashSet, +): Effect.Effect, UnsupportedDeclaration> => + Option.match(exportedSymbol(graph, entryFile, name), { + onNone: () => refuse(Option.getOrElse(exportOrigin(graph, entryFile, name), () => entryFile), `\`export { ${name} }\`, which names no declaration`), + onSome: (symbol) => + 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.exhaustive, + ), + }) + +type PendingAlias = { readonly local: LocalDeclaration; readonly node: TSTypeAliasDeclaration } + +const pendingAliasOf = (done: HashMap.HashMap) => (local: LocalDeclaration): Option.Option => + HashMap.has(done, local.key) ? Option.none() : Option.map(Arr.head(typeAliasesIn(local.declarations)), (node) => ({ local, node })) + +const buildOneLocal = ( + contextFor: (file: string) => TypeContext, + built: Ref.Ref>, + { local, node }: PendingAlias, +): Effect.Effect => + Effect.flatMap(typeAliasItem(contextFor(local.file), node, local.key), (item) => Ref.update(built, HashMap.set(local.key, item))) + +const buildReachableLocals = ( + contextFor: (file: string) => TypeContext, + reachable: Ref.Ref>, + built: Ref.Ref>, +): Effect.Effect => { + const drain = (): Effect.Effect => + Effect.gen(function* () { + const known = yield* Ref.get(reachable) + const done = yield* Ref.get(built) + return yield* Arr.match(Arr.getSomes(Arr.fromIterable(HashMap.values(known)).map(pendingAliasOf(done))), { + onEmpty: () => Effect.void, + onNonEmpty: (pending) => Effect.flatMap(Effect.forEach(pending, (alias) => buildOneLocal(contextFor, built, alias)), () => drain()), + }) + }) + return drain() +} + +const buildEntry = (graph: DeclarationGraph, entry: EntryDeclaration): Effect.Effect => + Effect.gen(function* () { + const file = entry.declarationFile + const exported = exportedNames(graph, file) + const index = reverseIndex(graph, file, exported) + 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 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 } + }) + +export type ModelOfGraphRequest = { + readonly graph: DeclarationGraph + readonly producer: Declarations['producer'] + readonly package: Declarations['package'] +} + +export const modelOfGraph = (request: ModelOfGraphRequest): Effect.Effect => + Effect.map(Effect.forEach(request.graph.entryPoints, (entry) => buildEntry(request.graph, entry)), (entryPoints) => + sortModel({ + schemaVersion: API_MODEL_SCHEMA_VERSION, + producer: { engine: ENGINE, declarations: request.producer }, + package: request.package, + entryPoints, + }), + ) + +export const buildModel = (declarations: Declarations): Effect.Effect => + Effect.flatMap(declarationGraph(declarations), (graph) => + modelOfGraph({ graph, producer: declarations.producer, package: declarations.package }), + ) diff --git a/packages/api-extractor/src/front-end/canonical-type-text.ts b/packages/api-extractor/src/front-end/canonical-type-text.ts new file mode 100644 index 0000000..5e32283 --- /dev/null +++ b/packages/api-extractor/src/front-end/canonical-type-text.ts @@ -0,0 +1,571 @@ +import { Array as Arr, Effect, HashMap, HashSet, Match, Option } from 'effect' +import type { + BindingPattern, + Expression, + ParamPattern, + PropertyKey, + TSCallSignatureDeclaration, + TSConditionalType, + TSConstructSignatureDeclaration, + TSConstructorType, + TSFunctionType, + TSImportType, + TSImportTypeQualifier, + TSIndexSignature, + TSIndexedAccessType, + TSInferType, + TSLiteral, + TSMappedType, + TSMethodSignature, + TSMethodSignatureKind, + TSNamedTupleMember, + TSOptionalType, + TSPropertySignature, + TSRestType, + TSSignature, + TSTemplateLiteralType, + TSTupleElement, + TSTupleType, + TSType, + TSTypeAnnotation, + TSTypeName, + TSTypeOperator, + TSTypeParameter, + TSTypeParameterDeclaration, + TSTypeParameterInstantiation, + TSTypePredicate, + TSTypeQuery, + TSTypeReference, + TemplateElement, +} from 'oxc-parser' +import { KeywordName } from '../model/type-expr.schema.ts' +import { + type Declaration, + type DeclarationGraph, + isRelativeSpecifier, + type NamespaceSpaces, + refuse, + resolveFile, + resolveName, +} from './declaration-graph.ts' +import { UnsupportedDeclaration } from './front-end-error.schema.ts' + +export type ReferenceTarget = { + readonly name: string + readonly module: Option.Option +} + +export type Naming = { + readonly localNameOf: (file: string, localName: string, declarations: ReadonlyArray) => Effect.Effect +} + +export type TypeContext = { + readonly graph: DeclarationGraph + readonly file: string + readonly naming: Naming + readonly namesInScope: HashSet.HashSet + readonly valuesInScope: HashSet.HashSet +} + +const withNamesInScope = (context: TypeContext, names: ReadonlyArray): TypeContext => ({ + ...context, + namesInScope: HashSet.union(context.namesInScope, HashSet.fromIterable(names)), +}) + +const withNamespaceScope = (context: TypeContext, spaces: NamespaceSpaces): TypeContext => ({ + ...withNamesInScope(context, spaces.types), + valuesInScope: HashSet.union(context.valuesInScope, HashSet.fromIterable(spaces.values)), +}) + +const inValueSpace = (context: TypeContext): TypeContext => ({ ...context, namesInScope: context.valuesInScope }) + +const KEYWORD_NODES = { + any: 'TSAnyKeyword', + bigint: 'TSBigIntKeyword', + boolean: 'TSBooleanKeyword', + never: 'TSNeverKeyword', + null: 'TSNullKeyword', + number: 'TSNumberKeyword', + object: 'TSObjectKeyword', + string: 'TSStringKeyword', + symbol: 'TSSymbolKeyword', + undefined: 'TSUndefinedKeyword', + unknown: 'TSUnknownKeyword', + void: 'TSVoidKeyword', +} as const + +type KeywordNodeName = (typeof KEYWORD_NODES)[KeywordName] + +const KEYWORD_ENTRIES: ReadonlyArray = KeywordName.literals.map((name) => [KEYWORD_NODES[name], name] as const) + +const KEYWORDS: HashMap.HashMap = HashMap.fromIterable(KEYWORD_ENTRIES) + +export const KEYWORD_NODE_NAMES: ReadonlyArray = KEYWORD_ENTRIES.map(([node]) => node) + +export const keywordNameOf = (nodeType: string): Option.Option => HashMap.get(KEYWORDS, nodeType) + +const keywordName = (nodeType: string): KeywordName => Option.getOrThrow(keywordNameOf(nodeType)) + +export const literalTextOf = (node: Expression | TSLiteral): Option.Option => + Match.value(node).pipe( + Match.when({ type: 'Literal', value: Match.string }, (literal) => Option.some(JSON.stringify(literal.value))), + Match.when({ type: 'Literal', value: Match.number }, (literal) => Option.some(String(literal.value))), + Match.when({ type: 'Literal', value: Match.bigint }, (literal) => Option.some(`${literal.value}n`)), + Match.when({ type: 'Literal', value: Match.boolean }, (literal) => Option.some(literal.raw ?? String(literal.value))), + Match.when({ type: 'TemplateLiteral', expressions: [] }, (literal) => Option.map(Arr.head(literal.quasis), (quasi) => `\`${quasi.value.raw}\``)), + Match.when({ type: 'UnaryExpression', operator: '-' }, (unary) => Option.map(literalTextOf(unary.argument), (text) => `-${text}`)), + Match.orElse(() => Option.none()), + ) + +export const expressionPath = (expression: Expression): Option.Option> => + Match.value(expression).pipe( + Match.when({ type: 'Identifier' }, (identifier) => Option.some>([identifier.name])), + Match.when({ type: 'ThisExpression' }, () => Option.some>(['this'])), + Match.when({ type: 'MemberExpression', computed: false, property: { type: 'Identifier' } }, (member) => + Option.map(expressionPath(member.object), (base) => [...base, member.property.name])), + Match.orElse(() => Option.none()), + ) + +export const propertyKeyName = (key: PropertyKey): Option.Option => + Match.value(key).pipe( + Match.when({ type: 'Identifier' }, (identifier) => Option.some(identifier.name)), + Match.when({ type: 'Literal', value: Match.string }, (literal) => Option.some(JSON.stringify(literal.value))), + Match.when({ type: 'Literal', value: Match.number }, (literal) => Option.some(String(literal.value))), + Match.when({ type: 'PrivateIdentifier' }, (identifier) => Option.some(`#${identifier.name}`)), + Match.when({ type: 'MemberExpression', computed: false, property: { type: 'Identifier' } }, (member) => + Option.map(expressionPath(member.object), (base) => `[${[...base, member.property.name].join('.')}]`)), + Match.orElse(() => Option.none()), + ) + +export const nameSegments = (name: TSTypeName): ReadonlyArray => + Match.value(name).pipe( + Match.when({ type: 'Identifier' }, (identifier) => [identifier.name]), + Match.when({ type: 'TSQualifiedName' }, (qualified) => [...nameSegments(qualified.left), qualified.right.name]), + Match.when({ type: 'ThisExpression' }, () => ['this']), + Match.orElse(() => []), + ) + +export const importSegments = (qualifier: TSImportTypeQualifier | null): ReadonlyArray => + Option.match(Option.fromNullishOr(qualifier), { + onNone: () => [], + onSome: (name) => + Match.value(name).pipe( + Match.when({ type: 'Identifier' }, (identifier) => [identifier.name]), + Match.when({ type: 'TSQualifiedName' }, (qualified) => [...importSegments(qualified.left), qualified.right.name]), + Match.orElse(() => []), + ), + }) + +export const typeParameterNames = (declaration: TSTypeParameterDeclaration | null | undefined): ReadonlyArray => + Option.match(Option.fromNullishOr(declaration), { + onNone: () => [], + onSome: (parameters) => parameters.params.map((parameter) => parameter.name.name), + }) + +const plainTarget = (segments: ReadonlyArray): ReferenceTarget => ({ name: segments.join('.'), module: Option.none() }) + +const qualify = (name: string, rest: ReadonlyArray): string => + Arr.match(rest, { onEmpty: () => name, onNonEmpty: (parts) => `${name}.${parts.join('.')}` }) + +const inOtherFile = (context: TypeContext): TypeContext => ({ ...context, namesInScope: HashSet.empty(), valuesInScope: HashSet.empty() }) + +const targetOf = (context: TypeContext, file: string, segments: ReadonlyArray): Effect.Effect => + Arr.match(segments, { + onEmpty: () => Effect.succeed(plainTarget(segments)), + onNonEmpty: ([head, ...rest]) => + HashSet.has(context.namesInScope, head) + ? Effect.succeed(plainTarget(segments)) + : Option.match(resolveName(context.graph, file, head), { + onNone: () => Effect.succeed(plainTarget(segments)), + onSome: (symbol) => + Match.value(symbol).pipe( + Match.when({ kind: 'external' }, (external) => Effect.succeed({ name: segments.join('.'), module: Option.some(external.module) })), + Match.when({ kind: 'local' }, (local) => + Effect.map(context.naming.localNameOf(local.file, local.name, local.declarations), (name) => ({ + name: qualify(name, rest), + module: Option.none(), + }))), + Match.when({ kind: 'namespace' }, (namespace) => targetOf(inOtherFile(context), namespace.file, rest)), + Match.exhaustive, + ), + }), + }) + +const referenceTargetFor = ( + context: TypeContext, + segments: ReadonlyArray, + source: Option.Option, +): Effect.Effect => + Option.match(source, { + onNone: () => targetOf(context, context.file, segments), + onSome: (specifier) => + isRelativeSpecifier(specifier) + ? Option.match(resolveFile(context.graph, context.file, specifier), { + onNone: () => Effect.succeed(plainTarget(segments)), + onSome: (file) => targetOf(inOtherFile(context), file, segments), + }) + : Effect.succeed({ name: segments.join('.'), module: Option.some(specifier) }), + }) + +const printReference = (target: ReferenceTarget, args: string): string => + Option.match(target.module, { + onNone: () => `${target.name}${args}`, + onSome: (module) => `import(${JSON.stringify(module)}).${target.name}${args}`, + }) + +const readonlyPrefix = (readonly: boolean): string => (readonly ? 'readonly ' : '') +const optionalMark = (optional: boolean): string => (optional ? '?' : '') + +const printOptional = ( + context: TypeContext, + node: TSType | null | undefined, + decorate: (text: string) => string, +): Effect.Effect => + Option.match(Option.fromNullishOr(node), { + onNone: () => Effect.succeed(''), + onSome: (type) => Effect.map(canonicalTypeText(context, type), decorate), + }) + +const annotationTypeOf = (annotation: TSTypeAnnotation | null | undefined): TSType | undefined => annotation?.typeAnnotation + +const printAnnotationOrDefault = ( + context: TypeContext, + annotation: TSTypeAnnotation | null | undefined, + fallback: KeywordName, +): Effect.Effect => + Option.match(Option.fromNullishOr(annotation), { + onNone: () => Effect.succeed(fallback), + onSome: (present) => canonicalTypeText(context, present.typeAnnotation), + }) + +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}` + }) + +const printTypeParameters = (context: TypeContext, declaration: TSTypeParameterDeclaration | null): Effect.Effect => + Option.match(Option.fromNullishOr(declaration), { + onNone: () => Effect.succeed(''), + onSome: (parameters) => + Effect.map(Effect.forEach(parameters.params, (parameter) => printTypeParameter(context, parameter)), (parts) => `<${parts.join(', ')}>`), + }) + +const printTypeArguments = (context: TypeContext, instantiation: TSTypeParameterInstantiation | null): Effect.Effect => + Option.match(Option.fromNullishOr(instantiation), { + onNone: () => Effect.succeed(''), + onSome: (arguments_) => + Arr.match(arguments_.params, { + onEmpty: () => Effect.succeed(''), + onNonEmpty: (params) => + Effect.map(Effect.forEach(params, (param) => canonicalTypeText(context, param)), (parts) => `<${parts.join(', ')}>`), + }), + }) + +const bindingName = (pattern: BindingPattern): string => + Match.value(pattern).pipe( + Match.when({ type: 'Identifier' }, (identifier) => identifier.name), + Match.when({ type: 'AssignmentPattern', left: { type: 'Identifier' } }, (assignment) => assignment.left.name), + Match.orElse(() => '_'), + ) + +const printParameter = (context: TypeContext, parameter: ParamPattern): Effect.Effect => + Match.value(parameter).pipe( + Match.when({ type: 'TSParameterProperty' }, (property) => printParameter(context, property.parameter)), + Match.when({ type: 'RestElement' }, (rest) => + Effect.map(printAnnotationOrDefault(context, rest.typeAnnotation, 'any'), (type) => `...${bindingName(rest.argument)}: ${type}`)), + Match.when({ type: 'Identifier' }, (identifier) => + Effect.map(printAnnotationOrDefault(context, identifier.typeAnnotation, 'any'), (type) => + `${bindingName(identifier)}${optionalMark(identifier.optional ?? false)}: ${type}`)), + Match.orElse(() => refuse(context.file, 'a destructuring parameter')), + ) + +const printParameters = (context: TypeContext, parameters: ReadonlyArray): Effect.Effect => + Effect.map(Effect.forEach(parameters, (parameter) => printParameter(context, parameter)), (parts) => parts.join(', ')) + +const printMethodTail = ( + context: TypeContext, + kind: TSMethodSignatureKind, + returnType: TSTypeAnnotation | null, +): Effect.Effect => + Match.value(kind).pipe( + Match.when('set', () => Effect.succeed('')), + Match.orElse(() => Effect.map(printAnnotationOrDefault(context, returnType, 'any'), (type) => `: ${type}`)), + ) + +const printArrowTail = ( + context: TypeContext, + returnType: TSTypeAnnotation | null, +): Effect.Effect => + Effect.map(printAnnotationOrDefault(context, returnType, 'any'), (type) => ` => ${type}`) + +type ReturnTail = (context: TypeContext, returnType: TSTypeAnnotation | null) => Effect.Effect + +const methodTail = (kind: TSMethodSignatureKind): ReturnTail => (context, returnType) => printMethodTail(context, kind, returnType) + +const printSignatureLike = ( + context: TypeContext, + typeParameters: TSTypeParameterDeclaration | null, + params: ReadonlyArray, + returnType: TSTypeAnnotation | null, + tail: ReturnTail, + decorate: (parts: { readonly typeParameters: string; readonly parameters: string; readonly returnType: string }) => string, +): Effect.Effect => + Effect.gen(function* () { + const inner = withNamesInScope(context, typeParameterNames(typeParameters)) + const printedTypeParameters = yield* printTypeParameters(inner, typeParameters) + const printedParameters = yield* printParameters(inner, params) + const printedReturnType = yield* tail(inner, returnType) + return decorate({ typeParameters: printedTypeParameters, parameters: printedParameters, returnType: printedReturnType }) + }) + +const printPropertySignature = (context: TypeContext, member: TSPropertySignature): Effect.Effect => + Option.match(propertyKeyName(member.key), { + onNone: () => refuse(context.file, `a ${member.key.type} member name`), + onSome: (name) => + Effect.map(printAnnotationOrDefault(context, member.typeAnnotation, 'any'), (type) => + `${readonlyPrefix(member.readonly)}${name}${optionalMark(member.optional)}: ${type}`), + }) + +const printMethodSignature = (context: TypeContext, member: TSMethodSignature): Effect.Effect => + Option.match(propertyKeyName(member.key), { + onNone: () => refuse(context.file, `a ${member.key.type} method name`), + onSome: (name) => + printSignatureLike(context, member.typeParameters, member.params, member.returnType, methodTail(member.kind), (parts) => + `${methodPrefix(member.kind)}${name}${optionalMark(member.optional)}${parts.typeParameters}(${parts.parameters})${parts.returnType}`), + }) + +const methodPrefix = (kind: TSMethodSignatureKind): string => + Match.value(kind).pipe( + Match.when('get', () => 'get '), + Match.when('set', () => 'set '), + Match.orElse(() => ''), + ) + +const printCallSignature = (context: TypeContext, member: TSCallSignatureDeclaration): Effect.Effect => + printSignatureLike(context, member.typeParameters, member.params, member.returnType, methodTail('method'), (parts) => + `${parts.typeParameters}(${parts.parameters})${parts.returnType}`) + +const printConstructSignature = (context: TypeContext, member: TSConstructSignatureDeclaration): Effect.Effect => + printSignatureLike(context, member.typeParameters, member.params, member.returnType, methodTail('method'), (parts) => + `new ${parts.typeParameters}(${parts.parameters})${parts.returnType}`) + +const printIndexSignature = (context: TypeContext, member: TSIndexSignature): Effect.Effect => + Arr.match(member.parameters, { + onEmpty: () => refuse(context.file, 'an index signature without a parameter'), + onNonEmpty: ([parameter]) => + Effect.gen(function* () { + const key = yield* canonicalTypeText(context, parameter.typeAnnotation.typeAnnotation) + const value = yield* canonicalTypeText(context, member.typeAnnotation.typeAnnotation) + return `${readonlyPrefix(member.readonly)}[key: ${key}]: ${value}` + }), + }) + +const printMember = (context: TypeContext, member: TSSignature): Effect.Effect => + Match.value(member).pipe( + Match.when({ type: 'TSPropertySignature' }, (property) => printPropertySignature(context, property)), + Match.when({ type: 'TSMethodSignature' }, (method) => printMethodSignature(context, method)), + Match.when({ type: 'TSCallSignatureDeclaration' }, (call) => printCallSignature(context, call)), + Match.when({ type: 'TSIndexSignature' }, (index) => printIndexSignature(context, index)), + Match.when({ type: 'TSConstructSignatureDeclaration' }, (construct) => printConstructSignature(context, construct)), + Match.orElse(() => refuse(context.file, 'an unsupported member')), + ) + +const printMembers = (context: TypeContext, members: ReadonlyArray): Effect.Effect => + Arr.match(members, { + onEmpty: () => Effect.succeed('{}'), + onNonEmpty: (present) => + Effect.map(Effect.forEach(present, (member) => printMember(context, member)), (parts) => `{ ${parts.join('; ')} }`), + }) + +const printSpecialTupleElement = ( + context: TypeContext, + element: TSNamedTupleMember | TSOptionalType | TSRestType, +): Effect.Effect => + Match.value(element).pipe( + Match.when({ type: 'TSNamedTupleMember' }, (named) => + Effect.map(printTupleElement(context, named.elementType), (type) => `${named.label.name}${optionalMark(named.optional)}: ${type}`)), + Match.when({ type: 'TSOptionalType' }, (optional) => + Effect.map(canonicalTypeText(context, optional.typeAnnotation), (type) => `${type}?`)), + Match.orElse((rest) => Effect.map(canonicalTypeText(context, rest.typeAnnotation), (type) => `...${type}`)), + ) + +const printTupleElement = (context: TypeContext, element: TSTupleElement): Effect.Effect => + Match.value(element).pipe( + Match.when({ type: Match.is('TSNamedTupleMember', 'TSOptionalType', 'TSRestType') }, (special) => printSpecialTupleElement(context, special)), + Match.orElse((plain) => canonicalTypeText(context, plain)), + ) + +const printTuple = (context: TypeContext, node: TSTupleType): Effect.Effect => + Effect.map(Effect.forEach(node.elementTypes, (element) => printTupleElement(context, element)), (parts) => `[${parts.join(', ')}]`) + +const printFunctionType = (context: TypeContext, node: TSFunctionType): Effect.Effect => + printSignatureLike(context, node.typeParameters, node.params, node.returnType, printArrowTail, (parts) => + `${parts.typeParameters}(${parts.parameters})${parts.returnType}`) + +const printConstructorType = (context: TypeContext, node: TSConstructorType): Effect.Effect => + printSignatureLike(context, node.typeParameters, node.params, node.returnType, printArrowTail, (parts) => + `${node.abstract ? 'abstract ' : ''}new ${parts.typeParameters}(${parts.parameters})${parts.returnType}`) + +const printTypeReference = (context: TypeContext, node: TSTypeReference): Effect.Effect => + Effect.gen(function* () { + const target = yield* referenceTargetFor(context, nameSegments(node.typeName), Option.none()) + const args = yield* printTypeArguments(context, node.typeArguments) + return printReference(target, args) + }) + +const printImportType = (context: TypeContext, node: TSImportType): Effect.Effect => + Arr.match(importSegments(node.qualifier), { + onEmpty: () => refuse(context.file, 'a bare import type'), + onNonEmpty: (segments) => + Effect.gen(function* () { + const target = yield* referenceTargetFor(context, segments, Option.some(node.source.value)) + const args = yield* printTypeArguments(context, node.typeArguments) + return printReference(target, args) + }), + }) + +const printQuery = (context: TypeContext, node: TSTypeQuery): Effect.Effect => + Match.value(node.exprName).pipe( + Match.when({ type: 'TSImportType' }, (imported) => Effect.map(printImportType(context, imported), (text) => `typeof ${text}`)), + Match.orElse((name) => + Effect.map(referenceTargetFor(inValueSpace(context), nameSegments(name), Option.none()), (target) => `typeof ${printReference(target, '')}`)), + ) + +const mappedReadonlyPrefix = (readonly: TSMappedType['readonly']): string => + Match.value(readonly).pipe( + Match.when(true, () => 'readonly '), + Match.when('+', () => '+readonly '), + Match.when('-', () => '-readonly '), + Match.orElse(() => ''), + ) + +const mappedOptionalSuffix = (optional: TSMappedType['optional']): string => + Match.value(optional).pipe( + Match.when(true, () => '?'), + Match.when('+', () => '+?'), + Match.when('-', () => '-?'), + Match.orElse(() => ''), + ) + +const printMappedType = (context: TypeContext, node: TSMappedType): Effect.Effect => + Effect.gen(function* () { + const inner = withNamesInScope(context, [node.key.name]) + const constraint = yield* canonicalTypeText(inner, node.constraint) + const nameType = yield* printOptional(inner, node.nameType, (text) => ` as ${text}`) + const value = yield* printOptional(inner, node.typeAnnotation, (text) => `: ${text}`) + return `{ ${mappedReadonlyPrefix(node.readonly)}[${node.key.name} in ${constraint}${nameType}]${mappedOptionalSuffix(node.optional)}${value} }` + }) + +const inferNamesFor = (context: TypeContext, node: TSConditionalType): ReadonlyArray => + Option.match(HashMap.get(context.graph.files, context.file), { + onNone: () => [], + onSome: (scope) => Option.getOrElse(HashMap.get(scope.conditionalInferences, node.start), (): ReadonlyArray => []), + }) + +const printConditional = (context: TypeContext, node: TSConditionalType): Effect.Effect => + Effect.gen(function* () { + const check = yield* canonicalTypeText(context, node.checkType) + const extending = yield* canonicalTypeText(context, node.extendsType) + const consequent = yield* canonicalTypeText(withNamesInScope(context, inferNamesFor(context, node)), node.trueType) + const alternate = yield* canonicalTypeText(context, node.falseType) + return `${check} extends ${extending} ? ${consequent} : ${alternate}` + }) + +const printInfer = (context: TypeContext, node: TSInferType): Effect.Effect => + Effect.gen(function* () { + const constraint = yield* printOptional(context, node.typeParameter.constraint, (text) => ` extends ${text}`) + return `infer ${node.typeParameter.name.name}${constraint}` + }) + +const printPredicate = (context: TypeContext, node: TSTypePredicate): Effect.Effect => + Effect.gen(function* () { + const tail = yield* printOptional(context, annotationTypeOf(node.typeAnnotation), (text) => ` is ${text}`) + const name = Match.value(node.parameterName).pipe( + Match.when({ type: 'Identifier' }, (identifier) => identifier.name), + Match.orElse(() => 'this'), + ) + return `${node.asserts ? 'asserts ' : ''}${name}${tail}` + }) + +const templateRaw = (quasis: ReadonlyArray, index: number): string => + Option.match(Arr.get(quasis, index), { onNone: () => '', onSome: (quasi) => quasi.value.raw }) + +const printTemplate = (context: TypeContext, node: TSTemplateLiteralType): Effect.Effect => + Effect.gen(function* () { + const parts = yield* Effect.forEach( + Arr.map(node.types, (type, index) => ({ quasi: templateRaw(node.quasis, index), type })), + (part) => Effect.map(canonicalTypeText(context, part.type), (text) => `${part.quasi}\${${text}}`), + ) + return `\`${parts.join('')}${templateRaw(node.quasis, node.types.length)}\`` + }) + +const printOperator = (context: TypeContext, node: TSTypeOperator): Effect.Effect => + Effect.map(canonicalTypeText(context, node.typeAnnotation), (text) => `${node.operator} ${text}`) + +const printIndexedAccess = (context: TypeContext, node: TSIndexedAccessType): Effect.Effect => + Effect.gen(function* () { + const object = yield* canonicalTypeText(context, node.objectType) + const index = yield* canonicalTypeText(context, node.indexType) + return `${object}[${index}]` + }) + +const canonicalTypeText = (context: TypeContext, node: TSType): Effect.Effect => + Match.value(node).pipe( + Match.when({ type: Match.is(...KEYWORD_NODE_NAMES) }, (keyword) => Effect.succeed(keywordName(keyword.type))), + Match.when({ type: 'TSThisType' }, () => Effect.succeed('this')), + Match.when({ type: 'TSIntrinsicKeyword' }, () => Effect.succeed('intrinsic')), + Match.when({ type: 'TSLiteralType' }, (literal) => + Option.match(literalTextOf(literal.literal), { + onNone: () => refuse(context.file, 'TSLiteralType'), + onSome: (text) => Effect.succeed(text), + })), + Match.when({ type: 'TSTypeReference' }, (reference) => printTypeReference(context, reference)), + Match.when({ type: 'TSImportType' }, (imported) => printImportType(context, imported)), + Match.when({ type: 'TSUnionType' }, (union) => + Effect.map(Effect.forEach(union.types, (member) => canonicalTypeText(context, member)), (parts) => parts.join(' | '))), + Match.when({ type: 'TSIntersectionType' }, (intersection) => + Effect.map(Effect.forEach(intersection.types, (member) => canonicalTypeText(context, member)), (parts) => parts.join(' & '))), + Match.when({ type: 'TSParenthesizedType' }, (parenthesised) => + Effect.map(canonicalTypeText(context, parenthesised.typeAnnotation), (text) => `(${text})`)), + Match.when({ type: 'TSTypeOperator' }, (operator) => printOperator(context, operator)), + Match.when({ type: 'TSArrayType' }, (array) => Effect.map(canonicalTypeText(context, array.elementType), (text) => `${text}[]`)), + Match.when({ type: 'TSIndexedAccessType' }, (indexed) => printIndexedAccess(context, indexed)), + Match.when({ type: 'TSTupleType' }, (tuple) => printTuple(context, tuple)), + Match.when({ type: 'TSFunctionType' }, (function_) => printFunctionType(context, function_)), + Match.when({ type: 'TSConstructorType' }, (constructor) => printConstructorType(context, constructor)), + Match.orElse((other) => printStructuredType(context, other)), + ) + +const printStructuredType = (context: TypeContext, node: TSType): Effect.Effect => + Match.value(node).pipe( + Match.when({ type: 'TSTypeLiteral' }, (literal) => printMembers(context, literal.members)), + Match.when({ type: 'TSMappedType' }, (mapped) => printMappedType(context, mapped)), + Match.when({ type: 'TSConditionalType' }, (conditional) => printConditional(context, conditional)), + Match.when({ type: 'TSInferType' }, (infer) => printInfer(context, infer)), + Match.when({ type: 'TSTemplateLiteralType' }, (template) => printTemplate(context, template)), + Match.when({ type: 'TSTypePredicate' }, (predicate) => printPredicate(context, predicate)), + Match.when({ type: 'TSTypeQuery' }, (query) => printQuery(context, query)), + Match.orElse(() => refuse(context.file, `a ${node.type} type`)), + ) + +export const TypePrinting: { + readonly canonicalTypeText: (context: TypeContext, node: TSType) => Effect.Effect + readonly referenceTargetFor: ( + context: TypeContext, + segments: ReadonlyArray, + source: Option.Option, + ) => Effect.Effect + readonly withNamesInScope: (context: TypeContext, names: ReadonlyArray) => TypeContext + readonly withNamespaceScope: (context: TypeContext, spaces: NamespaceSpaces) => TypeContext + readonly inValueSpace: (context: TypeContext) => TypeContext + readonly bindingName: (pattern: BindingPattern) => string +} = { + canonicalTypeText: canonicalTypeText, + referenceTargetFor: referenceTargetFor, + withNamesInScope: withNamesInScope, + withNamespaceScope: withNamespaceScope, + inValueSpace: inValueSpace, + bindingName: bindingName, +} diff --git a/packages/api-extractor/src/front-end/declaration-emitter.service.ts b/packages/api-extractor/src/front-end/declaration-emitter.service.ts new file mode 100644 index 0000000..520a515 --- /dev/null +++ b/packages/api-extractor/src/front-end/declaration-emitter.service.ts @@ -0,0 +1,24 @@ +import { Context, type Effect } from 'effect' +import type { PlatformError } from 'effect/PlatformError' +import type { Declarations, PackageSource } from './declarations.schema.ts' +import type { CompilerRejected, NotIsolated, UnsupportedDeclaration } from './front-end-error.schema.ts' + +export type DeclarationEmitter = { + readonly emit: (source: PackageSource) => Effect.Effect +} + +export interface IsolatedEmitterId { + readonly _: unique symbol +} + +export interface CompilerEmitterId { + readonly _: unique symbol +} + +export const IsolatedEmitter: Context.Service> = Context.Service>( + '@systemfsoftware/api-extractor/IsolatedEmitter', +) + +export const CompilerEmitter: Context.Service> = Context.Service>( + '@systemfsoftware/api-extractor/CompilerEmitter', +) diff --git a/packages/api-extractor/src/front-end/declaration-graph.ts b/packages/api-extractor/src/front-end/declaration-graph.ts new file mode 100644 index 0000000..17e64ce --- /dev/null +++ b/packages/api-extractor/src/front-end/declaration-graph.ts @@ -0,0 +1,679 @@ +import { Array as Arr, Effect, Function as Fn, HashMap, HashSet, Match, Option, Order, type Path } from 'effect' +import { + type Class, + type Comment, + type Declaration as DeclarationNode, + type Directive, + type ExportDefaultDeclaration, + type ExportNamedDeclaration, + type Function as FunctionNode, + type ImportDeclaration, + type ImportDeclarationSpecifier, + type ModuleExportName, + parseSync, + type Program, + type Statement, + type TSEnumDeclaration, + type TSInterfaceDeclaration, + type TSModuleBlock, + type TSModuleDeclaration, + type TSTypeAliasDeclaration, + type VariableDeclaration, + type VariableDeclarator, + Visitor, +} from 'oxc-parser' +import type { DeclarationFile, Declarations, SourceEntryPoint } from './declarations.schema.ts' +import { UnsupportedDeclaration } from './front-end-error.schema.ts' + +const RELATIVE_SPECIFIER = /^\.\.?(\/|$)/ +const SCRIPT_EXTENSION = /\.(?:d\.)?([mc]?)[tj]sx?$/ +const DECLARATION_EXTENSION = /\.d\.([mc]?)ts$/ + +export const isRelativeSpecifier = (specifier: string): boolean => RELATIVE_SPECIFIER.test(specifier) + +const isParentSegment = (path: Path.Path, relative: string): boolean => relative === '..' || relative.startsWith(`..${path.sep}`) + +const leavesRoot = (path: Path.Path, relative: string): boolean => isParentSegment(path, relative) || path.isAbsolute(relative) + +const isInsideRoot = (path: Path.Path) => (relative: string): boolean => relative !== '' && !leavesRoot(path, relative) + +export const relativeWithin: { + (root: string, target: string): (path: Path.Path) => Option.Option + (path: Path.Path, root: string, target: string): Option.Option +} = Fn.dual(3, (path: Path.Path, root: string, target: string): Option.Option => + Option.liftPredicate(path.relative(root, target), isInsideRoot(path))) + +export const directoryOf = (path: string): string => path.slice(0, Math.max(path.lastIndexOf('/'), 0)) + +const parentOf = (kept: ReadonlyArray): Option.Option> => + Option.map(Option.liftPredicate(kept, Arr.isReadonlyArrayNonEmpty), (nonEmpty) => nonEmpty.slice(0, -1)) + +const stepInto = (kept: ReadonlyArray, part: string): Option.Option> => + Match.value(part).pipe( + Match.when('..', () => parentOf(kept)), + Match.whenOr('.', '', () => Option.some(kept)), + Match.orElse(() => Option.some([...kept, part])), + ) + +const normalisedPath = (path: string): Option.Option => + Option.map( + Arr.reduce(path.split('/'), Option.some>([]), (parts, part) => Option.flatMap(parts, (kept) => stepInto(kept, part))), + Arr.join('/'), + ) + +const declarationExtensionOf = (flavour: string): string => (flavour === '' ? '.d.ts' : `.d.${flavour}ts`) + +const candidatesForBase = (base: string): ReadonlyArray => + Option.match(Option.fromNullishOr(SCRIPT_EXTENSION.exec(base)), { + onNone: () => [`${base}.d.ts`, `${base}/index.d.ts`], + onSome: (extension) => [base.slice(0, extension.index) + declarationExtensionOf(extension[1] ?? '')], + }) + +const joinedSpecifier = (fromDeclaration: string, specifier: string): string => + [directoryOf(fromDeclaration), specifier].filter((part) => part !== '').join('/') + +const declarationCandidates: { + (specifier: string): (fromDeclaration: string) => ReadonlyArray + (fromDeclaration: string, specifier: string): ReadonlyArray +} = Fn.dual(2, (fromDeclaration: string, specifier: string): ReadonlyArray => + Option.match(normalisedPath(joinedSpecifier(fromDeclaration, specifier)), { onNone: () => [], onSome: candidatesForBase })) + +export const declarationPathOf = (sourceFile: string): string => + DECLARATION_EXTENSION.test(sourceFile) ? sourceFile : sourceFile.replace(/\.([mc]?)tsx?$/, (_, flavour: string) => declarationExtensionOf(flavour)) + +export type EntryDeclaration = { + readonly exportPath: string + readonly declarationFile: string +} + +export const entryPointDeclarations = (entryPoints: ReadonlyArray): ReadonlyArray => + entryPoints.map((entry) => ({ exportPath: entry.exportPath, declarationFile: declarationPathOf(entry.sourceFile) })) + +export type SharedDeclaration = { + readonly declarationFile: string + readonly exportPaths: ReadonlyArray +} + +const exportPathsSharing = (entryPoints: ReadonlyArray, declarationFile: string): ReadonlyArray => + Arr.sort(entryPoints.filter((entry) => entry.declarationFile === declarationFile).map((entry) => entry.exportPath), Order.String) + +export const sharedDeclarations = (entryPoints: ReadonlyArray): ReadonlyArray => + Arr.sort(Arr.dedupe(entryPoints.map((entry) => entry.declarationFile)), Order.String).flatMap((declarationFile) => + Option.match(Option.liftPredicate(exportPathsSharing(entryPoints, declarationFile), (exportPaths) => exportPaths.length > 1), { + onNone: () => [], + onSome: (exportPaths) => [{ declarationFile, exportPaths }], + })) + +export const missingEntryDeclarations: { + (emitted: HashSet.HashSet): (entryDeclarations: ReadonlyArray) => ReadonlyArray + (emitted: HashSet.HashSet, entryDeclarations: ReadonlyArray): ReadonlyArray +} = Fn.dual(2, (emitted: HashSet.HashSet, entryDeclarations: ReadonlyArray): ReadonlyArray => + entryDeclarations.filter((declarationFile) => !HashSet.has(emitted, declarationFile))) + +const sourcesForFlavour = (stem: string, flavour: string): ReadonlyArray => + flavour === '' ? [`${stem}.ts`, `${stem}.tsx`] : [`${stem}.${flavour}ts`] + +export const sourceCandidatesOf = (declarationFile: string): ReadonlyArray => + Option.match(Option.fromNullishOr(DECLARATION_EXTENSION.exec(declarationFile)), { + onNone: () => [], + onSome: (flavour) => sourcesForFlavour(declarationFile.slice(0, flavour.index), flavour[1] ?? ''), + }) + +type Parsed = { readonly program: Program; readonly comments: ReadonlyArray } + +const parsed = (path: string, text: string): Effect.Effect => { + const result = parseSync(path, text, { lang: 'dts', sourceType: 'module' }) + return Option.match(Arr.head(result.errors), { + onNone: () => Effect.succeed({ program: result.program, comments: result.comments }), + onSome: (error) => refuse(path, `unparseable declaration text (${error.message})`), + }) +} + +const moduleNameOf = (name: ModuleExportName): string => (name.type === 'Literal' ? name.value : name.name) + +const sourceOfExport = (node: ExportNamedDeclaration): ReadonlyArray => (node.source === null ? [] : [node.source.value]) + +const specifiersOf = (program: Program): ReadonlyArray => { + const found: Array = [] + new Visitor({ + ImportDeclaration: (node) => void found.push(node.source.value), + ExportNamedDeclaration: (node) => void found.push(...sourceOfExport(node)), + ExportAllDeclaration: (node) => void found.push(node.source.value), + TSImportType: (node) => void found.push(node.source.value), + }).visit(program) + return found +} + +type InferenceFrame = { + readonly at: number + readonly from: number + readonly to: number + readonly names: Array +} + +const spansInfer = (frame: InferenceFrame, from: number, to: number): boolean => from >= frame.from && to <= frame.to + +const recordInfer = (frames: ReadonlyArray, from: number, to: number, name: string): void => + Arr.forEach(Arr.filter(Option.toArray(Arr.last(frames)), (frame) => spansInfer(frame, from, to)), (frame) => frame.names.push(name)) + +const closeInference = (frames: Array, collected: Array]>): void => { + Arr.forEach(Option.toArray(Arr.last(frames)), (frame) => void collected.push([frame.at, frame.names])) + void frames.pop() +} + +const conditionalInferencesOf = (program: Program): HashMap.HashMap> => { + const frames: Array = [] + const collected: Array]> = [] + new Visitor({ + TSConditionalType: (node) => void frames.push({ at: node.start, from: node.extendsType.start, to: node.extendsType.end, names: [] }), + 'TSConditionalType:exit': () => closeInference(frames, collected), + TSInferType: (node) => recordInfer(frames, node.start, node.end, node.typeParameter.name.name), + }).visit(program) + return HashMap.fromIterable(collected) +} + +type Loaded = HashMap.HashMap + +type Loader = (declarationFile: string) => Effect.Effect, E, R> + +const loadCandidate = (candidate: string, rest: ReadonlyArray, loaded: Loaded, load: Loader) => + Option.match(HashMap.get(loaded, candidate), { + onNone: () => + Effect.flatMap(load(candidate), (text) => + Option.match(text, { + onNone: () => firstLoaded(rest, loaded, load), + onSome: (value) => Effect.succeedSome([candidate, value] as const), + })), + onSome: (known) => Effect.succeedSome([candidate, known] as const), + }) + +const firstLoaded = ( + candidates: ReadonlyArray, + loaded: Loaded, + load: Loader, +): Effect.Effect, E, R> => + Arr.match(candidates, { + onEmpty: () => Effect.succeedNone, + onNonEmpty: ([candidate, ...rest]) => loadCandidate(candidate, rest, loaded, load), + }) + +export type UnresolvedImport = { + readonly file: string + readonly specifier: string +} + +export type ClosureResult = { + readonly files: ReadonlyArray + readonly unresolvedImports: ReadonlyArray +} + +const unresolvedImport = (file: string, specifier: string): UnresolvedImport => ({ file, specifier }) + +type ClosureTask = { + readonly candidates: ReadonlyArray + readonly unresolved: Option.Option +} + +const entryTask = (declarationFile: string): ClosureTask => ({ candidates: [declarationFile], unresolved: Option.none() }) + +const importTasks = (file: string, program: Program): ReadonlyArray => + specifiersOf(program) + .filter(isRelativeSpecifier) + .map((specifier) => ({ candidates: declarationCandidates(file, specifier), unresolved: Option.some(unresolvedImport(file, specifier)) })) + +type WorkQueue = { + readonly push: (task: ClosureTask) => void + readonly get: (index: number) => Option.Option +} + +const workQueue = (initial: ReadonlyArray): WorkQueue => { + const pending: Array = [...initial] + return { + push: (task) => void pending.push(task), + get: (index) => Arr.get(pending, index), + } +} + +type ClosureState = { + readonly loaded: Loaded + readonly unresolved: ReadonlyArray +} + +const visitFound = (file: string, text: string, queue: WorkQueue, at: number, state: ClosureState, load: Loader) => + HashMap.has(state.loaded, file) + ? visit(queue, at, state, load) + : Effect.flatMap(parsed(file, text), ({ program }) => { + Arr.forEach(importTasks(file, program), (task) => queue.push(task)) + return visit(queue, at, { loaded: HashMap.set(state.loaded, file, text), unresolved: state.unresolved }, load) + }) + +const visit = ( + queue: WorkQueue, + at: number, + state: ClosureState, + load: Loader, +): Effect.Effect => + Option.match(queue.get(at), { + onNone: () => Effect.succeed(state), + onSome: (task) => + Effect.flatMap(firstLoaded(task.candidates, state.loaded, load), (found) => + Option.match(found, { + onNone: () => + Option.match(task.unresolved, { + onNone: () => visit(queue, at + 1, state, load), + onSome: (unresolved) => visit(queue, at + 1, { loaded: state.loaded, unresolved: [...state.unresolved, unresolved] }, load), + }), + onSome: ([file, text]) => visitFound(file, text, queue, at + 1, state, load), + })), + }) + +const byPath: Order.Order = Order.mapInput(Order.String, (file: DeclarationFile) => file.path) + +export type ClosureRequest = { + readonly entryDeclarations: ReadonlyArray + readonly load: Loader +} + +export const closeDeclarations = (request: ClosureRequest): Effect.Effect => + Effect.map( + Effect.suspend(() => visit(workQueue(request.entryDeclarations.map(entryTask)), 0, { loaded: HashMap.empty(), unresolved: [] }, request.load)), + (state): ClosureResult => ({ + files: Arr.sort(Arr.map(HashMap.toEntries(state.loaded), ([path, text]) => ({ path, text })), byPath), + unresolvedImports: state.unresolved, + }), + ) + +export type Declared = + | { readonly kind: 'variable'; readonly constant: boolean; readonly node: VariableDeclarator; readonly name: string } + | { readonly kind: 'function'; readonly node: FunctionNode; readonly name: string } + | { readonly kind: 'class'; readonly node: Class; readonly name: string } + | { readonly kind: 'interface'; readonly node: TSInterfaceDeclaration; readonly name: string } + | { readonly kind: 'typeAlias'; readonly node: TSTypeAliasDeclaration; readonly name: string } + | { readonly kind: 'enum'; readonly node: TSEnumDeclaration; readonly name: string } + | { readonly kind: 'namespace'; readonly node: TSModuleDeclaration; readonly name: string } + +export type Declaration = { + readonly declared: Declared + readonly statementStart: number +} + +export type NamespaceSpaces = { + readonly types: ReadonlyArray + readonly values: ReadonlyArray +} + +type NamedDeclaration = { + readonly name: string + readonly kind: Declared['kind'] +} + +const namedDeclaration = (name: string, kind: Declared['kind']): NamedDeclaration => ({ name, kind }) + +const DEFAULT_EXPORT_NAME = 'default' + +const declarationName = (id: { readonly name: string } | null): string => (id === null ? DEFAULT_EXPORT_NAME : id.name) + +type ClassifiedDeclaration = + | { readonly kind: 'declared'; readonly declared: ReadonlyArray } + | { readonly kind: 'refused'; readonly construct: string } + +const declaredItems = (declared: ReadonlyArray): ClassifiedDeclaration => ({ kind: 'declared', declared }) + +const refusedDeclaration = (construct: string): ClassifiedDeclaration => ({ kind: 'refused', construct }) + +const declaredVariableOf = (node: VariableDeclaration) => (declarator: VariableDeclarator): Option.Option => + declarator.id.type === 'Identifier' + ? Option.some({ kind: 'variable', constant: node.kind === 'const', node: declarator, name: declarator.id.name }) + : Option.none() + +const classifiedVariable = (node: VariableDeclaration): ClassifiedDeclaration => { + const declared = Arr.getSomes(node.declarations.map((declarator) => declaredVariableOf(node)(declarator))) + return declared.length === node.declarations.length ? declaredItems(declared) : refusedDeclaration('a destructuring variable declaration') +} + +const classifiedNamespace = (module: TSModuleDeclaration): ClassifiedDeclaration => + Match.value(module.id).pipe( + Match.when({ type: 'Identifier' }, (id) => declaredItems([{ kind: 'namespace', node: module, name: id.name }])), + Match.when({ type: 'Literal' }, (id) => refusedDeclaration(`\`declare module "${id.value}"\``)), + Match.orElse(() => refusedDeclaration('a dotted namespace declaration')), + ) + +const classifyDeclaration = (node: DeclarableNode): ClassifiedDeclaration => + Match.value(node).pipe( + Match.when({ type: 'VariableDeclaration' }, (variable: VariableDeclaration) => classifiedVariable(variable)), + Match.when({ type: Match.is('FunctionDeclaration', 'TSDeclareFunction') }, (function_: FunctionNode) => + declaredItems([{ kind: 'function', node: function_, name: declarationName(function_.id) }])), + Match.when({ type: 'ClassDeclaration' }, (class_: Class) => declaredItems([{ kind: 'class', node: class_, name: declarationName(class_.id) }])), + Match.when({ type: 'TSInterfaceDeclaration' }, (iface: TSInterfaceDeclaration) => declaredItems([{ kind: 'interface', node: iface, name: iface.id.name }])), + Match.when({ type: 'TSTypeAliasDeclaration' }, (alias: TSTypeAliasDeclaration) => declaredItems([{ kind: 'typeAlias', node: alias, name: alias.id.name }])), + Match.when({ type: 'TSEnumDeclaration' }, (enumeration: TSEnumDeclaration) => declaredItems([{ kind: 'enum', node: enumeration, name: enumeration.id.name }])), + Match.when({ type: 'TSModuleDeclaration', global: true }, () => refusedDeclaration('`declare global`')), + Match.when({ type: 'TSModuleDeclaration', global: false }, (module: TSModuleDeclaration) => classifiedNamespace(module)), + Match.orElse(() => declaredItems([])), + ) + +const namedDeclarationsOf = (classified: ClassifiedDeclaration): ReadonlyArray => + classified.kind === 'declared' ? classified.declared.map((declared) => namedDeclaration(declared.name, declared.kind)) : [] + +const declarationsInStatement = (statement: Directive | Statement): ReadonlyArray => + Match.value(statement).pipe( + Match.when({ type: 'ExportNamedDeclaration' }, (exported) => + Option.match(Option.fromNullishOr(exported.declaration), { onNone: () => [], onSome: declarationsInStatement })), + Match.orElse((node) => namedDeclarationsOf(classifyDeclaration(node))), + ) + +export const statementsIn = (bodies: ReadonlyArray): ReadonlyArray => + Arr.flatMap(bodies, (body) => Option.match(Option.fromNullishOr(body), { onNone: () => [], onSome: (block) => block.body })) + +const declarationsIn = (bodies: ReadonlyArray): ReadonlyArray => + Arr.flatMap(statementsIn(bodies), declarationsInStatement) + +const declaresType = (kind: Declared['kind']): boolean => + Match.value(kind).pipe( + Match.whenOr('interface', 'typeAlias', 'class', 'enum', 'namespace', () => true), + Match.orElse(() => false), + ) + +const declaresValue = (kind: Declared['kind']): boolean => + Match.value(kind).pipe( + Match.whenOr('variable', 'function', 'class', 'enum', 'namespace', () => true), + Match.orElse(() => false), + ) + +const namesDeclaredBy = (declarations: ReadonlyArray, inSpace: (kind: Declared['kind']) => boolean): ReadonlyArray => + Arr.dedupe(Arr.map(Arr.filter(declarations, (declaration) => inSpace(declaration.kind)), (declaration) => declaration.name)) + +export const namespaceSpacesOf = (bodies: ReadonlyArray): NamespaceSpaces => { + const declarations = declarationsIn(bodies) + return { types: namesDeclaredBy(declarations, declaresType), values: namesDeclaredBy(declarations, declaresValue) } +} + +export type ImportBinding = { + readonly specifier: string + readonly imported: string +} + +export type ExportBinding = + | { readonly kind: 'local'; readonly local: string } + | { readonly kind: 'reexport'; readonly specifier: string; readonly imported: string } + +export type FileScope = { + readonly path: string + readonly text: string + readonly program: Program + readonly comments: ReadonlyArray + readonly conditionalInferences: HashMap.HashMap> + readonly declarations: HashMap.HashMap> + readonly imports: HashMap.HashMap + readonly exports: HashMap.HashMap + readonly starExports: ReadonlyArray +} + +export type ResolvedSymbol = + | { readonly kind: 'local'; readonly file: string; readonly name: string; readonly declarations: ReadonlyArray } + | { readonly kind: 'namespace'; readonly file: string } + | { readonly kind: 'external'; readonly file: string; readonly module: string; readonly imported: string } + +export type DeclarationGraph = { + readonly files: HashMap.HashMap + readonly entryPoints: ReadonlyArray +} + +type ScopeParts = { + readonly declarations: ReadonlyArray + readonly imports: ReadonlyArray + readonly exports: ReadonlyArray + readonly starExports: ReadonlyArray +} + +const EMPTY_PARTS: ScopeParts = { declarations: [], imports: [], exports: [], starExports: [] } + +type DeclaredResult = Effect.Effect, UnsupportedDeclaration> + +export const refuse: { + (construct: string): (file: string) => Effect.Effect + (file: string, construct: string): Effect.Effect +} = Fn.dual(2, (file: string, construct: string): Effect.Effect => + Effect.fail(new UnsupportedDeclaration({ file, construct }))) + +type DeclarableNode = DeclarationNode | Statement | ExportDefaultDeclaration['declaration'] + +export const declaredOf: { + (node: DeclarableNode): (file: string) => DeclaredResult + (node: DeclarableNode, file: string): DeclaredResult +} = Fn.dual(2, (node: DeclarableNode, file: string): DeclaredResult => + Match.value(classifyDeclaration(node)).pipe( + Match.when({ kind: 'declared' }, (classified) => Effect.succeed>(classified.declared)), + Match.when({ kind: 'refused' }, (classified) => refuse(file, classified.construct)), + Match.exhaustive, + )) + +const declarationsPart = (declared: ReadonlyArray, statementStart: number): ScopeParts => ({ + ...EMPTY_PARTS, + declarations: declared.map((item) => [item.name, { declared: item, statementStart }] as const), +}) + +const importedNameOf = (specifier: ImportDeclarationSpecifier): string => + Match.value(specifier).pipe( + Match.discriminatorsExhaustive('type')({ + ImportSpecifier: (named) => moduleNameOf(named.imported), + ImportNamespaceSpecifier: () => '*', + ImportDefaultSpecifier: () => DEFAULT_EXPORT_NAME, + }), + ) + +const importParts = (statement: ImportDeclaration): ScopeParts => ({ + ...EMPTY_PARTS, + imports: statement.specifiers.map((specifier) => [specifier.local.name, { specifier: statement.source.value, imported: importedNameOf(specifier) }] as const), +}) + +const exportBindingOf = (statement: ExportNamedDeclaration, local: string): ExportBinding => + statement.source === null ? { kind: 'local', local } : { kind: 'reexport', specifier: statement.source.value, imported: local } + +const exportListParts = (statement: ExportNamedDeclaration): ScopeParts => ({ + ...EMPTY_PARTS, + exports: statement.specifiers.map((specifier) => [moduleNameOf(specifier.exported), exportBindingOf(statement, moduleNameOf(specifier.local))] as const), +}) + +const exportedDeclarationParts = (declaration: DeclarationNode, statement: ExportNamedDeclaration, file: string) => + Effect.map(declaredOf(declaration, file), (declared): ScopeParts => ({ + ...declarationsPart(declared, statement.start), + exports: declared.map((item) => [item.name, { kind: 'local', local: item.name }] as const), + })) + +const exportNamedParts = (statement: ExportNamedDeclaration, file: string): Effect.Effect => + statement.declaration === null ? Effect.succeed(exportListParts(statement)) : exportedDeclarationParts(statement.declaration, statement, file) + +const defaultExportOf = (declared: ReadonlyArray, statementStart: number): ScopeParts => + Option.match(Arr.head(declared), { + onNone: () => EMPTY_PARTS, + onSome: (first) => ({ ...declarationsPart(declared, statementStart), exports: [[DEFAULT_EXPORT_NAME, { kind: 'local', local: first.name }]] }), + }) + +const exportDefaultParts = (statement: ExportDefaultDeclaration, file: string): Effect.Effect => + Match.value(statement.declaration).pipe( + Match.when({ type: 'Identifier' }, (identifier) => Effect.succeed({ ...EMPTY_PARTS, exports: [[DEFAULT_EXPORT_NAME, { kind: 'local', local: identifier.name }]] })), + Match.whenOr( + { type: 'FunctionDeclaration' }, + { type: 'TSDeclareFunction' }, + { type: 'ClassDeclaration' }, + { type: 'TSInterfaceDeclaration' }, + (declaration) => Effect.map(declaredOf(declaration, file), (declared) => defaultExportOf(declared, statement.start)), + ), + Match.orElse(() => refuse(file, '`export default` of an expression')), + ) + +const statementParts = (statement: Directive | Statement, file: string): Effect.Effect => + Match.value(statement).pipe( + Match.discriminators('type')({ + ImportDeclaration: (node) => Effect.succeed(importParts(node)), + ExportNamedDeclaration: (node) => exportNamedParts(node, file), + ExportAllDeclaration: (node) => + Effect.succeed( + node.exported === null + ? { ...EMPTY_PARTS, starExports: [node.source.value] } + : { ...EMPTY_PARTS, exports: [[moduleNameOf(node.exported), { kind: 'reexport', specifier: node.source.value, imported: '*' }]] }, + ), + ExportDefaultDeclaration: (node) => exportDefaultParts(node, file), + TSExportAssignment: () => refuse(file, '`export =`'), + TSImportEqualsDeclaration: () => refuse(file, '`import =`'), + TSNamespaceExportDeclaration: () => refuse(file, '`export as namespace`'), + }), + Match.orElse((node) => Effect.map(declaredOf(node, file), (declared) => declarationsPart(declared, node.start))), + ) + +export const grouped = (entries: ReadonlyArray): HashMap.HashMap> => + Arr.reduce(entries, HashMap.empty>(), (groups, [key, value]) => + HashMap.set(groups, key, [...Option.getOrElse(HashMap.get(groups, key), (): ReadonlyArray => []), value])) + +const declaresDefault = (node: ExportDefaultDeclaration): boolean => + Match.value(node.declaration).pipe( + Match.when({ type: Match.is('FunctionDeclaration', 'TSDeclareFunction', 'ClassDeclaration', 'TSInterfaceDeclaration') }, () => true), + Match.orElse(() => false), + ) + +const stopsImplicitExports = (statement: Directive | Statement): boolean => + Match.value(statement).pipe( + Match.discriminators('type')({ + ExportNamedDeclaration: (node) => node.declaration === null, + ExportAllDeclaration: () => true, + ExportDefaultDeclaration: (node) => !declaresDefault(node), + }), + Match.orElse(() => false), + ) + +const exportsNothing = (part: ScopeParts): boolean => part.exports.length === 0 && part.starExports.length === 0 + +const implicitNames = (part: ScopeParts): ReadonlyArray => + exportsNothing(part) ? part.declarations.map(([name]) => [name, { kind: 'local', local: name }] as const) : [] + +const implicitExports = (program: Program, parts: ReadonlyArray): ReadonlyArray => + program.body.some(stopsImplicitExports) ? [] : parts.flatMap(implicitNames) + +const scopeOf = (file: DeclarationFile): Effect.Effect => + Effect.gen(function* () { + const { program, comments } = yield* parsed(file.path, file.text) + const parts = yield* Effect.forEach(program.body, (statement) => statementParts(statement, file.path)) + return { + path: file.path, + text: file.text, + program, + comments, + conditionalInferences: conditionalInferencesOf(program), + declarations: grouped(parts.flatMap((part) => part.declarations)), + imports: HashMap.fromIterable(parts.flatMap((part) => part.imports)), + exports: HashMap.fromIterable([...implicitExports(program, parts), ...parts.flatMap((part) => part.exports)]), + starExports: parts.flatMap((part) => part.starExports), + } + }) + +export const declarationGraph = (declarations: Declarations): Effect.Effect => + Effect.map(Effect.forEach(declarations.files, scopeOf), (scopes) => ({ + files: HashMap.fromIterable(scopes.map((scope) => [scope.path, scope] as const)), + entryPoints: declarations.entryPoints, + })) + +const fileIn = (graph: DeclarationGraph, from: string, specifier: string): Option.Option => + Arr.findFirst(declarationCandidates(from, specifier), (candidate) => HashMap.has(graph.files, candidate)) + +export const resolveFile: { + (from: string, specifier: string): (graph: DeclarationGraph) => Option.Option + (graph: DeclarationGraph, from: string, specifier: string): Option.Option +} = Fn.dual(3, (graph: DeclarationGraph, from: string, specifier: string): Option.Option => + isRelativeSpecifier(specifier) ? fileIn(graph, from, specifier) : Option.none()) + +type Seen = HashSet.HashSet + +const packageImportTarget = (graph: DeclarationGraph, file: string, imported: string, seen: Seen): Option.Option => + imported === '*' ? Option.some({ kind: 'namespace', file }) : exportedFrom(graph, file, imported, seen) + +const importTarget = (graph: DeclarationGraph, from: string, binding: ImportBinding, seen: Seen): Option.Option => + isRelativeSpecifier(binding.specifier) + ? Option.flatMap(fileIn(graph, from, binding.specifier), (file) => packageImportTarget(graph, file, binding.imported, seen)) + : Option.some({ kind: 'external', file: from, module: binding.specifier, imported: binding.imported }) + +const importedName = (graph: DeclarationGraph, scope: FileScope, name: string, seen: Seen): Option.Option => + Option.flatMap(HashMap.get(scope.imports, name), (binding) => importTarget(graph, scope.path, binding, seen)) + +const nameIn = (graph: DeclarationGraph, scope: FileScope, name: string, seen: Seen): Option.Option => + Option.match(HashMap.get(scope.declarations, name), { + onNone: () => importedName(graph, scope, name, seen), + onSome: (declarations) => Option.some({ kind: 'local', file: scope.path, name, declarations }), + }) + +const resolvedIn = (graph: DeclarationGraph, file: string, name: string, seen: Seen): Option.Option => + Option.flatMap(HashMap.get(graph.files, file), (scope) => nameIn(graph, scope, name, seen)) + +export const resolveName: { + (file: string, name: string): (graph: DeclarationGraph) => Option.Option + (graph: DeclarationGraph, file: string, name: string): Option.Option +} = Fn.dual(3, (graph: DeclarationGraph, file: string, name: string) => resolvedIn(graph, file, name, HashSet.empty())) + +const boundExport = (graph: DeclarationGraph, scope: FileScope, binding: ExportBinding, seen: Seen): Option.Option => + binding.kind === 'local' + ? nameIn(graph, scope, binding.local, seen) + : importTarget(graph, scope.path, { specifier: binding.specifier, imported: binding.imported }, seen) + +const starredExport = (graph: DeclarationGraph, scope: FileScope, name: string, seen: Seen): Option.Option => + name === DEFAULT_EXPORT_NAME + ? Option.none() + : Arr.findFirst(scope.starExports, (specifier) => + Option.flatMap(resolveFile(graph, scope.path, specifier), (target) => exportedFrom(graph, target, name, seen))) + +const exportedFromScope = (graph: DeclarationGraph, scope: FileScope, name: string, seen: Seen): Option.Option => + Option.match(HashMap.get(scope.exports, name), { + onNone: () => starredExport(graph, scope, name, seen), + onSome: (binding) => boundExport(graph, scope, binding, seen), + }) + +const exportedFrom = (graph: DeclarationGraph, file: string, name: string, seen: Seen): Option.Option => { + const key = `${file}\u0000${name}` + return HashSet.has(seen, key) + ? Option.none() + : Option.flatMap(HashMap.get(graph.files, file), (scope) => exportedFromScope(graph, scope, name, HashSet.add(seen, key))) +} + +export const exportedSymbol: { + (file: string, name: string): (graph: DeclarationGraph) => Option.Option + (graph: DeclarationGraph, file: string, name: string): Option.Option +} = Fn.dual(3, (graph: DeclarationGraph, file: string, name: string) => exportedFrom(graph, file, name, HashSet.empty())) + +const starredOrigin = (graph: DeclarationGraph, scope: FileScope, name: string, seen: Seen): Option.Option => + Arr.findFirst(scope.starExports, (specifier) => + Option.flatMap(resolveFile(graph, scope.path, specifier), (target) => exportOriginFrom(graph, target, name, seen))) + +const originInScope = (graph: DeclarationGraph, scope: FileScope, name: string, seen: Seen): Option.Option => + HashMap.has(scope.exports, name) ? Option.some(scope.path) : starredOrigin(graph, scope, name, seen) + +const exportOriginFrom = (graph: DeclarationGraph, file: string, name: string, seen: Seen): Option.Option => { + const key = `${file}\u0000${name}` + return HashSet.has(seen, key) + ? Option.none() + : Option.flatMap(HashMap.get(graph.files, file), (scope) => originInScope(graph, scope, name, HashSet.add(seen, key))) +} + +export const exportOrigin: { + (file: string, name: string): (graph: DeclarationGraph) => Option.Option + (graph: DeclarationGraph, file: string, name: string): Option.Option +} = Fn.dual(3, (graph: DeclarationGraph, file: string, name: string) => exportOriginFrom(graph, file, name, HashSet.empty())) + +const starredNames = (graph: DeclarationGraph, scope: FileScope, seen: Seen): ReadonlyArray => + scope.starExports.flatMap((specifier) => + Option.match(resolveFile(graph, scope.path, specifier), { + onNone: () => [], + onSome: (target) => namesOf(graph, target, seen).filter((name) => name !== 'default'), + }) + ) + +const scopeNames = (graph: DeclarationGraph, scope: FileScope, seen: Seen): ReadonlyArray => + Arr.sort(Arr.dedupe([...HashMap.keys(scope.exports), ...starredNames(graph, scope, HashSet.add(seen, scope.path))]), Order.String) + +const namesOf = (graph: DeclarationGraph, file: string, seen: Seen): ReadonlyArray => + HashSet.has(seen, file) + ? [] + : Option.match(HashMap.get(graph.files, file), { onNone: () => [], onSome: (scope) => scopeNames(graph, scope, seen) }) + +export const exportedNames: { + (file: string): (graph: DeclarationGraph) => ReadonlyArray + (graph: DeclarationGraph, file: string): ReadonlyArray +} = Fn.dual(2, (graph: DeclarationGraph, file: string) => namesOf(graph, file, HashSet.empty())) diff --git a/packages/api-extractor/src/front-end/declarations.schema.ts b/packages/api-extractor/src/front-end/declarations.schema.ts new file mode 100644 index 0000000..fc2a548 --- /dev/null +++ b/packages/api-extractor/src/front-end/declarations.schema.ts @@ -0,0 +1,62 @@ +import { Schema } from 'effect' + +export type SourceEntryPoint = { + readonly exportPath: string + readonly sourceFile: string +} + +export type PackageSource = { + readonly name: string + readonly version: string + readonly packageRoot: string + readonly workspaceRoot: string + readonly tsconfigPath: string + readonly entryPoints: ReadonlyArray +} + +export type DeclarationFile = { + 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 }> + readonly files: ReadonlyArray +} + +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 })), +}) + +const positiveInt: Schema.Codec = Schema.Int.check(Schema.isGreaterThan(0)) + +export const Reported: Schema.TaggedStruct<'Reported', { + readonly file: Schema.optionalKey + readonly code: typeof positiveInt + readonly message: Schema.String +}> = Schema.TaggedStruct('Reported', { + file: Schema.optionalKey(Schema.String), + code: positiveInt, + message: Schema.String, +}) + +export type Reported = typeof Reported.Type + +export const Note: Schema.TaggedStruct<'Note', { + readonly file: Schema.optionalKey + readonly message: Schema.String +}> = Schema.TaggedStruct('Note', { + file: Schema.optionalKey(Schema.String), + message: Schema.String, +}) + +export type Note = typeof Note.Type + +export type Diagnostic = Reported | Note + +export const Diagnostic: Schema.Codec = Schema.Union([Reported, Note]) 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 new file mode 100644 index 0000000..5bd7014 --- /dev/null +++ b/packages/api-extractor/src/front-end/front-end-error.schema.ts @@ -0,0 +1,69 @@ +import { type Cause, Match, Schema } from 'effect' +import { Diagnostic, type Note, type Reported } from './declarations.schema.ts' + +type DiagnosticsFields = { readonly diagnostics: Schema.$Array } + +const diagnosticsFields: DiagnosticsFields = { diagnostics: Schema.Array(Diagnostic) } + +const fileLabel = (file: string | undefined): string => (file === undefined ? '' : `${file}: `) + +const reportedLine = ({ file, code, message }: Reported): string => `${fileLabel(file)}TS${code} ${message}` + +const noteLine = ({ file, message }: Note): string => `${fileLabel(file)}${message}` + +const listedLine: (diagnostic: Diagnostic) => string = Match.type().pipe( + Match.tag('Reported', reportedLine), + Match.tag('Note', noteLine), + Match.exhaustive, +) + +const listed = (diagnostics: ReadonlyArray): string => diagnostics.map(listedLine).join('\n') + +const NoSourceEntryBase: Schema.Class, Cause.YieldableError> = + Schema.TaggedError()('NoSourceEntry', { packageRoot: 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` + } +} + +type ManifestRejectedFields = { readonly packageRoot: Schema.String; readonly issue: Schema.String } + +const ManifestRejectedBase: Schema.Class, Cause.YieldableError> = + Schema.TaggedError()('ManifestRejected', { packageRoot: Schema.String, issue: Schema.String }) + +export class ManifestRejected extends ManifestRejectedBase { + override get message(): string { + return `${this.packageRoot}: the package manifest could not be read: ${this.issue}` + } +} + +const NotIsolatedBase: Schema.Class, Cause.YieldableError> = + Schema.TaggedError()('NotIsolated', diagnosticsFields) + +export class NotIsolated extends NotIsolatedBase { + override get message(): string { + return `declarations need the type checker:\n${listed(this.diagnostics)}` + } +} + +const CompilerRejectedBase: Schema.Class, Cause.YieldableError> = + Schema.TaggedError()('CompilerRejected', diagnosticsFields) + +export class CompilerRejected extends CompilerRejectedBase { + override get message(): string { + return `the compiler refused the package:\n${listed(this.diagnostics)}` + } +} + +type UnsupportedDeclarationFields = { readonly file: Schema.String; readonly construct: Schema.String } + +const UnsupportedDeclarationBase: Schema.Class, Cause.YieldableError> = + Schema.TaggedError()('UnsupportedDeclaration', { file: Schema.String, construct: Schema.String }) + +export class UnsupportedDeclaration extends UnsupportedDeclarationBase { + override get message(): string { + return `${this.file}: ${this.construct} is not supported` + } +} diff --git a/packages/api-extractor/src/front-end/oxc-isolated-emitter.ts b/packages/api-extractor/src/front-end/oxc-isolated-emitter.ts new file mode 100644 index 0000000..eac6d2c --- /dev/null +++ b/packages/api-extractor/src/front-end/oxc-isolated-emitter.ts @@ -0,0 +1,142 @@ +import { Array as Arr, Effect, FileSystem, HashMap, HashSet, Layer, Match, Option, Order, Path, Ref, Result } from 'effect' +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 { type DeclarationEmitter, IsolatedEmitter, type IsolatedEmitterId } from './declaration-emitter.service.ts' +import type { DeclarationFile, Declarations, Diagnostic, PackageSource } from './declarations.schema.ts' +import { NotIsolated, type UnsupportedDeclaration } from './front-end-error.schema.ts' +import { isDeclarationFile, readPackageFile } from './package-file.ts' + +export const OXC_TRANSFORM_PRODUCER: string = `oxc-transform@${oxcTransformManifest.version}` + +const DIAGNOSTIC_TEXT = /^TS(\d+): (.*)$/s + +const diagnosticOf = (file: string, text: string): Diagnostic => + Option.match(Option.fromNullishOr(DIAGNOSTIC_TEXT.exec(text)), { + onNone: (): Diagnostic => ({ _tag: 'Note', file, message: text }), + onSome: ([, code, message]): Diagnostic => ({ _tag: 'Reported', file, code: Number(code), message: message ?? '' }), + }) + +const codeOf = (diagnostic: Diagnostic): Option.Option => + Match.value(diagnostic).pipe( + Match.tag('Reported', ({ code }): Option.Option => Option.some(code)), + Match.tag('Note', () => Option.none()), + Match.exhaustive, + ) + +const diagnosticOrder: Order.Order = Order.combineAll([ + Order.mapInput(Option.makeOrder(Order.String), (diagnostic: Diagnostic) => Option.fromNullishOr(diagnostic.file)), + Order.mapInput(Option.makeOrder(Order.Number), codeOf), + Order.mapInput(Order.String, (diagnostic: Diagnostic) => diagnostic.message), +]) + +type Emission = { + readonly source: PackageSource + readonly realRoot: string + readonly diagnostics: Ref.Ref> +} + +const declare = (emission: Emission, sourceFile: string, text: string) => + Effect.gen(function* () { + const result = isolatedDeclarationSync(sourceFile, text, { stripInternal: false, sourcemap: false }) + yield* Ref.update(emission.diagnostics, (known) => [...known, ...result.errors.map((error) => diagnosticOf(sourceFile, error.message))]) + return result.code + }) + +const firstSource = ( + emission: Emission, + candidates: ReadonlyArray, +): Effect.Effect, PlatformError, FileSystem.FileSystem | Path.Path> => + Arr.match(candidates, { + onEmpty: () => Effect.succeedNone, + onNonEmpty: ([sourceFile, ...rest]) => + 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))), + })), + }) + +const unresolvedImportDiagnostic = (unresolved: UnresolvedImport): Diagnostic => ({ + _tag: 'Note', + file: unresolved.file, + message: `the import \`${unresolved.specifier}\` names no declaration file inside the package`, +}) + +const refusedOrClosed = ( + found: ReadonlyArray, + closed: Result.Result, +): Effect.Effect, NotIsolated | PlatformError | UnsupportedDeclaration> => + Arr.match(found, { + onEmpty: () => + Result.match(closed, { + onSuccess: (report) => + Arr.match(report.unresolvedImports.map(unresolvedImportDiagnostic), { + onEmpty: () => Effect.succeed(report.files), + onNonEmpty: (diagnostics) => Effect.fail(new NotIsolated({ diagnostics })), + }), + onFailure: Effect.fail, + }), + onNonEmpty: (diagnostics) => Effect.fail(new NotIsolated({ diagnostics })), + }) + +const missingEntryDiagnostic = (declarationFile: string): Diagnostic => ({ + _tag: 'Note', + file: declarationFile, + message: 'no source file inside the package could be read for this entry point', +}) + +const everyEntryEmitted = (files: ReadonlyArray, entryDeclarations: ReadonlyArray): Effect.Effect, NotIsolated> => + Arr.match(missingEntryDeclarations(HashSet.fromIterable(files.map((file) => file.path)), entryDeclarations), { + onEmpty: () => Effect.succeed(files), + onNonEmpty: (missing) => Effect.fail(new NotIsolated({ diagnostics: missing.map(missingEntryDiagnostic) })), + }) + +const sharedDeclarationDiagnostic = (shared: SharedDeclaration): Diagnostic => ({ + _tag: 'Note', + file: shared.declarationFile, + message: `two entry points share one declaration file: ${shared.exportPaths.map((exportPath) => `\`${exportPath}\``).join(' and ')}`, +}) + +const recordedSourceFirst = (recorded: HashMap.HashMap) => (declarationFile: string): ReadonlyArray => + Option.match(HashMap.get(recorded, declarationFile), { + onNone: () => [...sourceCandidatesOf(declarationFile), declarationFile], + onSome: (sourceFile) => [sourceFile], + }) + +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 entryPoints = entryPointDeclarations(source.entryPoints) + const entryDeclarations = entryPoints.map((entry) => entry.declarationFile) + yield* Arr.match(sharedDeclarations(entryPoints).map(sharedDeclarationDiagnostic), { + onEmpty: () => Effect.void, + onNonEmpty: (diagnostics) => Effect.fail(new NotIsolated({ diagnostics })), + }) + const candidatesOf = recordedSourceFirst(HashMap.fromIterable(source.entryPoints.map((entry) => [declarationPathOf(entry.sourceFile), entry.sourceFile] as const))) + const closed = yield* Effect.result(closeDeclarations({ + entryDeclarations, + load: (declarationFile) => firstSource(emission, candidatesOf(declarationFile)), + })) + const found = Arr.sort(yield* Ref.get(emission.diagnostics), diagnosticOrder) + const files = yield* everyEntryEmitted(yield* refusedOrClosed(found, closed), entryDeclarations) + const declarations: Declarations = { + producer: OXC_TRANSFORM_PRODUCER, + package: { name: source.name, version: source.version }, + entryPoints, + files, + } + return declarations + }) + +export const layer: Layer.Layer = Layer.effect( + IsolatedEmitter, + Effect.gen(function* () { + const services = yield* Effect.context() + const emitter: DeclarationEmitter = { emit: (source) => Effect.provideContext(emit(source), services) } + return emitter + }), +) diff --git a/packages/api-extractor/src/front-end/package-file.ts b/packages/api-extractor/src/front-end/package-file.ts new file mode 100644 index 0000000..21e1e54 --- /dev/null +++ b/packages/api-extractor/src/front-end/package-file.ts @@ -0,0 +1,30 @@ +import { Effect, FileSystem, Match, Option, Path } from 'effect' +import type { PlatformError } from 'effect/PlatformError' +import { relativeWithin } from './declaration-graph.ts' + +const DECLARATION_FILE = /\.d\.[cm]?ts$/ + +export const isDeclarationFile = (file: string): boolean => DECLARATION_FILE.test(file) + +export const isAbsent = (error: PlatformError): boolean => + Match.value(error.reason).pipe( + Match.tag('NotFound', 'BadResource', () => true), + Match.orElse(() => false), + ) + +export type PackageFileRequest = { + readonly packageRoot: string + readonly realRoot: string + readonly file: string +} + +export const readPackageFile = (request: PackageFileRequest): Effect.Effect, PlatformError, FileSystem.FileSystem | Path.Path> => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem + const path = yield* Path.Path + const real = yield* Effect.option(fileSystem.realPath(path.resolve(request.packageRoot, request.file))) + return yield* Option.match(Option.flatMap(real, (realFile) => Option.as(relativeWithin(path, request.realRoot, realFile), realFile)), { + onNone: () => Effect.succeedNone, + onSome: (realFile) => fileSystem.readFileString(realFile).pipe(Effect.asSome, Effect.catchIf(isAbsent, () => Effect.succeedNone)), + }) + }) diff --git a/packages/api-extractor/src/front-end/package-source.schema.ts b/packages/api-extractor/src/front-end/package-source.schema.ts new file mode 100644 index 0000000..8c97d84 --- /dev/null +++ b/packages/api-extractor/src/front-end/package-source.schema.ts @@ -0,0 +1,100 @@ +import { Match, Predicate, Schema, SchemaTransformation } from 'effect' + +type EncodedConditions = { readonly [condition: string]: EncodedExportTarget } + +export type EncodedExportTarget = string | null | ReadonlyArray | EncodedConditions + +const EncodedExportTarget: Schema.Codec = Schema.suspend((): Schema.Codec => + Schema.Union([Schema.String, Schema.Null, Schema.Array(EncodedExportTarget), Schema.Record(Schema.String, EncodedExportTarget)]), +).annotate({ recursionBudget: { maxDepth: 6, depthSize: 'small' } }) + +const PathTag = { _tag: 'Path' } as const +type PathTag = typeof PathTag + +const ConditionsTag = { _tag: 'Conditions' } as const +type ConditionsTag = typeof ConditionsTag + +const FallbacksTag = { _tag: 'Fallbacks' } as const +type FallbacksTag = typeof FallbacksTag + +const ExcludedTag = { _tag: 'Excluded' } as const +type ExcludedTag = typeof ExcludedTag + +interface ExportPath extends PathTag { + readonly path: string +} + +interface ExportConditions extends ConditionsTag { + readonly conditions: { readonly [condition: string]: ExportTarget } +} + +interface ExportFallbacks extends FallbacksTag { + readonly targets: ReadonlyArray +} + +interface ExportExcluded extends ExcludedTag {} + +export type ExportTarget = ExportPath | ExportConditions | ExportFallbacks | ExportExcluded + +const ExportPath: Schema.Codec = Schema.TaggedStruct('Path', { path: Schema.String }) + +const ExportExcluded: Schema.Codec = Schema.TaggedStruct('Excluded', {}) + +const ExportConditions: Schema.Codec = Schema.suspend((): Schema.Codec => + Schema.TaggedStruct('Conditions', { conditions: Schema.Record(Schema.String, ExportTarget) }), +) + +const ExportFallbacks: Schema.Codec = Schema.suspend((): Schema.Codec => + Schema.TaggedStruct('Fallbacks', { targets: Schema.Array(ExportTarget) }), +) + +export const ExportTarget: Schema.Codec = Schema.suspend((): Schema.Codec => + Schema.Union([ExportPath, ExportConditions, ExportFallbacks, ExportExcluded]), +).annotate({ recursionBudget: { maxDepth: 6, depthSize: 'small' } }) + +const isEncodedArray = (target: EncodedExportTarget): target is ReadonlyArray => Array.isArray(target) + +const isEncodedConditions = (target: EncodedExportTarget): target is EncodedConditions => + Predicate.isObject(target) && !isEncodedArray(target) + +const conditionsOf = (conditions: EncodedConditions): { readonly [condition: string]: ExportTarget } => + Object.fromEntries(Object.entries(conditions).map(([condition, target]) => [condition, exportTargetOf(target)] as const)) + +function exportTargetOf(encoded: EncodedExportTarget): ExportTarget { + return Match.value(encoded).pipe( + Match.when(Match.string, (path) => ExportPath.make({ path })), + Match.when(isEncodedArray, (targets) => ExportFallbacks.make({ targets: targets.map(exportTargetOf) })), + Match.when(isEncodedConditions, (conditions) => ExportConditions.make({ conditions: conditionsOf(conditions) })), + Match.orElse(() => ExportExcluded.make({})), + ) +} + +const encodedConditionsOf = (conditions: { readonly [condition: string]: ExportTarget }): EncodedConditions => + Object.fromEntries(Object.entries(conditions).map(([condition, target]) => [condition, encodedOf(target)] as const)) + +function encodedOf(target: ExportTarget): EncodedExportTarget { + return Match.value(target).pipe( + Match.tag('Path', ({ path }) => path), + Match.tag('Fallbacks', ({ targets }) => targets.map(encodedOf)), + Match.tag('Conditions', ({ conditions }) => encodedConditionsOf(conditions)), + Match.tag('Excluded', () => null), + Match.exhaustive, + ) +} + +export const ExportTargetCodec: Schema.Codec = EncodedExportTarget.pipe( + Schema.decodeTo(ExportTarget, SchemaTransformation.transform({ decode: exportTargetOf, encode: encodedOf })), +) + +type PackageManifestFields = { + readonly name: Schema.String + readonly version: Schema.String + readonly exports: 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)])), +}) +export type PackageManifest = typeof PackageManifest.Type diff --git a/packages/api-extractor/src/front-end/read-package.ts b/packages/api-extractor/src/front-end/read-package.ts new file mode 100644 index 0000000..bb16c33 --- /dev/null +++ b/packages/api-extractor/src/front-end/read-package.ts @@ -0,0 +1,289 @@ +import { Array as Arr, Effect, FileSystem, HashSet, Match, Option, Order, Path, Schema } from 'effect' +import type { PlatformError } from 'effect/PlatformError' +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' + +export const FAMILY_SOURCE_CONDITION = '@systemfsoftware/source' + +export type ReadPackageRequest = { + readonly packageRoot: string + readonly sourceCondition?: string +} + +const decodeManifest = Schema.decodeEffect(Schema.fromJsonString(PackageManifest)) + +const TYPESCRIPT_SOURCE = /\.(?:ts|tsx|mts|cts)$/ + +const isTypeScriptSource = (value: string): boolean => TYPESCRIPT_SOURCE.test(value) + +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.orElse((record) => Object.entries(record).filter(([exportPath]) => exportPath !== './package.json')), + ) + +const starCountOf = (exportPath: string): number => exportPath.split('*').length - 1 + +const isPatternKey = (exportPath: string): boolean => starCountOf(exportPath) === 1 + +const isExplicitKey = (exportPath: string): boolean => starCountOf(exportPath) === 0 + +type PatternParts = { readonly prefix: string; readonly suffix: string } + +const patternPartsOf = (target: string): Option.Option => + Option.flatMap(Option.liftPredicate(target.split('*'), (parts) => parts.length === 2), (parts) => + Option.all({ prefix: Arr.head(parts), suffix: Arr.get(parts, 1) }), + ) + +const matchedSubstring = (prefix: string, suffix: string, candidate: string): Option.Option => + Option.map( + Option.filter( + Option.filter(Option.liftPredicate(candidate, (value) => value.startsWith(prefix)), (value) => value.endsWith(suffix)), + (value) => value.length >= prefix.length + suffix.length, + ), + (value) => value.slice(prefix.length, value.length - suffix.length), + ) + +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) => + (candidate: string): Option.Option => + Option.map(Option.filter(matchedSubstring(prefix, suffix, candidate), isValidPatternMatch), (substring) => ({ + entry: { exportPath: key.replace('*', () => substring), sourceFile: candidate }, + key, + })) + +const joinRelative = (path: Path.Path, relative: string, name: string): string => path.join(relative, name).split(path.sep).join('/') + +const withinRoot = (path: Path.Path, root: string, candidate: string): Option.Option => + Option.orElse(relativeWithin(path, root, candidate), () => Option.liftPredicate(path.relative(root, candidate), (relative) => relative === '')) + +type Platform = FileSystem.FileSystem | Path.Path + +const listNames = (directory: string): Effect.Effect, PlatformError, Platform> => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem + return yield* fileSystem.readDirectory(directory).pipe(Effect.catchIf(isAbsent, () => Effect.succeed>([]))) + }) + +const isDirectory = (childPath: string): Effect.Effect => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem + return yield* fileSystem.stat(childPath).pipe( + Effect.map((info) => info.type === 'Directory'), + Effect.catchIf(isAbsent, () => Effect.succeed(false)), + ) + }) + +const directoryRealPath = (childPath: string): Effect.Effect, PlatformError, Platform> => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem + return yield* (yield* isDirectory(childPath)) + ? Effect.asSome(fileSystem.realPath(childPath)) + : Effect.succeed(Option.none()) + }) + +const MAX_PATTERN_DIRECTORIES = 1024 + +type Walk = { readonly names: ReadonlyArray; readonly listed: number } + +type WalkPattern = { readonly root: string; readonly key: string; readonly prefix: string } + +type WalkError = PlatformError | ManifestRejected + +const overBudget = (pattern: WalkPattern): ManifestRejected => + new ManifestRejected({ + packageRoot: pattern.root, + issue: `the pattern export "${pattern.key}" reaches more than ${MAX_PATTERN_DIRECTORIES} directories`, + }) + +const walkStep = (pattern: WalkPattern, relative: string, ancestors: HashSet.HashSet, walk: Walk): Effect.Effect => + Effect.gen(function* () { + const path = yield* Path.Path + const listed = walk.listed + 1 + const names = yield* (listed > MAX_PATTERN_DIRECTORIES + ? Effect.fail(overBudget(pattern)) + : listNames(path.join(pattern.root, relative))) + return yield* Effect.reduce(names, () => ({ names: walk.names, listed }), stepChild(pattern, relative, ancestors)) + }) + +const enterDirectory = ( + pattern: WalkPattern, + relative: string, + ancestors: HashSet.HashSet, + walk: Walk, + realPath: string, +): Effect.Effect => + HashSet.has(ancestors, realPath) ? Effect.succeed(walk) : walkStep(pattern, relative, HashSet.add(ancestors, realPath), walk) + +const leadsToPrefix = (prefix: string, childRelative: string): boolean => + `${childRelative}/`.startsWith(prefix) || prefix.startsWith(`${childRelative}/`) + +const isInvalidInsideMatch = (pattern: WalkPattern, childRelative: string, name: string): boolean => + INVALID_SEGMENT.test(name) && pattern.prefix.length <= childRelative.length - name.length + +const isUnmatchable = (pattern: WalkPattern, childRelative: string, name: string): boolean => + !leadsToPrefix(pattern.prefix, childRelative) || isInvalidInsideMatch(pattern, childRelative, name) + +const stepChild = (pattern: WalkPattern, relative: string, ancestors: HashSet.HashSet) => + (walk: Walk, name: string): Effect.Effect => + Effect.gen(function* () { + const path = yield* Path.Path + const childRelative = joinRelative(path, relative, name) + return yield* isUnmatchable(pattern, childRelative, name) + ? Effect.succeed(walk) + : Option.match(yield* directoryRealPath(path.join(pattern.root, relative, name)), { + onNone: () => Effect.succeed({ names: Arr.append(walk.names, childRelative), listed: walk.listed }), + onSome: (realPath) => enterDirectory(pattern, childRelative, ancestors, walk, realPath), + }) + }) + +const walkRelative = (pattern: WalkPattern, relative: string): Effect.Effect, WalkError, Platform> => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem + const path = yield* Path.Path + const absolute = path.join(pattern.root, relative) + const seed = yield* fileSystem.realPath(absolute).pipe(Effect.catchIf(isAbsent, () => Effect.succeed(absolute))) + const walk = yield* walkStep(pattern, relative, HashSet.make(seed), { names: [], listed: 0 }) + return walk.names + }) + +const walkDirectory = (pattern: WalkPattern, directory: string): Effect.Effect, WalkError, Platform> => + Effect.gen(function* () { + const path = yield* Path.Path + return yield* Option.match(withinRoot(path, pattern.root, path.resolve(pattern.root, directory)), { + onNone: () => Effect.succeed>([]), + onSome: (relative) => walkRelative(pattern, relative), + }) + }) + +type PatternEntry = { readonly entry: SourceEntryPoint; readonly key: string } + +const patternPrecedence: Order.Order = Order.combineAll([ + Order.mapInput(Order.Number, (key: string) => key.indexOf('*') + 1), + Order.mapInput(Order.Number, (key: string) => key.length), +]) + +const baseOf = (key: string): string => key.slice(0, key.indexOf('*')) + +const trailerOf = (key: string): string => key.slice(key.indexOf('*') + 1) + +const startsPastBase = (exportPath: string, key: string): boolean => exportPath.startsWith(baseOf(key)) && exportPath !== baseOf(key) + +const coversTrailer = (exportPath: string, key: string): boolean => exportPath.endsWith(trailerOf(key)) && exportPath.length >= key.length + +const endsWithTrailer = (exportPath: string, key: string): boolean => trailerOf(key).length === 0 || coversTrailer(exportPath, key) + +const keyMatches = (exportPath: string) => (key: string): boolean => startsPastBase(exportPath, key) && endsWithTrailer(exportPath, key) + +const winningKeyOf = (patternKeys: ReadonlyArray, exportPath: string): Option.Option => + Arr.match(patternKeys.filter(keyMatches(exportPath)), { + onEmpty: () => Option.none(), + onNonEmpty: (matching) => Option.some(Arr.max(matching, patternPrecedence)), + }) + +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) => + Option.match(patternPartsOf(targetText), { + onNone: () => Effect.succeed>([]), + 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)))) + }, + }), + }) + +const entryPointsOf = (root: string, manifest: PackageManifest, condition: string): Effect.Effect, WalkError, Platform> => + Effect.gen(function* () { + const entries = exportMapOf(manifest.exports) + const explicit = entries.filter(([exportPath]) => isExplicitKey(exportPath)) + const patterns = entries.filter(([exportPath]) => isPatternKey(exportPath)) + 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) + .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 workspaceRootFrom = (directory: string, packageRoot: string): Effect.Effect => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem + const path = yield* Path.Path + const holdsWorkspace = yield* fileSystem.exists(path.join(directory, 'pnpm-workspace.yaml')) + return holdsWorkspace ? directory : yield* workspaceRootAbove(path.dirname(directory), directory, packageRoot) + }) + +const workspaceRootAbove = (parent: string, directory: string, packageRoot: string): Effect.Effect => + parent === directory ? Effect.succeed(packageRoot) : workspaceRootFrom(parent, packageRoot) + +const tsconfigOf = (root: string): Effect.Effect => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem + const path = yield* Path.Path + const buildConfig = path.join(root, 'tsconfig.build.json') + return (yield* fileSystem.exists(buildConfig)) ? buildConfig : path.join(root, 'tsconfig.json') + }) + +const sourceOf = (root: string, manifest: PackageManifest, entryPoints: Arr.NonEmptyReadonlyArray): Effect.Effect => + Effect.gen(function* () { + const source: PackageSource = { + name: manifest.name, + version: manifest.version, + packageRoot: root, + workspaceRoot: yield* workspaceRootFrom(root, root), + tsconfigPath: yield* tsconfigOf(root), + entryPoints, + } + return source + }) + +export const readPackage = (request: ReadPackageRequest): Effect.Effect => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem + const path = yield* Path.Path + const root = path.resolve(request.packageRoot) + const manifest = yield* decodeManifest(yield* fileSystem.readFileString(path.join(root, 'package.json'))).pipe( + Effect.catchTag('SchemaError', (error) => 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), + }) + }) diff --git a/packages/api-extractor/src/front-end/resolve-messages.ts b/packages/api-extractor/src/front-end/resolve-messages.ts new file mode 100644 index 0000000..b169cee --- /dev/null +++ b/packages/api-extractor/src/front-end/resolve-messages.ts @@ -0,0 +1,540 @@ +import { type StandardModifierTagSet, TSDocParser } from '@microsoft/tsdoc' +import { Array as Arr, Effect, HashMap, HashSet, Match, Option, Order, Predicate } from 'effect' +import { + type Class, + type Comment, + type Expression, + type Program, + type TSClassImplements, + type TSImportType, + type TSConditionalType, + type TSGlobalDeclaration, + type TSInferType, + type TSInterfaceHeritage, + type TSModuleBlock, + type TSModuleDeclaration, + type TSMappedType, + type TSTypeName, + type TSTypeParameterDeclaration, + type TSTypeQuery, + type TSTypeReference, + Visitor, +} from 'oxc-parser' +import type { AnalysisMessage, AnalysisMessageId } from './analysis-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' + +type ReleaseTag = 'internal' | 'alpha' | 'beta' | 'public' + +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 declaredTagOf = (scope: FileScope, declaration: Declaration): Option.Option => + Option.flatMap(leadingCommentOf(scope, declaration.statementStart), (comment) => tagOfComment(comment.value)) + +const tagOf = (graph: DeclarationGraph, file: string, declaration: Declaration): Option.Option => + Option.flatMap(HashMap.get(graph.files, file), (scope) => declaredTagOf(scope, declaration)) + +const effectiveTagOf = (tag: Option.Option): ReleaseTag => Option.getOrElse(tag, () => 'public') + +type NameNode = TSTypeName | TSImportType | Expression + +const rootNameOf = (node: NameNode | null): Option.Option => + Match.value(node).pipe( + Match.when({ type: 'Identifier' }, (identifier) => Option.some(identifier.name)), + Match.when({ type: 'TSQualifiedName' }, (qualified) => rootNameOf(qualified.left)), + Match.when({ type: 'MemberExpression' }, (member) => rootNameOf(member.object)), + Match.orElse(() => Option.none()), + ) + +type Reference = { readonly start: number; readonly name: string } + +type ReferenceScan = { + readonly references: ReadonlyArray +} + +type NameSpace = 'type' | 'value' + +type NameScope = { + readonly start: number + readonly end: number + readonly types: ReadonlyArray + readonly values: ReadonlyArray +} + +type ScopeCarrier = { + readonly start: number + readonly end: number + readonly typeParameters?: TSTypeParameterDeclaration | null +} + +const EMPTY_SCAN: ReferenceScan = { references: [] } + +const withinScope = (scope: NameScope, at: number): boolean => at >= scope.start && at < scope.end + +const namesIn = (scope: NameScope, space: NameSpace): ReadonlyArray => (space === 'type' ? scope.types : scope.values) + +const matchesName = (scope: NameScope, name: string, space: NameSpace): boolean => + Arr.some(namesIn(scope, space), (candidate) => candidate === name) + +const shadowed = (scopes: ReadonlyArray, name: string, at: number, space: NameSpace): boolean => + Arr.some(scopes, (scope) => withinScope(scope, at) && matchesName(scope, name, space)) + +const addReference = (references: Array, scopes: ReadonlyArray, name: string, at: number, space: NameSpace): void => { + if (!shadowed(scopes, name, at, space)) { + references.push({ start: at, name }) + } +} + +const openScope = (scopes: Array, node: ScopeCarrier): void => + void scopes.push({ start: node.start, end: node.end, types: typeParameterNames(node.typeParameters), values: [] }) + +const closeScope = (scopes: Array): void => void scopes.pop() + +const openNamedScope = (scopes: Array, node: TSMappedType): void => + void scopes.push({ start: node.start, end: node.end, types: [node.key.name], values: [] }) + +type ModuleDeclaration = TSModuleDeclaration | TSGlobalDeclaration + +const moduleBlockOf = (node: ModuleDeclaration): Option.Option => Option.fromNullishOr(node.body) + +const namespaceNameOf = (node: ModuleDeclaration): Option.Option => + Match.value(node.id).pipe( + Match.when({ type: 'Identifier' }, (id) => Option.some(id.name)), + Match.orElse(() => Option.none()), + ) + +const namespaceBlocksOf = (node: ModuleDeclaration): ReadonlyArray => Option.toArray(moduleBlockOf(node)) + +type MergedNamespaceBlocks = HashMap.HashMap> + +type NamespaceBlockEntry = readonly [string, TSModuleBlock] + +type NamespacePathEntry = readonly [number, string] + +type NamespacePass = { + readonly blocks: ReadonlyArray + readonly pathsByStart: HashMap.HashMap +} + +const enterNamespacePath = ( + namespacePath: Array, + blocks: Array, + paths: Array, + node: ModuleDeclaration, +): void => + Option.match(namespaceNameOf(node), { + onNone: () => undefined, + onSome: (name) => { + namespacePath.push(name) + void paths.push([node.start, namespacePath.join('.')]) + Option.match(moduleBlockOf(node), { + onNone: () => undefined, + onSome: (block) => void blocks.push([namespacePath.join('.'), block]), + }) + }, + }) + +const leaveNamespacePath = (namespacePath: Array, node: ModuleDeclaration): void => + Option.match(namespaceNameOf(node), { onNone: () => undefined, onSome: () => void namespacePath.pop() }) + +const namespacePassOf = (program: Program): NamespacePass => { + const namespacePath: Array = [] + const blocks: Array = [] + const paths: Array = [] + new Visitor({ + TSModuleDeclaration: (node: ModuleDeclaration) => enterNamespacePath(namespacePath, blocks, paths, node), + 'TSModuleDeclaration:exit': (node: ModuleDeclaration) => leaveNamespacePath(namespacePath, node), + }).visit(program) + return { blocks, pathsByStart: HashMap.fromIterable(paths) } +} + +const withBlock = (blocksByPath: MergedNamespaceBlocks, [path, block]: NamespaceBlockEntry): MergedNamespaceBlocks => + HashMap.set(blocksByPath, path, Arr.append(Option.getOrElse(HashMap.get(blocksByPath, path), () => []), block)) + +const mergedBlocksByPath = (blocks: ReadonlyArray): MergedNamespaceBlocks => + Arr.reduce(blocks, HashMap.empty>(), withBlock) + +const namespaceBlocksAt = (blocksByPath: MergedNamespaceBlocks, path: string, node: ModuleDeclaration): ReadonlyArray => + Option.getOrElse(HashMap.get(blocksByPath, path), () => namespaceBlocksOf(node)) + +const namespaceScopeOf = (block: TSModuleBlock, blocks: ReadonlyArray): NameScope => ({ + start: block.start, + end: block.end, + ...namespaceSpacesOf(blocks), +}) + +const pushNamespaceScope = (scopes: Array, blocks: ReadonlyArray, node: ModuleDeclaration): void => + Option.match(moduleBlockOf(node), { + onNone: () => undefined, + onSome: (block) => void scopes.push(namespaceScopeOf(block, blocks)), + }) + +const popNamespaceScope = (scopes: Array, node: ModuleDeclaration): void => + Option.match(moduleBlockOf(node), { onNone: () => undefined, onSome: () => closeScope(scopes) }) + +const openNamespaceScope = ( + scopes: Array, + pathsByStart: HashMap.HashMap, + blocksByPath: MergedNamespaceBlocks, + node: ModuleDeclaration, +): void => + Option.match(HashMap.get(pathsByStart, node.start), { + onNone: () => pushNamespaceScope(scopes, namespaceBlocksOf(node), node), + onSome: (path) => pushNamespaceScope(scopes, namespaceBlocksAt(blocksByPath, path, node), node), + }) + +type ConditionalScope = { + readonly start: number + readonly end: number + readonly extendsStart: number + readonly extendsEnd: number + readonly types: Array + readonly values: ReadonlyArray +} + +const openConditional = (scopes: Array, conditionals: Array, node: TSConditionalType): void => { + const scope: ConditionalScope = { + start: node.start, + end: node.trueType.end, + extendsStart: node.extendsType.start, + extendsEnd: node.extendsType.end, + types: [], + values: [], + } + scopes.push(scope) + conditionals.push(scope) +} + +const closeConditional = (scopes: Array, conditionals: Array): void => { + scopes.pop() + conditionals.pop() +} + +const bindsInfer = (scope: ConditionalScope, at: number): boolean => at >= scope.extendsStart && at < scope.extendsEnd + +const bindInfer = (conditionals: ReadonlyArray, node: TSInferType): void => + Option.match(Arr.findLast(conditionals, (scope) => bindsInfer(scope, node.start)), { + onNone: () => undefined, + onSome: (scope) => void scope.types.push(node.typeParameter.name.name), + }) + +const scanOf = (program: Program): ReferenceScan => { + const references: Array = [] + const scopes: Array = [] + const conditionals: Array = [] + const namespacePass = namespacePassOf(program) + const blocksByPath = mergedBlocksByPath(namespacePass.blocks) + const record = (node: NameNode | null, start: number, space: NameSpace): void => + Option.match(rootNameOf(node), { onNone: () => undefined, onSome: (name) => addReference(references, scopes, name, start, space) }) + const enter = (node: ScopeCarrier): void => openScope(scopes, node) + const exit = (): void => closeScope(scopes) + new Visitor({ + TSTypeReference: (node: TSTypeReference) => record(node.typeName, node.start, 'type'), + TSTypeQuery: (node: TSTypeQuery) => record(node.exprName, node.start, 'value'), + TSInterfaceHeritage: (node: TSInterfaceHeritage) => record(node.expression, node.start, 'type'), + TSClassImplements: (node: TSClassImplements) => record(node.expression, node.start, 'type'), + ClassDeclaration: (node: Class) => { + record(node.superClass, node.start, 'value') + enter(node) + }, + 'ClassDeclaration:exit': exit, + ClassExpression: enter, + 'ClassExpression:exit': exit, + FunctionDeclaration: enter, + 'FunctionDeclaration:exit': exit, + FunctionExpression: enter, + 'FunctionExpression:exit': exit, + ArrowFunctionExpression: enter, + 'ArrowFunctionExpression:exit': exit, + TSDeclareFunction: enter, + 'TSDeclareFunction:exit': exit, + TSEmptyBodyFunctionExpression: enter, + 'TSEmptyBodyFunctionExpression:exit': exit, + TSInterfaceDeclaration: enter, + 'TSInterfaceDeclaration:exit': exit, + TSTypeAliasDeclaration: enter, + 'TSTypeAliasDeclaration:exit': exit, + TSMethodSignature: enter, + 'TSMethodSignature:exit': exit, + TSCallSignatureDeclaration: enter, + 'TSCallSignatureDeclaration:exit': exit, + TSConstructSignatureDeclaration: enter, + 'TSConstructSignatureDeclaration:exit': exit, + TSFunctionType: enter, + 'TSFunctionType:exit': exit, + TSConstructorType: enter, + 'TSConstructorType:exit': exit, + TSModuleDeclaration: (node: ModuleDeclaration) => openNamespaceScope(scopes, namespacePass.pathsByStart, blocksByPath, node), + 'TSModuleDeclaration:exit': (node: ModuleDeclaration) => popNamespaceScope(scopes, node), + TSMappedType: (node: TSMappedType) => openNamedScope(scopes, node), + 'TSMappedType:exit': exit, + TSConditionalType: (node: TSConditionalType) => openConditional(scopes, conditionals, node), + 'TSConditionalType:exit': () => closeConditional(scopes, conditionals), + TSInferType: (node: TSInferType) => bindInfer(conditionals, node), + }).visit(program) + return { references } +} + +const scansOf = (graph: DeclarationGraph): HashMap.HashMap => + HashMap.fromIterable(Arr.map(Arr.fromIterable(HashMap.values(graph.files)), (scope): readonly [string, ReferenceScan] => [scope.path, scanOf(scope.program)])) + +type Resolution = { + readonly graph: DeclarationGraph + readonly scans: HashMap.HashMap +} + +const resolutionOf = (graph: DeclarationGraph): Resolution => ({ graph, scans: scansOf(graph) }) + +const scanFor = (resolution: Resolution, file: string): ReferenceScan => + Option.getOrElse(HashMap.get(resolution.scans, file), () => EMPTY_SCAN) + +const statementEndOf = (scope: FileScope, statementStart: number): number => + Option.getOrElse( + Option.map(Arr.findFirst(scope.program.body, (statement) => statement.start === statementStart), (statement) => statement.end), + () => statementStart, + ) + +const referencesWithin = (scan: ReferenceScan, scope: FileScope, declaration: Declaration): ReadonlyArray => + Arr.filter(scan.references, (reference) => + reference.start >= declaration.statementStart && reference.start < statementEndOf(scope, declaration.statementStart)) + +const referenceNames = (scan: ReferenceScan, scope: FileScope, declaration: Declaration): ReadonlyArray => + Arr.map(referencesWithin(scan, scope, declaration), (reference) => reference.name) + +type ExportedItem = { + readonly file: string + readonly name: string + readonly namePath: string + readonly declarations: ReadonlyArray + readonly exportNames: ReadonlyArray +} + +const itemKey = (file: string, name: string): string => `${file}\u0000${name}` + +const pathOf = (prefix: string, name: string): string => (prefix === '' ? name : `${prefix}.${name}`) + +const itemAt = (file: string, name: string, namePath: string, declarations: ReadonlyArray, exportName: string): ExportedItem => ({ + file, + name, + namePath, + declarations, + exportNames: [exportName], +}) + +const symbolItems = (graph: DeclarationGraph, seen: HashSet.HashSet, file: string, prefix: string, name: string, symbol: ResolvedSymbol): ReadonlyArray => + Match.value(symbol).pipe( + Match.when({ kind: 'local' }, (local) => [itemAt(local.file, local.name, pathOf(prefix, name), local.declarations, name)]), + Match.when({ kind: 'namespace' }, (namespace) => itemsInFile(graph, HashSet.add(seen, file), namespace.file, pathOf(prefix, name))), + Match.orElse(() => []), + ) + +const itemsOfName = (graph: DeclarationGraph, seen: HashSet.HashSet, file: string, prefix: string, name: string): ReadonlyArray => + Option.match(exportedSymbol(graph, file, name), { + onNone: () => [], + onSome: (symbol) => symbolItems(graph, seen, file, prefix, name, symbol), + }) + +const itemsInFile = (graph: DeclarationGraph, seen: HashSet.HashSet, file: string, prefix: string): ReadonlyArray => + HashSet.has(seen, file) + ? [] + : Arr.flatMap(exportedNames(graph, file), (name) => itemsOfName(graph, HashSet.add(seen, file), file, prefix, name)) + +const itemList = (graph: DeclarationGraph, entryFile: string): ReadonlyArray => itemsInFile(graph, HashSet.empty(), entryFile, '') + +const intoItem = (existing: Option.Option, item: ExportedItem): ExportedItem => + Option.match(existing, { + onNone: () => item, + onSome: (known) => ({ ...known, exportNames: Arr.dedupe([...known.exportNames, ...item.exportNames]) }), + }) + +const mergeByKey = (items: ReadonlyArray): HashMap.HashMap => + Arr.reduce(items, HashMap.empty(), (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 => ({ + messageId, + entryPoint, + item, + symbol, +}) + +const untagged = (graph: DeclarationGraph, item: ExportedItem): ReadonlyArray => + Arr.filter(item.declarations, (declaration) => Option.isNone(tagOf(graph, item.file, declaration))) + +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 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 allTagged = (maxTag: ReleaseTag, tags: ReadonlyArray): boolean => Arr.every(tags, (tag) => tag === maxTag) + +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 effectiveTagsOf = (graph: DeclarationGraph, item: ExportedItem): ReadonlyArray => + Arr.map(item.declarations, (declaration) => effectiveTagOf(tagOf(graph, item.file, declaration))) + +const maxTagOf = (tags: ReadonlyArray): ReleaseTag => + Arr.reduce(tags, Option.getOrElse(Arr.head(tags), (): ReleaseTag => 'public'), (max: ReleaseTag, tag: ReleaseTag) => + RELEASE_TAG_RANK[tag] > RELEASE_TAG_RANK[max] ? tag : max) + +const maxTagIn = (graph: DeclarationGraph, item: ExportedItem): ReleaseTag => maxTagOf(effectiveTagsOf(graph, item)) + +const morePublic = (declaring: ReleaseTag, referenced: ReleaseTag): boolean => RELEASE_TAG_RANK[declaring] > RELEASE_TAG_RANK[referenced] + +const declaringTag = (graph: DeclarationGraph, file: string, declaration: Declaration): ReleaseTag => effectiveTagOf(tagOf(graph, file, declaration)) + +const incompatibleMessages = ( + graph: DeclarationGraph, + entry: EntryDeclaration, + item: ExportedItem, + 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)] + : [] + +const localMessages = ( + resolution: Resolution, + entry: EntryDeclaration, + consumable: HashMap.HashMap, + item: ExportedItem, + 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), + }) + +const resolvedNameMessages = ( + resolution: Resolution, + entry: EntryDeclaration, + consumable: HashMap.HashMap, + item: ExportedItem, + declaration: Declaration, + symbol: ResolvedSymbol, +): ReadonlyArray => + Match.value(symbol).pipe( + Match.when({ kind: 'local' }, (local) => localMessages(resolution, entry, consumable, item, 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), { + onNone: () => [], + onSome: (symbol) => resolvedNameMessages(resolution, entry, consumable, item, 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), { + onNone: () => [], + onSome: (scope) => + Arr.flatMap(referenceNames(scanFor(resolution, item.file), scope, declaration), (name) => + nameMessages(resolution, entry, consumable, item, 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 itemMessages = ( + resolution: Resolution, + entry: EntryDeclaration, + consumable: HashMap.HashMap, + item: ExportedItem, +): ReadonlyArray => { + const tags = effectiveTagsOf(resolution.graph, item) + const maxTag = maxTagOf(tags) + return [ + ...missingTagMessages(resolution.graph, entry, item), + ...underscoreMessages(maxTag, entry, item), + ...mixedMessages(entry, item, maxTag, tags), + ...referenceMessages(resolution, entry, consumable, item), + ] +} + +const entryMessages = (resolution: Resolution, 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)) +} + +const messageKey = (message: AnalysisMessage): string => [message.messageId, message.entryPoint, message.item, message.symbol].join('\u0000') + +const deduped = (messages: ReadonlyArray): ReadonlyArray => + Arr.dedupeWith(messages, (left, right) => messageKey(left) === messageKey(right)) + +const analysisMessageOrder: 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 messagesFor = (graph: DeclarationGraph): ReadonlyArray => { + const resolution = resolutionOf(graph) + return Arr.sort(deduped(Arr.flatMap(graph.entryPoints, (entry) => entryMessages(resolution, entry))), analysisMessageOrder) +} + +export const messagesOfGraph = (graph: DeclarationGraph): Effect.Effect, UnsupportedDeclaration> => + Effect.succeed(messagesFor(graph)) + +export const resolveMessages = (declarations: Declarations): Effect.Effect, UnsupportedDeclaration> => + Effect.map(declarationGraph(declarations), messagesFor) diff --git a/packages/api-extractor/src/front-end/route.schema.ts b/packages/api-extractor/src/front-end/route.schema.ts new file mode 100644 index 0000000..fc91383 --- /dev/null +++ b/packages/api-extractor/src/front-end/route.schema.ts @@ -0,0 +1,13 @@ +import { Schema } from 'effect' +import { Diagnostic } from './declarations.schema.ts' + +export const FastPath: Schema.TaggedStruct<'FastPath', {}> = Schema.TaggedStruct('FastPath', {}) +export type FastPath = typeof FastPath.Type + +export const Fallback: Schema.TaggedStruct<'Fallback', { readonly diagnostics: Schema.$Array }> = Schema.TaggedStruct('Fallback', { + diagnostics: Schema.Array(Diagnostic), +}) +export type Fallback = typeof Fallback.Type + +export const Route: Schema.Union = Schema.Union([FastPath, Fallback]) +export type Route = typeof Route.Type diff --git a/packages/api-extractor/src/front-end/type-expr-of.ts b/packages/api-extractor/src/front-end/type-expr-of.ts new file mode 100644 index 0000000..ba6d56c --- /dev/null +++ b/packages/api-extractor/src/front-end/type-expr-of.ts @@ -0,0 +1,572 @@ +import { Array as Arr, Effect, HashMap, Match, Option } from 'effect' +import type { + AccessorProperty, + ClassElement, + Expression, + MethodDefinition, + ParamPattern, + PropertyDefinition, + PropertyKey, + TSAccessibility, + TSCallSignatureDeclaration, + TSClassImplements, + TSConstructSignatureDeclaration, + TSIndexSignature, + TSInterfaceHeritage, + TSLiteralType, + TSMethodSignature, + TSPropertySignature, + TSSignature, + TSTupleElement, + TSType, + TSTypeAnnotation, + TSTypeLiteral, + TSTypeOperator, + TSTypeParameter, + TSTypeParameterDeclaration, + TSTypeParameterInstantiation, +} from 'oxc-parser' +import { normaliseType, type KeywordName, type Member, type Parameter, type Signature, type TypeExpr, type TypeParameter } from '../model/type-expr.schema.ts' +import { + expressionPath, + importSegments, + KEYWORD_NODE_NAMES, + keywordNameOf, + literalTextOf, + nameSegments, + propertyKeyName, + typeParameterNames, + TypePrinting, + type TypeContext, +} from './canonical-type-text.ts' +import { refuse } from './declaration-graph.ts' +import { UnsupportedDeclaration } from './front-end-error.schema.ts' + +const { bindingName, canonicalTypeText, inValueSpace, referenceTargetFor, withNamesInScope } = TypePrinting + +const unknownKeyword: TypeExpr = { kind: 'keyword', name: 'unknown' } + +const moduleField = (module: Option.Option): { readonly module?: string } => + Option.match(module, { onNone: () => ({}), onSome: (value) => ({ module: value }) }) + +const referenceExpr = (name: string, module: Option.Option, args: ReadonlyArray): TypeExpr => ({ + kind: 'reference', + name, + ...moduleField(module), + args, +}) + +const textExpr = (context: TypeContext, node: TSType): Effect.Effect => + Effect.map(canonicalTypeText(context, node), (text): TypeExpr => ({ kind: 'text', text })) + +const propertyKeyNameOf = (key: PropertyKey): Option.Option => + Match.value(key).pipe( + Match.when({ type: 'PrivateIdentifier' }, () => Option.none()), + Match.orElse(() => propertyKeyName(key)), + ) + +const typeArgumentsOf = ( + context: TypeContext, + instantiation: TSTypeParameterInstantiation | null | undefined, +): Effect.Effect, UnsupportedDeclaration> => + Option.match(Option.fromNullishOr(instantiation), { + onNone: () => Effect.succeed>([]), + onSome: (present) => Effect.forEach(present.params, (param) => typeExprOf(context, param)), + }) + +type ReferenceRequest = { + readonly targetContext: TypeContext + readonly segments: ReadonlyArray + readonly source: Option.Option +} + +const referenceTypeOf = ( + context: TypeContext, + request: ReferenceRequest, + typeArguments: TSTypeParameterInstantiation | null | undefined, +): Effect.Effect => + Effect.gen(function* () { + const target = yield* referenceTargetFor(request.targetContext, request.segments, request.source) + const args = yield* typeArgumentsOf(context, typeArguments) + return referenceExpr(target.name, target.module, args) + }) + +const annotationTypeOrDefault = ( + context: TypeContext, + annotation: TSTypeAnnotation | null | undefined, + fallback: KeywordName, +): Effect.Effect => + Option.match(Option.fromNullishOr(annotation), { + onNone: () => Effect.succeed({ kind: 'keyword', name: fallback }), + onSome: (present) => typeExprOf(context, present.typeAnnotation), + }) + +const constraintOf = (context: TypeContext, node: TSType | null): Effect.Effect, UnsupportedDeclaration> => + Option.match(Option.fromNullishOr(node), { + onNone: () => Effect.succeed({}), + onSome: (type) => Effect.map(typeExprOf(context, type), (constraint) => ({ constraint })), + }) + +const defaultOf = (context: TypeContext, node: TSType | null): Effect.Effect, UnsupportedDeclaration> => + Option.match(Option.fromNullishOr(node), { + onNone: () => Effect.succeed({}), + onSome: (type) => Effect.map(typeExprOf(context, type), (fallback) => ({ default: fallback })), + }) + +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 } + }) + +const typeParametersOf = ( + context: TypeContext, + declaration: TSTypeParameterDeclaration | null | undefined, +): Effect.Effect, UnsupportedDeclaration> => + Option.match(Option.fromNullishOr(declaration), { + onNone: () => Effect.succeed>([]), + onSome: (present) => { + const inner = withNamesInScope(context, typeParameterNames(present)) + return Effect.forEach(present.params, (parameter) => typeParameterOf(inner, parameter)) + }, + }) + +const parameterOf = (context: TypeContext, parameter: ParamPattern): Effect.Effect => + Match.value(parameter).pipe( + Match.when({ type: 'TSParameterProperty' }, (property) => parameterOf(context, property.parameter)), + Match.when({ type: 'RestElement' }, (rest) => + Effect.map(annotationTypeOrDefault(context, rest.typeAnnotation, 'any'), (type): Parameter => ({ + name: bindingName(rest.argument), + type, + optional: rest.optional ?? false, + rest: true, + }))), + Match.when({ type: 'Identifier' }, (identifier) => + Effect.map(annotationTypeOrDefault(context, identifier.typeAnnotation, 'any'), (type): Parameter => ({ + name: bindingName(identifier), + type, + optional: identifier.optional ?? false, + rest: false, + }))), + Match.orElse(() => refuse(context.file, 'a destructuring parameter')), + ) + +const parametersOf = (context: TypeContext, params: ReadonlyArray): Effect.Effect, UnsupportedDeclaration> => + Effect.forEach(params, (parameter) => parameterOf(context, parameter)) + +const signatureOf = ( + context: TypeContext, + typeParameters: TSTypeParameterDeclaration | null | undefined, + params: ReadonlyArray, + returnType: TSTypeAnnotation | null | undefined, +): Effect.Effect => + Effect.gen(function* () { + const inner = withNamesInScope(context, typeParameterNames(typeParameters)) + const bound = yield* typeParametersOf(inner, typeParameters) + const parameters = yield* parametersOf(inner, params) + const returns = yield* annotationTypeOrDefault(inner, returnType, 'any') + return { typeParameters: bound, parameters, returns } + }) + +const referenceToExpression = ( + context: TypeContext, + targetContext: TypeContext, + expression: Expression, + typeArguments: TSTypeParameterInstantiation | null | undefined, +): Effect.Effect => + Option.match(expressionPath(expression), { + onNone: () => refuse(context.file, `a ${expression.type} heritage clause`), + onSome: (segments) => referenceTypeOf(context, { targetContext, segments, source: Option.none() }, typeArguments), + }) + +const heritageOf = (context: TypeContext, heritage: TSInterfaceHeritage | TSClassImplements): Effect.Effect => + referenceToExpression(context, context, heritage.expression, heritage.typeArguments) + +const superClassOf = ( + context: TypeContext, + expression: Expression, + typeArguments: TSTypeParameterInstantiation | null | undefined, +): Effect.Effect => referenceToExpression(context, inValueSpace(context), expression, typeArguments) + +type DraftMethod = { + readonly kind: 'method' + readonly name: string + readonly optional: boolean + readonly signatures: ReadonlyArray +} + +type DraftAccessor = { + readonly kind: 'accessor' + readonly name: string + readonly get: Option.Option + readonly set: Option.Option +} + +type Draft = { readonly kind: 'member'; readonly member: Member } | DraftMethod | DraftAccessor + +const draftKey = (draft: Draft): Option.Option => + Match.value(draft).pipe( + Match.when({ kind: 'method' }, (method) => Option.some(`method\u0000${method.name}`)), + Match.when({ kind: 'accessor' }, (accessor) => Option.some(`accessor\u0000${accessor.name}`)), + Match.orElse(() => Option.none()), + ) + +const appendDraft = (existing: Draft, incoming: Draft): Draft => + Match.value(existing).pipe( + Match.when({ kind: 'method' }, (method) => + Match.value(incoming).pipe( + Match.when({ kind: 'method' }, (other) => ({ ...method, signatures: [...method.signatures, ...other.signatures] })), + Match.orElse(() => existing), + )), + Match.when({ kind: 'accessor' }, (accessor) => + Match.value(incoming).pipe( + Match.when({ kind: 'accessor' }, (other) => ({ + ...accessor, + get: Option.orElse(accessor.get, () => other.get), + set: Option.orElse(accessor.set, () => other.set), + })), + Match.orElse(() => existing), + )), + Match.orElse(() => existing), + ) + +type DraftState = { readonly ordered: ReadonlyArray; readonly index: HashMap.HashMap } + +const mergeDraft = (state: DraftState, draft: Draft): DraftState => + Option.match(draftKey(draft), { + onNone: () => ({ ordered: [...state.ordered, draft], index: state.index }), + onSome: (key) => + Option.match(HashMap.get(state.index, key), { + onNone: () => ({ ordered: [...state.ordered, draft], index: HashMap.set(state.index, key, state.ordered.length) }), + onSome: (position) => ({ + ordered: Arr.map(state.ordered, (existing, at) => (at === position ? appendDraft(existing, draft) : existing)), + index: state.index, + }), + }), + }) + +const accessorType = (draft: DraftAccessor): TypeExpr => + Option.match(draft.get, { + onSome: (signature) => signature.returns, + onNone: () => + Option.match(draft.set, { + onSome: (signature) => Option.getOrElse(Option.map(Arr.head(signature.parameters), (parameter) => parameter.type), () => unknownKeyword), + onNone: () => unknownKeyword, + }), + }) + +const finalise = (draft: Draft): Member => + Match.value(draft).pipe( + Match.when({ kind: 'member' }, (member) => member.member), + Match.when({ kind: 'method' }, (method) => ({ kind: 'method' as const, name: method.name, optional: method.optional, signatures: method.signatures })), + Match.when({ kind: 'accessor' }, (accessor) => ({ + kind: 'property' as const, + name: accessor.name, + optional: false, + readonly: Option.isNone(accessor.set), + type: accessorType(accessor), + })), + Match.exhaustive, + ) + +const assembleMembers = (drafts: ReadonlyArray): ReadonlyArray => + Arr.reduce(drafts, { ordered: [], index: HashMap.empty() }, mergeDraft).ordered.map(finalise) + +const memberPropertyOf = (context: TypeContext, member: TSPropertySignature): Effect.Effect => + Option.match(propertyKeyNameOf(member.key), { + onNone: () => refuse(context.file, `a ${member.key.type} member name`), + onSome: (name) => + Effect.map(annotationTypeOrDefault(context, member.typeAnnotation, 'any'), (type): Member => ({ + kind: 'property', + name, + optional: member.optional, + readonly: member.readonly, + type, + })), + }) + +const memberMethodDraftOf = (context: TypeContext, member: TSMethodSignature): Effect.Effect => + Option.match(propertyKeyNameOf(member.key), { + onNone: () => refuse(context.file, `a ${member.key.type} method name`), + onSome: (name) => + Effect.map(signatureOf(context, member.typeParameters, member.params, member.returnType), (signature): Draft => + Match.value(member.kind).pipe( + Match.when('get', () => ({ kind: 'accessor' as const, name, get: Option.some(signature), set: Option.none() })), + Match.when('set', () => ({ kind: 'accessor' as const, name, get: Option.none(), set: Option.some(signature) })), + Match.orElse(() => ({ kind: 'method' as const, name, optional: member.optional, signatures: [signature] })), + )), + }) + +const indexSignatureMemberOf = (context: TypeContext, member: TSIndexSignature): Effect.Effect => + Arr.match(member.parameters, { + onEmpty: () => refuse(context.file, 'an index signature without a parameter'), + onNonEmpty: ([parameter]) => + Effect.gen(function* () { + const key = yield* canonicalTypeText(context, parameter.typeAnnotation.typeAnnotation) + const type = yield* typeExprOf(context, member.typeAnnotation.typeAnnotation) + return { kind: 'property' as const, name: `[key: ${key}]`, optional: false, readonly: member.readonly, type } + }), + }) + +const callSignatureOf = (context: TypeContext, member: TSCallSignatureDeclaration): Effect.Effect => + Effect.map(signatureOf(context, member.typeParameters, member.params, member.returnType), (signature): Member => ({ kind: 'call', signature })) + +const constructSignatureOf = (context: TypeContext, member: TSConstructSignatureDeclaration): Effect.Effect => + Effect.map(signatureOf(context, member.typeParameters, member.params, member.returnType), (signature): Member => ({ + kind: 'method', + name: 'new', + optional: false, + signatures: [signature], + })) + +const memberDraftOf = (context: TypeContext, member: TSSignature): Effect.Effect => + Match.value(member).pipe( + Match.when({ type: 'TSPropertySignature' }, (property) => + Effect.map(memberPropertyOf(context, property), (built): Draft => ({ kind: 'member', member: built }))), + Match.when({ type: 'TSMethodSignature' }, (method) => memberMethodDraftOf(context, method)), + Match.when({ type: 'TSCallSignatureDeclaration' }, (call) => + Effect.map(callSignatureOf(context, call), (built): Draft => ({ kind: 'member', member: built }))), + Match.when({ type: 'TSIndexSignature' }, (index) => + Effect.map(indexSignatureMemberOf(context, index), (built): Draft => ({ kind: 'member', member: built }))), + Match.when({ type: 'TSConstructSignatureDeclaration' }, (construct) => + Effect.map(constructSignatureOf(context, construct), (built): Draft => ({ kind: 'member', member: built }))), + Match.orElse(() => refuse(context.file, 'an unsupported member')), + ) + +const membersOf = (context: TypeContext, members: ReadonlyArray): Effect.Effect, UnsupportedDeclaration> => + Effect.map(Effect.forEach(members, (member) => memberDraftOf(context, member)), assembleMembers) + +const ABSTRACT_ELEMENT_TYPES: ReadonlyArray = [ + 'TSAbstractMethodDefinition', + 'TSAbstractPropertyDefinition', + 'TSAbstractAccessorProperty', +] + +const isAbstractElement = (type: ClassElement['type']): boolean => + Match.value(type).pipe( + Match.when(Match.is(...ABSTRACT_ELEMENT_TYPES), () => true), + Match.orElse(() => false), + ) + +const accessibilityWord = (accessibility: TSAccessibility | null | undefined): ReadonlyArray => + Match.value(accessibility).pipe( + Match.when('private', () => ['private']), + Match.when('protected', () => ['protected']), + Match.orElse(() => []), + ) + +const flagWord = (flag: boolean, word: string): ReadonlyArray => (flag ? [word] : []) + +const modifierWords = ( + accessibility: TSAccessibility | null | undefined, + isStatic: boolean, + isAbstract: boolean, +): ReadonlyArray => [...accessibilityWord(accessibility), ...flagWord(isStatic, 'static'), ...flagWord(isAbstract, 'abstract')] + +const prefixedName = (words: ReadonlyArray, name: string): string => + Match.value(Arr.join(words, ' ')).pipe( + Match.when('', () => name), + Match.orElse((modifiers) => `${modifiers} ${name}`), + ) + +const accessibleName = ( + key: PropertyKey, + accessibility: TSAccessibility | null | undefined, + isStatic: boolean, + isAbstract: boolean, +): Option.Option => + Match.value(accessibility).pipe( + Match.when('private', () => Option.none()), + Match.orElse(() => + Option.map(propertyKeyNameOf(key), (name) => prefixedName(modifierWords(accessibility, isStatic, isAbstract), name)), + ), + ) + +const classMethodName = (method: MethodDefinition): Option.Option => + Match.value(method).pipe( + Match.when({ kind: 'constructor' }, (constructor) => + Option.some(prefixedName(accessibilityWord(constructor.accessibility), 'constructor')), + ), + Match.orElse(() => accessibleName(method.key, method.accessibility, method.static, isAbstractElement(method.type))), + ) + +const classMethodDraftOf = (context: TypeContext, method: MethodDefinition): Effect.Effect, UnsupportedDeclaration> => + Option.match(classMethodName(method), { + onNone: () => Effect.succeedNone, + onSome: (name) => + Effect.asSome(Effect.map(signatureOf(context, method.value.typeParameters, method.value.params, method.value.returnType), (signature): Draft => + Match.value(method.kind).pipe( + Match.when('constructor', () => ({ kind: 'method' as const, name, optional: false, signatures: [signature] })), + Match.when('method', () => ({ kind: 'method' as const, name, optional: method.optional ?? false, signatures: [signature] })), + Match.when('get', () => ({ kind: 'accessor' as const, name, get: Option.some(signature), set: Option.none() })), + Match.orElse(() => ({ kind: 'accessor' as const, name, get: Option.none(), set: Option.some(signature) })), + ))), + }) + +const optionalFlag = (optional: boolean | undefined): boolean => optional ?? false +const readonlyFlag = (readonly: boolean | undefined): boolean => readonly ?? false + +const propertyDraft = (name: string, property: PropertyDefinition, type: TypeExpr): Draft => ({ + kind: 'member', + member: { + kind: 'property', + name, + optional: optionalFlag(property.optional), + readonly: readonlyFlag(property.readonly), + type, + }, +}) + +const classPropertyDraftOf = (context: TypeContext, property: PropertyDefinition): Effect.Effect, UnsupportedDeclaration> => + Option.match(accessibleName(property.key, property.accessibility, property.static, isAbstractElement(property.type)), { + onNone: () => Effect.succeedNone, + onSome: (name) => + Effect.asSome(Effect.map(annotationTypeOrDefault(context, property.typeAnnotation, 'any'), (type) => propertyDraft(name, property, type))), + }) + +const classAccessorDraftOf = (context: TypeContext, accessor: AccessorProperty): Effect.Effect, UnsupportedDeclaration> => + Option.match(accessibleName(accessor.key, accessor.accessibility, accessor.static, isAbstractElement(accessor.type)), { + onNone: () => Effect.succeedNone, + onSome: (name) => + Effect.asSome(Effect.map(annotationTypeOrDefault(context, accessor.typeAnnotation, 'any'), (type): Draft => ({ + kind: 'member', + member: { kind: 'property', name, optional: false, readonly: false, type }, + }))), + }) + +const classElementDraftOf = (context: TypeContext, element: ClassElement): Effect.Effect, UnsupportedDeclaration> => + Match.value(element).pipe( + Match.when({ type: 'StaticBlock' }, () => Effect.succeedNone), + Match.when({ type: Match.is('MethodDefinition', 'TSAbstractMethodDefinition') }, (method) => classMethodDraftOf(context, method)), + Match.when({ type: Match.is('PropertyDefinition', 'TSAbstractPropertyDefinition') }, (property) => classPropertyDraftOf(context, property)), + Match.when({ type: Match.is('AccessorProperty', 'TSAbstractAccessorProperty') }, (accessor) => classAccessorDraftOf(context, accessor)), + Match.when({ type: 'TSIndexSignature' }, (index) => + Effect.asSome(Effect.map(indexSignatureMemberOf(context, index), (member): Draft => ({ kind: 'member', member })))), + Match.orElse(() => refuse(context.file, 'an unsupported class member')), + ) + +const classMembersOf = ( + context: TypeContext, + elements: ReadonlyArray, +): Effect.Effect, UnsupportedDeclaration> => + Effect.map( + Effect.forEach(elements, (element) => classElementDraftOf(context, element)), + (drafts) => assembleMembers(Arr.getSomes(drafts)), + ) + +const plainTupleElement = (element: TSTupleElement): Option.Option => + Match.value(element).pipe( + Match.when({ type: Match.is('TSNamedTupleMember', 'TSOptionalType', 'TSRestType') }, () => Option.none()), + Match.orElse((type) => Option.some(type)), + ) + +const plainElements = (elements: ReadonlyArray): Option.Option> => { + const mapped = elements.map(plainTupleElement) + return Arr.every(mapped, Option.isSome) ? Option.some(Arr.getSomes(mapped)) : Option.none() +} + +const readonlyOperatorExpr = (context: TypeContext, node: TSTypeOperator): Effect.Effect => + Match.value(node.typeAnnotation).pipe( + Match.when({ type: 'TSArrayType' }, (array) => + Effect.map(typeExprOf(context, array.elementType), (element): TypeExpr => ({ kind: 'array', readonly: true, element }))), + Match.when({ type: 'TSTupleType' }, (tuple) => + Option.match(plainElements(tuple.elementTypes), { + onNone: () => textExpr(context, node), + onSome: (types) => + Effect.map(Effect.forEach(types, (type) => typeExprOf(context, type)), (resolved): TypeExpr => ({ kind: 'tuple', readonly: true, elements: resolved })), + })), + Match.orElse(() => textExpr(context, node)), + ) + +const operatorExpr = (context: TypeContext, node: TSTypeOperator): Effect.Effect => + Match.value(node.operator).pipe( + Match.when('readonly', () => readonlyOperatorExpr(context, node)), + Match.orElse(() => textExpr(context, node)), + ) + +const objectMemberIsPlain = (member: TSSignature): boolean => + Match.value(member.type).pipe( + Match.when(Match.is('TSPropertySignature', 'TSMethodSignature', 'TSCallSignatureDeclaration'), () => true), + Match.orElse(() => false), + ) + +const objectExpr = (context: TypeContext, literal: TSTypeLiteral): Effect.Effect => + Arr.every(literal.members, objectMemberIsPlain) + ? Effect.map(membersOf(context, literal.members), (members): TypeExpr => normaliseType({ kind: 'object', members })) + : textExpr(context, literal) + +const unionExpr = ( + context: TypeContext, + kind: 'union' | 'intersection', + members: ReadonlyArray, +): Effect.Effect => + Effect.map(Effect.forEach(members, (member) => typeExprOf(context, member)), (resolved): TypeExpr => normaliseType({ kind, members: resolved })) + +const typeExprOf = (context: TypeContext, node: TSType): Effect.Effect => + Match.value(node).pipe( + Match.when({ type: Match.is(...KEYWORD_NODE_NAMES) }, (keyword) => + Effect.succeed({ kind: 'keyword', name: Option.getOrThrow(keywordNameOf(keyword.type)) })), + Match.when({ type: 'TSThisType' }, () => Effect.succeed(referenceExpr('this', Option.none(), []))), + Match.when({ type: 'TSLiteralType' }, (literal) => literalExpr(context, literal)), + Match.when({ type: 'TSTypeReference' }, (reference) => + referenceTypeOf(context, { targetContext: context, segments: nameSegments(reference.typeName), source: Option.none() }, reference.typeArguments)), + Match.when({ type: 'TSImportType' }, (imported) => + Arr.match(importSegments(imported.qualifier), { + onEmpty: () => textExpr(context, imported), + onNonEmpty: (segments) => + referenceTypeOf(context, { targetContext: context, segments, source: Option.some(imported.source.value) }, imported.typeArguments), + })), + Match.when({ type: 'TSUnionType' }, (union) => unionExpr(context, 'union', union.types)), + Match.when({ type: 'TSIntersectionType' }, (intersection) => unionExpr(context, 'intersection', intersection.types)), + Match.when({ type: 'TSParenthesizedType' }, (parenthesised) => typeExprOf(context, parenthesised.typeAnnotation)), + Match.when({ type: 'TSArrayType' }, (array) => + Effect.map(typeExprOf(context, array.elementType), (element): TypeExpr => ({ kind: 'array', readonly: false, element }))), + Match.when({ type: 'TSTypeOperator' }, (operator) => operatorExpr(context, operator)), + Match.when({ type: 'TSTupleType' }, (tuple) => + Option.match(plainElements(tuple.elementTypes), { + onNone: () => textExpr(context, tuple), + onSome: (types) => + Effect.map(Effect.forEach(types, (type) => typeExprOf(context, type)), (elements): TypeExpr => ({ kind: 'tuple', readonly: false, elements })), + })), + Match.when({ type: 'TSFunctionType' }, (function_) => + Effect.map(signatureOf(context, function_.typeParameters, function_.params, function_.returnType), (signature): TypeExpr => ({ kind: 'function', signature }))), + Match.when({ type: 'TSTypeLiteral' }, (literal) => objectExpr(context, literal)), + Match.orElse((other) => textExpr(context, other)), + ) + +const literalExpr = (context: TypeContext, node: TSLiteralType): Effect.Effect => + Option.match(literalTextOf(node.literal), { + onNone: () => textExpr(context, node), + onSome: (text) => Effect.succeed({ kind: 'literal', text }), + }) + +export const TypeExprs: { + readonly typeExprOf: (context: TypeContext, node: TSType) => Effect.Effect + readonly typeParametersOf: ( + context: TypeContext, + declaration: TSTypeParameterDeclaration | null | undefined, + ) => Effect.Effect, UnsupportedDeclaration> + readonly signatureOf: ( + context: TypeContext, + typeParameters: TSTypeParameterDeclaration | null | undefined, + params: ReadonlyArray, + returnType: TSTypeAnnotation | null | undefined, + ) => Effect.Effect + readonly superClassOf: ( + context: TypeContext, + expression: Expression, + typeArguments: TSTypeParameterInstantiation | null | undefined, + ) => Effect.Effect + readonly heritageOf: (context: TypeContext, heritage: TSInterfaceHeritage | TSClassImplements) => Effect.Effect + readonly membersOf: (context: TypeContext, members: ReadonlyArray) => Effect.Effect, UnsupportedDeclaration> + readonly classMembersOf: (context: TypeContext, elements: ReadonlyArray) => Effect.Effect, UnsupportedDeclaration> +} = { + typeExprOf: typeExprOf, + typeParametersOf: typeParametersOf, + signatureOf: signatureOf, + superClassOf: superClassOf, + heritageOf: heritageOf, + membersOf: membersOf, + classMembersOf: classMembersOf, +} diff --git a/packages/api-extractor/src/front-end/typescript-7-compiler.ts b/packages/api-extractor/src/front-end/typescript-7-compiler.ts new file mode 100644 index 0000000..f39432c --- /dev/null +++ b/packages/api-extractor/src/front-end/typescript-7-compiler.ts @@ -0,0 +1,390 @@ +import { Array as Arr, Effect, FileSystem, HashMap, HashSet, Layer, Option, Path, type Scope, Stream } from 'effect' +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 { CompilerEmitter, type CompilerEmitterId, type DeclarationEmitter } from './declaration-emitter.service.ts' +import type { Declarations, Diagnostic, PackageSource, SourceEntryPoint } from './declarations.schema.ts' +import { CompilerRejected, UnsupportedDeclaration } from './front-end-error.schema.ts' +import { isDeclarationFile, readPackageFile } from './package-file.ts' + +export const TYPESCRIPT_PRODUCER: string = `typescript@${typescriptManifest.version}` + +const TYPESCRIPT_MANIFEST_URL: string = import.meta.resolve('typescript/package.json') + +const TSC_SCRIPT: string = typescriptManifest.bin.tsc + +const DERIVED_CONFIG_NAME = 'tsconfig.json' + +const KEY_SEPARATOR = '\u0000' + +const ENTRY_SEPARATOR = ' and ' + +export type CompilerEmitPolicy = { + readonly noEmit: false + readonly declaration: true + readonly emitDeclarationOnly: true + readonly declarationMap: false + readonly sourceMap: false + readonly stripInternal: false + readonly isolatedDeclarations: false + readonly removeComments: false + readonly incremental: false + readonly composite: false + readonly tsBuildInfoFile: null + readonly rootDir: string + readonly outDir: string + readonly declarationDir: string +} + +export type CompilerEmitPolicyRequest = { + readonly workspaceRoot: string + readonly outDir: string +} + +export const compilerEmitPolicy: (request: CompilerEmitPolicyRequest) => CompilerEmitPolicy = ({ workspaceRoot, outDir }) => ({ + noEmit: false, + declaration: true, + emitDeclarationOnly: true, + declarationMap: false, + sourceMap: false, + stripInternal: false, + isolatedDeclarations: false, + removeComments: false, + incremental: false, + composite: false, + tsBuildInfoFile: null, + rootDir: workspaceRoot, + outDir, + declarationDir: outDir, +}) + +const ancestorsOf = (path: Path.Path, directory: string): ReadonlyArray => { + const parent = path.dirname(directory) + return parent === directory ? [directory] : [directory, ...ancestorsOf(path, parent)] +} + +const NODE_MODULES = 'node_modules' + +const MIRRORED_NODE_MODULES_SEGMENT = 'node_modules.mirrored' + +const mirroredSegment = (segment: string): string => (segment === NODE_MODULES ? MIRRORED_NODE_MODULES_SEGMENT : segment) + +const mirroredDirectory = (path: Path.Path, mirrorRoot: string, directory: string): string => + path.join(mirrorRoot, ...path.relative(path.parse(directory).root, directory).split(path.sep).map(mirroredSegment)) + +const looksUpNodeModules = (path: Path.Path, directory: string): boolean => path.basename(directory) !== NODE_MODULES + +const mirrorNodeModules = (mirrorRoot: string, directory: string): Effect.Effect => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem + const path = yield* Path.Path + const mirrored = mirroredDirectory(path, mirrorRoot, directory) + const nodeModules = path.join(directory, NODE_MODULES) + yield* fileSystem.makeDirectory(mirrored, { recursive: true }) + yield* Effect.when( + fileSystem.symlink(nodeModules, path.join(mirrored, NODE_MODULES)), + Effect.map(fileSystem.exists(nodeModules), (exists) => exists && looksUpNodeModules(path, directory)), + ) + }) + +const packageFileNames = (packageRoot: string, tsconfigPath: string): Effect.Effect>> => + Effect.acquireUseRelease( + Effect.sync(() => new API({ cwd: packageRoot })), + (api) => Effect.map(Effect.promise(() => api.parseConfigFile(tsconfigPath)), (parsed) => Option.some(parsed.fileNames)), + (api) => Effect.ignoreCause(Effect.promise(() => api.close())), + ).pipe(Effect.catchCause(() => Effect.succeedNone)) + +const rootFilesField = (fileNames: Option.Option>): { readonly files?: ReadonlyArray } => + Option.match(fileNames, { onNone: () => ({}), onSome: (files) => ({ files }) }) + +export type DerivedTsconfigRequest = { + readonly tsconfigPath: string + readonly packageRoot: string + readonly workspaceRoot: string + readonly outDir: string +} + +export const writeDerivedTsconfig = (request: DerivedTsconfigRequest): Effect.Effect => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem + const path = yield* Path.Path + const mirrorRoot = yield* fileSystem.makeTempDirectoryScoped({ prefix: 'api-extractor-tsconfig-' }) + const configDirectory = path.dirname(request.tsconfigPath) + yield* Effect.forEach(ancestorsOf(path, configDirectory), (directory) => mirrorNodeModules(mirrorRoot, directory), { discard: true }) + const derived = path.join(mirroredDirectory(path, mirrorRoot, configDirectory), DERIVED_CONFIG_NAME) + const fileNames = yield* packageFileNames(request.packageRoot, request.tsconfigPath) + yield* fileSystem.writeFileString( + derived, + JSON.stringify({ + extends: request.tsconfigPath, + compilerOptions: compilerEmitPolicy({ workspaceRoot: request.workspaceRoot, outDir: request.outDir }), + ...rootFilesField(fileNames), + }), + ) + return derived + }) + +type EmittedDeclaration = { readonly declared: string; readonly emittedFile: string } + +type CompilerRun = { + readonly derived: string + readonly exitCode: number + readonly outDir: string + readonly stderr: string + readonly stdout: string +} + +const drain = (stream: Stream.Stream): Effect.Effect => + Stream.decodeText(stream).pipe(Stream.mkString) + +const runCompiler = (source: PackageSource, tsc: string) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner + const outDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: 'api-extractor-tsc-' }) + const derived = yield* writeDerivedTsconfig({ tsconfigPath: source.tsconfigPath, packageRoot: source.packageRoot, workspaceRoot: source.workspaceRoot, outDir }) + const handle = yield* spawner.spawn(ChildProcess.make(process.execPath, [tsc, '-p', derived], { cwd: source.packageRoot })) + const [stdout, stderr] = yield* Effect.all([drain(handle.stdout), drain(handle.stderr)], { concurrency: 2 }) + const exitCode = yield* handle.exitCode + return { derived, exitCode, outDir, stderr, stdout } + }) + +const exitMessage = (exitCode: number, stdout: string, stderr: string): string => + Arr.match( + [stdout.trim(), stderr.trim()].filter((text) => text !== ''), + { + onEmpty: () => `\`tsc\` exited with code ${exitCode}`, + onNonEmpty: (texts) => [`\`tsc\` exited with code ${exitCode}`, ...texts].join('\n'), + }, + ) + +const exitDiagnostic = (run: CompilerRun): Diagnostic => ({ _tag: 'Note', message: exitMessage(run.exitCode, run.stdout, run.stderr) }) + +const indented = (text: string): string => text.replaceAll('\n', '\n ') + +const chainText = (diagnostic: ApiDiagnostic): string => + Arr.match(diagnostic.messageChain ?? [], { + onEmpty: () => diagnostic.text, + onNonEmpty: (chain) => `${diagnostic.text}\n ${chain.map((next) => indented(chainText(next))).join('\n ')}`, + }) + +const fileFieldOf = (path: Path.Path, packageRoot: string, fileName: Option.Option): { readonly file?: string } => + Option.match( + Option.flatMap(fileName, (name) => relativeWithin(path, packageRoot, path.resolve(packageRoot, name))), + { onNone: () => ({}), onSome: (file) => ({ file }) }, + ) + +const apiDiagnostic = (path: Path.Path, packageRoot: string, diagnostic: ApiDiagnostic): Diagnostic => ({ + _tag: 'Reported', + ...fileFieldOf(path, packageRoot, Option.fromNullishOr(diagnostic.fileName)), + code: diagnostic.code, + message: chainText(diagnostic), +}) + +const diagnosticKey = (diagnostic: ApiDiagnostic): string => + [diagnostic.fileName ?? '', diagnostic.pos, diagnostic.code, diagnostic.text].join(KEY_SEPARATOR) + +const isError = (diagnostic: ApiDiagnostic): boolean => diagnostic.category === DiagnosticCategory.Error + +const errorDiagnostics = (diagnostics: ReadonlyArray): ReadonlyArray => + Arr.dedupeWith(Arr.filter(diagnostics, isError), (left, right) => diagnosticKey(left) === diagnosticKey(right)) + +type DiagnosticGroup = (program: Program) => Promise> + +const diagnosticGroupsInReportingOrder: ReadonlyArray = [ + (program) => program.getConfigFileParsingDiagnostics(), + (program) => program.getSyntacticDiagnostics(), + (program) => program.getProgramDiagnostics(), + (program) => program.getGlobalDiagnostics(), + (program) => program.getSemanticDiagnostics(), + (program) => program.getDeclarationDiagnostics(), +] + +const gather = async (program: Program): Promise> => { + const collected: Array = [] + for (const group of diagnosticGroupsInReportingOrder) { + collected.push(...(await group(program))) + } + return collected +} + +const programOf = async (api: API, derived: string): Promise> => { + const snapshot = await api.updateSnapshot({ openProjects: [derived] }) + const project = snapshot.getProject(derived) + return project === undefined ? Option.none() : Option.some(project.program) +} + +const apiErrors = async (api: API, derived: string): Promise> => + Option.match(await programOf(api, derived), { + onNone: async () => [], + onSome: (program) => gather(program), + }) + +const collectApiErrors = (packageRoot: string, derived: string): Effect.Effect> => + Effect.acquireUseRelease( + Effect.sync(() => new API({ cwd: packageRoot })), + (api) => Effect.map(Effect.promise(() => apiErrors(api, derived)), errorDiagnostics), + (api) => Effect.ignoreCause(Effect.promise(() => api.close())), + ).pipe(Effect.catchCause(() => Effect.succeed>([]))) + +const refusal = (source: PackageSource, run: CompilerRun) => + Effect.gen(function* () { + const path = yield* Path.Path + const found = yield* collectApiErrors(source.packageRoot, run.derived) + const diagnostics = Arr.match(found, { + onEmpty: () => [exitDiagnostic(run)], + onNonEmpty: (errors) => errors.map((diagnostic) => apiDiagnostic(path, source.packageRoot, diagnostic)), + }) + return yield* new CompilerRejected({ diagnostics }) + }) + +const packageDeclarationOf = (path: Path.Path, declaredRoot: string, emittedFile: string): Option.Option => + Option.map(relativeWithin(path, declaredRoot, emittedFile), (declared) => ({ declared, emittedFile })) + +const emittedDeclarationFiles = (path: Path.Path, fileSystem: FileSystem.FileSystem, outDir: string, declaredRoot: string) => + Effect.map( + fileSystem.readDirectory(outDir, { recursive: true }), + (names): ReadonlyArray => + Arr.getSomes(names.map((name) => path.join(outDir, name)).filter(isDeclarationFile).map((file) => packageDeclarationOf(path, declaredRoot, file))), + ) + +const missingEntryDiagnostic = (declarationFile: string): Diagnostic => ({ + _tag: 'Note', + file: declarationFile, + message: 'the compiler emitted no declaration for this entry point', +}) + +const unsupportedImport = (unresolved: UnresolvedImport): UnsupportedDeclaration => + new UnsupportedDeclaration({ file: unresolved.file, construct: `the import \`${unresolved.specifier}\` names no declaration file inside the package` }) + +const handWrittenOr = (source: PackageSource, realRoot: string, emitted: HashMap.HashMap) => (declarationFile: string) => + Option.match(HashMap.get(emitted, declarationFile), { + onNone: () => readPackageFile({ packageRoot: source.packageRoot, realRoot, file: declarationFile }), + onSome: Effect.succeedSome, + }) + +const declarationsOf = ( + source: PackageSource, + entryPoints: ReadonlyArray, + load: (declarationFile: string) => Effect.Effect, PlatformError, FileSystem.FileSystem | Path.Path>, +): Effect.Effect => + Effect.flatMap( + closeDeclarations({ + entryDeclarations: entryPoints.map((entry) => entry.declarationFile), + load, + }), + (report) => + Arr.match(report.unresolvedImports, { + onEmpty: (): Effect.Effect => + Effect.succeed({ + producer: TYPESCRIPT_PRODUCER, + package: { name: source.name, version: source.version }, + entryPoints, + files: report.files, + }), + onNonEmpty: ([unresolved]) => Effect.fail(unsupportedImport(unresolved)), + }), + ) + +const handWrittenEntries = (entryPoints: ReadonlyArray): HashSet.HashSet => + HashSet.fromIterable(entryPoints.filter((entry) => isDeclarationFile(entry.sourceFile)).map((entry) => entry.sourceFile)) + +const compilerEntryDeclarations = (entryPoints: ReadonlyArray): ReadonlyArray => + entryPointDeclarations(entryPoints.filter((entry) => !isDeclarationFile(entry.sourceFile))) + +const declarationsFrom = (source: PackageSource, run: CompilerRun) => + Effect.gen(function* () { + const path = yield* Path.Path + const fileSystem = yield* FileSystem.FileSystem + const declaredRoot = path.join(run.outDir, path.relative(source.workspaceRoot, source.packageRoot)) + const declared = yield* emittedDeclarationFiles(path, fileSystem, run.outDir, declaredRoot) + const handWritten = handWrittenEntries(source.entryPoints) + const emitted = HashMap.filter( + HashMap.fromIterable( + yield* Effect.forEach(declared, (declaration) => + Effect.map(fileSystem.readFileString(declaration.emittedFile), (text) => [declaration.declared, text] as const), + ), + ), + (_text, declarationFile) => !HashSet.has(handWritten, declarationFile), + ) + 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))), + onNonEmpty: (missing) => Effect.fail(new CompilerRejected({ diagnostics: missing.map(missingEntryDiagnostic) })), + }) + }) + +const accepted = (source: PackageSource, run: CompilerRun) => + Option.match(Option.liftPredicate(run.exitCode, (code) => code === 0), { + onNone: () => refusal(source, run), + onSome: () => declarationsFrom(source, run), + }) + +const missingSourceDiagnostic = (entry: SourceEntryPoint, reason: string): Diagnostic => ({ + _tag: 'Note', + file: entry.sourceFile, + message: `the entry point ${entry.exportPath} names the source file ${entry.sourceFile}, which ${reason}`, +}) + +const entryProblem = (source: PackageSource) => + (entry: SourceEntryPoint): Effect.Effect, PlatformError, FileSystem.FileSystem | Path.Path> => + Effect.gen(function* () { + const path = yield* Path.Path + const fileSystem = yield* FileSystem.FileSystem + const root = yield* fileSystem.realPath(source.packageRoot) + const resolved = yield* Effect.option(fileSystem.realPath(path.resolve(source.packageRoot, entry.sourceFile))) + return Option.match(resolved, { + onNone: () => Option.some(missingSourceDiagnostic(entry, 'does not exist')), + onSome: (real) => + Option.match(relativeWithin(path, root, real), { + onSome: () => Option.none(), + onNone: () => Option.some(missingSourceDiagnostic(entry, 'resolves outside the package')), + }), + }) + }) + +const sharedDeclarationDiagnostic = (shared: SharedDeclaration): Diagnostic => ({ + _tag: 'Note', + file: shared.declarationFile, + message: `the entry points ${shared.exportPaths.join(ENTRY_SEPARATOR)} share the declaration file ${shared.declarationFile}`, +}) + +const checked = (source: PackageSource): Effect.Effect => + Effect.gen(function* () { + const problems = Arr.getSomes(yield* Effect.forEach(source.entryPoints, entryProblem(source))) + return yield* Arr.match(problems, { + onEmpty: () => + Option.match(Option.map(Arr.head(sharedDeclarations(entryPointDeclarations(source.entryPoints))), sharedDeclarationDiagnostic), { + onNone: () => Effect.void, + onSome: (diagnostic) => Effect.fail(new CompilerRejected({ diagnostics: [diagnostic] })), + }), + onNonEmpty: (diagnostics) => Effect.fail(new CompilerRejected({ diagnostics })), + }) + }) + +const emit = (source: PackageSource, tsc: string) => + Effect.scoped( + Effect.gen(function* () { + yield* checked(source) + const run = yield* runCompiler(source, tsc) + return yield* accepted(source, run) + }), + ) + +export const layer: Layer.Layer< + CompilerEmitterId, + never, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner +> = Layer.effect( + CompilerEmitter, + Effect.gen(function* () { + const path = yield* Path.Path + const services = yield* Effect.context() + const tsc = yield* Effect.orDie(path.fromFileUrl(new URL(TSC_SCRIPT, TYPESCRIPT_MANIFEST_URL))) + const emitter: DeclarationEmitter = { emit: (source) => Effect.provideContext(emit(source, tsc), services) } + return emitter + }), +) diff --git a/packages/api-extractor/src/judge-surfaces.workflow.ts b/packages/api-extractor/src/judge-surfaces.workflow.ts index e8179f7..33af051 100644 --- a/packages/api-extractor/src/judge-surfaces.workflow.ts +++ b/packages/api-extractor/src/judge-surfaces.workflow.ts @@ -10,7 +10,7 @@ import { AmbiguousRuleCorpus, CompareSurfaces, Verdict, VERDICT_SCHEMA_VERSION } * corpus, and the highest bump among them. A corpus that cannot pick one rule * per change is refused before anything is judged. */ -export const judgeSurfaces = Workflow.make({ +export const judgeSurfaces: Workflow.MadeWorkflow = Workflow.make({ command: CompareSurfaces, decision: Verdict, error: AmbiguousRuleCorpus, diff --git a/packages/api-extractor/src/mod.ts b/packages/api-extractor/src/mod.ts index 845fbdb..458e194 100644 --- a/packages/api-extractor/src/mod.ts +++ b/packages/api-extractor/src/mod.ts @@ -1,2 +1,3 @@ export * as ApiModel from './ApiModel.ts' +export * as FrontEnd from './FrontEnd.ts' export * as Verdict from './Verdict.ts' diff --git a/packages/api-extractor/src/model/api-model.schema.ts b/packages/api-extractor/src/model/api-model.schema.ts index 9d2d6ab..30554c4 100644 --- a/packages/api-extractor/src/model/api-model.schema.ts +++ b/packages/api-extractor/src/model/api-model.schema.ts @@ -3,7 +3,7 @@ import { canonicalKey, type Json } from '../canonical-json.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) +export const ModelVersion: Schema.Literal<2> = Schema.Literal(2) export type ModelVersion = typeof ModelVersion.Type export const API_MODEL_SCHEMA_VERSION: ModelVersion = 2 diff --git a/packages/api-extractor/src/model/type-expr.schema.ts b/packages/api-extractor/src/model/type-expr.schema.ts index 5a3b870..14e2b86 100644 --- a/packages/api-extractor/src/model/type-expr.schema.ts +++ b/packages/api-extractor/src/model/type-expr.schema.ts @@ -1,8 +1,7 @@ import { Array as Arr, Order, Schema } from 'effect' import { canonicalKey, type Json } from '../canonical-json.schema.ts' -/** TypeScript keyword types the model names directly. */ -export const KeywordName = Schema.Literals([ +const KEYWORD_NAMES = [ 'any', 'bigint', 'boolean', @@ -15,7 +14,10 @@ export const KeywordName = Schema.Literals([ 'undefined', 'unknown', 'void', -]) +] as const + +/** TypeScript keyword types the model names directly. */ +export const KeywordName: Schema.Literals = Schema.Literals(KEYWORD_NAMES) export type KeywordName = typeof KeywordName.Type export type KeywordType = { diff --git a/packages/api-extractor/src/rules/corpus.schema.ts b/packages/api-extractor/src/rules/corpus.schema.ts index 589584f..5398cbf 100644 --- a/packages/api-extractor/src/rules/corpus.schema.ts +++ b/packages/api-extractor/src/rules/corpus.schema.ts @@ -4,7 +4,7 @@ import { type Change, ChangeKind } from '../diff/change.schema.ts' import { Direction } from '../diff/direction.schema.ts' import { ChannelType, Position } from '../diff/position.schema.ts' -export const Bump = Schema.Literals(['none', 'patch', 'minor', 'major']) +export const Bump: Schema.Literals = Schema.Literals(['none', 'patch', 'minor', 'major']) export type Bump = typeof Bump.Type export type RuleMatch = { diff --git a/packages/api-extractor/src/rules/finding.schema.ts b/packages/api-extractor/src/rules/finding.schema.ts index 9932354..11c1701 100644 --- a/packages/api-extractor/src/rules/finding.schema.ts +++ b/packages/api-extractor/src/rules/finding.schema.ts @@ -2,8 +2,16 @@ import { Array as Arr, Function, Order, Schema } from 'effect' import type { Changes } from '../diff/change.schema.ts' import { Bump, classify, maxBump, type RuleCorpus } from './corpus.schema.ts' +type FindingFields = { + readonly rule_id: Schema.String + readonly reason_code: Schema.String + readonly bump: typeof Bump + readonly next_action: Schema.String + readonly locations: Schema.$Array +} + /** One rule that fired, with every location it fired at. */ -export const Finding = Schema.Struct({ +export const Finding: Schema.Struct = Schema.Struct({ rule_id: Schema.String, reason_code: Schema.String, bump: Bump, @@ -13,7 +21,7 @@ export const Finding = Schema.Struct({ export type Finding = typeof Finding.Type /** Every rule that fired, ordered by rule id. */ -export const Findings = Schema.Array(Finding) +export const Findings: Schema.$Array = Schema.Array(Finding) export type Findings = typeof Findings.Type /** One finding per rule that fired, ordered by rule id, each with its distinct locations in code-unit order. */ diff --git a/packages/api-extractor/src/verdict.cell.ts b/packages/api-extractor/src/verdict.cell.ts index 72b326f..46751d2 100644 --- a/packages/api-extractor/src/verdict.cell.ts +++ b/packages/api-extractor/src/verdict.cell.ts @@ -5,7 +5,7 @@ import { judgeSurfaces } from './judge-surfaces.workflow.ts' import type { ApiModel } from './model/api-model.schema.ts' import type { RuleCorpus } from './rules/corpus.schema.ts' import { defaultCorpus } from './rules/default-corpus.ts' -import { RuleCorpusRejected, SurfaceRejected } from './verdict.schema.ts' +import { RuleCorpusRejected, SurfaceRejected, type Verdict } from './verdict.schema.ts' import { ENGINE } from './version.ts' /** Two encoded surfaces of one package, and the corpus to judge them by (the shipped one when absent). */ @@ -20,7 +20,9 @@ export type VerdictInput = { * stamped with this engine. Both surfaces and the corpus are decoded before the * decision sees them. */ -export const verdictCell = Sandwich.named('api_extractor.verdict')((input: VerdictInput) => +export const verdictCell: Sandwich.WrittenCell = Sandwich.named( + 'api_extractor.verdict', +)((input: VerdictInput) => // The encoded command, not `new CompareSurfaces(...)`: the constructor validates and would throw // on an undecodable surface, where the cell must answer `SurfaceRejected`. Effect.succeed({ diff --git a/packages/api-extractor/src/verdict.schema.ts b/packages/api-extractor/src/verdict.schema.ts index 2ba05c9..beb67ac 100644 --- a/packages/api-extractor/src/verdict.schema.ts +++ b/packages/api-extractor/src/verdict.schema.ts @@ -1,11 +1,11 @@ import { Workflow } from '@systemfsoftware/effect-cell-types' -import { Schema } from 'effect' +import { Cause, Schema } from 'effect' import { ApiModel } from './model/api-model.schema.ts' import { Bump, RuleCorpus } from './rules/corpus.schema.ts' import { Findings } from './rules/finding.schema.ts' /** The verdict document's wire version. */ -export const VerdictVersion = Schema.Literal(1) +export const VerdictVersion: Schema.Literal<1> = Schema.Literal(1) export type VerdictVersion = typeof VerdictVersion.Type export const VERDICT_SCHEMA_VERSION: VerdictVersion = 1 @@ -13,20 +13,41 @@ export const VERDICT_SCHEMA_VERSION: VerdictVersion = 1 /** The family brand every outcome of the verdict decision carries. */ export const VerdictTypeId: unique symbol = Symbol.for('@systemfsoftware/api-extractor/Verdict') +type VerdictFields = { + readonly schemaVersion: typeof VerdictVersion + readonly engine: Schema.String + readonly corpusVersion: Schema.String + readonly declarations: Schema.Struct<{ readonly before: Schema.String; readonly after: Schema.String }> + readonly bump: typeof Bump + readonly findings: typeof Findings +} + +type VerdictStruct = Schema.TaggedStruct<'Verdict', VerdictFields> +type VerdictBrand = { readonly [VerdictTypeId]: symbol } +type VerdictValue = VerdictStruct['Type'] + /** The semver bump two surfaces of one package call for, with the findings behind it. */ -export class Verdict extends Schema.TaggedClass()('Verdict', { +export interface Verdict extends VerdictValue, VerdictBrand {} + +// Under isolatedDeclarations a class may neither extend an expression (TS9021) nor declare a +// computed member (TS9038), so the schema class is a typed constant and the brand sits on its prototype. +export const Verdict: Schema.Class = Schema.TaggedClass()('Verdict', { schemaVersion: VerdictVersion, engine: Schema.String, corpusVersion: Schema.String, declarations: Schema.Struct({ before: Schema.String, after: Schema.String }), bump: Bump, findings: Findings, -}) { - readonly [VerdictTypeId] = VerdictTypeId +}) +Object.defineProperty(Verdict.prototype, VerdictTypeId, { value: VerdictTypeId }) + +type CorpusAmbiguityFields = { + readonly corpusVersion: Schema.String + readonly ambiguities: Schema.$Array } /** The corpus a refusal names, and each way its rules fail to pick one rule per change. */ -export const CorpusAmbiguities = { +export const CorpusAmbiguities: CorpusAmbiguityFields = { corpusVersion: Schema.String, ambiguities: Schema.Array(Schema.String), } @@ -34,39 +55,61 @@ export const CorpusAmbiguities = { const ambiguityMessage = ({ corpusVersion, ambiguities }: { readonly corpusVersion: string; readonly ambiguities: ReadonlyArray }): string => `rule corpus ${corpusVersion} cannot pick one rule per change: ${ambiguities.join('; ')}` +const AmbiguousRuleCorpusBase: Schema.Class, Cause.YieldableError> = + Schema.TaggedError()('AmbiguousRuleCorpus', CorpusAmbiguities) + /** A corpus whose rules do not pick one rule per change: two rules share a match, or a rule id repeats. */ -export class AmbiguousRuleCorpus extends Schema.TaggedError()('AmbiguousRuleCorpus', CorpusAmbiguities) { +export class AmbiguousRuleCorpus extends AmbiguousRuleCorpusBase { override get message(): string { return ambiguityMessage(this) } } +const SurfaceRejectedBase: Schema.Class, Cause.YieldableError> = + Schema.TaggedError()('SurfaceRejected', { issue: Schema.String }) + /** A surface or corpus that does not decode against its schema. */ -export class SurfaceRejected extends Schema.TaggedError()('SurfaceRejected', { - issue: Schema.String, -}) { +export class SurfaceRejected extends SurfaceRejectedBase { override get message(): string { return `a surface or the corpus does not decode: ${this.issue}` } } +const RuleCorpusRejectedBase: Schema.Class, Cause.YieldableError> = + Schema.TaggedError()('RuleCorpusRejected', CorpusAmbiguities) + /** A corpus whose rules cannot pick one rule per change, refused by the verdict cell. */ -export class RuleCorpusRejected extends Schema.TaggedError()('RuleCorpusRejected', CorpusAmbiguities) { +export class RuleCorpusRejected extends RuleCorpusRejectedBase { override get message(): string { return ambiguityMessage(this) } } +type CompareSurfacesFields = { + readonly engine: Schema.String + readonly before: typeof ApiModel + readonly after: typeof ApiModel + readonly corpus: typeof RuleCorpus +} + +type CompareSurfacesStruct = Schema.TaggedStruct<'CompareSurfaces', CompareSurfacesFields> + /** * Judge the change from one surface of a package to the next against a rule * corpus. `engine` is the `@` the verdict is stamped with. */ -export class CompareSurfaces extends Schema.TaggedClass()('CompareSurfaces', { - engine: Schema.String, - before: ApiModel, - after: ApiModel, - corpus: RuleCorpus, -}) { - // The surfaces and corpus are documents, not span attributes; the verdict cell's span names the operation. - static readonly [Workflow.InstrumentationBrand] = {} as const -} +export type CompareSurfaces = CompareSurfacesStruct['Type'] + +// The surfaces and corpus are documents, not span attributes, so the instrumentation map is empty; +// the verdict cell's span names the operation. +export const CompareSurfaces: + & Schema.Class + & { readonly [Workflow.InstrumentationBrand]: {} } = Object.assign( + Schema.TaggedClass()('CompareSurfaces', { + engine: Schema.String, + before: ApiModel, + after: ApiModel, + corpus: RuleCorpus, + }), + { [Workflow.InstrumentationBrand]: {} }, + ) diff --git a/packages/api-extractor/tests/__fixtures__/declarations.ts b/packages/api-extractor/tests/__fixtures__/declarations.ts new file mode 100644 index 0000000..d1601a0 --- /dev/null +++ b/packages/api-extractor/tests/__fixtures__/declarations.ts @@ -0,0 +1,35 @@ +import { ApiModel, FrontEnd } from '@systemfsoftware/api-extractor' +import { Array as Arr, Function, Option } from 'effect' + +export type Model = ApiModel.ApiModel +export type EntryPoint = ApiModel.EntryPoint +export type Item = ApiModel.ApiItem + +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 })), + files: files.map(([path, text]) => ({ path, text })), +}) + +export const entryAt: { + (model: Model, path: string): EntryPoint + (path: string): (model: Model) => EntryPoint +} = Function.dual(2, (model: Model, path: string): EntryPoint => + Option.getOrThrow(Arr.findFirst(model.entryPoints, (entry) => entry.path === path))) + +export const itemOf: { + (model: Model, path: string, name: string, kind: Item['kind']): Item + (path: string, name: string, kind: Item['kind']): (model: Model) => Item +} = Function.dual(4, (model: Model, path: string, name: string, kind: Item['kind']): Item => + Option.getOrThrow(Arr.findFirst(entryAt(model, path).items, (item) => item.name === name && item.kind === kind))) + +export const itemsNamed: { + (model: Model, path: string, name: string): ReadonlyArray + (path: string, name: string): (model: Model) => ReadonlyArray +} = Function.dual(3, (model: Model, path: string, name: string): ReadonlyArray => + entryAt(model, path).items.filter((item) => item.name === name)) + +export const builtOf = (given: FrontEnd.Declarations) => FrontEnd.buildModel(given) diff --git a/packages/api-extractor/tests/__fixtures__/upstream-messages.ts b/packages/api-extractor/tests/__fixtures__/upstream-messages.ts new file mode 100644 index 0000000..ed8a080 --- /dev/null +++ b/packages/api-extractor/tests/__fixtures__/upstream-messages.ts @@ -0,0 +1,109 @@ +import { Extractor, ExtractorConfig, type ExtractorMessage } from '@microsoft/api-extractor' +import { type FrontEnd } from '@systemfsoftware/api-extractor' +import { fileURLToPath } from 'node:url' +import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +const packageRoot = fileURLToPath(new URL('../../', import.meta.url)) + +const FIVE_IDS: ReadonlyArray = [ + 'ae-forgotten-export', + 'ae-incompatible-release-tags', + 'ae-internal-missing-underscore', + 'ae-internal-mixed-release-tag', + 'ae-missing-release-tag', +] + +const TSCONFIG = { + compilerOptions: { + target: 'esnext', + module: 'esnext', + moduleResolution: 'bundler', + strict: true, + skipLibCheck: true, + allowImportingTsExtensions: true, + noEmit: true, + }, +} + +const ENTRY_POINT_IN_TEXT = /\bentry point (\S+)/ + +const firstQuoted = (text: string): string => { + const match = /"([^"]*)"/.exec(text) + return match === null ? '' : match[1] ?? '' +} + +const firstMatched = (pattern: RegExp, text: string): string => { + const match = pattern.exec(text) + return match === null ? '' : match[1] ?? '' +} + +const entryPointOf = (message: ExtractorMessage): string => + message.messageId === 'ae-forgotten-export' ? firstMatched(ENTRY_POINT_IN_TEXT, message.text) : '' + +export type UpstreamRow = readonly [string, string, string] + +const subjectOf = (message: ExtractorMessage): ReadonlyArray> => + FIVE_IDS.includes(message.messageId) ? [[message.messageId, firstQuoted(message.text), entryPointOf(message)]] : [] + +const sortedPairs = (pairs: ReadonlyArray>): ReadonlyArray => + pairs + .map(([messageId, symbol, entryPoint]) => [messageId ?? '', symbol ?? '', entryPoint ?? ''] as const) + .sort((left, right) => (left.join('\u0000') < right.join('\u0000') ? -1 : 1)) + +const writtenFiles = (root: string, declarations: FrontEnd.Declarations): void => { + for (const file of declarations.files) { + const target = join(root, file.path) + mkdirSync(dirname(target), { recursive: true }) + writeFileSync(target, file.text) + } +} + +const runExtractor = (root: string, entry: string): ReadonlyArray> => { + const messages: Array = [] + const config = ExtractorConfig.prepare({ + configObject: { + projectFolder: root, + mainEntryPointFilePath: join(root, entry), + compiler: { tsconfigFilePath: join(root, 'tsconfig.json') }, + apiReport: { enabled: false }, + docModel: { enabled: false }, + dtsRollup: { enabled: false }, + }, + configObjectFullPath: join(root, 'api-extractor.json'), + packageJsonFullPath: join(root, 'package.json'), + }) + Extractor.invoke(config, { + messageCallback: (message: ExtractorMessage) => void messages.push(message), + localBuild: true, + showVerboseMessages: false, + showDiagnostics: false, + }) + return messages.flatMap(subjectOf) +} + +export const upstreamPairs = (declarations: FrontEnd.Declarations): ReadonlyArray => { + const root = mkdtempSync(join(tmpdir(), 'upstream-messages-')) + try { + symlinkSync(join(packageRoot, 'node_modules'), join(root, 'node_modules'), 'dir') + writeFileSync(join(root, 'package.json'), JSON.stringify({ name: declarations.package.name, version: declarations.package.version })) + writeFileSync(join(root, 'tsconfig.json'), JSON.stringify(TSCONFIG)) + writtenFiles(root, declarations) + return sortedPairs(runExtractor(root, declarations.entryPoints[0]?.declarationFile ?? 'index.d.ts')) + } finally { + rmSync(root, { recursive: true, force: true }) + } +} + +export type DeclarationRequest = { + readonly entry: string + readonly files: ReadonlyArray> +} + +export const declarationsOf = (request: DeclarationRequest): FrontEnd.Declarations => ({ + producer: 'fixture', + package: { name: 'fixture', version: '1.0.0' }, + entryPoints: [{ exportPath: '.', declarationFile: request.entry }], + files: request.files.map(([path, text]) => ({ path: path ?? '', text: text ?? '' })), +}) diff --git a/packages/api-extractor/tests/analysis-messages.integration.test.ts b/packages/api-extractor/tests/analysis-messages.integration.test.ts new file mode 100644 index 0000000..5b04439 --- /dev/null +++ b/packages/api-extractor/tests/analysis-messages.integration.test.ts @@ -0,0 +1,562 @@ +import { NodeServices } from '@effect/platform-node' +import { FrontEnd } from '@systemfsoftware/api-extractor' +import { Gherkin, Given, it, makeFeature, Then, When } from '@systemfsoftware/effect-gherkin-spec' +import { Effect, Layer, Path } from 'effect' +import { declarationsOf, upstreamPairs } from './__fixtures__/upstream-messages.ts' + +const Feature = makeFeature({ it }) + +const emitters = FrontEnd.isolatedEmitterLayer.pipe(Layer.provideMerge(NodeServices.layer)) + +const packageRoot = Effect.flatMap(Path.Path, (path) => Effect.map(path.fromFileUrl(new URL('..', import.meta.url)), (root) => path.resolve(root))) + +type Row = readonly [string, string, string] + +const rowKey = (row: Row): string => row.join('\u0000') + +const sorted = (rows: ReadonlyArray): ReadonlyArray => + rows + .filter((row, index) => rows.findIndex((candidate) => rowKey(candidate) === rowKey(row)) === index) + .sort((left, right) => (rowKey(left) < rowKey(right) ? -1 : 1)) + +const baseName = (path: string): string => path.slice(path.lastIndexOf('/') + 1) + +const entryFileOf = (declarations: FrontEnd.Declarations, exportPath: string): string => { + const entry = declarations.entryPoints.find((candidate) => candidate.exportPath === exportPath) + return entry === undefined ? '' : baseName(entry.declarationFile) +} + +const entryPointOf = (declarations: FrontEnd.Declarations, message: FrontEnd.AnalysisMessage): string => + message.messageId === 'ae-forgotten-export' ? entryFileOf(declarations, message.entryPoint) : '' + +const asRows = (declarations: FrontEnd.Declarations, messages: ReadonlyArray): ReadonlyArray => + messages.map((message) => [message.messageId, message.symbol, entryPointOf(declarations, message)] as const) + +const bothSides = (declarations: FrontEnd.Declarations) => + Effect.map(FrontEnd.resolveMessages(declarations), (messages) => ({ declarations, messages, upstream: upstreamPairs(declarations) })) + +const reviewed = (declarations: FrontEnd.Declarations, messages: ReadonlyArray): ReadonlyArray => + sorted(asRows(declarations, messages)) + +const forgottenAt = (messages: ReadonlyArray): ReadonlyArray => + messages.filter((message) => message.messageId === 'ae-forgotten-export') + +const entry = (lines: ReadonlyArray): ReadonlyArray> => [['index.d.ts', lines.join('\n')]] + +const SOME_NOTE = 'the notes are derived here and api-extractor derives them from the same declarations' + +Feature('Review notes for a package match the ones api-extractor derives') + .withLayer(emitters) + .live('it reads the package from disk and runs api-extractor over declarations written to a real temporary directory') + .body(({ scenario }) => { + scenario( + 'A function returning a type the entry does not export is noted as a forgotten export', + Gherkin.Do.pipe( + Given('a package whose exported function returns a type the entry does not export')('declarations', () => + Effect.sync(() => + declarationsOf({ + entry: 'index.d.ts', + files: [ + [ + 'index.d.ts', + [ + "import type { Schema } from 'effect'", + "import type { Widget } from './widget.ts'", + 'export declare function makeWidget(input: Schema.Json): Widget', + '', + ].join('\n'), + ], + ['widget.d.ts', 'export interface Widget { readonly name: string }\n'], + ], + }))), + 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({ + pairs: sorted(scope.both.upstream), + forgotten: [{ messageId: 'ae-forgotten-export', entryPoint: '.', item: 'makeWidget', symbol: 'Widget' }], + })), + ), + ) + + scenario( + 'An exported value with no release tag is noted as untagged', + Gherkin.Do.pipe( + Given('a package exporting a value with no release tag')('declarations', () => + Effect.sync(() => declarationsOf({ entry: 'index.d.ts', files: entry(['export declare const value: string']) }))), + When(SOME_NOTE)('both', (scope) => bothSides(scope.declarations)), + Then('both note the exported value as untagged')((scope, expect) => expect(reviewed(scope.both.declarations, scope.both.messages)).toEqual(sorted(scope.both.upstream))), + ), + ) + + scenario( + 'An internal value whose name lacks an underscore is noted', + Gherkin.Do.pipe( + Given('a package exporting an internal value whose name lacks an underscore')('declarations', () => + Effect.sync(() => declarationsOf({ entry: 'index.d.ts', files: entry(['/** @internal */', 'export declare const secret: string']) }))), + When(SOME_NOTE)('both', (scope) => bothSides(scope.declarations)), + Then('both note the value should carry an underscore')((scope, expect) => expect(reviewed(scope.both.declarations, scope.both.messages)).toEqual(sorted(scope.both.upstream))), + ), + ) + + scenario( + 'A release tag written inside a code sample or a code span is not a release tag', + Gherkin.Do.pipe( + Given('a package whose doc comments name a release tag only inside a fenced code block and inside an inline code span')('declarations', () => + Effect.sync(() => + declarationsOf({ + entry: 'index.d.ts', + files: entry([ + '/**', + ' * Summary.', + ' * @example', + ' * ```ts', + ' * // @internal', + ' * ```', + ' */', + 'export declare function g(): void', + '/** Not `@beta` at all. */', + 'export declare function h(): void', + ]), + }))), + When(SOME_NOTE)('both', (scope) => bothSides(scope.declarations)), + Then('both note the two functions as untagged')((scope, expect) => + expect({ derived: reviewed(scope.both.declarations, scope.both.messages), upstream: sorted(scope.both.upstream) }).toEqual({ + derived: [['ae-missing-release-tag', 'g', ''], ['ae-missing-release-tag', 'h', '']], + upstream: [['ae-missing-release-tag', 'g', ''], ['ae-missing-release-tag', 'h', '']], + })), + ), + ) + + scenario( + "A release tag belongs to its own declaration, not to the one after it", + Gherkin.Do.pipe( + Given('a package where a tagged value and an interface member\'s doc comment precede untagged values')('declarations', () => + Effect.sync(() => + declarationsOf({ + entry: 'index.d.ts', + files: entry([ + '/** @internal */', + 'export declare const _hidden: string', + 'export declare const shown: string', + 'export interface I {', + ' /** @beta */', + ' readonly x: string', + '}', + 'export declare const after: string', + ]), + }))), + When(SOME_NOTE)('both', (scope) => bothSides(scope.declarations)), + Then('both note the untagged values as untagged')((scope, expect) => + expect({ + notes: reviewed(scope.both.declarations, scope.both.messages), + shownUntaggedUpstream: sorted(scope.both.upstream).some(([id, symbol]) => id === 'ae-missing-release-tag' && symbol === 'shown'), + }).toEqual({ notes: sorted(scope.both.upstream), shownUntaggedUpstream: true })), + ), + ) + + scenario( + 'A public function using a beta type is noted as incompatible', + Gherkin.Do.pipe( + Given('a package whose public function uses a beta type')('declarations', () => + Effect.sync(() => + declarationsOf({ + entry: 'index.d.ts', + files: entry(['/** @beta */', 'export interface B { readonly x: string }', '/** @public */', 'export declare function f(): B']), + }))), + When(SOME_NOTE)('both', (scope) => bothSides(scope.declarations)), + Then('both note the incompatible release tags')((scope, expect) => expect(reviewed(scope.both.declarations, scope.both.messages)).toEqual(sorted(scope.both.upstream))), + ), + ) + + scenario( + 'A class merged with an internal namespace is noted as mixed', + Gherkin.Do.pipe( + Given('a package with a public class merged with an internal namespace')('declarations', () => + Effect.sync(() => + declarationsOf({ + entry: 'index.d.ts', + files: entry(['/** @public */', 'export declare class C { }', '/** @internal */', 'export declare namespace C { }']), + }))), + When(SOME_NOTE)('both', (scope) => bothSides(scope.declarations)), + Then('both note the mixed release tags')((scope, expect) => expect(reviewed(scope.both.declarations, scope.both.messages)).toEqual(sorted(scope.both.upstream))), + ), + ) + + scenario( + 'A generic declaration does not hide a forgotten export of the same name', + Gherkin.Do.pipe( + Given('a package where a generic function and an imported type share a name but only the function binds it')('declarations', () => + Effect.sync(() => + declarationsOf({ + entry: 'index.d.ts', + files: [ + [ + 'index.d.ts', + [ + "import type { A } from './inner.ts'", + 'export declare function wrap(a: A): A', + 'export declare function use(a: A): A', + '', + ].join('\n'), + ], + ['inner.d.ts', 'export interface A { readonly x: string }\n'], + ], + }))), + When(SOME_NOTE)('both', (scope) => bothSides(scope.declarations)), + Then('both note the imported type as a forgotten export of the function that uses it')((scope, expect) => + expect({ + notes: reviewed(scope.both.declarations, scope.both.messages), + forgotten: forgottenAt(scope.both.messages), + }).toEqual({ + notes: sorted(scope.both.upstream), + forgotten: [{ messageId: 'ae-forgotten-export', entryPoint: '.', item: 'use', symbol: 'A' }], + })), + ), + ) + + scenario( + 'A mapped-type key or an inferred type variable does not stand for a declaration of the same name', + Gherkin.Do.pipe( + Given('a package whose mapped type and conditional type bind K and U while an exported function uses imported types K and U')('declarations', () => + Effect.sync(() => + declarationsOf({ + entry: 'index.d.ts', + files: [ + [ + 'index.d.ts', + [ + "import type { K, U } from './inner.ts'", + '/** @public */', + "export declare const keyed: { [K in 'a' | 'b']: K }", + '/** @public */', + 'export type Unwrap = T extends Promise ? U : never', + '/** @public */', + 'export declare function use(k: K, u: U): void', + '', + ].join('\n'), + ], + ['inner.d.ts', 'export interface K { readonly k: string }\nexport interface U { readonly u: string }\n'], + ], + }))), + When(SOME_NOTE)('both', (scope) => bothSides(scope.declarations)), + Then('both note K and U as forgotten exports of the function only')((scope, expect) => + expect({ + notes: reviewed(scope.both.declarations, scope.both.messages), + forgotten: forgottenAt(scope.both.messages), + }).toEqual({ + notes: sorted(scope.both.upstream), + forgotten: [ + { messageId: 'ae-forgotten-export', entryPoint: '.', item: 'use', symbol: 'K' }, + { messageId: 'ae-forgotten-export', entryPoint: '.', item: 'use', symbol: 'U' }, + ], + })), + ), + ) + + scenario( + 'A package that repeats one note reports it once, in code-unit order', + Gherkin.Do.pipe( + Given('a package whose exported function names one non-exported type twice beside an internal export without an underscore')('declarations', () => + Effect.sync(() => + declarationsOf({ + entry: 'index.d.ts', + files: [ + [ + 'index.d.ts', + [ + "import type { Missing } from './missing.ts'", + '/** @internal */', + 'export declare const secret: string', + 'export declare function f(a: Missing, b: Missing): void', + '', + ].join('\n'), + ], + ['missing.d.ts', 'export interface Missing { readonly x: string }\n'], + ], + }))), + When(SOME_NOTE)('both', (scope) => bothSides(scope.declarations)), + Then('the full list of notes is sorted and de-duplicated and matches upstream')((scope, expect) => + expect({ + notes: scope.both.messages, + pairs: reviewed(scope.both.declarations, scope.both.messages), + }).toEqual({ + notes: [ + { messageId: 'ae-forgotten-export', entryPoint: '.', item: 'f', symbol: 'Missing' }, + { messageId: 'ae-internal-missing-underscore', entryPoint: '.', item: 'secret', symbol: 'secret' }, + { messageId: 'ae-missing-release-tag', entryPoint: '.', item: 'f', symbol: 'f' }, + ], + pairs: sorted(scope.both.upstream), + })), + ), + ) + + scenario( + 'Each entry point of a package carries the notes its own file causes, under its own path', + Gherkin.Do.pipe( + Given('a package whose `.` entry exports one public value and whose `./util` entry exports an untagged function returning a type its file declares but does not export')( + 'declarations', + () => + Effect.sync((): FrontEnd.Declarations => ({ + producer: 'fixture', + package: { name: 'fixture', version: '1.0.0' }, + entryPoints: [ + { exportPath: '.', declarationFile: 'index.d.ts' }, + { exportPath: './util', declarationFile: 'util.d.ts' }, + ], + files: [ + { 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' }, + ], + }))), + 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({ + messages: [ + { messageId: 'ae-forgotten-export', entryPoint: './util', item: 'helper', symbol: 'Hidden' }, + { messageId: 'ae-missing-release-tag', entryPoint: './util', item: 'helper', symbol: 'helper' }, + ], + underMain: [], + })), + ), + ) + + scenario( + "A namespace body's own type declarations shadow a reference to an imported type of the same name", + Gherkin.Do.pipe( + Given('a package whose namespace declares an interface named like an imported type and uses that name in a function')('declarations', () => + Effect.sync(() => + declarationsOf({ + entry: 'index.d.ts', + files: [ + [ + 'index.d.ts', + [ + "import type { A } from './a.js'", + '/** @public */', + 'export declare namespace N {', + ' interface A { y: number }', + ' function f(): A', + '}', + '', + ].join('\n'), + ], + ['a.d.ts', '/** @public */\nexport interface A { readonly x: string }\n'], + ], + }))), + When(SOME_NOTE)('both', (scope) => bothSides(scope.declarations)), + Then('no note is derived for the name the namespace binds and api-extractor agrees')((scope, expect) => + expect({ + messages: scope.both.messages, + pairs: reviewed(scope.both.declarations, scope.both.messages), + }).toEqual({ + messages: [], + pairs: sorted(scope.both.upstream), + })), + ), + ) + + scenario( + 'A value-only declaration in a namespace body does not shadow a type reference to an imported type of the same name', + Gherkin.Do.pipe( + Given('a package whose namespace declares a value named like an imported type and returns that name from a function')('declarations', () => + Effect.sync(() => + declarationsOf({ + entry: 'index.d.ts', + files: [ + [ + 'index.d.ts', + [ + "import type { A } from './a.js'", + 'export declare namespace N {', + ' const A: number', + ' function f(): A', + '}', + '', + ].join('\n'), + ], + ['a.d.ts', '/** @public */\nexport interface A { readonly x: string }\n'], + ], + }))), + When(SOME_NOTE)('both', (scope) => bothSides(scope.declarations)), + Then('the type reference still names the forgotten export and api-extractor agrees')((scope, expect) => + expect({ + messages: scope.both.messages, + pairs: reviewed(scope.both.declarations, scope.both.messages), + }).toEqual({ + messages: [ + { messageId: 'ae-forgotten-export', entryPoint: '.', item: 'N', symbol: 'A' }, + { messageId: 'ae-missing-release-tag', entryPoint: '.', item: 'N', symbol: 'N' }, + ], + pairs: sorted(scope.both.upstream), + })), + ), + ) + + scenario( + "A `typeof` query reads a namespace body's value declarations rather than its type declarations", + Gherkin.Do.pipe( + Given('a package whose namespace declares a value named like an imported value and queries it with `typeof`')('declarations', () => + Effect.sync(() => + declarationsOf({ + entry: 'index.d.ts', + files: [ + [ + 'index.d.ts', + [ + "import { A } from './a.js'", + '/** @public */', + 'export declare namespace N {', + ' const A: number', + ' /** @public */', + ' export type T = typeof A', + '}', + '', + ].join('\n'), + ], + ['a.d.ts', '/** @public */\nexport declare const A: number\n'], + ], + }))), + When(SOME_NOTE)('both', (scope) => bothSides(scope.declarations)), + Then('no note is derived for the value the namespace binds and api-extractor agrees')((scope, expect) => + expect({ + messages: scope.both.messages, + pairs: reviewed(scope.both.declarations, scope.both.messages), + }).toEqual({ + messages: [], + pairs: sorted(scope.both.upstream), + })), + ), + ) + + scenario( + 'A type-only declaration in a namespace body does not shadow a `typeof` query of an imported value of the same name', + Gherkin.Do.pipe( + Given('a package whose namespace declares an interface named like an imported value and queries that name with `typeof`')('declarations', () => + Effect.sync(() => + declarationsOf({ + entry: 'index.d.ts', + files: [ + [ + 'index.d.ts', + [ + "import { A } from './a.js'", + '/** @public */', + 'export declare namespace N {', + ' interface A { y: number }', + ' /** @public */', + ' export type T = typeof A', + '}', + '', + ].join('\n'), + ], + ['a.d.ts', '/** @public */\nexport declare const A: number\n'], + ], + }))), + When(SOME_NOTE)('both', (scope) => bothSides(scope.declarations)), + Then('the query still names the forgotten export and api-extractor agrees')((scope, expect) => + expect({ + messages: scope.both.messages, + pairs: reviewed(scope.both.declarations, scope.both.messages), + }).toEqual({ + messages: [{ messageId: 'ae-forgotten-export', entryPoint: '.', item: 'N', symbol: 'A' }], + pairs: sorted(scope.both.upstream), + })), + ), + ) + + scenario( + 'A reference in one block of a merged namespace is bound by a declaration in the other block', + Gherkin.Do.pipe( + Given('a package declaring a namespace twice, one block declaring an interface named like an imported type and the other referring to that name')('declarations', () => + Effect.sync(() => + declarationsOf({ + entry: 'index.d.ts', + files: [ + [ + 'index.d.ts', + [ + "import type { A } from './a.js'", + '/** @public */', + 'export declare namespace N {', + ' interface A { y: number }', + '}', + '/** @public */', + 'export declare namespace N {', + ' function f(): A', + '}', + '', + ].join('\n'), + ], + ['a.d.ts', '/** @public */\nexport interface A { readonly x: string }\n'], + ], + }))), + When(SOME_NOTE)('both', (scope) => bothSides(scope.declarations)), + Then('no note is derived for the name the merged namespace binds and api-extractor agrees')((scope, expect) => + expect({ + messages: scope.both.messages, + pairs: reviewed(scope.both.declarations, scope.both.messages), + }).toEqual({ + messages: [], + pairs: sorted(scope.both.upstream), + })), + ), + ) + + scenario( + 'A reference in one block of merged nested namespaces is bound by a declaration in the other block of the inner namespace', + Gherkin.Do.pipe( + Given('a package declaring nested namespaces twice, one inner block declaring an interface named like an imported type and the other referring to that name')('declarations', () => + Effect.sync(() => + declarationsOf({ + entry: 'index.d.ts', + files: [ + [ + 'index.d.ts', + [ + "import type { A } from './a.js'", + '/** @public */', + 'export declare namespace N {', + ' namespace M {', + ' interface A { y: number }', + ' }', + '}', + '/** @public */', + 'export declare namespace N {', + ' namespace M {', + ' function f(): A', + ' }', + '}', + '', + ].join('\n'), + ], + ['a.d.ts', '/** @public */\nexport interface A { readonly x: string }\n'], + ], + }))), + When(SOME_NOTE)('both', (scope) => bothSides(scope.declarations)), + Then('no note is derived for the name the merged inner namespace binds and api-extractor agrees')((scope, expect) => + expect({ + messages: scope.both.messages, + pairs: reviewed(scope.both.declarations, scope.both.messages), + }).toEqual({ + messages: [], + pairs: sorted(scope.both.upstream), + })), + ), + ) + + scenario( + 'Every note for this package matches the ones api-extractor derives for it', + Gherkin.Do.pipe( + Given('this repository package')('source', () => Effect.flatMap(packageRoot, (root) => FrontEnd.readPackage({ packageRoot: root }))), + When('its declarations are derived here and api-extractor reviews the same declarations')('both', (scope) => + Effect.gen(function* () { + const isolated = yield* FrontEnd.IsolatedEmitter + return yield* bothSides(yield* isolated.emit(scope.source)) + })), + Then('every note matches')((scope, expect) => expect(reviewed(scope.both.declarations, scope.both.messages)).toEqual(sorted(scope.both.upstream))), + ), + ) + }) diff --git a/packages/api-extractor/tests/front-end-model.integration.test.ts b/packages/api-extractor/tests/front-end-model.integration.test.ts new file mode 100644 index 0000000..30b6a42 --- /dev/null +++ b/packages/api-extractor/tests/front-end-model.integration.test.ts @@ -0,0 +1,998 @@ +import { Gherkin, Given, it, makeFeature, Then, When } from '@systemfsoftware/effect-gherkin-spec' +import { ApiModel, FrontEnd } from '@systemfsoftware/api-extractor' +import { Effect, Layer } from 'effect' +import { isolatedDeclarationSync } from 'oxc-transform' +import { builtOf, declarations, entryAt, itemOf, itemsNamed, type Model } from './__fixtures__/declarations.ts' + +const Feature = makeFeature({ it }) + +const itemNames = (model: Model, path: string): ReadonlyArray => entryAt(model, path).items.map((item) => item.name) + +Feature('Building a package API model from its declaration text') + .withLayer(Layer.empty) + .body(({ scenario }) => { + scenario( + 'the same external type written two ways is one reference', + Gherkin.Do.pipe( + Given('an entry declaring the same external generic two ways')('declarations', () => + Effect.succeed( + declarations([ + [ + 'index.d.ts', + "import type { Effect } from 'effect'\n" + + 'export declare const viaImport: Effect.Effect\n' + + 'export declare const viaImportType: import("effect").Effect.Effect\n', + ], + ]), + )), + When('the package model is built')('model', (scope) => builtOf(scope.declarations)), + Then('both exports carry one reference to that module')((scope, expect) => + expect({ + viaImport: itemOf(scope.model, '.', 'viaImport', 'variable'), + viaImportType: itemOf(scope.model, '.', 'viaImportType', 'variable'), + }).toMatchObject({ + viaImport: { + kind: 'variable', + constant: true, + type: { kind: 'reference', name: 'Effect.Effect', module: 'effect', args: [{ kind: 'keyword', name: 'string' }] }, + }, + viaImportType: { + kind: 'variable', + constant: true, + type: { kind: 'reference', name: 'Effect.Effect', module: 'effect', args: [{ kind: 'keyword', name: 'string' }] }, + }, + })), + ), + ) + + scenario( + 'a mapped type printed by two emitters and by hand is one canonical text', + Gherkin.Do.pipe( + Given('an entry printed by an emitter and two hand-formatted entries of one mapped type')('declarations', () => + Effect.sync(() => { + const emitted = isolatedDeclarationSync('mapped.ts', 'export type M = { readonly [K in "a" | "b"]?: string }\n') + return declarations( + [ + ['emitted.d.ts', emitted.code], + ['spaced.d.ts', 'export declare type M = { readonly [K in "a" | "b"]?: string }\n'], + ['tight.d.ts', 'export declare type M = {readonly[K in "a"|"b"]?:string}\n'], + ], + [ + ['.', 'emitted.d.ts'], + ['./spaced', 'spaced.d.ts'], + ['./tight', 'tight.d.ts'], + ], + ) + })), + When('all three are built')('model', (scope) => builtOf(scope.declarations)), + Then('every alias carries the same canonical text')((scope, expect) => + expect({ + emitted: itemOf(scope.model, '.', 'M', 'typeAlias'), + spaced: itemOf(scope.model, './spaced', 'M', 'typeAlias'), + tight: itemOf(scope.model, './tight', 'M', 'typeAlias'), + }).toMatchObject({ + emitted: { type: { kind: 'text', text: '{ readonly [K in "a" | "b"]?: string }' } }, + spaced: { type: { kind: 'text', text: '{ readonly [K in "a" | "b"]?: string }' } }, + tight: { type: { kind: 'text', text: '{ readonly [K in "a" | "b"]?: string }' } }, + })), + ), + ) + + scenario( + 'a number spelled two ways by two emitters is one value', + Gherkin.Do.pipe( + Given('an entry emitted by oxc from `1_000` and `0x10`, and one spelled `1000` and `16` as tsc prints them')('declarations', () => + Effect.sync(() => { + const emitted = isolatedDeclarationSync( + 'numbers.ts', + 'export enum E { B = 1_000 }\nexport const u = 1_000\nexport type T = 0x10\nexport type K = { 0x10: string }\n', + ) + return declarations( + [ + ['emitted.d.ts', emitted.code], + ['printed.d.ts', 'export declare enum E { B = 1000 }\nexport declare const u = 1000;\nexport type T = 16;\nexport type K = { 16: string };\n'], + ], + [ + ['.', 'emitted.d.ts'], + ['./printed', 'printed.d.ts'], + ], + ) + })), + When('both are built')('model', (scope) => builtOf(scope.declarations)), + Then('both entries hold the same items, each number written as its value')((scope, expect) => + expect({ + printed: entryAt(scope.model, './printed').items, + E: itemOf(scope.model, '.', 'E', 'enum'), + u: itemOf(scope.model, '.', 'u', 'variable'), + T: itemOf(scope.model, '.', 'T', 'typeAlias'), + }).toMatchObject({ + printed: entryAt(scope.model, '.').items, + E: { members: [{ name: 'B', value: '1000' }] }, + u: { type: { kind: 'literal', text: '1000' } }, + T: { type: { kind: 'literal', text: '16' } }, + })), + ), + ) + + scenario( + 'a negative number, a bigint, a boolean, null and a single-quoted string each model as the type its value spells', + Gherkin.Do.pipe( + Given('an entry declaring a value of each of `-1`, `10n`, `true`, `null` and `\'a\'`')('declarations', () => + Effect.succeed( + declarations([ + [ + 'index.d.ts', + 'export declare const negative: -1\n' + + 'export declare const big: 10n\n' + + 'export declare const yes: true\n' + + 'export declare const nothing: null\n' + + "export declare const single: 'a'\n", + ], + ]), + )), + When('the model is built')('model', (scope) => builtOf(scope.declarations)), + Then('each value carries its own type, the string quoted as the compiler writes it')((scope, expect) => + expect({ + negative: itemOf(scope.model, '.', 'negative', 'variable'), + big: itemOf(scope.model, '.', 'big', 'variable'), + yes: itemOf(scope.model, '.', 'yes', 'variable'), + nothing: itemOf(scope.model, '.', 'nothing', 'variable'), + single: itemOf(scope.model, '.', 'single', 'variable'), + }).toMatchObject({ + negative: { type: { kind: 'literal', text: '-1' } }, + big: { type: { kind: 'literal', text: '10n' } }, + yes: { type: { kind: 'literal', text: 'true' } }, + nothing: { type: { kind: 'keyword', name: 'null' } }, + single: { type: { kind: 'literal', text: '"a"' } }, + })), + ), + ) + + scenario( + 'overloads collapse into one function carrying every signature', + Gherkin.Do.pipe( + Given('an entry declaring one function twice')('declarations', () => + Effect.succeed( + declarations([ + ['index.d.ts', 'export declare function pick(a: string): string\nexport declare function pick(a: number): number\n'], + ]), + )), + When('the model is built')('model', (scope) => builtOf(scope.declarations)), + Then('one function item carries both signatures in order')((scope, expect) => + expect({ + count: itemsNamed(scope.model, '.', 'pick').length, + pick: itemOf(scope.model, '.', 'pick', 'function'), + }).toMatchObject({ + count: 1, + pick: { + kind: 'function', + name: 'pick', + signatures: [ + { parameters: [{ name: 'a', type: { kind: 'keyword', name: 'string' } }], returns: { kind: 'keyword', name: 'string' } }, + { parameters: [{ name: 'a', type: { kind: 'keyword', name: 'number' } }], returns: { kind: 'keyword', name: 'number' } }, + ], + }, + })), + ), + ) + + scenario( + 'an alias an export reaches but the entry does not export lands in locals', + Gherkin.Do.pipe( + Given('an entry whose exported value names an alias it does not export')('declarations', () => + Effect.succeed( + declarations([ + ['index.d.ts', "import type { Hidden } from './hidden.js'\nexport declare const value: Hidden\n"], + ['hidden.d.ts', 'export type Hidden = { readonly a: string }\n'], + ]), + )), + When('the model is built')('model', (scope) => builtOf(scope.declarations)), + Then('the alias is a local beside the export')((scope, expect) => + expect(entryAt(scope.model, '.')).toMatchObject({ + items: [{ kind: 'variable', name: 'value', type: { kind: 'reference', name: 'Hidden', args: [] } }], + locals: [ + { + kind: 'typeAlias', + name: 'Hidden', + typeParameters: [], + type: { + kind: 'object', + members: [{ kind: 'property', name: 'a', optional: false, readonly: true, type: { kind: 'keyword', name: 'string' } }], + }, + }, + ], + })), + ), + ) + + scenario( + 'two reachable aliases of one name are renamed and their references follow', + Gherkin.Do.pipe( + Given('an entry reaching two distinct aliases sharing a name')('declarations', () => + Effect.succeed( + declarations([ + [ + 'index.d.ts', + "import type { Shared as SharedA } from './a.js'\n" + + "import type { Shared as SharedB } from './b.js'\n" + + 'export declare const pair: readonly [SharedA, SharedB]\n', + ], + ['a.d.ts', 'export type Shared = { readonly a: string }\n'], + ['b.d.ts', 'export type Shared = { readonly b: number }\n'], + ]), + )), + When('the model is built')('model', (scope) => builtOf(scope.declarations)), + Then('the locals are suffixed in file order and the references follow')((scope, expect) => + expect({ + locals: entryAt(scope.model, '.').locals.map((local) => local.name), + pair: itemOf(scope.model, '.', 'pair', 'variable'), + }).toMatchObject({ + locals: ['Shared', 'Shared_2'], + pair: { + type: { + kind: 'tuple', + readonly: true, + elements: [ + { kind: 'reference', name: 'Shared', args: [] }, + { kind: 'reference', name: 'Shared_2', args: [] }, + ], + }, + }, + })), + ), + ) + + scenario( + 'a built model decodes as schema version two', + Gherkin.Do.pipe( + Given('an entry with literals, an enum and a namespace')('declarations', () => + Effect.succeed( + declarations([ + [ + 'index.d.ts', + 'export declare const answer: 42\n' + + 'export declare const version = "1.0.0"\n' + + 'export declare const enum Mode { On = "on", Off = "off" }\n' + + "export * as ns from './ns.js'\n", + ], + ['ns.d.ts', 'export declare function inner(): void\n'], + ]), + )), + 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) => + expect({ + built: scope.roundTrip.model.schemaVersion, + decoded: scope.roundTrip.decoded.schemaVersion, + namespaces: entryAt(scope.roundTrip.decoded, '.').items + .filter((item) => item.kind === 'namespace') + .map((item) => item.name), + version: itemOf(scope.roundTrip.decoded, '.', 'version', 'variable'), + mode: itemOf(scope.roundTrip.decoded, '.', 'Mode', 'enum'), + }).toMatchObject({ + built: 2, + decoded: 2, + 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"' }] }, + })), + ), + ) + + scenario( + 'locals whose names share a prefix keep their own names in a text type', + Gherkin.Do.pipe( + Given( + 'an entry exporting names that also name aliases it reaches from another file, one alias name a prefix of the other, each used in a mapped type', + )('declarations', () => + Effect.succeed( + declarations([ + [ + 'index.d.ts', + "import type { B as ShortB, BC, Id as ShortId, Ids, K as ShortK, Key, Opt as ShortOpt, Options } from './a.js'\n" + + 'export type B = { readonly ownB: string }\n' + + 'export type Id = { readonly ownId: string }\n' + + 'export type K = { readonly ownK: string }\n' + + 'export type Opt = { readonly ownOpt: string }\n' + + 'export type S1 = { readonly [P in keyof ShortB]: BC }\n' + + 'export type S2 = { readonly [P in keyof ShortId]: Ids }\n' + + 'export type S3 = { readonly [P in keyof ShortK]: Key }\n' + + 'export type S4 = { readonly [P in keyof ShortOpt]: Options }\n', + ], + [ + 'a.d.ts', + 'export type B = { readonly b: string }\n' + + 'export type BC = { readonly bc: string }\n' + + 'export type Id = { readonly id: string }\n' + + 'export type Ids = { readonly ids: string }\n' + + 'export type K = { readonly k: string }\n' + + 'export type Key = { readonly key: string }\n' + + 'export type Opt = { readonly opt: string }\n' + + 'export type Options = { readonly options: string }\n', + ], + ]), + )), + When('the model is built')('model', (scope) => builtOf(scope.declarations)), + Then('every mapped type names the whole local name, never part of another')((scope, expect) => + expect({ + S1: itemOf(scope.model, '.', 'S1', 'typeAlias'), + S2: itemOf(scope.model, '.', 'S2', 'typeAlias'), + S3: itemOf(scope.model, '.', 'S3', 'typeAlias'), + S4: itemOf(scope.model, '.', 'S4', 'typeAlias'), + }).toMatchObject({ + S1: { kind: 'typeAlias', type: { kind: 'text', text: '{ readonly [P in keyof B_2]: BC }' } }, + S2: { kind: 'typeAlias', type: { kind: 'text', text: '{ readonly [P in keyof Id_2]: Ids }' } }, + S3: { kind: 'typeAlias', type: { kind: 'text', text: '{ readonly [P in keyof K_2]: Key }' } }, + S4: { kind: 'typeAlias', type: { kind: 'text', text: '{ readonly [P in keyof Opt_2]: Options }' } }, + })), + ), + ) + + scenario( + 'a named optional tuple member keeps its question mark', + Gherkin.Do.pipe( + Given('an entry declaring `[first: string, second?: number]`')('declarations', () => + Effect.succeed(declarations([['index.d.ts', 'export declare type Pair = [first: string, second?: number]\n']]))), + When('the model is built')('model', (scope) => builtOf(scope.declarations)), + Then('the canonical text marks the optional member')((scope, expect) => + expect(itemOf(scope.model, '.', 'Pair', 'typeAlias')).toMatchObject({ + kind: 'typeAlias', + type: { kind: 'text', text: '[first: string, second?: number]' }, + })), + ), + ) + + scenario( + 'an interface item keeps its type parameters, heritage and every member kind', + Gherkin.Do.pipe( + Given('an entry declaring an interface with a bounded default parameter, two extensions and property, method members')('declarations', () => + Effect.succeed( + declarations([ + [ + 'index.d.ts', + 'export interface Collection extends Base, Marker {\n' + + ' readonly size: number\n' + + ' count(): number\n' + + ' create(value: A): A\n' + + '}\n', + ], + ]), + )), + When('the model is built')('model', (scope) => builtOf(scope.declarations)), + Then('the interface item carries all of them')((scope, expect) => + expect(itemOf(scope.model, '.', 'Collection', 'interface')).toMatchObject({ + kind: 'interface', + name: 'Collection', + typeParameters: [{ name: 'A', constraint: { kind: 'keyword', name: 'string' }, default: { kind: 'literal', text: '"a"' } }], + extends: [ + { kind: 'reference', name: 'Base', args: [{ kind: 'reference', name: 'A', args: [] }] }, + { kind: 'reference', name: 'Marker', args: [] }, + ], + members: [ + { kind: 'property', name: 'size', optional: false, readonly: true, type: { kind: 'keyword', name: 'number' } }, + { kind: 'method', name: 'count', optional: false, signatures: [{ typeParameters: [], parameters: [], returns: { kind: 'keyword', name: 'number' } }] }, + { + kind: 'method', + name: 'create', + optional: false, + signatures: [ + { + typeParameters: [], + parameters: [{ name: 'value', type: { kind: 'reference', name: 'A', args: [] }, optional: false, rest: false }], + returns: { kind: 'reference', name: 'A', args: [] }, + }, + ], + }, + ], + })), + ), + ) + + scenario( + 'a class item keeps its abstractness, type parameters, heritage and every member kind', + Gherkin.Do.pipe( + Given('an entry declaring an abstract class extending a base, implementing a marker, with a property, a constructor, a getter and a method')( + 'declarations', + () => + Effect.succeed( + declarations([ + [ + 'index.d.ts', + 'export declare abstract class Bag extends Base implements Marker {\n' + + ' readonly size: number\n' + + ' constructor(initial: A)\n' + + ' get first(): A\n' + + ' create(value: A): A\n' + + '}\n', + ], + ]), + ), + ), + When('the model is built')('model', (scope) => builtOf(scope.declarations)), + Then('the class item carries all of them')((scope, expect) => + expect(itemOf(scope.model, '.', 'Bag', 'class')).toMatchObject({ + kind: 'class', + name: 'Bag', + abstract: true, + typeParameters: [{ name: 'A' }], + extends: [{ kind: 'reference', name: 'Base', args: [{ kind: 'reference', name: 'A', args: [] }] }], + implements: [{ kind: 'reference', name: 'Marker', args: [] }], + members: [ + { kind: 'property', name: 'size', optional: false, readonly: true, type: { kind: 'keyword', name: 'number' } }, + { + kind: 'method', + name: 'constructor', + optional: false, + signatures: [ + { + typeParameters: [], + parameters: [{ name: 'initial', type: { kind: 'reference', name: 'A', args: [] }, optional: false, rest: false }], + returns: { kind: 'keyword', name: 'any' }, + }, + ], + }, + { kind: 'property', name: 'first', optional: false, readonly: true, type: { kind: 'reference', name: 'A', args: [] } }, + { + kind: 'method', + name: 'create', + optional: false, + signatures: [ + { + typeParameters: [], + parameters: [{ name: 'value', type: { kind: 'reference', name: 'A', args: [] }, optional: false, rest: false }], + returns: { kind: 'reference', name: 'A', args: [] }, + }, + ], + }, + ], + })), + ), + ) + + scenario( + 'a class keeps its static field, its auto-accessor and its accessor pair exactly, and holds nothing for a private field or a static block', + Gherkin.Do.pipe( + Given('an entry declaring a class with a static field, a private field, an auto-accessor, a get/set pair and a static block')('declarations', () => + Effect.succeed( + declarations([ + [ + 'index.d.ts', + 'export declare class C {\n' + + ' static s: number\n' + + ' private q: string\n' + + ' accessor a: number\n' + + ' get g(): number\n' + + ' set g(v: number)\n' + + ' static {}\n' + + '}\n', + ], + ]), + )), + 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({ + kind: 'class', + name: 'C', + abstract: false, + typeParameters: [], + extends: [], + implements: [], + members: [ + { kind: 'property', name: 'static s', optional: false, readonly: false, type: { kind: 'keyword', name: 'number' } }, + { kind: 'property', name: 'a', optional: false, readonly: false, type: { kind: 'keyword', name: 'number' } }, + { kind: 'property', name: 'g', optional: false, readonly: false, type: { kind: 'keyword', name: 'number' } }, + ], + })), + ), + ) + + scenario( + 'a set accessor models as a writable property and prints without a return type', + Gherkin.Do.pipe( + Given('an interface, a class and a mapped value each declaring `set value(input: string)`')('declarations', () => + Effect.succeed( + declarations([ + [ + 'index.d.ts', + 'export interface Readout {\n set value(input: string)\n}\n' + + 'export declare class Meter {\n set value(input: string)\n}\n' + + 'export declare type Boxed = { [K in "a"]: { set value(input: string) } }\n', + ], + ]), + )), + When('the model is built')('model', (scope) => builtOf(scope.declarations)), + Then('each accessor is a writable property and the printed one invents no return type')((scope, expect) => + expect({ + interface: itemOf(scope.model, '.', 'Readout', 'interface'), + class: itemOf(scope.model, '.', 'Meter', 'class'), + boxed: itemOf(scope.model, '.', 'Boxed', 'typeAlias'), + }).toMatchObject({ + interface: { + kind: 'interface', + members: [{ kind: 'property', name: 'value', optional: false, readonly: false, type: { kind: 'keyword', name: 'string' } }], + }, + class: { + kind: 'class', + members: [{ kind: 'property', name: 'value', optional: false, readonly: false, type: { kind: 'keyword', name: 'string' } }], + }, + boxed: { kind: 'typeAlias', type: { kind: 'text', text: '{ [K in "a"]: { set value(input: string) } }' } }, + })), + ), + ) + + scenario( + 'an unnamed default function models the same default item as a named one', + Gherkin.Do.pipe( + Given('one entry declaring a named default function and another declaring the same function unnamed')('declarations', () => + Effect.succeed( + declarations( + [ + ['named.d.ts', 'export default function format(x: number): string;\n'], + ['unnamed.d.ts', 'export default function(x: number): string;\n'], + ], + [ + ['.', 'named.d.ts'], + ['./unnamed', 'unnamed.d.ts'], + ], + ), + )), + When('the model is built')('model', (scope) => builtOf(scope.declarations)), + Then('the unnamed entry models the default function the named entry models')((scope, expect) => + expect({ unnamed: itemOf(scope.model, './unnamed', 'default', 'function') }).toEqual({ + unnamed: itemOf(scope.model, '.', 'default', 'function'), + })), + ), + ) + + scenario( + 'an unnamed default class models the same default item as a named one', + Gherkin.Do.pipe( + Given('one entry declaring a named default class and another declaring the same class unnamed')('declarations', () => + Effect.succeed( + declarations( + [ + ['named.d.ts', 'export default class Widget {\n\tm(): void;\n}\n'], + ['unnamed.d.ts', 'export default class {\n\tm(): void;\n}\n'], + ], + [ + ['.', 'named.d.ts'], + ['./unnamed', 'unnamed.d.ts'], + ], + ), + )), + When('the model is built')('model', (scope) => builtOf(scope.declarations)), + Then('the unnamed entry models the default class the named entry models')((scope, expect) => + expect({ unnamed: itemOf(scope.model, './unnamed', 'default', 'class') }).toEqual({ + unnamed: itemOf(scope.model, '.', 'default', 'class'), + })), + ), + ) + + scenario( + 'a module that lists its exports offers only the names it lists', + Gherkin.Do.pipe( + Given('an entry exporting one value under another name and an entry whose export list is empty')('declarations', () => + Effect.succeed( + declarations( + [ + ['renamed.d.ts', 'declare const x: number\nexport { x as y }\n'], + ['nothing.d.ts', 'declare const x: number\nexport {}\n'], + ], + [ + ['.', 'renamed.d.ts'], + ['./nothing', 'nothing.d.ts'], + ], + ), + )), + When('the model is built')('model', (scope) => builtOf(scope.declarations)), + Then('each entry offers exactly the names a consumer can import from it')((scope, expect) => + expect({ renamed: itemNames(scope.model, '.'), nothing: itemNames(scope.model, './nothing') }).toEqual({ + renamed: ['y'], + nothing: [], + })), + ), + ) + + scenario( + 'a module that assigns its default export offers no other name', + Gherkin.Do.pipe( + Given('an entry assigning its default export to one of two declarations')('declarations', () => + Effect.succeed(declarations([['index.d.ts', 'declare const x: number\ndeclare const z: number\nexport default x\n']]))), + When('the model is built')('model', (scope) => builtOf(scope.declarations)), + Then('the entry offers the default name alone, bound to the assigned declaration')((scope, expect) => + expect({ + names: itemNames(scope.model, '.'), + default: itemOf(scope.model, '.', 'default', 'variable'), + }).toEqual({ + names: ['default'], + default: { kind: 'variable', name: 'default', constant: true, type: { kind: 'keyword', name: 'number' } }, + })), + ), + ) + + scenario( + 'a module that re-exports from another file offers only those names', + Gherkin.Do.pipe( + Given('three entries re-exporting everything, one name and everything as a namespace from another file')('declarations', () => + Effect.succeed( + declarations( + [ + ['star.d.ts', "export * from './b.js'\ndeclare const x: number\n"], + ['named.d.ts', "export { q } from './b.js'\ndeclare const x: number\n"], + ['namespace.d.ts', "export * as ns from './b.js'\ndeclare const x: number\n"], + ['b.d.ts', 'export declare const q: number\n'], + ], + [ + ['.', 'star.d.ts'], + ['./named', 'named.d.ts'], + ['./namespace', 'namespace.d.ts'], + ], + ), + )), + When('the model is built')('model', (scope) => builtOf(scope.declarations)), + Then('each entry offers exactly the names a consumer can import from it')((scope, expect) => + expect({ + star: itemNames(scope.model, '.'), + named: itemNames(scope.model, './named'), + namespace: itemNames(scope.model, './namespace'), + }).toEqual({ star: ['q'], named: ['q'], namespace: ['ns'] })), + ), + ) + + scenario( + 'a module that exports by declaring keeps the declarations it does not export', + Gherkin.Do.pipe( + Given('an entry declaring a default class beside an unexported class and an entry exporting one declaration beside another')('declarations', () => + Effect.succeed( + declarations( + [ + ['default.d.ts', 'export default class Widget {\n\tm(): void;\n}\ndeclare class Hidden {}\n'], + ['declared.d.ts', 'export declare const y: number\ndeclare const z: number\n'], + ], + [ + ['.', 'default.d.ts'], + ['./declared', 'declared.d.ts'], + ], + ), + )), + When('the model is built')('model', (scope) => builtOf(scope.declarations)), + Then('each entry offers exactly the names a consumer can import from it')((scope, expect) => + expect({ + declared: itemNames(scope.model, '.'), + exported: itemNames(scope.model, './declared'), + }).toEqual({ declared: ['Hidden', 'default'], exported: ['y', 'z'] })), + ), + ) + + scenario( + 'an interface with an index, a call and a construct signature keeps all three members', + Gherkin.Do.pipe( + Given('an entry declaring `interface Callable { [key: string]: number; (self: A): A; new (x: string): Callable }`')( + 'declarations', + () => + Effect.succeed( + declarations([ + ['index.d.ts', 'export interface Callable {\n [key: string]: number\n (self: A): A\n new (x: string): Callable\n}\n'], + ]), + ), + ), + When('the model is built')('model', (scope) => builtOf(scope.declarations)), + Then('the interface carries the index signature, the call signature and the construct signature as a `new` method')((scope, expect) => + expect(itemOf(scope.model, '.', 'Callable', 'interface')).toMatchObject({ + kind: 'interface', + name: 'Callable', + members: [ + { kind: 'property', name: '[key: string]', optional: false, readonly: false, type: { kind: 'keyword', name: 'number' } }, + { + kind: 'call', + signature: { + typeParameters: [{ name: 'A' }], + parameters: [{ name: 'self', type: { kind: 'reference', name: 'A', args: [] }, optional: false, rest: false }], + returns: { kind: 'reference', name: 'A', args: [] }, + }, + }, + { + kind: 'method', + name: 'new', + optional: false, + signatures: [ + { + typeParameters: [], + parameters: [{ name: 'x', type: { kind: 'keyword', name: 'string' }, optional: false, rest: false }], + returns: { kind: 'reference', name: 'Callable', args: [] }, + }, + ], + }, + ], + })), + ), + ) + + scenario( + 'a callback or constructable type nested in another type reads as the compiler writes it', + Gherkin.Do.pipe( + Given('an entry declaring a tuple of callback types beside a constructable type')('declarations', () => + Effect.succeed( + declarations([ + [ + 'index.d.ts', + 'export declare const handlers: [cb: (x: number) => string, done?: () => void]\n' + + 'export declare type Build = new (x: number) => object\n', + ], + ]), + )), + When('the model is built')('model', (scope) => builtOf(scope.declarations)), + Then('each callback reads as an arrow and the constructable type as a `new` arrow')((scope, expect) => + expect({ + handlers: itemOf(scope.model, '.', 'handlers', 'variable'), + build: itemOf(scope.model, '.', 'Build', 'typeAlias'), + }).toMatchObject({ + handlers: { type: { kind: 'text', text: '[cb: (x: number) => string, done?: () => void]' } }, + build: { type: { kind: 'text', text: 'new (x: number) => object' } }, + })), + ), + ) + + scenario( + 'a type the structure does not decompose keeps canonical text, a predicate keeping the meaning of its own syntax', + Gherkin.Do.pipe( + Given('an entry declaring a template literal, a predicate, an assertion, an indexed access, an abstract construct type and object literals holding a construct or index signature beside a property')('declarations', () => + Effect.succeed( + declarations([ + [ + 'index.d.ts', + 'export type Template = `a${string}`\n' + + 'export type Predicate = (x: unknown) => x is string\n' + + 'export type Assertion = (x: unknown) => asserts x is string\n' + + "export type Indexed = A['b']\n" + + 'export type AbstractConstruct = abstract new () => object\n' + + 'export type ConstructBesideProperty = { new (): object; p: number }\n' + + 'export type IndexBesideProperty = { [k: string]: number; p: number }\n', + ], + ]), + )), + When('the model is built')('model', (scope) => builtOf(scope.declarations)), + Then('each alias carries the canonical form of its own syntax')((scope, expect) => + expect({ + template: itemOf(scope.model, '.', 'Template', 'typeAlias'), + predicate: itemOf(scope.model, '.', 'Predicate', 'typeAlias'), + assertion: itemOf(scope.model, '.', 'Assertion', 'typeAlias'), + indexed: itemOf(scope.model, '.', 'Indexed', 'typeAlias'), + abstractConstruct: itemOf(scope.model, '.', 'AbstractConstruct', 'typeAlias'), + constructBesideProperty: itemOf(scope.model, '.', 'ConstructBesideProperty', 'typeAlias'), + indexBesideProperty: itemOf(scope.model, '.', 'IndexBesideProperty', 'typeAlias'), + }).toMatchObject({ + template: { kind: 'typeAlias', type: { kind: 'text', text: '`a${string}`' } }, + predicate: { type: { kind: 'function', signature: { typeParameters: [], parameters: [{ name: 'x', type: { kind: 'keyword', name: 'unknown' }, optional: false, rest: false }], returns: { kind: 'text', text: 'x is string' } } } }, + assertion: { type: { kind: 'function', signature: { typeParameters: [], parameters: [{ name: 'x', type: { kind: 'keyword', name: 'unknown' }, optional: false, rest: false }], returns: { kind: 'text', text: 'asserts x is string' } } } }, + indexed: { type: { kind: 'text', text: 'A["b"]' } }, + abstractConstruct: { type: { kind: 'text', text: 'abstract new () => object' } }, + constructBesideProperty: { type: { kind: 'text', text: '{ new (): object; p: number }' } }, + indexBesideProperty: { type: { kind: 'text', text: '{ [key: string]: number; p: number }' } }, + })), + ), + ) + + scenario( + 'a local alias whose name contains dollar signs keeps its name where a text type names it', + Gherkin.Do.pipe( + Given('an entry whose text type names two aliases it reaches, their names containing dollar signs')('declarations', () => + Effect.succeed( + declarations([ + [ + 'index.d.ts', + "import type { T$$ as First, $$T as Second } from './a.js'\n" + + 'export declare type M = { readonly [P in keyof First]: Second }\n', + ], + ['a.d.ts', 'export type T$$ = { readonly a: string }\nexport type $$T = { readonly b: number }\n'], + ]), + )), + When('the model is built')('model', (scope) => builtOf(scope.declarations)), + Then('the alias names inside the text are unchanged and match the locals')((scope, expect) => + expect({ + m: itemOf(scope.model, '.', 'M', 'typeAlias'), + locals: entryAt(scope.model, '.').locals.map((local) => local.name), + }).toMatchObject({ + m: { kind: 'typeAlias', type: { kind: 'text', text: '{ readonly [P in keyof T$$]: $$T }' } }, + locals: ['$$T', 'T$$'], + })), + ), + ) + + scenario( + 'a declaration a file keeps hidden is a reference of its own, not the exported name', + Gherkin.Do.pipe( + Given('an entry re-exporting an interface and three functions whose result types name hidden declarations, two of them sharing a name')( + 'declarations', + () => + Effect.succeed( + declarations([ + [ + 'index.d.ts', + "export { Item } from './item.js'\n" + + "export { make } from './make.js'\n" + + "export { a } from './a.js'\n" + + "export { b } from './b.js'\n", + ], + ['item.d.ts', 'export interface Item { readonly id: string }\n'], + ['make.d.ts', 'interface Item { readonly other: number }\nexport declare function make(): Item\nexport {}\n'], + ['a.d.ts', 'interface Opts { readonly a: string }\nexport declare function a(): Opts\nexport {}\n'], + ['b.d.ts', 'interface Opts { readonly b: number }\nexport declare function b(): Opts\nexport {}\n'], + ]), + ), + ), + When('the model is built')('model', (scope) => builtOf(scope.declarations)), + Then('each hidden declaration becomes its own reference, distinct from the exported name')((scope, expect) => + expect({ + exported: itemOf(scope.model, '.', 'Item', 'interface'), + make: itemOf(scope.model, '.', 'make', 'function'), + a: itemOf(scope.model, '.', 'a', 'function'), + b: itemOf(scope.model, '.', 'b', 'function'), + }).toMatchObject({ + exported: { kind: 'interface', name: 'Item' }, + make: { signatures: [{ returns: { kind: 'reference', name: 'Item_2', args: [] } }] }, + a: { signatures: [{ returns: { kind: 'reference', name: 'Opts', args: [] } }] }, + b: { signatures: [{ returns: { kind: 'reference', name: 'Opts_2', args: [] } }] }, + })), + ), + ) + + scenario( + 'two files that re-export each other offer each other’s exports once', + Gherkin.Do.pipe( + Given('an entry whose file re-exports a file that re-exports it back, each file declaring one value')('declarations', () => + Effect.succeed( + declarations( + [ + ['a.d.ts', "export * from './b.js'\nexport declare const a: string\n"], + ['b.d.ts', "export * from './a.js'\nexport declare const b: number\n"], + ], + [['.', 'a.d.ts']], + ), + )), + When('the model is built')('model', (scope) => builtOf(scope.declarations)), + Then('the entry offers each file’s own value, once')((scope, expect) => + expect({ + names: itemNames(scope.model, '.'), + a: itemOf(scope.model, '.', 'a', 'variable'), + b: itemOf(scope.model, '.', 'b', 'variable'), + }).toEqual({ + 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' } }, + })), + ), + ) + + scenario( + 'a package with two entry points models each entry from its own file', + Gherkin.Do.pipe( + Given('a main entry and a `./util` entry, each declaring its own value')('declarations', () => + Effect.succeed( + declarations( + [ + ['index.d.ts', 'export declare const main: string\n'], + ['util.d.ts', 'export declare const helper: number\n'], + ], + [ + ['./util', 'util.d.ts'], + ['.', 'index.d.ts'], + ], + ), + )), + When('the model is built')('model', (scope) => builtOf(scope.declarations)), + Then('the model lists both entries in path order, each with only its own value')((scope, expect) => + expect(scope.model.entryPoints.map((entry) => ({ path: entry.path, names: entry.items.map((item) => item.name) }))).toEqual([ + { path: '.', names: ['main'] }, + { path: './util', names: ['helper'] }, + ])), + ), + ) + + scenario( + "a type parameter does not hide a same-named type reached in another file", + Gherkin.Do.pipe( + Given('an entry whose generic `f` returns `ns.T`, where `ns` is the module `./x.js` and `x.d.ts` declares the type alias `T`')('declarations', () => + Effect.succeed( + declarations([ + ['index.d.ts', "import type * as ns from './x.js'\nexport declare function f(): ns.T\n"], + ['x.d.ts', 'export type T = { readonly a: string }\n'], + ]), + )), + 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([ + { + kind: 'typeAlias', + name: 'T', + typeParameters: [], + type: { kind: 'object', members: [{ kind: 'property', name: 'a', optional: false, readonly: true, type: { kind: 'keyword', name: 'string' } }] }, + }, + ])), + ), + ) + + scenario( + 'a private constructor is a member of its class, apart from a public one', + Gherkin.Do.pipe( + Given('an entry declaring `class Closed { private constructor() }` and `class Open { constructor() }`')('declarations', () => + Effect.succeed(declarations([['index.d.ts', 'export declare class Closed {\n private constructor()\n}\nexport declare class Open {\n constructor()\n}\n']]))), + When('the model is built')('model', (scope) => builtOf(scope.declarations)), + Then('`Closed` holds a `private constructor` member and `Open` a `constructor` member')((scope, expect) => + expect({ closed: itemOf(scope.model, '.', 'Closed', 'class'), open: itemOf(scope.model, '.', 'Open', 'class') }).toMatchObject({ + closed: { members: [{ kind: 'method', name: 'private constructor', optional: false, signatures: [{ typeParameters: [], parameters: [] }] }] }, + open: { members: [{ kind: 'method', name: 'constructor', optional: false, signatures: [{ typeParameters: [], parameters: [] }] }] }, + })), + ), + ) + + scenario( + "an index signature is named by its key type, whatever its parameter's name", + Gherkin.Do.pipe( + Given('an entry declaring `interface A { [k: string]: number }` and `type B = { [index: string]: number } & { readonly b: true }`')('declarations', () => + Effect.succeed(declarations([['index.d.ts', 'export interface A {\n [k: string]: number\n}\nexport type B = { [index: string]: number } & { readonly b: true }\n']]))), + When('the model is built')('model', (scope) => builtOf(scope.declarations)), + Then("`A` has the member `[key: string]`, and `B`'s index-only object reads `{ [key: string]: number }`")((scope, expect) => + expect({ a: itemOf(scope.model, '.', 'A', 'interface'), b: itemOf(scope.model, '.', 'B', 'typeAlias') }).toMatchObject({ + a: { members: [{ kind: 'property', name: '[key: string]', optional: false, readonly: false, type: { kind: 'keyword', name: 'number' } }] }, + b: { type: { kind: 'intersection', members: expect.arrayContaining([{ kind: 'text', text: '{ [key: string]: number }' }]) } }, + })), + ), + ) + + scenario( + 'an inferred name inside a conditional type reads as the name it binds, not the import or declaration it shadows', + Gherkin.Do.pipe( + Given( + 'an entry importing type `U` from another package, keeping an interface `V` to itself and re-exporting a different `V` from another file, with aliases `Elem` and `Pick2` binding `U` and `V` by `infer`', + )('declarations', () => + Effect.succeed( + declarations([ + [ + 'index.d.ts', + "import type { U } from 'pkg'\n" + + 'interface V { readonly hidden: string }\n' + + 'export type Elem = T extends Array ? U : never\n' + + 'export type Pick2 = T extends Map ? [V, W] : never\n' + + "export { V } from './v.js'\n", + ], + ['v.d.ts', 'export interface V { readonly exported: string }\n'], + ]), + )), + When('the model is built')('model', (scope) => builtOf(scope.declarations)), + Then('each alias reads with the names its conditional binds, untouched by the import or the hidden interface')((scope, expect) => + expect({ + elem: itemOf(scope.model, '.', 'Elem', 'typeAlias'), + pick2: itemOf(scope.model, '.', 'Pick2', 'typeAlias'), + }).toMatchObject({ + elem: { kind: 'typeAlias', type: { kind: 'text', text: 'T extends Array ? U : never' } }, + pick2: { kind: 'typeAlias', type: { kind: 'text', text: 'T extends Map ? [V, W] : never' } }, + })), + ), + ) + + scenario( + 'a class member carries its restricted access, staticness and abstractness in its name', + Gherkin.Do.pipe( + Given('an entry declaring `protected p`, `protected static q`, `abstract m`, `protected abstract get n`, `abstract x`, `abstract accessor a`, a public `get w` with a `protected set w`, `private static z`, `public r` and `protected constructor` in one class')('declarations', () => + 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({ + kind: 'class', name: 'C', abstract: true, typeParameters: [], extends: [], implements: [], + members: [ + { kind: 'property', name: 'protected p', optional: false, readonly: false, type: { kind: 'keyword', name: 'string' } }, + { kind: 'property', name: 'protected static q', optional: false, readonly: false, type: { kind: 'keyword', name: 'number' } }, + { kind: 'method', name: 'abstract m', optional: false, signatures: [{ typeParameters: [], parameters: [], returns: { kind: 'keyword', name: 'void' } }] }, + { kind: 'property', name: 'protected abstract n', optional: false, readonly: true, type: { kind: 'keyword', name: 'number' } }, + { kind: 'property', name: 'abstract x', optional: false, readonly: false, type: { kind: 'keyword', name: 'string' } }, + { kind: 'property', name: 'abstract a', optional: false, readonly: false, type: { kind: 'keyword', name: 'number' } }, + { kind: 'property', name: 'w', optional: false, readonly: true, type: { kind: 'keyword', name: 'number' } }, + { kind: 'property', name: 'protected w', optional: false, readonly: false, type: { kind: 'keyword', name: 'number' } }, + { kind: 'property', name: 'r', optional: false, readonly: false, type: { kind: 'keyword', name: 'string' } }, + { kind: 'method', name: 'protected constructor', optional: false, signatures: [{ typeParameters: [], parameters: [], returns: { kind: 'keyword', name: 'any' } }] }, + ], + })), + ), + ) + }) diff --git a/packages/api-extractor/tests/front-end-namespaces.integration.test.ts b/packages/api-extractor/tests/front-end-namespaces.integration.test.ts new file mode 100644 index 0000000..fdb9c99 --- /dev/null +++ b/packages/api-extractor/tests/front-end-namespaces.integration.test.ts @@ -0,0 +1,257 @@ +import { Gherkin, Given, it, makeFeature, Then, When } from '@systemfsoftware/effect-gherkin-spec' +import { Effect, Layer } from 'effect' +import { builtOf, declarations, itemOf } from './__fixtures__/declarations.ts' + +const Feature = makeFeature({ it }) + +Feature('Building the namespaces of a package API model') + .withLayer(Layer.empty) + .body(({ scenario }) => { + scenario( + 'a file re-exported under a namespace becomes a namespace item', + Gherkin.Do.pipe( + Given('an entry re-exporting one of its files under a namespace')('declarations', () => + Effect.succeed( + declarations([ + ['index.d.ts', "export * as ns from './ns.js'\n"], + ['ns.d.ts', 'export declare const inner: string\n'], + ]), + )), + When('the model is built')('model', (scope) => builtOf(scope.declarations)), + Then('the namespace item holds that file’s export')((scope, expect) => + expect(itemOf(scope.model, '.', 'ns', 'namespace')).toMatchObject({ + kind: 'namespace', + name: 'ns', + items: [{ kind: 'variable', name: 'inner', constant: true, type: { kind: 'keyword', name: 'string' } }], + })), + ), + ) + + scenario( + 'a namespace or enum declared twice is one item holding every member', + Gherkin.Do.pipe( + Given('an entry declaring namespace `N` twice and enum `E` twice, each declaration adding one member')('declarations', () => + Effect.succeed( + declarations([ + [ + 'index.d.ts', + 'export declare namespace N { const a: number }\nexport declare namespace N { const b: string }\nexport declare enum E { A = 1 }\nexport declare enum E { B = 2 }\n', + ], + ]), + )), + 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({ + namespace: { + kind: 'namespace', + name: 'N', + items: [ + { kind: 'variable', name: 'a', constant: true, type: { kind: 'keyword', name: 'number' } }, + { kind: 'variable', name: 'b', constant: true, type: { kind: 'keyword', name: 'string' } }, + ], + }, + enumeration: { kind: 'enum', name: 'E', const: false, members: [{ name: 'A', value: '1' }, { name: 'B', value: '2' }] }, + })), + ), + ) + + scenario( + 'a namespace or enum declared twice inside a namespace body is one item holding every member', + Gherkin.Do.pipe( + Given('an entry declaring `N` twice, each holding a `namespace M` and an `enum E` that adds one member')('declarations', () => + Effect.succeed( + declarations([ + [ + 'index.d.ts', + 'export declare namespace N { namespace M { const a: number } }\n' + + 'export declare namespace N { namespace M { const b: string } }\n' + + 'export declare namespace N { enum E { A = 1 } }\n' + + 'export declare namespace N { enum E { B = 2 } }\n', + ], + ]), + )), + 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({ + kind: 'namespace', + name: 'N', + items: [ + { kind: 'enum', name: 'E', const: false, members: [{ name: 'A', value: '1' }, { name: 'B', value: '2' }] }, + { + kind: 'namespace', + name: 'M', + items: [ + { kind: 'variable', name: 'a', constant: true, type: { kind: 'keyword', name: 'number' } }, + { kind: 'variable', name: 'b', constant: true, type: { kind: 'keyword', name: 'string' } }, + ], + }, + ], + })), + ), + ) + + scenario( + 'a namespace exported from inside a namespace body is an item of it, merged when declared twice', + Gherkin.Do.pipe( + Given('an entry declaring `N` twice, each body exporting a `namespace M` that holds one value')('declarations', () => + Effect.succeed( + declarations([ + [ + 'index.d.ts', + 'export declare namespace N { export namespace M { const a: number } }\n' + + 'export declare namespace N { export namespace M { const b: string } }\n', + ], + ]), + )), + 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({ + kind: 'namespace', + name: 'N', + items: [ + { + kind: 'namespace', + name: 'M', + items: [ + { kind: 'variable', name: 'a', constant: true, type: { kind: 'keyword', name: 'number' } }, + { kind: 'variable', name: 'b', constant: true, type: { kind: 'keyword', name: 'string' } }, + ], + }, + ], + })), + ), + ) + + scenario( + 'a name declared in a namespace body refers to that declaration inside the body, not to an import of the same name', + Gherkin.Do.pipe( + Given('an entry importing `A` from `pkg` and declaring namespace `N` with its own `A` and a function `f` returning `A`')('declarations', () => + Effect.succeed( + declarations([ + [ + 'index.d.ts', + "import type { A } from 'pkg'\n" + + 'export declare namespace N { interface A { y: number } function f(): A }\n' + + 'export declare const g: A\n', + ], + ]), + )), + 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({ + n: { + kind: 'namespace', + name: 'N', + items: [ + { + kind: 'interface', + name: 'A', + typeParameters: [], + extends: [], + members: [{ kind: 'property', name: 'y', optional: false, readonly: false, type: { kind: 'keyword', name: 'number' } }], + }, + { kind: 'function', name: 'f', signatures: [{ typeParameters: [], parameters: [], returns: { kind: 'reference', name: 'A', args: [] } }] }, + ], + }, + g: { kind: 'variable', name: 'g', constant: true, type: { kind: 'reference', name: 'A', module: 'pkg', args: [] } }, + })), + ), + ) + + scenario( + 'a typeof query and a class heritage read the names a namespace body declares as values', + Gherkin.Do.pipe( + Given('an entry importing `A` and `B` from `pkg`, with `N` declaring a value `A`, an interface `B`, `typeof A`, `typeof B` and a class extending `A`')( + 'declarations', + () => + Effect.succeed(declarations([[ + 'index.d.ts', + "import { A, B } from 'pkg'\nexport declare namespace N {\n const A: new () => object\n interface B { b: string }\n type OfA = typeof A\n type OfB = typeof B\n class C extends A {}\n}\n", + ]])), + ), + When('the model is built')('model', (scope) => builtOf(scope.declarations)), + 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: 'class', name: 'C', extends: [{ kind: 'reference', name: 'A', args: [] }] }), + ]), + })), + ), + ) + + scenario( + "a class or enum one block of a merged namespace declares is read in both spaces from the other block", + Gherkin.Do.pipe( + Given('an entry importing `C` and `E` from `pkg`, declaring `N` once with a class `C` and an enum `E`, and again with types naming `C`, `typeof C`, `E` and `typeof E`')( + 'declarations', + () => + Effect.succeed(declarations([[ + 'index.d.ts', + "import { C, E } from 'pkg'\nexport declare namespace N {\n class C {}\n enum E { A = 1 }\n}\nexport declare namespace N {\n type OfC = C\n type QueryC = typeof C\n type OfE = E\n type QueryE = typeof E\n}\n", + ]])), + ), + When('the model is built')('model', (scope) => builtOf(scope.declarations)), + 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' } }, + ]), + })), + ), + ) + + scenario( + 'a namespace body value does not shadow an imported type of the same name', + Gherkin.Do.pipe( + Given('an entry importing type `A` from `pkg` and declaring namespace `N` with a value `A` and a function `f` returning `A`')('declarations', () => + Effect.succeed( + declarations([ + [ + 'index.d.ts', + "import type { A } from 'pkg'\n" + + 'export declare namespace N { const A: number; function f(): A }\n', + ], + ]), + )), + 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({ + kind: 'namespace', + name: 'N', + items: [ + { kind: 'variable', name: 'A', constant: true, type: { kind: 'keyword', name: 'number' } }, + { + kind: 'function', + name: 'f', + signatures: [ + { typeParameters: [], parameters: [], returns: { kind: 'reference', name: 'A', module: 'pkg', args: [] } }, + ], + }, + ], + })), + ), + ) + + scenario( + 'a type reached through a namespace the file keeps hidden reads as the file writes it', + Gherkin.Do.pipe( + Given('an entry exporting `X = C.Inner`, where namespace `C` is declared but not exported')('declarations', () => + Effect.succeed( + declarations([['index.d.ts', 'declare namespace C { interface Inner { readonly a: string } }\nexport type X = C.Inner\nexport {}\n']]), + )), + 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({ + kind: 'typeAlias', + name: 'X', + typeParameters: [], + type: { kind: 'reference', name: 'C.Inner', args: [] }, + })), + ), + ) + }) diff --git a/packages/api-extractor/tests/front-end-refusals.integration.test.ts b/packages/api-extractor/tests/front-end-refusals.integration.test.ts new file mode 100644 index 0000000..47082bf --- /dev/null +++ b/packages/api-extractor/tests/front-end-refusals.integration.test.ts @@ -0,0 +1,247 @@ +import { Gherkin, Given, it, makeFeature, Then, When } from '@systemfsoftware/effect-gherkin-spec' +import { FrontEnd } from '@systemfsoftware/api-extractor' +import { Effect, Layer } from 'effect' +import { builtOf, declarations } from './__fixtures__/declarations.ts' + +const Feature = makeFeature({ it }) + +Feature('Refusing declaration text the model cannot hold') + .withLayer(Layer.empty) + .body(({ scenario }) => { + scenario( + 'an assignment export is refused naming the declaration file', + Gherkin.Do.pipe( + Given('an entry exporting by assignment')('declarations', () => + Effect.succeed(declarations([['index.d.ts', 'declare const value: string\nexport = value\n']]))), + When('the model is built')('refusal', (scope) => Effect.flip(FrontEnd.buildModel(scope.declarations))), + Then('the refusal names the declaration file')((scope, expect) => + expect(scope.refusal).toMatchObject({ _tag: 'UnsupportedDeclaration', file: 'index.d.ts' })), + ), + ) + + scenario( + 'an enum member whose declared value is computed is refused naming the member, not silently left without a value', + Gherkin.Do.pipe( + Given('a declaration file declaring `enum Flags { Read = 1, Write = 1 << 1 }`')('declarations', () => + Effect.succeed(declarations([['index.d.ts', 'export declare enum Flags {\n Read = 1,\n Write = 1 << 1\n}\n']]))), + When('the model is built')('refusal', (scope) => Effect.flip(FrontEnd.buildModel(scope.declarations))), + Then('the refusal names the file and the member')((scope, expect) => + expect(scope.refusal).toMatchObject({ _tag: 'UnsupportedDeclaration', file: 'index.d.ts', construct: 'enum member `Write` with a computed value' })), + ), + ) + + scenario( + 'an enum whose merged declarations repeat a member name is refused naming the repeated member', + Gherkin.Do.pipe( + Given('an entry declaring enum `E` with member `A` twice across two declarations')('declarations', () => + Effect.succeed(declarations([['index.d.ts', 'export declare enum E { A = 1 }\nexport declare enum E { A = 2 }\n']]))), + When('the model is built')('refusal', (scope) => Effect.flip(FrontEnd.buildModel(scope.declarations))), + Then('the refusal names the file and the repeated member')((scope, expect) => + expect({ + _tag: scope.refusal._tag, + file: scope.refusal.file, + construct: scope.refusal.construct, + }).toEqual({ _tag: 'UnsupportedDeclaration', file: 'index.d.ts', construct: 'enum member `A` declared twice' })), + ), + ) + + scenario( + 'a global augmentation is refused naming the file and the construct', + Gherkin.Do.pipe( + Given('a declaration file augmenting the global scope')('declarations', () => + Effect.succeed(declarations([['index.d.ts', 'declare global {\n interface Window { extra: string }\n}\nexport declare type T = string\n']]))), + When('the model is built')('refusal', (scope) => Effect.flip(FrontEnd.buildModel(scope.declarations))), + Then('the refusal names the file and the global augmentation')((scope, expect) => + expect(scope.refusal).toMatchObject({ _tag: 'UnsupportedDeclaration', file: 'index.d.ts', construct: '`declare global`' })), + ), + ) + + scenario( + 'a module augmentation is refused naming the file and the named module', + Gherkin.Do.pipe( + Given('a declaration file augmenting another module')('declarations', () => + Effect.succeed(declarations([['index.d.ts', 'declare module "other" {\n interface Thing { extra: string }\n}\nexport declare type T = string\n']]))), + When('the model is built')('refusal', (scope) => Effect.flip(FrontEnd.buildModel(scope.declarations))), + Then('the refusal names the file and the module')((scope, expect) => + expect(scope.refusal).toMatchObject({ _tag: 'UnsupportedDeclaration', file: 'index.d.ts', construct: '`declare module "other"`' })), + ), + ) + + scenario( + 'a named re-export from another package is refused naming the file and the re-export', + Gherkin.Do.pipe( + Given('an entry re-exporting one name from another package')('declarations', () => + Effect.succeed(declarations([['index.d.ts', "export { X } from 'other-pkg'\n"]]))), + When('the model is built')('refusal', (scope) => Effect.flip(FrontEnd.buildModel(scope.declarations))), + Then('the refusal names the file and the re-export')((scope, expect) => + expect(scope.refusal).toMatchObject({ + _tag: 'UnsupportedDeclaration', + file: 'index.d.ts', + construct: 're-export of "X" from "other-pkg"', + })), + ), + ) + + scenario( + 'an export list naming a name with no declaration is refused naming the file and the export', + Gherkin.Do.pipe( + Given('an entry declaring `present` and exporting `{ present, Missing }`')('declarations', () => + Effect.succeed(declarations([['index.d.ts', 'declare const present: number\nexport { present, Missing }\n']]))), + When('the model is built')('refusal', (scope) => Effect.flip(FrontEnd.buildModel(scope.declarations))), + Then('the refusal names the file and the unresolved export')((scope, expect) => + expect({ + _tag: scope.refusal._tag, + file: scope.refusal.file, + construct: scope.refusal.construct, + }).toEqual({ _tag: 'UnsupportedDeclaration', file: 'index.d.ts', construct: '`export { Missing }`, which names no declaration' })), + ), + ) + + scenario( + 'an export list reached through a star export is refused naming the file holding the unresolved name', + Gherkin.Do.pipe( + Given('an entry star-exporting a file declaring `present` and exporting `{ present, Missing }`')('declarations', () => + Effect.succeed( + declarations([ + ['index.d.ts', "export * from './a.js'\n"], + ['a.d.ts', 'declare const present: number\nexport { present, Missing }\n'], + ]), + )), + When('the model is built')('refusal', (scope) => Effect.flip(FrontEnd.buildModel(scope.declarations))), + Then('the refusal names the file holding the unresolved export')((scope, expect) => + expect({ + _tag: scope.refusal._tag, + file: scope.refusal.file, + construct: scope.refusal.construct, + }).toEqual({ _tag: 'UnsupportedDeclaration', file: 'a.d.ts', construct: '`export { Missing }`, which names no declaration' })), + ), + ) + + scenario( + 'a re-export from another package reached through a star export is refused naming the file that re-exports it, past an earlier star target listing the name unresolved', + Gherkin.Do.pipe( + Given("an entry star-exporting a file holding `export { x } from 'lodash'`")('direct', () => + Effect.succeed( + declarations([ + ['index.d.ts', "export * from './a.js'\n"], + ['a.d.ts', "export { x } from 'lodash'\n"], + ]), + )), + Given("an entry star-exporting `a.d.ts`, which lists `x` with no declaration, then `b.d.ts`, holding `export { x } from 'lodash'`")('behindUnresolved', () => + Effect.succeed( + declarations([ + ['index.d.ts', "export * from './a.js'\nexport * from './b.js'\n"], + ['a.d.ts', 'declare const present: number\nexport { present, x }\n'], + ['b.d.ts', "export { x } from 'lodash'\n"], + ]), + )), + When('each model is built')('refusals', (scope) => + Effect.all({ + direct: Effect.flip(FrontEnd.buildModel(scope.direct)), + behindUnresolved: Effect.flip(FrontEnd.buildModel(scope.behindUnresolved)), + })), + Then('each refusal names the file holding the re-export')((scope, expect) => + expect({ + direct: { _tag: scope.refusals.direct._tag, file: scope.refusals.direct.file, construct: scope.refusals.direct.construct }, + behindUnresolved: { _tag: scope.refusals.behindUnresolved._tag, file: scope.refusals.behindUnresolved.file, construct: scope.refusals.behindUnresolved.construct }, + }).toEqual({ + direct: { _tag: 'UnsupportedDeclaration', file: 'a.d.ts', construct: 're-export of "x" from "lodash"' }, + behindUnresolved: { _tag: 'UnsupportedDeclaration', file: 'b.d.ts', construct: 're-export of "x" from "lodash"' }, + })), + ), + ) + + scenario( + 'an untyped constant, a non-literal initialiser, an import by assignment, an exported namespace name, an expression default export, a destructuring constant, a destructuring parameter, a dotted namespace declaration, an unparseable declaration body and a bare import type are each refused naming the file and the construct', + Gherkin.Do.pipe( + Given('a file declaring `declare const x;`')('noType', () => + Effect.succeed(declarations([['index.d.ts', 'declare const x;\n']]))), + Given('a file whose exported constant is initialised by a call')('callInitialised', () => + Effect.succeed(declarations([['index.d.ts', 'declare function f(): string\nexport declare const x = f()\n']]))), + Given("a file importing by assignment with `import x = require('y')`")('importEquals', () => + Effect.succeed(declarations([['index.d.ts', "import x = require('y')\n"]]))), + Given('a file exporting a namespace name with `export as namespace X`')('namespaceName', () => + Effect.succeed(declarations([['index.d.ts', 'export as namespace X\n']]))), + Given('a file exporting an expression as its default with `export default 1 + 1`')('defaultExpression', () => + Effect.succeed(declarations([['index.d.ts', 'export default 1 + 1\n']]))), + Given('a file exporting a destructuring constant with `export declare const { a }: { a: string }`')('destructured', () => + Effect.succeed(declarations([['index.d.ts', 'export declare const { a }: { a: string }\n']]))), + Given('a file declaring a function with a destructuring parameter `{ a }: { a: string }`')('destructuringParam', () => + Effect.succeed(declarations([['index.d.ts', 'export declare function f({ a }: { a: string }): void\n']]))), + Given('a file declaring a dotted namespace `A.B`')('dottedNamespace', () => + Effect.succeed(declarations([['index.d.ts', 'export declare namespace A.B { const x: number }\n']]))), + Given('a file whose declaration body is malformed `export declare const = ;`')('unparseable', () => + Effect.succeed(declarations([['index.d.ts', 'export declare const = ;\n']]))), + Given("a file whose constant type is the bare `typeof import('./x.js')`")('bareImportType', () => + Effect.succeed( + declarations([ + ['index.d.ts', "export declare const v: typeof import('./x.js')\n"], + ['x.d.ts', 'export declare const inner: string\n'], + ]), + )), + When('each declaration file is built')('refusals', (scope) => + Effect.all({ + noType: Effect.flip(FrontEnd.buildModel(scope.noType)), + callInitialised: Effect.flip(FrontEnd.buildModel(scope.callInitialised)), + importEquals: Effect.flip(FrontEnd.buildModel(scope.importEquals)), + namespaceName: Effect.flip(FrontEnd.buildModel(scope.namespaceName)), + defaultExpression: Effect.flip(FrontEnd.buildModel(scope.defaultExpression)), + destructured: Effect.flip(FrontEnd.buildModel(scope.destructured)), + destructuringParam: Effect.flip(FrontEnd.buildModel(scope.destructuringParam)), + dottedNamespace: Effect.flip(FrontEnd.buildModel(scope.dottedNamespace)), + unparseable: Effect.flip(FrontEnd.buildModel(scope.unparseable)), + bareImportType: Effect.flip(FrontEnd.buildModel(scope.bareImportType)), + })), + Then('each refusal names the declaration file and the construct it cannot model')((scope, expect) => + expect(scope.refusals).toMatchObject({ + noType: { _tag: 'UnsupportedDeclaration', file: 'index.d.ts', construct: 'a variable declaration without a type' }, + callInitialised: { _tag: 'UnsupportedDeclaration', file: 'index.d.ts', construct: 'a variable initialiser that is not a literal' }, + importEquals: { _tag: 'UnsupportedDeclaration', file: 'index.d.ts', construct: '`import =`' }, + namespaceName: { _tag: 'UnsupportedDeclaration', file: 'index.d.ts', construct: '`export as namespace`' }, + defaultExpression: { _tag: 'UnsupportedDeclaration', file: 'index.d.ts', construct: '`export default` of an expression' }, + destructured: { _tag: 'UnsupportedDeclaration', file: 'index.d.ts', construct: 'a destructuring variable declaration' }, + destructuringParam: { _tag: 'UnsupportedDeclaration', file: 'index.d.ts', construct: 'a destructuring parameter' }, + dottedNamespace: { _tag: 'UnsupportedDeclaration', file: 'index.d.ts', construct: 'a dotted namespace declaration' }, + unparseable: { _tag: 'UnsupportedDeclaration', file: 'index.d.ts', construct: expect.stringMatching(/^unparseable declaration text/) }, + bareImportType: { _tag: 'UnsupportedDeclaration', file: 'index.d.ts', construct: 'a bare import type' }, + })), + ), + ) + + scenario( + 'an import alias inside a namespace body is refused, as it is at the top of a file', + Gherkin.Do.pipe( + Given('an entry whose namespace `N` writes `import X = M.Y`')('declarations', () => + Effect.succeed( + declarations([['index.d.ts', 'export declare namespace N { import X = M.Y; const a: X }\ndeclare namespace M { type Y = string }\nexport {}\n']]), + )), + When('the model is built')('refusal', (scope) => Effect.flip(builtOf(scope.declarations))), + Then('it is refused naming the file and `import =`')((scope, expect) => + expect(scope.refusal).toMatchObject({ _tag: 'UnsupportedDeclaration', file: 'index.d.ts', construct: '`import =`' })), + ), + ) + + scenario( + 'an exported import alias inside a namespace body is refused, as it is at the top of a file', + Gherkin.Do.pipe( + Given('an entry whose namespace `N` writes `export import X = M.Y`')('declarations', () => + Effect.succeed( + declarations([['index.d.ts', 'export declare namespace N { export import X = M.Y; const a: X }\ndeclare namespace M { type Y = string }\nexport {}\n']]), + )), + When('the model is built')('refusal', (scope) => Effect.flip(builtOf(scope.declarations))), + Then('it is refused naming the file and `import =`')((scope, expect) => + expect(scope.refusal).toMatchObject({ _tag: 'UnsupportedDeclaration', file: 'index.d.ts', construct: '`import =`' })), + ), + ) + + scenario( + 'a destructuring constant inside a namespace body is refused, as it is at the top of a file', + Gherkin.Do.pipe( + Given('an entry whose namespace `N` declares `const { a }: { a: number }`')('declarations', () => + Effect.succeed(declarations([['index.d.ts', 'export declare namespace N {\n const { a }: { a: number }\n}\n']]))), + When('the model is built')('refusal', (scope) => Effect.flip(FrontEnd.buildModel(scope.declarations))), + Then('it is refused naming the file and the destructuring declaration')((scope, expect) => + expect(scope.refusal).toMatchObject({ _tag: 'UnsupportedDeclaration', file: 'index.d.ts', construct: 'a destructuring variable declaration' })), + ), + ) + }) diff --git a/packages/api-extractor/tests/front-end-routing.integration.test.ts b/packages/api-extractor/tests/front-end-routing.integration.test.ts new file mode 100644 index 0000000..32e156c --- /dev/null +++ b/packages/api-extractor/tests/front-end-routing.integration.test.ts @@ -0,0 +1,277 @@ +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' + +const Feature = makeFeature({ it }) + +const emitters = Layer.mergeAll(FrontEnd.isolatedEmitterLayer, FrontEnd.compilerEmitterLayer).pipe(Layer.provideMerge(NodeServices.layer)) + +const packageRoot = Effect.flatMap(Path.Path, (path) => Effect.map(path.fromFileUrl(new URL('..', import.meta.url)), (root) => path.resolve(root))) + +const packageOf = (manifest: object, sources: Record) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem + const path = yield* Path.Path + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: 'front-end-package-' }) + yield* fileSystem.makeDirectory(path.join(root, 'src')) + yield* fileSystem.writeFileString(path.join(root, 'package.json'), JSON.stringify(manifest)) + yield* Effect.forEach(Object.entries(sources), ([file, text]) => fileSystem.writeFileString(path.join(root, 'src', file), text), { discard: true }) + return root + }) + +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 destructuredExportPackage = packageOf(sourceManifest('destructured', './src/mod.ts'), { + 'mod.ts': 'const pair = { a: 1, b: 2 }\nexport const { a, b } = pair\n', +}) + +const typeIncorrectSourcePackage = packageOf(sourceManifest('type-incorrect', './src/mod.ts'), { + 'mod.ts': "export const count: number = 'one'\n", +}) + +const missingEntrySourcePackage = packageOf(sourceManifest('missing-entry', './src/mod.ts'), {}) + +const tsxEntryBesideTsPackage = packageOf(sourceManifest('tsx-entry', './src/mod.tsx'), { + 'mod.tsx': 'export const fromTsx: number = 1\n', + 'mod.ts': 'export const fromTs: number = 2\n', +}) + +const directoryBesideTsxImportPackage = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem + const path = yield* Path.Path + const root = yield* packageOf(sourceManifest('directory-import', './src/mod.ts'), { + 'mod.ts': "export { a } from './a.js'\n", + 'a.tsx': 'export const a: number = 1\n', + }) + yield* fileSystem.makeDirectory(path.join(root, 'src', 'a.ts')) + return root +}) + +const entryOutsidePackage = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem + const path = yield* Path.Path + const parent = yield* fileSystem.makeTempDirectoryScoped({ prefix: 'front-end-escaping-entry-' }) + const root = path.join(parent, 'package') + yield* fileSystem.makeDirectory(path.join(parent, 'shared'), { recursive: true }) + yield* fileSystem.makeDirectory(root) + yield* fileSystem.writeFileString(path.join(parent, 'shared', 'mod.ts'), 'export const outside: number = 1\n') + yield* fileSystem.writeFileString(path.join(root, 'package.json'), JSON.stringify(sourceManifest('escaping-entry', '../shared/mod.ts'))) + return root +}) + +const emittedOnFastPath = (packageRoot: string) => + Effect.flatMap(FrontEnd.readPackage({ packageRoot }), (source) => Effect.flatMap(FrontEnd.IsolatedEmitter, (emitter) => emitter.emit(source))) + +const missingImportPackage = packageOf(sourceManifest('missing-import', './src/mod.ts'), { + 'mod.ts': "export { thing } from './missing.js'\n", +}) + +const deletedTsxEntryBesideTsPackage = packageOf(sourceManifest('deleted-tsx-entry', './src/mod.tsx'), { + 'mod.ts': 'export const fromTs: number = 2\n', +}) + +const sharedDeclarationPathPackage = packageOf( + { + name: 'shared-declaration', + version: '1.0.0', + exports: { + '.': { [FrontEnd.FAMILY_SOURCE_CONDITION]: './src/mod.ts' }, + './other': { [FrontEnd.FAMILY_SOURCE_CONDITION]: './src/mod.tsx' }, + }, + }, + { 'mod.ts': 'export const fromTs: number = 2\n', 'mod.tsx': 'export const fromTsx: number = 1\n' }, +) + +const symlinkedEntryPackage = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem + const path = yield* Path.Path + const parent = yield* fileSystem.makeTempDirectoryScoped({ prefix: 'front-end-symlinked-entry-' }) + const root = path.join(parent, 'package') + yield* fileSystem.makeDirectory(path.join(root, 'src'), { recursive: true }) + yield* fileSystem.writeFileString(path.join(parent, 'outside.ts'), 'export const outside: number = 1\n') + yield* fileSystem.writeFileString(path.join(root, 'package.json'), JSON.stringify(sourceManifest('symlinked-entry', './src/mod.ts'))) + yield* fileSystem.symlink(path.join(parent, 'outside.ts'), path.join(root, 'src', 'mod.ts')) + return root +}) + +Feature('A package is routed to the front-end its declarations allow') + .withLayer(emitters) + .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', + 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) => + expect({ + route: scope.analysis.route._tag, + producer: scope.analysis.model.producer.declarations, + entryPoints: scope.analysis.model.entryPoints.map((entry) => entry.path), + exports: scope.analysis.model.entryPoints.flatMap((entry) => entry.items.map((item) => `${item.kind}:${item.name}`)), + decoded: ApiModel.decode(ApiModel.encode(scope.analysis.model)), + }).toEqual({ + route: 'FastPath', + producer: FrontEnd.OXC_TRANSFORM_PRODUCER, + entryPoints: ['.'], + exports: ['namespace:ApiModel', 'namespace:FrontEnd', 'namespace:Verdict'], + decoded: scope.analysis.model, + }) + ), + ), + ) + + scenario( + 'a package whose annotated source is type-incorrect takes the fast path, which never type-checks', + Gherkin.Do.pipe( + Given("a package exporting `export const count: number = 'one'` from its only source")('packageRoot', () => typeIncorrectSourcePackage), + When('the engine analyses it')('analysis', (scope) => Effect.flatMap(FrontEnd.readPackage({ packageRoot: scope.packageRoot }), FrontEnd.analyse)), + Then('the model holds `count` as a constant variable of its annotated type, the value being no concern of the fast path')((scope, expect) => + expect({ + route: scope.analysis.route._tag, + exports: scope.analysis.model.entryPoints.flatMap((entry) => entry.items.map((item) => `${item.kind}:${item.name}`)), + count: scope.analysis.model.entryPoints.flatMap((entry) => entry.items).find((item) => item.name === 'count' && item.kind === 'variable'), + }).toEqual({ + route: 'FastPath', + exports: ['variable:count'], + count: { kind: 'variable', name: 'count', constant: true, type: { kind: 'keyword', name: 'number' } }, + }) + ), + ), + ) + + scenario( + 'a package whose exports name no TypeScript source is refused before either emitter runs', + Gherkin.Do.pipe( + Given('a package exporting only built files')('packageRoot', () => builtOnlyPackage), + When('the engine reads it')('refusal', (scope) => Effect.flip(FrontEnd.readPackage({ packageRoot: scope.packageRoot }))), + Then('it is refused as having no source entry')((scope, expect) => expect(scope.refusal).toMatchObject({ _tag: 'NoSourceEntry' })), + ), + ) + + scenario( + 'a destructured export, whose isolated declarations do not parse, is sent to the compiler fallback', + Gherkin.Do.pipe( + Given('a package exporting `const { a, b } = pair`')('packageRoot', () => destructuredExportPackage), + When('its declarations are emitted on the fast path')('refusal', (scope) => Effect.flip(emittedOnFastPath(scope.packageRoot))), + Then('the fast path declines with TS9019 instead of refusing the package')((scope, expect) => + expect(scope.refusal).toMatchObject({ _tag: 'NotIsolated', diagnostics: [{ file: 'src/mod.ts', code: 9019 }, { file: 'src/mod.ts', code: 9019 }] }) + ), + ), + ) + + scenario( + 'an entry point whose source file is missing is declined by the fast path instead of modelled as empty', + Gherkin.Do.pipe( + Given('a package whose export names `./src/mod.ts`, which does not exist')('packageRoot', () => missingEntrySourcePackage), + When('its declarations are emitted on the fast path')('refusal', (scope) => Effect.flip(emittedOnFastPath(scope.packageRoot))), + Then('the fast path declines naming the missing declaration file, with no TypeScript code since no compiler reported it')((scope, expect) => + expect({ refusal: scope.refusal, message: scope.refusal.message }).toMatchObject({ + refusal: { _tag: 'NotIsolated', diagnostics: [{ file: 'src/mod.d.ts', message: 'no source file inside the package could be read for this entry point' }] }, + message: 'declarations need the type checker:\nsrc/mod.d.ts: no source file inside the package could be read for this entry point', + }) + ), + ), + ) + + scenario( + 'a .tsx entry point is read from its own file, not from a sibling .ts of the same name', + Gherkin.Do.pipe( + Given('a package whose export names `./src/mod.tsx` beside a different `./src/mod.ts`')('packageRoot', () => tsxEntryBesideTsPackage), + When('the engine analyses it')('analysis', (scope) => Effect.flatMap(FrontEnd.readPackage({ packageRoot: scope.packageRoot }), FrontEnd.analyse)), + Then('the model holds the .tsx entry point\'s export')((scope, expect) => + expect({ + route: scope.analysis.route._tag, + exports: scope.analysis.model.entryPoints.flatMap((entry) => entry.items.map((item) => `${item.kind}:${item.name}`)), + }).toEqual({ route: 'FastPath', exports: ['variable:fromTsx'] }) + ), + ), + ) + + scenario( + 'an import whose .ts candidate is a directory is read from its .tsx source', + Gherkin.Do.pipe( + Given('a package whose entry re-exports `a` from `./a.js`, where `src/a.ts` is a directory beside `src/a.tsx`')('packageRoot', () => directoryBesideTsxImportPackage), + When('the engine analyses it')('analysis', (scope) => Effect.flatMap(FrontEnd.readPackage({ packageRoot: scope.packageRoot }), FrontEnd.analyse)), + Then('the fast path models `a` from the .tsx source')((scope, expect) => + expect({ + route: scope.analysis.route._tag, + exports: scope.analysis.model.entryPoints.flatMap((entry) => entry.items.map((item) => `${item.kind}:${item.name}`)), + }).toEqual({ route: 'FastPath', exports: ['variable:a'] }) + ), + ), + ) + + scenario( + 'an entry point whose source lies outside the package root is declined by the fast path, as the compiler fallback refuses it', + Gherkin.Do.pipe( + Given('a package whose export names `../shared/mod.ts`, a readable file beside the package')('packageRoot', () => entryOutsidePackage), + When('its declarations are emitted on the fast path')('refusal', (scope) => Effect.flip(emittedOnFastPath(scope.packageRoot))), + Then('the fast path declines naming the entry declaration file instead of modelling a file outside the package')((scope, expect) => + expect(scope.refusal).toMatchObject({ _tag: 'NotIsolated', diagnostics: [{ file: '../shared/mod.d.ts' }] }) + ), + ), + ) + + scenario( + 'a package whose entry imports a specifier that names no file is declined by the fast path, naming the import', + Gherkin.Do.pipe( + Given('a package whose entry re-exports from `./missing.js`, which has no source')('packageRoot', () => missingImportPackage), + When('its declarations are emitted on the fast path')('refusal', (scope) => Effect.flip(emittedOnFastPath(scope.packageRoot))), + Then('the fast path declines naming the importing declaration file and the specifier, with no TypeScript code')((scope, expect) => + expect({ refusal: scope.refusal, message: scope.refusal.message }).toMatchObject({ + refusal: { + _tag: 'NotIsolated', + diagnostics: [{ file: 'src/mod.d.ts', message: 'the import `./missing.js` names no declaration file inside the package' }], + }, + message: 'declarations need the type checker:\nsrc/mod.d.ts: the import `./missing.js` names no declaration file inside the package', + }) + ), + ), + ) + + scenario( + 'an entry point whose source file is deleted is not modelled from a sibling of the same name', + Gherkin.Do.pipe( + Given('a package whose export names a `./src/mod.tsx` that is deleted, beside an existing `./src/mod.ts`')('packageRoot', () => deletedTsxEntryBesideTsPackage), + When('its declarations are emitted on the fast path')('refusal', (scope) => Effect.flip(emittedOnFastPath(scope.packageRoot))), + Then('the fast path declines naming the missing entry declaration instead of modelling the sibling')((scope, expect) => + expect(scope.refusal).toMatchObject({ + _tag: 'NotIsolated', + diagnostics: [{ file: 'src/mod.d.ts', message: 'no source file inside the package could be read for this entry point' }], + }) + ), + ), + ) + + scenario( + 'two entry points that share one declaration file are declined by the fast path', + Gherkin.Do.pipe( + Given('a package whose exports name `./src/mod.ts` and `./src/mod.tsx`')('packageRoot', () => sharedDeclarationPathPackage), + When('its declarations are emitted on the fast path')('refusal', (scope) => Effect.flip(emittedOnFastPath(scope.packageRoot))), + Then('the fast path declines naming the shared declaration file and both export paths')((scope, expect) => + expect(scope.refusal).toMatchObject({ + _tag: 'NotIsolated', + diagnostics: [{ file: 'src/mod.d.ts', message: 'two entry points share one declaration file: `.` and `./other`' }], + }) + ), + ), + ) + + scenario( + 'an entry point whose source is a symlink out of the package is declined by the fast path', + Gherkin.Do.pipe( + Given('a package whose `./src/mod.ts` is a symlink to a file outside the package')('packageRoot', () => symlinkedEntryPackage), + When('its declarations are emitted on the fast path')('refusal', (scope) => Effect.flip(emittedOnFastPath(scope.packageRoot))), + Then('the fast path declines naming the missing entry declaration instead of reading through the symlink')((scope, expect) => + expect(scope.refusal).toMatchObject({ + _tag: 'NotIsolated', + diagnostics: [{ file: 'src/mod.d.ts', message: 'no source file inside the package could be read for this entry point' }], + }) + ), + ), + ) + }) diff --git a/packages/api-extractor/tests/package-reader.integration.test.ts b/packages/api-extractor/tests/package-reader.integration.test.ts new file mode 100644 index 0000000..d5114dc --- /dev/null +++ b/packages/api-extractor/tests/package-reader.integration.test.ts @@ -0,0 +1,627 @@ +import { NodeServices } from '@effect/platform-node' +import { FrontEnd } from '@systemfsoftware/api-extractor' +import { Gherkin, Given, it, makeFeature, Then, When } from '@systemfsoftware/effect-gherkin-spec' +import { Effect, FileSystem, Path } from 'effect' + +const Feature = makeFeature({ it }) + +const condition = FrontEnd.FAMILY_SOURCE_CONDITION + +const writeSource = (root: string) => + ([file, text]: readonly [string, string]) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem + const path = yield* Path.Path + const target = path.join(root, file) + yield* fileSystem.makeDirectory(path.dirname(target), { recursive: true }) + yield* fileSystem.writeFileString(target, text) + }) + +const packageOf = (exports: object, sources: Record) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem + const path = yield* Path.Path + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: 'package-reader-' }) + yield* fileSystem.writeFileString(path.join(root, 'package.json'), JSON.stringify({ name: 'fixture', version: '1.0.0', exports })) + yield* Effect.forEach(Object.entries(sources), writeSource(root), { discard: true }) + return root + }) + +const entryPointsOf = (packageRoot: string) => + Effect.map(FrontEnd.readPackage({ packageRoot }), (source) => source.entryPoints) + +const rawPackageOf = (manifest: string) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem + const path = yield* Path.Path + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: 'package-reader-' }) + yield* fileSystem.writeFileString(path.join(root, 'package.json'), manifest) + return root + }) + +const refusalOfReading = (packageRoot: string) => Effect.flip(FrontEnd.readPackage({ packageRoot })) + +const commonJsEntryPackage = packageOf( + { '.': { [condition]: './src/mod.ts' }, './legacy': { [condition]: './src/legacy.cts' } }, + { 'src/mod.ts': 'export const mod: number = 1\n', 'src/legacy.cts': 'export const legacy: number = 1\n' }, +) + +const patternPackage = packageOf({ './*': { [condition]: './src/*.ts' } }, { 'src/a.ts': '', 'src/b.ts': '', 'src/nested/c.ts': '' }) + +const loopPatternPackage = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem + const path = yield* Path.Path + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: 'package-reader-' }) + yield* fileSystem.writeFileString(path.join(root, 'package.json'), JSON.stringify({ name: 'fixture', version: '1.0.0', exports: { './*': { [condition]: './src/*.ts' } } })) + yield* fileSystem.makeDirectory(path.join(root, 'src'), { recursive: true }) + yield* fileSystem.writeFileString(path.join(root, 'src/a.ts'), '') + yield* fileSystem.symlink('.', path.join(root, 'src/loop')) + return root +}) + +const parentLoopPatternPackage = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem + const path = yield* Path.Path + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: 'package-reader-' }) + yield* fileSystem.writeFileString(path.join(root, 'package.json'), JSON.stringify({ name: 'fixture', version: '1.0.0', exports: { './*': { [condition]: './src/*.ts' } } })) + yield* fileSystem.makeDirectory(path.join(root, 'src/a/b'), { recursive: true }) + yield* fileSystem.writeFileString(path.join(root, 'src/a/x.ts'), '') + yield* fileSystem.symlink('..', path.join(root, 'src/a/b/up')) + return root +}) + +const explicitOverridePackage = packageOf( + { './special': { [condition]: './src/b.ts' }, './*': { [condition]: './src/*.ts' } }, + { 'src/a.ts': '', 'src/b.ts': '', 'src/special.ts': '' }, +) + +const outsidePatternPackage = packageOf({ './*': { [condition]: './src/*.ts' } }, { 'src/a.ts': '', 'other/b.ts': '' }) + +const missingPatternDirectoryPackage = packageOf( + { '.': { [condition]: './src/index.ts' }, './*': { [condition]: './missing/*.ts' } }, + { 'src/index.ts': 'export const main: number = 1\n' }, +) + +const nestedPatternPackage = packageOf( + { './*': { [condition]: './src/*.ts' }, './x/*': { [condition]: './lib/*.ts' } }, + { 'src/x/y.ts': '', 'lib/y.ts': '' }, +) + +const excludedTargetPackage = packageOf( + { '.': { [condition]: './src/mod.ts' }, './internal/*': null, './legacy': ['./dist/legacy.js'], './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 rootPatternPackage = packageOf( + { './*': { [condition]: './*.ts' } }, + { 'mod.ts': '', 'node_modules/dep/index.ts': '' }, +) + +const suffixlessPatternPackage = packageOf( + { './*': { [condition]: './src/*' } }, + { 'src/mod.ts': '', 'src/data.json': '{}\n', 'src/sub/x.ts': '' }, +) + +const dotSegmentPatternPackage = packageOf( + { '.': { [condition]: './entry.ts' }, './*': { [condition]: './src/*.ts' } }, + { 'entry.ts': '', 'src/a.ts': '', 'src/..ts': '', 'src/...ts': '' }, +) + +const starlessPatternTargetPackage = packageOf( + { '.': { [condition]: './entry.ts' }, './*': { [condition]: './src/plain.ts' } }, + { 'entry.ts': '', 'src/plain.ts': '' }, +) + +const twoStarPatternTargetPackage = packageOf( + { '.': { [condition]: './entry.ts' }, './*': { [condition]: './src/*/*.ts' } }, + { 'entry.ts': '', 'src/a/b.ts': '' }, +) + +const aliasedSiblingPatternPackage = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem + const path = yield* Path.Path + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: 'package-reader-' }) + yield* fileSystem.writeFileString( + path.join(root, 'package.json'), + JSON.stringify({ name: 'fixture', version: '1.0.0', exports: { './*': { [condition]: './src/*.ts' } } }), + ) + yield* writeSource(root)(['src/a/x.ts', '']) + yield* fileSystem.symlink('a', path.join(root, 'src', 'b')) + return root +}) + +const ALIAS_DEPTH = 10 + +const aliasFanOutPackage = (target: string, fanOutDirectory: string, sources: Record) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem + const path = yield* Path.Path + const root = yield* packageOf({ './*': { [condition]: target } }, sources) + const levels = Array.from({ length: ALIAS_DEPTH }, (_, depth) => path.join(root, fanOutDirectory, ...Array.from({ length: depth }, () => 'a'))) + yield* Effect.forEach(levels, (level) => + Effect.andThen(fileSystem.makeDirectory(path.join(level, 'a'), { recursive: true }), fileSystem.symlink('a', path.join(level, 'b'))), { discard: true }) + return root + }) + +const escapingPatternPackage = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem + const path = yield* Path.Path + const outsideRoot = yield* fileSystem.makeTempDirectoryScoped({ prefix: 'package-reader-' }) + const root = path.join(outsideRoot, 'pkg') + yield* fileSystem.makeDirectory(root, { recursive: true }) + yield* fileSystem.writeFileString( + path.join(root, 'package.json'), + JSON.stringify({ name: 'fixture', version: '1.0.0', exports: { './*': { [condition]: '../outside/*.ts' } } }), + ) + yield* writeSource(outsideRoot)(['outside/a.ts', '']) + return root +}) + +const rootWithoutManifest = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem + return yield* fileSystem.makeTempDirectoryScoped({ prefix: 'package-reader-' }) +}) + +const workspacePackageOf = (name: string, configFile: string) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem + const path = yield* Path.Path + const workspaceRoot = yield* fileSystem.makeTempDirectoryScoped({ prefix: 'package-reader-workspace-' }) + yield* fileSystem.writeFileString(path.join(workspaceRoot, 'pnpm-workspace.yaml'), 'packages:\n - packs/*\n') + const packageRoot = path.join(workspaceRoot, 'packs', name) + yield* fileSystem.makeDirectory(packageRoot, { recursive: true }) + yield* fileSystem.writeFileString( + path.join(packageRoot, 'package.json'), + JSON.stringify({ name: 'fixture', version: '1.0.0', exports: { '.': { [condition]: './src/mod.ts' } } }), + ) + yield* fileSystem.writeFileString(path.join(packageRoot, configFile), '{}\n') + yield* writeSource(packageRoot)(['src/mod.ts', 'export const mod: number = 1\n']) + return { packageRoot, workspaceRoot, tsconfigPath: path.join(packageRoot, configFile) } + }) + +Feature("A package's exports name its entry points") + .withLayer(NodeServices.layer) + .live('the package reader reads real files from disk') + .body(({ scenario }) => { + scenario( + 'a package whose exports name a .cts entry beside a .ts one reads both entry points', + Gherkin.Do.pipe( + Given('a package exporting `./src/mod.ts` at `.` and `./src/legacy.cts` at `./legacy`')('packageRoot', () => commonJsEntryPackage), + 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' }, + ]) + ), + ), + ) + + scenario( + 'a package whose exports name hand-written declaration files reads them as entry points', + Gherkin.Do.pipe( + Given('a package exporting `./src/index.d.ts` at `.`, `./src/legacy.d.cts` at `./legacy` and `./src/modern.d.mts` at `./modern`')('packageRoot', () => + packageOf( + { '.': { [condition]: './src/index.d.ts' }, './legacy': { [condition]: './src/legacy.d.cts' }, './modern': { [condition]: './src/modern.d.mts' } }, + { + 'src/index.d.ts': 'export declare const a: number\n', + 'src/legacy.d.cts': 'export declare const b: number\n', + 'src/modern.d.mts': 'export declare const c: number\n', + }, + )), + 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' }, + ]) + ), + ), + ) + + scenario( + 'a pattern export expands to one entry per source file under the target directory', + Gherkin.Do.pipe( + Given('a package exporting every `./src/.ts` through `"./*"`')('packageRoot', () => patternPackage), + 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' }, + ]) + ), + ), + ) + + scenario( + 'a pattern export walks a self-referential symlinked directory only once', + Gherkin.Do.pipe( + 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' }]) + ), + ), + ) + + scenario( + 'a pattern export walks a symlinked directory pointing at an ancestor two levels up only once', + Gherkin.Do.pipe( + 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' }]) + ), + ), + ) + + scenario( + 'a pattern export walks a directory symlinked to a sibling under the alias name as well', + Gherkin.Do.pipe( + Given('a package exporting every `./src/.ts` through `"./*"` where `src/b` is a symlink to its sibling `src/a`')('packageRoot', () => aliasedSiblingPatternPackage), + 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' }, + ]) + ), + ), + ) + + scenario( + 'an explicit subpath beats the pattern expansion of the same export path', + Gherkin.Do.pipe( + Given('a package exporting `./special` from `./src/b.ts` and every other `./src/.ts` through `"./*"`')('packageRoot', () => explicitOverridePackage), + 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' }, + ]) + ), + ), + ) + + scenario( + 'a pattern export whose target directory does not exist adds no entry beside an explicit one', + Gherkin.Do.pipe( + Given('a package exporting `./src/index.ts` at `.` and every `./missing/.ts` through `"./*"`, where `./missing` does not exist')('packageRoot', () => + 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' }]) + ), + ), + ) + + scenario( + 'a file outside the pattern target directory is not an entry point', + Gherkin.Do.pipe( + 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' }]) + ), + ), + ) + + scenario( + 'between two patterns the one with the longer key prefix wins the shared export path', + Gherkin.Do.pipe( + 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' }]) + ), + ), + ) + + scenario( + 'a null, array or non-path export target is skipped, 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), + 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' }]) + ), + ), + ) + + scenario( + 'a pattern export reads only TypeScript sources, and never a match through node_modules', + Gherkin.Do.pipe( + Given('a package exporting `./.ts` through `"./*"` with a `node_modules` dependency inside it')('rootPattern', () => rootPatternPackage), + Given('a package exporting every `./src/` through `"./*"` beside a JSON file and a directory')('suffixless', () => suffixlessPatternPackage), + When('the package reader reads both')('entryPoints', (scope) => + 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' }], + }) + ), + ), + ) + + scenario( + 'a package whose manifest does not decode is refused, naming its package root', + Gherkin.Do.pipe( + Given('a package whose manifest file is not valid JSON')('invalidJson', () => rawPackageOf('{ not json')), + Given('a package whose manifest records an export target that is not a path, list or condition map')('refusedManifest', () => + rawPackageOf(JSON.stringify({ name: 'fixture', version: '1.0.0', exports: 42 })), + ), + Given('a package root holding no manifest file at all')('missingManifest', () => rootWithoutManifest), + When('the package reader reads all three')('refusals', (scope) => + Effect.all({ + invalidJson: refusalOfReading(scope.invalidJson), + refusedManifest: refusalOfReading(scope.refusedManifest), + missingManifest: refusalOfReading(scope.missingManifest), + }), + ), + Then('the two undecodable manifests are refused as rejected manifests naming their package roots, while the missing manifest fails the platform read instead')( + (scope, expect) => + expect(scope.refusals).toMatchObject({ + invalidJson: { _tag: 'ManifestRejected', packageRoot: scope.invalidJson }, + refusedManifest: { _tag: 'ManifestRejected', packageRoot: scope.refusedManifest }, + missingManifest: { _tag: 'PlatformError' }, + }), + ), + ), + ) + + scenario( + 'a manifest that omits `version` or `name` is refused as a rejected manifest, naming its package root', + Gherkin.Do.pipe( + Given('a package whose manifest names a source entry but holds no `version`')('withoutVersion', () => + rawPackageOf(JSON.stringify({ name: 'fixture', exports: { '.': { [condition]: './src/mod.ts' } } }))), + Given('a package whose manifest names a source entry but holds no `name`')('withoutName', () => + rawPackageOf(JSON.stringify({ version: '1.0.0', exports: { '.': { [condition]: './src/mod.ts' } } }))), + When('the package reader reads both')('refusals', (scope) => + Effect.all({ withoutVersion: refusalOfReading(scope.withoutVersion), withoutName: refusalOfReading(scope.withoutName) })), + Then('each is refused as a rejected manifest naming its package root')( + (scope, expect) => + expect(scope.refusals).toMatchObject({ + withoutVersion: { _tag: 'ManifestRejected', packageRoot: scope.withoutVersion }, + withoutName: { _tag: 'ManifestRejected', packageRoot: scope.withoutName }, + }), + ), + ), + ) + + scenario( + 'a pattern export whose symlinked aliases fan out past the walk bound is refused, naming the export', + Gherkin.Do.pipe( + Given(`a package exporting every \`./src/.ts\` through \`"./*"\`, where each of ${ALIAS_DEPTH} nested directories holds \`a\` and a symlink \`b\` to it`)( + 'packageRoot', + () => aliasFanOutPackage('./src/*.ts', 'src', {}), + ), + When('the package reader reads it')('refusal', (scope) => refusalOfReading(scope.packageRoot)), + Then('it is refused as a rejected manifest naming the package root and the `./*` export')((scope, expect) => + expect(scope.refusal).toMatchObject({ + _tag: 'ManifestRejected', + packageRoot: scope.packageRoot, + issue: 'the pattern export "./*" reaches more than 1024 directories', + })), + ), + ) + + scenario( + 'a pattern export never walks into node_modules, however its symlinks fan out', + Gherkin.Do.pipe( + Given(`a package exporting every \`./.ts\` through \`"./*"\`, whose \`node_modules/dep\` holds ${ALIAS_DEPTH} nested directories each with \`a\` and a symlink \`b\` to it`)( + 'packageRoot', + () => aliasFanOutPackage('./*.ts', 'node_modules/dep', { 'mod.ts': '' }), + ), + 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' }])), + ), + ) + + scenario( + 'a pattern export prunes a node_modules directory nested under its pattern prefix', + Gherkin.Do.pipe( + Given(`a package exporting every \`./src/.ts\` through \`"./*"\`, whose \`src/node_modules/dep\` holds ${ALIAS_DEPTH} nested directories each with \`a\` and a symlink \`b\` to it`)( + 'packageRoot', + () => aliasFanOutPackage('./src/*.ts', 'src/node_modules/dep', { 'src/mod.ts': '' }), + ), + 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' }])), + ), + ) + + scenario( + 'an export path takes its entry only from the pattern key Node picks for it, even when that key names no file', + Gherkin.Do.pipe( + Given('a package exporting `./src/*.ts` at `"./*"`, `./vendor/*.ts` at `"./a/*"` and null at `"./internal/*"`, with `src/a/x.ts`, `src/b.ts`, `src/internal/z.ts` and `vendor/y.ts`')( + 'packageRoot', + () => + packageOf( + { './*': { [condition]: './src/*.ts' }, './a/*': { [condition]: './vendor/*.ts' }, './internal/*': null }, + { 'src/a/x.ts': '', 'src/b.ts': '', 'src/internal/z.ts': '', 'vendor/y.ts': '' }, + ), + ), + 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' }, + ])), + ), + ) + + scenario( + 'an export key or target holding two stars names no entry point', + Gherkin.Do.pipe( + Given('a package exporting `./src/mod.ts` at `.`, `./src/*.ts` at `"./*/*.ts"` and `./src/*/*.ts` at `"./lib/*"`, with `src/b.ts` and `src/c/c.ts`')('packageRoot', () => + packageOf( + { '.': { [condition]: './src/mod.ts' }, './*/*.ts': { [condition]: './src/*.ts' }, './lib/*': { [condition]: './src/*/*.ts' } }, + { 'src/mod.ts': '', 'src/b.ts': '', 'src/c/c.ts': '' }, + )), + 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' }])), + ), + ) + + scenario( + 'a pattern whose star sits inside a file name walks only directories its prefix can lead to', + Gherkin.Do.pipe( + Given(`a package exporting \`./src.ts\` through \`"./*"\`, with \`srcA.ts\` beside a \`node_modules/dep\` holding ${ALIAS_DEPTH} nested directories each with \`a\` and a symlink \`b\` to it`)( + 'packageRoot', + () => aliasFanOutPackage('./src*.ts', 'node_modules/dep', { 'srcA.ts': '' }), + ), + 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' }])), + ), + ) + + scenario( + 'a package whose exports is a bare path or absent 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' })), + ), + Given('a package with no `exports` field')('absentExports', () => rawPackageOf(JSON.stringify({ name: 'fixture', version: '1.0.0' }))), + 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) => + Effect.all({ + stringExports: refusalOfReading(scope.stringExports), + absentExports: refusalOfReading(scope.absentExports), + bareSubpathTarget: refusalOfReading(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 }, + }), + ), + ), + ) + + scenario( + 'a caller may name another source condition, and the reader follows only that one', + Gherkin.Do.pipe( + Given('a package whose `.` entry names `./src/mod.ts` under the family condition and `./src/other.ts` under a `development` condition')( + 'packageRoot', + () => + packageOf( + { '.': { [condition]: './src/mod.ts', development: './src/other.ts' } }, + { 'src/mod.ts': 'export const mod: number = 1\n', 'src/other.ts': 'export const other: number = 1\n' }, + ), + ), + When('the package reader reads it once with the default condition and once with `development`')('entryPoints', (scope) => + Effect.all({ + family: entryPointsOf(scope.packageRoot), + development: Effect.map(FrontEnd.readPackage({ packageRoot: scope.packageRoot, sourceCondition: 'development' }), (source) => source.entryPoints), + })), + 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' }], + }) + ), + ), + ) + + scenario( + 'a pattern export whose matched name holds a dot segment, or whose target is not a single-star pattern, names no entry point', + Gherkin.Do.pipe( + Given('a package exporting `./entry.ts` at `.` and, through `"./*"`, every `./src/.ts` beside files whose names leave a `.` and a `..` export-name segment')( + 'dotSegment', + () => dotSegmentPatternPackage, + ), + Given('a package whose `"./*"` target `./src/plain.ts` holds no `*`')('starlessTarget', () => starlessPatternTargetPackage), + Given('a package whose `"./*"` target `./src/*/*.ts` holds two `*`')('twoStarTarget', () => twoStarPatternTargetPackage), + When('the package reader reads all three')('entryPoints', (scope) => + Effect.all({ + dotSegment: entryPointsOf(scope.dotSegment), + starlessTarget: entryPointsOf(scope.starlessTarget), + twoStarTarget: entryPointsOf(scope.twoStarTarget), + })), + 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' }], + }) + ), + ), + ) + + scenario( + 'a pattern export names a file whose name holds dollar signs exactly as the file is named', + Gherkin.Do.pipe( + Given('a package exporting every `./src/.ts` through `"./*"`, with files `a$$b.ts` and `c$&d.ts`')('packageRoot', () => + packageOf({ './*': { [condition]: './src/*.ts' } }, { 'src/a$$b.ts': '', 'src/c$&d.ts': '' })), + 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' }, + ]) + ), + ), + ) + + scenario( + 'a pattern export whose target escapes the package root names no entry point', + Gherkin.Do.pipe( + Given('a package whose `"./*"` target points at `../outside/*.ts` beside it, where `../outside/a.ts` exists')('packageRoot', () => + escapingPatternPackage), + When('the package reader reads it')('refusal', (scope) => refusalOfReading(scope.packageRoot)), + Then('it is refused as having no source entry, naming its package root')((scope, expect) => + expect(scope.refusal).toMatchObject({ _tag: 'NoSourceEntry', packageRoot: scope.packageRoot }) + ), + ), + ) + + scenario( + 'the package reader reports the workspace root above the package and its build config when it holds one', + Gherkin.Do.pipe( + Given('a workspace directory holding `pnpm-workspace.yaml`, with a package two levels below it holding its own `tsconfig.build.json`')( + 'withBuildConfig', + () => workspacePackageOf('with-build-config', 'tsconfig.build.json'), + ), + Given('another workspace directory holding `pnpm-workspace.yaml`, with a package two levels below it holding only a `tsconfig.json`')( + 'withDefaultConfig', + () => workspacePackageOf('with-default-config', 'tsconfig.json'), + ), + When('the package reader reads both')('sources', (scope) => + Effect.all({ + withBuildConfig: FrontEnd.readPackage({ packageRoot: scope.withBuildConfig.packageRoot }), + withDefaultConfig: FrontEnd.readPackage({ packageRoot: scope.withDefaultConfig.packageRoot }), + })), + Then('each names the workspace root two levels above it and its own build config, or its `tsconfig.json`')((scope, expect) => + expect(scope.sources).toEqual({ + withBuildConfig: { + name: 'fixture', + version: '1.0.0', + packageRoot: scope.withBuildConfig.packageRoot, + workspaceRoot: scope.withBuildConfig.workspaceRoot, + tsconfigPath: scope.withBuildConfig.tsconfigPath, + entryPoints: [{ exportPath: '.', sourceFile: 'src/mod.ts' }], + }, + withDefaultConfig: { + name: 'fixture', + version: '1.0.0', + packageRoot: scope.withDefaultConfig.packageRoot, + workspaceRoot: scope.withDefaultConfig.workspaceRoot, + tsconfigPath: scope.withDefaultConfig.tsconfigPath, + entryPoints: [{ exportPath: '.', sourceFile: 'src/mod.ts' }], + }, + }) + ), + ), + ) + }) diff --git a/packages/api-extractor/tests/verdict.integration.test.ts b/packages/api-extractor/tests/verdict.integration.test.ts index 1cfa9ef..dba0bc7 100644 --- a/packages/api-extractor/tests/verdict.integration.test.ts +++ b/packages/api-extractor/tests/verdict.integration.test.ts @@ -53,6 +53,17 @@ Feature('The verdict cell judges two surfaces and refuses the ones it cannot') ), ) + scenario( + 'a verdict decoded from the document carries the family brand', + Gherkin.Do.pipe( + Given('two surfaces whose returned error channel gains a tag')('surfaces', () => Effect.sync(() => ({ before, after }))), + When('the verdict cell judges them and the document is decoded')('verdict', (scope) => + Effect.map(Verdict.cell.run(scope.surfaces), (document) => Result.getOrThrow(decodeVerdict(document)))), + Then('the decoded verdict holds `Verdict.VerdictTypeId`')((scope, expect) => + expect(scope.verdict).toSatisfy((verdict: object) => Verdict.VerdictTypeId in verdict, 'the verdict holds Verdict.VerdictTypeId')), + ), + ) + scenarioOutline( 'a surface repeating