Skip to content

Commit c5d6b2b

Browse files
fix(objectql,cli): os validate refuses a view container whose name disagrees with its object, as boot does (#20331) (#20391)
Fixes #20331 Clause-②: yes `os validate` now runs the same view-container `name` check the boot registrar runs, and reports the refusal in the boot registrar's own words. Before this change it passed a stack at exit 0, and `os serve` then refused that same stack at boot. The triage direction was "one judge, not a second rule". The check moved out of `ObjectQL.registerMetadataCollections` into one function, and boot and `os validate` both call it. There is no lint twin and no new error code, and boot's message and envelope are unchanged. ## Premise, measured on `origin/main` `862b6ce86` before any edit The setup: the CLI's dependency closure was built, then `os init my-app -t app --no-install`, `os g object order_line` and `os g view order_line`. The view's `name` was then hand-edited to `'order_line'`, bound to object `my_app_order_line`. | step | exit | what it printed | |---|---|---| | `os validate` | **0** | `✓ Validation passed`, `UI: 1 Views` | | `os serve --dev` | **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' …" | | `os compile` | **0** | writes an artifact carrying `{ name: 'order_line', object: 'my_app_order_line' }` | | `os serve`, booting that artifact (`dist/objectstack.json`, no config) | **1** | the same message | ## What changed 1. **`@objectstack/objectql`: `viewContainerNameRefusal(container, sourceLabel, ownerId)`** (new `src/view-container-name-refusal.ts`) returns the refusal the boot registrar throws, or `undefined`. It carries the same gate: the container branch (`isAggregatedViewContainer`), and a `name` that is present and differs from the derived key. It also carries boot's precondition for that gate: a falsy derived key is boot's warn-and-skip, and the function answers `undefined` for it. It uses the same message and the same `VALIDATION_ERROR` / 400 envelope. It derives the key itself with `deriveViewContainerObject`, which for a container is exactly what `resolveMetadataItemName` returns. So no caller re-spells "which derivation boot uses". 2. **`registerMetadataCollections`** keeps its `key === 'views'` narrowing and throws what the function returns. Nothing else in `engine.ts` moved. 3. **A new public export** from the `@objectstack/objectql` root entry: `viewContainerNameRefusal` and its type `ViewContainerNameRefusal`. This is the widening behind `Clause-②: yes`: `@objectstack/objectql` is graded `minor`, and `@objectstack/cli` stays `patch`. The changeset states it. 4. **`@objectstack/cli`: `findViewContainerNameRefusals(parsed)`** (new `src/utils/view-container-names.ts`) owns the WALK only, and the verdict is objectql's. It walks what the load path registers. With no `packages[]`, that is the top-level `views`, under the manifest id: `AppPlugin` hands `{ ...manifest, ...stack }` to the manifest service, and the id is read through `artifactPackageId`. With `packages[]`, it is each `packages[i].manifest.views`, under that package's id, and not the top level, which `resolveArtifactPackageOrder` never registers. 5. **`os validate` step 2c** runs right after the schema parse. It exits 1. The text face prints the refusals as bullets. `--json` emits `errors: [{ path, code, httpStatus, message }]` and carries `warnings` / `conversions` like every other exit. 6. **`test/validate-build-gate-parity.test.ts`** gains a `VALIDATE_ONLY_GATES` ledger. The closed roster had no honest place for a gate wired into `validate.ts` alone: `SHARED_NON_REGISTRY_GATES` asserts that both commands call it, and a `NOT_A_GATE` row would be a false statement. The new row is pruned both ways: it goes stale if `validate.ts` stops calling the gate, and it must move to `SHARED_NON_REGISTRY_GATES` if `compile.ts` starts calling it. ## After the fix, on the same project (CLI from source, `@objectstack/objectql` dist rebuilt) - `os validate` exits 1 with `✗ The server would refuse this stack at boot (1 view container)` and the boot message. `os validate --json` exits 1 with `errors[0] = { path: 'views[0]', code: 'VALIDATION_ERROR', httpStatus: 400, message }`. The `message` is **byte-equal** to the line `os serve --dev` printed before the fix (`diff` is empty). - Controls: `name: 'my_app_order_line'` gives exit 0, and deleting `name` gives exit 0. - `os serve --dev` still exits 1. Its message is byte-identical before and after the refactor (`diff` of the two boot logs is empty). ## Tests - `packages/objectql/src/view-container-name-refusal.test.ts` (new, 9 tests, with the falsy-derived-key control added in patch round 1): the refusal and its envelope. The manifest seam AND the nested-plugin seam throw exactly what the function returns. There are five controls (no `name`, a matching `name`, a name-only container, a non-container, an empty-string `name`), each also registered by boot. - `packages/cli/src/utils/view-container-names.test.ts` (new, unit tier, 5 tests): the walk. Every row's message equals the judge's answer and is never re-spelled. The `packages[]` bodies are judged under their own ids, and the top level is not judged when `packages[]` is present. - `packages/cli/test/validate-view-container-name.test.ts` (new, integration tier: it spawns the CLI and constructs `ObjectQL`, 5 tests): `--json` exits 1 and `errors[0].message` equals what `ObjectQL.registerApp` throws for the same stack. The text face exits 1 with the same words. The matching control and the no-`name` control both exit 0. A premise case asserts that boot refuses the divergent stack and accepts both controls. - The existing boot pins in `view-container-divergent-name-registrars.test.ts` are unchanged and green. - Runs at `4e15d3cea`: - `@objectstack/objectql`: `vitest run --project local`, four shards: 323 files, 5865 tests passed. Typecheck (`tsc --noEmit` ×2 plus `check:test-typecheck`) exit 0. - `@objectstack/cli`: `vitest run --project unit`, two shards: 232 files, 3315 tests passed. Typecheck exit 0. `--project integration` was run locally for the new file only (5/5); the rest of the integration tier is declared to CI. - `tsc --listFilesOnly` confirms every new file is in a typecheck program (`tsconfig.json` / `tsconfig.test.json` of each package). - `pnpm lint` (full `eslint . --no-inline-config`) exit 0. ## Ablation (fix committed first; mutation through `scripts/ablation-replace.mjs`) - **Mutation:** an early `return undefined` at the top of `viewContainerNameRefusal`, marker `__ABLATION_20331_NEVER_REFUSE`. The tool reported anchor x1 to x0 and blob `07da29b9` to `ab52f626`. The objectql build and `ablation-dist-preflight.mjs` found the marker in 4 built files. - objectql: 5 of 20 red. These are the 3 new refusal cases and the 2 existing boot pins: "THE PIN (#14666): the boot loop REFUSES the divergent container …" and "MEASURED CORRECTION … refuses, enveloped (#7378 row 1)". - cli: 6 of 10 red. These are the 3 walk cases, the premise case, THE PIN (`--json`) and the text face. The controls stayed green. - The direction is red, as expected. - **Restore:** `git checkout HEAD --` on the absolute path, by the tool's trap. The blob after the restore equals HEAD `07da29b9`, and `git diff HEAD` is empty. After the rebuild, `ablation-dist-preflight --absent` found the marker absent from all 14 built files and a working tree clean against HEAD. objectql was then 20/20 green and cli 10/10 green. ## Gates at `4e15d3cea` These were derived with `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands`, which gave 69 commands. `--ran` reconciled them as 68 run, 1 NOT-MEASURED and 0 UNRUN. Every command exited 0 except the one below: ```text pnpm check:type-check-debt -> 3 NOT MEASURED (PREREQUISITE NOT MET) ``` `check:type-check-debt --re-measure` runs a whole-workspace `turbo run build` inside itself. On this shared box that build did not fit the foreground cap: the first attempt was SIGTERMed mid-build, and I rebuilt `packages/spec` afterwards. Neither touched package carries a `DEBT` / `TEST_DEBT` row. `lint.yml` runs it in CI. Six gates first answered "prerequisite not met" or asked for deeper history, and each was re-run after its prerequisite was met: - `check:dual-build-cjs-loads`, `check:i18n`, `check:i18n-coverage` and `check:i18n-walk-parity` exited 3 until the CLI, the examples and the unbuilt packages were built, then 0. - `check-engine-split-ratio.mjs --days 90` exited 2 until the history was deepened with `--shallow-since`, then 0. - `check-issue-citations.mjs` exited 2 on four added citations that no longer resolve. Those were rephrased, and it then exited 0 with and without `--base origin/main`. ## Patch round 1 (head `6b5895b97`, after the FAIL review of `4e15d3cea`) 1. **Two red CI pins, and every fixture of the same shape.** `test/union-fold-command-parity.test.ts` (PEDIGREE CONTROL) and `test/validate-per-package-authoring-parity.test.ts` asserted exit 0 on stacks the boot registrar refuses: each carried containers named `*_list` bound to `*_account` / `*_order`. Each container now names its bound object, and each pin's assertion is unchanged. - **Census** of `packages/cli/test/`, `packages/cli/src/`, `packages/qa/` and `examples/` (all 963 tracked `.ts`/`.js`/`.json` files, the `.e2e` tier included). A structural scan read every object literal carrying `name` plus a container arm (`list`/`form`/`listViews`/`formViews`) and no `viewKind`, and derived the key as boot does. It found 15 divergent containers in 9 files, all fixed the same way: the 4 above, `build-text-face-advisory-count.test.ts` (2), `format-zod-union.test.ts` (1), `info-detail-package-fold.test.ts` (1), `lint-handwritten-checks-package-fold.test.ts` (1), `lint-label-case-localized.test.ts` (2), `lint-per-package-authoring-parity.test.ts` (2), `lint-per-package-authoring-seam.test.ts` (2). A rescan finds none, apart from the docblock examples in this PR's own files. `examples/`: `os validate` exits 0 on each of app-crm, app-multi-package, app-showcase and app-todo, with 0 container refusals. `packages/qa/`: no hit. `views` is not a map-form collection (`MAP_SUPPORTED_FIELDS` excludes it), so no key-injected name can hide there. 2. **Semver:** `@objectstack/objectql` is now `minor` with `Clause-②: yes`, for the two new root exports (`viewContainerNameRefusal`, `ViewContainerNameRefusal`). `@objectstack/cli` stays `patch`. The changeset states the widening. 3. **Boot's precondition moved with the gate.** `registerMetadataCollections` warns about and skips an entry whose derived key is falsy before the name check runs. `viewContainerNameRefusal` now returns `undefined` for that entry too. The derivation keeps `''` for `list: { data: { object: '' } }`, so without this `os validate` would refuse a container that boot only skips. A new control pins it: boot throws nothing and registers nothing, and the function returns `undefined`. The docblock and the `engine.ts` comment now say what moved: the message and the envelope moved byte for byte, and the gate moved whole, precondition included. 4. The gate-parity remedy text names `VALIDATE_ONLY_GATES` too. Runs at `6b5895b97`: - `@objectstack/objectql`: `--project local` in 4 shards, 325 files / 6017 tests passed. Typecheck exit 0. The new test file is 9/9. - `@objectstack/cli`: `--project unit` in 2 shards, 233 files / 3335 tests passed. `--project integration` IN FULL in 3 shards, 61 files / 512 tests passed and 1 skipped. Typecheck exit 0. - `OS_TEST_TIERS=nightly`: the census touched no `.e2e` file, so the 13 nightly files that run `os validate` were run instead. 13 files / 129 tests passed. - `pnpm lint` exit 0. `check-issue-citations --base origin/main` exit 0. - `check-changeset-no-major` level axis: driven with `--event` on this body with `Clause-②: yes`, it answers "✓ LEVEL AXIS … `@objectstack/objectql: minor`" (exit 0). - Gates: 69 derived, `--ran` reconciles 69 run, 0 NOT-MEASURED, 0 UNRUN, every one exit 0. That includes `check:type-check-debt`: 4 entries re-measured, none above its number. - **Guard ablation:** `scripts/ablation-replace.mjs --delete` removed the `if (!itemName) return undefined;` line (anchor x1 to x0, blob `21af64e5` to `56d25d71`). Exactly the new control went red: the function returned "Invalid `views:` container … binds to, ''" where `undefined` was expected. The other 20 tests stayed green, the existing boot pins among them. The restore gave a blob equal to HEAD `21af64e5` and an empty `git diff HEAD`, then 21/21 green. ## Acceptance notes - **`os compile` / `os build` does NOT reach the shared function.** It is not the same code path as validate, and under this card's scope I did not widen to it. It still exits 0 on this stack and writes an artifact that `os serve` refuses when it boots it, as measured above. The `VALIDATE_ONLY_GATES` row records that gap. I reported it to the seat as an out-of-scope finding. - **The artifact/HMR loader is not a second spelling of this function.** `MetadataPlugin._parseAndRegisterArtifact` (`packages/metadata/src/plugin.ts`, the container branch around :1103-:1121) registers the container under `deriveViewContainerObject(item)` through `this.manager.register('view', viewObject, item)`. The refusal there comes from the GENERIC register contract `assertMetadataRegisterContract` (`packages/core/src/metadata-service-contract.ts`, #7378 row 1), in its own words ("IMetadataService.register('view', …): data.name is …"). So both registrars enforce the same rule through different mechanisms, and boot's prose is its own on purpose. `packages/metadata/**` is untouched. - **Edge, not measured at a public door (read-only inference):** `ViewSchema.name` is `z.string().optional()`, so an empty-string container `name` is spec-valid. The boot loop (and `os validate`, which mirrors it) treats `''` as absent and registers the container. The generic register contract at the artifact door refuses it, because `'' !== undefined` and `'' !== key`. No producer of `name: ''` was found. - **Bound:** a nested `plugins[]` entry's `views` is registered by boot under the label `nested plugin`, but `os validate` does not walk it. The stack schema types `plugins` as `unknown[]`, and in an authored config they are runtime plugin instances. - **Upstream:** `origin/main` was merged at `b1cbd9277`, and again at `0d7ed5a37` in patch round 1. It has since moved 6 commits. One of them, #20368, touches `engine.ts` in the `engine.aggregate` hunks only, and a `git merge-tree` probe merges cleanly, so I did not merge again. - `packages/spec/**` and `packages/metadata/**` are unchanged. The diff adds no lint rule and no error code, and it does not change boot's message or envelope. --- _Generated by [Claude Code](https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c745e2b commit c5d6b2b

19 files changed

Lines changed: 844 additions & 69 deletions
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
'@objectstack/cli': patch
3+
'@objectstack/objectql': minor
4+
---
5+
6+
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)
7+
8+
Clause-②: yes
9+
10+
A view container is registered under the object it binds to. When its own `name`
11+
is set to something else, for example `{ name: 'order_line', object: 'my_app_order_line', list: { … } }`,
12+
the server refuses the whole stack at boot. `os validate` used to pass that stack
13+
at exit 0, so the first sign of the mistake was a server that would not start.
14+
15+
`os validate` now runs the same check the server runs at boot and prints the same
16+
message. The text form and `--json` both exit `1`. The `--json` failure payload lists
17+
one `errors` entry per refused container, with `path` (for example `views[0]`, or
18+
`packages[1].manifest.views[0]` in a multi-package stack), `code: 'VALIDATION_ERROR'`,
19+
`httpStatus: 400` and `message`. Every other exit, and the success payload, are
20+
unchanged.
21+
22+
**Fix:** remove the container's `name`, or set it to the object name the message names.
23+
24+
**New in `@objectstack/objectql` (the widening):** two new exports on the package's
25+
root entry, `viewContainerNameRefusal(container, sourceLabel, ownerId)` and its
26+
return type `ViewContainerNameRefusal`. The function returns the refusal the boot
27+
registrar throws, or `undefined`. It returns `undefined` for a container whose
28+
derived object key is empty, because the boot registrar skips that entry with a
29+
warning and never refuses it. The boot registrar now calls this function. What it
30+
refuses, its message and its `VALIDATION_ERROR` / `400` envelope are unchanged.
31+
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.

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

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ import {
5858
formatPermissionSetNameCollisions,
5959
} from '../utils/permission-set-name-collisions.js';
6060
import type { PermissionSetNameCollisionDiagnostic } from '@objectstack/plugin-security';
61+
// [#20331] The boot registrar's divergent view-container `name` refusal, walked
62+
// over the parsed stack the way the load path registers it. The verdict is
63+
// `@objectstack/objectql`'s; see the module header.
64+
import { findViewContainerNameRefusals } from '../utils/view-container-names.js';
6165

