From 65b7d86654de6549ec6267e4ebd967b58e27adbd Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Mon, 12 Oct 2026 15:32:07 +0000 Subject: [PATCH 01/99] docs(plan): front-ends (U7/PR3) plan Dated unified plan for the isolated-declarations fast path, the TypeScript 7 compiler fallback behind one emitter port, the syntactic model builder and the analysis-message resolver. Cites the U7 contract (origin R15/R17/R18, KTD9/KTD11/KTD13) and the cell-architecture, boundary-testing, schema-laws and package-topology packs; folds in the ce-doc-review findings. --- .../2026-10-12-1532-feat-front-ends-plan.md | 278 ++++++++++++++++++ 1 file changed, 278 insertions(+) create mode 100644 docs/plans/2026-10-12-1532-feat-front-ends-plan.md diff --git a/docs/plans/2026-10-12-1532-feat-front-ends-plan.md b/docs/plans/2026-10-12-1532-feat-front-ends-plan.md new file mode 100644 index 0000000..d15150d --- /dev/null +++ b/docs/plans/2026-10-12-1532-feat-front-ends-plan.md @@ -0,0 +1,278 @@ +--- +title: API extractor front-ends - Plan +type: feat +date: 2026-10-12 +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-12-1510-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 parameterised 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` (the compiler's takes options); 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. +- 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. + +### 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. 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. + +--- + +## 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-U9 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. From 925b4ccb9a20298f4b6a2f5d7b130937bd0d3f03 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Mon, 12 Oct 2026 15:32:27 +0000 Subject: [PATCH 02/99] refactor(api-extractor): make the package isolatedDeclarations-clean (U1) tsconfig.build.json sets isolatedDeclarations, so the build fails when an export loses its annotation (plan R15, KTD9). Exported schemas carry their Schema types; the Verdict and CompareSurfaces schema classes become typed constants beside their instance types (TS9021/TS9038), which keeps effect-schema-discovery generating their codec laws. Verdict output bytes are unchanged (host digest e4478f03...6da2). --- .../src/canonical-json.schema.ts | 2 +- .../api-extractor/src/diff/change.schema.ts | 6 +- .../src/diff/direction.schema.ts | 2 +- .../api-extractor/src/diff/position.schema.ts | 4 +- .../src/judge-surfaces.workflow.ts | 2 +- .../src/model/api-model.schema.ts | 2 +- .../src/model/type-expr.schema.ts | 7 +- .../api-extractor/src/rules/corpus.schema.ts | 2 +- .../api-extractor/src/rules/finding.schema.ts | 12 ++- packages/api-extractor/src/verdict.cell.ts | 6 +- packages/api-extractor/src/verdict.schema.ts | 83 ++++++++++++++----- packages/api-extractor/tsconfig.build.json | 3 +- 12 files changed, 94 insertions(+), 37 deletions(-) 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/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/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..477b91d 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,9 @@ export const KeywordName = Schema.Literals([ 'undefined', 'unknown', 'void', -]) +] as const + +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/tsconfig.build.json b/packages/api-extractor/tsconfig.build.json index 0546a58..c562b67 100644 --- a/packages/api-extractor/tsconfig.build.json +++ b/packages/api-extractor/tsconfig.build.json @@ -4,7 +4,8 @@ "noEmit": false, "declaration": true, "emitDeclarationOnly": true, - "stripInternal": true + "stripInternal": true, + "isolatedDeclarations": true }, "include": ["src"], "exclude": ["src/**/*.test.ts"] From 2721cc1cb7af8a6b204198a4919aba6c36c26229 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Mon, 12 Oct 2026 15:37:10 +0000 Subject: [PATCH 03/99] perf(profile): per-phase front-end profile on the real corpus (U2) apps/api-extractor-profile times discovery, oxc-transform and transpileDeclaration isolated emit, TypeScript 7 and 5.9.3 tsc emit, and oxc-parser and createSourceFile parsing for this repository's package and the 39 public systemfsoftware packages with a TypeScript source entry at 732f66a0c8, and records each candidate's isolated-clean verdict beside tsc --isolatedDeclarations (plan R12, R13, KTD10). Committed before the fast-path emitter is chosen. --- apps/api-extractor-profile/oxlint.config.ts | 4 + apps/api-extractor-profile/package.json | 28 + apps/api-extractor-profile/profile.ts | 353 +++ apps/api-extractor-profile/tsconfig.json | 21 + .../profiles/2026-10-12-front-end-phases.json | 2558 +++++++++++++++++ docs/profiles/2026-10-12-front-end-phases.md | 87 + pnpm-lock.yaml | 247 ++ pnpm-workspace.yaml | 2 + 8 files changed, 3300 insertions(+) create mode 100644 apps/api-extractor-profile/oxlint.config.ts create mode 100644 apps/api-extractor-profile/package.json create mode 100644 apps/api-extractor-profile/profile.ts create mode 100644 apps/api-extractor-profile/tsconfig.json create mode 100644 docs/profiles/2026-10-12-front-end-phases.json create mode 100644 docs/profiles/2026-10-12-front-end-phases.md 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..6db0512 --- /dev/null +++ b/apps/api-extractor-profile/package.json @@ -0,0 +1,28 @@ +{ + "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/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..015b941 --- /dev/null +++ b/apps/api-extractor-profile/profile.ts @@ -0,0 +1,353 @@ +import { NodeRuntime, NodeServices } from '@effect/platform-node' +import { Array as Arr, Duration, Effect, FileSystem, Option, Order, Path, 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 SOURCE_CONDITION = '@systemfsoftware/source' +const require = createRequire(import.meta.url) + +const ExportTarget = Schema.Union([Schema.String, Schema.Record(Schema.String, Schema.Json)]) + +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 + +class UsageError extends Schema.TaggedError()('UsageError', { usage: Schema.String }) {} + +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 => + typeof target === 'string' + ? Option.none() + : Option.fromNullishOr(target[SOURCE_CONDITION]).pipe(Option.filter((value): value is string => typeof value === 'string' && /\.m?tsx?$/.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 TYPESCRIPT_SOURCE = /\.m?tsx?$/ + +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(/\.(m?)tsx?$/, (_, m: string) => `.d.${m}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 args = [tsc, '-p', tsconfig, '--noEmit', 'false', '--declaration', '--emitDeclarationOnly', '--declarationMap', 'false', '--sourceMap', 'false', '--incremental', 'false', '--composite', 'false', '--tsBuildInfoFile', 'null', '--rootDir', subject.workspaceRoot, '--outDir', outDir, '--pretty', 'false', ...extra] + const handle = yield* spawner.spawn(ChildProcess.make(process.execPath, args, { cwd: subject.root })) + const stdout = yield* Stream.decodeText(handle.stdout).pipe(Stream.mkString) + 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 +} + +const differing = (left: ReadonlyMap, right: ReadonlyMap): number => + [...left].filter(([file, text]) => right.get(file) !== text).length + +const PHASES = ['discover', 'emit:oxc-transform', 'emit:transpileDeclaration', 'emit:tsc7', 'emit:tsc5.9.3', 'parse:oxc-parser', 'parse:createSourceFile'] 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: { readonly compared: number; readonly differing: number; readonly typescript59ExitCode: number } + readonly phases: Readonly> +} + +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) + 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: { compared: outputs7.size, differing: differing(outputs7, outputs59), typescript59ExitCode: tsc59.result.exitCode }, + 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, + }, + } + })) + +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 })) + return (yield* Stream.decodeText(handle.stdout).pipe(Stream.mkString)).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 |` + const rule = `|${'---|'.repeat(PHASES.length + 7)}` + 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} |` + ) + const totals = `| **total** | | ${report.packages.reduce((sum, item) => sum + item.sourceFiles, 0)} | | | | ${PHASES.map((phase) => total(report.packages, phase, 'warmMedianMs')).join(' | ')} | ${report.packages.reduce((sum, item) => sum + item.divergence.differing, 0)}/${report.packages.reduce((sum, item) => sum + item.divergence.compared, 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)) +}) + +NodeRuntime.runMain(program.pipe(Effect.scoped, Effect.provide(NodeServices.layer))) 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/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..9703680 --- /dev/null +++ b/docs/profiles/2026-10-12-front-end-phases.md @@ -0,0 +1,87 @@ +# 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 | diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2176976..806b07b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -179,6 +179,12 @@ catalogs: effect: specifier: 4.0.2 version: 4.0.2 + oxc-parser: + specifier: 0.150.0 + version: 0.150.0 + oxc-transform: + specifier: 0.150.0 + version: 0.150.0 oxlint: specifier: 1.82.0 version: 1.82.0 @@ -294,6 +300,42 @@ importers: specifier: 'catalog:' version: 5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.19.1)(vite@8.2.1(@types/node@24.19.1)(yaml@2.9.1)) + apps/api-extractor-profile: + devDependencies: + '@effect/platform-node': + specifier: 'catalog:' + version: 4.0.2(effect@4.0.2)(redis@6.3.0(@opentelemetry/api@1.9.1)) + '@systemfsoftware/oxlint-config-recommended': + specifier: file:../../.sfs-deps/oxlint-config-recommended-4.0.0.tgz + version: file:.sfs-deps/oxlint-config-recommended-4.0.0.tgz(effect@4.0.2)(oxlint-tsgolint@7.0.2001)(oxlint@1.82.0(oxlint-tsgolint@7.0.2001))(typescript@7.0.2) + '@systemfsoftware/tsconfig': + specifier: file:../../.sfs-deps/tsconfig-2.0.1.tgz + version: file:.sfs-deps/tsconfig-2.0.1.tgz + '@types/node': + specifier: 'catalog:' + version: 24.19.1 + effect: + specifier: 'catalog:' + version: 4.0.2 + oxc-parser: + specifier: 'catalog:' + version: 0.150.0 + oxc-transform: + specifier: 'catalog:' + version: 0.150.0 + oxlint: + specifier: 'catalog:' + version: 1.82.0(oxlint-tsgolint@7.0.2001) + oxlint-tsgolint: + specifier: 'catalog:' + version: 7.0.2001 + typescript: + specifier: 'catalog:' + version: 7.0.2 + typescript-5.9: + specifier: npm:typescript@5.9.3 + version: typescript@5.9.3 + packages/api-extractor: dependencies: '@systemfsoftware/effect-cell-types': @@ -836,6 +878,128 @@ packages: '@oxc-project/types@0.153.0': resolution: {integrity: sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==} + '@oxc-transform/binding-android-arm-eabi@0.150.0': + resolution: {integrity: sha512-FMvuCLBzhjtMYdIawHIFg8ueZSIIaJaFGsAN31nKD2U3BGyaqefw3n6pF6MtIZR6EjINCH7YdcDilcrtVxiHgA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxc-transform/binding-android-arm64@0.150.0': + resolution: {integrity: sha512-RP3hHZ7wtUTCBPay/7o7nYcRo2jtOHwdcyxlzPkIx2PMnUqi6Ryv3Yflut7aVeqyhZg6TnXVkTYsQAiNUY7idQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxc-transform/binding-darwin-arm64@0.150.0': + resolution: {integrity: sha512-y8r4xzxmS3Hvy6fx7NLNqXzVb23UEUIvmrHhn+AboTpHiDz4jyt+XKNmJcemYtq7RLjxHUawJ3972+T2tuW2wg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxc-transform/binding-darwin-x64@0.150.0': + resolution: {integrity: sha512-Jhx4fgbVcnTwDaIf9sdEGUA0uEKAfq9RN4if/oZqG8MgAOJGW0lY16GvrX1EbZ6mhGrb/hDQUl8D3ZsWhu+oOw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxc-transform/binding-freebsd-x64@0.150.0': + resolution: {integrity: sha512-q7sBPKEr+CRVk5uw1lyycsJSoBqvKB++f2SPSYyP4B1lePZohGbKAYhp+oKVPzzVPGCFlE2yRB0rOv5cxtwpWA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxc-transform/binding-linux-arm-gnueabihf@0.150.0': + resolution: {integrity: sha512-yVBrghJ9Tx34Bj0rA0+GvLtDZrwKTkNd/S7U2DdYW55wLncvbid9PYf4iEzeJCVX1k0fXHp02pFOKrn0Rlb7dg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-transform/binding-linux-arm-musleabihf@0.150.0': + resolution: {integrity: sha512-Q2Qm3OfzesjWNPPn7lGr+olbJjbvTLDKC4V/9innK2eCiTT1gVyXF/3oumbHhcySFTuIQbROjeCWtePYL/MQYA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-transform/binding-linux-arm64-gnu@0.150.0': + resolution: {integrity: sha512-J1TN6Rqn0eddj5aSsNN6ca2loE58xd6tAiIGrBvCyzykELQOiO2XL2gaTS/jGfWOZ1Pp8OuPE+tjLTgATpz6gA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxc-transform/binding-linux-arm64-musl@0.150.0': + resolution: {integrity: sha512-i9qOJpzOZ04W5D4z+1abn34fUFauM6+pg4b2Mc/JPsC7e4oW1SWyrV0iLn+gaeA7lTqfAQEbeV7JVLcf7f3sog==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxc-transform/binding-linux-ppc64-gnu@0.150.0': + resolution: {integrity: sha512-uebQiw5TZVNQkqX8oomDdVB0f3rZ+5UTVlDw6vnLhtpxS3lx8Zfj3UcDNXe0r8YzFLphfOTtjkLa7F7kDtgjCA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxc-transform/binding-linux-riscv64-gnu@0.150.0': + resolution: {integrity: sha512-hhVRTQsgXQF5eMc6nhcRS8SoB1BRdjle7Fllnf+VNkmrlyGLZW+kosFzzj3rtmRuYKhr2COcgt3AQGW+6g04lQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxc-transform/binding-linux-riscv64-musl@0.150.0': + resolution: {integrity: sha512-DiO+AaQ86HxqBFXdDcQVoT30SqtWzQJFosuetpF3yKWV4CVQfLW6xbCFgp+aynX4BwP8zk8wSqqqkokK3RAA1g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxc-transform/binding-linux-s390x-gnu@0.150.0': + resolution: {integrity: sha512-TWsp5bdNjyBNUQwTQ5yCZC3S2roCCd9wYWaioljs8cV0AepObFyWOBL5uHdQq2bl9iTIyVUd+h4QHkPEDxQxwA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxc-transform/binding-linux-x64-gnu@0.150.0': + resolution: {integrity: sha512-iQ3QZEYwxrcv6OndOtn9hIh46iAJej8HmWVxYmaYxGihCJsdKdJXi6+oNNsGtPhtFlNa6LOzO9kaXz4rZZnPJw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxc-transform/binding-linux-x64-musl@0.150.0': + resolution: {integrity: sha512-ANGzUuKi1WWSlQZUdrxMsHEIbPT7xkvoXEcwKzjT5fe2KnodB6Ay1LVfchbVsn+NrK7tO2uEKJo3I9yceBalsA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxc-transform/binding-openharmony-arm64@0.150.0': + resolution: {integrity: sha512-DgBT4QJbP9W1Egfr7Raj4x+TmCAYY1qsZa4a2ha9OPEGuje+NWKoRJ7ryzOF6YO8xQn0jAdWIrJovf400hVqjg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxc-transform/binding-win32-arm64-msvc@0.150.0': + resolution: {integrity: sha512-Ma/bZUknqTKuGg3ExmCgSgIJaEkBEiCBeDSHUUQbvGG6gQzDJpykk01DLAd3cKdXtgCIhoXuOxMa+1KJy+MBbg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxc-transform/binding-win32-ia32-msvc@0.150.0': + resolution: {integrity: sha512-iKWK5vdiVdSOaxj1m8Vx7cY9lb/7CRfD+RBoVnvE/16oHADX8BvwU3WLWcljytu9KpRssJEkChfGLE27iiSFww==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxc-transform/binding-win32-x64-msvc@0.150.0': + resolution: {integrity: sha512-YtB1uZbWc66RH/ArifCZsVlGC8UeauRJxV0qzc8MKnEGOoaaQOfeYjok6Sjf8gH8VdPKXibqnMYZo19Btn74gw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@oxlint-tsgolint/darwin-arm64@7.0.2001': resolution: {integrity: sha512-CUJEdbSZ54+Xy9OXqOhWLTKZKV0BBiV7C2i/ygyVmXtkUNXx5YCzN8DpSSshTAKktoL7S+tnQ/ftFG/i7X896w==} cpu: [arm64] @@ -2064,6 +2228,10 @@ packages: resolution: {integrity: sha512-zwajKw1GUa57IOdafIS7/yOObGF2NihwIfZfs5yJTRprMSkiwhWOc/k+n8bwnUPbHr2/XsjfBO8KyTPaW3VpWg==} engines: {node: ^20.19.0 || >=22.12.0} + oxc-transform@0.150.0: + resolution: {integrity: sha512-AG1Faii67ekBS9OQdxUQvxbXTNogLd/mYC0mNocYa457LhNA05fMUrX6Uf3KCIn136d68Jm9JcWKF6vL0TPT5A==} + engines: {node: ^20.19.0 || >=22.12.0} + oxc-walker@1.1.1: resolution: {integrity: sha512-Hwd7dq28zu/Er99+bpWp16CGwFrhyalJh0W3JOB7XpPvgWo3MqMi2ksWGKuzzA9zt50mJ2pIUwR5lpAdZ9ACNQ==} peerDependencies: @@ -2865,6 +3033,63 @@ snapshots: '@oxc-project/types@0.153.0': {} + '@oxc-transform/binding-android-arm-eabi@0.150.0': + optional: true + + '@oxc-transform/binding-android-arm64@0.150.0': + optional: true + + '@oxc-transform/binding-darwin-arm64@0.150.0': + optional: true + + '@oxc-transform/binding-darwin-x64@0.150.0': + optional: true + + '@oxc-transform/binding-freebsd-x64@0.150.0': + optional: true + + '@oxc-transform/binding-linux-arm-gnueabihf@0.150.0': + optional: true + + '@oxc-transform/binding-linux-arm-musleabihf@0.150.0': + optional: true + + '@oxc-transform/binding-linux-arm64-gnu@0.150.0': + optional: true + + '@oxc-transform/binding-linux-arm64-musl@0.150.0': + optional: true + + '@oxc-transform/binding-linux-ppc64-gnu@0.150.0': + optional: true + + '@oxc-transform/binding-linux-riscv64-gnu@0.150.0': + optional: true + + '@oxc-transform/binding-linux-riscv64-musl@0.150.0': + optional: true + + '@oxc-transform/binding-linux-s390x-gnu@0.150.0': + optional: true + + '@oxc-transform/binding-linux-x64-gnu@0.150.0': + optional: true + + '@oxc-transform/binding-linux-x64-musl@0.150.0': + optional: true + + '@oxc-transform/binding-openharmony-arm64@0.150.0': + optional: true + + '@oxc-transform/binding-win32-arm64-msvc@0.150.0': + optional: true + + '@oxc-transform/binding-win32-ia32-msvc@0.150.0': + optional: true + + '@oxc-transform/binding-win32-x64-msvc@0.150.0': + optional: true + '@oxlint-tsgolint/darwin-arm64@7.0.2001': optional: true @@ -3797,6 +4022,28 @@ snapshots: '@oxc-parser/binding-win32-ia32-msvc': 0.150.0 '@oxc-parser/binding-win32-x64-msvc': 0.150.0 + oxc-transform@0.150.0: + optionalDependencies: + '@oxc-transform/binding-android-arm-eabi': 0.150.0 + '@oxc-transform/binding-android-arm64': 0.150.0 + '@oxc-transform/binding-darwin-arm64': 0.150.0 + '@oxc-transform/binding-darwin-x64': 0.150.0 + '@oxc-transform/binding-freebsd-x64': 0.150.0 + '@oxc-transform/binding-linux-arm-gnueabihf': 0.150.0 + '@oxc-transform/binding-linux-arm-musleabihf': 0.150.0 + '@oxc-transform/binding-linux-arm64-gnu': 0.150.0 + '@oxc-transform/binding-linux-arm64-musl': 0.150.0 + '@oxc-transform/binding-linux-ppc64-gnu': 0.150.0 + '@oxc-transform/binding-linux-riscv64-gnu': 0.150.0 + '@oxc-transform/binding-linux-riscv64-musl': 0.150.0 + '@oxc-transform/binding-linux-s390x-gnu': 0.150.0 + '@oxc-transform/binding-linux-x64-gnu': 0.150.0 + '@oxc-transform/binding-linux-x64-musl': 0.150.0 + '@oxc-transform/binding-openharmony-arm64': 0.150.0 + '@oxc-transform/binding-win32-arm64-msvc': 0.150.0 + '@oxc-transform/binding-win32-ia32-msvc': 0.150.0 + '@oxc-transform/binding-win32-x64-msvc': 0.150.0 + oxc-walker@1.1.1(@oxc-project/types@0.150.0)(oxc-parser@0.150.0)(rolldown@1.2.13): optionalDependencies: '@oxc-project/types': 0.150.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b5c41cd..bc6b5ac 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -13,6 +13,8 @@ catalog: effect: 4.0.2 oxlint: 1.82.0 oxlint-tsgolint: 7.0.2001 + oxc-parser: 0.150.0 + oxc-transform: 0.150.0 tsdown: 0.23.0 turbo: 2.10.12 typescript: 7.0.2 From 056c36c131293d23d4fb7f820b61644929909158 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Mon, 12 Oct 2026 15:48:24 +0000 Subject: [PATCH 04/99] feat(front-end): emitter port and the oxc isolated-declarations adapter (U3) The committed profile picks oxc-transform 0.150.0 with oxc-parser by the plan's pre-registered rule (KTD2): both candidates agree with tsc --isolatedDeclarations on all 40 packages, and oxc emit plus parse takes 77.79 ms against transpileDeclaration's 947.03 ms. The Decision section of docs/profiles/2026-10-12-front-end-phases.md records the numbers. Adds the DeclarationEmitter port with IsolatedEmitter and CompilerEmitter tags (no driver imports), the Declarations meeting point, the package reader (NoSourceEntry for a package with no TypeScript source entry), the declaration graph both emitters close over, and the isolated adapter that refuses with NotIsolated and the emitter's diagnostics. --- docs/profiles/2026-10-12-front-end-phases.md | 10 + packages/api-extractor/package.json | 6 +- .../front-end/declaration-emitter.service.ts | 24 + .../src/front-end/declaration-graph.ts | 439 ++++++++++++++++++ .../src/front-end/declarations.schema.ts | 46 ++ .../src/front-end/front-end-error.schema.ts | 47 ++ .../src/front-end/oxc-isolated-emitter.ts | 98 ++++ .../src/front-end/package-source.schema.ts | 20 + .../src/front-end/read-package.ts | 79 ++++ pnpm-lock.yaml | 12 +- 10 files changed, 776 insertions(+), 5 deletions(-) create mode 100644 packages/api-extractor/src/front-end/declaration-emitter.service.ts create mode 100644 packages/api-extractor/src/front-end/declaration-graph.ts create mode 100644 packages/api-extractor/src/front-end/declarations.schema.ts create mode 100644 packages/api-extractor/src/front-end/front-end-error.schema.ts create mode 100644 packages/api-extractor/src/front-end/oxc-isolated-emitter.ts create mode 100644 packages/api-extractor/src/front-end/package-source.schema.ts create mode 100644 packages/api-extractor/src/front-end/read-package.ts diff --git a/docs/profiles/2026-10-12-front-end-phases.md b/docs/profiles/2026-10-12-front-end-phases.md index 9703680..77c94c7 100644 --- a/docs/profiles/2026-10-12-front-end-phases.md +++ b/docs/profiles/2026-10-12-front-end-phases.md @@ -85,3 +85,13 @@ the clean columns count isolated-declaration diagnostics over the files the entr | @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/packages/api-extractor/package.json b/packages/api-extractor/package.json index e33e018..041fb02 100644 --- a/packages/api-extractor/package.json +++ b/packages/api-extractor/package.json @@ -33,7 +33,10 @@ }, "dependencies": { "@systemfsoftware/effect-cell-types": "^12.0.0", - "effect": "catalog:" + "effect": "catalog:", + "oxc-parser": "catalog:", + "oxc-transform": "catalog:", + "typescript": "catalog:" }, "devDependencies": { "@microsoft/api-extractor": "catalog:", @@ -55,7 +58,6 @@ "oxlint": "catalog:", "oxlint-tsgolint": "catalog:", "tsdown": "catalog:", - "typescript": "catalog:", "vite": "catalog:", "vitest": "catalog:" }, 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..4d056e9 --- /dev/null +++ b/packages/api-extractor/src/front-end/declaration-graph.ts @@ -0,0 +1,439 @@ +import { Array as Arr, Effect, Function as Fn, HashMap, HashSet, Match, Option, Order } 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 TSModuleDeclaration, + type TSTypeAliasDeclaration, + type VariableDeclaration, + type VariableDeclarator, + Visitor, +} from 'oxc-parser' +import type { DeclarationFile, Declarations } 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 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('/') + +export 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 => + sourceFile.replace(/\.([mc]?)tsx?$/, (_, flavour: string) => declarationExtensionOf(flavour)) + +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) => Effect.fail(new UnsupportedDeclaration({ file: path, construct: `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]) + +export 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 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), + }) + +const followed = (file: string, program: Program): ReadonlyArray> => + specifiersOf(program).filter(isRelativeSpecifier).map((specifier) => declarationCandidates(file, specifier)) + +const visitFound = (file: string, text: string, rest: ReadonlyArray>, loaded: Loaded, load: Loader) => + HashMap.has(loaded, file) + ? visit(rest, loaded, load) + : Effect.flatMap(parsed(file, text), ({ program }) => visit([...rest, ...followed(file, program)], HashMap.set(loaded, file, text), load)) + +const visit = ( + pending: ReadonlyArray>, + loaded: Loaded, + load: Loader, +): Effect.Effect => + Arr.match(pending, { + onEmpty: () => Effect.succeed(loaded), + onNonEmpty: ([candidates, ...rest]) => + Effect.flatMap(firstLoaded(candidates, loaded, load), (found) => + Option.match(found, { + onNone: () => visit(rest, loaded, load), + onSome: ([file, text]) => visitFound(file, text, rest, loaded, 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, E | UnsupportedDeclaration, R> => + Effect.map( + visit(request.entryDeclarations.map(Arr.of), HashMap.empty(), request.load), + (loaded) => Arr.sort(Arr.map(HashMap.toEntries(loaded), ([path, text]) => ({ path, text })), byPath), + ) + +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 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 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 module: string; readonly imported: string } + +export type DeclarationGraph = { + readonly files: HashMap.HashMap + readonly entryPoints: ReadonlyArray<{ readonly exportPath: string; readonly declarationFile: string }> +} + +const unsupported = (file: string, construct: string): Effect.Effect => + Effect.fail(new UnsupportedDeclaration({ file, construct })) + +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> + +const named = (id: { readonly name: string } | null, declared: (name: string) => A): ReadonlyArray => + id === null ? [] : [declared(id.name)] + +const variablesOf = (node: VariableDeclaration, file: string): DeclaredResult => + Effect.forEach(node.declarations, (declarator) => + declarator.id.type === 'Identifier' + ? Effect.succeed({ kind: 'variable', constant: node.kind === 'const', node: declarator, name: declarator.id.name }) + : unsupported(file, 'a destructuring variable declaration')) + +const namespaceOf = (node: TSModuleDeclaration, file: string): DeclaredResult => + Match.value(node.id).pipe( + Match.when({ type: 'Identifier' }, (id) => Effect.succeed>([{ kind: 'namespace', node, name: id.name }])), + Match.when({ type: 'Literal' }, (id) => unsupported(file, `\`declare module "${id.value}"\``)), + Match.orElse(() => unsupported(file, 'a dotted namespace declaration')), + ) + +const declaredOf = (node: DeclarationNode | Statement | ExportDefaultDeclaration['declaration'], file: string): DeclaredResult => + Match.value(node).pipe( + Match.when({ type: 'VariableDeclaration' }, (variable) => variablesOf(variable, file)), + Match.when({ type: Match.is('FunctionDeclaration', 'TSDeclareFunction') }, (fn: FunctionNode) => + Effect.succeed(named(fn.id, (name) => ({ kind: 'function', node: fn, name })))), + Match.when({ type: 'ClassDeclaration' }, (cls: Class) => Effect.succeed(named(cls.id, (name) => ({ kind: 'class', node: cls, name })))), + Match.when({ type: 'TSInterfaceDeclaration' }, (iface) => Effect.succeed>([{ kind: 'interface', node: iface, name: iface.id.name }])), + Match.when({ type: 'TSTypeAliasDeclaration' }, (alias) => Effect.succeed>([{ kind: 'typeAlias', node: alias, name: alias.id.name }])), + Match.when({ type: 'TSEnumDeclaration' }, (enumeration) => + Effect.succeed>([{ kind: 'enum', node: enumeration, name: enumeration.id.name }])), + Match.when({ type: 'TSModuleDeclaration', global: true }, () => unsupported(file, '`declare global`')), + Match.when({ type: 'TSModuleDeclaration', global: false }, (module: TSModuleDeclaration) => namespaceOf(module, file)), + Match.orElse(() => Effect.succeed>([])), + ) + +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', + }), + ) + +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', { 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', { 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(() => unsupported(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: () => unsupported(file, '`export =`'), + TSImportEqualsDeclaration: () => unsupported(file, '`import =`'), + TSNamespaceExportDeclaration: () => unsupported(file, '`export as namespace`'), + }), + Match.orElse((node) => Effect.map(declaredOf(node, file), (declared) => declarationsPart(declared, node.start))), + ) + +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 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, + declarations: grouped(parts.flatMap((part) => part.declarations)), + imports: HashMap.fromIterable(parts.flatMap((part) => part.imports)), + exports: HashMap.fromIterable(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', 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' + ? 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 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..7572015 --- /dev/null +++ b/packages/api-extractor/src/front-end/declarations.schema.ts @@ -0,0 +1,46 @@ +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 })), +}) + +export type Diagnostic = { + readonly file: string + readonly code: number + readonly message: string +} + +export const Diagnostic: Schema.Codec = Schema.Struct({ + file: Schema.String, + code: Schema.Finite, + message: Schema.String, +}) 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..a8c607e --- /dev/null +++ b/packages/api-extractor/src/front-end/front-end-error.schema.ts @@ -0,0 +1,47 @@ +import { type Cause, Schema } from 'effect' +import { Diagnostic } from './declarations.schema.ts' + +type DiagnosticsFields = { readonly diagnostics: Schema.$Array } + +const diagnosticsFields: DiagnosticsFields = { diagnostics: Schema.Array(Diagnostic) } + +const listed = (diagnostics: ReadonlyArray): string => + diagnostics.map(({ file, code, message }) => `${file === '' ? '' : `${file}: `}TS${code} ${message}`).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` + } +} + +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..3747f37 --- /dev/null +++ b/packages/api-extractor/src/front-end/oxc-isolated-emitter.ts @@ -0,0 +1,98 @@ +import { Array as Arr, Effect, FileSystem, Layer, Match, Option, Order, Path, Ref } 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, sourceCandidatesOf } from './declaration-graph.ts' +import { type DeclarationEmitter, IsolatedEmitter, type IsolatedEmitterId } from './declaration-emitter.service.ts' +import type { Declarations, Diagnostic, PackageSource } from './declarations.schema.ts' +import { NotIsolated } from './front-end-error.schema.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: () => ({ file, code: 0, message: text }), + onSome: ([, code, message]) => ({ file, code: Number(code), message: message ?? '' }), + }) + +const diagnosticOrder: Order.Order = Order.combineAll([ + Order.mapInput(Order.String, (diagnostic: Diagnostic) => diagnostic.file), + Order.mapInput(Order.Number, (diagnostic: Diagnostic) => diagnostic.code), + Order.mapInput(Order.String, (diagnostic: Diagnostic) => diagnostic.message), +]) + +const isAbsent = (error: PlatformError): boolean => + Match.value(error.reason).pipe( + Match.tag('NotFound', 'BadResource', () => true), + Match.orElse(() => false), + ) + +type Emission = { + readonly source: PackageSource + readonly diagnostics: Ref.Ref> +} + +const readSource = (packageRoot: string, sourceFile: string) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem + const path = yield* Path.Path + return yield* fileSystem.readFileString(path.join(packageRoot, sourceFile)).pipe( + Effect.asSome, + Effect.catchIf(isAbsent, () => Effect.succeedNone), + ) + }) + +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(readSource(emission.source.packageRoot, sourceFile), (text) => + Option.match(text, { + onNone: () => firstSource(emission, rest), + onSome: (value) => Effect.asSome(declare(emission, sourceFile, value)), + })), + }) + +const refusedOrDeclared = (found: ReadonlyArray, declarations: Declarations): Effect.Effect => + Arr.match(found, { + onEmpty: () => Effect.succeed(declarations), + onNonEmpty: (diagnostics) => Effect.fail(new NotIsolated({ diagnostics })), + }) + +const emit = (source: PackageSource) => + Effect.gen(function* () { + const emission: Emission = { source, diagnostics: yield* Ref.make>([]) } + const entryPoints = source.entryPoints.map((entry) => ({ exportPath: entry.exportPath, declarationFile: declarationPathOf(entry.sourceFile) })) + const files = yield* closeDeclarations({ + entryDeclarations: entryPoints.map((entry) => entry.declarationFile), + load: (declarationFile) => firstSource(emission, sourceCandidatesOf(declarationFile)), + }) + const found = Arr.sort(yield* Ref.get(emission.diagnostics), diagnosticOrder) + return yield* refusedOrDeclared(found, { + producer: OXC_TRANSFORM_PRODUCER, + package: { name: source.name, version: source.version }, + entryPoints, + files, + }) + }) + +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-source.schema.ts b/packages/api-extractor/src/front-end/package-source.schema.ts new file mode 100644 index 0000000..50fdf74 --- /dev/null +++ b/packages/api-extractor/src/front-end/package-source.schema.ts @@ -0,0 +1,20 @@ +import { Schema } from 'effect' + +export const ExportTarget: Schema.Union>]> = Schema.Union([ + Schema.String, + Schema.Record(Schema.String, Schema.Json), +]) +export type ExportTarget = typeof ExportTarget.Type + +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, ExportTarget)])), +}) +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..13bdafb --- /dev/null +++ b/packages/api-extractor/src/front-end/read-package.ts @@ -0,0 +1,79 @@ +import { Array as Arr, Effect, FileSystem, Option, Order, Path, Schema } from 'effect' +import type { PlatformError } from 'effect/PlatformError' +import type { PackageSource, SourceEntryPoint } from './declarations.schema.ts' +import { NoSourceEntry } from './front-end-error.schema.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 = /\.m?tsx?$/ + +const isTypeScriptSource = (value: Schema.Json | undefined): value is string => typeof value === 'string' && TYPESCRIPT_SOURCE.test(value) + +const conditionTarget = (target: ExportTarget, condition: string): Option.Option => + typeof target === 'string' ? Option.none() : Option.liftPredicate(target[condition], isTypeScriptSource) + +const entryPointOf = ([exportPath, target]: readonly [string, ExportTarget], condition: string): Option.Option => + Option.map(conditionTarget(target, condition), (sourceFile) => ({ exportPath, sourceFile: sourceFile.replace(/^\.\//, '') })) + +const byExportPath: Order.Order = Order.mapInput(Order.String, (entry: SourceEntryPoint) => entry.exportPath) + +const exportMapOf = (exports: PackageManifest['exports']): ReadonlyArray => + typeof exports === 'object' ? Object.entries(exports).filter(([exportPath]) => exportPath !== './package.json') : [] + +const entryPointsOf = (manifest: PackageManifest, condition: string): ReadonlyArray => + Arr.sort(Arr.getSomes(exportMapOf(manifest.exports).map((entry) => entryPointOf(entry, condition))), byExportPath) + +type Platform = FileSystem.FileSystem | Path.Path + +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.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'))) + const entryPoints = entryPointsOf(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/pnpm-lock.yaml b/pnpm-lock.yaml index 806b07b..11bd542 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -344,6 +344,15 @@ importers: effect: specifier: 'catalog:' version: 4.0.2 + oxc-parser: + specifier: 'catalog:' + version: 0.150.0 + oxc-transform: + specifier: 'catalog:' + version: 0.150.0 + typescript: + specifier: 'catalog:' + version: 7.0.2 devDependencies: '@microsoft/api-extractor': specifier: 'catalog:' @@ -402,9 +411,6 @@ importers: tsdown: specifier: 'catalog:' version: 0.23.0(typescript@7.0.2) - typescript: - specifier: 'catalog:' - version: 7.0.2 vite: specifier: 'catalog:' version: 8.2.1(@types/node@24.19.1)(yaml@2.9.1) From 9a9abf45990b661ada0047cdb158a78f4b5fb18d Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Mon, 12 Oct 2026 17:25:42 +0000 Subject: [PATCH 05/99] feat(front-end): model builder, analysis-message resolver, TypeScript 7 fallback and router (U4-U8) - build-model, type-expr-of, canonical-type-text: one syntactic builder turns declaration text into the ApiModel v2 on both paths (KTD6, R6-R8); import() types and imported names give one reference; undecomposed types print through one canonical printer. - resolve-messages: ae-forgotten-export, ae-missing-release-tag, ae-internal-missing-underscore, ae-incompatible-release-tags and ae-internal-mixed-release-tag from declaration text (KTD7, R9-R11), checked against upstream api-extractor 7.59.1 run in-process. - typescript-7-compiler: the CompilerEmitter adapter runs this package's tsc 7 as a process, decodes its output through lawful line codecs, and closes over the same declaration graph as the isolated adapter (KTD3, R2, R5). - analyse and the FrontEnd namespace route FastPath or Fallback (KTD4, KTD8). - The declaration graph treats a declaration module without export {} as exporting every top-level declaration, as TypeScript does. - The isolated adapter declines with NotIsolated when oxc's output for a file that had diagnostics does not parse (a destructured export), instead of refusing the package. - e2e journeys: fast path and fallback give this package the same bytes and messages (AE1), an inferred export routes to the fallback naming TS9010 (AE2), a type error is refused with TS2322 (AE5). --- .../tests/front-ends.integration.test.ts | 95 +++ apps/api-extractor-profile/package.json | 1 + packages/api-extractor/package.json | 1 + packages/api-extractor/src/FrontEnd.ts | 17 + .../schema-laws.test.ts.jsonl | 4 + .../api-extractor/src/front-end/analyse.ts | 48 ++ .../src/front-end/analysis-message.schema.ts | 32 + .../src/front-end/build-model.ts | 625 ++++++++++++++++++ .../src/front-end/canonical-type-text.ts | 574 ++++++++++++++++ .../src/front-end/compiler-output.schema.ts | 173 +++++ .../src/front-end/declaration-graph.ts | 15 +- .../src/front-end/oxc-isolated-emitter.ts | 16 +- .../src/front-end/resolve-messages.ts | 317 +++++++++ .../src/front-end/route.schema.ts | 13 + .../src/front-end/type-expr-of.ts | 532 +++++++++++++++ .../src/front-end/typescript-7-compiler.ts | 161 +++++ packages/api-extractor/src/mod.ts | 1 + .../tests/__fixtures__/upstream-messages.ts | 97 +++ .../analysis-messages.integration.test.ts | 130 ++++ .../tests/front-end-model.integration.test.ts | 264 ++++++++ .../front-end-routing.integration.test.ts | 78 +++ pnpm-lock.yaml | 6 + 22 files changed, 3192 insertions(+), 8 deletions(-) create mode 100644 apps/api-extractor-e2e/tests/front-ends.integration.test.ts create mode 100644 packages/api-extractor/src/FrontEnd.ts create mode 100644 packages/api-extractor/src/__property_seeds__/schema-laws.test.ts.jsonl create mode 100644 packages/api-extractor/src/front-end/analyse.ts create mode 100644 packages/api-extractor/src/front-end/analysis-message.schema.ts create mode 100644 packages/api-extractor/src/front-end/build-model.ts create mode 100644 packages/api-extractor/src/front-end/canonical-type-text.ts create mode 100644 packages/api-extractor/src/front-end/compiler-output.schema.ts create mode 100644 packages/api-extractor/src/front-end/resolve-messages.ts create mode 100644 packages/api-extractor/src/front-end/route.schema.ts create mode 100644 packages/api-extractor/src/front-end/type-expr-of.ts create mode 100644 packages/api-extractor/src/front-end/typescript-7-compiler.ts create mode 100644 packages/api-extractor/tests/__fixtures__/upstream-messages.ts create mode 100644 packages/api-extractor/tests/analysis-messages.integration.test.ts create mode 100644 packages/api-extractor/tests/front-end-model.integration.test.ts create mode 100644 packages/api-extractor/tests/front-end-routing.integration.test.ts 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..96b99ef --- /dev/null +++ b/apps/api-extractor-e2e/tests/front-ends.integration.test.ts @@ -0,0 +1,95 @@ +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 STANDALONE_TSCONFIG = { + compilerOptions: { strict: true, target: 'es2022', module: 'nodenext', moduleResolution: 'nodenext', types: [], skipLibCheck: true }, + include: ['src'], +} + +const packageWith = (name: string, source: string) => + 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: { '.': { [FrontEnd.FAMILY_SOURCE_CONDITION]: './src/mod.ts' } } }), + ) + yield* fileSystem.writeFileString(path.join(root, 'tsconfig.json'), JSON.stringify(STANDALONE_TSCONFIG)) + yield* fileSystem.writeFileString(path.join(root, 'src', 'mod.ts'), source) + return yield* FrontEnd.readPackage({ packageRoot: root }) + }) + +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) + return { + bytes: ApiModel.serialise(yield* FrontEnd.buildModel({ ...declarations, producer: 'one producer' })), + messages: yield* FrontEnd.resolveMessages(declarations), + files: declarations.files.map((file) => file.path), + } + }) + +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 this package the same model and messages', + Gherkin.Do.pipe( + Given('this isolatedDeclarations-clean package')('source', () => thisPackage), + When('it is analysed through the isolated emitter and through the compiler')('analyses', (scope) => + Effect.all({ + isolated: analysedThrough(FrontEnd.IsolatedEmitter, scope.source), + compiled: analysedThrough(FrontEnd.CompilerEmitter, scope.source), + }) + ), + Then('the two serialise to the same bytes once the producer is set equal')((scope, expect) => + expect(scope.analyses.compiled).toEqual(scope.analyses.isolated) + ), + ), + ) + + 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')), + When('the engine analyses it')('analysis', (scope) => FrontEnd.analyse(scope.source)), + Then('it took the fallback for TS9010 on `ids` and modelled `ids` as a string array')((scope, expect) => + expect({ + route: scope.analysis.route, + items: scope.analysis.model.entryPoints.flatMap((entry) => entry.items), + }).toMatchObject({ + route: { _tag: 'Fallback', diagnostics: [{ file: 'src/mod.ts', code: 9010 }] }, + items: [{ kind: 'variable', name: 'ids', constant: true, type: { kind: 'array', element: { kind: 'keyword', name: 'string' } } }], + }) + ), + ), + ) + + scenario( + 'a package the compiler rejects is refused with the compiler diagnostic and no model', + Gherkin.Do.pipe( + Given('a package that needs the fallback and assigns a string to a number')('source', () => + packageWith('type-error', "export const ids = [1, 2].map(String)\nexport const count: number = 'one'\n")), + When('the engine analyses it')('refusal', (scope) => Effect.flip(FrontEnd.analyse(scope.source))), + Then('it is refused with TS2322')((scope, expect) => + expect(scope.refusal).toMatchObject({ _tag: 'CompilerRejected', diagnostics: [{ file: 'src/mod.ts', code: 2322 }] }) + ), + ), + ) + }) diff --git a/apps/api-extractor-profile/package.json b/apps/api-extractor-profile/package.json index 6db0512..50d03e6 100644 --- a/apps/api-extractor-profile/package.json +++ b/apps/api-extractor-profile/package.json @@ -14,6 +14,7 @@ }, "devDependencies": { "@effect/platform-node": "catalog:", + "@systemfsoftware/api-extractor": "workspace:*", "@systemfsoftware/oxlint-config-recommended": "^4.0.0", "@systemfsoftware/tsconfig": "^2.0.1", "@types/node": "catalog:", diff --git a/packages/api-extractor/package.json b/packages/api-extractor/package.json index 041fb02..90f030c 100644 --- a/packages/api-extractor/package.json +++ b/packages/api-extractor/package.json @@ -39,6 +39,7 @@ "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", diff --git a/packages/api-extractor/src/FrontEnd.ts b/packages/api-extractor/src/FrontEnd.ts new file mode 100644 index 0000000..742e543 --- /dev/null +++ b/packages/api-extractor/src/FrontEnd.ts @@ -0,0 +1,17 @@ +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, type PackageSource, type SourceEntryPoint } from './front-end/declarations.schema.ts' +export { CompilerRejected, 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 { layer as compilerEmitterLayer } from './front-end/typescript-7-compiler.ts' diff --git a/packages/api-extractor/src/__property_seeds__/schema-laws.test.ts.jsonl b/packages/api-extractor/src/__property_seeds__/schema-laws.test.ts.jsonl new file mode 100644 index 0000000..7993117 --- /dev/null +++ b/packages/api-extractor/src/__property_seeds__/schema-laws.test.ts.jsonl @@ -0,0 +1,4 @@ +{"_tag":"Refuted","property":"∀x_SourcelessDiagnosticLine_=x","seed":1588784167,"attempt":69,"size":7,"path":[0,2,3,15],"failure":"ReturnedFalse"} +{"_tag":"Refuted","property":"∀x_FiledDiagnosticLineEnc_=x","seed":817721849,"attempt":0,"size":0,"path":[0,0,0,0],"failure":"ReturnedFalse"} +{"_tag":"Refuted","property":"∀x_FiledDiagnosticLineEnc_=x","seed":2434346067,"attempt":27,"size":3,"path":[4,0,0,0],"failure":"ReturnedFalse"} +{"_tag":"Refuted","property":"∀x_FiledDiagnosticLine_=x","seed":707307667,"attempt":26,"size":3,"path":[0,0,0,0,3,0,0,0],"failure":"ReturnedFalse"} 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..1297e8c --- /dev/null +++ b/packages/api-extractor/src/front-end/analyse.ts @@ -0,0 +1,48 @@ +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 { buildModel } from './build-model.ts' +import { CompilerEmitter, type CompilerEmitterId, IsolatedEmitter, type IsolatedEmitterId } from './declaration-emitter.service.ts' +import type { Declarations, PackageSource } from './declarations.schema.ts' +import type { CompilerRejected, NotIsolated, UnsupportedDeclaration } from './front-end-error.schema.ts' +import { resolveMessages } 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 analysis: Analysis = { + route, + model: yield* buildModel(declarations), + messages: yield* resolveMessages(declarations), + } + 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..ebfc9c5 --- /dev/null +++ b/packages/api-extractor/src/front-end/analysis-message.schema.ts @@ -0,0 +1,32 @@ +import { Schema } from 'effect' + +export const AnalysisMessageId: Schema.Literals< + readonly [ + 'ae-forgotten-export', + 'ae-incompatible-release-tags', + 'ae-internal-missing-underscore', + 'ae-internal-mixed-release-tag', + 'ae-missing-release-tag', + ] +> = Schema.Literals([ + 'ae-forgotten-export', + 'ae-incompatible-release-tags', + 'ae-internal-missing-underscore', + 'ae-internal-mixed-release-tag', + 'ae-missing-release-tag', +]) +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..ddfc084 --- /dev/null +++ b/packages/api-extractor/src/front-end/build-model.ts @@ -0,0 +1,625 @@ +import { Array as Arr, Effect, HashMap, HashSet, Match, Option, Order, Ref } from 'effect' +import type { + BindingIdentifier, + BindingPattern, + Class, + Declaration as DeclarationNode, + Directive, + ExportNamedDeclaration, + Expression, + Function as FunctionNode, + Statement, + StringLiteral, + TSEnumDeclaration, + TSEnumMember, + TSEnumMemberName, + TSInterfaceDeclaration, + TSModuleBlock, + TSModuleDeclaration, + TSQualifiedName, + 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, exportedNames, exportedSymbol, type Declaration, type DeclarationGraph } 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 { refuse, withTypeParameters } = TypePrinting + +const { classMembersOf, heritageOf, membersOf, referenceToExpression, 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 } +type EntryFile = { readonly exportPath: string; readonly declarationFile: string } + +const localKey = (file: string, name: string): string => `${file}\u0000${name}` +const localKeyFile = (key: string): string => key.slice(0, Math.max(key.indexOf('\u0000'), 0)) +const localKeyName = (key: string): string => key.slice(key.indexOf('\u0000') + 1) + +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: Option.getOrElse(HashMap.get(rename, reference.name), () => reference.name), 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 declaresTypeAlias = (declarations: ReadonlyArray): boolean => + Arr.some(declarations, (declaration) => declaration.declared.kind === 'typeAlias') + +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: () => (declaresTypeAlias(declarations) ? recordLocal(reachable, file, name, declarations) : Effect.succeed(name)), + 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 = withTypeParameters(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 = withTypeParameters(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(referenceToExpression(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 = withTypeParameters(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 = (initializer: Expression | null): Pick => + Option.match(Option.fromNullishOr(initializer), { + onNone: () => ({}), + onSome: (expression) => Option.match(literalTextOf(expression), { onNone: () => ({}), onSome: (value) => ({ value }) }), + }) + +const enumMemberOf = (context: TypeContext, member: TSEnumMember): Effect.Effect => + Effect.map(enumMemberNameOf(context, member.id), (name): EnumMember => ({ name, ...memberValue(member.initializer) })) + +const enumItemOf = ( + context: TypeContext, + nodes: ReadonlyArray, + name: string, +): Effect.Effect, UnsupportedDeclaration> => + Arr.match(nodes, { + onEmpty: () => Effect.succeedNone, + onNonEmpty: ([node]) => + Effect.asSome( + Effect.map( + Effect.forEach(node.body.members, (member) => enumMemberOf(context, member)), + (members): ApiItem => ({ kind: 'enum', name, const: node.const, members }), + ), + ), + }) + +const namespaceItemOf = ( + context: TypeContext, + nodes: ReadonlyArray, + name: string, +): Effect.Effect, UnsupportedDeclaration> => + Arr.match(nodes, { + onEmpty: () => Effect.succeedNone, + onNonEmpty: ([node]) => + Effect.asSome(Effect.map(namespaceBodyItems(context, node.body), (items): ApiItem => ({ kind: 'namespace', name, items }))), + }) + +const namespaceBodyItems = (context: TypeContext, body: TSModuleBlock | null): Effect.Effect, UnsupportedDeclaration> => + Option.match(Option.fromNullishOr(body), { + onNone: () => Effect.succeed>([]), + onSome: (block) => Effect.map(Effect.forEach(block.body, (statement) => statementItems(context, statement)), (items) => items.flat()), + }) + +const moduleName = (id: BindingIdentifier | StringLiteral | TSQualifiedName): Option.Option => + Match.value(id).pipe( + Match.when({ type: 'Identifier' }, (identifier) => Option.some(identifier.name)), + Match.orElse(() => Option.none()), + ) + +const declaredStatementItems = (context: TypeContext, node: DeclarationNode | Statement): Effect.Effect, UnsupportedDeclaration> => + Match.value(node).pipe( + Match.when({ type: 'VariableDeclaration' }, (variable) => + Effect.map( + Effect.forEach(variable.declarations, (declarator) => declaratorStatementItems(context, declarator, variable.kind === 'const')), + (items) => items.flat(), + )), + Match.when({ type: Match.is('FunctionDeclaration', 'TSDeclareFunction') }, (function_) => + namedStatementItems(function_.id, (name) => functionItemOf(context, [function_], name))), + Match.when({ type: 'ClassDeclaration' }, (class_) => namedStatementItems(class_.id, (name) => classItemOf(context, [class_], name))), + Match.when({ type: 'TSInterfaceDeclaration' }, (iface) => optionalItems(interfaceItemOf(context, [iface], iface.id.name))), + Match.when({ type: 'TSTypeAliasDeclaration' }, (alias) => optionalItems(typeAliasItemOf(context, [alias], alias.id.name))), + Match.when({ type: 'TSEnumDeclaration' }, (enumeration) => optionalItems(enumItemOf(context, [enumeration], enumeration.id.name))), + Match.when({ type: 'TSModuleDeclaration', global: false }, (module) => namespaceStatementItems(context, module)), + Match.orElse(() => Effect.succeed>([])), + ) + +const statementItems = (context: TypeContext, statement: Directive | Statement): Effect.Effect, UnsupportedDeclaration> => + Match.value(statement).pipe( + Match.when({ type: 'ExportNamedDeclaration' }, (declaration) => exportedStatementItems(context, declaration)), + Match.when({ type: 'TSModuleDeclaration', global: false }, (module) => namespaceStatementItems(context, module)), + Match.when({ type: Match.is('VariableDeclaration', 'FunctionDeclaration', 'TSDeclareFunction', 'ClassDeclaration', 'TSInterfaceDeclaration', 'TSTypeAliasDeclaration', 'TSEnumDeclaration') }, (node) => + declaredStatementItems(context, node)), + Match.orElse(() => Effect.succeed>([])), + ) + +const exportedStatementItems = (context: TypeContext, declaration: ExportNamedDeclaration): Effect.Effect, UnsupportedDeclaration> => + Option.match(Option.fromNullishOr(declaration.declaration), { + onNone: () => Effect.succeed>([]), + onSome: (inner) => declaredStatementItems(context, inner), + }) + +const declaratorStatementItems = (context: TypeContext, declarator: VariableDeclarator, constant: boolean): Effect.Effect, UnsupportedDeclaration> => + Option.match( + Match.value(declarator.id).pipe( + Match.when({ type: 'Identifier' }, (identifier) => Option.some(identifier.name)), + Match.orElse(() => Option.none()), + ), + { + onNone: () => Effect.succeed>([]), + onSome: (name) => optionalItems(variableItemOf(context, [{ constant, node: declarator }], name)), + }, + ) + +const namedStatementItems = ( + id: BindingIdentifier | null, + build: (name: string) => Effect.Effect, UnsupportedDeclaration>, +): Effect.Effect, UnsupportedDeclaration> => + Option.match(Option.fromNullishOr(id), { + onNone: () => Effect.succeed>([]), + onSome: (identifier) => optionalItems(build(identifier.name)), + }) + +const namespaceStatementItems = (context: TypeContext, module: TSModuleDeclaration): Effect.Effect, UnsupportedDeclaration> => + Option.match(moduleName(module.id), { + onNone: () => refuse(context.file, `a ${module.id.type} namespace name`), + onSome: (name) => optionalItems(namespaceItemOf(context, [module], name)), + }) + +const optionalItems = (item: Effect.Effect, UnsupportedDeclaration>): Effect.Effect, UnsupportedDeclaration> => + Effect.map(item, (built) => Option.match(built, { onNone: () => [], onSome: (value) => [value] })) + +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: () => Effect.succeed>([]), + onSome: (symbol) => + Match.value(symbol).pipe( + Match.when({ kind: 'external' }, (external) => refuse(entryFile, `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, + ), + }) + +const buildOneLocal = ( + contextFor: (file: string) => TypeContext, + reachable: Ref.Ref>, + built: Ref.Ref>, + key: string, +): Effect.Effect => + Effect.gen(function* () { + const known = yield* Ref.get(reachable) + return yield* Option.match(HashMap.get(known, key), { + onNone: () => Effect.void, + onSome: (local) => + Effect.flatMap( + Arr.match(typeAliasesIn(local.declarations), { + onEmpty: () => refuse(local.file, `a local "${local.name}" that is not a type alias`), + onNonEmpty: ([node]) => typeAliasItem(contextFor(local.file), node, local.key), + }), + (item) => Ref.update(built, HashMap.set(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) + const pending = Arr.filter(HashMap.keys(known), (key) => !HashMap.has(done, key)) + return yield* Arr.match(pending, { + onEmpty: () => Effect.void, + onNonEmpty: (keys) => Effect.flatMap(Effect.forEach(keys, (key) => buildOneLocal(contextFor, reachable, built, key)), () => drain()), + }) + }) + return drain() +} + +const buildEntry = (graph: DeclarationGraph, entry: EntryFile): 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, typeParameters: 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 const buildModel = (declarations: Declarations): Effect.Effect => + Effect.flatMap(declarationGraph(declarations), (graph) => + Effect.map(Effect.forEach(graph.entryPoints, (entry) => buildEntry(graph, entry)), (entryPoints) => + sortModel({ + schemaVersion: API_MODEL_SCHEMA_VERSION, + producer: { engine: ENGINE, declarations: declarations.producer }, + package: declarations.package, + entryPoints, + }), + ), + ) 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..114c64e --- /dev/null +++ b/packages/api-extractor/src/front-end/canonical-type-text.ts @@ -0,0 +1,574 @@ +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 type { KeywordName } from '../model/type-expr.schema.ts' +import { type Declaration, type DeclarationGraph, isRelativeSpecifier, 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 typeParameters: HashSet.HashSet +} + +const withTypeParameters = (context: TypeContext, names: ReadonlyArray): TypeContext => ({ + ...context, + typeParameters: HashSet.union(context.typeParameters, HashSet.fromIterable(names)), +}) + +const refuse = (file: string, construct: string): Effect.Effect => + Effect.fail(new UnsupportedDeclaration({ file, construct })) + +type KeywordNodeName = + | 'TSAnyKeyword' + | 'TSBigIntKeyword' + | 'TSBooleanKeyword' + | 'TSNeverKeyword' + | 'TSNullKeyword' + | 'TSNumberKeyword' + | 'TSObjectKeyword' + | 'TSStringKeyword' + | 'TSSymbolKeyword' + | 'TSUndefinedKeyword' + | 'TSUnknownKeyword' + | 'TSVoidKeyword' + +const KEYWORDS: HashMap.HashMap = HashMap.fromIterable([ + ['TSAnyKeyword', 'any'], + ['TSBigIntKeyword', 'bigint'], + ['TSBooleanKeyword', 'boolean'], + ['TSNeverKeyword', 'never'], + ['TSNullKeyword', 'null'], + ['TSNumberKeyword', 'number'], + ['TSObjectKeyword', 'object'], + ['TSStringKeyword', 'string'], + ['TSSymbolKeyword', 'symbol'], + ['TSUndefinedKeyword', 'undefined'], + ['TSUnknownKeyword', 'unknown'], + ['TSVoidKeyword', 'void'], +]) + +export const KEYWORD_NODE_NAMES: ReadonlyArray = [ + 'TSAnyKeyword', + 'TSBigIntKeyword', + 'TSBooleanKeyword', + 'TSNeverKeyword', + 'TSNullKeyword', + 'TSNumberKeyword', + 'TSObjectKeyword', + 'TSStringKeyword', + 'TSSymbolKeyword', + 'TSUndefinedKeyword', + 'TSUnknownKeyword', + 'TSVoidKeyword', +] + +export const keywordNameOf = (nodeType: string): Option.Option => HashMap.get(KEYWORDS, nodeType) + +const keywordName = (nodeType: KeywordNodeName): 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(literal.raw ?? String(literal.value))), + Match.when({ type: 'Literal', value: Match.bigint }, (literal) => Option.some(literal.raw ?? `${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()), + ) + +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(literal.raw ?? 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 targetOf = (context: TypeContext, file: string, segments: ReadonlyArray): Effect.Effect => + Arr.match(segments, { + onEmpty: () => Effect.succeed(plainTarget(segments)), + onNonEmpty: ([head, ...rest]) => + HashSet.has(context.typeParameters, 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(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(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 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 printSignatureLike = ( + context: TypeContext, + typeParameters: TSTypeParameterDeclaration | null, + params: ReadonlyArray, + returnType: TSTypeAnnotation | null, + decorate: (parts: { readonly typeParameters: string; readonly parameters: string; readonly returnType: string }) => string, +): Effect.Effect => + Effect.gen(function* () { + const inner = withTypeParameters(context, typeParameterNames(typeParameters)) + const printedTypeParameters = yield* printTypeParameters(inner, typeParameters) + const printedParameters = yield* printParameters(inner, params) + const printedReturnType = yield* printMethodTail(inner, 'method', 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, (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, (parts) => + `${parts.typeParameters}(${parts.parameters})${parts.returnType}`) + +const printConstructSignature = (context: TypeContext, member: TSConstructSignatureDeclaration): Effect.Effect => + printSignatureLike(context, member.typeParameters, member.params, member.returnType, (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)}[${parameter.name}: ${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}: ${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 asPlainTupleType = (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 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) => + Option.match(asPlainTupleType(plain), { + onNone: () => refuse(context.file, 'a tuple element'), + onSome: (type) => canonicalTypeText(context, type), + })), + ) + +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, (parts) => + `${parts.typeParameters}(${parts.parameters}) =>${parts.returnType}`) + +const printConstructorType = (context: TypeContext, node: TSConstructorType): Effect.Effect => + printSignatureLike(context, node.typeParameters, node.params, node.returnType, (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(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 = withTypeParameters(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 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(context, 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 printAnnotationDecorated = ( + context: TypeContext, + annotation: TSTypeAnnotation | null | undefined, + decorate: (text: string) => string, +): Effect.Effect => + Option.match(Option.fromNullishOr(annotation), { + onNone: () => Effect.succeed(''), + onSome: (present) => Effect.map(canonicalTypeText(context, present.typeAnnotation), decorate), + }) + +const printPredicate = (context: TypeContext, node: TSTypePredicate): Effect.Effect => + Effect.gen(function* () { + const tail = yield* printAnnotationDecorated(context, 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 withTypeParameters: (context: TypeContext, names: ReadonlyArray) => TypeContext + readonly refuse: (file: string, construct: string) => Effect.Effect +} = { + canonicalTypeText: canonicalTypeText, + referenceTargetFor: referenceTargetFor, + withTypeParameters: withTypeParameters, + refuse: refuse, +} diff --git a/packages/api-extractor/src/front-end/compiler-output.schema.ts b/packages/api-extractor/src/front-end/compiler-output.schema.ts new file mode 100644 index 0000000..ced472b --- /dev/null +++ b/packages/api-extractor/src/front-end/compiler-output.schema.ts @@ -0,0 +1,173 @@ +import { Array as Arr, Match, Option, Schema, SchemaGetter } from 'effect' +import type { Diagnostic } from './declarations.schema.ts' + +const EMITTED_PREFIX = 'TSFILE: ' +const ERROR_PREFIX = 'error TS' +const CODE_SEPARATOR = ': ' +const TAIL_HEAD = '): error TS' +const EMITTED_PREFIX_LENGTH = EMITTED_PREFIX.length +const FILED_LINE = /^([\s\S]*)\(([0-9]{1,9}),([0-9]{1,9})\): error TS([0-9]{1,9}): ([\s\S]*)$/ + +type EmittedFileFields = { readonly file: string } + +type SourcelessDiagnosticFields = { readonly code: number; readonly message: string } + +type FiledDiagnosticFields = { + readonly file: string + readonly line: number + readonly column: number + readonly code: number + readonly message: string +} + +const Digits: Schema.String = Schema.String.check(Schema.isPattern(/^[0-9]{1,9}$/)) + +const FileText: Schema.String = Schema.String.check(Schema.isPattern(/^[^\n\r\u2028\u2029]+$/)) + +const MessageText: Schema.String = Schema.String.check( + Schema.isPattern(/^(?![\s\S]*\(\d{1,9},\d{1,9}\): error TS\d{1,9}: )[\s\S]*$/), +) + +const Position: Schema.Int = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)) + +const EmittedFileFields: Schema.Struct<{ readonly file: typeof FileText }> = Schema.Struct({ file: FileText }) + +const SourcelessDiagnosticFields: Schema.Struct<{ readonly code: typeof Position; readonly message: Schema.String }> = Schema.Struct({ + code: Position, + message: Schema.String, +}) + +const FiledDiagnosticFields: Schema.Struct<{ + readonly file: typeof FileText + readonly line: typeof Position + readonly column: typeof Position + readonly code: typeof Position + readonly message: typeof MessageText +}> = Schema.Struct({ file: FileText, line: Position, column: Position, code: Position, message: MessageText }) + +const emittedFileOf = (line: string): EmittedFileFields => ({ file: line.slice(EMITTED_PREFIX_LENGTH) }) + +const emittedLineOf = (fields: EmittedFileFields): `TSFILE: ${string}` => `${EMITTED_PREFIX}${fields.file}` + +const sourcelessDiagnosticOf = (line: string): SourcelessDiagnosticFields => { + const separator = line.indexOf(CODE_SEPARATOR, ERROR_PREFIX.length) + return { code: Number(line.slice(ERROR_PREFIX.length, separator)), message: line.slice(separator + CODE_SEPARATOR.length) } +} + +const sourcelessLineOf = (fields: SourcelessDiagnosticFields): `error TS${string}: ${string}` => + `${ERROR_PREFIX}${fields.code}${CODE_SEPARATOR}${fields.message}` + +const UNPARSED_FILED_LINE: FiledDiagnosticFields = { file: '', line: -1, column: -1, code: -1, message: '' } + +const partOf = (parts: RegExpExecArray) => (index: number): string => parts[index] ?? '' + +const numberPartOf = (parts: RegExpExecArray) => (index: number): number => Number(parts[index] ?? '') + +const filedFieldsFrom = (parts: RegExpExecArray): FiledDiagnosticFields => ({ + file: partOf(parts)(1), + line: numberPartOf(parts)(2), + column: numberPartOf(parts)(3), + code: numberPartOf(parts)(4), + message: partOf(parts)(5), +}) + +const filedDiagnosticOf = (line: string): FiledDiagnosticFields => + Option.match(Option.fromNullishOr(FILED_LINE.exec(line)), { onNone: () => UNPARSED_FILED_LINE, onSome: filedFieldsFrom }) + +const filedLineOf = (fields: FiledDiagnosticFields): `${string}(${number},${number}): error TS${number}: ${string}` => + `${fields.file}(${fields.line},${fields.column}${TAIL_HEAD}${fields.code}${CODE_SEPARATOR}${fields.message}` + +export const EmittedFileLine: Schema.Codec = Schema.TemplateLiteral([EMITTED_PREFIX, FileText]).pipe( + Schema.decodeTo(EmittedFileFields, { decode: SchemaGetter.transform(emittedFileOf), encode: SchemaGetter.transform(emittedLineOf) }), +) + +export const SourcelessDiagnosticLine: Schema.Codec = Schema.TemplateLiteral([ + ERROR_PREFIX, + Digits, + CODE_SEPARATOR, + Schema.String, +]).pipe( + Schema.decodeTo(SourcelessDiagnosticFields, { + decode: SchemaGetter.transform(sourcelessDiagnosticOf), + encode: SchemaGetter.transform(sourcelessLineOf), + }), +) + +export const FiledDiagnosticLine: Schema.Codec = Schema.TemplateLiteral([ + FileText, + '(', + Digits, + ',', + Digits, + TAIL_HEAD, + Digits, + CODE_SEPARATOR, + MessageText, +]).pipe( + Schema.decodeTo(FiledDiagnosticFields, { + decode: SchemaGetter.transform(filedDiagnosticOf), + encode: SchemaGetter.transform(filedLineOf), + }), +) + +type ParsedLine = + | { readonly kind: 'emittedFile'; readonly file: string } + | { readonly kind: 'diagnostic'; readonly diagnostic: Diagnostic } + +export type CompilerOutput = { + readonly emittedFiles: ReadonlyArray + readonly diagnostics: ReadonlyArray +} + +export type CompilerOutputRequest = { + readonly stdout: string + readonly packageRoot: string +} + +const posixOf = (value: string): string => value.replace(/\\/g, '/') + +const withTrailingSeparator = (value: string): string => `${value.replace(/\/+$/, '')}/` + +const packageRelativeFile = (packageRoot: string, file: string): string => { + const root = withTrailingSeparator(posixOf(packageRoot)) + const normalised = posixOf(file) + return normalised.startsWith(root) ? normalised.slice(root.length) : normalised +} + +const emittedFileOfLine = (line: string): Option.Option => + Option.map(Schema.decodeOption(EmittedFileLine)(line), (fields) => ({ kind: 'emittedFile', file: fields.file })) + +const sourcelessDiagnosticOfLine = (line: string): Option.Option => + Option.map(Schema.decodeOption(SourcelessDiagnosticLine)(line), (fields) => ({ + kind: 'diagnostic', + diagnostic: { file: '', code: fields.code, message: fields.message }, + })) + +const filedDiagnosticOfLine = (packageRoot: string) => (line: string): Option.Option => + Option.map(Schema.decodeOption(FiledDiagnosticLine)(line), (fields): ParsedLine => ({ + kind: 'diagnostic', + diagnostic: { file: packageRelativeFile(packageRoot, fields.file), code: fields.code, message: fields.message }, + })) + +const parsedLineOf = (packageRoot: string) => (line: string): Option.Option => + Option.orElse(emittedFileOfLine(line), () => + Option.orElse(sourcelessDiagnosticOfLine(line), () => filedDiagnosticOfLine(packageRoot)(line))) + +const emittedFileIn = (parsed: ParsedLine): Option.Option => + Match.value(parsed).pipe( + Match.when({ kind: 'emittedFile' }, (line) => Option.some(line.file)), + Match.orElse(() => Option.none()), + ) + +const diagnosticIn = (parsed: ParsedLine): Option.Option => + Match.value(parsed).pipe( + Match.when({ kind: 'diagnostic' }, (line) => Option.some(line.diagnostic)), + Match.orElse(() => Option.none()), + ) + +const linesOf = (stdout: string): ReadonlyArray => stdout.split('\n').map((line) => line.replace(/\r$/, '')) + +export const parseCompilerOutput: (request: CompilerOutputRequest) => CompilerOutput = (request) => { + const parsed = Arr.getSomes(linesOf(request.stdout).map(parsedLineOf(request.packageRoot))) + return { emittedFiles: Arr.getSomes(parsed.map(emittedFileIn)), diagnostics: Arr.getSomes(parsed.map(diagnosticIn)) } +} diff --git a/packages/api-extractor/src/front-end/declaration-graph.ts b/packages/api-extractor/src/front-end/declaration-graph.ts index 4d056e9..410fd77 100644 --- a/packages/api-extractor/src/front-end/declaration-graph.ts +++ b/packages/api-extractor/src/front-end/declaration-graph.ts @@ -330,6 +330,19 @@ const grouped = (entries: ReadonlyArray): HashMap.HashM Arr.reduce(entries, HashMap.empty>(), (groups, [key, value]) => HashMap.set(groups, key, [...Option.getOrElse(HashMap.get(groups, key), (): ReadonlyArray => []), value])) +const isEmptyExportList = (node: ExportNamedDeclaration): boolean => node.declaration === null && node.specifiers.length === 0 + +const isExportBlocker = (statement: Directive | Statement): boolean => + Match.value(statement).pipe( + Match.when({ type: 'ExportNamedDeclaration' }, isEmptyExportList), + Match.orElse(() => false), + ) + +const implicitExports = (program: Program, parts: ReadonlyArray): ReadonlyArray => + program.body.some(isExportBlocker) + ? [] + : parts.flatMap((part) => part.declarations.map(([name]) => [name, { kind: 'local', local: name }] as const)) + const scopeOf = (file: DeclarationFile): Effect.Effect => Effect.gen(function* () { const { program, comments } = yield* parsed(file.path, file.text) @@ -341,7 +354,7 @@ const scopeOf = (file: DeclarationFile): Effect.Effect part.declarations)), imports: HashMap.fromIterable(parts.flatMap((part) => part.imports)), - exports: HashMap.fromIterable(parts.flatMap((part) => part.exports)), + exports: HashMap.fromIterable([...implicitExports(program, parts), ...parts.flatMap((part) => part.exports)]), starExports: parts.flatMap((part) => part.starExports), } }) diff --git a/packages/api-extractor/src/front-end/oxc-isolated-emitter.ts b/packages/api-extractor/src/front-end/oxc-isolated-emitter.ts index 3747f37..6623d47 100644 --- a/packages/api-extractor/src/front-end/oxc-isolated-emitter.ts +++ b/packages/api-extractor/src/front-end/oxc-isolated-emitter.ts @@ -1,4 +1,4 @@ -import { Array as Arr, Effect, FileSystem, Layer, Match, Option, Order, Path, Ref } from 'effect' +import { Array as Arr, Effect, FileSystem, 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' } @@ -65,9 +65,9 @@ const firstSource = ( })), }) -const refusedOrDeclared = (found: ReadonlyArray, declarations: Declarations): Effect.Effect => +const refusedOrClosed = (found: ReadonlyArray, closed: Result.Result): Effect.Effect => Arr.match(found, { - onEmpty: () => Effect.succeed(declarations), + onEmpty: () => Result.match(closed, { onSuccess: Effect.succeed, onFailure: Effect.fail }), onNonEmpty: (diagnostics) => Effect.fail(new NotIsolated({ diagnostics })), }) @@ -75,17 +75,19 @@ const emit = (source: PackageSource) => Effect.gen(function* () { const emission: Emission = { source, diagnostics: yield* Ref.make>([]) } const entryPoints = source.entryPoints.map((entry) => ({ exportPath: entry.exportPath, declarationFile: declarationPathOf(entry.sourceFile) })) - const files = yield* closeDeclarations({ + const closed = yield* Effect.result(closeDeclarations({ entryDeclarations: entryPoints.map((entry) => entry.declarationFile), load: (declarationFile) => firstSource(emission, sourceCandidatesOf(declarationFile)), - }) + })) const found = Arr.sort(yield* Ref.get(emission.diagnostics), diagnosticOrder) - return yield* refusedOrDeclared(found, { + const files = yield* refusedOrClosed(found, closed) + 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( 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..2ea7b8f --- /dev/null +++ b/packages/api-extractor/src/front-end/resolve-messages.ts @@ -0,0 +1,317 @@ +import { Array as Arr, Effect, HashMap, HashSet, Match, Option, Order } from 'effect' +import { + type Class, + type Comment, + type Expression, + type Program, + type TSClassImplements, + type TSImportType, + type TSInterfaceHeritage, + type TSTypeName, + type TSTypeQuery, + type TSTypeReference, + Visitor, +} from 'oxc-parser' +import type { AnalysisMessage, AnalysisMessageId } from './analysis-message.schema.ts' +import type { Declaration, DeclarationGraph, FileScope, ResolvedSymbol } from './declaration-graph.ts' +import { declarationGraph, exportedNames, exportedSymbol, 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 RELEASE_TAG_TEXT = /@(public|beta|alpha|internal)\b/ +const RELEASE_TAG_NAMES: HashSet.HashSet = HashSet.make('public', 'beta', 'alpha', 'internal') + +const isReleaseTag = (name: string): name is ReleaseTag => HashSet.has(RELEASE_TAG_NAMES, name) + +const tagOfComment = (comment: string): Option.Option => + Option.flatMap(Option.fromNullishOr(RELEASE_TAG_TEXT.exec(comment)), (match) => + Option.flatMap(Option.fromNullishOr(match[1]), (name) => Option.liftPredicate(name, isReleaseTag))) + +const isDocComment = (comment: Comment): boolean => comment.type === 'Block' && comment.value.startsWith('*') + +const leadingCommentOf = (scope: FileScope, statementStart: number): Option.Option => + Arr.findLast(scope.comments, (comment) => isDocComment(comment) && comment.end <= 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 + readonly typeParameters: HashSet.HashSet +} + +const EMPTY_SCAN: ReferenceScan = { references: [], typeParameters: HashSet.empty() } + +const scanOf = (program: Program): ReferenceScan => { + const references: Array = [] + const typeParameters: Array = [] + const record = (node: NameNode | null, start: number): void => + Option.match(rootNameOf(node), { onNone: () => undefined, onSome: (name) => void references.push({ start, name }) }) + new Visitor({ + TSTypeReference: (node: TSTypeReference) => record(node.typeName, node.start), + TSTypeQuery: (node: TSTypeQuery) => record(node.exprName, node.start), + TSInterfaceHeritage: (node: TSInterfaceHeritage) => record(node.expression, node.start), + TSClassImplements: (node: TSClassImplements) => record(node.expression, node.start), + ClassDeclaration: (node: Class) => record(node.superClass, node.start), + TSTypeParameter: (node) => void typeParameters.push(node.name.name), + }).visit(program) + return { references, typeParameters: HashSet.fromIterable(typeParameters) } +} + +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( + Arr.filter(referencesWithin(scan, scope, declaration), (reference) => !HashSet.has(scan.typeParameters, reference.name)), + (reference) => reference.name, + ) + +type EntryPoint = { + readonly exportPath: string + readonly declarationFile: string +} + +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: EntryPoint, 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 underscoreMessages = (maxTag: ReleaseTag, entry: EntryPoint, item: ExportedItem): ReadonlyArray => + needsUnderscore(maxTag, item) + ? Arr.map(item.exportNames, (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: EntryPoint, 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: EntryPoint, + 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: EntryPoint, + 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: EntryPoint, + 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: EntryPoint, + 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: EntryPoint, + 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: EntryPoint, + consumable: HashMap.HashMap, + item: ExportedItem, +): ReadonlyArray => + Arr.flatMap(item.declarations, (declaration) => declarationMessages(resolution, entry, consumable, item, declaration)) + +const itemMessages = ( + resolution: Resolution, + entry: EntryPoint, + 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: EntryPoint): 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.fromIterable(HashMap.values(Arr.reduce(messages, HashMap.empty(), (known, message) => HashMap.set(known, messageKey(message), message)))) + +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 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..02e1995 --- /dev/null +++ b/packages/api-extractor/src/front-end/type-expr-of.ts @@ -0,0 +1,532 @@ +import { Array as Arr, Effect, HashMap, Match, Option } from 'effect' +import type { + AccessorProperty, + BindingPattern, + 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, + typeParameterNames, + TypePrinting, + type TypeContext, +} from './canonical-type-text.ts' +import { UnsupportedDeclaration } from './front-end-error.schema.ts' + +const { canonicalTypeText, referenceTargetFor, refuse, withTypeParameters } = 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: '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(literal.raw ?? String(literal.value))), + 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()), + ) + +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)), + }) + +const referenceTypeOf = ( + context: TypeContext, + segments: ReadonlyArray, + source: Option.Option, + typeArguments: TSTypeParameterInstantiation | null | undefined, +): Effect.Effect => + Effect.gen(function* () { + const target = yield* referenceTargetFor(context, segments, 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 = withTypeParameters(context, typeParameterNames(present)) + return Effect.forEach(present.params, (parameter) => typeParameterOf(inner, parameter)) + }, + }) + +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 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 = withTypeParameters(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, + 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, segments, Option.none(), typeArguments), + }) + +const heritageOf = (context: TypeContext, heritage: TSInterfaceHeritage | TSClassImplements): Effect.Effect => + referenceToExpression(context, heritage.expression, heritage.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: `[${parameter.name}: ${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 accessibleName = ( + key: PropertyKey, + accessibility: TSAccessibility | null | undefined, + isStatic: boolean, +): Option.Option => + Match.value(accessibility).pipe( + Match.when('private', () => Option.none()), + Match.orElse(() => Option.map(propertyKeyNameOf(key), (name) => (isStatic ? `static ${name}` : name))), + ) + +const classMethodDraftOf = (context: TypeContext, method: MethodDefinition): Effect.Effect, UnsupportedDeclaration> => + Option.match(accessibleName(method.key, method.accessibility, method.static), { + 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: 'constructor', 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), { + 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), { + 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, nameSegments(reference.typeName), Option.none(), reference.typeArguments)), + Match.when({ type: 'TSImportType' }, (imported) => + Arr.match(importSegments(imported.qualifier), { + onEmpty: () => textExpr(context, imported), + onNonEmpty: (segments) => referenceTypeOf(context, segments, 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 annotationTypeOrDefault: ( + context: TypeContext, + annotation: TSTypeAnnotation | null | undefined, + fallback: KeywordName, + ) => 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 parametersOf: (context: TypeContext, params: ReadonlyArray) => Effect.Effect, UnsupportedDeclaration> + readonly referenceToExpression: ( + 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, + annotationTypeOrDefault: annotationTypeOrDefault, + typeParametersOf: typeParametersOf, + signatureOf: signatureOf, + parametersOf: parametersOf, + referenceToExpression: referenceToExpression, + 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..3c2d328 --- /dev/null +++ b/packages/api-extractor/src/front-end/typescript-7-compiler.ts @@ -0,0 +1,161 @@ +import { Array as Arr, Effect, FileSystem, HashMap, Layer, Option, Path, Stream } from 'effect' +import type { PlatformError } from 'effect/PlatformError' +import { ChildProcess, ChildProcessSpawner } from 'effect/process' +import typescriptManifest from 'typescript/package.json' with { type: 'json' } +import { type CompilerOutput, parseCompilerOutput } from './compiler-output.schema.ts' +import { closeDeclarations, declarationPathOf } from './declaration-graph.ts' +import { CompilerEmitter, type CompilerEmitterId, type DeclarationEmitter } from './declaration-emitter.service.ts' +import type { Declarations, Diagnostic, PackageSource } from './declarations.schema.ts' +import { CompilerRejected } from './front-end-error.schema.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 + +type DeclarationEntryPoint = { readonly exportPath: string; readonly declarationFile: string } + +type EmittedDeclaration = { readonly declared: string; readonly emittedFile: string } + +type CompilerRun = { + readonly output: CompilerOutput + readonly exitCode: number + readonly stderr: string + readonly outDir: string +} + +const compilerArguments = (source: PackageSource, outDir: string): ReadonlyArray => [ + '-p', + source.tsconfigPath, + '--noEmit', + 'false', + '--declaration', + '--emitDeclarationOnly', + '--declarationMap', + 'false', + '--sourceMap', + 'false', + '--incremental', + 'false', + '--composite', + 'false', + '--tsBuildInfoFile', + 'null', + '--rootDir', + source.workspaceRoot, + '--outDir', + outDir, + '--listEmittedFiles', + '--pretty', + 'false', +] + +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 handle = yield* spawner.spawn( + ChildProcess.make(process.execPath, [tsc, ...compilerArguments(source, outDir)], { cwd: source.packageRoot }), + ) + const [stdout, stderr] = yield* Effect.all([drain(handle.stdout), drain(handle.stderr)], { concurrency: 2 }) + const exitCode = yield* handle.exitCode + return { output: parseCompilerOutput({ stdout, packageRoot: source.packageRoot }), exitCode, stderr, outDir } + }) + +const exitDiagnostic = (exitCode: number, stderr: string): Diagnostic => ({ + file: '', + code: 0, + message: stderr.trim() === '' ? `\`tsc\` exited with code ${exitCode}` : `\`tsc\` exited with code ${exitCode}: ${stderr.trim()}`, +}) + +const rejectionOf = (run: CompilerRun): Option.Option> => + Option.orElse(Option.liftPredicate(run.output.diagnostics, Arr.isReadonlyArrayNonEmpty), () => + Option.map(Option.liftPredicate(run.exitCode, (code) => code !== 0), (code) => [exitDiagnostic(code, run.stderr)])) + +const packageDeclarationOf = (path: Path.Path, declaredRoot: string, emittedFile: string): Option.Option => + Option.map( + Option.liftPredicate(path.relative(declaredRoot, emittedFile), (relative) => relative !== '' && !relative.startsWith('..')), + (declared) => ({ declared, emittedFile }), + ) + +const entryPointsOf = (source: PackageSource): ReadonlyArray => + source.entryPoints.map((entry) => ({ exportPath: entry.exportPath, declarationFile: declarationPathOf(entry.sourceFile) })) + +const missingEntryDiagnostic = (declarationFile: string): Diagnostic => ({ + file: declarationFile, + code: 0, + message: 'the compiler emitted no declaration for this entry point', +}) + +const missingEntries = ( + emitted: HashMap.HashMap, + entryPoints: ReadonlyArray, +): ReadonlyArray => + entryPoints.filter((entry) => !HashMap.has(emitted, entry.declarationFile)).map((entry) => missingEntryDiagnostic(entry.declarationFile)) + +const declarationsOf = ( + source: PackageSource, + entryPoints: ReadonlyArray, + emitted: HashMap.HashMap, +) => + Effect.map( + closeDeclarations({ + entryDeclarations: entryPoints.map((entry) => entry.declarationFile), + load: (declarationFile) => Effect.succeed(HashMap.get(emitted, declarationFile)), + }), + (files): Declarations => ({ + producer: TYPESCRIPT_PRODUCER, + package: { name: source.name, version: source.version }, + entryPoints, + files, + }), + ) + +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 = Arr.getSomes(run.output.emittedFiles.map((emittedFile) => packageDeclarationOf(path, declaredRoot, emittedFile))) + const emitted = HashMap.fromIterable( + yield* Effect.forEach(declared, (declaration) => + Effect.map(fileSystem.readFileString(declaration.emittedFile), (text) => [declaration.declared, text] as const), + ), + ) + const entryPoints = entryPointsOf(source) + return yield* Arr.match(missingEntries(emitted, entryPoints), { + onEmpty: () => declarationsOf(source, entryPoints, emitted), + onNonEmpty: (diagnostics) => Effect.fail(new CompilerRejected({ diagnostics })), + }) + }) + +const emit = (source: PackageSource, tsc: string) => + Effect.scoped( + Effect.gen(function* () { + const run = yield* runCompiler(source, tsc) + return yield* Option.match(rejectionOf(run), { + onNone: () => declarationsFrom(source, run), + onSome: (diagnostics) => Effect.fail(new CompilerRejected({ diagnostics })), + }) + }), + ) + +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/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/tests/__fixtures__/upstream-messages.ts b/packages/api-extractor/tests/__fixtures__/upstream-messages.ts new file mode 100644 index 0000000..9c9d863 --- /dev/null +++ b/packages/api-extractor/tests/__fixtures__/upstream-messages.ts @@ -0,0 +1,97 @@ +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 firstQuoted = (text: string): string => { + const match = /"([^"]*)"/.exec(text) + return match === null ? '' : match[1] ?? '' +} + +const subjectOf = (message: ExtractorMessage): ReadonlyArray> => + FIVE_IDS.includes(message.messageId) ? [[message.messageId, firstQuoted(message.text)]] : [] + +const sortedPairs = (pairs: ReadonlyArray>): ReadonlyArray => + pairs + .map(([messageId, symbol]) => [messageId ?? '', symbol ?? ''] 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..bebc35b --- /dev/null +++ b/packages/api-extractor/tests/analysis-messages.integration.test.ts @@ -0,0 +1,130 @@ +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 Pair = readonly [string, string] + +const asPairs = (messages: ReadonlyArray): ReadonlyArray => + messages.map((message) => [message.messageId, message.symbol] as const) + +const pairKey = (pair: Pair): string => `${pair[0]}\u0000${pair[1]}` + +const sorted = (pairs: ReadonlyArray): ReadonlyArray => + pairs + .filter((pair, index) => pairs.findIndex((candidate) => pairKey(candidate) === pairKey(pair)) === index) + .sort((left, right) => (pairKey(left) < pairKey(right) ? -1 : 1)) + +const bothSides = (declarations: FrontEnd.Declarations) => + Effect.map(FrontEnd.resolveMessages(declarations), (messages) => ({ messages, upstream: upstreamPairs(declarations) })) + +const reviewed = (messages: ReadonlyArray): ReadonlyArray => sorted(asPairs(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.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.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.messages)).toEqual(sorted(scope.both.upstream))), + ), + ) + + 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.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.messages)).toEqual(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.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..daa83ce --- /dev/null +++ b/packages/api-extractor/tests/front-end-model.integration.test.ts @@ -0,0 +1,264 @@ +import { Gherkin, Given, it, makeFeature, Then, When } from '@systemfsoftware/effect-gherkin-spec' +import { ApiModel, FrontEnd } from '@systemfsoftware/api-extractor' +import { Array as Arr, Effect, Layer, Option } from 'effect' +import { isolatedDeclarationSync } from 'oxc-transform' + +type Model = ApiModel.ApiModel +type EntryPoint = ApiModel.EntryPoint +type Item = ApiModel.ApiItem + +const Feature = makeFeature({ it }) + +const declarations = ( + files: ReadonlyArray, + entryPoints: ReadonlyArray = [['.', 'index.d.ts']], +): FrontEnd.Declarations => ({ + producer: 'fixture-emitter@0.0.0', + package: { name: 'fixture', version: '1.0.0' }, + entryPoints: entryPoints.map(([exportPath, declarationFile]) => ({ exportPath, declarationFile })), + files: files.map(([path, text]) => ({ path, text })), +}) + +const entryAt = (model: Model, path: string): EntryPoint => + Option.getOrThrow(Arr.findFirst(model.entryPoints, (entry) => entry.path === path)) + +const itemOf = (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)) + +const itemsNamed = (model: Model, path: string, name: string): ReadonlyArray => + entryAt(model, path).items.filter((item) => item.name === name) + +const builtOf = (given: FrontEnd.Declarations) => FrontEnd.buildModel(given) + +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 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( + '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( + '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( + '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"' }] }, + })), + ), + ) + }) 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..c65d9e9 --- /dev/null +++ b/packages/api-extractor/tests/front-end-routing.integration.test.ts @@ -0,0 +1,78 @@ +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, source: string) => + 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* fileSystem.writeFileString(path.join(root, 'src', 'mod.ts'), source) + return root + }) + +const builtOnlyPackage = packageOf({ name: 'no-source', version: '1.0.0', exports: { '.': { types: './dist/mod.d.ts', default: './dist/mod.js' } } }, '') + +const destructuredExportPackage = packageOf( + { name: 'destructured', version: '1.0.0', exports: { '.': { [FrontEnd.FAMILY_SOURCE_CONDITION]: './src/mod.ts' } } }, + 'const pair = { a: 1, b: 2 }\nexport const { a, b } = pair\n', +) + +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 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(Effect.flatMap(FrontEnd.readPackage({ packageRoot: scope.packageRoot }), (source) => + Effect.flatMap(FrontEnd.IsolatedEmitter, (emitter) => emitter.emit(source))))), + 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 }] }) + ), + ), + ) + }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 11bd542..35bfa14 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -305,6 +305,9 @@ importers: '@effect/platform-node': specifier: 'catalog:' version: 4.0.2(effect@4.0.2)(redis@6.3.0(@opentelemetry/api@1.9.1)) + '@systemfsoftware/api-extractor': + specifier: workspace:* + version: link:../../packages/api-extractor '@systemfsoftware/oxlint-config-recommended': specifier: file:../../.sfs-deps/oxlint-config-recommended-4.0.0.tgz version: file:.sfs-deps/oxlint-config-recommended-4.0.0.tgz(effect@4.0.2)(oxlint-tsgolint@7.0.2001)(oxlint@1.82.0(oxlint-tsgolint@7.0.2001))(typescript@7.0.2) @@ -354,6 +357,9 @@ importers: specifier: 'catalog:' version: 7.0.2 devDependencies: + '@effect/platform-node': + specifier: 'catalog:' + version: 4.0.2(effect@4.0.2)(redis@6.3.0(@opentelemetry/api@1.9.1)) '@microsoft/api-extractor': specifier: 'catalog:' version: 7.59.1(@types/node@24.19.1) From d4d55d3cbbf0d412114d00da3c398dbb7f3938e4 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Mon, 12 Oct 2026 17:39:43 +0000 Subject: [PATCH 06/99] refactor(front-end): simplify the front-end slice (ce-simplify-code) analyse builds the declaration graph once and hands it to the builder and the resolver; the closure walk pushes onto a queue instead of copying it per file; compiler-output decoders are built once; one bindingName and one optional-type printer; the analysis message ids are one const list; resolver de-duplication uses Arr.dedupeWith. No behaviour change: 198 package tests, 4 e2e tests and the host digest are unchanged. --- .../api-extractor/src/front-end/analyse.ts | 10 +++-- .../src/front-end/analysis-message.schema.ts | 14 ++----- .../src/front-end/build-model.ts | 25 +++++++++---- .../src/front-end/canonical-type-text.ts | 28 +++----------- .../src/front-end/compiler-output.schema.ts | 12 ++++-- .../src/front-end/declaration-graph.ts | 37 ++++++++++++++----- .../src/front-end/resolve-messages.ts | 5 ++- .../src/front-end/type-expr-of.ts | 10 +---- 8 files changed, 74 insertions(+), 67 deletions(-) diff --git a/packages/api-extractor/src/front-end/analyse.ts b/packages/api-extractor/src/front-end/analyse.ts index 1297e8c..10f2635 100644 --- a/packages/api-extractor/src/front-end/analyse.ts +++ b/packages/api-extractor/src/front-end/analyse.ts @@ -2,11 +2,12 @@ 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 { buildModel } from './build-model.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 { resolveMessages } from './resolve-messages.ts' +import { messagesOfGraph } from './resolve-messages.ts' import type { Route } from './route.schema.ts' export type Analysis = { @@ -39,10 +40,11 @@ export const analyse = ( ): Effect.Effect => Effect.gen(function* () { const { route, declarations } = yield* routed(source) + const graph = yield* declarationGraph(declarations) const analysis: Analysis = { route, - model: yield* buildModel(declarations), - messages: yield* resolveMessages(declarations), + 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 index ebfc9c5..d4419c9 100644 --- a/packages/api-extractor/src/front-end/analysis-message.schema.ts +++ b/packages/api-extractor/src/front-end/analysis-message.schema.ts @@ -1,20 +1,14 @@ import { Schema } from 'effect' -export const AnalysisMessageId: Schema.Literals< - readonly [ - 'ae-forgotten-export', - 'ae-incompatible-release-tags', - 'ae-internal-missing-underscore', - 'ae-internal-mixed-release-tag', - 'ae-missing-release-tag', - ] -> = Schema.Literals([ +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 = { diff --git a/packages/api-extractor/src/front-end/build-model.ts b/packages/api-extractor/src/front-end/build-model.ts index ddfc084..820f223 100644 --- a/packages/api-extractor/src/front-end/build-model.ts +++ b/packages/api-extractor/src/front-end/build-model.ts @@ -612,14 +612,23 @@ const buildEntry = (graph: DeclarationGraph, entry: EntryFile): Effect.Effect 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) => - Effect.map(Effect.forEach(graph.entryPoints, (entry) => buildEntry(graph, entry)), (entryPoints) => - sortModel({ - schemaVersion: API_MODEL_SCHEMA_VERSION, - producer: { engine: ENGINE, declarations: declarations.producer }, - package: declarations.package, - entryPoints, - }), - ), + 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 index 114c64e..c24b3db 100644 --- a/packages/api-extractor/src/front-end/canonical-type-text.ts +++ b/packages/api-extractor/src/front-end/canonical-type-text.ts @@ -232,6 +232,8 @@ const printOptional = ( 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, @@ -384,20 +386,10 @@ const printSpecialTupleElement = ( Match.orElse((rest) => Effect.map(canonicalTypeText(context, rest.typeAnnotation), (type) => `...${type}`)), ) -const asPlainTupleType = (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 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) => - Option.match(asPlainTupleType(plain), { - onNone: () => refuse(context.file, 'a tuple element'), - onSome: (type) => canonicalTypeText(context, type), - })), + Match.orElse((plain) => canonicalTypeText(context, plain)), ) const printTuple = (context: TypeContext, node: TSTupleType): Effect.Effect => @@ -476,19 +468,9 @@ const printInfer = (context: TypeContext, node: TSInferType): Effect.Effect string, -): Effect.Effect => - Option.match(Option.fromNullishOr(annotation), { - onNone: () => Effect.succeed(''), - onSome: (present) => Effect.map(canonicalTypeText(context, present.typeAnnotation), decorate), - }) - const printPredicate = (context: TypeContext, node: TSTypePredicate): Effect.Effect => Effect.gen(function* () { - const tail = yield* printAnnotationDecorated(context, node.typeAnnotation, (text) => ` is ${text}`) + 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'), @@ -566,9 +548,11 @@ export const TypePrinting: { ) => Effect.Effect readonly withTypeParameters: (context: TypeContext, names: ReadonlyArray) => TypeContext readonly refuse: (file: string, construct: string) => Effect.Effect + readonly bindingName: (pattern: BindingPattern) => string } = { canonicalTypeText: canonicalTypeText, referenceTargetFor: referenceTargetFor, withTypeParameters: withTypeParameters, refuse: refuse, + bindingName: bindingName, } diff --git a/packages/api-extractor/src/front-end/compiler-output.schema.ts b/packages/api-extractor/src/front-end/compiler-output.schema.ts index ced472b..c6a2b9b 100644 --- a/packages/api-extractor/src/front-end/compiler-output.schema.ts +++ b/packages/api-extractor/src/front-end/compiler-output.schema.ts @@ -110,6 +110,12 @@ export const FiledDiagnosticLine: Schema.Codec = }), ) +const decodeEmittedFileLine: (line: string) => Option.Option = Schema.decodeOption(EmittedFileLine) + +const decodeSourcelessDiagnosticLine: (line: string) => Option.Option = Schema.decodeOption(SourcelessDiagnosticLine) + +const decodeFiledDiagnosticLine: (line: string) => Option.Option = Schema.decodeOption(FiledDiagnosticLine) + type ParsedLine = | { readonly kind: 'emittedFile'; readonly file: string } | { readonly kind: 'diagnostic'; readonly diagnostic: Diagnostic } @@ -135,16 +141,16 @@ const packageRelativeFile = (packageRoot: string, file: string): string => { } const emittedFileOfLine = (line: string): Option.Option => - Option.map(Schema.decodeOption(EmittedFileLine)(line), (fields) => ({ kind: 'emittedFile', file: fields.file })) + Option.map(decodeEmittedFileLine(line), (fields) => ({ kind: 'emittedFile', file: fields.file })) const sourcelessDiagnosticOfLine = (line: string): Option.Option => - Option.map(Schema.decodeOption(SourcelessDiagnosticLine)(line), (fields) => ({ + Option.map(decodeSourcelessDiagnosticLine(line), (fields) => ({ kind: 'diagnostic', diagnostic: { file: '', code: fields.code, message: fields.message }, })) const filedDiagnosticOfLine = (packageRoot: string) => (line: string): Option.Option => - Option.map(Schema.decodeOption(FiledDiagnosticLine)(line), (fields): ParsedLine => ({ + Option.map(decodeFiledDiagnosticLine(line), (fields): ParsedLine => ({ kind: 'diagnostic', diagnostic: { file: packageRelativeFile(packageRoot, fields.file), code: fields.code, message: fields.message }, })) diff --git a/packages/api-extractor/src/front-end/declaration-graph.ts b/packages/api-extractor/src/front-end/declaration-graph.ts index 410fd77..a053a0e 100644 --- a/packages/api-extractor/src/front-end/declaration-graph.ts +++ b/packages/api-extractor/src/front-end/declaration-graph.ts @@ -130,23 +130,40 @@ const firstLoaded = ( const followed = (file: string, program: Program): ReadonlyArray> => specifiersOf(program).filter(isRelativeSpecifier).map((specifier) => declarationCandidates(file, specifier)) -const visitFound = (file: string, text: string, rest: ReadonlyArray>, loaded: Loaded, load: Loader) => +type WorkQueue = { + readonly push: (candidates: ReadonlyArray) => void + readonly get: (index: number) => Option.Option> +} + +const workQueue = (initial: ReadonlyArray>): WorkQueue => { + const pending: Array> = [...initial] + return { + push: (candidates) => void pending.push(candidates), + get: (index) => Arr.get(pending, index), + } +} + +const visitFound = (file: string, text: string, queue: WorkQueue, at: number, loaded: Loaded, load: Loader) => HashMap.has(loaded, file) - ? visit(rest, loaded, load) - : Effect.flatMap(parsed(file, text), ({ program }) => visit([...rest, ...followed(file, program)], HashMap.set(loaded, file, text), load)) + ? visit(queue, at, loaded, load) + : Effect.flatMap(parsed(file, text), ({ program }) => { + Arr.forEach(followed(file, program), (candidates) => queue.push(candidates)) + return visit(queue, at, HashMap.set(loaded, file, text), load) + }) const visit = ( - pending: ReadonlyArray>, + queue: WorkQueue, + at: number, loaded: Loaded, load: Loader, ): Effect.Effect => - Arr.match(pending, { - onEmpty: () => Effect.succeed(loaded), - onNonEmpty: ([candidates, ...rest]) => + Option.match(queue.get(at), { + onNone: () => Effect.succeed(loaded), + onSome: (candidates) => Effect.flatMap(firstLoaded(candidates, loaded, load), (found) => Option.match(found, { - onNone: () => visit(rest, loaded, load), - onSome: ([file, text]) => visitFound(file, text, rest, loaded, load), + onNone: () => visit(queue, at + 1, loaded, load), + onSome: ([file, text]) => visitFound(file, text, queue, at + 1, loaded, load), })), }) @@ -159,7 +176,7 @@ export type ClosureRequest = { export const closeDeclarations = (request: ClosureRequest): Effect.Effect, E | UnsupportedDeclaration, R> => Effect.map( - visit(request.entryDeclarations.map(Arr.of), HashMap.empty(), request.load), + Effect.suspend(() => visit(workQueue(request.entryDeclarations.map(Arr.of)), 0, HashMap.empty(), request.load)), (loaded) => Arr.sort(Arr.map(HashMap.toEntries(loaded), ([path, text]) => ({ path, text })), byPath), ) diff --git a/packages/api-extractor/src/front-end/resolve-messages.ts b/packages/api-extractor/src/front-end/resolve-messages.ts index 2ea7b8f..817e13b 100644 --- a/packages/api-extractor/src/front-end/resolve-messages.ts +++ b/packages/api-extractor/src/front-end/resolve-messages.ts @@ -299,7 +299,7 @@ const entryMessages = (resolution: Resolution, entry: EntryPoint): ReadonlyArray const messageKey = (message: AnalysisMessage): string => [message.messageId, message.entryPoint, message.item, message.symbol].join('\u0000') const deduped = (messages: ReadonlyArray): ReadonlyArray => - Arr.fromIterable(HashMap.values(Arr.reduce(messages, HashMap.empty(), (known, message) => HashMap.set(known, messageKey(message), message)))) + Arr.dedupeWith(messages, (left, right) => messageKey(left) === messageKey(right)) const analysisMessageOrder: Order.Order = Order.combineAll([ Order.mapInput(Order.String, (message: AnalysisMessage) => message.messageId), @@ -313,5 +313,8 @@ const messagesFor = (graph: DeclarationGraph): ReadonlyArray => 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/type-expr-of.ts b/packages/api-extractor/src/front-end/type-expr-of.ts index 02e1995..143e0d4 100644 --- a/packages/api-extractor/src/front-end/type-expr-of.ts +++ b/packages/api-extractor/src/front-end/type-expr-of.ts @@ -1,7 +1,6 @@ import { Array as Arr, Effect, HashMap, Match, Option } from 'effect' import type { AccessorProperty, - BindingPattern, ClassElement, Expression, MethodDefinition, @@ -41,7 +40,7 @@ import { } from './canonical-type-text.ts' import { UnsupportedDeclaration } from './front-end-error.schema.ts' -const { canonicalTypeText, referenceTargetFor, refuse, withTypeParameters } = TypePrinting +const { bindingName, canonicalTypeText, referenceTargetFor, refuse, withTypeParameters } = TypePrinting const unknownKeyword: TypeExpr = { kind: 'keyword', name: 'unknown' } @@ -130,13 +129,6 @@ const typeParametersOf = ( }, }) -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 parameterOf = (context: TypeContext, parameter: ParamPattern): Effect.Effect => Match.value(parameter).pipe( Match.when({ type: 'TSParameterProperty' }, (property) => parameterOf(context, property.parameter)), From fd4ad933743af7be22b27e21048ab3ccfb323ea2 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Mon, 12 Oct 2026 17:44:16 +0000 Subject: [PATCH 07/99] docs(front-end): second phase profile with build and resolve phases, README front-end section (U9) The harness now runs FrontEnd.readPackage, the routed emitters, buildModel and resolveMessages on every corpus package and records the route and build outcome. On systemfsoftware 732f66a0c8: 12 fast path, 28 fallback, 37 of 40 models built; declare global, a module augmentation and an external re-export are refused with UnsupportedDeclaration. The first profile and its emitter decision stay unchanged. --- README.md | 40 +- apps/api-extractor-profile/profile.ts | 89 +- .../2026-10-12-front-end-phases-built.json | 3160 +++++++++++++++++ .../2026-10-12-front-end-phases-built.md | 102 + 4 files changed, 3378 insertions(+), 13 deletions(-) create mode 100644 docs/profiles/2026-10-12-front-end-phases-built.json create mode 100644 docs/profiles/2026-10-12-front-end-phases-built.md diff --git a/README.md b/README.md index f61b770..5414ffe 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,44 @@ 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 +TypeScript file as an entry point; a package with none is refused with `NoSourceEntry`. +`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), the package takes the fallback: this package's own +TypeScript 7 `tsc` runs as a process, and `route` is `Fallback` with the diagnostics that +sent it there. A `tsc` that reports an error refuses the package with `CompilerRejected`. +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. + +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-profile/profile.ts b/apps/api-extractor-profile/profile.ts index 015b941..bbf58ff 100644 --- a/apps/api-extractor-profile/profile.ts +++ b/apps/api-extractor-profile/profile.ts @@ -1,5 +1,6 @@ import { NodeRuntime, NodeServices } from '@effect/platform-node' -import { Array as Arr, Duration, Effect, FileSystem, Option, Order, Path, Schema, Stream } from 'effect' +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' @@ -9,9 +10,10 @@ import { isolatedDeclarationSync } from 'oxc-transform' import ts59 from 'typescript-5.9' const WARM_RUNS = 5 -const SOURCE_CONDITION = '@systemfsoftware/source' const require = createRequire(import.meta.url) +const TYPESCRIPT_SOURCE = /\.m?tsx?$/ + const ExportTarget = Schema.Union([Schema.String, Schema.Record(Schema.String, Schema.Json)]) const Manifest = Schema.Struct({ @@ -60,7 +62,7 @@ const measure = (effect: Effect.Effect, runs: number = WARM_RU const sourceEntryOf = (target: typeof ExportTarget.Type): Option.Option => typeof target === 'string' ? Option.none() - : Option.fromNullishOr(target[SOURCE_CONDITION]).pipe(Option.filter((value): value is string => typeof value === 'string' && /\.m?tsx?$/.test(value))) + : Option.fromNullishOr(target[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))) @@ -81,8 +83,6 @@ const specifiersOf = (file: string, text: string): ReadonlyArray => { .filter((specifier) => RELATIVE.test(specifier)) } -const TYPESCRIPT_SOURCE = /\.m?tsx?$/ - const candidatesFor = (base: string): ReadonlyArray => TYPESCRIPT_SOURCE.test(base) ? [base] : ['.ts', '.tsx', '.mts', '/index.ts'].map((extension) => base + extension) @@ -190,7 +190,17 @@ const isolatedErrorsIn = (run: CompileRun, subject: Subject, sources: ReadonlyMa const differing = (left: ReadonlyMap, right: ReadonlyMap): number => [...left].filter(([file, text]) => right.get(file) !== text).length -const PHASES = ['discover', 'emit:oxc-transform', 'emit:transpileDeclaration', 'emit:tsc7', 'emit:tsc5.9.3', 'parse:oxc-parser', 'parse:createSourceFile'] as const +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 { @@ -210,12 +220,61 @@ interface PackageProfile { readonly clean: Readonly> readonly fallback: { readonly exitCode: number; readonly diagnostics: number } readonly divergence: { readonly compared: number; readonly differing: number; readonly typescript59ExitCode: number } + 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 => +const profilePackage = (subject: Subject): Effect.Effect => Effect.scoped(Effect.gen(function* () { const path = yield* Path.Path const discovery = yield* measure(discover(subject)) @@ -230,6 +289,7 @@ const profilePackage = (subject: Subject): Effect.Effect 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, @@ -246,6 +306,7 @@ const profilePackage = (subject: Subject): Effect.Effect, phase: Phase, key: keyof 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 |` - const rule = `|${'---|'.repeat(PHASES.length + 7)}` + const header = `| Package | Origin | Sources | oxc clean | tD clean | tsc ID clean | ${PHASES.join(' | ')} | TS7≠5.9.3 | route | model |` + const rule = `|${'---|'.repeat(PHASES.length + 9)}` 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.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.frontEnd.route} | ${item.frontEnd.outcome} |` ) - const totals = `| **total** | | ${report.packages.reduce((sum, item) => sum + item.sourceFiles, 0)} | | | | ${PHASES.map((phase) => total(report.packages, phase, 'warmMedianMs')).join(' | ')} | ${report.packages.reduce((sum, item) => sum + item.divergence.differing, 0)}/${report.packages.reduce((sum, item) => sum + item.divergence.compared, 0)} |` + 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)} | | |` return [ '# Front-end phase profile', '', @@ -350,4 +413,6 @@ const program = Effect.gen(function* () { yield* fileSystem.writeFileString(`${outputArgument}.md`, markdownOf(report)) }) -NodeRuntime.runMain(program.pipe(Effect.scoped, Effect.provide(NodeServices.layer))) +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/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). From 549b472541304c0a593c7d3db0203dfca0c4c4eb Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Mon, 12 Oct 2026 20:58:00 +0000 Subject: [PATCH 08/99] fix(front-end): apply the operator-accepted review dispositions (U10) - Changeset: a minor intent for @systemfsoftware/api-extractor, because the FrontEnd namespace is new public surface (package-topology/surface-changes-are-versioned). - Compiler-output codecs: Position is capped below 1e9 and digit text has one spelling (no zero padding). Each refinement has a refusal law in its own file (schema-laws/refusals-beside-generated-laws), and each law fails when its refinement is widened. The four committed Refuted seeds are kept as regression coverage, because they still express valid cases. - The isolated emitter declines with NotIsolated, naming the declaration file, when an entry point's source cannot be read, instead of modelling an empty entry point. - An entry point's declarations load from its recorded source path first, so a .tsx entry is no longer served from a sibling .ts. - The TypeScript 7 fallback passes --stripInternal false, so both routes keep @internal declarations. The e2e equality journey now covers a stripInternal package. - An enum member whose declared value is computed is refused with UnsupportedDeclaration naming the member, instead of losing its value. - README documents the builder's refusals and the unrefused export * from another package. - The plan is superseded by a new dated plan that adds U10 and KTD12, the Turbo evidence. KTD12 records that Turbo already owns build, cache and affected-task decisions here. #4 (implicit exports) stays rejected: tsc 7.0.2 accepts importing a non-exported interface from an import-bearing .d.ts (exit 0), and adding `export {}` makes the same import TS2459. --- .changeset/old-cobras-punch.md | 5 ++ README.md | 16 ++++++ .../tests/front-ends.integration.test.ts | 45 ++++++++++++---- ...> 2026-10-12-2048-feat-front-ends-plan.md} | 31 ++++++++++- .../src/front-end/build-model.ts | 13 +++-- .../src/front-end/compiler-output.schema.ts | 47 ++++++++++++++-- .../src/front-end/oxc-isolated-emitter.ts | 32 +++++++++-- .../src/front-end/typescript-7-compiler.ts | 2 + .../tests/front-end-model.integration.test.ts | 11 ++++ .../front-end-routing.integration.test.ts | 54 +++++++++++++++---- 10 files changed, 221 insertions(+), 35 deletions(-) create mode 100644 .changeset/old-cobras-punch.md rename docs/plans/{2026-10-12-1532-feat-front-ends-plan.md => 2026-10-12-2048-feat-front-ends-plan.md} (87%) diff --git a/.changeset/old-cobras-punch.md b/.changeset/old-cobras-punch.md new file mode 100644 index 0000000..0742362 --- /dev/null +++ b/.changeset/old-cobras-punch.md @@ -0,0 +1,5 @@ +--- +"@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. diff --git a/README.md b/README.md index 5414ffe..cf4e99e 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,22 @@ 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. +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: + +- an entry point that re-exports a name from another package (`export { it } from 'vitest'`). + The whole package is refused, not just that export. +- `declare global` and module augmentation (`declare module 'vitest'`) +- `export =` +- an enum member whose declared value is computed rather than a literal, for example a + hand-written `.d.ts` with `Write = 1 << 1`. Both emitters write constant members as + literals, so this happens only with declaration text written by hand. + +One construct is not refused: `export * from 'effect'`, or `export *` from any other +package, adds no items to the model. The names it re-exports are missing from the model, +and a verdict does not see them change. + The fast-path emitter was chosen from a per-phase profile on systemfsoftware's public packages, [`docs/profiles/2026-10-12-front-end-phases.md`](docs/profiles/2026-10-12-front-end-phases.md). To reproduce it, check out systemfsoftware at the commit the profile names, install it diff --git a/apps/api-extractor-e2e/tests/front-ends.integration.test.ts b/apps/api-extractor-e2e/tests/front-ends.integration.test.ts index 96b99ef..c4757fe 100644 --- a/apps/api-extractor-e2e/tests/front-ends.integration.test.ts +++ b/apps/api-extractor-e2e/tests/front-ends.integration.test.ts @@ -12,7 +12,7 @@ const STANDALONE_TSCONFIG = { include: ['src'], } -const packageWith = (name: string, source: string) => +const packageWith = (name: string, source: string, compilerOptions: object = {}) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem const path = yield* Path.Path @@ -22,11 +22,20 @@ const packageWith = (name: string, source: string) => path.join(root, 'package.json'), JSON.stringify({ name, version: '1.0.0', type: 'module', exports: { '.': { [FrontEnd.FAMILY_SOURCE_CONDITION]: './src/mod.ts' } } }), ) - yield* fileSystem.writeFileString(path.join(root, 'tsconfig.json'), JSON.stringify(STANDALONE_TSCONFIG)) + 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 thisPackage = Effect.gen(function* () { const path = yield* Path.Path const root = yield* path.fromFileUrl(new URL('../../../packages/api-extractor', import.meta.url)) @@ -36,29 +45,43 @@ const thisPackage = Effect.gen(function* () { 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(yield* FrontEnd.buildModel({ ...declarations, producer: 'one producer' })), + 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), } }) +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 this package the same model and messages', + 'the fast path and the compiler fallback give the same model and messages, @internal exports included', Gherkin.Do.pipe( Given('this isolatedDeclarations-clean package')('source', () => thisPackage), - When('it is analysed through the isolated emitter and through the compiler')('analyses', (scope) => - Effect.all({ - isolated: analysedThrough(FrontEnd.IsolatedEmitter, scope.source), - compiled: analysedThrough(FrontEnd.CompilerEmitter, scope.source), - }) + Given('a package whose tsconfig strips @internal and which exports an @internal constant')('internal', () => internalExportPackage), + When('each is analysed through the isolated emitter and through the compiler')('analyses', (scope) => + Effect.all({ ownPackage: throughBothRoutes(scope.source), internalExport: throughBothRoutes(scope.internal) }) ), - Then('the two serialise to the same bytes once the producer is set equal')((scope, expect) => - expect(scope.analyses.compiled).toEqual(scope.analyses.isolated) + Then('each pair serialises to the same bytes once the producer is set equal, keeping the @internal constant')((scope, expect) => + expect({ + ownPackage: scope.analyses.ownPackage.compiled, + internalExport: scope.analyses.internalExport.compiled, + internalExports: scope.analyses.internalExport.isolated.exports, + }).toEqual({ + ownPackage: scope.analyses.ownPackage.isolated, + internalExport: scope.analyses.internalExport.isolated, + internalExports: ['hidden', 'shown'], + }) ), ), ) diff --git a/docs/plans/2026-10-12-1532-feat-front-ends-plan.md b/docs/plans/2026-10-12-2048-feat-front-ends-plan.md similarity index 87% rename from docs/plans/2026-10-12-1532-feat-front-ends-plan.md rename to docs/plans/2026-10-12-2048-feat-front-ends-plan.md index d15150d..0e344e1 100644 --- a/docs/plans/2026-10-12-1532-feat-front-ends-plan.md +++ b/docs/plans/2026-10-12-2048-feat-front-ends-plan.md @@ -6,7 +6,7 @@ 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-12-1510-feat-front-ends-plan.md +supersedes: docs/plans/2026-10-12-1532-feat-front-ends-plan.md execution: code --- @@ -126,6 +126,16 @@ Product Contract preservation: Product Contract unchanged. - 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 @@ -249,6 +259,23 @@ U1 → U2 (profile committed) → U3 (choice recorded) → U4 → U5 → U6 → - **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. + - #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). + --- ## Verification Contract @@ -271,7 +298,7 @@ No mutation run, local or in CI (origin R23). ## Definition of Done -- U1-U9 verified; every Verification Contract gate passes locally and CI is green on the exact PR head. +- U1-U10 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. diff --git a/packages/api-extractor/src/front-end/build-model.ts b/packages/api-extractor/src/front-end/build-model.ts index 820f223..8803ac4 100644 --- a/packages/api-extractor/src/front-end/build-model.ts +++ b/packages/api-extractor/src/front-end/build-model.ts @@ -401,14 +401,19 @@ const enumMemberNameOf = (context: TypeContext, id: TSEnumMemberName): Effect.Ef Match.orElse(() => refuse(context.file, `a ${id.type} enum member name`)), ) -const memberValue = (initializer: Expression | null): Pick => +const memberValue = (context: TypeContext, name: string, initializer: Expression | null): Effect.Effect, UnsupportedDeclaration> => Option.match(Option.fromNullishOr(initializer), { - onNone: () => ({}), - onSome: (expression) => Option.match(literalTextOf(expression), { onNone: () => ({}), onSome: (value) => ({ value }) }), + 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.map(enumMemberNameOf(context, member.id), (name): EnumMember => ({ name, ...memberValue(member.initializer) })) + Effect.flatMap(enumMemberNameOf(context, member.id), (name) => + Effect.map(memberValue(context, name, member.initializer), (value): EnumMember => ({ name, ...value }))) const enumItemOf = ( context: TypeContext, diff --git a/packages/api-extractor/src/front-end/compiler-output.schema.ts b/packages/api-extractor/src/front-end/compiler-output.schema.ts index c6a2b9b..6406d7c 100644 --- a/packages/api-extractor/src/front-end/compiler-output.schema.ts +++ b/packages/api-extractor/src/front-end/compiler-output.schema.ts @@ -1,3 +1,4 @@ +/// import { Array as Arr, Match, Option, Schema, SchemaGetter } from 'effect' import type { Diagnostic } from './declarations.schema.ts' @@ -6,7 +7,9 @@ const ERROR_PREFIX = 'error TS' const CODE_SEPARATOR = ': ' const TAIL_HEAD = '): error TS' const EMITTED_PREFIX_LENGTH = EMITTED_PREFIX.length -const FILED_LINE = /^([\s\S]*)\(([0-9]{1,9}),([0-9]{1,9})\): error TS([0-9]{1,9}): ([\s\S]*)$/ +const POSITION_DIGITS = '(?:0|[1-9][0-9]{0,8})' +const POSITION_LIMIT = 1_000_000_000 +const FILED_LINE = new RegExp(`^([\\s\\S]*)\\((${POSITION_DIGITS}),(${POSITION_DIGITS})\\): error TS(${POSITION_DIGITS}): ([\\s\\S]*)$`) type EmittedFileFields = { readonly file: string } @@ -20,7 +23,7 @@ type FiledDiagnosticFields = { readonly message: string } -const Digits: Schema.String = Schema.String.check(Schema.isPattern(/^[0-9]{1,9}$/)) +const Digits: Schema.String = Schema.String.check(Schema.isPattern(new RegExp(`^${POSITION_DIGITS}$`))) const FileText: Schema.String = Schema.String.check(Schema.isPattern(/^[^\n\r\u2028\u2029]+$/)) @@ -28,7 +31,7 @@ const MessageText: Schema.String = Schema.String.check( Schema.isPattern(/^(?![\s\S]*\(\d{1,9},\d{1,9}\): error TS\d{1,9}: )[\s\S]*$/), ) -const Position: Schema.Int = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)) +const Position: Schema.Int = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0), Schema.isLessThan(POSITION_LIMIT)) const EmittedFileFields: Schema.Struct<{ readonly file: typeof FileText }> = Schema.Struct({ file: FileText }) @@ -177,3 +180,41 @@ export const parseCompilerOutput: (request: CompilerOutputRequest) => CompilerOu const parsed = Arr.getSomes(linesOf(request.stdout).map(parsedLineOf(request.packageRoot))) return { emittedFiles: Arr.getSomes(parsed.map(emittedFileIn)), diagnostics: Arr.getSomes(parsed.map(diagnosticIn)) } } + +const NINE_DIGIT_SEEDS: ReadonlyArray = [-1, 0, 999_999_999, 1_000_000_000, Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY, 0.5] + +const SPELLING_SEEDS: ReadonlyArray = ['', '0', '00', '007', '7', '999999999', '1000000000', '0999999999', '-1'] + +const nonNegativeSafeInteger = (value: number): boolean => Number.isSafeInteger(value) && value >= 0 + +const printableByTsc = (value: number): boolean => nonNegativeSafeInteger(value) && String(value).length <= 9 + +const spellsItsValue = (text: string): boolean => String(Number(text)) === text + +const canonicalSpelling = (text: string): boolean => spellsItsValue(text) && printableByTsc(Number(text)) + +const isSourcelessDiagnostic: (input: unknown) => input is SourcelessDiagnosticFields = Schema.is(SourcelessDiagnosticFields) + +const codeAccepted = (code: number): boolean => isSourcelessDiagnostic({ code, message: 'm' }) + +const codeTextDecodes = (text: string): boolean => Option.isSome(decodeSourcelessDiagnosticLine(`${ERROR_PREFIX}${text}${CODE_SEPARATOR}m`)) + +const paddedSpelling = (padding: number, value: number): string => `${'0'.repeat(Math.abs(padding) % 3)}${value}` + +if (import.meta.vitest !== void 0) { + // The test runner only exists under vitest; tsdown defines import.meta.vitest as undefined and drops this block. + const { it } = await import('@systemfsoftware/vitest') + + it.prop( + '∀n_SourcelessDiagnosticFieldsPositionRefusal_≡PrintableByTsc', + { of: [Schema.Finite], subject: codeAccepted }, + (subject, [value]) => Arr.every(Arr.append(NINE_DIGIT_SEEDS, value), (code) => subject(code) === printableByTsc(code)), + ) + + it.prop( + '∀s_SourcelessDiagnosticLineDigitsRefusal_≡CanonicalSpelling', + { of: [Schema.Int, Schema.Int], subject: codeTextDecodes }, + (subject, [padding, value]) => + Arr.every(Arr.append(SPELLING_SEEDS, paddedSpelling(padding, value)), (text) => subject(text) === canonicalSpelling(text)), + ) +} diff --git a/packages/api-extractor/src/front-end/oxc-isolated-emitter.ts b/packages/api-extractor/src/front-end/oxc-isolated-emitter.ts index 6623d47..d4c8e37 100644 --- a/packages/api-extractor/src/front-end/oxc-isolated-emitter.ts +++ b/packages/api-extractor/src/front-end/oxc-isolated-emitter.ts @@ -1,10 +1,10 @@ -import { Array as Arr, Effect, FileSystem, Layer, Match, Option, Order, Path, Ref, Result } from 'effect' +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, sourceCandidatesOf } from './declaration-graph.ts' import { type DeclarationEmitter, IsolatedEmitter, type IsolatedEmitterId } from './declaration-emitter.service.ts' -import type { Declarations, Diagnostic, PackageSource } from './declarations.schema.ts' +import type { DeclarationFile, Declarations, Diagnostic, PackageSource } from './declarations.schema.ts' import { NotIsolated } from './front-end-error.schema.ts' export const OXC_TRANSFORM_PRODUCER: string = `oxc-transform@${oxcTransformManifest.version}` @@ -71,16 +71,38 @@ const refusedOrClosed = (found: ReadonlyArray, closed: Result. onNonEmpty: (diagnostics) => Effect.fail(new NotIsolated({ diagnostics })), }) +const missingEntryDiagnostic = (declarationFile: string): Diagnostic => ({ + file: declarationFile, + code: 0, + message: 'no source file could be read for this entry point', +}) + +const missingEntries = (files: ReadonlyArray, entryDeclarations: ReadonlyArray): ReadonlyArray => { + const emitted = HashSet.fromIterable(files.map((file) => file.path)) + return entryDeclarations.filter((declarationFile) => !HashSet.has(emitted, declarationFile)).map(missingEntryDiagnostic) +} + +const everyEntryEmitted = (files: ReadonlyArray, entryDeclarations: ReadonlyArray): Effect.Effect, NotIsolated> => + Arr.match(missingEntries(files, entryDeclarations), { + onEmpty: () => Effect.succeed(files), + onNonEmpty: (diagnostics) => Effect.fail(new NotIsolated({ diagnostics })), + }) + +const recordedSourceFirst = (recorded: HashMap.HashMap) => (declarationFile: string): ReadonlyArray => + Arr.dedupe([...Option.toArray(HashMap.get(recorded, declarationFile)), ...sourceCandidatesOf(declarationFile)]) + const emit = (source: PackageSource) => Effect.gen(function* () { const emission: Emission = { source, diagnostics: yield* Ref.make>([]) } const entryPoints = source.entryPoints.map((entry) => ({ exportPath: entry.exportPath, declarationFile: declarationPathOf(entry.sourceFile) })) + const entryDeclarations = entryPoints.map((entry) => entry.declarationFile) + const candidatesOf = recordedSourceFirst(HashMap.fromIterable(source.entryPoints.map((entry) => [declarationPathOf(entry.sourceFile), entry.sourceFile] as const))) const closed = yield* Effect.result(closeDeclarations({ - entryDeclarations: entryPoints.map((entry) => entry.declarationFile), - load: (declarationFile) => firstSource(emission, sourceCandidatesOf(declarationFile)), + entryDeclarations, + load: (declarationFile) => firstSource(emission, candidatesOf(declarationFile)), })) const found = Arr.sort(yield* Ref.get(emission.diagnostics), diagnosticOrder) - const files = yield* refusedOrClosed(found, closed) + const files = yield* everyEntryEmitted(yield* refusedOrClosed(found, closed), entryDeclarations) const declarations: Declarations = { producer: OXC_TRANSFORM_PRODUCER, package: { name: source.name, version: source.version }, diff --git a/packages/api-extractor/src/front-end/typescript-7-compiler.ts b/packages/api-extractor/src/front-end/typescript-7-compiler.ts index 3c2d328..fa0c1ee 100644 --- a/packages/api-extractor/src/front-end/typescript-7-compiler.ts +++ b/packages/api-extractor/src/front-end/typescript-7-compiler.ts @@ -36,6 +36,8 @@ const compilerArguments = (source: PackageSource, outDir: string): ReadonlyArray 'false', '--sourceMap', 'false', + '--stripInternal', + 'false', '--incremental', 'false', '--composite', diff --git a/packages/api-extractor/tests/front-end-model.integration.test.ts b/packages/api-extractor/tests/front-end-model.integration.test.ts index daa83ce..d8ef821 100644 --- a/packages/api-extractor/tests/front-end-model.integration.test.ts +++ b/packages/api-extractor/tests/front-end-model.integration.test.ts @@ -225,6 +225,17 @@ Feature('Building a package API model from its declaration text') ), ) + 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( 'a built model decodes as schema version two', Gherkin.Do.pipe( diff --git a/packages/api-extractor/tests/front-end-routing.integration.test.ts b/packages/api-extractor/tests/front-end-routing.integration.test.ts index c65d9e9..e871ebb 100644 --- a/packages/api-extractor/tests/front-end-routing.integration.test.ts +++ b/packages/api-extractor/tests/front-end-routing.integration.test.ts @@ -9,23 +9,34 @@ const emitters = Layer.mergeAll(FrontEnd.isolatedEmitterLayer, FrontEnd.compiler const packageRoot = Effect.flatMap(Path.Path, (path) => Effect.map(path.fromFileUrl(new URL('..', import.meta.url)), (root) => path.resolve(root))) -const packageOf = (manifest: object, source: string) => +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* fileSystem.writeFileString(path.join(root, 'src', 'mod.ts'), source) + yield* Effect.forEach(Object.entries(sources), ([file, text]) => fileSystem.writeFileString(path.join(root, 'src', file), text), { discard: true }) return root }) -const builtOnlyPackage = packageOf({ name: 'no-source', version: '1.0.0', exports: { '.': { types: './dist/mod.d.ts', default: './dist/mod.js' } } }, '') +const sourceManifest = (name: string, entry: string) => ({ name, version: '1.0.0', exports: { '.': { [FrontEnd.FAMILY_SOURCE_CONDITION]: entry } } }) -const destructuredExportPackage = packageOf( - { name: 'destructured', version: '1.0.0', exports: { '.': { [FrontEnd.FAMILY_SOURCE_CONDITION]: './src/mod.ts' } } }, - 'const pair = { a: 1, b: 2 }\nexport const { a, b } = pair\n', -) +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 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 emittedOnFastPath = (packageRoot: string) => + Effect.flatMap(FrontEnd.readPackage({ packageRoot }), (source) => Effect.flatMap(FrontEnd.IsolatedEmitter, (emitter) => emitter.emit(source))) Feature('A package is routed to the front-end its declarations allow') .withLayer(emitters) @@ -67,12 +78,35 @@ Feature('A package is routed to the front-end its declarations allow') '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(Effect.flatMap(FrontEnd.readPackage({ packageRoot: scope.packageRoot }), (source) => - Effect.flatMap(FrontEnd.IsolatedEmitter, (emitter) => emitter.emit(source))))), + 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')((scope, expect) => + expect(scope.refusal).toMatchObject({ _tag: 'NotIsolated', diagnostics: [{ file: 'src/mod.d.ts', code: 0 }] }) + ), + ), + ) + + 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'] }) + ), + ), + ) }) From 1f13a4a0b44a0b98db18010b461785828307d143 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Mon, 12 Oct 2026 20:58:00 +0000 Subject: [PATCH 09/99] docs(solutions): the compiler fallback inherits the package tsconfig's emit options (ce-compound) The fallback runs tsc -p , so a package's stripInternal (or any other option that changes declaration text) diverges from the fast path's fixed options unless the adapter pins it on the command line. --- ...lback-inherits-declaration-emit-options.md | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 docs/solutions/logic-errors/compiler-fallback-inherits-declaration-emit-options.md 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..a9f96ca --- /dev/null +++ b/docs/solutions/logic-errors/compiler-fallback-inherits-declaration-emit-options.md @@ -0,0 +1,83 @@ +--- +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 +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 -p `, so it takes every option + the package sets unless its `compilerArguments` override that option on the command line. + +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 + +`compilerArguments` in the TypeScript 7 adapter passes `--stripInternal false`, next to its +other pinned options (`--declaration`, `--emitDeclarationOnly`, `--declarationMap false`, +`--sourceMap false`, `--incremental false`, `--composite false`, `--rootDir`, `--outDir`). +A command-line flag overrides the tsconfig, so the package no longer controls the option. + +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 the flag the compiled model lacks `hidden`, and the journey fails. + +## Architectural invariant + +**Single emit policy.** The fast path's options are the declaration-emit policy. The +fallback adapter restates on the command line every option in that policy that affects +declaration text, so that $\text{text}_{\text{fallback}}$ depends on the package only +through source and module resolution: + +``` +fallbackArgs = ['-p', tsconfig, ...emitPolicyAsFlags(FAST_PATH_OPTIONS), ...outputLocation] +``` + +## Prevention + +- Change both adapters together. An option added to the fast path's `isolatedDeclarationSync` + call is mirrored as a flag in `compilerArguments` 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. +- Code smell to grep for: a `compilerArguments` list that lacks a flag for an option the fast + path passes explicitly. + +## Related + +- `docs/plans/2026-10-12-2048-feat-front-ends-plan.md` (R3; U10 disposition #6) +- `docs/solutions/design-patterns/keyed-uniqueness-in-the-api-model-schema.md`: a law passes + vacuously when its corpus lacks the triggering shape From a2fe1eb4079030fadd7031d0b8fe4983054c6e96 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Mon, 12 Oct 2026 21:09:12 +0000 Subject: [PATCH 10/99] docs(solutions): state the single-emit-policy invariant only as far as the adapter establishes it The delta review noted that tsc -p still loads every other tsconfig option, so the doc no longer claims the fallback text depends on the package only through source. --- .../compiler-fallback-inherits-declaration-emit-options.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 index a9f96ca..e4bbd20 100644 --- a/docs/solutions/logic-errors/compiler-fallback-inherits-declaration-emit-options.md +++ b/docs/solutions/logic-errors/compiler-fallback-inherits-declaration-emit-options.md @@ -58,9 +58,10 @@ present. Without the flag the compiled model lacks `hidden`, and the journey fai ## Architectural invariant **Single emit policy.** The fast path's options are the declaration-emit policy. The -fallback adapter restates on the command line every option in that policy that affects -declaration text, so that $\text{text}_{\text{fallback}}$ depends on the package only -through source and module resolution: +fallback adapter restates every option in that policy on the command line, so a package's +tsconfig cannot make the routes disagree on those options. Other tsconfig options still +reach the fallback, because `-p` loads the whole file. An option outside the policy that +changes declaration text must be added to both adapters, together with an equality case: ``` fallbackArgs = ['-p', tsconfig, ...emitPolicyAsFlags(FAST_PATH_OPTIONS), ...outputLocation] From 3162d197a87683b4079dd0d735e5dd01a2c8635f Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 13 Oct 2026 07:27:55 +0000 Subject: [PATCH 11/99] fix(front-end): keep both emitters inside the analysed package (review #1, #2) - The compiler fallback passes `--declarationDir` pointing at its scoped temp directory. A tsconfig `declarationDir` overrides `--outDir` for declarations, so tsc wrote into the analysed package and the adapter then refused it. The front-ends equality journey now covers a package that sets `declarationDir` and asserts nothing is written into it. - The fast path reads an entry source only when it resolves inside the package root, sharing `relativeWithin` with the fallback's emitted-file filter. An entry outside the root was modelled on the fast path while tsc refused it (TS6059), so the two routes disagreed. It is now declined with NotIsolated and reaches the same refusal on the fallback. - The compiler-fallback solution doc adds the output-location case. --- .../tests/front-ends.integration.test.ts | 21 ++++++++++++++--- ...lback-inherits-declaration-emit-options.md | 14 +++++++++++ .../src/front-end/declaration-graph.ts | 14 ++++++++++- .../src/front-end/oxc-isolated-emitter.ts | 16 ++++++++----- .../src/front-end/typescript-7-compiler.ts | 9 ++++---- .../front-end-routing.integration.test.ts | 23 +++++++++++++++++++ 6 files changed, 82 insertions(+), 15 deletions(-) diff --git a/apps/api-extractor-e2e/tests/front-ends.integration.test.ts b/apps/api-extractor-e2e/tests/front-ends.integration.test.ts index c4757fe..663bfaf 100644 --- a/apps/api-extractor-e2e/tests/front-ends.integration.test.ts +++ b/apps/api-extractor-e2e/tests/front-ends.integration.test.ts @@ -36,6 +36,11 @@ const internalExportPackage = packageWith( { stripInternal: true }, ) +const declarationDirPackage = packageWith('declaration-dir', 'export const placed: number = 1\n', { declarationDir: 'types' }) + +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)) @@ -65,22 +70,32 @@ Feature('Both front-ends reduce a package to one model') .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, @internal exports included', + 'the fast path and the compiler fallback give the same model and messages, whatever the package tsconfig says about @internal or declarationDir', 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 sends declarations to `declarationDir: "types"`')('declarationDir', () => declarationDirPackage), When('each is analysed through the isolated emitter and through the compiler')('analyses', (scope) => - Effect.all({ ownPackage: throughBothRoutes(scope.source), internalExport: throughBothRoutes(scope.internal) }) + Effect.all({ + ownPackage: throughBothRoutes(scope.source), + internalExport: throughBothRoutes(scope.internal), + declarationDir: throughBothRoutes(scope.declarationDir), + wroteIntoPackage: writtenInto(scope.declarationDir, 'types'), + }) ), - Then('each pair serialises to the same bytes once the producer is set equal, keeping the @internal constant')((scope, expect) => + Then('each pair serialises to the same bytes 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, + declarationDir: scope.analyses.declarationDir.compiled, + wroteIntoPackage: scope.analyses.wroteIntoPackage, }).toEqual({ ownPackage: scope.analyses.ownPackage.isolated, internalExport: scope.analyses.internalExport.isolated, internalExports: ['hidden', 'shown'], + declarationDir: scope.analyses.declarationDir.isolated, + wroteIntoPackage: false, }) ), ), 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 index e4bbd20..96d74aa 100644 --- a/docs/solutions/logic-errors/compiler-fallback-inherits-declaration-emit-options.md +++ b/docs/solutions/logic-errors/compiler-fallback-inherits-declaration-emit-options.md @@ -55,6 +55,16 @@ package whose tsconfig sets `stripInternal: true` and which exports `/** @internal */ hidden`. It asserts byte equality between the routes and that `hidden` is present. Without the flag 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 +`tsc -p` with the adapter's flags printed a `TSFILE:` line under the package's own `types` +directory). Before `compilerArguments` also passed `--declarationDir `, the adapter +therefore wrote the package's declarations into that `types` directory, found nothing under +its temp `outDir`, 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. + ## Architectural invariant **Single emit policy.** The fast path's options are the declaration-emit policy. The @@ -74,6 +84,10 @@ fallbackArgs = ['-p', tsconfig, ...emitPolicyAsFlags(FAST_PATH_OPTIONS), ...outp - 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 flag (`--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. - Code smell to grep for: a `compilerArguments` list that lacks a flag for an option the fast path passes explicitly. diff --git a/packages/api-extractor/src/front-end/declaration-graph.ts b/packages/api-extractor/src/front-end/declaration-graph.ts index a053a0e..1bd0f63 100644 --- a/packages/api-extractor/src/front-end/declaration-graph.ts +++ b/packages/api-extractor/src/front-end/declaration-graph.ts @@ -1,4 +1,4 @@ -import { Array as Arr, Effect, Function as Fn, HashMap, HashSet, Match, Option, Order } from 'effect' +import { Array as Arr, Effect, Function as Fn, HashMap, HashSet, Match, Option, Order, type Path } from 'effect' import { type Class, type Comment, @@ -30,6 +30,18 @@ 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))) + const directoryOf = (path: string): string => path.slice(0, Math.max(path.lastIndexOf('/'), 0)) const parentOf = (kept: ReadonlyArray): Option.Option> => diff --git a/packages/api-extractor/src/front-end/oxc-isolated-emitter.ts b/packages/api-extractor/src/front-end/oxc-isolated-emitter.ts index d4c8e37..c5a7988 100644 --- a/packages/api-extractor/src/front-end/oxc-isolated-emitter.ts +++ b/packages/api-extractor/src/front-end/oxc-isolated-emitter.ts @@ -2,7 +2,7 @@ import { Array as Arr, Effect, FileSystem, HashMap, HashSet, Layer, Match, Optio import type { PlatformError } from 'effect/PlatformError' import { isolatedDeclarationSync } from 'oxc-transform' import oxcTransformManifest from 'oxc-transform/package.json' with { type: 'json' } -import { closeDeclarations, declarationPathOf, sourceCandidatesOf } from './declaration-graph.ts' +import { closeDeclarations, declarationPathOf, relativeWithin, sourceCandidatesOf } 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 } from './front-end-error.schema.ts' @@ -38,10 +38,14 @@ const readSource = (packageRoot: string, sourceFile: string) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem const path = yield* Path.Path - return yield* fileSystem.readFileString(path.join(packageRoot, sourceFile)).pipe( - Effect.asSome, - Effect.catchIf(isAbsent, () => Effect.succeedNone), - ) + return yield* Option.match(relativeWithin(path, packageRoot, path.resolve(packageRoot, sourceFile)), { + onNone: () => Effect.succeedNone, + onSome: (inside) => + fileSystem.readFileString(path.join(packageRoot, inside)).pipe( + Effect.asSome, + Effect.catchIf(isAbsent, () => Effect.succeedNone), + ), + }) }) const declare = (emission: Emission, sourceFile: string, text: string) => @@ -74,7 +78,7 @@ const refusedOrClosed = (found: ReadonlyArray, closed: Result. const missingEntryDiagnostic = (declarationFile: string): Diagnostic => ({ file: declarationFile, code: 0, - message: 'no source file could be read for this entry point', + message: 'no source file inside the package could be read for this entry point', }) const missingEntries = (files: ReadonlyArray, entryDeclarations: ReadonlyArray): ReadonlyArray => { diff --git a/packages/api-extractor/src/front-end/typescript-7-compiler.ts b/packages/api-extractor/src/front-end/typescript-7-compiler.ts index fa0c1ee..8c02ffb 100644 --- a/packages/api-extractor/src/front-end/typescript-7-compiler.ts +++ b/packages/api-extractor/src/front-end/typescript-7-compiler.ts @@ -3,7 +3,7 @@ import type { PlatformError } from 'effect/PlatformError' import { ChildProcess, ChildProcessSpawner } from 'effect/process' import typescriptManifest from 'typescript/package.json' with { type: 'json' } import { type CompilerOutput, parseCompilerOutput } from './compiler-output.schema.ts' -import { closeDeclarations, declarationPathOf } from './declaration-graph.ts' +import { closeDeclarations, declarationPathOf, relativeWithin } from './declaration-graph.ts' import { CompilerEmitter, type CompilerEmitterId, type DeclarationEmitter } from './declaration-emitter.service.ts' import type { Declarations, Diagnostic, PackageSource } from './declarations.schema.ts' import { CompilerRejected } from './front-end-error.schema.ts' @@ -48,6 +48,8 @@ const compilerArguments = (source: PackageSource, outDir: string): ReadonlyArray source.workspaceRoot, '--outDir', outDir, + '--declarationDir', + outDir, '--listEmittedFiles', '--pretty', 'false', @@ -80,10 +82,7 @@ const rejectionOf = (run: CompilerRun): Option.Option> Option.map(Option.liftPredicate(run.exitCode, (code) => code !== 0), (code) => [exitDiagnostic(code, run.stderr)])) const packageDeclarationOf = (path: Path.Path, declaredRoot: string, emittedFile: string): Option.Option => - Option.map( - Option.liftPredicate(path.relative(declaredRoot, emittedFile), (relative) => relative !== '' && !relative.startsWith('..')), - (declared) => ({ declared, emittedFile }), - ) + Option.map(relativeWithin(path, declaredRoot, emittedFile), (declared) => ({ declared, emittedFile })) const entryPointsOf = (source: PackageSource): ReadonlyArray => source.entryPoints.map((entry) => ({ exportPath: entry.exportPath, declarationFile: declarationPathOf(entry.sourceFile) })) diff --git a/packages/api-extractor/tests/front-end-routing.integration.test.ts b/packages/api-extractor/tests/front-end-routing.integration.test.ts index e871ebb..80715b2 100644 --- a/packages/api-extractor/tests/front-end-routing.integration.test.ts +++ b/packages/api-extractor/tests/front-end-routing.integration.test.ts @@ -35,6 +35,18 @@ const tsxEntryBesideTsPackage = packageOf(sourceManifest('tsx-entry', './src/mod 'mod.ts': 'export const fromTs: number = 2\n', }) +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))) @@ -109,4 +121,15 @@ Feature('A package is routed to the front-end its declarations allow') ), ), ) + + 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', code: 0 }] }) + ), + ), + ) }) From 72ebed4eb46e1ad8021684679a4fb0af9e00721e Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 13 Oct 2026 07:52:27 +0000 Subject: [PATCH 12/99] fix(front-end): write numbers by value so both routes build one model oxc-transform prints `1_000` as `1e3` where tsc prints `1000`, so an enum member or `const` initialised with `1_000` gave the fast path and the compiler fallback different models (R3, R7). Numeric literals, bigint literals and numeric property keys now print their value instead of their source spelling. The builder feature gains a scenario pairing oxc output with tsc's spelling of the same numbers. --- .../src/front-end/canonical-type-text.ts | 6 ++-- .../src/front-end/type-expr-of.ts | 2 +- .../tests/front-end-model.integration.test.ts | 36 +++++++++++++++++++ 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/packages/api-extractor/src/front-end/canonical-type-text.ts b/packages/api-extractor/src/front-end/canonical-type-text.ts index c24b3db..0c2330a 100644 --- a/packages/api-extractor/src/front-end/canonical-type-text.ts +++ b/packages/api-extractor/src/front-end/canonical-type-text.ts @@ -117,8 +117,8 @@ const keywordName = (nodeType: KeywordNodeName): KeywordName => Option.getOrThro 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(literal.raw ?? String(literal.value))), - Match.when({ type: 'Literal', value: Match.bigint }, (literal) => Option.some(literal.raw ?? `${literal.value}n`)), + 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}`)), @@ -138,7 +138,7 @@ 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(literal.raw ?? String(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('.')}]`)), diff --git a/packages/api-extractor/src/front-end/type-expr-of.ts b/packages/api-extractor/src/front-end/type-expr-of.ts index 143e0d4..a1f245d 100644 --- a/packages/api-extractor/src/front-end/type-expr-of.ts +++ b/packages/api-extractor/src/front-end/type-expr-of.ts @@ -61,7 +61,7 @@ const propertyKeyNameOf = (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(literal.raw ?? String(literal.value))), + Match.when({ type: 'Literal', value: Match.number }, (literal) => Option.some(String(literal.value))), 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()), diff --git a/packages/api-extractor/tests/front-end-model.integration.test.ts b/packages/api-extractor/tests/front-end-model.integration.test.ts index d8ef821..0a50faa 100644 --- a/packages/api-extractor/tests/front-end-model.integration.test.ts +++ b/packages/api-extractor/tests/front-end-model.integration.test.ts @@ -100,6 +100,42 @@ Feature('Building a package API model from its declaration text') ), ) + 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 file re-exported under a namespace becomes a namespace item', Gherkin.Do.pipe( From 7b597e4aabbf4659f71a3929b2dde5ea6dd9e95c Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 13 Oct 2026 08:05:43 +0000 Subject: [PATCH 13/99] fix(front-end): match api-extractor's release-tag notes on both routes - A declaration's release tag comes only from a doc comment after the previous top-level statement. The resolver took the last doc comment anywhere before the statement, so `/** @internal */` on one export, or an interface member's doc comment, tagged the exports after it. - `ae-internal-missing-underscore` names only export names without a leading underscore, as api-extractor 7.59.1 does; `_hidden` was flagged too. - The compiler fallback passes `--removeComments false`. A tsconfig `removeComments` stripped the `/** @public */` comment on that route only, so the routes disagreed on `ae-missing-release-tag`. The upstream-oracle feature gains a scenario with a tagged export, an interface member's doc comment and untagged exports after them; the equality journey gains a `removeComments` package, and the solution doc records the second text-changing option. --- .../tests/front-ends.integration.test.ts | 12 +++++++-- ...lback-inherits-declaration-emit-options.md | 6 +++++ .../src/front-end/resolve-messages.ts | 13 ++++++--- .../src/front-end/typescript-7-compiler.ts | 2 ++ .../analysis-messages.integration.test.ts | 27 +++++++++++++++++++ 5 files changed, 55 insertions(+), 5 deletions(-) diff --git a/apps/api-extractor-e2e/tests/front-ends.integration.test.ts b/apps/api-extractor-e2e/tests/front-ends.integration.test.ts index 663bfaf..3bdbe78 100644 --- a/apps/api-extractor-e2e/tests/front-ends.integration.test.ts +++ b/apps/api-extractor-e2e/tests/front-ends.integration.test.ts @@ -38,6 +38,8 @@ const internalExportPackage = packageWith( 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 writtenInto = (source: FrontEnd.PackageSource, directory: string) => Effect.flatMap(Path.Path, (path) => Effect.flatMap(FileSystem.FileSystem, (fileSystem) => fileSystem.exists(path.join(source.packageRoot, directory)))) @@ -70,30 +72,36 @@ Feature('Both front-ends reduce a package to one model') .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 or declarationDir', + 'the fast path and the compiler fallback give the same model and messages, whatever the package tsconfig says about @internal, comments or declarationDir', 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), 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), wroteIntoPackage: writtenInto(scope.declarationDir, 'types'), }) ), - Then('each pair serialises to the same bytes once the producer is set equal, and the compiler writes nothing into the package')((scope, expect) => + 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, 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, wroteIntoPackage: false, }) 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 index 96d74aa..38e3f7c 100644 --- a/docs/solutions/logic-errors/compiler-fallback-inherits-declaration-emit-options.md +++ b/docs/solutions/logic-errors/compiler-fallback-inherits-declaration-emit-options.md @@ -65,6 +65,12 @@ its temp `outDir`, and refused the package. The same journey now analyses a pack 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 passes `--removeComments false`, and the journey analyses a package whose tsconfig +sets `removeComments: true` and asserts both routes give the same notes, and none. + ## Architectural invariant **Single emit policy.** The fast path's options are the declaration-emit policy. The diff --git a/packages/api-extractor/src/front-end/resolve-messages.ts b/packages/api-extractor/src/front-end/resolve-messages.ts index 817e13b..ef20bd9 100644 --- a/packages/api-extractor/src/front-end/resolve-messages.ts +++ b/packages/api-extractor/src/front-end/resolve-messages.ts @@ -1,4 +1,4 @@ -import { Array as Arr, Effect, HashMap, HashSet, Match, Option, Order } from 'effect' +import { Array as Arr, Effect, HashMap, HashSet, Match, Option, Order, Predicate } from 'effect' import { type Class, type Comment, @@ -32,8 +32,13 @@ const tagOfComment = (comment: string): Option.Option => 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, (comment) => isDocComment(comment) && comment.end <= statementStart) + 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)) @@ -177,9 +182,11 @@ const missingTagMessages = (graph: DeclarationGraph, entry: EntryPoint, item: Ex const needsUnderscore = (maxTag: ReleaseTag, item: ExportedItem): boolean => maxTag === 'internal' && !item.namePath.includes('.') +const lacksUnderscore = (name: string): boolean => !name.startsWith('_') + const underscoreMessages = (maxTag: ReleaseTag, entry: EntryPoint, item: ExportedItem): ReadonlyArray => needsUnderscore(maxTag, item) - ? Arr.map(item.exportNames, (name) => analysisMessage('ae-internal-missing-underscore', entry.exportPath, item.namePath, name)) + ? 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) diff --git a/packages/api-extractor/src/front-end/typescript-7-compiler.ts b/packages/api-extractor/src/front-end/typescript-7-compiler.ts index 8c02ffb..452419b 100644 --- a/packages/api-extractor/src/front-end/typescript-7-compiler.ts +++ b/packages/api-extractor/src/front-end/typescript-7-compiler.ts @@ -38,6 +38,8 @@ const compilerArguments = (source: PackageSource, outDir: string): ReadonlyArray 'false', '--stripInternal', 'false', + '--removeComments', + 'false', '--incremental', 'false', '--composite', diff --git a/packages/api-extractor/tests/analysis-messages.integration.test.ts b/packages/api-extractor/tests/analysis-messages.integration.test.ts index bebc35b..2e15825 100644 --- a/packages/api-extractor/tests/analysis-messages.integration.test.ts +++ b/packages/api-extractor/tests/analysis-messages.integration.test.ts @@ -87,6 +87,33 @@ Feature('Review notes for a package match the ones api-extractor derives') ), ) + 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.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( From 2c1306320ce469b899b52a59a0f7f13ebad739d1 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 13 Oct 2026 16:31:22 +0000 Subject: [PATCH 14/99] test(front-end): keep only refusal checks no consumer can observe (U11) Under schema-laws/refusal-properties-cover-unobservable-boundaries (systemfsoftware #761), each in-source refusal property from ruling #2 was judged against the public entry: - The digit-spelling refusal is observable. TypeScript 7.0.2 prints an unresolved import specifier raw, so a package's own text can put `error TS007: ...` on tsc's stdout, and FrontEnd.analyse reports whether that line became a diagnostic. The property is removed; the e2e refusal journey now asserts that only tsc's own TS2882 diagnostics come back. That journey fails at fd4ad93, which returned a forged code 7, and passes here. - The Position cap refusal is not observable (Digits admits at most nine digits before the cap, and nothing outside the schema encodes), and its predicate restated the refinement rather than an independent oracle. The property is removed; the cap stays. - The four Refuted seeds belonged to the generated laws #9 deleted; the seed store is removed. The plan moves to a superseding dated file with U11. --- .../tests/front-ends.integration.test.ts | 20 ++++++++-- ...> 2026-10-13-1629-feat-front-ends-plan.md} | 19 +++++++-- ...lback-inherits-declaration-emit-options.md | 2 +- .../schema-laws.test.ts.jsonl | 4 -- .../src/front-end/compiler-output.schema.ts | 39 ------------------- 5 files changed, 33 insertions(+), 51 deletions(-) rename docs/plans/{2026-10-12-2048-feat-front-ends-plan.md => 2026-10-13-1629-feat-front-ends-plan.md} (92%) delete mode 100644 packages/api-extractor/src/__property_seeds__/schema-laws.test.ts.jsonl diff --git a/apps/api-extractor-e2e/tests/front-ends.integration.test.ts b/apps/api-extractor-e2e/tests/front-ends.integration.test.ts index 3bdbe78..0259887 100644 --- a/apps/api-extractor-e2e/tests/front-ends.integration.test.ts +++ b/apps/api-extractor-e2e/tests/front-ends.integration.test.ts @@ -128,13 +128,25 @@ Feature('Both front-ends reduce a package to one model') ) scenario( - 'a package the compiler rejects is refused with the compiler diagnostic and no model', + '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 and assigns a string to a number')('source', () => packageWith('type-error', "export const ids = [1, 2].map(String)\nexport const count: number = 'one'\n")), - When('the engine analyses it')('refusal', (scope) => Effect.flip(FrontEnd.analyse(scope.source))), - Then('it is refused with TS2322')((scope, expect) => - expect(scope.refusal).toMatchObject({ _tag: 'CompilerRejected', diagnostics: [{ file: 'src/mod.ts', code: 2322 }] }) + Given('a package that needs the fallback and whose unresolved imports make tsc print lines spelling a zero-padded and a ten-digit code')( + 'forged', + () => + packageWith( + 'forged-codes', + "export const ids = [1, 2].map(String)\nimport 'x\\nerror TS007: spelled by a module specifier'\nimport 'y\\nerror TS1000000000: spelled by a module specifier'\n", + ), + ), + When('the engine analyses each')('refusals', (scope) => + Effect.all({ typeError: Effect.flip(FrontEnd.analyse(scope.source)), forged: Effect.flip(FrontEnd.analyse(scope.forged)) })), + Then('each is refused with only the diagnostics tsc reported: TS2322, and TS2882 for each import')((scope, expect) => + expect(scope.refusals).toMatchObject({ + typeError: { _tag: 'CompilerRejected', diagnostics: [{ file: 'src/mod.ts', code: 2322 }] }, + forged: { _tag: 'CompilerRejected', diagnostics: [{ file: 'src/mod.ts', code: 2882 }, { file: 'src/mod.ts', code: 2882 }] }, + }) ), ), ) diff --git a/docs/plans/2026-10-12-2048-feat-front-ends-plan.md b/docs/plans/2026-10-13-1629-feat-front-ends-plan.md similarity index 92% rename from docs/plans/2026-10-12-2048-feat-front-ends-plan.md rename to docs/plans/2026-10-13-1629-feat-front-ends-plan.md index 0e344e1..42cd362 100644 --- a/docs/plans/2026-10-12-2048-feat-front-ends-plan.md +++ b/docs/plans/2026-10-13-1629-feat-front-ends-plan.md @@ -1,12 +1,12 @@ --- title: API extractor front-ends - Plan type: feat -date: 2026-10-12 +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-12-1532-feat-front-ends-plan.md +supersedes: docs/plans/2026-10-12-2048-feat-front-ends-plan.md execution: code --- @@ -266,7 +266,7 @@ U1 → U2 (profile committed) → U3 (choice recorded) → U4 → U5 → U6 → - **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. + - #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. @@ -276,6 +276,19 @@ U1 → U2 (profile committed) → U3 (choice recorded) → U4 → U5 → U6 → - **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. + --- ## Verification Contract 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 index 38e3f7c..3b6e6c8 100644 --- a/docs/solutions/logic-errors/compiler-fallback-inherits-declaration-emit-options.md +++ b/docs/solutions/logic-errors/compiler-fallback-inherits-declaration-emit-options.md @@ -99,6 +99,6 @@ fallbackArgs = ['-p', tsconfig, ...emitPolicyAsFlags(FAST_PATH_OPTIONS), ...outp ## Related -- `docs/plans/2026-10-12-2048-feat-front-ends-plan.md` (R3; U10 disposition #6) +- `docs/plans/2026-10-13-1629-feat-front-ends-plan.md` (R3; U10 disposition #6) - `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/src/__property_seeds__/schema-laws.test.ts.jsonl b/packages/api-extractor/src/__property_seeds__/schema-laws.test.ts.jsonl deleted file mode 100644 index 7993117..0000000 --- a/packages/api-extractor/src/__property_seeds__/schema-laws.test.ts.jsonl +++ /dev/null @@ -1,4 +0,0 @@ -{"_tag":"Refuted","property":"∀x_SourcelessDiagnosticLine_=x","seed":1588784167,"attempt":69,"size":7,"path":[0,2,3,15],"failure":"ReturnedFalse"} -{"_tag":"Refuted","property":"∀x_FiledDiagnosticLineEnc_=x","seed":817721849,"attempt":0,"size":0,"path":[0,0,0,0],"failure":"ReturnedFalse"} -{"_tag":"Refuted","property":"∀x_FiledDiagnosticLineEnc_=x","seed":2434346067,"attempt":27,"size":3,"path":[4,0,0,0],"failure":"ReturnedFalse"} -{"_tag":"Refuted","property":"∀x_FiledDiagnosticLine_=x","seed":707307667,"attempt":26,"size":3,"path":[0,0,0,0,3,0,0,0],"failure":"ReturnedFalse"} diff --git a/packages/api-extractor/src/front-end/compiler-output.schema.ts b/packages/api-extractor/src/front-end/compiler-output.schema.ts index 6406d7c..8e357fc 100644 --- a/packages/api-extractor/src/front-end/compiler-output.schema.ts +++ b/packages/api-extractor/src/front-end/compiler-output.schema.ts @@ -1,4 +1,3 @@ -/// import { Array as Arr, Match, Option, Schema, SchemaGetter } from 'effect' import type { Diagnostic } from './declarations.schema.ts' @@ -180,41 +179,3 @@ export const parseCompilerOutput: (request: CompilerOutputRequest) => CompilerOu const parsed = Arr.getSomes(linesOf(request.stdout).map(parsedLineOf(request.packageRoot))) return { emittedFiles: Arr.getSomes(parsed.map(emittedFileIn)), diagnostics: Arr.getSomes(parsed.map(diagnosticIn)) } } - -const NINE_DIGIT_SEEDS: ReadonlyArray = [-1, 0, 999_999_999, 1_000_000_000, Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY, 0.5] - -const SPELLING_SEEDS: ReadonlyArray = ['', '0', '00', '007', '7', '999999999', '1000000000', '0999999999', '-1'] - -const nonNegativeSafeInteger = (value: number): boolean => Number.isSafeInteger(value) && value >= 0 - -const printableByTsc = (value: number): boolean => nonNegativeSafeInteger(value) && String(value).length <= 9 - -const spellsItsValue = (text: string): boolean => String(Number(text)) === text - -const canonicalSpelling = (text: string): boolean => spellsItsValue(text) && printableByTsc(Number(text)) - -const isSourcelessDiagnostic: (input: unknown) => input is SourcelessDiagnosticFields = Schema.is(SourcelessDiagnosticFields) - -const codeAccepted = (code: number): boolean => isSourcelessDiagnostic({ code, message: 'm' }) - -const codeTextDecodes = (text: string): boolean => Option.isSome(decodeSourcelessDiagnosticLine(`${ERROR_PREFIX}${text}${CODE_SEPARATOR}m`)) - -const paddedSpelling = (padding: number, value: number): string => `${'0'.repeat(Math.abs(padding) % 3)}${value}` - -if (import.meta.vitest !== void 0) { - // The test runner only exists under vitest; tsdown defines import.meta.vitest as undefined and drops this block. - const { it } = await import('@systemfsoftware/vitest') - - it.prop( - '∀n_SourcelessDiagnosticFieldsPositionRefusal_≡PrintableByTsc', - { of: [Schema.Finite], subject: codeAccepted }, - (subject, [value]) => Arr.every(Arr.append(NINE_DIGIT_SEEDS, value), (code) => subject(code) === printableByTsc(code)), - ) - - it.prop( - '∀s_SourcelessDiagnosticLineDigitsRefusal_≡CanonicalSpelling', - { of: [Schema.Int, Schema.Int], subject: codeTextDecodes }, - (subject, [padding, value]) => - Arr.every(Arr.append(SPELLING_SEEDS, paddedSpelling(padding, value)), (text) => subject(text) === canonicalSpelling(text)), - ) -} From fb70da84e74622c2340515fd84974f7b00216d5a Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 13 Oct 2026 17:37:49 +0000 Subject: [PATCH 15/99] docs(plan): rule every open front-end finding (U12) Supersedes the 1629 plan. U12 gives every finding of the fd4ad93, a2fe1eb, 2b2e595 and 2c13063 reviews, the delta passes, the anchor-50 notes and the pack rows a ruling: a named fix with a test, a rejection with its reason, or a follow-up with an owner and a trigger. KTD3 is amended: tsc stdout is package-influenced text, so the fallback reads diagnostics from the TypeScript 7 API on one derived config and lists emitted files from its scoped directory. --- ...> 2026-10-13-1737-feat-front-ends-plan.md} | 54 +++++++++++++++++-- ...lback-inherits-declaration-emit-options.md | 2 +- 2 files changed, 52 insertions(+), 4 deletions(-) rename docs/plans/{2026-10-13-1629-feat-front-ends-plan.md => 2026-10-13-1737-feat-front-ends-plan.md} (82%) diff --git a/docs/plans/2026-10-13-1629-feat-front-ends-plan.md b/docs/plans/2026-10-13-1737-feat-front-ends-plan.md similarity index 82% rename from docs/plans/2026-10-13-1629-feat-front-ends-plan.md rename to docs/plans/2026-10-13-1737-feat-front-ends-plan.md index 42cd362..f1f60a9 100644 --- a/docs/plans/2026-10-13-1629-feat-front-ends-plan.md +++ b/docs/plans/2026-10-13-1737-feat-front-ends-plan.md @@ -6,7 +6,7 @@ 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-12-2048-feat-front-ends-plan.md +supersedes: docs/plans/2026-10-13-1629-feat-front-ends-plan.md execution: code --- @@ -118,6 +118,7 @@ Product Contract preservation: Product Contract unchanged. - KTD1. **One emitter port, two service tags, adapters as parameterised 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` (the compiler's takes options); 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 into its scoped temp directory, `{ "extends": , "compilerOptions": }`, where the emit policy is one exported record (`noEmit: false`, `declaration`, `emitDeclarationOnly`, `declarationMap: false`, `sourceMap: false`, `stripInternal: 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). - 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. @@ -175,7 +176,7 @@ Radical alternative considered: skip declaration emit on the fast path and build ### Sequencing -U1 → U2 (profile committed) → U3 (choice recorded) → U4 → U5 → U6 → U7 → U8 → U9. U4's builder can start on TypeScript 7 output before U3 lands only once KTD2's parser is known, so U3 precedes it. +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. --- @@ -291,6 +292,53 @@ U1 → U2 (profile committed) → U3 (choice recorded) → U4 → U5 → U6 → --- + +### 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. `readPackage` refuses an export whose recorded source file is missing (`MissingSource`, naming the export and the file), so neither route models a sibling `.ts` in place of a deleted `.tsx`. + - D2: two entry points whose sources share a declaration path are declined by the fast path, and `tsc` refuses them (TS5056). + - 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 message's entry point and item. +- **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`. +- **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. + +--- + ## Verification Contract | Gate | Command or evidence | Proves | @@ -311,7 +359,7 @@ No mutation run, local or in CI (origin R23). ## Definition of Done -- U1-U10 verified; every Verification Contract gate passes locally and CI is green on the exact PR head. +- U1-U12 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. 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 index 3b6e6c8..8f2ae7c 100644 --- a/docs/solutions/logic-errors/compiler-fallback-inherits-declaration-emit-options.md +++ b/docs/solutions/logic-errors/compiler-fallback-inherits-declaration-emit-options.md @@ -99,6 +99,6 @@ fallbackArgs = ['-p', tsconfig, ...emitPolicyAsFlags(FAST_PATH_OPTIONS), ...outp ## Related -- `docs/plans/2026-10-13-1629-feat-front-ends-plan.md` (R3; U10 disposition #6) +- `docs/plans/2026-10-13-1737-feat-front-ends-plan.md` (R3; U10 disposition #6; U12 emit policy) - `docs/solutions/design-patterns/keyed-uniqueness-in-the-api-model-schema.md`: a law passes vacuously when its corpus lacks the triggering shape From 25d0ef9c2b7d22a0234ea565f79445483ba6abc3 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 13 Oct 2026 17:41:21 +0000 Subject: [PATCH 16/99] fix(front-end): synthetic diagnostics carry no TypeScript code (U12 C4) A diagnostic no compiler reported (a missing entry source, a missing entry declaration, a tsc exit with nothing on record) printed as TS0, a code TypeScript never defines. Diagnostic.code is now optional and, when present, a positive integer, and refusal messages print TS only for a real code. Routing scenario: the missing-entry refusal message names no code; it fails at 2c13063. --- .../api-extractor/src/front-end/declarations.schema.ts | 4 ++-- .../src/front-end/front-end-error.schema.ts | 4 +++- .../api-extractor/src/front-end/oxc-isolated-emitter.ts | 5 ++--- .../api-extractor/src/front-end/typescript-7-compiler.ts | 2 -- .../tests/front-end-routing.integration.test.ts | 9 ++++++--- 5 files changed, 13 insertions(+), 11 deletions(-) diff --git a/packages/api-extractor/src/front-end/declarations.schema.ts b/packages/api-extractor/src/front-end/declarations.schema.ts index 7572015..dc5950c 100644 --- a/packages/api-extractor/src/front-end/declarations.schema.ts +++ b/packages/api-extractor/src/front-end/declarations.schema.ts @@ -35,12 +35,12 @@ export const Declarations: Schema.Codec = Schema.Struct({ export type Diagnostic = { readonly file: string - readonly code: number + readonly code?: number readonly message: string } export const Diagnostic: Schema.Codec = Schema.Struct({ file: Schema.String, - code: Schema.Finite, + code: Schema.optionalKey(Schema.Int.check(Schema.isGreaterThan(0))), message: Schema.String, }) diff --git a/packages/api-extractor/src/front-end/front-end-error.schema.ts b/packages/api-extractor/src/front-end/front-end-error.schema.ts index a8c607e..6a1ed2f 100644 --- a/packages/api-extractor/src/front-end/front-end-error.schema.ts +++ b/packages/api-extractor/src/front-end/front-end-error.schema.ts @@ -5,8 +5,10 @@ type DiagnosticsFields = { readonly diagnostics: Schema.$Array (code === undefined ? '' : `TS${code} `) + const listed = (diagnostics: ReadonlyArray): string => - diagnostics.map(({ file, code, message }) => `${file === '' ? '' : `${file}: `}TS${code} ${message}`).join('\n') + diagnostics.map(({ file, code, message }) => `${file === '' ? '' : `${file}: `}${codeLabel(code)}${message}`).join('\n') const NoSourceEntryBase: Schema.Class, Cause.YieldableError> = Schema.TaggedError()('NoSourceEntry', { packageRoot: Schema.String }) diff --git a/packages/api-extractor/src/front-end/oxc-isolated-emitter.ts b/packages/api-extractor/src/front-end/oxc-isolated-emitter.ts index c5a7988..9369587 100644 --- a/packages/api-extractor/src/front-end/oxc-isolated-emitter.ts +++ b/packages/api-extractor/src/front-end/oxc-isolated-emitter.ts @@ -13,13 +13,13 @@ const DIAGNOSTIC_TEXT = /^TS(\d+): (.*)$/s const diagnosticOf = (file: string, text: string): Diagnostic => Option.match(Option.fromNullishOr(DIAGNOSTIC_TEXT.exec(text)), { - onNone: () => ({ file, code: 0, message: text }), + onNone: () => ({ file, message: text }), onSome: ([, code, message]) => ({ file, code: Number(code), message: message ?? '' }), }) const diagnosticOrder: Order.Order = Order.combineAll([ Order.mapInput(Order.String, (diagnostic: Diagnostic) => diagnostic.file), - Order.mapInput(Order.Number, (diagnostic: Diagnostic) => diagnostic.code), + Order.mapInput(Option.makeOrder(Order.Number), (diagnostic: Diagnostic) => Option.fromNullishOr(diagnostic.code)), Order.mapInput(Order.String, (diagnostic: Diagnostic) => diagnostic.message), ]) @@ -77,7 +77,6 @@ const refusedOrClosed = (found: ReadonlyArray, closed: Result. const missingEntryDiagnostic = (declarationFile: string): Diagnostic => ({ file: declarationFile, - code: 0, message: 'no source file inside the package could be read for this entry point', }) diff --git a/packages/api-extractor/src/front-end/typescript-7-compiler.ts b/packages/api-extractor/src/front-end/typescript-7-compiler.ts index 452419b..7afabd6 100644 --- a/packages/api-extractor/src/front-end/typescript-7-compiler.ts +++ b/packages/api-extractor/src/front-end/typescript-7-compiler.ts @@ -75,7 +75,6 @@ const runCompiler = (source: PackageSource, tsc: string) => const exitDiagnostic = (exitCode: number, stderr: string): Diagnostic => ({ file: '', - code: 0, message: stderr.trim() === '' ? `\`tsc\` exited with code ${exitCode}` : `\`tsc\` exited with code ${exitCode}: ${stderr.trim()}`, }) @@ -91,7 +90,6 @@ const entryPointsOf = (source: PackageSource): ReadonlyArray ({ file: declarationFile, - code: 0, message: 'the compiler emitted no declaration for this entry point', }) diff --git a/packages/api-extractor/tests/front-end-routing.integration.test.ts b/packages/api-extractor/tests/front-end-routing.integration.test.ts index 80715b2..a3111ca 100644 --- a/packages/api-extractor/tests/front-end-routing.integration.test.ts +++ b/packages/api-extractor/tests/front-end-routing.integration.test.ts @@ -102,8 +102,11 @@ Feature('A package is routed to the front-end its declarations allow') 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')((scope, expect) => - expect(scope.refusal).toMatchObject({ _tag: 'NotIsolated', diagnostics: [{ file: 'src/mod.d.ts', code: 0 }] }) + 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', + }) ), ), ) @@ -128,7 +131,7 @@ Feature('A package is routed to the front-end its declarations allow') 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', code: 0 }] }) + expect(scope.refusal).toMatchObject({ _tag: 'NotIsolated', diagnostics: [{ file: '../shared/mod.d.ts' }] }) ), ), ) From 5a862acf8a081418f5e9c921bb428da151384787 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 13 Oct 2026 17:52:37 +0000 Subject: [PATCH 17/99] fix(front-end): keep local alias names, optional tuple marks and setter text exact (U12) - C1 (P1): a local key now has a start and an end delimiter, so one key can never occur inside another and a text type keeps Options, Ids, Key instead of Opt_2ions, Id_2s, K_2ey. - B5: a named optional tuple member prints its question mark. - 2b2e595 setter note: the printer passed 'method' for every signature, so a set accessor in a printed type literal gained ': any'. - C5/A16: one property-key resolver; each caller states its private-name decision. C9: one ordered keyword list. Both behaviour-preserving. - A8, C8/A9: scenarios pin the interface and class item shapes and the declare global, declare module and external re-export refusals. Each fix's scenario fails at 2c13063 and passes here. --- .../src/front-end/build-model.ts | 12 +- .../src/front-end/canonical-type-text.ts | 58 ++--- .../src/front-end/type-expr-of.ts | 9 +- .../tests/front-end-model.integration.test.ts | 241 ++++++++++++++++++ 4 files changed, 271 insertions(+), 49 deletions(-) diff --git a/packages/api-extractor/src/front-end/build-model.ts b/packages/api-extractor/src/front-end/build-model.ts index 8803ac4..36a70b6 100644 --- a/packages/api-extractor/src/front-end/build-model.ts +++ b/packages/api-extractor/src/front-end/build-model.ts @@ -52,9 +52,15 @@ type LocalDeclaration = { type VariableEntry = { readonly constant: boolean; readonly node: VariableDeclarator } type EntryFile = { readonly exportPath: string; readonly declarationFile: string } -const localKey = (file: string, name: string): string => `${file}\u0000${name}` -const localKeyFile = (key: string): string => key.slice(0, Math.max(key.indexOf('\u0000'), 0)) -const localKeyName = (key: string): string => key.slice(key.indexOf('\u0000') + 1) +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), diff --git a/packages/api-extractor/src/front-end/canonical-type-text.ts b/packages/api-extractor/src/front-end/canonical-type-text.ts index 0c2330a..6a1307b 100644 --- a/packages/api-extractor/src/front-end/canonical-type-text.ts +++ b/packages/api-extractor/src/front-end/canonical-type-text.ts @@ -66,21 +66,7 @@ const withTypeParameters = (context: TypeContext, names: ReadonlyArray): const refuse = (file: string, construct: string): Effect.Effect => Effect.fail(new UnsupportedDeclaration({ file, construct })) -type KeywordNodeName = - | 'TSAnyKeyword' - | 'TSBigIntKeyword' - | 'TSBooleanKeyword' - | 'TSNeverKeyword' - | 'TSNullKeyword' - | 'TSNumberKeyword' - | 'TSObjectKeyword' - | 'TSStringKeyword' - | 'TSSymbolKeyword' - | 'TSUndefinedKeyword' - | 'TSUnknownKeyword' - | 'TSVoidKeyword' - -const KEYWORDS: HashMap.HashMap = HashMap.fromIterable([ +export const KEYWORD_ENTRIES = [ ['TSAnyKeyword', 'any'], ['TSBigIntKeyword', 'bigint'], ['TSBooleanKeyword', 'boolean'], @@ -93,26 +79,17 @@ const KEYWORDS: HashMap.HashMap = HashMap.fromIterable = [ - 'TSAnyKeyword', - 'TSBigIntKeyword', - 'TSBooleanKeyword', - 'TSNeverKeyword', - 'TSNullKeyword', - 'TSNumberKeyword', - 'TSObjectKeyword', - 'TSStringKeyword', - 'TSSymbolKeyword', - 'TSUndefinedKeyword', - 'TSUnknownKeyword', - 'TSVoidKeyword', -] +] as const + +type KeywordNodeName = (typeof KEYWORD_ENTRIES)[number][0] + +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: KeywordNodeName): KeywordName => Option.getOrThrow(keywordNameOf(nodeType)) +const keywordName = (nodeType: string): KeywordName => Option.getOrThrow(keywordNameOf(nodeType)) export const literalTextOf = (node: Expression | TSLiteral): Option.Option => Match.value(node).pipe( @@ -134,7 +111,7 @@ export const expressionPath = (expression: Expression): Option.Option Option.none()), ) -const propertyKeyName = (key: PropertyKey): Option.Option => +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))), @@ -305,13 +282,14 @@ const printSignatureLike = ( typeParameters: TSTypeParameterDeclaration | null, params: ReadonlyArray, returnType: TSTypeAnnotation | null, + kind: TSMethodSignatureKind, decorate: (parts: { readonly typeParameters: string; readonly parameters: string; readonly returnType: string }) => string, ): Effect.Effect => Effect.gen(function* () { const inner = withTypeParameters(context, typeParameterNames(typeParameters)) const printedTypeParameters = yield* printTypeParameters(inner, typeParameters) const printedParameters = yield* printParameters(inner, params) - const printedReturnType = yield* printMethodTail(inner, 'method', returnType) + const printedReturnType = yield* printMethodTail(inner, kind, returnType) return decorate({ typeParameters: printedTypeParameters, parameters: printedParameters, returnType: printedReturnType }) }) @@ -327,7 +305,7 @@ const printMethodSignature = (context: TypeContext, member: TSMethodSignature): 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, (parts) => + printSignatureLike(context, member.typeParameters, member.params, member.returnType, member.kind, (parts) => `${methodPrefix(member.kind)}${name}${optionalMark(member.optional)}${parts.typeParameters}(${parts.parameters})${parts.returnType}`), }) @@ -339,11 +317,11 @@ const methodPrefix = (kind: TSMethodSignatureKind): string => ) const printCallSignature = (context: TypeContext, member: TSCallSignatureDeclaration): Effect.Effect => - printSignatureLike(context, member.typeParameters, member.params, member.returnType, (parts) => + printSignatureLike(context, member.typeParameters, member.params, member.returnType, 'method', (parts) => `${parts.typeParameters}(${parts.parameters})${parts.returnType}`) const printConstructSignature = (context: TypeContext, member: TSConstructSignatureDeclaration): Effect.Effect => - printSignatureLike(context, member.typeParameters, member.params, member.returnType, (parts) => + printSignatureLike(context, member.typeParameters, member.params, member.returnType, 'method', (parts) => `new ${parts.typeParameters}(${parts.parameters})${parts.returnType}`) const printIndexSignature = (context: TypeContext, member: TSIndexSignature): Effect.Effect => @@ -380,7 +358,7 @@ const printSpecialTupleElement = ( ): Effect.Effect => Match.value(element).pipe( Match.when({ type: 'TSNamedTupleMember' }, (named) => - Effect.map(printTupleElement(context, named.elementType), (type) => `${named.label.name}: ${type}`)), + 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}`)), @@ -396,11 +374,11 @@ const printTuple = (context: TypeContext, node: TSTupleType): Effect.Effect printTupleElement(context, element)), (parts) => `[${parts.join(', ')}]`) const printFunctionType = (context: TypeContext, node: TSFunctionType): Effect.Effect => - printSignatureLike(context, node.typeParameters, node.params, node.returnType, (parts) => + printSignatureLike(context, node.typeParameters, node.params, node.returnType, 'method', (parts) => `${parts.typeParameters}(${parts.parameters}) =>${parts.returnType}`) const printConstructorType = (context: TypeContext, node: TSConstructorType): Effect.Effect => - printSignatureLike(context, node.typeParameters, node.params, node.returnType, (parts) => + printSignatureLike(context, node.typeParameters, node.params, node.returnType, 'method', (parts) => `${node.abstract ? 'abstract ' : ''}new ${parts.typeParameters}(${parts.parameters}) =>${parts.returnType}`) const printTypeReference = (context: TypeContext, node: TSTypeReference): Effect.Effect => diff --git a/packages/api-extractor/src/front-end/type-expr-of.ts b/packages/api-extractor/src/front-end/type-expr-of.ts index a1f245d..832b85e 100644 --- a/packages/api-extractor/src/front-end/type-expr-of.ts +++ b/packages/api-extractor/src/front-end/type-expr-of.ts @@ -34,6 +34,7 @@ import { keywordNameOf, literalTextOf, nameSegments, + propertyKeyName, typeParameterNames, TypePrinting, type TypeContext, @@ -59,12 +60,8 @@ const textExpr = (context: TypeContext, node: TSType): Effect.Effect => 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: 'MemberExpression', computed: false, property: { type: 'Identifier' } }, (member) => - Option.map(expressionPath(member.object), (base) => `[${[...base, member.property.name].join('.')}]`)), - Match.orElse(() => Option.none()), + Match.when({ type: 'PrivateIdentifier' }, () => Option.none()), + Match.orElse(() => propertyKeyName(key)), ) const typeArgumentsOf = ( diff --git a/packages/api-extractor/tests/front-end-model.integration.test.ts b/packages/api-extractor/tests/front-end-model.integration.test.ts index 0a50faa..034bc58 100644 --- a/packages/api-extractor/tests/front-end-model.integration.test.ts +++ b/packages/api-extractor/tests/front-end-model.integration.test.ts @@ -308,4 +308,245 @@ Feature('Building a package API model from its declaration text') })), ), ) + + 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 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( + '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) } }' } }, + })), + ), + ) }) From 55eb06ec2404b2713eb1af00ef3e87ca1aef66be Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 13 Oct 2026 17:52:37 +0000 Subject: [PATCH 18/99] fix(front-end): scope type parameters to their declaration in the resolver (U12) - S1: a generic wrap no longer hides the ae-forgotten-export of a real non-exported A in the same file; type parameters bind only within the declaration that introduces them. - B9: an oracle scenario pins the full message array, order and de-duplication included. - A18: the upstream comparison also compares the entry point. ExtractorMessage carries no item field, so item is not comparable. The fixture's temp dirs move to the system temp directory. --- .../src/front-end/resolve-messages.ts | 83 +++++++++++-- .../tests/__fixtures__/upstream-messages.ts | 20 ++- .../analysis-messages.integration.test.ts | 114 +++++++++++++++--- 3 files changed, 185 insertions(+), 32 deletions(-) diff --git a/packages/api-extractor/src/front-end/resolve-messages.ts b/packages/api-extractor/src/front-end/resolve-messages.ts index ef20bd9..b6e4cb0 100644 --- a/packages/api-extractor/src/front-end/resolve-messages.ts +++ b/packages/api-extractor/src/front-end/resolve-messages.ts @@ -8,6 +8,7 @@ import { type TSImportType, type TSInterfaceHeritage, type TSTypeName, + type TSTypeParameterDeclaration, type TSTypeQuery, type TSTypeReference, Visitor, @@ -62,25 +63,88 @@ type Reference = { readonly start: number; readonly name: string } type ReferenceScan = { readonly references: ReadonlyArray - readonly typeParameters: HashSet.HashSet } -const EMPTY_SCAN: ReferenceScan = { references: [], typeParameters: HashSet.empty() } +type TypeParameterScope = { + readonly start: number + readonly end: number + readonly names: ReadonlyArray +} + +type ScopeCarrier = { + readonly start: number + readonly end: number + readonly typeParameters?: TSTypeParameterDeclaration | null +} + +const EMPTY_SCAN: ReferenceScan = { references: [] } + +const withinScope = (scope: TypeParameterScope, at: number): boolean => at >= scope.start && at < scope.end + +const matchesName = (scope: TypeParameterScope, name: string): boolean => Arr.some(scope.names, (candidate) => candidate === name) + +const shadowed = (scopes: ReadonlyArray, name: string, at: number): boolean => + Arr.some(scopes, (scope) => withinScope(scope, at) && matchesName(scope, name)) + +const typeParameterNamesOf = (declaration: TSTypeParameterDeclaration | null): ReadonlyArray => + declaration === null ? [] : declaration.params.map((parameter) => parameter.name.name) + +const addReference = (references: Array, scopes: ReadonlyArray, name: string, at: number): void => { + if (!shadowed(scopes, name, at)) { + references.push({ start: at, name }) + } +} + +const openScope = (scopes: Array, node: ScopeCarrier): void => + void scopes.push({ start: node.start, end: node.end, names: typeParameterNamesOf(node.typeParameters ?? null) }) + +const closeScope = (scopes: Array): void => void scopes.pop() const scanOf = (program: Program): ReferenceScan => { const references: Array = [] - const typeParameters: Array = [] + const scopes: Array = [] const record = (node: NameNode | null, start: number): void => - Option.match(rootNameOf(node), { onNone: () => undefined, onSome: (name) => void references.push({ start, name }) }) + Option.match(rootNameOf(node), { onNone: () => undefined, onSome: (name) => addReference(references, scopes, name, start) }) + const enter = (node: ScopeCarrier): void => openScope(scopes, node) + const exit = (): void => closeScope(scopes) new Visitor({ TSTypeReference: (node: TSTypeReference) => record(node.typeName, node.start), TSTypeQuery: (node: TSTypeQuery) => record(node.exprName, node.start), TSInterfaceHeritage: (node: TSInterfaceHeritage) => record(node.expression, node.start), TSClassImplements: (node: TSClassImplements) => record(node.expression, node.start), - ClassDeclaration: (node: Class) => record(node.superClass, node.start), - TSTypeParameter: (node) => void typeParameters.push(node.name.name), + ClassDeclaration: (node: Class) => { + record(node.superClass, node.start) + 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, }).visit(program) - return { references, typeParameters: HashSet.fromIterable(typeParameters) } + return { references } } const scansOf = (graph: DeclarationGraph): HashMap.HashMap => @@ -107,10 +171,7 @@ const referencesWithin = (scan: ReferenceScan, scope: FileScope, declaration: De reference.start >= declaration.statementStart && reference.start < statementEndOf(scope, declaration.statementStart)) const referenceNames = (scan: ReferenceScan, scope: FileScope, declaration: Declaration): ReadonlyArray => - Arr.map( - Arr.filter(referencesWithin(scan, scope, declaration), (reference) => !HashSet.has(scan.typeParameters, reference.name)), - (reference) => reference.name, - ) + Arr.map(referencesWithin(scan, scope, declaration), (reference) => reference.name) type EntryPoint = { readonly exportPath: string diff --git a/packages/api-extractor/tests/__fixtures__/upstream-messages.ts b/packages/api-extractor/tests/__fixtures__/upstream-messages.ts index 9c9d863..ed8a080 100644 --- a/packages/api-extractor/tests/__fixtures__/upstream-messages.ts +++ b/packages/api-extractor/tests/__fixtures__/upstream-messages.ts @@ -27,17 +27,29 @@ const TSCONFIG = { }, } +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)]] : [] + FIVE_IDS.includes(message.messageId) ? [[message.messageId, firstQuoted(message.text), entryPointOf(message)]] : [] -const sortedPairs = (pairs: ReadonlyArray>): ReadonlyArray => +const sortedPairs = (pairs: ReadonlyArray>): ReadonlyArray => pairs - .map(([messageId, symbol]) => [messageId ?? '', symbol ?? ''] as const) + .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 => { @@ -71,7 +83,7 @@ const runExtractor = (root: string, entry: string): ReadonlyArray => { +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') diff --git a/packages/api-extractor/tests/analysis-messages.integration.test.ts b/packages/api-extractor/tests/analysis-messages.integration.test.ts index 2e15825..eb907c4 100644 --- a/packages/api-extractor/tests/analysis-messages.integration.test.ts +++ b/packages/api-extractor/tests/analysis-messages.integration.test.ts @@ -10,22 +10,33 @@ const emitters = FrontEnd.isolatedEmitterLayer.pipe(Layer.provideMerge(NodeServi const packageRoot = Effect.flatMap(Path.Path, (path) => Effect.map(path.fromFileUrl(new URL('..', import.meta.url)), (root) => path.resolve(root))) -type Pair = readonly [string, string] +type Row = readonly [string, string, string] -const asPairs = (messages: ReadonlyArray): ReadonlyArray => - messages.map((message) => [message.messageId, message.symbol] as const) +const rowKey = (row: Row): string => row.join('\u0000') -const pairKey = (pair: Pair): string => `${pair[0]}\u0000${pair[1]}` +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 sorted = (pairs: ReadonlyArray): ReadonlyArray => - pairs - .filter((pair, index) => pairs.findIndex((candidate) => pairKey(candidate) === pairKey(pair)) === index) - .sort((left, right) => (pairKey(left) < pairKey(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) => ({ messages, upstream: upstreamPairs(declarations) })) + Effect.map(FrontEnd.resolveMessages(declarations), (messages) => ({ declarations, messages, upstream: upstreamPairs(declarations) })) -const reviewed = (messages: ReadonlyArray): ReadonlyArray => sorted(asPairs(messages)) +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') @@ -60,7 +71,7 @@ Feature('Review notes for a package match the ones api-extractor derives') }))), When(SOME_NOTE)('both', (scope) => bothSides(scope.declarations)), Then('the forgotten export names the type at the function name and the external type draws no note')((scope, expect) => - expect({ pairs: reviewed(scope.both.messages), forgotten: forgottenAt(scope.both.messages) }).toEqual({ + 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' }], })), @@ -73,7 +84,7 @@ Feature('Review notes for a package match the ones api-extractor derives') 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.messages)).toEqual(sorted(scope.both.upstream))), + Then('both note the exported value as untagged')((scope, expect) => expect(reviewed(scope.both.declarations, scope.both.messages)).toEqual(sorted(scope.both.upstream))), ), ) @@ -83,7 +94,7 @@ Feature('Review notes for a package match the ones api-extractor derives') 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.messages)).toEqual(sorted(scope.both.upstream))), + Then('both note the value should carry an underscore')((scope, expect) => expect(reviewed(scope.both.declarations, scope.both.messages)).toEqual(sorted(scope.both.upstream))), ), ) @@ -108,7 +119,7 @@ Feature('Review notes for a package match the ones api-extractor derives') When(SOME_NOTE)('both', (scope) => bothSides(scope.declarations)), Then('both note the untagged values as untagged')((scope, expect) => expect({ - notes: reviewed(scope.both.messages), + 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 })), ), @@ -124,7 +135,7 @@ Feature('Review notes for a package match the ones api-extractor derives') 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.messages)).toEqual(sorted(scope.both.upstream))), + Then('both note the incompatible release tags')((scope, expect) => expect(reviewed(scope.both.declarations, scope.both.messages)).toEqual(sorted(scope.both.upstream))), ), ) @@ -138,7 +149,76 @@ Feature('Review notes for a package match the ones api-extractor derives') 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.messages)).toEqual(sorted(scope.both.upstream))), + 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 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), + })), ), ) @@ -151,7 +231,7 @@ Feature('Review notes for a package match the ones api-extractor derives') const isolated = yield* FrontEnd.IsolatedEmitter return yield* bothSides(yield* isolated.emit(scope.source)) })), - Then('every note matches')((scope, expect) => expect(reviewed(scope.both.messages)).toEqual(sorted(scope.both.upstream))), + Then('every note matches')((scope, expect) => expect(reviewed(scope.both.declarations, scope.both.messages)).toEqual(sorted(scope.both.upstream))), ), ) }) From fa1f6953230a3ba1ef8ccfeb59369b045e643977 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 13 Oct 2026 17:52:37 +0000 Subject: [PATCH 19/99] fix(front-end): read .cts entries and expand subpath pattern exports (U12) - A11: .cts source entries are read like .ts, .tsx and .mts. - A12: a pattern export ("./*") expands to one entry per matching source under Node's exports rules: the * matches any substring, an explicit subpath wins, and a longer key prefix wins between patterns. Only the target's directory is walked, inside the package root. Scenarios fail at 2c13063 and pass here. --- .../src/front-end/read-package.ts | 130 ++++++++++++++++-- .../tests/package-reader.integration.test.ts | 121 ++++++++++++++++ 2 files changed, 240 insertions(+), 11 deletions(-) create mode 100644 packages/api-extractor/tests/package-reader.integration.test.ts diff --git a/packages/api-extractor/src/front-end/read-package.ts b/packages/api-extractor/src/front-end/read-package.ts index 13bdafb..5816c55 100644 --- a/packages/api-extractor/src/front-end/read-package.ts +++ b/packages/api-extractor/src/front-end/read-package.ts @@ -1,6 +1,7 @@ -import { Array as Arr, Effect, FileSystem, Option, Order, Path, Schema } from 'effect' +import { Array as Arr, Effect, FileSystem, HashMap, HashSet, Match, Option, Order, Path, Schema } from 'effect' import type { PlatformError } from 'effect/PlatformError' import type { PackageSource, SourceEntryPoint } from './declarations.schema.ts' +import { relativeWithin } from './declaration-graph.ts' import { NoSourceEntry } from './front-end-error.schema.ts' import { type ExportTarget, PackageManifest } from './package-source.schema.ts' @@ -13,26 +14,133 @@ export type ReadPackageRequest = { const decodeManifest = Schema.decodeEffect(Schema.fromJsonString(PackageManifest)) -const TYPESCRIPT_SOURCE = /\.m?tsx?$/ +const TYPESCRIPT_SOURCE = /\.(?:ts|tsx|mts|cts)$/ -const isTypeScriptSource = (value: Schema.Json | undefined): value is string => typeof value === 'string' && TYPESCRIPT_SOURCE.test(value) +const isTypeScriptSource = (value: string): boolean => TYPESCRIPT_SOURCE.test(value) -const conditionTarget = (target: ExportTarget, condition: string): Option.Option => - typeof target === 'string' ? Option.none() : Option.liftPredicate(target[condition], isTypeScriptSource) +const isString = (value: unknown): value is string => typeof value === 'string' -const entryPointOf = ([exportPath, target]: readonly [string, ExportTarget], condition: string): Option.Option => - Option.map(conditionTarget(target, condition), (sourceFile) => ({ exportPath, sourceFile: sourceFile.replace(/^\.\//, '') })) +const stripRelativePrefix = (value: string): string => value.replace(/^\.\//, '') + +const conditionValue = (target: ExportTarget, condition: string): Option.Option => + typeof target === 'string' ? Option.none() : Option.flatMap(Option.fromNullishOr(target[condition]), (value) => Option.liftPredicate(value, isString)) + +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 => typeof exports === 'object' ? Object.entries(exports).filter(([exportPath]) => exportPath !== './package.json') : [] -const entryPointsOf = (manifest: PackageManifest, condition: string): ReadonlyArray => - Arr.sort(Arr.getSomes(exportMapOf(manifest.exports).map((entry) => entryPointOf(entry, condition))), byExportPath) +const isPatternKey = (exportPath: string): boolean => exportPath.includes('*') + +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 patternEntryOf = (key: string, prefix: string, suffix: string) => + (candidate: string): Option.Option => + Option.map(matchedSubstring(prefix, suffix, candidate), (substring) => ({ + entry: { exportPath: key.replace('*', substring), sourceFile: candidate }, + key, + })) + +const directoryOf = (relative: string): string => { + const at = relative.lastIndexOf('/') + return at === -1 ? '' : relative.slice(0, at) +} + +const joinRelative = (path: Path.Path, relative: string, name: string): string => path.join(relative, name).split(path.sep).join('/') + +const absent = (error: PlatformError): boolean => + Match.value(error.reason).pipe( + Match.tag('NotFound', 'BadResource', () => true), + Match.orElse(() => false), + ) + +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 walkDirectory = (root: string, directory: string): Effect.Effect, PlatformError, Platform> => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem + const path = yield* Path.Path + return yield* Option.match(withinRoot(path, root, path.resolve(root, directory)), { + onNone: () => Effect.succeed>([]), + onSome: (relative) => + fileSystem.readDirectory(path.join(root, relative), { recursive: true }).pipe( + Effect.catchIf(absent, () => Effect.succeed>([])), + Effect.map((names) => names.map((name) => joinRelative(path, relative, name))), + ), + }) + }) + +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 preferredPattern = (candidate: PatternEntry, known: PatternEntry): PatternEntry => + patternPrecedence(candidate.key, known.key) > 0 ? candidate : known + +const bestPerPath = (items: ReadonlyArray): ReadonlyArray => + Arr.fromIterable( + HashMap.values( + Arr.reduce(items, HashMap.empty(), (best, item) => + HashMap.set( + best, + item.entry.exportPath, + Option.match(HashMap.get(best, item.entry.exportPath), { onNone: () => item, onSome: (known) => preferredPattern(item, known) }), + ), + ), + ), + ) + +const expandPattern = (root: string, condition: string, key: string, target: ExportTarget): Effect.Effect, PlatformError, Platform> => + Option.match(conditionValue(target, condition), { + onNone: () => Effect.succeed>([]), + onSome: (targetText) => + Option.match(patternPartsOf(targetText), { + onNone: () => Effect.succeed>([]), + onSome: (parts) => + Effect.map(walkDirectory(root, directoryOf(stripRelativePrefix(parts.prefix))), (candidates) => + Arr.getSomes(candidates.map(patternEntryOf(key, stripRelativePrefix(parts.prefix), parts.suffix))), + ), + }), + }) + +const entryPointsOf = (root: string, manifest: PackageManifest, condition: string): Effect.Effect, PlatformError, Platform> => + Effect.gen(function* () { + const entries = exportMapOf(manifest.exports) + const explicit = entries.filter(([exportPath]) => !isPatternKey(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 = bestPerPath(Arr.flatten(expanded).filter((item) => !HashSet.has(explicitPaths, item.entry.exportPath))) + 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 @@ -52,7 +160,7 @@ const tsconfigOf = (root: string): Effect.Effect) => +const sourceOf = (root: string, manifest: PackageManifest, entryPoints: Arr.NonEmptyReadonlyArray): Effect.Effect => Effect.gen(function* () { const source: PackageSource = { name: manifest.name, @@ -71,7 +179,7 @@ export const readPackage = (request: ReadPackageRequest): Effect.Effect Effect.fail(new NoSourceEntry({ packageRoot: root })), onNonEmpty: (nonEmpty) => sourceOf(root, manifest, nonEmpty), 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..088e702 --- /dev/null +++ b/packages/api-extractor/tests/package-reader.integration.test.ts @@ -0,0 +1,121 @@ +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 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 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 nestedPatternPackage = packageOf( + { './*': { [condition]: './src/*.ts' }, './x/*': { [condition]: './lib/*.ts' } }, + { 'src/x/y.ts': '', 'lib/y.ts': '' }, +) + +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 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( + '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 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' }]) + ), + ), + ) + }) From 3e8195423aba5eea37709f3a59a0c82616938c59 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 13 Oct 2026 17:52:37 +0000 Subject: [PATCH 20/99] fix(front-end): decline what the fast path cannot prove (U12) - A10: an import whose specifier names no declaration file is reported, not dropped: the fast path declines naming the import, and the compiler route refuses it with UnsupportedDeclaration. - D1: an entry declaration loads only from its recorded source, so a deleted mod.tsx is not modelled from a sibling mod.ts. - D2: two entry points sharing a declaration path are declined. - S3: the fast path compares real paths, so a symlink out of the package is not read. - C7/A17: the entry mapping and the missing-entry list live once in the declaration graph. Scenarios fail at 2c13063 and pass here. --- .../src/front-end/declaration-graph.ts | 93 ++++++++++++++----- .../src/front-end/oxc-isolated-emitter.ts | 86 ++++++++++++----- .../src/front-end/typescript-7-compiler.ts | 47 +++++----- .../front-end-routing.integration.test.ts | 91 ++++++++++++++++++ 4 files changed, 247 insertions(+), 70 deletions(-) diff --git a/packages/api-extractor/src/front-end/declaration-graph.ts b/packages/api-extractor/src/front-end/declaration-graph.ts index 1bd0f63..b49e604 100644 --- a/packages/api-extractor/src/front-end/declaration-graph.ts +++ b/packages/api-extractor/src/front-end/declaration-graph.ts @@ -21,7 +21,7 @@ import { type VariableDeclarator, Visitor, } from 'oxc-parser' -import type { DeclarationFile, Declarations } from './declarations.schema.ts' +import type { DeclarationFile, Declarations, SourceEntryPoint } from './declarations.schema.ts' import { UnsupportedDeclaration } from './front-end-error.schema.ts' const RELATIVE_SPECIFIER = /^\.\.?(\/|$)/ @@ -80,6 +80,20 @@ export const declarationCandidates: { export const declarationPathOf = (sourceFile: string): string => 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 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`] @@ -139,43 +153,73 @@ const firstLoaded = ( onNonEmpty: ([candidate, ...rest]) => loadCandidate(candidate, rest, loaded, load), }) -const followed = (file: string, program: Program): ReadonlyArray> => - specifiersOf(program).filter(isRelativeSpecifier).map((specifier) => declarationCandidates(file, specifier)) +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: (candidates: ReadonlyArray) => void - readonly get: (index: number) => Option.Option> + readonly push: (task: ClosureTask) => void + readonly get: (index: number) => Option.Option } -const workQueue = (initial: ReadonlyArray>): WorkQueue => { - const pending: Array> = [...initial] +const workQueue = (initial: ReadonlyArray): WorkQueue => { + const pending: Array = [...initial] return { - push: (candidates) => void pending.push(candidates), + push: (task) => void pending.push(task), get: (index) => Arr.get(pending, index), } } -const visitFound = (file: string, text: string, queue: WorkQueue, at: number, loaded: Loaded, load: Loader) => - HashMap.has(loaded, file) - ? visit(queue, at, loaded, load) +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(followed(file, program), (candidates) => queue.push(candidates)) - return visit(queue, at, HashMap.set(loaded, file, text), load) + 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, - loaded: Loaded, + state: ClosureState, load: Loader, -): Effect.Effect => +): Effect.Effect => Option.match(queue.get(at), { - onNone: () => Effect.succeed(loaded), - onSome: (candidates) => - Effect.flatMap(firstLoaded(candidates, loaded, load), (found) => + onNone: () => Effect.succeed(state), + onSome: (task) => + Effect.flatMap(firstLoaded(task.candidates, state.loaded, load), (found) => Option.match(found, { - onNone: () => visit(queue, at + 1, loaded, load), - onSome: ([file, text]) => visitFound(file, text, queue, at + 1, loaded, load), + 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), })), }) @@ -186,10 +230,13 @@ export type ClosureRequest = { readonly load: Loader } -export const closeDeclarations = (request: ClosureRequest): Effect.Effect, E | UnsupportedDeclaration, R> => +export const closeDeclarations = (request: ClosureRequest): Effect.Effect => Effect.map( - Effect.suspend(() => visit(workQueue(request.entryDeclarations.map(Arr.of)), 0, HashMap.empty(), request.load)), - (loaded) => Arr.sort(Arr.map(HashMap.toEntries(loaded), ([path, text]) => ({ path, text })), byPath), + 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 = diff --git a/packages/api-extractor/src/front-end/oxc-isolated-emitter.ts b/packages/api-extractor/src/front-end/oxc-isolated-emitter.ts index 9369587..c13971f 100644 --- a/packages/api-extractor/src/front-end/oxc-isolated-emitter.ts +++ b/packages/api-extractor/src/front-end/oxc-isolated-emitter.ts @@ -2,10 +2,10 @@ import { Array as Arr, Effect, FileSystem, HashMap, HashSet, Layer, Match, Optio import type { PlatformError } from 'effect/PlatformError' import { isolatedDeclarationSync } from 'oxc-transform' import oxcTransformManifest from 'oxc-transform/package.json' with { type: 'json' } -import { closeDeclarations, declarationPathOf, relativeWithin, sourceCandidatesOf } from './declaration-graph.ts' +import { closeDeclarations, declarationPathOf, entryPointDeclarations, missingEntryDeclarations, relativeWithin, sourceCandidatesOf, type ClosureResult, type EntryDeclaration, 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 } from './front-end-error.schema.ts' +import { NotIsolated, type UnsupportedDeclaration } from './front-end-error.schema.ts' export const OXC_TRANSFORM_PRODUCER: string = `oxc-transform@${oxcTransformManifest.version}` @@ -31,20 +31,26 @@ const isAbsent = (error: PlatformError): boolean => type Emission = { readonly source: PackageSource + readonly realRoot: string readonly diagnostics: Ref.Ref> } -const readSource = (packageRoot: string, sourceFile: string) => +const readSource = (emission: Emission, sourceFile: string) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem const path = yield* Path.Path - return yield* Option.match(relativeWithin(path, packageRoot, path.resolve(packageRoot, sourceFile)), { + const real = yield* Effect.option(fileSystem.realPath(path.resolve(emission.source.packageRoot, sourceFile))) + return yield* Option.match(real, { onNone: () => Effect.succeedNone, - onSome: (inside) => - fileSystem.readFileString(path.join(packageRoot, inside)).pipe( - Effect.asSome, - Effect.catchIf(isAbsent, () => Effect.succeedNone), - ), + onSome: (realFile) => + Option.match(relativeWithin(path, emission.realRoot, realFile), { + onNone: () => Effect.succeedNone, + onSome: () => + fileSystem.readFileString(realFile).pipe( + Effect.asSome, + Effect.catchIf(isAbsent, () => Effect.succeedNone), + ), + }), }) }) @@ -62,16 +68,32 @@ const firstSource = ( Arr.match(candidates, { onEmpty: () => Effect.succeedNone, onNonEmpty: ([sourceFile, ...rest]) => - Effect.flatMap(readSource(emission.source.packageRoot, sourceFile), (text) => + Effect.flatMap(readSource(emission, sourceFile), (text) => Option.match(text, { onNone: () => firstSource(emission, rest), onSome: (value) => Effect.asSome(declare(emission, sourceFile, value)), })), }) -const refusedOrClosed = (found: ReadonlyArray, closed: Result.Result): Effect.Effect => +const unresolvedImportDiagnostic = (unresolved: UnresolvedImport): Diagnostic => ({ + 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: Effect.succeed, onFailure: Effect.fail }), + 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 })), }) @@ -80,25 +102,45 @@ const missingEntryDiagnostic = (declarationFile: string): Diagnostic => ({ message: 'no source file inside the package could be read for this entry point', }) -const missingEntries = (files: ReadonlyArray, entryDeclarations: ReadonlyArray): ReadonlyArray => { - const emitted = HashSet.fromIterable(files.map((file) => file.path)) - return entryDeclarations.filter((declarationFile) => !HashSet.has(emitted, declarationFile)).map(missingEntryDiagnostic) -} - const everyEntryEmitted = (files: ReadonlyArray, entryDeclarations: ReadonlyArray): Effect.Effect, NotIsolated> => - Arr.match(missingEntries(files, entryDeclarations), { + Arr.match(missingEntryDeclarations(HashSet.fromIterable(files.map((file) => file.path)), entryDeclarations), { onEmpty: () => Effect.succeed(files), - onNonEmpty: (diagnostics) => Effect.fail(new NotIsolated({ diagnostics })), + onNonEmpty: (missing) => Effect.fail(new NotIsolated({ diagnostics: missing.map(missingEntryDiagnostic) })), }) +const sharedDeclarationDiagnostic = (declarationFile: string, exportPaths: ReadonlyArray): Diagnostic => ({ + file: declarationFile, + message: `two entry points share one declaration file: ${exportPaths.map((exportPath) => `\`${exportPath}\``).join(' and ')}`, +}) + +const sharedDeclarationOwners = (entryPoints: ReadonlyArray, declarationFile: string): ReadonlyArray => + Arr.sort(Arr.map(Arr.filter(entryPoints, (entry) => entry.declarationFile === declarationFile), (entry) => entry.exportPath), Order.String) + +const sharedDeclarationDiagnostics = (entryPoints: ReadonlyArray): ReadonlyArray => + Arr.dedupe(entryPoints.map((entry) => entry.declarationFile)).flatMap((declarationFile) => + Option.match(Option.liftPredicate(sharedDeclarationOwners(entryPoints, declarationFile), (exportPaths) => exportPaths.length > 1), { + onNone: () => [], + onSome: (exportPaths) => [sharedDeclarationDiagnostic(declarationFile, exportPaths)], + }), + ) + const recordedSourceFirst = (recorded: HashMap.HashMap) => (declarationFile: string): ReadonlyArray => - Arr.dedupe([...Option.toArray(HashMap.get(recorded, declarationFile)), ...sourceCandidatesOf(declarationFile)]) + Option.match(HashMap.get(recorded, declarationFile), { + onNone: () => sourceCandidatesOf(declarationFile), + onSome: (sourceFile) => [sourceFile], + }) const emit = (source: PackageSource) => Effect.gen(function* () { - const emission: Emission = { source, diagnostics: yield* Ref.make>([]) } - const entryPoints = source.entryPoints.map((entry) => ({ exportPath: entry.exportPath, declarationFile: declarationPathOf(entry.sourceFile) })) + 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(sharedDeclarationDiagnostics(entryPoints), { + 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, diff --git a/packages/api-extractor/src/front-end/typescript-7-compiler.ts b/packages/api-extractor/src/front-end/typescript-7-compiler.ts index 7afabd6..d891803 100644 --- a/packages/api-extractor/src/front-end/typescript-7-compiler.ts +++ b/packages/api-extractor/src/front-end/typescript-7-compiler.ts @@ -1,12 +1,12 @@ -import { Array as Arr, Effect, FileSystem, HashMap, Layer, Option, Path, Stream } from 'effect' +import { Array as Arr, Effect, FileSystem, HashMap, HashSet, Layer, Option, Path, Stream } from 'effect' import type { PlatformError } from 'effect/PlatformError' import { ChildProcess, ChildProcessSpawner } from 'effect/process' import typescriptManifest from 'typescript/package.json' with { type: 'json' } import { type CompilerOutput, parseCompilerOutput } from './compiler-output.schema.ts' -import { closeDeclarations, declarationPathOf, relativeWithin } from './declaration-graph.ts' +import { closeDeclarations, entryPointDeclarations, missingEntryDeclarations, relativeWithin, type EntryDeclaration, type UnresolvedImport } from './declaration-graph.ts' import { CompilerEmitter, type CompilerEmitterId, type DeclarationEmitter } from './declaration-emitter.service.ts' import type { Declarations, Diagnostic, PackageSource } from './declarations.schema.ts' -import { CompilerRejected } from './front-end-error.schema.ts' +import { CompilerRejected, UnsupportedDeclaration } from './front-end-error.schema.ts' export const TYPESCRIPT_PRODUCER: string = `typescript@${typescriptManifest.version}` @@ -14,8 +14,6 @@ const TYPESCRIPT_MANIFEST_URL: string = import.meta.resolve('typescript/package. const TSC_SCRIPT: string = typescriptManifest.bin.tsc -type DeclarationEntryPoint = { readonly exportPath: string; readonly declarationFile: string } - type EmittedDeclaration = { readonly declared: string; readonly emittedFile: string } type CompilerRun = { @@ -85,36 +83,35 @@ const rejectionOf = (run: CompilerRun): Option.Option> const packageDeclarationOf = (path: Path.Path, declaredRoot: string, emittedFile: string): Option.Option => Option.map(relativeWithin(path, declaredRoot, emittedFile), (declared) => ({ declared, emittedFile })) -const entryPointsOf = (source: PackageSource): ReadonlyArray => - source.entryPoints.map((entry) => ({ exportPath: entry.exportPath, declarationFile: declarationPathOf(entry.sourceFile) })) - const missingEntryDiagnostic = (declarationFile: string): Diagnostic => ({ file: declarationFile, message: 'the compiler emitted no declaration for this entry point', }) -const missingEntries = ( - emitted: HashMap.HashMap, - entryPoints: ReadonlyArray, -): ReadonlyArray => - entryPoints.filter((entry) => !HashMap.has(emitted, entry.declarationFile)).map((entry) => missingEntryDiagnostic(entry.declarationFile)) +const unsupportedImport = (unresolved: UnresolvedImport): UnsupportedDeclaration => + new UnsupportedDeclaration({ file: unresolved.file, construct: `the import \`${unresolved.specifier}\` names no declaration file` }) const declarationsOf = ( source: PackageSource, - entryPoints: ReadonlyArray, + entryPoints: ReadonlyArray, emitted: HashMap.HashMap, -) => - Effect.map( +): Effect.Effect => + Effect.flatMap( closeDeclarations({ entryDeclarations: entryPoints.map((entry) => entry.declarationFile), load: (declarationFile) => Effect.succeed(HashMap.get(emitted, declarationFile)), }), - (files): Declarations => ({ - producer: TYPESCRIPT_PRODUCER, - package: { name: source.name, version: source.version }, - entryPoints, - files, - }), + (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 declarationsFrom = (source: PackageSource, run: CompilerRun) => @@ -128,10 +125,10 @@ const declarationsFrom = (source: PackageSource, run: CompilerRun) => Effect.map(fileSystem.readFileString(declaration.emittedFile), (text) => [declaration.declared, text] as const), ), ) - const entryPoints = entryPointsOf(source) - return yield* Arr.match(missingEntries(emitted, entryPoints), { + const entryPoints = entryPointDeclarations(source.entryPoints) + return yield* Arr.match(missingEntryDeclarations(HashSet.fromIterable(HashMap.keys(emitted)), entryPoints.map((entry) => entry.declarationFile)), { onEmpty: () => declarationsOf(source, entryPoints, emitted), - onNonEmpty: (diagnostics) => Effect.fail(new CompilerRejected({ diagnostics })), + onNonEmpty: (missing) => Effect.fail(new CompilerRejected({ diagnostics: missing.map(missingEntryDiagnostic) })), }) }) diff --git a/packages/api-extractor/tests/front-end-routing.integration.test.ts b/packages/api-extractor/tests/front-end-routing.integration.test.ts index a3111ca..d6b7c74 100644 --- a/packages/api-extractor/tests/front-end-routing.integration.test.ts +++ b/packages/api-extractor/tests/front-end-routing.integration.test.ts @@ -50,6 +50,38 @@ const entryOutsidePackage = Effect.gen(function* () { 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') @@ -135,4 +167,63 @@ Feature('A package is routed to the front-end its declarations allow') ), ), ) + + 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' }], + }) + ), + ), + ) }) From 7520eae006a3a0db5e39016dcccd48b124c55eac Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 13 Oct 2026 18:23:02 +0000 Subject: [PATCH 21/99] fix(front-end): read compiler diagnostics from the TypeScript 7 API, not tsc stdout (U12 C3) tsc's stdout is package-influenced text: a module specifier holding a newline prints a well-formed 'error TS7:' or 'file(1,1): error TS8:' line, which parseCompilerOutput decoded as a real diagnostic. No line grammar can tell them apart, so the adapter reads no stdout: - One emit policy (compilerEmitPolicy), written with the package tsconfig as 'extends' into a derived config in the scoped directory; tsc runs as 'tsc -p '. The derived config carries the package's own typeRoots (from the API's parseConfigFile), else the default node_modules/@types chain, which TypeScript would otherwise resolve from the temp directory. - Emitted declarations are the .d.ts files under the scoped directory. - On a non-zero exit, diagnostics come from typescript/unstable/async on the same config, errors only, in tsc's reporting order, de-duplicated. A failed run the API has no error for (TS5033 on a long file name) is refused with one code-less diagnostic carrying tsc's text verbatim. - Before tsc runs: a missing or escaping entry source (D1) and two entries sharing a declaration path (D2, which tsc 7.0.2 accepts and silently collapses) are refused. - compiler-output.schema.ts is deleted, so the MessageText lookahead and the UNPARSED_FILED_LINE sentinel pack rows go with it. - FrontEnd exports TYPESCRIPT_PRODUCER (C10), compilerEmitPolicy and derivedTsconfig. The profile harness writes the same derived config (B2/A6) and fails on a failed git (A15); the profile is not re-run. The e2e refusal journey gains canonical sourceless and filed forged lines, a package with no tsconfig, an entry excluded from include, a 251-character file name, an escaping entry and a shared declaration path through CompilerEmitter (C6, A13, B10, D2). The equality journey gains a package with declared typeRoots. At 3e81954 the refusal journey returned codes 1234 and 4321 for the forged lines. --- .../tests/front-ends.integration.test.ts | 188 ++++++++++- apps/api-extractor-profile/profile.ts | 26 +- packages/api-extractor/src/FrontEnd.ts | 10 +- .../src/front-end/compiler-output.schema.ts | 181 ----------- .../src/front-end/typescript-7-compiler.ts | 294 +++++++++++++++--- 5 files changed, 447 insertions(+), 252 deletions(-) delete mode 100644 packages/api-extractor/src/front-end/compiler-output.schema.ts diff --git a/apps/api-extractor-e2e/tests/front-ends.integration.test.ts b/apps/api-extractor-e2e/tests/front-ends.integration.test.ts index 0259887..947d9de 100644 --- a/apps/api-extractor-e2e/tests/front-ends.integration.test.ts +++ b/apps/api-extractor-e2e/tests/front-ends.integration.test.ts @@ -1,7 +1,7 @@ import { NodeServices } from '@effect/platform-node' import { ApiModel, FrontEnd } from '@systemfsoftware/api-extractor' import { Gherkin, Given, it, makeFeature, Then, When } from '@systemfsoftware/effect-gherkin-spec' -import { Effect, FileSystem, Layer, Path } from 'effect' +import { Effect, FileSystem, Layer, Option, Path } from 'effect' const Feature = makeFeature({ it }) @@ -12,16 +12,39 @@ const STANDALONE_TSCONFIG = { 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: { '.': { [FrontEnd.FAMILY_SOURCE_CONDITION]: './src/mod.ts' } } }), - ) + 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 } }), @@ -40,6 +63,85 @@ const declarationDirPackage = packageWith('declaration-dir', 'export const place 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 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, +) + +type Refused = { readonly message: string; readonly diagnostics?: ReadonlyArray } + +const refusalOf = (error: Refused) => { + const diagnostics = error.diagnostics ?? [] + return { + error, + codes: diagnostics.map((diagnostic) => diagnostic.code), + lines: diagnostics.flatMap((diagnostic) => diagnostic.message.split('\n').filter((line) => /error TS\d+/.test(line))), + } +} + +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)))) @@ -72,18 +174,20 @@ Feature('Both front-ends reduce a package to one model') .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 or declarationDir', + 'the fast path and the compiler fallback give the same model and messages, whatever the package tsconfig says about @internal, comments, declarationDir or type roots', 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), 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), wroteIntoPackage: writtenInto(scope.declarationDir, 'types'), }) ), @@ -95,6 +199,7 @@ Feature('Both front-ends reduce a package to one model') removeComments: scope.analyses.removeComments.compiled, taggedNotes: scope.analyses.removeComments.isolated.messages, declarationDir: scope.analyses.declarationDir.compiled, + typeRoots: scope.analyses.typeRoots.compiled, wroteIntoPackage: scope.analyses.wroteIntoPackage, }).toEqual({ ownPackage: scope.analyses.ownPackage.isolated, @@ -103,6 +208,7 @@ Feature('Both front-ends reduce a package to one model') removeComments: scope.analyses.removeComments.isolated, taggedNotes: [], declarationDir: scope.analyses.declarationDir.isolated, + typeRoots: scope.analyses.typeRoots.isolated, wroteIntoPackage: false, }) ), @@ -132,20 +238,70 @@ Feature('Both front-ends reduce a package to one model') Gherkin.Do.pipe( Given('a package that needs the fallback and assigns a string to a number')('source', () => packageWith('type-error', "export const ids = [1, 2].map(String)\nexport const count: number = 'one'\n")), - Given('a package that needs the fallback and whose unresolved imports make tsc print lines spelling a zero-padded and a ten-digit code')( + 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', - () => - packageWith( - 'forged-codes', - "export const ids = [1, 2].map(String)\nimport 'x\\nerror TS007: spelled by a module specifier'\nimport 'y\\nerror TS1000000000: spelled by a module specifier'\n", - ), + () => 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), When('the engine analyses each')('refusals', (scope) => - Effect.all({ typeError: Effect.flip(FrontEnd.analyse(scope.source)), forged: Effect.flip(FrontEnd.analyse(scope.forged)) })), - Then('each is refused with only the diagnostics tsc reported: TS2322, and TS2882 for each import')((scope, expect) => - expect(scope.refusals).toMatchObject({ + 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), + })), + Then("each is refused with the compiler's own diagnostics, the spelled lines stay inside their message, and the entry outside the package and the shared declaration 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, + codes: { + excludedEntry: scope.refusals.excludedEntry.codes, + longName: scope.refusals.longName.codes, + escaping: scope.refusals.escaping.codes, + shared: scope.refusals.shared.codes, + }, + }).toMatchObject({ typeError: { _tag: 'CompilerRejected', diagnostics: [{ file: 'src/mod.ts', code: 2322 }] }, - forged: { _tag: 'CompilerRejected', diagnostics: [{ file: 'src/mod.ts', code: 2882 }, { file: 'src/mod.ts', code: 2882 }] }, + 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: [{ file: 'src/mod.d.ts', message: 'the compiler emitted no declaration for this entry point' }] }, + longName: { _tag: 'CompilerRejected', diagnostics: [{ file: '', message: expect.stringContaining('error TS5033') }] }, + escaping: { _tag: 'CompilerRejected', diagnostics: [{ file: '../shared/mod.ts', message: expect.stringContaining('resolves outside the package') }] }, + shared: { + _tag: 'CompilerRejected', + diagnostics: [{ file: 'src/mod.d.ts', message: expect.stringContaining('the entry points . and ./mod share the declaration file src/mod.d.ts') }], + }, + codes: { excludedEntry: [undefined], longName: [undefined], escaping: [undefined], shared: [undefined] }, }) ), ), diff --git a/apps/api-extractor-profile/profile.ts b/apps/api-extractor-profile/profile.ts index bbf58ff..2e9d887 100644 --- a/apps/api-extractor-profile/profile.ts +++ b/apps/api-extractor-profile/profile.ts @@ -24,10 +24,16 @@ const Manifest = Schema.Struct({ }) type Manifest = typeof Manifest.Type -type ProfileError = PlatformError | Schema.SchemaError | UsageError +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 @@ -153,10 +159,16 @@ interface CompileRun { const compile = (tsc: string, subject: Subject, extra: ReadonlyArray) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem + const path = yield* Path.Path const spawner = yield* ChildProcessSpawner.ChildProcessSpawner const outDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: 'front-end-profile-' }) const tsconfig = yield* tsconfigOf(subject.root) - const args = [tsc, '-p', tsconfig, '--noEmit', 'false', '--declaration', '--emitDeclarationOnly', '--declarationMap', 'false', '--sourceMap', 'false', '--incremental', 'false', '--composite', 'false', '--tsBuildInfoFile', 'null', '--rootDir', subject.workspaceRoot, '--outDir', outDir, '--pretty', 'false', ...extra] + const derived = path.join(outDir, 'tsconfig.json') + yield* fileSystem.writeFileString( + derived, + yield* FrontEnd.derivedTsconfig({ 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* Stream.decodeText(handle.stdout).pipe(Stream.mkString) const exitCode = yield* handle.exitCode @@ -325,7 +337,15 @@ 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 })) - return (yield* Stream.decodeText(handle.stdout).pipe(Stream.mkString)).split('\n').filter((line) => line !== '') + 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) => diff --git a/packages/api-extractor/src/FrontEnd.ts b/packages/api-extractor/src/FrontEnd.ts index 742e543..3840511 100644 --- a/packages/api-extractor/src/FrontEnd.ts +++ b/packages/api-extractor/src/FrontEnd.ts @@ -14,4 +14,12 @@ export { layer as isolatedEmitterLayer, OXC_TRANSFORM_PRODUCER } from './front-e 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 { layer as compilerEmitterLayer } from './front-end/typescript-7-compiler.ts' +export { + compilerEmitPolicy, + type CompilerEmitPolicy, + type CompilerEmitPolicyRequest, + derivedTsconfig, + type DerivedTsconfigRequest, + layer as compilerEmitterLayer, + TYPESCRIPT_PRODUCER, +} from './front-end/typescript-7-compiler.ts' diff --git a/packages/api-extractor/src/front-end/compiler-output.schema.ts b/packages/api-extractor/src/front-end/compiler-output.schema.ts deleted file mode 100644 index 8e357fc..0000000 --- a/packages/api-extractor/src/front-end/compiler-output.schema.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { Array as Arr, Match, Option, Schema, SchemaGetter } from 'effect' -import type { Diagnostic } from './declarations.schema.ts' - -const EMITTED_PREFIX = 'TSFILE: ' -const ERROR_PREFIX = 'error TS' -const CODE_SEPARATOR = ': ' -const TAIL_HEAD = '): error TS' -const EMITTED_PREFIX_LENGTH = EMITTED_PREFIX.length -const POSITION_DIGITS = '(?:0|[1-9][0-9]{0,8})' -const POSITION_LIMIT = 1_000_000_000 -const FILED_LINE = new RegExp(`^([\\s\\S]*)\\((${POSITION_DIGITS}),(${POSITION_DIGITS})\\): error TS(${POSITION_DIGITS}): ([\\s\\S]*)$`) - -type EmittedFileFields = { readonly file: string } - -type SourcelessDiagnosticFields = { readonly code: number; readonly message: string } - -type FiledDiagnosticFields = { - readonly file: string - readonly line: number - readonly column: number - readonly code: number - readonly message: string -} - -const Digits: Schema.String = Schema.String.check(Schema.isPattern(new RegExp(`^${POSITION_DIGITS}$`))) - -const FileText: Schema.String = Schema.String.check(Schema.isPattern(/^[^\n\r\u2028\u2029]+$/)) - -const MessageText: Schema.String = Schema.String.check( - Schema.isPattern(/^(?![\s\S]*\(\d{1,9},\d{1,9}\): error TS\d{1,9}: )[\s\S]*$/), -) - -const Position: Schema.Int = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0), Schema.isLessThan(POSITION_LIMIT)) - -const EmittedFileFields: Schema.Struct<{ readonly file: typeof FileText }> = Schema.Struct({ file: FileText }) - -const SourcelessDiagnosticFields: Schema.Struct<{ readonly code: typeof Position; readonly message: Schema.String }> = Schema.Struct({ - code: Position, - message: Schema.String, -}) - -const FiledDiagnosticFields: Schema.Struct<{ - readonly file: typeof FileText - readonly line: typeof Position - readonly column: typeof Position - readonly code: typeof Position - readonly message: typeof MessageText -}> = Schema.Struct({ file: FileText, line: Position, column: Position, code: Position, message: MessageText }) - -const emittedFileOf = (line: string): EmittedFileFields => ({ file: line.slice(EMITTED_PREFIX_LENGTH) }) - -const emittedLineOf = (fields: EmittedFileFields): `TSFILE: ${string}` => `${EMITTED_PREFIX}${fields.file}` - -const sourcelessDiagnosticOf = (line: string): SourcelessDiagnosticFields => { - const separator = line.indexOf(CODE_SEPARATOR, ERROR_PREFIX.length) - return { code: Number(line.slice(ERROR_PREFIX.length, separator)), message: line.slice(separator + CODE_SEPARATOR.length) } -} - -const sourcelessLineOf = (fields: SourcelessDiagnosticFields): `error TS${string}: ${string}` => - `${ERROR_PREFIX}${fields.code}${CODE_SEPARATOR}${fields.message}` - -const UNPARSED_FILED_LINE: FiledDiagnosticFields = { file: '', line: -1, column: -1, code: -1, message: '' } - -const partOf = (parts: RegExpExecArray) => (index: number): string => parts[index] ?? '' - -const numberPartOf = (parts: RegExpExecArray) => (index: number): number => Number(parts[index] ?? '') - -const filedFieldsFrom = (parts: RegExpExecArray): FiledDiagnosticFields => ({ - file: partOf(parts)(1), - line: numberPartOf(parts)(2), - column: numberPartOf(parts)(3), - code: numberPartOf(parts)(4), - message: partOf(parts)(5), -}) - -const filedDiagnosticOf = (line: string): FiledDiagnosticFields => - Option.match(Option.fromNullishOr(FILED_LINE.exec(line)), { onNone: () => UNPARSED_FILED_LINE, onSome: filedFieldsFrom }) - -const filedLineOf = (fields: FiledDiagnosticFields): `${string}(${number},${number}): error TS${number}: ${string}` => - `${fields.file}(${fields.line},${fields.column}${TAIL_HEAD}${fields.code}${CODE_SEPARATOR}${fields.message}` - -export const EmittedFileLine: Schema.Codec = Schema.TemplateLiteral([EMITTED_PREFIX, FileText]).pipe( - Schema.decodeTo(EmittedFileFields, { decode: SchemaGetter.transform(emittedFileOf), encode: SchemaGetter.transform(emittedLineOf) }), -) - -export const SourcelessDiagnosticLine: Schema.Codec = Schema.TemplateLiteral([ - ERROR_PREFIX, - Digits, - CODE_SEPARATOR, - Schema.String, -]).pipe( - Schema.decodeTo(SourcelessDiagnosticFields, { - decode: SchemaGetter.transform(sourcelessDiagnosticOf), - encode: SchemaGetter.transform(sourcelessLineOf), - }), -) - -export const FiledDiagnosticLine: Schema.Codec = Schema.TemplateLiteral([ - FileText, - '(', - Digits, - ',', - Digits, - TAIL_HEAD, - Digits, - CODE_SEPARATOR, - MessageText, -]).pipe( - Schema.decodeTo(FiledDiagnosticFields, { - decode: SchemaGetter.transform(filedDiagnosticOf), - encode: SchemaGetter.transform(filedLineOf), - }), -) - -const decodeEmittedFileLine: (line: string) => Option.Option = Schema.decodeOption(EmittedFileLine) - -const decodeSourcelessDiagnosticLine: (line: string) => Option.Option = Schema.decodeOption(SourcelessDiagnosticLine) - -const decodeFiledDiagnosticLine: (line: string) => Option.Option = Schema.decodeOption(FiledDiagnosticLine) - -type ParsedLine = - | { readonly kind: 'emittedFile'; readonly file: string } - | { readonly kind: 'diagnostic'; readonly diagnostic: Diagnostic } - -export type CompilerOutput = { - readonly emittedFiles: ReadonlyArray - readonly diagnostics: ReadonlyArray -} - -export type CompilerOutputRequest = { - readonly stdout: string - readonly packageRoot: string -} - -const posixOf = (value: string): string => value.replace(/\\/g, '/') - -const withTrailingSeparator = (value: string): string => `${value.replace(/\/+$/, '')}/` - -const packageRelativeFile = (packageRoot: string, file: string): string => { - const root = withTrailingSeparator(posixOf(packageRoot)) - const normalised = posixOf(file) - return normalised.startsWith(root) ? normalised.slice(root.length) : normalised -} - -const emittedFileOfLine = (line: string): Option.Option => - Option.map(decodeEmittedFileLine(line), (fields) => ({ kind: 'emittedFile', file: fields.file })) - -const sourcelessDiagnosticOfLine = (line: string): Option.Option => - Option.map(decodeSourcelessDiagnosticLine(line), (fields) => ({ - kind: 'diagnostic', - diagnostic: { file: '', code: fields.code, message: fields.message }, - })) - -const filedDiagnosticOfLine = (packageRoot: string) => (line: string): Option.Option => - Option.map(decodeFiledDiagnosticLine(line), (fields): ParsedLine => ({ - kind: 'diagnostic', - diagnostic: { file: packageRelativeFile(packageRoot, fields.file), code: fields.code, message: fields.message }, - })) - -const parsedLineOf = (packageRoot: string) => (line: string): Option.Option => - Option.orElse(emittedFileOfLine(line), () => - Option.orElse(sourcelessDiagnosticOfLine(line), () => filedDiagnosticOfLine(packageRoot)(line))) - -const emittedFileIn = (parsed: ParsedLine): Option.Option => - Match.value(parsed).pipe( - Match.when({ kind: 'emittedFile' }, (line) => Option.some(line.file)), - Match.orElse(() => Option.none()), - ) - -const diagnosticIn = (parsed: ParsedLine): Option.Option => - Match.value(parsed).pipe( - Match.when({ kind: 'diagnostic' }, (line) => Option.some(line.diagnostic)), - Match.orElse(() => Option.none()), - ) - -const linesOf = (stdout: string): ReadonlyArray => stdout.split('\n').map((line) => line.replace(/\r$/, '')) - -export const parseCompilerOutput: (request: CompilerOutputRequest) => CompilerOutput = (request) => { - const parsed = Arr.getSomes(linesOf(request.stdout).map(parsedLineOf(request.packageRoot))) - return { emittedFiles: Arr.getSomes(parsed.map(emittedFileIn)), diagnostics: Arr.getSomes(parsed.map(diagnosticIn)) } -} diff --git a/packages/api-extractor/src/front-end/typescript-7-compiler.ts b/packages/api-extractor/src/front-end/typescript-7-compiler.ts index d891803..664f5fe 100644 --- a/packages/api-extractor/src/front-end/typescript-7-compiler.ts +++ b/packages/api-extractor/src/front-end/typescript-7-compiler.ts @@ -1,11 +1,11 @@ -import { Array as Arr, Effect, FileSystem, HashMap, HashSet, Layer, Option, Path, Stream } from 'effect' +import { Array as Arr, Effect, FileSystem, HashMap, HashSet, Layer, Option, Order, Path, 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 { type CompilerOutput, parseCompilerOutput } from './compiler-output.schema.ts' import { closeDeclarations, entryPointDeclarations, missingEntryDeclarations, relativeWithin, type EntryDeclaration, type UnresolvedImport } from './declaration-graph.ts' import { CompilerEmitter, type CompilerEmitterId, type DeclarationEmitter } from './declaration-emitter.service.ts' -import type { Declarations, Diagnostic, PackageSource } from './declarations.schema.ts' +import type { Declarations, Diagnostic, PackageSource, SourceEntryPoint } from './declarations.schema.ts' import { CompilerRejected, UnsupportedDeclaration } from './front-end-error.schema.ts' export const TYPESCRIPT_PRODUCER: string = `typescript@${typescriptManifest.version}` @@ -14,75 +14,211 @@ const TYPESCRIPT_MANIFEST_URL: string = import.meta.resolve('typescript/package. const TSC_SCRIPT: string = typescriptManifest.bin.tsc +const DERIVED_CONFIG_NAME = 'tsconfig.json' + +const DECLARATION_FILE = /\.d\.[cm]?ts$/ + +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 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, + removeComments: false, + incremental: false, + composite: false, + tsBuildInfoFile: null, + rootDir: workspaceRoot, + outDir, + declarationDir: outDir, +}) + +const defaultTypeRoots = (path: Path.Path, directory: string): ReadonlyArray => { + const parent = path.dirname(directory) + const typeRoot = path.join(directory, 'node_modules', '@types') + return parent === directory ? [typeRoot] : [typeRoot, ...defaultTypeRoots(path, parent)] +} + +const isStringArray = (value: unknown): value is ReadonlyArray => Array.isArray(value) && value.every((entry) => typeof entry === 'string') + +const declaredTypeRoots = (packageRoot: string, tsconfigPath: string): Effect.Effect>> => + Effect.acquireUseRelease( + Effect.sync(() => new API({ cwd: packageRoot })), + (api) => Effect.map(Effect.promise(() => api.parseConfigFile(tsconfigPath)), (parsed) => Option.liftPredicate(parsed.options['typeRoots'], isStringArray)), + (api) => Effect.ignoreCause(Effect.promise(() => api.close())), + ).pipe(Effect.catchCause(() => Effect.succeedNone)) + +export type DerivedTsconfigRequest = { + readonly tsconfigPath: string + readonly packageRoot: string + readonly workspaceRoot: string + readonly outDir: string +} + +export const derivedTsconfig = (request: DerivedTsconfigRequest): Effect.Effect => + Effect.gen(function* () { + const path = yield* Path.Path + const typeRoots = Option.getOrElse(yield* declaredTypeRoots(request.packageRoot, request.tsconfigPath), () => defaultTypeRoots(path, request.packageRoot)) + return JSON.stringify({ + extends: request.tsconfigPath, + compilerOptions: { ...compilerEmitPolicy({ workspaceRoot: request.workspaceRoot, outDir: request.outDir }), typeRoots }, + }) + }) + type EmittedDeclaration = { readonly declared: string; readonly emittedFile: string } type CompilerRun = { - readonly output: CompilerOutput + readonly derived: string readonly exitCode: number - readonly stderr: string readonly outDir: string + readonly stderr: string + readonly stdout: string } -const compilerArguments = (source: PackageSource, outDir: string): ReadonlyArray => [ - '-p', - source.tsconfigPath, - '--noEmit', - 'false', - '--declaration', - '--emitDeclarationOnly', - '--declarationMap', - 'false', - '--sourceMap', - 'false', - '--stripInternal', - 'false', - '--removeComments', - 'false', - '--incremental', - 'false', - '--composite', - 'false', - '--tsBuildInfoFile', - 'null', - '--rootDir', - source.workspaceRoot, - '--outDir', - outDir, - '--declarationDir', - outDir, - '--listEmittedFiles', - '--pretty', - 'false', -] - 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 path = yield* Path.Path const spawner = yield* ChildProcessSpawner.ChildProcessSpawner const outDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: 'api-extractor-tsc-' }) - const handle = yield* spawner.spawn( - ChildProcess.make(process.execPath, [tsc, ...compilerArguments(source, outDir)], { cwd: source.packageRoot }), + const derived = path.join(outDir, DERIVED_CONFIG_NAME) + yield* fileSystem.writeFileString( + derived, + yield* derivedTsconfig({ 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 { output: parseCompilerOutput({ stdout, packageRoot: source.packageRoot }), exitCode, stderr, outDir } + 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 => ({ file: '', 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 exitDiagnostic = (exitCode: number, stderr: string): Diagnostic => ({ - file: '', - message: stderr.trim() === '' ? `\`tsc\` exited with code ${exitCode}` : `\`tsc\` exited with code ${exitCode}: ${stderr.trim()}`, +const fileOf = (path: Path.Path, packageRoot: string, fileName: Option.Option): string => + Option.getOrElse( + Option.flatMap(fileName, (name) => relativeWithin(path, packageRoot, path.resolve(packageRoot, name))), + () => '', + ) + +const apiDiagnostic = (path: Path.Path, packageRoot: string, diagnostic: ApiDiagnostic): Diagnostic => ({ + file: fileOf(path, packageRoot, Option.fromNullishOr(diagnostic.fileName)), + code: diagnostic.code, + message: chainText(diagnostic), }) -const rejectionOf = (run: CompilerRun): Option.Option> => - Option.orElse(Option.liftPredicate(run.output.diagnostics, Arr.isReadonlyArrayNonEmpty), () => - Option.map(Option.liftPredicate(run.exitCode, (code) => code !== 0), (code) => [exitDiagnostic(code, run.stderr)])) +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((file) => DECLARATION_FILE.test(file)).map((file) => packageDeclarationOf(path, declaredRoot, file))), + ) + const missingEntryDiagnostic = (declarationFile: string): Diagnostic => ({ file: declarationFile, message: 'the compiler emitted no declaration for this entry point', @@ -119,7 +255,7 @@ const declarationsFrom = (source: PackageSource, run: CompilerRun) => const path = yield* Path.Path const fileSystem = yield* FileSystem.FileSystem const declaredRoot = path.join(run.outDir, path.relative(source.workspaceRoot, source.packageRoot)) - const declared = Arr.getSomes(run.output.emittedFiles.map((emittedFile) => packageDeclarationOf(path, declaredRoot, emittedFile))) + const declared = yield* emittedDeclarationFiles(path, fileSystem, run.outDir, declaredRoot) const emitted = HashMap.fromIterable( yield* Effect.forEach(declared, (declaration) => Effect.map(fileSystem.readFileString(declaration.emittedFile), (text) => [declaration.declared, text] as const), @@ -132,14 +268,70 @@ const declarationsFrom = (source: PackageSource, run: CompilerRun) => }) }) +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 => ({ + 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 sharingEntries = (entries: ReadonlyArray, declarationFile: string): ReadonlyArray => + entries.filter((entry) => entry.declarationFile === declarationFile) + +const sharedDeclarationDiagnostic = (declarationFile: string, entries: ReadonlyArray): Diagnostic => ({ + file: declarationFile, + message: `the entry points ${entries.map((entry) => entry.exportPath).join(ENTRY_SEPARATOR)} share the declaration file ${declarationFile}`, +}) + +const sharedDeclaration = (entries: ReadonlyArray): Option.Option => + Option.flatMap( + Arr.findFirst( + Arr.sort(Arr.fromIterable(HashSet.fromIterable(entries.map((entry) => entry.declarationFile))), Order.String), + (declarationFile) => sharingEntries(entries, declarationFile).length > 1, + ), + (declarationFile) => Option.some(sharedDeclarationDiagnostic(declarationFile, sharingEntries(entries, 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(sharedDeclaration(entryPointDeclarations(source.entryPoints)), { + 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* Option.match(rejectionOf(run), { - onNone: () => declarationsFrom(source, run), - onSome: (diagnostics) => Effect.fail(new CompilerRejected({ diagnostics })), - }) + return yield* accepted(source, run) }), ) From 1ba9588cd30ec860a68845db82cf7fc8aa6e1be5 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 13 Oct 2026 18:26:28 +0000 Subject: [PATCH 22/99] fix(front-end): carry the package's root files and type roots into the derived config (U12 C3) A derived config in a temp directory resolves two defaults from its own location: include (**/*) and typeRoots (@types from node_modules upward). A package tsconfig that names no include therefore compiled nothing (TS18003), and the default typeRoots chain would replace a package's declared typeRoots. The adapter reads the package config once with the TypeScript 7 API's parseConfigFile and carries its resolved fileNames as files and its declared typeRoots, else the default chain from the package root. The equality journey gains a package with no include and one with typeRoots: ["./types"]; each fails at 7520eae and passes here. The solution doc and KTD3 record both settings, with the gate for each. --- .../tests/front-ends.integration.test.ts | 13 +++- .../2026-10-13-1737-feat-front-ends-plan.md | 2 +- ...lback-inherits-declaration-emit-options.md | 75 +++++++++++++------ .../src/front-end/typescript-7-compiler.ts | 15 +++- 4 files changed, 76 insertions(+), 29 deletions(-) diff --git a/apps/api-extractor-e2e/tests/front-ends.integration.test.ts b/apps/api-extractor-e2e/tests/front-ends.integration.test.ts index 947d9de..47e7e62 100644 --- a/apps/api-extractor-e2e/tests/front-ends.integration.test.ts +++ b/apps/api-extractor-e2e/tests/front-ends.integration.test.ts @@ -74,6 +74,13 @@ const declaredTypeRootsPackage = fallingBackPackage( { ...STANDALONE_TSCONFIG, compilerOptions: { ...STANDALONE_TSCONFIG.compilerOptions, types: ['greeting'], typeRoots: ['./types'] } }, ) +const defaultIncludePackage = fallingBackPackage( + 'default-include', + { '.': './src/mod.ts' }, + { 'src/mod.ts': 'export const counted: number = 1\n' }, + { compilerOptions: STANDALONE_TSCONFIG.compilerOptions }, +) + const forgedCodesPackage = packageWith( 'forged-codes', [ @@ -174,13 +181,14 @@ Feature('Both front-ends reduce a package to one model') .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 or type roots', + 'the fast path and the compiler fallback give the same model and messages, whatever the package tsconfig says about @internal, comments, declarationDir, type roots or included files', 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 no `include`, so every file under it is compiled')('defaultInclude', () => defaultIncludePackage), When('each is analysed through the isolated emitter and through the compiler')('analyses', (scope) => Effect.all({ ownPackage: throughBothRoutes(scope.source), @@ -188,6 +196,7 @@ Feature('Both front-ends reduce a package to one model') removeComments: throughBothRoutes(scope.removeComments), declarationDir: throughBothRoutes(scope.declarationDir), typeRoots: throughBothRoutes(scope.typeRoots), + defaultInclude: throughBothRoutes(scope.defaultInclude), wroteIntoPackage: writtenInto(scope.declarationDir, 'types'), }) ), @@ -200,6 +209,7 @@ Feature('Both front-ends reduce a package to one model') taggedNotes: scope.analyses.removeComments.isolated.messages, declarationDir: scope.analyses.declarationDir.compiled, typeRoots: scope.analyses.typeRoots.compiled, + defaultInclude: scope.analyses.defaultInclude.compiled, wroteIntoPackage: scope.analyses.wroteIntoPackage, }).toEqual({ ownPackage: scope.analyses.ownPackage.isolated, @@ -209,6 +219,7 @@ Feature('Both front-ends reduce a package to one model') taggedNotes: [], declarationDir: scope.analyses.declarationDir.isolated, typeRoots: scope.analyses.typeRoots.isolated, + defaultInclude: scope.analyses.defaultInclude.isolated, wroteIntoPackage: false, }) ), diff --git a/docs/plans/2026-10-13-1737-feat-front-ends-plan.md b/docs/plans/2026-10-13-1737-feat-front-ends-plan.md index f1f60a9..64651ac 100644 --- a/docs/plans/2026-10-13-1737-feat-front-ends-plan.md +++ b/docs/plans/2026-10-13-1737-feat-front-ends-plan.md @@ -118,7 +118,7 @@ Product Contract preservation: Product Contract unchanged. - KTD1. **One emitter port, two service tags, adapters as parameterised 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` (the compiler's takes options); 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 into its scoped temp directory, `{ "extends": , "compilerOptions": }`, where the emit policy is one exported record (`noEmit: false`, `declaration`, `emitDeclarationOnly`, `declarationMap: false`, `sourceMap: false`, `stripInternal: 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). + - **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 into its scoped temp directory, `{ "extends": , "compilerOptions": }`, where the emit policy is one exported record (`noEmit: false`, `declaration`, `emitDeclarationOnly`, `declarationMap: false`, `sourceMap: false`, `stripInternal: 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). Two settings resolve their defaults from the config file's directory, so the derived config carries the package's own values, read once with the API's `parseConfigFile`: `files` is the package config's resolved `fileNames` (a tsconfig with no `include` otherwise matches `**/*` in the temp directory, TS18003), and `typeRoots` is the package's declared value or the default `@types` chain from the package root (otherwise TS2688 for `types: ["node"]`). Both reproduced on 7.0.2; each 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. 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 index 8f2ae7c..62219b5 100644 --- a/docs/solutions/logic-errors/compiler-fallback-inherits-declaration-emit-options.md +++ b/docs/solutions/logic-errors/compiler-fallback-inherits-declaration-emit-options.md @@ -1,6 +1,7 @@ --- 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 @@ -23,8 +24,8 @@ 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 -p `, so it takes every option - the package sets unless its `compilerArguments` override that option on the command line. +- `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 @@ -45,57 +46,83 @@ one depends on the route it took. ## Solution -`compilerArguments` in the TypeScript 7 adapter passes `--stripInternal false`, next to its -other pinned options (`--declaration`, `--emitDeclarationOnly`, `--declarationMap false`, -`--sourceMap false`, `--incremental false`, `--composite false`, `--rootDir`, `--outDir`). -A command-line flag overrides the tsconfig, so the package no longer controls the option. +The emit policy is one exported record, `compilerEmitPolicy` in the TypeScript 7 adapter: +`stripInternal: 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 the flag the compiled model lacks `hidden`, and the journey fails. +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`: +`outDir` for `.d.ts` files (TSConfig reference, `declarationDir`: https://www.typescriptlang.org/tsconfig/#declarationDir; reproduced on TypeScript 7.0.2, where -`tsc -p` with the adapter's flags printed a `TSFILE:` line under the package's own `types` -directory). Before `compilerArguments` also passed `--declarationDir `, the adapter -therefore wrote the package's declarations into that `types` directory, found nothing under -its temp `outDir`, 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. +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 passes `--removeComments false`, and the journey analyses a package whose tsconfig +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 two settings whose defaults resolve from +the config file's directory, not the package's. Both were reproduced on TypeScript 7.0.2 with a +derived config in the 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'`. +- 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 therefore reads the package's config once with the TypeScript 7 API's +`parseConfigFile`. It sets `files` to the package config's resolved `fileNames`, and +`typeRoots` to the package's declared value or, when it declares none, the default chain from +the package root. Setting the default chain unconditionally would replace a package's declared +`typeRoots`. The journey's `typeRoots: ["./types"]` package fails that way. `paths` needed +nothing: a package declaring `paths: { "#lib/*": ["./lib/*"] }` compiled through the derived +config. + ## Architectural invariant **Single emit policy.** The fast path's options are the declaration-emit policy. The -fallback adapter restates every option in that policy on the command line, so a package's -tsconfig cannot make the routes disagree on those options. Other tsconfig options still -reach the fallback, because `-p` loads the whole file. An option outside the policy that -changes declaration text must be added to both adapters, together with an equality case: +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`, unless its default depends on the config file's location, in +which case the derived config carries the value the package's own config resolves to: ``` -fallbackArgs = ['-p', tsconfig, ...emitPolicyAsFlags(FAST_PATH_OPTIONS), ...outputLocation] +derived = { extends: packageTsconfig, files: packageFileNames, compilerOptions: { ...compilerEmitPolicy, typeRoots: declaredOrDefault } } ``` ## Prevention - Change both adapters together. An option added to the fast path's `isolatedDeclarationSync` - call is mirrored as a flag in `compilerArguments` in the same change. + 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 flag (`--outDir`, `--declarationDir`) points at the adapter's scoped temp + 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. -- Code smell to grep for: a `compilerArguments` list that lacks a flag for an option the fast - path passes explicitly. +- A setting whose default resolves from the config file's directory is carried into the + derived config with the value the package's config resolves to. Gate: the `typeRoots` and + no-`include` cases of the same journey; each refuses on the fallback when its value is not + carried. ## Related diff --git a/packages/api-extractor/src/front-end/typescript-7-compiler.ts b/packages/api-extractor/src/front-end/typescript-7-compiler.ts index 664f5fe..fc0a30c 100644 --- a/packages/api-extractor/src/front-end/typescript-7-compiler.ts +++ b/packages/api-extractor/src/front-end/typescript-7-compiler.ts @@ -67,13 +67,20 @@ const defaultTypeRoots = (path: Path.Path, directory: string): ReadonlyArray => Array.isArray(value) && value.every((entry) => typeof entry === 'string') -const declaredTypeRoots = (packageRoot: string, tsconfigPath: string): Effect.Effect>> => +type PackageConfig = { readonly typeRoots: Option.Option>; readonly fileNames: ReadonlyArray } + +const packageConfig = (packageRoot: string, tsconfigPath: string): Effect.Effect> => Effect.acquireUseRelease( Effect.sync(() => new API({ cwd: packageRoot })), - (api) => Effect.map(Effect.promise(() => api.parseConfigFile(tsconfigPath)), (parsed) => Option.liftPredicate(parsed.options['typeRoots'], isStringArray)), + (api) => + Effect.map(Effect.promise(() => api.parseConfigFile(tsconfigPath)), (parsed) => + Option.some({ typeRoots: Option.liftPredicate(parsed.options['typeRoots'], isStringArray), fileNames: parsed.fileNames })), (api) => Effect.ignoreCause(Effect.promise(() => api.close())), ).pipe(Effect.catchCause(() => Effect.succeedNone)) +const rootFilesField = (config: Option.Option): { readonly files?: ReadonlyArray } => + Option.match(config, { onNone: () => ({}), onSome: ({ fileNames }) => ({ files: fileNames }) }) + export type DerivedTsconfigRequest = { readonly tsconfigPath: string readonly packageRoot: string @@ -84,10 +91,12 @@ export type DerivedTsconfigRequest = { export const derivedTsconfig = (request: DerivedTsconfigRequest): Effect.Effect => Effect.gen(function* () { const path = yield* Path.Path - const typeRoots = Option.getOrElse(yield* declaredTypeRoots(request.packageRoot, request.tsconfigPath), () => defaultTypeRoots(path, request.packageRoot)) + const config = yield* packageConfig(request.packageRoot, request.tsconfigPath) + const typeRoots = Option.getOrElse(Option.flatMap(config, (found) => found.typeRoots), () => defaultTypeRoots(path, request.packageRoot)) return JSON.stringify({ extends: request.tsconfigPath, compilerOptions: { ...compilerEmitPolicy({ workspaceRoot: request.workspaceRoot, outDir: request.outDir }), typeRoots }, + ...rootFilesField(config), }) }) From 8efc4428e3a8a5aa78b51b65b303963f4d4f54d0 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 13 Oct 2026 18:26:28 +0000 Subject: [PATCH 23/99] docs(front-end): say both routes give one model apart from the producer stamp (U12 C11) README and the changeset now name the producer stamp as the one route difference, and the README describes entry expansion (.cts, subpath patterns), the entry checks that send a package to the fallback, and that compiler diagnostics come from the TypeScript 7 API. --- .changeset/old-cobras-punch.md | 2 +- README.md | 20 +++++++++++++++----- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/.changeset/old-cobras-punch.md b/.changeset/old-cobras-punch.md index 0742362..62560b0 100644 --- a/.changeset/old-cobras-punch.md +++ b/.changeset/old-cobras-punch.md @@ -2,4 +2,4 @@ "@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. +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. diff --git a/README.md b/README.md index cf4e99e..899b1e2 100644 --- a/README.md +++ b/README.md @@ -66,16 +66,26 @@ process.stdout.write(ApiModel.serialise(analysis.model)) ``` `readPackage` takes each export whose `@systemfsoftware/source` condition names a -TypeScript file as an entry point; a package with none is refused with `NoSourceEntry`. +`.ts`, `.tsx`, `.mts` or `.cts` file as an entry point. A subpath pattern (`"./*"`) +expands to one entry per matching source under Node's `exports` rules, and an explicit +subpath wins over a pattern. A package with no such export is refused with `NoSourceEntry`. `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), the package takes the fallback: this package's own -TypeScript 7 `tsc` runs as a process, and `route` is `Fallback` with the diagnostics that -sent it there. A `tsc` that reports an error refuses the package with `CompilerRejected`. +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, never from `tsc`'s printed output, which a +package's own file names and import specifiers can spell. A diagnostic no compiler reported +(a missing entry, say) carries no TypeScript code. 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. +`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 From 35c33f45a3d6c90c967155755afe90fc10c55b21 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 13 Oct 2026 18:34:35 +0000 Subject: [PATCH 24/99] docs(plan): correct D1 and D2 to what the adapters do; record delta rulings (U12) Supersedes the 1737 plan. D1 named a reader refusal (MissingSource) that would have reversed ruling #5's fast-path decline; the adapters decline (fast path) and refuse before tsc (compiler). D2 said tsc refuses a shared declaration path with TS5056; tsc 7.0.2 exits 0 and writes one file, so the compiler adapter refuses it first. The DeepSeek delta review's two findings are ruled: the D1 text finding is accepted by this correction, the dropped-exit-cause finding is rejected with its reason. --- ...s-plan.md => 2026-10-13-1834-feat-front-ends-plan.md} | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) rename docs/plans/{2026-10-13-1737-feat-front-ends-plan.md => 2026-10-13-1834-feat-front-ends-plan.md} (97%) diff --git a/docs/plans/2026-10-13-1737-feat-front-ends-plan.md b/docs/plans/2026-10-13-1834-feat-front-ends-plan.md similarity index 97% rename from docs/plans/2026-10-13-1737-feat-front-ends-plan.md rename to docs/plans/2026-10-13-1834-feat-front-ends-plan.md index 64651ac..83c64b5 100644 --- a/docs/plans/2026-10-13-1737-feat-front-ends-plan.md +++ b/docs/plans/2026-10-13-1834-feat-front-ends-plan.md @@ -6,7 +6,7 @@ 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-13-1629-feat-front-ends-plan.md +supersedes: docs/plans/2026-10-13-1737-feat-front-ends-plan.md execution: code --- @@ -304,8 +304,8 @@ U1 → U2 (profile committed) → U3 (choice recorded) → U4 → U5 → U6 → - 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. `readPackage` refuses an export whose recorded source file is missing (`MissingSource`, naming the export and the file), so neither route models a sibling `.ts` in place of a deleted `.tsx`. - - D2: two entry points whose sources share a declaration path are declined by the fast path, and `tsc` refuses them (TS5056). + - 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. @@ -333,6 +333,9 @@ U1 → U2 (profile committed) → U3 (choice recorded) → U4 → U5 → U6 → - 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. From e5ec09d26c3c72eae8d7ae7a5aad54f4bd0150ea Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 13 Oct 2026 18:34:43 +0000 Subject: [PATCH 25/99] docs(solutions): point the emit-option learning at the 1834 plan --- .../compiler-fallback-inherits-declaration-emit-options.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 62219b5..9588cd7 100644 --- a/docs/solutions/logic-errors/compiler-fallback-inherits-declaration-emit-options.md +++ b/docs/solutions/logic-errors/compiler-fallback-inherits-declaration-emit-options.md @@ -126,6 +126,6 @@ derived = { extends: packageTsconfig, files: packageFileNames, compilerOptions: ## Related -- `docs/plans/2026-10-13-1737-feat-front-ends-plan.md` (R3; U10 disposition #6; U12 emit policy) +- `docs/plans/2026-10-13-1834-feat-front-ends-plan.md` (R3; U10 disposition #6; U12 emit policy) - `docs/solutions/design-patterns/keyed-uniqueness-in-the-api-model-schema.md`: a law passes vacuously when its corpus lacks the triggering shape From c093a03d1e3c5927ca95df7131a85726b0423924 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 13 Oct 2026 19:40:14 +0000 Subject: [PATCH 26/99] fix(front-end): skip null and array export targets; pattern exports read only TypeScript sources (U13 #2, #8) A null target (an excluded subpath) or an array target (a fallback list) failed the manifest decode, so readPackage refused the whole package with a SchemaError. Both are now admitted and skipped: neither carries a source condition. A pattern export expanded to every file under its target directory: a JSON file, a directory, and, for a root-level "./*": "./*.ts", every .ts under node_modules. Expanded candidates now pass the same TypeScript-source filter as explicit entries, and a match with an empty, ".", ".." or node_modules segment is dropped, as Node's pattern resolution rejects it. Both reader scenarios fail at e5ec09d and pass after. --- .../src/front-end/package-source.schema.ts | 9 +++- .../src/front-end/read-package.ts | 14 +++++-- .../tests/package-reader.integration.test.ts | 42 +++++++++++++++++++ 3 files changed, 59 insertions(+), 6 deletions(-) diff --git a/packages/api-extractor/src/front-end/package-source.schema.ts b/packages/api-extractor/src/front-end/package-source.schema.ts index 50fdf74..259bf81 100644 --- a/packages/api-extractor/src/front-end/package-source.schema.ts +++ b/packages/api-extractor/src/front-end/package-source.schema.ts @@ -1,8 +1,13 @@ import { Schema } from 'effect' -export const ExportTarget: Schema.Union>]> = Schema.Union([ +export const ConditionMap: Schema.$Record> = Schema.Record(Schema.String, Schema.Json) +export type ConditionMap = typeof ConditionMap.Type + +export const ExportTarget: Schema.Union>, typeof ConditionMap]> = Schema.Union([ Schema.String, - Schema.Record(Schema.String, Schema.Json), + Schema.Null, + Schema.Array(Schema.Json), + ConditionMap, ]) export type ExportTarget = typeof ExportTarget.Type diff --git a/packages/api-extractor/src/front-end/read-package.ts b/packages/api-extractor/src/front-end/read-package.ts index 5816c55..29b1b17 100644 --- a/packages/api-extractor/src/front-end/read-package.ts +++ b/packages/api-extractor/src/front-end/read-package.ts @@ -3,7 +3,7 @@ import type { PlatformError } from 'effect/PlatformError' import type { PackageSource, SourceEntryPoint } from './declarations.schema.ts' import { relativeWithin } from './declaration-graph.ts' import { NoSourceEntry } from './front-end-error.schema.ts' -import { type ExportTarget, PackageManifest } from './package-source.schema.ts' +import { ConditionMap, type ExportTarget, PackageManifest } from './package-source.schema.ts' export const FAMILY_SOURCE_CONDITION = '@systemfsoftware/source' @@ -22,8 +22,10 @@ const isString = (value: unknown): value is string => typeof value === 'string' const stripRelativePrefix = (value: string): string => value.replace(/^\.\//, '') +const isConditionMap = Schema.is(ConditionMap) + const conditionValue = (target: ExportTarget, condition: string): Option.Option => - typeof target === 'string' ? Option.none() : Option.flatMap(Option.fromNullishOr(target[condition]), (value) => Option.liftPredicate(value, isString)) + Option.flatMap(Option.liftPredicate(target, isConditionMap), (conditions) => Option.liftPredicate(conditions[condition], isString)) const entryPointOf = (condition: string) => ([exportPath, target]: readonly [string, ExportTarget]): Option.Option => @@ -55,9 +57,13 @@ const matchedSubstring = (prefix: string, suffix: string, candidate: string): Op (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(matchedSubstring(prefix, suffix, candidate), (substring) => ({ + Option.map(Option.filter(matchedSubstring(prefix, suffix, candidate), isValidPatternMatch), (substring) => ({ entry: { exportPath: key.replace('*', substring), sourceFile: candidate }, key, })) @@ -125,7 +131,7 @@ const expandPattern = (root: string, condition: string, key: string, target: Exp onNone: () => Effect.succeed>([]), onSome: (parts) => Effect.map(walkDirectory(root, directoryOf(stripRelativePrefix(parts.prefix))), (candidates) => - Arr.getSomes(candidates.map(patternEntryOf(key, stripRelativePrefix(parts.prefix), parts.suffix))), + Arr.getSomes(candidates.filter(isTypeScriptSource).map(patternEntryOf(key, stripRelativePrefix(parts.prefix), parts.suffix))), ), }), }) diff --git a/packages/api-extractor/tests/package-reader.integration.test.ts b/packages/api-extractor/tests/package-reader.integration.test.ts index 088e702..8889078 100644 --- a/packages/api-extractor/tests/package-reader.integration.test.ts +++ b/packages/api-extractor/tests/package-reader.integration.test.ts @@ -49,6 +49,21 @@ const nestedPatternPackage = packageOf( { 'src/x/y.ts': '', 'lib/y.ts': '' }, ) +const excludedTargetPackage = packageOf( + { '.': { [condition]: './src/mod.ts' }, './internal/*': null, './legacy': ['./dist/legacy.js'] }, + { 'src/mod.ts': 'export const mod: 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': '' }, +) + Feature("A package's exports name its entry points") .withLayer(NodeServices.layer) .live('the package reader reads real files from disk') @@ -118,4 +133,31 @@ Feature("A package's exports name its entry points") ), ), ) + + scenario( + 'a null or array 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 and listing `./legacy` as a fallback array')('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' }], + }) + ), + ), + ) }) From c4202d9fba6e13a2b4627ae2b15dd8edfe403115 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 13 Oct 2026 19:40:14 +0000 Subject: [PATCH 27/99] fix(front-end): the fallback ignores the package's isolatedDeclarations; a diagnostic names a file only inside the package (U13 #4, #1) The derived tsconfig extends the package's own, so a package that sets isolatedDeclarations: true and has an unannotated export was declined by the fast path and then refused by tsc with the same TS9010. The emit policy now pins isolatedDeclarations: false. Diagnostic.file is optional, like code: a compiler diagnostic for no file, or for a file outside the package, carries no file instead of the '' sentinel. e2e: the TS9010 routing scenario gains a package whose tsconfig sets isolatedDeclarations, and the TS5033 refusal asserts no file. Both fail at e5ec09d and pass after. --- README.md | 3 +- .../tests/front-ends.integration.test.ts | 28 +++++++++++++------ .../src/front-end/declarations.schema.ts | 4 +-- .../src/front-end/front-end-error.schema.ts | 2 +- .../src/front-end/oxc-isolated-emitter.ts | 2 +- .../src/front-end/typescript-7-compiler.ts | 12 ++++---- 6 files changed, 32 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 899b1e2..c7d293e 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,8 @@ policy (`FrontEnd.compilerEmitPolicy`), and `route` is `Fallback` with the diagn sent it there. A `tsc` that fails refuses the package with `CompilerRejected`, whose diagnostics come from the TypeScript 7 API, never from `tsc`'s printed output, which a package's own file names and import specifiers can spell. A diagnostic no compiler reported -(a missing entry, say) carries no TypeScript code. +(a missing entry, say) carries no TypeScript code, and a diagnostic names a `file` only when +that file is inside the package. Both routes meet at declaration text: one syntactic builder makes the model and one resolver reports `ae-forgotten-export`, `ae-missing-release-tag`, `ae-internal-missing-underscore`, `ae-incompatible-release-tags` and diff --git a/apps/api-extractor-e2e/tests/front-ends.integration.test.ts b/apps/api-extractor-e2e/tests/front-ends.integration.test.ts index 47e7e62..0bc5910 100644 --- a/apps/api-extractor-e2e/tests/front-ends.integration.test.ts +++ b/apps/api-extractor-e2e/tests/front-ends.integration.test.ts @@ -139,10 +139,18 @@ const refusalOf = (error: Refused) => { return { error, codes: diagnostics.map((diagnostic) => diagnostic.code), + 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, items: analysis.model.entryPoints.flatMap((entry) => entry.items) }) + +const STRING_ARRAY_FALLBACK = { + route: { _tag: 'Fallback', diagnostics: [{ file: 'src/mod.ts', code: 9010 }] }, + 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 @@ -231,14 +239,14 @@ Feature('Both front-ends reduce a package to one model') 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')), - When('the engine analyses it')('analysis', (scope) => FrontEnd.analyse(scope.source)), - Then('it took the fallback for TS9010 on `ids` and modelled `ids` as a string array')((scope, expect) => - expect({ - route: scope.analysis.route, - items: scope.analysis.model.entryPoints.flatMap((entry) => entry.items), - }).toMatchObject({ - route: { _tag: 'Fallback', diagnostics: [{ file: 'src/mod.ts', code: 9010 }] }, - items: [{ kind: 'variable', name: 'ids', constant: true, type: { kind: 'array', element: { kind: 'keyword', name: 'string' } } }], + 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` 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, }) ), ), @@ -281,6 +289,7 @@ Feature('Both front-ends reduce a package to one model') longName: scope.refusals.longName.error, escaping: scope.refusals.escaping.error, shared: scope.refusals.shared.error, + files: { longName: scope.refusals.longName.files }, codes: { excludedEntry: scope.refusals.excludedEntry.codes, longName: scope.refusals.longName.codes, @@ -306,12 +315,13 @@ Feature('Both front-ends reduce a package to one model') ], withoutConfig: { _tag: 'CompilerRejected', diagnostics: expect.arrayContaining([expect.objectContaining({ code: 5083 })]) }, excludedEntry: { _tag: 'CompilerRejected', diagnostics: [{ file: 'src/mod.d.ts', message: 'the compiler emitted no declaration for this entry point' }] }, - longName: { _tag: 'CompilerRejected', diagnostics: [{ file: '', message: expect.stringContaining('error TS5033') }] }, + longName: { _tag: 'CompilerRejected', diagnostics: [{ message: expect.stringContaining('error TS5033') }] }, escaping: { _tag: 'CompilerRejected', diagnostics: [{ file: '../shared/mod.ts', message: expect.stringContaining('resolves outside the package') }] }, shared: { _tag: 'CompilerRejected', diagnostics: [{ file: 'src/mod.d.ts', message: expect.stringContaining('the entry points . and ./mod share the declaration file src/mod.d.ts') }], }, + files: { longName: [undefined] }, codes: { excludedEntry: [undefined], longName: [undefined], escaping: [undefined], shared: [undefined] }, }) ), diff --git a/packages/api-extractor/src/front-end/declarations.schema.ts b/packages/api-extractor/src/front-end/declarations.schema.ts index dc5950c..61268af 100644 --- a/packages/api-extractor/src/front-end/declarations.schema.ts +++ b/packages/api-extractor/src/front-end/declarations.schema.ts @@ -34,13 +34,13 @@ export const Declarations: Schema.Codec = Schema.Struct({ }) export type Diagnostic = { - readonly file: string + readonly file?: string readonly code?: number readonly message: string } export const Diagnostic: Schema.Codec = Schema.Struct({ - file: Schema.String, + file: Schema.optionalKey(Schema.String), code: Schema.optionalKey(Schema.Int.check(Schema.isGreaterThan(0))), message: Schema.String, }) diff --git a/packages/api-extractor/src/front-end/front-end-error.schema.ts b/packages/api-extractor/src/front-end/front-end-error.schema.ts index 6a1ed2f..c3428b7 100644 --- a/packages/api-extractor/src/front-end/front-end-error.schema.ts +++ b/packages/api-extractor/src/front-end/front-end-error.schema.ts @@ -8,7 +8,7 @@ const diagnosticsFields: DiagnosticsFields = { diagnostics: Schema.Array(Diagnos const codeLabel = (code: number | undefined): string => (code === undefined ? '' : `TS${code} `) const listed = (diagnostics: ReadonlyArray): string => - diagnostics.map(({ file, code, message }) => `${file === '' ? '' : `${file}: `}${codeLabel(code)}${message}`).join('\n') + diagnostics.map(({ file, code, message }) => `${file === undefined ? '' : `${file}: `}${codeLabel(code)}${message}`).join('\n') const NoSourceEntryBase: Schema.Class, Cause.YieldableError> = Schema.TaggedError()('NoSourceEntry', { packageRoot: Schema.String }) diff --git a/packages/api-extractor/src/front-end/oxc-isolated-emitter.ts b/packages/api-extractor/src/front-end/oxc-isolated-emitter.ts index c13971f..bd83c4c 100644 --- a/packages/api-extractor/src/front-end/oxc-isolated-emitter.ts +++ b/packages/api-extractor/src/front-end/oxc-isolated-emitter.ts @@ -18,7 +18,7 @@ const diagnosticOf = (file: string, text: string): Diagnostic => }) const diagnosticOrder: Order.Order = Order.combineAll([ - Order.mapInput(Order.String, (diagnostic: Diagnostic) => diagnostic.file), + Order.mapInput(Option.makeOrder(Order.String), (diagnostic: Diagnostic) => Option.fromNullishOr(diagnostic.file)), Order.mapInput(Option.makeOrder(Order.Number), (diagnostic: Diagnostic) => Option.fromNullishOr(diagnostic.code)), Order.mapInput(Order.String, (diagnostic: Diagnostic) => diagnostic.message), ]) diff --git a/packages/api-extractor/src/front-end/typescript-7-compiler.ts b/packages/api-extractor/src/front-end/typescript-7-compiler.ts index fc0a30c..455e4ce 100644 --- a/packages/api-extractor/src/front-end/typescript-7-compiler.ts +++ b/packages/api-extractor/src/front-end/typescript-7-compiler.ts @@ -29,6 +29,7 @@ export type CompilerEmitPolicy = { readonly declarationMap: false readonly sourceMap: false readonly stripInternal: false + readonly isolatedDeclarations: false readonly removeComments: false readonly incremental: false readonly composite: false @@ -50,6 +51,7 @@ export const compilerEmitPolicy: (request: CompilerEmitPolicyRequest) => Compile declarationMap: false, sourceMap: false, stripInternal: false, + isolatedDeclarations: false, removeComments: false, incremental: false, composite: false, @@ -139,7 +141,7 @@ const exitMessage = (exitCode: number, stdout: string, stderr: string): string = }, ) -const exitDiagnostic = (run: CompilerRun): Diagnostic => ({ file: '', message: exitMessage(run.exitCode, run.stdout, run.stderr) }) +const exitDiagnostic = (run: CompilerRun): Diagnostic => ({ message: exitMessage(run.exitCode, run.stdout, run.stderr) }) const indented = (text: string): string => text.replaceAll('\n', '\n ') @@ -149,14 +151,14 @@ const chainText = (diagnostic: ApiDiagnostic): string => onNonEmpty: (chain) => `${diagnostic.text}\n ${chain.map((next) => indented(chainText(next))).join('\n ')}`, }) -const fileOf = (path: Path.Path, packageRoot: string, fileName: Option.Option): string => - Option.getOrElse( +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 => ({ - file: fileOf(path, packageRoot, Option.fromNullishOr(diagnostic.fileName)), + ...fileFieldOf(path, packageRoot, Option.fromNullishOr(diagnostic.fileName)), code: diagnostic.code, message: chainText(diagnostic), }) From 432b608f15cfc21e8b9f3dddf07eeefe92079b8a Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 13 Oct 2026 19:52:37 +0000 Subject: [PATCH 28/99] fix(front-end): resolve types through the package's own node_modules ancestry; profile harness maps .cts and counts missing emits (U13 #5, #6) The derived tsconfig sat in a bare temp directory, so tsc looked up a `types` entry naming a package (`vitest/globals`) in node_modules directories above the temp dir and refused with TS2688. Four corpus packages the earlier profile modelled were refused this way. The derived config is now written into a scoped mirror of the package config's directory, each ancestor carrying a symlink to the real node_modules, so tsc's upward walk matches the package's own. The explicit typeRoots chain this replaces is gone; derivedTsconfig becomes writeDerivedTsconfig, which writes the mirror and returns the config path. e2e: the equality journey gains a package whose tsconfig names `types: ["greeting/globals"]` from its own node_modules through an exports subpath. It fails at c4202d9 with TS2688 and passes after. Profile harness: .cts sources map to .d.cts and are discovered; null and array export targets are skipped; a file missing from the 5.9.3 output is counted apart from a printing difference; tsc's stderr is drained alongside stdout. --- .../tests/front-ends.integration.test.ts | 17 +++++- apps/api-extractor-profile/profile.ts | 49 ++++++++------- packages/api-extractor/src/FrontEnd.ts | 2 +- .../src/front-end/typescript-7-compiler.ts | 61 +++++++++++-------- 4 files changed, 80 insertions(+), 49 deletions(-) diff --git a/apps/api-extractor-e2e/tests/front-ends.integration.test.ts b/apps/api-extractor-e2e/tests/front-ends.integration.test.ts index 0bc5910..c830894 100644 --- a/apps/api-extractor-e2e/tests/front-ends.integration.test.ts +++ b/apps/api-extractor-e2e/tests/front-ends.integration.test.ts @@ -74,6 +74,17 @@ const declaredTypeRootsPackage = fallingBackPackage( { ...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 defaultIncludePackage = fallingBackPackage( 'default-include', { '.': './src/mod.ts' }, @@ -189,13 +200,14 @@ Feature('Both front-ends reduce a package to one model') .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 or included files', + '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', 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 whose tsconfig names no `include`, so every file under it is compiled')('defaultInclude', () => defaultIncludePackage), When('each is analysed through the isolated emitter and through the compiler')('analyses', (scope) => Effect.all({ @@ -204,6 +216,7 @@ Feature('Both front-ends reduce a package to one model') removeComments: throughBothRoutes(scope.removeComments), declarationDir: throughBothRoutes(scope.declarationDir), typeRoots: throughBothRoutes(scope.typeRoots), + nodeModulesTypes: throughBothRoutes(scope.nodeModulesTypes), defaultInclude: throughBothRoutes(scope.defaultInclude), wroteIntoPackage: writtenInto(scope.declarationDir, 'types'), }) @@ -217,6 +230,7 @@ Feature('Both front-ends reduce a package to one model') taggedNotes: scope.analyses.removeComments.isolated.messages, declarationDir: scope.analyses.declarationDir.compiled, typeRoots: scope.analyses.typeRoots.compiled, + nodeModulesTypes: scope.analyses.nodeModulesTypes.compiled, defaultInclude: scope.analyses.defaultInclude.compiled, wroteIntoPackage: scope.analyses.wroteIntoPackage, }).toEqual({ @@ -227,6 +241,7 @@ Feature('Both front-ends reduce a package to one model') taggedNotes: [], declarationDir: scope.analyses.declarationDir.isolated, typeRoots: scope.analyses.typeRoots.isolated, + nodeModulesTypes: scope.analyses.nodeModulesTypes.isolated, defaultInclude: scope.analyses.defaultInclude.isolated, wroteIntoPackage: false, }) diff --git a/apps/api-extractor-profile/profile.ts b/apps/api-extractor-profile/profile.ts index 2e9d887..bb79331 100644 --- a/apps/api-extractor-profile/profile.ts +++ b/apps/api-extractor-profile/profile.ts @@ -12,9 +12,13 @@ import ts59 from 'typescript-5.9' const WARM_RUNS = 5 const require = createRequire(import.meta.url) -const TYPESCRIPT_SOURCE = /\.m?tsx?$/ +const TYPESCRIPT_SOURCE = /\.(?:ts|tsx|mts|cts)$/ -const ExportTarget = Schema.Union([Schema.String, Schema.Record(Schema.String, Schema.Json)]) +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, @@ -66,9 +70,8 @@ const measure = (effect: Effect.Effect, runs: number = WARM_RU }) const sourceEntryOf = (target: typeof ExportTarget.Type): Option.Option => - typeof target === 'string' - ? Option.none() - : Option.fromNullishOr(target[FrontEnd.FAMILY_SOURCE_CONDITION]).pipe(Option.filter((value): value is string => typeof value === 'string' && TYPESCRIPT_SOURCE.test(value))) + 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))) @@ -128,7 +131,7 @@ const discover = (subject: Subject) => }) const declarationPath = (root: string, file: string, path: Path.Path): string => - path.relative(root, file).replace(/\.(m?)tsx?$/, (_, m: string) => `.d.${m}ts`) + 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) @@ -159,18 +162,13 @@ interface CompileRun { const compile = (tsc: string, subject: Subject, extra: ReadonlyArray) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem - const path = yield* Path.Path const spawner = yield* ChildProcessSpawner.ChildProcessSpawner const outDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: 'front-end-profile-' }) const tsconfig = yield* tsconfigOf(subject.root) - const derived = path.join(outDir, 'tsconfig.json') - yield* fileSystem.writeFileString( - derived, - yield* FrontEnd.derivedTsconfig({ tsconfigPath: tsconfig, packageRoot: subject.root, workspaceRoot: subject.workspaceRoot, outDir }), - ) + 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* Stream.decodeText(handle.stdout).pipe(Stream.mkString) + 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 @@ -199,8 +197,17 @@ const isolatedErrorsIn = (run: CompileRun, subject: Subject, sources: ReadonlyMa return run.diagnostics.filter((line) => / error TS9\d{3}:/.test(line) && files.some((file) => line.startsWith(file))).length } -const differing = (left: ReadonlyMap, right: ReadonlyMap): number => - [...left].filter(([file, text]) => right.get(file) !== text).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', @@ -231,7 +238,7 @@ interface PackageProfile { readonly declarationStatements: number readonly clean: Readonly> readonly fallback: { readonly exitCode: number; readonly diagnostics: number } - readonly divergence: { readonly compared: number; readonly differing: number; readonly typescript59ExitCode: number } + readonly divergence: Divergence readonly frontEnd: FrontEndOutcome readonly phases: Readonly> } @@ -317,7 +324,7 @@ const profilePackage = (subject: Subject): Effect.Effect, phase: Phase, key: keyof 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 | route | model |` - const rule = `|${'---|'.repeat(PHASES.length + 9)}` + 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.frontEnd.route} | ${item.frontEnd.outcome} |` + `| ${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)} | | |` + 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', '', diff --git a/packages/api-extractor/src/FrontEnd.ts b/packages/api-extractor/src/FrontEnd.ts index 3840511..3ddf10f 100644 --- a/packages/api-extractor/src/FrontEnd.ts +++ b/packages/api-extractor/src/FrontEnd.ts @@ -18,7 +18,7 @@ export { compilerEmitPolicy, type CompilerEmitPolicy, type CompilerEmitPolicyRequest, - derivedTsconfig, + writeDerivedTsconfig, type DerivedTsconfigRequest, layer as compilerEmitterLayer, TYPESCRIPT_PRODUCER, diff --git a/packages/api-extractor/src/front-end/typescript-7-compiler.ts b/packages/api-extractor/src/front-end/typescript-7-compiler.ts index 455e4ce..3a14437 100644 --- a/packages/api-extractor/src/front-end/typescript-7-compiler.ts +++ b/packages/api-extractor/src/front-end/typescript-7-compiler.ts @@ -1,4 +1,4 @@ -import { Array as Arr, Effect, FileSystem, HashMap, HashSet, Layer, Option, Order, Path, Stream } from 'effect' +import { Array as Arr, Effect, FileSystem, HashMap, HashSet, Layer, Option, Order, 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' @@ -61,27 +61,33 @@ export const compilerEmitPolicy: (request: CompilerEmitPolicyRequest) => Compile declarationDir: outDir, }) -const defaultTypeRoots = (path: Path.Path, directory: string): ReadonlyArray => { +const ancestorsOf = (path: Path.Path, directory: string): ReadonlyArray => { const parent = path.dirname(directory) - const typeRoot = path.join(directory, 'node_modules', '@types') - return parent === directory ? [typeRoot] : [typeRoot, ...defaultTypeRoots(path, parent)] + return parent === directory ? [directory] : [directory, ...ancestorsOf(path, parent)] } -const isStringArray = (value: unknown): value is ReadonlyArray => Array.isArray(value) && value.every((entry) => typeof entry === 'string') +const mirroredDirectory = (path: Path.Path, mirrorRoot: string, directory: string): string => + path.join(mirrorRoot, path.relative(path.parse(directory).root, directory)) -type PackageConfig = { readonly typeRoots: Option.Option>; readonly fileNames: ReadonlyArray } +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')), fileSystem.exists(nodeModules)) + }) -const packageConfig = (packageRoot: string, tsconfigPath: string): Effect.Effect> => +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({ typeRoots: Option.liftPredicate(parsed.options['typeRoots'], isStringArray), fileNames: parsed.fileNames })), + (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 = (config: Option.Option): { readonly files?: ReadonlyArray } => - Option.match(config, { onNone: () => ({}), onSome: ({ fileNames }) => ({ files: fileNames }) }) +const rootFilesField = (fileNames: Option.Option>): { readonly files?: ReadonlyArray } => + Option.match(fileNames, { onNone: () => ({}), onSome: (files) => ({ files }) }) export type DerivedTsconfigRequest = { readonly tsconfigPath: string @@ -90,16 +96,24 @@ export type DerivedTsconfigRequest = { readonly outDir: string } -export const derivedTsconfig = (request: DerivedTsconfigRequest): Effect.Effect => +export const writeDerivedTsconfig = (request: DerivedTsconfigRequest): Effect.Effect => Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem const path = yield* Path.Path - const config = yield* packageConfig(request.packageRoot, request.tsconfigPath) - const typeRoots = Option.getOrElse(Option.flatMap(config, (found) => found.typeRoots), () => defaultTypeRoots(path, request.packageRoot)) - return JSON.stringify({ - extends: request.tsconfigPath, - compilerOptions: { ...compilerEmitPolicy({ workspaceRoot: request.workspaceRoot, outDir: request.outDir }), typeRoots }, - ...rootFilesField(config), - }) + 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 } @@ -118,14 +132,9 @@ const drain = (stream: Stream.Stream): Effect.Effect< const runCompiler = (source: PackageSource, tsc: string) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem - const path = yield* Path.Path const spawner = yield* ChildProcessSpawner.ChildProcessSpawner const outDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: 'api-extractor-tsc-' }) - const derived = path.join(outDir, DERIVED_CONFIG_NAME) - yield* fileSystem.writeFileString( - derived, - yield* derivedTsconfig({ tsconfigPath: source.tsconfigPath, packageRoot: source.packageRoot, workspaceRoot: source.workspaceRoot, outDir }), - ) + 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 From d77e2dd9477d3367fac825029620ae2b7e1c57ec Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 13 Oct 2026 19:55:28 +0000 Subject: [PATCH 29/99] fix(front-end): a mapped-type key or an infer variable shadows a declaration of the same name (U13) The resolver scoped only declared type parameters, so `{ [K in X]: K }` and `T extends Promise ? U : never` reported the binder as a reference to a non-exported K or U in the same file, a phantom ae-forgotten-export. A mapped type now binds its key over its own range, and an infer variable binds in its conditional type from the start through the true branch. The oracle feature gains a scenario where imported K and U are used by an exported function beside both binders; it matches upstream's notes, fails at 432b608 with the phantom K and U notes, and passes after. --- .../src/front-end/resolve-messages.ts | 39 +++++++++++++++++++ .../analysis-messages.integration.test.ts | 39 +++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/packages/api-extractor/src/front-end/resolve-messages.ts b/packages/api-extractor/src/front-end/resolve-messages.ts index b6e4cb0..32cf44c 100644 --- a/packages/api-extractor/src/front-end/resolve-messages.ts +++ b/packages/api-extractor/src/front-end/resolve-messages.ts @@ -6,7 +6,10 @@ import { type Program, type TSClassImplements, type TSImportType, + type TSConditionalType, + type TSInferType, type TSInterfaceHeritage, + type TSMappedType, type TSTypeName, type TSTypeParameterDeclaration, type TSTypeQuery, @@ -100,9 +103,40 @@ const openScope = (scopes: Array, node: ScopeCarrier): void const closeScope = (scopes: Array): void => void scopes.pop() +const openNamedScope = (scopes: Array, node: TSMappedType): void => + void scopes.push({ start: node.start, end: node.end, names: [node.key.name] }) + +type ConditionalScope = { + readonly start: number + readonly end: number + readonly extendsStart: number + readonly extendsEnd: number + readonly names: Array +} + +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, names: [] } + 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.names.push(node.typeParameter.name.name), + }) + const scanOf = (program: Program): ReferenceScan => { const references: Array = [] const scopes: Array = [] + const conditionals: Array = [] const record = (node: NameNode | null, start: number): void => Option.match(rootNameOf(node), { onNone: () => undefined, onSome: (name) => addReference(references, scopes, name, start) }) const enter = (node: ScopeCarrier): void => openScope(scopes, node) @@ -143,6 +177,11 @@ const scanOf = (program: Program): ReferenceScan => { 'TSFunctionType:exit': exit, TSConstructorType: enter, 'TSConstructorType:exit': exit, + 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 } } diff --git a/packages/api-extractor/tests/analysis-messages.integration.test.ts b/packages/api-extractor/tests/analysis-messages.integration.test.ts index eb907c4..e8efbe1 100644 --- a/packages/api-extractor/tests/analysis-messages.integration.test.ts +++ b/packages/api-extractor/tests/analysis-messages.integration.test.ts @@ -185,6 +185,45 @@ Feature('Review notes for a package match the ones api-extractor derives') ), ) + 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( From a4ed6fde2701ab2f87fd4b3c8e70fe9b2eaa4b70 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 13 Oct 2026 19:56:32 +0000 Subject: [PATCH 30/99] docs(front-end): rule the e5ec09d review (U13); third phase profile; mirrored node_modules learning Supersedes the 1834 plan. U13 rules every finding of the full-path review at e5ec09d: six fixed (#1, #2, #4, #5, #6, #8), the mapped-type/infer anchor-50 note fixed after reproduction, the TS2688 regression the rerun found fixed, #3 and #7 rejected with the validator's reasons, and the Diagnostic.code refusal note made a PR4 follow-up. KTD3 now describes the mirrored node_modules ancestry instead of the explicit typeRoots chain, and the policy gains isolatedDeclarations: false. docs/profiles/2026-10-13-front-end-phases: the U13 harness at 432b608 on the corpus at 732f66a0c8. 126 of 585 shared declaration files print differently between TypeScript 7 and 5.9.3 and none are missing; every package's route and outcome match the second profile. The emit-option learning gains the isolatedDeclarations case and the types-from-node_modules case, each with its gate. --- ...> 2026-10-13-1956-feat-front-ends-plan.md} | 26 +- .../profiles/2026-10-13-front-end-phases.json | 3200 +++++++++++++++++ docs/profiles/2026-10-13-front-end-phases.md | 97 + ...lback-inherits-declaration-emit-options.md | 69 +- 4 files changed, 3366 insertions(+), 26 deletions(-) rename docs/plans/{2026-10-13-1834-feat-front-ends-plan.md => 2026-10-13-1956-feat-front-ends-plan.md} (89%) create mode 100644 docs/profiles/2026-10-13-front-end-phases.json create mode 100644 docs/profiles/2026-10-13-front-end-phases.md diff --git a/docs/plans/2026-10-13-1834-feat-front-ends-plan.md b/docs/plans/2026-10-13-1956-feat-front-ends-plan.md similarity index 89% rename from docs/plans/2026-10-13-1834-feat-front-ends-plan.md rename to docs/plans/2026-10-13-1956-feat-front-ends-plan.md index 83c64b5..fc5cc1d 100644 --- a/docs/plans/2026-10-13-1834-feat-front-ends-plan.md +++ b/docs/plans/2026-10-13-1956-feat-front-ends-plan.md @@ -6,7 +6,7 @@ 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-13-1737-feat-front-ends-plan.md +supersedes: docs/plans/2026-10-13-1834-feat-front-ends-plan.md execution: code --- @@ -118,7 +118,7 @@ Product Contract preservation: Product Contract unchanged. - KTD1. **One emitter port, two service tags, adapters as parameterised 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` (the compiler's takes options); 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 into its scoped temp directory, `{ "extends": , "compilerOptions": }`, where the emit policy is one exported record (`noEmit: false`, `declaration`, `emitDeclarationOnly`, `declarationMap: false`, `sourceMap: false`, `stripInternal: 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). Two settings resolve their defaults from the config file's directory, so the derived config carries the package's own values, read once with the API's `parseConfigFile`: `files` is the package config's resolved `fileNames` (a tsconfig with no `include` otherwise matches `**/*` in the temp directory, TS18003), and `typeRoots` is the package's declared value or the default `@types` chain from the package root (otherwise TS2688 for `types: ["node"]`). Both reproduced on 7.0.2; each has an equality case in the e2e journey. + - **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`, so `tsc`'s upward `node_modules` walk 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. @@ -340,6 +340,26 @@ U1 → U2 (profile committed) → U3 (choice recorded) → U4 → U5 → U6 → - **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). +- **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. + --- ## Verification Contract @@ -362,7 +382,7 @@ No mutation run, local or in CI (origin R23). ## Definition of Done -- U1-U12 verified; every Verification Contract gate passes locally and CI is green on the exact PR head. +- U1-U13 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. 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 index 9588cd7..4f30ba5 100644 --- a/docs/solutions/logic-errors/compiler-fallback-inherits-declaration-emit-options.md +++ b/docs/solutions/logic-errors/compiler-fallback-inherits-declaration-emit-options.md @@ -47,7 +47,7 @@ one depends on the route it took. ## Solution The emit policy is one exported record, `compilerEmitPolicy` in the TypeScript 7 adapter: -`stripInternal: false`, `removeComments: false`, `declaration`, `emitDeclarationOnly`, +`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": , @@ -77,35 +77,54 @@ fallback reported `ae-missing-release-tag` for an export the fast path saw as ta 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 two settings whose defaults resolve from -the config file's directory, not the package's. Both were reproduced on TypeScript 7.0.2 with a -derived config in the 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'`. +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 therefore reads the package's config once with the TypeScript 7 API's -`parseConfigFile`. It sets `files` to the package config's resolved `fileNames`, and -`typeRoots` to the package's declared value or, when it declares none, the default chain from -the package root. Setting the default chain unconditionally would replace a package's declared -`typeRoots`. The journey's `typeRoots: ["./types"]` package fails that way. `paths` needed -nothing: a package declaring `paths: { "#lib/*": ["./lib/*"] }` compiled through the derived -config. +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. + +`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`, unless its default depends on the config file's location, in -which case the derived config carries the value the package's own config resolves to: +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 = { extends: packageTsconfig, files: packageFileNames, compilerOptions: { ...compilerEmitPolicy, typeRoots: declaredOrDefault } } +derived at mirror(dirname(packageTsconfig)) = { extends: packageTsconfig, files: packageFileNames, compilerOptions: compilerEmitPolicy } ``` ## Prevention @@ -119,13 +138,17 @@ derived = { extends: packageTsconfig, files: packageFileNames, compilerOptions: 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 whose default resolves from the config file's directory is carried into the - derived config with the value the package's config resolves to. Gate: the `typeRoots` and - no-`include` cases of the same journey; each refuses on the fallback when its value is not - carried. +- 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 `typeRoots: ["./types"]` case 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-13-1834-feat-front-ends-plan.md` (R3; U10 disposition #6; U12 emit policy) +- `docs/plans/2026-10-13-1834-feat-front-ends-plan.md` (R3; U10 disposition #6; U12 emit policy; U13 #4) - `docs/solutions/design-patterns/keyed-uniqueness-in-the-api-model-schema.md`: a law passes vacuously when its corpus lacks the triggering shape From 5831e974ad0fa9c0475fef35ad05a5204d1b11fb Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 13 Oct 2026 19:56:48 +0000 Subject: [PATCH 31/99] docs(solutions): point the emit-option learning at the 1956 plan --- .../compiler-fallback-inherits-declaration-emit-options.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 4f30ba5..6b26ff7 100644 --- a/docs/solutions/logic-errors/compiler-fallback-inherits-declaration-emit-options.md +++ b/docs/solutions/logic-errors/compiler-fallback-inherits-declaration-emit-options.md @@ -149,6 +149,6 @@ derived at mirror(dirname(packageTsconfig)) = { extends: packageTsconfig, files: ## Related -- `docs/plans/2026-10-13-1834-feat-front-ends-plan.md` (R3; U10 disposition #6; U12 emit policy; U13 #4) +- `docs/plans/2026-10-13-1956-feat-front-ends-plan.md` (R3; U10 disposition #6; U12 emit policy; U13 #4 and the mirrored `node_modules` ancestry) - `docs/solutions/design-patterns/keyed-uniqueness-in-the-api-model-schema.md`: a law passes vacuously when its corpus lacks the triggering shape From 18c81e09a6599702a1e2b335cbf795d19601ae17 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 13 Oct 2026 20:18:47 +0000 Subject: [PATCH 32/99] fix(front-end): mirror a package installed under node_modules without colliding with its own symlink (U13 delta) A package whose tsconfig lies under a node_modules directory made the mirror create /node_modules as a real directory before the symlink for that ancestor, so the fallback failed with AlreadyExists. A mirrored node_modules segment now gets its own name, and an ancestor named node_modules gets no symlink, as tsc never looks for node_modules inside one. e2e: the equality journey gains a package installed under node_modules whose tsconfig names a global type from a sibling dependency. It fails at 5831e97 with AlreadyExists and passes after. README: the file rule covers diagnostics the compiler reported; an entry-point refusal names the entry's recorded source. --- README.md | 5 ++-- .../tests/front-ends.integration.test.ts | 23 ++++++++++++++++++- .../src/front-end/typescript-7-compiler.ts | 17 +++++++++++--- 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index c7d293e..77800bf 100644 --- a/README.md +++ b/README.md @@ -79,8 +79,9 @@ policy (`FrontEnd.compilerEmitPolicy`), and `route` is `Fallback` with the diagn sent it there. A `tsc` that fails refuses the package with `CompilerRejected`, whose diagnostics come from the TypeScript 7 API, never from `tsc`'s printed output, which a package's own file names and import specifiers can spell. A diagnostic no compiler reported -(a missing entry, say) carries no TypeScript code, and a diagnostic names a `file` only when -that file is inside the package. +(a missing entry, say) carries no TypeScript code, and a diagnostic the compiler reported +names a `file` only when that file is inside the package. A refusal of an entry point names +the entry's recorded source, which may lie outside the package. Both routes meet at declaration text: one syntactic builder makes the model and one resolver reports `ae-forgotten-export`, `ae-missing-release-tag`, `ae-internal-missing-underscore`, `ae-incompatible-release-tags` and diff --git a/apps/api-extractor-e2e/tests/front-ends.integration.test.ts b/apps/api-extractor-e2e/tests/front-ends.integration.test.ts index c830894..73eef8c 100644 --- a/apps/api-extractor-e2e/tests/front-ends.integration.test.ts +++ b/apps/api-extractor-e2e/tests/front-ends.integration.test.ts @@ -85,6 +85,23 @@ const nodeModulesTypesPackage = fallingBackPackage( { ...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' }, @@ -200,7 +217,7 @@ Feature('Both front-ends reduce a package to one model') .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', + '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, and wherever the package is installed', 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), @@ -208,6 +225,7 @@ Feature('Both front-ends reduce a package to one model') 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), When('each is analysed through the isolated emitter and through the compiler')('analyses', (scope) => Effect.all({ @@ -217,6 +235,7 @@ Feature('Both front-ends reduce a package to one model') declarationDir: throughBothRoutes(scope.declarationDir), typeRoots: throughBothRoutes(scope.typeRoots), nodeModulesTypes: throughBothRoutes(scope.nodeModulesTypes), + underNodeModules: throughBothRoutes(scope.underNodeModules), defaultInclude: throughBothRoutes(scope.defaultInclude), wroteIntoPackage: writtenInto(scope.declarationDir, 'types'), }) @@ -231,6 +250,7 @@ Feature('Both front-ends reduce a package to one model') 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, wroteIntoPackage: scope.analyses.wroteIntoPackage, }).toEqual({ @@ -242,6 +262,7 @@ Feature('Both front-ends reduce a package to one model') 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, wroteIntoPackage: false, }) diff --git a/packages/api-extractor/src/front-end/typescript-7-compiler.ts b/packages/api-extractor/src/front-end/typescript-7-compiler.ts index 3a14437..c958aac 100644 --- a/packages/api-extractor/src/front-end/typescript-7-compiler.ts +++ b/packages/api-extractor/src/front-end/typescript-7-compiler.ts @@ -66,17 +66,28 @@ const ancestorsOf = (path: Path.Path, directory: string): ReadonlyArray 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)) + 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') + const nodeModules = path.join(directory, NODE_MODULES) yield* fileSystem.makeDirectory(mirrored, { recursive: true }) - yield* Effect.when(fileSystem.symlink(nodeModules, path.join(mirrored, 'node_modules')), fileSystem.exists(nodeModules)) + 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>> => From a98496e37468e7edebed5673423ad9bc2bf20fff Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 13 Oct 2026 20:20:00 +0000 Subject: [PATCH 33/99] docs(front-end): rule the U13 delta review; record the node_modules mirror exception Supersedes the 1956 plan. U13 gains the DeepSeek delta rulings over e5ec09d..5831e97: the under-node_modules mirror collision is fixed in 18c81e0, the README overstatement narrowed there too, and four residuals are recorded. KTD3 states the mirror's node_modules exception. The emit-option learning records the exception and its gate. --- ...md => 2026-10-13-2019-feat-front-ends-plan.md} | 8 ++++++-- ...-fallback-inherits-declaration-emit-options.md | 15 ++++++++++++--- 2 files changed, 18 insertions(+), 5 deletions(-) rename docs/plans/{2026-10-13-1956-feat-front-ends-plan.md => 2026-10-13-2019-feat-front-ends-plan.md} (96%) diff --git a/docs/plans/2026-10-13-1956-feat-front-ends-plan.md b/docs/plans/2026-10-13-2019-feat-front-ends-plan.md similarity index 96% rename from docs/plans/2026-10-13-1956-feat-front-ends-plan.md rename to docs/plans/2026-10-13-2019-feat-front-ends-plan.md index fc5cc1d..6f30df6 100644 --- a/docs/plans/2026-10-13-1956-feat-front-ends-plan.md +++ b/docs/plans/2026-10-13-2019-feat-front-ends-plan.md @@ -6,7 +6,7 @@ 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-13-1834-feat-front-ends-plan.md +supersedes: docs/plans/2026-10-13-1956-feat-front-ends-plan.md execution: code --- @@ -118,7 +118,7 @@ Product Contract preservation: Product Contract unchanged. - KTD1. **One emitter port, two service tags, adapters as parameterised 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` (the compiler's takes options); 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`, so `tsc`'s upward `node_modules` walk 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. + - **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. @@ -355,6 +355,10 @@ U1 → U2 (profile committed) → U3 (choice recorded) → U4 → U5 → U6 → - **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). 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 index 6b26ff7..0893785 100644 --- a/docs/solutions/logic-errors/compiler-fallback-inherits-declaration-emit-options.md +++ b/docs/solutions/logic-errors/compiler-fallback-inherits-declaration-emit-options.md @@ -105,6 +105,13 @@ instead. It did not cover `types` packages, and with it in place the mirror stil 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 @@ -141,14 +148,16 @@ derived at mirror(dirname(packageTsconfig)) = { extends: packageTsconfig, files: - 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 `typeRoots: ["./types"]` case when a default chain - replaces the declared one, and the no-`include` case when `files` is not carried. + 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-13-1956-feat-front-ends-plan.md` (R3; U10 disposition #6; U12 emit policy; U13 #4 and the mirrored `node_modules` ancestry) +- `docs/plans/2026-10-13-2019-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 From a740661683082047fce8f951c372ec2aaed866a4 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 13 Oct 2026 21:34:44 +0000 Subject: [PATCH 34/99] docs(plan): rule every finding of the a98496e review and plan the main merge (U14) Supersedes the 2019 plan. Maps the 13:15 decision to its U12 commits, rules the a98496e findings, the anchor-50 notes, the export-set evidence and both open pack rows, and plans the plain merge of main at 942ac81. --- ...> 2026-10-13-2114-feat-front-ends-plan.md} | 35 +++++++++++++++++-- ...lback-inherits-declaration-emit-options.md | 2 +- 2 files changed, 33 insertions(+), 4 deletions(-) rename docs/plans/{2026-10-13-2019-feat-front-ends-plan.md => 2026-10-13-2114-feat-front-ends-plan.md} (89%) diff --git a/docs/plans/2026-10-13-2019-feat-front-ends-plan.md b/docs/plans/2026-10-13-2114-feat-front-ends-plan.md similarity index 89% rename from docs/plans/2026-10-13-2019-feat-front-ends-plan.md rename to docs/plans/2026-10-13-2114-feat-front-ends-plan.md index 6f30df6..4435083 100644 --- a/docs/plans/2026-10-13-2019-feat-front-ends-plan.md +++ b/docs/plans/2026-10-13-2114-feat-front-ends-plan.md @@ -6,7 +6,7 @@ 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-13-1956-feat-front-ends-plan.md +supersedes: docs/plans/2026-10-13-2019-feat-front-ends-plan.md execution: code --- @@ -321,7 +321,7 @@ U1 → U2 (profile committed) → U3 (choice recorded) → U4 → U5 → U6 → - 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 message's entry point and item. + - 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. @@ -364,6 +364,35 @@ U1 → U2 (profile committed) → U3 (choice recorded) → U4 → U5 → U6 → - **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. + --- ## Verification Contract @@ -386,7 +415,7 @@ No mutation run, local or in CI (origin R23). ## Definition of Done -- U1-U13 verified; every Verification Contract gate passes locally and CI is green on the exact PR head. +- U1-U14 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. 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 index 0893785..65b38b3 100644 --- a/docs/solutions/logic-errors/compiler-fallback-inherits-declaration-emit-options.md +++ b/docs/solutions/logic-errors/compiler-fallback-inherits-declaration-emit-options.md @@ -158,6 +158,6 @@ derived at mirror(dirname(packageTsconfig)) = { extends: packageTsconfig, files: ## Related -- `docs/plans/2026-10-13-2019-feat-front-ends-plan.md` (R3; U10 disposition #6; U12 emit policy; U13 #4, the mirrored `node_modules` ancestry and its delta ruling) +- `docs/plans/2026-10-13-2114-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 From 44f2be050ab58263c44d50b0e6e793e1f7731f5d Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 13 Oct 2026 21:38:40 +0000 Subject: [PATCH 35/99] fix(front-end): read a hand-written declaration file a source imports, on both routes (U14 E8, E7) tsc emits nothing for a .d.ts input, so a source importing a hand-written shape.d.ts was declined by the fast path and refused by the fallback. Both emitters now read an in-package declaration file verbatim, after the .ts and .tsx candidates as tsc resolves them. The equality journey gains that package; it fails at a740661 with NotIsolated. The TS9010 routing scenario asserts the fallback's TYPESCRIPT_PRODUCER stamp (E7). --- .../tests/front-ends.integration.test.ts | 27 ++++++++++++-- .../src/front-end/oxc-isolated-emitter.ts | 36 ++++--------------- .../src/front-end/package-file.ts | 30 ++++++++++++++++ .../src/front-end/typescript-7-compiler.ts | 21 ++++++----- 4 files changed, 73 insertions(+), 41 deletions(-) create mode 100644 packages/api-extractor/src/front-end/package-file.ts diff --git a/apps/api-extractor-e2e/tests/front-ends.integration.test.ts b/apps/api-extractor-e2e/tests/front-ends.integration.test.ts index 73eef8c..957f6a1 100644 --- a/apps/api-extractor-e2e/tests/front-ends.integration.test.ts +++ b/apps/api-extractor-e2e/tests/front-ends.integration.test.ts @@ -109,6 +109,16 @@ const defaultIncludePackage = fallingBackPackage( { 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 forgedCodesPackage = packageWith( 'forged-codes', [ @@ -172,10 +182,15 @@ const refusalOf = (error: Refused) => { } } -const routedAs = (analysis: FrontEnd.Analysis) => ({ route: analysis.route, items: analysis.model.entryPoints.flatMap((entry) => entry.items) }) +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: [{ 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' } } }], } @@ -217,7 +232,7 @@ Feature('Both front-ends reduce a package to one model') .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, and wherever the package is installed', + '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 imports 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), @@ -227,6 +242,7 @@ Feature('Both front-ends reduce a package to one model') 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), When('each is analysed through the isolated emitter and through the compiler')('analyses', (scope) => Effect.all({ ownPackage: throughBothRoutes(scope.source), @@ -237,6 +253,7 @@ Feature('Both front-ends reduce a package to one model') nodeModulesTypes: throughBothRoutes(scope.nodeModulesTypes), underNodeModules: throughBothRoutes(scope.underNodeModules), defaultInclude: throughBothRoutes(scope.defaultInclude), + handWritten: throughBothRoutes(scope.handWritten), wroteIntoPackage: writtenInto(scope.declarationDir, 'types'), }) ), @@ -252,6 +269,8 @@ Feature('Both front-ends reduce a package to one model') 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, wroteIntoPackage: scope.analyses.wroteIntoPackage, }).toEqual({ ownPackage: scope.analyses.ownPackage.isolated, @@ -264,6 +283,8 @@ Feature('Both front-ends reduce a package to one model') 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'], wroteIntoPackage: false, }) ), @@ -279,7 +300,7 @@ Feature('Both front-ends reduce a package to one model') 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` and modelled `ids` as a string array')((scope, expect) => + 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, diff --git a/packages/api-extractor/src/front-end/oxc-isolated-emitter.ts b/packages/api-extractor/src/front-end/oxc-isolated-emitter.ts index bd83c4c..eb927a4 100644 --- a/packages/api-extractor/src/front-end/oxc-isolated-emitter.ts +++ b/packages/api-extractor/src/front-end/oxc-isolated-emitter.ts @@ -1,11 +1,12 @@ -import { Array as Arr, Effect, FileSystem, HashMap, HashSet, Layer, Match, Option, Order, Path, Ref, Result } from 'effect' +import { Array as Arr, Effect, FileSystem, HashMap, HashSet, Layer, 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, relativeWithin, sourceCandidatesOf, type ClosureResult, type EntryDeclaration, type UnresolvedImport } from './declaration-graph.ts' +import { closeDeclarations, declarationPathOf, entryPointDeclarations, missingEntryDeclarations, sourceCandidatesOf, type ClosureResult, type EntryDeclaration, 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}` @@ -23,37 +24,12 @@ const diagnosticOrder: Order.Order = Order.combineAll([ Order.mapInput(Order.String, (diagnostic: Diagnostic) => diagnostic.message), ]) -const isAbsent = (error: PlatformError): boolean => - Match.value(error.reason).pipe( - Match.tag('NotFound', 'BadResource', () => true), - Match.orElse(() => false), - ) - type Emission = { readonly source: PackageSource readonly realRoot: string readonly diagnostics: Ref.Ref> } -const readSource = (emission: Emission, sourceFile: string) => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem - const path = yield* Path.Path - const real = yield* Effect.option(fileSystem.realPath(path.resolve(emission.source.packageRoot, sourceFile))) - return yield* Option.match(real, { - onNone: () => Effect.succeedNone, - onSome: (realFile) => - Option.match(relativeWithin(path, emission.realRoot, realFile), { - onNone: () => Effect.succeedNone, - onSome: () => - fileSystem.readFileString(realFile).pipe( - Effect.asSome, - Effect.catchIf(isAbsent, () => Effect.succeedNone), - ), - }), - }) - }) - const declare = (emission: Emission, sourceFile: string, text: string) => Effect.gen(function* () { const result = isolatedDeclarationSync(sourceFile, text, { stripInternal: false, sourcemap: false }) @@ -68,10 +44,10 @@ const firstSource = ( Arr.match(candidates, { onEmpty: () => Effect.succeedNone, onNonEmpty: ([sourceFile, ...rest]) => - Effect.flatMap(readSource(emission, sourceFile), (text) => + Effect.flatMap(readPackageFile({ packageRoot: emission.source.packageRoot, realRoot: emission.realRoot, file: sourceFile }), (text) => Option.match(text, { onNone: () => firstSource(emission, rest), - onSome: (value) => Effect.asSome(declare(emission, sourceFile, value)), + onSome: (value) => (isDeclarationFile(sourceFile) ? Effect.succeedSome(value) : Effect.asSome(declare(emission, sourceFile, value))), })), }) @@ -126,7 +102,7 @@ const sharedDeclarationDiagnostics = (entryPoints: ReadonlyArray) => (declarationFile: string): ReadonlyArray => Option.match(HashMap.get(recorded, declarationFile), { - onNone: () => sourceCandidatesOf(declarationFile), + onNone: () => [...sourceCandidatesOf(declarationFile), declarationFile], onSome: (sourceFile) => [sourceFile], }) 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..421f631 --- /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) + +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/typescript-7-compiler.ts b/packages/api-extractor/src/front-end/typescript-7-compiler.ts index c958aac..bd965d8 100644 --- a/packages/api-extractor/src/front-end/typescript-7-compiler.ts +++ b/packages/api-extractor/src/front-end/typescript-7-compiler.ts @@ -7,6 +7,7 @@ import { closeDeclarations, entryPointDeclarations, missingEntryDeclarations, re 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}` @@ -16,8 +17,6 @@ const TSC_SCRIPT: string = typescriptManifest.bin.tsc const DERIVED_CONFIG_NAME = 'tsconfig.json' -const DECLARATION_FILE = /\.d\.[cm]?ts$/ - const KEY_SEPARATOR = '\u0000' const ENTRY_SEPARATOR = ' and ' @@ -247,7 +246,7 @@ const emittedDeclarationFiles = (path: Path.Path, fileSystem: FileSystem.FileSys Effect.map( fileSystem.readDirectory(outDir, { recursive: true }), (names): ReadonlyArray => - Arr.getSomes(names.map((name) => path.join(outDir, name)).filter((file) => DECLARATION_FILE.test(file)).map((file) => packageDeclarationOf(path, declaredRoot, file))), + Arr.getSomes(names.map((name) => path.join(outDir, name)).filter(isDeclarationFile).map((file) => packageDeclarationOf(path, declaredRoot, file))), ) const missingEntryDiagnostic = (declarationFile: string): Diagnostic => ({ @@ -258,19 +257,25 @@ const missingEntryDiagnostic = (declarationFile: string): Diagnostic => ({ const unsupportedImport = (unresolved: UnresolvedImport): UnsupportedDeclaration => new UnsupportedDeclaration({ file: unresolved.file, construct: `the import \`${unresolved.specifier}\` names no declaration file` }) +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, - emitted: HashMap.HashMap, -): Effect.Effect => + load: (declarationFile: string) => Effect.Effect, PlatformError, FileSystem.FileSystem | Path.Path>, +): Effect.Effect => Effect.flatMap( closeDeclarations({ entryDeclarations: entryPoints.map((entry) => entry.declarationFile), - load: (declarationFile) => Effect.succeed(HashMap.get(emitted, declarationFile)), + load, }), (report) => Arr.match(report.unresolvedImports, { - onEmpty: (): Effect.Effect => + onEmpty: (): Effect.Effect => Effect.succeed({ producer: TYPESCRIPT_PRODUCER, package: { name: source.name, version: source.version }, @@ -294,7 +299,7 @@ const declarationsFrom = (source: PackageSource, run: CompilerRun) => ) const entryPoints = entryPointDeclarations(source.entryPoints) return yield* Arr.match(missingEntryDeclarations(HashSet.fromIterable(HashMap.keys(emitted)), entryPoints.map((entry) => entry.declarationFile)), { - onEmpty: () => declarationsOf(source, entryPoints, emitted), + 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) })), }) }) From e882d6d21873820b92d398c257b0d99044bfa1bc Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 13 Oct 2026 21:39:34 +0000 Subject: [PATCH 36/99] fix(front-end): read release tags with the TSDoc parser upstream uses (U14 E3) A tag word inside a fenced code sample or an inline code span set the release tag, so the resolver noted an untagged function as @internal or tagged. @microsoft/tsdoc 0.16.0, the version api-extractor 7.59.1 resolves, now reads the modifier tags, in upstream's precedence. The oracle feature gains the fence and code-span case; it fails at a740661. --- packages/api-extractor/package.json | 1 + .../src/front-end/resolve-messages.ts | 19 ++++++++---- .../analysis-messages.integration.test.ts | 29 +++++++++++++++++++ pnpm-lock.yaml | 6 ++++ pnpm-workspace.yaml | 1 + 5 files changed, 51 insertions(+), 5 deletions(-) diff --git a/packages/api-extractor/package.json b/packages/api-extractor/package.json index 90f030c..e6a2756 100644 --- a/packages/api-extractor/package.json +++ b/packages/api-extractor/package.json @@ -32,6 +32,7 @@ "mutation": "stryker run" }, "dependencies": { + "@microsoft/tsdoc": "catalog:", "@systemfsoftware/effect-cell-types": "^12.0.0", "effect": "catalog:", "oxc-parser": "catalog:", diff --git a/packages/api-extractor/src/front-end/resolve-messages.ts b/packages/api-extractor/src/front-end/resolve-messages.ts index 32cf44c..6af361f 100644 --- a/packages/api-extractor/src/front-end/resolve-messages.ts +++ b/packages/api-extractor/src/front-end/resolve-messages.ts @@ -1,3 +1,4 @@ +import { type StandardModifierTagSet, TSDocParser } from '@microsoft/tsdoc' import { Array as Arr, Effect, HashMap, HashSet, Match, Option, Order, Predicate } from 'effect' import { type Class, @@ -25,14 +26,22 @@ 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 RELEASE_TAG_TEXT = /@(public|beta|alpha|internal)\b/ -const RELEASE_TAG_NAMES: HashSet.HashSet = HashSet.make('public', 'beta', 'alpha', 'internal') -const isReleaseTag = (name: string): name is ReleaseTag => HashSet.has(RELEASE_TAG_NAMES, name) +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 => - Option.flatMap(Option.fromNullishOr(RELEASE_TAG_TEXT.exec(comment)), (match) => - Option.flatMap(Option.fromNullishOr(match[1]), (name) => Option.liftPredicate(name, isReleaseTag))) + tagIn(releaseTagParser.parseString(docCommentText(comment)).docComment.modifierTagSet) const isDocComment = (comment: Comment): boolean => comment.type === 'Block' && comment.value.startsWith('*') diff --git a/packages/api-extractor/tests/analysis-messages.integration.test.ts b/packages/api-extractor/tests/analysis-messages.integration.test.ts index e8efbe1..161fcec 100644 --- a/packages/api-extractor/tests/analysis-messages.integration.test.ts +++ b/packages/api-extractor/tests/analysis-messages.integration.test.ts @@ -98,6 +98,35 @@ Feature('Review notes for a package match the ones api-extractor derives') ), ) + 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( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cecad9f..2b394a5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -173,6 +173,9 @@ catalogs: '@microsoft/api-extractor': specifier: 7.59.1 version: 7.59.1 + '@microsoft/tsdoc': + specifier: 0.16.0 + version: 0.16.0 '@types/node': specifier: 24.19.1 version: 24.19.1 @@ -344,6 +347,9 @@ importers: packages/api-extractor: dependencies: + '@microsoft/tsdoc': + specifier: 'catalog:' + version: 0.16.0 '@systemfsoftware/effect-cell-types': specifier: file:../../.sfs-deps/effect-cell-types-12.0.0.tgz version: file:.sfs-deps/effect-cell-types-12.0.0.tgz(effect@4.0.2) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index bc6b5ac..0e04431 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -9,6 +9,7 @@ catalog: "@effect/platform-node": 4.0.2 "@effect/tsgo": 0.50.0 "@microsoft/api-extractor": 7.59.1 + "@microsoft/tsdoc": 0.16.0 "@types/node": 24.19.1 effect: 4.0.2 oxlint: 1.82.0 From a0d13d1f201361f2516b3c840f16d0daeb6f0fb0 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 13 Oct 2026 21:41:56 +0000 Subject: [PATCH 37/99] fix(front-end): refuse an undecodable manifest with ManifestRejected; decode export targets into a tagged union (U14 E6, E9) readPackage leaked Schema.SchemaError into its public error channel; an undecodable package.json is now refused with ManifestRejected naming the package root. Reader scenario; at a740661 both cases fail with SchemaError. Node's export-target shape stays the Encoded side and decodes with Schema.decodeTo into Path, Conditions, Fallbacks or Excluded; the reader matches the decoded union only (pack: schema-laws, rich-type-over-foreign-encoded). Existing reader scenarios are unchanged. --- README.md | 2 + packages/api-extractor/src/FrontEnd.ts | 2 +- .../src/front-end/front-end-error.schema.ts | 11 +++ .../src/front-end/package-source.schema.ts | 99 ++++++++++++++++--- .../src/front-end/read-package.ts | 29 ++++-- .../tests/package-reader.integration.test.ts | 30 ++++++ 6 files changed, 151 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 77800bf..3c254f2 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,8 @@ process.stdout.write(ApiModel.serialise(analysis.model)) `.ts`, `.tsx`, `.mts` or `.cts` file as an entry point. A subpath pattern (`"./*"`) expands to one entry per matching source under Node's `exports` rules, and an explicit subpath wins over a pattern. A package with no such export is refused with `NoSourceEntry`. +A package whose `package.json` does not decode — invalid JSON, or a manifest the schema +refuses — is refused with `ManifestRejected`, naming the package root. `analyse` first emits declarations with `oxc-transform`'s isolated-declarations emitter, which never type-checks. If any source an entry point reaches needs the checker (an unannotated export, TS9xxx), or an entry cannot be proven from its own file (its source is diff --git a/packages/api-extractor/src/FrontEnd.ts b/packages/api-extractor/src/FrontEnd.ts index 3ddf10f..0c987d8 100644 --- a/packages/api-extractor/src/FrontEnd.ts +++ b/packages/api-extractor/src/FrontEnd.ts @@ -9,7 +9,7 @@ export { type IsolatedEmitterId, } from './front-end/declaration-emitter.service.ts' export { type DeclarationFile, Declarations, Diagnostic, type PackageSource, type SourceEntryPoint } from './front-end/declarations.schema.ts' -export { CompilerRejected, NoSourceEntry, NotIsolated, UnsupportedDeclaration } from './front-end/front-end-error.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' diff --git a/packages/api-extractor/src/front-end/front-end-error.schema.ts b/packages/api-extractor/src/front-end/front-end-error.schema.ts index c3428b7..bea8741 100644 --- a/packages/api-extractor/src/front-end/front-end-error.schema.ts +++ b/packages/api-extractor/src/front-end/front-end-error.schema.ts @@ -19,6 +19,17 @@ export class NoSourceEntry extends NoSourceEntryBase { } } +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) diff --git a/packages/api-extractor/src/front-end/package-source.schema.ts b/packages/api-extractor/src/front-end/package-source.schema.ts index 259bf81..8c97d84 100644 --- a/packages/api-extractor/src/front-end/package-source.schema.ts +++ b/packages/api-extractor/src/front-end/package-source.schema.ts @@ -1,25 +1,100 @@ -import { Schema } from 'effect' +import { Match, Predicate, Schema, SchemaTransformation } from 'effect' -export const ConditionMap: Schema.$Record> = Schema.Record(Schema.String, Schema.Json) -export type ConditionMap = typeof ConditionMap.Type +type EncodedConditions = { readonly [condition: string]: EncodedExportTarget } -export const ExportTarget: Schema.Union>, typeof ConditionMap]> = Schema.Union([ - Schema.String, - Schema.Null, - Schema.Array(Schema.Json), - ConditionMap, -]) -export type ExportTarget = typeof ExportTarget.Type +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]>> + 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, ExportTarget)])), + 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 index 29b1b17..110426c 100644 --- a/packages/api-extractor/src/front-end/read-package.ts +++ b/packages/api-extractor/src/front-end/read-package.ts @@ -2,8 +2,8 @@ import { Array as Arr, Effect, FileSystem, HashMap, HashSet, Match, Option, Orde import type { PlatformError } from 'effect/PlatformError' import type { PackageSource, SourceEntryPoint } from './declarations.schema.ts' import { relativeWithin } from './declaration-graph.ts' -import { NoSourceEntry } from './front-end-error.schema.ts' -import { ConditionMap, type ExportTarget, PackageManifest } from './package-source.schema.ts' +import { ManifestRejected, NoSourceEntry } from './front-end-error.schema.ts' +import { type ExportTarget, PackageManifest } from './package-source.schema.ts' export const FAMILY_SOURCE_CONDITION = '@systemfsoftware/source' @@ -18,14 +18,19 @@ const TYPESCRIPT_SOURCE = /\.(?:ts|tsx|mts|cts)$/ const isTypeScriptSource = (value: string): boolean => TYPESCRIPT_SOURCE.test(value) -const isString = (value: unknown): value is string => typeof value === 'string' - const stripRelativePrefix = (value: string): string => value.replace(/^\.\//, '') -const isConditionMap = Schema.is(ConditionMap) +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 => - Option.flatMap(Option.liftPredicate(target, isConditionMap), (conditions) => Option.liftPredicate(conditions[condition], isString)) + 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 => @@ -37,7 +42,11 @@ const entryPointOf = (condition: string) => const byExportPath: Order.Order = Order.mapInput(Order.String, (entry: SourceEntryPoint) => entry.exportPath) const exportMapOf = (exports: PackageManifest['exports']): ReadonlyArray => - typeof exports === 'object' ? Object.entries(exports).filter(([exportPath]) => exportPath !== './package.json') : [] + Match.value(exports).pipe( + Match.when(Match.string, () => []), + Match.when(Match.undefined, () => []), + Match.orElse((record) => Object.entries(record).filter(([exportPath]) => exportPath !== './package.json')), + ) const isPatternKey = (exportPath: string): boolean => exportPath.includes('*') @@ -179,12 +188,14 @@ const sourceOf = (root: string, manifest: PackageManifest, entryPoints: Arr.NonE return source }) -export const readPackage = (request: ReadPackageRequest): Effect.Effect => +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'))) + 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 })), diff --git a/packages/api-extractor/tests/package-reader.integration.test.ts b/packages/api-extractor/tests/package-reader.integration.test.ts index 8889078..243e2f8 100644 --- a/packages/api-extractor/tests/package-reader.integration.test.ts +++ b/packages/api-extractor/tests/package-reader.integration.test.ts @@ -30,6 +30,17 @@ const packageOf = (exports: object, sources: Record) => 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' }, @@ -160,4 +171,23 @@ Feature("A package's exports name its entry points") ), ), ) + + 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 })), + ), + When('the package reader reads both')('refusals', (scope) => + Effect.all({ invalidJson: refusalOfReading(scope.invalidJson), refusedManifest: refusalOfReading(scope.refusedManifest) }), + ), + Then('each is refused as a rejected manifest, naming its own package root')((scope, expect) => + expect(scope.refusals).toMatchObject({ + invalidJson: { _tag: 'ManifestRejected', packageRoot: scope.invalidJson }, + refusedManifest: { _tag: 'ManifestRejected', packageRoot: scope.refusedManifest }, + }), + ), + ), + ) }) From f91e8c3fd60ccc06c80c2b482c5509a8171841df Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 13 Oct 2026 21:50:10 +0000 Subject: [PATCH 38/99] fix(front-end): model an anonymous default and follow tsc's implicit exports (U14 E1, E2, E4, E5) An anonymous export default function or class was dropped from the model on both routes; it is now the default item its named spelling gives (E1). A module declaration file's other declarations are offered only when it has no export declaration and no export assignment, and the local name of export default class|function|interface X is never offered, as tsc 7.0.2 resolves imports (TS2614, TS2460, TS2459). Ruling #4 stands: export {} still stops them (E2). Builder scenarios for each shape fail at a740661. A builder scenario covers index, call and construct signatures (E4). TypeExprs drops two members no caller reads (E5). --- .../src/front-end/declaration-graph.ts | 41 ++-- .../src/front-end/type-expr-of.ts | 8 - .../tests/front-end-model.integration.test.ts | 190 ++++++++++++++++++ 3 files changed, 217 insertions(+), 22 deletions(-) diff --git a/packages/api-extractor/src/front-end/declaration-graph.ts b/packages/api-extractor/src/front-end/declaration-graph.ts index b49e604..21c2953 100644 --- a/packages/api-extractor/src/front-end/declaration-graph.ts +++ b/packages/api-extractor/src/front-end/declaration-graph.ts @@ -297,8 +297,9 @@ const EMPTY_PARTS: ScopeParts = { declarations: [], imports: [], exports: [], st type DeclaredResult = Effect.Effect, UnsupportedDeclaration> -const named = (id: { readonly name: string } | null, declared: (name: string) => A): ReadonlyArray => - id === null ? [] : [declared(id.name)] +const DEFAULT_EXPORT_NAME = 'default' + +const declarationName = (id: { readonly name: string } | null): string => (id === null ? DEFAULT_EXPORT_NAME : id.name) const variablesOf = (node: VariableDeclaration, file: string): DeclaredResult => Effect.forEach(node.declarations, (declarator) => @@ -317,8 +318,9 @@ const declaredOf = (node: DeclarationNode | Statement | ExportDefaultDeclaration Match.value(node).pipe( Match.when({ type: 'VariableDeclaration' }, (variable) => variablesOf(variable, file)), Match.when({ type: Match.is('FunctionDeclaration', 'TSDeclareFunction') }, (fn: FunctionNode) => - Effect.succeed(named(fn.id, (name) => ({ kind: 'function', node: fn, name })))), - Match.when({ type: 'ClassDeclaration' }, (cls: Class) => Effect.succeed(named(cls.id, (name) => ({ kind: 'class', node: cls, name })))), + Effect.succeed>([{ kind: 'function', node: fn, name: declarationName(fn.id) }])), + Match.when({ type: 'ClassDeclaration' }, (cls: Class) => + Effect.succeed>([{ kind: 'class', node: cls, name: declarationName(cls.id) }])), Match.when({ type: 'TSInterfaceDeclaration' }, (iface) => Effect.succeed>([{ kind: 'interface', node: iface, name: iface.id.name }])), Match.when({ type: 'TSTypeAliasDeclaration' }, (alias) => Effect.succeed>([{ kind: 'typeAlias', node: alias, name: alias.id.name }])), Match.when({ type: 'TSEnumDeclaration' }, (enumeration) => @@ -338,7 +340,7 @@ const importedNameOf = (specifier: ImportDeclarationSpecifier): string => Match.discriminatorsExhaustive('type')({ ImportSpecifier: (named) => moduleNameOf(named.imported), ImportNamespaceSpecifier: () => '*', - ImportDefaultSpecifier: () => 'default', + ImportDefaultSpecifier: () => DEFAULT_EXPORT_NAME, }), ) @@ -367,12 +369,12 @@ const exportNamedParts = (statement: ExportNamedDeclaration, file: string): Effe const defaultExportOf = (declared: ReadonlyArray, statementStart: number): ScopeParts => Option.match(Arr.head(declared), { onNone: () => EMPTY_PARTS, - onSome: (first) => ({ ...declarationsPart(declared, statementStart), exports: [['default', { kind: 'local', local: first.name }]] }), + 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', { kind: 'local', local: identifier.name }]] })), + Match.when({ type: 'Identifier' }, (identifier) => Effect.succeed({ ...EMPTY_PARTS, exports: [[DEFAULT_EXPORT_NAME, { kind: 'local', local: identifier.name }]] })), Match.whenOr( { type: 'FunctionDeclaration' }, { type: 'TSDeclareFunction' }, @@ -406,18 +408,29 @@ const grouped = (entries: ReadonlyArray): HashMap.HashM Arr.reduce(entries, HashMap.empty>(), (groups, [key, value]) => HashMap.set(groups, key, [...Option.getOrElse(HashMap.get(groups, key), (): ReadonlyArray => []), value])) -const isEmptyExportList = (node: ExportNamedDeclaration): boolean => node.declaration === null && node.specifiers.length === 0 +const declaresDefault = (node: ExportDefaultDeclaration): boolean => + Match.value(node.declaration).pipe( + Match.when({ type: Match.is('FunctionDeclaration', 'TSDeclareFunction', 'ClassDeclaration', 'TSInterfaceDeclaration') }, () => true), + Match.orElse(() => false), + ) -const isExportBlocker = (statement: Directive | Statement): boolean => +const stopsImplicitExports = (statement: Directive | Statement): boolean => Match.value(statement).pipe( - Match.when({ type: 'ExportNamedDeclaration' }, isEmptyExportList), + 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(isExportBlocker) - ? [] - : parts.flatMap((part) => part.declarations.map(([name]) => [name, { kind: 'local', local: name }] as const)) + program.body.some(stopsImplicitExports) ? [] : parts.flatMap(implicitNames) const scopeOf = (file: DeclarationFile): Effect.Effect => Effect.gen(function* () { @@ -483,7 +496,7 @@ const boundExport = (graph: DeclarationGraph, scope: FileScope, binding: ExportB : 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' + name === DEFAULT_EXPORT_NAME ? Option.none() : Arr.findFirst(scope.starExports, (specifier) => Option.flatMap(resolveFile(graph, scope.path, specifier), (target) => exportedFrom(graph, target, name, seen))) diff --git a/packages/api-extractor/src/front-end/type-expr-of.ts b/packages/api-extractor/src/front-end/type-expr-of.ts index 832b85e..7166152 100644 --- a/packages/api-extractor/src/front-end/type-expr-of.ts +++ b/packages/api-extractor/src/front-end/type-expr-of.ts @@ -484,11 +484,6 @@ const literalExpr = (context: TypeContext, node: TSLiteralType): Effect.Effect Effect.Effect - readonly annotationTypeOrDefault: ( - context: TypeContext, - annotation: TSTypeAnnotation | null | undefined, - fallback: KeywordName, - ) => Effect.Effect readonly typeParametersOf: ( context: TypeContext, declaration: TSTypeParameterDeclaration | null | undefined, @@ -499,7 +494,6 @@ export const TypeExprs: { params: ReadonlyArray, returnType: TSTypeAnnotation | null | undefined, ) => Effect.Effect - readonly parametersOf: (context: TypeContext, params: ReadonlyArray) => Effect.Effect, UnsupportedDeclaration> readonly referenceToExpression: ( context: TypeContext, expression: Expression, @@ -510,10 +504,8 @@ export const TypeExprs: { readonly classMembersOf: (context: TypeContext, elements: ReadonlyArray) => Effect.Effect, UnsupportedDeclaration> } = { typeExprOf: typeExprOf, - annotationTypeOrDefault: annotationTypeOrDefault, typeParametersOf: typeParametersOf, signatureOf: signatureOf, - parametersOf: parametersOf, referenceToExpression: referenceToExpression, heritageOf: heritageOf, membersOf: membersOf, diff --git a/packages/api-extractor/tests/front-end-model.integration.test.ts b/packages/api-extractor/tests/front-end-model.integration.test.ts index 034bc58..6c20687 100644 --- a/packages/api-extractor/tests/front-end-model.integration.test.ts +++ b/packages/api-extractor/tests/front-end-model.integration.test.ts @@ -28,6 +28,8 @@ const itemOf = (model: Model, path: string, name: string, kind: Item['kind']): I const itemsNamed = (model: Model, path: string, name: string): ReadonlyArray => entryAt(model, path).items.filter((item) => item.name === name) +const itemNames = (model: Model, path: string): ReadonlyArray => entryAt(model, path).items.map((item) => item.name) + const builtOf = (given: FrontEnd.Declarations) => FrontEnd.buildModel(given) Feature('Building a package API model from its declaration text') @@ -549,4 +551,192 @@ Feature('Building a package API model from its declaration text') })), ), ) + + 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: [] }, + }, + ], + }, + ], + })), + ), + ) }) From 1109e488b9f1bf9bfb96e9e7f399856bc9ac74b7 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 13 Oct 2026 22:37:40 +0000 Subject: [PATCH 39/99] fix(front-end): keep a hand-written .d.ts entry as its own declaration on both routes (U15) An entry whose source condition names a declaration file mapped to a .d.d.ts path: the fast path modelled it and the compiler route refused it, so the routes disagreed (R3). The entry keeps its own path, and the compiler route reads it as a hand-written declaration instead of expecting tsc to emit it. The equality journey's new package fails at 932ae50 with CompilerRejected naming src/index.d.d.ts. --- .../tests/front-ends.integration.test.ts | 18 +++++++++++++++++- .../src/front-end/declaration-graph.ts | 2 +- .../src/front-end/typescript-7-compiler.ts | 6 +++++- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/apps/api-extractor-e2e/tests/front-ends.integration.test.ts b/apps/api-extractor-e2e/tests/front-ends.integration.test.ts index 957f6a1..ba2c771 100644 --- a/apps/api-extractor-e2e/tests/front-ends.integration.test.ts +++ b/apps/api-extractor-e2e/tests/front-ends.integration.test.ts @@ -119,6 +119,13 @@ const handWrittenDeclarationPackage = fallingBackPackage( STANDALONE_TSCONFIG, ) +const handWrittenEntryPackage = fallingBackPackage( + 'hand-written-entry', + { '.': './src/index.d.ts' }, + { 'src/index.d.ts': 'export declare const a: number;\n' }, + STANDALONE_TSCONFIG, +) + const forgedCodesPackage = packageWith( 'forged-codes', [ @@ -218,6 +225,7 @@ const analysedThrough = (emitter: Effect.Effect 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 })), } }) @@ -232,7 +240,7 @@ Feature('Both front-ends reduce a package to one model') .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 imports a hand-written declaration file', + '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), @@ -243,6 +251,7 @@ Feature('Both front-ends reduce a package to one model') 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 it')('handWrittenEntry', () => handWrittenEntryPackage), When('each is analysed through the isolated emitter and through the compiler')('analyses', (scope) => Effect.all({ ownPackage: throughBothRoutes(scope.source), @@ -254,6 +263,7 @@ Feature('Both front-ends reduce a package to one model') underNodeModules: throughBothRoutes(scope.underNodeModules), defaultInclude: throughBothRoutes(scope.defaultInclude), handWritten: throughBothRoutes(scope.handWritten), + handWrittenEntry: throughBothRoutes(scope.handWrittenEntry), wroteIntoPackage: writtenInto(scope.declarationDir, 'types'), }) ), @@ -271,6 +281,9 @@ Feature('Both front-ends reduce a package to one model') 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, @@ -285,6 +298,9 @@ Feature('Both front-ends reduce a package to one model') 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, }) ), diff --git a/packages/api-extractor/src/front-end/declaration-graph.ts b/packages/api-extractor/src/front-end/declaration-graph.ts index 21c2953..94f180e 100644 --- a/packages/api-extractor/src/front-end/declaration-graph.ts +++ b/packages/api-extractor/src/front-end/declaration-graph.ts @@ -78,7 +78,7 @@ export const declarationCandidates: { Option.match(normalisedPath(joinedSpecifier(fromDeclaration, specifier)), { onNone: () => [], onSome: candidatesForBase })) export const declarationPathOf = (sourceFile: string): string => - sourceFile.replace(/\.([mc]?)tsx?$/, (_, flavour: string) => declarationExtensionOf(flavour)) + DECLARATION_EXTENSION.test(sourceFile) ? sourceFile : sourceFile.replace(/\.([mc]?)tsx?$/, (_, flavour: string) => declarationExtensionOf(flavour)) export type EntryDeclaration = { readonly exportPath: string diff --git a/packages/api-extractor/src/front-end/typescript-7-compiler.ts b/packages/api-extractor/src/front-end/typescript-7-compiler.ts index bd965d8..da5ad6f 100644 --- a/packages/api-extractor/src/front-end/typescript-7-compiler.ts +++ b/packages/api-extractor/src/front-end/typescript-7-compiler.ts @@ -286,6 +286,9 @@ const declarationsOf = ( }), ) +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 @@ -298,7 +301,8 @@ const declarationsFrom = (source: PackageSource, run: CompilerRun) => ), ) const entryPoints = entryPointDeclarations(source.entryPoints) - return yield* Arr.match(missingEntryDeclarations(HashSet.fromIterable(HashMap.keys(emitted)), entryPoints.map((entry) => entry.declarationFile)), { + 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) })), }) From 5dd424be85552131ff47691114dad73851d77ef3 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 13 Oct 2026 22:37:40 +0000 Subject: [PATCH 40/99] docs(front-end): say when a CompilerRejected carries tsc's printed output (U15) The README said refusal diagnostics never come from tsc's printed output. When the TypeScript 7 API reports no error, the refusal carries one exit diagnostic with that output (plan U13 P3), so the README now says so and tells a consumer to treat it as untrusted text. --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3c254f2..8582b6d 100644 --- a/README.md +++ b/README.md @@ -79,8 +79,10 @@ import names no file), the package takes the fallback. This package's own TypeSc `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, never from `tsc`'s printed output, which a -package's own file names and import specifiers can spell. A diagnostic no compiler reported +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 diagnostic no compiler reported (a missing entry, say) carries no TypeScript code, and a diagnostic the compiler reported 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. From 611a0240637cc0159b7ebf5f6344b86a15f56520 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 13 Oct 2026 22:39:23 +0000 Subject: [PATCH 41/99] refactor(front-end): one shared-declaration rule and one absent-file predicate (U15) Both emitters detected entry points sharing a declaration file with their own copy of the rule; declaration-graph.ts now owns sharedDeclarations and each adapter only words its refusal. read-package.ts reuses package-file's isAbsent instead of a second copy. No behaviour change: routing, reader and e2e journeys pass unchanged. --- .../src/front-end/declaration-graph.ts | 15 ++++++++++++ .../src/front-end/oxc-isolated-emitter.ts | 21 ++++------------ .../src/front-end/package-file.ts | 2 +- .../src/front-end/read-package.ts | 9 ++----- .../src/front-end/typescript-7-compiler.ts | 24 +++++-------------- 5 files changed, 29 insertions(+), 42 deletions(-) diff --git a/packages/api-extractor/src/front-end/declaration-graph.ts b/packages/api-extractor/src/front-end/declaration-graph.ts index 94f180e..c3469b7 100644 --- a/packages/api-extractor/src/front-end/declaration-graph.ts +++ b/packages/api-extractor/src/front-end/declaration-graph.ts @@ -88,6 +88,21 @@ export type EntryDeclaration = { 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 diff --git a/packages/api-extractor/src/front-end/oxc-isolated-emitter.ts b/packages/api-extractor/src/front-end/oxc-isolated-emitter.ts index eb927a4..c68a88f 100644 --- a/packages/api-extractor/src/front-end/oxc-isolated-emitter.ts +++ b/packages/api-extractor/src/front-end/oxc-isolated-emitter.ts @@ -2,7 +2,7 @@ import { Array as Arr, Effect, FileSystem, HashMap, HashSet, Layer, Option, Orde 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, sourceCandidatesOf, type ClosureResult, type EntryDeclaration, type UnresolvedImport } from './declaration-graph.ts' +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' @@ -84,22 +84,11 @@ const everyEntryEmitted = (files: ReadonlyArray, entryDeclarati onNonEmpty: (missing) => Effect.fail(new NotIsolated({ diagnostics: missing.map(missingEntryDiagnostic) })), }) -const sharedDeclarationDiagnostic = (declarationFile: string, exportPaths: ReadonlyArray): Diagnostic => ({ - file: declarationFile, - message: `two entry points share one declaration file: ${exportPaths.map((exportPath) => `\`${exportPath}\``).join(' and ')}`, +const sharedDeclarationDiagnostic = (shared: SharedDeclaration): Diagnostic => ({ + file: shared.declarationFile, + message: `two entry points share one declaration file: ${shared.exportPaths.map((exportPath) => `\`${exportPath}\``).join(' and ')}`, }) -const sharedDeclarationOwners = (entryPoints: ReadonlyArray, declarationFile: string): ReadonlyArray => - Arr.sort(Arr.map(Arr.filter(entryPoints, (entry) => entry.declarationFile === declarationFile), (entry) => entry.exportPath), Order.String) - -const sharedDeclarationDiagnostics = (entryPoints: ReadonlyArray): ReadonlyArray => - Arr.dedupe(entryPoints.map((entry) => entry.declarationFile)).flatMap((declarationFile) => - Option.match(Option.liftPredicate(sharedDeclarationOwners(entryPoints, declarationFile), (exportPaths) => exportPaths.length > 1), { - onNone: () => [], - onSome: (exportPaths) => [sharedDeclarationDiagnostic(declarationFile, exportPaths)], - }), - ) - const recordedSourceFirst = (recorded: HashMap.HashMap) => (declarationFile: string): ReadonlyArray => Option.match(HashMap.get(recorded, declarationFile), { onNone: () => [...sourceCandidatesOf(declarationFile), declarationFile], @@ -113,7 +102,7 @@ const emit = (source: PackageSource) => const emission: Emission = { source, realRoot, diagnostics: yield* Ref.make>([]) } const entryPoints = entryPointDeclarations(source.entryPoints) const entryDeclarations = entryPoints.map((entry) => entry.declarationFile) - yield* Arr.match(sharedDeclarationDiagnostics(entryPoints), { + yield* Arr.match(sharedDeclarations(entryPoints).map(sharedDeclarationDiagnostic), { onEmpty: () => Effect.void, onNonEmpty: (diagnostics) => Effect.fail(new NotIsolated({ diagnostics })), }) diff --git a/packages/api-extractor/src/front-end/package-file.ts b/packages/api-extractor/src/front-end/package-file.ts index 421f631..21e1e54 100644 --- a/packages/api-extractor/src/front-end/package-file.ts +++ b/packages/api-extractor/src/front-end/package-file.ts @@ -6,7 +6,7 @@ const DECLARATION_FILE = /\.d\.[cm]?ts$/ export const isDeclarationFile = (file: string): boolean => DECLARATION_FILE.test(file) -const isAbsent = (error: PlatformError): boolean => +export const isAbsent = (error: PlatformError): boolean => Match.value(error.reason).pipe( Match.tag('NotFound', 'BadResource', () => true), Match.orElse(() => false), diff --git a/packages/api-extractor/src/front-end/read-package.ts b/packages/api-extractor/src/front-end/read-package.ts index 110426c..d33c218 100644 --- a/packages/api-extractor/src/front-end/read-package.ts +++ b/packages/api-extractor/src/front-end/read-package.ts @@ -3,6 +3,7 @@ import type { PlatformError } from 'effect/PlatformError' import type { PackageSource, SourceEntryPoint } from './declarations.schema.ts' import { 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' @@ -84,12 +85,6 @@ const directoryOf = (relative: string): string => { const joinRelative = (path: Path.Path, relative: string, name: string): string => path.join(relative, name).split(path.sep).join('/') -const absent = (error: PlatformError): boolean => - Match.value(error.reason).pipe( - Match.tag('NotFound', 'BadResource', () => true), - Match.orElse(() => false), - ) - 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 === '')) @@ -103,7 +98,7 @@ const walkDirectory = (root: string, directory: string): Effect.Effect Effect.succeed>([]), onSome: (relative) => fileSystem.readDirectory(path.join(root, relative), { recursive: true }).pipe( - Effect.catchIf(absent, () => Effect.succeed>([])), + Effect.catchIf(isAbsent, () => Effect.succeed>([])), Effect.map((names) => names.map((name) => joinRelative(path, relative, name))), ), }) diff --git a/packages/api-extractor/src/front-end/typescript-7-compiler.ts b/packages/api-extractor/src/front-end/typescript-7-compiler.ts index da5ad6f..ce1c60f 100644 --- a/packages/api-extractor/src/front-end/typescript-7-compiler.ts +++ b/packages/api-extractor/src/front-end/typescript-7-compiler.ts @@ -1,9 +1,9 @@ -import { Array as Arr, Effect, FileSystem, HashMap, HashSet, Layer, Option, Order, Path, type Scope, Stream } from 'effect' +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, type EntryDeclaration, type UnresolvedImport } from './declaration-graph.ts' +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' @@ -336,29 +336,17 @@ const entryProblem = (source: PackageSource) => }) }) -const sharingEntries = (entries: ReadonlyArray, declarationFile: string): ReadonlyArray => - entries.filter((entry) => entry.declarationFile === declarationFile) - -const sharedDeclarationDiagnostic = (declarationFile: string, entries: ReadonlyArray): Diagnostic => ({ - file: declarationFile, - message: `the entry points ${entries.map((entry) => entry.exportPath).join(ENTRY_SEPARATOR)} share the declaration file ${declarationFile}`, +const sharedDeclarationDiagnostic = (shared: SharedDeclaration): Diagnostic => ({ + file: shared.declarationFile, + message: `the entry points ${shared.exportPaths.join(ENTRY_SEPARATOR)} share the declaration file ${shared.declarationFile}`, }) -const sharedDeclaration = (entries: ReadonlyArray): Option.Option => - Option.flatMap( - Arr.findFirst( - Arr.sort(Arr.fromIterable(HashSet.fromIterable(entries.map((entry) => entry.declarationFile))), Order.String), - (declarationFile) => sharingEntries(entries, declarationFile).length > 1, - ), - (declarationFile) => Option.some(sharedDeclarationDiagnostic(declarationFile, sharingEntries(entries, 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(sharedDeclaration(entryPointDeclarations(source.entryPoints)), { + Option.match(Option.map(Arr.head(sharedDeclarations(entryPointDeclarations(source.entryPoints))), sharedDeclarationDiagnostic), { onNone: () => Effect.void, onSome: (diagnostic) => Effect.fail(new CompilerRejected({ diagnostics: [diagnostic] })), }), From 4fec99b544729feda8f39d63145565bafb3dda25 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 13 Oct 2026 22:41:08 +0000 Subject: [PATCH 42/99] fix(front-end): arrow text, literal renames and distinct names for hidden declarations (U15) - A function or constructor type inside text printed '=>: T'; it now prints '(x: number) => string' as tsc does. - Local renames inserted the model name as a replacement pattern, so 'T$$' became 'T$' in text; the name is now inserted literally. - A reference to an unexported interface, class or other non-alias declaration kept its bare name, so it read as the exported item of the same name, and two hidden 'Opts' collapsed into one. Every reachable hidden declaration now takes a distinct model name through the same assignment the alias locals use; only aliases are emitted into locals (schema unchanged). - A scenario pins that two files re-exporting each other offer each other's exports once. The first three scenarios fail at 932ae50. --- .../src/front-end/build-model.ts | 9 +- .../src/front-end/canonical-type-text.ts | 28 +++-- .../tests/front-end-model.integration.test.ts | 116 ++++++++++++++++++ 3 files changed, 141 insertions(+), 12 deletions(-) diff --git a/packages/api-extractor/src/front-end/build-model.ts b/packages/api-extractor/src/front-end/build-model.ts index 36a70b6..9f13d05 100644 --- a/packages/api-extractor/src/front-end/build-model.ts +++ b/packages/api-extractor/src/front-end/build-model.ts @@ -85,7 +85,7 @@ const assignNames = (exported: ReadonlyArray, reachable: HashMap.HashMap ).assigned const replaceNames = (text: string, rename: HashMap.HashMap): string => - Arr.reduce(HashMap.toEntries(rename), text, (accumulated, [key, name]) => accumulated.replaceAll(key, name)) + 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) }) }) @@ -203,7 +203,7 @@ const localNameOf = ( declarations: ReadonlyArray, ): Effect.Effect => Option.match(HashMap.get(exported, localKey(file, name)), { - onNone: () => (declaresTypeAlias(declarations) ? recordLocal(reachable, file, name, declarations) : Effect.succeed(name)), + onNone: () => recordLocal(reachable, file, name, declarations), onSome: (exportName) => Effect.succeed(exportName), }) @@ -598,7 +598,10 @@ const buildReachableLocals = ( Effect.gen(function* () { const known = yield* Ref.get(reachable) const done = yield* Ref.get(built) - const pending = Arr.filter(HashMap.keys(known), (key) => !HashMap.has(done, key)) + const pending = Arr.map( + Arr.filter(HashMap.values(known), (local) => declaresTypeAlias(local.declarations) && !HashMap.has(done, local.key)), + (local) => local.key, + ) return yield* Arr.match(pending, { onEmpty: () => Effect.void, onNonEmpty: (keys) => Effect.flatMap(Effect.forEach(keys, (key) => buildOneLocal(contextFor, reachable, built, key)), () => drain()), diff --git a/packages/api-extractor/src/front-end/canonical-type-text.ts b/packages/api-extractor/src/front-end/canonical-type-text.ts index 6a1307b..069b9f2 100644 --- a/packages/api-extractor/src/front-end/canonical-type-text.ts +++ b/packages/api-extractor/src/front-end/canonical-type-text.ts @@ -277,19 +277,29 @@ const printMethodTail = ( 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, - kind: TSMethodSignatureKind, + tail: ReturnTail, decorate: (parts: { readonly typeParameters: string; readonly parameters: string; readonly returnType: string }) => string, ): Effect.Effect => Effect.gen(function* () { const inner = withTypeParameters(context, typeParameterNames(typeParameters)) const printedTypeParameters = yield* printTypeParameters(inner, typeParameters) const printedParameters = yield* printParameters(inner, params) - const printedReturnType = yield* printMethodTail(inner, kind, returnType) + const printedReturnType = yield* tail(inner, returnType) return decorate({ typeParameters: printedTypeParameters, parameters: printedParameters, returnType: printedReturnType }) }) @@ -305,7 +315,7 @@ const printMethodSignature = (context: TypeContext, member: TSMethodSignature): 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, member.kind, (parts) => + 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}`), }) @@ -317,11 +327,11 @@ const methodPrefix = (kind: TSMethodSignatureKind): string => ) const printCallSignature = (context: TypeContext, member: TSCallSignatureDeclaration): Effect.Effect => - printSignatureLike(context, member.typeParameters, member.params, member.returnType, 'method', (parts) => + 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, 'method', (parts) => + 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 => @@ -374,12 +384,12 @@ const printTuple = (context: TypeContext, node: TSTupleType): Effect.Effect printTupleElement(context, element)), (parts) => `[${parts.join(', ')}]`) const printFunctionType = (context: TypeContext, node: TSFunctionType): Effect.Effect => - printSignatureLike(context, node.typeParameters, node.params, node.returnType, 'method', (parts) => - `${parts.typeParameters}(${parts.parameters}) =>${parts.returnType}`) + 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, 'method', (parts) => - `${node.abstract ? 'abstract ' : ''}new ${parts.typeParameters}(${parts.parameters}) =>${parts.returnType}`) + 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* () { diff --git a/packages/api-extractor/tests/front-end-model.integration.test.ts b/packages/api-extractor/tests/front-end-model.integration.test.ts index 6c20687..992c042 100644 --- a/packages/api-extractor/tests/front-end-model.integration.test.ts +++ b/packages/api-extractor/tests/front-end-model.integration.test.ts @@ -739,4 +739,120 @@ Feature('Building a package API model from its declaration text') })), ), ) + + 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 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' } }, + })), + ), + ) }) From eca9cee80b14cba32d5c52630f32735164cd29ad Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 13 Oct 2026 22:44:22 +0000 Subject: [PATCH 43/99] test(front-end): pin a two-entry model and a caller-chosen source condition (U15) Test gaps from the 932ae50 review: no scenario built a package with two entry points that do not share a declaration file, and none read a package with the reader's sourceCondition option (KTD5). Both pin existing behaviour. --- .../tests/front-end-model.integration.test.ts | 25 +++++++++++++++++++ .../tests/package-reader.integration.test.ts | 25 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/packages/api-extractor/tests/front-end-model.integration.test.ts b/packages/api-extractor/tests/front-end-model.integration.test.ts index 992c042..e06a1ef 100644 --- a/packages/api-extractor/tests/front-end-model.integration.test.ts +++ b/packages/api-extractor/tests/front-end-model.integration.test.ts @@ -855,4 +855,29 @@ Feature('Building a package API model from its declaration text') })), ), ) + + 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'] }, + ])), + ), + ) }) diff --git a/packages/api-extractor/tests/package-reader.integration.test.ts b/packages/api-extractor/tests/package-reader.integration.test.ts index 243e2f8..52196fe 100644 --- a/packages/api-extractor/tests/package-reader.integration.test.ts +++ b/packages/api-extractor/tests/package-reader.integration.test.ts @@ -190,4 +190,29 @@ Feature("A package's exports name its entry points") ), ), ) + + 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' }], + }) + ), + ), + ) }) From 34206fdce9b775d8738eb542c20f30f7f5c1dce7 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 13 Oct 2026 22:51:06 +0000 Subject: [PATCH 44/99] fix(front-end): a diagnostic is Reported or a Note, not a record whose code may be absent (U15) Pack rows schema-laws/tagged-unions-over-state-by-presence and rich-type-over-foreign-encoded: Diagnostic told a compiler diagnostic from a reason no compiler reported only by whether code was present, and consumers probed it. Diagnostic is now the union of Reported (TS code, optional in-package file, message) and Note (optional file, message); the adapters build the variant, and the refusal text and the fast path's diagnostic order match on the tag. Refusal messages read as before. The e2e routing and refusal journeys assert the tags; both fail at 4fec99b. --- README.md | 9 +++--- .../tests/front-ends.integration.test.ts | 21 ++++++++----- packages/api-extractor/src/FrontEnd.ts | 2 +- .../src/front-end/declarations.schema.ts | 30 ++++++++++++++----- .../src/front-end/front-end-error.schema.ts | 19 ++++++++---- .../src/front-end/oxc-isolated-emitter.ts | 18 ++++++++--- .../src/front-end/typescript-7-compiler.ts | 6 +++- 7 files changed, 76 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 8582b6d..1d41d12 100644 --- a/README.md +++ b/README.md @@ -82,10 +82,11 @@ sent it there. A `tsc` that fails refuses the package with `CompilerRejected`, w 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 diagnostic no compiler reported -(a missing entry, say) carries no TypeScript code, and a diagnostic the compiler reported -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. +treats that message as untrusted text. A `Reported` diagnostic is one the compiler +reported, carrying its `TSxxxx` code; a `Note` is a refusal reason no compiler reported, so +it carries no code. Either names a `file` only when that file is inside the package. A +refusal of an entry point names the entry's recorded source, which may lie outside the +package. Both routes meet at declaration text: one syntactic builder makes the model and one resolver reports `ae-forgotten-export`, `ae-missing-release-tag`, `ae-internal-missing-underscore`, `ae-incompatible-release-tags` and diff --git a/apps/api-extractor-e2e/tests/front-ends.integration.test.ts b/apps/api-extractor-e2e/tests/front-ends.integration.test.ts index ba2c771..5f725cc 100644 --- a/apps/api-extractor-e2e/tests/front-ends.integration.test.ts +++ b/apps/api-extractor-e2e/tests/front-ends.integration.test.ts @@ -1,7 +1,7 @@ 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, Option, Path } from 'effect' +import { Effect, FileSystem, Layer, Match, Option, Path } from 'effect' const Feature = makeFeature({ it }) @@ -179,11 +179,18 @@ const sharedDeclarationPackage = fallingBackPackage( 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((diagnostic) => diagnostic.code), + 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))), } @@ -196,7 +203,7 @@ const routedAs = (analysis: FrontEnd.Analysis) => ({ }) const STRING_ARRAY_FALLBACK = { - route: { _tag: 'Fallback', diagnostics: [{ file: 'src/mod.ts', code: 9010 }] }, + 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' } } }], } @@ -370,7 +377,7 @@ Feature('Both front-ends reduce a package to one model') shared: scope.refusals.shared.codes, }, }).toMatchObject({ - typeError: { _tag: 'CompilerRejected', diagnostics: [{ file: 'src/mod.ts', code: 2322 }] }, + typeError: { _tag: 'CompilerRejected', diagnostics: [{ _tag: 'Reported', file: 'src/mod.ts', code: 2322 }] }, forged: { _tag: 'CompilerRejected', diagnostics: [ @@ -387,12 +394,12 @@ Feature('Both front-ends reduce a package to one model') '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: [{ file: 'src/mod.d.ts', message: 'the compiler emitted no declaration for this entry point' }] }, + 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: [{ file: '../shared/mod.ts', message: expect.stringContaining('resolves outside the package') }] }, + escaping: { _tag: 'CompilerRejected', diagnostics: [{ _tag: 'Note', file: '../shared/mod.ts', message: expect.stringContaining('resolves outside the package') }] }, shared: { _tag: 'CompilerRejected', - diagnostics: [{ file: 'src/mod.d.ts', message: expect.stringContaining('the entry points . and ./mod share the declaration file src/mod.d.ts') }], + diagnostics: [{ _tag: 'Note', file: 'src/mod.d.ts', message: expect.stringContaining('the entry points . and ./mod share the declaration file src/mod.d.ts') }], }, files: { longName: [undefined] }, codes: { excludedEntry: [undefined], longName: [undefined], escaping: [undefined], shared: [undefined] }, diff --git a/packages/api-extractor/src/FrontEnd.ts b/packages/api-extractor/src/FrontEnd.ts index 0c987d8..e1bcfb2 100644 --- a/packages/api-extractor/src/FrontEnd.ts +++ b/packages/api-extractor/src/FrontEnd.ts @@ -8,7 +8,7 @@ export { IsolatedEmitter, type IsolatedEmitterId, } from './front-end/declaration-emitter.service.ts' -export { type DeclarationFile, Declarations, Diagnostic, type PackageSource, type SourceEntryPoint } from './front-end/declarations.schema.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' diff --git a/packages/api-extractor/src/front-end/declarations.schema.ts b/packages/api-extractor/src/front-end/declarations.schema.ts index 61268af..fc2a548 100644 --- a/packages/api-extractor/src/front-end/declarations.schema.ts +++ b/packages/api-extractor/src/front-end/declarations.schema.ts @@ -33,14 +33,30 @@ export const Declarations: Schema.Codec = Schema.Struct({ files: Schema.Array(Schema.Struct({ path: Schema.String, text: Schema.String })), }) -export type Diagnostic = { - readonly file?: string - readonly code?: number - readonly message: string -} +const positiveInt: Schema.Codec = Schema.Int.check(Schema.isGreaterThan(0)) -export const Diagnostic: Schema.Codec = Schema.Struct({ +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: Schema.optionalKey(Schema.Int.check(Schema.isGreaterThan(0))), + 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 index bea8741..5bd7014 100644 --- a/packages/api-extractor/src/front-end/front-end-error.schema.ts +++ b/packages/api-extractor/src/front-end/front-end-error.schema.ts @@ -1,14 +1,23 @@ -import { type Cause, Schema } from 'effect' -import { Diagnostic } from './declarations.schema.ts' +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 codeLabel = (code: number | undefined): string => (code === undefined ? '' : `TS${code} `) +const fileLabel = (file: string | undefined): string => (file === undefined ? '' : `${file}: `) -const listed = (diagnostics: ReadonlyArray): string => - diagnostics.map(({ file, code, message }) => `${file === undefined ? '' : `${file}: `}${codeLabel(code)}${message}`).join('\n') +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 }) diff --git a/packages/api-extractor/src/front-end/oxc-isolated-emitter.ts b/packages/api-extractor/src/front-end/oxc-isolated-emitter.ts index c68a88f..eac6d2c 100644 --- a/packages/api-extractor/src/front-end/oxc-isolated-emitter.ts +++ b/packages/api-extractor/src/front-end/oxc-isolated-emitter.ts @@ -1,4 +1,4 @@ -import { Array as Arr, Effect, FileSystem, HashMap, HashSet, Layer, Option, Order, Path, Ref, Result } from 'effect' +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' } @@ -14,13 +14,20 @@ const DIAGNOSTIC_TEXT = /^TS(\d+): (.*)$/s const diagnosticOf = (file: string, text: string): Diagnostic => Option.match(Option.fromNullishOr(DIAGNOSTIC_TEXT.exec(text)), { - onNone: () => ({ file, message: text }), - onSome: ([, code, message]) => ({ file, code: Number(code), message: message ?? '' }), + 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), (diagnostic: Diagnostic) => Option.fromNullishOr(diagnostic.code)), + Order.mapInput(Option.makeOrder(Order.Number), codeOf), Order.mapInput(Order.String, (diagnostic: Diagnostic) => diagnostic.message), ]) @@ -52,6 +59,7 @@ const firstSource = ( }) const unresolvedImportDiagnostic = (unresolved: UnresolvedImport): Diagnostic => ({ + _tag: 'Note', file: unresolved.file, message: `the import \`${unresolved.specifier}\` names no declaration file inside the package`, }) @@ -74,6 +82,7 @@ const refusedOrClosed = ( }) const missingEntryDiagnostic = (declarationFile: string): Diagnostic => ({ + _tag: 'Note', file: declarationFile, message: 'no source file inside the package could be read for this entry point', }) @@ -85,6 +94,7 @@ const everyEntryEmitted = (files: ReadonlyArray, entryDeclarati }) 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 ')}`, }) diff --git a/packages/api-extractor/src/front-end/typescript-7-compiler.ts b/packages/api-extractor/src/front-end/typescript-7-compiler.ts index ce1c60f..8cca577 100644 --- a/packages/api-extractor/src/front-end/typescript-7-compiler.ts +++ b/packages/api-extractor/src/front-end/typescript-7-compiler.ts @@ -160,7 +160,7 @@ const exitMessage = (exitCode: number, stdout: string, stderr: string): string = }, ) -const exitDiagnostic = (run: CompilerRun): Diagnostic => ({ message: exitMessage(run.exitCode, run.stdout, run.stderr) }) +const exitDiagnostic = (run: CompilerRun): Diagnostic => ({ _tag: 'Note', message: exitMessage(run.exitCode, run.stdout, run.stderr) }) const indented = (text: string): string => text.replaceAll('\n', '\n ') @@ -177,6 +177,7 @@ const fileFieldOf = (path: Path.Path, packageRoot: string, fileName: Option.Opti ) 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), @@ -250,6 +251,7 @@ const emittedDeclarationFiles = (path: Path.Path, fileSystem: FileSystem.FileSys ) const missingEntryDiagnostic = (declarationFile: string): Diagnostic => ({ + _tag: 'Note', file: declarationFile, message: 'the compiler emitted no declaration for this entry point', }) @@ -315,6 +317,7 @@ const accepted = (source: PackageSource, run: CompilerRun) => }) 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}`, }) @@ -337,6 +340,7 @@ const entryProblem = (source: PackageSource) => }) 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}`, }) From 47bf1c3a59f8fd40a0cb593582b60a2eab85876b Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 13 Oct 2026 22:51:33 +0000 Subject: [PATCH 45/99] test(front-end): a refused package's diagnostic keeps tsc's elaboration lines (U15) Test gap from the 932ae50 review: no assertion reached a TypeScript 7 diagnostic with a message chain. The refusal journey's type-error package now also assigns a callback of the wrong parameter type, and the refusal keeps 'Types of parameters ... are incompatible' as an indented line of the TS2322 message. Pins existing behaviour. --- .../tests/front-ends.integration.test.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/api-extractor-e2e/tests/front-ends.integration.test.ts b/apps/api-extractor-e2e/tests/front-ends.integration.test.ts index 5f725cc..8a894ff 100644 --- a/apps/api-extractor-e2e/tests/front-ends.integration.test.ts +++ b/apps/api-extractor-e2e/tests/front-ends.integration.test.ts @@ -335,8 +335,11 @@ Feature('Both front-ends reduce a package to one model') 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 and assigns a string to a number')('source', () => - packageWith('type-error', "export const ids = [1, 2].map(String)\nexport const count: number = 'one'\n")), + 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, @@ -377,7 +380,13 @@ Feature('Both front-ends reduce a package to one model') shared: scope.refusals.shared.codes, }, }).toMatchObject({ - typeError: { _tag: 'CompilerRejected', diagnostics: [{ _tag: 'Reported', file: 'src/mod.ts', code: 2322 }] }, + 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: [ From 425c81cc8435359f07c627aabace164da0f46348 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 13 Oct 2026 22:52:21 +0000 Subject: [PATCH 46/99] docs(plan): rule every finding of the 932ae50 review (U15) Supersedes the 2114 plan. Records the fixing commits for the confirmed defects and both pack rows, the rejections with their probe evidence, and the PR4/PR5 follow-ups with their triggers. --- ...> 2026-10-13-2251-feat-front-ends-plan.md} | 28 +++++++++++++++++-- ...lback-inherits-declaration-emit-options.md | 2 +- 2 files changed, 27 insertions(+), 3 deletions(-) rename docs/plans/{2026-10-13-2114-feat-front-ends-plan.md => 2026-10-13-2251-feat-front-ends-plan.md} (90%) diff --git a/docs/plans/2026-10-13-2114-feat-front-ends-plan.md b/docs/plans/2026-10-13-2251-feat-front-ends-plan.md similarity index 90% rename from docs/plans/2026-10-13-2114-feat-front-ends-plan.md rename to docs/plans/2026-10-13-2251-feat-front-ends-plan.md index 4435083..b82bf1c 100644 --- a/docs/plans/2026-10-13-2114-feat-front-ends-plan.md +++ b/docs/plans/2026-10-13-2251-feat-front-ends-plan.md @@ -6,7 +6,7 @@ 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-13-2019-feat-front-ends-plan.md +supersedes: docs/plans/2026-10-13-2114-feat-front-ends-plan.md execution: code --- @@ -393,6 +393,30 @@ U1 → U2 (profile committed) → U3 (choice recorded) → U4 → U5 → U6 → - **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. + --- ## Verification Contract @@ -415,7 +439,7 @@ No mutation run, local or in CI (origin R23). ## Definition of Done -- U1-U14 verified; every Verification Contract gate passes locally and CI is green on the exact PR head. +- U1-U15 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. 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 index 65b38b3..ec1abd8 100644 --- a/docs/solutions/logic-errors/compiler-fallback-inherits-declaration-emit-options.md +++ b/docs/solutions/logic-errors/compiler-fallback-inherits-declaration-emit-options.md @@ -158,6 +158,6 @@ derived at mirror(dirname(packageTsconfig)) = { extends: packageTsconfig, files: ## Related -- `docs/plans/2026-10-13-2114-feat-front-ends-plan.md` (R3; U10 disposition #6; U12 emit policy; U13 #4, the mirrored `node_modules` ancestry and its delta ruling) +- `docs/plans/2026-10-13-2251-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 From 8f4617ac00189fc8be6e3d74d4d973f8bc098b08 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 13 Oct 2026 23:00:07 +0000 Subject: [PATCH 47/99] fix(front-end): merged namespaces and enums keep every member; a qualified hidden name reads as written (U15) Final-head review at 425c81c: - A namespace or enum declared twice (declaration merging) kept only the first declaration's members; the builder now reads every body. - 4fec99b renamed hidden declarations through a NUL-delimited key but matched only a whole reference name, so 'C.Inner' through a hidden namespace C leaked the key into the model. Structural references are renamed through the same key substitution as text. - The local-alias drain builds only aliases, so its 'not a type alias' refusal could no longer be reached; it is gone. Both scenarios fail at 425c81c. --- .../src/front-end/build-model.ts | 52 ++++++++----------- .../tests/front-end-model.integration.test.ts | 46 ++++++++++++++++ 2 files changed, 67 insertions(+), 31 deletions(-) diff --git a/packages/api-extractor/src/front-end/build-model.ts b/packages/api-extractor/src/front-end/build-model.ts index 9f13d05..680703d 100644 --- a/packages/api-extractor/src/front-end/build-model.ts +++ b/packages/api-extractor/src/front-end/build-model.ts @@ -117,7 +117,7 @@ const renameReference = (reference: TypeExpr & { readonly kind: 'reference' }, r const args = reference.args.map((argument) => renameType(argument, rename)) return Option.match(Option.fromNullishOr(reference.module), { onSome: () => ({ ...reference, args }), - onNone: () => ({ ...reference, name: Option.getOrElse(HashMap.get(rename, reference.name), () => reference.name), args }), + onNone: () => ({ ...reference, name: replaceNames(reference.name, rename), args }), }) } @@ -180,9 +180,6 @@ const reverseIndex = (graph: DeclarationGraph, file: string, exported: ReadonlyA ), })) -const declaresTypeAlias = (declarations: ReadonlyArray): boolean => - Arr.some(declarations, (declaration) => declaration.declared.kind === 'typeAlias') - const recordLocal = ( reachable: Ref.Ref>, file: string, @@ -428,11 +425,11 @@ const enumItemOf = ( ): Effect.Effect, UnsupportedDeclaration> => Arr.match(nodes, { onEmpty: () => Effect.succeedNone, - onNonEmpty: ([node]) => + onNonEmpty: (present) => Effect.asSome( Effect.map( - Effect.forEach(node.body.members, (member) => enumMemberOf(context, member)), - (members): ApiItem => ({ kind: 'enum', name, const: node.const, members }), + Effect.forEach(present.flatMap((node) => node.body.members), (member) => enumMemberOf(context, member)), + (members): ApiItem => ({ kind: 'enum', name, const: Arr.headNonEmpty(present).const, members }), ), ), }) @@ -444,8 +441,14 @@ const namespaceItemOf = ( ): Effect.Effect, UnsupportedDeclaration> => Arr.match(nodes, { onEmpty: () => Effect.succeedNone, - onNonEmpty: ([node]) => - Effect.asSome(Effect.map(namespaceBodyItems(context, node.body), (items): ApiItem => ({ kind: 'namespace', name, items }))), + onNonEmpty: (present) => + Effect.asSome( + Effect.map(Effect.forEach(present, (node) => namespaceBodyItems(context, node.body)), (bodies): ApiItem => ({ + kind: 'namespace', + name, + items: bodies.flat(), + })), + ), }) const namespaceBodyItems = (context: TypeContext, body: TSModuleBlock | null): Effect.Effect, UnsupportedDeclaration> => @@ -568,26 +571,17 @@ const itemsForExport = ( ), }) +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, - reachable: Ref.Ref>, built: Ref.Ref>, - key: string, + { local, node }: PendingAlias, ): Effect.Effect => - Effect.gen(function* () { - const known = yield* Ref.get(reachable) - return yield* Option.match(HashMap.get(known, key), { - onNone: () => Effect.void, - onSome: (local) => - Effect.flatMap( - Arr.match(typeAliasesIn(local.declarations), { - onEmpty: () => refuse(local.file, `a local "${local.name}" that is not a type alias`), - onNonEmpty: ([node]) => typeAliasItem(contextFor(local.file), node, local.key), - }), - (item) => Ref.update(built, HashMap.set(key, item)), - ), - }) - }) + Effect.flatMap(typeAliasItem(contextFor(local.file), node, local.key), (item) => Ref.update(built, HashMap.set(local.key, item))) const buildReachableLocals = ( contextFor: (file: string) => TypeContext, @@ -598,13 +592,9 @@ const buildReachableLocals = ( Effect.gen(function* () { const known = yield* Ref.get(reachable) const done = yield* Ref.get(built) - const pending = Arr.map( - Arr.filter(HashMap.values(known), (local) => declaresTypeAlias(local.declarations) && !HashMap.has(done, local.key)), - (local) => local.key, - ) - return yield* Arr.match(pending, { + return yield* Arr.match(Arr.getSomes(Arr.fromIterable(HashMap.values(known)).map(pendingAliasOf(done))), { onEmpty: () => Effect.void, - onNonEmpty: (keys) => Effect.flatMap(Effect.forEach(keys, (key) => buildOneLocal(contextFor, reachable, built, key)), () => drain()), + onNonEmpty: (pending) => Effect.flatMap(Effect.forEach(pending, (alias) => buildOneLocal(contextFor, built, alias)), () => drain()), }) }) return drain() diff --git a/packages/api-extractor/tests/front-end-model.integration.test.ts b/packages/api-extractor/tests/front-end-model.integration.test.ts index e06a1ef..8e84353 100644 --- a/packages/api-extractor/tests/front-end-model.integration.test.ts +++ b/packages/api-extractor/tests/front-end-model.integration.test.ts @@ -880,4 +880,50 @@ Feature('Building a package API model from its declaration text') ])), ), ) + + 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 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: [] }, + })), + ), + ) }) From 95956df668bd17609ba4fc099c72230788a188dd Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 13 Oct 2026 23:00:57 +0000 Subject: [PATCH 48/99] docs(front-end): list every UnsupportedDeclaration cause (U15) Final-head api-contract review: the README attributed the refusal only to the builder and omitted the statement forms it refuses and the compiler fallback's unresolved relative import. --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 1d41d12..693e75f 100644 --- a/README.md +++ b/README.md @@ -96,8 +96,11 @@ from the producer stamp (`producer.declarations`), which names the emitter that 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: +version 2 cannot hold. The compiler fallback refuses the same way when a declaration it +emitted imports a relative path that names no declaration file inside the package. +- a destructuring declaration, a variable without a type, `import x = require(...)`, + `export as 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. - `declare global` and module augmentation (`declare module 'vitest'`) From f98127b925708eefd15781f0b659489eb38ee8b5 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 13 Oct 2026 23:04:48 +0000 Subject: [PATCH 49/99] fix(front-end): the compiler route reads a hand-written entry, not tsc's emit of its same-stem source (U15) DeepSeek delta lens at 425c81c: an entry naming src/index.d.ts beside an src/index.ts the tsconfig also compiles was modelled from the hand-written file on the fast path and from tsc's emit of index.ts on the fallback, so the routes disagreed (a: string against a: number). The fallback now drops tsc's emit for any declaration file an entry names as its own source and reads that file, as the fast path does. The import refusal names 'inside the package', as the fast path's does. The equality journey's hand-written entry package gains the sibling and fails at 95956df. --- .../tests/front-ends.integration.test.ts | 4 ++-- .../src/front-end/typescript-7-compiler.ts | 15 +++++++++++---- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/apps/api-extractor-e2e/tests/front-ends.integration.test.ts b/apps/api-extractor-e2e/tests/front-ends.integration.test.ts index 8a894ff..63c998f 100644 --- a/apps/api-extractor-e2e/tests/front-ends.integration.test.ts +++ b/apps/api-extractor-e2e/tests/front-ends.integration.test.ts @@ -122,7 +122,7 @@ const handWrittenDeclarationPackage = fallingBackPackage( const handWrittenEntryPackage = fallingBackPackage( 'hand-written-entry', { '.': './src/index.d.ts' }, - { 'src/index.d.ts': 'export declare const a: number;\n' }, + { 'src/index.d.ts': 'export declare const a: string;\n', 'src/index.ts': 'export const a: number = 1\n' }, STANDALONE_TSCONFIG, ) @@ -258,7 +258,7 @@ Feature('Both front-ends reduce a package to one model') 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 it')('handWrittenEntry', () => handWrittenEntryPackage), + 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), diff --git a/packages/api-extractor/src/front-end/typescript-7-compiler.ts b/packages/api-extractor/src/front-end/typescript-7-compiler.ts index 8cca577..f39432c 100644 --- a/packages/api-extractor/src/front-end/typescript-7-compiler.ts +++ b/packages/api-extractor/src/front-end/typescript-7-compiler.ts @@ -257,7 +257,7 @@ const missingEntryDiagnostic = (declarationFile: string): Diagnostic => ({ }) const unsupportedImport = (unresolved: UnresolvedImport): UnsupportedDeclaration => - new UnsupportedDeclaration({ file: unresolved.file, construct: `the import \`${unresolved.specifier}\` names no declaration file` }) + 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), { @@ -288,6 +288,9 @@ const declarationsOf = ( }), ) +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))) @@ -297,10 +300,14 @@ const declarationsFrom = (source: PackageSource, run: CompilerRun) => 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 emitted = HashMap.fromIterable( - yield* Effect.forEach(declared, (declaration) => - Effect.map(fileSystem.readFileString(declaration.emittedFile), (text) => [declaration.declared, text] as const), + 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) From dfd896d36183dbe9e61e15e1bf41fefa91275540 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 13 Oct 2026 23:09:21 +0000 Subject: [PATCH 50/99] test(front-end): pin refusals, literal types, pattern edges and per-entry notes (U15) Final-head testing review gaps, each pinning existing behaviour with exact values: notes for a second entry point carry its own path; the builder's statement-form and variable refusals name file and construct; negative, bigint, boolean, null and single-quoted literals; a pattern match with a dot segment and a malformed pattern target read no entry. --- .../analysis-messages.integration.test.ts | 33 +++++++++ .../tests/front-end-model.integration.test.ts | 70 +++++++++++++++++++ .../tests/package-reader.integration.test.ts | 40 +++++++++++ 3 files changed, 143 insertions(+) diff --git a/packages/api-extractor/tests/analysis-messages.integration.test.ts b/packages/api-extractor/tests/analysis-messages.integration.test.ts index 161fcec..3b13d9e 100644 --- a/packages/api-extractor/tests/analysis-messages.integration.test.ts +++ b/packages/api-extractor/tests/analysis-messages.integration.test.ts @@ -290,6 +290,39 @@ Feature('Review notes for a package match the ones api-extractor derives') ), ) + 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( 'Every note for this package matches the ones api-extractor derives for it', Gherkin.Do.pipe( diff --git a/packages/api-extractor/tests/front-end-model.integration.test.ts b/packages/api-extractor/tests/front-end-model.integration.test.ts index 8e84353..fecbe59 100644 --- a/packages/api-extractor/tests/front-end-model.integration.test.ts +++ b/packages/api-extractor/tests/front-end-model.integration.test.ts @@ -138,6 +138,40 @@ Feature('Building a package API model from its declaration text') ), ) + 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( 'a file re-exported under a namespace becomes a namespace item', Gherkin.Do.pipe( @@ -518,6 +552,42 @@ Feature('Building a package API model from its declaration text') ), ) + scenario( + 'an untyped constant, a non-literal initialiser, an import by assignment, an exported namespace name, an expression default export and a destructuring constant 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']]))), + 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)), + })), + 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' }, + })), + ), + ) + scenario( 'a set accessor models as a writable property and prints without a return type', Gherkin.Do.pipe( diff --git a/packages/api-extractor/tests/package-reader.integration.test.ts b/packages/api-extractor/tests/package-reader.integration.test.ts index 52196fe..eb9d92e 100644 --- a/packages/api-extractor/tests/package-reader.integration.test.ts +++ b/packages/api-extractor/tests/package-reader.integration.test.ts @@ -75,6 +75,21 @@ const suffixlessPatternPackage = packageOf( { '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': '' }, +) + Feature("A package's exports name its entry points") .withLayer(NodeServices.layer) .live('the package reader reads real files from disk') @@ -215,4 +230,29 @@ Feature("A package's exports name its entry points") ), ), ) + + 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' }], + }) + ), + ), + ) }) From 99b7495846c807c0085aceb35fb91c9616cfda48 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 13 Oct 2026 23:09:40 +0000 Subject: [PATCH 51/99] fix(front-end): a pattern export inserts the matched file name literally (U15) DeepSeek delta lens at 425c81c: patternEntryOf built the export path with key.replace('*', substring), so a matched name holding $$ or $& was read as a replacement pattern ('src/a$$b.ts' exported as './a$b'). The matched name is inserted literally, as 4fec99b did for local renames. Reader scenario; it fails at dfd896d. --- .../api-extractor/src/front-end/read-package.ts | 2 +- .../tests/package-reader.integration.test.ts | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/api-extractor/src/front-end/read-package.ts b/packages/api-extractor/src/front-end/read-package.ts index d33c218..b200ed1 100644 --- a/packages/api-extractor/src/front-end/read-package.ts +++ b/packages/api-extractor/src/front-end/read-package.ts @@ -74,7 +74,7 @@ const isValidPatternMatch = (substring: string): boolean => !substring.split(/[/ 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 }, + entry: { exportPath: key.replace('*', () => substring), sourceFile: candidate }, key, })) diff --git a/packages/api-extractor/tests/package-reader.integration.test.ts b/packages/api-extractor/tests/package-reader.integration.test.ts index eb9d92e..5ba3527 100644 --- a/packages/api-extractor/tests/package-reader.integration.test.ts +++ b/packages/api-extractor/tests/package-reader.integration.test.ts @@ -255,4 +255,19 @@ Feature("A package's exports name its entry points") ), ), ) + + 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' }, + ]) + ), + ), + ) }) From a6300c707deb29ca4d42dbfaff8d5f8fec8921f7 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 13 Oct 2026 23:10:37 +0000 Subject: [PATCH 52/99] docs(plan): rule every finding of the 425c81c review (U16) Supersedes the 2251 plan. Records the fixing commits for the qualified hidden name, merged namespaces and enums, the same-stem hand-written entry and the literal pattern export; the rejections with their reasons; and the PR4, PR5 and PR6 follow-ups with their triggers. KTD1 no longer says the compiler layer takes options. --- ...> 2026-10-13-2310-feat-front-ends-plan.md} | 33 +++++++++++++++++-- ...lback-inherits-declaration-emit-options.md | 2 +- 2 files changed, 31 insertions(+), 4 deletions(-) rename docs/plans/{2026-10-13-2251-feat-front-ends-plan.md => 2026-10-13-2310-feat-front-ends-plan.md} (91%) diff --git a/docs/plans/2026-10-13-2251-feat-front-ends-plan.md b/docs/plans/2026-10-13-2310-feat-front-ends-plan.md similarity index 91% rename from docs/plans/2026-10-13-2251-feat-front-ends-plan.md rename to docs/plans/2026-10-13-2310-feat-front-ends-plan.md index b82bf1c..30c8b1e 100644 --- a/docs/plans/2026-10-13-2251-feat-front-ends-plan.md +++ b/docs/plans/2026-10-13-2310-feat-front-ends-plan.md @@ -6,7 +6,7 @@ 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-13-2114-feat-front-ends-plan.md +supersedes: docs/plans/2026-10-13-2251-feat-front-ends-plan.md execution: code --- @@ -115,7 +115,7 @@ Product Contract preservation: Product Contract unchanged. ### Key Technical Decisions -- KTD1. **One emitter port, two service tags, adapters as parameterised 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` (the compiler's takes options); 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. +- 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. @@ -417,6 +417,33 @@ U1 → U2 (profile committed) → U3 (choice recorded) → U4 → U5 → U6 → - **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. + --- ## Verification Contract @@ -439,7 +466,7 @@ No mutation run, local or in CI (origin R23). ## Definition of Done -- U1-U15 verified; every Verification Contract gate passes locally and CI is green on the exact PR head. +- U1-U16 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. 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 index ec1abd8..a74534f 100644 --- a/docs/solutions/logic-errors/compiler-fallback-inherits-declaration-emit-options.md +++ b/docs/solutions/logic-errors/compiler-fallback-inherits-declaration-emit-options.md @@ -158,6 +158,6 @@ derived at mirror(dirname(packageTsconfig)) = { extends: packageTsconfig, files: ## Related -- `docs/plans/2026-10-13-2251-feat-front-ends-plan.md` (R3; U10 disposition #6; U12 emit policy; U13 #4, the mirrored `node_modules` ancestry and its delta ruling) +- `docs/plans/2026-10-13-2310-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 From 2dd44cb8034a8eb3e94165456deedc1663a2c739 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 13 Oct 2026 23:24:57 +0000 Subject: [PATCH 53/99] fix(front-end): an infer name reads as the name its conditional binds (U17) Correctness review at a6300c7: the printer resolved a conditional type's infer U 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 infer names their extends clause binds, and the true branch prints with them in scope, as a mapped type's key already is. Builder scenario; it fails at a6300c7. --- .../src/front-end/canonical-type-text.ts | 8 ++++- .../src/front-end/declaration-graph.ts | 30 ++++++++++++++++++ .../tests/front-end-model.integration.test.ts | 31 +++++++++++++++++++ 3 files changed, 68 insertions(+), 1 deletion(-) diff --git a/packages/api-extractor/src/front-end/canonical-type-text.ts b/packages/api-extractor/src/front-end/canonical-type-text.ts index 069b9f2..8661277 100644 --- a/packages/api-extractor/src/front-end/canonical-type-text.ts +++ b/packages/api-extractor/src/front-end/canonical-type-text.ts @@ -441,11 +441,17 @@ const printMappedType = (context: TypeContext, node: TSMappedType): Effect.Effec 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(context, node.trueType) + const consequent = yield* canonicalTypeText(withTypeParameters(context, inferNamesFor(context, node)), node.trueType) const alternate = yield* canonicalTypeText(context, node.falseType) return `${check} extends ${extending} ? ${consequent} : ${alternate}` }) diff --git a/packages/api-extractor/src/front-end/declaration-graph.ts b/packages/api-extractor/src/front-end/declaration-graph.ts index c3469b7..74ba4c6 100644 --- a/packages/api-extractor/src/front-end/declaration-graph.ts +++ b/packages/api-extractor/src/front-end/declaration-graph.ts @@ -143,6 +143,34 @@ export const specifiersOf = (program: Program): ReadonlyArray => { 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> @@ -282,6 +310,7 @@ export type FileScope = { 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 @@ -456,6 +485,7 @@ const scopeOf = (file: DeclarationFile): Effect.Effect part.declarations)), imports: HashMap.fromIterable(parts.flatMap((part) => part.imports)), exports: HashMap.fromIterable([...implicitExports(program, parts), ...parts.flatMap((part) => part.exports)]), diff --git a/packages/api-extractor/tests/front-end-model.integration.test.ts b/packages/api-extractor/tests/front-end-model.integration.test.ts index fecbe59..ec382b3 100644 --- a/packages/api-extractor/tests/front-end-model.integration.test.ts +++ b/packages/api-extractor/tests/front-end-model.integration.test.ts @@ -996,4 +996,35 @@ Feature('Building a package API model from its declaration text') })), ), ) + + 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' } }, + })), + ), + ) }) From 68502ef5e34feb9d787856e0282cc6348d3ec115 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 13 Oct 2026 23:26:41 +0000 Subject: [PATCH 54/99] test(front-end): a bare-path or absent exports names no source entry (U17) Testing review at a6300c7: the reader's string and undefined exports branches had no scenario. Pins existing behaviour. --- .../tests/package-reader.integration.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/api-extractor/tests/package-reader.integration.test.ts b/packages/api-extractor/tests/package-reader.integration.test.ts index 5ba3527..5c313fd 100644 --- a/packages/api-extractor/tests/package-reader.integration.test.ts +++ b/packages/api-extractor/tests/package-reader.integration.test.ts @@ -206,6 +206,25 @@ Feature("A package's exports name its entry points") ), ) + 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' }))), + When('the package reader reads both')('refusals', (scope) => + Effect.all({ stringExports: refusalOfReading(scope.stringExports), absentExports: refusalOfReading(scope.absentExports) }), + ), + 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 }, + }), + ), + ), + ) + scenario( 'a caller may name another source condition, and the reader follows only that one', Gherkin.Do.pipe( From 0e1d692dabaa69cdd49deef709b6db379ad96b28 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 13 Oct 2026 23:33:00 +0000 Subject: [PATCH 55/99] fix(front-end): merge namespace bodies at any depth and refuse a repeated enum member (U17) Review at a6300c7: - DeepSeek delta P3: merged enum bodies repeating a member name built a model ApiModel.decode rejects; the builder refuses it naming the member. - Probe on the delta's residual: a namespace or enum declared twice inside a namespace body stayed two items; bodies merge by name at every depth, as the top level does. - Testing P2/P3: the destructuring-parameter, dotted-namespace, unparseable-text and bare-import-type refusals are pinned. - Maintainability P3: one refuse helper and one directoryOf. - DeepSeek delta P3 and api-contract P3: the README lists every UnsupportedDeclaration cause and readPackage's sourceCondition option. The enum and nested-merge scenarios fail at 2dd44cb. --- README.md | 26 ++- .../src/front-end/build-model.ts | 175 +++++++++++------- .../src/front-end/canonical-type-text.ts | 7 +- .../src/front-end/declaration-graph.ts | 29 +-- .../src/front-end/read-package.ts | 7 +- .../src/front-end/type-expr-of.ts | 3 +- .../tests/front-end-model.integration.test.ts | 73 +++++++- 7 files changed, 221 insertions(+), 99 deletions(-) diff --git a/README.md b/README.md index 693e75f..af4fdf8 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,9 @@ process.stdout.write(ApiModel.serialise(analysis.model)) ``` `readPackage` takes each export whose `@systemfsoftware/source` condition names a -`.ts`, `.tsx`, `.mts` or `.cts` file as an entry point. A subpath pattern (`"./*"`) +`.ts`, `.tsx`, `.mts` or `.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 (`"./*"`) expands to one entry per matching source under Node's `exports` rules, and an explicit subpath wins over a pattern. A package with no such export is refused with `NoSourceEntry`. A package whose `package.json` does not decode — invalid JSON, or a manifest the schema @@ -97,17 +99,27 @@ from the producer stamp (`producer.declarations`), which names the emitter that 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. +emitted imports a relative path that names no declaration file inside the package. This +is the whole list: -- a destructuring declaration, a variable without a type, `import x = require(...)`, - `export as namespace` and `export default` of an expression +- 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. -- `declare global` and module augmentation (`declare module 'vitest'`) +- `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`. Both emitters write constant members as - literals, so this happens only with declaration text written by hand. + 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, diff --git a/packages/api-extractor/src/front-end/build-model.ts b/packages/api-extractor/src/front-end/build-model.ts index 680703d..688ba86 100644 --- a/packages/api-extractor/src/front-end/build-model.ts +++ b/packages/api-extractor/src/front-end/build-model.ts @@ -5,7 +5,6 @@ import type { Class, Declaration as DeclarationNode, Directive, - ExportNamedDeclaration, Expression, Function as FunctionNode, Statement, @@ -19,6 +18,7 @@ import type { TSQualifiedName, TSTypeAliasDeclaration, TSTypeAnnotation, + VariableDeclaration, VariableDeclarator, } from 'oxc-parser' import { @@ -33,12 +33,12 @@ import { 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, exportedNames, exportedSymbol, type Declaration, type DeclarationGraph } from './declaration-graph.ts' +import { declarationGraph, exportedNames, exportedSymbol, refuse, type Declaration, type Declared, type DeclarationGraph } 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 { refuse, withTypeParameters } = TypePrinting +const { withTypeParameters } = TypePrinting const { classMembersOf, heritageOf, membersOf, referenceToExpression, signatureOf, typeExprOf, typeParametersOf } = TypeExprs @@ -418,6 +418,36 @@ 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, @@ -427,10 +457,7 @@ const enumItemOf = ( onEmpty: () => Effect.succeedNone, onNonEmpty: (present) => Effect.asSome( - Effect.map( - Effect.forEach(present.flatMap((node) => node.body.members), (member) => enumMemberOf(context, member)), - (members): ApiItem => ({ kind: 'enum', name, const: Arr.headNonEmpty(present).const, members }), - ), + Effect.map(enumMembersOf(context, present), (members): ApiItem => ({ kind: 'enum', name, const: Arr.headNonEmpty(present).const, members })), ), }) @@ -443,87 +470,105 @@ const namespaceItemOf = ( onEmpty: () => Effect.succeedNone, onNonEmpty: (present) => Effect.asSome( - Effect.map(Effect.forEach(present, (node) => namespaceBodyItems(context, node.body)), (bodies): ApiItem => ({ - kind: 'namespace', - name, - items: bodies.flat(), - })), + Effect.map(namespaceBodyItems(context, present.map((node) => node.body)), (items): ApiItem => ({ kind: 'namespace', name, items })), ), }) -const namespaceBodyItems = (context: TypeContext, body: TSModuleBlock | null): Effect.Effect, UnsupportedDeclaration> => - Option.match(Option.fromNullishOr(body), { - onNone: () => Effect.succeed>([]), - onSome: (block) => Effect.map(Effect.forEach(block.body, (statement) => statementItems(context, statement)), (items) => items.flat()), - }) - -const moduleName = (id: BindingIdentifier | StringLiteral | TSQualifiedName): Option.Option => - Match.value(id).pipe( - Match.when({ type: 'Identifier' }, (identifier) => Option.some(identifier.name)), - Match.orElse(() => Option.none()), +const namespaceBodyItems = ( + context: TypeContext, + bodies: ReadonlyArray, +): Effect.Effect, UnsupportedDeclaration> => + Effect.map( + Effect.flatMap(bodyDeclarations(context, bodies), (groups) => + Effect.forEach(Arr.sort(HashMap.keys(groups), Order.String), (name) => + itemsOfDeclarations(context, Option.getOrThrow(HashMap.get(groups, name)), name))), + (items) => items.flat(), ) -const declaredStatementItems = (context: TypeContext, node: DeclarationNode | Statement): Effect.Effect, UnsupportedDeclaration> => - Match.value(node).pipe( - Match.when({ type: 'VariableDeclaration' }, (variable) => - Effect.map( - Effect.forEach(variable.declarations, (declarator) => declaratorStatementItems(context, declarator, variable.kind === 'const')), - (items) => items.flat(), - )), - Match.when({ type: Match.is('FunctionDeclaration', 'TSDeclareFunction') }, (function_) => - namedStatementItems(function_.id, (name) => functionItemOf(context, [function_], name))), - Match.when({ type: 'ClassDeclaration' }, (class_) => namedStatementItems(class_.id, (name) => classItemOf(context, [class_], name))), - Match.when({ type: 'TSInterfaceDeclaration' }, (iface) => optionalItems(interfaceItemOf(context, [iface], iface.id.name))), - Match.when({ type: 'TSTypeAliasDeclaration' }, (alias) => optionalItems(typeAliasItemOf(context, [alias], alias.id.name))), - Match.when({ type: 'TSEnumDeclaration' }, (enumeration) => optionalItems(enumItemOf(context, [enumeration], enumeration.id.name))), - Match.when({ type: 'TSModuleDeclaration', global: false }, (module) => namespaceStatementItems(context, module)), - Match.orElse(() => Effect.succeed>([])), - ) +const bodyStatements = (bodies: ReadonlyArray): ReadonlyArray => + bodies.flatMap((body) => Option.match(Option.fromNullishOr(body), { onNone: () => [], onSome: (block) => block.body })) -const statementItems = (context: TypeContext, statement: Directive | Statement): Effect.Effect, UnsupportedDeclaration> => - Match.value(statement).pipe( - Match.when({ type: 'ExportNamedDeclaration' }, (declaration) => exportedStatementItems(context, declaration)), - Match.when({ type: 'TSModuleDeclaration', global: false }, (module) => namespaceStatementItems(context, module)), - Match.when({ type: Match.is('VariableDeclaration', 'FunctionDeclaration', 'TSDeclareFunction', 'ClassDeclaration', 'TSInterfaceDeclaration', 'TSTypeAliasDeclaration', 'TSEnumDeclaration') }, (node) => - declaredStatementItems(context, node)), - Match.orElse(() => Effect.succeed>([])), +const bodyDeclarations = ( + context: TypeContext, + bodies: ReadonlyArray, +): Effect.Effect>, UnsupportedDeclaration> => + Effect.map( + Effect.forEach(bodyStatements(bodies), (statement) => statementDeclarations(context, statement)), + (all) => groupDeclarations(all.flat()), ) -const exportedStatementItems = (context: TypeContext, declaration: ExportNamedDeclaration): Effect.Effect, UnsupportedDeclaration> => - Option.match(Option.fromNullishOr(declaration.declaration), { - onNone: () => Effect.succeed>([]), - onSome: (inner) => declaredStatementItems(context, inner), +const groupDeclarations = (declarations: ReadonlyArray): HashMap.HashMap> => + Arr.reduce(declarations, HashMap.empty>(), (groups, declaration) => { + const name = declaration.declared.name + return HashMap.set(groups, name, [...Option.getOrElse(HashMap.get(groups, name), (): ReadonlyArray => []), declaration]) }) -const declaratorStatementItems = (context: TypeContext, declarator: VariableDeclarator, constant: boolean): Effect.Effect, UnsupportedDeclaration> => +const declaredEntry = (declared: Declared, statementStart: number): Declaration => ({ declared, statementStart }) + +const identifierName = (id: { readonly name: string } | null): Option.Option => + Option.map(Option.fromNullishOr(id), (present) => present.name) + +const oneDeclaration = (declared: Option.Option, statementStart: number): ReadonlyArray => + Option.match(declared, { onNone: () => [], onSome: (present) => [declaredEntry(present, statementStart)] }) + +const declaratorDeclarations = (variable: VariableDeclaration, declarator: VariableDeclarator): ReadonlyArray => Option.match( Match.value(declarator.id).pipe( - Match.when({ type: 'Identifier' }, (identifier) => Option.some(identifier.name)), + Match.when({ type: 'Identifier' }, (id) => Option.some(id.name)), Match.orElse(() => Option.none()), ), { - onNone: () => Effect.succeed>([]), - onSome: (name) => optionalItems(variableItemOf(context, [{ constant, node: declarator }], name)), + onNone: () => [], + onSome: (name) => [declaredEntry({ kind: 'variable', constant: variable.kind === 'const', node: declarator, name }, variable.start)], }, ) -const namedStatementItems = ( - id: BindingIdentifier | null, - build: (name: string) => Effect.Effect, UnsupportedDeclaration>, -): Effect.Effect, UnsupportedDeclaration> => - Option.match(Option.fromNullishOr(id), { - onNone: () => Effect.succeed>([]), - onSome: (identifier) => optionalItems(build(identifier.name)), - }) +const nodeDeclarations = (node: DeclarationNode | Statement): ReadonlyArray => + Match.value(node).pipe( + Match.when({ type: 'VariableDeclaration' }, (variable) => variable.declarations.flatMap((declarator) => declaratorDeclarations(variable, declarator))), + Match.when({ type: Match.is('FunctionDeclaration', 'TSDeclareFunction') }, (function_: FunctionNode) => + oneDeclaration(Option.map(identifierName(function_.id), (name): Declared => ({ kind: 'function', node: function_, name })), function_.start)), + Match.when({ type: 'ClassDeclaration' }, (class_: Class) => + oneDeclaration(Option.map(identifierName(class_.id), (name): Declared => ({ kind: 'class', node: class_, name })), class_.start)), + Match.when({ type: 'TSInterfaceDeclaration' }, (iface: TSInterfaceDeclaration) => + [declaredEntry({ kind: 'interface', node: iface, name: iface.id.name }, iface.start)]), + Match.when({ type: 'TSTypeAliasDeclaration' }, (alias: TSTypeAliasDeclaration) => + [declaredEntry({ kind: 'typeAlias', node: alias, name: alias.id.name }, alias.start)]), + Match.when({ type: 'TSEnumDeclaration' }, (enumeration: TSEnumDeclaration) => + [declaredEntry({ kind: 'enum', node: enumeration, name: enumeration.id.name }, enumeration.start)]), + Match.orElse(() => []), + ) -const namespaceStatementItems = (context: TypeContext, module: TSModuleDeclaration): Effect.Effect, UnsupportedDeclaration> => +const namespaceDeclarations = ( + context: TypeContext, + module: TSModuleDeclaration, +): Effect.Effect, UnsupportedDeclaration> => Option.match(moduleName(module.id), { onNone: () => refuse(context.file, `a ${module.id.type} namespace name`), - onSome: (name) => optionalItems(namespaceItemOf(context, [module], name)), + onSome: (name) => Effect.succeed>([declaredEntry({ kind: 'namespace', node: module, name }, module.start)]), }) -const optionalItems = (item: Effect.Effect, UnsupportedDeclaration>): Effect.Effect, UnsupportedDeclaration> => - Effect.map(item, (built) => Option.match(built, { onNone: () => [], onSome: (value) => [value] })) +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) => Effect.succeed(nodeDeclarations(inner)), + })), + Match.when({ type: Match.is('VariableDeclaration', 'FunctionDeclaration', 'TSDeclareFunction', 'ClassDeclaration', 'TSInterfaceDeclaration', 'TSTypeAliasDeclaration', 'TSEnumDeclaration') }, (node) => + Effect.succeed(nodeDeclarations(node))), + Match.when({ type: 'TSModuleDeclaration', global: false }, (module) => namespaceDeclarations(context, module)), + Match.orElse(() => Effect.succeed>([])), + ) + +const moduleName = (id: BindingIdentifier | StringLiteral | TSQualifiedName): Option.Option => + Match.value(id).pipe( + Match.when({ type: 'Identifier' }, (identifier) => Option.some(identifier.name)), + Match.orElse(() => Option.none()), + ) const itemsOfDeclarations = (context: TypeContext, declarations: ReadonlyArray, name: string): Effect.Effect, UnsupportedDeclaration> => Effect.map( diff --git a/packages/api-extractor/src/front-end/canonical-type-text.ts b/packages/api-extractor/src/front-end/canonical-type-text.ts index 8661277..e3c0e74 100644 --- a/packages/api-extractor/src/front-end/canonical-type-text.ts +++ b/packages/api-extractor/src/front-end/canonical-type-text.ts @@ -39,7 +39,7 @@ import type { TemplateElement, } from 'oxc-parser' import type { KeywordName } from '../model/type-expr.schema.ts' -import { type Declaration, type DeclarationGraph, isRelativeSpecifier, resolveFile, resolveName } from './declaration-graph.ts' +import { type Declaration, type DeclarationGraph, isRelativeSpecifier, refuse, resolveFile, resolveName } from './declaration-graph.ts' import { UnsupportedDeclaration } from './front-end-error.schema.ts' export type ReferenceTarget = { @@ -63,9 +63,6 @@ const withTypeParameters = (context: TypeContext, names: ReadonlyArray): typeParameters: HashSet.union(context.typeParameters, HashSet.fromIterable(names)), }) -const refuse = (file: string, construct: string): Effect.Effect => - Effect.fail(new UnsupportedDeclaration({ file, construct })) - export const KEYWORD_ENTRIES = [ ['TSAnyKeyword', 'any'], ['TSBigIntKeyword', 'bigint'], @@ -541,12 +538,10 @@ export const TypePrinting: { source: Option.Option, ) => Effect.Effect readonly withTypeParameters: (context: TypeContext, names: ReadonlyArray) => TypeContext - readonly refuse: (file: string, construct: string) => Effect.Effect readonly bindingName: (pattern: BindingPattern) => string } = { canonicalTypeText: canonicalTypeText, referenceTargetFor: referenceTargetFor, withTypeParameters: withTypeParameters, - refuse: refuse, bindingName: bindingName, } diff --git a/packages/api-extractor/src/front-end/declaration-graph.ts b/packages/api-extractor/src/front-end/declaration-graph.ts index 74ba4c6..12558b4 100644 --- a/packages/api-extractor/src/front-end/declaration-graph.ts +++ b/packages/api-extractor/src/front-end/declaration-graph.ts @@ -42,7 +42,7 @@ export const relativeWithin: { } = Fn.dual(3, (path: Path.Path, root: string, target: string): Option.Option => Option.liftPredicate(path.relative(root, target), isInsideRoot(path))) -const directoryOf = (path: string): string => path.slice(0, Math.max(path.lastIndexOf('/'), 0)) +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)) @@ -124,7 +124,7 @@ const parsed = (path: string, text: string): Effect.Effect Effect.succeed({ program: result.program, comments: result.comments }), - onSome: (error) => Effect.fail(new UnsupportedDeclaration({ file: path, construct: `unparseable declaration text (${error.message})` })), + onSome: (error) => refuse(path, `unparseable declaration text (${error.message})`), }) } @@ -327,9 +327,6 @@ export type DeclarationGraph = { readonly entryPoints: ReadonlyArray<{ readonly exportPath: string; readonly declarationFile: string }> } -const unsupported = (file: string, construct: string): Effect.Effect => - Effect.fail(new UnsupportedDeclaration({ file, construct })) - type ScopeParts = { readonly declarations: ReadonlyArray readonly imports: ReadonlyArray @@ -341,6 +338,12 @@ const EMPTY_PARTS: ScopeParts = { declarations: [], imports: [], exports: [], st 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 }))) + const DEFAULT_EXPORT_NAME = 'default' const declarationName = (id: { readonly name: string } | null): string => (id === null ? DEFAULT_EXPORT_NAME : id.name) @@ -349,13 +352,13 @@ const variablesOf = (node: VariableDeclaration, file: string): DeclaredResult => Effect.forEach(node.declarations, (declarator) => declarator.id.type === 'Identifier' ? Effect.succeed({ kind: 'variable', constant: node.kind === 'const', node: declarator, name: declarator.id.name }) - : unsupported(file, 'a destructuring variable declaration')) + : refuse(file, 'a destructuring variable declaration')) const namespaceOf = (node: TSModuleDeclaration, file: string): DeclaredResult => Match.value(node.id).pipe( Match.when({ type: 'Identifier' }, (id) => Effect.succeed>([{ kind: 'namespace', node, name: id.name }])), - Match.when({ type: 'Literal' }, (id) => unsupported(file, `\`declare module "${id.value}"\``)), - Match.orElse(() => unsupported(file, 'a dotted namespace declaration')), + Match.when({ type: 'Literal' }, (id) => refuse(file, `\`declare module "${id.value}"\``)), + Match.orElse(() => refuse(file, 'a dotted namespace declaration')), ) const declaredOf = (node: DeclarationNode | Statement | ExportDefaultDeclaration['declaration'], file: string): DeclaredResult => @@ -369,7 +372,7 @@ const declaredOf = (node: DeclarationNode | Statement | ExportDefaultDeclaration Match.when({ type: 'TSTypeAliasDeclaration' }, (alias) => Effect.succeed>([{ kind: 'typeAlias', node: alias, name: alias.id.name }])), Match.when({ type: 'TSEnumDeclaration' }, (enumeration) => Effect.succeed>([{ kind: 'enum', node: enumeration, name: enumeration.id.name }])), - Match.when({ type: 'TSModuleDeclaration', global: true }, () => unsupported(file, '`declare global`')), + Match.when({ type: 'TSModuleDeclaration', global: true }, () => refuse(file, '`declare global`')), Match.when({ type: 'TSModuleDeclaration', global: false }, (module: TSModuleDeclaration) => namespaceOf(module, file)), Match.orElse(() => Effect.succeed>([])), ) @@ -426,7 +429,7 @@ const exportDefaultParts = (statement: ExportDefaultDeclaration, file: string): { type: 'TSInterfaceDeclaration' }, (declaration) => Effect.map(declaredOf(declaration, file), (declared) => defaultExportOf(declared, statement.start)), ), - Match.orElse(() => unsupported(file, '`export default` of an expression')), + Match.orElse(() => refuse(file, '`export default` of an expression')), ) const statementParts = (statement: Directive | Statement, file: string): Effect.Effect => @@ -441,9 +444,9 @@ const statementParts = (statement: Directive | Statement, file: string): Effect. : { ...EMPTY_PARTS, exports: [[moduleNameOf(node.exported), { kind: 'reexport', specifier: node.source.value, imported: '*' }]] }, ), ExportDefaultDeclaration: (node) => exportDefaultParts(node, file), - TSExportAssignment: () => unsupported(file, '`export =`'), - TSImportEqualsDeclaration: () => unsupported(file, '`import =`'), - TSNamespaceExportDeclaration: () => unsupported(file, '`export as namespace`'), + 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))), ) diff --git a/packages/api-extractor/src/front-end/read-package.ts b/packages/api-extractor/src/front-end/read-package.ts index b200ed1..6dce575 100644 --- a/packages/api-extractor/src/front-end/read-package.ts +++ b/packages/api-extractor/src/front-end/read-package.ts @@ -1,7 +1,7 @@ import { Array as Arr, Effect, FileSystem, HashMap, HashSet, Match, Option, Order, Path, Schema } from 'effect' import type { PlatformError } from 'effect/PlatformError' import type { PackageSource, SourceEntryPoint } from './declarations.schema.ts' -import { relativeWithin } from './declaration-graph.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' @@ -78,11 +78,6 @@ const patternEntryOf = (key: string, prefix: string, suffix: string) => key, })) -const directoryOf = (relative: string): string => { - const at = relative.lastIndexOf('/') - return at === -1 ? '' : relative.slice(0, at) -} - 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 => diff --git a/packages/api-extractor/src/front-end/type-expr-of.ts b/packages/api-extractor/src/front-end/type-expr-of.ts index 7166152..4724718 100644 --- a/packages/api-extractor/src/front-end/type-expr-of.ts +++ b/packages/api-extractor/src/front-end/type-expr-of.ts @@ -39,9 +39,10 @@ import { 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, referenceTargetFor, refuse, withTypeParameters } = TypePrinting +const { bindingName, canonicalTypeText, referenceTargetFor, withTypeParameters } = TypePrinting const unknownKeyword: TypeExpr = { kind: 'keyword', name: 'unknown' } diff --git a/packages/api-extractor/tests/front-end-model.integration.test.ts b/packages/api-extractor/tests/front-end-model.integration.test.ts index ec382b3..27bec21 100644 --- a/packages/api-extractor/tests/front-end-model.integration.test.ts +++ b/packages/api-extractor/tests/front-end-model.integration.test.ts @@ -308,6 +308,21 @@ Feature('Building a package API model from its declaration text') ), ) + 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 built model decodes as schema version two', Gherkin.Do.pipe( @@ -553,7 +568,7 @@ Feature('Building a package API model from its declaration text') ) scenario( - 'an untyped constant, a non-literal initialiser, an import by assignment, an exported namespace name, an expression default export and a destructuring constant are each refused naming the file and the construct', + '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']]))), @@ -567,6 +582,19 @@ Feature('Building a package API model from its declaration text') 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)), @@ -575,6 +603,10 @@ Feature('Building a package API model from its declaration text') 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({ @@ -584,6 +616,10 @@ Feature('Building a package API model from its declaration text') 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' }, })), ), ) @@ -979,6 +1015,41 @@ Feature('Building a package API model from its declaration text') ), ) + 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 type reached through a namespace the file keeps hidden reads as the file writes it', Gherkin.Do.pipe( From 460f42992b7f9aacf146f3cc813730ecdd2f3e3b Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 13 Oct 2026 23:33:54 +0000 Subject: [PATCH 56/99] docs(plan): rule every finding of the a6300c7 review (U17) Supersedes the 2310 plan. Records the fixing commits for the infer scope, the repeated enum member, nested namespace merging, the README refusal list and the pinning scenarios; the rejections with their reasons; and the PR4 and PR5 follow-ups with their triggers. --- ...> 2026-10-13-2333-feat-front-ends-plan.md} | 32 +++++++++++++++++-- ...lback-inherits-declaration-emit-options.md | 2 +- 2 files changed, 31 insertions(+), 3 deletions(-) rename docs/plans/{2026-10-13-2310-feat-front-ends-plan.md => 2026-10-13-2333-feat-front-ends-plan.md} (92%) diff --git a/docs/plans/2026-10-13-2310-feat-front-ends-plan.md b/docs/plans/2026-10-13-2333-feat-front-ends-plan.md similarity index 92% rename from docs/plans/2026-10-13-2310-feat-front-ends-plan.md rename to docs/plans/2026-10-13-2333-feat-front-ends-plan.md index 30c8b1e..f1cfb96 100644 --- a/docs/plans/2026-10-13-2310-feat-front-ends-plan.md +++ b/docs/plans/2026-10-13-2333-feat-front-ends-plan.md @@ -6,7 +6,7 @@ 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-13-2251-feat-front-ends-plan.md +supersedes: docs/plans/2026-10-13-2310-feat-front-ends-plan.md execution: code --- @@ -444,6 +444,34 @@ U1 → U2 (profile committed) → U3 (choice recorded) → U4 → U5 → U6 → - **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`. This package's own model bytes are unchanged (host digest `e4478f03…6da2`). + - 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. + --- ## Verification Contract @@ -466,7 +494,7 @@ No mutation run, local or in CI (origin R23). ## Definition of Done -- U1-U16 verified; every Verification Contract gate passes locally and CI is green on the exact PR head. +- U1-U17 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. 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 index a74534f..f8d11ac 100644 --- a/docs/solutions/logic-errors/compiler-fallback-inherits-declaration-emit-options.md +++ b/docs/solutions/logic-errors/compiler-fallback-inherits-declaration-emit-options.md @@ -158,6 +158,6 @@ derived at mirror(dirname(packageTsconfig)) = { extends: packageTsconfig, files: ## Related -- `docs/plans/2026-10-13-2310-feat-front-ends-plan.md` (R3; U10 disposition #6; U12 emit policy; U13 #4, the mirrored `node_modules` ancestry and its delta ruling) +- `docs/plans/2026-10-13-2333-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 From e0790d68cb733bc0a172f9c71bb26e8f74dfaf29 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 13 Oct 2026 23:42:30 +0000 Subject: [PATCH 57/99] fix(front-end): a namespace exported inside a namespace body stays an item (U18) Testing review at 460f429 (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 goes through the same statement path as an unexported one. Builder scenario; it fails at 460f429. --- .../src/front-end/build-model.ts | 2 +- .../tests/front-end-model.integration.test.ts | 32 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/api-extractor/src/front-end/build-model.ts b/packages/api-extractor/src/front-end/build-model.ts index 688ba86..d938645 100644 --- a/packages/api-extractor/src/front-end/build-model.ts +++ b/packages/api-extractor/src/front-end/build-model.ts @@ -556,7 +556,7 @@ const statementDeclarations = ( Match.when({ type: 'ExportNamedDeclaration' }, (declaration) => Option.match(Option.fromNullishOr(declaration.declaration), { onNone: () => Effect.succeed>([]), - onSome: (inner) => Effect.succeed(nodeDeclarations(inner)), + onSome: (inner) => statementDeclarations(context, inner), })), Match.when({ type: Match.is('VariableDeclaration', 'FunctionDeclaration', 'TSDeclareFunction', 'ClassDeclaration', 'TSInterfaceDeclaration', 'TSTypeAliasDeclaration', 'TSEnumDeclaration') }, (node) => Effect.succeed(nodeDeclarations(node))), diff --git a/packages/api-extractor/tests/front-end-model.integration.test.ts b/packages/api-extractor/tests/front-end-model.integration.test.ts index 27bec21..7454586 100644 --- a/packages/api-extractor/tests/front-end-model.integration.test.ts +++ b/packages/api-extractor/tests/front-end-model.integration.test.ts @@ -1050,6 +1050,38 @@ Feature('Building a package API model from its declaration text') ), ) + 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 type reached through a namespace the file keeps hidden reads as the file writes it', Gherkin.Do.pipe( From fef7fe8fbd8aabdcf2e797d7d2d62c7e7d1a8d9a Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 13 Oct 2026 23:48:32 +0000 Subject: [PATCH 58/99] fix(front-end): a pattern walk enters each real directory once (U18) Security review at 460f429: walkDirectory used Node's recursive readdir, which follows a symlinked directory, so src/loop -> . listed ./loop/a, ./loop/loop/a and so on until ELOOP, and several loops made readPackage effectively never return. The walk lists one directory at a time and descends only into a real path it has not visited. Reader scenario; it fails at e0790d6. Correctness review: the README names hand-written .d.ts, .d.mts and .d.cts entry files, and a reader scenario pins them (existing behaviour). --- README.md | 3 +- .../src/front-end/read-package.ts | 73 +++++++++++++++++-- .../tests/package-reader.integration.test.ts | 40 ++++++++++ 3 files changed, 109 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index af4fdf8..a182598 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,8 @@ process.stdout.write(ApiModel.serialise(analysis.model)) ``` `readPackage` takes each export whose `@systemfsoftware/source` condition names a -`.ts`, `.tsx`, `.mts` or `.cts` file as an entry point; `readPackage({ packageRoot, +`.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 (`"./*"`) expands to one entry per matching source under Node's `exports` rules, and an explicit diff --git a/packages/api-extractor/src/front-end/read-package.ts b/packages/api-extractor/src/front-end/read-package.ts index 6dce575..7a026ae 100644 --- a/packages/api-extractor/src/front-end/read-package.ts +++ b/packages/api-extractor/src/front-end/read-package.ts @@ -85,17 +85,78 @@ const withinRoot = (path: Path.Path, root: string, candidate: string): Option.Op type Platform = FileSystem.FileSystem | Path.Path -const walkDirectory = (root: string, directory: string): Effect.Effect, PlatformError, Platform> => +type WalkState = { + readonly visited: HashSet.HashSet + readonly names: ReadonlyArray +} + +const emptyWalk = (visited: HashSet.HashSet): WalkState => ({ visited, names: [] }) + +const keepName = (state: WalkState, name: string): WalkState => ({ visited: state.visited, names: Arr.append(state.names, name) }) + +const mergeWalk = (state: WalkState, walked: WalkState): WalkState => ({ visited: walked.visited, names: Arr.appendAll(state.names, walked.names) }) + +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 walkStep = (root: string, relative: string, visited: HashSet.HashSet): Effect.Effect => + Effect.gen(function* () { + const path = yield* Path.Path + const names = yield* listNames(path.join(root, relative)) + return yield* Effect.reduce(names, () => emptyWalk(visited), stepChild(root, relative)) + }) + +const enterDirectory = (root: string, relative: string, state: WalkState, realPath: string): Effect.Effect => + HashSet.has(state.visited, realPath) + ? Effect.succeed(state) + : Effect.map(walkStep(root, relative, HashSet.add(state.visited, realPath)), (walked) => mergeWalk(state, walked)) + +const stepChild = (root: string, relative: string) => + (state: WalkState, name: string): Effect.Effect => + Effect.gen(function* () { + const path = yield* Path.Path + const childRelative = joinRelative(path, relative, name) + return yield* Option.match(yield* directoryRealPath(path.join(root, relative, name)), { + onNone: () => Effect.succeed(keepName(state, childRelative)), + onSome: (realPath) => enterDirectory(root, childRelative, state, realPath), + }) + }) + +const walkRelative = (root: string, relative: string): Effect.Effect, PlatformError, Platform> => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem + const path = yield* Path.Path + const absolute = path.join(root, relative) + const seed = yield* fileSystem.realPath(absolute).pipe(Effect.catchIf(isAbsent, () => Effect.succeed(absolute))) + return (yield* walkStep(root, relative, HashSet.make(seed))).names + }) + +const walkDirectory = (root: string, directory: string): Effect.Effect, PlatformError, Platform> => + Effect.gen(function* () { const path = yield* Path.Path return yield* Option.match(withinRoot(path, root, path.resolve(root, directory)), { onNone: () => Effect.succeed>([]), - onSome: (relative) => - fileSystem.readDirectory(path.join(root, relative), { recursive: true }).pipe( - Effect.catchIf(isAbsent, () => Effect.succeed>([])), - Effect.map((names) => names.map((name) => joinRelative(path, relative, name))), - ), + onSome: (relative) => walkRelative(root, relative), }) }) diff --git a/packages/api-extractor/tests/package-reader.integration.test.ts b/packages/api-extractor/tests/package-reader.integration.test.ts index 5c313fd..219e288 100644 --- a/packages/api-extractor/tests/package-reader.integration.test.ts +++ b/packages/api-extractor/tests/package-reader.integration.test.ts @@ -48,6 +48,17 @@ const commonJsEntryPackage = packageOf( 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 explicitOverridePackage = packageOf( { './special': { [condition]: './src/b.ts' }, './*': { [condition]: './src/*.ts' } }, { 'src/a.ts': '', 'src/b.ts': '', 'src/special.ts': '' }, @@ -108,6 +119,24 @@ Feature("A package's exports name its entry points") ), ) + 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 `.` and `./src/legacy.d.cts` at `./legacy`')('packageRoot', () => + packageOf( + { '.': { [condition]: './src/index.d.ts' }, './legacy': { [condition]: './src/legacy.d.cts' } }, + { 'src/index.d.ts': 'export declare const a: number\n', 'src/legacy.d.cts': 'export declare const b: number\n' }, + )), + When('the package reader reads its entry points')('entryPoints', (scope) => entryPointsOf(scope.packageRoot)), + Then('each declaration file is its own entry source')((scope, expect) => + expect(scope.entryPoints).toEqual([ + { exportPath: '.', sourceFile: 'src/index.d.ts' }, + { exportPath: './legacy', sourceFile: 'src/legacy.d.cts' }, + ]) + ), + ), + ) + scenario( 'a pattern export expands to one entry per source file under the target directory', Gherkin.Do.pipe( @@ -123,6 +152,17 @@ Feature("A package's exports name its entry points") ), ) + 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( 'an explicit subpath beats the pattern expansion of the same export path', Gherkin.Do.pipe( From 1f3d00ca26685788cd5d6aa403fa5a4aa1aedead Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 13 Oct 2026 23:48:32 +0000 Subject: [PATCH 59/99] fix(front-end): a namespace body's own names shadow the file's; import = in a body is refused (U18) DeepSeek peer at 460f429: 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 import("pkg").A. A body's declared names are in scope for its items, like type parameters. DeepSeek delta residual: import X = M.Y in a namespace body was dropped silently; it is refused as it is at the top of a file. Maintainability P3: the body grouping reuses declaration-graph's grouped. The shadowing scenario fails at e0790d6; the import = scenario fails without the new arm. --- .../src/front-end/build-model.ts | 28 ++++++----- .../src/front-end/declaration-graph.ts | 2 +- .../tests/front-end-model.integration.test.ts | 49 +++++++++++++++++++ 3 files changed, 65 insertions(+), 14 deletions(-) diff --git a/packages/api-extractor/src/front-end/build-model.ts b/packages/api-extractor/src/front-end/build-model.ts index d938645..9000c74 100644 --- a/packages/api-extractor/src/front-end/build-model.ts +++ b/packages/api-extractor/src/front-end/build-model.ts @@ -33,7 +33,7 @@ import { 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, exportedNames, exportedSymbol, refuse, type Declaration, type Declared, type DeclarationGraph } from './declaration-graph.ts' +import { declarationGraph, exportedNames, exportedSymbol, grouped, refuse, type Declaration, type Declared, type DeclarationGraph } 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' @@ -474,17 +474,24 @@ const namespaceItemOf = ( ), }) -const namespaceBodyItems = ( +const byGroupName: Order.Order]> = Order.mapInput(Order.String, ([name]) => name) + +const itemsOfGroups = ( context: TypeContext, - bodies: ReadonlyArray, + groups: HashMap.HashMap>, ): Effect.Effect, UnsupportedDeclaration> => Effect.map( - Effect.flatMap(bodyDeclarations(context, bodies), (groups) => - Effect.forEach(Arr.sort(HashMap.keys(groups), Order.String), (name) => - itemsOfDeclarations(context, Option.getOrThrow(HashMap.get(groups, name)), name))), + 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(withTypeParameters(context, Arr.fromIterable(HashMap.keys(groups))), groups)) + const bodyStatements = (bodies: ReadonlyArray): ReadonlyArray => bodies.flatMap((body) => Option.match(Option.fromNullishOr(body), { onNone: () => [], onSome: (block) => block.body })) @@ -494,15 +501,9 @@ const bodyDeclarations = ( ): Effect.Effect>, UnsupportedDeclaration> => Effect.map( Effect.forEach(bodyStatements(bodies), (statement) => statementDeclarations(context, statement)), - (all) => groupDeclarations(all.flat()), + (all) => grouped(all.flat().map((declaration) => [declaration.declared.name, declaration] as const)), ) -const groupDeclarations = (declarations: ReadonlyArray): HashMap.HashMap> => - Arr.reduce(declarations, HashMap.empty>(), (groups, declaration) => { - const name = declaration.declared.name - return HashMap.set(groups, name, [...Option.getOrElse(HashMap.get(groups, name), (): ReadonlyArray => []), declaration]) - }) - const declaredEntry = (declared: Declared, statementStart: number): Declaration => ({ declared, statementStart }) const identifierName = (id: { readonly name: string } | null): Option.Option => @@ -561,6 +562,7 @@ const statementDeclarations = ( Match.when({ type: Match.is('VariableDeclaration', 'FunctionDeclaration', 'TSDeclareFunction', 'ClassDeclaration', 'TSInterfaceDeclaration', 'TSTypeAliasDeclaration', 'TSEnumDeclaration') }, (node) => Effect.succeed(nodeDeclarations(node))), Match.when({ type: 'TSModuleDeclaration', global: false }, (module) => namespaceDeclarations(context, module)), + Match.when({ type: 'TSImportEqualsDeclaration' }, () => refuse(context.file, '`import =`')), Match.orElse(() => Effect.succeed>([])), ) diff --git a/packages/api-extractor/src/front-end/declaration-graph.ts b/packages/api-extractor/src/front-end/declaration-graph.ts index 12558b4..719bef6 100644 --- a/packages/api-extractor/src/front-end/declaration-graph.ts +++ b/packages/api-extractor/src/front-end/declaration-graph.ts @@ -451,7 +451,7 @@ const statementParts = (statement: Directive | Statement, file: string): Effect. Match.orElse((node) => Effect.map(declaredOf(node, file), (declared) => declarationsPart(declared, node.start))), ) -const grouped = (entries: ReadonlyArray): HashMap.HashMap> => +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])) diff --git a/packages/api-extractor/tests/front-end-model.integration.test.ts b/packages/api-extractor/tests/front-end-model.integration.test.ts index 7454586..c934521 100644 --- a/packages/api-extractor/tests/front-end-model.integration.test.ts +++ b/packages/api-extractor/tests/front-end-model.integration.test.ts @@ -1082,6 +1082,55 @@ Feature('Building a package API model from its declaration text') ), ) + 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( + '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( 'a type reached through a namespace the file keeps hidden reads as the file writes it', Gherkin.Do.pipe( From 3ff23efefc8eb4941ee07d9d6356e41cc7730bb4 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 13 Oct 2026 23:49:37 +0000 Subject: [PATCH 60/99] docs(plan): rule every finding of the 460f429 review (U18) Supersedes the 2333 plan. Records the fixing commits for the exported nested namespace, the symlink-cycle walk, namespace-body scoping, import = in a body and the README entry spellings; corrects U17 H3's host-digest claim with a measured analyse digest; and the rejections and PR4/PR5 follow-ups with their triggers. --- ...> 2026-10-13-2348-feat-front-ends-plan.md} | 29 +++++++++++++++++-- ...lback-inherits-declaration-emit-options.md | 2 +- 2 files changed, 27 insertions(+), 4 deletions(-) rename docs/plans/{2026-10-13-2333-feat-front-ends-plan.md => 2026-10-13-2348-feat-front-ends-plan.md} (93%) diff --git a/docs/plans/2026-10-13-2333-feat-front-ends-plan.md b/docs/plans/2026-10-13-2348-feat-front-ends-plan.md similarity index 93% rename from docs/plans/2026-10-13-2333-feat-front-ends-plan.md rename to docs/plans/2026-10-13-2348-feat-front-ends-plan.md index f1cfb96..88d2d5a 100644 --- a/docs/plans/2026-10-13-2333-feat-front-ends-plan.md +++ b/docs/plans/2026-10-13-2348-feat-front-ends-plan.md @@ -6,7 +6,7 @@ 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-13-2310-feat-front-ends-plan.md +supersedes: docs/plans/2026-10-13-2333-feat-front-ends-plan.md execution: code --- @@ -451,7 +451,7 @@ U1 → U2 (profile committed) → U3 (choice recorded) → U4 → U5 → U6 → - **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`. This package's own model bytes are unchanged (host digest `e4478f03…6da2`). + - 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`). @@ -472,6 +472,29 @@ U1 → U2 (profile committed) → U3 (choice recorded) → U4 → U5 → U6 → - **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. + --- ## Verification Contract @@ -494,7 +517,7 @@ No mutation run, local or in CI (origin R23). ## Definition of Done -- U1-U17 verified; every Verification Contract gate passes locally and CI is green on the exact PR head. +- U1-U18 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. 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 index f8d11ac..7737cba 100644 --- a/docs/solutions/logic-errors/compiler-fallback-inherits-declaration-emit-options.md +++ b/docs/solutions/logic-errors/compiler-fallback-inherits-declaration-emit-options.md @@ -158,6 +158,6 @@ derived at mirror(dirname(packageTsconfig)) = { extends: packageTsconfig, files: ## Related -- `docs/plans/2026-10-13-2333-feat-front-ends-plan.md` (R3; U10 disposition #6; U12 emit policy; U13 #4, the mirrored `node_modules` ancestry and its delta ruling) +- `docs/plans/2026-10-13-2348-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 From 9b04f014bfe9932a1511a5bb8a3003fff56146c0 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Wed, 14 Oct 2026 00:04:42 +0000 Subject: [PATCH 61/99] fix(front-end): a pattern walk stops only on a cycle and reads names in code-unit order (U19) DeepSeek delta at 3ff23ef (P2): fef7fe8's walk-wide visited set listed a sibling's symlinked alias (src/b -> a) under only one name, 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 sorts each listing. Reader scenario; it fails at 3ff23ef. Api-contract and testing reviews: pins for a .d.mts entry, a plain-string subpath target, a missing package.json (PlatformError), the workspace root and tsconfig choice, and a pattern target outside the package. The README says where the condition is read and names PlatformError. --- README.md | 8 +- .../src/front-end/read-package.ts | 41 ++--- .../tests/package-reader.integration.test.ts | 160 ++++++++++++++++-- 3 files changed, 171 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index a182598..8596592 100644 --- a/README.md +++ b/README.md @@ -71,9 +71,13 @@ 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 (`"./*"`) expands to one entry per matching source under Node's `exports` rules, and an explicit -subpath wins over a pattern. A package with no such export is refused with `NoSourceEntry`. +subpath wins over a pattern. The condition is read where the family writes it, directly +under an export path; a target that nests it under another condition, or lists targets in +an array, 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. +refuses — is refused with `ManifestRejected`, naming the package root. 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 diff --git a/packages/api-extractor/src/front-end/read-package.ts b/packages/api-extractor/src/front-end/read-package.ts index 7a026ae..d57e79a 100644 --- a/packages/api-extractor/src/front-end/read-package.ts +++ b/packages/api-extractor/src/front-end/read-package.ts @@ -85,17 +85,6 @@ const withinRoot = (path: Path.Path, root: string, candidate: string): Option.Op type Platform = FileSystem.FileSystem | Path.Path -type WalkState = { - readonly visited: HashSet.HashSet - readonly names: ReadonlyArray -} - -const emptyWalk = (visited: HashSet.HashSet): WalkState => ({ visited, names: [] }) - -const keepName = (state: WalkState, name: string): WalkState => ({ visited: state.visited, names: Arr.append(state.names, name) }) - -const mergeWalk = (state: WalkState, walked: WalkState): WalkState => ({ visited: walked.visited, names: Arr.appendAll(state.names, walked.names) }) - const listNames = (directory: string): Effect.Effect, PlatformError, Platform> => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem @@ -119,26 +108,32 @@ const directoryRealPath = (childPath: string): Effect.Effect()) }) -const walkStep = (root: string, relative: string, visited: HashSet.HashSet): Effect.Effect => +const walkStep = (root: string, relative: string, ancestors: HashSet.HashSet): Effect.Effect, PlatformError, Platform> => Effect.gen(function* () { const path = yield* Path.Path const names = yield* listNames(path.join(root, relative)) - return yield* Effect.reduce(names, () => emptyWalk(visited), stepChild(root, relative)) + return yield* Effect.reduce(Arr.sort(names, Order.String), Arr.empty, stepChild(root, relative, ancestors)) }) -const enterDirectory = (root: string, relative: string, state: WalkState, realPath: string): Effect.Effect => - HashSet.has(state.visited, realPath) - ? Effect.succeed(state) - : Effect.map(walkStep(root, relative, HashSet.add(state.visited, realPath)), (walked) => mergeWalk(state, walked)) - -const stepChild = (root: string, relative: string) => - (state: WalkState, name: string): Effect.Effect => +const enterDirectory = ( + root: string, + relative: string, + names: ReadonlyArray, + ancestors: HashSet.HashSet, + realPath: string, +): Effect.Effect, PlatformError, Platform> => + HashSet.has(ancestors, realPath) + ? Effect.succeed(names) + : Effect.map(walkStep(root, relative, HashSet.add(ancestors, realPath)), (walked) => Arr.appendAll(names, walked)) + +const stepChild = (root: string, relative: string, ancestors: HashSet.HashSet) => + (names: ReadonlyArray, name: string): Effect.Effect, PlatformError, Platform> => Effect.gen(function* () { const path = yield* Path.Path const childRelative = joinRelative(path, relative, name) return yield* Option.match(yield* directoryRealPath(path.join(root, relative, name)), { - onNone: () => Effect.succeed(keepName(state, childRelative)), - onSome: (realPath) => enterDirectory(root, childRelative, state, realPath), + onNone: () => Effect.succeed(Arr.append(names, childRelative)), + onSome: (realPath) => enterDirectory(root, childRelative, names, ancestors, realPath), }) }) @@ -148,7 +143,7 @@ const walkRelative = (root: string, relative: string): Effect.Effect Effect.succeed(absolute))) - return (yield* walkStep(root, relative, HashSet.make(seed))).names + return yield* walkStep(root, relative, HashSet.make(seed)) }) const walkDirectory = (root: string, directory: string): Effect.Effect, PlatformError, Platform> => diff --git a/packages/api-extractor/tests/package-reader.integration.test.ts b/packages/api-extractor/tests/package-reader.integration.test.ts index 219e288..210e8d5 100644 --- a/packages/api-extractor/tests/package-reader.integration.test.ts +++ b/packages/api-extractor/tests/package-reader.integration.test.ts @@ -101,6 +101,55 @@ const twoStarPatternTargetPackage = packageOf( { '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 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') @@ -122,16 +171,21 @@ Feature("A package's exports name its entry points") 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 `.` and `./src/legacy.d.cts` at `./legacy`')('packageRoot', () => + 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' } }, - { 'src/index.d.ts': 'export declare const a: number\n', 'src/legacy.d.cts': 'export declare const b: number\n' }, + { '.': { [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 is its own entry source')((scope, expect) => + 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' }, ]) ), ), @@ -163,6 +217,20 @@ Feature("A package's exports name its entry points") ), ) + 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( @@ -234,15 +302,22 @@ Feature("A package's exports name its entry points") 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 })), ), - When('the package reader reads both')('refusals', (scope) => - Effect.all({ invalidJson: refusalOfReading(scope.invalidJson), refusedManifest: refusalOfReading(scope.refusedManifest) }), - ), - Then('each is refused as a rejected manifest, naming its own package root')((scope, expect) => - expect(scope.refusals).toMatchObject({ - invalidJson: { _tag: 'ManifestRejected', packageRoot: scope.invalidJson }, - refusedManifest: { _tag: 'ManifestRejected', packageRoot: scope.refusedManifest }, + 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' }, + }), + ), ), ) @@ -253,13 +328,21 @@ Feature("A package's exports name its entry points") 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' }))), - When('the package reader reads both')('refusals', (scope) => - Effect.all({ stringExports: refusalOfReading(scope.stringExports), absentExports: refusalOfReading(scope.absentExports) }), + 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 }, }), ), ), @@ -329,4 +412,55 @@ Feature("A package's exports name its entry points") ), ), ) + + 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' }], + }, + }) + ), + ), + ) }) From 8881471239cdc06cee2d5ba5dea2403de9c203d5 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Wed, 14 Oct 2026 00:08:15 +0000 Subject: [PATCH 62/99] fix(front-end): only a namespace body's type names shadow a type reference (U19) DeepSeek delta at 3ff23ef: 1f3d00c put every name a namespace body declares in scope, so a const A in N shadowed an imported type A in N.f's return type. Only names declared as an interface, type alias, class, enum or namespace are in scope for type positions. Builder scenario; it fails at 3ff23ef. An exported import alias in a body is pinned as refused (existing behaviour). Maintainability review: TypeContext.typeParameters is namesInScope, three module-private helpers lose their export, and the 1180-line model feature is split into the built-model feature and a refusals feature over one shared fixture module. --- .../src/front-end/build-model.ts | 25 ++- .../src/front-end/canonical-type-text.ts | 20 +- .../src/front-end/declaration-graph.ts | 4 +- .../src/front-end/type-expr-of.ts | 6 +- .../tests/__fixtures__/declarations.ts | 35 ++++ .../tests/front-end-model.integration.test.ts | 190 +++--------------- .../front-end-refusals.integration.test.ts | 167 +++++++++++++++ 7 files changed, 263 insertions(+), 184 deletions(-) create mode 100644 packages/api-extractor/tests/__fixtures__/declarations.ts create mode 100644 packages/api-extractor/tests/front-end-refusals.integration.test.ts diff --git a/packages/api-extractor/src/front-end/build-model.ts b/packages/api-extractor/src/front-end/build-model.ts index 9000c74..e2c2d14 100644 --- a/packages/api-extractor/src/front-end/build-model.ts +++ b/packages/api-extractor/src/front-end/build-model.ts @@ -38,7 +38,7 @@ import type { Declarations } from './declarations.schema.ts' import { UnsupportedDeclaration } from './front-end-error.schema.ts' import { TypeExprs } from './type-expr-of.ts' -const { withTypeParameters } = TypePrinting +const { withNamesInScope } = TypePrinting const { classMembersOf, heritageOf, membersOf, referenceToExpression, signatureOf, typeExprOf, typeParametersOf } = TypeExprs @@ -269,7 +269,7 @@ const namespacesIn = (declarations: ReadonlyArray): ReadonlyArray => Effect.gen(function* () { - const inner = withTypeParameters(context, typeParameterNames(node.typeParameters)) + 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 } @@ -306,7 +306,7 @@ const interfaceItemOf = ( Effect.asSome( Effect.gen(function* () { const first = Arr.headNonEmpty(present) - const inner = withTypeParameters(context, typeParameterNames(first.typeParameters)) + 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)) @@ -335,7 +335,7 @@ const classItemOf = ( onNonEmpty: ([node]) => Effect.asSome( Effect.gen(function* () { - const inner = withTypeParameters(context, typeParameterNames(node.typeParameters)) + 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) @@ -485,12 +485,25 @@ const itemsOfGroups = ( (items) => items.flat(), ) +const isTypeSpaceKind = (kind: Declared['kind']): boolean => + Match.value(kind).pipe( + Match.whenOr('interface', 'typeAlias', 'class', 'enum', 'namespace', () => true), + Match.orElse(() => false), + ) + +const declaresType = (declarations: ReadonlyArray): boolean => + Arr.some(declarations, (declaration) => isTypeSpaceKind(declaration.declared.kind)) + +const typeSpaceNames = (groups: HashMap.HashMap>): ReadonlyArray => + Arr.filter(Arr.fromIterable(HashMap.keys(groups)), (name) => + Option.match(HashMap.get(groups, name), { onNone: () => false, onSome: declaresType })) + const namespaceBodyItems = ( context: TypeContext, bodies: ReadonlyArray, ): Effect.Effect, UnsupportedDeclaration> => Effect.flatMap(bodyDeclarations(context, bodies), (groups) => - itemsOfGroups(withTypeParameters(context, Arr.fromIterable(HashMap.keys(groups))), groups)) + itemsOfGroups(withNamesInScope(context, typeSpaceNames(groups)), groups)) const bodyStatements = (bodies: ReadonlyArray): ReadonlyArray => bodies.flatMap((body) => Option.match(Option.fromNullishOr(body), { onNone: () => [], onSome: (block) => block.body })) @@ -655,7 +668,7 @@ const buildEntry = (graph: DeclarationGraph, entry: EntryFile): Effect.Effect()) 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, typeParameters: HashSet.empty() }) + const contextFor = (at: string): TypeContext => ({ graph, file: at, naming, namesInScope: 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)) diff --git a/packages/api-extractor/src/front-end/canonical-type-text.ts b/packages/api-extractor/src/front-end/canonical-type-text.ts index e3c0e74..135ca91 100644 --- a/packages/api-extractor/src/front-end/canonical-type-text.ts +++ b/packages/api-extractor/src/front-end/canonical-type-text.ts @@ -55,15 +55,15 @@ export type TypeContext = { readonly graph: DeclarationGraph readonly file: string readonly naming: Naming - readonly typeParameters: HashSet.HashSet + readonly namesInScope: HashSet.HashSet } -const withTypeParameters = (context: TypeContext, names: ReadonlyArray): TypeContext => ({ +const withNamesInScope = (context: TypeContext, names: ReadonlyArray): TypeContext => ({ ...context, - typeParameters: HashSet.union(context.typeParameters, HashSet.fromIterable(names)), + namesInScope: HashSet.union(context.namesInScope, HashSet.fromIterable(names)), }) -export const KEYWORD_ENTRIES = [ +const KEYWORD_ENTRIES = [ ['TSAnyKeyword', 'any'], ['TSBigIntKeyword', 'bigint'], ['TSBooleanKeyword', 'boolean'], @@ -153,7 +153,7 @@ const targetOf = (context: TypeContext, file: string, segments: ReadonlyArray Effect.succeed(plainTarget(segments)), onNonEmpty: ([head, ...rest]) => - HashSet.has(context.typeParameters, head) + HashSet.has(context.namesInScope, head) ? Effect.succeed(plainTarget(segments)) : Option.match(resolveName(context.graph, file, head), { onNone: () => Effect.succeed(plainTarget(segments)), @@ -293,7 +293,7 @@ const printSignatureLike = ( decorate: (parts: { readonly typeParameters: string; readonly parameters: string; readonly returnType: string }) => string, ): Effect.Effect => Effect.gen(function* () { - const inner = withTypeParameters(context, typeParameterNames(typeParameters)) + const inner = withNamesInScope(context, typeParameterNames(typeParameters)) const printedTypeParameters = yield* printTypeParameters(inner, typeParameters) const printedParameters = yield* printParameters(inner, params) const printedReturnType = yield* tail(inner, returnType) @@ -431,7 +431,7 @@ const mappedOptionalSuffix = (optional: TSMappedType['optional']): string => const printMappedType = (context: TypeContext, node: TSMappedType): Effect.Effect => Effect.gen(function* () { - const inner = withTypeParameters(context, [node.key.name]) + 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}`) @@ -448,7 +448,7 @@ const printConditional = (context: TypeContext, node: TSConditionalType): Effect Effect.gen(function* () { const check = yield* canonicalTypeText(context, node.checkType) const extending = yield* canonicalTypeText(context, node.extendsType) - const consequent = yield* canonicalTypeText(withTypeParameters(context, inferNamesFor(context, node)), node.trueType) + const consequent = yield* canonicalTypeText(withNamesInScope(context, inferNamesFor(context, node)), node.trueType) const alternate = yield* canonicalTypeText(context, node.falseType) return `${check} extends ${extending} ? ${consequent} : ${alternate}` }) @@ -537,11 +537,11 @@ export const TypePrinting: { segments: ReadonlyArray, source: Option.Option, ) => Effect.Effect - readonly withTypeParameters: (context: TypeContext, names: ReadonlyArray) => TypeContext + readonly withNamesInScope: (context: TypeContext, names: ReadonlyArray) => TypeContext readonly bindingName: (pattern: BindingPattern) => string } = { canonicalTypeText: canonicalTypeText, referenceTargetFor: referenceTargetFor, - withTypeParameters: withTypeParameters, + withNamesInScope: withNamesInScope, bindingName: bindingName, } diff --git a/packages/api-extractor/src/front-end/declaration-graph.ts b/packages/api-extractor/src/front-end/declaration-graph.ts index 719bef6..7f3127a 100644 --- a/packages/api-extractor/src/front-end/declaration-graph.ts +++ b/packages/api-extractor/src/front-end/declaration-graph.ts @@ -71,7 +71,7 @@ const candidatesForBase = (base: string): ReadonlyArray => const joinedSpecifier = (fromDeclaration: string, specifier: string): string => [directoryOf(fromDeclaration), specifier].filter((part) => part !== '').join('/') -export const declarationCandidates: { +const declarationCandidates: { (specifier: string): (fromDeclaration: string) => ReadonlyArray (fromDeclaration: string, specifier: string): ReadonlyArray } = Fn.dual(2, (fromDeclaration: string, specifier: string): ReadonlyArray => @@ -132,7 +132,7 @@ const moduleNameOf = (name: ModuleExportName): string => (name.type === 'Literal const sourceOfExport = (node: ExportNamedDeclaration): ReadonlyArray => (node.source === null ? [] : [node.source.value]) -export const specifiersOf = (program: Program): ReadonlyArray => { +const specifiersOf = (program: Program): ReadonlyArray => { const found: Array = [] new Visitor({ ImportDeclaration: (node) => void found.push(node.source.value), diff --git a/packages/api-extractor/src/front-end/type-expr-of.ts b/packages/api-extractor/src/front-end/type-expr-of.ts index 4724718..226320b 100644 --- a/packages/api-extractor/src/front-end/type-expr-of.ts +++ b/packages/api-extractor/src/front-end/type-expr-of.ts @@ -42,7 +42,7 @@ import { import { refuse } from './declaration-graph.ts' import { UnsupportedDeclaration } from './front-end-error.schema.ts' -const { bindingName, canonicalTypeText, referenceTargetFor, withTypeParameters } = TypePrinting +const { bindingName, canonicalTypeText, referenceTargetFor, withNamesInScope } = TypePrinting const unknownKeyword: TypeExpr = { kind: 'keyword', name: 'unknown' } @@ -122,7 +122,7 @@ const typeParametersOf = ( Option.match(Option.fromNullishOr(declaration), { onNone: () => Effect.succeed>([]), onSome: (present) => { - const inner = withTypeParameters(context, typeParameterNames(present)) + const inner = withNamesInScope(context, typeParameterNames(present)) return Effect.forEach(present.params, (parameter) => typeParameterOf(inner, parameter)) }, }) @@ -157,7 +157,7 @@ const signatureOf = ( returnType: TSTypeAnnotation | null | undefined, ): Effect.Effect => Effect.gen(function* () { - const inner = withTypeParameters(context, typeParameterNames(typeParameters)) + 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') diff --git a/packages/api-extractor/tests/__fixtures__/declarations.ts b/packages/api-extractor/tests/__fixtures__/declarations.ts new file mode 100644 index 0000000..b82b53a --- /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: ReadonlyArray): 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/front-end-model.integration.test.ts b/packages/api-extractor/tests/front-end-model.integration.test.ts index c934521..110ee69 100644 --- a/packages/api-extractor/tests/front-end-model.integration.test.ts +++ b/packages/api-extractor/tests/front-end-model.integration.test.ts @@ -1,37 +1,13 @@ import { Gherkin, Given, it, makeFeature, Then, When } from '@systemfsoftware/effect-gherkin-spec' import { ApiModel, FrontEnd } from '@systemfsoftware/api-extractor' -import { Array as Arr, Effect, Layer, Option } from 'effect' +import { Effect, Layer } from 'effect' import { isolatedDeclarationSync } from 'oxc-transform' - -type Model = ApiModel.ApiModel -type EntryPoint = ApiModel.EntryPoint -type Item = ApiModel.ApiItem +import { builtOf, declarations, entryAt, itemOf, itemsNamed, type Model } from './__fixtures__/declarations.ts' const Feature = makeFeature({ it }) -const declarations = ( - files: ReadonlyArray, - entryPoints: ReadonlyArray = [['.', 'index.d.ts']], -): FrontEnd.Declarations => ({ - producer: 'fixture-emitter@0.0.0', - package: { name: 'fixture', version: '1.0.0' }, - entryPoints: entryPoints.map(([exportPath, declarationFile]) => ({ exportPath, declarationFile })), - files: files.map(([path, text]) => ({ path, text })), -}) - -const entryAt = (model: Model, path: string): EntryPoint => - Option.getOrThrow(Arr.findFirst(model.entryPoints, (entry) => entry.path === path)) - -const itemOf = (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)) - -const itemsNamed = (model: Model, path: string, name: string): ReadonlyArray => - entryAt(model, path).items.filter((item) => item.name === name) - const itemNames = (model: Model, path: string): ReadonlyArray => entryAt(model, path).items.map((item) => item.name) -const builtOf = (given: FrontEnd.Declarations) => FrontEnd.buildModel(given) - Feature('Building a package API model from its declaration text') .withLayer(Layer.empty) .body(({ scenario }) => { @@ -286,43 +262,6 @@ Feature('Building a package API model from its declaration text') ), ) - 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 built model decodes as schema version two', Gherkin.Do.pipe( @@ -530,100 +469,6 @@ Feature('Building a package API model from its declaration text') ), ) - 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 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( 'a set accessor models as a writable property and prints without a return type', Gherkin.Do.pipe( @@ -1119,15 +964,34 @@ Feature('Building a package API model from its declaration text') ) scenario( - 'an import alias inside a namespace body is refused, as it is at the top of a file', + 'a namespace body value does not shadow an imported type of the same name', Gherkin.Do.pipe( - Given('an entry whose namespace `N` writes `import X = M.Y`')('declarations', () => + 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', 'export declare namespace N { import X = M.Y; const a: X }\ndeclare namespace M { type Y = string }\nexport {}\n']]), + 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')('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 =`' })), + 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: [] } }, + ], + }, + ], + })), ), ) 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..6cdfa0f --- /dev/null +++ b/packages/api-extractor/tests/front-end-refusals.integration.test.ts @@ -0,0 +1,167 @@ +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 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 =`' })), + ), + ) + }) From 681efe30d61d7602ffb482c79d1c9553814b1665 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Wed, 14 Oct 2026 00:08:50 +0000 Subject: [PATCH 63/99] docs(plan): rule every finding of the 3ff23ef review (U19) Supersedes the 2348 plan. Records the fixing commits for the symlink alias walk, value-name shadowing, the test-file split and the pins; the rejections with their reasons; and the PR4/PR5 follow-ups with triggers. --- ...> 2026-10-14-0008-feat-front-ends-plan.md} | 24 +++++++++++++++++-- ...lback-inherits-declaration-emit-options.md | 2 +- 2 files changed, 23 insertions(+), 3 deletions(-) rename docs/plans/{2026-10-13-2348-feat-front-ends-plan.md => 2026-10-14-0008-feat-front-ends-plan.md} (94%) diff --git a/docs/plans/2026-10-13-2348-feat-front-ends-plan.md b/docs/plans/2026-10-14-0008-feat-front-ends-plan.md similarity index 94% rename from docs/plans/2026-10-13-2348-feat-front-ends-plan.md rename to docs/plans/2026-10-14-0008-feat-front-ends-plan.md index 88d2d5a..d982ca6 100644 --- a/docs/plans/2026-10-13-2348-feat-front-ends-plan.md +++ b/docs/plans/2026-10-14-0008-feat-front-ends-plan.md @@ -6,7 +6,7 @@ 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-13-2333-feat-front-ends-plan.md +supersedes: docs/plans/2026-10-13-2348-feat-front-ends-plan.md execution: code --- @@ -495,6 +495,26 @@ U1 → U2 (profile committed) → U3 (choice recorded) → U4 → U5 → U6 → - **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. + --- ## Verification Contract @@ -517,7 +537,7 @@ No mutation run, local or in CI (origin R23). ## Definition of Done -- U1-U18 verified; every Verification Contract gate passes locally and CI is green on the exact PR head. +- U1-U19 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. 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 index 7737cba..87fb2f7 100644 --- a/docs/solutions/logic-errors/compiler-fallback-inherits-declaration-emit-options.md +++ b/docs/solutions/logic-errors/compiler-fallback-inherits-declaration-emit-options.md @@ -158,6 +158,6 @@ derived at mirror(dirname(packageTsconfig)) = { extends: packageTsconfig, files: ## Related -- `docs/plans/2026-10-13-2348-feat-front-ends-plan.md` (R3; U10 disposition #6; U12 emit policy; U13 #4, the mirrored `node_modules` ancestry and its delta ruling) +- `docs/plans/2026-10-14-0008-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 From 24e9b67a70953f0da3dd16d74daf716f39466820 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Wed, 14 Oct 2026 00:23:19 +0000 Subject: [PATCH 64/99] fix(front-end): a pattern walk lists at most 4096 directories (U20) Security review at 681efe3 (P1): symlinked aliases of a directory, which J1 keeps walking under every name, fan out exponentially with depth. The walk counts the directories it lists per pattern export and refuses past 4096 with ManifestRejected naming the export; a symlink back to an ancestor is still never entered. Reader scenario: twelve nested a/b->a levels list 8191 directories at 681efe3 (NoSourceEntry) and are refused now. --- README.md | 5 +- .../src/front-end/read-package.ts | 62 ++++++++++++------- .../tests/package-reader.integration.test.ts | 33 ++++++++++ 3 files changed, 76 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 8596592..95b239b 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,10 @@ subpath wins over a pattern. The condition is read where the family writes it, d under an export path; a target that nests it under another condition, or lists targets in an array, 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. A `package.json` +refuses — is refused with `ManifestRejected`, naming the package root. So is a pattern +export whose walk lists more than 4096 directories, which symlinked aliases of a +directory can reach by fanning out; a symlink back to an ancestor is never entered. +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, diff --git a/packages/api-extractor/src/front-end/read-package.ts b/packages/api-extractor/src/front-end/read-package.ts index d57e79a..cc1ebf6 100644 --- a/packages/api-extractor/src/front-end/read-package.ts +++ b/packages/api-extractor/src/front-end/read-package.ts @@ -108,50 +108,66 @@ const directoryRealPath = (childPath: string): Effect.Effect()) }) -const walkStep = (root: string, relative: string, ancestors: HashSet.HashSet): Effect.Effect, PlatformError, Platform> => +const MAX_PATTERN_DIRECTORIES = 4096 + +type Walk = { readonly names: ReadonlyArray; readonly listed: number } + +type WalkPattern = { readonly root: string; readonly key: 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 names = yield* listNames(path.join(root, relative)) - return yield* Effect.reduce(Arr.sort(names, Order.String), Arr.empty, stepChild(root, relative, ancestors)) + 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(Arr.sort(names, Order.String), () => ({ names: walk.names, listed }), stepChild(pattern, relative, ancestors)) }) const enterDirectory = ( - root: string, + pattern: WalkPattern, relative: string, - names: ReadonlyArray, ancestors: HashSet.HashSet, + walk: Walk, realPath: string, -): Effect.Effect, PlatformError, Platform> => - HashSet.has(ancestors, realPath) - ? Effect.succeed(names) - : Effect.map(walkStep(root, relative, HashSet.add(ancestors, realPath)), (walked) => Arr.appendAll(names, walked)) +): Effect.Effect => + HashSet.has(ancestors, realPath) ? Effect.succeed(walk) : walkStep(pattern, relative, HashSet.add(ancestors, realPath), walk) -const stepChild = (root: string, relative: string, ancestors: HashSet.HashSet) => - (names: ReadonlyArray, name: string): Effect.Effect, PlatformError, Platform> => +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* Option.match(yield* directoryRealPath(path.join(root, relative, name)), { - onNone: () => Effect.succeed(Arr.append(names, childRelative)), - onSome: (realPath) => enterDirectory(root, childRelative, names, ancestors, realPath), + return yield* 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 = (root: string, relative: string): Effect.Effect, PlatformError, Platform> => +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(root, relative) + const absolute = path.join(pattern.root, relative) const seed = yield* fileSystem.realPath(absolute).pipe(Effect.catchIf(isAbsent, () => Effect.succeed(absolute))) - return yield* walkStep(root, relative, HashSet.make(seed)) + const walk = yield* walkStep(pattern, relative, HashSet.make(seed), { names: [], listed: 0 }) + return walk.names }) -const walkDirectory = (root: string, directory: string): Effect.Effect, PlatformError, Platform> => +const walkDirectory = (pattern: WalkPattern, directory: string): Effect.Effect, WalkError, Platform> => Effect.gen(function* () { const path = yield* Path.Path - return yield* Option.match(withinRoot(path, root, path.resolve(root, directory)), { + return yield* Option.match(withinRoot(path, pattern.root, path.resolve(pattern.root, directory)), { onNone: () => Effect.succeed>([]), - onSome: (relative) => walkRelative(root, relative), + onSome: (relative) => walkRelative(pattern, relative), }) }) @@ -178,20 +194,20 @@ const bestPerPath = (items: ReadonlyArray): ReadonlyArray, PlatformError, Platform> => +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) => - Effect.map(walkDirectory(root, directoryOf(stripRelativePrefix(parts.prefix))), (candidates) => + Effect.map(walkDirectory({ root, key }, directoryOf(stripRelativePrefix(parts.prefix))), (candidates) => Arr.getSomes(candidates.filter(isTypeScriptSource).map(patternEntryOf(key, stripRelativePrefix(parts.prefix), parts.suffix))), ), }), }) -const entryPointsOf = (root: string, manifest: PackageManifest, condition: string): Effect.Effect, PlatformError, Platform> => +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]) => !isPatternKey(exportPath)) diff --git a/packages/api-extractor/tests/package-reader.integration.test.ts b/packages/api-extractor/tests/package-reader.integration.test.ts index 210e8d5..87cd355 100644 --- a/packages/api-extractor/tests/package-reader.integration.test.ts +++ b/packages/api-extractor/tests/package-reader.integration.test.ts @@ -114,6 +114,22 @@ const aliasedSiblingPatternPackage = Effect.gen(function* () { return root }) +const ALIAS_DEPTH = 12 + +const aliasFanOutPackage = 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' } } }), + ) + const levels = Array.from({ length: ALIAS_DEPTH }, (_, depth) => path.join(root, 'src', ...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 @@ -321,6 +337,23 @@ Feature("A package's exports name its entry points") ), ) + 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, + ), + 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 4096 directories', + })), + ), + ) + scenario( 'a package whose exports is a bare path or absent names no source entry', Gherkin.Do.pipe( From 4ba7034d2c59c08f25bf76f8e36af8c709d51a11 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Wed, 14 Oct 2026 00:23:39 +0000 Subject: [PATCH 65/99] fix(front-end): scope, private constructors and index-signature names in the model (U20) Review at 681efe3: - correctness P2: a type parameter hid a same-named type reached in another file (f(): ns.T read as the parameter). A lookup that crosses a file boundary starts with no names in scope. - 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'. - an index signature took its parameter's name, so renaming k to key changed the model; it is named by its key type alone, as tsc reads it. - the declarations fixture takes at most one entry-point list. Builder scenarios; the scope one fails at 681efe3, the other two failed before the fix with the dropped member and the parameter name. --- .../src/front-end/canonical-type-text.ts | 8 +-- .../src/front-end/type-expr-of.ts | 12 +++-- .../tests/__fixtures__/declarations.ts | 2 +- .../tests/front-end-model.integration.test.ts | 51 +++++++++++++++++++ 4 files changed, 66 insertions(+), 7 deletions(-) diff --git a/packages/api-extractor/src/front-end/canonical-type-text.ts b/packages/api-extractor/src/front-end/canonical-type-text.ts index 135ca91..50fb830 100644 --- a/packages/api-extractor/src/front-end/canonical-type-text.ts +++ b/packages/api-extractor/src/front-end/canonical-type-text.ts @@ -149,6 +149,8 @@ const plainTarget = (segments: ReadonlyArray): ReferenceTarget => ({ nam 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() }) + const targetOf = (context: TypeContext, file: string, segments: ReadonlyArray): Effect.Effect => Arr.match(segments, { onEmpty: () => Effect.succeed(plainTarget(segments)), @@ -165,7 +167,7 @@ const targetOf = (context: TypeContext, file: string, segments: ReadonlyArray targetOf(context, namespace.file, rest)), + Match.when({ kind: 'namespace' }, (namespace) => targetOf(inOtherFile(context), namespace.file, rest)), Match.exhaustive, ), }), @@ -182,7 +184,7 @@ const referenceTargetFor = ( isRelativeSpecifier(specifier) ? Option.match(resolveFile(context.graph, context.file, specifier), { onNone: () => Effect.succeed(plainTarget(segments)), - onSome: (file) => targetOf(context, file, segments), + onSome: (file) => targetOf(inOtherFile(context), file, segments), }) : Effect.succeed({ name: segments.join('.'), module: Option.some(specifier) }), }) @@ -338,7 +340,7 @@ const printIndexSignature = (context: TypeContext, member: TSIndexSignature): Ef Effect.gen(function* () { const key = yield* canonicalTypeText(context, parameter.typeAnnotation.typeAnnotation) const value = yield* canonicalTypeText(context, member.typeAnnotation.typeAnnotation) - return `${readonlyPrefix(member.readonly)}[${parameter.name}: ${key}]: ${value}` + return `${readonlyPrefix(member.readonly)}[key: ${key}]: ${value}` }), }) diff --git a/packages/api-extractor/src/front-end/type-expr-of.ts b/packages/api-extractor/src/front-end/type-expr-of.ts index 226320b..e839959 100644 --- a/packages/api-extractor/src/front-end/type-expr-of.ts +++ b/packages/api-extractor/src/front-end/type-expr-of.ts @@ -293,7 +293,7 @@ const indexSignatureMemberOf = (context: TypeContext, member: TSIndexSignature): 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: `[${parameter.name}: ${key}]`, optional: false, readonly: member.readonly, type } + return { kind: 'property' as const, name: `[key: ${key}]`, optional: false, readonly: member.readonly, type } }), }) @@ -335,13 +335,19 @@ const accessibleName = ( Match.orElse(() => Option.map(propertyKeyNameOf(key), (name) => (isStatic ? `static ${name}` : name))), ) +const classMethodName = (method: MethodDefinition): Option.Option => + Match.value(method).pipe( + Match.when({ kind: 'constructor' }, (constructor) => Option.some(constructor.accessibility === 'private' ? 'private constructor' : 'constructor')), + Match.orElse(() => accessibleName(method.key, method.accessibility, method.static)), + ) + const classMethodDraftOf = (context: TypeContext, method: MethodDefinition): Effect.Effect, UnsupportedDeclaration> => - Option.match(accessibleName(method.key, method.accessibility, method.static), { + 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: 'constructor', optional: false, signatures: [signature] })), + 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) })), diff --git a/packages/api-extractor/tests/__fixtures__/declarations.ts b/packages/api-extractor/tests/__fixtures__/declarations.ts index b82b53a..d1601a0 100644 --- a/packages/api-extractor/tests/__fixtures__/declarations.ts +++ b/packages/api-extractor/tests/__fixtures__/declarations.ts @@ -7,7 +7,7 @@ export type Item = ApiModel.ApiItem export type SourceFiles = ReadonlyArray -export const declarations = (files: SourceFiles, ...entryPoints: ReadonlyArray): FrontEnd.Declarations => ({ +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 })), diff --git a/packages/api-extractor/tests/front-end-model.integration.test.ts b/packages/api-extractor/tests/front-end-model.integration.test.ts index 110ee69..683b8a8 100644 --- a/packages/api-extractor/tests/front-end-model.integration.test.ts +++ b/packages/api-extractor/tests/front-end-model.integration.test.ts @@ -963,6 +963,57 @@ Feature('Building a package API model from its declaration text') ), ) + 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( 'a namespace body value does not shadow an imported type of the same name', Gherkin.Do.pipe( From 9e220fccb2adccf0c63edbcd3a2b7efba6ca9eca Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Wed, 14 Oct 2026 00:23:39 +0000 Subject: [PATCH 66/99] fix(front-end): a namespace body's names shadow imports in the resolver (U20) Review at 681efe3: the resolver ignored a namespace body's own declarations, so a reference to the body's type A reported the imported A as a forgotten export. declaration-graph's 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 queries and class heritage the value space. Analysis scenarios agree with api-extractor; two fail at 681efe3. --- .../src/front-end/build-model.ts | 17 +-- .../src/front-end/declaration-graph.ts | 72 +++++++++ .../src/front-end/resolve-messages.ts | 86 +++++++---- .../analysis-messages.integration.test.ts | 144 ++++++++++++++++++ 4 files changed, 277 insertions(+), 42 deletions(-) diff --git a/packages/api-extractor/src/front-end/build-model.ts b/packages/api-extractor/src/front-end/build-model.ts index e2c2d14..01b04d4 100644 --- a/packages/api-extractor/src/front-end/build-model.ts +++ b/packages/api-extractor/src/front-end/build-model.ts @@ -33,7 +33,7 @@ import { 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, exportedNames, exportedSymbol, grouped, refuse, type Declaration, type Declared, type DeclarationGraph } from './declaration-graph.ts' +import { declarationGraph, exportedNames, exportedSymbol, grouped, namespaceSpacesOf, refuse, type Declaration, type Declared, type DeclarationGraph } 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' @@ -485,25 +485,12 @@ const itemsOfGroups = ( (items) => items.flat(), ) -const isTypeSpaceKind = (kind: Declared['kind']): boolean => - Match.value(kind).pipe( - Match.whenOr('interface', 'typeAlias', 'class', 'enum', 'namespace', () => true), - Match.orElse(() => false), - ) - -const declaresType = (declarations: ReadonlyArray): boolean => - Arr.some(declarations, (declaration) => isTypeSpaceKind(declaration.declared.kind)) - -const typeSpaceNames = (groups: HashMap.HashMap>): ReadonlyArray => - Arr.filter(Arr.fromIterable(HashMap.keys(groups)), (name) => - Option.match(HashMap.get(groups, name), { onNone: () => false, onSome: declaresType })) - const namespaceBodyItems = ( context: TypeContext, bodies: ReadonlyArray, ): Effect.Effect, UnsupportedDeclaration> => Effect.flatMap(bodyDeclarations(context, bodies), (groups) => - itemsOfGroups(withNamesInScope(context, typeSpaceNames(groups)), groups)) + itemsOfGroups(withNamesInScope(context, namespaceSpacesOf(bodies).types), groups)) const bodyStatements = (bodies: ReadonlyArray): ReadonlyArray => bodies.flatMap((body) => Option.match(Option.fromNullishOr(body), { onNone: () => [], onSome: (block) => block.body })) diff --git a/packages/api-extractor/src/front-end/declaration-graph.ts b/packages/api-extractor/src/front-end/declaration-graph.ts index 7f3127a..3889ff9 100644 --- a/packages/api-extractor/src/front-end/declaration-graph.ts +++ b/packages/api-extractor/src/front-end/declaration-graph.ts @@ -14,7 +14,9 @@ import { type Program, type Statement, type TSEnumDeclaration, + type TSGlobalDeclaration, type TSInterfaceDeclaration, + type TSModuleBlock, type TSModuleDeclaration, type TSTypeAliasDeclaration, type VariableDeclaration, @@ -296,6 +298,76 @@ export type Declaration = { 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 optionalDeclaration = (id: { readonly name: string } | null, kind: Declared['kind']): ReadonlyArray => + Option.match(Option.fromNullishOr(id), { onNone: () => [], onSome: (present) => [namedDeclaration(present.name, kind)] }) + +const variableDeclarationsIn = (variable: VariableDeclaration): ReadonlyArray => + Arr.getSomes( + variable.declarations.map((declarator) => + declarator.id.type === 'Identifier' ? Option.some(namedDeclaration(declarator.id.name, 'variable')) : Option.none()), + ) + +const namespaceNameIn = (id: TSModuleDeclaration['id']): ReadonlyArray => + Match.value(id).pipe( + Match.when({ type: 'Identifier' }, (identifier) => [namedDeclaration(identifier.name, 'namespace')]), + Match.orElse(() => []), + ) + +const namespaceDeclarationIn = (module: TSModuleDeclaration | TSGlobalDeclaration): ReadonlyArray => + module.kind === 'global' ? [] : namespaceNameIn(module.id) + +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.when({ type: 'VariableDeclaration' }, (variable: VariableDeclaration) => variableDeclarationsIn(variable)), + Match.when({ type: Match.is('FunctionDeclaration', 'TSDeclareFunction') }, (function_: FunctionNode) => optionalDeclaration(function_.id, 'function')), + Match.when({ type: 'ClassDeclaration' }, (class_: Class) => optionalDeclaration(class_.id, 'class')), + Match.when({ type: 'TSInterfaceDeclaration' }, (iface: TSInterfaceDeclaration) => [namedDeclaration(iface.id.name, 'interface')]), + Match.when({ type: 'TSTypeAliasDeclaration' }, (alias: TSTypeAliasDeclaration) => [namedDeclaration(alias.id.name, 'typeAlias')]), + Match.when({ type: 'TSEnumDeclaration' }, (enumeration: TSEnumDeclaration) => [namedDeclaration(enumeration.id.name, 'enum')]), + Match.when({ type: 'TSModuleDeclaration' }, (module: TSModuleDeclaration | TSGlobalDeclaration) => namespaceDeclarationIn(module)), + Match.orElse(() => []), + ) + +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 diff --git a/packages/api-extractor/src/front-end/resolve-messages.ts b/packages/api-extractor/src/front-end/resolve-messages.ts index 6af361f..40efae3 100644 --- a/packages/api-extractor/src/front-end/resolve-messages.ts +++ b/packages/api-extractor/src/front-end/resolve-messages.ts @@ -8,8 +8,11 @@ import { type TSClassImplements, type TSImportType, type TSConditionalType, + type TSGlobalDeclaration, type TSInferType, type TSInterfaceHeritage, + type TSModuleBlock, + type TSModuleDeclaration, type TSMappedType, type TSTypeName, type TSTypeParameterDeclaration, @@ -19,7 +22,7 @@ import { } from 'oxc-parser' import type { AnalysisMessage, AnalysisMessageId } from './analysis-message.schema.ts' import type { Declaration, DeclarationGraph, FileScope, ResolvedSymbol } from './declaration-graph.ts' -import { declarationGraph, exportedNames, exportedSymbol, resolveName } 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' @@ -77,10 +80,13 @@ type ReferenceScan = { readonly references: ReadonlyArray } -type TypeParameterScope = { +type NameSpace = 'type' | 'value' + +type NameScope = { readonly start: number readonly end: number - readonly names: ReadonlyArray + readonly types: ReadonlyArray + readonly values: ReadonlyArray } type ScopeCarrier = { @@ -91,45 +97,69 @@ type ScopeCarrier = { const EMPTY_SCAN: ReferenceScan = { references: [] } -const withinScope = (scope: TypeParameterScope, at: number): boolean => at >= scope.start && at < scope.end +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: TypeParameterScope, name: string): boolean => Arr.some(scope.names, (candidate) => candidate === name) +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): boolean => - Arr.some(scopes, (scope) => withinScope(scope, at) && matchesName(scope, name)) +const shadowed = (scopes: ReadonlyArray, name: string, at: number, space: NameSpace): boolean => + Arr.some(scopes, (scope) => withinScope(scope, at) && matchesName(scope, name, space)) const typeParameterNamesOf = (declaration: TSTypeParameterDeclaration | null): ReadonlyArray => declaration === null ? [] : declaration.params.map((parameter) => parameter.name.name) -const addReference = (references: Array, scopes: ReadonlyArray, name: string, at: number): void => { - if (!shadowed(scopes, name, at)) { +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, names: typeParameterNamesOf(node.typeParameters ?? null) }) +const openScope = (scopes: Array, node: ScopeCarrier): void => + void scopes.push({ start: node.start, end: node.end, types: typeParameterNamesOf(node.typeParameters ?? null), values: [] }) + +const closeScope = (scopes: Array): void => void scopes.pop() -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: [] }) -const openNamedScope = (scopes: Array, node: TSMappedType): void => - void scopes.push({ start: node.start, end: node.end, names: [node.key.name] }) +type ModuleDeclaration = TSModuleDeclaration | TSGlobalDeclaration + +const moduleBlockOf = (node: ModuleDeclaration): Option.Option => Option.fromNullishOr(node.body) + +const openNamespaceScope = (scopes: Array, node: ModuleDeclaration): void => + Option.match(moduleBlockOf(node), { + onNone: () => undefined, + onSome: (block) => void scopes.push({ start: block.start, end: block.end, ...namespaceSpacesOf([block]) }), + }) + +const closeNamespaceScope = (scopes: Array, node: ModuleDeclaration): void => + Option.match(moduleBlockOf(node), { onNone: () => undefined, onSome: () => closeScope(scopes) }) type ConditionalScope = { readonly start: number readonly end: number readonly extendsStart: number readonly extendsEnd: number - readonly names: Array + 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, names: [] } +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 => { +const closeConditional = (scopes: Array, conditionals: Array): void => { scopes.pop() conditionals.pop() } @@ -139,24 +169,24 @@ const bindsInfer = (scope: ConditionalScope, at: number): boolean => at >= scope const bindInfer = (conditionals: ReadonlyArray, node: TSInferType): void => Option.match(Arr.findLast(conditionals, (scope) => bindsInfer(scope, node.start)), { onNone: () => undefined, - onSome: (scope) => void scope.names.push(node.typeParameter.name.name), + onSome: (scope) => void scope.types.push(node.typeParameter.name.name), }) const scanOf = (program: Program): ReferenceScan => { const references: Array = [] - const scopes: Array = [] + const scopes: Array = [] const conditionals: Array = [] - const record = (node: NameNode | null, start: number): void => - Option.match(rootNameOf(node), { onNone: () => undefined, onSome: (name) => addReference(references, scopes, name, start) }) + 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), - TSTypeQuery: (node: TSTypeQuery) => record(node.exprName, node.start), - TSInterfaceHeritage: (node: TSInterfaceHeritage) => record(node.expression, node.start), - TSClassImplements: (node: TSClassImplements) => record(node.expression, node.start), + 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) + record(node.superClass, node.start, 'value') enter(node) }, 'ClassDeclaration:exit': exit, @@ -186,6 +216,8 @@ const scanOf = (program: Program): ReferenceScan => { 'TSFunctionType:exit': exit, TSConstructorType: enter, 'TSConstructorType:exit': exit, + TSModuleDeclaration: (node: ModuleDeclaration) => openNamespaceScope(scopes, node), + 'TSModuleDeclaration:exit': (node: ModuleDeclaration) => closeNamespaceScope(scopes, node), TSMappedType: (node: TSMappedType) => openNamedScope(scopes, node), 'TSMappedType:exit': exit, TSConditionalType: (node: TSConditionalType) => openConditional(scopes, conditionals, node), diff --git a/packages/api-extractor/tests/analysis-messages.integration.test.ts b/packages/api-extractor/tests/analysis-messages.integration.test.ts index 3b13d9e..2d9fb46 100644 --- a/packages/api-extractor/tests/analysis-messages.integration.test.ts +++ b/packages/api-extractor/tests/analysis-messages.integration.test.ts @@ -323,6 +323,150 @@ Feature('Review notes for a package match the ones api-extractor derives') ), ) + 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( 'Every note for this package matches the ones api-extractor derives for it', Gherkin.Do.pipe( From abb30229fe371b7e9dd25ee7b676d962b4aafcd7 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Wed, 14 Oct 2026 00:26:31 +0000 Subject: [PATCH 67/99] fix(front-end): typeof and class heritage read a namespace body's values (U20) The builder scoped only a namespace body's type names, so with const A declared in N, 'typeof A' printed the imported A (typeof import("pkg").A). TypeContext gains valuesInScope from namespaceSpacesOf; typeof queries and a class's superclass resolve their head in the value space, their type arguments in the type space. A file boundary clears both. Builder scenario; fails at 9e220fc. --- .../src/front-end/build-model.ts | 10 +++--- .../src/front-end/canonical-type-text.ts | 26 ++++++++++++-- .../src/front-end/type-expr-of.ts | 34 +++++++++++++------ .../tests/front-end-model.integration.test.ts | 23 +++++++++++++ 4 files changed, 75 insertions(+), 18 deletions(-) diff --git a/packages/api-extractor/src/front-end/build-model.ts b/packages/api-extractor/src/front-end/build-model.ts index 01b04d4..bd7193e 100644 --- a/packages/api-extractor/src/front-end/build-model.ts +++ b/packages/api-extractor/src/front-end/build-model.ts @@ -38,9 +38,9 @@ import type { Declarations } from './declarations.schema.ts' import { UnsupportedDeclaration } from './front-end-error.schema.ts' import { TypeExprs } from './type-expr-of.ts' -const { withNamesInScope } = TypePrinting +const { withNamesInScope, withNamespaceScope } = TypePrinting -const { classMembersOf, heritageOf, membersOf, referenceToExpression, signatureOf, typeExprOf, typeParametersOf } = TypeExprs +const { classMembersOf, heritageOf, membersOf, superClassOf, signatureOf, typeExprOf, typeParametersOf } = TypeExprs type LocalDeclaration = { readonly key: string @@ -322,7 +322,7 @@ const implementedClauses = (context: TypeContext, node: Class) => const extendsOf = (context: TypeContext, node: Class): Effect.Effect, UnsupportedDeclaration> => Option.match(Option.fromNullishOr(node.superClass), { onNone: () => Effect.succeed>([]), - onSome: (superClass) => Effect.map(referenceToExpression(context, superClass, node.superTypeArguments), (extended) => [extended]), + onSome: (superClass) => Effect.map(superClassOf(context, superClass, node.superTypeArguments), (extended) => [extended]), }) const classItemOf = ( @@ -490,7 +490,7 @@ const namespaceBodyItems = ( bodies: ReadonlyArray, ): Effect.Effect, UnsupportedDeclaration> => Effect.flatMap(bodyDeclarations(context, bodies), (groups) => - itemsOfGroups(withNamesInScope(context, namespaceSpacesOf(bodies).types), groups)) + itemsOfGroups(withNamespaceScope(context, namespaceSpacesOf(bodies)), groups)) const bodyStatements = (bodies: ReadonlyArray): ReadonlyArray => bodies.flatMap((body) => Option.match(Option.fromNullishOr(body), { onNone: () => [], onSome: (block) => block.body })) @@ -655,7 +655,7 @@ const buildEntry = (graph: DeclarationGraph, entry: EntryFile): Effect.Effect()) 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() }) + 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)) diff --git a/packages/api-extractor/src/front-end/canonical-type-text.ts b/packages/api-extractor/src/front-end/canonical-type-text.ts index 50fb830..1f998be 100644 --- a/packages/api-extractor/src/front-end/canonical-type-text.ts +++ b/packages/api-extractor/src/front-end/canonical-type-text.ts @@ -39,7 +39,15 @@ import type { TemplateElement, } from 'oxc-parser' import type { KeywordName } from '../model/type-expr.schema.ts' -import { type Declaration, type DeclarationGraph, isRelativeSpecifier, refuse, resolveFile, resolveName } from './declaration-graph.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 = { @@ -56,6 +64,7 @@ export type TypeContext = { readonly file: string readonly naming: Naming readonly namesInScope: HashSet.HashSet + readonly valuesInScope: HashSet.HashSet } const withNamesInScope = (context: TypeContext, names: ReadonlyArray): TypeContext => ({ @@ -63,6 +72,13 @@ const withNamesInScope = (context: TypeContext, names: ReadonlyArray): T 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_ENTRIES = [ ['TSAnyKeyword', 'any'], ['TSBigIntKeyword', 'bigint'], @@ -149,7 +165,7 @@ const plainTarget = (segments: ReadonlyArray): ReferenceTarget => ({ nam 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() }) +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, { @@ -412,7 +428,7 @@ const printQuery = (context: TypeContext, node: TSTypeQuery): Effect.Effect Effect.map(printImportType(context, imported), (text) => `typeof ${text}`)), Match.orElse((name) => - Effect.map(referenceTargetFor(context, nameSegments(name), Option.none()), (target) => `typeof ${printReference(target, '')}`)), + Effect.map(referenceTargetFor(inValueSpace(context), nameSegments(name), Option.none()), (target) => `typeof ${printReference(target, '')}`)), ) const mappedReadonlyPrefix = (readonly: TSMappedType['readonly']): string => @@ -540,10 +556,14 @@ export const TypePrinting: { 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/type-expr-of.ts b/packages/api-extractor/src/front-end/type-expr-of.ts index e839959..6473c54 100644 --- a/packages/api-extractor/src/front-end/type-expr-of.ts +++ b/packages/api-extractor/src/front-end/type-expr-of.ts @@ -42,7 +42,7 @@ import { import { refuse } from './declaration-graph.ts' import { UnsupportedDeclaration } from './front-end-error.schema.ts' -const { bindingName, canonicalTypeText, referenceTargetFor, withNamesInScope } = TypePrinting +const { bindingName, canonicalTypeText, inValueSpace, referenceTargetFor, withNamesInScope } = TypePrinting const unknownKeyword: TypeExpr = { kind: 'keyword', name: 'unknown' } @@ -74,14 +74,19 @@ const typeArgumentsOf = ( 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, - segments: ReadonlyArray, - source: Option.Option, + request: ReferenceRequest, typeArguments: TSTypeParameterInstantiation | null | undefined, ): Effect.Effect => Effect.gen(function* () { - const target = yield* referenceTargetFor(context, segments, source) + const target = yield* referenceTargetFor(request.targetContext, request.segments, request.source) const args = yield* typeArgumentsOf(context, typeArguments) return referenceExpr(target.name, target.module, args) }) @@ -166,16 +171,23 @@ const signatureOf = ( 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, segments, Option.none(), typeArguments), + onSome: (segments) => referenceTypeOf(context, { targetContext, segments, source: Option.none() }, typeArguments), }) const heritageOf = (context: TypeContext, heritage: TSInterfaceHeritage | TSClassImplements): Effect.Effect => - referenceToExpression(context, heritage.expression, heritage.typeArguments) + 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' @@ -459,11 +471,13 @@ const typeExprOf = (context: TypeContext, node: TSType): Effect.Effect({ 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, nameSegments(reference.typeName), Option.none(), reference.typeArguments)), + 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, segments, Option.some(imported.source.value), imported.typeArguments), + 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)), @@ -501,7 +515,7 @@ export const TypeExprs: { params: ReadonlyArray, returnType: TSTypeAnnotation | null | undefined, ) => Effect.Effect - readonly referenceToExpression: ( + readonly superClassOf: ( context: TypeContext, expression: Expression, typeArguments: TSTypeParameterInstantiation | null | undefined, @@ -513,7 +527,7 @@ export const TypeExprs: { typeExprOf: typeExprOf, typeParametersOf: typeParametersOf, signatureOf: signatureOf, - referenceToExpression: referenceToExpression, + superClassOf: superClassOf, heritageOf: heritageOf, membersOf: membersOf, classMembersOf: classMembersOf, diff --git a/packages/api-extractor/tests/front-end-model.integration.test.ts b/packages/api-extractor/tests/front-end-model.integration.test.ts index 683b8a8..3617c0c 100644 --- a/packages/api-extractor/tests/front-end-model.integration.test.ts +++ b/packages/api-extractor/tests/front-end-model.integration.test.ts @@ -1014,6 +1014,29 @@ Feature('Building a package API model from its declaration text') ), ) + 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 namespace body value does not shadow an imported type of the same name', Gherkin.Do.pipe( From 3016e546270ab7b558f149d649d3dcf655de8c34 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Wed, 14 Oct 2026 00:29:20 +0000 Subject: [PATCH 68/99] refactor(front-end): a pattern walk lists directories in readdir order (U20) DeepSeek delta gap at 681efe3: no scenario fails without 9b04f01's code-unit sort of each listing. Since J1 every alias is walked, entry points are re-sorted by export path, and the walk bound counts every listed directory, the listing order reaches no output; the sort goes. --- packages/api-extractor/src/front-end/read-package.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/api-extractor/src/front-end/read-package.ts b/packages/api-extractor/src/front-end/read-package.ts index cc1ebf6..8da3e8e 100644 --- a/packages/api-extractor/src/front-end/read-package.ts +++ b/packages/api-extractor/src/front-end/read-package.ts @@ -129,7 +129,7 @@ const walkStep = (pattern: WalkPattern, relative: string, ancestors: HashSet.Has const names = yield* (listed > MAX_PATTERN_DIRECTORIES ? Effect.fail(overBudget(pattern)) : listNames(path.join(pattern.root, relative))) - return yield* Effect.reduce(Arr.sort(names, Order.String), () => ({ names: walk.names, listed }), stepChild(pattern, relative, ancestors)) + return yield* Effect.reduce(names, () => ({ names: walk.names, listed }), stepChild(pattern, relative, ancestors)) }) const enterDirectory = ( From 43a8ee44613467c6ffa2f28a51ceac635788da61 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Wed, 14 Oct 2026 00:37:19 +0000 Subject: [PATCH 69/99] refactor(front-end): one declaration classifier and one entry shape (U20) Maintainability P3s at 681efe3: the statement-to-declaration switch existed three times (file scopes, the builder's namespace bodies and namespaceSpacesOf) and the {exportPath, declarationFile} pair four times. declaration-graph's classifyDeclaration is the one switch and EntryDeclaration the one shape. The copies had drifted: a namespace body silently dropped a destructuring constant that tsc 7.0.2 accepts in a declare namespace, so N.a vanished from the model; it is refused as it is at file scope. Refusals scenario; fails at abb3022 with a model whose N has no items. --- .../src/front-end/build-model.ts | 64 +---------- .../src/front-end/declaration-graph.ts | 107 +++++++++--------- .../src/front-end/resolve-messages.ts | 29 ++--- .../front-end-refusals.integration.test.ts | 11 ++ 4 files changed, 79 insertions(+), 132 deletions(-) diff --git a/packages/api-extractor/src/front-end/build-model.ts b/packages/api-extractor/src/front-end/build-model.ts index bd7193e..d739543 100644 --- a/packages/api-extractor/src/front-end/build-model.ts +++ b/packages/api-extractor/src/front-end/build-model.ts @@ -1,24 +1,19 @@ import { Array as Arr, Effect, HashMap, HashSet, Match, Option, Order, Ref } from 'effect' import type { - BindingIdentifier, BindingPattern, Class, - Declaration as DeclarationNode, Directive, Expression, Function as FunctionNode, Statement, - StringLiteral, TSEnumDeclaration, TSEnumMember, TSEnumMemberName, TSInterfaceDeclaration, TSModuleBlock, TSModuleDeclaration, - TSQualifiedName, TSTypeAliasDeclaration, TSTypeAnnotation, - VariableDeclaration, VariableDeclarator, } from 'oxc-parser' import { @@ -33,7 +28,7 @@ import { 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, exportedNames, exportedSymbol, grouped, namespaceSpacesOf, refuse, type Declaration, type Declared, type DeclarationGraph } from './declaration-graph.ts' +import { declarationGraph, declaredOf, exportedNames, exportedSymbol, grouped, namespaceSpacesOf, refuse, 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' @@ -50,7 +45,6 @@ type LocalDeclaration = { } type VariableEntry = { readonly constant: boolean; readonly node: VariableDeclarator } -type EntryFile = { readonly exportPath: string; readonly declarationFile: string } const KEY_DELIMITER = '\u0000' @@ -506,49 +500,6 @@ const bodyDeclarations = ( const declaredEntry = (declared: Declared, statementStart: number): Declaration => ({ declared, statementStart }) -const identifierName = (id: { readonly name: string } | null): Option.Option => - Option.map(Option.fromNullishOr(id), (present) => present.name) - -const oneDeclaration = (declared: Option.Option, statementStart: number): ReadonlyArray => - Option.match(declared, { onNone: () => [], onSome: (present) => [declaredEntry(present, statementStart)] }) - -const declaratorDeclarations = (variable: VariableDeclaration, declarator: VariableDeclarator): ReadonlyArray => - Option.match( - Match.value(declarator.id).pipe( - Match.when({ type: 'Identifier' }, (id) => Option.some(id.name)), - Match.orElse(() => Option.none()), - ), - { - onNone: () => [], - onSome: (name) => [declaredEntry({ kind: 'variable', constant: variable.kind === 'const', node: declarator, name }, variable.start)], - }, - ) - -const nodeDeclarations = (node: DeclarationNode | Statement): ReadonlyArray => - Match.value(node).pipe( - Match.when({ type: 'VariableDeclaration' }, (variable) => variable.declarations.flatMap((declarator) => declaratorDeclarations(variable, declarator))), - Match.when({ type: Match.is('FunctionDeclaration', 'TSDeclareFunction') }, (function_: FunctionNode) => - oneDeclaration(Option.map(identifierName(function_.id), (name): Declared => ({ kind: 'function', node: function_, name })), function_.start)), - Match.when({ type: 'ClassDeclaration' }, (class_: Class) => - oneDeclaration(Option.map(identifierName(class_.id), (name): Declared => ({ kind: 'class', node: class_, name })), class_.start)), - Match.when({ type: 'TSInterfaceDeclaration' }, (iface: TSInterfaceDeclaration) => - [declaredEntry({ kind: 'interface', node: iface, name: iface.id.name }, iface.start)]), - Match.when({ type: 'TSTypeAliasDeclaration' }, (alias: TSTypeAliasDeclaration) => - [declaredEntry({ kind: 'typeAlias', node: alias, name: alias.id.name }, alias.start)]), - Match.when({ type: 'TSEnumDeclaration' }, (enumeration: TSEnumDeclaration) => - [declaredEntry({ kind: 'enum', node: enumeration, name: enumeration.id.name }, enumeration.start)]), - Match.orElse(() => []), - ) - -const namespaceDeclarations = ( - context: TypeContext, - module: TSModuleDeclaration, -): Effect.Effect, UnsupportedDeclaration> => - Option.match(moduleName(module.id), { - onNone: () => refuse(context.file, `a ${module.id.type} namespace name`), - onSome: (name) => Effect.succeed>([declaredEntry({ kind: 'namespace', node: module, name }, module.start)]), - }) - const statementDeclarations = ( context: TypeContext, statement: Directive | Statement, @@ -559,17 +510,8 @@ const statementDeclarations = ( onNone: () => Effect.succeed>([]), onSome: (inner) => statementDeclarations(context, inner), })), - Match.when({ type: Match.is('VariableDeclaration', 'FunctionDeclaration', 'TSDeclareFunction', 'ClassDeclaration', 'TSInterfaceDeclaration', 'TSTypeAliasDeclaration', 'TSEnumDeclaration') }, (node) => - Effect.succeed(nodeDeclarations(node))), - Match.when({ type: 'TSModuleDeclaration', global: false }, (module) => namespaceDeclarations(context, module)), Match.when({ type: 'TSImportEqualsDeclaration' }, () => refuse(context.file, '`import =`')), - Match.orElse(() => Effect.succeed>([])), - ) - -const moduleName = (id: BindingIdentifier | StringLiteral | TSQualifiedName): Option.Option => - Match.value(id).pipe( - Match.when({ type: 'Identifier' }, (identifier) => Option.some(identifier.name)), - Match.orElse(() => Option.none()), + 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> => @@ -647,7 +589,7 @@ const buildReachableLocals = ( return drain() } -const buildEntry = (graph: DeclarationGraph, entry: EntryFile): Effect.Effect => +const buildEntry = (graph: DeclarationGraph, entry: EntryDeclaration): Effect.Effect => Effect.gen(function* () { const file = entry.declarationFile const exported = exportedNames(graph, file) diff --git a/packages/api-extractor/src/front-end/declaration-graph.ts b/packages/api-extractor/src/front-end/declaration-graph.ts index 3889ff9..3f9735a 100644 --- a/packages/api-extractor/src/front-end/declaration-graph.ts +++ b/packages/api-extractor/src/front-end/declaration-graph.ts @@ -14,7 +14,6 @@ import { type Program, type Statement, type TSEnumDeclaration, - type TSGlobalDeclaration, type TSInterfaceDeclaration, type TSModuleBlock, type TSModuleDeclaration, @@ -310,36 +309,57 @@ type NamedDeclaration = { const namedDeclaration = (name: string, kind: Declared['kind']): NamedDeclaration => ({ name, kind }) -const optionalDeclaration = (id: { readonly name: string } | null, kind: Declared['kind']): ReadonlyArray => - Option.match(Option.fromNullishOr(id), { onNone: () => [], onSome: (present) => [namedDeclaration(present.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 variableDeclarationsIn = (variable: VariableDeclaration): ReadonlyArray => - Arr.getSomes( - variable.declarations.map((declarator) => - declarator.id.type === 'Identifier' ? Option.some(namedDeclaration(declarator.id.name, 'variable')) : Option.none()), +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 namespaceNameIn = (id: TSModuleDeclaration['id']): ReadonlyArray => - Match.value(id).pipe( - Match.when({ type: 'Identifier' }, (identifier) => [namedDeclaration(identifier.name, 'namespace')]), - Match.orElse(() => []), +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 namespaceDeclarationIn = (module: TSModuleDeclaration | TSGlobalDeclaration): ReadonlyArray => - module.kind === 'global' ? [] : namespaceNameIn(module.id) +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.when({ type: 'VariableDeclaration' }, (variable: VariableDeclaration) => variableDeclarationsIn(variable)), - Match.when({ type: Match.is('FunctionDeclaration', 'TSDeclareFunction') }, (function_: FunctionNode) => optionalDeclaration(function_.id, 'function')), - Match.when({ type: 'ClassDeclaration' }, (class_: Class) => optionalDeclaration(class_.id, 'class')), - Match.when({ type: 'TSInterfaceDeclaration' }, (iface: TSInterfaceDeclaration) => [namedDeclaration(iface.id.name, 'interface')]), - Match.when({ type: 'TSTypeAliasDeclaration' }, (alias: TSTypeAliasDeclaration) => [namedDeclaration(alias.id.name, 'typeAlias')]), - Match.when({ type: 'TSEnumDeclaration' }, (enumeration: TSEnumDeclaration) => [namedDeclaration(enumeration.id.name, 'enum')]), - Match.when({ type: 'TSModuleDeclaration' }, (module: TSModuleDeclaration | TSGlobalDeclaration) => namespaceDeclarationIn(module)), - Match.orElse(() => []), + Match.orElse((node) => namedDeclarationsOf(classifyDeclaration(node))), ) const statementsIn = (bodies: ReadonlyArray): ReadonlyArray => @@ -396,7 +416,7 @@ export type ResolvedSymbol = export type DeclarationGraph = { readonly files: HashMap.HashMap - readonly entryPoints: ReadonlyArray<{ readonly exportPath: string; readonly declarationFile: string }> + readonly entryPoints: ReadonlyArray } type ScopeParts = { @@ -416,38 +436,17 @@ export const refuse: { } = Fn.dual(2, (file: string, construct: string): Effect.Effect => Effect.fail(new UnsupportedDeclaration({ file, construct }))) -const DEFAULT_EXPORT_NAME = 'default' - -const declarationName = (id: { readonly name: string } | null): string => (id === null ? DEFAULT_EXPORT_NAME : id.name) - -const variablesOf = (node: VariableDeclaration, file: string): DeclaredResult => - Effect.forEach(node.declarations, (declarator) => - declarator.id.type === 'Identifier' - ? Effect.succeed({ kind: 'variable', constant: node.kind === 'const', node: declarator, name: declarator.id.name }) - : refuse(file, 'a destructuring variable declaration')) - -const namespaceOf = (node: TSModuleDeclaration, file: string): DeclaredResult => - Match.value(node.id).pipe( - Match.when({ type: 'Identifier' }, (id) => Effect.succeed>([{ kind: 'namespace', node, name: id.name }])), - Match.when({ type: 'Literal' }, (id) => refuse(file, `\`declare module "${id.value}"\``)), - Match.orElse(() => refuse(file, 'a dotted namespace declaration')), - ) - -const declaredOf = (node: DeclarationNode | Statement | ExportDefaultDeclaration['declaration'], file: string): DeclaredResult => - Match.value(node).pipe( - Match.when({ type: 'VariableDeclaration' }, (variable) => variablesOf(variable, file)), - Match.when({ type: Match.is('FunctionDeclaration', 'TSDeclareFunction') }, (fn: FunctionNode) => - Effect.succeed>([{ kind: 'function', node: fn, name: declarationName(fn.id) }])), - Match.when({ type: 'ClassDeclaration' }, (cls: Class) => - Effect.succeed>([{ kind: 'class', node: cls, name: declarationName(cls.id) }])), - Match.when({ type: 'TSInterfaceDeclaration' }, (iface) => Effect.succeed>([{ kind: 'interface', node: iface, name: iface.id.name }])), - Match.when({ type: 'TSTypeAliasDeclaration' }, (alias) => Effect.succeed>([{ kind: 'typeAlias', node: alias, name: alias.id.name }])), - Match.when({ type: 'TSEnumDeclaration' }, (enumeration) => - Effect.succeed>([{ kind: 'enum', node: enumeration, name: enumeration.id.name }])), - Match.when({ type: 'TSModuleDeclaration', global: true }, () => refuse(file, '`declare global`')), - Match.when({ type: 'TSModuleDeclaration', global: false }, (module: TSModuleDeclaration) => namespaceOf(module, file)), - Match.orElse(() => Effect.succeed>([])), - ) +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, diff --git a/packages/api-extractor/src/front-end/resolve-messages.ts b/packages/api-extractor/src/front-end/resolve-messages.ts index 40efae3..6ed47d4 100644 --- a/packages/api-extractor/src/front-end/resolve-messages.ts +++ b/packages/api-extractor/src/front-end/resolve-messages.ts @@ -21,7 +21,7 @@ import { Visitor, } from 'oxc-parser' import type { AnalysisMessage, AnalysisMessageId } from './analysis-message.schema.ts' -import type { Declaration, DeclarationGraph, FileScope, ResolvedSymbol } from './declaration-graph.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' @@ -253,11 +253,6 @@ const referencesWithin = (scan: ReferenceScan, scope: FileScope, declaration: De const referenceNames = (scan: ReferenceScan, scope: FileScope, declaration: Declaration): ReadonlyArray => Arr.map(referencesWithin(scan, scope, declaration), (reference) => reference.name) -type EntryPoint = { - readonly exportPath: string - readonly declarationFile: string -} - type ExportedItem = { readonly file: string readonly name: string @@ -318,14 +313,14 @@ const analysisMessage = (messageId: AnalysisMessageId, entryPoint: string, item: const untagged = (graph: DeclarationGraph, item: ExportedItem): ReadonlyArray => Arr.filter(item.declarations, (declaration) => Option.isNone(tagOf(graph, item.file, declaration))) -const missingTagMessages = (graph: DeclarationGraph, entry: EntryPoint, item: ExportedItem): ReadonlyArray => +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: EntryPoint, item: ExportedItem): ReadonlyArray => +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)) : [] @@ -335,7 +330,7 @@ const allTagged = (maxTag: ReleaseTag, tags: ReadonlyArray): boolean const mixedTagged = (maxTag: ReleaseTag, tags: ReadonlyArray): boolean => !allTagged(maxTag, tags) && Arr.some(tags, (tag) => tag === 'internal') -const mixedMessages = (entry: EntryPoint, item: ExportedItem, maxTag: ReleaseTag, tags: ReadonlyArray): ReadonlyArray => +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 => @@ -353,7 +348,7 @@ const declaringTag = (graph: DeclarationGraph, file: string, declaration: Declar const incompatibleMessages = ( graph: DeclarationGraph, - entry: EntryPoint, + entry: EntryDeclaration, item: ExportedItem, declaration: Declaration, referenced: ExportedItem, @@ -364,7 +359,7 @@ const incompatibleMessages = ( const localMessages = ( resolution: Resolution, - entry: EntryPoint, + entry: EntryDeclaration, consumable: HashMap.HashMap, item: ExportedItem, declaration: Declaration, @@ -377,7 +372,7 @@ const localMessages = ( const resolvedNameMessages = ( resolution: Resolution, - entry: EntryPoint, + entry: EntryDeclaration, consumable: HashMap.HashMap, item: ExportedItem, declaration: Declaration, @@ -390,7 +385,7 @@ const resolvedNameMessages = ( const nameMessages = ( resolution: Resolution, - entry: EntryPoint, + entry: EntryDeclaration, consumable: HashMap.HashMap, item: ExportedItem, declaration: Declaration, @@ -403,7 +398,7 @@ const nameMessages = ( const declarationMessages = ( resolution: Resolution, - entry: EntryPoint, + entry: EntryDeclaration, consumable: HashMap.HashMap, item: ExportedItem, declaration: Declaration, @@ -417,7 +412,7 @@ const declarationMessages = ( const referenceMessages = ( resolution: Resolution, - entry: EntryPoint, + entry: EntryDeclaration, consumable: HashMap.HashMap, item: ExportedItem, ): ReadonlyArray => @@ -425,7 +420,7 @@ const referenceMessages = ( const itemMessages = ( resolution: Resolution, - entry: EntryPoint, + entry: EntryDeclaration, consumable: HashMap.HashMap, item: ExportedItem, ): ReadonlyArray => { @@ -439,7 +434,7 @@ const itemMessages = ( ] } -const entryMessages = (resolution: Resolution, entry: EntryPoint): ReadonlyArray => { +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)) } diff --git a/packages/api-extractor/tests/front-end-refusals.integration.test.ts b/packages/api-extractor/tests/front-end-refusals.integration.test.ts index 6cdfa0f..4fab66b 100644 --- a/packages/api-extractor/tests/front-end-refusals.integration.test.ts +++ b/packages/api-extractor/tests/front-end-refusals.integration.test.ts @@ -164,4 +164,15 @@ Feature('Refusing declaration text the model cannot hold') 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' })), + ), + ) }) From db5c70a19acf6170e657016f4b871c319c2837f7 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Wed, 14 Oct 2026 00:42:14 +0000 Subject: [PATCH 70/99] test(front-end): namespaces get their own feature; the fast path is pinned type-unchecked (U20) Review at 681efe3: - maintainability P2: the model feature was 1120 lines again; its eight namespace scenarios move verbatim into 'Building the namespaces of a package API model' (896 and 233 lines). - testing P2: no scenario showed the fast path skips the type checker. An annotated but type-incorrect package (count: number = 'one') takes FastPath and models count as a number. Pins existing behaviour. --- .../tests/front-end-model.integration.test.ts | 224 ----------------- .../front-end-namespaces.integration.test.ts | 233 ++++++++++++++++++ .../front-end-routing.integration.test.ts | 23 ++ 3 files changed, 256 insertions(+), 224 deletions(-) create mode 100644 packages/api-extractor/tests/front-end-namespaces.integration.test.ts diff --git a/packages/api-extractor/tests/front-end-model.integration.test.ts b/packages/api-extractor/tests/front-end-model.integration.test.ts index 3617c0c..63ce8dc 100644 --- a/packages/api-extractor/tests/front-end-model.integration.test.ts +++ b/packages/api-extractor/tests/front-end-model.integration.test.ts @@ -148,26 +148,6 @@ Feature('Building a package API model from its declaration text') ), ) - 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( 'overloads collapse into one function carrying every signature', Gherkin.Do.pipe( @@ -832,137 +812,6 @@ Feature('Building a package API model from its declaration text') ), ) - 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 type parameter does not hide a same-named type reached in another file", Gherkin.Do.pipe( @@ -1014,79 +863,6 @@ Feature('Building a package API model from its declaration text') ), ) - 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 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: [] }, - })), - ), - ) - scenario( 'an inferred name inside a conditional type reads as the name it binds, not the import or declaration it shadows', Gherkin.Do.pipe( 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..faf1902 --- /dev/null +++ b/packages/api-extractor/tests/front-end-namespaces.integration.test.ts @@ -0,0 +1,233 @@ +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 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-routing.integration.test.ts b/packages/api-extractor/tests/front-end-routing.integration.test.ts index d6b7c74..820c1a0 100644 --- a/packages/api-extractor/tests/front-end-routing.integration.test.ts +++ b/packages/api-extractor/tests/front-end-routing.integration.test.ts @@ -28,6 +28,10 @@ const destructuredExportPackage = packageOf(sourceManifest('destructured', './sr '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'), { @@ -109,6 +113,25 @@ Feature('A package is routed to the front-end its declarations allow') ), ) + 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( From e8df9af6dd2530bd14d385b8d677e900bab381ce Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Wed, 14 Oct 2026 00:42:14 +0000 Subject: [PATCH 71/99] fix(front-end): a pattern walk skips node_modules and lists at most 1024 directories (U20) A root pattern (./* -> ./*.ts) walked into node_modules, whose pnpm symlinks fan out like any alias tree, so 24e9b67's bound refused the package although no export name can pass through a node_modules segment. The walk no longer enters a directory whose name is an invalid export segment wherever that segment lies wholly inside the matched name; with that, the bound drops to 1024. Reader scenario: a node_modules fan-out of depth 13 is refused at 3016e54 and read now. Pins (existing behaviour): a symlink to an ancestor two levels up is walked once (DeepSeek delta gap); a manifest without name or version is ManifestRejected (api-contract gap). --- README.md | 5 +- .../src/front-end/read-package.ts | 26 +++--- .../tests/package-reader.integration.test.ts | 83 +++++++++++++++---- 3 files changed, 86 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 95b239b..e8011d8 100644 --- a/README.md +++ b/README.md @@ -76,8 +76,9 @@ under an export path; a target that nests it under another condition, or lists t an array, 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 4096 directories, which symlinked aliases of a -directory can reach by fanning out; a symlink back to an ancestor is never entered. +export whose walk lists more than 1024 directories, which symlinked aliases of a +directory can reach by fanning out; a symlink back to an ancestor is never entered, nor +is 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. diff --git a/packages/api-extractor/src/front-end/read-package.ts b/packages/api-extractor/src/front-end/read-package.ts index 8da3e8e..fdaf35d 100644 --- a/packages/api-extractor/src/front-end/read-package.ts +++ b/packages/api-extractor/src/front-end/read-package.ts @@ -108,11 +108,11 @@ const directoryRealPath = (childPath: string): Effect.Effect()) }) -const MAX_PATTERN_DIRECTORIES = 4096 +const MAX_PATTERN_DIRECTORIES = 1024 type Walk = { readonly names: ReadonlyArray; readonly listed: number } -type WalkPattern = { readonly root: string; readonly key: string } +type WalkPattern = { readonly root: string; readonly key: string; readonly prefix: string } type WalkError = PlatformError | ManifestRejected @@ -141,15 +141,20 @@ const enterDirectory = ( ): Effect.Effect => HashSet.has(ancestors, realPath) ? Effect.succeed(walk) : walkStep(pattern, relative, HashSet.add(ancestors, realPath), walk) +const isUnmatchable = (pattern: WalkPattern, childRelative: string, name: string): boolean => + INVALID_SEGMENT.test(name) && pattern.prefix.length <= childRelative.length - name.length + 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* 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), - }) + 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> => @@ -200,10 +205,11 @@ const expandPattern = (root: string, condition: string, key: string, target: Exp onSome: (targetText) => Option.match(patternPartsOf(targetText), { onNone: () => Effect.succeed>([]), - onSome: (parts) => - Effect.map(walkDirectory({ root, key }, directoryOf(stripRelativePrefix(parts.prefix))), (candidates) => - Arr.getSomes(candidates.filter(isTypeScriptSource).map(patternEntryOf(key, stripRelativePrefix(parts.prefix), parts.suffix))), - ), + 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)))) + }, }), }) diff --git a/packages/api-extractor/tests/package-reader.integration.test.ts b/packages/api-extractor/tests/package-reader.integration.test.ts index 87cd355..d80c0ab 100644 --- a/packages/api-extractor/tests/package-reader.integration.test.ts +++ b/packages/api-extractor/tests/package-reader.integration.test.ts @@ -59,6 +59,17 @@ const loopPatternPackage = Effect.gen(function* () { 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': '' }, @@ -114,21 +125,18 @@ const aliasedSiblingPatternPackage = Effect.gen(function* () { return root }) -const ALIAS_DEPTH = 12 +const ALIAS_DEPTH = 10 -const aliasFanOutPackage = 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' } } }), - ) - const levels = Array.from({ length: ALIAS_DEPTH }, (_, depth) => path.join(root, 'src', ...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 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 @@ -233,6 +241,17 @@ Feature("A package's exports name its entry points") ), ) + 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( @@ -337,23 +356,55 @@ Feature("A package's exports name its entry points") ), ) + 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, + () => 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 4096 directories', + 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 package whose exports is a bare path or absent names no source entry', Gherkin.Do.pipe( From 5d32c3fa8969696b004cd1df4d15dafc1d7ae516 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Wed, 14 Oct 2026 00:44:06 +0000 Subject: [PATCH 72/99] docs(plan): rule every finding of the 681efe3 review (U20) Supersedes the 0008 plan. Records the fixing commits for the walk bound and node_modules pruning, cross-file scope, private constructors, index signature names, namespace-body type and value spaces in the builder and the resolver, the single classifier and the test split; the rejections with their reasons; and the PR4 and PR5 follow-ups with their triggers. --- ...> 2026-10-14-0042-feat-front-ends-plan.md} | 34 +++++++++++++++++-- ...lback-inherits-declaration-emit-options.md | 2 +- 2 files changed, 33 insertions(+), 3 deletions(-) rename docs/plans/{2026-10-14-0008-feat-front-ends-plan.md => 2026-10-14-0042-feat-front-ends-plan.md} (92%) diff --git a/docs/plans/2026-10-14-0008-feat-front-ends-plan.md b/docs/plans/2026-10-14-0042-feat-front-ends-plan.md similarity index 92% rename from docs/plans/2026-10-14-0008-feat-front-ends-plan.md rename to docs/plans/2026-10-14-0042-feat-front-ends-plan.md index d982ca6..52d8491 100644 --- a/docs/plans/2026-10-14-0008-feat-front-ends-plan.md +++ b/docs/plans/2026-10-14-0042-feat-front-ends-plan.md @@ -6,7 +6,7 @@ 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-13-2348-feat-front-ends-plan.md +supersedes: docs/plans/2026-10-14-0008-feat-front-ends-plan.md execution: code --- @@ -515,6 +515,36 @@ U1 → U2 (profile committed) → U3 (choice recorded) → U4 → U5 → U6 → - **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. + --- ## Verification Contract @@ -537,7 +567,7 @@ No mutation run, local or in CI (origin R23). ## Definition of Done -- U1-U19 verified; every Verification Contract gate passes locally and CI is green on the exact PR head. +- U1-U20 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. 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 index 87fb2f7..a9ff938 100644 --- a/docs/solutions/logic-errors/compiler-fallback-inherits-declaration-emit-options.md +++ b/docs/solutions/logic-errors/compiler-fallback-inherits-declaration-emit-options.md @@ -158,6 +158,6 @@ derived at mirror(dirname(packageTsconfig)) = { extends: packageTsconfig, files: ## Related -- `docs/plans/2026-10-14-0008-feat-front-ends-plan.md` (R3; U10 disposition #6; U12 emit policy; U13 #4, the mirrored `node_modules` ancestry and its delta ruling) +- `docs/plans/2026-10-14-0042-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 From e5db940d87ddd2ec0d6af3e692d0c44c9b55cb37 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Wed, 14 Oct 2026 00:57:16 +0000 Subject: [PATCH 73/99] fix(front-end): an export naming no declaration is refused, not dropped (U21) Correctness residual at 5d32c3f: itemsForExport returned no items when an export list named nothing, so a hand-written entry .d.ts holding 'export { present, Missing }' gave a model silently missing Missing, on both routes. It is refused with UnsupportedDeclaration naming the file and the export. Refusals scenario; fails at 5d32c3f with a model holding only present. The README lists the cause. --- README.md | 2 ++ .../api-extractor/src/front-end/build-model.ts | 2 +- .../tests/front-end-refusals.integration.test.ts | 15 +++++++++++++++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e8011d8..63a7f7d 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,8 @@ is the whole list: 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 =` diff --git a/packages/api-extractor/src/front-end/build-model.ts b/packages/api-extractor/src/front-end/build-model.ts index d739543..6db9d1b 100644 --- a/packages/api-extractor/src/front-end/build-model.ts +++ b/packages/api-extractor/src/front-end/build-model.ts @@ -549,7 +549,7 @@ const itemsForExport = ( visited: HashSet.HashSet, ): Effect.Effect, UnsupportedDeclaration> => Option.match(exportedSymbol(graph, entryFile, name), { - onNone: () => Effect.succeed>([]), + onNone: () => refuse(entryFile, `\`export { ${name} }\`, which names no declaration`), onSome: (symbol) => Match.value(symbol).pipe( Match.when({ kind: 'external' }, (external) => refuse(entryFile, `re-export of "${name}" from "${external.module}"`)), diff --git a/packages/api-extractor/tests/front-end-refusals.integration.test.ts b/packages/api-extractor/tests/front-end-refusals.integration.test.ts index 4fab66b..a426345 100644 --- a/packages/api-extractor/tests/front-end-refusals.integration.test.ts +++ b/packages/api-extractor/tests/front-end-refusals.integration.test.ts @@ -82,6 +82,21 @@ Feature('Refusing declaration text the model cannot hold') ), ) + 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 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( From 688e85d271848a2ccb941cb23c88501b428536b2 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Wed, 14 Oct 2026 00:58:34 +0000 Subject: [PATCH 74/99] fix(front-end): pattern exports follow Node's winning key; the walk stays on the prefix (U21) Review at 5d32c3f: - DeepSeek peer P2: 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, so ./a/x was modelled although no consumer can import it. Each pattern entry is kept only when its key is the one Node's pattern-key order picks for the export path, which also leaves ./internal/z out under a null ./internal/*. - DeepSeek peer P3: a key with two stars produced export paths holding a literal star; only single-star keys are patterns, and others name no entry. - 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. Reader scenarios; each fails at 5d32c3f. Pin (existing behaviour): a node_modules fan-out under src/ is pruned. The README states the rules. --- README.md | 13 +++-- .../src/front-end/read-package.ts | 57 ++++++++++++------- .../tests/package-reader.integration.test.ts | 57 +++++++++++++++++++ 3 files changed, 102 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 63a7f7d..c492279 100644 --- a/README.md +++ b/README.md @@ -69,16 +69,19 @@ process.stdout.write(ApiModel.serialise(analysis.model)) `.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 (`"./*"`) -expands to one entry per matching source under Node's `exports` rules, and an explicit -subpath wins over a pattern. The condition is read where the family writes it, directly +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 with two +stars names no entry. The condition is read where the family writes it, directly under an export path; a target that nests it under another condition, or lists targets in an array, 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; a symlink back to an ancestor is never entered, nor -is a `node_modules` directory no export name could pass through. +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. diff --git a/packages/api-extractor/src/front-end/read-package.ts b/packages/api-extractor/src/front-end/read-package.ts index fdaf35d..bb16c33 100644 --- a/packages/api-extractor/src/front-end/read-package.ts +++ b/packages/api-extractor/src/front-end/read-package.ts @@ -1,4 +1,4 @@ -import { Array as Arr, Effect, FileSystem, HashMap, HashSet, Match, Option, Order, Path, Schema } from 'effect' +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' @@ -49,7 +49,11 @@ const exportMapOf = (exports: PackageManifest['exports']): ReadonlyArray Object.entries(record).filter(([exportPath]) => exportPath !== './package.json')), ) -const isPatternKey = (exportPath: string): boolean => exportPath.includes('*') +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 } @@ -141,9 +145,15 @@ const enterDirectory = ( ): Effect.Effect => HashSet.has(ancestors, realPath) ? Effect.succeed(walk) : walkStep(pattern, relative, HashSet.add(ancestors, realPath), walk) -const isUnmatchable = (pattern: WalkPattern, childRelative: string, name: string): boolean => +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* () { @@ -183,21 +193,26 @@ const patternPrecedence: Order.Order = Order.combineAll([ Order.mapInput(Order.Number, (key: string) => key.length), ]) -const preferredPattern = (candidate: PatternEntry, known: PatternEntry): PatternEntry => - patternPrecedence(candidate.key, known.key) > 0 ? candidate : known - -const bestPerPath = (items: ReadonlyArray): ReadonlyArray => - Arr.fromIterable( - HashMap.values( - Arr.reduce(items, HashMap.empty(), (best, item) => - HashMap.set( - best, - item.entry.exportPath, - Option.match(HashMap.get(best, item.entry.exportPath), { onNone: () => item, onSome: (known) => preferredPattern(item, known) }), - ), - ), - ), - ) +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), { @@ -216,11 +231,13 @@ const expandPattern = (root: string, condition: string, key: string, target: Exp 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]) => !isPatternKey(exportPath)) + 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 = bestPerPath(Arr.flatten(expanded).filter((item) => !HashSet.has(explicitPaths, item.entry.exportPath))) + 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) }) diff --git a/packages/api-extractor/tests/package-reader.integration.test.ts b/packages/api-extractor/tests/package-reader.integration.test.ts index d80c0ab..b762011 100644 --- a/packages/api-extractor/tests/package-reader.integration.test.ts +++ b/packages/api-extractor/tests/package-reader.integration.test.ts @@ -405,6 +405,63 @@ Feature("A package's exports name its entry points") ), ) + 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 holding two stars names no entry point', + Gherkin.Do.pipe( + Given('a package exporting `./src/mod.ts` at `.` and `./src/*.ts` at `"./*/*.ts"`, with `src/b.ts`')('packageRoot', () => + packageOf({ '.': { [condition]: './src/mod.ts' }, './*/*.ts': { [condition]: './src/*.ts' } }, { 'src/mod.ts': '', 'src/b.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( From aea9874ced72cd5a1d2dd2c03718248974cea752 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Wed, 14 Oct 2026 01:06:39 +0000 Subject: [PATCH 75/99] fix(front-end): the resolver scopes a merged namespace with every block (U21) Adversarial P2 and DeepSeek delta P2 at 5d32c3f: the resolver scoped a namespace body with that block's 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 although tsc and the builder bind it to N.A. One pass over each file maps a namespace path (N, N.M) to all its blocks, and entering a block scopes it with their union. Analysis scenarios for a top-level and a nested merge agree with upstream; both fail at 5d32c3f. --- .../src/front-end/resolve-messages.ts | 85 +++++++++++++++++-- .../analysis-messages.integration.test.ts | 80 +++++++++++++++++ 2 files changed, 160 insertions(+), 5 deletions(-) diff --git a/packages/api-extractor/src/front-end/resolve-messages.ts b/packages/api-extractor/src/front-end/resolve-messages.ts index 6ed47d4..00a568f 100644 --- a/packages/api-extractor/src/front-end/resolve-messages.ts +++ b/packages/api-extractor/src/front-end/resolve-messages.ts @@ -128,15 +128,88 @@ type ModuleDeclaration = TSModuleDeclaration | TSGlobalDeclaration const moduleBlockOf = (node: ModuleDeclaration): Option.Option => Option.fromNullishOr(node.body) -const openNamespaceScope = (scopes: Array, node: ModuleDeclaration): void => +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] + +const namespacePathOf = (namespacePath: ReadonlyArray, name: string): string => [...namespacePath, name].join('.') + +const enterNamespacePath = (namespacePath: Array, blocks: Array, node: ModuleDeclaration): void => + Option.match(namespaceNameOf(node), { + onNone: () => undefined, + onSome: (name) => { + namespacePath.push(name) + 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 mergedBlockEntries = (program: Program): ReadonlyArray => { + const namespacePath: Array = [] + const blocks: Array = [] + new Visitor({ + TSModuleDeclaration: (node: ModuleDeclaration) => enterNamespacePath(namespacePath, blocks, node), + 'TSModuleDeclaration:exit': (node: ModuleDeclaration) => leaveNamespacePath(namespacePath, node), + }).visit(program) + return blocks +} + +const withBlock = (blocksByPath: MergedNamespaceBlocks, [path, block]: NamespaceBlockEntry): MergedNamespaceBlocks => + HashMap.set(blocksByPath, path, Arr.append(Option.getOrElse(HashMap.get(blocksByPath, path), () => []), block)) + +const mergedBlocksByPath = (program: Program): MergedNamespaceBlocks => + Arr.reduce(mergedBlockEntries(program), 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({ start: block.start, end: block.end, ...namespaceSpacesOf([block]) }), + onSome: (block) => void scopes.push(namespaceScopeOf(block, blocks)), }) -const closeNamespaceScope = (scopes: Array, node: ModuleDeclaration): void => +const popNamespaceScope = (scopes: Array, node: ModuleDeclaration): void => Option.match(moduleBlockOf(node), { onNone: () => undefined, onSome: () => closeScope(scopes) }) +const openNamespaceScope = (scopes: Array, namespacePath: Array, blocksByPath: MergedNamespaceBlocks, node: ModuleDeclaration): void => + Option.match(namespaceNameOf(node), { + onNone: () => pushNamespaceScope(scopes, namespaceBlocksOf(node), node), + onSome: (name) => { + const path = namespacePathOf(namespacePath, name) + namespacePath.push(name) + pushNamespaceScope(scopes, namespaceBlocksAt(blocksByPath, path, node), node) + }, + }) + +const closeNamespaceScope = (scopes: Array, namespacePath: Array, node: ModuleDeclaration): void => + Option.match(namespaceNameOf(node), { + onNone: () => popNamespaceScope(scopes, node), + onSome: () => { + namespacePath.pop() + popNamespaceScope(scopes, node) + }, + }) + type ConditionalScope = { readonly start: number readonly end: number @@ -176,6 +249,8 @@ const scanOf = (program: Program): ReferenceScan => { const references: Array = [] const scopes: Array = [] const conditionals: Array = [] + const namespacePath: Array = [] + const blocksByPath = mergedBlocksByPath(program) 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) @@ -216,8 +291,8 @@ const scanOf = (program: Program): ReferenceScan => { 'TSFunctionType:exit': exit, TSConstructorType: enter, 'TSConstructorType:exit': exit, - TSModuleDeclaration: (node: ModuleDeclaration) => openNamespaceScope(scopes, node), - 'TSModuleDeclaration:exit': (node: ModuleDeclaration) => closeNamespaceScope(scopes, node), + TSModuleDeclaration: (node: ModuleDeclaration) => openNamespaceScope(scopes, namespacePath, blocksByPath, node), + 'TSModuleDeclaration:exit': (node: ModuleDeclaration) => closeNamespaceScope(scopes, namespacePath, node), TSMappedType: (node: TSMappedType) => openNamedScope(scopes, node), 'TSMappedType:exit': exit, TSConditionalType: (node: TSConditionalType) => openConditional(scopes, conditionals, node), diff --git a/packages/api-extractor/tests/analysis-messages.integration.test.ts b/packages/api-extractor/tests/analysis-messages.integration.test.ts index 2d9fb46..5b04439 100644 --- a/packages/api-extractor/tests/analysis-messages.integration.test.ts +++ b/packages/api-extractor/tests/analysis-messages.integration.test.ts @@ -467,6 +467,86 @@ Feature('Review notes for a package match the ones api-extractor derives') ), ) + 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( From c9fc3834b1efe7f14a1f5c333214453acc3cec15 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Wed, 14 Oct 2026 01:08:26 +0000 Subject: [PATCH 76/99] test(front-end): a merged namespace's class and enum read in both spaces; the changeset names new runtime dependencies (U21) Testing and adversarial gaps at 5d32c3f: no scenario named a class or an enum from both a type position and a typeof query, nor across merged blocks. One pins both (existing behaviour). api-contract P3: the release note now says the package installs typescript 7, oxc-parser, oxc-transform and @microsoft/tsdoc at runtime. --- .changeset/old-cobras-punch.md | 2 ++ .../front-end-namespaces.integration.test.ts | 24 +++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/.changeset/old-cobras-punch.md b/.changeset/old-cobras-punch.md index 62560b0..c03ea22 100644 --- a/.changeset/old-cobras-punch.md +++ b/.changeset/old-cobras-punch.md @@ -3,3 +3,5 @@ --- 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/packages/api-extractor/tests/front-end-namespaces.integration.test.ts b/packages/api-extractor/tests/front-end-namespaces.integration.test.ts index faf1902..fdb9c99 100644 --- a/packages/api-extractor/tests/front-end-namespaces.integration.test.ts +++ b/packages/api-extractor/tests/front-end-namespaces.integration.test.ts @@ -181,6 +181,30 @@ Feature('Building the namespaces of a package API model') ), ) + 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( From 9854ba8b3def3e9b28a37a2b8a23ec7872a5c24c Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Wed, 14 Oct 2026 01:09:24 +0000 Subject: [PATCH 77/99] docs(plan): rule every finding of the 5d32c3f review (U21) Supersedes the 0042 plan. Records the fixing commits for merged namespace scopes in the resolver, the unresolved-export refusal, Node's winning pattern key, single-star keys and the prefix-bound walk; the correction of U20 K1's fail-before depth; the pins; the rejections with their reasons; and the follow-ups with owners and triggers. --- ...> 2026-10-14-0108-feat-front-ends-plan.md} | 33 +++++++++++++++++-- ...lback-inherits-declaration-emit-options.md | 2 +- 2 files changed, 32 insertions(+), 3 deletions(-) rename docs/plans/{2026-10-14-0042-feat-front-ends-plan.md => 2026-10-14-0108-feat-front-ends-plan.md} (94%) diff --git a/docs/plans/2026-10-14-0042-feat-front-ends-plan.md b/docs/plans/2026-10-14-0108-feat-front-ends-plan.md similarity index 94% rename from docs/plans/2026-10-14-0042-feat-front-ends-plan.md rename to docs/plans/2026-10-14-0108-feat-front-ends-plan.md index 52d8491..4b81115 100644 --- a/docs/plans/2026-10-14-0042-feat-front-ends-plan.md +++ b/docs/plans/2026-10-14-0108-feat-front-ends-plan.md @@ -6,7 +6,7 @@ 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-0008-feat-front-ends-plan.md +supersedes: docs/plans/2026-10-14-0042-feat-front-ends-plan.md execution: code --- @@ -545,6 +545,35 @@ U1 → U2 (profile committed) → U3 (choice recorded) → U4 → U5 → U6 → - **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. + --- ## Verification Contract @@ -567,7 +596,7 @@ No mutation run, local or in CI (origin R23). ## Definition of Done -- U1-U20 verified; every Verification Contract gate passes locally and CI is green on the exact PR head. +- U1-U21 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. 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 index a9ff938..f82ffed 100644 --- a/docs/solutions/logic-errors/compiler-fallback-inherits-declaration-emit-options.md +++ b/docs/solutions/logic-errors/compiler-fallback-inherits-declaration-emit-options.md @@ -158,6 +158,6 @@ derived at mirror(dirname(packageTsconfig)) = { extends: packageTsconfig, files: ## Related -- `docs/plans/2026-10-14-0042-feat-front-ends-plan.md` (R3; U10 disposition #6; U12 emit policy; U13 #4, the mirrored `node_modules` ancestry and its delta ruling) +- `docs/plans/2026-10-14-0108-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 From b88de2fb587d5e471f85100c922e603c82fe9034 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Wed, 14 Oct 2026 01:22:44 +0000 Subject: [PATCH 78/99] refactor(front-end): the resolver builds each namespace path once (U22) Maintainability P3 at 9854ba8: the pre-pass and the reference scan each kept a dotted namespace path. The pre-pass now also records each named namespace's path by its start offset, and the scan looks it up instead of keeping its own stack. No behaviour change: analysis scenarios unchanged. --- .../src/front-end/resolve-messages.ts | 58 ++++++++++--------- 1 file changed, 31 insertions(+), 27 deletions(-) diff --git a/packages/api-extractor/src/front-end/resolve-messages.ts b/packages/api-extractor/src/front-end/resolve-messages.ts index 00a568f..7af6538 100644 --- a/packages/api-extractor/src/front-end/resolve-messages.ts +++ b/packages/api-extractor/src/front-end/resolve-messages.ts @@ -140,13 +140,24 @@ type MergedNamespaceBlocks = HashMap.HashMap, name: string): string => [...namespacePath, name].join('.') +type NamespacePathEntry = readonly [number, string] -const enterNamespacePath = (namespacePath: Array, blocks: Array, node: ModuleDeclaration): void => +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]), @@ -157,21 +168,22 @@ const enterNamespacePath = (namespacePath: Array, blocks: Array, node: ModuleDeclaration): void => Option.match(namespaceNameOf(node), { onNone: () => undefined, onSome: () => void namespacePath.pop() }) -const mergedBlockEntries = (program: Program): ReadonlyArray => { +const namespacePassOf = (program: Program): NamespacePass => { const namespacePath: Array = [] const blocks: Array = [] + const paths: Array = [] new Visitor({ - TSModuleDeclaration: (node: ModuleDeclaration) => enterNamespacePath(namespacePath, blocks, node), + TSModuleDeclaration: (node: ModuleDeclaration) => enterNamespacePath(namespacePath, blocks, paths, node), 'TSModuleDeclaration:exit': (node: ModuleDeclaration) => leaveNamespacePath(namespacePath, node), }).visit(program) - return blocks + 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 = (program: Program): MergedNamespaceBlocks => - Arr.reduce(mergedBlockEntries(program), HashMap.empty>(), withBlock) +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)) @@ -191,23 +203,15 @@ const pushNamespaceScope = (scopes: Array, blocks: ReadonlyArray, node: ModuleDeclaration): void => Option.match(moduleBlockOf(node), { onNone: () => undefined, onSome: () => closeScope(scopes) }) -const openNamespaceScope = (scopes: Array, namespacePath: Array, blocksByPath: MergedNamespaceBlocks, node: ModuleDeclaration): void => - Option.match(namespaceNameOf(node), { +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: (name) => { - const path = namespacePathOf(namespacePath, name) - namespacePath.push(name) - pushNamespaceScope(scopes, namespaceBlocksAt(blocksByPath, path, node), node) - }, - }) - -const closeNamespaceScope = (scopes: Array, namespacePath: Array, node: ModuleDeclaration): void => - Option.match(namespaceNameOf(node), { - onNone: () => popNamespaceScope(scopes, node), - onSome: () => { - namespacePath.pop() - popNamespaceScope(scopes, node) - }, + onSome: (path) => pushNamespaceScope(scopes, namespaceBlocksAt(blocksByPath, path, node), node), }) type ConditionalScope = { @@ -249,8 +253,8 @@ const scanOf = (program: Program): ReferenceScan => { const references: Array = [] const scopes: Array = [] const conditionals: Array = [] - const namespacePath: Array = [] - const blocksByPath = mergedBlocksByPath(program) + 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) @@ -291,8 +295,8 @@ const scanOf = (program: Program): ReferenceScan => { 'TSFunctionType:exit': exit, TSConstructorType: enter, 'TSConstructorType:exit': exit, - TSModuleDeclaration: (node: ModuleDeclaration) => openNamespaceScope(scopes, namespacePath, blocksByPath, node), - 'TSModuleDeclaration:exit': (node: ModuleDeclaration) => closeNamespaceScope(scopes, namespacePath, node), + 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), From 5c6c2d143d8b6fbb52036bc3a91d43ca5cd074b2 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Wed, 14 Oct 2026 01:23:13 +0000 Subject: [PATCH 79/99] fix(front-end): an unresolved export is refused naming the file that lists it (U22) DeepSeek delta P3 at 9854ba8: an entry holding only `export * from './a.js'` over `export { present, Missing }` was refused naming the entry, a file that holds no such statement. The refusal now names the file whose export map lists the name, found through star exports in resolution order. Refusals scenario; fails at 9854ba8. Maintainability P3: the builder's body-statement flatten was a copy of the graph's statementsIn, which it now imports. --- .../src/front-end/build-model.ts | 9 +++----- .../src/front-end/declaration-graph.ts | 21 ++++++++++++++++++- .../front-end-refusals.integration.test.ts | 20 ++++++++++++++++++ 3 files changed, 43 insertions(+), 7 deletions(-) diff --git a/packages/api-extractor/src/front-end/build-model.ts b/packages/api-extractor/src/front-end/build-model.ts index 6db9d1b..1695cf6 100644 --- a/packages/api-extractor/src/front-end/build-model.ts +++ b/packages/api-extractor/src/front-end/build-model.ts @@ -28,7 +28,7 @@ import { 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, grouped, namespaceSpacesOf, refuse, type Declaration, type Declared, type DeclarationGraph, type EntryDeclaration } from './declaration-graph.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' @@ -486,15 +486,12 @@ const namespaceBodyItems = ( Effect.flatMap(bodyDeclarations(context, bodies), (groups) => itemsOfGroups(withNamespaceScope(context, namespaceSpacesOf(bodies)), groups)) -const bodyStatements = (bodies: ReadonlyArray): ReadonlyArray => - bodies.flatMap((body) => Option.match(Option.fromNullishOr(body), { onNone: () => [], onSome: (block) => block.body })) - const bodyDeclarations = ( context: TypeContext, bodies: ReadonlyArray, ): Effect.Effect>, UnsupportedDeclaration> => Effect.map( - Effect.forEach(bodyStatements(bodies), (statement) => statementDeclarations(context, statement)), + Effect.forEach(statementsIn(bodies), (statement) => statementDeclarations(context, statement)), (all) => grouped(all.flat().map((declaration) => [declaration.declared.name, declaration] as const)), ) @@ -549,7 +546,7 @@ const itemsForExport = ( visited: HashSet.HashSet, ): Effect.Effect, UnsupportedDeclaration> => Option.match(exportedSymbol(graph, entryFile, name), { - onNone: () => refuse(entryFile, `\`export { ${name} }\`, which names no declaration`), + 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(entryFile, `re-export of "${name}" from "${external.module}"`)), diff --git a/packages/api-extractor/src/front-end/declaration-graph.ts b/packages/api-extractor/src/front-end/declaration-graph.ts index 3f9735a..c0d55cd 100644 --- a/packages/api-extractor/src/front-end/declaration-graph.ts +++ b/packages/api-extractor/src/front-end/declaration-graph.ts @@ -362,7 +362,7 @@ const declarationsInStatement = (statement: Directive | Statement): ReadonlyArra Match.orElse((node) => namedDeclarationsOf(classifyDeclaration(node))), ) -const statementsIn = (bodies: ReadonlyArray): ReadonlyArray => +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 => @@ -638,6 +638,25 @@ export const exportedSymbol: { (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), { diff --git a/packages/api-extractor/tests/front-end-refusals.integration.test.ts b/packages/api-extractor/tests/front-end-refusals.integration.test.ts index a426345..6fed0f4 100644 --- a/packages/api-extractor/tests/front-end-refusals.integration.test.ts +++ b/packages/api-extractor/tests/front-end-refusals.integration.test.ts @@ -97,6 +97,26 @@ Feature('Refusing declaration text the model cannot hold') ), ) + 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( '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( From eec0927b231213e62c9d48acda3112b1fb568b48 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Wed, 14 Oct 2026 01:24:34 +0000 Subject: [PATCH 80/99] test(front-end): pin class members, undecomposed type text and a missing pattern target (U22) Testing gaps at 9854ba8: static fields, auto-accessors and accessor pairs model exactly while a private field and a static block hold nothing; template literal, predicate, asserts, indexed access, abstract construct and mixed-signature object types keep canonical text; a pattern export whose target directory does not exist adds no entry. Pins existing behaviour. --- .../tests/front-end-model.integration.test.ts | 77 +++++++++++++++++++ .../tests/package-reader.integration.test.ts | 17 ++++ 2 files changed, 94 insertions(+) diff --git a/packages/api-extractor/tests/front-end-model.integration.test.ts b/packages/api-extractor/tests/front-end-model.integration.test.ts index 63ce8dc..312dee9 100644 --- a/packages/api-extractor/tests/front-end-model.integration.test.ts +++ b/packages/api-extractor/tests/front-end-model.integration.test.ts @@ -449,6 +449,43 @@ Feature('Building a package API model from its declaration text') ), ) + 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( @@ -696,6 +733,46 @@ Feature('Building a package API model from its declaration text') ), ) + 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( diff --git a/packages/api-extractor/tests/package-reader.integration.test.ts b/packages/api-extractor/tests/package-reader.integration.test.ts index b762011..76f5bdb 100644 --- a/packages/api-extractor/tests/package-reader.integration.test.ts +++ b/packages/api-extractor/tests/package-reader.integration.test.ts @@ -77,6 +77,11 @@ const explicitOverridePackage = packageOf( 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': '' }, @@ -281,6 +286,18 @@ Feature("A package's exports name its entry points") ), ) + 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( From 42a57e70668f2667b08771475c5b15589ee84d07 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Wed, 14 Oct 2026 01:24:57 +0000 Subject: [PATCH 81/99] docs(front-end): the fallback needs TypeScript 7's native compiler package (U22) Adversarial P2 at 9854ba8: without the platform package typescript installs as an optional dependency, every package needing the fallback is refused with CompilerRejected carrying tsc's message, which names the missing package (probed: the tsc shim exits 1 with 'Unable to resolve @typescript/typescript-linux-x64'). --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c492279..643c5bc 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,11 @@ treats that message as untrusted text. A `Reported` diagnostic is one the compil 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. +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. 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 From 1e619025fb0e05dc22c9796a310b471a60135074 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Wed, 14 Oct 2026 01:30:43 +0000 Subject: [PATCH 82/99] fix(front-end): a class member's name carries protected and abstract (U22) Found pinning the testing gap at 9854ba8: `protected p` modelled as a public `p`, an abstract method as a concrete one and a protected constructor as `constructor`, so public->protected and concrete->abstract changes were invisible to the diff. Following U20 K3 (`private constructor`) and the existing `static` prefix, a member is named with its modifiers in TypeScript's written order: protected, static, abstract. Builder scenario; fails at eec0927. --- .../src/front-end/type-expr-of.ts | 48 +++++++++++++++++-- .../tests/front-end-model.integration.test.ts | 21 ++++++++ 2 files changed, 64 insertions(+), 5 deletions(-) diff --git a/packages/api-extractor/src/front-end/type-expr-of.ts b/packages/api-extractor/src/front-end/type-expr-of.ts index 6473c54..ba6d56c 100644 --- a/packages/api-extractor/src/front-end/type-expr-of.ts +++ b/packages/api-extractor/src/front-end/type-expr-of.ts @@ -337,20 +337,58 @@ const memberDraftOf = (context: TypeContext, member: TSSignature): Effect.Effect 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) => (isStatic ? `static ${name}` : name))), + 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(constructor.accessibility === 'private' ? 'private constructor' : 'constructor')), - Match.orElse(() => accessibleName(method.key, method.accessibility, method.static)), + 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> => @@ -381,14 +419,14 @@ const propertyDraft = (name: string, property: PropertyDefinition, type: TypeExp }) const classPropertyDraftOf = (context: TypeContext, property: PropertyDefinition): Effect.Effect, UnsupportedDeclaration> => - Option.match(accessibleName(property.key, property.accessibility, property.static), { + 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), { + 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 => ({ diff --git a/packages/api-extractor/tests/front-end-model.integration.test.ts b/packages/api-extractor/tests/front-end-model.integration.test.ts index 312dee9..6e5e0df 100644 --- a/packages/api-extractor/tests/front-end-model.integration.test.ts +++ b/packages/api-extractor/tests/front-end-model.integration.test.ts @@ -970,4 +970,25 @@ Feature('Building a package API model from its declaration text') })), ), ) + + 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`, `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 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, and `public r` as the plain name')((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: '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' } }] }, + ], + })), + ), + ) }) From ea58038c79054b4250bb151040f7394f8d99a192 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Wed, 14 Oct 2026 01:32:14 +0000 Subject: [PATCH 83/99] docs(plan): rule every finding of the 9854ba8 review (U22) Supersedes the 0108 plan. Records the fixing commits for member modifiers, the star-export refusal origin, the native compiler requirement and the two de-duplications; the pins; the rejections with their reasons; and the follow-ups with owners and triggers. --- ...> 2026-10-14-0130-feat-front-ends-plan.md} | 30 +++++++++++++++++-- ...lback-inherits-declaration-emit-options.md | 2 +- 2 files changed, 29 insertions(+), 3 deletions(-) rename docs/plans/{2026-10-14-0108-feat-front-ends-plan.md => 2026-10-14-0130-feat-front-ends-plan.md} (95%) diff --git a/docs/plans/2026-10-14-0108-feat-front-ends-plan.md b/docs/plans/2026-10-14-0130-feat-front-ends-plan.md similarity index 95% rename from docs/plans/2026-10-14-0108-feat-front-ends-plan.md rename to docs/plans/2026-10-14-0130-feat-front-ends-plan.md index 4b81115..65c626a 100644 --- a/docs/plans/2026-10-14-0108-feat-front-ends-plan.md +++ b/docs/plans/2026-10-14-0130-feat-front-ends-plan.md @@ -6,7 +6,7 @@ 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-0042-feat-front-ends-plan.md +supersedes: docs/plans/2026-10-14-0108-feat-front-ends-plan.md execution: code --- @@ -574,6 +574,32 @@ U1 → U2 (profile committed) → U3 (choice recorded) → U4 → U5 → U6 → - **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. + --- ## Verification Contract @@ -596,7 +622,7 @@ No mutation run, local or in CI (origin R23). ## Definition of Done -- U1-U21 verified; every Verification Contract gate passes locally and CI is green on the exact PR head. +- U1-U22 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. 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 index f82ffed..f3fdcb3 100644 --- a/docs/solutions/logic-errors/compiler-fallback-inherits-declaration-emit-options.md +++ b/docs/solutions/logic-errors/compiler-fallback-inherits-declaration-emit-options.md @@ -158,6 +158,6 @@ derived at mirror(dirname(packageTsconfig)) = { extends: packageTsconfig, files: ## Related -- `docs/plans/2026-10-14-0108-feat-front-ends-plan.md` (R3; U10 disposition #6; U12 emit policy; U13 #4, the mirrored `node_modules` ancestry and its delta ruling) +- `docs/plans/2026-10-14-0130-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 From 4ce098039b8fea5c6fb9308e1b3577cbfc28d2d5 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Wed, 14 Oct 2026 01:37:22 +0000 Subject: [PATCH 84/99] test(front-end): an abstract property, an abstract accessor and a private static member (U23) Testing P2 and correctness gap at ea58038: two of the three abstract element arms had no scenario. The class modifier scenario now holds an abstract property, an abstract auto-accessor and a private static member; it fails when either abstract arm is removed. --- .../api-extractor/tests/front-end-model.integration.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/api-extractor/tests/front-end-model.integration.test.ts b/packages/api-extractor/tests/front-end-model.integration.test.ts index 6e5e0df..148e94c 100644 --- a/packages/api-extractor/tests/front-end-model.integration.test.ts +++ b/packages/api-extractor/tests/front-end-model.integration.test.ts @@ -974,8 +974,8 @@ Feature('Building a package API model from its declaration text') 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`, `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 public r: string\n protected constructor()\n}\n']]))), + Given('an entry declaring `protected p`, `protected static q`, `abstract m`, `protected abstract get n`, `abstract x`, `abstract accessor a`, `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 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, and `public r` as the plain name')((scope, expect) => expect(itemOf(scope.model, '.', 'C', 'class')).toEqual({ @@ -985,6 +985,8 @@ Feature('Building a package API model from its declaration text') { 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: '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' } }] }, ], From a40e5524b692e7983ce84ed6cfeed08f997a5901 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Wed, 14 Oct 2026 01:40:12 +0000 Subject: [PATCH 85/99] docs(api-extractor): restore KeywordName's doc comment (U23) api-contract P3 at ea58038: the isolatedDeclarations refactor (U1) moved the keyword list into a constant and dropped the comment from the public KeywordName schema. --- packages/api-extractor/src/model/type-expr.schema.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/api-extractor/src/model/type-expr.schema.ts b/packages/api-extractor/src/model/type-expr.schema.ts index 477b91d..14e2b86 100644 --- a/packages/api-extractor/src/model/type-expr.schema.ts +++ b/packages/api-extractor/src/model/type-expr.schema.ts @@ -16,6 +16,7 @@ const KEYWORD_NAMES = [ '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 From 8c1bc4e02b0638f40b13389aa7d94af97a9dba77 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Wed, 14 Oct 2026 01:43:04 +0000 Subject: [PATCH 86/99] fix(front-end): an external re-export reached through export * is refused naming its file (U23) DeepSeek delta P3 at ea58038: M2 named the listing file for an export list that names no declaration, but an `export { x } from 'lodash'` reached through the entry's `export *` was still refused naming the entry, which holds no such statement. Both refusals now name the file whose export map lists the name. Refusals scenario; fails at ea58038. --- .../src/front-end/build-model.ts | 10 ++++++---- .../front-end-refusals.integration.test.ts | 20 +++++++++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/packages/api-extractor/src/front-end/build-model.ts b/packages/api-extractor/src/front-end/build-model.ts index 1695cf6..edeba9d 100644 --- a/packages/api-extractor/src/front-end/build-model.ts +++ b/packages/api-extractor/src/front-end/build-model.ts @@ -544,18 +544,20 @@ const itemsForExport = ( 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`), +): Effect.Effect, UnsupportedDeclaration> => { + const origin = (): string => Option.getOrElse(exportOrigin(graph, entryFile, name), () => entryFile) + return Option.match(exportedSymbol(graph, entryFile, name), { + onNone: () => refuse(origin(), `\`export { ${name} }\`, which names no declaration`), onSome: (symbol) => Match.value(symbol).pipe( - Match.when({ kind: 'external' }, (external) => refuse(entryFile, `re-export of "${name}" from "${external.module}"`)), + Match.when({ kind: 'external' }, (external) => refuse(origin(), `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 } diff --git a/packages/api-extractor/tests/front-end-refusals.integration.test.ts b/packages/api-extractor/tests/front-end-refusals.integration.test.ts index 6fed0f4..894290e 100644 --- a/packages/api-extractor/tests/front-end-refusals.integration.test.ts +++ b/packages/api-extractor/tests/front-end-refusals.integration.test.ts @@ -117,6 +117,26 @@ Feature('Refusing declaration text the model cannot hold') ), ) + scenario( + 'a re-export from another package reached through a star export is refused naming the file that re-exports it', + Gherkin.Do.pipe( + Given("an entry star-exporting a file holding `export { x } from 'lodash'`")('declarations', () => + Effect.succeed( + declarations([ + ['index.d.ts', "export * from './a.js'\n"], + ['a.d.ts', "export { x } from 'lodash'\n"], + ]), + )), + When('the model is built')('refusal', (scope) => Effect.flip(FrontEnd.buildModel(scope.declarations))), + Then('the refusal names the file holding the re-export')((scope, expect) => + expect({ + _tag: scope.refusal._tag, + file: scope.refusal.file, + construct: scope.refusal.construct, + }).toEqual({ _tag: 'UnsupportedDeclaration', file: 'a.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( From 69173f86055100dcd0340503b5fef093001ee4e4 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Wed, 14 Oct 2026 01:43:04 +0000 Subject: [PATCH 87/99] test(front-end): a public getter with a protected setter models as two members (U23) DeepSeek delta P3 at ea58038 read the split as a defect. TypeScript admits only this split (TS2808 refuses a getter less accessible than its setter, TS2676 mismatched abstractness), and two members say it exactly: `w` reads publicly and only a subclass writes `protected w`. Pins existing behaviour. --- .../tests/front-end-model.integration.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/api-extractor/tests/front-end-model.integration.test.ts b/packages/api-extractor/tests/front-end-model.integration.test.ts index 148e94c..30b6a42 100644 --- a/packages/api-extractor/tests/front-end-model.integration.test.ts +++ b/packages/api-extractor/tests/front-end-model.integration.test.ts @@ -974,10 +974,10 @@ Feature('Building a package API model from its declaration text') 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`, `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 private static z: number\n public r: string\n protected constructor()\n}\n']]))), + 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, and `public r` as the plain name')((scope, expect) => + 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: [ @@ -987,6 +987,8 @@ Feature('Building a package API model from its declaration text') { 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' } }] }, ], From fa6bca20c3fa23ffafdeddf9ccde4f39039ac44b Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Wed, 14 Oct 2026 01:44:04 +0000 Subject: [PATCH 88/99] docs(plan): rule every finding of the ea58038 review (U23) Supersedes the 0130 plan. Records the fixing commits for the external re-export refusal origin, the abstract-arm pins and KeywordName's doc comment; the accessor-pair pin; the rejections with their probes; and the follow-ups with owners and triggers. --- ...> 2026-10-14-0143-feat-front-ends-plan.md} | 28 +++++++++++++++++-- ...lback-inherits-declaration-emit-options.md | 2 +- 2 files changed, 27 insertions(+), 3 deletions(-) rename docs/plans/{2026-10-14-0130-feat-front-ends-plan.md => 2026-10-14-0143-feat-front-ends-plan.md} (95%) diff --git a/docs/plans/2026-10-14-0130-feat-front-ends-plan.md b/docs/plans/2026-10-14-0143-feat-front-ends-plan.md similarity index 95% rename from docs/plans/2026-10-14-0130-feat-front-ends-plan.md rename to docs/plans/2026-10-14-0143-feat-front-ends-plan.md index 65c626a..bb7f3f0 100644 --- a/docs/plans/2026-10-14-0130-feat-front-ends-plan.md +++ b/docs/plans/2026-10-14-0143-feat-front-ends-plan.md @@ -6,7 +6,7 @@ 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-0108-feat-front-ends-plan.md +supersedes: docs/plans/2026-10-14-0130-feat-front-ends-plan.md execution: code --- @@ -600,6 +600,30 @@ U1 → U2 (profile committed) → U3 (choice recorded) → U4 → U5 → U6 → - **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. + --- ## Verification Contract @@ -622,7 +646,7 @@ No mutation run, local or in CI (origin R23). ## Definition of Done -- U1-U22 verified; every Verification Contract gate passes locally and CI is green on the exact PR head. +- U1-U23 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. 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 index f3fdcb3..043f810 100644 --- a/docs/solutions/logic-errors/compiler-fallback-inherits-declaration-emit-options.md +++ b/docs/solutions/logic-errors/compiler-fallback-inherits-declaration-emit-options.md @@ -158,6 +158,6 @@ derived at mirror(dirname(packageTsconfig)) = { extends: packageTsconfig, files: ## Related -- `docs/plans/2026-10-14-0130-feat-front-ends-plan.md` (R3; U10 disposition #6; U12 emit policy; U13 #4, the mirrored `node_modules` ancestry and its delta ruling) +- `docs/plans/2026-10-14-0143-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 From d08f6480b1916c6ec4586f3bfa279f5bd7778146 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Wed, 14 Oct 2026 01:53:04 +0000 Subject: [PATCH 89/99] fix(front-end): an external re-export is refused naming the file that resolved it (U24) DeepSeek delta P3 at fa6bca2: N1 named the first star target listing the 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 now carries the file whose binding resolved it, and the refusal names that file; the unresolved-name refusal keeps naming the first listing file. Refusals scenario; fails at fa6bca2. --- .../src/front-end/build-model.ts | 10 +++---- .../src/front-end/declaration-graph.ts | 4 +-- .../front-end-refusals.integration.test.ts | 30 ++++++++++++++----- 3 files changed, 28 insertions(+), 16 deletions(-) diff --git a/packages/api-extractor/src/front-end/build-model.ts b/packages/api-extractor/src/front-end/build-model.ts index edeba9d..41154b7 100644 --- a/packages/api-extractor/src/front-end/build-model.ts +++ b/packages/api-extractor/src/front-end/build-model.ts @@ -544,20 +544,18 @@ const itemsForExport = ( entryFile: string, name: string, visited: HashSet.HashSet, -): Effect.Effect, UnsupportedDeclaration> => { - const origin = (): string => Option.getOrElse(exportOrigin(graph, entryFile, name), () => entryFile) - return Option.match(exportedSymbol(graph, entryFile, name), { - onNone: () => refuse(origin(), `\`export { ${name} }\`, which names no declaration`), +): 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(origin(), `re-export of "${name}" from "${external.module}"`)), + 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 } diff --git a/packages/api-extractor/src/front-end/declaration-graph.ts b/packages/api-extractor/src/front-end/declaration-graph.ts index c0d55cd..17e64ce 100644 --- a/packages/api-extractor/src/front-end/declaration-graph.ts +++ b/packages/api-extractor/src/front-end/declaration-graph.ts @@ -412,7 +412,7 @@ export type FileScope = { 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 module: string; readonly imported: string } + | { readonly kind: 'external'; readonly file: string; readonly module: string; readonly imported: string } export type DeclarationGraph = { readonly files: HashMap.HashMap @@ -590,7 +590,7 @@ const packageImportTarget = (graph: DeclarationGraph, file: string, imported: st 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', module: binding.specifier, imported: binding.imported }) + : 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)) diff --git a/packages/api-extractor/tests/front-end-refusals.integration.test.ts b/packages/api-extractor/tests/front-end-refusals.integration.test.ts index 894290e..47082bf 100644 --- a/packages/api-extractor/tests/front-end-refusals.integration.test.ts +++ b/packages/api-extractor/tests/front-end-refusals.integration.test.ts @@ -118,22 +118,36 @@ Feature('Refusing declaration text the model cannot hold') ) scenario( - 'a re-export from another package reached through a star export is refused naming the file that re-exports it', + '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'`")('declarations', () => + 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"], ]), )), - When('the model is built')('refusal', (scope) => Effect.flip(FrontEnd.buildModel(scope.declarations))), - Then('the refusal names the file holding the re-export')((scope, expect) => + 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({ - _tag: scope.refusal._tag, - file: scope.refusal.file, - construct: scope.refusal.construct, - }).toEqual({ _tag: 'UnsupportedDeclaration', file: 'a.d.ts', construct: 're-export of "x" from "lodash"' })), + 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"' }, + })), ), ) From 84ef8d21ae0ebaac6c039da70daeed03fef428aa Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Wed, 14 Oct 2026 01:54:19 +0000 Subject: [PATCH 90/99] test(api-extractor): a decoded verdict carries the family brand (U24) Testing gap at fa6bca2: U1 moved VerdictTypeId from an instance field to Verdict.prototype for isolatedDeclarations, and only an orchestrator probe read it. A verdict decoded from the cell's document holds the brand; the scenario fails when the prototype brand is removed. --- .../api-extractor/tests/verdict.integration.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) 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