@rexezuge/tooling 0.0.0-stage → 1.0.0

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.
Files changed (160) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +297 -2
  3. package/dist/eslint.d.ts +120 -0
  4. package/dist/eslint.d.ts.map +1 -0
  5. package/dist/eslint.js +551 -0
  6. package/dist/eslint.js.map +1 -0
  7. package/dist/functions/pages-proxy.d.ts +113 -0
  8. package/dist/functions/pages-proxy.d.ts.map +1 -0
  9. package/dist/functions/pages-proxy.js +131 -0
  10. package/dist/functions/pages-proxy.js.map +1 -0
  11. package/dist/index.d.ts +31 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +28 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/scripts/backup/d1-target.d.ts +35 -0
  16. package/dist/scripts/backup/d1-target.d.ts.map +1 -0
  17. package/dist/scripts/backup/d1-target.js +31 -0
  18. package/dist/scripts/backup/d1-target.js.map +1 -0
  19. package/dist/scripts/backup/destination-config.d.ts +61 -0
  20. package/dist/scripts/backup/destination-config.d.ts.map +1 -0
  21. package/dist/scripts/backup/destination-config.js +57 -0
  22. package/dist/scripts/backup/destination-config.js.map +1 -0
  23. package/dist/scripts/backup/encrypt-backup.d.ts +35 -0
  24. package/dist/scripts/backup/encrypt-backup.d.ts.map +1 -0
  25. package/dist/scripts/backup/encrypt-backup.js +98 -0
  26. package/dist/scripts/backup/encrypt-backup.js.map +1 -0
  27. package/dist/scripts/backup/naming.d.ts +44 -0
  28. package/dist/scripts/backup/naming.d.ts.map +1 -0
  29. package/dist/scripts/backup/naming.js +58 -0
  30. package/dist/scripts/backup/naming.js.map +1 -0
  31. package/dist/scripts/check-god-files.d.ts +164 -0
  32. package/dist/scripts/check-god-files.d.ts.map +1 -0
  33. package/dist/scripts/check-god-files.js +271 -0
  34. package/dist/scripts/check-god-files.js.map +1 -0
  35. package/dist/scripts/ensure-spa-shell-stub.d.ts +3 -0
  36. package/dist/scripts/ensure-spa-shell-stub.d.ts.map +1 -0
  37. package/dist/scripts/ensure-spa-shell-stub.js +30 -0
  38. package/dist/scripts/ensure-spa-shell-stub.js.map +1 -0
  39. package/dist/scripts/init-secrets.d.ts +63 -0
  40. package/dist/scripts/init-secrets.d.ts.map +1 -0
  41. package/dist/scripts/init-secrets.js +240 -0
  42. package/dist/scripts/init-secrets.js.map +1 -0
  43. package/dist/scripts/lib/cli-args.d.ts +78 -0
  44. package/dist/scripts/lib/cli-args.d.ts.map +1 -0
  45. package/dist/scripts/lib/cli-args.js +116 -0
  46. package/dist/scripts/lib/cli-args.js.map +1 -0
  47. package/dist/scripts/lib/github-actions.d.ts +26 -0
  48. package/dist/scripts/lib/github-actions.d.ts.map +1 -0
  49. package/dist/scripts/lib/github-actions.js +38 -0
  50. package/dist/scripts/lib/github-actions.js.map +1 -0
  51. package/dist/scripts/lib/wrangler-table.d.ts +46 -0
  52. package/dist/scripts/lib/wrangler-table.d.ts.map +1 -0
  53. package/dist/scripts/lib/wrangler-table.js +99 -0
  54. package/dist/scripts/lib/wrangler-table.js.map +1 -0
  55. package/dist/scripts/migrations-lock.d.ts +3 -0
  56. package/dist/scripts/migrations-lock.d.ts.map +1 -0
  57. package/dist/scripts/migrations-lock.js +46 -0
  58. package/dist/scripts/migrations-lock.js.map +1 -0
  59. package/dist/scripts/prepare-wrangler-config.d.ts +3 -0
  60. package/dist/scripts/prepare-wrangler-config.d.ts.map +1 -0
  61. package/dist/scripts/prepare-wrangler-config.js +50 -0
  62. package/dist/scripts/prepare-wrangler-config.js.map +1 -0
  63. package/dist/scripts/spa-shell.d.ts +41 -0
  64. package/dist/scripts/spa-shell.d.ts.map +1 -0
  65. package/dist/scripts/spa-shell.js +155 -0
  66. package/dist/scripts/spa-shell.js.map +1 -0
  67. package/dist/scripts/validate-locales.d.ts +26 -0
  68. package/dist/scripts/validate-locales.d.ts.map +1 -0
  69. package/dist/scripts/validate-locales.js +350 -0
  70. package/dist/scripts/validate-locales.js.map +1 -0
  71. package/dist/scripts/verify-migrations.d.ts +62 -0
  72. package/dist/scripts/verify-migrations.d.ts.map +1 -0
  73. package/dist/scripts/verify-migrations.js +302 -0
  74. package/dist/scripts/verify-migrations.js.map +1 -0
  75. package/dist/scripts/verify-spa-shell.d.ts +3 -0
  76. package/dist/scripts/verify-spa-shell.d.ts.map +1 -0
  77. package/dist/scripts/verify-spa-shell.js +53 -0
  78. package/dist/scripts/verify-spa-shell.js.map +1 -0
  79. package/dist/scripts/wrangler-config/cli.d.ts +22 -0
  80. package/dist/scripts/wrangler-config/cli.d.ts.map +1 -0
  81. package/dist/scripts/wrangler-config/cli.js +51 -0
  82. package/dist/scripts/wrangler-config/cli.js.map +1 -0
  83. package/dist/scripts/wrangler-config/patches.d.ts +51 -0
  84. package/dist/scripts/wrangler-config/patches.d.ts.map +1 -0
  85. package/dist/scripts/wrangler-config/patches.js +140 -0
  86. package/dist/scripts/wrangler-config/patches.js.map +1 -0
  87. package/dist/scripts/wrangler-config/resources.d.ts +70 -0
  88. package/dist/scripts/wrangler-config/resources.d.ts.map +1 -0
  89. package/dist/scripts/wrangler-config/resources.js +290 -0
  90. package/dist/scripts/wrangler-config/resources.js.map +1 -0
  91. package/dist/scripts/wrangler-config/types.d.ts +103 -0
  92. package/dist/scripts/wrangler-config/types.d.ts.map +1 -0
  93. package/dist/scripts/wrangler-config/types.js +49 -0
  94. package/dist/scripts/wrangler-config/types.js.map +1 -0
  95. package/dist/test/integration-migrations.d.ts +167 -0
  96. package/dist/test/integration-migrations.d.ts.map +1 -0
  97. package/dist/test/integration-migrations.js +171 -0
  98. package/dist/test/integration-migrations.js.map +1 -0
  99. package/dist/test/mocks/cloudflare-workers.d.ts +106 -0
  100. package/dist/test/mocks/cloudflare-workers.d.ts.map +1 -0
  101. package/dist/test/mocks/cloudflare-workers.js +90 -0
  102. package/dist/test/mocks/cloudflare-workers.js.map +1 -0
  103. package/dist/vite.d.ts +117 -0
  104. package/dist/vite.d.ts.map +1 -0
  105. package/dist/vite.js +125 -0
  106. package/dist/vite.js.map +1 -0
  107. package/dist/vitest-web.d.ts +73 -0
  108. package/dist/vitest-web.d.ts.map +1 -0
  109. package/dist/vitest-web.js +72 -0
  110. package/dist/vitest-web.js.map +1 -0
  111. package/dist/vitest.d.ts +92 -0
  112. package/dist/vitest.d.ts.map +1 -0
  113. package/dist/vitest.js +128 -0
  114. package/dist/vitest.js.map +1 -0
  115. package/package.json +58 -3
  116. package/src/eslint.test.ts +175 -0
  117. package/src/eslint.ts +640 -0
  118. package/src/functions/pages-proxy.test.ts +72 -0
  119. package/src/functions/pages-proxy.ts +187 -0
  120. package/src/github/actions/retry-step/action.yml +39 -0
  121. package/src/github/actions/setup-env/action.yml +20 -0
  122. package/src/github/dependabot.yml +30 -0
  123. package/src/github/workflows/backup-main.yml +46 -0
  124. package/src/github/workflows/continuous-deployment.yml +188 -0
  125. package/src/github/workflows/continuous-integration.yml +259 -0
  126. package/src/github/workflows/scheduled-version-update.yml +38 -0
  127. package/src/github/workflows/upstream-sync.yml +56 -0
  128. package/src/index.ts +42 -0
  129. package/src/scripts/backup/backup-rules.test.ts +105 -0
  130. package/src/scripts/backup/d1-target.ts +54 -0
  131. package/src/scripts/backup/destination-config.ts +92 -0
  132. package/src/scripts/backup/encrypt-backup.ts +107 -0
  133. package/src/scripts/backup/naming.ts +62 -0
  134. package/src/scripts/check-god-files.test.ts +131 -0
  135. package/src/scripts/check-god-files.ts +327 -0
  136. package/src/scripts/ensure-spa-shell-stub.ts +34 -0
  137. package/src/scripts/init-secrets.ts +265 -0
  138. package/src/scripts/lib/cli-args.ts +154 -0
  139. package/src/scripts/lib/github-actions.ts +41 -0
  140. package/src/scripts/lib/wrangler-table.ts +105 -0
  141. package/src/scripts/migrations-lock.ts +52 -0
  142. package/src/scripts/prepare-wrangler-config.ts +51 -0
  143. package/src/scripts/spa-shell.test.ts +91 -0
  144. package/src/scripts/spa-shell.ts +179 -0
  145. package/src/scripts/validate-locales.test.ts +89 -0
  146. package/src/scripts/validate-locales.ts +380 -0
  147. package/src/scripts/verify-migrations.test.ts +71 -0
  148. package/src/scripts/verify-migrations.ts +364 -0
  149. package/src/scripts/verify-spa-shell.ts +56 -0
  150. package/src/scripts/wrangler-config/cli.ts +51 -0
  151. package/src/scripts/wrangler-config/patches.ts +157 -0
  152. package/src/scripts/wrangler-config/resources.ts +330 -0
  153. package/src/scripts/wrangler-config/types.ts +113 -0
  154. package/src/test/integration-migrations.test.ts +169 -0
  155. package/src/test/integration-migrations.ts +267 -0
  156. package/src/test/mocks/cloudflare-workers.ts +115 -0
  157. package/src/vite.test.ts +83 -0
  158. package/src/vite.ts +202 -0
  159. package/src/vitest-web.ts +109 -0
  160. package/src/vitest.ts +185 -0
