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//
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';
5887import path from 'node:path' ;
5988import { fileURLToPath } from 'node:url' ;
6089import { TEMPLATES , sanitizeNamespace , writeTemplateSrcFiles } from '../src/commands/init.js' ;
90+ import { templates as createTemplates } from '../src/commands/create.js' ;
6191
6292const HERE = path . dirname ( fileURLToPath ( import . meta. url ) ) ;
6393const TMP_ROOT = path . resolve ( HERE , '../tmp' ) ;
@@ -70,43 +100,99 @@ afterAll(() => {
70100} ) ;
71101
72102interface 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 */
85130function 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 : / \b p a c k a g e s \/ [ a - z 0 - 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 / r e f u s e s t h i s ( a p p | p l u g i n ) a t t h e b o u n d a r y | i n c o m p a t i b l e r u n t i m e / 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 / s t a m p e d / i,
176293 ) ;
177294 } ,
178295 ) ;
179296
180297 const objectFiles = rendered . filter ( ( r ) => / ^ s r c \/ o b j e c t s \/ (? ! i n d e x \. t s $ ) [ ^ / ] + \. t s $ / . 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 / o r g - w i d e d e f a u l t | O W D / 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 / r e q u i r e d | r e f u s e s / 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 ( / h t t p s : \/ \/ o b j e c t s t a c k \. a i \/ d o c s \/ ( [ \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