diff --git a/.github/workflows/validate-deps.yml b/.github/workflows/validate-deps.yml index fedd06db08..5c1932ef74 100644 --- a/.github/workflows/validate-deps.yml +++ b/.github/workflows/validate-deps.yml @@ -72,9 +72,17 @@ jobs: # another (the 15.1.0 quickstart shipped exactly that: plugin-auth # declared better-auth ^1.6.23 while CI ran the 1.7.0-rc.1 override, # and every fresh project 500'd on auth). + # + # The same run also prints two informational censuses (#6046): overrides + # nothing in the dependency tree consumes, and selectors whose upper + # bound excludes their own target (#4961 / #5032). Both are REPORTS and + # never fail the job — an unconsumed override is a legitimate posture + # (#5835 ruling A). `--self-test` proves the check in both directions, + # so run the `check:override-consistency` chain rather than the bare + # script: a self-test nothing invokes is a phantom check. - name: Verify overrides are reflected in published manifests - run: node scripts/check-override-consistency.mjs - + run: pnpm check:override-consistency + # Fail the workflow if known vulnerabilities are found — enforces # security compliance before merging. # diff --git a/package.json b/package.json index ed13649459..15e23212de 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "check:objectui-pin-fresh": "node scripts/check-objectui-pin-fresh.mjs --self-test && node scripts/check-objectui-pin-fresh.mjs", "check:prerelease-pins": "node scripts/check-prerelease-pin-watch.mjs --self-test && node scripts/check-prerelease-pin-watch.mjs", "check:empty-changeset": "node scripts/check-empty-changeset.mjs --self-test && node scripts/check-empty-changeset.mjs", + "check:override-consistency": "node scripts/check-override-consistency.mjs --self-test && node scripts/check-override-consistency.mjs", "check:release-notes": "node scripts/check-release-notes.mjs", "check:release-body": "node scripts/release-github-releases.mjs --self-test", "check:node-version": "node scripts/check-node-version.mjs", diff --git a/scripts/check-override-consistency.mjs b/scripts/check-override-consistency.mjs index 06e8de798f..766593a436 100644 --- a/scripts/check-override-consistency.mjs +++ b/scripts/check-override-consistency.mjs @@ -25,12 +25,49 @@ * * Uses the `semver` package (root devDependency), so it must run after * `pnpm install` — which is how the validate-deps workflow orders it. + * + * --------------------------------------------------------------------------- + * Health report (#6046, companion to the #5835 ruling A) — REPORTS, NEVER RED + * --------------------------------------------------------------------------- + * The rule above only fires when an override's package is DECLARED by a + * publishable manifest. An override that nothing declares — and, after #5825 + * retired vscode-objectstack, that nothing even pulls transitively — is simply + * skipped, so it is completely invisible. That is dangerous in both + * directions: it can be trusted as though it were in force, or deleted by the + * next agent tidying up. So the check also prints two informational censuses. + * + * Both are REPORTS and leave the exit code alone. A pinned-but-unconsumed + * override is a legitimate defence-in-depth posture (that is exactly what + * #5835 ruling A decided for form-data / undici); the goal here is visibility, + * not prohibition. Nothing below may ever call process.exit. + * + * 1. IDLE OVERRIDES — the package name appears nowhere in the dependency tree, + * so the override cannot rewrite anything until something reintroduces the + * dependency. + * + * The census is NAME-LEVEL on purpose. A tempting alternative is to count + * resolutions that fall inside the override's selector, but pnpm-lock.yaml + * records POST-override resolutions: a working override has already moved + * its matches up to the target and out of the selector. Measured on the + * tree this check ships against, that alternative scores zero in-selector + * resolutions for 28 of 28 overrides — working ones included — so it cannot + * tell "idle" from "doing its job". Absence of the name is the only signal + * the lockfile can honestly supply. + * + * 2. SELF-EXPIRING SELECTORS — the selector's exclusive upper bound `=7.23.0 + * <7.28.0` did exactly that when 7.28.0's own advisories landed (#4961, + * #5032). The durable shape puts the upper bound above the target's line + * and moves only the replacement target. */ -import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; import { dirname, join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import semver from 'semver'; +import { parse as parseYaml } from 'yaml'; const __dirname = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(__dirname, '..'); @@ -214,7 +251,373 @@ function declarationMatchesTarget(declaredRange, target) { return false; } +/* ------------------------------------------------------------------------- + * Health report 1: idle overrides (zero consumers in the dependency tree) + * ---------------------------------------------------------------------- */ + +/** Dependency maps to walk in each lockfile section. */ +const LOCK_IMPORTER_FIELDS = ['dependencies', 'devDependencies', 'optionalDependencies']; +const LOCK_SNAPSHOT_FIELDS = ['dependencies', 'optionalDependencies']; + +/** + * Index every package name the dependency tree depends on, from the local + * pnpm-lock.yaml. Deterministic and offline — the lockfile is checked in, and + * the validate-deps workflow runs `pnpm install --frozen-lockfile` first, so + * it is always current where this check runs. + * + * `importers` covers the workspace projects' own declarations; `snapshots` + * covers every resolved third-party package's resolved dependencies. Between + * them, a name that appears in neither is depended on by nothing. + * + * @param {string} [text] lockfile contents; read from disk when omitted + * @returns {Map> | null} + * null when there is no readable lockfile (census skipped, never fatal) + */ +function buildConsumerIndex(text) { + let source = text; + if (source === undefined) { + const lockPath = resolve(repoRoot, 'pnpm-lock.yaml'); + if (!existsSync(lockPath)) return null; + source = readFileSync(lockPath, 'utf8'); + } + let lock; + try { + lock = parseYaml(source); + } catch { + return null; + } + if (!lock || typeof lock !== 'object') return null; + + /** @type {Map>} */ + const index = new Map(); + const add = (name, from, version) => { + const list = index.get(name) ?? []; + list.push({ from, version: String(version) }); + index.set(name, list); + }; + + for (const [path, importer] of Object.entries(lock.importers ?? {})) { + for (const field of LOCK_IMPORTER_FIELDS) { + for (const [name, spec] of Object.entries(importer?.[field] ?? {})) { + add(name, `workspace ${path} (${field})`, spec?.version ?? spec?.specifier ?? '?'); + } + } + } + for (const [id, snapshot] of Object.entries(lock.snapshots ?? {})) { + for (const field of LOCK_SNAPSHOT_FIELDS) { + for (const [name, version] of Object.entries(snapshot?.[field] ?? {})) { + add(name, id, version); + } + } + } + return index; +} + +/** + * @param {ReturnType} overrides + * @param {Map>} consumerIndex + * @returns {Array<{ override: any, consumers: Array<{ from: string, version: string }> }>} + * one entry per override whose package nothing in the tree depends on + */ +function findIdleOverrides(overrides, consumerIndex) { + const idle = []; + for (const override of overrides) { + const consumers = consumerIndex.get(override.name) ?? []; + if (consumers.length === 0) idle.push({ override, consumers }); + } + return idle; +} + +/* ------------------------------------------------------------------------- + * Health report 2: self-expiring selectors (#4961 / #5032) + * ---------------------------------------------------------------------- */ + +/** + * The exclusive upper bound of a selector, if it has one: `>=7.23.0 <7.29.0` + * and `<4.0.6` both yield the bound version. `<=V` is deliberately not a + * match — it covers V, so the target's own version stays in scope. + * + * @param {string | null} selector @returns {string | null} + */ +function exclusiveUpperBound(selector) { + if (!selector) return null; + const match = /(?:^|\s)<\s*([0-9][^\s|]*)/.exec(selector); + if (!match) return null; + return semver.valid(match[1]) ? match[1] : null; +} + +/** The lowest version an override target can resolve to. */ +function targetFloor(target) { + if (semver.valid(target) !== null) return target; + try { + return semver.minVersion(target)?.version ?? null; + } catch { + return null; + } +} + +/** + * Report an override whose selector excludes the very target it pins to: with + * an exclusive bound `} overrides + * @returns {Array<{ override: any, bound: string, floor: string }>} + */ +function findSelfExpiringOverrides(overrides) { + const found = []; + for (const override of overrides) { + const bound = exclusiveUpperBound(override.selector); + if (bound === null) continue; + const floor = targetFloor(override.target); + if (floor === null) continue; + if (semver.lte(bound, floor)) found.push({ override, bound, floor }); + } + return found; +} + +/** `pkg@selector` (or bare `pkg`) exactly as it is keyed in the YAML. */ +function overrideKey(override) { + return override.selector ? `${override.name}@${override.selector}` : override.name; +} + +/** + * Print both censuses. Informational only — this never sets the exit code. + * + * @param {ReturnType} overrides + */ +function reportOverrideHealth(overrides) { + const consumerIndex = buildConsumerIndex(); + const idle = consumerIndex === null ? [] : findIdleOverrides(overrides, consumerIndex); + const selfExpiring = findSelfExpiringOverrides(overrides); + + if (consumerIndex === null) { + console.log( + '\nnote: no readable pnpm-lock.yaml — the idle-override census was skipped.', + ); + } else if (idle.length === 0) { + console.log(`\n[report] Consumer census: all ${overrides.length} override(s) name a package the dependency tree still pulls.`); + } else { + console.log( + `\n[report] Consumer census: ${idle.length} of ${overrides.length} override(s) ` + + 'name a package NOTHING in the dependency tree depends on.', + ); + for (const { override } of idle) { + console.log(` - '${overrideKey(override)}': '${override.target}' — 0 consumers`); + } + console.log( + '\n These are REPORTS, not failures. An override with no consumer today is a\n' + + ' legitimate defence-in-depth posture: it costs nothing and pins the version\n' + + ' in advance if any dependency reintroduces the package. #5835 ruling A\n' + + ' decided exactly this for the form-data / undici OSV pins — KEEP THEM.\n' + + ' Do not "tidy up" an entry listed here without a ruling that says to.', + ); + } + + if (selfExpiring.length > 0) { + console.log( + `\n[report] Self-expiring selectors: ${selfExpiring.length} of ${overrides.length} override(s) ` + + 'use a selector that excludes their own target.', + ); + for (const { override, bound, floor } of selfExpiring) { + // The bound-vs-floor relation is explained once below and is identical on + // every row, so only the strictly-worse variant annotates itself: a bound + // BELOW the floor leaves [bound, floor) covered by neither. + const gap = semver.lt(bound, floor) ? ` (gap: ${bound} .. ${floor} matches nothing)` : ''; + console.log(` - '${overrideKey(override)}' -> '${override.target}'${gap}`); + } + console.log( + '\n Also REPORTS, not failures. The shape is the prevailing one in this file,\n' + + ' so this is a standing ledger rather than a regression: in each row above the\n' + + ' exclusive upper bound sits at or below the target floor, so nothing at or\n' + + ' above the target is in scope and the selector has to be widened in lockstep\n' + + ' every time the target moves. `undici@>=7.23.0 <7.28.0` stopped matching the\n' + + ' day 7.28.0 got its own advisory (#4961, #5032). The durable shape puts the\n' + + ' upper bound above the target\'s version line and moves only the target.', + ); + } +} + +/* ------------------------------------------------------------------------- + * Self-test — proves every rule in this file in BOTH directions, so a check + * that has quietly stopped checking cannot pass as green. + * ---------------------------------------------------------------------- */ + +/** @param {string} key @param {string} target */ +function fixtureOverride(key, target) { + const at = key.lastIndexOf('@'); + return at > 0 + ? { name: key.slice(0, at), selector: key.slice(at + 1), target } + : { name: key, selector: null, target }; +} + +/** + * A miniature pnpm-lock.yaml: one workspace importer that declares `semver`, + * one third-party snapshot that pulls `undici`. Nothing anywhere pulls + * `form-data` — the shape #5835 found in the real tree. + */ +const FIXTURE_LOCKFILE = [ + "lockfileVersion: '9.0'", + '', + 'importers:', + ' .:', + ' devDependencies:', + ' semver:', + " specifier: ^7.8.5", + ' version: 7.8.5', + '', + 'packages:', + ' undici@7.29.0:', + ' resolution: {integrity: sha512-fixture}', + '', + 'snapshots:', + " '@ai-sdk/provider-utils@5.0.16':", + ' dependencies:', + ' undici: 7.29.0', + '', +].join('\n'); + +function selfTest() { + const index = buildConsumerIndex(FIXTURE_LOCKFILE); + const idleKeys = (keys) => + findIdleOverrides(keys.map(([k, t]) => fixtureOverride(k, t)), index).map((e) => + overrideKey(e.override), + ); + const expiringKeys = (keys) => + findSelfExpiringOverrides(keys.map(([k, t]) => fixtureOverride(k, t))).map((e) => + overrideKey(e.override), + ); + + const cases = [ + // --- consumer census ------------------------------------------------- + { + name: 'lockfile parses into a consumer index', + actual: () => index !== null && index.size > 0, + expect: true, + }, + { + name: 'unparseable lockfile -> census skipped, never a crash', + actual: () => buildConsumerIndex('this: [is not: valid yaml'), + expect: null, + }, + { + name: 'lockfile that is not a mapping -> census skipped', + actual: () => buildConsumerIndex('just a scalar'), + expect: null, + }, + { + name: 'transitive consumer present (snapshot pulls undici) -> NOT reported', + actual: () => idleKeys([['undici@>=7.23.0 <7.29.0', '^7.29.0']]).length, + expect: 0, + }, + { + name: 'workspace importer counts as a consumer (semver) -> NOT reported', + actual: () => idleKeys([['semver@<7.8.5', '^7.8.5']]).length, + expect: 0, + }, + { + name: 'zero consumers (nothing pulls form-data) -> REPORTED', + actual: () => idleKeys([['form-data@<4.0.6', '>=4.0.6']]).join(','), + expect: 'form-data@<4.0.6', + }, + { + name: 'census reports only the idle one out of a mixed set', + actual: () => + idleKeys([ + ['undici@>=7.23.0 <7.29.0', '^7.29.0'], + ['form-data@<4.0.6', '>=4.0.6'], + ['semver@<7.8.5', '^7.8.5'], + ]).join(','), + expect: 'form-data@<4.0.6', + }, + // --- self-expiring selectors ---------------------------------------- + { + name: 'bound equal to the target floor (the #5032 undici shape) -> REPORTED', + actual: () => expiringKeys([['undici@>=7.23.0 <7.29.0', '^7.29.0']]).join(','), + expect: 'undici@>=7.23.0 <7.29.0', + }, + { + name: 'bound below the target floor (uncovered gap) -> REPORTED', + actual: () => expiringKeys([['@hono/node-server@<2.0.5', '^2.0.10']]).join(','), + expect: '@hono/node-server@<2.0.5', + }, + { + name: 'bound above the target version line (the durable shape) -> NOT reported', + actual: () => expiringKeys([['undici@>=7.23.0 <8.0.0', '^7.29.0']]).length, + expect: 0, + }, + { + name: 'selector with no upper bound -> NOT reported', + actual: () => expiringKeys([['esbuild', '>=0.28.1']]).length, + expect: 0, + }, + { + name: 'inclusive upper bound covers the target -> NOT reported', + actual: () => expiringKeys([['uuid@<=11.1.1', '11.1.1']]).length, + expect: 0, + }, + // --- the core rule this file has always enforced --------------------- + { + name: 'declared range that reaches the target -> no violation', + actual: () => declarationMatchesTarget('^7.29.0', '^7.29.0'), + expect: true, + }, + { + name: 'declared range that cannot reach the target -> violation', + actual: () => declarationMatchesTarget('^1.6.23', '1.7.0-rc.1'), + expect: false, + }, + { + name: 'no implicit prereleases: ^1.7.0 does not reach 1.7.0-rc.2', + actual: () => declarationMatchesTarget('^1.7.0', '1.7.0-rc.2'), + expect: false, + }, + { + name: 'declaration outside the selector scope -> override does not apply', + actual: () => overrideApplies(fixtureOverride('form-data@<4.0.6', '>=4.0.6'), '^5.0.0'), + expect: false, + }, + { + name: 'declaration inside the selector scope -> override applies', + actual: () => overrideApplies(fixtureOverride('form-data@<4.0.6', '>=4.0.6'), '^4.0.0'), + expect: true, + }, + ]; + + let passed = true; + console.log('check-override-consistency self-test (both directions):'); + for (const testCase of cases) { + let actual; + try { + actual = testCase.actual(); + } catch (error) { + actual = `threw: ${error.message}`; + } + const ok = actual === testCase.expect; + if (!ok) passed = false; + console.log( + `${ok ? ' ✓' : ' ✗'} ${testCase.name}` + + (ok ? '' : `\n expected ${JSON.stringify(testCase.expect)}, got ${JSON.stringify(actual)}`), + ); + } + + if (!passed) { + console.error('\n✗ self-test failed — this check does not do what it claims.'); + process.exit(1); + } + console.log( + `\n✓ self-test passed (${cases.length} assertions): consumers present are not reported,` + + '\n zero-consumer and self-expiring overrides are, and the manifest rule still' + + '\n separates reachable declarations from unreachable ones.', + ); +} + function main() { + if (process.argv.includes('--self-test')) { + selfTest(); + return; + } const overrides = readOverrides(); if (overrides.length === 0) { console.log('✓ No overrides in pnpm-workspace.yaml — nothing to check.'); @@ -256,14 +659,18 @@ function main() { } } + // Informational censuses first, blocking verdict last: CI readers look at + // the bottom of a log, and the verdict is what decides the job. + reportOverrideHealth(overrides); + if (violations.length === 0) { console.log( - `✓ ${checked} published-manifest declaration(s) covered by pnpm-workspace.yaml overrides all resolve to their override targets.`, + `\n✓ ${checked} published-manifest declaration(s) covered by pnpm-workspace.yaml overrides all resolve to their override targets.`, ); return; } - console.error('✗ pnpm-workspace.yaml overrides are not reflected in published manifests.'); + console.error('\n✗ pnpm-workspace.yaml overrides are not reflected in published manifests.'); console.error( '\npnpm overrides apply only inside this workspace — they do NOT ship with', );