@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
@@ -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 3: everything else scan.ts kept as a real candidate —
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
+ }