Skip to content

Commit af102f0

Browse files
os-trumpclaude
andauthored
test(cli): widen the scaffold-comment pin to both scaffolders (#15151)
`init-template-comments-self-contained.test.ts` rendered its population from one source — `TEMPLATES` from `init.js` — so its three assertions held for `os init` only. `create.ts` exports a second, independent template map whose entries render text straight into a scaffolded project, and nothing read it: an edit to a `create` literal citing an ADR, or linking a docs page that later moves, shipped to every `os create` user with every gate green. The population is now DERIVED from both maps, the way `scaffold-manifest-schema.test.ts` derives its own sweep, so a template added to either map is swept the day it is added. `create` has no `configContent`/`srcFiles` split, so every entry of its one `files` map is rendered and serialised exactly the way `Create.run()` does. The three assertions are reused unchanged; only their labels now name the scaffold arm (`init:app`, `create:example`) so a failure says which command ships the text. The `create` vacuity guard checks its in-source LITERALS were reached rather than requiring an `objectstack.config.ts`, which its `plugin` template deliberately does not emit. Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6e2caec commit af102f0

1 file changed

Lines changed: 156 additions & 39 deletions

File tree

packages/cli/test/init-template-comments-self-contained.test.ts

Lines changed: 156 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22
//
3-
// Every comment that ships into a project scaffolded by `objectstack init`
4-
// must be followable by the person reading it — someone who has that
5-
// project and nothing else.
3+
// Every comment that ships into a project this package scaffolds must be
4+
// followable by the person reading it — someone who has that project and
5+
// nothing else. This package has TWO scaffolders and both ship such text,
6+
// so both are swept: `objectstack init` and `objectstack create`.
67
//
78
// ## The defect
89
//
@@ -16,19 +17,47 @@
1617
// bundled template *files*; this is the OTHER scaffolder, which renders its
1718
// templates as in-source string literals instead.
1819
//
20+
// ## Why the population spans BOTH scaffolders
21+
//
22+
// `packages/cli/src/commands/create.ts` exports a second, independent
23+
// template map (`templates`) whose entries render text — an
24+
// `objectstack.config.ts`, a plugin `src/index.ts`, a `README.md` — as
25+
// in-source string literals and write them straight into the user's
26+
// project. Same emitter shape, same population: text a scaffolded project
27+
// actually receives. While this pin read only `init`'s map, its three
28+
// assertions held for `init` only, and an edit to a `create` literal that
29+
// cited an ADR or linked a docs page that later moved shipped to every
30+
// `os create` user with every gate green. So the population is DERIVED
31+
// from both maps — never written down — the way
32+
// `scaffold-manifest-schema.test.ts` derives its own sweep: a template
33+
// added to either map is swept the day it is added, with nobody
34+
// remembering to extend this file.
35+
//
36+
// `create` has no `configContent` / `srcFiles` split. Every file it writes
37+
// lives in one `files` map keyed by the path it lands at and is rendered by
38+
// calling that entry, so this pin renders EVERY entry and serialises each
39+
// one exactly the way `Create.run()` does (a string verbatim, anything else
40+
// through `JSON.stringify(_, null, 2)`). Sweeping the whole map rather than
41+
// a chosen subset is deliberate: a filter is a place a future file can
42+
// escape through silently, which is the shape of the defect above.
43+
//
1944
// ## Why the population is the RENDERED output, not the source file
2045
//
2146
// `init.ts` also carries its own ordinary source comments that legitimately
2247
// cite ADRs and issue numbers (e.g. the `printCreatedFilesSummary` doc
2348
// comment cites #10499) — those never ship, because they live outside the
2449
// `configContent` / `srcFiles` functions the command actually writes to
25-
// disk. A pin that greps `init.ts` wholesale would match those too and
26-
// report on the wrong population. So this pin does not read the source
27-
// file at all: it calls the exact functions the `init` command calls
28-
// (`template.configContent(...)`, `writeTemplateSrcFiles(...)`) and scans
29-
// the files they actually write — the same real emitter
30-
// `init-scaffold-authoring-rules.test.ts` uses, for the same reason (so
31-
// neither test can drift from what `init` really does).
50+
// disk. `create.ts` is the same: its `run()` body cites `packages/plugins`
51+
// as a destination directory, which ships nowhere. A pin that grepped
52+
// either source file wholesale would match those too and report on the
53+
// wrong population. So this pin does not read the source files at all: it
54+
// calls the exact functions each command calls
55+
// (`template.configContent(...)`, `writeTemplateSrcFiles(...)`, and for
56+
// `create` the entries of `template.files`) and scans the files they
57+
// actually write — the same real emitters
58+
// `init-scaffold-authoring-rules.test.ts` and
59+
// `scaffold-manifest-schema.test.ts` use, for the same reason (so no test
60+
// can drift from what the commands really do).
3261
//
3362
// ## Why this pin has TWO halves, and why the second is the load-bearing one
3463
//
@@ -58,6 +87,7 @@ import fs from 'node:fs';
5887
import path from 'node:path';
5988
import { fileURLToPath } from 'node:url';
6089
import { TEMPLATES, sanitizeNamespace, writeTemplateSrcFiles } from '../src/commands/init.js';
90+
import { templates as createTemplates } from '../src/commands/create.js';
6191

6292
const HERE = path.dirname(fileURLToPath(import.meta.url));
6393
const TMP_ROOT = path.resolve(HERE, '../tmp');
@@ -70,43 +100,99 @@ afterAll(() => {
70100
});
71101

72102
interface Rendered {
73-
templateKey: string;
103+
/** `<command>:<template key>` — the command a user would have typed. */
104+
scaffoldId: string;
74105
file: string;
75106
content: string;
107+
/**
108+
* The template entry rendered a STRING — an in-source literal, the only
109+
* kind of emitted file that can carry prose. Used by the vacuity guard
110+
* so "this arm was swept" cannot be satisfied by serialised JSON alone.
111+
*/
112+
fromLiteral: boolean;
113+
}
114+
115+
/** A throwaway project directory, registered for cleanup. */
116+
function makeRoot(scaffoldId: string): string {
117+
fs.mkdirSync(TMP_ROOT, { recursive: true });
118+
const root = fs.mkdtempSync(path.join(TMP_ROOT, `render-${scaffoldId.replace(':', '-')}-`));
119+
roots.push(root);
120+
return root;
76121
}
77122

78123
/**
79-
* Render every built-in template through `init`'s own emitter — the exact
80-
* functions the command calls, writing to real files in a throwaway
81-
* directory (mirroring `init-scaffold-authoring-rules.test.ts`) — and
82-
* return every file it produced. This IS the population the defect lives
83-
* in: text a scaffolded project actually receives.
124+
* Render every built-in template of BOTH scaffolders through its own
125+
* emitter — the exact functions each command calls, writing to real files
126+
* in a throwaway directory (mirroring `init-scaffold-authoring-rules.test.ts`)
127+
* — and return every file they produced. This IS the population the defect
128+
* lives in: text a scaffolded project actually receives.
84129
*/
85130
function renderAll(): Rendered[] {
86131
const namespace = sanitizeNamespace(PROJECT_NAME);
87132
const out: Rendered[] = [];
88-
fs.mkdirSync(TMP_ROOT, { recursive: true });
89133

134+
const collect = (scaffoldId: string, root: string, literals: Set<string>) => {
135+
const walk = (dir: string) => {
136+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
137+
const abs = path.join(dir, entry.name);
138+
if (entry.isDirectory()) walk(abs);
139+
else {
140+
const file = path.relative(root, abs);
141+
out.push({
142+
scaffoldId,
143+
file,
144+
content: fs.readFileSync(abs, 'utf8'),
145+
fromLiteral: literals.has(file),
146+
});
147+
}
148+
}
149+
};
150+
walk(root);
151+
};
152+
153+
// ── `os init -t <key>`: every template renders a config plus src files ──
90154
for (const templateKey of Object.keys(TEMPLATES)) {
91155
const template = TEMPLATES[templateKey];
92-
const root = fs.mkdtempSync(path.join(TMP_ROOT, `render-${templateKey}-`));
93-
roots.push(root);
156+
const root = makeRoot(`init:${templateKey}`);
94157

95158
fs.writeFileSync(
96159
path.join(root, 'objectstack.config.ts'),
97160
template.configContent(PROJECT_NAME, namespace),
98161
);
99162
writeTemplateSrcFiles(template.srcFiles, root, PROJECT_NAME, namespace);
100163

101-
const walk = (dir: string) => {
164+
// Everything `init` emits here is an in-source string literal.
165+
const literals = new Set<string>();
166+
const markAll = (dir: string) => {
102167
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
103168
const abs = path.join(dir, entry.name);
104-
if (entry.isDirectory()) walk(abs);
105-
else out.push({ templateKey, file: path.relative(root, abs), content: fs.readFileSync(abs, 'utf8') });
169+
if (entry.isDirectory()) markAll(abs);
170+
else literals.add(path.relative(root, abs));
106171
}
107172
};
108-
walk(root);
173+
markAll(root);
174+
collect(`init:${templateKey}`, root, literals);
175+
}
176+
177+
// ── `os create <key> <name>`: one `files` map keyed by destination path ─
178+
for (const templateKey of Object.keys(createTemplates)) {
179+
const template = createTemplates[templateKey as keyof typeof createTemplates];
180+
const root = makeRoot(`create:${templateKey}`);
181+
const files = template.files as Record<string, (name: string) => unknown>;
182+
const literals = new Set<string>();
183+
184+
for (const [filePath, render] of Object.entries(files)) {
185+
const content = render(PROJECT_NAME);
186+
// Exactly what `Create.run()` writes for this entry.
187+
const text = typeof content === 'string' ? content : JSON.stringify(content, null, 2);
188+
if (typeof content === 'string') literals.add(filePath);
189+
const abs = path.join(root, filePath);
190+
fs.mkdirSync(path.dirname(abs), { recursive: true });
191+
fs.writeFileSync(abs, text);
192+
}
193+
collect(`create:${templateKey}`, root, literals);
109194
}
195+
110196
return out;
111197
}
112198

