|
| 1 | +// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. |
| 2 | +// |
| 3 | +// The declaration surface of `scripts/sync-template-versions.mjs` (#9554). |
| 4 | +// |
| 5 | +// That script stamps three version surfaces per bundled template and discovers |
| 6 | +// the template set by WALKING `src/templates/` — "deliberately not a curated |
| 7 | +// list", because a hand-kept list is what let `specVersion` drift eleven majors |
| 8 | +// (#9264). But for one release it declared that set where nobody could read it: |
| 9 | +// nothing was exported and the sync ran at module scope, so a consumer that |
| 10 | +// imported the file to ask "which paths does the version pass write?" rewrote |
| 11 | +// every template instead of getting an answer. `cut-rc.yml`'s release-file |
| 12 | +// allowlist therefore RESTATED two of the paths, both hard-coding the template |
| 13 | +// name `blank`. |
| 14 | +// |
| 15 | +// ## Why a fixture with a SECOND template, and why that is the whole point |
| 16 | +// |
| 17 | +// The repo ships exactly one template today, so on the live tree the walk and a |
| 18 | +// literal `blank` pair agree and nothing is red — which is precisely why this |
| 19 | +// finding could only be found by reading rather than by a failing test. Every |
| 20 | +// assertion below that ran only against the live tree would pass just as |
| 21 | +// happily against a `stampedPaths()` that returned two hard-coded `blank` |
| 22 | +// strings. So the load-bearing cases run against a temp checkout carrying TWO |
| 23 | +// templates: `blank` and `second`. An implementation that restated `blank` |
| 24 | +// fails there, and that is the assertion that keeps this fixed rather than |
| 25 | +// re-found the day a second template ships. |
| 26 | +// |
| 27 | +// The same fixture is deliberately built STALE (pinning ^17 while its |
| 28 | +// scaffolder reads 42.0.0). That makes the import-safety assertion non-vacuous |
| 29 | +// in the one way that matters: an unguarded module imported against a stale |
| 30 | +// tree REWRITES it, so "the files are byte-identical after import" is evidence |
| 31 | +// only when there was something for a rewrite to do. Byte-identity over an |
| 32 | +// already-in-lockstep tree would prove nothing at all. |
| 33 | +// |
| 34 | +// Scope note: this file asserts the DECLARATION surface and the entry-point |
| 35 | +// guard. #9348 (the script has no `--self-test` and runs nowhere in CI) is a |
| 36 | +// separate change to the same file and is not implemented here. |
| 37 | + |
| 38 | +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; |
| 39 | +import fs from 'node:fs'; |
| 40 | +import os from 'node:os'; |
| 41 | +import path from 'node:path'; |
| 42 | +import { execFileSync } from 'node:child_process'; |
| 43 | +import { fileURLToPath, pathToFileURL } from 'node:url'; |
| 44 | + |
| 45 | +const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); |
| 46 | +const repoRoot = path.resolve(pkgRoot, '..', '..'); |
| 47 | +const SYNC_SCRIPT = path.join(repoRoot, 'scripts', 'sync-template-versions.mjs'); |
| 48 | + |
| 49 | +/** |
| 50 | + * The script is loaded the way its real consumer loads it — by URL, at run |
| 51 | + * time — rather than by a static import. `cut-rc.yml` reaches it as |
| 52 | + * `node -e 'import { … } from "./scripts/sync-template-versions.mjs"'`, and a |
| 53 | + * `.mjs` outside this package's `rootDir` is not statically importable from |
| 54 | + * `src/` anyway. |
| 55 | + */ |
| 56 | +type SyncModule = { |
| 57 | + TEMPLATE_DIR: string; |
| 58 | + TEMPLATE_ROOT: string; |
| 59 | + TEMPLATE_PKG_FILE: string; |
| 60 | + VERSION_SOURCE: string; |
| 61 | + TEXT_STAMPS: { file: string; key: string; pattern: RegExp }[]; |
| 62 | + findTemplateDirs: (templateRoot?: string) => string[]; |
| 63 | + stampedPaths: (options?: { root?: string }) => string[]; |
| 64 | + loadScaffolderVersion: (file?: string) => { version: string; major: string; range: string }; |
| 65 | +}; |
| 66 | + |
| 67 | +const loadSync = async (file = SYNC_SCRIPT): Promise<SyncModule> => |
| 68 | + (await import(pathToFileURL(file).href)) as SyncModule; |
| 69 | + |
| 70 | +// ── the two-template fixture ──────────────────────────────────────────────── |
| 71 | + |
| 72 | +/** A throwaway checkout shaped like this repo: `scripts/` + the template tree. */ |
| 73 | +let fixture: string; |
| 74 | +let fixtureScript: string; |
| 75 | + |
| 76 | +/** Deliberately not the live version, so a stamp that ran is unmistakable. */ |
| 77 | +const FIXTURE_VERSION = '42.0.0'; |
| 78 | +/** Deliberately stale: every fixture surface pins this and must move to 42. */ |
| 79 | +const STALE_MAJOR = '17'; |
| 80 | + |
| 81 | +const FIXTURE_TEMPLATES = ['blank', 'second'] as const; |
| 82 | + |
| 83 | +const fixtureTemplateDir = (template: string) => |
| 84 | + path.join(fixture, 'packages', 'create-objectstack', 'src', 'templates', template); |
| 85 | + |
| 86 | +const writeFixtureFile = (file: string, content: string) => { |
| 87 | + fs.mkdirSync(path.dirname(file), { recursive: true }); |
| 88 | + fs.writeFileSync(file, content); |
| 89 | +}; |
| 90 | + |
| 91 | +beforeAll(() => { |
| 92 | + fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'sync-template-versions-9554-')); |
| 93 | + |
| 94 | + // The script resolves its repo root from its OWN location |
| 95 | + // (`dirname(dirname(import.meta.url))`), so a copy two levels above the |
| 96 | + // template tree makes the fixture a complete, self-consistent checkout. |
| 97 | + fixtureScript = path.join(fixture, 'scripts', 'sync-template-versions.mjs'); |
| 98 | + writeFixtureFile(fixtureScript, fs.readFileSync(SYNC_SCRIPT, 'utf8')); |
| 99 | + |
| 100 | + writeFixtureFile( |
| 101 | + path.join(fixture, 'packages', 'create-objectstack', 'package.json'), |
| 102 | + JSON.stringify({ name: 'create-objectstack', version: FIXTURE_VERSION }, null, 2) + '\n', |
| 103 | + ); |
| 104 | + |
| 105 | + for (const template of FIXTURE_TEMPLATES) { |
| 106 | + const dir = fixtureTemplateDir(template); |
| 107 | + writeFixtureFile( |
| 108 | + path.join(dir, 'package.json'), |
| 109 | + JSON.stringify( |
| 110 | + { |
| 111 | + name: `template-${template}`, |
| 112 | + dependencies: { '@objectstack/spec': `^${STALE_MAJOR}.0.0`, chalk: '^6.0.0' }, |
| 113 | + devDependencies: { '@objectstack/cli': `^${STALE_MAJOR}.0.0` }, |
| 114 | + }, |
| 115 | + null, |
| 116 | + 2, |
| 117 | + ) + '\n', |
| 118 | + ); |
| 119 | + writeFixtureFile( |
| 120 | + path.join(dir, 'objectstack.config.ts'), |
| 121 | + `export default defineStack({ manifest: { engines: { protocol: '^${STALE_MAJOR}' } } });\n`, |
| 122 | + ); |
| 123 | + writeFixtureFile( |
| 124 | + path.join(dir, 'objectstack.manifest.json'), |
| 125 | + `{\n "specVersion": "^${STALE_MAJOR}.0.0",\n "scaffold": { "variables": [] }\n}\n`, |
| 126 | + ); |
| 127 | + } |
| 128 | + |
| 129 | + // A file, not a directory, beside the templates: the walk must ignore it the |
| 130 | + // way the live tree's `templates/AGENTS.md` is ignored. |
| 131 | + writeFixtureFile( |
| 132 | + path.join(fixture, 'packages', 'create-objectstack', 'src', 'templates', 'AGENTS.md'), |
| 133 | + '# not a template\n', |
| 134 | + ); |
| 135 | +}); |
| 136 | + |
| 137 | +afterAll(() => { |
| 138 | + if (fixture) fs.rmSync(fixture, { recursive: true, force: true }); |
| 139 | +}); |
| 140 | + |
| 141 | +/** Every fixture surface, as absolute paths. */ |
| 142 | +const allFixtureSurfaces = () => |
| 143 | + FIXTURE_TEMPLATES.flatMap((template) => |
| 144 | + ['package.json', 'objectstack.config.ts', 'objectstack.manifest.json'].map((file) => |
| 145 | + path.join(fixtureTemplateDir(template), file), |
| 146 | + ), |
| 147 | + ); |
| 148 | + |
| 149 | +const snapshotFixture = () => |
| 150 | + Object.fromEntries(allFixtureSurfaces().map((file) => [file, fs.readFileSync(file, 'utf8')])); |
| 151 | + |
| 152 | +// ── import safety (#9554) ─────────────────────────────────────────────────── |
| 153 | + |
| 154 | +describe('sync-template-versions.mjs is import-safe', () => { |
| 155 | + it('importing it against a STALE two-template checkout rewrites nothing', async () => { |
| 156 | + const before = snapshotFixture(); |
| 157 | + |
| 158 | + // Anti-vacuity: the fixture must genuinely need stamping, or byte-identity |
| 159 | + // below is a statement about a tree no correct implementation would touch. |
| 160 | + expect( |
| 161 | + Object.values(before).every((text) => text.includes(`^${STALE_MAJOR}`)), |
| 162 | + 'the fixture starts STALE on every surface, so an unguarded import would have work to do', |
| 163 | + ).toBe(true); |
| 164 | + |
| 165 | + await loadSync(fixtureScript); |
| 166 | + |
| 167 | + expect( |
| 168 | + snapshotFixture(), |
| 169 | + 'importing the module must not run the sync — the entry-point guard is what lets a ' + |
| 170 | + 'consumer read the declarations instead of restating them (#9554)', |
| 171 | + ).toEqual(before); |
| 172 | + }); |
| 173 | + |
| 174 | + it('exports the declaration surface a consumer needs', async () => { |
| 175 | + const sync = await loadSync(); |
| 176 | + expect(typeof sync.stampedPaths).toBe('function'); |
| 177 | + expect(typeof sync.findTemplateDirs).toBe('function'); |
| 178 | + expect(typeof sync.loadScaffolderVersion).toBe('function'); |
| 179 | + expect(Array.isArray(sync.TEXT_STAMPS)).toBe(true); |
| 180 | + expect(sync.TEXT_STAMPS.length).toBeGreaterThan(0); |
| 181 | + expect(sync.TEMPLATE_DIR).toBe('packages/create-objectstack/src/templates'); |
| 182 | + }); |
| 183 | + |
| 184 | + it('reading the version THROWS rather than exiting the host process', async () => { |
| 185 | + const sync = await loadSync(); |
| 186 | + const bad = path.join(fixture, 'unparseable.json'); |
| 187 | + fs.writeFileSync(bad, JSON.stringify({ version: 'workspace:*' })); |
| 188 | + // A module-scope `process.exit(1)` on an unparseable version is a worse |
| 189 | + // import hazard than the sync, not a smaller one: it kills the consumer. |
| 190 | + expect(() => sync.loadScaffolderVersion(bad)).toThrow(/cannot parse/i); |
| 191 | + }); |
| 192 | +}); |
| 193 | + |
| 194 | +// ── the entry point still stamps (#9554 must not break the release path) ──── |
| 195 | + |
| 196 | +describe('the entry-point guard leaves the CLI path working', () => { |
| 197 | + it('running the script stamps EVERY template, including the second one', () => { |
| 198 | + const stdout = execFileSync(process.execPath, [fixtureScript], { encoding: 'utf8' }); |
| 199 | + |
| 200 | + for (const template of FIXTURE_TEMPLATES) { |
| 201 | + const dir = fixtureTemplateDir(template); |
| 202 | + const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')); |
| 203 | + expect( |
| 204 | + pkg.dependencies['@objectstack/spec'], |
| 205 | + `${template}/package.json @objectstack/* ranges move to the scaffolder's major`, |
| 206 | + ).toBe('^42.0.0'); |
| 207 | + expect(pkg.devDependencies['@objectstack/cli']).toBe('^42.0.0'); |
| 208 | + expect( |
| 209 | + pkg.dependencies.chalk, |
| 210 | + 'a non-@objectstack dependency is never touched', |
| 211 | + ).toBe('^6.0.0'); |
| 212 | + |
| 213 | + expect(fs.readFileSync(path.join(dir, 'objectstack.config.ts'), 'utf8')).toContain( |
| 214 | + "engines: { protocol: '^42' }", |
| 215 | + ); |
| 216 | + |
| 217 | + const manifest = fs.readFileSync(path.join(dir, 'objectstack.manifest.json'), 'utf8'); |
| 218 | + expect(manifest).toContain('"specVersion": "^42.0.0"'); |
| 219 | + expect( |
| 220 | + manifest, |
| 221 | + 'the manifest is rewritten as TEXT, so unrelated compact structure survives', |
| 222 | + ).toContain('"scaffold": { "variables": [] }'); |
| 223 | + } |
| 224 | + |
| 225 | + // A guard that made the version pass silently stop stamping would be far |
| 226 | + // worse than the finding it fixes, so the run is observed to REPORT both. |
| 227 | + expect(stdout).toContain('2 template(s) in lockstep with create-objectstack@42.0.0'); |
| 228 | + expect(stdout).toContain('second/objectstack.manifest.json'); |
| 229 | + }); |
| 230 | +}); |
| 231 | + |
| 232 | +// ── stampedPaths() is derived from the walk, never a restated list ────────── |
| 233 | + |
| 234 | +describe('stampedPaths()', () => { |
| 235 | + it('covers every discovered template — the case a literal list fails', async () => { |
| 236 | + const sync = await loadSync(fixtureScript); |
| 237 | + const paths = sync.stampedPaths({ root: fixture }); |
| 238 | + const prefix = 'packages/create-objectstack/src/templates'; |
| 239 | + |
| 240 | + expect(paths).toEqual([ |
| 241 | + `${prefix}/blank/objectstack.config.ts`, |
| 242 | + `${prefix}/blank/objectstack.manifest.json`, |
| 243 | + `${prefix}/blank/package.json`, |
| 244 | + `${prefix}/second/objectstack.config.ts`, |
| 245 | + `${prefix}/second/objectstack.manifest.json`, |
| 246 | + `${prefix}/second/package.json`, |
| 247 | + ]); |
| 248 | + |
| 249 | + // The finding, stated as an assertion: the pair `cut-rc.yml` spells |
| 250 | + // literally is a STRICT SUBSET of what the version pass actually writes as |
| 251 | + // soon as a second template exists. An implementation that restated |
| 252 | + // `blank` would satisfy every other assertion in this file. |
| 253 | + const literals = [ |
| 254 | + `${prefix}/blank/objectstack.config.ts`, |
| 255 | + `${prefix}/blank/objectstack.manifest.json`, |
| 256 | + ]; |
| 257 | + const uncovered = paths.filter((p) => !literals.includes(p)); |
| 258 | + expect( |
| 259 | + uncovered.some((p) => p.includes('/second/')), |
| 260 | + 'the second template is covered by the declaration and by no literal `blank` pair', |
| 261 | + ).toBe(true); |
| 262 | + }); |
| 263 | + |
| 264 | + it('names only paths that exist — consumers build git pathspecs out of them', async () => { |
| 265 | + const sync = await loadSync(); |
| 266 | + const paths = sync.stampedPaths(); |
| 267 | + expect(paths.length).toBeGreaterThan(0); |
| 268 | + for (const p of paths) { |
| 269 | + expect(p, 'repo-relative, never absolute').not.toMatch(/^([/]|[A-Za-z]:)/); |
| 270 | + expect(p, 'POSIX separators — these are git pathspecs downstream').not.toContain('\\'); |
| 271 | + expect(fs.existsSync(path.join(repoRoot, p)), `${p} exists in this checkout`).toBe(true); |
| 272 | + } |
| 273 | + expect(new Set(paths).size, 'no duplicates').toBe(paths.length); |
| 274 | + expect([...paths].sort(), 'stable order').toEqual(paths); |
| 275 | + }); |
| 276 | + |
| 277 | + it('agrees with the live walk rather than with a remembered template set', async () => { |
| 278 | + const sync = await loadSync(); |
| 279 | + const templates = sync.findTemplateDirs(); |
| 280 | + expect(templates.length).toBeGreaterThan(0); |
| 281 | + |
| 282 | + const files = [sync.TEMPLATE_PKG_FILE, ...sync.TEXT_STAMPS.map((s) => s.file)]; |
| 283 | + const expected = templates |
| 284 | + .flatMap((t) => files.map((f) => `${sync.TEMPLATE_DIR}/${t}/${f}`)) |
| 285 | + .sort(); |
| 286 | + expect(sync.stampedPaths()).toEqual(expected); |
| 287 | + }); |
| 288 | + |
| 289 | + it('REFUSES an empty template set instead of returning an empty allowlist', async () => { |
| 290 | + const sync = await loadSync(); |
| 291 | + const empty = fs.mkdtempSync(path.join(os.tmpdir(), 'sync-template-versions-empty-')); |
| 292 | + fs.mkdirSync(path.join(empty, 'packages', 'create-objectstack', 'src', 'templates'), { |
| 293 | + recursive: true, |
| 294 | + }); |
| 295 | + try { |
| 296 | + // An empty list reads exactly like "no template paths need staging" and |
| 297 | + // means "the directory moved" — the vacuous-green shape the script's own |
| 298 | + // run refuses, and the one `cut-rc.yml` already guards for the doc half. |
| 299 | + expect(() => sync.stampedPaths({ root: empty })).toThrow(/no template directories/i); |
| 300 | + } finally { |
| 301 | + fs.rmSync(empty, { recursive: true, force: true }); |
| 302 | + } |
| 303 | + }); |
| 304 | +}); |
0 commit comments