@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
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`.
|