openuispec 0.2.11 → 0.2.12

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/check/index.ts CHANGED
@@ -14,10 +14,13 @@ import { existsSync, readFileSync } from "node:fs";
14
14
  import { join, resolve } from "node:path";
15
15
  import YAML from "yaml";
16
16
  import {
17
+ computeSharedDrift,
17
18
  findProjectDir,
19
+ hasDriftChanges,
18
20
  readManifest,
19
21
  readProjectName,
20
22
  resolveOutputDir,
23
+ sharedLayersForTarget,
21
24
  } from "../drift/index.js";
22
25
  import {
23
26
  buildAjv,
@@ -153,6 +156,20 @@ function determinePrepare(
153
156
  );
154
157
  }
155
158
 
159
+ // Check for shared layer drift (only when tracks are configured)
160
+ const sharedLayers = sharedLayersForTarget(projectDir, target);
161
+ for (const layer of sharedLayers) {
162
+ if (layer.tracks.length === 0) continue;
163
+ const driftResult = computeSharedDrift(projectDir, layer);
164
+ if (driftResult.state !== null) {
165
+ if (hasDriftChanges(driftResult.drift)) {
166
+ warnings.push(
167
+ `Shared layer "${layer.name}" has spec drift — shared code may need updates before ${target} generation.`
168
+ );
169
+ }
170
+ }
171
+ }
172
+
156
173
  const ready =
157
174
  missing.length === 0 && backendContextReady && !pendingUserConfirmation;
158
175
 
package/cli/index.ts CHANGED
@@ -95,6 +95,19 @@ function checkRulesVersion(): void {
95
95
 
96
96
  // ── spec helpers (shared with MCP server) ────────────────────────────
97
97
 
98
+ function lookupLocaleKey(content: Record<string, unknown>, key: string): { found: boolean; value: unknown } {
99
+ if (key in content) return { found: true, value: content[key] };
100
+ const parts = key.split(".");
101
+ let current: unknown = content;
102
+ for (const part of parts) {
103
+ if (current === null || current === undefined || typeof current !== "object" || Array.isArray(current)) {
104
+ return { found: false, value: undefined };
105
+ }
106
+ current = (current as Record<string, unknown>)[part];
107
+ }
108
+ return current !== undefined ? { found: true, value: current } : { found: false, value: undefined };
109
+ }
110
+
98
111
  function resolveSpecDir(projectDir: string, manifest: any, key: string): string {
99
112
  return resolve(projectDir, manifest.includes?.[key] ?? `./${key}/`);
100
113
  }
@@ -293,7 +306,10 @@ async function main(): Promise<void> {
293
306
  const content = JSON.parse(readFileSync(filePath, "utf-8"));
294
307
  if (keys) {
295
308
  const filtered: Record<string, unknown> = {};
296
- for (const key of keys) { if (key in content) filtered[key] = content[key]; }
309
+ for (const key of keys) {
310
+ const result = lookupLocaleKey(content, key);
311
+ if (result.found) filtered[key] = result.value;
312
+ }
297
313
  console.log(JSON.stringify(filtered, null, 2));
298
314
  } else {
299
315
  console.log(JSON.stringify(content, null, 2));
package/cli/init.ts CHANGED
@@ -33,6 +33,7 @@ export type InitOptionsResponse = {
33
33
  options?: string[];
34
34
  }>;
35
35
  configure_targets_note: string;
36
+ shared_layer_note?: string;
36
37
  };
37
38
 
38
39
  export function listInitOptions(): InitOptionsResponse {
@@ -78,9 +79,17 @@ export function listInitOptions(): InitOptionsResponse {
78
79
  type: "yes_no",
79
80
  default: defaults.configureTargets,
80
81
  },
82
+ {
83
+ key: "with_shared",
84
+ prompt: "Does the project share code between platforms (e.g. KMP commonMain)?",
85
+ type: "yes_no",
86
+ default: false,
87
+ },
81
88
  ],
82
89
  configure_targets_note:
83
90
  "If configure_targets is true, use `openuispec configure-target <target> --list-options` for each target after init to present stack choices to the user.",
91
+ shared_layer_note:
92
+ "If with_shared is true, add shared layer config to generation.shared in the manifest. Each shared layer needs: name, platforms (subset of targets), language, root (path relative to openuispec.yaml), tracks (spec categories: manifest, contracts, flows, screens, tokens, platform, locales), and scope (what code belongs there). Also add generation.structure entries for each target to define where platform-specific UI code goes and its scope.",
84
93
  };
85
94
  }
