openuispec 0.2.11 → 0.2.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- 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/examples/taskflow/README.md +1 -1
- package/mcp-server/index.ts +25 -4
- package/package.json +1 -1
- package/prepare/index.ts +139 -18
- package/schema/custom-contract.schema.json +2 -2
- package/schema/openuispec.schema.json +71 -6
- package/schema/semantic-lint.ts +25 -1
- package/spec/openuispec-v0.1.md +22 -9
- package/status/index.ts +70 -0
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
|
|
|
@@ -87,7 +87,7 @@ taskflow/
|
|
|
87
87
|
|
|
88
88
|
Pass the entire `taskflow/` directory as context to an AI code generator with the prompt:
|
|
89
89
|
|
|
90
|
-
> Generate a native {ios|android|web} application from this OpenUISpec. Follow all contract
|
|
90
|
+
> Generate a native {ios|android|web} application from this OpenUISpec. Follow all contract UI states, apply token ranges for the target platform, and implement the navigation flows as defined. Use the platform adaptation file for target-specific overrides.
|
|
91
91
|
|
|
92
92
|
The AI should produce:
|
|
93
93
|
1. Compilable platform code (Swift/Kotlin/TypeScript)
|
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
|
};
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
},
|
|
31
31
|
"states": {
|
|
32
32
|
"type": "object",
|
|
33
|
-
"description": "
|
|
33
|
+
"description": "UI interaction states (e.g. idle, active, disabled, loading, error) — each key is a state name. These are visual/interaction states, not business logic states.",
|
|
34
34
|
"additionalProperties": {
|
|
35
35
|
"$ref": "https://openuispec.rsteam.uz/schema/custom-contract.schema.json#/$defs/state_def"
|
|
36
36
|
}
|
|
@@ -165,7 +165,7 @@
|
|
|
165
165
|
},
|
|
166
166
|
"state_def": {
|
|
167
167
|
"type": "object",
|
|
168
|
-
"description": "A single
|
|
168
|
+
"description": "A single UI interaction state",
|
|
169
169
|
"properties": {
|
|
170
170
|
"semantic": {
|
|
171
171
|
"type": "string"
|