@clossys/writer 0.3.9 → 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.
- package/README.md +60 -4
- 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/cli.d.ts.map +1 -1
- package/dist/cli.js +216 -9
- package/dist/cli.js.map +1 -1
- 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/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -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/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/schema.d.ts.map +1 -1
- package/dist/schema.js +22 -0
- package/dist/schema.js.map +1 -1
- 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 +11 -0
- package/dist/types.d.ts.map +1 -1
- package/fit-signals.json +11 -0
- package/package.json +195 -6
- package/skill/SKILL.md +49 -0
- package/src/addressability.ts +186 -4
- package/src/cli.ts +228 -9
- package/src/fold-wallpaper-gate.ts +29 -0
- package/src/index.ts +10 -0
- package/src/live-copy-gate.ts +150 -0
- package/src/render-registry-parity.ts +65 -0
- package/src/schema.ts +24 -0
- package/src/treatment-word-budget.ts +100 -0
- package/src/types.ts +11 -0
- package/CHANGELOG.md +0 -330
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 {
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
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,
|
|
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 (
|
|
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
|
@@ -123,6 +123,16 @@ export type {
|
|
|
123
123
|
AddressabilityViolation,
|
|
124
124
|
} from "./addressability.js";
|
|
125
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
|
+
|
|
126
136
|
// Locale-coverage governance: is every declared locale covered by a source
|
|
127
137
|
// locale's registry, where has a target locale drifted, has a translation
|
|
128
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
|
|