openuispec 0.2.19 → 0.2.20
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/dist/check/audit.js +392 -0
- package/dist/check/index.js +216 -0
- package/dist/cli/configure-target.js +391 -0
- package/dist/cli/index.js +510 -0
- package/dist/cli/init.js +1047 -0
- package/dist/drift/index.js +903 -0
- package/dist/mcp-server/index.js +886 -0
- package/dist/mcp-server/preview-render.js +1761 -0
- package/dist/mcp-server/preview.js +233 -0
- package/dist/mcp-server/screenshot-android.js +458 -0
- package/dist/mcp-server/screenshot-ios.js +639 -0
- package/dist/mcp-server/screenshot-shared.js +180 -0
- package/dist/mcp-server/screenshot.js +459 -0
- package/dist/prepare/index.js +1216 -0
- package/dist/runtime/package-paths.js +33 -0
- package/dist/schema/semantic-lint.js +564 -0
- package/dist/schema/validate.js +689 -0
- package/dist/status/index.js +194 -0
- package/package.json +12 -13
- package/check/audit.ts +0 -426
- package/check/index.ts +0 -320
- package/cli/configure-target.ts +0 -523
- package/cli/index.ts +0 -537
- package/cli/init.ts +0 -1253
- package/drift/index.ts +0 -1165
- package/mcp-server/index.ts +0 -1041
- package/mcp-server/preview-render.ts +0 -1922
- package/mcp-server/preview.ts +0 -292
- package/mcp-server/screenshot-android.ts +0 -621
- package/mcp-server/screenshot-ios.ts +0 -753
- package/mcp-server/screenshot-shared.ts +0 -237
- package/mcp-server/screenshot.ts +0 -563
- package/prepare/index.ts +0 -1530
- package/schema/semantic-lint.ts +0 -692
- package/schema/validate.ts +0 -870
- package/scripts/regenerate-previews.ts +0 -136
- package/scripts/take-all-screenshots.ts +0 -507
- package/status/index.ts +0 -275
|
@@ -0,0 +1,689 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Validate OpenUISpec files against their JSON Schemas.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* openuispec validate # validate all spec files
|
|
7
|
+
* openuispec validate tokens screens # validate specific groups
|
|
8
|
+
* npm run validate # from repo (uses examples/taskflow/openuispec)
|
|
9
|
+
*/
|
|
10
|
+
import { readFileSync, readdirSync, existsSync } from "node:fs";
|
|
11
|
+
import { resolve, join, basename } from "node:path";
|
|
12
|
+
import { createRequire } from "node:module";
|
|
13
|
+
import YAML from "yaml";
|
|
14
|
+
import { runSemanticLint, collectSemanticLint } from "./semantic-lint.js";
|
|
15
|
+
import { resolvePackagePath } from "../runtime/package-paths.js";
|
|
16
|
+
const require = createRequire(import.meta.url);
|
|
17
|
+
const Ajv2020 = require("ajv/dist/2020");
|
|
18
|
+
const addFormats = require("ajv-formats");
|
|
19
|
+
const SCHEMA_DIR = resolve(resolvePackagePath(import.meta.url, "schema"));
|
|
20
|
+
// ── helpers ──────────────────────────────────────────────────────────
|
|
21
|
+
function loadJson(filePath) {
|
|
22
|
+
return JSON.parse(readFileSync(filePath, "utf-8"));
|
|
23
|
+
}
|
|
24
|
+
function loadYaml(filePath) {
|
|
25
|
+
return YAML.parse(readFileSync(filePath, "utf-8"));
|
|
26
|
+
}
|
|
27
|
+
function loadData(filePath) {
|
|
28
|
+
return filePath.endsWith(".json") ? loadJson(filePath) : loadYaml(filePath);
|
|
29
|
+
}
|
|
30
|
+
function listFiles(dir, ext) {
|
|
31
|
+
try {
|
|
32
|
+
return readdirSync(dir)
|
|
33
|
+
.filter((f) => f.endsWith(ext))
|
|
34
|
+
.sort()
|
|
35
|
+
.map((f) => join(dir, f));
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function isRecord(value) {
|
|
42
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
43
|
+
}
|
|
44
|
+
function isNonEmptyString(value) {
|
|
45
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
46
|
+
}
|
|
47
|
+
function getSingleRootValue(data) {
|
|
48
|
+
if (!isRecord(data))
|
|
49
|
+
return undefined;
|
|
50
|
+
const values = Object.values(data);
|
|
51
|
+
return values.length === 1 ? values[0] : undefined;
|
|
52
|
+
}
|
|
53
|
+
const STANDARD_CONTRACT_RULES = {
|
|
54
|
+
action_trigger: {
|
|
55
|
+
requiredProps: ["label"],
|
|
56
|
+
nonEmptyStringProps: ["label"],
|
|
57
|
+
},
|
|
58
|
+
data_display: {
|
|
59
|
+
requiredProps: ["title"],
|
|
60
|
+
nonEmptyStringProps: ["title"],
|
|
61
|
+
},
|
|
62
|
+
input_field: {
|
|
63
|
+
requiredProps: ["label"],
|
|
64
|
+
nonEmptyStringProps: ["label"],
|
|
65
|
+
validate(node, props, path) {
|
|
66
|
+
const inputType = node.input_type;
|
|
67
|
+
if (inputType === "select" || inputType === "radio") {
|
|
68
|
+
return hasOwnProp(props, "options")
|
|
69
|
+
? []
|
|
70
|
+
: [{
|
|
71
|
+
path,
|
|
72
|
+
message: `contract "input_field" with input_type="${String(inputType)}" requires props.options`,
|
|
73
|
+
}];
|
|
74
|
+
}
|
|
75
|
+
if (inputType === "slider") {
|
|
76
|
+
return hasOwnProp(props, "range")
|
|
77
|
+
? []
|
|
78
|
+
: [{
|
|
79
|
+
path,
|
|
80
|
+
message: 'contract "input_field" with input_type="slider" requires props.range',
|
|
81
|
+
}];
|
|
82
|
+
}
|
|
83
|
+
return [];
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
nav_container: {
|
|
87
|
+
requiredProps: ["items"],
|
|
88
|
+
validate(_node, props, path) {
|
|
89
|
+
const items = props.items;
|
|
90
|
+
if (!Array.isArray(items)) {
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
const errors = [];
|
|
94
|
+
for (const [index, item] of items.entries()) {
|
|
95
|
+
const itemPath = `${path}/props/items/${index}`;
|
|
96
|
+
if (!isRecord(item)) {
|
|
97
|
+
errors.push({
|
|
98
|
+
path: itemPath,
|
|
99
|
+
message: "nav_container items must be objects",
|
|
100
|
+
});
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
for (const key of ["id", "label", "icon", "destination"]) {
|
|
104
|
+
if (!hasOwnProp(item, key)) {
|
|
105
|
+
errors.push({
|
|
106
|
+
path: itemPath,
|
|
107
|
+
message: `nav_container item requires "${key}"`,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (hasOwnProp(item, "label") && !isNonEmptyString(item.label)) {
|
|
112
|
+
errors.push({
|
|
113
|
+
path: `${itemPath}/label`,
|
|
114
|
+
message: 'nav_container item "label" must be a non-empty string',
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return errors;
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
feedback: {
|
|
122
|
+
requiredProps: ["message"],
|
|
123
|
+
nonEmptyStringProps: ["message"],
|
|
124
|
+
},
|
|
125
|
+
surface: {
|
|
126
|
+
requiredProps: ["content"],
|
|
127
|
+
},
|
|
128
|
+
collection: {
|
|
129
|
+
requiredProps: ["data", "item_contract", "item_props_map"],
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
function hasOwnProp(obj, key) {
|
|
133
|
+
return Object.prototype.hasOwnProperty.call(obj, key);
|
|
134
|
+
}
|
|
135
|
+
function validateStandardContractUsage(node, path) {
|
|
136
|
+
const contract = node.contract;
|
|
137
|
+
if (typeof contract !== "string")
|
|
138
|
+
return [];
|
|
139
|
+
const rule = STANDARD_CONTRACT_RULES[contract];
|
|
140
|
+
if (!rule)
|
|
141
|
+
return [];
|
|
142
|
+
const props = isRecord(node.props) ? node.props : {};
|
|
143
|
+
const errors = [];
|
|
144
|
+
for (const prop of rule.requiredProps ?? []) {
|
|
145
|
+
if (!hasOwnProp(props, prop)) {
|
|
146
|
+
errors.push({
|
|
147
|
+
path,
|
|
148
|
+
message: `contract "${contract}" requires props.${prop}`,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
for (const prop of rule.nonEmptyStringProps ?? []) {
|
|
153
|
+
if (hasOwnProp(props, prop) && !isNonEmptyString(props[prop])) {
|
|
154
|
+
errors.push({
|
|
155
|
+
path: `${path}/props/${prop}`,
|
|
156
|
+
message: `props.${prop} for contract "${contract}" must be a non-empty string`,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
errors.push(...(rule.validate?.(node, props, path) ?? []));
|
|
161
|
+
return errors;
|
|
162
|
+
}
|
|
163
|
+
function lintSectionItems(items, path) {
|
|
164
|
+
if (!Array.isArray(items))
|
|
165
|
+
return [];
|
|
166
|
+
const errors = [];
|
|
167
|
+
for (const [index, item] of items.entries()) {
|
|
168
|
+
const itemPath = `${path}/${index}`;
|
|
169
|
+
if (!isRecord(item)) {
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
errors.push(...validateStandardContractUsage(item, itemPath));
|
|
173
|
+
if (Array.isArray(item.children)) {
|
|
174
|
+
errors.push(...lintSectionItems(item.children, `${itemPath}/children`));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return errors;
|
|
178
|
+
}
|
|
179
|
+
function lintScreenLikeDefinition(screenDef, path) {
|
|
180
|
+
if (!isRecord(screenDef))
|
|
181
|
+
return [];
|
|
182
|
+
const errors = [];
|
|
183
|
+
if (isRecord(screenDef.layout)) {
|
|
184
|
+
errors.push(...lintSectionItems(screenDef.layout.sections, `${path}/layout/sections`));
|
|
185
|
+
}
|
|
186
|
+
if (isRecord(screenDef.navigation)) {
|
|
187
|
+
errors.push(...validateStandardContractUsage(screenDef.navigation, `${path}/navigation`));
|
|
188
|
+
}
|
|
189
|
+
if (isRecord(screenDef.surfaces)) {
|
|
190
|
+
for (const [surfaceId, surfaceDef] of Object.entries(screenDef.surfaces)) {
|
|
191
|
+
const surfacePath = `${path}/surfaces/${surfaceId}`;
|
|
192
|
+
if (!isRecord(surfaceDef)) {
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
errors.push(...validateStandardContractUsage(surfaceDef, surfacePath));
|
|
196
|
+
const props = isRecord(surfaceDef.props) ? surfaceDef.props : {};
|
|
197
|
+
if (Array.isArray(props.content)) {
|
|
198
|
+
errors.push(...lintSectionItems(props.content, `${surfacePath}/props/content`));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return errors;
|
|
203
|
+
}
|
|
204
|
+
function lintScreenFile(dataPath) {
|
|
205
|
+
const root = getSingleRootValue(loadData(dataPath));
|
|
206
|
+
const errors = lintScreenLikeDefinition(root, basename(dataPath));
|
|
207
|
+
if (errors.length === 0) {
|
|
208
|
+
return 0;
|
|
209
|
+
}
|
|
210
|
+
console.log(` FAIL ${basename(dataPath)} (${errors.length} contract usage error(s))`);
|
|
211
|
+
for (const error of errors.slice(0, 5)) {
|
|
212
|
+
console.log(` [${error.path}] ${error.message}`);
|
|
213
|
+
}
|
|
214
|
+
if (errors.length > 5) {
|
|
215
|
+
console.log(` ... and ${errors.length - 5} more`);
|
|
216
|
+
}
|
|
217
|
+
console.log(" Hint: built-in contract instances inherit required props from the spec even when contracts/<name>.yaml does not restate them.");
|
|
218
|
+
return errors.length;
|
|
219
|
+
}
|
|
220
|
+
function lintFlowFile(dataPath) {
|
|
221
|
+
const root = getSingleRootValue(loadData(dataPath));
|
|
222
|
+
if (!isRecord(root) || !isRecord(root.screens)) {
|
|
223
|
+
return 0;
|
|
224
|
+
}
|
|
225
|
+
const errors = [];
|
|
226
|
+
for (const [screenId, screenEntry] of Object.entries(root.screens)) {
|
|
227
|
+
if (!isRecord(screenEntry) || !isRecord(screenEntry.screen_inline)) {
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
errors.push(...lintScreenLikeDefinition(screenEntry.screen_inline, `${basename(dataPath)}/screens/${screenId}/screen_inline`));
|
|
231
|
+
}
|
|
232
|
+
if (errors.length === 0) {
|
|
233
|
+
return 0;
|
|
234
|
+
}
|
|
235
|
+
console.log(` FAIL ${basename(dataPath)} (${errors.length} contract usage error(s))`);
|
|
236
|
+
for (const error of errors.slice(0, 5)) {
|
|
237
|
+
console.log(` [${error.path}] ${error.message}`);
|
|
238
|
+
}
|
|
239
|
+
if (errors.length > 5) {
|
|
240
|
+
console.log(` ... and ${errors.length - 5} more`);
|
|
241
|
+
}
|
|
242
|
+
console.log(" Hint: flow screen_inline sections follow the same built-in contract requirements as screens/*.yaml.");
|
|
243
|
+
return errors.length;
|
|
244
|
+
}
|
|
245
|
+
function collectValidateFile(ajv, dataPath, schemaId, label) {
|
|
246
|
+
const name = label ?? basename(dataPath);
|
|
247
|
+
const data = loadData(dataPath);
|
|
248
|
+
const validate = ajv.getSchema(schemaId);
|
|
249
|
+
if (!validate) {
|
|
250
|
+
return [{ file: name, path: "(root)", message: `schema ${schemaId} not found` }];
|
|
251
|
+
}
|
|
252
|
+
const valid = validate(data);
|
|
253
|
+
if (valid)
|
|
254
|
+
return [];
|
|
255
|
+
const errors = validate.errors ?? [];
|
|
256
|
+
return errors.map((e) => ({
|
|
257
|
+
file: name,
|
|
258
|
+
path: e.instancePath || "(root)",
|
|
259
|
+
message: e.message ?? "unknown error",
|
|
260
|
+
}));
|
|
261
|
+
}
|
|
262
|
+
function collectLintScreenFile(dataPath) {
|
|
263
|
+
const root = getSingleRootValue(loadData(dataPath));
|
|
264
|
+
const errors = lintScreenLikeDefinition(root, basename(dataPath));
|
|
265
|
+
return errors.map((e) => ({
|
|
266
|
+
file: basename(dataPath),
|
|
267
|
+
path: e.path,
|
|
268
|
+
message: e.message,
|
|
269
|
+
}));
|
|
270
|
+
}
|
|
271
|
+
function collectLintFlowFile(dataPath) {
|
|
272
|
+
const root = getSingleRootValue(loadData(dataPath));
|
|
273
|
+
if (!isRecord(root) || !isRecord(root.screens))
|
|
274
|
+
return [];
|
|
275
|
+
const errors = [];
|
|
276
|
+
for (const [screenId, screenEntry] of Object.entries(root.screens)) {
|
|
277
|
+
if (!isRecord(screenEntry) || !isRecord(screenEntry.screen_inline))
|
|
278
|
+
continue;
|
|
279
|
+
errors.push(...lintScreenLikeDefinition(screenEntry.screen_inline, `${basename(dataPath)}/screens/${screenId}/screen_inline`));
|
|
280
|
+
}
|
|
281
|
+
return errors.map((e) => ({
|
|
282
|
+
file: basename(dataPath),
|
|
283
|
+
path: e.path,
|
|
284
|
+
message: e.message,
|
|
285
|
+
}));
|
|
286
|
+
}
|
|
287
|
+
// ── build Ajv instance with all schemas ──────────────────────────────
|
|
288
|
+
function buildAjv() {
|
|
289
|
+
const ajv = new Ajv2020({
|
|
290
|
+
strict: false,
|
|
291
|
+
allErrors: true,
|
|
292
|
+
verbose: true,
|
|
293
|
+
});
|
|
294
|
+
addFormats(ajv);
|
|
295
|
+
const schemaFiles = [
|
|
296
|
+
...listFiles(join(SCHEMA_DIR, "defs"), ".schema.json"),
|
|
297
|
+
...listFiles(join(SCHEMA_DIR, "tokens"), ".schema.json"),
|
|
298
|
+
...listFiles(SCHEMA_DIR, ".schema.json"),
|
|
299
|
+
];
|
|
300
|
+
const schemas = schemaFiles.map((f) => loadJson(f));
|
|
301
|
+
ajv.addSchema(schemas);
|
|
302
|
+
return ajv;
|
|
303
|
+
}
|
|
304
|
+
const BASE = "https://openuispec.rsteam.uz/schema/";
|
|
305
|
+
const TOKEN_FILE_SCHEMAS = {
|
|
306
|
+
"color.yaml": "color.schema.json",
|
|
307
|
+
"typography.yaml": "typography.schema.json",
|
|
308
|
+
"spacing.yaml": "spacing.schema.json",
|
|
309
|
+
"elevation.yaml": "elevation.schema.json",
|
|
310
|
+
"motion.yaml": "motion.schema.json",
|
|
311
|
+
"layout.yaml": "layout.schema.json",
|
|
312
|
+
"themes.yaml": "themes.schema.json",
|
|
313
|
+
"icons.yaml": "icons.schema.json",
|
|
314
|
+
};
|
|
315
|
+
// ── validate one file ────────────────────────────────────────────────
|
|
316
|
+
function validateFile(ajv, dataPath, schemaId, label) {
|
|
317
|
+
const name = label ?? basename(dataPath);
|
|
318
|
+
const data = loadData(dataPath);
|
|
319
|
+
const validate = ajv.getSchema(schemaId);
|
|
320
|
+
if (!validate) {
|
|
321
|
+
console.log(` SKIP ${name} (schema ${schemaId} not found)`);
|
|
322
|
+
return 1;
|
|
323
|
+
}
|
|
324
|
+
const valid = validate(data);
|
|
325
|
+
if (valid) {
|
|
326
|
+
console.log(` OK ${name}`);
|
|
327
|
+
return 0;
|
|
328
|
+
}
|
|
329
|
+
// Convert schema URL to a local path for display
|
|
330
|
+
const schemaRelPath = schemaId.replace(BASE, "");
|
|
331
|
+
const schemaLocalPath = resolve(SCHEMA_DIR, schemaRelPath);
|
|
332
|
+
const errors = validate.errors ?? [];
|
|
333
|
+
console.log(` FAIL ${name} (${errors.length} error(s))`);
|
|
334
|
+
for (const e of errors.slice(0, 5)) {
|
|
335
|
+
const instancePath = e.instancePath || "(root)";
|
|
336
|
+
console.log(` [${instancePath}] ${e.message}`);
|
|
337
|
+
if (e.params) {
|
|
338
|
+
const info = Object.entries(e.params)
|
|
339
|
+
.map(([k, v]) => `${k}=${String(v)}`)
|
|
340
|
+
.join(", ");
|
|
341
|
+
if (info)
|
|
342
|
+
console.log(` ${info}`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
if (errors.length > 5) {
|
|
346
|
+
console.log(` ... and ${errors.length - 5} more`);
|
|
347
|
+
}
|
|
348
|
+
// Show hint when root-level structure is wrong (missing wrapper key)
|
|
349
|
+
const hasRootRequired = errors.some((e) => !e.instancePath && e.keyword === "required");
|
|
350
|
+
const hasRootAdditional = errors.some((e) => !e.instancePath && e.keyword === "additionalProperties");
|
|
351
|
+
if (hasRootRequired || hasRootAdditional) {
|
|
352
|
+
const expectedKey = errors.find((e) => !e.instancePath && e.keyword === "required")?.params?.missingProperty;
|
|
353
|
+
if (expectedKey) {
|
|
354
|
+
console.log(`\n Hint: "${name}" needs a root "${expectedKey}:" wrapper key.`);
|
|
355
|
+
console.log(` Example:`);
|
|
356
|
+
console.log(` ${expectedKey}:`);
|
|
357
|
+
console.log(` ...your content here...`);
|
|
358
|
+
}
|
|
359
|
+
else {
|
|
360
|
+
console.log(`\n Hint: This file has unexpected top-level properties.`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
console.log(` Schema: ${schemaLocalPath}`);
|
|
364
|
+
return errors.length;
|
|
365
|
+
}
|
|
366
|
+
// ── includes resolution ──────────────────────────────────────────────
|
|
367
|
+
const DEFAULT_INCLUDES = {
|
|
368
|
+
tokens: "./tokens/",
|
|
369
|
+
contracts: "./contracts/",
|
|
370
|
+
components: "./components/",
|
|
371
|
+
screens: "./screens/",
|
|
372
|
+
flows: "./flows/",
|
|
373
|
+
platform: "./platform/",
|
|
374
|
+
locales: "./locales/",
|
|
375
|
+
};
|
|
376
|
+
function readIncludes(projectDir) {
|
|
377
|
+
const manifestPath = join(projectDir, "openuispec.yaml");
|
|
378
|
+
try {
|
|
379
|
+
const manifest = loadYaml(manifestPath);
|
|
380
|
+
const inc = manifest?.includes;
|
|
381
|
+
return { ...DEFAULT_INCLUDES, ...inc };
|
|
382
|
+
}
|
|
383
|
+
catch {
|
|
384
|
+
return DEFAULT_INCLUDES;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
function resolveInclude(projectDir, includePath) {
|
|
388
|
+
return resolve(projectDir, includePath);
|
|
389
|
+
}
|
|
390
|
+
const GROUPS = {
|
|
391
|
+
manifest: {
|
|
392
|
+
label: "Root manifest",
|
|
393
|
+
run(ajv, projectDir) {
|
|
394
|
+
return validateFile(ajv, join(projectDir, "openuispec.yaml"), `${BASE}openuispec.schema.json`);
|
|
395
|
+
},
|
|
396
|
+
collectJson(ajv, projectDir, _includes, groupKey) {
|
|
397
|
+
return {
|
|
398
|
+
group: groupKey,
|
|
399
|
+
errors: collectValidateFile(ajv, join(projectDir, "openuispec.yaml"), `${BASE}openuispec.schema.json`),
|
|
400
|
+
};
|
|
401
|
+
},
|
|
402
|
+
},
|
|
403
|
+
tokens: {
|
|
404
|
+
label: "Tokens",
|
|
405
|
+
run(ajv, projectDir, includes) {
|
|
406
|
+
let errors = 0;
|
|
407
|
+
const tokensDir = resolveInclude(projectDir, includes.tokens);
|
|
408
|
+
for (const [data, schema] of Object.entries(TOKEN_FILE_SCHEMAS)) {
|
|
409
|
+
const filePath = join(tokensDir, data);
|
|
410
|
+
if (existsSync(filePath)) {
|
|
411
|
+
errors += validateFile(ajv, filePath, `${BASE}tokens/${schema}`);
|
|
412
|
+
}
|
|
413
|
+
else {
|
|
414
|
+
console.log(` FAIL ${data} (required token file is missing)`);
|
|
415
|
+
errors += 1;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return errors;
|
|
419
|
+
},
|
|
420
|
+
collectJson(ajv, projectDir, includes, groupKey) {
|
|
421
|
+
const errors = [];
|
|
422
|
+
const tokensDir = resolveInclude(projectDir, includes.tokens);
|
|
423
|
+
for (const [data, schema] of Object.entries(TOKEN_FILE_SCHEMAS)) {
|
|
424
|
+
const filePath = join(tokensDir, data);
|
|
425
|
+
if (existsSync(filePath)) {
|
|
426
|
+
errors.push(...collectValidateFile(ajv, filePath, `${BASE}tokens/${schema}`));
|
|
427
|
+
}
|
|
428
|
+
else {
|
|
429
|
+
errors.push({
|
|
430
|
+
file: data,
|
|
431
|
+
path: "(root)",
|
|
432
|
+
message: "required token file is missing",
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return { group: groupKey, errors };
|
|
437
|
+
},
|
|
438
|
+
},
|
|
439
|
+
screens: {
|
|
440
|
+
label: "Screens",
|
|
441
|
+
run(ajv, projectDir, includes) {
|
|
442
|
+
let errors = 0;
|
|
443
|
+
const dir = resolveInclude(projectDir, includes.screens);
|
|
444
|
+
for (const f of listFiles(dir, ".yaml")) {
|
|
445
|
+
const schemaErrors = validateFile(ajv, f, `${BASE}screen.schema.json`);
|
|
446
|
+
errors += schemaErrors;
|
|
447
|
+
if (schemaErrors === 0) {
|
|
448
|
+
errors += lintScreenFile(f);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return errors;
|
|
452
|
+
},
|
|
453
|
+
collectJson(ajv, projectDir, includes, groupKey) {
|
|
454
|
+
const errors = [];
|
|
455
|
+
const dir = resolveInclude(projectDir, includes.screens);
|
|
456
|
+
for (const f of listFiles(dir, ".yaml")) {
|
|
457
|
+
const schemaErrors = collectValidateFile(ajv, f, `${BASE}screen.schema.json`);
|
|
458
|
+
errors.push(...schemaErrors);
|
|
459
|
+
if (schemaErrors.length === 0) {
|
|
460
|
+
errors.push(...collectLintScreenFile(f));
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
return { group: groupKey, errors };
|
|
464
|
+
},
|
|
465
|
+
},
|
|
466
|
+
flows: {
|
|
467
|
+
label: "Flows",
|
|
468
|
+
run(ajv, projectDir, includes) {
|
|
469
|
+
let errors = 0;
|
|
470
|
+
const dir = resolveInclude(projectDir, includes.flows);
|
|
471
|
+
for (const f of listFiles(dir, ".yaml")) {
|
|
472
|
+
const schemaErrors = validateFile(ajv, f, `${BASE}flow.schema.json`);
|
|
473
|
+
errors += schemaErrors;
|
|
474
|
+
if (schemaErrors === 0) {
|
|
475
|
+
errors += lintFlowFile(f);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return errors;
|
|
479
|
+
},
|
|
480
|
+
collectJson(ajv, projectDir, includes, groupKey) {
|
|
481
|
+
const errors = [];
|
|
482
|
+
const dir = resolveInclude(projectDir, includes.flows);
|
|
483
|
+
for (const f of listFiles(dir, ".yaml")) {
|
|
484
|
+
const schemaErrors = collectValidateFile(ajv, f, `${BASE}flow.schema.json`);
|
|
485
|
+
errors.push(...schemaErrors);
|
|
486
|
+
if (schemaErrors.length === 0) {
|
|
487
|
+
errors.push(...collectLintFlowFile(f));
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
return { group: groupKey, errors };
|
|
491
|
+
},
|
|
492
|
+
},
|
|
493
|
+
platform: {
|
|
494
|
+
label: "Platform",
|
|
495
|
+
run(ajv, projectDir, includes) {
|
|
496
|
+
let errors = 0;
|
|
497
|
+
const dir = resolveInclude(projectDir, includes.platform);
|
|
498
|
+
for (const f of listFiles(dir, ".yaml")) {
|
|
499
|
+
errors += validateFile(ajv, f, `${BASE}platform.schema.json`);
|
|
500
|
+
}
|
|
501
|
+
return errors;
|
|
502
|
+
},
|
|
503
|
+
collectJson(ajv, projectDir, includes, groupKey) {
|
|
504
|
+
const errors = [];
|
|
505
|
+
const dir = resolveInclude(projectDir, includes.platform);
|
|
506
|
+
for (const f of listFiles(dir, ".yaml")) {
|
|
507
|
+
errors.push(...collectValidateFile(ajv, f, `${BASE}platform.schema.json`));
|
|
508
|
+
}
|
|
509
|
+
return { group: groupKey, errors };
|
|
510
|
+
},
|
|
511
|
+
},
|
|
512
|
+
locales: {
|
|
513
|
+
label: "Locales",
|
|
514
|
+
run(ajv, projectDir, includes) {
|
|
515
|
+
let errors = 0;
|
|
516
|
+
const dir = resolveInclude(projectDir, includes.locales);
|
|
517
|
+
for (const f of listFiles(dir, ".json")) {
|
|
518
|
+
errors += validateFile(ajv, f, `${BASE}locale.schema.json`);
|
|
519
|
+
}
|
|
520
|
+
return errors;
|
|
521
|
+
},
|
|
522
|
+
collectJson(ajv, projectDir, includes, groupKey) {
|
|
523
|
+
const errors = [];
|
|
524
|
+
const dir = resolveInclude(projectDir, includes.locales);
|
|
525
|
+
for (const f of listFiles(dir, ".json")) {
|
|
526
|
+
errors.push(...collectValidateFile(ajv, f, `${BASE}locale.schema.json`));
|
|
527
|
+
}
|
|
528
|
+
return { group: groupKey, errors };
|
|
529
|
+
},
|
|
530
|
+
},
|
|
531
|
+
contracts: {
|
|
532
|
+
label: "Contracts",
|
|
533
|
+
run(ajv, projectDir, includes) {
|
|
534
|
+
let errors = 0;
|
|
535
|
+
const dir = resolveInclude(projectDir, includes.contracts);
|
|
536
|
+
for (const f of listFiles(dir, ".yaml")) {
|
|
537
|
+
const name = basename(f);
|
|
538
|
+
if (name.startsWith("x_")) {
|
|
539
|
+
errors += validateFile(ajv, f, `${BASE}custom-contract.schema.json`);
|
|
540
|
+
}
|
|
541
|
+
else {
|
|
542
|
+
errors += validateFile(ajv, f, `${BASE}contract.schema.json`);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
return errors;
|
|
546
|
+
},
|
|
547
|
+
collectJson(ajv, projectDir, includes, groupKey) {
|
|
548
|
+
const errors = [];
|
|
549
|
+
const dir = resolveInclude(projectDir, includes.contracts);
|
|
550
|
+
for (const f of listFiles(dir, ".yaml")) {
|
|
551
|
+
const name = basename(f);
|
|
552
|
+
if (name.startsWith("x_")) {
|
|
553
|
+
errors.push(...collectValidateFile(ajv, f, `${BASE}custom-contract.schema.json`));
|
|
554
|
+
}
|
|
555
|
+
else {
|
|
556
|
+
errors.push(...collectValidateFile(ajv, f, `${BASE}contract.schema.json`));
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
return { group: groupKey, errors };
|
|
560
|
+
},
|
|
561
|
+
},
|
|
562
|
+
components: {
|
|
563
|
+
label: "Components",
|
|
564
|
+
run(ajv, projectDir, includes) {
|
|
565
|
+
let errors = 0;
|
|
566
|
+
const dir = resolveInclude(projectDir, includes.components);
|
|
567
|
+
for (const f of listFiles(dir, ".yaml")) {
|
|
568
|
+
errors += validateFile(ajv, f, `${BASE}component.schema.json`);
|
|
569
|
+
}
|
|
570
|
+
return errors;
|
|
571
|
+
},
|
|
572
|
+
collectJson(ajv, projectDir, includes, groupKey) {
|
|
573
|
+
const errors = [];
|
|
574
|
+
const dir = resolveInclude(projectDir, includes.components);
|
|
575
|
+
for (const f of listFiles(dir, ".yaml")) {
|
|
576
|
+
errors.push(...collectValidateFile(ajv, f, `${BASE}component.schema.json`));
|
|
577
|
+
}
|
|
578
|
+
return { group: groupKey, errors };
|
|
579
|
+
},
|
|
580
|
+
},
|
|
581
|
+
semantic: {
|
|
582
|
+
label: "Semantic",
|
|
583
|
+
run(_ajv, projectDir, includes) {
|
|
584
|
+
return runSemanticLint(projectDir, includes);
|
|
585
|
+
},
|
|
586
|
+
collectJson(_ajv, projectDir, includes, groupKey) {
|
|
587
|
+
const lintErrors = collectSemanticLint(projectDir, includes);
|
|
588
|
+
return {
|
|
589
|
+
group: groupKey,
|
|
590
|
+
errors: lintErrors.map((e) => ({
|
|
591
|
+
file: e.path.includes("/") ? e.path.split("/")[0] : e.path,
|
|
592
|
+
path: e.path,
|
|
593
|
+
message: e.message,
|
|
594
|
+
})),
|
|
595
|
+
};
|
|
596
|
+
},
|
|
597
|
+
},
|
|
598
|
+
};
|
|
599
|
+
// ── project resolution ───────────────────────────────────────────────
|
|
600
|
+
function findProjectDir(cwd) {
|
|
601
|
+
const candidates = [
|
|
602
|
+
join(cwd, "openuispec"),
|
|
603
|
+
cwd,
|
|
604
|
+
];
|
|
605
|
+
for (const dir of candidates) {
|
|
606
|
+
if (existsSync(join(dir, "openuispec.yaml"))) {
|
|
607
|
+
return dir;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
// Fallback for running from repo root with examples/
|
|
611
|
+
const exampleCandidates = [
|
|
612
|
+
join(cwd, "examples", "taskflow", "openuispec"),
|
|
613
|
+
join(cwd, "examples", "taskflow"),
|
|
614
|
+
];
|
|
615
|
+
for (const dir of exampleCandidates) {
|
|
616
|
+
if (existsSync(join(dir, "openuispec.yaml"))) {
|
|
617
|
+
return dir;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
console.error("Error: No openuispec.yaml found.\n" +
|
|
621
|
+
"Run from a directory containing openuispec.yaml or an openuispec/ subdirectory.");
|
|
622
|
+
process.exit(1);
|
|
623
|
+
}
|
|
624
|
+
// ── main ─────────────────────────────────────────────────────────────
|
|
625
|
+
export { buildAjv, readIncludes, GROUPS };
|
|
626
|
+
export function buildValidateResult(groups, cwd = process.cwd()) {
|
|
627
|
+
const projectDir = findProjectDir(cwd);
|
|
628
|
+
const includes = readIncludes(projectDir);
|
|
629
|
+
const ajv = buildAjv();
|
|
630
|
+
const keys = groups && groups.length > 0
|
|
631
|
+
? groups.filter((k) => k in GROUPS)
|
|
632
|
+
: Object.keys(GROUPS);
|
|
633
|
+
const results = [];
|
|
634
|
+
let totalErrors = 0;
|
|
635
|
+
for (const key of keys) {
|
|
636
|
+
const result = GROUPS[key].collectJson(ajv, projectDir, includes, key);
|
|
637
|
+
results.push(result);
|
|
638
|
+
totalErrors += result.errors.length;
|
|
639
|
+
}
|
|
640
|
+
return { total_errors: totalErrors, groups: results };
|
|
641
|
+
}
|
|
642
|
+
export function runValidate(argv) {
|
|
643
|
+
const jsonMode = argv.includes("--json");
|
|
644
|
+
const filteredArgs = argv.filter((a) => a !== "--json");
|
|
645
|
+
const selected = filteredArgs.length > 0
|
|
646
|
+
? filteredArgs.filter((a) => a in GROUPS)
|
|
647
|
+
: Object.keys(GROUPS);
|
|
648
|
+
if (selected.length === 0) {
|
|
649
|
+
console.error(`Unknown group(s). Available: ${Object.keys(GROUPS).join(", ")}`);
|
|
650
|
+
process.exit(1);
|
|
651
|
+
}
|
|
652
|
+
const projectDir = findProjectDir(process.cwd());
|
|
653
|
+
const includes = readIncludes(projectDir);
|
|
654
|
+
const ajv = buildAjv();
|
|
655
|
+
if (jsonMode) {
|
|
656
|
+
const groups = [];
|
|
657
|
+
let totalErrors = 0;
|
|
658
|
+
for (const key of selected) {
|
|
659
|
+
const result = GROUPS[key].collectJson(ajv, projectDir, includes, key);
|
|
660
|
+
groups.push(result);
|
|
661
|
+
totalErrors += result.errors.length;
|
|
662
|
+
}
|
|
663
|
+
console.log(JSON.stringify({ groups, total_errors: totalErrors }, null, 2));
|
|
664
|
+
if (totalErrors > 0) {
|
|
665
|
+
process.exit(2);
|
|
666
|
+
}
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
let totalErrors = 0;
|
|
670
|
+
for (const key of selected) {
|
|
671
|
+
const group = GROUPS[key];
|
|
672
|
+
console.log(`\n${group.label}:`);
|
|
673
|
+
totalErrors += group.run(ajv, projectDir, includes);
|
|
674
|
+
}
|
|
675
|
+
console.log(`\n${"=".repeat(50)}`);
|
|
676
|
+
if (totalErrors > 0) {
|
|
677
|
+
console.log(`FAILED: ${totalErrors} total validation error(s)`);
|
|
678
|
+
process.exit(2);
|
|
679
|
+
}
|
|
680
|
+
else {
|
|
681
|
+
console.log("ALL PASSED: Every example file validates successfully");
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
// Direct execution
|
|
685
|
+
const isDirectRun = process.argv[1]?.endsWith("validate.ts") ||
|
|
686
|
+
process.argv[1]?.endsWith("validate.js");
|
|
687
|
+
if (isDirectRun) {
|
|
688
|
+
runValidate(process.argv.slice(2));
|
|
689
|
+
}
|