@clossys/writer 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/CHANGELOG.md +196 -0
  2. package/LICENSE +21 -0
  3. package/README.md +593 -0
  4. package/dist/addressability.d.ts +245 -0
  5. package/dist/addressability.d.ts.map +1 -0
  6. package/dist/addressability.js +567 -0
  7. package/dist/addressability.js.map +1 -0
  8. package/dist/checker.d.ts +159 -0
  9. package/dist/checker.d.ts.map +1 -0
  10. package/dist/checker.js +177 -0
  11. package/dist/checker.js.map +1 -0
  12. package/dist/cli.d.ts +149 -0
  13. package/dist/cli.d.ts.map +1 -0
  14. package/dist/cli.js +1049 -0
  15. package/dist/cli.js.map +1 -0
  16. package/dist/copy-gate.d.ts +107 -0
  17. package/dist/copy-gate.d.ts.map +1 -0
  18. package/dist/copy-gate.js +132 -0
  19. package/dist/copy-gate.js.map +1 -0
  20. package/dist/fingerprint.d.ts +61 -0
  21. package/dist/fingerprint.d.ts.map +1 -0
  22. package/dist/fingerprint.js +64 -0
  23. package/dist/fingerprint.js.map +1 -0
  24. package/dist/index.d.ts +47 -0
  25. package/dist/index.d.ts.map +1 -0
  26. package/dist/index.js +63 -0
  27. package/dist/index.js.map +1 -0
  28. package/dist/locale-coverage.d.ts +227 -0
  29. package/dist/locale-coverage.d.ts.map +1 -0
  30. package/dist/locale-coverage.js +414 -0
  31. package/dist/locale-coverage.js.map +1 -0
  32. package/dist/passage.d.ts +294 -0
  33. package/dist/passage.d.ts.map +1 -0
  34. package/dist/passage.js +463 -0
  35. package/dist/passage.js.map +1 -0
  36. package/dist/path-exclusions.d.ts +114 -0
  37. package/dist/path-exclusions.d.ts.map +1 -0
  38. package/dist/path-exclusions.js +213 -0
  39. package/dist/path-exclusions.js.map +1 -0
  40. package/dist/registry.d.ts +61 -0
  41. package/dist/registry.d.ts.map +1 -0
  42. package/dist/registry.js +64 -0
  43. package/dist/registry.js.map +1 -0
  44. package/dist/resolve.d.ts +33 -0
  45. package/dist/resolve.d.ts.map +1 -0
  46. package/dist/resolve.js +133 -0
  47. package/dist/resolve.js.map +1 -0
  48. package/dist/scan.d.ts +457 -0
  49. package/dist/scan.d.ts.map +1 -0
  50. package/dist/scan.js +1698 -0
  51. package/dist/scan.js.map +1 -0
  52. package/dist/schema.d.ts +81 -0
  53. package/dist/schema.d.ts.map +1 -0
  54. package/dist/schema.js +482 -0
  55. package/dist/schema.js.map +1 -0
  56. package/dist/types.d.ts +268 -0
  57. package/dist/types.d.ts.map +1 -0
  58. package/dist/types.js +40 -0
  59. package/dist/types.js.map +1 -0
  60. package/dist/voice/checker.d.ts +186 -0
  61. package/dist/voice/checker.d.ts.map +1 -0
  62. package/dist/voice/checker.js +483 -0
  63. package/dist/voice/checker.js.map +1 -0
  64. package/dist/voice/derivation-coverage.d.ts +168 -0
  65. package/dist/voice/derivation-coverage.d.ts.map +1 -0
  66. package/dist/voice/derivation-coverage.js +181 -0
  67. package/dist/voice/derivation-coverage.js.map +1 -0
  68. package/dist/voice/fields.d.ts +107 -0
  69. package/dist/voice/fields.d.ts.map +1 -0
  70. package/dist/voice/fields.js +144 -0
  71. package/dist/voice/fields.js.map +1 -0
  72. package/dist/voice/index.d.ts +19 -0
  73. package/dist/voice/index.d.ts.map +1 -0
  74. package/dist/voice/index.js +24 -0
  75. package/dist/voice/index.js.map +1 -0
  76. package/dist/voice/internal/parse-template.d.ts +27 -0
  77. package/dist/voice/internal/parse-template.d.ts.map +1 -0
  78. package/dist/voice/internal/parse-template.js +111 -0
  79. package/dist/voice/internal/parse-template.js.map +1 -0
  80. package/dist/voice/internal/pattern-safety.d.ts +111 -0
  81. package/dist/voice/internal/pattern-safety.d.ts.map +1 -0
  82. package/dist/voice/internal/pattern-safety.js +278 -0
  83. package/dist/voice/internal/pattern-safety.js.map +1 -0
  84. package/dist/voice/schema.d.ts +65 -0
  85. package/dist/voice/schema.d.ts.map +1 -0
  86. package/dist/voice/schema.js +535 -0
  87. package/dist/voice/schema.js.map +1 -0
  88. package/dist/voice/types.d.ts +282 -0
  89. package/dist/voice/types.d.ts.map +1 -0
  90. package/dist/voice/types.js +37 -0
  91. package/dist/voice/types.js.map +1 -0
  92. package/package.json +72 -0
  93. package/src/addressability.ts +664 -0
  94. package/src/checker.ts +293 -0
  95. package/src/cli.ts +1166 -0
  96. package/src/copy-gate.ts +197 -0
  97. package/src/fingerprint.ts +66 -0
  98. package/src/index.ts +163 -0
  99. package/src/locale-coverage.ts +512 -0
  100. package/src/passage.ts +644 -0
  101. package/src/path-exclusions.ts +265 -0
  102. package/src/registry.ts +103 -0
  103. package/src/resolve.ts +172 -0
  104. package/src/scan.ts +1959 -0
  105. package/src/schema.ts +528 -0
  106. package/src/types.ts +301 -0
  107. package/src/voice/checker.ts +590 -0
  108. package/src/voice/derivation-coverage.ts +217 -0
  109. package/src/voice/fields.ts +160 -0
  110. package/src/voice/index.ts +55 -0
  111. package/src/voice/internal/parse-template.ts +114 -0
  112. package/src/voice/internal/pattern-safety.ts +336 -0
  113. package/src/voice/schema.ts +585 -0
  114. package/src/voice/types.ts +329 -0
  115. package/templates/voice-record.template.jsonc +135 -0
