@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.
Files changed (136) hide show
  1. package/README.md +647 -6
  2. package/dist/addressability.d.ts +11 -1
  3. package/dist/addressability.d.ts.map +1 -1
  4. package/dist/addressability.js +169 -3
  5. package/dist/addressability.js.map +1 -1
  6. package/dist/approval-bypass.d.ts +120 -0
  7. package/dist/approval-bypass.d.ts.map +1 -0
  8. package/dist/approval-bypass.js +2318 -0
  9. package/dist/approval-bypass.js.map +1 -0
  10. package/dist/approval.d.ts +62 -0
  11. package/dist/approval.d.ts.map +1 -0
  12. package/dist/approval.js +421 -0
  13. package/dist/approval.js.map +1 -0
  14. package/dist/approve-cli.d.ts +82 -0
  15. package/dist/approve-cli.d.ts.map +1 -0
  16. package/dist/approve-cli.js +430 -0
  17. package/dist/approve-cli.js.map +1 -0
  18. package/dist/cli.d.ts +15 -0
  19. package/dist/cli.d.ts.map +1 -1
  20. package/dist/cli.js +264 -22
  21. package/dist/cli.js.map +1 -1
  22. package/dist/field-options.d.ts +59 -0
  23. package/dist/field-options.d.ts.map +1 -0
  24. package/dist/field-options.js +109 -0
  25. package/dist/field-options.js.map +1 -0
  26. package/dist/fold-wallpaper-gate.d.ts +7 -0
  27. package/dist/fold-wallpaper-gate.d.ts.map +1 -0
  28. package/dist/fold-wallpaper-gate.js +29 -0
  29. package/dist/fold-wallpaper-gate.js.map +1 -0
  30. package/dist/front-door.check.d.ts +2 -0
  31. package/dist/front-door.check.d.ts.map +1 -0
  32. package/dist/front-door.check.js +8 -0
  33. package/dist/front-door.check.js.map +1 -0
  34. package/dist/front-door.d.ts +82 -0
  35. package/dist/front-door.d.ts.map +1 -0
  36. package/dist/front-door.js +271 -0
  37. package/dist/front-door.js.map +1 -0
  38. package/dist/generated/advisor-plan-contract.generated.d.ts +3 -0
  39. package/dist/generated/advisor-plan-contract.generated.d.ts.map +1 -0
  40. package/dist/generated/advisor-plan-contract.generated.js +430 -0
  41. package/dist/generated/advisor-plan-contract.generated.js.map +1 -0
  42. package/dist/generated/contract-schema.generated.d.ts +97 -0
  43. package/dist/generated/contract-schema.generated.d.ts.map +1 -0
  44. package/dist/generated/contract-schema.generated.js +496 -0
  45. package/dist/generated/contract-schema.generated.js.map +1 -0
  46. package/dist/index.d.ts +18 -2
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +26 -0
  49. package/dist/index.js.map +1 -1
  50. package/dist/live-copy-gate.d.ts +16 -0
  51. package/dist/live-copy-gate.d.ts.map +1 -0
  52. package/dist/live-copy-gate.js +135 -0
  53. package/dist/live-copy-gate.js.map +1 -0
  54. package/dist/messaging-kit.d.ts +113 -0
  55. package/dist/messaging-kit.d.ts.map +1 -0
  56. package/dist/messaging-kit.js +121 -0
  57. package/dist/messaging-kit.js.map +1 -0
  58. package/dist/passage.d.ts +2 -1
  59. package/dist/passage.d.ts.map +1 -1
  60. package/dist/passage.js +2 -1
  61. package/dist/passage.js.map +1 -1
  62. package/dist/plan-authority.d.ts +73 -0
  63. package/dist/plan-authority.d.ts.map +1 -0
  64. package/dist/plan-authority.js +126 -0
  65. package/dist/plan-authority.js.map +1 -0
  66. package/dist/plan-contract.d.ts +16 -0
  67. package/dist/plan-contract.d.ts.map +1 -0
  68. package/dist/plan-contract.js +33 -0
  69. package/dist/plan-contract.js.map +1 -0
  70. package/dist/plan-digest.d.ts +23 -0
  71. package/dist/plan-digest.d.ts.map +1 -0
  72. package/dist/plan-digest.js +82 -0
  73. package/dist/plan-digest.js.map +1 -0
  74. package/dist/plan-rules.d.ts +54 -0
  75. package/dist/plan-rules.d.ts.map +1 -0
  76. package/dist/plan-rules.js +145 -0
  77. package/dist/plan-rules.js.map +1 -0
  78. package/dist/render-registry-parity.d.ts +20 -0
  79. package/dist/render-registry-parity.d.ts.map +1 -0
  80. package/dist/render-registry-parity.js +45 -0
  81. package/dist/render-registry-parity.js.map +1 -0
  82. package/dist/resolve.d.ts +40 -6
  83. package/dist/resolve.d.ts.map +1 -1
  84. package/dist/resolve.js +144 -18
  85. package/dist/resolve.js.map +1 -1
  86. package/dist/scan.d.ts +32 -0
  87. package/dist/scan.d.ts.map +1 -1
  88. package/dist/scan.js +67 -6
  89. package/dist/scan.js.map +1 -1
  90. package/dist/schema.d.ts +1 -0
  91. package/dist/schema.d.ts.map +1 -1
  92. package/dist/schema.js +231 -5
  93. package/dist/schema.js.map +1 -1
  94. package/dist/site-identity.d.ts +88 -0
  95. package/dist/site-identity.d.ts.map +1 -0
  96. package/dist/site-identity.js +82 -0
  97. package/dist/site-identity.js.map +1 -0
  98. package/dist/treatment-word-budget.d.ts +31 -0
  99. package/dist/treatment-word-budget.d.ts.map +1 -0
  100. package/dist/treatment-word-budget.js +80 -0
  101. package/dist/treatment-word-budget.js.map +1 -0
  102. package/dist/types.d.ts +76 -7
  103. package/dist/types.d.ts.map +1 -1
  104. package/dist/types.js +9 -7
  105. package/dist/types.js.map +1 -1
  106. package/fit-signals.json +11 -0
  107. package/package.json +201 -11
  108. package/skill/SKILL.md +49 -0
  109. package/src/addressability.ts +186 -4
  110. package/src/approval-bypass.ts +2263 -0
  111. package/src/approval.ts +491 -0
  112. package/src/approve-cli.ts +474 -0
  113. package/src/cli.ts +274 -22
  114. package/src/field-options.ts +134 -0
  115. package/src/fold-wallpaper-gate.ts +29 -0
  116. package/src/front-door.check.ts +10 -0
  117. package/src/front-door.ts +312 -0
  118. package/src/generated/advisor-plan-contract.generated.ts +430 -0
  119. package/src/generated/contract-schema.generated.ts +520 -0
  120. package/src/index.ts +107 -1
  121. package/src/live-copy-gate.ts +150 -0
  122. package/src/messaging-kit.ts +195 -0
  123. package/src/passage.ts +2 -1
  124. package/src/plan-authority.ts +160 -0
  125. package/src/plan-contract.ts +45 -0
  126. package/src/plan-digest.ts +81 -0
  127. package/src/plan-rules.ts +179 -0
  128. package/src/render-registry-parity.ts +65 -0
  129. package/src/resolve.ts +185 -19
  130. package/src/scan.ts +97 -6
  131. package/src/schema.ts +247 -5
  132. package/src/site-identity.ts +125 -0
  133. package/src/treatment-word-budget.ts +100 -0
  134. package/src/types.ts +83 -7
  135. package/templates/front-door.en.json +426 -0
  136. 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 { checkVoiceDerivationCoverage, type VoiceDerivationCoverageResult } from "./voice/index.js";
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]" 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.
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
- return { recordFile, scanDir, format, help };
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 = v.position === "markup-text" ? "a markup text node" : `the "${v.attribute}" attribute`;
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 = result.unchecked.length > 0 ? 2 : result.findings.length > 0 ? 1 : 0;
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 ? "indeterminate" : result.findings.length > 0 ? "findings" : "clean";
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, or a string position could not be confidently classified).
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 (const arg of argv) {
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`, and `writer-check passages`
1295
- * are ONE `bin` entry, one compiled file, dispatched by an explicit FIRST
1296
- * ARGUMENT — never by how this file was invoked (its own path, a symlink
1297
- * name, an installed `bin` shim, ...). That is deliberate: this
1298
- * repository's own root `package.json` invokes every gate by compiled path
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 either subcommand existing at all.
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", {});