From 88f60daedf06b4f1239adf910f2ddf9f62e28be1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 14:15:55 +0000 Subject: [PATCH 1/2] fix(create-objectstack): generate the on-ramp's version pins from the shared emission policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `npx create-objectstack` declared `typescript: ^6.0.0` while `os init` and `os create` emitted `^5.3.0` from the shared `SCAFFOLD_*` constants, so two projects created the same day got different TypeScript majors depending on which documented entry point the reader followed. The structural cause is that `create-objectstack` cannot import from `@objectstack/cli` — the dependency edge runs the other way and the npx package must not pull the CLI's closure — so its bundled template restated the policy and the restatement decayed. Editing the values into agreement would leave them free to diverge again for the same reason, so they are generated instead: - `scripts/sync-scaffold-emission-policy.mjs` reads the constants out of `packages/cli/src/commands/init.ts` and stamps them into every bundled template's `package.json`; `create-objectstack`'s `build` runs it, and `pnpm check:scaffold-emission-policy` reds on drift (both legs in lint.yml). - `test/scaffold-emission-policy.e2e.test.ts` now drives all THREE scaffolders and compares the manifests they emit, rather than restating the constants. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ --- ...ffold-on-ramp-emission-policy-generated.md | 36 ++ .github/workflows/lint.yml | 19 + package.json | 2 + packages/cli/src/commands/init.ts | 35 +- .../test/scaffold-emission-policy.e2e.test.ts | 155 ++++- packages/create-objectstack/package.json | 3 +- .../src/templates/blank/package.json | 2 +- scripts/cross-package-test-inputs.mjs | 12 + scripts/sync-scaffold-emission-policy.mjs | 574 ++++++++++++++++++ turbo.json | 2 + 10 files changed, 823 insertions(+), 17 deletions(-) create mode 100644 .changeset/scaffold-on-ramp-emission-policy-generated.md create mode 100644 scripts/sync-scaffold-emission-policy.mjs diff --git a/.changeset/scaffold-on-ramp-emission-policy-generated.md b/.changeset/scaffold-on-ramp-emission-policy-generated.md new file mode 100644 index 0000000000..b21a950309 --- /dev/null +++ b/.changeset/scaffold-on-ramp-emission-policy-generated.md @@ -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. diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 130c893dba..c7cab236ad 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -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, diff --git a/package.json b/package.json index b84bdca13c..24aaef6343 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 52764264e0..fb2602d9db 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -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 = '';` 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'; diff --git a/packages/cli/test/scaffold-emission-policy.e2e.test.ts b/packages/cli/test/scaffold-emission-policy.e2e.test.ts index d38b0b18b4..647975974a 100644 --- a/packages/cli/test/scaffold-emission-policy.e2e.test.ts +++ b/packages/cli/test/scaffold-emission-policy.e2e.test.ts @@ -43,10 +43,22 @@ * 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 @@ -54,7 +66,7 @@ * 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'; @@ -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, @@ -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. @@ -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 | 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; + }, 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 }> { + 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).map(([n]) => n)).toContain( + 'typescript', + ); + expect((emitted?.engines as Record | undefined)?.pnpm).toBeTypeOf('string'); + }); + + it('declares exactly one range per third-party dependency, across ALL THREE scaffolders', () => { + const byName = new Map>(); + for (const { id, manifest } of allSixManifests()) { + for (const [name, range] of [ + ...thirdPartyOnly(manifest.dependencies as Record), + ...thirdPartyOnly(manifest.devDependencies as Record), + ]) { + const ranges = byName.get(name) ?? new Map(); + 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 | undefined)?.typescript + ?? (manifest.dependencies as Record | 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 | 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; + engines?: Record; + }; + expect(committed.devDependencies?.typescript).toBe( + (emitted?.devDependencies as Record | undefined)?.typescript, + ); + expect(committed.engines?.pnpm).toBe((emitted?.engines as Record | undefined)?.pnpm); + }); +}); diff --git a/packages/create-objectstack/package.json b/packages/create-objectstack/package.json index fc54248249..6e80297886 100644 --- a/packages/create-objectstack/package.json +++ b/packages/create-objectstack/package.json @@ -12,7 +12,8 @@ } }, "scripts": { - "build": "tsup && node ../../scripts/check-dts-emitted.mjs", + "build": "pnpm gen:emission-policy && tsup && node ../../scripts/check-dts-emitted.mjs", + "gen:emission-policy": "node ../../scripts/sync-scaffold-emission-policy.mjs", "typecheck": "tsc --noEmit", "dev": "tsup --watch", "test": "vitest run" diff --git a/packages/create-objectstack/src/templates/blank/package.json b/packages/create-objectstack/src/templates/blank/package.json index 125997e869..48dca9cd5c 100644 --- a/packages/create-objectstack/src/templates/blank/package.json +++ b/packages/create-objectstack/src/templates/blank/package.json @@ -24,6 +24,6 @@ }, "devDependencies": { "@objectstack/cli": "^17.0.0", - "typescript": "^6.0.0" + "typescript": "^5.3.0" } } diff --git a/scripts/cross-package-test-inputs.mjs b/scripts/cross-package-test-inputs.mjs index 8dd4920315..e0d8ce4066 100644 --- a/scripts/cross-package-test-inputs.mjs +++ b/scripts/cross-package-test-inputs.mjs @@ -532,6 +532,18 @@ 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 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 diff --git a/scripts/sync-scaffold-emission-policy.mjs b/scripts/sync-scaffold-emission-policy.mjs new file mode 100644 index 0000000000..b433769300 --- /dev/null +++ b/scripts/sync-scaffold-emission-policy.mjs @@ -0,0 +1,574 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. +// +// Generate `create-objectstack`'s bundled-template version pins FROM the shared +// scaffold emission policy, and refuse a tree where the two have drifted. +// +// node scripts/sync-scaffold-emission-policy.mjs # write +// node scripts/sync-scaffold-emission-policy.mjs --check # verdict only, exit 1 on drift +// node scripts/sync-scaffold-emission-policy.mjs --self-test +// +// ## The defect, and why a hand edit does not close it +// +// Three scaffolders emit a `package.json` for a new project: `os init`, `os +// create` and `npx create-objectstack`. The first two IMPORT the emission +// policy (`SCAFFOLD_*` in `packages/cli/src/commands/init.ts`); the third +// restated it, in a committed template file, and the restatement decayed — +// `typescript` sat at `^6.0.0` there while the policy said `^5.3.0`, so two +// projects created the same day got different TypeScript MAJORS depending on +// which documented entry point the reader followed. +// +// The structural cause is not carelessness: `create-objectstack` CANNOT import +// from `@objectstack/cli`. The dependency edge runs the other way (the CLI +// depends on this package for its `created-summary` renderer), so a reverse +// import is a cycle — and the package publishes as a two-dependency `npx` +// entry point that must not pull the CLI's ~50-package closure. Its emission is +// a committed template copied byte-for-byte, with no renderer to route through +// a constant. +// +// ⛔ So editing `^6.0.0` to `^5.3.0` by hand does NOT close this. The two +// values would agree today and diverge again the next time the policy moves, +// silently, for the same structural reason and with nothing red. What closes it +// is the repo's usual generated-file pattern: the values are GENERATED into the +// committed template at build time (`create-objectstack`'s `build` runs this +// script, the way `packages/spec`'s runs `gen:schema`), and `--check` is a gate +// that reddens the moment the inlined values disagree with the source. +// +// ## Which range survived, and the measurement behind it +// +// `^5.3.0`, the shared policy's value. The repo's OWN devDependency is +// `typescript@^6.0.3` in every workspace package — which is exactly the reading +// that could have made `^6.0.0` the right value and the policy the stale one. +// It does not, and the two facts are stated TOGETHER in one sentence on a live +// doc page (`content/docs/getting-started/index.mdx`): "ObjectStack works with +// TypeScript 5.3+, but the project itself is built and tested against +// TypeScript 6.x". The floor a scaffolded project DECLARES is a support +// promise to its user; the version this monorepo builds itself with is not that +// promise. `content/docs/deployment/troubleshooting.mdx` states the same floor +// again, and `init.ts` records the type-check measurement behind it (5.3.3 +// checks every emitted shape with results identical to 6.0.3). +// +// ## Two failure contracts, both deliberately LOUD +// +// The sibling rewriter `sync-template-versions.mjs` carries the lesson this one +// is built on: its failure mode was loud for the keys it covered and MUTE for +// the key it did not, and `specVersion` drifted eleven majors inside that mute +// spot. So here: +// +// * a policy constant that cannot be read out of the source is a THROW, never +// a skipped stamp — a renamed or deleted `SCAFFOLD_*` export must red, not +// quietly stop being enforced; +// * a template `package.json` that does not DECLARE a stamped key is a hard +// failure naming the path, never a template silently exempted. A new +// template that genuinely should not declare `typescript` is a row to +// reconsider in `POLICY_STAMPS`, not a file to skip. +// +// The template set is DISCOVERED by walking `src/templates/` — reused from +// `sync-template-versions.mjs` rather than restated, for the reason its header +// gives: a hand-kept list is what let the sibling drift. + +import { readFileSync, writeFileSync, mkdirSync, mkdtempSync, rmSync, cpSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { dirname, join, relative, sep } from 'node:path'; +import { isEntrypoint } from './invoked-as.mjs'; +import { TEMPLATE_DIR, TEMPLATE_PKG_FILE, findTemplateDirs } from './sync-template-versions.mjs'; + +/** The repo this script lives in — resolved from the script, so cwd cannot lie. */ +const root = dirname(dirname(fileURLToPath(import.meta.url))); + +/** + * The single source of the emission policy, repo-relative. + * + * `os init` declares these constants and `os create` imports them, so this file + * is already the one definition for two of the three scaffolders; this script + * is what extends it to the third across the package boundary that forbids an + * import. + */ +export const POLICY_SOURCE = 'packages/cli/src/commands/init.ts'; + +/** + * The stamps, as a table over (template JSON block, field, policy constant). + * + * Table-driven so that adding a fourth generated surface is a row and every row + * shares ONE failure contract — the shape whose absence let the sibling + * rewriter go mute on the one key it did not cover. + * + * Only the values that are POLICY are here. `@objectstack/*` ranges are stamped + * from the scaffolder's own version by `sync-template-versions.mjs` and are not + * this script's business; the two tables are disjoint by construction, which + * `--self-test` pins. + */ +export const POLICY_STAMPS = [ + { + key: 'engines.pnpm', + block: 'engines', + field: 'pnpm', + constant: 'SCAFFOLD_PNPM_RANGE', + // The minimum pnpm measured to honour the emitted `pnpm-workspace.yaml`'s + // build allowlist. `os init` writes it from the same constant. + }, + { + key: 'devDependencies.typescript', + block: 'devDependencies', + field: 'typescript', + constant: 'SCAFFOLD_TYPESCRIPT_RANGE', + // The floor the docs already promise. This is the value that split three + // ways across three scaffolders (#16485). + }, +]; + +/** Repo-relative, always POSIX-separated: these paths are git pathspecs downstream. */ +const rel = (p) => relative(root, p).split(sep).join('/'); + +/** + * Read one `export const = '';` out of the policy source. + * + * Exactly one declaration must match. Zero means the constant was renamed or + * deleted and this script would otherwise stamp nothing while exiting 0; more + * than one means the source no longer has a single answer to give. Both THROW — + * this module is importable, and a library call that exits the host process is + * not a usable declaration surface (`main()` turns the throw into the exit). + */ +export function readPolicyConstant(source, name, { label = POLICY_SOURCE } = {}) { + const re = new RegExp(`^export const ${name}\\s*=\\s*'([^']*)';`, 'gm'); + const matches = [...source.matchAll(re)]; + if (matches.length === 0) { + throw new Error( + `sync-scaffold-emission-policy: ${label} declares no \`export const ${name} = '…';\`.\n` + + 'The emission policy is the SOURCE this script generates from — a renamed or deleted\n' + + 'constant must red here rather than silently stop being enforced. Either restore the\n' + + 'export or update the matching row in POLICY_STAMPS.', + ); + } + if (matches.length > 1) { + throw new Error( + `sync-scaffold-emission-policy: ${label} declares \`${name}\` ${matches.length} times; ` + + 'the policy source must have exactly one answer per constant.', + ); + } + return matches[0][1]; +} + +/** + * The whole policy, as `{ : }` over every declared stamp. + * + * @param {string} [file] absolute path to the policy source + */ +export function readEmissionPolicy(file = join(root, POLICY_SOURCE)) { + let source; + try { + source = readFileSync(file, 'utf8'); + } catch (err) { + throw new Error(`sync-scaffold-emission-policy: cannot read policy source ${rel(file)}: ${err.message}`); + } + const label = rel(file); + const policy = {}; + for (const stamp of POLICY_STAMPS) { + policy[stamp.constant] = readPolicyConstant(source, stamp.constant, { label }); + } + return policy; +} + +/** + * Every repo-relative path this script may write, across ALL template dirs. + * + * Zero templates THROWS rather than returning `[]`, for the same reason the run + * refuses a vacuous green: an empty set reads exactly like "nothing to stamp" + * and means "the directory moved". + * + * @param {{ root?: string }} [options] checkout to walk; defaults to this one + */ +export function stampedPolicyPaths({ root: base = root } = {}) { + const templates = findTemplateDirs(join(base, TEMPLATE_DIR)); + if (templates.length === 0) { + throw new Error( + `sync-scaffold-emission-policy: no template directories under ${TEMPLATE_DIR}. Every bundled\n` + + 'template carries the emission policy, so this is almost certainly a moved directory rather\n' + + 'than an empty one — refusing to report an empty stamped-path set.', + ); + } + return templates.map((template) => `${TEMPLATE_DIR}/${template}/${TEMPLATE_PKG_FILE}`).sort(); +} + +/** + * The `[start, end)` span of the object literal `"": { … }` in JSON + * text, with string contents skipped so a brace inside a value cannot end it. + */ +function findBlockSpan(text, blockKey, label) { + const opens = [...text.matchAll(new RegExp(`"${blockKey}"\\s*:\\s*\\{`, 'g'))]; + if (opens.length !== 1) { + throw new Error( + `sync-scaffold-emission-policy: ${label} declares the \`${blockKey}\` object ${opens.length} time(s); ` + + 'exactly one is required so a targeted rewrite cannot land in the wrong block.', + ); + } + const start = opens[0].index + opens[0][0].length - 1; + let depth = 0; + for (let i = start; i < text.length; i++) { + const ch = text[i]; + if (ch === '"') { + i++; + while (i < text.length && text[i] !== '"') i += text[i] === '\\' ? 2 : 1; + continue; + } + if (ch === '{') depth++; + else if (ch === '}' && --depth === 0) return { start, end: i + 1 }; + } + throw new Error(`sync-scaffold-emission-policy: ${label} has an unterminated \`${blockKey}\` object.`); +} + +/** + * Apply every stamp to one template `package.json`, as TEXT. + * + * Rewritten by targeted replacement rather than parse/re-serialize so a run + * touches the stamped values and nothing else — the same reason the sibling + * rewriter is text-based. The PARSE is still done, and its value is compared + * against the one the text pattern found: two independent reads that must + * agree, so a pattern that matched the wrong span cannot pass unnoticed. + * + * @returns {{ text: string, drift: Array<{ key: string, from: string, to: string }> }} + */ +export function stampPolicy(text, policy, { label = TEMPLATE_PKG_FILE } = {}) { + let parsed; + try { + parsed = JSON.parse(text); + } catch (err) { + throw new Error(`sync-scaffold-emission-policy: ${label} could not be read as JSON: ${err.message}`); + } + let out = text; + const drift = []; + for (const stamp of POLICY_STAMPS) { + const expected = policy[stamp.constant]; + if (typeof expected !== 'string' || expected === '') { + throw new Error(`sync-scaffold-emission-policy: no policy value for ${stamp.constant}.`); + } + const declared = parsed?.[stamp.block]?.[stamp.field]; + if (typeof declared !== 'string') { + throw new Error( + `sync-scaffold-emission-policy: ${label} declares no \`${stamp.key}\`.\n` + + 'Every bundled template carries the whole emission policy — a template that omits a\n' + + 'stamped key would be silently exempt from it, which is the mute-failure class this\n' + + 'script exists to close. Declare the key, or reconsider the POLICY_STAMPS row.', + ); + } + const span = findBlockSpan(out, stamp.block, label); + const block = out.slice(span.start, span.end); + const field = new RegExp(`("${stamp.field}"\\s*:\\s*)"([^"]*)"`, 'g'); + const hits = [...block.matchAll(field)]; + if (hits.length !== 1) { + throw new Error( + `sync-scaffold-emission-policy: ${label} matches \`${stamp.key}\` ${hits.length} time(s) as text ` + + 'while JSON.parse found it once — refusing to rewrite a span this script cannot locate exactly.', + ); + } + if (hits[0][2] !== declared) { + throw new Error( + `sync-scaffold-emission-policy: ${label} — the text match for \`${stamp.key}\` reads ` + + `"${hits[0][2]}" but JSON.parse reads "${declared}". The rewrite would land somewhere ` + + 'other than the value being checked.', + ); + } + if (declared === expected) continue; + drift.push({ key: stamp.key, from: declared, to: expected }); + out = + out.slice(0, span.start) + + block.replace(field, `$1"${expected}"`) + + out.slice(span.end); + } + return { text: out, drift }; +} + +// --------------------------------------------------------------------------- + +/** @param {{ check: boolean, base?: string }} options */ +export function run({ check, base = root }) { + const policy = readEmissionPolicy(join(base, POLICY_SOURCE)); + const paths = stampedPolicyPaths({ root: base }); + const drifted = []; + let clean = 0; + + for (const path of paths) { + const abs = join(base, path); + let text; + try { + text = readFileSync(abs, 'utf8'); + } catch (err) { + throw new Error(`sync-scaffold-emission-policy: cannot read ${path}: ${err.message}`); + } + const result = stampPolicy(text, policy, { label: path }); + if (result.drift.length === 0) { + clean++; + console.log(` ${path} already emits the shared policy`); + continue; + } + for (const d of result.drift) drifted.push({ path, ...d }); + if (!check) { + writeFileSync(abs, result.text); + for (const d of result.drift) console.log(` ${path}: ${d.key} ${d.from} -> ${d.to}`); + } + } + + const declared = Object.entries(policy) + .map(([name, value]) => `${name}=${value}`) + .join(', '); + + if (check && drifted.length > 0) { + console.error( + `\n✗ create-objectstack's bundled templates have drifted from the shared emission policy.\n` + + ` Policy source: ${POLICY_SOURCE} (${declared})\n`, + ); + for (const d of drifted) { + console.error(` ${d.path}: ${d.key} is "${d.from}" but the policy declares "${d.to}"`); + } + console.error( + '\n These values are GENERATED, not authored — ⛔ do not hand-edit them into agreement,\n' + + ' which is what let them diverge in the first place. Regenerate:\n' + + '\n pnpm gen:scaffold-emission-policy\n' + + '\n and commit the result. To change what a scaffolded project DECLARES, move the\n' + + ` constant in ${POLICY_SOURCE} — all three scaffolders follow it.\n`, + ); + return 1; + } + + console.log( + check + ? `✓ check:scaffold-emission-policy: ${paths.length} bundled template(s) emit the shared policy (${declared}).` + : `✓ sync-scaffold-emission-policy: ${paths.length} bundled template(s) in lockstep with ${POLICY_SOURCE} ` + + `(${declared}); ${clean} already clean, ${drifted.length} value(s) rewritten.`, + ); + return 0; +} + +function main() { + const check = process.argv.includes('--check'); + try { + process.exit(run({ check })); + } catch (err) { + console.error(`\n✗ ${err.message}\n`); + process.exit(1); + } +} + +// --------------------------------------------------------------------------- +// Self-test +// --------------------------------------------------------------------------- + +// Every section opens with `battery()` and every assertion is attributed +// to the battery most recently opened, so "the cases never ran" cannot print the +// same line as "every case held". The counts are a FLOOR — adding cases is +// ordinary work — and a battery BELOW its floor means cases stopped registering. +const SELF_TEST_BATTERIES = Object.freeze({ + 'A: a CLEAN corpus is REACHED, and left byte-identical and UNWRITTEN': 6, + 'B: DRIFT is rewritten, and --check reds on it first': 8, + 'C: a renamed policy constant is a hard failure, never a silent skip': 3, + 'D: a template omitting a stamped key exits 1 naming the path': 3, + 'E: an unparseable template package.json exits 1 naming it': 2, + 'F: zero templates refuses a vacuous green': 2, + 'G: the stamp table and the sibling rewriter do not both own a value': 2, +}); +const SELF_TEST_BATTERY_FLOOR = 7; +const UNATTRIBUTED_BATTERY = '(no battery open)'; +const SELF_TEST_VERDICT = 'sync-scaffold-emission-policy self-test reached its verdict'; + +const SELF_TEST_POLICY = { SCAFFOLD_PNPM_RANGE: '>=99.1', SCAFFOLD_TYPESCRIPT_RANGE: '^9.9.9' }; +const STALE = { SCAFFOLD_PNPM_RANGE: '>=1.0', SCAFFOLD_TYPESCRIPT_RANGE: '^1.0.0' }; + +function policySource(policy = SELF_TEST_POLICY) { + return [ + '// fixture policy source', + `export const SCAFFOLD_PNPM_RANGE = '${policy.SCAFFOLD_PNPM_RANGE}';`, + '', + `export const SCAFFOLD_TYPESCRIPT_RANGE = '${policy.SCAFFOLD_TYPESCRIPT_RANGE}';`, + '', + ].join('\n'); +} + +function templatePkg(policy) { + return `${JSON.stringify( + { + name: 'objectstack-fixture', + private: true, + engines: { pnpm: policy.SCAFFOLD_PNPM_RANGE }, + dependencies: { '@objectstack/spec': '^17.0.0' }, + devDependencies: { '@objectstack/cli': '^17.0.0', typescript: policy.SCAFFOLD_TYPESCRIPT_RANGE }, + }, + null, + 2, + )}\n`; +} + +function buildFixture(dir, { templates = ['blank', 'second'], policy = SELF_TEST_POLICY, templatePolicy = policy } = {}) { + const scripts = join(dir, 'scripts'); + mkdirSync(scripts, { recursive: true }); + for (const file of ['sync-scaffold-emission-policy.mjs', 'sync-template-versions.mjs', 'invoked-as.mjs']) { + cpSync(join(root, 'scripts', file), join(scripts, file)); + } + mkdirSync(join(dir, dirname(POLICY_SOURCE)), { recursive: true }); + writeFileSync(join(dir, POLICY_SOURCE), policySource(policy)); + for (const template of templates) { + mkdirSync(join(dir, TEMPLATE_DIR, template), { recursive: true }); + writeFileSync(join(dir, TEMPLATE_DIR, template, TEMPLATE_PKG_FILE), templatePkg(templatePolicy)); + } + return join(scripts, 'sync-scaffold-emission-policy.mjs'); +} + +function runFixture(script, args = []) { + const r = spawnSync(process.execPath, [script, ...args], { encoding: 'utf8' }); + return { status: r.status, output: `${r.stdout ?? ''}${r.stderr ?? ''}` }; +} + +function selfTest() { + const failures = []; + const opened = new Map(); + let battery = UNATTRIBUTED_BATTERY; + let checked = 0; + const open = (name) => { + battery = name; + if (!opened.has(name)) opened.set(name, 0); + }; + const ok = (cond, what) => { + checked++; + opened.set(battery, (opened.get(battery) ?? 0) + 1); + console.log(` ${cond ? '✓' : '✗'} [${battery}] ${what}`); + if (!cond) failures.push(`[${battery}] ${what}`); + }; + const sandbox = (fn, options) => { + const dir = mkdtempSync(join(tmpdir(), 'scaffold-policy-')); + try { + return fn(dir, buildFixture(dir, options)); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }; + const readTemplates = (dir, templates = ['blank', 'second']) => + templates.map((t) => readFileSync(join(dir, TEMPLATE_DIR, t, TEMPLATE_PKG_FILE), 'utf8')); + + open('A: a CLEAN corpus is REACHED, and left byte-identical and UNWRITTEN'); + sandbox((dir, script) => { + const before = readTemplates(dir); + const write = runFixture(script); + ok(write.status === 0, 'a clean corpus exits 0 on a write run'); + ok(write.output.includes('blank/package.json already emits'), 'the run REACHED blank and judged it clean'); + ok(write.output.includes('second/package.json already emits'), 'the run REACHED second — the walk is not a literal'); + ok(readTemplates(dir).every((t, i) => t === before[i]), 'a clean corpus is left BYTE-IDENTICAL'); + const check = runFixture(script, ['--check']); + ok(check.status === 0, '--check exits 0 on a clean corpus'); + ok(check.output.includes('>=99.1') && check.output.includes('^9.9.9'), 'the verdict names the policy it enforced'); + }); + + open('B: DRIFT is rewritten, and --check reds on it first'); + sandbox( + (dir, script) => { + const before = readTemplates(dir); + ok(before[0].includes('^1.0.0'), 'the fixture really is STALE — a clean one would prove nothing here'); + const check = runFixture(script, ['--check']); + ok(check.status === 1, '--check EXITS 1 on drift'); + ok(check.output.includes('devDependencies.typescript is "^1.0.0"'), '--check names the key and the found value'); + ok(check.output.includes('"^9.9.9"'), '--check names the value the policy declares'); + ok(check.output.includes('pnpm gen:scaffold-emission-policy'), '--check names the remedy'); + ok(readTemplates(dir).every((t, i) => t === before[i]), '--check WROTE NOTHING'); + const write = runFixture(script); + ok(write.status === 0, 'the write run exits 0'); + const after = readTemplates(dir); + ok( + after.every((t) => t.includes('"typescript": "^9.9.9"') && t.includes('"pnpm": ">=99.1"') && t.includes('"@objectstack/spec": "^17.0.0"')), + 'EVERY template now emits the policy, with the @objectstack/* ranges untouched', + ); + ok(runFixture(script, ['--check']).status === 0, '--check is green after the rewrite (idempotent)'); + }, + { templatePolicy: STALE }, + ); + + open('C: a renamed policy constant is a hard failure, never a silent skip'); + sandbox((dir, script) => { + writeFileSync( + join(dir, POLICY_SOURCE), + policySource().replace('SCAFFOLD_TYPESCRIPT_RANGE', 'SCAFFOLD_TS_RANGE_RENAMED'), + ); + const r = runFixture(script, ['--check']); + ok(r.status === 1, 'a renamed constant EXITS 1 rather than stamping the rest and passing'); + ok(r.output.includes('SCAFFOLD_TYPESCRIPT_RANGE'), 'the failure names the constant it could not read'); + ok(r.output.includes(POLICY_SOURCE), 'the failure names the policy source'); + }); + + open('D: a template omitting a stamped key exits 1 naming the path'); + sandbox((dir, script) => { + const pkg = JSON.parse(readFileSync(join(dir, TEMPLATE_DIR, 'second', TEMPLATE_PKG_FILE), 'utf8')); + delete pkg.devDependencies.typescript; + writeFileSync(join(dir, TEMPLATE_DIR, 'second', TEMPLATE_PKG_FILE), `${JSON.stringify(pkg, null, 2)}\n`); + const r = runFixture(script, ['--check']); + ok(r.status === 1, 'a template that omits a stamped key EXITS 1 — never a silent exemption'); + ok(r.output.includes('second/package.json'), 'the failure names the template path'); + ok(r.output.includes('devDependencies.typescript'), 'the failure names the missing key'); + }); + + open('E: an unparseable template package.json exits 1 naming it'); + sandbox((dir, script) => { + writeFileSync(join(dir, TEMPLATE_DIR, 'blank', TEMPLATE_PKG_FILE), '{ not json\n'); + const r = runFixture(script, ['--check']); + ok(r.status === 1, 'an unparseable template EXITS 1'); + ok(r.output.includes('blank/package.json') && r.output.includes('could not be read as JSON'), 'it names the file'); + }); + + open('F: zero templates refuses a vacuous green'); + sandbox( + (dir, script) => { + rmSync(join(dir, TEMPLATE_DIR, 'blank'), { recursive: true, force: true }); + const r = runFixture(script, ['--check']); + ok(r.status === 1, 'an empty templates directory EXITS 1 rather than reporting nothing to do'); + ok(r.output.includes(TEMPLATE_DIR), 'the refusal names the directory it walked'); + }, + { templates: ['blank'] }, + ); + + open('G: the stamp table and the sibling rewriter do not both own a value'); + ok(POLICY_STAMPS.length > 0, 'the stamp table is non-empty — an empty table would make every case above vacuous'); + ok( + POLICY_STAMPS.every((s) => !s.field.startsWith('@objectstack/')), + 'no row claims an @objectstack/* range — those belong to sync-template-versions.mjs', + ); + + const missing = Object.keys(SELF_TEST_BATTERIES).filter((n) => !opened.has(n)); + const extra = [...opened.keys()].filter((n) => !(n in SELF_TEST_BATTERIES)); + const below = [...opened].filter(([n, c]) => n in SELF_TEST_BATTERIES && c < SELF_TEST_BATTERIES[n]); + if (missing.length || extra.length || below.length || opened.size < SELF_TEST_BATTERY_FLOOR) { + console.error( + '\n✗ sync-scaffold-emission-policy self-test: the battery roster does not hold.\n' + + ` never opened: ${JSON.stringify(missing)}\n unattributed/unknown: ${JSON.stringify(extra)}\n` + + ` below floor: ${JSON.stringify(below)}\n batteries opened: ${opened.size} (floor ${SELF_TEST_BATTERY_FLOOR})\n` + + ' A battery at or below its floor means cases STOPPED RUNNING — the battery is the bug, not the number.\n', + ); + return 'roster failed'; + } + if (failures.length) { + console.error(`\n✗ sync-scaffold-emission-policy self-test: ${failures.length} failure(s)\n`); + for (const f of failures) console.error(` ${f}`); + return 'assertions failed'; + } + console.log( + `✓ sync-scaffold-emission-policy --self-test: ${checked} assertions over temp fixtures, running the real CLI. ` + + 'A CLEAN corpus is observed REACHED, byte-identical and UNWRITTEN; DRIFT is observed reddening --check ' + + 'BEFORE the rewrite and green after it; and a renamed policy constant, a template omitting a stamped key, ' + + 'an unparseable template and an empty templates directory are each observed exiting 1 and naming the path.', + ); + return SELF_TEST_VERDICT; +} + +// Entry-point guard: this file is importable, and an import that rewrote every +// bundled template as a side effect is strictly worse than a missing export. +if (isEntrypoint(import.meta.url)) { + if (process.argv.includes('--self-test')) { + if (selfTest() !== SELF_TEST_VERDICT) { + console.error( + '\n✗ sync-scaffold-emission-policy self-test: selfTest() returned without reaching its verdict,\n' + + 'so no success line was printed. Exiting 0 here would report a self-test that never\n' + + 'finished as a self-test that passed.\n', + ); + process.exit(1); + } + } else { + main(); + } +} diff --git a/turbo.json b/turbo.json index 415786b27e..da2faf01da 100644 --- a/turbo.json +++ b/turbo.json @@ -135,6 +135,8 @@ "$TURBO_ROOT$/packages/spec/src/system/translation.zod.ts", "$TURBO_ROOT$/scripts/check-cross-package-test-inputs.mjs", "$TURBO_ROOT$/packages/create-objectstack/src/templates/blank/pnpm-workspace.yaml", + "$TURBO_ROOT$/packages/create-objectstack/bin/create-objectstack.js", + "$TURBO_ROOT$/packages/create-objectstack/src/templates/blank/package.json", "$TURBO_ROOT$/packages/drivers/driver-sql/src/sql-driver.ts", "$TURBO_ROOT$/packages/drivers/driver-sql/src/schema-drift.ts", "$TURBO_ROOT$/packages/spec/src/data/field.zod.ts" From cb9f71bded69434e9e5098e84bfdd87ca0745910 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 14:53:57 +0000 Subject: [PATCH 2/2] fix(create-objectstack): declare the generator's inputs and its merge disposition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three gate findings from the first pass, all in the wiring rather than the mechanism: - `check:cross-package-test-inputs` — the emission-policy pin names `scripts/sync-scaffold-emission-policy.mjs`, so `@objectstack/cli` must declare it (with the on-ramp's `bin/` entry and bundled template) or a change to it would replay a cached green over the divergence the pin exists to catch. - `check:merge-driver` — every `gen:` name owes a recorded merge disposition. The bundled `package.json` is MIXED: the generator rewrites two values and reproduces none of the rest, so it is `NOT_DRIVER_MANAGED` with the reason. - The same gate counts generator NAMES, so `create-objectstack`'s build calls the script directly rather than declaring a second `gen:` alias for it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ --- packages/create-objectstack/package.json | 3 +-- scripts/cross-package-test-inputs.mjs | 6 ++++++ scripts/regen-artifacts.mjs | 15 +++++++++++++++ turbo.json | 1 + 4 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/create-objectstack/package.json b/packages/create-objectstack/package.json index 6e80297886..57c3951b1d 100644 --- a/packages/create-objectstack/package.json +++ b/packages/create-objectstack/package.json @@ -12,8 +12,7 @@ } }, "scripts": { - "build": "pnpm gen:emission-policy && tsup && node ../../scripts/check-dts-emitted.mjs", - "gen:emission-policy": "node ../../scripts/sync-scaffold-emission-policy.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" diff --git a/scripts/cross-package-test-inputs.mjs b/scripts/cross-package-test-inputs.mjs index e0d8ce4066..f883d62ab5 100644 --- a/scripts/cross-package-test-inputs.mjs +++ b/scripts/cross-package-test-inputs.mjs @@ -544,6 +544,12 @@ export const CROSS_PACKAGE_TEST_INPUTS = { // 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 diff --git a/scripts/regen-artifacts.mjs b/scripts/regen-artifacts.mjs index 8d27f52c2c..a4beb9c4cf 100644 --- a/scripts/regen-artifacts.mjs +++ b/scripts/regen-artifacts.mjs @@ -786,6 +786,21 @@ export const NOT_DRIVER_MANAGED = Object.freeze([ + 'block inside 267 lines of hand-written guide prose. Same generator, same deferral hazard, ' + 'same answer: guard the block with `check:skill-docs`, leave the prose to text-merge.', }, + { + path: 'packages/create-objectstack/src/templates/*/package.json', + gen: 'gen:scaffold-emission-policy', + owner: ROOT_OWNER, + why: + 'MIXED, and the generated part is TWO VALUES of it. `gen:scaffold-emission-policy` rewrites ' + + "only `engines.pnpm` and `devDependencies.typescript` from the CLI's shared `SCAFFOLD_*` " + + 'constants; everything else in the file — the scripts block, the dependency LIST, and the ' + + '`@objectstack/*` ranges a different pass (`scripts/sync-template-versions.mjs`) stamps at ' + + 'version time — is authored or owned elsewhere. So "discard both sides and re-run the ' + + 'generator" is not even defined here: the generator REFUSES a template that does not ' + + 'already declare the keys it stamps, and it would reproduce none of the rest. Guarding the ' + + 'two values with `check:scaffold-emission-policy` is the whole mechanism; a conflict in ' + + 'this file is a human\'s, exactly as it was before those two values were generated.', + }, { path: 'packages/spec/json-schema/**', gen: 'gen:openapi', diff --git a/turbo.json b/turbo.json index da2faf01da..11806e06b8 100644 --- a/turbo.json +++ b/turbo.json @@ -137,6 +137,7 @@ "$TURBO_ROOT$/packages/create-objectstack/src/templates/blank/pnpm-workspace.yaml", "$TURBO_ROOT$/packages/create-objectstack/bin/create-objectstack.js", "$TURBO_ROOT$/packages/create-objectstack/src/templates/blank/package.json", + "$TURBO_ROOT$/scripts/sync-scaffold-emission-policy.mjs", "$TURBO_ROOT$/packages/drivers/driver-sql/src/sql-driver.ts", "$TURBO_ROOT$/packages/drivers/driver-sql/src/schema-drift.ts", "$TURBO_ROOT$/packages/spec/src/data/field.zod.ts"