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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
---
title: The e2e bake resolved an npm-alias edge from the registry, so the release PR failed with ETARGET
date: 2026-10-08
category: build-errors
module: e2e-lane
problem_type: build_error
component: tooling
severity: high
symptoms:
- "Every e2e job on the release PR fails in the fixture bake with `npm error notarget No matching version found for @systemfsoftware/stryker-js-vitest-runner@^9.0.0`"
- "The runner tarball carrying exactly that version is in the same `npm install`"
- "e2e on `main` passes, because the runner's version there is also the registry's latest"
root_cause: wrong_api
resolution_type: code_fix
tags: [e2e-lane, pnpm-pack, workspace-protocol, npm-alias, npm-install, etarget, tarball-closure]
---

# The e2e bake resolved an npm-alias edge from the registry

## Problem & Observable Boundary

The `@systemfsoftware/stryker-js` manifest names the runner behind `testRunner: 'vm'` through a workspace alias, `"@systemfsoftware/stryker-js-vm-runner": "workspace:@systemfsoftware/stryker-js-vitest-runner@^"`. It needs the alias because the same manifest lists `@systemfsoftware/stryker-js-vitest-runner` as a `catalog:stryker` devDependency (the dogfood pin, START-6). The bake installed `npm install /packs/*.tgz`. On `main`, npm downloaded the published runner into the alias slot and the bake copied the packed runner over it. On the release commit the runner version was ahead of the registry, and the install failed with ETARGET before the copy.

The closure was complete: the runner is an entry package, so its tarball was always in the install. The defect was in how the install consumed the closure.

## Mechanism & Failure Modes

Let $T$ be the set of packed tarballs handed to one `npm install`, and let $e = (a \to \texttt{npm:}t\texttt{@}r)$ be an edge of a member, where $a$ is the alias name and $t$ is a closure member.

1. **Pack-time rewrite.** `pnpm pack` performs $\rho: \texttt{workspace:}t\texttt{@\^{}} \mapsto \texttt{npm:}t\texttt{@\^{}}v_t$.
2. **Name-keyed placement.** npm places a tarball for $t$ at `node_modules/t`. Edge $e$ needs `node_modules/a`. Since $a \neq t$, no member of $T$ fills that slot, and npm resolves $\texttt{npm:}t\texttt{@}r$ against the registry $R$.
3. **Failure condition.** $v_t \notin R \Rightarrow$ ETARGET. When $v_t \in R$ the install succeeds, but the alias slot holds the published $t$, not the packed one. Nothing fails, so the defect is silent until a release.

## Architectural Invariants

### I1: Every closure edge is satisfied by a local spec

_For every edge npm installs for a packed member (`dependencies`, `optionalDependencies`, and `peerDependencies` not marked optional in `peerDependenciesMeta`) whose target, by name or through `npm:`, is a closure member, the same `npm install` carries a spec that fills that edge's slot from the member's tarball._

```ts
specs = [...tarballs, ...aliasEdges.map((e) => `${e.alias}@file:${tarballOf(e.target)}`)]
```

npm places `alias@file:<tgz>` at `node_modules/alias` and records the tarball's real `name`. The dependent's `npm:` edge then validates against it with no registry request.

### I2: A workspace target outside the plan refuses it

_The plan fails global setup, naming the edge, when:_

- _a packed member's installed edge targets a workspace package with no tarball in the closure (`UnpackedWorkspaceDependency`);_
- _two edges give one alias name different targets, or an alias name is also a member's own name (`ConflictingAliasTargets`), because npm keeps whichever spec comes last;_
- _a fixture manifest (`dependencies`, `devDependencies`, required peers, `optionalDependencies`) names a workspace package (`FixtureNamesWorkspacePackage`), because the fixture's own `npm install` runs before the closure specs and resolves that edge from the registry._

A registry fallback for a workspace package is never a valid outcome. An optional peer npm will not install is not an edge.

### I3: Tarball lookup is exact

_A member's tarball is `<scope>-<name>-<semver>.tgz`, not any file that starts with `<scope>-<name>-`._ With prefix matching, `@systemfsoftware/stryker-js` could select `systemfsoftware-stryker-js-vitest-runner-*.tgz`. That would drop the CLI's own tarball from the bake cache key, and now that the install is planned from the looked-up tarballs, from the install too.

