@clossys/writer 0.3.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.
Files changed (115) hide show
  1. package/CHANGELOG.md +196 -0
  2. package/LICENSE +21 -0
  3. package/README.md +593 -0
  4. package/dist/addressability.d.ts +245 -0
  5. package/dist/addressability.d.ts.map +1 -0
  6. package/dist/addressability.js +567 -0
  7. package/dist/addressability.js.map +1 -0
  8. package/dist/checker.d.ts +159 -0
  9. package/dist/checker.d.ts.map +1 -0
  10. package/dist/checker.js +177 -0
  11. package/dist/checker.js.map +1 -0
  12. package/dist/cli.d.ts +149 -0
  13. package/dist/cli.d.ts.map +1 -0
  14. package/dist/cli.js +1049 -0
  15. package/dist/cli.js.map +1 -0
  16. package/dist/copy-gate.d.ts +107 -0
  17. package/dist/copy-gate.d.ts.map +1 -0
  18. package/dist/copy-gate.js +132 -0
  19. package/dist/copy-gate.js.map +1 -0
  20. package/dist/fingerprint.d.ts +61 -0
  21. package/dist/fingerprint.d.ts.map +1 -0
  22. package/dist/fingerprint.js +64 -0
  23. package/dist/fingerprint.js.map +1 -0
  24. package/dist/index.d.ts +47 -0
  25. package/dist/index.d.ts.map +1 -0
  26. package/dist/index.js +63 -0
  27. package/dist/index.js.map +1 -0
  28. package/dist/locale-coverage.d.ts +227 -0
  29. package/dist/locale-coverage.d.ts.map +1 -0
  30. package/dist/locale-coverage.js +414 -0
  31. package/dist/locale-coverage.js.map +1 -0
  32. package/dist/passage.d.ts +294 -0
  33. package/dist/passage.d.ts.map +1 -0
  34. package/dist/passage.js +463 -0
  35. package/dist/passage.js.map +1 -0
  36. package/dist/path-exclusions.d.ts +114 -0
  37. package/dist/path-exclusions.d.ts.map +1 -0
  38. package/dist/path-exclusions.js +213 -0
  39. package/dist/path-exclusions.js.map +1 -0
  40. package/dist/registry.d.ts +61 -0
  41. package/dist/registry.d.ts.map +1 -0
  42. package/dist/registry.js +64 -0
  43. package/dist/registry.js.map +1 -0
  44. package/dist/resolve.d.ts +33 -0
  45. package/dist/resolve.d.ts.map +1 -0
  46. package/dist/resolve.js +133 -0
  47. package/dist/resolve.js.map +1 -0
  48. package/dist/scan.d.ts +457 -0
  49. package/dist/scan.d.ts.map +1 -0
  50. package/dist/scan.js +1698 -0
  51. package/dist/scan.js.map +1 -0
  52. package/dist/schema.d.ts +81 -0
  53. package/dist/schema.d.ts.map +1 -0
  54. package/dist/schema.js +482 -0
  55. package/dist/schema.js.map +1 -0
  56. package/dist/types.d.ts +268 -0
  57. package/dist/types.d.ts.map +1 -0
  58. package/dist/types.js +40 -0
  59. package/dist/types.js.map +1 -0
  60. package/dist/voice/checker.d.ts +186 -0
  61. package/dist/voice/checker.d.ts.map +1 -0
  62. package/dist/voice/checker.js +483 -0
  63. package/dist/voice/checker.js.map +1 -0
  64. package/dist/voice/derivation-coverage.d.ts +168 -0
  65. package/dist/voice/derivation-coverage.d.ts.map +1 -0
  66. package/dist/voice/derivation-coverage.js +181 -0
  67. package/dist/voice/derivation-coverage.js.map +1 -0
  68. package/dist/voice/fields.d.ts +107 -0
  69. package/dist/voice/fields.d.ts.map +1 -0
  70. package/dist/voice/fields.js +144 -0
  71. package/dist/voice/fields.js.map +1 -0
  72. package/dist/voice/index.d.ts +19 -0
  73. package/dist/voice/index.d.ts.map +1 -0
  74. package/dist/voice/index.js +24 -0
  75. package/dist/voice/index.js.map +1 -0
  76. package/dist/voice/internal/parse-template.d.ts +27 -0
  77. package/dist/voice/internal/parse-template.d.ts.map +1 -0
  78. package/dist/voice/internal/parse-template.js +111 -0
  79. package/dist/voice/internal/parse-template.js.map +1 -0
  80. package/dist/voice/internal/pattern-safety.d.ts +111 -0
  81. package/dist/voice/internal/pattern-safety.d.ts.map +1 -0
  82. package/dist/voice/internal/pattern-safety.js +278 -0
  83. package/dist/voice/internal/pattern-safety.js.map +1 -0
  84. package/dist/voice/schema.d.ts +65 -0
  85. package/dist/voice/schema.d.ts.map +1 -0
  86. package/dist/voice/schema.js +535 -0
  87. package/dist/voice/schema.js.map +1 -0
  88. package/dist/voice/types.d.ts +282 -0
  89. package/dist/voice/types.d.ts.map +1 -0
  90. package/dist/voice/types.js +37 -0
  91. package/dist/voice/types.js.map +1 -0
  92. package/package.json +72 -0
  93. package/src/addressability.ts +664 -0
  94. package/src/checker.ts +293 -0
  95. package/src/cli.ts +1166 -0
  96. package/src/copy-gate.ts +197 -0
  97. package/src/fingerprint.ts +66 -0
  98. package/src/index.ts +163 -0
  99. package/src/locale-coverage.ts +512 -0
  100. package/src/passage.ts +644 -0
  101. package/src/path-exclusions.ts +265 -0
  102. package/src/registry.ts +103 -0
  103. package/src/resolve.ts +172 -0
  104. package/src/scan.ts +1959 -0
  105. package/src/schema.ts +528 -0
  106. package/src/types.ts +301 -0
  107. package/src/voice/checker.ts +590 -0
  108. package/src/voice/derivation-coverage.ts +217 -0
  109. package/src/voice/fields.ts +160 -0
  110. package/src/voice/index.ts +55 -0
  111. package/src/voice/internal/parse-template.ts +114 -0
  112. package/src/voice/internal/pattern-safety.ts +336 -0
  113. package/src/voice/schema.ts +585 -0
  114. package/src/voice/types.ts +329 -0
  115. package/templates/voice-record.template.jsonc +135 -0
