Skip to content

Commit 968793d

Browse files
authored
Merge pull request #802 from objectstack-ai/copilot/enhance-spec-schema-validation
2 parents 974573e + 8f78604 commit 968793d

11 files changed

Lines changed: 714 additions & 14 deletions

File tree

ROADMAP.md

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,11 +98,28 @@ This strategy ensures rapid iteration while maintaining a clear path to producti
9898
| `.describe()` Annotations | 8,425+ |
9999
| Service Contracts | 27 |
100100
| Contracts Implemented | 13 (52%) |
101-
| Test Files | 218 |
102-
| Tests Passing | 6,202 / 6,202 |
101+
| Test Files | 229 |
102+
| Tests Passing | 6,456 / 6,456 |
103103
| `@deprecated` Items | 3 |
104104
| Protocol Domains | 15 (Data, UI, AI, API, Automation, Cloud, Contracts, Identity, Integration, Kernel, QA, Security, Shared, Studio, System) |
105105

106+
### Spec Protocol Hardening Status
107+
108+
| Item | Status | Details |
109+
|:---|:---:|:---|
110+
| `defineStack()` strict by default || `strict: true` default since v3.0.2, validates schemas + cross-references |
111+
| `z.any()` elimination in UI protocol || All `filter` fields → `FilterConditionSchema` or `ViewFilterRuleSchema`, all `value` fields → typed unions |
112+
| Filter format unification || MongoDB-style filters use `FilterConditionSchema`, declarative view/tab filters use `ViewFilterRuleSchema``z.array(z.unknown())` eliminated |
113+
| Seed data → object cross-reference || `validateCrossReferences` detects seed data referencing undefined objects |
114+
| Navigation → object/dashboard/page/report cross-reference || App navigation items validated against defined metadata (recursive group support) |
115+
| Negative validation tests (dashboard, page, report, view) || Missing required fields, invalid enums, type violations, cross-reference errors all covered |
116+
| Example-level strict validation tests || Todo-style and CRM-style full app configs validated in strict mode |
117+
| SSOT: types from Zod (`z.infer`) || 135 UI types derived via `z.infer`, zero duplicate interfaces in `.zod.ts` files |
118+
| `z.any()` in data/filter.zod.ts (8 instances) | ✅ Justified | Runtime comparison operators (`$eq`, `$ne`, `$in`, `$nin`) accept any value type |
119+
| `z.unknown()` in extensibility fields | ✅ Justified | `properties`, `children`, `context`, `options`, `body` — inherently dynamic extensibility points |
120+
| DashboardWidget discriminated union by type | 🔴 | Planned — chart/metric/pivot subtypes with type-specific required fields |
121+
| CI lint rule rejecting new `z.any()` | 🔴 | Planned — eslint or custom lint rule to block `z.any()` additions |
122+
106123
---
107124

108125
## 🎯 Priority Roadmap — February 2026

