@viyv/agent-ui-schema 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +4 -0
- package/.turbo/turbo-test.log +16 -0
- package/.turbo/turbo-typecheck.log +4 -0
- package/dist/__tests__/expression.test.d.ts +2 -0
- package/dist/__tests__/expression.test.d.ts.map +1 -0
- package/dist/__tests__/expression.test.js +77 -0
- package/dist/__tests__/expression.test.js.map +1 -0
- package/dist/__tests__/page-spec.test.d.ts +2 -0
- package/dist/__tests__/page-spec.test.d.ts.map +1 -0
- package/dist/__tests__/page-spec.test.js +223 -0
- package/dist/__tests__/page-spec.test.js.map +1 -0
- package/dist/__tests__/validator.test.d.ts +2 -0
- package/dist/__tests__/validator.test.d.ts.map +1 -0
- package/dist/__tests__/validator.test.js +177 -0
- package/dist/__tests__/validator.test.js.map +1 -0
- package/dist/action-def.d.ts +227 -0
- package/dist/action-def.d.ts.map +1 -0
- package/dist/action-def.js +54 -0
- package/dist/action-def.js.map +1 -0
- package/dist/catalog.d.ts +16 -0
- package/dist/catalog.d.ts.map +1 -0
- package/dist/catalog.js +8 -0
- package/dist/catalog.js.map +1 -0
- package/dist/data-source.d.ts +195 -0
- package/dist/data-source.d.ts.map +1 -0
- package/dist/data-source.js +28 -0
- package/dist/data-source.js.map +1 -0
- package/dist/element-def.d.ts +37 -0
- package/dist/element-def.d.ts.map +1 -0
- package/dist/element-def.js +13 -0
- package/dist/element-def.js.map +1 -0
- package/dist/expression.d.ts +28 -0
- package/dist/expression.d.ts.map +1 -0
- package/dist/expression.js +56 -0
- package/dist/expression.js.map +1 -0
- package/dist/hook-def.d.ts +456 -0
- package/dist/hook-def.d.ts.map +1 -0
- package/dist/hook-def.js +67 -0
- package/dist/hook-def.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/page-spec.d.ts +684 -0
- package/dist/page-spec.d.ts.map +1 -0
- package/dist/page-spec.js +34 -0
- package/dist/page-spec.js.map +1 -0
- package/dist/page-store.d.ts +20 -0
- package/dist/page-store.d.ts.map +1 -0
- package/dist/page-store.js +2 -0
- package/dist/page-store.js.map +1 -0
- package/dist/patch.d.ts +144 -0
- package/dist/patch.d.ts.map +1 -0
- package/dist/patch.js +23 -0
- package/dist/patch.js.map +1 -0
- package/dist/validator.d.ts +13 -0
- package/dist/validator.d.ts.map +1 -0
- package/dist/validator.js +286 -0
- package/dist/validator.js.map +1 -0
- package/package.json +27 -0
- package/src/__tests__/expression.test.ts +93 -0
- package/src/__tests__/page-spec.test.ts +242 -0
- package/src/__tests__/validator.test.ts +189 -0
- package/src/action-def.ts +63 -0
- package/src/catalog.ts +24 -0
- package/src/data-source.ts +46 -0
- package/src/element-def.ts +17 -0
- package/src/expression.ts +77 -0
- package/src/hook-def.ts +79 -0
- package/src/index.ts +72 -0
- package/src/page-spec.ts +42 -0
- package/src/page-store.ts +18 -0
- package/src/patch.ts +28 -0
- package/src/validator.ts +347 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
// Expression types
|
|
4
|
+
export type ExpressionRef =
|
|
5
|
+
| { type: 'hook'; hookId: string; path: string[] }
|
|
6
|
+
| { type: 'state'; key: string }
|
|
7
|
+
| { type: 'bindState'; key: string }
|
|
8
|
+
| { type: 'action'; actionId: string }
|
|
9
|
+
| { type: 'item'; path: string[] }
|
|
10
|
+
| { type: 'param'; name: string }
|
|
11
|
+
| { type: 'expr'; code: string };
|
|
12
|
+
|
|
13
|
+
// Pattern matchers — identifiers allow word chars plus hyphens (e.g. "my-hook")
|
|
14
|
+
const ID = '[\\w-]+';
|
|
15
|
+
const HOOK_PATTERN = new RegExp(`^\\$hook\\.(${ID})(\\.(.+))?$`);
|
|
16
|
+
const STATE_PATTERN = new RegExp(`^\\$state\\.(${ID})$`);
|
|
17
|
+
const BIND_STATE_PATTERN = new RegExp(`^\\$bindState\\.(${ID})$`);
|
|
18
|
+
const ACTION_PATTERN = new RegExp(`^\\$action\\.(${ID})$`);
|
|
19
|
+
const ITEM_PATTERN = /^\$item(\.(.+))?$/;
|
|
20
|
+
const PARAM_PATTERN = new RegExp(`^\\$param\\.(${ID})$`);
|
|
21
|
+
const EXPR_PATTERN = /^\$expr\((.+)\)$/s;
|
|
22
|
+
|
|
23
|
+
export function isExpression(value: unknown): value is string {
|
|
24
|
+
return typeof value === 'string' && value.startsWith('$');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function parseExpression(value: string): ExpressionRef | null {
|
|
28
|
+
let match: RegExpMatchArray | null;
|
|
29
|
+
|
|
30
|
+
match = value.match(HOOK_PATTERN);
|
|
31
|
+
if (match) {
|
|
32
|
+
const hookId = match[1];
|
|
33
|
+
const rest = match[3];
|
|
34
|
+
const path = rest ? rest.split('.') : [];
|
|
35
|
+
return { type: 'hook', hookId, path };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
match = value.match(STATE_PATTERN);
|
|
39
|
+
if (match) {
|
|
40
|
+
return { type: 'state', key: match[1] };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
match = value.match(BIND_STATE_PATTERN);
|
|
44
|
+
if (match) {
|
|
45
|
+
return { type: 'bindState', key: match[1] };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
match = value.match(ACTION_PATTERN);
|
|
49
|
+
if (match) {
|
|
50
|
+
return { type: 'action', actionId: match[1] };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
match = value.match(ITEM_PATTERN);
|
|
54
|
+
if (match) {
|
|
55
|
+
const rest = match[2];
|
|
56
|
+
const path = rest ? rest.split('.') : [];
|
|
57
|
+
return { type: 'item', path };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
match = value.match(PARAM_PATTERN);
|
|
61
|
+
if (match) {
|
|
62
|
+
return { type: 'param', name: match[1] };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
match = value.match(EXPR_PATTERN);
|
|
66
|
+
if (match) {
|
|
67
|
+
return { type: 'expr', code: match[1] };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const ExpressionStringSchema = z
|
|
74
|
+
.string()
|
|
75
|
+
.refine((val) => !val.startsWith('$') || parseExpression(val) !== null, {
|
|
76
|
+
message: 'Invalid expression syntax',
|
|
77
|
+
});
|
package/src/hook-def.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
export const UseStateHookSchema = z.object({
|
|
4
|
+
use: z.literal('useState'),
|
|
5
|
+
params: z.object({
|
|
6
|
+
initial: z.unknown(),
|
|
7
|
+
}),
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
export const UseDerivedHookSchema = z.object({
|
|
11
|
+
use: z.literal('useDerived'),
|
|
12
|
+
from: z.string().min(1),
|
|
13
|
+
params: z.object({
|
|
14
|
+
sort: z
|
|
15
|
+
.object({
|
|
16
|
+
key: z.string(),
|
|
17
|
+
order: z.enum(['asc', 'desc']).default('asc'),
|
|
18
|
+
})
|
|
19
|
+
.optional(),
|
|
20
|
+
filter: z
|
|
21
|
+
.object({
|
|
22
|
+
key: z.string(),
|
|
23
|
+
match: z.unknown(),
|
|
24
|
+
})
|
|
25
|
+
.optional(),
|
|
26
|
+
limit: z.number().int().positive().optional(),
|
|
27
|
+
groupBy: z.string().optional(),
|
|
28
|
+
aggregate: z
|
|
29
|
+
.object({
|
|
30
|
+
fn: z.enum(['sum', 'avg', 'count', 'min', 'max']),
|
|
31
|
+
key: z.string(),
|
|
32
|
+
})
|
|
33
|
+
.optional(),
|
|
34
|
+
}),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
export const UseFetchHookSchema = z.object({
|
|
38
|
+
use: z.literal('useFetch'),
|
|
39
|
+
params: z.object({
|
|
40
|
+
url: z.string().url(),
|
|
41
|
+
method: z.enum(['GET', 'POST']).default('GET'),
|
|
42
|
+
headers: z.record(z.string()).optional(),
|
|
43
|
+
body: z.unknown().optional(),
|
|
44
|
+
refreshInterval: z.number().int().positive().optional(),
|
|
45
|
+
}),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
export const UseSqlQueryHookSchema = z.object({
|
|
49
|
+
use: z.literal('useSqlQuery'),
|
|
50
|
+
params: z.object({
|
|
51
|
+
connection: z.string().min(1),
|
|
52
|
+
query: z.string().min(1),
|
|
53
|
+
refreshInterval: z.number().int().positive().optional(),
|
|
54
|
+
}),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
export const UseAgentQueryHookSchema = z.object({
|
|
58
|
+
use: z.literal('useAgentQuery'),
|
|
59
|
+
params: z.object({
|
|
60
|
+
endpoint: z.string().min(1),
|
|
61
|
+
query: z.record(z.unknown()).optional(),
|
|
62
|
+
refreshInterval: z.number().int().positive().optional(),
|
|
63
|
+
}),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
export const HookDefSchema = z.discriminatedUnion('use', [
|
|
67
|
+
UseStateHookSchema,
|
|
68
|
+
UseDerivedHookSchema,
|
|
69
|
+
UseFetchHookSchema,
|
|
70
|
+
UseSqlQueryHookSchema,
|
|
71
|
+
UseAgentQueryHookSchema,
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
export type HookDef = z.infer<typeof HookDefSchema>;
|
|
75
|
+
export type UseStateHook = z.infer<typeof UseStateHookSchema>;
|
|
76
|
+
export type UseDerivedHook = z.infer<typeof UseDerivedHookSchema>;
|
|
77
|
+
export type UseFetchHook = z.infer<typeof UseFetchHookSchema>;
|
|
78
|
+
export type UseSqlQueryHook = z.infer<typeof UseSqlQueryHookSchema>;
|
|
79
|
+
export type UseAgentQueryHook = z.infer<typeof UseAgentQueryHookSchema>;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Expression
|
|
2
|
+
export { parseExpression, isExpression, ExpressionStringSchema } from './expression.js';
|
|
3
|
+
export type { ExpressionRef } from './expression.js';
|
|
4
|
+
|
|
5
|
+
// Hook definitions
|
|
6
|
+
export {
|
|
7
|
+
HookDefSchema,
|
|
8
|
+
UseStateHookSchema,
|
|
9
|
+
UseDerivedHookSchema,
|
|
10
|
+
UseFetchHookSchema,
|
|
11
|
+
UseSqlQueryHookSchema,
|
|
12
|
+
UseAgentQueryHookSchema,
|
|
13
|
+
} from './hook-def.js';
|
|
14
|
+
export type {
|
|
15
|
+
HookDef,
|
|
16
|
+
UseStateHook,
|
|
17
|
+
UseDerivedHook,
|
|
18
|
+
UseFetchHook,
|
|
19
|
+
UseSqlQueryHook,
|
|
20
|
+
UseAgentQueryHook,
|
|
21
|
+
} from './hook-def.js';
|
|
22
|
+
|
|
23
|
+
// Element definitions
|
|
24
|
+
export { ElementDefSchema, VisibilityConditionSchema } from './element-def.js';
|
|
25
|
+
export type { ElementDef, VisibilityCondition } from './element-def.js';
|
|
26
|
+
|
|
27
|
+
// Action definitions
|
|
28
|
+
export {
|
|
29
|
+
ActionDefSchema,
|
|
30
|
+
SetStateActionSchema,
|
|
31
|
+
RefreshHookActionSchema,
|
|
32
|
+
NavigateActionSchema,
|
|
33
|
+
SubmitFormActionSchema,
|
|
34
|
+
AddItemActionSchema,
|
|
35
|
+
RemoveItemActionSchema,
|
|
36
|
+
UpdateItemActionSchema,
|
|
37
|
+
} from './action-def.js';
|
|
38
|
+
export type { ActionDef } from './action-def.js';
|
|
39
|
+
|
|
40
|
+
// Page spec
|
|
41
|
+
export { PageSpecSchema, ParamDefSchema, ThemeSchema, PageMetaSchema } from './page-spec.js';
|
|
42
|
+
export type { PageSpec, ParamDef, Theme, PageMeta } from './page-spec.js';
|
|
43
|
+
|
|
44
|
+
// Catalog
|
|
45
|
+
export { defineCatalog } from './catalog.js';
|
|
46
|
+
export type { ComponentMeta, ComponentCatalog } from './catalog.js';
|
|
47
|
+
|
|
48
|
+
// Page store
|
|
49
|
+
export type { PageStore, PageStorePage } from './page-store.js';
|
|
50
|
+
|
|
51
|
+
// Data source
|
|
52
|
+
export {
|
|
53
|
+
DataSourceMetaSchema,
|
|
54
|
+
TableMetaSchema,
|
|
55
|
+
ColumnMetaSchema,
|
|
56
|
+
EndpointMetaSchema,
|
|
57
|
+
} from './data-source.js';
|
|
58
|
+
export type {
|
|
59
|
+
DataSourceMeta,
|
|
60
|
+
TableMeta,
|
|
61
|
+
ColumnMeta,
|
|
62
|
+
EndpointMeta,
|
|
63
|
+
DataConnector,
|
|
64
|
+
} from './data-source.js';
|
|
65
|
+
|
|
66
|
+
// JSON Patch
|
|
67
|
+
export { JsonPatchSchema, JsonPatchOpSchema } from './patch.js';
|
|
68
|
+
export type { JsonPatch, JsonPatchOp } from './patch.js';
|
|
69
|
+
|
|
70
|
+
// Validator
|
|
71
|
+
export { validatePageSpec } from './validator.js';
|
|
72
|
+
export type { ValidationResult, ValidationError } from './validator.js';
|
package/src/page-spec.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { ActionDefSchema } from './action-def.js';
|
|
3
|
+
import { ElementDefSchema } from './element-def.js';
|
|
4
|
+
import { HookDefSchema } from './hook-def.js';
|
|
5
|
+
|
|
6
|
+
export const ParamDefSchema = z.object({
|
|
7
|
+
type: z.enum(['string', 'number']).default('string'),
|
|
8
|
+
default: z.unknown().optional(),
|
|
9
|
+
description: z.string().optional(),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export const ThemeSchema = z.object({
|
|
13
|
+
colorScheme: z.enum(['light', 'dark', 'auto']).default('auto'),
|
|
14
|
+
accentColor: z.string().optional(),
|
|
15
|
+
spacing: z.enum(['compact', 'default', 'relaxed']).default('default'),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export const PageMetaSchema = z.object({
|
|
19
|
+
createdAt: z.string().datetime().optional(),
|
|
20
|
+
updatedAt: z.string().datetime().optional(),
|
|
21
|
+
createdBy: z.string().optional(),
|
|
22
|
+
tags: z.array(z.string()).optional(),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export const PageSpecSchema = z.object({
|
|
26
|
+
id: z.string().min(1),
|
|
27
|
+
title: z.string().min(1),
|
|
28
|
+
description: z.string().optional(),
|
|
29
|
+
hooks: z.record(HookDefSchema).default({}),
|
|
30
|
+
root: z.string().min(1),
|
|
31
|
+
elements: z.record(ElementDefSchema),
|
|
32
|
+
state: z.record(z.unknown()).default({}),
|
|
33
|
+
actions: z.record(ActionDefSchema).default({}),
|
|
34
|
+
params: z.record(ParamDefSchema).optional(),
|
|
35
|
+
theme: ThemeSchema.optional(),
|
|
36
|
+
meta: PageMetaSchema.optional(),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
export type PageSpec = z.infer<typeof PageSpecSchema>;
|
|
40
|
+
export type ParamDef = z.infer<typeof ParamDefSchema>;
|
|
41
|
+
export type Theme = z.infer<typeof ThemeSchema>;
|
|
42
|
+
export type PageMeta = z.infer<typeof PageMetaSchema>;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { PageSpec } from './page-spec.js';
|
|
2
|
+
|
|
3
|
+
export interface PageStorePage {
|
|
4
|
+
id: string;
|
|
5
|
+
spec: PageSpec;
|
|
6
|
+
createdAt: Date;
|
|
7
|
+
updatedAt: Date;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface PageStore {
|
|
11
|
+
list(): Promise<PageStorePage[]>;
|
|
12
|
+
get(id: string): Promise<PageStorePage | null>;
|
|
13
|
+
save(spec: PageSpec): Promise<PageStorePage>;
|
|
14
|
+
update(id: string, spec: PageSpec): Promise<PageStorePage>;
|
|
15
|
+
delete(id: string): Promise<void>;
|
|
16
|
+
savePreview(spec: PageSpec): Promise<{ previewId: string; expiresAt: Date }>;
|
|
17
|
+
getPreview(previewId: string): Promise<PageSpec | null>;
|
|
18
|
+
}
|
package/src/patch.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
/** JSON Pointer string (RFC 6901): must be empty or start with "/" */
|
|
4
|
+
const JsonPointerSchema = z.string().refine((s) => s === '' || s.startsWith('/'), {
|
|
5
|
+
message: 'JSON Pointer must be empty or start with "/"',
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
export const JsonPatchOpSchema = z.discriminatedUnion('op', [
|
|
9
|
+
z.object({ op: z.literal('add'), path: JsonPointerSchema, value: z.unknown() }),
|
|
10
|
+
z.object({ op: z.literal('remove'), path: JsonPointerSchema }),
|
|
11
|
+
z.object({ op: z.literal('replace'), path: JsonPointerSchema, value: z.unknown() }),
|
|
12
|
+
z.object({
|
|
13
|
+
op: z.literal('move'),
|
|
14
|
+
from: JsonPointerSchema,
|
|
15
|
+
path: JsonPointerSchema,
|
|
16
|
+
}),
|
|
17
|
+
z.object({
|
|
18
|
+
op: z.literal('copy'),
|
|
19
|
+
from: JsonPointerSchema,
|
|
20
|
+
path: JsonPointerSchema,
|
|
21
|
+
}),
|
|
22
|
+
z.object({ op: z.literal('test'), path: JsonPointerSchema, value: z.unknown() }),
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
export const JsonPatchSchema = z.array(JsonPatchOpSchema);
|
|
26
|
+
|
|
27
|
+
export type JsonPatchOp = z.infer<typeof JsonPatchOpSchema>;
|
|
28
|
+
export type JsonPatch = z.infer<typeof JsonPatchSchema>;
|
package/src/validator.ts
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import type { ComponentCatalog } from './catalog.js';
|
|
2
|
+
import { isExpression, parseExpression } from './expression.js';
|
|
3
|
+
import type { HookDef } from './hook-def.js';
|
|
4
|
+
import type { PageSpec } from './page-spec.js';
|
|
5
|
+
import { PageSpecSchema } from './page-spec.js';
|
|
6
|
+
|
|
7
|
+
export interface ValidationError {
|
|
8
|
+
path: string;
|
|
9
|
+
message: string;
|
|
10
|
+
severity: 'error' | 'warning';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ValidationResult {
|
|
14
|
+
valid: boolean;
|
|
15
|
+
errors: ValidationError[];
|
|
16
|
+
warnings: ValidationError[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function validatePageSpec(input: unknown, catalog?: ComponentCatalog): ValidationResult {
|
|
20
|
+
const errors: ValidationError[] = [];
|
|
21
|
+
const warnings: ValidationError[] = [];
|
|
22
|
+
|
|
23
|
+
// 1. Zod schema validation
|
|
24
|
+
const parsed = PageSpecSchema.safeParse(input);
|
|
25
|
+
if (!parsed.success) {
|
|
26
|
+
for (const issue of parsed.error.issues) {
|
|
27
|
+
errors.push({
|
|
28
|
+
path: issue.path.join('.'),
|
|
29
|
+
message: issue.message,
|
|
30
|
+
severity: 'error',
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
return { valid: false, errors, warnings };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const spec = parsed.data;
|
|
37
|
+
|
|
38
|
+
// 2. Root element exists
|
|
39
|
+
if (!spec.elements[spec.root]) {
|
|
40
|
+
errors.push({
|
|
41
|
+
path: 'root',
|
|
42
|
+
message: `Root element "${spec.root}" not found in elements`,
|
|
43
|
+
severity: 'error',
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 3. Children reference integrity
|
|
48
|
+
for (const [id, element] of Object.entries(spec.elements)) {
|
|
49
|
+
if (element.children) {
|
|
50
|
+
for (const childId of element.children) {
|
|
51
|
+
if (!spec.elements[childId]) {
|
|
52
|
+
errors.push({
|
|
53
|
+
path: `elements.${id}.children`,
|
|
54
|
+
message: `Child element "${childId}" not found`,
|
|
55
|
+
severity: 'error',
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 4. Expression reference validity
|
|
63
|
+
validateExpressions(spec, errors, warnings);
|
|
64
|
+
|
|
65
|
+
// 5. Hook dependency validation (dangling references + cycles)
|
|
66
|
+
validateHookDependencies(spec.hooks, errors);
|
|
67
|
+
validateHookCycles(spec.hooks, errors);
|
|
68
|
+
|
|
69
|
+
// 6. SQL safety check
|
|
70
|
+
validateSqlSafety(spec.hooks, errors, warnings);
|
|
71
|
+
|
|
72
|
+
// 7. CRUD action hookId validation
|
|
73
|
+
validateActions(spec, errors);
|
|
74
|
+
|
|
75
|
+
// 8. Component props validation against catalog
|
|
76
|
+
if (catalog) {
|
|
77
|
+
validateComponentProps(spec, catalog, warnings);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
valid: errors.length === 0,
|
|
82
|
+
errors,
|
|
83
|
+
warnings,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function validateExpressionRef(
|
|
88
|
+
ref: ReturnType<typeof parseExpression>,
|
|
89
|
+
spec: PageSpec,
|
|
90
|
+
path: string,
|
|
91
|
+
errors: ValidationError[],
|
|
92
|
+
): void {
|
|
93
|
+
if (!ref) return;
|
|
94
|
+
if (ref.type === 'hook' && !spec.hooks[ref.hookId]) {
|
|
95
|
+
errors.push({
|
|
96
|
+
path,
|
|
97
|
+
message: `Hook "${ref.hookId}" not defined`,
|
|
98
|
+
severity: 'error',
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
if (ref.type === 'state' && !(ref.key in spec.state)) {
|
|
102
|
+
errors.push({
|
|
103
|
+
path,
|
|
104
|
+
message: `State key "${ref.key}" not defined`,
|
|
105
|
+
severity: 'error',
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
if (ref.type === 'bindState' && !(ref.key in spec.state)) {
|
|
109
|
+
errors.push({
|
|
110
|
+
path,
|
|
111
|
+
message: `State key "${ref.key}" not defined for binding`,
|
|
112
|
+
severity: 'error',
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
if (ref.type === 'action' && !spec.actions[ref.actionId]) {
|
|
116
|
+
errors.push({
|
|
117
|
+
path,
|
|
118
|
+
message: `Action "${ref.actionId}" not defined`,
|
|
119
|
+
severity: 'error',
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
if (ref.type === 'param' && spec.params && !(ref.name in spec.params)) {
|
|
123
|
+
errors.push({
|
|
124
|
+
path,
|
|
125
|
+
message: `Param "${ref.name}" not defined in params`,
|
|
126
|
+
severity: 'error',
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
// $item is validated for Repeater ancestry in validateExpressions
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function buildParentMap(elements: Record<string, { children?: string[] }>): Record<string, string> {
|
|
133
|
+
const parentMap: Record<string, string> = {};
|
|
134
|
+
for (const [id, element] of Object.entries(elements)) {
|
|
135
|
+
if (element.children) {
|
|
136
|
+
for (const childId of element.children) {
|
|
137
|
+
parentMap[childId] = id;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return parentMap;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function hasRepeaterAncestor(
|
|
145
|
+
elemId: string,
|
|
146
|
+
elements: Record<string, { type: string; children?: string[] }>,
|
|
147
|
+
parentMap: Record<string, string>,
|
|
148
|
+
): boolean {
|
|
149
|
+
let current = parentMap[elemId];
|
|
150
|
+
while (current) {
|
|
151
|
+
if (elements[current]?.type === 'Repeater') return true;
|
|
152
|
+
current = parentMap[current];
|
|
153
|
+
}
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function validateExpressions(
|
|
158
|
+
spec: PageSpec,
|
|
159
|
+
errors: ValidationError[],
|
|
160
|
+
warnings: ValidationError[],
|
|
161
|
+
): void {
|
|
162
|
+
const parentMap = buildParentMap(spec.elements);
|
|
163
|
+
|
|
164
|
+
for (const [elemId, element] of Object.entries(spec.elements)) {
|
|
165
|
+
// Validate prop expressions
|
|
166
|
+
for (const [propKey, propValue] of Object.entries(element.props)) {
|
|
167
|
+
if (isExpression(propValue)) {
|
|
168
|
+
const ref = parseExpression(propValue);
|
|
169
|
+
if (!ref) {
|
|
170
|
+
errors.push({
|
|
171
|
+
path: `elements.${elemId}.props.${propKey}`,
|
|
172
|
+
message: `Invalid expression: ${propValue}`,
|
|
173
|
+
severity: 'error',
|
|
174
|
+
});
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
validateExpressionRef(ref, spec, `elements.${elemId}.props.${propKey}`, errors);
|
|
178
|
+
|
|
179
|
+
if (ref.type === 'item' && !hasRepeaterAncestor(elemId, spec.elements, parentMap)) {
|
|
180
|
+
warnings.push({
|
|
181
|
+
path: `elements.${elemId}.props.${propKey}`,
|
|
182
|
+
message: '$item expression used outside Repeater context',
|
|
183
|
+
severity: 'warning',
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Validate visibility expression
|
|
190
|
+
if (element.visible) {
|
|
191
|
+
const expr = element.visible.expr;
|
|
192
|
+
if (isExpression(expr)) {
|
|
193
|
+
const ref = parseExpression(expr);
|
|
194
|
+
if (!ref) {
|
|
195
|
+
errors.push({
|
|
196
|
+
path: `elements.${elemId}.visible.expr`,
|
|
197
|
+
message: `Invalid visibility expression: ${expr}`,
|
|
198
|
+
severity: 'error',
|
|
199
|
+
});
|
|
200
|
+
} else {
|
|
201
|
+
validateExpressionRef(ref, spec, `elements.${elemId}.visible.expr`, errors);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function validateHookDependencies(hooks: Record<string, HookDef>, errors: ValidationError[]): void {
|
|
209
|
+
for (const [hookId, hook] of Object.entries(hooks)) {
|
|
210
|
+
if (hook.use === 'useDerived' && !hooks[hook.from]) {
|
|
211
|
+
errors.push({
|
|
212
|
+
path: `hooks.${hookId}.from`,
|
|
213
|
+
message: `Derived hook "${hookId}" references undefined source hook "${hook.from}"`,
|
|
214
|
+
severity: 'error',
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function validateHookCycles(hooks: Record<string, HookDef>, errors: ValidationError[]): void {
|
|
221
|
+
const visited = new Set<string>();
|
|
222
|
+
const inStack = new Set<string>();
|
|
223
|
+
|
|
224
|
+
function dfs(hookId: string): boolean {
|
|
225
|
+
if (inStack.has(hookId)) return true;
|
|
226
|
+
if (visited.has(hookId)) return false;
|
|
227
|
+
|
|
228
|
+
visited.add(hookId);
|
|
229
|
+
inStack.add(hookId);
|
|
230
|
+
|
|
231
|
+
const hook = hooks[hookId];
|
|
232
|
+
if (hook && hook.use === 'useDerived') {
|
|
233
|
+
if (dfs(hook.from)) {
|
|
234
|
+
errors.push({
|
|
235
|
+
path: `hooks.${hookId}`,
|
|
236
|
+
message: `Circular dependency detected involving hook "${hookId}"`,
|
|
237
|
+
severity: 'error',
|
|
238
|
+
});
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
inStack.delete(hookId);
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
for (const hookId of Object.keys(hooks)) {
|
|
248
|
+
dfs(hookId);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function validateActions(spec: PageSpec, errors: ValidationError[]): void {
|
|
253
|
+
for (const [actionId, action] of Object.entries(spec.actions)) {
|
|
254
|
+
if ('hookId' in action && action.type !== 'refreshHook') {
|
|
255
|
+
const hook = spec.hooks[action.hookId];
|
|
256
|
+
if (!hook) {
|
|
257
|
+
errors.push({
|
|
258
|
+
path: `actions.${actionId}.hookId`,
|
|
259
|
+
message: `Hook "${action.hookId}" not defined`,
|
|
260
|
+
severity: 'error',
|
|
261
|
+
});
|
|
262
|
+
} else if (hook.use !== 'useState') {
|
|
263
|
+
errors.push({
|
|
264
|
+
path: `actions.${actionId}.hookId`,
|
|
265
|
+
message: `Hook "${action.hookId}" is not useState (cannot mutate ${hook.use} hooks)`,
|
|
266
|
+
severity: 'error',
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function validateComponentProps(
|
|
274
|
+
spec: PageSpec,
|
|
275
|
+
catalog: ComponentCatalog,
|
|
276
|
+
warnings: ValidationError[],
|
|
277
|
+
): void {
|
|
278
|
+
for (const [elemId, element] of Object.entries(spec.elements)) {
|
|
279
|
+
const meta = catalog.components[element.type];
|
|
280
|
+
if (!meta) {
|
|
281
|
+
warnings.push({
|
|
282
|
+
path: `elements.${elemId}.type`,
|
|
283
|
+
message: `Unknown component type "${element.type}"`,
|
|
284
|
+
severity: 'warning',
|
|
285
|
+
});
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Build a props object with only static (non-expression) values
|
|
290
|
+
const staticProps: Record<string, unknown> = {};
|
|
291
|
+
for (const [key, value] of Object.entries(element.props)) {
|
|
292
|
+
if (isExpression(value)) continue;
|
|
293
|
+
staticProps[key] = value;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Skip validation if all props are expressions
|
|
297
|
+
if (Object.keys(staticProps).length === 0) continue;
|
|
298
|
+
|
|
299
|
+
const result = meta.propsSchema.safeParse(staticProps);
|
|
300
|
+
if (!result.success) {
|
|
301
|
+
for (const issue of result.error.issues) {
|
|
302
|
+
warnings.push({
|
|
303
|
+
path: `elements.${elemId}.props.${issue.path.join('.')}`,
|
|
304
|
+
message: issue.message,
|
|
305
|
+
severity: 'warning',
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const SQL_DANGEROUS_PATTERNS = [
|
|
313
|
+
/\b(INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|TRUNCATE|EXEC|EXECUTE)\b/i,
|
|
314
|
+
/;\s*\w/, // multiple statements
|
|
315
|
+
/--/, // SQL comments (potential injection)
|
|
316
|
+
/\/\*/, // block comments
|
|
317
|
+
];
|
|
318
|
+
|
|
319
|
+
function validateSqlSafety(
|
|
320
|
+
hooks: Record<string, HookDef>,
|
|
321
|
+
errors: ValidationError[],
|
|
322
|
+
_warnings: ValidationError[],
|
|
323
|
+
): void {
|
|
324
|
+
for (const [hookId, hook] of Object.entries(hooks)) {
|
|
325
|
+
if (hook.use !== 'useSqlQuery') continue;
|
|
326
|
+
|
|
327
|
+
const query = hook.params.query;
|
|
328
|
+
|
|
329
|
+
for (const pattern of SQL_DANGEROUS_PATTERNS) {
|
|
330
|
+
if (pattern.test(query)) {
|
|
331
|
+
errors.push({
|
|
332
|
+
path: `hooks.${hookId}.params.query`,
|
|
333
|
+
message: `Potentially unsafe SQL detected: ${pattern.source}`,
|
|
334
|
+
severity: 'error',
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (!query.trim().toUpperCase().startsWith('SELECT')) {
|
|
340
|
+
errors.push({
|
|
341
|
+
path: `hooks.${hookId}.params.query`,
|
|
342
|
+
message: 'SQL query must start with SELECT',
|
|
343
|
+
severity: 'error',
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|