6266
export default class Validate extends Command {
6367
static override description =
@@ -328,6 +332,51 @@ export default class Validate extends Command {
328332
this.exit(1);
329333
}
330334

335+
// 2c. [#20331] The boot registrar's divergent view-container `name`
336+
// refusal, judged here by the SAME function
337+
// `ObjectQL.registerMetadataCollections` throws the answer of
338+
// (`viewContainerNameRefusal`, `@objectstack/objectql`). This door
339+
// used to pass `{ name: 'order_line', object: 'my_app_order_line',
340+
// list: {…} }` at exit 0 while `os serve` refused the same stack at
341+
// boot — the silent-validator shape, on the command whose whole job
342+
// is to say what the runtime will accept.
343+
//
344+
// ⛔ One judge, not a second rule: not an `@objectstack/lint`
345+
// registry member and not a re-spelling of the check. The helper
346+
// owns only the WALK (which `views:` entries boot registers, under
347+
// which package id); the verdict and its words are the runtime's,
348+
// so the author reads here exactly what the server would print.
349+
//
350+
// Right after the parse, ahead of the rule table: this is the
351+
// runtime's own accept set, the same class as the schema, and
352+
// 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`).
355+
const containerNameRefusals = findViewContainerNameRefusals(result.data as Record<string, unknown>);
356+
if (containerNameRefusals.length > 0) {
357+
if (flags.json) {
358+
await emitJson({
359+
valid: false,
360+
errors: containerNameRefusals,
361+
// [#12047] Every exit carries the lists the run has computed so
362+
// far — here the pre-parse ones only, and the conversion notices
363+
// `normalizeStackInput` filled at step 2.
364+
warnings: warningsSoFar(),
365+
conversions: conversionNotices,
366+
duration: timer.elapsed(),
367+
});
368+
this.exit(1);
369+
}
370+
const n = containerNameRefusals.length;
371+
console.log('');
372+
printError(`The server would refuse this stack at boot (${n} view container${n > 1 ? 's' : ''})`);
373+
printBulletList(
374+
containerNameRefusals.map((r) => r.message),
375+
{ noun: 'view-container refusal(s)', remedy: JSON_FULL_LIST_REMEDY },
376+
);
377+
this.exit(1);
378+
}
379+
331380
// 3. The author-time rule registry (#4409). Every rule the three authoring
332381
// commands share — expressions, view shape, widget/action/filter/name
333382
// references, SDUI styling, page sources, security posture, the CLI's
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20331] `findViewContainerNameRefusals` — the WALK `os validate` runs the
5+
* boot registrar's view-container `name` judgment over.
6+
*
7+
* The verdict is `@objectstack/objectql`'s `viewContainerNameRefusal`, the
8+
* function the boot loop throws the answer of; its own file pins that. What
9+
* this module owns, and what is pinned here, is which `views:` entries it hands
10+
* that judge and under which package id — the load path's answer:
11+
*
12+
* - no `packages[]` → the top-level `views`, owned by `manifest.id`;
13+
* - `packages[]` → each body's own `views`, owned by that package, and the
14+
* top level NOT at all (the load path does not register from it).
15+
*
16+
* Every row's message is asserted EQUAL to the judge's answer for the same
17+
* entry, source label and id — never re-spelled — so this file cannot drift
18+
* into a second copy of the words it exists to repeat.
19+
*/
20+
21+
import { describe, expect, it } from 'vitest';
22+
import { viewContainerNameRefusal } from '@objectstack/objectql';
23+
import { findViewContainerNameRefusals } from './view-container-names.js';
24+
25+
const ID = 'com.example.vcn';
26+
27+
const view = (extra: Record<string, unknown>) => ({
28+
object: 'vcn_order_line',
29+
list: { type: 'grid', columns: [{ field: 'name' }] },
30+
...extra,
31+
});
32+
33+
const divergent = view({ name: 'order_line' });
34+
35+
const manifest = (id: string) => ({ id, name: id, version: '1.0.0', type: 'app' });
36+
37+
describe('#20331 — findViewContainerNameRefusals walks what the load path registers', () => {
38+
it('a one-package stack: the top-level `views`, under the manifest id, in the judge\'s words', () => {
39+
const rows = findViewContainerNameRefusals({ manifest: manifest(ID), views: [divergent] });
40+
expect(rows).toHaveLength(1);
41+
const expected = viewContainerNameRefusal(divergent, 'manifest', ID);
42+
expect(expected).toBeDefined();
43+
expect(rows[0]).toEqual({
44+
path: 'views[0]',
45+
code: 'VALIDATION_ERROR',
46+
httpStatus: 400,
47+
message: expected!.message,
48+
});
49+
expect(rows[0].message).toContain(`from manifest '${ID}'`);
50+
});
51+
52+
it('reports EVERY divergent container, located, and none of the others', () => {
53+
const rows = findViewContainerNameRefusals({
54+
manifest: manifest(ID),
55+
views: [
56+
view({ name: 'vcn_order_line' }),
57+
divergent,
58+
view({}),
59+
view({ name: 'order_line_two' }),
60+
],
61+
});
62+
expect(rows.map((r) => r.path)).toEqual(['views[1]', 'views[3]']);
63+
});
64+
65+
it('CONTROL: a matching `name`, an absent one, and no `views` at all report nothing', () => {
66+
expect(findViewContainerNameRefusals({ manifest: manifest(ID), views: [view({ name: 'vcn_order_line' })] }))
67+
.toEqual([]);
68+
expect(findViewContainerNameRefusals({ manifest: manifest(ID), views: [view({})] })).toEqual([]);
69+
expect(findViewContainerNameRefusals({ manifest: manifest(ID) })).toEqual([]);
70+
});
71+
72+
it('a `packages[]` stack: each body\'s own `views`, under THAT package\'s id', () => {
73+
const rows = findViewContainerNameRefusals({
74+
packages: [
75+
{ manifest: { ...manifest('com.example.core'), views: [view({ name: 'vcn_order_line' })] } },
76+
{ manifest: { ...manifest('com.example.orders'), views: [divergent] } },
77+
],
78+
});
79+
expect(rows).toHaveLength(1);
80+
expect(rows[0].path).toBe('packages[1].manifest.views[0]');
81+
expect(rows[0].message).toBe(viewContainerNameRefusal(divergent, 'manifest', 'com.example.orders')!.message);
82+
});
83+
84+
it('a `packages[]` stack: the top-level `views` is NOT judged, because the load path does not register it', () => {
85+
// `resolveArtifactPackageOrder` returns the package bodies alone once
86+
// `packages` is present, so a top-level copy never reaches the registrar.
87+
// Judging it here would refuse a stack the server loads.
88+
const rows = findViewContainerNameRefusals({
89+
manifest: manifest(ID),
90+
views: [divergent],
91+
packages: [{ manifest: { ...manifest('com.example.core'), views: [view({ name: 'vcn_order_line' })] } }],
92+
});
93+
expect(rows).toEqual([]);
94+
});
95+
});
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `os validate`'s author-time half of the boot registrar's divergent
5+
* view-container `name` refusal (#20331).
6+
*
7+
* ## The defect
8+
*
9+
* A `views:` container whose own `name` disagrees with the object key it binds
10+
* to — `{ name: 'order_line', object: 'my_app_order_line', list: {…} }` — is
11+
* refused at boot by `ObjectQL.registerMetadataCollections` (#7378 row 1).
12+
* `os validate` had no counterpart, so it exited 0 on that stack and
13+
* `os serve` then refused it. `os validate` is the author-time judge of what
14+
* the runtime will accept (NORTH-STAR road step ①), and a green validate
15+
* followed by a boot refusal is the silent-validator shape.
16+
*
17+
* ## One judge, not a second rule
18+
*
19+
* {@link viewContainerNameRefusal} is `@objectstack/objectql`'s, the SAME
20+
* function the boot registrar throws the answer of. ⛔ Not an `@objectstack/lint`
21+
* rule and not a re-spelling of the check here: a twin agrees with the
22+
* runtime only until one of the two is edited, which is the drift this card
23+
* exists to close. What this module owns is the WALK — which `views:` entries
24+
* boot judges, and under which package id — and nothing about the verdict.
25+
*
26+
* ## The walk is the boot path's
27+
*
28+
* `AppPlugin` hands the manifest service `{ ...stack.manifest, ...stack }`;
29+
* `resolveArtifactPackageOrder` returns that payload itself when it carries no
30+
* `packages` key and each `packages[i].manifest` body otherwise (ADR-0130 D4);
31+
* `ObjectQL.registerApp(body)` then runs `registerMetadataCollections(body,
32+
* body.id || body.name, 'manifest')`. So:
33+
*
34+
* - no `packages[]` → the top-level `views`, owned by the manifest's id;
35+
* - `packages[]` → each body's own `views`, owned by that package's id,
36+
* and ⛔ NOT the top level, which the load path does not register from.
37+
*
38+
* The package id is read through the owners that already exist for it: the
39+
* runtime's `artifactPackageId` (`@objectstack/core` — "every seam that has to
40+
* name a package reads the id through THIS function") for the one-package
41+
* payload, and this package's `artifactPackages` for `packages[]`, the reader
42+
* both `os build` and `os validate` already walk with.
43+
*
44+
* ⚠️ Bound, stated rather than hidden: a nested `plugins[]` entry's `views` is
45+
* registered by boot too (label `nested plugin`), and is NOT walked here. The
46+
* stack schema types `plugins` as `unknown[]` — in an authored config they are
47+
* runtime plugin instances, not metadata bundles — so this door has no parsed
48+
* shape to walk there.
49+
*
50+
* Reads the PARSED stack — what `defineStack()` hands the boot wrap, and what
51+
* `os build` serializes.
52+
*/
53+
54+
import { artifactPackageId } from '@objectstack/core';
55+
import { viewContainerNameRefusal } from '@objectstack/objectql';
56+
57+
import { artifactPackages } from './artifact-packages.js';
58+
59+
/** One refusal, located. `message` is the boot registrar's, verbatim. */
60+
export interface ViewContainerNameRefusalRow {
61+
/** Where the entry sits in the parsed stack, e.g. `views[0]`. */
62+
path: string;
63+
/** The ADR-0112 code the boot registrar throws with. */
64+
code: string;
65+
/** The HTTP status the boot registrar throws with. */
66+
httpStatus: number;
67+
/** The refusal, in the boot registrar's own words. */
68+
message: string;
69+
}
70+
71+
type AnyRec = Record<string, unknown>;
72+
73+
const asRec = (v: unknown): AnyRec | undefined =>
74+
v && typeof v === 'object' && !Array.isArray(v) ? (v as AnyRec) : undefined;
75+
76+
/**
77+
* Every `views:` container in this stack that the boot registrar would refuse
78+
* for a divergent `name`, in the order boot meets them within each body.
79+
*
80+
* Returns `[]` for a stack the boot registrar accepts on this axis.
81+
*/
82+
export function findViewContainerNameRefusals(parsed: AnyRec): ViewContainerNameRefusalRow[] {
83+
const bodies: Array<{ at: string; ownerId: string | undefined; views: unknown }> = [];
84+
// The resolver's own branch test (`declared === undefined`). On the PARSED
85+
// stack a present `packages` is an array — `ArtifactPackageSchema[]`,
86+
// `.optional()`, so `null` and every non-array were refused at the parse.
87+
if (parsed.packages !== undefined) {
88+
for (const pkg of artifactPackages(parsed)) {
89+
bodies.push({ at: `packages[${pkg.index}].manifest.views`, ownerId: pkg.id, views: pkg.body.views });
90+
}
91+
} else {
92+
const manifest = asRec(parsed.manifest);
93+
const payload = manifest ? { ...manifest, ...parsed } : parsed;
94+
bodies.push({ at: 'views', ownerId: artifactPackageId(payload), views: parsed.views });
95+
}
96+
97+
const rows: ViewContainerNameRefusalRow[] = [];
98+
for (const { at, ownerId, views } of bodies) {
99+
if (!Array.isArray(views)) continue;
100+
views.forEach((entry, index) => {
101+
const refusal = viewContainerNameRefusal(entry, 'manifest', ownerId);
102+
if (!refusal) return;
103+
rows.push({
104+
path: `${at}[${index}]`,
105+
code: refusal.code,
106+
httpStatus: refusal.httpStatus,
107+
message: refusal.message,
108+
});
109+
});
110+
}
111+
return rows;
112+
}

‎packages/cli/test/build-text-face-advisory-count.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,11 +187,11 @@ const ordersObjects = [{
187187
}];
188188
const ordersViews = [
189189
{
190-
name: 'bc_account_list', label: 'Account List', object: 'bc_account',
190+
name: 'bc_account', label: 'Account List', object: 'bc_account',
191191
list: { label: 'Account List', columns: ['name', 'industry'] },
192192
},
193193
{
194-
name: 'bc_order_list', label: 'Order List', object: 'bc_order',
194+
name: 'bc_order', label: 'Order List', object: 'bc_order',
195195
list: { label: 'Order List', columns: ['name', 'account'] },
196196
},
197197
];

‎packages/cli/test/format-zod-union.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ const TOOLTIP_ALIAS_STACK = {
185185
manifest: { id: 'com.example.union-probe', name: 'Union Probe', namespace: 'union_probe', version: '1.0.0', type: 'app' },
186186
views: [
187187
{
188-
name: 'union_probe_view',
188+
name: 'union_probe_obj',
189189
object: 'union_probe_obj',
190190
list: {
191191
name: 'union_probe_list',

‎packages/cli/test/info-detail-package-fold.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ const OBJECTS = [
9191

9292
const VIEWS = [
9393
{
94-
name: 'ob_order_views',
94+
name: 'ob_order',
9595
object: 'ob_order',
9696
list: {
9797
label: 'Orders',

‎packages/cli/test/lint-handwritten-checks-package-fold.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ const definitions = () => ({
103103
],
104104
views: [
105105
{
106-
name: 'probe_view',
106+
name: 'probe_order',
107107
object: 'probe_order',
108108
list: { label: 'order list', columns: ['number'] }, // convention/label-case
109109
},

‎packages/cli/test/lint-label-case-localized.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,13 +92,13 @@ const stackWithApp = (label: unknown) => ({
9292

9393
const stackWithListLabel = (label: unknown) => ({
9494
manifest: MANIFEST,
95-
views: [{ name: 'invoice_views', object: 'invoice', list: { label, type: 'grid', columns: ['name'] } }],
95+
views: [{ name: 'invoice', object: 'invoice', list: { label, type: 'grid', columns: ['name'] } }],
9696
});
9797

9898
const stackWithNamedListLabel = (label: unknown) => ({
9999
manifest: MANIFEST,
100100
views: [{
101-
name: 'invoice_views',
101+
name: 'invoice',
102102
object: 'invoice',
103103
listViews: { all: { label, type: 'grid', columns: ['name'] } },
104104
}],

‎packages/cli/test/lint-per-package-authoring-parity.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,11 +152,11 @@ const ordersObjects = [{
152152
}];
153153
const ordersViews = [
154154
{
155-
name: 'pp_account_list', label: 'Account List', object: 'pp_account',
155+
name: 'pp_account', label: 'Account List', object: 'pp_account',
156156
list: { label: 'Account List', columns: ['name', 'industry'] },
157157
},
158158
{
159-
name: 'pp_order_list', label: 'Order List', object: 'pp_order',
159+
name: 'pp_order', label: 'Order List', object: 'pp_order',
160160
list: { label: 'Order List', columns: ['name', 'account'] },
161161
},
162162
];

0 commit comments

Comments
 (0)