Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .changeset/scaffold-pnpm-workspace-boundary-consistency.md
Original file line numberDiff line numberDiff line change
@@ -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.
217 changes: 217 additions & 0 deletions packages/cli/test/scaffold-workspace-consistency.test.ts
Original file line numberDiff line numberDiff line change
@@ -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<string, string> {
const prose = new Map<string, string>();
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]);
}
});
});
6 changes: 3 additions & 3 deletions packages/create-objectstack/src/template-consistency.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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'];
Expand DownExpand Up@@ -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);
}
});
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
23 changes: 23 additions & 0 deletions scripts/check-cross-package-test-inputs.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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': {
Expand Down
4 changes: 3 additions & 1 deletion turbo.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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": {
Expand Down
Loading