From 3f96816841bf43e75cd89540464ac2a275dd1fe9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 13:33:13 +0000 Subject: [PATCH] fix(create-objectstack): template baseline pins @objectstack/* exactly; specVersion stays a range scripts/sync-template-versions.mjs now stamps the scaffolder's exact version (prereleases verbatim) into every bundled template's @objectstack/* dependencies, and keeps the manifest's specVersion at the major's range. The two values were one before, so the ratchet compared them for equality; it now holds the deps at the exact version and specVersion at the range the pinned spec dependency's major implies. The self-test gains two batteries: a prerelease corpus judged clean at its exact version with specVersion still a range, and a scaffolder version that is not a whole semver refused with nothing written. The version read is anchored at both ends, since it is now written verbatim. The blank template is restamped by the script itself, and the stale caret comments in index.ts and runtime-image.ts are corrected. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01GcPmaQ9wUnUUM3ATKkiCes --- .../25510-template-baseline-exact-pins.md | 14 ++ packages/create-objectstack/src/index.ts | 18 +- .../create-objectstack/src/runtime-image.ts | 19 +- .../src/template-consistency.test.ts | 48 +++-- .../src/template-version-stamps.test.ts | 64 ++++++- .../src/templates/blank/package.json | 18 +- scripts/sync-template-versions.mjs | 165 ++++++++++++++---- 7 files changed, 261 insertions(+), 85 deletions(-) create mode 100644 .changeset/25510-template-baseline-exact-pins.md diff --git a/.changeset/25510-template-baseline-exact-pins.md b/.changeset/25510-template-baseline-exact-pins.md new file mode 100644 index 00000000000..b32fad5058e --- /dev/null +++ b/.changeset/25510-template-baseline-exact-pins.md @@ -0,0 +1,14 @@ +--- +'create-objectstack': patch +--- + +fix(create-objectstack): the bundled blank template pins every `@objectstack/*` dependency at the exact release version + +Clause-②: no +Metadata-protocol: none + +The `blank` template's committed `package.json` now lists each `@objectstack/*` package at the exact version of the `create-objectstack` release that ships it (`18.1.0`, a prerelease verbatim), instead of `^18.0.0`. A scaffolded project already received exact pins, because the scaffolder rewrites these entries at scaffold time; the template a reader opens in the tarball or on GitHub now says the same thing, which is what the v18 docs prescribe ("Pin exact versions"). + +The template manifest's `specVersion` stays a range (`^18.0.0`): it names the `@objectstack/spec` line the template is authored against, not a build. The release-time version pass keeps both values current — the exact version in the dependencies, the major's range in `specVersion`. + +No action is needed in an existing project. diff --git a/packages/create-objectstack/src/index.ts b/packages/create-objectstack/src/index.ts index a76503cfe4e..85a862be374 100644 --- a/packages/create-objectstack/src/index.ts +++ b/packages/create-objectstack/src/index.ts @@ -33,9 +33,10 @@ * validate` already omits the description line entirely when the field is * absent, so this is a clean landing rather than a placeholder. * - * Then we run ` install` — and only afterwards can the Dockerfile's runtime - * image tag be pinned, because the template carries a caret range and the tag - * has to name the @objectstack/cli version npm actually resolved (#9017). With + * Then we run ` install` — and only afterwards is the Dockerfile's runtime + * image tag pinned, to the @objectstack/cli version npm actually resolved + * (#9017). The generated package.json pins that version exactly, but the + * installed copy is what builds the artifact, so it is the one read. With * `--skip-install` there is no resolved version, so the template keeps `latest` * and its comment keeps telling the reader to pin by hand — true in that path. * @@ -173,10 +174,10 @@ function rewriteProjectIdentity( const templateNamespace = readTemplateNamespace(targetDir); // package.json — set .name and pin @objectstack/* deps to this scaffolder's - // own release line. All @objectstack packages (including create-objectstack) - // version in lockstep, so `^` always resolves and always matches + // own exact version. All @objectstack packages (including create-objectstack) + // version in lockstep, so `` always resolves and always matches // the framework the docs describe. Without this, a template whose literal - // ranges have gone stale scaffolds a project several majors behind the + // pins have gone stale scaffolds a project several majors behind the // published framework (the `^6.0.0`-era templates installed 6.x while the // registry was at 14.x). const pkgPath = path.join(targetDir, 'package.json'); @@ -582,8 +583,9 @@ const program = new Command() } // Pin the Dockerfile's runtime image to the CLI that will build this - // project's artifact — knowable only now, because the template pins a - // caret RANGE and npm has just resolved it (#9017). Skipped without an + // project's artifact — the copy npm has just installed (#9017). The + // generated package.json pins it exactly, but the install is what + // builds the artifact, so the tag follows it. Skipped without an // install: with no node_modules there is no resolved version, and the // template's own comment then correctly tells the user to pin by hand. if (installed) { diff --git a/packages/create-objectstack/src/runtime-image.ts b/packages/create-objectstack/src/runtime-image.ts index 0c4f045e2bf..d07efbdc19e 100644 --- a/packages/create-objectstack/src/runtime-image.ts +++ b/packages/create-objectstack/src/runtime-image.ts @@ -16,16 +16,13 @@ // comment and the published tag guidance. // // The tag is read from the INSTALLED cli — `node_modules/@objectstack/cli`'s -// own version — and not from the generated `package.json`, because that file -// carries a caret RANGE (`^17.0.0`, written by syncObjectStackDeps). The two -// are not interchangeable: -// -// - npm resolves `^17.0.0` to the newest 17.x, so the artifact is built by -// (say) 17.0.5 while the range's floor is 17.0.0. Pinning the floor would -// ship a runtime image OLDER than the CLI that built the artifact — the -// exact promise the comment makes, broken in a new way. -// - The rolling `:17` tag does match the range's float window, but it is a -// rolling tag, which is what `docker/README.md` tells production not to use. +// own version — and not from the generated `package.json`. That file now pins +// the exact version (written by syncObjectStackDeps), so on a clean install the +// two agree; the installed copy is still the one that builds the artifact, and +// it is the only value that stays true when they do not (an override, a hand +// edit, a package manager that resolved something else). The rolling `:17` tag +// is never the answer: it is a rolling tag, which is what `docker/README.md` +// tells production not to use. // // The resolved version is the only value that makes the sentence true, and it // is the same rule the repo already applies for the same reason in @@ -41,7 +38,7 @@ // and correct either way — only the tag is less precise — so aborting a // finished scaffold would be disproportionate. The real guard against a silent // no-op is `runtime-image.test.ts`, which asserts on SCAFFOLDED OUTPUT: the -// emitted Dockerfile's `FROM` tag against the emitted `package.json`'s range. +// emitted Dockerfile's `FROM` tag against the emitted `package.json`'s pin. // A template edit that moves the anchor turns that test red in CI. import fs from 'node:fs'; diff --git a/packages/create-objectstack/src/template-consistency.test.ts b/packages/create-objectstack/src/template-consistency.test.ts index 64282300c15..39e8e33f729 100644 --- a/packages/create-objectstack/src/template-consistency.test.ts +++ b/packages/create-objectstack/src/template-consistency.test.ts @@ -89,7 +89,9 @@ const REPO_READ_ENV: NodeJS.ProcessEnv = gitFreeEnv(); // // Every bundled template declares the platform it targets in THREE places, and // each one is committed to git, shipped in the tarball and copied into every -// scaffolded project. `scripts/sync-template-versions.mjs` re-stamps all three +// scaffolded project: the `@objectstack/*` dependencies at the scaffolder's +// EXACT version, `specVersion` at its major's range, and `engines.protocol` at +// its protocol major. `scripts/sync-template-versions.mjs` re-stamps all three // at version time; these ratchets are the CI half, because that script runs on // a changesets/action release PR that gets no CI at all. // @@ -121,22 +123,25 @@ describe('bundled template declared version surfaces', () => { const readTemplateFile = (name: string) => fs.readFileSync(path.join(templateDir, name), 'utf8'); - it('package.json pins every @objectstack/* dep to the current major', () => { + // Exact, not a range: on the v18 line a breaking change may ship in a minor + // or a patch, the docs tell a project to pin exact versions, and the + // scaffold writes exact pins (`syncObjectStackDeps`). The committed + // baseline says the same thing the scaffold writes. + it("package.json pins every @objectstack/* dep to create-objectstack's exact version", () => { const templatePkg = JSON.parse(readTemplateFile('package.json')); const allDeps = { ...templatePkg.dependencies, ...templatePkg.devDependencies }; const stackDeps = Object.entries(allDeps).filter(([name]) => name.startsWith('@objectstack/'), ); expect(stackDeps.length).toBeGreaterThan(0); - for (const [name, range] of stackDeps) { - const match = /^\^(\d+)\./.exec(String(range)); - expect(match, `${name} range "${range}" must be ^.x`).not.toBeNull(); + for (const [name, pin] of stackDeps) { expect( - Number(match![1]), - `${name} pins ^${match![1]}.x but create-objectstack is v${ownMajor} — ` + - 'bump the template with the release (scaffold-time sync only fixes ' + - 'generated projects, not this committed baseline)', - ).toBe(ownMajor); + pin, + `${template}/package.json pins ${name} at "${String(pin)}" but create-objectstack is ` + + `${ownPkg.version} — every @objectstack/* dep is the exact version, no ^ or ~; ` + + 'scripts/sync-template-versions.mjs re-stamps it at version time (scaffold-time ' + + 'sync only fixes generated projects, not this committed baseline)', + ).toBe(ownPkg.version); } }); @@ -296,12 +301,14 @@ describe('bundled template declared version surfaces', () => { ).toBe(ownMajor); }); - // The invariant that makes the two files one fact rather than two: the - // manifest's declared spec range and the dependency a scaffolded project - // actually installs must agree. Either alone can be self-consistently - // stale; only comparing them catches a stamp that covered one and not the - // other, which is the exact failure this card is about. - it('specVersion agrees with the @objectstack/spec dependency the template installs', () => { + // The invariant that ties the two files together: the manifest's declared + // spec range and the dependency a scaffolded project actually installs + // must name the same line. Either alone can be self-consistently stale; + // only comparing them catches a stamp that covered one and not the other, + // which is the exact failure this card is about. They are no longer EQUAL + // — the dependency is an exact version, `specVersion` a range — so what is + // compared is the range the exact dependency's major implies. + it('specVersion is the range of the @objectstack/spec dependency the template installs', () => { const manifest = JSON.parse(readTemplateFile('objectstack.manifest.json')); const templatePkg = JSON.parse(readTemplateFile('package.json')); const specDep = @@ -313,11 +320,16 @@ describe('bundled template declared version surfaces', () => { `${template}/package.json must depend on @objectstack/spec for its manifest's ` + 'specVersion to be checkable against something', ).toBeDefined(); + const depMajor = /^(\d+)\.\d+\.\d+/.exec(String(specDep)); + expect( + depMajor, + `${template}/package.json pins @objectstack/spec at "${String(specDep)}", which is not an exact version`, + ).not.toBeNull(); expect( manifest.specVersion, `${template} declares specVersion "${manifest.specVersion}" but installs ` + - `@objectstack/spec "${specDep}" — one fact written twice, and they disagree`, - ).toBe(specDep); + `@objectstack/spec "${String(specDep)}" — the range must be ^${depMajor![1]}.0.0, the line the pin is on`, + ).toBe(`^${depMajor![1]}.0.0`); }); }); }); diff --git a/packages/create-objectstack/src/template-version-stamps.test.ts b/packages/create-objectstack/src/template-version-stamps.test.ts index 48ba146ed36..88d1831192f 100644 --- a/packages/create-objectstack/src/template-version-stamps.test.ts +++ b/packages/create-objectstack/src/template-version-stamps.test.ts @@ -107,7 +107,7 @@ const writeFixtureFile = (file: string, content: string) => { * resolve its repo root to the real checkout instead of this fixture — which is * the very thing the copy exists to prevent. */ -const copyWithLocalImports = (absFile: string, seen = new Set()): void => { +const copyWithLocalImports = (absFile: string, seen = new Set(), into = fixture): void => { if (!fs.existsSync(absFile)) return; const real = fs.realpathSync(absFile); if (seen.has(real)) return; @@ -118,13 +118,13 @@ const copyWithLocalImports = (absFile: string, seen = new Set()): void = if (rel.startsWith('..') || path.isAbsolute(rel)) return; const source = fs.readFileSync(real, 'utf8'); - writeFixtureFile(path.join(fixture, rel), source); + writeFixtureFile(path.join(into, rel), source); const specifiers = [ ...source.matchAll(/\bfrom\s+'(\.[^']*)'/g), ...source.matchAll(/\bimport\s*\(\s*'(\.[^']*)'/g), ]; - for (const m of specifiers) copyWithLocalImports(path.resolve(path.dirname(real), m[1]), seen); + for (const m of specifiers) copyWithLocalImports(path.resolve(path.dirname(real), m[1]), seen, into); }; beforeAll(() => { @@ -241,9 +241,9 @@ describe('the entry-point guard leaves the CLI path working', () => { const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')); expect( pkg.dependencies['@objectstack/spec'], - `${template}/package.json @objectstack/* ranges move to the scaffolder's major`, - ).toBe('^42.0.0'); - expect(pkg.devDependencies['@objectstack/cli']).toBe('^42.0.0'); + `${template}/package.json @objectstack/* deps move to the scaffolder's EXACT version`, + ).toBe(FIXTURE_VERSION); + expect(pkg.devDependencies['@objectstack/cli']).toBe(FIXTURE_VERSION); expect( pkg.dependencies.chalk, 'a non-@objectstack dependency is never touched', @@ -254,7 +254,10 @@ describe('the entry-point guard leaves the CLI path working', () => { ); const manifest = fs.readFileSync(path.join(dir, 'objectstack.manifest.json'), 'utf8'); - expect(manifest).toContain('"specVersion": "^42.0.0"'); + expect( + manifest, + 'specVersion stays a RANGE while the deps beside it become exact', + ).toContain('"specVersion": "^42.0.0"'); expect( manifest, 'the manifest is rewritten as TEXT, so unrelated compact structure survives', @@ -266,6 +269,53 @@ describe('the entry-point guard leaves the CLI path working', () => { expect(stdout).toContain('2 template(s) in lockstep with create-objectstack@42.0.0'); expect(stdout).toContain('second/objectstack.manifest.json'); }); + + it('stamps a PRERELEASE verbatim into the deps, and only its major into specVersion', () => { + // A release-candidate cut (`cut-rc.yml`) runs this same pass at a version + // like `19.0.0-rc.0`. The deps take it verbatim, because a scaffold of the + // rc must install the rc; `specVersion` keeps the major's range, because it + // names the spec line the template is authored against, not a build. + const prerelease = '43.0.0-rc.2'; + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sync-template-versions-rc-')); + try { + const script = path.join(dir, 'scripts', 'sync-template-versions.mjs'); + copyWithLocalImports(SYNC_SCRIPT, new Set(), dir); + writeFixtureFile( + path.join(dir, 'packages', 'create-objectstack', 'package.json'), + JSON.stringify({ name: 'create-objectstack', version: prerelease }, null, 2) + '\n', + ); + const templateDir = path.join(dir, 'packages', 'create-objectstack', 'src', 'templates', 'blank'); + writeFixtureFile( + path.join(templateDir, 'package.json'), + JSON.stringify( + { name: 'template-blank', dependencies: { '@objectstack/spec': `^${STALE_MAJOR}.0.0` } }, + null, + 2, + ) + '\n', + ); + writeFixtureFile( + path.join(templateDir, 'objectstack.config.ts'), + `export default defineStack({ manifest: { engines: { protocol: '^${STALE_MAJOR}' } } });\n`, + ); + writeFixtureFile( + path.join(templateDir, 'objectstack.manifest.json'), + `{\n "specVersion": "^${STALE_MAJOR}.0.0",\n "scaffold": { "variables": [] }\n}\n`, + ); + + execFileSync(process.execPath, [script], { encoding: 'utf8' }); + + const pkg = JSON.parse(fs.readFileSync(path.join(templateDir, 'package.json'), 'utf8')); + expect(pkg.dependencies['@objectstack/spec']).toBe(prerelease); + expect(fs.readFileSync(path.join(templateDir, 'objectstack.manifest.json'), 'utf8')).toContain( + '"specVersion": "^43.0.0"', + ); + expect(fs.readFileSync(path.join(templateDir, 'objectstack.config.ts'), 'utf8')).toContain( + "engines: { protocol: '^43' }", + ); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); }); // ── stampedPaths() is derived from the walk, never a restated list ────────── diff --git a/packages/create-objectstack/src/templates/blank/package.json b/packages/create-objectstack/src/templates/blank/package.json index 0e046c0e938..56a675703b6 100644 --- a/packages/create-objectstack/src/templates/blank/package.json +++ b/packages/create-objectstack/src/templates/blank/package.json @@ -16,17 +16,17 @@ "test": "vitest run" }, "dependencies": { - "@objectstack/spec": "^18.0.0", - "@objectstack/runtime": "^18.0.0", - "@objectstack/plugin-hono-server": "^18.0.0", - "@objectstack/connector-rest": "^18.0.0", - "@objectstack/connector-openapi": "^18.0.0", - "@objectstack/connector-mcp": "^18.0.0" + "@objectstack/spec": "18.1.0", + "@objectstack/runtime": "18.1.0", + "@objectstack/plugin-hono-server": "18.1.0", + "@objectstack/connector-rest": "18.1.0", + "@objectstack/connector-openapi": "18.1.0", + "@objectstack/connector-mcp": "18.1.0" }, "devDependencies": { - "@objectstack/cli": "^18.0.0", - "@objectstack/skills": "^18.0.0", - "@objectstack/verify": "^18.0.0", + "@objectstack/cli": "18.1.0", + "@objectstack/skills": "18.1.0", + "@objectstack/verify": "18.1.0", "typescript": "^5.3.0", "vitest": "~4.1.12" }, diff --git a/scripts/sync-template-versions.mjs b/scripts/sync-template-versions.mjs index f7704db8f4b..32c2123d3fa 100644 --- a/scripts/sync-template-versions.mjs +++ b/scripts/sync-template-versions.mjs @@ -35,15 +35,29 @@ // skip. A silent skip is indistinguishable from a green run, which is // precisely the invisibility this card was filed about. // -// TWO VALUES, TWO MEANINGS — do not collapse them. `engines.protocol` is the -// ADR-0087 D1 runtime handshake range and carries the PROTOCOL major (`^17`). -// `specVersion` is documented by TemplateManifestSchema as the "Compatible -// @objectstack/spec semver range" and carries the PACKAGE range (`^17.0.0`) — -// the same value this script writes into the template's own -// `@objectstack/spec` dependency, so the manifest and the package.json state -// one fact once. They agree on the major today only because the spec package's -// major and the protocol major are kept in lockstep; they are still two -// different declarations and are stamped from two different values. +// THREE VALUES, THREE MEANINGS — do not collapse them. +// +// * `engines.protocol` is the ADR-0087 D1 runtime handshake range and +// carries the PROTOCOL major (`^18`). +// * `specVersion` is documented by TemplateManifestSchema as the "Compatible +// @objectstack/spec semver range" and carries the PACKAGE range +// (`^18.0.0`). It states which spec line the template is authored +// against, so it stays a RANGE. +// * every `@objectstack/*` dependency carries the scaffolder's EXACT version +// (`18.1.0`, a prerelease such as `19.0.0-rc.0` verbatim). A breaking +// change on the v18 line may ship in a minor or a patch, so the docs tell a +// project to pin exact versions (`content/docs/releases/v18/index.mdx`), +// and the committed baseline says what the scaffold writes +// (`syncObjectStackDeps` in `create-objectstack/src/pkg-utils.ts`). The +// exact version is always published for every one of these names: they +// all sit in the changesets `fixed` group with `create-objectstack`. +// +// Deps and `specVersion` used to be one value, which is why the ratchet +// compared them for equality. They now agree on the MAJOR — the exact spec +// dependency lies on the line `specVersion` names — and that is what +// `template-consistency.test.ts` holds. `engines.protocol` and `specVersion` +// agree on the major only because the spec package's major and the protocol +// major are kept in lockstep; they are still different declarations. // // ## THE DECLARATIONS ARE EXPORTED, AND NOTHING RUNS ON IMPORT (#9554) // @@ -80,8 +94,8 @@ // // `stampedPaths()` reports all THREE per-template surfaces, not just the two // text stamps. A consumer asking which files the version pass writes is asking -// about `package.json` too — it carries the `@objectstack/*` ranges this script -// rewrites — and an export that answered the narrower question while being +// about `package.json` too — it carries the `@objectstack/*` versions this +// script rewrites — and an export that answered the narrower question while being // named for the wider one would seed the next restatement. import { @@ -125,11 +139,13 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'Control E: a stamp FILE that does not exist exits 1 naming it': 2, 'Control F: an unparseable template package.json exits 1 naming it': 2, 'Control H: zero templates refuses a vacuous green': 3, + 'Control I: a PRERELEASE corpus is clean at its exact version, with specVersion still a range': 10, + 'Control J: a scaffolder version that is not a whole semver exits 1 and writes nothing': 3, }); // DELETING an entry silences that battery's floor exactly as effectively as // zeroing it, so the roster's own size is pinned too. -const SELF_TEST_BATTERY_FLOOR = 8; +const SELF_TEST_BATTERY_FLOOR = 10; // The key an assertion is filed under when no battery is open. It is not a // declared battery, so it reds by the same set difference rather than silently @@ -191,7 +207,7 @@ export const TEXT_STAMPS = [ // `create-objectstack` copies it verbatim into every scaffolded project // (it rewrites name/displayName/namespace and drops description, and has // never touched this key), so a stale value ships to real users. Carries - // the PACKAGE range. + // the PACKAGE range — a range even though the deps beside it are exact. pattern: /("specVersion"\s*:\s*)"[^"]*"/, value: ({ range }) => range, replacement: ({ range }) => `$1"${range}"`, @@ -200,7 +216,7 @@ export const TEXT_STAMPS = [ /** * The per-template file that is stamped by DEPENDENCY REWRITE rather than by a - * text stamp: every `@objectstack/*` range in it moves to the package range. + * text stamp: every `@objectstack/*` entry in it moves to the exact version. * Named here rather than inline so `stampedPaths()` and `main()` cannot drift. */ export const TEMPLATE_PKG_FILE = 'package.json'; @@ -249,7 +265,14 @@ export function stampedPaths({ root: base = root } = {}) { } /** - * The scaffolder's version, and the two values stamped from it. + * A whole semver version, prerelease allowed, build metadata refused. Anchored + * at BOTH ends: the version is written verbatim into every `@objectstack/*` + * dependency, so a prefix match would stamp `18.1.0garbage` as a pin. + */ +const EXACT_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; + +/** + * The scaffolder's version, and the values stamped from it. * * THROWS on an unparseable version rather than exiting: this module is * importable (#9554), and a library call that kills the host process is not a @@ -259,14 +282,15 @@ export function stampedPaths({ root: base = root } = {}) { */ export function loadScaffolderVersion(file = join(root, VERSION_SOURCE)) { const version = JSON.parse(readFileSync(file, 'utf8')).version; - if (!/^\d+\.\d+\.\d+/.test(String(version))) { + if (!EXACT_VERSION.test(String(version))) { throw new Error(`cannot parse create-objectstack version '${version}'`); } const major = String(version).split('.')[0]; return { + /** The exact version: every `@objectstack/*` dependency, prereleases verbatim. */ version: String(version), major, - /** The `@objectstack/*` package range: dependencies AND the manifest's `specVersion`. */ + /** The manifest's `specVersion` range. ⛔ Not a dependency value — deps are exact. */ range: `^${major}.0.0`, }; } @@ -301,7 +325,7 @@ function main() { const problems = []; for (const template of templates) { - // ── package.json: every @objectstack/* range → the package range ──────── + // ── package.json: every @objectstack/* dep → the exact version ────────── const templatePkgPath = join(TEMPLATE_ROOT, template, TEMPLATE_PKG_FILE); let templatePkg; try { @@ -309,7 +333,7 @@ function main() { } catch (err) { problems.push( `${rel(templatePkgPath)} could not be read as JSON (${err.message}). Every bundled ` + - 'template ships a package.json pinning the @objectstack/* ranges a scaffolded project installs.', + 'template ships a package.json pinning the @objectstack/* versions a scaffolded project installs.', ); templatePkg = null; } @@ -322,9 +346,9 @@ function main() { for (const dep of Object.keys(deps)) { if (!dep.startsWith('@objectstack/')) continue; stackDeps++; - if (deps[dep] !== range) { - console.log(` ${template}/package.json ${dep}: ${deps[dep]} → ${range}`); - deps[dep] = range; + if (deps[dep] !== version) { + console.log(` ${template}/package.json ${dep}: ${deps[dep]} → ${version}`); + deps[dep] = version; changed++; } } @@ -340,13 +364,13 @@ function main() { ); } else if (changed === 0) { console.log( - `✓ ${template}/package.json already pins ${range} across ${stackDeps} @objectstack/* dep(s)` + + `✓ ${template}/package.json already pins ${version} across ${stackDeps} @objectstack/* dep(s)` + ` — in lockstep with create-objectstack@${version}`, ); } else { writeFileSync(templatePkgPath, JSON.stringify(templatePkg, null, 2) + '\n'); console.log( - `✓ ${template}/package.json: ${changed} of ${stackDeps} @objectstack/* range(s) → ${range}` + + `✓ ${template}/package.json: ${changed} of ${stackDeps} @objectstack/* dep(s) → ${version}` + ` (lockstep with create-objectstack@${version})`, ); } @@ -402,7 +426,7 @@ function main() { console.log( `\n✓ sync-template-versions: ${templates.length} template(s) in lockstep with ` + - `create-objectstack@${version} — deps and specVersion at ${range}, engines.protocol at '^${major}'.`, + `create-objectstack@${version} — deps at ${version}, specVersion at ${range}, engines.protocol at '^${major}'.`, ); } @@ -516,10 +540,13 @@ function writeFixtureFile(file, contents) { } /** - * A throwaway checkout shaped like this repo, IN LOCKSTEP unless `major` says - * otherwise. Returns the path of the copied script to run. + * A throwaway checkout shaped like this repo, IN LOCKSTEP with `version`: every + * `@objectstack/*` dep at that EXACT version, `specVersion` at its major's + * range, `engines.protocol` at its major. Returns the path of the copied script + * to run. */ -function buildFixture(dir, { templates = ['blank', 'second'], major = '42' } = {}) { +function buildFixture(dir, { templates = ['blank', 'second'], version = SELF_TEST_VERSION } = {}) { + const major = version.split('.')[0]; const script = join(dir, 'scripts', 'sync-template-versions.mjs'); writeFixtureFile(script, readFileSync(fileURLToPath(import.meta.url), 'utf8')); // The entry guard is imported, not re-typed (`scripts/invoked-as.mjs`), so the @@ -531,7 +558,7 @@ function buildFixture(dir, { templates = ['blank', 'second'], major = '42' } = { ); writeFixtureFile( join(dir, VERSION_SOURCE), - JSON.stringify({ name: 'create-objectstack', version: SELF_TEST_VERSION }, null, 2) + '\n', + JSON.stringify({ name: 'create-objectstack', version }, null, 2) + '\n', ); // Created even when `templates` is empty: the vacuous-green guard is about an // EMPTY templates directory, not a missing one. @@ -543,8 +570,8 @@ function buildFixture(dir, { templates = ['blank', 'second'], major = '42' } = { JSON.stringify( { name: `template-${template}`, - dependencies: { '@objectstack/spec': `^${major}.0.0`, chalk: '^6.0.0' }, - devDependencies: { '@objectstack/cli': `^${major}.0.0` }, + dependencies: { '@objectstack/spec': version, chalk: '^6.0.0' }, + devDependencies: { '@objectstack/cli': version }, }, null, 2, @@ -821,6 +848,78 @@ function selfTest() { `a run that stamped nothing must not print the success line\n${output}`, ); } + + // ── Control I: a PRERELEASE corpus is clean at its exact version, with specVersion still a range ── + // + // The two values a release-candidate cut stamps, observed in the direction + // only a clean corpus can show. The deps carry `42.0.0-rc.1` verbatim and + // `specVersion` carries `^42.0.0`; a rewriter that collapsed the two values + // back into one — either one — rewrites this corpus, and Control A would + // not notice, because on a plain release both readings of "the major" agree. + battery('Control I: a PRERELEASE corpus is clean at its exact version, with specVersion still a range'); + { + const prerelease = '42.0.0-rc.1'; + const dir = fixtureDir('prerelease'); + const script = buildFixture(dir, { version: prerelease }); + const surfaces = fixtureSurfaces(dir); + const backdated = new Date(Date.now() - 60_000); + for (const file of surfaces) utimesSync(file, backdated, backdated); + const before = surfaces.map((file) => ({ + file, + text: readFileSync(file, 'utf8'), + mtimeMs: statSync(file).mtimeMs, + })); + + const { status, output } = runFixture(script); + assert(status === 0, `a prerelease corpus in lockstep exits 0 — got ${status}\n${output}`); + for (const template of ['blank', 'second']) { + assert( + output.includes(`${template}/${TEMPLATE_PKG_FILE} already pins ${prerelease}`), + `${template}/${TEMPLATE_PKG_FILE} is judged clean at the EXACT prerelease, not at a range\n${output}`, + ); + } + assert( + output.includes(`specVersion at ^42.0.0`), + `the run reports specVersion as the major's range, not the prerelease\n${output}`, + ); + for (const snapshot of before) { + const rel = relative(dir, snapshot.file); + assert( + readFileSync(snapshot.file, 'utf8') === snapshot.text && + statSync(snapshot.file).mtimeMs === snapshot.mtimeMs, + `${rel} was left byte-identical and UNWRITTEN on a clean prerelease run`, + ); + } + } + + // ── Control J: a scaffolder version that is not a whole semver exits 1 and writes nothing ── + // + // The version is now written VERBATIM into every dependency, so a prefix + // match is no longer enough to accept it: `42.0.0garbage` would become a + // pin no registry can resolve. The read throws, and `main()` must turn that + // into exit 1 before it opens a single template. + battery('Control J: a scaffolder version that is not a whole semver exits 1 and writes nothing'); + { + const dir = fixtureDir('bad-version'); + const script = buildFixture(dir); + writeFixtureFile( + join(dir, VERSION_SOURCE), + JSON.stringify({ name: 'create-objectstack', version: '42.0.0garbage' }, null, 2) + '\n', + ); + const surfaces = fixtureSurfaces(dir); + const before = surfaces.map((file) => readFileSync(file, 'utf8')); + + const { status, output } = runFixture(script); + assert(status === 1, `an unparseable scaffolder version exits 1 — got ${status}\n${output}`); + assert( + output.includes(`cannot parse create-objectstack version '42.0.0garbage'`), + `the failure names the version it refused\n${output}`, + ); + assert( + surfaces.every((file, i) => readFileSync(file, 'utf8') === before[i]), + 'no template surface was rewritten from a version that was refused', + ); + } } finally { rmSync(scratch, { recursive: true, force: true }); } @@ -876,7 +975,9 @@ function selfTest() { `✓ sync-template-versions --self-test: ${checked} assertions over temp fixtures, running the real CLI. ` + 'A CLEAN corpus is observed REACHED, byte-identical and UNWRITTEN; a missing stamp, a missing file, an ' + 'unparseable package.json, a template with no @objectstack/* dependency and an empty templates directory ' + - 'are each observed exiting 1 and naming the path; and one run is observed naming EVERY unstamped surface. ' + + 'are each observed exiting 1 and naming the path; one run is observed naming EVERY unstamped surface; a ' + + 'prerelease corpus is observed clean with deps at the exact version and specVersion still a range; and a ' + + 'scaffolder version that is not a whole semver is observed exiting 1 with nothing written. ' + 'The STALE -> rewritten direction and the discovery walk belong to ' + 'packages/create-objectstack/src/template-version-stamps.test.ts and are deliberately not restated here.', );