Skip to content

Commit 4034f47

Browse files
feat(spec,lint): object-level composite / repeater are untyped JSON — a typed list is a child object (#25755)
Fixes #25681 Clause-②: yes (widening) Metadata-protocol: semantic Ruling-ref: 6386744473 Ruling 6386744473 (batch #12 item 4, letter b), verbatim: > **b** = 不给对象字段的 `repeater` / `composite` 加子字段声明;明确写下「对象级 `repeater` / `composite` 是无类型 JSON,要带列的列表请建子对象(master_detail)」,并对期望列的 repeater 做 lint/拒收。协议不新增面。 ## What changed - **`FieldType` (`packages/spec/src/data/field.zod.ts`).** The comments on `composite` / `repeater` used to promise "declared sub-fields". They now say these are untyped JSON containers of CMS lineage (Strapi component, ACF group or repeater), that `FieldSchema` has no sub-field member, and that a typed list is a child object with a `master_detail` field (`inlineEdit: 'grid'` edits it inline on the parent's form). No schema change: the enum, `FieldSchema` and every generated artifact are byte-identical (`check:generated`: all 14 up to date). - **The parse refusal names the route (`packages/spec/src/data/authoring-key-lint.ts`).** Three `FIELD_KEY_GUIDANCE` rows, `fields` / `subFields` / `columns`, fed into the strict `FieldSchema` through its existing `guidance` channel. The parse already refused all three before this PR, but its message named only the key, so an author learned the key was wrong but not where the list goes. The `columns` row also names the three real column keys (`lookupColumns`, `relatedListColumns`, `inlineColumns`). This changes a message, not what parses. - **New lint rule `field/embedded-sub-fields` (error) in `lintDataModel` (`packages/lint`).** It fires when a `composite` / `repeater` object field carries `fields`, `subFields` or `columns`. It has an `os explain` entry and a one-line `fix`. Its docblock's intake table states which doors reach it (below). - **Docs.** `data-modeling/field-types.mdx` gets the same sentence plus a child-object example, the shape Salesforce, Dynamics, ServiceNow and Odoo use. `data-modeling/validation-rules.mdx` loses the false "Sub-field shapes are declared on the field". `protocol/objectui/layout-dsl.mdx` now says that a form field's `fields` list belongs to the form, not to the object field. - Not touched, by the ruling: a form view's `fields` sub-rows (`view.zod.ts`), `field.form.ts`, objectui. No member was added to `FieldSchema`. The published skill `skills/objectstack-data/rules/field-types.md` repeated "declared sub-fields". `skills/**` is a governed surface (Tier H), so that fix is its own draft PR on `claude/issue-25681-skills-repeater-untyped` (Refs #25681). This PR carries no governed path. ## Measured before building (on `origin/main` `47af3dde`) - **Premise holds.** `field.zod.ts:96`–`:97` promised "declared sub-fields", and so did `field-types.mdx:515`/`:522` and the skill file. `FieldSchema` declares no sub-field member. The `FieldType` enum has no `.describe()`, so the generated references never carried the claim. - **Mechanism (the PM's lead): the parse already refuses.** `FieldSchema.safeParse` on a `repeater` / `composite` carrying `fields`, `subFields`, `columns`, `schema`, `items`, `properties`, `itemFields`, `of` or `children` answers `unrecognized_keys` on every one. Before this PR the message was ``Unrecognized key(s) on this field: `fields`. Until this shape was closed these were dropped silently — …``: it names no route. `defineStack(x)` (strict) throws the same message. `defineStack(x, { strict: false })` ACCEPTS the field and keeps `fields` verbatim. - **So the lint arm reaches only the unparsed doors**, the posture `security-owd-alias` documents. Its docblock and `os explain` both say so: | door | reaches `field/embedded-sub-fields`? | |---|---| | `defineStack(x)` (strict), hence `os validate` / `os build` / `os lint` on such a config | no — refused at load, now naming the route | | `saveMetaItem` (Studio / REST `/meta` / MCP) | no — refused at the parse | | `os lint` on a raw object-literal config (`lintConfig` runs before any parse) | **yes** | | `defineStack(x, { strict: false })` | **yes** | | metadata-generation rubric (`scoreMetadata`: `lintConfig` on the normalized input, beside its own schema verdict) | **yes** | - **Producers.** This repo: the only object fields of these types are `examples/app-showcase/src/data/objects/field-zoo.object.ts:166`–`:167` (`f_composite`, `f_repeater`), and neither carries a sub-field key. Every other hit is a metadata FORM row (`*.form.ts`, excluded by the ruling). HotCRM at `a4b3560`: 0 object fields of type `repeater` / `composite` (control: `type: 'lookup'` hits in `src/sales/actions/activity-actions.ts`). objectui at `a3c0da9`: `packages/fields/src/index.tsx:3625`–`:3626` already states that "the spec's `FieldSchema` has no sub-field member for either type and refuses `fields` / `subFields`". No producer is refused by this PR that was not already refused. ## Ablations (commit first, mutate through `scripts/ablation-replace.mjs`, self-restore) | leg | mutation | predicted | observed | |---|---|---|---| | A | `data-model-rules.ts`: `if (declared.length > 0) {` → `if (false && …)` | 11 red (9 in `embedded-sub-fields.test.ts`, 2 in `fix-lines.test.ts`), 13 green | **11 red, 13 green** | | B | `authoring-key-lint.ts`: delete the `fields:` guidance row | 2 red (`repeater + fields`, `composite + fields`), 19 green | **2 red, 19 green** | Both restored blob-identical to HEAD (`678d7d76`, `a2d3a01b`), and `git diff HEAD` was empty after each. Both suites resolve the mutated module from `src/`, never through a `dist/` export, so no rebuild leg applies. ## Verification Every reading below is on head `87ff1c79` (this branch with `origin/main` `5cf1b9d2` merged in), after `pnpm install --frozen-lockfile` and a rebuild of the `@objectstack/cli...` closure. Each exit code was captured to a file before any pipe. | command | exit | reading | |---|---|---| | `pnpm --filter @objectstack/spec test` | 1 | 720/721 files, 21818/21842 tests. All 23 failures are in `scripts/liveness/check-liveness.test.ts`, the `main`-red below | | `pnpm --filter @objectstack/spec test:repo` | 0 | 79/79 files, 1269 tests | | `pnpm --filter @objectstack/spec typecheck` | 0 | | | `pnpm --filter @objectstack/spec check:generated` | 1 | 13/14 up to date; `liveness/state-counts/` is the `main`-red below. On the pre-merge head `22414cf1`: 14/14 | | `pnpm --filter @objectstack/lint test` | 0 | 162/162 files, 7301 tests | | `pnpm --filter @objectstack/lint typecheck` | 0 | | | `pnpm --filter @objectstack/cli test` (unit **and** integration) | 0 | 501 passed + 1 skipped files, 6297 tests | | `pnpm --filter @objectstack/cli typecheck` | 0 | | | `node scripts/pm/dispatch-gates.mjs --ran …` | 0 | `✓ 111 derived famil(ies) accounted for — 111 run, 0 NOT-MEASURED`. 110 exit 0; `check:liveness` exit 1 is the `main`-red below | | `node scripts/check-dev-prereqs.mjs` (local-only) | 0 | | Pre-merge head `22414cf1`, the same battery, all green: spec `test` 721/721 (21829 tests), spec `test:repo` 79/79, lint 162/162 (7296 tests), cli 500 + 1 skipped files (6291 tests), and 111/111 families (110 run green, `check:dual-build-cjs-loads` NOT MEASURED there and green on `87ff1c79`). **Not this PR's: `check:liveness` is red on `main` `5cf1b9d2`.** The `translation` ledger row `flows.screens` cites `packages/services/service-automation/src/engine.ts#renderFlowTextSlot`. `engine.ts` no longer names `screens` in any spelling: the refactors `f3fea0a0` and `0189524a` moved `renderFlowTextSlot` into `node-text-rendering.ts`. Its one earlier hit was a coincidental comment ("states two screens down"), which moved to `run-resume.ts`. Neither file of this PR touches it. Proposed patch, proved but **not** applied here: delete the clause `; packages/services/service-automation/src/engine.ts#renderFlowTextSlot (…)` from that row's `evidence` in `packages/spec/liveness/translation.json`. The `screen-nodes.ts#registerScreenNodes` pointer beside it already cites the caller and names the key. With that deletion, `pnpm check:liveness` answers ✓ (run through `scripts/ablation-replace.mjs`, then restored blob-identical). Repointing to `node-text-rendering.ts` alone would stay red: that file does not name `screens` either. No open PR carries a fix (searched 2026-10-25). ## Acceptance notes - The guidance rows are an exact, case-sensitive channel (`shared/suggestions.zod.ts`): `subfields` or `sub_fields` still gets the bare refusal. The three spellings are the ones the ruling names. - `record` (ADR-0007) is also stored as untyped JSON on an object field, but the ruling names only `composite` / `repeater`, so the lint does not judge `record` (pinned silent). - `docs/audits/2026-06-handwritten-docs-accuracy-followups.md:66` asked "whether sub-shapes are declared elsewhere". This PR answers it (they are not, by ruling); the audit record is left as written. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01T2U7eGg8aRTpJ8kKXxDiog --- _Generated by [Claude Code](https://claude.ai/code/session_01T2U7eGg8aRTpJ8kKXxDiog)_ --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: objectstack-fleet[bot] <332303061+objectstack-fleet[bot]@users.noreply.github.com>
1 parent 3a2d38c commit 4034f47

12 files changed

Lines changed: 369 additions & 14 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/lint': minor
4+
---
5+
6+
feat(spec,lint): object-level `composite` / `repeater` are untyped JSON — a typed list is a child object
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: semantic
10+
Ruling-ref: 6386744473
11+
12+
On an object field, `composite` and `repeater` are untyped JSON containers of CMS lineage (a Strapi component, an ACF group or repeater). The `FieldType` comments used to promise "declared sub-fields", but `FieldSchema` has no member that declares them, so nothing gave the inner keys labels, types, options, required flags or translations, and nothing could validate or address one row. The comments and the field-types docs now say so, and point a list that needs typed columns to a child object: a `master_detail` field pointing back at the parent, with `inlineEdit: 'grid'` to edit the rows inline on the parent's form.
13+
14+
The strict field schema already refused `fields`, `subFields` and `columns` on a field, but named only the key. The refusal now names the route: a typed list is a child object (`columns` also names the live `lookupColumns`, `relatedListColumns` and `inlineColumns`). This changes the message, not what parses.
15+
16+
New `@objectstack/lint` rule `field/embedded-sub-fields` (error): a `composite` / `repeater` object field carrying `fields`, `subFields` or `columns`. It fires on the doors that never parse (`os lint` on a raw object-literal config, `defineStack(x, { strict: false })`, the metadata-generation rubric); every parsed door refuses the field first. `os explain field/embedded-sub-fields` prints the reasoning. A form view's `fields` sub-rows and the Studio metadata forms are not judged.
17+
18+
What to do: nothing for a field that declares no sub-field key — the type is unchanged. A field that carried one never parsed under `defineStack`; model the list as a child object.

‎content/docs/data-modeling/field-types.mdx‎

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -511,18 +511,43 @@ guarantee as a separate requirement to satisfy explicitly, not something
511511
512512
These types store structured values as JSON on the parent row — no separate table or foreign key.
513513
514+
<Callout type="warn">
515+
On an object field, `composite` and `repeater` are **untyped JSON** containers of CMS lineage (similar
516+
to a Strapi component, an ACF group or an ACF repeater). A field declares no sub-fields: `fields`,
517+
`subFields` and `columns` are refused on the field, so nothing gives the inner keys labels, types,
518+
options, required flags or translations, and nothing validates or addresses a single row.
519+
520+
**A list that needs typed columns is a child object** with a [`master_detail`](#master_detail) field
521+
pointing back at the parent — the shape Salesforce, Dynamics, ServiceNow and Odoo use for line items.
522+
The child's own fields carry the types and validation, `inlineEdit: 'grid'` edits the rows as an
523+
inline grid on the parent's form, and every row is a record a lookup, a roll-up or a flow can reach.
524+
</Callout>
525+
514526
### `composite`
515-
Single embedded sub-object with declared sub-fields (similar to a Strapi component / ACF group).
527+
A single embedded JSON object, untyped — no declared sub-fields.
516528
517529
```typescript
518530
{ name: 'dimensions', label: 'Dimensions', type: 'composite' }
519531
```
520532
521533
### `repeater`
522-
Repeating embedded sub-object array with declared sub-fields (similar to a Strapi repeatable component / ACF repeater).
534+
An array of embedded JSON objects, untyped — no declared sub-fields or columns. For typed rows, use a
535+
child object (see above).
523536
524537
```typescript
525-
{ name: 'line_items', label: 'Line Items', type: 'repeater' }
538+
{ name: 'import_log', label: 'Import Log', type: 'repeater' }
539+
```
540+
541+
```typescript
542+
// A typed list: a child object, edited inline on the parent's form
543+
{
544+
name: 'invoice_line',
545+
fields: {
546+
invoice: { type: 'master_detail', reference: 'invoice', required: true, deleteBehavior: 'cascade', inlineEdit: 'grid' },
547+
product: { type: 'text', label: 'Product' },
548+
quantity: { type: 'number', label: 'Quantity' },
549+
},
550+
}
526551
```
527552
528553
### `record`

‎content/docs/data-modeling/validation-rules.mdx‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -391,9 +391,11 @@ application-level emptiness check is needed.
391391
## Embedded Structured Types
392392

393393
These types store structured values as JSON on the parent row — no separate table and no
394-
foreign key. Sub-field shapes are declared on the field, and the stored value is validated
395-
as an open object map (`valueSchemaFor`), so sub-keys are not constrained by the field type
396-
itself.
394+
foreign key. A `composite` / `repeater` field declares no sub-fields (`fields`, `subFields` and
395+
`columns` are refused on it), and the stored value is validated as an open object map
396+
(`valueSchemaFor`), so sub-keys are not constrained at all. A list that needs typed, validated
397+
columns is a child object with a `master_detail` field — see
398+
[Embedded Structured Types](/docs/data-modeling/field-types#embedded-structured-types).
397399

398400
### `composite`
399401

‎content/docs/protocol/objectui/layout-dsl.mdx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -465,8 +465,10 @@ sections:
465465

466466
Both examples were doing something the declared shape already does — see the
467467
replacement below. For a value that is genuinely one composite thing rather
468-
than neighbouring fields, the grouping belongs on the **field**: a field of
469-
type `address` (or `composite`) carries its own `fields` list.
468+
than neighbouring fields, the grouping belongs on the **field**: a form field of
469+
type `address` (or `composite`) carries its own `fields` list. That list is the
470+
form's; the object field itself declares no sub-fields (see
471+
[Embedded Structured Types](/docs/data-modeling/field-types#embedded-structured-types)).
470472
</Callout>
471473

472474
Fields sit on the same row because the section says how many columns it has, and
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// `field/embedded-sub-fields` — an object-level `composite` / `repeater` is
4+
// untyped JSON (ruling 6386744473), so declaring `fields` / `subFields` /
5+
// `columns` on one is an error whose route is a child object.
6+
//
7+
// The rule reaches only the doors that never parse (its docblock's intake
8+
// table): the strict field schema refuses the same keys first, with the same
9+
// route. Both halves are pinned below, so neither can be credited for the
10+
// other's door.
11+
import { describe, expect, it } from 'vitest';
12+
import { FieldSchema } from '@objectstack/spec/data';
13+
14+
import { FIELD_EMBEDDED_SUB_FIELDS, lintDataModel } from './data-model-rules.js';
15+
import { explainRule } from './rule-explanations.js';
16+
17+
const RULE = 'field/embedded-sub-fields';
18+
19+
/** One object carrying `checklist` as `def`, in the record shape authors write. */
20+
const audit = (def: Record<string, unknown>) => [
21+
{ name: 'supplier_audit', fields: { name: { type: 'text' }, checklist: def } },
22+
];
23+
const findings = (objects: unknown[]) => lintDataModel(objects as any[]).filter((i) => i.rule === RULE);
24+
25+
const SUB_ROWS = [{ name: 'item', label: 'Item', type: 'text' }];
26+
27+
describe('field/embedded-sub-fields — refuses a typed list on an embedded-JSON field', () => {
28+
for (const type of ['repeater', 'composite'] as const) {
29+
for (const [key, value] of [['fields', SUB_ROWS], ['subFields', SUB_ROWS], ['columns', ['item', 'score']]] as const) {
30+
it(`${type} + \`${key}\` is an error at that key`, () => {
31+
const [issue, ...rest] = findings(audit({ type, [key]: value }));
32+
expect(rest).toEqual([]);
33+
expect(issue).toMatchObject({
34+
severity: 'error',
35+
rule: RULE,
36+
path: `objects[0].fields.checklist.${key}`,
37+
});
38+
});
39+
}
40+
}
41+
42+
it('the array field shape is judged the same as the record shape', () => {
43+
const objects = [{ name: 'supplier_audit', fields: [{ name: 'checklist', type: 'repeater', fields: SUB_ROWS }] }];
44+
expect(findings(objects)).toHaveLength(1);
45+
});
46+
47+
it('several keys on one field are one finding naming every key, located at the first', () => {
48+
const [issue, ...rest] = findings(audit({ type: 'repeater', fields: SUB_ROWS, columns: ['item'] }));
49+
expect(rest).toEqual([]);
50+
expect(issue!.path).toBe('objects[0].fields.checklist.fields');
51+
expect(issue!.message).toContain('`fields`, `columns`');
52+
});
53+
});
54+
55+
describe('field/embedded-sub-fields — stays silent outside its scope', () => {
56+
it('a bare composite / repeater is untyped JSON, not an error', () => {
57+
expect(findings(audit({ type: 'repeater' }))).toEqual([]);
58+
expect(findings(audit({ type: 'composite' }))).toEqual([]);
59+
});
60+
61+
it('other field types are not judged here, whatever they carry', () => {
62+
expect(findings(audit({ type: 'json', fields: SUB_ROWS }))).toEqual([]);
63+
expect(findings(audit({ type: 'record', fields: SUB_ROWS }))).toEqual([]);
64+
expect(findings(audit({ type: 'lookup', reference: 'account', columns: ['name'] }))).toEqual([]);
65+
});
66+
67+
it('a key that merely shares a name with an inherited property is not authored', () => {
68+
const inherited = Object.create({ fields: SUB_ROWS }) as Record<string, unknown>;
69+
inherited.type = 'repeater';
70+
expect(findings(audit(inherited))).toEqual([]);
71+
});
72+
});
73+
74+
describe('field/embedded-sub-fields — the parsed doors refuse first, with the same route', () => {
75+
it('the strict field schema refuses each key by name and names `master_detail`', () => {
76+
for (const key of ['fields', 'subFields', 'columns']) {
77+
const r = FieldSchema.safeParse({ name: 'checklist', type: 'repeater', [key]: SUB_ROWS });
78+
expect(r.success, key).toBe(false);
79+
if (r.success) continue;
80+
const issue = r.error.issues.find((i) => i.code === 'unrecognized_keys');
81+
expect(issue, key).toBeTruthy();
82+
expect(issue!.message, key).toContain('`master_detail`');
83+
}
84+
});
85+
});
86+
87+
describe('field/embedded-sub-fields — one-line verdict, `os explain` carries the rest', () => {
88+
it('the published constant spells the id the findings carry', () => {
89+
expect(FIELD_EMBEDDED_SUB_FIELDS).toBe(RULE);
90+
});
91+
92+
it('the verdict names the type, the field and the keys, on one line of at most 200 characters', () => {
93+
const [issue] = findings(audit({ type: 'repeater', fields: SUB_ROWS }));
94+
expect(issue!.message).toBe(
95+
'repeater field "supplier_audit.checklist" declares `fields`, but an object-level repeater is untyped ' +
96+
'JSON with no sub-fields',
97+
);
98+
expect(issue!.message).not.toContain('\n');
99+
expect(issue!.message.length).toBeLessThanOrEqual(200);
100+
});
101+
102+
it('`os explain` carries the route, the exclusions and the reach', () => {
103+
const entry = explainRule(RULE);
104+
expect(entry, 'no `os explain field/embedded-sub-fields` entry').toBeDefined();
105+
const text = entry!.paragraphs.join('\n');
106+
for (const fact of [
107+
'untyped JSON',
108+
'child object',
109+
'`master_detail`',
110+
'inline grid',
111+
'form view',
112+
'refuses all three keys at the parse',
113+
'never parse',
114+
]) {
115+
expect(text, fact).toContain(fact);
116+
}
117+
});
118+
});

‎packages/lint/src/data-model-rules.fix-lines.test.ts‎

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,17 @@
55
// three ADR-0120 uniqueness ids carry it as the `hint` `os validate` and
66
// `os build` print. What the hints used to explain is `os explain` text.
77
//
8-
// The three `lintDataModel` sweep ids (`object/missing-name-field`,
9-
// `field/select-missing-options`, `rollup/non-numeric-aggregand`) are never
8+
// The four `lintDataModel` sweep ids (`object/missing-name-field`,
9+
// `field/select-missing-options`, `rollup/non-numeric-aggregand`,
10+
// `field/embedded-sub-fields`) are never
1011
// adapted into an `AuthoringFinding`, so a census that reads `hint` never sees
1112
// them. This file reads `fix` directly, over every arm each rule has, with long
1213
// object and field names where a name is interpolated.
1314

1415
import { describe, expect, it } from 'vitest';
1516

1617
import {
18+
FIELD_EMBEDDED_SUB_FIELDS,
1719
lintDataModel,
1820
lintLegacyOrganizationComposites,
1921
lintUniqueDeclarations,
@@ -36,6 +38,7 @@ const IDS = [
3638
MISSING_NAME_FIELD,
3739
SELECT_MISSING_OPTIONS,
3840
ROLLUP_NON_NUMERIC_AGGREGAND,
41+
FIELD_EMBEDDED_SUB_FIELDS,
3942
];
4043

4144
const LONG_OBJECT = 'acme_customer_purchase_order_header';
@@ -170,6 +173,24 @@ describe('[#22161] data-model-rules.ts — one remedy line per fix', () => {
170173
}
171174
});
172175

176+
it('field/embedded-sub-fields: one line, whatever the type, the keys or the names', () => {
177+
const fix =
178+
'Make the typed list a child object whose `master_detail` field points back here ' +
179+
"(`inlineEdit: 'grid'` edits it inline), and remove the key.";
180+
for (const type of ['repeater', 'composite']) {
181+
const objects = [
182+
{
183+
name: LONG_OBJECT,
184+
fields: {
185+
name: { type: 'text' },
186+
[LONG_FIELD]: { type, fields: [{ name: 'item' }], subFields: [{ name: 'item' }], columns: ['item'] },
187+
},
188+
},
189+
];
190+
expect(fixOf(record(lintDataModel(objects)), FIELD_EMBEDDED_SUB_FIELDS)).toBe(fix);
191+
}
192+
});
193+
173194
it('rollup/non-numeric-aggregand: one line naming the child type, not the parent', () => {
174195
expect(fixOf(record(lintDataModel(rollup('datetime', LONG_OBJECT))), ROLLUP_NON_NUMERIC_AGGREGAND)).toBe(
175196
"Aggregate a numeric or boolean child field, or use function: 'count'; a roll-up cannot store a " +
@@ -181,7 +202,7 @@ describe('[#22161] data-model-rules.ts — one remedy line per fix', () => {
181202
});
182203

183204
it('every fix the cases above fired is one line of at most 200 characters', () => {
184-
// The coverage control first: all six ids fired.
205+
// The coverage control first: all seven ids fired.
185206
expect([...new Set(fired.map((f) => f.rule))].sort()).toEqual([...IDS].sort());
186207
for (const f of fired) {
187208
expect(f.fix, f.rule).toBeTruthy();

‎packages/lint/src/data-model-rules.ts‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,50 @@ export const RELATIONSHIP_DELETE_BEHAVIOR = 'relationship/delete-behavior';
261261
/** The rule id R13 reports under (a `min`/`max` roll-up over a non-numeric child). */
262262
export const ROLLUP_NON_NUMERIC_AGGREGAND = 'rollup/non-numeric-aggregand';
263263

264+
/**
265+
* The rule id R14 reports under: an object field of type `composite` /
266+
* `repeater` that declares sub-fields or columns.
267+
*
268+
* Ruling 6386744473: an object-level `composite` / `repeater` is untyped JSON,
269+
* no sub-field member is added to `FieldSchema`, and a list that needs typed
270+
* columns is a child object (`master_detail`). A form view's `fields` sub-rows
271+
* (`view.zod.ts`) and the Studio metadata forms are other schemas and are not
272+
* judged here.
273+
*
274+
* ## Intake — which doors reach this rule at all
275+
*
276+
* `FieldSchema` is strict, and its `FIELD_KEY_GUIDANCE` rows for these three
277+
* keys make the parse refusal name the child-object route. So on every door
278+
* that PARSES first this rule is unreachable by construction — the object
279+
* never arrives. Measured on the source tree this rule shipped against:
280+
*
281+
* | door | reaches the rule? |
282+
* |--------------------------------------------------------------|-------------------------|
283+
* | `defineStack(x)` (strict default), hence `os validate` / | no — refused at load |
284+
* | `os build` / `os lint` on such a config | |
285+
* | `saveMetaItem` (Studio / REST `/meta` / MCP) | no — 422 at the parse |
286+
* | **`os lint` on a RAW object-literal config** (never parses) | **yes — fires** |
287+
* | **`defineStack(x, { strict: false })`** (keeps the key) | **yes — fires** |
288+
* | **the metadata-generation rubric** (`scoreMetadata`), beside | **yes — fires** |
289+
* | its own schema verdict | |
290+
* | **`lintDataModel` called directly with an unparsed stack** | **yes — fires** |
291+
*
292+
* It is not a second opinion on the parse: it exists so the unparsed doors
293+
* name the same route instead of letting the key ride to `os build`. A
294+
* consumer crediting it as coverage on a `defineStack`-authored app is
295+
* crediting the wrong gate — there the credit belongs to the schema.
296+
*/
297+
export const FIELD_EMBEDDED_SUB_FIELDS = 'field/embedded-sub-fields';
298+
299+
/** The two embedded-JSON field types R14 judges. */
300+
const EMBEDDED_JSON_FIELD_TYPES = new Set(['composite', 'repeater']);
301+
302+
/**
303+
* The keys an author writes when they expect a typed list on R14's types — the
304+
* three the ruling names, the same three `FIELD_KEY_GUIDANCE` answers.
305+
*/
306+
const SUB_FIELD_KEYS = ['fields', 'subFields', 'columns'] as const;
307+
264308
/**
265309
* [#22161] The consequence every `controlled_by_parent` arm of R2 ends with —
266310
* one wording, so the three shapes cannot drift. Why such a record is
@@ -778,6 +822,25 @@ export function lintDataModel(objects: any[]): LintIssue[] {
778822
}
779823
}
780824

825+
// R14 — an object-level `composite` / `repeater` declares no sub-fields
826+
// (see FIELD_EMBEDDED_SUB_FIELDS for the ruling and the doors it reaches).
827+
if (EMBEDDED_JSON_FIELD_TYPES.has(type)) {
828+
const declared = SUB_FIELD_KEYS.filter((k) => Object.prototype.hasOwnProperty.call(def, k));
829+
if (declared.length > 0) {
830+
issues.push({
831+
severity: 'error',
832+
rule: FIELD_EMBEDDED_SUB_FIELDS,
833+
message:
834+
`${type} field "${obj.name}.${fieldName}" declares \`${declared.join('`, `')}\`, but an ` +
835+
`object-level ${type} is untyped JSON with no sub-fields`,
836+
path: `${fieldPath}.${declared[0]}`,
837+
fix:
838+
'Make the typed list a child object whose `master_detail` field points back here ' +
839+
"(`inlineEdit: 'grid'` edits it inline), and remove the key.",
840+
});
841+
}
842+
}
843+
781844
// R13 — a `min`/`max` roll-up must aggregate a child field whose ANSWER
782845
// fits the column the roll-up is stored into.
783846
//

‎packages/lint/src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1009,6 +1009,7 @@ export {
10091009
RELATIONSHIP_MASTER_DETAIL_REQUIRED,
10101010
RELATIONSHIP_DELETE_BEHAVIOR,
10111011
ROLLUP_NON_NUMERIC_AGGREGAND,
1012+
FIELD_EMBEDDED_SUB_FIELDS,
10121013
} from './data-model-rules.js';
10131014
export type { LintIssue, LocatedLintIssue, Severity } from './data-model-rules.js';
10141015

0 commit comments

Comments
 (0)