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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .changeset/20331-validate-view-container-name.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
'@objectstack/cli': 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-②: 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: { … } }`,
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.

**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.
49 changes: 49 additions & 0 deletions packages/cli/src/commands/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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<string, unknown>);
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, and the conversion notices
// `normalizeStackInput` filled at step 2.
warnings: warningsSoFar(),
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
Expand Down
95 changes: 95 additions & 0 deletions packages/cli/src/utils/view-container-names.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => ({
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([]);
});
});
112 changes: 112 additions & 0 deletions packages/cli/src/utils/view-container-names.ts
Original file line number Diff line number Diff line change
@@ -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` (#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<string, unknown>;

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;
}
4 changes: 2 additions & 2 deletions packages/cli/test/build-text-face-advisory-count.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'] },
},
];
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/test/format-zod-union.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/test/info-detail-package-fold.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ const OBJECTS = [

const VIEWS = [
{
name: 'ob_order_views',
name: 'ob_order',
object: 'ob_order',
list: {
label: 'Orders',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/test/lint-label-case-localized.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'] } },
}],
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/test/lint-per-package-authoring-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'] },
},
];
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/test/lint-per-package-authoring-seam.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,13 +136,13 @@ function twoPackageArtifact(): Record<string, unknown> {
],
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'] },
Expand Down
Loading
Loading