@clossys/writer 0.3.8 → 0.4.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 (56) hide show
  1. package/README.md +103 -4
  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/approved-copy-coverage-rate-cli.d.ts +10 -0
  7. package/dist/approved-copy-coverage-rate-cli.d.ts.map +1 -0
  8. package/dist/approved-copy-coverage-rate-cli.js +59 -0
  9. package/dist/approved-copy-coverage-rate-cli.js.map +1 -0
  10. package/dist/approved-copy-coverage-rate.d.ts +23 -0
  11. package/dist/approved-copy-coverage-rate.d.ts.map +1 -0
  12. package/dist/approved-copy-coverage-rate.js +153 -0
  13. package/dist/approved-copy-coverage-rate.js.map +1 -0
  14. package/dist/cli.d.ts.map +1 -1
  15. package/dist/cli.js +216 -9
  16. package/dist/cli.js.map +1 -1
  17. package/dist/fold-wallpaper-gate.d.ts +7 -0
  18. package/dist/fold-wallpaper-gate.d.ts.map +1 -0
  19. package/dist/fold-wallpaper-gate.js +29 -0
  20. package/dist/fold-wallpaper-gate.js.map +1 -0
  21. package/dist/index.d.ts +6 -0
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +3 -0
  24. package/dist/index.js.map +1 -1
  25. package/dist/live-copy-gate.d.ts +16 -0
  26. package/dist/live-copy-gate.d.ts.map +1 -0
  27. package/dist/live-copy-gate.js +135 -0
  28. package/dist/live-copy-gate.js.map +1 -0
  29. package/dist/render-registry-parity.d.ts +20 -0
  30. package/dist/render-registry-parity.d.ts.map +1 -0
  31. package/dist/render-registry-parity.js +45 -0
  32. package/dist/render-registry-parity.js.map +1 -0
  33. package/dist/schema.d.ts.map +1 -1
  34. package/dist/schema.js +22 -0
  35. package/dist/schema.js.map +1 -1
  36. package/dist/treatment-word-budget.d.ts +31 -0
  37. package/dist/treatment-word-budget.d.ts.map +1 -0
  38. package/dist/treatment-word-budget.js +80 -0
  39. package/dist/treatment-word-budget.js.map +1 -0
  40. package/dist/types.d.ts +11 -0
  41. package/dist/types.d.ts.map +1 -1
  42. package/fit-signals.json +11 -0
  43. package/package.json +202 -6
  44. package/skill/SKILL.md +49 -0
  45. package/src/addressability.ts +186 -4
  46. package/src/approved-copy-coverage-rate-cli.ts +55 -0
  47. package/src/approved-copy-coverage-rate.ts +201 -0
  48. package/src/cli.ts +228 -9
  49. package/src/fold-wallpaper-gate.ts +29 -0
  50. package/src/index.ts +16 -0
  51. package/src/live-copy-gate.ts +150 -0
  52. package/src/render-registry-parity.ts +65 -0
  53. package/src/schema.ts +24 -0
  54. package/src/treatment-word-budget.ts +100 -0
  55. package/src/types.ts +11 -0
  56. package/CHANGELOG.md +0 -307
package/src/cli.ts CHANGED
@@ -132,10 +132,13 @@ import {
132
132
  } from "./addressability.js";
133
133
  import { checkCopyTraceability, type CopyGateFinding, type CopyGateIgnored, type CopyGateResult } from "./copy-gate.js";
134
134
  import { checkLocaleCoverage, type LocaleCoverageReport } from "./locale-coverage.js";
135
+ import { liveTreeExists, scanLiveCopyTrees } from "./live-copy-gate.js";
135
136
  import { checkPassageComposition, readPassageRecord, type PassageGateResult } from "./passage.js";
136
137
  import { readCopyRecord } from "./registry.js";
138
+ import { checkRenderRegistryParity } from "./render-registry-parity.js";
137
139
  import { scanCopySourceTree, type ScanResult, type UncheckedItem } from "./scan.js";
138
- import { checkVoiceDerivationCoverage, type VoiceDerivationCoverageResult } from "./voice/index.js";
140
+ import { checkTreatmentWordBudgets, type TreatmentWordBudgetFinding } from "./treatment-word-budget.js";
141
+ import { checkVoiceDerivationCoverage, parseVoiceRecord, type VoiceDerivationCoverageResult } from "./voice/index.js";
139
142
 