packages/spec/src/stack.test.ts

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -847,3 +847,289 @@ describe('defineStack - Map Format Support', () => {
847847
expect(result.views![0].list?.type).toBe('grid');
848848
});
849849
});
850+
851+
// ============================================================================
852+
// Negative / Inverse Validation Tests — Cross-Reference
853+
// ============================================================================
854+
855+
describe('defineStack - Seed Data Cross-Reference Validation', () => {
856+
const baseManifest = {
857+
id: 'com.example.test',
858+
name: 'test-project',
859+
version: '1.0.0',
860+
type: 'app' as const,
861+
};
862+
863+
it('should detect seed data referencing undefined object', () => {
864+
const config = {
865+
manifest: baseManifest,
866+
objects: [
867+
{ name: 'account', fields: { name: { type: 'text' } } },
868+
],
869+
data: [
870+
{ object: 'ghost_object', records: [{ name: 'Test' }] },
871+
],
872+
};
873+
expect(() => defineStack(config)).toThrow('ghost_object');
874+
expect(() => defineStack(config)).toThrow('cross-reference validation failed');
875+
});
876+
877+
it('should pass when seed data references defined object', () => {
878+
const config = {
879+
manifest: baseManifest,
880+
objects: [
881+
{ name: 'account', fields: { name: { type: 'text' } } },
882+
],
883+
data: [
884+
{ object: 'account', records: [{ name: 'Acme Corp' }] },
885+
],
886+
};
887+
expect(() => defineStack(config)).not.toThrow();
888+
});
889+
});
890+
891+
describe('defineStack - Navigation Cross-Reference Validation', () => {
892+
const baseManifest = {
893+
id: 'com.example.test',
894+
name: 'test-project',
895+
version: '1.0.0',
896+
type: 'app' as const,
897+
};
898+
899+
it('should detect navigation referencing undefined object', () => {
900+
const config = {
901+
manifest: baseManifest,
902+
objects: [
903+
{ name: 'task', fields: { title: { type: 'text' } } },
904+
],
905+
apps: [
906+
{
907+
name: 'my_app',
908+
label: 'My App',
909+
navigation: [
910+
{ id: 'nav_missing', type: 'object' as const, label: 'Missing', objectName: 'nonexistent_object' },
911+
],
912+
},
913+
],
914+
};
915+
expect(() => defineStack(config)).toThrow('nonexistent_object');
916+
});
917+
918+
it('should detect navigation referencing undefined dashboard', () => {
919+
const config = {
920+
manifest: baseManifest,
921+
objects: [
922+
{ name: 'task', fields: { title: { type: 'text' } } },
923+
],
924+
dashboards: [
925+
{ name: 'sales_dashboard', label: 'Sales', widgets: [] },
926+
],
927+
apps: [
928+
{
929+
name: 'my_app',
930+
label: 'My App',
931+
navigation: [
932+
{ id: 'nav_ghost', type: 'dashboard' as const, label: 'Missing', dashboardName: 'ghost_dashboard' },
933+
],
934+
},
935+
],
936+
};
937+
expect(() => defineStack(config)).toThrow('ghost_dashboard');
938+
});
939+
940+
it('should pass when all navigation references are valid', () => {
941+
const config = {
942+
manifest: baseManifest,
943+
objects: [
944+
{ name: 'task', fields: { title: { type: 'text' } } },
945+
],
946+
dashboards: [
947+
{ name: 'task_overview', label: 'Overview', widgets: [] },
948+
],
949+
apps: [
950+
{
951+
name: 'my_app',
952+
label: 'My App',
953+
navigation: [
954+
{ id: 'nav_tasks', type: 'object' as const, label: 'Tasks', objectName: 'task' },
955+
{ id: 'nav_overview', type: 'dashboard' as const, label: 'Overview', dashboardName: 'task_overview' },
956+
],
957+
},
958+
],
959+
};
960+
expect(() => defineStack(config)).not.toThrow();
961+
});
962+
});
963+
964+
// ============================================================================
965+
// Example-Level Strict Validation — mirrors examples/app-todo & examples/app-crm
966+
// ============================================================================
967+
968+
describe('defineStack - Example-Level Strict Validation', () => {
969+
it('should validate a Todo-style app config (strict mode)', () => {
970+
const todoConfig = {
971+
manifest: {
972+
id: 'com.example.todo',
973+
namespace: 'todo',
974+
version: '2.0.0',
975+
type: 'app' as const,
976+
name: 'Todo Manager',
977+
description: 'A comprehensive Todo app',
978+
},
979+
objects: [
980+
{
981+
name: 'task',
982+
label: 'Task',
983+
fields: {
984+
subject: { type: 'text', label: 'Subject', required: true },
985+
status: { type: 'select', label: 'Status', options: [
986+
{ value: 'not_started', label: 'Not Started' },
987+
{ value: 'in_progress', label: 'In Progress' },
988+
{ value: 'completed', label: 'Completed' },
989+
]},
990+
priority: { type: 'select', label: 'Priority', options: [
991+
{ value: 'low', label: 'Low' },
992+
{ value: 'normal', label: 'Normal' },
993+
{ value: 'high', label: 'High' },
994+
]},
995+
category: { type: 'text', label: 'Category' },
996+
due_date: { type: 'date', label: 'Due Date' },
997+
},
998+
},
999+
],
1000+
data: [
1001+
{
1002+
object: 'task',
1003+
mode: 'upsert' as const,
1004+
externalId: 'subject',
1005+
records: [
1006+
{ subject: 'Learn ObjectStack', status: 'completed', priority: 'high', category: 'Work' },
1007+
{ subject: 'Build a cool app', status: 'in_progress', priority: 'normal', category: 'Work' },
1008+
],
1009+
},
1010+
],
1011+
dashboards: [
1012+
{
1013+
name: 'task_overview',
1014+
label: 'Task Overview',
1015+
widgets: [
1016+
{ title: 'Total Tasks', type: 'metric', object: 'task', aggregate: 'count', layout: { x: 0, y: 0, w: 3, h: 2 } },
1017+
{ title: 'By Status', type: 'pie', object: 'task', categoryField: 'status', aggregate: 'count', layout: { x: 3, y: 0, w: 6, h: 4 } },
1018+
],
1019+
},
1020+
],
1021+
apps: [
1022+
{
1023+
name: 'todo_app',
1024+
label: 'Todo Manager',
1025+
navigation: [
1026+
{ id: 'nav_tasks', type: 'object' as const, label: 'Tasks', objectName: 'task' },
1027+
{ id: 'nav_dashboard', type: 'dashboard' as const, label: 'Overview', dashboardName: 'task_overview' },
1028+
],
1029+
},
1030+
],
1031+
};
1032+
expect(() => defineStack(todoConfig, { strict: true })).not.toThrow();
1033+
});
1034+
1035+
it('should validate a CRM-style app config with seed data and reports (strict mode)', () => {
1036+
const crmConfig = {
1037+
manifest: {
1038+
id: 'com.example.crm',
1039+
namespace: 'crm',
1040+
version: '1.0.0',
1041+
type: 'app' as const,
1042+
name: 'Sales CRM',
1043+
description: 'Complete sales management solution',
1044+
},
1045+
objects: [
1046+
{
1047+
name: 'account',
1048+
label: 'Account',
1049+
fields: {
1050+
name: { type: 'text', label: 'Name', required: true },
1051+
industry: { type: 'text', label: 'Industry' },
1052+
annual_revenue: { type: 'number', label: 'Annual Revenue' },
1053+
},
1054+
},
1055+
{
1056+
name: 'opportunity',
1057+
label: 'Opportunity',
1058+
fields: {
1059+
name: { type: 'text', label: 'Name', required: true },
1060+
amount: { type: 'currency', label: 'Amount' },
1061+
stage: { type: 'select', label: 'Stage', options: [
1062+
{ value: 'prospecting', label: 'Prospecting' },
1063+
{ value: 'negotiation', label: 'Negotiation' },
1064+
{ value: 'closed_won', label: 'Closed Won' },
1065+
]},
1066+
},
1067+
},
1068+
],
1069+
data: [
1070+
{
1071+
object: 'account',
1072+
mode: 'upsert' as const,
1073+
externalId: 'name',
1074+
records: [
1075+
{ name: 'Acme Corp', industry: 'technology', annual_revenue: 5000000 },
1076+
],
1077+
},
1078+
],
1079+
reports: [
1080+
{
1081+
name: 'pipeline_report',
1082+
label: 'Pipeline Report',
1083+
objectName: 'opportunity',
1084+
type: 'summary' as const,
1085+
columns: [
1086+
{ field: 'name' },
1087+
{ field: 'amount', aggregate: 'sum' as const },
1088+
],
1089+
groupingsDown: [{ field: 'stage' }],
1090+
},
1091+
],
1092+
dashboards: [
1093+
{
1094+
name: 'sales_overview',
1095+
label: 'Sales Overview',
1096+
widgets: [
1097+
{ title: 'Pipeline Value', type: 'metric', object: 'opportunity', valueField: 'amount', aggregate: 'sum', layout: { x: 0, y: 0, w: 4, h: 2 } },
1098+
],
1099+
},
1100+
],
1101+
apps: [
1102+
{
1103+
name: 'sales_crm',
1104+
label: 'Sales CRM',
1105+
icon: 'briefcase',
1106+
navigation: [
1107+
{ id: 'nav_accounts', type: 'object' as const, label: 'Accounts', objectName: 'account' },
1108+
{ id: 'nav_opportunities', type: 'object' as const, label: 'Opportunities', objectName: 'opportunity' },
1109+
{ id: 'nav_dashboard', type: 'dashboard' as const, label: 'Sales Overview', dashboardName: 'sales_overview' },
1110+
{ id: 'nav_report', type: 'report' as const, label: 'Pipeline', reportName: 'pipeline_report' },
1111+
],
1112+
},
1113+
],
1114+
};
1115+
expect(() => defineStack(crmConfig, { strict: true })).not.toThrow();
1116+
});
1117+
1118+
it('should reject CRM config with seed data referencing non-existent object', () => {
1119+
const badConfig = {
1120+
manifest: {
1121+
id: 'com.example.crm',
1122+
name: 'crm',
1123+
version: '1.0.0',
1124+
type: 'app' as const,
1125+
},
1126+
objects: [
1127+
{ name: 'account', fields: { name: { type: 'text' } } },
1128+
],
1129+
data: [
1130+
{ object: 'contact', records: [{ name: 'John' }] },
1131+
],
1132+
};
1133+
expect(() => defineStack(badConfig, { strict: true })).toThrow('contact');
1134+
});
1135+
});