`installClosure` and its refusals in `@systemfsoftware/stryker-e2e-core` implement I1 and I2. The harness plans the install only when a fixture needs baking, and passes the plan to `bake-fixtures.sh` as arguments. The step that copied the packed runner over the alias directory is deleted.

## Anti-Pattern Code Smells

- **Glob install with an alias in the closure.** `npm install /packs/*.tgz` assumes every edge is name-keyed. Any `npm:` spec in a packed manifest breaks that assumption.
- **Post-install repair.** Copying a package over a directory npm already filled from the registry hides the registry dependency and leaves the lockfile lying about provenance.
- **Prefix tarball lookup.** `startsWith(prefix) && endsWith('.tgz')` across sibling packages whose names share a prefix.

## Verification & Prevention

- `install-closure.integration.test.ts` in `@systemfsoftware/stryker-e2e-core` runs under `pnpm test`. It packs a closure with the production alias shape and installs the plan with `npm install --package-lock-only --offline`, an empty cache and an unreachable registry. It then requires `file:` provenance for every workspace package in the lockfile. Any registry fallback for a closure member fails the install. Its other scenarios cover each I2 refusal and the optional-peer exemption.
- Two-sided check (scratch evidence, not a committed fixture): the same offline install given the tarball list alone exits `ENOTCACHED` for `@systemfsoftware/stryker-js-vitest-runner`, and given the plan it exits 0.

## Related

- `docs/solutions/build-errors/e2e-lane-packed-a-subset-of-its-workspace-closure.md`: the closure-completeness fix this one extends to alias edges
- `docs/plans/2026-09-25-0809-refactor-vm-runner-runs-vitest-plan.md`: why the CLI names the runner through an alias
- Failing run: https://github.com/systemfsoftware/stryker-js-effect/actions/runs/37744072050
29 changes: 29 additions & 0 deletions test/e2e-core/src/install-closure.schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import * as S from 'effect/Schema'

const EdgeSpecs = S.optional(S.Record(S.String, S.String))

const PeerDependenciesMeta = S.optional(S.Record(S.String, S.Struct({ optional: S.optional(S.Boolean) })))

export const PackedManifest = S.Struct({
name: S.String,
dependencies: EdgeSpecs,
peerDependencies: EdgeSpecs,
peerDependenciesMeta: PeerDependenciesMeta,
optionalDependencies: EdgeSpecs,
})
export type PackedManifest = typeof PackedManifest.Type

export const PackedMember = S.Struct({ tarballPath: S.String, manifest: PackedManifest })
export type PackedMember = typeof PackedMember.Type

export const FixtureManifest = S.Struct({
dependencies: EdgeSpecs,
devDependencies: EdgeSpecs,
peerDependencies: EdgeSpecs,
peerDependenciesMeta: PeerDependenciesMeta,
optionalDependencies: EdgeSpecs,
})
export type FixtureManifest = typeof FixtureManifest.Type

export const StagedFixtureManifest = S.Struct({ path: S.String, manifest: FixtureManifest })
export type StagedFixtureManifest = typeof StagedFixtureManifest.Type
216 changes: 216 additions & 0 deletions test/e2e-core/src/install-closure.workflow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import { Workflow } from '@systemfsoftware/effect-cell-types'
import * as Arr from 'effect/Array'
import * as Boolean from 'effect/Boolean'
import { pipe } from 'effect/Function'
import * as Option from 'effect/Option'
import * as Rec from 'effect/Record'
import * as Result from 'effect/Result'
import * as S from 'effect/Schema'

import { type PackedManifest, PackedMember, StagedFixtureManifest } from './install-closure.schema.js'

export class InstallClosureCommand extends S.TaggedClass<InstallClosureCommand>()('InstallClosureCommand', {
members: S.Array(PackedMember),
fixtures: S.Array(StagedFixtureManifest),
workspace: S.Array(S.String),
}) {
static readonly [Workflow.InstrumentationBrand] = {} as const
}

const ClosureInstallTypeId: unique symbol = Symbol.for('@systemfsoftware/stryker-e2e-core/ClosureInstall')
type ClosureInstallTypeId = typeof ClosureInstallTypeId

export class ClosureInstall extends S.TaggedClass<ClosureInstall>()('ClosureInstall', {
specs: S.Array(S.String),
}) {
readonly [ClosureInstallTypeId] = ClosureInstallTypeId
}

