Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/20301-lint-list-view-tabs-walk-deleted.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@objectstack/lint": patch
---

The list-view field-reference rule no longer walks a list view's own `tabs[].filter`

Clause-②: no

The list view's own `tabs` is a `retiredKey` tombstone on every list-view shape, and this rule judges the parsed stack, so the key could never reach the walk: the parse refuses it first, with its prescription. The dead branch is deleted. The rule still judges `filter` and `userFilters.tabs[].filter` exactly as before.

No finding changes for any stack that `os validate`, `os lint` or `os build` accepts.
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@objectstack/metadata-protocol": patch
---

`computeViewReferenceDiagnostics` no longer walks a list view's own `tabs[].filter`

Clause-②: no

The list view's own `tabs` 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, so the read could never see it. A served body that still carries it is already badged by the spec diagnostics (`computeMetadataDiagnostics`), with the tombstone's prescription. The `userFilters.tabs[].filter`, `filterableFields` and `kanban` checks are unchanged.
15 changes: 15 additions & 0 deletions .changeset/20301-spec-view-container-name-ledger-note.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@objectstack/spec": patch
---

Liveness ledger: the view container's body `name` row stays `dead`, and its note now states what the platform actually does with the key

Clause-②: no

- The old note said the body copy was "a copy nobody reads". Measured, the metadata door stamps the save name into every saved view body that has none, containers included (`normalizeViewMetadata` in `@objectstack/metadata-protocol`). Its overlay paths key on that stamped copy: `hydrateOverlayIntoRegistry` registers no body without a `name`, and `mergePackageAwareOverlay` slots an overlay row by it.
- The verdict is unchanged, because the ledger's `live` means that authoring the key changes runtime behaviour. An authored container `name` only restates the key the container already registers under, or contradicts it. `os validate` and `os lint` keep warning `liveness-dead-property` ("drop it").
- The note records why the key is kept rather than tombstoned: the door's own saves stamp it, so a tombstone would refuse the platform's own writes. A maintainer ruling also refused a spec-level forbid of a container's `name`.
- It corrects the old attribution too. Artifact-shipped containers and the metadata-validation sweep author no `name`; what was read as theirs is the door's stamp.
- The ledger README's `view` cell says the same. The `view.list.tabs` row's note now records that the two author-time walks that still read a list view's own `tabs` are deleted.
- A comment in `system/i18n-resolver.ts` that still called the list view's own `tabs` a live carrier now says the key is a tombstone and `UserFiltersSchema.tabs` is the one carrier.
- ⛔ No schema, parse, export, status or accept-set change.
25 changes: 8 additions & 17 deletions packages/lint/src/validate-list-view-field-refs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,6 @@ const FULL_LIST_VIEW: AnyRec = {
fields: [{ field: 'status' }],
tabs: [{ name: 'open', filter: [{ field: 'status', operator: 'equals', value: 'open' }] }],
},
tabs: [{ name: 'mine', filter: [{ field: 'business_unit', operator: 'equals', value: 'x' }] }],
kanban: { groupByField: 'status', summarizeField: 'estimate', columns: ['title'], titleField: 'title' },
calendar: {
startDateField: 'due_at',
Expand Down Expand Up @@ -241,23 +240,22 @@ function positionAsserted(path: string, walked: ReadonlySet<string>): string | u
}

/**
* [#18836] The rule's three hard-coded filter walks, spelled as
* [#18836] The rule's two hard-coded filter walks, spelled as
* {@link casePath} normalises the paths they report at.
*
* They are open code, not table rows — `checkListView` calls `checkFilter` on
* `listView.filter`, on `tabs[]` and on `userFilters.tabs[]` — so the position
* `listView.filter` and on `userFilters.tabs[]` — so the position
* set derived from the rule cannot reach them, and the completeness assertion
* below would let their rows be deleted in silence. That is precisely the hole
* the floor this card replaced DID cover, by counting rows.
*
* So they are declared here and asserted EXACTLY: delete one of their rows and
* the list comes up short; give a fourth hard-coded walk a row without adding
* the list comes up short; give a third hard-coded walk a row without adding
* it here and the list comes up long. Together with the derived assertion, every
* row in both tables is then accounted for by one criterion or the other.
*/
const HARD_CODED_FILTER_WALKS = [
'filter.field',
'tabs.filter.field',
'userFilters.tabs.filter.field',
];

