Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .changeset/scaffold-on-ramp-emission-policy-generated.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
"create-objectstack": minor
---

`npx create-objectstack` now declares the same TypeScript range as `os init` and
`os create`, and the value is generated rather than restated.

Three scaffolders write a new project's `package.json`, and the range that
decides whether that project type-checks at all had split: `os init` and
`os create` emitted `typescript: ^5.3.0` from a shared emission policy, while
this package's bundled template carried `^6.0.0`. Two projects created the same
day got different TypeScript **majors** depending on which documented entry
point the reader followed.

- **What changed for a scaffolded project.** Its declared `typescript`
devDependency floor moves from `^6.0.0` to `^5.3.0`. Both resolve to the same
installed compiler on a fresh install; what moves is the floor the project
**declares**, and a floor is a support promise. `^5.3.0` is the promise the
docs already make — "ObjectStack works with TypeScript 5.3+" on the getting
started page, "TypeScript 5.3.0 or later" in the deployment troubleshooting
page — and it is measured rather than assumed: TypeScript 5.3.3 type-checks
every shape these scaffolders emit with results identical to 6.0.3. The repo's
own `typescript@^6.0.3` devDependency is deliberately not this value; the same
doc sentence states both halves ("…but the project itself is built and tested
against TypeScript 6.x"). `engines.pnpm` was already in agreement and is now
held there by the same mechanism.
- **Why the value is generated.** This package cannot import from
`@objectstack/cli`: the dependency edge runs the other way, and the `npx`
package must not pull the CLI's package closure. So the values are stamped
into the bundled template at build time by
`scripts/sync-scaffold-emission-policy.mjs`, read out of the same
`SCAFFOLD_*` constants the other two scaffolders import, and
`pnpm check:scaffold-emission-policy` reddens the moment the inlined values
disagree with that source. Editing the two into agreement by hand would have
left them free to diverge again on the next move, silently, for the same
structural reason — which is how they diverged the first time.
19 changes: 19 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2033,6 +2033,25 @@ jobs:
- name: Template version-time rewriter self-test
run: pnpm check:template-version-sync

# #16485 — the BUILD-time generator beside the version-time one above, and
# the only one of the two whose --check leg runs on a real corpus here.
# `create-objectstack` cannot import @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 scaffold emission policy and the
# restatement decayed: `typescript` reached `^6.0.0` there while `os init`
# and `os create` emitted `^5.3.0` from the shared constants, so two
# projects created the same day got different TypeScript majors depending
# on which documented entry point the reader followed. The values are now
# generated into the template from `packages/cli/src/commands/init.ts`, and
# this is the leg that reddens on drift — a hand edit into agreement would
# have satisfied the acceptance box and diverged again on the next move.
# Both legs run: --self-test covers the red paths a green corpus cannot
# reach (a renamed policy constant, a template omitting a stamped key, an
# unparseable template, an empty templates directory), --check covers the
# live tree.
- name: Scaffold emission policy generated into the on-ramp
run: pnpm check:scaffold-emission-policy

# #15332 — the THIRD version-time rewriter, and the third self-test beside
# the two above. scripts/sync-release-index-currency.mjs joins the root
# `version` chain and stamps the release index's "current series: X.Y.Z,
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@
"check:react-page-adapter-contract": "node scripts/check-react-page-adapter-contract.mjs --self-test && node scripts/check-react-page-adapter-contract.mjs",
"check:page-declaration-shape": "node scripts/check-page-declaration-shape.mjs --self-test && node scripts/check-page-declaration-shape.mjs",
"check:template-version-sync": "node scripts/sync-template-versions.mjs --self-test",
"gen:scaffold-emission-policy": "node scripts/sync-scaffold-emission-policy.mjs",
"check:scaffold-emission-policy": "node scripts/sync-scaffold-emission-policy.mjs --self-test && node scripts/sync-scaffold-emission-policy.mjs --check",
"check:role-word": "node scripts/check-role-word.mjs --self-test && node scripts/check-role-word.mjs",
"check:corpus-claim-drift": "node scripts/check-corpus-claim-drift.mjs --self-test && node scripts/check-corpus-claim-drift.mjs",
"check:skill-identifier-liveness": "node scripts/check-skill-identifier-liveness.mjs --self-test && node scripts/check-skill-identifier-liveness.mjs",
Expand Down
35 changes: 25 additions & 10 deletions packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,16 +282,31 @@ export const SCAFFOLD_PNPM_RANGE = '>=10.15';
// 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.
// `create-objectstack` — the third scaffolder, and the one that CANNOT reach
// these constants by import. 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 a committed template file copied
// byte-for-byte, with no renderer to route through a constant. That is the
// whole reason its `typescript` line drifted to `^6.0.0` while these two held.
//
// It is unified anyway, by GENERATION rather than by import:
// `scripts/sync-scaffold-emission-policy.mjs` reads the `SCAFFOLD_*` constants
// out of THIS file and stamps them into every bundled template's
// `package.json`; `create-objectstack`'s `build` runs it, and
// `pnpm check:scaffold-emission-policy` reddens the moment the inlined values
// disagree with the source. ⛔ So a value below is read by a script as well as
// by a compiler: keep the `export const NAME = '<value>';` spelling on one
// line, and add the row to that script's `POLICY_STAMPS` if a new constant has
// to reach the templates too.
//
// ⚠️ Unifying it moved a scaffolded project's DECLARED floor from `^6.0.0` to
// `^5.3.0` (both resolve to typescript 5.9.3 or later at install time; the
// floor is the support promise). The repo's own devDependency is `^6.0.3`, and
// that is deliberately NOT this value: `content/docs/getting-started/index.mdx`
// states both halves in one sentence — "ObjectStack works with TypeScript 5.3+,
// but the project itself is built and tested against TypeScript 6.x".

/** The TypeScript range every scaffolded project declares. */
export const SCAFFOLD_TYPESCRIPT_RANGE = '^5.3.0';
Expand Down
155 changes: 150 additions & 5 deletions packages/cli/test/scaffold-emission-policy.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,18 +43,30 @@
* 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.
* 5. The THIRD scaffolder — `npx create-objectstack`, the documented on-ramp
* — is now in scope (#16485). It still cannot IMPORT these constants: the
* dependency edge runs the other way and the npx package must not pull the
* CLI's closure. It reaches them by GENERATION instead
* (`scripts/sync-scaffold-emission-policy.mjs` stamps its bundled template
* from this same file at build time, and `pnpm check:scaffold-emission-policy`
* reddens on drift). While it was out of scope its `typescript` line sat at
* `^6.0.0`, so two projects created the same day got different TypeScript
* MAJORS depending on which entry point the reader followed.
*
* ⚠️ The on-ramp is measured by DRIVING it — spawning its real `bin/` entry into
* a throwaway directory and reading the emitted `package.json` off disk — and
* never by reading the committed template the generator writes. A pin that read
* the generator's own output would be reading the same source it is guarding,
* and would stay green through a build that stopped copying templates at all.
* Its `dist/` is present because `@objectstack/cli#test` depends on `^build`.
*
* 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 { describe, it, expect, afterAll, beforeAll } from 'vitest';
import { execFile } from 'node:child_process';
import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
Expand All @@ -64,6 +76,7 @@ import { childEnv } from './helpers/serve-process.js';
import {
renderScaffoldPackageJson,
renderScaffoldTsconfig,
SCAFFOLD_PNPM_RANGE,
SCAFFOLD_TSCONFIG_INCLUDE_WITH_ROOT_CONFIG,
SCAFFOLD_TSX_RANGE,
SCAFFOLD_TYPES_NODE_RANGE,
Expand All @@ -78,6 +91,16 @@ const HERE = resolve(fileURLToPath(import.meta.url), '..');
const CLI = resolve(HERE, '../bin/run-dev.js');
const TSX = resolve(HERE, '../../../node_modules/.bin/tsx');

/**
* The on-ramp's real entry point and the template it ships, both declared as
* cross-package inputs of `@objectstack/cli` (scripts/cross-package-test-inputs.mjs,
* mirrored into turbo.json) — a template-only diff changes what the block at the
* bottom of this file measures, so without the declaration this suite would
* replay a cached green over exactly the divergence it exists to catch.
*/
const ON_RAMP_BIN = resolve(HERE, '../../..', 'packages/create-objectstack/bin/create-objectstack.js');
const ON_RAMP_TEMPLATE_PKG = resolve(HERE, '../../..', 'packages/create-objectstack/src/templates/blank/package.json');

// 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.
Expand Down Expand Up @@ -279,3 +302,125 @@ describe('the emitted tsconfig.json comes from the shared renderer', () => {
},
);
});

describe('the on-ramp emits the same policy — measured by DRIVING it', () => {
/**
* `npx create-objectstack`'s emitted `package.json`, produced by spawning the
* package's real `bin/` entry. `--skip-install` and `--skip-skills` keep the
* run offline and fs-only; everything this block reads is written before
* either step would run.
*/
let sandbox = '';
let emitted: Record<string, unknown> | null = null;
let failure = '';

beforeAll(async () => {
sandbox = mkdtempSync(join(tmpdir(), 'on-ramp-policy-'));
const run = await new Promise<{ code: number; stderr: string }>((done) => {
execFile(
process.execPath,
[ON_RAMP_BIN, PROBE_NAME, '--skip-install', '--skip-skills'],
{ cwd: sandbox, maxBuffer: 8 * 1024 * 1024, env: childEnv({ NO_COLOR: '1' }) },
(err, _stdout, stderr) => {
done({ code: err ? Number((err as { code?: unknown }).code ?? 1) : 0, stderr: String(stderr) });
},
);
});
if (run.code !== 0) {
// `bin/create-objectstack.js` imports `../dist/index.js`, so an unbuilt
// package fails here rather than anywhere informative. Say which build.
failure =
`create-objectstack exited ${run.code}. If it could not resolve ../dist/index.js, this suite ` +
'ran without its dependency build — `pnpm --filter create-objectstack build`, which ' +
`\`@objectstack/cli#test\` normally supplies via \`^build\`.\n${run.stderr}`;
return;
}
const projectDir = join(sandbox, PROBE_NAME);
emitted = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf8')) as Record<string, unknown>;
}, RUN_TIMEOUT_MS);

afterAll(() => {
if (sandbox) rmSync(sandbox, { recursive: true, force: true });
});

/** The on-ramp's emission, beside the five the two CLI commands render. */
function allSixManifests(): Array<{ id: string; manifest: Record<string, unknown> }> {
return [...emittedManifests(), { id: 'npx create-objectstack', manifest: emitted! }];
}

it('really drove the on-ramp, and got a manifest with policy in it (control)', () => {
expect(failure, failure).toBe('');
expect(readdirSync(join(sandbox, PROBE_NAME)).length).toBeGreaterThan(1);
// Without this, every assertion below would range over an empty harvest —
// the vacuity that would let this whole block certify the defect it exists
// for. The on-ramp declares exactly one third-party dependency today, so
// `toContain` rather than a count.
expect(thirdPartyOnly(emitted?.devDependencies as Record<string, unknown>).map(([n]) => n)).toContain(
'typescript',
);
expect((emitted?.engines as Record<string, unknown> | undefined)?.pnpm).toBeTypeOf('string');
});

it('declares exactly one range per third-party dependency, across ALL THREE scaffolders', () => {
const byName = new Map<string, Map<string, string[]>>();
for (const { id, manifest } of allSixManifests()) {
for (const [name, range] of [
...thirdPartyOnly(manifest.dependencies as Record<string, unknown>),
...thirdPartyOnly(manifest.devDependencies as Record<string, unknown>),
]) {
const ranges = byName.get(name) ?? new Map<string, string[]>();
ranges.set(range, [...(ranges.get(range) ?? []), id]);
byName.set(name, ranges);
}
}
const disagreements: string[] = [];
for (const [name, ranges] of byName) {
if (ranges.size === 1) continue;
disagreements.push(
`${name}: ${[...ranges].map(([r, ids]) => `${r} (${ids.join(', ')})`).join(' vs ')}`,
);
}
expect(
disagreements,
'a scaffolded project must declare the same third-party ranges whichever documented entry '
+ 'point created it. The on-ramp reaches the policy by generation, not import: run '
+ '`pnpm gen:scaffold-emission-policy` and commit the template it rewrites',
).toEqual([]);
});

it('emits the exported TypeScript and pnpm constants, not a restatement of them', () => {
const typescriptRanges = new Set(
allSixManifests().map(
({ manifest }) =>
(manifest.devDependencies as Record<string, string> | undefined)?.typescript
?? (manifest.dependencies as Record<string, string> | undefined)?.typescript,
),
);
expect([...typescriptRanges], 'every emission declares typescript, at one range').toEqual([
SCAFFOLD_TYPESCRIPT_RANGE,
]);

const pnpmRanges = new Set(
allSixManifests().map(({ manifest }) => (manifest.engines as Record<string, string> | undefined)?.pnpm),
);
expect([...pnpmRanges], 'every emission declares engines.pnpm, at one range').toEqual([
SCAFFOLD_PNPM_RANGE,
]);
});

it('carries the committed template through unchanged — the generator ran, the build copied', () => {
// The one place the committed template is read, and deliberately as a
// CONSEQUENCE rather than as the expectation: the drive above already
// settled what the on-ramp emits. This says the bytes a reader would edit
// are the bytes that shipped, so a stale `dist/` or a generator that never
// ran is legible as itself rather than as a policy disagreement.
const committed = JSON.parse(readFileSync(ON_RAMP_TEMPLATE_PKG, 'utf8')) as {
devDependencies?: Record<string, string>;
engines?: Record<string, string>;
};
expect(committed.devDependencies?.typescript).toBe(
(emitted?.devDependencies as Record<string, string> | undefined)?.typescript,
);
expect(committed.engines?.pnpm).toBe((emitted?.engines as Record<string, string> | undefined)?.pnpm);
});
});
2 changes: 1 addition & 1 deletion packages/create-objectstack/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
}
},
"scripts": {
"build": "tsup && node ../../scripts/check-dts-emitted.mjs",
"build": "node ../../scripts/sync-scaffold-emission-policy.mjs && tsup && node ../../scripts/check-dts-emitted.mjs",
"typecheck": "tsc --noEmit",
"dev": "tsup --watch",
"test": "vitest run"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,6 @@
},
"devDependencies": {
"@objectstack/cli": "^17.0.0",
"typescript": "^6.0.0"
"typescript": "^5.3.0"
}
}
18 changes: 18 additions & 0 deletions scripts/cross-package-test-inputs.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,24 @@ export const CROSS_PACKAGE_TEST_INPUTS = {
// One file, not `packages/create-objectstack/**`: the test reads that
// template and nothing else across the boundary.
'packages/create-objectstack/src/templates/blank/pnpm-workspace.yaml',
// The on-ramp's ENTRY POINT and the template it ships, the second pair of
// that same shape (#16485). test/scaffold-emission-policy.e2e.test.ts
// SPAWNS `bin/create-objectstack.js` into a throwaway directory and reads
// the `package.json` it emits, then compares the third-party ranges and
// `engines.pnpm` against the five this package renders. Three scaffolders
// emit a new project's manifest and only two of them can import the
// `SCAFFOLD_*` constants — the third reaches them by generation — so a
// diff to either of these files is exactly the divergence that pin exists
// to catch, and without the declaration `@objectstack/cli#test` hashes the
// same and replays a cached green over it.
'packages/create-objectstack/bin/create-objectstack.js',
'packages/create-objectstack/src/templates/blank/package.json',
// The generator that ties those two to this package's own constants, and
// the third entry of the mention shape on this package — settled the way
// check-nul-bytes.mjs above is. It earns the declaration on the merits
// too: it is what makes the bundled template equal `SCAFFOLD_*`, so a
// change to it changes what that pin measures.
'scripts/sync-scaffold-emission-policy.mjs',
// The two files that hold the COLUMN authority the CLI's migration
// generators mirror, READ by
// src/commands/generate-multiple-json-column.pin.test.ts (#14829). That
Expand Down
Loading
Loading