@objectstack/sdui-parser 11.2.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.
@@ -0,0 +1,144 @@
1
+ /**
2
+ * ObjectUI — SDUI tree validation against the registry manifest (ADR-0080 §3/§6)
3
+ *
4
+ * Shallow, author-time validation: unknown component, unknown/missing prop,
5
+ * wrong coarse type, illegal enum value. Collects `requires` (plugin provenance)
6
+ * and binding sites the SERVER must resolve against object schema (we cannot
7
+ * resolve objects/fields here — that check is framework-side by design).
8
+ */
9
+
10
+ import type {
11
+ Diagnostic,
12
+ Manifest,
13
+ ManifestInput,
14
+ SchemaElement,
15
+ SchemaNode,
16
+ ValidationResult,
17
+ } from './types.js';
18
+
19
+ /** Base props every node may carry (mirrors BaseSchema) — never "unknown prop". */
20
+ const BASE_PROPS = new Set([
21
+ 'type',
22
+ 'id',
23
+ 'className',
24
+ 'style',
25
+ 'visible',
26
+ 'visibleOn',
27
+ 'disabled',
28
+ 'disabledOn',
29
+ 'children',
30
+ ]);
31
+
32
+ const isExpr = (v: unknown): boolean =>
33
+ typeof v === 'object' && v !== null && '$expr' in (v as Record<string, unknown>);
34
+
35
+ export function validateTree(tree: SchemaElement | null, manifest: Manifest): ValidationResult {
36
+ const diagnostics: Diagnostic[] = [];
37
+ const requires = new Set<string>();
38
+ const bindings: ValidationResult['bindings'] = [];
39
+
40
+ const visit = (node: SchemaNode): void => {
41
+ if (typeof node === 'string') return;
42
+ const comp = manifest.components[node.type];
43
+ if (!comp) {
44
+ diagnostics.push({
45
+ severity: 'error',
46
+ code: 'unknown-component',
47
+ message: `<${node.type}> is not a known component`,
48
+ tag: node.type,
49
+ });
50
+ } else {
51
+ if (comp.namespace) requires.add(comp.namespace);
52
+ const byName = new Map(comp.inputs.map((i) => [i.name, i]));
53
+
54
+ // required present?
55
+ for (const input of comp.inputs) {
56
+ if (input.required && !(input.name in node)) {
57
+ diagnostics.push({
58
+ severity: 'error',
59
+ code: 'missing-required-prop',
60
+ message: `<${node.type}> is missing required prop "${input.name}"`,
61
+ tag: node.type,
62
+ });
63
+ }
64
+ }
65
+
66
+ // each provided prop
67
+ for (const [key, value] of Object.entries(node)) {
68
+ if (BASE_PROPS.has(key)) continue;
69
+ const input = byName.get(key);
70
+ if (!input) {
71
+ diagnostics.push({
72
+ severity: 'warning',
73
+ code: 'unknown-prop',
74
+ message: `<${node.type}> has no prop "${key}"`,
75
+ tag: node.type,
76
+ });
77
+ continue;
78
+ }
79
+ if (input.binding) {
80
+ bindings.push({ tag: node.type, input: key, kind: input.binding, value });
81
+ }
82
+ if (!isExpr(value)) {
83
+ const typeDiag = checkType(node.type, input, value);
84
+ if (typeDiag) diagnostics.push(typeDiag);
85
+ }
86
+ }
87
+
88
+ // containment
89
+ if (node.children?.length && !comp.isContainer) {
90
+ diagnostics.push({
91
+ severity: 'warning',
92
+ code: 'not-a-container',
93
+ message: `<${node.type}> does not accept children`,
94
+ tag: node.type,
95
+ });
96
+ }
97
+ }
98
+
99
+ if (node.children) node.children.forEach(visit);
100
+ };
101
+
102
+ if (tree) visit(tree);
103
+ return { diagnostics, requires: [...requires], bindings };
104
+ }
105
+
106
+ function checkType(tag: string, input: ManifestInput, value: unknown): Diagnostic | null {
107
+ const mismatch = (expected: string): Diagnostic => ({
108
+ severity: 'warning',
109
+ code: 'type-mismatch',
110
+ message: `<${tag}> prop "${input.name}" expected ${expected}`,
111
+ tag,
112
+ });
113
+ switch (input.type) {
114
+ case 'number':
115
+ return typeof value === 'number' ? null : mismatch('a number');
116
+ case 'boolean':
117
+ return typeof value === 'boolean' ? null : mismatch('a boolean');
118
+ case 'string':
119
+ case 'color':
120
+ case 'date':
121
+ case 'code':
122
+ case 'file':
123
+ return typeof value === 'string' ? null : mismatch('a string');
124
+ case 'array':
125
+ return Array.isArray(value) ? null : mismatch('an array');
126
+ case 'object':
127
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
128
+ ? null
129
+ : mismatch('an object');
130
+ case 'enum': {
131
+ const allowed = (input.enum ?? []).map((e) => (typeof e === 'object' ? e.value : e));
132
+ return allowed.includes(value as never)
133
+ ? null
134
+ : {
135
+ severity: 'error',
136
+ code: 'invalid-enum',
137
+ message: `<${tag}> prop "${input.name}"=${JSON.stringify(value)} is not one of ${JSON.stringify(allowed)}`,
138
+ tag,
139
+ };
140
+ }
141
+ default:
142
+ return null;
143
+ }
144
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "include": ["src/**/*"],
4
+ "exclude": ["node_modules", "dist", "**/*.test.ts"],
5
+ "compilerOptions": {
6
+ "outDir": "dist",
7
+ "rootDir": "src"
8
+ }
9
+ }