@vmz/vmz 0.0.0 → 0.0.2
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 +48 -2
- package/bin/vmz.js +4 -0
- package/dist/application-cmd.d.ts +22 -0
- package/dist/application-cmd.js +348 -0
- package/dist/bundler-adapter.d.ts +64 -0
- package/dist/bundler-adapter.js +111 -0
- package/dist/cli.d.ts +15 -0
- package/dist/cli.js +370 -0
- package/dist/dev-session.d.ts +35 -0
- package/dist/dev-session.js +290 -0
- package/dist/document-build.d.ts +99 -0
- package/dist/document-build.js +274 -0
- package/dist/document-check.d.ts +44 -0
- package/dist/document-check.js +246 -0
- package/dist/document-cmd.d.ts +9 -0
- package/dist/document-cmd.js +147 -0
- package/dist/document-designs.d.ts +9 -0
- package/dist/document-designs.js +126 -0
- package/dist/document-enrich.d.ts +23 -0
- package/dist/document-enrich.js +234 -0
- package/dist/document-evidence.d.ts +49 -0
- package/dist/document-evidence.js +501 -0
- package/dist/document-integrate.d.ts +35 -0
- package/dist/document-integrate.js +89 -0
- package/dist/document-interactive.d.ts +69 -0
- package/dist/document-interactive.js +255 -0
- package/dist/document-locale.d.ts +31 -0
- package/dist/document-locale.js +59 -0
- package/dist/document-markdown.d.ts +13 -0
- package/dist/document-markdown.js +39 -0
- package/dist/document-scan.d.ts +21 -0
- package/dist/document-scan.js +151 -0
- package/dist/document-schema.d.ts +87 -0
- package/dist/document-schema.js +88 -0
- package/dist/explain-cmd.d.ts +5 -0
- package/dist/explain-cmd.js +123 -0
- package/dist/index.d.ts +808 -0
- package/dist/index.js +569 -0
- package/dist/locale-check.d.ts +106 -0
- package/dist/locale-check.js +737 -0
- package/dist/locale-cmd.d.ts +5 -0
- package/dist/locale-cmd.js +443 -0
- package/dist/locale-delivery.d.ts +298 -0
- package/dist/locale-delivery.js +444 -0
- package/dist/locale-router.d.ts +207 -0
- package/dist/locale-router.js +508 -0
- package/dist/locale-runtime.d.ts +406 -0
- package/dist/locale-runtime.js +542 -0
- package/dist/locale-schema.d.ts +9 -0
- package/dist/locale-schema.js +10 -0
- package/dist/locale-tooling.d.ts +118 -0
- package/dist/locale-tooling.js +358 -0
- package/dist/log.d.ts +19 -0
- package/dist/log.js +42 -0
- package/dist/packages.d.ts +27 -0
- package/dist/packages.js +147 -0
- package/dist/plugin-host.d.ts +30 -0
- package/dist/plugin-host.js +370 -0
- package/dist/refactor-cmd.d.ts +8 -0
- package/dist/refactor-cmd.js +156 -0
- package/dist/resolve.d.ts +25 -0
- package/dist/resolve.js +56 -0
- package/dist/test-cmd.d.ts +9 -0
- package/dist/test-cmd.js +343 -0
- package/dist/test-compile.d.ts +2 -0
- package/dist/test-compile.js +3 -0
- package/dist/test-discover.d.ts +2 -0
- package/dist/test-discover.js +3 -0
- package/dist/test-logic.d.ts +2 -0
- package/dist/test-logic.js +3 -0
- package/dist/test-protocol.d.ts +2 -0
- package/dist/test-protocol.js +3 -0
- package/dist/watch-diff.d.ts +17 -0
- package/dist/watch-diff.js +56 -0
- package/package.json +86 -4
|
@@ -0,0 +1,737 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
/**
|
|
3
|
+
* Locale I0 check + I1 message contracts / typed module projection.
|
|
4
|
+
* Design: 规划设计/vmz/28 · I0/I1
|
|
5
|
+
*
|
|
6
|
+
* Not an I18n IR: filesystem + MessageCatalogManifest projection into VPG-shaped views.
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import JSON5 from 'json5';
|
|
11
|
+
import { DIAG_CATALOG_CONFLICT, DIAG_CATALOG_PARSE, DIAG_DEFAULT_MISSING, DIAG_DIR_MISSING, DIAG_DIR_ORPHAN, DIAG_FALLBACK_CYCLE, DIAG_FALLBACK_UNKNOWN, DIAG_ID_COLLISION, DIAG_ID_INVALID, DIAG_LAYOUT_ILLEGAL, DIAG_MANIFEST_MISSING, DIAG_MESSAGE_ARRAY_FORBIDDEN, DIAG_MESSAGE_HTML_FORBIDDEN, DIAG_MESSAGE_MISSING_DEFAULT, DIAG_MESSAGE_PARAMETER_MISMATCH, DIAG_MESSAGE_SYNTAX_INVALID, DIAG_MESSAGE_UNUSED, LOCALE_CHECK_SCHEMA, LOCALE_ID_RE, LOCALE_MANIFEST_SCHEMA, LOCALE_RESERVED_TOP, LOCALE_TYPED_MODULE_SCHEMA, LOCALE_VIRTUAL_MODULE_PREFIX, MESSAGE_CATALOG_SCHEMA, MESSAGE_NODE_SCHEMA, } from './locale-schema.js';
|
|
12
|
+
/**
|
|
13
|
+
* @param {string} literal
|
|
14
|
+
*/
|
|
15
|
+
export function validateLocaleId(literal) {
|
|
16
|
+
const name = String(literal || '');
|
|
17
|
+
if (!name)
|
|
18
|
+
return { ok: false, message: 'empty LocaleId' };
|
|
19
|
+
if (name.includes('_')) {
|
|
20
|
+
return { ok: false, message: `LocaleId must use '-' not '_': ${JSON.stringify(name)}` };
|
|
21
|
+
}
|
|
22
|
+
if (name !== name.toLowerCase()) {
|
|
23
|
+
return {
|
|
24
|
+
ok: false,
|
|
25
|
+
message: `LocaleId must be lowercase ASCII (got ${JSON.stringify(name)})`,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
if (!LOCALE_ID_RE.test(name)) {
|
|
29
|
+
return { ok: false, message: `LocaleId is not lowercase BCP 47 form: ${JSON.stringify(name)}` };
|
|
30
|
+
}
|
|
31
|
+
return { ok: true };
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Flatten catalog object into MessageId → string template.
|
|
35
|
+
* Arrays are forbidden.
|
|
36
|
+
* @param {any} node
|
|
37
|
+
* @param {string} prefix
|
|
38
|
+
* @param {Array<{code:string,severity:string,message:string,path?:string}>} diagnostics
|
|
39
|
+
* @param {string} sourcePath
|
|
40
|
+
* @returns {Map<string, string>}
|
|
41
|
+
*/
|
|
42
|
+
export function flattenCatalog(node, prefix, diagnostics, sourcePath) {
|
|
43
|
+
/** @type {Map<string, string>} */
|
|
44
|
+
const out = new Map();
|
|
45
|
+
if (node == null || typeof node !== 'object') {
|
|
46
|
+
diagnostics.push({
|
|
47
|
+
code: DIAG_CATALOG_PARSE,
|
|
48
|
+
severity: 'error',
|
|
49
|
+
message: `catalog root must be an object`,
|
|
50
|
+
path: sourcePath,
|
|
51
|
+
});
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
if (Array.isArray(node)) {
|
|
55
|
+
diagnostics.push({
|
|
56
|
+
code: DIAG_MESSAGE_ARRAY_FORBIDDEN,
|
|
57
|
+
severity: 'error',
|
|
58
|
+
message: `arrays cannot carry messages (unstable identity)`,
|
|
59
|
+
path: sourcePath,
|
|
60
|
+
});
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
for (const [key, value] of Object.entries(node)) {
|
|
64
|
+
const id = prefix ? `${prefix}.${key}` : key;
|
|
65
|
+
if (value != null && typeof value === 'object') {
|
|
66
|
+
if (Array.isArray(value)) {
|
|
67
|
+
diagnostics.push({
|
|
68
|
+
code: DIAG_MESSAGE_ARRAY_FORBIDDEN,
|
|
69
|
+
severity: 'error',
|
|
70
|
+
message: `array at ${id} cannot carry messages`,
|
|
71
|
+
path: sourcePath,
|
|
72
|
+
});
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
for (const [k, v] of flattenCatalog(value, id, diagnostics, sourcePath)) {
|
|
76
|
+
out.set(k, v);
|
|
77
|
+
}
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (typeof value !== 'string') {
|
|
81
|
+
diagnostics.push({
|
|
82
|
+
code: DIAG_CATALOG_PARSE,
|
|
83
|
+
severity: 'error',
|
|
84
|
+
message: `message ${id} must be a string template`,
|
|
85
|
+
path: sourcePath,
|
|
86
|
+
});
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (/<\/?[a-zA-Z]/.test(value) || /javascript:/i.test(value)) {
|
|
90
|
+
diagnostics.push({
|
|
91
|
+
code: DIAG_MESSAGE_HTML_FORBIDDEN,
|
|
92
|
+
severity: 'error',
|
|
93
|
+
message: `message ${id} forbids HTML/JS sinks`,
|
|
94
|
+
path: sourcePath,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
out.set(id, value);
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* ICU MessageFormat compatible subset: extract argument names + kinds.
|
|
103
|
+
* @param {string} template
|
|
104
|
+
* @returns {{ ok: boolean, params: Array<{ name: string, kind: string }>, error?: string }}
|
|
105
|
+
*/
|
|
106
|
+
export function extractMessageParams(template) {
|
|
107
|
+
const text = String(template ?? '');
|
|
108
|
+
/** @type {Map<string, string>} */
|
|
109
|
+
const params = new Map();
|
|
110
|
+
let i = 0;
|
|
111
|
+
while (i < text.length) {
|
|
112
|
+
const ch = text[i];
|
|
113
|
+
if (ch === "'") {
|
|
114
|
+
// apostrophe escape: '' or '…'
|
|
115
|
+
if (text[i + 1] === "'") {
|
|
116
|
+
i += 2;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const end = text.indexOf("'", i + 1);
|
|
120
|
+
if (end < 0)
|
|
121
|
+
return { ok: false, params: [], error: 'unclosed apostrophe escape' };
|
|
122
|
+
i = end + 1;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (ch !== '{') {
|
|
126
|
+
i += 1;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const close = findMatchingBrace(text, i);
|
|
130
|
+
if (close < 0)
|
|
131
|
+
return { ok: false, params: [], error: 'unclosed {' };
|
|
132
|
+
const inner = text.slice(i + 1, close).trim();
|
|
133
|
+
if (inner === '#') {
|
|
134
|
+
i = close + 1;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const parts = splitTopLevel(inner, ',');
|
|
138
|
+
const name = (parts[0] || '').trim();
|
|
139
|
+
if (!name || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
|
|
140
|
+
return { ok: false, params: [], error: `invalid argument name in {${inner}}` };
|
|
141
|
+
}
|
|
142
|
+
const kind = (parts[1] || 'string').trim().toLowerCase() || 'string';
|
|
143
|
+
if ((kind === 'plural' || kind === 'select' || kind === 'selectordinal') && !/\bother\b/.test(inner)) {
|
|
144
|
+
return { ok: false, params: [], error: `${kind} requires other` };
|
|
145
|
+
}
|
|
146
|
+
const prev = params.get(name);
|
|
147
|
+
if (prev && prev !== kind) {
|
|
148
|
+
return { ok: false, params: [], error: `argument ${name} kind conflict ${prev} vs ${kind}` };
|
|
149
|
+
}
|
|
150
|
+
params.set(name, kind);
|
|
151
|
+
i = close + 1;
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
ok: true,
|
|
155
|
+
params: [...params.entries()].map(([name, kind]) => ({ name, kind })),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
function findMatchingBrace(text, openIdx) {
|
|
159
|
+
let depth = 0;
|
|
160
|
+
for (let i = openIdx; i < text.length; i++) {
|
|
161
|
+
if (text[i] === "'") {
|
|
162
|
+
if (text[i + 1] === "'") {
|
|
163
|
+
i += 1;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
const end = text.indexOf("'", i + 1);
|
|
167
|
+
if (end < 0)
|
|
168
|
+
return -1;
|
|
169
|
+
i = end;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (text[i] === '{')
|
|
173
|
+
depth += 1;
|
|
174
|
+
else if (text[i] === '}') {
|
|
175
|
+
depth -= 1;
|
|
176
|
+
if (depth === 0)
|
|
177
|
+
return i;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return -1;
|
|
181
|
+
}
|
|
182
|
+
function splitTopLevel(text, sep) {
|
|
183
|
+
/** @type {string[]} */
|
|
184
|
+
const parts = [];
|
|
185
|
+
let depth = 0;
|
|
186
|
+
let start = 0;
|
|
187
|
+
for (let i = 0; i < text.length; i++) {
|
|
188
|
+
const ch = text[i];
|
|
189
|
+
if (ch === '{')
|
|
190
|
+
depth += 1;
|
|
191
|
+
else if (ch === '}')
|
|
192
|
+
depth -= 1;
|
|
193
|
+
else if (ch === sep && depth === 0) {
|
|
194
|
+
parts.push(text.slice(start, i));
|
|
195
|
+
start = i + 1;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
parts.push(text.slice(start));
|
|
199
|
+
return parts;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Detect fallback cycles (explicit DAG).
|
|
203
|
+
* @param {Record<string, string[]>} fallback
|
|
204
|
+
* @param {Set<string>} known
|
|
205
|
+
*/
|
|
206
|
+
export function findFallbackCycles(fallback, known) {
|
|
207
|
+
/** @type {string[]} */
|
|
208
|
+
const cycles = [];
|
|
209
|
+
/** @type {string[]} */
|
|
210
|
+
const unknown = [];
|
|
211
|
+
for (const [from, tos] of Object.entries(fallback || {})) {
|
|
212
|
+
if (!known.has(from))
|
|
213
|
+
unknown.push(from);
|
|
214
|
+
for (const to of tos || []) {
|
|
215
|
+
if (!known.has(to))
|
|
216
|
+
unknown.push(`${from}->${to}`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
for (const start of Object.keys(fallback || {})) {
|
|
220
|
+
/** @type {Set<string>} */
|
|
221
|
+
const stack = new Set();
|
|
222
|
+
/** @type {string[]} */
|
|
223
|
+
const path = [];
|
|
224
|
+
const visit = (node) => {
|
|
225
|
+
if (stack.has(node)) {
|
|
226
|
+
cycles.push([...path, node].join(' -> '));
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (!fallback[node])
|
|
230
|
+
return;
|
|
231
|
+
stack.add(node);
|
|
232
|
+
path.push(node);
|
|
233
|
+
for (const next of fallback[node] || [])
|
|
234
|
+
visit(next);
|
|
235
|
+
path.pop();
|
|
236
|
+
stack.delete(node);
|
|
237
|
+
};
|
|
238
|
+
visit(start);
|
|
239
|
+
}
|
|
240
|
+
return { cycles: [...new Set(cycles)], unknown: [...new Set(unknown)] };
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* @param {{ projectRoot: string, strict?: boolean, checkUnused?: boolean }} opts
|
|
244
|
+
*/
|
|
245
|
+
export function checkLocales(opts) {
|
|
246
|
+
const projectRoot = path.resolve(opts.projectRoot);
|
|
247
|
+
const localesRoot = path.join(projectRoot, 'locales');
|
|
248
|
+
/** @type {Array<{ code: string, severity: string, message: string, path?: string }>} */
|
|
249
|
+
const diagnostics = [];
|
|
250
|
+
const manifestPathJson5 = path.join(localesRoot, 'locales.json5');
|
|
251
|
+
const manifestPathJson = path.join(localesRoot, 'locales.json');
|
|
252
|
+
const manifestPath = fs.existsSync(manifestPathJson5) ? manifestPathJson5 : fs.existsSync(manifestPathJson) ? manifestPathJson : null;
|
|
253
|
+
if (!fs.existsSync(localesRoot) || !manifestPath) {
|
|
254
|
+
diagnostics.push({
|
|
255
|
+
code: DIAG_MANIFEST_MISSING,
|
|
256
|
+
severity: 'error',
|
|
257
|
+
message: 'locales/locales.json5 missing (LocaleId policy truth source)',
|
|
258
|
+
path: 'locales/locales.json5',
|
|
259
|
+
});
|
|
260
|
+
return emptyReport(projectRoot, diagnostics);
|
|
261
|
+
}
|
|
262
|
+
/** @type {any} */
|
|
263
|
+
let rawManifest = null;
|
|
264
|
+
try {
|
|
265
|
+
rawManifest = JSON5.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
266
|
+
}
|
|
267
|
+
catch (e) {
|
|
268
|
+
diagnostics.push({
|
|
269
|
+
code: DIAG_CATALOG_PARSE,
|
|
270
|
+
severity: 'error',
|
|
271
|
+
message: `locales.json5 parse failed: ${e.message || e}`,
|
|
272
|
+
path: path.relative(projectRoot, manifestPath).replace(/\\/g, '/'),
|
|
273
|
+
});
|
|
274
|
+
return emptyReport(projectRoot, diagnostics);
|
|
275
|
+
}
|
|
276
|
+
const localeEntries = Array.isArray(rawManifest.locales) ? rawManifest.locales : [];
|
|
277
|
+
/** @type {string[]} */
|
|
278
|
+
const orderedIds = [];
|
|
279
|
+
/** @type {Set<string>} */
|
|
280
|
+
const seen = new Set();
|
|
281
|
+
for (const entry of localeEntries) {
|
|
282
|
+
const id = String(entry?.id || '');
|
|
283
|
+
const v = validateLocaleId(id);
|
|
284
|
+
if (!v.ok) {
|
|
285
|
+
diagnostics.push({
|
|
286
|
+
code: DIAG_ID_INVALID,
|
|
287
|
+
severity: 'error',
|
|
288
|
+
message: v.message,
|
|
289
|
+
path: path.relative(projectRoot, manifestPath).replace(/\\/g, '/'),
|
|
290
|
+
});
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
if (seen.has(id)) {
|
|
294
|
+
diagnostics.push({
|
|
295
|
+
code: DIAG_ID_COLLISION,
|
|
296
|
+
severity: 'error',
|
|
297
|
+
message: `duplicate LocaleId ${id} in locales[]`,
|
|
298
|
+
path: path.relative(projectRoot, manifestPath).replace(/\\/g, '/'),
|
|
299
|
+
});
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
seen.add(id);
|
|
303
|
+
orderedIds.push(id);
|
|
304
|
+
}
|
|
305
|
+
const defaultLocale = String(rawManifest.defaultLocale || '');
|
|
306
|
+
if (!defaultLocale || !seen.has(defaultLocale)) {
|
|
307
|
+
diagnostics.push({
|
|
308
|
+
code: DIAG_DEFAULT_MISSING,
|
|
309
|
+
severity: 'error',
|
|
310
|
+
message: `defaultLocale ${JSON.stringify(defaultLocale)} missing from locales[]`,
|
|
311
|
+
path: path.relative(projectRoot, manifestPath).replace(/\\/g, '/'),
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
const fallback = rawManifest.fallback && typeof rawManifest.fallback === 'object' ? rawManifest.fallback : {};
|
|
315
|
+
const fb = findFallbackCycles(fallback, seen);
|
|
316
|
+
for (const u of fb.unknown) {
|
|
317
|
+
diagnostics.push({
|
|
318
|
+
code: DIAG_FALLBACK_UNKNOWN,
|
|
319
|
+
severity: 'error',
|
|
320
|
+
message: `fallback references unknown LocaleId: ${u}`,
|
|
321
|
+
path: path.relative(projectRoot, manifestPath).replace(/\\/g, '/'),
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
for (const c of fb.cycles) {
|
|
325
|
+
diagnostics.push({
|
|
326
|
+
code: DIAG_FALLBACK_CYCLE,
|
|
327
|
+
severity: 'error',
|
|
328
|
+
message: `fallback cycle: ${c}`,
|
|
329
|
+
path: path.relative(projectRoot, manifestPath).replace(/\\/g, '/'),
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
/** @type {string[]} */
|
|
333
|
+
const diskLocales = [];
|
|
334
|
+
for (const ent of fs.readdirSync(localesRoot, { withFileTypes: true })) {
|
|
335
|
+
if (LOCALE_RESERVED_TOP.has(ent.name))
|
|
336
|
+
continue;
|
|
337
|
+
if (ent.isFile()) {
|
|
338
|
+
diagnostics.push({
|
|
339
|
+
code: DIAG_LAYOUT_ILLEGAL,
|
|
340
|
+
severity: 'error',
|
|
341
|
+
message: `illegal top-level file under /locales: ${ent.name} (only locales.json5 + LocaleId dirs)`,
|
|
342
|
+
path: `locales/${ent.name}`,
|
|
343
|
+
});
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
if (!ent.isDirectory())
|
|
347
|
+
continue;
|
|
348
|
+
const v = validateLocaleId(ent.name);
|
|
349
|
+
if (!v.ok) {
|
|
350
|
+
diagnostics.push({
|
|
351
|
+
code: DIAG_ID_INVALID,
|
|
352
|
+
severity: 'error',
|
|
353
|
+
message: `directory ${ent.name}: ${v.message}`,
|
|
354
|
+
path: `locales/${ent.name}`,
|
|
355
|
+
});
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
diskLocales.push(ent.name);
|
|
359
|
+
if (!seen.has(ent.name)) {
|
|
360
|
+
diagnostics.push({
|
|
361
|
+
code: DIAG_DIR_ORPHAN,
|
|
362
|
+
severity: 'error',
|
|
363
|
+
message: `locale directory ${ent.name} not listed in locales.json5 locales[]`,
|
|
364
|
+
path: `locales/${ent.name}`,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
for (const id of orderedIds) {
|
|
369
|
+
if (!diskLocales.includes(id)) {
|
|
370
|
+
diagnostics.push({
|
|
371
|
+
code: DIAG_DIR_MISSING,
|
|
372
|
+
severity: 'error',
|
|
373
|
+
message: `locales[] entry ${id} has no locales/${id}/ directory`,
|
|
374
|
+
path: `locales/${id}`,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
/** @type {Map<string, { messageId: string, catalogId: string, variants: Record<string, { template: string, params: any[], path: string }> }>} */
|
|
379
|
+
const messages = new Map();
|
|
380
|
+
/** @type {string[]} */
|
|
381
|
+
const catalogIds = [];
|
|
382
|
+
for (const locale of orderedIds) {
|
|
383
|
+
const dir = path.join(localesRoot, locale);
|
|
384
|
+
if (!fs.existsSync(dir))
|
|
385
|
+
continue;
|
|
386
|
+
walkCatalogFiles(dir, (fileAbs) => {
|
|
387
|
+
const rel = path.relative(dir, fileAbs).replace(/\\/g, '/');
|
|
388
|
+
const catalogId = rel.replace(/\.(json5|json|ya?ml)$/i, '').replace(/\//g, '.');
|
|
389
|
+
if (!catalogIds.includes(catalogId))
|
|
390
|
+
catalogIds.push(catalogId);
|
|
391
|
+
/** @type {any} */
|
|
392
|
+
let parsed = null;
|
|
393
|
+
try {
|
|
394
|
+
const text = fs.readFileSync(fileAbs, 'utf8');
|
|
395
|
+
if (/\.ya?ml$/i.test(fileAbs)) {
|
|
396
|
+
diagnostics.push({
|
|
397
|
+
code: DIAG_CATALOG_PARSE,
|
|
398
|
+
severity: 'warning',
|
|
399
|
+
message: `YAML catalog ${rel} deferred (I0 first slice: JSON5 only)`,
|
|
400
|
+
path: path.relative(projectRoot, fileAbs).replace(/\\/g, '/'),
|
|
401
|
+
});
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
parsed = JSON5.parse(text);
|
|
405
|
+
}
|
|
406
|
+
catch (e) {
|
|
407
|
+
diagnostics.push({
|
|
408
|
+
code: DIAG_CATALOG_PARSE,
|
|
409
|
+
severity: 'error',
|
|
410
|
+
message: `catalog parse failed: ${e.message || e}`,
|
|
411
|
+
path: path.relative(projectRoot, fileAbs).replace(/\\/g, '/'),
|
|
412
|
+
});
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
const flat = flattenCatalog(parsed, '', diagnostics, path.relative(projectRoot, fileAbs).replace(/\\/g, '/'));
|
|
416
|
+
for (const [key, template] of flat) {
|
|
417
|
+
const messageId = `${catalogId}.${key}`;
|
|
418
|
+
const extracted = extractMessageParams(template);
|
|
419
|
+
if (!extracted.ok) {
|
|
420
|
+
diagnostics.push({
|
|
421
|
+
code: DIAG_MESSAGE_SYNTAX_INVALID,
|
|
422
|
+
severity: 'error',
|
|
423
|
+
message: `${messageId} @ ${locale}: ${extracted.error}`,
|
|
424
|
+
path: path.relative(projectRoot, fileAbs).replace(/\\/g, '/'),
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
let node = messages.get(messageId);
|
|
428
|
+
if (!node) {
|
|
429
|
+
node = { messageId, catalogId, variants: {} };
|
|
430
|
+
messages.set(messageId, node);
|
|
431
|
+
}
|
|
432
|
+
if (node.variants[locale]) {
|
|
433
|
+
diagnostics.push({
|
|
434
|
+
code: DIAG_CATALOG_CONFLICT,
|
|
435
|
+
severity: 'error',
|
|
436
|
+
message: `duplicate message ${messageId} in locale ${locale}`,
|
|
437
|
+
path: path.relative(projectRoot, fileAbs).replace(/\\/g, '/'),
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
node.variants[locale] = {
|
|
441
|
+
template,
|
|
442
|
+
params: extracted.ok ? extracted.params : [],
|
|
443
|
+
path: path.relative(projectRoot, fileAbs).replace(/\\/g, '/'),
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
// Default locale must define every MessageId; param contracts must match across variants.
|
|
449
|
+
for (const node of messages.values()) {
|
|
450
|
+
if (defaultLocale && !node.variants[defaultLocale]) {
|
|
451
|
+
diagnostics.push({
|
|
452
|
+
code: DIAG_MESSAGE_MISSING_DEFAULT,
|
|
453
|
+
severity: 'error',
|
|
454
|
+
message: `message ${node.messageId} missing in defaultLocale ${defaultLocale}`,
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
const base = defaultLocale ? node.variants[defaultLocale] : null;
|
|
458
|
+
const baseSig = base ? paramSignature(base.params) : null;
|
|
459
|
+
for (const [loc, variant] of Object.entries(node.variants)) {
|
|
460
|
+
if (base && loc !== defaultLocale) {
|
|
461
|
+
const sig = paramSignature(variant.params);
|
|
462
|
+
if (sig !== baseSig) {
|
|
463
|
+
diagnostics.push({
|
|
464
|
+
code: DIAG_MESSAGE_PARAMETER_MISMATCH,
|
|
465
|
+
severity: 'error',
|
|
466
|
+
message: `message ${node.messageId}: params ${sig} @ ${loc} != ${baseSig} @ ${defaultLocale}`,
|
|
467
|
+
path: variant.path,
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
if (opts.strict) {
|
|
473
|
+
for (const loc of orderedIds) {
|
|
474
|
+
if (!node.variants[loc] && loc !== defaultLocale) {
|
|
475
|
+
const edges = fallback[loc] || [];
|
|
476
|
+
const canFallback = edges.some((e) => node.variants[e]);
|
|
477
|
+
if (!canFallback && rawManifest.missing !== 'warn') {
|
|
478
|
+
diagnostics.push({
|
|
479
|
+
code: DIAG_MESSAGE_MISSING_VARIANT,
|
|
480
|
+
severity: 'error',
|
|
481
|
+
message: `message ${node.messageId} missing variant ${loc} (no fallback edge)`,
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
// I1: scan source for #locales/* imports / message references.
|
|
489
|
+
const used = scanLocaleUsages(projectRoot);
|
|
490
|
+
/** @type {any[]} */
|
|
491
|
+
const typedModules = [];
|
|
492
|
+
const byCatalog = new Map();
|
|
493
|
+
for (const node of messages.values()) {
|
|
494
|
+
if (!byCatalog.has(node.catalogId))
|
|
495
|
+
byCatalog.set(node.catalogId, []);
|
|
496
|
+
byCatalog.get(node.catalogId).push(node);
|
|
497
|
+
}
|
|
498
|
+
for (const [catalogId, nodes] of byCatalog) {
|
|
499
|
+
/** @type {Map<string, number>} */
|
|
500
|
+
const leafCount = new Map();
|
|
501
|
+
for (const n of nodes) {
|
|
502
|
+
const leaf = leafExportName(n.messageId);
|
|
503
|
+
leafCount.set(leaf, (leafCount.get(leaf) || 0) + 1);
|
|
504
|
+
}
|
|
505
|
+
const exports = nodes.map((n) => {
|
|
506
|
+
const leaf = leafExportName(n.messageId);
|
|
507
|
+
const exportName = (leafCount.get(leaf) || 0) > 1 ? n.messageId.slice(catalogId.length + 1).replace(/\./g, '_') : leaf;
|
|
508
|
+
const base = n.variants[defaultLocale] || Object.values(n.variants)[0];
|
|
509
|
+
return {
|
|
510
|
+
exportName,
|
|
511
|
+
messageId: n.messageId,
|
|
512
|
+
params: base?.params || [],
|
|
513
|
+
};
|
|
514
|
+
});
|
|
515
|
+
typedModules.push({
|
|
516
|
+
schema: LOCALE_TYPED_MODULE_SCHEMA,
|
|
517
|
+
module: `${LOCALE_VIRTUAL_MODULE_PREFIX}${catalogId}`,
|
|
518
|
+
catalogId,
|
|
519
|
+
exports,
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
if (opts.checkUnused !== false) {
|
|
523
|
+
const referenced = new Set(used.messageIds);
|
|
524
|
+
for (const [catalogId, names] of used.importedNames) {
|
|
525
|
+
for (const name of names) {
|
|
526
|
+
for (const node of messages.values()) {
|
|
527
|
+
if (node.catalogId !== catalogId)
|
|
528
|
+
continue;
|
|
529
|
+
if (leafExportName(node.messageId) === name || node.messageId === `${catalogId}.${name}`) {
|
|
530
|
+
referenced.add(node.messageId);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
for (const node of messages.values()) {
|
|
536
|
+
if (!referenced.has(node.messageId) && used.importedNames.size + used.catalogs.size > 0) {
|
|
537
|
+
diagnostics.push({
|
|
538
|
+
code: DIAG_MESSAGE_UNUSED,
|
|
539
|
+
severity: 'warning',
|
|
540
|
+
message: `message ${node.messageId} is never referenced via #locales/*`,
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
const hasErrors = diagnostics.some((d) => d.severity === 'error');
|
|
546
|
+
const messageNodes = [...messages.values()].map((n) => ({
|
|
547
|
+
schema: MESSAGE_NODE_SCHEMA,
|
|
548
|
+
messageId: n.messageId,
|
|
549
|
+
catalogId: n.catalogId,
|
|
550
|
+
variants: Object.fromEntries(Object.entries(n.variants).map(([loc, v]) => [loc, { template: v.template, params: v.params, path: v.path }])),
|
|
551
|
+
}));
|
|
552
|
+
return {
|
|
553
|
+
schema: LOCALE_CHECK_SCHEMA,
|
|
554
|
+
status: hasErrors ? 'failed' : 'ready',
|
|
555
|
+
root: projectRoot,
|
|
556
|
+
localesRoot: path.relative(projectRoot, localesRoot).replace(/\\/g, '/') || 'locales',
|
|
557
|
+
manifest: {
|
|
558
|
+
schema: LOCALE_MANIFEST_SCHEMA,
|
|
559
|
+
schemaVersion: rawManifest.schemaVersion ?? 1,
|
|
560
|
+
defaultLocale,
|
|
561
|
+
locales: localeEntries,
|
|
562
|
+
fallback,
|
|
563
|
+
routing: rawManifest.routing || { strategy: 'prefix', defaultPrefix: 'include' },
|
|
564
|
+
missing: rawManifest.missing || 'error',
|
|
565
|
+
},
|
|
566
|
+
catalogIds: catalogIds.sort(),
|
|
567
|
+
messageCatalog: {
|
|
568
|
+
schema: MESSAGE_CATALOG_SCHEMA,
|
|
569
|
+
messages: messageNodes,
|
|
570
|
+
},
|
|
571
|
+
typedModules,
|
|
572
|
+
usages: {
|
|
573
|
+
catalogs: [...used.catalogs],
|
|
574
|
+
messageIds: [...used.messageIds],
|
|
575
|
+
},
|
|
576
|
+
diagnostics,
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
function leafExportName(messageId) {
|
|
580
|
+
const parts = String(messageId).split('.');
|
|
581
|
+
return parts[parts.length - 1] || messageId;
|
|
582
|
+
}
|
|
583
|
+
function paramSignature(params) {
|
|
584
|
+
return (params || [])
|
|
585
|
+
.map((p) => `${p.name}:${p.kind}`)
|
|
586
|
+
.sort()
|
|
587
|
+
.join(',');
|
|
588
|
+
}
|
|
589
|
+
function emptyReport(projectRoot, diagnostics) {
|
|
590
|
+
return {
|
|
591
|
+
schema: LOCALE_CHECK_SCHEMA,
|
|
592
|
+
status: 'failed',
|
|
593
|
+
root: projectRoot,
|
|
594
|
+
localesRoot: 'locales',
|
|
595
|
+
manifest: null,
|
|
596
|
+
catalogIds: [],
|
|
597
|
+
messageCatalog: { schema: MESSAGE_CATALOG_SCHEMA, messages: [] },
|
|
598
|
+
typedModules: [],
|
|
599
|
+
usages: { catalogs: [], messageIds: [] },
|
|
600
|
+
diagnostics,
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
function walkCatalogFiles(dir, fn) {
|
|
604
|
+
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
605
|
+
const full = path.join(dir, ent.name);
|
|
606
|
+
if (ent.isDirectory())
|
|
607
|
+
walkCatalogFiles(full, fn);
|
|
608
|
+
else if (/\.(json5|json|ya?ml)$/i.test(ent.name))
|
|
609
|
+
fn(full);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* Scan src for `#locales/<catalog>` imports and `messageId` string literals after import.
|
|
614
|
+
* First-slice heuristic — full VPG edges land with compiler I1 deepen.
|
|
615
|
+
*/
|
|
616
|
+
export function scanLocaleUsages(projectRoot) {
|
|
617
|
+
/** @type {Set<string>} */
|
|
618
|
+
const catalogs = new Set();
|
|
619
|
+
/** @type {Set<string>} */
|
|
620
|
+
const messageIds = new Set();
|
|
621
|
+
/** @type {Map<string, Set<string>>} */
|
|
622
|
+
const importedNames = new Map();
|
|
623
|
+
const src = path.join(projectRoot, 'src');
|
|
624
|
+
if (!fs.existsSync(src))
|
|
625
|
+
return { catalogs, messageIds, importedNames };
|
|
626
|
+
walkSource(src, (file) => {
|
|
627
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
628
|
+
for (const m of text.matchAll(/#locales\/([A-Za-z0-9_./-]+)/g)) {
|
|
629
|
+
catalogs.add(m[1].replace(/\.ts$/, '').replace(/\/index$/, ''));
|
|
630
|
+
}
|
|
631
|
+
for (const m of text.matchAll(/import\s*\{([^}]+)\}\s*from\s*['"]#locales\/([^'"]+)['"]/g)) {
|
|
632
|
+
const catalogId = m[2].replace(/\.ts$/, '');
|
|
633
|
+
catalogs.add(catalogId);
|
|
634
|
+
if (!importedNames.has(catalogId))
|
|
635
|
+
importedNames.set(catalogId, new Set());
|
|
636
|
+
for (const part of m[1].split(',')) {
|
|
637
|
+
const name = part
|
|
638
|
+
.trim()
|
|
639
|
+
.split(/\s+as\s+/)
|
|
640
|
+
.pop()
|
|
641
|
+
?.trim();
|
|
642
|
+
if (name) {
|
|
643
|
+
importedNames.get(catalogId).add(name);
|
|
644
|
+
messageIds.add(`${catalogId}.${name}`);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
});
|
|
649
|
+
return { catalogs, messageIds, importedNames };
|
|
650
|
+
}
|
|
651
|
+
function walkSource(dir, fn) {
|
|
652
|
+
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
653
|
+
const full = path.join(dir, ent.name);
|
|
654
|
+
if (ent.isDirectory())
|
|
655
|
+
walkSource(full, fn);
|
|
656
|
+
else if (/\.(vmz|ts|tsx|js|mjs|cjs)$/i.test(ent.name))
|
|
657
|
+
fn(full);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* Emit typed module stubs for `#locales/*` (I1 surface).
|
|
662
|
+
* @param {ReturnType<typeof checkLocales>} report
|
|
663
|
+
* @param {string} outDir
|
|
664
|
+
*/
|
|
665
|
+
export function emitLocaleTypedModules(report, outDir) {
|
|
666
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
667
|
+
/** @type {string[]} */
|
|
668
|
+
const written = [];
|
|
669
|
+
for (const mod of report.typedModules || []) {
|
|
670
|
+
const lines = [
|
|
671
|
+
`/** Generated LocalizedText module — ${mod.module} (vmz.locale.typed_module.v0) */`,
|
|
672
|
+
`export type LocalizedText = string & { readonly __brand: 'LocalizedText' };`,
|
|
673
|
+
'',
|
|
674
|
+
];
|
|
675
|
+
for (const exp of mod.exports) {
|
|
676
|
+
if (!exp.params.length) {
|
|
677
|
+
lines.push(`export declare function ${exp.exportName}(): LocalizedText;`);
|
|
678
|
+
}
|
|
679
|
+
else {
|
|
680
|
+
const args = exp.params.map((p) => `${p.name}: ${p.kind === 'plural' || p.kind === 'number' ? 'number' : 'string'}`).join('; ');
|
|
681
|
+
lines.push(`export declare function ${exp.exportName}(args: { ${args} }): LocalizedText;`);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
lines.push('');
|
|
685
|
+
const file = path.join(outDir, `${mod.catalogId}.d.ts`);
|
|
686
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
687
|
+
fs.writeFileSync(file, lines.join('\n'), 'utf8');
|
|
688
|
+
written.push(file);
|
|
689
|
+
}
|
|
690
|
+
const index = {
|
|
691
|
+
schema: LOCALE_TYPED_MODULE_SCHEMA,
|
|
692
|
+
modules: (report.typedModules || []).map((m) => m.module),
|
|
693
|
+
outDir,
|
|
694
|
+
};
|
|
695
|
+
fs.writeFileSync(path.join(outDir, 'index.json'), `${JSON.stringify(index, null, 2)}\n`, 'utf8');
|
|
696
|
+
return written;
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* MessageId rename plan (I1) — WorkspaceEdit-shaped, no parallel rename IR.
|
|
700
|
+
* @param {ReturnType<typeof checkLocales>} report
|
|
701
|
+
* @param {string} fromId
|
|
702
|
+
* @param {string} toId
|
|
703
|
+
*/
|
|
704
|
+
export function planLocaleRename(report, fromId, toId) {
|
|
705
|
+
const node = (report.messageCatalog?.messages || []).find((m) => m.messageId === fromId);
|
|
706
|
+
if (!node) {
|
|
707
|
+
return {
|
|
708
|
+
schema: 'vmz.locale.rename.v0',
|
|
709
|
+
status: 'failed',
|
|
710
|
+
fromId,
|
|
711
|
+
toId,
|
|
712
|
+
edits: [],
|
|
713
|
+
error: `unknown MessageId ${fromId}`,
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
/** @type {Array<{ path: string, kind: string, from: string, to: string }>} */
|
|
717
|
+
const edits = [];
|
|
718
|
+
for (const variant of Object.values(node.variants || {})) {
|
|
719
|
+
edits.push({
|
|
720
|
+
path: variant.path,
|
|
721
|
+
kind: 'catalog_key',
|
|
722
|
+
from: fromId,
|
|
723
|
+
to: toId,
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
return {
|
|
727
|
+
schema: 'vmz.locale.rename.v0',
|
|
728
|
+
status: 'ready',
|
|
729
|
+
fromId,
|
|
730
|
+
toId,
|
|
731
|
+
edits,
|
|
732
|
+
virtualModule: `${LOCALE_VIRTUAL_MODULE_PREFIX}${node.catalogId}`,
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
export function localeHasErrors(report) {
|
|
736
|
+
return (report.diagnostics || []).some((d) => d.severity === 'error');
|
|
737
|
+
}
|