Skip to content

Commit 88f60da

Browse files
committed
fix(create-objectstack): generate the on-ramp's version pins from the shared emission policy
`npx create-objectstack` declared `typescript: ^6.0.0` while `os init` and `os create` emitted `^5.3.0` from the shared `SCAFFOLD_*` constants, so two projects created the same day got different TypeScript majors depending on which documented entry point the reader followed. The structural cause is that `create-objectstack` cannot import from `@objectstack/cli` — the dependency edge runs the other way and the npx package must not pull the CLI's closure — so its bundled template restated the policy and the restatement decayed. Editing the values into agreement would leave them free to diverge again for the same reason, so they are generated instead: - `scripts/sync-scaffold-emission-policy.mjs` reads the constants out of `packages/cli/src/commands/init.ts` and stamps them into every bundled template's `package.json`; `create-objectstack`'s `build` runs it, and `pnpm check:scaffold-emission-policy` reds on drift (both legs in lint.yml). - `test/scaffold-emission-policy.e2e.test.ts` now drives all THREE scaffolders and compares the manifests they emit, rather than restating the constants. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ
1 parent 55bbd92 commit 88f60da

10 files changed

Lines changed: 823 additions & 17 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
"create-objectstack": minor
3+
---
4+
5+
`npx create-objectstack` now declares the same TypeScript range as `os init` and
6+
`os create`, and the value is generated rather than restated.
7+
8+
Three scaffolders write a new project's `package.json`, and the range that
9+
decides whether that project type-checks at all had split: `os init` and
10+
`os create` emitted `typescript: ^5.3.0` from a shared emission policy, while
11+
this package's bundled template carried `^6.0.0`. Two projects created the same
12+
day got different TypeScript **majors** depending on which documented entry
13+
point the reader followed.
14+
15+
- **What changed for a scaffolded project.** Its declared `typescript`
16+
devDependency floor moves from `^6.0.0` to `^5.3.0`. Both resolve to the same
17+
installed compiler on a fresh install; what moves is the floor the project
18+
**declares**, and a floor is a support promise. `^5.3.0` is the promise the
19+
docs already make — "ObjectStack works with TypeScript 5.3+" on the getting
20+
started page, "TypeScript 5.3.0 or later" in the deployment troubleshooting
21+
page — and it is measured rather than assumed: TypeScript 5.3.3 type-checks
22+
every shape these scaffolders emit with results identical to 6.0.3. The repo's
23+
own `typescript@^6.0.3` devDependency is deliberately not this value; the same
24+
doc sentence states both halves ("…but the project itself is built and tested
25+
against TypeScript 6.x"). `engines.pnpm` was already in agreement and is now
26+
held there by the same mechanism.
27+
- **Why the value is generated.** This package cannot import from
28+
`@objectstack/cli`: the dependency edge runs the other way, and the `npx`
29+
package must not pull the CLI's package closure. So the values are stamped
30+
into the bundled template at build time by
31+
`scripts/sync-scaffold-emission-policy.mjs`, read out of the same
32+
`SCAFFOLD_*` constants the other two scaffolders import, and
33+
`pnpm check:scaffold-emission-policy` reddens the moment the inlined values
34+
disagree with that source. Editing the two into agreement by hand would have
35+
left them free to diverge again on the next move, silently, for the same
36+
structural reason — which is how they diverged the first time.

.github/workflows/lint.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2033,6 +2033,25 @@ jobs:
20332033
- name: Template version-time rewriter self-test
20342034
run: pnpm check:template-version-sync
20352035

2036+
# #16485 — the BUILD-time generator beside the version-time one above, and
2037+
# the only one of the two whose --check leg runs on a real corpus here.
2038+
# `create-objectstack` cannot import @objectstack/cli (the dependency edge
2039+
# runs the other way, and the npx package must not pull the CLI's closure),
2040+
# so its bundled template RESTATED the scaffold emission policy and the
2041+
# restatement decayed: `typescript` reached `^6.0.0` there while `os init`
2042+
# and `os create` emitted `^5.3.0` from the shared constants, so two
2043+
# projects created the same day got different TypeScript majors depending
2044+
# on which documented entry point the reader followed. The values are now
2045+
# generated into the template from `packages/cli/src/commands/init.ts`, and
2046+
# this is the leg that reddens on drift — a hand edit into agreement would
2047+
# have satisfied the acceptance box and diverged again on the next move.
2048+
# Both legs run: --self-test covers the red paths a green corpus cannot
2049+
# reach (a renamed policy constant, a template omitting a stamped key, an
2050+
# unparseable template, an empty templates directory), --check covers the
2051+
# live tree.
2052+
- name: Scaffold emission policy generated into the on-ramp
2053+
run: pnpm check:scaffold-emission-policy
2054+
20362055
# #15332 — the THIRD version-time rewriter, and the third self-test beside
20372056
# the two above. scripts/sync-release-index-currency.mjs joins the root
20382057
# `version` chain and stamps the release index's "current series: X.Y.Z,

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@
5555
"check:react-page-adapter-contract": "node scripts/check-react-page-adapter-contract.mjs --self-test && node scripts/check-react-page-adapter-contract.mjs",
5656
"check:page-declaration-shape": "node scripts/check-page-declaration-shape.mjs --self-test && node scripts/check-page-declaration-shape.mjs",
5757
"check:template-version-sync": "node scripts/sync-template-versions.mjs --self-test",
58+
"gen:scaffold-emission-policy": "node scripts/sync-scaffold-emission-policy.mjs",
59+
"check:scaffold-emission-policy": "node scripts/sync-scaffold-emission-policy.mjs --self-test && node scripts/sync-scaffold-emission-policy.mjs --check",
5860
"check:role-word": "node scripts/check-role-word.mjs --self-test && node scripts/check-role-word.mjs",
5961
"check:corpus-claim-drift": "node scripts/check-corpus-claim-drift.mjs --self-test && node scripts/check-corpus-claim-drift.mjs",
6062
"check:skill-identifier-liveness": "node scripts/check-skill-identifier-liveness.mjs --self-test && node scripts/check-skill-identifier-liveness.mjs",

packages/cli/src/commands/init.ts

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -282,16 +282,31 @@ export const SCAFFOLD_PNPM_RANGE = '>=10.15';
282282
// changes is the floor each project DECLARES — and a floor is a support
283283
// promise, so the one that survives is the one the docs already make.
284284
//
285-
// ⛔ `create-objectstack`'s `^6.0.0` is deliberately NOT unified here. That
286-
// package cannot import from `@objectstack/cli`: the dependency edge already
287-
// runs the other way (`create-objectstack` is a `workspace:*` dependency of
288-
// this package, and this file imports its `created-summary` renderer), so a
289-
// reverse import is a cycle — and it publishes as a two-dependency `npx`
290-
// package that must not pull the CLI's ~50-package closure. Its emission is
291-
// also a committed template file copied byte-for-byte, with no renderer to
292-
// route through a constant. Unifying it would move a scaffolded project from
293-
// TypeScript 6.0.3 to 5.9.3, which is a user-visible change and a support
294-
// decision, not a refactor.
285+
// `create-objectstack` — the third scaffolder, and the one that CANNOT reach
286+
// these constants by import. The dependency edge already runs the other way
287+
// (`create-objectstack` is a `workspace:*` dependency of this package, and this
288+
// file imports its `created-summary` renderer), so a reverse import is a cycle
289+
// — and it publishes as a two-dependency `npx` package that must not pull the
290+
// CLI's ~50-package closure. Its emission is a committed template file copied
291+
// byte-for-byte, with no renderer to route through a constant. That is the
292+
// whole reason its `typescript` line drifted to `^6.0.0` while these two held.
293+
//
294+
// It is unified anyway, by GENERATION rather than by import:
295+
// `scripts/sync-scaffold-emission-policy.mjs` reads the `SCAFFOLD_*` constants
296+
// out of THIS file and stamps them into every bundled template's
297+
// `package.json`; `create-objectstack`'s `build` runs it, and
298+
// `pnpm check:scaffold-emission-policy` reddens the moment the inlined values
299+
// disagree with the source. ⛔ So a value below is read by a script as well as
300+
// by a compiler: keep the `export const NAME = '<value>';` spelling on one
301+
// line, and add the row to that script's `POLICY_STAMPS` if a new constant has
302+
// to reach the templates too.
303+
//
304+
// ⚠️ Unifying it moved a scaffolded project's DECLARED floor from `^6.0.0` to
305+
// `^5.3.0` (both resolve to typescript 5.9.3 or later at install time; the
306+
// floor is the support promise). The repo's own devDependency is `^6.0.3`, and
307+
// that is deliberately NOT this value: `content/docs/getting-started/index.mdx`
308+
// states both halves in one sentence — "ObjectStack works with TypeScript 5.3+,
309+
// but the project itself is built and tested against TypeScript 6.x".
295310

296311
/** The TypeScript range every scaffolded project declares. */
297312
export const SCAFFOLD_TYPESCRIPT_RANGE = '^5.3.0';

packages/cli/test/scaffold-emission-policy.e2e.test.ts

Lines changed: 150 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,18 +43,30 @@
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';
5870
import { execFile } from 'node:child_process';
5971
import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs';
6072
import { tmpdir } from 'node:os';
@@ -64,6 +76,7 @@ import { childEnv } from './helpers/serve-process.js';
6476
import {
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), '..');
7891
const CLI = resolve(HERE, '../bin/run-dev.js');
7992
const 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+
});

