openuispec 0.2.19 → 0.2.21

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 (38) hide show
  1. package/dist/check/audit.js +392 -0
  2. package/dist/check/index.js +216 -0
  3. package/dist/cli/configure-target.js +391 -0
  4. package/dist/cli/index.js +510 -0
  5. package/dist/cli/init.js +964 -0
  6. package/dist/drift/index.js +903 -0
  7. package/dist/mcp-server/index.js +888 -0
  8. package/dist/mcp-server/preview-render.js +1761 -0
  9. package/dist/mcp-server/preview.js +229 -0
  10. package/dist/mcp-server/screenshot-android.js +458 -0
  11. package/dist/mcp-server/screenshot-ios.js +639 -0
  12. package/dist/mcp-server/screenshot-shared.js +185 -0
  13. package/dist/mcp-server/screenshot.js +469 -0
  14. package/dist/prepare/index.js +1216 -0
  15. package/dist/runtime/package-paths.js +33 -0
  16. package/dist/schema/semantic-lint.js +564 -0
  17. package/dist/schema/validate.js +689 -0
  18. package/dist/status/index.js +194 -0
  19. package/package.json +13 -14
  20. package/check/audit.ts +0 -426
  21. package/check/index.ts +0 -320
  22. package/cli/configure-target.ts +0 -523
  23. package/cli/index.ts +0 -537
  24. package/cli/init.ts +0 -1253
  25. package/drift/index.ts +0 -1165
  26. package/mcp-server/index.ts +0 -1041
  27. package/mcp-server/preview-render.ts +0 -1922
  28. package/mcp-server/preview.ts +0 -292
  29. package/mcp-server/screenshot-android.ts +0 -621
  30. package/mcp-server/screenshot-ios.ts +0 -753
  31. package/mcp-server/screenshot-shared.ts +0 -237
  32. package/mcp-server/screenshot.ts +0 -563
  33. package/prepare/index.ts +0 -1530
  34. package/schema/semantic-lint.ts +0 -692
  35. package/schema/validate.ts +0 -870
  36. package/scripts/regenerate-previews.ts +0 -136
  37. package/scripts/take-all-screenshots.ts +0 -507
  38. package/status/index.ts +0 -275
