Skip to content

Commit ae8e3ca

Browse files
fix(lint)!: refuse a view container whose object names no object (#20253)
Fixes #20216 Clause-②: no (narrowing) **Landing order: PR #20228 → PR #20229 → this PR.** #20228 has merged. #20229 (the `packages[]` read in `artifactProvidedObjectNames`, still in a rework round) edits the same file. This PR does not touch that hunk, its import line, or `packages/lint/src/object-graph.ts`, and a local `git merge-tree` of this branch against #20229's head `a4b05d6` writes a clean tree. The seat enqueues this PR after #20229 merges, and this branch merges `origin/main` then. ## CI fix round: `Test Core (6/6)`, and a file-surface amendment - **What went red:** `packages/cli/test/generate-scaffold-validates.test.ts`, "`os g 'view'` writes a stack `os validate` accepts", got `object-reference-unknown at views[0].object: view container object "probe_thing" …`. - **Why:** the harness judged each scaffold in a host stack holding only the collection under test. The view scaffold binds `probe_thing`, the object `os g object probe_thing` writes, but no object was in the stack. The new leg refused it correctly: the omission was the harness's, not the template's. Reproduced red locally before the fix (1 failed, 17 passed). - **Fix (test fixture only):** every `namesObject` generator other than `object` itself (view, action, flow, app) is now judged beside the object scaffold for the same name, materialized through the same `bundle-require` loader. A new pin asserts the view's binding and the seeded object's `name` are the same spelling, and that a non-binding generator (`dashboard`) and `object` itself carry no seeded object. ⛔ The rule is not weakened, `probe_thing` is not special-cased, and no test is skipped. - **File-surface amendment:** `packages/cli/test/generate-scaffold-validates.test.ts`, test fixture only. No other CLI fixture needed a change (see the runs below). - **Other generators:** action, flow and app also bind `probe_thing`. They passed before the seeding and still pass with it. ## What changed A view container's own `object` (`ViewSchema.object`) is the key the runtime indexes views by (`getViewsByObject()` / `GET /meta/view?object=`). Nothing resolved it at authoring time: `defineStack`'s `validateCrossReferences` reads a container's `list.data` / `form.data` bindings, never the container's own key. - `packages/lint/src/validate-object-references.ts` gains a view-container leg beside the relationship-target leg. It uses the same `check` ladder and the same `resolvable` set: the stack's objects plus what its `packages[]` provide. - An unresolved unprefixed name is an **error**, `object-reference-unknown` at `views[N].object`. - A known platform object passes. - A platform-shaped name that nothing registers gets the existing `object-reference-unregistered-platform` advisory. - The refusal is located and carries a prescription: - it lists the objects the stack declares (`Defined objects: ...`); - when the bound name is exactly a declared object minus the stack's `manifest.namespace` prefix, the hint names that object: `write "my_app_order_line", not "order_line"`. - It gates like its sibling. It is the same member of the same reference-integrity suite entry, so `os validate`, `os build` and `os lint` all run it at the same tier. - Not judged, on purpose: - a container with no `object` (its binding falls back to `list.data.object` / `form.data.object` / `name`, which is a different reference); - a runtime-authored container (#13407 is out of scope). This member does not run on a `view` write at the runtime publish gate, and the runtime is untouched. - ⛔ No second copy in `packages/spec/src/stack.zod.ts`. ## Measured at the public door (CLI built at this branch) The scratch project has `manifest.namespace: 'my_app'`, an object `my_app_order_line`, and a view container with `object: 'order_line'`. | run | `os validate` | `os build` | |:--|:--|:--| | leg disabled (ablation, lint rebuilt, marker proven in `dist/`) | exit 0, "Validation passed", nothing about the view | not run | | this branch, `object: 'order_line'` | exit 1, `object-reference-unknown at views[0].object`, hint names `my_app_order_line` | exit 1, same rule and path | | this branch, `object: 'my_app_order_line'` (control) | exit 0 | exit 0 | `os generate view order_line` in the same namespaced project (after PR #20214) writes `object: 'my_app_order_line'`, which passes. ## Census of producers (H2) The instrument is one TypeScript-AST scan at `0d60f88760`. It reads `examples/**`, `packages/**` (tests and fixtures included), `skills/**`, and the ts/js code fences in `content/docs/**` md/mdx (generated `references/` excluded). It covers 7242 files and counts object literals that carry a view-container slot (`list`/`form`/`listViews`/`formViews`). A name resolves when it is declared by an object literal (`name` + `fields`) anywhere in the corpus, or when it is a platform-provided object. | tree | containers | carrying `object` | unresolved | |:--|--:|--:|--:| | examples | 10 | 0 | 0 | | packages | 367 | 105 | 11 | | skills | 3 | 0 | 0 | | content/docs | 21 | 0 | 0 | - **Control from the same instrument:** 94 of the 105 containers carrying `object` resolve. - **No example or platform package ships a dangling container.** No example container carries `object` at all; they bind through `list.data.object`. - **The 11 unresolved:** - 10 are non-literal `object` expressions in code, not stored views: schema/`strictObject` definitions in `view.zod.ts`, walkers in `validate-translation-references.ts` / `validate-translatable-sections.ts` / `protocol.ts`, a helper in a rest measurement test, and the parameterised helper in this PR's own test. - 1 literal: `packages/cli/test/format-zod-union.test.ts` (`union_probe_obj`). That specimen fails schema parse first, which that file asserts as exactly one `invalid_union` issue. `os validate` exits at the schema step, so author-time rules never run on it. No pin flips. - **Pin sweep ①:** grepping `object-reference-unknown` and the rule's message across the repo found no pin on a view container. The pins in `packages/cli` (`artifact-packages.test.ts`, `build-multi-package-artifact.e2e.test.ts`, `union-fold-command-parity.test.ts`) have fixtures with no container `object`, so none flips. **②:** nothing flipped, so no load-bearing re-pin was owed. ## Tests (at `60808317dc` unless marked) - `pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2`: **109 files / 4242 tests passed**. - `pnpm --filter @objectstack/lint typecheck`: exit 0 (`tsc --noEmit` plus `check:test-typecheck: OK`). - `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2`: **227 files / 3219 tests passed**. This includes the fixed `generate-scaffold-validates.test.ts` (19 of 19). - CLI integration tier, the 21 files that reference views or scaffolds (`--project integration`): **21 files / 196 tests passed**. - `pnpm --filter @objectstack/cli typecheck`: exit 0 (`check:test-typecheck: OK`). - Nightly tier (`OS_TEST_TIERS=nightly`), 7 e2e files on views or scaffolds: 6 files passed. One test failed in `generate-agent-retired.e2e.test.ts` ("`os g object … --dry-run` still previews a typed object file"). It expects `import * as Data from '@objectstack/spec/data'`, but the object template writes `import { ObjectSchema }` since #20195. That failure is independent of this PR: neither file differs from the merge base (0 diff lines). - New pins in `validate-object-references.test.ts` (two new `describe` blocks, appended so they stay clear of #20229's hunk): - `object: 'order_line'` in a `my_app` stack is refused, naming `my_app_order_line`; the prefixed container is the control; - the map form of `views` is read; - a non-prefix miss is refused without the namespace prescription; - a container over a `packages[]` sibling's object passes, and the same package alone is refused (control); - `sys_user` passes and `sys_approval_process` advises; - no views, `views: []`, and a container with no `object` stay silent; - the finding reaches the gating tier of `runAuthoringRules` for `validate`, `build` and `lint`. - **Unit ablation** (`scripts/ablation-replace.mjs`, anchor `const bound = strName(view.object);`, anchor count 1 to 0, blob `27699c9` to `03f6f47`): **8 refusal pins red; 48 green, including both controls (prefixed container, silence).** Restored with blob equal to HEAD and `git diff HEAD` empty. Taken at `9fa1ff4c61`. Since then only comment lines changed in `src`. - **Door ablation:** marker planted, `@objectstack/lint` rebuilt, `ablation-dist-preflight` found the marker in 4 built files, and `os validate` exited 0. Then the restore leg: blob equal to HEAD, lint rebuilt, preflight `--absent` confirmed the marker gone from all 14 built files with a clean tree, and `os validate` exited 1 again. ## Gates (derived by `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` at `60808317dc`) - 60 families derived (one new: `check:cli-test-child-env`, green) and all run at `60808317dc`. `--ran` reconciliation with exit codes: 59 run, 1 NOT MEASURED, 0 unrun. - The ones this diff actually moves are green: `check-adr-0087-registration` (disposition `not-required (no-migration-prescription)` accepted), `check-changeset-no-major`, `check-empty-changeset`, `check:doc-authoring`, `check:nul-bytes`, `check-closing-keyword-parity`, `check:published-files`, `check:type-check-coverage`, `check:test-source-alias`. - `check:type-check-debt` now measures green (`none above its recorded number`). - NOT MEASURED: `check:dual-build-cjs-loads` (exit 3, PREREQUISITE NOT MET: it needs every package's `dist/`, and this box built the lint and CLI closures only). Declared to CI. - Correction to the first round: I declared the `packages/cli` suites to CI without running them, and the census missed `os g view` because its container sits inside a template string the AST scan cannot see. That is the red this round fixes. The CLI unit project now runs in full here. ## Changeset `.changeset/20216-view-container-object-refused.md`: - `@objectstack/lint: minor`, carrying a **BREAKING** banner and `Clause-②: no (narrowing)`; - a before/after accept-set table; - ADR-0087 `not-required (no-migration-prescription)`: nothing authorable moves in spec, and which object an author meant is a fact about their stack, not a mechanical conversion. The table is a behaviour table (door: FROM exit 0, TO exit 1). My first draft headed it "FROM → TO", and the ADR-0087 gate read that heading as a rewrite prescription and refused the exemption. The heading now says "before and after", and the table carries no rewrite row. ## Acceptance notes (not filed) - The namespace prescription is on this leg only. The other sites on the same rule (a field `reference`, action params, dataset base object) could give the same hint for the same missing-prefix miss. I noted it and did not widen it here. Carrier: none. - A container whose `object` and `list.data.object` name different objects is not judged by any rule. It is out of this card's scope, and I found no instance in the census. --- _Generated by [Claude Code](https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5f9d7d7 commit ae8e3ca

4 files changed

Lines changed: 293 additions & 8 deletions

File tree

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
'@objectstack/lint': minor
3+
---
4+
5+
fix(lint)!: a view container whose `object` names no object is refused by `os validate`, `os build` and `os lint` (`object-reference-unknown`), and the refusal names the namespace-prefixed object when that is the one the stack declares (#20216)
6+
7+
Clause-②: no (narrowing)
8+
9+
**BREAKING** — an accept-set narrowing on one authored key, shipped as `minor` under the
10+
launch-window convention (`check-changeset-no-major` refuses `major` until GA; breaking-ness
11+
is carried by this banner and the ADR-0087 disposition below, not by the level).
12+
13+
**What changed.** `ViewSchema.object` is how a stack-level `views: [...]` container says
14+
which object its views belong to, and it is the key the runtime indexes views by
15+
(`getViewsByObject()` / `GET /meta/view?object=`). The schema declares it `z.string()`, and
16+
nothing resolved it: `defineStack`'s cross-reference check reads a container's
17+
`list.data` / `form.data` bindings, never the container's own key. So a container bound to a
18+
name no object carries passed: `os validate` printed "Validation passed" and exited 0,
19+
saying nothing about the view, and `os build` / `os lint` run the same rule table. At
20+
runtime none of its views was found for any object. The common case is not a typo but a
21+
missing namespace prefix — `object: 'order_line'` in a project whose object is
22+
`my_app_order_line` — which is exactly what `os generate view` wrote in every namespaced
23+
project until its template learned the prefix.
24+
25+
The key now joins `validateObjectReferences` and rides the ladder every other object-name
26+
site on that rule uses, resolved against the same set as a field's relationship target:
27+
28+
1. the stack's own objects, or an object an entry of the artifact's `packages[]` provides → ok;
29+
2. a known platform object (`PLATFORM_PROVIDED_OBJECT_NAMES`) → ok;
30+
3. unresolved and not platform-prefixed → **`error`** `object-reference-unknown` at
31+
`views[N].object`, so `os validate` / `os build` / `os lint` exit 1;
32+
4. unresolved, platform-prefixed, registered by nothing → the existing
33+
`object-reference-unregistered-platform` advisory.
34+
35+
The refusal lists the objects the stack does declare, and when the bound name is exactly a
36+
declared object minus the stack's `manifest.namespace` prefix, the hint names that prefixed
37+
object outright. Not judged, on purpose: a container that carries no `object` (its binding
38+
then falls back to `list.data.object` / `form.data.object` / its `name`, a different
39+
reference), and a container authored at runtime (this rule does not run on a `view` write at
40+
the runtime publish gate; that door is unchanged).
41+
42+
## The accept set, before and after
43+
44+
This is a behaviour table, not a rewrite: the FROM column is what the door did, the TO
45+
column is what it does now.
46+
47+
| where | FROM | TO |
48+
|:--|:--|:--|
49+
| `os validate`, `os build`, `os lint` on a view container bound to a name no object carries | exit 0, no finding | exit 1, `object-reference-unknown` at `views[N].object` |
50+
| the same, on a platform-prefixed name nothing registers | exit 0, no finding | the `object-reference-unregistered-platform` advisory, exit unchanged |
51+
| a runtime `view` write | unchanged | unchanged |
52+
53+
Nothing an author writes changes spelling, and no key or value is retired. A container that
54+
is refused was already dead at runtime; the finding's own hint says which object to bind it
55+
to.
56+
57+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable moves: `packages/spec` is untouched, `ViewSchema.object` keeps its key, its type and its legality, and no stored metadata representation changes shape, so `objectstack migrate meta` has nothing to rewrite and the ledger has no row to gain. What narrows is the set of VALUES the author-time rule accepts for a reference that must resolve to a declared object, and which declared object an author meant is a fact about their stack, never something a mechanical conversion can derive; the refusal carries its own correction. The other categories are closed on facts: `@objectstack/lint` publishes (not `unpublished`); no ADR-0087 id covers it (not `registered` / `already-registered`); and the change is rule behaviour, not a TypeScript declaration (not `runtime-interface-only` / `type-surface-only`). -->

‎packages/cli/test/generate-scaffold-validates.test.ts‎

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -132,31 +132,64 @@ afterAll(() => {
132132
fs.rmSync(TMP_ROOT, { recursive: true, force: true });
133133
});
134134

135-
/** A legal, minimal host stack. Only the collection under test is populated. */
136-
const hostStack = (collection: string, artifact: unknown) => ({
135+
/**
136+
* A legal, minimal host stack: the collection under test, plus the objects a
137+
* binding scaffold needs present (see {@link boundObjects}).
138+
*/
139+
const hostStack = (collection: string, artifact: unknown, objects: readonly unknown[] = []) => ({
137140
manifest: {
138141
id: 'com.example.scaffold',
139142
name: 'scaffold',
140143
version: '1.0.0',
141144
type: 'app' as const,
142145
namespace: 'scaffold',
143146
},
147+
...(objects.length > 0 ? { objects: [...objects] } : {}),
144148
[collection]: [artifact],
145149
});
146150

151+
/** Materialize one scaffold through the loader `os validate` uses (see the header). */
152+
async function loadScaffold(fileStem: string, source: string): Promise<unknown> {
153+
const file = path.join(TMP_ROOT, `${fileStem}.scaffold.ts`);
154+
fs.writeFileSync(file, source, 'utf8');
155+
const { mod } = await bundleRequire({ filepath: file, external: BUNDLE_REQUIRE_EXTERNALS });
156+
return (mod as { default?: unknown }).default ?? mod;
157+
}
158+
159+
/**
160+
* The object a BINDING scaffold names, as `os g object` writes it for the same
161+
* name — the precondition the author's own project supplies.
162+
*
163+
* A generator flagged `namesObject` (other than `object` itself) writes a
164+
* binding to `objectNameFor(STEM)`: a view container's `object`, an action's or
165+
* a flow start node's `objectName`, an app nav entry's `objectName`. The
166+
* templates are written to COMPOSE — `os g object NAME` then `os g view NAME`
167+
* — so each binding names exactly the object the object scaffold declares.
168+
* Validating a binding scaffold in a stack WITHOUT that object judged it against
169+
* an empty object set, and once the author-time rules resolved a view
170+
* container's `object` (`object-reference-unknown` at `views[0].object`), the
171+
* harness's own omission read as the scaffold's defect. So the object is
172+
* scaffolded here, through the same loader, and carried beside the artifact —
173+
* ⛔ never special-cased in a rule, and ⛔ never the scaffold under test edited
174+
* to fit the harness.
175+
*/
176+
async function boundObjects(type: string): Promise<unknown[]> {
177+
const target = GENERATOR_SCAFFOLD_TARGETS.find((t) => t.type === type);
178+
if (!target?.namesObject || type === 'object') return [];
179+
const objectTarget = GENERATOR_SCAFFOLD_TARGETS.find((t) => t.type === 'object');
180+
if (!objectTarget) throw new Error('the `object` generator must exist to seed a binding scaffold');
181+
return [await loadScaffold('bound-object', objectTarget.generate(STEM))];
182+
}
183+
147184
/**
148185
* Load a scaffold the way `os validate` loads authored TypeScript, then run
149186
* the two steps `Validate.run()` runs on it.
150187
*/
151188
async function validateScaffold(type: string, source: string) {
152-
const file = path.join(TMP_ROOT, `${type}.scaffold.ts`);
153-
fs.writeFileSync(file, source, 'utf8');
154-
155-
const { mod } = await bundleRequire({ filepath: file, external: BUNDLE_REQUIRE_EXTERNALS });
156-
const artifact = (mod as { default?: unknown }).default ?? mod;
189+
const artifact = await loadScaffold(type, source);
157190

158191
const normalized = normalizeStackInput(
159-
hostStack(singularToPlural(type), artifact) as Record<string, unknown>,
192+
hostStack(singularToPlural(type), artifact, await boundObjects(type)) as Record<string, unknown>,
160193
) as Record<string, unknown>;
161194

162195
const unknownKeys = [
@@ -203,6 +236,22 @@ describe('[#14087] every `os generate` scaffold passes `os validate`', () => {
203236
expect(Object.keys(KNOWN_UNVALIDATED_SCAFFOLDS)).not.toContain('flow');
204237
});
205238

239+
it('a binding scaffold is judged beside the object `os g object` writes for the same name', async () => {
240+
// The precondition `boundObjects` supplies is only honest while the view's
241+
// binding and the object's name are the SAME spelling. Pinned directly, so
242+
// a template drifting one side of the pair turns this red rather than
243+
// quietly handing the view scaffold an object it does not bind.
244+
const [object] = (await boundObjects('view')) as { name?: unknown }[];
245+
const view = GENERATOR_SCAFFOLD_TARGETS.find((t) => t.type === 'view');
246+
expect(view, 'the view generator must exist').toBeDefined();
247+
const container = (await loadScaffold('view-binding', view!.generate(STEM))) as { object?: unknown };
248+
expect(object?.name).toBe(STEM);
249+
expect(container.object).toBe(object?.name);
250+
// …and a non-binding generator is judged with no object carried at all.
251+
expect(await boundObjects('dashboard')).toEqual([]);
252+
expect(await boundObjects('object')).toEqual([]);
253+
});
254+
206255
const clean = GENERATOR_SCAFFOLD_TARGETS.filter((t) => !(t.type in KNOWN_UNVALIDATED_SCAFFOLDS));
207256
const known = GENERATOR_SCAFFOLD_TARGETS.filter((t) => t.type in KNOWN_UNVALIDATED_SCAFFOLDS);
208257

‎packages/lint/src/validate-object-references.test.ts‎

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
OBJECT_REFERENCE_UNKNOWN,
77
OBJECT_REFERENCE_UNREGISTERED_PLATFORM,
88
} from './validate-object-references.js';
9+
import { runAuthoringRules, splitBySeverity } from './authoring-rules.js';
910

1011
/** Minimal stack with one own object, mirroring the HotCRM shape. */
1112
const baseStack = () => ({
@@ -685,3 +686,121 @@ describe('[#19289] validateObjectReferences — a `user` target comes from the T
685686
expect(findings[0].rule).toBe(OBJECT_REFERENCE_UNKNOWN);
686687
});
687688
});
689+
690+
/**
691+
* [#20216] A view CONTAINER's own `object` — the key `getViewsByObject()`
692+
* indexes by. Modelled on an `os init -t app` project (namespace `my_app`),
693+
* where the object is `my_app_order_line` and the pre-fix `os generate view`
694+
* template wrote the bare short name.
695+
*/
696+
describe('[#20216] validateObjectReferences — view container `object`', () => {
697+
const appStack = (views: unknown, extra: Record<string, unknown> = {}) => ({
698+
manifest: { id: 'com.example.my_app', namespace: 'my_app' },
699+
objects: [
700+
{ name: 'my_app_order', fields: { name: { type: 'text' } } },
701+
{ name: 'my_app_order_line', fields: { name: { type: 'text' } } },
702+
],
703+
views,
704+
...extra,
705+
});
706+
const container = (object: string, name = 'order_line') => ({
707+
name,
708+
label: 'Order Line',
709+
object,
710+
list: { type: 'grid', columns: [{ field: 'name' }] },
711+
});
712+
713+
it('refuses a container bound to the un-prefixed short name, and names the prefixed object', () => {
714+
const findings = validateObjectReferences(appStack([container('order_line')]));
715+
expect(findings).toHaveLength(1);
716+
const [f] = findings;
717+
expect(f.severity).toBe('error');
718+
expect(f.rule).toBe(OBJECT_REFERENCE_UNKNOWN);
719+
expect(f.where).toBe('view "order_line"');
720+
expect(f.path).toBe('views[0].object');
721+
expect(f.message).toContain('view container object "order_line"');
722+
// The namespace prescription names the ONE spelling that resolves…
723+
expect(f.hint).toContain('manifest.namespace "my_app"');
724+
expect(f.hint).toContain('write "my_app_order_line", not "order_line"');
725+
// …and the hint still lists every object the stack does declare.
726+
expect(f.hint).toContain('Defined objects: my_app_order, my_app_order_line.');
727+
// What the miss costs, not only that it is a miss.
728+
expect(f.hint).toContain('getViewsByObject()');
729+
});
730+
731+
it('CONTROL — the correctly prefixed container is clean', () => {
732+
expect(validateObjectReferences(appStack([container('my_app_order_line')]))).toEqual([]);
733+
});
734+
735+
it('reads the map form of `views` too, locating the finding by position', () => {
736+
const findings = validateObjectReferences(
737+
appStack({ order_line: { object: 'order_line', list: { type: 'grid', columns: ['name'] } } }),
738+
);
739+
expect(findings.map((f) => [f.path, f.where, f.rule])).toEqual([
740+
['views[0].object', 'view "order_line"', OBJECT_REFERENCE_UNKNOWN],
741+
]);
742+
});
743+
744+
it('a miss that is NOT a missing prefix is refused without the namespace prescription', () => {
745+
const findings = validateObjectReferences(appStack([container('invoice')]));
746+
expect(findings).toHaveLength(1);
747+
expect(findings[0].severity).toBe('error');
748+
expect(findings[0].path).toBe('views[0].object');
749+
// `my_app_invoice` is not declared, so prefixing is not the fix and is not offered.
750+
expect(findings[0].hint).not.toContain('namespace prefix');
751+
expect(findings[0].hint).toContain('Defined objects: my_app_order, my_app_order_line.');
752+
});
753+
754+
it('passes a container over an object a `packages[]` sibling provides (rung ①, artifact scope)', () => {
755+
const CORE_BODY = { id: 'com.example.core', objects: [{ name: 'crm_account', fields: {} }] };
756+
const UI_BODY = { id: 'com.example.ui', namespace: 'crm', views: [container('crm_account', 'crm_account')] };
757+
const perPackage = { ...UI_BODY, manifest: UI_BODY, packages: [{ manifest: UI_BODY }, { manifest: CORE_BODY }] };
758+
expect(validateObjectReferences(perPackage)).toEqual([]);
759+
// Control: the same package judged ALONE refuses it — the context is what resolves it.
760+
const alone = validateObjectReferences({ ...UI_BODY, manifest: UI_BODY });
761+
expect(alone.map((f) => [f.path, f.severity])).toEqual([['views[0].object', 'error']]);
762+
});
763+
764+
it('keeps the platform ladder: a known platform object passes, an unregistered platform name advises', () => {
765+
expect(validateObjectReferences(appStack([container('sys_user', 'sys_user')]))).toEqual([]);
766+
const findings = validateObjectReferences(appStack([container('sys_approval_process', 'approvals')]));
767+
expect(findings).toHaveLength(1);
768+
expect(findings[0].severity).toBe('warning');
769+
expect(findings[0].rule).toBe(OBJECT_REFERENCE_UNREGISTERED_PLATFORM);
770+
expect(findings[0].path).toBe('views[0].object');
771+
});
772+
773+
it('stays silent on a stack with no views, and on a container that carries no `object`', () => {
774+
expect(validateObjectReferences(appStack(undefined))).toEqual([]);
775+
expect(validateObjectReferences(appStack([]))).toEqual([]);
776+
// No top-level `object`: the binding falls back to `list.data.object` /
777+
// `name`, a different reference this leg does not own.
778+
const unbound = { list: { type: 'grid', data: { provider: 'object', object: 'order_line' }, columns: ['name'] } };
779+
expect(validateObjectReferences(appStack([unbound]))).toEqual([]);
780+
});
781+
});
782+
783+
/**
784+
* [#20216] The same finding through the ONE rule table all three commands run
785+
* (`runAuthoringRules`), at the gating tier — so `os validate`, `os build` and
786+
* `os lint` exit 1 on it exactly as they do on the sibling relationship-target
787+
* leg, rather than the rule merely existing.
788+
*/
789+
describe('[#20216] a dangling view container `object` gates every CLI command', () => {
790+
const stack = (object: string) => ({
791+
manifest: { id: 'com.example.my_app', namespace: 'my_app' },
792+
objects: [{ name: 'my_app_order_line', fields: { name: { type: 'text' } } }],
793+
views: [{ name: 'order_line', object, list: { type: 'grid', columns: [{ field: 'name' }] } }],
794+
});
795+
const hits = (command: 'validate' | 'build' | 'lint', object: string) =>
796+
splitBySeverity(runAuthoringRules(command, { normalized: stack(object) as never }))
797+
.errors.filter((f) => f.rule === OBJECT_REFERENCE_UNKNOWN)
798+
.map((f) => f.path);
799+
800+
for (const command of ['validate', 'build', 'lint'] as const) {
801+
it(`\`${command}\` refuses the un-prefixed binding and passes the prefixed one`, () => {
802+
expect(hits(command, 'order_line')).toEqual(['views[0].object']);
803+
expect(hits(command, 'my_app_order_line')).toEqual([]);
804+
});
805+
}
806+
});

‎packages/lint/src/validate-object-references.ts‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@
4646
* typed. Dead → the record picker asks the REST layer for an object that
4747
* is not registered (404 `OBJECT_NOT_FOUND`), `$expand` on the field
4848
* fails, and the form renders a control that can never resolve a value.
49+
* - a view container's own `object` (#20216) — `ViewSchema.object`, the
50+
* binding a stack-level `views: [...]` entry uses to say which object its
51+
* views belong to. `getViewsByObject()` indexes views by exactly this key,
52+
* so a container bound to a name nothing registers is dead at runtime —
53+
* none of its views is ever found — while authoring stayed green.
4954
*
5055
* ── Severity ladder (the point of the rule) ──────────────────────────────
5156
*
@@ -328,6 +333,61 @@ export function validateObjectReferences(stack: AnyRec): ObjectRefFinding[] {
328333
}
329334
}
330335

336+
// ── View containers → the container's own `object` (#20216) ──
337+
// `ViewSchema.object` is `z.string()`, and nothing resolved it: `defineStack`'s
338+
// `validateCrossReferences` reads a container's `list.data` / `form.data`
339+
// bindings, never the container's own key. Yet that key is the one the
340+
// runtime indexes by — `getViewsByObject()` / `GET /meta/view?object=` match a
341+
// view's `object` against the object asked for — so a container bound to a
342+
// name no object carries is never found for ANY object. Measured at the
343+
// public door with this leg disabled: `os validate` printed "Validation
344+
// passed" and exited 0, saying nothing about the view, on
345+
// `object: 'order_line'` in a project whose object is
346+
// `my_app_order_line`. That is exactly the shape `os generate view` wrote in
347+
// every namespaced project until its template learned the prefix, and any
348+
// hand or AI author can still write it.
349+
//
350+
// The same ladder and the same `resolvable` set as the relationship leg above:
351+
// this stack's objects plus what its `packages[]` provide (rung ①), a known
352+
// platform object (rung ③), a platform-shaped miss advises (rung ④), and an
353+
// unprefixed miss is the typo class and gates (rung ②).
354+
//
355+
// The one thing this leg adds is the namespace prescription. The dominant
356+
// miss is not a typo but a MISSING PREFIX — ADR-0028 names every object
357+
// `${manifest.namespace}_${shortName}`, and the short name is what an author
358+
// naturally types — so when exactly that prefixed spelling is declared, the
359+
// hint says so by name rather than leaving the author to infer it from the
360+
// edit-distance suggestion.
361+
//
362+
// ⛔ A container with no `object` is not judged here: its binding then falls
363+
// back to `list.data.object` / `form.data.object` / its `name`
364+
// (`deriveViewContainerObject`), which is a different reference with its own
365+
// owner. ⛔ Nor is a runtime-authored container — that is the runtime's door,
366+
// and this member does not run on a `view` write (`REFERENCE_INTEGRITY_RULES`).
367+
const namespace = strName((stack.manifest as AnyRec | undefined)?.namespace);
368+
const views = recordsOf(stack.views);
369+
for (let vi = 0; vi < views.length; vi++) {
370+
const view = views[vi];
371+
const bound = strName(view.object);
372+
if (!bound) continue;
373+
const prefixed = namespace ? `${namespace}_${bound}` : undefined;
374+
const namespaceHint =
375+
prefixed && !bound.startsWith(`${namespace}_`) && resolvable.has(prefixed)
376+
? ` Object names carry the package namespace prefix (manifest.namespace "${namespace}"): ` +
377+
`write "${prefixed}", not "${bound}".`
378+
: '';
379+
check(
380+
bound,
381+
`view ${strName(view.name) ? `"${strName(view.name)}"` : `#${vi}`}`,
382+
`views[${vi}].object`,
383+
'view container object',
384+
'The runtime indexes a container\'s views by this key (`getViewsByObject()` / ' +
385+
'`GET /meta/view?object=`), so a container bound to an object nothing registers is ' +
386+
'never found: none of its views appears for any object.' +
387+
namespaceHint,
388+
);
389+
}
390+
331391
// ── Actions (global + object-embedded) → param object targets ──
332392
const checkActionParams = (action: AnyRec, actionPath: string, actionLabel: string) => {
333393
const params = recordsOf(action.params);

0 commit comments

Comments
 (0)