/** * Every module's bundled `@celilo/*` range must be able to resolve the version * the workspace is about to publish. * * The failure this exists to catch has no symptom. A module bundles its own * copy of `@celilo/capabilities` and runs THAT copy (celilo#173), so when the * workspace publishes a new major, a module still pinned to the old caret * range keeps installing the old copy. Everything typechecks. Every test * passes. The new export is simply unreachable to every module in the fleet, * and nothing anywhere says so. * * That is not hypothetical. `firewall_registry` was registered in * `CapabilityRegistry` and npm's published `@celilo/capabilities@2.6.0` * contains zero occurrences of it. Every module bundled `^2.6.0`, and a caret * range never crosses a major, so the registration reached nothing. It was * found by a person reading the diff (celilo#1089), not by a gate. * * `npm-consumer-smoke` cannot see it: it installs from locally-packed tarballs * and never consults a registry, which is exactly why this class drifts unseen. * `check:modules` cannot see it either — it installs each module's DECLARED * range and typechecks, so a module that does not yet USE the new export is * perfectly happy on the old copy. Both gates are green while the thing is * broken. * * ## THIS GATE IS DELIBERATELY NARROW. READ THIS BEFORE TRUSTING IT. * * Its name invites a stronger reading than it earns. It compares the WORKSPACE * version against each module's range. It does NOT consult a registry, so it * cannot see a gap between what is PUBLISHED and what a module bundles. * * Measured, not assumed: run against `00c12ec2^1` — the commit immediately * before the Version Packages PR that shipped capabilities 3.0.0 — this gate * passes 3/0. At that commit the workspace was 2.6.0 and every module was * `^2.6.0`, which satisfies, while npm's published 2.6.0 contained zero * occurrences of `firewall_registry`. **So it would not have caught * celilo#1089, the bug it was written for.** Nor `hello-trespass` sitting at * `^2.3.0`, because `satisfies('2.6.0', '^2.3.0')` is true: a caret crosses * minors freely and stops only at a major. * * If you are here because a published export turned out to be unreachable and * you are wondering why this was green: it never looked. That check needs a * registry query, which is network-bound and therefore does not belong beside * hermetic checks in `ci/validate` — a flaky gate gets disabled. Its home is * the release pipeline or a scheduled job. * * And one limitation no in-repo gate can ever close, however it is written: * out-of-repo modules. `lunacycle` lives outside this checkout and no sweep * here can see its pin at all (lunacycle#64). * * What this DOES catch is a module whose pin is out of lockstep with the * workspace, which happens when a module is added or edited outside the * release sweep's window: the Version Packages PR is open with everything at * `^3.0.0`, a new module merges to main at `^2.6.0`, the version PR merges, * and nothing re-runs the sweep until the next release. * * `scripts/sync-consumer-pins.ts` is what normally keeps these aligned, and it * runs inside the version-PR phase of `release.yml`. This gate is the check * that it RAN and covered everything — a module added between releases carries * whatever range its author typed until the next release sweeps it. */ import { describe, expect, test } from 'bun:test'; import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; import { repoRoot } from './capability-shape'; interface PackageJson { name?: string; version?: string; dependencies?: Record; devDependencies?: Record; } function readJson(path: string): PackageJson { return JSON.parse(readFileSync(path, 'utf-8')) as PackageJson; } /** `@celilo/*` package name → the version this checkout would publish. */ function workspaceVersions(root: string): Map { const versions = new Map(); for (const dir of ['packages', 'apps']) { const base = join(root, dir); if (!existsSync(base)) continue; for (const entry of readdirSync(base)) { const manifest = join(base, entry, 'package.json'); if (!existsSync(manifest)) continue; const pkg = readJson(manifest); if (pkg.name?.startsWith('@celilo/') && pkg.version) versions.set(pkg.name, pkg.version); } } return versions; } interface ModuleDep { module: string; dep: string; range: string; } /** Every `@celilo/*` dependency declared by any module's `scripts/` package. */ function moduleDeps(root: string): ModuleDep[] { const out: ModuleDep[] = []; const modulesDir = join(root, 'modules'); for (const module of readdirSync(modulesDir)) { const manifest = join(modulesDir, module, 'scripts', 'package.json'); if (!existsSync(manifest)) continue; const pkg = readJson(manifest); for (const [dep, range] of Object.entries({ ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}), })) { if (dep.startsWith('@celilo/')) out.push({ module, dep, range }); } } return out; } describe('a module can reach what the workspace publishes', () => { const root = repoRoot(); const versions = workspaceVersions(root); const deps = moduleDeps(root); test('the scan found the workspace packages and the module deps', () => { // An empty scan passes every assertion below while checking nothing, which // is the failure mode that makes a green gate worse than no gate. expect(versions.size).toBeGreaterThan(0); expect(deps.length).toBeGreaterThan(0); expect(versions.has('@celilo/capabilities')).toBe(true); }); test('every module range resolves the version this checkout would publish', () => { const unreachable = deps .filter(({ dep, range }) => { const version = versions.get(dep); // A dep on a package this checkout does not build is out of scope: it // resolves from the registry like any third-party dependency. if (!version) return false; return !Bun.semver.satisfies(version, range); }) .map( ({ module, dep, range }) => `${module} pins ${dep}@${range}, which cannot resolve the workspace's ${versions.get(dep)}`, ) .sort(); const guidance = [ 'A module bundles its own copy and runs THAT copy (celilo#173). A range that', 'cannot reach the version about to be published means every export added', 'since is unreachable to that module, silently.', '', 'Fix: bun scripts/sync-consumer-pins.ts (then commit the rewritten pins)', '', ...unreachable, ].join('\n'); expect(unreachable, guidance).toEqual([]); }); test('PROVE IT FAILS: a caret range does not cross a major', () => { // The exact shape of celilo#1089: modules on ^2.6.0, workspace at 3.0.0. expect(Bun.semver.satisfies('3.0.0', '^2.6.0')).toBe(false); // And the near-miss that makes it easy to believe you are covered: a caret // DOES cross a minor, so a lagging pin looks harmless right up until the // major. expect(Bun.semver.satisfies('2.6.0', '^2.3.0')).toBe(true); }); });