From 2d399309f161f7bebaa4be18bd62e59c80135a17 Mon Sep 17 00:00:00 2001 From: os-litant Date: Sat, 5 Sep 2026 16:14:03 +0000 Subject: [PATCH 1/4] refactor(cli): extract the restated scaffold emission policy into one module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --- packages/cli/src/commands/create.ts | 87 +++++++--------- packages/cli/src/commands/init.ts | 156 ++++++++++++++++++++++++---- 2 files changed, 173 insertions(+), 70 deletions(-) diff --git a/packages/cli/src/commands/create.ts b/packages/cli/src/commands/create.ts index 9189d2f740..d86240fc89 100644 --- a/packages/cli/src/commands/create.ts +++ b/packages/cli/src/commands/create.ts @@ -58,12 +58,17 @@ * * ## Why the standalone shape reuses `init`'s renderers * - * `renderPnpmWorkspaceYaml()` and `SCAFFOLD_PNPM_RANGE` are `init.ts`'s, and - * they are CALLED here rather than restated. A restatement is the two-producer - * defect `test/scaffold-workspace-consistency.test.ts` exists to catch, and it - * has already been paid for once in this repo: the build-approval block landed - * in one scaffold path and not the other, and one of them shipped the pre-fix - * shape for months. + * `renderPnpmWorkspaceYaml()`, `SCAFFOLD_PNPM_RANGE`, `renderScaffoldTsconfig()` + * and the `SCAFFOLD_*_RANGE` constants are `init.ts`'s, and they are CALLED here + * rather than restated. A restatement is the two-producer defect + * `test/scaffold-workspace-consistency.test.ts` exists to catch, and it has + * already been paid for twice in this repo: the build-approval block landed in + * one scaffold path and not the other, and one of them shipped the pre-fix shape + * for months; and the TypeScript range a scaffold installs was written in six + * places and split into three values, the split surviving 102 days on the value + * that decides whether the scaffold type-checks at all. The emission policy has + * one home now — see the block above `renderScaffoldPackageJson` in `init.ts` + * for the measurement and for which values survived. * * ## The pin * @@ -84,8 +89,16 @@ import { getCliVersion, NPM_PACKAGE_NAME_MAX_LENGTH, renderPnpmWorkspaceYaml, + renderScaffoldTsconfig, sanitizeNamespace, SCAFFOLD_PNPM_RANGE, + SCAFFOLD_TSCONFIG_INCLUDE_SRC_ONLY, + SCAFFOLD_TSCONFIG_INCLUDE_WITH_ROOT_CONFIG, + SCAFFOLD_TSX_RANGE, + SCAFFOLD_TYPES_NODE_RANGE, + SCAFFOLD_TYPESCRIPT_RANGE, + SCAFFOLD_VITEST_RANGE, + SCAFFOLD_ZOD_RANGE, validateProjectName, } from './init.js'; @@ -121,22 +134,6 @@ export function rootTsconfigExtends(inRepoDir: string, projectDirName: string): return `${'../'.repeat(depth)}tsconfig.json`; } -/** - * The compiler options a standalone scaffold carries in full, because it - * extends nothing. Deliberately the same set `objectstack init` writes: two - * scaffolders that disagree about `moduleResolution` is a support question - * nobody can answer, and `bundler` is what resolves the `exports` subpaths - * (`@objectstack/spec/contracts`, `/kernel`) the templates import. - */ -const STANDALONE_COMPILER_OPTIONS = { - target: 'ES2022', - module: 'ESNext', - moduleResolution: 'bundler', - strict: true, - esModuleInterop: true, - skipLibCheck: true, -} as const; - /** A rendered file: JSON objects are stringified on write, strings land as-is. */ type FileRenderer = (name: string) => unknown; @@ -253,26 +250,20 @@ export const templates: Record = { license: 'MIT', dependencies: { '@objectstack/spec': objectstackDependencySpec(placement), - zod: '^4.3.6', + zod: SCAFFOLD_ZOD_RANGE, }, devDependencies: { - '@types/node': '^22.0.0', - typescript: '^5.8.0', - vitest: '^4.0.0', + '@types/node': SCAFFOLD_TYPES_NODE_RANGE, + typescript: SCAFFOLD_TYPESCRIPT_RANGE, + vitest: SCAFFOLD_VITEST_RANGE, }, }), 'tsconfig.json': (name: string) => standalone - ? { - compilerOptions: { - ...STANDALONE_COMPILER_OPTIONS, - outDir: 'dist', - rootDir: 'src', - declaration: true, - }, - include: ['src/**/*'], - exclude: ['dist', 'node_modules'], - } + ? renderScaffoldTsconfig({ + rootDir: 'src', + include: SCAFFOLD_TSCONFIG_INCLUDE_SRC_ONLY, + }) : { extends: rootTsconfigExtends(PLUGIN_IN_REPO_DIR, `plugin-${name}`), compilerOptions: { @@ -367,13 +358,13 @@ MIT dependencies: { '@objectstack/spec': objectstackDependencySpec(placement), '@objectstack/cli': objectstackDependencySpec(placement), - zod: '^4.3.6', + zod: SCAFFOLD_ZOD_RANGE, }, devDependencies: { - '@types/node': '^22.0.0', - tsx: '^4.21.0', - typescript: '^5.8.0', - vitest: '^4.0.0', + '@types/node': SCAFFOLD_TYPES_NODE_RANGE, + tsx: SCAFFOLD_TSX_RANGE, + typescript: SCAFFOLD_TYPESCRIPT_RANGE, + vitest: SCAFFOLD_VITEST_RANGE, }, }), 'objectstack.config.ts': (name: string) => { @@ -445,16 +436,10 @@ ${ }`, 'tsconfig.json': (name: string) => standalone - ? { - compilerOptions: { - ...STANDALONE_COMPILER_OPTIONS, - outDir: 'dist', - rootDir: '.', - declaration: true, - }, - include: ['*.ts', 'src/**/*'], - exclude: ['dist', 'node_modules'], - } + ? renderScaffoldTsconfig({ + rootDir: '.', + include: SCAFFOLD_TSCONFIG_INCLUDE_WITH_ROOT_CONFIG, + }) : { extends: rootTsconfigExtends(EXAMPLE_IN_REPO_DIR, name), compilerOptions: { diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index d0e83bb76e..5e8581d225 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -219,6 +219,133 @@ export const SCAFFOLD_ALLOWED_PEER_VERSIONS: Record = { */ export const SCAFFOLD_PNPM_RANGE = '>=10.15'; +// ─── Shared emission policy ────────────────────────────────────────── +// +// The third-party ranges and the `tsconfig.json` an emitted project carries, +// declared ONCE for both scaffolders in this package. +// +// ## Why these are constants and not literals in each template +// +// Restating them is the defect. Measured on the tree, the TypeScript range a +// scaffolded project installs was written in SIX places across three +// scaffolders and had split into THREE values: +// +// os init `^5.3.0` (three write points in this file) +// os create `^5.8.0` (two write points in create.ts) +// create-objectstack `^6.0.0` (its bundled template's package.json) +// +// The split is not recent and nobody caused it deliberately: `^5.3.0` and +// `^5.8.0` were written in the same commit (2026-02-07), and the third value +// arrived with the bundled template on 2026-05-25 — 102 days of three +// scaffolders answering "which TypeScript does a new ObjectStack project +// install?" three different ways, on the value that decides whether the +// scaffold type-checks at all. +// +// The control for that reading is in this same file: `SCAFFOLD_PNPM_RANGE` +// and `renderPnpmWorkspaceYaml()` are IMPORTED by the other scaffolder rather +// than restated, and they have not drifted across any of the five emissions. +// Same files, same authors, same window — the restated values split, the +// imported ones did not. That is the whole argument for this block. +// +// ## The surviving values, and why they are these +// +// `^5.3.0` over `^5.8.0`: **`TypeScript 5.3+` is already a recorded decision** +// on two live doc pages — `content/docs/getting-started/index.mdx` ("ObjectStack +// works with TypeScript 5.3+, but the project itself is built and tested +// against TypeScript 6.x") and `content/docs/deployment/troubleshooting.mdx` +// ("TypeScript 5.3.0 or later for full type inference support"). `^5.8.0` +// matches no statement anywhere. It is also the value three of the five +// emissions already carried, and it is measured rather than assumed: TypeScript +// 5.3.3 type-checks every shape these two commands emit with results identical +// to 6.0.3 (measured against this repo's own `@objectstack/spec` build, on the +// `skipLibCheck` configuration the scaffold actually emits). +// +// `^4.0.0` over `^4.0.18`: no recorded decision exists for either, both were +// written in the same 2026-02-07 commit, and `^4.0.18` claims a PATCH-level +// floor nothing justifies while being strictly the narrower of the two. +// +// ⚠️ Neither choice changes what a scaffolded project INSTALLS. Measured +// against the registry: `^5.3.0` and `^5.8.0` both resolve to typescript +// 5.9.3, and `^4.0.0` and `^4.0.18` both resolve to vitest 4.1.11. What +// changes is the floor each project DECLARES — and a floor is a support +// promise, so the one that survives is the one the docs already make. +// +// ⛔ `create-objectstack`'s `^6.0.0` is deliberately NOT unified here. That +// package cannot import from `@objectstack/cli`: the dependency edge already +// runs the other way (`create-objectstack` is a `workspace:*` dependency of +// this package, and this file imports its `created-summary` renderer), so a +// reverse import is a cycle — and it publishes as a two-dependency `npx` +// package that must not pull the CLI's ~50-package closure. Its emission is +// also a committed template file copied byte-for-byte, with no renderer to +// route through a constant. Unifying it would move a scaffolded project from +// TypeScript 6.0.3 to 5.9.3, which is a user-visible change and a support +// decision, not a refactor. + +/** The TypeScript range every scaffolded project declares. */ +export const SCAFFOLD_TYPESCRIPT_RANGE = '^5.3.0'; + +/** The vitest range a scaffolded project declares when its template tests. */ +export const SCAFFOLD_VITEST_RANGE = '^4.0.0'; + +/** The `@types/node` range a scaffolded project declares. */ +export const SCAFFOLD_TYPES_NODE_RANGE = '^22.0.0'; + +/** The `tsx` range a scaffolded project declares when its scripts need it. */ +export const SCAFFOLD_TSX_RANGE = '^4.21.0'; + +/** The zod range a scaffolded project declares when it authors schemas. */ +export const SCAFFOLD_ZOD_RANGE = '^4.3.6'; + +/** + * The compiler options every STANDALONE scaffold carries in full, because it + * extends nothing. + * + * `bundler` is what resolves the `exports` subpaths (`@objectstack/spec/data`, + * `/contracts`, `/kernel`) the templates import; two scaffolders that disagree + * about `moduleResolution` is a support question nobody can answer. The + * `--in-repo` placement of `os create` does NOT use these — it inherits its + * module semantics from the repo config it extends. + */ +export const SCAFFOLD_TSCONFIG_COMPILER_OPTIONS = { + target: 'ES2022', + module: 'ESNext', + moduleResolution: 'bundler', + strict: true, + esModuleInterop: true, + skipLibCheck: true, +} as const; + +/** + * Render the `tsconfig.json` a standalone scaffold receives. + * + * `rootDir` and `include` are the only things the emitted shapes differ on: + * `os create plugin` compiles `src/` alone, while `os init`'s three templates + * and `os create example` also compile the `objectstack.config.ts` at the + * project root. Measured before this renderer existed, four of the five + * emitted `tsconfig.json` files were already byte-identical and the fifth + * differed only in those two keys — so nothing here is a new decision. + */ +export function renderScaffoldTsconfig( + options: { rootDir: string; include: string[] }, +): Record { + return { + compilerOptions: { + ...SCAFFOLD_TSCONFIG_COMPILER_OPTIONS, + outDir: 'dist', + rootDir: options.rootDir, + declaration: true, + }, + include: options.include, + exclude: ['dist', 'node_modules'], + }; +} + +/** `include` for a scaffold whose root `objectstack.config.ts` is compiled too. */ +export const SCAFFOLD_TSCONFIG_INCLUDE_WITH_ROOT_CONFIG = ['*.ts', 'src/**/*']; + +/** `include` for a scaffold that compiles `src/` alone. */ +export const SCAFFOLD_TSCONFIG_INCLUDE_SRC_ONLY = ['src/**/*']; + /** * Render the `package.json` written into a freshly scaffolded project. * @@ -381,7 +508,7 @@ export const TEMPLATES: Record Date: Sat, 5 Sep 2026 16:19:39 +0000 Subject: [PATCH 2/4] test(cli): pin the shared scaffold emission policy across all five emissions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --- .../test/scaffold-emission-policy.e2e.test.ts | 278 ++++++++++++++++++ scripts/cross-package-test-inputs.mjs | 13 + turbo.json | 2 + 3 files changed, 293 insertions(+) create mode 100644 packages/cli/test/scaffold-emission-policy.e2e.test.ts diff --git a/packages/cli/test/scaffold-emission-policy.e2e.test.ts b/packages/cli/test/scaffold-emission-policy.e2e.test.ts new file mode 100644 index 0000000000..ddeab9dd38 --- /dev/null +++ b/packages/cli/test/scaffold-emission-policy.e2e.test.ts @@ -0,0 +1,278 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * PIN — the two CLI scaffolders emit ONE emission policy, not two copies of it. + * + * ## The defect this exists for + * + * `os init` and `os create` each wrote the third-party ranges and the + * `tsconfig.json` a new project receives, in their own words. Measured on the + * tree the day this landed, 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` in + * `init.ts`, `^5.8.0` in `create.ts`, `^6.0.0` in the bundled + * `create-objectstack` template). vitest had split into two. The two CLI values + * were written in the SAME commit and stayed apart for 211 days; the third + * arrived 102 days before the measurement. + * + * The control for that reading sits in the same file as the defect: + * `SCAFFOLD_PNPM_RANGE` and `renderPnpmWorkspaceYaml()` are IMPORTED by the + * other scaffolder rather than restated, and across the same five emissions, + * the same window and the same authors they did not drift at all. + * + * ## What is asserted, and why no expected value is written down here + * + * Every expectation below is DERIVED — from the renderers, from the other + * scaffolder, or from the doc page that already states the answer. A test that + * transcribed `'^5.3.0'` would go green on a tree where one scaffolder had been + * edited and the other had not, which is the exact state it exists to catch. + * + * 1. Across all five emissions, each third-party dependency name resolves to + * exactly ONE range. This is the property; the value it settles on is not. + * 2. That one range IS the exported constant, so a template that grows a + * literal instead of importing turns this red. + * 3. The surviving TypeScript range is the floor the DOCS state. `^5.3.0` + * beat `^5.8.0` because two live pages already promise "TypeScript 5.3+"; + * that is what made the choice a recorded decision rather than a silent + * pick, and this case is what keeps the two ends tied together. + * 4. `os init`'s `tsconfig.json` is written inside `run()`, so it is measured + * by DRIVING the real command into a throwaway directory and reading the + * bytes off disk — a renderer that is exported but no longer called would + * pass every in-process assertion here. + * + * ⚠️ `create-objectstack`'s `^6.0.0` is deliberately out of scope and is NOT + * asserted against: that package cannot import from `@objectstack/cli` (the + * dependency edge runs the other way), and unifying it would change what a + * scaffolded project installs. + * + * Spawned through `bin/run-dev.js` + tsx, so this suite does not depend on + * `packages/cli/dist` having been built (`@objectstack/cli#test` depends on + * `^build` only) — the same reason `create-refuses-invalid-project-name.e2e.test.ts` + * spawns that way. + */ + +import { describe, it, expect } from 'vitest'; +import { execFile } from 'node:child_process'; +import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { childEnv } from './helpers/serve-process.js'; +import { + renderScaffoldPackageJson, + renderScaffoldTsconfig, + SCAFFOLD_TSCONFIG_INCLUDE_WITH_ROOT_CONFIG, + SCAFFOLD_TSX_RANGE, + SCAFFOLD_TYPES_NODE_RANGE, + SCAFFOLD_TYPESCRIPT_RANGE, + SCAFFOLD_VITEST_RANGE, + SCAFFOLD_ZOD_RANGE, + TEMPLATES, +} from '../src/commands/init.js'; +import { DEFAULT_PLACEMENT, templates } from '../src/commands/create.js'; + +const HERE = resolve(fileURLToPath(import.meta.url), '..'); +const CLI = resolve(HERE, '../bin/run-dev.js'); +const TSX = resolve(HERE, '../../../node_modules/.bin/tsx'); + +// One `resolve(HERE, …)` call per line and nothing split across lines: +// `check:cross-package-test-inputs` reconstructs these reads by SOURCE SCAN, +// and a spelling it cannot parse leaves the glob declared and held by nothing. +// Both are declared for `@objectstack/cli` in +// scripts/cross-package-test-inputs.mjs and mirrored into turbo.json. +const GETTING_STARTED = resolve(HERE, '../../..', 'content/docs/getting-started/index.mdx'); +const TROUBLESHOOTING = resolve(HERE, '../../..', 'content/docs/deployment/troubleshooting.mdx'); + +/** oclif + tsx cold start with every command module loaded; ~2-10 s when healthy. */ +const RUN_TIMEOUT_MS = 180_000; + +const PROBE_NAME = 'emission-policy-probe'; + +/** `@objectstack/*` ranges are the CLI's own version — pinned by `init.test.ts`. */ +function thirdPartyOnly(deps: Record | undefined): Array<[string, string]> { + return Object.entries(deps ?? {}) + .filter(([name, range]) => !name.startsWith('@objectstack/') && typeof range === 'string') + .map(([name, range]) => [name, range as string]); +} + +/** + * Every `package.json` the two commands emit for the shape a reader of the docs + * actually gets — `os init`'s three templates and `os create`'s two, in its + * DEFAULT placement. `--in-repo` is excluded on purpose: it emits `workspace:*` + * and is documented as platform-work-only. + */ +function emittedManifests(): Array<{ id: string; manifest: Record }> { + const out: Array<{ id: string; manifest: Record }> = []; + for (const [key, template] of Object.entries(TEMPLATES)) { + out.push({ + id: `os init -t ${key}`, + manifest: renderScaffoldPackageJson(PROBE_NAME, template), + }); + } + for (const [key, template] of Object.entries(templates)) { + const render = template.filesFor(DEFAULT_PLACEMENT)['package.json']; + out.push({ + id: `os create ${key}`, + manifest: render(PROBE_NAME) as Record, + }); + } + return out; +} + +/** ` -> every range any emission declares for it`. */ +function declaredRanges(): Map> { + const byName = new Map>(); + for (const { id, manifest } of emittedManifests()) { + const deps = [ + ...thirdPartyOnly(manifest.dependencies as Record), + ...thirdPartyOnly(manifest.devDependencies as Record), + ]; + for (const [name, range] of deps) { + const ranges = byName.get(name) ?? new Map(); + ranges.set(range, [...(ranges.get(range) ?? []), id]); + byName.set(name, ranges); + } + } + return byName; +} + +describe('scaffold emission policy — one definition, five emissions', () => { + it('harvests a non-empty policy from all five emissions (control)', () => { + // Without this, every assertion below passes over an empty harvest — the + // vacuity that would make the whole file certify the defect it exists for. + const manifests = emittedManifests(); + expect(manifests.map((m) => m.id).sort()).toEqual([ + 'os create example', + 'os create plugin', + 'os init -t app', + 'os init -t empty', + 'os init -t plugin', + ]); + const names = [...declaredRanges().keys()]; + expect(names).toContain('typescript'); + expect(names).toContain('vitest'); + expect(names.length).toBeGreaterThanOrEqual(4); + }); + + it('declares exactly one range per third-party dependency', () => { + const disagreements: string[] = []; + for (const [name, ranges] of declaredRanges()) { + if (ranges.size === 1) continue; + const detail = [...ranges] + .map(([range, emissions]) => `${range} (${emissions.join(', ')})`) + .join(' vs '); + disagreements.push(`${name}: ${detail}`); + } + expect( + disagreements, + 'these dependency names are restated with different ranges by different ' + + 'scaffolders — declare the range once in init.ts and import it', + ).toEqual([]); + }); + + it('emits the exported constant rather than a literal, for every policy range', () => { + const ranges = declaredRanges(); + const expected: Array<[string, string]> = [ + ['typescript', SCAFFOLD_TYPESCRIPT_RANGE], + ['vitest', SCAFFOLD_VITEST_RANGE], + ['@types/node', SCAFFOLD_TYPES_NODE_RANGE], + ['tsx', SCAFFOLD_TSX_RANGE], + ['zod', SCAFFOLD_ZOD_RANGE], + ]; + for (const [name, constant] of expected) { + expect([...(ranges.get(name)?.keys() ?? [])], name).toEqual([constant]); + } + }); +}); + +describe('the surviving TypeScript range is the floor the docs already state', () => { + /** `TypeScript 5.3+` / `TypeScript 5.3.0 or later`, normalised to `major.minor`. */ + function statedFloors(file: string): string[] { + const text = readFileSync(file, 'utf8'); + const out: string[] = []; + const re = /TypeScript (\d+)\.(\d+)(?:\.\d+)?(?:\+| or later)/g; + for (const m of text.matchAll(re)) out.push(`${m[1]}.${m[2]}`); + return out; + } + + it('finds a stated floor on both pages (control)', () => { + // A regex that matched nothing would make the case below assert `[] === []`. + expect(statedFloors(GETTING_STARTED).length).toBeGreaterThan(0); + expect(statedFloors(TROUBLESHOOTING).length).toBeGreaterThan(0); + }); + + it('agrees with what the scaffolders emit', () => { + const floors = new Set([...statedFloors(GETTING_STARTED), ...statedFloors(TROUBLESHOOTING)]); + expect([...floors], 'the two pages state different TypeScript floors').toHaveLength(1); + const [floor] = [...floors]; + expect( + SCAFFOLD_TYPESCRIPT_RANGE, + 'the emitted range and the documented floor have to be the same promise — ' + + 'change both together, or neither', + ).toBe(`^${floor}.0`); + }); +}); + +describe('the emitted tsconfig.json comes from the shared renderer', () => { + function runCli(args: string[], cwd: string): Promise<{ code: number; stderr: string }> { + return new Promise((done) => { + execFile( + TSX, + [CLI, ...args], + { cwd, maxBuffer: 8 * 1024 * 1024, env: childEnv({ NO_COLOR: '1' }) }, + (err, _stdout, stderr) => { + done({ + code: err + ? typeof (err as { code?: unknown }).code === 'number' + ? (err as unknown as { code: number }).code + : 1 + : 0, + stderr: String(stderr), + }); + }, + ); + }); + } + + /** Everything but the two keys the emitted shapes legitimately differ on. */ + function base(tsconfig: Record): Record { + const options = { ...(tsconfig.compilerOptions as Record) }; + delete options.rootDir; + return options; + } + + it( + 'os init writes exactly what renderScaffoldTsconfig() returns, and os create shares its base', + { timeout: RUN_TIMEOUT_MS }, + async () => { + const sandbox = mkdtempSync(join(tmpdir(), 'emission-policy-')); + try { + const run = await runCli(['init', PROBE_NAME, '-t', 'app', '--no-install'], sandbox); + expect(run.code, run.stderr).toBe(0); + + // The emission really happened — an absent or empty directory would let + // every comparison below run over nothing. + const projectDir = join(sandbox, PROBE_NAME); + expect(readdirSync(projectDir).length).toBeGreaterThan(1); + + const emitted = readFileSync(join(projectDir, 'tsconfig.json'), 'utf8'); + const rendered = renderScaffoldTsconfig({ + rootDir: '.', + include: SCAFFOLD_TSCONFIG_INCLUDE_WITH_ROOT_CONFIG, + }); + expect(emitted).toBe(`${JSON.stringify(rendered, null, 2)}\n`); + + // `os create`'s standalone tsconfigs measured against the bytes `os + // init` actually wrote, not against a transcription of either. + const emittedOptions = base(JSON.parse(emitted) as Record); + for (const [key, template] of Object.entries(templates)) { + const render = template.filesFor(DEFAULT_PLACEMENT)['tsconfig.json']; + const created = render(PROBE_NAME) as Record; + expect(base(created), `os create ${key}`).toEqual(emittedOptions); + } + } finally { + rmSync(sandbox, { recursive: true, force: true }); + } + }, + ); +}); diff --git a/scripts/cross-package-test-inputs.mjs b/scripts/cross-package-test-inputs.mjs index 065e771576..5cd177ba21 100644 --- a/scripts/cross-package-test-inputs.mjs +++ b/scripts/cross-package-test-inputs.mjs @@ -291,6 +291,17 @@ export const CROSS_PACKAGE_TEST_INPUTS = { // merge queue would be the first signal -- the shape the three e2e pages // above were declared for. // + // The two pages added for #15818 are read by + // test/scaffold-emission-policy.e2e.test.ts, which holds the TypeScript range + // BOTH scaffolders emit equal to the floor those pages promise a reader + // ("ObjectStack works with TypeScript 5.3+", "TypeScript 5.3.0 or later"). + // That promise is what settled which of three restated ranges survived the + // extraction, so the pin is the only thing that keeps the emitted value and + // the documented one from parting again. Same both-ways coupling as the + // #14824 trio: a range moved in `init.ts` must redden the pages that still + // promise the old floor, and a page rewritten to a new floor must redden + // until the scaffolders follow. + // // `connector-mcp-plugin.ts` is read by test/serve-capability-identity.test.ts, // which pins that the connector still registers the name the #7652 repro uses // rather than importing the class. It surfaced with the three above and has the @@ -349,6 +360,8 @@ export const CROSS_PACKAGE_TEST_INPUTS = { 'examples/app-showcase/src/ui/pages/task-triage.page.ts', 'content/docs/deployment/cli.mdx', 'content/docs/deployment/index.mdx', + 'content/docs/deployment/troubleshooting.mdx', + 'content/docs/getting-started/index.mdx', 'content/docs/permissions/authentication.mdx', 'content/docs/plugins/index.mdx', 'content/docs/protocol/kernel/index.mdx', diff --git a/turbo.json b/turbo.json index a89f287450..277117613b 100644 --- a/turbo.json +++ b/turbo.json @@ -109,6 +109,8 @@ "$TURBO_ROOT$/examples/app-showcase/src/ui/pages/task-triage.page.ts", "$TURBO_ROOT$/content/docs/deployment/cli.mdx", "$TURBO_ROOT$/content/docs/deployment/index.mdx", + "$TURBO_ROOT$/content/docs/deployment/troubleshooting.mdx", + "$TURBO_ROOT$/content/docs/getting-started/index.mdx", "$TURBO_ROOT$/content/docs/permissions/authentication.mdx", "$TURBO_ROOT$/content/docs/plugins/index.mdx", "$TURBO_ROOT$/content/docs/protocol/kernel/index.mdx", From 65bafb464e4da17d203bbb86ccdda6c72333e067 Mon Sep 17 00:00:00 2001 From: os-litant Date: Sat, 5 Sep 2026 16:22:52 +0000 Subject: [PATCH 3/4] chore(changeset): record the scaffold emission policy extraction Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --- .../scaffold-emission-policy-one-definition.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .changeset/scaffold-emission-policy-one-definition.md diff --git a/.changeset/scaffold-emission-policy-one-definition.md b/.changeset/scaffold-emission-policy-one-definition.md new file mode 100644 index 0000000000..204d1b4c84 --- /dev/null +++ b/.changeset/scaffold-emission-policy-one-definition.md @@ -0,0 +1,18 @@ +--- +"@objectstack/cli": patch +--- + +`objectstack init` and `objectstack create` now read one emission policy instead of each restating it. + +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. The two CLI values were written in the same commit and stayed apart for 211 days. + +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`. + +Two emitted values had to survive the merge, and both are argued rather than picked: + +- **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. +- **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. + +**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. + +`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. From 78fd6b2ade0ca4b446b959c076ceb8f8983d22c6 Mon Sep 17 00:00:00 2001 From: os-litant Date: Sat, 5 Sep 2026 17:44:16 +0000 Subject: [PATCH 4/4] fix(cli): correct the shipped provenance claim, and record the two new declarations in the CI filter-parity pin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --- ...scaffold-emission-policy-one-definition.md | 2 +- packages/cli/src/commands/create.ts | 5 ++-- packages/cli/src/commands/init.ts | 24 +++++++++++---- .../test/scaffold-emission-policy.e2e.test.ts | 9 ++++-- scripts/check-ci-filter-parity.mjs | 29 ++++++++++++++----- 5 files changed, 50 insertions(+), 19 deletions(-) diff --git a/.changeset/scaffold-emission-policy-one-definition.md b/.changeset/scaffold-emission-policy-one-definition.md index 204d1b4c84..c62f323ba8 100644 --- a/.changeset/scaffold-emission-policy-one-definition.md +++ b/.changeset/scaffold-emission-policy-one-definition.md @@ -4,7 +4,7 @@ `objectstack init` and `objectstack create` now read one emission policy instead of each restating it. -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. The two CLI values were written in the same commit and stayed apart for 211 days. +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. 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`. diff --git a/packages/cli/src/commands/create.ts b/packages/cli/src/commands/create.ts index d86240fc89..250081090a 100644 --- a/packages/cli/src/commands/create.ts +++ b/packages/cli/src/commands/create.ts @@ -65,8 +65,9 @@ * already been paid for twice in this repo: the build-approval block landed in * one scaffold path and not the other, and one of them shipped the pre-fix shape * for months; and the TypeScript range a scaffold installs was written in six - * places and split into three values, the split surviving 102 days on the value - * that decides whether the scaffold type-checks at all. The emission policy has + * places and split into three values — the two CLI values 210 days apart, the + * third 53 and recorded nowhere — on the value that decides whether the + * scaffold type-checks at all. The emission policy has * one home now — see the block above `renderScaffoldPackageJson` in `init.ts` * for the measurement and for which values survived. * diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 5e8581d225..5837dda90a 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -234,12 +234,24 @@ export const SCAFFOLD_PNPM_RANGE = '>=10.15'; // os create `^5.8.0` (two write points in create.ts) // create-objectstack `^6.0.0` (its bundled template's package.json) // -// The split is not recent and nobody caused it deliberately: `^5.3.0` and -// `^5.8.0` were written in the same commit (2026-02-07), and the third value -// arrived with the bundled template on 2026-05-25 — 102 days of three -// scaffolders answering "which TypeScript does a new ObjectStack project -// install?" three different ways, on the value that decides whether the -// scaffold type-checks at all. +// The split is not recent and nobody caused it deliberately. Read off `git +// log -G` over the three files, as of 2026-09-05: +// +// 338e68d2564 2026-02-07 `^5.3.0` and `^5.8.0` written in the SAME commit +// — 210 days apart and counting +// dbb54e12f0c 2026-05-25 the bundled template lands, also at `^5.3.0` +// — so this is still a TWO-value tree +// eaff01425b7 2026-07-14 that template's line moves `^5.3.0` → `^6.0.0` +// — the third value, 53 days old +// +// ⚠️ And the move that made the third value recorded no reasoning about +// TypeScript at all. #2907's commit message documents the `@objectstack/*` +// version sync and nothing else, and in that same one-file diff the five +// `@objectstack/*` ranges move `^6.0.0` → `^14.0.0` while the `typescript` +// line moves ONTO the `^6.0.0` those lines are vacating. Whatever the intent +// was, no statement of it exists — which is the point: an unrecorded value is +// what a restatement decays into, and it decayed on the value that decides +// whether a new project type-checks at all. // // The control for that reading is in this same file: `SCAFFOLD_PNPM_RANGE` // and `renderPnpmWorkspaceYaml()` are IMPORTED by the other scaffolder rather diff --git a/packages/cli/test/scaffold-emission-policy.e2e.test.ts b/packages/cli/test/scaffold-emission-policy.e2e.test.ts index ddeab9dd38..d38b0b18b4 100644 --- a/packages/cli/test/scaffold-emission-policy.e2e.test.ts +++ b/packages/cli/test/scaffold-emission-policy.e2e.test.ts @@ -11,9 +11,12 @@ * 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` in * `init.ts`, `^5.8.0` in `create.ts`, `^6.0.0` in the bundled - * `create-objectstack` template). vitest had split into two. The two CLI values - * were written in the SAME commit and stayed apart for 211 days; the third - * arrived 102 days before the measurement. + * `create-objectstack` template). vitest had split into two. Dated off `git + * log -G` as of 2026-09-05: the two CLI values were written in the SAME commit + * (338e68d2564, 2026-02-07) and stayed apart for 210 days; the bundled template + * landed at `^5.3.0` too (dbb54e12f0c, 2026-05-25) and only became the third + * value 53 days ago, when eaff01425b7 moved it to `^6.0.0` without recording + * any reasoning about TypeScript. * * The control for that reading sits in the same file as the defect: * `SCAFFOLD_PNPM_RANGE` and `renderPnpmWorkspaceYaml()` are IMPORTED by the diff --git a/scripts/check-ci-filter-parity.mjs b/scripts/check-ci-filter-parity.mjs index 26563d289c..a9f229d976 100644 --- a/scripts/check-ci-filter-parity.mjs +++ b/scripts/check-ci-filter-parity.mjs @@ -680,14 +680,20 @@ export async function selfTest() { // @objectstack/cli: `os create`'s emitted plugin shape is stated nowhere but // its documentation, so the pin holding the template to it reads all three // pages, each covered only through the `content/**` root #10015 added. - // Ten plus one plus two plus one plus one plus one plus three: the rollback - // now uncovers nineteen. This pin is judged over the LIVE declaration table on - // purpose: a declaration added under a root the rollback keeps leaves the - // count alone, one under a new root moves it and is recorded here by name. + // Plus, since #15818, the two doc pages THAT card declared for the same + // package: the TypeScript floor both scaffolders emit was chosen because those + // two pages already promise it, so the pin holding the emitted range to that + // promise reads both -- and each is covered only through the same `content/**` + // root, exactly like #14824's three. + // Ten plus one plus two plus one plus one plus one plus three plus two: the + // rollback now uncovers twenty-one. This pin is judged over the LIVE + // declaration table on purpose: a declaration added under a root the rollback + // keeps leaves the count alone, one under a new root moves it and is recorded + // here by name. const preFix = judge(fixtureWorkflow({ core: real.filters?.core, crosspkg: ['scripts/**'] }), CROSS_PACKAGE_TEST_INPUTS); assert( - new Set(uncoveredGlobs(preFix)).size === 19, - `rolling \`crosspkg\` back to its pre-#10015 list uncovers the ten it fixed plus #10848's one plus #10178's two plus #12201's one plus #12924's one plus #14561's one plus #14824's three -- got ${new Set(uncoveredGlobs(preFix)).size}`, + new Set(uncoveredGlobs(preFix)).size === 21, + `rolling \`crosspkg\` back to its pre-#10015 list uncovers the ten it fixed plus #10848's one plus #10178's two plus #12201's one plus #12924's one plus #14561's one plus #14824's three plus #15818's two -- got ${new Set(uncoveredGlobs(preFix)).size}`, ); assert( uncoveredGlobs(preFix).includes('skills/**'), @@ -723,6 +729,15 @@ export async function selfTest() { `-- and #14824 added the \`os create plugin\` scaffold-listing page ${page}, by name`, ); } + for (const page of [ + 'content/docs/deployment/troubleshooting.mdx', + 'content/docs/getting-started/index.mdx', + ]) { + assert( + uncoveredGlobs(preFix).includes(page), + `-- and #15818 added the TypeScript-floor page ${page}, by name`, + ); + } // ── (7) WIRING: the gate and its self-test really run in CI ────────────── battery('(7) WIRING: the gate and its self-test really run in CI'); @@ -794,7 +809,7 @@ export async function selfTest() { `same-root-different-file case observed failing and then covered by naming the file, a glob covered by ` + `\`core\`, one covered only by \`crosspkg\` and one covered by neither judged separately in one table, the ` + `stale-entry direction, seven refusals over subjects that could not be read, the checked-in ci.yml, the ` + - `pre-#10015 rollback uncovering the ten it fixed plus #10848's one plus #10178's two plus #12201's one plus #12924's one plus #14561's one plus #14824's three, ` + + `pre-#10015 rollback uncovering the ten it fixed plus #10848's one plus #10178's two plus #12201's one plus #12924's one plus #14561's one plus #14824's three plus #15818's two, ` + `and the CI wiring read out of lint.yml.`, ); selfTestReachedVerdict = true;