@clossys/writer 0.3.8 → 0.4.0

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 (56) hide show
  1. package/README.md +103 -4
  2. package/dist/addressability.d.ts +11 -1
  3. package/dist/addressability.d.ts.map +1 -1
  4. package/dist/addressability.js +169 -3
  5. package/dist/addressability.js.map +1 -1
  6. package/dist/approved-copy-coverage-rate-cli.d.ts +10 -0
  7. package/dist/approved-copy-coverage-rate-cli.d.ts.map +1 -0
  8. package/dist/approved-copy-coverage-rate-cli.js +59 -0
  9. package/dist/approved-copy-coverage-rate-cli.js.map +1 -0
  10. package/dist/approved-copy-coverage-rate.d.ts +23 -0
  11. package/dist/approved-copy-coverage-rate.d.ts.map +1 -0
  12. package/dist/approved-copy-coverage-rate.js +153 -0
  13. package/dist/approved-copy-coverage-rate.js.map +1 -0
  14. package/dist/cli.d.ts.map +1 -1
  15. package/dist/cli.js +216 -9
  16. package/dist/cli.js.map +1 -1
  17. package/dist/fold-wallpaper-gate.d.ts +7 -0
  18. package/dist/fold-wallpaper-gate.d.ts.map +1 -0
  19. package/dist/fold-wallpaper-gate.js +29 -0
  20. package/dist/fold-wallpaper-gate.js.map +1 -0
  21. package/dist/index.d.ts +6 -0
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +3 -0
  24. package/dist/index.js.map +1 -1
  25. package/dist/live-copy-gate.d.ts +16 -0
  26. package/dist/live-copy-gate.d.ts.map +1 -0
  27. package/dist/live-copy-gate.js +135 -0
  28. package/dist/live-copy-gate.js.map +1 -0
  29. package/dist/render-registry-parity.d.ts +20 -0
  30. package/dist/render-registry-parity.d.ts.map +1 -0
  31. package/dist/render-registry-parity.js +45 -0
  32. package/dist/render-registry-parity.js.map +1 -0
  33. package/dist/schema.d.ts.map +1 -1
  34. package/dist/schema.js +22 -0
  35. package/dist/schema.js.map +1 -1
  36. package/dist/treatment-word-budget.d.ts +31 -0
  37. package/dist/treatment-word-budget.d.ts.map +1 -0
  38. package/dist/treatment-word-budget.js +80 -0
  39. package/dist/treatment-word-budget.js.map +1 -0
  40. package/dist/types.d.ts +11 -0
  41. package/dist/types.d.ts.map +1 -1
  42. package/fit-signals.json +11 -0
  43. package/package.json +202 -6
  44. package/skill/SKILL.md +49 -0
  45. package/src/addressability.ts +186 -4
  46. package/src/approved-copy-coverage-rate-cli.ts +55 -0
  47. package/src/approved-copy-coverage-rate.ts +201 -0
  48. package/src/cli.ts +228 -9
  49. package/src/fold-wallpaper-gate.ts +29 -0
  50. package/src/index.ts +16 -0
  51. package/src/live-copy-gate.ts +150 -0
  52. package/src/render-registry-parity.ts +65 -0
  53. package/src/schema.ts +24 -0
  54. package/src/treatment-word-budget.ts +100 -0
  55. package/src/types.ts +11 -0
  56. package/CHANGELOG.md +0 -307
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Treatment-level word budgets for registry entries — display headings,
3
+ * eyebrows, button labels, and other short treatments that are not body
4
+ * measure. Pure gate: takes an already-validated `CopyRegistry`, never I/O.
5
+ */
6
+
7
+ import type { CopyEntryId, CopyRegistry, CopyRegistryEntry } from "./types.js";
8
+
9
+ /** Known slot treatments with a default maximum word count when the entry omits `maxWords`. */
10
+ export const DEFAULT_TREATMENT_WORD_BUDGETS: Readonly<Record<string, number>> = {
11
+ "display-heading": 12,
12
+ eyebrow: 8,
13
+ button: 6,
14
+ };
15
+
16
+ export type TreatmentWordBudgetRule = "word-budget-exceeded" | "unknown-treatment";
17
+
18
+ export interface TreatmentWordBudgetFinding {
19
+ rule: TreatmentWordBudgetRule;
20
+ severity: "error";
21
+ entryId: CopyEntryId;
22
+ treatment: string;
23
+ maxWords: number;
24
+ actualWords: number;
25
+ message: string;
26
+ }
27
+
28
+ export interface TreatmentWordBudgetResult {
29
+ entriesChecked: number;
30
+ findings: TreatmentWordBudgetFinding[];
31
+ }
32
+
33
+ /** Counts words in resolved copy text (whitespace-separated tokens). */
34
+ export function countCopyWords(text: string): number {
35
+ const trimmed = text.trim();
36
+ if (trimmed.length === 0) return 0;
37
+ return trimmed.split(/\s+/u).length;
38
+ }
39
+
40
+ function budgetForEntry(entry: CopyRegistryEntry): { treatment: string; maxWords: number } | undefined {
41
+ const treatment = entry.treatment;
42
+ if (typeof treatment !== "string" || treatment.trim().length === 0) return undefined;
43
+ const normalized = treatment.trim();
44
+ if (entry.maxWords !== undefined) {
45
+ if (typeof entry.maxWords !== "number" || !Number.isFinite(entry.maxWords) || entry.maxWords < 1) {
46
+ return undefined;
47
+ }
48
+ return { treatment: normalized, maxWords: Math.floor(entry.maxWords) };
49
+ }
50
+ const defaultMax = DEFAULT_TREATMENT_WORD_BUDGETS[normalized];
51
+ if (defaultMax === undefined) return undefined;
52
+ return { treatment: normalized, maxWords: defaultMax };
53
+ }
54
+
55
+ /**
56
+ * Fails when an approved entry declares a `treatment` (or `maxWords`) and
57
+ * its `text` exceeds the applicable budget. Draft and retired entries are
58
+ * skipped — only copy that can resolve at render is measured.
59
+ */
60
+ export function checkTreatmentWordBudgets(registry: CopyRegistry): TreatmentWordBudgetResult {
61
+ const findings: TreatmentWordBudgetFinding[] = [];
62
+ let entriesChecked = 0;
63
+
64
+ for (const entry of registry.entries) {
65
+ if (entry.status !== "approved") continue;
66
+ const budget = budgetForEntry(entry);
67
+ if (!budget) {
68
+ if (typeof entry.treatment === "string" && entry.treatment.trim().length > 0) {
69
+ const normalized = entry.treatment.trim();
70
+ if (entry.maxWords === undefined && DEFAULT_TREATMENT_WORD_BUDGETS[normalized] === undefined) {
71
+ findings.push({
72
+ rule: "unknown-treatment",
73
+ severity: "error",
74
+ entryId: entry.id,
75
+ treatment: normalized,
76
+ maxWords: 0,
77
+ actualWords: countCopyWords(entry.text),
78
+ message: `Entry "${entry.id}" declares treatment "${normalized}" with no maxWords and no built-in default — add maxWords or use a known treatment (${Object.keys(DEFAULT_TREATMENT_WORD_BUDGETS).join(", ")}).`,
79
+ });
80
+ }
81
+ }
82
+ continue;
83
+ }
84
+ entriesChecked++;
85
+ const actualWords = countCopyWords(entry.text);
86
+ if (actualWords > budget.maxWords) {
87
+ findings.push({
88
+ rule: "word-budget-exceeded",
89
+ severity: "error",
90
+ entryId: entry.id,
91
+ treatment: budget.treatment,
92
+ maxWords: budget.maxWords,
93
+ actualWords,
94
+ message: `Entry "${entry.id}" (${budget.treatment}) has ${actualWords} word(s) but the budget is ${budget.maxWords}.`,
95
+ });
96
+ }
97
+ }
98
+
99
+ return { entriesChecked, findings };
100
+ }
package/src/types.ts CHANGED
@@ -166,6 +166,17 @@ export interface CopyEntry {
166
166
  * same seam, one layer over).