@@ -0,0 +1,392 @@
1
+ /**
2
+ * Design quality audit for OpenUISpec projects.
3
+ *
4
+ * Checks token patterns and contract completeness to produce a numeric quality score.
5
+ * Score formula: max(0, 100 - errors × 10 - warnings × 3)
6
+ *
7
+ * Usage:
8
+ * openuispec check --target web --audit
9
+ * openuispec check --target ios --audit --min-score 70
10
+ * openuispec check --target web --audit --format json
11
+ */
12
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
13
+ import { join, resolve } from "node:path";
14
+ import YAML from "yaml";
15
+ const AI_DEFAULT_FONTS = new Set(["Inter", "Roboto", "Arial", "Open Sans"]);
16
+ const REQUIRED_TOKEN_FILES = [
17
+ "color.yaml",
18
+ "typography.yaml",
19
+ "spacing.yaml",
20
+ "elevation.yaml",
21
+ "motion.yaml",
22
+ "layout.yaml",
23
+ "themes.yaml",
24
+ "icons.yaml",
25
+ ];
26
+ function readYaml(path) {
27
+ try {
28
+ return YAML.parse(readFileSync(path, "utf-8"));
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ }
34
+ function readYamlForAudit(path, domain, findings) {
35
+ try {
36
+ return YAML.parse(readFileSync(path, "utf-8"));
37
+ }
38
+ catch (err) {
39
+ if (err?.code === "ENOENT")
40
+ return null;
41
+ findings.push({
42
+ domain,
43
+ rule: "unreadable_file",
44
+ severity: "error",
45
+ message: `Could not parse "${path}". Fix malformed YAML before relying on this audit.`,
46
+ });
47
+ return null;
48
+ }
49
+ }
50
+ function checkRequiredTokenFiles(tokensDir, findings) {
51
+ for (const filename of REQUIRED_TOKEN_FILES) {
52
+ if (!existsSync(join(tokensDir, filename))) {
53
+ findings.push({
54
+ domain: "tokens",
55
+ rule: "missing_file",
56
+ severity: "error",
57
+ message: `Required token file "${filename}" is missing.`,
58
+ });
59
+ }
60
+ }
61
+ }
62
+ function checkTypography(tokensDir, findings) {
63
+ const doc = readYamlForAudit(join(tokensDir, "typography.yaml"), "typography", findings);
64
+ if (!doc?.typography)
65
+ return;
66
+ // Font diversity: primary must NOT be a common AI default
67
+ const primaryFont = doc.typography.font_family?.primary?.value;
68
+ if (typeof primaryFont === "string" && AI_DEFAULT_FONTS.has(primaryFont)) {
69
+ findings.push({
70
+ domain: "typography",
71
+ rule: "font_diversity",
72
+ severity: "error",
73
+ message: `Primary font "${primaryFont}" is an AI-default choice. Use a distinctive brand font.`,
74
+ });
75
+ }
76
+ // Scale usage: at least 4 distinct scale levels defined
77
+ const scaleKeys = Object.keys(doc.typography.scale ?? {});
78
+ if (scaleKeys.length > 0 && scaleKeys.length < 4) {
79
+ findings.push({
80
+ domain: "typography",
81
+ rule: "scale_usage",
82
+ severity: "warning",
83
+ message: `Only ${scaleKeys.length} type scale level(s) defined. Use ≥4 distinct levels for clear hierarchy.`,
84
+ });
85
+ }
86
+ // Weight hierarchy: at least 2 distinct weights
87
+ if (doc.typography.scale) {
88
+ const weights = new Set();
89
+ for (const level of Object.values(doc.typography.scale)) {
90
+ if (typeof level?.weight === "number") {
91
+ weights.add(level.weight);
92
+ }
93
+ }
94
+ if (weights.size > 0 && weights.size < 2) {
95
+ findings.push({
96
+ domain: "typography",
97
+ rule: "weight_hierarchy",
98
+ severity: "warning",
99
+ message: "Only 1 distinct font weight used across the type scale. Use ≥2 weights (e.g. 400 + 700) for clear hierarchy.",
100
+ });
101
+ }
102
+ }
103
+ }
104
+ function checkColor(tokensDir, findings) {
105
+ const doc = readYamlForAudit(join(tokensDir, "color.yaml"), "color", findings);
106
+ if (!doc?.color)
107
+ return;
108
+ // Pure black/white check
109
+ function scanForPure(obj, path) {
110
+ if (typeof obj !== "object" || obj === null)
111
+ return;
112
+ if (typeof obj.reference === "string") {
113
+ const ref = obj.reference.toUpperCase();
114
+ if (ref === "#000000" || ref === "#000") {
115
+ findings.push({
116
+ domain: "color",
117
+ rule: "pure_black",
118
+ severity: "error",
119
+ message: `Token at ${path} uses pure black (#000000). Use a near-black with hue instead.`,
120
+ });
121
+ }
122
+ if (ref === "#FFFFFF" || ref === "#FFF") {
123
+ findings.push({
124
+ domain: "color",
125
+ rule: "pure_white",
126
+ severity: "error",
127
+ message: `Token at ${path} uses pure white (#FFFFFF). Use a slightly tinted white instead.`,
128
+ });
129
+ }
130
+ }
131
+ for (const [key, value] of Object.entries(obj)) {
132
+ if (key !== "reference" && typeof value === "object") {
133
+ scanForPure(value, `${path}.${key}`);
134
+ }
135
+ }
136
+ }
137
+ scanForPure(doc.color, "color");
138
+ // Semantic color completeness: success, warning, danger, info
139
+ if (doc.color.semantic) {
140
+ const required = ["success", "warning", "danger", "info"];
141
+ const defined = Object.keys(doc.color.semantic);
142
+ for (const name of required) {
143
+ if (!defined.includes(name)) {
144
+ findings.push({
145
+ domain: "color",
146
+ rule: "semantic_completeness",
147
+ severity: "warning",
148
+ message: `Semantic color "${name}" is missing. Define all four (success, warning, danger, info) for complete state coverage.`,
149
+ });
150
+ }
151
+ }
152
+ }
153
+ // Theme coverage: check themes.yaml for both light + dark
154
+ {
155
+ const themes = readYamlForAudit(join(tokensDir, "themes.yaml"), "color", findings);
156
+ if (!themes?.themes)
157
+ return;
158
+ const themeKeys = Object.keys(themes.themes);
159
+ const hasLight = themeKeys.some((k) => k.includes("light"));
160
+ const hasDark = themeKeys.some((k) => k.includes("dark"));
161
+ if (!hasLight || !hasDark) {
162
+ findings.push({
163
+ domain: "color",
164
+ rule: "theme_coverage",
165
+ severity: "warning",
166
+ message: "Both light and dark themes should be defined in tokens/themes.yaml.",
167
+ });
168
+ }
169
+ }
170
+ }
171
+ function checkSpacing(tokensDir, findings) {
172
+ const doc = readYamlForAudit(join(tokensDir, "spacing.yaml"), "spacing", findings);
173
+ if (!doc?.spacing)
174
+ return;
175
+ // Scale usage: at least 4 distinct values
176
+ const scale = doc.spacing.scale ?? {};
177
+ const scaleCount = Object.keys(scale).length;
178
+ if (scaleCount > 0 && scaleCount < 4) {
179
+ findings.push({
180
+ domain: "spacing",
181
+ rule: "scale_usage",
182
+ severity: "warning",
183
+ message: `Only ${scaleCount} spacing scale value(s) defined. Define ≥4 for meaningful spatial rhythm.`,
184
+ });
185
+ }
186
+ // Alias usage: page_margin and card_padding should exist
187
+ const aliases = doc.spacing.aliases ?? {};
188
+ const aliasKeys = Object.keys(aliases).map((k) => k.toLowerCase());
189
+ if (!aliasKeys.some((k) => k.includes("page_margin") || k.includes("page"))) {
190
+ findings.push({
191
+ domain: "spacing",
192
+ rule: "alias_page_margin",
193
+ severity: "warning",
194
+ message: "No page_margin alias found in spacing tokens. Define it for consistent screen padding.",
195
+ });
196
+ }
197
+ if (!aliasKeys.some((k) => k.includes("card_padding") || k.includes("card"))) {
198
+ findings.push({
199
+ domain: "spacing",
200
+ rule: "alias_card_padding",
201
+ severity: "warning",
202
+ message: "No card_padding alias found in spacing tokens. Define it for consistent card spacing.",
203
+ });
204
+ }
205
+ }
206
+ function checkMotion(tokensDir, findings) {
207
+ const doc = readYamlForAudit(join(tokensDir, "motion.yaml"), "motion", findings);
208
+ if (!doc?.motion)
209
+ return;
210
+ // Duration variety: at least 2 distinct durations
211
+ const durations = doc.motion.duration ?? {};
212
+ const distinctDurations = new Set(Object.values(durations));
213
+ if (Object.keys(durations).length > 0 && distinctDurations.size < 2) {
214
+ findings.push({
215
+ domain: "motion",
216
+ rule: "duration_variety",
217
+ severity: "warning",
218
+ message: "Only 1 distinct duration value found. Use ≥2 distinct durations (e.g. quick + normal).",
219
+ });
220
+ }
221
+ // Reduced motion: must be defined
222
+ if (!doc.motion.reduced_motion) {
223
+ findings.push({
224
+ domain: "motion",
225
+ rule: "reduced_motion",
226
+ severity: "error",
227
+ message: "motion.reduced_motion is not defined. Must specify policy for prefers-reduced-motion.",
228
+ });
229
+ }
230
+ // Easing quality: enter + exit curves, at least one cubic-bezier
231
+ if (doc.motion.easing) {
232
+ const easings = doc.motion.easing;
233
+ const keys = Object.keys(easings);
234
+ if (!keys.includes("enter") || !keys.includes("exit")) {
235
+ findings.push({
236
+ domain: "motion",
237
+ rule: "easing_quality",
238
+ severity: "warning",
239
+ message: "Motion easing should define at least 'enter' and 'exit' curves for asymmetric transitions.",
240
+ });
241
+ }
242
+ const hasCubicBezier = Object.values(easings).some((v) => typeof v === "string" && v.includes("cubic-bezier"));
243
+ if (!hasCubicBezier) {
244
+ findings.push({
245
+ domain: "motion",
246
+ rule: "easing_quality",
247
+ severity: "warning",
248
+ message: "All easing curves are generic keywords. Use at least one cubic-bezier() for nuanced motion.",
249
+ });
250
+ }
251
+ }
252
+ }
253
+ function checkElevationProgression(tokensDir, findings) {
254
+ const doc = readYamlForAudit(join(tokensDir, "elevation.yaml"), "elevation", findings);
255
+ if (!doc?.elevation)
256
+ return;
257
+ const levels = Object.keys(doc.elevation).filter((k) => k !== "none");
258
+ if (levels.length < 2) {
259
+ findings.push({
260
+ domain: "elevation",
261
+ rule: "level_count",
262
+ severity: "warning",
263
+ message: `Only ${levels.length} non-none elevation level(s) defined. Define ≥2 (e.g. sm, md, lg) for meaningful depth hierarchy.`,
264
+ });
265
+ return;
266
+ }
267
+ const androidValues = [];
268
+ for (const level of levels) {
269
+ const val = doc.elevation[level]?.platform?.android?.elevation;
270
+ if (typeof val === "number")
271
+ androidValues.push(val);
272
+ }
273
+ if (androidValues.length >= 2) {
274
+ for (let i = 1; i < androidValues.length; i++) {
275
+ if (androidValues[i] <= androidValues[i - 1]) {
276
+ findings.push({
277
+ domain: "elevation",
278
+ rule: "progression",
279
+ severity: "warning",
280
+ message: "Elevation levels do not increase monotonically. Each level should cast a deeper shadow than the previous.",
281
+ });
282
+ break;
283
+ }
284
+ }
285
+ }
286
+ }
287
+ function checkLayoutSizeClasses(tokensDir, findings) {
288
+ const doc = readYamlForAudit(join(tokensDir, "layout.yaml"), "layout", findings);
289
+ if (!doc?.layout?.size_classes)
290
+ return;
291
+ const classes = Object.keys(doc.layout.size_classes);
292
+ if (classes.length < 2) {
293
+ findings.push({
294
+ domain: "layout",
295
+ rule: "size_class_coverage",
296
+ severity: "warning",
297
+ message: `Only ${classes.length} size class(es) defined. Define at least compact + regular for responsive layouts.`,
298
+ });
299
+ }
300
+ else if (!classes.includes("compact")) {
301
+ findings.push({
302
+ domain: "layout",
303
+ rule: "size_class_coverage",
304
+ severity: "warning",
305
+ message: "No 'compact' size class defined. Mobile-first layouts require a compact breakpoint.",
306
+ });
307
+ }
308
+ }
309
+ function checkContractStateCoverage(contractsDir, findings) {
310
+ if (!existsSync(contractsDir))
311
+ return;
312
+ for (const file of readdirSync(contractsDir).filter((f) => f.endsWith(".yaml") && !f.startsWith("x_"))) {
313
+ const doc = readYamlForAudit(join(contractsDir, file), "contracts", findings);
314
+ if (!doc)
315
+ continue;
316
+ const contractName = Object.keys(doc)[0];
317
+ const contract = doc[contractName];
318
+ const mustHandle = contract?.generation?.must_handle ?? [];
319
+ if (mustHandle.length === 0) {
320
+ findings.push({
321
+ domain: "contracts",
322
+ rule: "state_coverage",
323
+ severity: "warning",
324
+ message: `Contract "${contractName}" has no generation.must_handle entries. Define required states for AI compliance.`,
325
+ });
326
+ }
327
+ }
328
+ }
329
+ function checkContracts(contractsDir, findings) {
330
+ // All collections have empty_state in must_handle or variants
331
+ {
332
+ const doc = readYamlForAudit(join(contractsDir, "collection.yaml"), "contracts", findings);
333
+ const collection = doc ? doc[Object.keys(doc)[0]] : null;
334
+ if (collection) {
335
+ const mustHandle = collection.generation?.must_handle ?? [];
336
+ const hasEmptyState = mustHandle.some((s) => s.toLowerCase().includes("empty"));
337
+ if (!hasEmptyState) {
338
+ findings.push({
339
+ domain: "contracts",
340
+ rule: "collection_empty_state",
341
+ severity: "warning",
342
+ message: "collection contract does not list empty_state handling in generation.must_handle.",
343
+ });
344
+ }
345
+ }
346
+ }
347
+ }
348
+ export function buildAuditResult(projectDir, threshold = 0) {
349
+ const manifest = readYaml(join(projectDir, "openuispec.yaml"));
350
+ const tokensDir = resolve(projectDir, manifest?.includes?.tokens ?? "./tokens/");
351
+ const contractsDir = resolve(projectDir, manifest?.includes?.contracts ?? "./contracts/");
352
+ // Use audit_threshold from manifest if no CLI override
353
+ const effectiveThreshold = threshold > 0 ? threshold : (manifest?.generation_guidance?.audit_threshold ?? 0);
354
+ const findings = [];
355
+ checkRequiredTokenFiles(tokensDir, findings);
356
+ checkTypography(tokensDir, findings);
357
+ checkColor(tokensDir, findings);
358
+ checkSpacing(tokensDir, findings);
359
+ checkMotion(tokensDir, findings);
360
+ checkElevationProgression(tokensDir, findings);
361
+ checkLayoutSizeClasses(tokensDir, findings);
362
+ checkContracts(contractsDir, findings);
363
+ checkContractStateCoverage(contractsDir, findings);
364
+ const errors = findings.filter((f) => f.severity === "error").length;
365
+ const warnings = findings.filter((f) => f.severity === "warning").length;
366
+ const score = Math.max(0, 100 - errors * 10 - warnings * 3);
367
+ return {
368
+ score,
369
+ errors,
370
+ warnings,
371
+ findings,
372
+ passed: score >= effectiveThreshold,
373
+ threshold: effectiveThreshold,
374
+ };
375
+ }
376
+ export function formatAuditResult(result) {
377
+ const lines = [
378
+ `Design Quality Score: ${result.score}/100`,
379
+ `Errors: ${result.errors} Warnings: ${result.warnings}`,
380
+ result.threshold > 0 ? `Threshold: ${result.threshold} — ${result.passed ? "PASS" : "FAIL"}` : "",
381
+ "",
382
+ ].filter((l) => l !== "" || lines?.length === 0);
383
+ if (result.findings.length === 0) {
384
+ lines.push("No issues found.");
385
+ }
386
+ else {
387
+ for (const f of result.findings) {
388
+ lines.push(`[${f.severity.toUpperCase()}] [${f.domain}] ${f.message}`);
389
+ }
390
+ }
391
+ return lines.join("\n");
392
+ }
@@ -0,0 +1,216 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Composite check command for OpenUISpec projects.
4
+ *
5
+ * Combines schema validation, semantic linting, and prepare readiness
6
+ * into a single call for AI agents.
7
+ *
8
+ * Usage:
9
+ * openuispec check --target web # human-readable summary
10
+ * openuispec check --target ios --json # machine-readable output
11
+ */
12
+ import { existsSync, readFileSync } from "node:fs";
13
+ import { join, resolve } from "node:path";
14
+ import YAML from "yaml";
15
+ import { computeSharedDrift, findProjectDir, hasDriftChanges, readManifest, readProjectName, resolveOutputDir, sharedLayersForTarget, } from "../drift/index.js";
16
+ import { buildAjv, readIncludes, GROUPS, } from "../schema/validate.js";
17
+ import { collectSemanticLint } from "../schema/semantic-lint.js";
18
+ import { buildAuditResult, formatAuditResult } from './audit.js';
19
+ // ── prepare readiness helpers ─────────────────────────────────────────
20
+ const PRESENTATION_ONLY_KEYS = new Set(["naming", "bundler", "css"]);
21
+ function platformStackKeys(target) {
22
+ switch (target) {
23
+ case "android":
24
+ return ["architecture", "state", "preferences", "database", "di", "naming"];
25
+ case "web":
26
+ return ["runtime", "routing", "state", "storage_backend", "bundler", "css", "naming"];
27
+ case "ios":
28
+ return ["architecture", "persistence", "di", "naming"];
29
+ default:
30
+ return [];
31
+ }
32
+ }
33
+ function requiredPlatformDecisionKeys(target) {
34
+ return platformStackKeys(target).filter((key) => !PRESENTATION_ONLY_KEYS.has(key));
35
+ }
36
+ function readPlatformDefinition(projectDir, manifest, target) {
37
+ const platformDir = resolve(projectDir, manifest.includes?.platform ?? "./platform/");
38
+ const platformPath = join(platformDir, `${target}.yaml`);
39
+ if (!existsSync(platformPath))
40
+ return {};
41
+ try {
42
+ const doc = YAML.parse(readFileSync(platformPath, "utf-8"));
43
+ return doc?.[target] ?? {};
44
+ }
45
+ catch {
46
+ return {};
47
+ }
48
+ }
49
+ function missingPlatformDecisions(target, platformDef) {
50
+ const generation = platformDef.generation ?? {};
51
+ return requiredPlatformDecisionKeys(target).filter((key) => {
52
+ const value = generation[key];
53
+ return typeof value !== "string" || value.trim().length === 0;
54
+ });
55
+ }
56
+ function hasApiEndpoints(manifest) {
57
+ const endpoints = manifest.api?.endpoints;
58
+ return typeof endpoints === "object" && endpoints !== null && Object.keys(endpoints).length > 0;
59
+ }
60
+ function resolveBackendRoot(projectDir, manifest) {
61
+ const backendRoot = manifest.generation?.code_roots?.backend;
62
+ if (typeof backendRoot !== "string" || backendRoot.trim().length === 0) {
63
+ return null;
64
+ }
65
+ return resolve(projectDir, backendRoot);
66
+ }
67
+ function determinePrepare(projectDir, projectName, target) {
68
+ const manifest = readManifest(projectDir);
69
+ const outputDir = resolveOutputDir(projectDir, projectName, target);
70
+ const statePath = join(outputDir, ".openuispec-state.json");
71
+ const snapshotExists = existsSync(statePath);
72
+ const mode = snapshotExists ? "update" : "bootstrap";
73
+ const platformDef = readPlatformDefinition(projectDir, manifest, target);
74
+ const missing = missingPlatformDecisions(target, platformDef);
75
+ const warnings = [];
76
+ const backendContextRequired = hasApiEndpoints(manifest);
77
+ const backendRoot = resolveBackendRoot(projectDir, manifest);
78
+ const backendContextReady = !backendContextRequired || (backendRoot !== null && existsSync(backendRoot));
79
+ if (!backendContextReady) {
80
+ warnings.push("api endpoints require generation.code_roots.backend to point at an existing backend folder");
81
+ }
82
+ const stackConfirmation = platformDef.generation?.stack_confirmation;
83
+ const pendingUserConfirmation = typeof stackConfirmation === "string" && stackConfirmation !== "confirmed";
84
+ if (pendingUserConfirmation) {
85
+ warnings.push(`Target stack for "${target}" requires explicit user confirmation before implementation.`);
86
+ }
87
+ // Check for shared layer drift (only when tracks are configured)
88
+ const sharedLayers = sharedLayersForTarget(projectDir, target);
89
+ for (const layer of sharedLayers) {
90
+ if (layer.tracks.length === 0)
91
+ continue;
92
+ const driftResult = computeSharedDrift(projectDir, layer);
93
+ if (driftResult.state !== null) {
94
+ if (hasDriftChanges(driftResult.drift)) {
95
+ warnings.push(`Shared layer "${layer.name}" has spec drift — shared code may need updates before ${target} generation.`);
96
+ }
97
+ }
98
+ }
99
+ const ready = missing.length === 0 && backendContextReady && !pendingUserConfirmation;
100
+ return { mode, ready, missing, warnings };
101
+ }
102
+ // ── core (importable, no process.exit) ───────────────────────────────
103
+ export function buildCheckResult(target, cwd = process.cwd(), includeAudit = false) {
104
+ const projectDir = findProjectDir(cwd);
105
+ const projectName = readProjectName(projectDir);
106
+ const includes = readIncludes(projectDir);
107
+ const ajv = buildAjv();
108
+ // 1. Schema validation (all groups except semantic)
109
+ const schemaGroupKeys = Object.keys(GROUPS).filter((k) => k !== "semantic");
110
+ const validationGroups = [];
111
+ let validationTotalErrors = 0;
112
+ for (const key of schemaGroupKeys) {
113
+ const result = GROUPS[key].collectJson(ajv, projectDir, includes, key);
114
+ validationGroups.push(result);
115
+ validationTotalErrors += result.errors.length;
116
+ }
117
+ const validation = {
118
+ total_errors: validationTotalErrors,
119
+ groups: validationGroups,
120
+ };
121
+ // 2. Semantic validation
122
+ const semanticErrors = collectSemanticLint(projectDir, includes);
123
+ const semantic = {
124
+ total_errors: semanticErrors.length,
125
+ errors: semanticErrors.map((e) => ({ path: e.path, message: e.message })),
126
+ };
127
+ // 3. Prepare readiness
128
+ const prepare = determinePrepare(projectDir, projectName, target);
129
+ const audit = includeAudit ? buildAuditResult(projectDir) : undefined;
130
+ return { target, validation, semantic, prepare, audit };
131
+ }
132
+ // ── main ──────────────────────────────────────────────────────────────
133
+ export function runCheck(argv) {
134
+ const isJson = argv.includes("--json");
135
+ const targetIdx = argv.indexOf("--target");
136
+ const target = targetIdx !== -1 && argv[targetIdx + 1] ? argv[targetIdx + 1] : null;
137
+ if (!target) {
138
+ console.error("Error: --target is required for check");
139
+ console.error("Usage: openuispec check --target <target> [--json]");
140
+ process.exit(1);
141
+ }
142
+ const includeAudit = argv.includes("--audit");
143
+ const minScoreIdx = argv.indexOf("--min-score");
144
+ const minScore = minScoreIdx !== -1 && argv[minScoreIdx + 1] ? parseInt(argv[minScoreIdx + 1], 10) : 0;
145
+ const result = buildCheckResult(target, undefined, includeAudit);
146
+ if (isJson) {
147
+ console.log(JSON.stringify(result, null, 2));
148
+ }
149
+ else {
150
+ printReport(result);
151
+ if (result.audit) {
152
+ console.log("\nDesign Quality Audit");
153
+ console.log("====================");
154
+ console.log(formatAuditResult(result.audit));
155
+ if (minScore > 0 && result.audit.score < minScore) {
156
+ console.error(`\nFAIL: Score ${result.audit.score} is below --min-score ${minScore}`);
157
+ process.exit(1);
158
+ }
159
+ }
160
+ }
161
+ // Exit codes: 0 = clean + ready, 2 = validation errors, 1 = config error
162
+ const totalErrors = result.validation.total_errors + result.semantic.total_errors;
163
+ if (totalErrors > 0) {
164
+ process.exit(2);
165
+ }
166
+ if (!result.prepare.ready) {
167
+ process.exit(2);
168
+ }
169
+ process.exit(0);
170
+ }
171
+ function printReport(result) {
172
+ console.log("OpenUISpec Check");
173
+ console.log("================");
174
+ console.log(`Target: ${result.target}`);
175
+ const totalValidation = result.validation.total_errors;
176
+ const totalSemantic = result.semantic.total_errors;
177
+ console.log(`\nSchema validation: ${totalValidation === 0 ? "PASS" : `FAIL (${totalValidation} error(s))`}`);
178
+ if (totalValidation > 0) {
179
+ for (const group of result.validation.groups) {
180
+ if (group.errors.length > 0) {
181
+ console.log(` ${group.group}: ${group.errors.length} error(s)`);
182
+ for (const err of group.errors.slice(0, 3)) {
183
+ console.log(` [${err.file}] ${err.path}: ${err.message}`);
184
+ }
185
+ if (group.errors.length > 3) {
186
+ console.log(` ... and ${group.errors.length - 3} more`);
187
+ }
188
+ }
189
+ }
190
+ }
191
+ console.log(`Semantic lint: ${totalSemantic === 0 ? "PASS" : `FAIL (${totalSemantic} error(s))`}`);
192
+ if (totalSemantic > 0) {
193
+ for (const err of result.semantic.errors.slice(0, 5)) {
194
+ console.log(` [${err.path}] ${err.message}`);
195
+ }
196
+ if (result.semantic.errors.length > 5) {
197
+ console.log(` ... and ${result.semantic.errors.length - 5} more`);
198
+ }
199
+ }
200
+ console.log(`\nPrepare readiness: ${result.prepare.ready ? "READY" : "NOT READY"}`);
201
+ console.log(` Mode: ${result.prepare.mode}`);
202
+ if (result.prepare.missing.length > 0) {
203
+ console.log(` Missing platform decisions: ${result.prepare.missing.join(", ")}`);
204
+ }
205
+ for (const w of result.prepare.warnings) {
206
+ console.log(` Warning: ${w}`);
207
+ }
208
+ const totalErrors = result.validation.total_errors + result.semantic.total_errors;
209
+ console.log(`\nResult: ${totalErrors === 0 && result.prepare.ready ? "ALL CLEAR" : "ACTION NEEDED"}`);
210
+ }
211
+ // Direct execution
212
+ const isDirectRun = process.argv[1]?.endsWith("check/index.ts") ||
213
+ process.argv[1]?.endsWith("check/index.js");
214
+ if (isDirectRun) {
215
+ runCheck(process.argv.slice(2));
216
+ }