openuispec 0.2.18 → 0.2.20

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 (45) hide show
  1. package/README.md +2 -10
  2. package/dist/check/audit.js +392 -0
  3. package/dist/check/index.js +216 -0
  4. package/dist/cli/configure-target.js +391 -0
  5. package/dist/cli/index.js +510 -0
  6. package/dist/cli/init.js +1047 -0
  7. package/dist/drift/index.js +903 -0
  8. package/dist/mcp-server/index.js +886 -0
  9. package/dist/mcp-server/preview-render.js +1761 -0
  10. package/dist/mcp-server/preview.js +233 -0
  11. package/dist/mcp-server/screenshot-android.js +458 -0
  12. package/dist/mcp-server/screenshot-ios.js +639 -0
  13. package/dist/mcp-server/screenshot-shared.js +180 -0
  14. package/dist/mcp-server/screenshot.js +459 -0
  15. package/dist/prepare/index.js +1216 -0
  16. package/dist/runtime/package-paths.js +33 -0
  17. package/dist/schema/semantic-lint.js +564 -0
  18. package/dist/schema/validate.js +689 -0
  19. package/dist/status/index.js +194 -0
  20. package/docs/images/how-it-works.svg +56 -0
  21. package/docs/images/workflows.svg +76 -0
  22. package/package.json +12 -13
  23. package/check/audit.ts +0 -426
  24. package/check/index.ts +0 -320
  25. package/cli/configure-target.ts +0 -523
  26. package/cli/index.ts +0 -537
  27. package/cli/init.ts +0 -1253
  28. package/docs/images/how-it-works-dark.png +0 -0
  29. package/docs/images/how-it-works-light.png +0 -0
  30. package/docs/images/workflows-dark.png +0 -0
  31. package/docs/images/workflows-light.png +0 -0
  32. package/drift/index.ts +0 -1165
  33. package/mcp-server/index.ts +0 -1041
  34. package/mcp-server/preview-render.ts +0 -1922
  35. package/mcp-server/preview.ts +0 -292
  36. package/mcp-server/screenshot-android.ts +0 -621
  37. package/mcp-server/screenshot-ios.ts +0 -753
  38. package/mcp-server/screenshot-shared.ts +0 -237
  39. package/mcp-server/screenshot.ts +0 -563
  40. package/prepare/index.ts +0 -1530
  41. package/schema/semantic-lint.ts +0 -692
  42. package/schema/validate.ts +0 -870
  43. package/scripts/regenerate-previews.ts +0 -136
  44. package/scripts/take-all-screenshots.ts +0 -507
  45. package/status/index.ts +0 -275
