@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/README.md ADDED
@@ -0,0 +1,593 @@
1
+ # @clossys/writer
2
+
3
+ **The writer role — is it well said?** This package is named for the job,
4
+ not the artifact. The records it owns are still copy records, and the
5
+ vocabulary inside it (`CopyRegistry`, `CopyRef`, `checkCopyRecord`, the
6
+ `copy-record` schema, and every voice/glossary/claim term) is unchanged: a
7
+ role owns artifacts, and renaming the role does not rename what it reasons
8
+ about.
9
+
10
+ `@clossys/writer` owns the language system for a product: its voice
11
+ rules, glossary, claims register, addressable copy records, and source
12
+ traceability checks. It ships the machinery and a deliberately unbound voice
13
+ template; each consumer supplies its own language and facts.
14
+
15
+ ```bash
16
+ npm install @clossys/writer
17
+ ```
18
+
19
+ ## Public entry points
20
+
21
+ - `@clossys/writer` exposes both voice and copy-record APIs.
22
+ - `@clossys/writer/voice` exposes only the voice contract:
23
+ `checkCopy`, `auditClaimsRegister`, `isCiBlockingSeverity`,
24
+ `checkPatternSafety`, `parseVoiceRecord`, `validateVoiceRecordShape`,
25
+ `VOICE_FIELDS`, `VOICE_SEVERITIES`, and their types — including the rule
26
+ vocabulary described below: `PatternRule`, `VoicePattern`, `VoiceSeverity`,
27
+ `VoiceChannel`.
28
+ - `@clossys/writer/voice-record.template.jsonc` is an annotated,
29
+ unbound template to copy into a consumer repository and fill in.
30
+
31
+ ```ts
32
+ import {
33
+ createCopyResolver,
34
+ checkCopy,
35
+ checkCopyRecord,
36
+ type CopyRegistry,
37
+ type VoiceRecord,
38
+ } from "@clossys/writer";
39
+
40
+ const voice: VoiceRecord = {
41
+ id: "acme-app",
42
+ rules: {
43
+ person: { description: "second person", forbiddenPronouns: ["we", "our"] },
44
+ tense: { description: "present tense", forbiddenMarkers: ["will"] },
45
+ formality: "neutral",
46
+ tone: ["direct"],
47
+ },
48
+ glossary: [{ term: "utilize", status: "forbidden", reason: "prefer plain language" }],
49
+ claims: [],
50
+ };
51
+
52
+ const record: CopyRegistry = {
53
+ id: "acme-app",
54
+ locale: "en",
55
+ revision: "2026-08-11",
56
+ source: { kind: "consumer", reference: "editorial/revisions/42" },
57
+ entries: [{ id: "home.title", text: "Plan your week.", context: "home page heading", status: "approved" }],
58
+ };
59
+
60
+ checkCopy(voice, record.entries[0].text);
61
+ checkCopyRecord(record, voice);
62
+ const resolveCopy = createCopyResolver(record);
63
+ resolveCopy({ id: "home.title" });
64
+ ```
65
+
66
+ `writer-check` scans a source tree and checks its user-facing literals against a
67
+ registered `CopyRecord`. It exits 0 when clean, 1 when it finds traceability
68
+ issues, and 2 when it cannot run.
69
+
70
+ This package does not resolve a claim's `factRef`, infer tone or grammar, or
71
+ ship actual product language. Those decisions remain with the consumer and the
72
+ product's facts system.
73
+
74
+ ## Resolving copy for a surface
75
+
76
+ `CopyRecord` is the minimal schema used by source scanning and voice checking.
77
+ Rendered output must use the stronger `CopyRegistry`: one locale, a revision,
78
+ opaque source provenance, and an explicit lifecycle state for every entry.
79
+ Only `approved` entries resolve. This prevents a surface manifest from claiming
80
+ traceability for draft, retired, unversioned, or unlocalised text.
81
+
82
+ ```ts
83
+ import { createCopyResolver, type CopyRef, type CopyRegistry } from "@clossys/writer";
84
+
85
+ declare const registry: CopyRegistry;
86
+ const ref: CopyRef = { id: "account.greeting", locale: "en", values: { name: "Ada" } };
87
+ const resolved = createCopyResolver(registry)(ref);
88
+
89
+ if (!resolved) throw new Error("Required copy could not be resolved");
90
+ // resolved.text goes to the renderer. Its registry/revision/locale/source and
91
+ // entry identifier can be retained structurally by
92
+ // surface's createResolvedOutputManifest helper.
93
+ ```
94
+
95
+ The resolver is strict: it fails closed for an invalid runtime registry,
96
+ unknown ID, locale mismatch, draft/retired entry, missing placeholder value,
97
+ or unexpected value. Locale fallback belongs to a consumer-owned registry
98
+ selector, rather than an implicit package policy.
99
+
100
+ ## Where this package sits on i18n
101
+
102
+ "i18n" is two different things, and this package deliberately does only one
103
+ of them.
104
+
105
+ **Translation RUNTIME** — ICU message format, plural rules, locale
106
+ negotiation, date/number formatting — is out of scope. `CopyLocale` is a
107
+ plain string (see `types.ts`) precisely so this package never grows a second
108
+ internationalisation stack: that job already belongs to `Intl`
109
+ (`Intl.PluralRules`, `Intl.ListFormat`, `Intl.RelativeTimeFormat`,
110
+ `Intl.NumberFormat`) and a consumer's own choice of locale-negotiation
111
+ policy. This is a scope decision, not a gap this package failed to fill —
112
+ see `placeholders`' own doc comment in `types.ts` for the same point made
113
+ about interpolation specifically.
114
+
115
+ **Translation GOVERNANCE** — is every locale actually covered, has the
116
+ source locale drifted ahead of its translations, is a target locale carrying
117
+ entries that no longer exist upstream — is this package's job, because it is
118
+ the same "addressable, checkable copy" problem this package already solves
119
+ within one locale, applied across locales. What this package DOES do for
120
+ multi-locale work:
121
+
122
+ - **Addressing and resolution**: `CopyRegistry` is already locale-keyed
123
+ (`locale: CopyLocale`), and `resolveCopyRef` already fails closed with a
124
+ `"locale-mismatch"` issue when a `CopyRef` requests a locale a given
125
+ registry does not provide.
126
+ - **Coverage governance**: `checkLocaleCoverage` (below) checks a set of
127
+ locale-keyed registries against a declared source locale for missing
128
+ coverage (an entry the source has that a target locale doesn't) and
129
+ orphaned entries (an entry a target locale has that the source no longer
130
+ does).
131
+ - **Staleness governance**: `checkLocaleCoverage` also checks whether a
132
+ target-locale entry's translation is still current against a since-edited
133
+ source entry. This is deliberately **content-derived**, not a
134
+ human-maintained revision counter: `CopyRegistryEntry.translation`
135
+ (`CopyTranslationProvenance`) records a `sourceFingerprint` — the source
136
+ entry's `text`, digested by `computeCopyFingerprint` (`node:crypto`
137
+ `sha256`, no runtime dependency) at the moment the translation was
138
+ produced. `checkLocaleCoverage` recomputes that fingerprint against the
139
+ source entry's CURRENT `text` and compares. A hand-bumped revision number
140
+ requires someone to remember to bump it every time source copy changes,
141
+ and nothing enforces that discipline — it drifts silently. A content hash
142
+ cannot drift: identical text always fingerprints identically, and any
143
+ edit changes the fingerprint with certainty.
144
+
145
+ `translation` is optional — an entry authored before this field existed,
146
+ or by a host that has not adopted it, remains a fully valid
147
+ `CopyRegistryEntry`. `checkLocaleCoverage` treats "checked, still current"
148
+ (no finding), "checked, and stale" (`"locale-coverage:stale-entry"`), and
149
+ "no provenance recorded, cannot tell" (`"locale-coverage:provenance-missing"`)
150
+ as three genuinely different outcomes, never collapsed into one signal —
151
+ collapsing "cannot tell" into either of the other two would silently
152
+ report a dimension it did not actually check as either clean or dirty.
153
+ - **Interpolation-parity governance**: for every entry present in both a
154
+ source and target locale, `checkLocaleCoverage` also compares each
155
+ entry's `placeholders` in both directions: a name the source declares
156
+ that the target's translation is missing
157
+ (`"locale-coverage:interpolation-missing"`, a broken sentence at render
158
+ time — a required value has nowhere to interpolate into) and a name the
159
+ target declares that the source never did
160
+ (`"locale-coverage:interpolation-extra"`, an unfilled `{name}` token
161
+ rendered straight to a user). Both are `"error"` severity, matching the
162
+ same-class-of-bug precedent `placeholder-missing-from-text` already sets
163
+ within one locale.
164
+
165
+ See `locale-coverage.ts`'s doc comment for the full design, including why
166
+ this stays translation *governance*, never a competing translation
167
+ runtime — the boundary below is unchanged by any of this.
168
+
169
+ `writer-check locale-coverage <registries-file> <source-locale>
170
+ [declared-locale...]` (see `cli.ts` — a THIRD subcommand of the existing
171
+ `writer-check` bin, dispatched on an explicit `argv[0] === "locale-coverage"`,
172
+ never by installed `bin` name or invoking path, the same dispatch shape
173
+ `cli.ts` already uses for its `voice-derivation-coverage` and
174
+ `addressability` subcommands) runs `checkLocaleCoverage` from the command
175
+ line. `registries-file` is a JSON file: a plain object mapping each
176
+ locale to its `CopyRegistry` (`{"en": {...}, "fr": {...}}`).
177
+ `declared-locale` defaults to every key `registries-file` itself declares,
178
+ in file order, when omitted; pass it explicitly to also assert that some
179
+ OTHER locale — declared, but with no registry present in the file at
180
+ all — is missing. Exit code is the exact mapping
181
+ `LocaleCoverageReport.complete`'s own doc comment states as its caller
182
+ contract: `0` when every declared locale was evaluated and no
183
+ `"error"`-severity finding was produced (a `"warning"`-only run — orphaned
184
+ entries, stale translations, missing provenance — still exits `0`), `1`
185
+ when every declared locale was evaluated but at least one `"error"`-severity
186
+ finding was produced, `2` when any declared locale was NOT actually
187
+ evaluated (bad input, an unreadable/unparseable/non-object registries-file,
188
+ zero declared locales, or a missing/invalid/empty source locale).
189
+
190
+ ### Voice glossary vs. i18n glossary — two different axes, easy to conflate
191
+
192
+ `@clossys/writer/voice`'s `GlossaryEntry` (`term`/`status`/`reason`/
193
+ `alternative`/`caseSensitive`) is a **voice** glossary: it enforces brand
194
+ terms *within one locale* — "never say utilize, say use," checked by
195
+ `checkCopy` against one string in one language. It has no concept of a
196
+ second locale at all: a `VoiceRecord` is documented as "one consumer's
197
+ complete, bound voice," singular, and `checkCopy` never receives a locale
198
+ argument.
199
+
200
+ An **i18n** glossary is a different axis over similar-looking machinery: it
201
+ enforces that a *term stays equivalent across locales* — that whatever "an
202
+ en entry translates to in fr" actually says the same thing, not that either
203
+ locale's copy avoids a forbidden word. That requires a locale-keyed
204
+ term-registry shape (multiple per-locale phrases grouped under one
205
+ term-equivalence id) that `GlossaryEntry`/`VoiceRecord` do not have and were
206
+ never designed to grow: `VoiceRecord` is deliberately a single, monolingual,
207
+ bound voice, and threading a locale axis through `checkCopy`'s single-string
208
+ contract would distort what that function already promises, not extend it.
209
+ This package does not ship an i18n glossary for that reason — implementing
210
+ one honestly is a new register (structurally closer to `checkLocaleCoverage`
211
+ above, generalized from entry ids to term-equivalence ids) rather than a
212
+ small addition to `copy/voice`.
213
+
214
+ ## Voice rule vocabulary: patterns, severity, and channels
215
+
216
+ Three additions to `copy/voice`'s rule model, all strictly additive: an
217
+ existing `VoiceRecord` that uses none of them validates and behaves exactly
218
+ as it did before (see "Scope discipline" below).
219
+
220
+ ### Pattern rules vs. the glossary
221
+
222
+ `GlossaryEntry` matches one literal term. It cannot express alternation
223
+ (`"deep dive"` vs. `"dive deep"`), an optional apostrophe (`"it's"` vs.
224
+ `"its"`), or a hard ban on a specific character (a U+2014 em dash). A
225
+ `PatternRule` closes all three gaps with one mechanism — a regex — because a
226
+ punctuation ban is just a pattern with no alternation in it:
227
+
228
+ ```ts
229
+ import { checkCopy, type VoiceRecord } from "@clossys/writer/voice";
230
+
231
+ const voice: VoiceRecord = {
232
+ id: "acme-app",
233
+ rules: { /* ... */ } as VoiceRecord["rules"],
234
+ glossary: [],
235
+ claims: [],
236
+ patterns: [
237
+ {
238
+ id: "no-em-dash",
239
+ description: "hard ban on the em dash",
240
+ pattern: { source: "\\u2014" },
241
+ severity: "error",
242
+ reason: "house style bans the em dash — use a comma or period",
243
+ },
244
+ {
245
+ id: "deep-dive",
246
+ description: "banned buzzword, either word order",
247
+ pattern: { source: "\\b(deep dive|dive deep)\\b", flags: "i" },
248
+ severity: "warning",
249
+ reason: "overused",
250
+ alternative: "look closely at",
251
+ },
252
+ ],
253
+ };
254
+
255
+ checkCopy(voice, "Let's take a deep dive into this — with an em dash.");
256
+ ```
257
+
258
+ **Regex safety.** A caller-supplied pattern can hang a scanner via
259
+ catastrophic backtracking. This package's position: bound the pattern AT
260
+ REGISTRATION TIME, never at run time — there is no runtime timeout anywhere
261
+ in this package. `checkPatternSafety` (exported, so a consumer can validate
262
+ a pattern before it ever reaches a `VoiceRecord`) rejects, as a real
263
+ finding, never a silent skip: a disallowed flag (only `i`/`u`/`s` are
264
+ accepted — never `g`/`y`/`m`), a source over 200 characters, a
265
+ backreference (`\1`, `\k<name>`), a bounded quantifier whose upper bound
266
+ exceeds 50, and — the classic catastrophic-backtracking shape — a
267
+ quantifier applied to a group that itself contains an unbounded quantifier
268
+ (`(a+)+`, `(a*)*`, `(.*)+`). This is a real, bounded, documented static
269
+ gate, not a general ReDoS detector: it does not catch overlapping-alternation
270
+ blowup with no nested quantifier (`(a|a)+`) — see
271
+ `src/voice/internal/pattern-safety.ts`'s top doc comment for the complete,
272
+ honest limitation list.
273
+
274
+ **An invalid pattern is a finding, not a silent skip.** Both
275
+ `validateVoiceRecordShape`/`parseVoiceRecord` (at registration) and
276
+ `checkCopy` itself (as defense-in-depth, since `checkCopy` does not trust
277
+ that its `VoiceRecord` argument was ever validated) run this same gate. A
278
+ pattern that fails compiles to a `"pattern:invalid-rule"` **error** finding
279
+ — always `"error"`, regardless of the rule's own declared severity, and,
280
+ like `"voice:unbound-placeholder"`, it can never be waived away. A rule the
281
+ author believes is enforcing something must never silently stop enforcing
282
+ it.
283
+
284
+ Patterns are serialized as `{ source, flags }` — the two `RegExp`
285
+ constructor arguments — never a real `RegExp` instance, because a
286
+ `VoiceRecord` is checked-in JSON data, not code.
287
+
288
+ ### Three severity tiers, and what each means for CI
289
+
290
+ `VoiceFinding.severity` is now `"error" | "warning" | "advisory"` (widened
291
+ from `"error" | "warning"` — every existing finding this package produces
292
+ still uses exactly the same value it always did):
293
+
294
+ | Tier | Meaning |
295
+ | --- | --- |
296
+ | `"error"` | Fails CI. This package's documented idiom — `report.findings.some(f => f.severity === "error")` — treats this, and only this, tier as build-breaking. `isCiBlockingSeverity(severity)` is the same check, exported so a caller does not have to hardcode the string. |
297
+ | `"warning"` | Fails only a narrower, editorial gate — a stricter, consumer-owned check (e.g. "block merge to a marketing branch") that this package does not implement. Unchanged in behavior from before this release: the `tense` dimension already produced `"warning"` findings, and nothing about how they flow through `checkCopy` has changed. |
298
+ | `"advisory"` | Purely informational. Never fails anything, including the narrower editorial gate. |
299
+
300
+ Only `PatternRule.severity` requires an author to pick one of these
301
+ explicitly — every other dimension's severity remains hardcoded by
302
+ `checker.ts`, exactly as before.
303
+
304
+ ### Channel scoping
305
+
306
+ An optional `channel` on a `GlossaryEntry` or `PatternRule` scopes it to one
307
+ named channel — LinkedIn, X, HN, or whatever a consumer's own product calls
308
+ its channels. **This package does not define what a channel is.** `VoiceChannel`
309
+ is a plain string, validated for shape only (non-empty, non-whitespace),
310
+ exactly the same seam `CopyLocale` draws for locale and `Claim.factRef`
311
+ draws for a facts registry:
312
+
313
+ ```ts
314
+ {
315
+ term: "synergy",
316
+ status: "forbidden",
317
+ reason: "buzzword",
318
+ caseSensitive: false,
319
+ channel: "linkedin", // this rule only applies when checkCopy is called with { channel: "linkedin" }
320
+ }
321
+ ```
322
+
323
+ ```ts
324
+ checkCopy(voice, copyForLinkedIn, { channel: "linkedin" });
325
+ ```
326
+
327
+ A rule with no `channel` is global and always applies. A channel-scoped rule
328
+ applies only when `options.channel` matches it EXACTLY (plain string
329
+ equality — no case-folding, no interpretation). Omitting `options.channel`
330
+ entirely is identical to every release before this one: no rule has a
331
+ `channel` unless its author added one.
332
+
333
+ ### Scope discipline
334
+
335
+ `patterns` is optional at the `VoiceRecord` TYPE level (not merely defaulted
336
+ at validation time, the way `glossary`/`claims` are) specifically so every
337
+ `VoiceRecord` object literal written before this feature existed keeps
338
+ compiling and behaving unchanged: a record that never declares `patterns`
339
+ gets no `"pattern"` entry in `checkCopy`'s `ran`/`skipped` at all — not
340
+ "skipped for lack of configuration", genuinely absent, so
341
+ `report.complete`/`report.skipped.length` reproduce this package's
342
+ pre-pattern-rule behavior exactly. `packages/writer/src/voice/checker.test.ts`
343
+ pins this explicitly.
344
+
345
+ ## Path exclusions for the scanning surface
346
+
347
+ The mention-vs-use failure: a style guide that documents this voice's own
348
+ banned terms — "never say X, say Y instead" — necessarily contains the
349
+ literal banned text, as a MENTION, not a USE. Scanned like any other file,
350
+ that mention looks identical to real, unregistered product copy.
351
+ `ScanOptions.pathExclusions` fixes this for `scanCopySourceTree`'s scanning
352
+ surface (the walk that feeds `checkCopyTraceability`):
353
+
354
+ ```ts
355
+ import { scanCopySourceTree } from "@clossys/writer";
356
+
357
+ const scan = scanCopySourceTree(sourceDir, {
358
+ pathExclusions: [
359
+ { path: "docs/style-guide.ts", reason: "documents banned terms; does not ship them" },
360
+ { path: "docs/**", reason: "internal documentation, not product copy" },
361
+ { path: "fixtures/*.ts", reason: "test fixtures" },
362
+ ],
363
+ });
364
+ ```
365
+
366
+ A matched file is skipped BEFORE it is ever tokenized — it contributes no
367
+ candidates, no excluded literals, no citations, no unchecked items, exactly
368
+ as if it did not exist. The pattern language is deliberately small (no glob
369
+ library, this package's usual zero-runtime-dependency rule): an exact path,
370
+ a `dir/**` subtree, or a single `*` confined to the final path segment.
371
+
372
+ **This is a different feature from `ExclusionReason`/`ExcludedLiteral`**,
373
+ which scan.ts already had: that mechanism classifies one LITERAL already
374
+ found inside a file that IS being scanned (is this string an import
375
+ specifier, a CSS class, ...?) — a per-literal judgment. `pathExclusions`
376
+ answers a different question at a different granularity: should this FILE
377
+ be looked at at all, before a single character of it is tokenized? See
378
+ `src/path-exclusions.ts`'s top doc comment for the full argument for why
379
+ these are two separate mechanisms, not a coincidence of both being named
380
+ "exclusion".
381
+
382
+ **Fails closed**, mirroring `checkCopy`'s `VoiceCheckWaiver` handling: a
383
+ malformed entry (missing/empty `path` or `reason`, or a pattern this small
384
+ grammar cannot parse) is never applied — it exempts nothing — and is
385
+ reported as an `"error"`-severity `PathExclusionFinding` on
386
+ `ScanResult.pathExclusionFindings`. An exclusion that matched zero files
387
+ this run is reported too, as a `"warning"`, since a stale exclusion (the
388
+ file it named was renamed or deleted) is otherwise indistinguishable from
389
+ one still doing real work.
390
+
391
+ ## Copy addressability — is prose resolved from the registry, or typed inline?
392
+
393
+ `checkCopyTraceability` above answers "does this literal match a registered
394
+ entry, or carry a `copy:<id>` citation?" — but a literal that matches is
395
+ still a literal: the sentence sits in the component's own source, so a
396
+ marketing rename means editing every component that typed it, not one
397
+ registry entry. `checkAddressability` (`addressability.ts`) answers the
398
+ stricter question traceability does not: is this prose actually resolved
399
+ from the registry by id? There is no citation or text-match escape hatch.
400
+
401
+ ```ts
402
+ import { scanAddressabilitySources, checkAddressability } from "@clossys/writer";
403
+
404
+ const scan = scanAddressabilitySources(sourceDir);
405
+ const result = checkAddressability(scan);
406
+ // result.verdict: "satisfied" | "violated" | "indeterminate"
407
+ ```
408
+
409
+ Three positions are classified:
410
+
411
+ 1. **Markup text nodes** (`<span>Hello</span>`'s `Hello`) — always a
412
+ violation when it carries real prose.
413
+ 2. **The four user-facing attributes** — `aria-label`, `placeholder`,
414
+ `alt`, `title` — carry prose a person reads and are NOT text nodes; a
415
+ scanner that only understands text nodes reports zero on a component
416
+ whose entire user-facing surface is `<input aria-label="..." />`.
417
+ A violation when the value is literal prose ON A JSX ELEMENT — but NOT
418
+ when the same shape is actually a destructuring-pattern default or a
419
+ plain parameter default (`{ "aria-label": ariaLabel = "Pagination" }`,
420
+ `{ placeholder = "Search" }`, `function f(alt = "...")`): the consumer
421
+ can override a default, so the component's own source does not lock
422
+ the sentence in the way a hardcoded JSX attribute does, and flagging
423
+ one would invert the verdict on an already-addressable construct.
424
+ 3. **Everything else** — a template literal (in any position, including
425
+ one of the four attributes above), an object/array literal value, or a
426
+ prop that is none of the four — this gate cannot confidently tell
427
+ whether it is resolved-through-an-id or genuinely non-user-facing, so it
428
+ is reported as `unchecked` (indeterminate), never silently treated as
429
+ clean — UNLESS the string is itself shaped like a CSS/Tailwind utility
430
+ class list (`"border-t border-line-base pt-xs"`), which is definitively
431
+ not prose and is excluded outright, the same way `scan.ts` already
432
+ excludes that shape when it has an actual `className` attribute name to
433
+ key off.
434
+
435
+ **Verdict precedence (changed in 0.2.0 — see issue #407).**
436
+ `AddressabilityGateResult.verdict` is `"violated"` whenever at least one
437
+ violation was found, REGARDLESS of how many string positions are
438
+ unclassified. It is `"indeterminate"` only when there are zero violations
439
+ AND `unchecked` is non-empty, zero components were scanned, or the tree
440
+ could not be read. This is deliberately the OPPOSITE of
441
+ `checkCopyTraceability`'s "a `2` gates before findings are counted"
442
+ precedence: on a real tree, hundreds of string positions are token data this
443
+ gate cannot classify by design, so letting unclassified-count outrank a
444
+ violation made `"violated"` unreachable outside a fixture — every real run
445
+ had *some* unclassified positions, so it always read `2`. A caller that had
446
+ learned to treat that `2` as "coverage is never complete, ignore it" would
447
+ never see the violations a run actually found. The coverage gap itself is
448
+ still fully reported: `unchecked`/`reasons` stay populated and
449
+ `writer-check addressability`'s own accounting output still prints them
450
+ unconditionally, independent of verdict — only the verdict (and the exit
451
+ code a `1` maps to) changed.
452
+
453
+ Before 0.2.0, a caller that treated exit `2` from `writer-check
454
+ addressability` as "flaky, coverage is never complete, ignore it" will now
455
+ see exit `1` on trees that used to report `2` — that is the fix, not a
456
+ regression: those trees had a real violation the old precedence was hiding
457
+ behind a coverage-gap exit code.
458
+
459
+ `writer-check addressability [scan-dir]` (see `cli.ts` — a subcommand of the
460
+ existing `writer-check` bin, dispatched on an explicit `argv[0] ===
461
+ "addressability"`, never by installed `bin` name or invoking path) exits
462
+ `0` clean / `1` at least one violation (regardless of unclassified count) /
463
+ `2` zero violations but could not fully evaluate — deliberately a SEPARATE
464
+ exit code from `writer-check`'s default command rather than folded into it,
465
+ since the two gates' natural test fixtures are structurally incompatible (a
466
+ literal traceability needs to prove a registry match is exactly a literal
467
+ addressability cannot confirm is safe).
468
+
469
+ ## The passage layer — the missing middle between an entry and a document
470
+
471
+ `@example/copy` (now `writer`) has terms (a glossary) and entries
472
+ (single addressable strings) and nothing between them. `@clossys/designer`
473
+ has tokens, atoms, **and blocks**. In practice nobody reuses one string —
474
+ they reuse a whole empty-state (title + body + action), a whole FAQ item
475
+ (question + answer), a whole error (message + recovery). `Passage`
476
+ (`passage.ts`) is that missing middle: it composes entry/term REFERENCES
477
+ the way a block composes atoms, never a raw sentence of its own. Terms ≈
478
+ tokens, entries ≈ atoms, passages ≈ blocks; documents (a later,
479
+ composition-layer concern, mirroring how a view composes blocks) are out
480
+ of scope here — see [issue #373](https://github.com/clossys/foundry/issues/373).
481
+
482
+ ```ts
483
+ import { checkPassageComposition, readPassageRecord } from "@clossys/writer";
484
+
485
+ const read = readPassageRecord("passages.json");
486
+ if (read.complete && read.record) {
487
+ const result = checkPassageComposition(read.record);
488
+ // result.verdict: "satisfied" | "violated" | "indeterminate"
489
+ }
490
+ ```
491
+
492
+ A `Passage` has a stable, dot-separated `id`, a required `context` (an
493
+ unlocatable passage is not reviewable — the same rule `CopyEntry.context`
494
+ already enforces one layer down), and `fields`: named slots, each of which
495
+ should hold a `PassageReference` — `{ ref: "entry", id }` or `{ ref: "term",
496
+ term }` — never a literal string, and never a pointer into another
497
+ passage's own fields (`{ ref: "passage", ... }`).
498
+
499
+ **The gate, with the ternary:**
500
+
501
+ - **`0` (satisfied)** — every passage references only entries and terms,
502
+ at least one passage evaluated.
503
+ - **`1` (violated)** — a passage inlines a literal string instead of
504
+ referencing an entry (the verbal equivalent of a hardcoded value instead
505
+ of a token), or references another passage's own internals. Wins over an
506
+ incomplete picture in the same run — the same "a real violation must
507
+ outrank an incomplete scan" precedence `checkAddressability` settled on
508
+ for issue #407/#433, applied here from the start rather than discovered
509
+ later.
510
+ - **`2` (indeterminate)** — the registry could not be
511
+ read/parsed/validated, zero passages were registered, or a field's value
512
+ could not be confidently classified, with zero violations found. Fails
513
+ CLOSED with a machine-readable reason (`PassageGateResult.reasons`);
514
+ never a silent pass.
515
+
516
+ **What this gate deliberately does not do**: verify a referenced entry id
517
+ or term actually EXISTS in a real `CopyRecord`/glossary. That is a
518
+ different, weaker question ("does this id resolve") than the one this gate
519
+ answers ("is this field a reference at all, or a smuggled-in literal") —
520
+ the same split `checkAddressability` already draws from
521
+ `checkCopyTraceability`. `passage.adversarial.test.ts` proves the
522
+ separation directly: a weaker tool that only checks "every referenced
523
+ entry id exists" passes a passage built entirely from inline literals
524
+ (zero references means nothing to check), while `writer-check passages`,
525
+ spawned as the compiled CLI exactly the way this repository invokes every
526
+ gate, correctly exits `1` on the identical fixture — plus the sanity check
527
+ that the weak tool is not simply broken (both agree when references
528
+ genuinely are valid).
529
+
530
+ **Not ported**: `@clossys/designer/tokens`' `brandable` boolean. In
531
+ tokens, `brandable` marks a subset WITHIN one namespace (154 ship, 42 are
532
+ brandable). The voice record's own consumer/machinery split runs between
533
+ FILES instead — forcing a boolean into a `Passage` field here would be
534
+ false symmetry with a split that does not exist at this layer. This
535
+ package mirrors the LADDER (terms/entries/passages/documents), never the
536
+ binding mechanism.
537
+
538
+ `writer-check passages <registry-file>` (see `cli.ts` — a subcommand of the
539
+ same `writer-check` bin, dispatched on `argv[0] === "passages"`, the same
540
+ fully-separate dispatch `addressability` already uses) exits `0` satisfied
541
+ / `1` violated / `2` indeterminate.
542
+
543
+ ## API
544
+
545
+ The root entry point exports the copy registry and traceability surface:
546
+
547
+ - Registry and schema: `parseCopyRecord`, `validateCopyRecordShape`,
548
+ `parseCopyRegistry`, `validateCopyRegistryShape`, `readCopyRecord`,
549
+ `createCopyResolver`, `resolveCopyRef`, `checkCopyRecord`, `CopyRecord`,
550
+ `CopyRegistry`, `CopyEntry`, `CopyRegistryEntry`, `CopyRef`, `CopyResolution`,
551
+ `CopyResolver`, `CopySource`, `CopyLocale`, `CopyValue`, `CopyEntryStatus`,
552
+ `CopyResolveIssue`, `CopyResolveIssueReason`, `CopyResolveResult`,
553
+ `CopyEntryId`, `CopyFinding`, `CopyRegistryReadIssue`,
554
+ `CopyRegistryReadIssueReason`, `CopyRegistryReadResult`,
555
+ `CopyEntryCheckResult`, `CopyEntrySkip`, `CopyRecordCheckOptions`,
556
+ `CopyRecordCheckReport`, `CopyRecordFinding`, and
557
+ `CopyRecordWaivedFinding`.
558
+ - Translation provenance and fingerprinting (see "Where this package sits
559
+ on i18n" above): `CopyTranslationProvenance`, `computeCopyFingerprint`,
560
+ and `COPY_FINGERPRINT_ALGORITHM`.
561
+ - Source discovery: `extractCopyCandidates`, `scanCopySourceTree`,
562
+ `PLACEHOLDER_SENTINEL`, `Citation`, `CopyCandidate`, `ExcludedLiteral`,
563
+ `ExclusionReason`, `ParseFailure`, `ScanOptions`, `ScanResult`,
564
+ `SkippedFile`, and `UncheckedItem`.
565
+ - Path exclusions for the scanning surface (see above): `validatePathExclusions`,
566
+ `PathExclusion`, `ExcludedPath`, `PathExclusionFinding`,
567
+ `PathExclusionFindingRule`, and `PathExclusionValidation`.
568
+ - Traceability: `checkCopyTraceability`, `CopyGateFinding`,
569
+ `CopyGateIgnored`, `CopyGateResult`, and `CopyGateRule`.
570
+ - Addressability (see above): `scanAddressabilitySources`,
571
+ `extractAddressabilityCandidates`, `checkAddressability`,
572
+ `AddressabilityGateResult`, `AddressabilityPosition`,
573
+ `AddressabilityScanOptions`, `AddressabilityScanResult`,
574
+ `AddressabilityUncheckedItem`, `AddressabilityVerdict`, and
575
+ `AddressabilityViolation`.
576
+ - Locale-coverage governance: `checkLocaleCoverage`, `LocaleCoverageFinding`,
577
+ `LocaleCoverageReport`, `LocaleCoverageSkip`, and
578
+ `LocaleCoverageSkipReason` — see "Where this package sits on i18n" above.
579
+ - The passage layer (see above): `checkPassageComposition`,
580
+ `classifyPassageField`, `parsePassageRecord`, `readPassageRecord`,
581
+ `validatePassageRecordShape`, `Passage`, `PassageEntryReference`,
582
+ `PassageFieldClassification`, `PassageFinding`, `PassageGateResult`,
583
+ `PassageId`, `PassageRecord`, `PassageReference`,
584
+ `PassageRegistryReadIssue`, `PassageRegistryReadIssueReason`,
585
+ `PassageRegistryReadResult`, `PassageTermReference`,
586
+ `PassageUnclassifiedItem`, `PassageVerdict`, `PassageViolation`, and
587
+ `PassageViolationRule`.
588
+
589
+ The voice names described under Public entry points are re-exported from the
590
+ root and from `@clossys/writer/voice`, including the rule-vocabulary
591
+ additions: `PatternRule`, `VoicePattern`, `VoiceSeverity`, `VOICE_SEVERITIES`,
592
+ `VoiceChannel`, `isCiBlockingSeverity`, `checkPatternSafety`,
593
+ `PatternSafetyIssue`, and `PatternSafetyResult`.