openuispec 0.1.25 → 0.1.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +44 -2
  2. package/cli/index.ts +21 -3
  3. package/cli/init.ts +24 -8
  4. package/docs/implementation-notes.md +115 -0
  5. package/docs/release-notes-v0.1.26.md +64 -0
  6. package/docs/release-notes-v0.1.27.md +28 -0
  7. package/drift/index.ts +375 -18
  8. package/examples/todo-orbit/AGENTS.md +11 -4
  9. package/examples/todo-orbit/CLAUDE.md +11 -4
  10. package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/java/uz/rsteam/todoorbit/ui/components/CommonComponents.kt +69 -18
  11. package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/java/uz/rsteam/todoorbit/ui/screens/TasksScreen.kt +5 -0
  12. package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/java/uz/rsteam/todoorbit/ui/sheets/EditorSheets.kt +5 -2
  13. package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/res/values/strings.xml +1 -0
  14. package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/res/values-ru/strings.xml +1 -0
  15. package/examples/todo-orbit/generated/ios/Todo Orbit/Resources/en.lproj/Localizable.strings +1 -0
  16. package/examples/todo-orbit/generated/ios/Todo Orbit/Resources/ru.lproj/Localizable.strings +1 -0
  17. package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Flows/RecurringRuleSheet.swift +3 -0
  18. package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Flows/TaskEditorSheet.swift +1 -0
  19. package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Screens/SettingsView.swift +2 -0
  20. package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Screens/TasksHomeView.swift +1 -0
  21. package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Support/AppModel.swift +1 -1
  22. package/examples/todo-orbit/generated/web/Todo Orbit/src/App.tsx +59 -6
  23. package/examples/todo-orbit/generated/web/Todo Orbit/src/styles.css +40 -0
  24. package/examples/todo-orbit/openuispec/README.md +24 -131
  25. package/examples/todo-orbit/openuispec/flows/create_recurring_rule.yaml +3 -0
  26. package/examples/todo-orbit/openuispec/flows/create_task.yaml +1 -0
  27. package/examples/todo-orbit/openuispec/flows/edit_task.yaml +1 -0
  28. package/examples/todo-orbit/openuispec/locales/en.json +1 -0
  29. package/examples/todo-orbit/openuispec/locales/ru.json +1 -0
  30. package/examples/todo-orbit/openuispec/screens/task_detail.yaml +1 -0
  31. package/examples/todo-orbit/openuispec/tokens/icons.yaml +6 -0
  32. package/package.json +6 -1
  33. package/prepare/index.ts +391 -0
  34. package/schema/semantic-lint.ts +592 -0
  35. package/schema/validate.ts +8 -9
  36. package/status/index.ts +187 -0
package/drift/index.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  * openuispec drift --target ios # check drift for ios
11
11
  * openuispec drift # check all targets with snapshots
12
12
  * openuispec drift --snapshot --target ios # snapshot for ios
13
+ * openuispec drift --target ios --explain # explain semantic changes since baseline
13
14
  * openuispec drift --json --target ios # machine-readable output
14
15
  * openuispec drift --target ios --all # include stubs in drift count
15
16
  */
@@ -17,6 +18,7 @@
17
18
  import { readFileSync, writeFileSync, readdirSync, existsSync } from "node:fs";
18
19
  import { resolve, join, relative, basename, dirname } from "node:path";
19
20
  import { createHash } from "node:crypto";
21
+ import { execFileSync } from "node:child_process";
20
22
  import YAML from "yaml";
21
23
 
22
24
  const STATE_FILE = ".openuispec-state.json";
@@ -28,20 +30,47 @@ interface FileEntry {
28
30
  status: string;
29
31
  }
30
32
 
