diff --git a/.changeset/24728-filterable-fields-residue.md b/.changeset/24728-filterable-fields-residue.md new file mode 100644 index 00000000000..ecb2796b7da --- /dev/null +++ b/.changeset/24728-filterable-fields-residue.md @@ -0,0 +1,43 @@ +--- +'@objectstack/lint': minor +'@objectstack/metadata-protocol': patch +'@objectstack/metadata-core': patch +--- + +fix(lint)!: `os lint` and the react-page props check report the retired list-view `filterableFields` by name, and the readers that still treated it as a field position go + +Clause-②: yes (narrowing) + + + +**BREAKING** (an authoring narrowing), shipped as `minor` under the launch-window convention for breaking changes. + +`@objectstack/spec` 18 retired the list view's `filterableFields` with a `retiredKey()` tombstone, so `os validate` and `os build` refuse it at the parse. Two authoring checks still read it as a live field position: + +- **`os lint`** never parses: it judges the normalized stack, and the conversion that strips the key is retired from the load path. On a JSON, YAML or raw-object source the key reached the list-view field-reference rule as written. A valid field name passed `os lint`, and a stale one was reported as an unknown field (`list-view-field-unknown` / `list-view-field-dotted`), never as the retirement. +- **The react-page props check** read `` as a field-bearing prop. A react page's source is parsed against no list-view schema, so `os validate`, `os build` and `os lint` passed the prop whenever its names resolved. + +Both now refuse the key by name, as an `error`, whatever it names: + +- a list view (every rung: an object's `listViews`, a `defineView` container's `list` / `listViews`, the flattened list overlay and the view item record) reports `list-view-key-retired` at `….filterableFields`, whether or not its object binding resolves; +- `` on a react page reports `react-prop-retired`. + +The message names the retirement and the hint is the tombstone's own "Delete the key" sentence, read from `ListViewSchema` rather than retyped. The new rule id is exported from the package root as `LIST_VIEW_KEY_RETIRED`. + +### FROM → TO + +| before | after | +| --- | --- | +| `filterableFields: ['status']` on a list view in a non-`defineStack` source: `os lint` passed | `os lint` fails with `list-view-key-retired` | +| `filterableFields: ['ghost']` on such a list view: `list-view-field-unknown` | `list-view-key-retired` | +| `` on a react page: passed every authoring command | `react-prop-retired` | + +**The one-line fix:** delete `filterableFields` (the key or the prop). For a curated end-user filter bar, declare `userFilters: { element: 'dropdown', fields: [{ field: 'status' }] }`; the Filter panel offers every field the user may read. `os migrate meta --from 17` lists the edits for existing metadata sources; a react page's source is yours to edit by hand. + +### The residue readers that go + +- **`@objectstack/lint`**: the list-view field-reference rule no longer walks `filterableFields` as a filter position (its severity and dotted-path maps), the field-consumer rule no longer counts it as a behavioural read, and `os explain list-view-field-unknown` / `list-view-field-dotted` no longer name it as a checked position. +- **`@objectstack/metadata-protocol`**: `computeViewReferenceDiagnostics` no longer walks `filterableFields`, the same deletion its retired sibling `tabs` had. A stored body has the key stripped by the conversion replay before it is served, and the write door refuses it. +- **`@objectstack/metadata-core`**: the ADR-0106 field-security scrub no longer classifies `filterableFields` as a list-view name list. A body that still carries the key goes the unclassified way and fails closed: a list view whose `filterableFields` names a field the caller may not read is dropped whole, where the scrub used to remove the name. + +⛔ **Untouched:** `userFilters` and `searchableFields` keep their meaning and their checks. diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index dffbccc0b32..61210fa331b 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -496,12 +496,15 @@ export type { // [#24668] Its FOURTH: a list view judged against the whole collection (a // runtime write that names no package) whose bound object exists nowhere — // refused the same way; `wholeCollection` is the statement. +// Its FIFTH: a list view still carrying a key the spec retired, reported by +// name with the tombstone's prescription (`os lint` never parses). export { validateListViewFieldRefs, LIST_VIEW_FIELD_UNKNOWN, LIST_VIEW_FIELD_DOTTED, LIST_VIEW_OBJECT_OUTSIDE_CLOSURE, LIST_VIEW_OBJECT_UNKNOWN, + LIST_VIEW_KEY_RETIRED, } from './validate-list-view-field-refs.js'; export type { ListViewFieldRefFinding, diff --git a/packages/lint/src/list-view-filterable-fields-retired.test.ts b/packages/lint/src/list-view-filterable-fields-retired.test.ts new file mode 100644 index 00000000000..483370502d9 --- /dev/null +++ b/packages/lint/src/list-view-filterable-fields-retired.test.ts @@ -0,0 +1,138 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `os lint` reports the list view's retired `filterableFields` BY NAME. + * + * @objectstack/spec 18 retired the key with a `retiredKey()` tombstone, so + * `os validate` and `os build` refuse it at the parse. `os lint` never parses: + * it judges the `normalizeStackInput` output, and the D2 conversion that + * strips the key is retired from the load path, so on a non-`defineStack` + * source the key reaches the list-view field-reference rule as written. That + * rule used to walk it as a field position — a valid name passed `os lint`, + * and a stale one was named as an unknown FIELD. Now it reports the + * retirement, once per list view, with the tombstone's own prescription. + * + * This file authors the retired key as a key on purpose, so the spec's + * tree-scoped absence walk (`view-list-filterable-fields-retirement.test.ts`) + * excludes it by name, as a member of the retirement kit. + */ + +import { describe, expect, it } from 'vitest'; + +import { runAuthoringRules } from './authoring-rules.js'; +import { + LIST_VIEW_FIELD_DOTTED, + LIST_VIEW_FIELD_UNKNOWN, + LIST_VIEW_KEY_RETIRED, + LIST_VIEW_RETIRED_KEYS_REPORTED, + listViewRetiredKeyPrescription, + validateListViewFieldRefs, +} from './validate-list-view-field-refs.js'; +import { retiredKeyRemedy } from './validate-retired-permission-residue.js'; + +type AnyRec = Record; + +const task = { + name: 'duly_task', + fields: { + title: { type: 'text' }, + status: { type: 'select', options: [{ label: 'Open', value: 'open' }] }, + owner: { type: 'lookup', reference: 'duly_person' }, + }, +}; +const person = { name: 'duly_person', fields: { name: { type: 'text' } } }; + +const listWith = (extra: AnyRec): AnyRec => ({ + type: 'grid', + data: { provider: 'object', object: 'duly_task' }, + columns: ['title'], + ...extra, +}); + +const stackWithList = (list: AnyRec, objects: unknown[] = [task, person]): AnyRec => ({ + objects, + views: [{ name: 'task', objectName: 'duly_task', list }], +}); + +const retiredOf = (findings: F[]): F[] => findings.filter((f) => f.rule === LIST_VIEW_KEY_RETIRED); + +describe('the list view\'s retired `filterableFields` is reported by name, never as a field position', () => { + const prescription = listViewRetiredKeyPrescription('filterableFields'); + + it('the tombstone publishes its prescription, and the rule reports exactly the retired key it used to walk', () => { + expect(prescription).not.toBeNull(); + expect(LIST_VIEW_RETIRED_KEYS_REPORTED).toEqual(['filterableFields']); + // A key that is not a tombstone has no prescription to read. + expect(listViewRetiredKeyPrescription('columns')).toBeNull(); + expect(listViewRetiredKeyPrescription('no_such_key')).toBeNull(); + }); + + it('a VALID field name is reported as the retirement, where it used to pass in silence', () => { + const findings = validateListViewFieldRefs(stackWithList(listWith({ filterableFields: ['status'] }))); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + rule: LIST_VIEW_KEY_RETIRED, + severity: 'error', + path: 'views[0].list.filterableFields', + }); + expect(findings[0]!.message).toContain('`filterableFields`'); + // Read from the tombstone, never retyped. + expect(findings[0]!.hint).toBe(retiredKeyRemedy(prescription!)); + }); + + it('a stale or dotted name is reported as the retirement, never as a field reference', () => { + const findings = validateListViewFieldRefs( + stackWithList(listWith({ filterableFields: ['nope_field', 'owner.name'] })), + ); + expect(findings.map((f) => f.rule)).toEqual([LIST_VIEW_KEY_RETIRED]); + expect(findings.filter((f) => f.rule === LIST_VIEW_FIELD_UNKNOWN || f.rule === LIST_VIEW_FIELD_DOTTED)).toEqual([]); + }); + + it('is reported at every list-view rung the rule walks', () => { + const lv = listWith({ filterableFields: [] }); + const stack: AnyRec = { + objects: [{ ...task, listViews: { mine: lv } }, person], + views: [ + { name: 'task', objectName: 'duly_task', list: lv, listViews: { open: lv } }, + { name: 'duly_task.flat', object: 'duly_task', viewKind: 'list', ...lv }, + { name: 'duly_task.item', object: 'duly_task', viewKind: 'list', config: lv }, + ], + }; + expect(retiredOf(validateListViewFieldRefs(stack)).map((f) => f.path).sort()).toEqual([ + 'objects[0].listViews.mine.filterableFields', + 'views[0].list.filterableFields', + 'views[0].listViews.open.filterableFields', + 'views[1].filterableFields', + 'views[2].config.filterableFields', + ]); + }); + + it('does not depend on the binding: an unresolvable object, a non-object provider and an empty graph all report it', () => { + const unbound = validateListViewFieldRefs( + stackWithList(listWith({ data: { provider: 'object', object: 'no_such_object' }, filterableFields: ['x'] })), + ); + expect(retiredOf(unbound)).toHaveLength(1); + const inline = validateListViewFieldRefs( + stackWithList(listWith({ data: { provider: 'value', items: [] }, filterableFields: ['x'] })), + ); + expect(retiredOf(inline)).toHaveLength(1); + const noObjects = validateListViewFieldRefs(stackWithList(listWith({ filterableFields: ['x'] }), [])); + expect(retiredOf(noObjects)).toHaveLength(1); + }); + + it('lit control: a list view without the key reports no retirement, and its surviving filter positions are still judged', () => { + const findings = validateListViewFieldRefs( + stackWithList(listWith({ userFilters: { element: 'dropdown', fields: [{ field: 'nope_field' }] } })), + ); + expect(retiredOf(findings)).toEqual([]); + expect(findings.map((f) => f.rule)).toEqual([LIST_VIEW_FIELD_UNKNOWN]); + }); + + it('reaches `os lint`\'s registry run on the normalized (unparsed) stack, as an error', () => { + const stack = stackWithList(listWith({ filterableFields: ['status'] })); + const findings = runAuthoringRules('lint', { normalized: stack }); + const retired = findings.filter((f) => f.rule === LIST_VIEW_KEY_RETIRED); + expect(retired).toHaveLength(1); + expect(retired[0]).toMatchObject({ severity: 'error', path: 'views[0].list.filterableFields' }); + }); +}); diff --git a/packages/lint/src/rule-explanations.ts b/packages/lint/src/rule-explanations.ts index 1ac2091a590..2d8e6cccc12 100644 --- a/packages/lint/src/rule-explanations.ts +++ b/packages/lint/src/rule-explanations.ts @@ -3123,7 +3123,7 @@ const LIST_VIEW_FIELD_UNKNOWN_EXPLANATION: RuleExplanation = { paragraphs: [ // The positions are held to `listViewWalkedPositions()` by the rule's test. 'Every field a list view names must be a field of its bound object: its `columns` (and a ' + - 'column\'s `summary` and `prefix`), `filterableFields`, `hiddenFields`, `fieldOrder`, ' + + 'column\'s `summary` and `prefix`), `hiddenFields`, `fieldOrder`, ' + '`grouping`, `rowColor` and `userFilters`, the keys of its `filter` and of each ' + '`userFilters.tabs[].filter`, and every binding inside a `kanban`, `calendar`, `gantt`, ' + '`timeline`, `gallery`, `map` or `tree` block. Nothing resolves the name at render time: the ' + @@ -3160,7 +3160,7 @@ const LIST_VIEW_FIELD_DOTTED_EXPLANATION: RuleExplanation = { 'dotted entry whatever its head (`assertProjectionHasNoDottedPaths` on the engine boundary, ' + '`assertProjectionFieldsExist` at the REST ingress, both `400 INVALID_FIELD`), so the view\'s ' + 'FIRST fetch is refused, not rendered.', - 'Filter: the view\'s `filter`, its `userFilters.tabs[].filter`, `filterableFields` and ' + + 'Filter: the view\'s `filter`, its `userFilters.tabs[].filter` and ' + '`userFilters.fields` are judged by the head\'s class, through the classifier the door ' + 'itself uses (`classifyDottedFilterHead`). A relation head stores the related record\'s id, ' + 'not an embedded document; a virtual head is computed on read, so no driver materialises a ' + diff --git a/packages/lint/src/validate-field-consumers.ts b/packages/lint/src/validate-field-consumers.ts index 7cf2aab4208..e9804197f00 100644 --- a/packages/lint/src/validate-field-consumers.ts +++ b/packages/lint/src/validate-field-consumers.ts @@ -304,7 +304,9 @@ const PROSE_KEYS: ReadonlySet = new Set([ /** Inside a display root (and everywhere else), these path segments make a site behavioural. */ const BEHAVIOUR_SEGMENTS: ReadonlySet = new Set([ 'filter', 'filters', 'runtimeFilter', 'relatedListFilter', 'where', 'criteria', 'conditions', - 'condition', 'defaultFilter', 'userFilters', 'quickFilters', 'filterableFields', + // `filterableFields` is not here: @objectstack/spec 18 retired the key and no + // list view reads it, so a field it names is not read by it either. + 'condition', 'defaultFilter', 'userFilters', 'quickFilters', 'sort', 'sortBy', 'defaultSort', 'grouping', 'groupBy', 'groupByField', 'groupField', 'startField', 'endField', 'dateField', 'startDateField', 'endDateField', 'coverField', 'titleField', 'colorField', 'latitudeField', 'longitudeField', 'locationField', diff --git a/packages/lint/src/validate-list-view-field-refs.test.ts b/packages/lint/src/validate-list-view-field-refs.test.ts index 5fcf480f873..38bc0e959a0 100644 --- a/packages/lint/src/validate-list-view-field-refs.test.ts +++ b/packages/lint/src/validate-list-view-field-refs.test.ts @@ -90,7 +90,6 @@ const FULL_LIST_VIEW: AnyRec = { { field: 'period_key', summary: { field: 'estimate' }, prefix: { field: 'status' } }, ], filter: [{ field: 'visible_from', operator: 'equals', value: '2026-01-01' }], - filterableFields: ['status'], hiddenFields: ['business_unit'], fieldOrder: ['title', 'status'], grouping: { fields: [{ field: 'business_unit' }] }, @@ -297,7 +296,6 @@ describe('#14107 — every other walked position', () => { 'views[0].list.columns[0].prefix.field', 'warning', ], - [{ filterableFields: [BAD] }, 'views[0].list.filterableFields[0]', 'error'], [{ hiddenFields: [BAD] }, 'views[0].list.hiddenFields[0]', 'warning'], [{ fieldOrder: [BAD] }, 'views[0].list.fieldOrder[0]', 'warning'], [{ rowColor: { field: BAD } }, 'views[0].list.rowColor.field', 'warning'], @@ -735,19 +733,19 @@ describe('#14282 — a dotted key the FILTER door refuses, and the ones it serve expect(findings.every((f) => f.rule === LIST_VIEW_FIELD_DOTTED)).toBe(true); }); - it('the two positions that DECLARE end-user filterable names are judged', () => { + it('the position that DECLARES end-user filterable names is judged', () => { // objectui folds the resulting conditions into the fetched query - // (`buildEffectiveFilter`), so these names become filter keys. + // (`buildEffectiveFilter`), so these names become filter keys. Its retired + // sibling `filterableFields` is reported by name instead + // (`list-view-filterable-fields-retired.test.ts`). const findings = validateListViewFieldRefs( stackWith( mutate({ - filterableFields: ['owner.name'], userFilters: { fields: [{ field: 'parent.title' }] }, }), ), ); expect(idsOf(findings).sort()).toEqual([ - 'views[0].list.filterableFields[0]', 'views[0].list.userFilters.fields[0].field', ]); expect(findings.every((f) => f.rule === LIST_VIEW_FIELD_DOTTED)).toBe(true); diff --git a/packages/lint/src/validate-list-view-field-refs.ts b/packages/lint/src/validate-list-view-field-refs.ts index 4a1359629b1..f1549557214 100644 --- a/packages/lint/src/validate-list-view-field-refs.ts +++ b/packages/lint/src/validate-list-view-field-refs.ts @@ -135,11 +135,12 @@ * `assertProjectionFieldsExist` answers the same at the REST ingress * (#7532). So every dotted column is reported, whatever its head's type. * - **Filter** — the view's own `filter`, its `userFilters.tabs[].filter`, - * and the two positions that DECLARE which names the end user may - * filter on (`filterableFields`, spelled by the - * spec as "bare field names enabled for end-user filtering", and - * `userFilters.fields`; objectui folds the resulting conditions into the - * fetched query through `buildEffectiveFilter`). Here the door does NOT + * and the position that DECLARES which names the end user may filter on + * (`userFilters.fields`; objectui folds the resulting conditions into the + * fetched query through `buildEffectiveFilter`). The second such position, + * `filterableFields`, retired in @objectstack/spec 18 and is no longer a + * field position here: it is reported by name instead — see + * {@link LIST_VIEW_KEY_RETIRED}. Here the door does NOT * refuse everything: `assertFilterIsMaterializable` judges a dotted key * only when `classifyDottedFilterHead` classifies its head as `relation`, * `virtual` or `scalar`, and deliberately passes structured/JSON heads @@ -327,8 +328,10 @@ */ import { classifyDottedFilterHead } from '@objectstack/spec/data'; +import { ListViewSchema } from '@objectstack/spec/ui'; import { walkFilterFieldKeys } from './filter-walk.js'; +import { retiredKeyRemedy } from './validate-retired-permission-residue.js'; import { describeFieldPathVerdict, indexObjectGraph, @@ -361,6 +364,74 @@ export const LIST_VIEW_OBJECT_UNKNOWN = 'list-view-object-unknown'; */ export const LIST_VIEW_FIELD_DOTTED = 'list-view-field-dotted'; +/** + * A list view that still carries a key `@objectstack/spec` retired — reported + * BY NAME, with the tombstone's own prescription, never as a field reference. + * + * ## Why `os lint` needs it + * + * `retiredKey()` puts the refusal in the PARSE: `os validate` and `os build` + * refuse such a list view with the prescription before any rule runs. `os + * lint` never parses — it judges the `normalizeStackInput` output — and the + * D2 conversion that strips the key is retired from the load path, so on a + * non-`defineStack` source (JSON, YAML, a raw object) the key reaches this + * rule as written. Until the key retired, this rule walked it as a field + * position, so on that input `os lint` passed a list view the other two + * commands refuse, or named a stale entry as an unknown FIELD rather than + * naming the retirement. This finding is that missing channel: one `error` + * per list view, at the key, whatever its value and whatever the binding + * resolves to. + * + * ## The wording is READ, never retyped + * + * The prescription is the tombstone's published `description` + * (`[REMOVED] `) on `ListViewSchema`, the one wording the parse + * raises too, so an author refused through either door is told the same + * thing. The `hint` is its "Delete the key" sentence, cut by + * {@link retiredKeyRemedy} exactly as the permission-residue rule cuts its + * own. An unresolvable description yields NO finding rather than a hint this + * module invented. + * + * ## Which keys + * + * {@link LIST_VIEW_RETIRED_KEYS_REPORTED} — the retired keys this rule used to + * read as field positions. The other list-view tombstones were never field + * positions here, so nothing in this rule ever gave them a wrong answer. + */ +export const LIST_VIEW_KEY_RETIRED = 'list-view-key-retired'; + +/** + * The retired list-view keys reported by name (see {@link LIST_VIEW_KEY_RETIRED}). + * `validate-react-page-props.ts` reads the same list for ``'s props. + */ +export const LIST_VIEW_RETIRED_KEYS_REPORTED: readonly string[] = Object.freeze(['filterableFields']); + +/** Strip the `[REMOVED] ` marker `retiredKey()` prefixes onto its guidance. */ +const REMOVED_MARKER = /^\[REMOVED\]\s*/; + +/** + * The retirement's own prescription for a list-view `key`, read from the + * tombstone's published description on `ListViewSchema`. `null` when the key + * is not a tombstone there, or its description cannot be resolved. + */ +export function listViewRetiredKeyPrescription(key: string): string | null { + const shape = (ListViewSchema as unknown as { shape?: Record }).shape; + const described = shape?.[key]?.description; + if (typeof described !== 'string' || !REMOVED_MARKER.test(described)) return null; + const prescription = described.replace(REMOVED_MARKER, '').trim(); + return prescription.length > 0 ? prescription : null; +} + +/** + * The verdict head of a retirement prescription: its first clause, up to the + * first ` — `, without the trailing period. Cut, never rewritten. + */ +export function retiredKeyVerdictHead(prescription: string): string { + const dash = prescription.indexOf(' — '); + const head = dash > 0 ? prescription.slice(0, dash) : prescription; + return head.replace(/\.$/, ''); +} + /** * Which query door a position's written name reaches, for the #14282 verdict. * `undefined` (the default for every position) means "no door this card @@ -451,7 +522,7 @@ const POSITIONS: Record = { '': { // A stale `hiddenFields` / `fieldOrder` entry hides and orders nothing — // the inert case, warned rather than gated. - lists: { filterableFields: 'error', hiddenFields: 'warning', fieldOrder: 'warning' }, + lists: { hiddenFields: 'warning', fieldOrder: 'warning' }, entries: { columns: 'error' }, }, grouping: { entries: { fields: 'error' } }, @@ -593,7 +664,7 @@ const POSITIONS: Record = { * exception: measured client-side, it ACCEPTS a dot-path. See the module note. */ const DOTTED_AXIS: Record> = { - '': { columns: 'projection', filterableFields: 'filter' }, + '': { columns: 'projection' }, userFilters: { fields: 'filter' }, }; @@ -750,7 +821,9 @@ export function validateListViewFieldRefs( // object binding is then outside it, and is refused below. [#24668] An empty // WHOLE collection is not: no deployment serves zero objects, so it means // the host supplied no universe (see the section of that name). - if (graph.size === 0 && !scope) return findings; + // The retired-key finding below does not depend on any field map, so an + // empty graph only switches the field positions off. + const noUniverse = graph.size === 0 && !scope; /** * [#24563] One list view whose binding is not in the stated closure — see @@ -893,6 +966,23 @@ export function validateListViewFieldRefs( ): void => { if (!isRec(listView)) return; + // ── A retired key, reported by name before any skip: its verdict does + // not depend on the binding or on a field map (see LIST_VIEW_KEY_RETIRED). + for (const key of LIST_VIEW_RETIRED_KEYS_REPORTED) { + if (!Object.prototype.hasOwnProperty.call(listView, key)) continue; + const prescription = listViewRetiredKeyPrescription(key); + if (prescription === null) continue; + findings.push({ + severity: 'error', + rule: LIST_VIEW_KEY_RETIRED, + where, + path: `${path}.${key}`, + message: `\`${key}\` is a retired list-view key: ${retiredKeyVerdictHead(prescription)}, so \`os validate\` and \`os build\` refuse this list view`, + hint: retiredKeyRemedy(prescription), + }); + } + if (noUniverse) return; + // ── Skip 4 (this surface's own): a list view bound to a non-`object` // provider names no fields on any object graph. const data = listView.data; diff --git a/packages/lint/src/validate-react-page-props.test.ts b/packages/lint/src/validate-react-page-props.test.ts index b388ebeec9e..b6f07fc4258 100644 --- a/packages/lint/src/validate-react-page-props.test.ts +++ b/packages/lint/src/validate-react-page-props.test.ts @@ -33,6 +33,8 @@ import { REACT_RECORD_BLOCK_ALTERNATIVES, } from '@objectstack/spec/ui'; import { explainRule } from './rule-explanations.js'; +import { listViewRetiredKeyPrescription } from './validate-list-view-field-refs.js'; +import { retiredKeyRemedy } from './validate-retired-permission-residue.js'; // [#22161] Each finding of the five ids below is one verdict sentence; the // reasoning it used to carry is the id's `os explain` entry. Every call in this @@ -689,11 +691,31 @@ describe('validateReactPageProps — field props (#4340)', () => { ]); }); - it('covers the legacy `filterableFields` shorthand', () => { - const f = unknownFields( - validateReactPageProps(propsPage(jsx('ListView', `data={{ provider: 'object', object: 'crm_account' }} filterableFields={['nope']}`))), + it('refuses the retired `filterableFields` prop by name, and never judges it as a field position', () => { + // @objectstack/spec 18 retired the list view's `filterableFields` with no + // replacement. A react page's source is parsed against no list-view + // schema, so the prop is refused here, by name, whatever it names: a + // valid field (`name`) and a stale one (`nope`) get the same verdict, and + // neither is reported as a field reference. + const prescription = listViewRetiredKeyPrescription('filterableFields'); + expect(prescription, 'the tombstone publishes its prescription').not.toBeNull(); + for (const named of ['name', 'nope']) { + const f = validateReactPageProps( + propsPage(jsx('ListView', `data={{ provider: 'object', object: 'crm_account' }} filterableFields={['${named}']}`)), + ); + expect(unknownFields(f), named).toEqual([]); + const retired = f.filter((x) => x.rule === REACT_PROP_RETIRED); + expect(retired, named).toHaveLength(1); + expect(retired[0]).toMatchObject({ severity: 'error', path: 'pages[0].source' }); + expect(retired[0]!.message).toContain('"filterableFields"'); + // Read from the tombstone, never retyped. + expect(retired[0]!.hint).toBe(retiredKeyRemedy(prescription!)); + } + // Lit control: the surviving filter-bar prop is not refused. + const control = validateReactPageProps( + propsPage(jsx('ListView', `data={{ provider: 'object', object: 'crm_account' }} userFilters={{ element: 'dropdown', fields: [{ field: 'name' }] }}`)), ); - expect(f).toHaveLength(1); + expect(control.filter((x) => x.rule === REACT_PROP_RETIRED)).toEqual([]); }); }); diff --git a/packages/lint/src/validate-react-page-props.ts b/packages/lint/src/validate-react-page-props.ts index 700d05f7d53..8269227e721 100644 --- a/packages/lint/src/validate-react-page-props.ts +++ b/packages/lint/src/validate-react-page-props.ts @@ -46,6 +46,12 @@ import { isRecordContextBlockType, } from '@objectstack/spec/ui'; import { VALID_AST_OPERATORS } from '@objectstack/spec/data'; +import { + LIST_VIEW_RETIRED_KEYS_REPORTED, + listViewRetiredKeyPrescription, + retiredKeyVerdictHead, +} from './validate-list-view-field-refs.js'; +import { retiredKeyRemedy } from './validate-retired-permission-residue.js'; import { checkSearchableFieldList, indexObjectSearchTargets, @@ -142,6 +148,16 @@ interface BlockSpec { * the old key, which is exactly how a retired spelling would keep shipping. */ retired: Map; + /** + * Props of the block's SPEC schema that the spec has RETIRED with no + * replacement: prop name → the `retiredKey()` tombstone's own prescription. + * Today `` alone, over `LIST_VIEW_RETIRED_KEYS_REPORTED`. A react + * page's `source` is parsed against no list-view schema, so without this the + * prop would pass as an unknown prop and become a silent no-op the day the + * renderer stops reading it. Writing one is an error carrying the + * prescription, the same verdict `os lint` gives the key on a list view. + */ + retiredSchemaProps: Map; /** The contract's own description per required binding — the hint when one is missing. */ requiredDescriptions: Map; } @@ -160,6 +176,14 @@ const BLOCKS: Map = new Map( b.interactions.filter((i) => i.deprecated).map((i) => [i.name, i.deprecated!]), ), retired: new Map(Object.entries(REACT_RETIRED_OVERLAY_PROPS[b.tag] ?? {})), + retiredSchemaProps: new Map( + b.tag === 'ListView' + ? LIST_VIEW_RETIRED_KEYS_REPORTED.flatMap((key) => { + const prescription = listViewRetiredKeyPrescription(key); + return prescription === null ? [] : [[key, prescription] as [string, string]]; + }) + : [], + ), requiredDescriptions: new Map( b.interactions.filter((i) => i.required).map((i) => [i.name, i.description]), ), @@ -767,10 +791,11 @@ const REACT_FIELD_SPECS: Readonly> = { ListView: { // `fields` is the React overlay's "limit/order the columns"; `columns` the // spec ListView prop. Both name columns on the bound object, and a page may - // write either. `hiddenFields`/`fieldOrder`/`filterableFields` are schema - // props outside the curated contract — unadvertised but honored by the - // renderer, so a stale name there is drift just the same. - fields: ['fields', 'columns', 'hiddenFields', 'fieldOrder', 'filterableFields'], + // write either. `hiddenFields`/`fieldOrder` are schema props outside the + // curated contract — unadvertised but honored by the renderer, so a stale + // name there is drift just the same. `filterableFields` is not a field + // position: the spec retired it, and the prop is refused by name above. + fields: ['fields', 'columns', 'hiddenFields', 'fieldOrder'], sorts: ['sort'], nestedFields: ['userFilters', 'grouping'], filterArrays: ['filters'], @@ -1262,6 +1287,19 @@ export function validateReactPageProps(stack: AnyRec): ReactPropFinding[] { }); continue; } + // A retired SPEC-schema prop with no replacement: refused by name, + // with the tombstone's own prescription, never passed as unknown. + const retiredPrescription = block.retiredSchemaProps.get(u); + if (retiredPrescription !== undefined) { + findings.push({ + severity: 'error', + rule: REACT_PROP_RETIRED, + where, path, + message: `<${tag}> prop "${u}" is retired and no prop replaces it: ${retiredKeyVerdictHead(retiredPrescription)}.`, + hint: retiredKeyRemedy(retiredPrescription), + }); + continue; + } // [commit 5383fa670] Deprecate-first: the old spelling keeps working, and // every use says so — the contract's note names the canonical // metadata-tier spelling to write instead. diff --git a/packages/metadata-core/src/object-schema-fls-references.ts b/packages/metadata-core/src/object-schema-fls-references.ts index 79fa4d6296c..3295ab060c7 100644 --- a/packages/metadata-core/src/object-schema-fls-references.ts +++ b/packages/metadata-core/src/object-schema-fls-references.ts @@ -716,7 +716,10 @@ const LIST_VIEW_NAME_LISTS: Readonly> = { hiddenFields: names, fieldOrder: names, searchableFields: names, - filterableFields: names, + // `filterableFields` is not classified: @objectstack/spec 18 retired it, the + // object door refuses it, and no list view reads it. A body that still + // carries one goes the unclassified way (fail closed): a denied field it + // names drops the whole list view. }; /** diff --git a/packages/metadata-protocol/src/metadata-diagnostics.ts b/packages/metadata-protocol/src/metadata-diagnostics.ts index e7c03cb8b36..4e632251f0d 100644 --- a/packages/metadata-protocol/src/metadata-diagnostics.ts +++ b/packages/metadata-protocol/src/metadata-diagnostics.ts @@ -228,14 +228,11 @@ export function computeViewReferenceDiagnostics( userFilters?.tabs?.forEach((t, i) => t?.filter?.forEach((r, j) => requireField(r?.field, `userFilters.tabs.${i}.filter.${j}.field`))); - // A list view's OWN `tabs` is not walked: it is a `retiredKey` tombstone on - // every list-view shape. The write door refuses it, and a stored or - // artifact-shipped body has it stripped by the conversion replay before it - // is served. A body that still carries it is already badged by the spec - // diagnostics, with the tombstone's prescription. - - (view?.filterableFields as string[] | undefined)?.forEach((f, i) => - requireField(f, `filterableFields.${i}`)); + // A list view's OWN `tabs` and `filterableFields` are not walked: each is a + // `retiredKey` tombstone on every list-view shape. The write door refuses + // it, and a stored or artifact-shipped body has it stripped by the + // conversion replay before it is served. A body that still carries it is + // already badged by the spec diagnostics, with the tombstone's prescription. const kanban = view?.kanban as { groupByField?: string } | undefined; if (kanban?.groupByField) { diff --git a/packages/objectql/src/metadata-diagnostics.test.ts b/packages/objectql/src/metadata-diagnostics.test.ts index 78b19c22ecb..6ef5cddfd4f 100644 --- a/packages/objectql/src/metadata-diagnostics.test.ts +++ b/packages/objectql/src/metadata-diagnostics.test.ts @@ -30,7 +30,6 @@ describe('computeViewReferenceDiagnostics (ADR-0047)', () => { fields: [{ field: 'industry' }, { field: 'is_active' }], tabs: [{ name: 't', filter: [{ field: 'status', operator: 'equals', value: 'x' }] }], }, - filterableFields: ['status'], kanban: { groupByField: 'status', columns: ['name'] }, }, objectDef); expect(result.valid).toBe(true); @@ -68,11 +67,11 @@ describe('computeViewReferenceDiagnostics (ADR-0047)', () => { it('supports array-shaped field definitions', () => { const result = computeViewReferenceDiagnostics({ - filterableFields: ['priority', 'missing'], + userFilters: { element: 'dropdown', fields: [{ field: 'priority' }, { field: 'missing' }] }, }, { fields: [{ name: 'priority', type: 'select' }] }); expect(result.valid).toBe(false); expect(result.errors).toHaveLength(1); - expect(result.errors?.[0].path).toBe('filterableFields.1'); + expect(result.errors?.[0].path).toBe('userFilters.fields.1.field'); }); it('is permissive when the view has no filter surface', () => { diff --git a/packages/spec/src/ui/view-list-filterable-fields-retirement.test.ts b/packages/spec/src/ui/view-list-filterable-fields-retirement.test.ts index e352d006407..1b509afdfba 100644 --- a/packages/spec/src/ui/view-list-filterable-fields-retirement.test.ts +++ b/packages/spec/src/ui/view-list-filterable-fields-retirement.test.ts @@ -358,6 +358,9 @@ describe('tree-scoped absence: no list-view payload inside the declared radius s 'packages/spec/src/ui/view.zod.ts', // This pin authors the key at every door to assert the refusal. THIS_FILE, + // The `os lint` twin of that pin: `os lint` never parses, so the lint + // rule reports the key by name, and this file authors it to assert that. + 'packages/lint/src/list-view-filterable-fields-retired.test.ts', ]); const EXCLUDED_PREFIXES = [ // The D2 conversion's fixture authors the pre-retirement payload on purpose. @@ -369,23 +372,16 @@ describe('tree-scoped absence: no list-view payload inside the declared radius s 'packages/spec/liveness/', ]; /** - * RESIDUE, declared and self-expiring — not exempted. Outside this - * retirement's file surface, and reported as its follow-up: the author-time - * reference walks that still classify the key off RAW input — the lint - * list-view field-ref rule (its severity and role maps, and its fixtures), - * the ADR-0106 field-security scrub's list-view name lists in - * `@objectstack/metadata-core`, and the fixtures of - * `computeViewReferenceDiagnostics` (`@objectstack/metadata-protocol`) in - * `@objectstack/objectql`. Each entry is asserted to STILL hold an offender, - * so the day the follow-up deletes a key this set goes red and the entry - * leaves with it. + * RESIDUE, declared and self-expiring — not exempted. Empty: the follow-up + * deleted every author-time reference walk that still classified the key + * off RAW input (the lint list-view field-ref rule's severity and role maps + * and its fixtures, the ADR-0106 field-security scrub's list-view name + * lists in `@objectstack/metadata-core`, and the fixtures of + * `computeViewReferenceDiagnostics` in `@objectstack/objectql`), and `os + * lint` now reports the key by name. An entry added here is asserted to + * STILL hold an offender, so it leaves the day its key does. */ - const RESIDUE = new Set([ - 'packages/lint/src/validate-list-view-field-refs.test.ts', - 'packages/lint/src/validate-list-view-field-refs.ts', - 'packages/metadata-core/src/object-schema-fls-references.ts', - 'packages/objectql/src/metadata-diagnostics.test.ts', - ]); + const RESIDUE = new Set([]); /** tsup's own bundle of `tsup.config.ts`, written and deleted mid-build. */ const TSUP_BUNDLED_CONFIG = /\.bundled_[^./]+\.mjs$/;