Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions ROADMAP.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -98,11 +98,28 @@ 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,456 / 6,456 |
| `@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` 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 (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()` 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 |

---

## 🎯 Priority Roadmap — February 2026
Expand Down
286 changes: 286 additions & 0 deletions packages/spec/src/stack.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -847,3 +847,289 @@ 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');
});

CopilotAIFeb 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The negative test for dashboard reference validation should include a case where no dashboards are defined at all (dashboards array is empty or undefined), but navigation references a dashboard. This would verify that the validation correctly catches dangling references even when the target collection is empty.

Example test case:

it('should detect navigation referencing dashboard when no dashboards are defined',()=>{constconfig={manifest: baseManifest,objects: [{name: 'task',fields: {title: {type: 'text'}}}],// No dashboards definedapps: [{name: 'my_app',label: 'My App',navigation: [{id: 'nav_ghost',type: 'dashboard'asconst,label: 'Missing',dashboardName: 'ghost_dashboard'},],}],};expect(()=>defineStack(config)).toThrow('ghost_dashboard');});

Similar tests should be added for page and report references as well.

Suggested change
it('should detect navigation referencing dashboard when no dashboards are defined (dashboards undefined)',()=>{
constconfig={
manifest: baseManifest,
objects: [
{name: 'task',fields: {title: {type: 'text'}}},
],
// dashboards property intentionally omitted
apps: [
{
name: 'my_app',
label: 'My App',
navigation: [
{
id: 'nav_ghost_no_dashboards',
type: 'dashboard'asconst,
label: 'Missing',
dashboardName: 'ghost_dashboard',
},
],
},
],
};
expect(()=>defineStack(config)).toThrow('ghost_dashboard');
});
it('should detect navigation referencing dashboard when dashboards array is empty',()=>{
constconfig={
manifest: baseManifest,
objects: [
{name: 'task',fields: {title: {type: 'text'}}},
],
dashboards: [],
apps: [
{
name: 'my_app',
label: 'My App',
navigation: [
{
id: 'nav_ghost_empty_dashboards',
type: 'dashboard'asconst,
label: 'Missing',
dashboardName: 'ghost_dashboard',
},
],
},
],
};
expect(()=>defineStack(config)).toThrow('ghost_dashboard');
});
it('should detect navigation referencing page when no pages are defined',()=>{
constconfig={
manifest: baseManifest,
objects: [
{name: 'task',fields: {title: {type: 'text'}}},
],
// pages property intentionally omitted
apps: [
{
name: 'my_app',
label: 'My App',
navigation: [
{
id: 'nav_page_ghost',
type: 'page'asconst,
label: 'Missing',
pageName: 'ghost_page',
},
],
},
],
};
expect(()=>defineStack(config)).toThrow('ghost_page');
});
it('should detect navigation referencing report when no reports are defined',()=>{
constconfig={
manifest: baseManifest,
objects: [
{name: 'task',fields: {title: {type: 'text'}}},
],
// reports property intentionally omitted
apps: [
{
name: 'my_app',
label: 'My App',
navigation: [
{
id: 'nav_report_ghost',
type: 'report'asconst,
label: 'Missing',
reportName: 'ghost_report',
},
],
},
],
};
expect(()=>defineStack(config)).toThrow('ghost_report');
});

Copilot uses AI. Check for mistakes.
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();
});
});

// ============================================================================
// 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');
});
});
68 changes: 68 additions & 0 deletions packages/spec/src/stack.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<string>();
if (config.dashboards) {
for (const d of config.dashboards) {
dashboardNames.add(d.name);
}
}
const pageNames = new Set<string>();
if (config.pages) {
for (const p of config.pages) {
pageNames.add(p.name);
}
}
const reportNames = new Set<string>();
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<string, unknown>;
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)) {
Comment on lines +378 to +388

CopilotAIFeb 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cross-reference validation for navigation items only validates dashboard/page/report references when their respective collections have at least one item (dashboardNames.size > 0, pageNames.size > 0, reportNames.size > 0). This means if an app navigation references a dashboard but no dashboards are defined at all, the validation will not catch this error.

Consider removing the size checks so that any reference to a dashboard/page/report is validated, regardless of whether those collections are empty or not. This would make the validation more consistent with how object references are validated (which don't have this size check on line 373).

Suggested change
if(nav.type==='dashboard'&&typeofnav.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'&&typeofnav.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'&&typeofnav.reportName==='string'&&reportNames.size>0&&!reportNames.has(nav.reportName)){
if(nav.type==='dashboard'&&typeofnav.dashboardName==='string'&&!dashboardNames.has(nav.dashboardName)){
errors.push(
`App '${appName}' navigation references dashboard '${nav.dashboardName}' which is not defined in dashboards.`,
);
}
if(nav.type==='page'&&typeofnav.pageName==='string'&&!pageNames.has(nav.pageName)){
errors.push(
`App '${appName}' navigation references page '${nav.pageName}' which is not defined in pages.`,
);
}
if(nav.type==='report'&&typeofnav.reportName==='string'&&!reportNames.has(nav.reportName)){

Copilot uses AI. Check for mistakes.
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;
}

Expand Down
Loading