openuispec 0.2.19 → 0.2.21
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/dist/check/audit.js +392 -0
- package/dist/check/index.js +216 -0
- package/dist/cli/configure-target.js +391 -0
- package/dist/cli/index.js +510 -0
- package/dist/cli/init.js +964 -0
- package/dist/drift/index.js +903 -0
- package/dist/mcp-server/index.js +888 -0
- package/dist/mcp-server/preview-render.js +1761 -0
- package/dist/mcp-server/preview.js +229 -0
- package/dist/mcp-server/screenshot-android.js +458 -0
- package/dist/mcp-server/screenshot-ios.js +639 -0
- package/dist/mcp-server/screenshot-shared.js +185 -0
- package/dist/mcp-server/screenshot.js +469 -0
- package/dist/prepare/index.js +1216 -0
- package/dist/runtime/package-paths.js +33 -0
- package/dist/schema/semantic-lint.js +564 -0
- package/dist/schema/validate.js +689 -0
- package/dist/status/index.js +194 -0
- package/package.json +13 -14
- package/check/audit.ts +0 -426
- package/check/index.ts +0 -320
- package/cli/configure-target.ts +0 -523
- package/cli/index.ts +0 -537
- package/cli/init.ts +0 -1253
- package/drift/index.ts +0 -1165
- package/mcp-server/index.ts +0 -1041
- package/mcp-server/preview-render.ts +0 -1922
- package/mcp-server/preview.ts +0 -292
- package/mcp-server/screenshot-android.ts +0 -621
- package/mcp-server/screenshot-ios.ts +0 -753
- package/mcp-server/screenshot-shared.ts +0 -237
- package/mcp-server/screenshot.ts +0 -563
- package/prepare/index.ts +0 -1530
- package/schema/semantic-lint.ts +0 -692
- package/schema/validate.ts +0 -870
- package/scripts/regenerate-previews.ts +0 -136
- package/scripts/take-all-screenshots.ts +0 -507
- package/status/index.ts +0 -275
package/drift/index.ts
DELETED
|
@@ -1,1165 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env tsx
|
|
2
|
-
/**
|
|
3
|
-
* Hash-based drift detection for OpenUISpec projects.
|
|
4
|
-
*
|
|
5
|
-
* Tracks spec changes via content hashes so you know what to
|
|
6
|
-
* re-generate or review after editing spec files.
|
|
7
|
-
* Stub screens/flows are reported separately and don't fail CI.
|
|
8
|
-
*
|
|
9
|
-
* Usage:
|
|
10
|
-
* openuispec drift --target ios # check drift for ios
|
|
11
|
-
* openuispec drift # check all targets with snapshots
|
|
12
|
-
* openuispec drift --snapshot --target ios # snapshot for ios
|
|
13
|
-
* openuispec drift --target ios --explain # explain semantic changes since baseline
|
|
14
|
-
* openuispec drift --json --target ios # machine-readable output
|
|
15
|
-
* openuispec drift --target ios --all # include stubs in drift count
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
import { readFileSync, writeFileSync, readdirSync, existsSync } from "node:fs";
|
|
19
|
-
import { resolve, join, relative, basename, dirname } from "node:path";
|
|
20
|
-
import { createHash } from "node:crypto";
|
|
21
|
-
import { execFileSync } from "node:child_process";
|
|
22
|
-
import YAML from "yaml";
|
|
23
|
-
|
|
24
|
-
const STATE_FILE = ".openuispec-state.json";
|
|
25
|
-
export const SUPPORTED_TARGETS = ["ios", "android", "web"] as const;
|
|
26
|
-
export type SupportedTarget = typeof SUPPORTED_TARGETS[number];
|
|
27
|
-
|
|
28
|
-
// ── types ─────────────────────────────────────────────────────────────
|
|
29
|
-
|
|
30
|
-
interface FileEntry {
|
|
31
|
-
hash: string;
|
|
32
|
-
status: string;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export interface StateFile {
|
|
36
|
-
spec_version: string;
|
|
37
|
-
snapshot_at: string;
|
|
38
|
-
target: string;
|
|
39
|
-
baseline?: BaselineRef;
|
|
40
|
-
files: Record<string, FileEntry>;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export interface BaselineRef {
|
|
44
|
-
kind: "git_commit" | "working_tree";
|
|
45
|
-
commit: string | null;
|
|
46
|
-
branch: string | null;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export interface DriftResult {
|
|
50
|
-
changed: string[];
|
|
51
|
-
added: string[];
|
|
52
|
-
removed: string[];
|
|
53
|
-
unchanged: string[];
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export interface SemanticChange {
|
|
57
|
-
kind: "added" | "removed" | "changed";
|
|
58
|
-
path: string;
|
|
59
|
-
before?: string;
|
|
60
|
-
after?: string;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export interface FileExplanation {
|
|
64
|
-
file: string;
|
|
65
|
-
status: "added" | "removed" | "changed";
|
|
66
|
-
changes: SemanticChange[];
|
|
67
|
-
truncated: boolean;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export interface ExplainResult {
|
|
71
|
-
available: boolean;
|
|
72
|
-
note?: string;
|
|
73
|
-
files: FileExplanation[];
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// ── shared layer types ───────────────────────────────────────────────
|
|
77
|
-
|
|
78
|
-
export interface SharedLayerConfig {
|
|
79
|
-
name: string;
|
|
80
|
-
platforms: string[];
|
|
81
|
-
language: string;
|
|
82
|
-
root: string; // resolved absolute path
|
|
83
|
-
paths: Record<string, string>;
|
|
84
|
-
tracks: string[]; // spec categories to track for drift
|
|
85
|
-
scope: string; // what code belongs in this layer
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
export interface SharedLayerState {
|
|
89
|
-
spec_version: string;
|
|
90
|
-
snapshot_at: string;
|
|
91
|
-
layer_name: string;
|
|
92
|
-
generated_by_target: string;
|
|
93
|
-
baseline?: BaselineRef;
|
|
94
|
-
files: Record<string, FileEntry>;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
export interface SharedLayerDriftResult {
|
|
98
|
-
layer: SharedLayerConfig;
|
|
99
|
-
drift: DriftResult;
|
|
100
|
-
state: SharedLayerState | null;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// ── helpers ───────────────────────────────────────────────────────────
|
|
104
|
-
|
|
105
|
-
export function listFiles(dir: string, ext: string): string[] {
|
|
106
|
-
try {
|
|
107
|
-
return readdirSync(dir)
|
|
108
|
-
.filter((f) => f.endsWith(ext))
|
|
109
|
-
.sort()
|
|
110
|
-
.map((f) => join(dir, f));
|
|
111
|
-
} catch {
|
|
112
|
-
return [];
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
export function isSupportedTarget(value: string): value is SupportedTarget {
|
|
117
|
-
return (SUPPORTED_TARGETS as readonly string[]).includes(value);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function hashFile(filePath: string): string {
|
|
121
|
-
const content = readFileSync(filePath);
|
|
122
|
-
const hash = createHash("sha256").update(content).digest("hex");
|
|
123
|
-
return `sha256:${hash}`;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function readFileIfExists(filePath: string): string | null {
|
|
127
|
-
try {
|
|
128
|
-
return readFileSync(filePath, "utf-8");
|
|
129
|
-
} catch {
|
|
130
|
-
return null;
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function parseSpecDocument(relPath: string, content: string): unknown {
|
|
135
|
-
if (relPath.endsWith(".json")) {
|
|
136
|
-
return JSON.parse(content);
|
|
137
|
-
}
|
|
138
|
-
return YAML.parse(content);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/** Read the status field from a screen or flow YAML file. */
|
|
142
|
-
export function readStatus(filePath: string): string {
|
|
143
|
-
try {
|
|
144
|
-
const doc = YAML.parse(readFileSync(filePath, "utf-8"));
|
|
145
|
-
if (doc && typeof doc === "object") {
|
|
146
|
-
const rootKey = Object.keys(doc)[0];
|
|
147
|
-
const def = doc[rootKey];
|
|
148
|
-
if (def && typeof def.status === "string") {
|
|
149
|
-
return def.status;
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
} catch {
|
|
153
|
-
// If we can't parse, treat as ready
|
|
154
|
-
}
|
|
155
|
-
return "ready";
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/** Returns true if a file is a screen or flow (has status semantics). */
|
|
159
|
-
export function hasStatusSemantics(relPath: string): boolean {
|
|
160
|
-
const dir = dirname(relPath);
|
|
161
|
-
return dir === "screens" || dir === "flows";
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
export function discoverSpecFiles(projectDir: string): string[] {
|
|
165
|
-
const manifest = join(projectDir, "openuispec.yaml");
|
|
166
|
-
if (!existsSync(manifest)) {
|
|
167
|
-
throw new Error(`No openuispec.yaml found in ${projectDir}`);
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
const doc = YAML.parse(readFileSync(manifest, "utf-8"));
|
|
171
|
-
const includes = doc.includes ?? {};
|
|
172
|
-
const files: string[] = [manifest];
|
|
173
|
-
|
|
174
|
-
if (includes.tokens) {
|
|
175
|
-
files.push(...listFiles(resolve(projectDir, includes.tokens), ".yaml"));
|
|
176
|
-
}
|
|
177
|
-
if (includes.screens) {
|
|
178
|
-
files.push(...listFiles(resolve(projectDir, includes.screens), ".yaml"));
|
|
179
|
-
}
|
|
180
|
-
if (includes.flows) {
|
|
181
|
-
files.push(...listFiles(resolve(projectDir, includes.flows), ".yaml"));
|
|
182
|
-
}
|
|
183
|
-
if (includes.platform) {
|
|
184
|
-
files.push(...listFiles(resolve(projectDir, includes.platform), ".yaml"));
|
|
185
|
-
}
|
|
186
|
-
if (includes.locales) {
|
|
187
|
-
files.push(...listFiles(resolve(projectDir, includes.locales), ".json"));
|
|
188
|
-
}
|
|
189
|
-
if (includes.contracts) {
|
|
190
|
-
files.push(...listFiles(resolve(projectDir, includes.contracts), ".yaml"));
|
|
191
|
-
}
|
|
192
|
-
if (includes.components) {
|
|
193
|
-
files.push(...listFiles(resolve(projectDir, includes.components), ".yaml"));
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
return files;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
/** Classify a relative spec path into a lowercase category. */
|
|
200
|
-
export function specCategory(relPath: string): string {
|
|
201
|
-
if (relPath === "openuispec.yaml") return "manifest";
|
|
202
|
-
const dir = dirname(relPath);
|
|
203
|
-
if (dir === "tokens") return "tokens";
|
|
204
|
-
if (dir === "screens") return "screens";
|
|
205
|
-
if (dir === "flows") return "flows";
|
|
206
|
-
if (dir === "platform") return "platform";
|
|
207
|
-
if (dir === "locales") return "locales";
|
|
208
|
-
if (dir === "contracts") return "contracts";
|
|
209
|
-
if (dir === "components") return "components";
|
|
210
|
-
return "other";
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
function categorize(relPath: string): string {
|
|
214
|
-
const cat = specCategory(relPath);
|
|
215
|
-
return cat.charAt(0).toUpperCase() + cat.slice(1);
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/** Returns true when a DriftResult contains any changes. */
|
|
219
|
-
export function hasDriftChanges(d: DriftResult): boolean {
|
|
220
|
-
return d.changed.length > 0 || d.added.length > 0 || d.removed.length > 0;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/** Hash spec files into FileEntry records keyed by relative path. */
|
|
224
|
-
export function buildFileEntries(
|
|
225
|
-
projectDir: string,
|
|
226
|
-
files: string[]
|
|
227
|
-
): { entries: Record<string, FileEntry>; stubs: number } {
|
|
228
|
-
const entries: Record<string, FileEntry> = {};
|
|
229
|
-
let stubs = 0;
|
|
230
|
-
for (const f of files) {
|
|
231
|
-
const rel = relative(projectDir, f);
|
|
232
|
-
const status = hasStatusSemantics(rel) ? readStatus(f) : "ready";
|
|
233
|
-
entries[rel] = { hash: hashFile(f), status };
|
|
234
|
-
if (status === "stub") stubs++;
|
|
235
|
-
}
|
|
236
|
-
return { entries, stubs };
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
function runGit(args: string[], cwd: string): string | null {
|
|
240
|
-
try {
|
|
241
|
-
return execFileSync("git", args, {
|
|
242
|
-
cwd,
|
|
243
|
-
encoding: "utf-8",
|
|
244
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
245
|
-
}).trim();
|
|
246
|
-
} catch {
|
|
247
|
-
return null;
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
function gitPathForFile(projectDir: string, relPath: string): string | null {
|
|
252
|
-
const repoRoot = runGit(["rev-parse", "--show-toplevel"], projectDir);
|
|
253
|
-
if (!repoRoot) return null;
|
|
254
|
-
return relative(repoRoot, join(projectDir, relPath));
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
function readFileFromGit(projectDir: string, commit: string, relPath: string): string | null {
|
|
258
|
-
const gitPath = gitPathForFile(projectDir, relPath);
|
|
259
|
-
if (!gitPath) return null;
|
|
260
|
-
return runGit(["show", `${commit}:${gitPath}`], projectDir);
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
function captureBaseline(projectDir: string, files: string[]): BaselineRef | undefined {
|
|
264
|
-
const repoRoot = runGit(["rev-parse", "--show-toplevel"], projectDir);
|
|
265
|
-
if (!repoRoot) return undefined;
|
|
266
|
-
|
|
267
|
-
const branch = runGit(["branch", "--show-current"], projectDir);
|
|
268
|
-
const commit = runGit(["rev-parse", "HEAD"], projectDir);
|
|
269
|
-
const repoPaths = files.map((file) => relative(repoRoot, file));
|
|
270
|
-
const status = runGit(["status", "--porcelain", "--", ...repoPaths], projectDir) ?? "";
|
|
271
|
-
|
|
272
|
-
return {
|
|
273
|
-
kind: status.length > 0 ? "working_tree" : "git_commit",
|
|
274
|
-
commit,
|
|
275
|
-
branch: branch || null,
|
|
276
|
-
};
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
export function formatBaseline(baseline?: BaselineRef): string | null {
|
|
280
|
-
if (!baseline) return null;
|
|
281
|
-
|
|
282
|
-
const ref = baseline.commit ? baseline.commit.slice(0, 12) : "uncommitted";
|
|
283
|
-
const branchSuffix = baseline.branch ? ` on ${baseline.branch}` : "";
|
|
284
|
-
|
|
285
|
-
if (baseline.kind === "git_commit") {
|
|
286
|
-
return `${ref}${branchSuffix} (exact git baseline)`;
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
return `${ref}${branchSuffix} + working tree spec changes`;
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
const MAX_CHANGES_PER_FILE = 20;
|
|
293
|
-
const MAX_VALUE_LENGTH = 120;
|
|
294
|
-
|
|
295
|
-
function summarizeValue(value: unknown): string {
|
|
296
|
-
if (typeof value === "string") {
|
|
297
|
-
return value.length > MAX_VALUE_LENGTH
|
|
298
|
-
? JSON.stringify(`${value.slice(0, MAX_VALUE_LENGTH - 1)}…`)
|
|
299
|
-
: JSON.stringify(value);
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
const serialized = JSON.stringify(value);
|
|
303
|
-
if (!serialized) return String(value);
|
|
304
|
-
return serialized.length > MAX_VALUE_LENGTH
|
|
305
|
-
? `${serialized.slice(0, MAX_VALUE_LENGTH - 1)}…`
|
|
306
|
-
: serialized;
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
function compareSemanticValue(
|
|
310
|
-
path: string,
|
|
311
|
-
before: unknown,
|
|
312
|
-
after: unknown,
|
|
313
|
-
changes: SemanticChange[]
|
|
314
|
-
): void {
|
|
315
|
-
if (changes.length >= MAX_CHANGES_PER_FILE) return;
|
|
316
|
-
|
|
317
|
-
if (before === undefined && after === undefined) return;
|
|
318
|
-
if (before === undefined) {
|
|
319
|
-
changes.push({ kind: "added", path, after: summarizeValue(after) });
|
|
320
|
-
return;
|
|
321
|
-
}
|
|
322
|
-
if (after === undefined) {
|
|
323
|
-
changes.push({ kind: "removed", path, before: summarizeValue(before) });
|
|
324
|
-
return;
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
if (Array.isArray(before) || Array.isArray(after)) {
|
|
328
|
-
if (!Array.isArray(before) || !Array.isArray(after)) {
|
|
329
|
-
changes.push({
|
|
330
|
-
kind: "changed",
|
|
331
|
-
path,
|
|
332
|
-
before: summarizeValue(before),
|
|
333
|
-
after: summarizeValue(after),
|
|
334
|
-
});
|
|
335
|
-
return;
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
const maxLength = Math.max(before.length, after.length);
|
|
339
|
-
for (let index = 0; index < maxLength; index += 1) {
|
|
340
|
-
compareSemanticValue(`${path}[${index}]`, before[index], after[index], changes);
|
|
341
|
-
if (changes.length >= MAX_CHANGES_PER_FILE) return;
|
|
342
|
-
}
|
|
343
|
-
return;
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
if (
|
|
347
|
-
before &&
|
|
348
|
-
after &&
|
|
349
|
-
typeof before === "object" &&
|
|
350
|
-
typeof after === "object"
|
|
351
|
-
) {
|
|
352
|
-
const beforeObj = before as Record<string, unknown>;
|
|
353
|
-
const afterObj = after as Record<string, unknown>;
|
|
354
|
-
const keys = Array.from(new Set([...Object.keys(beforeObj), ...Object.keys(afterObj)])).sort();
|
|
355
|
-
|
|
356
|
-
for (const key of keys) {
|
|
357
|
-
const nextPath = path ? `${path}.${key}` : key;
|
|
358
|
-
compareSemanticValue(nextPath, beforeObj[key], afterObj[key], changes);
|
|
359
|
-
if (changes.length >= MAX_CHANGES_PER_FILE) return;
|
|
360
|
-
}
|
|
361
|
-
return;
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
if (before !== after) {
|
|
365
|
-
changes.push({
|
|
366
|
-
kind: "changed",
|
|
367
|
-
path,
|
|
368
|
-
before: summarizeValue(before),
|
|
369
|
-
after: summarizeValue(after),
|
|
370
|
-
});
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
function explainFileChange(
|
|
375
|
-
projectDir: string,
|
|
376
|
-
baselineCommit: string,
|
|
377
|
-
relPath: string,
|
|
378
|
-
status: "added" | "removed" | "changed"
|
|
379
|
-
): FileExplanation {
|
|
380
|
-
if (status === "added") {
|
|
381
|
-
return {
|
|
382
|
-
file: relPath,
|
|
383
|
-
status,
|
|
384
|
-
changes: [{ kind: "added", path: relPath }],
|
|
385
|
-
truncated: false,
|
|
386
|
-
};
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
if (status === "removed") {
|
|
390
|
-
return {
|
|
391
|
-
file: relPath,
|
|
392
|
-
status,
|
|
393
|
-
changes: [{ kind: "removed", path: relPath }],
|
|
394
|
-
truncated: false,
|
|
395
|
-
};
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
const beforeContent = readFileFromGit(projectDir, baselineCommit, relPath);
|
|
399
|
-
const afterContent = readFileIfExists(join(projectDir, relPath));
|
|
400
|
-
|
|
401
|
-
if (!beforeContent || !afterContent) {
|
|
402
|
-
return {
|
|
403
|
-
file: relPath,
|
|
404
|
-
status,
|
|
405
|
-
changes: [
|
|
406
|
-
{
|
|
407
|
-
kind: "changed",
|
|
408
|
-
path: relPath,
|
|
409
|
-
before: beforeContent ? "available" : "missing from baseline",
|
|
410
|
-
after: afterContent ? "available" : "missing from working tree",
|
|
411
|
-
},
|
|
412
|
-
],
|
|
413
|
-
truncated: false,
|
|
414
|
-
};
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
try {
|
|
418
|
-
const beforeDoc = parseSpecDocument(relPath, beforeContent);
|
|
419
|
-
const afterDoc = parseSpecDocument(relPath, afterContent);
|
|
420
|
-
const changes: SemanticChange[] = [];
|
|
421
|
-
compareSemanticValue("", beforeDoc, afterDoc, changes);
|
|
422
|
-
|
|
423
|
-
return {
|
|
424
|
-
file: relPath,
|
|
425
|
-
status,
|
|
426
|
-
changes,
|
|
427
|
-
truncated: changes.length >= MAX_CHANGES_PER_FILE,
|
|
428
|
-
};
|
|
429
|
-
} catch (error) {
|
|
430
|
-
return {
|
|
431
|
-
file: relPath,
|
|
432
|
-
status,
|
|
433
|
-
changes: [
|
|
434
|
-
{
|
|
435
|
-
kind: "changed",
|
|
436
|
-
path: relPath,
|
|
437
|
-
after: error instanceof Error ? error.message : "unable to parse file diff",
|
|
438
|
-
},
|
|
439
|
-
],
|
|
440
|
-
truncated: false,
|
|
441
|
-
};
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
export function explainDrift(projectDir: string, result: CheckResult): ExplainResult {
|
|
446
|
-
const baseline = result.state.baseline;
|
|
447
|
-
if (!baseline?.commit) {
|
|
448
|
-
return {
|
|
449
|
-
available: false,
|
|
450
|
-
note: "No git baseline metadata found in snapshot. Re-run `openuispec drift --snapshot --target <target>` from a git checkout.",
|
|
451
|
-
files: [],
|
|
452
|
-
};
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
if (baseline.kind !== "git_commit") {
|
|
456
|
-
return {
|
|
457
|
-
available: false,
|
|
458
|
-
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.",
|
|
459
|
-
files: [],
|
|
460
|
-
};
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
const files: FileExplanation[] = [];
|
|
464
|
-
for (const relPath of result.drift.added) {
|
|
465
|
-
files.push(explainFileChange(projectDir, baseline.commit, relPath, "added"));
|
|
466
|
-
}
|
|
467
|
-
for (const relPath of result.drift.removed) {
|
|
468
|
-
files.push(explainFileChange(projectDir, baseline.commit, relPath, "removed"));
|
|
469
|
-
}
|
|
470
|
-
for (const relPath of result.drift.changed) {
|
|
471
|
-
files.push(explainFileChange(projectDir, baseline.commit, relPath, "changed"));
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
files.sort((a, b) => a.file.localeCompare(b.file));
|
|
475
|
-
return { available: true, files };
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
// ── project resolution ───────────────────────────────────────────────
|
|
479
|
-
|
|
480
|
-
/** Find the spec project directory by looking for openuispec.yaml. */
|
|
481
|
-
export function findProjectDir(cwd: string): string {
|
|
482
|
-
const candidates = [
|
|
483
|
-
join(cwd, "openuispec"),
|
|
484
|
-
cwd,
|
|
485
|
-
// Fallback for running from repo root with examples/
|
|
486
|
-
join(cwd, "examples", "taskflow"),
|
|
487
|
-
];
|
|
488
|
-
for (const dir of candidates) {
|
|
489
|
-
if (existsSync(join(dir, "openuispec.yaml"))) {
|
|
490
|
-
return dir;
|
|
491
|
-
}
|
|
492
|
-
}
|
|
493
|
-
throw new Error(
|
|
494
|
-
"No openuispec.yaml found. " +
|
|
495
|
-
"Run from a directory containing openuispec.yaml or an openuispec/ subdirectory."
|
|
496
|
-
);
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
/** Read and parse the manifest YAML. */
|
|
500
|
-
export function readManifest(projectDir: string): Record<string, any> {
|
|
501
|
-
return YAML.parse(readFileSync(join(projectDir, "openuispec.yaml"), "utf-8"));
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
/** Read the project name from the manifest. */
|
|
505
|
-
export function readProjectName(projectDir: string): string {
|
|
506
|
-
const doc = readManifest(projectDir);
|
|
507
|
-
return doc.project?.name ?? basename(projectDir);
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
/** Read per-target output_dir map from the manifest. */
|
|
511
|
-
export function readOutputDirs(projectDir: string): Record<string, string> {
|
|
512
|
-
try {
|
|
513
|
-
const doc = YAML.parse(readFileSync(join(projectDir, "openuispec.yaml"), "utf-8"));
|
|
514
|
-
return doc.generation?.output_dir ?? {};
|
|
515
|
-
} catch {
|
|
516
|
-
return {};
|
|
517
|
-
}
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
/** Resolve the generated output directory for a target. */
|
|
521
|
-
export function resolveOutputDir(projectDir: string, projectName: string, target: string): string {
|
|
522
|
-
const outputDirs = readOutputDirs(projectDir);
|
|
523
|
-
if (outputDirs[target]) {
|
|
524
|
-
return resolve(projectDir, outputDirs[target]);
|
|
525
|
-
}
|
|
526
|
-
// Default: generated/<target>/<project_name> relative to cwd
|
|
527
|
-
return resolve(projectDir, "..", "generated", target, projectName);
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
export function stateFilePath(projectDir: string, projectName: string, target: string): string {
|
|
531
|
-
return join(resolveOutputDir(projectDir, projectName, target), STATE_FILE);
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
function missingSnapshotMessage(
|
|
535
|
-
cwd: string,
|
|
536
|
-
projectDir: string,
|
|
537
|
-
projectName: string,
|
|
538
|
-
target: string
|
|
539
|
-
): string {
|
|
540
|
-
const outDir = resolveOutputDir(projectDir, projectName, target);
|
|
541
|
-
if (!existsSync(outDir)) {
|
|
542
|
-
return (
|
|
543
|
-
`No snapshot found for target "${target}".\n` +
|
|
544
|
-
`Output directory not found: ${relative(cwd, outDir)}\n` +
|
|
545
|
-
`Run code generation for "${target}" first, then run: openuispec drift --snapshot --target ${target}`
|
|
546
|
-
);
|
|
547
|
-
}
|
|
548
|
-
return (
|
|
549
|
-
`No snapshot found for target "${target}".\n` +
|
|
550
|
-
`Run: openuispec drift --snapshot --target ${target}`
|
|
551
|
-
);
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
export function discoverTargets(projectDir: string, projectName: string): string[] {
|
|
555
|
-
const outputDirs = readOutputDirs(projectDir);
|
|
556
|
-
const targets: string[] = [];
|
|
557
|
-
|
|
558
|
-
// Check configured output_dir entries
|
|
559
|
-
for (const [target, dir] of Object.entries(outputDirs)) {
|
|
560
|
-
const resolved = resolve(projectDir, dir);
|
|
561
|
-
if (existsSync(join(resolved, STATE_FILE))) {
|
|
562
|
-
targets.push(target);
|
|
563
|
-
}
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
// Also check default generated/ directory
|
|
567
|
-
const generatedDir = resolve(projectDir, "..", "generated");
|
|
568
|
-
if (existsSync(generatedDir)) {
|
|
569
|
-
try {
|
|
570
|
-
for (const entry of readdirSync(generatedDir)) {
|
|
571
|
-
if (!targets.includes(entry)) {
|
|
572
|
-
const defaultPath = join(generatedDir, entry, projectName, STATE_FILE);
|
|
573
|
-
if (existsSync(defaultPath)) {
|
|
574
|
-
targets.push(entry);
|
|
575
|
-
}
|
|
576
|
-
}
|
|
577
|
-
}
|
|
578
|
-
} catch {
|
|
579
|
-
// ignore
|
|
580
|
-
}
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
return targets.sort();
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
/**
|
|
587
|
-
* Normalize a state file entry. Handles backward compatibility
|
|
588
|
-
* with old format where files were stored as plain hash strings.
|
|
589
|
-
*/
|
|
590
|
-
function normalizeEntry(value: string | FileEntry): FileEntry {
|
|
591
|
-
if (typeof value === "string") {
|
|
592
|
-
return { hash: value, status: "ready" };
|
|
593
|
-
}
|
|
594
|
-
return value;
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
// ── shared layers ────────────────────────────────────────────────────
|
|
598
|
-
|
|
599
|
-
/** Parse `generation.shared` from the manifest and resolve roots to absolute paths. */
|
|
600
|
-
export function readSharedLayers(projectDir: string): SharedLayerConfig[] {
|
|
601
|
-
const manifest = readManifest(projectDir);
|
|
602
|
-
const shared = manifest.generation?.shared;
|
|
603
|
-
if (!shared || typeof shared !== "object") return [];
|
|
604
|
-
|
|
605
|
-
return Object.entries(shared).map(([name, config]) => {
|
|
606
|
-
const cfg = config as Record<string, any>;
|
|
607
|
-
return {
|
|
608
|
-
name,
|
|
609
|
-
platforms: Array.isArray(cfg.platforms) ? cfg.platforms : [],
|
|
610
|
-
language: typeof cfg.language === "string" ? cfg.language : "",
|
|
611
|
-
root: resolve(projectDir, cfg.root ?? "."),
|
|
612
|
-
paths: typeof cfg.paths === "object" && cfg.paths !== null ? cfg.paths : {},
|
|
613
|
-
tracks: Array.isArray(cfg.tracks) ? cfg.tracks : [],
|
|
614
|
-
scope: typeof cfg.scope === "string" ? cfg.scope : "",
|
|
615
|
-
};
|
|
616
|
-
});
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
/** Return shared layers whose `platforms` array includes the given target. */
|
|
620
|
-
export function sharedLayersForTarget(projectDir: string, target: string): SharedLayerConfig[] {
|
|
621
|
-
return readSharedLayers(projectDir).filter((layer) => layer.platforms.includes(target));
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
/** Returns the state file path for a shared layer. */
|
|
625
|
-
export function sharedLayerStatePath(layer: SharedLayerConfig): string {
|
|
626
|
-
return join(layer.root, `.openuispec-shared-${layer.name}.json`);
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
/** Read an existing shared layer state file, or null if it doesn't exist. */
|
|
630
|
-
export function readSharedLayerState(layer: SharedLayerConfig): SharedLayerState | null {
|
|
631
|
-
try {
|
|
632
|
-
return JSON.parse(readFileSync(sharedLayerStatePath(layer), "utf-8"));
|
|
633
|
-
} catch {
|
|
634
|
-
return null;
|
|
635
|
-
}
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
/** Filter spec files to only those in the given categories. */
|
|
639
|
-
function filterSpecFilesByCategories(
|
|
640
|
-
projectDir: string,
|
|
641
|
-
files: string[],
|
|
642
|
-
categories: string[]
|
|
643
|
-
): string[] {
|
|
644
|
-
const catSet = new Set(categories);
|
|
645
|
-
return files.filter((f) => catSet.has(specCategory(relative(projectDir, f))));
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
/** Create a snapshot of spec state for a shared layer. */
|
|
649
|
-
export function createSharedSnapshot(
|
|
650
|
-
cwd: string,
|
|
651
|
-
layerName: string,
|
|
652
|
-
generatedByTarget: string
|
|
653
|
-
): SnapshotResult {
|
|
654
|
-
const projectDir = findProjectDir(cwd);
|
|
655
|
-
const layers = readSharedLayers(projectDir);
|
|
656
|
-
const layer = layers.find((l) => l.name === layerName);
|
|
657
|
-
if (!layer) {
|
|
658
|
-
throw new Error(`Shared layer "${layerName}" not found in generation.shared`);
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
const manifest = readManifest(projectDir);
|
|
662
|
-
const allFiles = discoverSpecFiles(projectDir);
|
|
663
|
-
const files = filterSpecFilesByCategories(projectDir, allFiles, layer.tracks);
|
|
664
|
-
const baseline = captureBaseline(projectDir, allFiles);
|
|
665
|
-
|
|
666
|
-
const { entries, stubs: stubCount } = buildFileEntries(projectDir, files);
|
|
667
|
-
|
|
668
|
-
const state: SharedLayerState = {
|
|
669
|
-
spec_version: manifest.spec_version ?? "0.2",
|
|
670
|
-
snapshot_at: new Date().toISOString(),
|
|
671
|
-
layer_name: layerName,
|
|
672
|
-
generated_by_target: generatedByTarget,
|
|
673
|
-
baseline,
|
|
674
|
-
files: entries,
|
|
675
|
-
};
|
|
676
|
-
|
|
677
|
-
const outPath = sharedLayerStatePath(layer);
|
|
678
|
-
writeFileSync(outPath, JSON.stringify(state, null, 2) + "\n");
|
|
679
|
-
|
|
680
|
-
return {
|
|
681
|
-
target: `shared:${layerName}`,
|
|
682
|
-
snapshot_at: state.snapshot_at,
|
|
683
|
-
files_hashed: Object.keys(entries).length,
|
|
684
|
-
stubs: stubCount,
|
|
685
|
-
state_path: relative(cwd, outPath),
|
|
686
|
-
baseline: formatBaseline(baseline),
|
|
687
|
-
};
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
/** Compute drift for a shared layer against its saved state. */
|
|
691
|
-
export function computeSharedDrift(
|
|
692
|
-
projectDir: string,
|
|
693
|
-
layer: SharedLayerConfig
|
|
694
|
-
): SharedLayerDriftResult {
|
|
695
|
-
const state = readSharedLayerState(layer);
|
|
696
|
-
if (!state) {
|
|
697
|
-
return {
|
|
698
|
-
layer,
|
|
699
|
-
drift: { changed: [], added: [], removed: [], unchanged: [] },
|
|
700
|
-
state: null,
|
|
701
|
-
};
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
const allFiles = discoverSpecFiles(projectDir);
|
|
705
|
-
const files = filterSpecFilesByCategories(projectDir, allFiles, layer.tracks);
|
|
706
|
-
const { entries: current } = buildFileEntries(projectDir, files);
|
|
707
|
-
|
|
708
|
-
const drift: DriftResult = { changed: [], added: [], removed: [], unchanged: [] };
|
|
709
|
-
|
|
710
|
-
for (const [rel, entry] of Object.entries(current)) {
|
|
711
|
-
const snapshotEntry = state.files[rel]
|
|
712
|
-
? normalizeEntry(state.files[rel] as string | FileEntry)
|
|
713
|
-
: null;
|
|
714
|
-
if (!snapshotEntry) {
|
|
715
|
-
drift.added.push(rel);
|
|
716
|
-
} else if (snapshotEntry.hash !== entry.hash) {
|
|
717
|
-
drift.changed.push(rel);
|
|
718
|
-
} else {
|
|
719
|
-
drift.unchanged.push(rel);
|
|
720
|
-
}
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
for (const rel of Object.keys(state.files)) {
|
|
724
|
-
if (!(rel in current)) {
|
|
725
|
-
drift.removed.push(rel);
|
|
726
|
-
}
|
|
727
|
-
}
|
|
728
|
-
|
|
729
|
-
return { layer, drift, state };
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
/** Read `generation.structure[target]` from the manifest. */
|
|
733
|
-
export function readTargetStructure(
|
|
734
|
-
projectDir: string,
|
|
735
|
-
target: string
|
|
736
|
-
): { root: string; paths: Record<string, string>; scope: string } | null {
|
|
737
|
-
const manifest = readManifest(projectDir);
|
|
738
|
-
const structure = manifest.generation?.structure?.[target];
|
|
739
|
-
if (!structure || typeof structure !== "object" || typeof structure.root !== "string") {
|
|
740
|
-
return null;
|
|
741
|
-
}
|
|
742
|
-
return {
|
|
743
|
-
root: resolve(projectDir, structure.root),
|
|
744
|
-
paths: typeof structure.paths === "object" && structure.paths !== null ? structure.paths : {},
|
|
745
|
-
scope: typeof structure.scope === "string" ? structure.scope : "",
|
|
746
|
-
};
|
|
747
|
-
}
|
|
748
|
-
|
|
749
|
-
// ── snapshot ──────────────────────────────────────────────────────────
|
|
750
|
-
|
|
751
|
-
export interface SnapshotResult {
|
|
752
|
-
target: string;
|
|
753
|
-
snapshot_at: string;
|
|
754
|
-
files_hashed: number;
|
|
755
|
-
stubs: number;
|
|
756
|
-
state_path: string;
|
|
757
|
-
baseline: string | null;
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
export function createSnapshot(cwd: string, target: string): SnapshotResult {
|
|
761
|
-
const projectDir = findProjectDir(cwd);
|
|
762
|
-
const projectName = readProjectName(projectDir);
|
|
763
|
-
const outDir = resolveOutputDir(projectDir, projectName, target);
|
|
764
|
-
if (!existsSync(outDir)) {
|
|
765
|
-
throw new Error(
|
|
766
|
-
`Output directory not found: ${relative(cwd, outDir)}\n` +
|
|
767
|
-
`Run code generation for "${target}" first.`
|
|
768
|
-
);
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
const manifest = readManifest(projectDir);
|
|
772
|
-
const files = discoverSpecFiles(projectDir);
|
|
773
|
-
const baseline = captureBaseline(projectDir, files);
|
|
774
|
-
const { entries, stubs: stubCount } = buildFileEntries(projectDir, files);
|
|
775
|
-
|
|
776
|
-
const state: StateFile = {
|
|
777
|
-
spec_version: manifest.spec_version ?? "0.2",
|
|
778
|
-
snapshot_at: new Date().toISOString(),
|
|
779
|
-
target,
|
|
780
|
-
baseline,
|
|
781
|
-
files: entries,
|
|
782
|
-
};
|
|
783
|
-
|
|
784
|
-
const outPath = stateFilePath(projectDir, projectName, target);
|
|
785
|
-
writeFileSync(outPath, JSON.stringify(state, null, 2) + "\n");
|
|
786
|
-
|
|
787
|
-
// Auto-snapshot shared layers with tracks for this target if their state file doesn't exist yet
|
|
788
|
-
const sharedLayers = sharedLayersForTarget(projectDir, target);
|
|
789
|
-
for (const layer of sharedLayers) {
|
|
790
|
-
if (layer.tracks.length === 0) continue;
|
|
791
|
-
const layerStatePath = sharedLayerStatePath(layer);
|
|
792
|
-
if (!existsSync(layerStatePath) && existsSync(layer.root)) {
|
|
793
|
-
try {
|
|
794
|
-
createSharedSnapshot(cwd, layer.name, target);
|
|
795
|
-
} catch {
|
|
796
|
-
// Non-fatal: shared layer snapshot is best-effort during target snapshot
|
|
797
|
-
}
|
|
798
|
-
}
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
return {
|
|
802
|
-
target,
|
|
803
|
-
snapshot_at: state.snapshot_at,
|
|
804
|
-
files_hashed: Object.keys(entries).length,
|
|
805
|
-
stubs: stubCount,
|
|
806
|
-
state_path: relative(cwd, outPath),
|
|
807
|
-
baseline: formatBaseline(baseline),
|
|
808
|
-
};
|
|
809
|
-
}
|
|
810
|
-
|
|
811
|
-
function snapshot(cwd: string, projectDir: string, target: string): void {
|
|
812
|
-
try {
|
|
813
|
-
const result = createSnapshot(cwd, target);
|
|
814
|
-
console.log(`Snapshot saved: ${result.state_path}`);
|
|
815
|
-
console.log(` ${result.files_hashed} files hashed`);
|
|
816
|
-
if (result.stubs > 0) {
|
|
817
|
-
console.log(` ${result.stubs} stubs (not tracked for drift)`);
|
|
818
|
-
}
|
|
819
|
-
console.log(` target: ${result.target}`);
|
|
820
|
-
if (result.baseline) {
|
|
821
|
-
console.log(` baseline: ${result.baseline}`);
|
|
822
|
-
}
|
|
823
|
-
} catch (err) {
|
|
824
|
-
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
825
|
-
process.exit(1);
|
|
826
|
-
}
|
|
827
|
-
}
|
|
828
|
-
|
|
829
|
-
// ── check ─────────────────────────────────────────────────────────────
|
|
830
|
-
|
|
831
|
-
export interface CheckResult {
|
|
832
|
-
state: StateFile;
|
|
833
|
-
drift: DriftResult;
|
|
834
|
-
stubDrift: DriftResult;
|
|
835
|
-
statuses: Record<string, string>;
|
|
836
|
-
explanation?: ExplainResult;
|
|
837
|
-
shared_layer_drift?: SharedLayerDriftResult[];
|
|
838
|
-
}
|
|
839
|
-
|
|
840
|
-
export function computeDrift(
|
|
841
|
-
projectDir: string,
|
|
842
|
-
state: StateFile,
|
|
843
|
-
includeAll: boolean
|
|
844
|
-
): CheckResult {
|
|
845
|
-
const files = discoverSpecFiles(projectDir);
|
|
846
|
-
const { entries: current } = buildFileEntries(projectDir, files);
|
|
847
|
-
|
|
848
|
-
const drift: DriftResult = { changed: [], added: [], removed: [], unchanged: [] };
|
|
849
|
-
const stubDrift: DriftResult = { changed: [], added: [], removed: [], unchanged: [] };
|
|
850
|
-
const statuses: Record<string, string> = {};
|
|
851
|
-
|
|
852
|
-
for (const [rel, entry] of Object.entries(current)) {
|
|
853
|
-
const currentStatus = entry.status;
|
|
854
|
-
statuses[rel] = currentStatus;
|
|
855
|
-
const bucket = currentStatus === "stub" && !includeAll ? stubDrift : drift;
|
|
856
|
-
|
|
857
|
-
const snapshotEntry = state.files[rel]
|
|
858
|
-
? normalizeEntry(state.files[rel] as string | FileEntry)
|
|
859
|
-
: null;
|
|
860
|
-
|
|
861
|
-
if (!snapshotEntry) {
|
|
862
|
-
bucket.added.push(rel);
|
|
863
|
-
} else if (snapshotEntry.hash !== entry.hash) {
|
|
864
|
-
bucket.changed.push(rel);
|
|
865
|
-
} else {
|
|
866
|
-
bucket.unchanged.push(rel);
|
|
867
|
-
}
|
|
868
|
-
}
|
|
869
|
-
|
|
870
|
-
for (const [rel, raw] of Object.entries(state.files)) {
|
|
871
|
-
if (!(rel in current)) {
|
|
872
|
-
const entry = normalizeEntry(raw as string | FileEntry);
|
|
873
|
-
statuses[rel] = entry.status;
|
|
874
|
-
const bucket = entry.status === "stub" && !includeAll ? stubDrift : drift;
|
|
875
|
-
bucket.removed.push(rel);
|
|
876
|
-
}
|
|
877
|
-
}
|
|
878
|
-
|
|
879
|
-
return { state, drift, stubDrift, statuses };
|
|
880
|
-
}
|
|
881
|
-
|
|
882
|
-
export function loadTargetDrift(
|
|
883
|
-
cwd: string,
|
|
884
|
-
target: string,
|
|
885
|
-
includeAll: boolean,
|
|
886
|
-
explainOutput: boolean
|
|
887
|
-
): { projectDir: string; projectName: string; statePath: string; result: CheckResult } {
|
|
888
|
-
const projectDir = findProjectDir(cwd);
|
|
889
|
-
const projectName = readProjectName(projectDir);
|
|
890
|
-
const statePath = stateFilePath(projectDir, projectName, target);
|
|
891
|
-
if (!existsSync(statePath)) {
|
|
892
|
-
throw new Error(missingSnapshotMessage(cwd, projectDir, projectName, target));
|
|
893
|
-
}
|
|
894
|
-
|
|
895
|
-
const state: StateFile = JSON.parse(readFileSync(statePath, "utf-8"));
|
|
896
|
-
const result = computeDrift(projectDir, state, includeAll);
|
|
897
|
-
if (explainOutput) {
|
|
898
|
-
result.explanation = explainDrift(projectDir, result);
|
|
899
|
-
}
|
|
900
|
-
|
|
901
|
-
// Include shared layer drift for this target (only layers with tracks configured)
|
|
902
|
-
const sharedLayers = sharedLayersForTarget(projectDir, target);
|
|
903
|
-
const trackingLayers = sharedLayers.filter((l) => l.tracks.length > 0);
|
|
904
|
-
if (trackingLayers.length > 0) {
|
|
905
|
-
result.shared_layer_drift = trackingLayers.map((layer) =>
|
|
906
|
-
computeSharedDrift(projectDir, layer)
|
|
907
|
-
);
|
|
908
|
-
}
|
|
909
|
-
|
|
910
|
-
return { projectDir, projectName, statePath, result };
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
function check(
|
|
914
|
-
cwd: string,
|
|
915
|
-
projectDir: string,
|
|
916
|
-
target: string,
|
|
917
|
-
jsonOutput: boolean,
|
|
918
|
-
includeAll: boolean,
|
|
919
|
-
explainOutput: boolean
|
|
920
|
-
): void {
|
|
921
|
-
const { result } = loadTargetDrift(cwd, target, includeAll, explainOutput);
|
|
922
|
-
|
|
923
|
-
if (jsonOutput) {
|
|
924
|
-
printJson(result);
|
|
925
|
-
} else {
|
|
926
|
-
printReport(projectDir, result);
|
|
927
|
-
}
|
|
928
|
-
|
|
929
|
-
const d = result.drift;
|
|
930
|
-
const hasDrift = d.changed.length > 0 || d.added.length > 0 || d.removed.length > 0;
|
|
931
|
-
process.exit(hasDrift ? 2 : 0);
|
|
932
|
-
}
|
|
933
|
-
|
|
934
|
-
function checkAll(
|
|
935
|
-
cwd: string,
|
|
936
|
-
projectDir: string,
|
|
937
|
-
jsonOutput: boolean,
|
|
938
|
-
includeAll: boolean,
|
|
939
|
-
explainOutput: boolean
|
|
940
|
-
): void {
|
|
941
|
-
const projectName = readProjectName(projectDir);
|
|
942
|
-
const targets = discoverTargets(projectDir, projectName);
|
|
943
|
-
if (targets.length === 0) {
|
|
944
|
-
console.error(
|
|
945
|
-
"No snapshots found. Run: openuispec drift --snapshot --target <target>"
|
|
946
|
-
);
|
|
947
|
-
process.exit(1);
|
|
948
|
-
}
|
|
949
|
-
|
|
950
|
-
let anyDrift = false;
|
|
951
|
-
|
|
952
|
-
for (const target of targets) {
|
|
953
|
-
const statePath = stateFilePath(projectDir, projectName, target);
|
|
954
|
-
const state: StateFile = JSON.parse(readFileSync(statePath, "utf-8"));
|
|
955
|
-
const result = computeDrift(projectDir, state, includeAll);
|
|
956
|
-
if (explainOutput) {
|
|
957
|
-
result.explanation = explainDrift(projectDir, result);
|
|
958
|
-
}
|
|
959
|
-
|
|
960
|
-
if (jsonOutput) {
|
|
961
|
-
printJson(result);
|
|
962
|
-
} else {
|
|
963
|
-
printReport(projectDir, result);
|
|
964
|
-
}
|
|
965
|
-
|
|
966
|
-
const d = result.drift;
|
|
967
|
-
const hasDrift = d.changed.length > 0 || d.added.length > 0 || d.removed.length > 0;
|
|
968
|
-
if (hasDrift) anyDrift = true;
|
|
969
|
-
|
|
970
|
-
if (targets.length > 1 && target !== targets[targets.length - 1]) {
|
|
971
|
-
console.log("");
|
|
972
|
-
}
|
|
973
|
-
}
|
|
974
|
-
|
|
975
|
-
process.exit(anyDrift ? 2 : 0);
|
|
976
|
-
}
|
|
977
|
-
|
|
978
|
-
// ── output ────────────────────────────────────────────────────────────
|
|
979
|
-
|
|
980
|
-
function printJson(result: CheckResult): void {
|
|
981
|
-
const stubTotal =
|
|
982
|
-
result.stubDrift.changed.length +
|
|
983
|
-
result.stubDrift.added.length +
|
|
984
|
-
result.stubDrift.removed.length +
|
|
985
|
-
result.stubDrift.unchanged.length;
|
|
986
|
-
|
|
987
|
-
console.log(
|
|
988
|
-
JSON.stringify(
|
|
989
|
-
{
|
|
990
|
-
snapshot_at: result.state.snapshot_at,
|
|
991
|
-
target: result.state.target,
|
|
992
|
-
baseline: result.state.baseline,
|
|
993
|
-
...result.drift,
|
|
994
|
-
explanation: result.explanation,
|
|
995
|
-
stubs: stubTotal > 0 ? result.stubDrift : undefined,
|
|
996
|
-
},
|
|
997
|
-
null,
|
|
998
|
-
2
|
|
999
|
-
)
|
|
1000
|
-
);
|
|
1001
|
-
}
|
|
1002
|
-
|
|
1003
|
-
function printReport(projectDir: string, result: CheckResult): void {
|
|
1004
|
-
const projectName = readProjectName(projectDir);
|
|
1005
|
-
|
|
1006
|
-
console.log("OpenUISpec Drift Check");
|
|
1007
|
-
console.log("======================");
|
|
1008
|
-
console.log(`Project: ${projectName}`);
|
|
1009
|
-
console.log(`Snapshot: ${result.state.snapshot_at}`);
|
|
1010
|
-
console.log(`Target: ${result.state.target}`);
|
|
1011
|
-
const baselineLabel = formatBaseline(result.state.baseline);
|
|
1012
|
-
if (baselineLabel) {
|
|
1013
|
-
console.log(`Baseline: ${baselineLabel}`);
|
|
1014
|
-
}
|
|
1015
|
-
|
|
1016
|
-
const d = result.drift;
|
|
1017
|
-
|
|
1018
|
-
const allTracked = new Set([
|
|
1019
|
-
...d.unchanged,
|
|
1020
|
-
...d.changed,
|
|
1021
|
-
...d.added,
|
|
1022
|
-
...d.removed,
|
|
1023
|
-
]);
|
|
1024
|
-
|
|
1025
|
-
const categories = new Map<string, string[]>();
|
|
1026
|
-
for (const f of allTracked) {
|
|
1027
|
-
const cat = categorize(f);
|
|
1028
|
-
if (!categories.has(cat)) categories.set(cat, []);
|
|
1029
|
-
categories.get(cat)!.push(f);
|
|
1030
|
-
}
|
|
1031
|
-
|
|
1032
|
-
const order = [
|
|
1033
|
-
"Manifest",
|
|
1034
|
-
"Tokens",
|
|
1035
|
-
"Screens",
|
|
1036
|
-
"Flows",
|
|
1037
|
-
"Platform",
|
|
1038
|
-
"Locales",
|
|
1039
|
-
"Contracts",
|
|
1040
|
-
"Components",
|
|
1041
|
-
"Other",
|
|
1042
|
-
];
|
|
1043
|
-
|
|
1044
|
-
for (const cat of order) {
|
|
1045
|
-
const files = categories.get(cat);
|
|
1046
|
-
if (!files) continue;
|
|
1047
|
-
files.sort();
|
|
1048
|
-
|
|
1049
|
-
console.log(`\n${cat}`);
|
|
1050
|
-
for (const f of files) {
|
|
1051
|
-
const name = basename(f);
|
|
1052
|
-
if (d.changed.includes(f)) {
|
|
1053
|
-
console.log(` \u2717 ${name} (changed)`);
|
|
1054
|
-
} else if (d.added.includes(f)) {
|
|
1055
|
-
console.log(` + ${name} (added)`);
|
|
1056
|
-
} else if (d.removed.includes(f)) {
|
|
1057
|
-
console.log(` - ${name} (removed)`);
|
|
1058
|
-
} else {
|
|
1059
|
-
console.log(` \u2713 ${name}`);
|
|
1060
|
-
}
|
|
1061
|
-
}
|
|
1062
|
-
}
|
|
1063
|
-
|
|
1064
|
-
const sd = result.stubDrift;
|
|
1065
|
-
const allStubs = [
|
|
1066
|
-
...sd.unchanged,
|
|
1067
|
-
...sd.changed,
|
|
1068
|
-
...sd.added,
|
|
1069
|
-
...sd.removed,
|
|
1070
|
-
];
|
|
1071
|
-
|
|
1072
|
-
if (allStubs.length > 0) {
|
|
1073
|
-
console.log("\nStubs (not tracked)");
|
|
1074
|
-
allStubs.sort();
|
|
1075
|
-
for (const f of allStubs) {
|
|
1076
|
-
const name = basename(f);
|
|
1077
|
-
const hasChange =
|
|
1078
|
-
sd.changed.includes(f) || sd.added.includes(f) || sd.removed.includes(f);
|
|
1079
|
-
console.log(` \u00b7 ${name}${hasChange ? " (changed)" : ""}`);
|
|
1080
|
-
}
|
|
1081
|
-
}
|
|
1082
|
-
|
|
1083
|
-
const stubCount = allStubs.length;
|
|
1084
|
-
const stubSuffix = stubCount > 0 ? ` (${stubCount} stubs skipped)` : "";
|
|
1085
|
-
console.log(
|
|
1086
|
-
`\nSummary: ${d.changed.length} changed, ${d.added.length} added, ${d.removed.length} removed${stubSuffix}`
|
|
1087
|
-
);
|
|
1088
|
-
|
|
1089
|
-
if (result.explanation) {
|
|
1090
|
-
console.log("\nSemantic Changes");
|
|
1091
|
-
console.log("----------------");
|
|
1092
|
-
|
|
1093
|
-
if (!result.explanation.available) {
|
|
1094
|
-
console.log(result.explanation.note ?? "Semantic explanation unavailable.");
|
|
1095
|
-
return;
|
|
1096
|
-
}
|
|
1097
|
-
|
|
1098
|
-
if (result.explanation.files.length === 0) {
|
|
1099
|
-
console.log("No semantic changes to explain.");
|
|
1100
|
-
return;
|
|
1101
|
-
}
|
|
1102
|
-
|
|
1103
|
-
for (const file of result.explanation.files) {
|
|
1104
|
-
console.log(`\n${file.file}`);
|
|
1105
|
-
if (file.changes.length === 0) {
|
|
1106
|
-
console.log(" · no property-level changes detected");
|
|
1107
|
-
continue;
|
|
1108
|
-
}
|
|
1109
|
-
|
|
1110
|
-
for (const change of file.changes) {
|
|
1111
|
-
const pathLabel = change.path || "(root)";
|
|
1112
|
-
if (change.kind === "added") {
|
|
1113
|
-
const value = change.after ? ` = ${change.after}` : "";
|
|
1114
|
-
console.log(` + ${pathLabel}${value}`);
|
|
1115
|
-
} else if (change.kind === "removed") {
|
|
1116
|
-
const value = change.before ? ` (was ${change.before})` : "";
|
|
1117
|
-
console.log(` - ${pathLabel}${value}`);
|
|
1118
|
-
} else {
|
|
1119
|
-
console.log(` ~ ${pathLabel}: ${change.before ?? "?"} -> ${change.after ?? "?"}`);
|
|
1120
|
-
}
|
|
1121
|
-
}
|
|
1122
|
-
|
|
1123
|
-
if (file.truncated) {
|
|
1124
|
-
console.log(` … truncated after ${MAX_CHANGES_PER_FILE} changes`);
|
|
1125
|
-
}
|
|
1126
|
-
}
|
|
1127
|
-
}
|
|
1128
|
-
}
|
|
1129
|
-
|
|
1130
|
-
// ── main ──────────────────────────────────────────────────────────────
|
|
1131
|
-
|
|
1132
|
-
export function runDrift(argv: string[]): void {
|
|
1133
|
-
const isSnapshot = argv.includes("--snapshot");
|
|
1134
|
-
const isJson = argv.includes("--json");
|
|
1135
|
-
const includeAll = argv.includes("--all");
|
|
1136
|
-
const explainOutput = argv.includes("--explain");
|
|
1137
|
-
|
|
1138
|
-
const targetIdx = argv.indexOf("--target");
|
|
1139
|
-
const target = targetIdx !== -1 && argv[targetIdx + 1] ? argv[targetIdx + 1] : null;
|
|
1140
|
-
|
|
1141
|
-
const cwd = process.cwd();
|
|
1142
|
-
const projectDir = findProjectDir(cwd);
|
|
1143
|
-
|
|
1144
|
-
if (isSnapshot) {
|
|
1145
|
-
if (!target) {
|
|
1146
|
-
console.error("Error: --target is required for --snapshot");
|
|
1147
|
-
console.error("Usage: openuispec drift --snapshot --target <target>");
|
|
1148
|
-
process.exit(1);
|
|
1149
|
-
}
|
|
1150
|
-
snapshot(cwd, projectDir, target);
|
|
1151
|
-
} else if (target) {
|
|
1152
|
-
check(cwd, projectDir, target, isJson, includeAll, explainOutput);
|
|
1153
|
-
} else {
|
|
1154
|
-
checkAll(cwd, projectDir, isJson, includeAll, explainOutput);
|
|
1155
|
-
}
|
|
1156
|
-
}
|
|
1157
|
-
|
|
1158
|
-
// Direct execution
|
|
1159
|
-
const isDirectRun =
|
|
1160
|
-
process.argv[1]?.endsWith("drift/index.ts") ||
|
|
1161
|
-
process.argv[1]?.endsWith("drift/index.js");
|
|
1162
|
-
|
|
1163
|
-
if (isDirectRun) {
|
|
1164
|
-
runDrift(process.argv.slice(2));
|
|
1165
|
-
}
|