Skip to content

Commit acd0095

Browse files
objectstack-fleet[bot]hotlongclaude
authored
fix(cli): os build refuses a view container whose name disagrees with its object, as boot does (#20393) (#20459)
Fixes #20393 Clause-②: no `os build` / `os compile` now runs the check `os validate` has run since #20331: a `views:` container whose own `name` disagrees with the object key it binds to is refused. The build exits 1 with the message the boot registrar prints, and it writes no artifact. Before this change the build exited 0 and wrote `dist/objectstack.json`, and `os serve` then refused that artifact at boot. This follows triage's grade on the card (comment 5865077508): the build calls the same function over the same view set, the `validate-build-gate-parity` row moves from `VALIDATE_ONLY_GATES` to `SHARED_NON_REGISTRY_GATES`, and there is no second rule. ## Premise, measured on `origin/main` `7fa3e3e07` before any edit (H0) The setup: the CLI's dependency closure was built (turbo, 59 tasks). Then `os init my-app -t app --no-install`, `os g object order_line` and `os g view order_line` were run, and the view's `name` was hand-edited to `'order_line'`, bound to `my_app_order_line`. | step | exit | what it did | |---|---|---| | `os validate` | **1** | "The server would refuse this stack at boot (1 view container)" (the #20331 control) | | `os build` | **0** | `Build complete`, and wrote `dist/objectstack.json` carrying `[{"name":"order_line","object":"my_app_order_line"}]` | | `os serve` in a directory holding only that artifact (no config) | **1** | "Invalid `views:` container from manifest 'com.example.my-app': the container's own `name` is 'order_line', which disagrees with the object key it binds to, 'my_app_order_line' …" | The card's moot condition does not hold: the container body `name` still parses on `main` (#20357 retired `list.tabs` only). ## What changed 1. **`packages/cli/src/commands/compile.ts`, new step 3a**, right after the schema parse and before the rule table and any artifact write. It makes the same call `validate.ts` step 2c makes, `findViewContainerNameRefusals(result.data)`. That is the walk over `@objectstack/objectql`'s `viewContainerNameRefusal`, the function the boot registrar throws the answer of. There is no second implementation of the check or of the walk. The message is the runtime's own, unchanged. - `--json`: `{ success: false, errors, warnings: warningsSoFar(), conversions }`. This is build's schema-exit envelope, with the refusal rows in `errors` exactly as `os validate --json` carries them: `{ path, code: 'VALIDATION_ERROR', httpStatus: 400, message }`. The exit carries `warnings` and `conversions` like every other exit, so the `build-json-failure-warnings` contract holds. - Text face: `os validate`'s header ("The server would refuse this stack at boot (N view container(s))") and the bullet list. No step line is printed, so a passing build prints what it printed before (the docs transcripts stay true). 2. **`packages/cli/test/validate-build-gate-parity.test.ts`**: `findViewContainerNameRefusals` moves to `SHARED_NON_REGISTRY_GATES`, so `both commands run findViewContainerNameRefusals` now holds both doors to it. `VALIDATE_ONLY_GATES` stays, empty, with a note that empty is its steady state. Its two-way pruning test is unchanged. 3. **`packages/cli/src/commands/validate.ts`**: one comment sentence in step 2c said "`os build` does not run it", which this change makes false. It now points at build's step 3a. Code is unchanged. 4. **New `packages/cli/test/build-view-container-name.test.ts`** (integration tier: it spawns the CLI and constructs `ObjectQL`). It has 6 tests: - a premise case: boot refuses both divergent payloads and accepts both controls; - THE PIN: `build --json` exits 1, `success: false`, and `errors[0]` equals what `ObjectQL.registerApp` throws for the same payload (`message`, `code`, `httpStatus`, `path: 'views[0]'`). No artifact is written; - the text face: exit 1, the same words, no `Build complete`, no artifact; - a `packages[]` stack: the divergent container in the second body is refused as `packages[1].manifest.views[0]`, under that package's id and in the words boot throws for that body. The matching container in the first body is not reported; - two controls, a matching `name` and no `name`: each exits 0. The matching control's written artifact is registered by boot without a throw. 5. **Changesets.** - New `.changeset/20393-build-view-container-name.md`: `@objectstack/cli` `patch`, `Clause-②: no`. - The pending `.changeset/20331-validate-view-container-name.md` was also edited. See the gate note below: this is a deliberate correction and needs your confirmation. ### Which shape build judges, and why the verdict is boot's (H1) Build judges `result.data`, the output of `ObjectStackDefinitionSchema.safeParse(lowerCallables(normalized).lowered)`. That is the same expression, over the same pipeline, as the input to `validate.ts` step 2c. It is also the object build serializes. Step 4 adds only `docs`, `packages[i].manifest.docs` (via `attachPackageDocs`, docs only) and `runtimeModule`, never a view. So every `views:` entry the artifact carries is judged here, at the top level or in each `packages[i].manifest` body. The walker mirrors the load path over that shape: `{ ...manifest, ...stack }` under `artifactPackageId`, or each package body under its own id. Measured after the fix on the repro project: `os build --json`'s `errors[0].message` is **byte-equal** to the line `os serve` printed when booting the pre-fix artifact (`cmp`: identical, 568 bytes). ## After the fix, on the same project (CLI from source) - `os build` exits 1 with the refusal, and no `dist/` directory is created. `os build --json` exits 1 with `success: false`, and `errors[0]` is `views[0]` / `VALIDATION_ERROR` / `400`. - Controls: `name: 'my_app_order_line'` gives exit 0 and an artifact. Deleting `name` gives exit 0 and an artifact. - `examples/`: `os build --json` exits 0 with `success: true` on each of app-crm, app-multi-package, app-showcase and app-todo, with no refusals. The output was written outside the tree. ## Fixture census (H2): no build-door fixture turned red A structural scan read 7,909 tracked `.ts`/`.js`/`.json` files under `packages/`, `examples/`, `apps/` and `scripts/`, including the 260 string and template literals that carry config source (the configs tests write to disk). It found 936 containers. It read each object literal carrying a container arm (`list`/`form`/`listViews`/`formViews`) and no `viewKind`, and derived the key as boot does. It found 21 divergent containers, all outside every build door: `packages/lint` rule unit tests (13), `packages/objectql` (3, including the refusal's own fixtures), `packages/metadata-protocol` (2) and `packages/spec` (2). None of these runs `os build`. `packages/cli`, `packages/qa` and `examples/` have none; #20331's patch round had already fixed that population. There were 9 non-literal-name containers, none in a build-door suite. The behavioural half agrees: all 15 build-door `.e2e` suites (the nightly tier) and the full unit tier are green at the head. ## Ablation (H3), with the fix committed first The mutation went through `scripts/ablation-replace.mjs` on `packages/cli/src/commands/compile.ts`. It replaced the call with `const containerNameRefusals: ReturnType of typeof findViewContainerNameRefusals = []` plus the marker `__ABLATION_20393_NO_CALL`. The tool reported anchor x1 to x0, replacement x0 to x1, and blob `6b4b8871` to `b7f532cb`. On disk, the marker count was 1 and `= findViewContainerNameRefusals(` counted 0. - Both pins read source: the parity test reads `src/commands/compile.ts` as text, and the build-door test runs `bin/run-dev.js`, which is `src/` through tsx. So no `dist/` leg applies. - The result was 4 of 30 red: `both commands run findViewContainerNameRefusals` (unit), THE PIN, the text face, and the `packages[]` case. The premise, both controls and the rest of the parity file stayed green. The direction is red, as expected. - **Restore:** by the tool's trap, `git checkout HEAD --` on the absolute path. The blob after the restore equals HEAD `6b4b8871`, `git diff HEAD` is empty, `git status --porcelain` is empty, and the marker count is 0. The re-run was 30 of 30 green. ## Tests, at head `0c4e9c3724` (after merging `origin/main` `3cf644938`) - `@objectstack/cli` `vitest run --project unit --maxWorkers=2`, two shards: 117 + 116 files, 1789 + 1547 tests, all passed. - `--project integration`, run locally for the two view-container files only: 11 of 11 passed. The rest of the integration tier is declared to CI. - Nightly `.e2e` build-door suites (`OS_TEST_TIERS=nightly`), 15 files: 297 of 297 passed. - `pnpm --filter @objectstack/cli typecheck`: exit 0. `tsc --listFilesOnly` puts the new test in the `tsconfig.test.json` program. - **Host note (macOS):** three suites compare paths under the default `TMPDIR`, which macOS resolves from `/var` to `/private/var`: `published-subpath-console.pin`, `published-subpath-hook-body.pin` and `config-miss-stdout-purity.e2e`. Under the default `TMPDIR` they fail on that prefix alone, for commands this PR does not touch (`os diff`, `os info`, `os verify`, …). With `TMPDIR` set to its realpath they pass (29 of 29 and 174 of 174), so the counts above were taken that way. See the Acceptance notes. ## Gates, at `0c4e9c3724` - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` gave 63 commands. `--ran` reconciled them as 63 run, 0 NOT-MEASURED and 0 UNRUN. Every command exited 0 except the one below. - `check:dual-build-cjs-loads` and `check:i18n-coverage` first exited 3 (PREREQUISITE NOT MET, unbuilt packages). Each exited 0 after the named packages were built. - `pnpm lint` (full `eslint . --no-inline-config`): exit 0 in 45 s. - `node scripts/check-issue-citations.mjs --base origin/main`, run after merging `origin/main`: exit 0, 3 citations resolve. - Two families take their argv from the workflow and are CI-only: `check-issue-citations.mjs --census` and the dogfood shard attestation. ### ⚠ `check-empty-changeset --base origin/main` exits 1 by design: a pending release note is corrected here `.changeset/20331-validate-view-container-name.md` is #20331's pending entry. It closes with "Not changed: `os build` does not run this check, so it still writes an artifact carrying such a container …". This PR makes that sentence false. Both entries ship in the same release while that file is pending, and a published `CHANGELOG.md` sentence is corrected only in the entry that carries it. So that paragraph now reads "`os build` runs the same check as well (#20393, its own entry), so it no longer writes an artifact carrying such a container." The gate classifies this as the DELIBERATE CORRECTION class: it stays red, and its remedy is to confirm the correction on the PR, ⛔ not to restore the file. - **Needs confirmation:** keep the correction. - **The alternative** is to restore the file from base and let this PR's own entry carry the correction. The gate goes green, and the release then carries the false sentence in the #20331 entry. - If a release consumes `20331-validate-view-container-name.md` before this lands, that file's edit becomes a modify/delete conflict. Drop the edit then: the sentence was true in that release. ## Declared narrowing: verification ran UNLOCKED Every build and test run above went through `scripts/pm/os-verify-lock.sh`, and each run printed this disclosure (quoted verbatim from the first one): ```text **Declared narrowing — verification ran UNLOCKED.** `scripts/pm/os-verify-lock.sh` could not take the shared verify lock on this host: no usable `flock`. The shared verify lock is declared Linux-only (`flock` is util-linux, and a stock macOS does not ship it), so the command below was run directly, without the lock — a declared narrowing, not a silent one. No serialization guarantee held for this run, nor for any sibling agent in this container while it ran. pnpm turbo run build --filter='@objectstack/cli...' --concurrency=2 ``` ## Acceptance notes - **File surface beyond the claim, declared:** the claim named `compile.ts`, the parity test, `packages/cli` tests and one new changeset. Two more files were edited, each because this change made a sentence in it false. One is a comment sentence in `validate.ts` step 2c ("`os build` does not run it"). The other is the closing paragraph of the pending `.changeset/20331-validate-view-container-name.md` (above). - **Headers now incomplete, not false, and left alone:** - `packages/cli/src/utils/view-container-names.ts` opens "`os validate`'s author-time half of …". Both doors call it now. - `packages/objectql/src/view-container-name-refusal.ts` says "called by the boot registrar and by `os validate`". It is read-only for this card, and `os build` reaches it through the walker. - Carrier: none. - **Bound carried over, unchanged:** the walker does not walk a nested `plugins[]` entry's `views`, which boot also registers. Its header states why: the stack schema types `plugins` as `unknown[]`. Build inherits that bound. It does not widen it. - **macOS test portability (not a product defect, not filed):** three suites fail under macOS's default `TMPDIR` on a `/var` vs `/private/var` prefix: `test/published-subpath-console.pin.test.ts`, `test/published-subpath-hook-body.pin.test.ts` and `test/config-miss-stdout-purity.e2e.test.ts`. They pass with a realpath `TMPDIR`, and CI (Linux) is unaffected. There is no public-door reach. Carrier: none. --- _Generated by [Claude Code](https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289)_ --------- Co-authored-by: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent d753744 commit acd0095

6 files changed

Lines changed: 346 additions & 11 deletions

File tree

‎.changeset/20331-validate-view-container-name.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,5 +29,5 @@ derived object key is empty, because the boot registrar skips that entry with a
2929
warning and never refuses it. The boot registrar now calls this function. What it
3030
refuses, its message and its `VALIDATION_ERROR` / `400` envelope are unchanged.
3131

32-
Not changed: `os build` does not run this check, so it still writes an artifact
33-
carrying such a container, and the server refuses that artifact when it loads it.
32+
`os build` runs the same check as well (#20393, its own entry), so it no longer
33+
writes an artifact carrying such a container.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli): `os build` / `os compile` refuses a `views:` container whose own `name` disagrees with the object it binds to, and writes no artifact the server would refuse at boot (#20393)
6+
7+
Clause-②: no
8+
9+
A view container is registered under the object it binds to. When its own `name`
10+
is set to something else, for example `{ name: 'order_line', object: 'my_app_order_line', list: { … } }`,
11+
the server refuses the whole stack at boot. `os validate` has refused that stack
12+
since #20331, but `os build` still exited `0` and wrote `dist/objectstack.json`
13+
carrying the container, so `os serve` then refused the artifact it was handed.
14+
15+
`os build` now runs the same check `os validate` runs, right after the schema
16+
check and before anything is written, and prints the message the server prints
17+
at boot. The text form and `--json` both exit `1`, and no artifact is written. The
18+
`--json` failure payload is `{ success: false, errors, warnings, conversions }`,
19+
with one `errors` entry per refused container: `path` (for example `views[0]`, or
20+
`packages[1].manifest.views[0]` in a multi-package stack), `code: 'VALIDATION_ERROR'`,
21+
`httpStatus: 400` and `message`, the same rows `os validate --json` reports. A stack
22+
the server accepts builds exactly as before, with the same output.
23+
24+
**Fix:** remove the container's `name`, or set it to the object name the message names.

‎packages/cli/src/commands/compile.ts‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ import {
5959
formatPermissionSetNameCollisions,
6060
} from '../utils/permission-set-name-collisions.js';
6161
import type { PermissionSetNameCollisionDiagnostic } from '@objectstack/plugin-security';
62+
// [#20393] The boot registrar's divergent view-container `name` refusal — the
63+
// walk `os validate` step 2c runs, over `@objectstack/objectql`'s one judge.
64+
import { findViewContainerNameRefusals } from '../utils/view-container-names.js';
6265

6366
export default class Compile extends Command {
6467
static override description = 'Compile ObjectStack configuration to JSON artifact';
@@ -378,6 +381,48 @@ export default class Compile extends Command {
378381
this.exit(1);
379382
}
380383

384+
// 3a. [#20393] The boot registrar's divergent view-container `name`
385+
// refusal — the SAME walk `os validate` runs at its step 2c, over the
386+
// same judge (`viewContainerNameRefusal`, `@objectstack/objectql`)
387+
// `ObjectQL.registerMetadataCollections` throws the answer of. This
388+
// door used to exit 0 on `{ name: 'order_line', object:
389+
// 'my_app_order_line', list: {…} }` and WRITE an artifact carrying
390+
// it, which `os serve` then refused at boot — the command that ships
391+
// shipping the failure.
392+
//
393+
// ⛔ One judge, not a second rule, and not a second walk: the call is
394+
// the one `validate.ts` makes, so the two doors cannot disagree about
395+
// which `views:` entries boot registers or under which package id,
396+
// and the message is the runtime's own, verbatim.
397+
//
398+
// The input is `result.data`, the parsed stack this command
399+
// serializes: every `views:` entry the artifact carries — top level,
400+
// or each `packages[i].manifest` body — is the one judged here
401+
// (step 4 adds docs and `runtimeModule`, never a view). So the
402+
// verdict is the one boot reaches on the artifact.
403+
//
404+
// Right after the parse, ahead of the rule table and of every
405+
// artifact write, mirroring `os validate`: this is the runtime's own
406+
// accept set, the same class as the schema. The `--json` face is the
407+
// schema exit's envelope just above (`errors`, as `os validate --json`
408+
// carries these rows); the text face is `os validate`'s. No step
409+
// line, as on `os validate`: a passing build prints what it printed.
410+
const containerNameRefusals = findViewContainerNameRefusals(result.data as Record<string, unknown>);
411+
if (containerNameRefusals.length > 0) {
412+
if (flags.json) {
413+
await emitJson({ success: false, errors: containerNameRefusals, warnings: warningsSoFar(), conversions: conversionNotices }, 0, { compact: true });
414+
this.exit(1);
415+
}
416+
const n = containerNameRefusals.length;
417+
console.log('');
418+
printError(`The server would refuse this stack at boot (${n} view container${n > 1 ? 's' : ''})`);
419+
printBulletList(
420+
containerNameRefusals.map((r) => r.message),
421+
{ noun: 'view-container refusal(s)', remedy: JSON_FULL_LIST_REMEDY },
422+
);
423+
this.exit(1);
424+
}
425+
381426
// 3b. The author-time rule registry (#4409) — one table, three commands.
382427
// `os build` was the WEAKEST of the three authoring gates before it:
383428
// it published stacks `os validate` or `os lint` refuse, because the

‎packages/cli/src/commands/validate.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -350,8 +350,8 @@ export default class Validate extends Command {
350350
// Right after the parse, ahead of the rule table: this is the
351351
// runtime's own accept set, the same class as the schema, and
352352
// nothing below it is worth reading about a stack the server will
353-
// not load. `os build` does not run it (see the ledger row in
354-
// `test/validate-build-gate-parity.test.ts`).
353+
// not load. `os build` runs the same call at its step 3a (#20393);
354+
// `test/validate-build-gate-parity.test.ts` holds both doors to it.
355355
const containerNameRefusals = findViewContainerNameRefusals(result.data as Record<string, unknown>);
356356
if (containerNameRefusals.length > 0) {
357357
if (flags.json) {
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20393] `os build` refuses a `views:` container whose own `name` disagrees
5+
* with the object key it binds to — the stack `os serve` refuses at boot —
6+
* says so in the boot registrar's own words, and writes NO artifact.
7+
*
8+
* ## The defect, measured before the fix
9+
*
10+
* On an `os init -t app` project with a view `{ name: 'order_line', object:
11+
* 'my_app_order_line', list: {…} }`, `os build` exited 0 and wrote
12+
* `dist/objectstack.json` carrying that container; `os serve`, booting that
13+
* artifact with no config, exited 1 with "Invalid `views:` container from
14+
* manifest 'com.example.my-app': the container's own `name` is 'order_line',
15+
* which disagrees with the object key it binds to, 'my_app_order_line' …".
16+
* `os validate` had refused the same stack since #20331; the build — the door
17+
* that SHIPS — did not, so it shipped the failure.
18+
*
19+
* ## One judge, one walk
20+
*
21+
* `compile.ts` makes the call `validate.ts` makes: `findViewContainerNameRefusals`
22+
* over the parsed stack, which hands each entry to `@objectstack/objectql`'s
23+
* `viewContainerNameRefusal` — the function the boot loop throws the answer of.
24+
* So the pins below assert EQUALITY with the message the boot registrar
25+
* actually throws for the same payload, driven through `ObjectQL.registerApp`,
26+
* rather than a literal copy of the words.
27+
*
28+
* The CLI runs through `bin/run-dev.js` (source, via tsx); its dependencies —
29+
* `@objectstack/objectql` among them — resolve through `exports` to `dist/`.
30+
*/
31+
32+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
33+
import { execFile } from 'node:child_process';
34+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from 'node:fs';
35+
import { tmpdir } from 'node:os';
36+
import { join, resolve } from 'node:path';
37+
import { fileURLToPath } from 'node:url';
38+
import { ObjectQL } from '@objectstack/objectql';
39+
import { childEnv } from './helpers/serve-process.js';
40+
41+
const HERE = resolve(fileURLToPath(import.meta.url), '..');
42+
const CLI = resolve(HERE, '../bin/run-dev.js');
43+
const TSX = resolve(HERE, '../../../node_modules/.bin/tsx');
44+
/** A cold `tsx` spawn of the CLI source entry runs well past vitest's 5 s default. */
45+
const SPAWN_TIMEOUT_MS = 120_000;
46+
/** `os build`'s default `--output`, relative to the working directory. */
47+
const ARTIFACT = join('dist', 'objectstack.json');
48+
49+
interface Run {
50+
code: number;
51+
stdout: string;
52+
stderr: string;
53+
}
54+
55+
function runCli(args: string[], cwd: string): Promise<Run> {
56+
return new Promise((resolvePromise) => {
57+
execFile(
58+
TSX,
59+
[CLI, ...args],
60+
{ cwd, maxBuffer: 16 * 1024 * 1024, env: childEnv({ NO_COLOR: '1' }) },
61+
(err, stdout, stderr) => {
62+
resolvePromise({
63+
code: err ? (typeof (err as { code?: unknown }).code === 'number' ? (err as unknown as { code: number }).code : 1) : 0,
64+
stdout: String(stdout),
65+
stderr: String(stderr),
66+
});
67+
},
68+
);
69+
});
70+
}
71+
72+
function payloadOf(run: Run, label: string): Record<string, any> {
73+
try {
74+
return JSON.parse(run.stdout) as Record<string, any>;
75+
} catch {
76+
throw new Error(`${label}: stdout was not one JSON document (exit ${run.code})\n${run.stdout}\n${run.stderr}`);
77+
}
78+
}
79+
80+
const NS = 'bvcn';
81+
const ID = `com.example.${NS}`;
82+
const OBJECT = `${NS}_order_line`;
83+
84+
const orderLineObject = (name: string) => ({
85+
name,
86+
label: 'Order Line',
87+
sharingModel: 'private',
88+
fields: { name: { type: 'text', label: 'Name' } },
89+
});
90+
91+
const container = (object: string, viewName: string | undefined) => ({
92+
...(viewName === undefined ? {} : { name: viewName }),
93+
label: 'Order Line',
94+
object,
95+
list: { type: 'grid', columns: [{ field: 'name' }] },
96+
});
97+
98+
/** A one-package stack, as data — written to disk as the config AND handed to boot. */
99+
function stack(viewName: string | undefined): Record<string, unknown> {
100+
return {
101+
manifest: { id: ID, name: NS, version: '1.0.0', type: 'app', namespace: NS },
102+
objects: [orderLineObject(OBJECT)],
103+
views: [container(OBJECT, viewName)],
104+
};
105+
}
106+
107+
/**
108+
* A `packages[]` stack (ADR-0130 D4): the load path registers each body under
109+
* its own id and never the top level. The divergent container sits in the
110+
* SECOND body; the first carries a matching one, which must not be reported.
111+
*/
112+
const CORE_ID = 'com.example.bvcn-core';
113+
const ORDERS_ID = 'com.example.bvcn-orders';
114+
const ordersBody = {
115+
id: ORDERS_ID,
116+
name: 'bvcn_orders',
117+
version: '1.0.0',
118+
type: 'app',
119+
objects: [orderLineObject('bvcn_orders_line')],
120+
views: [container('bvcn_orders_line', 'order_line')],
121+
};
122+
function packagesStack(): Record<string, unknown> {
123+
return {
124+
packages: [
125+
{
126+
manifest: {
127+
id: CORE_ID,
128+
name: 'bvcn_core',
129+
version: '1.0.0',
130+
type: 'app',
131+
objects: [orderLineObject('bvcn_core_line')],
132+
views: [container('bvcn_core_line', 'bvcn_core_line')],
133+
},
134+
},
135+
{ manifest: ordersBody },
136+
],
137+
};
138+
}
139+
140+
/**
141+
* What the boot registrar throws for a payload, driven the way the load path
142+
* drives it: one body into `registerApp`.
143+
*/
144+
function bootRefusal(payload: Record<string, unknown>): any {
145+
try {
146+
new ObjectQL().registerApp(payload);
147+
} catch (e) {
148+
return e;
149+
}
150+
return undefined;
151+
}
152+
153+
/** A one-package stack (or its artifact) as `AppPlugin` hands it: `{ ...manifest, ...stack }`. */
154+
const asBootPayload = (s: Record<string, unknown>) => ({ ...(s.manifest as Record<string, unknown>), ...s });
155+
156+
const dirs: Record<string, string> = {};
157+
let root = '';
158+
159+
beforeAll(() => {
160+
root = mkdtempSync(join(tmpdir(), 'os-build-view-container-name-'));
161+
const make = (label: string, s: Record<string, unknown>) => {
162+
const dir = join(root, label);
163+
mkdirSync(dir, { recursive: true });
164+
writeFileSync(join(dir, 'objectstack.config.ts'), `export default ${JSON.stringify(s, null, 2)};\n`);
165+
dirs[label] = dir;
166+
};
167+
make('divergent-json', stack('order_line'));
168+
make('divergent-text', stack('order_line'));
169+
make('divergent-packages', packagesStack());
170+
make('matching', stack(OBJECT));
171+
make('anonymous', stack(undefined));
172+
});
173+
174+
afterAll(() => {
175+
if (root) rmSync(root, { recursive: true, force: true });
176+
});
177+
178+
describe('#20393 — os build refuses what the boot registrar refuses, in its words, and ships nothing', () => {
179+
it('premise: the boot registrar refuses both divergent payloads, and accepts both controls', () => {
180+
// Without this, the pins below could agree with a boot loop that had
181+
// stopped refusing anything.
182+
const refusal = bootRefusal(asBootPayload(stack('order_line')));
183+
expect(refusal).toBeInstanceOf(Error);
184+
expect(refusal.code).toBe('VALIDATION_ERROR');
185+
expect(bootRefusal(ordersBody)).toBeInstanceOf(Error);
186+
expect(bootRefusal(asBootPayload(stack(OBJECT)))).toBeUndefined();
187+
expect(bootRefusal(asBootPayload(stack(undefined)))).toBeUndefined();
188+
});
189+
190+
it('THE PIN: --json exits 1, reports the boot registrar\'s refusal verbatim, and writes no artifact', async () => {
191+
const dir = dirs['divergent-json'];
192+
const run = await runCli(['build', '--json'], dir);
193+
const payload = payloadOf(run, 'divergent --json');
194+
expect(run.code).toBe(1);
195+
expect(payload.success).toBe(false);
196+
expect(Array.isArray(payload.errors)).toBe(true);
197+
expect(payload.errors).toHaveLength(1);
198+
199+
const boot = bootRefusal(asBootPayload(stack('order_line')));
200+
const [row] = payload.errors;
201+
expect(row.message).toBe(boot.message);
202+
// The envelope boot throws with, carried onto the row.
203+
expect(row.code).toBe(boot.code);
204+
expect(row.httpStatus).toBe(boot.httpStatus);
205+
expect(row.path).toBe('views[0]');
206+
// The point of the card: the door that ships ships nothing.
207+
expect(existsSync(join(dir, ARTIFACT)), 'os build wrote an artifact the server refuses at boot').toBe(false);
208+
}, SPAWN_TIMEOUT_MS);
209+
210+
it('the text face exits 1, prints the same words, and writes no artifact', async () => {
211+
const dir = dirs['divergent-text'];
212+
const run = await runCli(['build'], dir);
213+
expect(run.code).toBe(1);
214+
expect(run.stdout).not.toContain('Build complete');
215+
expect(run.stdout).toContain(bootRefusal(asBootPayload(stack('order_line'))).message);
216+
expect(existsSync(join(dir, ARTIFACT))).toBe(false);
217+
}, SPAWN_TIMEOUT_MS);
218+
219+
it('a `packages[]` stack: the body boot refuses is refused under its own id, and the matching one is not', async () => {
220+
const dir = dirs['divergent-packages'];
221+
const run = await runCli(['build', '--json'], dir);
222+
const payload = payloadOf(run, 'packages --json');
223+
expect(run.code).toBe(1);
224+
expect(payload.success).toBe(false);
225+
expect(payload.errors).toHaveLength(1);
226+
const [row] = payload.errors;
227+
expect(row.path).toBe('packages[1].manifest.views[0]');
228+
expect(row.message).toBe(bootRefusal(ordersBody).message);
229+
expect(row.message).toContain(`from manifest '${ORDERS_ID}'`);
230+
expect(existsSync(join(dir, ARTIFACT))).toBe(false);
231+
}, SPAWN_TIMEOUT_MS);
232+
233+
it('CONTROL: a container whose `name` matches its object builds, and the artifact it writes boots', async () => {
234+
const dir = dirs.matching;
235+
const run = await runCli(['build', '--json'], dir);
236+
const payload = payloadOf(run, 'matching --json');
237+
expect(payload.success, JSON.stringify(payload.errors ?? payload.error)).toBe(true);
238+
expect(run.code).toBe(0);
239+
const artifact = JSON.parse(readFileSync(join(dir, ARTIFACT), 'utf8')) as Record<string, unknown>;
240+
expect(bootRefusal(asBootPayload(artifact))).toBeUndefined();
241+
}, SPAWN_TIMEOUT_MS);
242+
243+
it('CONTROL: a container with no `name` builds — the shape boot also accepts', async () => {
244+
const dir = dirs.anonymous;
245+
const run = await runCli(['build', '--json'], dir);
246+
const payload = payloadOf(run, 'anonymous --json');
247+
expect(payload.success, JSON.stringify(payload.errors ?? payload.error)).toBe(true);
248+
expect(run.code).toBe(0);
249+
expect(existsSync(join(dir, ARTIFACT))).toBe(true);
250+
}, SPAWN_TIMEOUT_MS);
251+
});

‎packages/cli/test/validate-build-gate-parity.test.ts‎

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,21 @@ const SHARED_NON_REGISTRY_GATES: readonly string[] = [
123123
// `kind:'html'` page to check. Not a registry rule: the manifest is a file
124124
// in the working directory, not part of the stack a rule is handed.
125125
'resolveJsxGateManifest',
126+
// [#20331, #20393] The boot registrar's divergent view-container `name`
127+
// refusal (`viewContainerNameRefusal`, @objectstack/objectql), judged at
128+
// author time by the same function boot throws the answer of. Not a registry
129+
// rule: the WALK decides which `views:` entries boot registers and under
130+
// which package id — the top level under the manifest's id, or each
131+
// `packages[i].manifest` body under its own — which is the artifact's
132+
// package reading, not the one stack a rule is handed.
133+
//
134+
// ⭐ This row is the #20331 VALIDATE_ONLY_GATES entry CLOSED. That entry read
135+
// "`os build` still emits an artifact carrying such a container, which the
136+
// runtime refuses when it loads it", and it was right. `compile.ts` now makes
137+
// the same call (#20393), so the row moved here, where both doors are held to
138+
// it — deleted there rather than reworded, for the reason the
139+
// `runPerPackageAuthoringRules` row above gives.
140+
'findViewContainerNameRefusals',
126141
];
127142

128143
/**
@@ -159,14 +174,14 @@ const BUILD_ONLY_GATES: Readonly<Record<string, string>> = {
159174
* validate-only` below: a row whose gate `validate.ts` no longer calls is
160175
* stale, and a row whose gate `compile.ts` now calls too belongs in
161176
* SHARED_NON_REGISTRY_GATES instead.
177+
*
178+
* EMPTY is this ledger's steady state: every row is a gap the build carries.
179+
* Its one row, `findViewContainerNameRefusals` (#20331), moved to
180+
* SHARED_NON_REGISTRY_GATES when `compile.ts` gained the call (#20393). The
181+
* ledger stays, empty, because it is the only honest place the closed roster
182+
* has for the next validate-only gate.
162183
*/
163-
const VALIDATE_ONLY_GATES: Readonly<Record<string, string>> = {
164-
findViewContainerNameRefusals:
165-
'[#20331] The boot registrar\'s divergent view-container `name` refusal ' +
166-
'(`viewContainerNameRefusal`, @objectstack/objectql), judged at author time by the same function ' +
167-
'boot throws the answer of. Wired into validate.ts only, by that card\'s scope: `os build` still ' +
168-
'emits an artifact carrying such a container, which the runtime refuses when it loads it.',
169-
};
184+
const VALIDATE_ONLY_GATES: Readonly<Record<string, string>> = {};
170185

171186
/**
172187
* Everything else the two commands call, and the reason each one is NOT an

0 commit comments

Comments
 (0)