From 0495d6b5e991e260ac8684fea85d4fd6944d1d58 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Sat, 4 Jul 2026 22:56:47 +0800 Subject: [PATCH 1/2] feat(spec,lint): reject userFilters on object list views (ADR-0053 phase 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../adr-0053-list-view-mode-guardrail.md | 27 ++++ packages/cli/src/commands/validate.ts | 30 ++++ packages/lint/src/index.ts | 3 + .../lint/src/validate-list-view-mode.test.ts | 68 ++++++++++ packages/lint/src/validate-list-view-mode.ts | 128 ++++++++++++++++++ packages/spec/src/data/object.zod.ts | 10 +- packages/spec/src/ui/object-list-view.test.ts | 33 +++++ packages/spec/src/ui/view.zod.ts | 17 ++- 8 files changed, 310 insertions(+), 6 deletions(-) create mode 100644 .changeset/adr-0053-list-view-mode-guardrail.md create mode 100644 packages/lint/src/validate-list-view-mode.test.ts create mode 100644 packages/lint/src/validate-list-view-mode.ts create mode 100644 packages/spec/src/ui/object-list-view.test.ts diff --git a/.changeset/adr-0053-list-view-mode-guardrail.md b/.changeset/adr-0053-list-view-mode-guardrail.md new file mode 100644 index 0000000000..c6793ad8e7 --- /dev/null +++ b/.changeset/adr-0053-list-view-mode-guardrail.md @@ -0,0 +1,27 @@ +--- +"@objectstack/spec": minor +"@objectstack/lint": minor +"@objectstack/cli": minor +--- + +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. This lands the phase-4 guardrail as a +layered defence, so the wrong-context authoring mistake is caught without +breaking existing metadata: + +- **Type-level (author time):** new `ObjectListViewSchema` = `ListViewSchema` + minus `userFilters`. Object built-in `listViews` and `defineView` + `list`/`listViews` now use it, so `userFilters` on an object list view is a + `tsc` error. The full `ListViewSchema` (page "filters" mode) is untouched. +- **Runtime (back-compat):** the field is STRIPPED at parse (default strip, no + throw), so existing metadata keeps loading — `ObjectSchema.parse` never fails + on a stray `userFilters`. +- **Author/CI (actionable):** new `@objectstack/lint` rule + `validateListViewMode`, wired into `os validate`, reports the wrong-context + field PRE-parse (before the schema strips it) with a fix hint. + +Closes the schema half of objectui #2219; supersedes the interim runtime warn in +objectui #2220. diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index 66d1711fdd..6b084c4a11 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -9,6 +9,7 @@ import { ZodError } from 'zod'; import { ObjectStackDefinitionSchema, normalizeStackInput } from '@objectstack/spec'; import { loadConfig } from '../utils/config.js'; import { validateStackExpressions } from '@objectstack/lint'; +import { validateListViewMode } from '@objectstack/lint'; import { validateWidgetBindings } from '@objectstack/lint'; import { validateResponsiveStyles } from '@objectstack/lint'; import { validateJsxPages, validateReactPages, validateReactPageProps, validatePageSourceStyling } from '@objectstack/lint'; @@ -108,6 +109,35 @@ export default class Validate extends Command { this.exit(1); } + // 2c. ADR-0053 list-view navigation modes — `userFilters`/`quickFilters` + // on an object list view ("views" mode) are silently dropped: the + // object-list schema (ObjectListViewSchema) OMITS them, so this is + // checked on `normalized` (PRE-parse) — `result.data` has already had + // the field stripped. They belong to a page list ("filters" mode). + // See objectui #2219 and ADR-0053 phase 4. + if (!flags.json) printStep('Checking list-view navigation modes (ADR-0053)...'); + const listViewFindings = validateListViewMode(normalized as Record); + const listViewErrors = listViewFindings.filter((f) => f.severity === 'error'); + + if (listViewErrors.length > 0) { + if (flags.json) { + console.log(JSON.stringify({ + valid: false, + errors: listViewErrors, + duration: timer.elapsed(), + }, null, 2)); + this.exit(1); + } + console.log(''); + printError(`List-view mode check failed (${listViewErrors.length} issue${listViewErrors.length > 1 ? 's' : ''})`); + for (const f of listViewErrors.slice(0, 50)) { + console.log(` • ${f.where}: ${f.message}`); + console.log(chalk.dim(` ${f.hint}`)); + console.log(chalk.dim(` rule: ${f.rule} at ${f.path}`)); + } + this.exit(1); + } + // 3. Dashboard widget reference integrity (issue #1721) — a semantic // cross-reference pass the protocol schema cannot express: every // widget's `dataset`/`dimensions`/`values` and chartConfig diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index bf9226ab1f..14edb10f91 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -26,6 +26,9 @@ export type { WidgetBindingFinding, WidgetBindingSeverity } from './validate-wid export { validateStackExpressions } from './validate-expressions.js'; export type { ExprIssue } from './validate-expressions.js'; +export { validateListViewMode, LIST_VIEW_FILTERS_IN_VIEWS_MODE } from './validate-list-view-mode.js'; +export type { ListViewModeFinding, ListViewModeSeverity } from './validate-list-view-mode.js'; + export { validateResponsiveStyles, STYLE_NODE_MISSING_ID, diff --git a/packages/lint/src/validate-list-view-mode.test.ts b/packages/lint/src/validate-list-view-mode.test.ts new file mode 100644 index 0000000000..419573b2cc --- /dev/null +++ b/packages/lint/src/validate-list-view-mode.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest'; +import { validateListViewMode, LIST_VIEW_FILTERS_IN_VIEWS_MODE } from './validate-list-view-mode.js'; + +describe('validateListViewMode (ADR-0053 views-mode guardrail)', () => { + it('passes a clean stack — object listViews without page-only filters', () => { + const findings = validateListViewMode({ + objects: [ + { name: 'task', listViews: { my_pending: { label: 'My Pending', filter: [] } } }, + ], + }); + expect(findings).toHaveLength(0); + }); + + it('flags userFilters on an object built-in list view, with location + hint', () => { + const findings = validateListViewMode({ + objects: [ + { name: 'task', listViews: { tabular: { label: 'Tabular', userFilters: { element: 'dropdown' } } } }, + ], + }); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + severity: 'error', + rule: LIST_VIEW_FILTERS_IN_VIEWS_MODE, + path: 'objects[0].listViews.tabular.userFilters', + }); + expect(findings[0].where).toContain('task'); + expect(findings[0].message).toContain('views'); + expect(findings[0].hint).toContain('filters'); + }); + + it('flags quickFilters too', () => { + const findings = validateListViewMode({ + objects: [ + { name: 'task', listViews: { all: { label: 'All', quickFilters: [{ field: 'status' }] } } }, + ], + }); + expect(findings).toHaveLength(1); + expect(findings[0].path).toBe('objects[0].listViews.all.quickFilters'); + }); + + it('flags userFilters on a defineView default list AND named listViews', () => { + const findings = validateListViewMode({ + views: [ + { + objectName: 'task', + list: { userFilters: { element: 'tabs' } }, + listViews: { mine: { label: 'Mine', userFilters: { element: 'dropdown' } } }, + }, + ], + }); + expect(findings).toHaveLength(2); + const paths = findings.map((f) => f.path).sort(); + expect(paths).toEqual(['views[0].list.userFilters', 'views[0].listViews.mine.userFilters']); + expect(findings.every((f) => f.where.includes('task'))).toBe(true); + }); + + it('handles the name-keyed map form of objects', () => { + const findings = validateListViewMode({ + objects: { task: { listViews: { t: { userFilters: { element: 'dropdown' } } } } }, + }); + expect(findings).toHaveLength(1); + expect(findings[0].where).toContain('task'); + }); + + it('stays silent on an empty stack', () => { + expect(validateListViewMode({})).toHaveLength(0); + }); +}); diff --git a/packages/lint/src/validate-list-view-mode.ts b/packages/lint/src/validate-list-view-mode.ts new file mode 100644 index 0000000000..7e5907dd5b --- /dev/null +++ b/packages/lint/src/validate-list-view-mode.ts @@ -0,0 +1,128 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Build-time guardrail for ADR-0053 list-view navigation modes. +// +// A pure `(stack) => Finding[]` rule (ADR-0019), run from `os validate` and +// reusable by AI authoring. It catches the "wrong context" authoring mistake +// the type system alone cannot surface at author time: `userFilters` / +// `quickFilters` placed on an object list view ("views" mode — where the +// ViewTabBar is the only nav control), where they are silently dropped. Those +// controls belong to a page list (InterfaceListPage, "filters" mode) only. +// +// Runs PRE-parse (on the normalizeStackInput output, before the +// ObjectStackDefinition parse): the object-list schema (ObjectListViewSchema) +// OMITS `userFilters`, so a post-parse stack has already had the field +// stripped and this rule would never see it. The layering is deliberate — +// tsc rejects it at author time, the schema strips it at runtime (no throw, +// back-compat), and this rule reports it at `os validate` with a fix hint. +// See objectui #2219 / #2220 and ADR-0053 phase 4. + +export type ListViewModeSeverity = 'error' | 'warning'; + +export interface ListViewModeFinding { + severity: ListViewModeSeverity; + rule: string; + /** Human-readable location, e.g. `object "task" › listViews.my_pending`. */ + where: string; + /** Config path, e.g. `objects[0].listViews.my_pending.userFilters`. */ + path: string; + message: string; + hint: string; +} + +// Rule id (registry entry). +export const LIST_VIEW_FILTERS_IN_VIEWS_MODE = 'list-view-filters-in-views-mode'; + +type AnyRec = Record; + +/** Page filters-mode controls that must not appear on an object list view. */ +const FORBIDDEN_FIELDS = ['userFilters', 'quickFilters'] as const; + +/** Coerce an array-or-name-keyed-map collection to an array (name injected). */ +function asArray(v: unknown): AnyRec[] { + if (Array.isArray(v)) return v as AnyRec[]; + if (v && typeof v === 'object') { + return Object.entries(v as AnyRec).map(([name, def]) => ({ + name, + ...(def as AnyRec), + })); + } + return []; +} + +/** Emit a finding for each forbidden field present on a single list-view def. */ +function scanView( + view: unknown, + where: string, + path: string, + out: ListViewModeFinding[], +): void { + if (!view || typeof view !== 'object') return; + const rec = view as AnyRec; + for (const field of FORBIDDEN_FIELDS) { + if (rec[field] == null) continue; + out.push({ + severity: 'error', + rule: LIST_VIEW_FILTERS_IN_VIEWS_MODE, + where, + path: `${path}.${field}`, + message: + `\`${field}\` is a page filters-mode control and is ignored on an object ` + + `list view ("views" mode) — the ViewTabBar is the only nav control here.`, + hint: + `Move \`${field}\` to a page list (InterfaceListPage, "filters" mode), or ` + + `remove it. See ADR-0053.`, + }); + } +} + +/** Scan a `listViews` record (name → list-view def). */ +function scanListViews( + listViews: unknown, + wherePrefix: string, + pathPrefix: string, + out: ListViewModeFinding[], +): void { + if (!listViews || typeof listViews !== 'object') return; + for (const [name, view] of Object.entries(listViews as AnyRec)) { + scanView( + view, + `${wherePrefix} › listViews.${name}`, + `${pathPrefix}.listViews.${name}`, + out, + ); + } +} + +/** + * Flag ADR-0053 "views" mode violations: `userFilters` / `quickFilters` on an + * object's built-in named views or a `defineView` default `list` / named + * `listViews`. Returns the list of findings (empty = clean). Caller decides how + * to surface / whether to fail the build. + * + * Feed the PRE-parse stack (normalizeStackInput output) — see file header. + */ +export function validateListViewMode(stack: AnyRec): ListViewModeFinding[] { + const out: ListViewModeFinding[] = []; + + // Object built-in named views (object.zod.ts `listViews`). + asArray(stack.objects).forEach((obj, i) => { + const label = typeof obj.name === 'string' ? `object "${obj.name}"` : `objects[${i}]`; + scanListViews(obj.listViews, label, `objects[${i}]`, out); + }); + + // `defineView` aggregates (stack `views`: default `list` + named `listViews`). + asArray(stack.views).forEach((view, i) => { + const named = + typeof view.objectName === 'string' + ? view.objectName + : typeof view.name === 'string' + ? view.name + : undefined; + const label = named ? `view "${named}"` : `views[${i}]`; + scanView(view.list, `${label} › list`, `views[${i}].list`, out); + scanListViews(view.listViews, label, `views[${i}]`, out); + }); + + return out; +} diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 0c12e9c7d2..4d61915bd6 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -4,7 +4,7 @@ import { z } from 'zod'; import { FieldSchema } from './field.zod'; import { ValidationRuleSchema } from './validation.zod'; import { ActionSchema } from '../ui/action.zod'; -import { ListViewSchema } from '../ui/view.zod'; +import { ObjectListViewSchema } from '../ui/view.zod'; /** * API Operations Enum @@ -629,8 +629,10 @@ const ObjectSchemaBase = z.object({ * business context — e.g. an approval-request list should ship with * "My pending", "I submitted", "Completed" tabs out of the box. * - * Each value is a `ListViewSchema` (see `@objectstack/spec/ui`) so authors - * get the full tab/filter/sort/grouping vocabulary. + * Each value is an `ObjectListViewSchema` (a `ListViewSchema` minus the + * page-only `userFilters` — ADR-0053 "views" mode, where the `ViewTabBar` is + * the only nav control) so authors get the full tab/filter/sort/grouping + * vocabulary without the wrong-context filter bar. * * @example * ```ts @@ -644,7 +646,7 @@ const ObjectSchemaBase = z.object({ * } * ``` */ - listViews: z.record(z.string(), ListViewSchema).optional().describe('Built-in named list views (segmented tabs) shipped with the object schema'), + 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)'), /** * Search Engine Config diff --git a/packages/spec/src/ui/object-list-view.test.ts b/packages/spec/src/ui/object-list-view.test.ts new file mode 100644 index 0000000000..81c2d19fa0 --- /dev/null +++ b/packages/spec/src/ui/object-list-view.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; +import { ObjectListViewSchema, ListViewSchema } from './view.zod'; + +/** + * ADR-0053 phase 4 — the object list view ("views" mode) must not carry the + * page-only `userFilters` control. The guardrail is layered: the field is + * OMITTED from ObjectListViewSchema (untypable at author time), STRIPPED at + * parse (no throw — runtime back-compat), while the full ListViewSchema used by + * page lists ("filters" mode) still accepts it. See objectui #2219 / #2220. + */ +describe('ObjectListViewSchema (ADR-0053 "views" mode)', () => { + const base = { columns: ['name'] }; + + it('omits userFilters from its shape (untypable at author time)', () => { + expect('userFilters' in (ObjectListViewSchema as unknown as { shape: Record }).shape).toBe(false); + }); + + it('strips an authored userFilters at parse instead of throwing (runtime back-compat)', () => { + const parsed = ObjectListViewSchema.parse({ ...base, userFilters: { element: 'dropdown' } } as never); + expect(parsed).not.toHaveProperty('userFilters'); + expect((parsed as { columns: string[] }).columns).toEqual(['name']); // sibling survives + }); + + it('accepts a clean object list view unchanged', () => { + const parsed = ObjectListViewSchema.parse({ ...base, label: 'All' } as never); + expect((parsed as { label?: string }).label).toBe('All'); + }); + + it('ListViewSchema (page "filters" mode) still accepts userFilters', () => { + const parsed = ListViewSchema.parse({ ...base, userFilters: { element: 'dropdown' } } as never); + expect((parsed as { userFilters?: unknown }).userFilters).toEqual({ element: 'dropdown' }); + }); +}); diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 512a0d9030..71823c9d3f 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -890,6 +890,19 @@ export const FormViewSchema = lazySchema(() => z.object({ aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes for the form view'), })); +/** + * ADR-0053 "views" mode — an object's default list + named list views. + * + * Structurally a {@link ListViewSchema} MINUS the page-only control `userFilters`: + * that field belongs to a page list (`InterfaceListPage`, "filters" mode), never an + * object list view, whose only nav control is the `ViewTabBar`. Omitting the field + * makes the wrong-context state untypable at author time (tsc). Runtime parse still + * STRIPS an authored `userFilters` silently (default strip, no throw) for back-compat, + * and the CLI `validate` list-view-mode rule reports it pre-parse. See objectui #2219 + * and ADR-0053 phase 4. + */ +export const ObjectListViewSchema = lazySchema(() => ListViewSchema.omit({ userFilters: true })); + /** * Master View Schema * Can define multiple named views. @@ -909,9 +922,9 @@ export const FormViewSchema = lazySchema(() => z.object({ * } */ export const ViewSchema = lazySchema(() => z.object({ - list: ListViewSchema.optional(), // Default list view + list: ObjectListViewSchema.optional(), // Default list view (views mode — no userFilters, ADR-0053) form: FormViewSchema.optional(), // Default form view - listViews: z.record(z.string(), ListViewSchema).optional().describe('Additional named list views'), + listViews: z.record(z.string(), ObjectListViewSchema).optional().describe('Additional named list views (views mode — no userFilters, ADR-0053)'), formViews: z.record(z.string(), FormViewSchema).optional().describe('Additional named form views'), /** * ADR-0010 §3.7 — Package-level protection envelope. Package From 1e4112fddb313ab2999a41adbc1b2fe8c94a73b2 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Sat, 4 Jul 2026 23:39:43 +0800 Subject: [PATCH 2/2] 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 --- packages/spec/api-surface.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 3bb3f4019e..ba8f3f793a 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -3232,6 +3232,7 @@ "NotificationTypeSchema (const)", "NumberFormat (type)", "NumberFormatSchema (const)", + "ObjectListViewSchema (const)", "ObjectNavItem (type)", "ObjectNavItemSchema (const)", "OfflineCacheConfig (type)",