openuispec 0.1.25 → 0.1.28

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 (139) hide show
  1. package/README.md +63 -18
  2. package/cli/index.ts +21 -3
  3. package/cli/init.ts +27 -11
  4. package/docs/implementation-notes.md +119 -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/docs/release-notes-v0.1.28.md +25 -0
  8. package/docs/stress-test-maturity-report.md +1 -1
  9. package/drift/index.ts +396 -22
  10. package/examples/taskflow/AGENTS.md +112 -0
  11. package/examples/taskflow/CLAUDE.md +112 -0
  12. package/examples/taskflow/generated/android/TaskFlow/README.md +43 -0
  13. package/examples/taskflow/generated/android/TaskFlow/app/build.gradle.kts +76 -0
  14. package/examples/taskflow/generated/android/TaskFlow/app/proguard-rules.pro +1 -0
  15. package/examples/taskflow/generated/android/TaskFlow/app/src/main/AndroidManifest.xml +21 -0
  16. package/examples/taskflow/generated/android/TaskFlow/app/src/main/java/uz/rsteam/taskflow/MainActivity.kt +19 -0
  17. package/examples/taskflow/generated/android/TaskFlow/app/src/main/java/uz/rsteam/taskflow/TaskFlowApp.kt +283 -0
  18. package/examples/taskflow/generated/android/TaskFlow/app/src/main/java/uz/rsteam/taskflow/model/DomainModels.kt +106 -0
  19. package/examples/taskflow/generated/android/TaskFlow/app/src/main/java/uz/rsteam/taskflow/model/SampleData.kt +57 -0
  20. package/examples/taskflow/generated/android/TaskFlow/app/src/main/java/uz/rsteam/taskflow/ui/components/Common.kt +109 -0
  21. package/examples/taskflow/generated/android/TaskFlow/app/src/main/java/uz/rsteam/taskflow/ui/screens/HomeScreen.kt +112 -0
  22. package/examples/taskflow/generated/android/TaskFlow/app/src/main/java/uz/rsteam/taskflow/ui/screens/ProjectsScreen.kt +61 -0
  23. package/examples/taskflow/generated/android/TaskFlow/app/src/main/java/uz/rsteam/taskflow/ui/screens/SettingsScreen.kt +82 -0
  24. package/examples/taskflow/generated/android/TaskFlow/app/src/main/java/uz/rsteam/taskflow/ui/screens/TaskDetailScreen.kt +111 -0
  25. package/examples/taskflow/generated/android/TaskFlow/app/src/main/java/uz/rsteam/taskflow/ui/sheets/Sheets.kt +77 -0
  26. package/examples/taskflow/generated/android/TaskFlow/app/src/main/java/uz/rsteam/taskflow/ui/theme/Color.kt +30 -0
  27. package/examples/taskflow/generated/android/TaskFlow/app/src/main/java/uz/rsteam/taskflow/ui/theme/Theme.kt +86 -0
  28. package/examples/taskflow/generated/android/TaskFlow/app/src/main/java/uz/rsteam/taskflow/ui/theme/Type.kt +57 -0
  29. package/examples/taskflow/generated/android/TaskFlow/app/src/main/res/values/strings.xml +155 -0
  30. package/examples/taskflow/generated/android/TaskFlow/app/src/main/res/values/themes.xml +4 -0
  31. package/examples/taskflow/generated/android/TaskFlow/build.gradle.kts +5 -0
  32. package/examples/taskflow/generated/android/TaskFlow/gradle/gradle-daemon-jvm.properties +12 -0
  33. package/examples/taskflow/generated/android/TaskFlow/gradle/wrapper/gradle-wrapper.jar +0 -0
  34. package/examples/taskflow/generated/android/TaskFlow/gradle/wrapper/gradle-wrapper.properties +7 -0
  35. package/examples/taskflow/generated/android/TaskFlow/gradle.properties +4 -0
  36. package/examples/taskflow/generated/android/TaskFlow/gradlew +18 -0
  37. package/examples/taskflow/generated/android/TaskFlow/gradlew.bat +12 -0
  38. package/examples/taskflow/generated/android/TaskFlow/settings.gradle.kts +18 -0
  39. package/examples/taskflow/generated/ios/TaskFlow/README.md +21 -0
  40. package/examples/taskflow/generated/ios/TaskFlow/Resources/en.lproj/Localizable.strings +115 -0
  41. package/examples/taskflow/generated/ios/TaskFlow/Sources/TaskFlow/App/TaskFlowApp.swift +24 -0
  42. package/examples/taskflow/generated/ios/TaskFlow/Sources/TaskFlow/Components/AppChrome.swift +150 -0
  43. package/examples/taskflow/generated/ios/TaskFlow/Sources/TaskFlow/Flows/TaskEditorSheet.swift +220 -0
  44. package/examples/taskflow/generated/ios/TaskFlow/Sources/TaskFlow/Models/DomainModels.swift +122 -0
  45. package/examples/taskflow/generated/ios/TaskFlow/Sources/TaskFlow/Screens/CalendarView.swift +21 -0
  46. package/examples/taskflow/generated/ios/TaskFlow/Sources/TaskFlow/Screens/HomeView.swift +201 -0
  47. package/examples/taskflow/generated/ios/TaskFlow/Sources/TaskFlow/Screens/ProfileEditView.swift +48 -0
  48. package/examples/taskflow/generated/ios/TaskFlow/Sources/TaskFlow/Screens/ProjectDetailView.swift +59 -0
  49. package/examples/taskflow/generated/ios/TaskFlow/Sources/TaskFlow/Screens/ProjectsView.swift +63 -0
  50. package/examples/taskflow/generated/ios/TaskFlow/Sources/TaskFlow/Screens/SettingsView.swift +85 -0
  51. package/examples/taskflow/generated/ios/TaskFlow/Sources/TaskFlow/Screens/TaskDetailView.swift +219 -0
  52. package/examples/taskflow/generated/ios/TaskFlow/Sources/TaskFlow/Support/AppModel.swift +320 -0
  53. package/examples/taskflow/generated/ios/TaskFlow/Sources/TaskFlow/Support/AppSupport.swift +41 -0
  54. package/examples/taskflow/generated/ios/TaskFlow/project.yml +26 -0
  55. package/examples/taskflow/generated/web/TaskFlow/README.md +19 -0
  56. package/examples/taskflow/generated/web/TaskFlow/index.html +12 -0
  57. package/examples/taskflow/generated/web/TaskFlow/package-lock.json +1908 -0
  58. package/examples/taskflow/generated/web/TaskFlow/package.json +24 -0
  59. package/examples/taskflow/generated/web/TaskFlow/src/App.tsx +58 -0
  60. package/examples/taskflow/generated/web/TaskFlow/src/AppShell.tsx +55 -0
  61. package/examples/taskflow/generated/web/TaskFlow/src/components/Common.tsx +82 -0
  62. package/examples/taskflow/generated/web/TaskFlow/src/components/Modals.tsx +191 -0
  63. package/examples/taskflow/generated/web/TaskFlow/src/components/Nav.tsx +41 -0
  64. package/examples/taskflow/generated/web/TaskFlow/src/generated/messages.ts +131 -0
  65. package/examples/taskflow/generated/web/TaskFlow/src/hooks.ts +25 -0
  66. package/examples/taskflow/generated/web/TaskFlow/src/i18n.ts +39 -0
  67. package/examples/taskflow/generated/web/TaskFlow/src/locales.en.json +111 -0
  68. package/examples/taskflow/generated/web/TaskFlow/src/main.tsx +13 -0
  69. package/examples/taskflow/generated/web/TaskFlow/src/screens/HomeScreen.tsx +111 -0
  70. package/examples/taskflow/generated/web/TaskFlow/src/screens/ProjectsScreen.tsx +82 -0
  71. package/examples/taskflow/generated/web/TaskFlow/src/screens/SettingsScreens.tsx +132 -0
  72. package/examples/taskflow/generated/web/TaskFlow/src/screens/TaskDetail.tsx +105 -0
  73. package/examples/taskflow/generated/web/TaskFlow/src/store.ts +216 -0
  74. package/examples/taskflow/generated/web/TaskFlow/src/styles.css +617 -0
  75. package/examples/taskflow/generated/web/TaskFlow/src/types.ts +64 -0
  76. package/examples/taskflow/generated/web/TaskFlow/src/utils.ts +78 -0
  77. package/examples/taskflow/generated/web/TaskFlow/tsconfig.json +21 -0
  78. package/examples/taskflow/generated/web/TaskFlow/vite.config.ts +6 -0
  79. package/examples/taskflow/openuispec/README.md +49 -0
  80. package/examples/todo-orbit/AGENTS.md +46 -14
  81. package/examples/todo-orbit/CLAUDE.md +46 -14
  82. package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/java/uz/rsteam/todoorbit/ui/components/CommonComponents.kt +69 -18
  83. package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/java/uz/rsteam/todoorbit/ui/screens/TasksScreen.kt +5 -0
  84. package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/java/uz/rsteam/todoorbit/ui/sheets/EditorSheets.kt +5 -2
  85. package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/res/values/strings.xml +1 -0
  86. package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/res/values-ru/strings.xml +1 -0
  87. package/examples/todo-orbit/generated/ios/Todo Orbit/Resources/en.lproj/Localizable.strings +1 -0
  88. package/examples/todo-orbit/generated/ios/Todo Orbit/Resources/ru.lproj/Localizable.strings +1 -0
  89. package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Flows/RecurringRuleSheet.swift +3 -0
  90. package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Flows/TaskEditorSheet.swift +1 -0
  91. package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Screens/SettingsView.swift +2 -0
  92. package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Screens/TasksHomeView.swift +1 -0
  93. package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Support/AppModel.swift +1 -1
  94. package/examples/todo-orbit/generated/web/Todo Orbit/src/App.tsx +59 -6
  95. package/examples/todo-orbit/generated/web/Todo Orbit/src/styles.css +40 -0
  96. package/examples/todo-orbit/openuispec/README.md +24 -131
  97. package/examples/todo-orbit/openuispec/flows/create_recurring_rule.yaml +3 -0
  98. package/examples/todo-orbit/openuispec/flows/create_task.yaml +1 -0
  99. package/examples/todo-orbit/openuispec/flows/edit_task.yaml +1 -0
  100. package/examples/todo-orbit/openuispec/locales/en.json +1 -0
  101. package/examples/todo-orbit/openuispec/locales/ru.json +1 -0
  102. package/examples/todo-orbit/openuispec/screens/task_detail.yaml +1 -0
  103. package/examples/todo-orbit/openuispec/tokens/icons.yaml +6 -0
  104. package/package.json +6 -1
  105. package/prepare/index.ts +391 -0
  106. package/schema/semantic-lint.ts +592 -0
  107. package/schema/validate.ts +17 -13
  108. package/status/index.ts +200 -0
  109. /package/examples/taskflow/{contracts → openuispec/contracts}/README.md +0 -0
  110. /package/examples/taskflow/{contracts → openuispec/contracts}/action_trigger.yaml +0 -0
  111. /package/examples/taskflow/{contracts → openuispec/contracts}/collection.yaml +0 -0
  112. /package/examples/taskflow/{contracts → openuispec/contracts}/data_display.yaml +0 -0
  113. /package/examples/taskflow/{contracts → openuispec/contracts}/feedback.yaml +0 -0
  114. /package/examples/taskflow/{contracts → openuispec/contracts}/input_field.yaml +0 -0
  115. /package/examples/taskflow/{contracts → openuispec/contracts}/nav_container.yaml +0 -0
  116. /package/examples/taskflow/{contracts → openuispec/contracts}/surface.yaml +0 -0
  117. /package/examples/taskflow/{contracts → openuispec/contracts}/x_media_player.yaml +0 -0
  118. /package/examples/taskflow/{flows → openuispec/flows}/create_task.yaml +0 -0
  119. /package/examples/taskflow/{flows → openuispec/flows}/edit_task.yaml +0 -0
  120. /package/examples/taskflow/{locales → openuispec/locales}/en.json +0 -0
  121. /package/examples/taskflow/{openuispec.yaml → openuispec/openuispec.yaml} +0 -0
  122. /package/examples/taskflow/{platform → openuispec/platform}/android.yaml +0 -0
  123. /package/examples/taskflow/{platform → openuispec/platform}/ios.yaml +0 -0
  124. /package/examples/taskflow/{platform → openuispec/platform}/web.yaml +0 -0
  125. /package/examples/taskflow/{screens → openuispec/screens}/calendar.yaml +0 -0
  126. /package/examples/taskflow/{screens → openuispec/screens}/home.yaml +0 -0
  127. /package/examples/taskflow/{screens → openuispec/screens}/profile_edit.yaml +0 -0
  128. /package/examples/taskflow/{screens → openuispec/screens}/project_detail.yaml +0 -0
  129. /package/examples/taskflow/{screens → openuispec/screens}/projects.yaml +0 -0
  130. /package/examples/taskflow/{screens → openuispec/screens}/settings.yaml +0 -0
  131. /package/examples/taskflow/{screens → openuispec/screens}/task_detail.yaml +0 -0
  132. /package/examples/taskflow/{tokens → openuispec/tokens}/color.yaml +0 -0
  133. /package/examples/taskflow/{tokens → openuispec/tokens}/elevation.yaml +0 -0
  134. /package/examples/taskflow/{tokens → openuispec/tokens}/icons.yaml +0 -0
  135. /package/examples/taskflow/{tokens → openuispec/tokens}/layout.yaml +0 -0
  136. /package/examples/taskflow/{tokens → openuispec/tokens}/motion.yaml +0 -0
  137. /package/examples/taskflow/{tokens → openuispec/tokens}/spacing.yaml +0 -0
  138. /package/examples/taskflow/{tokens → openuispec/tokens}/themes.yaml +0 -0
  139. /package/examples/taskflow/{tokens → openuispec/tokens}/typography.yaml +0 -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,31 @@ 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