31
- interface StateFile {
33
+ export interface StateFile {
32
34
  spec_version: string;
33
35
  snapshot_at: string;
34
36
  target: string;
37
+ baseline?: BaselineRef;
35
38
  files: Record<string, FileEntry>;
36
39
  }
37
40
 
38
- interface DriftResult {
41
+ export interface BaselineRef {
42
+ kind: "git_commit" | "working_tree";
43
+ commit: string | null;
44
+ branch: string | null;
45
+ }
46
+
47
+ export interface DriftResult {
39
48
  changed: string[];
40
49
  added: string[];
41
50
  removed: string[];
42
51
  unchanged: string[];
43
52
  }
44
53
 
54
+ export interface SemanticChange {
55
+ kind: "added" | "removed" | "changed";
56
+ path: string;
57
+ before?: string;
58
+ after?: string;
59
+ }
60
+
61
+ export interface FileExplanation {
62
+ file: string;
63
+ status: "added" | "removed" | "changed";
64
+ changes: SemanticChange[];
65
+ truncated: boolean;
66
+ }
67
+
68
+ export interface ExplainResult {
69
+ available: boolean;
70
+ note?: string;
71
+ files: FileExplanation[];
72
+ }
73
+
45
74
  // ── helpers ───────────────────────────────────────────────────────────
46
75
 
47
76
  function listFiles(dir: string, ext: string): string[] {
@@ -61,6 +90,21 @@ function hashFile(filePath: string): string {
61
90
  return `sha256:${hash}`;
62
91
  }
63
92
 
93
+ function readFileIfExists(filePath: string): string | null {
94
+ try {
95
+ return readFileSync(filePath, "utf-8");
96
+ } catch {
97
+ return null;
98
+ }
99
+ }
100
+
101
+ function parseSpecDocument(relPath: string, content: string): unknown {
102
+ if (relPath.endsWith(".json")) {
103
+ return JSON.parse(content);
104
+ }
105
+ return YAML.parse(content);
106
+ }
107
+
64
108
  /** Read the status field from a screen or flow YAML file. */
65
109
  function readStatus(filePath: string): string {
66
110
  try {
@@ -129,10 +173,249 @@ function categorize(relPath: string): string {
129
173
  return "Other";
130
174
  }
131
175
 
176
+ function runGit(args: string[], cwd: string): string | null {
177
+ try {
178
+ return execFileSync("git", args, {
179
+ cwd,
180
+ encoding: "utf-8",
181
+ stdio: ["ignore", "pipe", "ignore"],
182
+ }).trim();
183
+ } catch {
184
+ return null;
185
+ }
186
+ }
187
+
188
+ function gitPathForFile(projectDir: string, relPath: string): string | null {
189
+ const repoRoot = runGit(["rev-parse", "--show-toplevel"], projectDir);
190
+ if (!repoRoot) return null;
191
+ return relative(repoRoot, join(projectDir, relPath));
192
+ }
193
+
194
+ function readFileFromGit(projectDir: string, commit: string, relPath: string): string | null {
195
+ const gitPath = gitPathForFile(projectDir, relPath);
196
+ if (!gitPath) return null;
197
+ return runGit(["show", `${commit}:${gitPath}`], projectDir);
198
+ }
199
+
200
+ function captureBaseline(projectDir: string, files: string[]): BaselineRef | undefined {
201
+ const repoRoot = runGit(["rev-parse", "--show-toplevel"], projectDir);
202
+ if (!repoRoot) return undefined;
203
+
204
+ const branch = runGit(["branch", "--show-current"], projectDir);
205
+ const commit = runGit(["rev-parse", "HEAD"], projectDir);
206
+ const repoPaths = files.map((file) => relative(repoRoot, file));
207
+ const status = runGit(["status", "--porcelain", "--", ...repoPaths], projectDir) ?? "";
208
+
209
+ return {
210
+ kind: status.length > 0 ? "working_tree" : "git_commit",
211
+ commit,
212
+ branch: branch || null,
213
+ };
214
+ }
215
+
216
+ export function formatBaseline(baseline?: BaselineRef): string | null {
217
+ if (!baseline) return null;
218
+
219
+ const ref = baseline.commit ? baseline.commit.slice(0, 12) : "uncommitted";
220
+ const branchSuffix = baseline.branch ? ` on ${baseline.branch}` : "";
221
+
222
+ if (baseline.kind === "git_commit") {
223
+ return `${ref}${branchSuffix} (exact git baseline)`;
224
+ }
225
+
226
+ return `${ref}${branchSuffix} + working tree spec changes`;
227
+ }
228
+
229
+ const MAX_CHANGES_PER_FILE = 20;
230
+ const MAX_VALUE_LENGTH = 120;
231
+
232
+ function summarizeValue(value: unknown): string {
233
+ if (typeof value === "string") {
234
+ return value.length > MAX_VALUE_LENGTH
235
+ ? JSON.stringify(`${value.slice(0, MAX_VALUE_LENGTH - 1)}…`)
236
+ : JSON.stringify(value);
237
+ }
238
+
239
+ const serialized = JSON.stringify(value);
240
+ if (!serialized) return String(value);
241
+ return serialized.length > MAX_VALUE_LENGTH
242
+ ? `${serialized.slice(0, MAX_VALUE_LENGTH - 1)}…`
243
+ : serialized;
244
+ }
245
+
246
+ function compareSemanticValue(
247
+ path: string,
248
+ before: unknown,
249
+ after: unknown,
250
+ changes: SemanticChange[]
251
+ ): void {
252
+ if (changes.length >= MAX_CHANGES_PER_FILE) return;
253
+
254
+ if (before === undefined && after === undefined) return;
255
+ if (before === undefined) {
256
+ changes.push({ kind: "added", path, after: summarizeValue(after) });
257
+ return;
258
+ }
259
+ if (after === undefined) {
260
+ changes.push({ kind: "removed", path, before: summarizeValue(before) });
261
+ return;
262
+ }
263
+
264
+ if (Array.isArray(before) || Array.isArray(after)) {
265
+ if (!Array.isArray(before) || !Array.isArray(after)) {
266
+ changes.push({
267
+ kind: "changed",
268
+ path,
269
+ before: summarizeValue(before),
270
+ after: summarizeValue(after),
271
+ });
272
+ return;
273
+ }
274
+
275
+ const maxLength = Math.max(before.length, after.length);
276
+ for (let index = 0; index < maxLength; index += 1) {
277
+ compareSemanticValue(`${path}[${index}]`, before[index], after[index], changes);
278
+ if (changes.length >= MAX_CHANGES_PER_FILE) return;
279
+ }
280
+ return;
281
+ }
282
+
283
+ if (
284
+ before &&
285
+ after &&
286
+ typeof before === "object" &&
287
+ typeof after === "object"
288
+ ) {
289
+ const beforeObj = before as Record<string, unknown>;
290
+ const afterObj = after as Record<string, unknown>;
291
+ const keys = Array.from(new Set([...Object.keys(beforeObj), ...Object.keys(afterObj)])).sort();
292
+
293
+ for (const key of keys) {
294
+ const nextPath = path ? `${path}.${key}` : key;
295
+ compareSemanticValue(nextPath, beforeObj[key], afterObj[key], changes);
296
+ if (changes.length >= MAX_CHANGES_PER_FILE) return;
297
+ }
298
+ return;
299
+ }
300
+
301
+ if (before !== after) {
302
+ changes.push({
303
+ kind: "changed",
304
+ path,
305
+ before: summarizeValue(before),
306
+ after: summarizeValue(after),
307
+ });
308
+ }
309
+ }
310
+
311
+ function explainFileChange(
312
+ projectDir: string,
313
+ baselineCommit: string,
314
+ relPath: string,
315
+ status: "added" | "removed" | "changed"
316
+ ): FileExplanation {
317
+ if (status === "added") {
318
+ return {
319
+ file: relPath,
320
+ status,
321
+ changes: [{ kind: "added", path: relPath }],
322
+ truncated: false,
323
+ };
324
+ }
325
+
326
+ if (status === "removed") {
327
+ return {
328
+ file: relPath,
329
+ status,
330
+ changes: [{ kind: "removed", path: relPath }],
331
+ truncated: false,
332
+ };
333
+ }
334
+
335
+ const beforeContent = readFileFromGit(projectDir, baselineCommit, relPath);
336
+ const afterContent = readFileIfExists(join(projectDir, relPath));
337
+
338
+ if (!beforeContent || !afterContent) {
339
+ return {
340
+ file: relPath,
341
+ status,
342
+ changes: [
343
+ {
344
+ kind: "changed",
345
+ path: relPath,
346
+ before: beforeContent ? "available" : "missing from baseline",
347
+ after: afterContent ? "available" : "missing from working tree",
348
+ },
349
+ ],
350
+ truncated: false,
351
+ };
352
+ }
353
+
354
+ try {
355
+ const beforeDoc = parseSpecDocument(relPath, beforeContent);
356
+ const afterDoc = parseSpecDocument(relPath, afterContent);
357
+ const changes: SemanticChange[] = [];
358
+ compareSemanticValue("", beforeDoc, afterDoc, changes);
359
+
360
+ return {
361
+ file: relPath,
362
+ status,
363
+ changes,
364
+ truncated: changes.length >= MAX_CHANGES_PER_FILE,
365
+ };
366
+ } catch (error) {
367
+ return {
368
+ file: relPath,
369
+ status,
370
+ changes: [
371
+ {
372
+ kind: "changed",
373
+ path: relPath,
374
+ after: error instanceof Error ? error.message : "unable to parse file diff",
375
+ },
376
+ ],
377
+ truncated: false,
378
+ };
379
+ }
380
+ }
381
+
382
+ export function explainDrift(projectDir: string, result: CheckResult): ExplainResult {
383
+ const baseline = result.state.baseline;
384
+ if (!baseline?.commit) {
385
+ return {
386
+ available: false,
387
+ note: "No git baseline metadata found in snapshot. Re-run `openuispec drift --snapshot --target <target>` from a git checkout.",
388
+ files: [],
389
+ };
390
+ }
391
+
392
+ if (baseline.kind !== "git_commit") {
393
+ return {
394
+ available: false,
395
+ note: "Snapshot was created from a dirty working tree, so semantic diff cannot reconstruct the exact baseline. Re-snapshot from a clean commit for precise explanations.",
396
+ files: [],
397
+ };
398
+ }
399
+
400
+ const files: FileExplanation[] = [];
401
+ for (const relPath of result.drift.added) {
402
+ files.push(explainFileChange(projectDir, baseline.commit, relPath, "added"));
403
+ }
404
+ for (const relPath of result.drift.removed) {
405
+ files.push(explainFileChange(projectDir, baseline.commit, relPath, "removed"));
406
+ }
407
+ for (const relPath of result.drift.changed) {
408
+ files.push(explainFileChange(projectDir, baseline.commit, relPath, "changed"));
409
+ }
410
+
411
+ files.sort((a, b) => a.file.localeCompare(b.file));
412
+ return { available: true, files };
413
+ }
414
+
132
415
  // ── project resolution ───────────────────────────────────────────────
133
416
 
134
417
  /** Find the spec project directory by looking for openuispec.yaml. */
135
- function findProjectDir(cwd: string): string {
418
+ export function findProjectDir(cwd: string): string {
136
419
  const candidates = [
137
420
  join(cwd, "openuispec"),
138
421
  cwd,
@@ -152,7 +435,7 @@ function findProjectDir(cwd: string): string {
152
435
  }
153
436
 
154
437
  /** Read the project name from the manifest. */
155
- function readProjectName(projectDir: string): string {
438
+ export function readProjectName(projectDir: string): string {
156
439
  const doc = YAML.parse(
157
440
  readFileSync(join(projectDir, "openuispec.yaml"), "utf-8")
158
441
  );
@@ -160,7 +443,7 @@ function readProjectName(projectDir: string): string {
160
443
  }
161
444
 
162
445
  /** Read per-target output_dir map from the manifest. */
163
- function readOutputDirs(projectDir: string): Record<string, string> {
446
+ export function readOutputDirs(projectDir: string): Record<string, string> {
164
447
  try {
165
448
  const doc = YAML.parse(readFileSync(join(projectDir, "openuispec.yaml"), "utf-8"));
166
449
  return doc.generation?.output_dir ?? {};
@@ -170,7 +453,7 @@ function readOutputDirs(projectDir: string): Record<string, string> {
170
453
  }
171
454
 
172
455
  /** Resolve the generated output directory for a target. */
173
- function resolveOutputDir(projectDir: string, projectName: string, target: string): string {
456
+ export function resolveOutputDir(projectDir: string, projectName: string, target: string): string {
174
457
  const outputDirs = readOutputDirs(projectDir);
175
458
  if (outputDirs[target]) {
176
459
  return resolve(projectDir, outputDirs[target]);
@@ -179,11 +462,11 @@ function resolveOutputDir(projectDir: string, projectName: string, target: strin
179
462
  return resolve(projectDir, "..", "generated", target, projectName);
180
463
  }
181
464
 
182
- function stateFilePath(projectDir: string, projectName: string, target: string): string {
465
+ export function stateFilePath(projectDir: string, projectName: string, target: string): string {
183
466
  return join(resolveOutputDir(projectDir, projectName, target), STATE_FILE);
184
467
  }
185
468
 
186
- function discoverTargets(projectDir: string, projectName: string): string[] {
469
+ export function discoverTargets(projectDir: string, projectName: string): string[] {
187
470
  const outputDirs = readOutputDirs(projectDir);
188
471
  const targets: string[] = [];
189
472
 
@@ -241,6 +524,7 @@ function snapshot(cwd: string, projectDir: string, target: string): void {
241
524
 
242
525
  const files = discoverSpecFiles(projectDir);
243
526
  const doc = YAML.parse(readFileSync(join(projectDir, "openuispec.yaml"), "utf-8"));
527
+ const baseline = captureBaseline(projectDir, files);
244
528
 
245
529
  const entries: Record<string, FileEntry> = {};
246
530
  let stubCount = 0;
@@ -256,6 +540,7 @@ function snapshot(cwd: string, projectDir: string, target: string): void {
256
540
  spec_version: doc.spec_version ?? "0.1",
257
541
  snapshot_at: new Date().toISOString(),
258
542
  target,
543
+ baseline,
259
544
  files: entries,
260
545
  };
261
546
 
@@ -267,18 +552,23 @@ function snapshot(cwd: string, projectDir: string, target: string): void {
267
552
  console.log(` ${stubCount} stubs (not tracked for drift)`);
268
553
  }
269
554
  console.log(` target: ${target}`);
555
+ const baselineLabel = formatBaseline(baseline);
556
+ if (baselineLabel) {
557
+ console.log(` baseline: ${baselineLabel}`);
558
+ }
270
559
  }
271
560
 
272
561
  // ── check ─────────────────────────────────────────────────────────────
273
562
 
274
- interface CheckResult {
563
+ export interface CheckResult {
275
564
  state: StateFile;
276
565
  drift: DriftResult;
277
566
  stubDrift: DriftResult;
278
567
  statuses: Record<string, string>;
568
+ explanation?: ExplainResult;
279
569
  }
280
570
 
281
- function computeDrift(
571
+ export function computeDrift(
282
572
  projectDir: string,
283
573
  state: StateFile,
284
574
  includeAll: boolean
@@ -326,13 +616,13 @@ function computeDrift(
326
616
  return { state, drift, stubDrift, statuses };
327
617
  }
328
618
 
329
- function check(
619
+ export function loadTargetDrift(
330
620
  cwd: string,
331
- projectDir: string,
332
621
  target: string,
333
- jsonOutput: boolean,
334
- includeAll: boolean
335
- ): void {
622
+ includeAll: boolean,
623
+ explainOutput: boolean
624
+ ): { projectDir: string; projectName: string; statePath: string; result: CheckResult } {
625
+ const projectDir = findProjectDir(cwd);
336
626
  const projectName = readProjectName(projectDir);
337
627
  const statePath = stateFilePath(projectDir, projectName, target);
338
628
  if (!existsSync(statePath)) {
@@ -345,6 +635,22 @@ function check(
345
635
 
346
636
  const state: StateFile = JSON.parse(readFileSync(statePath, "utf-8"));
347
637
  const result = computeDrift(projectDir, state, includeAll);
638
+ if (explainOutput) {
639
+ result.explanation = explainDrift(projectDir, result);
640
+ }
641
+
642
+ return { projectDir, projectName, statePath, result };
643
+ }
644
+
645
+ function check(
646
+ cwd: string,
647
+ projectDir: string,
648
+ target: string,
649
+ jsonOutput: boolean,
650
+ includeAll: boolean,
651
+ explainOutput: boolean
652
+ ): void {
653
+ const { result } = loadTargetDrift(cwd, target, includeAll, explainOutput);
348
654
 
349
655
  if (jsonOutput) {
350
656
  printJson(result);
@@ -361,7 +667,8 @@ function checkAll(
361
667
  cwd: string,
362
668
  projectDir: string,
363
669
  jsonOutput: boolean,
364
- includeAll: boolean
670
+ includeAll: boolean,
671
+ explainOutput: boolean
365
672
  ): void {
366
673
  const projectName = readProjectName(projectDir);
367
674
  const targets = discoverTargets(projectDir, projectName);
@@ -378,6 +685,9 @@ function checkAll(
378
685
  const statePath = stateFilePath(projectDir, projectName, target);
379
686
  const state: StateFile = JSON.parse(readFileSync(statePath, "utf-8"));
380
687
  const result = computeDrift(projectDir, state, includeAll);
688
+ if (explainOutput) {
689
+ result.explanation = explainDrift(projectDir, result);
690
+ }
381
691
 
382
692
  if (jsonOutput) {
383
693
  printJson(result);
@@ -411,7 +721,9 @@ function printJson(result: CheckResult): void {
411
721
  {
412
722
  snapshot_at: result.state.snapshot_at,
413
723
  target: result.state.target,
724
+ baseline: result.state.baseline,
414
725
  ...result.drift,
726
+ explanation: result.explanation,
415
727
  stubs: stubTotal > 0 ? result.stubDrift : undefined,
416
728
  },
417
729
  null,
@@ -428,6 +740,10 @@ function printReport(projectDir: string, result: CheckResult): void {
428
740
  console.log(`Project: ${projectName}`);
429
741
  console.log(`Snapshot: ${result.state.snapshot_at}`);
430
742
  console.log(`Target: ${result.state.target}`);
743
+ const baselineLabel = formatBaseline(result.state.baseline);
744
+ if (baselineLabel) {
745
+ console.log(`Baseline: ${baselineLabel}`);
746
+ }
431
747
 
432
748
  const d = result.drift;
433
749
 
@@ -500,6 +816,46 @@ function printReport(projectDir: string, result: CheckResult): void {
500
816
  console.log(
501
817
  `\nSummary: ${d.changed.length} changed, ${d.added.length} added, ${d.removed.length} removed${stubSuffix}`
502
818
  );
819
+
820
+ if (result.explanation) {
821
+ console.log("\nSemantic Changes");
822
+ console.log("----------------");
823
+
824
+ if (!result.explanation.available) {
825
+ console.log(result.explanation.note ?? "Semantic explanation unavailable.");
826
+ return;
827
+ }
828
+
829
+ if (result.explanation.files.length === 0) {
830
+ console.log("No semantic changes to explain.");
831
+ return;
832
+ }
833
+
834
+ for (const file of result.explanation.files) {
835
+ console.log(`\n${file.file}`);
836
+ if (file.changes.length === 0) {
837
+ console.log(" · no property-level changes detected");
838
+ continue;
839
+ }
840
+
841
+ for (const change of file.changes) {
842
+ const pathLabel = change.path || "(root)";
843
+ if (change.kind === "added") {
844
+ const value = change.after ? ` = ${change.after}` : "";
845
+ console.log(` + ${pathLabel}${value}`);
846
+ } else if (change.kind === "removed") {
847
+ const value = change.before ? ` (was ${change.before})` : "";
848
+ console.log(` - ${pathLabel}${value}`);
849
+ } else {
850
+ console.log(` ~ ${pathLabel}: ${change.before ?? "?"} -> ${change.after ?? "?"}`);
851
+ }
852
+ }
853
+
854
+ if (file.truncated) {
855
+ console.log(` … truncated after ${MAX_CHANGES_PER_FILE} changes`);
856
+ }
857
+ }
858
+ }
503
859
  }
504
860
 
505
861
  // ── main ──────────────────────────────────────────────────────────────
@@ -508,6 +864,7 @@ export function runDrift(argv: string[]): void {
508
864
  const isSnapshot = argv.includes("--snapshot");
509
865
  const isJson = argv.includes("--json");
510
866
  const includeAll = argv.includes("--all");
867
+ const explainOutput = argv.includes("--explain");
511
868
 
512
869
  const targetIdx = argv.indexOf("--target");
513
870
  const target = targetIdx !== -1 && argv[targetIdx + 1] ? argv[targetIdx + 1] : null;
@@ -523,9 +880,9 @@ export function runDrift(argv: string[]): void {
523
880
  }
524
881
  snapshot(cwd, projectDir, target);
525
882
  } else if (target) {
526
- check(cwd, projectDir, target, isJson, includeAll);
883
+ check(cwd, projectDir, target, isJson, includeAll, explainOutput);
527
884
  } else {
528
- checkAll(cwd, projectDir, isJson, includeAll);
885
+ checkAll(cwd, projectDir, isJson, includeAll, explainOutput);
529
886
  }
530
887
  }
531
888
 
@@ -1,5 +1,5 @@
1
1
  <!-- openuispec-rules-start -->
2
- <!-- openuispec-rules-version: 0.1.22 -->
2
+ <!-- openuispec-rules-version: 0.1.23 -->
3
3
  # OpenUISpec — AI Assistant Rules
4
4
  # ================================
5
5
  # This project uses OpenUISpec to define UI as a semantic spec.
@@ -66,15 +66,22 @@ This means the project has existing UI code but hasn't been specced yet. Your jo
66
66
 
67
67
  ## After modifying spec files
68
68
  1. Run `openuispec validate` to check specs against the schema.
69
- 2. **Update the generated code** for each affected platform to match the new spec.
70
- 3. Run `openuispec drift --snapshot --target <target>` to baseline the updated state.
71
- 4. Run `openuispec drift` to verify no untracked drift remains.
69
+ 2. Run `openuispec validate semantic`.
70
+ 3. Run `openuispec drift --target <target> --explain` to inspect semantic changes since that target's baseline.
71
+ 4. Run `openuispec prepare --target <target>` to build the target update bundle.
72
+ 5. **Update the generated code** for each affected platform to match the new spec.
73
+ 6. Run `openuispec drift --snapshot --target <target>` to baseline the updated state.
74
+ 7. Run `openuispec drift --target <target> --explain` again to confirm no spec changes remain for that target.
75
+ 8. Run `openuispec status` to see which other targets are still behind.
72
76
 
73
77
  ## CLI commands
74
78
  - `openuispec init` — scaffold a new spec project
75
79
  - `openuispec validate [group...]` — validate spec files against schemas
80
+ - `openuispec validate semantic` — run semantic cross-reference linting
76
81
  - `openuispec drift --target <t>` — check for spec drift
82
+ - `openuispec drift --target <t> --explain` — explain semantic spec drift since the target baseline
77
83
  - `openuispec drift --snapshot --target <t>` — snapshot current state
84
+ - `openuispec prepare --target <t>` — build an AI-ready target update bundle
78
85
  - `openuispec update-rules` — update AI rules to match installed package version
79
86
  - `openuispec drift --all` — include stubs in drift check
80
87
  <!-- openuispec-rules-end -->
@@ -1,5 +1,5 @@
1
1
  <!-- openuispec-rules-start -->
2
- <!-- openuispec-rules-version: 0.1.22 -->
2
+ <!-- openuispec-rules-version: 0.1.23 -->
3
3
  # OpenUISpec — AI Assistant Rules
4
4
  # ================================
5
5
  # This project uses OpenUISpec to define UI as a semantic spec.
@@ -66,15 +66,22 @@ This means the project has existing UI code but hasn't been specced yet. Your jo
66
66
 
67
67
  ## After modifying spec files
68
68
  1. Run `openuispec validate` to check specs against the schema.
69
- 2. **Update the generated code** for each affected platform to match the new spec.
70
- 3. Run `openuispec drift --snapshot --target <target>` to baseline the updated state.
71
- 4. Run `openuispec drift` to verify no untracked drift remains.
69
+ 2. Run `openuispec validate semantic`.
70
+ 3. Run `openuispec drift --target <target> --explain` to inspect semantic changes since that target's baseline.
71
+ 4. Run `openuispec prepare --target <target>` to build the target update bundle.
72
+ 5. **Update the generated code** for each affected platform to match the new spec.
73
+ 6. Run `openuispec drift --snapshot --target <target>` to baseline the updated state.
74
+ 7. Run `openuispec drift --target <target> --explain` again to confirm no spec changes remain for that target.
75
+ 8. Run `openuispec status` to see which other targets are still behind.
72
76
 
73
77
  ## CLI commands
74
78
  - `openuispec init` — scaffold a new spec project
75
79
  - `openuispec validate [group...]` — validate spec files against schemas
80
+ - `openuispec validate semantic` — run semantic cross-reference linting
76
81
  - `openuispec drift --target <t>` — check for spec drift
82
+ - `openuispec drift --target <t> --explain` — explain semantic spec drift since the target baseline
77
83
  - `openuispec drift --snapshot --target <t>` — snapshot current state
84
+ - `openuispec prepare --target <t>` — build an AI-ready target update bundle
78
85
  - `openuispec update-rules` — update AI rules to match installed package version
79
86
  - `openuispec drift --all` — include stubs in drift check
80
87
  <!-- openuispec-rules-end -->