openuispec 0.1.29 → 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.
- package/README.md +3 -1
- package/cli/configure-target.ts +124 -17
- package/cli/index.ts +23 -6
- package/cli/init.ts +37 -36
- package/cli/target-presets.json +40 -40
- package/docs/implementation-notes.md +4 -0
- package/drift/index.ts +8 -2
- package/examples/taskflow/AGENTS.md +4 -3
- package/examples/taskflow/CLAUDE.md +4 -3
- package/examples/todo-orbit/AGENTS.md +4 -3
- package/examples/todo-orbit/CLAUDE.md +4 -3
- package/package.json +1 -1
- package/prepare/index.ts +46 -1
- package/schema/semantic-lint.ts +31 -5
- package/schema/validate.ts +192 -5
- package/status/index.ts +13 -7
package/schema/validate.ts
CHANGED
|
@@ -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
|
-
|
|
596
|
-
?
|
|
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(
|
|
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(
|
|
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
|
}
|