@@ -0,0 +1,1216 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * AI preparation bundle for OpenUISpec projects.
4
+ *
5
+ * Usage:
6
+ * openuispec prepare --target ios # AI-ready work bundle for ios
7
+ * openuispec prepare --target web --json # machine-readable output
8
+ */
9
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
10
+ import { basename, extname, join, relative, resolve } from "node:path";
11
+ import { findPackageRoot } from "../runtime/package-paths.js";
12
+ import YAML from "yaml";
13
+ import { listTargetWizardOptions } from "../cli/configure-target.js";
14
+ import { discoverSpecFiles, findProjectDir, hasStatusSemantics, isSupportedTarget, loadTargetDrift, readManifest, readProjectName, readStatus, hasDriftChanges, readSharedLayerState, readTargetStructure, resolveOutputDir, sharedLayersForTarget, specCategory, computeSharedDrift, } from "../drift/index.js";
15
+ function resolvePackageRoot() {
16
+ return findPackageRoot(import.meta.url);
17
+ }
18
+ function readPlatformDefinition(projectDir, manifest, target) {
19
+ const platformDir = resolve(projectDir, manifest.includes?.platform ?? "./platform/");
20
+ const platformPath = join(platformDir, `${target}.yaml`);
21
+ if (!existsSync(platformPath))
22
+ return {};
23
+ try {
24
+ const doc = YAML.parse(readFileSync(platformPath, "utf-8"));
25
+ return doc?.[target] ?? {};
26
+ }
27
+ catch {
28
+ return {};
29
+ }
30
+ }
31
+ function readTargetPresetDependencyLinks(target) {
32
+ try {
33
+ const presetPath = join(resolvePackageRoot(), "cli", "target-presets.json");
34
+ const presets = JSON.parse(readFileSync(presetPath, "utf-8"));
35
+ return {
36
+ questions: (presets[target]?.questions ?? []).map((question) => ({
37
+ key: question.key,
38
+ options: question.options ?? [],
39
+ })),
40
+ };
41
+ }
42
+ catch {
43
+ return { questions: [] };
44
+ }
45
+ }
46
+ function platformStackKeys(target) {
47
+ switch (target) {
48
+ case "android":
49
+ return ["architecture", "state", "preferences", "database", "di", "naming"];
50
+ case "web":
51
+ return ["runtime", "routing", "state", "storage_backend", "bundler", "css", "naming"];
52
+ case "ios":
53
+ return ["architecture", "persistence", "di", "naming"];
54
+ default:
55
+ return [];
56
+ }
57
+ }
58
+ function buildPlatformConfig(target, platformDef) {
59
+ const generation = platformDef.generation && typeof platformDef.generation === "object" ? platformDef.generation : {};
60
+ const links = readTargetPresetDependencyLinks(target);
61
+ const stack = Object.fromEntries(platformStackKeys(target)
62
+ .map((key) => [key, generation[key]])
63
+ .filter((entry) => typeof entry[1] === "string" && entry[1].trim().length > 0));
64
+ const dependencies = Array.isArray(generation.dependencies)
65
+ ? generation.dependencies.filter((dep) => typeof dep === "string")
66
+ : [];
67
+ const selectedOptionRefs = Object.fromEntries(links.questions
68
+ .map((question) => {
69
+ const generationValue = generation[question.key];
70
+ if (typeof generationValue !== "string" || generationValue.trim().length === 0) {
71
+ return null;
72
+ }
73
+ const selected = question.options.find((option) => option.value === generationValue || option.generation_value === generationValue);
74
+ if (!selected?.refs) {
75
+ return null;
76
+ }
77
+ return [
78
+ question.key,
79
+ {
80
+ value: selected.value,
81
+ plugins: selected.refs.plugins ?? [],
82
+ libraries: selected.refs.libraries ?? [],
83
+ packages: selected.refs.packages ?? [],
84
+ docs: selected.refs.docs ?? [],
85
+ },
86
+ ];
87
+ })
88
+ .filter((entry) => entry !== null));
89
+ return {
90
+ framework: typeof platformDef.framework === "string" ? platformDef.framework : null,
91
+ language: typeof platformDef.language === "string" ? platformDef.language : null,
92
+ min_version: typeof platformDef.min_version === "string" ? platformDef.min_version : null,
93
+ min_sdk: typeof platformDef.min_sdk === "number" ? platformDef.min_sdk : null,
94
+ target_sdk: typeof platformDef.target_sdk === "number" ? platformDef.target_sdk : null,
95
+ generation,
96
+ stack,
97
+ stack_confirmation: {
98
+ status: typeof generation.stack_confirmation?.status === "string"
99
+ ? generation.stack_confirmation.status
100
+ : null,
101
+ requires_user_confirmation: generation.stack_confirmation?.status === "pending_user_confirmation",
102
+ },
103
+ dependency_guidance: {
104
+ anchor_refs_only: true,
105
+ notes: [
106
+ "Selected option refs are anchor dependencies and setup references, not a complete dependency manifest.",
107
+ "Add any supporting runtime, build, plugin, repository, annotation-processing, and dev/test dependencies required by the current platform/framework setup.",
108
+ "Resolve exact versions, compatibility, and project wiring from current platform documentation instead of relying on stale memory.",
109
+ ],
110
+ },
111
+ selected_option_refs: selectedOptionRefs,
112
+ dependencies,
113
+ };
114
+ }
115
+ function hasApiEndpoints(manifest) {
116
+ const endpoints = manifest.api?.endpoints;
117
+ return typeof endpoints === "object" && endpoints !== null && Object.keys(endpoints).length > 0;
118
+ }
119
+ const KNOWN_WEB_FRAMEWORKS = new Set(["react", "vue", "svelte"]);
120
+ function isKnownWebFramework(framework) {
121
+ return framework !== null && KNOWN_WEB_FRAMEWORKS.has(framework);
122
+ }
123
+ function resolveBackendRoot(projectDir, manifest) {
124
+ const backendRoot = manifest.generation?.code_roots?.backend;
125
+ if (typeof backendRoot !== "string" || backendRoot.trim().length === 0) {
126
+ return null;
127
+ }
128
+ return resolve(projectDir, backendRoot);
129
+ }
130
+ // Use specCategory from drift/index.ts (imported above) instead of a local duplicate.
131
+ const categorizeSpecFile = specCategory;
132
+ function buildSharedLayerInfos(projectDir, target, layers) {
133
+ const resolvedLayers = layers ?? sharedLayersForTarget(projectDir, target);
134
+ if (resolvedLayers.length === 0)
135
+ return [];
136
+ return resolvedLayers.map((layer) => {
137
+ const state = readSharedLayerState(layer);
138
+ const alreadyGenerated = state !== null;
139
+ // Only compute hash-based drift when tracks are configured
140
+ let hasDrift = false;
141
+ if (layer.tracks.length > 0) {
142
+ const driftResult = computeSharedDrift(projectDir, layer);
143
+ hasDrift = driftResult.state !== null && hasDriftChanges(driftResult.drift);
144
+ }
145
+ let guidance;
146
+ if (alreadyGenerated && !hasDrift) {
147
+ guidance = `Shared code already generated by ${state.generated_by_target} — read existing code, don't regenerate.`;
148
+ }
149
+ else if (alreadyGenerated && hasDrift) {
150
+ guidance = `Generated by ${state.generated_by_target} but spec has drifted — review shared code for needed updates.`;
151
+ }
152
+ else {
153
+ guidance = `Generate shared layer alongside ${target} platform code.`;
154
+ }
155
+ return {
156
+ name: layer.name,
157
+ platforms: layer.platforms,
158
+ language: layer.language,
159
+ root: layer.root,
160
+ paths: layer.paths,
161
+ scope: layer.scope,
162
+ tracks: layer.tracks,
163
+ already_generated: alreadyGenerated,
164
+ generated_by_target: state?.generated_by_target ?? null,
165
+ has_drift: hasDrift,
166
+ guidance,
167
+ };
168
+ });
169
+ }
170
+ function collectSharedLayerPaths(layers) {
171
+ const paths = [];
172
+ for (const layer of layers) {
173
+ paths.push(layer.root);
174
+ for (const p of Object.values(layer.paths)) {
175
+ paths.push(resolve(layer.root, p));
176
+ }
177
+ }
178
+ return paths;
179
+ }
180
+ function dedupeExistingPaths(candidates) {
181
+ const seen = new Set();
182
+ return candidates
183
+ .map((c) => resolve(c))
184
+ .filter((c) => existsSync(c))
185
+ .filter((c) => {
186
+ if (seen.has(c))
187
+ return false;
188
+ seen.add(c);
189
+ return true;
190
+ });
191
+ }
192
+ function suggestCodeRoots(target, outputDir, projectDir, sharedLayers) {
193
+ const layers = sharedLayers ?? (projectDir ? sharedLayersForTarget(projectDir, target) : []);
194
+ // When generation.structure is defined for the target, use it instead of heuristics
195
+ if (projectDir) {
196
+ const structure = readTargetStructure(projectDir, target);
197
+ if (structure) {
198
+ const candidates = [
199
+ structure.root,
200
+ ...Object.values(structure.paths).map((p) => resolve(structure.root, p)),
201
+ ...collectSharedLayerPaths(layers),
202
+ ];
203
+ return dedupeExistingPaths(candidates);
204
+ }
205
+ }
206
+ const candidates = [];
207
+ if (target === "web") {
208
+ candidates.push(join(outputDir, "src"), outputDir);
209
+ }
210
+ else if (target === "ios") {
211
+ candidates.push(join(outputDir, "Sources"), join(outputDir, "Resources"), outputDir);
212
+ }
213
+ else if (target === "android") {
214
+ candidates.push(join(outputDir, "app", "src", "main", "java"), join(outputDir, "app", "src", "main", "kotlin"), join(outputDir, "app", "src", "main", "res"), outputDir);
215
+ }
216
+ else {
217
+ candidates.push(outputDir);
218
+ }
219
+ candidates.push(...collectSharedLayerPaths(layers));
220
+ return dedupeExistingPaths(candidates);
221
+ }
222
+ function walkFiles(root, files, depth = 0) {
223
+ if (depth > 8)
224
+ return;
225
+ for (const entry of readdirSync(root)) {
226
+ if (entry === ".git" ||
227
+ entry === ".next" ||
228
+ entry === ".turbo" ||
229
+ entry === ".cache" ||
230
+ entry === ".vercel" ||
231
+ entry === "node_modules" ||
232
+ entry === "build" ||
233
+ entry === "dist" ||
234
+ entry === "coverage" ||
235
+ entry === "storybook-static" ||
236
+ entry === "out" ||
237
+ entry === ".gradle" ||
238
+ entry === "DerivedData") {
239
+ continue;
240
+ }
241
+ const fullPath = join(root, entry);
242
+ let stat;
243
+ try {
244
+ stat = statSync(fullPath);
245
+ }
246
+ catch {
247
+ continue;
248
+ }
249
+ if (stat.isDirectory()) {
250
+ walkFiles(fullPath, files, depth + 1);
251
+ continue;
252
+ }
253
+ files.push(fullPath);
254
+ }
255
+ }
256
+ const SEARCHABLE_EXTENSIONS = new Set([
257
+ ".ts", ".tsx", ".js", ".jsx", ".json",
258
+ ".swift", ".kt", ".kts", ".xml",
259
+ ".css", ".scss", ".md", ".plist",
260
+ ".yaml", ".yml", ".strings",
261
+ ]);
262
+ const MAX_PREPARE_CHANGES_PER_ITEM = 8;
263
+ const MAX_PREPARE_LIKELY_FILES = 4;
264
+ function isSearchableFile(filePath) {
265
+ if (basename(filePath) === ".openuispec-state.json")
266
+ return false;
267
+ return SEARCHABLE_EXTENSIONS.has(extname(filePath).toLowerCase());
268
+ }
269
+ function collectSearchCandidates(outputDir, codeRoots) {
270
+ const visited = new Set();
271
+ const candidates = [];
272
+ for (const root of codeRoots) {
273
+ const files = [];
274
+ walkFiles(root, files);
275
+ for (const filePath of files) {
276
+ if (!isSearchableFile(filePath) || visited.has(filePath))
277
+ continue;
278
+ visited.add(filePath);
279
+ const relPath = relative(outputDir, filePath);
280
+ candidates.push({
281
+ path: filePath,
282
+ relPath,
283
+ relPathLower: relPath.toLowerCase(),
284
+ });
285
+ }
286
+ }
287
+ return candidates;
288
+ }
289
+ function readSearchCandidateContent(candidate) {
290
+ if (candidate.contentLower !== undefined) {
291
+ return candidate.contentLower;
292
+ }
293
+ try {
294
+ candidate.contentLower = readFileSync(candidate.path, "utf-8").toLowerCase();
295
+ }
296
+ catch {
297
+ candidate.contentLower = null;
298
+ }
299
+ return candidate.contentLower;
300
+ }
301
+ function normalizeTerm(term) {
302
+ const normalized = term.toLowerCase().replace(/[^a-z0-9._/-]+/g, "").trim();
303
+ if (!normalized || normalized.length < 3)
304
+ return null;
305
+ if (["type", "props", "layout", "children", "title", "body", "root"].includes(normalized)) {
306
+ return null;
307
+ }
308
+ return normalized;
309
+ }
310
+ function buildSearchTerms(file) {
311
+ const terms = new Set();
312
+ const stem = basename(file.file, extname(file.file));
313
+ const baseTerms = [
314
+ stem,
315
+ stem.replace(/_/g, ""),
316
+ stem.replace(/_/g, "."),
317
+ ];
318
+ for (const term of baseTerms) {
319
+ const normalized = normalizeTerm(term);
320
+ if (normalized)
321
+ terms.add(normalized);
322
+ }
323
+ for (const change of file.changes) {
324
+ for (const part of change.path.split(/[.[\]/]+/)) {
325
+ const normalized = normalizeTerm(part);
326
+ if (normalized)
327
+ terms.add(normalized);
328
+ }
329
+ const normalizedPath = normalizeTerm(change.path);
330
+ if (normalizedPath)
331
+ terms.add(normalizedPath);
332
+ }
333
+ return Array.from(terms).sort((a, b) => b.length - a.length);
334
+ }
335
+ function searchLikelyFiles(candidates, file) {
336
+ const terms = buildSearchTerms(file);
337
+ if (terms.length === 0)
338
+ return [];
339
+ const scored = candidates
340
+ .map((candidate) => {
341
+ const pathScore = terms.reduce((sum, term) => sum + (candidate.relPathLower.includes(term) ? 5 : 0), 0);
342
+ let contentScore = 0;
343
+ if (pathScore > 0 || terms.some((term) => term.includes("."))) {
344
+ const text = readSearchCandidateContent(candidate);
345
+ if (text) {
346
+ contentScore = terms.reduce((sum, term) => sum + (text.includes(term) ? 2 : 0), 0);
347
+ }
348
+ }
349
+ return { relPath: candidate.relPath, score: pathScore + contentScore };
350
+ })
351
+ .filter((entry) => entry.score > 0)
352
+ .sort((a, b) => b.score - a.score || a.relPath.localeCompare(b.relPath))
353
+ .slice(0, 10);
354
+ const unique = new Set();
355
+ const results = [];
356
+ for (const entry of scored) {
357
+ if (unique.has(entry.relPath))
358
+ continue;
359
+ unique.add(entry.relPath);
360
+ results.push(entry.relPath);
361
+ if (results.length >= MAX_PREPARE_LIKELY_FILES)
362
+ break;
363
+ }
364
+ return results;
365
+ }
366
+ function compactPrepareSemanticChanges(changes) {
367
+ if (changes.length <= MAX_PREPARE_CHANGES_PER_ITEM) {
368
+ return { changes, truncated: false };
369
+ }
370
+ return {
371
+ changes: changes.slice(0, MAX_PREPARE_CHANGES_PER_ITEM),
372
+ truncated: true,
373
+ };
374
+ }
375
+ function buildCategoryNotes(category, target) {
376
+ switch (category) {
377
+ case "screens":
378
+ return ["Update the target screen/view implementation and any matching navigation title or route shell."];
379
+ case "flows":
380
+ return ["Update target flow wiring, sheet/modal presentation, and action handlers for this flow."];
381
+ case "locales":
382
+ return ["Update target localization resources so new or changed locale keys are available at runtime."];
383
+ case "tokens":
384
+ return ["Update target theme, style, or shared visual tokens if the spec change affects appearance or spacing semantics."];
385
+ case "contracts":
386
+ return ["Update shared target primitives/renderers that realize this contract family."];
387
+ case "platform":
388
+ return [`Update ${target}-specific shell, navigation, or platform override behavior.`];
389
+ case "manifest":
390
+ return ["Recheck app shell, routing, data wiring, and generation target assumptions from the project manifest."];
391
+ default:
392
+ return ["Review the semantic diff and update the target implementation accordingly."];
393
+ }
394
+ }
395
+ function buildBootstrapNotes(category, target, specStatus) {
396
+ const notes = buildCategoryNotes(category, target);
397
+ if (specStatus === "stub") {
398
+ notes.unshift("This spec file is marked stub, so treat it as incomplete guidance during first-time generation.");
399
+ }
400
+ else if (specStatus === "draft") {
401
+ notes.unshift("This spec file is draft quality. Generate conservatively and expect follow-up refinement.");
402
+ }
403
+ else if (specStatus === "ready") {
404
+ notes.unshift("This spec file is ready and should be implemented in the initial target output.");
405
+ }
406
+ return notes;
407
+ }
408
+ function generationRules(target, outputDir, manifest, sharedLayers) {
409
+ const outputFormat = manifest.generation?.output_format?.[target] ?? {};
410
+ const rules = [
411
+ "Read openuispec.yaml first, then follow the referenced spec files instead of inventing structure from memory.",
412
+ "Implement every ready or draft screen and flow in the target output; treat stub screens and flows as incomplete guidance.",
413
+ "Apply token files, locale resources, contracts, and platform overrides together so the generated target is internally consistent.",
414
+ "Do not leave unresolved locale keys, token references, or placeholder assets in the generated UI.",
415
+ `Write the generated ${target} output under ${outputDir}.`,
416
+ `After the first accepted ${target} output exists, run \`openuispec drift --snapshot --target ${target}\` to baseline it.`,
417
+ ];
418
+ if (outputFormat.framework || outputFormat.language) {
419
+ rules.unshift(`Target output must follow ${outputFormat.language ?? "the configured language"} / ${outputFormat.framework ?? "the configured framework"}.`);
420
+ }
421
+ if (sharedLayers && sharedLayers.length > 0) {
422
+ for (const layer of sharedLayers) {
423
+ if (layer.already_generated) {
424
+ rules.push(`Shared layer "${layer.name}" already generated by ${layer.generated_by_target}. Read the existing code under ${layer.root} instead of regenerating it.`);
425
+ }
426
+ else {
427
+ rules.push(`Generate shared layer "${layer.name}" (${layer.language}) under ${layer.root} alongside ${target} platform code.`);
428
+ }
429
+ if (layer.scope) {
430
+ rules.push(`Shared layer "${layer.name}" scope: ${layer.scope}`);
431
+ }
432
+ }
433
+ }
434
+ // Include target structure scope when defined
435
+ const structure = manifest.generation?.structure?.[target];
436
+ if (structure?.scope) {
437
+ rules.push(`Target "${target}" scope: ${structure.scope}`);
438
+ }
439
+ // Include extra_rules from manifest, filtered by target platform tag
440
+ const extraRules = Array.isArray(manifest.generation?.extra_rules)
441
+ ? manifest.generation.extra_rules.filter((rule) => typeof rule === "string")
442
+ : [];
443
+ for (const rule of extraRules) {
444
+ if (matchesTargetPlatform(rule, target))
445
+ rules.push(rule);
446
+ }
447
+ return rules;
448
+ }
449
+ function matchesTargetPlatform(item, target) {
450
+ const tagMatch = item.match(/^\[([a-z]+)\]/i);
451
+ return !tagMatch || tagMatch[1].toLowerCase() === target;
452
+ }
453
+ function complexityRule(complexity) {
454
+ switch (complexity) {
455
+ case 'restrained':
456
+ return 'Minimal motion (required state transitions only). No decorative shadows. Clean whitespace. Precise token application. No background effects.';
457
+ case 'elaborate':
458
+ return 'Rich animations with staggered reveals. Creative elevation. Platform-specific flourishes.';
459
+ default:
460
+ return 'Apply all motion.patterns. Use elevation tokens fully. Standard state animations.';
461
+ }
462
+ }
463
+ function qualityTierRule(tier) {
464
+ switch (tier) {
465
+ case 'mvp':
466
+ return 'Functional-only. Use semantic tokens but tolerate simple layouts. Skip elevation, motion patterns, and adaptive breakpoints.';
467
+ case 'flagship':
468
+ return 'Pixel-perfect. Every token, motion pattern, elevation level, and adaptive breakpoint must be implemented. All contract states required. No shortcuts.';
469
+ default:
470
+ return 'Production-quality. Apply all tokens, handle accessibility, support adaptive breakpoints. Motion and elevation expected but minor shortcuts acceptable.';
471
+ }
472
+ }
473
+ function buildQualityTest(complexity, qualityTier, personality) {
474
+ const items = [
475
+ 'Inter/Roboto/Arial as the primary font when the spec defines a custom font_family.',
476
+ 'Pure black (#000000) or pure white (#FFFFFF) — all colors must resolve through tokens.',
477
+ 'Cyan-on-dark, purple-to-blue gradient, or neon accent color schemes not in color tokens.',
478
+ 'Card-wrapping every content group — cards are for distinct, comparable items only.',
479
+ 'Identical spacing values throughout — the spec defines a scale with distinct levels.',
480
+ 'Bounce or elastic easing — use only the easing curves from motion tokens.',
481
+ 'Shadows on elements with no elevation token assigned.',
482
+ 'A single font weight everywhere — the type scale defines multiple weights for hierarchy.',
483
+ ];
484
+ if (complexity === 'restrained') {
485
+ items.push('Any decorative animation, gradient, glassmorphism, or background effect — this is a restrained design.');
486
+ }
487
+ else if (complexity === 'elaborate') {
488
+ items.push('Missing entrance animations or transition effects — this is an elaborate design that expects rich motion.');
489
+ }
490
+ if (qualityTier === 'flagship') {
491
+ items.push('Any adaptive breakpoint missing — flagship quality requires all size classes implemented.');
492
+ items.push('Any must_handle state not implemented — flagship requires full contract compliance.');
493
+ }
494
+ const numbered = items.map((item, i) => `${i + 1}. ${item}`);
495
+ numbered.unshift('After generation, verify the output does NOT exhibit these AI-slop indicators:');
496
+ if (personality) {
497
+ numbered.push(`Design personality check: "${personality}" — verify the output tone matches.`);
498
+ }
499
+ return numbered.join('\n');
500
+ }
501
+ function buildDesignContext(manifest) {
502
+ const design = manifest.design;
503
+ if (!design)
504
+ return undefined;
505
+ const complexity = design.complexity ?? 'balanced';
506
+ const tier = design.quality_tier ?? 'production';
507
+ return {
508
+ ...(design.personality ? { personality: design.personality } : {}),
509
+ complexity,
510
+ quality_tier: tier,
511
+ ...(design.audience ? { audience: design.audience } : {}),
512
+ complexity_rule: complexityRule(complexity),
513
+ quality_tier_rule: qualityTierRule(tier),
514
+ quality_test: buildQualityTest(complexity, tier, design.personality),
515
+ };
516
+ }
517
+ function buildAntiPatterns(manifest, projectDir, target) {
518
+ // Universal anti-patterns from generation_guidance
519
+ const universal = {};
520
+ const universalRaw = manifest.generation_guidance?.universal_anti_patterns ?? {};
521
+ for (const [domain, items] of Object.entries(universalRaw)) {
522
+ if (Array.isArray(items)) {
523
+ const filtered = items.filter((item) => matchesTargetPlatform(item, target));
524
+ if (filtered.length > 0)
525
+ universal[domain] = filtered;
526
+ }
527
+ }
528
+ // Contract-specific must_avoid
529
+ const contract_specific = {};
530
+ const contractsDir = resolve(projectDir, manifest.includes?.contracts ?? './contracts/');
531
+ if (existsSync(contractsDir)) {
532
+ for (const file of readdirSync(contractsDir).filter((f) => f.endsWith('.yaml') && !f.startsWith('x_'))) {
533
+ try {
534
+ const content = YAML.parse(readFileSync(join(contractsDir, file), 'utf-8'));
535
+ const contractName = Object.keys(content)[0];
536
+ const mustAvoid = content[contractName]?.generation?.must_avoid ?? [];
537
+ if (mustAvoid.length > 0) {
538
+ const filtered = mustAvoid.filter((item) => matchesTargetPlatform(item, target));
539
+ if (filtered.length > 0)
540
+ contract_specific[contractName] = filtered;
541
+ }
542
+ }
543
+ catch {
544
+ continue;
545
+ }
546
+ }
547
+ }
548
+ // Project-specific avoid from design section
549
+ const project_specific = [];
550
+ const designAvoid = manifest.design?.avoid ?? [];
551
+ for (const item of designAvoid) {
552
+ if (matchesTargetPlatform(item, target))
553
+ project_specific.push(item);
554
+ }
555
+ const hasContent = Object.keys(universal).length > 0 || Object.keys(contract_specific).length > 0 || project_specific.length > 0;
556
+ if (!hasContent)
557
+ return undefined;
558
+ return { universal, contract_specific, project_specific };
559
+ }
560
+ function localizationConstraints(target, platformConfig) {
561
+ switch (target) {
562
+ case "ios":
563
+ return {
564
+ must_use_platform_native_i18n: true,
565
+ forbid_in_memory_string_maps: true,
566
+ runtime_resources: [
567
+ "Bundle-backed locale resources under Resources/<locale>.lproj/Localizable.strings",
568
+ ],
569
+ required_files: [
570
+ "Resources/en.lproj/Localizable.strings",
571
+ "Resources/<other-locale>.lproj/Localizable.strings",
572
+ ],
573
+ lookup_module_guidance: "Use SwiftUI/Foundation localization backed by bundle resources, not inline dictionaries or generated in-memory maps.",
574
+ notes: [
575
+ "Localized strings must resolve through the app bundle at runtime.",
576
+ "Do not hardcode locale maps inside views, models, or app support files.",
577
+ ],
578
+ };
579
+ case "android":
580
+ return {
581
+ must_use_platform_native_i18n: true,
582
+ forbid_in_memory_string_maps: true,
583
+ runtime_resources: [
584
+ "Android string resources under app/src/main/res/values/strings.xml and locale-specific values-*/strings.xml",
585
+ ],
586
+ required_files: [
587
+ "app/src/main/res/values/strings.xml",
588
+ "app/src/main/res/values-<locale>/strings.xml",
589
+ ],
590
+ lookup_module_guidance: "Use Android string resources with stringResource/getString lookups, not Kotlin in-memory maps or constants tables.",
591
+ notes: [
592
+ "Localized resources must be packaged under res/values* so they participate in Android resource resolution.",
593
+ "Do not leave locale content embedded inside composable files or support classes.",
594
+ ],
595
+ };
596
+ case "web": {
597
+ const fw = platformConfig?.framework;
598
+ if (fw && !isKnownWebFramework(fw)) {
599
+ return {
600
+ must_use_platform_native_i18n: true,
601
+ forbid_in_memory_string_maps: true,
602
+ runtime_resources: [
603
+ "File-backed locale resources such as src/locales/<locale>.json or framework-native message modules",
604
+ ],
605
+ required_files: [
606
+ "src/locales/<locale>.json or equivalent file-backed locale resources",
607
+ "A dedicated i18n module/provider wired through the selected web framework",
608
+ ],
609
+ lookup_module_guidance: "Use file-backed locale resources with a dedicated i18n module/provider, not inline maps in the root app shell, route modules, or screen/component files.",
610
+ notes: [
611
+ `The configured web framework (${fw}) is not a built-in preset, so this guidance stays framework-agnostic.`,
612
+ "Locale files must be imported from dedicated resource files, not defined inline in UI components.",
613
+ ],
614
+ };
615
+ }
616
+ if (fw === "vue") {
617
+ return {
618
+ must_use_platform_native_i18n: true,
619
+ forbid_in_memory_string_maps: true,
620
+ runtime_resources: [
621
+ "File-backed locale resources such as src/locales/<locale>.json loaded through vue-i18n",
622
+ ],
623
+ required_files: [
624
+ "src/locales/<locale>.json or equivalent file-backed locale resources",
625
+ "src/i18n.ts or equivalent vue-i18n setup module",
626
+ ],
627
+ lookup_module_guidance: "Use vue-i18n with file-backed locale resources, not inline string maps in App.vue or component files.",
628
+ notes: [
629
+ "Locale files must be imported from dedicated resource files, not defined inline in Vue components.",
630
+ "The generated web app should support locale fallback through vue-i18n rather than ad hoc object lookups.",
631
+ ],
632
+ };
633
+ }
634
+ if (fw === "svelte") {
635
+ return {
636
+ must_use_platform_native_i18n: true,
637
+ forbid_in_memory_string_maps: true,
638
+ runtime_resources: [
639
+ "File-backed locale resources such as src/lib/locales/<locale>.json or SvelteKit i18n modules",
640
+ ],
641
+ required_files: [
642
+ "src/lib/locales/<locale>.json or equivalent file-backed locale resources",
643
+ "src/lib/i18n.ts or equivalent dedicated i18n module",
644
+ ],
645
+ lookup_module_guidance: "Use file-backed locale resources with a dedicated i18n module, not inline string maps in +layout.svelte, +page.svelte, or component files.",
646
+ notes: [
647
+ "Locale files must be imported from dedicated resource files under src/lib/, not defined inline in Svelte components.",
648
+ "The generated web app should support locale fallback through the i18n module rather than ad hoc object lookups.",
649
+ ],
650
+ };
651
+ }
652
+ return {
653
+ must_use_platform_native_i18n: true,
654
+ forbid_in_memory_string_maps: true,
655
+ runtime_resources: [
656
+ "File-backed locale resources such as src/locales/<locale>.json or generated message modules",
657
+ ],
658
+ required_files: [
659
+ "src/locales/<locale>.json or equivalent file-backed locale resources",
660
+ "src/i18n.ts or equivalent dedicated i18n module",
661
+ ],
662
+ lookup_module_guidance: "Use file-backed locale resources with a dedicated i18n module/provider, not a giant in-memory map inside App.tsx or screen/component files.",
663
+ notes: [
664
+ "Locale files must be imported from dedicated resource files, not defined inline in UI components.",
665
+ "The generated web app should support locale fallback through the i18n module rather than ad hoc object lookups.",
666
+ ],
667
+ };
668
+ }
669
+ default:
670
+ return {
671
+ must_use_platform_native_i18n: true,
672
+ forbid_in_memory_string_maps: true,
673
+ runtime_resources: ["Use target-native runtime localization resources."],
674
+ required_files: ["Create file-backed locale resources for each supported locale."],
675
+ lookup_module_guidance: "Use the target's native localization system instead of inline string maps.",
676
+ notes: [],
677
+ };
678
+ }
679
+ }
680
+ function fileStructureConstraints(target, platformConfig) {
681
+ switch (target) {
682
+ case "ios":
683
+ return {
684
+ forbid_single_file_output: true,
685
+ required_directories: [
686
+ "Sources/<Project>/App",
687
+ "Sources/<Project>/Screens",
688
+ "Sources/<Project>/Components",
689
+ "Sources/<Project>/Models",
690
+ "Sources/<Project>/Support",
691
+ "Resources",
692
+ ],
693
+ screen_split_rule: "Generate one primary screen/view per file under Sources/<Project>/Screens.",
694
+ component_split_rule: "Place reusable UI primitives and shared subviews under Sources/<Project>/Components instead of keeping them in a monolithic screen file.",
695
+ notes: [
696
+ "The app entry file may stay separate, but it must not contain the full app implementation.",
697
+ "Models, support logic, and screens should live in separate files and folders.",
698
+ ],
699
+ };
700
+ case "android":
701
+ return {
702
+ forbid_single_file_output: true,
703
+ required_directories: [
704
+ "app/src/main/java/<package>/ui/screens",
705
+ "app/src/main/java/<package>/ui/components",
706
+ "app/src/main/java/<package>/model",
707
+ "app/src/main/java/<package>/support",
708
+ "app/src/main/res",
709
+ ],
710
+ screen_split_rule: "Generate one primary screen composable file per screen under ui/screens.",
711
+ component_split_rule: "Put reusable composables under ui/components and keep models/support logic out of screen files.",
712
+ notes: [
713
+ "Do not place every screen, component, and model into a single Kotlin source file.",
714
+ "Resource XML, models, and UI code should remain separated.",
715
+ ],
716
+ };
717
+ case "web": {
718
+ const fw = platformConfig?.framework;
719
+ if (fw && !isKnownWebFramework(fw)) {
720
+ return {
721
+ forbid_single_file_output: true,
722
+ required_directories: [
723
+ "src/routes or framework-appropriate screen modules",
724
+ "src/components",
725
+ "src/locales or generated messages",
726
+ "src/state, src/store, or framework-specific state modules",
727
+ "src",
728
+ ],
729
+ screen_split_rule: "Generate one primary route/screen module per screen in the framework-appropriate location rather than embedding the entire app in a single root file.",
730
+ component_split_rule: "Put reusable components in dedicated component modules and keep state/i18n/helpers in separate support files.",
731
+ notes: [
732
+ `The configured web framework (${fw}) is not a built-in preset, so file layout guidance is framework-agnostic.`,
733
+ "The root app shell may compose providers and routing, but it must not contain the entire generated application.",
734
+ ],
735
+ };
736
+ }
737
+ if (fw === "vue") {
738
+ return {
739
+ forbid_single_file_output: true,
740
+ required_directories: [
741
+ "src/views or src/pages",
742
+ "src/components",
743
+ "src/locales",
744
+ "src/stores",
745
+ "src",
746
+ ],
747
+ screen_split_rule: "Generate one Vue SFC per screen under src/views rather than embedding all screens in App.vue.",
748
+ component_split_rule: "Put reusable components under src/components and keep stores/i18n helpers in separate modules.",
749
+ notes: [
750
+ "App.vue may compose the app shell with router-view, but it must not contain the entire generated application.",
751
+ "Pinia stores, composables, and locale resources should live outside view/component files.",
752
+ ],
753
+ };
754
+ }
755
+ if (fw === "svelte") {
756
+ return {
757
+ forbid_single_file_output: true,
758
+ required_directories: [
759
+ "src/routes",
760
+ "src/lib/components",
761
+ "src/lib/locales",
762
+ "src/lib/stores",
763
+ "src/lib",
764
+ ],
765
+ screen_split_rule: "Generate one +page.svelte per screen under src/routes following SvelteKit file-based routing.",
766
+ component_split_rule: "Put reusable components under src/lib/components and keep stores/i18n helpers in src/lib/.",
767
+ notes: [
768
+ "+layout.svelte may compose the app shell, but it must not contain the entire generated application.",
769
+ "Svelte stores, helpers, and locale resources should live under src/lib/ outside route files.",
770
+ ],
771
+ };
772
+ }
773
+ return {
774
+ forbid_single_file_output: true,
775
+ required_directories: [
776
+ "src/screens",
777
+ "src/components",
778
+ "src/locales or src/generated",
779
+ "src/state or src/store",
780
+ "src",
781
+ ],
782
+ screen_split_rule: "Generate one screen module per screen under src/screens rather than embedding all screens in App.tsx.",
783
+ component_split_rule: "Put reusable components under src/components and keep state/i18n helpers in separate modules.",
784
+ notes: [
785
+ "App.tsx may compose the app shell, but it must not contain the entire generated application.",
786
+ "Shared state, helpers, and generated resources should live outside screen/component files.",
787
+ ],
788
+ };
789
+ }
790
+ default:
791
+ return {
792
+ forbid_single_file_output: true,
793
+ required_directories: ["Create separate directories for screens, components, resources, and support code."],
794
+ screen_split_rule: "Generate one screen per file.",
795
+ component_split_rule: "Keep reusable components separate from screens.",
796
+ notes: [],
797
+ };
798
+ }
799
+ }
800
+ function generationConstraints(target, platformConfig) {
801
+ return {
802
+ localization: localizationConstraints(target, platformConfig),
803
+ file_structure: fileStructureConstraints(target, platformConfig),
804
+ platform_setup: {
805
+ refresh_target_platform_knowledge: true,
806
+ notes: [
807
+ `Refresh current ${target} platform/framework setup guidance before generation instead of relying on stale memory.`,
808
+ "Check current project scaffolding, resource wiring, navigation APIs, packaging rules, and other toolchain-sensitive conventions for this target.",
809
+ ],
810
+ },
811
+ };
812
+ }
813
+ const PRESENTATION_ONLY_KEYS = new Set(["naming", "bundler", "css"]);
814
+ function requiredPlatformDecisionKeys(target) {
815
+ return platformStackKeys(target).filter((key) => !PRESENTATION_ONLY_KEYS.has(key));
816
+ }
817
+ function missingPlatformDecisions(target, platformDef) {
818
+ const generation = platformDef.generation ?? {};
819
+ return requiredPlatformDecisionKeys(target).filter((key) => {
820
+ const value = generation[key];
821
+ return typeof value !== "string" || value.trim().length === 0;
822
+ });
823
+ }
824
+ function referenceExamples() {
825
+ const packageRoot = resolvePackageRoot();
826
+ const candidates = [
827
+ join(packageRoot, "README.md"),
828
+ join(packageRoot, "spec", "openuispec-v0.2.md"),
829
+ join(packageRoot, "examples", "taskflow", "openuispec"),
830
+ join(packageRoot, "schema"),
831
+ ];
832
+ return candidates.filter((candidate) => existsSync(candidate));
833
+ }
834
+ function generationWarnings(target, platformConfig) {
835
+ const warnings = [];
836
+ if (platformConfig.stack_confirmation.requires_user_confirmation) {
837
+ warnings.push(`The configured ${target} stack was applied from defaults and still requires explicit user confirmation before implementation.`);
838
+ }
839
+ for (const [key, value] of Object.entries(platformConfig.stack)) {
840
+ if (!PRESENTATION_ONLY_KEYS.has(key) && !platformConfig.selected_option_refs[key]) {
841
+ warnings.push(`The configured ${target} ${key} value "${value}" is custom or not covered by the preset catalog, so automatic dependency guidance is unavailable for that choice.`);
842
+ }
843
+ }
844
+ if (target === "web" && platformConfig.framework && !isKnownWebFramework(platformConfig.framework)) {
845
+ warnings.push(`The configured web framework "${platformConfig.framework}" is custom, so bootstrap generation constraints are framework-agnostic instead of React-specific.`);
846
+ }
847
+ return warnings;
848
+ }
849
+ function readAllSpecContents(projectDir) {
850
+ return discoverSpecFiles(projectDir).map((filePath) => {
851
+ const relPath = relative(projectDir, filePath);
852
+ return {
853
+ path: relPath,
854
+ category: categorizeSpecFile(relPath),
855
+ content: readFileSync(filePath, "utf-8"),
856
+ };
857
+ });
858
+ }
859
+ function bootstrapSpecFiles(projectDir, target) {
860
+ return discoverSpecFiles(projectDir)
861
+ .map((filePath) => {
862
+ const relPath = relative(projectDir, filePath);
863
+ const specStatus = hasStatusSemantics(relPath) ? readStatus(filePath) : null;
864
+ const category = categorizeSpecFile(relPath);
865
+ return {
866
+ spec_file: relPath,
867
+ category,
868
+ spec_status: specStatus,
869
+ notes: buildBootstrapNotes(category, target, specStatus),
870
+ };
871
+ })
872
+ .sort((a, b) => a.category.localeCompare(b.category) || a.spec_file.localeCompare(b.spec_file));
873
+ }
874
+ function explanationItems(explanation, outputDir, codeRoots, target) {
875
+ if (!explanation?.available)
876
+ return [];
877
+ const searchCandidates = collectSearchCandidates(outputDir, codeRoots);
878
+ return explanation.files.map((file) => {
879
+ const compact = compactPrepareSemanticChanges(file.changes);
880
+ const notes = buildCategoryNotes(categorizeSpecFile(file.file), target);
881
+ if (file.truncated || compact.truncated) {
882
+ notes.push("Semantic diff truncated for prepare output; use `openuispec drift --target " + target + " --explain` for the full file-level diff.");
883
+ }
884
+ return {
885
+ spec_file: file.file,
886
+ category: categorizeSpecFile(file.file),
887
+ status: file.status,
888
+ semantic_changes: compact.changes,
889
+ likely_files: searchLikelyFiles(searchCandidates, file),
890
+ notes,
891
+ };
892
+ });
893
+ }
894
+ function printReport(result) {
895
+ console.log("OpenUISpec Prepare");
896
+ console.log("==================");
897
+ console.log(`Project: ${result.project}`);
898
+ console.log(`Target: ${result.target}`);
899
+ console.log(`Mode: ${result.mode}`);
900
+ console.log(`Output: ${result.output_dir}`);
901
+ if (result.backend_root) {
902
+ console.log(`Backend: ${result.backend_root}`);
903
+ }
904
+ const platformLabel = [result.platform_config.language, result.platform_config.framework]
905
+ .filter(Boolean)
906
+ .join(" / ");
907
+ if (platformLabel) {
908
+ console.log(`Platform: ${platformLabel}`);
909
+ }
910
+ if (result.baseline.commit) {
911
+ const shortCommit = result.baseline.commit.slice(0, 12);
912
+ const branch = result.baseline.branch ? ` on ${result.baseline.branch}` : "";
913
+ console.log(`Baseline: ${shortCommit}${branch} (${result.baseline.kind ?? "unknown"})`);
914
+ }
915
+ console.log(`Summary: ${result.summary.changed} changed, ${result.summary.added} added, ${result.summary.removed} removed`);
916
+ if (Object.keys(result.platform_config.stack).length > 0 || result.platform_config.dependencies.length > 0) {
917
+ console.log("\nPlatform Stack");
918
+ for (const [key, value] of Object.entries(result.platform_config.stack)) {
919
+ console.log(` ${key}: ${value}`);
920
+ }
921
+ if (Object.keys(result.platform_config.selected_option_refs).length > 0) {
922
+ console.log(" selected option refs:");
923
+ for (const [key, refs] of Object.entries(result.platform_config.selected_option_refs)) {
924
+ console.log(` - ${key}: ${refs.value}`);
925
+ }
926
+ }
927
+ if (result.platform_config.stack_confirmation.status) {
928
+ console.log(` stack confirmation: ${result.platform_config.stack_confirmation.status}`);
929
+ }
930
+ if (result.platform_config.dependency_guidance.notes.length > 0) {
931
+ console.log(" dependency guidance:");
932
+ for (const note of result.platform_config.dependency_guidance.notes) {
933
+ console.log(` - ${note}`);
934
+ }
935
+ }
936
+ if (result.platform_config.dependencies.length > 0) {
937
+ console.log(" dependencies:");
938
+ for (const dependency of result.platform_config.dependencies) {
939
+ console.log(` - ${dependency}`);
940
+ }
941
+ }
942
+ }
943
+ if (result.mode === "bootstrap" && result.bootstrap) {
944
+ console.log("\nBootstrap Bundle");
945
+ console.log(` output exists: ${result.bootstrap.output_exists ? "yes" : "no"}`);
946
+ console.log(` generation ready: ${result.bootstrap.generation_ready ? "yes" : "no"}`);
947
+ console.log(` pending user confirmation: ${result.bootstrap.pending_user_confirmation ? "yes" : "no"}`);
948
+ if (result.bootstrap.missing_platform_decisions.length > 0) {
949
+ console.log(" missing platform decisions:");
950
+ for (const key of result.bootstrap.missing_platform_decisions) {
951
+ console.log(` - ${key}`);
952
+ }
953
+ }
954
+ if (result.bootstrap.target_stack_options) {
955
+ console.log(" target stack options:");
956
+ console.log(` - interactive: ${result.bootstrap.target_stack_options.interactive_command}`);
957
+ console.log(` - non-interactive schema: openuispec configure-target ${result.target} --list-options`);
958
+ }
959
+ if (result.bootstrap.generation_warnings.length > 0) {
960
+ console.log(" generation warnings:");
961
+ for (const warning of result.bootstrap.generation_warnings) {
962
+ console.log(` - ${warning}`);
963
+ }
964
+ }
965
+ if (result.code_roots.length > 0) {
966
+ console.log(" code roots:");
967
+ for (const root of result.code_roots) {
968
+ console.log(` - ${root}`);
969
+ }
970
+ }
971
+ console.log(" spec files:");
972
+ for (const file of result.bootstrap.spec_files) {
973
+ const statusLabel = file.spec_status ? ` [${file.spec_status}]` : "";
974
+ console.log(` - ${file.spec_file} (${file.category})${statusLabel}`);
975
+ }
976
+ console.log(" generation rules:");
977
+ for (const rule of result.bootstrap.generation_rules) {
978
+ console.log(` - ${rule}`);
979
+ }
980
+ console.log(" generation constraints:");
981
+ console.log(` - localization: native i18n required = ${result.bootstrap.generation_constraints.localization.must_use_platform_native_i18n ? "yes" : "no"}`);
982
+ console.log(` - localization: forbid in-memory maps = ${result.bootstrap.generation_constraints.localization.forbid_in_memory_string_maps ? "yes" : "no"}`);
983
+ console.log(` - file structure: forbid single-file output = ${result.bootstrap.generation_constraints.file_structure.forbid_single_file_output ? "yes" : "no"}`);
984
+ console.log(` - platform setup: refresh target knowledge = ${result.bootstrap.generation_constraints.platform_setup.refresh_target_platform_knowledge ? "yes" : "no"}`);
985
+ console.log(" references:");
986
+ for (const ref of result.bootstrap.reference_examples) {
987
+ console.log(` - ${ref}`);
988
+ }
989
+ }
990
+ else if (!result.changes_available) {
991
+ console.log(`\n${result.explanation_note ?? "No semantic changes available."}`);
992
+ }
993
+ else if (result.items.length === 0) {
994
+ console.log("\nNo target updates are currently required from spec drift.");
995
+ }
996
+ else {
997
+ console.log("\nCode Roots");
998
+ for (const root of result.code_roots) {
999
+ console.log(` - ${root}`);
1000
+ }
1001
+ console.log("\nWork Items");
1002
+ for (const item of result.items) {
1003
+ console.log(`\n${item.spec_file}`);
1004
+ for (const change of item.semantic_changes) {
1005
+ const pathLabel = change.path || "(root)";
1006
+ if (change.kind === "added") {
1007
+ console.log(` + ${pathLabel}${change.after ? ` = ${change.after}` : ""}`);
1008
+ }
1009
+ else if (change.kind === "removed") {
1010
+ console.log(` - ${pathLabel}${change.before ? ` (was ${change.before})` : ""}`);
1011
+ }
1012
+ else {
1013
+ console.log(` ~ ${pathLabel}: ${change.before ?? "?"} -> ${change.after ?? "?"}`);
1014
+ }
1015
+ }
1016
+ if (item.likely_files.length > 0) {
1017
+ console.log(" likely target files:");
1018
+ for (const file of item.likely_files) {
1019
+ console.log(` - ${file}`);
1020
+ }
1021
+ }
1022
+ for (const note of item.notes) {
1023
+ console.log(` note: ${note}`);
1024
+ }
1025
+ }
1026
+ }
1027
+ console.log("\nNext Steps");
1028
+ for (const step of result.next_steps) {
1029
+ console.log(` - ${step}`);
1030
+ }
1031
+ }
1032
+ function buildBootstrapPrepareResult(cwd, target, includeContents = false) {
1033
+ const projectDir = findProjectDir(cwd);
1034
+ const projectName = readProjectName(projectDir);
1035
+ const outputDir = resolveOutputDir(projectDir, projectName, target);
1036
+ const manifest = readManifest(projectDir);
1037
+ const platformDef = readPlatformDefinition(projectDir, manifest, target);
1038
+ const platformConfig = buildPlatformConfig(target, platformDef);
1039
+ const outputFormat = manifest.generation?.output_format?.[target] ?? {};
1040
+ const sharedLayerConfigs = sharedLayersForTarget(projectDir, target);
1041
+ const codeRoots = suggestCodeRoots(target, outputDir, projectDir, sharedLayerConfigs);
1042
+ const missingDecisions = missingPlatformDecisions(target, platformDef);
1043
+ const sharedLayerInfos = buildSharedLayerInfos(projectDir, target, sharedLayerConfigs);
1044
+ const backendRoot = resolveBackendRoot(projectDir, manifest);
1045
+ const backendContextReady = true; // backend is optional — not a generation blocker
1046
+ const pendingUserConfirmation = platformConfig.stack_confirmation.requires_user_confirmation;
1047
+ const nextSteps = [
1048
+ ...(hasApiEndpoints(manifest) && (backendRoot === null || !existsSync(backendRoot))
1049
+ ? [
1050
+ "Optional: set `generation.code_roots.backend` in openuispec.yaml to help AI locate your backend code.",
1051
+ ]
1052
+ : []),
1053
+ ...(pendingUserConfirmation
1054
+ ? [
1055
+ `Run \`openuispec configure-target ${target}\` without \`--defaults\` and confirm the stack choices before implementation.`,
1056
+ ]
1057
+ : []),
1058
+ ...(missingDecisions.length > 0
1059
+ ? [
1060
+ `Run \`openuispec configure-target ${target}\` to choose the missing ${target} stack defaults before generation.`,
1061
+ ]
1062
+ : []),
1063
+ `Read the manifest and referenced ${target} spec files from this bundle before generating target code.`,
1064
+ ...(missingDecisions.length === 0
1065
+ ? [
1066
+ `Generate the initial ${target} implementation in ${outputDir}.`,
1067
+ "Build or run the generated target and review screens, flows, and localization wiring.",
1068
+ `After the first accepted ${target} output exists, run \`openuispec drift --snapshot --target ${target}\` to baseline it.`,
1069
+ ]
1070
+ : []),
1071
+ ];
1072
+ if (outputFormat.framework || outputFormat.language) {
1073
+ nextSteps.unshift(`Target mapping context: ${outputFormat.language ?? "unknown language"} / ${outputFormat.framework ?? "unknown framework"}.`);
1074
+ }
1075
+ const outputDirExists = existsSync(outputDir);
1076
+ const snapshotPath = join(outputDir, ".openuispec-state.json");
1077
+ const snapshotFileExists = existsSync(snapshotPath);
1078
+ const antiPatterns = buildAntiPatterns(manifest, projectDir, target);
1079
+ const designContext = buildDesignContext(manifest);
1080
+ return {
1081
+ mode: "bootstrap",
1082
+ project: projectName,
1083
+ target,
1084
+ output_dir: outputDir,
1085
+ backend_root: backendRoot,
1086
+ platform_config: platformConfig,
1087
+ code_roots: codeRoots,
1088
+ baseline: {
1089
+ kind: null,
1090
+ commit: null,
1091
+ branch: null,
1092
+ },
1093
+ baseline_status: {
1094
+ output_exists: outputDirExists,
1095
+ snapshot_exists: snapshotFileExists,
1096
+ action_needed: outputDirExists && !snapshotFileExists
1097
+ ? `Baseline pending — when satisfied with the generated output, run: openuispec drift --snapshot --target ${target}`
1098
+ : null,
1099
+ },
1100
+ summary: {
1101
+ changed: 0,
1102
+ added: 0,
1103
+ removed: 0,
1104
+ },
1105
+ changes_available: false,
1106
+ explanation_note: "No snapshot exists yet. This is a first-time generation bundle.",
1107
+ items: [],
1108
+ ...(sharedLayerInfos.length > 0 ? { shared_layers: sharedLayerInfos } : {}),
1109
+ ...(includeContents ? { spec_contents: readAllSpecContents(projectDir) } : {}),
1110
+ ...(antiPatterns ? { anti_patterns: antiPatterns } : {}),
1111
+ ...(designContext ? { design_context: designContext } : {}),
1112
+ bootstrap: {
1113
+ output_exists: existsSync(outputDir),
1114
+ generation_ready: missingDecisions.length === 0 && backendContextReady && !pendingUserConfirmation,
1115
+ missing_platform_decisions: missingDecisions,
1116
+ pending_user_confirmation: pendingUserConfirmation,
1117
+ includes: manifest.includes ?? {},
1118
+ target_stack_options: (missingDecisions.length > 0 || pendingUserConfirmation) && isSupportedTarget(target)
1119
+ ? listTargetWizardOptions(target)
1120
+ : null,
1121
+ output_format: outputFormat,
1122
+ i18n: {
1123
+ default_locale: manifest.i18n?.default_locale ?? null,
1124
+ supported_locales: manifest.i18n?.supported_locales ?? [],
1125
+ },
1126
+ spec_files: bootstrapSpecFiles(projectDir, target),
1127
+ generation_rules: generationRules(target, outputDir, manifest, sharedLayerInfos),
1128
+ generation_constraints: generationConstraints(target, platformConfig),
1129
+ generation_warnings: generationWarnings(target, platformConfig),
1130
+ reference_examples: referenceExamples(),
1131
+ },
1132
+ next_steps: nextSteps,
1133
+ };
1134
+ }
1135
+ function buildUpdatePrepareResult(cwd, target, includeContents = false) {
1136
+ const { projectDir, projectName, result } = loadTargetDrift(cwd, target, false, true);
1137
+ const outputDir = resolveOutputDir(projectDir, projectName, target);
1138
+ const sharedLayerConfigs = sharedLayersForTarget(projectDir, target);
1139
+ const codeRoots = suggestCodeRoots(target, outputDir, projectDir, sharedLayerConfigs);
1140
+ const manifest = readManifest(projectDir);
1141
+ const antiPatterns = buildAntiPatterns(manifest, projectDir, target);
1142
+ const designContext = buildDesignContext(manifest);
1143
+ const sharedLayerInfos = buildSharedLayerInfos(projectDir, target, sharedLayerConfigs);
1144
+ const platformDef = readPlatformDefinition(projectDir, manifest, target);
1145
+ const platformConfig = buildPlatformConfig(target, platformDef);
1146
+ const outputFormat = manifest.generation?.output_format?.[target] ?? {};
1147
+ const backendRoot = resolveBackendRoot(projectDir, manifest);
1148
+ const items = explanationItems(result.explanation, outputDir, codeRoots, target);
1149
+ const nextSteps = [
1150
+ `Update the ${target} implementation in ${outputDir} to match the semantic changes above.`,
1151
+ "Build or run the target and review the affected screens/flows.",
1152
+ `After the UI is updated, run \`openuispec drift --snapshot --target ${target}\` to accept the new baseline.`,
1153
+ `Run \`openuispec drift --target ${target} --explain\` again to confirm no spec changes remain for this target.`,
1154
+ ];
1155
+ if (outputFormat.framework || outputFormat.language) {
1156
+ nextSteps.unshift(`Target mapping context: ${outputFormat.language ?? "unknown language"} / ${outputFormat.framework ?? "unknown framework"}.`);
1157
+ }
1158
+ return {
1159
+ mode: "update",
1160
+ project: projectName,
1161
+ target,
1162
+ output_dir: outputDir,
1163
+ backend_root: backendRoot,
1164
+ platform_config: platformConfig,
1165
+ code_roots: codeRoots,
1166
+ baseline: {
1167
+ kind: result.state.baseline?.kind ?? null,
1168
+ commit: result.state.baseline?.commit ?? null,
1169
+ branch: result.state.baseline?.branch ?? null,
1170
+ },
1171
+ summary: {
1172
+ changed: result.drift.changed.length,
1173
+ added: result.drift.added.length,
1174
+ removed: result.drift.removed.length,
1175
+ },
1176
+ changes_available: result.explanation?.available ?? false,
1177
+ explanation_note: result.explanation?.note,
1178
+ items,
1179
+ ...(sharedLayerInfos.length > 0 ? { shared_layers: sharedLayerInfos } : {}),
1180
+ ...(includeContents ? { spec_contents: readAllSpecContents(projectDir) } : {}),
1181
+ ...(antiPatterns ? { anti_patterns: antiPatterns } : {}),
1182
+ ...(designContext ? { design_context: designContext } : {}),
1183
+ next_steps: nextSteps,
1184
+ };
1185
+ }
1186
+ export function buildPrepareResult(target, cwd = process.cwd(), includeContents = false) {
1187
+ const projectDir = findProjectDir(cwd);
1188
+ const projectName = readProjectName(projectDir);
1189
+ const outputDir = resolveOutputDir(projectDir, projectName, target);
1190
+ const statePath = join(outputDir, ".openuispec-state.json");
1191
+ if (!existsSync(statePath)) {
1192
+ return buildBootstrapPrepareResult(cwd, target, includeContents);
1193
+ }
1194
+ return buildUpdatePrepareResult(cwd, target, includeContents);
1195
+ }
1196
+ export function runPrepare(argv) {
1197
+ const isJson = argv.includes("--json");
1198
+ const targetIdx = argv.indexOf("--target");
1199
+ const target = targetIdx !== -1 && argv[targetIdx + 1] ? argv[targetIdx + 1] : null;
1200
+ if (!target) {
1201
+ console.error("Error: --target is required for prepare");
1202
+ console.error("Usage: openuispec prepare --target <target> [--json]");
1203
+ process.exit(1);
1204
+ }
1205
+ const result = buildPrepareResult(target);
1206
+ if (isJson) {
1207
+ console.log(JSON.stringify(result, null, 2));
1208
+ return;
1209
+ }
1210
+ printReport(result);
1211
+ }
1212
+ const isDirectRun = process.argv[1]?.endsWith("prepare/index.ts") ||
1213
+ process.argv[1]?.endsWith("prepare/index.js");
1214
+ if (isDirectRun) {
1215
+ runPrepare(process.argv.slice(2));
1216
+ }