@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/checker.ts ADDED
@@ -0,0 +1,293 @@
1
+ /**
2
+ * `checkCopyRecord` — runs every entry in a `CopyRecord` through
3
+ * `@clossys/writer/voice`'s own `checkCopy`, against a single
4
+ * `VoiceRecord`, and reports the result. This is the one file in this
5
+ * package that imports `@clossys/writer/voice`: `types.ts` and
6
+ * `schema.ts` are pure, dependency-free data/validation, exactly like
7
+ * `voice`'s own `types.ts`/`schema.ts`; this file is where the two
8
+ * packages' machinery actually meet.
9
+ *
10
+ * FIVE AGENTS FINISHED A REPAIR ROUND ON THIS REPOSITORY AN HOUR BEFORE
11
+ * THIS FILE WAS WRITTEN, AND EVERY ONE OF THEM SHIPPED A FIRST DRAFT THAT
12
+ * REPRODUCED THE EXACT DEFECT IT WAS FIXING. The recurring shape was "a
13
+ * check that passes while asserting nothing" — `checkCopyRecord` IS a
14
+ * check, so this file is written against that failure mode directly, not
15
+ * as an afterthought:
16
+ *
17
+ * 1. NO SILENT NARROWING. If an entry is not run through `checkCopy`
18
+ * for any reason, it is recorded in `report.skipped`, by id — never
19
+ * folded silently into a "0 findings" result that looks identical
20
+ * to "everything ran and was clean". `report.checkedCount` and
21
+ * `report.skippedCount` are always populated (not just inferable
22
+ * from array lengths), so a caller reading only those two numbers,
23
+ * without inspecting either array, still cannot mistake one for the
24
+ * other.
25
+ * 2. FAILS CLOSED. An invalid `CopyRecord`, an invalid `VoiceRecord`,
26
+ * or a `CopyRecord` with zero entries are each treated as a
27
+ * FAILURE — `report.complete` is `false` and `report.findings`
28
+ * names the reason — never as a clean pass with nothing to check.
29
+ * `checkCopyRecord` re-validates BOTH its arguments itself
30
+ * (`validateCopyRecordShape` and, imported from `voice`,
31
+ * `validateVoiceRecordShape`), even though both are typed inputs a
32
+ * TypeScript caller "should" have already validated — because a
33
+ * plain-JS caller, or a value that merely satisfies the type at
34
+ * compile time without actually conforming at runtime, is exactly
35
+ * the case a check that "passes while asserting nothing" would fail
36
+ * to catch. This is a deliberate difference from `voice`'s own
37
+ * `checkCopy`, which does NOT self-validate its `VoiceRecord`
38
+ * argument (see that function's own doc comment) — this package
39
+ * chooses the stricter behavior for its own entry point because it
40
+ * is the thing this whole package's design brief was written to
41
+ * distrust in itself.
42
+ * 3. `report.complete` MEANS "did this run check everything it could
43
+ * have", never "did everything pass" — the same non-negotiable
44
+ * distinction `voice`'s own `VoiceCheckReport.complete` draws. A
45
+ * `CopyRecord` with real voice violations in it can still be
46
+ * `complete: true` (every entry got checked; some of them failed);
47
+ * a `CopyRecord` this function refused to check at all is always
48
+ * `complete: false`, no matter how "clean" an empty `findings`
49
+ * array might otherwise look.
50
+ *
51
+ * Pure, no I/O: `checkCopyRecord` never reads a file — that is
52
+ * `registry.ts`'s job — and never resolves a `factRef` against anything,
53
+ * for the same reason `voice`'s `checkCopy` does not: see
54
+ * `@clossys/writer/voice`'s README, "The `factRef` seam".
55
+ */
56
+
57
+ import {
58
+ checkCopy,
59
+ validateVoiceRecordShape,
60
+ type VoiceCheckReport,
61
+ type VoiceCheckWaiver,
62
+ type VoiceFinding,
63
+ type VoiceRecord,
64
+ } from "./voice/index.js";
65
+ import { validateCopyRecordShape } from "./schema.js";
66
+ import type { CopyEntryId, CopyRecord } from "./types.js";
67
+
68
+ /** One entry `checkCopyRecord` did NOT run through `voice`'s `checkCopy` this run, and why. */
69
+ export interface CopyEntrySkip {
70
+ entryId: CopyEntryId;
71
+ /**
72
+ * Human-readable reason. In practice always because the `CopyRecord` or
73
+ * `VoiceRecord` given to this call failed its own shape validation —
74
+ * see `report.findings` for the specific violation(s). An entry that
75
+ * passes `validateCopyRecordShape` (id well-formed and unique, `text`
76
+ * and `context` non-empty, every declared placeholder present) is
77
+ * always run, never skipped for a reason internal to the entry itself;
78
+ * `voice`'s own `checkCopy` has its own, separately-reported notion of
79
+ * an incomplete run (see `CopyEntryCheckResult.report.skipped`) for a
80
+ * dimension it could not evaluate against a structurally valid entry.
81
+ */
82
+ reason: string;
83
+ }
84
+
85
+ /** One entry `checkCopyRecord` DID run through `voice`'s `checkCopy`, and the resulting report. */
86
+ export interface CopyEntryCheckResult {
87
+ entryId: CopyEntryId;
88
+ /**
89
+ * `voice`'s own `checkCopy` report for this entry's `text`, unmodified.
90
+ * Its own `.complete`/`.skipped` describe whether `checkCopy` itself
91
+ * could evaluate every dimension against this ONE entry — a separate
92
+ * question from whether `checkCopyRecord` ran this entry at all.
93
+ */
94
+ report: VoiceCheckReport;
95
+ }
96
+
97
+ /** One `VoiceFinding` from one entry's `checkCopy` run, flattened into `report.findings` and tagged with the entry it came from. */
98
+ export interface CopyRecordFinding extends VoiceFinding {
99
+ /** The entry this finding is about, or `undefined` for a record-level finding not specific to any one entry (an invalid `CopyRecord`/`VoiceRecord`, or zero entries). */
100
+ entryId?: CopyEntryId;
101
+ }
102
+
103
+ /** A `CopyRecordFinding` that was waived, with the waiver that covered it — mirrors `voice`'s `WaivedVoiceFinding`. */
104
+ export interface CopyRecordWaivedFinding extends CopyRecordFinding {
105
+ waiver: VoiceCheckWaiver;
106
+ }
107
+
108
+ export interface CopyRecordCheckOptions {
109
+ /**
110
+ * Waivers applied uniformly to EVERY entry's `checkCopy` call — the
111
+ * same `VoiceCheckWaiver[]` shape `voice`'s own `checkCopy` accepts.
112
+ * There is no per-entry waiver targeting in this version: a waiver
113
+ * scoped to one entry's finding still matches the same `rule`+`path`
114
+ * pair in every other entry's report too. A caller that genuinely needs
115
+ * per-entry waiver scoping can call `voice`'s `checkCopy` directly,
116
+ * per entry, with its own waiver list — this package's own `checker.ts`
117
+ * does exactly that under the hood.
118
+ */
119
+ waivers?: VoiceCheckWaiver[];
120
+ }
121
+
122
+ export interface CopyRecordCheckReport {
123
+ recordId: string;
124
+ /** Every entry actually run through `checkCopy`, with its own `VoiceCheckReport`. */
125
+ checked: CopyEntryCheckResult[];
126
+ /** Every entry NOT run, by id, and why. Empty does not by itself mean "nothing was skipped for a bad reason" — see `report.complete`, which is `false` whenever this run could not be trusted enough to check every entry, including the cases where this is legitimately `[]` (zero entries; see `checkedCount`). */
127
+ skipped: CopyEntrySkip[];
128
+ /** `checked.length`, restated as its own field so a caller does not have to inspect the array to get this number — see this file's top-of-file "no silent narrowing" note. */
129
+ checkedCount: number;
130
+ /** `skipped.length`, restated as its own field for the same reason as `checkedCount`. */
131
+ skippedCount: number;
132
+ /**
133
+ * Every non-waived finding this run produced, flattened across every
134
+ * checked entry AND any record-level problem (an invalid `CopyRecord`,
135
+ * an invalid `VoiceRecord`, or zero entries), each entry-sourced
136
+ * finding tagged with the `entryId` it came from. This is the one field
137
+ * a caller checking "is anything wrong" should read — it is populated
138
+ * in every failure case this function knows about, never left empty to
139
+ * imply a clean run when the run in fact could not be trusted (see
140
+ * `.complete`).
141
+ */
142
+ findings: CopyRecordFinding[];
143
+ /** Every finding covered by a waiver, across every checked entry, each with the waiver that covered it — mirrors `voice`'s `VoiceCheckReport.waived`. */
144
+ waived: CopyRecordWaivedFinding[];
145
+ /**
146
+ * `true` exactly when: the given `CopyRecord` was structurally valid,
147
+ * the given `VoiceRecord` was structurally valid, the record had at
148
+ * least one entry, and every entry was actually run (`skipped` is
149
+ * empty). `false` in every other case. Independent of `findings` —
150
+ * see this file's top-of-file note #3. Read this field, not an empty
151
+ * `findings` array, to decide whether a run can be trusted as "checked
152
+ * everything, found nothing".
153
+ */
154
+ complete: boolean;
155
+ }
156
+
157
+ function recordLevelFailure(
158
+ recordId: string,
159
+ findings: CopyRecordFinding[],
160
+ skipped: CopyEntrySkip[],
161
+ ): CopyRecordCheckReport {
162
+ return {
163
+ recordId,
164
+ checked: [],
165
+ skipped,
166
+ checkedCount: 0,
167
+ skippedCount: skipped.length,
168
+ findings,
169
+ waived: [],
170
+ complete: false,
171
+ };
172
+ }
173
+
174
+ /**
175
+ * Best-effort per-entry skip attribution for a `CopyRecord` whose own
176
+ * shape is invalid. `record.entries` is typed as `CopyEntry[]`, but the
177
+ * whole point of the branch that calls this is that the type cannot be
178
+ * trusted at runtime — so this reads defensively, using whatever id it
179
+ * can find and falling back to a positional label (`"$3"`, meaning index
180
+ * 3) for an entry too malformed to have a usable id at all. Never throws.
181
+ */
182
+ function bestEffortSkipList(entriesValue: unknown, reason: string): CopyEntrySkip[] {
183
+ if (!Array.isArray(entriesValue)) return [];
184
+ return entriesValue.map((entry, i) => {
185
+ const hasUsableId =
186
+ typeof entry === "object" &&
187
+ entry !== null &&
188
+ !Array.isArray(entry) &&
189
+ typeof (entry as Record<string, unknown>).id === "string" &&
190
+ ((entry as Record<string, unknown>).id as string).length > 0;
191
+ const entryId = hasUsableId ? ((entry as Record<string, unknown>).id as string) : `$${i}`;
192
+ return { entryId, reason };
193
+ });
194
+ }
195
+
196
+ /**
197
+ * Checks every entry in `record` against `voiceRecord`, via `voice`'s own
198
+ * `checkCopy`. See this file's top-of-file doc comment for the fail-
199
+ * closed behavior on an invalid `record`, an invalid `voiceRecord`, and a
200
+ * `record` with zero entries.
201
+ *
202
+ * Throws a plain `Error` (not a finding) for caller-input problems that
203
+ * are not this function's job to interpret as data: `record` or
204
+ * `voiceRecord` not an object at all. This mirrors `voice`'s own
205
+ * `checkCopy`, which treats a malformed call the same way — a hard
206
+ * upfront rejection, not a soft finding that could be mistaken for
207
+ * something the checker discovered about the record itself. A `record`
208
+ * or `voiceRecord` that IS an object but fails shape validation is,
209
+ * unlike a non-object, a real finding (`report.findings`), not a thrown
210
+ * error — see note #2 above for why this function validates both itself
211
+ * rather than trusting its own type signature.
212
+ */
213
+ export function checkCopyRecord(
214
+ record: CopyRecord,
215
+ voiceRecord: VoiceRecord,
216
+ options: CopyRecordCheckOptions = {},
217
+ ): CopyRecordCheckReport {
218
+ if (record === null || typeof record !== "object") {
219
+ throw new TypeError("checkCopyRecord: record must be a CopyRecord object");
220
+ }
221
+ if (voiceRecord === null || typeof voiceRecord !== "object") {
222
+ throw new TypeError("checkCopyRecord: voiceRecord must be a VoiceRecord object");
223
+ }
224
+
225
+ const recordIdForReport = isNonEmptyStringField(record, "id") ? (record as { id: string }).id : "(unknown)";
226
+
227
+ // --- fail closed: the CopyRecord itself must be well-formed --------------
228
+ const recordShapeFindings = validateCopyRecordShape(record);
229
+ if (recordShapeFindings.length > 0) {
230
+ const findings: CopyRecordFinding[] = recordShapeFindings.map((f) => ({ ...f }));
231
+ const entriesValue = (record as unknown as Record<string, unknown>).entries;
232
+ return recordLevelFailure(recordIdForReport, findings, bestEffortSkipList(entriesValue, "record-shape-invalid"));
233
+ }
234
+
235
+ // --- fail closed: the VoiceRecord itself must be well-formed --------------
236
+ // Unlike voice's own checkCopy, this function does not trust its typed
237
+ // input — see this file's top-of-file note #2 for why.
238
+ const voiceShapeFindings = validateVoiceRecordShape(voiceRecord);
239
+ if (voiceShapeFindings.length > 0) {
240
+ const findings: CopyRecordFinding[] = voiceShapeFindings.map((f) => ({ ...f }));
241
+ // record is now known well-formed (previous branch would have returned
242
+ // otherwise), so every entry has a real, unique id to attribute a skip to.
243
+ const skipped: CopyEntrySkip[] = record.entries.map((entry) => ({
244
+ entryId: entry.id,
245
+ reason: "voice-record-invalid",
246
+ }));
247
+ return recordLevelFailure(recordIdForReport, findings, skipped);
248
+ }
249
+
250
+ // --- fail closed: zero entries is nothing to check, not a clean pass -----
251
+ if (record.entries.length === 0) {
252
+ const findings: CopyRecordFinding[] = [
253
+ {
254
+ rule: "record:no-entries",
255
+ severity: "error",
256
+ message: `CopyRecord "${recordIdForReport}" has zero entries — there is nothing for checkCopyRecord to check.`,
257
+ },
258
+ ];
259
+ return recordLevelFailure(recordIdForReport, findings, []);
260
+ }
261
+
262
+ // --- the real work: run every entry through voice's checkCopy ------------
263
+ const checked: CopyEntryCheckResult[] = [];
264
+ const findings: CopyRecordFinding[] = [];
265
+ const waived: CopyRecordWaivedFinding[] = [];
266
+
267
+ for (const entry of record.entries) {
268
+ const entryReport = checkCopy(voiceRecord, entry.text, { waivers: options.waivers });
269
+ checked.push({ entryId: entry.id, report: entryReport });
270
+ for (const finding of entryReport.findings) {
271
+ findings.push({ ...finding, entryId: entry.id });
272
+ }
273
+ for (const w of entryReport.waived) {
274
+ waived.push({ ...w, entryId: entry.id });
275
+ }
276
+ }
277
+
278
+ return {
279
+ recordId: recordIdForReport,
280
+ checked,
281
+ skipped: [],
282
+ checkedCount: checked.length,
283
+ skippedCount: 0,
284
+ findings,
285
+ waived,
286
+ complete: true,
287
+ };
288
+ }
289
+
290
+ function isNonEmptyStringField(value: object, key: string): boolean {
291
+ const v = (value as Record<string, unknown>)[key];
292
+ return typeof v === "string" && v.length > 0;
293
+ }