diff --git a/.changeset/28643-build-override-write-gate.md b/.changeset/28643-build-override-write-gate.md new file mode 100644 index 00000000000..b0b733a0437 --- /dev/null +++ b/.changeset/28643-build-override-write-gate.md @@ -0,0 +1,12 @@ +--- +'@objectstack/cli': patch +--- + +fix(cli): `os build` runs the override write gate `os validate` runs (ADR-0146 D2, #28643) + +Clause-②: no (narrowing) +Metadata-protocol: none + +`os build` now calls `findOverrideDeclarationRefusals` beside the dependency-range check, the same walk over the publish door's own judge (`judgeOverrideWrite`) that `os validate`, the publish doors and the managed install ask. A stack carrying an `overrides` declaration the gate does not admit, or a dependent's item bearing a name a package in its dependency closure ships, is refused at build with the rows `os validate` prints (`NOT_OVERRIDABLE`, exit 1), and no artifact is written. Before, the build emitted that artifact and the managed install or a publish door refused it later with the same judge; what any door accepts is unchanged. + + diff --git a/packages/cli/src/commands/compile.ts b/packages/cli/src/commands/compile.ts index daba252de55..101f6bc62f2 100644 --- a/packages/cli/src/commands/compile.ts +++ b/packages/cli/src/commands/compile.ts @@ -76,6 +76,9 @@ import { findViewContainerNameRefusals } from '../utils/view-container-names.js' // A field `picklist` that names no picklist the stack declares — the walk // `os validate` step 2d runs, over the same judge. import { judgePicklistReferences, picklistRefusalHeading, printPicklistReferenceNotices } from '../utils/picklist-references.js'; +// [ADR-0146 D2, #28643] The override write gate — the walk `os validate` step +// 3a-iii runs, over the publish door's own judge (`judgeOverrideWrite`). +import { findOverrideDeclarationRefusals } from '../utils/override-declarations.js'; export default class Compile extends Command { static override description = 'Compile ObjectStack configuration to JSON artifact'; @@ -768,6 +771,37 @@ export default class Compile extends Command { ); } + // 3b-iii. [ADR-0146 D2, #28643] The override write gate, at build time — + // the SAME call `os validate` makes at its step 3a-iii, over the + // walk `utils/override-declarations.ts` takes and the publish door's + // own judge (`judgeOverrideWrite`). Only the base's paired + // `.custom` declares an override; a dependent's item bearing a + // name a package in its closure ships, and an `overrides` declaration + // the gate does not admit, are refused. Before this the build emitted + // such an artifact and the managed install or a publish door refused + // it later with the same judge; now the door that ships refuses it + // and writes no artifact. + // + // Beside the dependency-range check below and ahead of every artifact + // write. The `--json` face is this command's failure envelope + // (`errors`, the rows `os validate --json` carries); the text face is + // `os validate`'s. No step line, as on `os validate`. + const overrideRefusals = findOverrideDeclarationRefusals(result.data as Record); + if (overrideRefusals.length > 0) { + if (flags.json) { + await emitJson({ success: false, errors: overrideRefusals, warnings: warningsSoFar(), conversions: conversionNotices }, 0, { compact: true }); + this.exit(1); + } + const n = overrideRefusals.length; + console.log(''); + printError(`The publish door would refuse ${n} item${n > 1 ? 's' : ''} under the override write gate (ADR-0146 D2)`); + printBulletList( + overrideRefusals.map((r) => `${r.path} [${r.code}] ${r.message}`), + { noun: 'override refusal(s)', remedy: JSON_FULL_LIST_REMEDY }, + ); + this.exit(1); + } + // 3b-quater. ADR-0141 D2, author side — every `manifest.dependencies` // range of the packages one install act would deliver, judged by the // install's own predicate (`utils/dependency-range-check.ts`). diff --git a/packages/cli/test/build-override-declarations.test.ts b/packages/cli/test/build-override-declarations.test.ts new file mode 100644 index 00000000000..d24a6e91d34 --- /dev/null +++ b/packages/cli/test/build-override-declarations.test.ts @@ -0,0 +1,164 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0146 D2, #28643] `os build` asks the override write gate that `os + * validate`, the publish doors and the managed install ask, and refuses the + * build with the rows `os validate` prints: same rows, same exit code, and + * NO artifact written. + * + * ## The defect, measured before the fix + * + * On a dependent package carrying `overrides: { package: 'com.acme.crm', … }` + * (only `com.acme.crm.custom` may declare one), and on a `packages[]` stack + * whose dependent ships a dashboard under the base's name, `os build --json` + * exited 0 and wrote `dist/objectstack.json`, while `os validate --json` + * exited 1 with one `NOT_OVERRIDABLE` row each. The managed install and the + * publish doors refused that artifact later with the same judge. + * + * ## One judge, one walk + * + * `compile.ts` makes the call `validate.ts` makes + * (`findOverrideDeclarationRefusals`, over `judgeOverrideWrite`), so the pins + * below assert EQUALITY with what `os validate` answers for the same config, + * run in the same process, rather than a copy of the judge's words. Both + * commands run in-process (`Command.run`), not as spawned CLIs. + */ + +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import Compile from '../src/commands/compile'; +import Validate from '../src/commands/validate'; +import { defineStackSource, linkSpec } from './helpers/define-stack-fixture.js'; + +const CLI_ROOT = resolve(fileURLToPath(import.meta.url), '..', '..'); +const BASE = 'com.acme.crm'; + +interface Run { + /** The exit code `this.exit(n)` threw, or 0 when the command returned. */ + code: number; + stdout: string; +} + +/** Runs one command in-process; stdout captured ANSI-free, the write callback honoured. */ +async function run(command: typeof Compile | typeof Validate, argv: string[]): Promise { + const chunks: string[] = []; + const spy = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: unknown, ...rest: unknown[]) => { + chunks.push(String(chunk)); + const done = rest.find((a) => typeof a === 'function') as ((e?: Error | null) => void) | undefined; + done?.(null); + return true; + }) as never); + const exitCodeBefore = process.exitCode; + let code = 0; + try { + await command.run(argv, { root: CLI_ROOT }); + } catch (error) { + const exit = (error as { oclif?: { exit?: unknown } }).oclif?.exit; + if (typeof exit !== 'number') throw error; + code = exit; + } finally { + spy.mockRestore(); + process.exitCode = exitCodeBefore; + } + // eslint-disable-next-line no-control-regex + return { code, stdout: chunks.join('').replace(/\u001b\[[0-9;]*m/g, '') }; +} + +const json = (r: Run, label: string): Record => { + try { + return JSON.parse(r.stdout) as Record; + } catch { + throw new Error(`${label}: stdout was not one JSON document (exit ${r.code})\n${r.stdout}`); + } +}; + +const dashboard = (name: string, extra: Record = {}) => ({ name, label: 'Board', widgets: [], ...extra }); + +/** A single-package stack declaring an override of the base's dashboard. */ +const declaring = (id: string): Record => ({ + manifest: { id, name: 'ovr', version: '1.0.0', type: 'app', namespace: 'ovr', dependencies: { [BASE]: '^1.0.0' } }, + dashboards: [dashboard('crm_board', { overrides: { package: BASE, basedOn: '1.0.0' } })], +}); + +/** A `packages[]` stack whose dependent ships the base's dashboard name, undeclared. */ +const undeclaredSameName = (): Record => ({ + manifest: { id: 'com.acme.bundle', name: 'bundle', version: '1.0.0', type: 'app' }, + packages: [ + { manifest: { id: BASE, name: 'crm', version: '1.0.0', type: 'app', dashboards: [dashboard('crm_board')] } }, + { + manifest: { + id: 'com.acme.partner', name: 'partner', version: '1.0.0', type: 'app', + dependencies: { [BASE]: '^1.0.0' }, dashboards: [dashboard('crm_board')], + }, + }, + ], +}); + +let root = ''; +const fixtures: Record = {}; + +beforeAll(() => { + root = mkdtempSync(join(tmpdir(), 'os-build-override-declarations-')); + const make = (label: string, s: Record) => { + const dir = join(root, label); + mkdirSync(dir, { recursive: true }); + // `strict: false`: the content is the door's to judge, not the producer's. + writeFileSync(join(dir, 'objectstack.config.ts'), defineStackSource(s, { strict: false })); + linkSpec(dir); + fixtures[label] = { config: join(dir, 'objectstack.config.ts'), artifact: join(dir, 'dist', 'objectstack.json') }; + }; + make('partner', declaring('com.acme.partner')); + make('undeclared', undeclaredSameName()); + make('pair', declaring('com.acme.crm.custom')); +}); + +afterAll(() => { + if (root) rmSync(root, { recursive: true, force: true }); +}); + +const build = (label: string, ...flags: string[]) => + run(Compile, [fixtures[label].config, '--output', fixtures[label].artifact, ...flags]); +const validate = (label: string, ...flags: string[]) => run(Validate, [fixtures[label].config, ...flags]); + +describe('#28643 — os build refuses what os validate refuses under the override write gate, and ships nothing', () => { + it.each(['partner', 'undeclared'])('THE PIN (%s): --json exits as validate does, with validate\'s rows, and writes no artifact', async (label) => { + const validated = await validate(label, '--json'); + expect(validated.code, validated.stdout).toBe(1); + const expected = json(validated, `validate ${label}`).errors as Array>; + // Anti-vacuity: the judge refuses this config, under the override gate. + expect(expected.map((e) => e.code)).toEqual(['NOT_OVERRIDABLE']); + + const built = await build(label, '--json'); + const payload = json(built, `build ${label}`); + expect(built.code).toBe(validated.code); + expect(payload.success).toBe(false); + expect(payload.errors).toEqual(expected); + expect(existsSync(fixtures[label].artifact), 'os build wrote an artifact the publish door refuses').toBe(false); + }, 60_000); + + it('the text face exits 1 and prints the refusal block os validate prints', async () => { + const validated = await validate('undeclared'); + expect(validated.code).toBe(1); + const block = validated.stdout.slice(validated.stdout.indexOf('✗ The publish door would refuse')).trimEnd(); + expect(block).toContain('[NOT_OVERRIDABLE]'); + + const built = await build('undeclared'); + expect(built.code).toBe(1); + expect(built.stdout).toContain(block); + expect(built.stdout).not.toContain('Build complete'); + expect(existsSync(fixtures.undeclared.artifact)).toBe(false); + }, 60_000); + + it('CONTROL: the base\'s paired .custom declaring the same override builds, and validates clean', async () => { + expect((await validate('pair', '--json')).code).toBe(0); + const built = await build('pair', '--json'); + const payload = json(built, 'build pair'); + expect(payload.success, JSON.stringify(payload.errors ?? payload.error)).toBe(true); + expect(built.code).toBe(0); + const artifact = JSON.parse(readFileSync(fixtures.pair.artifact, 'utf8')) as Record; + expect(artifact.dashboards.map((d: { name: string }) => d.name)).toEqual(['crm_board']); + }, 60_000); +}); diff --git a/packages/cli/test/validate-build-gate-parity.test.ts b/packages/cli/test/validate-build-gate-parity.test.ts index 076046367ff..6c21bb4acee 100644 --- a/packages/cli/test/validate-build-gate-parity.test.ts +++ b/packages/cli/test/validate-build-gate-parity.test.ts @@ -183,6 +183,20 @@ const SHARED_NON_REGISTRY_GATES: readonly string[] = [ // and the walk reads every dataset where it sits, the package bodies // included, across the whole artifact. 'judgeSeedRecordValues', + // [ADR-0146 D2, #28410, #28643] The override write gate: an item bearing a + // name a package in its dependency closure ships, or an `overrides` + // declaration the gate does not admit, judged by the publish door's own + // `judgeOverrideWrite`. Not a registry rule: the WALK decides which packages + // of the artifact are in each package's closure and which of them ship the + // same name, so it is cross-package by construction. + // + // ⭐ This row is the #28410 VALIDATE_ONLY_GATES entry CLOSED. That entry read + // "os build is a fourth point it did not name", and it was right: the build + // emitted an artifact the managed install and the publish doors refused + // later. `compile.ts` now makes the same call (#28643), so the row moved + // here — deleted there rather than reworded, for the reason the + // `runPerPackageAuthoringRules` row above gives. + 'findOverrideDeclarationRefusals', ]; /** @@ -221,22 +235,13 @@ const BUILD_ONLY_GATES: Readonly> = { * SHARED_NON_REGISTRY_GATES instead. * * EMPTY is this ledger's steady state: every row is a gap the build carries. - * Its one row, `findViewContainerNameRefusals` (#20331), moved to - * SHARED_NON_REGISTRY_GATES when `compile.ts` gained the call (#20393). The - * ledger stays, empty, because it is the only honest place the closed roster - * has for the next validate-only gate. + * Both rows it has held moved to SHARED_NON_REGISTRY_GATES when `compile.ts` + * gained the call: `findViewContainerNameRefusals` (#20331 → #20393) and + * `findOverrideDeclarationRefusals` (#28410 → #28643). The ledger stays, + * empty, because it is the only honest place the closed roster has for the + * next validate-only gate. */ -const VALIDATE_ONLY_GATES: Readonly> = { - // [#28410] A real gap in the build's direction, reported and not closed: - // card #28410 (ADR-0146 D2, ruling A on #28390) named three gate points for - // the override write gate — the publish doors, `os validate` and the - // install door — and `os build` is not one of them. The artifact a build - // emits is still judged where it lands: the marketplace managed install - // and the publish doors refuse the same items with the same judge. Whether - // the build refuses too is a separate decision, raised on the card. - findOverrideDeclarationRefusals: - 'card #28410 placed the override write gate at publish, os validate and install; os build is a fourth point it did not name (open question on #28410). The install and publish doors refuse the same items with the same judge.', -}; +const VALIDATE_ONLY_GATES: Readonly> = {}; /** * Everything else the two commands call, and the reason each one is NOT an