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