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.', );