@clossys/writer 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/CHANGELOG.md +196 -0
  2. package/LICENSE +21 -0
  3. package/README.md +593 -0
  4. package/dist/addressability.d.ts +245 -0
  5. package/dist/addressability.d.ts.map +1 -0
  6. package/dist/addressability.js +567 -0
  7. package/dist/addressability.js.map +1 -0
  8. package/dist/checker.d.ts +159 -0
  9. package/dist/checker.d.ts.map +1 -0
  10. package/dist/checker.js +177 -0
  11. package/dist/checker.js.map +1 -0
  12. package/dist/cli.d.ts +149 -0
  13. package/dist/cli.d.ts.map +1 -0
  14. package/dist/cli.js +1049 -0
  15. package/dist/cli.js.map +1 -0
  16. package/dist/copy-gate.d.ts +107 -0
  17. package/dist/copy-gate.d.ts.map +1 -0
  18. package/dist/copy-gate.js +132 -0
  19. package/dist/copy-gate.js.map +1 -0
  20. package/dist/fingerprint.d.ts +61 -0
  21. package/dist/fingerprint.d.ts.map +1 -0
  22. package/dist/fingerprint.js +64 -0
  23. package/dist/fingerprint.js.map +1 -0
  24. package/dist/index.d.ts +47 -0
  25. package/dist/index.d.ts.map +1 -0
  26. package/dist/index.js +63 -0
  27. package/dist/index.js.map +1 -0
  28. package/dist/locale-coverage.d.ts +227 -0
  29. package/dist/locale-coverage.d.ts.map +1 -0
  30. package/dist/locale-coverage.js +414 -0
  31. package/dist/locale-coverage.js.map +1 -0
  32. package/dist/passage.d.ts +294 -0
  33. package/dist/passage.d.ts.map +1 -0
  34. package/dist/passage.js +463 -0
  35. package/dist/passage.js.map +1 -0
  36. package/dist/path-exclusions.d.ts +114 -0
  37. package/dist/path-exclusions.d.ts.map +1 -0
  38. package/dist/path-exclusions.js +213 -0
  39. package/dist/path-exclusions.js.map +1 -0
  40. package/dist/registry.d.ts +61 -0
  41. package/dist/registry.d.ts.map +1 -0
  42. package/dist/registry.js +64 -0
  43. package/dist/registry.js.map +1 -0
  44. package/dist/resolve.d.ts +33 -0
  45. package/dist/resolve.d.ts.map +1 -0
  46. package/dist/resolve.js +133 -0
  47. package/dist/resolve.js.map +1 -0
  48. package/dist/scan.d.ts +457 -0
  49. package/dist/scan.d.ts.map +1 -0
  50. package/dist/scan.js +1698 -0
  51. package/dist/scan.js.map +1 -0
  52. package/dist/schema.d.ts +81 -0
  53. package/dist/schema.d.ts.map +1 -0
  54. package/dist/schema.js +482 -0
  55. package/dist/schema.js.map +1 -0
  56. package/dist/types.d.ts +268 -0
  57. package/dist/types.d.ts.map +1 -0
  58. package/dist/types.js +40 -0
  59. package/dist/types.js.map +1 -0
  60. package/dist/voice/checker.d.ts +186 -0
  61. package/dist/voice/checker.d.ts.map +1 -0
  62. package/dist/voice/checker.js +483 -0
  63. package/dist/voice/checker.js.map +1 -0
  64. package/dist/voice/derivation-coverage.d.ts +168 -0
  65. package/dist/voice/derivation-coverage.d.ts.map +1 -0
  66. package/dist/voice/derivation-coverage.js +181 -0
  67. package/dist/voice/derivation-coverage.js.map +1 -0
  68. package/dist/voice/fields.d.ts +107 -0
  69. package/dist/voice/fields.d.ts.map +1 -0
  70. package/dist/voice/fields.js +144 -0
  71. package/dist/voice/fields.js.map +1 -0
  72. package/dist/voice/index.d.ts +19 -0
  73. package/dist/voice/index.d.ts.map +1 -0
  74. package/dist/voice/index.js +24 -0
  75. package/dist/voice/index.js.map +1 -0
  76. package/dist/voice/internal/parse-template.d.ts +27 -0
  77. package/dist/voice/internal/parse-template.d.ts.map +1 -0
  78. package/dist/voice/internal/parse-template.js +111 -0
  79. package/dist/voice/internal/parse-template.js.map +1 -0
  80. package/dist/voice/internal/pattern-safety.d.ts +111 -0
  81. package/dist/voice/internal/pattern-safety.d.ts.map +1 -0
  82. package/dist/voice/internal/pattern-safety.js +278 -0
  83. package/dist/voice/internal/pattern-safety.js.map +1 -0
  84. package/dist/voice/schema.d.ts +65 -0
  85. package/dist/voice/schema.d.ts.map +1 -0
  86. package/dist/voice/schema.js +535 -0
  87. package/dist/voice/schema.js.map +1 -0
  88. package/dist/voice/types.d.ts +282 -0
  89. package/dist/voice/types.d.ts.map +1 -0
  90. package/dist/voice/types.js +37 -0
  91. package/dist/voice/types.js.map +1 -0
  92. package/package.json +72 -0
  93. package/src/addressability.ts +664 -0
  94. package/src/checker.ts +293 -0
  95. package/src/cli.ts +1166 -0
  96. package/src/copy-gate.ts +197 -0
  97. package/src/fingerprint.ts +66 -0
  98. package/src/index.ts +163 -0
  99. package/src/locale-coverage.ts +512 -0
  100. package/src/passage.ts +644 -0
  101. package/src/path-exclusions.ts +265 -0
  102. package/src/registry.ts +103 -0
  103. package/src/resolve.ts +172 -0
  104. package/src/scan.ts +1959 -0
  105. package/src/schema.ts +528 -0
  106. package/src/types.ts +301 -0
  107. package/src/voice/checker.ts +590 -0
  108. package/src/voice/derivation-coverage.ts +217 -0
  109. package/src/voice/fields.ts +160 -0
  110. package/src/voice/index.ts +55 -0
  111. package/src/voice/internal/parse-template.ts +114 -0
  112. package/src/voice/internal/pattern-safety.ts +336 -0
  113. package/src/voice/schema.ts +585 -0
  114. package/src/voice/types.ts +329 -0
  115. package/templates/voice-record.template.jsonc +135 -0
