diff --git a/.github/scripts/check-node-floor.mjs b/.github/scripts/check-node-floor.mjs index f2ad27f..cd5dd2c 100644 --- a/.github/scripts/check-node-floor.mjs +++ b/.github/scripts/check-node-floor.mjs @@ -5,11 +5,12 @@ * * ## Why this exists * - * Three files declare the Node floor: + * Four files declare the Node floor: * - * package.json engines.node - * apps/docs/package.json engines.node - * .node-version + * package.json engines.node + * apps/docs/package.json engines.node + * tools/ci-scripts/package.json engines.node + * .node-version a version-manager pin, not a range * * Nothing in the install path reads any of them. `.npmrc` does not set * `engine-strict=true` and pnpm does not enforce `engines` by default, so @@ -42,18 +43,32 @@ * each declared floor to be at least that. `RANGE_CASES` in the self-test pins * that reduction on the exact range shapes this lockfile contains. * - * ## Known limitation, deliberately not enforced here + * ## The second rule: the declared floor must be a version that works * - * The reduction above is max-of-minimums, which is blind to a GAP inside a - * disjunctive range. `yargs@18.0.0` declares - * `^20.19.0 || ^22.12.0 || >=23`: its minimum is 20.19.0, far below 22, so it - * never moves the maximum — yet Node 22.0.0 through 22.11.x satisfies none of - * its three branches, and `>=22` claims exactly that window is supported. A - * stricter rule ("the declared floor version must itself satisfy every - * dependency range") would catch it and is RED on `main` today. Closing that - * gap means changing a declared value, which is a separate decision — filed as - * its own card rather than decided by this script. This gate is honest about - * what it proves: no dependency requires a floor higher than the one declared. + * Max-of-minimums is blind to a GAP inside a disjunctive range, and this repo + * had one. `yargs@18.0.0` declares `^20.19.0 || ^22.12.0 || >=23`: its minimum + * is 20.19.0, far below 22, so it never moves the maximum — yet Node 22.0.0 + * through 22.11.x satisfies none of its three branches, and the `>=22` this + * repo used to declare claimed exactly that window was supported. + * + * So the floor is also checked the other way round: the lowest version each + * declaration claims to support must ITSELF satisfy every `engines.node` range + * in the tree. That asks "does version X satisfy range R", which the reduction + * never asks, so the parser below computes a full interval per comparator + * rather than only a lower bound. + * + * The two rules are not independent. `unsupported` is strictly stronger: a + * floor below the max-of-minimums also fails to satisfy the range that + * produced that minimum, so a `lockfile` finding always arrives with an + * `unsupported` twin, and a fixture pins that. `lockfile` is kept because it + * is the more actionable half — it names the version to bump TO, which + * `unsupported` cannot, a gap having no single answer. The case that separates + * them (a floor clearing every minimum and still landing in a hole) is pinned + * as its own fixture, because that case is the whole reason this rule exists. + * + * Both rules judge the FLOOR and nothing above it. A dependency that excludes + * some HIGHER version is deliberately not a finding here: `engines.node` + * declares where support starts, and this gate checks that the start is real. * * ## Why there is no YAML dependency * @@ -79,11 +94,19 @@ import { fileURLToPath } from 'node:url'; const HERE = dirname(fileURLToPath(import.meta.url)); const ROOT = resolve(HERE, '../..'); -/** The `package.json` files whose `engines.node` this gate governs. */ -const DECLARATION_FILES = ['package.json', 'apps/docs/package.json']; +/** + * The `package.json` files whose `engines.node` this gate governs — every + * workspace package that declares one. + * + * `.node-version` is deliberately absent. It is a version-manager pin rather + * than a range, it is checked by the `node-version` rule below, and adding it + * here would send a bare `22` through a range parser that would read it as the + * floor 22.0.0 and call the repo's own `>=22.12.0` a disagreement. + */ +const DECLARATION_FILES = ['package.json', 'apps/docs/package.json', 'tools/ci-scripts/package.json']; /** Every rule this script enforces; the self-test asserts each one has a red fixture. */ -const RULES = ['lockfile', 'declarations', 'node-version', 'range', 'coverage', 'ungoverned']; +const RULES = ['lockfile', 'unsupported', 'declarations', 'node-version', 'range', 'coverage', 'ungoverned']; /** * Reported, never blocking — the same split `check-translations.mjs` already @@ -91,30 +114,40 @@ const RULES = ['lockfile', 'declarations', 'node-version', 'range', 'coverage', * * `ungoverned` names this gate's own blind spot. `DECLARATION_FILES` is an * explicit list, so a workspace package that declares `engines.node` outside - * it is simply not checked, and a gate silently covering two of three + * it is simply not checked, and a gate silently covering some of the * declarations is the same structurally-silent shape this one exists to end. - * It is advisory rather than blocking because "this file is not governed" is - * not a claim that its value is WRONG: `tools/ci-scripts` declares `>=20.0.0` - * and has no dependencies of its own, so that may well be correct in - * isolation. Whether the workspace should hold one floor or several is a - * decision for the seat, not something for this script to force by going red. + * + * Every workspace package that declares a floor today is governed, so this + * fires on nothing — it is here for the NEXT package to declare one. That the + * workspace holds a single floor rather than a floor per package is a decided + * policy: one lockfile installs under one Node, so a per-package floor nothing + * installs separately is a claim nobody can act on. `tools/ci-scripts` used to + * declare `>=20.0.0` on the reasoning that it has no dependencies of its own; + * it now carries the workspace floor like everything else. + * + * It stays advisory rather than blocking because "this file is not governed" + * is not a claim that its value is WRONG, and a new package appearing with a + * defensible floor of its own should surface a decision, not a red build. */ const ADVISORY = new Set(['ungoverned']); /** * Rules that must ALSO ship a fixture proving they stay silent. A rule that * quietly stopped firing is invisible to a suite that only asserts rules can - * fire, and for `lockfile` the silent case is the load-bearing one: it is the - * direction guard. A dependency asking for less than the declaration is the - * overwhelmingly common case, and a rule that fired on it would be reverted + * fire, and for `lockfile` and `unsupported` the silent case is the + * load-bearing one: they are the direction guard. A dependency asking for less + * than the declaration is the overwhelmingly common case — 426 of the 428 + * ranges in this lockfile — and a rule that fired on it would be reverted * within a day. */ -const SILENT_RULES = ['lockfile', 'declarations', 'node-version', 'ungoverned']; +const SILENT_RULES = ['lockfile', 'unsupported', 'declarations', 'node-version', 'ungoverned']; /* ------------------------------------------------------- semver, a subset -- - * Only what `engines.node` ranges actually use, and only the LOWER bound — - * this gate never asks "does version X satisfy range R", it asks "what is the - * lowest version that satisfies R". Upper bounds are parsed and discarded. + * Only what `engines.node` ranges actually use, and only what the two rules + * above need: the lowest version satisfying a range, and whether one concrete + * version satisfies it. Both are derived from ONE comparator parser, so there + * is a single answer to "is this a shape we understand" — two parsers that + * disagreed about that would put a range under one rule and out of the other. */ function cmp(a, b) { @@ -125,29 +158,72 @@ function cmp(a, b) { const fmt = (v) => v.join('.'); const show = (b) => fmt(b.v) + (b.exclusive ? ' (exclusive)' : ''); -/** Lower bound of one comparator, or null if this parser does not understand it. */ -function comparatorLowerBound(token) { +/** Everything, used for `*` and for a wildcard major. */ +const ANY = { lo: [0, 0, 0], loExclusive: false, hi: null, hiExclusive: false }; + +/** Ceiling of `^v`, which is special-cased all the way down for 0.x. */ +function caretCeiling(v, hasMinor, hasPatch) { + if (v[0] > 0) return [v[0] + 1, 0, 0]; + if (!hasMinor) return [1, 0, 0]; + if (v[1] > 0 || !hasPatch) return [0, v[1] + 1, 0]; + return [0, 0, v[2] + 1]; +} + +/** + * One comparator as a half-open-ish interval, or null if this parser does not + * understand it. `hi === null` means unbounded above. + */ +function parseComparator(token) { const t = token.trim(); - if (t === '' || t === '*' || t === 'x' || t === 'X') return { v: [0, 0, 0], exclusive: false }; + if (t === '' || t === '*' || t === 'x' || t === 'X') return ANY; const m = /^(>=|<=|>|<|=|\^|~)?\s*v?(\d+|[xX*])(?:\.(\d+|[xX*]))?(?:\.(\d+|[xX*]))?(?:[-+][0-9A-Za-z.-]+)?$/.exec(t); if (!m) return null; const op = m[1] ?? ''; - // `<` and `<=` constrain only the top of the range; they leave the floor at zero. - if (op === '<' || op === '<=') return { v: [0, 0, 0], exclusive: false }; const wild = (p) => p === undefined || p === 'x' || p === 'X' || p === '*'; - if (wild(m[2])) return { v: [0, 0, 0], exclusive: false }; - const v = [Number(m[2]), wild(m[3]) ? 0 : Number(m[3]), wild(m[4]) ? 0 : Number(m[4])]; - return { v, exclusive: op === '>' }; + if (wild(m[2])) return ANY; + const hasMinor = !wild(m[3]); + const hasPatch = !wild(m[4]); + const v = [Number(m[2]), hasMinor ? Number(m[3]) : 0, hasPatch ? Number(m[4]) : 0]; + // `<` and `<=` constrain only the top of the range; they leave the floor at zero. + if (op === '<') return { lo: [0, 0, 0], loExclusive: false, hi: v, hiExclusive: true }; + if (op === '<=') return { lo: [0, 0, 0], loExclusive: false, hi: v, hiExclusive: false }; + if (op === '>') return { lo: v, loExclusive: true, hi: null, hiExclusive: false }; + if (op === '>=') return { lo: v, loExclusive: false, hi: null, hiExclusive: false }; + if (op === '^') return { lo: v, loExclusive: false, hi: caretCeiling(v, hasMinor, hasPatch), hiExclusive: true }; + if (op === '~') { + return { lo: v, loExclusive: false, hi: hasMinor ? [v[0], v[1] + 1, 0] : [v[0] + 1, 0, 0], hiExclusive: true }; + } + // `=X` and a bare `X`: a partial version is a range over the parts it omits, + // so `20` is every 20.x and `6.*` is every 6.x — which is why the reduction + // and the satisfies check disagree about `18 || 20 || >=22` at 19.0.0. + if (!hasMinor) return { lo: v, loExclusive: false, hi: [v[0] + 1, 0, 0], hiExclusive: true }; + if (!hasPatch) return { lo: v, loExclusive: false, hi: [v[0], v[1] + 1, 0], hiExclusive: true }; + return { lo: v, loExclusive: false, hi: v, hiExclusive: false }; } -/** Lower bound of one space-separated conjunction (`14 >=14.17`, `1.0.0 - 2.0.0`). */ -function disjunctLowerBound(text) { +/** Comparator tokens of one disjunct (`14 >=14.17`, `1.0.0 - 2.0.0`). */ +function tokenize(text) { // A comparator may be separated from its version by spaces — `>= 0.10` and // `>= 10.*` are both in this repo's lockfile today. Glue those back together // before splitting, or the operator becomes a token of its own and the whole // range reads as unparseable. (The self-test caught exactly that.) The // hyphen of a `A - B` range is deliberately not in this operator set. - const tokens = text.replace(/(>=|<=|>|<|=|\^|~)\s+/g, '$1').trim().split(/\s+/).filter(Boolean); + return text + .replace(/(>=|<=|>|<|=|\^|~)\s+/g, '$1') + .trim() + .split(/\s+/) + .filter(Boolean); +} + +/** Lower bound of one comparator, or null if this parser does not understand it. */ +function comparatorLowerBound(token) { + const c = parseComparator(token); + return c === null ? null : { v: c.lo, exclusive: c.loExclusive }; +} + +/** Lower bound of one space-separated conjunction (`14 >=14.17`, `1.0.0 - 2.0.0`). */ +function disjunctLowerBound(text) { + const tokens = tokenize(text); if (!tokens.length) return { v: [0, 0, 0], exclusive: false }; // Hyphen range: "A - B" is inclusive of A, so A is the floor. if (tokens.length === 3 && tokens[1] === '-') return comparatorLowerBound(tokens[0]); @@ -173,12 +249,65 @@ function rangeLowerBound(range) { return best; } +/** Does one concrete version fall inside one comparator's interval? */ +function satisfiesComparator(v, c) { + const low = cmp(v, c.lo); + if (c.loExclusive ? low <= 0 : low < 0) return false; + if (c.hi !== null) { + const high = cmp(v, c.hi); + if (c.hiExclusive ? high >= 0 : high > 0) return false; + } + return true; +} + +/** Does one concrete version satisfy a conjunction? null if unparseable. */ +function satisfiesDisjunct(v, text) { + const tokens = tokenize(text); + if (!tokens.length) return true; + if (tokens.length === 3 && tokens[1] === '-') { + const lower = parseComparator(tokens[0]); + const upper = parseComparator(tokens[2]); + if (lower === null || upper === null) return null; + if (cmp(v, lower.lo) < 0) return false; + // "A - B" runs to the END of a partial B, which is exactly the ceiling + // `parseComparator` already computed for a bare version. + if (upper.hi === null) return true; + return upper.hiExclusive ? cmp(v, upper.hi) < 0 : cmp(v, upper.hi) <= 0; + } + for (const token of tokens) { + const c = parseComparator(token); + if (c === null) return null; + if (!satisfiesComparator(v, c)) return false; + } + return true; +} + +/** Does one concrete version satisfy a whole range? null if any part is unparseable. */ +function satisfiesRange(v, range) { + let satisfied = false; + for (const part of String(range).split('||')) { + const ok = satisfiesDisjunct(v, part); + if (ok === null) return null; + if (ok) satisfied = true; + } + return satisfied; +} + /** Does a declared floor version clear a required lower bound? */ function meets(declared, required) { const c = cmp(declared, required.v); return required.exclusive ? c > 0 : c >= 0; } +/** + * The lowest concrete version a declaration actually claims to support. For + * `>22.0.0` that is 22.0.1, not 22.0.0 — testing the excluded version itself + * would let a `>` declaration walk past the satisfies check on a technicality. + */ +function lowestClaimed(floor) { + return floor.exclusive ? [floor.v[0], floor.v[1], floor.v[2] + 1] : floor.v; +} + /* ------------------------------------------------------------- lockfile -- */ /** @@ -360,7 +489,7 @@ function evaluate({ declarations, nodeVersion, lock, missing = [], ungoverned = add('range', `${decl.source}: engines.node ${JSON.stringify(decl.value)} is not a range this parser understands`); continue; } - floors.push({ source: decl.source, raw: decl.value, v: bound.v }); + floors.push({ source: decl.source, raw: decl.value, v: bound.v, exclusive: bound.exclusive }); } // The declarations must agree with each other, semantically — ">=22" and @@ -389,6 +518,37 @@ function evaluate({ declarations, nodeVersion, lock, missing = [], ungoverned = } } + // The strengthened rule: the floor is not just a number to compare against, + // it is a version this repo says works. So every dependency must actually + // support it. This sees INSIDE a disjunction, which the reduction above + // cannot: a range whose minimum is far below the floor can still exclude the + // floor itself. Findings are grouped by range, because one gap is normally + // one range shared by several packages, and a per-package list would report + // the same defect many times. + // + // An unparseable range yields null here and is skipped — `range` has already + // reported it, and a second finding for the same unread text would double- + // count one defect rather than name two. + for (const floor of floors) { + const claimed = lowestClaimed(floor); + const offenders = new Map(); + for (const entry of scan.entries) { + if (satisfiesRange(claimed, entry.range) !== false) continue; + const packages = offenders.get(entry.range) ?? []; + packages.push(entry.pkg); + offenders.set(entry.range, packages); + } + for (const [range, packages] of offenders) { + add( + 'unsupported', + `${floor.source} declares engines.node ${JSON.stringify(floor.raw)}, so it claims Node ` + + `${fmt(claimed)} is supported — but ${packages.slice(0, 3).join(', ')}` + + `${packages.length > 3 ? ` and ${packages.length - 3} more` : ''} declare${packages.length === 1 ? 's' : ''} ` + + `engines.node ${JSON.stringify(range)}, which ${fmt(claimed)} does not satisfy`, + ); + } + } + // `.node-version` is a version-manager pin, not a range. A bare major is a // LINE pin ("latest 22.x"), so only its major is comparable; comparing it as // 22.0.0 against a floor of 22.12.0 would be a false red on a correct repo. @@ -537,28 +697,41 @@ const CASES = [ { name: 'clean baseline', expect: [], - // ">=22" and ">=22.0.0" are byte-different and identical as floors, and + // ">=22.12.0" and ">=22.12" are byte-different and identical as floors, and // every workspace package.json that declares a floor is a governed one. - ignores: ['declarations', 'ungoverned'], + // `unsupported` is silent here for the reason that matters: the floor sits + // INSIDE the `^22.12.0` branch of the lockfile's disjunctive range, which + // is the state this repo had to reach for the rule to be green at all. + ignores: ['declarations', 'ungoverned', 'unsupported'], }, { name: 'declared floor below what the tree requires', root: '>=20', docs: '>=20.0.0', + ci: '>=20.0.0', nodeVersion: '20', - expect: ['lockfile'], + // Both rules fire, and that is structural rather than sloppy: a floor + // below the max-of-minimums necessarily fails to satisfy the range that + // produced that minimum. Pinned as a pair so that a future change making + // them independent has to say so here. + expect: ['lockfile', 'unsupported'], }, { name: 'dependency floors far below the declaration', lock: LOW_LOCK, expect: [], - // The direction guard: this is the normal case and must never fire. - ignores: ['lockfile'], + // The direction guard: this is the normal case and must never fire. It + // covers both rules — a dependency asking for far less than the + // declaration neither raises the required floor nor excludes it. + ignores: ['lockfile', 'unsupported'], }, { name: 'the two engines declarations disagree', - root: '>=22', - docs: '>=22.5.0', + // Both values clear the lockfile's disjunctive gap, so this case reports + // the disagreement and nothing else — a fixture that also tripped + // `unsupported` would not prove `declarations` fired for its own reason. + root: '>=22.12.0', + docs: '>=22.13.0', expect: ['declarations'], }, { @@ -607,6 +780,39 @@ const CASES = [ lock: CLEAN_LOCK.replace(" engines: {node: '>=10'}", " engines:\n node: '>=24'"), expect: ['coverage'], }, + { + // The case the whole rule exists for, and the one `lockfile` structurally + // cannot catch: 22.0.0 clears every minimum in the tree (the maximum is + // 22.0.0, from wrangler) and still satisfies no branch of + // `^20.19.0 || ^22.12.0 || >=23`. These were this repo's own declared + // values until the rule landed, so this fixture is also the regression + // test for them. `lockfile` staying silent here is the whole point. + name: 'a floor inside a gap in a disjunctive range', + root: '>=22', + docs: '>=22.0.0', + ci: '>=22.0.0', + expect: ['unsupported'], + }, + { + // An exclusive floor claims support starting one patch up, so that is the + // version tested. Judging 22.0.0 here would let `>22.0.0` past a check + // that `>=22` fails, for a version it does not even claim. + name: 'an exclusive floor lands in the same gap', + root: '>22.0.0', + docs: '>22.0.0', + ci: '>22.0.0', + expect: ['unsupported'], + }, + { + // The regression test for governing `tools/ci-scripts`: this disagreement + // is invisible unless that third file is actually read as a declaration. + // A minor-level difference keeps it isolated — the bare-major + // `.node-version` pin compares majors only, and both values clear the + // lockfile's gap, so `declarations` fires alone. + name: 'the tools/ci-scripts declaration disagrees', + ci: '>=22.13.0', + expect: ['declarations'], + }, { name: 'a workspace package outside the governed set', extra: { 'tools/thing/package.json': { engines: { node: '>=20.0.0' } } }, @@ -641,25 +847,72 @@ const RANGE_CASES = [ ['latest', null], ]; +/** + * The satisfies predicate, pinned where it DISAGREES with the reduction above — + * that disagreement is the rule. Every left column here is a range shape this + * repo's lockfile actually contains. + */ +const SATISFIES_CASES = [ + // The gap. Its lower bound is 20.19.0, so the reduction waves 22.0.0 through. + ['^20.19.0 || ^22.12.0 || >=23', '22.0.0', false], + ['^20.19.0 || ^22.12.0 || >=23', '22.11.99', false], + ['^20.19.0 || ^22.12.0 || >=23', '22.12.0', true], + ['^20.19.0 || ^22.12.0 || >=23', '23.4.1', true], + ['^20.19.0 || ^22.12.0 || >=23', '20.19.0', true], + ['^20.19.0 || ^22.12.0 || >=23', '20.18.0', false], + // A bare major is a whole line, so this range has a hole at 19 and 21. + ['18 || 20 || >=22', '19.0.0', false], + ['18 || 20 || >=22', '20.9.9', true], + ['18 || 20 || >=22', '22.12.0', true], + // An upper bound the reduction discards entirely: lower bound 0.0.0, and + // 22.12.0 nonetheless outside it. + ['<=18.0.0', '22.12.0', false], + ['18.0.0 - 20.0.0', '20.0.0', true], + ['18.0.0 - 20.0.0', '20.0.1', false], + ['~18.2.0', '18.2.9', true], + ['~18.2.0', '18.3.0', false], + ['>20.1.0', '20.1.0', false], + ['>20.1.0', '20.1.1', true], + // A conjunction inside a disjunct. + ['>=16 || 14 >=14.17', '14.18.0', true], + ['>=16 || 14 >=14.17', '14.16.0', false], + // The ordinary shapes, which must simply be true at the repo's floor. + ['>= 0.10', '22.12.0', true], + ['6.* || 8.* || >= 10.*', '22.12.0', true], + ['4.x || >=6.0.0', '22.12.0', true], + ['^12.17.0 || ^14.13 || >=16.0.0', '22.12.0', true], + ['*', '22.12.0', true], + // Unparseable is neither true nor false: `range` reports it, this stays out. + ['lts/*', '22.12.0', null], +]; + function selfTest() { const dir = mkdtempSync(join(tmpdir(), 'node-floor-')); let failed = 0; try { for (const c of CASES) { - const root = 'root' in c ? c.root : '>=22'; - const docs = 'docs' in c ? c.docs : '>=22.0.0'; + const root = 'root' in c ? c.root : '>=22.12.0'; + const docs = 'docs' in c ? c.docs : '>=22.12'; + const ci = 'ci' in c ? c.ci : '>=22.12.0'; + // Cleared first, so a previous case's `extra` package cannot linger and + // so the governed third declaration below survives into the fixture. + rmSync(join(dir, 'tools'), { recursive: true, force: true }); mkdirSync(join(dir, 'apps/docs'), { recursive: true }); + mkdirSync(join(dir, 'tools/ci-scripts'), { recursive: true }); writeFileSync(join(dir, 'package.json'), JSON.stringify(root === null ? {} : { engines: { node: root } })); writeFileSync( join(dir, 'apps/docs/package.json'), JSON.stringify(docs === null ? {} : { engines: { node: docs } }), ); + writeFileSync( + join(dir, 'tools/ci-scripts/package.json'), + JSON.stringify(ci === null ? {} : { engines: { node: ci } }), + ); writeFileSync(join(dir, '.node-version'), `${c.nodeVersion ?? '22'}\n`); writeFileSync(join(dir, 'pnpm-lock.yaml'), c.lock ?? CLEAN_LOCK); // A real workspace file, so the baseline exercises the governed-file // exclusion rather than skipping discovery altogether. writeFileSync(join(dir, 'pnpm-workspace.yaml'), c.workspace ?? 'packages:\n - apps/*\n - tools/*\n'); - rmSync(join(dir, 'tools'), { recursive: true, force: true }); for (const [rel, body] of Object.entries(c.extra ?? {})) { mkdirSync(dirname(join(dir, rel)), { recursive: true }); writeFileSync(join(dir, rel), JSON.stringify(body)); @@ -687,6 +940,17 @@ function selfTest() { rmSync(dir, { recursive: true, force: true }); } + console.log(''); + for (const [range, version, expected] of SATISFIES_CASES) { + const got = satisfiesRange(version.split('.').map(Number), range); + const ok = got === expected; + if (!ok) failed += 1; + console.log( + `${ok ? '✓' : '✗'} satisfies ${version.padEnd(9)} ${JSON.stringify(range).padEnd(34)} -> ${got}` + + (ok ? '' : ` expected ${expected}`), + ); + } + console.log(''); for (const [range, expected] of RANGE_CASES) { const bound = rangeLowerBound(range); @@ -728,8 +992,9 @@ function selfTest() { process.exit(1); } console.log( - `✓ self-test: ${CASES.length} rule case(s) and ${RANGE_CASES.length} range case(s) — every rule ` + - 'demonstrated able to fail, every silence-bearing rule demonstrated able to stay silent', + `✓ self-test: ${CASES.length} rule case(s), ${SATISFIES_CASES.length} satisfies case(s) and ` + + `${RANGE_CASES.length} range case(s) — every rule demonstrated able to fail, every ` + + 'silence-bearing rule demonstrated able to stay silent', ); } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fdd426a..a4a76ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,13 +8,14 @@ on: merge_group: jobs: - # The three Node floor declarations (root `engines.node`, `apps/docs` - # `engines.node`, `.node-version`) are read by nothing in the install path: - # `.npmrc` sets no `engine-strict`, pnpm does not enforce `engines` by - # default, and every workflow here pins `node-version` explicitly instead of - # consulting them. This job is what makes them mechanically checkable. It - # needs no install — the script is zero-dependency and reads the lockfile as - # text — so it stays a seconds-long job that can run alongside `build`. + # The four Node floor declarations (`engines.node` in the root, in `apps/docs` + # and in `tools/ci-scripts`, plus `.node-version`) are read by nothing in the + # install path: `.npmrc` sets no `engine-strict`, pnpm does not enforce + # `engines` by default, and every workflow here pins `node-version` + # explicitly instead of consulting them. This job is what makes them + # mechanically checkable. It needs no install — the script is zero-dependency + # and reads the lockfile as text — so it stays a seconds-long job that can + # run alongside `build`. node-floor: name: Node floor runs-on: ubuntu-latest diff --git a/apps/docs/package.json b/apps/docs/package.json index ec46f57..0c62846 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -40,6 +40,6 @@ "wrangler": "^4.50.0" }, "engines": { - "node": ">=22.0.0" + "node": ">=22.12.0" } } diff --git a/package.json b/package.json index d5b6e0f..751d94f 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "license": "Apache-2.0", "packageManager": "pnpm@10.28.2", "engines": { - "node": ">=22" + "node": ">=22.12.0" }, "scripts": { "dev": "turbo run dev", diff --git a/tools/ci-scripts/package.json b/tools/ci-scripts/package.json index 855e2ee..c65be27 100644 --- a/tools/ci-scripts/package.json +++ b/tools/ci-scripts/package.json @@ -8,6 +8,6 @@ "test": "node run-self-tests.mjs" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.12.0" } }