@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.
Files changed (75) hide show
  1. package/.turbo/turbo-build.log +4 -0
  2. package/.turbo/turbo-test.log +16 -0
  3. package/.turbo/turbo-typecheck.log +4 -0
  4. package/dist/__tests__/expression.test.d.ts +2 -0
  5. package/dist/__tests__/expression.test.d.ts.map +1 -0
  6. package/dist/__tests__/expression.test.js +77 -0
  7. package/dist/__tests__/expression.test.js.map +1 -0
  8. package/dist/__tests__/page-spec.test.d.ts +2 -0
  9. package/dist/__tests__/page-spec.test.d.ts.map +1 -0
  10. package/dist/__tests__/page-spec.test.js +223 -0
  11. package/dist/__tests__/page-spec.test.js.map +1 -0
  12. package/dist/__tests__/validator.test.d.ts +2 -0
  13. package/dist/__tests__/validator.test.d.ts.map +1 -0
  14. package/dist/__tests__/validator.test.js +177 -0
  15. package/dist/__tests__/validator.test.js.map +1 -0
  16. package/dist/action-def.d.ts +227 -0
  17. package/dist/action-def.d.ts.map +1 -0
  18. package/dist/action-def.js +54 -0
  19. package/dist/action-def.js.map +1 -0
  20. package/dist/catalog.d.ts +16 -0
  21. package/dist/catalog.d.ts.map +1 -0
  22. package/dist/catalog.js +8 -0
  23. package/dist/catalog.js.map +1 -0
  24. package/dist/data-source.d.ts +195 -0
  25. package/dist/data-source.d.ts.map +1 -0
  26. package/dist/data-source.js +28 -0
  27. package/dist/data-source.js.map +1 -0
  28. package/dist/element-def.d.ts +37 -0
  29. package/dist/element-def.d.ts.map +1 -0
  30. package/dist/element-def.js +13 -0
  31. package/dist/element-def.js.map +1 -0
  32. package/dist/expression.d.ts +28 -0
  33. package/dist/expression.d.ts.map +1 -0
  34. package/dist/expression.js +56 -0
  35. package/dist/expression.js.map +1 -0
  36. package/dist/hook-def.d.ts +456 -0
  37. package/dist/hook-def.d.ts.map +1 -0
  38. package/dist/hook-def.js +67 -0
  39. package/dist/hook-def.js.map +1 -0
  40. package/dist/index.d.ts +20 -0
  41. package/dist/index.d.ts.map +1 -0
  42. package/dist/index.js +19 -0
  43. package/dist/index.js.map +1 -0
  44. package/dist/page-spec.d.ts +684 -0
  45. package/dist/page-spec.d.ts.map +1 -0
  46. package/dist/page-spec.js +34 -0
  47. package/dist/page-spec.js.map +1 -0
  48. package/dist/page-store.d.ts +20 -0
  49. package/dist/page-store.d.ts.map +1 -0
  50. package/dist/page-store.js +2 -0
  51. package/dist/page-store.js.map +1 -0
  52. package/dist/patch.d.ts +144 -0
  53. package/dist/patch.d.ts.map +1 -0
  54. package/dist/patch.js +23 -0
  55. package/dist/patch.js.map +1 -0
  56. package/dist/validator.d.ts +13 -0
  57. package/dist/validator.d.ts.map +1 -0
  58. package/dist/validator.js +286 -0
  59. package/dist/validator.js.map +1 -0
  60. package/package.json +27 -0
  61. package/src/__tests__/expression.test.ts +93 -0
  62. package/src/__tests__/page-spec.test.ts +242 -0
  63. package/src/__tests__/validator.test.ts +189 -0
  64. package/src/action-def.ts +63 -0
  65. package/src/catalog.ts +24 -0
  66. package/src/data-source.ts +46 -0
  67. package/src/element-def.ts +17 -0
  68. package/src/expression.ts +77 -0
  69. package/src/hook-def.ts +79 -0
  70. package/src/index.ts +72 -0
  71. package/src/page-spec.ts +42 -0
  72. package/src/page-store.ts +18 -0
  73. package/src/patch.ts +28 -0
  74. package/src/validator.ts +347 -0
  75. 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
+ });
@@ -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';
@@ -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>;
@@ -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
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src"
6
+ },
7
+ "include": ["src"]
8
+ }