@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.
- package/CHANGELOG.md +196 -0
- package/LICENSE +21 -0
- package/README.md +593 -0
- package/dist/addressability.d.ts +245 -0
- package/dist/addressability.d.ts.map +1 -0
- package/dist/addressability.js +567 -0
- package/dist/addressability.js.map +1 -0
- package/dist/checker.d.ts +159 -0
- package/dist/checker.d.ts.map +1 -0
- package/dist/checker.js +177 -0
- package/dist/checker.js.map +1 -0
- package/dist/cli.d.ts +149 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +1049 -0
- package/dist/cli.js.map +1 -0
- package/dist/copy-gate.d.ts +107 -0
- package/dist/copy-gate.d.ts.map +1 -0
- package/dist/copy-gate.js +132 -0
- package/dist/copy-gate.js.map +1 -0
- package/dist/fingerprint.d.ts +61 -0
- package/dist/fingerprint.d.ts.map +1 -0
- package/dist/fingerprint.js +64 -0
- package/dist/fingerprint.js.map +1 -0
- package/dist/index.d.ts +47 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +63 -0
- package/dist/index.js.map +1 -0
- package/dist/locale-coverage.d.ts +227 -0
- package/dist/locale-coverage.d.ts.map +1 -0
- package/dist/locale-coverage.js +414 -0
- package/dist/locale-coverage.js.map +1 -0
- package/dist/passage.d.ts +294 -0
- package/dist/passage.d.ts.map +1 -0
- package/dist/passage.js +463 -0
- package/dist/passage.js.map +1 -0
- package/dist/path-exclusions.d.ts +114 -0
- package/dist/path-exclusions.d.ts.map +1 -0
- package/dist/path-exclusions.js +213 -0
- package/dist/path-exclusions.js.map +1 -0
- package/dist/registry.d.ts +61 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +64 -0
- package/dist/registry.js.map +1 -0
- package/dist/resolve.d.ts +33 -0
- package/dist/resolve.d.ts.map +1 -0
- package/dist/resolve.js +133 -0
- package/dist/resolve.js.map +1 -0
- package/dist/scan.d.ts +457 -0
- package/dist/scan.d.ts.map +1 -0
- package/dist/scan.js +1698 -0
- package/dist/scan.js.map +1 -0
- package/dist/schema.d.ts +81 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +482 -0
- package/dist/schema.js.map +1 -0
- package/dist/types.d.ts +268 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +40 -0
- package/dist/types.js.map +1 -0
- package/dist/voice/checker.d.ts +186 -0
- package/dist/voice/checker.d.ts.map +1 -0
- package/dist/voice/checker.js +483 -0
- package/dist/voice/checker.js.map +1 -0
- package/dist/voice/derivation-coverage.d.ts +168 -0
- package/dist/voice/derivation-coverage.d.ts.map +1 -0
- package/dist/voice/derivation-coverage.js +181 -0
- package/dist/voice/derivation-coverage.js.map +1 -0
- package/dist/voice/fields.d.ts +107 -0
- package/dist/voice/fields.d.ts.map +1 -0
- package/dist/voice/fields.js +144 -0
- package/dist/voice/fields.js.map +1 -0
- package/dist/voice/index.d.ts +19 -0
- package/dist/voice/index.d.ts.map +1 -0
- package/dist/voice/index.js +24 -0
- package/dist/voice/index.js.map +1 -0
- package/dist/voice/internal/parse-template.d.ts +27 -0
- package/dist/voice/internal/parse-template.d.ts.map +1 -0
- package/dist/voice/internal/parse-template.js +111 -0
- package/dist/voice/internal/parse-template.js.map +1 -0
- package/dist/voice/internal/pattern-safety.d.ts +111 -0
- package/dist/voice/internal/pattern-safety.d.ts.map +1 -0
- package/dist/voice/internal/pattern-safety.js +278 -0
- package/dist/voice/internal/pattern-safety.js.map +1 -0
- package/dist/voice/schema.d.ts +65 -0
- package/dist/voice/schema.d.ts.map +1 -0
- package/dist/voice/schema.js +535 -0
- package/dist/voice/schema.js.map +1 -0
- package/dist/voice/types.d.ts +282 -0
- package/dist/voice/types.d.ts.map +1 -0
- package/dist/voice/types.js +37 -0
- package/dist/voice/types.js.map +1 -0
- package/package.json +72 -0
- package/src/addressability.ts +664 -0
- package/src/checker.ts +293 -0
- package/src/cli.ts +1166 -0
- package/src/copy-gate.ts +197 -0
- package/src/fingerprint.ts +66 -0
- package/src/index.ts +163 -0
- package/src/locale-coverage.ts +512 -0
- package/src/passage.ts +644 -0
- package/src/path-exclusions.ts +265 -0
- package/src/registry.ts +103 -0
- package/src/resolve.ts +172 -0
- package/src/scan.ts +1959 -0
- package/src/schema.ts +528 -0
- package/src/types.ts +301 -0
- package/src/voice/checker.ts +590 -0
- package/src/voice/derivation-coverage.ts +217 -0
- package/src/voice/fields.ts +160 -0
- package/src/voice/index.ts +55 -0
- package/src/voice/internal/parse-template.ts +114 -0
- package/src/voice/internal/pattern-safety.ts +336 -0
- package/src/voice/schema.ts +585 -0
- package/src/voice/types.ts +329 -0
- 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
|
+
}
|