@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
package/src/schema.ts ADDED
@@ -0,0 +1,528 @@
1
+ /**
2
+ * Structural validation for a candidate `CopyRecord`, hand-rolled in the
3
+ * same plain-type-guard, accumulate-and-keep-going style as
4
+ * `@example/strategy`'s `validation.ts` and
5
+ * `@clossys/writer/voice`'s `schema.ts` — no schema library, for the
6
+ * same reason both of those files give: this package's entire job is
7
+ * dependency-free data validation, and a public package should not force
8
+ * every consumer onto one schema library's major version for the sake of
9
+ * shape-checking a handful of nested objects.
10
+ *
11
+ * Every check here is a REAL check, not decoration — this package's own
12
+ * design brief was explicit that a validator whose findings are all
13
+ * mechanically re-derivable from data already present elsewhere is dead
14
+ * weight (this repository has already deleted one whole package,
15
+ * `contract`, for exactly that failure: it validated fields that were all
16
+ * mechanically re-derivable from data already present in the same
17
+ * `package.json`, so its own checks could never produce a real finding).
18
+ * The placeholder check in particular
19
+ * (`validateCopyEntryShape`'s handling of `placeholders`) is the one most
20
+ * likely to be mistaken for decoration and is not: a name declared in
21
+ * `placeholders` but missing from `text` is a real authoring bug — copy
22
+ * was edited and an interpolation was dropped, or the placeholder list
23
+ * drifted from the sentence — and this is the only place in this package
24
+ * that catches it.
25
+ */
26
+
27
+ import type {
28
+ CopyEntry,
29
+ CopyEntryId,
30
+ CopyEntryStatus,
31
+ CopyFinding,
32
+ CopyRegistry,
33
+ CopyRegistryEntry,
34
+ CopyRecord,
35
+ CopySource,
36
+ } from "./types.js";
37
+
38
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
39
+ return typeof value === "object" && value !== null && !Array.isArray(value);
40
+ }
41
+
42
+ function isNonEmptyString(value: unknown): value is string {
43
+ return typeof value === "string" && value.trim().length > 0;
44
+ }
45
+
46
+ function isCopyEntryStatus(value: unknown): value is CopyEntryStatus {
47
+ return value === "draft" || value === "approved" || value === "retired";
48
+ }
49
+
50
+ /**
51
+ * `CopyEntryId` shape: dot-separated, lowercase, kebab-case within each
52
+ * segment, at least one dot (i.e. at least two segments) — e.g.
53
+ * `"pagination.no-results"`, `"onboarding.step-2.title"`. See
54
+ * `types.ts`'s doc comment on `CopyEntryId` for why a bare, unnamespaced
55
+ * id is rejected rather than merely discouraged.
56
+ */
57
+ const COPY_ENTRY_ID_RE = /^[a-z0-9]+(-[a-z0-9]+)*(\.[a-z0-9]+(-[a-z0-9]+)*)+$/;
58
+
59
+ /**
60
+ * The literal placeholder syntax this package checks `text` against for
61
+ * each declared name in `placeholders`: `{name}`, brace-delimited — the
62
+ * same convention ICU MessageFormat and most i18n libraries already use.
63
+ * A plain substring check, not a regex scan of `text` for placeholder-
64
+ * *shaped* tokens: this function only ever needs to ask "does `{name}`
65
+ * appear in this text for this specific declared name", never "what
66
+ * placeholders does this text contain" — the latter would be scope creep
67
+ * into a job (extracting/parsing interpolation syntax) this package does
68
+ * not need in order to do the one check it actually promises.
69
+ */
70
+ function textContainsPlaceholder(text: string, name: string): boolean {
71
+ return text.includes(`{${name}}`);
72
+ }
73
+
74
+ /**
75
+ * Validates a field that is a list of strings — `entries` (elsewhere) and
76
+ * `placeholders` here share this exact shape and this exact validation.
77
+ * `undefined` is valid for `placeholders` (a default of `[]` applies
78
+ * later, in `buildCopyRecord`) — only a present-but-wrong-shaped value is
79
+ * a finding.
80
+ */
81
+ function validatePlaceholdersShape(value: unknown, path: string): CopyFinding[] {
82
+ if (value === undefined) return [];
83
+ if (!Array.isArray(value)) {
84
+ return [
85
+ {
86
+ rule: "placeholders-shape",
87
+ severity: "error",
88
+ message: `${path} must be an array of non-empty strings when present, got ${JSON.stringify(value)}.`,
89
+ path,
90
+ },
91
+ ];
92
+ }
93
+ const findings: CopyFinding[] = [];
94
+ value.forEach((item, i) => {
95
+ if (!isNonEmptyString(item)) {
96
+ findings.push({
97
+ rule: "placeholders-shape",
98
+ severity: "error",
99
+ message: `${path}.${i} must be a non-empty string, got ${JSON.stringify(item)}.`,
100
+ path: `${path}.${i}`,
101
+ });
102
+ }
103
+ });
104
+ return findings;
105
+ }
106
+
107
+ // Rules: entry-shape, id-shape, id-well-formed, text-shape, context-shape,
108
+ // placeholders-shape, placeholder-missing-from-text, fact-ref-shape
109
+ function validateCopyEntryShape(value: unknown, path: string): CopyFinding[] {
110
+ if (!isPlainObject(value)) {
111
+ return [{ rule: "entry-shape", severity: "error", message: `${path} must be an object.`, path }];
112
+ }
113
+ // Snapshot every field this function reads, exactly once, before any
114
+ // check runs — mirroring `@clossys/writer/voice`'s `schema.ts` and
115
+ // `@example/policy`'s `validateBindingShape`, both of which
116
+ // document why: a hostile or merely-badly-behaved `value` (a field
117
+ // implemented as a getter, or a Proxy) could otherwise return a
118
+ // different result on each property read.
119
+ const id = value.id;
120
+ const text = value.text;
121
+ const context = value.context;
122
+ const placeholders = value.placeholders;
123
+ const factRef = value.factRef;
124
+
125
+ const findings: CopyFinding[] = [];
126
+
127
+ if (!isNonEmptyString(id)) {
128
+ findings.push({
129
+ rule: "id-shape",
130
+ severity: "error",
131
+ message: `${path}.id must be a non-empty string, got ${JSON.stringify(id)}.`,
132
+ path: `${path}.id`,
133
+ });
134
+ } else if (!COPY_ENTRY_ID_RE.test(id)) {
135
+ findings.push({
136
+ rule: "id-well-formed",
137
+ severity: "error",
138
+ message: `${path}.id "${id}" must be dot-separated and lowercase, with at least one dot (e.g. "pagination.no-results"), got ${JSON.stringify(id)}.`,
139
+ path: `${path}.id`,
140
+ });
141
+ }
142
+
143
+ if (!isNonEmptyString(text)) {
144
+ findings.push({
145
+ rule: "text-shape",
146
+ severity: "error",
147
+ message: `${path}.text must be a non-empty string, got ${JSON.stringify(text)}.`,
148
+ path: `${path}.text`,
149
+ });
150
+ }
151
+
152
+ if (!isNonEmptyString(context)) {
153
+ findings.push({
154
+ rule: "context-shape",
155
+ severity: "error",
156
+ message: `${path}.context must be a non-empty string, got ${JSON.stringify(context)}. An entry with no stated location cannot be reviewed.`,
157
+ path: `${path}.context`,
158
+ });
159
+ }
160
+
161
+ findings.push(...validatePlaceholdersShape(placeholders, `${path}.placeholders`));
162
+
163
+ // The real check: a declared placeholder that never actually appears in
164
+ // `text`. Only checkable once both `text` and `placeholders` are
165
+ // individually well-formed — there is no meaningful "is {name} in the
166
+ // text" question to ask against a `text` that failed `text-shape` or a
167
+ // `placeholders` entry that failed `placeholders-shape`, and reporting
168
+ // this rule on top of an already-broken shape would only add noise
169
+ // pointing at the same underlying problem.
170
+ if (isNonEmptyString(text) && Array.isArray(placeholders)) {
171
+ placeholders.forEach((name, i) => {
172
+ if (typeof name === "string" && name.length > 0 && !textContainsPlaceholder(text, name)) {
173
+ findings.push({
174
+ rule: "placeholder-missing-from-text",
175
+ severity: "error",
176
+ message: `${path}.placeholders.${i} declares "${name}" but "{${name}}" does not appear in ${path}.text.`,
177
+ path: `${path}.placeholders.${i}`,
178
+ });
179
+ }
180
+ });
181
+ }
182
+
183
+ if (factRef !== undefined && !isNonEmptyString(factRef)) {
184
+ findings.push({
185
+ rule: "fact-ref-shape",
186
+ severity: "error",
187
+ message: `${path}.factRef must be a non-empty string when present, got ${JSON.stringify(factRef)}.`,
188
+ path: `${path}.factRef`,
189
+ });
190
+ }
191
+
192
+ return findings;
193
+ }
194
+
195
+ /**
196
+ * Validates that `value` conforms to the `CopyRecord` shape. Returns a
197
+ * `CopyFinding[]`; empty means `value` is a well-formed `CopyRecord`
198
+ * (once defaults are applied — see `parseCopyRecord`). Never throws — any
199
+ * input, including `null`, a string, or a wildly malformed object,
200
+ * produces findings rather than an exception, the same discipline
201
+ * `@clossys/writer/voice`'s `validateVoiceRecordShape` and
202
+ * `@example/policy`'s `validateBindingShape` both hold to.
203
+ *
204
+ * Checks, in full: `id` present and non-empty; `entries` present and an
205
+ * array; every entry's own shape (`id` non-empty and well-formed per
206
+ * `COPY_ENTRY_ID_RE`, `text` non-empty, `context` non-empty,
207
+ * `placeholders` — if present — an array of non-empty strings each
208
+ * actually found in `text`, `factRef` — if present — a non-empty
209
+ * string); and, at the record level, that every entry's `id` is unique
210
+ * within the record. `entries` being present but empty (`[]`) is a
211
+ * well-formed, zero-finding `CopyRecord` as far as THIS function is
212
+ * concerned — whether zero entries is itself a problem is a judgment call
213
+ * for a caller with more context than a shape validator has, and is
214
+ * exactly the call `checker.ts`'s `checkCopyRecord` makes (see that
215
+ * file's doc comment): it fails closed on a record with no entries to
216
+ * check, rather than silently reporting a "clean" run that checked
217
+ * nothing.
218
+ */
219
+ export function validateCopyRecordShape(value: unknown): CopyFinding[] {
220
+ if (!isPlainObject(value)) {
221
+ return [{ rule: "record-present", severity: "error", message: "A copy record must be an object.", path: "$" }];
222
+ }
223
+
224
+ const id = value.id;
225
+ const entries = value.entries;
226
+
227
+ const findings: CopyFinding[] = [];
228
+
229
+ if (!isNonEmptyString(id)) {
230
+ findings.push({
231
+ rule: "id-shape",
232
+ severity: "error",
233
+ message: `id must be a non-empty string, got ${JSON.stringify(id)}.`,
234
+ path: "id",
235
+ });
236
+ }
237
+
238
+ if (!Array.isArray(entries)) {
239
+ findings.push({
240
+ rule: "entries-shape",
241
+ severity: "error",
242
+ message: `entries must be an array, got ${JSON.stringify(entries)}.`,
243
+ path: "entries",
244
+ });
245
+ } else {
246
+ entries.forEach((entry, i) => findings.push(...validateCopyEntryShape(entry, `entries.${i}`)));
247
+
248
+ // Uniqueness is a whole-record property, not a per-entry one, so it is
249
+ // checked here rather than inside validateCopyEntryShape. Only entries
250
+ // that already passed their own id-shape check contribute a value to
251
+ // compare — an entry with a missing/malformed id is already reported
252
+ // above by id-shape/id-well-formed, and folding an undefined or
253
+ // non-string id into a "duplicate" comparison would produce a second,
254
+ // misleading finding about the same underlying problem.
255
+ const seen = new Map<CopyEntryId, number>(); // id -> first index seen at
256
+ entries.forEach((entry, i) => {
257
+ if (!isPlainObject(entry)) return;
258
+ const entryId = entry.id;
259
+ if (typeof entryId !== "string" || entryId.length === 0) return;
260
+ const firstIndex = seen.get(entryId);
261
+ if (firstIndex === undefined) {
262
+ seen.set(entryId, i);
263
+ } else {
264
+ findings.push({
265
+ rule: "id-unique",
266
+ severity: "error",
267
+ message: `entries.${i}.id "${entryId}" duplicates entries.${firstIndex}.id — every entry's id must be unique within a CopyRecord.`,
268
+ path: `entries.${i}.id`,
269
+ });
270
+ }
271
+ });
272
+ }
273
+
274
+ return findings;
275
+ }
276
+
277
+ /**
278
+ * Validates a `CopyRegistryEntry.translation` field, when present.
279
+ * `undefined` is always valid — see `types.ts`'s doc comment on
280
+ * `CopyTranslationProvenance` for why an entry with no `translation` must
281
+ * remain a fully valid `CopyRegistryEntry` rather than becoming invalid the
282
+ * moment this field was introduced. Only a present-but-malformed value is a
283
+ * finding.
284
+ */
285
+ function validateTranslationShape(value: unknown, path: string): CopyFinding[] {
286
+ if (value === undefined) return [];
287
+ if (!isPlainObject(value)) {
288
+ return [
289
+ {
290
+ rule: "translation-shape",
291
+ severity: "error",
292
+ message: `${path} must be an object when present, got ${JSON.stringify(value)}.`,
293
+ path,
294
+ },
295
+ ];
296
+ }
297
+
298
+ const sourceFingerprint = value.sourceFingerprint;
299
+ const fingerprintAlgorithm = value.fingerprintAlgorithm;
300
+ const translatedAt = value.translatedAt;
301
+ const translatedBy = value.translatedBy;
302
+ const reviewedAt = value.reviewedAt;
303
+ const reviewedBy = value.reviewedBy;
304
+
305
+ const findings: CopyFinding[] = [];
306
+
307
+ if (!isNonEmptyString(sourceFingerprint)) {
308
+ findings.push({
309
+ rule: "translation-source-fingerprint-shape",
310
+ severity: "error",
311
+ message: `${path}.sourceFingerprint must be a non-empty string, got ${JSON.stringify(sourceFingerprint)}.`,
312
+ path: `${path}.sourceFingerprint`,
313
+ });
314
+ }
315
+
316
+ if (!isNonEmptyString(fingerprintAlgorithm)) {
317
+ findings.push({
318
+ rule: "translation-fingerprint-algorithm-shape",
319
+ severity: "error",
320
+ message: `${path}.fingerprintAlgorithm must be a non-empty string, got ${JSON.stringify(fingerprintAlgorithm)}.`,
321
+ path: `${path}.fingerprintAlgorithm`,
322
+ });
323
+ }
324
+
325
+ if (!isNonEmptyString(translatedAt)) {
326
+ findings.push({
327
+ rule: "translation-translated-at-shape",
328
+ severity: "error",
329
+ message: `${path}.translatedAt must be a non-empty ISO-8601 timestamp string, got ${JSON.stringify(translatedAt)}.`,
330
+ path: `${path}.translatedAt`,
331
+ });
332
+ }
333
+
334
+ if (translatedBy !== undefined && !isNonEmptyString(translatedBy)) {
335
+ findings.push({
336
+ rule: "translation-translated-by-shape",
337
+ severity: "error",
338
+ message: `${path}.translatedBy must be a non-empty string when present, got ${JSON.stringify(translatedBy)}.`,
339
+ path: `${path}.translatedBy`,
340
+ });
341
+ }
342
+
343
+ if (reviewedAt !== undefined && !isNonEmptyString(reviewedAt)) {
344
+ findings.push({
345
+ rule: "translation-reviewed-at-shape",
346
+ severity: "error",
347
+ message: `${path}.reviewedAt must be a non-empty ISO-8601 timestamp string when present, got ${JSON.stringify(reviewedAt)}.`,
348
+ path: `${path}.reviewedAt`,
349
+ });
350
+ }
351
+
352
+ if (reviewedBy !== undefined && !isNonEmptyString(reviewedBy)) {
353
+ findings.push({
354
+ rule: "translation-reviewed-by-shape",
355
+ severity: "error",
356
+ message: `${path}.reviewedBy must be a non-empty string when present, got ${JSON.stringify(reviewedBy)}.`,
357
+ path: `${path}.reviewedBy`,
358
+ });
359
+ }
360
+
361
+ return findings;
362
+ }
363
+
364
+ /**
365
+ * Validates the stronger registry shape used to resolve `CopyRef`s. A plain
366
+ * `CopyRecord` deliberately remains valid for source scanning and voice
367
+ * checking; it cannot become rendered content until it carries locale,
368
+ * revision, source provenance, and an explicit lifecycle state per entry.
369
+ */
370
+ export function validateCopyRegistryShape(value: unknown): CopyFinding[] {
371
+ const findings = validateCopyRecordShape(value);
372
+ if (!isPlainObject(value)) return findings;
373
+
374
+ const locale = value.locale;
375
+ const revision = value.revision;
376
+ const source = value.source;
377
+ const entries = value.entries;
378
+
379
+ if (!isNonEmptyString(locale)) {
380
+ findings.push({
381
+ rule: "registry-locale-shape",
382
+ severity: "error",
383
+ message: `locale must be a non-empty locale tag, got ${JSON.stringify(locale)}.`,
384
+ path: "locale",
385
+ });
386
+ }
387
+
388
+ if (!isNonEmptyString(revision)) {
389
+ findings.push({
390
+ rule: "registry-revision-shape",
391
+ severity: "error",
392
+ message: `revision must be a non-empty provenance revision, got ${JSON.stringify(revision)}.`,
393
+ path: "revision",
394
+ });
395
+ }
396
+
397
+ if (!isPlainObject(source)) {
398
+ findings.push({
399
+ rule: "registry-source-shape",
400
+ severity: "error",
401
+ message: `source must be an object with kind and reference, got ${JSON.stringify(source)}.`,
402
+ path: "source",
403
+ });
404
+ } else {
405
+ const kind = source.kind;
406
+ const reference = source.reference;
407
+ if (kind !== "consumer" && kind !== "generated" && kind !== "imported") {
408
+ findings.push({
409
+ rule: "registry-source-kind",
410
+ severity: "error",
411
+ message: `source.kind must be "consumer", "generated", or "imported", got ${JSON.stringify(kind)}.`,
412
+ path: "source.kind",
413
+ });
414
+ }
415
+ if (!isNonEmptyString(reference)) {
416
+ findings.push({
417
+ rule: "registry-source-reference-shape",
418
+ severity: "error",
419
+ message: `source.reference must be a non-empty opaque provenance reference, got ${JSON.stringify(reference)}.`,
420
+ path: "source.reference",
421
+ });
422
+ }
423
+ }
424
+
425
+ if (Array.isArray(entries)) {
426
+ entries.forEach((entry, i) => {
427
+ if (!isPlainObject(entry)) return;
428
+ if (!isCopyEntryStatus(entry.status)) {
429
+ findings.push({
430
+ rule: "entry-status-shape",
431
+ severity: "error",
432
+ message: `entries.${i}.status must be "draft", "approved", or "retired", got ${JSON.stringify(entry.status)}.`,
433
+ path: `entries.${i}.status`,
434
+ });
435
+ }
436
+ findings.push(...validateTranslationShape(entry.translation, `entries.${i}.translation`));
437
+ });
438
+ }
439
+
440
+ return findings;
441
+ }
442
+
443
+ /**
444
+ * Builds a fully-defaulted `CopyRecord` from `value`, which the caller
445
+ * must already know passed `validateCopyRecordShape` with zero findings
446
+ * — this function does no validation of its own and will throw or
447
+ * produce garbage on unvalidated input. Only called internally, from
448
+ * `parseCopyRecord`, immediately after that check — mirrors
449
+ * `@clossys/writer/voice`'s `buildVoiceRecord` exactly, including that
450
+ * split's reasoning: see this file's `parseCopyRecord` doc comment for
451
+ * why this is a deliberate two-pass design.
452
+ */
453
+ function buildCopyRecord(value: Record<string, unknown>): CopyRecord {
454
+ const entriesRaw = value.entries as unknown[];
455
+
456
+ const builtEntries: CopyEntry[] = entriesRaw.map((raw) => {
457
+ const entry = raw as Record<string, unknown>;
458
+ return {
459
+ id: entry.id as string,
460
+ text: entry.text as string,
461
+ context: entry.context as string,
462
+ placeholders: [...((entry.placeholders as string[] | undefined) ?? [])],
463
+ factRef: entry.factRef as string | undefined,
464
+ };
465
+ });
466
+
467
+ return {
468
+ id: value.id as string,
469
+ entries: builtEntries,
470
+ };
471
+ }
472
+
473
+ function buildCopyRegistry(value: Record<string, unknown>): CopyRegistry {
474
+ const record = buildCopyRecord(value);
475
+ const entries = (value.entries as Array<Record<string, unknown>>).map((entry) => ({
476
+ ...record.entries.find((item) => item.id === entry.id)!,
477
+ status: entry.status as CopyEntryStatus,
478
+ translation: entry.translation as CopyRegistryEntry["translation"],
479
+ })) as CopyRegistryEntry[];
480
+
481
+ return {
482
+ ...record,
483
+ locale: value.locale as string,
484
+ revision: value.revision as string,
485
+ source: value.source as CopySource,
486
+ entries,
487
+ };
488
+ }
489
+
490
+ /**
491
+ * Parses `value` as a `CopyRecord`, throwing a plain `Error` (never a
492
+ * `CopyFinding[]`) if it does not conform. For a caller that wants
493
+ * fail-fast construction — `registry.ts`'s `readCopyRecord`, in
494
+ * particular — rather than `validateCopyRecordShape`'s "collect every
495
+ * problem and keep going" shape. The thrown message includes every issue
496
+ * `validateCopyRecordShape` would have reported, joined, so nothing is
497
+ * lost by throwing instead of returning.
498
+ *
499
+ * Calls `validateCopyRecordShape(value)` and, only if it reports zero
500
+ * findings, makes one further pass over `value` (`buildCopyRecord`) to
501
+ * apply this schema's one default (`placeholders: []`) and produce a
502
+ * fully populated `CopyRecord`. This two-pass shape mirrors
503
+ * `@clossys/writer/voice`'s `parseVoiceRecord` — see that function's own
504
+ * doc comment for the fuller TOCTOU-safety argument for why reading twice
505
+ * is a deliberate choice here, not an oversight, given this package's
506
+ * actual threat model (a consumer's own in-memory config object, not a
507
+ * value crossing a real trust boundary).
508
+ */
509
+ export function parseCopyRecord(value: unknown): CopyRecord {
510
+ const findings = validateCopyRecordShape(value);
511
+ if (findings.length > 0) {
512
+ const detail = findings.map((f) => ` - ${f.path ?? "(root)"}: ${f.message}`).join("\n");
513
+ throw new Error(`parseCopyRecord: value is not a valid CopyRecord:\n${detail}`);
514
+ }
515
+ // Safe: validateCopyRecordShape returning no findings means every field
516
+ // buildCopyRecord reads below is present and correctly typed.
517
+ return buildCopyRecord(value as Record<string, unknown>);
518
+ }
519
+
520
+ /** Parses a versioned, locale-specific registry or throws every finding. */
521
+ export function parseCopyRegistry(value: unknown): CopyRegistry {
522
+ const findings = validateCopyRegistryShape(value);
523
+ if (findings.length > 0) {
524
+ const detail = findings.map((f) => ` - ${f.path ?? "(root)"}: ${f.message}`).join("\n");
525
+ throw new Error(`parseCopyRegistry: value is not a valid CopyRegistry:\n${detail}`);
526
+ }
527
+ return buildCopyRegistry(value as Record<string, unknown>);
528
+ }