packages/spec/src/stack.zod.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,74 @@ function validateCrossReferences(config: ObjectStackDefinition): string[] {
332332
}
333333
}
334334

335+
// Validate seed data → object references
336+
if (config.data) {
337+
for (const dataset of config.data) {
338+
if (dataset.object && !objectNames.has(dataset.object)) {
339+
errors.push(
340+
`Seed data references object '${dataset.object}' which is not defined in objects.`,
341+
);
342+
}
343+
}
344+
}
345+
346+
// Validate app navigation → object/dashboard/page/report references
347+
if (config.apps) {
348+
const dashboardNames = new Set<string>();
349+
if (config.dashboards) {
350+
for (const d of config.dashboards) {
351+
dashboardNames.add(d.name);
352+
}
353+
}
354+
const pageNames = new Set<string>();
355+
if (config.pages) {
356+
for (const p of config.pages) {
357+
pageNames.add(p.name);
358+
}
359+
}
360+
const reportNames = new Set<string>();
361+
if (config.reports) {
362+
for (const r of config.reports) {
363+
reportNames.add(r.name);
364+
}
365+
}
366+
367+
for (const app of config.apps) {
368+
if (!app.navigation) continue;
369+
const checkNavItems = (items: unknown[], appName: string) => {
370+
for (const item of items) {
371+
if (!item || typeof item !== 'object') continue;
372+
const nav = item as Record<string, unknown>;
373+
if (nav.type === 'object' && typeof nav.objectName === 'string' && !objectNames.has(nav.objectName)) {
374+
errors.push(
375+
`App '${appName}' navigation references object '${nav.objectName}' which is not defined in objects.`,
376+
);
377+
}
378+
if (nav.type === 'dashboard' && typeof nav.dashboardName === 'string' && dashboardNames.size > 0 && !dashboardNames.has(nav.dashboardName)) {
379+
errors.push(
380+
`App '${appName}' navigation references dashboard '${nav.dashboardName}' which is not defined in dashboards.`,
381+
);
382+
}
383+
if (nav.type === 'page' && typeof nav.pageName === 'string' && pageNames.size > 0 && !pageNames.has(nav.pageName)) {
384+
errors.push(
385+
`App '${appName}' navigation references page '${nav.pageName}' which is not defined in pages.`,
386+
);
387+
}
388+
if (nav.type === 'report' && typeof nav.reportName === 'string' && reportNames.size > 0 && !reportNames.has(nav.reportName)) {
389+
errors.push(
390+
`App '${appName}' navigation references report '${nav.reportName}' which is not defined in reports.`,
391+
);
392+
}
393+
// Recurse into group children
394+
if (nav.type === 'group' && Array.isArray(nav.children)) {
395+
checkNavItems(nav.children, appName);
396+
}
397+
}
398+
};
399+
checkNavItems(app.navigation, app.name);
400+
}
401+
}
402+
335403
return errors;
336404
}
337405

0 commit comments

Comments
 (0)