diff --git a/.changeset/scaffold-pnpm-workspace-boundary-consistency.md b/.changeset/scaffold-pnpm-workspace-boundary-consistency.md new file mode 100644 index 0000000000..18d27ba963 --- /dev/null +++ b/.changeset/scaffold-pnpm-workspace-boundary-consistency.md @@ -0,0 +1,41 @@ +--- +"create-objectstack": patch +--- + +Correct the pnpm boundary the blank template states for `allowBuilds`, and gate +the two scaffold paths against each other (#10498, #10499). + +`packages/create-objectstack/src/templates/blank/pnpm-workspace.yaml` is copied +verbatim into every scaffolded project, so its header comment is prose that +ships **inside the user's own repository**. It said `allowBuilds` needs +pnpm >= 10.31 and that `onlyBuiltDependencies` covers pnpm 10.0–10.30. Measured +on a probe depending on `esbuild@0.28.2`, with a workspace file carrying only +`allowBuilds`, one clean install per pnpm version and each with its own +`--store-dir` (isolation matters — pnpm's side-effects cache will otherwise hand +a later run a build an earlier run performed, and it reads as "the key worked"): + +| pnpm | `allowBuilds` alone | +|:--|:--| +| 10.15.0 – 10.25.0 | ignored — build not run | +| **10.26.0** | **honoured — build ran** | +| 10.28.0 – 10.33.0 | honoured — build ran | + +So the floor is 10.26.0 and the older-key band is 10.0–10.25. A user on pnpm +10.28 was being told by the file in front of them that their pnpm cannot read +the key it is in fact reading. Both load-bearing claims in that comment were +correct and are unchanged: both keys are needed, and pnpm 11 reads only +`allowBuilds`. No setting, no assertion and no install behaviour changes — the +rendered `onlyBuiltDependencies` / `allowBuilds` values are byte-identical. + +The reason it was wrong for so long is the second half of this change. +`objectstack init` renders the same file from `renderPnpmWorkspaceYaml()` in +`packages/cli`, it was corrected to the measured numbers separately, and each +package's ratchets are package-local — so neither could ever fail for the other +file's regression, and the two scaffold paths shipped contradictory prose about +the same rule with every gate green. `packages/cli/test/scaffold-workspace-consistency.test.ts` +now compares the two **rendered outputs**: the packages each key actually grants +a build to, and the pnpm versions each file actually names for each key. It was +confirmed failing against the live divergence before this correction landed. + +Bumped `patch` rather than left out: the corrected text is user-visible — it is +delivered into every new project — while nothing executable moves. diff --git a/packages/cli/test/scaffold-workspace-consistency.test.ts b/packages/cli/test/scaffold-workspace-consistency.test.ts new file mode 100644 index 0000000000..0dfbe09e8a --- /dev/null +++ b/packages/cli/test/scaffold-workspace-consistency.test.ts @@ -0,0 +1,217 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// scaffold-workspace-consistency — the two scaffold paths render a +// `pnpm-workspace.yaml` into a new user's project independently, and this file +// is the only thing that can fail when they disagree (#10499). +// +// ── The shape of the defect ───────────────────────────────────────────────── +// +// Two producers write that file, each stating the same pnpm build-approval rule +// in its own words: +// +// * `renderPnpmWorkspaceYaml()` in `packages/cli/src/commands/init.ts`, for +// `objectstack init` — a string builder, ratcheted by `test/init.test.ts`. +// * `packages/create-objectstack/src/templates/blank/pnpm-workspace.yaml`, +// for `npx create-objectstack` — a literal file copied into the project, +// ratcheted by that package's `src/template-consistency.test.ts`. +// +// Both ratchets are PACKAGE-LOCAL, so neither can fail for the other file's +// regression: `allowBuilds` was added to the template when pnpm 11 turned an +// unapproved build script into a hard error, the renderer was not touched, and +// one of the two scaffold paths went on shipping the pre-fix shape for months +// — found by a first-run audit (#10405), not by a gate. The measured pnpm +// boundary was corrected in the renderer by that fix and NOT in the template, +// which is the second instance of the same class (#10498): a user on pnpm +// 10.28 was told by the file inside their own project that their pnpm cannot +// read the key it is in fact reading, while the sibling scaffold path said the +// opposite. +// +// ── ⚠️ The assertion this file must NOT make ──────────────────────────────── +// +// "Both files mention `allowBuilds`" passes while the two contradict each +// other — it passed on `main` throughout the divergence above. An assertion +// that green-lights the live defect is worse than no gate, because it certifies +// the state the gate exists to catch. So what is compared here is the RENDERED +// OUTPUT of each producer: the packages each one actually grants a build, and +// the pnpm versions each one actually names for each key. Neither file's +// expected content is restated below — every expected value comes from the +// OTHER producer, so this file measures the two against each other rather than +// against a transcription that stops tracking either of them. +// +// ── Why this file lives in `packages/cli` ─────────────────────────────────── +// +// The CLI side must be CALLED rather than text-parsed (it is a string builder; +// parsing its source would measure the source, not the render), and only this +// package can call it. The template side is a static file, so reading it IS +// reading its producer. `packages/cli` already depends on `create-objectstack` +// (`workspace:*`, for the shared `created-summary` renderer), so the read below +// introduces no dependency edge in either direction — and no shared module: the +// two producers stay independent, this file just makes their disagreement +// loud. The read escapes this package, so it is declared in +// `scripts/check-cross-package-test-inputs.mjs` and in turbo.json's +// `@objectstack/cli#test` inputs; without that declaration a template-only diff +// could not reach this suite and its cache would replay a stale green. +// +// ⛔ `peerDependencyRules` is deliberately NOT compared here. The peer-warning +// skew between the two files is #10931, open on this same surface; a limb +// added here would either duplicate that card or pre-empt its ruling. + +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +// `.js`, not extensionless: this package is `moduleResolution: NodeNext`, where a +// relative import without the extension does not resolve — every symbol it names +// becomes `any` (TS2835 + a TS7006 cascade). packages/cli/test is a HIDDEN +// typecheck layer (tsconfig `include` is `src` only) held by a shrink-only +// ledger in scripts/check-type-check-coverage.mjs, so an extensionless import +// here raises that count and reddens check:type-check-debt for everyone. +import { renderPnpmWorkspaceYaml } from '../src/commands/init.js'; + +const HERE = resolve(fileURLToPath(import.meta.url), '..'); + +// One line on purpose: `check:cross-package-test-inputs` reconstructs this read +// by SOURCE SCAN, and a `resolve(HERE, …)` split across lines is a spelling it +// does not recognise — which would leave the glob declared and held by nothing. +const TEMPLATE_WORKSPACE_YAML = resolve(HERE, '../../create-objectstack/src/templates/blank/pnpm-workspace.yaml'); + +/** The rendered output of each scaffold path, keyed by the command a user runs. */ +const RENDERED = { + 'objectstack init': renderPnpmWorkspaceYaml(), + 'npx create-objectstack': readFileSync(TEMPLATE_WORKSPACE_YAML, 'utf8'), +} as const; + +type Producer = keyof typeof RENDERED; +const [CLI, TEMPLATE] = Object.keys(RENDERED) as [Producer, Producer]; + +/** The two keys that grant a dependency's build script permission to run. */ +const APPROVAL_KEYS = ['allowBuilds', 'onlyBuiltDependencies'] as const; + +/** + * The packages each key actually grants a build to, read out of the settings + * with the prose stripped first — the comments below each key NAME these + * packages, and must never be what satisfies an assertion about the grant. + */ +function grantedBuilds(yaml: string): Record<(typeof APPROVAL_KEYS)[number], string[]> { + const settings = yaml.replace(/^\s*#.*$/gm, ''); + const mapping = /^allowBuilds:\n((?:[ \t]+.*\n?)*)/m.exec(settings)?.[1] ?? ''; + const list = /^onlyBuiltDependencies:\n((?:[ \t]*-.*\n?)*)/m.exec(settings)?.[1] ?? ''; + return { + allowBuilds: [...mapping.matchAll(/^[ \t]+([^\s:]+):[ \t]*true[ \t]*$/gm)] + .map((m) => m[1]) + .sort(), + onlyBuiltDependencies: [...list.matchAll(/^[ \t]*-[ \t]*(\S+)[ \t]*$/gm)] + .map((m) => m[1]) + .sort(), + }; +} + +/** + * The prose each file attaches to each approval key, as one string per key. + * + * Both files write it as a definition list inside the header comment — the key + * at the start of a comment line, its explanation column-aligned after it, and + * continuation lines indented under that. The two wordings differ and are meant + * to; only the VERSION CLAIMS inside them are compared. + */ +function keyProse(yaml: string): Map { + const prose = new Map(); + let current: string | null = null; + for (const line of yaml.split('\n')) { + const definition = /^#[ \t]{2,}(allowBuilds|onlyBuiltDependencies)[ \t]{2,}(\S.*)$/.exec(line); + if (definition) { + current = definition[1]; + prose.set(current, definition[2].trim()); + continue; + } + // A continuation is an indented comment line that starts no new key; a bare + // `#` (or anything that is not an indented comment) closes the entry. + const continuation = current === null ? null : /^#[ \t]{2,}(\S.*)$/.exec(line); + if (continuation) { + prose.set(current!, `${prose.get(current!)} ${continuation[1].trim()}`); + continue; + } + current = null; + } + return prose; +} + +/** + * Every pnpm version a piece of prose names, in the order it names them. + * + * Dotted only: `10.26`, `10.0`, `11.22.0` are boundary CLAIMS, while the bare + * majors both files use in passing ("pnpm 11 reads ONLY this one") are prose, + * and so is the `1` in "exits 1". + */ +function versionsNamed(prose: string): string[] { + return [...prose.matchAll(/\d+\.\d+(?:\.\d+)?/g)].map((m) => m[0]); +} + +describe('the two scaffold paths render the same pnpm build approvals (#10499)', () => { + it('grants exactly the same packages a build, under both keys', () => { + const cli = grantedBuilds(RENDERED[CLI]); + const template = grantedBuilds(RENDERED[TEMPLATE]); + + // Non-vacuity: an empty grant on both sides would compare equal while + // approving nothing, which is the shape that fails a user's first install. + for (const [producer, granted] of [[CLI, cli], [TEMPLATE, template]] as const) { + for (const key of APPROVAL_KEYS) { + expect( + granted[key].length, + `${producer} renders no package under \`${key}\` — a scaffolded project's ` + + 'first `pnpm install` fails on pnpm 11 with ERR_PNPM_IGNORED_BUILDS', + ).toBeGreaterThan(0); + } + } + + for (const key of APPROVAL_KEYS) { + expect( + cli[key], + `\`${key}\` grants a different build set in the two scaffold paths: ` + + `${CLI} approves [${cli[key].join(', ')}] and ${TEMPLATE} approves ` + + `[${template[key].join(', ')}]. Both write a pnpm-workspace.yaml into a new ` + + 'user\'s project and neither package\'s own tests can see the other, so a ' + + 'divergence here ships to whichever half of users took the other path.', + ).toEqual(template[key]); + } + }); + + it('states the same pnpm version boundary for each key', () => { + const cli = keyProse(RENDERED[CLI]); + const template = keyProse(RENDERED[TEMPLATE]); + + expect( + [...cli.keys()].sort(), + 'the two scaffold paths explain a different set of build-approval keys', + ).toEqual([...template.keys()].sort()); + + for (const key of APPROVAL_KEYS) { + const claimed = { + [CLI]: versionsNamed(cli.get(key) ?? ''), + [TEMPLATE]: versionsNamed(template.get(key) ?? ''), + }; + + // Non-vacuity again: prose naming no version at all would compare equal + // between the two files while telling the reader nothing, and this whole + // block would pass over a boundary nobody states. + for (const producer of [CLI, TEMPLATE] as const) { + expect( + claimed[producer].length, + `${producer} states no pnpm version for \`${key}\` — the boundary is what the ` + + 'reader of a scaffolded project needs, and an unstated one cannot be kept ' + + 'in step with the other scaffold path', + ).toBeGreaterThan(0); + } + + expect( + claimed[CLI], + `the two scaffold paths tell a user different things about which pnpm reads ` + + `\`${key}\`: ${CLI} names [${claimed[CLI].join(', ')}] and ${TEMPLATE} names ` + + `[${claimed[TEMPLATE].join(', ')}]. Both files ship into a user's own project, ` + + 'so one of them is telling that user their pnpm cannot read a key it is ' + + 'reading. The measured boundary is the one to move TO — never move a correct ' + + 'file to match a wrong one.', + ).toEqual(claimed[TEMPLATE]); + } + }); +}); diff --git a/packages/create-objectstack/src/template-consistency.test.ts b/packages/create-objectstack/src/template-consistency.test.ts index 2ae47f3014..d9cc196217 100644 --- a/packages/create-objectstack/src/template-consistency.test.ts +++ b/packages/create-objectstack/src/template-consistency.test.ts @@ -435,7 +435,7 @@ describe('blank template explicit empty workspace', () => { // hard error, so the template declaring nothing meant `npx create-objectstack` // + `pnpm install` exited 1 for every user on a current pnpm (#3119). Both keys // are load-bearing and read by different pnpm versions: pnpm 11 honours only -// `allowBuilds`, while pnpm 10.0–10.30 understand only `onlyBuiltDependencies`. +// `allowBuilds`, while pnpm 10.0–10.25 understand only `onlyBuiltDependencies`. describe('blank template pnpm build approvals (#3119)', () => { const wsPath = path.join(pkgRoot, 'src', 'templates', 'blank', 'pnpm-workspace.yaml'); const APPROVED = ['better-sqlite3', 'esbuild']; @@ -463,12 +463,12 @@ describe('blank template pnpm build approvals (#3119)', () => { } }); - it('lists the same packages under onlyBuiltDependencies for pnpm 10.0–10.30', () => { + it('lists the same packages under onlyBuiltDependencies for pnpm 10.0–10.25', () => { const block = /^onlyBuiltDependencies:\n((?:[ \t]*-.*\n?)*)/m.exec(settings)?.[1] ?? ''; for (const pkg of APPROVED) { expect( new RegExp(`^\\s*-\\s*${pkg}\\s*$`, 'm').test(block), - `onlyBuiltDependencies must list "${pkg}" — pnpm < 10.31 does not understand allowBuilds`, + `onlyBuiltDependencies must list "${pkg}" — pnpm < 10.26 does not understand allowBuilds`, ).toBe(true); } }); diff --git a/packages/create-objectstack/src/templates/blank/pnpm-workspace.yaml b/packages/create-objectstack/src/templates/blank/pnpm-workspace.yaml index 1cc1696274..9b9058ad73 100644 --- a/packages/create-objectstack/src/templates/blank/pnpm-workspace.yaml +++ b/packages/create-objectstack/src/templates/blank/pnpm-workspace.yaml @@ -12,9 +12,9 @@ packages: [] # ERR_PNPM_IGNORED_BUILDS — pnpm 10 only warned, pnpm 11 made it a hard error. # # Both keys are needed; they are read by different pnpm versions: -# allowBuilds pnpm >= 10.31 and pnpm 11+. pnpm 11 reads ONLY this +# allowBuilds pnpm >= 10.26 and pnpm 11+. pnpm 11 reads ONLY this # one — onlyBuiltDependencies alone still errors. -# onlyBuiltDependencies pnpm 10.0–10.30, which do not understand allowBuilds. +# onlyBuiltDependencies pnpm 10.0–10.25, which do not understand allowBuilds. # # better-sqlite3 is the native sqlite driver (@objectstack/driver-sql's optional # dependency); esbuild compiles objectstack.config.ts. Both ship prebuilt diff --git a/scripts/check-cross-package-test-inputs.mjs b/scripts/check-cross-package-test-inputs.mjs index 60fbf72624..d35f909b2b 100644 --- a/scripts/check-cross-package-test-inputs.mjs +++ b/scripts/check-cross-package-test-inputs.mjs @@ -384,6 +384,13 @@ export const CROSS_PACKAGE_TEST_INPUTS = { 'content/docs/deployment/index.mdx', 'content/docs/permissions/authentication.mdx', 'scripts/check-nul-bytes.mjs', + // This gate's OWN script, the third entry of the mention shape on this + // package: test/scaffold-workspace-consistency.test.ts quotes it while + // explaining where its cross-package read is declared. Settled the way + // check-nul-bytes.mjs above is — the literal collector takes quoted paths + // without parsing, so a mention forces a declaration, and declaring one + // rarely-touched file is cheaper than rewording prose to dodge a scanner. + 'scripts/check-cross-package-test-inputs.mjs', 'scripts/js-comment-mask.mjs', 'scripts/js-comment-mask.d.mts', // `translation.zod.ts` is the second entry no test READS -- named in a @@ -398,6 +405,22 @@ export const CROSS_PACKAGE_TEST_INPUTS = { // `@objectstack/spec` is a real dependency of this package, so the graph // already re-runs these tests on any spec change. 'packages/spec/src/system/translation.zod.ts', + // The blank template's rendered `pnpm-workspace.yaml`, READ by + // test/scaffold-workspace-consistency.test.ts (#10499). Two scaffold + // paths write that file into a new user's project — this package's + // `renderPnpmWorkspaceYaml()` and create-objectstack's literal + // template — and each package's own ratchets are package-local, so + // neither could ever fail for the other's regression. The consistency + // test compares the two RENDERED outputs, which makes the template file + // a real input to this package's verdict: a template-only diff changes + // what that test measures. Without this declaration such a diff reaches + // neither layer — `turbo ls --affected` would still pick cli up (it + // depends on create-objectstack for the shared `created-summary` + // renderer), but `@objectstack/cli#test` would hash the same and replay + // a cached green over the divergence, which is #7802's Layer B exactly. + // One file, not `packages/create-objectstack/**`: the test reads that + // template and nothing else across the boundary. + 'packages/create-objectstack/src/templates/blank/pnpm-workspace.yaml', ], }, '@objectstack/client': { diff --git a/turbo.json b/turbo.json index 667339bcae..6a5451b8d7 100644 --- a/turbo.json +++ b/turbo.json @@ -75,7 +75,9 @@ "$TURBO_ROOT$/scripts/check-nul-bytes.mjs", "$TURBO_ROOT$/scripts/js-comment-mask.mjs", "$TURBO_ROOT$/scripts/js-comment-mask.d.mts", - "$TURBO_ROOT$/packages/spec/src/system/translation.zod.ts" + "$TURBO_ROOT$/packages/spec/src/system/translation.zod.ts", + "$TURBO_ROOT$/scripts/check-cross-package-test-inputs.mjs", + "$TURBO_ROOT$/packages/create-objectstack/src/templates/blank/pnpm-workspace.yaml" ] }, "@objectstack/client#test": {