@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.
- package/CHANGELOG.md +196 -0
- package/LICENSE +21 -0
- package/README.md +593 -0
- package/dist/addressability.d.ts +245 -0
- package/dist/addressability.d.ts.map +1 -0
- package/dist/addressability.js +567 -0
- package/dist/addressability.js.map +1 -0
- package/dist/checker.d.ts +159 -0
- package/dist/checker.d.ts.map +1 -0
- package/dist/checker.js +177 -0
- package/dist/checker.js.map +1 -0
- package/dist/cli.d.ts +149 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +1049 -0
- package/dist/cli.js.map +1 -0
- package/dist/copy-gate.d.ts +107 -0
- package/dist/copy-gate.d.ts.map +1 -0
- package/dist/copy-gate.js +132 -0
- package/dist/copy-gate.js.map +1 -0
- package/dist/fingerprint.d.ts +61 -0
- package/dist/fingerprint.d.ts.map +1 -0
- package/dist/fingerprint.js +64 -0
- package/dist/fingerprint.js.map +1 -0
- package/dist/index.d.ts +47 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +63 -0
- package/dist/index.js.map +1 -0
- package/dist/locale-coverage.d.ts +227 -0
- package/dist/locale-coverage.d.ts.map +1 -0
- package/dist/locale-coverage.js +414 -0
- package/dist/locale-coverage.js.map +1 -0
- package/dist/passage.d.ts +294 -0
- package/dist/passage.d.ts.map +1 -0
- package/dist/passage.js +463 -0
- package/dist/passage.js.map +1 -0
- package/dist/path-exclusions.d.ts +114 -0
- package/dist/path-exclusions.d.ts.map +1 -0
- package/dist/path-exclusions.js +213 -0
- package/dist/path-exclusions.js.map +1 -0
- package/dist/registry.d.ts +61 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +64 -0
- package/dist/registry.js.map +1 -0
- package/dist/resolve.d.ts +33 -0
- package/dist/resolve.d.ts.map +1 -0
- package/dist/resolve.js +133 -0
- package/dist/resolve.js.map +1 -0
- package/dist/scan.d.ts +457 -0
- package/dist/scan.d.ts.map +1 -0
- package/dist/scan.js +1698 -0
- package/dist/scan.js.map +1 -0
- package/dist/schema.d.ts +81 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +482 -0
- package/dist/schema.js.map +1 -0
- package/dist/types.d.ts +268 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +40 -0
- package/dist/types.js.map +1 -0
- package/dist/voice/checker.d.ts +186 -0
- package/dist/voice/checker.d.ts.map +1 -0
- package/dist/voice/checker.js +483 -0
- package/dist/voice/checker.js.map +1 -0
- package/dist/voice/derivation-coverage.d.ts +168 -0
- package/dist/voice/derivation-coverage.d.ts.map +1 -0
- package/dist/voice/derivation-coverage.js +181 -0
- package/dist/voice/derivation-coverage.js.map +1 -0
- package/dist/voice/fields.d.ts +107 -0
- package/dist/voice/fields.d.ts.map +1 -0
- package/dist/voice/fields.js +144 -0
- package/dist/voice/fields.js.map +1 -0
- package/dist/voice/index.d.ts +19 -0
- package/dist/voice/index.d.ts.map +1 -0
- package/dist/voice/index.js +24 -0
- package/dist/voice/index.js.map +1 -0
- package/dist/voice/internal/parse-template.d.ts +27 -0
- package/dist/voice/internal/parse-template.d.ts.map +1 -0
- package/dist/voice/internal/parse-template.js +111 -0
- package/dist/voice/internal/parse-template.js.map +1 -0
- package/dist/voice/internal/pattern-safety.d.ts +111 -0
- package/dist/voice/internal/pattern-safety.d.ts.map +1 -0
- package/dist/voice/internal/pattern-safety.js +278 -0
- package/dist/voice/internal/pattern-safety.js.map +1 -0
- package/dist/voice/schema.d.ts +65 -0
- package/dist/voice/schema.d.ts.map +1 -0
- package/dist/voice/schema.js +535 -0
- package/dist/voice/schema.js.map +1 -0
- package/dist/voice/types.d.ts +282 -0
- package/dist/voice/types.d.ts.map +1 -0
- package/dist/voice/types.js +37 -0
- package/dist/voice/types.js.map +1 -0
- package/package.json +72 -0
- package/src/addressability.ts +664 -0
- package/src/checker.ts +293 -0
- package/src/cli.ts +1166 -0
- package/src/copy-gate.ts +197 -0
- package/src/fingerprint.ts +66 -0
- package/src/index.ts +163 -0
- package/src/locale-coverage.ts +512 -0
- package/src/passage.ts +644 -0
- package/src/path-exclusions.ts +265 -0
- package/src/registry.ts +103 -0
- package/src/resolve.ts +172 -0
- package/src/scan.ts +1959 -0
- package/src/schema.ts +528 -0
- package/src/types.ts +301 -0
- package/src/voice/checker.ts +590 -0
- package/src/voice/derivation-coverage.ts +217 -0
- package/src/voice/fields.ts +160 -0
- package/src/voice/index.ts +55 -0
- package/src/voice/internal/parse-template.ts +114 -0
- package/src/voice/internal/pattern-safety.ts +336 -0
- package/src/voice/schema.ts +585 -0
- package/src/voice/types.ts +329 -0
- 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
|
+
}
|