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 +17 -0
- package/cli/index.ts +17 -1
- package/cli/init.ts +201 -2
- package/docs/file-formats.md +36 -0
- package/docs/implementation-notes.md +7 -0
- package/drift/index.ts +241 -26
- package/mcp-server/index.ts +25 -4
- package/package.json +1 -1
- package/prepare/index.ts +139 -18
- package/schema/openuispec.schema.json +59 -0
- package/schema/semantic-lint.ts +25 -1
- package/spec/openuispec-v0.1.md +13 -0
- package/status/index.ts +70 -0
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) {
|
|
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: {
|
|
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
|
);
|
package/docs/file-formats.md
CHANGED
|
@@ -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
|
-
|
|
170
|
-
|
|
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 "
|
|
173
|
-
if (dir === "screens") return "
|
|
174
|
-
if (dir === "flows") return "
|
|
175
|
-
if (dir === "platform") return "
|
|
176
|
-
if (dir === "locales") return "
|
|
177
|
-
if (dir === "contracts") return "
|
|
178
|
-
return "
|
|
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
|
|
package/mcp-server/index.ts
CHANGED
|
@@ -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
|
-
|
|
714
|
-
|
|
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
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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": [
|
package/schema/semantic-lint.ts
CHANGED
|
@@ -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
|
-
|
|
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>([
|
package/spec/openuispec-v0.1.md
CHANGED
|
@@ -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 {
|