@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,590 @@
1
+ /**
2
+ * `checkCopy` — the working voice checker. Given a `VoiceRecord` (already
3
+ * validated — see `schema.ts`) and a piece of copy, reports the subset of
4
+ * voice violations that are actually mechanically detectable: forbidden
5
+ * glossary terms, forbidden person/tense word-markers, claims made in the
6
+ * copy that lack a `factRef` while `requiresSupport` is true, and —
7
+ * unconditionally, whether or not `copy` is even given — whether `record`
8
+ * itself is still (partly) an unedited copy of `voice-record.template.jsonc`.
9
+ *
10
+ * Pure, no I/O: every input is already in memory, every output is plain
11
+ * data. `checkCopy` never reads a file, never calls `strategy` or any other
12
+ * package, and never resolves a `factRef` against anything — see the
13
+ * README's "The `factRef` seam" for why that resolution is deliberately not
14
+ * this package's job.
15
+ *
16
+ * FAILS CLOSED. Three situations that would otherwise look like "nothing
17
+ * was wrong" are instead surfaced as an explicit, visible incompleteness or
18
+ * an explicit, unmissable error — never as a silent, empty-findings pass:
19
+ *
20
+ * 1. `copy` is empty or whitespace-only: every dimension is recorded in
21
+ * `skipped`, not run, and `findings` is empty for a reason a caller
22
+ * can see, not because the copy was clean.
23
+ * 2. A dimension has nothing configured to check against (e.g. no
24
+ * forbidden pronouns in `rules.person`, or an empty glossary) is
25
+ * likewise recorded in `skipped` rather than silently contributing
26
+ * zero findings to what would otherwise read as a clean run.
27
+ * 3. `record` still carries `TEMPLATE_PLACEHOLDER` in one or more of its
28
+ * bindable string fields — this voice was never actually bound, only
29
+ * copied from the template. See "The unbound signal" below.
30
+ *
31
+ * `report.complete` is `true` exactly when `skipped` is empty — mirroring
32
+ * `@example/gates`' `FoundationReport.complete` — so a caller can
33
+ * ask "did this report actually check everything it could have" with one
34
+ * boolean read. `report.bound` is the analogous read for #3.
35
+ *
36
+ * THE UNBOUND SIGNAL
37
+ * -------------------
38
+ * `@example/ui/tokens` has a visual answer to "did anyone bind this
39
+ * yet": import only `tokens.css` and the page renders in visible grey, plus
40
+ * a literal dev-mode badge, until `data-brand-bound` is set. Text has no
41
+ * pixels to fall back to — there is no "render this copy in grey" — so the
42
+ * honest analog is not visual, it's TRUTHFUL DEFAULT DATA: every bindable
43
+ * slot in `voice-record.template.jsonc` is filled with one specific,
44
+ * exported, unmistakable sentinel (`TEMPLATE_PLACEHOLDER`, from
45
+ * `fields.ts`) instead of a plausible-looking example value. A plausible
46
+ * example (a real-sounding pronoun, a real-sounding claim) is exactly what
47
+ * would let an unbound voice "happen to pass" — it would look indistinguishable
48
+ * from a deliberately-authored record that just happens to match the
49
+ * example. A loud, structurally-unmistakable placeholder cannot be
50
+ * mistaken for real content, which is what makes detecting it a reliable
51
+ * signal rather than a heuristic.
52
+ *
53
+ * `findPlaceholderPaths` below scans every string `checkCopy` can reach in
54
+ * `record` — recursively, the same "leaf, not tree" walk `fields.ts`
55
+ * documents for the template itself — for exact equality with
56
+ * `TEMPLATE_PLACEHOLDER`. If it finds even one, `report.bound` is `false`
57
+ * AND, critically, an `"error"`-severity `"voice:unbound-placeholder"`
58
+ * finding is pushed into `report.findings` for each one — not merely a flag
59
+ * a caller has to remember to check. This is the deliberate design choice:
60
+ * this package's own README (see "Usage") already tells every caller to do
61
+ * `if (report.findings.some(f => f.severity === "error")) process.exitCode = 1;`.
62
+ * An unbound record fails that exact, already-idiomatic check on its own,
63
+ * with no second code path a caller has to add and no way for "still
64
+ * template" and "genuinely clean" to ever produce the same result shape —
65
+ * which is the whole point: an unbound voice must never look like a bound
66
+ * one that happens to pass.
67
+ */
68
+
69
+ import { TEMPLATE_PLACEHOLDER } from "./fields.js";
70
+ import { checkPatternSafety, countPatternMatches } from "./internal/pattern-safety.js";
71
+ import type { Claim, PatternRule, VoiceChannel, VoiceFinding, VoiceRecord, VoiceSeverity } from "./types.js";
72
+
73
+ /**
74
+ * Which checkable dimension a run can evaluate. `"pattern"` is CONDITIONAL
75
+ * on `record.patterns` — a `VoiceRecord` that never declares `patterns` at
76
+ * all (`record.patterns === undefined`, the exact shape of every existing
77
+ * `VoiceRecord` written before this feature existed) never sees `"pattern"`
78
+ * in `ran`/`skipped` at all, so `report.skipped`/`report.ran`/
79
+ * `report.complete` behave byte-for-byte as they did before this addition —
80
+ * see `checkCopy`'s own comment on `patternsDeclared` for the mechanism,
81
+ * and `checker.test.ts`'s "an existing VoiceRecord that never declares
82
+ * patterns" test for the pinned proof. A `VoiceRecord` that DOES declare
83
+ * `patterns` (even as `[]`) gets `"pattern"` in `skipped`/`ran` exactly like
84
+ * every other dimension already works.
85
+ */
86
+ export type VoiceCheckDimension = "glossary" | "person" | "tense" | "claims" | "pattern";
87
+
88
+ /** One dimension `checkCopy` did not evaluate this run, and why. */
89
+ export interface VoiceDimensionSkip {
90
+ dimension: VoiceCheckDimension;
91
+ /**
92
+ * Machine-readable reason: `"empty-copy"` (nothing to scan at all —
93
+ * every dimension gets this reason together), or one of
94
+ * `"no-forbidden-terms-configured"` / `"no-forbidden-pronouns-configured"`
95
+ * / `"no-forbidden-markers-configured"` / `"no-claims-configured"` /
96
+ * `"no-patterns-configured"` (the copy was real, but this dimension's own
97
+ * `VoiceRecord` list was empty).
98
+ */
99
+ reason: string;
100
+ }
101
+
102
+ /**
103
+ * `true` exactly for `"error"` — the tier this package's own documented CI
104
+ * idiom (`report.findings.some(f => f.severity === "error")`) treats as
105
+ * build-breaking. Exported so a caller does not have to hardcode the string
106
+ * `"error"` itself to implement that exact idiom — see `VoiceSeverity`'s own
107
+ * doc comment (`types.ts`) for the full three-value mapping this mirrors:
108
+ * `"error"` fails CI, `"warning"` fails only a narrower editorial gate this
109
+ * package does not implement, `"advisory"` never fails anything.
110
+ */
111
+ export function isCiBlockingSeverity(severity: VoiceSeverity): boolean {
112
+ return severity === "error";
113
+ }
114
+
115
+ /**
116
+ * Does `ruleChannel` apply to a check being run for `requestedChannel`? A
117
+ * rule with no `channel` (`undefined`) is global and always applies. A rule
118
+ * WITH a `channel` applies only when `requestedChannel` is given and equals
119
+ * it exactly (plain string equality — this package does not normalize case
120
+ * or interpret channel names at all, see `VoiceChannel`'s doc comment in
121
+ * `types.ts`). Omitting `requestedChannel` entirely (the default — see
122
+ * `VoiceCheckOptions.channel`) means "no channel context for this check", so
123
+ * a channel-scoped rule simply does not apply — the same behavior as if the
124
+ * rule's own term/pattern never matched, not a reported skip: a rule that
125
+ * was never in scope for this check is a normal, honest non-match, not a
126
+ * coverage gap.
127
+ */
128
+ function ruleAppliesToChannel(ruleChannel: VoiceChannel | undefined, requestedChannel: VoiceChannel | undefined): boolean {
129
+ if (ruleChannel === undefined) return true;
130
+ return requestedChannel !== undefined && requestedChannel === ruleChannel;
131
+ }
132
+
133
+ /**
134
+ * An explicit, auditable exception for one specific finding. Scoped
135
+ * narrowly on purpose — a waiver matches exactly one `rule` plus the exact
136
+ * `path` of the finding it covers (a single term, pronoun, marker, or claim
137
+ * id), never a whole dimension or the whole run. `reason` is required and
138
+ * non-empty: a waiver with no stated reason is rejected (see
139
+ * `"waiver:invalid"` below), not silently honored.
140
+ */
141
+ export interface VoiceCheckWaiver {
142
+ /** Must equal the `rule` of the finding this waiver covers, e.g. `"glossary:forbidden-term"`. */
143
+ rule: string;
144
+ /** Must equal the `path` of the finding this waiver covers, e.g. the exact forbidden term. */
145
+ match: string;
146
+ /** Required. Why this specific instance is acceptable — this is the audit trail. */
147
+ reason: string;
148
+ }
149
+
150
+ /** A `VoiceFinding` that was waived, with the waiver that covered it attached for the audit trail. */
151
+ export interface WaivedVoiceFinding extends VoiceFinding {
152
+ waiver: VoiceCheckWaiver;
153
+ }
154
+
155
+ export interface VoiceCheckOptions {
156
+ /** Waivers to apply to this run's raw findings before returning. Defaults to none. */
157
+ waivers?: VoiceCheckWaiver[];
158
+ /**
159
+ * The channel THIS `copy` is being checked for, e.g. `"linkedin"`. A
160
+ * channel-scoped `glossary`/`patterns` entry (see `VoiceChannel` in
161
+ * `types.ts`) only fires when it matches this value exactly — see
162
+ * `ruleAppliesToChannel`'s own doc comment for the omitted-vs-mismatched
163
+ * distinction. Omitted (the default) is IDENTICAL to every prior release
164
+ * of this package: no rule has a `channel` unless its author added one,
165
+ * so an existing `VoiceRecord`/caller that never touches this option
166
+ * behaves exactly as before.
167
+ */
168
+ channel?: VoiceChannel;
169
+ }
170
+
171
+ export interface VoiceCheckReport {
172
+ /** Findings not covered by any waiver. Empty does not necessarily mean "clean" — check `complete` and `bound` too. */
173
+ findings: VoiceFinding[];
174
+ /** Findings that were covered by a waiver, each with the waiver that covered it. */
175
+ waived: WaivedVoiceFinding[];
176
+ /** Dimensions this run could not evaluate, and why. Empty means every dimension ran. */
177
+ skipped: VoiceDimensionSkip[];
178
+ /** Dimensions this run actually evaluated (produced zero or more raw findings from real input). */
179
+ ran: VoiceCheckDimension[];
180
+ /** `true` exactly when `skipped` is empty. `false` means this report cannot vouch for having checked everything it could have. */
181
+ complete: boolean;
182
+ /**
183
+ * `false` if `record` still carries `TEMPLATE_PLACEHOLDER` in one or more
184
+ * bindable fields — i.e. this is (at least partly) an unedited copy of
185
+ * `voice-record.template.jsonc`, not a real, bound voice. See this file's
186
+ * top-of-file doc comment, "The unbound signal". Computed unconditionally,
187
+ * independent of `copy` — an unbound `record` is reported as such even
188
+ * when `copy` is empty. Every unbound field also produces its own
189
+ * `"voice:unbound-placeholder"` **error** in `findings`, so `bound: false`
190
+ * is never a silent flag a caller has to remember to check separately.
191
+ */
192
+ bound: boolean;
193
+ }
194
+
195
+ /** The four dimensions every `VoiceRecord` always carries. `"pattern"` is deliberately NOT here — see `VoiceCheckDimension`'s own doc comment — and is appended by `checkCopy` only when `record.patterns !== undefined`. */
196
+ const ALL_DIMENSIONS: readonly VoiceCheckDimension[] = ["glossary", "person", "tense", "claims"];
197
+
198
+ function escapeRegExp(term: string): string {
199
+ return term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
200
+ }
201
+
202
+ /**
203
+ * Counts non-overlapping whole-word/whole-phrase occurrences of `term` in
204
+ * `haystack`. `\b` boundaries mean a multi-word phrase like `"state of the
205
+ * art"` still requires a real word boundary at its very start and very
206
+ * end — it will not match inside a longer word, but it also will not
207
+ * distinguish "used in dialogue/a quotation" from "used as the voice's own
208
+ * words". See README's limits section for that specific gap.
209
+ */
210
+ function countMatches(haystack: string, term: string, caseSensitive: boolean): number {
211
+ const trimmed = term.trim();
212
+ if (trimmed.length === 0) return 0;
213
+ const pattern = new RegExp(`\\b${escapeRegExp(trimmed)}\\b`, caseSensitive ? "g" : "gi");
214
+ const matches = haystack.match(pattern);
215
+ return matches ? matches.length : 0;
216
+ }
217
+
218
+ /**
219
+ * `true` for a single uppercase letter — `"I"`, but not `"we"`, `"Will"`,
220
+ * or `"i"`. `person`/`tense` word lists have no per-entry `caseSensitive`
221
+ * field the way glossary entries do (see README's tables), so this is the
222
+ * one automatic exception to their otherwise-uniform case-insensitive
223
+ * matching: a bare `"I"` matched case-insensitively would also fire on any
224
+ * stray lowercase `"i"` (a roman numeral, a loop variable in quoted code)
225
+ * that is not the first-person pronoun at all — a real, common false
226
+ * positive for the single most common `forbiddenPronouns` entry there is.
227
+ * Matching a single uppercase letter case-sensitively closes that specific
228
+ * gap without losing sentence-initial-capitalization tolerance for every
229
+ * multi-letter entry (`"we"` still needs to match `"We"`).
230
+ */
231
+ function isSingleUppercaseLetter(term: string): boolean {
232
+ return term.length === 1 && term >= "A" && term <= "Z";
233
+ }
234
+
235
+ /** Case sensitivity `checkCopy` uses for one `person`/`tense` word-list entry — see `isSingleUppercaseLetter`. */
236
+ function markerCaseSensitivity(term: string): boolean {
237
+ return isSingleUppercaseLetter(term);
238
+ }
239
+
240
+ /**
241
+ * Recursively finds every dot-path in `value` whose STRING value is exactly
242
+ * `TEMPLATE_PLACEHOLDER` — the same "leaf, not tree" walk
243
+ * `internal/parse-template.ts`'s `extractFieldPaths` uses on the template
244
+ * itself, except this one DOES descend into arrays (`glossary.0.term`,
245
+ * `claims.1.text`, ...): the template's own coverage test only needs to
246
+ * know a bindable collection EXISTS, but a real record's placeholder scan
247
+ * needs to know exactly which entry, of however many a real consumer added,
248
+ * still carries the sentinel — a bare `"glossary"` path in a finding would
249
+ * not tell anyone which of N entries to go fix.
250
+ *
251
+ * Exact string equality only, never a substring/contains check: a real
252
+ * voice is free to legitimately quote or discuss this package's own
253
+ * placeholder text (documentation about `voice-record.template.jsonc`
254
+ * itself, say) without that mention alone flipping `bound` to `false` for
255
+ * an otherwise fully-authored field.
256
+ */
257
+ function findPlaceholderPaths(value: unknown, prefix = ""): string[] {
258
+ if (typeof value === "string") {
259
+ return value === TEMPLATE_PLACEHOLDER ? [prefix || "$"] : [];
260
+ }
261
+ if (Array.isArray(value)) {
262
+ return value.flatMap((item, i) => findPlaceholderPaths(item, prefix ? `${prefix}.${i}` : String(i)));
263
+ }
264
+ if (value !== null && typeof value === "object") {
265
+ return Object.entries(value as Record<string, unknown>).flatMap(([key, nested]) =>
266
+ findPlaceholderPaths(nested, prefix ? `${prefix}.${key}` : key),
267
+ );
268
+ }
269
+ return [];
270
+ }
271
+
272
+ /**
273
+ * Registry-level audit, independent of any copy: which claims in `claims`
274
+ * require support (`requiresSupport: true`, the default) but carry no
275
+ * `factRef`. Useful as a standalone "is this claims register itself
276
+ * complete" check, separate from `checkCopy`'s "did THIS copy use one of
277
+ * them" question. Pure, no I/O.
278
+ */
279
+ export function auditClaimsRegister(claims: Claim[]): VoiceFinding[] {
280
+ const findings: VoiceFinding[] = [];
281
+ for (const claim of claims) {
282
+ if (claim.requiresSupport && !claim.factRef) {
283
+ findings.push({
284
+ rule: "claim:missing-fact-ref",
285
+ severity: "warning",
286
+ message: `Claim "${claim.id}" ("${claim.text}") requires support but has no factRef.`,
287
+ path: claim.id,
288
+ });
289
+ }
290
+ }
291
+ return findings;
292
+ }
293
+
294
+ /**
295
+ * Checks `copy` against `record`. See this file's top-of-file doc comment
296
+ * for the fail-closed behavior on empty copy and on unconfigured
297
+ * dimensions, and the package README for exactly what each dimension does
298
+ * and does not attempt.
299
+ *
300
+ * Throws a plain `Error` (not a finding) for caller-input problems that are
301
+ * not this function's job to interpret as data: `copy` not a string, or
302
+ * `record` not an object. This mirrors `@example/gates`'
303
+ * `runFoundationCheck`, which treats a malformed call the same way — a hard
304
+ * upfront rejection, not a soft finding that could be mistaken for
305
+ * something the checker discovered about the copy itself.
306
+ */
307
+ export function checkCopy(record: VoiceRecord, copy: string, options: VoiceCheckOptions = {}): VoiceCheckReport {
308
+ if (typeof copy !== "string") {
309
+ throw new TypeError("checkCopy: copy must be a string");
310
+ }
311
+ if (record === null || typeof record !== "object") {
312
+ throw new TypeError(
313
+ "checkCopy: record must be a VoiceRecord object — validate untrusted input with validateVoiceRecordShape first",
314
+ );
315
+ }
316
+
317
+ const ran: VoiceCheckDimension[] = [];
318
+ const skipped: VoiceDimensionSkip[] = [];
319
+ const rawFindings: VoiceFinding[] = [];
320
+
321
+ // --- unbound-placeholder scan ---------------------------------------
322
+ // Runs unconditionally, before the copy-empty branch below and
323
+ // independent of it — whether `record` is still (partly) an unedited
324
+ // copy of the template is a property of `record`, not of `copy`. See
325
+ // this file's top-of-file doc comment, "The unbound signal".
326
+ //
327
+ // Kept in its OWN array, deliberately never merged into `rawFindings`
328
+ // below: `rawFindings` is what the waiver loop matches against, and a
329
+ // `"voice:unbound-placeholder"` finding must not be waivable. Every other
330
+ // finding this function produces is a judgment call a reviewer can
331
+ // legitimately override with a reason (a forbidden term quoted from a
332
+ // review, a claim phrase inside a testimonial); an unbound record is not
333
+ // a judgment call, it's a structural precondition — the same category as
334
+ // the `TypeError`s above, which also cannot be waived. Letting a waiver
335
+ // remove this finding from `findings` would reopen exactly the gap this
336
+ // whole mechanism exists to close: a caller relying on this package's own
337
+ // README idiom (`findings.some(f => f.severity === "error")`) would see a
338
+ // clean run for a voice that was never actually bound.
339
+ const placeholderPaths = findPlaceholderPaths(record);
340
+ const bound = placeholderPaths.length === 0;
341
+ const unboundFindings: VoiceFinding[] = placeholderPaths.map((path) => ({
342
+ rule: "voice:unbound-placeholder",
343
+ severity: "error",
344
+ message: `This voice record still carries the template placeholder value at "${path}" — voice-record.template.jsonc was copied but never fully filled in. An unbound voice must never be treated as checked; see the README's "The unbound signal". This finding cannot be waived.`,
345
+ path,
346
+ }));
347
+
348
+ // --- pattern-rule safety gate ----------------------------------------
349
+ // ALSO runs unconditionally, ALSO before the copy-empty branch, ALSO kept
350
+ // in its own never-waivable array — the exact same reasoning as the
351
+ // unbound-placeholder scan directly above, extended to a second
352
+ // structural precondition: whether `record` even carries a runnable set
353
+ // of pattern rules is a property of `record` alone, independent of both
354
+ // `copy` and `options.channel` (an invalid regex is invalid no matter
355
+ // which channel a caller happens to be checking today). `checkCopy` does
356
+ // not trust that `record` was ever run through `parseVoiceRecord` — see
357
+ // this function's own doc comment — so this re-runs the exact same gate
358
+ // `schema.ts`'s `validatePatternRuleShape` already applies at
359
+ // registration. A pattern that fails is reported as a real,
360
+ // "pattern:invalid-rule" error finding here and is never attempted
361
+ // against `copy` below — see `internal/pattern-safety.ts` for the full
362
+ // safety gate this calls, and `types.ts`'s header comment for why a check
363
+ // that cannot run must fail, never silently pass.
364
+ //
365
+ // `patternsDeclared` is the backward-compatibility hinge for this whole
366
+ // dimension: `true` only when `record.patterns` is present at all
367
+ // (even as `[]`) — see `types.ts`'s `VoiceRecord` doc comment and
368
+ // `schema.ts`'s `buildVoiceRecord` for why `patterns` is deliberately
369
+ // never defaulted to `[]` the way `glossary`/`claims` are. A record that
370
+ // never declares `patterns` gets NO `"pattern"` entry in `ran`/`skipped`
371
+ // at all below — not "skipped for lack of configuration", genuinely
372
+ // ABSENT, so `report.skipped.length`/`report.ran`/`report.complete`
373
+ // reproduce this package's pre-pattern-rule behavior exactly for any
374
+ // existing `VoiceRecord`. See `checker.test.ts`'s pinned test for the
375
+ // proof.
376
+ const patternsDeclared = record.patterns !== undefined;
377
+ const validPatternRules: Array<{ rule: PatternRule; regex: RegExp }> = [];
378
+ const invalidPatternFindings: VoiceFinding[] = [];
379
+ for (const rule of record.patterns ?? []) {
380
+ const safety = checkPatternSafety(rule.pattern);
381
+ if (safety.ok) {
382
+ validPatternRules.push({ rule, regex: safety.regex });
383
+ } else {
384
+ invalidPatternFindings.push({
385
+ rule: "pattern:invalid-rule",
386
+ severity: "error",
387
+ message: `Pattern rule "${rule.id}" ("${rule.description}") has an invalid or unsafe pattern (${safety.issue}: ${safety.detail}) and could not be evaluated. A rule that cannot run must be reported as broken, never silently skipped — see types.ts's header comment. This finding cannot be waived.`,
388
+ path: rule.id,
389
+ });
390
+ }
391
+ }
392
+
393
+ const trimmedCopy = copy.trim();
394
+
395
+ if (trimmedCopy.length === 0) {
396
+ // Nothing to scan at all. Every APPLICABLE dimension is unreachable,
397
+ // not clean — recording each as skipped is what keeps this from
398
+ // reading like a report that checked the copy and found it flawless.
399
+ // "pattern" only joins this list when `patternsDeclared` — see that
400
+ // variable's own comment above.
401
+ for (const dimension of patternsDeclared ? [...ALL_DIMENSIONS, "pattern" as const] : ALL_DIMENSIONS) {
402
+ skipped.push({ dimension, reason: "empty-copy" });
403
+ }
404
+ } else {
405
+ // --- glossary --------------------------------------------------------
406
+ const forbiddenTerms = record.glossary.filter((entry) => entry.status === "forbidden");
407
+ if (forbiddenTerms.length === 0) {
408
+ skipped.push({ dimension: "glossary", reason: "no-forbidden-terms-configured" });
409
+ } else {
410
+ ran.push("glossary");
411
+ for (const entry of forbiddenTerms) {
412
+ if (!ruleAppliesToChannel(entry.channel, options.channel)) continue;
413
+ const count = countMatches(copy, entry.term, entry.caseSensitive);
414
+ if (count > 0) {
415
+ const alt = entry.alternative ? ` Use "${entry.alternative}" instead.` : "";
416
+ rawFindings.push({
417
+ rule: "glossary:forbidden-term",
418
+ severity: "error",
419
+ message: `Forbidden term "${entry.term}" appears ${count} time(s). ${entry.reason}${alt}`,
420
+ path: entry.term,
421
+ });
422
+ }
423
+ }
424
+ }
425
+
426
+ // --- person ------------------------------------------------------------
427
+ if (record.rules.person.forbiddenPronouns.length === 0) {
428
+ skipped.push({ dimension: "person", reason: "no-forbidden-pronouns-configured" });
429
+ } else {
430
+ ran.push("person");
431
+ for (const pronoun of record.rules.person.forbiddenPronouns) {
432
+ const count = countMatches(copy, pronoun, markerCaseSensitivity(pronoun));
433
+ if (count > 0) {
434
+ rawFindings.push({
435
+ rule: "person:forbidden-pronoun",
436
+ severity: "error",
437
+ message: `Pronoun "${pronoun}" appears ${count} time(s), contradicting this voice's person rule (${record.rules.person.description}).`,
438
+ path: pronoun,
439
+ });
440
+ }
441
+ }
442
+ }
443
+
444
+ // --- tense ---------------------------------------------------------
445
+ if (record.rules.tense.forbiddenMarkers.length === 0) {
446
+ skipped.push({ dimension: "tense", reason: "no-forbidden-markers-configured" });
447
+ } else {
448
+ ran.push("tense");
449
+ for (const marker of record.rules.tense.forbiddenMarkers) {
450
+ const count = countMatches(copy, marker, markerCaseSensitivity(marker));
451
+ if (count > 0) {
452
+ rawFindings.push({
453
+ rule: "tense:forbidden-marker",
454
+ severity: "warning",
455
+ message: `Marker "${marker}" appears ${count} time(s), which may contradict this voice's tense rule (${record.rules.tense.description}). This is a word-marker heuristic, not grammatical tense parsing — verify by eye.`,
456
+ path: marker,
457
+ });
458
+ }
459
+ }
460
+ }
461
+
462
+ // --- claims ----------------------------------------------------------
463
+ if (record.claims.length === 0) {
464
+ skipped.push({ dimension: "claims", reason: "no-claims-configured" });
465
+ } else {
466
+ ran.push("claims");
467
+ for (const claim of record.claims) {
468
+ if (!claim.requiresSupport) continue; // this claim was explicitly marked as not needing a factRef
469
+ if (claim.factRef) continue; // has a factRef — whether it RESOLVES is a later gate's job, not this function's; see README
470
+ const phrases = claim.matchPhrases.length > 0 ? claim.matchPhrases : [claim.text];
471
+ const isMade = phrases.some((phrase) => countMatches(copy, phrase, false) > 0);
472
+ if (isMade) {
473
+ rawFindings.push({
474
+ rule: "claim:unsupported",
475
+ severity: "error",
476
+ message: `Claim "${claim.id}" ("${claim.text}") appears in this copy but has no factRef and is marked as requiring support.`,
477
+ path: claim.id,
478
+ });
479
+ }
480
+ }
481
+ }
482
+
483
+ // --- pattern -------------------------------------------------------
484
+ // Entirely gated on `patternsDeclared` (see its own comment above) —
485
+ // a record that never declared `patterns` gets no `"pattern"` entry
486
+ // in `ran`/`skipped` at all, preserving pre-existing 4-dimension
487
+ // behavior exactly. `validPatternRules` (built above, unconditionally)
488
+ // already excludes every rule that failed `checkPatternSafety`; those
489
+ // are reported once, via `invalidPatternFindings`, never here. Whether
490
+ // the "pattern" dimension itself counts as configured, once declared,
491
+ // is judged against `record.patterns`'s length — a record whose ONLY
492
+ // pattern rule is invalid still "ran" this dimension (it produced a
493
+ // real, unmissable finding), it did not skip it for lack of
494
+ // configuration.
495
+ if (patternsDeclared) {
496
+ const configuredPatterns = record.patterns ?? [];
497
+ if (configuredPatterns.length === 0) {
498
+ skipped.push({ dimension: "pattern", reason: "no-patterns-configured" });
499
+ } else {
500
+ ran.push("pattern");
501
+ for (const { rule, regex } of validPatternRules) {
502
+ if (!ruleAppliesToChannel(rule.channel, options.channel)) continue;
503
+ const count = countPatternMatches(copy, regex);
504
+ if (count > 0) {
505
+ const alt = rule.alternative ? ` Use "${rule.alternative}" instead.` : "";
506
+ rawFindings.push({
507
+ rule: "pattern:matched",
508
+ severity: rule.severity,
509
+ message: `Pattern rule "${rule.id}" ("${rule.description}") matched ${count} time(s). ${rule.reason}${alt}`,
510
+ path: rule.id,
511
+ });
512
+ }
513
+ }
514
+ }
515
+ }
516
+ }
517
+
518
+ // --- waivers -------------------------------------------------------------
519
+ // Validate each waiver's own shape first (required, non-empty rule/match/
520
+ // reason) — an invalid waiver is reported as its own error and is never
521
+ // applied, so a caller cannot accidentally waive something by supplying a
522
+ // malformed waiver object.
523
+ const configFindings: VoiceFinding[] = [];
524
+ const validWaivers: VoiceCheckWaiver[] = [];
525
+ for (const waiver of options.waivers ?? []) {
526
+ const valid =
527
+ typeof waiver?.rule === "string" &&
528
+ waiver.rule.length > 0 &&
529
+ typeof waiver?.match === "string" &&
530
+ waiver.match.length > 0 &&
531
+ typeof waiver?.reason === "string" &&
532
+ waiver.reason.trim().length > 0;
533
+ if (!valid) {
534
+ configFindings.push({
535
+ rule: "waiver:invalid",
536
+ severity: "error",
537
+ message: `Waiver is missing a required field — "rule", "match", and a non-empty "reason" are all required. Given: ${JSON.stringify(waiver)}`,
538
+ });
539
+ } else {
540
+ validWaivers.push(waiver);
541
+ }
542
+ }
543
+
544
+ const findings: VoiceFinding[] = [];
545
+ const waived: WaivedVoiceFinding[] = [];
546
+ const usedWaiverIndexes = new Set<number>();
547
+
548
+ for (const finding of rawFindings) {
549
+ const waiverIndex = validWaivers.findIndex((w) => w.rule === finding.rule && w.match === finding.path);
550
+ if (waiverIndex >= 0) {
551
+ usedWaiverIndexes.add(waiverIndex);
552
+ waived.push({ ...finding, waiver: validWaivers[waiverIndex] as VoiceCheckWaiver });
553
+ } else {
554
+ findings.push(finding);
555
+ }
556
+ }
557
+
558
+ // A waiver that never matched anything this run is dead configuration —
559
+ // surfaced as its own warning rather than silently doing nothing, so
560
+ // waivers cannot quietly accumulate forever without anyone noticing one
561
+ // stopped applying (e.g. after the copy that needed it was rewritten).
562
+ validWaivers.forEach((waiver, index) => {
563
+ if (!usedWaiverIndexes.has(index)) {
564
+ configFindings.push({
565
+ rule: "waiver:unused",
566
+ severity: "warning",
567
+ message: `Waiver for rule "${waiver.rule}" matching "${waiver.match}" did not match any finding in this run. Remove it, or check for a typo. Reason on file: "${waiver.reason}"`,
568
+ path: waiver.match,
569
+ });
570
+ }
571
+ });
572
+
573
+ findings.push(...configFindings);
574
+ // Unbound-placeholder and invalid-pattern findings are prepended, not
575
+ // appended: they describe the most fundamental things that can be wrong
576
+ // with this run (there is no real voice here yet / a rule cannot even be
577
+ // evaluated), and — unlike everything else in `findings` — were never
578
+ // eligible for the waiver loop above. See this function's own comments on
579
+ // `unboundFindings` and `invalidPatternFindings` for why.
580
+ findings.unshift(...unboundFindings, ...invalidPatternFindings);
581
+
582
+ return {
583
+ findings,
584
+ waived,
585
+ skipped,
586
+ ran,
587
+ complete: skipped.length === 0,
588
+ bound,
589
+ };
590
+ }