Skip to content

Commit 4b2de3c

Browse files
os-steveclaude
andauthored
fix(scripts): export sync-template-versions' declaration surface and guard its entry point (#9648)
The script models its targets well — a TEXT_STAMPS table and a findTemplateDirs() walk that is deliberately not a curated list — but exported none of it and ran the sync at module scope, so a consumer that imported it to ask which paths the version pass writes rewrote every template instead of getting an answer. Export stampedPaths() plus the raw declarations, and add the entry-point guard #9064 added to check-docs-image-tag.mjs. The version read moved out of module scope into loadScaffolderVersion(), which throws instead of exiting: an import that can kill its host process is a worse hazard than the sync, not a smaller one. Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 1cf043c commit 4b2de3c

2 files changed

Lines changed: 559 additions & 119 deletions

File tree

Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
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

Comments
 (0)