diff --git a/packages/create-objectstack/src/template-version-stamps.test.ts b/packages/create-objectstack/src/template-version-stamps.test.ts new file mode 100644 index 0000000000..cce8b44544 --- /dev/null +++ b/packages/create-objectstack/src/template-version-stamps.test.ts @@ -0,0 +1,304 @@ +// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. +// +// The declaration surface of `scripts/sync-template-versions.mjs` (#9554). +// +// That script stamps three version surfaces per bundled template and discovers +// the template set by WALKING `src/templates/` — "deliberately not a curated +// list", because a hand-kept list is what let `specVersion` drift eleven majors +// (#9264). But for one release it declared that set where nobody could read it: +// nothing was exported and the sync ran at module scope, so a consumer that +// imported the file to ask "which paths does the version pass write?" rewrote +// every template instead of getting an answer. `cut-rc.yml`'s release-file +// allowlist therefore RESTATED two of the paths, both hard-coding the template +// name `blank`. +// +// ## Why a fixture with a SECOND template, and why that is the whole point +// +// The repo ships exactly one template today, so on the live tree the walk and a +// literal `blank` pair agree and nothing is red — which is precisely why this +// finding could only be found by reading rather than by a failing test. Every +// assertion below that ran only against the live tree would pass just as +// happily against a `stampedPaths()` that returned two hard-coded `blank` +// strings. So the load-bearing cases run against a temp checkout carrying TWO +// templates: `blank` and `second`. An implementation that restated `blank` +// fails there, and that is the assertion that keeps this fixed rather than +// re-found the day a second template ships. +// +// The same fixture is deliberately built STALE (pinning ^17 while its +// scaffolder reads 42.0.0). That makes the import-safety assertion non-vacuous +// in the one way that matters: an unguarded module imported against a stale +// tree REWRITES it, so "the files are byte-identical after import" is evidence +// only when there was something for a rewrite to do. Byte-identity over an +// already-in-lockstep tree would prove nothing at all. +// +// Scope note: this file asserts the DECLARATION surface and the entry-point +// guard. #9348 (the script has no `--self-test` and runs nowhere in CI) is a +// separate change to the same file and is not implemented here. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const repoRoot = path.resolve(pkgRoot, '..', '..'); +const SYNC_SCRIPT = path.join(repoRoot, 'scripts', 'sync-template-versions.mjs'); + +/** + * The script is loaded the way its real consumer loads it — by URL, at run + * time — rather than by a static import. `cut-rc.yml` reaches it as + * `node -e 'import { … } from "./scripts/sync-template-versions.mjs"'`, and a + * `.mjs` outside this package's `rootDir` is not statically importable from + * `src/` anyway. + */ +type SyncModule = { + TEMPLATE_DIR: string; + TEMPLATE_ROOT: string; + TEMPLATE_PKG_FILE: string; + VERSION_SOURCE: string; + TEXT_STAMPS: { file: string; key: string; pattern: RegExp }[]; + findTemplateDirs: (templateRoot?: string) => string[]; + stampedPaths: (options?: { root?: string }) => string[]; + loadScaffolderVersion: (file?: string) => { version: string; major: string; range: string }; +}; + +const loadSync = async (file = SYNC_SCRIPT): Promise => + (await import(pathToFileURL(file).href)) as SyncModule; + +// ── the two-template fixture ──────────────────────────────────────────────── + +/** A throwaway checkout shaped like this repo: `scripts/` + the template tree. */ +let fixture: string; +let fixtureScript: string; + +/** Deliberately not the live version, so a stamp that ran is unmistakable. */ +const FIXTURE_VERSION = '42.0.0'; +/** Deliberately stale: every fixture surface pins this and must move to 42. */ +const STALE_MAJOR = '17'; + +const FIXTURE_TEMPLATES = ['blank', 'second'] as const; + +const fixtureTemplateDir = (template: string) => + path.join(fixture, 'packages', 'create-objectstack', 'src', 'templates', template); + +const writeFixtureFile = (file: string, content: string) => { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, content); +}; + +beforeAll(() => { + fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'sync-template-versions-9554-')); + + // The script resolves its repo root from its OWN location + // (`dirname(dirname(import.meta.url))`), so a copy two levels above the + // template tree makes the fixture a complete, self-consistent checkout. + fixtureScript = path.join(fixture, 'scripts', 'sync-template-versions.mjs'); + writeFixtureFile(fixtureScript, fs.readFileSync(SYNC_SCRIPT, 'utf8')); + + writeFixtureFile( + path.join(fixture, 'packages', 'create-objectstack', 'package.json'), + JSON.stringify({ name: 'create-objectstack', version: FIXTURE_VERSION }, null, 2) + '\n', + ); + + for (const template of FIXTURE_TEMPLATES) { + const dir = fixtureTemplateDir(template); + writeFixtureFile( + path.join(dir, 'package.json'), + JSON.stringify( + { + name: `template-${template}`, + dependencies: { '@objectstack/spec': `^${STALE_MAJOR}.0.0`, chalk: '^6.0.0' }, + devDependencies: { '@objectstack/cli': `^${STALE_MAJOR}.0.0` }, + }, + null, + 2, + ) + '\n', + ); + writeFixtureFile( + path.join(dir, 'objectstack.config.ts'), + `export default defineStack({ manifest: { engines: { protocol: '^${STALE_MAJOR}' } } });\n`, + ); + writeFixtureFile( + path.join(dir, 'objectstack.manifest.json'), + `{\n "specVersion": "^${STALE_MAJOR}.0.0",\n "scaffold": { "variables": [] }\n}\n`, + ); + } + + // A file, not a directory, beside the templates: the walk must ignore it the + // way the live tree's `templates/AGENTS.md` is ignored. + writeFixtureFile( + path.join(fixture, 'packages', 'create-objectstack', 'src', 'templates', 'AGENTS.md'), + '# not a template\n', + ); +}); + +afterAll(() => { + if (fixture) fs.rmSync(fixture, { recursive: true, force: true }); +}); + +/** Every fixture surface, as absolute paths. */ +const allFixtureSurfaces = () => + FIXTURE_TEMPLATES.flatMap((template) => + ['package.json', 'objectstack.config.ts', 'objectstack.manifest.json'].map((file) => + path.join(fixtureTemplateDir(template), file), + ), + ); + +const snapshotFixture = () => + Object.fromEntries(allFixtureSurfaces().map((file) => [file, fs.readFileSync(file, 'utf8')])); + +// ── import safety (#9554) ─────────────────────────────────────────────────── + +describe('sync-template-versions.mjs is import-safe', () => { + it('importing it against a STALE two-template checkout rewrites nothing', async () => { + const before = snapshotFixture(); + + // Anti-vacuity: the fixture must genuinely need stamping, or byte-identity + // below is a statement about a tree no correct implementation would touch. + expect( + Object.values(before).every((text) => text.includes(`^${STALE_MAJOR}`)), + 'the fixture starts STALE on every surface, so an unguarded import would have work to do', + ).toBe(true); + + await loadSync(fixtureScript); + + expect( + snapshotFixture(), + 'importing the module must not run the sync — the entry-point guard is what lets a ' + + 'consumer read the declarations instead of restating them (#9554)', + ).toEqual(before); + }); + + it('exports the declaration surface a consumer needs', async () => { + const sync = await loadSync(); + expect(typeof sync.stampedPaths).toBe('function'); + expect(typeof sync.findTemplateDirs).toBe('function'); + expect(typeof sync.loadScaffolderVersion).toBe('function'); + expect(Array.isArray(sync.TEXT_STAMPS)).toBe(true); + expect(sync.TEXT_STAMPS.length).toBeGreaterThan(0); + expect(sync.TEMPLATE_DIR).toBe('packages/create-objectstack/src/templates'); + }); + + it('reading the version THROWS rather than exiting the host process', async () => { + const sync = await loadSync(); + const bad = path.join(fixture, 'unparseable.json'); + fs.writeFileSync(bad, JSON.stringify({ version: 'workspace:*' })); + // A module-scope `process.exit(1)` on an unparseable version is a worse + // import hazard than the sync, not a smaller one: it kills the consumer. + expect(() => sync.loadScaffolderVersion(bad)).toThrow(/cannot parse/i); + }); +}); + +// ── the entry point still stamps (#9554 must not break the release path) ──── + +describe('the entry-point guard leaves the CLI path working', () => { + it('running the script stamps EVERY template, including the second one', () => { + const stdout = execFileSync(process.execPath, [fixtureScript], { encoding: 'utf8' }); + + for (const template of FIXTURE_TEMPLATES) { + const dir = fixtureTemplateDir(template); + 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'); + expect( + pkg.dependencies.chalk, + 'a non-@objectstack dependency is never touched', + ).toBe('^6.0.0'); + + expect(fs.readFileSync(path.join(dir, 'objectstack.config.ts'), 'utf8')).toContain( + "engines: { protocol: '^42' }", + ); + + const manifest = fs.readFileSync(path.join(dir, 'objectstack.manifest.json'), 'utf8'); + expect(manifest).toContain('"specVersion": "^42.0.0"'); + expect( + manifest, + 'the manifest is rewritten as TEXT, so unrelated compact structure survives', + ).toContain('"scaffold": { "variables": [] }'); + } + + // A guard that made the version pass silently stop stamping would be far + // worse than the finding it fixes, so the run is observed to REPORT both. + expect(stdout).toContain('2 template(s) in lockstep with create-objectstack@42.0.0'); + expect(stdout).toContain('second/objectstack.manifest.json'); + }); +}); + +// ── stampedPaths() is derived from the walk, never a restated list ────────── + +describe('stampedPaths()', () => { + it('covers every discovered template — the case a literal list fails', async () => { + const sync = await loadSync(fixtureScript); + const paths = sync.stampedPaths({ root: fixture }); + const prefix = 'packages/create-objectstack/src/templates'; + + expect(paths).toEqual([ + `${prefix}/blank/objectstack.config.ts`, + `${prefix}/blank/objectstack.manifest.json`, + `${prefix}/blank/package.json`, + `${prefix}/second/objectstack.config.ts`, + `${prefix}/second/objectstack.manifest.json`, + `${prefix}/second/package.json`, + ]); + + // The finding, stated as an assertion: the pair `cut-rc.yml` spells + // literally is a STRICT SUBSET of what the version pass actually writes as + // soon as a second template exists. An implementation that restated + // `blank` would satisfy every other assertion in this file. + const literals = [ + `${prefix}/blank/objectstack.config.ts`, + `${prefix}/blank/objectstack.manifest.json`, + ]; + const uncovered = paths.filter((p) => !literals.includes(p)); + expect( + uncovered.some((p) => p.includes('/second/')), + 'the second template is covered by the declaration and by no literal `blank` pair', + ).toBe(true); + }); + + it('names only paths that exist — consumers build git pathspecs out of them', async () => { + const sync = await loadSync(); + const paths = sync.stampedPaths(); + expect(paths.length).toBeGreaterThan(0); + for (const p of paths) { + expect(p, 'repo-relative, never absolute').not.toMatch(/^([/]|[A-Za-z]:)/); + expect(p, 'POSIX separators — these are git pathspecs downstream').not.toContain('\\'); + expect(fs.existsSync(path.join(repoRoot, p)), `${p} exists in this checkout`).toBe(true); + } + expect(new Set(paths).size, 'no duplicates').toBe(paths.length); + expect([...paths].sort(), 'stable order').toEqual(paths); + }); + + it('agrees with the live walk rather than with a remembered template set', async () => { + const sync = await loadSync(); + const templates = sync.findTemplateDirs(); + expect(templates.length).toBeGreaterThan(0); + + const files = [sync.TEMPLATE_PKG_FILE, ...sync.TEXT_STAMPS.map((s) => s.file)]; + const expected = templates + .flatMap((t) => files.map((f) => `${sync.TEMPLATE_DIR}/${t}/${f}`)) + .sort(); + expect(sync.stampedPaths()).toEqual(expected); + }); + + it('REFUSES an empty template set instead of returning an empty allowlist', async () => { + const sync = await loadSync(); + const empty = fs.mkdtempSync(path.join(os.tmpdir(), 'sync-template-versions-empty-')); + fs.mkdirSync(path.join(empty, 'packages', 'create-objectstack', 'src', 'templates'), { + recursive: true, + }); + try { + // An empty list reads exactly like "no template paths need staging" and + // means "the directory moved" — the vacuous-green shape the script's own + // run refuses, and the one `cut-rc.yml` already guards for the doc half. + expect(() => sync.stampedPaths({ root: empty })).toThrow(/no template directories/i); + } finally { + fs.rmSync(empty, { recursive: true, force: true }); + } + }); +}); diff --git a/scripts/sync-template-versions.mjs b/scripts/sync-template-versions.mjs index 122acd4f22..546d9b1f73 100644 --- a/scripts/sync-template-versions.mjs +++ b/scripts/sync-template-versions.mjs @@ -12,6 +12,8 @@ // CI, so fixing the files at version time is the only spot that cannot be // skipped. // +// node scripts/sync-template-versions.mjs +// // 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 @@ -42,192 +44,326 @@ // 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. +// +// ## THE DECLARATIONS ARE EXPORTED, AND NOTHING RUNS ON IMPORT (#9554) +// +// Everything above says the target set is DECLARED rather than restated — and +// for one release it was declared in a place no one could read. `TEXT_STAMPS`, +// `TEMPLATE_ROOT` and `findTemplateDirs()` were all module-private, and the +// sync executed at module scope, so a consumer that imported this file to ask +// "which files does the version pass stamp?" would have rewritten the templates +// instead of getting an answer. The only available option was to restate the +// paths, and `cut-rc.yml`'s release-file allowlist did exactly that: two +// literals, both hard-coding the template name `blank`, in a workflow whose own +// comment recorded that it would rather read this list. +// +// That restatement is the #9264 failure one layer up. `findTemplateDirs()` +// exists BECAUSE the template set is not curated, so the day a second template +// ships the walk picks it up, this script stamps it, and a literal `blank` pair +// does not cover it — the allowlist assertion trips and the cut refuses to +// push, with nothing red until someone attempts a release. +// +// So the surface is import-safe, mirroring `check-docs-image-tag.mjs` (#9064) +// and read by `sync-docs-image-tags.mjs` the same way: +// +// * `stampedPaths()` — the derived answer, repo-relative POSIX paths across +// ALL template dirs. This is what a consumer wants; deriving it here is +// what keeps consumers from re-implementing the walk and the join. +// * `TEXT_STAMPS`, `TEMPLATE_DIR`, `TEMPLATE_ROOT`, `findTemplateDirs()`, +// `VERSION_SOURCE`, `loadScaffolderVersion()` — the raw declarations, for +// a consumer that needs the keys and patterns rather than the paths. +// * nothing executes, reads a file, or exits the process at import. The +// version read used to sit at module scope and `process.exit(1)` on an +// unparseable version — an import that can kill its host process is a +// worse import hazard than the sync, not a smaller one, so it moved into +// `loadScaffolderVersion()`, which THROWS. Only `main()` exits. +// +// `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 +// named for the wider one would seed the next restatement. import { readFileSync, writeFileSync, readdirSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; -import { dirname, join, relative } from 'node:path'; +import { dirname, join, relative, resolve, sep } from 'node:path'; +/** The repo this script lives in — resolved from the script, so cwd cannot lie. */ const root = dirname(dirname(fileURLToPath(import.meta.url))); -const scaffolderPkgPath = join(root, 'packages/create-objectstack/package.json'); + +/** Where the scaffolder's own version is read from. Repo-relative. */ +export const VERSION_SOURCE = 'packages/create-objectstack/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. + * Where the bundled templates live, repo-relative. A DIRECTORY, walked, never a + * hand-kept list — see the header. Mirrors `check-template-manifests.ts`. */ -const TEMPLATE_ROOT = join(root, 'packages/create-objectstack/src/templates'); +export const TEMPLATE_DIR = 'packages/create-objectstack/src/templates'; -const rel = (p) => relative(root, p); +/** Absolute counterpart of `TEMPLATE_DIR` for the checkout this script lives in. */ +export const TEMPLATE_ROOT = join(root, TEMPLATE_DIR); -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 major = String(version).split('.')[0]; -/** The `@objectstack/*` package range: dependencies AND the manifest's `specVersion`. */ -const range = `^${major}.0.0`; +/** Repo-relative, always POSIX-separated: these paths are git pathspecs downstream. */ +const rel = (p) => relative(root, p).split(sep).join('/'); /** - * The text stamps, as a table over (file, key, pattern, replacement). + * The text stamps, as a table over (file, key, pattern, value, 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). * + * `value` and `replacement` are FUNCTIONS of the version being stamped, not + * strings baked in at module load. That is what lets this table be a static + * declaration an importer can read without the module first going and reading + * `create-objectstack/package.json` (#9554) — and it is the same split + * `check-docs-image-tag.mjs` makes between its static `PATTERNS` and the + * `expected` version threaded through as a parameter. + * * 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 = [ +export 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. + // is the ratchet that closes grandfathering. Carries the PROTOCOL major. pattern: /engines:\s*\{\s*protocol:\s*'[^']*'\s*\}/, - replacement: `engines: { protocol: '^${major}' }`, + value: ({ major }) => `^${major}`, + replacement: ({ major }) => `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. + // never touched this key), so a stale value ships to real users. Carries + // the PACKAGE range. pattern: /("specVersion"\s*:\s*)"[^"]*"/, - replacement: `$1"${range}"`, + value: ({ range }) => range, + replacement: ({ range }) => `$1"${range}"`, }, ]; +/** + * 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. + * Named here rather than inline so `stampedPaths()` and `main()` cannot drift. + */ +export const TEMPLATE_PKG_FILE = 'package.json'; + /** Every bundled template directory, sorted. Deliberately not a curated list. */ -function findTemplateDirs() { - return readdirSync(TEMPLATE_ROOT, { withFileTypes: true }) +export function findTemplateDirs(templateRoot = TEMPLATE_ROOT) { + return readdirSync(templateRoot, { withFileTypes: true }) .filter((e) => e.isDirectory() && e.name !== 'node_modules' && e.name !== 'dist') .map((e) => e.name) .sort(); } -const templates = findTemplateDirs(); +/** + * Every repo-relative path this script may write, across ALL template dirs. + * + * This is the export `cut-rc.yml`'s release-file allowlist is for: the paths a + * version pass may touch under `src/templates/`, derived from the same walk and + * the same table the sync itself uses, so the workflow cannot describe a + * different set than the one that gets written. `sync-docs-image-tags.mjs` and + * that allowlist already read the doc half from `check-docs-image-tag.mjs`'s + * `SURFACES`; this is the template half on the same terms. + * + * Zero templates THROWS rather than returning `[]`, for the same reason the run + * itself refuses a vacuous green: an empty allowlist reads exactly like "no + * template paths need staging" and means "the directory moved". A consumer + * building a pathspec out of nothing would stage nothing and then blame the + * unstaged files it caused. + * + * @param {{ root?: string }} [options] checkout to walk; defaults to this one + * @returns {string[]} sorted, POSIX-separated, repo-relative paths + */ +export function stampedPaths({ root: base = root } = {}) { + const templateRoot = join(base, TEMPLATE_DIR); + const templates = findTemplateDirs(templateRoot); + if (templates.length === 0) { + throw new Error( + `sync-template-versions: no template directories under ${TEMPLATE_DIR}. Every release ` + + 'stamps at least one bundled template, so this is almost certainly a moved directory ' + + 'rather than an empty one — refusing to report an empty stamped-path set.', + ); + } + const files = [TEMPLATE_PKG_FILE, ...TEXT_STAMPS.map((stamp) => stamp.file)]; + return templates + .flatMap((template) => files.map((file) => `${TEMPLATE_DIR}/${template}/${file}`)) + .sort(); +} -// 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); +/** + * The scaffolder's version, and the two 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 + * usable declaration surface. `main()` turns the throw into the exit. + * + * @param {string} [file] absolute path to create-objectstack's package.json + */ +export function loadScaffolderVersion(file = join(root, VERSION_SOURCE)) { + const version = JSON.parse(readFileSync(file, 'utf8')).version; + if (!/^\d+\.\d+\.\d+/.test(String(version))) { + throw new Error(`cannot parse create-objectstack version '${version}'`); + } + const major = String(version).split('.')[0]; + return { + version: String(version), + major, + /** The `@objectstack/*` package range: dependencies AND the manifest's `specVersion`. */ + range: `^${major}.0.0`, + }; } -/** 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; +function main() { + let scaffolder; try { - templatePkg = JSON.parse(readFileSync(templatePkgPath, 'utf8')); + scaffolder = loadScaffolderVersion(); } 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; + console.error(`✗ sync-template-versions: ${err.message}`); + process.exit(1); } + const { version, major, range } = scaffolder; - 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++; - } - } - } + const templates = findTemplateDirs(); - 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})`, - ); - } + // 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); } - // ── the text stamps ─────────────────────────────────────────────────────── - for (const stamp of TEXT_STAMPS) { - const path = join(TEMPLATE_ROOT, template, stamp.file); - let src; + /** 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, TEMPLATE_PKG_FILE); + let templatePkg; try { - src = readFileSync(path, 'utf8'); + templatePkg = JSON.parse(readFileSync(templatePkgPath, '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.', + `${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.', ); - continue; + templatePkg = null; } - // 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; + 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})`, + ); + } } - 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}'`); + // ── the text stamps ───────────────────────────────────────────────────── + for (const stamp of TEXT_STAMPS) { + const path = join(TEMPLATE_ROOT, template, stamp.file); + const value = stamp.value({ version, major, range }); + 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} = ${value}.`, + ); + continue; + } + + const stamped = src.replace(stamp.pattern, stamp.replacement({ version, major, range })); + if (stamped === src) { + console.log(`✓ ${template}/${stamp.file} already stamps ${stamp.key} '${value}'`); + } else { + writeFileSync(path, stamped); + console.log(`✓ ${template}/${stamp.file}: ${stamp.key} → '${value}'`); + } } } -} -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', + 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); + } + + 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}'.`, ); - process.exit(1); } -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}'.`, -); +// --------------------------------------------------------------------------- + +// Entry-point guard (#9554), the same one #9064 added to check-docs-image-tag.mjs +// and for the same reason: this file is importable, and an import that rewrote +// every bundled template as a side effect is strictly worse than the missing +// export it was working around. +if (resolve(process.argv[1] ?? '') === resolve(fileURLToPath(import.meta.url))) { + main(); +}