|
| 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 | +} |
0 commit comments