Skip to content

Commit 6456329

Browse files
os-litantclaude
andcommitted
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
1 parent 2d39930 commit 6456329

3 files changed

Lines changed: 293 additions & 0 deletions

File tree

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* PIN — the two CLI scaffolders emit ONE emission policy, not two copies of it.
5+
*
6+
* ## The defect this exists for
7+
*
8+
* `os init` and `os create` each wrote the third-party ranges and the
9+
* `tsconfig.json` a new project receives, in their own words. Measured on the
10+
* tree the day this landed, the TypeScript range — the value that decides
11+
* whether a scaffolded project type-checks at all — was written in SIX places
12+
* across three scaffolders and had split into THREE values (`^5.3.0` in
13+
* `init.ts`, `^5.8.0` in `create.ts`, `^6.0.0` in the bundled
14+
* `create-objectstack` template). vitest had split into two. The two CLI values
15+
* were written in the SAME commit and stayed apart for 211 days; the third
16+
* arrived 102 days before the measurement.
17+
*
18+
* The control for that reading sits in the same file as the defect:
19+
* `SCAFFOLD_PNPM_RANGE` and `renderPnpmWorkspaceYaml()` are IMPORTED by the
20+
* other scaffolder rather than restated, and across the same five emissions,
21+
* the same window and the same authors they did not drift at all.
22+
*
23+
* ## What is asserted, and why no expected value is written down here
24+
*
25+
* Every expectation below is DERIVED — from the renderers, from the other
26+
* scaffolder, or from the doc page that already states the answer. A test that
27+
* transcribed `'^5.3.0'` would go green on a tree where one scaffolder had been
28+
* edited and the other had not, which is the exact state it exists to catch.
29+
*
30+
* 1. Across all five emissions, each third-party dependency name resolves to
31+
* exactly ONE range. This is the property; the value it settles on is not.
32+
* 2. That one range IS the exported constant, so a template that grows a
33+
* literal instead of importing turns this red.
34+
* 3. The surviving TypeScript range is the floor the DOCS state. `^5.3.0`
35+
* beat `^5.8.0` because two live pages already promise "TypeScript 5.3+";
36+
* that is what made the choice a recorded decision rather than a silent
37+
* pick, and this case is what keeps the two ends tied together.
38+
* 4. `os init`'s `tsconfig.json` is written inside `run()`, so it is measured
39+
* by DRIVING the real command into a throwaway directory and reading the
40+
* bytes off disk — a renderer that is exported but no longer called would
41+
* pass every in-process assertion here.
42+
*
43+
* ⚠️ `create-objectstack`'s `^6.0.0` is deliberately out of scope and is NOT
44+
* asserted against: that package cannot import from `@objectstack/cli` (the
45+
* dependency edge runs the other way), and unifying it would change what a
46+
* scaffolded project installs.
47+
*
48+
* Spawned through `bin/run-dev.js` + tsx, so this suite does not depend on
49+
* `packages/cli/dist` having been built (`@objectstack/cli#test` depends on
50+
* `^build` only) — the same reason `create-refuses-invalid-project-name.e2e.test.ts`
51+
* spawns that way.
52+
*/
53+
54+
import { describe, it, expect } from 'vitest';
55+
import { execFile } from 'node:child_process';
56+
import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs';
57+
import { tmpdir } from 'node:os';
58+
import { join, resolve } from 'node:path';
59+
import { fileURLToPath } from 'node:url';
60+
import { childEnv } from './helpers/serve-process.js';
61+
import {
62+
renderScaffoldPackageJson,
63+
renderScaffoldTsconfig,
64+
SCAFFOLD_TSCONFIG_INCLUDE_WITH_ROOT_CONFIG,
65+
SCAFFOLD_TSX_RANGE,
66+
SCAFFOLD_TYPES_NODE_RANGE,
67+
SCAFFOLD_TYPESCRIPT_RANGE,
68+
SCAFFOLD_VITEST_RANGE,
69+
SCAFFOLD_ZOD_RANGE,
70+
TEMPLATES,
71+
} from '../src/commands/init.js';
72+
import { DEFAULT_PLACEMENT, templates } from '../src/commands/create.js';
73+
74+
const HERE = resolve(fileURLToPath(import.meta.url), '..');
75+
const CLI = resolve(HERE, '../bin/run-dev.js');
76+
const TSX = resolve(HERE, '../../../node_modules/.bin/tsx');
77+
78+
// One `resolve(HERE, …)` call per line and nothing split across lines:
79+
// `check:cross-package-test-inputs` reconstructs these reads by SOURCE SCAN,
80+
// and a spelling it cannot parse leaves the glob declared and held by nothing.
81+
// Both are declared for `@objectstack/cli` in
82+
// scripts/cross-package-test-inputs.mjs and mirrored into turbo.json.
83+
const GETTING_STARTED = resolve(HERE, '../../..', 'content/docs/getting-started/index.mdx');
84+
const TROUBLESHOOTING = resolve(HERE, '../../..', 'content/docs/deployment/troubleshooting.mdx');
85+
86+
/** oclif + tsx cold start with every command module loaded; ~2-10 s when healthy. */
87+
const RUN_TIMEOUT_MS = 180_000;
88+
89+
const PROBE_NAME = 'emission-policy-probe';
90+
91+
/** `@objectstack/*` ranges are the CLI's own version — pinned by `init.test.ts`. */
92+
function thirdPartyOnly(deps: Record<string, unknown> | undefined): Array<[string, string]> {
93+
return Object.entries(deps ?? {})
94+
.filter(([name, range]) => !name.startsWith('@objectstack/') && typeof range === 'string')
95+
.map(([name, range]) => [name, range as string]);
96+
}
97+
98+
/**
99+
* Every `package.json` the two commands emit for the shape a reader of the docs
100+
* actually gets — `os init`'s three templates and `os create`'s two, in its
101+
* DEFAULT placement. `--in-repo` is excluded on purpose: it emits `workspace:*`
102+
* and is documented as platform-work-only.
103+
*/
104+
function emittedManifests(): Array<{ id: string; manifest: Record<string, unknown> }> {
105+
const out: Array<{ id: string; manifest: Record<string, unknown> }> = [];
106+
for (const [key, template] of Object.entries(TEMPLATES)) {
107+
out.push({
108+
id: `os init -t ${key}`,
109+
manifest: renderScaffoldPackageJson(PROBE_NAME, template),
110+
});
111+
}
112+
for (const [key, template] of Object.entries(templates)) {
113+
const render = template.filesFor(DEFAULT_PLACEMENT)['package.json'];
114+
out.push({
115+
id: `os create ${key}`,
116+
manifest: render(PROBE_NAME) as Record<string, unknown>,
117+
});
118+
}
119+
return out;
120+
}
121+
122+
/** `<dependency name> -> every range any emission declares for it`. */
123+
function declaredRanges(): Map<string, Map<string, string[]>> {
124+
const byName = new Map<string, Map<string, string[]>>();
125+
for (const { id, manifest } of emittedManifests()) {
126+
const deps = [
127+
...thirdPartyOnly(manifest.dependencies as Record<string, unknown>),
128+
...thirdPartyOnly(manifest.devDependencies as Record<string, unknown>),
129+
];
130+
for (const [name, range] of deps) {
131+
const ranges = byName.get(name) ?? new Map<string, string[]>();
132+
ranges.set(range, [...(ranges.get(range) ?? []), id]);
133+
byName.set(name, ranges);
134+
}
135+
}
136+
return byName;
137+
}
138+
139+
describe('scaffold emission policy — one definition, five emissions', () => {
140+
it('harvests a non-empty policy from all five emissions (control)', () => {
141+
// Without this, every assertion below passes over an empty harvest — the
142+
// vacuity that would make the whole file certify the defect it exists for.
143+
const manifests = emittedManifests();
144+
expect(manifests.map((m) => m.id).sort()).toEqual([
145+
'os create example',
146+
'os create plugin',
147+
'os init -t app',
148+
'os init -t empty',
149+
'os init -t plugin',
150+
]);
151+
const names = [...declaredRanges().keys()];
152+
expect(names).toContain('typescript');
153+
expect(names).toContain('vitest');
154+
expect(names.length).toBeGreaterThanOrEqual(4);
155+
});
156+
157+
it('declares exactly one range per third-party dependency', () => {
158+
const disagreements: string[] = [];
159+
for (const [name, ranges] of declaredRanges()) {
160+
if (ranges.size === 1) continue;
161+
const detail = [...ranges]
162+
.map(([range, emissions]) => `${range} (${emissions.join(', ')})`)
163+
.join(' vs ');
164+
disagreements.push(`${name}: ${detail}`);
165+
}
166+
expect(
167+
disagreements,
168+
'these dependency names are restated with different ranges by different '
169+
+ 'scaffolders — declare the range once in init.ts and import it',
170+
).toEqual([]);
171+
});
172+
173+
it('emits the exported constant rather than a literal, for every policy range', () => {
174+
const ranges = declaredRanges();
175+
const expected: Array<[string, string]> = [
176+
['typescript', SCAFFOLD_TYPESCRIPT_RANGE],
177+
['vitest', SCAFFOLD_VITEST_RANGE],
178+
['@types/node', SCAFFOLD_TYPES_NODE_RANGE],
179+
['tsx', SCAFFOLD_TSX_RANGE],
180+
['zod', SCAFFOLD_ZOD_RANGE],
181+
];
182+
for (const [name, constant] of expected) {
183+
expect([...(ranges.get(name)?.keys() ?? [])], name).toEqual([constant]);
184+
}
185+
});
186+
});
187+
188+
describe('the surviving TypeScript range is the floor the docs already state', () => {
189+
/** `TypeScript 5.3+` / `TypeScript 5.3.0 or later`, normalised to `major.minor`. */
190+
function statedFloors(file: string): string[] {
191+
const text = readFileSync(file, 'utf8');
192+
const out: string[] = [];
193+
const re = /TypeScript (\d+)\.(\d+)(?:\.\d+)?(?:\+| or later)/g;
194+
for (const m of text.matchAll(re)) out.push(`${m[1]}.${m[2]}`);
195+
return out;
196+
}
197+
198+
it('finds a stated floor on both pages (control)', () => {
199+
// A regex that matched nothing would make the case below assert `[] === []`.
200+
expect(statedFloors(GETTING_STARTED).length).toBeGreaterThan(0);
201+
expect(statedFloors(TROUBLESHOOTING).length).toBeGreaterThan(0);
202+
});
203+
204+
it('agrees with what the scaffolders emit', () => {
205+
const floors = new Set([...statedFloors(GETTING_STARTED), ...statedFloors(TROUBLESHOOTING)]);
206+
expect([...floors], 'the two pages state different TypeScript floors').toHaveLength(1);
207+
const [floor] = [...floors];
208+
expect(
209+
SCAFFOLD_TYPESCRIPT_RANGE,
210+
'the emitted range and the documented floor have to be the same promise — '
211+
+ 'change both together, or neither',
212+
).toBe(`^${floor}.0`);
213+
});
214+
});
215+
216+
describe('the emitted tsconfig.json comes from the shared renderer', () => {
217+
function runCli(args: string[], cwd: string): Promise<{ code: number; stderr: string }> {
218+
return new Promise((done) => {
219+
execFile(
220+
TSX,
221+
[CLI, ...args],
222+
{ cwd, maxBuffer: 8 * 1024 * 1024, env: childEnv({ NO_COLOR: '1' }) },
223+
(err, _stdout, stderr) => {
224+
done({
225+
code: err
226+
? typeof (err as { code?: unknown }).code === 'number'
227+
? (err as unknown as { code: number }).code
228+
: 1
229+
: 0,
230+
stderr: String(stderr),
231+
});
232+
},
233+
);
234+
});
235+
}
236+
237+
/** Everything but the two keys the emitted shapes legitimately differ on. */
238+
function base(tsconfig: Record<string, unknown>): Record<string, unknown> {
239+
const options = { ...(tsconfig.compilerOptions as Record<string, unknown>) };
240+
delete options.rootDir;
241+
return options;
242+
}
243+
244+
it(
245+
'os init writes exactly what renderScaffoldTsconfig() returns, and os create shares its base',
246+
{ timeout: RUN_TIMEOUT_MS },
247+
async () => {
248+
const sandbox = mkdtempSync(join(tmpdir(), 'emission-policy-'));
249+
try {
250+
const run = await runCli(['init', PROBE_NAME, '-t', 'app', '--no-install'], sandbox);
251+
expect(run.code, run.stderr).toBe(0);
252+
253+
// The emission really happened — an absent or empty directory would let
254+
// every comparison below run over nothing.
255+
const projectDir = join(sandbox, PROBE_NAME);
256+
expect(readdirSync(projectDir).length).toBeGreaterThan(1);
257+
258+
const emitted = readFileSync(join(projectDir, 'tsconfig.json'), 'utf8');
259+
const rendered = renderScaffoldTsconfig({
260+
rootDir: '.',
261+
include: SCAFFOLD_TSCONFIG_INCLUDE_WITH_ROOT_CONFIG,
262+
});
263+
expect(emitted).toBe(`${JSON.stringify(rendered, null, 2)}\n`);
264+
265+
// `os create`'s standalone tsconfigs measured against the bytes `os
266+
// init` actually wrote, not against a transcription of either.
267+
const emittedOptions = base(JSON.parse(emitted) as Record<string, unknown>);
268+
for (const [key, template] of Object.entries(templates)) {
269+
const render = template.filesFor(DEFAULT_PLACEMENT)['tsconfig.json'];
270+
const created = render(PROBE_NAME) as Record<string, unknown>;
271+
expect(base(created), `os create ${key}`).toEqual(emittedOptions);
272+
}
273+
} finally {
274+
rmSync(sandbox, { recursive: true, force: true });
275+
}
276+
},
277+
);
278+
});

