@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,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
|
+
}
|