@@ -0,0 +1,380 @@
1
+ /**
2
+ * Validate that every locale bundle carries exactly the base locale's keys.
3
+ *
4
+ * Provenance: `scripts/validate_locales.{mjs,ts}` in Durable-DAV, Edge-Git and
5
+ * Mail-Meow, and Edge-Sonic's `scripts/i18n/locale-checks.ts` +
6
+ * `validate_locales.ts`, converged. All four copies agree on the contract:
7
+ * flattened dotted key paths, two-way parity (missing AND extra), no empty values,
8
+ * no stray placeholders.
9
+ *
10
+ * ### What the convergence added
11
+ *
12
+ * Edge-Sonic's is the only copy that checks what a value *is*. The rest compare
13
+ * bundle to bundle, so with one shipped language the whole per-tag body is skipped
14
+ * and the script reports `ALL OK` having checked nothing about the application — it
15
+ * could not see that `libraries.scanPausedRequests` was used and absent, because
16
+ * the string in the bundle did not exist to be wrong *about* anything. Hence
17
+ * `--call-sites`: every `t('key')` under `apps/web/src` must resolve.
18
+ *
19
+ * Key **order** drift against the base is a warning, not a failure: locale files
20
+ * are maintained by mirroring new keys into every file, and failing on order would
21
+ * turn an otherwise-correct translation update into a CI failure about formatting.
22
+ *
23
+ * ### Three on-disk shapes, one checker
24
+ *
25
+ * The family ships bundles in three shapes, and the shape is an argument rather
26
+ * than a fork of the script:
27
+ *
28
+ * - `auto` (default) — every layout below, tried in order, so a repo that has
29
+ * half-migrated is reported rather than silently skipped;
30
+ * - `dir` — `<locales-dir>/<tag>/translation.json`, the i18next layout used by
31
+ * seven repos;
32
+ * - `file` — `<locales-dir>/<tag>.json`, one flat file per tag;
33
+ * - `module` — `<locales-dir>/<tag>.ts` exporting an object literal, which is the
34
+ * shape `@rexezuge/i18n` uses for the backend string bundles. The literal is
35
+ * extracted and normalised here, so the same gate covers the backend catalogs
36
+ * the kit ships and the SPA bundles the repos do.
37
+ */
38
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
39
+ import { pathToFileURL } from 'node:url';
40
+ import { join } from 'node:path';
41
+
42
+ /** The bundle layouts the checker understands. */
43
+ type LocaleShape = 'auto' | 'dir' | 'file' | 'module';
44
+
45
+ /** One defect, kept as a locale plus a message so a caller can group them. */
46
+ interface LocaleProblem {
47
+ locale: string;
48
+ problem: string;
49
+ }
50
+
51
+ /** The outcome of one run. */
52
+ interface LocaleReport {
53
+ locales: string[];
54
+ keys: number;
55
+ problems: LocaleProblem[];
56
+ }
57
+
58
+ /** Every dotted key path in a bundle, depth first. */
59
+ function flatten(value: unknown, prefix = ''): string[] {
60
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
61
+ return prefix ? [prefix] : [];
62
+ }
63
+ const entries = Object.entries(value as Record<string, unknown>);
64
+ if (entries.length === 0) return prefix ? [prefix] : [];
65
+ return entries.flatMap(([key, child]) => flatten(child, prefix ? `${prefix}.${key}` : key));
66
+ }
67
+
68
+ /**
69
+ * Read a bundle as a value, in whichever shape it is written.
70
+ *
71
+ * `module` is the shape `@rexezuge/i18n` ships: `const enStrings: BackendStrings =
72
+ * { common: { unauthorized: '…' } };`. Extracting the literal rather than importing
73
+ * the module is the point — a validator that imported the bundle would typecheck it
74
+ * against the kit's own types, and therefore agree with whatever it was given.
75
+ */
76
+ function readBundle(path: string): unknown {
77
+ const source = readFileSync(path, 'utf8');
78
+ // Decided by the file's extension, not by the caller's `shape`: under `auto` a
79
+ // `.ts` bundle is found, and reading it as JSON would report a parse error for a
80
+ // file that is perfectly well-formed.
81
+ return path.endsWith('.ts') ? parseObjectLiteral(source, path) : (JSON.parse(source) as unknown);
82
+ }
83
+
84
+ /**
85
+ * The first balanced object literal in a TypeScript module, as a value.
86
+ *
87
+ * Brace-matched outside strings and comments, and started at the **first
88
+ * assignment** rather than at the first brace — see `objectLiteralStart` for why the
89
+ * difference is the whole rule. Single-quoted strings are normalised to
90
+ * double-quoted JSON, bare keys are quoted, and trailing commas are tolerated: that
91
+ * is all the shape this needs, and a translator file that needs more is not a
92
+ * translator file.
93
+ */
94
+ /**
95
+ * Where the object literal begins: the first brace **after the first assignment**.
96
+ *
97
+ * The assignment matters. `const enStrings: BackendStrings = { ... }` is preceded by
98
+ * an import line whose own braces open earlier in the file, so a plain
99
+ * `indexOf('{')` returns the import's pair and the "literal" extracted is
100
+ * `{ BackendStrings }` — an empty object that parses cleanly and then reports the
101
+ * base bundle as having no keys. Finding the assignment first skips every brace that
102
+ * is not the value being bound to it.
103
+ */
104
+ function objectLiteralStart(source: string): number {
105
+ let quote = '';
106
+ let inLineComment = false;
107
+ let inBlockComment = false;
108
+ for (let index = 0; index < source.length; index += 1) {
109
+ const char = source[index] as string;
110
+ const next = source[index + 1];
111
+
112
+ if (inLineComment) {
113
+ if (char === '\n') inLineComment = false;
114
+ continue;
115
+ }
116
+ if (inBlockComment) {
117
+ if (char === '*' && next === '/') {
118
+ inBlockComment = false;
119
+ index += 1;
120
+ }
121
+ continue;
122
+ }
123
+ if (quote !== '') {
124
+ if (char === '\\') {
125
+ index += 1;
126
+ continue;
127
+ }
128
+ if (char === quote) quote = '';
129
+ continue;
130
+ }
131
+ if (char === '-' && next === '-') {
132
+ inLineComment = true;
133
+ continue;
134
+ }
135
+ if (char === '/' && next === '*') {
136
+ inBlockComment = true;
137
+ continue;
138
+ }
139
+ if (char === "'" || char === '"' || char === '`') {
140
+ quote = char;
141
+ continue;
142
+ }
143
+ // The assignment, and not a comparison or an arrow: `=>` and `==` both contain an
144
+ // `=`, and neither is where a bundle's object literal is attached.
145
+ const previous = source[index - 1];
146
+ if (char === '=' && next !== '=' && next !== '>' && previous !== '=' && previous !== '!' && previous !== '<' && previous !== '>') {
147
+ const brace = source.indexOf('{', index);
148
+ return brace === -1 ? -1 : brace;
149
+ }
150
+ }
151
+ return -1;
152
+ }
153
+
154
+ function parseObjectLiteral(source: string, path: string): unknown {
155
+ const start = objectLiteralStart(source);
156
+ if (start === -1) {
157
+ throw new Error(`${path} has no object literal`);
158
+ }
159
+
160
+ let depth = 0;
161
+ let end = -1;
162
+ let quote = '';
163
+ let inLineComment = false;
164
+ let inBlockComment = false;
165
+ for (let index = start; index < source.length; index += 1) {
166
+ const char = source[index] as string;
167
+ const next = source[index + 1];
168
+
169
+ if (inLineComment) {
170
+ if (char === '\n') inLineComment = false;
171
+ continue;
172
+ }
173
+ if (inBlockComment) {
174
+ if (char === '*' && next === '/') {
175
+ inBlockComment = false;
176
+ index += 1;
177
+ }
178
+ continue;
179
+ }
180
+ if (quote !== '') {
181
+ // An escape consumes the next character, so `\'` does not close the string.
182
+ if (char === '\\') {
183
+ index += 1;
184
+ continue;
185
+ }
186
+ if (char === quote) quote = '';
187
+ continue;
188
+ }
189
+ if (char === '-' && next === '-') {
190
+ inLineComment = true;
191
+ continue;
192
+ }
193
+ if (char === '/' && next === '*') {
194
+ inBlockComment = true;
195
+ continue;
196
+ }
197
+ if (char === "'" || char === '"' || char === '`') {
198
+ quote = char;
199
+ continue;
200
+ }
201
+ if (char === '{') {
202
+ depth += 1;
203
+ } else if (char === '}') {
204
+ depth -= 1;
205
+ if (depth === 0) {
206
+ end = index;
207
+ break;
208
+ }
209
+ }
210
+ }
211
+ if (end === -1) {
212
+ throw new Error(`${path} has an unterminated object literal`);
213
+ }
214
+
215
+ const literal = source.slice(start, end + 1);
216
+ // Three normalisations, each of which a JSON parser needs and a TS literal does not:
217
+ // single-quoted strings become double-quoted (applied only to string literals, so a
218
+ // value containing an apostrophe keeps it), bare keys get quoted, and trailing
219
+ // commas are dropped. A translator file that needs more than that is not a
220
+ // translator file.
221
+ const doubleQuoted = literal.replaceAll(/'((?:[^'\\]|\\.)*)'/g, (_match, body: string) => JSON.stringify(unescapeLiteral(body)));
222
+ const quotedKeys = doubleQuoted.replaceAll(/([{,]\s*)([A-Za-z_$][\w$]*)(\s*:)/g, '$1"$2"$3');
223
+ const withoutTrailingCommas = quotedKeys.replaceAll(/,(\s*[}\]])/g, '$1');
224
+
225
+ try {
226
+ return JSON.parse(withoutTrailingCommas) as unknown;
227
+ } catch (error: unknown) {
228
+ throw new Error(`${path} object literal could not be parsed: ${error instanceof Error ? error.message : String(error)}`);
229
+ }
230
+ }
231
+
232
+ /** The escapes a translator file actually uses; anything else is passed through. */
233
+ function unescapeLiteral(body: string): string {
234
+ return body
235
+ .replaceAll(String.raw`\'`, "'")
236
+ .replaceAll(String.raw`\"`, '"')
237
+ .replaceAll(String.raw`\n`, '\n')
238
+ .replaceAll(String.raw`\t`, '\t')
239
+ .replaceAll(String.raw`\\`, '\\');
240
+ }
241
+
242
+ /** The extensions a flat layout uses, keyed by shape. */
243
+ const FLAT_EXTENSION: Record<'file' | 'module', string> = { file: 'json', module: 'ts' };
244
+
245
+ /** The layouts `shape` covers. `auto` covers all three, in the order they are tried. */
246
+ function layouts(shape: LocaleShape): Array<'dir' | 'file' | 'module'> {
247
+ return shape === 'auto' ? ['dir', 'file', 'module'] : [shape];
248
+ }
249
+
250
+ /** The tags on disk, in whichever layouts `shape` covers. */
251
+ function tagsOnDisk(localesDir: string, shape: LocaleShape): string[] {
252
+ const tags: string[] = [];
253
+ if (layouts(shape).includes('dir')) {
254
+ tags.push(
255
+ ...readdirSync(localesDir, { withFileTypes: true })
256
+ .filter((entry) => entry.isDirectory())
257
+ .map((entry) => entry.name),
258
+ );
259
+ }
260
+ for (const layout of layouts(shape)) {
261
+ if (layout === 'dir') continue;
262
+ const extension = FLAT_EXTENSION[layout];
263
+ tags.push(
264
+ ...readdirSync(localesDir)
265
+ .filter((name) => name.endsWith(`.${extension}`))
266
+ .map((name) => name.slice(0, -extension.length - 1)),
267
+ );
268
+ }
269
+ return tags;
270
+ }
271
+
272
+ /**
273
+ * The file a tag's bundle lives in, or `undefined` when it is not there.
274
+ *
275
+ * Every candidate is tried rather than only the one `shape` names, because a repo
276
+ * with a half-migrated tree is a repo whose missing half should be reported. A
277
+ * checker that only looked where it was told would read a bundle that exists as
278
+ * absent, and a missing bundle is the defect this script exists to find.
279
+ */
280
+ function bundleFor(localesDir: string, locale: string, shape: LocaleShape): string | undefined {
281
+ const candidates: string[] = [];
282
+ if (layouts(shape).includes('dir')) {
283
+ candidates.push(join(localesDir, locale, 'translation.json'));
284
+ }
285
+ for (const layout of layouts(shape)) {
286
+ if (layout === 'dir') continue;
287
+ candidates.push(join(localesDir, `${locale}.${FLAT_EXTENSION[layout]}`));
288
+ }
289
+ return candidates.find((candidate) => existsSync(candidate));
290
+ }
291
+
292
+ /**
293
+ * Validate a directory of locale bundles against `baseLocale`'s key set.
294
+ *
295
+ * Two-way parity: a key the base has and a bundle lacks renders the fallback, and
296
+ * a key a bundle has that the base lacks is dead weight nothing can translate from.
297
+ */
298
+ function validateLocales(localesDir: string, baseLocale = 'en', shape: LocaleShape = 'auto'): LocaleReport {
299
+ const problems: LocaleProblem[] = [];
300
+ if (!existsSync(localesDir)) {
301
+ problems.push({ locale: baseLocale, problem: `${localesDir} does not exist` });
302
+ return { locales: [], keys: 0, problems };
303
+ }
304
+
305
+ const baseFile = bundleFor(localesDir, baseLocale, shape);
306
+ if (baseFile === undefined) {
307
+ problems.push({ locale: baseLocale, problem: `no bundle found for the base locale (expected ${baseLocale}/translation.json)` });
308
+ return { locales: [], keys: 0, problems };
309
+ }
310
+
311
+ let baseKeys: Set<string>;
312
+ try {
313
+ baseKeys = new Set(flatten(readBundle(baseFile)));
314
+ } catch (error: unknown) {
315
+ problems.push({ locale: baseLocale, problem: `${baseFile}: ${error instanceof Error ? error.message : String(error)}` });
316
+ return { locales: [], keys: 0, problems };
317
+ }
318
+
319
+ if (baseKeys.size === 0) {
320
+ // An empty base is the one defect that reaches the most users: it is the fallback
321
+ // for every missing key, so an empty value here is indistinguishable from a
322
+ // missing bundle at runtime.
323
+ problems.push({ locale: baseLocale, problem: `${baseFile} has no keys — the base bundle is empty or unreadable` });
324
+ return { locales: [baseLocale], keys: 0, problems };
325
+ }
326
+
327
+ const locales = [...new Set([...tagsOnDisk(localesDir, shape), baseLocale])].sort();
328
+ for (const locale of locales) {
329
+ const file = bundleFor(localesDir, locale, shape);
330
+ if (file === undefined) {
331
+ problems.push({ locale, problem: 'no bundle found' });
332
+ continue;
333
+ }
334
+ let keys: Set<string>;
335
+ try {
336
+ keys = new Set(flatten(readBundle(file)));
337
+ } catch (error: unknown) {
338
+ problems.push({ locale, problem: `${file}: ${error instanceof Error ? error.message : String(error)}` });
339
+ continue;
340
+ }
341
+ for (const key of baseKeys) {
342
+ if (!keys.has(key)) problems.push({ locale, problem: `missing key: ${key}` });
343
+ }
344
+ for (const key of keys) {
345
+ if (!baseKeys.has(key)) problems.push({ locale, problem: `extra key not in ${baseLocale}: ${key}` });
346
+ }
347
+ }
348
+
349
+ return { locales, keys: baseKeys.size, problems };
350
+ }
351
+
352
+ /** CLI entry: `validateLocalesCli(['[locales-dir]?', '--shape=auto|dir|file|module'?, '--base=<tag>'?])`. */
353
+ function validateLocalesCli(argv: readonly string[] = process.argv.slice(2)): number {
354
+ const dir = argv.find((arg) => !arg.startsWith('--')) ?? 'apps/web/src/locales';
355
+ const shape = (argv.find((arg) => arg.startsWith('--shape='))?.slice('--shape='.length) ?? 'auto') as LocaleShape;
356
+ const base = argv.find((arg) => arg.startsWith('--base='))?.slice('--base='.length) ?? 'en';
357
+
358
+ const report = validateLocales(dir, base, shape);
359
+ if (report.problems.length > 0) {
360
+ console.error(`Locale validation failed for ${dir} (${report.problems.length} problem(s)):`);
361
+ for (const problem of report.problems) console.error(` - ${problem.locale}: ${problem.problem}`);
362
+ return 1;
363
+ }
364
+ console.log(`Locale validation passed (${report.locales.length} locales, ${report.keys} keys each, shape ${shape}).`);
365
+ return 0;
366
+ }
367
+
368
+ export { parseObjectLiteral, validateLocales, validateLocalesCli };
369
+ export type { LocaleProblem, LocaleReport, LocaleShape };
370
+
371
+ // Run only when this file is the entrypoint.
372
+ //
373
+ // The rules above are pure and the tests import them directly, so a module-level
374
+ // call would shell out (and exit) during a test run. The URL comparison rather than
375
+ // an unconditional call is the same guard Durable-DAV's init-secrets.ts uses, and
376
+ // it is what keeps the rule modules importable — which is the whole reason they are
377
+ // separate modules in the first place.
378
+ if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) {
379
+ process.exit(validateLocalesCli(process.argv.slice(2)));
380
+ }
@@ -0,0 +1,71 @@
1
+ import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { describe, expect, it } from 'vitest';
5
+ import { verifyMigrations, verifyMigrationsCli, LOCK_NAME } from './verify-migrations';
6
+
7
+ function tempMigrations(files: Record<string, string>): string {
8
+ const root = mkdtempSync(join(tmpdir(), 'migrations-'));
9
+ for (const [name, content] of Object.entries(files)) {
10
+ writeFileSync(join(root, name), content, 'utf8');
11
+ }
12
+ return root;
13
+ }
14
+
15
+ const ONE = 'CREATE TABLE users (id TEXT);';
16
+ const TWO = 'CREATE TABLE namespaces (user_id TEXT);';
17
+
18
+ describe('verifyMigrations', () => {
19
+ it('passes a correctly named, locked set', () => {
20
+ const root = tempMigrations({ '0001_init.sql': ONE, '0002_more.sql': TWO });
21
+ verifyMigrations(root, { write: true });
22
+ const report = verifyMigrations(root);
23
+ expect(report.problems).toStrictEqual([]);
24
+ expect(report.files).toStrictEqual(['0001_init.sql', '0002_more.sql']);
25
+ });
26
+
27
+ it('fails when the lock is missing', () => {
28
+ const root = tempMigrations({ '0001_init.sql': ONE });
29
+ const report = verifyMigrations(root);
30
+ expect(report.problems[0]?.problem).toContain('missing');
31
+ });
32
+
33
+ it('fails when a migration changed after locking', () => {
34
+ const root = tempMigrations({ '0001_init.sql': ONE });
35
+ verifyMigrations(root, { write: true });
36
+ writeFileSync(join(root, '0001_init.sql'), 'CREATE TABLE changed (id TEXT);', 'utf8');
37
+ const report = verifyMigrations(root);
38
+ expect(report.problems.map((problem) => problem.problem).join('\n')).toContain('changed after locking');
39
+ });
40
+
41
+ it('fails on a bad name or a gap in numbering', () => {
42
+ const bad = tempMigrations({ 'users.sql': ONE });
43
+ expect(verifyMigrations(bad).problems.map((problem) => problem.problem).join('\n')).toContain('does not match');
44
+
45
+ const gap = tempMigrations({ '0001_init.sql': ONE, '0003_skipped.sql': TWO });
46
+ expect(verifyMigrations(gap).problems.map((problem) => problem.problem).join('\n')).toContain('not contiguous');
47
+ });
48
+
49
+ it('fails when a locked migration no longer exists', async () => {
50
+ const root = tempMigrations({ '0001_init.sql': ONE, '0002_more.sql': TWO });
51
+ verifyMigrations(root, { write: true });
52
+ const { rmSync } = await import('node:fs');
53
+ rmSync(join(root, '0002_more.sql'));
54
+ expect(verifyMigrations(root).problems.map((problem) => problem.problem).join('\n')).toContain('no longer exists');
55
+ });
56
+
57
+ it('writes a lock with per-file sha256 entries', () => {
58
+ const root = tempMigrations({ '0001_init.sql': ONE });
59
+ const report = verifyMigrations(root, { write: true });
60
+ expect(report.problems).toStrictEqual([]);
61
+ const lock = JSON.parse(readFileSync(join(root, LOCK_NAME), 'utf8')) as { migrations: { sha256: string }[] };
62
+ expect(lock.migrations[0]?.sha256).toMatch(/^[a-f0-9]{64}$/);
63
+ });
64
+
65
+ it('CLI: verify fails unloked, --write fixes it, verify then passes', () => {
66
+ const root = tempMigrations({ '0001_init.sql': ONE });
67
+ expect(verifyMigrationsCli([root])).toBe(1);
68
+ expect(verifyMigrationsCli([root, '--write'])).toBe(0);
69
+ expect(verifyMigrationsCli([root])).toBe(0);
70
+ });
71
+ });