167
167
  */
168
168
  factRef?: string;
169
+ /**
170
+ * Named slot treatment for word-budget governance — e.g. `display-heading`,
171
+ * `eyebrow`, `button`. When set on an approved registry entry, `writer-check`
172
+ * applies `maxWords` or the built-in default for that treatment.
173
+ */
174
+ treatment?: string;
175
+ /**
176
+ * Maximum word count for this entry's `text` when `treatment` is set.
177
+ * Overrides the built-in default table when present.
178
+ */
179
+ maxWords?: number;
169
180
  }
170
181
 
171
182
  /**
package/CHANGELOG.md DELETED
@@ -1,307 +0,0 @@
1
- # Changelog
2
-
3
- All notable changes to this package are documented here. Format follows
4
- [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
-
6
- ## [0.3.8] - 2026-09-16
7
-
8
- ### Added
9
-
10
- - README now documents the `writer-check --format json` contract added in
11
- 0.3.7: the exact object shape, and the guarantee that `verdict`, `findings`
12
- and `unchecked` are present together on every path — clean, findings, and
13
- total failure alike. The flag was documented in `--help` and in this
14
- changelog but not in the README, where this package documents its other
15
- CLI exit-code contracts (#880).
16
-
17
- ### Changed
18
-
19
- - Version bumped from 0.3.7 to 0.3.8 without a functional change to the
20
- shipped code. 0.3.7 was never published: its retained qualification record
21
- bound a tarball that could not be reproduced from the tree, because the
22
- record was generated against an incrementally-built `dist/` that carried
23
- stale artifacts. Qualification records are immutable — one introduction per
24
- path, hash-pinned — so the 0.3.7 record could not be corrected and 0.3.7
25
- could never be published. It is skipped deliberately rather than reused.
26
-
27
- The mechanism is filed as #893; this release's own record was generated
28
- from a clean build on the pinned release runtime specifically because of it.
29
-
30
- ## [0.3.7] - 2026-09-15
31
-
32
- ### Fixed
33
-
34
- - The default `writer-check` command's scanner could not classify a `//`
35
- line comment or a `/* */` block comment placed BETWEEN a JSX element's
36
- attributes — legal TSX every mainstream formatter produces for a
37
- commented-out or explained prop. The attribute-parsing loop inside
38
- `tryScanJsxElement` had no case for either comment form, so it fell
39
- through to the loop's own "not committed, silently not-JSX" backtrack;
40
- for a NESTED element (the realistic shape in a real component tree)
41
- that backtrack surfaced as an `"unrecognized-jsx-child"` `unchecked`
42
- entry, and this package's own indeterminate-fold ("one unreadable
43
- region must not let a scan report itself clean") turned that ONE
44
- construct into exit code 2 for the WHOLE run — discarding every real
45
- finding the same scan had already produced (issue #753: a 270-entry
46
- `CopyRecord` scan against a real application tree extracted 294
47
- candidates, produced 292 findings, and still exited 2 over this one
48
- construct). Comment trivia between attributes is now skipped exactly
49
- like the whitespace surrounding it, before any commit decision is
50
- made — the same "silent, pre-commit trivia" treatment this loop
51
- already gives TSX's generic-arrow-function syntax.
52
-
53
- ### Added
54
-
55
- - `writer-check --format json`: the default command's own structured,
56
- machine-readable report (`CopyTraceabilityReport`), matching the
57
- `--format json` shape `inspector`'s CLI already publishes. Exit code 2
58
- (indeterminate) is unchanged and still wins whenever any JSX construct
59
- is `unchecked` — an indeterminate result must never read as clean, and
60
- this does not touch that rule. What changes is that a consumer no
61
- longer has to choose between reading the exit code and discarding
62
- everything the run DID measure: `--format json` prints exactly one
63
- object to stdout, always carrying `verdict` (the same
64
- `"clean" | "findings" | "indeterminate"` states this package uses
65
- everywhere), `findings`, and `unchecked` together, regardless of which
66
- verdict resulted — so a real finding produced alongside a genuinely
67
- unclassifiable construct in the same run is still reachable by a
68
- consumer that reads past the exit code, exactly as #753 asked for,
69
- while a consumer that only checks `exitCode`/`verdict` still, correctly,
70
- never sees an indeterminate run reported as a pass.
71
-
72
- ## [0.3.6] - 2026-09-14
73
-
74
- ### Changed
75
-
76
- - Patch version bump only, to obtain a fresh, never-before-used
77
- `governance/release-qualifications/` record path. The 0.3.5 qualification
78
- record added by #811 was orphaned when that pull request was squash-merged
79
- (#821) and had to be removed (#834); the immutability gate that protects
80
- already-introduced record paths (`check-candidate-qualification.mjs`'s
81
- single-introduction-commit invariant) means a valid record can never again
82
- be introduced at the `0.3.5` path, so this package moves to `0.3.6`
83
- purely to regain one. No functional or behavioral change.
84
-
85
-
86
- ## [0.3.5] - 2026-09-09
87
-
88
- ### Changed
89
-
90
- - Historical entries below now describe the previous npm scope without naming
91
- the producer account this catalogue no longer publishes under, and links to
92
- this repository use its current `clossys/foundry` path. No date, version,
93
- or recorded fact changed — only the way the retired scope is referred to.
94
-
95
-
96
- ## [0.3.4] - 2026-09-02
97
-
98
- ### Fixed
99
-
100
- - Declared `bin` targets without a leading `./`. npm rejected the dotted
101
- form as an invalid script name and **removed the entry entirely** on
102
- publish, so `writer-check` would not have been installed
103
- by a consumer of the previous release.
104
-
105
- ### Changed
106
-
107
- - Named Clossys as copyright holder in `LICENSE` and as `author` in the
108
- package manifest, so every package in the catalogue attributes identically.
109
-
110
-
111
- ## [0.3.3] - 2026-08-31
112
-
113
- ### Changed
114
-
115
- - Prepared a bounded trusted-publisher patch source for provenance after the owner-present first publication and anonymous registry verification. This change does not publish the package or claim provenance.
116
-
117
- ## [0.3.2] - 2026-08-30
118
-
119
- ### Changed
120
-
121
- - Updated the package's public repository, issue-tracker, and homepage metadata to the canonical Foundry repository. This change is not a publication or qualification claim.
122
-
123
- ## [0.3.1] - 2026-08-24
124
-
125
- ### Fixed
126
-
127
- - Added a current lifecycle note to the historical donor availability record.
128
-
129
- ## [0.3.0] - 2026-08-21
130
-
131
- ### Added — the passage layer (issue #373)
132
-
133
- **A `Passage` composes `CopyEntry`/glossary-term REFERENCES the way a UI
134
- block composes atoms.** `@clossys/writer` already had terms (a
135
- glossary) and entries (single addressable strings) and nothing between
136
- them — in practice nobody reuses one string; they reuse a whole
137
- empty-state (title + body + action), a whole FAQ item (question + answer),
138
- a whole error (message + recovery). This release adds that missing
139
- middle: terms ≈ tokens, entries ≈ atoms, passages ≈ blocks. Documents
140
- (the composition layer, mirroring how a view composes blocks) remain out
141
- of scope for this release — see the issue.
142
-
143
- - **`PassageRecord`/`Passage`** (`passage.ts`): a `Passage` has a stable
144
- dot-separated `id`, a required `context` (an unlocatable passage is not
145
- reviewable, mirroring `CopyEntry.context`'s own rule), and `fields` — a
146
- set of named slots (`title`, `body`, `action`, ...) each of which should
147
- hold a `PassageReference`: `{ ref: "entry", id }` or `{ ref: "term",
148
- term }`.
149
- - **`validatePassageRecordShape`/`parsePassageRecord`** — structural
150
- validation, in the same dependency-free, accumulate-and-keep-going style
151
- every other schema in this package uses. Deliberately does NOT validate
152
- a field's own value shape (whether it's a literal or a reference) — see
153
- below.
154
- - **`readPassageRecord`** — the one place in `passage.ts` that touches a
155
- filesystem; never throws, mirrors `registry.ts`'s `readCopyRecord`
156
- exactly.
157
- - **`classifyPassageField`/`checkPassageComposition`** — the passage
158
- COMPOSITION gate, wired to a new CLI subcommand, `writer-check passages
159
- <registry-file>`, dispatched the identical way `writer-check
160
- addressability` already is (a fully separate top-level branch, its own
161
- exported `mainPassagesCheck`, never folded through `main()`). THE
162
- TERNARY:
163
- - **`0` (satisfied)** — every passage references only entries and
164
- terms, at least one passage evaluated.
165
- - **`1` (violated)** — a passage inlines a literal string instead of
166
- referencing an entry (the verbal equivalent of a hardcoded value
167
- instead of a token), or references another passage's own internals
168
- (`{ ref: "passage", ... }`) rather than composing entries/terms the
169
- way a block composes atoms. Wins over an incomplete picture in the
170
- same run — the identical "a real violation must outrank an incomplete
171
- scan" precedence this package's own `addressability` gate settled on
172
- for issue #407/#433, applied here from the start.
173
- - **`2` (indeterminate)** — the registry could not be
174
- read/parsed/validated, zero passages were registered, or a field's
175
- value could not be confidently classified, with zero violations found.
176
- Fails CLOSED with a machine-readable reason; never a silent pass.
177
- - **What this gate deliberately does not do**: verify a referenced entry
178
- id or term actually exists in a real `CopyRecord`/glossary. That is a
179
- different, weaker question than the one this gate answers (composition
180
- purity: is this field a reference at all, never mind whether it
181
- resolves) — the same split `addressability.ts` already draws from
182
- `copy-gate.ts`'s traceability check. See
183
- `passage.adversarial.test.ts` for the proof: a weaker
184
- "every referenced entry id exists" tool passes a passage built entirely
185
- from inline literals (it has zero references to check), while
186
- `writer-check passages`, spawned as the compiled CLI, correctly exits 1
187
- on the identical fixture.
188
- - Not ported from `@clossys/designer/tokens`: the `brandable` boolean.
189
- See `passage.ts`'s own top doc comment, "WHERE THE MIRROR STOPS", for
190
- why forcing that field into this layer would be false symmetry.
191
-
192
- `checkPassageComposition`, `classifyPassageField`, `parsePassageRecord`,
193
- `readPassageRecord`, `validatePassageRecordShape`, and every associated
194
- type are exported from this package's root entry point.
195
-
196
- ## [0.2.0] - 2026-08-21
197
-
198
- ### Changed — BEHAVIOURAL, read this before upgrading (issue #407)
199
-
200
- **`writer-check addressability`'s exit-code precedence flipped.** A run that
201
- finds at least one violation now exits `1` REGARDLESS of how many string
202
- positions are unclassified. Previously, any unclassified ("unchecked")
203
- position forced exit `2` ("indeterminate") even when the same run had
204
- already found and named real violations — and on any real tree, hundreds of
205
- positions are token data this gate cannot classify by design, so the old
206
- precedence made `1` unreachable outside a fixture: every real run had *some*
207
- unclassified positions and therefore always read `2`, silently discarding
208
- every violation it had actually found.
209
-
210
- - **`violated` (exit `1`)** — at least one violation, regardless of
211
- unclassified count. The coverage gap is not hidden by this: `unchecked`
212
- and `reasons` are still populated and `writer-check addressability`
213
- still prints them unconditionally; only the verdict changed.
214
- - **`indeterminate` (exit `2`)** — zero violations AND at least one
215
- unclassified position (or the tree could not be read, or zero components
216
- were scanned). The honest "found nothing, but did not see everything"
217
- case — unchanged, and still never a pass.
218
- - **`satisfied` (exit `0`)** — zero violations, zero unclassified, over at
219
- least one scanned file. Unchanged.
220
-
221
- **If your CI treats `writer-check addressability`'s exit `2` as
222
- non-blocking or "flaky, coverage is never complete, ignore it": stop.** A
223
- tree that used to report `2` will now correctly report `1` whenever it
224
- contains a real violation, and that is the point of this release, not a
225
- regression — those trees always had the violation, this exit code was
226
- previously the only thing hiding it.
227
-
228
- `checkAddressability`'s `AddressabilityGateResult.verdict`/`.reasons` and
229
- `mainAddressabilityCheck`'s exit code both changed together; no other
230
- export's shape changed. See `addressability.ts`'s top doc comment ("THE
231
- TERNARY") and `README.md`'s "Copy addressability" section for the full
232
- precedence.
233
-
234
- ## [0.1.0] - 2026-08-21
235
-
236
- First release. This package is the writer role, recut from the previous
237
- scope's `copy` per
238
- [decision 10](../../docs/DECISIONS.md#10-recutting-the-expression-surface-into-role-shaped-packages).
239
-
240
- This changelog starts here rather than carrying the donor's history, which
241
- cites decisions and issues that would mean nothing — or the wrong thing — to
242
- a reader who arrives at this package first.
243
-
244
- ### Added
245
-
246
- - A consumer-owned language system: voice rules (glossary terms and
247
- regex-safe pattern rules), claims validation, addressable and
248
- locale-aware copy records (`CopyRegistry`, `CopyRef`), source
249
- traceability scanning, and computed content fingerprints.
250
- - `readCopyRecord` and the `checkCopy` voice checker, unchanged from the
251
- donor.
252
- - Four gates, all reachable from the single `writer-check` bin: the
253
- default traceability command, `addressability`, `voice-derivation-coverage`,
254
- and `locale-coverage`. Each dispatches on `argv[0]` matching exactly —
255
- never on `basename(process.argv[1])`, which would see `cli.js` and
256
- silently run the wrong command wherever a gate is invoked by compiled
257
- path.
258
- - Zero runtime dependencies, unchanged from the donor.
259
- - The `voice-record.template.jsonc` template, still reachable at the
260
- `@clossys/writer/voice-record.template.jsonc` export subpath.
261
- - **The published tarball carries this changelog.** `files` includes
262
- `CHANGELOG.md`, following the convention the operation packages adopted in
263
- #417. A consumer reading the installed package should not have to leave it
264
- to find out what changed; a new package should be born with the current
265
- convention rather than inheriting its donor's gap.
266
-
267
- ### Changed from the previous scope's `copy`
268
-
269
- - **The package is named for the job, not the artifact.** The role's
270
- exclusive question is *is it well said?* A name that describes a thing
271
- rather than a doer is an artifact, and an artifact belongs inside a role.
272
- - **The bin is `writer-check`, not `copy-check`.** Same program, same four
273
- subcommands, renamed to match the package.
274
- - **Nothing else was renamed.** `CopyRegistry`, `CopyRef`, `checkCopyRecord`,
275
- `CopyEntry`, `checkCopyTraceability`, the `copy-record` vocabulary, and
276
- every voice/glossary/claim term keep their names. A role owns artifacts;
277
- renaming the role does not rename what it reasons about. A sweep that also
278
- renamed the vocabulary would have made the diff unreviewable while
279
- changing no behaviour.
280
- - Self-referential `copy` package-name mentions in doc
281
- comments — including the `/voice` subpath — were updated to
282
- `@clossys/writer`, the same treatment `strategist` gave its own
283
- self-references to its own donor, `strategy`.
284
-
285
- ### On the donor, and why it is not deprecated yet
286
-
287
- > **Current lifecycle note:** the previous scope's `copy` and
288
- > `surface` are now retired. This release note records their
289
- > state at 0.1.0; the lifecycle contract is the authority for current
290
- > availability.
291
-
292
- The previous scope's `copy` stays `published` for now. It cannot be marked
293
- deprecated while that scope's `surface` still declares it as a runtime
294
- dependency — `check:package-governance` reports a lifecycle finding for a live
295
- package depending on a deprecated one, and it is right to. The donors are
296
- deprecated once `publisher` replaces `surface` and depends on this package
297
- instead.
298
-
299
- This is a deferral with a trigger, not an omission.
300
-
301
- ### Not included
302
-
303
- - **No forwarding stub in the donor.** The previous scope's `copy` is
304
- deprecated-and-retained: still installable for a consumer already pinned
305
- to it, with no re-export pointing here. A stub would keep the old name
306
- importable, and a supersession check could then never reach zero — the
307
- forwarding layer would defeat the gate built to prove the swap completed.