openuispec 0.1.24 → 0.1.27
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 +44 -2
- package/cli/index.ts +21 -3
- package/cli/init.ts +56 -17
- package/docs/implementation-notes.md +115 -0
- package/docs/release-notes-v0.1.26.md +64 -0
- package/docs/release-notes-v0.1.27.md +28 -0
- package/drift/index.ts +375 -18
- package/examples/todo-orbit/AGENTS.md +11 -4
- package/examples/todo-orbit/CLAUDE.md +11 -4
- package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/java/uz/rsteam/todoorbit/ui/components/CommonComponents.kt +69 -18
- package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/java/uz/rsteam/todoorbit/ui/screens/TasksScreen.kt +5 -0
- package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/java/uz/rsteam/todoorbit/ui/sheets/EditorSheets.kt +5 -2
- package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/res/values/strings.xml +1 -0
- package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/res/values-ru/strings.xml +1 -0
- package/examples/todo-orbit/generated/ios/Todo Orbit/Resources/en.lproj/Localizable.strings +1 -0
- package/examples/todo-orbit/generated/ios/Todo Orbit/Resources/ru.lproj/Localizable.strings +1 -0
- package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Flows/RecurringRuleSheet.swift +3 -0
- package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Flows/TaskEditorSheet.swift +1 -0
- package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Screens/SettingsView.swift +2 -0
- package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Screens/TasksHomeView.swift +1 -0
- package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Support/AppModel.swift +1 -1
- package/examples/todo-orbit/generated/web/Todo Orbit/src/App.tsx +59 -6
- package/examples/todo-orbit/generated/web/Todo Orbit/src/styles.css +40 -0
- package/examples/todo-orbit/openuispec/README.md +24 -131
- package/examples/todo-orbit/openuispec/flows/create_recurring_rule.yaml +3 -0
- package/examples/todo-orbit/openuispec/flows/create_task.yaml +1 -0
- package/examples/todo-orbit/openuispec/flows/edit_task.yaml +1 -0
- package/examples/todo-orbit/openuispec/locales/en.json +1 -0
- package/examples/todo-orbit/openuispec/locales/ru.json +1 -0
- package/examples/todo-orbit/openuispec/screens/task_detail.yaml +1 -0
- package/examples/todo-orbit/openuispec/tokens/icons.yaml +6 -0
- package/package.json +6 -1
- package/prepare/index.ts +391 -0
- package/schema/semantic-lint.ts +592 -0
- package/schema/validate.ts +8 -9
- package/status/index.ts +187 -0
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import { basename, join, resolve } from "node:path";
|
|
3
|
+
import YAML from "yaml";
|
|
4
|
+
|
|
5
|
+
type UnknownRecord = Record<string, unknown>;
|
|
6
|
+
|
|
7
|
+
export interface Includes {
|
|
8
|
+
tokens: string;
|
|
9
|
+
contracts: string;
|
|
10
|
+
screens: string;
|
|
11
|
+
flows: string;
|
|
12
|
+
platform: string;
|
|
13
|
+
locales: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface UsageLint {
|
|
17
|
+
path: string;
|
|
18
|
+
message: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface SemanticContext {
|
|
22
|
+
localeFiles: Map<string, Set<string>>;
|
|
23
|
+
formatterNames: Set<string>;
|
|
24
|
+
mapperNames: Set<string>;
|
|
25
|
+
contractNames: Set<string>;
|
|
26
|
+
tokenRefs: Set<string>;
|
|
27
|
+
iconRefs: Set<string>;
|
|
28
|
+
iconVariantSuffixes: string[];
|
|
29
|
+
screenRefs: Set<string>;
|
|
30
|
+
flowRefs: Set<string>;
|
|
31
|
+
apiRefs: Set<string>;
|
|
32
|
+
defaultLocale: string | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const BUILTIN_CONTRACTS = new Set([
|
|
36
|
+
"nav_container",
|
|
37
|
+
"surface",
|
|
38
|
+
"action_trigger",
|
|
39
|
+
"input_field",
|
|
40
|
+
"data_display",
|
|
41
|
+
"collection",
|
|
42
|
+
"feedback",
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
const BUILTIN_FORMATTERS = new Set([
|
|
46
|
+
"date",
|
|
47
|
+
"time",
|
|
48
|
+
"datetime",
|
|
49
|
+
"date_relative",
|
|
50
|
+
"currency",
|
|
51
|
+
"number",
|
|
52
|
+
"percent",
|
|
53
|
+
"boolean",
|
|
54
|
+
"duration",
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
function loadJson(filePath: string): Record<string, unknown> {
|
|
58
|
+
return JSON.parse(readFileSync(filePath, "utf-8")) as Record<string, unknown>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function loadYaml(filePath: string): unknown {
|
|
62
|
+
return YAML.parse(readFileSync(filePath, "utf-8"));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function loadData(filePath: string): unknown {
|
|
66
|
+
return filePath.endsWith(".json") ? loadJson(filePath) : loadYaml(filePath);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function listFiles(dir: string, ext: string): string[] {
|
|
70
|
+
try {
|
|
71
|
+
return readdirSync(dir)
|
|
72
|
+
.filter((file) => file.endsWith(ext))
|
|
73
|
+
.sort()
|
|
74
|
+
.map((file) => join(dir, file));
|
|
75
|
+
} catch {
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isRecord(value: unknown): value is UnknownRecord {
|
|
81
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function getSingleRootValue(data: unknown): unknown {
|
|
85
|
+
if (!isRecord(data)) return undefined;
|
|
86
|
+
const values = Object.values(data);
|
|
87
|
+
return values.length === 1 ? values[0] : undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function rootKeys(filePath: string): string[] {
|
|
91
|
+
const data = loadData(filePath);
|
|
92
|
+
if (!isRecord(data)) return [];
|
|
93
|
+
return Object.keys(data);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function addNestedObjectKeys(prefix: string, value: unknown, refs: Set<string>): void {
|
|
97
|
+
if (!isRecord(value)) return;
|
|
98
|
+
for (const [key, nested] of Object.entries(value)) {
|
|
99
|
+
const next = `${prefix}.${key}`;
|
|
100
|
+
refs.add(next);
|
|
101
|
+
addNestedObjectKeys(next, nested, refs);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function collectTokenRefs(root: string, value: unknown, refs: Set<string>): void {
|
|
106
|
+
if (!root || !isRecord(value)) return;
|
|
107
|
+
refs.add(root);
|
|
108
|
+
|
|
109
|
+
if (root === "spacing") {
|
|
110
|
+
for (const group of ["scale", "aliases"]) {
|
|
111
|
+
const groupValue = value[group];
|
|
112
|
+
if (!isRecord(groupValue)) continue;
|
|
113
|
+
for (const [key, nested] of Object.entries(groupValue)) {
|
|
114
|
+
const ref = `${root}.${key}`;
|
|
115
|
+
refs.add(ref);
|
|
116
|
+
addNestedObjectKeys(ref, nested, refs);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (root === "typography") {
|
|
123
|
+
for (const group of ["scale", "font_family"]) {
|
|
124
|
+
const groupValue = value[group];
|
|
125
|
+
if (!isRecord(groupValue)) continue;
|
|
126
|
+
for (const [key, nested] of Object.entries(groupValue)) {
|
|
127
|
+
const ref = group === "scale" ? `${root}.${key}` : `${root}.${group}.${key}`;
|
|
128
|
+
refs.add(ref);
|
|
129
|
+
addNestedObjectKeys(ref, nested, refs);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (root === "elevation") {
|
|
136
|
+
for (const [key, nested] of Object.entries(value)) {
|
|
137
|
+
const ref = `${root}.${key}`;
|
|
138
|
+
refs.add(ref);
|
|
139
|
+
addNestedObjectKeys(ref, nested, refs);
|
|
140
|
+
}
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (root === "color") {
|
|
145
|
+
addNestedObjectKeys(root, value, refs);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function collectApiRefs(prefix: string, value: unknown, refs: Set<string>): void {
|
|
150
|
+
if (!isRecord(value)) return;
|
|
151
|
+
|
|
152
|
+
const looksLikeEndpoint = typeof value.method === "string" || typeof value.path === "string";
|
|
153
|
+
if (looksLikeEndpoint && prefix) {
|
|
154
|
+
refs.add(`api.${prefix}`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
for (const [key, nested] of Object.entries(value)) {
|
|
158
|
+
const nextPrefix = prefix ? `${prefix}.${key}` : key;
|
|
159
|
+
collectApiRefs(nextPrefix, nested, refs);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function iconNameWithVariant(name: string, variant: string): string {
|
|
164
|
+
if (!variant) return name;
|
|
165
|
+
if (variant.startsWith("_")) return `${name}${variant}`;
|
|
166
|
+
return `${name}_${variant}`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function collectIconRefs(filePath: string): { refs: Set<string>; suffixes: string[] } {
|
|
170
|
+
const refs = new Set<string>();
|
|
171
|
+
const suffixes: string[] = [];
|
|
172
|
+
const data = loadData(filePath);
|
|
173
|
+
if (!isRecord(data) || !isRecord(data.icons)) return { refs, suffixes };
|
|
174
|
+
|
|
175
|
+
const icons = data.icons as UnknownRecord;
|
|
176
|
+
const variantSuffixes = isRecord(icons.variants?.suffixes) ? icons.variants.suffixes : {};
|
|
177
|
+
for (const suffix of Object.keys(variantSuffixes)) {
|
|
178
|
+
if (suffix.trim()) suffixes.push(suffix);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const registerEntry = (name: string, value: unknown) => {
|
|
182
|
+
refs.add(name);
|
|
183
|
+
if (!isRecord(value)) return;
|
|
184
|
+
if (Array.isArray(value.variants)) {
|
|
185
|
+
for (const variant of value.variants) {
|
|
186
|
+
if (typeof variant === "string" && variant.trim()) {
|
|
187
|
+
refs.add(iconNameWithVariant(name, variant));
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const registry = isRecord(icons.registry) ? icons.registry : {};
|
|
194
|
+
for (const groupValue of Object.values(registry)) {
|
|
195
|
+
if (!isRecord(groupValue)) continue;
|
|
196
|
+
for (const [name, value] of Object.entries(groupValue)) {
|
|
197
|
+
registerEntry(name, value);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const custom = isRecord(icons.custom) ? icons.custom : {};
|
|
202
|
+
for (const namespaceValue of Object.values(custom)) {
|
|
203
|
+
if (!isRecord(namespaceValue)) continue;
|
|
204
|
+
for (const [name, value] of Object.entries(namespaceValue)) {
|
|
205
|
+
registerEntry(name, value);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (typeof icons.fallback?.missing_icon === "string") {
|
|
210
|
+
refs.add(icons.fallback.missing_icon);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return { refs, suffixes };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function buildContext(projectDir: string, includes: Includes): SemanticContext {
|
|
217
|
+
const manifestPath = join(projectDir, "openuispec.yaml");
|
|
218
|
+
const manifest = loadYaml(manifestPath) as UnknownRecord;
|
|
219
|
+
|
|
220
|
+
const localeDir = resolve(projectDir, includes.locales);
|
|
221
|
+
const localeFiles = new Map<string, Set<string>>();
|
|
222
|
+
for (const filePath of listFiles(localeDir, ".json")) {
|
|
223
|
+
const localeName = basename(filePath, ".json");
|
|
224
|
+
const data = loadJson(filePath);
|
|
225
|
+
localeFiles.set(localeName, new Set(Object.keys(data)));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const formatterNames = new Set<string>([
|
|
229
|
+
...BUILTIN_FORMATTERS,
|
|
230
|
+
...(isRecord(manifest.formatters) ? Object.keys(manifest.formatters) : []),
|
|
231
|
+
]);
|
|
232
|
+
const mapperNames = new Set<string>(isRecord(manifest.mappers) ? Object.keys(manifest.mappers) : []);
|
|
233
|
+
|
|
234
|
+
const contractNames = new Set<string>(BUILTIN_CONTRACTS);
|
|
235
|
+
const contractsDir = resolve(projectDir, includes.contracts);
|
|
236
|
+
for (const filePath of listFiles(contractsDir, ".yaml")) {
|
|
237
|
+
for (const key of rootKeys(filePath)) {
|
|
238
|
+
contractNames.add(key);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const tokenRefs = new Set<string>();
|
|
243
|
+
const tokensDir = resolve(projectDir, includes.tokens);
|
|
244
|
+
for (const filePath of listFiles(tokensDir, ".yaml")) {
|
|
245
|
+
const data = loadData(filePath);
|
|
246
|
+
if (!isRecord(data)) continue;
|
|
247
|
+
for (const [root, value] of Object.entries(data)) {
|
|
248
|
+
collectTokenRefs(root, value, tokenRefs);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const iconsPath = join(tokensDir, "icons.yaml");
|
|
253
|
+
const iconData = existsSync(iconsPath)
|
|
254
|
+
? collectIconRefs(iconsPath)
|
|
255
|
+
: { refs: new Set<string>(), suffixes: [] };
|
|
256
|
+
|
|
257
|
+
const screenRefs = new Set<string>();
|
|
258
|
+
const screensDir = resolve(projectDir, includes.screens);
|
|
259
|
+
for (const filePath of listFiles(screensDir, ".yaml")) {
|
|
260
|
+
for (const key of rootKeys(filePath)) {
|
|
261
|
+
screenRefs.add(`screens/${key}`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const flowRefs = new Set<string>();
|
|
266
|
+
const flowsDir = resolve(projectDir, includes.flows);
|
|
267
|
+
for (const filePath of listFiles(flowsDir, ".yaml")) {
|
|
268
|
+
for (const key of rootKeys(filePath)) {
|
|
269
|
+
flowRefs.add(`flows/${key}`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const apiRefs = new Set<string>();
|
|
274
|
+
if (isRecord(manifest.api) && isRecord(manifest.api.endpoints)) {
|
|
275
|
+
collectApiRefs("", manifest.api.endpoints, apiRefs);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const defaultLocale =
|
|
279
|
+
isRecord(manifest.i18n) && typeof manifest.i18n.default_locale === "string"
|
|
280
|
+
? manifest.i18n.default_locale
|
|
281
|
+
: localeFiles.keys().next().value ?? null;
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
localeFiles,
|
|
285
|
+
formatterNames,
|
|
286
|
+
mapperNames,
|
|
287
|
+
contractNames,
|
|
288
|
+
tokenRefs,
|
|
289
|
+
iconRefs: iconData.refs,
|
|
290
|
+
iconVariantSuffixes: iconData.suffixes,
|
|
291
|
+
screenRefs,
|
|
292
|
+
flowRefs,
|
|
293
|
+
apiRefs,
|
|
294
|
+
defaultLocale,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function missingLocalesForKey(context: SemanticContext, key: string): string[] {
|
|
299
|
+
const missing: string[] = [];
|
|
300
|
+
for (const [locale, keys] of context.localeFiles.entries()) {
|
|
301
|
+
if (!keys.has(key)) missing.push(locale);
|
|
302
|
+
}
|
|
303
|
+
return missing.sort();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function validateLocaleRef(value: string, path: string, context: SemanticContext, errors: UsageLint[]): void {
|
|
307
|
+
const ref = value.slice(3);
|
|
308
|
+
if (!ref) return;
|
|
309
|
+
|
|
310
|
+
if (!ref.includes("{")) {
|
|
311
|
+
const missing = missingLocalesForKey(context, ref);
|
|
312
|
+
if (missing.length > 0) {
|
|
313
|
+
errors.push({
|
|
314
|
+
path,
|
|
315
|
+
message: `locale key "${ref}" is missing in locale(s): ${missing.join(", ")}`,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const staticPrefix = ref.split("{", 1)[0];
|
|
322
|
+
if (staticPrefix) {
|
|
323
|
+
const hasMatch = Array.from(context.localeFiles.values()).some((keys) =>
|
|
324
|
+
Array.from(keys).some((key) => key.startsWith(staticPrefix))
|
|
325
|
+
);
|
|
326
|
+
if (!hasMatch) {
|
|
327
|
+
errors.push({
|
|
328
|
+
path,
|
|
329
|
+
message: `dynamic locale key prefix "${staticPrefix}" does not match any locale entry`,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
for (const formatter of extractFormatterRefs(ref)) {
|
|
335
|
+
if (!context.formatterNames.has(formatter)) {
|
|
336
|
+
errors.push({
|
|
337
|
+
path,
|
|
338
|
+
message: `unknown formatter "${formatter}" used inside locale key reference`,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function extractFormatterRefs(value: string): string[] {
|
|
345
|
+
const refs = new Set<string>();
|
|
346
|
+
const regex = /\|\s*format:([A-Za-z0-9_.-]+)/g;
|
|
347
|
+
let match: RegExpExecArray | null;
|
|
348
|
+
while ((match = regex.exec(value)) !== null) {
|
|
349
|
+
const name = match[1].split(".", 1)[0];
|
|
350
|
+
if (name) refs.add(name);
|
|
351
|
+
}
|
|
352
|
+
return Array.from(refs);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function extractMapperRefs(value: string): string[] {
|
|
356
|
+
const refs = new Set<string>();
|
|
357
|
+
const regex = /\|\s*map:([A-Za-z0-9_.-]+)/g;
|
|
358
|
+
let match: RegExpExecArray | null;
|
|
359
|
+
while ((match = regex.exec(value)) !== null) {
|
|
360
|
+
if (match[1]) refs.add(match[1]);
|
|
361
|
+
}
|
|
362
|
+
return Array.from(refs);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function maybeTokenRef(value: string): boolean {
|
|
366
|
+
return /^(color|typography|spacing|elevation)\./.test(value);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function isDynamicReference(value: string): boolean {
|
|
370
|
+
return value.includes("{") || value.includes("}");
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function isKnownIconRef(value: string, context: SemanticContext): boolean {
|
|
374
|
+
if (context.iconRefs.has(value)) return true;
|
|
375
|
+
for (const suffix of context.iconVariantSuffixes) {
|
|
376
|
+
if (value.endsWith(suffix)) {
|
|
377
|
+
const base = value.slice(0, -suffix.length);
|
|
378
|
+
if (context.iconRefs.has(base)) return true;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function validateStringValue(
|
|
385
|
+
key: string | null,
|
|
386
|
+
value: string,
|
|
387
|
+
path: string,
|
|
388
|
+
context: SemanticContext,
|
|
389
|
+
formIds: Set<string>,
|
|
390
|
+
errors: UsageLint[],
|
|
391
|
+
options: { validateTokens: boolean }
|
|
392
|
+
): void {
|
|
393
|
+
if (value.startsWith("$t:")) {
|
|
394
|
+
validateLocaleRef(value, path, context, errors);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
for (const formatter of extractFormatterRefs(value)) {
|
|
398
|
+
if (!context.formatterNames.has(formatter)) {
|
|
399
|
+
errors.push({ path, message: `unknown formatter "${formatter}"` });
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
for (const mapper of extractMapperRefs(value)) {
|
|
404
|
+
if (!context.mapperNames.has(mapper)) {
|
|
405
|
+
errors.push({ path, message: `unknown mapper "${mapper}"` });
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (options.validateTokens && maybeTokenRef(value) && !isDynamicReference(value) && !context.tokenRefs.has(value)) {
|
|
410
|
+
errors.push({ path, message: `unknown token reference "${value}"` });
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if ((key === "contract" || key === "item_contract") && !context.contractNames.has(value)) {
|
|
414
|
+
errors.push({ path, message: `unknown contract "${value}"` });
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (
|
|
418
|
+
(key === "icon" || key === "icon_active") &&
|
|
419
|
+
!isDynamicReference(value) &&
|
|
420
|
+
!value.includes(".") &&
|
|
421
|
+
!isKnownIconRef(value, context)
|
|
422
|
+
) {
|
|
423
|
+
errors.push({ path, message: `unknown icon "${value}"` });
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if ((key === "source" || key === "endpoint") && value.startsWith("api.") && !context.apiRefs.has(value)) {
|
|
427
|
+
errors.push({ path, message: `unknown API reference "${value}"` });
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (key === "destination" && value !== "$back") {
|
|
431
|
+
if (value.startsWith("screens/") && !context.screenRefs.has(value)) {
|
|
432
|
+
errors.push({ path, message: `unknown screen destination "${value}"` });
|
|
433
|
+
} else if (value.startsWith("flows/") && !context.flowRefs.has(value)) {
|
|
434
|
+
errors.push({ path, message: `unknown flow destination "${value}"` });
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (key === "target" && value.startsWith("screens/")) {
|
|
439
|
+
const screenTarget = value.split(".", 1)[0];
|
|
440
|
+
if (!context.screenRefs.has(screenTarget)) {
|
|
441
|
+
errors.push({ path, message: `unknown screen target "${value}"` });
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (key === "form_id" && !formIds.has(value)) {
|
|
446
|
+
errors.push({ path, message: `unknown form_id "${value}" in submit action` });
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function collectFormIds(value: unknown, formIds: Set<string>): void {
|
|
451
|
+
if (Array.isArray(value)) {
|
|
452
|
+
for (const item of value) collectFormIds(item, formIds);
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
if (!isRecord(value)) return;
|
|
456
|
+
|
|
457
|
+
if (typeof value.form_id === "string" && value.form_id.trim()) {
|
|
458
|
+
formIds.add(value.form_id);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
for (const nested of Object.values(value)) {
|
|
462
|
+
collectFormIds(nested, formIds);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function traverse(
|
|
467
|
+
value: unknown,
|
|
468
|
+
path: string,
|
|
469
|
+
context: SemanticContext,
|
|
470
|
+
formIds: Set<string>,
|
|
471
|
+
errors: UsageLint[],
|
|
472
|
+
key: string | null = null,
|
|
473
|
+
options: { validateTokens: boolean }
|
|
474
|
+
): void {
|
|
475
|
+
if (Array.isArray(value)) {
|
|
476
|
+
for (const [index, item] of value.entries()) {
|
|
477
|
+
traverse(item, `${path}/${index}`, context, formIds, errors, key, options);
|
|
478
|
+
}
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if (typeof value === "string") {
|
|
483
|
+
validateStringValue(key, value, path, context, formIds, errors, options);
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
if (!isRecord(value)) return;
|
|
488
|
+
|
|
489
|
+
if (
|
|
490
|
+
key === "icon" &&
|
|
491
|
+
typeof value.ref === "string" &&
|
|
492
|
+
!isDynamicReference(value.ref) &&
|
|
493
|
+
!value.ref.includes(".") &&
|
|
494
|
+
!isKnownIconRef(value.ref, context)
|
|
495
|
+
) {
|
|
496
|
+
errors.push({ path: `${path}/ref`, message: `unknown icon "${value.ref}"` });
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (key === "sections") {
|
|
500
|
+
for (const [index, entry] of Object.entries(value)) {
|
|
501
|
+
if (typeof entry === "string" && entry.startsWith("screens/") && !context.screenRefs.has(entry)) {
|
|
502
|
+
errors.push({
|
|
503
|
+
path: `${path}/${index}`,
|
|
504
|
+
message: `unknown screen section reference "${entry}"`,
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
for (const [childKey, nested] of Object.entries(value)) {
|
|
511
|
+
traverse(nested, `${path}/${childKey}`, context, formIds, errors, childKey, options);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function lintFile(
|
|
516
|
+
filePath: string,
|
|
517
|
+
context: SemanticContext,
|
|
518
|
+
options: { validateTokens: boolean }
|
|
519
|
+
): UsageLint[] {
|
|
520
|
+
const data = loadData(filePath);
|
|
521
|
+
const formIds = new Set<string>();
|
|
522
|
+
collectFormIds(data, formIds);
|
|
523
|
+
|
|
524
|
+
const errors: UsageLint[] = [];
|
|
525
|
+
traverse(data, basename(filePath), context, formIds, errors, null, options);
|
|
526
|
+
return errors;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function lintLocaleCoverage(context: SemanticContext): UsageLint[] {
|
|
530
|
+
const errors: UsageLint[] = [];
|
|
531
|
+
if (context.localeFiles.size <= 1) return errors;
|
|
532
|
+
|
|
533
|
+
const baselineLocale =
|
|
534
|
+
(context.defaultLocale && context.localeFiles.has(context.defaultLocale) && context.defaultLocale) ||
|
|
535
|
+
Array.from(context.localeFiles.keys())[0];
|
|
536
|
+
if (!baselineLocale) return errors;
|
|
537
|
+
|
|
538
|
+
const baselineKeys = context.localeFiles.get(baselineLocale) ?? new Set<string>();
|
|
539
|
+
for (const [locale, keys] of context.localeFiles.entries()) {
|
|
540
|
+
if (locale === baselineLocale) continue;
|
|
541
|
+
|
|
542
|
+
for (const key of baselineKeys) {
|
|
543
|
+
if (!keys.has(key)) {
|
|
544
|
+
errors.push({
|
|
545
|
+
path: `${locale}.json`,
|
|
546
|
+
message: `missing locale key "${key}" from baseline locale "${baselineLocale}"`,
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
return errors;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function printSemanticErrors(label: string, errors: UsageLint[]): number {
|
|
556
|
+
if (errors.length === 0) return 0;
|
|
557
|
+
const previewLimit = 10;
|
|
558
|
+
|
|
559
|
+
console.log(` FAIL ${label} (${errors.length} semantic error(s))`);
|
|
560
|
+
for (const error of errors.slice(0, previewLimit)) {
|
|
561
|
+
console.log(` [${error.path}] ${error.message}`);
|
|
562
|
+
}
|
|
563
|
+
if (errors.length > previewLimit) {
|
|
564
|
+
console.log(` ... and ${errors.length - previewLimit} more`);
|
|
565
|
+
}
|
|
566
|
+
return errors.length;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
export function runSemanticLint(projectDir: string, includes: Includes): number {
|
|
570
|
+
const context = buildContext(projectDir, includes);
|
|
571
|
+
let total = 0;
|
|
572
|
+
const contractsDir = resolve(projectDir, includes.contracts);
|
|
573
|
+
|
|
574
|
+
total += printSemanticErrors("locales", lintLocaleCoverage(context));
|
|
575
|
+
|
|
576
|
+
const files = [
|
|
577
|
+
join(projectDir, "openuispec.yaml"),
|
|
578
|
+
...listFiles(resolve(projectDir, includes.screens), ".yaml"),
|
|
579
|
+
...listFiles(resolve(projectDir, includes.flows), ".yaml"),
|
|
580
|
+
...listFiles(resolve(projectDir, includes.platform), ".yaml"),
|
|
581
|
+
...listFiles(resolve(projectDir, includes.contracts), ".yaml"),
|
|
582
|
+
];
|
|
583
|
+
|
|
584
|
+
for (const filePath of files) {
|
|
585
|
+
total += printSemanticErrors(
|
|
586
|
+
basename(filePath),
|
|
587
|
+
lintFile(filePath, context, { validateTokens: !filePath.startsWith(contractsDir) })
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
return total;
|
|
592
|
+
}
|
package/schema/validate.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { fileURLToPath } from "node:url";
|
|
|
14
14
|
import { createRequire } from "node:module";
|
|
15
15
|
import type { ErrorObject } from "ajv";
|
|
16
16
|
import YAML from "yaml";
|
|
17
|
+
import { runSemanticLint, type Includes } from "./semantic-lint.js";
|
|
17
18
|
|
|
18
19
|
const require = createRequire(import.meta.url);
|
|
19
20
|
const Ajv2020 = require("ajv/dist/2020") as typeof import("ajv").default;
|
|
@@ -409,15 +410,6 @@ function validateFile(
|
|
|
409
410
|
|
|
410
411
|
// ── includes resolution ──────────────────────────────────────────────
|
|
411
412
|
|
|
412
|
-
interface Includes {
|
|
413
|
-
tokens: string;
|
|
414
|
-
contracts: string;
|
|
415
|
-
screens: string;
|
|
416
|
-
flows: string;
|
|
417
|
-
platform: string;
|
|
418
|
-
locales: string;
|
|
419
|
-
}
|
|
420
|
-
|
|
421
413
|
const DEFAULT_INCLUDES: Includes = {
|
|
422
414
|
tokens: "./tokens/",
|
|
423
415
|
contracts: "./contracts/",
|
|
@@ -558,6 +550,13 @@ const GROUPS: Record<string, ValidationGroup> = {
|
|
|
558
550
|
return errors;
|
|
559
551
|
},
|
|
560
552
|
},
|
|
553
|
+
|
|
554
|
+
semantic: {
|
|
555
|
+
label: "Semantic",
|
|
556
|
+
run(_ajv, projectDir, includes) {
|
|
557
|
+
return runSemanticLint(projectDir, includes);
|
|
558
|
+
},
|
|
559
|
+
},
|
|
561
560
|
};
|
|
562
561
|
|
|
563
562
|
// ── project resolution ───────────────────────────────────────────────
|