140
143
  const USAGE = `Usage: writer-check <record-file> [scan-dir] [options]
141
144
  or: writer-check voice-derivation-coverage <obligations-file> <brand-derived-rule-ids-file> [options]
@@ -154,6 +157,9 @@ are separate, stricter gates; neither runs as part of this default command.
154
157
 
155
158
  Options:
156
159
  --help Print this message and exit 0.
160
+ --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.
161
+ --live <dir> Declared live-copy tree the page actually renders (repeatable). Requires --voice-record.
162
+ --voice-record <file> Voice record JSON for claims when --live is set.
157
163
  --format <text|json> Output format. Defaults to text. "json" prints exactly one
158
164
  machine-readable object to stdout (see CopyTraceabilityReport in
159
165
  cli.ts) instead of the human-readable report — "verdict" states the
@@ -206,6 +212,9 @@ export class CliInputError extends Error {}
206
212
  interface ParsedArgs {
207
213
  recordFile?: string;
208
214
  scanDir?: string;
215
+ renderRegistryFile?: string;
216
+ liveTrees: string[];
217
+ voiceRecordFile?: string;
209
218
  format: "text" | "json";
210
219
  help: boolean;
211
220
  }
@@ -213,6 +222,9 @@ interface ParsedArgs {
213
222
  function parseArgs(argv: string[]): ParsedArgs {
214
223
  let recordFile: string | undefined;
215
224
  let scanDir: string | undefined;
225
+ let renderRegistryFile: string | undefined;
226
+ const liveTrees: string[] = [];
227
+ let voiceRecordFile: string | undefined;
216
228
  let format: "text" | "json" = "text";
217
229
  let help = false;
218
230
 
@@ -222,6 +234,14 @@ function parseArgs(argv: string[]): ParsedArgs {
222
234
  help = true;
223
235
  continue;
224
236
  }
237
+ if (arg === "--render-registry") {
238
+ const value = argv[++i];
239
+ if (value === undefined || value.startsWith("-")) {
240
+ throw new CliInputError("--render-registry requires a file path");
241
+ }
242
+ renderRegistryFile = value;
243
+ continue;
244
+ }
225
245
  if (arg === "--format") {
226
246
  const value = argv[++i];
227
247
  if (value !== "text" && value !== "json") {
@@ -230,6 +250,18 @@ function parseArgs(argv: string[]): ParsedArgs {
230
250
  format = value;
231
251
  continue;
232
252
  }
253
+ if (arg === "--live") {
254
+ const value = argv[++i];
255
+ if (value === undefined || value.startsWith("-")) throw new CliInputError("--live requires a directory path");
256
+ liveTrees.push(value);
257
+ continue;
258
+ }
259
+ if (arg === "--voice-record") {
260
+ const value = argv[++i];
261
+ if (value === undefined || value.startsWith("-")) throw new CliInputError("--voice-record requires a file path");
262
+ voiceRecordFile = value;
263
+ continue;
264
+ }
233
265
  if (arg.startsWith("-")) {
234
266
  throw new CliInputError(`unknown flag "${arg}"`);
235
267
  }
@@ -242,7 +274,22 @@ function parseArgs(argv: string[]): ParsedArgs {
242
274
  }
243
275
  }
244
276
 
245
- return { recordFile, scanDir, format, help };
277
+ if (liveTrees.length > 0 && !voiceRecordFile) {
278
+ throw new CliInputError("--voice-record is required when --live is declared");
279
+ }
280
+
281
+ return { recordFile, scanDir, renderRegistryFile, liveTrees, voiceRecordFile, format, help };
282
+ }
283
+
284
+ function printTreatmentWordBudgetReport(findings: TreatmentWordBudgetFinding[]): void {
285
+ if (findings.length === 0) {
286
+ console.log("No treatment word-budget findings.");
287
+ return;
288
+ }
289
+ console.log(`\n${findings.length} treatment word-budget finding(s):`);
290
+ for (const f of findings) {
291
+ console.log(` [${f.rule}] ${f.entryId} ${f.message}`);
292
+ }
246
293
  }
247
294
 
248
295
  function requireDirectory(label: string, path: string): void {
@@ -710,7 +757,12 @@ function printAddressabilityReport(result: AddressabilityGateResult): void {
710
757
  } else {
711
758
  console.log(`\n[addressability] ${result.violations.length} violation(s):`);
712
759
  for (const v of result.violations) {
713
- const where = v.position === "markup-text" ? "a markup text node" : `the "${v.attribute}" attribute`;
760
+ const where =
761
+ v.position === "markup-text"
762
+ ? "a markup text node"
763
+ : v.position === "object-literal-copy"
764
+ ? `object-literal key "${v.keyPath ?? v.objectKey}"`
765
+ : `the "${v.attribute}" attribute`;
714
766
  console.log(` [copy-addressability] ${v.file}:${v.line} inline prose in ${where}, not resolved from the copy registry by id: ${v.raw}`);
715
767
  }
716
768
  }
@@ -856,6 +908,39 @@ export function main(argv: string[]): number {
856
908
  return 2;
857
909
  }
858
910
 
911
+ let recordRaw: unknown;
912
+ try {
913
+ recordRaw = JSON.parse(readFileSync(recordFile, "utf8"));
914
+ } catch (error) {
915
+ const detail = error instanceof Error ? error.message : String(error);
916
+ if (!jsonMode) {
917
+ console.error(`\nCopy record could not be re-read for registry parity: ${detail}`);
918
+ console.error("Refusing to report a pass without confirming the render registry store.");
919
+ }
920
+ emitJson(emptyReport(recordFile, scanDir, 2, `copy record could not be re-read: ${detail}`));
921
+ return 2;
922
+ }
923
+
924
+ const renderRegistryPath = args.renderRegistryFile ? resolve(args.renderRegistryFile) : undefined;
925
+ if (renderRegistryPath) requireFile("render-registry", renderRegistryPath);
926
+
927
+ const parity = checkRenderRegistryParity(recordFile, recordRaw, renderRegistryPath);
928
+ if (!parity.ok || !parity.registry) {
929
+ if (!jsonMode) {
930
+ console.error(`\nRender registry parity: ${parity.detail ?? parity.reason}`);
931
+ console.error("Refusing to report a pass when the CLI is not reading the same store createCopyResolver uses.");
932
+ }
933
+ emitJson(emptyReport(recordFile, scanDir, 2, parity.detail ?? parity.reason ?? "render registry parity failed"));
934
+ return 2;
935
+ }
936
+
937
+ const treatmentBudget = checkTreatmentWordBudgets(parity.registry);
938
+ if (!jsonMode && treatmentBudget.entriesChecked > 0) {
939
+ console.log(
940
+ `Treatment word budgets: ${treatmentBudget.entriesChecked} approved entr${treatmentBudget.entriesChecked === 1 ? "y" : "ies"} with a treatment checked.`,
941
+ );
942
+ }
943
+
859
944
  // No argv flag populates `pathExclusions` today — this CLI always calls
860
945
  // `scanCopySourceTree` with defaults (`[]`), so `scan.excludedFiles`/
861
946
  // `scan.pathExclusionFindings` are always empty in THIS binary as
@@ -944,6 +1029,66 @@ export function main(argv: string[]): number {
944
1029
 
945
1030
  const result = checkCopyTraceability(scan.candidates, scan.citations, read.record, scan.filesScanned, scan.unchecked);
946
1031
  if (!jsonMode) printGateReport(result);
1032
+ if (!jsonMode) printTreatmentWordBudgetReport(treatmentBudget.findings);
1033
+
1034
+ const hasTreatmentFindings = treatmentBudget.findings.length > 0;
1035
+
1036
+ if (args.liveTrees.length > 0) {
1037
+ for (const livePath of args.liveTrees) {
1038
+ const resolvedLive = resolve(livePath);
1039
+ if (!liveTreeExists(resolvedLive)) {
1040
+ if (!jsonMode) {
1041
+ console.error(`\nDeclared live-copy tree "${resolvedLive}" does not exist.`);
1042
+ console.error("Refusing to report a pass without scanning every declared live tree.");
1043
+ }
1044
+ emitJson(emptyReport(recordFile, scanDir, 2, `declared live-copy tree missing: ${resolvedLive}`));
1045
+ return 2;
1046
+ }
1047
+ }
1048
+ const voicePath = resolve(args.voiceRecordFile as string);
1049
+ requireFile("voice-record", voicePath);
1050
+ let voiceRaw: unknown;
1051
+ try {
1052
+ voiceRaw = JSON.parse(readFileSync(voicePath, "utf8"));
1053
+ } catch (error) {
1054
+ if (!jsonMode) console.error(`Voice record could not be loaded: ${error instanceof Error ? error.message : String(error)}`);
1055
+ emitJson(emptyReport(recordFile, scanDir, 2, "voice record could not be loaded"));
1056
+ return 2;
1057
+ }
1058
+ let voiceRecord;
1059
+ try {
1060
+ voiceRecord = parseVoiceRecord(voiceRaw);
1061
+ } catch (error) {
1062
+ if (!jsonMode) console.error(`Voice record is invalid: ${error instanceof Error ? error.message : String(error)}`);
1063
+ emitJson(emptyReport(recordFile, scanDir, 2, "voice record is invalid"));
1064
+ return 2;
1065
+ }
1066
+ const liveScan = scanLiveCopyTrees(voiceRecord, args.liveTrees.map((p) => resolve(p)));
1067
+ if (!jsonMode) {
1068
+ console.log(`Live-copy trees: ${args.liveTrees.length}, ${liveScan.filesScanned} file(s) scanned.`);
1069
+ if (liveScan.findings.length > 0) {
1070
+ console.log(`\n${liveScan.findings.length} live-copy finding(s):`);
1071
+ for (const f of liveScan.findings) console.log(` [${f.rule}] ${f.file}:${f.line} ${f.message}`);
1072
+ }
1073
+ }
1074
+ if (liveScan.findings.length > 0) {
1075
+ const exitCode: 0 | 1 | 2 = result.unchecked.length > 0 ? 2 : 1;
1076
+ emitJson({
1077
+ recordFile,
1078
+ scanDir,
1079
+ verdict: result.unchecked.length > 0 ? "indeterminate" : "findings",
1080
+ exitCode,
1081
+ filesScanned: scan.filesScanned,
1082
+ candidatesScanned: result.candidatesScanned,
1083
+ matched: result.matched,
1084
+ findings: result.findings,
1085
+ ignored: result.ignored,
1086
+ unchecked: result.unchecked,
1087
+ parseFailures: scan.parseFailures.map((p) => ({ file: p.file, detail: p.detail })),
1088
+ });
1089
+ return exitCode;
1090
+ }
1091
+ }
947
1092
 
948
1093
  // `unchecked` wins over everything else in the exit-code decision — see
949
1094
  // this file's own top doc comment for why it is a `2`, not a `1`: it
@@ -963,9 +1108,14 @@ export function main(argv: string[]): number {
963
1108
  // the SAME object, so a consumer keying on `exitCode`/`verdict` alone
964
1109
  // still fails closed exactly as this package intends, while a consumer
965
1110
  // 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;
1111
+ const exitCode: 0 | 1 | 2 =
1112
+ result.unchecked.length > 0 ? 2 : result.findings.length > 0 || hasTreatmentFindings ? 1 : 0;
967
1113
  const verdict: CopyTraceabilityReport["verdict"] =
968
- result.unchecked.length > 0 ? "indeterminate" : result.findings.length > 0 ? "findings" : "clean";
1114
+ result.unchecked.length > 0
1115
+ ? "indeterminate"
1116
+ : result.findings.length > 0 || hasTreatmentFindings
1117
+ ? "findings"
1118
+ : "clean";
969
1119
  emitJson({
970
1120
  recordFile,
971
1121
  scanDir,
@@ -1176,24 +1326,82 @@ registry-text-match escape hatch here, unlike the default command.
1176
1326
 
1177
1327
  Options:
1178
1328
  --help Print this message and exit 0.
1329
+ --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.
1330
+ --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.
1331
+ --require-chrome Refuse to run (exit 2) when no --chrome file was declared — use when the surface mounts persistent chrome outside scan-dir.
1179
1332
 
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).
1333
+ 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
1334
  `;
