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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/28727-build-extension-field-suffix.md
Original file line number Diff line number Diff line change
@@ -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`).

<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no export or type changes: `os build` now runs the `__c` extension-field reservation `os validate`, the save, restore and publish doors and the marketplace install already run, with the same judge (`judgeExtensionFieldSuffix`) and the same rows. An artifact it now refuses was already refused wherever it landed, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone; the remedy is the judge's own sentence (rename the field, or add the suffix). -->
35 changes: 35 additions & 0 deletions packages/cli/src/commands/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string, unknown>);
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`).
Expand Down
174 changes: 174 additions & 0 deletions packages/cli/test/build-extension-field-suffix.test.ts
Original file line number Diff line number Diff line change
@@ -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<Run> {
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<string, any> => {
try {
return JSON.parse(r.stdout) as Record<string, any>;
} 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<string, unknown> => ({
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<string, unknown> => ({
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<string, { config: string; dist: string; artifact: string }> = {};

beforeAll(() => {
root = mkdtempSync(join(tmpdir(), 'os-build-extension-field-suffix-'));
const make = (label: string, s: Record<string, unknown>) => {
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<Record<string, unknown>>;
// 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<string, any>;
expect(Object.keys(artifact.objectExtensions[0].fields)).toEqual(['region__c']);
}, 60_000);
});
35 changes: 21 additions & 14 deletions packages/cli/test/validate-build-gate-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
];

/**
Expand Down Expand Up @@ -235,22 +251,13 @@ const BUILD_ONLY_GATES: Readonly<Record<string, string>> = {
* 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<Record<string, string>> = {
// [#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 ' +
Expand Down
Loading