From 99fed97aa490c7a4f728a3fdda7f7eeedcb114d8 Mon Sep 17 00:00:00 2001 From: "C. Spencer Beggs" Date: Tue, 21 Jul 2026 15:13:06 -0400 Subject: [PATCH 1/2] feat: mirror pnpm release-age gate in npm and workspaces, type compiler options encode - ReleaseAgeGate and PartialReleaseAgeGate in @effected/npm: strictest-wins combine, pnpm-parity flat star exclude matching, pure publish-age version filtering with a caller clock. - ConfigDependencyHooks.inject now returns HookInjection so one hook replay yields catalogs and release-age keys; WorkspaceCatalogs gains releaseAgeGate assembling the effective gate strictest-wins. - TsEnumCodec.encodeCompilerOptions returns the new structural ProgrammaticCompilerOptions type so consumers drop their casts. - Changesets: @effected/npm 0.3.0, @effected/workspaces 0.6.0, @effected/tsconfig-json 0.3.0. Closes #120 Signed-off-by: C. Spencer Beggs --- .changeset/programmatic-compiler-options.md | 26 +++ .changeset/release-age-gate-assembly.md | 53 +++++ .changeset/release-age-gate-vocabulary.md | 36 +++ .claude/design/effected/packages/npm.md | 25 +- .../design/effected/packages/tsconfig-json.md | 15 +- .../design/effected/packages/workspaces.md | 14 +- .claude/design/refs.json | 12 +- packages/npm/CLAUDE.md | 9 +- packages/npm/README.md | 1 + packages/npm/__test__/ReleaseAgeGate.test.ts | 218 ++++++++++++++++++ packages/npm/src/ReleaseAgeGate.ts | 204 ++++++++++++++++ packages/npm/src/index.ts | 1 + packages/tsconfig-json/CLAUDE.md | 4 +- packages/tsconfig-json/README.md | 2 +- .../TsEnumCodec.assignability.test.ts | 80 +++++++ packages/tsconfig-json/src/TsEnumCodec.ts | 110 ++++++++- packages/tsconfig-json/src/index.ts | 6 +- packages/workspaces/CLAUDE.md | 12 +- packages/workspaces/README.md | 2 +- .../__test__/WorkspaceCatalogs.test.ts | 70 ++++++ .../fixtures/hook-pnpmfile-age-1440.mjs | 14 ++ .../fixtures/hook-pnpmfile-age-4320.mjs | 13 ++ .../fixtures/hook-pnpmfile-age-garbage.mjs | 14 ++ .../ConfigDependencyHooks.int.test.ts | 137 ++++++++++- .../workspaces/src/ConfigDependencyHooks.ts | 98 +++++++- packages/workspaces/src/WorkspaceCatalogs.ts | 103 ++++++++- packages/workspaces/src/index.ts | 6 +- 27 files changed, 1217 insertions(+), 68 deletions(-) create mode 100644 .changeset/programmatic-compiler-options.md create mode 100644 .changeset/release-age-gate-assembly.md create mode 100644 .changeset/release-age-gate-vocabulary.md create mode 100644 packages/npm/__test__/ReleaseAgeGate.test.ts create mode 100644 packages/npm/src/ReleaseAgeGate.ts create mode 100644 packages/tsconfig-json/__test__/TsEnumCodec.assignability.test.ts create mode 100644 packages/workspaces/__test__/fixtures/hook-pnpmfile-age-1440.mjs create mode 100644 packages/workspaces/__test__/fixtures/hook-pnpmfile-age-4320.mjs create mode 100644 packages/workspaces/__test__/fixtures/hook-pnpmfile-age-garbage.mjs diff --git a/.changeset/programmatic-compiler-options.md b/.changeset/programmatic-compiler-options.md new file mode 100644 index 00000000..936b2e67 --- /dev/null +++ b/.changeset/programmatic-compiler-options.md @@ -0,0 +1,26 @@ +--- +"@effected/tsconfig-json": minor +--- + +## Features + +### `TsEnumCodec.encodeCompilerOptions` returns `ProgrammaticCompilerOptions` + +`encodeCompilerOptions` returned `Record`, so a consumer +handing the result to a `ts.CompilerOptions`-shaped API (`@typescript/vfs`, +the TypeScript compiler API) had to cast. It now returns the new exported +`ProgrammaticCompilerOptions` type (values `ProgrammaticCompilerOptionsValue`) +— a structural transcription of TypeScript's own `CompilerOptionsValue`, +verified assignable to the real `ts.CompilerOptions` without importing +`typescript`, preserving the package's zero-`typescript`-import rule: + +```ts +import { TsEnumCodec } from "@effected/tsconfig-json"; +import { createVirtualTypeScriptEnvironment } from "@typescript/vfs"; + +const compilerOptions = TsEnumCodec.encodeCompilerOptions(decoded); +// no cast needed — assignable to ts.CompilerOptions +createVirtualTypeScriptEnvironment(fsMap, rootFiles, system, compilerOptions); +``` + +Runtime behavior is unchanged; only the declared return type narrows. diff --git a/.changeset/release-age-gate-assembly.md b/.changeset/release-age-gate-assembly.md new file mode 100644 index 00000000..1d05abc1 --- /dev/null +++ b/.changeset/release-age-gate-assembly.md @@ -0,0 +1,53 @@ +--- +"@effected/workspaces": minor +--- + +## Breaking Changes + +### `ConfigDependencyHooks.inject` returns `HookInjection`, not a bare catalogs record + +`inject` previously resolved to the catalogs a replayed `updateConfig` hook +produced. It now resolves to a `HookInjection`: + +```ts +interface HookInjection { + readonly catalogs: Readonly>>>; + readonly releaseAge: PartialReleaseAgeGate; +} +``` + +One hook replay now yields both the catalogs and the release-age keys +(`minimumReleaseAge` / `minimumReleaseAgeExclude`) the hooks leave on the +config, so the config-dependency code — which executes arbitrary +`pnpmfile.cjs` logic — still runs exactly once. When two hooks both set a +release-age key, the later hook wins. `ConfigDependencyHooks.layerNoop` now +returns `{ catalogs: seed, releaseAge: {} }` instead of the bare seed. A +caller that awaited `inject` directly and read the catalogs off the resolved +value needs to read `.catalogs` instead. + +## Features + +### `WorkspaceCatalogs.releaseAgeGate()` + +Assembles the workspace's effective pnpm release-age gate from inline +`pnpm-workspace.yaml` keys and the replayed config-dependency hooks, +strictest-wins via `ReleaseAgeGate.combine`, in the same single memoized +assembly pass as `set()`: + +```ts +import { WorkspaceCatalogs } from "@effected/workspaces"; + +const program = Effect.gen(function* () { + const catalogs = yield* WorkspaceCatalogs; + const gate = yield* catalogs.releaseAgeGate(); + // gate.ageMinutes, gate.exclude +}); +``` + +A present-but-malformed inline `minimumReleaseAge` or +`minimumReleaseAgeExclude` now fails typed as `CatalogAssemblyError` +(`source: "manifest"`) instead of being silently ignored — a silently-dropped +gate is exactly the "install refuses a version the resolver already picked" +bug this vocabulary exists to prevent. A workspace with no +`pnpm-workspace.yaml` (a bun/npm workspace) has no release-age keys, so the +gate is the inert zero gate. `HookInjection` is exported from the package. diff --git a/.changeset/release-age-gate-vocabulary.md b/.changeset/release-age-gate-vocabulary.md new file mode 100644 index 00000000..bcf1efae --- /dev/null +++ b/.changeset/release-age-gate-vocabulary.md @@ -0,0 +1,36 @@ +--- +"@effected/npm": minor +--- + +## Features + +### `ReleaseAgeGate` / `PartialReleaseAgeGate` + +Adds shared vocabulary for pnpm's publish-time release-age gate — the +`minimumReleaseAge` / `minimumReleaseAgeExclude` config pnpm uses to refuse +installing a version younger than a cutoff +(`ERR_PNPM_NO_MATURE_MATCHING_VERSION`). A resolver that picks the highest +in-range version with no publish-time awareness can pick a version pnpm then +rejects; mirroring the gate at resolution time avoids that. + +```ts +import { ReleaseAgeGate } from "@effected/npm"; + +const gate = ReleaseAgeGate.combine({ ageMinutes: 1440 }, { exclude: ["@my-scope/*"] }); + +const eligible = gate.filterVersions( + ["1.0.0", "1.0.1"], + { "1.0.0": "2020-01-01T00:00:00Z", "1.0.1": "2026-07-21T00:00:00Z" }, + "prettier", + Date.now(), +); +``` + +`ReleaseAgeGate.combine` merges partial contributions from multiple config +sources strictest-wins: the maximum of the contributed ages (clamped +non-negative), and the exclude sets unioned. `matchesExclude` mirrors pnpm's +own `@pnpm/matcher` name-matching semantics — a `*`-glob crosses `/`, unlike +`@effected/glob`'s minimatch dialect — so `isExcluded` and `filterVersions` +behave exactly like pnpm's own gate. `filterVersions` takes the caller's +clock; a version with a missing or unparseable publish timestamp is dropped, +matching pnpm's strict posture. diff --git a/.claude/design/effected/packages/npm.md b/.claude/design/effected/packages/npm.md index 0684e456..d4ec8445 100644 --- a/.claude/design/effected/packages/npm.md +++ b/.claude/design/effected/packages/npm.md @@ -3,8 +3,8 @@ status: current module: effected category: architecture created: 2026-07-08 -updated: 2026-07-20 -last-synced: 2026-07-20 +updated: 2026-07-21 +last-synced: 2026-07-21 completeness: 93 related: - ../architecture.md @@ -20,7 +20,7 @@ related: ## Overview -`@effected/npm` is a **pure-tier** package (no `npm-effect` source repo behind it) that owns three things: the **dependency-resolution contracts** a package.json-document library defines but cannot implement — `CatalogResolver` and `WorkspaceResolver`, resolving pnpm `catalog:` / `workspace:` specifiers to concrete versions — the **cross-cutting npm vocabulary** that flows between the manifest, lockfile and workspace packages: `DependencySpecifier`, the dependency-section literals (`DependencyKind` / `DependencyField`) and `IntegrityHash` — and the **[`Manifest` domain model](#manifest-tolerant-manifest-level-resolution)**, manifest-level resolution built on the per-specifier contracts (2026-07-16, from the systems dogfood feedback). +`@effected/npm` is a **pure-tier** package (no `npm-effect` source repo behind it) that owns three things: the **dependency-resolution contracts** a package.json-document library defines but cannot implement — `CatalogResolver` and `WorkspaceResolver`, resolving pnpm `catalog:` / `workspace:` specifiers to concrete versions — the **cross-cutting npm vocabulary** that flows between the manifest, lockfile and workspace packages: `DependencySpecifier`, the dependency-section literals (`DependencyKind` / `DependencyField`), `IntegrityHash` and the **[`ReleaseAgeGate`](#releaseagegate-the-release-age-gate-vocabulary)** publish-time gate — and the **[`Manifest` domain model](#manifest-tolerant-manifest-level-resolution)**, manifest-level resolution built on the per-specifier contracts (2026-07-16, from the systems dogfood feedback). Resolution lives here rather than in package-json because it fundamentally requires workspace/catalog context that a manifest library cannot have; the full rationale is [resolution belongs to @effected/npm](package-json.md#resolution-belongs-to-effectednpm). The vocabulary lives here because these scalars are shared by three or more packages, and a single home stops each from prefix-sniffing its own reimplementation. @@ -46,9 +46,10 @@ Per the [module-per-concept standard](../effect-standards.md#module-layout-modul - `DependencySpecifier.ts` — the branded specifier, its classification statics and the `FromString` codec to the classified union. - `DependencySection.ts` — the `DependencyKind` / `DependencyField` literals and their mapping. - `IntegrityHash.ts` — the SRI/corepack/yarn integrity brand. +- `ReleaseAgeGate.ts` — the `ReleaseAgeGate` class and its permissive `PartialReleaseAgeGate` input shape; the pure release-age gate vocabulary (see [ReleaseAgeGate](#releaseagegate-the-release-age-gate-vocabulary)). - `index.ts` — the public surface and the composite `Default` layer (`Layer.mergeAll(CatalogResolver.noop, WorkspaceResolver.noop)`), which lives here because merging both no-op layers is the cycle-free home. -Every class factory is written **inline** with the synthesized `_base` heritage symbols suppressed narrowly in `savvy.build.ts` (`ae-forgotten-export` / `_base` pattern), per the [API-Extractor policy](../effect-standards.md#api-extractor--effect-class-factories), keeping `dist/prod/issues.json` zero-warning. The prod gate's expected suppressed count is **14** (`suppressed: 0` in the prod gate means the build did not run properly). +Every class factory is written **inline** with the synthesized `_base` heritage symbols suppressed narrowly in `savvy.build.ts` (`ae-forgotten-export` / `_base` pattern), per the [API-Extractor policy](../effect-standards.md#api-extractor--effect-class-factories), keeping `dist/prod/issues.json` zero-warning. The prod gate's expected suppressed count is **15** (`ReleaseAgeGate`'s `_base` joined the count when the gate landed; `suppressed: 0` in the prod gate means the build did not run properly). ## Resolver contracts @@ -103,6 +104,20 @@ One concept, owned here as two `Schema.Literals`: `DependencyKind` (the short ki An SRI brand covering **three** textual forms, because lockfile integrity is not all-SRI: npm/pnpm record `sha512-` SRI, corepack records the `name@version+sha512.hex` pin form, and yarn Berry records `/` cache checksums. Dropping the yarn form would silently discard integrity the [lockfiles](lockfiles.md) model treats as load-bearing, so the brand covers all three. `algorithmOf` returns `Option.none()` for the yarn form (which names no algorithm); the SRI and corepack forms report theirs. +## ReleaseAgeGate: the release-age gate vocabulary + +pnpm's publish-time **release-age gate** is shared npm vocabulary, so it is resident here (2026-07-21, dogfood request from systems relaying silk-update-action, which fails `ERR_PNPM_NO_MATURE_MATCHING_VERSION` without it). pnpm reads two config keys — `minimumReleaseAge` (minutes a published version must age before it is eligible) and `minimumReleaseAgeExclude` (name patterns exempt from the gate) — and refuses to install a version younger than the cutoff. A resolver that picks the highest in-range version with no publish-time awareness picks a version pnpm then rejects; mirroring the gate at resolution time (drop too-young candidates before picking) fixes it. + +This is a **schema-first port of the gate vocabulary only.** `ReleaseAgeGate` is a `Schema.Class` — `ageMinutes` (non-negative, finite) and `exclude` (`readonly string[]`) — with: + +- **`combine(...contributions)`** (static, variadic, **total**) — assembles the effective gate from partial contributions across sources (inline `pnpm-workspace.yaml`, replayed hooks, `pnpm config get`): **strictest age wins** (clamped `Math.max`, non-finite contributions ignored), exclude sets **union** (deduplicated, insertion order preserved). Zero contributions yield the inert zero gate (`ageMinutes: 0`, `exclude: []`). It never throws — which is why the partial input shape does not clamp. +- **`matchesExclude(name, patterns)`** (static) plus instance **`isExcluded(name)`** — flat-string `*` matching with **`@pnpm/matcher` parity: `*` crosses `/`**, so a bare `*` matches a scoped name and `@scope/*` matches a whole scope. This is **deliberately NOT `@effected/glob`'s minimatch dialect** (where `*` refuses to cross `/`); pnpm treats the package name as a flat string, and routing through `@effected/glob` would silently change which packages a gate exempts. The divergence is documented in the module's TSDoc — do not "fix" it. +- **`filterVersions(versions, times, name, now)`** (instance, **pure, caller-supplied clock**) — drops versions younger than the cutoff (`now - ageMinutes * 60000`) and versions with a missing or unparseable timestamp (pnpm's strict posture: an unestablishable age is too young); a version exactly at the cutoff is kept. A no-op when the gate is inert (`ageMinutes <= 0`) or the name is excluded. No error channel; it reads no wall clock. + +**`PartialReleaseAgeGate`** is the permissive input: a `Schema.Struct` with `optionalKey` `ageMinutes` / `exclude` and **no non-negative check** — raw values arrive from arbitrary config sources and `combine` is the single clamping authority. A source sets the age, the exclude list, both, or neither. + +**Consumer-side config readers are deliberately NOT ported.** `readConfigReleaseAge` / `parsePnpmGate` from the rolldown-pnpm-config reference stay out — reading the gate from `pnpm-workspace.yaml` keys or replayed `updateConfig` hooks is config IO, a boundary/integrated concern. `@effected/workspaces` owns that assembly (`WorkspaceCatalogs.releaseAgeGate`, its `ConfigDependencyHooks` release-age surfacing — [workspaces.md](workspaces.md#configdependencyhooks--the-opt-in-replay-seam)); this pure module is the vocabulary those readers combine into. + ## Vocabulary registry Four packages (npm, package-json, lockfiles, workspaces) operate around overlapping npm concepts. This registry maps where each concept lives, surveyed against npm's [`package.json`](https://docs.npmjs.com/cli/v12/configuring-npm/package-json) and [`package-lock.json`](https://docs.npmjs.com/cli/v12/configuring-npm/package-lock-json) documentation. **API ships on evidence, the registry ships the map**: an unmodeled concept stays unmodeled until a consumer materializes, but nobody rebuilds an idiom for not knowing its home. @@ -157,4 +172,4 @@ The lockfiles npm parser normalizes `package-lock.json` into the one `Lockfile` ## Testing -`@effect/vitest`, `it.effect`; tests in `__test__/` per concept. The resolver surface is contracts, so those tests are light: the no-op layers return `Option.none()`, a stub-implementation layer proves the contract is implementable, and `DependencyResolutionError` preserves its structured `cause`. The vocabulary tests carry the weight — specifier classification across the protocol set, the `DependencySpecifier` round-trip property, the resolution projections (`catalogNameOf`, `resolveWorkspace`, `WorkspaceSpecifier#resolve`), `DependencyKind`/`DependencyField` mapping, and `IntegrityHash` across all three forms. `__test__/Manifest.test.ts` drives the tolerance boundary (dependency fields fail typed, everything else rides `rest` and round-trips), `needsResolution`, and `resolve()` over stub resolver layers including the `UnresolvedDependencyError` cases. +`@effect/vitest`, `it.effect`; tests in `__test__/` per concept. The resolver surface is contracts, so those tests are light: the no-op layers return `Option.none()`, a stub-implementation layer proves the contract is implementable, and `DependencyResolutionError` preserves its structured `cause`. The vocabulary tests carry the weight — specifier classification across the protocol set, the `DependencySpecifier` round-trip property, the resolution projections (`catalogNameOf`, `resolveWorkspace`, `WorkspaceSpecifier#resolve`), `DependencyKind`/`DependencyField` mapping, `IntegrityHash` across all three forms, and `ReleaseAgeGate` (`combine`'s strictest-wins/union/totality, `@pnpm/matcher`-parity `matchesExclude`, and `filterVersions`' cutoff/exactly-at-cutoff/missing-timestamp/inert/excluded cases). `__test__/Manifest.test.ts` drives the tolerance boundary (dependency fields fail typed, everything else rides `rest` and round-trips), `needsResolution`, and `resolve()` over stub resolver layers including the `UnresolvedDependencyError` cases. diff --git a/.claude/design/effected/packages/tsconfig-json.md b/.claude/design/effected/packages/tsconfig-json.md index aa263e9c..bd3f0b46 100644 --- a/.claude/design/effected/packages/tsconfig-json.md +++ b/.claude/design/effected/packages/tsconfig-json.md @@ -3,8 +3,8 @@ status: current module: effected category: architecture created: 2026-07-13 -updated: 2026-07-20 -last-synced: 2026-07-20 +updated: 2026-07-21 +last-synced: 2026-07-21 completeness: 95 related: - ../roadmap.md @@ -133,7 +133,7 @@ Nearest-tsconfig upward search over [@effected/walker](walker.md), with the file One pure module owns the version-coupled string↔numeric mappings **as plain data**: `ScriptTarget`, `ModuleKind` (including the 101/102 `node18`/`node20` gaps that not all TypeScript versions export), `ModuleResolutionKind`, `JsxEmit`, `ModuleDetectionKind`, `NewLineKind`, plus the lib-reference normalizer (`lib.esnext.d.ts` ↔ `esnext`). -- The **encode** direction feeds an external Twoslash/virtual-TS environment: output shaped like `ts.CompilerOptions` but typed structurally, with no `typescript` type import. +- The **encode** direction feeds an external Twoslash/virtual-TS environment: output shaped like `ts.CompilerOptions` but typed structurally, with no `typescript` type import (see [the typed encode return](#the-typed-encode-return-programmaticcompileroptions)). - The **decode** direction absorbs numeric configs coming out of TS APIs during consumers' transition off direct config-API usage. When TypeScript adds an enum member, the change here is a data edit and a test fixture, not a dependency bump. Two whole-object helpers: @@ -141,6 +141,14 @@ When TypeScript adds an enum member, the change here is a data edit and a test f - **`decodeCompilerOptions` returns `Record`, not `CompilerOptions.Type`** — passthrough-honest. A numeric value with no table entry (a future TS enum member) is left as-is rather than errored; callers wanting the validated shape decode through the schema afterwards. - **The `lib` encode direction emits the file-name form** (`lib.esnext.d.ts`), not the short name — verified against the installed TypeScript (`pathForLibFile` joins each `options.lib` entry onto the lib directory as a literal file name). A virtual-TS environment hands the options straight through to `ts.createProgram`, so consumers get the one form the real compiler resolves. The decode direction and `normalizeLibReference` emit the short form. +### The typed encode return: ProgrammaticCompilerOptions + +`encodeCompilerOptions` returns the exported structural types **`ProgrammaticCompilerOptions`** / **`ProgrammaticCompilerOptionsValue`**, not the old `Record` (2026-07-21, closes [#120](https://github.com/spencerbeggs/effected/issues/120)) — so a consumer handing the result to `@typescript/vfs`'s `createVirtualTypeScriptEnvironment` or to `ts.createProgram` no longer ends the pipeline with a cast. The types are a **verbatim structural transcription of `typescript@6.0.3`'s `CompilerOptionsValue`** (minus the compiler-internal `TsConfigSourceFile`, an AST node unreachable from JSON), cited in TSDoc — the package's **zero-`typescript` rule is preserved**, nothing is imported. The six enum-family keys are typed `number` (optional), sound because a decoded `CompilerOptions.Type` restricts each spelling to the tables' covered literals (verified full coverage), so the "unknown string passes through unencoded" branch is unreachable for well-typed input; `lib` is typed `string[]`. + +One **documented internal assertion** lives at `encodeCompilerOptions`'s return, bridging the codec's `unknown`/`readonly` internal record to the tsc-assignable value union — owned once here rather than re-cast at every call site, exactly as `ts.CompilerOptions`'s own index signature makes the identical unproven claim about passthrough values. Runtime behavior is unchanged; only the declared return narrows. `decodeCompilerOptions` is untouched. A compile-time assignability test (`__test__/TsEnumCodec.assignability.test.ts`) pins the result assignable to a **cited structural replica** of `ts.CompilerOptions` (no `typescript` import). + +The free assignability targets the **TS6 / `@typescript/vfs` consumer specifically**: `typescript@7.0.2`'s `CompilerOptions` dropped its index signature while keeping nominal enums, so the structural-subset argument is against the TS6 shape the encode target's consumer pins — worth recording as the version-coupled nuance it is. + ## Portable tsconfig A small pure module providing the **portable tsconfig** filter: it takes a resolved config and produces a self-contained, machine-independent one — `compilerOptions` only, emit/path/file-selection options excluded, `composite: false` and `noEmit: true` forced, `$schema` stamped (`https://json.schemastore.org/tsconfig`). It is generic to any virtual-TS or Twoslash environment. @@ -163,6 +171,7 @@ Per the [testing standards](../effect-standards.md#testing-standards): `@effect/ - **Data-driven tsc-parity tests** for the extends lookup rules, recorded from the TypeScript-source verification. - **Hostile inputs** — cycles, deep chains, malformed JSONC, dunder keys — each failing with its typed error, never a defect. - **Round-trip properties on the document schema**, including unknown-key preservation through decode and re-encode. +- **A compile-time assignability test** (`TsEnumCodec.assignability.test.ts`) proving `encodeCompilerOptions`'s `ProgrammaticCompilerOptions` return assignable to a cited structural replica of `ts.CompilerOptions` (no `typescript` import). ## Evidence base and consumers diff --git a/.claude/design/effected/packages/workspaces.md b/.claude/design/effected/packages/workspaces.md index bb61666e..77b168b9 100644 --- a/.claude/design/effected/packages/workspaces.md +++ b/.claude/design/effected/packages/workspaces.md @@ -3,8 +3,8 @@ status: current module: effected category: architecture created: 2026-07-10 -updated: 2026-07-20 -last-synced: 2026-07-20 +updated: 2026-07-21 +last-synced: 2026-07-21 completeness: 95 related: - ../effect-standards.md @@ -96,7 +96,7 @@ Module-per-concept. | `src/WorkspaceCatalogs.ts` | `CatalogSet` class, `WorkspaceCatalogs` service + layer, the `catalogResolver` layer | | `src/WorkspaceSnapshots.ts` | `WorkspaceSnapshots` service + layers, the snapshot error unions | | `src/WorkspaceStateSnapshot.ts` | `WorkspaceStateSnapshot` and `PackageStateSnapshot` value classes | -| `src/ConfigDependencyHooks.ts` | `ConfigDependencyHooks` contract service, `layerLive`, `layerNoop` | +| `src/ConfigDependencyHooks.ts` | `ConfigDependencyHooks` contract service, `layerLive`, `layerNoop`, the `HookInjection` result type (`catalogs` + `releaseAge`) | | `src/LockfileReader.ts` | `LockfileReader` service + layer (the IO half of `@effected/lockfiles`), `LockfileReadError` | | `src/Publishability.ts` | `PublishTarget`, `PublishabilityDetectorShape`, `PublishabilityDetector` service + default layer | | `src/Workspaces.ts` | The composite layers, `resolverLayer`, `resolveManifest` | @@ -172,6 +172,8 @@ Root resolution is one concern, applied uniformly: every root-consuming layer is `CatalogSet` is the immutable, fully-normalized catalog collection with the one resolution semantic (constructors plus `merge` and `rangeOf`). It carries statics for its three sources: `fromLockfile`, `fromBunBlocks` and `fromManifestWorkspaces`. `WorkspaceCatalogs` assembles it with pnpm's precedence and memoizes. **`internal/catalogs.ts` is the only module that imports `@pnpm/catalogs.*`** — the tier-3 blast radius is one file. +**`WorkspaceCatalogs.releaseAgeGate(): Effect`** (2026-07-21) assembles the workspace's effective pnpm release-age gate. It folds the inline `pnpm-workspace.yaml` `minimumReleaseAge` / `minimumReleaseAgeExclude` keys and the hook contributions ([ConfigDependencyHooks](#configdependencyhooks--the-opt-in-replay-seam)) through `ReleaseAgeGate.combine` (strictest-wins — [npm.md](npm.md#releaseagegate-the-release-age-gate-vocabulary)), reusing **the same single memoized `assemble` pass** as `set` — one root discovery, one YAML read, one hook replay, both outputs (`CatalogSet` and `ReleaseAgeGate`) memoized together, so the config-dependency code runs exactly once. Present-but-malformed inline release-age values **hard-fail** as `CatalogAssemblyError(source: "manifest")` — the same load-bearing posture as a malformed inline catalog block (a silently-ignored gate is the "install refuses a too-young version the resolver already picked" bug); absent keys contribute nothing. Under the default layer (no-op hooks) the gate sees inline values only; under `layerWithConfigDependencies` it sees both. A bun/npm workspace (no `pnpm-workspace.yaml`) has no release-age keys, so the gate is inert. There is **deliberately no top-level `Workspaces.releaseAgeGate` convenience** — the service method is the surface. + The reader is **PM-aware**. File presence picks the reader: `pnpm-workspace.yaml` present → the pnpm path; absent → the root `package.json` `workspaces.catalog` / `catalogs` path (bun's analogue). The catalog readers **hard-fail by design** because their output is load-bearing for diffing — a silently-empty read is the "every dependency looks added" bug: - A present-but-malformed `workspaces` shape (a number, a string, an object with malformed `packages`/`catalog`/`catalogs`) fails with `CatalogAssemblyError`. An absent field, or one explicitly `null`, yields empty. @@ -224,6 +226,11 @@ Mechanics follow the house rules: caching per `(root, ref)` via `Effect.cachedIn - **Failure is typed, never silent.** A config dependency that fails to load or replay fails with a `"hooks"`-source `CatalogAssemblyError`. - **Security guard:** `layerLive` rejects a config-dependency name containing a `..` path segment **before** building the `import()` target, so a malicious `configDependencies` entry cannot escape the intended directory. +**`inject` returns a structured `HookInjection`, not bare catalogs** (2026-07-21; breaking pre-`0.1.0`, accepted). The replay now surfaces pnpm's release-age keys alongside the catalogs: `HookInjection { catalogs, releaseAge: PartialReleaseAgeGate }`, exported from the index. A **sibling method** would re-execute config-dependency code — the whole point of the seam is that one replay over one mutable config object yields both outputs, exactly as pnpm replays hooks. `layerNoop` returns `{ catalogs: seed, releaseAge: {} }`. + +- **Release-age keys thread last-hook-wins.** `minimumReleaseAge` / `minimumReleaseAgeExclude` are read off the one final threaded config object, so when two hooks both set a key the later write wins — pnpm's single-mutable-config-object behavior. +- **A malformed release-age value is tolerantly dropped**, keeping the prior threaded value — matching the catalog slice's tolerant `configOf`. `CatalogAssemblyError` stays reserved for a load/replay *mechanism* failure, not a hook's returned *data*. The `releaseAge` contribution is a `PartialReleaseAgeGate` because a consumer folds it into an effective gate with `ReleaseAgeGate.combine` alongside the inline values ([npm.md](npm.md#releaseagegate-the-release-age-gate-vocabulary)); hooks setting no release-age keys contribute an empty gate. + ## Error handling The package's own `Schema.TaggedErrorClass` types with **structured** fields: @@ -275,6 +282,7 @@ Mutation-proven edges: - A bun or npm workspace read at a ref must not collapse to the root package alone; `at("HEAD")` and `worktree()` agree on a clean tree (which also pins the unconditional inline-bun-catalog read). - The double-default rejection (`workspaces.catalog` plus `workspaces.catalogs.default`), checked structurally. - Hook replay through the opt-in layer against a fixture pnpmfile; the default layer provably never loads it. +- Release-age assembly: inline `minimumReleaseAge` / `minimumReleaseAgeExclude` folded through `ReleaseAgeGate.combine`, a malformed inline value hard-failing typed; hook-injected release-age via fixture pnpmfiles (`age-1440`, `age-4320` strictest-wins, `age-garbage` tolerantly dropped); the default no-op layer sees inline only. - TTL-cache discipline: a failed `at(ref)` init is retried, not memoized. ## Build diff --git a/.claude/design/refs.json b/.claude/design/refs.json index 9e686711..98168192 100644 --- a/.claude/design/refs.json +++ b/.claude/design/refs.json @@ -106,8 +106,8 @@ { "source": "packages/npm/CLAUDE.md", "target": ".claude/design/effected/packages/npm.md", - "hash": "5dd33b8fa2fe9749eeec9937a2c4720a041756dc84ec18548b97a4c0653208c7", - "recordedAt": "2026-07-16" + "hash": "53949a4695a2d2287045c8120761bf46ccb882b07ca172f4a0ef3e7acb339dea", + "recordedAt": "2026-07-21" }, { "source": "packages/package-json/CLAUDE.md", @@ -166,8 +166,8 @@ { "source": "packages/tsconfig-json/CLAUDE.md", "target": ".claude/design/effected/packages/tsconfig-json.md", - "hash": "2899cb7f73d9e06dcea0fbbb5c175806500fa2663c6233064d768a89f739fe3e", - "recordedAt": "2026-07-16" + "hash": "05c7ec58a7c127ffb79f3675da7dc5267527f178d91cd2a80051f50f3aeff1e0", + "recordedAt": "2026-07-21" }, { "source": "packages/walker/CLAUDE.md", @@ -178,8 +178,8 @@ { "source": "packages/workspaces/CLAUDE.md", "target": ".claude/design/effected/packages/workspaces.md", - "hash": "bfb803fab2f14b0fbe70ea954ce25ec78d695623ccee449066a5f58b9a30474a", - "recordedAt": "2026-07-17" + "hash": "3a0aa7545a485edf575858437fb1aede8d367e13f0b08059db833f96a03f3141", + "recordedAt": "2026-07-21" }, { "source": "packages/xdg/CLAUDE.md", diff --git a/packages/npm/CLAUDE.md b/packages/npm/CLAUDE.md index 824ecd0b..4db84e1e 100644 --- a/packages/npm/CLAUDE.md +++ b/packages/npm/CLAUDE.md @@ -26,6 +26,7 @@ An **internal package with no source repo** — not migrated from a `*-effect` r - `DependencySpecifier` (`src/DependencySpecifier.ts`) — the specifier concept relocated from `@effected/package-json`: a branded string with eleven-protocol taxonomy statics (`protocolOf` and friends), the resolution statics `catalogNameOf`, `resolveWorkspace` (the pnpm publish-time projection; the alias form `workspace:@` — last-`@` split, scoped-aware — projects to `npm:@`) and `workspaceTargetOf` (the alias target name, `None` for the plain form), plus a `FromString` codec decoding to a coarse five-case tagged union (`CatalogSpecifier` | `WorkspaceSpecifier` | `RangeSpecifier` | `DistTagSpecifier` | `RawSpecifier`, matchable as `ClassifiedSpecifier`) that encodes back **byte-for-byte**. `WorkspaceSpecifier#resolve(version)` applies the same projection to an already-classified instance — one shared implementation. Range detection decodes `@effected/semver`'s `Range.FromString` purely — the only use of the workspace edge. Also `InvalidDependencySpecifierError`, `isValidDependencySpecifier`. - `DependencySection` (`src/DependencySection.ts`) — the kit-wide dependency-section vocabulary: `DependencyKind` (`prod`/`dev`/`peer`/`optional`) and `DependencyField` (the four manifest key names) as literal schemas, plus the bidirectional `fieldOf`/`kindOf` mapping. Replaces the private copies package-json, lockfiles and workspaces each carried. - `IntegrityHash` (`src/IntegrityHash.ts`) — a brand over the three textual integrity forms: SRI (`-`), corepack (`.`) and yarn (`10c0/`). `algorithmOf` is `None` for the yarn form, which names no algorithm. Also `InvalidIntegrityHashError`, `isValidIntegrityHash`. +- `ReleaseAgeGate` / `PartialReleaseAgeGate` (`src/ReleaseAgeGate.ts`) — the minimum-release-age gate vocabulary. `ReleaseAgeGate` is a `Schema.Class` (a `minReleaseAge` plus an `exclude` set); statics `combine` (variadic, **strictest-wins** — the single clamping authority) and `matchesExclude` (flat-`*` @pnpm/matcher parity, deliberately **not** `@effected/glob`'s dialect); instance `isExcluded` / `filterVersions` are pure and take the caller's clock, **dropping** versions with a missing or unparseable timestamp. `PartialReleaseAgeGate` is the permissive `Schema.Struct` inbound form (hook/manifest contributions); it is `combine`d into a clamped `ReleaseAgeGate` — never clamped in isolation. Consumers today are `@effected/package-json` (`Package.resolve`, and re-exporting `DependencySpecifier`), `@effected/lockfiles`, and `@effected/workspaces`. Arrows point *at* this package; the only outbound edge is the pure `@effected/semver` peer. @@ -45,15 +46,17 @@ Consumers today are `@effected/package-json` (`Package.resolve`, and re-exportin `@effected/lockfiles` was the second consumer that pulled `DependencySpecifier`, `DependencyField` and `IntegrityHash` here; `package-json` now re-exports `DependencySpecifier` rather than owning it. `PackageName` stays in `@effected/package-json` until a second consumer materializes. Do not pre-claim the pnpm `catalogs:` record shape — that routes to `@effected/lockfiles`. +The release-age gate vocabulary (`ReleaseAgeGate` / `PartialReleaseAgeGate`) landed here as the pure home for the contract `@effected/workspaces` surfaces (`WorkspaceCatalogsShape.releaseAgeGate`, `HookInjection.releaseAge`) but cannot own. + ## Testing and building -Tests live in `__test__/`, use `@effect/vitest`, and assert with `assert.*` — never `expect`. Provide layers via top-level `layer(...)` grouping, not per-test `Effect.provide`. Each contract has a **stub-implementation layer** test proving it is implementable — the pattern real consumers follow. Stubs build `Option` results with `Option.fromUndefinedOr`; `Option.fromNullable` is gone in v4. Currently 68 tests across 6 files. +Tests live in `__test__/`, use `@effect/vitest`, and assert with `assert.*` — never `expect`. Provide layers via top-level `layer(...)` grouping, not per-test `Effect.provide`. Each contract has a **stub-implementation layer** test proving it is implementable — the pattern real consumers follow. Stubs build `Option` results with `Option.fromUndefinedOr`; `Option.fromNullable` is gone in v4. Currently 99 tests across 7 files. ```bash -pnpm vitest run packages/npm # 68 tests +pnpm vitest run packages/npm # 99 tests pnpm build --filter @effected/npm # dev + prod ``` Never run `node savvy.build.ts --target prod` directly — it skips `build:dev`, emits no `.d.ts`, and leaves a truncated `issues.json` shaped exactly like a clean gate. -`savvy.build.ts` **does** suppress `ae-forgotten-export` for the `_base` pattern: every factory-backed class (`Context.Service`, `Schema.Class`, `Schema.TaggedErrorClass`) is written inline per house policy. A clean `dist/prod/issues.json` has empty `warnings`/`errors` and **fourteen** `suppressed` entries — the two resolver contracts, `DependencyResolutionError`, `CatalogAssemblyError`, the five `DependencySpecifier` union members, the two integrity/specifier validation errors, and the three `Manifest` classes (`Manifest`, `ManifestDecodeError`, `UnresolvedDependencyError`). `suppressed: 0` in the *prod* gate means the build did not run properly. `dist/dev/issues.json` legitimately has `suppressed: []`; the dev target does not run API Extractor. +`savvy.build.ts` **does** suppress `ae-forgotten-export` for the `_base` pattern: every factory-backed class (`Context.Service`, `Schema.Class`, `Schema.TaggedErrorClass`) is written inline per house policy. A clean `dist/prod/issues.json` has empty `warnings`/`errors` and **fifteen** `suppressed` entries — the two resolver contracts, `DependencyResolutionError`, `CatalogAssemblyError`, the five `DependencySpecifier` union members, the two integrity/specifier validation errors, the three `Manifest` classes (`Manifest`, `ManifestDecodeError`, `UnresolvedDependencyError`), and `ReleaseAgeGate`'s `_base`. `suppressed: 0` in the *prod* gate means the build did not run properly. `dist/dev/issues.json` legitimately has `suppressed: []`; the dev target does not run API Extractor. diff --git a/packages/npm/README.md b/packages/npm/README.md index 0c01e34b..eef9df27 100644 --- a/packages/npm/README.md +++ b/packages/npm/README.md @@ -135,6 +135,7 @@ Effect.runPromise(Effect.provide(program, Default)).then(console.log); - `DependencySpecifier` — the specifier taxonomy: an eleven-protocol classifier, a codec decoding any specifier into a matchable tagged union that encodes back byte-for-byte, and the resolution statics (`catalogNameOf`, `resolveWorkspace`, `workspaceTargetOf`) implementing pnpm's publish-time projection. - `DependencySection` — the kit-wide dependency vocabulary: `DependencyKind`, `DependencyField` and the mapping between them. - `IntegrityHash` — a brand over the three textual integrity forms (SRI, corepack, yarn), with `algorithmOf`. +- `ReleaseAgeGate` / `PartialReleaseAgeGate` — pnpm's `minimumReleaseAge` / `minimumReleaseAgeExclude` gate as pure vocabulary: `combine` merges contributions from multiple config sources strictest-age-wins, and `filterVersions` / `isExcluded` drop candidate versions younger than the cutoff against a caller-supplied clock, so a resolver never picks a version pnpm would reject. `matchesExclude` is pnpm's flat-`*` matcher, deliberately not `@effected/glob`'s dialect. `PartialReleaseAgeGate` is the permissive inbound form each source contributes. The surface grows when a consumer proves it needs more, not before. diff --git a/packages/npm/__test__/ReleaseAgeGate.test.ts b/packages/npm/__test__/ReleaseAgeGate.test.ts new file mode 100644 index 00000000..a1b58002 --- /dev/null +++ b/packages/npm/__test__/ReleaseAgeGate.test.ts @@ -0,0 +1,218 @@ +import { assert, describe, it } from "@effect/vitest"; +import { Effect, Schema } from "effect"; +import { PartialReleaseAgeGate, ReleaseAgeGate } from "../src/index.js"; + +describe("ReleaseAgeGate.combine", () => { + it("returns the inert zero gate when nothing is contributed", () => { + const gate = ReleaseAgeGate.combine(); + assert.strictEqual(gate.ageMinutes, 0); + assert.deepStrictEqual(gate.exclude, []); + }); + + it("returns the zero gate when both contributions are absent (empty)", () => { + const gate = ReleaseAgeGate.combine({}, {}); + assert.strictEqual(gate.ageMinutes, 0); + assert.deepStrictEqual(gate.exclude, []); + }); + + it("carries a one-sided age contribution through", () => { + const gate = ReleaseAgeGate.combine({ ageMinutes: 1440 }, {}); + assert.strictEqual(gate.ageMinutes, 1440); + assert.deepStrictEqual(gate.exclude, []); + }); + + it("carries a one-sided exclude contribution through", () => { + const gate = ReleaseAgeGate.combine({}, { exclude: ["@effect/*"] }); + assert.strictEqual(gate.ageMinutes, 0); + assert.deepStrictEqual(gate.exclude, ["@effect/*"]); + }); + + it("takes the strictest (maximum) age", () => { + const gate = ReleaseAgeGate.combine({ ageMinutes: 720 }, { ageMinutes: 1440 }); + assert.strictEqual(gate.ageMinutes, 1440); + }); + + it("clamps a negative contribution to zero", () => { + const gate = ReleaseAgeGate.combine({ ageMinutes: -5 }); + assert.strictEqual(gate.ageMinutes, 0); + }); + + it("ignores non-finite contributions but keeps a finite one", () => { + const gate = ReleaseAgeGate.combine({ ageMinutes: Number.NaN }, { ageMinutes: 100 }); + assert.strictEqual(gate.ageMinutes, 100); + }); + + it("falls back to the zero gate when every age is non-finite", () => { + const gate = ReleaseAgeGate.combine({ ageMinutes: Number.POSITIVE_INFINITY }, { ageMinutes: Number.NaN }); + assert.strictEqual(gate.ageMinutes, 0); + }); + + it("unions and deduplicates the exclude sets, preserving insertion order", () => { + const gate = ReleaseAgeGate.combine({ exclude: ["a", "b"] }, { exclude: ["b", "c"] }); + assert.deepStrictEqual(gate.exclude, ["a", "b", "c"]); + }); + + it("combines age and exclude from many sources", () => { + const gate = ReleaseAgeGate.combine( + { ageMinutes: 60, exclude: ["@my/pkg"] }, + { ageMinutes: 1440 }, + { exclude: ["@other/*", "@my/pkg"] }, + ); + assert.strictEqual(gate.ageMinutes, 1440); + assert.deepStrictEqual(gate.exclude, ["@my/pkg", "@other/*"]); + }); +}); + +describe("ReleaseAgeGate.matchesExclude", () => { + it("matches an exact package name", () => { + assert.isTrue(ReleaseAgeGate.matchesExclude("prettier", ["prettier"])); + assert.isFalse(ReleaseAgeGate.matchesExclude("prettier", ["eslint"])); + }); + + it("returns false against an empty pattern list", () => { + assert.isFalse(ReleaseAgeGate.matchesExclude("prettier", [])); + }); + + it("lets a bare `*` cross `/` and match a scoped name (pnpm parity)", () => { + assert.isTrue(ReleaseAgeGate.matchesExclude("@scope/pkg", ["*"])); + assert.isTrue(ReleaseAgeGate.matchesExclude("prettier", ["*"])); + }); + + it("matches every package in a scope with `@scope/*`", () => { + assert.isTrue(ReleaseAgeGate.matchesExclude("@effect/vitest", ["@effect/*"])); + assert.isTrue(ReleaseAgeGate.matchesExclude("@effect/platform", ["@effect/*"])); + assert.isFalse(ReleaseAgeGate.matchesExclude("@effected/npm", ["@effect/*"])); + }); + + it("treats a mid-name `*` as any run of characters including `/`", () => { + assert.isTrue(ReleaseAgeGate.matchesExclude("@effect/platform-node", ["@effect/platform*"])); + assert.isTrue(ReleaseAgeGate.matchesExclude("@scope/a/b", ["@scope/*"])); + }); + + it("does not treat other glob metacharacters as wildcards", () => { + // A `.` in the pattern is a literal dot, not a regex any-char. + assert.isFalse(ReleaseAgeGate.matchesExclude("aXb", ["a.b"])); + assert.isTrue(ReleaseAgeGate.matchesExclude("a.b", ["a.b"])); + }); + + it("matches if any pattern in the list matches", () => { + assert.isTrue(ReleaseAgeGate.matchesExclude("prettier", ["eslint", "prettier"])); + }); +}); + +describe("ReleaseAgeGate#isExcluded", () => { + it("checks the package name against the gate's own exclude list", () => { + const gate = ReleaseAgeGate.make({ ageMinutes: 1440, exclude: ["@my/*"] }); + assert.isTrue(gate.isExcluded("@my/pkg")); + assert.isFalse(gate.isExcluded("prettier")); + }); +}); + +describe("ReleaseAgeGate#filterVersions", () => { + // Fixed clock: 2026-07-21T00:00:00Z. + const now = Date.parse("2026-07-21T00:00:00Z"); + const day = 24 * 60; // minutes in a day + + it("drops versions younger than the cutoff", () => { + const gate = ReleaseAgeGate.make({ ageMinutes: day, exclude: [] }); + const versions = ["1.0.0", "1.0.1"]; + const times = { + "1.0.0": "2026-07-01T00:00:00Z", // old + "1.0.1": "2026-07-20T23:00:00Z", // 1h old, younger than 1 day + }; + assert.deepStrictEqual(gate.filterVersions(versions, times, "prettier", now), ["1.0.0"]); + }); + + it("keeps a version published exactly at the cutoff (boundary inclusive)", () => { + const gate = ReleaseAgeGate.make({ ageMinutes: day, exclude: [] }); + const times = { "1.0.0": "2026-07-20T00:00:00Z" }; // exactly 1 day old + assert.deepStrictEqual(gate.filterVersions(["1.0.0"], times, "prettier", now), ["1.0.0"]); + }); + + it("drops a version one millisecond younger than the cutoff", () => { + const gate = ReleaseAgeGate.make({ ageMinutes: day, exclude: [] }); + const times = { "1.0.0": "2026-07-20T00:00:00.001Z" }; // 1ms too young + assert.deepStrictEqual(gate.filterVersions(["1.0.0"], times, "prettier", now), []); + }); + + it("drops versions with a missing timestamp", () => { + const gate = ReleaseAgeGate.make({ ageMinutes: day, exclude: [] }); + const times = { "1.0.0": "2026-07-01T00:00:00Z" }; // no entry for 1.0.1 + assert.deepStrictEqual(gate.filterVersions(["1.0.0", "1.0.1"], times, "prettier", now), ["1.0.0"]); + }); + + it("drops versions with an unparseable timestamp", () => { + const gate = ReleaseAgeGate.make({ ageMinutes: day, exclude: [] }); + const times = { "1.0.0": "not-a-date", "1.0.1": "2026-07-01T00:00:00Z" }; + assert.deepStrictEqual(gate.filterVersions(["1.0.0", "1.0.1"], times, "prettier", now), ["1.0.1"]); + }); + + it("is a no-op (returns all versions) when the package is excluded", () => { + const gate = ReleaseAgeGate.make({ ageMinutes: day, exclude: ["@my/*"] }); + const versions = ["1.0.0", "1.0.1"]; + const times = { "1.0.0": "2026-07-20T23:00:00Z", "1.0.1": "2026-07-20T23:59:00Z" }; + assert.deepStrictEqual(gate.filterVersions(versions, times, "@my/pkg", now), versions); + }); + + it("is a no-op when the age is zero", () => { + const gate = ReleaseAgeGate.make({ ageMinutes: 0, exclude: [] }); + const versions = ["1.0.0", "1.0.1"]; + assert.deepStrictEqual(gate.filterVersions(versions, {}, "prettier", now), versions); + }); +}); + +describe("ReleaseAgeGate schema", () => { + it.effect("decodes and re-encodes a gate round-trip", () => + Effect.gen(function* () { + const input = { ageMinutes: 1440, exclude: ["@effect/*", "prettier"] }; + const gate = yield* Schema.decodeUnknownEffect(ReleaseAgeGate)(input); + assert.instanceOf(gate, ReleaseAgeGate); + assert.strictEqual(gate.ageMinutes, 1440); + assert.deepStrictEqual(gate.exclude, ["@effect/*", "prettier"]); + const encoded = yield* Schema.encodeUnknownEffect(ReleaseAgeGate)(gate); + assert.deepStrictEqual(encoded, input); + }), + ); + + it.effect("rejects a negative age", () => + Effect.gen(function* () { + const error = yield* Effect.flip(Schema.decodeUnknownEffect(ReleaseAgeGate)({ ageMinutes: -1, exclude: [] })); + assert.strictEqual(error._tag, "SchemaError"); + }), + ); + + it.effect("rejects a non-finite age", () => + Effect.gen(function* () { + const error = yield* Effect.flip( + Schema.decodeUnknownEffect(ReleaseAgeGate)({ ageMinutes: Number.POSITIVE_INFINITY, exclude: [] }), + ); + assert.strictEqual(error._tag, "SchemaError"); + }), + ); +}); + +describe("PartialReleaseAgeGate schema", () => { + it.effect("decodes an empty contribution", () => + Effect.gen(function* () { + const partial = yield* Schema.decodeUnknownEffect(PartialReleaseAgeGate)({}); + assert.deepStrictEqual(partial, {}); + }), + ); + + it.effect("decodes and re-encodes a full contribution round-trip", () => + Effect.gen(function* () { + const input = { ageMinutes: 720, exclude: ["a", "b"] }; + const partial = yield* Schema.decodeUnknownEffect(PartialReleaseAgeGate)(input); + assert.deepStrictEqual(partial, input); + const encoded = yield* Schema.encodeUnknownEffect(PartialReleaseAgeGate)(partial); + assert.deepStrictEqual(encoded, input); + }), + ); + + it.effect("tolerates a negative age (the clamp lives in combine, not the schema)", () => + Effect.gen(function* () { + const partial = yield* Schema.decodeUnknownEffect(PartialReleaseAgeGate)({ ageMinutes: -10 }); + assert.strictEqual(partial.ageMinutes, -10); + }), + ); +}); diff --git a/packages/npm/src/ReleaseAgeGate.ts b/packages/npm/src/ReleaseAgeGate.ts new file mode 100644 index 00000000..0b0ebc28 --- /dev/null +++ b/packages/npm/src/ReleaseAgeGate.ts @@ -0,0 +1,204 @@ +// The `ReleaseAgeGate` concept: pnpm's publish-time release-age gate as shared +// npm dependency vocabulary. A gate says how long (in minutes) a published +// version must age before it is eligible, and which package names are exempt. +// +// pnpm reads two config keys — `minimumReleaseAge` (minutes) and +// `minimumReleaseAgeExclude` (name patterns) — and refuses to install a version +// younger than the cutoff (`ERR_PNPM_NO_MATURE_MATCHING_VERSION`). A resolver +// that picks the highest in-range version with no publish-time awareness will +// pick a version pnpm then rejects; mirroring the gate at resolution time (drop +// candidates younger than the cutoff, unless excluded, before picking) fixes it. +// +// This module is the pure vocabulary: a `Schema.Class` gate, a partial-source +// input shape, `combine` for merging contributions from multiple sources, the +// name matcher, and a pure version filter. The clock is the caller's — every +// operation is pure. Reading the gate from `pnpm-workspace.yaml` keys or from +// replayed `updateConfig` hooks is a consumer concern (config IO), not this +// pure-tier module's. + +import { Schema } from "effect"; + +// pnpm's release-age is measured in minutes; the filter converts to ms. +const MS_PER_MINUTE = 60_000; + +/** + * A source's partial contribution to a {@link ReleaseAgeGate}: the effective + * gate is assembled from more than one place (inline `pnpm-workspace.yaml` + * keys, replayed `updateConfig` hooks, `pnpm config get` output), and each + * source may set the age, the exclude list, both, or neither. Absent fields + * contribute nothing to the combination. + * + * Deliberately permissive: unlike {@link ReleaseAgeGate} it does not constrain + * `ageMinutes` to be non-negative, because the raw values arrive from arbitrary + * config sources and {@link ReleaseAgeGate.combine} is the single authority + * that clamps them. + * + * @public + */ +export const PartialReleaseAgeGate = Schema.Struct({ + /** Minutes a release must age; absent means this source sets no age. */ + ageMinutes: Schema.optionalKey(Schema.Number), + /** Exempt package-name patterns; absent means this source adds no exemptions. */ + exclude: Schema.optionalKey(Schema.Array(Schema.String)), +}); + +/** + * One source's partial contribution to a release-age gate. All fields optional. + * + * @public + */ +export type PartialReleaseAgeGate = typeof PartialReleaseAgeGate.Type; + +// A non-negative, finite minute count. `isGreaterThanOrEqualTo(0)` already +// rejects `NaN` (NaN >= 0 is false); `isFinite` additionally rejects Infinity. +// Integrality is deliberately NOT enforced — pnpm's config is a non-negative +// integer of minutes, but `combine` takes `Math.max` of arbitrary finite +// contributions, and requiring an integer here would make a fractional +// contribution throw at construction, breaking `combine`'s totality. +const AgeMinutes = Schema.Number.check(Schema.isGreaterThanOrEqualTo(0), Schema.isFinite()); + +// Match a package name against a single pattern with pnpm `@pnpm/matcher` +// semantics: an exact-name match, or a `*`-glob where `*` matches ANY run of +// characters INCLUDING `/`. All other regex metacharacters are escaped, so +// `*` is the only wildcard. See the divergence note on `matchesExclude`. +const matchesPattern = (name: string, pattern: string): boolean => { + if (pattern === name) return true; + if (!pattern.includes("*")) return false; + // Escape every regex metacharacter EXCEPT `*`, then turn `*` into `.*` + // (crosses `/`, unlike minimatch). The class omits `*` so it survives to + // the second replace. + const source = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*"); + return new RegExp(`^${source}$`).test(name); +}; + +const matchesExclude = (name: string, patterns: readonly string[]): boolean => + patterns.some((pattern) => matchesPattern(name, pattern)); + +/** + * pnpm's publish-time release-age gate: the number of minutes a published + * version must age before it is eligible, and the set of package-name patterns + * exempt from the gate. Mirrors pnpm's `minimumReleaseAge` / + * `minimumReleaseAgeExclude` config so a resolver can drop too-young candidate + * versions before picking, avoiding `ERR_PNPM_NO_MATURE_MATCHING_VERSION`. + * + * `ageMinutes` is constrained non-negative and finite; a `ReleaseAgeGate` with + * `ageMinutes <= 0` is an inert gate that filters nothing. Assemble a gate from + * multiple config sources with {@link ReleaseAgeGate.combine}, and apply it to + * a package's candidate versions with {@link ReleaseAgeGate.filterVersions}. + * + * @example + * ```ts + * import { ReleaseAgeGate } from "@effected/npm"; + * + * const gate = ReleaseAgeGate.combine( + * { ageMinutes: 1440 }, + * { exclude: ["@my-scope/*"] }, + * ); + * // gate.ageMinutes === 1440, gate.exclude === ["@my-scope/*"] + * + * const eligible = gate.filterVersions( + * ["1.0.0", "1.0.1"], + * { "1.0.0": "2020-01-01T00:00:00Z", "1.0.1": "2026-07-21T00:00:00Z" }, + * "prettier", + * Date.now(), + * ); + * ``` + * + * @public + */ +export class ReleaseAgeGate extends Schema.Class("ReleaseAgeGate")({ + /** Minutes a published version must age before it is eligible (non-negative, finite). */ + ageMinutes: AgeMinutes, + /** Package-name patterns exempt from the gate (exact names or `*`-globs). */ + exclude: Schema.Array(Schema.String), +}) { + /** + * Combine partial contributions from multiple sources into one effective + * gate: **strictest age wins** (the maximum of the contributed ages, + * clamped to be non-negative) and the exclude sets **union** (deduplicated, + * insertion order preserved). A contribution's absent field adds nothing; a + * negative or non-finite contributed age is ignored by the clamp. With no + * contributions (or only empty ones) the result is the inert zero gate + * (`ageMinutes: 0`, `exclude: []`). + * + * `combine` is total — it never throws on a fractional, negative, or + * non-finite contribution — which is why {@link (PartialReleaseAgeGate:variable)} + * does not constrain its `ageMinutes` and this method owns the clamp. + * + * @param contributions - the partial gates to merge, one per source. + */ + static combine(...contributions: readonly PartialReleaseAgeGate[]): ReleaseAgeGate { + const ages = contributions + .map((contribution) => contribution.ageMinutes) + .filter((age): age is number => typeof age === "number" && Number.isFinite(age)); + const ageMinutes = ages.length > 0 ? Math.max(0, ...ages) : 0; + const exclude = [...new Set(contributions.flatMap((contribution) => contribution.exclude ?? []))]; + return ReleaseAgeGate.make({ ageMinutes, exclude }); + } + + /** + * Whether a package name matches any of `patterns`, using pnpm's + * `@pnpm/matcher` semantics: an exact-name match, or a `*`-glob where `*` + * matches any run of characters **including `/`** — so a bare `*` matches a + * scoped name like `@scope/pkg`, and `@scope/*` matches every package in a + * scope. + * + * @remarks + * This is deliberately **NOT** `@effected/glob`'s minimatch dialect, in + * which `*` refuses to cross `/` (there `*` matches `pkg` but not + * `@scope/pkg`, and you would need `**`). pnpm treats the package name as a + * flat string, so this matcher does too. Do not "fix" this to route through + * `@effected/glob`: it would silently change which packages a gate exempts + * and diverge from pnpm's own behavior. + * + * @param name - the package name to test. + * @param patterns - the exclude patterns (exact names or `*`-globs). + */ + static matchesExclude(name: string, patterns: readonly string[]): boolean { + return matchesExclude(name, patterns); + } + + /** + * Whether this gate exempts the given package name from the release-age + * check — `ReleaseAgeGate.matchesExclude(name, this.exclude)`. + * + * @param name - the package name to test. + */ + isExcluded(name: string): boolean { + return matchesExclude(name, this.exclude); + } + + /** + * Filter a package's candidate versions to those old enough to pass the + * gate, given each version's publish timestamp and a caller-supplied `now`. + * + * A version is kept when it has a parseable publish timestamp at or before + * the cutoff (`now - ageMinutes * 60000`). A version with a **missing or + * unparseable** timestamp in `times` is **dropped** — matching pnpm's strict + * posture: a version whose age cannot be established is treated as too young. + * The clock is the caller's; this method reads no wall clock. + * + * Returns all versions unchanged (a no-op) when the gate is inert + * (`ageMinutes <= 0`) or the package name is excluded. + * + * @param versions - the candidate version strings. + * @param times - a map from version string to its ISO-8601 publish date. + * @param name - the package name (checked against the gate's `exclude` list). + * @param now - the current time in epoch milliseconds (the caller's clock). + */ + filterVersions( + versions: readonly string[], + times: Readonly>, + name: string, + now: number, + ): readonly string[] { + if (this.ageMinutes <= 0 || this.isExcluded(name)) return versions; + const cutoff = now - this.ageMinutes * MS_PER_MINUTE; + return versions.filter((version) => { + const time = times[version]; + if (time === undefined) return false; + const published = Date.parse(time); + return Number.isFinite(published) && published <= cutoff; + }); + } +} diff --git a/packages/npm/src/index.ts b/packages/npm/src/index.ts index 92cdd57f..25d8a5a4 100644 --- a/packages/npm/src/index.ts +++ b/packages/npm/src/index.ts @@ -49,6 +49,7 @@ export { ManifestDecodeError, UnresolvedDependencyError, } from "./Manifest.js"; +export { PartialReleaseAgeGate, ReleaseAgeGate } from "./ReleaseAgeGate.js"; export { DependencyResolutionError, WorkspaceResolver } from "./WorkspaceResolver.js"; /** diff --git a/packages/tsconfig-json/CLAUDE.md b/packages/tsconfig-json/CLAUDE.md index 6b9bfb18..fba4136c 100644 --- a/packages/tsconfig-json/CLAUDE.md +++ b/packages/tsconfig-json/CLAUDE.md @@ -15,7 +15,7 @@ tsconfig.json schemas, `extends`-chain resolution and config discovery. The one - `CompilerOptions` — string-level literal-union schemas: case-insensitive decode, canonical-lowercase encode; typed live option set plus passthrough so unknown **and** dead options survive. - `TsconfigJson` — the document schema, the `TsconfigJsonFromString` JSONC codec (bound once at module top level) and `TsconfigParseError`. **Every parse is JSONC** via `@effected/jsonc`; there is no JSON-strict path. - `ResolvedTsconfig` — the pure merge engine: E4 per-field semantics, path-option absolutization, final-phase `${configDir}` substitution, `pathsBase` provenance. No `FileSystem`, no `Path` service. -- `TsEnumCodec` — string↔numeric data maps (including the `node18`=101 / `node20`=102 gaps) and the `lib` normalizer. `encodeCompilerOptions` feeds the external `type-registry-effect` package's `TsEnvironment` (the former `@effected/ts-vfs`, now outside this kit) and emits `lib` in the **file-name form** (`lib.esnext.d.ts`) — verified against typescript 6.0.3's `pathForLibFile`, which joins the entry verbatim onto the lib directory. +- `TsEnumCodec` — string↔numeric data maps (including the `node18`=101 / `node20`=102 gaps) and the `lib` normalizer. `encodeCompilerOptions` returns the exported `ProgrammaticCompilerOptions` (values `ProgrammaticCompilerOptionsValue`) — a structural transcription of typescript 6.0.3's `CompilerOptionsValue` that keeps the zero-`typescript`-imports rule (one documented internal assertion), feeds the external `type-registry-effect` package's `TsEnvironment` (the former `@effected/ts-vfs`, now outside this kit), and emits `lib` in the **file-name form** (`lib.esnext.d.ts`) — verified against typescript 6.0.3's `pathForLibFile`, which joins the entry verbatim onto the lib directory. - `PortableTsconfig` — an **allow-list** filter (never a deny-list), forced `composite: false` / `noEmit: true`, and a `$schema` stamp. - `JsxConfig` — a pure projection from decoded compiler options to the JSX transform a bundler can configure: `react-jsx` / `react-jsxdev` → the `automatic` runtime (`importSource` defaulting to `"react"`, tsc's own default), `react` → `classic`; `preserve`, `react-native` and an absent `jsx` → `Option.none()`. The classic factory options stay on `CompilerOptions`. - `TsconfigLoader` — `load` / `resolve` / `compilerOptions` (a thin projection of `resolve` down to the merged options), each an `Effect.fn` with a named span (`TsconfigLoader.load` etc.): depth-first `extends` with **per-branch** cycle stacks (diamonds are legal), `MAX_EXTENDS_DEPTH = 32`, and `TsconfigExtendsError` with reasons `not-found` / `cycle` / `depth` / `empty`. @@ -47,7 +47,7 @@ Target probing uses `fs.exists`, which is true for a directory, where tsc's `hos ## Testing and building -Tests live in `__test__/` (155 passing), use `@effect/vitest`, and assert with `assert.*` — **never** `expect`. +Tests live in `__test__/` (158 passing), use `@effect/vitest`, and assert with `assert.*` — **never** `expect`. ```bash pnpm vitest run packages/tsconfig-json diff --git a/packages/tsconfig-json/README.md b/packages/tsconfig-json/README.md index 9e9fa533..6b479daf 100644 --- a/packages/tsconfig-json/README.md +++ b/packages/tsconfig-json/README.md @@ -112,7 +112,7 @@ const compilerOptions = TsconfigLoaderSync.compilerOptions("./tsconfig.json", op - `TsconfigLoaderSync` — the synchronous facade for sync-only hosts: `load`, `resolve` and `compilerOptions` over consumer-supplied `{ fileSystem, path }` operations, running the same pipeline and throwing the same typed errors. - `ResolvedTsconfig` — the pure merge engine behind `resolve`: per-field merge semantics, path-option absolutization against the declaring config's directory, final `${configDir}` substitution and `pathsBase` provenance, with no filesystem access at all. - `TsconfigDiscovery.findNearest` — the nearest `tsconfig.json` (or any filename via `options.filename`) at or above a starting directory, over `@effected/walker`; one unreadable ancestor cannot hide a config above it. -- `TsEnumCodec` — the string↔numeric enum tables as plain data with zero `typescript` imports. `encodeCompilerOptions` produces the numeric shape `ts.CompilerOptions` expects, with `lib` entries in the file-name form the compiler resolves verbatim; `decodeCompilerOptions` reverses it. +- `TsEnumCodec` — the string↔numeric enum tables as plain data with zero `typescript` imports. `encodeCompilerOptions` returns the exported `ProgrammaticCompilerOptions` type — the numeric shape `ts.CompilerOptions` expects, so you hand it to a `ts.CompilerOptions`-shaped API without a cast — with `lib` entries in the file-name form the compiler resolves verbatim; `decodeCompilerOptions` reverses it. - `PortableTsconfig.make` — an allow-list projection down to machine-independent type-semantics options, with `composite: false` and `noEmit: true` forced: the slice a virtual TypeScript environment (Twoslash, API Extractor, an in-memory language service) can safely inherit. - `JsxConfig.fromCompilerOptions` — the JSX transform a bundler can configure, projected from decoded options: `react-jsx` / `react-jsxdev` select the automatic runtime with its import source (defaulting to `react`, tsc's own default), `react` selects classic, and `preserve`, `react-native` or an absent `jsx` yield `Option.none()`. - Typed failures everywhere: a malformed file is a `TsconfigParseError` carrying its path, a broken chain is a `TsconfigExtendsError` with a `not-found` / `cycle` / `depth` / `empty` reason and the full resolution chain, and IO errors flow through as `PlatformError`. Nothing fails as a defect. diff --git a/packages/tsconfig-json/__test__/TsEnumCodec.assignability.test.ts b/packages/tsconfig-json/__test__/TsEnumCodec.assignability.test.ts new file mode 100644 index 00000000..a08ff3e7 --- /dev/null +++ b/packages/tsconfig-json/__test__/TsEnumCodec.assignability.test.ts @@ -0,0 +1,80 @@ +import { assert, describe, it } from "@effect/vitest"; +import type { ProgrammaticCompilerOptions } from "../src/TsEnumCodec.js"; +import { TsEnumCodec } from "../src/TsEnumCodec.js"; + +// Compile-time proof that `encodeCompilerOptions`'s return +// (`ProgrammaticCompilerOptions`) is assignable to TypeScript's own +// `ts.CompilerOptions` — the shape `@typescript/vfs`'s +// `createVirtualTypeScriptEnvironment` / `createDefaultMapFromNodeModules` and +// `ts.createProgram` consume — WITHOUT importing `typescript` (the package's +// zero-`typescript` HARD RULE, tests included). Per the tsc-parity discipline +// (facts transcribed from TypeScript source with a version citation), the +// assignability target is transcribed here as a structural replica. +// +// Transcribed from `typescript@6.0.3`'s +// `node_modules/typescript/lib/typescript.d.ts` (the version `@typescript/vfs@1.6.4`, +// the encode target's consumer, pins). Assignability to the REAL `ts.CompilerOptions` +// of that version was additionally settled at rung 3 by a throwaway probe +// (`tsc --noEmit --strict` against the real `.d.ts`, 2026-07-21): a plain +// `number` reaches the enum-typed named keys through the index signature, so +// the enum keys typed `number` and the passthrough index signature are both +// accepted, while an `unknown`-valued index signature is correctly rejected. +// +// The replica is deliberately at least as strict as the real interface: its +// index-signature value union is the exact transcription of +// `CompilerOptionsValue` (minus the compiler-internal `TsConfigSourceFile` +// node, which no JSON-derived value can be), and the numeric enums are +// transcribed as their structural `number` (the replica cannot express nominal +// enums without importing them — the probe above covered that gap). + +/** Transcription of `CompilerOptionsValue` (typescript@6.0.3). */ +type CompilerOptionsValueReplica = + | string + | number + | boolean + | (string | number)[] + | string[] + | { [index: string]: string[] } // MapLike + | { name: string }[] // PluginImport[] + | { path: string; originalPath?: string; prepend?: boolean; circular?: boolean }[] // ProjectReference[] + | null + | undefined; + +/** Structural replica of `ts.CompilerOptions` (typescript@6.0.3), enums as `number`. */ +interface CompilerOptionsReplica { + [option: string]: CompilerOptionsValueReplica; + target?: number; // ScriptTarget + module?: number; // ModuleKind + moduleResolution?: number; // ModuleResolutionKind + jsx?: number; // JsxEmit + newLine?: number; // NewLineKind + moduleDetection?: number; // ModuleDetectionKind + lib?: string[]; +} + +describe("TsEnumCodec — ProgrammaticCompilerOptions assignability", () => { + it("ProgrammaticCompilerOptions is assignable to ts.CompilerOptions (no cast)", () => { + const programmatic: ProgrammaticCompilerOptions = { target: 10, strict: true, lib: ["lib.esnext.d.ts"] }; + // The proof: assigns to the tsc replica with no cast. A regression that + // widened the return (e.g. back to `Record`) fails here. + const compilerOptions: CompilerOptionsReplica = programmatic; + assert.strictEqual(compilerOptions.target, 10); + }); + + it("encodeCompilerOptions(...) result assigns to ts.CompilerOptions with no cast", () => { + // The ergonomic win the issue asked for: the consumer's trailing cast is + // gone — the boundary hands back an honest, tsc-assignable type. + const compilerOptions: CompilerOptionsReplica = TsEnumCodec.encodeCompilerOptions({ + target: "es2023", + strict: true, + lib: ["esnext"], + }); + assert.deepStrictEqual(compilerOptions, { target: 10, strict: true, lib: ["lib.esnext.d.ts"] }); + }); + + it("structurally guarantees enum keys read back as number", () => { + const programmatic = TsEnumCodec.encodeCompilerOptions({ module: "nodenext" }); + const module: number | undefined = programmatic.module; + assert.strictEqual(module, 199); + }); +}); diff --git a/packages/tsconfig-json/src/TsEnumCodec.ts b/packages/tsconfig-json/src/TsEnumCodec.ts index 99fb71a4..d741b7ac 100644 --- a/packages/tsconfig-json/src/TsEnumCodec.ts +++ b/packages/tsconfig-json/src/TsEnumCodec.ts @@ -237,17 +237,110 @@ const COMPILER_OPTION_ENUM_KEYS: ReadonlyArray | PluginImport[] | ProjectReference[] + * | null | undefined; + * interface MapLike { [index: string]: T } + * interface PluginImport { name: string } + * interface ProjectReference { path: string; originalPath?: string; prepend?: boolean; circular?: boolean } + * ``` + * + * The one member deliberately omitted is `TsConfigSourceFile` (present only in + * the interface's index signature, not `CompilerOptionsValue` itself): it is a + * full parsed-AST node the compiler synthesizes, never a value reachable from + * JSON, so it cannot appear in options this codec builds. Omitting it keeps the + * union a strict structural subset of the compiler's own index-signature value + * type, which is what assignability to `ts.CompilerOptions` requires. Arrays + * are intentionally mutable (`string[]`, not `readonly string[]`): TypeScript's + * mutable array members are not assignable from a `readonly` array, and the one + * narrowing below reconciles this with the codec's actual `readonly`-array + * outputs. + * + * @public + */ +export type ProgrammaticCompilerOptionsValue = + | string + | number + | boolean + | (string | number)[] + | string[] + | { readonly [index: string]: string[] } + | { readonly name: string }[] + | { readonly path: string; readonly originalPath?: string; readonly prepend?: boolean; readonly circular?: boolean }[] + | null + | undefined; + +/** + * The shape {@link (TsEnumCodec:variable).encodeCompilerOptions} returns: the + * numeric-enum-encoded `compilerOptions` a virtual-TS environment and the + * TypeScript compiler API consume programmatically. + * + * It is deliberately shaped to be assignable to TypeScript's `ts.CompilerOptions` + * without naming it (the zero-`typescript` rule), so a consumer handing the + * result to `@typescript/vfs`'s `createVirtualTypeScriptEnvironment` / + * `createDefaultMapFromNodeModules` or to `ts.createProgram` no longer ends the + * pipeline with a cast. Verified assignable to the real `ts.CompilerOptions` + * (`typescript@6.0.3`, `@typescript/vfs@1.6.4`) by a compile-time test + * (`__test__/TsEnumCodec.assignability.test.ts`). + * + * What it honestly **claims**: the six enum-family keys read back as `number` + * (they are always encoded — a decoded `CompilerOptions.Type` constrains each + * to a spelling the codec's tables cover, so the runtime "unknown string passes + * through unencoded" branch is unreachable for a well-typed input); `lib` reads + * back as `string[]` (the file-name form); every other value is one of the + * structural forms `ts.CompilerOptions` accepts. + * + * What it does **not** prove: that an arbitrary passthrough value carried + * through from JSONC (the schema preserves unknown keys as `unknown`) fits + * {@link ProgrammaticCompilerOptionsValue}. Neither does `ts.CompilerOptions`: + * its own index signature makes the identical unproven claim, and any consumer + * feeding parsed tsconfig to `ts.createProgram` relies on it. The single + * narrowing that bridges the codec's `unknown`/`readonly` outputs to this type + * lives once, at `encodeCompilerOptions`'s return (below), so the assertion is + * owned here rather than re-made at every call site. + * + * @public + */ +export interface ProgrammaticCompilerOptions { + readonly [option: string]: ProgrammaticCompilerOptionsValue; + readonly target?: number; + readonly module?: number; + readonly moduleResolution?: number; + readonly jsx?: number; + readonly newLine?: number; + readonly moduleDetection?: number; + readonly lib?: string[]; +} + /** * Encodes a decoded `compilerOptions` object into the numeric-enum-shaped - * form `ts.CompilerOptions` (and `@typescript/vfs`'s `TsEnvironment`) expect: - * every R1.6 enum family becomes its numeric value, and `lib` entries become - * the file-name form (`lib.esnext.d.ts`) — see the module banner for the - * evidence. Every other key (booleans, strings, arrays, unknown passthrough - * keys) is copied through untouched. + * {@link ProgrammaticCompilerOptions} form `ts.CompilerOptions` (and + * `@typescript/vfs`'s `TsEnvironment`) expect: every R1.6 enum family becomes + * its numeric value, and `lib` entries become the file-name form + * (`lib.esnext.d.ts`) — see the module banner for the evidence. Every other key + * (booleans, strings, arrays, unknown passthrough keys) is copied through + * untouched. + * + * The return carries this package's single narrowing from the codec's internal + * `Record` (whose values include the schema's `unknown` + * passthrough and its `readonly` arrays) to {@link ProgrammaticCompilerOptions} + * — see that type's docs for why the package owns this one assertion instead of + * leaving every consumer to cast. Runtime behavior is unchanged; only the + * declared return type narrows. * * @public */ -const encodeCompilerOptions = (options: CompilerOptions.Type): Record => { +const encodeCompilerOptions = (options: CompilerOptions.Type): ProgrammaticCompilerOptions => { const source: Readonly> = options; const result: Record = { ...source }; @@ -264,7 +357,10 @@ const encodeCompilerOptions = (options: CompilerOptions.Type): Record (typeof entry === "string" ? `lib.${normalizeLibReference(entry)}.d.ts` : entry)); } - return result; + // The single documented narrowing (see ProgrammaticCompilerOptions): the + // result's values are `unknown`/`readonly` here, but structurally satisfy + // the tsc-assignable value union — asserted once, so consumers do not cast. + return result as ProgrammaticCompilerOptions; }; /** diff --git a/packages/tsconfig-json/src/index.ts b/packages/tsconfig-json/src/index.ts index 76050b43..6edf9461 100644 --- a/packages/tsconfig-json/src/index.ts +++ b/packages/tsconfig-json/src/index.ts @@ -34,5 +34,9 @@ export { export { TsconfigExtendsError, TsconfigLoader } from "./TsconfigLoader.js"; export type { SyncFileSystem, SyncPath, TsconfigLoaderSyncOptions } from "./TsconfigLoaderSync.js"; export { TsconfigLoaderSync } from "./TsconfigLoaderSync.js"; -export type { EnumFamily } from "./TsEnumCodec.js"; +export type { + EnumFamily, + ProgrammaticCompilerOptions, + ProgrammaticCompilerOptionsValue, +} from "./TsEnumCodec.js"; export { TsEnumCodec } from "./TsEnumCodec.js"; diff --git a/packages/workspaces/CLAUDE.md b/packages/workspaces/CLAUDE.md index 41301f58..9922c329 100644 --- a/packages/workspaces/CLAUDE.md +++ b/packages/workspaces/CLAUDE.md @@ -24,8 +24,8 @@ Other runtime deps are `workspace:~` edges: `@effected/git`, `@effected/glob`, ` - `ChangeDetector.ts` — `ChangeDetector`, `ChangeDetectionOptions`, `ChangeDetectionError`, `ChangeDetectionFailure` - `WorkspaceSnapshots.ts` — `WorkspaceSnapshots` (`at(ref)` / `worktree()`), plus the `WorkspaceSnapshotAtFailure` / `WorkspaceSnapshotWorktreeFailure` unions - `WorkspaceStateSnapshot.ts` — `WorkspaceStateSnapshot`, `PackageStateSnapshot` -- `WorkspaceCatalogs.ts` — `CatalogSet`, `WorkspaceCatalogs`, the `catalogResolver` layer, the `CatalogAssemblyFailure` type union -- `ConfigDependencyHooks.ts` — `ConfigDependencyHooks` contract, `layerNoop` / `layerLive` +- `WorkspaceCatalogs.ts` — `CatalogSet`, `WorkspaceCatalogs` (with `releaseAgeGate()`), the `catalogResolver` layer, the `CatalogAssemblyFailure` type union +- `ConfigDependencyHooks.ts` — `ConfigDependencyHooks` contract, `HookInjection`, `layerNoop` / `layerLive` - `LockfileReader.ts` — `LockfileReader`, `LockfileReadError` - `Publishability.ts` — `PublishabilityDetector`, `PublishTarget` - `Workspaces.ts` — the composite layers (`layer`, `layerWithConfigDependencies`, `layerWithGit`, `resolvers`) plus the one-call manifest path (`resolverLayer`, `resolveManifest`) @@ -107,7 +107,7 @@ The deliberate exception is `Workspaces.resolverLayer(options?)`: a **fresh, unm The one exception is `__test__/integration/self.int.test.ts`, which discovers **this repository** through `@effect/platform-node` (a devDependency). It is the only test that proves the whole stack composes against a real pnpm workspace — and it is what caught the multi-document lockfile bug. -262 tests across `__test__/`. `savvy.build.ts` carries the narrow `_base` suppression (`{ messageId: "ae-forgotten-export", pattern: "_base" }`) for the 28 synthesized error/schema-class bases in the prod `issues.json` (`CatalogAssemblyError`'s base moved out with it); never widen it. Never run `node savvy.build.ts --target prod` directly — build through `pnpm build --filter @effected/workspaces`. +273 tests across `__test__/`. `savvy.build.ts` carries the narrow `_base` suppression (`{ messageId: "ae-forgotten-export", pattern: "_base" }`) for the 28 synthesized error/schema-class bases in the prod `issues.json` (`CatalogAssemblyError`'s base moved out with it); never widen it. Never run `node savvy.build.ts --target prod` directly — build through `pnpm build --filter @effected/workspaces`. ## Point-in-time surface (as built) @@ -123,8 +123,12 @@ The v3 deferrals are now shipped (piece 3, 2026-07-15); `silk-update-action` and `WorkspaceCatalogs` picks its inline reader by **file presence**: `pnpm-workspace.yaml` selects the pnpm blocks; its absence selects bun's root `package.json` `workspaces.catalog` / `workspaces.catalogs`. The lockfile source is PM-aware too — `CatalogSet.fromLockfile` reads whichever extension (pnpm or bun) the parsed lockfile carries. New `CatalogSet` statics: `fromLockfile`, `fromBunBlocks`, `fromManifestWorkspaces`. Both **live** inline readers **hard-fail** on a malformed catalog block or a default catalog declared twice (top-level `catalog` *and* `catalogs.default` for pnpm; `workspaces.catalog` *and* `workspaces.catalogs.default` for bun) — a silently-empty catalog is the "every dependency looks newly added" bug. The two paths share one validator (`validatedCatalogBlocks`), so they fail typed on exactly the same conditions rather than one hard-failing and the other normalizing to `{}`. The at-ref readers (`CatalogSet.fromWorkspaceYaml`, `bunInlineCatalogs`) are deliberately **tolerant** of the same shapes; that asymmetry is intentional. The presence probe itself distinguishes genuine absence from a probe FAILURE: a non-NotFound `PlatformError` from `fs.exists` (a permission/IO error) fails typed rather than collapsing to "absent" and selecting the wrong reader. +`WorkspaceCatalogs.releaseAgeGate()` returns an `Effect`: inline `pnpm-workspace.yaml` release-age keys and hook contributions are combined **strictest-wins** in the **same single memoized assemble pass** as the catalogs. Malformed inline values **hard-fail** typed as `CatalogAssemblyError(source: "manifest")` — the same posture as inline catalog blocks. There is deliberately **no** top-level `Workspaces` convenience. + ### `ConfigDependencyHooks` — opt-in pnpmfile replay -A pnpm config dependency's pnpmfile `updateConfig` hook can mutate catalogs; replaying it executes config-dependency code, so it is gated. `layerLive` dynamically `import()`s each config dependency's pnpmfile **in process** (no subprocess) and replays it; `layerNoop` returns the inline seed untouched. The **default** `WorkspaceCatalogs.layer` / `Workspaces.layer` wire `layerNoop` — the default path provably runs no config-dependency code; opting in is explicit via `WorkspaceCatalogs.layerWithConfigDependencies` / `Workspaces.layerWithConfigDependencies`. A `..` segment in a config-dependency name, or a hook that fails to load or replay, fails typed as a `hooks`-source `CatalogAssemblyError` — never a silent skip. +A pnpm config dependency's pnpmfile `updateConfig` hook can mutate catalogs; replaying it executes config-dependency code, so it is gated. `layerLive` dynamically `import()`s each config dependency's pnpmfile **in process** (no subprocess) and replays it; `layerNoop` returns `{ catalogs: seed, releaseAge: {} }` untouched. + +`ConfigDependencyHooks.inject` returns a `HookInjection { catalogs, releaseAge: PartialReleaseAgeGate }` — **one replay, both outputs**. Release-age keys thread **last-hook-wins**; malformed hook values are tolerantly dropped (`CatalogAssemblyError` stays mechanism-only). `HookInjection` is exported from `index.ts`. The **default** `WorkspaceCatalogs.layer` / `Workspaces.layer` wire `layerNoop` — the default path provably runs no config-dependency code; opting in is explicit via `WorkspaceCatalogs.layerWithConfigDependencies` / `Workspaces.layerWithConfigDependencies`. A `..` segment in a config-dependency name, or a hook that fails to load or replay, fails typed as a `hooks`-source `CatalogAssemblyError` — never a silent skip. The pnpmfile is loaded by trying `pnpmfile.mjs` **first** (pnpm 11 ships the config-dependency pnpmfile as an ES module — a pnpm-11-native config dep may carry only `.mjs`) and falling back to `pnpmfile.cjs` (legacy); `import()` loads both. The "no pnpmfile" skip is keyed on the dynamic `import()` raising `ERR_MODULE_NOT_FOUND` **for the candidate file itself** — discriminated by comparing Node's `err.url` (the offending module's URL) against the candidate URL, so an `ERR_MODULE_NOT_FOUND` for a module the pnpmfile *imports* surfaces typed rather than being mistaken for an absent file. There is **no `existsSync` precheck** (it returns false for an existing-but-inaccessible file and would silently skip a real hook); any other load failure surfaces typed. `internal/catalogs.ts` stays the only `@pnpm/catalogs.*` importer. diff --git a/packages/workspaces/README.md b/packages/workspaces/README.md index 8ff6101e..fca1848c 100644 --- a/packages/workspaces/README.md +++ b/packages/workspaces/README.md @@ -215,7 +215,7 @@ A name miss in the derived `getPackage` fails with the service's own typed `Pack - `WorkspacePackage` — a deliberately tolerant manifest model, so one member with an odd version cannot fail discovery for the whole repo. `manifestRecord` keeps the as-read `package.json` for tolerant access to fields outside the typed slice without a second read; `WorkspacePackage.manifest(pkg)` re-reads and is the opt-in bridge to `@effected/package-json`'s strict `Package`. - `DependencyGraph` — a value class over discovered packages: `levels()` for parallel build tiers, the flattened topological order, and `CyclicDependencyError` when there isn't one. - `PackageManagerDetector` — npm, pnpm, yarn or bun from lockfiles and the `packageManager` field. -- `WorkspaceCatalogs` — pnpm catalog assembly and `catalog:` resolution, on pnpm's own catalog packages. +- `WorkspaceCatalogs` — pnpm catalog assembly and `catalog:` resolution, on pnpm's own catalog packages; `releaseAgeGate()` assembles the effective `@effected/npm` `ReleaseAgeGate` from inline `pnpm-workspace.yaml` release-age keys and replayed hook contributions, strictest-wins, in the same pass as the catalogs. - `LockfileReader` — locate and parse the workspace's lockfile through `@effected/lockfiles`. - `ChangeDetector` — git-range change detection over `@effected/git`'s `Git` service; swap the layer to mock it with no repository. - `PublishabilityDetector` — whether a package publishes and to where, as a `PublishTarget` (registry, directory, access, provenance). The default layer implements npm's semantics; swap the layer if yours differ. diff --git a/packages/workspaces/__test__/WorkspaceCatalogs.test.ts b/packages/workspaces/__test__/WorkspaceCatalogs.test.ts index 47997374..450f340a 100644 --- a/packages/workspaces/__test__/WorkspaceCatalogs.test.ts +++ b/packages/workspaces/__test__/WorkspaceCatalogs.test.ts @@ -438,3 +438,73 @@ describe("WorkspaceCatalogs — the pnpm inline path hard-fails, like the bun pa ); }); }); + +// ── the effective release-age gate, inline source (no-op hooks) ───────────── + +const inlineGateTree: Tree = { + "/repo/pnpm-workspace.yaml": [ + "packages:", + " - 'packages/*'", + "minimumReleaseAge: 1440", + "minimumReleaseAgeExclude:", + ' - "@x/*"', + " - typescript", + "catalog:", + " effect: ^4.0.0", + "", + ].join("\n"), + "/repo/package.json": JSON.stringify({ name: "root", version: "0.0.0" }), + "/repo/packages/a/package.json": manifest("@x/a"), +}; + +const malformedGateTree: Tree = { + "/repo/pnpm-workspace.yaml": ["packages:", " - 'packages/*'", 'minimumReleaseAge: "soon"', ""].join("\n"), + "/repo/package.json": JSON.stringify({ name: "root", version: "0.0.0" }), +}; + +describe("WorkspaceCatalogs.releaseAgeGate — the inline pnpm-workspace.yaml source", () => { + layer(workspacesOver(inlineGateTree))((it) => { + it.effect("surfaces the inline minimumReleaseAge and minimumReleaseAgeExclude", () => + Effect.gen(function* () { + const catalogs = yield* WorkspaceCatalogs; + const gate = yield* catalogs.releaseAgeGate(); + assert.strictEqual(gate.ageMinutes, 1440); + assert.deepStrictEqual([...gate.exclude], ["@x/*", "typescript"]); + }), + ); + }); + + layer(workspacesOver(withLockfileAndInline))((it) => { + it.effect("is the inert zero gate when no release-age keys are declared", () => + Effect.gen(function* () { + const catalogs = yield* WorkspaceCatalogs; + const gate = yield* catalogs.releaseAgeGate(); + assert.strictEqual(gate.ageMinutes, 0); + assert.deepStrictEqual([...gate.exclude], []); + }), + ); + }); + + layer(workspacesOver(npmWorkspace))((it) => { + it.effect("is the inert zero gate for a workspace with no pnpm-workspace.yaml", () => + Effect.gen(function* () { + const catalogs = yield* WorkspaceCatalogs; + const gate = yield* catalogs.releaseAgeGate(); + assert.strictEqual(gate.ageMinutes, 0); + assert.deepStrictEqual([...gate.exclude], []); + }), + ); + }); + + layer(workspacesOver(malformedGateTree))((it) => { + it.effect("a malformed inline minimumReleaseAge fails typed as CatalogAssemblyError", () => + Effect.gen(function* () { + const catalogs = yield* WorkspaceCatalogs; + const error = yield* Effect.flip(catalogs.releaseAgeGate()); + assert.instanceOf(error, CatalogAssemblyError); + assert.strictEqual(error.source, "manifest"); + assert.strictEqual(error.path, "pnpm-workspace.yaml"); + }), + ); + }); +}); diff --git a/packages/workspaces/__test__/fixtures/hook-pnpmfile-age-1440.mjs b/packages/workspaces/__test__/fixtures/hook-pnpmfile-age-1440.mjs new file mode 100644 index 00000000..e876211c --- /dev/null +++ b/packages/workspaces/__test__/fixtures/hook-pnpmfile-age-1440.mjs @@ -0,0 +1,14 @@ +// A config-dependency pnpmfile that sets pnpm's release-age keys to a LOW age +// (1440 minutes = 1 day) and a DISTINCT exclude list. Used two ways: +// - alone, to prove a hook's numeric age + array exclude are surfaced; +// - replayed AFTER `hook-pnpmfile-age-4320.mjs`, to prove last-wins threading +// (the final 1440 / `@scope/b` overwrite the earlier 4320 / `@scope/a`). +export const hooks = { + updateConfig(config) { + return { + ...config, + minimumReleaseAge: 1440, + minimumReleaseAgeExclude: ["@scope/b"], + }; + }, +}; diff --git a/packages/workspaces/__test__/fixtures/hook-pnpmfile-age-4320.mjs b/packages/workspaces/__test__/fixtures/hook-pnpmfile-age-4320.mjs new file mode 100644 index 00000000..2185c930 --- /dev/null +++ b/packages/workspaces/__test__/fixtures/hook-pnpmfile-age-4320.mjs @@ -0,0 +1,13 @@ +// A config-dependency pnpmfile that sets pnpm's release-age keys to a HIGH age +// (4320 minutes = 3 days). Paired with `hook-pnpmfile-age-1440.mjs` to prove +// last-wins threading across hooks: replayed FIRST, its 4320 must be overwritten +// by the later hook's lower 1440 (not maxed to 4320). +export const hooks = { + updateConfig(config) { + return { + ...config, + minimumReleaseAge: 4320, + minimumReleaseAgeExclude: ["@scope/a", "typescript"], + }; + }, +}; diff --git a/packages/workspaces/__test__/fixtures/hook-pnpmfile-age-garbage.mjs b/packages/workspaces/__test__/fixtures/hook-pnpmfile-age-garbage.mjs new file mode 100644 index 00000000..e7862fa2 --- /dev/null +++ b/packages/workspaces/__test__/fixtures/hook-pnpmfile-age-garbage.mjs @@ -0,0 +1,14 @@ +// A config-dependency pnpmfile that returns MALFORMED release-age values: a +// non-numeric `minimumReleaseAge` and a non-array `minimumReleaseAgeExclude`. +// The seam reads a hook's returned DATA tolerantly (only a load/replay failure +// is typed), so both garbage values are dropped and the hook contributes an +// empty release-age gate — never a typed failure. +export const hooks = { + updateConfig(config) { + return { + ...config, + minimumReleaseAge: "soon", + minimumReleaseAgeExclude: "nope", + }; + }, +}; diff --git a/packages/workspaces/__test__/integration/ConfigDependencyHooks.int.test.ts b/packages/workspaces/__test__/integration/ConfigDependencyHooks.int.test.ts index af620c11..00a6a3cd 100644 --- a/packages/workspaces/__test__/integration/ConfigDependencyHooks.int.test.ts +++ b/packages/workspaces/__test__/integration/ConfigDependencyHooks.int.test.ts @@ -20,6 +20,9 @@ const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), "..", "fixtures") const CJS_FIXTURE = join(FIXTURES, "hook-pnpmfile.cjs"); const MJS_FIXTURE = join(FIXTURES, "hook-pnpmfile.mjs"); const NESTED_MISSING_FIXTURE = join(FIXTURES, "hook-pnpmfile-nested-missing.mjs"); +const AGE_4320_FIXTURE = join(FIXTURES, "hook-pnpmfile-age-4320.mjs"); +const AGE_1440_FIXTURE = join(FIXTURES, "hook-pnpmfile-age-1440.mjs"); +const AGE_GARBAGE_FIXTURE = join(FIXTURES, "hook-pnpmfile-age-garbage.mjs"); const DEP_NAME = "cfg-fixture"; // A config dependency shipping ONLY `pnpmfile.mjs` — the pnpm-11-native shape. @@ -28,6 +31,10 @@ const MJS_DEP_NAME = "cfg-fixture-mjs"; const NEITHER_DEP_NAME = "cfg-fixture-neither"; // A config dependency whose `pnpmfile.mjs` has a missing nested import. const NESTED_DEP_NAME = "cfg-fixture-nested-missing"; +// Config dependencies whose hooks set pnpm's release-age keys. +const AGE_4320_DEP_NAME = "cfg-fixture-age-4320"; +const AGE_1440_DEP_NAME = "cfg-fixture-age-1440"; +const AGE_GARBAGE_DEP_NAME = "cfg-fixture-age-garbage"; const SEED = { default: { effect: "^4.0.0" } } as const; let root: string; @@ -51,6 +58,16 @@ beforeAll(() => { const nestedDir = configDepDir(NESTED_DEP_NAME); mkdirSync(nestedDir, { recursive: true }); copyFileSync(NESTED_MISSING_FIXTURE, join(nestedDir, "pnpmfile.mjs")); + // Config dependencies whose hooks set pnpm's release-age keys. + const age4320Dir = configDepDir(AGE_4320_DEP_NAME); + mkdirSync(age4320Dir, { recursive: true }); + copyFileSync(AGE_4320_FIXTURE, join(age4320Dir, "pnpmfile.mjs")); + const age1440Dir = configDepDir(AGE_1440_DEP_NAME); + mkdirSync(age1440Dir, { recursive: true }); + copyFileSync(AGE_1440_FIXTURE, join(age1440Dir, "pnpmfile.mjs")); + const ageGarbageDir = configDepDir(AGE_GARBAGE_DEP_NAME); + mkdirSync(ageGarbageDir, { recursive: true }); + copyFileSync(AGE_GARBAGE_FIXTURE, join(ageGarbageDir, "pnpmfile.mjs")); }); afterAll(() => { @@ -66,9 +83,11 @@ describe("ConfigDependencyHooks.layerLive — replays the pnpmfile", () => { const result = yield* hooks.inject(root, { [DEP_NAME]: "1.0.0" }, SEED); // The hook injected into the default catalog and a named one, and the seed // survived. - assert.strictEqual(result.default?.["hooked-dep"], "^9.9.9"); - assert.strictEqual(result.default?.effect, "^4.0.0"); - assert.strictEqual(result.extra?.["extra-dep"], "^1.2.3"); + assert.strictEqual(result.catalogs.default?.["hooked-dep"], "^9.9.9"); + assert.strictEqual(result.catalogs.default?.effect, "^4.0.0"); + assert.strictEqual(result.catalogs.extra?.["extra-dep"], "^1.2.3"); + // This fixture sets no release-age keys, so it contributes an empty gate. + assert.deepStrictEqual(result.releaseAge, {}); // The side-effect marker proves the fixture actually executed. assert.isTrue(existsSync(markerPath)); }).pipe( @@ -88,7 +107,7 @@ describe("ConfigDependencyHooks.layerLive — replays the pnpmfile", () => { // the `.mjs` and `.cjs` candidate imports fail ERR_MODULE_NOT_FOUND for the // candidate itself and it is skipped; the seed passes through unchanged. const result = yield* hooks.inject(root, { "absent-dep": "1.0.0" }, SEED); - assert.deepStrictEqual(result, SEED); + assert.deepStrictEqual(result, { catalogs: SEED, releaseAge: {} }); }).pipe(Effect.provide(ConfigDependencyHooks.layerLive)), ); }); @@ -103,9 +122,9 @@ describe("ConfigDependencyHooks.layerLive — pnpm 11 .mjs pnpmfile and load dis // and replays it — the DISTINCT `mjs-dep` / `mjsExtra` entries prove it was // the `.mjs` (there is no `.cjs` to have loaded), and the seed survived. const result = yield* hooks.inject(root, { [MJS_DEP_NAME]: "1.0.0" }, SEED); - assert.strictEqual(result.default?.["mjs-dep"], "^2.0.0"); - assert.strictEqual(result.default?.effect, "^4.0.0"); - assert.strictEqual(result.mjsExtra?.["mjs-extra-dep"], "^3.4.5"); + assert.strictEqual(result.catalogs.default?.["mjs-dep"], "^2.0.0"); + assert.strictEqual(result.catalogs.default?.effect, "^4.0.0"); + assert.strictEqual(result.catalogs.mjsExtra?.["mjs-extra-dep"], "^3.4.5"); // The side-effect marker proves the `.mjs` fixture actually executed. assert.isTrue(existsSync(markerPath)); }).pipe( @@ -124,7 +143,7 @@ describe("ConfigDependencyHooks.layerLive — pnpm 11 .mjs pnpmfile and load dis // The directory exists but carries neither candidate, so both imports fail // ERR_MODULE_NOT_FOUND for the candidate itself — the legitimate skip. const result = yield* hooks.inject(root, { [NEITHER_DEP_NAME]: "1.0.0" }, SEED); - assert.deepStrictEqual(result, SEED); + assert.deepStrictEqual(result, { catalogs: SEED, releaseAge: {} }); }).pipe(Effect.provide(ConfigDependencyHooks.layerLive)), ); @@ -162,7 +181,7 @@ describe("ConfigDependencyHooks.layerLive — rejects a traversal name before im // `@scope/pkg` contains a `/` but no `..`; it resolves inside `.pnpm-config`, // finds no pnpmfile, and contributes nothing — the seed passes through. const result = yield* hooks.inject(root, { "@scope/pkg": "1.0.0" }, SEED); - assert.deepStrictEqual(result, SEED); + assert.deepStrictEqual(result, { catalogs: SEED, releaseAge: {} }); }).pipe(Effect.provide(ConfigDependencyHooks.layerLive)), ); }); @@ -223,7 +242,7 @@ describe("ConfigDependencyHooks.layerNoop — provably never loads the pnpmfile" const result = yield* hooks.inject(root, { [DEP_NAME]: "1.0.0" }, SEED); // Same root and same declared config dependency as the live test — the ONLY // difference is the layer. The seed is unchanged and the fixture never ran. - assert.deepStrictEqual(result, SEED); + assert.deepStrictEqual(result, { catalogs: SEED, releaseAge: {} }); assert.isFalse(existsSync(markerPath)); }).pipe( Effect.provide(ConfigDependencyHooks.layerNoop), @@ -234,4 +253,102 @@ describe("ConfigDependencyHooks.layerNoop — provably never loads the pnpmfile" ), ); }); + + it.effect("contributes an empty release-age gate — the no-op runs no hooks", () => + Effect.gen(function* () { + const hooks = yield* ConfigDependencyHooks; + // Declaring the age-setting config dependency changes nothing under the + // no-op: it runs no hooks, so it contributes no release-age keys. + const result = yield* hooks.inject(root, { [AGE_4320_DEP_NAME]: "1.0.0" }, SEED); + assert.deepStrictEqual(result.releaseAge, {}); + }).pipe(Effect.provide(ConfigDependencyHooks.layerNoop)), + ); +}); + +describe("ConfigDependencyHooks.layerLive — surfaces the release-age keys hooks set", () => { + it.effect("a hook's numeric minimumReleaseAge and array exclude become the gate contribution", () => + Effect.gen(function* () { + const hooks = yield* ConfigDependencyHooks; + // The `age-1440` fixture sets `minimumReleaseAge: 1440` and + // `minimumReleaseAgeExclude: ["@scope/b"]`; both must survive to `releaseAge` + // (mapped onto `ageMinutes` / `exclude`), and the catalog seed is unchanged. + const result = yield* hooks.inject(root, { [AGE_1440_DEP_NAME]: "1.0.0" }, SEED); + assert.deepStrictEqual(result.releaseAge, { ageMinutes: 1440, exclude: ["@scope/b"] }); + assert.deepStrictEqual(result.catalogs, SEED); + }).pipe(Effect.provide(ConfigDependencyHooks.layerLive)), + ); + + it.effect("a later hook rewrites an earlier hook's release-age value — last-wins, not max", () => + Effect.gen(function* () { + const hooks = yield* ConfigDependencyHooks; + // Two config deps replayed IN ORDER: `age-4320` (4320) THEN `age-1440` + // (1440). Threading is last-wins over one config object exactly as pnpm + // replays — so the LATER, LOWER 1440 wins. A strictest-wins merge WITHIN the + // replay would have kept 4320, so 1440 is the discriminating result. The + // exclude is last-wins too (`@scope/b`, not `@scope/a`). + const result = yield* hooks.inject(root, { [AGE_4320_DEP_NAME]: "1.0.0", [AGE_1440_DEP_NAME]: "1.0.0" }, SEED); + assert.deepStrictEqual(result.releaseAge, { ageMinutes: 1440, exclude: ["@scope/b"] }); + }).pipe(Effect.provide(ConfigDependencyHooks.layerLive)), + ); + + it.effect("a hook returning garbage release-age values drops them — tolerant, never a typed failure", () => + Effect.gen(function* () { + const hooks = yield* ConfigDependencyHooks; + // The `age-garbage` fixture returns a non-numeric age and a non-array + // exclude. A hook's returned DATA is read tolerantly (only a load/replay + // mechanism failure is typed), so both are dropped: an empty contribution. + const result = yield* hooks.inject(root, { [AGE_GARBAGE_DEP_NAME]: "1.0.0" }, SEED); + assert.deepStrictEqual(result.releaseAge, {}); + }).pipe(Effect.provide(ConfigDependencyHooks.layerLive)), + ); +}); + +describe("WorkspaceCatalogs.releaseAgeGate — combines inline + hook sources strictest-wins", () => { + // The virtual tree is keyed to the REAL temp root: the effect-FileSystem read of + // pnpm-workspace.yaml is virtual, while layerLive's node:fs read of the config + // dependency's real pnpmfile hits disk under the same root — the same split the + // default-layer test above relies on. + const treeWith = (inlineAge: number, inlineExclude: readonly string[], dep: string): Tree => ({ + [`${root}/pnpm-workspace.yaml`]: [ + "packages:", + " - packages/*", + `minimumReleaseAge: ${inlineAge}`, + // Emit the exclude block only when non-empty — a bare `key:` line yields a + // null value pnpm treats as no excludes. + ...(inlineExclude.length > 0 + ? ["minimumReleaseAgeExclude:", ...inlineExclude.map((pattern) => ` - "${pattern}"`)] + : []), + "configDependencies:", + ` ${dep}: '1.0.0'`, + "", + ].join("\n"), + [`${root}/package.json`]: JSON.stringify({ name: "root", version: "0.0.0", private: true }), + [`${root}/packages/a/package.json`]: manifest("@x/a"), + }); + + it.effect("the hook's stricter age wins and excludes union", () => { + const tree = treeWith(1440, ["inline-excl"], AGE_4320_DEP_NAME); + const appLayer = Workspaces.layerWithConfigDependencies({ cwd: root }).pipe(Layer.provideMerge(platform(tree))); + return Effect.gen(function* () { + const catalogs = yield* WorkspaceCatalogs; + const gate = yield* catalogs.releaseAgeGate(); + // Inline 1440 vs hook 4320 → max is the hook's 4320. + assert.strictEqual(gate.ageMinutes, 4320); + // Excludes union: the inline pattern plus the hook's two, deduped. + assert.deepStrictEqual([...gate.exclude].sort(), ["@scope/a", "inline-excl", "typescript"]); + }).pipe(Effect.provide(appLayer)); + }); + + it.effect("the inline stricter age wins", () => { + const tree = treeWith(10_000, [], AGE_1440_DEP_NAME); + const appLayer = Workspaces.layerWithConfigDependencies({ cwd: root }).pipe(Layer.provideMerge(platform(tree))); + return Effect.gen(function* () { + const catalogs = yield* WorkspaceCatalogs; + const gate = yield* catalogs.releaseAgeGate(); + // Inline 10000 vs hook 1440 → max is the inline 10000. + assert.strictEqual(gate.ageMinutes, 10_000); + // Only the hook set an exclude here. + assert.deepStrictEqual([...gate.exclude], ["@scope/b"]); + }).pipe(Effect.provide(appLayer)); + }); }); diff --git a/packages/workspaces/src/ConfigDependencyHooks.ts b/packages/workspaces/src/ConfigDependencyHooks.ts index 4a48b244..7c9d2a8e 100644 --- a/packages/workspaces/src/ConfigDependencyHooks.ts +++ b/packages/workspaces/src/ConfigDependencyHooks.ts @@ -19,15 +19,45 @@ import { join } from "node:path"; import { pathToFileURL } from "node:url"; +import type { PartialReleaseAgeGate } from "@effected/npm"; import { CatalogAssemblyError } from "@effected/npm"; import { Context, Effect, Layer, Predicate, Result } from "effect"; import type { CatalogEntries } from "./internal/catalogs.js"; import { normalize } from "./internal/catalogs.js"; -/** The pnpm config surface a `pnpmfile.cjs` `updateConfig` hook reads and rewrites — the catalog slice. */ +/** + * The pnpm config surface a `pnpmfile.cjs` `updateConfig` hook reads and + * rewrites: the catalog slice, plus the release-age keys pnpm honours + * (`minimumReleaseAge` in minutes, `minimumReleaseAgeExclude` as name patterns). + * The catalog fields are always present; the release-age fields are `undefined` + * until a hook sets them. + */ interface HookConfig { catalog: Record; catalogs: Record>; + minimumReleaseAge: number | undefined; + minimumReleaseAgeExclude: readonly string[] | undefined; +} + +/** + * The result of replaying a workspace's `configDependencies` hooks: the catalogs + * the hooks yield, and the release-age gate contribution they leave on the + * config (pnpm's `minimumReleaseAge` / `minimumReleaseAgeExclude`). + * + * @remarks + * `releaseAge` is a `PartialReleaseAgeGate` — the age, the exclude list, + * both, or neither, depending on what the replayed hooks set. It is deliberately + * a *partial* contribution: a consumer folds it into an effective gate with + * `ReleaseAgeGate.combine` alongside inline `pnpm-workspace.yaml` values. Hooks + * that set no release-age keys contribute an empty gate (`{}`). + * + * @public + */ +export interface HookInjection { + /** The catalogs the replayed hooks yield, as `catalog name → dependency → range`. */ + readonly catalogs: Readonly>>>; + /** The release-age gate contribution the replayed hooks leave on the config. */ + readonly releaseAge: PartialReleaseAgeGate; } /** @@ -36,16 +66,30 @@ interface HookConfig { * @remarks * `inject` is given the workspace root, the manifest's `configDependencies` * (name → version+integrity), and the inline-catalog seed as a plain - * `catalog name → dependency name → range` record, and produces the catalogs the - * replayed hooks yield. The default (no-op) implementation returns the seed - * unchanged and loads nothing. + * `catalog name → dependency name → range` record, and produces a + * {@link HookInjection}: the catalogs the replayed hooks yield **and** the + * release-age gate contribution they leave on the config. The default (no-op) + * implementation returns the seed catalogs unchanged, contributes an empty + * release-age gate, and loads nothing. * * @public */ export interface ConfigDependencyHooksShape { /** * Replay each config dependency's `updateConfig` hook over `seed`, in - * declaration order, and return the resulting catalogs. + * declaration order, and return both the resulting catalogs and the + * release-age gate contribution the hooks leave behind. + * + * @remarks + * The hooks are replayed once over a single threaded config object, exactly + * as pnpm does — so catalogs and the release-age keys + * (`minimumReleaseAge` / `minimumReleaseAgeExclude`) are both read off that + * one final object, and the config-dependency code executes only once. When + * two hooks both set a release-age key the **later hook wins** (it rewrites + * the threaded value); a hook that returns a malformed value for a key leaves + * the prior threaded value in place (tolerant threading, matching the catalog + * slice). A hook failing to load or replay fails typed with a + * `hooks`-source `CatalogAssemblyError`, never a silent skip. * * @param root - The workspace root; config dependencies resolve under * `/node_modules/.pnpm-config/`. @@ -57,7 +101,7 @@ export interface ConfigDependencyHooksShape { root: string, configDependencies: Readonly>, seed: Readonly>>>, - ) => Effect.Effect>>>, CatalogAssemblyError>; + ) => Effect.Effect; } /** Whether `value` is a non-null, non-array object. */ @@ -99,18 +143,50 @@ const seedToConfig = (seed: Readonly + typeof value === "number" && Number.isFinite(value) ? value : fallback; + +/** A string array if `value` is one, else the prior threaded value — a malformed exclude is dropped, not fatal. */ +const stringArrayOr = (value: unknown, fallback: readonly string[] | undefined): readonly string[] | undefined => + Array.isArray(value) && value.every((entry) => typeof entry === "string") ? (value as readonly string[]) : fallback; + +/** + * Read the catalog slice and the release-age keys back out of whatever a hook + * returned, threading the prior config as the fallback. + * + * @remarks + * Tolerant by design, matching this seam's discipline: a hook's returned *data* + * is normalized, never a typed failure (only a load/replay *mechanism* failure + * raises `CatalogAssemblyError`). A hook that omits a key, or returns a + * malformed value for it, leaves the prior threaded value in place; a hook that + * sets a well-formed key rewrites it — so across hooks the **last well-formed + * write wins**, exactly as pnpm's single mutable config object behaves. + */ const configOf = (value: unknown, fallback: HookConfig): HookConfig => { if (!isObject(value)) return fallback; return { catalog: isObject(value.catalog) ? (value.catalog as Record) : fallback.catalog, catalogs: isObject(value.catalogs) ? (value.catalogs as Record>) : fallback.catalogs, + minimumReleaseAge: finiteNumberOr(value.minimumReleaseAge, fallback.minimumReleaseAge), + minimumReleaseAgeExclude: stringArrayOr(value.minimumReleaseAgeExclude, fallback.minimumReleaseAgeExclude), }; }; +/** + * Project the threaded config's release-age keys into a partial gate + * contribution, omitting a key the hooks never set (never an explicit + * `undefined` — the fields are `optionalKey`). + */ +const releaseAgeOf = (config: HookConfig): PartialReleaseAgeGate => ({ + ...(config.minimumReleaseAge !== undefined ? { ageMinutes: config.minimumReleaseAge } : {}), + ...(config.minimumReleaseAgeExclude !== undefined ? { exclude: config.minimumReleaseAgeExclude } : {}), +}); + /** Fold the hook config back into the normalized `catalog name → dependency → range` record. */ const configToEntries = (config: HookConfig): CatalogEntries => { const raw: Record = { ...config.catalogs }; @@ -158,7 +234,7 @@ export class ConfigDependencyHooks extends Context.Service = Layer.succeed(ConfigDependencyHooks, { - inject: (_root, _configDependencies, seed) => Effect.succeed(seed), + inject: (_root, _configDependencies, seed) => Effect.succeed({ catalogs: seed, releaseAge: {} }), }); /** @@ -181,7 +257,7 @@ export class ConfigDependencyHooks extends Context.Service Effect.gen(function* () { const names = Object.keys(configDependencies); - if (names.length === 0) return seed; + if (names.length === 0) return { catalogs: seed, releaseAge: {} }; let config = seedToConfig(seed); for (const name of names) { @@ -241,7 +317,7 @@ export class ConfigDependencyHooks extends Context.Service new CatalogAssemblyError({ source: "hooks", path: name, cause }), }); } - return configToEntries(config); + return { catalogs: configToEntries(config), releaseAge: releaseAgeOf(config) }; }), }); } diff --git a/packages/workspaces/src/WorkspaceCatalogs.ts b/packages/workspaces/src/WorkspaceCatalogs.ts index 2e1ee405..590b3120 100644 --- a/packages/workspaces/src/WorkspaceCatalogs.ts +++ b/packages/workspaces/src/WorkspaceCatalogs.ts @@ -6,7 +6,13 @@ // inline `pnpm-workspace.yaml` catalogs, which win. import type { Lockfile } from "@effected/lockfiles"; -import { CatalogAssemblyError, CatalogResolver, DependencyResolutionError } from "@effected/npm"; +import { + CatalogAssemblyError, + CatalogResolver, + DependencyResolutionError, + PartialReleaseAgeGate, + ReleaseAgeGate, +} from "@effected/npm"; import { Yaml } from "@effected/yaml"; import { Context, Duration, Effect, Exit, FileSystem, Layer, Option, Path, PlatformError, Schema } from "effect"; import { ConfigDependencyHooks } from "./ConfigDependencyHooks.js"; @@ -210,6 +216,43 @@ const configDependenciesOf = (document: unknown): Record => { return out; }; +/** + * The inline release-age gate contribution of a parsed `pnpm-workspace.yaml` + * document — pnpm's top-level `minimumReleaseAge` (minutes) and + * `minimumReleaseAgeExclude` (name patterns) keys, mapped onto + * `@effected/npm`'s `PartialReleaseAgeGate` field names + * (`ageMinutes` / `exclude`). + * + * @remarks + * **Hard-fail by design**, the same posture as the live catalog-block reader: + * a present-but-malformed value (a non-numeric `minimumReleaseAge`, a + * `minimumReleaseAgeExclude` that is not a string array) fails typed as a + * `CatalogAssemblyError` rather than being silently dropped, because a + * silently-ignored gate is the "install refuses a too-young version the + * resolver already picked" bug this vocabulary exists to prevent. An absent or + * explicitly `null` key contributes nothing; the permissive + * `PartialReleaseAgeGate` accepts any finite `ageMinutes` (negatives and + * fractions included) — `ReleaseAgeGate.combine` is the single clamping + * authority, exactly as on the hook path. + */ +const inlineReleaseAge = (document: unknown): Effect.Effect => { + if (!isObject(document)) return Effect.succeed({}); + const raw: Record = {}; + if (document.minimumReleaseAge !== undefined && document.minimumReleaseAge !== null) { + raw.ageMinutes = document.minimumReleaseAge; + } + if (document.minimumReleaseAgeExclude !== undefined && document.minimumReleaseAgeExclude !== null) { + raw.exclude = document.minimumReleaseAgeExclude; + } + if (Object.keys(raw).length === 0) return Effect.succeed({}); + return Schema.decodeUnknownEffect(PartialReleaseAgeGate)(raw).pipe( + Effect.catchTag( + "SchemaError", + (cause) => new CatalogAssemblyError({ source: "manifest", path: "pnpm-workspace.yaml", cause }), + ), + ); +}; + /** A hard-fail catalog-assembly failure naming the malformed part of a `workspaces` field. */ const malformed = (source: "manifest" | "catalog", path: string, detail: string): CatalogAssemblyError => new CatalogAssemblyError({ source, path, cause: new Error(detail) }); @@ -341,6 +384,12 @@ const validatePnpmWorkspaceCatalogs = (document: unknown): Effect.Effect Effect.Effect, CatalogAssemblyFailure>; + /** + * The effective pnpm release-age gate for the workspace, combined + * strictest-wins from the inline `pnpm-workspace.yaml` keys + * (`minimumReleaseAge` / `minimumReleaseAgeExclude`) and the replayed + * config-dependency hooks. Assembled from the same single read and hook + * replay as `set`, and memoized with it. + * + * @remarks + * Under the default layer (no-op hooks) only inline values contribute; under + * {@link WorkspaceCatalogs.layerWithConfigDependencies} the replayed hooks + * contribute too. A workspace with no pnpm-workspace.yaml (a bun/npm + * workspace) has no release-age keys, so the gate is the inert zero gate. + */ + readonly releaseAgeGate: () => Effect.Effect; } /** @@ -438,7 +501,11 @@ export class WorkspaceCatalogs extends Context.Service = Effect.gen(function* () { + // The single assembly pass produces BOTH the catalog set and the effective + // release-age gate off one root discovery, one inline read, and one hook + // replay — so the config-dependency hooks (which execute arbitrary code) + // run exactly once, and both outputs share the same memo. + const assemble: Effect.Effect = Effect.gen(function* () { const root = yield* Effect.suspend(() => roots.find(options?.cwd ?? process.cwd())); // The lockfile is a RECORD of what was installed; an absent or @@ -457,6 +524,11 @@ export class WorkspaceCatalogs extends Context.Service Date: Tue, 21 Jul 2026 15:30:28 -0400 Subject: [PATCH 2/2] fix: address PR review feedback - Replace the exclude matcher's star-to-regex compilation with a linear two-pointer wildcard scan so a config-supplied pattern cannot trigger catastrophic regex backtracking; matching semantics unchanged. - Correct the npm package context file to name the real ageMinutes field. Signed-off-by: C. Spencer Beggs --- packages/npm/CLAUDE.md | 2 +- packages/npm/src/ReleaseAgeGate.ts | 36 ++++++++++++++++++++++++------ 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/packages/npm/CLAUDE.md b/packages/npm/CLAUDE.md index 4db84e1e..16cb436b 100644 --- a/packages/npm/CLAUDE.md +++ b/packages/npm/CLAUDE.md @@ -26,7 +26,7 @@ An **internal package with no source repo** — not migrated from a `*-effect` r - `DependencySpecifier` (`src/DependencySpecifier.ts`) — the specifier concept relocated from `@effected/package-json`: a branded string with eleven-protocol taxonomy statics (`protocolOf` and friends), the resolution statics `catalogNameOf`, `resolveWorkspace` (the pnpm publish-time projection; the alias form `workspace:@` — last-`@` split, scoped-aware — projects to `npm:@`) and `workspaceTargetOf` (the alias target name, `None` for the plain form), plus a `FromString` codec decoding to a coarse five-case tagged union (`CatalogSpecifier` | `WorkspaceSpecifier` | `RangeSpecifier` | `DistTagSpecifier` | `RawSpecifier`, matchable as `ClassifiedSpecifier`) that encodes back **byte-for-byte**. `WorkspaceSpecifier#resolve(version)` applies the same projection to an already-classified instance — one shared implementation. Range detection decodes `@effected/semver`'s `Range.FromString` purely — the only use of the workspace edge. Also `InvalidDependencySpecifierError`, `isValidDependencySpecifier`. - `DependencySection` (`src/DependencySection.ts`) — the kit-wide dependency-section vocabulary: `DependencyKind` (`prod`/`dev`/`peer`/`optional`) and `DependencyField` (the four manifest key names) as literal schemas, plus the bidirectional `fieldOf`/`kindOf` mapping. Replaces the private copies package-json, lockfiles and workspaces each carried. - `IntegrityHash` (`src/IntegrityHash.ts`) — a brand over the three textual integrity forms: SRI (`-`), corepack (`.`) and yarn (`10c0/`). `algorithmOf` is `None` for the yarn form, which names no algorithm. Also `InvalidIntegrityHashError`, `isValidIntegrityHash`. -- `ReleaseAgeGate` / `PartialReleaseAgeGate` (`src/ReleaseAgeGate.ts`) — the minimum-release-age gate vocabulary. `ReleaseAgeGate` is a `Schema.Class` (a `minReleaseAge` plus an `exclude` set); statics `combine` (variadic, **strictest-wins** — the single clamping authority) and `matchesExclude` (flat-`*` @pnpm/matcher parity, deliberately **not** `@effected/glob`'s dialect); instance `isExcluded` / `filterVersions` are pure and take the caller's clock, **dropping** versions with a missing or unparseable timestamp. `PartialReleaseAgeGate` is the permissive `Schema.Struct` inbound form (hook/manifest contributions); it is `combine`d into a clamped `ReleaseAgeGate` — never clamped in isolation. +- `ReleaseAgeGate` / `PartialReleaseAgeGate` (`src/ReleaseAgeGate.ts`) — the minimum-release-age gate vocabulary. `ReleaseAgeGate` is a `Schema.Class` (an `ageMinutes` value plus an `exclude` set); statics `combine` (variadic, **strictest-wins** — the single clamping authority) and `matchesExclude` (flat-`*` @pnpm/matcher parity, deliberately **not** `@effected/glob`'s dialect); instance `isExcluded` / `filterVersions` are pure and take the caller's clock, **dropping** versions with a missing or unparseable timestamp. `PartialReleaseAgeGate` is the permissive `Schema.Struct` inbound form (hook/manifest contributions); it is `combine`d into a clamped `ReleaseAgeGate` — never clamped in isolation. Consumers today are `@effected/package-json` (`Package.resolve`, and re-exporting `DependencySpecifier`), `@effected/lockfiles`, and `@effected/workspaces`. Arrows point *at* this package; the only outbound edge is the pure `@effected/semver` peer. diff --git a/packages/npm/src/ReleaseAgeGate.ts b/packages/npm/src/ReleaseAgeGate.ts index 0b0ebc28..c95b53cc 100644 --- a/packages/npm/src/ReleaseAgeGate.ts +++ b/packages/npm/src/ReleaseAgeGate.ts @@ -59,16 +59,38 @@ const AgeMinutes = Schema.Number.check(Schema.isGreaterThanOrEqualTo(0), Schema. // Match a package name against a single pattern with pnpm `@pnpm/matcher` // semantics: an exact-name match, or a `*`-glob where `*` matches ANY run of -// characters INCLUDING `/`. All other regex metacharacters are escaped, so -// `*` is the only wildcard. See the divergence note on `matchesExclude`. +// characters INCLUDING `/`. Every other character matches literally, so `*` +// is the only wildcard. See the divergence note on `matchesExclude`. const matchesPattern = (name: string, pattern: string): boolean => { if (pattern === name) return true; if (!pattern.includes("*")) return false; - // Escape every regex metacharacter EXCEPT `*`, then turn `*` into `.*` - // (crosses `/`, unlike minimatch). The class omits `*` so it survives to - // the second replace. - const source = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*"); - return new RegExp(`^${source}$`).test(name); + // Linear two-pointer wildcard scan (greedy `*` with backtrack-to-star): + // `*` matches any run of characters including `/` (crosses `/`, unlike + // minimatch); every other character matches literally. No RegExp is built + // from the pattern — a config-supplied pattern like `*a*a*a*b` must not be + // able to trigger catastrophic regex backtracking against a near-match. + let nameIndex = 0; + let patternIndex = 0; + let starIndex = -1; + let retryNameIndex = 0; + while (nameIndex < name.length) { + if (pattern[patternIndex] === "*") { + starIndex = patternIndex; + patternIndex += 1; + retryNameIndex = nameIndex; + } else if (pattern[patternIndex] === name[nameIndex]) { + patternIndex += 1; + nameIndex += 1; + } else if (starIndex !== -1) { + patternIndex = starIndex + 1; + retryNameIndex += 1; + nameIndex = retryNameIndex; + } else { + return false; + } + } + while (pattern[patternIndex] === "*") patternIndex += 1; + return patternIndex === pattern.length; }; const matchesExclude = (name: string, patterns: readonly string[]): boolean =>