openuispec 0.1.0 → 0.1.2
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 +2 -36
- package/cli/index.ts +19 -9
- package/cli/init.ts +12 -0
- package/drift/index.ts +266 -158
- package/package.json +2 -1
- package/schema/validate.ts +60 -31
package/README.md
CHANGED
|
@@ -17,45 +17,11 @@ The result: each platform feels native, but every app stays consistent because i
|
|
|
17
17
|
|
|
18
18
|
## How it works
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
┌─────────────────────────┐
|
|
22
|
-
│ OpenUISpec (YAML) │ ← Single source of truth
|
|
23
|
-
│ Tokens + Contracts │
|
|
24
|
-
│ Screens + Flows │
|
|
25
|
-
└────────┬────────────────┘
|
|
26
|
-
│
|
|
27
|
-
AI Generation Layer
|
|
28
|
-
│
|
|
29
|
-
┌────┴─────┬──────────┐
|
|
30
|
-
▼ ▼ ▼
|
|
31
|
-
SwiftUI Compose React
|
|
32
|
-
(iOS) (Android) (Web)
|
|
33
|
-
```
|
|
20
|
+

|
|
34
21
|
|
|
35
22
|
## Workflows
|
|
36
23
|
|
|
37
|
-
OpenUISpec
|
|
38
|
-
|
|
39
|
-
```
|
|
40
|
-
DESIGN MODE (spec-first) DEVELOPMENT MODE (platform-first)
|
|
41
|
-
For new features & design changes For iteration, tweaks & bug fixes
|
|
42
|
-
|
|
43
|
-
Spec (YAML) Xcode / Android Studio
|
|
44
|
-
│ │
|
|
45
|
-
▼ ▼
|
|
46
|
-
AI generates native code Developer edits native code
|
|
47
|
-
│ │
|
|
48
|
-
▼ ▼
|
|
49
|
-
Refine per platform AI syncs changes back to spec
|
|
50
|
-
│
|
|
51
|
-
▼
|
|
52
|
-
Other platforms see spec diff
|
|
53
|
-
and update their code
|
|
54
|
-
```
|
|
55
|
-
|
|
56
|
-
**Design mode** is the starting point: you write (or generate) spec YAML, then AI produces native SwiftUI, Compose, or React code. This is how new screens, flows, and design system changes enter the project.
|
|
57
|
-
|
|
58
|
-
**Development mode** is where most time is spent: a developer works in their IDE with live preview, iterating on layout, interactions, and fixes. When they're done, AI reads the code changes and updates the spec YAML. Other platform teams see the spec diff in version control and propagate the changes to their codebase. The spec acts as a shared sync layer — a UI changelog that keeps all platforms consistent.
|
|
24
|
+

|
|
59
25
|
|
|
60
26
|
## Key concepts
|
|
61
27
|
|
package/cli/index.ts
CHANGED
|
@@ -3,26 +3,33 @@
|
|
|
3
3
|
* OpenUISpec CLI
|
|
4
4
|
*
|
|
5
5
|
* Usage:
|
|
6
|
-
* openuispec init
|
|
7
|
-
* openuispec drift
|
|
8
|
-
* openuispec drift --target
|
|
9
|
-
* openuispec
|
|
6
|
+
* openuispec init Create a new spec project
|
|
7
|
+
* openuispec drift [--target <t>] Check for spec drift
|
|
8
|
+
* openuispec drift --snapshot --target <t> Snapshot current state
|
|
9
|
+
* openuispec validate [group...] Validate spec files against schemas
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { init } from "./init.js";
|
|
13
13
|
|
|
14
14
|
async function main(): Promise<void> {
|
|
15
|
-
const [command] = process.argv.slice(2);
|
|
15
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
16
16
|
|
|
17
17
|
switch (command) {
|
|
18
18
|
case "init":
|
|
19
19
|
await init();
|
|
20
20
|
break;
|
|
21
21
|
|
|
22
|
-
case "drift":
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
case "drift": {
|
|
23
|
+
const { runDrift } = await import("../drift/index.js");
|
|
24
|
+
runDrift(rest);
|
|
25
|
+
break;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
case "validate": {
|
|
29
|
+
const { runValidate } = await import("../schema/validate.js");
|
|
30
|
+
runValidate(rest);
|
|
25
31
|
break;
|
|
32
|
+
}
|
|
26
33
|
|
|
27
34
|
case undefined:
|
|
28
35
|
case "--help":
|
|
@@ -34,8 +41,11 @@ Usage:
|
|
|
34
41
|
openuispec init Create a new spec project
|
|
35
42
|
openuispec drift [--target <t>] Check for spec drift
|
|
36
43
|
openuispec drift --snapshot --target <t> Snapshot current state
|
|
44
|
+
openuispec validate [group...] Validate spec files
|
|
45
|
+
|
|
46
|
+
Validate groups: manifest, tokens, screens, flows, platform, locales, custom_contracts
|
|
37
47
|
|
|
38
|
-
Learn more: https://github.com/
|
|
48
|
+
Learn more: https://github.com/rsktash/openuispec
|
|
39
49
|
`);
|
|
40
50
|
break;
|
|
41
51
|
|
package/cli/init.ts
CHANGED
|
@@ -223,6 +223,18 @@ function aiRulesBlock(specDir: string, targets: string[]): string {
|
|
|
223
223
|
2. Targets in this project: ${targetList}.
|
|
224
224
|
3. Run \`openuispec drift\` to verify no untracked drift remains.
|
|
225
225
|
|
|
226
|
+
## Screen and flow status
|
|
227
|
+
Screens and flows have a \`status\` field that controls drift tracking:
|
|
228
|
+
- \`stub\` — placeholder, not yet specced. Drift detection skips these.
|
|
229
|
+
- \`draft\` — in progress, actively being specced. Tracked by drift.
|
|
230
|
+
- \`ready\` — fully specified (default if omitted). Tracked by drift.
|
|
231
|
+
|
|
232
|
+
When adopting OpenUISpec in an existing project:
|
|
233
|
+
1. Create spec files for existing screens as \`status: stub\` initially.
|
|
234
|
+
2. When speccing a screen from existing code, change status to \`draft\`.
|
|
235
|
+
3. Once the spec is complete and reviewed, change to \`ready\` (or remove the field).
|
|
236
|
+
4. Only \`draft\` and \`ready\` screens trigger drift failures in CI.
|
|
237
|
+
|
|
226
238
|
## Spec file conventions
|
|
227
239
|
- Tokens (colors, typography, spacing, motion, icons) live in \`${specDir}/tokens/\`.
|
|
228
240
|
- Contracts (UI component definitions) live in \`${specDir}/contracts/\`.
|
package/drift/index.ts
CHANGED
|
@@ -4,33 +4,35 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Tracks spec changes via content hashes so you know what to
|
|
6
6
|
* re-generate or review after editing spec files.
|
|
7
|
+
* Stub screens/flows are reported separately and don't fail CI.
|
|
7
8
|
*
|
|
8
9
|
* Usage:
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* openuispec drift --target ios # check drift for ios
|
|
11
|
+
* openuispec drift # check all targets with snapshots
|
|
12
|
+
* openuispec drift --snapshot --target ios # snapshot for ios
|
|
13
|
+
* openuispec drift --json --target ios # machine-readable output
|
|
14
|
+
* openuispec drift --target ios --all # include stubs in drift count
|
|
13
15
|
*/
|
|
14
16
|
|
|
15
|
-
import { readFileSync, writeFileSync, readdirSync, existsSync
|
|
17
|
+
import { readFileSync, writeFileSync, readdirSync, existsSync } from "node:fs";
|
|
16
18
|
import { resolve, join, relative, basename, dirname } from "node:path";
|
|
17
|
-
import { fileURLToPath } from "node:url";
|
|
18
19
|
import { createHash } from "node:crypto";
|
|
19
20
|
import YAML from "yaml";
|
|
20
21
|
|
|
21
|
-
const __dirname = fileURLToPath(new URL(".", import.meta.url));
|
|
22
|
-
const REPO_ROOT = resolve(__dirname, "..");
|
|
23
|
-
const PROJECT_DIR = resolve(REPO_ROOT, "examples", "taskflow");
|
|
24
|
-
const GENERATED_DIR = resolve(REPO_ROOT, "generated");
|
|
25
22
|
const STATE_FILE = ".openuispec-state.json";
|
|
26
23
|
|
|
27
24
|
// ── types ─────────────────────────────────────────────────────────────
|
|
28
25
|
|
|
26
|
+
interface FileEntry {
|
|
27
|
+
hash: string;
|
|
28
|
+
status: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
29
31
|
interface StateFile {
|
|
30
32
|
spec_version: string;
|
|
31
33
|
snapshot_at: string;
|
|
32
34
|
target: string;
|
|
33
|
-
files: Record<string,
|
|
35
|
+
files: Record<string, FileEntry>;
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
interface DriftResult {
|
|
@@ -59,6 +61,29 @@ function hashFile(filePath: string): string {
|
|
|
59
61
|
return `sha256:${hash}`;
|
|
60
62
|
}
|
|
61
63
|
|
|
64
|
+
/** Read the status field from a screen or flow YAML file. */
|
|
65
|
+
function readStatus(filePath: string): string {
|
|
66
|
+
try {
|
|
67
|
+
const doc = YAML.parse(readFileSync(filePath, "utf-8"));
|
|
68
|
+
if (doc && typeof doc === "object") {
|
|
69
|
+
const rootKey = Object.keys(doc)[0];
|
|
70
|
+
const def = doc[rootKey];
|
|
71
|
+
if (def && typeof def.status === "string") {
|
|
72
|
+
return def.status;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
} catch {
|
|
76
|
+
// If we can't parse, treat as ready
|
|
77
|
+
}
|
|
78
|
+
return "ready";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Returns true if a file is a screen or flow (has status semantics). */
|
|
82
|
+
function hasStatusSemantics(relPath: string): boolean {
|
|
83
|
+
const dir = dirname(relPath);
|
|
84
|
+
return dir === "screens" || dir === "flows";
|
|
85
|
+
}
|
|
86
|
+
|
|
62
87
|
function discoverSpecFiles(projectDir: string): string[] {
|
|
63
88
|
const manifest = join(projectDir, "openuispec.yaml");
|
|
64
89
|
if (!existsSync(manifest)) {
|
|
@@ -70,32 +95,21 @@ function discoverSpecFiles(projectDir: string): string[] {
|
|
|
70
95
|
const includes = doc.includes ?? {};
|
|
71
96
|
const files: string[] = [manifest];
|
|
72
97
|
|
|
73
|
-
// Tokens
|
|
74
98
|
if (includes.tokens) {
|
|
75
99
|
files.push(...listFiles(resolve(projectDir, includes.tokens), ".yaml"));
|
|
76
100
|
}
|
|
77
|
-
|
|
78
|
-
// Screens
|
|
79
101
|
if (includes.screens) {
|
|
80
102
|
files.push(...listFiles(resolve(projectDir, includes.screens), ".yaml"));
|
|
81
103
|
}
|
|
82
|
-
|
|
83
|
-
// Flows
|
|
84
104
|
if (includes.flows) {
|
|
85
105
|
files.push(...listFiles(resolve(projectDir, includes.flows), ".yaml"));
|
|
86
106
|
}
|
|
87
|
-
|
|
88
|
-
// Platform
|
|
89
107
|
if (includes.platform) {
|
|
90
108
|
files.push(...listFiles(resolve(projectDir, includes.platform), ".yaml"));
|
|
91
109
|
}
|
|
92
|
-
|
|
93
|
-
// Locales
|
|
94
110
|
if (includes.locales) {
|
|
95
111
|
files.push(...listFiles(resolve(projectDir, includes.locales), ".json"));
|
|
96
112
|
}
|
|
97
|
-
|
|
98
|
-
// Custom contracts
|
|
99
113
|
if (includes.contracts) {
|
|
100
114
|
files.push(...listFiles(resolve(projectDir, includes.contracts), ".yaml"));
|
|
101
115
|
}
|
|
@@ -115,33 +129,78 @@ function categorize(relPath: string): string {
|
|
|
115
129
|
return "Other";
|
|
116
130
|
}
|
|
117
131
|
|
|
118
|
-
|
|
119
|
-
|
|
132
|
+
// ── project resolution ───────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
/** Find the spec project directory by looking for openuispec.yaml. */
|
|
135
|
+
function findProjectDir(cwd: string): string {
|
|
136
|
+
const candidates = [
|
|
137
|
+
join(cwd, "openuispec"),
|
|
138
|
+
cwd,
|
|
139
|
+
// Fallback for running from repo root with examples/
|
|
140
|
+
join(cwd, "examples", "taskflow"),
|
|
141
|
+
];
|
|
142
|
+
for (const dir of candidates) {
|
|
143
|
+
if (existsSync(join(dir, "openuispec.yaml"))) {
|
|
144
|
+
return dir;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
console.error(
|
|
148
|
+
"Error: No openuispec.yaml found.\n" +
|
|
149
|
+
"Run from a directory containing openuispec.yaml or an openuispec/ subdirectory."
|
|
150
|
+
);
|
|
151
|
+
process.exit(1);
|
|
120
152
|
}
|
|
121
153
|
|
|
122
|
-
|
|
123
|
-
|
|
154
|
+
/** Read the project name from the manifest. */
|
|
155
|
+
function readProjectName(projectDir: string): string {
|
|
156
|
+
const doc = YAML.parse(
|
|
157
|
+
readFileSync(join(projectDir, "openuispec.yaml"), "utf-8")
|
|
158
|
+
);
|
|
159
|
+
return doc.project?.name ?? basename(projectDir);
|
|
124
160
|
}
|
|
125
161
|
|
|
126
|
-
/**
|
|
127
|
-
function
|
|
128
|
-
|
|
162
|
+
/** Resolve the generated output directory for a target. */
|
|
163
|
+
function resolveOutputDir(cwd: string, projectName: string, target: string): string {
|
|
164
|
+
return resolve(cwd, "generated", target, projectName);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function stateFilePath(cwd: string, projectName: string, target: string): string {
|
|
168
|
+
return join(resolveOutputDir(cwd, projectName, target), STATE_FILE);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function discoverTargets(cwd: string, projectName: string): string[] {
|
|
172
|
+
const generatedDir = resolve(cwd, "generated");
|
|
173
|
+
if (!existsSync(generatedDir)) return [];
|
|
129
174
|
try {
|
|
130
|
-
return readdirSync(
|
|
131
|
-
.filter((entry) =>
|
|
175
|
+
return readdirSync(generatedDir)
|
|
176
|
+
.filter((entry) =>
|
|
177
|
+
existsSync(stateFilePath(cwd, projectName, entry))
|
|
178
|
+
)
|
|
132
179
|
.sort();
|
|
133
180
|
} catch {
|
|
134
181
|
return [];
|
|
135
182
|
}
|
|
136
183
|
}
|
|
137
184
|
|
|
185
|
+
/**
|
|
186
|
+
* Normalize a state file entry. Handles backward compatibility
|
|
187
|
+
* with old format where files were stored as plain hash strings.
|
|
188
|
+
*/
|
|
189
|
+
function normalizeEntry(value: string | FileEntry): FileEntry {
|
|
190
|
+
if (typeof value === "string") {
|
|
191
|
+
return { hash: value, status: "ready" };
|
|
192
|
+
}
|
|
193
|
+
return value;
|
|
194
|
+
}
|
|
195
|
+
|
|
138
196
|
// ── snapshot ──────────────────────────────────────────────────────────
|
|
139
197
|
|
|
140
|
-
function snapshot(projectDir: string, target: string): void {
|
|
141
|
-
const
|
|
198
|
+
function snapshot(cwd: string, projectDir: string, target: string): void {
|
|
199
|
+
const projectName = readProjectName(projectDir);
|
|
200
|
+
const outDir = resolveOutputDir(cwd, projectName, target);
|
|
142
201
|
if (!existsSync(outDir)) {
|
|
143
202
|
console.error(
|
|
144
|
-
`Error: Output directory not found: ${relative(
|
|
203
|
+
`Error: Output directory not found: ${relative(cwd, outDir)}\n` +
|
|
145
204
|
`Run code generation for "${target}" first.`
|
|
146
205
|
);
|
|
147
206
|
process.exit(1);
|
|
@@ -150,89 +209,132 @@ function snapshot(projectDir: string, target: string): void {
|
|
|
150
209
|
const files = discoverSpecFiles(projectDir);
|
|
151
210
|
const doc = YAML.parse(readFileSync(join(projectDir, "openuispec.yaml"), "utf-8"));
|
|
152
211
|
|
|
153
|
-
const
|
|
212
|
+
const entries: Record<string, FileEntry> = {};
|
|
213
|
+
let stubCount = 0;
|
|
214
|
+
|
|
154
215
|
for (const f of files) {
|
|
155
216
|
const rel = relative(projectDir, f);
|
|
156
|
-
|
|
217
|
+
const status = hasStatusSemantics(rel) ? readStatus(f) : "ready";
|
|
218
|
+
entries[rel] = { hash: hashFile(f), status };
|
|
219
|
+
if (status === "stub") stubCount++;
|
|
157
220
|
}
|
|
158
221
|
|
|
159
222
|
const state: StateFile = {
|
|
160
223
|
spec_version: doc.spec_version ?? "0.1",
|
|
161
224
|
snapshot_at: new Date().toISOString(),
|
|
162
225
|
target,
|
|
163
|
-
files:
|
|
226
|
+
files: entries,
|
|
164
227
|
};
|
|
165
228
|
|
|
166
|
-
const outPath = stateFilePath(target);
|
|
229
|
+
const outPath = stateFilePath(cwd, projectName, target);
|
|
167
230
|
writeFileSync(outPath, JSON.stringify(state, null, 2) + "\n");
|
|
168
|
-
console.log(`Snapshot saved: ${relative(
|
|
169
|
-
console.log(` ${Object.keys(
|
|
231
|
+
console.log(`Snapshot saved: ${relative(cwd, outPath)}`);
|
|
232
|
+
console.log(` ${Object.keys(entries).length} files hashed`);
|
|
233
|
+
if (stubCount > 0) {
|
|
234
|
+
console.log(` ${stubCount} stubs (not tracked for drift)`);
|
|
235
|
+
}
|
|
170
236
|
console.log(` target: ${target}`);
|
|
171
237
|
}
|
|
172
238
|
|
|
173
239
|
// ── check ─────────────────────────────────────────────────────────────
|
|
174
240
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
);
|
|
182
|
-
process.exit(1);
|
|
183
|
-
}
|
|
241
|
+
interface CheckResult {
|
|
242
|
+
state: StateFile;
|
|
243
|
+
drift: DriftResult;
|
|
244
|
+
stubDrift: DriftResult;
|
|
245
|
+
statuses: Record<string, string>;
|
|
246
|
+
}
|
|
184
247
|
|
|
185
|
-
|
|
248
|
+
function computeDrift(
|
|
249
|
+
projectDir: string,
|
|
250
|
+
state: StateFile,
|
|
251
|
+
includeAll: boolean
|
|
252
|
+
): CheckResult {
|
|
186
253
|
const files = discoverSpecFiles(projectDir);
|
|
187
254
|
|
|
188
|
-
const
|
|
255
|
+
const current: Record<string, FileEntry> = {};
|
|
189
256
|
for (const f of files) {
|
|
190
|
-
|
|
257
|
+
const rel = relative(projectDir, f);
|
|
258
|
+
const status = hasStatusSemantics(rel) ? readStatus(f) : "ready";
|
|
259
|
+
current[rel] = { hash: hashFile(f), status };
|
|
191
260
|
}
|
|
192
261
|
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
262
|
+
const drift: DriftResult = { changed: [], added: [], removed: [], unchanged: [] };
|
|
263
|
+
const stubDrift: DriftResult = { changed: [], added: [], removed: [], unchanged: [] };
|
|
264
|
+
const statuses: Record<string, string> = {};
|
|
265
|
+
|
|
266
|
+
for (const [rel, entry] of Object.entries(current)) {
|
|
267
|
+
const currentStatus = entry.status;
|
|
268
|
+
statuses[rel] = currentStatus;
|
|
269
|
+
const bucket = currentStatus === "stub" && !includeAll ? stubDrift : drift;
|
|
199
270
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
271
|
+
const snapshotEntry = state.files[rel]
|
|
272
|
+
? normalizeEntry(state.files[rel] as string | FileEntry)
|
|
273
|
+
: null;
|
|
274
|
+
|
|
275
|
+
if (!snapshotEntry) {
|
|
276
|
+
bucket.added.push(rel);
|
|
277
|
+
} else if (snapshotEntry.hash !== entry.hash) {
|
|
278
|
+
bucket.changed.push(rel);
|
|
206
279
|
} else {
|
|
207
|
-
|
|
280
|
+
bucket.unchanged.push(rel);
|
|
208
281
|
}
|
|
209
282
|
}
|
|
210
283
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
284
|
+
for (const [rel, raw] of Object.entries(state.files)) {
|
|
285
|
+
if (!(rel in current)) {
|
|
286
|
+
const entry = normalizeEntry(raw as string | FileEntry);
|
|
287
|
+
statuses[rel] = entry.status;
|
|
288
|
+
const bucket = entry.status === "stub" && !includeAll ? stubDrift : drift;
|
|
289
|
+
bucket.removed.push(rel);
|
|
215
290
|
}
|
|
216
291
|
}
|
|
217
292
|
|
|
293
|
+
return { state, drift, stubDrift, statuses };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function check(
|
|
297
|
+
cwd: string,
|
|
298
|
+
projectDir: string,
|
|
299
|
+
target: string,
|
|
300
|
+
jsonOutput: boolean,
|
|
301
|
+
includeAll: boolean
|
|
302
|
+
): void {
|
|
303
|
+
const projectName = readProjectName(projectDir);
|
|
304
|
+
const statePath = stateFilePath(cwd, projectName, target);
|
|
305
|
+
if (!existsSync(statePath)) {
|
|
306
|
+
console.error(
|
|
307
|
+
`No snapshot found for target "${target}".\n` +
|
|
308
|
+
`Run: openuispec drift --snapshot --target ${target}`
|
|
309
|
+
);
|
|
310
|
+
process.exit(1);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const state: StateFile = JSON.parse(readFileSync(statePath, "utf-8"));
|
|
314
|
+
const result = computeDrift(projectDir, state, includeAll);
|
|
315
|
+
|
|
218
316
|
if (jsonOutput) {
|
|
219
|
-
printJson(
|
|
317
|
+
printJson(result);
|
|
220
318
|
} else {
|
|
221
|
-
printReport(projectDir,
|
|
319
|
+
printReport(projectDir, result);
|
|
222
320
|
}
|
|
223
321
|
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
result.added.length > 0 ||
|
|
227
|
-
result.removed.length > 0;
|
|
322
|
+
const d = result.drift;
|
|
323
|
+
const hasDrift = d.changed.length > 0 || d.added.length > 0 || d.removed.length > 0;
|
|
228
324
|
process.exit(hasDrift ? 1 : 0);
|
|
229
325
|
}
|
|
230
326
|
|
|
231
|
-
function checkAll(
|
|
232
|
-
|
|
327
|
+
function checkAll(
|
|
328
|
+
cwd: string,
|
|
329
|
+
projectDir: string,
|
|
330
|
+
jsonOutput: boolean,
|
|
331
|
+
includeAll: boolean
|
|
332
|
+
): void {
|
|
333
|
+
const projectName = readProjectName(projectDir);
|
|
334
|
+
const targets = discoverTargets(cwd, projectName);
|
|
233
335
|
if (targets.length === 0) {
|
|
234
336
|
console.error(
|
|
235
|
-
"No snapshots found. Run:
|
|
337
|
+
"No snapshots found. Run: openuispec drift --snapshot --target <target>"
|
|
236
338
|
);
|
|
237
339
|
process.exit(1);
|
|
238
340
|
}
|
|
@@ -240,48 +342,18 @@ function checkAll(projectDir: string, jsonOutput: boolean): void {
|
|
|
240
342
|
let anyDrift = false;
|
|
241
343
|
|
|
242
344
|
for (const target of targets) {
|
|
243
|
-
const statePath = stateFilePath(target);
|
|
345
|
+
const statePath = stateFilePath(cwd, projectName, target);
|
|
244
346
|
const state: StateFile = JSON.parse(readFileSync(statePath, "utf-8"));
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
const currentHashes: Record<string, string> = {};
|
|
248
|
-
for (const f of files) {
|
|
249
|
-
currentHashes[relative(projectDir, f)] = hashFile(f);
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
const result: DriftResult = {
|
|
253
|
-
changed: [],
|
|
254
|
-
added: [],
|
|
255
|
-
removed: [],
|
|
256
|
-
unchanged: [],
|
|
257
|
-
};
|
|
258
|
-
|
|
259
|
-
for (const [rel, hash] of Object.entries(currentHashes)) {
|
|
260
|
-
if (!(rel in state.files)) {
|
|
261
|
-
result.added.push(rel);
|
|
262
|
-
} else if (state.files[rel] !== hash) {
|
|
263
|
-
result.changed.push(rel);
|
|
264
|
-
} else {
|
|
265
|
-
result.unchanged.push(rel);
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
for (const rel of Object.keys(state.files)) {
|
|
270
|
-
if (!(rel in currentHashes)) {
|
|
271
|
-
result.removed.push(rel);
|
|
272
|
-
}
|
|
273
|
-
}
|
|
347
|
+
const result = computeDrift(projectDir, state, includeAll);
|
|
274
348
|
|
|
275
349
|
if (jsonOutput) {
|
|
276
|
-
printJson(
|
|
350
|
+
printJson(result);
|
|
277
351
|
} else {
|
|
278
|
-
printReport(projectDir,
|
|
352
|
+
printReport(projectDir, result);
|
|
279
353
|
}
|
|
280
354
|
|
|
281
|
-
const
|
|
282
|
-
|
|
283
|
-
result.added.length > 0 ||
|
|
284
|
-
result.removed.length > 0;
|
|
355
|
+
const d = result.drift;
|
|
356
|
+
const hasDrift = d.changed.length > 0 || d.added.length > 0 || d.removed.length > 0;
|
|
285
357
|
if (hasDrift) anyDrift = true;
|
|
286
358
|
|
|
287
359
|
if (targets.length > 1 && target !== targets[targets.length - 1]) {
|
|
@@ -294,13 +366,20 @@ function checkAll(projectDir: string, jsonOutput: boolean): void {
|
|
|
294
366
|
|
|
295
367
|
// ── output ────────────────────────────────────────────────────────────
|
|
296
368
|
|
|
297
|
-
function printJson(
|
|
369
|
+
function printJson(result: CheckResult): void {
|
|
370
|
+
const stubTotal =
|
|
371
|
+
result.stubDrift.changed.length +
|
|
372
|
+
result.stubDrift.added.length +
|
|
373
|
+
result.stubDrift.removed.length +
|
|
374
|
+
result.stubDrift.unchanged.length;
|
|
375
|
+
|
|
298
376
|
console.log(
|
|
299
377
|
JSON.stringify(
|
|
300
378
|
{
|
|
301
|
-
snapshot_at: state.snapshot_at,
|
|
302
|
-
target: state.target,
|
|
303
|
-
...result,
|
|
379
|
+
snapshot_at: result.state.snapshot_at,
|
|
380
|
+
target: result.state.target,
|
|
381
|
+
...result.drift,
|
|
382
|
+
stubs: stubTotal > 0 ? result.stubDrift : undefined,
|
|
304
383
|
},
|
|
305
384
|
null,
|
|
306
385
|
2
|
|
@@ -308,32 +387,26 @@ function printJson(state: StateFile, result: DriftResult): void {
|
|
|
308
387
|
);
|
|
309
388
|
}
|
|
310
389
|
|
|
311
|
-
function printReport(
|
|
312
|
-
projectDir
|
|
313
|
-
state: StateFile,
|
|
314
|
-
result: DriftResult
|
|
315
|
-
): void {
|
|
316
|
-
const doc = YAML.parse(
|
|
317
|
-
readFileSync(join(projectDir, "openuispec.yaml"), "utf-8")
|
|
318
|
-
);
|
|
319
|
-
const projectName = doc.project?.name ?? basename(projectDir);
|
|
390
|
+
function printReport(projectDir: string, result: CheckResult): void {
|
|
391
|
+
const projectName = readProjectName(projectDir);
|
|
320
392
|
|
|
321
393
|
console.log("OpenUISpec Drift Check");
|
|
322
394
|
console.log("======================");
|
|
323
395
|
console.log(`Project: ${projectName}`);
|
|
324
|
-
console.log(`Snapshot: ${state.snapshot_at}`);
|
|
325
|
-
console.log(`Target: ${state.target}`);
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
...
|
|
331
|
-
...
|
|
332
|
-
...
|
|
396
|
+
console.log(`Snapshot: ${result.state.snapshot_at}`);
|
|
397
|
+
console.log(`Target: ${result.state.target}`);
|
|
398
|
+
|
|
399
|
+
const d = result.drift;
|
|
400
|
+
|
|
401
|
+
const allTracked = new Set([
|
|
402
|
+
...d.unchanged,
|
|
403
|
+
...d.changed,
|
|
404
|
+
...d.added,
|
|
405
|
+
...d.removed,
|
|
333
406
|
]);
|
|
334
407
|
|
|
335
408
|
const categories = new Map<string, string[]>();
|
|
336
|
-
for (const f of
|
|
409
|
+
for (const f of allTracked) {
|
|
337
410
|
const cat = categorize(f);
|
|
338
411
|
if (!categories.has(cat)) categories.set(cat, []);
|
|
339
412
|
categories.get(cat)!.push(f);
|
|
@@ -358,41 +431,76 @@ function printReport(
|
|
|
358
431
|
console.log(`\n${cat}`);
|
|
359
432
|
for (const f of files) {
|
|
360
433
|
const name = basename(f);
|
|
361
|
-
if (
|
|
362
|
-
console.log(`
|
|
363
|
-
} else if (
|
|
434
|
+
if (d.changed.includes(f)) {
|
|
435
|
+
console.log(` \u2717 ${name} (changed)`);
|
|
436
|
+
} else if (d.added.includes(f)) {
|
|
364
437
|
console.log(` + ${name} (added)`);
|
|
365
|
-
} else if (
|
|
438
|
+
} else if (d.removed.includes(f)) {
|
|
366
439
|
console.log(` - ${name} (removed)`);
|
|
367
440
|
} else {
|
|
368
|
-
console.log(`
|
|
441
|
+
console.log(` \u2713 ${name}`);
|
|
369
442
|
}
|
|
370
443
|
}
|
|
371
444
|
}
|
|
372
445
|
|
|
446
|
+
const sd = result.stubDrift;
|
|
447
|
+
const allStubs = [
|
|
448
|
+
...sd.unchanged,
|
|
449
|
+
...sd.changed,
|
|
450
|
+
...sd.added,
|
|
451
|
+
...sd.removed,
|
|
452
|
+
];
|
|
453
|
+
|
|
454
|
+
if (allStubs.length > 0) {
|
|
455
|
+
console.log("\nStubs (not tracked)");
|
|
456
|
+
allStubs.sort();
|
|
457
|
+
for (const f of allStubs) {
|
|
458
|
+
const name = basename(f);
|
|
459
|
+
const hasChange =
|
|
460
|
+
sd.changed.includes(f) || sd.added.includes(f) || sd.removed.includes(f);
|
|
461
|
+
console.log(` \u00b7 ${name}${hasChange ? " (changed)" : ""}`);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const stubCount = allStubs.length;
|
|
466
|
+
const stubSuffix = stubCount > 0 ? ` (${stubCount} stubs skipped)` : "";
|
|
373
467
|
console.log(
|
|
374
|
-
`\nSummary: ${
|
|
468
|
+
`\nSummary: ${d.changed.length} changed, ${d.added.length} added, ${d.removed.length} removed${stubSuffix}`
|
|
375
469
|
);
|
|
376
470
|
}
|
|
377
471
|
|
|
378
472
|
// ── main ──────────────────────────────────────────────────────────────
|
|
379
473
|
|
|
380
|
-
|
|
381
|
-
const isSnapshot =
|
|
382
|
-
const isJson =
|
|
474
|
+
export function runDrift(argv: string[]): void {
|
|
475
|
+
const isSnapshot = argv.includes("--snapshot");
|
|
476
|
+
const isJson = argv.includes("--json");
|
|
477
|
+
const includeAll = argv.includes("--all");
|
|
383
478
|
|
|
384
|
-
const targetIdx =
|
|
385
|
-
const target = targetIdx !== -1 &&
|
|
479
|
+
const targetIdx = argv.indexOf("--target");
|
|
480
|
+
const target = targetIdx !== -1 && argv[targetIdx + 1] ? argv[targetIdx + 1] : null;
|
|
386
481
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
482
|
+
const cwd = process.cwd();
|
|
483
|
+
const projectDir = findProjectDir(cwd);
|
|
484
|
+
|
|
485
|
+
if (isSnapshot) {
|
|
486
|
+
if (!target) {
|
|
487
|
+
console.error("Error: --target is required for --snapshot");
|
|
488
|
+
console.error("Usage: openuispec drift --snapshot --target <target>");
|
|
489
|
+
process.exit(1);
|
|
490
|
+
}
|
|
491
|
+
snapshot(cwd, projectDir, target);
|
|
492
|
+
} else if (target) {
|
|
493
|
+
check(cwd, projectDir, target, isJson, includeAll);
|
|
494
|
+
} else {
|
|
495
|
+
checkAll(cwd, projectDir, isJson, includeAll);
|
|
392
496
|
}
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Direct execution
|
|
500
|
+
const isDirectRun =
|
|
501
|
+
process.argv[1]?.endsWith("drift/index.ts") ||
|
|
502
|
+
process.argv[1]?.endsWith("drift/index.js");
|
|
503
|
+
|
|
504
|
+
if (isDirectRun) {
|
|
505
|
+
runDrift(process.argv.slice(2));
|
|
398
506
|
}
|
package/package.json
CHANGED
package/schema/validate.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env tsx
|
|
2
2
|
/**
|
|
3
|
-
* Validate
|
|
3
|
+
* Validate OpenUISpec files against their JSON Schemas.
|
|
4
4
|
*
|
|
5
5
|
* Usage:
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* openuispec validate # validate all spec files
|
|
7
|
+
* openuispec validate tokens screens # validate specific groups
|
|
8
|
+
* npm run validate # from repo (uses examples/taskflow)
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { readFileSync, readdirSync } from "node:fs";
|
|
11
|
+
import { readFileSync, readdirSync, existsSync } from "node:fs";
|
|
12
12
|
import { resolve, join, basename } from "node:path";
|
|
13
13
|
import { fileURLToPath } from "node:url";
|
|
14
14
|
import { createRequire } from "node:module";
|
|
@@ -21,7 +21,6 @@ const addFormats = require("ajv-formats") as typeof import("ajv-formats").defaul
|
|
|
21
21
|
|
|
22
22
|
const __dirname = fileURLToPath(new URL(".", import.meta.url));
|
|
23
23
|
const SCHEMA_DIR = resolve(__dirname);
|
|
24
|
-
const EXAMPLES_DIR = resolve(SCHEMA_DIR, "..", "examples", "taskflow");
|
|
25
24
|
|
|
26
25
|
type AjvInstance = InstanceType<typeof Ajv2020>;
|
|
27
26
|
|
|
@@ -119,16 +118,16 @@ const BASE = "https://openuispec.org/schema/";
|
|
|
119
118
|
|
|
120
119
|
interface ValidationGroup {
|
|
121
120
|
label: string;
|
|
122
|
-
run(ajv: AjvInstance): number;
|
|
121
|
+
run(ajv: AjvInstance, projectDir: string): number;
|
|
123
122
|
}
|
|
124
123
|
|
|
125
124
|
const GROUPS: Record<string, ValidationGroup> = {
|
|
126
125
|
manifest: {
|
|
127
126
|
label: "Root manifest",
|
|
128
|
-
run(ajv) {
|
|
127
|
+
run(ajv, projectDir) {
|
|
129
128
|
return validateFile(
|
|
130
129
|
ajv,
|
|
131
|
-
join(
|
|
130
|
+
join(projectDir, "openuispec.yaml"),
|
|
132
131
|
`${BASE}openuispec.schema.json`,
|
|
133
132
|
);
|
|
134
133
|
},
|
|
@@ -136,7 +135,7 @@ const GROUPS: Record<string, ValidationGroup> = {
|
|
|
136
135
|
|
|
137
136
|
tokens: {
|
|
138
137
|
label: "Tokens",
|
|
139
|
-
run(ajv) {
|
|
138
|
+
run(ajv, projectDir) {
|
|
140
139
|
let errors = 0;
|
|
141
140
|
const tokenMap: Record<string, string> = {
|
|
142
141
|
"color.yaml": "color.schema.json",
|
|
@@ -149,11 +148,10 @@ const GROUPS: Record<string, ValidationGroup> = {
|
|
|
149
148
|
"icons.yaml": "icons.schema.json",
|
|
150
149
|
};
|
|
151
150
|
for (const [data, schema] of Object.entries(tokenMap)) {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
);
|
|
151
|
+
const filePath = join(projectDir, "tokens", data);
|
|
152
|
+
if (existsSync(filePath)) {
|
|
153
|
+
errors += validateFile(ajv, filePath, `${BASE}tokens/${schema}`);
|
|
154
|
+
}
|
|
157
155
|
}
|
|
158
156
|
return errors;
|
|
159
157
|
},
|
|
@@ -161,9 +159,9 @@ const GROUPS: Record<string, ValidationGroup> = {
|
|
|
161
159
|
|
|
162
160
|
screens: {
|
|
163
161
|
label: "Screens",
|
|
164
|
-
run(ajv) {
|
|
162
|
+
run(ajv, projectDir) {
|
|
165
163
|
let errors = 0;
|
|
166
|
-
for (const f of listFiles(join(
|
|
164
|
+
for (const f of listFiles(join(projectDir, "screens"), ".yaml")) {
|
|
167
165
|
errors += validateFile(ajv, f, `${BASE}screen.schema.json`);
|
|
168
166
|
}
|
|
169
167
|
return errors;
|
|
@@ -172,9 +170,9 @@ const GROUPS: Record<string, ValidationGroup> = {
|
|
|
172
170
|
|
|
173
171
|
flows: {
|
|
174
172
|
label: "Flows",
|
|
175
|
-
run(ajv) {
|
|
173
|
+
run(ajv, projectDir) {
|
|
176
174
|
let errors = 0;
|
|
177
|
-
for (const f of listFiles(join(
|
|
175
|
+
for (const f of listFiles(join(projectDir, "flows"), ".yaml")) {
|
|
178
176
|
errors += validateFile(ajv, f, `${BASE}flow.schema.json`);
|
|
179
177
|
}
|
|
180
178
|
return errors;
|
|
@@ -183,9 +181,9 @@ const GROUPS: Record<string, ValidationGroup> = {
|
|
|
183
181
|
|
|
184
182
|
platform: {
|
|
185
183
|
label: "Platform",
|
|
186
|
-
run(ajv) {
|
|
184
|
+
run(ajv, projectDir) {
|
|
187
185
|
let errors = 0;
|
|
188
|
-
for (const f of listFiles(join(
|
|
186
|
+
for (const f of listFiles(join(projectDir, "platform"), ".yaml")) {
|
|
189
187
|
errors += validateFile(ajv, f, `${BASE}platform.schema.json`);
|
|
190
188
|
}
|
|
191
189
|
return errors;
|
|
@@ -194,9 +192,9 @@ const GROUPS: Record<string, ValidationGroup> = {
|
|
|
194
192
|
|
|
195
193
|
locales: {
|
|
196
194
|
label: "Locales",
|
|
197
|
-
run(ajv) {
|
|
195
|
+
run(ajv, projectDir) {
|
|
198
196
|
let errors = 0;
|
|
199
|
-
for (const f of listFiles(join(
|
|
197
|
+
for (const f of listFiles(join(projectDir, "locales"), ".json")) {
|
|
200
198
|
errors += validateFile(ajv, f, `${BASE}locale.schema.json`);
|
|
201
199
|
}
|
|
202
200
|
return errors;
|
|
@@ -205,9 +203,9 @@ const GROUPS: Record<string, ValidationGroup> = {
|
|
|
205
203
|
|
|
206
204
|
custom_contracts: {
|
|
207
205
|
label: "Custom contracts",
|
|
208
|
-
run(ajv) {
|
|
206
|
+
run(ajv, projectDir) {
|
|
209
207
|
let errors = 0;
|
|
210
|
-
for (const f of listFiles(join(
|
|
208
|
+
for (const f of listFiles(join(projectDir, "contracts"), ".yaml")) {
|
|
211
209
|
if (basename(f).startsWith("x_")) {
|
|
212
210
|
errors += validateFile(
|
|
213
211
|
ajv,
|
|
@@ -221,13 +219,36 @@ const GROUPS: Record<string, ValidationGroup> = {
|
|
|
221
219
|
},
|
|
222
220
|
};
|
|
223
221
|
|
|
222
|
+
// ── project resolution ───────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
function findProjectDir(cwd: string): string {
|
|
225
|
+
const candidates = [
|
|
226
|
+
join(cwd, "openuispec"),
|
|
227
|
+
cwd,
|
|
228
|
+
];
|
|
229
|
+
for (const dir of candidates) {
|
|
230
|
+
if (existsSync(join(dir, "openuispec.yaml"))) {
|
|
231
|
+
return dir;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
// Fallback for running from repo root with examples/
|
|
235
|
+
const examplesDir = join(cwd, "examples", "taskflow");
|
|
236
|
+
if (existsSync(join(examplesDir, "openuispec.yaml"))) {
|
|
237
|
+
return examplesDir;
|
|
238
|
+
}
|
|
239
|
+
console.error(
|
|
240
|
+
"Error: No openuispec.yaml found.\n" +
|
|
241
|
+
"Run from a directory containing openuispec.yaml or an openuispec/ subdirectory."
|
|
242
|
+
);
|
|
243
|
+
process.exit(1);
|
|
244
|
+
}
|
|
245
|
+
|
|
224
246
|
// ── main ─────────────────────────────────────────────────────────────
|
|
225
247
|
|
|
226
|
-
function
|
|
227
|
-
const args = process.argv.slice(2);
|
|
248
|
+
export function runValidate(argv: string[]): void {
|
|
228
249
|
const selected =
|
|
229
|
-
|
|
230
|
-
?
|
|
250
|
+
argv.length > 0
|
|
251
|
+
? argv.filter((a) => a in GROUPS)
|
|
231
252
|
: Object.keys(GROUPS);
|
|
232
253
|
|
|
233
254
|
if (selected.length === 0) {
|
|
@@ -237,13 +258,14 @@ function main(): void {
|
|
|
237
258
|
process.exit(2);
|
|
238
259
|
}
|
|
239
260
|
|
|
261
|
+
const projectDir = findProjectDir(process.cwd());
|
|
240
262
|
const ajv = buildAjv();
|
|
241
263
|
let totalErrors = 0;
|
|
242
264
|
|
|
243
265
|
for (const key of selected) {
|
|
244
266
|
const group = GROUPS[key];
|
|
245
267
|
console.log(`\n${group.label}:`);
|
|
246
|
-
totalErrors += group.run(ajv);
|
|
268
|
+
totalErrors += group.run(ajv, projectDir);
|
|
247
269
|
}
|
|
248
270
|
|
|
249
271
|
console.log(`\n${"=".repeat(50)}`);
|
|
@@ -255,4 +277,11 @@ function main(): void {
|
|
|
255
277
|
}
|
|
256
278
|
}
|
|
257
279
|
|
|
258
|
-
|
|
280
|
+
// Direct execution
|
|
281
|
+
const isDirectRun =
|
|
282
|
+
process.argv[1]?.endsWith("validate.ts") ||
|
|
283
|
+
process.argv[1]?.endsWith("validate.js");
|
|
284
|
+
|
|
285
|
+
if (isDirectRun) {
|
|
286
|
+
runValidate(process.argv.slice(2));
|
|
287
|
+
}
|