export class UnpackedWorkspaceDependency extends S.TaggedError<UnpackedWorkspaceDependency>()(
'UnpackedWorkspaceDependency',
{
dependent: S.String,
dependency: S.String,
target: S.String,
},
) {
override get message(): string {
return `${this.dependent} depends on the workspace package ${this.target} as "${this.dependency}", but the packed closure carries no tarball for it, so npm would resolve it from the registry`
}
}

export class ConflictingAliasTargets extends S.TaggedError<ConflictingAliasTargets>()('ConflictingAliasTargets', {
dependency: S.String,
targets: S.Array(S.String),
}) {
override get message(): string {
return `the packed closure installs "${this.dependency}" as more than one package (${
this.targets.join(', ')
}); npm keeps whichever spec comes last, so every other dependent would get the wrong package`
}
}

export class FixtureNamesWorkspacePackage extends S.TaggedError<FixtureNamesWorkspacePackage>()(
'FixtureNamesWorkspacePackage',
{
fixture: S.String,
dependency: S.String,
target: S.String,
},
) {
override get message(): string {
return `${this.fixture} depends on the workspace package ${this.target} as "${this.dependency}", but the fixture's own npm install resolves it from the registry; the bake installs the packed closure into every fixture, so drop the edge`
}
}

export const InstallClosureFailure = S.Union([
UnpackedWorkspaceDependency,
ConflictingAliasTargets,
FixtureNamesWorkspacePackage,
])
export type InstallClosureFailure = typeof InstallClosureFailure.Type

interface ClosureEdge {
readonly dependent: string
readonly dependency: string
readonly target: string
}

type EdgeSpecs = Readonly<Record<string, string>>

type PeerEdges = Pick<PackedManifest, 'peerDependencies' | 'peerDependenciesMeta'>

const NPM_ALIAS = /^npm:((?:@[^/@]+\/)?[^/@]+)(?:@.*)?$/

const NO_EDGES: EdgeSpecs = {}

const specsOf = (specs: EdgeSpecs | undefined): EdgeSpecs =>
Option.getOrElse(Option.fromUndefinedOr(specs), () => NO_EDGES)

const targetOf = (dependency: string, spec: string): string =>
Option.getOrElse(
Option.flatMap(Option.fromNullishOr(NPM_ALIAS.exec(spec)), (match) => Arr.get(match, 1)),
() => dependency,
)

const optionalPeer = (manifest: PeerEdges, dependency: string): boolean =>
pipe(
Option.fromUndefinedOr(manifest.peerDependenciesMeta),
Option.flatMap((meta) => Rec.get(meta, dependency)),
Option.flatMap((entry) => Option.fromUndefinedOr(entry.optional)),
Option.getOrElse(() => false),
)

const installedPeers = (manifest: PeerEdges): EdgeSpecs =>
Rec.filter(specsOf(manifest.peerDependencies), (_, dependency) => Boolean.not(optionalPeer(manifest, dependency)))

const edgesIn = (dependent: string, fields: ReadonlyArray<EdgeSpecs>): ReadonlyArray<ClosureEdge> =>
fields.flatMap((specs) =>
Object.entries(specs).map(([dependency, spec]) => ({ dependent, dependency, target: targetOf(dependency, spec) }))
)

const memberEdgesOf = (manifest: PackedManifest): ReadonlyArray<ClosureEdge> =>
edgesIn(manifest.name, [
specsOf(manifest.dependencies),
installedPeers(manifest),
specsOf(manifest.optionalDependencies),
])

const fixtureEdgesOf = (fixture: StagedFixtureManifest): ReadonlyArray<ClosureEdge> =>
edgesIn(fixture.path, [
specsOf(fixture.manifest.dependencies),
specsOf(fixture.manifest.devDependencies),
installedPeers(fixture.manifest),
specsOf(fixture.manifest.optionalDependencies),
])

const tarballOf = (command: InstallClosureCommand, name: string): Option.Option<string> =>
Option.map(
Arr.findFirst(command.members, (member) => member.manifest.name === name),
(member) => member.tarballPath,
)

const unpacked = (command: InstallClosureCommand, edge: ClosureEdge): boolean =>
Boolean.and(command.workspace.includes(edge.target), Option.isNone(tarballOf(command, edge.target)))

const isAlias = (edge: ClosureEdge): boolean => edge.target !== edge.dependency