@@ -0,0 +1,512 @@
1
+ /**
2
+ * `checkLocaleCoverage` — governance over WHICH locales a copy registry set
3
+ * actually covers, and where they have drifted from each other. This is the
4
+ * other half of the split this package's README states plainly: translation
5
+ * RUNTIME (ICU plural rules, locale negotiation, date/number formatting) is
6
+ * out of scope, deliberately left to `Intl` and a consumer's own choice —
7
+ * see `types.ts`'s doc comment on `CopyLocale`. Translation GOVERNANCE —
8
+ * is every locale covered, has the source locale drifted ahead of its
9
+ * translations — is already this package's job: `CopyLocale`,
10
+ * locale-keyed registries, and `resolve.ts`'s `"locale-mismatch"` issue all
11
+ * already exist. This file adds the axis that was still missing: not
12
+ * "does THIS registry resolve", but "does the WHOLE SET of a project's
13
+ * locales actually cover the same ground, with translations that are still
14
+ * current and interpolate correctly".
15
+ *
16
+ * Four governance questions, one axis each — all implemented:
17
+ *
18
+ * 1. MISSING COVERAGE — an entry the source locale has that a target
19
+ * locale does not (`"locale-coverage:missing-entry"`).
20
+ * 2. ORPHANED ENTRIES — an entry a target locale has that the source
21
+ * locale does not — a translation of something that no longer exists
22
+ * in the source (`"locale-coverage:orphaned-entry"`).
23
+ * 3. STALE TRANSLATIONS — a target-locale entry whose recorded
24
+ * `translation.sourceFingerprint` no longer matches the source entry's
25
+ * current text (`"locale-coverage:stale-entry"`), or — when the target
26
+ * entry carries no provenance at all — that staleness genuinely
27
+ * cannot be evaluated (`"locale-coverage:provenance-missing"`). See
28
+ * "STALE TRANSLATIONS, NOW IMPLEMENTED" below.
29
+ * 4. INTERPOLATION PARITY — a placeholder name declared by one locale's
30
+ * entry and missing from the other's, checked both directions
31
+ * (`"locale-coverage:interpolation-missing"`,
32
+ * `"locale-coverage:interpolation-extra"`).
33
+ *
34
+ * STALE TRANSLATIONS, NOW IMPLEMENTED
35
+ * ---------------------------------------------------------------------------
36
+ * This package previously left staleness unimplemented — `CopyRegistryEntry`
37
+ * carried no per-entry revision or approval timestamp, only
38
+ * `status: CopyEntryStatus` (a lifecycle state, not a version marker), and
39
+ * the only revision-shaped field anywhere in the contract,
40
+ * `CopyRegistry.revision: string`, was declared at the WHOLE-REGISTRY level
41
+ * and documented as an opaque, unordered provenance string — a git SHA, a
42
+ * CMS revision id, and an ISO date were all valid values, and only one of
43
+ * those three is safely comparable with `<`. Comparing it would have meant
44
+ * inventing an ordering this package's own contract explicitly promised not
45
+ * to interpret. Rather than fake the check, every run unconditionally
46
+ * pushed a `"locale-coverage:staleness-not-checked"` finding instead.
47
+ *
48
+ * `CopyRegistryEntry.translation?: CopyTranslationProvenance` (`types.ts`)
49
+ * closes that gap with per-entry, CONTENT-DERIVED provenance instead of a
50
+ * human-maintained counter: `translation.sourceFingerprint` records
51
+ * `computeCopyFingerprint(sourceEntry.text)` (`fingerprint.ts`, `node:crypto`
52
+ * `sha256`) at the moment a translation was produced. Content-derived, not
53
+ * hand-bumped, because a hand-bumped revision requires someone to remember
54
+ * to update it every time source copy changes, and nothing enforces that
55
+ * discipline — it can drift silently, indistinguishable at the type level
56
+ * from a genuinely current one. A content hash cannot drift: if the source
57
+ * `text` changed by one character, a fresh `computeCopyFingerprint` call
58
+ * returns a different value with certainty; if it did not change, the same
59
+ * value with equal certainty. See `fingerprint.ts`'s and `types.ts`'s own
60
+ * doc comments for the fuller argument.
61
+ *
62
+ * `checkLocaleCoverage` now, for every target entry that exists in both
63
+ * locales (i.e. already excluded from missing/orphaned):
64
+ *
65
+ * - if the entry carries `translation`, recomputes
66
+ * `computeCopyFingerprint(sourceEntry.text)` and compares it to
67
+ * `translation.sourceFingerprint`. A mismatch is
68
+ * `"locale-coverage:stale-entry"` (`"warning"`) — the translation is
69
+ * real, current-shaped data that has fallen behind.
70
+ * - if the entry carries no `translation` at all, emits
71
+ * `"locale-coverage:provenance-missing"` (`"warning"`) instead. This is
72
+ * load-bearing, not decoration: `translation` is optional (see
73
+ * BACKWARD COMPATIBILITY below), so an entry with none is neither
74
+ * "checked and current" nor "checked and stale" — it is "cannot tell".
75
+ * Collapsing that into either of the other two would recreate exactly
76
+ * the silent-pass failure mode this file previously avoided by naming
77
+ * `"staleness-not-checked"` on every run. Now that a real per-entry
78
+ * verdict exists for entries that DO carry provenance,
79
+ * `"provenance-missing"` is that same "cannot tell" honesty, narrowed to
80
+ * the one entry it is actually true of, instead of a blanket notice
81
+ * covering every entry regardless of whether it could have been
82
+ * checked.
83
+ *
84
+ * `"locale-coverage:staleness-not-checked"` is REMOVED — not gated, not
85
+ * deprecated, deleted — because leaving it in place once a real per-entry
86
+ * verdict exists would be a gate reporting a gap it no longer has, actively
87
+ * misleading a caller into believing the whole axis is still unchecked.
88
+ *
89
+ * INTERPOLATION PARITY, BOTH DIRECTIONS
90
+ * ---------------------------------------------------------------------------
91
+ * For every entry present in both locales — independent of whether it
92
+ * carries `translation` — this file also compares `placeholders` (`{name}`
93
+ * interpolations, `types.ts`'s `CopyEntry.placeholders`) between the source
94
+ * and target entry. A name the source declares but the target's translation
95
+ * omits renders a broken sentence at runtime: `resolve.ts`'s `interpolate`
96
+ * only fills placeholders the entry itself declares, so a value with
97
+ * nowhere to interpolate into is a hole in the rendered text
98
+ * (`"locale-coverage:interpolation-missing"`). A name the target declares
99
+ * that the source never did renders the opposite failure: an unfilled,
100
+ * literal `{name}` token sent straight to a user
101
+ * (`"locale-coverage:interpolation-extra"`). Both are real correctness
102
+ * bugs, both `"error"` severity — matching `schema.ts`'s existing
103
+ * `placeholder-missing-from-text` precedent for the same class of bug
104
+ * within one locale — never `"warning"`: unlike staleness, which can be
105
+ * "cannot tell", a placeholder set is present-or-absent, always computable
106
+ * for any two entries with well-formed `placeholders` arrays, so there is
107
+ * no "cannot tell" state for this axis.
108
+ *
109
+ * BACKWARD COMPATIBILITY
110
+ * ---------------------------------------------------------------------------
111
+ * `translation` is optional on `CopyRegistryEntry` at the TYPE level, and
112
+ * `schema.ts`'s `validateCopyRegistryShape` does not require it — an entry
113
+ * with no `translation` remains a fully valid `CopyRegistryEntry`, and no
114
+ * registry that predates this field becomes invalid overnight.
115
+ * `report.complete` is unaffected by provenance presence or absence: it
116
+ * continues to mean only "every declared target locale was evaluated",
117
+ * exactly as before this file's own doc comment already stated —
118
+ * presence/absence of `translation` produces FINDINGS
119
+ * (`stale-entry`/`provenance-missing`), never a decline. The one rule this
120
+ * file will not bend on: `"locale-coverage:provenance-missing"` and
121
+ * `"locale-coverage:stale-entry"` are never the same finding and never
122
+ * collapsed into one signal. A caller must be able to tell apart three
123
+ * genuinely different outcomes for any one target entry — "checked, and
124
+ * still current" (no finding at all), "checked, and it's stale"
125
+ * (`stale-entry`), and "could not check — no provenance recorded"
126
+ * (`provenance-missing`). Folding the third into either of the first two is
127
+ * exactly the silent-pass failure this file exists to refuse.
128
+ *
129
+ * FAIL-CLOSED DESIGN, matching `checker.ts`'s `checkCopyRecord` precedent
130
+ * ---------------------------------------------------------------------------
131
+ * An empty registry set, a source locale with zero entries, or a declared
132
+ * target locale entirely absent from the registries given to this run must
133
+ * never look like a clean pass — each is its own named finding, and each
134
+ * collapses `report.complete` to `false`. `report.complete` means "every
135
+ * declared target locale was actually evaluated", never "everything found
136
+ * was fine" — the same non-negotiable split `checker.ts` and `voice`'s own
137
+ * `checkCopy` both draw for their own `.complete` fields. No branch in this
138
+ * file returns an empty `findings` array on a decline path; every early
139
+ * return states, in `findings`, exactly why nothing further was checked.
140
+ *
141
+ * NON-GOAL, RESTATED: no competing i18n runtime. Everything above is
142
+ * translation GOVERNANCE — is a translation current, does it interpolate
143
+ * correctly. Translation RUNTIME — ICU plural rules, locale negotiation,
144
+ * date/number formatting — remains out of scope and belongs to `Intl`
145
+ * (`Intl.PluralRules`, `Intl.ListFormat`, `Intl.RelativeTimeFormat`,
146
+ * `Intl.NumberFormat`) plus a consumer's own locale-negotiation policy, per
147
+ * the README's "Where this package sits on i18n". Nothing in this file
148
+ * formats, negotiates, or pluralizes anything, and it never will.
149
+ */
150
+
151
+ import { computeCopyFingerprint } from "./fingerprint.js";
152
+ import { validateCopyRegistryShape } from "./schema.js";
153
+ import type { CopyEntryId, CopyFinding, CopyLocale, CopyRegistry, CopyRegistryEntry } from "./types.js";
154
+
155
+ /** One thing `checkLocaleCoverage` found wrong, tagged with the locale and/or entry it concerns. */
156
+ export interface LocaleCoverageFinding extends CopyFinding {
157
+ /** The locale this finding is about. Absent for a finding about the declared-locale set as a whole (e.g. no locales declared at all). */
158
+ locale?: CopyLocale;
159
+ /** The specific entry id this finding is about, when there is one. */
160
+ entryId?: CopyEntryId;
161
+ }
162
+
163
+ /** Why a declared target locale was not actually evaluated this run. */
164
+ export type LocaleCoverageSkipReason =
165
+ | "target-locale-missing"
166
+ | "target-registry-invalid"
167
+ | "target-registry-locale-mismatch";
168
+
169
+ /** One declared target locale `checkLocaleCoverage` did NOT evaluate, and why — mirrors `checker.ts`'s `CopyEntrySkip`. */
170
+ export interface LocaleCoverageSkip {
171
+ locale: CopyLocale;
172
+ reason: LocaleCoverageSkipReason;
173
+ }
174
+
175
+ export interface LocaleCoverageReport {
176
+ /** Echoed back for a caller building its own report header. */
177
+ sourceLocale: CopyLocale;
178
+ /** Every locale this run was told the project must cover, `sourceLocale` included, in the order given. */
179
+ declaredLocales: CopyLocale[];
180
+ /** `declaredLocales` minus `sourceLocale` — every locale this run attempted to check coverage for. */
181
+ targetLocales: CopyLocale[];
182
+ /** Target locales actually evaluated (had a valid, correctly-keyed registry to compare against the source). */
183
+ checkedLocales: CopyLocale[];
184
+ /** Target locales NOT evaluated, and why — never silently folded into a report that looks like every declared locale was checked. */
185
+ skippedLocales: LocaleCoverageSkip[];
186
+ /** `sourceRegistry.entries.length`, once a valid source registry was found — `0` while a decline finding explains why no source registry was reached at all. */
187
+ sourceEntryCount: number;
188
+ /**
189
+ * Every finding this run produced: structural declines (empty registry
190
+ * set, missing/invalid/empty source locale, a declared target locale
191
+ * entirely absent), missing coverage, orphaned entries, stale entries,
192
+ * entries with no translation provenance to check staleness against, and
193
+ * interpolation-parity mismatches (both directions). Populated on every
194
+ * decline path — never left empty to imply a clean run that in fact never
195
+ * started. This is the one field a caller checking "is anything wrong"
196
+ * should read; read `.complete`, not an empty `findings`, to decide
197
+ * whether a run can be trusted as "checked everything it could".
198
+ */
199
+ findings: LocaleCoverageFinding[];
200
+ /**
201
+ * `true` exactly when: at least one locale was declared, `sourceLocale`
202
+ * is among them, the registry set was non-empty, the source locale's
203
+ * registry was present/valid/non-empty and correctly keyed, and every
204
+ * OTHER declared locale was actually evaluated (`skippedLocales` is
205
+ * empty). Independent of whether any finding was produced — a project
206
+ * with real missing-coverage or orphaned-entry findings can still be
207
+ * `complete: true` (every declared locale got checked; some of them
208
+ * failed). A mapping to this package's usual three-state exit-code
209
+ * contract (0 clean / 1 findings / 2 could not run), for a caller that
210
+ * wants one: `!complete` → 2, `complete && findings.some(f =>
211
+ * f.severity === "error")` → 1, otherwise → 0.
212
+ */
213
+ complete: boolean;
214
+ }
215
+
216
+ function decline(
217
+ sourceLocale: CopyLocale,
218
+ declaredLocales: CopyLocale[],
219
+ targetLocales: CopyLocale[],
220
+ finding: LocaleCoverageFinding,
221
+ ): LocaleCoverageReport {
222
+ return {
223
+ sourceLocale,
224
+ declaredLocales,
225
+ targetLocales,
226
+ checkedLocales: [],
227
+ skippedLocales: [],
228
+ sourceEntryCount: 0,
229
+ findings: [finding],
230
+ complete: false,
231
+ };
232
+ }
233
+
234
+ function uniqueInOrder(values: readonly CopyLocale[]): CopyLocale[] {
235
+ const seen = new Set<CopyLocale>();
236
+ const result: CopyLocale[] = [];
237
+ for (const value of values) {
238
+ if (!seen.has(value)) {
239
+ seen.add(value);
240
+ result.push(value);
241
+ }
242
+ }
243
+ return result;
244
+ }
245
+
246
+ /**
247
+ * Compares one target-locale entry against its source-locale counterpart
248
+ * for the two per-entry axes beyond missing/orphaned: staleness (via
249
+ * content fingerprint) and interpolation parity (both directions). Called
250
+ * only for an entry id present in BOTH `sourceEntry` and `targetEntry` —
251
+ * missing/orphaned entries never reach this function, since there is no
252
+ * counterpart to compare against. See this file's top-of-file doc comment,
253
+ * "STALE TRANSLATIONS, NOW IMPLEMENTED" and "INTERPOLATION PARITY, BOTH
254
+ * DIRECTIONS", for the full argument behind each finding pushed here.
255
+ */
256
+ function checkEntryProvenanceAndInterpolation(
257
+ sourceEntry: CopyRegistryEntry,
258
+ targetEntry: CopyRegistryEntry,
259
+ sourceLocale: CopyLocale,
260
+ targetLocale: CopyLocale,
261
+ findings: LocaleCoverageFinding[],
262
+ ): void {
263
+ // --- staleness: content fingerprint, or "cannot tell" if none was recorded ---
264
+ if (targetEntry.translation === undefined) {
265
+ findings.push({
266
+ rule: "locale-coverage:provenance-missing",
267
+ severity: "warning",
268
+ message: `Entry "${targetEntry.id}" in locale "${targetLocale}" carries no translation provenance — staleness cannot be evaluated for it. This is distinct from "checked and not stale": it means "cannot tell".`,
269
+ path: targetEntry.id,
270
+ locale: targetLocale,
271
+ entryId: targetEntry.id,
272
+ });
273
+ } else {
274
+ const currentFingerprint = computeCopyFingerprint(sourceEntry.text);
275
+ if (targetEntry.translation.sourceFingerprint !== currentFingerprint) {
276
+ findings.push({
277
+ rule: "locale-coverage:stale-entry",
278
+ severity: "warning",
279
+ message: `Entry "${targetEntry.id}" in locale "${targetLocale}" was translated from source text fingerprinted as "${targetEntry.translation.sourceFingerprint}", but source locale "${sourceLocale}"'s current text fingerprints as "${currentFingerprint}" — the translation is behind an edited source.`,
280
+ path: targetEntry.id,
281
+ locale: targetLocale,
282
+ entryId: targetEntry.id,
283
+ });
284
+ }
285
+ }
286
+
287
+ // --- interpolation parity, both directions ---------------------------------
288
+ const sourcePlaceholders = new Set(sourceEntry.placeholders ?? []);
289
+ const targetPlaceholders = new Set(targetEntry.placeholders ?? []);
290
+
291
+ for (const name of sourcePlaceholders) {
292
+ if (!targetPlaceholders.has(name)) {
293
+ findings.push({
294
+ rule: "locale-coverage:interpolation-missing",
295
+ severity: "error",
296
+ message: `Entry "${targetEntry.id}" in locale "${targetLocale}" is missing placeholder "{${name}}", which source locale "${sourceLocale}"'s entry declares — the translation will render with a required value that has nowhere to interpolate into.`,
297
+ path: targetEntry.id,
298
+ locale: targetLocale,
299
+ entryId: targetEntry.id,
300
+ });
301
+ }
302
+ }
303
+ for (const name of targetPlaceholders) {
304
+ if (!sourcePlaceholders.has(name)) {
305
+ findings.push({
306
+ rule: "locale-coverage:interpolation-extra",
307
+ severity: "error",
308
+ message: `Entry "${targetEntry.id}" in locale "${targetLocale}" declares placeholder "{${name}}", which source locale "${sourceLocale}"'s entry does not — the translation will render an unfilled "{${name}}" token to a user.`,
309
+ path: targetEntry.id,
310
+ locale: targetLocale,
311
+ entryId: targetEntry.id,
312
+ });
313
+ }
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Checks that every `declaredLocales` entry other than `sourceLocale` covers
319
+ * the same set of entry ids `registries[sourceLocale]` declares, and reports
320
+ * any entry each target locale is missing or has orphaned. For every entry
321
+ * present in both locales, also checks staleness (via content fingerprint)
322
+ * and interpolation parity — see this file's top-of-file doc comment for
323
+ * the full design and the rule names each check can produce.
324
+ *
325
+ * `registries` is deliberately typed to accept `unknown` per-locale values
326
+ * and validated here via `validateCopyRegistryShape`, the same "do not trust
327
+ * a typed input at runtime" discipline `checker.ts`'s `checkCopyRecord`
328
+ * documents for itself: a plain-JS caller, or a value from parsed JSON that
329
+ * merely satisfies the type at compile time, is exactly the case a check
330
+ * that "passes while asserting nothing" would fail to catch.
331
+ *
332
+ * Never throws. Every input shape — an empty `registries` object, an empty
333
+ * `declaredLocales` array, a `sourceLocale` not present in either, a
334
+ * malformed registry at any key — produces a `LocaleCoverageReport` with
335
+ * `complete: false` and a finding explaining exactly what could not be
336
+ * checked, never an exception and never a silently empty result.
337
+ */
338
+ export function checkLocaleCoverage(
339
+ registries: Readonly<Record<CopyLocale, unknown>>,
340
+ sourceLocale: CopyLocale,
341
+ declaredLocales: readonly CopyLocale[],
342
+ ): LocaleCoverageReport {
343
+ const declared = uniqueInOrder(declaredLocales);
344
+ const targetLocales = declared.filter((locale) => locale !== sourceLocale);
345
+
346
+ // --- fail closed: nothing declared at all ---------------------------------
347
+ if (declared.length === 0) {
348
+ return decline(sourceLocale, declared, targetLocales, {
349
+ rule: "locale-coverage:no-declared-locales",
350
+ severity: "error",
351
+ message:
352
+ "declaredLocales is empty — there is no locale set to check coverage against. At minimum, sourceLocale must be declared. An empty declared-locale set must never report clean.",
353
+ });
354
+ }
355
+
356
+ // --- fail closed: sourceLocale itself was not declared --------------------
357
+ if (!declared.includes(sourceLocale)) {
358
+ return decline(sourceLocale, declared, targetLocales, {
359
+ rule: "locale-coverage:source-locale-not-declared",
360
+ severity: "error",
361
+ message: `sourceLocale "${sourceLocale}" is not present in declaredLocales (${declared.join(", ") || "(empty)"}). The source locale must be one of the locales this run is told to cover.`,
362
+ locale: sourceLocale,
363
+ });
364
+ }
365
+
366
+ // --- fail closed: an empty registry set --------------------------------
367
+ const registryLocales = Object.keys(registries);
368
+ if (registryLocales.length === 0) {
369
+ return decline(sourceLocale, declared, targetLocales, {
370
+ rule: "locale-coverage:no-registries",
371
+ severity: "error",
372
+ message:
373
+ "registries is empty — there is nothing to check coverage against. An empty registry set must never report clean.",
374
+ });
375
+ }
376
+
377
+ // --- fail closed: declared source locale entirely absent ------------------
378
+ if (!Object.hasOwn(registries, sourceLocale)) {
379
+ return decline(sourceLocale, declared, targetLocales, {
380
+ rule: "locale-coverage:source-locale-missing",
381
+ severity: "error",
382
+ message: `sourceLocale "${sourceLocale}" is declared but has no registry in the given registry set — there is no source of truth to check other locales against.`,
383
+ locale: sourceLocale,
384
+ });
385
+ }
386
+
387
+ // --- fail closed: source registry is not structurally valid --------------
388
+ const sourceCandidate = registries[sourceLocale];
389
+ const sourceShapeFindings = validateCopyRegistryShape(sourceCandidate);
390
+ if (sourceShapeFindings.length > 0) {
391
+ const detail = sourceShapeFindings.map((f) => `${f.path ?? "(root)"}: ${f.message}`).join("; ");
392
+ return decline(sourceLocale, declared, targetLocales, {
393
+ rule: "locale-coverage:source-registry-invalid",
394
+ severity: "error",
395
+ message: `The registry for source locale "${sourceLocale}" is not a valid CopyRegistry: ${detail}`,
396
+ locale: sourceLocale,
397
+ });
398
+ }
399
+ const sourceRegistry = sourceCandidate as CopyRegistry;
400
+
401
+ // --- fail closed: the registry keyed under sourceLocale claims a different locale ---
402
+ if (sourceRegistry.locale !== sourceLocale) {
403
+ return decline(sourceLocale, declared, targetLocales, {
404
+ rule: "locale-coverage:source-registry-locale-mismatch",
405
+ severity: "error",
406
+ message: `The registry keyed under source locale "${sourceLocale}" declares locale "${sourceRegistry.locale}" — the registries map key and the registry's own locale must agree, or a caller cannot trust which locale is actually being read.`,
407
+ locale: sourceLocale,
408
+ });
409
+ }
410
+
411
+ // --- fail closed: source locale with zero entries -------------------------
412
+ if (sourceRegistry.entries.length === 0) {
413
+ return decline(sourceLocale, declared, targetLocales, {
414
+ rule: "locale-coverage:source-locale-empty",
415
+ severity: "error",
416
+ message: `Source locale "${sourceLocale}" has zero entries — there is nothing for other locales' coverage to be measured against. A source locale with zero entries must never report clean.`,
417
+ locale: sourceLocale,
418
+ });
419
+ }
420
+
421
+ const sourceIds = new Set(sourceRegistry.entries.map((entry) => entry.id));
422
+ const findings: LocaleCoverageFinding[] = [];
423
+ const checkedLocales: CopyLocale[] = [];
424
+ const skippedLocales: LocaleCoverageSkip[] = [];
425
+
426
+ for (const locale of targetLocales) {
427
+ // --- a declared target locale entirely absent from the registry set ---
428
+ if (!Object.hasOwn(registries, locale)) {
429
+ skippedLocales.push({ locale, reason: "target-locale-missing" });
430
+ findings.push({
431
+ rule: "locale-coverage:target-locale-missing",
432
+ severity: "error",
433
+ message: `Locale "${locale}" is declared but has no registry in the given registry set — its coverage could not be checked at all. A declared locale that is entirely absent must never report clean.`,
434
+ locale,
435
+ });
436
+ continue;
437
+ }
438
+
439
+ const targetCandidate = registries[locale];
440
+ const targetShapeFindings = validateCopyRegistryShape(targetCandidate);
441
+ if (targetShapeFindings.length > 0) {
442
+ const detail = targetShapeFindings.map((f) => `${f.path ?? "(root)"}: ${f.message}`).join("; ");
443
+ skippedLocales.push({ locale, reason: "target-registry-invalid" });
444
+ findings.push({
445
+ rule: "locale-coverage:target-registry-invalid",
446
+ severity: "error",
447
+ message: `The registry for locale "${locale}" is not a valid CopyRegistry, so its coverage could not be checked: ${detail}`,
448
+ locale,
449
+ });
450
+ continue;
451
+ }
452
+ const targetRegistry = targetCandidate as CopyRegistry;
453
+
454
+ if (targetRegistry.locale !== locale) {
455
+ skippedLocales.push({ locale, reason: "target-registry-locale-mismatch" });
456
+ findings.push({
457
+ rule: "locale-coverage:target-registry-locale-mismatch",
458
+ severity: "error",
459
+ message: `The registry keyed under locale "${locale}" declares locale "${targetRegistry.locale}" — the registries map key and the registry's own locale must agree, so its coverage could not be checked.`,
460
+ locale,
461
+ });
462
+ continue;
463
+ }
464
+
465
+ // --- the real work: compare entry ids against the source locale -------
466
+ checkedLocales.push(locale);
467
+ const targetById = new Map(targetRegistry.entries.map((entry) => [entry.id, entry]));
468
+
469
+ for (const entry of sourceRegistry.entries) {
470
+ const targetEntry = targetById.get(entry.id);
471
+ if (!targetEntry) {
472
+ findings.push({
473
+ rule: "locale-coverage:missing-entry",
474
+ severity: "error",
475
+ message: `Entry "${entry.id}" exists in source locale "${sourceLocale}" but has no counterpart in locale "${locale}".`,
476
+ path: entry.id,
477
+ locale,
478
+ entryId: entry.id,
479
+ });
480
+ continue;
481
+ }
482
+ // Present in both locales: staleness and interpolation parity are
483
+ // only meaningful for a real pair — see this function's own doc
484
+ // comment and this file's top-of-file "STALE TRANSLATIONS, NOW
485
+ // IMPLEMENTED" / "INTERPOLATION PARITY, BOTH DIRECTIONS".
486
+ checkEntryProvenanceAndInterpolation(entry, targetEntry, sourceLocale, locale, findings);
487
+ }
488
+ for (const entry of targetRegistry.entries) {
489
+ if (!sourceIds.has(entry.id)) {
490
+ findings.push({
491
+ rule: "locale-coverage:orphaned-entry",
492
+ severity: "warning",
493
+ message: `Entry "${entry.id}" exists in locale "${locale}" but has no counterpart in source locale "${sourceLocale}" — likely a translation of an entry that no longer exists.`,
494
+ path: entry.id,
495
+ locale,
496
+ entryId: entry.id,
497
+ });
498
+ }
499
+ }
500
+ }
501
+
502
+ return {
503
+ sourceLocale,
504
+ declaredLocales: declared,
505
+ targetLocales,
506
+ checkedLocales,
507
+ skippedLocales,
508
+ sourceEntryCount: sourceRegistry.entries.length,
509
+ findings,
510
+ complete: skippedLocales.length === 0,
511
+ };
512
+ }