Expand Down Expand Up @@ -292,11 +290,6 @@ describe('#14107 — every other walked position', () => {
'views[0].list.userFilters.tabs[0].filter[0].field',
'error',
],
[
{ tabs: [{ name: 'a', filter: [{ field: BAD, operator: 'equals', value: 1 }] }] },
'views[0].list.tabs[0].filter[0].field',
'error',
],
[{ kanban: { summarizeField: BAD } }, 'views[0].list.kanban.summarizeField', 'warning'],
[{ kanban: { columns: [BAD] } }, 'views[0].list.kanban.columns[0]', 'warning'],
// [#18565] Calendar's level, not the required siblings' — see the row's
Expand Down Expand Up @@ -426,15 +419,15 @@ describe('#14107 — every other walked position', () => {
// filter walks — nothing else. A set, compared in both directions, which
// holds three things the completeness assertion does not:
//
// - SHORT ⇒ RED. Delete a filter-walk row and the set loses a member. All
// three rows measured, one by one. That is exactly the coverage the
// - SHORT ⇒ RED. Delete a filter-walk row and the set loses a member. Every
// row measured, one by one. That is exactly the coverage the
// row-counting floor had and the derived assertion cannot reach.
// - LONG ⇒ RED, measured two ways. Remove one of the three declarations
// - LONG ⇒ RED, measured two ways. Remove one of the declarations
// below and the rows outnumber them. Leave a row behind for a position the
// rule has DROPPED and it matches neither side, so it arrives here as an
// extra — red here as well as in that row's own per-case assertion, which
// is how this assertion ends up carrying the direction its sibling above
// cannot. A fourth hard-coded walk given a row without being declared
// cannot. A third hard-coded walk given a row without being declared
// below lands in the same place by the same comparison.
it('accounts for every asserted path, as a walked position or a declared filter walk', () => {
const walkedSet = new Set(listViewWalkedPositions());
Expand Down Expand Up @@ -707,11 +700,10 @@ describe('#14282 — a dotted key the FILTER door refuses, and the ones it serve
expect(validateListViewFieldRefs(stackWith(mutate(filterOn('id.x'))))).toEqual([]);
});

it('the tab and user-filter tab presets are judged on the same axis', () => {
it('the user-filter tab presets are judged on the same axis', () => {
const findings = validateListViewFieldRefs(
stackWith(
mutate({
tabs: [{ name: 'mine', filter: [{ field: 'owner.name', operator: 'equals', value: 'x' }] }],
userFilters: {
fields: [{ field: 'status' }],
tabs: [{ name: 'open', filter: [{ field: 'parent.title', operator: 'equals', value: 'x' }] }],
Expand All @@ -720,7 +712,6 @@ describe('#14282 — a dotted key the FILTER door refuses, and the ones it serve
),
);
expect(idsOf(findings).sort()).toEqual([
'views[0].list.tabs[0].filter[0].field',
'views[0].list.userFilters.tabs[0].filter[0].field',
]);
expect(findings.every((f) => f.rule === LIST_VIEW_FIELD_DOTTED)).toBe(true);
Expand Down
34 changes: 19 additions & 15 deletions packages/lint/src/validate-list-view-field-refs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,9 +134,9 @@
* `INVALID_FIELD` / 400 (`packages/objectql/src/engine.ts`, #7589), and
* `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 `tabs[].filter`, its
* `userFilters.tabs[].filter`, and the two positions that DECLARE which
* names the end user may filter on (`filterableFields`, spelled by the
* - **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
Expand Down Expand Up @@ -201,9 +201,10 @@
* owned by `validateActionNameRefs`.
* - **`conditionalFormatting[].condition`** — a CEL predicate, owned by the
* expression rules.
* - **`tabs[].view` / `addRecord.formView`** — view names, owned by
* `lintViewRefs`. (`pageName` was here too until #17063 retired the
* `type: 'page'` view mount; a list view carries no page reference now.)
* - **`addRecord.formView`** — a view name, owned by `lintViewRefs`.
* (`pageName` was here too until #17063 retired the `type: 'page'` view
* mount, and `tabs[].view` until the list view's own `tabs` was retired; a
* list view carries neither reference now.)
* - **The `data.object` binding itself** — `validateObjectReferences` owns
* object-name reference sites, with the curated cross-package severity
* ladder a local "not in this stack ⇒ error" would not have. When the bound
Expand Down Expand Up @@ -520,11 +521,11 @@ const COLUMN_ENTRY_POSITIONS: Array<{ block: string; key: string; severity: Sev
* It names positions only — no severity, no shape — so reading it can never
* stand in for reading the tables.
*
* The three filter walks further down (`listView.filter`, `tabs[].filter`,
* The two filter walks further down (`listView.filter`,
* `userFilters.tabs[].filter`) are deliberately absent: they are open code, not
* table rows, so there is nothing HERE to derive them from. ⛔ Absent from this
* list is not absent from the test's account of itself — the test declares those
* three as an explicit list and asserts it exactly, because a row of theirs
* two as an explicit list and asserts it exactly, because a row of theirs
* deleted in silence is the one thing the row-counting floor this replaced did
* cover.
*/
Expand Down Expand Up @@ -768,12 +769,16 @@ export function validateListViewFieldRefs(stack: AnyRec): ListViewFieldRefFindin
}
}

// ── Filter KEYS: the view's own filter, its tabs' filters, and the tab
// presets inside `userFilters`. `walkFilterFieldKeys` handles all three
// authored filter shapes (Mongo condition object, `{ field, operator,
// value }` rules, `[field, op, value]` triples) so a filter authored one
// way is not judged while another is silently skipped (#3574's own
// failure mode).
// ── Filter KEYS: the view's own filter and the tab presets inside
// `userFilters`. `walkFilterFieldKeys` handles all three authored filter
// shapes (Mongo condition object, `{ field, operator, value }` rules,
// `[field, op, value]` triples) so a filter authored one way is not judged
// while another is silently skipped (#3574's own failure mode).
//
// The list view's OWN `tabs` is not walked: it is a `retiredKey` tombstone
// on every list-view shape, and this rule judges the PARSED stack
// (`input: 'parsed'` in the authoring-rule registry), where the key can
// never arrive — the parse refuses it with its prescription first.
const checkFilter = (filter: unknown, filterWhere: string, filterPath: string): void => {
if (filter === undefined || filter === null) return;
walkFilterFieldKeys(filter, filterPath, ({ field, path: at }) => {
Expand All @@ -792,7 +797,6 @@ export function validateListViewFieldRefs(stack: AnyRec): ListViewFieldRefFindin
});
};

checkTabs(listView.tabs, `${where} › tabs`, `${path}.tabs`);
if (isRec(listView.userFilters)) {
checkTabs(
listView.userFilters.tabs,
Expand Down
7 changes: 5 additions & 2 deletions packages/metadata-protocol/src/metadata-diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,8 +228,11 @@ export function computeViewReferenceDiagnostics(
userFilters?.tabs?.forEach((t, i) =>
t?.filter?.forEach((r, j) => requireField(r?.field, `userFilters.tabs.${i}.filter.${j}.field`)));

(view?.tabs as Array<{ filter?: Array<{ field?: string }> }> | undefined)?.forEach((t, i) =>
t?.filter?.forEach((r, j) => requireField(r?.field, `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}`));
Expand Down
7 changes: 3 additions & 4 deletions packages/objectql/src/metadata-diagnostics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ describe('computeViewReferenceDiagnostics (ADR-0047)', () => {
fields: [{ field: 'industry' }, { field: 'is_active' }],
tabs: [{ name: 't', filter: [{ field: 'status', operator: 'equals', value: 'x' }] }],
},
tabs: [{ name: 'a', filter: [{ field: 'industry', operator: 'equals', value: 'technology' }] }],
filterableFields: ['status'],
kanban: { groupByField: 'status', columns: ['name'] },
}, objectDef);
Expand All @@ -48,12 +47,12 @@ describe('computeViewReferenceDiagnostics (ADR-0047)', () => {
});
});

it('flags tab filter rules pointing at unknown fields', () => {
it('flags user-filter tab preset rules pointing at unknown fields', () => {
const result = computeViewReferenceDiagnostics({
tabs: [{ name: 'bad', filter: [{ field: 'ghost', operator: 'equals', value: 1 }] }],
userFilters: { element: 'tabs', tabs: [{ name: 'bad', filter: [{ field: 'ghost', operator: 'equals', value: 1 }] }] },
}, objectDef);
expect(result.valid).toBe(false);
expect(result.errors?.[0].path).toBe('tabs.0.filter.0.field');
expect(result.errors?.[0].path).toBe('userFilters.tabs.0.filter.0.field');
});

it('flags kanban groupBy on a non-select-like field', () => {
Expand Down
Loading
Loading