-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathtemplate-consistency.test.ts
More file actions
367 lines (332 loc) · 16.1 KB
/
Copy pathtemplate-consistency.test.ts
File metadata and controls
367 lines (332 loc) · 16.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license.
//
// Drift ratchets for the scaffolder's user-facing surfaces. Both of these
// rotted silently once before (#2899 follow-up): the bundled template pinned
// `^6.0.0` while the registry was publishing 14.x, and the README advertised
// a template set (`minimal-api`/`full-stack`/`plugin`) that never shipped.
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 } from 'node:url';
import { syncObjectStackDeps } from './pkg-utils.js';
import { copyDir, TEMPLATE_FILE_ALIASES } from './template-copy.js';
const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const repoRoot = path.resolve(pkgRoot, '..', '..');
const ownPkg = JSON.parse(fs.readFileSync(path.join(pkgRoot, 'package.json'), 'utf8'));
const ownMajor = Number(ownPkg.version.split('.')[0]);
// The TEMPLATES registry, read from src/index.ts as text — importing the
// module would run the CLI (it calls program.parse() on import). Parse only
// the lines between the TEMPLATES declaration and its closing brace.
const REGISTRY_SOURCE = fs.readFileSync(path.join(pkgRoot, 'src', 'index.ts'), 'utf8');
const registryBlock =
/const TEMPLATES: Record<string, TemplateInfo> = \{([\s\S]*?)\n\};/.exec(
REGISTRY_SOURCE,
)?.[1] ?? '';
const registryTemplates = [...registryBlock.matchAll(/^ ([a-z][a-z0-9_]*): \{$/gm)].map(
(m) => m[1],
);
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 ^<major>.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);
}
});
});
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);
});
});
// Packing ratchet (#3120): every template file must survive a real `npm pack`
// and land in a scaffold under its intended name. `.gitignore` did not — npm
// strips it from the tarball at every depth, so the file the build had
// faithfully copied to dist/templates/blank/ was dropped at publish and every
// scaffolded project came out with no `.gitignore`, leaving node_modules/ and
// the secret-bearing .env from the template README un-ignored for every user.
//
// This bug is invisible to source-level assertions: the file is present in
// src/templates/, present in a local build, and only vanishes at publish. So
// pack for real and scaffold from the extracted tarball with the real copyDir.
// Reading the repo's own dist/ instead would be doubly false green — turbo's
// `test` task only dependsOn `^build` (not its own build) and excludes dist/**
// from its inputs, so dist/ here is routinely absent or stale, and a pass on it
// would be cached.
describe('templates survive npm packing', () => {
const templatesSrc = path.join(pkgRoot, 'src', 'templates');
const blankSrc = path.join(templatesSrc, 'blank');
const walkRel = (dir: string, rel = ''): string[] =>
fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const relPath = rel ? `${rel}/${entry.name}` : entry.name;
if (entry.isDirectory()) return walkRel(path.join(dir, entry.name), relPath);
return entry.isFile() ? [relPath] : [];
});
// The name a template file is expected to land under, i.e. its alias applied
// to the basename.
const scaffoldedAs = (rel: string): string => {
const parts = rel.split('/');
const base = parts[parts.length - 1];
parts[parts.length - 1] = TEMPLATE_FILE_ALIASES.get(base) ?? base;
return parts.join('/');
};
let tmp: string;
let packed: string[];
let scaffolded: string[];
let collected: string[];
beforeAll(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'create-objectstack-pack-'));
// Stage exactly what a publish would: the *real* package.json (so the real
// `files` allowlist decides what ships) plus src/templates copied to
// dist/templates, which is what tsup.config.ts's onSuccess hook does. The
// test asserts that mirror below rather than assuming it.
fs.cpSync(path.join(pkgRoot, 'package.json'), path.join(tmp, 'package.json'));
fs.cpSync(templatesSrc, path.join(tmp, 'dist', 'templates'), { recursive: true });
execFileSync('npm', ['pack', '--ignore-scripts'], { cwd: tmp, stdio: 'pipe' });
const tgz = fs.readdirSync(tmp).find((f) => f.endsWith('.tgz'));
if (!tgz) throw new Error(`npm pack produced no tarball in ${tmp}`);
execFileSync('tar', ['xzf', tgz], { cwd: tmp });
packed = walkRel(path.join(tmp, 'package', 'dist', 'templates'));
// Scaffold from the *packed* template with the real copy logic.
const out = path.join(tmp, 'scaffold');
collected = [];
copyDir(path.join(tmp, 'package', 'dist', 'templates', 'blank'), out, collected);
scaffolded = walkRel(out);
}, 120_000);
afterAll(() => {
if (tmp) fs.rmSync(tmp, { recursive: true, force: true });
});
it('stages dist/templates the way this suite mirrors it', () => {
const tsupConfig = fs.readFileSync(path.join(pkgRoot, 'tsup.config.ts'), 'utf8');
expect(
tsupConfig.replace(/\s+/g, ' '),
'the packing ratchet stages dist/templates by hand because dist/ is not ' +
'built here; if the build stopped copying src/templates → dist/templates ' +
'that mirror is now a fiction — update both together',
).toContain("cpSync('src/templates', 'dist/templates', { recursive: true })");
});
// Covers every bundled template, not just blank: the tarball must carry
// src/templates/** verbatim (aliases are a scaffold-time concern).
it('carries every src/templates file into the tarball', () => {
expect(
packed.sort(),
'a file under src/templates/ did not survive `npm pack`. npm strips ' +
'.gitignore and .npmrc from tarballs at every depth — commit it under a ' +
'placeholder name and map it back in TEMPLATE_FILE_ALIASES, the way ' +
'_gitignore → .gitignore works.',
).toEqual(walkRel(templatesSrc).sort());
});
it('restores the aliased names when scaffolding', () => {
const expected = walkRel(blankSrc).map(scaffoldedAs).sort();
expect(scaffolded.sort()).toEqual(expected);
// What the CLI prints as "Created files:" must match what it actually wrote.
expect(collected.sort()).toEqual(expected);
});
// Not redundant with the file-set check above: that one applies the alias map
// to both sides, so emptying TEMPLATE_FILE_ALIASES makes it agree with itself
// on `_gitignore` and pass. Naming the destination literally is what pins the
// mapping down.
it('ships a .gitignore that ignores node_modules and the README secrets', () => {
const gitignore = path.join(tmp, 'scaffold', '.gitignore');
expect(fs.existsSync(gitignore), 'scaffold has no .gitignore').toBe(true);
const rules = fs.readFileSync(gitignore, 'utf8').split('\n').map((l) => l.trim());
expect(rules).toContain('node_modules');
// The template README has users write OS_AUTH_SECRET / OS_SECRET_KEY into a
// .env, and docker-compose.yml calls it "never committed" — so the ignore
// file, not just the prose, has to enforce that.
expect(rules).toContain('.env');
});
it('leaves a literal template dotfile that packs fine alone', () => {
// .dockerignore is NOT stripped — verified against the published 15.1.1
// tarball, which ships it while .gitignore is absent. It stays literal, so
// the alias map covers only what is genuinely broken.
expect(fs.existsSync(path.join(blankSrc, '.dockerignore'))).toBe(true);
expect(TEMPLATE_FILE_ALIASES.has('.dockerignore')).toBe(false);
expect(scaffolded).toContain('.dockerignore');
});
});
// pnpm 11 turned an unapproved dependency build script from a warning into a
// hard error, so the template declaring nothing meant `npx create-objectstack`
// + `pnpm install` exited 1 for every user on a current pnpm (#3119). Both keys
// are load-bearing and read by different pnpm versions: pnpm 11 honours only
// `allowBuilds`, while pnpm 10.0–10.30 understand only `onlyBuiltDependencies`.
describe('blank template pnpm build approvals (#3119)', () => {
const wsPath = path.join(pkgRoot, 'src', 'templates', 'blank', 'pnpm-workspace.yaml');
const APPROVED = ['better-sqlite3', 'esbuild'];
// Strip comments: the prose below explains these keys and must not satisfy
// an assertion that the settings themselves are supposed to satisfy.
const settings = fs.existsSync(wsPath)
? fs.readFileSync(wsPath, 'utf8').replace(/^\s*#.*$/gm, '')
: '';
it('ships a pnpm-workspace.yaml', () => {
expect(
fs.existsSync(wsPath),
'without it a fresh `pnpm install` exits 1 on pnpm 11 (ERR_PNPM_IGNORED_BUILDS)',
).toBe(true);
});
it('sets allowBuilds.<pkg> = true, the only key pnpm 11 reads', () => {
const block = /^allowBuilds:\n((?:[ \t]+.*\n?)*)/m.exec(settings)?.[1] ?? '';
for (const pkg of APPROVED) {
expect(
new RegExp(`^\\s+${pkg}:\\s*true\\s*$`, 'm').test(block),
`allowBuilds must set "${pkg}: true" — pnpm 11 errors on an unapproved build ` +
'and ignores onlyBuiltDependencies',
).toBe(true);
}
});
it('lists the same packages under onlyBuiltDependencies for pnpm 10.0–10.30', () => {
const block = /^onlyBuiltDependencies:\n((?:[ \t]*-.*\n?)*)/m.exec(settings)?.[1] ?? '';
for (const pkg of APPROVED) {
expect(
new RegExp(`^\\s*-\\s*${pkg}\\s*$`, 'm').test(block),
`onlyBuiltDependencies must list "${pkg}" — pnpm < 10.31 does not understand allowBuilds`,
).toBe(true);
}
});
});
describe('README template table', () => {
it('lists exactly the templates in the TEMPLATES registry', () => {
const readme = fs.readFileSync(path.join(pkgRoot, 'README.md'), 'utf8');
// Table rows under "## Templates": | `name` ... | source | description |
const section = readme.split(/^## Templates$/m)[1]?.split(/^## /m)[0] ?? '';
const documented = [...section.matchAll(/^\| `([a-z][a-z0-9_-]*)`/gm)].map(
(m) => m[1],
);
expect(documented.sort()).toEqual([...registryTemplates].sort());
});
});
// Skills catalog boundary (15.1 third-party eval): scaffolded projects once
// received the repo-internal `dogfood-verification` skill because the
// scaffolder installed with a repo-wide `skills add … --all`, whose discovery
// also walks `.claude/skills/`. The published catalog is exactly the root
// `skills/` directory; everything else must stay repo-internal.
describe('skills catalog boundary', () => {
const frontmatterOf = (file: string): string =>
/^---\n([\s\S]*?)\n---/.exec(fs.readFileSync(file, 'utf8'))?.[1] ?? '';
const isMarkedInternal = (fm: string): boolean =>
/^metadata:\s*$/m.test(fm) && /^ +internal:\s*true\s*(#.*)?$/m.test(fm);
const trackedSkillFiles = execFileSync('git', ['ls-files', '*SKILL.md'], {
cwd: repoRoot,
encoding: 'utf8',
})
.split('\n')
.filter(Boolean);
it('finds the curated skills/ catalog (sanity)', () => {
expect(
trackedSkillFiles.filter((f) => f.startsWith('skills/')).length,
).toBeGreaterThan(0);
});
it('marks every SKILL.md outside skills/ as metadata.internal', () => {
for (const rel of trackedSkillFiles) {
if (rel.startsWith('skills/')) continue;
expect(
isMarkedInternal(frontmatterOf(path.join(repoRoot, rel))),
`${rel} is outside the published skills/ catalog but is not hidden from ` +
'the skills CLI. Add to its frontmatter:\n' +
'metadata:\n internal: true\n' +
'or move it into skills/ if it is meant for customers.',
).toBe(true);
}
});
it('never marks a curated skills/ entry internal', () => {
for (const rel of trackedSkillFiles) {
if (!rel.startsWith('skills/')) continue;
expect(
isMarkedInternal(frontmatterOf(path.join(repoRoot, rel))),
`${rel} is in the published catalog but marked metadata.internal — ` +
'customers would silently stop receiving it.',
).toBe(false);
}
});
it('scaffolder installs from the curated skills/ subpath, not the repo root', () => {
expect(REGISTRY_SOURCE).toContain(
'skills add objectstack-ai/framework/skills --all',
);
expect(REGISTRY_SOURCE).not.toMatch(
/skills add objectstack-ai\/framework(?!\/skills)/,
);
});
// The /skills subpath is the hard boundary: the skills CLI's `--all`
// implies `--skill '*'`, which INCLUDES metadata.internal skills — so any
// customer-facing surface advertising a repo-root install would leak
// internal skills again.
it('no customer-facing surface advertises a repo-root skills install', () => {
const surfaces = [
'content/docs',
'skills',
'packages/create-objectstack',
// this file mentions the bare form on purpose (needle + error message)
':(exclude)packages/create-objectstack/src/template-consistency.test.ts',
// CHANGELOGs are auto-generated from changeset prose and legitimately
// quote a removed command in past tense while documenting its removal
// (#3101: "…advertised `skills add objectstack-ai/framework --all` … now
// scoped to the /skills subpath"). Documenting a fix is not advertising
// the anti-pattern — only real customer-facing surfaces count.
':(exclude)**/CHANGELOG.md',
];
let candidates = '';
try {
candidates = execFileSync(
'git',
['grep', '-nF', 'skills add objectstack-ai/framework', '--', ...surfaces],
{ cwd: repoRoot, encoding: 'utf8' },
);
} catch {
// git grep exits 1 on no matches — nothing to check then.
}
const rootInstalls = candidates
.split('\n')
.filter((line) => /skills add objectstack-ai\/framework(?!\/skills)/.test(line));
expect(
rootInstalls,
'these lines advertise `skills add objectstack-ai/framework` without ' +
'the /skills subpath — repo-root + --all installs internal skills',
).toEqual([]);
});
});
describe('syncObjectStackDeps', () => {
it('rewrites @objectstack/* ranges in deps and devDeps', () => {
const pkg = {
dependencies: { '@objectstack/spec': '^6.0.0', chalk: '^5.0.0' },
devDependencies: { '@objectstack/cli': '^6.0.0', typescript: '^6.0.0' },
};
syncObjectStackDeps(pkg, '14.7.0');
expect(pkg.dependencies['@objectstack/spec']).toBe('^14.7.0');
expect(pkg.devDependencies['@objectstack/cli']).toBe('^14.7.0');
expect(pkg.dependencies.chalk).toBe('^5.0.0');
expect(pkg.devDependencies.typescript).toBe('^6.0.0');
});
it('is a no-op on the 0.0.0 fallback version', () => {
const pkg = { dependencies: { '@objectstack/spec': '^6.0.0' } };
syncObjectStackDeps(pkg, '0.0.0');
expect(pkg.dependencies['@objectstack/spec']).toBe('^6.0.0');
});
});