Skip to content

Commit e695fe0

Browse files
os-zhuangclaude
andauthored
feat(spec,lint): reject userFilters on object list views (ADR-0053 phase 4) (#2583)
* feat(spec,lint): reject userFilters on object list views (ADR-0053 phase 4) ADR-0053 reserves userFilters/quickFilters for page lists ("filters" mode); on an object list view ("views" mode, where the ViewTabBar is the only nav control) they are silently dropped. Land the phase-4 guardrail as a layered defence so the wrong-context authoring mistake is caught without breaking existing metadata: - Type (author time): new ObjectListViewSchema = ListViewSchema minus userFilters. Object built-in listViews + defineView list/listViews use it, so userFilters on an object list view is a tsc error. Full ListViewSchema (page filters mode) untouched. - Runtime (back-compat): field STRIPPED at parse (default strip, no throw) — existing metadata keeps loading; ObjectSchema.parse never fails on a stray userFilters. - Author/CI (actionable): new @objectstack/lint validateListViewMode, wired into `os validate`, reports the wrong-context field PRE-parse (before the schema strips it) with a fix hint. Verified: spec 6673 tests + lint 122 tests green, turbo build (54 pkgs, tsc typecheck) green, e2e normalize+lint chain catches both array/map forms. Closes the schema half of objectui #2219; supersedes the interim runtime warn in objectui #2220. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(spec): approve ObjectListViewSchema in public API surface New export from the ADR-0053 phase-4 guardrail; the api-surface check flagged it as 1 added / 0 breaking. Regenerated the snapshot via `pnpm --filter @objectstack/spec gen:api-surface`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e3498fb commit e695fe0

9 files changed

Lines changed: 311 additions & 6 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/lint": minor
4+
"@objectstack/cli": minor
5+
---
6+
7+
feat(spec,lint): reject userFilters on object list views (ADR-0053 phase 4)
8+
9+
ADR-0053 reserves `userFilters`/`quickFilters` for page lists ("filters" mode);
10+
on an object list view ("views" mode — where the `ViewTabBar` is the only nav
11+
control) they are silently dropped. This lands the phase-4 guardrail as a
12+
layered defence, so the wrong-context authoring mistake is caught without
13+
breaking existing metadata:
14+
15+
- **Type-level (author time):** new `ObjectListViewSchema` = `ListViewSchema`
16+
minus `userFilters`. Object built-in `listViews` and `defineView`
17+
`list`/`listViews` now use it, so `userFilters` on an object list view is a
18+
`tsc` error. The full `ListViewSchema` (page "filters" mode) is untouched.
19+
- **Runtime (back-compat):** the field is STRIPPED at parse (default strip, no
20+
throw), so existing metadata keeps loading — `ObjectSchema.parse` never fails
21+
on a stray `userFilters`.
22+
- **Author/CI (actionable):** new `@objectstack/lint` rule
23+
`validateListViewMode`, wired into `os validate`, reports the wrong-context
24+
field PRE-parse (before the schema strips it) with a fix hint.
25+
26+
Closes the schema half of objectui #2219; supersedes the interim runtime warn in
27+
objectui #2220.

packages/cli/src/commands/validate.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { ZodError } from 'zod';
99
import { ObjectStackDefinitionSchema, normalizeStackInput } from '@objectstack/spec';
1010
import { loadConfig } from '../utils/config.js';
1111
import { validateStackExpressions } from '@objectstack/lint';
12+
import { validateListViewMode } from '@objectstack/lint';
1213
import { validateWidgetBindings } from '@objectstack/lint';
1314
import { validateResponsiveStyles } from '@objectstack/lint';
1415
import { validateJsxPages, validateReactPages, validateReactPageProps, validatePageSourceStyling } from '@objectstack/lint';
@@ -108,6 +109,35 @@ export default class Validate extends Command {
108109
this.exit(1);
109110
}
110111

112+
// 2c. ADR-0053 list-view navigation modes — `userFilters`/`quickFilters`
113+
// on an object list view ("views" mode) are silently dropped: the
114+
// object-list schema (ObjectListViewSchema) OMITS them, so this is
115+
// checked on `normalized` (PRE-parse) — `result.data` has already had
116+
// the field stripped. They belong to a page list ("filters" mode).
117+
// See objectui #2219 and ADR-0053 phase 4.
118+
if (!flags.json) printStep('Checking list-view navigation modes (ADR-0053)...');
119+
const listViewFindings = validateListViewMode(normalized as Record<string, unknown>);
120+
const listViewErrors = listViewFindings.filter((f) => f.severity === 'error');
121+
122+
if (listViewErrors.length > 0) {
123+
if (flags.json) {
124+
console.log(JSON.stringify({
125+
valid: false,
126+
errors: listViewErrors,
127+
duration: timer.elapsed(),
128+
}, null, 2));
129+
this.exit(1);
130+
}
131+
console.log('');
132+
printError(`List-view mode check failed (${listViewErrors.length} issue${listViewErrors.length > 1 ? 's' : ''})`);
133+
for (const f of listViewErrors.slice(0, 50)) {
134+
console.log(` • ${f.where}: ${f.message}`);
135+
console.log(chalk.dim(` ${f.hint}`));
136+
console.log(chalk.dim(` rule: ${f.rule} at ${f.path}`));
137+
}
138+
this.exit(1);
139+
}
140+
111141
// 3. Dashboard widget reference integrity (issue #1721) — a semantic
112142
// cross-reference pass the protocol schema cannot express: every
113143
// widget's `dataset`/`dimensions`/`values` and chartConfig

packages/lint/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ export type { WidgetBindingFinding, WidgetBindingSeverity } from './validate-wid
2626
export { validateStackExpressions } from './validate-expressions.js';
2727
export type { ExprIssue } from './validate-expressions.js';
2828

29+
export { validateListViewMode, LIST_VIEW_FILTERS_IN_VIEWS_MODE } from './validate-list-view-mode.js';
30+
export type { ListViewModeFinding, ListViewModeSeverity } from './validate-list-view-mode.js';
31+
2932
export {
3033
validateResponsiveStyles,
3134
STYLE_NODE_MISSING_ID,
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { validateListViewMode, LIST_VIEW_FILTERS_IN_VIEWS_MODE } from './validate-list-view-mode.js';
3+
4+
describe('validateListViewMode (ADR-0053 views-mode guardrail)', () => {
5+
it('passes a clean stack — object listViews without page-only filters', () => {
6+
const findings = validateListViewMode({
7+
objects: [
8+
{ name: 'task', listViews: { my_pending: { label: 'My Pending', filter: [] } } },
9+
],
10+
});
11+
expect(findings).toHaveLength(0);
12+
});
13+
14+
it('flags userFilters on an object built-in list view, with location + hint', () => {
15+
const findings = validateListViewMode({
16+
objects: [
17+
{ name: 'task', listViews: { tabular: { label: 'Tabular', userFilters: { element: 'dropdown' } } } },
18+
],
19+
});
20+
expect(findings).toHaveLength(1);
21+
expect(findings[0]).toMatchObject({
22+
severity: 'error',
23+
rule: LIST_VIEW_FILTERS_IN_VIEWS_MODE,
24+
path: 'objects[0].listViews.tabular.userFilters',
25+
});
26+
expect(findings[0].where).toContain('task');
27+
expect(findings[0].message).toContain('views');
28+
expect(findings[0].hint).toContain('filters');
29+
});
30+
31+
it('flags quickFilters too', () => {
32+
const findings = validateListViewMode({
33+
objects: [
34+
{ name: 'task', listViews: { all: { label: 'All', quickFilters: [{ field: 'status' }] } } },
35+
],
36+
});
37+
expect(findings).toHaveLength(1);
38+
expect(findings[0].path).toBe('objects[0].listViews.all.quickFilters');
39+
});
40+
41+
it('flags userFilters on a defineView default list AND named listViews', () => {
42+
const findings = validateListViewMode({
43+
views: [
44+
{
45+
objectName: 'task',
46+
list: { userFilters: { element: 'tabs' } },
47+
listViews: { mine: { label: 'Mine', userFilters: { element: 'dropdown' } } },
48+
},
49+
],
50+
});
51+
expect(findings).toHaveLength(2);
52+
const paths = findings.map((f) => f.path).sort();
53+
expect(paths).toEqual(['views[0].list.userFilters', 'views[0].listViews.mine.userFilters']);
54+
expect(findings.every((f) => f.where.includes('task'))).toBe(true);
55+
});
56+
57+
it('handles the name-keyed map form of objects', () => {
58+
const findings = validateListViewMode({
59+
objects: { task: { listViews: { t: { userFilters: { element: 'dropdown' } } } } },
60+
});
61+
expect(findings).toHaveLength(1);
62+
expect(findings[0].where).toContain('task');
63+
});
64+
65+
it('stays silent on an empty stack', () => {
66+
expect(validateListViewMode({})).toHaveLength(0);
67+
});
68+
});
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// Build-time guardrail for ADR-0053 list-view navigation modes.
4+
//
5+
// A pure `(stack) => Finding[]` rule (ADR-0019), run from `os validate` and
6+
// reusable by AI authoring. It catches the "wrong context" authoring mistake
7+
// the type system alone cannot surface at author time: `userFilters` /
8+
// `quickFilters` placed on an object list view ("views" mode — where the
9+
// ViewTabBar is the only nav control), where they are silently dropped. Those
10+
// controls belong to a page list (InterfaceListPage, "filters" mode) only.
11+
//
12+
// Runs PRE-parse (on the normalizeStackInput output, before the
13+
// ObjectStackDefinition parse): the object-list schema (ObjectListViewSchema)
14+
// OMITS `userFilters`, so a post-parse stack has already had the field
15+
// stripped and this rule would never see it. The layering is deliberate —
16+
// tsc rejects it at author time, the schema strips it at runtime (no throw,
17+
// back-compat), and this rule reports it at `os validate` with a fix hint.
18+
// See objectui #2219 / #2220 and ADR-0053 phase 4.
19+
20+
export type ListViewModeSeverity = 'error' | 'warning';
21+
22+
export interface ListViewModeFinding {
23+
severity: ListViewModeSeverity;
24+
rule: string;
25+
/** Human-readable location, e.g. `object "task" › listViews.my_pending`. */
26+
where: string;
27+
/** Config path, e.g. `objects[0].listViews.my_pending.userFilters`. */
28+
path: string;
29+
message: string;
30+
hint: string;
31+
}
32+
33+
// Rule id (registry entry).
34+
export const LIST_VIEW_FILTERS_IN_VIEWS_MODE = 'list-view-filters-in-views-mode';
35+
36+
type AnyRec = Record<string, unknown>;
37+
38+
/** Page filters-mode controls that must not appear on an object list view. */
39+
const FORBIDDEN_FIELDS = ['userFilters', 'quickFilters'] as const;
40+
41+
/** Coerce an array-or-name-keyed-map collection to an array (name injected). */
42+
function asArray(v: unknown): AnyRec[] {
43+
if (Array.isArray(v)) return v as AnyRec[];
44+
if (v && typeof v === 'object') {
45+
return Object.entries(v as AnyRec).map(([name, def]) => ({
46+
name,
47+
...(def as AnyRec),
48+
}));
49+
}
50+
return [];
51+
}
52+
53+
/** Emit a finding for each forbidden field present on a single list-view def. */
54+
function scanView(
55+
view: unknown,
56+
where: string,
57+
path: string,
58+
out: ListViewModeFinding[],
59+
): void {
60+
if (!view || typeof view !== 'object') return;
61+
const rec = view as AnyRec;
62+
for (const field of FORBIDDEN_FIELDS) {
63+
if (rec[field] == null) continue;
64+
out.push({
65+
severity: 'error',
66+
rule: LIST_VIEW_FILTERS_IN_VIEWS_MODE,
67+
where,
68+
path: `${path}.${field}`,
69+
message:
70+
`\`${field}\` is a page filters-mode control and is ignored on an object ` +
71+
`list view ("views" mode) — the ViewTabBar is the only nav control here.`,
72+
hint:
73+
`Move \`${field}\` to a page list (InterfaceListPage, "filters" mode), or ` +
74+
`remove it. See ADR-0053.`,
75+
});
76+
}
77+
}
78+
79+
/** Scan a `listViews` record (name → list-view def). */
80+
function scanListViews(
81+
listViews: unknown,
82+
wherePrefix: string,
83+
pathPrefix: string,
84+
out: ListViewModeFinding[],
85+
): void {
86+
if (!listViews || typeof listViews !== 'object') return;
87+
for (const [name, view] of Object.entries(listViews as AnyRec)) {
88+
scanView(
89+
view,
90+
`${wherePrefix} › listViews.${name}`,
91+
`${pathPrefix}.listViews.${name}`,
92+
out,
93+
);
94+
}
95+
}
96+
97+
/**
98+
* Flag ADR-0053 "views" mode violations: `userFilters` / `quickFilters` on an
99+
* object's built-in named views or a `defineView` default `list` / named
100+
* `listViews`. Returns the list of findings (empty = clean). Caller decides how
101+
* to surface / whether to fail the build.
102+
*
103+
* Feed the PRE-parse stack (normalizeStackInput output) — see file header.
104+
*/
105+
export function validateListViewMode(stack: AnyRec): ListViewModeFinding[] {
106+
const out: ListViewModeFinding[] = [];
107+
108+
// Object built-in named views (object.zod.ts `listViews`).
109+
asArray(stack.objects).forEach((obj, i) => {
110+
const label = typeof obj.name === 'string' ? `object "${obj.name}"` : `objects[${i}]`;
111+
scanListViews(obj.listViews, label, `objects[${i}]`, out);
112+
});
113+
114+
// `defineView` aggregates (stack `views`: default `list` + named `listViews`).
115+
asArray(stack.views).forEach((view, i) => {
116+
const named =
117+
typeof view.objectName === 'string'
118+
? view.objectName
119+
: typeof view.name === 'string'
120+
? view.name
121+
: undefined;
122+
const label = named ? `view "${named}"` : `views[${i}]`;
123+
scanView(view.list, `${label} › list`, `views[${i}].list`, out);
124+
scanListViews(view.listViews, label, `views[${i}]`, out);
125+
});
126+
127+
return out;
128+
}

packages/spec/api-surface.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3232,6 +3232,7 @@
32323232
"NotificationTypeSchema (const)",
32333233
"NumberFormat (type)",
32343234
"NumberFormatSchema (const)",
3235+
"ObjectListViewSchema (const)",
32353236
"ObjectNavItem (type)",
32363237
"ObjectNavItemSchema (const)",
32373238
"OfflineCacheConfig (type)",

packages/spec/src/data/object.zod.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { z } from 'zod';
44
import { FieldSchema } from './field.zod';
55
import { ValidationRuleSchema } from './validation.zod';
66
import { ActionSchema } from '../ui/action.zod';
7-
import { ListViewSchema } from '../ui/view.zod';
7+
import { ObjectListViewSchema } from '../ui/view.zod';
88

99
/**
1010
* API Operations Enum
@@ -629,8 +629,10 @@ const ObjectSchemaBase = z.object({
629629
* business context — e.g. an approval-request list should ship with
630630
* "My pending", "I submitted", "Completed" tabs out of the box.
631631
*
632-
* Each value is a `ListViewSchema` (see `@objectstack/spec/ui`) so authors
633-
* get the full tab/filter/sort/grouping vocabulary.
632+
* Each value is an `ObjectListViewSchema` (a `ListViewSchema` minus the
633+
* page-only `userFilters` — ADR-0053 "views" mode, where the `ViewTabBar` is
634+
* the only nav control) so authors get the full tab/filter/sort/grouping
635+
* vocabulary without the wrong-context filter bar.
634636
*
635637
* @example
636638
* ```ts
@@ -644,7 +646,7 @@ const ObjectSchemaBase = z.object({
644646
* }
645647
* ```
646648
*/
647-
listViews: z.record(z.string(), ListViewSchema).optional().describe('Built-in named list views (segmented tabs) shipped with the object schema'),
649+
listViews: z.record(z.string(), ObjectListViewSchema).optional().describe('Built-in named list views (segmented tabs) shipped with the object schema — "views" mode, no page-only userFilters (ADR-0053)'),
648650

649651
/**
650652
* Search Engine Config
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { ObjectListViewSchema, ListViewSchema } from './view.zod';
3+
4+
/**
5+
* ADR-0053 phase 4 — the object list view ("views" mode) must not carry the
6+
* page-only `userFilters` control. The guardrail is layered: the field is
7+
* OMITTED from ObjectListViewSchema (untypable at author time), STRIPPED at
8+
* parse (no throw — runtime back-compat), while the full ListViewSchema used by
9+
* page lists ("filters" mode) still accepts it. See objectui #2219 / #2220.
10+
*/
11+
describe('ObjectListViewSchema (ADR-0053 "views" mode)', () => {
12+
const base = { columns: ['name'] };
13+
14+
it('omits userFilters from its shape (untypable at author time)', () => {
15+
expect('userFilters' in (ObjectListViewSchema as unknown as { shape: Record<string, unknown> }).shape).toBe(false);
16+
});
17+
18+
it('strips an authored userFilters at parse instead of throwing (runtime back-compat)', () => {
19+
const parsed = ObjectListViewSchema.parse({ ...base, userFilters: { element: 'dropdown' } } as never);
20+
expect(parsed).not.toHaveProperty('userFilters');
21+
expect((parsed as { columns: string[] }).columns).toEqual(['name']); // sibling survives
22+
});
23+
24+
it('accepts a clean object list view unchanged', () => {
25+
const parsed = ObjectListViewSchema.parse({ ...base, label: 'All' } as never);
26+
expect((parsed as { label?: string }).label).toBe('All');
27+
});
28+
29+
it('ListViewSchema (page "filters" mode) still accepts userFilters', () => {
30+
const parsed = ListViewSchema.parse({ ...base, userFilters: { element: 'dropdown' } } as never);
31+
expect((parsed as { userFilters?: unknown }).userFilters).toEqual({ element: 'dropdown' });
32+
});
33+
});

packages/spec/src/ui/view.zod.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -890,6 +890,19 @@ export const FormViewSchema = lazySchema(() => z.object({
890890
aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes for the form view'),
891891
}));
892892

893+
/**
894+
* ADR-0053 "views" mode — an object's default list + named list views.
895+
*
896+
* Structurally a {@link ListViewSchema} MINUS the page-only control `userFilters`:
897+
* that field belongs to a page list (`InterfaceListPage`, "filters" mode), never an
898+
* object list view, whose only nav control is the `ViewTabBar`. Omitting the field
899+
* makes the wrong-context state untypable at author time (tsc). Runtime parse still
900+
* STRIPS an authored `userFilters` silently (default strip, no throw) for back-compat,
901+
* and the CLI `validate` list-view-mode rule reports it pre-parse. See objectui #2219
902+
* and ADR-0053 phase 4.
903+
*/
904+
export const ObjectListViewSchema = lazySchema(() => ListViewSchema.omit({ userFilters: true }));
905+
893906
/**
894907
* Master View Schema
895908
* Can define multiple named views.
@@ -909,9 +922,9 @@ export const FormViewSchema = lazySchema(() => z.object({
909922
* }
910923
*/
911924
export const ViewSchema = lazySchema(() => z.object({
912-
list: ListViewSchema.optional(), // Default list view
925+
list: ObjectListViewSchema.optional(), // Default list view (views mode — no userFilters, ADR-0053)
913926
form: FormViewSchema.optional(), // Default form view
914-
listViews: z.record(z.string(), ListViewSchema).optional().describe('Additional named list views'),
927+
listViews: z.record(z.string(), ObjectListViewSchema).optional().describe('Additional named list views (views mode — no userFilters, ADR-0053)'),
915928
formViews: z.record(z.string(), FormViewSchema).optional().describe('Additional named form views'),
916929
/**
917930
* ADR-0010 §3.7 — Package-level protection envelope. Package

0 commit comments

Comments
 (0)