diff --git a/.changeset/6313-quick-reference-node-floor.md b/.changeset/6313-quick-reference-node-floor.md new file mode 100644 index 000000000..c67eeeddb --- /dev/null +++ b/.changeset/6313-quick-reference-node-floor.md @@ -0,0 +1,16 @@ +--- +--- + +Tooling-only fix (objectui#6313): `QUICK_REFERENCE.md`'s "Current Release" pin +(`scripts/__tests__/quick-reference-current-release-4143.test.ts`) and the sync +generator (`scripts/sync-quick-reference-release.mjs`) derived the Node/pnpm +version floors with `match(/(\d+)/)?.[1]`, which keeps only the LEADING integer +group. Against root `engines.node: ">=22.11"` (objectui#5306 / PR #6311) that +produced a floor of `22`, silently discarding the `.11` — so a row reading +exactly `≥ 22` passed a pin whose whole premise is disagreeing with its own +cited anchor. Both derivations now strip the comparator and keep the WHOLE +version string; `pnpm quick-reference:sync` regenerated the Node.js row to +`≥ 22.11`. Added regression coverage pinning that a `≥ 22`-shaped row is now +rejected and that a `≥ 220`-shaped row still is (objectui#4913), and corrected +the test file's docblock, which had gone stale on the same anchor one decimal +place up. No published package source changed. diff --git a/QUICK_REFERENCE.md b/QUICK_REFERENCE.md index 0708d493d..4097a911b 100644 --- a/QUICK_REFERENCE.md +++ b/QUICK_REFERENCE.md @@ -127,7 +127,7 @@ behind (objectui#5394 — that had happened once per release, three times). `apps/console/package.json`) - **Client:** `@objectstack/client` ^17.0.0 (declared by `apps/console/package.json` and `packages/data-objectstack/package.json`) -- **Node.js:** ≥ 22 (see root `engines.node`) +- **Node.js:** ≥ 22.11 (see root `engines.node`) - **pnpm:** ≥ 10 (the workspace pins `pnpm@10.31.0` via `packageManager`) - **React:** 18.x or 19.x (the `peerDependencies.react` range the packages declare) - **TypeScript:** ≥ 5.0 (strict mode) — the stack floor stated in AGENTS.md §2, not a diff --git a/scripts/__tests__/quick-reference-current-release-4143.test.ts b/scripts/__tests__/quick-reference-current-release-4143.test.ts index 85cadb8f1..e24a89715 100644 --- a/scripts/__tests__/quick-reference-current-release-4143.test.ts +++ b/scripts/__tests__/quick-reference-current-release-4143.test.ts @@ -12,10 +12,19 @@ import { fileURLToPath } from 'node:url'; * * The card's premise also said the block's Node/pnpm/React rows were "currently * accurate". Two of the three were. **Node was not** — the row read `≥ 20` while - * root `engines.node` has been `>=22`, and the row *names that anchor in its own - * text*. A claim that cites its anchor and disagrees with it is the strongest - * possible argument that review alone does not hold this block: the reviewer had - * the pointer and still did not follow it. + * root `engines.node` was `>=22` at the time, and the row *names that anchor in + * its own text*. A claim that cites its anchor and disagrees with it is the + * strongest possible argument that review alone does not hold this block: the + * reviewer had the pointer and still did not follow it. + * + * `engines.node` has since moved again, to `>=22.11` (objectui#5306 / PR #6311) — + * and objectui#6313 is the same shape of drift one decimal place down: the + * forward assertion below derived its expected floor with `match(/(\d+)/)`, + * which keeps only the LEADING integer group and silently discards everything + * after it. `>=22.11` derived a floor of `22`, so a row reading exactly `≥ 22` + * (missing the `.11` its own anchor carries) passed. The derivation below now + * strips the comparator and keeps the WHOLE version string instead, so the row + * and the anchor can no longer disagree at the minor version. * * ## Why a per-block pin and not the repo's existing ratchet * @@ -123,6 +132,21 @@ function peerMajors(range: string): string[] { .filter((major): major is string => major !== undefined); } +/** + * The floor an `engines`-style range states, as the WHOLE version string — not just + * its leading integer group. + * + * objectui#6313: the previous derivation, `match(/(\d+)/)?.[1]`, kept only the + * leading digits, so `>=22.11` produced a floor of `22` and silently discarded the + * `.11` rather than failing. A row reading exactly `≥ 22` then satisfied an anchor + * that actually says `22.11`. Stripping the comparator and keeping the remainder + * whole means the row and the anchor can no longer disagree at any segment the + * anchor carries. + */ +function versionFloor(range: string): string | undefined { + return range.match(/^[\^~>=<\s]*(.+)$/)?.[1]; +} + /** * The `## Current Release` section, from its heading to the next `## ` heading (or EOF). * Scoping matters: `## Repository Layout` above it also carries numbers, and asserting @@ -314,8 +338,8 @@ describe('QUICK_REFERENCE.md "Current Release" — toolchain rows', () => { const engine = rootManifest.engines?.node; expect(engine, 'root package.json must still declare engines.node').toBeDefined(); - const floor = (engine as string).match(/(\d+)/)?.[1]; - expect(floor, `engines.node must still carry a numeric floor (got "${engine}")`).toBeDefined(); + const floor = versionFloor(engine as string); + expect(floor, `engines.node must still carry a version floor (got "${engine}")`).toBeDefined(); expectRowStates( 'Node.js', [`≥ ${floor}`], @@ -323,10 +347,64 @@ describe('QUICK_REFERENCE.md "Current Release" — toolchain rows', () => { ); }); + /** + * objectui#6313: `match(/(\d+)/)?.[1]` on `engines.node` kept only the leading + * integer group, so `>=22.11` derived a floor of `22` and a row reading exactly + * `≥ 22` — missing the `.11` its own anchor states — passed. Two properties, both + * measured against the REAL `engines.node` rather than a hard-coded literal, so + * this cannot itself go stale the way the row it guards did: + * + * - The old (leading-integer) and new (whole-string) derivations must disagree + * whenever the anchor carries a minor — proving the fix changed what floor + * comes OUT, not just how it is computed. If `engines.node` ever loses its + * minor this assertion goes quiet, which is why the next one does not depend + * on that disagreement to make its point. + * - A row stating the coarser, pre-fix floor must be REJECTED by the same + * equality `expectRowStates` uses above. This is the entire point of the card: + * if `≥ 22` were accepted before the fix and still accepted after it, the + * derivation changed without changing what the pin enforces. + */ + it('derives the Node floor as the whole version, not just its leading integer group', () => { + const engine = rootManifest.engines?.node as string; + const staleFloor = engine.match(/(\d+)/)?.[1]; + const floor = versionFloor(engine); + + expect( + floor, + `engines.node ("${engine}") no longer carries a minor segment for the leading-integer ` + + `derivation ("${staleFloor}") to differ from — objectui#6313's regression case needs one ` + + 're-anchored to keep exercising the disagreement', + ).not.toBe(staleFloor); + + expect( + statedLiterals(`≥ ${staleFloor}`), + `a row reading "≥ ${staleFloor}" must NOT satisfy the derived Node floor "≥ ${floor}" — ` + + 'accepting it is exactly the objectui#6313 staleness this pin exists to catch', + ).not.toEqual(statedLiterals(`≥ ${floor}`)); + }); + + /** + * The coarseness objectui#4913 already fixed for `toContain` (a numerically-prefixed + * SUPERSET like `≥ 220` passing against a derived `≥ 22`) must not come back now that + * the floor is derived differently. Built off the real anchor's leading integer group + * so this states the exact shape the card's own history section records, rather than + * a made-up number. + */ + it('rejects a `≥ 220`-shaped row as equal to a shorter derived Node floor', () => { + const engine = rootManifest.engines?.node as string; + const staleFloor = engine.match(/(\d+)/)?.[1] as string; + + expect( + statedLiterals(`≥ ${staleFloor}0`), + `"≥ ${staleFloor}0" must not equal "≥ ${staleFloor}" — a numeric-prefix superset is not the ` + + 'same version literal, the objectui#4913 regression this guards', + ).not.toEqual(statedLiterals(`≥ ${staleFloor}`)); + }); + it('states the pnpm floor and the pinned `packageManager`', () => { const engine = rootManifest.engines?.pnpm; expect(engine, 'root package.json must still declare engines.pnpm').toBeDefined(); - const floor = (engine as string).match(/(\d+)/)?.[1]; + const floor = versionFloor(engine as string); const pinned = rootManifest.packageManager; expect(pinned, 'root package.json must still declare packageManager').toBeDefined(); @@ -420,8 +498,8 @@ describe('QUICK_REFERENCE.md "Current Release" — no un-derived literal', () => add(consoleManifest.version); add(rootManifest.devDependencies?.['@objectstack/spec']); add(consoleManifest.devDependencies?.['@objectstack/client']); - add(`≥ ${rootManifest.engines?.node?.match(/(\d+)/)?.[1]}`); - add(`≥ ${rootManifest.engines?.pnpm?.match(/(\d+)/)?.[1]}`); + add(`≥ ${versionFloor(rootManifest.engines?.node ?? '')}`); + add(`≥ ${versionFloor(rootManifest.engines?.pnpm ?? '')}`); add(rootManifest.packageManager?.split('@').pop()); for (const major of peerMajors(reactManifest.peerDependencies?.react ?? '')) { add(`${major}.x`); diff --git a/scripts/sync-quick-reference-release.mjs b/scripts/sync-quick-reference-release.mjs index d4eb8bc84..6cfba4778 100644 --- a/scripts/sync-quick-reference-release.mjs +++ b/scripts/sync-quick-reference-release.mjs @@ -88,8 +88,8 @@ export const normalize = (literal) => literal.replace(/\s+/g, ''); * @property {string} version the one version the `fixed` group carries * @property {string} spec the `@objectstack/spec` range * @property {string} client the `@objectstack/client` range - * @property {string} nodeFloor numeric floor out of root `engines.node` - * @property {string} pnpmFloor numeric floor out of root `engines.pnpm` + * @property {string} nodeFloor version floor out of root `engines.node` + * @property {string} pnpmFloor version floor out of root `engines.pnpm` * @property {string} pnpmPinned the version half of root `packageManager` * @property {string[]} reactMajors the majors named by the react peer range */ @@ -114,6 +114,21 @@ export function peerMajors(range) { .filter((major) => major !== undefined); } +/** + * The floor an `engines`-style range states, as the WHOLE version string — not + * just its leading integer group. + * + * objectui#6313: the previous derivation, `match(/(\d+)/)?.[1]`, kept only the + * leading digits, so `>=22.11` produced a floor of `22` and this script would + * regenerate `≥ 22`, silently discarding the `.11` the anchor actually states. + * Stripping the comparator and keeping the remainder whole means the row this + * script writes and the anchor it reads can no longer disagree at any segment + * the anchor carries. Same derivation as the gate's copy of this function. + */ +export function versionFloor(range) { + return range.match(/^[\^~>=<\s]*(.+)$/)?.[1]; +} + /** * Reads every anchor the block quotes, from the manifests that own them. * @@ -180,11 +195,11 @@ export function readAnchors(root = repoRoot) { ); } - const nodeFloor = rootManifest.engines?.node?.match(/(\d+)/)?.[1]; - const pnpmFloor = rootManifest.engines?.pnpm?.match(/(\d+)/)?.[1]; + const nodeFloor = rootManifest.engines?.node && versionFloor(rootManifest.engines.node); + const pnpmFloor = rootManifest.engines?.pnpm && versionFloor(rootManifest.engines.pnpm); const pnpmPinned = rootManifest.packageManager?.split('@').pop(); - if (!nodeFloor) throw new Error('root package.json must still declare a numeric engines.node floor'); - if (!pnpmFloor) throw new Error('root package.json must still declare a numeric engines.pnpm floor'); + if (!nodeFloor) throw new Error('root package.json must still declare a version-floor engines.node'); + if (!pnpmFloor) throw new Error('root package.json must still declare a version-floor engines.pnpm'); if (!pnpmPinned) throw new Error('root package.json must still declare packageManager'); const peer = reactManifest.peerDependencies?.react;