@@ -124,17 +210,36 @@ const MONOREPO_ONLY = [
124210
{ label: 'a monorepo package path', re: /\bpackages\/[a-z0-9][\w-]*\//i },
125211
];
126212

127-
describe('rendered init templates are followable by a stranger', () => {
213+
describe('rendered scaffold templates are followable by a stranger', () => {
128214
const rendered = renderAll();
129215

130216
// ── vacuity guard: prove this is reading real rendered output ──────────
131-
it('rendered a real, non-empty project per template (vacuity guard)', () => {
217+
it('rendered a real, non-empty project per template of BOTH scaffolders (vacuity guard)', () => {
132218
expect(Object.keys(TEMPLATES).length).toBeGreaterThan(0);
219+
expect(Object.keys(createTemplates).length).toBeGreaterThan(0);
133220
expect(rendered.length).toBeGreaterThan(0);
221+
134222
for (const templateKey of Object.keys(TEMPLATES)) {
135-
const files = rendered.filter((r) => r.templateKey === templateKey);
136-
expect(files.map((f) => f.file), `template "${templateKey}"`).toContain('objectstack.config.ts');
223+
const files = rendered.filter((r) => r.scaffoldId === `init:${templateKey}`);
224+
expect(files.map((f) => f.file), `template "init:${templateKey}"`).toContain('objectstack.config.ts');
225+
}
226+
227+
// `create`'s templates are NOT checked for that one filename: its
228+
// `plugin` template emits no `objectstack.config.ts` at all (its
229+
// `src/index.ts` declares a `Plugin` object instead), so requiring one
230+
// would report on a surface that scaffolder does not have. What must
231+
// hold for every `create` template is that the sweep reached its
232+
// in-source LITERALS — the only emitted files that can carry prose —
233+
// which is where this pin's three assertions have anything to read.
234+
for (const templateKey of Object.keys(createTemplates)) {
235+
const literals = rendered.filter((r) => r.scaffoldId === `create:${templateKey}` && r.fromLiteral);
236+
expect(
237+
literals.length,
238+
`template "create:${templateKey}" contributed no rendered string literal — the sweep ` +
239+
'reached none of its prose, so every assertion below passes vacuously for it',
240+
).toBeGreaterThan(0);
137241
}
242+
138243
// The two templates that emit an object (app, plugin) must have reached
139244
// the OWD comment's file, or assertion 2 below would vacuously pass.
140245
// Selected STRUCTURALLY (anything under src/objects/ that is not the
@@ -145,16 +250,28 @@ describe('rendered init templates are followable by a stranger', () => {
145250
expect(objectFiles.length).toBeGreaterThan(0);
146251
});
147252

253+
// The second scaffolder, named — so a future edit that drops it from the
254+
// population fails with this card's own vocabulary rather than a bare
255+
// count that a shrinking sweep satisfies just as well.
256+
it('sweeps the `os create` scaffolder, not just `os init`', () => {
257+
const ids = [...new Set(rendered.map((r) => r.scaffoldId))];
258+
expect(ids).toContain('create:example');
259+
expect(ids).toContain('create:plugin');
260+
expect(ids.filter((id) => id.startsWith('init:')).length).toBe(Object.keys(TEMPLATES).length);
261+
expect(ids.filter((id) => id.startsWith('create:')).length).toBe(Object.keys(createTemplates).length);
262+
});
263+
148264
// ── assertion 1: nothing unfollowable ───────────────────────────────────
149-
it.each(rendered.map((r) => [`${r.templateKey}/${r.file}`, r] as const))(
265+
it.each(rendered.map((r) => [`${r.scaffoldId}/${r.file}`, r] as const))(
150266
'%s cites nothing that only exists in this monorepo',
151267
(_label, r) => {
268+
const command = r.scaffoldId.startsWith('init:') ? 'os init' : 'os create';
152269
for (const { label, re } of MONOREPO_ONLY) {
153270
const hit = re.exec(r.content);
154271
expect(
155272
hit,
156-
`${r.templateKey}/${r.file} cites ${label} (${JSON.stringify(hit?.[0])}). A project ` +
157-
'scaffolded by `os init` ships no ADRs, no issue tracker and none of this repo\'s ' +
273+
`${r.scaffoldId}/${r.file} cites ${label} (${JSON.stringify(hit?.[0])}). A project ` +
274+
`scaffolded by \`${command}\` ships no ADRs, no issue tracker and none of this repo's ` +
158275
'scripts, so this reads as a reference the newcomer is failing to follow. State the ' +
159276
'fact self-contained, or link a public docs page — do not delete the rationale.',
160277
).toBeNull();
@@ -165,26 +282,26 @@ describe('rendered init templates are followable by a stranger', () => {
165282
// ── assertion 2: the rationale survives ─────────────────────────────────
166283
// The FACT each removed reference was carrying, matched loosely enough
167284
// that rewording is free and deletion is not.
168-
it.each(rendered.filter((r) => r.file === 'objectstack.config.ts').map((r) => [r.templateKey, r] as const))(
169-
'template "%s" objectstack.config.ts still explains the protocol range',
170-
(_templateKey, r) => {
171-
expect(r.content, `${r.templateKey}/${r.file} must still explain why the range exists`).toMatch(
285+
it.each(rendered.filter((r) => r.file === 'objectstack.config.ts').map((r) => [r.scaffoldId, r] as const))(
286+
'scaffold "%s" objectstack.config.ts still explains the protocol range',
287+
(_scaffoldId, r) => {
288+
expect(r.content, `${r.scaffoldId}/${r.file} must still explain why the range exists`).toMatch(
172289
/refuses this (app|plugin) at the boundary|incompatible runtime/i,
173290
);
174-
expect(r.content, `${r.templateKey}/${r.file} must still explain it was stamped by scaffolding`).toMatch(
291+
expect(r.content, `${r.scaffoldId}/${r.file} must still explain it was stamped by scaffolding`).toMatch(
175292
/stamped/i,
176293
);
177294
},
178295
);
179296

180297
const objectFiles = rendered.filter((r) => /^src\/objects\/(?!index\.ts$)[^/]+\.ts$/.test(r.file));
181-
it.each(objectFiles.map((r) => [`${r.templateKey}/${r.file}`, r] as const))(
298+
it.each(objectFiles.map((r) => [`${r.scaffoldId}/${r.file}`, r] as const))(
182299
'%s still explains the org-wide default',
183300
(_label, r) => {
184-
expect(r.content, `${r.templateKey}/${r.file} must still explain what OWD means`).toMatch(
301+
expect(r.content, `${r.scaffoldId}/${r.file} must still explain what OWD means`).toMatch(
185302
/org-wide default|OWD/i,
186303
);
187-
expect(r.content, `${r.templateKey}/${r.file} must still explain declaring it is required`).toMatch(
304+
expect(r.content, `${r.scaffoldId}/${r.file} must still explain declaring it is required`).toMatch(
188305
/required|refuses/i,
189306
);
190307
},
@@ -210,7 +327,7 @@ describe('rendered init templates are followable by a stranger', () => {
210327
const urls: { where: string; url: string; route: string }[] = [];
211328
for (const r of rendered) {
212329
for (const m of r.content.matchAll(/https:\/\/objectstack\.ai\/docs\/([\w./-]*[\w-])/g)) {
213-
urls.push({ where: `${r.templateKey}/${r.file}`, url: m[0], route: m[1] });
330+
urls.push({ where: `${r.scaffoldId}/${r.file}`, url: m[0], route: m[1] });
214331
}
215332
}
216333
// Non-vacuity: the rewrite puts docs links in every template on

0 commit comments

Comments
 (0)