diff --git a/.changeset/28727-build-extension-field-suffix.md b/.changeset/28727-build-extension-field-suffix.md new file mode 100644 index 00000000000..640403baf36 --- /dev/null +++ b/.changeset/28727-build-extension-field-suffix.md @@ -0,0 +1,14 @@ +--- +'@objectstack/cli': patch +--- + +fix(cli): `os build` runs the `__c` extension-field reservation `os validate` runs (ADR-0146 D10, #28727) + +Clause-②: no (narrowing) +Metadata-protocol: none + +**BREAKING** accept-set narrowing at `os build` (`@objectstack/cli`): a package source whose field breaks the `__c` reservation now fails the build instead of emitting an artifact. + +`os build` now calls `findExtensionFieldSuffixRefusals` beside the override write gate, the same walk over the save and install doors' own judge (`judgeExtensionFieldSuffix`) that `os validate`, the save, restore and publish doors and the marketplace install ask. A field spelled `__c` outside a base's paired `.custom` package, and an `objectExtensions[].fields` key of a `.custom` pair without the suffix, are refused at build with the rows `os validate` prints (`VALIDATION_ERROR`, exit 1), and nothing is written to `dist/`. Before, the build emitted that artifact and the marketplace install or a save or publish door refused it later with the same judge; what any of those doors accepts is unchanged. The fix is the judge's own sentence: rename a plain package's `x__c` field, or suffix the `.custom` extension field (`region` to `region__c`). + + diff --git a/packages/cli/src/commands/compile.ts b/packages/cli/src/commands/compile.ts index 101f6bc62f2..8f239dd841d 100644 --- a/packages/cli/src/commands/compile.ts +++ b/packages/cli/src/commands/compile.ts @@ -79,6 +79,10 @@ import { judgePicklistReferences, picklistRefusalHeading, printPicklistReference // [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'; +// [ADR-0146 D10, #28727] The `__c` extension-field reservation — the walk +// `os validate` step 3a-iv runs, over the save and install doors' own judge +// (`judgeExtensionFieldSuffix`). +import { findExtensionFieldSuffixRefusals } from '../utils/extension-field-suffix.js'; export default class Compile extends Command { static override description = 'Compile ObjectStack configuration to JSON artifact'; @@ -802,6 +806,37 @@ export default class Compile extends Command { this.exit(1); } + // 3b-iii-bis. [ADR-0146 D10, #28727] The `__c` extension-field + // reservation, at build time — the SAME call `os validate` makes at + // its step 3a-iv, over the walk `utils/extension-field-suffix.ts` + // takes and the save and install doors' own judge + // (`judgeExtensionFieldSuffix`): a field spelled `__c` only in a + // base's paired `.custom` package, and every + // `objectExtensions[].fields` key of a `.custom` pair suffixed. Before + // this the build emitted such an artifact and the marketplace install + // or a save or publish door refused it later with the same judge; now + // the door that ships refuses it and writes no artifact. + // + // Beside the override gate above, ahead of every artifact write, with + // the same two faces: this command's failure envelope under `--json` + // (`errors`, the rows `os validate --json` carries), `os validate`'s + // text otherwise. + const suffixRefusals = findExtensionFieldSuffixRefusals(result.data as Record); + if (suffixRefusals.length > 0) { + if (flags.json) { + await emitJson({ success: false, errors: suffixRefusals, warnings: warningsSoFar(), conversions: conversionNotices }, 0, { compact: true }); + this.exit(1); + } + const n = suffixRefusals.length; + console.log(''); + printError(`${n} field${n > 1 ? 's break' : ' breaks'} the \`__c\` extension-field reservation (ADR-0146 D10)`); + printBulletList( + suffixRefusals.map((r) => `${r.path} [${r.code}] ${r.message}`), + { noun: 'extension-field 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-extension-field-suffix.test.ts b/packages/cli/test/build-extension-field-suffix.test.ts new file mode 100644 index 00000000000..7f4d3aa3d27 --- /dev/null +++ b/packages/cli/test/build-extension-field-suffix.test.ts @@ -0,0 +1,174 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0146 D10, #28727] `os build` asks the `__c` extension-field + * reservation that `os validate`, the save, restore and publish doors and the + * marketplace install ask, and refuses the build with the rows `os validate` + * prints: same rows, same exit code, and NOTHING written to `dist/`. + * + * ## The defect, measured before the fix + * + * On a plain package declaring a field `x__c` on its own object (case (a)), + * and on a base's paired `.custom` extending a base object with a field + * `region` that lacks the suffix (case (b)), `os build --json` exited 0 and + * wrote `dist/objectstack.json`, while `os validate --json` exited 1 with one + * `VALIDATION_ERROR` row each. The marketplace install and the save and + * publish doors refused that artifact later with the same judge. + * + * ## One judge, one walk + * + * `compile.ts` makes the call `validate.ts` makes + * (`findExtensionFieldSuffixRefusals`, over `judgeExtensionFieldSuffix`), 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. + * Which packages the walk judges is pinned by its own file + * (`src/utils/extension-field-suffix.test.ts`). 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}`); + } +}; + +/** Case (a): a plain package declaring `x__c` on its own object. */ +const plainOwnField = (): Record => ({ + manifest: { id: 'com.acme.partner', name: 'partner', version: '1.0.0', type: 'app', namespace: 'partner' }, + objects: [ + { + name: 'partner_ticket', + label: 'Ticket', + sharingModel: 'private', + fields: { title: { type: 'text', label: 'Title' }, x__c: { type: 'text', label: 'X' } }, + }, + ], +}); + +/** The base's paired `.custom` extending the base's `crm_account` with one field named `field`. */ +const pairExtension = (field: string): Record => ({ + manifest: { + id: `${BASE}.custom`, name: 'ext', version: '1.0.0', type: 'app', namespace: 'crm_custom', + dependencies: { [BASE]: '^1.0.0' }, + }, + objectExtensions: [{ extend: 'crm_account', fields: { [field]: { type: 'text', label: 'Region' } } }], +}); + +let root = ''; +const fixtures: Record = {}; + +beforeAll(() => { + root = mkdtempSync(join(tmpdir(), 'os-build-extension-field-suffix-')); + 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'), + dist: join(dir, 'dist'), + artifact: join(dir, 'dist', 'objectstack.json'), + }; + }; + make('plainOwnField', plainOwnField()); + make('pairPlain', pairExtension('region')); + make('pairSuffixed', pairExtension('region__c')); +}); + +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('#28727 — os build refuses what os validate refuses under the `__c` reservation, and writes nothing', () => { + it.each([ + ['plainOwnField', 'reserved_outside_pair', 'objects'], + ['pairPlain', 'unsuffixed_in_pair', 'objectExtensions'], + ])('THE PIN (%s): --json exits as validate does, with validate\'s rows, and dist/ stays empty', async (label, rule, at) => { + 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 case it names. + expect(expected.map((e) => [e.code, e.rule])).toEqual([['VALIDATION_ERROR', rule]]); + expect(String(expected[0]!.path).startsWith(at)).toBe(true); + + 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].dist), 'os build wrote into dist/ for an artifact the doors refuse').toBe(false); + }, 60_000); + + it('the text face exits 1 and prints the refusal block os validate prints', async () => { + const validated = await validate('pairPlain'); + expect(validated.code).toBe(1); + const block = validated.stdout.slice(validated.stdout.indexOf('✗ 1 field breaks the')).trimEnd(); + expect(block).toContain('objectExtensions[0].fields.region [VALIDATION_ERROR]'); + + const built = await build('pairPlain'); + expect(built.code).toBe(1); + expect(built.stdout).toContain(block); + expect(built.stdout).not.toContain('Build complete'); + expect(existsSync(fixtures.pairPlain.dist)).toBe(false); + }, 60_000); + + it('CONTROL: the base\'s paired .custom extending with `region__c` builds, and validates clean', async () => { + expect((await validate('pairSuffixed', '--json')).code).toBe(0); + const built = await build('pairSuffixed', '--json'); + const payload = json(built, 'build pairSuffixed'); + expect(payload.success, JSON.stringify(payload.errors ?? payload.error)).toBe(true); + expect(built.code).toBe(0); + const artifact = JSON.parse(readFileSync(fixtures.pairSuffixed.artifact, 'utf8')) as Record; + expect(Object.keys(artifact.objectExtensions[0].fields)).toEqual(['region__c']); + }, 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 7291d00b277..632d6647001 100644 --- a/packages/cli/test/validate-build-gate-parity.test.ts +++ b/packages/cli/test/validate-build-gate-parity.test.ts @@ -197,6 +197,22 @@ const SHARED_NON_REGISTRY_GATES: readonly string[] = [ // here — deleted there rather than reworded, for the reason the // `runPerPackageAuthoringRules` row above gives. 'findOverrideDeclarationRefusals', + // [ADR-0146 D10, #28564, #28727] The `__c` extension-field reservation: a + // field spelled `__c` only in a base's paired `.custom` package, and every + // `objectExtensions[].fields` key of a `.custom` pair suffixed, judged by + // the save and install doors' own `judgeExtensionFieldSuffix`. Not a + // registry rule: the WALK decides which package each field is declared in — + // each `packages[]` entry under its own id, or the stack under + // `manifest.id` — and the verdict turns on that package's id, which the + // union stack a registry rule is handed does not carry. + // + // ⭐ This row is the #28564 VALIDATE_ONLY_GATES entry CLOSED. That entry read + // "os build is a further point it did not name", and it was right: the + // build emitted an artifact the marketplace install and the save and + // publish doors refused later. `compile.ts` now makes the same call + // (#28727), so the row moved here — deleted there rather than reworded, for + // the reason the `runPerPackageAuthoringRules` row above gives. + 'findExtensionFieldSuffixRefusals', ]; /** @@ -235,22 +251,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. - * Two rows it has held moved to SHARED_NON_REGISTRY_GATES when `compile.ts` - * gained the call: `findViewContainerNameRefusals` (#20331 → #20393) and - * `findOverrideDeclarationRefusals` (#28410 → #28643). It holds two today, - * `findExtensionFieldSuffixRefusals` (#28564) and `findFieldPatchTargetRefusals` - * (#28565), whose cards did not name the build. + * Three rows it has held moved to SHARED_NON_REGISTRY_GATES when `compile.ts` + * gained the call: `findViewContainerNameRefusals` (#20331 → #20393), + * `findOverrideDeclarationRefusals` (#28410 → #28643) and + * `findExtensionFieldSuffixRefusals` (#28564 → #28727). It holds one today, + * `findFieldPatchTargetRefusals` (#28565), whose card did not name the build. */ const VALIDATE_ONLY_GATES: Readonly> = { - // [#28564] A real gap in the build's direction, reported and not closed — - // the shape `findOverrideDeclarationRefusals` held until #28643 closed it. - // Card #28564 (ADR-0146 D10, decided item 7) named the `__c` reservation's - // doors — the save, restore and publish doors, `os validate`, the install - // doors and the duplicate — and `os build` is not one of them. The artifact - // a build emits is still judged where it lands: the marketplace install and - // the save and publish doors refuse the same fields with the same judge. - findExtensionFieldSuffixRefusals: - 'card #28564 placed the __c reservation at the save/restore/publish doors, os validate, the install doors and duplicate; os build is a further point it did not name (as #28410 left the override gate until #28643). The install and publish doors refuse the same fields with the same judge.', findFieldPatchTargetRefusals: '[ADR-0146 D10, second rule] An object extension\'s `fieldPatches` naming a field or option value its ' + 'in-stack target lacks, judged by the marketplace install door\'s own `fieldPatchTargetRefusals`. Wired ' +