Skip to content

Commit 7845951

Browse files
os-litantclaude
andauthored
refactor(cli): declare the scaffold emission policy once — os init and os create stop restating the tsconfig and the devtool ranges (#15974)
* refactor(cli): extract the restated scaffold emission policy into one module `os init` and `os create` each restated the third-party ranges and the `tsconfig.json` an emitted project carries. Measured on the tree, the TypeScript range was written in six places across three scaffolders and had split into three values (`^5.3.0` / `^5.8.0` / `^6.0.0`); vitest into two. Both CLI scaffolders now read one definition per value. Surviving values: `^5.3.0` for TypeScript (the floor two live doc pages already state) and `^4.0.0` for vitest (no recorded decision for either; `^4.0.18` claimed a patch-level floor nothing justifies). Neither changes what an emitted project installs — `^5.3.0` and `^5.8.0` both resolve to typescript 5.9.3, `^4.0.0` and `^4.0.18` both to vitest 4.1.11. `create-objectstack`'s `^6.0.0` is deliberately untouched: it cannot import from `@objectstack/cli` (the dependency edge runs the other way), and unifying it would change what a scaffolded project installs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N * test(cli): pin the shared scaffold emission policy across all five emissions Both ends of every expectation are derived — from the renderers, from the other scaffolder, or from the doc pages that already state the TypeScript floor — so a transcription cannot go green on a half-edited tree. `os init` writes its `tsconfig.json` inside `run()`, so that half is measured by driving the real command into a throwaway directory and reading the bytes off disk; an exported renderer nobody calls would pass every in-process assertion. The two doc pages the floor case reads are declared in `scripts/cross-package-test-inputs.mjs` and mirrored into `turbo.json`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N * chore(changeset): record the scaffold emission policy extraction Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N * fix(cli): correct the shipped provenance claim, and record the two new declarations in the CI filter-parity pin Two review findings from PR #15974. 1. The head reddened `check-ci-filter-parity --self-test`: the pre-#10015 rollback now uncovers 21 globs, not 19. Measured, the delta is exactly the two `content/docs` pages this branch declared for `@objectstack/cli` (troubleshooting.mdx, getting-started/index.mdx) — an origin/main control yields 19 with an empty symmetric difference otherwise. Both are recorded by name, the way the pin's own comment prescribes for #14824's three, rather than the count merely being bumped. 2. The shipped history claim was wrong. `dbb54e12f0c` (2026-05-25) added the bundled template at `^5.3.0`, not `^6.0.0`; `eaff01425b7` (#2907, 2026-07-14) moved it to `^6.0.0` and recorded no reasoning about TypeScript — in that same one-file diff the five `@objectstack/*` ranges move `^6.0.0` to `^14.0.0` while the `typescript` line moves onto the `^6.0.0` they are vacating. So the three-value split is 53 days old, not 102, and the two-value split is 210 (the changeset said 211). Corrected in init.ts, create.ts, the pin's header and the changeset, which now agree. A dated provenance claim written into source comments is exactly the restated fact nothing checks that this card exists to close; shipping a wrong one inside the fix would have been self-refuting. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent b1817fe commit 7845951

7 files changed

Lines changed: 522 additions & 77 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
`objectstack init` and `objectstack create` now read one emission policy instead of each restating it.
6+
7+
Both commands write a `tsconfig.json` and a set of third-party dependency ranges into a new project. Each had written those in its own words, and the words had come apart. Measured on the tree: the TypeScript range — the value that decides whether a scaffolded project type-checks at all — was written in six places across three scaffolders and had split into three values (`^5.3.0`, `^5.8.0`, `^6.0.0`); the vitest range into two. Dated off `git log -G` as of 2026-09-05: the two CLI values were written in the same commit and stayed apart for 210 days, and the third value is 53 days old — the bundled template landed at `^5.3.0` like the others and was moved to `^6.0.0` later, in a commit that records no reasoning about TypeScript.
8+
9+
The control for that reading was already in the same file: `SCAFFOLD_PNPM_RANGE` and `renderPnpmWorkspaceYaml()` are imported by the second scaffolder rather than restated, and across the same five emissions, the same window and the same authors, they had not drifted at all. So the policy moved to where those already live — `renderScaffoldTsconfig()` and one `SCAFFOLD_*_RANGE` constant per dependency, in `init.ts`, imported by `create.ts`.
10+
11+
Two emitted values had to survive the merge, and both are argued rather than picked:
12+
13+
- **TypeScript `^5.3.0`.** `TypeScript 5.3+` is already this project's published floor — `content/docs/getting-started/index.mdx` says so, and `content/docs/deployment/troubleshooting.mdx` repeats it. `^5.8.0` matched no statement anywhere, and `^5.3.0` was already what three of the five emissions carried. Measured rather than assumed: TypeScript 5.3.3 type-checks every shape these two commands emit with results identical to 6.0.3.
14+
- **vitest `^4.0.0`.** Neither value was a recorded decision and both were written in the same commit; `^4.0.18` claimed a patch-level floor nothing justifies and was strictly the narrower of the two.
15+
16+
**Nothing a scaffolded project installs changes.** `^5.3.0` and `^5.8.0` both resolve to typescript 5.9.3, and `^4.0.0` and `^4.0.18` both to vitest 4.1.11 — what moves is the floor each project declares, which is a support promise, so the surviving one is the promise the docs already make. Driving all five emissions and hashing the trees before and after: every `tsconfig.json` is byte-identical, `os init -t app` and `os init -t empty` are byte-identical in full, and exactly three `package.json` files change by exactly the one line each.
17+
18+
`npx create-objectstack` is deliberately untouched. It cannot import from `@objectstack/cli` — the dependency edge runs the other way — and its `^6.0.0` is a different question: unifying it would change which major of TypeScript a scaffolded project installs.

packages/cli/src/commands/create.ts

Lines changed: 37 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -58,12 +58,18 @@
5858
*
5959
* ## Why the standalone shape reuses `init`'s renderers
6060
*
61-
* `renderPnpmWorkspaceYaml()` and `SCAFFOLD_PNPM_RANGE` are `init.ts`'s, and
62-
* they are CALLED here rather than restated. A restatement is the two-producer
63-
* defect `test/scaffold-workspace-consistency.test.ts` exists to catch, and it
64-
* has already been paid for once in this repo: the build-approval block landed
65-
* in one scaffold path and not the other, and one of them shipped the pre-fix
66-
* shape for months.
61+
* `renderPnpmWorkspaceYaml()`, `SCAFFOLD_PNPM_RANGE`, `renderScaffoldTsconfig()`
62+
* and the `SCAFFOLD_*_RANGE` constants are `init.ts`'s, and they are CALLED here
63+
* rather than restated. A restatement is the two-producer defect
64+
* `test/scaffold-workspace-consistency.test.ts` exists to catch, and it has
65+
* already been paid for twice in this repo: the build-approval block landed in
66+
* one scaffold path and not the other, and one of them shipped the pre-fix shape
67+
* for months; and the TypeScript range a scaffold installs was written in six
68+
* places and split into three values — the two CLI values 210 days apart, the
69+
* third 53 and recorded nowhere — on the value that decides whether the
70+
* scaffold type-checks at all. The emission policy has
71+
* one home now — see the block above `renderScaffoldPackageJson` in `init.ts`
72+
* for the measurement and for which values survived.
6773
*
6874
* ## The pin
6975
*
@@ -84,8 +90,16 @@ import {
8490
getCliVersion,
8591
NPM_PACKAGE_NAME_MAX_LENGTH,
8692
renderPnpmWorkspaceYaml,
93+
renderScaffoldTsconfig,
8794
sanitizeNamespace,
8895
SCAFFOLD_PNPM_RANGE,
96+
SCAFFOLD_TSCONFIG_INCLUDE_SRC_ONLY,
97+
SCAFFOLD_TSCONFIG_INCLUDE_WITH_ROOT_CONFIG,
98+
SCAFFOLD_TSX_RANGE,
99+
SCAFFOLD_TYPES_NODE_RANGE,
100+
SCAFFOLD_TYPESCRIPT_RANGE,
101+
SCAFFOLD_VITEST_RANGE,
102+
SCAFFOLD_ZOD_RANGE,
89103
validateProjectName,
90104
} from './init.js';
91105

@@ -121,22 +135,6 @@ export function rootTsconfigExtends(inRepoDir: string, projectDirName: string):
121135
return `${'../'.repeat(depth)}tsconfig.json`;
122136
}
123137