1182
1335
 
1336
+ const ADDRESSABILITY_EXTENSION_RE = /^\.[a-zA-Z0-9]+$/;
1337
+
1183
1338
  interface AddressabilityParsedArgs {
1184
1339
  scanDir?: string;
1340
+ /** Set only when the caller passed at least one `--extensions` flag. */
1341
+ extensions?: string[];
1342
+ chromeFiles: string[];
1343
+ requireChrome: boolean;
1185
1344
  help: boolean;
1186
1345
  }
1187
1346
 
1188
1347
  function parseAddressabilityArgs(argv: string[]): AddressabilityParsedArgs {
1189
1348
  let scanDir: string | undefined;
1349
+ const extensions: string[] = [];
1350
+ const chromeFiles: string[] = [];
1190
1351
  let help = false;
1352
+ let sawExtensions = false;
1353
+ let requireChrome = false;
1191
1354
 
1192
- for (const arg of argv) {
1355
+ for (let i = 0; i < argv.length; i++) {
1356
+ const arg = argv[i] as string;
1193
1357
  if (arg === "--help" || arg === "-h") {
1194
1358
  help = true;
1195
1359
  continue;
1196
1360
  }
1361
+ if (arg === "--require-chrome") {
1362
+ requireChrome = true;
1363
+ continue;
1364
+ }
1365
+ if (arg === "--chrome") {
1366
+ const value = argv[++i];
1367
+ if (value === undefined || value.startsWith("-")) {
1368
+ throw new CliInputError("--chrome requires a file path");
1369
+ }
1370
+ chromeFiles.push(value);
1371
+ continue;
1372
+ }
1373
+ if (arg === "--extensions") {
1374
+ sawExtensions = true;
1375
+ const value = argv[++i];
1376
+ if (value === undefined || value.startsWith("-")) {
1377
+ throw new CliInputError("--extensions requires an extension such as .mjs");
1378
+ }
1379
+ // Repeatable AND comma-separated: `--extensions .mjs,.cjs` and
1380
+ // `--extensions .mjs --extensions .cjs` union to the same set. Every
1381
+ // token from the split is validated individually — a bare comma or a
1382
+ // trailing/leading comma yields an empty token, which is a usage
1383
+ // error below rather than being silently dropped (an --extensions
1384
+ // flag that resolves to zero extensions must never fall back to the
1385
+ // default set — that would be a vacuous pass on the caller's own
1386
+ // explicit-but-empty request).
1387
+ const tokens = value.split(",");
1388
+ if (tokens.length === 0 || tokens.some((token) => token.trim().length === 0)) {
1389
+ throw new CliInputError(
1390
+ `--extensions requires at least one non-empty extension such as .mjs, got ${JSON.stringify(value)}`,
1391
+ );
1392
+ }
1393
+ for (const rawToken of tokens) {
1394
+ const token = rawToken.trim().toLowerCase();
1395
+ if (!token.startsWith(".")) {
1396
+ throw new CliInputError(`--extensions values must include the leading dot, got ${JSON.stringify(token)}`);
1397
+ }
1398
+ if (!ADDRESSABILITY_EXTENSION_RE.test(token)) {
1399
+ throw new CliInputError(`invalid --extensions value ${JSON.stringify(token)}`);
1400
+ }
1401
+ extensions.push(token);
1402
+ }
1403
+ continue;
1404
+ }
1197
1405
  if (arg.startsWith("-")) {
1198
1406
  throw new CliInputError(`unknown flag "${arg}"`);
1199
1407
  }
@@ -1204,7 +1412,7 @@ function parseAddressabilityArgs(argv: string[]): AddressabilityParsedArgs {
1204
1412
  }
1205
1413
  }
1206
1414
 
1207
- return { scanDir, help };
1415
+ return { scanDir, help, extensions: sawExtensions ? extensions : undefined, chromeFiles, requireChrome };
1208
1416
  }
1209
1417
 
1210
1418
  /**
@@ -1223,12 +1431,23 @@ export function mainAddressabilityCheck(argv: string[]): number {
1223
1431
  const scanDir = resolve(args.scanDir ?? process.cwd());
1224
1432
  requireDirectory("scan-dir", scanDir);
1225
1433
 
1434
+ if (args.requireChrome && args.chromeFiles.length === 0) {
1435
+ throw new CliInputError("--require-chrome was set but no --chrome file was declared");
1436
+ }
1437
+
1226
1438
  console.log(`Scan directory: ${scanDir}`);
1439
+ if (args.chromeFiles.length > 0) {
1440
+ console.log(`Chrome files: ${args.chromeFiles.join(", ")}`);
1441
+ }
1442
+
1443
+ const scanOptions: { extensions?: string[]; chromeFiles?: string[] } = {};
1444
+ if (args.extensions) scanOptions.extensions = args.extensions;
1445
+ if (args.chromeFiles.length > 0) scanOptions.chromeFiles = args.chromeFiles;
1227
1446
 
1228
1447
  // Throws (fail-closed) on an unreadable directory, exactly like
1229
1448
  // `scanCopySourceTree` — caught by `runAddressabilityCheck()`'s own
1230
1449
  // catch-all below.
1231
- const scan = scanAddressabilitySources(scanDir);
1450
+ const scan = scanAddressabilitySources(scanDir, scanOptions);
1232
1451
  printAddressabilityAccounting(scan);
1233
1452
 
1234
1453
  const result = checkAddressability(scan);
@@ -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
+ }
package/src/index.ts CHANGED
@@ -54,6 +54,12 @@ export { readCopyRecord } from "./registry.js";
54
54
  export type { CopyRegistryReadIssue, CopyRegistryReadIssueReason, CopyRegistryReadResult } from "./registry.js";
55
55
 
56
56
  export { checkCopyRecord } from "./checker.js";
57
+ export { assessApprovedCopyCoverageRate } from "./approved-copy-coverage-rate.js";
58
+ export type {
59
+ ApprovedCopyCoverageRateAssessment,
60
+ ApprovedCopyCoverageRateFinding,
61
+ ApprovedCopyCoverageRateState,
62
+ } from "./approved-copy-coverage-rate.js";
57
63
  export type {
58
64
  CopyEntryCheckResult,
59
65
  CopyEntrySkip,
@@ -117,6 +123,16 @@ export type {
117
123
  AddressabilityViolation,
118
124
  } from "./addressability.js";
119
125
 
126
+ export { checkRenderRegistryParity } from "./render-registry-parity.js";
127
+ export type { RenderRegistryParityReason, RenderRegistryParityResult } from "./render-registry-parity.js";
128
+
129
+ export { DEFAULT_TREATMENT_WORD_BUDGETS, checkTreatmentWordBudgets, countCopyWords } from "./treatment-word-budget.js";
130
+ export type {
131
+ TreatmentWordBudgetFinding,
132
+ TreatmentWordBudgetResult,
133
+ TreatmentWordBudgetRule,
134
+ } from "./treatment-word-budget.js";
135
+
120
136
  // Locale-coverage governance: is every declared locale covered by a source
121
137
  // locale's registry, where has a target locale drifted, has a translation
122
138
  // gone stale against an edited source (via content fingerprint), and does
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Scans declared live-copy trees for claim-shaped and magnitude-shaped prose
3
+ * that is not accounted for in the voice claims register.
4
+ */
5
+
6
+ import { readdirSync, readFileSync, statSync } from "node:fs";
7
+ import { join, resolve } from "node:path";
8
+ import { findFoldWallpaperPhrase } from "./fold-wallpaper-gate.js";
9
+ import { checkCopy, type VoiceFinding, type VoiceRecord } from "./voice/index.js";
10
+
11
+ const LIVE_FILE_RE = /\.(tsx?|jsx?|mdx?|md|json)$/i;
12
+ const IGNORE_MARKER_RE = /(?:<!--|\/\*|\{\/\*|\/\/)\s*(?:copy-gate:ignore|voice-gate:ignore)/i;
13
+ const CLAIM_CITATION_RE = /(?:<!--|\/\*|\{\/\*|\/\/)\s*claim:([a-zA-Z][a-zA-Z0-9-]*)/g;
14
+
15
+ const NUMERIC_CLAIM_RE = new RegExp(
16
+ [
17
+ String.raw`[$€£]\s?\d[\d,]*(?:\.\d+)?\s?[kKmMbB]?\b`,
18
+ String.raw`\b\d+(?:\.\d+)?\s?%`,
19
+ String.raw`\b\d+(?:\.\d+)?[xX]\b`,
20
+ String.raw`\b\d{1,3}(?:,\d{3})+\b`,
21
+ String.raw`\b\d+(?:\.\d+)?\s?(?:million|billion|thousand)\b`,
22
+ ].join("|"),
23
+ "gi",
24
+ );
25
+
26
+ export interface LiveCopyFinding extends VoiceFinding {
27
+ file: string;
28
+ line: number;
29
+ }
30
+
31
+ export interface LiveCopyScanResult {
32
+ filesScanned: number;
33
+ findings: LiveCopyFinding[];
34
+ }
35
+
36
+ function walkLiveTree(root: string): string[] {
37
+ const files: string[] = [];
38
+ const stack = [resolve(root)];
39
+ while (stack.length > 0) {
40
+ const dir = stack.pop() as string;
41
+ let entries;
42
+ try {
43
+ entries = readdirSync(dir, { withFileTypes: true });
44
+ } catch {
45
+ continue;
46
+ }
47
+ for (const entry of entries) {
48
+ const full = join(dir, entry.name);
49
+ if (entry.isDirectory()) {
50
+ if (entry.name === "node_modules" || entry.name === ".git") continue;
51
+ stack.push(full);
52
+ } else if (entry.isFile() && LIVE_FILE_RE.test(entry.name)) {
53
+ files.push(full);
54
+ }
55
+ }
56
+ }
57
+ return files;
58
+ }
59
+
60
+ function extractStringLiterals(text: string): { line: number; value: string }[] {
61
+ const out: { line: number; value: string }[] = [];
62
+ const lines = text.split(/\r?\n/);
63
+ const literalRe = /(["'`])(?:(?!\1)[^\\]|\\.)*\1/g;
64
+ for (let i = 0; i < lines.length; i++) {
65
+ const line = lines[i] as string;
66
+ if (IGNORE_MARKER_RE.test(line)) continue;
67
+ for (const m of line.matchAll(literalRe)) {
68
+ const raw = m[0] as string;
69
+ const value = raw.slice(1, -1);
70
+ if (value.trim().length > 0) out.push({ line: i + 1, value });
71
+ }
72
+ }
73
+ return out;
74
+ }
75
+
76
+ function lineHasClaimCitation(line: string): boolean {
77
+ CLAIM_CITATION_RE.lastIndex = 0;
78
+ return CLAIM_CITATION_RE.test(line);
79
+ }
80
+
81
+ export function scanLiveCopyTrees(record: VoiceRecord, roots: string[]): LiveCopyScanResult {
82
+ const findings: LiveCopyFinding[] = [];
83
+ let filesScanned = 0;
84
+
85
+ for (const root of roots) {
86
+ for (const file of walkLiveTree(root)) {
87
+ let text: string;
88
+ try {
89
+ text = readFileSync(file, "utf8");
90
+ } catch {
91
+ continue;
92
+ }
93
+ filesScanned++;
94
+ const lines = text.split(/\r?\n/);
95
+ for (const { line, value } of extractStringLiterals(text)) {
96
+ const sourceLine = lines[line - 1] ?? "";
97
+ if (lineHasClaimCitation(sourceLine)) continue;
98
+
99
+ const wallpaperPhrase = findFoldWallpaperPhrase(value);
100
+ if (wallpaperPhrase) {
101
+ findings.push({
102
+ rule: "fold-wallpaper",
103
+ severity: "error",
104
+ message: `Fold wallpaper phrase "${wallpaperPhrase}" is generic competitor copy and must not headline the hero.`,
105
+ path: value.slice(0, 80),
106
+ file,
107
+ line,
108
+ });
109
+ }
110
+
111
+ const voiceReport = checkCopy(record, value);
112
+ for (const f of voiceReport.findings) {
113
+ if (f.rule === "claim:unsupported") {
114
+ findings.push({ ...f, file, line });
115
+ }
116
+ }
117
+
118
+ if (NUMERIC_CLAIM_RE.test(value)) {
119
+ NUMERIC_CLAIM_RE.lastIndex = 0;
120
+ const covered = record.claims.some((claim) => {
121
+ if (!claim.requiresSupport) return true;
122
+ if (claim.factRef) return true;
123
+ const phrases = claim.matchPhrases.length > 0 ? claim.matchPhrases : [claim.text];
124
+ return phrases.some((p) => value.toLowerCase().includes(p.toLowerCase()));
125
+ });
126
+ if (!covered) {
127
+ findings.push({
128
+ rule: "live:magnitude-unmarked",
129
+ severity: "error",
130
+ message: `Magnitude-shaped copy is not covered by a supported claim in the voice register.`,
131
+ path: value.slice(0, 80),
132
+ file,
133
+ line,
134
+ });
135
+ }
136
+ }
137
+ }
138
+ }
139
+ }
140
+
141
+ return { filesScanned, findings };
142
+ }
143
+
144
+ export function liveTreeExists(path: string): boolean {
145
+ try {
146
+ return statSync(resolve(path)).isDirectory();
147
+ } catch {
148
+ return false;
149
+ }
150
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Ensures the registry file `writer-check` reads is the same store a surface
3
+ * passes to `createCopyResolver` at render — never a second, request-scoped
4
+ * module the CLI does not scan.
5
+ */
6
+
7
+ import { realpathSync } from "node:fs";
8
+ import { resolve } from "node:path";
9
+ import { validateCopyRegistryShape } from "./schema.js";
10
+ import type { CopyRegistry } from "./types.js";
11
+
12
+ export type RenderRegistryParityReason =
13
+ | "render-registry-path-mismatch"
14
+ | "record-not-copy-registry";
15
+
16
+ export interface RenderRegistryParityResult {
17
+ ok: boolean;
18
+ reason?: RenderRegistryParityReason;
19
+ detail?: string;
20
+ registry?: CopyRegistry;
21
+ }
22
+
23
+ /**
24
+ * Confirms `recordFile` is a `CopyRegistry` and, when `renderRegistryFile`
25
+ * is supplied, that both paths resolve to the same file (the render store
26
+ * and the gate store must be one file).
27
+ */
28
+ export function checkRenderRegistryParity(
29
+ recordFile: string,
30
+ recordValue: unknown,
31
+ renderRegistryFile?: string,
32
+ ): RenderRegistryParityResult {
33
+ const registryFindings = validateCopyRegistryShape(recordValue);
34
+ if (registryFindings.length > 0) {
35
+ const detail = registryFindings.map((f) => f.message).join("; ");
36
+ return {
37
+ ok: false,
38
+ reason: "record-not-copy-registry",
39
+ detail: `record-file is not a CopyRegistry (the store createCopyResolver reads): ${detail}`,
40
+ };
41
+ }
42
+
43
+ if (renderRegistryFile !== undefined) {
44
+ const recordReal = safeRealpath(resolve(recordFile));
45
+ const renderReal = safeRealpath(resolve(renderRegistryFile));
46
+ if (recordReal !== renderReal) {
47
+ return {
48
+ ok: false,
49
+ reason: "render-registry-path-mismatch",
50
+ detail:
51
+ "record-file and --render-registry must be the same file — a second approved store the CLI does not read is not an approved registry.",
52
+ };
53
+ }
54
+ }
55
+
56
+ return { ok: true, registry: recordValue as CopyRegistry };
57
+ }
58
+
59
+ function safeRealpath(path: string): string {
60
+ try {
61
+ return realpathSync(path);
62
+ } catch {
63
+ return path;
64
+ }
65
+ }
package/src/schema.ts CHANGED
@@ -121,6 +121,8 @@ function validateCopyEntryShape(value: unknown, path: string): CopyFinding[] {
121
121
  const context = value.context;
122
122
  const placeholders = value.placeholders;
123
123
  const factRef = value.factRef;
124
+ const treatment = value.treatment;
125
+ const maxWords = value.maxWords;
124
126
 
125
127
  const findings: CopyFinding[] = [];
126
128
 
@@ -189,6 +191,26 @@ function validateCopyEntryShape(value: unknown, path: string): CopyFinding[] {
189
191
  });
190
192
  }
191
193
 
194
+ if (treatment !== undefined && !isNonEmptyString(treatment)) {
195
+ findings.push({
196
+ rule: "treatment-shape",
197
+ severity: "error",
198
+ message: `${path}.treatment must be a non-empty string when present, got ${JSON.stringify(treatment)}.`,
199
+ path: `${path}.treatment`,
200
+ });
201
+ }
202
+
203
+ if (maxWords !== undefined) {
204
+ if (typeof maxWords !== "number" || !Number.isFinite(maxWords) || maxWords < 1) {
205
+ findings.push({
206
+ rule: "max-words-shape",
207
+ severity: "error",
208
+ message: `${path}.maxWords must be a positive finite number when present, got ${JSON.stringify(maxWords)}.`,
209
+ path: `${path}.maxWords`,
210
+ });
211
+ }
212
+ }
213
+
192
214
  return findings;
193
215
  }
194
216
 
@@ -461,6 +483,8 @@ function buildCopyRecord(value: Record<string, unknown>): CopyRecord {
461
483
  context: entry.context as string,
462
484
  placeholders: [...((entry.placeholders as string[] | undefined) ?? [])],
463
485
  factRef: entry.factRef as string | undefined,
486
+ treatment: entry.treatment as string | undefined,
487
+ maxWords: entry.maxWords as number | undefined,
464
488
  };
465
489
  });
466
490