openuispec 0.1.28 → 0.1.30

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.
@@ -111,6 +111,16 @@
111
111
  "type": "string"
112
112
  }
113
113
  },
114
+ "code_roots": {
115
+ "type": "object",
116
+ "description": "Additional project code roots used as generation context.",
117
+ "properties": {
118
+ "backend": {
119
+ "type": "string"
120
+ }
121
+ },
122
+ "additionalProperties": false
123
+ },
114
124
  "output_format": {
115
125
  "type": "object",
116
126
  "description": "Per-target generation config",
@@ -1,6 +1,7 @@
1
- import { existsSync, readFileSync, readdirSync } from "node:fs";
1
+ import { existsSync, readFileSync } from "node:fs";
2
2
  import { basename, join, resolve } from "node:path";
3
3
  import YAML from "yaml";
4
+ import { listFiles, readManifest } from "../drift/index.js";
4
5
 
5
6
  type UnknownRecord = Record<string, unknown>;
6
7
 
@@ -19,6 +20,7 @@ export interface UsageLint {
19
20
  }
20
21
 
21
22
  interface SemanticContext {
23
+ manifest: unknown;
22
24
  localeFiles: Map<string, Set<string>>;
23
25
  formatterNames: Set<string>;
24
26
  mapperNames: Set<string>;
@@ -66,17 +68,6 @@ function loadData(filePath: string): unknown {
66
68
  return filePath.endsWith(".json") ? loadJson(filePath) : loadYaml(filePath);
67
69
  }
68
70
 
69
- function listFiles(dir: string, ext: string): string[] {
70
- try {
71
- return readdirSync(dir)
72
- .filter((file) => file.endsWith(ext))
73
- .sort()
74
- .map((file) => join(dir, file));
75
- } catch {
76
- return [];
77
- }
78
- }
79
-
80
71
  function isRecord(value: unknown): value is UnknownRecord {
81
72
  return typeof value === "object" && value !== null && !Array.isArray(value);
82
73
  }
@@ -213,9 +204,7 @@ function collectIconRefs(filePath: string): { refs: Set<string>; suffixes: strin
213
204
  return { refs, suffixes };
214
205
  }
215
206
 
216
- function buildContext(projectDir: string, includes: Includes): SemanticContext {
217
- const manifestPath = join(projectDir, "openuispec.yaml");
218
- const manifest = loadYaml(manifestPath) as UnknownRecord;
207
+ function buildContext(projectDir: string, includes: Includes, manifest: UnknownRecord): SemanticContext {
219
208
 
220
209
  const localeDir = resolve(projectDir, includes.locales);
221
210
  const localeFiles = new Map<string, Set<string>>();
@@ -281,6 +270,7 @@ function buildContext(projectDir: string, includes: Includes): SemanticContext {
281
270
  : localeFiles.keys().next().value ?? null;
282
271
 
283
272
  return {
273
+ manifest,
284
274
  localeFiles,
285
275
  formatterNames,
286
276
  mapperNames,
@@ -552,6 +542,37 @@ function lintLocaleCoverage(context: SemanticContext): UsageLint[] {
552
542
  return errors;
553
543
  }
554
544
 
545
+ function lintManifestGenerationContext(projectDir: string, manifest: unknown): UsageLint[] {
546
+ if (!isRecord(manifest)) return [];
547
+
548
+ const hasApiEndpoints =
549
+ isRecord(manifest.api) &&
550
+ isRecord(manifest.api.endpoints) &&
551
+ Object.keys(manifest.api.endpoints).length > 0;
552
+ if (!hasApiEndpoints) return [];
553
+
554
+ const generation = isRecord(manifest.generation) ? manifest.generation : {};
555
+ const codeRoots = isRecord(generation.code_roots) ? generation.code_roots : null;
556
+ const backendRoot = codeRoots && typeof codeRoots.backend === "string" ? codeRoots.backend.trim() : "";
557
+
558
+ if (!backendRoot) {
559
+ return [{
560
+ path: "openuispec.yaml",
561
+ message: 'api endpoints require generation.code_roots.backend to point at the backend folder',
562
+ }];
563
+ }
564
+
565
+ const resolvedBackendRoot = resolve(projectDir, backendRoot);
566
+ if (!existsSync(resolvedBackendRoot)) {
567
+ return [{
568
+ path: "openuispec.yaml",
569
+ message: `generation.code_roots.backend points to a missing folder: ${backendRoot}`,
570
+ }];
571
+ }
572
+
573
+ return [];
574
+ }
575
+
555
576
  function printSemanticErrors(label: string, errors: UsageLint[]): number {
556
577
  if (errors.length === 0) return 0;
557
578
  const previewLimit = 10;
@@ -566,12 +587,41 @@ function printSemanticErrors(label: string, errors: UsageLint[]): number {
566
587
  return errors.length;
567
588
  }
568
589
 
590
+ export function collectSemanticLint(projectDir: string, includes: Includes): UsageLint[] {
591
+ const manifest = readManifest(projectDir) as UnknownRecord;
592
+ const context = buildContext(projectDir, includes, manifest);
593
+ const contractsDir = resolve(projectDir, includes.contracts);
594
+
595
+ const allErrors: UsageLint[] = [
596
+ ...lintLocaleCoverage(context),
597
+ ...lintManifestGenerationContext(projectDir, context.manifest),
598
+ ];
599
+
600
+ const files = [
601
+ join(projectDir, "openuispec.yaml"),
602
+ ...listFiles(resolve(projectDir, includes.screens), ".yaml"),
603
+ ...listFiles(resolve(projectDir, includes.flows), ".yaml"),
604
+ ...listFiles(resolve(projectDir, includes.platform), ".yaml"),
605
+ ...listFiles(resolve(projectDir, includes.contracts), ".yaml"),
606
+ ];
607
+
608
+ for (const filePath of files) {
609
+ allErrors.push(
610
+ ...lintFile(filePath, context, { validateTokens: !filePath.startsWith(contractsDir) })
611
+ );
612
+ }
613
+
614
+ return allErrors;
615
+ }
616
+
569
617
  export function runSemanticLint(projectDir: string, includes: Includes): number {
570
- const context = buildContext(projectDir, includes);
618
+ const manifest = readManifest(projectDir) as UnknownRecord;
619
+ const context = buildContext(projectDir, includes, manifest);
571
620
  let total = 0;
572
621
  const contractsDir = resolve(projectDir, includes.contracts);
573
622
 
574
623
  total += printSemanticErrors("locales", lintLocaleCoverage(context));
624
+ total += printSemanticErrors("openuispec.yaml", lintManifestGenerationContext(projectDir, context.manifest));
575
625
 
576
626
  const files = [
577
627
  join(projectDir, "openuispec.yaml"),
@@ -14,7 +14,7 @@ import { fileURLToPath } from "node:url";
14
14
  import { createRequire } from "node:module";
15
15
  import type { ErrorObject } from "ajv";
16
16
  import YAML from "yaml";
17
- import { runSemanticLint, type Includes } from "./semantic-lint.js";
17
+ import { runSemanticLint, collectSemanticLint, type Includes } from "./semantic-lint.js";
18
18
 
19
19
  const require = createRequire(import.meta.url);
20
20
  const Ajv2020 = require("ajv/dist/2020") as typeof import("ajv").default;
@@ -311,6 +311,71 @@ function lintFlowFile(dataPath: string): number {
311
311
  return errors.length;
312
312
  }
313
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
+
314
379
  // ── build Ajv instance with all schemas ──────────────────────────────
315
380
 
316
381
  function buildAjv(): AjvInstance {
@@ -436,9 +501,15 @@ function resolveInclude(projectDir: string, includePath: string): string {
436
501
 
437
502
  // ── validation groups ────────────────────────────────────────────────
438
503
 
504
+ interface JsonGroupResult {
505
+ group: string;
506
+ errors: JsonError[];
507
+ }
508
+
439
509
  interface ValidationGroup {
440
510
  label: string;
441
511
  run(ajv: AjvInstance, projectDir: string, includes: Includes): number;
512
+ collectJson(ajv: AjvInstance, projectDir: string, includes: Includes, groupKey: string): JsonGroupResult;
442
513
  }
443
514
 
444
515
  const GROUPS: Record<string, ValidationGroup> = {
@@ -451,6 +522,12 @@ const GROUPS: Record<string, ValidationGroup> = {
451
522
  `${BASE}openuispec.schema.json`,
452
523
  );
453
524
  },
525
+ collectJson(ajv, projectDir, _includes, groupKey) {
526
+ return {
527
+ group: groupKey,
528
+ errors: collectValidateFile(ajv, join(projectDir, "openuispec.yaml"), `${BASE}openuispec.schema.json`),
529
+ };
530
+ },
454
531
  },
455
532
 
456
533
  tokens: {
@@ -476,6 +553,27 @@ const GROUPS: Record<string, ValidationGroup> = {
476
553
  }
477
554
  return errors;
478
555
  },
556
+ collectJson(ajv, projectDir, includes, groupKey) {
557
+ const errors: JsonError[] = [];
558
+ const tokensDir = resolveInclude(projectDir, includes.tokens);
559
+ const tokenMap: Record<string, string> = {
560
+ "color.yaml": "color.schema.json",
561
+ "typography.yaml": "typography.schema.json",
562
+ "spacing.yaml": "spacing.schema.json",
563
+ "elevation.yaml": "elevation.schema.json",
564
+ "motion.yaml": "motion.schema.json",
565
+ "layout.yaml": "layout.schema.json",
566
+ "themes.yaml": "themes.schema.json",
567
+ "icons.yaml": "icons.schema.json",
568
+ };
569
+ for (const [data, schema] of Object.entries(tokenMap)) {
570
+ const filePath = join(tokensDir, data);
571
+ if (existsSync(filePath)) {
572
+ errors.push(...collectValidateFile(ajv, filePath, `${BASE}tokens/${schema}`));
573
+ }
574
+ }
575
+ return { group: groupKey, errors };
576
+ },
479
577
  },
480
578
 
481
579
  screens: {
@@ -492,6 +590,18 @@ const GROUPS: Record<string, ValidationGroup> = {
492
590
  }
493
591
  return errors;
494
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
+ },
495
605
  },
496
606
 
497
607
  flows: {
@@ -508,6 +618,18 @@ const GROUPS: Record<string, ValidationGroup> = {
508
618
  }
509
619
  return errors;
510
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
+ },
511
633
  },
512
634
 
513
635
  platform: {
@@ -520,6 +642,14 @@ const GROUPS: Record<string, ValidationGroup> = {
520
642
  }
521
643
  return errors;
522
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
+ },
523
653
  },
524
654
 
525
655
  locales: {
@@ -532,6 +662,14 @@ const GROUPS: Record<string, ValidationGroup> = {
532
662
  }
533
663
  return errors;
534
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
+ },
535
673
  },
536
674
 
537
675
  contracts: {
@@ -549,6 +687,19 @@ const GROUPS: Record<string, ValidationGroup> = {
549
687
  }
550
688
  return errors;
551
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
+ },
552
703
  },
553
704
 
554
705
  semantic: {
@@ -556,6 +707,17 @@ const GROUPS: Record<string, ValidationGroup> = {
556
707
  run(_ajv, projectDir, includes) {
557
708
  return runSemanticLint(projectDir, includes);
558
709
  },
710
+ collectJson(_ajv, projectDir, includes, groupKey) {
711
+ const lintErrors = collectSemanticLint(projectDir, includes);
712
+ return {
713
+ group: groupKey,
714
+ errors: lintErrors.map((e) => ({
715
+ file: e.path.includes("/") ? e.path.split("/")[0] : e.path,
716
+ path: e.path,
717
+ message: e.message,
718
+ })),
719
+ };
720
+ },
559
721
  },
560
722
  };
561
723
 
@@ -590,22 +752,47 @@ function findProjectDir(cwd: string): string {
590
752
 
591
753
  // ── main ─────────────────────────────────────────────────────────────
592
754
 
755
+ export { buildAjv, readIncludes, GROUPS };
756
+ export type { JsonGroupResult, JsonError };
757
+
593
758
  export function runValidate(argv: string[]): void {
759
+ const jsonMode = argv.includes("--json");
760
+ const filteredArgs = argv.filter((a) => a !== "--json");
761
+
594
762
  const selected =
595
- argv.length > 0
596
- ? argv.filter((a) => a in GROUPS)
763
+ filteredArgs.length > 0
764
+ ? filteredArgs.filter((a) => a in GROUPS)
597
765
  : Object.keys(GROUPS);
598
766
 
599
767
  if (selected.length === 0) {
600
768
  console.error(
601
769
  `Unknown group(s). Available: ${Object.keys(GROUPS).join(", ")}`,
602
770
  );
603
- process.exit(2);
771
+ process.exit(1);
604
772
  }
605
773
 
606
774
  const projectDir = findProjectDir(process.cwd());
607
775
  const includes = readIncludes(projectDir);
608
776
  const ajv = buildAjv();
777
+
778
+ if (jsonMode) {
779
+ const groups: JsonGroupResult[] = [];
780
+ let totalErrors = 0;
781
+
782
+ for (const key of selected) {
783
+ const result = GROUPS[key].collectJson(ajv, projectDir, includes, key);
784
+ groups.push(result);
785
+ totalErrors += result.errors.length;
786
+ }
787
+
788
+ console.log(JSON.stringify({ groups, total_errors: totalErrors }, null, 2));
789
+
790
+ if (totalErrors > 0) {
791
+ process.exit(2);
792
+ }
793
+ return;
794
+ }
795
+
609
796
  let totalErrors = 0;
610
797
 
611
798
  for (const key of selected) {
@@ -617,7 +804,7 @@ export function runValidate(argv: string[]): void {
617
804
  console.log(`\n${"=".repeat(50)}`);
618
805
  if (totalErrors > 0) {
619
806
  console.log(`FAILED: ${totalErrors} total validation error(s)`);
620
- process.exit(1);
807
+ process.exit(2);
621
808
  } else {
622
809
  console.log("ALL PASSED: Every example file validates successfully");
623
810
  }
package/status/index.ts CHANGED
@@ -40,6 +40,8 @@ interface TargetStatus {
40
40
  removed: number;
41
41
  behind: boolean;
42
42
  explain_available: boolean;
43
+ status: "up to date" | "behind" | "needs baseline" | "needs generation";
44
+ recommended_next_step: string;
43
45
  note?: string;
44
46
  }
45
47
 
@@ -96,6 +98,10 @@ function buildTargetStatus(cwd: string, projectDir: string, projectName: string,
96
98
  removed: 0,
97
99
  behind: false,
98
100
  explain_available: false,
101
+ status: outputExists ? "needs baseline" : "needs generation",
102
+ recommended_next_step: outputExists
103
+ ? `Run \`openuispec drift --snapshot --target ${target}\` after reviewing the generated output.`
104
+ : `Run code generation for "${target}", then \`openuispec prepare --target ${target}\` to build the target work bundle.`,
99
105
  note: outputExists
100
106
  ? "No snapshot found for this target."
101
107
  : `Output directory not found. Run code generation for "${target}" first.`,
@@ -126,6 +132,11 @@ function buildTargetStatus(cwd: string, projectDir: string, projectName: string,
126
132
  removed,
127
133
  behind: changed + added + removed > 0,
128
134
  explain_available: explanation.available,
135
+ status: changed + added + removed > 0 ? "behind" : "up to date",
136
+ recommended_next_step:
137
+ changed + added + removed > 0
138
+ ? `Run \`openuispec prepare --target ${target}\` to build the target work bundle for the pending spec changes.`
139
+ : `No immediate action required for "${target}". Re-run \`openuispec status\` after spec changes or after re-baselining.`,
129
140
  note: explanation.available ? undefined : explanation.note,
130
141
  };
131
142
  }
@@ -156,12 +167,6 @@ function printReport(result: StatusResult): void {
156
167
  : target.output_exists
157
168
  ? "no snapshot"
158
169
  : "output missing";
159
- const status = target.snapshot
160
- ? (target.behind ? "behind" : "up to date")
161
- : target.output_exists
162
- ? "needs baseline"
163
- : "needs generation";
164
-
165
170
  console.log(`${target.target}`);
166
171
  console.log(` output: ${target.output_dir}`);
167
172
  console.log(` output exists: ${target.output_exists ? "yes" : "no"}`);
@@ -170,11 +175,12 @@ function printReport(result: StatusResult): void {
170
175
  console.log(` baseline: ${target.baseline.label}`);
171
176
  }
172
177
  console.log(` drift: ${summary}`);
173
- console.log(` status: ${status}`);
178
+ console.log(` status: ${target.status}`);
174
179
  console.log(` explain: ${target.explain_available ? "available" : "unavailable"}`);
175
180
  if (target.note) {
176
181
  console.log(` note: ${target.note}`);
177
182
  }
183
+ console.log(` next: ${target.recommended_next_step}`);
178
184
  console.log("");
179
185
  }
180
186
  }