@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/dist/cli.js ADDED
@@ -0,0 +1,1049 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `writer-check` — the CLI for `checkCopyTraceability` — AND, in this same
4
+ * file and under this SAME `writer-check` bin, a subcommand
5
+ * (`writer-check addressability`, dispatched on `argv[0] === "addressability"`
6
+ * before any of the existing argument parsing below runs — see the bottom
7
+ * of this file), `checkAddressability`: is user-facing prose resolved from
8
+ * the copy registry by id, rather than typed inline? A stricter, DIFFERENT
9
+ * question from traceability — see `addressability.ts`'s top doc comment
10
+ * for the full split. Deliberately NOT merged into the default command's
11
+ * own exit code: traceability's own fixtures/tests are built entirely
12
+ * around bare literal assignments (a registered string, an unregistered
13
+ * one, a `copy-gate:ignore` marker, a `copy:<id>` citation) — exactly the
14
+ * shapes addressability treats as either a violation (an attribute/
15
+ * text-node literal) or an unclassifiable position (a bare assignment,
16
+ * position 3 — see `addressability.ts`), by design and on purpose.
17
+ * Combining the two into one number would make the default command's own
18
+ * "clean pass" fixtures structurally unwritable (a literal a
19
+ * registered-text match needs to exist for traceability to prove anything
20
+ * is exactly a literal addressability cannot confirm is safe) rather than
21
+ * genuinely complementary. A SEPARATE `bin` entry pointing at this same
22
+ * compiled file was rejected for the identical reason `@example/ui`
23
+ * never does that: this repository's own root `package.json` invokes every
24
+ * gate by its COMPILED PATH (`node packages/ui/dist/tokens/contrast-cli.js`,
25
+ * `node packages/controller/dist/cli.js`, ...), never by installed `bin`
26
+ * name — a second `bin` entry pointing at the same file is invisible to
27
+ * that invocation style, and worse, silently falls through to the OTHER
28
+ * command rather than erroring. An explicit `argv[0]` subcommand is
29
+ * reachable however the file is invoked, by path or by name. Both
30
+ * commands are still presentation-only wrappers over this package's pure
31
+ * gates, both still print every skip/exclusion count unconditionally, and
32
+ * both still use the identical three-state exit-code contract described
33
+ * below — same shape, same file, same bin, two subcommands, two numbers,
34
+ * matching `@example/strategy`'s `strategy-facts-check` shape
35
+ * closely, deliberately: same argument order (the thing being checked
36
+ * FOR, then the thing being checked, where applicable), same `--help`,
37
+ * same `CliInputError` split between "bad arguments" and "ran, found
38
+ * something wrong".
39
+ *
40
+ * A second subcommand, `voice-derivation-coverage`, wires
41
+ * `checkVoiceDerivationCoverage` (`./voice/derivation-coverage.ts`) in with
42
+ * the identical shape: parse argv, load two JSON files, run the pure check,
43
+ * print a report, pick an exit code from the same 0/1/2 contract — see
44
+ * `runVoiceDerivationCoverage`'s own doc comment below. `main()` dispatches
45
+ * to it only when `argv[0]` is exactly `"voice-derivation-coverage"`; any
46
+ * other first argument (including every existing caller's real
47
+ * `record-file` path) falls through to the original, unchanged behavior
48
+ * above — this addition is purely additive to the argv contract.
49
+ *
50
+ * A third subcommand, `locale-coverage`, wires `checkLocaleCoverage`
51
+ * (`./locale-coverage.ts`) in with the identical shape again: parse argv,
52
+ * load a JSON registries file, run the pure check, print a report, pick an
53
+ * exit code — see `runLocaleCoverage`'s own doc comment below.
54
+ * `checkLocaleCoverage` already defines its own caller contract for turning
55
+ * a `LocaleCoverageReport` into this package's usual three-state exit code
56
+ * (see `LocaleCoverageReport.complete`'s own doc comment in
57
+ * `locale-coverage.ts`); `runLocaleCoverage` applies that mapping exactly,
58
+ * rather than inventing a second one. `main()` dispatches to it only when
59
+ * `argv[0]` is exactly `"locale-coverage"` — checked alongside
60
+ * `"voice-derivation-coverage"`, before either falls through to the
61
+ * original `record-file` handling — so this, too, is purely additive to the
62
+ * argv contract.
63
+ *
64
+ * A FOURTH subcommand, `passages` (`writer-check passages <registry-file>`),
65
+ * wires `checkPassageComposition` (`./passage.ts`) in — the passage-layer
66
+ * COMPOSITION gate: does every passage in a `PassageRecord` reference only
67
+ * registered entries and glossary terms, never an inlined literal string
68
+ * or another passage's own internals? Dispatched EXACTLY the way
69
+ * `addressability` is (see below): a fully separate top-level branch at
70
+ * the very bottom of this file, its own exported `mainPassagesCheck`, its
71
+ * own `runPassagesCheck` wrapper — never folded through `main()` at all,
72
+ * for the identical reason addressability is not: this gate's own ternary
73
+ * precedence ("a real violation wins over an incomplete picture" — see
74
+ * `passage.ts`'s own top doc comment, "THE TERNARY") is independent of
75
+ * every other subcommand's exit-code contract, and keeping it a fully
76
+ * separate dispatch path keeps that independence visible in this file's
77
+ * own structure, not just in a comment.
78
+ *
79
+ * Exit codes — a contract a consumer's CI depends on, matching this
80
+ * repository's `foundry-check` convention (`@example/gates`):
81
+ *
82
+ * 0 — ran cleanly: the copy record loaded, at least one file was
83
+ * actually scanned (successfully tokenized — see `scan.ts`'s
84
+ * `ScanResult.filesScanned`), zero findings.
85
+ * 1 — ran cleanly, at least one finding (an unregistered copy candidate,
86
+ * or a citation to a copy id that does not exist).
87
+ * 2 — could not run: bad input, the copy record missing/unreadable/
88
+ * invalid, the scan directory does not exist, the walk matched zero
89
+ * files, ANY matched file failed to tokenize (see `scan.parseFailures`
90
+ * — a file that could not be parsed was never examined for copy, and
91
+ * one such file among a hundred clean ones is still incomplete
92
+ * coverage, not a clean scan), an unreadable directory
93
+ * during the walk, an unexpected exception, a malformed
94
+ * `ScanOptions.pathExclusions` entry (see below), OR — as of the JSX
95
+ * text-node scanning added for issue #37 — at least one
96
+ * `ScanResult.unchecked` entry: a JSX construct the scanner
97
+ * recognized but could not reliably classify. That last case lands
98
+ * here, not as a `1`-severity finding, deliberately: `unchecked`
99
+ * means a REGION of an otherwise-matched, otherwise-parseable file
100
+ * was never actually examined for copy — the exact same shape of
101
+ * problem `parseFailures`/`filesScanned === 0` already are, just at
102
+ * finer grain (part of one file, rather than a whole file), so it
103
+ * gets the same answer they do. Every real finding is still printed
104
+ * first (see `printGateReport`) — `unchecked` does not suppress
105
+ * what WAS learned, it just refuses to let that partial picture
106
+ * read as "clean" the way a bare exit `0` would. Kept strictly
107
+ * distinct from 1 — a gate that reports "clean" after failing to
108
+ * run, OR after only partially running, is worse than no gate at
109
+ * all. This is the explicit third state this gate is built around:
110
+ * "could not check" must never be reported as a pass.
111
+ *
112
+ * A malformed `pathExclusions` entry (see `scan.ts`/`path-exclusions.ts`)
113
+ * gets the same `2` treatment as `unchecked`, for the identical reason: an
114
+ * exclusion list this run cannot trust means this run cannot say which
115
+ * files were correctly in or out of scope, which is "could not run", not "a
116
+ * clean scan that happened to find nothing". This CLI does not currently
117
+ * accept `pathExclusions` from argv — see `main()`'s own comment — so this
118
+ * only fires for a caller driving `scanCopySourceTree` directly with a
119
+ * malformed list and inspecting `ScanResult` themselves; it is handled here
120
+ * anyway so the accounting/exit-code contract stays correct for a future
121
+ * CLI flag without a second pass over this logic.
122
+ */
123
+ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
124
+ import { resolve } from "node:path";
125
+ import { fileURLToPath } from "node:url";
126
+ import { checkAddressability, scanAddressabilitySources, } from "./addressability.js";
127
+ import { checkCopyTraceability } from "./copy-gate.js";
128
+ import { checkLocaleCoverage } from "./locale-coverage.js";
129
+ import { checkPassageComposition, readPassageRecord } from "./passage.js";
130
+ import { readCopyRecord } from "./registry.js";
131
+ import { scanCopySourceTree } from "./scan.js";
132
+ import { checkVoiceDerivationCoverage } from "./voice/index.js";
133
+ const USAGE = `Usage: writer-check <record-file> [scan-dir] [options]
134
+ or: writer-check voice-derivation-coverage <obligations-file> <brand-derived-rule-ids-file> [options]
135
+ or: writer-check locale-coverage <registries-file> <source-locale> [declared-locale...] [options]
136
+
137
+ record-file Path to a CopyRecord JSON file (see @clossys/writer's README). Required.
138
+ scan-dir Directory to scan for user-facing string/template literals. Defaults to the current working directory.
139
+
140
+ See also "writer-check addressability [scan-dir]" and "writer-check passages
141
+ <registry-file>" (this same file's other subcommands, both dispatched on
142
+ argv[0] before anything below) — addressability asks whether user-facing
143
+ prose is resolved from the copy registry by id, rather than typed inline;
144
+ passages asks whether a passage composes registered entries/terms rather
145
+ than inlining a literal or reaching into another passage's internals. Both
146
+ are separate, stricter gates; neither runs as part of this default command.
147
+
148
+ Options:
149
+ --help Print this message and exit 0.
150
+
151
+ Exit codes: 0 = clean, 1 = at least one finding, 2 = could not run (bad input, missing/invalid record, nothing matched to scan, or every matched file failed to parse).
152
+
153
+ Run "writer-check voice-derivation-coverage --help" for the second subcommand's own usage.
154
+ Run "writer-check locale-coverage --help" for the third subcommand's own usage.
155
+ Run "writer-check addressability --help" for that subcommand's own usage.
156
+ Run "writer-check passages --help" for the passage-composition subcommand's own usage.
157
+ `;
158
+ const VOICE_DERIVATION_COVERAGE_USAGE = `Usage: writer-check voice-derivation-coverage <obligations-file> <brand-derived-rule-ids-file> [options]
159
+
160
+ obligations-file Path to a JSON file containing an array of voice rule id strings (the thing being checked FOR). Required.
161
+ brand-derived-rule-ids-file Path to a JSON file containing an array of voice rule id strings a brand attribute actually derives — e.g. every BrandDerivation.voiceRules entry a consumer's own strategy declares (the thing being checked). Required.
162
+
163
+ Options:
164
+ --help Print this message and exit 0.
165
+
166
+ Checks, in both directions, whether obligations-file fully accounts for the rule ids brand-derived-rule-ids-file lists — see checkVoiceDerivationCoverage's own doc comment (src/voice/derivation-coverage.ts) for why this package cannot derive that list itself and must take it from the caller.
167
+
168
+ Exit codes: 0 = satisfied, 1 = violated (a real coverage gap in either direction), 2 = indeterminate (could not run: bad input, missing/unreadable/unparseable file, zero obligations supplied, or zero brand-derived rule ids supplied).
169
+ `;
170
+ const LOCALE_COVERAGE_USAGE = `Usage: writer-check locale-coverage <registries-file> <source-locale> [declared-locale...] [options]
171
+
172
+ registries-file Path to a JSON file: a plain object mapping each locale to its CopyRegistry (unknown at parse time, validated per-locale by checkLocaleCoverage itself) — e.g. {"en": {...}, "fr": {...}}. Required.
173
+ source-locale The locale every other declared locale's coverage is measured against. Required.
174
+ declared-locale One or more locales (source-locale included) that make up the full set this run must cover. When omitted, defaults to every key registries-file itself declares, in file order. Pass this explicitly to additionally assert that some OTHER locale — declared, but with no registry present in registries-file at all — is missing; checkLocaleCoverage reports that as its own finding rather than silently ignoring it.
175
+
176
+ Options:
177
+ --help Print this message and exit 0.
178
+
179
+ Checks that every declared locale other than source-locale covers the same entry ids the source locale does, and reports missing coverage, orphaned entries, stale translations, and interpolation-parity gaps — see checkLocaleCoverage's own doc comment (src/locale-coverage.ts) for the full design.
180
+
181
+ Exit codes — the exact mapping LocaleCoverageReport.complete's own doc comment states as its caller contract: 0 = every declared locale was evaluated and no error-severity finding was produced, 1 = every declared locale was evaluated but at least one error-severity finding was produced, 2 = could not run (bad input, missing/unreadable/unparseable registries-file, registries-file is not a JSON object, zero declared locales, or at least one declared locale was NOT actually evaluated — an entirely-absent, invalid, or mis-keyed target registry, or the source locale itself missing/invalid/empty).
182
+ `;
183
+ /** Exported for `cli.test.ts` — anything wrong with the arguments themselves always maps to exit code 2, never 1. */
184
+ export class CliInputError extends Error {
185
+ }
186
+ function parseArgs(argv) {
187
+ let recordFile;
188
+ let scanDir;
189
+ let help = false;
190
+ for (const arg of argv) {
191
+ if (arg === "--help" || arg === "-h") {
192
+ help = true;
193
+ continue;
194
+ }
195
+ if (arg.startsWith("-")) {
196
+ throw new CliInputError(`unknown flag "${arg}"`);
197
+ }
198
+ if (recordFile === undefined) {
199
+ recordFile = arg;
200
+ }
201
+ else if (scanDir === undefined) {
202
+ scanDir = arg;
203
+ }
204
+ else {
205
+ throw new CliInputError(`unexpected extra argument "${arg}"`);
206
+ }
207
+ }
208
+ return { recordFile, scanDir, help };
209
+ }
210
+ function requireDirectory(label, path) {
211
+ if (!existsSync(path))
212
+ throw new CliInputError(`${label} "${path}" does not exist`);
213
+ let stat;
214
+ try {
215
+ stat = statSync(path);
216
+ }
217
+ catch (error) {
218
+ throw new CliInputError(`cannot read ${label} "${path}": ${error instanceof Error ? error.message : String(error)}`);
219
+ }
220
+ if (!stat.isDirectory())
221
+ throw new CliInputError(`${label} "${path}" is not a directory`);
222
+ }
223
+ function requireFile(label, path) {
224
+ if (!existsSync(path))
225
+ throw new CliInputError(`${label} "${path}" does not exist`);
226
+ let stat;
227
+ try {
228
+ stat = statSync(path);
229
+ }
230
+ catch (error) {
231
+ throw new CliInputError(`cannot read ${label} "${path}": ${error instanceof Error ? error.message : String(error)}`);
232
+ }
233
+ if (!stat.isFile())
234
+ throw new CliInputError(`${label} "${path}" is not a file`);
235
+ }
236
+ /**
237
+ * Prints every scan-side accounting number — matched, excluded (by
238
+ * reason), skipped-by-design, and parse failures — unconditionally, not
239
+ * only when something looks wrong. This is the direct fix for the failure
240
+ * mode this package's own brief warns against: "if your scanner skips
241
+ * files, skips a syntax it cannot parse, or excludes a category of
242
+ * literal, the run output must say how many and why. A gate that quietly
243
+ * narrows its own coverage and reports the narrowed result as a pass is
244
+ * the exact failure mode above."
245
+ */
246
+ function printScanAccounting(scan) {
247
+ console.log(`Scanned ${scan.filesScanned} file${scan.filesScanned === 1 ? "" : "s"}, ` +
248
+ `${scan.candidates.length} candidate${scan.candidates.length === 1 ? "" : "s"} extracted.`);
249
+ if (scan.excluded.length > 0) {
250
+ const byReason = new Map();
251
+ for (const e of scan.excluded)
252
+ byReason.set(e.reason, (byReason.get(e.reason) ?? 0) + 1);
253
+ console.log(`${scan.excluded.length} literal(s) deliberately excluded (never registered as copy):`);
254
+ for (const [reason, count] of [...byReason.entries()].sort((a, b) => b[1] - a[1])) {
255
+ console.log(` ${count} ${reason}`);
256
+ }
257
+ }
258
+ if (scan.skippedByDesign.length > 0) {
259
+ console.log(`${scan.skippedByDesign.length} file(s) skipped by design (test/check/declaration files):`);
260
+ for (const s of scan.skippedByDesign)
261
+ console.log(` ${s.file}`);
262
+ }
263
+ if (scan.excludedFiles.length > 0) {
264
+ console.log(`${scan.excludedFiles.length} file(s) excluded via pathExclusions (never scanned):`);
265
+ for (const e of scan.excludedFiles)
266
+ console.log(` ${e.file} (pattern "${e.pattern}": ${e.reason})`);
267
+ }
268
+ if (scan.pathExclusionFindings.length > 0) {
269
+ for (const f of scan.pathExclusionFindings) {
270
+ const line = ` [${f.rule}] ${f.path ? `"${f.path}": ` : ""}${f.message}`;
271
+ if (f.severity === "error")
272
+ console.error(line);
273
+ else
274
+ console.log(line);
275
+ }
276
+ }
277
+ if (scan.parseFailures.length > 0) {
278
+ console.error(`${scan.parseFailures.length} file(s) could NOT be parsed and were NOT scanned for copy:`);
279
+ for (const p of scan.parseFailures)
280
+ console.error(` ${p.file}: ${p.detail}`);
281
+ }
282
+ // JSX constructs the scanner recognized but could not classify — the
283
+ // same "could not check" severity as a parse failure (console.error,
284
+ // not console.log), because it means the same thing: some part of a
285
+ // matched file was never actually examined for copy. `main()` below is
286
+ // what turns a non-empty list here into exit code 2 — see its own doc
287
+ // comment for why.
288
+ if (scan.unchecked.length > 0) {
289
+ console.error(`${scan.unchecked.length} JSX construct(s) recognized but NOT reliably classified — coverage for these is incomplete:`);
290
+ for (const u of scan.unchecked)
291
+ console.error(` [${u.kind}] ${u.file}:${u.line} ${u.detail}`);
292
+ }
293
+ }
294
+ function printGateReport(result) {
295
+ console.log(`${result.candidatesScanned} candidate${result.candidatesScanned === 1 ? "" : "s"} evaluated, ` +
296
+ `${result.matched} matched a registered entry.`);
297
+ if (result.ignored.length > 0) {
298
+ console.log(`${result.ignored.length} explicitly ignored via "copy-gate:ignore":`);
299
+ for (const ig of result.ignored)
300
+ console.log(` ${ig.file}:${ig.line} ${ig.snippet}`);
301
+ }
302
+ if (result.findings.length === 0) {
303
+ console.log("No findings.");
304
+ return;
305
+ }
306
+ console.log(`\n${result.findings.length} finding(s):`);
307
+ for (const f of result.findings) {
308
+ console.log(` [${f.rule}] ${f.file}:${f.line} ${f.message}`);
309
+ }
310
+ }
311
+ function parseVoiceDerivationCoverageArgs(argv) {
312
+ let obligationsFile;
313
+ let brandDerivedRuleIdsFile;
314
+ let help = false;
315
+ for (const arg of argv) {
316
+ if (arg === "--help" || arg === "-h") {
317
+ help = true;
318
+ continue;
319
+ }
320
+ if (arg.startsWith("-")) {
321
+ throw new CliInputError(`unknown flag "${arg}"`);
322
+ }
323
+ if (obligationsFile === undefined) {
324
+ obligationsFile = arg;
325
+ }
326
+ else if (brandDerivedRuleIdsFile === undefined) {
327
+ brandDerivedRuleIdsFile = arg;
328
+ }
329
+ else {
330
+ throw new CliInputError(`unexpected extra argument "${arg}"`);
331
+ }
332
+ }
333
+ return { obligationsFile, brandDerivedRuleIdsFile, help };
334
+ }
335
+ /**
336
+ * Reads and JSON-parses a file, never throwing: an I/O failure or a JSON
337
+ * syntax error is returned as `{ ok: false, detail }`, the same
338
+ * never-throw discipline `readCopyRecord` (`registry.ts`) holds to for the
339
+ * copy record it loads. Kept local to this file rather than promoted to a
340
+ * shared helper: `./voice` is deliberately zero-I/O (see `checker.ts`'s own
341
+ * doc comment, "Pure, no I/O"), so the one place in this package that reads
342
+ * either of `checkVoiceDerivationCoverage`'s two lists off disk is this
343
+ * CLI, not the `voice` module itself.
344
+ */
345
+ function readJsonFile(label, path) {
346
+ let raw;
347
+ try {
348
+ raw = readFileSync(path, "utf8");
349
+ }
350
+ catch (error) {
351
+ return { ok: false, detail: `cannot read ${label} "${path}": ${error instanceof Error ? error.message : String(error)}` };
352
+ }
353
+ try {
354
+ return { ok: true, value: JSON.parse(raw) };
355
+ }
356
+ catch (error) {
357
+ return { ok: false, detail: `${label} "${path}" is not valid JSON: ${error instanceof Error ? error.message : String(error)}` };
358
+ }
359
+ }
360
+ /** Both `obligations-file` and `brand-derived-rule-ids-file` must be a JSON array of non-empty strings — the plain rule-id list shape `checkVoiceDerivationCoverage` takes on both sides. Any other shape is "could not run", never a silently-empty list. `label` names which of the two files this call is validating, for the error message only. */
361
+ function readStringList(label, path) {
362
+ const parsed = readJsonFile(label, path);
363
+ if (!parsed.ok)
364
+ return parsed;
365
+ const { value } = parsed;
366
+ if (!Array.isArray(value) || !value.every((item) => typeof item === "string" && item.length > 0)) {
367
+ return { ok: false, detail: `${label} "${path}" must be a JSON array of non-empty strings, got ${JSON.stringify(value)}.` };
368
+ }
369
+ return { ok: true, value };
370
+ }
371
+ function printVoiceDerivationCoverageReport(result) {
372
+ console.log(`${result.obligationsChecked} obligation(s) checked against ${result.rulesChecked} brand-derived rule id(s).`);
373
+ if (result.obligationsMissingFromRecord.length > 0) {
374
+ console.log(`${result.obligationsMissingFromRecord.length} obligation(s) name a rule id not in the supplied brand-derived list:`);
375
+ for (const id of result.obligationsMissingFromRecord)
376
+ console.log(` ${id}`);
377
+ }
378
+ if (result.recordRulesNotObliged.length > 0) {
379
+ console.log(`${result.recordRulesNotObliged.length} brand-derived rule id(s) are reached by no obligation:`);
380
+ for (const id of result.recordRulesNotObliged)
381
+ console.log(` ${id}`);
382
+ }
383
+ if (result.ok) {
384
+ console.log("Voice derivation coverage: satisfied.");
385
+ }
386
+ else if (result.reason === "coverage-gap") {
387
+ console.log("Voice derivation coverage: violated.");
388
+ }
389
+ else {
390
+ console.log(`Voice derivation coverage: indeterminate (${result.reason}).`);
391
+ }
392
+ }
393
+ /**
394
+ * `voice-derivation-coverage`'s own `main`-equivalent: parse argv, load the
395
+ * two JSON files, run `checkVoiceDerivationCoverage`, print a report, pick
396
+ * an exit code — the identical shape `main()` uses for `checkCopyTraceability`
397
+ * above, projected onto this gate's own three-state result.
398
+ *
399
+ * A file that is missing/unreadable/unparseable never reaches
400
+ * `checkVoiceDerivationCoverage` at all: it is "could not run" (exit `2`),
401
+ * decided and reported here, before the pure check function is ever
402
+ * called — exactly how `main()` above never calls `checkCopyTraceability`
403
+ * when `readCopyRecord` fails. `checkVoiceDerivationCoverage` itself only
404
+ * ever sees two real `string[]` lists; its own
405
+ * `"no-obligations-provided"`/`"no-brand-derived-rules-provided"`
406
+ * indeterminate reasons are for a run that loaded both files cleanly but
407
+ * had nothing to compare (one or both lists parsed as `[]`).
408
+ */
409
+ function runVoiceDerivationCoverage(argv) {
410
+ const args = parseVoiceDerivationCoverageArgs(argv);
411
+ if (args.help) {
412
+ console.log(VOICE_DERIVATION_COVERAGE_USAGE);
413
+ return 0;
414
+ }
415
+ if (!args.obligationsFile) {
416
+ throw new CliInputError("obligations-file is required");
417
+ }
418
+ if (!args.brandDerivedRuleIdsFile) {
419
+ throw new CliInputError("brand-derived-rule-ids-file is required");
420
+ }
421
+ const obligationsFile = resolve(args.obligationsFile);
422
+ const brandDerivedRuleIdsFile = resolve(args.brandDerivedRuleIdsFile);
423
+ requireFile("obligations-file", obligationsFile);
424
+ requireFile("brand-derived-rule-ids-file", brandDerivedRuleIdsFile);
425
+ console.log(`Obligations file: ${obligationsFile}`);
426
+ console.log(`Brand-derived rule ids file: ${brandDerivedRuleIdsFile}`);
427
+ const obligationsRead = readStringList("obligations-file", obligationsFile);
428
+ if (!obligationsRead.ok) {
429
+ console.error(`\nObligations could not be loaded: ${obligationsRead.detail}`);
430
+ console.error("Refusing to report a pass with no trustworthy obligation list to check against.");
431
+ return 2;
432
+ }
433
+ const brandDerivedRuleIdsRead = readStringList("brand-derived-rule-ids-file", brandDerivedRuleIdsFile);
434
+ if (!brandDerivedRuleIdsRead.ok) {
435
+ console.error(`\nBrand-derived rule ids could not be loaded: ${brandDerivedRuleIdsRead.detail}`);
436
+ console.error("Refusing to report a pass with no trustworthy brand-derived rule id list to check against.");
437
+ return 2;
438
+ }
439
+ const result = checkVoiceDerivationCoverage(obligationsRead.value, brandDerivedRuleIdsRead.value);
440
+ printVoiceDerivationCoverageReport(result);
441
+ // Same fail-closed mapping `main()` uses above, restated for this gate's
442
+ // own three-state result: `indeterminate` (nothing meaningful was
443
+ // compared) is `2`, never `0` and never conflated with a real `1`
444
+ // violation.
445
+ if (!result.ok && result.reason !== "coverage-gap")
446
+ return 2;
447
+ return result.ok ? 0 : 1;
448
+ }
449
+ function parseLocaleCoverageArgs(argv) {
450
+ let registriesFile;
451
+ let sourceLocale;
452
+ const declaredLocales = [];
453
+ let help = false;
454
+ for (const arg of argv) {
455
+ if (arg === "--help" || arg === "-h") {
456
+ help = true;
457
+ continue;
458
+ }
459
+ if (arg.startsWith("-")) {
460
+ throw new CliInputError(`unknown flag "${arg}"`);
461
+ }
462
+ if (registriesFile === undefined) {
463
+ registriesFile = arg;
464
+ }
465
+ else if (sourceLocale === undefined) {
466
+ sourceLocale = arg;
467
+ }
468
+ else {
469
+ declaredLocales.push(arg);
470
+ }
471
+ }
472
+ return { registriesFile, sourceLocale, declaredLocales, help };
473
+ }
474
+ /**
475
+ * Reads and JSON-parses `registries-file`, then confirms the TOP-LEVEL shape
476
+ * is a plain object (never an array, never `null`, never a primitive) —
477
+ * `checkLocaleCoverage` itself validates each PER-LOCALE registry value via
478
+ * `validateCopyRegistryShape`, so this only needs to confirm the container
479
+ * this CLI reads off disk is the `Record<CopyLocale, unknown>` shape
480
+ * `checkLocaleCoverage` expects, never the per-locale contents. Mirrors
481
+ * `readJsonFile`'s never-throw discipline: any failure comes back as
482
+ * `{ ok: false, detail }`, never an exception.
483
+ */
484
+ function readRegistriesFile(path) {
485
+ const parsed = readJsonFile("registries-file", path);
486
+ if (!parsed.ok)
487
+ return parsed;
488
+ const { value } = parsed;
489
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
490
+ return {
491
+ ok: false,
492
+ detail: `registries-file "${path}" must be a JSON object mapping each locale to its CopyRegistry, got ${JSON.stringify(value)}.`,
493
+ };
494
+ }
495
+ return { ok: true, value: value };
496
+ }
497
+ function printLocaleCoverageReport(result) {
498
+ console.log(`Source locale "${result.sourceLocale}": ${result.sourceEntryCount} entr${result.sourceEntryCount === 1 ? "y" : "ies"}. ` +
499
+ `${result.checkedLocales.length} of ${result.targetLocales.length} declared target locale(s) evaluated.`);
500
+ if (result.skippedLocales.length > 0) {
501
+ console.error(`${result.skippedLocales.length} declared locale(s) could NOT be evaluated:`);
502
+ for (const s of result.skippedLocales)
503
+ console.error(` ${s.locale} [${s.reason}]`);
504
+ }
505
+ if (result.findings.length === 0) {
506
+ console.log("No findings.");
507
+ return;
508
+ }
509
+ console.log(`\n${result.findings.length} finding(s):`);
510
+ for (const f of result.findings) {
511
+ const where = f.locale ? (f.entryId ? `${f.locale}:${f.entryId}` : f.locale) : "(locale set)";
512
+ console.log(` [${f.severity}] [${f.rule}] ${where} ${f.message}`);
513
+ }
514
+ }
515
+ /**
516
+ * `locale-coverage`'s own `main`-equivalent: parse argv, load the
517
+ * registries file, run `checkLocaleCoverage`, print a report, pick an exit
518
+ * code — the identical shape `runVoiceDerivationCoverage` above uses for
519
+ * `checkVoiceDerivationCoverage`.
520
+ *
521
+ * `declared-locale` defaults to every key `registries-file` itself declares,
522
+ * in file order, when none are given on argv — the common case, where a
523
+ * project simply wants "check coverage across every locale I actually
524
+ * produced a registry for." Passing `declared-locale` explicitly is how a
525
+ * caller additionally asserts that some OTHER locale — one the project is
526
+ * SUPPOSED to cover but has no registry file for at all — is missing;
527
+ * `checkLocaleCoverage` reports that as its own `"target-locale-missing"`
528
+ * finding (and folds the run to `complete: false`) rather than this CLI
529
+ * silently narrowing the declared set to only what exists on disk.
530
+ *
531
+ * A registries-file that is missing/unreadable/unparseable/not-an-object
532
+ * never reaches `checkLocaleCoverage` at all: it is "could not run" (exit
533
+ * `2`), decided and reported here — exactly how `main()` above never calls
534
+ * `checkCopyTraceability` when `readCopyRecord` fails, and
535
+ * `runVoiceDerivationCoverage` never calls `checkVoiceDerivationCoverage`
536
+ * when either of its own files fails to load.
537
+ */
538
+ function runLocaleCoverage(argv) {
539
+ const args = parseLocaleCoverageArgs(argv);
540
+ if (args.help) {
541
+ console.log(LOCALE_COVERAGE_USAGE);
542
+ return 0;
543
+ }
544
+ if (!args.registriesFile) {
545
+ throw new CliInputError("registries-file is required");
546
+ }
547
+ if (!args.sourceLocale) {
548
+ throw new CliInputError("source-locale is required");
549
+ }
550
+ const registriesFile = resolve(args.registriesFile);
551
+ requireFile("registries-file", registriesFile);
552
+ console.log(`Registries file: ${registriesFile}`);
553
+ console.log(`Source locale: ${args.sourceLocale}`);
554
+ const registriesRead = readRegistriesFile(registriesFile);
555
+ if (!registriesRead.ok) {
556
+ console.error(`\nRegistries could not be loaded: ${registriesRead.detail}`);
557
+ console.error("Refusing to report a pass with no trustworthy registry set to check coverage against.");
558
+ return 2;
559
+ }
560
+ const declaredLocales = args.declaredLocales.length > 0 ? args.declaredLocales : Object.keys(registriesRead.value);
561
+ console.log(`Declared locales: ${declaredLocales.length > 0 ? declaredLocales.join(", ") : "(none)"}`);
562
+ const result = checkLocaleCoverage(registriesRead.value, args.sourceLocale, declaredLocales);
563
+ printLocaleCoverageReport(result);
564
+ // The exact mapping LocaleCoverageReport.complete's own doc comment
565
+ // states as its caller contract (locale-coverage.ts): `!complete` — not
566
+ // every declared locale was actually evaluated, including "checked
567
+ // nothing" shapes like zero declared locales or a missing/empty source
568
+ // locale — is always `2`, never `0` and never conflated with a real `1`.
569
+ // A `complete` run with only warning-severity findings (e.g. every
570
+ // finding is `orphaned-entry`, `stale-entry`, or `provenance-missing`)
571
+ // is `0`; at least one error-severity finding (missing-entry,
572
+ // interpolation-missing/-extra, or a structural decline) is `1`.
573
+ if (!result.complete)
574
+ return 2;
575
+ return result.findings.some((f) => f.severity === "error") ? 1 : 0;
576
+ }
577
+ /**
578
+ * `copy-addressability`'s own accounting — a DIFFERENT gate from
579
+ * traceability above (see `addressability.ts`'s top doc comment): is this
580
+ * same source tree's user-facing prose resolved from the copy registry by
581
+ * id, rather than typed inline? Mirrors `printScanAccounting`'s
582
+ * "print every skip/exclusion count unconditionally" discipline.
583
+ */
584
+ function printAddressabilityAccounting(scan) {
585
+ console.log(`[addressability] Scanned ${scan.filesScanned} file${scan.filesScanned === 1 ? "" : "s"}, ` +
586
+ `${scan.violations.length} inline user-facing string${scan.violations.length === 1 ? "" : "s"} found.`);
587
+ if (scan.skippedByDesign.length > 0) {
588
+ console.log(`[addressability] ${scan.skippedByDesign.length} file(s) skipped by design (test/check/declaration files).`);
589
+ }
590
+ if (scan.excludedFiles.length > 0) {
591
+ console.log(`[addressability] ${scan.excludedFiles.length} file(s) excluded via pathExclusions.`);
592
+ }
593
+ if (scan.pathExclusionFindings.length > 0) {
594
+ for (const f of scan.pathExclusionFindings) {
595
+ const line = `[addressability] [${f.rule}] ${f.path ? `"${f.path}": ` : ""}${f.message}`;
596
+ if (f.severity === "error")
597
+ console.error(line);
598
+ else
599
+ console.log(line);
600
+ }
601
+ }
602
+ if (scan.parseFailures.length > 0) {
603
+ console.error(`[addressability] ${scan.parseFailures.length} file(s) could NOT be parsed and were NOT scanned:`);
604
+ for (const p of scan.parseFailures)
605
+ console.error(` ${p.file}: ${p.detail}`);
606
+ }
607
+ if (scan.unchecked.length > 0) {
608
+ console.error(`[addressability] ${scan.unchecked.length} string position(s) recognized but NOT reliably classified — coverage for these is incomplete:`);
609
+ for (const u of scan.unchecked)
610
+ console.error(` [${u.kind}] ${u.file}:${u.line} ${u.detail}`);
611
+ }
612
+ }
613
+ function printAddressabilityReport(result) {
614
+ if (result.violations.length === 0) {
615
+ console.log("[addressability] No inline user-facing prose found.");
616
+ }
617
+ else {
618
+ console.log(`\n[addressability] ${result.violations.length} violation(s):`);
619
+ for (const v of result.violations) {
620
+ const where = v.position === "markup-text" ? "a markup text node" : `the "${v.attribute}" attribute`;
621
+ console.log(` [copy-addressability] ${v.file}:${v.line} inline prose in ${where}, not resolved from the copy registry by id: ${v.raw}`);
622
+ }
623
+ }
624
+ if (result.verdict === "indeterminate") {
625
+ console.error(`[addressability] indeterminate — ${result.reasons.join("; ")}. Refusing to report a pass.`);
626
+ }
627
+ }
628
+ /**
629
+ * Exported (unlike a typical CLI `main`) so `cli.test.ts` can exercise the
630
+ * whole argv-to-exit-code contract directly, against real `mkdtemp` temp
631
+ * directories, without spawning a subprocess for every case. Takes `argv`
632
+ * as a parameter rather than reading `process.argv` itself for exactly
633
+ * that reason — `run()` below is the only caller that reads the real
634
+ * `process.argv`.
635
+ */
636
+ export function main(argv) {
637
+ // Subcommand dispatch: only `argv[0] === "voice-derivation-coverage"` or
638
+ // `argv[0] === "locale-coverage"` exactly diverts to the second/third
639
+ // subcommand — see this file's top-of-file doc comment. Every other first
640
+ // argument, including any real `record-file` path an existing caller
641
+ // already passes, falls straight through to the original behavior below,
642
+ // unchanged.
643
+ if (argv[0] === "voice-derivation-coverage") {
644
+ return runVoiceDerivationCoverage(argv.slice(1));
645
+ }
646
+ if (argv[0] === "locale-coverage") {
647
+ return runLocaleCoverage(argv.slice(1));
648
+ }
649
+ const args = parseArgs(argv);
650
+ if (args.help) {
651
+ console.log(USAGE);
652
+ return 0;
653
+ }
654
+ if (!args.recordFile) {
655
+ throw new CliInputError("record-file is required");
656
+ }
657
+ const recordFile = resolve(args.recordFile);
658
+ const scanDir = resolve(args.scanDir ?? process.cwd());
659
+ requireFile("record-file", recordFile);
660
+ requireDirectory("scan-dir", scanDir);
661
+ console.log(`Copy record: ${recordFile}`);
662
+ console.log(`Scan directory: ${scanDir}`);
663
+ // The copy record itself missing/unreadable/unparseable/invalid is
664
+ // fail-closed: there is no trustworthy set of registered entries to
665
+ // check candidates against, so this is "could not run", never a clean
666
+ // pass produced from zero entries — the same discipline
667
+ // `strategy-facts-check` holds `facts.json` to.
668
+ const read = readCopyRecord(recordFile);
669
+ if (!read.complete || !read.record) {
670
+ console.error(`\nCopy record could not be loaded:`);
671
+ for (const issue of read.issues)
672
+ console.error(` [${issue.reason}] ${issue.detail}`);
673
+ console.error("Refusing to report a pass with no trustworthy copy record to check source against.");
674
+ return 2;
675
+ }
676
+ // No argv flag populates `pathExclusions` today — this CLI always calls
677
+ // `scanCopySourceTree` with defaults (`[]`), so `scan.excludedFiles`/
678
+ // `scan.pathExclusionFindings` are always empty in THIS binary as
679
+ // shipped. The accounting/exit-code handling below exists anyway so a
680
+ // future flag (or a caller wrapping this same `main()`'s pieces with its
681
+ // own options) does not require touching this exit-code logic again —
682
+ // see this file's top doc comment.
683
+ const scan = scanCopySourceTree(scanDir); // throws (fail-closed) on an unreadable directory — caught by run()
684
+ printScanAccounting(scan);
685
+ // A malformed pathExclusions entry means this run cannot trust which
686
+ // files were correctly excluded — "could not run", not "ran and found
687
+ // nothing wrong". Checked before the files-scanned gate below: an invalid
688
+ // exclusion list is a configuration problem independent of whether the
689
+ // walk itself found anything.
690
+ const invalidPathExclusions = scan.pathExclusionFindings.filter((f) => f.severity === "error");
691
+ if (invalidPathExclusions.length > 0) {
692
+ console.error(`\n${invalidPathExclusions.length} pathExclusions entr${invalidPathExclusions.length === 1 ? "y is" : "ies are"} invalid — refusing to report a pass built on an exclusion list that cannot be trusted.`);
693
+ return 2;
694
+ }
695
+ // Zero files SUCCESSFULLY scanned is the exact failure mode this gate is
696
+ // built to never silently pass: "nothing to scan" (the walk matched no
697
+ // files at all) and "everything that matched failed to parse" (see
698
+ // scan.parseFailures) are both, deliberately, the same outcome here —
699
+ // neither one is "scanned everything, found nothing wrong".
700
+ if (scan.filesScanned === 0) {
701
+ console.error(scan.parseFailures.length > 0
702
+ ? `\nEvery file matched under "${scanDir}" failed to parse — nothing was actually scanned.`
703
+ : `\nNo files matched under "${scanDir}" — nothing was scanned.`);
704
+ console.error("Refusing to report a pass for a scan that checked nothing.");
705
+ return 2;
706
+ }
707
+ // A file that failed to parse was matched, was in scope, and was never
708
+ // examined for copy — and the check above only catches the case where
709
+ // EVERY file failed. One parse failure among a hundred clean files used
710
+ // to land here with `filesScanned > 0` and be dropped from the exit-code
711
+ // decision entirely: the run reported on the files it could read and
712
+ // exited 0 or 1 as if the unreadable one had been checked and found
713
+ // clean. That is the same collapse `unchecked` is handled below to
714
+ // prevent, only coarser — a whole file never examined rather than a
715
+ // construct within one — so it gets the same answer, for the reason
716
+ // this file's own header already gave for `unchecked`: "a gate that
717
+ // reports 'clean' after failing to run, OR after only partially
718
+ // running, is worse than no gate at all."
719
+ //
720
+ // Every parse failure was already printed in full by
721
+ // `printScanAccounting` above, and every finding from the files that DID
722
+ // parse is still printed below — this refuses to let the run as a whole
723
+ // read as fully accounted-for, it does not hide anything that was
724
+ // learned.
725
+ if (scan.parseFailures.length > 0) {
726
+ console.error(`\n${scan.parseFailures.length} of ${scan.parseFailures.length + scan.filesScanned} matched file(s) under "${scanDir}" could not be parsed and were never examined for copy.`);
727
+ console.error("Refusing to report a pass for a scan whose coverage is incomplete.");
728
+ return 2;
729
+ }
730
+ const result = checkCopyTraceability(scan.candidates, scan.citations, read.record, scan.filesScanned, scan.unchecked);
731
+ printGateReport(result);
732
+ // `unchecked` wins over everything else in the exit-code decision — see
733
+ // this file's own top doc comment for why it is a `2`, not a `1`: it
734
+ // means part of a matched file was never actually examined, which is
735
+ // "could not run [fully]", not "ran and found something wrong". Every
736
+ // finding was still printed above, so nothing real is hidden — this
737
+ // only refuses to let the run as a whole read as clean or as
738
+ // fully-accounted-for.
739
+ if (result.unchecked.length > 0)
740
+ return 2;
741
+ return result.findings.length > 0 ? 1 : 0;
742
+ }
743
+ function run() {
744
+ try {
745
+ process.exitCode = main(process.argv.slice(2));
746
+ }
747
+ catch (error) {
748
+ if (error instanceof CliInputError) {
749
+ console.error(`writer-check: ${error.message}`);
750
+ console.error(`\n${USAGE}`);
751
+ process.exitCode = 2;
752
+ }
753
+ else {
754
+ console.error(`writer-check: unexpected error: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
755
+ process.exitCode = 2;
756
+ }
757
+ }
758
+ }
759
+ // ============================================================================
760
+ // "writer-check passages" — a SUBCOMMAND of the SAME `writer-check` bin,
761
+ // same file, same three-state exit-code shape as the default command above
762
+ // and as `writer-check addressability` below. Dispatched on
763
+ // `argv[0] === "passages"` at the very bottom of this file, BEFORE any of
764
+ // the default command's own argument parsing runs — see this file's top
765
+ // doc comment ("A FOURTH subcommand, `passages`") for why this mirrors
766
+ // addressability's own fully-separate dispatch rather than being folded
767
+ // through `main()` the way `voice-derivation-coverage`/`locale-coverage`
768
+ // are.
769
+ // ============================================================================
770
+ const PASSAGES_USAGE = `Usage: writer-check passages <registry-file> [options]
771
+
772
+ registry-file Path to a PassageRecord JSON file: { id: string, passages: Passage[] } — see @clossys/writer's passage.ts for the full shape. Required.
773
+
774
+ Checks the PASSAGE-LAYER COMPOSITION gate (issue #373): does every passage's
775
+ every field reference a registered entry ({ ref: "entry", id }) or a
776
+ glossary term ({ ref: "term", term }), never a literal string inlined
777
+ directly into the field, and never a reference into another passage's own
778
+ internals ({ ref: "passage", ... })? See @clossys/writer's
779
+ passage.ts, "THE GATE", for the full ternary this subcommand exposes, and
780
+ "WHAT THIS GATE DELIBERATELY DOES NOT DO" for why this does not verify a
781
+ referenced entry id/term actually exists in a real CopyRecord/glossary —
782
+ that is a different, weaker question this gate's own adversarial proof is
783
+ built to separate itself from.
784
+
785
+ Options:
786
+ --help Print this message and exit 0.
787
+
788
+ Exit codes: 0 = satisfied (every passage references only entries and terms, at least one passage evaluated), 1 = violated (a passage inlines a literal string, or references another passage's internals — regardless of how many other fields are unclassifiable), 2 = indeterminate (registry-file missing/unreadable/unparseable/invalid, zero passages registered, or a field's value could not be confidently classified, with zero violations found).
789
+ `;
790
+ function parsePassagesArgs(argv) {
791
+ let registryFile;
792
+ let help = false;
793
+ for (const arg of argv) {
794
+ if (arg === "--help" || arg === "-h") {
795
+ help = true;
796
+ continue;
797
+ }
798
+ if (arg.startsWith("-")) {
799
+ throw new CliInputError(`unknown flag "${arg}"`);
800
+ }
801
+ if (registryFile === undefined) {
802
+ registryFile = arg;
803
+ }
804
+ else {
805
+ throw new CliInputError(`unexpected extra argument "${arg}"`);
806
+ }
807
+ }
808
+ return { registryFile, help };
809
+ }
810
+ function printPassagesReport(result) {
811
+ console.log(`[passages] ${result.passagesEvaluated} passage(s) evaluated, ${result.violations.length} violation(s), ` +
812
+ `${result.unclassified.length} field(s) unclassified.`);
813
+ if (result.violations.length > 0) {
814
+ console.log(`\n[passages] ${result.violations.length} violation(s):`);
815
+ for (const v of result.violations) {
816
+ console.log(` [${v.rule}] ${v.message}`);
817
+ }
818
+ }
819
+ else {
820
+ console.log("[passages] No composition violations found.");
821
+ }
822
+ if (result.unclassified.length > 0) {
823
+ console.error(`[passages] ${result.unclassified.length} field(s) could not be confidently classified:`);
824
+ for (const u of result.unclassified)
825
+ console.error(` ${u.detail}`);
826
+ }
827
+ if (result.verdict === "indeterminate") {
828
+ console.error(`[passages] indeterminate — ${result.reasons.join("; ")}. Refusing to report a pass.`);
829
+ }
830
+ }
831
+ /**
832
+ * Exported (unlike a typical CLI `main`) for the identical reason `main`
833
+ * and `mainAddressabilityCheck` above are — `cli.test.ts` exercises the
834
+ * whole argv-to-exit-code contract directly against real fixture files,
835
+ * without spawning a subprocess.
836
+ */
837
+ export function mainPassagesCheck(argv) {
838
+ const args = parsePassagesArgs(argv);
839
+ if (args.help) {
840
+ console.log(PASSAGES_USAGE);
841
+ return 0;
842
+ }
843
+ if (!args.registryFile) {
844
+ throw new CliInputError("registry-file is required");
845
+ }
846
+ const registryFile = resolve(args.registryFile);
847
+ requireFile("registry-file", registryFile);
848
+ console.log(`[passages] Registry: ${registryFile}`);
849
+ // The passage record itself missing/unreadable/unparseable/invalid is
850
+ // fail-closed: there is no trustworthy set of passages to evaluate, so
851
+ // this is "could not run", never a clean pass produced with nothing
852
+ // trustworthy behind it — the same discipline `main()` above holds
853
+ // `readCopyRecord` to.
854
+ const read = readPassageRecord(registryFile);
855
+ if (!read.complete || !read.record) {
856
+ console.error(`\n[passages] Registry could not be loaded:`);
857
+ for (const issue of read.issues)
858
+ console.error(` [${issue.reason}] ${issue.detail}`);
859
+ console.error("[passages] Refusing to report a pass with no trustworthy passage registry to check.");
860
+ return 2;
861
+ }
862
+ const result = checkPassageComposition(read.record);
863
+ printPassagesReport(result);
864
+ // Same fail-closed, "violated wins" mapping `passage.ts`'s own
865
+ // `checkPassageComposition` already applies when computing `verdict` —
866
+ // this is just the verdict-to-exit-code projection, identical in shape
867
+ // to `mainAddressabilityCheck`'s own mapping below.
868
+ if (result.verdict === "indeterminate")
869
+ return 2;
870
+ return result.verdict === "violated" ? 1 : 0;
871
+ }
872
+ /**
873
+ * `argv` here is whatever followed the `"passages"` subcommand token — the
874
+ * dispatch at the bottom of this file strips that token BEFORE calling
875
+ * this, so `mainPassagesCheck` never sees it.
876
+ */
877
+ function runPassagesCheck(argv) {
878
+ try {
879
+ process.exitCode = mainPassagesCheck(argv);
880
+ }
881
+ catch (error) {
882
+ if (error instanceof CliInputError) {
883
+ console.error(`writer-check passages: ${error.message}`);
884
+ console.error(`\n${PASSAGES_USAGE}`);
885
+ process.exitCode = 2;
886
+ }
887
+ else {
888
+ console.error(`writer-check passages: unexpected error: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
889
+ process.exitCode = 2;
890
+ }
891
+ }
892
+ }
893
+ // ============================================================================
894
+ // "writer-check addressability" — a SUBCOMMAND of the SAME `writer-check` bin,
895
+ // same file, same three-state exit-code shape as the default command
896
+ // above. Dispatched on `argv[0] === "addressability"` at the very bottom
897
+ // of this file, BEFORE any of the default command's own argument parsing
898
+ // runs — see this file's top doc comment for why this is a subcommand
899
+ // rather than a second `bin` entry, and why its own exit code is not
900
+ // folded into the default command's.
901
+ // ============================================================================
902
+ const ADDRESSABILITY_USAGE = `Usage: writer-check addressability [scan-dir] [options]
903
+
904
+ scan-dir Directory to scan for user-facing prose. Defaults to the current working directory.
905
+
906
+ Checks whether user-facing prose is resolved from the copy registry by id,
907
+ rather than typed inline in a component — a stricter, separate question
908
+ from writer-check's default traceability command (see
909
+ @clossys/writer's addressability.ts for the full contract). Three
910
+ positions are classified: markup text nodes and the four user-facing
911
+ attributes (aria-label, placeholder, alt, title) are violations when they
912
+ carry literal prose; every other string position (a template literal, an
913
+ object/array literal value, a prop outside the four) is reported as
914
+ unclassifiable rather than assumed clean. There is no citation or
915
+ registry-text-match escape hatch here, unlike the default command.
916
+
917
+ Options:
918
+ --help Print this message and exit 0.
919
+
920
+ Exit codes: 0 = clean, 1 = at least one inline user-facing string found, 2 = could not run (bad input, nothing matched to scan, every matched file failed to parse, or a string position could not be confidently classified).
921
+ `;
922
+ function parseAddressabilityArgs(argv) {
923
+ let scanDir;
924
+ let help = false;
925
+ for (const arg of argv) {
926
+ if (arg === "--help" || arg === "-h") {
927
+ help = true;
928
+ continue;
929
+ }
930
+ if (arg.startsWith("-")) {
931
+ throw new CliInputError(`unknown flag "${arg}"`);
932
+ }
933
+ if (scanDir === undefined) {
934
+ scanDir = arg;
935
+ }
936
+ else {
937
+ throw new CliInputError(`unexpected extra argument "${arg}"`);
938
+ }
939
+ }
940
+ return { scanDir, help };
941
+ }
942
+ /**
943
+ * Exported (unlike a typical CLI `main`) for the identical reason `main`
944
+ * above is — `cli.test.ts` exercises the whole argv-to-exit-code contract
945
+ * directly against real `mkdtemp` directories, without spawning a
946
+ * subprocess.
947
+ */
948
+ export function mainAddressabilityCheck(argv) {
949
+ const args = parseAddressabilityArgs(argv);
950
+ if (args.help) {
951
+ console.log(ADDRESSABILITY_USAGE);
952
+ return 0;
953
+ }
954
+ const scanDir = resolve(args.scanDir ?? process.cwd());
955
+ requireDirectory("scan-dir", scanDir);
956
+ console.log(`Scan directory: ${scanDir}`);
957
+ // Throws (fail-closed) on an unreadable directory, exactly like
958
+ // `scanCopySourceTree` — caught by `runAddressabilityCheck()`'s own
959
+ // catch-all below.
960
+ const scan = scanAddressabilitySources(scanDir);
961
+ printAddressabilityAccounting(scan);
962
+ const result = checkAddressability(scan);
963
+ printAddressabilityReport(result);
964
+ // Since #407, addressability's own precedence is the OPPOSITE of
965
+ // writer-check's default command: a real violation wins over an
966
+ // incomplete picture, rather than "could not run [fully]" winning over a
967
+ // real finding — see `addressability.ts`'s top doc comment, "THE
968
+ // TERNARY". `checkAddressability` already applies it when computing
969
+ // `verdict`, so this is just the verdict-to-exit-code mapping.
970
+ if (result.verdict === "indeterminate")
971
+ return 2;
972
+ return result.verdict === "violated" ? 1 : 0;
973
+ }
974
+ /**
975
+ * `argv` here is whatever followed the `"addressability"` subcommand token
976
+ * — the dispatch at the bottom of this file strips that token BEFORE
977
+ * calling this, so `mainAddressabilityCheck` never sees it and its own
978
+ * argument parsing (`parseAddressabilityArgs`) stays identical to
979
+ * `main`'s own shape (just `[scan-dir] [options]`, no subcommand of its
980
+ * own to account for).
981
+ */
982
+ function runAddressabilityCheck(argv) {
983
+ try {
984
+ process.exitCode = mainAddressabilityCheck(argv);
985
+ }
986
+ catch (error) {
987
+ if (error instanceof CliInputError) {
988
+ console.error(`writer-check addressability: ${error.message}`);
989
+ console.error(`\n${ADDRESSABILITY_USAGE}`);
990
+ process.exitCode = 2;
991
+ }
992
+ else {
993
+ console.error(`writer-check addressability: unexpected error: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
994
+ process.exitCode = 2;
995
+ }
996
+ }
997
+ }
998
+ /**
999
+ * Same real-path guard `@example/strategy`'s `cli.ts` and
1000
+ * `@example/gates`' `cli.ts` both use, for the same reason: `npm
1001
+ * install` publishes `bin` entries as symlinks, so comparing
1002
+ * `process.argv[1]` to `import.meta.url` without resolving symlinks on
1003
+ * both sides fails the moment this file is actually invoked the only way
1004
+ * it ships — as an installed CLI — and does so silently (`run()` never
1005
+ * fires, nothing prints, exit code 0). See `strategy`'s `cli.ts` doc
1006
+ * comment for the full story; this is the same fix, applied here.
1007
+ */
1008
+ function detectMainModule() {
1009
+ const argvPath = process.argv[1];
1010
+ if (argvPath === undefined)
1011
+ return false;
1012
+ const modulePath = fileURLToPath(import.meta.url);
1013
+ try {
1014
+ return realpathSync(resolve(argvPath)) === realpathSync(modulePath);
1015
+ }
1016
+ catch {
1017
+ return resolve(argvPath) === modulePath;
1018
+ }
1019
+ }
1020
+ /**
1021
+ * `writer-check`, `writer-check addressability`, and `writer-check passages`
1022
+ * are ONE `bin` entry, one compiled file, dispatched by an explicit FIRST
1023
+ * ARGUMENT — never by how this file was invoked (its own path, a symlink
1024
+ * name, an installed `bin` shim, ...). That is deliberate: this
1025
+ * repository's own root `package.json` invokes every gate by compiled path
1026
+ * (`node packages/ui/dist/tokens/contrast-cli.js`,
1027
+ * `node packages/controller/dist/cli.js`, ...), never by `bin` name, so
1028
+ * any dispatch keyed on the invoking path/name (a second `bin` entry
1029
+ * pointing at this same file, or a `basename(process.argv[1])` sniff)
1030
+ * would be unreachable under that invocation style — worse, it would
1031
+ * silently fall through to the DEFAULT command instead of erroring,
1032
+ * exactly the "runs the wrong thing without ever failing" failure mode a
1033
+ * gate must never have. `rawArgs[0]` is read BEFORE `main()`'s own
1034
+ * `parseArgs` ever sees the array, so the default command's argument
1035
+ * shape is completely unaffected by either subcommand existing at all.
1036
+ */
1037
+ if (detectMainModule()) {
1038
+ const rawArgs = process.argv.slice(2);
1039
+ if (rawArgs[0] === "addressability") {
1040
+ runAddressabilityCheck(rawArgs.slice(1));
1041
+ }
1042
+ else if (rawArgs[0] === "passages") {
1043
+ runPassagesCheck(rawArgs.slice(1));
1044
+ }
1045
+ else {
1046
+ run();
1047
+ }
1048
+ }
1049
+ //# sourceMappingURL=cli.js.map