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