124-
/**
125-
* The compiler options a standalone scaffold carries in full, because it
126-
* extends nothing. Deliberately the same set `objectstack init` writes: two
127-
* scaffolders that disagree about `moduleResolution` is a support question
128-
* nobody can answer, and `bundler` is what resolves the `exports` subpaths
129-
* (`@objectstack/spec/contracts`, `/kernel`) the templates import.
130-
*/
131-
const STANDALONE_COMPILER_OPTIONS = {
132-
target: 'ES2022',
133-
module: 'ESNext',
134-
moduleResolution: 'bundler',
135-
strict: true,
136-
esModuleInterop: true,
137-
skipLibCheck: true,
138-
} as const;
139-
140138
/** A rendered file: JSON objects are stringified on write, strings land as-is. */
141139
type FileRenderer = (name: string) => unknown;
142140

@@ -253,26 +251,20 @@ export const templates: Record<string, CreateTemplate> = {
253251
license: 'MIT',
254252
dependencies: {
255253
'@objectstack/spec': objectstackDependencySpec(placement),
256-
zod: '^4.3.6',
254+
zod: SCAFFOLD_ZOD_RANGE,
257255
},
258256
devDependencies: {
259-
'@types/node': '^22.0.0',
260-
typescript: '^5.8.0',
261-
vitest: '^4.0.0',
257+
'@types/node': SCAFFOLD_TYPES_NODE_RANGE,
258+
typescript: SCAFFOLD_TYPESCRIPT_RANGE,
259+
vitest: SCAFFOLD_VITEST_RANGE,
262260
},
263261
}),
264262
'tsconfig.json': (name: string) =>
265263
standalone
266-
? {
267-
compilerOptions: {
268-
...STANDALONE_COMPILER_OPTIONS,
269-
outDir: 'dist',
270-
rootDir: 'src',
271-
declaration: true,
272-
},
273-
include: ['src/**/*'],
274-
exclude: ['dist', 'node_modules'],
275-
}
264+
? renderScaffoldTsconfig({
265+
rootDir: 'src',
266+
include: SCAFFOLD_TSCONFIG_INCLUDE_SRC_ONLY,
267+
})
276268
: {
277269
extends: rootTsconfigExtends(PLUGIN_IN_REPO_DIR, `plugin-${name}`),
278270
compilerOptions: {
@@ -367,13 +359,13 @@ MIT
367359
dependencies: {
368360
'@objectstack/spec': objectstackDependencySpec(placement),
369361
'@objectstack/cli': objectstackDependencySpec(placement),
370-
zod: '^4.3.6',
362+
zod: SCAFFOLD_ZOD_RANGE,
371363
},
372364
devDependencies: {
373-
'@types/node': '^22.0.0',
374-
tsx: '^4.21.0',
375-
typescript: '^5.8.0',
376-
vitest: '^4.0.0',
365+
'@types/node': SCAFFOLD_TYPES_NODE_RANGE,
366+
tsx: SCAFFOLD_TSX_RANGE,
367+
typescript: SCAFFOLD_TYPESCRIPT_RANGE,
368+
vitest: SCAFFOLD_VITEST_RANGE,
377369
},
378370
}),
379371
'objectstack.config.ts': (name: string) => {
@@ -445,16 +437,10 @@ ${
445437
}`,
446438
'tsconfig.json': (name: string) =>
447439
standalone
448-
? {
449-
compilerOptions: {
450-
...STANDALONE_COMPILER_OPTIONS,
451-
outDir: 'dist',
452-
rootDir: '.',
453-
declaration: true,
454-
},
455-
include: ['*.ts', 'src/**/*'],
456-
exclude: ['dist', 'node_modules'],
457-
}
440+
? renderScaffoldTsconfig({
441+
rootDir: '.',
442+
include: SCAFFOLD_TSCONFIG_INCLUDE_WITH_ROOT_CONFIG,
443+
})
458444
: {
459445
extends: rootTsconfigExtends(EXAMPLE_IN_REPO_DIR, name),
460446
compilerOptions: {

packages/cli/src/commands/init.ts

Lines changed: 149 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,145 @@ export const SCAFFOLD_ALLOWED_PEER_VERSIONS: Record<string, string> = {
219219
*/
220220
export const SCAFFOLD_PNPM_RANGE = '>=10.15';
221221

222+
// ─── Shared emission policy ──────────────────────────────────────────
223+
//
224+
// The third-party ranges and the `tsconfig.json` an emitted project carries,
225+
// declared ONCE for both scaffolders in this package.
226+
//
227+
// ## Why these are constants and not literals in each template
228+
//
229+
// Restating them is the defect. Measured on the tree, the TypeScript range a
230+
// scaffolded project installs was written in SIX places across three
231+
// scaffolders and had split into THREE values:
232+
//
233+
// os init `^5.3.0` (three write points in this file)
234+
// os create `^5.8.0` (two write points in create.ts)
235+
// create-objectstack `^6.0.0` (its bundled template's package.json)
236+
//
237+
// The split is not recent and nobody caused it deliberately. Read off `git
238+
// log -G` over the three files, as of 2026-09-05:
239+
//
240+
// 338e68d2564 2026-02-07 `^5.3.0` and `^5.8.0` written in the SAME commit
241+
// — 210 days apart and counting
242+
// dbb54e12f0c 2026-05-25 the bundled template lands, also at `^5.3.0`
243+
// — so this is still a TWO-value tree
244+
// eaff01425b7 2026-07-14 that template's line moves `^5.3.0` → `^6.0.0`
245+
// — the third value, 53 days old
246+
//
247+
// ⚠️ And the move that made the third value recorded no reasoning about
248+
// TypeScript at all. #2907's commit message documents the `@objectstack/*`
249+
// version sync and nothing else, and in that same one-file diff the five
250+
// `@objectstack/*` ranges move `^6.0.0` → `^14.0.0` while the `typescript`
251+
// line moves ONTO the `^6.0.0` those lines are vacating. Whatever the intent
252+
// was, no statement of it exists — which is the point: an unrecorded value is
253+
// what a restatement decays into, and it decayed on the value that decides
254+
// whether a new project type-checks at all.
255+
//
256+
// The control for that reading is in this same file: `SCAFFOLD_PNPM_RANGE`
257+
// and `renderPnpmWorkspaceYaml()` are IMPORTED by the other scaffolder rather
258+
// than restated, and they have not drifted across any of the five emissions.
259+
// Same files, same authors, same window — the restated values split, the
260+
// imported ones did not. That is the whole argument for this block.
261+
//
262+
// ## The surviving values, and why they are these
263+
//
264+
// `^5.3.0` over `^5.8.0`: **`TypeScript 5.3+` is already a recorded decision**
265+
// on two live doc pages — `content/docs/getting-started/index.mdx` ("ObjectStack
266+
// works with TypeScript 5.3+, but the project itself is built and tested
267+
// against TypeScript 6.x") and `content/docs/deployment/troubleshooting.mdx`
268+
// ("TypeScript 5.3.0 or later for full type inference support"). `^5.8.0`
269+
// matches no statement anywhere. It is also the value three of the five
270+
// emissions already carried, and it is measured rather than assumed: TypeScript
271+
// 5.3.3 type-checks every shape these two commands emit with results identical
272+
// to 6.0.3 (measured against this repo's own `@objectstack/spec` build, on the
273+
// `skipLibCheck` configuration the scaffold actually emits).
274+
//
275+
// `^4.0.0` over `^4.0.18`: no recorded decision exists for either, both were
276+
// written in the same 2026-02-07 commit, and `^4.0.18` claims a PATCH-level
277+
// floor nothing justifies while being strictly the narrower of the two.
278+
//
279+
// ⚠️ Neither choice changes what a scaffolded project INSTALLS. Measured
280+
// against the registry: `^5.3.0` and `^5.8.0` both resolve to typescript
281+
// 5.9.3, and `^4.0.0` and `^4.0.18` both resolve to vitest 4.1.11. What
282+
// changes is the floor each project DECLARES — and a floor is a support
283+
// promise, so the one that survives is the one the docs already make.
284+
//
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.
295+
296+
/** The TypeScript range every scaffolded project declares. */
297+
export const SCAFFOLD_TYPESCRIPT_RANGE = '^5.3.0';
298+
299+
/** The vitest range a scaffolded project declares when its template tests. */
300+
export const SCAFFOLD_VITEST_RANGE = '^4.0.0';
301+
302+
/** The `@types/node` range a scaffolded project declares. */
303+
export const SCAFFOLD_TYPES_NODE_RANGE = '^22.0.0';
304+
305+
/** The `tsx` range a scaffolded project declares when its scripts need it. */
306+
export const SCAFFOLD_TSX_RANGE = '^4.21.0';
307+
308+
/** The zod range a scaffolded project declares when it authors schemas. */
309+
export const SCAFFOLD_ZOD_RANGE = '^4.3.6';
310+
311+
/**
312+
* The compiler options every STANDALONE scaffold carries in full, because it
313+
* extends nothing.
314+
*
315+
* `bundler` is what resolves the `exports` subpaths (`@objectstack/spec/data`,
316+
* `/contracts`, `/kernel`) the templates import; two scaffolders that disagree
317+
* about `moduleResolution` is a support question nobody can answer. The
318+
* `--in-repo` placement of `os create` does NOT use these — it inherits its
319+
* module semantics from the repo config it extends.
320+
*/
321+
export const SCAFFOLD_TSCONFIG_COMPILER_OPTIONS = {
322+
target: 'ES2022',
323+
module: 'ESNext',
324+
moduleResolution: 'bundler',
325+
strict: true,
326+
esModuleInterop: true,
327+
skipLibCheck: true,
328+
} as const;
329+
330+
/**
331+
* Render the `tsconfig.json` a standalone scaffold receives.
332+
*
333+
* `rootDir` and `include` are the only things the emitted shapes differ on:
334+
* `os create plugin` compiles `src/` alone, while `os init`'s three templates
335+
* and `os create example` also compile the `objectstack.config.ts` at the
336+
* project root. Measured before this renderer existed, four of the five
337+
* emitted `tsconfig.json` files were already byte-identical and the fifth
338+
* differed only in those two keys — so nothing here is a new decision.
339+
*/
340+
export function renderScaffoldTsconfig(
341+
options: { rootDir: string; include: string[] },
342+
): Record<string, unknown> {
343+
return {
344+
compilerOptions: {
345+
...SCAFFOLD_TSCONFIG_COMPILER_OPTIONS,
346+
outDir: 'dist',
347+
rootDir: options.rootDir,
348+
declaration: true,
349+
},
350+
include: options.include,
351+
exclude: ['dist', 'node_modules'],
352+
};
353+
}
354+
355+
/** `include` for a scaffold whose root `objectstack.config.ts` is compiled too. */
356+
export const SCAFFOLD_TSCONFIG_INCLUDE_WITH_ROOT_CONFIG = ['*.ts', 'src/**/*'];
357+
358+
/** `include` for a scaffold that compiles `src/` alone. */
359+
export const SCAFFOLD_TSCONFIG_INCLUDE_SRC_ONLY = ['src/**/*'];
360+
222361
/**
223362
* Render the `package.json` written into a freshly scaffolded project.
224363
*
@@ -381,7 +520,7 @@ export const TEMPLATES: Record<string, {
381520
get devDependencies() {
382521
return {
383522
'@objectstack/cli': pkgVersion(),
384-
'typescript': '^5.3.0',
523+
'typescript': SCAFFOLD_TYPESCRIPT_RANGE,
385524
};
386525
},
387526
scripts: {
@@ -471,8 +610,8 @@ export default ${toCamelCase(namespace)}Item;
471610
get devDependencies() {
472611
return {
473612
'@objectstack/cli': pkgVersion(),
474-
'typescript': '^5.3.0',
475-
'vitest': '^4.0.18',
613+
'typescript': SCAFFOLD_TYPESCRIPT_RANGE,
614+
'vitest': SCAFFOLD_VITEST_RANGE,
476615
};
477616
},
478617
scripts: {
@@ -545,7 +684,7 @@ export default ${toCamelCase(namespace)}Item;
545684
get devDependencies() {
546685
return {
547686
'@objectstack/cli': pkgVersion(),
548-
'typescript': '^5.3.0',
687+
'typescript': SCAFFOLD_TYPESCRIPT_RANGE,
549688
};
550689
},
551690
scripts: {
@@ -856,21 +995,12 @@ export default class Init extends Command {
856995
// 3. Create tsconfig.json if missing
857996
const tsconfigPath = path.join(targetDir, 'tsconfig.json');
858997
if (!fs.existsSync(tsconfigPath)) {
859-
const tsconfig = {
860-
compilerOptions: {
861-
target: 'ES2022',
862-
module: 'ESNext',
863-
moduleResolution: 'bundler',
864-
strict: true,
865-
esModuleInterop: true,
866-
skipLibCheck: true,
867-
outDir: 'dist',
868-
rootDir: '.',
869-
declaration: true,
870-
},
871-
include: ['*.ts', 'src/**/*'],
872-
exclude: ['dist', 'node_modules'],
873-
};
998+
// The shared emission policy, not a second copy of it — every option
999+
// below used to be restated here and again in `create.ts`.
1000+
const tsconfig = renderScaffoldTsconfig({
1001+
rootDir: '.',
1002+
include: SCAFFOLD_TSCONFIG_INCLUDE_WITH_ROOT_CONFIG,
1003+
});
8741004
fs.writeFileSync(tsconfigPath, JSON.stringify(tsconfig, null, 2) + '\n');
8751005
}
8761006

0 commit comments

Comments
 (0)