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 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
+ ![How OpenUISpec works](docs/images/how-it-works.jpg)
34
21
 
35
22
  ## Workflows
36
23
 
37
- OpenUISpec supports two complementary workflows — one for designing new features, another for day-to-day development.
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
+ ![OpenUISpec workflows](docs/images/workflows.png)
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 # scaffold a new spec project
7
- * openuispec drift # check for spec drift (all targets)
8
- * openuispec drift --target ios
9
- * openuispec drift --snapshot --target ios
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
- console.error("drift command coming soon use npm run drift for now");
24
- process.exit(1);
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/anthropics/openuispec
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
- * npm run drift -- --target ios # check drift for ios
10
- * npm run drift # check all targets with snapshots
11
- * npm run drift -- --snapshot --target ios # snapshot for ios
12
- * npm run drift -- --json --target ios # machine-readable output
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, mkdirSync } from "node:fs";
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, 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
- function resolveOutputDir(target: string): string {
119
- return resolve(GENERATED_DIR, target, "TaskFlow");
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
- function stateFilePath(target: string): string {
123
- return join(resolveOutputDir(target), STATE_FILE);
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
- /** Discover all targets that have an existing snapshot. */
127
- function discoverTargets(): string[] {
128
- if (!existsSync(GENERATED_DIR)) return [];
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(GENERATED_DIR)
131
- .filter((entry) => existsSync(stateFilePath(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 outDir = resolveOutputDir(target);
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(REPO_ROOT, outDir)}\n` +
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 hashes: Record<string, string> = {};
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
- hashes[rel] = hashFile(f);
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: hashes,
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(REPO_ROOT, outPath)}`);
169
- console.log(` ${Object.keys(hashes).length} files hashed`);
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
- function check(projectDir: string, target: string, jsonOutput: boolean): void {
176
- const statePath = stateFilePath(target);
177
- if (!existsSync(statePath)) {
178
- console.error(
179
- `No snapshot found for target "${target}".\n` +
180
- `Run: npm run drift -- --snapshot --target ${target}`
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
- const state: StateFile = JSON.parse(readFileSync(statePath, "utf-8"));
248
+ function computeDrift(
249
+ projectDir: string,
250
+ state: StateFile,
251
+ includeAll: boolean
252
+ ): CheckResult {
186
253
  const files = discoverSpecFiles(projectDir);
187
254
 
188
- const currentHashes: Record<string, string> = {};
255
+ const current: Record<string, FileEntry> = {};
189
256
  for (const f of files) {
190
- currentHashes[relative(projectDir, f)] = hashFile(f);
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 result: DriftResult = {
194
- changed: [],
195
- added: [],
196
- removed: [],
197
- unchanged: [],
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
- // Check current files against snapshot
201
- for (const [rel, hash] of Object.entries(currentHashes)) {
202
- if (!(rel in state.files)) {
203
- result.added.push(rel);
204
- } else if (state.files[rel] !== hash) {
205
- result.changed.push(rel);
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
- result.unchanged.push(rel);
280
+ bucket.unchanged.push(rel);
208
281
  }
209
282
  }
210
283
 
211
- // Check for removed files
212
- for (const rel of Object.keys(state.files)) {
213
- if (!(rel in currentHashes)) {
214
- result.removed.push(rel);
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(state, result);
317
+ printJson(result);
220
318
  } else {
221
- printReport(projectDir, state, result);
319
+ printReport(projectDir, result);
222
320
  }
223
321
 
224
- const hasDrift =
225
- result.changed.length > 0 ||
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(projectDir: string, jsonOutput: boolean): void {
232
- const targets = discoverTargets();
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: npm run drift -- --snapshot --target <target>"
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 files = discoverSpecFiles(projectDir);
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(state, result);
350
+ printJson(result);
277
351
  } else {
278
- printReport(projectDir, state, result);
352
+ printReport(projectDir, result);
279
353
  }
280
354
 
281
- const hasDrift =
282
- result.changed.length > 0 ||
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(state: StateFile, result: DriftResult): void {
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: string,
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
- // Group all files by category
328
- const allFiles = new Set([
329
- ...result.unchanged,
330
- ...result.changed,
331
- ...result.added,
332
- ...result.removed,
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 allFiles) {
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 (result.changed.includes(f)) {
362
- console.log(` ${name} (changed)`);
363
- } else if (result.added.includes(f)) {
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 (result.removed.includes(f)) {
438
+ } else if (d.removed.includes(f)) {
366
439
  console.log(` - ${name} (removed)`);
367
440
  } else {
368
- console.log(` ${name}`);
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: ${result.changed.length} changed, ${result.added.length} added, ${result.removed.length} removed`
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
- const args = process.argv.slice(2);
381
- const isSnapshot = args.includes("--snapshot");
382
- const isJson = args.includes("--json");
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 = args.indexOf("--target");
385
- const target = targetIdx !== -1 && args[targetIdx + 1] ? args[targetIdx + 1] : null;
479
+ const targetIdx = argv.indexOf("--target");
480
+ const target = targetIdx !== -1 && argv[targetIdx + 1] ? argv[targetIdx + 1] : null;
386
481
 
387
- if (isSnapshot) {
388
- if (!target) {
389
- console.error("Error: --target is required for --snapshot");
390
- console.error("Usage: npm run drift -- --snapshot --target <target>");
391
- process.exit(1);
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
- snapshot(PROJECT_DIR, target);
394
- } else if (target) {
395
- check(PROJECT_DIR, target, isJson);
396
- } else {
397
- checkAll(PROJECT_DIR, isJson);
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
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "openuispec",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
+ "license": "MIT",
4
5
  "type": "module",
5
6
  "description": "A semantic UI specification format for AI-native, platform-native app development",
6
7
  "repository": {
@@ -1,14 +1,14 @@
1
1
  #!/usr/bin/env tsx
2
2
  /**
3
- * Validate all TaskFlow example files against their OpenUISpec JSON Schemas.
3
+ * Validate OpenUISpec files against their JSON Schemas.
4
4
  *
5
5
  * Usage:
6
- * npm run validate # validate everything
7
- * npm run validate:tokens # validate only tokens
8
- * npx tsx schema/validate.ts screens flows # multiple groups
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(EXAMPLES_DIR, "openuispec.yaml"),
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
- errors += validateFile(
153
- ajv,
154
- join(EXAMPLES_DIR, "tokens", data),
155
- `${BASE}tokens/${schema}`,
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(EXAMPLES_DIR, "screens"), ".yaml")) {
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(EXAMPLES_DIR, "flows"), ".yaml")) {
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(EXAMPLES_DIR, "platform"), ".yaml")) {
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(EXAMPLES_DIR, "locales"), ".json")) {
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(EXAMPLES_DIR, "contracts"), ".yaml")) {
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 main(): void {
227
- const args = process.argv.slice(2);
248
+ export function runValidate(argv: string[]): void {
228
249
  const selected =
229
- args.length > 0
230
- ? args.filter((a) => a in GROUPS)
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
- main();
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
+ }