openuispec 0.1.24 → 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.
- package/README.md +44 -2
- package/cli/index.ts +21 -3
- package/cli/init.ts +56 -17
- package/docs/implementation-notes.md +115 -0
- package/docs/release-notes-v0.1.26.md +64 -0
- package/docs/release-notes-v0.1.27.md +28 -0
- package/drift/index.ts +375 -18
- package/examples/todo-orbit/AGENTS.md +11 -4
- package/examples/todo-orbit/CLAUDE.md +11 -4
- package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/java/uz/rsteam/todoorbit/ui/components/CommonComponents.kt +69 -18
- package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/java/uz/rsteam/todoorbit/ui/screens/TasksScreen.kt +5 -0
- package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/java/uz/rsteam/todoorbit/ui/sheets/EditorSheets.kt +5 -2
- package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/res/values/strings.xml +1 -0
- package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/res/values-ru/strings.xml +1 -0
- package/examples/todo-orbit/generated/ios/Todo Orbit/Resources/en.lproj/Localizable.strings +1 -0
- package/examples/todo-orbit/generated/ios/Todo Orbit/Resources/ru.lproj/Localizable.strings +1 -0
- package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Flows/RecurringRuleSheet.swift +3 -0
- package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Flows/TaskEditorSheet.swift +1 -0
- package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Screens/SettingsView.swift +2 -0
- package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Screens/TasksHomeView.swift +1 -0
- package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Support/AppModel.swift +1 -1
- package/examples/todo-orbit/generated/web/Todo Orbit/src/App.tsx +59 -6
- package/examples/todo-orbit/generated/web/Todo Orbit/src/styles.css +40 -0
- package/examples/todo-orbit/openuispec/README.md +24 -131
- package/examples/todo-orbit/openuispec/flows/create_recurring_rule.yaml +3 -0
- package/examples/todo-orbit/openuispec/flows/create_task.yaml +1 -0
- package/examples/todo-orbit/openuispec/flows/edit_task.yaml +1 -0
- package/examples/todo-orbit/openuispec/locales/en.json +1 -0
- package/examples/todo-orbit/openuispec/locales/ru.json +1 -0
- package/examples/todo-orbit/openuispec/screens/task_detail.yaml +1 -0
- package/examples/todo-orbit/openuispec/tokens/icons.yaml +6 -0
- package/package.json +6 -1
- package/prepare/index.ts +391 -0
- package/schema/semantic-lint.ts +592 -0
- package/schema/validate.ts +8 -9
- 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
|
|
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
|
|
619
|
+
export function loadTargetDrift(
|
|
330
620
|
cwd: string,
|
|
331
|
-
projectDir: string,
|
|
332
621
|
target: string,
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
):
|
|
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.
|
|
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.
|
|
70
|
-
3. Run `openuispec drift --
|
|
71
|
-
4. Run `openuispec
|
|
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.
|
|
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.
|
|
70
|
-
3. Run `openuispec drift --
|
|
71
|
-
4. Run `openuispec
|
|
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 -->
|