@clossys/writer 0.3.9 → 0.5.0
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/README.md +647 -6
- package/dist/addressability.d.ts +11 -1
- package/dist/addressability.d.ts.map +1 -1
- package/dist/addressability.js +169 -3
- package/dist/addressability.js.map +1 -1
- package/dist/approval-bypass.d.ts +120 -0
- package/dist/approval-bypass.d.ts.map +1 -0
- package/dist/approval-bypass.js +2318 -0
- package/dist/approval-bypass.js.map +1 -0
- package/dist/approval.d.ts +62 -0
- package/dist/approval.d.ts.map +1 -0
- package/dist/approval.js +421 -0
- package/dist/approval.js.map +1 -0
- package/dist/approve-cli.d.ts +82 -0
- package/dist/approve-cli.d.ts.map +1 -0
- package/dist/approve-cli.js +430 -0
- package/dist/approve-cli.js.map +1 -0
- package/dist/cli.d.ts +15 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +264 -22
- package/dist/cli.js.map +1 -1
- package/dist/field-options.d.ts +59 -0
- package/dist/field-options.d.ts.map +1 -0
- package/dist/field-options.js +109 -0
- package/dist/field-options.js.map +1 -0
- package/dist/fold-wallpaper-gate.d.ts +7 -0
- package/dist/fold-wallpaper-gate.d.ts.map +1 -0
- package/dist/fold-wallpaper-gate.js +29 -0
- package/dist/fold-wallpaper-gate.js.map +1 -0
- package/dist/front-door.check.d.ts +2 -0
- package/dist/front-door.check.d.ts.map +1 -0
- package/dist/front-door.check.js +8 -0
- package/dist/front-door.check.js.map +1 -0
- package/dist/front-door.d.ts +82 -0
- package/dist/front-door.d.ts.map +1 -0
- package/dist/front-door.js +271 -0
- package/dist/front-door.js.map +1 -0
- package/dist/generated/advisor-plan-contract.generated.d.ts +3 -0
- package/dist/generated/advisor-plan-contract.generated.d.ts.map +1 -0
- package/dist/generated/advisor-plan-contract.generated.js +430 -0
- package/dist/generated/advisor-plan-contract.generated.js.map +1 -0
- package/dist/generated/contract-schema.generated.d.ts +97 -0
- package/dist/generated/contract-schema.generated.d.ts.map +1 -0
- package/dist/generated/contract-schema.generated.js +496 -0
- package/dist/generated/contract-schema.generated.js.map +1 -0
- package/dist/index.d.ts +18 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +26 -0
- package/dist/index.js.map +1 -1
- package/dist/live-copy-gate.d.ts +16 -0
- package/dist/live-copy-gate.d.ts.map +1 -0
- package/dist/live-copy-gate.js +135 -0
- package/dist/live-copy-gate.js.map +1 -0
- package/dist/messaging-kit.d.ts +113 -0
- package/dist/messaging-kit.d.ts.map +1 -0
- package/dist/messaging-kit.js +121 -0
- package/dist/messaging-kit.js.map +1 -0
- package/dist/passage.d.ts +2 -1
- package/dist/passage.d.ts.map +1 -1
- package/dist/passage.js +2 -1
- package/dist/passage.js.map +1 -1
- package/dist/plan-authority.d.ts +73 -0
- package/dist/plan-authority.d.ts.map +1 -0
- package/dist/plan-authority.js +126 -0
- package/dist/plan-authority.js.map +1 -0
- package/dist/plan-contract.d.ts +16 -0
- package/dist/plan-contract.d.ts.map +1 -0
- package/dist/plan-contract.js +33 -0
- package/dist/plan-contract.js.map +1 -0
- package/dist/plan-digest.d.ts +23 -0
- package/dist/plan-digest.d.ts.map +1 -0
- package/dist/plan-digest.js +82 -0
- package/dist/plan-digest.js.map +1 -0
- package/dist/plan-rules.d.ts +54 -0
- package/dist/plan-rules.d.ts.map +1 -0
- package/dist/plan-rules.js +145 -0
- package/dist/plan-rules.js.map +1 -0
- package/dist/render-registry-parity.d.ts +20 -0
- package/dist/render-registry-parity.d.ts.map +1 -0
- package/dist/render-registry-parity.js +45 -0
- package/dist/render-registry-parity.js.map +1 -0
- package/dist/resolve.d.ts +40 -6
- package/dist/resolve.d.ts.map +1 -1
- package/dist/resolve.js +144 -18
- package/dist/resolve.js.map +1 -1
- package/dist/scan.d.ts +32 -0
- package/dist/scan.d.ts.map +1 -1
- package/dist/scan.js +67 -6
- package/dist/scan.js.map +1 -1
- package/dist/schema.d.ts +1 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +231 -5
- package/dist/schema.js.map +1 -1
- package/dist/site-identity.d.ts +88 -0
- package/dist/site-identity.d.ts.map +1 -0
- package/dist/site-identity.js +82 -0
- package/dist/site-identity.js.map +1 -0
- package/dist/treatment-word-budget.d.ts +31 -0
- package/dist/treatment-word-budget.d.ts.map +1 -0
- package/dist/treatment-word-budget.js +80 -0
- package/dist/treatment-word-budget.js.map +1 -0
- package/dist/types.d.ts +76 -7
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +9 -7
- package/dist/types.js.map +1 -1
- package/fit-signals.json +11 -0
- package/package.json +201 -11
- package/skill/SKILL.md +49 -0
- package/src/addressability.ts +186 -4
- package/src/approval-bypass.ts +2263 -0
- package/src/approval.ts +491 -0
- package/src/approve-cli.ts +474 -0
- package/src/cli.ts +274 -22
- package/src/field-options.ts +134 -0
- package/src/fold-wallpaper-gate.ts +29 -0
- package/src/front-door.check.ts +10 -0
- package/src/front-door.ts +312 -0
- package/src/generated/advisor-plan-contract.generated.ts +430 -0
- package/src/generated/contract-schema.generated.ts +520 -0
- package/src/index.ts +107 -1
- package/src/live-copy-gate.ts +150 -0
- package/src/messaging-kit.ts +195 -0
- package/src/passage.ts +2 -1
- package/src/plan-authority.ts +160 -0
- package/src/plan-contract.ts +45 -0
- package/src/plan-digest.ts +81 -0
- package/src/plan-rules.ts +179 -0
- package/src/render-registry-parity.ts +65 -0
- package/src/resolve.ts +185 -19
- package/src/scan.ts +97 -6
- package/src/schema.ts +247 -5
- package/src/site-identity.ts +125 -0
- package/src/treatment-word-budget.ts +100 -0
- package/src/types.ts +83 -7
- package/templates/front-door.en.json +426 -0
- package/CHANGELOG.md +0 -330
package/src/cli.ts
CHANGED
|
@@ -109,6 +109,21 @@
|
|
|
109
109
|
* all. This is the explicit third state this gate is built around:
|
|
110
110
|
* "could not check" must never be reported as a pass.
|
|
111
111
|
*
|
|
112
|
+
* A FIFTH and SIXTH subcommand, `approve` and `approval-state`, are
|
|
113
|
+
* dispatched the same way `addressability` and `passages` are — a fully
|
|
114
|
+
* separate top-level branch at the very bottom of this file, never folded
|
|
115
|
+
* through `main()`. `writer-check approve` writes a
|
|
116
|
+
* `CopyRegistryEntry.approval` record so nobody hand-edits one; its whole
|
|
117
|
+
* argument-parsing, validation, and atomic-write contract lives in
|
|
118
|
+
* `mainApproveCommand` (`./approve-cli.ts`) — this file only wires its
|
|
119
|
+
* exit code through. `writer-check approval-state` reports the current
|
|
120
|
+
* approval state of a registry from two angles at once: whether every
|
|
121
|
+
* approved entry's own record is current (`assessCopyApprovals`) and
|
|
122
|
+
* whether consumer source actually routes copy through the registry's
|
|
123
|
+
* approval lifecycle rather than around it (`scanApprovalBypass`/
|
|
124
|
+
* `checkApprovalBypass`); its own contract lives in
|
|
125
|
+
* `mainApprovalStateCommand` (`./approval.ts`).
|
|
126
|
+
*
|
|
112
127
|
* A malformed `pathExclusions` entry (see `scan.ts`/`path-exclusions.ts`)
|
|
113
128
|
* gets the same `2` treatment as `unchecked`, for the identical reason: an
|
|
114
129
|
* exclusion list this run cannot trust means this run cannot say which
|
|
@@ -132,10 +147,15 @@ import {
|
|
|
132
147
|
} from "./addressability.js";
|
|
133
148
|
import { checkCopyTraceability, type CopyGateFinding, type CopyGateIgnored, type CopyGateResult } from "./copy-gate.js";
|
|
134
149
|
import { checkLocaleCoverage, type LocaleCoverageReport } from "./locale-coverage.js";
|
|
150
|
+
import { liveTreeExists, scanLiveCopyTrees } from "./live-copy-gate.js";
|
|
135
151
|
import { checkPassageComposition, readPassageRecord, type PassageGateResult } from "./passage.js";
|
|
152
|
+
import { mainApprovalStateCommand } from "./approval.js";
|
|
153
|
+
import { mainApproveCommand } from "./approve-cli.js";
|
|
136
154
|
import { readCopyRecord } from "./registry.js";
|
|
155
|
+
import { checkRenderRegistryParity } from "./render-registry-parity.js";
|
|
137
156
|
import { scanCopySourceTree, type ScanResult, type UncheckedItem } from "./scan.js";
|
|
138
|
-
import {
|
|
157
|
+
import { checkTreatmentWordBudgets, type TreatmentWordBudgetFinding } from "./treatment-word-budget.js";
|
|
158
|
+
import { checkVoiceDerivationCoverage, parseVoiceRecord, type VoiceDerivationCoverageResult } from "./voice/index.js";
|
|
139
159
|
|
|
140
160
|
const USAGE = `Usage: writer-check <record-file> [scan-dir] [options]
|
|
141
161
|
or: writer-check voice-derivation-coverage <obligations-file> <brand-derived-rule-ids-file> [options]
|
|
@@ -144,16 +164,24 @@ const USAGE = `Usage: writer-check <record-file> [scan-dir] [options]
|
|
|
144
164
|
record-file Path to a CopyRecord JSON file (see @clossys/writer's README). Required.
|
|
145
165
|
scan-dir Directory to scan for user-facing string/template literals. Defaults to the current working directory.
|
|
146
166
|
|
|
147
|
-
See also "writer-check addressability [scan-dir]"
|
|
148
|
-
<registry-file>"
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
167
|
+
See also "writer-check addressability [scan-dir]", "writer-check passages
|
|
168
|
+
<registry-file>", "writer-check approve <registry-file> <entry-id>...", and
|
|
169
|
+
"writer-check approval-state <registry-file> <scan-dir>" (this same file's
|
|
170
|
+
other subcommands, all dispatched on argv[0] before anything below) —
|
|
171
|
+
addressability asks whether user-facing prose is resolved from the copy
|
|
172
|
+
registry by id, rather than typed inline; passages asks whether a passage
|
|
173
|
+
composes registered entries/terms rather than inlining a literal or
|
|
174
|
+
reaching into another passage's internals; approve writes an entry's
|
|
175
|
+
approval record; approval-state reports whether a
|
|
176
|
+
registry's approval records are current and whether consumer source
|
|
177
|
+
actually routes copy through them. All four are separate gates/commands;
|
|
178
|
+
none runs as part of this default command.
|
|
154
179
|
|
|
155
180
|
Options:
|
|
156
181
|
--help Print this message and exit 0.
|
|
182
|
+
--render-registry <file> Path the surface passes to createCopyResolver at render. When set, must be the same file as record-file (after realpath). When omitted, record-file itself must be a CopyRegistry — not a plain CopyRecord or an in-memory-only store.
|
|
183
|
+
--live <dir> Declared live-copy tree the page actually renders (repeatable). Requires --voice-record.
|
|
184
|
+
--voice-record <file> Voice record JSON for claims when --live is set.
|
|
157
185
|
--format <text|json> Output format. Defaults to text. "json" prints exactly one
|
|
158
186
|
machine-readable object to stdout (see CopyTraceabilityReport in
|
|
159
187
|
cli.ts) instead of the human-readable report — "verdict" states the
|
|
@@ -171,6 +199,8 @@ Run "writer-check voice-derivation-coverage --help" for the second subcommand's
|
|
|
171
199
|
Run "writer-check locale-coverage --help" for the third subcommand's own usage.
|
|
172
200
|
Run "writer-check addressability --help" for that subcommand's own usage.
|
|
173
201
|
Run "writer-check passages --help" for the passage-composition subcommand's own usage.
|
|
202
|
+
Run "writer-check approve --help" for the approval-recording subcommand's own usage.
|
|
203
|
+
Run "writer-check approval-state --help" for the approval-state subcommand's own usage.
|
|
174
204
|
`;
|
|
175
205
|
|
|
176
206
|
const VOICE_DERIVATION_COVERAGE_USAGE = `Usage: writer-check voice-derivation-coverage <obligations-file> <brand-derived-rule-ids-file> [options]
|
|
@@ -206,6 +236,9 @@ export class CliInputError extends Error {}
|
|
|
206
236
|
interface ParsedArgs {
|
|
207
237
|
recordFile?: string;
|
|
208
238
|
scanDir?: string;
|
|
239
|
+
renderRegistryFile?: string;
|
|
240
|
+
liveTrees: string[];
|
|
241
|
+
voiceRecordFile?: string;
|
|
209
242
|
format: "text" | "json";
|
|
210
243
|
help: boolean;
|
|
211
244
|
}
|
|
@@ -213,6 +246,9 @@ interface ParsedArgs {
|
|
|
213
246
|
function parseArgs(argv: string[]): ParsedArgs {
|
|
214
247
|
let recordFile: string | undefined;
|
|
215
248
|
let scanDir: string | undefined;
|
|
249
|
+
let renderRegistryFile: string | undefined;
|
|
250
|
+
const liveTrees: string[] = [];
|
|
251
|
+
let voiceRecordFile: string | undefined;
|
|
216
252
|
let format: "text" | "json" = "text";
|
|
217
253
|
let help = false;
|
|
218
254
|
|
|
@@ -222,6 +258,14 @@ function parseArgs(argv: string[]): ParsedArgs {
|
|
|
222
258
|
help = true;
|
|
223
259
|
continue;
|
|
224
260
|
}
|
|
261
|
+
if (arg === "--render-registry") {
|
|
262
|
+
const value = argv[++i];
|
|
263
|
+
if (value === undefined || value.startsWith("-")) {
|
|
264
|
+
throw new CliInputError("--render-registry requires a file path");
|
|
265
|
+
}
|
|
266
|
+
renderRegistryFile = value;
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
225
269
|
if (arg === "--format") {
|
|
226
270
|
const value = argv[++i];
|
|
227
271
|
if (value !== "text" && value !== "json") {
|
|
@@ -230,6 +274,18 @@ function parseArgs(argv: string[]): ParsedArgs {
|
|
|
230
274
|
format = value;
|
|
231
275
|
continue;
|
|
232
276
|
}
|
|
277
|
+
if (arg === "--live") {
|
|
278
|
+
const value = argv[++i];
|
|
279
|
+
if (value === undefined || value.startsWith("-")) throw new CliInputError("--live requires a directory path");
|
|
280
|
+
liveTrees.push(value);
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
if (arg === "--voice-record") {
|
|
284
|
+
const value = argv[++i];
|
|
285
|
+
if (value === undefined || value.startsWith("-")) throw new CliInputError("--voice-record requires a file path");
|
|
286
|
+
voiceRecordFile = value;
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
233
289
|
if (arg.startsWith("-")) {
|
|
234
290
|
throw new CliInputError(`unknown flag "${arg}"`);
|
|
235
291
|
}
|
|
@@ -242,7 +298,22 @@ function parseArgs(argv: string[]): ParsedArgs {
|
|
|
242
298
|
}
|
|
243
299
|
}
|
|
244
300
|
|
|
245
|
-
|
|
301
|
+
if (liveTrees.length > 0 && !voiceRecordFile) {
|
|
302
|
+
throw new CliInputError("--voice-record is required when --live is declared");
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return { recordFile, scanDir, renderRegistryFile, liveTrees, voiceRecordFile, format, help };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function printTreatmentWordBudgetReport(findings: TreatmentWordBudgetFinding[]): void {
|
|
309
|
+
if (findings.length === 0) {
|
|
310
|
+
console.log("No treatment word-budget findings.");
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
console.log(`\n${findings.length} treatment word-budget finding(s):`);
|
|
314
|
+
for (const f of findings) {
|
|
315
|
+
console.log(` [${f.rule}] ${f.entryId} ${f.message}`);
|
|
316
|
+
}
|
|
246
317
|
}
|
|
247
318
|
|
|
248
319
|
function requireDirectory(label: string, path: string): void {
|
|
@@ -710,7 +781,12 @@ function printAddressabilityReport(result: AddressabilityGateResult): void {
|
|
|
710
781
|
} else {
|
|
711
782
|
console.log(`\n[addressability] ${result.violations.length} violation(s):`);
|
|
712
783
|
for (const v of result.violations) {
|
|
713
|
-
const where =
|
|
784
|
+
const where =
|
|
785
|
+
v.position === "markup-text"
|
|
786
|
+
? "a markup text node"
|
|
787
|
+
: v.position === "object-literal-copy"
|
|
788
|
+
? `object-literal key "${v.keyPath ?? v.objectKey}"`
|
|
789
|
+
: `the "${v.attribute}" attribute`;
|
|
714
790
|
console.log(` [copy-addressability] ${v.file}:${v.line} inline prose in ${where}, not resolved from the copy registry by id: ${v.raw}`);
|
|
715
791
|
}
|
|
716
792
|
}
|
|
@@ -856,6 +932,39 @@ export function main(argv: string[]): number {
|
|
|
856
932
|
return 2;
|
|
857
933
|
}
|
|
858
934
|
|
|
935
|
+
let recordRaw: unknown;
|
|
936
|
+
try {
|
|
937
|
+
recordRaw = JSON.parse(readFileSync(recordFile, "utf8"));
|
|
938
|
+
} catch (error) {
|
|
939
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
940
|
+
if (!jsonMode) {
|
|
941
|
+
console.error(`\nCopy record could not be re-read for registry parity: ${detail}`);
|
|
942
|
+
console.error("Refusing to report a pass without confirming the render registry store.");
|
|
943
|
+
}
|
|
944
|
+
emitJson(emptyReport(recordFile, scanDir, 2, `copy record could not be re-read: ${detail}`));
|
|
945
|
+
return 2;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
const renderRegistryPath = args.renderRegistryFile ? resolve(args.renderRegistryFile) : undefined;
|
|
949
|
+
if (renderRegistryPath) requireFile("render-registry", renderRegistryPath);
|
|
950
|
+
|
|
951
|
+
const parity = checkRenderRegistryParity(recordFile, recordRaw, renderRegistryPath);
|
|
952
|
+
if (!parity.ok || !parity.registry) {
|
|
953
|
+
if (!jsonMode) {
|
|
954
|
+
console.error(`\nRender registry parity: ${parity.detail ?? parity.reason}`);
|
|
955
|
+
console.error("Refusing to report a pass when the CLI is not reading the same store createCopyResolver uses.");
|
|
956
|
+
}
|
|
957
|
+
emitJson(emptyReport(recordFile, scanDir, 2, parity.detail ?? parity.reason ?? "render registry parity failed"));
|
|
958
|
+
return 2;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
const treatmentBudget = checkTreatmentWordBudgets(parity.registry);
|
|
962
|
+
if (!jsonMode && treatmentBudget.entriesChecked > 0) {
|
|
963
|
+
console.log(
|
|
964
|
+
`Treatment word budgets: ${treatmentBudget.entriesChecked} approved entr${treatmentBudget.entriesChecked === 1 ? "y" : "ies"} with a treatment checked.`,
|
|
965
|
+
);
|
|
966
|
+
}
|
|
967
|
+
|
|
859
968
|
// No argv flag populates `pathExclusions` today — this CLI always calls
|
|
860
969
|
// `scanCopySourceTree` with defaults (`[]`), so `scan.excludedFiles`/
|
|
861
970
|
// `scan.pathExclusionFindings` are always empty in THIS binary as
|
|
@@ -944,6 +1053,66 @@ export function main(argv: string[]): number {
|
|
|
944
1053
|
|
|
945
1054
|
const result = checkCopyTraceability(scan.candidates, scan.citations, read.record, scan.filesScanned, scan.unchecked);
|
|
946
1055
|
if (!jsonMode) printGateReport(result);
|
|
1056
|
+
if (!jsonMode) printTreatmentWordBudgetReport(treatmentBudget.findings);
|
|
1057
|
+
|
|
1058
|
+
const hasTreatmentFindings = treatmentBudget.findings.length > 0;
|
|
1059
|
+
|
|
1060
|
+
if (args.liveTrees.length > 0) {
|
|
1061
|
+
for (const livePath of args.liveTrees) {
|
|
1062
|
+
const resolvedLive = resolve(livePath);
|
|
1063
|
+
if (!liveTreeExists(resolvedLive)) {
|
|
1064
|
+
if (!jsonMode) {
|
|
1065
|
+
console.error(`\nDeclared live-copy tree "${resolvedLive}" does not exist.`);
|
|
1066
|
+
console.error("Refusing to report a pass without scanning every declared live tree.");
|
|
1067
|
+
}
|
|
1068
|
+
emitJson(emptyReport(recordFile, scanDir, 2, `declared live-copy tree missing: ${resolvedLive}`));
|
|
1069
|
+
return 2;
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
const voicePath = resolve(args.voiceRecordFile as string);
|
|
1073
|
+
requireFile("voice-record", voicePath);
|
|
1074
|
+
let voiceRaw: unknown;
|
|
1075
|
+
try {
|
|
1076
|
+
voiceRaw = JSON.parse(readFileSync(voicePath, "utf8"));
|
|
1077
|
+
} catch (error) {
|
|
1078
|
+
if (!jsonMode) console.error(`Voice record could not be loaded: ${error instanceof Error ? error.message : String(error)}`);
|
|
1079
|
+
emitJson(emptyReport(recordFile, scanDir, 2, "voice record could not be loaded"));
|
|
1080
|
+
return 2;
|
|
1081
|
+
}
|
|
1082
|
+
let voiceRecord;
|
|
1083
|
+
try {
|
|
1084
|
+
voiceRecord = parseVoiceRecord(voiceRaw);
|
|
1085
|
+
} catch (error) {
|
|
1086
|
+
if (!jsonMode) console.error(`Voice record is invalid: ${error instanceof Error ? error.message : String(error)}`);
|
|
1087
|
+
emitJson(emptyReport(recordFile, scanDir, 2, "voice record is invalid"));
|
|
1088
|
+
return 2;
|
|
1089
|
+
}
|
|
1090
|
+
const liveScan = scanLiveCopyTrees(voiceRecord, args.liveTrees.map((p) => resolve(p)));
|
|
1091
|
+
if (!jsonMode) {
|
|
1092
|
+
console.log(`Live-copy trees: ${args.liveTrees.length}, ${liveScan.filesScanned} file(s) scanned.`);
|
|
1093
|
+
if (liveScan.findings.length > 0) {
|
|
1094
|
+
console.log(`\n${liveScan.findings.length} live-copy finding(s):`);
|
|
1095
|
+
for (const f of liveScan.findings) console.log(` [${f.rule}] ${f.file}:${f.line} ${f.message}`);
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
if (liveScan.findings.length > 0) {
|
|
1099
|
+
const exitCode: 0 | 1 | 2 = result.unchecked.length > 0 ? 2 : 1;
|
|
1100
|
+
emitJson({
|
|
1101
|
+
recordFile,
|
|
1102
|
+
scanDir,
|
|
1103
|
+
verdict: result.unchecked.length > 0 ? "indeterminate" : "findings",
|
|
1104
|
+
exitCode,
|
|
1105
|
+
filesScanned: scan.filesScanned,
|
|
1106
|
+
candidatesScanned: result.candidatesScanned,
|
|
1107
|
+
matched: result.matched,
|
|
1108
|
+
findings: result.findings,
|
|
1109
|
+
ignored: result.ignored,
|
|
1110
|
+
unchecked: result.unchecked,
|
|
1111
|
+
parseFailures: scan.parseFailures.map((p) => ({ file: p.file, detail: p.detail })),
|
|
1112
|
+
});
|
|
1113
|
+
return exitCode;
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
947
1116
|
|
|
948
1117
|
// `unchecked` wins over everything else in the exit-code decision — see
|
|
949
1118
|
// this file's own top doc comment for why it is a `2`, not a `1`: it
|
|
@@ -963,9 +1132,14 @@ export function main(argv: string[]): number {
|
|
|
963
1132
|
// the SAME object, so a consumer keying on `exitCode`/`verdict` alone
|
|
964
1133
|
// still fails closed exactly as this package intends, while a consumer
|
|
965
1134
|
// that reads further is never forced to throw away what was measured.
|
|
966
|
-
const exitCode: 0 | 1 | 2 =
|
|
1135
|
+
const exitCode: 0 | 1 | 2 =
|
|
1136
|
+
result.unchecked.length > 0 ? 2 : result.findings.length > 0 || hasTreatmentFindings ? 1 : 0;
|
|
967
1137
|
const verdict: CopyTraceabilityReport["verdict"] =
|
|
968
|
-
result.unchecked.length > 0
|
|
1138
|
+
result.unchecked.length > 0
|
|
1139
|
+
? "indeterminate"
|
|
1140
|
+
: result.findings.length > 0 || hasTreatmentFindings
|
|
1141
|
+
? "findings"
|
|
1142
|
+
: "clean";
|
|
969
1143
|
emitJson({
|
|
970
1144
|
recordFile,
|
|
971
1145
|
scanDir,
|
|
@@ -1176,24 +1350,82 @@ registry-text-match escape hatch here, unlike the default command.
|
|
|
1176
1350
|
|
|
1177
1351
|
Options:
|
|
1178
1352
|
--help Print this message and exit 0.
|
|
1353
|
+
--extensions <ext> File extension(s) to scan, including the leading dot (for example .mjs). Repeatable, and each occurrence may itself be a comma-separated list (for example --extensions .mjs,.cjs); values union across both forms. When omitted, default is .ts, .tsx, .js, .jsx. When present, replaces that default. Every value must include the leading dot; a flag that resolves to zero extensions (for example a bare comma) is a usage error, never a vacuous pass.
|
|
1354
|
+
--chrome <file> Persistent chrome (site header, footer, skip link, nav labels) shell or layout file to scan in addition to scan-dir (repeatable; paths relative to scan-dir unless absolute). Each file must exist.
|
|
1355
|
+
--require-chrome Refuse to run (exit 2) when no --chrome file was declared — use when the surface mounts persistent chrome outside scan-dir.
|
|
1179
1356
|
|
|
1180
|
-
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,
|
|
1357
|
+
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, a string position could not be confidently classified, a declared chrome file is missing, or --require-chrome was set without --chrome).
|
|
1181
1358
|
`;
|
|
1182
1359
|
|
|
1360
|
+
const ADDRESSABILITY_EXTENSION_RE = /^\.[a-zA-Z0-9]+$/;
|
|
1361
|
+
|
|
1183
1362
|
interface AddressabilityParsedArgs {
|
|
1184
1363
|
scanDir?: string;
|
|
1364
|
+
/** Set only when the caller passed at least one `--extensions` flag. */
|
|
1365
|
+
extensions?: string[];
|
|
1366
|
+
chromeFiles: string[];
|
|
1367
|
+
requireChrome: boolean;
|
|
1185
1368
|
help: boolean;
|
|
1186
1369
|
}
|
|
1187
1370
|
|
|
1188
1371
|
function parseAddressabilityArgs(argv: string[]): AddressabilityParsedArgs {
|
|
1189
1372
|
let scanDir: string | undefined;
|
|
1373
|
+
const extensions: string[] = [];
|
|
1374
|
+
const chromeFiles: string[] = [];
|
|
1190
1375
|
let help = false;
|
|
1376
|
+
let sawExtensions = false;
|
|
1377
|
+
let requireChrome = false;
|
|
1191
1378
|
|
|
1192
|
-
for (
|
|
1379
|
+
for (let i = 0; i < argv.length; i++) {
|
|
1380
|
+
const arg = argv[i] as string;
|
|
1193
1381
|
if (arg === "--help" || arg === "-h") {
|
|
1194
1382
|
help = true;
|
|
1195
1383
|
continue;
|
|
1196
1384
|
}
|
|
1385
|
+
if (arg === "--require-chrome") {
|
|
1386
|
+
requireChrome = true;
|
|
1387
|
+
continue;
|
|
1388
|
+
}
|
|
1389
|
+
if (arg === "--chrome") {
|
|
1390
|
+
const value = argv[++i];
|
|
1391
|
+
if (value === undefined || value.startsWith("-")) {
|
|
1392
|
+
throw new CliInputError("--chrome requires a file path");
|
|
1393
|
+
}
|
|
1394
|
+
chromeFiles.push(value);
|
|
1395
|
+
continue;
|
|
1396
|
+
}
|
|
1397
|
+
if (arg === "--extensions") {
|
|
1398
|
+
sawExtensions = true;
|
|
1399
|
+
const value = argv[++i];
|
|
1400
|
+
if (value === undefined || value.startsWith("-")) {
|
|
1401
|
+
throw new CliInputError("--extensions requires an extension such as .mjs");
|
|
1402
|
+
}
|
|
1403
|
+
// Repeatable AND comma-separated: `--extensions .mjs,.cjs` and
|
|
1404
|
+
// `--extensions .mjs --extensions .cjs` union to the same set. Every
|
|
1405
|
+
// token from the split is validated individually — a bare comma or a
|
|
1406
|
+
// trailing/leading comma yields an empty token, which is a usage
|
|
1407
|
+
// error below rather than being silently dropped (an --extensions
|
|
1408
|
+
// flag that resolves to zero extensions must never fall back to the
|
|
1409
|
+
// default set — that would be a vacuous pass on the caller's own
|
|
1410
|
+
// explicit-but-empty request).
|
|
1411
|
+
const tokens = value.split(",");
|
|
1412
|
+
if (tokens.length === 0 || tokens.some((token) => token.trim().length === 0)) {
|
|
1413
|
+
throw new CliInputError(
|
|
1414
|
+
`--extensions requires at least one non-empty extension such as .mjs, got ${JSON.stringify(value)}`,
|
|
1415
|
+
);
|
|
1416
|
+
}
|
|
1417
|
+
for (const rawToken of tokens) {
|
|
1418
|
+
const token = rawToken.trim().toLowerCase();
|
|
1419
|
+
if (!token.startsWith(".")) {
|
|
1420
|
+
throw new CliInputError(`--extensions values must include the leading dot, got ${JSON.stringify(token)}`);
|
|
1421
|
+
}
|
|
1422
|
+
if (!ADDRESSABILITY_EXTENSION_RE.test(token)) {
|
|
1423
|
+
throw new CliInputError(`invalid --extensions value ${JSON.stringify(token)}`);
|
|
1424
|
+
}
|
|
1425
|
+
extensions.push(token);
|
|
1426
|
+
}
|
|
1427
|
+
continue;
|
|
1428
|
+
}
|
|
1197
1429
|
if (arg.startsWith("-")) {
|
|
1198
1430
|
throw new CliInputError(`unknown flag "${arg}"`);
|
|
1199
1431
|
}
|
|
@@ -1204,7 +1436,7 @@ function parseAddressabilityArgs(argv: string[]): AddressabilityParsedArgs {
|
|
|
1204
1436
|
}
|
|
1205
1437
|
}
|
|
1206
1438
|
|
|
1207
|
-
return { scanDir, help };
|
|
1439
|
+
return { scanDir, help, extensions: sawExtensions ? extensions : undefined, chromeFiles, requireChrome };
|
|
1208
1440
|
}
|
|
1209
1441
|
|
|
1210
1442
|
/**
|
|
@@ -1223,12 +1455,23 @@ export function mainAddressabilityCheck(argv: string[]): number {
|
|
|
1223
1455
|
const scanDir = resolve(args.scanDir ?? process.cwd());
|
|
1224
1456
|
requireDirectory("scan-dir", scanDir);
|
|
1225
1457
|
|
|
1458
|
+
if (args.requireChrome && args.chromeFiles.length === 0) {
|
|
1459
|
+
throw new CliInputError("--require-chrome was set but no --chrome file was declared");
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1226
1462
|
console.log(`Scan directory: ${scanDir}`);
|
|
1463
|
+
if (args.chromeFiles.length > 0) {
|
|
1464
|
+
console.log(`Chrome files: ${args.chromeFiles.join(", ")}`);
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
const scanOptions: { extensions?: string[]; chromeFiles?: string[] } = {};
|
|
1468
|
+
if (args.extensions) scanOptions.extensions = args.extensions;
|
|
1469
|
+
if (args.chromeFiles.length > 0) scanOptions.chromeFiles = args.chromeFiles;
|
|
1227
1470
|
|
|
1228
1471
|
// Throws (fail-closed) on an unreadable directory, exactly like
|
|
1229
1472
|
// `scanCopySourceTree` — caught by `runAddressabilityCheck()`'s own
|
|
1230
1473
|
// catch-all below.
|
|
1231
|
-
const scan = scanAddressabilitySources(scanDir);
|
|
1474
|
+
const scan = scanAddressabilitySources(scanDir, scanOptions);
|
|
1232
1475
|
printAddressabilityAccounting(scan);
|
|
1233
1476
|
|
|
1234
1477
|
const result = checkAddressability(scan);
|
|
@@ -1291,11 +1534,12 @@ function detectMainModule(): boolean {
|
|
|
1291
1534
|
}
|
|
1292
1535
|
|
|
1293
1536
|
/**
|
|
1294
|
-
* `writer-check`, `writer-check addressability`,
|
|
1295
|
-
*
|
|
1296
|
-
*
|
|
1297
|
-
*
|
|
1298
|
-
*
|
|
1537
|
+
* `writer-check`, `writer-check addressability`, `writer-check passages`,
|
|
1538
|
+
* `writer-check approve`, and `writer-check approval-state` are ONE `bin`
|
|
1539
|
+
* entry, one compiled file, dispatched by an explicit FIRST ARGUMENT — never
|
|
1540
|
+
* by how this file was invoked (its own path, a symlink name, an installed
|
|
1541
|
+
* `bin` shim, ...). That is deliberate: this repository's own root
|
|
1542
|
+
* `package.json` invokes every gate by compiled path
|
|
1299
1543
|
* (`node packages/ui/dist/tokens/contrast-cli.js`,
|
|
1300
1544
|
* `node packages/controller/dist/cli.js`, ...), never by `bin` name, so
|
|
1301
1545
|
* any dispatch keyed on the invoking path/name (a second `bin` entry
|
|
@@ -1305,7 +1549,11 @@ function detectMainModule(): boolean {
|
|
|
1305
1549
|
* exactly the "runs the wrong thing without ever failing" failure mode a
|
|
1306
1550
|
* gate must never have. `rawArgs[0]` is read BEFORE `main()`'s own
|
|
1307
1551
|
* `parseArgs` ever sees the array, so the default command's argument
|
|
1308
|
-
* shape is completely unaffected by
|
|
1552
|
+
* shape is completely unaffected by any subcommand existing at all.
|
|
1553
|
+
* `mainApproveCommand`/`mainApprovalStateCommand` never throw (see their own
|
|
1554
|
+
* doc comments), so — unlike `runAddressabilityCheck`/`runPassagesCheck`
|
|
1555
|
+
* above — their branches assign `process.exitCode` directly, with no
|
|
1556
|
+
* try/catch wrapper of their own to add.
|
|
1309
1557
|
*/
|
|
1310
1558
|
if (detectMainModule()) {
|
|
1311
1559
|
const rawArgs = process.argv.slice(2);
|
|
@@ -1313,6 +1561,10 @@ if (detectMainModule()) {
|
|
|
1313
1561
|
runAddressabilityCheck(rawArgs.slice(1));
|
|
1314
1562
|
} else if (rawArgs[0] === "passages") {
|
|
1315
1563
|
runPassagesCheck(rawArgs.slice(1));
|
|
1564
|
+
} else if (rawArgs[0] === "approve") {
|
|
1565
|
+
process.exitCode = mainApproveCommand(rawArgs.slice(1));
|
|
1566
|
+
} else if (rawArgs[0] === "approval-state") {
|
|
1567
|
+
process.exitCode = mainApprovalStateCommand(rawArgs.slice(1));
|
|
1316
1568
|
} else {
|
|
1317
1569
|
run();
|
|
1318
1570
|
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The option handling and per-field resolution shared by the two reserved
|
|
3
|
+
* copy kinds that resolve literal text from a `CopyRegistry`: the site
|
|
4
|
+
* identity (`site-identity.ts`) and the messaging kit (`messaging-kit.ts`).
|
|
5
|
+
*
|
|
6
|
+
* Both asked the same two questions of every field — are the options
|
|
7
|
+
* well-formed, and does `resolveCopyRef` return literal, non-blank text —
|
|
8
|
+
* and used to answer them with two copies of the same code. They differ only
|
|
9
|
+
* in the words of their messages and in the names of their two own reasons,
|
|
10
|
+
* which the caller passes in as `FieldLabels`.
|
|
11
|
+
*
|
|
12
|
+
* Internal: not exported from the package index.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { resolveCopyRef } from "./resolve.js";
|
|
16
|
+
import type { CopyResolveIssue, CopyResolveIssueReason, CopyResolveOptions } from "./resolve.js";
|
|
17
|
+
import type { CopyRegistry, CopyRegistryEntry, CopyResolution } from "./types.js";
|
|
18
|
+
|
|
19
|
+
export type PreparedOptions =
|
|
20
|
+
| { ok: true; locale: string | undefined; resolveOptions: CopyResolveOptions }
|
|
21
|
+
| { ok: false };
|
|
22
|
+
|
|
23
|
+
/** The words and reasons a caller contributes to the shared resolution. */
|
|
24
|
+
export interface FieldLabels<Reason extends string> {
|
|
25
|
+
/** Subject of the "options are malformed" message, e.g. `"Site identity"`. */
|
|
26
|
+
optionsSubject: string;
|
|
27
|
+
/** Subject of an entry's messages, e.g. `Site identity "site.name"`. */
|
|
28
|
+
entrySubject: (id: string) => string;
|
|
29
|
+
/** Reason reported when an entry declares placeholders or its text would be rewritten. */
|
|
30
|
+
placeholderReason: Reason;
|
|
31
|
+
/** Reason reported when the resolved text is blank after trimming. */
|
|
32
|
+
blankReason: Reason;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** One reason a field could not be resolved, tagged with the caller's field name. */
|
|
36
|
+
export interface FieldIssue<Field extends string, Reason extends string> extends Omit<CopyResolveIssue, "reason"> {
|
|
37
|
+
reason: CopyResolveIssueReason | Reason;
|
|
38
|
+
field: Field;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
42
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Splits `locale` (a ref field) from the options `resolveCopyRef` reads.
|
|
47
|
+
* `resolveCopyRef` only looks at `target`, `acceptDelegateInProduction`
|
|
48
|
+
* and `now`, so only those are forwarded; each property is read once.
|
|
49
|
+
* When `now` is absent it is fixed here once, so every field is judged
|
|
50
|
+
* against the same instant. The forwarded values are not validated here —
|
|
51
|
+
* `resolveCopyRef` still does that, and refuses a malformed one.
|
|
52
|
+
*/
|
|
53
|
+
export function prepareOptions(options: unknown): PreparedOptions {
|
|
54
|
+
if (options === undefined) return { ok: true, locale: undefined, resolveOptions: { now: new Date() } };
|
|
55
|
+
if (!isPlainObject(options)) return { ok: false };
|
|
56
|
+
try {
|
|
57
|
+
const { locale, target, acceptDelegateInProduction, now } = options;
|
|
58
|
+
if (locale !== undefined && (typeof locale !== "string" || locale.trim().length === 0)) return { ok: false };
|
|
59
|
+
const resolveOptions: Record<string, unknown> = { now: now === undefined ? new Date() : now };
|
|
60
|
+
if (target !== undefined) resolveOptions.target = target;
|
|
61
|
+
if (acceptDelegateInProduction !== undefined) resolveOptions.acceptDelegateInProduction = acceptDelegateInProduction;
|
|
62
|
+
return { ok: true, locale, resolveOptions: resolveOptions as CopyResolveOptions };
|
|
63
|
+
} catch {
|
|
64
|
+
return { ok: false };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Resolves one reserved id as literal text. Every `resolveCopyRef` refusal is
|
|
70
|
+
* kept with its own reason, except that the placeholder-value reasons surface
|
|
71
|
+
* as `labels.placeholderReason`, once per field. Text that is blank after
|
|
72
|
+
* trimming, or an entry that declares placeholders or whose text the
|
|
73
|
+
* resolver would rewrite, is refused too. Never throws.
|
|
74
|
+
*/
|
|
75
|
+
export function resolveField<Field extends string, Reason extends string>(
|
|
76
|
+
registry: unknown,
|
|
77
|
+
field: Field,
|
|
78
|
+
id: string,
|
|
79
|
+
prepared: PreparedOptions,
|
|
80
|
+
labels: FieldLabels<Reason>,
|
|
81
|
+
): { resolution?: CopyResolution; issues: FieldIssue<Field, Reason>[] } {
|
|
82
|
+
const subject = labels.entrySubject(id);
|
|
83
|
+
if (!prepared.ok) {
|
|
84
|
+
return { issues: [{ reason: "invalid-options", field, id, message: `${labels.optionsSubject} options are malformed.` }] };
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
const ref = prepared.locale === undefined ? { id } : { id, locale: prepared.locale };
|
|
88
|
+
const result = resolveCopyRef(registry, ref, prepared.resolveOptions);
|
|
89
|
+
if (!result.complete || !result.resolution) {
|
|
90
|
+
const issues: FieldIssue<Field, Reason>[] = [];
|
|
91
|
+
let placeholderReported = false;
|
|
92
|
+
for (const issue of result.issues) {
|
|
93
|
+
if (issue.reason === "missing-placeholder-value" || issue.reason === "unexpected-placeholder-value") {
|
|
94
|
+
if (placeholderReported) continue;
|
|
95
|
+
placeholderReported = true;
|
|
96
|
+
issues.push({
|
|
97
|
+
reason: labels.placeholderReason,
|
|
98
|
+
field,
|
|
99
|
+
id,
|
|
100
|
+
message: `${subject} must be literal text; an entry that declares placeholders cannot be used.`,
|
|
101
|
+
});
|
|
102
|
+
} else {
|
|
103
|
+
issues.push({ ...issue, field });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (issues.length === 0) {
|
|
107
|
+
issues.push({ reason: "invalid-registry", field, id, message: `${subject} could not be resolved.` });
|
|
108
|
+
}
|
|
109
|
+
return { issues };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const resolution = result.resolution;
|
|
113
|
+
if (resolution.text.trim().length === 0) {
|
|
114
|
+
return { issues: [{ reason: labels.blankReason, field, id, message: `${subject} resolved to blank text.` }] };
|
|
115
|
+
}
|
|
116
|
+
// The registry validated (resolution completed), so `entries` is a real array.
|
|
117
|
+
const entry = (registry as CopyRegistry).entries.find((candidate: CopyRegistryEntry) => candidate.id === id);
|
|
118
|
+
if (entry && ((entry.placeholders?.length ?? 0) > 0 || entry.text !== resolution.text)) {
|
|
119
|
+
return {
|
|
120
|
+
issues: [
|
|
121
|
+
{
|
|
122
|
+
reason: labels.placeholderReason,
|
|
123
|
+
field,
|
|
124
|
+
id,
|
|
125
|
+
message: `${subject} must be literal text; its entry declares placeholders or contains text the resolver would rewrite.`,
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
return { resolution, issues: [] };
|
|
131
|
+
} catch {
|
|
132
|
+
return { issues: [{ reason: "invalid-registry", field, id, message: `${subject} could not be resolved.` }] };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in fold wallpaper phrases — generic competitor copy, not consumer-specific.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export const FOLD_WALLPAPER_PHRASES: readonly string[] = [
|
|
6
|
+
"for founders and operators",
|
|
7
|
+
"ai intelligence",
|
|
8
|
+
"ai-native",
|
|
9
|
+
"for founders",
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
function isAlphanumeric(ch: string): boolean {
|
|
13
|
+
return /[a-z0-9]/i.test(ch);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Case-insensitive, whole-phrase-ish match inside a string literal value. */
|
|
17
|
+
export function findFoldWallpaperPhrase(value: string): string | undefined {
|
|
18
|
+
const haystack = value.toLowerCase();
|
|
19
|
+
const ordered = [...FOLD_WALLPAPER_PHRASES].sort((a, b) => b.length - a.length);
|
|
20
|
+
for (const phrase of ordered) {
|
|
21
|
+
const idx = haystack.indexOf(phrase);
|
|
22
|
+
if (idx === -1) continue;
|
|
23
|
+
const beforeOk = idx === 0 || !isAlphanumeric(haystack[idx - 1] ?? "");
|
|
24
|
+
const afterIdx = idx + phrase.length;
|
|
25
|
+
const afterOk = afterIdx >= haystack.length || !isAlphanumeric(haystack[afterIdx] ?? "");
|
|
26
|
+
if (beforeOk && afterOk) return phrase;
|
|
27
|
+
}
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Compile-time assertions for the front-door copy kind. This file has no
|
|
2
|
+
// runtime footprint and is not exported from `index.ts`; `tsc` compiling it is
|
|
3
|
+
// the check.
|
|
4
|
+
|
|
5
|
+
import { resolveFrontDoorCopy } from "./front-door.js";
|
|
6
|
+
|
|
7
|
+
// @ts-expect-error unknown front-door key
|
|
8
|
+
resolveFrontDoorCopy("front-door.sign-in.heading", {});
|
|
9
|
+
|
|
10
|
+
resolveFrontDoorCopy("front-door.sign-in.title", {});
|