From 04b8218500f06bf30233cc7d00a60b6c3aa1d7e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 14:47:54 +0000 Subject: [PATCH 1/4] test(cli): drive the two new bootSchemaStack --json members in the stdout-purity family The family-roster pin in json-stdout-purity.e2e.test.ts discovered `migrate organization-ownership` and `migrate security-catalog-overlays` (both boot the shared stack and declare --json) while FAMILY drove neither. Each is now driven in its bare form: the ownership plan's refusal face over a database with no platform tables, and the overlay step's read-only preview. The roster check itself is unchanged. Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS Co-authored-by: Claude --- packages/cli/test/json-stdout-purity.e2e.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/cli/test/json-stdout-purity.e2e.test.ts b/packages/cli/test/json-stdout-purity.e2e.test.ts index cc932b0671f..698034ba9bc 100644 --- a/packages/cli/test/json-stdout-purity.e2e.test.ts +++ b/packages/cli/test/json-stdout-purity.e2e.test.ts @@ -100,9 +100,22 @@ const FAMILY: Record = { 'migrate files-to-references': [], 'migrate meta': ['--stored'], 'migrate multi-value-columns': [], + // Its bare form is its only mode — a read-only plan (`deferSchemaDdl` + + // `readOnlyProbe`). The fixture's database holds none of the platform tables + // the ceremony inventories, so the face driven here is its REFUSAL face + // (`plan_refused`, `emitJson(…, 1, { compact: true })`), emitted after the + // stack is shut down. A refused plan writes no plan file, so the fixture + // directory gains nothing. + 'migrate organization-ownership': [], 'migrate plan': [], 'migrate recorded-by': [], 'migrate resume': [], + // A preview is its DEFAULT and the only form driven here: without `--apply` + // it composes the deployment the way `os serve` does (the host config's + // plugins and the auth-gated security plugin) and boots read-only with no + // `sys_metadata` hydration, so it deletes nothing. No table exists in the + // fixture, so the face driven here is its empty listing. + 'migrate security-catalog-overlays': [], 'migrate summary-nulls': [], // One object per run, so it needs a name: the fixture's own `jp_ticket`, // which the composed host config registers. No database file exists, so the From bff640d98bbc87be7f4657efb972ed5bfcb97834 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 14:54:10 +0000 Subject: [PATCH 2/4] fix(create-objectstack): stamp the template's engines.protocol from PROTOCOL_VERSION's major sync-template-versions.mjs stamped engines.protocol from create-objectstack's own package major, though its header says the key carries the PROTOCOL major. In Changesets pre mode the protocol moves ahead of the version pass (packages 17.7.0 beside PROTOCOL_VERSION 18.0.0), so the blank template kept '^17' and a project scaffolded from main was refused at the protocol handshake on boot. - The stamp now reads the protocol major from PROTOCOL_VERSION's declaration (source text, the expression check-changeset-no-major.mjs reads it with); specVersion and the @objectstack/* ranges keep the package range. An unreadable declaration is a hard failure, never a fall-back. - The blank template is re-stamped by the script: '^18'. - template-consistency.test.ts judges engines.protocol against PROTOCOL_MAJOR (spec source, via the existing vitest alias), not the package major. - The self-test's fixtures carry a package major (42) and a protocol major (43) that differ; Control I and Control J pin the two sources and the hard failure. template-version-stamps.test.ts's fixture gains the same split. Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS Co-authored-by: Claude --- ...-engines-protocol-from-protocol-version.md | 14 + .../src/template-consistency.test.ts | 20 +- .../src/template-version-stamps.test.ts | 24 +- .../src/templates/blank/objectstack.config.ts | 2 +- scripts/sync-template-versions.mjs | 255 ++++++++++++++++-- 5 files changed, 279 insertions(+), 36 deletions(-) create mode 100644 .changeset/22622-scaffold-engines-protocol-from-protocol-version.md diff --git a/.changeset/22622-scaffold-engines-protocol-from-protocol-version.md b/.changeset/22622-scaffold-engines-protocol-from-protocol-version.md new file mode 100644 index 00000000000..c06a282045c --- /dev/null +++ b/.changeset/22622-scaffold-engines-protocol-from-protocol-version.md @@ -0,0 +1,14 @@ +--- +"create-objectstack": patch +--- + +fix(create-objectstack): the blank template's `engines.protocol` is stamped from the protocol major, not the package major + +Clause-②: no + +`engines.protocol` in the scaffolded `objectstack.config.ts` is the range the runtime's protocol handshake checks before it loads the project. + +- The release stamp (`scripts/sync-template-versions.mjs`) used to write it from create-objectstack's own major. It now writes the major of `PROTOCOL_VERSION`, the value the handshake compares against. The two differ while the protocol moves ahead of a release: in that window the unreleased template declared `'^17'` beside a protocol-18 runtime, which refused to load the project it scaffolded. +- The template now declares `'^18'`, written by that stamp rather than by hand. +- Unchanged: `specVersion` and the `@objectstack/*` dependency ranges still carry the package range. +- No released version shipped the mismatch. A project you scaffolded earlier keeps its own range; to move it to protocol 18, run `objectstack migrate meta --from 17 --write`, which rewrites `engines.protocol` with the rest of the step. diff --git a/packages/create-objectstack/src/template-consistency.test.ts b/packages/create-objectstack/src/template-consistency.test.ts index d32c819cf82..319ffb02290 100644 --- a/packages/create-objectstack/src/template-consistency.test.ts +++ b/packages/create-objectstack/src/template-consistency.test.ts @@ -16,6 +16,7 @@ import { copyDir, TEMPLATE_FILE_ALIASES } from './template-copy.js'; import { TEMPLATES } from './template-registry.js'; import { gitFreeEnv } from '../../../scripts/git-env.mjs'; import { SKILLS_CATALOG, SKILLS_INSTALL_COMMAND } from './skills-install.js'; +import { PROTOCOL_MAJOR, PROTOCOL_VERSION } from '@objectstack/spec/kernel'; const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const repoRoot = path.resolve(pkgRoot, '..', '..'); @@ -191,7 +192,17 @@ describe('bundled template declared version surfaces', () => { // `objectstack.manifest.json` — the two were conflated in this suite's own // naming and in the sync script's log strings, and that conflation is part // of how the sibling key below went unwatched for eleven majors. - it("objectstack.config.ts stamps engines.protocol at the scaffolder's major (ADR-0087 D1)", () => { + // + // NOTE the value: the PROTOCOL major, judged against `PROTOCOL_VERSION` — + // the constant the runtime's ADR-0087 D1 handshake compares a scaffolded + // project's range to — and NOT against this package's own major. The two + // differ for the whole pre-mode window in which the protocol moves ahead of + // the version pass (packages at 17.x beside protocol 18): judged against the + // package major, a template the runtime refuses at boot stayed green here. + // `@objectstack/spec/kernel` resolves to the spec's SOURCE through this + // package's vitest alias, so the verdict needs no build — it runs on the + // release path's post-version tree, where nothing has been built. + it('objectstack.config.ts stamps an engines.protocol that PROTOCOL_VERSION satisfies (ADR-0087 D1)', () => { const config = readTemplateFile('objectstack.config.ts'); const match = /engines:\s*\{\s*protocol:\s*'\^(\d+)'\s*\}/.exec(config); expect( @@ -200,9 +211,10 @@ describe('bundled template declared version surfaces', () => { ).not.toBeNull(); expect( Number(match![1]), - `${template} stamps engines.protocol '^${match![1]}' but create-objectstack is v${ownMajor} — ` + - 'scripts/sync-template-versions.mjs re-stamps this at version time; keep them in lockstep', - ).toBe(ownMajor); + `${template} stamps engines.protocol '^${match![1]}', which PROTOCOL_VERSION ${PROTOCOL_VERSION} does not ` + + 'satisfy: every project it scaffolds is refused at the protocol handshake by the runtime it ships ' + + 'with. Run `node scripts/sync-template-versions.mjs` — it stamps this key from PROTOCOL_VERSION\'s major.', + ).toBe(PROTOCOL_MAJOR); }); // The key #9264 is about. Required by TemplateManifestSchema, read by the diff --git a/packages/create-objectstack/src/template-version-stamps.test.ts b/packages/create-objectstack/src/template-version-stamps.test.ts index 48ba146ed36..fe05f9af64c 100644 --- a/packages/create-objectstack/src/template-version-stamps.test.ts +++ b/packages/create-objectstack/src/template-version-stamps.test.ts @@ -25,7 +25,10 @@ // re-found the day a second template ships. // // The same fixture is deliberately built STALE (pinning ^17 while its -// scaffolder reads 42.0.0). That makes the import-safety assertion non-vacuous +// scaffolder reads 42.0.0 and its PROTOCOL_VERSION reads 43.0.0 — two majors, +// because `engines.protocol` is stamped from the protocol constant and every +// other surface from the package, and with the two equal a stamp read from the +// wrong source would pass). That makes the import-safety assertion non-vacuous // in the one way that matters: an unguarded module imported against a stale // tree REWRITES it, so "the files are byte-identical after import" is evidence // only when there was something for a rewrite to do. Byte-identity over an @@ -62,6 +65,7 @@ type SyncModule = { findTemplateDirs: (templateRoot?: string) => string[]; stampedPaths: (options?: { root?: string }) => string[]; loadScaffolderVersion: (file?: string) => { version: string; major: string; range: string }; + loadProtocolMajor: (file?: string) => { source: string; protocolMajor: string }; }; const loadSync = async (file = SYNC_SCRIPT): Promise => @@ -75,6 +79,11 @@ let fixtureScript: string; /** Deliberately not the live version, so a stamp that ran is unmistakable. */ const FIXTURE_VERSION = '42.0.0'; +/** + * The fixture's PROTOCOL major — one ahead of its package major, the shape the + * pre-mode window leaves in the real tree. `engines.protocol` must move to THIS. + */ +const FIXTURE_PROTOCOL_MAJOR = '43'; /** Deliberately stale: every fixture surface pins this and must move to 42. */ const STALE_MAJOR = '17'; @@ -140,6 +149,11 @@ beforeAll(() => { path.join(fixture, 'packages', 'create-objectstack', 'package.json'), JSON.stringify({ name: 'create-objectstack', version: FIXTURE_VERSION }, null, 2) + '\n', ); + // The protocol major's one source, in the shape the real constant spells it. + writeFixtureFile( + path.join(fixture, 'packages', 'spec', 'src', 'kernel', 'protocol-version.ts'), + `export const PROTOCOL_VERSION = '${FIXTURE_PROTOCOL_MAJOR}.0.0';\n`, + ); for (const template of FIXTURE_TEMPLATES) { const dir = fixtureTemplateDir(template); @@ -215,6 +229,7 @@ describe('sync-template-versions.mjs is import-safe', () => { expect(typeof sync.stampedPaths).toBe('function'); expect(typeof sync.findTemplateDirs).toBe('function'); expect(typeof sync.loadScaffolderVersion).toBe('function'); + expect(typeof sync.loadProtocolMajor).toBe('function'); expect(Array.isArray(sync.TEXT_STAMPS)).toBe(true); expect(sync.TEXT_STAMPS.length).toBeGreaterThan(0); expect(sync.TEMPLATE_DIR).toBe('packages/create-objectstack/src/templates'); @@ -249,9 +264,10 @@ describe('the entry-point guard leaves the CLI path working', () => { 'a non-@objectstack dependency is never touched', ).toBe('^6.0.0'); - expect(fs.readFileSync(path.join(dir, 'objectstack.config.ts'), 'utf8')).toContain( - "engines: { protocol: '^42' }", - ); + expect( + fs.readFileSync(path.join(dir, 'objectstack.config.ts'), 'utf8'), + `${template}/objectstack.config.ts engines.protocol moves to the PROTOCOL major, not the package major`, + ).toContain(`engines: { protocol: '^${FIXTURE_PROTOCOL_MAJOR}' }`); const manifest = fs.readFileSync(path.join(dir, 'objectstack.manifest.json'), 'utf8'); expect(manifest).toContain('"specVersion": "^42.0.0"'); diff --git a/packages/create-objectstack/src/templates/blank/objectstack.config.ts b/packages/create-objectstack/src/templates/blank/objectstack.config.ts index 6308f29eeec..b5dcfda7cdd 100644 --- a/packages/create-objectstack/src/templates/blank/objectstack.config.ts +++ b/packages/create-objectstack/src/templates/blank/objectstack.config.ts @@ -36,7 +36,7 @@ export default defineStack({ // match the ObjectStack version you installed — change it when you // deliberately move to a new protocol major, not to silence a mismatch. // Guide: https://objectstack.ai/docs/upgrading - engines: { protocol: '^17' }, + engines: { protocol: '^18' }, }, // `automation` backs flow execution and materializes any declarative diff --git a/scripts/sync-template-versions.mjs b/scripts/sync-template-versions.mjs index f7704db8f4b..f7e100de74d 100644 --- a/scripts/sync-template-versions.mjs +++ b/scripts/sync-template-versions.mjs @@ -35,15 +35,47 @@ // skip. A silent skip is indistinguishable from a green run, which is // precisely the invisibility this card was filed about. // -// TWO VALUES, TWO MEANINGS — do not collapse them. `engines.protocol` is the -// ADR-0087 D1 runtime handshake range and carries the PROTOCOL major (`^17`). -// `specVersion` is documented by TemplateManifestSchema as the "Compatible -// @objectstack/spec semver range" and carries the PACKAGE range (`^17.0.0`) — -// the same value this script writes into the template's own -// `@objectstack/spec` dependency, so the manifest and the package.json state -// one fact once. They agree on the major today only because the spec package's -// major and the protocol major are kept in lockstep; they are still two -// different declarations and are stamped from two different values. +// TWO VALUES, TWO MEANINGS, TWO SOURCES — do not collapse them. +// `engines.protocol` is the ADR-0087 D1 runtime handshake range and carries the +// PROTOCOL major (`^18`), read from the one place that major is declared: +// `PROTOCOL_VERSION` in `packages/spec/src/kernel/protocol-version.ts`, the +// constant the runtime's handshake compares against. `specVersion` is +// documented by TemplateManifestSchema as the "Compatible @objectstack/spec +// semver range" and carries the PACKAGE range (`^17.0.0`), read from this +// scaffolder's own version — the same value this script writes into the +// template's own `@objectstack/spec` dependency, so the manifest and the +// package.json state one fact once. +// +// The two majors are NOT always equal, which is why the protocol major is read +// from the constant rather than inferred from the package. The constant is held +// to the spec package's major with one exception (ruling record 6049734955, +// Q1 -> B): in Changesets pre mode with a pending `major`, the protocol moves in +// a reviewed pull request ahead of the version pass. For that whole window the +// tree carries packages at `17.x` beside `PROTOCOL_VERSION = '18.0.0'`, and a +// template stamped from the PACKAGE major declares `^17` — a range the runtime +// it is booted against refuses at the handshake. Read from the constant, the +// stamp cannot lag the protocol it declares compatibility with. +// +// Ordering, on the release path: the root `version` script runs +// `sync-protocol-version.mjs` BEFORE this script, so outside that exception the +// constant has already followed the spec package's new major by the time this +// reads it. The two stamps then agree on the major again — from two sources. +// +// The constant is read from SOURCE TEXT, never a build: this script runs under +// plain `node` on the release path before any build exists, and a `.ts` import +// needs type stripping the declared `engines.node` floor (>=22.0.0) does not +// guarantee. It is read HERE, with the expression `check-changeset-no-major.mjs` +// reads the same declaration with (`protocolMajorIn()`: line-anchored, either +// quote, so a commented-out declaration declares nothing), and NOT imported +// from there, because this module is not a leaf: `sync-scaffold-emission-policy.mjs` +// imports it inside create-objectstack's BUILD, and `template-version-stamps.test.ts` +// copies its import closure into a fixture inside that package's TEST. An +// import would pull a 4,600-line changeset gate and its siblings into both, +// leaving the turbo inputs and the cross-package declaration that name this +// closure (`scripts/cross-package-test-inputs.mjs`: "the stamper's own import +// closure") short. The second reader cannot drift silently: a declaration it +// stops matching is a hard failure naming the file (`loadProtocolMajor()`), and +// the self-test's Control J pins that. // // ## THE DECLARATIONS ARE EXPORTED, AND NOTHING RUNS ON IMPORT (#9554) // @@ -70,8 +102,9 @@ // ALL template dirs. This is what a consumer wants; deriving it here is // what keeps consumers from re-implementing the walk and the join. // * `TEXT_STAMPS`, `TEMPLATE_DIR`, `TEMPLATE_ROOT`, `findTemplateDirs()`, -// `VERSION_SOURCE`, `loadScaffolderVersion()` — the raw declarations, for -// a consumer that needs the keys and patterns rather than the paths. +// `VERSION_SOURCE`, `loadScaffolderVersion()`, `loadProtocolMajor()` — the +// raw declarations, for a consumer that needs the keys and patterns rather +// than the paths. // * nothing executes, reads a file, or exits the process at import. The // version read used to sit at module scope and `process.exit(1)` on an // unparseable version — an import that can kill its host process is a @@ -125,11 +158,13 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'Control E: a stamp FILE that does not exist exits 1 naming it': 2, 'Control F: an unparseable template package.json exits 1 naming it': 2, 'Control H: zero templates refuses a vacuous green': 3, + 'Control I: engines.protocol is stamped from PROTOCOL_VERSION, never from the package major': 5, + 'Control J: an unreadable PROTOCOL_VERSION exits 1 naming its source, and stamps nothing': 6, }); // DELETING an entry silences that battery's floor exactly as effectively as // zeroing it, so the roster's own size is pinned too. -const SELF_TEST_BATTERY_FLOOR = 8; +const SELF_TEST_BATTERY_FLOOR = 10; // The key an assertion is filed under when no battery is open. It is not a // declared battery, so it reds by the same set difference rather than silently @@ -142,6 +177,21 @@ const root = dirname(dirname(fileURLToPath(import.meta.url))); /** Where the scaffolder's own version is read from. Repo-relative. */ export const VERSION_SOURCE = 'packages/create-objectstack/package.json'; +/** + * Where the protocol major `engines.protocol` carries is read from: the + * declaration of `PROTOCOL_VERSION`, the constant the runtime's ADR-0087 D1 + * handshake compares against. Repo-relative. + */ +export const PROTOCOL_VERSION_SOURCE = 'packages/spec/src/kernel/protocol-version.ts'; + +/** + * `PROTOCOL_VERSION`'s declaration, as `check-changeset-no-major.mjs`'s + * `protocolMajorIn()` reads it — the same expression, so the stamper and the + * protocol-lockstep gate agree on what counts as the declaration (see "TWO + * SOURCES" in the header for why it is not imported). + */ +const PROTOCOL_DECLARATION = /^export const PROTOCOL_VERSION = (['"])(\d+)\.\d+\.\d+\1;/m; + /** * Where the bundled templates live, repo-relative. A DIRECTORY, walked, never a * hand-kept list — see the header. Mirrors `check-template-manifests.ts`. @@ -179,10 +229,12 @@ export const TEXT_STAMPS = [ key: 'engines.protocol', // ADR-0087 D1 — the runtime refuses an incompatible package at the boundary // with the exact migration command. Scaffolds populate it by default; this - // is the ratchet that closes grandfathering. Carries the PROTOCOL major. + // is the ratchet that closes grandfathering. Carries the PROTOCOL major, + // read from `PROTOCOL_VERSION` (`loadProtocolMajor()`), never the package + // major — see "TWO SOURCES" in the header. pattern: /engines:\s*\{\s*protocol:\s*'[^']*'\s*\}/, - value: ({ major }) => `^${major}`, - replacement: ({ major }) => `engines: { protocol: '^${major}' }`, + value: ({ protocolMajor }) => `^${protocolMajor}`, + replacement: ({ protocolMajor }) => `engines: { protocol: '^${protocolMajor}' }`, }, { file: 'objectstack.manifest.json', @@ -271,17 +323,53 @@ export function loadScaffolderVersion(file = join(root, VERSION_SOURCE)) { }; } +/** + * The PROTOCOL major `engines.protocol` is stamped from: `PROTOCOL_VERSION`'s + * major, read from its declaration's source text (see "TWO SOURCES" in the + * header for why the source and not a build, and why this reader). + * + * THROWS when the file cannot be read or carries no declaration, for the same + * reason `loadScaffolderVersion()` does — and never falls back to the package + * major: that fallback is the very reading that stamped `^17` beside a protocol + * of 18, and a stamp from it would be indistinguishable from a correct one. + * + * @param {string} [file] absolute path to `protocol-version.ts` + * @returns {{ source: string, protocolMajor: string }} + */ +export function loadProtocolMajor(file = join(root, PROTOCOL_VERSION_SOURCE)) { + let text; + try { + text = readFileSync(file, 'utf8'); + } catch (err) { + throw new Error(`cannot read PROTOCOL_VERSION: ${rel(file)} could not be read (${err.message})`); + } + const m = PROTOCOL_DECLARATION.exec(text); + const protocolMajor = m ? Number.parseInt(m[2], 10) : null; + if (!Number.isInteger(protocolMajor) || protocolMajor < 1) { + throw new Error( + `cannot read PROTOCOL_VERSION: ${rel(file)} carries no \`export const PROTOCOL_VERSION = 'N.x.y';\` ` + + 'declaration, so the protocol major engines.protocol carries is unknown', + ); + } + return { source: rel(file), protocolMajor: String(protocolMajor) }; +} + // --------------------------------------------------------------------------- function main() { let scaffolder; + let protocol; try { scaffolder = loadScaffolderVersion(); + protocol = loadProtocolMajor(); } catch (err) { console.error(`✗ sync-template-versions: ${err.message}`); process.exit(1); } const { version, major, range } = scaffolder; + const { protocolMajor } = protocol; + /** Everything a stamp may be a function of — the two sources, side by side. */ + const values = { version, major, range, protocolMajor }; const templates = findTemplateDirs(); @@ -355,7 +443,7 @@ function main() { // ── the text stamps ───────────────────────────────────────────────────── for (const stamp of TEXT_STAMPS) { const path = join(TEMPLATE_ROOT, template, stamp.file); - const value = stamp.value({ version, major, range }); + const value = stamp.value(values); let src; try { src = readFileSync(path, 'utf8'); @@ -379,7 +467,7 @@ function main() { continue; } - const stamped = src.replace(stamp.pattern, stamp.replacement({ version, major, range })); + const stamped = src.replace(stamp.pattern, stamp.replacement(values)); if (stamped === src) { console.log(`✓ ${template}/${stamp.file} already stamps ${stamp.key} '${value}'`); } else { @@ -402,7 +490,8 @@ function main() { console.log( `\n✓ sync-template-versions: ${templates.length} template(s) in lockstep with ` + - `create-objectstack@${version} — deps and specVersion at ${range}, engines.protocol at '^${major}'.`, + `create-objectstack@${version} — deps and specVersion at ${range}; engines.protocol at ` + + `'^${protocolMajor}', the protocol major of PROTOCOL_VERSION (${protocol.source}).`, ); } @@ -496,6 +585,22 @@ function main() { /** The scaffolder version every fixture declares — never a live one, so a stamp that ran is unmistakable. */ const SELF_TEST_VERSION = '42.0.0'; +/** `SELF_TEST_VERSION`'s major: the PACKAGE major, the source of every `@objectstack/*` range and `specVersion`. */ +const SELF_TEST_PACKAGE_MAJOR = '42'; + +/** + * The PROTOCOL major every fixture's `PROTOCOL_VERSION` declares — deliberately + * NOT the package major, and one ahead of it, the shape the pre-mode exception + * leaves in the real tree (packages at 17.x, protocol 18). With the two equal, + * a stamp read from the wrong source would be byte-identical to a correct one, + * and every case below would pass either way. + */ +const SELF_TEST_PROTOCOL_MAJOR = '43'; + +/** The declaration a fixture's `protocol-version.ts` carries, in the shape the real file spells it. */ +const protocolDeclaration = (protocolMajor) => + `/** fixture */\nexport const PROTOCOL_VERSION = '${protocolMajor}.0.0';\n`; + /** * Fixture file bodies, keyed by `TEXT_STAMPS[].key` rather than by file name. * @@ -503,11 +608,17 @@ const SELF_TEST_VERSION = '42.0.0'; * stamp row added to `TEXT_STAMPS` with no body here fails the self-test * loudly, instead of silently sitting outside every fixture — which is the * one-key-one-file blind spot (#9264) reappearing in the test harness. + * + * Each body names its OWN source by hand — `engines.protocol` the protocol + * major, `specVersion` the package major — rather than calling + * `stamp.value()`: a fixture built from the script's own reading would agree + * with a script that read the wrong source, and Control A would stay green. */ const SELF_TEST_BODIES = { - 'engines.protocol': (major) => - `export default defineStack({ manifest: { engines: { protocol: '^${major}' } } });\n`, - specVersion: (major) => `{\n "specVersion": "^${major}.0.0",\n "scaffold": { "variables": [] }\n}\n`, + 'engines.protocol': ({ protocolMajor }) => + `export default defineStack({ manifest: { engines: { protocol: '^${protocolMajor}' } } });\n`, + specVersion: ({ packageMajor }) => + `{\n "specVersion": "^${packageMajor}.0.0",\n "scaffold": { "variables": [] }\n}\n`, }; function writeFixtureFile(file, contents) { @@ -516,10 +627,20 @@ function writeFixtureFile(file, contents) { } /** - * A throwaway checkout shaped like this repo, IN LOCKSTEP unless `major` says - * otherwise. Returns the path of the copied script to run. + * A throwaway checkout shaped like this repo, IN LOCKSTEP unless an option says + * otherwise — its PACKAGE major and its PROTOCOL major deliberately different. + * `protocolSource` replaces the fixture's `protocol-version.ts` text; `null` + * leaves the file out. Returns the path of the copied script to run. */ -function buildFixture(dir, { templates = ['blank', 'second'], major = '42' } = {}) { +function buildFixture( + dir, + { + templates = ['blank', 'second'], + packageMajor = SELF_TEST_PACKAGE_MAJOR, + protocolMajor = SELF_TEST_PROTOCOL_MAJOR, + protocolSource = protocolDeclaration(SELF_TEST_PROTOCOL_MAJOR), + } = {}, +) { const script = join(dir, 'scripts', 'sync-template-versions.mjs'); writeFixtureFile(script, readFileSync(fileURLToPath(import.meta.url), 'utf8')); // The entry guard is imported, not re-typed (`scripts/invoked-as.mjs`), so the @@ -533,6 +654,7 @@ function buildFixture(dir, { templates = ['blank', 'second'], major = '42' } = { join(dir, VERSION_SOURCE), JSON.stringify({ name: 'create-objectstack', version: SELF_TEST_VERSION }, null, 2) + '\n', ); + if (protocolSource !== null) writeFixtureFile(join(dir, PROTOCOL_VERSION_SOURCE), protocolSource); // Created even when `templates` is empty: the vacuous-green guard is about an // EMPTY templates directory, not a missing one. mkdirSync(join(dir, TEMPLATE_DIR), { recursive: true }); @@ -543,15 +665,15 @@ function buildFixture(dir, { templates = ['blank', 'second'], major = '42' } = { JSON.stringify( { name: `template-${template}`, - dependencies: { '@objectstack/spec': `^${major}.0.0`, chalk: '^6.0.0' }, - devDependencies: { '@objectstack/cli': `^${major}.0.0` }, + dependencies: { '@objectstack/spec': `^${packageMajor}.0.0`, chalk: '^6.0.0' }, + devDependencies: { '@objectstack/cli': `^${packageMajor}.0.0` }, }, null, 2, ) + '\n', ); for (const stamp of TEXT_STAMPS) { - writeFixtureFile(join(templateDir, stamp.file), SELF_TEST_BODIES[stamp.key](major)); + writeFixtureFile(join(templateDir, stamp.file), SELF_TEST_BODIES[stamp.key]({ packageMajor, protocolMajor })); } } return script; @@ -821,6 +943,83 @@ function selfTest() { `a run that stamped nothing must not print the success line\n${output}`, ); } + + // ── Control I: engines.protocol from PROTOCOL_VERSION, never the package ─ + // + // The two-sources contract, executed in the direction that matters: one + // template's `engines.protocol` carries the PACKAGE major — exactly what + // stamping it from create-objectstack's version used to write — while its + // manifest is already clean. The run must move the protocol key to the + // fixture's PROTOCOL major and leave `specVersion` on the PACKAGE range. + // Control A covers the other half: a corpus clean on both sources is + // judged clean, so a script that read the package major for this key would + // rewrite `^43` there and fail Control A instead. + battery('Control I: engines.protocol is stamped from PROTOCOL_VERSION, never from the package major'); + { + const dir = fixtureDir('two-sources'); + const script = buildFixture(dir); + const engines = TEXT_STAMPS.find((s) => s.key === 'engines.protocol'); + const spec = TEXT_STAMPS.find((s) => s.key === 'specVersion'); + const configPath = join(dir, TEMPLATE_DIR, 'blank', engines.file); + const manifestPath = join(dir, TEMPLATE_DIR, 'blank', spec.file); + writeFixtureFile(configPath, SELF_TEST_BODIES['engines.protocol']({ protocolMajor: SELF_TEST_PACKAGE_MAJOR })); + const manifestBefore = readFileSync(manifestPath, 'utf8'); + + assert( + SELF_TEST_PACKAGE_MAJOR !== SELF_TEST_PROTOCOL_MAJOR, + 'the fixture\'s package and protocol majors differ, so a stamp from the wrong source is observable', + ); + const { status, output } = runFixture(script); + assert(status === 0, `a template stamped from the package major is re-stamped, exit 0 — got ${status}\n${output}`); + assert( + readFileSync(configPath, 'utf8').includes(`engines: { protocol: '^${SELF_TEST_PROTOCOL_MAJOR}' }`), + `blank/${engines.file} now declares the PROTOCOL major ^${SELF_TEST_PROTOCOL_MAJOR}, not the package ` + + `major ^${SELF_TEST_PACKAGE_MAJOR}\n${output}`, + ); + assert( + readFileSync(manifestPath, 'utf8') === manifestBefore, + `blank/${spec.file} keeps specVersion on the PACKAGE range ^${SELF_TEST_PACKAGE_MAJOR}.0.0 — the protocol ` + + `major does not leak into it\n${output}`, + ); + assert( + output.includes(`'^${SELF_TEST_PROTOCOL_MAJOR}', the protocol major of PROTOCOL_VERSION (${PROTOCOL_VERSION_SOURCE})`), + `the success line names the protocol major's source, not only its value\n${output}`, + ); + } + + // ── Control J: an unreadable PROTOCOL_VERSION is a hard failure ───────── + // + // Missing and undeclared are both exit 1, naming the file, BEFORE anything + // is written. ⛔ Never a fall-back to the package major: that reading is the + // defect this source replaced, and a stamp from it is indistinguishable + // from a correct one. The fixtures are clean on both sources, so a + // fall-back run would have work to do (`^43` -> `^42`) and byte-identity + // below is not vacuous. + battery('Control J: an unreadable PROTOCOL_VERSION exits 1 naming its source, and stamps nothing'); + for (const [name, protocolSource, why] of [ + ['protocol-missing', null, 'a missing protocol-version.ts'], + [ + 'protocol-undeclared', + `// export const PROTOCOL_VERSION = '${SELF_TEST_PROTOCOL_MAJOR}.0.0';\n`, + 'a protocol-version.ts whose only declaration is commented out', + ], + ]) { + const dir = fixtureDir(name); + const script = buildFixture(dir, { protocolSource }); + const surfaces = fixtureSurfaces(dir); + const before = surfaces.map((file) => readFileSync(file, 'utf8')); + + const { status, output } = runFixture(script); + assert(status === 1, `${why} exits 1 — got ${status}\n${output}`); + assert( + output.includes(PROTOCOL_VERSION_SOURCE) && output.includes('cannot read PROTOCOL_VERSION'), + `${why}: the failure names the protocol source\n${output}`, + ); + assert( + surfaces.every((file, i) => readFileSync(file, 'utf8') === before[i]), + `${why}: no template surface is written — no stamp from a fall-back value\n${output}`, + ); + } } finally { rmSync(scratch, { recursive: true, force: true }); } @@ -877,6 +1076,8 @@ function selfTest() { 'A CLEAN corpus is observed REACHED, byte-identical and UNWRITTEN; a missing stamp, a missing file, an ' + 'unparseable package.json, a template with no @objectstack/* dependency and an empty templates directory ' + 'are each observed exiting 1 and naming the path; and one run is observed naming EVERY unstamped surface. ' + + 'engines.protocol is observed stamped from PROTOCOL_VERSION and specVersion from the package, over a fixture ' + + 'whose two majors differ, and an unreadable PROTOCOL_VERSION is observed exiting 1 with nothing written. ' + 'The STALE -> rewritten direction and the discovery walk belong to ' + 'packages/create-objectstack/src/template-version-stamps.test.ts and are deliberately not restated here.', ); From 60fa01c7a727ec491aa4cef76186e262f4da79fd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 15:59:53 +0000 Subject: [PATCH 3/4] revert(create-objectstack): keep the template stamp, the sync script and their tests as main has them The Scaffold E2E gate (repo-built scaffolder, registry-installed @objectstack/* at ^17.0.0) refused a template declaring engines.protocol '^18': a template's range must match the protocol of the packages it installs, which is the package major the sync script and the template-consistency ratchet already enforce. The skew the nightly hit is the in-repo pairing of a published-line scaffold with the workspace runtime during the pre-mode window, so the alignment moves into that test's setup. Restores the four files to their blobs on main and drops the create-objectstack changeset. Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS Co-authored-by: Claude --- ...-engines-protocol-from-protocol-version.md | 14 - .../src/template-consistency.test.ts | 20 +- .../src/template-version-stamps.test.ts | 24 +- .../src/templates/blank/objectstack.config.ts | 2 +- scripts/sync-template-versions.mjs | 255 ++---------------- 5 files changed, 36 insertions(+), 279 deletions(-) delete mode 100644 .changeset/22622-scaffold-engines-protocol-from-protocol-version.md diff --git a/.changeset/22622-scaffold-engines-protocol-from-protocol-version.md b/.changeset/22622-scaffold-engines-protocol-from-protocol-version.md deleted file mode 100644 index c06a282045c..00000000000 --- a/.changeset/22622-scaffold-engines-protocol-from-protocol-version.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"create-objectstack": patch ---- - -fix(create-objectstack): the blank template's `engines.protocol` is stamped from the protocol major, not the package major - -Clause-②: no - -`engines.protocol` in the scaffolded `objectstack.config.ts` is the range the runtime's protocol handshake checks before it loads the project. - -- The release stamp (`scripts/sync-template-versions.mjs`) used to write it from create-objectstack's own major. It now writes the major of `PROTOCOL_VERSION`, the value the handshake compares against. The two differ while the protocol moves ahead of a release: in that window the unreleased template declared `'^17'` beside a protocol-18 runtime, which refused to load the project it scaffolded. -- The template now declares `'^18'`, written by that stamp rather than by hand. -- Unchanged: `specVersion` and the `@objectstack/*` dependency ranges still carry the package range. -- No released version shipped the mismatch. A project you scaffolded earlier keeps its own range; to move it to protocol 18, run `objectstack migrate meta --from 17 --write`, which rewrites `engines.protocol` with the rest of the step. diff --git a/packages/create-objectstack/src/template-consistency.test.ts b/packages/create-objectstack/src/template-consistency.test.ts index 319ffb02290..d32c819cf82 100644 --- a/packages/create-objectstack/src/template-consistency.test.ts +++ b/packages/create-objectstack/src/template-consistency.test.ts @@ -16,7 +16,6 @@ import { copyDir, TEMPLATE_FILE_ALIASES } from './template-copy.js'; import { TEMPLATES } from './template-registry.js'; import { gitFreeEnv } from '../../../scripts/git-env.mjs'; import { SKILLS_CATALOG, SKILLS_INSTALL_COMMAND } from './skills-install.js'; -import { PROTOCOL_MAJOR, PROTOCOL_VERSION } from '@objectstack/spec/kernel'; const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const repoRoot = path.resolve(pkgRoot, '..', '..'); @@ -192,17 +191,7 @@ describe('bundled template declared version surfaces', () => { // `objectstack.manifest.json` — the two were conflated in this suite's own // naming and in the sync script's log strings, and that conflation is part // of how the sibling key below went unwatched for eleven majors. - // - // NOTE the value: the PROTOCOL major, judged against `PROTOCOL_VERSION` — - // the constant the runtime's ADR-0087 D1 handshake compares a scaffolded - // project's range to — and NOT against this package's own major. The two - // differ for the whole pre-mode window in which the protocol moves ahead of - // the version pass (packages at 17.x beside protocol 18): judged against the - // package major, a template the runtime refuses at boot stayed green here. - // `@objectstack/spec/kernel` resolves to the spec's SOURCE through this - // package's vitest alias, so the verdict needs no build — it runs on the - // release path's post-version tree, where nothing has been built. - it('objectstack.config.ts stamps an engines.protocol that PROTOCOL_VERSION satisfies (ADR-0087 D1)', () => { + it("objectstack.config.ts stamps engines.protocol at the scaffolder's major (ADR-0087 D1)", () => { const config = readTemplateFile('objectstack.config.ts'); const match = /engines:\s*\{\s*protocol:\s*'\^(\d+)'\s*\}/.exec(config); expect( @@ -211,10 +200,9 @@ describe('bundled template declared version surfaces', () => { ).not.toBeNull(); expect( Number(match![1]), - `${template} stamps engines.protocol '^${match![1]}', which PROTOCOL_VERSION ${PROTOCOL_VERSION} does not ` + - 'satisfy: every project it scaffolds is refused at the protocol handshake by the runtime it ships ' + - 'with. Run `node scripts/sync-template-versions.mjs` — it stamps this key from PROTOCOL_VERSION\'s major.', - ).toBe(PROTOCOL_MAJOR); + `${template} stamps engines.protocol '^${match![1]}' but create-objectstack is v${ownMajor} — ` + + 'scripts/sync-template-versions.mjs re-stamps this at version time; keep them in lockstep', + ).toBe(ownMajor); }); // The key #9264 is about. Required by TemplateManifestSchema, read by the diff --git a/packages/create-objectstack/src/template-version-stamps.test.ts b/packages/create-objectstack/src/template-version-stamps.test.ts index fe05f9af64c..48ba146ed36 100644 --- a/packages/create-objectstack/src/template-version-stamps.test.ts +++ b/packages/create-objectstack/src/template-version-stamps.test.ts @@ -25,10 +25,7 @@ // re-found the day a second template ships. // // The same fixture is deliberately built STALE (pinning ^17 while its -// scaffolder reads 42.0.0 and its PROTOCOL_VERSION reads 43.0.0 — two majors, -// because `engines.protocol` is stamped from the protocol constant and every -// other surface from the package, and with the two equal a stamp read from the -// wrong source would pass). That makes the import-safety assertion non-vacuous +// scaffolder reads 42.0.0). That makes the import-safety assertion non-vacuous // in the one way that matters: an unguarded module imported against a stale // tree REWRITES it, so "the files are byte-identical after import" is evidence // only when there was something for a rewrite to do. Byte-identity over an @@ -65,7 +62,6 @@ type SyncModule = { findTemplateDirs: (templateRoot?: string) => string[]; stampedPaths: (options?: { root?: string }) => string[]; loadScaffolderVersion: (file?: string) => { version: string; major: string; range: string }; - loadProtocolMajor: (file?: string) => { source: string; protocolMajor: string }; }; const loadSync = async (file = SYNC_SCRIPT): Promise => @@ -79,11 +75,6 @@ let fixtureScript: string; /** Deliberately not the live version, so a stamp that ran is unmistakable. */ const FIXTURE_VERSION = '42.0.0'; -/** - * The fixture's PROTOCOL major — one ahead of its package major, the shape the - * pre-mode window leaves in the real tree. `engines.protocol` must move to THIS. - */ -const FIXTURE_PROTOCOL_MAJOR = '43'; /** Deliberately stale: every fixture surface pins this and must move to 42. */ const STALE_MAJOR = '17'; @@ -149,11 +140,6 @@ beforeAll(() => { path.join(fixture, 'packages', 'create-objectstack', 'package.json'), JSON.stringify({ name: 'create-objectstack', version: FIXTURE_VERSION }, null, 2) + '\n', ); - // The protocol major's one source, in the shape the real constant spells it. - writeFixtureFile( - path.join(fixture, 'packages', 'spec', 'src', 'kernel', 'protocol-version.ts'), - `export const PROTOCOL_VERSION = '${FIXTURE_PROTOCOL_MAJOR}.0.0';\n`, - ); for (const template of FIXTURE_TEMPLATES) { const dir = fixtureTemplateDir(template); @@ -229,7 +215,6 @@ describe('sync-template-versions.mjs is import-safe', () => { expect(typeof sync.stampedPaths).toBe('function'); expect(typeof sync.findTemplateDirs).toBe('function'); expect(typeof sync.loadScaffolderVersion).toBe('function'); - expect(typeof sync.loadProtocolMajor).toBe('function'); expect(Array.isArray(sync.TEXT_STAMPS)).toBe(true); expect(sync.TEXT_STAMPS.length).toBeGreaterThan(0); expect(sync.TEMPLATE_DIR).toBe('packages/create-objectstack/src/templates'); @@ -264,10 +249,9 @@ describe('the entry-point guard leaves the CLI path working', () => { 'a non-@objectstack dependency is never touched', ).toBe('^6.0.0'); - expect( - fs.readFileSync(path.join(dir, 'objectstack.config.ts'), 'utf8'), - `${template}/objectstack.config.ts engines.protocol moves to the PROTOCOL major, not the package major`, - ).toContain(`engines: { protocol: '^${FIXTURE_PROTOCOL_MAJOR}' }`); + expect(fs.readFileSync(path.join(dir, 'objectstack.config.ts'), 'utf8')).toContain( + "engines: { protocol: '^42' }", + ); const manifest = fs.readFileSync(path.join(dir, 'objectstack.manifest.json'), 'utf8'); expect(manifest).toContain('"specVersion": "^42.0.0"'); diff --git a/packages/create-objectstack/src/templates/blank/objectstack.config.ts b/packages/create-objectstack/src/templates/blank/objectstack.config.ts index b5dcfda7cdd..6308f29eeec 100644 --- a/packages/create-objectstack/src/templates/blank/objectstack.config.ts +++ b/packages/create-objectstack/src/templates/blank/objectstack.config.ts @@ -36,7 +36,7 @@ export default defineStack({ // match the ObjectStack version you installed — change it when you // deliberately move to a new protocol major, not to silence a mismatch. // Guide: https://objectstack.ai/docs/upgrading - engines: { protocol: '^18' }, + engines: { protocol: '^17' }, }, // `automation` backs flow execution and materializes any declarative diff --git a/scripts/sync-template-versions.mjs b/scripts/sync-template-versions.mjs index f7e100de74d..f7704db8f4b 100644 --- a/scripts/sync-template-versions.mjs +++ b/scripts/sync-template-versions.mjs @@ -35,47 +35,15 @@ // skip. A silent skip is indistinguishable from a green run, which is // precisely the invisibility this card was filed about. // -// TWO VALUES, TWO MEANINGS, TWO SOURCES — do not collapse them. -// `engines.protocol` is the ADR-0087 D1 runtime handshake range and carries the -// PROTOCOL major (`^18`), read from the one place that major is declared: -// `PROTOCOL_VERSION` in `packages/spec/src/kernel/protocol-version.ts`, the -// constant the runtime's handshake compares against. `specVersion` is -// documented by TemplateManifestSchema as the "Compatible @objectstack/spec -// semver range" and carries the PACKAGE range (`^17.0.0`), read from this -// scaffolder's own version — the same value this script writes into the -// template's own `@objectstack/spec` dependency, so the manifest and the -// package.json state one fact once. -// -// The two majors are NOT always equal, which is why the protocol major is read -// from the constant rather than inferred from the package. The constant is held -// to the spec package's major with one exception (ruling record 6049734955, -// Q1 -> B): in Changesets pre mode with a pending `major`, the protocol moves in -// a reviewed pull request ahead of the version pass. For that whole window the -// tree carries packages at `17.x` beside `PROTOCOL_VERSION = '18.0.0'`, and a -// template stamped from the PACKAGE major declares `^17` — a range the runtime -// it is booted against refuses at the handshake. Read from the constant, the -// stamp cannot lag the protocol it declares compatibility with. -// -// Ordering, on the release path: the root `version` script runs -// `sync-protocol-version.mjs` BEFORE this script, so outside that exception the -// constant has already followed the spec package's new major by the time this -// reads it. The two stamps then agree on the major again — from two sources. -// -// The constant is read from SOURCE TEXT, never a build: this script runs under -// plain `node` on the release path before any build exists, and a `.ts` import -// needs type stripping the declared `engines.node` floor (>=22.0.0) does not -// guarantee. It is read HERE, with the expression `check-changeset-no-major.mjs` -// reads the same declaration with (`protocolMajorIn()`: line-anchored, either -// quote, so a commented-out declaration declares nothing), and NOT imported -// from there, because this module is not a leaf: `sync-scaffold-emission-policy.mjs` -// imports it inside create-objectstack's BUILD, and `template-version-stamps.test.ts` -// copies its import closure into a fixture inside that package's TEST. An -// import would pull a 4,600-line changeset gate and its siblings into both, -// leaving the turbo inputs and the cross-package declaration that name this -// closure (`scripts/cross-package-test-inputs.mjs`: "the stamper's own import -// closure") short. The second reader cannot drift silently: a declaration it -// stops matching is a hard failure naming the file (`loadProtocolMajor()`), and -// the self-test's Control J pins that. +// TWO VALUES, TWO MEANINGS — do not collapse them. `engines.protocol` is the +// ADR-0087 D1 runtime handshake range and carries the PROTOCOL major (`^17`). +// `specVersion` is documented by TemplateManifestSchema as the "Compatible +// @objectstack/spec semver range" and carries the PACKAGE range (`^17.0.0`) — +// the same value this script writes into the template's own +// `@objectstack/spec` dependency, so the manifest and the package.json state +// one fact once. They agree on the major today only because the spec package's +// major and the protocol major are kept in lockstep; they are still two +// different declarations and are stamped from two different values. // // ## THE DECLARATIONS ARE EXPORTED, AND NOTHING RUNS ON IMPORT (#9554) // @@ -102,9 +70,8 @@ // ALL template dirs. This is what a consumer wants; deriving it here is // what keeps consumers from re-implementing the walk and the join. // * `TEXT_STAMPS`, `TEMPLATE_DIR`, `TEMPLATE_ROOT`, `findTemplateDirs()`, -// `VERSION_SOURCE`, `loadScaffolderVersion()`, `loadProtocolMajor()` — the -// raw declarations, for a consumer that needs the keys and patterns rather -// than the paths. +// `VERSION_SOURCE`, `loadScaffolderVersion()` — the raw declarations, for +// a consumer that needs the keys and patterns rather than the paths. // * nothing executes, reads a file, or exits the process at import. The // version read used to sit at module scope and `process.exit(1)` on an // unparseable version — an import that can kill its host process is a @@ -158,13 +125,11 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'Control E: a stamp FILE that does not exist exits 1 naming it': 2, 'Control F: an unparseable template package.json exits 1 naming it': 2, 'Control H: zero templates refuses a vacuous green': 3, - 'Control I: engines.protocol is stamped from PROTOCOL_VERSION, never from the package major': 5, - 'Control J: an unreadable PROTOCOL_VERSION exits 1 naming its source, and stamps nothing': 6, }); // DELETING an entry silences that battery's floor exactly as effectively as // zeroing it, so the roster's own size is pinned too. -const SELF_TEST_BATTERY_FLOOR = 10; +const SELF_TEST_BATTERY_FLOOR = 8; // The key an assertion is filed under when no battery is open. It is not a // declared battery, so it reds by the same set difference rather than silently @@ -177,21 +142,6 @@ const root = dirname(dirname(fileURLToPath(import.meta.url))); /** Where the scaffolder's own version is read from. Repo-relative. */ export const VERSION_SOURCE = 'packages/create-objectstack/package.json'; -/** - * Where the protocol major `engines.protocol` carries is read from: the - * declaration of `PROTOCOL_VERSION`, the constant the runtime's ADR-0087 D1 - * handshake compares against. Repo-relative. - */ -export const PROTOCOL_VERSION_SOURCE = 'packages/spec/src/kernel/protocol-version.ts'; - -/** - * `PROTOCOL_VERSION`'s declaration, as `check-changeset-no-major.mjs`'s - * `protocolMajorIn()` reads it — the same expression, so the stamper and the - * protocol-lockstep gate agree on what counts as the declaration (see "TWO - * SOURCES" in the header for why it is not imported). - */ -const PROTOCOL_DECLARATION = /^export const PROTOCOL_VERSION = (['"])(\d+)\.\d+\.\d+\1;/m; - /** * Where the bundled templates live, repo-relative. A DIRECTORY, walked, never a * hand-kept list — see the header. Mirrors `check-template-manifests.ts`. @@ -229,12 +179,10 @@ export const TEXT_STAMPS = [ key: 'engines.protocol', // ADR-0087 D1 — the runtime refuses an incompatible package at the boundary // with the exact migration command. Scaffolds populate it by default; this - // is the ratchet that closes grandfathering. Carries the PROTOCOL major, - // read from `PROTOCOL_VERSION` (`loadProtocolMajor()`), never the package - // major — see "TWO SOURCES" in the header. + // is the ratchet that closes grandfathering. Carries the PROTOCOL major. pattern: /engines:\s*\{\s*protocol:\s*'[^']*'\s*\}/, - value: ({ protocolMajor }) => `^${protocolMajor}`, - replacement: ({ protocolMajor }) => `engines: { protocol: '^${protocolMajor}' }`, + value: ({ major }) => `^${major}`, + replacement: ({ major }) => `engines: { protocol: '^${major}' }`, }, { file: 'objectstack.manifest.json', @@ -323,53 +271,17 @@ export function loadScaffolderVersion(file = join(root, VERSION_SOURCE)) { }; } -/** - * The PROTOCOL major `engines.protocol` is stamped from: `PROTOCOL_VERSION`'s - * major, read from its declaration's source text (see "TWO SOURCES" in the - * header for why the source and not a build, and why this reader). - * - * THROWS when the file cannot be read or carries no declaration, for the same - * reason `loadScaffolderVersion()` does — and never falls back to the package - * major: that fallback is the very reading that stamped `^17` beside a protocol - * of 18, and a stamp from it would be indistinguishable from a correct one. - * - * @param {string} [file] absolute path to `protocol-version.ts` - * @returns {{ source: string, protocolMajor: string }} - */ -export function loadProtocolMajor(file = join(root, PROTOCOL_VERSION_SOURCE)) { - let text; - try { - text = readFileSync(file, 'utf8'); - } catch (err) { - throw new Error(`cannot read PROTOCOL_VERSION: ${rel(file)} could not be read (${err.message})`); - } - const m = PROTOCOL_DECLARATION.exec(text); - const protocolMajor = m ? Number.parseInt(m[2], 10) : null; - if (!Number.isInteger(protocolMajor) || protocolMajor < 1) { - throw new Error( - `cannot read PROTOCOL_VERSION: ${rel(file)} carries no \`export const PROTOCOL_VERSION = 'N.x.y';\` ` + - 'declaration, so the protocol major engines.protocol carries is unknown', - ); - } - return { source: rel(file), protocolMajor: String(protocolMajor) }; -} - // --------------------------------------------------------------------------- function main() { let scaffolder; - let protocol; try { scaffolder = loadScaffolderVersion(); - protocol = loadProtocolMajor(); } catch (err) { console.error(`✗ sync-template-versions: ${err.message}`); process.exit(1); } const { version, major, range } = scaffolder; - const { protocolMajor } = protocol; - /** Everything a stamp may be a function of — the two sources, side by side. */ - const values = { version, major, range, protocolMajor }; const templates = findTemplateDirs(); @@ -443,7 +355,7 @@ function main() { // ── the text stamps ───────────────────────────────────────────────────── for (const stamp of TEXT_STAMPS) { const path = join(TEMPLATE_ROOT, template, stamp.file); - const value = stamp.value(values); + const value = stamp.value({ version, major, range }); let src; try { src = readFileSync(path, 'utf8'); @@ -467,7 +379,7 @@ function main() { continue; } - const stamped = src.replace(stamp.pattern, stamp.replacement(values)); + const stamped = src.replace(stamp.pattern, stamp.replacement({ version, major, range })); if (stamped === src) { console.log(`✓ ${template}/${stamp.file} already stamps ${stamp.key} '${value}'`); } else { @@ -490,8 +402,7 @@ function main() { console.log( `\n✓ sync-template-versions: ${templates.length} template(s) in lockstep with ` + - `create-objectstack@${version} — deps and specVersion at ${range}; engines.protocol at ` + - `'^${protocolMajor}', the protocol major of PROTOCOL_VERSION (${protocol.source}).`, + `create-objectstack@${version} — deps and specVersion at ${range}, engines.protocol at '^${major}'.`, ); } @@ -585,22 +496,6 @@ function main() { /** The scaffolder version every fixture declares — never a live one, so a stamp that ran is unmistakable. */ const SELF_TEST_VERSION = '42.0.0'; -/** `SELF_TEST_VERSION`'s major: the PACKAGE major, the source of every `@objectstack/*` range and `specVersion`. */ -const SELF_TEST_PACKAGE_MAJOR = '42'; - -/** - * The PROTOCOL major every fixture's `PROTOCOL_VERSION` declares — deliberately - * NOT the package major, and one ahead of it, the shape the pre-mode exception - * leaves in the real tree (packages at 17.x, protocol 18). With the two equal, - * a stamp read from the wrong source would be byte-identical to a correct one, - * and every case below would pass either way. - */ -const SELF_TEST_PROTOCOL_MAJOR = '43'; - -/** The declaration a fixture's `protocol-version.ts` carries, in the shape the real file spells it. */ -const protocolDeclaration = (protocolMajor) => - `/** fixture */\nexport const PROTOCOL_VERSION = '${protocolMajor}.0.0';\n`; - /** * Fixture file bodies, keyed by `TEXT_STAMPS[].key` rather than by file name. * @@ -608,17 +503,11 @@ const protocolDeclaration = (protocolMajor) => * stamp row added to `TEXT_STAMPS` with no body here fails the self-test * loudly, instead of silently sitting outside every fixture — which is the * one-key-one-file blind spot (#9264) reappearing in the test harness. - * - * Each body names its OWN source by hand — `engines.protocol` the protocol - * major, `specVersion` the package major — rather than calling - * `stamp.value()`: a fixture built from the script's own reading would agree - * with a script that read the wrong source, and Control A would stay green. */ const SELF_TEST_BODIES = { - 'engines.protocol': ({ protocolMajor }) => - `export default defineStack({ manifest: { engines: { protocol: '^${protocolMajor}' } } });\n`, - specVersion: ({ packageMajor }) => - `{\n "specVersion": "^${packageMajor}.0.0",\n "scaffold": { "variables": [] }\n}\n`, + 'engines.protocol': (major) => + `export default defineStack({ manifest: { engines: { protocol: '^${major}' } } });\n`, + specVersion: (major) => `{\n "specVersion": "^${major}.0.0",\n "scaffold": { "variables": [] }\n}\n`, }; function writeFixtureFile(file, contents) { @@ -627,20 +516,10 @@ function writeFixtureFile(file, contents) { } /** - * A throwaway checkout shaped like this repo, IN LOCKSTEP unless an option says - * otherwise — its PACKAGE major and its PROTOCOL major deliberately different. - * `protocolSource` replaces the fixture's `protocol-version.ts` text; `null` - * leaves the file out. Returns the path of the copied script to run. + * A throwaway checkout shaped like this repo, IN LOCKSTEP unless `major` says + * otherwise. Returns the path of the copied script to run. */ -function buildFixture( - dir, - { - templates = ['blank', 'second'], - packageMajor = SELF_TEST_PACKAGE_MAJOR, - protocolMajor = SELF_TEST_PROTOCOL_MAJOR, - protocolSource = protocolDeclaration(SELF_TEST_PROTOCOL_MAJOR), - } = {}, -) { +function buildFixture(dir, { templates = ['blank', 'second'], major = '42' } = {}) { const script = join(dir, 'scripts', 'sync-template-versions.mjs'); writeFixtureFile(script, readFileSync(fileURLToPath(import.meta.url), 'utf8')); // The entry guard is imported, not re-typed (`scripts/invoked-as.mjs`), so the @@ -654,7 +533,6 @@ function buildFixture( join(dir, VERSION_SOURCE), JSON.stringify({ name: 'create-objectstack', version: SELF_TEST_VERSION }, null, 2) + '\n', ); - if (protocolSource !== null) writeFixtureFile(join(dir, PROTOCOL_VERSION_SOURCE), protocolSource); // Created even when `templates` is empty: the vacuous-green guard is about an // EMPTY templates directory, not a missing one. mkdirSync(join(dir, TEMPLATE_DIR), { recursive: true }); @@ -665,15 +543,15 @@ function buildFixture( JSON.stringify( { name: `template-${template}`, - dependencies: { '@objectstack/spec': `^${packageMajor}.0.0`, chalk: '^6.0.0' }, - devDependencies: { '@objectstack/cli': `^${packageMajor}.0.0` }, + dependencies: { '@objectstack/spec': `^${major}.0.0`, chalk: '^6.0.0' }, + devDependencies: { '@objectstack/cli': `^${major}.0.0` }, }, null, 2, ) + '\n', ); for (const stamp of TEXT_STAMPS) { - writeFixtureFile(join(templateDir, stamp.file), SELF_TEST_BODIES[stamp.key]({ packageMajor, protocolMajor })); + writeFixtureFile(join(templateDir, stamp.file), SELF_TEST_BODIES[stamp.key](major)); } } return script; @@ -943,83 +821,6 @@ function selfTest() { `a run that stamped nothing must not print the success line\n${output}`, ); } - - // ── Control I: engines.protocol from PROTOCOL_VERSION, never the package ─ - // - // The two-sources contract, executed in the direction that matters: one - // template's `engines.protocol` carries the PACKAGE major — exactly what - // stamping it from create-objectstack's version used to write — while its - // manifest is already clean. The run must move the protocol key to the - // fixture's PROTOCOL major and leave `specVersion` on the PACKAGE range. - // Control A covers the other half: a corpus clean on both sources is - // judged clean, so a script that read the package major for this key would - // rewrite `^43` there and fail Control A instead. - battery('Control I: engines.protocol is stamped from PROTOCOL_VERSION, never from the package major'); - { - const dir = fixtureDir('two-sources'); - const script = buildFixture(dir); - const engines = TEXT_STAMPS.find((s) => s.key === 'engines.protocol'); - const spec = TEXT_STAMPS.find((s) => s.key === 'specVersion'); - const configPath = join(dir, TEMPLATE_DIR, 'blank', engines.file); - const manifestPath = join(dir, TEMPLATE_DIR, 'blank', spec.file); - writeFixtureFile(configPath, SELF_TEST_BODIES['engines.protocol']({ protocolMajor: SELF_TEST_PACKAGE_MAJOR })); - const manifestBefore = readFileSync(manifestPath, 'utf8'); - - assert( - SELF_TEST_PACKAGE_MAJOR !== SELF_TEST_PROTOCOL_MAJOR, - 'the fixture\'s package and protocol majors differ, so a stamp from the wrong source is observable', - ); - const { status, output } = runFixture(script); - assert(status === 0, `a template stamped from the package major is re-stamped, exit 0 — got ${status}\n${output}`); - assert( - readFileSync(configPath, 'utf8').includes(`engines: { protocol: '^${SELF_TEST_PROTOCOL_MAJOR}' }`), - `blank/${engines.file} now declares the PROTOCOL major ^${SELF_TEST_PROTOCOL_MAJOR}, not the package ` + - `major ^${SELF_TEST_PACKAGE_MAJOR}\n${output}`, - ); - assert( - readFileSync(manifestPath, 'utf8') === manifestBefore, - `blank/${spec.file} keeps specVersion on the PACKAGE range ^${SELF_TEST_PACKAGE_MAJOR}.0.0 — the protocol ` + - `major does not leak into it\n${output}`, - ); - assert( - output.includes(`'^${SELF_TEST_PROTOCOL_MAJOR}', the protocol major of PROTOCOL_VERSION (${PROTOCOL_VERSION_SOURCE})`), - `the success line names the protocol major's source, not only its value\n${output}`, - ); - } - - // ── Control J: an unreadable PROTOCOL_VERSION is a hard failure ───────── - // - // Missing and undeclared are both exit 1, naming the file, BEFORE anything - // is written. ⛔ Never a fall-back to the package major: that reading is the - // defect this source replaced, and a stamp from it is indistinguishable - // from a correct one. The fixtures are clean on both sources, so a - // fall-back run would have work to do (`^43` -> `^42`) and byte-identity - // below is not vacuous. - battery('Control J: an unreadable PROTOCOL_VERSION exits 1 naming its source, and stamps nothing'); - for (const [name, protocolSource, why] of [ - ['protocol-missing', null, 'a missing protocol-version.ts'], - [ - 'protocol-undeclared', - `// export const PROTOCOL_VERSION = '${SELF_TEST_PROTOCOL_MAJOR}.0.0';\n`, - 'a protocol-version.ts whose only declaration is commented out', - ], - ]) { - const dir = fixtureDir(name); - const script = buildFixture(dir, { protocolSource }); - const surfaces = fixtureSurfaces(dir); - const before = surfaces.map((file) => readFileSync(file, 'utf8')); - - const { status, output } = runFixture(script); - assert(status === 1, `${why} exits 1 — got ${status}\n${output}`); - assert( - output.includes(PROTOCOL_VERSION_SOURCE) && output.includes('cannot read PROTOCOL_VERSION'), - `${why}: the failure names the protocol source\n${output}`, - ); - assert( - surfaces.every((file, i) => readFileSync(file, 'utf8') === before[i]), - `${why}: no template surface is written — no stamp from a fall-back value\n${output}`, - ); - } } finally { rmSync(scratch, { recursive: true, force: true }); } @@ -1076,8 +877,6 @@ function selfTest() { 'A CLEAN corpus is observed REACHED, byte-identical and UNWRITTEN; a missing stamp, a missing file, an ' + 'unparseable package.json, a template with no @objectstack/* dependency and an empty templates directory ' + 'are each observed exiting 1 and naming the path; and one run is observed naming EVERY unstamped surface. ' + - 'engines.protocol is observed stamped from PROTOCOL_VERSION and specVersion from the package, over a fixture ' + - 'whose two majors differ, and an unreadable PROTOCOL_VERSION is observed exiting 1 with nothing written. ' + 'The STALE -> rewritten direction and the discovery walk belong to ' + 'packages/create-objectstack/src/template-version-stamps.test.ts and are deliberately not restated here.', ); From 3d63ed7db567cb823780b191d032fe1050f5a118 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 16:01:45 +0000 Subject: [PATCH 4/4] test(cli): the noise-budget e2e aligns the scaffold's engines.protocol with the runtime it boots The file scaffolds the blank starter and boots it against the workspace runtime without an install. In a Changesets pre-mode window the workspace protocol (18) is ahead of the published line the template stamps (^17), so the handshake refused the pair before any boot diagnostic was printed and the budget assertion read an empty block. The setup now rewrites the one key to '^' + PROTOCOL_MAJOR of the workspace spec, digits only, so it leaves the scaffold byte-for-byte as written when the majors agree. A direct rewrite rather than `migrate meta --write`: that codemod refuses files under a node_modules segment, where this project lives. Budget, assertions and fixture objects and actions are unchanged. Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS Co-authored-by: Claude --- ...-boot-diagnostics-noise-budget.e2e.test.ts | 41 ++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/packages/cli/test/serve-boot-diagnostics-noise-budget.e2e.test.ts b/packages/cli/test/serve-boot-diagnostics-noise-budget.e2e.test.ts index 623cd29386f..058fbecec7d 100644 --- a/packages/cli/test/serve-boot-diagnostics-noise-budget.e2e.test.ts +++ b/packages/cli/test/serve-boot-diagnostics-noise-budget.e2e.test.ts @@ -30,9 +30,10 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { execFile } from 'node:child_process'; -import { appendFileSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { appendFileSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel'; import { childEnv, randomPort, runServe, type ServeRun } from './helpers/serve-process.js'; const HERE = resolve(fileURLToPath(import.meta.url), '..'); @@ -122,6 +123,43 @@ let root: string; let scaffold: { code: number; output: string }; let boot: ServeRun | undefined; +/** + * Align the scaffold's `engines.protocol` with the protocol of the runtime this + * file boots: the WORKSPACE `@objectstack/spec`, whose `PROTOCOL_MAJOR` this + * reads. + * + * The template stamps the protocol of the packages a real project INSTALLS, + * which is the published line (`create-objectstack`'s own major). That is + * right for users: the Scaffold E2E gate installs from the registry and refuses + * any other range. This file installs nothing — the project resolves the + * workspace copies — so in a Changesets pre-mode window, where + * `PROTOCOL_VERSION` moves ahead of the version pass (packages at 17.x beside + * protocol 18), the pair it boots is one no user has: a published-line scaffold + * on a next-major runtime. The handshake refuses that pair before a single boot + * diagnostic is printed, and this file goes red on the setup rather than the + * budget (review 6099406666 on #22622). So the setup aligns the pair it boots. + * + * Only the major's digits are rewritten. Outside such a window the template + * already stamps `'^PROTOCOL_MAJOR'`, and the file is left byte-for-byte as + * scaffolded. + * + * A direct rewrite of this one key, not `objectstack migrate meta --from N + * --write`, the step the refusal prescribes: that codemod refuses to edit any + * file under a `node_modules` path segment, and this project lives under this + * package's `node_modules` so that its imports resolve. Measured on a scaffold + * there: exit 0, "not written [outside-project]", config unchanged. + */ +function alignProtocolRange(configPath: string): void { + const source = readFileSync(configPath, 'utf8'); + const stamp = /engines:\s*\{\s*protocol:\s*'\^(\d+)'\s*\}/; + if (!stamp.test(source)) { + throw new Error(`${configPath} declares no engines.protocol stamp, so the pair this file boots cannot be aligned`); + } + const aligned = source.replace(stamp, (literal, major: string) => + literal.replace(`'^${major}'`, `'^${PROTOCOL_MAJOR}'`)); + if (aligned !== source) writeFileSync(configPath, aligned); +} + function scaffoldProject(cwd: string): Promise<{ code: number; output: string }> { return new Promise((resolveRun) => { execFile( @@ -141,6 +179,7 @@ beforeAll(async () => { scaffold = await scaffoldProject(root); if (scaffold.code !== 0) return; const dir = join(root, PROJECT); + alignProtocolRange(join(dir, 'objectstack.config.ts')); writeFileSync(join(dir, 'src', 'objects', 'ticket.object.ts'), TICKET_OBJECT); appendFileSync(join(dir, 'src', 'objects', 'index.ts'), "export { Ticket } from './ticket.object.js';\n"); writeFileSync(join(dir, 'src', 'actions', 'ticket.actions.ts'), TICKET_ACTIONS);