package/src/passage.ts ADDED
@@ -0,0 +1,644 @@
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
+
108
+ import { readFileSync } from "node:fs";
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // Types
112
+ // ---------------------------------------------------------------------------
113
+
114
+ /**
115
+ * A stable address for one `Passage`, unique within its `PassageRecord`.
116
+ * Same shape as `CopyEntryId` (`types.ts`) — dot-separated, lowercase,
117
+ * kebab-case within each segment, at least one dot — deliberately
118
+ * duplicated as its own regex below rather than importing `CopyEntryId`'s
119
+ * private pattern: `schema.ts` does not export `COPY_ENTRY_ID_RE`, and
120
+ * this package's own precedent (`addressability.ts`'s locally redefined
121
+ * `SKIP_FILE_RE`) is to redefine a small, unexported pattern locally
122
+ * rather than reach across a file boundary for it.
123
+ */
124
+ export type PassageId = string;
125
+
126
+ /**
127
+ * A reference to a registered `CopyEntry`, by id. This — and
128
+ * `PassageTermReference` below — are the ONLY two shapes a `Passage`
129
+ * field may validly hold; see this file's top doc comment, "THE GATE".
130
+ */
131
+ export interface PassageEntryReference {
132
+ ref: "entry";
133
+ id: string;
134
+ }
135
+
136
+ /** A reference to a glossary term (see `@clossys/writer/voice`'s `GlossaryEntry.term`), by its term string. */
137
+ export interface PassageTermReference {
138
+ ref: "term";
139
+ term: string;
140
+ }
141
+
142
+ /** The two valid reference shapes a `Passage` field may hold. */
143
+ export type PassageReference = PassageEntryReference | PassageTermReference;
144
+
145
+ /**
146
+ * One reusable, addressable unit of composed language. `fields` is
147
+ * intentionally `Record<string, unknown>`, not `Record<string,
148
+ * PassageReference>` — see this file's top doc comment for why a field's
149
+ * actual value is a runtime classification question, not a compile-time
150
+ * type guarantee, for data that arrives as real (possibly malformed) JSON
151
+ * on disk.
152
+ */
153
+ export interface Passage {
154
+ /** Stable address. Unique within the `PassageRecord` it belongs to — see `PassageId`. */
155
+ id: PassageId;
156
+ /**
157
+ * Where this passage appears — a screen name, a component, a flow.
158
+ * Required, mirroring `CopyEntry.context`'s own "unlocatable copy is
159
+ * not reviewable copy" rule, restated once more here because it is the
160
+ * identical rule at one layer up: an unlocatable passage is not
161
+ * reviewable either.
162
+ */
163
+ context: string;
164
+ /**
165
+ * Named slots this passage composes — e.g. `{ title: {...}, body:
166
+ * {...}, action: {...} }` for an empty-state. Each value should be a
167
+ * `PassageReference`; whether it actually is one is exactly what
168
+ * `checkPassageComposition` (via `classifyPassageField`) determines.
169
+ */
170
+ fields: Record<string, unknown>;
171
+ }
172
+
173
+ /** One consumer's complete, registered set of passages. The "brand.css" of this file — foundry ships the schema this conforms to, never a real instance of it. */
174
+ export interface PassageRecord {
175
+ id: string;
176
+ passages: Passage[];
177
+ }
178
+
179
+ /**
180
+ * One thing `validatePassageRecordShape` found wrong with a candidate
181
+ * `PassageRecord`/`Passage`. Deliberately the same shape as `CopyFinding`
182
+ * (`types.ts`) — `rule`/`severity`/`message`/optional `path` — so a
183
+ * caller already handling one kind of finding in this package does not
184
+ * need a second mental model for this one. Defined fresh here rather than
185
+ * imported: this file does not import from `types.ts` at all (see this
186
+ * file's top doc comment on why passages never reach for a `CopyRecord`).
187
+ */
188
+ export interface PassageFinding {
189
+ rule: string;
190
+ severity: "error";
191
+ message: string;
192
+ path?: string;
193
+ }
194
+
195
+ // ---------------------------------------------------------------------------
196
+ // Shape validation
197
+ // ---------------------------------------------------------------------------
198
+
199
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
200
+ return typeof value === "object" && value !== null && !Array.isArray(value);
201
+ }
202
+
203
+ function isNonEmptyString(value: unknown): value is string {
204
+ return typeof value === "string" && value.trim().length > 0;
205
+ }
206
+
207
+ /** 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. */
208
+ const PASSAGE_ID_RE = /^[a-z0-9]+(-[a-z0-9]+)*(\.[a-z0-9]+(-[a-z0-9]+)*)+$/;
209
+
210
+ /**
211
+ * Validates one `Passage`'s SHAPE only: `id` present/well-formed,
212
+ * `context` present, `fields` a non-empty object. Deliberately does NOT
213
+ * validate each field VALUE's shape — see this file's top doc comment,
214
+ * "THE GATE": rejecting a field that holds a raw literal string here, as
215
+ * a schema error, would turn every real violation `checkPassageComposition`
216
+ * exists to catch into an "indeterminate" (invalid-schema) read instead
217
+ * of a "violated" one, the exact collapse #407/#433 already fixed for a
218
+ * different gate in this same package.
219
+ */
220
+ function validatePassageShape(value: unknown, path: string): PassageFinding[] {
221
+ if (!isPlainObject(value)) {
222
+ return [{ rule: "passage-shape", severity: "error", message: `${path} must be an object.`, path }];
223
+ }
224
+ // Snapshot every field this function reads, exactly once, before any
225
+ // check runs — the same defensive discipline `schema.ts`'s
226
+ // `validateCopyEntryShape` documents (a hostile/badly-behaved `value`
227
+ // could otherwise return a different result on each property read).
228
+ const id = value.id;
229
+ const context = value.context;
230
+ const fields = value.fields;
231
+
232
+ const findings: PassageFinding[] = [];
233
+
234
+ if (!isNonEmptyString(id)) {
235
+ findings.push({
236
+ rule: "id-shape",
237
+ severity: "error",
238
+ message: `${path}.id must be a non-empty string, got ${JSON.stringify(id)}.`,
239
+ path: `${path}.id`,
240
+ });
241
+ } else if (!PASSAGE_ID_RE.test(id)) {
242
+ findings.push({
243
+ rule: "id-well-formed",
244
+ severity: "error",
245
+ message: `${path}.id "${id}" must be dot-separated and lowercase, with at least one dot (e.g. "onboarding.empty-state"), got ${JSON.stringify(id)}.`,
246
+ path: `${path}.id`,
247
+ });
248
+ }
249
+
250
+ if (!isNonEmptyString(context)) {
251
+ findings.push({
252
+ rule: "context-shape",
253
+ severity: "error",
254
+ message: `${path}.context must be a non-empty string, got ${JSON.stringify(context)}. A passage with no stated location cannot be reviewed.`,
255
+ path: `${path}.context`,
256
+ });
257
+ }
258
+
259
+ if (!isPlainObject(fields)) {
260
+ findings.push({
261
+ rule: "fields-shape",
262
+ severity: "error",
263
+ message: `${path}.fields must be an object, got ${JSON.stringify(fields)}.`,
264
+ path: `${path}.fields`,
265
+ });
266
+ } else if (Object.keys(fields).length === 0) {
267
+ findings.push({
268
+ rule: "fields-non-empty",
269
+ severity: "error",
270
+ message: `${path}.fields must have at least one field — a passage with zero fields composes nothing.`,
271
+ path: `${path}.fields`,
272
+ });
273
+ }
274
+
275
+ return findings;
276
+ }
277
+
278
+ /**
279
+ * Validates that `value` conforms to the `PassageRecord` shape. Returns a
280
+ * `PassageFinding[]`; empty means `value` is a well-formed `PassageRecord`.
281
+ * Never throws — mirrors `schema.ts`'s `validateCopyRecordShape` exactly:
282
+ * any input, including `null`/a string/a wildly malformed object,
283
+ * produces findings rather than an exception.
284
+ */
285
+ export function validatePassageRecordShape(value: unknown): PassageFinding[] {
286
+ if (!isPlainObject(value)) {
287
+ return [{ rule: "record-present", severity: "error", message: "A passage record must be an object.", path: "$" }];
288
+ }
289
+
290
+ const id = value.id;
291
+ const passages = value.passages;
292
+
293
+ const findings: PassageFinding[] = [];
294
+
295
+ if (!isNonEmptyString(id)) {
296
+ findings.push({
297
+ rule: "id-shape",
298
+ severity: "error",
299
+ message: `id must be a non-empty string, got ${JSON.stringify(id)}.`,
300
+ path: "id",
301
+ });
302
+ }
303
+
304
+ if (!Array.isArray(passages)) {
305
+ findings.push({
306
+ rule: "passages-shape",
307
+ severity: "error",
308
+ message: `passages must be an array, got ${JSON.stringify(passages)}.`,
309
+ path: "passages",
310
+ });
311
+ } else {
312
+ passages.forEach((passage, i) => findings.push(...validatePassageShape(passage, `passages.${i}`)));
313
+
314
+ // Uniqueness is a whole-record property, checked here rather than
315
+ // inside validatePassageShape — mirrors validateCopyRecordShape's own
316
+ // id-unique pass exactly, including only comparing passages whose own
317
+ // id already passed shape validation.
318
+ const seen = new Map<string, number>();
319
+ passages.forEach((passage, i) => {
320
+ if (!isPlainObject(passage)) return;
321
+ const passageId = passage.id;
322
+ if (typeof passageId !== "string" || passageId.length === 0) return;
323
+ const firstIndex = seen.get(passageId);
324
+ if (firstIndex === undefined) {
325
+ seen.set(passageId, i);
326
+ } else {
327
+ findings.push({
328
+ rule: "id-unique",
329
+ severity: "error",
330
+ message: `passages.${i}.id "${passageId}" duplicates passages.${firstIndex}.id — every passage's id must be unique within a PassageRecord.`,
331
+ path: `passages.${i}.id`,
332
+ });
333
+ }
334
+ });
335
+ }
336
+
337
+ return findings;
338
+ }
339
+
340
+ function buildPassageRecord(value: Record<string, unknown>): PassageRecord {
341
+ const passagesRaw = value.passages as unknown[];
342
+ const built: Passage[] = passagesRaw.map((raw) => {
343
+ const passage = raw as Record<string, unknown>;
344
+ return {
345
+ id: passage.id as string,
346
+ context: passage.context as string,
347
+ fields: { ...(passage.fields as Record<string, unknown>) },
348
+ };
349
+ });
350
+
351
+ return {
352
+ id: value.id as string,
353
+ passages: built,
354
+ };
355
+ }
356
+
357
+ /**
358
+ * Parses `value` as a `PassageRecord`, throwing a plain `Error` (never a
359
+ * `PassageFinding[]`) if it does not conform — mirrors `schema.ts`'s
360
+ * `parseCopyRecord` exactly, including the two-pass validate-then-build
361
+ * shape and why (see that function's own doc comment for the fuller
362
+ * argument).
363
+ */
364
+ export function parsePassageRecord(value: unknown): PassageRecord {
365
+ const findings = validatePassageRecordShape(value);
366
+ if (findings.length > 0) {
367
+ const detail = findings.map((f) => ` - ${f.path ?? "(root)"}: ${f.message}`).join("\n");
368
+ throw new Error(`parsePassageRecord: value is not a valid PassageRecord:\n${detail}`);
369
+ }
370
+ // Safe: validatePassageRecordShape returning no findings means every
371
+ // field buildPassageRecord reads below is present and correctly typed.
372
+ return buildPassageRecord(value as Record<string, unknown>);
373
+ }
374
+
375
+ // ---------------------------------------------------------------------------
376
+ // Registry I/O — the one place in this file that touches a filesystem
377
+ // ---------------------------------------------------------------------------
378
+
379
+ /**
380
+ * Why `path` did not become a usable `PassageRecord`. Mirrors
381
+ * `registry.ts`'s `CopyRegistryReadIssueReason` exactly — see that type's
382
+ * own doc comment for what each reason means.
383
+ */
384
+ export type PassageRegistryReadIssueReason = "unreadable" | "unparseable" | "invalid-schema";
385
+
386
+ export interface PassageRegistryReadIssue {
387
+ reason: PassageRegistryReadIssueReason;
388
+ detail: string;
389
+ }
390
+
391
+ export interface PassageRegistryReadResult {
392
+ path: string;
393
+ record?: PassageRecord;
394
+ issues: PassageRegistryReadIssue[];
395
+ /** `true` exactly when `issues` is empty and `record` is populated — see `registry.ts`'s `CopyRegistryReadResult.complete` for why this is the shared one-boolean-read contract across this package. */
396
+ complete: boolean;
397
+ }
398
+
399
+ /**
400
+ * Reads and validates the `PassageRecord` at `path`. Never throws: every
401
+ * failure — an unreadable file, invalid JSON, or a schema violation — is
402
+ * recorded into `issues` and reflected in `.complete`. Mirrors
403
+ * `registry.ts`'s `readCopyRecord` exactly, including the discipline it
404
+ * documents: an unreadable/invalid registry is "could not run" (fed into
405
+ * exit `2` by the CLI), never a clean pass produced from a record this
406
+ * function could not actually trust.
407
+ */
408
+ export function readPassageRecord(path: string): PassageRegistryReadResult {
409
+ let raw: string;
410
+ try {
411
+ raw = readFileSync(path, "utf8");
412
+ } catch (error) {
413
+ return {
414
+ path,
415
+ issues: [{ reason: "unreadable", detail: error instanceof Error ? error.message : String(error) }],
416
+ complete: false,
417
+ };
418
+ }
419
+
420
+ let parsed: unknown;
421
+ try {
422
+ parsed = JSON.parse(raw);
423
+ } catch (error) {
424
+ return {
425
+ path,
426
+ issues: [{ reason: "unparseable", detail: error instanceof Error ? error.message : String(error) }],
427
+ complete: false,
428
+ };
429
+ }
430
+
431
+ try {
432
+ const record = parsePassageRecord(parsed);
433
+ return { path, record, issues: [], complete: true };
434
+ } catch (error) {
435
+ return {
436
+ path,
437
+ issues: [{ reason: "invalid-schema", detail: error instanceof Error ? error.message : String(error) }],
438
+ complete: false,
439
+ };
440
+ }
441
+ }
442
+
443
+ // ---------------------------------------------------------------------------
444
+ // Field classification — the heart of the gate
445
+ // ---------------------------------------------------------------------------
446
+
447
+ export type PassageFieldClassification =
448
+ | "entry-reference"
449
+ | "term-reference"
450
+ | "inline-literal"
451
+ | "passage-internals-reference"
452
+ | "unclassifiable";
453
+
454
+ interface PassageFieldClassificationResult {
455
+ classification: PassageFieldClassification;
456
+ detail: string;
457
+ }
458
+
459
+ /** 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. */
460
+ function safePreview(value: unknown): string {
461
+ let text: string;
462
+ try {
463
+ text = JSON.stringify(value) ?? String(value);
464
+ } catch {
465
+ text = String(value);
466
+ }
467
+ return text.length > 120 ? `${text.slice(0, 120)}…` : text;
468
+ }
469
+
470
+ /**
471
+ * Classifies one `Passage` field's raw value into exactly one of the five
472
+ * `PassageFieldClassification` outcomes. Pure — no I/O, never throws on
473
+ * any input shape.
474
+ *
475
+ * - A plain string is ALWAYS `"inline-literal"` — real prose (or
476
+ * anything else) sitting directly in the field instead of a
477
+ * reference. This is the shape the adversarial proof's separating
478
+ * fixture is built from — see this file's top doc comment.
479
+ * - `{ ref: "entry", id: <non-empty string> }` is `"entry-reference"`.
480
+ * - `{ ref: "term", term: <non-empty string> }` is `"term-reference"`.
481
+ * - `{ ref: "passage", ... }` — REGARDLESS of what else it carries — is
482
+ * always `"passage-internals-reference"`: a passage may compose
483
+ * entries and terms, never reach directly into another passage's own
484
+ * fields. This is a VIOLATION, not something this function treats as
485
+ * merely unclassifiable, because the shape is unambiguous the moment
486
+ * `ref === "passage"` is seen.
487
+ * - `{ ref: "entry" | "term", ... }` with a missing/malformed
488
+ * `id`/`term` is `"unclassifiable"` — the AUTHOR'S intent (compose an
489
+ * entry or a term) is legible, but the reference itself is broken, so
490
+ * this function refuses to guess which finding shape to promote it
491
+ * to rather than silently treating a broken reference as either
492
+ * "fine" or "definitely a literal."
493
+ * - Anything else (a number, boolean, null, array, or an object with no
494
+ * recognized `ref`) is `"unclassifiable"`.
495
+ */
496
+ export function classifyPassageField(value: unknown): PassageFieldClassificationResult {
497
+ if (typeof value === "string") {
498
+ return {
499
+ classification: "inline-literal",
500
+ 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`,
501
+ };
502
+ }
503
+
504
+ if (isPlainObject(value)) {
505
+ const ref = value.ref;
506
+
507
+ if (ref === "entry") {
508
+ const id = value.id;
509
+ if (isNonEmptyString(id)) {
510
+ return { classification: "entry-reference", detail: `references entry "${id}"` };
511
+ }
512
+ return {
513
+ classification: "unclassifiable",
514
+ detail: `{ ref: "entry" } is missing a valid non-empty "id" (got ${safePreview(id)})`,
515
+ };
516
+ }
517
+
518
+ if (ref === "term") {
519
+ const term = value.term;
520
+ if (isNonEmptyString(term)) {
521
+ return { classification: "term-reference", detail: `references term "${term}"` };
522
+ }
523
+ return {
524
+ classification: "unclassifiable",
525
+ detail: `{ ref: "term" } is missing a valid non-empty "term" (got ${safePreview(term)})`,
526
+ };
527
+ }
528
+
529
+ if (ref === "passage") {
530
+ const id = typeof value.id === "string" && value.id.length > 0 ? value.id : "?";
531
+ const field = typeof value.field === "string" && value.field.length > 0 ? value.field : "?";
532
+ return {
533
+ classification: "passage-internals-reference",
534
+ 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`,
535
+ };
536
+ }
537
+ }
538
+
539
+ return {
540
+ classification: "unclassifiable",
541
+ 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`,
542
+ };
543
+ }
544
+
545
+ // ---------------------------------------------------------------------------
546
+ // The gate
547
+ // ---------------------------------------------------------------------------
548
+
549
+ export type PassageViolationRule = "field-inlines-literal" | "field-references-passage-internals";
550
+
551
+ export interface PassageViolation {
552
+ passageId: PassageId;
553
+ field: string;
554
+ rule: PassageViolationRule;
555
+ message: string;
556
+ }
557
+
558
+ export interface PassageUnclassifiedItem {
559
+ passageId: PassageId;
560
+ field: string;
561
+ detail: string;
562
+ }
563
+
564
+ export type PassageVerdict = "satisfied" | "violated" | "indeterminate";
565
+
566
+ export interface PassageGateResult {
567
+ verdict: PassageVerdict;
568
+ violations: PassageViolation[];
569
+ /** Every field this gate could not confidently classify — never silently dropped. See `classifyPassageField`'s own doc comment. */
570
+ unclassified: PassageUnclassifiedItem[];
571
+ /** How many passages had at least one field examined. Required to be > 0 for a satisfied verdict — see this file's top doc comment, "THE GATE". */
572
+ passagesEvaluated: number;
573
+ /** Human-readable reasons coverage is incomplete — empty for `"satisfied"`. Non-empty for `"violated"` too is possible (a real violation AND unclassified fields in the same run) — only `"indeterminate"` requires this to be non-empty. */
574
+ reasons: string[];
575
+ }
576
+
577
+ /**
578
+ * Pure — takes an already-parsed `PassageRecord` (see `readPassageRecord`/
579
+ * `parsePassageRecord`), classifies every field of every passage, and
580
+ * decides the ternary verdict. Never throws, does no I/O.
581
+ *
582
+ * PRECEDENCE: a real violation wins over an incomplete picture — see this
583
+ * file's top doc comment, "THE TERNARY", for why this mirrors
584
+ * `checkAddressability`'s own post-#407 ordering rather than the older
585
+ * "indeterminate wins" precedence `checkCopyTraceability`/`scan.ts` still
586
+ * use for a different reason (see `checkAddressability`'s own doc comment
587
+ * for the fuller argument against letting a permanent-in-practice
588
+ * coverage gap make a real violation unreachable).
589
+ */
590
+ export function checkPassageComposition(record: PassageRecord): PassageGateResult {
591
+ const violations: PassageViolation[] = [];
592
+ const unclassified: PassageUnclassifiedItem[] = [];
593
+ let passagesEvaluated = 0;
594
+
595
+ for (const passage of record.passages) {
596
+ const fieldNames = Object.keys(passage.fields);
597
+ if (fieldNames.length === 0) continue; // defensive only — validatePassageRecordShape already requires >= 1 field
598
+ passagesEvaluated++;
599
+
600
+ for (const field of fieldNames) {
601
+ const value = passage.fields[field];
602
+ const { classification, detail } = classifyPassageField(value);
603
+
604
+ if (classification === "inline-literal") {
605
+ violations.push({
606
+ passageId: passage.id,
607
+ field,
608
+ rule: "field-inlines-literal",
609
+ message: `${passage.id}.${field}: ${detail}`,
610
+ });
611
+ } else if (classification === "passage-internals-reference") {
612
+ violations.push({
613
+ passageId: passage.id,
614
+ field,
615
+ rule: "field-references-passage-internals",
616
+ message: `${passage.id}.${field}: ${detail}`,
617
+ });
618
+ } else if (classification === "unclassifiable") {
619
+ unclassified.push({ passageId: passage.id, field, detail: `${passage.id}.${field}: ${detail}` });
620
+ }
621
+ // "entry-reference" / "term-reference": exactly the property this gate requires — no finding.
622
+ }
623
+ }
624
+
625
+ const reasons: string[] = [];
626
+ if (passagesEvaluated === 0) {
627
+ reasons.push(
628
+ record.passages.length === 0 ? "no passages are registered" : "no passage had at least one field to evaluate",
629
+ );
630
+ }
631
+ if (unclassified.length > 0) {
632
+ reasons.push(`${unclassified.length} field(s) could not be confidently classified`);
633
+ }
634
+
635
+ const base = { violations, unclassified, passagesEvaluated };
636
+
637
+ // A real violation wins over an incomplete picture — see this file's
638
+ // top doc comment, "THE TERNARY". The coverage gap is not hidden:
639
+ // `reasons`/`unclassified` are still populated and still printed by
640
+ // `cli.ts`'s `printPassagesReport` regardless of verdict.
641
+ if (violations.length > 0) return { verdict: "violated", reasons, ...base };
642
+ if (reasons.length > 0) return { verdict: "indeterminate", reasons, ...base };
643
+ return { verdict: "satisfied", reasons, ...base };
644
+ }