From d5dc6f9c6898ab506626e1df31f1d9acd3914eba Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 15:26:11 +0000 Subject: [PATCH 1/2] fix(create-objectstack): stamp specVersion in the template sync, on every template (#9264) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The blank template declared its target platform in two places that disagreed by eleven majors: `specVersion: "^6.0.0"` in objectstack.manifest.json against `engines.protocol: '^17'` in objectstack.config.ts. sync-template-versions.mjs re-stamped the config key and the template's @objectstack/* dependency ranges, and never opened the manifest — so a green sync run was never evidence about specVersion. The scaffolder copies the manifest into every scaffolded project without touching specVersion, so the stale range shipped to real users, not just to the template registry. specVersion is REQUIRED by TemplateManifestSchema, so deletion was not available. It is also a different fact from engines.protocol: the schema documents it as the compatible @objectstack/spec range (^17.0.0), while engines.protocol is the ADR-0087 D1 protocol major (^17). They are stamped from two different values. Structural half, since one-key-one-file coverage is what let this sit: - templates are DISCOVERED by walking src/templates/, matching check-template-manifests.ts, so a second template is covered when it lands - every stamp is required — a missing file, absent stamp or dep-less package.json fails loudly naming the path, never skips - the manifest is rewritten as text, not re-serialized, so the compact scaffold.variables formatting survives every release CI half: four per-template ratchets in template-consistency.test.ts, including the cross-file invariant that specVersion must equal the @objectstack/spec range the template installs. Co-Authored-By: Claude --- .changeset/template-spec-version-sync.md | 64 +++++ .../src/template-consistency.test.ts | 154 +++++++--- .../templates/blank/objectstack.manifest.json | 2 +- scripts/sync-template-versions.mjs | 270 ++++++++++++++---- 4 files changed, 397 insertions(+), 93 deletions(-) create mode 100644 .changeset/template-spec-version-sync.md diff --git a/.changeset/template-spec-version-sync.md b/.changeset/template-spec-version-sync.md new file mode 100644 index 0000000000..ca530b2c3e --- /dev/null +++ b/.changeset/template-spec-version-sync.md @@ -0,0 +1,64 @@ +--- +"create-objectstack": patch +--- + +fix(create-objectstack): the blank template's `specVersion` stops shipping eleven majors stale, and the version-time sync covers every declared surface on every template (#9264) + +The one bundled template declared the platform it targets in **two** places that +disagreed by eleven majors: + +| file | key | was | +|:--|:--|:--| +| `objectstack.manifest.json` | `specVersion` | `^6.0.0` | +| `objectstack.config.ts` | `engines.protocol` | `^17` | + +`scripts/sync-template-versions.mjs` re-stamped the config key and the template's +`@objectstack/*` dependency ranges, and **never opened the manifest at all**. So +`engines.protocol` tracked every major bump while `specVersion` sat at the value +it held when the script was written — and a green `sync-template-versions` run +was never evidence about it, because the script's failure mode was loud for the +keys it covered and mute for the key it did not. + +**This is not confined to the registry contract.** `create-objectstack` copies +the manifest into every scaffolded project, rewriting `name`, `displayName` and +`namespace` and dropping `description` — it has never touched `specVersion`. So +every project scaffolded since v7 was stamped with a `^6.0.0` spec range while +installing `@objectstack/spec@^17.0.0`. + +**The two keys are two facts, and the fix keeps them apart.** `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 the script already writes into the template's own +`@objectstack/spec` dependency, so the manifest and the `package.json` now state +one fact once. They agree on the major only because the spec package's major and +the protocol major are kept in lockstep; they are stamped from two different +values. + +Deleting the key was not available: `specVersion` is **required** by +`TemplateManifestSchema`, and every shipped manifest is parsed against it by +`check:template-manifests`. + +**Two structural changes, because one-key-one-file coverage is what let this +sit:** + +- the sync script's file list is now **discovered**, not hard-coded — templates + are found by walking `src/templates/`, the same way `check-template-manifests` + finds the manifests it parses, so a second template is covered on the day it + lands; +- **every stamp is required**. A template whose file is missing, whose stamp is + absent, or whose `package.json` declares no `@objectstack/*` dependency is a + hard failure naming the path — never a skip. A skipped stamp is + indistinguishable from a synced one in the log, which is the invisibility this + fixes. + +The manifest is rewritten as **text** rather than parsed and re-serialized: +`objectstack.manifest.json` keeps `scaffold.variables` compact on one line, and +`JSON.stringify(…, null, 2)` would reformat unrelated structure on every release. + +CI coverage lands as four per-template ratchets in `template-consistency.test.ts`, +generalized off `blank` onto the same directory walk — including the invariant +that catches this exact class: the manifest's `specVersion` must equal the +`@objectstack/spec` range the template actually installs. Either file alone can +be self-consistently stale; only comparing them catches a stamp that covered one +and not the other. diff --git a/packages/create-objectstack/src/template-consistency.test.ts b/packages/create-objectstack/src/template-consistency.test.ts index 241b9ef513..dacb7a0c7c 100644 --- a/packages/create-objectstack/src/template-consistency.test.ts +++ b/packages/create-objectstack/src/template-consistency.test.ts @@ -72,46 +72,128 @@ const REPO_READ_ENV: NodeJS.ProcessEnv = (() => { return env; })(); -describe('blank template package.json', () => { - const templatePkg = JSON.parse( - fs.readFileSync( - path.join(pkgRoot, 'src', 'templates', 'blank', 'package.json'), - 'utf8', - ), - ); - - it('pins every @objectstack/* dep to the current major', () => { - 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(); +// ── Declared version surfaces, per bundled template (#9264) ───────────────── +// +// 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 +// 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. +// +// The template list is DISCOVERED, not written down — the same directory walk +// the sync script and `check-template-manifests.ts` both use. A hand-kept list +// is precisely what failed here: coverage of one key in one file is how +// `specVersion` sat at `^6.0.0` while `engines.protocol` tracked every major up +// to `^17`, eleven majors of drift behind a green sync run. +const TEMPLATES_DIR = path.join(pkgRoot, 'src', 'templates'); +const bundledTemplates = fs + .readdirSync(TEMPLATES_DIR, { withFileTypes: true }) + .filter((e) => e.isDirectory() && e.name !== 'node_modules' && e.name !== 'dist') + .map((e) => e.name) + .sort(); + +describe('bundled template declared version surfaces', () => { + // Vacuous-green guard: describe.each over an empty list is a silent pass, and + // "the templates directory moved" must not read as "every template is clean". + it('discovers at least one bundled template', () => { + expect( + bundledTemplates.length, + `no template directories under ${path.relative(pkgRoot, TEMPLATES_DIR)} — ` + + 'the per-template ratchets below would all pass vacuously', + ).toBeGreaterThan(0); + }); + + describe.each(bundledTemplates)('%s', (template) => { + const templateDir = path.join(TEMPLATES_DIR, template); + const readTemplateFile = (name: string) => + fs.readFileSync(path.join(templateDir, name), 'utf8'); + + it('package.json pins every @objectstack/* dep to the current major', () => { + 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(); + 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); + } + }); + + // NOTE the file: this stamp lives in `objectstack.config.ts`, inside the + // `defineStack({ manifest: … })` literal. It is NOT in + // `objectstack.manifest.json` — the two were conflated in this suite's own + // naming and in the sync script's log strings, and that conflation is part + // of how the sibling key below went unwatched for eleven majors. + it("objectstack.config.ts stamps engines.protocol at the scaffolder's major (ADR-0087 D1)", () => { + const config = readTemplateFile('objectstack.config.ts'); + const match = /engines:\s*\{\s*protocol:\s*'\^(\d+)'\s*\}/.exec(config); + expect( + match, + `${template}/objectstack.config.ts must stamp engines.protocol (ADR-0087 D1)`, + ).not.toBeNull(); 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)', + `${template} stamps engines.protocol '^${match![1]}' but create-objectstack is v${ownMajor} — ` + + 'scripts/sync-template-versions.mjs re-stamps this at version time; keep them in lockstep', ).toBe(ownMajor); - } - }); -}); + }); -describe('blank template manifest engines.protocol (ADR-0087 D1)', () => { - it('stamps the current protocol major so the handshake covers fresh scaffolds', () => { - const config = fs.readFileSync( - path.join(pkgRoot, 'src', 'templates', 'blank', 'objectstack.config.ts'), - 'utf8', - ); - const match = /engines:\s*\{\s*protocol:\s*'\^(\d+)'\s*\}/.exec(config); - expect(match, 'template manifest must stamp engines.protocol (ADR-0087 D1)').not.toBeNull(); - expect( - Number(match![1]), - `template stamps engines.protocol '^${match![1]}' but create-objectstack is v${ownMajor} — ` + - 'scripts/sync-template-versions.mjs re-stamps this at version time; keep them in lockstep', - ).toBe(ownMajor); + // The key #9264 is about. Required by TemplateManifestSchema, read by the + // template registry, and copied verbatim into every scaffolded project — + // `create-objectstack` rewrites name/displayName/namespace and drops + // description, and has never touched this one. + it('objectstack.manifest.json declares specVersion at the current @objectstack/spec range', () => { + const manifest = JSON.parse(readTemplateFile('objectstack.manifest.json')); + expect( + typeof manifest.specVersion, + `${template}/objectstack.manifest.json must declare specVersion — it is REQUIRED by ` + + 'TemplateManifestSchema (packages/spec/src/cloud/template-manifest.zod.ts)', + ).toBe('string'); + + const match = /^\^(\d+)\.\d+\.\d+$/.exec(manifest.specVersion); + expect( + match, + `specVersion "${manifest.specVersion}" must be a ^.0.0 package range — it is the ` + + 'compatible @objectstack/spec range, not the protocol major that engines.protocol carries', + ).not.toBeNull(); + expect( + Number(match![1]), + `${template} declares specVersion "${manifest.specVersion}" but create-objectstack is ` + + `v${ownMajor} — scripts/sync-template-versions.mjs re-stamps this at version time`, + ).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', () => { + const manifest = JSON.parse(readTemplateFile('objectstack.manifest.json')); + const templatePkg = JSON.parse(readTemplateFile('package.json')); + const specDep = + templatePkg.dependencies?.['@objectstack/spec'] ?? + templatePkg.devDependencies?.['@objectstack/spec']; + + expect( + specDep, + `${template}/package.json must depend on @objectstack/spec for its manifest's ` + + 'specVersion to be checkable against something', + ).toBeDefined(); + expect( + manifest.specVersion, + `${template} declares specVersion "${manifest.specVersion}" but installs ` + + `@objectstack/spec "${specDep}" — one fact written twice, and they disagree`, + ).toBe(specDep); + }); }); }); diff --git a/packages/create-objectstack/src/templates/blank/objectstack.manifest.json b/packages/create-objectstack/src/templates/blank/objectstack.manifest.json index bc0e00e002..7ce5cc7f1c 100644 --- a/packages/create-objectstack/src/templates/blank/objectstack.manifest.json +++ b/packages/create-objectstack/src/templates/blank/objectstack.manifest.json @@ -2,7 +2,7 @@ "$schema": "https://schemas.objectstack.dev/template-manifest.json", "name": "blank", "namespace": "blank", - "specVersion": "^6.0.0", + "specVersion": "^17.0.0", "displayName": "Blank Starter", "description": "Minimal ObjectStack environment with a single object — a clean slate for building.", "category": "starter", diff --git a/scripts/sync-template-versions.mjs b/scripts/sync-template-versions.mjs index f5af6fd2be..122acd4f22 100644 --- a/scripts/sync-template-versions.mjs +++ b/scripts/sync-template-versions.mjs @@ -1,75 +1,233 @@ // Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. // -// Re-sync the create-objectstack blank template's @objectstack/* dependency -// ranges with the scaffolder's own package version. Runs as part of the root -// `version` script (changesets/action calls `pnpm run version` when preparing -// the release PR), so a version bump can never ship with the template pinning -// a stale range — the drift class behind #2907: the template froze at ^6.0.0 -// while the registry published 14.x, and every fresh `npm create objectstack` -// project landed eight majors behind the docs. The scaffold-time dep rewrite -// (pkg-utils.ts) and the ratchet test (template-consistency.test.ts) both -// guard this too, but release PRs opened by changesets/action with the default -// GITHUB_TOKEN do not trigger CI, so fixing the file at version time is the -// only spot that cannot be skipped. - -import { readFileSync, writeFileSync } from 'node:fs'; +// Re-sync every bundled create-objectstack template's declared version surfaces +// with the scaffolder's own package version. Runs as part of the root `version` +// script (changesets/action calls `pnpm run version` when preparing the release +// PR), so a version bump can never ship with a template pinning a stale range — +// the drift class behind #2907: the template froze at ^6.0.0 while the registry +// published 14.x, and every fresh `npm create objectstack` project landed eight +// majors behind the docs. The scaffold-time dep rewrite (pkg-utils.ts) and the +// ratchet tests (template-consistency.test.ts) both guard this too, but release +// PRs opened by changesets/action with the default GITHUB_TOKEN do not trigger +// CI, so fixing the files at version time is the only spot that cannot be +// skipped. +// +// THREE SURFACES, NOT ONE, AND THE MISSING ONE IS WHY (#9264). This script used +// to stamp two keys in two hard-coded `blank/` paths and never opened +// `objectstack.manifest.json` at all. So `specVersion` — a REQUIRED field of +// TemplateManifestSchema, copied verbatim into every scaffolded project by +// `create-objectstack` — sat at `^6.0.0` while the config's `engines.protocol` +// tracked every major up to ^17. Eleven majors of drift, and a green +// `sync-template-versions` run was never evidence about it: the script's failure +// mode was loud for the keys it covered and MUTE for the key it did not. +// +// Two structural consequences, both deliberate: +// +// * the file list is DISCOVERED, never hard-coded. Templates are found by +// walking `src/templates/`, the same way `check-template-manifests.ts` +// finds the manifests it parses, and for the same stated reason: a template +// added tomorrow is covered on the day it lands, without anyone remembering +// this script exists. One-key-one-file coverage is what let #9264 sit. +// * every stamp is REQUIRED. A template whose file is missing, or whose file +// carries no stamp to sync, is a hard failure naming the path — never a +// 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. + +import { readFileSync, writeFileSync, readdirSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; -import { dirname, join } from 'node:path'; +import { dirname, join, relative } from 'node:path'; const root = dirname(dirname(fileURLToPath(import.meta.url))); const scaffolderPkgPath = join(root, 'packages/create-objectstack/package.json'); -const templatePkgPath = join( - root, - 'packages/create-objectstack/src/templates/blank/package.json', -); + +/** + * Where the bundled templates live. A DIRECTORY, walked, never a hand-kept + * list — see the header. Mirrors `check-template-manifests.ts`'s TEMPLATE_ROOT. + */ +const TEMPLATE_ROOT = join(root, 'packages/create-objectstack/src/templates'); + +const rel = (p) => relative(root, p); const version = JSON.parse(readFileSync(scaffolderPkgPath, 'utf8')).version; if (!/^\d+\.\d+\.\d+/.test(String(version))) { console.error(`✗ sync-template-versions: cannot parse create-objectstack version '${version}'`); process.exit(1); } -const range = `^${String(version).split('.')[0]}.0.0`; - -const templatePkg = JSON.parse(readFileSync(templatePkgPath, 'utf8')); -let changed = 0; -for (const deps of [templatePkg.dependencies, templatePkg.devDependencies]) { - if (!deps) continue; - for (const dep of Object.keys(deps)) { - if (dep.startsWith('@objectstack/') && deps[dep] !== range) { - console.log(` ${dep}: ${deps[dep]} → ${range}`); - deps[dep] = range; - changed++; +const major = String(version).split('.')[0]; +/** The `@objectstack/*` package range: dependencies AND the manifest's `specVersion`. */ +const range = `^${major}.0.0`; + +/** + * The text stamps, as a table over (file, key, pattern, replacement). + * + * Table-driven rather than one block per key so that adding a fourth declared + * version surface is a row, and so that all of them share ONE failure contract + * — the shape whose absence let `specVersion` drift unnoticed (#9264). + * + * Rewritten as TEXT, not parse/re-serialize, and that matters for the manifest: + * `objectstack.manifest.json` keeps `scaffold.variables` compact on one line, so + * `JSON.stringify(…, null, 2)` would reformat 42 bytes of unrelated structure + * on every release. A targeted replace touches the value and nothing else. + */ +const TEXT_STAMPS = [ + { + file: 'objectstack.config.ts', + key: 'engines.protocol', + value: `^${major}`, + // ADR-0087 D1 — the runtime refuses an incompatible package at the boundary + // with the exact migration command. Scaffolds populate it by default; this + // is the ratchet that closes grandfathering. + pattern: /engines:\s*\{\s*protocol:\s*'[^']*'\s*\}/, + replacement: `engines: { protocol: '^${major}' }`, + }, + { + file: 'objectstack.manifest.json', + key: 'specVersion', + value: range, + // Required by TemplateManifestSchema and read by the template registry; + // `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. + pattern: /("specVersion"\s*:\s*)"[^"]*"/, + replacement: `$1"${range}"`, + }, +]; + +/** Every bundled template directory, sorted. Deliberately not a curated list. */ +function findTemplateDirs() { + return readdirSync(TEMPLATE_ROOT, { withFileTypes: true }) + .filter((e) => e.isDirectory() && e.name !== 'node_modules' && e.name !== 'dist') + .map((e) => e.name) + .sort(); +} + +const templates = findTemplateDirs(); + +// Vacuous-green guard, same rationale as check-template-manifests.ts: zero +// templates is far more likely to mean "the directory moved" than "we ship no +// templates", and a run that stamped nothing must not report success. +if (templates.length === 0) { + console.error( + `✗ sync-template-versions: no template directories under ${rel(TEMPLATE_ROOT)}.\n` + + ' Every release stamps at least one bundled template, so this is almost certainly a\n' + + ' moved directory rather than an empty one. A sync that rewrote nothing must not pass.', + ); + process.exit(1); +} + +/** Problems are collected so one run names every unstamped file, not just the first. */ +const problems = []; + +for (const template of templates) { + // ── package.json: every @objectstack/* range → the package range ────────── + const templatePkgPath = join(TEMPLATE_ROOT, template, 'package.json'); + let templatePkg; + try { + templatePkg = JSON.parse(readFileSync(templatePkgPath, 'utf8')); + } 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.', + ); + templatePkg = null; + } + + if (templatePkg) { + let stackDeps = 0; + let changed = 0; + for (const deps of [templatePkg.dependencies, templatePkg.devDependencies]) { + if (!deps) continue; + 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; + changed++; + } + } + } + + if (stackDeps === 0) { + // Zero matches is the silent-skip shape this script exists to refuse: it + // reads exactly like "already in lockstep" and means the opposite. + problems.push( + `${rel(templatePkgPath)} declares no @objectstack/* dependency, so nothing was synced. ` + + 'A bundled template installs the platform it scaffolds against — add the deps, or drop ' + + 'the template directory.', + ); + } else if (changed === 0) { + console.log( + `✓ ${template}/package.json already pins ${range} 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}` + + ` (lockstep with create-objectstack@${version})`, + ); + } + } + + // ── the text stamps ─────────────────────────────────────────────────────── + for (const stamp of TEXT_STAMPS) { + const path = join(TEMPLATE_ROOT, template, stamp.file); + let src; + try { + src = readFileSync(path, 'utf8'); + } catch (err) { + problems.push( + `${rel(path)} could not be read (${err.message}), so ${stamp.key} was not synced. ` + + 'Every bundled template declares it; a template that genuinely should not be stamped ' + + 'is a decision to record in TEXT_STAMPS, not a file to skip.', + ); + continue; + } + + // Absence is a hard failure, never a skip — the #9264 lesson. Tested before + // the replace so "key missing" and "value already correct" stay distinct: + // both produce an unchanged string, and only one of them is fine. + if (!stamp.pattern.test(src)) { + problems.push( + `${rel(path)} has no ${stamp.key} stamp to sync (expected to match ${stamp.pattern}). ` + + `It should declare ${stamp.key} = ${stamp.value}.`, + ); + continue; + } + + const stamped = src.replace(stamp.pattern, stamp.replacement); + if (stamped === src) { + console.log(`✓ ${template}/${stamp.file} already stamps ${stamp.key} '${stamp.value}'`); + } else { + writeFileSync(path, stamped); + console.log(`✓ ${template}/${stamp.file}: ${stamp.key} → '${stamp.value}'`); } } } -if (changed === 0) { - console.log(`✓ blank template already pins ${range} — in lockstep with create-objectstack@${version}`); -} else { - writeFileSync(templatePkgPath, JSON.stringify(templatePkg, null, 2) + '\n'); - console.log(`✓ blank template: ${changed} @objectstack/* range(s) → ${range} (lockstep with create-objectstack@${version})`); +if (problems.length > 0) { + console.error(`\n✗ sync-template-versions: ${problems.length} unstamped surface(s).\n`); + for (const p of problems) console.error(` • ${p}`); + console.error( + '\n Each of these is a declared version surface that would have shipped stale. The script\n' + + ' fails rather than skipping, because a skipped stamp is indistinguishable from a synced\n' + + ' one in the log — which is how specVersion drifted eleven majors (#9264).\n', + ); + process.exit(1); } -// Also re-stamp the manifest's `engines.protocol` range in the template stack -// config (ADR-0087 D1 — scaffolds populate the handshake field by default, the -// ratchet that closes grandfathering). Same lockstep rationale as above. -const templateConfigPath = join( - root, - 'packages/create-objectstack/src/templates/blank/objectstack.config.ts', +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}'.`, ); -const major = String(version).split('.')[0]; -const config = readFileSync(templateConfigPath, 'utf8'); -const stamped = config.replace( - /engines:\s*\{\s*protocol:\s*'[^']*'\s*\}/, - `engines: { protocol: '^${major}' }`, -); -if (stamped === config) { - if (!config.includes(`engines: { protocol: '^${major}' }`)) { - console.error('✗ sync-template-versions: template objectstack.config.ts has no engines.protocol stamp to sync'); - process.exit(1); - } - console.log(`✓ blank template manifest already stamps engines.protocol '^${major}'`); -} else { - writeFileSync(templateConfigPath, stamped); - console.log(`✓ blank template manifest: engines.protocol → '^${major}'`); -} From 451011b4e3cc7773fc0902b1bba8e011a2a25cf0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 15:35:53 +0000 Subject: [PATCH 2/2] chore: declare sync-template-versions.mjs as a create-objectstack test input (#9264) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check:cross-package-test-inputs flags the script path named in the new ratchet block's comment. Settled the way this gate's own ledger settles the identical check-nul-bytes.mjs case: declare the file rather than reword prose to dodge a source scanner. The coupling is real as well as cheap — that script stamps the three per-template version surfaces the ratchets assert, so a change to the stamper is exactly what those ratchets exist to catch. turbo.json's create-objectstack#test inputs move in the same commit; the gate requires the declaration and the turbo hash to agree. Co-Authored-By: Claude --- scripts/check-cross-package-test-inputs.mjs | 12 +++++++++++- turbo.json | 3 ++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/scripts/check-cross-package-test-inputs.mjs b/scripts/check-cross-package-test-inputs.mjs index b52870dd7c..c6a0374cdc 100644 --- a/scripts/check-cross-package-test-inputs.mjs +++ b/scripts/check-cross-package-test-inputs.mjs @@ -276,7 +276,17 @@ const CROSS_PACKAGE_TEST_INPUTS = { 'create-objectstack': { // src/template-consistency.test.ts reads doc frontmatter by repo-relative // path to decide which templates are internal. - globs: ['content/**'], + // + // `sync-template-versions.mjs` is named in a comment rather than read, the + // same shape as `check-nul-bytes.mjs` above and settled the same way: a + // mention forces a declaration, and declaring the file is cheaper than + // rewording prose to dodge the scanner. Here the coupling is real on top of + // being cheap — that script STAMPS the three per-template version surfaces + // (`package.json` @objectstack/* ranges, `objectstack.config.ts` + // `engines.protocol`, `objectstack.manifest.json` `specVersion`) that the + // ratchets in that test assert, so a change to the stamper is exactly the + // change those ratchets exist to catch (#9264). + globs: ['content/**', 'scripts/sync-template-versions.mjs'], }, }; diff --git a/turbo.json b/turbo.json index 62d0178b01..cbff026e2d 100644 --- a/turbo.json +++ b/turbo.json @@ -188,7 +188,8 @@ "!dist/**", "!coverage/**", "!.turbo/**", - "$TURBO_ROOT$/content/**" + "$TURBO_ROOT$/content/**", + "$TURBO_ROOT$/scripts/sync-template-versions.mjs" ] }, "test:e2e": {