4343 * bytes off disk — a renderer that is exported but no longer called would
4444 * pass every in-process assertion here.
4545 *
46- * ⚠️ `create-objectstack`'s `^6.0.0` is deliberately out of scope and is NOT
47- * asserted against: that package cannot import from `@objectstack/cli` (the
48- * dependency edge runs the other way), and unifying it would change what a
49- * scaffolded project installs.
46+ * 5. The THIRD scaffolder — `npx create-objectstack`, the documented on-ramp
47+ * — is now in scope (#16485). It still cannot IMPORT these constants: the
48+ * dependency edge runs the other way and the npx package must not pull the
49+ * CLI's closure. It reaches them by GENERATION instead
50+ * (`scripts/sync-scaffold-emission-policy.mjs` stamps its bundled template
51+ * from this same file at build time, and `pnpm check:scaffold-emission-policy`
52+ * reddens on drift). While it was out of scope its `typescript` line sat at
53+ * `^6.0.0`, so two projects created the same day got different TypeScript
54+ * MAJORS depending on which entry point the reader followed.
55+ *
56+ * ⚠️ The on-ramp is measured by DRIVING it — spawning its real `bin/` entry into
57+ * a throwaway directory and reading the emitted `package.json` off disk — and
58+ * never by reading the committed template the generator writes. A pin that read
59+ * the generator's own output would be reading the same source it is guarding,
60+ * and would stay green through a build that stopped copying templates at all.
61+ * Its `dist/` is present because `@objectstack/cli#test` depends on `^build`.
5062 *
5163 * Spawned through `bin/run-dev.js` + tsx, so this suite does not depend on
5264 * `packages/cli/dist` having been built (`@objectstack/cli#test` depends on
5365 * `^build` only) — the same reason `create-refuses-invalid-project-name.e2e.test.ts`
5466 * spawns that way.
5567 */
5668
57- import { describe , it , expect } from 'vitest' ;
69+ import { describe , it , expect , afterAll , beforeAll } from 'vitest' ;
5870import { execFile } from 'node:child_process' ;
5971import { mkdtempSync , readFileSync , readdirSync , rmSync } from 'node:fs' ;
6072import { tmpdir } from 'node:os' ;
@@ -64,6 +76,7 @@ import { childEnv } from './helpers/serve-process.js';
6476import {
6577 renderScaffoldPackageJson ,
6678 renderScaffoldTsconfig ,
79+ SCAFFOLD_PNPM_RANGE ,
6780 SCAFFOLD_TSCONFIG_INCLUDE_WITH_ROOT_CONFIG ,
6881 SCAFFOLD_TSX_RANGE ,
6982 SCAFFOLD_TYPES_NODE_RANGE ,
@@ -78,6 +91,16 @@ const HERE = resolve(fileURLToPath(import.meta.url), '..');
7891const CLI = resolve ( HERE , '../bin/run-dev.js' ) ;
7992const TSX = resolve ( HERE , '../../../node_modules/.bin/tsx' ) ;
8093
94+ /**
95+ * The on-ramp's real entry point and the template it ships, both declared as
96+ * cross-package inputs of `@objectstack/cli` (scripts/cross-package-test-inputs.mjs,
97+ * mirrored into turbo.json) — a template-only diff changes what the block at the
98+ * bottom of this file measures, so without the declaration this suite would
99+ * replay a cached green over exactly the divergence it exists to catch.
100+ */
101+ const ON_RAMP_BIN = resolve ( HERE , '../../..' , 'packages/create-objectstack/bin/create-objectstack.js' ) ;
102+ const ON_RAMP_TEMPLATE_PKG = resolve ( HERE , '../../..' , 'packages/create-objectstack/src/templates/blank/package.json' ) ;
103+
81104// One `resolve(HERE, …)` call per line and nothing split across lines:
82105// `check:cross-package-test-inputs` reconstructs these reads by SOURCE SCAN,
83106// and a spelling it cannot parse leaves the glob declared and held by nothing.
@@ -279,3 +302,125 @@ describe('the emitted tsconfig.json comes from the shared renderer', () => {
279302 } ,
280303 ) ;
281304} ) ;
305+
306+ describe ( 'the on-ramp emits the same policy — measured by DRIVING it' , ( ) => {
307+ /**
308+ * `npx create-objectstack`'s emitted `package.json`, produced by spawning the
309+ * package's real `bin/` entry. `--skip-install` and `--skip-skills` keep the
310+ * run offline and fs-only; everything this block reads is written before
311+ * either step would run.
312+ */
313+ let sandbox = '' ;
314+ let emitted : Record < string , unknown > | null = null ;
315+ let failure = '' ;
316+
317+ beforeAll ( async ( ) => {
318+ sandbox = mkdtempSync ( join ( tmpdir ( ) , 'on-ramp-policy-' ) ) ;
319+ const run = await new Promise < { code : number ; stderr : string } > ( ( done ) => {
320+ execFile (
321+ process . execPath ,
322+ [ ON_RAMP_BIN , PROBE_NAME , '--skip-install' , '--skip-skills' ] ,
323+ { cwd : sandbox , maxBuffer : 8 * 1024 * 1024 , env : childEnv ( { NO_COLOR : '1' } ) } ,
324+ ( err , _stdout , stderr ) => {
325+ done ( { code : err ? Number ( ( err as { code ?: unknown } ) . code ?? 1 ) : 0 , stderr : String ( stderr ) } ) ;
326+ } ,
327+ ) ;
328+ } ) ;
329+ if ( run . code !== 0 ) {
330+ // `bin/create-objectstack.js` imports `../dist/index.js`, so an unbuilt
331+ // package fails here rather than anywhere informative. Say which build.
332+ failure =
333+ `create-objectstack exited ${ run . code } . If it could not resolve ../dist/index.js, this suite ` +
334+ 'ran without its dependency build — `pnpm --filter create-objectstack build`, which ' +
335+ `\`@objectstack/cli#test\` normally supplies via \`^build\`.\n${ run . stderr } ` ;
336+ return ;
337+ }
338+ const projectDir = join ( sandbox , PROBE_NAME ) ;
339+ emitted = JSON . parse ( readFileSync ( join ( projectDir , 'package.json' ) , 'utf8' ) ) as Record < string , unknown > ;
340+ } , RUN_TIMEOUT_MS ) ;
341+
342+ afterAll ( ( ) => {
343+ if ( sandbox ) rmSync ( sandbox , { recursive : true , force : true } ) ;
344+ } ) ;
345+
346+ /** The on-ramp's emission, beside the five the two CLI commands render. */
347+ function allSixManifests ( ) : Array < { id : string ; manifest : Record < string , unknown > } > {
348+ return [ ...emittedManifests ( ) , { id : 'npx create-objectstack' , manifest : emitted ! } ] ;
349+ }
350+
351+ it ( 'really drove the on-ramp, and got a manifest with policy in it (control)' , ( ) => {
352+ expect ( failure , failure ) . toBe ( '' ) ;
353+ expect ( readdirSync ( join ( sandbox , PROBE_NAME ) ) . length ) . toBeGreaterThan ( 1 ) ;
354+ // Without this, every assertion below would range over an empty harvest —
355+ // the vacuity that would let this whole block certify the defect it exists
356+ // for. The on-ramp declares exactly one third-party dependency today, so
357+ // `toContain` rather than a count.
358+ expect ( thirdPartyOnly ( emitted ?. devDependencies as Record < string , unknown > ) . map ( ( [ n ] ) => n ) ) . toContain (
359+ 'typescript' ,
360+ ) ;
361+ expect ( ( emitted ?. engines as Record < string , unknown > | undefined ) ?. pnpm ) . toBeTypeOf ( 'string' ) ;
362+ } ) ;
363+
364+ it ( 'declares exactly one range per third-party dependency, across ALL THREE scaffolders' , ( ) => {
365+ const byName = new Map < string , Map < string , string [ ] > > ( ) ;
366+ for ( const { id, manifest } of allSixManifests ( ) ) {
367+ for ( const [ name , range ] of [
368+ ...thirdPartyOnly ( manifest . dependencies as Record < string , unknown > ) ,
369+ ...thirdPartyOnly ( manifest . devDependencies as Record < string , unknown > ) ,
370+ ] ) {
371+ const ranges = byName . get ( name ) ?? new Map < string , string [ ] > ( ) ;
372+ ranges . set ( range , [ ...( ranges . get ( range ) ?? [ ] ) , id ] ) ;
373+ byName . set ( name , ranges ) ;
374+ }
375+ }
376+ const disagreements : string [ ] = [ ] ;
377+ for ( const [ name , ranges ] of byName ) {
378+ if ( ranges . size === 1 ) continue ;
379+ disagreements . push (
380+ `${ name } : ${ [ ...ranges ] . map ( ( [ r , ids ] ) => `${ r } (${ ids . join ( ', ' ) } )` ) . join ( ' vs ' ) } ` ,
381+ ) ;
382+ }
383+ expect (
384+ disagreements ,
385+ 'a scaffolded project must declare the same third-party ranges whichever documented entry '
386+ + 'point created it. The on-ramp reaches the policy by generation, not import: run '
387+ + '`pnpm gen:scaffold-emission-policy` and commit the template it rewrites' ,
388+ ) . toEqual ( [ ] ) ;
389+ } ) ;
390+
391+ it ( 'emits the exported TypeScript and pnpm constants, not a restatement of them' , ( ) => {
392+ const typescriptRanges = new Set (
393+ allSixManifests ( ) . map (
394+ ( { manifest } ) =>
395+ ( manifest . devDependencies as Record < string , string > | undefined ) ?. typescript
396+ ?? ( manifest . dependencies as Record < string , string > | undefined ) ?. typescript ,
397+ ) ,
398+ ) ;
399+ expect ( [ ...typescriptRanges ] , 'every emission declares typescript, at one range' ) . toEqual ( [
400+ SCAFFOLD_TYPESCRIPT_RANGE ,
401+ ] ) ;
402+
403+ const pnpmRanges = new Set (
404+ allSixManifests ( ) . map ( ( { manifest } ) => ( manifest . engines as Record < string , string > | undefined ) ?. pnpm ) ,
405+ ) ;
406+ expect ( [ ...pnpmRanges ] , 'every emission declares engines.pnpm, at one range' ) . toEqual ( [
407+ SCAFFOLD_PNPM_RANGE ,
408+ ] ) ;
409+ } ) ;
410+
411+ it ( 'carries the committed template through unchanged — the generator ran, the build copied' , ( ) => {
412+ // The one place the committed template is read, and deliberately as a
413+ // CONSEQUENCE rather than as the expectation: the drive above already
414+ // settled what the on-ramp emits. This says the bytes a reader would edit
415+ // are the bytes that shipped, so a stale `dist/` or a generator that never
416+ // ran is legible as itself rather than as a policy disagreement.
417+ const committed = JSON . parse ( readFileSync ( ON_RAMP_TEMPLATE_PKG , 'utf8' ) ) as {
418+ devDependencies ?: Record < string , string > ;
419+ engines ?: Record < string , string > ;
420+ } ;
421+ expect ( committed . devDependencies ?. typescript ) . toBe (
422+ ( emitted ?. devDependencies as Record < string , string > | undefined ) ?. typescript ,
423+ ) ;
424+ expect ( committed . engines ?. pnpm ) . toBe ( ( emitted ?. engines as Record < string , string > | undefined ) ?. pnpm ) ;
425+ } ) ;
426+ } ) ;
0 commit comments