const targetsOf = (
command: InstallClosureCommand,
aliases: ReadonlyArray<ClosureEdge>,
dependency: string,
): ReadonlyArray<string> =>
Arr.dedupe([
...aliases.filter((edge) => edge.dependency === dependency).map((edge) => edge.target),
...Option.toArray(Option.map(tarballOf(command, dependency), () => dependency)),
]).sort()

const fixtureRefusal = (command: InstallClosureCommand): Result.Result<void, FixtureNamesWorkspacePackage> =>
Option.match(
Arr.findFirst(command.fixtures.flatMap(fixtureEdgesOf), (edge) => command.workspace.includes(edge.target)),
{
onNone: () => Result.succeed(undefined),
onSome: (edge) =>
Result.fail(
FixtureNamesWorkspacePackage.make({
fixture: edge.dependent,
dependency: edge.dependency,
target: edge.target,
}),
),
},
)

const unpackedRefusal = (
command: InstallClosureCommand,
edges: ReadonlyArray<ClosureEdge>,
): Result.Result<void, UnpackedWorkspaceDependency> =>
Option.match(Arr.findFirst(edges, (edge) => unpacked(command, edge)), {
onNone: () => Result.succeed(undefined),
onSome: (edge) => Result.fail(UnpackedWorkspaceDependency.make(edge)),
})

const conflictRefusal = (
command: InstallClosureCommand,
aliases: ReadonlyArray<ClosureEdge>,
): Result.Result<void, ConflictingAliasTargets> =>
Option.match(
Arr.findFirst(
Arr.dedupe(aliases.map((edge) => edge.dependency)),
(dependency) => targetsOf(command, aliases, dependency).length > 1,
),
{
onNone: () => Result.succeed(undefined),
onSome: (dependency) =>
Result.fail(ConflictingAliasTargets.make({ dependency, targets: targetsOf(command, aliases, dependency) })),
},
)

const aliasSpecOf = (command: InstallClosureCommand, edge: ClosureEdge): ReadonlyArray<string> =>
Option.toArray(Option.map(tarballOf(command, edge.target), (path) => `${edge.dependency}@file:${path}`))

const installOf = (command: InstallClosureCommand, aliases: ReadonlyArray<ClosureEdge>): ClosureInstall =>
ClosureInstall.make({
specs: [
...command.members.map((member) => member.tarballPath),
...Arr.dedupe(aliases.flatMap((edge) => aliasSpecOf(command, edge))).sort(),
],
})

const decide = (command: InstallClosureCommand): Result.Result<ClosureInstall, InstallClosureFailure> => {
const edges = command.members.flatMap((member) => memberEdgesOf(member.manifest))
const aliases = edges.filter(isAlias)
return pipe(
fixtureRefusal(command),
Result.flatMap((): Result.Result<void, InstallClosureFailure> => unpackedRefusal(command, edges)),
Result.flatMap((): Result.Result<void, InstallClosureFailure> => conflictRefusal(command, aliases)),
Result.map(() => installOf(command, aliases)),
)
}

export const installClosure = Workflow.make({
command: InstallClosureCommand,
decision: ClosureInstall,
error: InstallClosureFailure,
decide,
})
2 changes: 2 additions & 0 deletions test/e2e-core/src/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ export * from './closure.schema.js'
export * from './confirm-annotations.workflow.js'
export * from './decode-report.workflow.js'
export * from './decode-stream.workflow.js'
export * from './install-closure.schema.js'
export * from './install-closure.workflow.js'
export * from './key-material.js'
export * from './match-annotations.workflow.js'
export * from './match.schema.js'
Expand Down
14 changes: 14 additions & 0 deletions test/e2e-core/tests/__fixtures__/npm-closure.schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import * as S from 'effect/Schema'

export const NpmManifestJson = S.fromJsonString(S.Struct({
name: S.String,
version: S.String,
private: S.optional(S.Boolean),
dependencies: S.optional(S.Record(S.String, S.String)),
}))

export const NpmLockEntry = S.Struct({ name: S.optional(S.String), resolved: S.optional(S.String) })
export type NpmLockEntry = typeof NpmLockEntry.Type

export const NpmLockfileJson = S.fromJsonString(S.Struct({ packages: S.Record(S.String, NpmLockEntry) }))
export type NpmLockfile = typeof NpmLockfileJson.Type
Loading
Loading