packages/create-objectstack/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
}
1313
},
1414
"scripts": {
15-
"build": "tsup && node ../../scripts/check-dts-emitted.mjs",
15+
"build": "pnpm gen:emission-policy && tsup && node ../../scripts/check-dts-emitted.mjs",
16+
"gen:emission-policy": "node ../../scripts/sync-scaffold-emission-policy.mjs",
1617
"typecheck": "tsc --noEmit",
1718
"dev": "tsup --watch",
1819
"test": "vitest run"

packages/create-objectstack/src/templates/blank/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,6 @@
2424
},
2525
"devDependencies": {
2626
"@objectstack/cli": "^17.0.0",
27-
"typescript": "^6.0.0"
27+
"typescript": "^5.3.0"
2828
}
2929
}

scripts/cross-package-test-inputs.mjs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,18 @@ export const CROSS_PACKAGE_TEST_INPUTS = {
532532
// One file, not `packages/create-objectstack/**`: the test reads that
533533
// template and nothing else across the boundary.
534534
'packages/create-objectstack/src/templates/blank/pnpm-workspace.yaml',
535+
// The on-ramp's ENTRY POINT and the template it ships, the second pair of
536+
// that same shape (#16485). test/scaffold-emission-policy.e2e.test.ts
537+
// SPAWNS `bin/create-objectstack.js` into a throwaway directory and reads
538+
// the `package.json` it emits, then compares the third-party ranges and
539+
// `engines.pnpm` against the five this package renders. Three scaffolders
540+
// emit a new project's manifest and only two of them can import the
541+
// `SCAFFOLD_*` constants — the third reaches them by generation — so a
542+
// diff to either of these files is exactly the divergence that pin exists
543+
// to catch, and without the declaration `@objectstack/cli#test` hashes the
544+
// same and replays a cached green over it.
545+
'packages/create-objectstack/bin/create-objectstack.js',
546+
'packages/create-objectstack/src/templates/blank/package.json',
535547
// The two files that hold the COLUMN authority the CLI's migration
536548
// generators mirror, READ by
537549
// src/commands/generate-multiple-json-column.pin.test.ts (#14829). That

0 commit comments

Comments
 (0)