86
95
 
@@ -171,7 +180,12 @@ function getPackageVersion(): string {
171
180
  function manifestTemplate(
172
181
  name: string,
173
182
  targets: string[],
174
- options: { withApi: boolean; backendPath: string | null }
183
+ options: {
184
+ withApi: boolean;
185
+ backendPath: string | null;
186
+ sharedLayers: SharedLayerAnswers[];
187
+ structures: StructureAnswers[];
188
+ }
175
189
  ): string {
176
190
  const targetList = targets.join(", ");
177
191
  const outputLines = targets
@@ -186,6 +200,38 @@ function manifestTemplate(
186
200
  })
187
201
  .join("\n");
188
202
 
203
+ function yamlPathsBlock(paths: Record<string, string>): string {
204
+ const entries = Object.entries(paths);
205
+ return entries.length > 0
206
+ ? `\n paths:\n${entries.map(([k, v]) => ` ${k}: "${v}"`).join("\n")}`
207
+ : "";
208
+ }
209
+
210
+ let sharedBlock = "";
211
+ if (options.sharedLayers.length > 0) {
212
+ const layers = options.sharedLayers.map((layer) => {
213
+ const tracksLine = layer.tracks.length > 0
214
+ ? `\n tracks: [${layer.tracks.join(", ")}]`
215
+ : "";
216
+ return ` ${layer.name}:
217
+ platforms: [${layer.platforms.join(", ")}]
218
+ language: ${layer.language}
219
+ root: "${layer.root}"
220
+ scope: "${layer.scope}"${tracksLine}${yamlPathsBlock(layer.paths)}`;
221
+ }).join("\n");
222
+ sharedBlock = ` shared:\n${layers}\n`;
223
+ }
224
+
225
+ let structureBlock = "";
226
+ if (options.structures.length > 0) {
227
+ const entries = options.structures.map((s) => {
228
+ const scopeLine = s.scope ? `\n scope: "${s.scope}"` : "";
229
+ return ` ${s.target}:
230
+ root: "${s.root}"${scopeLine}${yamlPathsBlock(s.paths)}`;
231
+ }).join("\n");
232
+ structureBlock = ` structure:\n${entries}\n`;
233
+ }
234
+
189
235
  return `# ${name} — OpenUISpec v0.1
190
236
  spec_version: "0.1"
191
237
 
@@ -218,7 +264,7 @@ ${options.withApi ? ` code_roots:
218
264
  backend: "${options.backendPath}" # Required when api.endpoints are declared
219
265
  ` : ""} output_format:
220
266
  ${outputLines}
221
-
267
+ ${sharedBlock}${structureBlock}
222
268
  data_model: {}
223
269
 
224
270
  api:
@@ -679,6 +725,23 @@ export function extractRulesVersion(filePath: string): string | null {
679
725
 
680
726
  export { getPackageVersion };
681
727
 
728
+ interface SharedLayerAnswers {
729
+ name: string;
730
+ platforms: string[];
731
+ language: string;
732
+ root: string;
733
+ tracks: string[];
734
+ scope: string;
735
+ paths: Record<string, string>;
736
+ }
737
+
738
+ interface StructureAnswers {
739
+ target: string;
740
+ root: string;
741
+ scope: string;
742
+ paths: Record<string, string>;
743
+ }
744
+
682
745
  interface InitOptions {
683
746
  defaults: boolean;
684
747
  quiet: boolean;
@@ -688,6 +751,7 @@ interface InitOptions {
688
751
  withApi?: boolean;
689
752
  backendPath?: string;
690
753
  configureTargets?: boolean;
754
+ withShared?: boolean;
691
755
  }
692
756
 
693
757
  interface InitAnswers {
@@ -697,6 +761,8 @@ interface InitAnswers {
697
761
  withApi: boolean;
698
762
  backendPath: string | null;
699
763
  configureTargets: boolean;
764
+ sharedLayers: SharedLayerAnswers[];
765
+ structures: StructureAnswers[];
700
766
  }
701
767
 
702
768
  function parseTargetsValue(raw: string): string[] {
@@ -752,6 +818,12 @@ function parseInitArgs(argv: string[]): InitOptions {
752
818
  case "--no-configure-targets":
753
819
  options.configureTargets = false;
754
820
  break;
821
+ case "--with-shared":
822
+ options.withShared = true;
823
+ break;
824
+ case "--no-shared":
825
+ options.withShared = false;
826
+ break;
755
827
  default:
756
828
  if (arg.startsWith("--")) {
757
829
  console.error(`Error: Unknown init option: ${arg}`);
@@ -773,9 +845,127 @@ function collectDefaults(): InitAnswers {
773
845
  withApi: true,
774
846
  backendPath: "../backend/",
775
847
  configureTargets: true,
848
+ sharedLayers: [],
849
+ structures: [],
776
850
  };
777
851
  }
778
852
 
853
+ const SHARED_LAYER_DEFAULTS: Record<string, {
854
+ language: string;
855
+ root: string;
856
+ tracks: string[];
857
+ scope: string;
858
+ paths: Record<string, string>;
859
+ structureScope: Record<string, string>;
860
+ }> = {
861
+ kmp: {
862
+ language: "kotlin",
863
+ root: "../shared",
864
+ tracks: [],
865
+ scope: "Business logic, data models, repositories, API clients, view models/stores. No UI rendering.",
866
+ paths: { domain: "commonMain/domain/", features: "commonMain/features/" },
867
+ structureScope: {
868
+ ios: "Pure SwiftUI views and navigation. All business logic comes from the shared layer.",
869
+ android: "Pure Compose UI and navigation. All business logic comes from the shared layer.",
870
+ },
871
+ },
872
+ };
873
+
874
+ function defaultSharedConfig(targets: string[]): {
875
+ sharedLayers: SharedLayerAnswers[];
876
+ structures: StructureAnswers[];
877
+ } {
878
+ const mobilePlatforms = targets.filter((t) => t === "ios" || t === "android");
879
+ if (mobilePlatforms.length < 2) {
880
+ return { sharedLayers: [], structures: [] };
881
+ }
882
+
883
+ const preset = SHARED_LAYER_DEFAULTS.kmp;
884
+ const sharedLayers: SharedLayerAnswers[] = [{
885
+ name: "mobile_common",
886
+ platforms: mobilePlatforms,
887
+ language: preset.language,
888
+ root: preset.root,
889
+ tracks: preset.tracks,
890
+ scope: preset.scope,
891
+ paths: preset.paths,
892
+ }];
893
+
894
+ const structures: StructureAnswers[] = mobilePlatforms.map((t) => ({
895
+ target: t,
896
+ root: preset.root,
897
+ scope: preset.structureScope[t] ?? `Pure ${t} UI. Business logic comes from the shared layer.`,
898
+ paths: { ui: `${t}App/ui/` },
899
+ }));
900
+
901
+ return { sharedLayers, structures };
902
+ }
903
+
904
+ async function collectSharedLayerAnswers(
905
+ rl: ReturnType<typeof createInterface>,
906
+ targets: string[],
907
+ ): Promise<{ sharedLayers: SharedLayerAnswers[]; structures: StructureAnswers[] }> {
908
+ const withShared = await askYesNo(rl, "\nShare code between platforms (e.g. KMP commonMain)?", false);
909
+ if (!withShared) return { sharedLayers: [], structures: [] };
910
+
911
+ const defaults = defaultSharedConfig(targets);
912
+ const defaultLayer = defaults.sharedLayers[0];
913
+ if (!defaultLayer) return { sharedLayers: [], structures: [] };
914
+
915
+ console.log("\n Shared layer defaults (KMP):");
916
+ console.log(` platforms: ${defaultLayer.platforms.join(", ")}`);
917
+ console.log(` language: ${defaultLayer.language}`);
918
+ console.log(` root: ${defaultLayer.root}`);
919
+ console.log(` scope: ${defaultLayer.scope}`);
920
+
921
+ const useDefaults = await askYesNo(rl, " Use these defaults?", true);
922
+ if (useDefaults) return defaults;
923
+
924
+ const layerName = await ask(rl, " Shared layer name", defaultLayer.name);
925
+ const platformsRaw = await askList(rl, " Platforms", targets, defaultLayer.platforms);
926
+ const language = await ask(rl, " Language", defaultLayer.language);
927
+ const root = await ask(rl, " Root path (relative to openuispec.yaml)", defaultLayer.root);
928
+ const scope = await ask(rl, " Scope (what code belongs here)", defaultLayer.scope);
929
+ const wantTracks = await askYesNo(rl, " Enable hash-based drift tracking for this layer?", false);
930
+ const tracksRaw = wantTracks
931
+ ? await askList(
932
+ rl,
933
+ " Tracked spec categories",
934
+ ["manifest", "tokens", "contracts", "screens", "flows", "platform", "locales"],
935
+ ["manifest", "contracts", "flows"]
936
+ )
937
+ : [];
938
+
939
+ const sharedLayers: SharedLayerAnswers[] = [{
940
+ name: layerName,
941
+ platforms: platformsRaw,
942
+ language,
943
+ root,
944
+ tracks: tracksRaw,
945
+ scope,
946
+ paths: defaultLayer.paths,
947
+ }];
948
+
949
+ const structures: StructureAnswers[] = [];
950
+ for (const t of platformsRaw) {
951
+ const defaultStructure = defaults.structures.find((s) => s.target === t);
952
+ const structRoot = await ask(rl, ` ${t} structure root`, defaultStructure?.root ?? root);
953
+ const structScope = await ask(
954
+ rl,
955
+ ` ${t} scope (what code belongs in the ${t} target)`,
956
+ defaultStructure?.scope ?? `Pure ${t} UI rendering.`
957
+ );
958
+ structures.push({
959
+ target: t,
960
+ root: structRoot,
961
+ scope: structScope,
962
+ paths: defaultStructure?.paths ?? { ui: `${t}App/ui/` },
963
+ });
964
+ }
965
+
966
+ return { sharedLayers, structures };
967
+ }
968
+
779
969
  async function collectInteractiveAnswers(rl: ReturnType<typeof createInterface>): Promise<InitAnswers> {
780
970
  const defaults = collectDefaults();
781
971
  const name = await ask(rl, "Project name", defaults.name);
@@ -792,6 +982,7 @@ async function collectInteractiveAnswers(rl: ReturnType<typeof createInterface>)
792
982
  ? await ask(rl, "Backend folder path relative to openuispec.yaml", defaults.backendPath ?? "../backend/")
793
983
  : null;
794
984
  const configureTargets = await askYesNo(rl, "Configure target stacks now?", defaults.configureTargets);
985
+ const { sharedLayers, structures } = await collectSharedLayerAnswers(rl, targets);
795
986
 
796
987
  return {
797
988
  name,
@@ -800,6 +991,8 @@ async function collectInteractiveAnswers(rl: ReturnType<typeof createInterface>)
800
991
  withApi,
801
992
  backendPath,
802
993
  configureTargets,
994
+ sharedLayers,
995
+ structures,
803
996
  };
804
997
  }
805
998
 
@@ -823,6 +1016,8 @@ function collectNonInteractiveAnswers(argv: string[]): InitAnswers {
823
1016
 
824
1017
  const withApi = parsed.withApi ?? defaults.withApi;
825
1018
  const backendPath = withApi ? parsed.backendPath ?? defaults.backendPath : null;
1019
+ const withShared = parsed.withShared ?? false;
1020
+ const { sharedLayers, structures } = withShared ? defaultSharedConfig(targets) : { sharedLayers: [], structures: [] };
826
1021
 
827
1022
  return {
828
1023
  name: parsed.name ?? defaults.name,
@@ -831,6 +1026,8 @@ function collectNonInteractiveAnswers(argv: string[]): InitAnswers {
831
1026
  withApi,
832
1027
  backendPath,
833
1028
  configureTargets: parsed.configureTargets ?? defaults.configureTargets,
1029
+ sharedLayers,
1030
+ structures,
834
1031
  };
835
1032
  }
836
1033
 
@@ -879,6 +1076,8 @@ export async function init(argv: string[] = []): Promise<void> {
879
1076
  manifestTemplate(answers.name, answers.targets, {
880
1077
  withApi: answers.withApi,
881
1078
  backendPath: answers.backendPath,
1079
+ sharedLayers: answers.sharedLayers,
1080
+ structures: answers.structures,
882
1081
  }),
883
1082
  quiet
884
1083
  );
@@ -64,6 +64,42 @@ Paths are relative to `openuispec.yaml`. The `.openuispec-state.json` file recor
64
64
  - `generation.extra_rules` can hold project-wide generation conventions
65
65
  - `drift --snapshot` requires that target output directory to already exist
66
66
 
67
+ ## Shared code layers
68
+
69
+ Projects that share business logic between platforms (e.g. KMP `commonMain`) can declare `generation.shared` to tell AI what code belongs in the shared layer vs platform-specific targets:
70
+
71
+ ```yaml
72
+ generation:
73
+ targets: [ios, android, web]
74
+ shared:
75
+ mobile_common:
76
+ platforms: [ios, android]
77
+ language: kotlin
78
+ root: "../shared"
79
+ scope: "Business logic, data models, repositories, API clients, view models. No UI rendering."
80
+ # tracks: [manifest, contracts] # optional — enables hash-based drift detection for this layer
81
+ paths:
82
+ domain: "commonMain/domain/"
83
+ features: "commonMain/features/"
84
+ structure:
85
+ ios:
86
+ root: "../shared"
87
+ scope: "Pure SwiftUI views and navigation. All business logic comes from the shared layer."
88
+ paths:
89
+ ui: "iosApp/ui/"
90
+ android:
91
+ root: "../shared"
92
+ scope: "Pure Compose UI and navigation. All business logic comes from the shared layer."
93
+ paths:
94
+ ui: "androidApp/ui/"
95
+ ```
96
+
97
+ - **`scope`** (required on shared, optional on structure) — tells AI what code belongs where. This is the primary mechanism for routing generation work between shared and platform layers.
98
+ - **`tracks`** (optional) — when set, enables hash-based drift detection scoped to specific spec categories (`manifest`, `tokens`, `contracts`, `screens`, `flows`, `platform`, `locales`). When omitted, the shared layer relies on `scope` alone.
99
+ - **`structure`** — when present, overrides the heuristic code root discovery for a target. Paths are relative to `root`.
100
+ - Shared layers are not targets — they are tracked alongside targets in `prepare` and `status` output.
101
+ - `openuispec init --with-shared` scaffolds KMP defaults when both ios and android targets are selected.
102
+
67
103
  ## Spec sections overview
68
104
 
69
105
  | Section | What it defines |
@@ -87,6 +87,13 @@
87
87
  - backend generation context
88
88
  - if the manifest declares `api.endpoints`, `generation.code_roots.backend` is required
89
89
  - `prepare` should surface the resolved backend root so AI can inspect backend code when generating API clients
90
+ - Shared code layers (`generation.shared`):
91
+ - when configured, `prepare` includes `shared_layers` in its output with per-layer `scope`, `already_generated`, and `guidance`
92
+ - `scope` tells AI what code belongs in the shared layer vs the platform target — this is the primary routing mechanism
93
+ - optional `tracks` enables hash-based drift detection scoped to specific spec categories
94
+ - `generation.structure` overrides heuristic code root discovery when present, and its `scope` field tells AI what goes in the platform-specific target
95
+ - `suggestCodeRoots` includes shared layer roots and structure paths alongside target output directories
96
+ - generation rules include shared layer and target scope descriptions
90
97
  - Important positioning:
91
98
  - `prepare` does not generate code
92
99
  - `prepare` does not verify code correctness
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
 
@@ -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.12",
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
  };
@@ -152,6 +152,65 @@
152
152
  },
153
153
  "additionalProperties": true
154
154
  }
155
+ },
156
+ "shared": {
157
+ "type": "object",
158
+ "description": "Named shared code layers that span multiple targets (e.g. KMP common modules)",
159
+ "additionalProperties": {
160
+ "type": "object",
161
+ "properties": {
162
+ "platforms": {
163
+ "type": "array",
164
+ "items": { "type": "string" }
165
+ },
166
+ "language": {
167
+ "type": "string"
168
+ },
169
+ "root": {
170
+ "type": "string"
171
+ },
172
+ "paths": {
173
+ "type": "object",
174
+ "additionalProperties": { "type": "string" }
175
+ },
176
+ "tracks": {
177
+ "type": "array",
178
+ "description": "Spec categories this shared layer tracks for drift. Must be explicit — choose which categories actually affect the shared code.",
179
+ "items": {
180
+ "type": "string",
181
+ "enum": ["manifest", "tokens", "contracts", "screens", "flows", "platform", "locales"]
182
+ }
183
+ },
184
+ "scope": {
185
+ "type": "string",
186
+ "description": "What code belongs in this shared layer (e.g. 'Business logic, data models, repositories, API clients, view models. No UI rendering.')"
187
+ }
188
+ },
189
+ "required": ["platforms", "language", "root", "scope"],
190
+ "additionalProperties": false
191
+ }
192
+ },
193
+ "structure": {
194
+ "type": "object",
195
+ "description": "Per-target output directory structure (overrides heuristic code root discovery)",
196
+ "additionalProperties": {
197
+ "type": "object",
198
+ "properties": {
199
+ "root": {
200
+ "type": "string"
201
+ },
202
+ "paths": {
203
+ "type": "object",
204
+ "additionalProperties": { "type": "string" }
205
+ },
206
+ "scope": {
207
+ "type": "string",
208
+ "description": "What code belongs in this target directory (e.g. 'Pure SwiftUI views and navigation. All business logic comes from the shared layer.')"
209
+ }
210
+ },
211
+ "required": ["root"],
212
+ "additionalProperties": false
213
+ }
155
214
  }
156
215
  },
157
216
  "required": [
@@ -5,6 +5,23 @@ import { listFiles, readManifest } from "../drift/index.js";
5
5
 
6
6
  type UnknownRecord = Record<string, unknown>;
7
7
 
8
+ /** Collect all locale keys from a JSON object, supporting both flat dotted keys and nested objects. */
9
+ function collectLocaleKeys(data: unknown, prefix = ""): string[] {
10
+ if (!data || typeof data !== "object" || Array.isArray(data)) return [];
11
+ const keys: string[] = [];
12
+ for (const [key, value] of Object.entries(data as UnknownRecord)) {
13
+ if (key.startsWith("$")) continue; // skip $locale, $direction
14
+ const fullKey = prefix ? `${prefix}.${key}` : key;
15
+ if (typeof value === "string") {
16
+ keys.push(fullKey);
17
+ } else if (typeof value === "object" && value !== null && !Array.isArray(value)) {
18
+ // Nested object — recurse to flatten
19
+ keys.push(...collectLocaleKeys(value, fullKey));
20
+ }
21
+ }
22
+ return keys;
23
+ }
24
+
8
25
  export interface Includes {
9
26
  tokens: string;
10
27
  contracts: string;
@@ -212,7 +229,14 @@ function buildContext(projectDir: string, includes: Includes, manifest: UnknownR
212
229
  for (const filePath of listFiles(localeDir, ".json")) {
213
230
  const localeName = basename(filePath, ".json");
214
231
  const data = loadJson(filePath);
215
- localeFiles.set(localeName, new Set(Object.keys(data)));
232
+ // Support both flat dotted keys ("nav.home": "Home") and nested objects ({ nav: { home: "Home" } })
233
+ const flatKeys = Object.keys(data as object).filter(k => !k.startsWith("$"));
234
+ const hasNestedObjects = flatKeys.some(k => {
235
+ const v = (data as UnknownRecord)[k];
236
+ return typeof v === "object" && v !== null && !Array.isArray(v);
237
+ });
238
+ const allKeys = hasNestedObjects ? collectLocaleKeys(data) : flatKeys;
239
+ localeFiles.set(localeName, new Set(allKeys));
216
240
  }
217
241
 
218
242
  const formatterNames = new Set<string>([
@@ -90,6 +90,19 @@ generation:
90
90
  ios: { language: swift, framework: swiftui }
91
91
  android: { language: kotlin, framework: compose }
92
92
  web: { language: typescript, framework: react }
93
+ # shared: # optional: cross-platform shared code layers
94
+ # mobile_common:
95
+ # platforms: [ios, android]
96
+ # language: kotlin
97
+ # root: "../shared"
98
+ # scope: "Business logic, data models, repositories, view models. No UI."
99
+ # paths:
100
+ # domain: "commonMain/domain/"
101
+ # structure: # optional: per-target directory structure (overrides heuristics)
102
+ # ios:
103
+ # root: "../shared"
104
+ # scope: "Pure SwiftUI views and navigation."
105
+ # paths: { ui: "iosApp/ui/" }
93
106
  ```
94
107
 
95
108
  ---
package/status/index.ts CHANGED
@@ -10,14 +10,19 @@
10
10
  import { existsSync } from "node:fs";
11
11
  import {
12
12
  computeDrift,
13
+ computeSharedDrift,
13
14
  discoverTargets,
14
15
  explainDrift,
15
16
  findProjectDir,
16
17
  formatBaseline,
18
+ hasDriftChanges,
17
19
  readOutputDirs,
18
20
  readProjectName,
21
+ readSharedLayers,
22
+ readSharedLayerState,
19
23
  resolveOutputDir,
20
24
  stateFilePath,
25
+ type SharedLayerConfig,
21
26
  type StateFile,
22
27
  } from "../drift/index.js";
23
28
 
@@ -45,9 +50,21 @@ interface TargetStatus {
45
50
  note?: string;
46
51
  }
47
52
 
53
+ interface SharedLayerStatus {
54
+ name: string;
55
+ platforms: string[];
56
+ root: string;
57
+ snapshot: boolean;
58
+ snapshot_at: string | null;
59
+ generated_by_target: string | null;
60
+ has_drift: boolean;
61
+ status: "up to date" | "behind" | "needs generation";
62
+ }
63
+
48
64
  export interface StatusResult {
49
65
  project: string;
50
66
  targets: TargetStatus[];
67
+ shared_layers?: SharedLayerStatus[];
51
68
  }
52
69
 
53
70
  function configuredTargets(projectDir: string): string[] {
@@ -141,6 +158,38 @@ function buildTargetStatus(cwd: string, projectDir: string, projectName: string,
141
158
  };
142
159
  }
143
160
 
161
+ function buildSharedLayerStatus(projectDir: string, layer: SharedLayerConfig): SharedLayerStatus {
162
+ const state = readSharedLayerState(layer);
163
+ if (!state) {
164
+ return {
165
+ name: layer.name,
166
+ platforms: layer.platforms,
167
+ root: layer.root,
168
+ snapshot: false,
169
+ snapshot_at: null,
170
+ generated_by_target: null,
171
+ has_drift: false,
172
+ status: "needs generation",
173
+ };
174
+ }
175
+
176
+ let hasDrift = false;
177
+ if (layer.tracks.length > 0) {
178
+ hasDrift = hasDriftChanges(computeSharedDrift(projectDir, layer).drift);
179
+ }
180
+
181
+ return {
182
+ name: layer.name,
183
+ platforms: layer.platforms,
184
+ root: layer.root,
185
+ snapshot: true,
186
+ snapshot_at: state.snapshot_at,
187
+ generated_by_target: state.generated_by_target,
188
+ has_drift: hasDrift,
189
+ status: hasDrift ? "behind" : "up to date",
190
+ };
191
+ }
192
+
144
193
  export function buildStatusResult(cwd: string = process.cwd()): StatusResult {
145
194
  const projectDir = findProjectDir(cwd);
146
195
  const projectName = readProjectName(projectDir);
@@ -148,9 +197,15 @@ export function buildStatusResult(cwd: string = process.cwd()): StatusResult {
148
197
  buildTargetStatus(cwd, projectDir, projectName, target)
149
198
  );
150
199
 
200
+ const sharedLayers = readSharedLayers(projectDir);
201
+ const sharedLayerStatuses = sharedLayers.length > 0
202
+ ? sharedLayers.map((layer) => buildSharedLayerStatus(projectDir, layer))
203
+ : undefined;
204
+
151
205
  return {
152
206
  project: projectName,
153
207
  targets,
208
+ ...(sharedLayerStatuses ? { shared_layers: sharedLayerStatuses } : {}),
154
209
  };
155
210
  }
156
211
 
@@ -182,6 +237,21 @@ function printReport(result: StatusResult): void {
182
237
  console.log(` next: ${target.recommended_next_step}`);
183
238
  console.log("");
184
239
  }
240
+
241
+ if (result.shared_layers && result.shared_layers.length > 0) {
242
+ console.log("Shared Layers");
243
+ console.log("─────────────");
244
+ for (const layer of result.shared_layers) {
245
+ console.log(`${layer.name} (${layer.platforms.join(", ")})`);
246
+ console.log(` root: ${layer.root}`);
247
+ console.log(` snapshot: ${layer.snapshot ? layer.snapshot_at : "missing"}`);
248
+ if (layer.generated_by_target) {
249
+ console.log(` generated by: ${layer.generated_by_target}`);
250
+ }
251
+ console.log(` status: ${layer.status}`);
252
+ console.log("");
253
+ }
254
+ }
185
255
  }
186
256
 
187
257
  export function runStatus(argv: string[]): void {