openuispec 0.1.28 → 0.1.29

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/prepare/index.ts CHANGED
@@ -8,10 +8,17 @@
8
8
  */
9
9
 
10
10
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
11
- import { basename, extname, join, relative, resolve } from "node:path";
11
+ import { basename, dirname, extname, join, relative, resolve } from "node:path";
12
+ import { fileURLToPath } from "node:url";
12
13
  import YAML from "yaml";
13
14
  import {
15
+ discoverSpecFiles,
16
+ findProjectDir,
17
+ hasStatusSemantics,
14
18
  loadTargetDrift,
19
+ readManifest,
20
+ readProjectName,
21
+ readStatus,
15
22
  resolveOutputDir,
16
23
  type FileExplanation,
17
24
  type ExplainResult,
@@ -27,10 +34,87 @@ interface PrepareItem {
27
34
  notes: string[];
28
35
  }
29
36
 
37
+ interface BootstrapSpecFile {
38
+ spec_file: string;
39
+ category: string;
40
+ spec_status: string | null;
41
+ notes: string[];
42
+ }
43
+
44
+ interface PrepareLocalizationConstraints {
45
+ must_use_platform_native_i18n: boolean;
46
+ forbid_in_memory_string_maps: boolean;
47
+ runtime_resources: string[];
48
+ required_files: string[];
49
+ lookup_module_guidance: string;
50
+ notes: string[];
51
+ }
52
+
53
+ interface PrepareFileStructureConstraints {
54
+ forbid_single_file_output: boolean;
55
+ required_directories: string[];
56
+ screen_split_rule: string;
57
+ component_split_rule: string;
58
+ notes: string[];
59
+ }
60
+
61
+ interface PreparePlatformSetupConstraints {
62
+ refresh_target_platform_knowledge: boolean;
63
+ notes: string[];
64
+ }
65
+
66
+ interface PrepareGenerationConstraints {
67
+ localization: PrepareLocalizationConstraints;
68
+ file_structure: PrepareFileStructureConstraints;
69
+ platform_setup: PreparePlatformSetupConstraints;
70
+ }
71
+
72
+ interface PreparePlatformConfig {
73
+ framework: string | null;
74
+ language: string | null;
75
+ min_version: string | null;
76
+ min_sdk: number | null;
77
+ target_sdk: number | null;
78
+ generation: Record<string, any>;
79
+ stack: Record<string, string>;
80
+ dependency_guidance: {
81
+ anchor_refs_only: boolean;
82
+ notes: string[];
83
+ };
84
+ selected_option_refs: Record<string, {
85
+ value: string;
86
+ plugins: string[];
87
+ libraries: string[];
88
+ packages: string[];
89
+ docs: string[];
90
+ }>;
91
+ dependencies: string[];
92
+ }
93
+
94
+ interface PrepareBootstrapBundle {
95
+ output_exists: boolean;
96
+ generation_ready: boolean;
97
+ missing_platform_decisions: string[];
98
+ generation_warnings: string[];
99
+ includes: Record<string, string>;
100
+ output_format: Record<string, any>;
101
+ i18n: {
102
+ default_locale: string | null;
103
+ supported_locales: string[];
104
+ };
105
+ spec_files: BootstrapSpecFile[];
106
+ generation_rules: string[];
107
+ generation_constraints: PrepareGenerationConstraints;
108
+ reference_examples: string[];
109
+ }
110
+
30
111
  interface PrepareResult {
112
+ mode: "bootstrap" | "update";
31
113
  project: string;
32
114
  target: string;
33
115
  output_dir: string;
116
+ backend_root: string | null;
117
+ platform_config: PreparePlatformConfig;
34
118
  code_roots: string[];
35
119
  baseline: {
36
120
  kind: string | null;
@@ -45,11 +129,168 @@ interface PrepareResult {
45
129
  changes_available: boolean;
46
130
  explanation_note?: string;
47
131
  items: PrepareItem[];
132
+ bootstrap?: PrepareBootstrapBundle;
48
133
  next_steps: string[];
49
134
  }
50
135
 
51
- function readManifest(projectDir: string): Record<string, any> {
52
- return YAML.parse(readFileSync(join(projectDir, "openuispec.yaml"), "utf-8"));
136
+ function resolvePackageRoot(): string {
137
+ return resolve(dirname(fileURLToPath(import.meta.url)), "..");
138
+ }
139
+
140
+ function readPlatformDefinition(projectDir: string, manifest: Record<string, any>, target: string): Record<string, any> {
141
+ const platformDir = resolve(projectDir, manifest.includes?.platform ?? "./platform/");
142
+ const platformPath = join(platformDir, `${target}.yaml`);
143
+ if (!existsSync(platformPath)) return {};
144
+ try {
145
+ const doc = YAML.parse(readFileSync(platformPath, "utf-8"));
146
+ return doc?.[target] ?? {};
147
+ } catch {
148
+ return {};
149
+ }
150
+ }
151
+
152
+ function readTargetPresetDependencyLinks(target: string): {
153
+ questions: Array<{
154
+ key: string;
155
+ options: Array<{
156
+ value: string;
157
+ generation_value?: string;
158
+ refs?: {
159
+ plugins?: string[];
160
+ libraries?: string[];
161
+ packages?: string[];
162
+ docs?: string[];
163
+ };
164
+ }>;
165
+ }>;
166
+ } {
167
+ try {
168
+ const presetPath = join(resolvePackageRoot(), "cli", "target-presets.json");
169
+ const presets = JSON.parse(readFileSync(presetPath, "utf-8")) as Record<
170
+ string,
171
+ {
172
+ questions?: Array<{
173
+ key: string;
174
+ options?: Array<{
175
+ value: string;
176
+ generation_value?: string;
177
+ refs?: {
178
+ plugins?: string[];
179
+ libraries?: string[];
180
+ packages?: string[];
181
+ docs?: string[];
182
+ };
183
+ }>;
184
+ }>;
185
+ }
186
+ >;
187
+ return {
188
+ questions: (presets[target]?.questions ?? []).map((question) => ({
189
+ key: question.key,
190
+ options: question.options ?? [],
191
+ })),
192
+ };
193
+ } catch {
194
+ return { questions: [] };
195
+ }
196
+ }
197
+
198
+ function platformStackKeys(target: string): string[] {
199
+ switch (target) {
200
+ case "android":
201
+ return ["architecture", "state", "preferences", "database", "di", "naming"];
202
+ case "web":
203
+ return ["runtime", "routing", "state", "storage_backend", "bundler", "css", "naming"];
204
+ case "ios":
205
+ return ["architecture", "persistence", "di", "naming"];
206
+ default:
207
+ return [];
208
+ }
209
+ }
210
+
211
+ function buildPlatformConfig(target: string, platformDef: Record<string, any>): PreparePlatformConfig {
212
+ const generation =
213
+ platformDef.generation && typeof platformDef.generation === "object" ? platformDef.generation : {};
214
+ const links = readTargetPresetDependencyLinks(target);
215
+ const stack = Object.fromEntries(
216
+ platformStackKeys(target)
217
+ .map((key) => [key, generation[key]])
218
+ .filter((entry): entry is [string, string] => typeof entry[1] === "string" && entry[1].trim().length > 0)
219
+ );
220
+ const dependencies = Array.isArray(generation.dependencies)
221
+ ? generation.dependencies.filter((dep): dep is string => typeof dep === "string")
222
+ : [];
223
+ const selectedOptionRefs = Object.fromEntries(
224
+ links.questions
225
+ .map((question) => {
226
+ const generationValue = generation[question.key];
227
+ if (typeof generationValue !== "string" || generationValue.trim().length === 0) {
228
+ return null;
229
+ }
230
+ const selected = question.options.find(
231
+ (option) => option.value === generationValue || option.generation_value === generationValue
232
+ );
233
+ if (!selected?.refs) {
234
+ return null;
235
+ }
236
+ return [
237
+ question.key,
238
+ {
239
+ value: selected.value,
240
+ plugins: selected.refs.plugins ?? [],
241
+ libraries: selected.refs.libraries ?? [],
242
+ packages: selected.refs.packages ?? [],
243
+ docs: selected.refs.docs ?? [],
244
+ },
245
+ ] as const;
246
+ })
247
+ .filter((entry): entry is readonly [string, {
248
+ value: string;
249
+ plugins: string[];
250
+ libraries: string[];
251
+ packages: string[];
252
+ docs: string[];
253
+ }] => entry !== null)
254
+ );
255
+
256
+ return {
257
+ framework: typeof platformDef.framework === "string" ? platformDef.framework : null,
258
+ language: typeof platformDef.language === "string" ? platformDef.language : null,
259
+ min_version: typeof platformDef.min_version === "string" ? platformDef.min_version : null,
260
+ min_sdk: typeof platformDef.min_sdk === "number" ? platformDef.min_sdk : null,
261
+ target_sdk: typeof platformDef.target_sdk === "number" ? platformDef.target_sdk : null,
262
+ generation,
263
+ stack,
264
+ dependency_guidance: {
265
+ anchor_refs_only: true,
266
+ notes: [
267
+ "Selected option refs are anchor dependencies and setup references, not a complete dependency manifest.",
268
+ "Add any supporting runtime, build, plugin, repository, annotation-processing, and dev/test dependencies required by the current platform/framework setup.",
269
+ "Resolve exact versions, compatibility, and project wiring from current platform documentation instead of relying on stale memory.",
270
+ ],
271
+ },
272
+ selected_option_refs: selectedOptionRefs,
273
+ dependencies,
274
+ };
275
+ }
276
+
277
+ function hasApiEndpoints(manifest: Record<string, any>): boolean {
278
+ const endpoints = manifest.api?.endpoints;
279
+ return typeof endpoints === "object" && endpoints !== null && Object.keys(endpoints).length > 0;
280
+ }
281
+
282
+ const KNOWN_WEB_FRAMEWORKS = new Set(["react", "vue", "svelte"]);
283
+
284
+ function isKnownWebFramework(framework: string | null): boolean {
285
+ return framework !== null && KNOWN_WEB_FRAMEWORKS.has(framework);
286
+ }
287
+
288
+ function resolveBackendRoot(projectDir: string, manifest: Record<string, any>): string | null {
289
+ const backendRoot = manifest.generation?.code_roots?.backend;
290
+ if (typeof backendRoot !== "string" || backendRoot.trim().length === 0) {
291
+ return null;
292
+ }
293
+ return resolve(projectDir, backendRoot);
53
294
  }
54
295
 
55
296
  function categorizeSpecFile(relPath: string): string {
@@ -119,27 +360,16 @@ function walkFiles(root: string, files: string[], depth = 0): void {
119
360
  }
120
361
  }
121
362
 
363
+ const SEARCHABLE_EXTENSIONS = new Set([
364
+ ".ts", ".tsx", ".js", ".jsx", ".json",
365
+ ".swift", ".kt", ".kts", ".xml",
366
+ ".css", ".scss", ".md", ".plist",
367
+ ".yaml", ".yml", ".strings",
368
+ ]);
369
+
122
370
  function isSearchableFile(filePath: string): boolean {
123
371
  if (basename(filePath) === ".openuispec-state.json") return false;
124
- const ext = extname(filePath).toLowerCase();
125
- return new Set([
126
- ".ts",
127
- ".tsx",
128
- ".js",
129
- ".jsx",
130
- ".json",
131
- ".swift",
132
- ".kt",
133
- ".kts",
134
- ".xml",
135
- ".css",
136
- ".scss",
137
- ".md",
138
- ".plist",
139
- ".yaml",
140
- ".yml",
141
- ".strings",
142
- ]).has(ext);
372
+ return SEARCHABLE_EXTENSIONS.has(extname(filePath).toLowerCase());
143
373
  }
144
374
 
145
375
  function normalizeTerm(term: string): string | null {
@@ -241,6 +471,371 @@ function buildCategoryNotes(category: string, target: string): string[] {
241
471
  }
242
472
  }
243
473
 
474
+ function buildBootstrapNotes(category: string, target: string, specStatus: string | null): string[] {
475
+ const notes = buildCategoryNotes(category, target);
476
+ if (specStatus === "stub") {
477
+ notes.unshift("This spec file is marked stub, so treat it as incomplete guidance during first-time generation.");
478
+ } else if (specStatus === "draft") {
479
+ notes.unshift("This spec file is draft quality. Generate conservatively and expect follow-up refinement.");
480
+ } else if (specStatus === "ready") {
481
+ notes.unshift("This spec file is ready and should be implemented in the initial target output.");
482
+ }
483
+ return notes;
484
+ }
485
+
486
+ function generationRules(target: string, outputDir: string, manifest: Record<string, any>): string[] {
487
+ const outputFormat = manifest.generation?.output_format?.[target] ?? {};
488
+ const rules = [
489
+ "Read openuispec.yaml first, then follow the referenced spec files instead of inventing structure from memory.",
490
+ "Implement every ready or draft screen and flow in the target output; treat stub screens and flows as incomplete guidance.",
491
+ "Apply token files, locale resources, contracts, and platform overrides together so the generated target is internally consistent.",
492
+ "Do not leave unresolved locale keys, token references, or placeholder assets in the generated UI.",
493
+ `Write the generated ${target} output under ${outputDir}.`,
494
+ `After the first accepted ${target} output exists, run \`openuispec drift --snapshot --target ${target}\` to baseline it.`,
495
+ ];
496
+
497
+ if (outputFormat.framework || outputFormat.language) {
498
+ rules.unshift(
499
+ `Target output must follow ${outputFormat.language ?? "the configured language"} / ${outputFormat.framework ?? "the configured framework"}.`
500
+ );
501
+ }
502
+
503
+ return rules;
504
+ }
505
+
506
+ function localizationConstraints(
507
+ target: string,
508
+ platformConfig?: Pick<PreparePlatformConfig, "framework">
509
+ ): PrepareLocalizationConstraints {
510
+ switch (target) {
511
+ case "ios":
512
+ return {
513
+ must_use_platform_native_i18n: true,
514
+ forbid_in_memory_string_maps: true,
515
+ runtime_resources: [
516
+ "Bundle-backed locale resources under Resources/<locale>.lproj/Localizable.strings",
517
+ ],
518
+ required_files: [
519
+ "Resources/en.lproj/Localizable.strings",
520
+ "Resources/<other-locale>.lproj/Localizable.strings",
521
+ ],
522
+ lookup_module_guidance:
523
+ "Use SwiftUI/Foundation localization backed by bundle resources, not inline dictionaries or generated in-memory maps.",
524
+ notes: [
525
+ "Localized strings must resolve through the app bundle at runtime.",
526
+ "Do not hardcode locale maps inside views, models, or app support files.",
527
+ ],
528
+ };
529
+ case "android":
530
+ return {
531
+ must_use_platform_native_i18n: true,
532
+ forbid_in_memory_string_maps: true,
533
+ runtime_resources: [
534
+ "Android string resources under app/src/main/res/values/strings.xml and locale-specific values-*/strings.xml",
535
+ ],
536
+ required_files: [
537
+ "app/src/main/res/values/strings.xml",
538
+ "app/src/main/res/values-<locale>/strings.xml",
539
+ ],
540
+ lookup_module_guidance:
541
+ "Use Android string resources with stringResource/getString lookups, not Kotlin in-memory maps or constants tables.",
542
+ notes: [
543
+ "Localized resources must be packaged under res/values* so they participate in Android resource resolution.",
544
+ "Do not leave locale content embedded inside composable files or support classes.",
545
+ ],
546
+ };
547
+ case "web": {
548
+ const fw = platformConfig?.framework;
549
+ if (fw && !isKnownWebFramework(fw)) {
550
+ return {
551
+ must_use_platform_native_i18n: true,
552
+ forbid_in_memory_string_maps: true,
553
+ runtime_resources: [
554
+ "File-backed locale resources such as src/locales/<locale>.json or framework-native message modules",
555
+ ],
556
+ required_files: [
557
+ "src/locales/<locale>.json or equivalent file-backed locale resources",
558
+ "A dedicated i18n module/provider wired through the selected web framework",
559
+ ],
560
+ lookup_module_guidance:
561
+ "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.",
562
+ notes: [
563
+ `The configured web framework (${fw}) is not a built-in preset, so this guidance stays framework-agnostic.`,
564
+ "Locale files must be imported from dedicated resource files, not defined inline in UI components.",
565
+ ],
566
+ };
567
+ }
568
+ if (fw === "vue") {
569
+ return {
570
+ must_use_platform_native_i18n: true,
571
+ forbid_in_memory_string_maps: true,
572
+ runtime_resources: [
573
+ "File-backed locale resources such as src/locales/<locale>.json loaded through vue-i18n",
574
+ ],
575
+ required_files: [
576
+ "src/locales/<locale>.json or equivalent file-backed locale resources",
577
+ "src/i18n.ts or equivalent vue-i18n setup module",
578
+ ],
579
+ lookup_module_guidance:
580
+ "Use vue-i18n with file-backed locale resources, not inline string maps in App.vue or component files.",
581
+ notes: [
582
+ "Locale files must be imported from dedicated resource files, not defined inline in Vue components.",
583
+ "The generated web app should support locale fallback through vue-i18n rather than ad hoc object lookups.",
584
+ ],
585
+ };
586
+ }
587
+ if (fw === "svelte") {
588
+ return {
589
+ must_use_platform_native_i18n: true,
590
+ forbid_in_memory_string_maps: true,
591
+ runtime_resources: [
592
+ "File-backed locale resources such as src/lib/locales/<locale>.json or SvelteKit i18n modules",
593
+ ],
594
+ required_files: [
595
+ "src/lib/locales/<locale>.json or equivalent file-backed locale resources",
596
+ "src/lib/i18n.ts or equivalent dedicated i18n module",
597
+ ],
598
+ lookup_module_guidance:
599
+ "Use file-backed locale resources with a dedicated i18n module, not inline string maps in +layout.svelte, +page.svelte, or component files.",
600
+ notes: [
601
+ "Locale files must be imported from dedicated resource files under src/lib/, not defined inline in Svelte components.",
602
+ "The generated web app should support locale fallback through the i18n module rather than ad hoc object lookups.",
603
+ ],
604
+ };
605
+ }
606
+ return {
607
+ must_use_platform_native_i18n: true,
608
+ forbid_in_memory_string_maps: true,
609
+ runtime_resources: [
610
+ "File-backed locale resources such as src/locales/<locale>.json or generated message modules",
611
+ ],
612
+ required_files: [
613
+ "src/locales/<locale>.json or equivalent file-backed locale resources",
614
+ "src/i18n.ts or equivalent dedicated i18n module",
615
+ ],
616
+ lookup_module_guidance:
617
+ "Use file-backed locale resources with a dedicated i18n module/provider, not a giant in-memory map inside App.tsx or screen/component files.",
618
+ notes: [
619
+ "Locale files must be imported from dedicated resource files, not defined inline in UI components.",
620
+ "The generated web app should support locale fallback through the i18n module rather than ad hoc object lookups.",
621
+ ],
622
+ };
623
+ }
624
+ default:
625
+ return {
626
+ must_use_platform_native_i18n: true,
627
+ forbid_in_memory_string_maps: true,
628
+ runtime_resources: ["Use target-native runtime localization resources."],
629
+ required_files: ["Create file-backed locale resources for each supported locale."],
630
+ lookup_module_guidance:
631
+ "Use the target's native localization system instead of inline string maps.",
632
+ notes: [],
633
+ };
634
+ }
635
+ }
636
+
637
+ function fileStructureConstraints(
638
+ target: string,
639
+ platformConfig?: Pick<PreparePlatformConfig, "framework">
640
+ ): PrepareFileStructureConstraints {
641
+ switch (target) {
642
+ case "ios":
643
+ return {
644
+ forbid_single_file_output: true,
645
+ required_directories: [
646
+ "Sources/<Project>/App",
647
+ "Sources/<Project>/Screens",
648
+ "Sources/<Project>/Components",
649
+ "Sources/<Project>/Models",
650
+ "Sources/<Project>/Support",
651
+ "Resources",
652
+ ],
653
+ screen_split_rule: "Generate one primary screen/view per file under Sources/<Project>/Screens.",
654
+ component_split_rule: "Place reusable UI primitives and shared subviews under Sources/<Project>/Components instead of keeping them in a monolithic screen file.",
655
+ notes: [
656
+ "The app entry file may stay separate, but it must not contain the full app implementation.",
657
+ "Models, support logic, and screens should live in separate files and folders.",
658
+ ],
659
+ };
660
+ case "android":
661
+ return {
662
+ forbid_single_file_output: true,
663
+ required_directories: [
664
+ "app/src/main/java/<package>/ui/screens",
665
+ "app/src/main/java/<package>/ui/components",
666
+ "app/src/main/java/<package>/model",
667
+ "app/src/main/java/<package>/support",
668
+ "app/src/main/res",
669
+ ],
670
+ screen_split_rule: "Generate one primary screen composable file per screen under ui/screens.",
671
+ component_split_rule: "Put reusable composables under ui/components and keep models/support logic out of screen files.",
672
+ notes: [
673
+ "Do not place every screen, component, and model into a single Kotlin source file.",
674
+ "Resource XML, models, and UI code should remain separated.",
675
+ ],
676
+ };
677
+ case "web": {
678
+ const fw = platformConfig?.framework;
679
+ if (fw && !isKnownWebFramework(fw)) {
680
+ return {
681
+ forbid_single_file_output: true,
682
+ required_directories: [
683
+ "src/routes or framework-appropriate screen modules",
684
+ "src/components",
685
+ "src/locales or generated messages",
686
+ "src/state, src/store, or framework-specific state modules",
687
+ "src",
688
+ ],
689
+ screen_split_rule:
690
+ "Generate one primary route/screen module per screen in the framework-appropriate location rather than embedding the entire app in a single root file.",
691
+ component_split_rule:
692
+ "Put reusable components in dedicated component modules and keep state/i18n/helpers in separate support files.",
693
+ notes: [
694
+ `The configured web framework (${fw}) is not a built-in preset, so file layout guidance is framework-agnostic.`,
695
+ "The root app shell may compose providers and routing, but it must not contain the entire generated application.",
696
+ ],
697
+ };
698
+ }
699
+ if (fw === "vue") {
700
+ return {
701
+ forbid_single_file_output: true,
702
+ required_directories: [
703
+ "src/views or src/pages",
704
+ "src/components",
705
+ "src/locales",
706
+ "src/stores",
707
+ "src",
708
+ ],
709
+ screen_split_rule: "Generate one Vue SFC per screen under src/views rather than embedding all screens in App.vue.",
710
+ component_split_rule: "Put reusable components under src/components and keep stores/i18n helpers in separate modules.",
711
+ notes: [
712
+ "App.vue may compose the app shell with router-view, but it must not contain the entire generated application.",
713
+ "Pinia stores, composables, and locale resources should live outside view/component files.",
714
+ ],
715
+ };
716
+ }
717
+ if (fw === "svelte") {
718
+ return {
719
+ forbid_single_file_output: true,
720
+ required_directories: [
721
+ "src/routes",
722
+ "src/lib/components",
723
+ "src/lib/locales",
724
+ "src/lib/stores",
725
+ "src/lib",
726
+ ],
727
+ screen_split_rule: "Generate one +page.svelte per screen under src/routes following SvelteKit file-based routing.",
728
+ component_split_rule: "Put reusable components under src/lib/components and keep stores/i18n helpers in src/lib/.",
729
+ notes: [
730
+ "+layout.svelte may compose the app shell, but it must not contain the entire generated application.",
731
+ "Svelte stores, helpers, and locale resources should live under src/lib/ outside route files.",
732
+ ],
733
+ };
734
+ }
735
+ return {
736
+ forbid_single_file_output: true,
737
+ required_directories: [
738
+ "src/screens",
739
+ "src/components",
740
+ "src/locales or src/generated",
741
+ "src/state or src/store",
742
+ "src",
743
+ ],
744
+ screen_split_rule: "Generate one screen module per screen under src/screens rather than embedding all screens in App.tsx.",
745
+ component_split_rule: "Put reusable components under src/components and keep state/i18n helpers in separate modules.",
746
+ notes: [
747
+ "App.tsx may compose the app shell, but it must not contain the entire generated application.",
748
+ "Shared state, helpers, and generated resources should live outside screen/component files.",
749
+ ],
750
+ };
751
+ }
752
+ default:
753
+ return {
754
+ forbid_single_file_output: true,
755
+ required_directories: ["Create separate directories for screens, components, resources, and support code."],
756
+ screen_split_rule: "Generate one screen per file.",
757
+ component_split_rule: "Keep reusable components separate from screens.",
758
+ notes: [],
759
+ };
760
+ }
761
+ }
762
+
763
+ function generationConstraints(target: string, platformConfig: PreparePlatformConfig): PrepareGenerationConstraints {
764
+ return {
765
+ localization: localizationConstraints(target, platformConfig),
766
+ file_structure: fileStructureConstraints(target, platformConfig),
767
+ platform_setup: {
768
+ refresh_target_platform_knowledge: true,
769
+ notes: [
770
+ `Refresh current ${target} platform/framework setup guidance before generation instead of relying on stale memory.`,
771
+ "Check current project scaffolding, resource wiring, navigation APIs, packaging rules, and other toolchain-sensitive conventions for this target.",
772
+ ],
773
+ },
774
+ };
775
+ }
776
+
777
+ const PRESENTATION_ONLY_KEYS = new Set(["naming", "bundler", "css"]);
778
+
779
+ function requiredPlatformDecisionKeys(target: string): string[] {
780
+ return platformStackKeys(target).filter((key) => !PRESENTATION_ONLY_KEYS.has(key));
781
+ }
782
+
783
+ function missingPlatformDecisions(target: string, platformDef: Record<string, any>): string[] {
784
+ const generation = platformDef.generation ?? {};
785
+ return requiredPlatformDecisionKeys(target).filter((key) => {
786
+ const value = generation[key];
787
+ return typeof value !== "string" || value.trim().length === 0;
788
+ });
789
+ }
790
+
791
+ function referenceExamples(): string[] {
792
+ const packageRoot = resolvePackageRoot();
793
+ const candidates = [
794
+ join(packageRoot, "README.md"),
795
+ join(packageRoot, "spec", "openuispec-v0.1.md"),
796
+ join(packageRoot, "examples", "taskflow", "openuispec"),
797
+ join(packageRoot, "schema"),
798
+ ];
799
+
800
+ return candidates.filter((candidate) => existsSync(candidate));
801
+ }
802
+
803
+ function generationWarnings(target: string, platformConfig: PreparePlatformConfig): string[] {
804
+ const warnings: string[] = [];
805
+
806
+ for (const [key, value] of Object.entries(platformConfig.stack)) {
807
+ if (!PRESENTATION_ONLY_KEYS.has(key) && !platformConfig.selected_option_refs[key]) {
808
+ warnings.push(
809
+ `The configured ${target} ${key} value "${value}" is custom or not covered by the preset catalog, so automatic dependency guidance is unavailable for that choice.`
810
+ );
811
+ }
812
+ }
813
+
814
+ if (target === "web" && platformConfig.framework && !isKnownWebFramework(platformConfig.framework)) {
815
+ warnings.push(
816
+ `The configured web framework "${platformConfig.framework}" is custom, so bootstrap generation constraints are framework-agnostic instead of React-specific.`
817
+ );
818
+ }
819
+
820
+ return warnings;
821
+ }
822
+
823
+ function bootstrapSpecFiles(projectDir: string, target: string): BootstrapSpecFile[] {
824
+ return discoverSpecFiles(projectDir)
825
+ .map((filePath) => {
826
+ const relPath = relative(projectDir, filePath);
827
+ const specStatus = hasStatusSemantics(relPath) ? readStatus(filePath) : null;
828
+ const category = categorizeSpecFile(relPath);
829
+ return {
830
+ spec_file: relPath,
831
+ category,
832
+ spec_status: specStatus,
833
+ notes: buildBootstrapNotes(category, target, specStatus),
834
+ };
835
+ })
836
+ .sort((a, b) => a.category.localeCompare(b.category) || a.spec_file.localeCompare(b.spec_file));
837
+ }
838
+
244
839
  function explanationItems(
245
840
  explanation: ExplainResult | undefined,
246
841
  outputDir: string,
@@ -264,7 +859,19 @@ function printReport(result: PrepareResult): void {
264
859
  console.log("==================");
265
860
  console.log(`Project: ${result.project}`);
266
861
  console.log(`Target: ${result.target}`);
862
+ console.log(`Mode: ${result.mode}`);
267
863
  console.log(`Output: ${result.output_dir}`);
864
+ if (result.backend_root) {
865
+ console.log(`Backend: ${result.backend_root}`);
866
+ }
867
+
868
+ const platformLabel = [result.platform_config.language, result.platform_config.framework]
869
+ .filter(Boolean)
870
+ .join(" / ");
871
+ if (platformLabel) {
872
+ console.log(`Platform: ${platformLabel}`);
873
+ }
874
+
268
875
  if (result.baseline.commit) {
269
876
  const shortCommit = result.baseline.commit.slice(0, 12);
270
877
  const branch = result.baseline.branch ? ` on ${result.baseline.branch}` : "";
@@ -275,7 +882,87 @@ function printReport(result: PrepareResult): void {
275
882
  `Summary: ${result.summary.changed} changed, ${result.summary.added} added, ${result.summary.removed} removed`
276
883
  );
277
884
 
278
- if (!result.changes_available) {
885
+ if (Object.keys(result.platform_config.stack).length > 0 || result.platform_config.dependencies.length > 0) {
886
+ console.log("\nPlatform Stack");
887
+ for (const [key, value] of Object.entries(result.platform_config.stack)) {
888
+ console.log(` ${key}: ${value}`);
889
+ }
890
+ if (Object.keys(result.platform_config.selected_option_refs).length > 0) {
891
+ console.log(" selected option refs:");
892
+ for (const [key, refs] of Object.entries(result.platform_config.selected_option_refs)) {
893
+ console.log(` - ${key}: ${refs.value}`);
894
+ }
895
+ }
896
+ if (result.platform_config.dependency_guidance.notes.length > 0) {
897
+ console.log(" dependency guidance:");
898
+ for (const note of result.platform_config.dependency_guidance.notes) {
899
+ console.log(` - ${note}`);
900
+ }
901
+ }
902
+ if (result.platform_config.dependencies.length > 0) {
903
+ console.log(" dependencies:");
904
+ for (const dependency of result.platform_config.dependencies) {
905
+ console.log(` - ${dependency}`);
906
+ }
907
+ }
908
+ }
909
+
910
+ if (result.mode === "bootstrap" && result.bootstrap) {
911
+ console.log("\nBootstrap Bundle");
912
+ console.log(` output exists: ${result.bootstrap.output_exists ? "yes" : "no"}`);
913
+ console.log(` generation ready: ${result.bootstrap.generation_ready ? "yes" : "no"}`);
914
+
915
+ if (result.bootstrap.missing_platform_decisions.length > 0) {
916
+ console.log(" missing platform decisions:");
917
+ for (const key of result.bootstrap.missing_platform_decisions) {
918
+ console.log(` - ${key}`);
919
+ }
920
+ }
921
+
922
+ if (result.bootstrap.generation_warnings.length > 0) {
923
+ console.log(" generation warnings:");
924
+ for (const warning of result.bootstrap.generation_warnings) {
925
+ console.log(` - ${warning}`);
926
+ }
927
+ }
928
+
929
+ if (result.code_roots.length > 0) {
930
+ console.log(" code roots:");
931
+ for (const root of result.code_roots) {
932
+ console.log(` - ${root}`);
933
+ }
934
+ }
935
+
936
+ console.log(" spec files:");
937
+ for (const file of result.bootstrap.spec_files) {
938
+ const statusLabel = file.spec_status ? ` [${file.spec_status}]` : "";
939
+ console.log(` - ${file.spec_file} (${file.category})${statusLabel}`);
940
+ }
941
+
942
+ console.log(" generation rules:");
943
+ for (const rule of result.bootstrap.generation_rules) {
944
+ console.log(` - ${rule}`);
945
+ }
946
+
947
+ console.log(" generation constraints:");
948
+ console.log(
949
+ ` - localization: native i18n required = ${result.bootstrap.generation_constraints.localization.must_use_platform_native_i18n ? "yes" : "no"}`
950
+ );
951
+ console.log(
952
+ ` - localization: forbid in-memory maps = ${result.bootstrap.generation_constraints.localization.forbid_in_memory_string_maps ? "yes" : "no"}`
953
+ );
954
+ console.log(
955
+ ` - file structure: forbid single-file output = ${result.bootstrap.generation_constraints.file_structure.forbid_single_file_output ? "yes" : "no"}`
956
+ );
957
+ console.log(
958
+ ` - platform setup: refresh target knowledge = ${result.bootstrap.generation_constraints.platform_setup.refresh_target_platform_knowledge ? "yes" : "no"}`
959
+ );
960
+
961
+ console.log(" references:");
962
+ for (const ref of result.bootstrap.reference_examples) {
963
+ console.log(` - ${ref}`);
964
+ }
965
+ } else if (!result.changes_available) {
279
966
  console.log(`\n${result.explanation_note ?? "No semantic changes available."}`);
280
967
  } else if (result.items.length === 0) {
281
968
  console.log("\nNo target updates are currently required from spec drift.");
@@ -318,13 +1005,97 @@ function printReport(result: PrepareResult): void {
318
1005
  }
319
1006
  }
320
1007
 
321
- function buildPrepareResult(target: string): PrepareResult {
322
- const cwd = process.cwd();
1008
+ function buildBootstrapPrepareResult(cwd: string, target: string): PrepareResult {
1009
+ const projectDir = findProjectDir(cwd);
1010
+ const projectName = readProjectName(projectDir);
1011
+ const outputDir = resolveOutputDir(projectDir, projectName, target);
1012
+ const manifest = readManifest(projectDir);
1013
+ const platformDef = readPlatformDefinition(projectDir, manifest, target);
1014
+ const platformConfig = buildPlatformConfig(target, platformDef);
1015
+ const outputFormat = manifest.generation?.output_format?.[target] ?? {};
1016
+ const codeRoots = suggestCodeRoots(target, outputDir);
1017
+ const missingDecisions = missingPlatformDecisions(target, platformDef);
1018
+ const backendRoot = resolveBackendRoot(projectDir, manifest);
1019
+ const backendContextRequired = hasApiEndpoints(manifest);
1020
+ const backendContextReady = !backendContextRequired || (backendRoot !== null && existsSync(backendRoot));
1021
+
1022
+ const nextSteps = [
1023
+ ...(!backendContextReady
1024
+ ? [
1025
+ "Set `generation.code_roots.backend` in openuispec.yaml to the backend folder used to implement the declared API endpoints.",
1026
+ ]
1027
+ : []),
1028
+ ...(missingDecisions.length > 0
1029
+ ? [
1030
+ `Run \`openuispec configure-target ${target}\` to choose the missing ${target} stack defaults before generation.`,
1031
+ ]
1032
+ : []),
1033
+ `Read the manifest and referenced ${target} spec files from this bundle before generating target code.`,
1034
+ ...(missingDecisions.length === 0
1035
+ ? [
1036
+ `Generate the initial ${target} implementation in ${outputDir}.`,
1037
+ "Build or run the generated target and review screens, flows, and localization wiring.",
1038
+ `After the first accepted ${target} output exists, run \`openuispec drift --snapshot --target ${target}\` to baseline it.`,
1039
+ ]
1040
+ : []),
1041
+ ];
1042
+
1043
+ if (outputFormat.framework || outputFormat.language) {
1044
+ nextSteps.unshift(
1045
+ `Target mapping context: ${outputFormat.language ?? "unknown language"} / ${outputFormat.framework ?? "unknown framework"}.`
1046
+ );
1047
+ }
1048
+
1049
+ return {
1050
+ mode: "bootstrap",
1051
+ project: projectName,
1052
+ target,
1053
+ output_dir: outputDir,
1054
+ backend_root: backendRoot,
1055
+ platform_config: platformConfig,
1056
+ code_roots: codeRoots,
1057
+ baseline: {
1058
+ kind: null,
1059
+ commit: null,
1060
+ branch: null,
1061
+ },
1062
+ summary: {
1063
+ changed: 0,
1064
+ added: 0,
1065
+ removed: 0,
1066
+ },
1067
+ changes_available: false,
1068
+ explanation_note: "No snapshot exists yet. This is a first-time generation bundle.",
1069
+ items: [],
1070
+ bootstrap: {
1071
+ output_exists: existsSync(outputDir),
1072
+ generation_ready: missingDecisions.length === 0 && backendContextReady,
1073
+ missing_platform_decisions: missingDecisions,
1074
+ includes: manifest.includes ?? {},
1075
+ output_format: outputFormat,
1076
+ i18n: {
1077
+ default_locale: manifest.i18n?.default_locale ?? null,
1078
+ supported_locales: manifest.i18n?.supported_locales ?? [],
1079
+ },
1080
+ spec_files: bootstrapSpecFiles(projectDir, target),
1081
+ generation_rules: generationRules(target, outputDir, manifest),
1082
+ generation_constraints: generationConstraints(target, platformConfig),
1083
+ generation_warnings: generationWarnings(target, platformConfig),
1084
+ reference_examples: referenceExamples(),
1085
+ },
1086
+ next_steps: nextSteps,
1087
+ };
1088
+ }
1089
+
1090
+ function buildUpdatePrepareResult(cwd: string, target: string): PrepareResult {
323
1091
  const { projectDir, projectName, result } = loadTargetDrift(cwd, target, false, true);
324
1092
  const outputDir = resolveOutputDir(projectDir, projectName, target);
325
1093
  const codeRoots = suggestCodeRoots(target, outputDir);
326
1094
  const manifest = readManifest(projectDir);
1095
+ const platformDef = readPlatformDefinition(projectDir, manifest, target);
1096
+ const platformConfig = buildPlatformConfig(target, platformDef);
327
1097
  const outputFormat = manifest.generation?.output_format?.[target] ?? {};
1098
+ const backendRoot = resolveBackendRoot(projectDir, manifest);
328
1099
  const items = explanationItems(result.explanation, outputDir, codeRoots, target);
329
1100
 
330
1101
  const nextSteps = [
@@ -341,9 +1112,12 @@ function buildPrepareResult(target: string): PrepareResult {
341
1112
  }
342
1113
 
343
1114
  return {
1115
+ mode: "update",
344
1116
  project: projectName,
345
1117
  target,
346
1118
  output_dir: outputDir,
1119
+ backend_root: backendRoot,
1120
+ platform_config: platformConfig,
347
1121
  code_roots: codeRoots,
348
1122
  baseline: {
349
1123
  kind: result.state.baseline?.kind ?? null,
@@ -362,6 +1136,18 @@ function buildPrepareResult(target: string): PrepareResult {
362
1136
  };
363
1137
  }
364
1138
 
1139
+ function buildPrepareResult(target: string): PrepareResult {
1140
+ const cwd = process.cwd();
1141
+ const projectDir = findProjectDir(cwd);
1142
+ const projectName = readProjectName(projectDir);
1143
+ const outputDir = resolveOutputDir(projectDir, projectName, target);
1144
+ const statePath = join(outputDir, ".openuispec-state.json");
1145
+ if (!existsSync(statePath)) {
1146
+ return buildBootstrapPrepareResult(cwd, target);
1147
+ }
1148
+ return buildUpdatePrepareResult(cwd, target);
1149
+ }
1150
+
365
1151
  export function runPrepare(argv: string[]): void {
366
1152
  const isJson = argv.includes("--json");
367
1153
  const targetIdx = argv.indexOf("--target");