openuispec 0.2.18 → 0.2.20
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 -10
- package/dist/check/audit.js +392 -0
- package/dist/check/index.js +216 -0
- package/dist/cli/configure-target.js +391 -0
- package/dist/cli/index.js +510 -0
- package/dist/cli/init.js +1047 -0
- package/dist/drift/index.js +903 -0
- package/dist/mcp-server/index.js +886 -0
- package/dist/mcp-server/preview-render.js +1761 -0
- package/dist/mcp-server/preview.js +233 -0
- package/dist/mcp-server/screenshot-android.js +458 -0
- package/dist/mcp-server/screenshot-ios.js +639 -0
- package/dist/mcp-server/screenshot-shared.js +180 -0
- package/dist/mcp-server/screenshot.js +459 -0
- package/dist/prepare/index.js +1216 -0
- package/dist/runtime/package-paths.js +33 -0
- package/dist/schema/semantic-lint.js +564 -0
- package/dist/schema/validate.js +689 -0
- package/dist/status/index.js +194 -0
- package/docs/images/how-it-works.svg +56 -0
- package/docs/images/workflows.svg +76 -0
- package/package.json +12 -13
- package/check/audit.ts +0 -426
- package/check/index.ts +0 -320
- package/cli/configure-target.ts +0 -523
- package/cli/index.ts +0 -537
- package/cli/init.ts +0 -1253
- package/docs/images/how-it-works-dark.png +0 -0
- package/docs/images/how-it-works-light.png +0 -0
- package/docs/images/workflows-dark.png +0 -0
- package/docs/images/workflows-light.png +0 -0
- package/drift/index.ts +0 -1165
- package/mcp-server/index.ts +0 -1041
- package/mcp-server/preview-render.ts +0 -1922
- package/mcp-server/preview.ts +0 -292
- package/mcp-server/screenshot-android.ts +0 -621
- package/mcp-server/screenshot-ios.ts +0 -753
- package/mcp-server/screenshot-shared.ts +0 -237
- package/mcp-server/screenshot.ts +0 -563
- package/prepare/index.ts +0 -1530
- package/schema/semantic-lint.ts +0 -692
- package/schema/validate.ts +0 -870
- package/scripts/regenerate-previews.ts +0 -136
- package/scripts/take-all-screenshots.ts +0 -507
- package/status/index.ts +0 -275
package/schema/semantic-lint.ts
DELETED
|
@@ -1,692 +0,0 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
-
import { basename, join, resolve } from "node:path";
|
|
3
|
-
import YAML from "yaml";
|
|
4
|
-
import { listFiles, readManifest } from "../drift/index.js";
|
|
5
|
-
|
|
6
|
-
type UnknownRecord = Record<string, unknown>;
|
|
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
|
-
|
|
25
|
-
export interface Includes {
|
|
26
|
-
tokens: string;
|
|
27
|
-
contracts: string;
|
|
28
|
-
components: string;
|
|
29
|
-
screens: string;
|
|
30
|
-
flows: string;
|
|
31
|
-
platform: string;
|
|
32
|
-
locales: string;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export interface UsageLint {
|
|
36
|
-
path: string;
|
|
37
|
-
message: string;
|
|
38
|
-
severity?: "error" | "warning";
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
interface SemanticContext {
|
|
42
|
-
manifest: unknown;
|
|
43
|
-
localeFiles: Map<string, Set<string>>;
|
|
44
|
-
formatterNames: Set<string>;
|
|
45
|
-
mapperNames: Set<string>;
|
|
46
|
-
contractNames: Set<string>;
|
|
47
|
-
componentNames: Set<string>;
|
|
48
|
-
tokenRefs: Set<string>;
|
|
49
|
-
iconRefs: Set<string>;
|
|
50
|
-
iconVariantSuffixes: string[];
|
|
51
|
-
screenRefs: Set<string>;
|
|
52
|
-
flowRefs: Set<string>;
|
|
53
|
-
apiRefs: Set<string>;
|
|
54
|
-
defaultLocale: string | null;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const BUILTIN_CONTRACTS = new Set([
|
|
58
|
-
"nav_container",
|
|
59
|
-
"surface",
|
|
60
|
-
"action_trigger",
|
|
61
|
-
"input_field",
|
|
62
|
-
"data_display",
|
|
63
|
-
"collection",
|
|
64
|
-
"feedback",
|
|
65
|
-
]);
|
|
66
|
-
|
|
67
|
-
const BUILTIN_FORMATTERS = new Set([
|
|
68
|
-
"date",
|
|
69
|
-
"time",
|
|
70
|
-
"datetime",
|
|
71
|
-
"date_relative",
|
|
72
|
-
"currency",
|
|
73
|
-
"number",
|
|
74
|
-
"percent",
|
|
75
|
-
"boolean",
|
|
76
|
-
"duration",
|
|
77
|
-
]);
|
|
78
|
-
|
|
79
|
-
function loadJson(filePath: string): Record<string, unknown> {
|
|
80
|
-
return JSON.parse(readFileSync(filePath, "utf-8")) as Record<string, unknown>;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function loadYaml(filePath: string): unknown {
|
|
84
|
-
return YAML.parse(readFileSync(filePath, "utf-8"));
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function loadData(filePath: string): unknown {
|
|
88
|
-
return filePath.endsWith(".json") ? loadJson(filePath) : loadYaml(filePath);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function isRecord(value: unknown): value is UnknownRecord {
|
|
92
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function getSingleRootValue(data: unknown): unknown {
|
|
96
|
-
if (!isRecord(data)) return undefined;
|
|
97
|
-
const values = Object.values(data);
|
|
98
|
-
return values.length === 1 ? values[0] : undefined;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function rootKeys(filePath: string): string[] {
|
|
102
|
-
const data = loadData(filePath);
|
|
103
|
-
if (!isRecord(data)) return [];
|
|
104
|
-
return Object.keys(data);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function addNestedObjectKeys(prefix: string, value: unknown, refs: Set<string>): void {
|
|
108
|
-
if (!isRecord(value)) return;
|
|
109
|
-
for (const [key, nested] of Object.entries(value)) {
|
|
110
|
-
const next = `${prefix}.${key}`;
|
|
111
|
-
refs.add(next);
|
|
112
|
-
addNestedObjectKeys(next, nested, refs);
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
function collectTokenRefs(root: string, value: unknown, refs: Set<string>): void {
|
|
117
|
-
if (!root || !isRecord(value)) return;
|
|
118
|
-
refs.add(root);
|
|
119
|
-
|
|
120
|
-
if (root === "spacing") {
|
|
121
|
-
for (const group of ["scale", "aliases"]) {
|
|
122
|
-
const groupValue = value[group];
|
|
123
|
-
if (!isRecord(groupValue)) continue;
|
|
124
|
-
for (const [key, nested] of Object.entries(groupValue)) {
|
|
125
|
-
const ref = `${root}.${key}`;
|
|
126
|
-
refs.add(ref);
|
|
127
|
-
addNestedObjectKeys(ref, nested, refs);
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
return;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
if (root === "typography") {
|
|
134
|
-
for (const group of ["scale", "font_family"]) {
|
|
135
|
-
const groupValue = value[group];
|
|
136
|
-
if (!isRecord(groupValue)) continue;
|
|
137
|
-
for (const [key, nested] of Object.entries(groupValue)) {
|
|
138
|
-
const ref = group === "scale" ? `${root}.${key}` : `${root}.${group}.${key}`;
|
|
139
|
-
refs.add(ref);
|
|
140
|
-
addNestedObjectKeys(ref, nested, refs);
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
return;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
if (root === "elevation") {
|
|
147
|
-
for (const [key, nested] of Object.entries(value)) {
|
|
148
|
-
const ref = `${root}.${key}`;
|
|
149
|
-
refs.add(ref);
|
|
150
|
-
addNestedObjectKeys(ref, nested, refs);
|
|
151
|
-
}
|
|
152
|
-
return;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
if (root === "color") {
|
|
156
|
-
addNestedObjectKeys(root, value, refs);
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
function collectApiRefs(prefix: string, value: unknown, refs: Set<string>): void {
|
|
161
|
-
if (!isRecord(value)) return;
|
|
162
|
-
|
|
163
|
-
const looksLikeEndpoint = typeof value.method === "string" || typeof value.path === "string";
|
|
164
|
-
if (looksLikeEndpoint && prefix) {
|
|
165
|
-
refs.add(`api.${prefix}`);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
for (const [key, nested] of Object.entries(value)) {
|
|
169
|
-
const nextPrefix = prefix ? `${prefix}.${key}` : key;
|
|
170
|
-
collectApiRefs(nextPrefix, nested, refs);
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
function iconNameWithVariant(name: string, variant: string): string {
|
|
175
|
-
if (!variant) return name;
|
|
176
|
-
if (variant.startsWith("_")) return `${name}${variant}`;
|
|
177
|
-
return `${name}_${variant}`;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
function collectIconRefs(filePath: string): { refs: Set<string>; suffixes: string[] } {
|
|
181
|
-
const refs = new Set<string>();
|
|
182
|
-
const suffixes: string[] = [];
|
|
183
|
-
const data = loadData(filePath);
|
|
184
|
-
if (!isRecord(data) || !isRecord(data.icons)) return { refs, suffixes };
|
|
185
|
-
|
|
186
|
-
const icons = data.icons as UnknownRecord;
|
|
187
|
-
const variants = isRecord(icons.variants) ? icons.variants : {};
|
|
188
|
-
const variantSuffixes = isRecord(variants.suffixes) ? variants.suffixes : {};
|
|
189
|
-
for (const suffix of Object.keys(variantSuffixes)) {
|
|
190
|
-
if (suffix.trim()) suffixes.push(suffix);
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
const registerEntry = (name: string, value: unknown) => {
|
|
194
|
-
refs.add(name);
|
|
195
|
-
if (!isRecord(value)) return;
|
|
196
|
-
if (Array.isArray(value.variants)) {
|
|
197
|
-
for (const variant of value.variants) {
|
|
198
|
-
if (typeof variant === "string" && variant.trim()) {
|
|
199
|
-
refs.add(iconNameWithVariant(name, variant));
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
};
|
|
204
|
-
|
|
205
|
-
const registry = isRecord(icons.registry) ? icons.registry : {};
|
|
206
|
-
for (const groupValue of Object.values(registry)) {
|
|
207
|
-
if (!isRecord(groupValue)) continue;
|
|
208
|
-
for (const [name, value] of Object.entries(groupValue)) {
|
|
209
|
-
registerEntry(name, value);
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
const custom = isRecord(icons.custom) ? icons.custom : {};
|
|
214
|
-
for (const namespaceValue of Object.values(custom)) {
|
|
215
|
-
if (!isRecord(namespaceValue)) continue;
|
|
216
|
-
for (const [name, value] of Object.entries(namespaceValue)) {
|
|
217
|
-
registerEntry(name, value);
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
const fallback = isRecord(icons.fallback) ? icons.fallback : {};
|
|
222
|
-
if (typeof fallback.missing_icon === "string") {
|
|
223
|
-
refs.add(fallback.missing_icon);
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
return { refs, suffixes };
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
function buildContext(projectDir: string, includes: Includes, manifest: UnknownRecord): SemanticContext {
|
|
230
|
-
|
|
231
|
-
const localeDir = resolve(projectDir, includes.locales);
|
|
232
|
-
const localeFiles = new Map<string, Set<string>>();
|
|
233
|
-
for (const filePath of listFiles(localeDir, ".json")) {
|
|
234
|
-
const localeName = basename(filePath, ".json");
|
|
235
|
-
const data = loadJson(filePath);
|
|
236
|
-
// Support both flat dotted keys ("nav.home": "Home") and nested objects ({ nav: { home: "Home" } })
|
|
237
|
-
const flatKeys = Object.keys(data as object).filter(k => !k.startsWith("$"));
|
|
238
|
-
const hasNestedObjects = flatKeys.some(k => {
|
|
239
|
-
const v = (data as UnknownRecord)[k];
|
|
240
|
-
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
241
|
-
});
|
|
242
|
-
const allKeys = hasNestedObjects ? collectLocaleKeys(data) : flatKeys;
|
|
243
|
-
localeFiles.set(localeName, new Set(allKeys));
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
const formatterNames = new Set<string>([
|
|
247
|
-
...BUILTIN_FORMATTERS,
|
|
248
|
-
...(isRecord(manifest.formatters) ? Object.keys(manifest.formatters) : []),
|
|
249
|
-
]);
|
|
250
|
-
const mapperNames = new Set<string>(isRecord(manifest.mappers) ? Object.keys(manifest.mappers) : []);
|
|
251
|
-
|
|
252
|
-
const contractNames = new Set<string>(BUILTIN_CONTRACTS);
|
|
253
|
-
const contractsDir = resolve(projectDir, includes.contracts);
|
|
254
|
-
for (const filePath of listFiles(contractsDir, ".yaml")) {
|
|
255
|
-
for (const key of rootKeys(filePath)) {
|
|
256
|
-
contractNames.add(key);
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
// Components are also valid references in screen sections (via "component:" key)
|
|
261
|
-
const componentNames = new Set<string>();
|
|
262
|
-
const componentsDir = resolve(projectDir, includes.components);
|
|
263
|
-
for (const filePath of listFiles(componentsDir, ".yaml")) {
|
|
264
|
-
for (const key of rootKeys(filePath)) {
|
|
265
|
-
componentNames.add(key);
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
const tokenRefs = new Set<string>();
|
|
270
|
-
const tokensDir = resolve(projectDir, includes.tokens);
|
|
271
|
-
for (const filePath of listFiles(tokensDir, ".yaml")) {
|
|
272
|
-
const data = loadData(filePath);
|
|
273
|
-
if (!isRecord(data)) continue;
|
|
274
|
-
for (const [root, value] of Object.entries(data)) {
|
|
275
|
-
collectTokenRefs(root, value, tokenRefs);
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
const iconsPath = join(tokensDir, "icons.yaml");
|
|
280
|
-
const iconData = existsSync(iconsPath)
|
|
281
|
-
? collectIconRefs(iconsPath)
|
|
282
|
-
: { refs: new Set<string>(), suffixes: [] };
|
|
283
|
-
|
|
284
|
-
const screenRefs = new Set<string>();
|
|
285
|
-
const screensDir = resolve(projectDir, includes.screens);
|
|
286
|
-
for (const filePath of listFiles(screensDir, ".yaml")) {
|
|
287
|
-
for (const key of rootKeys(filePath)) {
|
|
288
|
-
screenRefs.add(`screens/${key}`);
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
const flowRefs = new Set<string>();
|
|
293
|
-
const flowsDir = resolve(projectDir, includes.flows);
|
|
294
|
-
for (const filePath of listFiles(flowsDir, ".yaml")) {
|
|
295
|
-
for (const key of rootKeys(filePath)) {
|
|
296
|
-
flowRefs.add(`flows/${key}`);
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
const apiRefs = new Set<string>();
|
|
301
|
-
if (isRecord(manifest.api) && isRecord(manifest.api.endpoints)) {
|
|
302
|
-
collectApiRefs("", manifest.api.endpoints, apiRefs);
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
const defaultLocale =
|
|
306
|
-
isRecord(manifest.i18n) && typeof manifest.i18n.default_locale === "string"
|
|
307
|
-
? manifest.i18n.default_locale
|
|
308
|
-
: localeFiles.keys().next().value ?? null;
|
|
309
|
-
|
|
310
|
-
return {
|
|
311
|
-
manifest,
|
|
312
|
-
localeFiles,
|
|
313
|
-
formatterNames,
|
|
314
|
-
mapperNames,
|
|
315
|
-
contractNames,
|
|
316
|
-
componentNames,
|
|
317
|
-
tokenRefs,
|
|
318
|
-
iconRefs: iconData.refs,
|
|
319
|
-
iconVariantSuffixes: iconData.suffixes,
|
|
320
|
-
screenRefs,
|
|
321
|
-
flowRefs,
|
|
322
|
-
apiRefs,
|
|
323
|
-
defaultLocale,
|
|
324
|
-
};
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
function missingLocalesForKey(context: SemanticContext, key: string): string[] {
|
|
328
|
-
const missing: string[] = [];
|
|
329
|
-
for (const [locale, keys] of context.localeFiles.entries()) {
|
|
330
|
-
if (!keys.has(key)) missing.push(locale);
|
|
331
|
-
}
|
|
332
|
-
return missing.sort();
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
function validateLocaleRef(value: string, path: string, context: SemanticContext, errors: UsageLint[]): void {
|
|
336
|
-
const ref = value.slice(3);
|
|
337
|
-
if (!ref) return;
|
|
338
|
-
|
|
339
|
-
if (!ref.includes("{")) {
|
|
340
|
-
const missing = missingLocalesForKey(context, ref);
|
|
341
|
-
if (missing.length > 0) {
|
|
342
|
-
errors.push({
|
|
343
|
-
path,
|
|
344
|
-
message: `locale key "${ref}" is missing in locale(s): ${missing.join(", ")}`,
|
|
345
|
-
});
|
|
346
|
-
}
|
|
347
|
-
return;
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
const staticPrefix = ref.split("{", 1)[0];
|
|
351
|
-
if (staticPrefix) {
|
|
352
|
-
const hasMatch = Array.from(context.localeFiles.values()).some((keys) =>
|
|
353
|
-
Array.from(keys).some((key) => key.startsWith(staticPrefix))
|
|
354
|
-
);
|
|
355
|
-
if (!hasMatch) {
|
|
356
|
-
errors.push({
|
|
357
|
-
path,
|
|
358
|
-
message: `dynamic locale key prefix "${staticPrefix}" does not match any locale entry`,
|
|
359
|
-
});
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
for (const formatter of extractFormatterRefs(ref)) {
|
|
364
|
-
if (!context.formatterNames.has(formatter)) {
|
|
365
|
-
errors.push({
|
|
366
|
-
path,
|
|
367
|
-
message: `unknown formatter "${formatter}" used inside locale key reference`,
|
|
368
|
-
});
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
function extractFormatterRefs(value: string): string[] {
|
|
374
|
-
const refs = new Set<string>();
|
|
375
|
-
const regex = /\|\s*format:([A-Za-z0-9_.-]+)/g;
|
|
376
|
-
let match: RegExpExecArray | null;
|
|
377
|
-
while ((match = regex.exec(value)) !== null) {
|
|
378
|
-
const name = match[1].split(".", 1)[0];
|
|
379
|
-
if (name) refs.add(name);
|
|
380
|
-
}
|
|
381
|
-
return Array.from(refs);
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
function extractMapperRefs(value: string): string[] {
|
|
385
|
-
const refs = new Set<string>();
|
|
386
|
-
const regex = /\|\s*map:([A-Za-z0-9_.-]+)/g;
|
|
387
|
-
let match: RegExpExecArray | null;
|
|
388
|
-
while ((match = regex.exec(value)) !== null) {
|
|
389
|
-
if (match[1]) refs.add(match[1]);
|
|
390
|
-
}
|
|
391
|
-
return Array.from(refs);
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
function maybeTokenRef(value: string): boolean {
|
|
395
|
-
return /^(color|typography|spacing|elevation)\./.test(value);
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
function isDynamicReference(value: string): boolean {
|
|
399
|
-
return value.includes("{") || value.includes("}");
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
function isKnownIconRef(value: string, context: SemanticContext): boolean {
|
|
403
|
-
if (context.iconRefs.has(value)) return true;
|
|
404
|
-
for (const suffix of context.iconVariantSuffixes) {
|
|
405
|
-
if (value.endsWith(suffix)) {
|
|
406
|
-
const base = value.slice(0, -suffix.length);
|
|
407
|
-
if (context.iconRefs.has(base)) return true;
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
|
-
return false;
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
function validateStringValue(
|
|
414
|
-
key: string | null,
|
|
415
|
-
value: string,
|
|
416
|
-
path: string,
|
|
417
|
-
context: SemanticContext,
|
|
418
|
-
formIds: Set<string>,
|
|
419
|
-
errors: UsageLint[],
|
|
420
|
-
options: { validateTokens: boolean }
|
|
421
|
-
): void {
|
|
422
|
-
if (value.startsWith("$t:")) {
|
|
423
|
-
validateLocaleRef(value, path, context, errors);
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
for (const formatter of extractFormatterRefs(value)) {
|
|
427
|
-
if (!context.formatterNames.has(formatter)) {
|
|
428
|
-
errors.push({ path, message: `unknown formatter "${formatter}"` });
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
for (const mapper of extractMapperRefs(value)) {
|
|
433
|
-
if (!context.mapperNames.has(mapper)) {
|
|
434
|
-
errors.push({ path, message: `unknown mapper "${mapper}"` });
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
if (options.validateTokens && maybeTokenRef(value) && !isDynamicReference(value) && !context.tokenRefs.has(value)) {
|
|
439
|
-
errors.push({ path, message: `unknown token reference "${value}"` });
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
if ((key === "contract" || key === "item_contract") && !context.contractNames.has(value)) {
|
|
443
|
-
errors.push({ path, message: `unknown contract "${value}"` });
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
if (key === "component" && !path.includes("platform_mapping") && !context.componentNames.has(value)) {
|
|
447
|
-
errors.push({ path, message: `unknown component "${value}"` });
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
if (
|
|
451
|
-
(key === "icon" || key === "icon_active") &&
|
|
452
|
-
!isDynamicReference(value) &&
|
|
453
|
-
!value.includes(".") &&
|
|
454
|
-
!isKnownIconRef(value, context)
|
|
455
|
-
) {
|
|
456
|
-
errors.push({ path, message: `unknown icon "${value}"` });
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
if ((key === "source" || key === "endpoint") && value.startsWith("api.") && !context.apiRefs.has(value)) {
|
|
460
|
-
errors.push({ path, message: `unknown API reference "${value}"` });
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
if (key === "destination" && value !== "$back") {
|
|
464
|
-
if (value.startsWith("screens/") && !context.screenRefs.has(value)) {
|
|
465
|
-
errors.push({ path, message: `unknown screen destination "${value}"` });
|
|
466
|
-
} else if (value.startsWith("flows/") && !context.flowRefs.has(value)) {
|
|
467
|
-
errors.push({ path, message: `unknown flow destination "${value}"` });
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
if (key === "target" && value.startsWith("screens/")) {
|
|
472
|
-
const screenTarget = value.split(".", 1)[0];
|
|
473
|
-
if (!context.screenRefs.has(screenTarget)) {
|
|
474
|
-
errors.push({ path, message: `unknown screen target "${value}"` });
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
if (key === "form_id" && !formIds.has(value)) {
|
|
479
|
-
errors.push({ path, message: `unknown form_id "${value}" in submit action` });
|
|
480
|
-
}
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
function collectFormIds(value: unknown, formIds: Set<string>): void {
|
|
484
|
-
if (Array.isArray(value)) {
|
|
485
|
-
for (const item of value) collectFormIds(item, formIds);
|
|
486
|
-
return;
|
|
487
|
-
}
|
|
488
|
-
if (!isRecord(value)) return;
|
|
489
|
-
|
|
490
|
-
if (typeof value.form_id === "string" && value.form_id.trim()) {
|
|
491
|
-
formIds.add(value.form_id);
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
for (const nested of Object.values(value)) {
|
|
495
|
-
collectFormIds(nested, formIds);
|
|
496
|
-
}
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
function traverse(
|
|
500
|
-
value: unknown,
|
|
501
|
-
path: string,
|
|
502
|
-
context: SemanticContext,
|
|
503
|
-
formIds: Set<string>,
|
|
504
|
-
errors: UsageLint[],
|
|
505
|
-
key: string | null = null,
|
|
506
|
-
options: { validateTokens: boolean }
|
|
507
|
-
): void {
|
|
508
|
-
if (Array.isArray(value)) {
|
|
509
|
-
for (const [index, item] of value.entries()) {
|
|
510
|
-
traverse(item, `${path}/${index}`, context, formIds, errors, key, options);
|
|
511
|
-
}
|
|
512
|
-
return;
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
if (typeof value === "string") {
|
|
516
|
-
validateStringValue(key, value, path, context, formIds, errors, options);
|
|
517
|
-
return;
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
if (!isRecord(value)) return;
|
|
521
|
-
|
|
522
|
-
if (
|
|
523
|
-
key === "icon" &&
|
|
524
|
-
typeof value.ref === "string" &&
|
|
525
|
-
!isDynamicReference(value.ref) &&
|
|
526
|
-
!value.ref.includes(".") &&
|
|
527
|
-
!isKnownIconRef(value.ref, context)
|
|
528
|
-
) {
|
|
529
|
-
errors.push({ path: `${path}/ref`, message: `unknown icon "${value.ref}"` });
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
if (key === "sections") {
|
|
533
|
-
for (const [index, entry] of Object.entries(value)) {
|
|
534
|
-
if (typeof entry === "string" && entry.startsWith("screens/") && !context.screenRefs.has(entry)) {
|
|
535
|
-
errors.push({
|
|
536
|
-
path: `${path}/${index}`,
|
|
537
|
-
message: `unknown screen section reference "${entry}"`,
|
|
538
|
-
});
|
|
539
|
-
}
|
|
540
|
-
}
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
for (const [childKey, nested] of Object.entries(value)) {
|
|
544
|
-
traverse(nested, `${path}/${childKey}`, context, formIds, errors, childKey, options);
|
|
545
|
-
}
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
function lintFile(
|
|
549
|
-
filePath: string,
|
|
550
|
-
context: SemanticContext,
|
|
551
|
-
options: { validateTokens: boolean }
|
|
552
|
-
): UsageLint[] {
|
|
553
|
-
const data = loadData(filePath);
|
|
554
|
-
const formIds = new Set<string>();
|
|
555
|
-
collectFormIds(data, formIds);
|
|
556
|
-
|
|
557
|
-
const errors: UsageLint[] = [];
|
|
558
|
-
traverse(data, basename(filePath), context, formIds, errors, null, options);
|
|
559
|
-
return errors;
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
function lintLocaleCoverage(context: SemanticContext): UsageLint[] {
|
|
563
|
-
const errors: UsageLint[] = [];
|
|
564
|
-
if (context.localeFiles.size <= 1) return errors;
|
|
565
|
-
|
|
566
|
-
const baselineLocale =
|
|
567
|
-
(context.defaultLocale && context.localeFiles.has(context.defaultLocale) && context.defaultLocale) ||
|
|
568
|
-
Array.from(context.localeFiles.keys())[0];
|
|
569
|
-
if (!baselineLocale) return errors;
|
|
570
|
-
|
|
571
|
-
const baselineKeys = context.localeFiles.get(baselineLocale) ?? new Set<string>();
|
|
572
|
-
for (const [locale, keys] of context.localeFiles.entries()) {
|
|
573
|
-
if (locale === baselineLocale) continue;
|
|
574
|
-
|
|
575
|
-
for (const key of baselineKeys) {
|
|
576
|
-
if (!keys.has(key)) {
|
|
577
|
-
errors.push({
|
|
578
|
-
path: `${locale}.json`,
|
|
579
|
-
message: `missing locale key "${key}" from baseline locale "${baselineLocale}"`,
|
|
580
|
-
});
|
|
581
|
-
}
|
|
582
|
-
}
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
return errors;
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
function lintManifestGenerationContext(projectDir: string, manifest: unknown): UsageLint[] {
|
|
589
|
-
if (!isRecord(manifest)) return [];
|
|
590
|
-
|
|
591
|
-
// code_roots.backend is optional — it's a hint for AI generation, not a hard requirement
|
|
592
|
-
const generation = isRecord(manifest.generation) ? manifest.generation : {};
|
|
593
|
-
const codeRoots = isRecord(generation.code_roots) ? generation.code_roots : null;
|
|
594
|
-
const backendRoot = codeRoots && typeof codeRoots.backend === "string" ? codeRoots.backend.trim() : "";
|
|
595
|
-
|
|
596
|
-
if (!backendRoot) return [];
|
|
597
|
-
|
|
598
|
-
const resolvedBackendRoot = resolve(projectDir, backendRoot);
|
|
599
|
-
if (!existsSync(resolvedBackendRoot)) {
|
|
600
|
-
return [{
|
|
601
|
-
path: "openuispec.yaml",
|
|
602
|
-
severity: "warning",
|
|
603
|
-
message: `generation.code_roots.backend points to a missing folder: ${backendRoot}`,
|
|
604
|
-
}];
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
return [];
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
function printSemanticErrors(label: string, errors: UsageLint[]): number {
|
|
611
|
-
if (errors.length === 0) return 0;
|
|
612
|
-
const previewLimit = 10;
|
|
613
|
-
|
|
614
|
-
const realErrors = errors.filter((e) => e.severity !== "warning");
|
|
615
|
-
const warnings = errors.filter((e) => e.severity === "warning");
|
|
616
|
-
|
|
617
|
-
if (realErrors.length > 0) {
|
|
618
|
-
console.log(` FAIL ${label} (${realErrors.length} semantic error(s))`);
|
|
619
|
-
for (const error of realErrors.slice(0, previewLimit)) {
|
|
620
|
-
console.log(` [${error.path}] ${error.message}`);
|
|
621
|
-
}
|
|
622
|
-
if (realErrors.length > previewLimit) {
|
|
623
|
-
console.log(` ... and ${realErrors.length - previewLimit} more`);
|
|
624
|
-
}
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
for (const warning of warnings) {
|
|
628
|
-
console.log(` WARN ${label}: ${warning.message}`);
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
return realErrors.length;
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
export function collectSemanticLint(projectDir: string, includes: Includes): UsageLint[] {
|
|
635
|
-
const manifest = readManifest(projectDir) as UnknownRecord;
|
|
636
|
-
const context = buildContext(projectDir, includes, manifest);
|
|
637
|
-
const contractsDir = resolve(projectDir, includes.contracts);
|
|
638
|
-
const componentsDir = resolve(projectDir, includes.components);
|
|
639
|
-
|
|
640
|
-
const allErrors: UsageLint[] = [
|
|
641
|
-
...lintLocaleCoverage(context),
|
|
642
|
-
...lintManifestGenerationContext(projectDir, context.manifest),
|
|
643
|
-
];
|
|
644
|
-
|
|
645
|
-
const files = [
|
|
646
|
-
join(projectDir, "openuispec.yaml"),
|
|
647
|
-
...listFiles(resolve(projectDir, includes.screens), ".yaml"),
|
|
648
|
-
...listFiles(resolve(projectDir, includes.flows), ".yaml"),
|
|
649
|
-
...listFiles(resolve(projectDir, includes.platform), ".yaml"),
|
|
650
|
-
...listFiles(resolve(projectDir, includes.contracts), ".yaml"),
|
|
651
|
-
...listFiles(componentsDir, ".yaml"),
|
|
652
|
-
];
|
|
653
|
-
|
|
654
|
-
for (const filePath of files) {
|
|
655
|
-
const isContractOrComponent = filePath.startsWith(contractsDir) || filePath.startsWith(componentsDir);
|
|
656
|
-
allErrors.push(
|
|
657
|
-
...lintFile(filePath, context, { validateTokens: !isContractOrComponent })
|
|
658
|
-
);
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
return allErrors;
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
export function runSemanticLint(projectDir: string, includes: Includes): number {
|
|
665
|
-
const manifest = readManifest(projectDir) as UnknownRecord;
|
|
666
|
-
const context = buildContext(projectDir, includes, manifest);
|
|
667
|
-
let total = 0;
|
|
668
|
-
const contractsDir = resolve(projectDir, includes.contracts);
|
|
669
|
-
const componentsDir = resolve(projectDir, includes.components);
|
|
670
|
-
|
|
671
|
-
total += printSemanticErrors("locales", lintLocaleCoverage(context));
|
|
672
|
-
total += printSemanticErrors("openuispec.yaml", lintManifestGenerationContext(projectDir, context.manifest));
|
|
673
|
-
|
|
674
|
-
const files = [
|
|
675
|
-
join(projectDir, "openuispec.yaml"),
|
|
676
|
-
...listFiles(resolve(projectDir, includes.screens), ".yaml"),
|
|
677
|
-
...listFiles(resolve(projectDir, includes.flows), ".yaml"),
|
|
678
|
-
...listFiles(resolve(projectDir, includes.platform), ".yaml"),
|
|
679
|
-
...listFiles(resolve(projectDir, includes.contracts), ".yaml"),
|
|
680
|
-
...listFiles(componentsDir, ".yaml"),
|
|
681
|
-
];
|
|
682
|
-
|
|
683
|
-
for (const filePath of files) {
|
|
684
|
-
const isContractOrComponent = filePath.startsWith(contractsDir) || filePath.startsWith(componentsDir);
|
|
685
|
-
total += printSemanticErrors(
|
|
686
|
-
basename(filePath),
|
|
687
|
-
lintFile(filePath, context, { validateTokens: !isContractOrComponent })
|
|
688
|
-
);
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
return total;
|
|
692
|
-
}
|