From c781175287c0fb4dac9969e8350b9de931bee6ef Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Feb 2026 07:17:11 +0000 Subject: [PATCH 1/4] Initial plan From ce0797930e079ab5adf90920be2f8c14b8a41dd9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Feb 2026 07:23:48 +0000 Subject: [PATCH 2/4] Replace z.any() in UI schemas, add cross-reference validation for seed data and navigation, add negative validation tests Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- packages/spec/src/stack.test.ts | 113 +++++++++++++++++++++++++ packages/spec/src/stack.zod.ts | 68 +++++++++++++++ packages/spec/src/ui/component.zod.ts | 5 +- packages/spec/src/ui/dashboard.test.ts | 88 +++++++++++++++++++ packages/spec/src/ui/dashboard.zod.ts | 4 +- packages/spec/src/ui/page.test.ts | 79 +++++++++++++++++ packages/spec/src/ui/page.zod.ts | 7 +- packages/spec/src/ui/report.test.ts | 63 ++++++++++++++ 8 files changed, 420 insertions(+), 7 deletions(-) diff --git a/packages/spec/src/stack.test.ts b/packages/spec/src/stack.test.ts index 8df6380536..3b263f6faf 100644 --- a/packages/spec/src/stack.test.ts +++ b/packages/spec/src/stack.test.ts @@ -847,3 +847,116 @@ describe('defineStack - Map Format Support', () => { expect(result.views![0].list?.type).toBe('grid'); }); }); + +// ============================================================================ +// Negative / Inverse Validation Tests — Cross-Reference +// ============================================================================ + +describe('defineStack - Seed Data Cross-Reference Validation', () => { + const baseManifest = { + id: 'com.example.test', + name: 'test-project', + version: '1.0.0', + type: 'app' as const, + }; + + it('should detect seed data referencing undefined object', () => { + const config = { + manifest: baseManifest, + objects: [ + { name: 'account', fields: { name: { type: 'text' } } }, + ], + data: [ + { object: 'ghost_object', records: [{ name: 'Test' }] }, + ], + }; + expect(() => defineStack(config)).toThrow('ghost_object'); + expect(() => defineStack(config)).toThrow('cross-reference validation failed'); + }); + + it('should pass when seed data references defined object', () => { + const config = { + manifest: baseManifest, + objects: [ + { name: 'account', fields: { name: { type: 'text' } } }, + ], + data: [ + { object: 'account', records: [{ name: 'Acme Corp' }] }, + ], + }; + expect(() => defineStack(config)).not.toThrow(); + }); +}); + +describe('defineStack - Navigation Cross-Reference Validation', () => { + const baseManifest = { + id: 'com.example.test', + name: 'test-project', + version: '1.0.0', + type: 'app' as const, + }; + + it('should detect navigation referencing undefined object', () => { + const config = { + manifest: baseManifest, + objects: [ + { name: 'task', fields: { title: { type: 'text' } } }, + ], + apps: [ + { + name: 'my_app', + label: 'My App', + navigation: [ + { id: 'nav_missing', type: 'object' as const, label: 'Missing', objectName: 'nonexistent_object' }, + ], + }, + ], + }; + expect(() => defineStack(config)).toThrow('nonexistent_object'); + }); + + it('should detect navigation referencing undefined dashboard', () => { + const config = { + manifest: baseManifest, + objects: [ + { name: 'task', fields: { title: { type: 'text' } } }, + ], + dashboards: [ + { name: 'sales_dashboard', label: 'Sales', widgets: [] }, + ], + apps: [ + { + name: 'my_app', + label: 'My App', + navigation: [ + { id: 'nav_ghost', type: 'dashboard' as const, label: 'Missing', dashboardName: 'ghost_dashboard' }, + ], + }, + ], + }; + expect(() => defineStack(config)).toThrow('ghost_dashboard'); + }); + + it('should pass when all navigation references are valid', () => { + const config = { + manifest: baseManifest, + objects: [ + { name: 'task', fields: { title: { type: 'text' } } }, + ], + dashboards: [ + { name: 'task_overview', label: 'Overview', widgets: [] }, + ], + apps: [ + { + name: 'my_app', + label: 'My App', + navigation: [ + { id: 'nav_tasks', type: 'object' as const, label: 'Tasks', objectName: 'task' }, + { id: 'nav_overview', type: 'dashboard' as const, label: 'Overview', dashboardName: 'task_overview' }, + ], + }, + ], + }; + expect(() => defineStack(config)).not.toThrow(); + }); +}); diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index 8e441a283c..c008664342 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -332,6 +332,74 @@ function validateCrossReferences(config: ObjectStackDefinition): string[] { } } + // Validate seed data → object references + if (config.data) { + for (const dataset of config.data) { + if (dataset.object && !objectNames.has(dataset.object)) { + errors.push( + `Seed data references object '${dataset.object}' which is not defined in objects.`, + ); + } + } + } + + // Validate app navigation → object/dashboard/page/report references + if (config.apps) { + const dashboardNames = new Set(); + if (config.dashboards) { + for (const d of config.dashboards) { + dashboardNames.add(d.name); + } + } + const pageNames = new Set(); + if (config.pages) { + for (const p of config.pages) { + pageNames.add(p.name); + } + } + const reportNames = new Set(); + if (config.reports) { + for (const r of config.reports) { + reportNames.add(r.name); + } + } + + for (const app of config.apps) { + if (!app.navigation) continue; + const checkNavItems = (items: unknown[], appName: string) => { + for (const item of items) { + if (!item || typeof item !== 'object') continue; + const nav = item as Record; + if (nav.type === 'object' && typeof nav.objectName === 'string' && !objectNames.has(nav.objectName)) { + errors.push( + `App '${appName}' navigation references object '${nav.objectName}' which is not defined in objects.`, + ); + } + if (nav.type === 'dashboard' && typeof nav.dashboardName === 'string' && dashboardNames.size > 0 && !dashboardNames.has(nav.dashboardName)) { + errors.push( + `App '${appName}' navigation references dashboard '${nav.dashboardName}' which is not defined in dashboards.`, + ); + } + if (nav.type === 'page' && typeof nav.pageName === 'string' && pageNames.size > 0 && !pageNames.has(nav.pageName)) { + errors.push( + `App '${appName}' navigation references page '${nav.pageName}' which is not defined in pages.`, + ); + } + if (nav.type === 'report' && typeof nav.reportName === 'string' && reportNames.size > 0 && !reportNames.has(nav.reportName)) { + errors.push( + `App '${appName}' navigation references report '${nav.reportName}' which is not defined in reports.`, + ); + } + // Recurse into group children + if (nav.type === 'group' && Array.isArray(nav.children)) { + checkNavItems(nav.children, appName); + } + } + }; + checkNavItems(app.navigation, app.name); + } + } + return errors; } diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index f7b6b44e2a..d9d6d25df2 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { z } from 'zod'; +import { FilterConditionSchema } from '../data/filter.zod'; import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod'; import { FeedItemType, FeedFilterMode } from '../data/feed.zod'; @@ -184,7 +185,7 @@ export const ElementNumberPropsSchema = z.object({ field: z.string().optional().describe('Field to aggregate'), aggregate: z.enum(['count', 'sum', 'avg', 'min', 'max']) .describe('Aggregation function'), - filter: z.any().optional().describe('Filter criteria'), + filter: FilterConditionSchema.optional().describe('Filter criteria'), format: z.enum(['number', 'currency', 'percent']).optional().describe('Number display format'), prefix: z.string().optional().describe('Prefix text (e.g. "$")'), suffix: z.string().optional().describe('Suffix text (e.g. "%")'), @@ -247,7 +248,7 @@ export const ElementRecordPickerPropsSchema = z.object({ object: z.string().describe('Object to pick records from'), displayField: z.string().describe('Field to display as the record label'), searchFields: z.array(z.string()).optional().describe('Fields to search against'), - filter: z.any().optional().describe('Filter criteria for available records'), + filter: FilterConditionSchema.optional().describe('Filter criteria for available records'), multiple: z.boolean().optional().default(false).describe('Allow multiple record selection'), targetVariable: z.string().optional().describe('Page variable to bind selected record ID(s)'), placeholder: I18nLabelSchema.optional().describe('Placeholder text'), diff --git a/packages/spec/src/ui/dashboard.test.ts b/packages/spec/src/ui/dashboard.test.ts index 762156ce14..d3ef43e865 100644 --- a/packages/spec/src/ui/dashboard.test.ts +++ b/packages/spec/src/ui/dashboard.test.ts @@ -1597,3 +1597,91 @@ describe('DashboardWidgetSchema - pivot/funnel/grouped-bar types', () => { expect(dashboard.widgets[2].measures).toHaveLength(3); }); }); + +// ============================================================================ +// Negative / Inverse Validation Tests +// ============================================================================ + +describe('DashboardWidgetSchema - Negative Validation', () => { + it('should reject widget without layout', () => { + expect(() => DashboardWidgetSchema.parse({ + title: 'Bad Widget', + type: 'metric', + })).toThrow(); + }); + + it('should reject widget with invalid type enum', () => { + expect(() => DashboardWidgetSchema.parse({ + type: 'nonexistent_chart', + layout: { x: 0, y: 0, w: 4, h: 2 }, + })).toThrow(); + }); + + it('should reject widget with non-numeric layout values', () => { + expect(() => DashboardWidgetSchema.parse({ + type: 'metric', + layout: { x: 'a', y: 0, w: 4, h: 2 }, + })).toThrow(); + }); + + it('should reject widget with incomplete layout', () => { + expect(() => DashboardWidgetSchema.parse({ + type: 'metric', + layout: { x: 0, y: 0 }, + })).toThrow(); + }); + + it('should reject widget with invalid aggregate enum', () => { + expect(() => DashboardWidgetSchema.parse({ + type: 'metric', + aggregate: 'median', + layout: { x: 0, y: 0, w: 4, h: 2 }, + })).toThrow(); + }); +}); + +describe('DashboardSchema - Negative Validation', () => { + it('should reject dashboard without name', () => { + expect(() => DashboardSchema.parse({ + label: 'No Name Dashboard', + widgets: [], + })).toThrow(); + }); + + it('should reject dashboard without label', () => { + expect(() => DashboardSchema.parse({ + name: 'no_label', + widgets: [], + })).toThrow(); + }); + + it('should reject dashboard without widgets', () => { + expect(() => DashboardSchema.parse({ + name: 'no_widgets', + label: 'Missing Widgets', + })).toThrow(); + }); + + it('should reject dashboard with camelCase name', () => { + expect(() => DashboardSchema.parse({ + name: 'salesDashboard', + label: 'Bad Name', + widgets: [], + })).toThrow(); + }); +}); + +describe('GlobalFilterSchema - Negative Validation', () => { + it('should reject filter without field', () => { + expect(() => GlobalFilterSchema.parse({ + label: 'No Field', + })).toThrow(); + }); + + it('should reject filter with invalid type enum', () => { + expect(() => GlobalFilterSchema.parse({ + field: 'status', + type: 'invalid_type', + })).toThrow(); + }); +}); diff --git a/packages/spec/src/ui/dashboard.zod.ts b/packages/spec/src/ui/dashboard.zod.ts index a6d6462a6c..62865846b6 100644 --- a/packages/spec/src/ui/dashboard.zod.ts +++ b/packages/spec/src/ui/dashboard.zod.ts @@ -186,7 +186,7 @@ export const GlobalFilterSchema = z.object({ /** Static options for select/lookup filters */ options: z.array(z.object({ - value: z.any(), + value: z.union([z.string(), z.number(), z.boolean()]).describe('Option value'), label: I18nLabelSchema, })).optional().describe('Static filter options'), @@ -194,7 +194,7 @@ export const GlobalFilterSchema = z.object({ optionsFrom: GlobalFilterOptionsFromSchema.optional().describe('Dynamic filter options from object'), /** Default filter value */ - defaultValue: z.any().optional().describe('Default filter value'), + defaultValue: z.union([z.string(), z.number(), z.boolean()]).optional().describe('Default filter value'), /** Filter application scope */ scope: z.enum(['dashboard', 'widget']).default('dashboard').describe('Filter application scope'), diff --git a/packages/spec/src/ui/page.test.ts b/packages/spec/src/ui/page.test.ts index 4ceba7e954..9f0fe44485 100644 --- a/packages/spec/src/ui/page.test.ts +++ b/packages/spec/src/ui/page.test.ts @@ -1095,3 +1095,82 @@ describe('PageSchema with interfaceConfig', () => { expect(page.interfaceConfig?.allowPrinting).toBe(false); }); }); + +// ============================================================================ +// Negative / Inverse Validation Tests +// ============================================================================ + +describe('PageSchema - Negative Validation', () => { + it('should reject page without name', () => { + expect(() => PageSchema.parse({ + label: 'No Name Page', + regions: [], + })).toThrow(); + }); + + it('should reject page without label', () => { + expect(() => PageSchema.parse({ + name: 'no_label', + regions: [], + })).toThrow(); + }); + + it('should reject page without regions', () => { + expect(() => PageSchema.parse({ + name: 'no_regions', + label: 'No Regions', + })).toThrow(); + }); + + it('should reject page with camelCase name', () => { + expect(() => PageSchema.parse({ + name: 'myPage', + label: 'CamelCase Name', + regions: [], + })).toThrow(); + }); + + it('should reject page with invalid type enum', () => { + expect(() => PageSchema.parse({ + name: 'bad_type', + label: 'Bad Type', + type: 'nonexistent_type', + regions: [], + })).toThrow(); + }); +}); + +describe('PageComponentSchema - Negative Validation', () => { + it('should reject component without type', () => { + expect(() => PageComponentSchema.parse({ + properties: {}, + })).toThrow(); + }); + + it('should reject component without properties', () => { + expect(() => PageComponentSchema.parse({ + type: 'record:details', + })).toThrow(); + }); +}); + +describe('RecordReviewConfigSchema - Negative Validation', () => { + it('should reject review config without object', () => { + expect(() => RecordReviewConfigSchema.parse({ + actions: [{ label: 'Approve', type: 'approve' }], + })).toThrow(); + }); + + it('should reject review config without actions', () => { + expect(() => RecordReviewConfigSchema.parse({ + object: 'lead', + })).toThrow(); + }); + + it('should reject review action with invalid type enum', () => { + expect(() => RecordReviewConfigSchema.parse({ + object: 'lead', + actions: [{ label: 'Bad', type: 'invalid_action' }], + })).toThrow(); + }); +}); diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index d87b21d6aa..33065e603d 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; import { SortItemSchema } from '../shared/enums.zod'; +import { FilterConditionSchema } from '../data/filter.zod'; import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod'; import { ResponsiveConfigSchema } from './responsive.zod'; import { @@ -50,7 +51,7 @@ export const PageComponentType = z.enum([ export const ElementDataSourceSchema = z.object({ object: z.string().describe('Object to query'), view: z.string().optional().describe('Named view to apply'), - filter: z.any().optional().describe('Additional filter criteria'), + filter: FilterConditionSchema.optional().describe('Additional filter criteria'), sort: z.array(SortItemSchema).optional().describe('Sort order'), limit: z.number().int().positive().optional().describe('Max records to display'), }); @@ -178,7 +179,7 @@ export const PageTypeSchema = z.enum([ */ export const RecordReviewConfigSchema = z.object({ object: z.string().describe('Target object for review'), - filter: z.any().optional().describe('Filter criteria for review queue'), + filter: FilterConditionSchema.optional().describe('Filter criteria for review queue'), sort: z.array(SortItemSchema).optional().describe('Sort order for review queue'), displayFields: z.array(z.string()).optional() .describe('Fields to display on the review page'), @@ -188,7 +189,7 @@ export const RecordReviewConfigSchema = z.object({ .describe('Action type'), field: z.string().optional() .describe('Field to update on action'), - value: z.any().optional() + value: z.union([z.string(), z.number(), z.boolean()]).optional() .describe('Value to set on action'), nextRecord: z.boolean().optional().default(true) .describe('Auto-advance to next record after action'), diff --git a/packages/spec/src/ui/report.test.ts b/packages/spec/src/ui/report.test.ts index 3a78af1ebf..7fa2c3ed92 100644 --- a/packages/spec/src/ui/report.test.ts +++ b/packages/spec/src/ui/report.test.ts @@ -481,3 +481,66 @@ describe('Report Performance Integration', () => { })).not.toThrow(); }); }); + +// ============================================================================ +// Negative / Inverse Validation Tests +// ============================================================================ + +describe('ReportSchema - Negative Validation', () => { + it('should reject report without name', () => { + expect(() => ReportSchema.parse({ + label: 'No Name Report', + objectName: 'contact', + columns: [{ field: 'name' }], + })).toThrow(); + }); + + it('should reject report without label', () => { + expect(() => ReportSchema.parse({ + name: 'no_label', + objectName: 'contact', + columns: [{ field: 'name' }], + })).toThrow(); + }); + + it('should reject report without objectName', () => { + expect(() => ReportSchema.parse({ + name: 'no_object', + label: 'No Object Report', + columns: [{ field: 'name' }], + })).toThrow(); + }); + + it('should reject report without columns', () => { + expect(() => ReportSchema.parse({ + name: 'no_columns', + label: 'No Columns Report', + objectName: 'contact', + })).toThrow(); + }); + + it('should reject report with camelCase name', () => { + expect(() => ReportSchema.parse({ + name: 'myReport', + label: 'CamelCase Name', + objectName: 'contact', + columns: [{ field: 'name' }], + })).toThrow(); + }); + + it('should reject report with invalid type enum', () => { + expect(() => ReportSchema.parse({ + name: 'invalid_type', + label: 'Invalid Type', + objectName: 'contact', + type: 'pivot', + columns: [{ field: 'name' }], + })).toThrow(); + }); + + it('should reject report column without field', () => { + expect(() => ReportColumnSchema.parse({ + label: 'No Field', + })).toThrow(); + }); +}); From 2a60554357ec258da8e5641c646375f38c059e9a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Feb 2026 07:26:20 +0000 Subject: [PATCH 3/4] Update ROADMAP.md with spec hardening status and current test metrics Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- ROADMAP.md | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 35ec83f85c..cc5fbb775d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -98,11 +98,25 @@ This strategy ensures rapid iteration while maintaining a clear path to producti | `.describe()` Annotations | 8,425+ | | Service Contracts | 27 | | Contracts Implemented | 13 (52%) | -| Test Files | 218 | -| Tests Passing | 6,202 / 6,202 | +| Test Files | 229 | +| Tests Passing | 6,445 / 6,445 | | `@deprecated` Items | 3 | | Protocol Domains | 15 (Data, UI, AI, API, Automation, Cloud, Contracts, Identity, Integration, Kernel, QA, Security, Shared, Studio, System) | +### Spec Protocol Hardening Status + +| Item | Status | Details | +|:---|:---:|:---| +| `defineStack()` strict by default | ✅ | `strict: true` default since v3.0.2, validates schemas + cross-references | +| `z.any()` elimination in UI protocol | ✅ | All `filter` fields → `FilterConditionSchema`, all `value` fields → typed unions | +| Seed data → object cross-reference | ✅ | `validateCrossReferences` detects seed data referencing undefined objects | +| Navigation → object/dashboard/page/report cross-reference | ✅ | App navigation items validated against defined metadata | +| Negative validation tests (dashboard, page, report) | ✅ | Missing required fields, invalid enums, type violations all covered | +| `z.any()` in data/filter.zod.ts (8 instances) | ✅ Justified | Runtime comparison operators (`$eq`, `$ne`, `$in`, `$nin`) accept any value type | +| `z.unknown()` hardening in remaining schemas | 🟡 | `z.unknown()` used for extensible config/metadata fields — stricter composite schemas planned | +| DashboardWidget discriminated union by type | 🔴 | Planned — chart/metric/pivot subtypes with type-specific required fields | +| CI lint rule rejecting new `z.any()` | 🔴 | Planned — eslint or custom lint rule to block `z.any()` additions | + --- ## 🎯 Priority Roadmap — February 2026 From 8f78604c62cfbeabddc6792af45f90abd1c7d9d1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Feb 2026 07:54:33 +0000 Subject: [PATCH 4/4] Add ViewFilterRuleSchema, unify filter types in view/page/component, add example-level strict validation tests Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- ROADMAP.md | 13 +- packages/spec/src/stack.test.ts | 173 ++++++++++++++++++++++++++ packages/spec/src/ui/component.zod.ts | 3 +- packages/spec/src/ui/page.zod.ts | 3 +- packages/spec/src/ui/view.test.ts | 69 ++++++++++ packages/spec/src/ui/view.zod.ts | 32 ++++- 6 files changed, 283 insertions(+), 10 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index cc5fbb775d..c674aec618 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -99,7 +99,7 @@ This strategy ensures rapid iteration while maintaining a clear path to producti | Service Contracts | 27 | | Contracts Implemented | 13 (52%) | | Test Files | 229 | -| Tests Passing | 6,445 / 6,445 | +| Tests Passing | 6,456 / 6,456 | | `@deprecated` Items | 3 | | Protocol Domains | 15 (Data, UI, AI, API, Automation, Cloud, Contracts, Identity, Integration, Kernel, QA, Security, Shared, Studio, System) | @@ -108,12 +108,15 @@ This strategy ensures rapid iteration while maintaining a clear path to producti | Item | Status | Details | |:---|:---:|:---| | `defineStack()` strict by default | ✅ | `strict: true` default since v3.0.2, validates schemas + cross-references | -| `z.any()` elimination in UI protocol | ✅ | All `filter` fields → `FilterConditionSchema`, all `value` fields → typed unions | +| `z.any()` elimination in UI protocol | ✅ | All `filter` fields → `FilterConditionSchema` or `ViewFilterRuleSchema`, all `value` fields → typed unions | +| Filter format unification | ✅ | MongoDB-style filters use `FilterConditionSchema`, declarative view/tab filters use `ViewFilterRuleSchema` — `z.array(z.unknown())` eliminated | | Seed data → object cross-reference | ✅ | `validateCrossReferences` detects seed data referencing undefined objects | -| Navigation → object/dashboard/page/report cross-reference | ✅ | App navigation items validated against defined metadata | -| Negative validation tests (dashboard, page, report) | ✅ | Missing required fields, invalid enums, type violations all covered | +| Navigation → object/dashboard/page/report cross-reference | ✅ | App navigation items validated against defined metadata (recursive group support) | +| Negative validation tests (dashboard, page, report, view) | ✅ | Missing required fields, invalid enums, type violations, cross-reference errors all covered | +| Example-level strict validation tests | ✅ | Todo-style and CRM-style full app configs validated in strict mode | +| SSOT: types from Zod (`z.infer`) | ✅ | 135 UI types derived via `z.infer`, zero duplicate interfaces in `.zod.ts` files | | `z.any()` in data/filter.zod.ts (8 instances) | ✅ Justified | Runtime comparison operators (`$eq`, `$ne`, `$in`, `$nin`) accept any value type | -| `z.unknown()` hardening in remaining schemas | 🟡 | `z.unknown()` used for extensible config/metadata fields — stricter composite schemas planned | +| `z.unknown()` in extensibility fields | ✅ Justified | `properties`, `children`, `context`, `options`, `body` — inherently dynamic extensibility points | | DashboardWidget discriminated union by type | 🔴 | Planned — chart/metric/pivot subtypes with type-specific required fields | | CI lint rule rejecting new `z.any()` | 🔴 | Planned — eslint or custom lint rule to block `z.any()` additions | diff --git a/packages/spec/src/stack.test.ts b/packages/spec/src/stack.test.ts index 3b263f6faf..90190bcec9 100644 --- a/packages/spec/src/stack.test.ts +++ b/packages/spec/src/stack.test.ts @@ -960,3 +960,176 @@ describe('defineStack - Navigation Cross-Reference Validation', () => { expect(() => defineStack(config)).not.toThrow(); }); }); + +// ============================================================================ +// Example-Level Strict Validation — mirrors examples/app-todo & examples/app-crm +// ============================================================================ + +describe('defineStack - Example-Level Strict Validation', () => { + it('should validate a Todo-style app config (strict mode)', () => { + const todoConfig = { + manifest: { + id: 'com.example.todo', + namespace: 'todo', + version: '2.0.0', + type: 'app' as const, + name: 'Todo Manager', + description: 'A comprehensive Todo app', + }, + objects: [ + { + name: 'task', + label: 'Task', + fields: { + subject: { type: 'text', label: 'Subject', required: true }, + status: { type: 'select', label: 'Status', options: [ + { value: 'not_started', label: 'Not Started' }, + { value: 'in_progress', label: 'In Progress' }, + { value: 'completed', label: 'Completed' }, + ]}, + priority: { type: 'select', label: 'Priority', options: [ + { value: 'low', label: 'Low' }, + { value: 'normal', label: 'Normal' }, + { value: 'high', label: 'High' }, + ]}, + category: { type: 'text', label: 'Category' }, + due_date: { type: 'date', label: 'Due Date' }, + }, + }, + ], + data: [ + { + object: 'task', + mode: 'upsert' as const, + externalId: 'subject', + records: [ + { subject: 'Learn ObjectStack', status: 'completed', priority: 'high', category: 'Work' }, + { subject: 'Build a cool app', status: 'in_progress', priority: 'normal', category: 'Work' }, + ], + }, + ], + dashboards: [ + { + name: 'task_overview', + label: 'Task Overview', + widgets: [ + { title: 'Total Tasks', type: 'metric', object: 'task', aggregate: 'count', layout: { x: 0, y: 0, w: 3, h: 2 } }, + { title: 'By Status', type: 'pie', object: 'task', categoryField: 'status', aggregate: 'count', layout: { x: 3, y: 0, w: 6, h: 4 } }, + ], + }, + ], + apps: [ + { + name: 'todo_app', + label: 'Todo Manager', + navigation: [ + { id: 'nav_tasks', type: 'object' as const, label: 'Tasks', objectName: 'task' }, + { id: 'nav_dashboard', type: 'dashboard' as const, label: 'Overview', dashboardName: 'task_overview' }, + ], + }, + ], + }; + expect(() => defineStack(todoConfig, { strict: true })).not.toThrow(); + }); + + it('should validate a CRM-style app config with seed data and reports (strict mode)', () => { + const crmConfig = { + manifest: { + id: 'com.example.crm', + namespace: 'crm', + version: '1.0.0', + type: 'app' as const, + name: 'Sales CRM', + description: 'Complete sales management solution', + }, + objects: [ + { + name: 'account', + label: 'Account', + fields: { + name: { type: 'text', label: 'Name', required: true }, + industry: { type: 'text', label: 'Industry' }, + annual_revenue: { type: 'number', label: 'Annual Revenue' }, + }, + }, + { + name: 'opportunity', + label: 'Opportunity', + fields: { + name: { type: 'text', label: 'Name', required: true }, + amount: { type: 'currency', label: 'Amount' }, + stage: { type: 'select', label: 'Stage', options: [ + { value: 'prospecting', label: 'Prospecting' }, + { value: 'negotiation', label: 'Negotiation' }, + { value: 'closed_won', label: 'Closed Won' }, + ]}, + }, + }, + ], + data: [ + { + object: 'account', + mode: 'upsert' as const, + externalId: 'name', + records: [ + { name: 'Acme Corp', industry: 'technology', annual_revenue: 5000000 }, + ], + }, + ], + reports: [ + { + name: 'pipeline_report', + label: 'Pipeline Report', + objectName: 'opportunity', + type: 'summary' as const, + columns: [ + { field: 'name' }, + { field: 'amount', aggregate: 'sum' as const }, + ], + groupingsDown: [{ field: 'stage' }], + }, + ], + dashboards: [ + { + name: 'sales_overview', + label: 'Sales Overview', + widgets: [ + { title: 'Pipeline Value', type: 'metric', object: 'opportunity', valueField: 'amount', aggregate: 'sum', layout: { x: 0, y: 0, w: 4, h: 2 } }, + ], + }, + ], + apps: [ + { + name: 'sales_crm', + label: 'Sales CRM', + icon: 'briefcase', + navigation: [ + { id: 'nav_accounts', type: 'object' as const, label: 'Accounts', objectName: 'account' }, + { id: 'nav_opportunities', type: 'object' as const, label: 'Opportunities', objectName: 'opportunity' }, + { id: 'nav_dashboard', type: 'dashboard' as const, label: 'Sales Overview', dashboardName: 'sales_overview' }, + { id: 'nav_report', type: 'report' as const, label: 'Pipeline', reportName: 'pipeline_report' }, + ], + }, + ], + }; + expect(() => defineStack(crmConfig, { strict: true })).not.toThrow(); + }); + + it('should reject CRM config with seed data referencing non-existent object', () => { + const badConfig = { + manifest: { + id: 'com.example.crm', + name: 'crm', + version: '1.0.0', + type: 'app' as const, + }, + objects: [ + { name: 'account', fields: { name: { type: 'text' } } }, + ], + data: [ + { object: 'contact', records: [{ name: 'John' }] }, + ], + }; + expect(() => defineStack(badConfig, { strict: true })).toThrow('contact'); + }); +}); diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index d9d6d25df2..b2f34bd793 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -2,6 +2,7 @@ import { z } from 'zod'; import { FilterConditionSchema } from '../data/filter.zod'; +import { ViewFilterRuleSchema } from './view.zod'; import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod'; import { FeedItemType, FeedFilterMode } from '../data/feed.zod'; @@ -77,7 +78,7 @@ export const RecordRelatedListProps = z.object({ })) ]).optional().describe('Sort order for related records'), limit: z.number().int().positive().default(5).describe('Number of records to display initially'), - filter: z.array(z.unknown()).optional().describe('Additional filter criteria for related records'), + filter: z.array(ViewFilterRuleSchema).optional().describe('Additional filter criteria for related records'), title: I18nLabelSchema.optional().describe('Custom title for the related list'), showViewAll: z.boolean().default(true).describe('Show "View All" link to see all related records'), actions: z.array(z.string()).optional().describe('Action IDs available for related records'), diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index 33065e603d..956f9dc19a 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -10,6 +10,7 @@ import { UserActionsConfigSchema, AppearanceConfigSchema, ViewTabSchema, + ViewFilterRuleSchema, AddRecordConfigSchema, } from './view.zod'; @@ -213,7 +214,7 @@ export const InterfacePageConfigSchema = z.object({ /** Data binding */ source: z.string().optional().describe('Source object name for the page'), levels: z.number().int().min(1).optional().describe('Number of hierarchy levels to display'), - filterBy: z.array(z.unknown()).optional().describe('Page-level filter criteria'), + filterBy: z.array(ViewFilterRuleSchema).optional().describe('Page-level filter criteria'), /** Appearance */ appearance: AppearanceConfigSchema.optional().describe('Appearance and visualization configuration'), diff --git a/packages/spec/src/ui/view.test.ts b/packages/spec/src/ui/view.test.ts index 3ed5421f3a..7cca2f8392 100644 --- a/packages/spec/src/ui/view.test.ts +++ b/packages/spec/src/ui/view.test.ts @@ -26,6 +26,7 @@ import { UserActionsConfigSchema, AppearanceConfigSchema, ViewTabSchema, + ViewFilterRuleSchema, AddRecordConfigSchema, type View, type ListView, @@ -2300,3 +2301,71 @@ describe('ListViewSchema — Airtable Interface parity fields', () => { expect(listView.allowPrinting).toBeUndefined(); }); }); + +// ============================================================================ +// ViewFilterRuleSchema Tests +// ============================================================================ + +describe('ViewFilterRuleSchema', () => { + it('should accept a filter rule with field, operator, and value', () => { + const rule = ViewFilterRuleSchema.parse({ + field: 'status', + operator: 'equals', + value: 'active', + }); + expect(rule.field).toBe('status'); + expect(rule.operator).toBe('equals'); + expect(rule.value).toBe('active'); + }); + + it('should accept a unary filter rule without value', () => { + const rule = ViewFilterRuleSchema.parse({ + field: 'close_date', + operator: 'this_quarter', + }); + expect(rule.value).toBeUndefined(); + }); + + it('should accept boolean and number filter values', () => { + expect(() => ViewFilterRuleSchema.parse({ field: 'archived', operator: 'equals', value: false })).not.toThrow(); + expect(() => ViewFilterRuleSchema.parse({ field: 'amount', operator: 'gte', value: 1000 })).not.toThrow(); + }); + + it('should accept array filter values (for IN operator)', () => { + expect(() => ViewFilterRuleSchema.parse({ + field: 'status', + operator: 'in', + value: ['active', 'pending'], + })).not.toThrow(); + }); + + it('should reject filter rule without field', () => { + expect(() => ViewFilterRuleSchema.parse({ operator: 'equals', value: 'x' })).toThrow(); + }); + + it('should reject filter rule without operator', () => { + expect(() => ViewFilterRuleSchema.parse({ field: 'status', value: 'x' })).toThrow(); + }); +}); + +describe('ListViewSchema filter field', () => { + it('should accept typed filter array', () => { + const view = ListViewSchema.parse({ + type: 'grid', + columns: ['name', 'status'], + filter: [ + { field: 'status', operator: 'equals', value: 'active' }, + { field: 'archived', operator: 'equals', value: false }, + ], + }); + expect(view.filter).toHaveLength(2); + }); + + it('should reject filter with non-object entries', () => { + expect(() => ListViewSchema.parse({ + type: 'grid', + columns: ['name'], + filter: ['invalid_string'], + })).toThrow(); + }); +}); diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 6d0f14c728..06076ef3ae 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -44,6 +44,31 @@ export const ViewDataSchema = z.discriminatedUnion('provider', [ }), ]); +/** + * View Filter Rule Schema + * Standardized filter condition used in list views, tabs, and page-level filters. + * Uses a declarative array-of-objects format: [{ field, operator, value }]. + * + * @example + * ```ts + * filter: [ + * { field: 'status', operator: 'equals', value: 'active' }, + * { field: 'close_date', operator: 'this_quarter' }, + * ] + * ``` + */ +export const ViewFilterRuleSchema = z.object({ + /** Field name to filter on */ + field: z.string().describe('Field name to filter on'), + /** Filter operator */ + operator: z.string().describe('Filter operator (e.g. equals, not_equals, contains, this_quarter)'), + /** Filter value (optional for unary operators like is_null, this_quarter) */ + value: z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(z.union([z.string(), z.number()]))]) + .optional().describe('Filter value'), +}).describe('View filter rule'); + +export type ViewFilterRule = z.infer; + /** * Column Summary Function Schema * Aggregation function for column footer (Airtable-style column summaries) @@ -231,7 +256,7 @@ export const ViewTabSchema = z.object({ label: I18nLabelSchema.optional().describe('Display label'), icon: z.string().optional().describe('Tab icon name'), view: z.string().optional().describe('Referenced list view name from listViews'), - filter: z.array(z.unknown()).optional().describe('Tab-specific filter criteria'), + filter: z.array(ViewFilterRuleSchema).optional().describe('Tab-specific filter criteria'), order: z.number().int().min(0).optional().describe('Tab display order'), pinned: z.boolean().default(false).describe('Pin tab (cannot be removed by users)'), isDefault: z.boolean().default(false).describe('Set as the default active tab'), @@ -360,7 +385,7 @@ export const ListViewSchema = z.object({ z.array(z.string()), // Legacy: simple field names z.array(ListColumnSchema), // Enhanced: detailed column config ]).describe('Fields to display as columns'), - filter: z.array(z.unknown()).optional().describe('Filter criteria (JSON Rules)'), + filter: z.array(ViewFilterRuleSchema).optional().describe('Filter criteria (JSON Rules)'), sort: z.union([ z.string(), //Legacy "field desc" z.array(z.object({ @@ -378,7 +403,8 @@ export const ListViewSchema = z.object({ field: z.string().describe('Field name to filter by'), label: z.string().optional().describe('Display label for the chip'), operator: z.enum(['equals', 'not_equals', 'contains', 'in', 'is_null', 'is_not_null']).default('equals').describe('Filter operator'), - value: z.unknown().optional().describe('Preset filter value'), + value: z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(z.union([z.string(), z.number()]))]) + .optional().describe('Preset filter value'), })).optional().describe('One-click filter chips for quick record filtering'), /** Grid Features */