+ function missingSnapshotMessage(
470
+ cwd: string,
471
+ projectDir: string,
472
+ projectName: string,
473
+ target: string
474
+ ): string {
475
+ const outDir = resolveOutputDir(projectDir, projectName, target);
476
+ if (!existsSync(outDir)) {
477
+ return (
478
+ `No snapshot found for target "${target}".\n` +
479
+ `Output directory not found: ${relative(cwd, outDir)}\n` +
480
+ `Run code generation for "${target}" first, then run: openuispec drift --snapshot --target ${target}`
481
+ );
482
+ }
483
+ return (
484
+ `No snapshot found for target "${target}".\n` +
485
+ `Run: openuispec drift --snapshot --target ${target}`
486
+ );
487
+ }
488
+
489
+ export function discoverTargets(projectDir: string, projectName: string): string[] {
187
490
  const outputDirs = readOutputDirs(projectDir);
188
491
  const targets: string[] = [];
189
492
 
@@ -241,6 +544,7 @@ function snapshot(cwd: string, projectDir: string, target: string): void {
241
544
 
242
545
  const files = discoverSpecFiles(projectDir);
243
546
  const doc = YAML.parse(readFileSync(join(projectDir, "openuispec.yaml"), "utf-8"));
547
+ const baseline = captureBaseline(projectDir, files);
244
548
 
245
549
  const entries: Record<string, FileEntry> = {};
246
550
  let stubCount = 0;
@@ -256,6 +560,7 @@ function snapshot(cwd: string, projectDir: string, target: string): void {
256
560
  spec_version: doc.spec_version ?? "0.1",
257
561
  snapshot_at: new Date().toISOString(),
258
562
  target,
563
+ baseline,
259
564
  files: entries,
260
565
  };
261
566
 
@@ -267,18 +572,23 @@ function snapshot(cwd: string, projectDir: string, target: string): void {
267
572
  console.log(` ${stubCount} stubs (not tracked for drift)`);
268
573
  }
269
574
  console.log(` target: ${target}`);
575
+ const baselineLabel = formatBaseline(baseline);
576
+ if (baselineLabel) {
577
+ console.log(` baseline: ${baselineLabel}`);
578
+ }
270
579
  }
271
580
 
272
581
  // ── check ─────────────────────────────────────────────────────────────
273
582
 
274
- interface CheckResult {
583
+ export interface CheckResult {
275
584
  state: StateFile;
276
585
  drift: DriftResult;
277
586
  stubDrift: DriftResult;
278
587
  statuses: Record<string, string>;
588
+ explanation?: ExplainResult;
279
589
  }
280
590
 
281
- function computeDrift(
591
+ export function computeDrift(
282
592
  projectDir: string,
283
593
  state: StateFile,
284
594
  includeAll: boolean
@@ -326,25 +636,38 @@ function computeDrift(
326
636
  return { state, drift, stubDrift, statuses };
327
637
  }
328
638
 
329
- function check(
639
+ export function loadTargetDrift(
330
640
  cwd: string,
331
- projectDir: string,
332
641
  target: string,
333
- jsonOutput: boolean,
334
- includeAll: boolean
335
- ): void {
642
+ includeAll: boolean,
643
+ explainOutput: boolean
644
+ ): { projectDir: string; projectName: string; statePath: string; result: CheckResult } {
645
+ const projectDir = findProjectDir(cwd);
336
646
  const projectName = readProjectName(projectDir);
337
647
  const statePath = stateFilePath(projectDir, projectName, target);
338
648
  if (!existsSync(statePath)) {
339
- console.error(
340
- `No snapshot found for target "${target}".\n` +
341
- `Run: openuispec drift --snapshot --target ${target}`
342
- );
649
+ console.error(missingSnapshotMessage(cwd, projectDir, projectName, target));
343
650
  process.exit(1);
344
651
  }
345
652
 
346
653
  const state: StateFile = JSON.parse(readFileSync(statePath, "utf-8"));
347
654
  const result = computeDrift(projectDir, state, includeAll);
655
+ if (explainOutput) {
656
+ result.explanation = explainDrift(projectDir, result);
657
+ }
658
+
659
+ return { projectDir, projectName, statePath, result };
660
+ }
661
+
662
+ function check(
663
+ cwd: string,
664
+ projectDir: string,
665
+ target: string,
666
+ jsonOutput: boolean,
667
+ includeAll: boolean,
668
+ explainOutput: boolean
669
+ ): void {
670
+ const { result } = loadTargetDrift(cwd, target, includeAll, explainOutput);
348
671
 
349
672
  if (jsonOutput) {
350
673
  printJson(result);
@@ -361,7 +684,8 @@ function checkAll(
361
684
  cwd: string,
362
685
  projectDir: string,
363
686
  jsonOutput: boolean,
364
- includeAll: boolean
687
+ includeAll: boolean,
688
+ explainOutput: boolean
365
689
  ): void {
366
690
  const projectName = readProjectName(projectDir);
367
691
  const targets = discoverTargets(projectDir, projectName);
@@ -378,6 +702,9 @@ function checkAll(
378
702
  const statePath = stateFilePath(projectDir, projectName, target);
379
703
  const state: StateFile = JSON.parse(readFileSync(statePath, "utf-8"));
380
704
  const result = computeDrift(projectDir, state, includeAll);
705
+ if (explainOutput) {
706
+ result.explanation = explainDrift(projectDir, result);
707
+ }
381
708
 
382
709
  if (jsonOutput) {
383
710
  printJson(result);
@@ -411,7 +738,9 @@ function printJson(result: CheckResult): void {
411
738
  {
412
739
  snapshot_at: result.state.snapshot_at,
413
740
  target: result.state.target,
741
+ baseline: result.state.baseline,
414
742
  ...result.drift,
743
+ explanation: result.explanation,
415
744
  stubs: stubTotal > 0 ? result.stubDrift : undefined,
416
745
  },
417
746
  null,
@@ -428,6 +757,10 @@ function printReport(projectDir: string, result: CheckResult): void {
428
757
  console.log(`Project: ${projectName}`);
429
758
  console.log(`Snapshot: ${result.state.snapshot_at}`);
430
759
  console.log(`Target: ${result.state.target}`);
760
+ const baselineLabel = formatBaseline(result.state.baseline);
761
+ if (baselineLabel) {
762
+ console.log(`Baseline: ${baselineLabel}`);
763
+ }
431
764
 
432
765
  const d = result.drift;
433
766
 
@@ -500,6 +833,46 @@ function printReport(projectDir: string, result: CheckResult): void {
500
833
  console.log(
501
834
  `\nSummary: ${d.changed.length} changed, ${d.added.length} added, ${d.removed.length} removed${stubSuffix}`
502
835
  );
836
+
837
+ if (result.explanation) {
838
+ console.log("\nSemantic Changes");
839
+ console.log("----------------");
840
+
841
+ if (!result.explanation.available) {
842
+ console.log(result.explanation.note ?? "Semantic explanation unavailable.");
843
+ return;
844
+ }
845
+
846
+ if (result.explanation.files.length === 0) {
847
+ console.log("No semantic changes to explain.");
848
+ return;
849
+ }
850
+
851
+ for (const file of result.explanation.files) {
852
+ console.log(`\n${file.file}`);
853
+ if (file.changes.length === 0) {
854
+ console.log(" · no property-level changes detected");
855
+ continue;
856
+ }
857
+
858
+ for (const change of file.changes) {
859
+ const pathLabel = change.path || "(root)";
860
+ if (change.kind === "added") {
861
+ const value = change.after ? ` = ${change.after}` : "";
862
+ console.log(` + ${pathLabel}${value}`);
863
+ } else if (change.kind === "removed") {
864
+ const value = change.before ? ` (was ${change.before})` : "";
865
+ console.log(` - ${pathLabel}${value}`);
866
+ } else {
867
+ console.log(` ~ ${pathLabel}: ${change.before ?? "?"} -> ${change.after ?? "?"}`);
868
+ }
869
+ }
870
+
871
+ if (file.truncated) {
872
+ console.log(` … truncated after ${MAX_CHANGES_PER_FILE} changes`);
873
+ }
874
+ }
875
+ }
503
876
  }
504
877
 
505
878
  // ── main ──────────────────────────────────────────────────────────────
@@ -508,6 +881,7 @@ export function runDrift(argv: string[]): void {
508
881
  const isSnapshot = argv.includes("--snapshot");
509
882
  const isJson = argv.includes("--json");
510
883
  const includeAll = argv.includes("--all");
884
+ const explainOutput = argv.includes("--explain");
511
885
 
512
886
  const targetIdx = argv.indexOf("--target");
513
887
  const target = targetIdx !== -1 && argv[targetIdx + 1] ? argv[targetIdx + 1] : null;
@@ -523,9 +897,9 @@ export function runDrift(argv: string[]): void {
523
897
  }
524
898
  snapshot(cwd, projectDir, target);
525
899
  } else if (target) {
526
- check(cwd, projectDir, target, isJson, includeAll);
900
+ check(cwd, projectDir, target, isJson, includeAll, explainOutput);
527
901
  } else {
528
- checkAll(cwd, projectDir, isJson, includeAll);
902
+ checkAll(cwd, projectDir, isJson, includeAll, explainOutput);
529
903
  }
530
904
  }
531
905
 
@@ -0,0 +1,112 @@
1
+ <!-- openuispec-rules-start -->
2
+ <!-- openuispec-rules-version: 0.1.28 -->
3
+ # OpenUISpec — AI Assistant Rules
4
+ # ================================
5
+ # This project uses OpenUISpec to define UI as a semantic spec.
6
+ # Spec files are the single source of truth for all UI across platforms.
7
+ # Targets: "ios", "android", "web"
8
+
9
+ ## IMPORTANT — Read the specification before working with spec files
10
+
11
+ The spec format, file schemas, and generation rules are defined in the installed `openuispec` package.
12
+ You MUST read the reference files listed below before creating, editing, or generating from any spec file.
13
+ Do NOT guess the file format — skipping this step will produce invalid YAML that fails validation.
14
+
15
+ **Find the package in this order:**
16
+ 1. `node_modules/openuispec/` (project dependency)
17
+ 2. Run `npm root -g` → `<prefix>/openuispec/` (global install)
18
+ 3. Online: `https://openuispec.rsteam.uz/llms-full.txt` (if not installed)
19
+
20
+ **Reference files inside the package (read in this order):**
21
+ 1. `README.md` — schema tables, file format reference, root wrapper keys
22
+ 2. `spec/openuispec-v0.1.md` — full specification (contracts, layout, expressions, adaptive, etc.)
23
+ 3. `examples/taskflow/openuispec/` — complete working example with all file types
24
+ 4. `schema/` — JSON Schemas for every file type
25
+
26
+ These files are updated with each package version. Always read from the installed package,
27
+ not from cached or memorized content, to ensure you use the latest spec.
28
+
29
+ ## What is OpenUISpec
30
+ OpenUISpec is a YAML-based spec format that describes an app's UI semantically — tokens, screens, flows, and platform overrides. AI reads the spec and generates native code (SwiftUI, Compose, React). AI reads native code and updates the spec. The spec is the sync layer between platforms.
31
+
32
+ ## Spec location
33
+ - Spec root: `openuispec/`
34
+ - Manifest: `openuispec/openuispec.yaml` — always read this first.
35
+ - Tokens: `openuispec/tokens/`
36
+ - Screens: `openuispec/screens/`
37
+ - Flows: `openuispec/flows/`
38
+ - Contracts: `openuispec/contracts/`
39
+ - Platform: `openuispec/platform/`
40
+ - Locales: `openuispec/locales/`
41
+
42
+ **Note:** These are the default paths. Actual paths are in `includes:` in `openuispec.yaml` and may use relative paths. Always read `openuispec.yaml` to find the real directories.
43
+
44
+ ## If spec directories are empty (first-time setup)
45
+ This means the project has existing UI code but hasn't been specced yet. Your job:
46
+
47
+ 1. **Read the spec first** — find and read `spec/openuispec-v0.1.md` from the installed package.
48
+ 2. **Find existing screens** — scan the codebase for UI screen files.
49
+ 3. **Create stubs** — for each screen, create `openuispec/screens/<name>.yaml` with:
50
+ ```yaml
51
+ screen_name:
52
+ semantic: "Brief description of what this screen does"
53
+ status: stub
54
+ layout:
55
+ type: scroll_vertical
56
+ ```
57
+ 4. **Extract tokens** — scan for colors, fonts, spacing and create files in `openuispec/tokens/`.
58
+ 5. **Update the manifest** — fill in `data_model` and `api.endpoints` in `openuispec/openuispec.yaml`.
59
+
60
+ ## OpenUISpec Source Of Truth
61
+
62
+ OpenUISpec spec files are the primary source of truth for UI behavior across platforms.
63
+
64
+ ### Start from spec when:
65
+ - the request changes screen structure
66
+ - the request changes navigation
67
+ - the request changes fields, actions, validation, or data binding
68
+ - the request changes tokens, variants, contracts, flows, or localization
69
+ - the request affects more than one platform
70
+ - the request is phrased in product/UI terms rather than platform-code terms
71
+
72
+ Spec-first workflow:
73
+ 1. Read `openuispec/openuispec.yaml` and the relevant spec files first.
74
+ 2. Update the spec first.
75
+ 3. Update the affected generated/native UI code to match the spec.
76
+ 4. Run `openuispec validate`.
77
+ 5. Run `openuispec validate semantic`.
78
+ 6. Run `openuispec drift --target <target> --explain` to inspect semantic changes since that target's baseline.
79
+ 7. Run `openuispec prepare --target <target>` to build the AI/developer work bundle for that target.
80
+ 8. Verify the affected UI targets build/run if possible.
81
+ 9. Only then run `openuispec drift --snapshot --target <target>` for affected targets, after that target output directory exists.
82
+ 10. Run `openuispec drift --target <target> --explain` again to confirm no spec changes remain for that target.
83
+ 11. Use `openuispec status` to see which other targets are still behind the updated spec.
84
+
85
+ ### Start from platform code when:
86
+ - the change is platform-specific polish
87
+ - the change is a local bug fix that does not alter shared semantic behavior
88
+ - the request explicitly asks for an iOS-only, Android-only, or web-only adjustment
89
+
90
+ Platform-first workflow:
91
+ 1. Update native/platform code.
92
+ 2. If the change affects shared semantics, sync the spec afterward.
93
+ 3. If the change is intentionally platform-specific, document it in `platform/*.yaml` when appropriate.
94
+
95
+ ### Never do this:
96
+ - Do not snapshot drift immediately after changing spec unless the UI code has also been updated.
97
+ - Do not treat `openuispec drift` as proof that generated UI matches the spec.
98
+ - Do not skip `--explain` / `prepare` when another platform needs to catch up with shared spec changes.
99
+ - Do not modify generated UI without checking whether the spec must change first.
100
+
101
+ ## CLI commands
102
+ - `openuispec init` — scaffold a new spec project
103
+ - `openuispec validate [group...]` — validate spec files against schemas
104
+ - `openuispec validate semantic` — run semantic cross-reference linting
105
+ - `openuispec drift --target <t>` — check for spec drift
106
+ - `openuispec drift --target <t> --explain` — explain semantic spec drift since the target baseline
107
+ - `openuispec drift --snapshot --target <t>` — snapshot current state after the target output exists
108
+ - `openuispec prepare --target <t>` — build an AI-ready target update bundle
109
+ - `openuispec status` — show cross-target baseline/drift status
110
+ - `openuispec update-rules` — update AI rules to match installed package version
111
+ - `openuispec drift --all` — include stubs in drift check
112
+ <!-- openuispec-rules-end -->