openuispec 0.2.11 → 0.2.13

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/drift/index.ts CHANGED
@@ -73,6 +73,33 @@ export interface ExplainResult {
73
73
  files: FileExplanation[];
74
74
  }
75
75
 
76
+ // ── shared layer types ───────────────────────────────────────────────
77
+
78
+ export interface SharedLayerConfig {
79
+ name: string;
80
+ platforms: string[];
81
+ language: string;
82
+ root: string; // resolved absolute path
83
+ paths: Record<string, string>;
84
+ tracks: string[]; // spec categories to track for drift
85
+ scope: string; // what code belongs in this layer
86
+ }
87
+
88
+ export interface SharedLayerState {
89
+ spec_version: string;
90
+ snapshot_at: string;
91
+ layer_name: string;
92
+ generated_by_target: string;
93
+ baseline?: BaselineRef;
94
+ files: Record<string, FileEntry>;
95
+ }
96
+
97
+ export interface SharedLayerDriftResult {
98
+ layer: SharedLayerConfig;
99
+ drift: DriftResult;
100
+ state: SharedLayerState | null;
101
+ }
102
+
76
103
  // ── helpers ───────────────────────────────────────────────────────────
77
104
 
78
105
  export function listFiles(dir: string, ext: string): string[] {
@@ -166,16 +193,43 @@ export function discoverSpecFiles(projectDir: string): string[] {
166
193
  return files;
167
194
  }
168
195
 
169
- function categorize(relPath: string): string {
170
- if (relPath === "openuispec.yaml") return "Manifest";
196
+ /** Classify a relative spec path into a lowercase category. */
197
+ export function specCategory(relPath: string): string {
198
+ if (relPath === "openuispec.yaml") return "manifest";
171
199
  const dir = dirname(relPath);
172
- if (dir === "tokens") return "Tokens";
173
- if (dir === "screens") return "Screens";
174
- if (dir === "flows") return "Flows";
175
- if (dir === "platform") return "Platform";
176
- if (dir === "locales") return "Locales";
177
- if (dir === "contracts") return "Contracts";
178
- return "Other";
200
+ if (dir === "tokens") return "tokens";
201
+ if (dir === "screens") return "screens";
202
+ if (dir === "flows") return "flows";
203
+ if (dir === "platform") return "platform";
204
+ if (dir === "locales") return "locales";
205
+ if (dir === "contracts") return "contracts";
206
+ return "other";
207
+ }
208
+
209
+ function categorize(relPath: string): string {
210
+ const cat = specCategory(relPath);
211
+ return cat.charAt(0).toUpperCase() + cat.slice(1);
212
+ }
213
+
214
+ /** Returns true when a DriftResult contains any changes. */
215
+ export function hasDriftChanges(d: DriftResult): boolean {
216
+ return d.changed.length > 0 || d.added.length > 0 || d.removed.length > 0;
217
+ }
218
+
219
+ /** Hash spec files into FileEntry records keyed by relative path. */
220
+ export function buildFileEntries(
221
+ projectDir: string,
222
+ files: string[]
223
+ ): { entries: Record<string, FileEntry>; stubs: number } {
224
+ const entries: Record<string, FileEntry> = {};
225
+ let stubs = 0;
226
+ for (const f of files) {
227
+ const rel = relative(projectDir, f);
228
+ const status = hasStatusSemantics(rel) ? readStatus(f) : "ready";
229
+ entries[rel] = { hash: hashFile(f), status };
230
+ if (status === "stub") stubs++;
231
+ }
232
+ return { entries, stubs };
179
233
  }
180
234
 
181
235
  function runGit(args: string[], cwd: string): string | null {
@@ -536,6 +590,158 @@ function normalizeEntry(value: string | FileEntry): FileEntry {
536
590
  return value;
537
591
  }
538
592
 
593
+ // ── shared layers ────────────────────────────────────────────────────
594
+
595
+ /** Parse `generation.shared` from the manifest and resolve roots to absolute paths. */
596
+ export function readSharedLayers(projectDir: string): SharedLayerConfig[] {
597
+ const manifest = readManifest(projectDir);
598
+ const shared = manifest.generation?.shared;
599
+ if (!shared || typeof shared !== "object") return [];
600
+
601
+ return Object.entries(shared).map(([name, config]) => {
602
+ const cfg = config as Record<string, any>;
603
+ return {
604
+ name,
605
+ platforms: Array.isArray(cfg.platforms) ? cfg.platforms : [],
606
+ language: typeof cfg.language === "string" ? cfg.language : "",
607
+ root: resolve(projectDir, cfg.root ?? "."),
608
+ paths: typeof cfg.paths === "object" && cfg.paths !== null ? cfg.paths : {},
609
+ tracks: Array.isArray(cfg.tracks) ? cfg.tracks : [],
610
+ scope: typeof cfg.scope === "string" ? cfg.scope : "",
611
+ };
612
+ });
613
+ }
614
+
615
+ /** Return shared layers whose `platforms` array includes the given target. */
616
+ export function sharedLayersForTarget(projectDir: string, target: string): SharedLayerConfig[] {
617
+ return readSharedLayers(projectDir).filter((layer) => layer.platforms.includes(target));
618
+ }
619
+
620
+ /** Returns the state file path for a shared layer. */
621
+ export function sharedLayerStatePath(layer: SharedLayerConfig): string {
622
+ return join(layer.root, `.openuispec-shared-${layer.name}.json`);
623
+ }
624
+
625
+ /** Read an existing shared layer state file, or null if it doesn't exist. */
626
+ export function readSharedLayerState(layer: SharedLayerConfig): SharedLayerState | null {
627
+ try {
628
+ return JSON.parse(readFileSync(sharedLayerStatePath(layer), "utf-8"));
629
+ } catch {
630
+ return null;
631
+ }
632
+ }
633
+
634
+ /** Filter spec files to only those in the given categories. */
635
+ function filterSpecFilesByCategories(
636
+ projectDir: string,
637
+ files: string[],
638
+ categories: string[]
639
+ ): string[] {
640
+ const catSet = new Set(categories);
641
+ return files.filter((f) => catSet.has(specCategory(relative(projectDir, f))));
642
+ }
643
+
644
+ /** Create a snapshot of spec state for a shared layer. */
645
+ export function createSharedSnapshot(
646
+ cwd: string,
647
+ layerName: string,
648
+ generatedByTarget: string
649
+ ): SnapshotResult {
650
+ const projectDir = findProjectDir(cwd);
651
+ const layers = readSharedLayers(projectDir);
652
+ const layer = layers.find((l) => l.name === layerName);
653
+ if (!layer) {
654
+ throw new Error(`Shared layer "${layerName}" not found in generation.shared`);
655
+ }
656
+
657
+ const manifest = readManifest(projectDir);
658
+ const allFiles = discoverSpecFiles(projectDir);
659
+ const files = filterSpecFilesByCategories(projectDir, allFiles, layer.tracks);
660
+ const baseline = captureBaseline(projectDir, allFiles);
661
+
662
+ const { entries, stubs: stubCount } = buildFileEntries(projectDir, files);
663
+
664
+ const state: SharedLayerState = {
665
+ spec_version: manifest.spec_version ?? "0.1",
666
+ snapshot_at: new Date().toISOString(),
667
+ layer_name: layerName,
668
+ generated_by_target: generatedByTarget,
669
+ baseline,
670
+ files: entries,
671
+ };
672
+
673
+ const outPath = sharedLayerStatePath(layer);
674
+ writeFileSync(outPath, JSON.stringify(state, null, 2) + "\n");
675
+
676
+ return {
677
+ target: `shared:${layerName}`,
678
+ snapshot_at: state.snapshot_at,
679
+ files_hashed: Object.keys(entries).length,
680
+ stubs: stubCount,
681
+ state_path: relative(cwd, outPath),
682
+ baseline: formatBaseline(baseline),
683
+ };
684
+ }
685
+
686
+ /** Compute drift for a shared layer against its saved state. */
687
+ export function computeSharedDrift(
688
+ projectDir: string,
689
+ layer: SharedLayerConfig
690
+ ): SharedLayerDriftResult {
691
+ const state = readSharedLayerState(layer);
692
+ if (!state) {
693
+ return {
694
+ layer,
695
+ drift: { changed: [], added: [], removed: [], unchanged: [] },
696
+ state: null,
697
+ };
698
+ }
699
+
700
+ const allFiles = discoverSpecFiles(projectDir);
701
+ const files = filterSpecFilesByCategories(projectDir, allFiles, layer.tracks);
702
+ const { entries: current } = buildFileEntries(projectDir, files);
703
+
704
+ const drift: DriftResult = { changed: [], added: [], removed: [], unchanged: [] };
705
+
706
+ for (const [rel, entry] of Object.entries(current)) {
707
+ const snapshotEntry = state.files[rel]
708
+ ? normalizeEntry(state.files[rel] as string | FileEntry)
709
+ : null;
710
+ if (!snapshotEntry) {
711
+ drift.added.push(rel);
712
+ } else if (snapshotEntry.hash !== entry.hash) {
713
+ drift.changed.push(rel);
714
+ } else {
715
+ drift.unchanged.push(rel);
716
+ }
717
+ }
718
+
719
+ for (const rel of Object.keys(state.files)) {
720
+ if (!(rel in current)) {
721
+ drift.removed.push(rel);
722
+ }
723
+ }
724
+
725
+ return { layer, drift, state };
726
+ }
727
+
728
+ /** Read `generation.structure[target]` from the manifest. */
729
+ export function readTargetStructure(
730
+ projectDir: string,
731
+ target: string
732
+ ): { root: string; paths: Record<string, string>; scope: string } | null {
733
+ const manifest = readManifest(projectDir);
734
+ const structure = manifest.generation?.structure?.[target];
735
+ if (!structure || typeof structure !== "object" || typeof structure.root !== "string") {
736
+ return null;
737
+ }
738
+ return {
739
+ root: resolve(projectDir, structure.root),
740
+ paths: typeof structure.paths === "object" && structure.paths !== null ? structure.paths : {},
741
+ scope: typeof structure.scope === "string" ? structure.scope : "",
742
+ };
743
+ }
744
+
539
745
  // ── snapshot ──────────────────────────────────────────────────────────
540
746
 
541
747
  export interface SnapshotResult {
@@ -561,16 +767,7 @@ export function createSnapshot(cwd: string, target: string): SnapshotResult {
561
767
  const manifest = readManifest(projectDir);
562
768
  const files = discoverSpecFiles(projectDir);
563
769
  const baseline = captureBaseline(projectDir, files);
564
-
565
- const entries: Record<string, FileEntry> = {};
566
- let stubCount = 0;
567
-
568
- for (const f of files) {
569
- const rel = relative(projectDir, f);
570
- const status = hasStatusSemantics(rel) ? readStatus(f) : "ready";
571
- entries[rel] = { hash: hashFile(f), status };
572
- if (status === "stub") stubCount++;
573
- }
770
+ const { entries, stubs: stubCount } = buildFileEntries(projectDir, files);
574
771
 
575
772
  const state: StateFile = {
576
773
  spec_version: manifest.spec_version ?? "0.1",
@@ -583,6 +780,20 @@ export function createSnapshot(cwd: string, target: string): SnapshotResult {
583
780
  const outPath = stateFilePath(projectDir, projectName, target);
584
781
  writeFileSync(outPath, JSON.stringify(state, null, 2) + "\n");
585
782
 
783
+ // Auto-snapshot shared layers with tracks for this target if their state file doesn't exist yet
784
+ const sharedLayers = sharedLayersForTarget(projectDir, target);
785
+ for (const layer of sharedLayers) {
786
+ if (layer.tracks.length === 0) continue;
787
+ const layerStatePath = sharedLayerStatePath(layer);
788
+ if (!existsSync(layerStatePath) && existsSync(layer.root)) {
789
+ try {
790
+ createSharedSnapshot(cwd, layer.name, target);
791
+ } catch {
792
+ // Non-fatal: shared layer snapshot is best-effort during target snapshot
793
+ }
794
+ }
795
+ }
796
+
586
797
  return {
587
798
  target,
588
799
  snapshot_at: state.snapshot_at,
@@ -619,6 +830,7 @@ export interface CheckResult {
619
830
  stubDrift: DriftResult;
620
831
  statuses: Record<string, string>;
621
832
  explanation?: ExplainResult;
833
+ shared_layer_drift?: SharedLayerDriftResult[];
622
834
  }
623
835
 
624
836
  export function computeDrift(
@@ -627,13 +839,7 @@ export function computeDrift(
627
839
  includeAll: boolean
628
840
  ): CheckResult {
629
841
  const files = discoverSpecFiles(projectDir);
630
-
631
- const current: Record<string, FileEntry> = {};
632
- for (const f of files) {
633
- const rel = relative(projectDir, f);
634
- const status = hasStatusSemantics(rel) ? readStatus(f) : "ready";
635
- current[rel] = { hash: hashFile(f), status };
636
- }
842
+ const { entries: current } = buildFileEntries(projectDir, files);
637
843
 
638
844
  const drift: DriftResult = { changed: [], added: [], removed: [], unchanged: [] };
639
845
  const stubDrift: DriftResult = { changed: [], added: [], removed: [], unchanged: [] };
@@ -688,6 +894,15 @@ export function loadTargetDrift(
688
894
  result.explanation = explainDrift(projectDir, result);
689
895
  }
690
896
 
897
+ // Include shared layer drift for this target (only layers with tracks configured)
898
+ const sharedLayers = sharedLayersForTarget(projectDir, target);
899
+ const trackingLayers = sharedLayers.filter((l) => l.tracks.length > 0);
900
+ if (trackingLayers.length > 0) {
901
+ result.shared_layer_drift = trackingLayers.map((layer) =>
902
+ computeSharedDrift(projectDir, layer)
903
+ );
904
+ }
905
+
691
906
  return { projectDir, projectName, statePath, result };
692
907
  }
693
908
 
@@ -87,7 +87,7 @@ taskflow/
87
87
 
88
88
  Pass the entire `taskflow/` directory as context to an AI code generator with the prompt:
89
89
 
90
- > Generate a native {ios|android|web} application from this OpenUISpec. Follow all contract state machines, apply token ranges for the target platform, and implement the navigation flows as defined. Use the platform adaptation file for target-specific overrides.
90
+ > Generate a native {ios|android|web} application from this OpenUISpec. Follow all contract UI states, apply token ranges for the target platform, and implement the navigation flows as defined. Use the platform adaptation file for target-specific overrides.
91
91
 
92
92
  The AI should produce:
93
93
  1. Compilable platform code (Swift/Kotlin/TypeScript)
@@ -147,9 +147,12 @@ server.registerTool(
147
147
  const baselineReminder = baselinePending
148
148
  ? " ⚠ Baseline pending — remind user to run `openuispec drift --snapshot --target " + target + "` when satisfied."
149
149
  : "";
150
+ const sharedHint = result.shared_layers?.length
151
+ ? ` ℹ ${result.shared_layers.length} shared layer(s) detected — check shared_layers for generation guidance.`
152
+ : "";
150
153
  const hint = (include_specs
151
154
  ? "next_tool: openuispec_check (after generating code)"
152
- : "next_tool: openuispec_read_specs (load spec contents for generation)") + baselineReminder;
155
+ : "next_tool: openuispec_read_specs (load spec contents for generation)") + baselineReminder + sharedHint;
153
156
  return toolResult(result, hint);
154
157
  } catch (err) {
155
158
  return toolError(err);
@@ -676,6 +679,25 @@ server.registerTool(
676
679
  }
677
680
  );
678
681
 
682
+ // ── locale key lookup (supports both flat dotted keys and nested objects) ──
683
+
684
+ function lookupLocaleKey(content: Record<string, unknown>, key: string): { found: boolean; value: unknown } {
685
+ // 1. Try flat (literal) key first: { "nav.tasks": "Tasks" }
686
+ if (key in content) {
687
+ return { found: true, value: content[key] };
688
+ }
689
+ // 2. Try nested path: { nav: { tasks: "Tasks" } }
690
+ const parts = key.split(".");
691
+ let current: unknown = content;
692
+ for (const part of parts) {
693
+ if (current === null || current === undefined || typeof current !== "object" || Array.isArray(current)) {
694
+ return { found: false, value: undefined };
695
+ }
696
+ current = (current as Record<string, unknown>)[part];
697
+ }
698
+ return current !== undefined ? { found: true, value: current } : { found: false, value: undefined };
699
+ }
700
+
679
701
  // ── tool: openuispec_get_locale ─────────────────────────────────────
680
702
 
681
703
  server.registerTool(
@@ -710,9 +732,8 @@ server.registerTool(
710
732
  if (keys && keys.length > 0) {
711
733
  const filtered: Record<string, unknown> = {};
712
734
  for (const key of keys) {
713
- if (key in content) {
714
- filtered[key] = content[key];
715
- }
735
+ const { found, value } = lookupLocaleKey(content, key);
736
+ if (found) filtered[key] = value;
716
737
  }
717
738
  return toolResult({ locale, path: relative(projectDir, filePath), content: filtered });
718
739
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openuispec",
3
- "version": "0.2.11",
3
+ "version": "0.2.13",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "A semantic UI specification format for AI-native, platform-native app development",
package/prepare/index.ts CHANGED
@@ -21,10 +21,17 @@ import {
21
21
  readManifest,
22
22
  readProjectName,
23
23
  readStatus,
24
+ hasDriftChanges,
25
+ readSharedLayerState,
26
+ readTargetStructure,
24
27
  resolveOutputDir,
28
+ sharedLayersForTarget,
29
+ specCategory,
30
+ computeSharedDrift,
25
31
  type FileExplanation,
26
32
  type ExplainResult,
27
33
  type SemanticChange,
34
+ type SharedLayerConfig,
28
35
  } from "../drift/index.js";
29
36
 
30
37
  interface PrepareItem {
@@ -116,6 +123,20 @@ interface PrepareBootstrapBundle {
116
123
  reference_examples: string[];
117
124
  }
118
125
 
126
+ interface SharedLayerInfo {
127
+ name: string;
128
+ platforms: string[];
129
+ language: string;
130
+ root: string;
131
+ paths: Record<string, string>;
132
+ scope: string;
133
+ tracks: string[];
134
+ already_generated: boolean;
135
+ generated_by_target: string | null;
136
+ has_drift: boolean;
137
+ guidance: string;
138
+ }
139
+
119
140
  interface SpecFileContent {
120
141
  path: string;
121
142
  category: string;
@@ -148,6 +169,7 @@ export interface PrepareResult {
148
169
  changes_available: boolean;
149
170
  explanation_note?: string;
150
171
  items: PrepareItem[];
172
+ shared_layers?: SharedLayerInfo[];
151
173
  bootstrap?: PrepareBootstrapBundle;
152
174
  spec_contents?: SpecFileContent[];
153
175
  next_steps: string[];
@@ -321,13 +343,88 @@ function resolveBackendRoot(projectDir: string, manifest: Record<string, any>):
321
343
  return resolve(projectDir, backendRoot);
322
344
  }
323
345
 
324
- function categorizeSpecFile(relPath: string): string {
325
- if (relPath === "openuispec.yaml") return "manifest";
326
- const group = relPath.split("/")[0];
327
- return group || "other";
346
+ // Use specCategory from drift/index.ts (imported above) instead of a local duplicate.
347
+ const categorizeSpecFile = specCategory;
348
+
349
+ function buildSharedLayerInfos(projectDir: string, target: string, layers?: SharedLayerConfig[]): SharedLayerInfo[] {
350
+ const resolvedLayers = layers ?? sharedLayersForTarget(projectDir, target);
351
+ if (resolvedLayers.length === 0) return [];
352
+
353
+ return resolvedLayers.map((layer) => {
354
+ const state = readSharedLayerState(layer);
355
+ const alreadyGenerated = state !== null;
356
+
357
+ // Only compute hash-based drift when tracks are configured
358
+ let hasDrift = false;
359
+ if (layer.tracks.length > 0) {
360
+ const driftResult = computeSharedDrift(projectDir, layer);
361
+ hasDrift = driftResult.state !== null && hasDriftChanges(driftResult.drift);
362
+ }
363
+
364
+ let guidance: string;
365
+ if (alreadyGenerated && !hasDrift) {
366
+ guidance = `Shared code already generated by ${state!.generated_by_target} — read existing code, don't regenerate.`;
367
+ } else if (alreadyGenerated && hasDrift) {
368
+ guidance = `Generated by ${state!.generated_by_target} but spec has drifted — review shared code for needed updates.`;
369
+ } else {
370
+ guidance = `Generate shared layer alongside ${target} platform code.`;
371
+ }
372
+
373
+ return {
374
+ name: layer.name,
375
+ platforms: layer.platforms,
376
+ language: layer.language,
377
+ root: layer.root,
378
+ paths: layer.paths,
379
+ scope: layer.scope,
380
+ tracks: layer.tracks,
381
+ already_generated: alreadyGenerated,
382
+ generated_by_target: state?.generated_by_target ?? null,
383
+ has_drift: hasDrift,
384
+ guidance,
385
+ };
386
+ });
387
+ }
388
+
389
+ function collectSharedLayerPaths(layers: SharedLayerConfig[]): string[] {
390
+ const paths: string[] = [];
391
+ for (const layer of layers) {
392
+ paths.push(layer.root);
393
+ for (const p of Object.values(layer.paths)) {
394
+ paths.push(resolve(layer.root, p));
395
+ }
396
+ }
397
+ return paths;
328
398
  }
329
399
 
330
- function suggestCodeRoots(target: string, outputDir: string): string[] {
400
+ function dedupeExistingPaths(candidates: string[]): string[] {
401
+ const seen = new Set<string>();
402
+ return candidates
403
+ .map((c) => resolve(c))
404
+ .filter((c) => existsSync(c))
405
+ .filter((c) => {
406
+ if (seen.has(c)) return false;
407
+ seen.add(c);
408
+ return true;
409
+ });
410
+ }
411
+
412
+ function suggestCodeRoots(target: string, outputDir: string, projectDir?: string, sharedLayers?: SharedLayerConfig[]): string[] {
413
+ const layers = sharedLayers ?? (projectDir ? sharedLayersForTarget(projectDir, target) : []);
414
+
415
+ // When generation.structure is defined for the target, use it instead of heuristics
416
+ if (projectDir) {
417
+ const structure = readTargetStructure(projectDir, target);
418
+ if (structure) {
419
+ const candidates = [
420
+ structure.root,
421
+ ...Object.values(structure.paths).map((p) => resolve(structure.root, p)),
422
+ ...collectSharedLayerPaths(layers),
423
+ ];
424
+ return dedupeExistingPaths(candidates);
425
+ }
426
+ }
427
+
331
428
  const candidates: string[] = [];
332
429
 
333
430
  if (target === "web") {
@@ -345,15 +442,8 @@ function suggestCodeRoots(target: string, outputDir: string): string[] {
345
442
  candidates.push(outputDir);
346
443
  }
347
444
 
348
- const seen = new Set<string>();
349
- return candidates
350
- .map((candidate) => resolve(candidate))
351
- .filter((candidate) => existsSync(candidate))
352
- .filter((candidate) => {
353
- if (seen.has(candidate)) return false;
354
- seen.add(candidate);
355
- return true;
356
- });
445
+ candidates.push(...collectSharedLayerPaths(layers));
446
+ return dedupeExistingPaths(candidates);
357
447
  }
358
448
 
359
449
  function walkFiles(root: string, files: string[], depth = 0): void {
@@ -511,7 +601,7 @@ function buildBootstrapNotes(category: string, target: string, specStatus: strin
511
601
  return notes;
512
602
  }
513
603
 
514
- function generationRules(target: string, outputDir: string, manifest: Record<string, any>): string[] {
604
+ function generationRules(target: string, outputDir: string, manifest: Record<string, any>, sharedLayers?: SharedLayerInfo[]): string[] {
515
605
  const outputFormat = manifest.generation?.output_format?.[target] ?? {};
516
606
  const rules = [
517
607
  "Read openuispec.yaml first, then follow the referenced spec files instead of inventing structure from memory.",
@@ -528,6 +618,31 @@ function generationRules(target: string, outputDir: string, manifest: Record<str
528
618
  );
529
619
  }
530
620
 
621
+ if (sharedLayers && sharedLayers.length > 0) {
622
+ for (const layer of sharedLayers) {
623
+ if (layer.already_generated) {
624
+ rules.push(
625
+ `Shared layer "${layer.name}" already generated by ${layer.generated_by_target}. Read the existing code under ${layer.root} instead of regenerating it.`
626
+ );
627
+ } else {
628
+ rules.push(
629
+ `Generate shared layer "${layer.name}" (${layer.language}) under ${layer.root} alongside ${target} platform code.`
630
+ );
631
+ }
632
+ if (layer.scope) {
633
+ rules.push(
634
+ `Shared layer "${layer.name}" scope: ${layer.scope}`
635
+ );
636
+ }
637
+ }
638
+ }
639
+
640
+ // Include target structure scope when defined
641
+ const structure = manifest.generation?.structure?.[target];
642
+ if (structure?.scope) {
643
+ rules.push(`Target "${target}" scope: ${structure.scope}`);
644
+ }
645
+
531
646
  return rules;
532
647
  }
533
648
 
@@ -1070,8 +1185,10 @@ function buildBootstrapPrepareResult(cwd: string, target: string, includeContent
1070
1185
  const platformDef = readPlatformDefinition(projectDir, manifest, target);
1071
1186
  const platformConfig = buildPlatformConfig(target, platformDef);
1072
1187
  const outputFormat = manifest.generation?.output_format?.[target] ?? {};
1073
- const codeRoots = suggestCodeRoots(target, outputDir);
1188
+ const sharedLayerConfigs = sharedLayersForTarget(projectDir, target);
1189
+ const codeRoots = suggestCodeRoots(target, outputDir, projectDir, sharedLayerConfigs);
1074
1190
  const missingDecisions = missingPlatformDecisions(target, platformDef);
1191
+ const sharedLayerInfos = buildSharedLayerInfos(projectDir, target, sharedLayerConfigs);
1075
1192
  const backendRoot = resolveBackendRoot(projectDir, manifest);
1076
1193
  const backendContextReady = true; // backend is optional — not a generation blocker
1077
1194
  const pendingUserConfirmation = platformConfig.stack_confirmation.requires_user_confirmation;
@@ -1140,6 +1257,7 @@ function buildBootstrapPrepareResult(cwd: string, target: string, includeContent
1140
1257
  changes_available: false,
1141
1258
  explanation_note: "No snapshot exists yet. This is a first-time generation bundle.",
1142
1259
  items: [],
1260
+ ...(sharedLayerInfos.length > 0 ? { shared_layers: sharedLayerInfos } : {}),
1143
1261
  ...(includeContents ? { spec_contents: readAllSpecContents(projectDir) } : {}),
1144
1262
  bootstrap: {
1145
1263
  output_exists: existsSync(outputDir),
@@ -1157,7 +1275,7 @@ function buildBootstrapPrepareResult(cwd: string, target: string, includeContent
1157
1275
  supported_locales: manifest.i18n?.supported_locales ?? [],
1158
1276
  },
1159
1277
  spec_files: bootstrapSpecFiles(projectDir, target),
1160
- generation_rules: generationRules(target, outputDir, manifest),
1278
+ generation_rules: generationRules(target, outputDir, manifest, sharedLayerInfos),
1161
1279
  generation_constraints: generationConstraints(target, platformConfig),
1162
1280
  generation_warnings: generationWarnings(target, platformConfig),
1163
1281
  reference_examples: referenceExamples(),
@@ -1169,8 +1287,10 @@ function buildBootstrapPrepareResult(cwd: string, target: string, includeContent
1169
1287
  function buildUpdatePrepareResult(cwd: string, target: string, includeContents: boolean = false): PrepareResult {
1170
1288
  const { projectDir, projectName, result } = loadTargetDrift(cwd, target, false, true);
1171
1289
  const outputDir = resolveOutputDir(projectDir, projectName, target);
1172
- const codeRoots = suggestCodeRoots(target, outputDir);
1290
+ const sharedLayerConfigs = sharedLayersForTarget(projectDir, target);
1291
+ const codeRoots = suggestCodeRoots(target, outputDir, projectDir, sharedLayerConfigs);
1173
1292
  const manifest = readManifest(projectDir);
1293
+ const sharedLayerInfos = buildSharedLayerInfos(projectDir, target, sharedLayerConfigs);
1174
1294
  const platformDef = readPlatformDefinition(projectDir, manifest, target);
1175
1295
  const platformConfig = buildPlatformConfig(target, platformDef);
1176
1296
  const outputFormat = manifest.generation?.output_format?.[target] ?? {};
@@ -1211,6 +1331,7 @@ function buildUpdatePrepareResult(cwd: string, target: string, includeContents:
1211
1331
  changes_available: result.explanation?.available ?? false,
1212
1332
  explanation_note: result.explanation?.note,
1213
1333
  items,
1334
+ ...(sharedLayerInfos.length > 0 ? { shared_layers: sharedLayerInfos } : {}),
1214
1335
  ...(includeContents ? { spec_contents: readAllSpecContents(projectDir) } : {}),
1215
1336
  next_steps: nextSteps,
1216
1337
  };
@@ -30,7 +30,7 @@
30
30
  },
31
31
  "states": {
32
32
  "type": "object",
33
- "description": "State machine definition — each key is a state name",
33
+ "description": "UI interaction states (e.g. idle, active, disabled, loading, error) — each key is a state name. These are visual/interaction states, not business logic states.",
34
34
  "additionalProperties": {
35
35
  "$ref": "https://openuispec.rsteam.uz/schema/custom-contract.schema.json#/$defs/state_def"
36
36
  }
@@ -165,7 +165,7 @@
165
165
  },
166
166
  "state_def": {
167
167
  "type": "object",
168
- "description": "A single state in the state machine",
168
+ "description": "A single UI interaction state",
169
169
  "properties": {
170
170
  "semantic": {
171
171
  "type": "string"