diff --git a/.changeset/view-binding-blocks-complete.md b/.changeset/view-binding-blocks-complete.md new file mode 100644 index 0000000000..b1e2ef27a8 --- /dev/null +++ b/.changeset/view-binding-blocks-complete.md @@ -0,0 +1,37 @@ +--- +'@objectstack/spec': minor +'@objectstack/lint': patch +--- + +`view/layout-without-binding` now covers every view type that carries a binding block, and a new `view/tree-without-parent-field` rule catches the silently flat tree + +`checkViewCompleteness`'s `VIEW_BINDING_BLOCKS` table named `kanban` / `calendar` / `gantt` only, while +`ListViewSchema.type` has six members with a type-specific binding block. The other three fell through +the same trapdoor the rule exists to close: objectui's ListView adapter falls back to literal field +names (`timeline` → `startDateField || 'created_at'`, `titleField || 'name'`; `map` → +`locationField || 'location'`; `tree` → `labelField || titleField || 'name'`), so a view authored +without its block rendered empty — `timeline` drops every row whose start date fails to parse — +while `os validate --json` reported `warnings: []` and `valid: true`. Measured both ways: deleting a +`timeline` block was silent, deleting the sibling `gantt` block warned as designed. + +- The table now names all six. Each new entry carries a `fix` hint naming the keys that make the block + a binding (`timeline`'s two schema-required keys; either coordinate form for `map`; `parentField` + + `labelField` for `tree`). Severity stays `warning` (ADR-0078 §1 — the view degrades, it does not die). +- `map` is read for its coordinate binding, not merely for block presence: `ListMapConfigSchema` requires + no key, so a `map` block declaring neither `locationField` nor the `latitudeField`/`longitudeField` + pair is the same unbound view with braces and is warned about at `map.locationField`. +- **New rule `view/tree-without-parent-field`** (warning, path `tree.parentField`): a `type: 'tree'` view + with no declared `parentField` on an object that carries neither a `tree` field nor a + `lookup`/`master_detail` back to itself renders FLAT — every record at depth 0, a correct-looking table + whose expand slot never opens. Every `TreeConfigSchema` key is optional, so `tree: {}` satisfies the + block check and still renders flat; this rule mirrors objectui's `detectParentField` exactly, so a + view the renderer resolves by auto-detection is never warned about. +- `checkViewCompleteness(view, boundObject?)` takes the bound object definition as an optional second + argument (additive; one-argument callers are unchanged and the tree rule stays silent for them). + `@objectstack/lint`'s `validate-functional-completeness` resolves the object by name from + `stack.objects` — the list view's own `data.object` first, then the container's binding — and hands + it over; no rule logic moved into lint. +- `gallery` is measured (`titleField || 'name'`) and deliberately not added: its schema has no binding key + to demand. `page` stays deliberately absent (a `page` view refuses at parse via `checkListViewPageMount`). + +Under `os validate --strict` the new warnings are failures, as every warning in this family is. diff --git a/packages/lint/src/validate-functional-completeness.test.ts b/packages/lint/src/validate-functional-completeness.test.ts index de41cde094..96e869a0e3 100644 --- a/packages/lint/src/validate-functional-completeness.test.ts +++ b/packages/lint/src/validate-functional-completeness.test.ts @@ -15,6 +15,7 @@ import { describe, expect, it } from 'vitest'; +import { runAuthoringRules, splitBySeverity } from './authoring-rules.js'; import { validateFunctionalCompleteness } from './validate-functional-completeness.js'; const bareSummary = { type: 'summary' }; @@ -85,6 +86,52 @@ describe('validateFunctionalCompleteness — the walk', () => { expect(findings.every((f) => f.severity === 'warning')).toBe(true); }); + it('hands the bound object to the view predicate — the tree parent pointer resolves against `stack.objects`', () => { + // The predicate's tree rule needs the object's fields; this proves the + // walk actually delivers them, in every resolution the sibling + // reference-integrity rules use. Array-form objects, container binding: + const flat = validateFunctionalCompleteness({ + objects: [{ name: 'unit', fields: [{ name: 'name', type: 'text' }] }], + views: [{ object: 'unit', listViews: { org: { type: 'tree', tree: {} } } }], + }); + expect(flat.map((f) => [f.rule, f.severity, f.path])).toEqual([ + ['view/tree-without-parent-field', 'warning', 'views[0].listViews.org.tree.parentField'], + ]); + expect(flat[0].where).toMatch(/› listViews\.org$/); + + // Map-form objects (the walk injects `name`), a self-lookup → silent. + expect(validateFunctionalCompleteness({ + objects: { unit: { fields: { parent: { type: 'lookup', reference: 'unit' } } } }, + views: [{ object: 'unit', listViews: { org: { type: 'tree', tree: {} } } }], + })).toEqual([]); + + // The container's default `list` slot is handed the object too. + expect(validateFunctionalCompleteness({ + objects: [{ name: 'unit', fields: [] }], + views: [{ object: 'unit', list: { type: 'tree', tree: {} } }], + }).map((f) => f.path)).toEqual(['views[0].list.tree.parentField']); + + // A list view's own `data.object` retargets the lookup (ADR-0047): + // `cat` carries a `tree` field, so the view bound to it is clean even + // though the container's object has nothing to detect. + expect(validateFunctionalCompleteness({ + objects: [ + { name: 'unit', fields: [] }, + { name: 'cat', fields: [{ name: 'parent', type: 'tree' }] }, + ], + views: [{ + object: 'unit', + listViews: { org: { type: 'tree', tree: {}, data: { provider: 'object', object: 'cat' } } }, + }], + })).toEqual([]); + + // An object the stack does not declare: nothing is handed over and the + // tree rule stays silent — `validate-object-references` owns that miss. + expect(validateFunctionalCompleteness({ + views: [{ object: 'ghost', listViews: { org: { type: 'tree', tree: {} } } }], + })).toEqual([]); + }); + it('walks webhooks in both spellings', () => { expect(validateFunctionalCompleteness({ webhooks: [{ name: 'notify', url: 'https://x' }], @@ -128,3 +175,72 @@ describe('validateFunctionalCompleteness — the walk', () => { } }); }); + +/** + * The card's acceptance criteria, pinned end-to-end through the rule table + * (the #14108 precedent): `timeline` / `map` / `tree` without their binding + * block must produce a diagnostic on `os validate` (and `os build`), and a + * `tree` view bound to an object with no self-reference must produce one + * whether its block is empty or absent. The clean twins prove the fixtures + * fail for the right reason. + */ +describe('#14106 acceptance — timeline / map / tree bindings reach `validate` AND `build`', () => { + const object = { + name: 'duly_task', + fields: { + subject: { type: 'text' }, + last_update_at: { type: 'datetime' }, + site: { type: 'text' }, + }, + }; + const repro = { + objects: [object], + views: [{ + object: 'duly_task', + listViews: { + recent: { type: 'timeline', columns: ['subject'] }, + sites: { type: 'map', columns: ['subject'] }, + flat: { type: 'tree', tree: {}, columns: ['subject'] }, + flatter: { type: 'tree', columns: ['subject'] }, + }, + }], + }; + const clean = { + objects: [{ + ...object, + fields: { ...object.fields, parent: { type: 'lookup', reference: 'duly_task' } }, + }], + views: [{ + object: 'duly_task', + listViews: { + recent: { + type: 'timeline', columns: ['subject'], + timeline: { startDateField: 'last_update_at', titleField: 'subject' }, + }, + sites: { type: 'map', columns: ['subject'], map: { locationField: 'site' } }, + flat: { type: 'tree', tree: {}, columns: ['subject'] }, + declared: { type: 'tree', tree: { parentField: 'parent' }, columns: ['subject'] }, + }, + }], + }; + const BINDING_RULES = ['view/layout-without-binding', 'view/tree-without-parent-field']; + + for (const command of ['validate', 'build'] as const) { + it(`the measured repro is diagnosed by \`${command}\``, () => { + const { advisories } = splitBySeverity(runAuthoringRules(command, { normalized: repro as never })); + const hits = advisories.filter((f) => BINDING_RULES.includes(f.rule)).map((f) => `${f.rule} @ ${f.path}`).sort(); + expect(hits).toEqual([ + 'view/layout-without-binding @ views[0].listViews.flatter.tree', + 'view/layout-without-binding @ views[0].listViews.recent.timeline', + 'view/layout-without-binding @ views[0].listViews.sites.map', + 'view/tree-without-parent-field @ views[0].listViews.flat.tree.parentField', + 'view/tree-without-parent-field @ views[0].listViews.flatter.tree.parentField', + ]); + }); + + it(`the bound stack passes \`${command}\``, () => { + const { errors, advisories } = splitBySeverity(runAuthoringRules(command, { normalized: clean as never })); + expect([...errors, ...advisories].filter((f) => BINDING_RULES.includes(f.rule))).toEqual([]); + }); + } +}); diff --git a/packages/lint/src/validate-functional-completeness.ts b/packages/lint/src/validate-functional-completeness.ts index e78d5b5166..e4cec2e4b0 100644 --- a/packages/lint/src/validate-functional-completeness.ts +++ b/packages/lint/src/validate-functional-completeness.ts @@ -103,15 +103,38 @@ export function validateFunctionalCompleteness(stack: unknown): FunctionalComple // ── List views: views[] containers → list / listViews.* ──────────────── // (Form views carry no layout-binding contract; field completeness inside // objects is already covered above.) + // + // The predicate takes the BOUND OBJECT as its optional second argument — + // the `tree` parent-pointer rule needs the object's fields to ask whether + // the renderer could auto-detect one. This is wiring only: the object is + // looked up by name in `stack.objects` (already normalized to an array, so + // both authorable spellings arrive with a `name`), resolved in the order + // the sibling reference-integrity rules use — a list view's own + // `data.object` retarget first (ADR-0047), then the container's binding — + // and handed over. Absent from the stack, nothing is handed and the + // predicate stays silent on that rule; the dangling `data.object` itself + // is `validate-object-references`' finding. + const objectsByName = new Map(entriesOf(stack.objects).map((o) => [o.name, o.def])); + const strName = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined); + const boundObjectOf = (view: AnyRec, container: AnyRec): AnyRec | undefined => { + const own = isRec(view.data) ? strName(view.data.object) : undefined; + const name = own ?? strName(container.objectName) ?? strName(container.object); + return name ? objectsByName.get(name) : undefined; + }; for (const [vi, container] of entriesOf(stack.views).entries()) { const where = container.def.object ? `view container "${container.name}"` : `view container [${vi}]`; if (isRec(container.def.list)) { - push(out, checkViewCompleteness(container.def.list), `${where} › list`, `views[${vi}].list`); + push( + out, + checkViewCompleteness(container.def.list, boundObjectOf(container.def.list, container.def)), + `${where} › list`, + `views[${vi}].list`, + ); } for (const lv of entriesOf(container.def.listViews)) { push( out, - checkViewCompleteness(lv.def), + checkViewCompleteness(lv.def, boundObjectOf(lv.def, container.def)), `${where} › listViews.${lv.name}`, `views[${vi}].listViews${lv.key}`, ); diff --git a/packages/spec/api-surface/kernel.json b/packages/spec/api-surface/kernel.json index 581f6edd9f..970ef195c8 100644 --- a/packages/spec/api-surface/kernel.json +++ b/packages/spec/api-surface/kernel.json @@ -442,6 +442,7 @@ "UpgradeSnapshotParsed (type)", "UpgradeSnapshotSchema (const)", "VIEW_LAYOUT_WITHOUT_BINDING (const)", + "VIEW_TREE_WITHOUT_PARENT_FIELD (const)", "ValidationError (type)", "ValidationErrorSchema (const)", "ValidationResult (type)", diff --git a/packages/spec/export-origins/kernel.json b/packages/spec/export-origins/kernel.json index 7be30f52ab..3e92adcacd 100644 --- a/packages/spec/export-origins/kernel.json +++ b/packages/spec/export-origins/kernel.json @@ -442,6 +442,7 @@ "UpgradeSnapshotParsed": "src/kernel/package-upgrade.zod.ts#UpgradeSnapshotParsed (type)", "UpgradeSnapshotSchema": "src/kernel/package-upgrade.zod.ts#UpgradeSnapshotSchema (const)", "VIEW_LAYOUT_WITHOUT_BINDING": "src/kernel/functional-completeness.ts#VIEW_LAYOUT_WITHOUT_BINDING (const)", + "VIEW_TREE_WITHOUT_PARENT_FIELD": "src/kernel/functional-completeness.ts#VIEW_TREE_WITHOUT_PARENT_FIELD (const)", "ValidationError": "src/kernel/plugin-validator.zod.ts#ValidationError (type)", "ValidationErrorSchema": "src/kernel/plugin-validator.zod.ts#ValidationErrorSchema (const)", "ValidationResult": "src/kernel/plugin-validator.zod.ts#ValidationResult (type)", diff --git a/packages/spec/src/kernel/functional-completeness.test.ts b/packages/spec/src/kernel/functional-completeness.test.ts index dc969fcaa4..ac23e8dbd6 100644 --- a/packages/spec/src/kernel/functional-completeness.test.ts +++ b/packages/spec/src/kernel/functional-completeness.test.ts @@ -27,6 +27,7 @@ import { FIELD_RELATIONSHIP_WITHOUT_REFERENCE, FIELD_CHOICE_WITHOUT_OPTIONS, VIEW_LAYOUT_WITHOUT_BINDING, + VIEW_TREE_WITHOUT_PARENT_FIELD, WEBHOOK_WITHOUT_TRIGGERS, } from './functional-completeness'; @@ -114,29 +115,168 @@ describe('checkFieldCompleteness — the verified inert shapes go red', () => { }); describe('checkViewCompleteness — layout bindings', () => { - it.each(['kanban', 'calendar', 'gantt'])('flags a %s view missing its block as a WARNING', (type) => { + // Six of the view `type` members carry a binding block. The renderer's + // fallback for every one is a literal field name (measured in objectui's + // ListView adapter — see the table's docblock), so the missing block is the + // same defect on all six, not a lesser one on the last three. + it.each(['kanban', 'calendar', 'gantt', 'timeline', 'map', 'tree'])('flags a %s view missing its block as a WARNING', (type) => { const f = only(checkViewCompleteness({ type }) as never); expect(f.rule).toBe(VIEW_LAYOUT_WITHOUT_BINDING); expect(f.severity).toBe('warning'); expect(f.path).toBe(type); + // The prescription names the block and the keys that make it a binding. + expect(f.fix.startsWith(`${type}: {`)).toBe(true); }); - it('is silent when the block is present', () => { + it('is silent when the block is present and bound — one fixture per covered type', () => { expect(checkViewCompleteness({ type: 'calendar', calendar: { startDateField: 'due_at', titleField: 'title' }, })).toEqual([]); + // The card's own repro, the direction that was silent before this table + // reached `timeline`: the declared block must stay clean. + expect(checkViewCompleteness({ + type: 'timeline', + timeline: { startDateField: 'last_update_at', titleField: 'subject' }, + })).toEqual([]); + // Both coordinate forms `ListMapConfigSchema` documents. + expect(checkViewCompleteness({ type: 'map', map: { locationField: 'site' } })).toEqual([]); + expect(checkViewCompleteness({ + type: 'map', + map: { latitudeField: 'lat', longitudeField: 'lng' }, + })).toEqual([]); + expect(checkViewCompleteness({ type: 'tree', tree: { parentField: 'parent' } })).toEqual([]); + }); + + it('names the schema-required keys in the timeline prescription', () => { + // `TimelineConfigSchema` requires exactly these two; the hint must not + // send an author to declare a block the parser then refuses. + const f = only(checkViewCompleteness({ type: 'timeline' }) as never); + expect(f.fix).toContain('startDateField'); + expect(f.fix).toContain('titleField'); }); - it('is silent on grid and on the unverified types (timeline, tree)', () => { - // timeline/tree have config schemas too, but their renderer behaviour has - // not had its verification pass — verify-then-enforce, one shape at a time. - for (const type of ['grid', 'gallery', 'timeline', 'tree', 'map']) { + it('flags a `map` block that declares neither coordinate form — `map: {}` is the unbound view with braces', () => { + // `ListMapConfigSchema` requires no key, so block presence alone would + // bless `map: { titleField }` on its way to `locationField || 'location'`. + for (const map of [{}, { titleField: 'title' }, { latitudeField: 'lat' }, { longitudeField: 'lng' }]) { + const f = only(checkViewCompleteness({ type: 'map', map }) as never); + expect(f.rule).toBe(VIEW_LAYOUT_WITHOUT_BINDING); + expect(f.severity).toBe('warning'); + expect(f.path).toBe('map.locationField'); + expect(f.message).toContain("locationField || 'location'"); + expect(f.fix).toContain('locationField'); + expect(f.fix).toContain('latitudeField'); + } + }); + + it('is silent on the types with no binding block to demand (grid, gallery, chart)', () => { + // `gallery` IS measured (`titleField || 'name'`) and deliberately absent: + // `GalleryConfigSchema` requires no key, so block presence would assert + // nothing, and the fallback mis-titles cards rather than emptying them. + for (const type of ['grid', 'gallery', 'chart']) { expect(checkViewCompleteness({ type })).toEqual([]); } }); }); +describe('checkViewCompleteness — the tree parent pointer (the silent-flat half)', () => { + // A `tree: {}` block satisfies the binding-block table (every key is + // optional) and still renders flat on an object with no self-reference — + // the shape a block-presence gate would vouch for. This rule is the second + // check the triage asked for, and it mirrors objectui's `detectParentField` + // exactly: `type: 'tree'`, else a lookup / master_detail back to the object. + const flatObject = { + name: 'business_unit', + fields: { name: { type: 'text' }, manager: { type: 'lookup', reference: 'sys_user' } }, + }; + const rulesOf = (view: unknown, object: unknown) => + checkViewCompleteness(view, object).map((f) => f.rule).sort(); + + it('flags a tree view whose block is EMPTY on an object with nothing to auto-detect', () => { + const f = only(checkViewCompleteness({ type: 'tree', tree: {} }, flatObject) as never); + expect(f.rule).toBe(VIEW_TREE_WITHOUT_PARENT_FIELD); + expect(f.severity).toBe('warning'); + expect(f.path).toBe('tree.parentField'); + // The message carries the renderer evidence — the discipline every rule + // in this module is held to. + expect(f.message).toContain('ObjectTree.tsx'); + expect(f.message).toContain('depth 0'); + expect(f.fix).toContain('parentField'); + }); + + it('flags a tree view whose block is ABSENT — both the binding rule and the parent-pointer rule', () => { + expect(rulesOf({ type: 'tree' }, flatObject)).toEqual([ + VIEW_LAYOUT_WITHOUT_BINDING, + VIEW_TREE_WITHOUT_PARENT_FIELD, + ]); + }); + + it('a block that binds only the label is still flat', () => { + expect(rulesOf({ type: 'tree', tree: { labelField: 'name' } }, flatObject)) + .toEqual([VIEW_TREE_WITHOUT_PARENT_FIELD]); + // An empty string is not a declaration either. + expect(rulesOf({ type: 'tree', tree: { parentField: '' } }, flatObject)) + .toEqual([VIEW_TREE_WITHOUT_PARENT_FIELD]); + }); + + it('is silent when `parentField` is declared, whatever the object declares', () => { + expect(checkViewCompleteness({ type: 'tree', tree: { parentField: 'parent' } }, flatObject)).toEqual([]); + }); + + it('is silent when the object carries a `tree` field — the renderer auto-detects it', () => { + expect(checkViewCompleteness({ type: 'tree', tree: {} }, { + name: 'category', + fields: { name: { type: 'text' }, parent: { type: 'tree' } }, + })).toEqual([]); + }); + + it.each(['lookup', 'master_detail'])('is silent when the object carries a %s back to itself', (type) => { + expect(checkViewCompleteness({ type: 'tree', tree: {} }, { + name: 'business_unit', + fields: { name: { type: 'text' }, parent: { type, reference: 'business_unit' } }, + })).toEqual([]); + // …and not when the same field points at ANOTHER object: a lookup is only + // a parent pointer when it comes back to the object it lives on. + expect(rulesOf({ type: 'tree', tree: {} }, { + name: 'business_unit', + fields: { name: { type: 'text' }, parent: { type, reference: 'department' } }, + })).toEqual([VIEW_TREE_WITHOUT_PARENT_FIELD]); + }); + + it('reads array-form fields too — both authorable spellings', () => { + expect(checkViewCompleteness({ type: 'tree', tree: {} }, { + name: 'business_unit', + fields: [{ name: 'name', type: 'text' }, { name: 'parent', type: 'lookup', reference: 'business_unit' }], + })).toEqual([]); + expect(rulesOf({ type: 'tree', tree: {} }, { + name: 'business_unit', + fields: [{ name: 'name', type: 'text' }], + })).toEqual([VIEW_TREE_WITHOUT_PARENT_FIELD]); + }); + + it('needs the object name to recognise a self-reference — mirrors the renderer, which needs it too', () => { + expect(rulesOf({ type: 'tree', tree: {} }, { + fields: { parent: { type: 'lookup', reference: 'business_unit' } }, + })).toEqual([VIEW_TREE_WITHOUT_PARENT_FIELD]); + }); + + it('stays silent with no object in hand — the second clause cannot be asserted', () => { + // The one-argument call is the pre-existing signature every other consumer + // uses; it must not start guessing about objects it was never shown. A + // view naming an object the stack does not declare belongs to + // `validate-object-references`. + expect(checkViewCompleteness({ type: 'tree', tree: {} })).toEqual([]); + expect(checkViewCompleteness({ type: 'tree', tree: {} }, undefined)).toEqual([]); + }); + + it('never throws on junk objects', () => { + for (const junk of [null, 42, 'x', [], {}, { fields: 'nope' }, { fields: [null, 7] }, { fields: { a: null } }]) { + expect(() => checkViewCompleteness({ type: 'tree', tree: {} }, junk)).not.toThrow(); + } + }); +}); + describe('checkWebhookCompleteness — the rule the runtime comment argued against', () => { it('flags a webhook with no `triggers` as an ERROR', () => { const f = only(checkWebhookCompleteness({ name: 'notify_slack', url: 'https://x' }) as never); @@ -189,6 +329,7 @@ describe('registry hygiene', () => { 'field/relationship-without-reference', 'field/summary-without-operations', 'view/layout-without-binding', + 'view/tree-without-parent-field', 'webhook/without-triggers', ]); }); @@ -201,9 +342,10 @@ describe('registry hygiene', () => { ...checkFieldCompleteness({ type: 'select' }), ...checkFieldCompleteness({ type: 'checkboxes' }), ...checkViewCompleteness({ type: 'kanban' }), + ...checkViewCompleteness({ type: 'tree', tree: {} }, { name: 'unit', fields: {} }), ...checkWebhookCompleteness({ url: 'https://x' }), ]; - expect(all).toHaveLength(7); + expect(all).toHaveLength(8); for (const f of all) { expect(f.fix.length).toBeGreaterThan(8); expect(f.message.length).toBeGreaterThan(60); diff --git a/packages/spec/src/kernel/functional-completeness.ts b/packages/spec/src/kernel/functional-completeness.ts index 99d4dbead4..386f2bbcff 100644 --- a/packages/spec/src/kernel/functional-completeness.ts +++ b/packages/spec/src/kernel/functional-completeness.ts @@ -77,6 +77,7 @@ export const FIELD_FORMULA_WITHOUT_EXPRESSION = 'field/formula-without-expressio export const FIELD_RELATIONSHIP_WITHOUT_REFERENCE = 'field/relationship-without-reference'; export const FIELD_CHOICE_WITHOUT_OPTIONS = 'field/choice-without-options'; export const VIEW_LAYOUT_WITHOUT_BINDING = 'view/layout-without-binding'; +export const VIEW_TREE_WITHOUT_PARENT_FIELD = 'view/tree-without-parent-field'; export const WEBHOOK_WITHOUT_TRIGGERS = 'webhook/without-triggers'; /** Every rule id this module can emit — pinned by tests so ids cannot drift. */ @@ -86,6 +87,7 @@ export const FUNCTIONAL_COMPLETENESS_RULES = [ FIELD_RELATIONSHIP_WITHOUT_REFERENCE, FIELD_CHOICE_WITHOUT_OPTIONS, VIEW_LAYOUT_WITHOUT_BINDING, + VIEW_TREE_WITHOUT_PARENT_FIELD, WEBHOOK_WITHOUT_TRIGGERS, ] as const; @@ -193,14 +195,50 @@ export function checkFieldCompleteness(def: unknown): CompletenessFinding[] { /** * The layout-specific config block each view type is inert without. The - * renderer falls back to LITERAL field names (`start_date`, `status`, …), so a - * view without its block renders — but empty — on any object that does not - * happen to declare those exact fields. Degrades rather than fully dies, and - * the renderer half of the evidence lives in objectui: WARNING, not error - * (ADR-0078 §1), until a verification pass on the renderer promotes it. + * renderer falls back to LITERAL field names, so a view without its block + * renders — but empty — on any object that does not happen to declare those + * exact fields. Degrades rather than fully dies: WARNING, not error + * (ADR-0078 §1). * - * `timeline` / `tree` have config schemas too but are NOT flagged yet — same - * verify-then-enforce gate; the audit's Tier-A names only these three. + * Every entry names its measured renderer fallback. The verify-then-enforce + * gate this table sits behind (the audit's Tier-A had named only the first + * three) was discharged for `timeline` / `map` / `tree` by two measurements + * on record: the per-type props builder of objectui's ListView adapter + * (`packages/plugin-list/src/ListView.tsx`, read back verbatim from the built + * console 17.2.0), and a both-direction ablation on `os validate` — deleting + * a `timeline` block left `warnings: []` with `valid: true`, deleting the + * sibling `gantt` block warned as designed. The gate was working; the table + * was short. + * + * - `kanban` → `groupBy = groupByField || groupField || ` + * - `calendar` → `startDateField || 'start_date'`, `endDateField || 'end_date'` + * - `gantt` → `startDateField || 'start_date'`, `endDateField || 'end_date'`, + * `progressField || 'progress'`, `dependenciesField || 'dependencies'` — + * fails CLOSED (`null` unless both dates resolve): a blank chart + * - `timeline` → `startDateField || 'created_at'`, `titleField || 'name'` — the + * sharpest member: `created_at` is a plausible-looking name many objects do + * not declare, and the renderer drops every row whose start date fails to + * parse, so the view is blank rather than merely mis-titled + * - `map` → `locationField || 'location'` — rows whose coordinates do not + * parse are dropped and the chrome renders over nothing. ⚠️ Unlike the + * others, `ListMapConfigSchema` requires NO key: its docblock says the + * coordinates come from EITHER a `latitudeField`/`longitudeField` pair OR a + * `locationField`, and nothing in the schema demands one form. A present + * block declaring neither is the same unbound view with an extra pair of + * braces, so {@link checkViewCompleteness} reads a `map` block for its + * coordinate binding, not merely for its presence. + * - `tree` → `labelField || titleField || 'name'`; the load-bearing binding + * is `parentField`, and a missing one puts every record at depth 0. Every + * `TreeConfigSchema` key is optional, so `tree: {}` satisfies THIS table and + * still renders flat — the parent pointer has its own rule, + * {@link VIEW_TREE_WITHOUT_PARENT_FIELD}, below. + * + * `gallery` is measured too (`titleField || 'name'`) and is deliberately NOT + * here: `GalleryConfigSchema` requires no key, so "has a `gallery` block" + * would assert nothing an author could act on, and the fallback mis-titles + * cards rather than emptying the surface — a degradation this table cannot + * express without inventing a binding key the schema does not have. + * Recorded, not enforced. * * ⛔ [#13216] `page` does NOT belong in this table, and completing the map with * it would be a regression in two independent ways. Its binding is `pageName`, @@ -216,33 +254,149 @@ const VIEW_BINDING_BLOCKS: Readonly> = { kanban: 'kanban', calendar: 'calendar', gantt: 'gantt', + timeline: 'timeline', + map: 'map', + tree: 'tree', +}; + +/** + * The `fix` hint per entry. Each names the keys that make the block a + * binding: the schema's required keys where it has them (`timeline` requires + * both of its; `calendar` requires `startDateField`), and for `map` / `tree`, + * whose schemas require nothing, the keys the renderer reads instead of a + * literal. + */ +const VIEW_BINDING_FIX: Readonly> = { + kanban: "kanban: { groupByField: '' }", + calendar: "calendar: { startDateField: '', titleField: '' }", + gantt: "gantt: { startDateField: '', endDateField: '', titleField: '' }", + timeline: "timeline: { startDateField: '', titleField: '' }", + map: "map: { locationField: '' } — or { latitudeField: '', longitudeField: '' }", + tree: "tree: { parentField: '', labelField: '' }", }; +const isNonEmptyString = (v: unknown): v is string => typeof v === 'string' && v.length > 0; + +/** A `map` block's coordinate binding — either of the two forms its schema documents. */ +const hasMapCoordinateBinding = (block: AnyRec): boolean => + isNonEmptyString(block.locationField) + || (isNonEmptyString(block.latitudeField) && isNonEmptyString(block.longitudeField)); + +/** + * The field definitions of a bound object in either authorable spelling — a + * name-keyed map (`fields: { parent: {…} }`) or an array (`fields: [{ name: + * 'parent', … }]`). Only the definitions are needed: parent-pointer detection + * reads `type` and `reference`, never the key. + */ +function fieldDefsOf(object: AnyRec): AnyRec[] { + const fields = object.fields; + if (Array.isArray(fields)) return fields.filter(isRec); + if (isRec(fields)) return Object.values(fields).filter(isRec); + return []; +} + +/** + * Whether the tree renderer could auto-detect a parent pointer on this object + * — a mirror of objectui's `detectParentField` + * (`packages/plugin-tree/src/ObjectTree.tsx`): a field declared + * `type: 'tree'`, else a `lookup` / `master_detail` whose `reference` is the + * object's own name. Mirrored, not tightened: a stricter predicate here would + * warn about a view that renders correctly, a looser one would bless the flat + * render. The renderer also reads `reference_to`; that is the retired spelling + * the ADR-0087 conversion layer folds to `reference` before this predicate + * ever sees the stack, so it needs no arm here. An object with no `name` + * cannot be self-referenced — the renderer's detection needs the object name + * for the lookup arm too. + */ +function hasDetectableParentField(object: AnyRec): boolean { + const own = isNonEmptyString(object.name) ? object.name : undefined; + return fieldDefsOf(object).some((def) => + def.type === 'tree' + || ((def.type === 'lookup' || def.type === 'master_detail') && own !== undefined && def.reference === own)); +} + /** * Completeness of a single list-view definition (a container's `list` / * `listViews.*` entry). + * + * `boundObject` is the definition of the object the view is bound to, when + * the caller can resolve it (`@objectstack/lint`'s walk looks it up by name in + * `stack.objects`). It feeds exactly one rule — + * {@link VIEW_TREE_WITHOUT_PARENT_FIELD} — whose second clause ("nothing on + * the object to auto-detect from") cannot be asserted without it: with no + * object in hand that rule stays silent rather than guess, the same + * only-assert-what-was-verified stance every rule in this module takes. A + * view naming an object the stack does not declare is + * `validate-object-references`' finding, not this one's. + * + * ## Why `tree` needs a second rule, not a stronger entry in the table + * + * The binding-block rule asks "is the block there"; for `tree` that is the + * weakest assertion in the table, because every `TreeConfigSchema` key is + * optional and `parentField` is documented as auto-detected when omitted. So + * a `tree: {}` view is spec-valid, satisfies the table, and on an object with + * no self-reference still renders FLAT: `detectParentField` returns nothing, + * `buildForest` makes every record a root, and the result is a complete, + * correct-looking table with an expand slot that never opens — the one shape + * in this family that looks right on every surface an author can see. A gate + * that passed it would be vouching for it, which is worse than no gate. The + * rule fires only when BOTH halves fail — `parentField` undeclared AND nothing + * on the bound object the renderer would detect — so a view that renders + * correctly by auto-detection is never warned about. */ -export function checkViewCompleteness(view: unknown): CompletenessFinding[] { +export function checkViewCompleteness(view: unknown, boundObject?: unknown): CompletenessFinding[] { if (!isRec(view)) return []; const type = typeof view.type === 'string' ? view.type : undefined; if (!type) return []; + const out: CompletenessFinding[] = []; + const block = VIEW_BINDING_BLOCKS[type]; - if (!block || isRec(view[block])) return []; - return [{ - rule: VIEW_LAYOUT_WITHOUT_BINDING, - severity: 'warning', - path: block, - message: - `A \`${type}\` view with no \`${block}\` block is bound to nothing: the renderer falls ` - + 'back to literal default field names, which works only if the object happens to declare ' - + 'them — on any other object the view renders empty while authoring reports success.', - fix: - type === 'kanban' - ? "kanban: { groupByField: '' }" - : type === 'calendar' - ? "calendar: { startDateField: '', titleField: '' }" - : "gantt: { startDateField: '', endDateField: '', titleField: '' }", - }]; + if (block && !isRec(view[block])) { + out.push({ + rule: VIEW_LAYOUT_WITHOUT_BINDING, + severity: 'warning', + path: block, + message: + `A \`${type}\` view with no \`${block}\` block is bound to nothing: the renderer falls ` + + 'back to literal default field names, which works only if the object happens to declare ' + + 'them — on any other object the view renders empty while authoring reports success.', + fix: VIEW_BINDING_FIX[type], + }); + } else if (type === 'map' && isRec(view.map) && !hasMapCoordinateBinding(view.map)) { + out.push({ + rule: VIEW_LAYOUT_WITHOUT_BINDING, + severity: 'warning', + path: 'map.locationField', + message: + 'A `map` view whose `map` block declares neither `locationField` nor the `latitudeField`/' + + '`longitudeField` pair is bound to nothing: the renderer reads `locationField || ' + + "'location'` (objectui `ListView.tsx`), which works only if the object happens to declare " + + 'a `location` field — on any other object every marker is dropped and the map renders ' + + 'empty while authoring reports success.', + fix: VIEW_BINDING_FIX.map, + }); + } + + if (type === 'tree' && isRec(boundObject)) { + const declared = isRec(view.tree) && isNonEmptyString(view.tree.parentField); + if (!declared && !hasDetectableParentField(boundObject)) { + out.push({ + rule: VIEW_TREE_WITHOUT_PARENT_FIELD, + severity: 'warning', + path: 'tree.parentField', + message: + 'A `tree` view with no resolvable parent pointer renders FLAT, not empty: `parentField` is ' + + 'undeclared and the bound object declares neither a `tree` field nor a lookup/master_detail ' + + 'back to itself, so the renderer\'s auto-detection finds nothing (objectui `ObjectTree.tsx` — ' + + '`detectParentField`) and `buildForest` makes every record a root at depth 0. The result is ' + + 'a complete, correct-looking table with an expand slot that never opens, while authoring ' + + 'reports success. Declare `tree.parentField`, or add a self-referencing field to the object.', + fix: "tree: { parentField: '' }", + }); + } + } + + return out; } /** diff --git a/scripts/adr-anchors/packages__spec__src__kernel__functional-completeness.ts.json b/scripts/adr-anchors/packages__spec__src__kernel__functional-completeness.ts.json index eb6c606aa8..d276ec7071 100644 --- a/scripts/adr-anchors/packages__spec__src__kernel__functional-completeness.ts.json +++ b/scripts/adr-anchors/packages__spec__src__kernel__functional-completeness.ts.json @@ -3,5 +3,5 @@ "adrs": [ "ADR-0078" ], - "invariant": "Every rule here cites the runtime line that silently skips the instance, and every deliberate NON-rule cites the evidence that exempts it (ADR-0078 §6). `multiselect` without `options` is NOT flagged — `record-validator.ts` blesses it verbatim as free-form tags, which is §1 case (3) genuinely-optional; `user` relationships and `timeline`/`tree` views are exempt for their own stated reasons. A rule added without its skip-site citation, or an exemption 'fixed', is a false prescription: it tells an AI author to change working metadata, which is the failure this gate exists to prevent." + "invariant": "Every rule here cites the runtime line that silently skips the instance, and every deliberate NON-rule cites the evidence that exempts it (ADR-0078 §6). `multiselect` without `options` is NOT flagged — `record-validator.ts` blesses it verbatim as free-form tags, which is §1 case (3) genuinely-optional; `user` relationships are exempt for their stated reason; `gallery` views are recorded as measured-but-not-enforced (their schema has no binding key to demand), and every view type in `VIEW_BINDING_BLOCKS` cites its measured renderer fallback, with the `tree` parent pointer held to the renderer's own auto-detection rule. A rule added without its skip-site citation, or an exemption 'fixed', is a false prescription: it tells an AI author to change working metadata, which is the failure this gate exists to prevent." }