From 868c1914b52b448242401a3819bf62cb64a9b5f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 04:44:51 +0000 Subject: [PATCH 1/4] fix(objectql,cli): os validate runs the boot registrar's view-container name check (#20331) The divergent view-container `name` refusal moves out of `ObjectQL.registerMetadataCollections` into `viewContainerNameRefusal` (`@objectstack/objectql`), unchanged: the boot loop throws what it returns. `os validate` calls the same function over the views the load path registers and reports the refusal in the boot registrar's words, exiting 1 on a stack it used to pass while `os serve` refused it. Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude --- .../20331-validate-view-container-name.md | 30 +++ packages/cli/src/commands/validate.ts | 49 +++++ .../src/utils/view-container-names.test.ts | 95 +++++++++ .../cli/src/utils/view-container-names.ts | 112 +++++++++++ .../test/validate-build-gate-parity.test.ts | 48 ++++- .../test/validate-view-container-name.test.ts | 184 ++++++++++++++++++ packages/objectql/src/engine.ts | 66 ++----- packages/objectql/src/index.ts | 8 + .../src/view-container-name-refusal.test.ts | 120 ++++++++++++ .../src/view-container-name-refusal.ts | 101 ++++++++++ 10 files changed, 762 insertions(+), 51 deletions(-) create mode 100644 .changeset/20331-validate-view-container-name.md create mode 100644 packages/cli/src/utils/view-container-names.test.ts create mode 100644 packages/cli/src/utils/view-container-names.ts create mode 100644 packages/cli/test/validate-view-container-name.test.ts create mode 100644 packages/objectql/src/view-container-name-refusal.test.ts create mode 100644 packages/objectql/src/view-container-name-refusal.ts diff --git a/.changeset/20331-validate-view-container-name.md b/.changeset/20331-validate-view-container-name.md new file mode 100644 index 00000000000..704ce7ce2b7 --- /dev/null +++ b/.changeset/20331-validate-view-container-name.md @@ -0,0 +1,30 @@ +--- +'@objectstack/cli': patch +'@objectstack/objectql': patch +--- + +fix(cli): `os validate` refuses a `views:` container whose own `name` disagrees with the object it binds to, the stack the server refuses at boot (#20331) + +Clause-②: no + +A view container is registered under the object it binds to. When its own `name` +is set to something else, for example `{ name: 'order_line', object: 'my_app_order_line', list: { … } }`, +the server refuses the whole stack at boot. `os validate` used to pass that stack +at exit 0, so the first sign of the mistake was a server that would not start. + +`os validate` now runs the same check the server runs at boot and prints the same +message. The text form and `--json` both exit `1`. The `--json` failure payload lists +one `errors` entry per refused container, with `path` (for example `views[0]`, or +`packages[1].manifest.views[0]` in a multi-package stack), `code: 'VALIDATION_ERROR'`, +`httpStatus: 400` and `message`. Every other exit, and the success payload, are +unchanged. + +**Fix:** remove the container's `name`, or set it to the object name the message names. + +`@objectstack/objectql` exports the check as `viewContainerNameRefusal(container, sourceLabel, ownerId)`, +with its type `ViewContainerNameRefusal`. It returns the refusal the boot registrar +throws, or `undefined`. The boot registrar now calls this function. What it refuses, +its message and its `VALIDATION_ERROR` / `400` envelope are unchanged. + +Not changed: `os build` does not run this check, so it still writes an artifact +carrying such a container, and the server refuses that artifact when it loads it. diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index a08b1e4739b..a230b544f44 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -58,6 +58,10 @@ import { formatPermissionSetNameCollisions, } from '../utils/permission-set-name-collisions.js'; import type { PermissionSetNameCollisionDiagnostic } from '@objectstack/plugin-security'; +// [#20331] The boot registrar's divergent view-container `name` refusal, walked +// over the parsed stack the way the load path registers it. The verdict is +// `@objectstack/objectql`'s; see the module header. +import { findViewContainerNameRefusals } from '../utils/view-container-names.js'; export default class Validate extends Command { static override description = @@ -328,6 +332,51 @@ export default class Validate extends Command { this.exit(1); } + // 2c. [#20331] The boot registrar's divergent view-container `name` + // refusal, judged here by the SAME function + // `ObjectQL.registerMetadataCollections` throws the answer of + // (`viewContainerNameRefusal`, `@objectstack/objectql`). This door + // used to pass `{ name: 'order_line', object: 'my_app_order_line', + // list: {…} }` at exit 0 while `os serve` refused the same stack at + // boot — the silent-validator shape, on the command whose whole job + // is to say what the runtime will accept. + // + // ⛔ One judge, not a second rule: not an `@objectstack/lint` + // registry member and not a re-spelling of the check. The helper + // owns only the WALK (which `views:` entries boot registers, under + // which package id); the verdict and its words are the runtime's, + // so the author reads here exactly what the server would print. + // + // Right after the parse, ahead of the rule table: this is the + // runtime's own accept set, the same class as the schema, and + // nothing below it is worth reading about a stack the server will + // not load. `os build` does not run it (see the ledger row in + // `test/validate-build-gate-parity.test.ts`). + const containerNameRefusals = findViewContainerNameRefusals(result.data as Record); + if (containerNameRefusals.length > 0) { + if (flags.json) { + await emitJson({ + valid: false, + errors: containerNameRefusals, + // [#12047] Every exit carries the lists the run has computed so + // far — here the pre-parse ones only. + warnings: warningsSoFar(), + // [#12125] Computed at step 2, above this gate. + conversions: conversionNotices, + duration: timer.elapsed(), + }); + this.exit(1); + } + const n = containerNameRefusals.length; + console.log(''); + printError(`The server would refuse this stack at boot (${n} view container${n > 1 ? 's' : ''})`); + printBulletList( + containerNameRefusals.map((r) => r.message), + { noun: 'view-container refusal(s)', remedy: JSON_FULL_LIST_REMEDY }, + ); + this.exit(1); + } + // 3. The author-time rule registry (#4409). Every rule the three authoring // commands share — expressions, view shape, widget/action/filter/name // references, SDUI styling, page sources, security posture, the CLI's diff --git a/packages/cli/src/utils/view-container-names.test.ts b/packages/cli/src/utils/view-container-names.test.ts new file mode 100644 index 00000000000..ce5eeec21d1 --- /dev/null +++ b/packages/cli/src/utils/view-container-names.test.ts @@ -0,0 +1,95 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20331] `findViewContainerNameRefusals` — the WALK `os validate` runs the + * boot registrar's view-container `name` judgment over. + * + * The verdict is `@objectstack/objectql`'s `viewContainerNameRefusal`, the + * function the boot loop throws the answer of; its own file pins that. What + * this module owns, and what is pinned here, is which `views:` entries it hands + * that judge and under which package id — the load path's answer: + * + * - no `packages[]` → the top-level `views`, owned by `manifest.id`; + * - `packages[]` → each body's own `views`, owned by that package, and the + * top level NOT at all (the load path does not register from it). + * + * Every row's message is asserted EQUAL to the judge's answer for the same + * entry, source label and id — never re-spelled — so this file cannot drift + * into a second copy of the words it exists to repeat. + */ + +import { describe, expect, it } from 'vitest'; +import { viewContainerNameRefusal } from '@objectstack/objectql'; +import { findViewContainerNameRefusals } from './view-container-names.js'; + +const ID = 'com.example.vcn'; + +const view = (extra: Record) => ({ + object: 'vcn_order_line', + list: { type: 'grid', columns: [{ field: 'name' }] }, + ...extra, +}); + +const divergent = view({ name: 'order_line' }); + +const manifest = (id: string) => ({ id, name: id, version: '1.0.0', type: 'app' }); + +describe('#20331 — findViewContainerNameRefusals walks what the load path registers', () => { + it('a one-package stack: the top-level `views`, under the manifest id, in the judge\'s words', () => { + const rows = findViewContainerNameRefusals({ manifest: manifest(ID), views: [divergent] }); + expect(rows).toHaveLength(1); + const expected = viewContainerNameRefusal(divergent, 'manifest', ID); + expect(expected).toBeDefined(); + expect(rows[0]).toEqual({ + path: 'views[0]', + code: 'VALIDATION_ERROR', + httpStatus: 400, + message: expected!.message, + }); + expect(rows[0].message).toContain(`from manifest '${ID}'`); + }); + + it('reports EVERY divergent container, located, and none of the others', () => { + const rows = findViewContainerNameRefusals({ + manifest: manifest(ID), + views: [ + view({ name: 'vcn_order_line' }), + divergent, + view({}), + view({ name: 'order_line_two' }), + ], + }); + expect(rows.map((r) => r.path)).toEqual(['views[1]', 'views[3]']); + }); + + it('CONTROL: a matching `name`, an absent one, and no `views` at all report nothing', () => { + expect(findViewContainerNameRefusals({ manifest: manifest(ID), views: [view({ name: 'vcn_order_line' })] })) + .toEqual([]); + expect(findViewContainerNameRefusals({ manifest: manifest(ID), views: [view({})] })).toEqual([]); + expect(findViewContainerNameRefusals({ manifest: manifest(ID) })).toEqual([]); + }); + + it('a `packages[]` stack: each body\'s own `views`, under THAT package\'s id', () => { + const rows = findViewContainerNameRefusals({ + packages: [ + { manifest: { ...manifest('com.example.core'), views: [view({ name: 'vcn_order_line' })] } }, + { manifest: { ...manifest('com.example.orders'), views: [divergent] } }, + ], + }); + expect(rows).toHaveLength(1); + expect(rows[0].path).toBe('packages[1].manifest.views[0]'); + expect(rows[0].message).toBe(viewContainerNameRefusal(divergent, 'manifest', 'com.example.orders')!.message); + }); + + it('a `packages[]` stack: the top-level `views` is NOT judged, because the load path does not register it', () => { + // `resolveArtifactPackageOrder` returns the package bodies alone once + // `packages` is present, so a top-level copy never reaches the registrar. + // Judging it here would refuse a stack the server loads. + const rows = findViewContainerNameRefusals({ + manifest: manifest(ID), + views: [divergent], + packages: [{ manifest: { ...manifest('com.example.core'), views: [view({ name: 'vcn_order_line' })] } }], + }); + expect(rows).toEqual([]); + }); +}); diff --git a/packages/cli/src/utils/view-container-names.ts b/packages/cli/src/utils/view-container-names.ts new file mode 100644 index 00000000000..75e48e4be56 --- /dev/null +++ b/packages/cli/src/utils/view-container-names.ts @@ -0,0 +1,112 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `os validate`'s author-time half of the boot registrar's divergent + * view-container `name` refusal (#20331). + * + * ## The defect + * + * A `views:` container whose own `name` disagrees with the object key it binds + * to — `{ name: 'order_line', object: 'my_app_order_line', list: {…} }` — is + * refused at boot by `ObjectQL.registerMetadataCollections` (#14666, #7378 row + * 1). `os validate` had no counterpart, so it exited 0 on that stack and + * `os serve` then refused it. `os validate` is the author-time judge of what + * the runtime will accept (NORTH-STAR road step ①), and a green validate + * followed by a boot refusal is the silent-validator shape. + * + * ## One judge, not a second rule + * + * {@link viewContainerNameRefusal} is `@objectstack/objectql`'s, the SAME + * function the boot registrar throws the answer of. ⛔ Not an `@objectstack/lint` + * rule and not a re-spelling of the check here: a twin agrees with the + * runtime only until one of the two is edited, which is the drift this card + * exists to close. What this module owns is the WALK — which `views:` entries + * boot judges, and under which package id — and nothing about the verdict. + * + * ## The walk is the boot path's + * + * `AppPlugin` hands the manifest service `{ ...stack.manifest, ...stack }`; + * `resolveArtifactPackageOrder` returns that payload itself when it carries no + * `packages` key and each `packages[i].manifest` body otherwise (ADR-0130 D4); + * `ObjectQL.registerApp(body)` then runs `registerMetadataCollections(body, + * body.id || body.name, 'manifest')`. So: + * + * - no `packages[]` → the top-level `views`, owned by the manifest's id; + * - `packages[]` → each body's own `views`, owned by that package's id, + * and ⛔ NOT the top level, which the load path does not register from. + * + * The package id is read through the owners that already exist for it: the + * runtime's `artifactPackageId` (`@objectstack/core` — "every seam that has to + * name a package reads the id through THIS function") for the one-package + * payload, and this package's `artifactPackages` for `packages[]`, the reader + * both `os build` and `os validate` already walk with. + * + * ⚠️ Bound, stated rather than hidden: a nested `plugins[]` entry's `views` is + * registered by boot too (label `nested plugin`), and is NOT walked here. The + * stack schema types `plugins` as `unknown[]` — in an authored config they are + * runtime plugin instances, not metadata bundles — so this door has no parsed + * shape to walk there. + * + * Reads the PARSED stack — what `defineStack()` hands the boot wrap, and what + * `os build` serializes. + */ + +import { artifactPackageId } from '@objectstack/core'; +import { viewContainerNameRefusal } from '@objectstack/objectql'; + +import { artifactPackages } from './artifact-packages.js'; + +/** One refusal, located. `message` is the boot registrar's, verbatim. */ +export interface ViewContainerNameRefusalRow { + /** Where the entry sits in the parsed stack, e.g. `views[0]`. */ + path: string; + /** The ADR-0112 code the boot registrar throws with. */ + code: string; + /** The HTTP status the boot registrar throws with. */ + httpStatus: number; + /** The refusal, in the boot registrar's own words. */ + message: string; +} + +type AnyRec = Record; + +const asRec = (v: unknown): AnyRec | undefined => + v && typeof v === 'object' && !Array.isArray(v) ? (v as AnyRec) : undefined; + +/** + * Every `views:` container in this stack that the boot registrar would refuse + * for a divergent `name`, in the order boot meets them within each body. + * + * Returns `[]` for a stack the boot registrar accepts on this axis. + */ +export function findViewContainerNameRefusals(parsed: AnyRec): ViewContainerNameRefusalRow[] { + const bodies: Array<{ at: string; ownerId: string | undefined; views: unknown }> = []; + // The resolver's own branch test (`declared === undefined`). On the PARSED + // stack a present `packages` is an array — `ArtifactPackageSchema[]`, + // `.optional()`, so `null` and every non-array were refused at the parse. + if (parsed.packages !== undefined) { + for (const pkg of artifactPackages(parsed)) { + bodies.push({ at: `packages[${pkg.index}].manifest.views`, ownerId: pkg.id, views: pkg.body.views }); + } + } else { + const manifest = asRec(parsed.manifest); + const payload = manifest ? { ...manifest, ...parsed } : parsed; + bodies.push({ at: 'views', ownerId: artifactPackageId(payload), views: parsed.views }); + } + + const rows: ViewContainerNameRefusalRow[] = []; + for (const { at, ownerId, views } of bodies) { + if (!Array.isArray(views)) continue; + views.forEach((entry, index) => { + const refusal = viewContainerNameRefusal(entry, 'manifest', ownerId); + if (!refusal) return; + rows.push({ + path: `${at}[${index}]`, + code: refusal.code, + httpStatus: refusal.httpStatus, + message: refusal.message, + }); + }); + } + return rows; +} diff --git a/packages/cli/test/validate-build-gate-parity.test.ts b/packages/cli/test/validate-build-gate-parity.test.ts index 66480c839c0..63f80975f49 100644 --- a/packages/cli/test/validate-build-gate-parity.test.ts +++ b/packages/cli/test/validate-build-gate-parity.test.ts @@ -47,9 +47,10 @@ const COMMANDS_DIR = join(__dirname, '..', 'src', 'commands'); * the whole point of the file. * * ⭐ [#18491] This roster is now CLOSED rather than advisory: every bare - * identifier either command calls must appear in exactly one of the three - * ledgers in this file — here, in {@link BUILD_ONLY_GATES}, or in - * {@link NOT_A_GATE} with the reason it is not a gate. A name nobody + * identifier either command calls must appear in exactly one of the + * ledgers in this file — here, in {@link BUILD_ONLY_GATES}, in + * {@link VALIDATE_ONLY_GATES} (#20331), or in {@link NOT_A_GATE} with the + * reason it is not a gate. A name nobody * classified fails, so a gate arrives here by being ADDED to the commands, not * by being spelled a particular way. */ @@ -141,6 +142,32 @@ const BUILD_ONLY_GATES: Readonly> = { buildRuntimeBundle: 'Emits the objectstack-runtime.{hash}.mjs sibling module. Artifact output by definition.', }; +/** + * Gates `os validate` runs that `os build` does not — the SUPERSET direction of + * this file's contract, which leaves `os validate` stricter than the build and + * never weaker than it. + * + * ⭐ [#20331] Why this ledger exists. Before it, the closed roster had no honest + * place for a gate wired into `validate.ts` alone: SHARED_NON_REGISTRY_GATES + * asserts both commands call it, BUILD_ONLY_GATES is the opposite direction, + * and a NOT_A_GATE row would be the false statement that ledger's header + * warns about. Each entry here is a written claim that the BUILD lacks a gate + * `os validate` has — a real gap in the build's direction, reported and not + * closed — with the reason it was not wired there. + * + * ⛔ Pruned both ways by `every VALIDATE_ONLY_GATES entry is still + * validate-only` below: a row whose gate `validate.ts` no longer calls is + * stale, and a row whose gate `compile.ts` now calls too belongs in + * SHARED_NON_REGISTRY_GATES instead. + */ +const VALIDATE_ONLY_GATES: Readonly> = { + findViewContainerNameRefusals: + '[#20331] The boot registrar\'s divergent view-container `name` refusal ' + + '(`viewContainerNameRefusal`, @objectstack/objectql), judged at author time by the same function ' + + 'boot throws the answer of. Wired into validate.ts only, by that card\'s scope: `os build` still ' + + 'emits an artifact carrying such a container, which the runtime refuses when it loads it.', +}; + /** * Everything else the two commands call, and the reason each one is NOT an * artifact-level gate. Keyed by reason so the classification is readable as a @@ -317,6 +344,7 @@ const PARITY_COMMANDS: readonly string[] = ['compile.ts', 'validate.ts']; const CLASSIFIED: ReadonlySet = new Set([ ...SHARED_NON_REGISTRY_GATES, ...Object.keys(BUILD_ONLY_GATES), + ...Object.keys(VALIDATE_ONLY_GATES), ...NOT_A_GATE_NAMES, ]); @@ -928,6 +956,7 @@ describe('os validate is the read-only superset of os build (#3782, #4409)', () const buckets: ReadonlyArray]> = [ ['SHARED_NON_REGISTRY_GATES', new Set(SHARED_NON_REGISTRY_GATES)], ['BUILD_ONLY_GATES', new Set(Object.keys(BUILD_ONLY_GATES))], + ['VALIDATE_ONLY_GATES', new Set(Object.keys(VALIDATE_ONLY_GATES))], ['NOT_A_GATE', NOT_A_GATE_NAMES], ]; const doubled = [...CLASSIFIED].filter( @@ -997,6 +1026,19 @@ describe('os validate is the read-only superset of os build (#3782, #4409)', () expect(stale, `BUILD_ONLY_GATES entries compile.ts no longer calls: ${stale.join(', ')}`).toEqual([]); }); + it('every VALIDATE_ONLY_GATES entry is still validate-only', () => { + // [#20331] Pruned in BOTH directions, because the row makes two claims: + // validate runs the gate, and the build does not. + const stale = Object.keys(VALIDATE_ONLY_GATES).filter((g) => !calls('validate.ts', g)); + expect(stale, `VALIDATE_ONLY_GATES entries validate.ts no longer calls: ${stale.join(', ')}`).toEqual([]); + const nowShared = Object.keys(VALIDATE_ONLY_GATES).filter((g) => calls('compile.ts', g)); + expect( + nowShared, + `compile.ts now calls ${nowShared.join(', ')} too — the build gap is closed. Move the row to ` + + `SHARED_NON_REGISTRY_GATES, where both commands are held to it.`, + ).toEqual([]); + }); + /** * The same drift, one layer down and easier to miss: not "does this command * run the gate" but "does it LISTEN to what the gate says". The ADR-0087 D2 diff --git a/packages/cli/test/validate-view-container-name.test.ts b/packages/cli/test/validate-view-container-name.test.ts new file mode 100644 index 00000000000..11863b7993b --- /dev/null +++ b/packages/cli/test/validate-view-container-name.test.ts @@ -0,0 +1,184 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20331] `os validate` refuses a `views:` container whose own `name` + * disagrees with the object key it binds to — the stack `os serve` refuses at + * boot — and says so in the boot registrar's own words. + * + * ## The defect, measured before the fix + * + * On an `os init -t app` project with a view `{ name: 'order_line', object: + * 'my_app_order_line', list: {…} }`, `os validate` exited 0 (`✓ Validation + * passed`, `UI: 1 Views`) and `os serve --dev` exited 1 with "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 author-time judge of what the runtime accepts + * passed a stack the runtime refuses. + * + * ## One judge + * + * The check lives in `@objectstack/objectql`'s `viewContainerNameRefusal`; the + * boot loop throws what it returns and `os validate` reports it. So the + * headline pin below asserts EQUALITY with the message the boot registrar + * actually throws for the same stack — driven through `ObjectQL.registerApp` + * with the payload `AppPlugin` hands it — rather than a literal copy of the + * words, which would be a second spelling of them. + * + * The CLI runs through `bin/run-dev.js` (source, via tsx); its dependencies — + * `@objectstack/objectql` among them — resolve through `exports` to `dist/`. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { execFile } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { ObjectQL } from '@objectstack/objectql'; +import { childEnv } from './helpers/serve-process.js'; + +const HERE = resolve(fileURLToPath(import.meta.url), '..'); +const CLI = resolve(HERE, '../bin/run-dev.js'); +const TSX = resolve(HERE, '../../../node_modules/.bin/tsx'); + +interface Run { + code: number; + stdout: string; + stderr: string; +} + +function runCli(args: string[], cwd: string): Promise { + return new Promise((resolvePromise) => { + execFile( + TSX, + [CLI, ...args], + { cwd, maxBuffer: 16 * 1024 * 1024, env: childEnv({ NO_COLOR: '1' }) }, + (err, stdout, stderr) => { + resolvePromise({ + code: err ? (typeof (err as { code?: unknown }).code === 'number' ? (err as unknown as { code: number }).code : 1) : 0, + stdout: String(stdout), + stderr: String(stderr), + }); + }, + ); + }); +} + +function payloadOf(run: Run, label: string): Record { + try { + return JSON.parse(run.stdout) as Record; + } catch { + throw new Error(`${label}: stdout was not one JSON document (exit ${run.code})\n${run.stdout}\n${run.stderr}`); + } +} + +const NS = 'vcn'; +const ID = `com.example.${NS}`; +const OBJECT = `${NS}_order_line`; + +/** The stack, as data — written to disk as the config AND handed to boot. */ +function stack(viewName: string | undefined): Record { + return { + manifest: { id: ID, name: NS, version: '1.0.0', type: 'app', namespace: NS }, + objects: [ + { + name: OBJECT, + label: 'Order Line', + sharingModel: 'private', + fields: { name: { type: 'text', label: 'Name' } }, + }, + ], + views: [ + { + ...(viewName === undefined ? {} : { name: viewName }), + label: 'Order Line', + object: OBJECT, + list: { type: 'grid', columns: [{ field: 'name' }] }, + }, + ], + }; +} + +/** + * What the boot registrar throws for this stack, driven the way `AppPlugin` + * drives it: `{ ...manifest, ...stack }` into `registerApp`. + */ +function bootRefusal(s: Record): any { + const payload = { ...(s.manifest as Record), ...s }; + try { + new ObjectQL().registerApp(payload); + } catch (e) { + return e; + } + return undefined; +} + +const dirs: Record = {}; +let root = ''; + +beforeAll(() => { + root = mkdtempSync(join(tmpdir(), 'os-validate-view-container-name-')); + const make = (label: string, s: Record) => { + const dir = join(root, label); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'objectstack.config.ts'), `export default ${JSON.stringify(s, null, 2)};\n`); + dirs[label] = dir; + }; + make('divergent', stack('order_line')); + make('matching', stack(OBJECT)); + make('anonymous', stack(undefined)); +}); + +afterAll(() => { + if (root) rmSync(root, { recursive: true, force: true }); +}); + +describe('#20331 — os validate refuses what the boot registrar refuses, in its words', () => { + it('premise: the boot registrar refuses the divergent stack, and accepts both controls', () => { + // Without this, the pins below could agree with a boot loop that had + // stopped refusing anything. + const refusal = bootRefusal(stack('order_line')); + expect(refusal).toBeInstanceOf(Error); + expect(refusal.code).toBe('VALIDATION_ERROR'); + expect(bootRefusal(stack(OBJECT))).toBeUndefined(); + expect(bootRefusal(stack(undefined))).toBeUndefined(); + }); + + it('THE PIN: --json exits 1 and reports the boot registrar\'s refusal, message equal to what boot throws', async () => { + const run = await runCli(['validate', '--json'], dirs.divergent); + const payload = payloadOf(run, 'divergent --json'); + expect(run.code).toBe(1); + expect(payload.valid).toBe(false); + expect(Array.isArray(payload.errors)).toBe(true); + expect(payload.errors).toHaveLength(1); + + const boot = bootRefusal(stack('order_line')); + const [row] = payload.errors; + expect(row.message).toBe(boot.message); + // The envelope boot throws with, carried onto the row. + expect(row.code).toBe(boot.code); + expect(row.httpStatus).toBe(boot.httpStatus); + expect(row.path).toBe('views[0]'); + }); + + it('the text face exits 1 and prints the same words', async () => { + const run = await runCli(['validate'], dirs.divergent); + expect(run.code).toBe(1); + expect(run.stdout).not.toContain('Validation passed'); + expect(run.stdout).toContain(bootRefusal(stack('order_line')).message); + }); + + it('CONTROL: a container whose `name` matches its object validates', async () => { + const run = await runCli(['validate', '--json'], dirs.matching); + const payload = payloadOf(run, 'matching --json'); + expect(payload.valid, JSON.stringify(payload.errors ?? payload.error)).toBe(true); + expect(run.code).toBe(0); + }); + + it('CONTROL: a container with no `name` validates — the shape boot also accepts', async () => { + const run = await runCli(['validate', '--json'], dirs.anonymous); + const payload = payloadOf(run, 'anonymous --json'); + expect(payload.valid, JSON.stringify(payload.errors ?? payload.error)).toBe(true); + expect(run.code).toBe(0); + }); +}); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 0cc05ee8a8f..5dfc72ff1db 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -215,6 +215,9 @@ import { // for the reason the `/errors` import above states (#14680). See // `resolveMetadataItemName` below for why this registrar lost its fourth copy. import { deriveViewContainerObject } from '@objectstack/metadata/view-container'; +// [#20331] The divergent view-container `name` refusal — the ONE judge this +// registrar and `os validate` both call. +import { viewContainerNameRefusal } from './view-container-name-refusal.js'; import { bindHooksToEngine } from './hook-binder.js'; import { validateRecord, normalizeMultiValueFields, normalizeBlankTypedValues, coerceBooleanFields, ValidationError, buildFieldError, resolveFieldLabel, valueShapePostureSetByEnv, mediaPostureSetByEnv, isScannableValueShapeField, valueShapeStrictEffective, mediaStrictEffective } from './validation/record-validator.js'; import type { AdmittedValueShapeViolation, AdmittedValueShapeViolationSink } from './validation/record-validator.js'; @@ -6556,57 +6559,24 @@ export class ObjectQL implements IObjectQLEngine { // reads a non-container's own `name` FIRST. // // Scope is the ruling's named main risk, so the gate is three - // independent narrowings and each one is load-bearing: + // independent narrowings and each one is load-bearing. The first + // stays here: // * `key === 'views'` — this is the GENERIC metadata loop; no // other kind changes behaviour and they all keep the - // reconcile below; - // * `isAggregatedViewContainer(item)` — the CONTAINER branch - // only. A standalone ViewItem's `name` is its identity, not - // a binding, and it has no disagreement to make; - // * `name` present AND different. A container carrying no - // `name` is untouched (the reconcile below still mints the - // derived key onto it); so is one whose `name` already - // equals the derived key; and so is one that declares no - // binding anywhere else, because `deriveViewContainerObject` - // then derives the key FROM that same `name` and it cannot - // disagree with itself. + // reconcile below. + // The other two — the CONTAINER branch only, and `name` present + // AND different — and the message, the envelope and the reason + // the prose is this seam's own live in `viewContainerNameRefusal` + // (`view-container-name-refusal.ts`). // - // The runtime string deliberately carries NO tracker id: it is read by - // authors and operators who cannot resolve `#NNNN` - // (`check:doc-authoring`, maintainer ruling 2026-08-12). The rule it - // enforces is #7378 row 1, named in this comment instead, where the - // reader who can resolve it is already looking. - // - // The ADR-0112 envelope is the artifact door's exactly — - // `VALIDATION_ERROR` / 400 — because a document refused at one - // SOURCE registrar must be refused the same way at the other, - // and that equality is asserted in - // `view-container-divergent-name-registrars.test.ts`. The prose - // is this seam's own on purpose: `assertMetadataRegisterContract` - // opens its message with `IMetadataService.register(...)`, an API - // this seam does not call, and a refusal that misnames its own - // door is the opposite of "locate the mismatch". - if ( - key === 'views' - && isAggregatedViewContainer(item) - && typeof item.name === 'string' - && item.name - && item.name !== itemName - ) { - const err: Error & { code?: string; status?: number; httpStatus?: number } = new Error( - `Invalid \`views:\` container from ${sourceLabel} '${ownerId}': the container's own ` - + `\`name\` is '${item.name}', which disagrees with the object key it binds to, ` - + `'${itemName}' (derived from its own \`object\`, else \`list.data.object\` / ` - + '`form.data.object`). A disagreement is almost always an authoring bug, and resolving ' - + 'it silently in either direction can file the item under a key the caller never wrote ' - + '(refuse loudly, locate the mismatch) — the artifact/HMR loader refuses ' - + 'this same document. Register under one name: drop `name`, or set it to ' - + `'${itemName}'.`, - ); - err.code = 'VALIDATION_ERROR'; - err.status = 400; - err.httpStatus = 400; - throw err; + // [#20331] Moved there unchanged so that `os validate` runs the + // SAME judgment, in the same words, instead of passing a document + // this loop then refuses at boot. ⛔ Do not re-inline it here, and + // do not write a second copy of it anywhere else: one judge, two + // doors. + if (key === 'views') { + const refusal = viewContainerNameRefusal(item, sourceLabel, ownerId); + if (refusal) throw refusal; } const toRegister = item.name === itemName ? item : { ...item, name: itemName }; // [#5320] The `views:` tighten — containers ONLY, the contract the diff --git a/packages/objectql/src/index.ts b/packages/objectql/src/index.ts index 376322a4caf..fe857b42167 100644 --- a/packages/objectql/src/index.ts +++ b/packages/objectql/src/index.ts @@ -104,6 +104,14 @@ export type { NavGroupContribution, } from './nav-contribution-diagnostics.js'; +// [#20331] The divergent view-container `name` refusal the boot registrar +// throws. Exported because `os validate` is the SECOND door that has to answer +// "will boot refuse this `views:` container?" — at author time, before the +// server refuses it — and it must answer with the same judgment in the same +// words. A consumer calls this; it does not re-derive the check. +export { viewContainerNameRefusal } from './view-container-name-refusal.js'; +export type { ViewContainerNameRefusal } from './view-container-name-refusal.js'; + // Search-normalization companion column (#2486 — pinyin recall). Shared by // the registry's compile-time provisioning seam, the engine's `$search` // expansion, and plugin-pinyin-search's populate hooks. diff --git a/packages/objectql/src/view-container-name-refusal.test.ts b/packages/objectql/src/view-container-name-refusal.test.ts new file mode 100644 index 00000000000..0c73bda46ed --- /dev/null +++ b/packages/objectql/src/view-container-name-refusal.test.ts @@ -0,0 +1,120 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20331] `viewContainerNameRefusal` — the ONE judge of a divergent + * view-container `name`, and proof that the boot registrar throws exactly what + * it returns. + * + * The refusal used to be written inline in `registerMetadataCollections`, so + * `os validate` had nothing to call and passed (exit 0) a document `os serve` + * refused at boot. The judgment moved to its own module unchanged; the boot + * loop throws what it returns, and the CLI reports it + * (`packages/cli/test/validate-view-container-name.test.ts`). + * + * What the boot loop itself does with this document — refuse, register + * nothing, envelope equal to the artifact/HMR door's — stays pinned where it + * always was, in `view-container-divergent-name-registrars.test.ts`. This file + * pins the other half: that the shared function IS the boot loop's answer, at + * both of its seams, so a second door calling it gets the same words. + */ + +import { describe, it, expect } from 'vitest'; +import { ObjectQL } from './engine'; +import { viewContainerNameRefusal } from './view-container-name-refusal'; + +const PKG = 'com.acme.crm'; + +/** The row's own `name` is NOT the object it binds to. */ +const divergent = { + name: 'lead_views', + object: 'crm_lead', + list: { label: 'All Leads', type: 'grid', columns: [{ field: 'name' }] }, +}; + +/** Drive the boot registrar once and hand back what it threw, if anything. */ +function bootRefusal(manifest: Record): any { + try { + new ObjectQL().registerApp({ id: PKG, name: 'crm', ...manifest } as any); + } catch (e) { + return e; + } + return undefined; +} + +describe('#20331 — viewContainerNameRefusal is the boot registrar\'s judgment', () => { + it('refuses a container whose own `name` disagrees with its derived key, in the ADR-0112 envelope', () => { + const refusal = viewContainerNameRefusal(divergent, 'manifest', PKG); + expect(refusal).toBeInstanceOf(Error); + expect(refusal?.code).toBe('VALIDATION_ERROR'); + expect(refusal?.status).toBe(400); + expect(refusal?.httpStatus).toBe(400); + // Both values named, and the source it came from — what makes the + // diagnostic locate the mismatch rather than merely report one. + expect(refusal?.message).toContain("`name` is 'lead_views'"); + expect(refusal?.message).toContain("binds to, 'crm_lead'"); + expect(refusal?.message).toContain(`from manifest '${PKG}'`); + }); + + it('the manifest seam throws exactly what the function returns for the same document', () => { + const thrown = bootRefusal({ views: [divergent] }); + const returned = viewContainerNameRefusal(divergent, 'manifest', PKG); + expect(thrown).toBeInstanceOf(Error); + expect(returned).toBeDefined(); + expect(thrown.message).toBe(returned!.message); + expect(thrown.code).toBe(returned!.code); + expect(thrown.status).toBe(returned!.status); + expect(thrown.httpStatus).toBe(returned!.httpStatus); + }); + + it('…and so does the nested-plugin seam, under its own source label and the parent\'s id', () => { + // `registerPlugin` runs the SAME `registerMetadataCollections` body with + // the label `nested plugin` and the parent package as owner, so the one + // judge answers for both seams. + const thrown = bootRefusal({ plugins: [{ name: 'crm-extras', views: [divergent] }] }); + const returned = viewContainerNameRefusal(divergent, 'nested plugin', PKG); + expect(thrown).toBeInstanceOf(Error); + expect(thrown.message).toBe(returned!.message); + expect(thrown.message).toContain(`from nested plugin '${PKG}'`); + expect(thrown.code).toBe('VALIDATION_ERROR'); + }); + + // ------------------------------------------------------------------ + // Controls — each is a document the boot loop registers, so the judge + // must answer "nothing" for it. A judge that refused them would turn a + // valid stack into a boot failure at both doors at once. + // ------------------------------------------------------------------ + + it('CONTROL: a container with no `name` is not refused, and boot registers it', () => { + const { name: _drop, ...anonymous } = divergent; + expect(viewContainerNameRefusal(anonymous, 'manifest', PKG)).toBeUndefined(); + expect(bootRefusal({ views: [anonymous] })).toBeUndefined(); + }); + + it('CONTROL: a container whose `name` equals its bound object is not refused', () => { + const agreeing = { ...divergent, name: 'crm_lead' }; + expect(viewContainerNameRefusal(agreeing, 'manifest', PKG)).toBeUndefined(); + expect(bootRefusal({ views: [agreeing] })).toBeUndefined(); + }); + + it('CONTROL: a container declaring no binding but its `name` cannot disagree with itself', () => { + const nameOnly = { name: 'lead_views', list: { type: 'grid', columns: [{ field: 'name' }] } }; + expect(viewContainerNameRefusal(nameOnly, 'manifest', PKG)).toBeUndefined(); + expect(bootRefusal({ views: [nameOnly] })).toBeUndefined(); + }); + + it('CONTROL: a non-container entry keyed by its own `name` is not judged', () => { + // Not an aggregated container (`viewKind` set), so its `name` is its + // identity and there is no binding for it to disagree with. + const item = { name: 'crm_lead.hot', object: 'crm_lead', viewKind: 'list', config: { type: 'grid' } }; + expect(viewContainerNameRefusal(item, 'manifest', PKG)).toBeUndefined(); + // Nor is anything that is not an object at all. + expect(viewContainerNameRefusal(undefined, 'manifest', PKG)).toBeUndefined(); + expect(viewContainerNameRefusal('crm_lead', 'manifest', PKG)).toBeUndefined(); + }); + + it('CONTROL: an empty-string `name` is treated as absent, as boot treats it', () => { + const blank = { ...divergent, name: '' }; + expect(viewContainerNameRefusal(blank, 'manifest', PKG)).toBeUndefined(); + expect(bootRefusal({ views: [blank] })).toBeUndefined(); + }); +}); diff --git a/packages/objectql/src/view-container-name-refusal.ts b/packages/objectql/src/view-container-name-refusal.ts new file mode 100644 index 00000000000..df2e66b324c --- /dev/null +++ b/packages/objectql/src/view-container-name-refusal.ts @@ -0,0 +1,101 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The divergent view-container `name` refusal — ONE judge, called by the boot + * registrar and by `os validate`. + * + * ## What it judges + * + * An aggregated `defineView` container is registered under the OBJECT it + * binds to, not under its own `name` (`resolveMetadataItemName` in + * `engine.ts`). A container whose own `name` is set and differs from that + * derived key is refused: resolving the disagreement silently in either + * direction files the item under a key the author never wrote (#7378 row 1). + * The boot loop adopted the refusal under the #14666 ruling (maintainer, + * 2026-09-03, direction 2), converging onto the artifact/HMR loader, which + * already refused the same document. + * + * ## Why this is a module and not a block inside `registerMetadataCollections` + * + * `os validate` is the author-time judge of what the runtime will accept. It + * used to pass this document (exit 0) while `os serve` refused it at boot + * (#20331). The fix is the SAME judgment at both doors, in the same words — + * not a second rule that agrees with the first only until one of them is + * edited. So the check moved here, byte for byte, and the boot loop throws + * what this returns; the CLI reports it. + * + * ## Why it derives the key itself instead of taking it + * + * For a container, the key boot registers under IS + * {@link deriveViewContainerObject} — the first branch of + * `resolveMetadataItemName` returns exactly that, gated on the same + * {@link isAggregatedViewContainer}. Taking the key as a parameter would make + * every other caller re-derive it, which is a second spelling of "which + * derivation does boot use for a container" — the drift this module exists to + * close. Deriving here keeps one answer for both doors. + * + * The gate is two of the three narrowings the ruling named as load-bearing; + * the third, `key === 'views'`, stays at the call site, because only the + * `views:` collection carries containers: + * * {@link isAggregatedViewContainer} — the CONTAINER branch only. A + * standalone ViewItem's `name` is its identity, not a binding; + * * `name` present AND different. A container with no `name` is untouched; + * so is one whose `name` already equals the derived key, and one that + * declares no binding anywhere else, because the derivation then falls + * back to that same `name` and cannot disagree with itself. + * + * The runtime string carries NO tracker id: it is read by authors and + * operators who cannot resolve one (`check:doc-authoring`). The envelope is + * the artifact door's — `VALIDATION_ERROR` / 400 — asserted equal to it in + * `view-container-divergent-name-registrars.test.ts`. + */ + +import { isAggregatedViewContainer } from '@objectstack/spec'; +// The LEAF subpath, for the reason `engine.ts` states at its own import (#14680). +import { deriveViewContainerObject } from '@objectstack/metadata/view-container'; + +/** The refusal, in the ADR-0112 envelope the boot registrar throws it in. */ +export interface ViewContainerNameRefusal extends Error { + code: 'VALIDATION_ERROR'; + status: 400; + httpStatus: 400; +} + +/** + * Judge one `views:` entry: the refusal when it is an aggregated container + * whose own `name` disagrees with the object key it binds to, else + * `undefined`. + * + * Pure: it throws nothing and registers nothing. The boot registrar throws + * what it returns; `os validate` reports it. + * + * @param container One entry of a `views:` collection. + * @param sourceLabel The words naming the source, as the boot registrar + * names it (`manifest`, `nested plugin`). + * @param ownerId The owning package id the boot registrar stamps. + */ +export function viewContainerNameRefusal( + container: unknown, + sourceLabel: string, + ownerId: string | undefined, +): ViewContainerNameRefusal | undefined { + if (!isAggregatedViewContainer(container)) return undefined; + const name = (container as { name?: unknown }).name; + if (typeof name !== 'string' || !name) return undefined; + const itemName = deriveViewContainerObject(container); + if (name === itemName) return undefined; + const err = new Error( + `Invalid \`views:\` container from ${sourceLabel} '${ownerId}': the container's own ` + + `\`name\` is '${name}', which disagrees with the object key it binds to, ` + + `'${itemName}' (derived from its own \`object\`, else \`list.data.object\` / ` + + '`form.data.object`). A disagreement is almost always an authoring bug, and resolving ' + + 'it silently in either direction can file the item under a key the caller never wrote ' + + '(refuse loudly, locate the mismatch) — the artifact/HMR loader refuses ' + + 'this same document. Register under one name: drop `name`, or set it to ' + + `'${itemName}'.`, + ) as ViewContainerNameRefusal; + err.code = 'VALIDATION_ERROR'; + err.status = 400; + err.httpStatus = 400; + return err; +} From 1bffac748969026e9eb48aef6b9d49e07f46c9ab Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 04:49:53 +0000 Subject: [PATCH 2/4] test(cli): give the validate view-container spawn pins a spawn-sized timeout Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude --- packages/cli/test/validate-view-container-name.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/cli/test/validate-view-container-name.test.ts b/packages/cli/test/validate-view-container-name.test.ts index 11863b7993b..6a2fbb20c10 100644 --- a/packages/cli/test/validate-view-container-name.test.ts +++ b/packages/cli/test/validate-view-container-name.test.ts @@ -40,6 +40,8 @@ import { childEnv } from './helpers/serve-process.js'; const HERE = resolve(fileURLToPath(import.meta.url), '..'); const CLI = resolve(HERE, '../bin/run-dev.js'); const TSX = resolve(HERE, '../../../node_modules/.bin/tsx'); +/** A cold `tsx` spawn of the CLI source entry runs well past vitest's 5 s default. */ +const SPAWN_TIMEOUT_MS = 120_000; interface Run { code: number; @@ -159,26 +161,26 @@ describe('#20331 — os validate refuses what the boot registrar refuses, in its expect(row.code).toBe(boot.code); expect(row.httpStatus).toBe(boot.httpStatus); expect(row.path).toBe('views[0]'); - }); + }, SPAWN_TIMEOUT_MS); it('the text face exits 1 and prints the same words', async () => { const run = await runCli(['validate'], dirs.divergent); expect(run.code).toBe(1); expect(run.stdout).not.toContain('Validation passed'); expect(run.stdout).toContain(bootRefusal(stack('order_line')).message); - }); + }, SPAWN_TIMEOUT_MS); it('CONTROL: a container whose `name` matches its object validates', async () => { const run = await runCli(['validate', '--json'], dirs.matching); const payload = payloadOf(run, 'matching --json'); expect(payload.valid, JSON.stringify(payload.errors ?? payload.error)).toBe(true); expect(run.code).toBe(0); - }); + }, SPAWN_TIMEOUT_MS); it('CONTROL: a container with no `name` validates — the shape boot also accepts', async () => { const run = await runCli(['validate', '--json'], dirs.anonymous); const payload = payloadOf(run, 'anonymous --json'); expect(payload.valid, JSON.stringify(payload.errors ?? payload.error)).toBe(true); expect(run.code).toBe(0); - }); + }, SPAWN_TIMEOUT_MS); }); From 4e15d3cea36793e6b2aeb3b9f1164fc6fd409d24 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 04:55:25 +0000 Subject: [PATCH 3/4] chore: cite only records that resolve in the new view-container name comments Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude --- packages/cli/src/commands/validate.ts | 4 ++-- packages/cli/src/utils/view-container-names.ts | 4 ++-- packages/objectql/src/view-container-name-refusal.ts | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index a230b544f44..e30ff085b91 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -359,9 +359,9 @@ export default class Validate extends Command { valid: false, errors: containerNameRefusals, // [#12047] Every exit carries the lists the run has computed so - // far — here the pre-parse ones only. + // far — here the pre-parse ones only, and the conversion notices + // `normalizeStackInput` filled at step 2. warnings: warningsSoFar(), - // [#12125] Computed at step 2, above this gate. conversions: conversionNotices, duration: timer.elapsed(), }); diff --git a/packages/cli/src/utils/view-container-names.ts b/packages/cli/src/utils/view-container-names.ts index 75e48e4be56..a61633a25bf 100644 --- a/packages/cli/src/utils/view-container-names.ts +++ b/packages/cli/src/utils/view-container-names.ts @@ -8,8 +8,8 @@ * * A `views:` container whose own `name` disagrees with the object key it binds * to — `{ name: 'order_line', object: 'my_app_order_line', list: {…} }` — is - * refused at boot by `ObjectQL.registerMetadataCollections` (#14666, #7378 row - * 1). `os validate` had no counterpart, so it exited 0 on that stack and + * refused at boot by `ObjectQL.registerMetadataCollections` (#7378 row 1). + * `os validate` had no counterpart, so it exited 0 on that stack and * `os serve` then refused it. `os validate` is the author-time judge of what * the runtime will accept (NORTH-STAR road step ①), and a green validate * followed by a boot refusal is the silent-validator shape. diff --git a/packages/objectql/src/view-container-name-refusal.ts b/packages/objectql/src/view-container-name-refusal.ts index df2e66b324c..07da29b9b46 100644 --- a/packages/objectql/src/view-container-name-refusal.ts +++ b/packages/objectql/src/view-container-name-refusal.ts @@ -11,8 +11,8 @@ * `engine.ts`). A container whose own `name` is set and differs from that * derived key is refused: resolving the disagreement silently in either * direction files the item under a key the author never wrote (#7378 row 1). - * The boot loop adopted the refusal under the #14666 ruling (maintainer, - * 2026-09-03, direction 2), converging onto the artifact/HMR loader, which + * The boot loop adopted the refusal under the maintainer ruling of + * 2026-09-03 (direction 2), converging onto the artifact/HMR loader, which * already refused the same document. * * ## Why this is a module and not a block inside `registerMetadataCollections` @@ -51,7 +51,7 @@ */ import { isAggregatedViewContainer } from '@objectstack/spec'; -// The LEAF subpath, for the reason `engine.ts` states at its own import (#14680). +// The LEAF subpath, for the reason `engine.ts` states at its own import. import { deriveViewContainerObject } from '@objectstack/metadata/view-container'; /** The refusal, in the ADR-0112 envelope the boot registrar throws it in. */ From 6b5895b978310cf4e11f437cd6947e49d38b4760 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 06:29:36 +0000 Subject: [PATCH 4/4] fix(objectql,cli): carry boot's falsy-key skip into the shared view-container judge; fixtures boot accepts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit viewContainerNameRefusal now answers undefined for a container whose derived object key is falsy, the entry registerMetadataCollections warns about and skips before the name check, so os validate cannot refuse what boot skips. Pinned by a control that boot registers nothing and throws nothing. Every CLI test fixture whose views: container carried a name other than its bound object (two red integration pins among them) now names the object, so each is a stack the server loads. The changeset grades @objectstack/objectql minor with Clause-② yes for its two new root exports, and the gate-parity remedy text names VALIDATE_ONLY_GATES. Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude --- .../20331-validate-view-container-name.md | 15 +++++---- .../build-text-face-advisory-count.test.ts | 4 +-- packages/cli/test/format-zod-union.test.ts | 2 +- .../cli/test/info-detail-package-fold.test.ts | 2 +- ...nt-handwritten-checks-package-fold.test.ts | 2 +- .../test/lint-label-case-localized.test.ts | 4 +-- .../lint-per-package-authoring-parity.test.ts | 4 +-- .../lint-per-package-authoring-seam.test.ts | 4 +-- .../test/union-fold-command-parity.test.ts | 8 +++-- .../test/validate-build-gate-parity.test.ts | 7 ++-- ...idate-per-package-authoring-parity.test.ts | 8 +++-- packages/objectql/src/engine.ts | 14 +++++--- .../src/view-container-name-refusal.test.ts | 32 +++++++++++++++++++ .../src/view-container-name-refusal.ts | 18 +++++++++-- 14 files changed, 93 insertions(+), 31 deletions(-) diff --git a/.changeset/20331-validate-view-container-name.md b/.changeset/20331-validate-view-container-name.md index 704ce7ce2b7..4c6b9e6bb6a 100644 --- a/.changeset/20331-validate-view-container-name.md +++ b/.changeset/20331-validate-view-container-name.md @@ -1,11 +1,11 @@ --- '@objectstack/cli': patch -'@objectstack/objectql': patch +'@objectstack/objectql': minor --- fix(cli): `os validate` refuses a `views:` container whose own `name` disagrees with the object it binds to, the stack the server refuses at boot (#20331) -Clause-②: no +Clause-②: yes A view container is registered under the object it binds to. When its own `name` is set to something else, for example `{ name: 'order_line', object: 'my_app_order_line', list: { … } }`, @@ -21,10 +21,13 @@ unchanged. **Fix:** remove the container's `name`, or set it to the object name the message names. -`@objectstack/objectql` exports the check as `viewContainerNameRefusal(container, sourceLabel, ownerId)`, -with its type `ViewContainerNameRefusal`. It returns the refusal the boot registrar -throws, or `undefined`. The boot registrar now calls this function. What it refuses, -its message and its `VALIDATION_ERROR` / `400` envelope are unchanged. +**New in `@objectstack/objectql` (the widening):** two new exports on the package's +root entry, `viewContainerNameRefusal(container, sourceLabel, ownerId)` and its +return type `ViewContainerNameRefusal`. The function returns the refusal the boot +registrar throws, or `undefined`. It returns `undefined` for a container whose +derived object key is empty, because the boot registrar skips that entry with a +warning and never refuses it. The boot registrar now calls this function. What it +refuses, its message and its `VALIDATION_ERROR` / `400` envelope are unchanged. Not changed: `os build` does not run this check, so it still writes an artifact carrying such a container, and the server refuses that artifact when it loads it. diff --git a/packages/cli/test/build-text-face-advisory-count.test.ts b/packages/cli/test/build-text-face-advisory-count.test.ts index e6d1765c04b..99db107bcf7 100644 --- a/packages/cli/test/build-text-face-advisory-count.test.ts +++ b/packages/cli/test/build-text-face-advisory-count.test.ts @@ -187,11 +187,11 @@ const ordersObjects = [{ }]; const ordersViews = [ { - name: 'bc_account_list', label: 'Account List', object: 'bc_account', + name: 'bc_account', label: 'Account List', object: 'bc_account', list: { label: 'Account List', columns: ['name', 'industry'] }, }, { - name: 'bc_order_list', label: 'Order List', object: 'bc_order', + name: 'bc_order', label: 'Order List', object: 'bc_order', list: { label: 'Order List', columns: ['name', 'account'] }, }, ]; diff --git a/packages/cli/test/format-zod-union.test.ts b/packages/cli/test/format-zod-union.test.ts index 3446dbd120e..43d57346391 100644 --- a/packages/cli/test/format-zod-union.test.ts +++ b/packages/cli/test/format-zod-union.test.ts @@ -185,7 +185,7 @@ const TOOLTIP_ALIAS_STACK = { manifest: { id: 'com.example.union-probe', name: 'Union Probe', namespace: 'union_probe', version: '1.0.0', type: 'app' }, views: [ { - name: 'union_probe_view', + name: 'union_probe_obj', object: 'union_probe_obj', list: { name: 'union_probe_list', diff --git a/packages/cli/test/info-detail-package-fold.test.ts b/packages/cli/test/info-detail-package-fold.test.ts index 152ce112915..6f2a5ba3a8b 100644 --- a/packages/cli/test/info-detail-package-fold.test.ts +++ b/packages/cli/test/info-detail-package-fold.test.ts @@ -91,7 +91,7 @@ const OBJECTS = [ const VIEWS = [ { - name: 'ob_order_views', + name: 'ob_order', object: 'ob_order', list: { label: 'Orders', diff --git a/packages/cli/test/lint-handwritten-checks-package-fold.test.ts b/packages/cli/test/lint-handwritten-checks-package-fold.test.ts index 18f71fdee1d..269f1a5c7f5 100644 --- a/packages/cli/test/lint-handwritten-checks-package-fold.test.ts +++ b/packages/cli/test/lint-handwritten-checks-package-fold.test.ts @@ -103,7 +103,7 @@ const definitions = () => ({ ], views: [ { - name: 'probe_view', + name: 'probe_order', object: 'probe_order', list: { label: 'order list', columns: ['number'] }, // convention/label-case }, diff --git a/packages/cli/test/lint-label-case-localized.test.ts b/packages/cli/test/lint-label-case-localized.test.ts index 924ac2abd1f..d7554ff8719 100644 --- a/packages/cli/test/lint-label-case-localized.test.ts +++ b/packages/cli/test/lint-label-case-localized.test.ts @@ -92,13 +92,13 @@ const stackWithApp = (label: unknown) => ({ const stackWithListLabel = (label: unknown) => ({ manifest: MANIFEST, - views: [{ name: 'invoice_views', object: 'invoice', list: { label, type: 'grid', columns: ['name'] } }], + views: [{ name: 'invoice', object: 'invoice', list: { label, type: 'grid', columns: ['name'] } }], }); const stackWithNamedListLabel = (label: unknown) => ({ manifest: MANIFEST, views: [{ - name: 'invoice_views', + name: 'invoice', object: 'invoice', listViews: { all: { label, type: 'grid', columns: ['name'] } }, }], diff --git a/packages/cli/test/lint-per-package-authoring-parity.test.ts b/packages/cli/test/lint-per-package-authoring-parity.test.ts index e874f594f69..80a607bbc62 100644 --- a/packages/cli/test/lint-per-package-authoring-parity.test.ts +++ b/packages/cli/test/lint-per-package-authoring-parity.test.ts @@ -152,11 +152,11 @@ const ordersObjects = [{ }]; const ordersViews = [ { - name: 'pp_account_list', label: 'Account List', object: 'pp_account', + name: 'pp_account', label: 'Account List', object: 'pp_account', list: { label: 'Account List', columns: ['name', 'industry'] }, }, { - name: 'pp_order_list', label: 'Order List', object: 'pp_order', + name: 'pp_order', label: 'Order List', object: 'pp_order', list: { label: 'Order List', columns: ['name', 'account'] }, }, ]; diff --git a/packages/cli/test/lint-per-package-authoring-seam.test.ts b/packages/cli/test/lint-per-package-authoring-seam.test.ts index a22658631bd..afdf17ece3c 100644 --- a/packages/cli/test/lint-per-package-authoring-seam.test.ts +++ b/packages/cli/test/lint-per-package-authoring-seam.test.ts @@ -136,13 +136,13 @@ function twoPackageArtifact(): Record { ], views: [ { - name: 'pp_account_list', + name: 'pp_account', label: 'Account List', object: 'pp_account', list: { label: 'Account List', columns: ['name', 'industry'] }, }, { - name: 'pp_order_list', + name: 'pp_order', label: 'Order List', object: 'pp_order', list: { label: 'Order List', columns: ['name', 'account'] }, diff --git a/packages/cli/test/union-fold-command-parity.test.ts b/packages/cli/test/union-fold-command-parity.test.ts index 81b3a5bf364..62d9b9174e8 100644 --- a/packages/cli/test/union-fold-command-parity.test.ts +++ b/packages/cli/test/union-fold-command-parity.test.ts @@ -203,13 +203,17 @@ const perPackageOnlyStack = (): Record => { account: { name: 'account', type: 'lookup', label: 'Account', reference: 'ob_account' }, }, }]; + // [#20331] Each container's `name` IS the object it binds to: the boot + // registrar refuses a container whose own `name` disagrees with that key, + // and `os validate` now refuses it too, so a `*_list` name here would make + // this a stack the server cannot load. const ordersViews = [ { - name: 'ob_account_list', label: 'Account List', object: 'ob_account', + name: 'ob_account', label: 'Account List', object: 'ob_account', list: { label: 'Account List', columns: ['name', 'industry'] }, }, { - name: 'ob_order_list', label: 'Order List', object: 'ob_order', + name: 'ob_order', label: 'Order List', object: 'ob_order', list: { label: 'Order List', columns: ['name', 'account'] }, }, ]; diff --git a/packages/cli/test/validate-build-gate-parity.test.ts b/packages/cli/test/validate-build-gate-parity.test.ts index 63f80975f49..7e29dbd6505 100644 --- a/packages/cli/test/validate-build-gate-parity.test.ts +++ b/packages/cli/test/validate-build-gate-parity.test.ts @@ -836,9 +836,10 @@ describe('os validate is the read-only superset of os build (#3782, #4409)', () `${unclassified.join(', ')}.\n` + `Every call site must land in exactly one ledger. If it is an artifact-level gate, wire it ` + `into BOTH commands and add it to SHARED_NON_REGISTRY_GATES; if it genuinely cannot run ` + - `read-only, add it to BUILD_ONLY_GATES with a reason; if it is not a gate at all, add it to ` + - `NOT_A_GATE under the reason that says so. Registering it in ` + - `packages/lint/src/authoring-rules.ts instead is better than all three — then all THREE ` + + `read-only, add it to BUILD_ONLY_GATES with a reason; if validate.ts runs it and compile.ts ` + + `deliberately does not, add it to VALIDATE_ONLY_GATES with the reason the build lacks it; if ` + + `it is not a gate at all, add it to NOT_A_GATE under the reason that says so. Registering it in ` + + `packages/lint/src/authoring-rules.ts instead is better than all four — then all THREE ` + `authoring commands get it and no roster row is needed.`, ).toEqual([]); }); diff --git a/packages/cli/test/validate-per-package-authoring-parity.test.ts b/packages/cli/test/validate-per-package-authoring-parity.test.ts index 893dc2856e6..adaaaf17879 100644 --- a/packages/cli/test/validate-per-package-authoring-parity.test.ts +++ b/packages/cli/test/validate-per-package-authoring-parity.test.ts @@ -161,13 +161,17 @@ const ordersObjects = [{ account: { name: 'account', type: 'lookup', label: 'Account', reference: 'pp_account' }, }, }]; +// [#20331] Each container's name IS the object it binds to: the boot registrar +// refuses a container whose own name disagrees with that key, and os validate +// now refuses it too, so a *_list name here would make this a stack the +// server cannot load. const ordersViews = [ { - name: 'pp_account_list', label: 'Account List', object: 'pp_account', + name: 'pp_account', label: 'Account List', object: 'pp_account', list: { label: 'Account List', columns: ['name', 'industry'] }, }, { - name: 'pp_order_list', label: 'Order List', object: 'pp_order', + name: 'pp_order', label: 'Order List', object: 'pp_order', list: { label: 'Order List', columns: ['name', 'account'] }, }, ]; diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 5dfc72ff1db..2a035efa3f7 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -6569,11 +6569,15 @@ export class ObjectQL implements IObjectQLEngine { // the prose is this seam's own live in `viewContainerNameRefusal` // (`view-container-name-refusal.ts`). // - // [#20331] Moved there unchanged so that `os validate` runs the - // SAME judgment, in the same words, instead of passing a document - // this loop then refuses at boot. ⛔ Do not re-inline it here, and - // do not write a second copy of it anywhere else: one judge, two - // doors. + // [#20331] Moved there so that `os validate` runs the SAME + // judgment, in the same words, instead of passing a document this + // loop then refuses at boot. The message and the envelope moved + // unchanged. The `!itemName` skip just above is this loop's + // precondition for the check, so the function carries it too: it + // answers `undefined` for a falsy derived key, and a door calling + // it without this loop cannot refuse an entry this loop skips. + // ⛔ Do not re-inline it here, and do not write a second copy of + // it anywhere else: one judge, two doors. if (key === 'views') { const refusal = viewContainerNameRefusal(item, sourceLabel, ownerId); if (refusal) throw refusal; diff --git a/packages/objectql/src/view-container-name-refusal.test.ts b/packages/objectql/src/view-container-name-refusal.test.ts index 0c73bda46ed..f4117d74ebf 100644 --- a/packages/objectql/src/view-container-name-refusal.test.ts +++ b/packages/objectql/src/view-container-name-refusal.test.ts @@ -20,6 +20,7 @@ import { describe, it, expect } from 'vitest'; import { ObjectQL } from './engine'; +import { deriveViewContainerObject } from '@objectstack/metadata'; import { viewContainerNameRefusal } from './view-container-name-refusal'; const PKG = 'com.acme.crm'; @@ -117,4 +118,35 @@ describe('#20331 — viewContainerNameRefusal is the boot registrar\'s judgment' expect(viewContainerNameRefusal(blank, 'manifest', PKG)).toBeUndefined(); expect(bootRefusal({ views: [blank] })).toBeUndefined(); }); + + it('CONTROL: a derived key that is falsy is boot\'s skip, not a refusal — boot registers nothing and throws nothing', () => { + // Boot's precondition, carried with the gate. `registerMetadataCollections` + // warns and skips an entry whose derived key is falsy BEFORE the name + // check runs. The key can be `''` while `name` is set: the derivation's + // `??` chain keeps an empty `list.data.object`. A judge that refused + // this entry would have a second door refuse what boot only warns about. + const unbound = { + name: 'lead_views', + list: { + label: 'All Leads', + type: 'grid', + data: { provider: 'object', object: '' }, + columns: [{ field: 'name' }], + }, + }; + // Premise guard: the derived key really is falsy (and not `undefined`, + // which the name fallback would have replaced). + expect(deriveViewContainerObject(unbound)).toBe(''); + + const engine = new ObjectQL(); + let thrown: unknown; + try { + engine.registerApp({ id: PKG, name: 'crm', views: [unbound] } as any); + } catch (e) { + thrown = e; + } + expect(thrown).toBeUndefined(); + expect(engine.registry.listItems('view') ?? []).toEqual([]); + expect(viewContainerNameRefusal(unbound, 'manifest', PKG)).toBeUndefined(); + }); }); diff --git a/packages/objectql/src/view-container-name-refusal.ts b/packages/objectql/src/view-container-name-refusal.ts index 07da29b9b46..21af64e519d 100644 --- a/packages/objectql/src/view-container-name-refusal.ts +++ b/packages/objectql/src/view-container-name-refusal.ts @@ -21,8 +21,12 @@ * used to pass this document (exit 0) while `os serve` refused it at boot * (#20331). The fix is the SAME judgment at both doors, in the same words — * not a second rule that agrees with the first only until one of them is - * edited. So the check moved here, byte for byte, and the boot loop throws - * what this returns; the CLI reports it. + * edited. So the check moved here, and the boot loop throws what this + * returns; the CLI reports it. The message and the envelope moved byte for + * byte. The GATE moved whole, precondition included: the boot loop skips an + * entry whose derived key is falsy (it warns and registers nothing) BEFORE it + * reaches this check, and this function answers `undefined` for that entry + * too, so a door that calls it alone cannot refuse what boot skips. * * ## Why it derives the key itself instead of taking it * @@ -43,6 +47,12 @@ * so is one whose `name` already equals the derived key, and one that * declares no binding anywhere else, because the derivation then falls * back to that same `name` and cannot disagree with itself. + * Ahead of both sits the boot loop's own precondition: a derived key that is + * falsy means boot skips the entry, so there is nothing to refuse. The key + * CAN be `''` while `name` is set — `deriveViewContainerObject`'s `??` chain + * keeps an empty string, so `list: { data: { object: '' } }` with no + * top-level `object` derives `''` — and without this line a second door + * would refuse a container boot only warns about. * * The runtime string carries NO tracker id: it is read by authors and * operators who cannot resolve one (`check:doc-authoring`). The envelope is @@ -83,6 +93,10 @@ export function viewContainerNameRefusal( const name = (container as { name?: unknown }).name; if (typeof name !== 'string' || !name) return undefined; const itemName = deriveViewContainerObject(container); + // Boot's precondition, carried with the gate: `registerMetadataCollections` + // warns and skips an entry whose derived key is falsy before it reaches + // this check, so such an entry is never refused. + if (!itemName) return undefined; if (name === itemName) return undefined; const err = new Error( `Invalid \`views:\` container from ${sourceLabel} '${ownerId}': the container's own `