@@ -0,0 +1,463 @@
1
+ /**
2
+ * The PASSAGE LAYER — issue #373. `@example/copy` (now `writer`,
3
+ * per issue #373's own retargeting comment) has terms (a glossary) and
4
+ * entries (single addressable strings) and nothing between them.
5
+ * `@clossys/designer` has tokens, atoms, AND BLOCKS. This file is the
6
+ * missing middle: a `Passage` composes `CopyEntry`/glossary-term
7
+ * REFERENCES the way a block composes atoms — never a raw sentence of its
8
+ * own. Terms ≈ tokens, entries ≈ atoms, passages ≈ blocks, documents (a
9
+ * later, composition-layer concern, out of scope here — see the issue)
10
+ * ≈ views.
11
+ *
12
+ * WHERE THE MIRROR STOPS. This file deliberately does NOT port
13
+ * `@clossys/designer/tokens`' `brandable` boolean onto a `Passage`
14
+ * field. In tokens, `brandable` marks a subset WITHIN one namespace (154
15
+ * ship, 42 are brandable). The voice record's own consumer/machinery
16
+ * split runs between FILES instead (a consumer's own `VoiceRecord` vs.
17
+ * this package's shipped machinery) — forcing a boolean into a field here
18
+ * would be false symmetry with a split that does not exist at this
19
+ * layer. This file mirrors the LADDER (structure: terms/entries/passages/
20
+ * documents), not the binding mechanism.
21
+ *
22
+ * ============================================================================
23
+ * WHAT A `Passage` IS
24
+ * ============================================================================
25
+ *
26
+ * One reusable, addressable unit of composed language — an empty-state
27
+ * (title + body + action), an FAQ item (question + answer), an error
28
+ * (message + recovery) — never a single string, and never real prose of
29
+ * its own. `fields` is a plain `Record<string, unknown>` AT THE TYPE
30
+ * LEVEL, deliberately: a field's value is exactly what
31
+ * `classifyPassageField`/`checkPassageComposition` below classify at
32
+ * RUNTIME, not what a compile-time type could statically forbid a
33
+ * malformed registry file (real JSON on disk, not TypeScript-checked
34
+ * code) from containing. See "THE TERNARY" below for why a field holding
35
+ * a raw literal string must reach the GATE as a real, reportable
36
+ * violation rather than being rejected upstream as a schema error.
37
+ *
38
+ * This file ships no example content of anyone's real copy — the same
39
+ * "vocabulary layer, never a source of real words" constraint `types.ts`
40
+ * documents for `CopyEntry`/`CopyRecord`. Every fixture in this package's
41
+ * own tests is structural placeholder text, never something a product
42
+ * would actually show a user.
43
+ *
44
+ * ============================================================================
45
+ * THE GATE, WITH THE TERNARY (issue #373)
46
+ * ============================================================================
47
+ *
48
+ * 0 — SATISFIED: every passage references only entries and terms, and
49
+ * at least one passage was evaluated.
50
+ * 1 — VIOLATED: a passage inlines a literal string instead of
51
+ * referencing an entry (the verbal equivalent of a hardcoded color
52
+ * instead of a token), OR a passage references another passage's
53
+ * OWN INTERNALS (`{ ref: "passage", id, field }`) rather than
54
+ * composing entries/terms the way a block composes atoms, never
55
+ * reaching directly into a sibling block's own implementation.
56
+ * 2 — INDETERMINATE: the registry could not be read/parsed/validated,
57
+ * zero passages were evaluated (an empty `passages: []`), or a
58
+ * field's value could not be confidently classified as any of the
59
+ * above (not a string, not a recognized `{ ref: ... }` shape).
60
+ *
61
+ * PRECEDENCE — a real violation outranks an incomplete picture. This
62
+ * package already settled this exact question for a different gate
63
+ * (`addressability.ts`, issue #407/#433): letting "at least one
64
+ * unclassifiable field" outrank a confirmed violation would make the
65
+ * `"violated"` branch practically unreachable the moment a registry ever
66
+ * mixes a genuinely new/unknown field shape in with a real inlined
67
+ * literal — the identical collapse #407 fixed for addressability is fixed
68
+ * here from the start, not discovered later. `checkPassageComposition`
69
+ * applies the same "violated wins" ordering `checkAddressability` does.
70
+ *
71
+ * ============================================================================
72
+ * THE ADVERSARIAL SEPARATION (the weaker tool this gate must beat)
73
+ * ============================================================================
74
+ *
75
+ * A weaker tool that only checks "every referenced entry id exists"
76
+ * passes a passage built ENTIRELY from inline literals, because such a
77
+ * passage has ZERO references for that tool to check — nothing to
78
+ * validate is indistinguishable, to that tool, from everything being
79
+ * valid. The separating fixture: a passage with zero references and real
80
+ * prose sitting directly in its fields. The reference-checker reports
81
+ * clean (0 references, 0 problems). This gate must exit 1 — see
82
+ * `passage.adversarial.test.ts`, which runs both tools against the
83
+ * identical fixture in the same test, plus the sanity check that the weak
84
+ * tool is not simply broken (it correctly reports 0 when references truly
85
+ * do all exist).
86
+ *
87
+ * ============================================================================
88
+ * WHAT THIS GATE DELIBERATELY DOES NOT DO
89
+ * ============================================================================
90
+ *
91
+ * - It does not verify a referenced entry id or term actually EXISTS in
92
+ * a real `CopyRecord`/glossary. That is a DIFFERENT, weaker question
93
+ * ("does this id resolve") than the one this gate answers ("is this
94
+ * field a reference at all, or a smuggled-in literal") — the same
95
+ * split `addressability.ts` draws from `copy-gate.ts`'s traceability
96
+ * check (see that file's own top doc comment: "this gate has no
97
+ * `CopyRecord` input at all"). A gate with visibility into both a
98
+ * `PassageRecord` and a real `CopyRecord`/glossary to check
99
+ * referential integrity is a different, later gate — precisely the
100
+ * "weaker tool" this file's own adversarial proof names and beats on
101
+ * the property that actually matters here: composition purity.
102
+ * - It does not resolve/render a passage into text. Rendering a
103
+ * `Passage` against a real `CopyRecord`/`VoiceRecord` glossary is a
104
+ * consumer-owned, later concern — this file only ever validates
105
+ * SHAPE and COMPOSITION PURITY.
106
+ */
107
+ import { readFileSync } from "node:fs";
108
+ // ---------------------------------------------------------------------------
109
+ // Shape validation
110
+ // ---------------------------------------------------------------------------
111
+ function isPlainObject(value) {
112
+ return typeof value === "object" && value !== null && !Array.isArray(value);
113
+ }
114
+ function isNonEmptyString(value) {
115
+ return typeof value === "string" && value.trim().length > 0;
116
+ }
117
+ /** Same shape `COPY_ENTRY_ID_RE` (`schema.ts`) checks a `CopyEntryId` against — see `PassageId`'s own doc comment for why this is redefined locally rather than imported. */
118
+ const PASSAGE_ID_RE = /^[a-z0-9]+(-[a-z0-9]+)*(\.[a-z0-9]+(-[a-z0-9]+)*)+$/;
119
+ /**
120
+ * Validates one `Passage`'s SHAPE only: `id` present/well-formed,
121
+ * `context` present, `fields` a non-empty object. Deliberately does NOT
122
+ * validate each field VALUE's shape — see this file's top doc comment,
123
+ * "THE GATE": rejecting a field that holds a raw literal string here, as
124
+ * a schema error, would turn every real violation `checkPassageComposition`
125
+ * exists to catch into an "indeterminate" (invalid-schema) read instead
126
+ * of a "violated" one, the exact collapse #407/#433 already fixed for a
127
+ * different gate in this same package.
128
+ */
129
+ function validatePassageShape(value, path) {
130
+ if (!isPlainObject(value)) {
131
+ return [{ rule: "passage-shape", severity: "error", message: `${path} must be an object.`, path }];
132
+ }
133
+ // Snapshot every field this function reads, exactly once, before any
134
+ // check runs — the same defensive discipline `schema.ts`'s
135
+ // `validateCopyEntryShape` documents (a hostile/badly-behaved `value`
136
+ // could otherwise return a different result on each property read).
137
+ const id = value.id;
138
+ const context = value.context;
139
+ const fields = value.fields;
140
+ const findings = [];
141
+ if (!isNonEmptyString(id)) {
142
+ findings.push({
143
+ rule: "id-shape",
144
+ severity: "error",
145
+ message: `${path}.id must be a non-empty string, got ${JSON.stringify(id)}.`,
146
+ path: `${path}.id`,
147
+ });
148
+ }
149
+ else if (!PASSAGE_ID_RE.test(id)) {
150
+ findings.push({
151
+ rule: "id-well-formed",
152
+ severity: "error",
153
+ message: `${path}.id "${id}" must be dot-separated and lowercase, with at least one dot (e.g. "onboarding.empty-state"), got ${JSON.stringify(id)}.`,
154
+ path: `${path}.id`,
155
+ });
156
+ }
157
+ if (!isNonEmptyString(context)) {
158
+ findings.push({
159
+ rule: "context-shape",
160
+ severity: "error",
161
+ message: `${path}.context must be a non-empty string, got ${JSON.stringify(context)}. A passage with no stated location cannot be reviewed.`,
162
+ path: `${path}.context`,
163
+ });
164
+ }
165
+ if (!isPlainObject(fields)) {
166
+ findings.push({
167
+ rule: "fields-shape",
168
+ severity: "error",
169
+ message: `${path}.fields must be an object, got ${JSON.stringify(fields)}.`,
170
+ path: `${path}.fields`,
171
+ });
172
+ }
173
+ else if (Object.keys(fields).length === 0) {
174
+ findings.push({
175
+ rule: "fields-non-empty",
176
+ severity: "error",
177
+ message: `${path}.fields must have at least one field — a passage with zero fields composes nothing.`,
178
+ path: `${path}.fields`,
179
+ });
180
+ }
181
+ return findings;
182
+ }
183
+ /**
184
+ * Validates that `value` conforms to the `PassageRecord` shape. Returns a
185
+ * `PassageFinding[]`; empty means `value` is a well-formed `PassageRecord`.
186
+ * Never throws — mirrors `schema.ts`'s `validateCopyRecordShape` exactly:
187
+ * any input, including `null`/a string/a wildly malformed object,
188
+ * produces findings rather than an exception.
189
+ */
190
+ export function validatePassageRecordShape(value) {
191
+ if (!isPlainObject(value)) {
192
+ return [{ rule: "record-present", severity: "error", message: "A passage record must be an object.", path: "$" }];
193
+ }
194
+ const id = value.id;
195
+ const passages = value.passages;
196
+ const findings = [];
197
+ if (!isNonEmptyString(id)) {
198
+ findings.push({
199
+ rule: "id-shape",
200
+ severity: "error",
201
+ message: `id must be a non-empty string, got ${JSON.stringify(id)}.`,
202
+ path: "id",
203
+ });
204
+ }
205
+ if (!Array.isArray(passages)) {
206
+ findings.push({
207
+ rule: "passages-shape",
208
+ severity: "error",
209
+ message: `passages must be an array, got ${JSON.stringify(passages)}.`,
210
+ path: "passages",
211
+ });
212
+ }
213
+ else {
214
+ passages.forEach((passage, i) => findings.push(...validatePassageShape(passage, `passages.${i}`)));
215
+ // Uniqueness is a whole-record property, checked here rather than
216
+ // inside validatePassageShape — mirrors validateCopyRecordShape's own
217
+ // id-unique pass exactly, including only comparing passages whose own
218
+ // id already passed shape validation.
219
+ const seen = new Map();
220
+ passages.forEach((passage, i) => {
221
+ if (!isPlainObject(passage))
222
+ return;
223
+ const passageId = passage.id;
224
+ if (typeof passageId !== "string" || passageId.length === 0)
225
+ return;
226
+ const firstIndex = seen.get(passageId);
227
+ if (firstIndex === undefined) {
228
+ seen.set(passageId, i);
229
+ }
230
+ else {
231
+ findings.push({
232
+ rule: "id-unique",
233
+ severity: "error",
234
+ message: `passages.${i}.id "${passageId}" duplicates passages.${firstIndex}.id — every passage's id must be unique within a PassageRecord.`,
235
+ path: `passages.${i}.id`,
236
+ });
237
+ }
238
+ });
239
+ }
240
+ return findings;
241
+ }
242
+ function buildPassageRecord(value) {
243
+ const passagesRaw = value.passages;
244
+ const built = passagesRaw.map((raw) => {
245
+ const passage = raw;
246
+ return {
247
+ id: passage.id,
248
+ context: passage.context,
249
+ fields: { ...passage.fields },
250
+ };
251
+ });
252
+ return {
253
+ id: value.id,
254
+ passages: built,
255
+ };
256
+ }
257
+ /**
258
+ * Parses `value` as a `PassageRecord`, throwing a plain `Error` (never a
259
+ * `PassageFinding[]`) if it does not conform — mirrors `schema.ts`'s
260
+ * `parseCopyRecord` exactly, including the two-pass validate-then-build
261
+ * shape and why (see that function's own doc comment for the fuller
262
+ * argument).
263
+ */
264
+ export function parsePassageRecord(value) {
265
+ const findings = validatePassageRecordShape(value);
266
+ if (findings.length > 0) {
267
+ const detail = findings.map((f) => ` - ${f.path ?? "(root)"}: ${f.message}`).join("\n");
268
+ throw new Error(`parsePassageRecord: value is not a valid PassageRecord:\n${detail}`);
269
+ }
270
+ // Safe: validatePassageRecordShape returning no findings means every
271
+ // field buildPassageRecord reads below is present and correctly typed.
272
+ return buildPassageRecord(value);
273
+ }
274
+ /**
275
+ * Reads and validates the `PassageRecord` at `path`. Never throws: every
276
+ * failure — an unreadable file, invalid JSON, or a schema violation — is
277
+ * recorded into `issues` and reflected in `.complete`. Mirrors
278
+ * `registry.ts`'s `readCopyRecord` exactly, including the discipline it
279
+ * documents: an unreadable/invalid registry is "could not run" (fed into
280
+ * exit `2` by the CLI), never a clean pass produced from a record this
281
+ * function could not actually trust.
282
+ */
283
+ export function readPassageRecord(path) {
284
+ let raw;
285
+ try {
286
+ raw = readFileSync(path, "utf8");
287
+ }
288
+ catch (error) {
289
+ return {
290
+ path,
291
+ issues: [{ reason: "unreadable", detail: error instanceof Error ? error.message : String(error) }],
292
+ complete: false,
293
+ };
294
+ }
295
+ let parsed;
296
+ try {
297
+ parsed = JSON.parse(raw);
298
+ }
299
+ catch (error) {
300
+ return {
301
+ path,
302
+ issues: [{ reason: "unparseable", detail: error instanceof Error ? error.message : String(error) }],
303
+ complete: false,
304
+ };
305
+ }
306
+ try {
307
+ const record = parsePassageRecord(parsed);
308
+ return { path, record, issues: [], complete: true };
309
+ }
310
+ catch (error) {
311
+ return {
312
+ path,
313
+ issues: [{ reason: "invalid-schema", detail: error instanceof Error ? error.message : String(error) }],
314
+ complete: false,
315
+ };
316
+ }
317
+ }
318
+ /** A short, length-capped JSON preview of an arbitrary field value, for a finding's own message — never large enough to make a report unreadable. */
319
+ function safePreview(value) {
320
+ let text;
321
+ try {
322
+ text = JSON.stringify(value) ?? String(value);
323
+ }
324
+ catch {
325
+ text = String(value);
326
+ }
327
+ return text.length > 120 ? `${text.slice(0, 120)}…` : text;
328
+ }
329
+ /**
330
+ * Classifies one `Passage` field's raw value into exactly one of the five
331
+ * `PassageFieldClassification` outcomes. Pure — no I/O, never throws on
332
+ * any input shape.
333
+ *
334
+ * - A plain string is ALWAYS `"inline-literal"` — real prose (or
335
+ * anything else) sitting directly in the field instead of a
336
+ * reference. This is the shape the adversarial proof's separating
337
+ * fixture is built from — see this file's top doc comment.
338
+ * - `{ ref: "entry", id: <non-empty string> }` is `"entry-reference"`.
339
+ * - `{ ref: "term", term: <non-empty string> }` is `"term-reference"`.
340
+ * - `{ ref: "passage", ... }` — REGARDLESS of what else it carries — is
341
+ * always `"passage-internals-reference"`: a passage may compose
342
+ * entries and terms, never reach directly into another passage's own
343
+ * fields. This is a VIOLATION, not something this function treats as
344
+ * merely unclassifiable, because the shape is unambiguous the moment
345
+ * `ref === "passage"` is seen.
346
+ * - `{ ref: "entry" | "term", ... }` with a missing/malformed
347
+ * `id`/`term` is `"unclassifiable"` — the AUTHOR'S intent (compose an
348
+ * entry or a term) is legible, but the reference itself is broken, so
349
+ * this function refuses to guess which finding shape to promote it
350
+ * to rather than silently treating a broken reference as either
351
+ * "fine" or "definitely a literal."
352
+ * - Anything else (a number, boolean, null, array, or an object with no
353
+ * recognized `ref`) is `"unclassifiable"`.
354
+ */
355
+ export function classifyPassageField(value) {
356
+ if (typeof value === "string") {
357
+ return {
358
+ classification: "inline-literal",
359
+ detail: `field holds a literal string ${JSON.stringify(safePreview(value))} instead of a reference to a registered entry — the verbal equivalent of inlining a raw value instead of a token`,
360
+ };
361
+ }
362
+ if (isPlainObject(value)) {
363
+ const ref = value.ref;
364
+ if (ref === "entry") {
365
+ const id = value.id;
366
+ if (isNonEmptyString(id)) {
367
+ return { classification: "entry-reference", detail: `references entry "${id}"` };
368
+ }
369
+ return {
370
+ classification: "unclassifiable",
371
+ detail: `{ ref: "entry" } is missing a valid non-empty "id" (got ${safePreview(id)})`,
372
+ };
373
+ }
374
+ if (ref === "term") {
375
+ const term = value.term;
376
+ if (isNonEmptyString(term)) {
377
+ return { classification: "term-reference", detail: `references term "${term}"` };
378
+ }
379
+ return {
380
+ classification: "unclassifiable",
381
+ detail: `{ ref: "term" } is missing a valid non-empty "term" (got ${safePreview(term)})`,
382
+ };
383
+ }
384
+ if (ref === "passage") {
385
+ const id = typeof value.id === "string" && value.id.length > 0 ? value.id : "?";
386
+ const field = typeof value.field === "string" && value.field.length > 0 ? value.field : "?";
387
+ return {
388
+ classification: "passage-internals-reference",
389
+ detail: `reaches directly into passage "${id}"'s own field "${field}" instead of referencing an entry or a term — a passage may compose entries and terms, never another passage's internals`,
390
+ };
391
+ }
392
+ }
393
+ return {
394
+ classification: "unclassifiable",
395
+ detail: `field value ${safePreview(value)} is neither a literal string, an { ref: "entry" }/{ ref: "term" } reference, nor an { ref: "passage" } internals reference — cannot be classified`,
396
+ };
397
+ }
398
+ /**
399
+ * Pure — takes an already-parsed `PassageRecord` (see `readPassageRecord`/
400
+ * `parsePassageRecord`), classifies every field of every passage, and
401
+ * decides the ternary verdict. Never throws, does no I/O.
402
+ *
403
+ * PRECEDENCE: a real violation wins over an incomplete picture — see this
404
+ * file's top doc comment, "THE TERNARY", for why this mirrors
405
+ * `checkAddressability`'s own post-#407 ordering rather than the older
406
+ * "indeterminate wins" precedence `checkCopyTraceability`/`scan.ts` still
407
+ * use for a different reason (see `checkAddressability`'s own doc comment
408
+ * for the fuller argument against letting a permanent-in-practice
409
+ * coverage gap make a real violation unreachable).
410
+ */
411
+ export function checkPassageComposition(record) {
412
+ const violations = [];
413
+ const unclassified = [];
414
+ let passagesEvaluated = 0;
415
+ for (const passage of record.passages) {
416
+ const fieldNames = Object.keys(passage.fields);
417
+ if (fieldNames.length === 0)
418
+ continue; // defensive only — validatePassageRecordShape already requires >= 1 field
419
+ passagesEvaluated++;
420
+ for (const field of fieldNames) {
421
+ const value = passage.fields[field];
422
+ const { classification, detail } = classifyPassageField(value);
423
+ if (classification === "inline-literal") {
424
+ violations.push({
425
+ passageId: passage.id,
426
+ field,
427
+ rule: "field-inlines-literal",
428
+ message: `${passage.id}.${field}: ${detail}`,
429
+ });
430
+ }
431
+ else if (classification === "passage-internals-reference") {
432
+ violations.push({
433
+ passageId: passage.id,
434
+ field,
435
+ rule: "field-references-passage-internals",
436
+ message: `${passage.id}.${field}: ${detail}`,
437
+ });
438
+ }
439
+ else if (classification === "unclassifiable") {
440
+ unclassified.push({ passageId: passage.id, field, detail: `${passage.id}.${field}: ${detail}` });
441
+ }
442
+ // "entry-reference" / "term-reference": exactly the property this gate requires — no finding.
443
+ }
444
+ }
445
+ const reasons = [];
446
+ if (passagesEvaluated === 0) {
447
+ reasons.push(record.passages.length === 0 ? "no passages are registered" : "no passage had at least one field to evaluate");
448
+ }
449
+ if (unclassified.length > 0) {
450
+ reasons.push(`${unclassified.length} field(s) could not be confidently classified`);
451
+ }
452
+ const base = { violations, unclassified, passagesEvaluated };
453
+ // A real violation wins over an incomplete picture — see this file's
454
+ // top doc comment, "THE TERNARY". The coverage gap is not hidden:
455
+ // `reasons`/`unclassified` are still populated and still printed by
456
+ // `cli.ts`'s `printPassagesReport` regardless of verdict.
457
+ if (violations.length > 0)
458
+ return { verdict: "violated", reasons, ...base };
459
+ if (reasons.length > 0)
460
+ return { verdict: "indeterminate", reasons, ...base };
461
+ return { verdict: "satisfied", reasons, ...base };
462
+ }
463
+ //# sourceMappingURL=passage.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"passage.js","sourceRoot":"","sources":["../src/passage.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Path exclusions for `scan.ts`'s SCANNING SURFACE — `scanCopySourceTree`'s
3
+ * directory walk — never to be confused with `scan.ts`'s existing
4
+ * `ExclusionReason`/`ExcludedLiteral`, a DIFFERENT feature this file does
5
+ * not touch. Read `scan.ts`'s own top doc comment ("THE BOUNDARY") before
6
+ * this one: that mechanism decides, for a LITERAL already found inside a
7
+ * file that IS being scanned, whether that one literal is real copy or
8
+ * something structural (an import specifier, a CSS class, an aria label,
9
+ * ...) — a per-LITERAL classification made from the few dozen characters of
10
+ * context immediately around it. This file answers a completely different
11
+ * question, at a completely different granularity: should this FILE be
12
+ * looked at for copy candidates AT ALL, before a single character of it is
13
+ * ever tokenized? That is a per-FILE scope decision, made once, from the
14
+ * file's own path alone — extending `ExclusionReason` to cover it would be
15
+ * a coincidence of both being named "exclusion", not a shared mechanism;
16
+ * this is why it is a new, separate type (`PathExclusion`) and a new,
17
+ * separate `ScanResult` field (`excludedFiles`), not a tenth
18
+ * `ExclusionReason`.
19
+ *
20
+ * THE MOTIVATING CASE ("mention vs. use"): a style guide or README that
21
+ * documents this voice's own banned terms — "never say X, say Y instead" —
22
+ * necessarily CONTAINS the literal banned text, as a MENTION, not a USE.
23
+ * Scanned like any other file, that mention looks identical to real,
24
+ * unregistered product copy to `checkCopyTraceability` (`copy-gate.ts`):
25
+ * the traceability gate has no notion of "this file's job is to talk about
26
+ * copy, not ship it". A `PathExclusion` on that one file's path is the fix
27
+ * — the file is never tokenized at all, so it can never produce a
28
+ * candidate, `unregistered-copy` finding, or citation. See this file's
29
+ * README section, "Path exclusions vs. per-literal exclusions", for the
30
+ * fuller argument, including why this stays scoped to `scan.ts`'s existing
31
+ * scanning surface (copy TRACEABILITY) rather than inventing a new
32
+ * directory-scanning feature for VOICE/glossary rules, which this package
33
+ * does not otherwise have — a consumer building their own "walk a
34
+ * directory, then run each file's text through `checkCopy`" tool gets the
35
+ * identical mention-vs-use protection by reusing THIS mechanism (call
36
+ * `scanCopySourceTree` with `pathExclusions`, then feed its candidates'
37
+ * text through `checkCopy` themselves) rather than this package growing a
38
+ * second, redundant exclusion list.
39
+ *
40
+ * FAILS CLOSED, mirroring `checker.ts`'s `VoiceCheckWaiver` handling (the
41
+ * established precedent for "a caller-supplied list of exceptions" in this
42
+ * package's ecosystem) almost exactly:
43
+ *
44
+ * - A MALFORMED entry (not an object, `path`/`reason` missing or not a
45
+ * non-empty string, or a `path` this file's own small pattern language
46
+ * cannot parse — see `classifyPathExclusionPattern`) is never applied:
47
+ * it exempts NOTHING, and is reported as its own `"error"`-severity
48
+ * `PathExclusionFinding` — the exact "a malformed exclusion entry is a
49
+ * finding, never a silent exemption" requirement. This mirrors
50
+ * `"waiver:invalid"`.
51
+ * - An exclusion that never matched a single scanned file is dead
52
+ * configuration — reported as its own `"warning"`-severity
53
+ * `PathExclusionFinding`, mirroring `"waiver:unused"`, so a stale
54
+ * exclusion (the file it named was renamed or deleted) cannot silently
55
+ * sit in a config forever looking like it is still doing something.
56
+ */
57
+ export interface PathExclusion {
58
+ /**
59
+ * A repo-relative, `/`-joined path pattern — see
60
+ * `classifyPathExclusionPattern`'s doc comment for the exact, small,
61
+ * hand-rolled pattern language this supports (no glob library — this
62
+ * package's zero-runtime-dependency rule applies here too).
63
+ */
64
+ path: string;
65
+ /** Required — an exclusion with no stated reason is not auditable, the same discipline `GlossaryEntry.reason` and `VoiceCheckWaiver.reason` already hold every other exception in this package's ecosystem to. */
66
+ reason: string;
67
+ }
68
+ /** One file `scanCopySourceTree` skipped entirely because its path matched a `PathExclusion` — never tokenized, contributes zero candidates/excluded-literals/citations/unchecked-items. */
69
+ export interface ExcludedPath {
70
+ file: string;
71
+ reason: string;
72
+ /** The `PathExclusion.path` pattern that matched, for audit. */
73
+ pattern: string;
74
+ }
75
+ export type PathExclusionFindingRule = "path-exclusion-invalid" | "path-exclusion-unused";
76
+ /** One problem with the `pathExclusions` CONFIGURATION itself — never about a scanned file's content. See this file's top doc comment for the fail-closed contract both rules implement. */
77
+ export interface PathExclusionFinding {
78
+ rule: PathExclusionFindingRule;
79
+ /** `"error"` for a malformed entry (never applied); `"warning"` for a well-formed entry that matched nothing this run (applied as designed, just possibly stale). */
80
+ severity: "error" | "warning";
81
+ message: string;
82
+ /** The exclusion's own `path` pattern, when there is one to point at (absent only for a top-level shape error, e.g. `pathExclusions` itself not being an array). */
83
+ path?: string;
84
+ }
85
+ /** One validated `PathExclusion`, tagged with which of this file's three supported pattern shapes it is — computed once by `validatePathExclusions` so `matchesPathExclusion` never has to re-derive it (and never has to re-validate what was already validated). */
86
+ export interface ValidatedPathExclusion extends PathExclusion {
87
+ kind: "exact" | "subtree" | "segment-wildcard";
88
+ }
89
+ /**
90
+ * Matches `relPath` (already `/`-joined, repo-relative — the same shape
91
+ * `scanCopySourceTree` produces for every `CopyCandidate.file`) against one
92
+ * already-classified exclusion. Never called with a pattern that failed
93
+ * `classifyPathExclusionPattern` — `validatePathExclusions` filters those
94
+ * out before anything reaches here, so this function does not re-validate.
95
+ */
96
+ export declare function matchesPathExclusion(relPath: string, exclusion: ValidatedPathExclusion): boolean;
97
+ export interface PathExclusionValidation {
98
+ /** Every exclusion that passed shape+pattern validation — safe to match files against. */
99
+ valid: ValidatedPathExclusion[];
100
+ /** Malformed entries, as `"error"`-severity findings. Never includes `"path-exclusion-unused"` — that rule is only ever added later, once the walk has actually run; see `scanCopySourceTree`. */
101
+ findings: PathExclusionFinding[];
102
+ }
103
+ /**
104
+ * Validates `value` (`ScanOptions.pathExclusions`, or any raw candidate list
105
+ * — this is called on a TYPED `PathExclusion[]` from a TypeScript caller,
106
+ * but re-validates every field anyway, the same defense-in-depth this
107
+ * repository's other schema functions apply to already-typed input: see
108
+ * `@clossys/writer/voice`'s `schema.ts` header comment for why a
109
+ * plain-JS caller, or a value that merely satisfies the type at compile
110
+ * time without conforming at runtime, is exactly the case this guards
111
+ * against). Never throws.
112
+ */
113
+ export declare function validatePathExclusions(value: unknown): PathExclusionValidation;
114
+ //# sourceMappingURL=path-exclusions.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"path-exclusions.d.ts","sourceRoot":"","sources":["../src/path-exclusions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuDG;AAEH,MAAM,WAAW,aAAa;IAC5B;;;;;OAKG;IACH,IAAI,EAAE,MAAM,CAAC;IACb,kNAAkN;IAClN,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,4LAA4L;AAC5L,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,gEAAgE;IAChE,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,MAAM,wBAAwB,GAAG,wBAAwB,GAAG,uBAAuB,CAAC;AAE1F,4LAA4L;AAC5L,MAAM,WAAW,oBAAoB;IACnC,IAAI,EAAE,wBAAwB,CAAC;IAC/B,qKAAqK;IACrK,QAAQ,EAAE,OAAO,GAAG,SAAS,CAAC;IAC9B,OAAO,EAAE,MAAM,CAAC;IAChB,oKAAoK;IACpK,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,sQAAsQ;AACtQ,MAAM,WAAW,sBAAuB,SAAQ,aAAa;IAC3D,IAAI,EAAE,OAAO,GAAG,SAAS,GAAG,kBAAkB,CAAC;CAChD;AA4DD;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,sBAAsB,GAAG,OAAO,CAuBhG;AAED,MAAM,WAAW,uBAAuB;IACtC,0FAA0F;IAC1F,KAAK,EAAE,sBAAsB,EAAE,CAAC;IAChC,kMAAkM;IAClM,QAAQ,EAAE,oBAAoB,EAAE,CAAC;CAClC;AAED;;;;;;;;;GASG;AACH,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,OAAO,GAAG,uBAAuB,CA+D9E"}