@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.
- package/README.md +103 -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/approved-copy-coverage-rate-cli.d.ts +10 -0
- package/dist/approved-copy-coverage-rate-cli.d.ts.map +1 -0
- package/dist/approved-copy-coverage-rate-cli.js +59 -0
- package/dist/approved-copy-coverage-rate-cli.js.map +1 -0
- package/dist/approved-copy-coverage-rate.d.ts +23 -0
- package/dist/approved-copy-coverage-rate.d.ts.map +1 -0
- package/dist/approved-copy-coverage-rate.js +153 -0
- package/dist/approved-copy-coverage-rate.js.map +1 -0
- 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 +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -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 +202 -6
- package/skill/SKILL.md +49 -0
- package/src/addressability.ts +186 -4
- package/src/approved-copy-coverage-rate-cli.ts +55 -0
- package/src/approved-copy-coverage-rate.ts +201 -0
- package/src/cli.ts +228 -9
- package/src/fold-wallpaper-gate.ts +29 -0
- package/src/index.ts +16 -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 -307
package/src/addressability.ts
CHANGED
|
@@ -157,8 +157,8 @@
|
|
|
157
157
|
* position classified, and no inline user-facing prose found.
|
|
158
158
|
*/
|
|
159
159
|
|
|
160
|
-
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
161
|
-
import { extname, join, relative, sep } from "node:path";
|
|
160
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
161
|
+
import { extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
162
162
|
import { extractCopyCandidates } from "./scan.js";
|
|
163
163
|
import type { ParseFailure, SkippedFile } from "./scan.js";
|
|
164
164
|
import {
|
|
@@ -182,6 +182,12 @@ export interface AddressabilityScanOptions {
|
|
|
182
182
|
skipDirs?: string[];
|
|
183
183
|
/** Same mechanism `scan.ts`'s `ScanOptions.pathExclusions` uses — see `path-exclusions.ts`. */
|
|
184
184
|
pathExclusions?: PathExclusion[];
|
|
185
|
+
/**
|
|
186
|
+
* Consumer-declared persistent chrome (site header, footer, skip link, nav
|
|
187
|
+
* labels) — shell or layout files scanned in addition to the directory walk.
|
|
188
|
+
* Paths are relative to `root` unless absolute. Each file must exist.
|
|
189
|
+
*/
|
|
190
|
+
chromeFiles?: string[];
|
|
185
191
|
}
|
|
186
192
|
|
|
187
193
|
const DEFAULT_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx"];
|
|
@@ -190,7 +196,7 @@ const DEFAULT_SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", "cov
|
|
|
190
196
|
/** `.test.ts(x)`, `.spec.ts(x)`, `.check.ts(x)`, and bare `.d.ts` — the identical pattern `scan.ts` and `@example/ui`'s `style-scan.ts` both use, reused verbatim rather than reinvented (not exported by `scan.ts`, so redefined locally here — the same precedent `style-scan.ts` sets for its own copy of this constant). */
|
|
191
197
|
const SKIP_FILE_RE = /\.(test|spec|check)\.(ts|tsx|js|jsx)$|\.d\.ts$/;
|
|
192
198
|
|
|
193
|
-
export type AddressabilityPosition = "markup-text" | "user-facing-attribute";
|
|
199
|
+
export type AddressabilityPosition = "markup-text" | "user-facing-attribute" | "object-literal-copy";
|
|
194
200
|
|
|
195
201
|
export interface AddressabilityViolation {
|
|
196
202
|
file: string;
|
|
@@ -198,6 +204,10 @@ export interface AddressabilityViolation {
|
|
|
198
204
|
position: AddressabilityPosition;
|
|
199
205
|
/** Set only for `"user-facing-attribute"` — which of the four this literal was the value of. */
|
|
200
206
|
attribute?: "aria-label" | "placeholder" | "alt" | "title";
|
|
207
|
+
/** Set only for `"object-literal-copy"` — the property key this literal was the value of. */
|
|
208
|
+
objectKey?: string;
|
|
209
|
+
/** Set only for `"object-literal-copy"` — dotted/bracket path when nested under `items`/`nav`/`links`. */
|
|
210
|
+
keyPath?: string;
|
|
201
211
|
/** The literal's own raw source text, for a human auditing the finding. */
|
|
202
212
|
raw: string;
|
|
203
213
|
}
|
|
@@ -316,6 +326,12 @@ export function scanAddressabilitySources(root: string, options: AddressabilityS
|
|
|
316
326
|
|
|
317
327
|
walk(root);
|
|
318
328
|
|
|
329
|
+
if (options.chromeFiles !== undefined) {
|
|
330
|
+
for (const chromePath of options.chromeFiles) {
|
|
331
|
+
scanAddressabilityChromeFile(root, chromePath, extensions, result);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
319
335
|
for (const exclusion of pathExclusionValidation.valid) {
|
|
320
336
|
if ((matchCounts.get(exclusion) ?? 0) === 0) {
|
|
321
337
|
result.pathExclusionFindings.push({
|
|
@@ -330,6 +346,62 @@ export function scanAddressabilitySources(root: string, options: AddressabilityS
|
|
|
330
346
|
return result;
|
|
331
347
|
}
|
|
332
348
|
|
|
349
|
+
function scanAddressabilityChromeFile(
|
|
350
|
+
root: string,
|
|
351
|
+
chromePath: string,
|
|
352
|
+
extensions: Set<string>,
|
|
353
|
+
result: AddressabilityScanResult,
|
|
354
|
+
): void {
|
|
355
|
+
const full = isAbsolute(chromePath) ? resolve(chromePath) : resolve(root, chromePath);
|
|
356
|
+
if (!existsSync(full)) {
|
|
357
|
+
throw new Error(`scanAddressabilitySources: chrome file "${chromePath}" does not exist (resolved "${full}")`);
|
|
358
|
+
}
|
|
359
|
+
let stat;
|
|
360
|
+
try {
|
|
361
|
+
stat = statSync(full);
|
|
362
|
+
} catch (error) {
|
|
363
|
+
throw new Error(
|
|
364
|
+
`scanAddressabilitySources: cannot read chrome file "${chromePath}": ${error instanceof Error ? error.message : String(error)}`,
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
if (!stat.isFile()) {
|
|
368
|
+
throw new Error(`scanAddressabilitySources: chrome path "${chromePath}" is not a file`);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const relPath = relative(root, full).split(sep).join("/");
|
|
372
|
+
const displayPath = relPath.length > 0 && !relPath.startsWith("..") ? relPath : chromePath;
|
|
373
|
+
const entryName = full.slice(full.lastIndexOf(sep) + 1);
|
|
374
|
+
|
|
375
|
+
if (!extensions.has(extname(entryName).toLowerCase())) {
|
|
376
|
+
throw new Error(
|
|
377
|
+
`scanAddressabilitySources: chrome file "${chromePath}" has extension ${extname(entryName)} which is not in the scan extension set`,
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (SKIP_FILE_RE.test(entryName)) {
|
|
382
|
+
result.skippedByDesign.push({ file: displayPath, reason: "test-or-check-file" });
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
let content: string;
|
|
387
|
+
try {
|
|
388
|
+
content = readFileSync(full, "utf8");
|
|
389
|
+
} catch (error) {
|
|
390
|
+
throw new Error(
|
|
391
|
+
`scanAddressabilitySources: cannot read chrome file "${full}": ${error instanceof Error ? error.message : String(error)}`,
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const extracted = extractAddressabilityCandidates(content, displayPath);
|
|
396
|
+
if (extracted.parseFailure) {
|
|
397
|
+
result.parseFailures.push({ file: displayPath, detail: extracted.parseFailure });
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
result.filesScanned++;
|
|
401
|
+
result.violations.push(...extracted.violations);
|
|
402
|
+
result.unchecked.push(...extracted.unchecked);
|
|
403
|
+
}
|
|
404
|
+
|
|
333
405
|
// ------------------------------------------------------------- extraction
|
|
334
406
|
|
|
335
407
|
/** `ariaLabel` -> `arialabel`, `aria-label` -> `arialabel` — the identical case/hyphen-insensitive normalization `scan.ts`'s own `normalizeIdentifier` performs (not exported, so reimplemented locally — a one-line heuristic, not worth a cross-file dependency). */
|
|
@@ -414,6 +486,83 @@ const DESTRUCTURING_OR_PARAMETER_DEFAULT_RE =
|
|
|
414
486
|
* `DESTRUCTURING_OR_PARAMETER_DEFAULT_RE`'s own doc comment for exactly
|
|
415
487
|
* what is and is not matched.
|
|
416
488
|
*/
|
|
489
|
+
/** Copy-bearing object-literal property keys (issue #1063) — normalized like `normalizeAttrName`. */
|
|
490
|
+
const COPY_BEARING_OBJECT_KEYS = new Set([
|
|
491
|
+
"label",
|
|
492
|
+
"title",
|
|
493
|
+
"cta",
|
|
494
|
+
"caption",
|
|
495
|
+
"heading",
|
|
496
|
+
"kicker",
|
|
497
|
+
"body",
|
|
498
|
+
"description",
|
|
499
|
+
"arialabel",
|
|
500
|
+
"text",
|
|
501
|
+
]);
|
|
502
|
+
|
|
503
|
+
/** Non-copy object-literal keys — route targets, icons, tokens; not Writer surfaces. */
|
|
504
|
+
const ALLOWLISTED_OBJECT_LITERAL_KEYS = new Set([
|
|
505
|
+
"href",
|
|
506
|
+
"to",
|
|
507
|
+
"path",
|
|
508
|
+
"pathname",
|
|
509
|
+
"icon",
|
|
510
|
+
"iconname",
|
|
511
|
+
"slug",
|
|
512
|
+
"token",
|
|
513
|
+
"variant",
|
|
514
|
+
"size",
|
|
515
|
+
]);
|
|
516
|
+
|
|
517
|
+
const OBJECT_LITERAL_CONTAINER_KEYS = ["items", "nav", "links"] as const;
|
|
518
|
+
|
|
519
|
+
const OBJECT_LITERAL_PROP_NAME_RE =
|
|
520
|
+
/(?:"([^"]+)"|'([^']+)'|([A-Za-z_$][A-Za-z0-9_$-]*))\s*:\s*$/;
|
|
521
|
+
|
|
522
|
+
function literalStartIndex(content: string, line: number, raw: string): number | undefined {
|
|
523
|
+
const lines = content.split("\n");
|
|
524
|
+
const lineText = lines[line - 1];
|
|
525
|
+
if (lineText === undefined) return undefined;
|
|
526
|
+
const col = lineText.indexOf(raw);
|
|
527
|
+
if (col === -1) return undefined;
|
|
528
|
+
let start = 0;
|
|
529
|
+
for (let i = 0; i < line - 1; i++) start += lines[i]!.length + 1;
|
|
530
|
+
return start + col;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function objectLiteralPropertyName(content: string, start: number): string | undefined {
|
|
534
|
+
const from = Math.max(0, start - 120);
|
|
535
|
+
const before = content.slice(from, start);
|
|
536
|
+
const match = OBJECT_LITERAL_PROP_NAME_RE.exec(before);
|
|
537
|
+
if (!match) return undefined;
|
|
538
|
+
return match[1] ?? match[2] ?? match[3];
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function objectLiteralKeyPath(content: string, start: number, propName: string): string {
|
|
542
|
+
const from = Math.max(0, start - 500);
|
|
543
|
+
const slice = content.slice(from, start);
|
|
544
|
+
for (const container of OBJECT_LITERAL_CONTAINER_KEYS) {
|
|
545
|
+
const re = new RegExp(`\\b${container}\\s*:\\s*\\[`, "g");
|
|
546
|
+
let lastIndex = -1;
|
|
547
|
+
let m: RegExpExecArray | null;
|
|
548
|
+
while ((m = re.exec(slice)) !== null) lastIndex = m.index;
|
|
549
|
+
if (lastIndex !== -1) return `${container}[].${propName}`;
|
|
550
|
+
}
|
|
551
|
+
return propName;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** Route/icon-shaped literal values that are never copy, regardless of key. */
|
|
555
|
+
function looksLikeAllowlistedConstantValue(raw: string): boolean {
|
|
556
|
+
const quote = raw[0];
|
|
557
|
+
if (quote !== '"' && quote !== "'") return false;
|
|
558
|
+
const text = raw.slice(1, -1).trim();
|
|
559
|
+
if (text.length === 0) return false;
|
|
560
|
+
if (/^\/[^\s]*$/.test(text)) return true;
|
|
561
|
+
if (/^#[\w-]+$/.test(text)) return true;
|
|
562
|
+
if (/^https?:\/\//.test(text)) return true;
|
|
563
|
+
return false;
|
|
564
|
+
}
|
|
565
|
+
|
|
417
566
|
function isDestructuringOrParameterDefault(lines: string[], line: number, raw: string): boolean {
|
|
418
567
|
const lineText = lines[line - 1];
|
|
419
568
|
if (lineText === undefined) return false;
|
|
@@ -575,13 +724,46 @@ export function extractAddressabilityCandidates(content: string, filePath: strin
|
|
|
575
724
|
violations.push({ file: c.file, line: c.line, position: "user-facing-attribute", attribute, raw: c.raw });
|
|
576
725
|
}
|
|
577
726
|
|
|
578
|
-
// ---- position
|
|
727
|
+
// ---- position 3a: copy-bearing object-literal property values (issue
|
|
728
|
+
// #1063) — chrome config bags (`{ label: "...", href: "/join" }`) are
|
|
729
|
+
// Writer surfaces; allowlisted keys and route/icon-shaped values are not.
|
|
730
|
+
const objectLiteralClaimed = new Set<string>();
|
|
731
|
+
for (const c of candidates) {
|
|
732
|
+
if (c.kind !== "string") continue;
|
|
733
|
+
if (claimed.has(`${c.line}::${c.raw}`)) continue;
|
|
734
|
+
if (looksLikeUtilityClassList(c.raw)) continue;
|
|
735
|
+
const start = literalStartIndex(content, c.line, c.raw);
|
|
736
|
+
if (start === undefined) continue;
|
|
737
|
+
const propName = objectLiteralPropertyName(content, start);
|
|
738
|
+
if (propName === undefined) continue;
|
|
739
|
+
const normalizedProp = normalizeAttrName(propName);
|
|
740
|
+
if (ALLOWLISTED_OBJECT_LITERAL_KEYS.has(normalizedProp) || looksLikeAllowlistedConstantValue(c.raw)) {
|
|
741
|
+
objectLiteralClaimed.add(`${c.line}::${c.raw}`);
|
|
742
|
+
continue;
|
|
743
|
+
}
|
|
744
|
+
if (!COPY_BEARING_OBJECT_KEYS.has(normalizedProp)) continue;
|
|
745
|
+
if (isDestructuringOrParameterDefault(lines, c.line, c.raw)) continue;
|
|
746
|
+
if (!hasProse(c.raw)) continue;
|
|
747
|
+
const keyPath = objectLiteralKeyPath(content, start, propName);
|
|
748
|
+
objectLiteralClaimed.add(`${c.line}::${c.raw}`);
|
|
749
|
+
violations.push({
|
|
750
|
+
file: c.file,
|
|
751
|
+
line: c.line,
|
|
752
|
+
position: "object-literal-copy",
|
|
753
|
+
objectKey: propName,
|
|
754
|
+
keyPath,
|
|
755
|
+
raw: c.raw,
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// ---- position 3b: everything else scan.ts kept as a real candidate —
|
|
579
760
|
// never silently treated as clean, never treated as a violation either
|
|
580
761
|
// — except a plain string shaped like a utility class LIST, which is
|
|
581
762
|
// definitively not prose (see looksLikeUtilityClassList).
|
|
582
763
|
for (const c of candidates) {
|
|
583
764
|
if (c.kind === "jsx-text") continue; // already position 1
|
|
584
765
|
if (c.kind === "string" && claimed.has(`${c.line}::${c.raw}`)) continue; // already position 2
|
|
766
|
+
if (c.kind === "string" && objectLiteralClaimed.has(`${c.line}::${c.raw}`)) continue;
|
|
585
767
|
if (c.kind === "string" && looksLikeUtilityClassList(c.raw)) continue; // a class-name list, not prose
|
|
586
768
|
unchecked.push({
|
|
587
769
|
file: c.file,
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { assessApprovedCopyCoverageRate } from "./approved-copy-coverage-rate.js";
|
|
6
|
+
|
|
7
|
+
const USAGE = `Usage: writer-rate-check <assessment.json>\n\nAssess approved copy coverage rate from consumer-supplied independent observations.\nExit codes: 0 = satisfied, 1 = violated, 2 = indeterminate or unreadable.\nwriter-check remains the multi-mode CLI and is not this assessment.`;
|
|
8
|
+
|
|
9
|
+
export class WriterRateCliInputError extends Error {}
|
|
10
|
+
|
|
11
|
+
/** Reads caller-owned assessment evidence without treating it as validated. */
|
|
12
|
+
export function readWriterAssessmentJson(path: string): unknown {
|
|
13
|
+
const resolved = resolve(path);
|
|
14
|
+
if (!existsSync(resolved)) throw new WriterRateCliInputError(`assessment file "${path}" does not exist`);
|
|
15
|
+
try {
|
|
16
|
+
if (!statSync(resolved).isFile()) throw new WriterRateCliInputError(`assessment file "${path}" is not a file`);
|
|
17
|
+
return JSON.parse(readFileSync(resolved, "utf8"));
|
|
18
|
+
} catch (cause) {
|
|
19
|
+
if (cause instanceof WriterRateCliInputError) throw cause;
|
|
20
|
+
throw new WriterRateCliInputError(`assessment file "${path}" is unreadable JSON: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Testable CLI dispatcher. Invalid arguments throw; the executable maps them to exit 2. */
|
|
25
|
+
export function main(argv: readonly string[]): number {
|
|
26
|
+
if (argv.length === 1 && (argv[0] === "--help" || argv[0] === "-h")) {
|
|
27
|
+
console.log(USAGE);
|
|
28
|
+
return 0;
|
|
29
|
+
}
|
|
30
|
+
if (argv.length !== 1) throw new WriterRateCliInputError("exactly one assessment.json file is required");
|
|
31
|
+
const report = assessApprovedCopyCoverageRate(readWriterAssessmentJson(argv[0] as string));
|
|
32
|
+
console.log(JSON.stringify(report, null, 2));
|
|
33
|
+
return report.state === "satisfied" ? 0 : report.state === "violated" ? 1 : 2;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function run(): void {
|
|
37
|
+
try {
|
|
38
|
+
process.exitCode = main(process.argv.slice(2));
|
|
39
|
+
} catch (cause) {
|
|
40
|
+
console.error(`writer-rate-check: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
41
|
+
process.exitCode = 2;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Resolves an npm/POSIX bin symlink before deciding whether this module is the entrypoint. */
|
|
46
|
+
export function isDirectInvocation(moduleUrl: string, argvPath: string | undefined): boolean {
|
|
47
|
+
if (argvPath === undefined) return false;
|
|
48
|
+
try {
|
|
49
|
+
return realpathSync(fileURLToPath(moduleUrl)) === realpathSync(resolve(argvPath));
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (isDirectInvocation(import.meta.url, process.argv[1])) run();
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Computes the Writer charter metric `approved copy coverage rate` from
|
|
3
|
+
* consumer-supplied independent observations.
|
|
4
|
+
*
|
|
5
|
+
* This module does not invent observations and does not relabel
|
|
6
|
+
* `checkCopyRecord` or `checkCopyTraceability`. An empty evaluated set is
|
|
7
|
+
* indeterminate, never a perfect rate of 1. `writer-check` remains the
|
|
8
|
+
* multi-mode CLI.
|
|
9
|
+
*/
|
|
10
|
+
const METRIC = "approved copy coverage rate" as const;
|
|
11
|
+
const RESERVED_OBSERVERS = new Set(["writer", "@clossys/writer"]);
|
|
12
|
+
const PROPOSED_POSITIONS: readonly unknown[] = [];
|
|
13
|
+
|
|
14
|
+
export type ApprovedCopyCoverageRateState = "satisfied" | "violated" | "indeterminate";
|
|
15
|
+
|
|
16
|
+
export interface ApprovedCopyCoverageRateFinding {
|
|
17
|
+
readonly rule: string;
|
|
18
|
+
readonly severity: "error";
|
|
19
|
+
readonly message: string;
|
|
20
|
+
readonly path?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ApprovedCopyCoverageRateAssessment {
|
|
24
|
+
readonly metric: "approved copy coverage rate";
|
|
25
|
+
readonly state: ApprovedCopyCoverageRateState;
|
|
26
|
+
readonly rate: number | null;
|
|
27
|
+
readonly evaluatedCopy: number;
|
|
28
|
+
readonly approvedCoveredCopy: number;
|
|
29
|
+
readonly findings: readonly ApprovedCopyCoverageRateFinding[];
|
|
30
|
+
readonly proposedPositions: readonly unknown[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type UnknownRecord = Record<string, unknown>;
|
|
34
|
+
|
|
35
|
+
function record(value: unknown): value is UnknownRecord {
|
|
36
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function text(value: unknown): value is string {
|
|
40
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function timestamp(value: unknown): value is string {
|
|
44
|
+
return text(value) && !Number.isNaN(Date.parse(value));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function finding(rule: string, message: string, path?: string): ApprovedCopyCoverageRateFinding {
|
|
48
|
+
return path === undefined ? { rule, severity: "error", message } : { rule, severity: "error", message, path };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function evidenceCounts(value: unknown): boolean {
|
|
52
|
+
if (!Array.isArray(value) || value.length === 0) return false;
|
|
53
|
+
return value.every((item) => record(item) && text(item.id) && text(item.description));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function reservedObserver(value: string): boolean {
|
|
57
|
+
return RESERVED_OBSERVERS.has(value.trim().toLowerCase());
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function report(
|
|
61
|
+
state: ApprovedCopyCoverageRateState,
|
|
62
|
+
evaluatedCopy: number,
|
|
63
|
+
approvedCoveredCopy: number,
|
|
64
|
+
rate: number | null,
|
|
65
|
+
findings: readonly ApprovedCopyCoverageRateFinding[],
|
|
66
|
+
): ApprovedCopyCoverageRateAssessment {
|
|
67
|
+
return {
|
|
68
|
+
metric: METRIC,
|
|
69
|
+
state,
|
|
70
|
+
rate,
|
|
71
|
+
evaluatedCopy,
|
|
72
|
+
approvedCoveredCopy,
|
|
73
|
+
findings,
|
|
74
|
+
proposedPositions: PROPOSED_POSITIONS,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface CountingObservation {
|
|
79
|
+
readonly approved: boolean;
|
|
80
|
+
readonly traceable: boolean;
|
|
81
|
+
readonly resolvedFromRegistry: boolean;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isCovered(entry: CountingObservation): boolean {
|
|
85
|
+
return entry.approved && entry.traceable && entry.resolvedFromRegistry;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Computes `approved copy coverage rate`: shipped audience-facing copy
|
|
90
|
+
* resolved from approved traceable registry entries / all shipped
|
|
91
|
+
* audience-facing copy evaluated.
|
|
92
|
+
*/
|
|
93
|
+
export function assessApprovedCopyCoverageRate(input: unknown): ApprovedCopyCoverageRateAssessment {
|
|
94
|
+
if (!record(input)) {
|
|
95
|
+
return report("indeterminate", 0, 0, null, [finding("assessment-shape", "Assessment input must be an object.", "$")]);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const findings: ApprovedCopyCoverageRateFinding[] = [];
|
|
99
|
+
if (!timestamp(input.asOf)) {
|
|
100
|
+
findings.push(finding("assessment-as-of", "asOf must be an interpretable timestamp.", "asOf"));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const declaredCopy = input.declaredCopy;
|
|
104
|
+
if (!Array.isArray(declaredCopy)) {
|
|
105
|
+
findings.push(finding("declared-copy-required", "declaredCopy must be an array of { id } objects.", "declaredCopy"));
|
|
106
|
+
return report("indeterminate", 0, 0, null, findings);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const declaredIds: string[] = [];
|
|
110
|
+
const declaredSet = new Set<string>();
|
|
111
|
+
declaredCopy.forEach((item, index) => {
|
|
112
|
+
const path = `declaredCopy[${index}]`;
|
|
113
|
+
if (!record(item) || !text(item.id)) {
|
|
114
|
+
findings.push(finding("copy-id-required", "id must be a non-empty string.", `${path}.id`));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (declaredSet.has(item.id)) {
|
|
118
|
+
findings.push(finding("duplicate-copy-id", `Duplicate id "${item.id}".`, `${path}.id`));
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
declaredSet.add(item.id);
|
|
122
|
+
declaredIds.push(item.id);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
if (declaredIds.length === 0) {
|
|
126
|
+
findings.push(finding("declared-copy-empty", "No declared copy is available for the approved-copy-coverage metric; the rate is indeterminate, never 1.", "declaredCopy"));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const observations = input.observations;
|
|
130
|
+
if (observations !== undefined && !Array.isArray(observations)) {
|
|
131
|
+
findings.push(finding("observations-shape", "observations must be an array.", "observations"));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const counting = new Map<string, CountingObservation[]>();
|
|
135
|
+
if (Array.isArray(observations)) {
|
|
136
|
+
observations.forEach((item, index) => {
|
|
137
|
+
const path = `observations[${index}]`;
|
|
138
|
+
if (!record(item)) {
|
|
139
|
+
findings.push(finding("observation-shape", "An observation must be an object.", path));
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (text(item.observerRef) && reservedObserver(item.observerRef)) {
|
|
143
|
+
findings.push(finding("self-observation", "Writer cannot observe itself; reserved observerRef values are not independent.", `${path}.observerRef`));
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (text(item.copyId) && !declaredSet.has(item.copyId)) {
|
|
147
|
+
findings.push(finding("unknown-copy", `Observation copyId "${item.copyId}" is not declared copy.`, `${path}.copyId`));
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (item.independent !== true) return;
|
|
151
|
+
if (
|
|
152
|
+
typeof item.approved !== "boolean" ||
|
|
153
|
+
typeof item.traceable !== "boolean" ||
|
|
154
|
+
typeof item.resolvedFromRegistry !== "boolean"
|
|
155
|
+
) return;
|
|
156
|
+
if (!text(item.observerRef) || !evidenceCounts(item.evidence) || !text(item.copyId) || !declaredSet.has(item.copyId)) return;
|
|
157
|
+
const bucket = counting.get(item.copyId) ?? [];
|
|
158
|
+
bucket.push({
|
|
159
|
+
approved: item.approved,
|
|
160
|
+
traceable: item.traceable,
|
|
161
|
+
resolvedFromRegistry: item.resolvedFromRegistry,
|
|
162
|
+
});
|
|
163
|
+
counting.set(item.copyId, bucket);
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
let evaluatedCopy = 0;
|
|
168
|
+
let approvedCoveredCopy = 0;
|
|
169
|
+
for (const id of declaredIds) {
|
|
170
|
+
const observed = counting.get(id) ?? [];
|
|
171
|
+
if (observed.length === 0) {
|
|
172
|
+
findings.push(finding("copy-unevaluated", `Declared copy "${id}" has no counting independent observation.`, `declaredCopy.${id}`));
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
evaluatedCopy += 1;
|
|
176
|
+
const allCovered = observed.every((entry) => isCovered(entry));
|
|
177
|
+
if (allCovered) approvedCoveredCopy += 1;
|
|
178
|
+
else {
|
|
179
|
+
findings.push(finding("copy-not-covered", `Declared copy "${id}" was independently observed not resolved from an approved traceable registry entry.`, `declaredCopy.${id}`));
|
|
180
|
+
}
|
|
181
|
+
if (observed.length > 1) {
|
|
182
|
+
const first = observed[0]!;
|
|
183
|
+
const disagree = observed.some((entry) =>
|
|
184
|
+
entry.approved !== first.approved ||
|
|
185
|
+
entry.traceable !== first.traceable ||
|
|
186
|
+
entry.resolvedFromRegistry !== first.resolvedFromRegistry
|
|
187
|
+
);
|
|
188
|
+
if (disagree) {
|
|
189
|
+
findings.push(finding("observation-disagreement", `Counting observations for "${id}" disagree.`, `observations.${id}`));
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const rate = evaluatedCopy === 0 ? null : approvedCoveredCopy / evaluatedCopy;
|
|
195
|
+
if (evaluatedCopy === 0) return report("indeterminate", 0, 0, null, findings);
|
|
196
|
+
const coverageComplete = evaluatedCopy === declaredIds.length;
|
|
197
|
+
const noErrorFindings = findings.length === 0;
|
|
198
|
+
const state: ApprovedCopyCoverageRateState =
|
|
199
|
+
coverageComplete && approvedCoveredCopy === evaluatedCopy && noErrorFindings ? "satisfied" : "violated";
|
|
200
|
+
return report(state, evaluatedCopy, approvedCoveredCopy, rate, findings);
|
|
201
|
+
}
|