scripts/cross-package-test-inputs.mjs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,17 @@ export const CROSS_PACKAGE_TEST_INPUTS = {
291291
// merge queue would be the first signal -- the shape the three e2e pages
292292
// above were declared for.
293293
//
294+
// The two pages added for #15818 are read by
295+
// test/scaffold-emission-policy.e2e.test.ts, which holds the TypeScript range
296+
// BOTH scaffolders emit equal to the floor those pages promise a reader
297+
// ("ObjectStack works with TypeScript 5.3+", "TypeScript 5.3.0 or later").
298+
// That promise is what settled which of three restated ranges survived the
299+
// extraction, so the pin is the only thing that keeps the emitted value and
300+
// the documented one from parting again. Same both-ways coupling as the
301+
// #14824 trio: a range moved in `init.ts` must redden the pages that still
302+
// promise the old floor, and a page rewritten to a new floor must redden
303+
// until the scaffolders follow.
304+
//
294305
// `connector-mcp-plugin.ts` is read by test/serve-capability-identity.test.ts,
295306
// which pins that the connector still registers the name the #7652 repro uses
296307
// rather than importing the class. It surfaced with the three above and has the
@@ -349,6 +360,8 @@ export const CROSS_PACKAGE_TEST_INPUTS = {
349360
'examples/app-showcase/src/ui/pages/task-triage.page.ts',
350361
'content/docs/deployment/cli.mdx',
351362
'content/docs/deployment/index.mdx',
363+
'content/docs/deployment/troubleshooting.mdx',
364+
'content/docs/getting-started/index.mdx',
352365
'content/docs/permissions/authentication.mdx',
353366
'content/docs/plugins/index.mdx',
354367
'content/docs/protocol/kernel/index.mdx',

turbo.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,8 @@
109109
"$TURBO_ROOT$/examples/app-showcase/src/ui/pages/task-triage.page.ts",
110110
"$TURBO_ROOT$/content/docs/deployment/cli.mdx",
111111
"$TURBO_ROOT$/content/docs/deployment/index.mdx",
112+
"$TURBO_ROOT$/content/docs/deployment/troubleshooting.mdx",
113+
"$TURBO_ROOT$/content/docs/getting-started/index.mdx",
112114
"$TURBO_ROOT$/content/docs/permissions/authentication.mdx",
113115
"$TURBO_ROOT$/content/docs/plugins/index.mdx",
114116
"$TURBO_ROOT$/content/docs/protocol/kernel/index.mdx",

0 commit comments

Comments
 (0)