@clossys/writer 0.3.9 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/README.md +647 -6
  2. package/dist/addressability.d.ts +11 -1
  3. package/dist/addressability.d.ts.map +1 -1
  4. package/dist/addressability.js +169 -3
  5. package/dist/addressability.js.map +1 -1
  6. package/dist/approval-bypass.d.ts +120 -0
  7. package/dist/approval-bypass.d.ts.map +1 -0
  8. package/dist/approval-bypass.js +2318 -0
  9. package/dist/approval-bypass.js.map +1 -0
  10. package/dist/approval.d.ts +62 -0
  11. package/dist/approval.d.ts.map +1 -0
  12. package/dist/approval.js +421 -0
  13. package/dist/approval.js.map +1 -0
  14. package/dist/approve-cli.d.ts +82 -0
  15. package/dist/approve-cli.d.ts.map +1 -0
  16. package/dist/approve-cli.js +430 -0
  17. package/dist/approve-cli.js.map +1 -0
  18. package/dist/cli.d.ts +15 -0
  19. package/dist/cli.d.ts.map +1 -1
  20. package/dist/cli.js +264 -22
  21. package/dist/cli.js.map +1 -1
  22. package/dist/field-options.d.ts +59 -0
  23. package/dist/field-options.d.ts.map +1 -0
  24. package/dist/field-options.js +109 -0
  25. package/dist/field-options.js.map +1 -0
  26. package/dist/fold-wallpaper-gate.d.ts +7 -0
  27. package/dist/fold-wallpaper-gate.d.ts.map +1 -0
  28. package/dist/fold-wallpaper-gate.js +29 -0
  29. package/dist/fold-wallpaper-gate.js.map +1 -0
  30. package/dist/front-door.check.d.ts +2 -0
  31. package/dist/front-door.check.d.ts.map +1 -0
  32. package/dist/front-door.check.js +8 -0
  33. package/dist/front-door.check.js.map +1 -0
  34. package/dist/front-door.d.ts +82 -0
  35. package/dist/front-door.d.ts.map +1 -0
  36. package/dist/front-door.js +271 -0
  37. package/dist/front-door.js.map +1 -0
  38. package/dist/generated/advisor-plan-contract.generated.d.ts +3 -0
  39. package/dist/generated/advisor-plan-contract.generated.d.ts.map +1 -0
  40. package/dist/generated/advisor-plan-contract.generated.js +430 -0
  41. package/dist/generated/advisor-plan-contract.generated.js.map +1 -0
  42. package/dist/generated/contract-schema.generated.d.ts +97 -0
  43. package/dist/generated/contract-schema.generated.d.ts.map +1 -0
  44. package/dist/generated/contract-schema.generated.js +496 -0
  45. package/dist/generated/contract-schema.generated.js.map +1 -0
  46. package/dist/index.d.ts +18 -2
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +26 -0
  49. package/dist/index.js.map +1 -1
  50. package/dist/live-copy-gate.d.ts +16 -0
  51. package/dist/live-copy-gate.d.ts.map +1 -0
  52. package/dist/live-copy-gate.js +135 -0
  53. package/dist/live-copy-gate.js.map +1 -0
  54. package/dist/messaging-kit.d.ts +113 -0
  55. package/dist/messaging-kit.d.ts.map +1 -0
  56. package/dist/messaging-kit.js +121 -0
  57. package/dist/messaging-kit.js.map +1 -0
  58. package/dist/passage.d.ts +2 -1
  59. package/dist/passage.d.ts.map +1 -1
  60. package/dist/passage.js +2 -1
  61. package/dist/passage.js.map +1 -1
  62. package/dist/plan-authority.d.ts +73 -0
  63. package/dist/plan-authority.d.ts.map +1 -0
  64. package/dist/plan-authority.js +126 -0
  65. package/dist/plan-authority.js.map +1 -0
  66. package/dist/plan-contract.d.ts +16 -0
  67. package/dist/plan-contract.d.ts.map +1 -0
  68. package/dist/plan-contract.js +33 -0
  69. package/dist/plan-contract.js.map +1 -0
  70. package/dist/plan-digest.d.ts +23 -0
  71. package/dist/plan-digest.d.ts.map +1 -0
  72. package/dist/plan-digest.js +82 -0
  73. package/dist/plan-digest.js.map +1 -0
  74. package/dist/plan-rules.d.ts +54 -0
  75. package/dist/plan-rules.d.ts.map +1 -0
  76. package/dist/plan-rules.js +145 -0
  77. package/dist/plan-rules.js.map +1 -0
  78. package/dist/render-registry-parity.d.ts +20 -0
  79. package/dist/render-registry-parity.d.ts.map +1 -0
  80. package/dist/render-registry-parity.js +45 -0
  81. package/dist/render-registry-parity.js.map +1 -0
  82. package/dist/resolve.d.ts +40 -6
  83. package/dist/resolve.d.ts.map +1 -1
  84. package/dist/resolve.js +144 -18
  85. package/dist/resolve.js.map +1 -1
  86. package/dist/scan.d.ts +32 -0
  87. package/dist/scan.d.ts.map +1 -1
  88. package/dist/scan.js +67 -6
  89. package/dist/scan.js.map +1 -1
  90. package/dist/schema.d.ts +1 -0
  91. package/dist/schema.d.ts.map +1 -1
  92. package/dist/schema.js +231 -5
  93. package/dist/schema.js.map +1 -1
  94. package/dist/site-identity.d.ts +88 -0
  95. package/dist/site-identity.d.ts.map +1 -0
  96. package/dist/site-identity.js +82 -0
  97. package/dist/site-identity.js.map +1 -0
  98. package/dist/treatment-word-budget.d.ts +31 -0
  99. package/dist/treatment-word-budget.d.ts.map +1 -0
  100. package/dist/treatment-word-budget.js +80 -0
  101. package/dist/treatment-word-budget.js.map +1 -0
  102. package/dist/types.d.ts +76 -7
  103. package/dist/types.d.ts.map +1 -1
  104. package/dist/types.js +9 -7
  105. package/dist/types.js.map +1 -1
  106. package/fit-signals.json +11 -0
  107. package/package.json +201 -11
  108. package/skill/SKILL.md +49 -0
  109. package/src/addressability.ts +186 -4
  110. package/src/approval-bypass.ts +2263 -0
  111. package/src/approval.ts +491 -0
  112. package/src/approve-cli.ts +474 -0
  113. package/src/cli.ts +274 -22
  114. package/src/field-options.ts +134 -0
  115. package/src/fold-wallpaper-gate.ts +29 -0
  116. package/src/front-door.check.ts +10 -0
  117. package/src/front-door.ts +312 -0
  118. package/src/generated/advisor-plan-contract.generated.ts +430 -0
  119. package/src/generated/contract-schema.generated.ts +520 -0
  120. package/src/index.ts +107 -1
  121. package/src/live-copy-gate.ts +150 -0
  122. package/src/messaging-kit.ts +195 -0
  123. package/src/passage.ts +2 -1
  124. package/src/plan-authority.ts +160 -0
  125. package/src/plan-contract.ts +45 -0
  126. package/src/plan-digest.ts +81 -0
  127. package/src/plan-rules.ts +179 -0
  128. package/src/render-registry-parity.ts +65 -0
  129. package/src/resolve.ts +185 -19
  130. package/src/scan.ts +97 -6
  131. package/src/schema.ts +247 -5
  132. package/src/site-identity.ts +125 -0
  133. package/src/treatment-word-budget.ts +100 -0
  134. package/src/types.ts +83 -7
  135. package/templates/front-door.en.json +426 -0
  136. package/CHANGELOG.md +0 -330
package/src/scan.ts CHANGED
@@ -634,6 +634,14 @@ interface TokenizeResult {
634
634
  literals: Literal[];
635
635
  jsxTexts: JsxTextRun[];
636
636
  unchecked: RawUnchecked[];
637
+ /**
638
+ * Half-open `[start, end)` ranges of `content` that are NOT code: every
639
+ * comment, every string literal, every template literal's static segments
640
+ * (never its `${...}` interpolations, which are code), every regex literal
641
+ * body, and every JSX text run. Read only by `maskNonCode` below; every
642
+ * other caller ignores it.
643
+ */
644
+ opaque: Array<[number, number]>;
637
645
  /** Set when the scanner could not reach a consistent end-of-file state (unterminated string/template/comment, or a regex heuristic that desynced quote tracking). */
638
646
  failure?: string;
639
647
  }
@@ -687,6 +695,7 @@ function tokenize(content: string, isJsxFile: boolean): TokenizeResult {
687
695
  const literals: Literal[] = [];
688
696
  const jsxTexts: JsxTextRun[] = [];
689
697
  const unchecked: RawUnchecked[] = [];
698
+ const opaque: Array<[number, number]> = [];
690
699
  const n = content.length;
691
700
  let i = 0;
692
701
  let line = 1;
@@ -745,6 +754,7 @@ function tokenize(content: string, isJsxFile: boolean): TokenizeResult {
745
754
  }
746
755
  if (ck === '"' || ck === "'") {
747
756
  const q = ck;
757
+ const stringStart = j;
748
758
  j++;
749
759
  while (j < n && content[j] !== q) {
750
760
  if (content[j] === "\\") j++;
@@ -752,6 +762,7 @@ function tokenize(content: string, isJsxFile: boolean): TokenizeResult {
752
762
  j++;
753
763
  }
754
764
  j++; // consume closing quote, or run past EOF — caught by the outer unterminated-template check
765
+ opaque.push([stringStart, Math.min(j, n)]);
755
766
  continue;
756
767
  }
757
768
  if (ck === "`") {
@@ -762,6 +773,7 @@ function tokenize(content: string, isJsxFile: boolean): TokenizeResult {
762
773
  }
763
774
  if (ck === "/" && content[j + 1] === "/") {
764
775
  const nl = content.indexOf("\n", j);
776
+ opaque.push([j, nl === -1 ? n : nl]);
765
777
  j = nl === -1 ? n : nl;
766
778
  continue;
767
779
  }
@@ -769,6 +781,7 @@ function tokenize(content: string, isJsxFile: boolean): TokenizeResult {
769
781
  const close = content.indexOf("*/", j + 2);
770
782
  if (close === -1) return -1;
771
783
  advanceLine(j, close + 2);
784
+ opaque.push([j, close + 2]);
772
785
  j = close + 2;
773
786
  continue;
774
787
  }
@@ -780,22 +793,28 @@ function tokenize(content: string, isJsxFile: boolean): TokenizeResult {
780
793
  /** Skips an entire template literal starting at `pos` (`content[pos] === "\`"`), including every interpolation it contains, recursively. Returns the index one past its closing backtick, or `-1` if unterminated. */
781
794
  function skipTemplateFrom(pos: number): number {
782
795
  let j = pos + 1;
796
+ let segmentStart = pos;
783
797
  while (j < n) {
784
798
  const cj = content[j] as string;
785
799
  if (cj === "\\" && j + 1 < n) {
786
800
  j += 2;
787
801
  continue;
788
802
  }
789
- if (cj === "`") return j + 1;
803
+ if (cj === "`") {
804
+ opaque.push([segmentStart, j + 1]);
805
+ return j + 1;
806
+ }
790
807
  if (cj === "\n") {
791
808
  line++;
792
809
  j++;
793
810
  continue;
794
811
  }
795
812
  if (cj === "$" && content[j + 1] === "{") {
813
+ opaque.push([segmentStart, j]);
796
814
  const next = skipInterpolation(j + 2);
797
815
  if (next === -1) return -1;
798
816
  j = next;
817
+ segmentStart = j;
799
818
  continue;
800
819
  }
801
820
  j++;
@@ -858,6 +877,7 @@ function tokenize(content: string, isJsxFile: boolean): TokenizeResult {
858
877
  literalFailure = `unterminated string starting at line ${startLine}`;
859
878
  return -1;
860
879
  }
880
+ opaque.push([start, j]);
861
881
  literals.push({
862
882
  kind: "string",
863
883
  line: startLine,
@@ -892,6 +912,7 @@ function tokenize(content: string, isJsxFile: boolean): TokenizeResult {
892
912
  let j = pos + 1;
893
913
  let current = "";
894
914
  let closed = false;
915
+ let segmentStart = pos;
895
916
  while (j < n) {
896
917
  const cj = content[j] as string;
897
918
  if (cj === "\\" && j + 1 < n) {
@@ -905,6 +926,7 @@ function tokenize(content: string, isJsxFile: boolean): TokenizeResult {
905
926
  break;
906
927
  }
907
928
  if (cj === "$" && content[j + 1] === "{") {
929
+ opaque.push([segmentStart, j]);
908
930
  staticParts.push(unescape(current));
909
931
  current = "";
910
932
  placeholderCount++;
@@ -920,6 +942,7 @@ function tokenize(content: string, isJsxFile: boolean): TokenizeResult {
920
942
  return -1;
921
943
  }
922
944
  j = next;
945
+ segmentStart = j;
923
946
  continue;
924
947
  }
925
948
  if (cj === "\n") line++;
@@ -931,6 +954,7 @@ function tokenize(content: string, isJsxFile: boolean): TokenizeResult {
931
954
  return -1;
932
955
  }
933
956
  staticParts.push(unescape(current));
957
+ opaque.push([segmentStart, j]);
934
958
  literals.push({
935
959
  kind: "template",
936
960
  line: startLine,
@@ -981,6 +1005,7 @@ function tokenize(content: string, isJsxFile: boolean): TokenizeResult {
981
1005
  }
982
1006
  if (cj === "/" && content[j + 1] === "/") {
983
1007
  const nl = content.indexOf("\n", j);
1008
+ opaque.push([j, nl === -1 ? n : nl]);
984
1009
  j = nl === -1 ? n : nl;
985
1010
  continue;
986
1011
  }
@@ -988,6 +1013,7 @@ function tokenize(content: string, isJsxFile: boolean): TokenizeResult {
988
1013
  const close = content.indexOf("*/", j + 2);
989
1014
  if (close === -1) return -1;
990
1015
  advanceLine(j, close + 2);
1016
+ opaque.push([j, close + 2]);
991
1017
  j = close + 2;
992
1018
  continue;
993
1019
  }
@@ -1079,6 +1105,7 @@ function tokenize(content: string, isJsxFile: boolean): TokenizeResult {
1079
1105
  if (found !== -1) {
1080
1106
  let m = found + 1;
1081
1107
  while (m < n && /[a-z]/i.test(content[m] as string)) m++;
1108
+ opaque.push([j, m]);
1082
1109
  j = m;
1083
1110
  localLast = "value";
1084
1111
  continue;
@@ -1116,7 +1143,10 @@ function tokenize(content: string, isJsxFile: boolean): TokenizeResult {
1116
1143
  const flush = (): void => {
1117
1144
  if (j > runStart) {
1118
1145
  const raw = content.slice(runStart, j);
1119
- if (raw.trim().length > 0) jsxTexts.push({ line: runStartLine, raw });
1146
+ if (raw.trim().length > 0) {
1147
+ jsxTexts.push({ line: runStartLine, raw });
1148
+ opaque.push([runStart, j]);
1149
+ }
1120
1150
  }
1121
1151
  };
1122
1152
 
@@ -1268,12 +1298,14 @@ function tokenize(content: string, isJsxFile: boolean): TokenizeResult {
1268
1298
  const entryLiteralsCount = literals.length;
1269
1299
  const entryJsxTextsCount = jsxTexts.length;
1270
1300
  const entryUncheckedCount = unchecked.length;
1301
+ const entryOpaqueCount = opaque.length;
1271
1302
 
1272
1303
  function backtrack(): null {
1273
1304
  line = entryLine;
1274
1305
  literals.length = entryLiteralsCount;
1275
1306
  jsxTexts.length = entryJsxTextsCount;
1276
1307
  unchecked.length = entryUncheckedCount;
1308
+ opaque.length = entryOpaqueCount;
1277
1309
  return null;
1278
1310
  }
1279
1311
 
@@ -1339,6 +1371,7 @@ function tokenize(content: string, isJsxFile: boolean): TokenizeResult {
1339
1371
  // above.
1340
1372
  if (cj === "/" && content[j + 1] === "/") {
1341
1373
  const nl = content.indexOf("\n", j);
1374
+ opaque.push([j, nl === -1 ? n : nl]);
1342
1375
  j = nl === -1 ? n : nl;
1343
1376
  continue;
1344
1377
  }
@@ -1346,6 +1379,7 @@ function tokenize(content: string, isJsxFile: boolean): TokenizeResult {
1346
1379
  const close = content.indexOf("*/", j + 2);
1347
1380
  if (close === -1) return backtrack(); // unterminated block comment — not committed, silently not-JSX
1348
1381
  advanceLine(j, close + 2);
1382
+ opaque.push([j, close + 2]);
1349
1383
  j = close + 2;
1350
1384
  continue;
1351
1385
  }
@@ -1456,6 +1490,7 @@ function tokenize(content: string, isJsxFile: boolean): TokenizeResult {
1456
1490
  // line comment
1457
1491
  if (c === "/" && content[i + 1] === "/") {
1458
1492
  const nl = content.indexOf("\n", i);
1493
+ opaque.push([i, nl === -1 ? n : nl]);
1459
1494
  i = nl === -1 ? n : nl;
1460
1495
  continue;
1461
1496
  }
@@ -1463,8 +1498,9 @@ function tokenize(content: string, isJsxFile: boolean): TokenizeResult {
1463
1498
  // block comment
1464
1499
  if (c === "/" && content[i + 1] === "*") {
1465
1500
  const close = content.indexOf("*/", i + 2);
1466
- if (close === -1) return { literals, jsxTexts, unchecked, failure: `unterminated block comment starting at line ${line}` };
1501
+ if (close === -1) return { literals, jsxTexts, unchecked, opaque, failure: `unterminated block comment starting at line ${line}` };
1467
1502
  advanceLine(i, close + 2);
1503
+ opaque.push([i, close + 2]);
1468
1504
  i = close + 2;
1469
1505
  continue;
1470
1506
  }
@@ -1472,7 +1508,7 @@ function tokenize(content: string, isJsxFile: boolean): TokenizeResult {
1472
1508
  // string literal
1473
1509
  if (c === '"' || c === "'") {
1474
1510
  const end = scanStringLiteral(i);
1475
- if (end === -1) return { literals, jsxTexts, unchecked, failure: literalFailure };
1511
+ if (end === -1) return { literals, jsxTexts, unchecked, opaque, failure: literalFailure };
1476
1512
  lastTokenType = "value";
1477
1513
  i = end;
1478
1514
  continue;
@@ -1481,7 +1517,7 @@ function tokenize(content: string, isJsxFile: boolean): TokenizeResult {
1481
1517
  // template literal
1482
1518
  if (c === "`") {
1483
1519
  const end = scanTemplateLiteral(i);
1484
- if (end === -1) return { literals, jsxTexts, unchecked, failure: literalFailure };
1520
+ if (end === -1) return { literals, jsxTexts, unchecked, opaque, failure: literalFailure };
1485
1521
  lastTokenType = "value";
1486
1522
  i = end;
1487
1523
  continue;
@@ -1540,6 +1576,7 @@ function tokenize(content: string, isJsxFile: boolean): TokenizeResult {
1540
1576
  if (found !== -1) {
1541
1577
  let k = found + 1;
1542
1578
  while (k < n && /[a-z]/i.test(content[k] as string)) k++;
1579
+ opaque.push([i, k]);
1543
1580
  i = k;
1544
1581
  lastTokenType = "value";
1545
1582
  continue;
@@ -1581,7 +1618,7 @@ function tokenize(content: string, isJsxFile: boolean): TokenizeResult {
1581
1618
  i++;
1582
1619
  }
1583
1620
 
1584
- return { literals, jsxTexts, unchecked };
1621
+ return { literals, jsxTexts, unchecked, opaque };
1585
1622
  }
1586
1623
 
1587
1624
  // ---------------------------------------------------------- classification
@@ -1988,3 +2025,57 @@ export function extractCopyCandidates(content: string, filePath: string): Extrac
1988
2025
 
1989
2026
  return { candidates, excluded, citations, unchecked };
1990
2027
  }
2028
+
2029
+ /** One string or template literal `maskNonCode` found, with its position in the original content. */
2030
+ export interface MaskedLiteral {
2031
+ kind: "string" | "template";
2032
+ line: number;
2033
+ /** Index of the opening quote/backtick. */
2034
+ start: number;
2035
+ /** Index one past the closing quote/backtick. */
2036
+ end: number;
2037
+ raw: string;
2038
+ /** The literal's static text, unescaped; a template's interpolations are dropped. */
2039
+ text: string;
2040
+ }
2041
+
2042
+ /** `maskNonCode`'s result. `code` has the same length and line breaks as the input. */
2043
+ export interface CodeMask {
2044
+ code: string;
2045
+ literals: MaskedLiteral[];
2046
+ failure?: string;
2047
+ }
2048
+
2049
+ /**
2050
+ * Pure. Runs this file's tokenizer over `content` and returns a copy of it in
2051
+ * which every comment, string literal, template static segment, regex literal
2052
+ * and (in a `.tsx`/`.jsx` file) JSX text run is replaced by spaces, keeping
2053
+ * every newline and every character offset. A caller can then match code
2054
+ * patterns against `code` without a comment or a string ever matching, and
2055
+ * look up the literal at a given offset through `literals`. A template's
2056
+ * `${...}` interpolations stay visible in `code`, because they are code.
2057
+ *
2058
+ * Exported for `approval-bypass.ts` so it delegates literal and comment
2059
+ * boundaries to this tokenizer instead of growing a second one. Deliberately
2060
+ * not re-exported from `index.ts`: it is an internal helper, not package API.
2061
+ */
2062
+ export function maskNonCode(content: string, filePath: string): CodeMask {
2063
+ const { literals, opaque, failure } = tokenize(content, JSX_FILE_RE.test(filePath));
2064
+ const masked = content.split("");
2065
+ if (!failure) {
2066
+ for (const [start, end] of opaque) {
2067
+ for (let k = start; k < end && k < masked.length; k++) {
2068
+ if (masked[k] !== "\n") masked[k] = " ";
2069
+ }
2070
+ }
2071
+ }
2072
+ const out: MaskedLiteral[] = literals.map((lit) => ({
2073
+ kind: lit.kind,
2074
+ line: lit.line,
2075
+ start: lit.start,
2076
+ end: lit.end,
2077
+ raw: lit.raw,
2078
+ text: lit.staticParts.join(""),
2079
+ }));
2080
+ return failure ? { code: masked.join(""), literals: out, failure } : { code: masked.join(""), literals: out };
2081
+ }
package/src/schema.ts CHANGED
@@ -34,6 +34,8 @@ import type {
34
34
  CopyRecord,
35
35
  CopySource,
36
36
  } from "./types.js";
37
+ import { COPY_FINGERPRINT_ALGORITHM } from "./fingerprint.js";
38
+ import { isDelegateScopeItem, isEntryInDelegateScope } from "./approval.js";
37
39
 
38
40
  function isPlainObject(value: unknown): value is Record<string, unknown> {
39
41
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -121,6 +123,8 @@ function validateCopyEntryShape(value: unknown, path: string): CopyFinding[] {
121
123
  const context = value.context;
122
124
  const placeholders = value.placeholders;
123
125
  const factRef = value.factRef;
126
+ const treatment = value.treatment;
127
+ const maxWords = value.maxWords;
124
128
 
125
129
  const findings: CopyFinding[] = [];
126
130
 
@@ -189,6 +193,26 @@ function validateCopyEntryShape(value: unknown, path: string): CopyFinding[] {
189
193
  });
190
194
  }
191
195
 
196
+ if (treatment !== undefined && !isNonEmptyString(treatment)) {
197
+ findings.push({
198
+ rule: "treatment-shape",
199
+ severity: "error",
200
+ message: `${path}.treatment must be a non-empty string when present, got ${JSON.stringify(treatment)}.`,
201
+ path: `${path}.treatment`,
202
+ });
203
+ }
204
+
205
+ if (maxWords !== undefined) {
206
+ if (typeof maxWords !== "number" || !Number.isFinite(maxWords) || maxWords < 1) {
207
+ findings.push({
208
+ rule: "max-words-shape",
209
+ severity: "error",
210
+ message: `${path}.maxWords must be a positive finite number when present, got ${JSON.stringify(maxWords)}.`,
211
+ path: `${path}.maxWords`,
212
+ });
213
+ }
214
+ }
215
+
192
216
  return findings;
193
217
  }
194
218
 
@@ -361,6 +385,207 @@ function validateTranslationShape(value: unknown, path: string): CopyFinding[] {
361
385
  return findings;
362
386
  }
363
387
 
388
+ /**
389
+ * `YYYY-MM-DDTHH:MM:SS(.sss)?Z` — the one ISO 8601 UTC shape this package
390
+ * accepts for `approvedAt`/`expiresAt`. Also requires `Date.parse` to
391
+ * actually accept it, so a string that merely matches the pattern but names
392
+ * an impossible date (e.g. a bad month) is still rejected.
393
+ */
394
+ const ISO_UTC_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/;
395
+
396
+ export function isIsoUtcTimestamp(value: unknown): value is string {
397
+ if (typeof value !== "string" || !ISO_UTC_TIMESTAMP_RE.test(value)) return false;
398
+ const year = Number(value.slice(0, 4));
399
+ const month = Number(value.slice(5, 7));
400
+ const day = Number(value.slice(8, 10));
401
+ const parsed = Date.parse(value);
402
+ if (Number.isNaN(parsed)) return false;
403
+ const utc = new Date(parsed);
404
+ return utc.getUTCFullYear() === year && utc.getUTCMonth() + 1 === month && utc.getUTCDate() === day;
405
+ }
406
+
407
+ /** `textFingerprint` shape: a `sha256` hex digest — 64 lowercase hex characters. */
408
+ const COPY_FINGERPRINT_RE = /^[0-9a-f]{64}$/;
409
+
410
+ /**
411
+ * Validates a `CopyRegistryEntry.approval` field, when present. `undefined`
412
+ * is always valid, on any entry — an entry with no approval record keeps
413
+ * resolving exactly as it did before this field existed. Staleness (does
414
+ * `textFingerprint` still match the entry's current `text`) and expiry
415
+ * against "now" are runtime concerns for the resolver (`resolve.ts`) and
416
+ * `assessCopyApprovals` (`approval.ts`), never shape findings here: a
417
+ * registry with a stale or expired record is still a well-formed
418
+ * `CopyRegistry`.
419
+ */
420
+ function validateApprovalShape(value: unknown, entryStatus: unknown, entryId: unknown, path: string): CopyFinding[] {
421
+ if (value === undefined) return [];
422
+ if (!isPlainObject(value)) {
423
+ return [
424
+ {
425
+ rule: "approval-shape",
426
+ severity: "error",
427
+ message: `${path} must be an object when present, got ${JSON.stringify(value)}.`,
428
+ path,
429
+ },
430
+ ];
431
+ }
432
+
433
+ const approvedBy = value.approvedBy;
434
+ const approvedAt = value.approvedAt;
435
+ const textFingerprint = value.textFingerprint;
436
+ const fingerprintAlgorithm = value.fingerprintAlgorithm;
437
+ const delegate = value.delegate;
438
+ const pendingOwnerReview = value.pendingOwnerReview;
439
+ const expiresAt = value.expiresAt;
440
+
441
+ const findings: CopyFinding[] = [];
442
+
443
+ if (entryStatus !== "approved") {
444
+ findings.push({
445
+ rule: "approval-status-mismatch",
446
+ severity: "error",
447
+ message: `${path} is present but the entry's status is ${JSON.stringify(entryStatus)}, not "approved".`,
448
+ path,
449
+ });
450
+ }
451
+
452
+ const isDelegateRecord = approvedBy === "delegate";
453
+
454
+ if (approvedBy !== "owner" && approvedBy !== "delegate") {
455
+ findings.push({
456
+ rule: "approval-approved-by",
457
+ severity: "error",
458
+ message: `${path}.approvedBy must be "owner" or "delegate", got ${JSON.stringify(approvedBy)}.`,
459
+ path: `${path}.approvedBy`,
460
+ });
461
+ }
462
+
463
+ if (!isIsoUtcTimestamp(approvedAt)) {
464
+ findings.push({
465
+ rule: "approval-approved-at-shape",
466
+ severity: "error",
467
+ message: `${path}.approvedAt must be an ISO 8601 UTC timestamp, got ${JSON.stringify(approvedAt)}.`,
468
+ path: `${path}.approvedAt`,
469
+ });
470
+ }
471
+
472
+ if (typeof textFingerprint !== "string" || !COPY_FINGERPRINT_RE.test(textFingerprint)) {
473
+ findings.push({
474
+ rule: "approval-fingerprint-shape",
475
+ severity: "error",
476
+ message: `${path}.textFingerprint must be a 64-character lowercase hex string, got ${JSON.stringify(textFingerprint)}.`,
477
+ path: `${path}.textFingerprint`,
478
+ });
479
+ }
480
+
481
+ if (fingerprintAlgorithm !== COPY_FINGERPRINT_ALGORITHM) {
482
+ findings.push({
483
+ rule: "approval-fingerprint-shape",
484
+ severity: "error",
485
+ message: `${path}.fingerprintAlgorithm must be ${JSON.stringify(COPY_FINGERPRINT_ALGORITHM)}, got ${JSON.stringify(fingerprintAlgorithm)}.`,
486
+ path: `${path}.fingerprintAlgorithm`,
487
+ });
488
+ }
489
+
490
+ if (isDelegateRecord && delegate === undefined) {
491
+ findings.push({
492
+ rule: "approval-delegate-required",
493
+ severity: "error",
494
+ message: `${path}.delegate is required when approvedBy is "delegate".`,
495
+ path: `${path}.delegate`,
496
+ });
497
+ }
498
+
499
+ if (approvedBy === "owner") {
500
+ (["delegate", "pendingOwnerReview", "expiresAt"] as const).forEach((field) => {
501
+ if (value[field] !== undefined) {
502
+ findings.push({
503
+ rule: "approval-delegate-forbidden",
504
+ severity: "error",
505
+ message: `${path}.${field} is forbidden when approvedBy is "owner".`,
506
+ path: `${path}.${field}`,
507
+ });
508
+ }
509
+ });
510
+ }
511
+
512
+ // The remaining checks (scope shape, scope membership, pendingOwnerReview,
513
+ // expiresAt) only make sense on an actual delegate record — on an owner
514
+ // record any of these fields is already reported above as forbidden, and
515
+ // shape-checking a field that should not exist would only add noise.
516
+ if (!isDelegateRecord) return findings;
517
+
518
+ let scopeIsValid = false;
519
+ if (!isPlainObject(delegate)) {
520
+ if (delegate !== undefined) {
521
+ findings.push({
522
+ rule: "approval-delegate-scope-shape",
523
+ severity: "error",
524
+ message: `${path}.delegate must be an object, got ${JSON.stringify(delegate)}.`,
525
+ path: `${path}.delegate`,
526
+ });
527
+ }
528
+ } else {
529
+ const delegateId = delegate.id;
530
+ const scope = delegate.scope;
531
+ const idIsValid = isNonEmptyString(delegateId);
532
+ const scopeIsArrayOfItems = Array.isArray(scope) && scope.length > 0 && scope.every((item) => isDelegateScopeItem(item));
533
+ if (!idIsValid || !scopeIsArrayOfItems) {
534
+ findings.push({
535
+ rule: "approval-delegate-scope-shape",
536
+ severity: "error",
537
+ message: `${path}.delegate must have a non-empty string id and a non-empty array scope of well-formed namespace items, got ${JSON.stringify(delegate)}.`,
538
+ path: `${path}.delegate`,
539
+ });
540
+ } else {
541
+ scopeIsValid = true;
542
+ }
543
+
544
+ if (scopeIsValid && typeof entryId === "string" && !isEntryInDelegateScope(entryId, scope as string[])) {
545
+ findings.push({
546
+ rule: "approval-outside-delegate-scope",
547
+ severity: "error",
548
+ message: `${path}.delegate scope ${JSON.stringify(scope)} does not cover entry id ${JSON.stringify(entryId)}.`,
549
+ path: `${path}.delegate`,
550
+ });
551
+ }
552
+ }
553
+
554
+ if (typeof pendingOwnerReview !== "boolean") {
555
+ findings.push({
556
+ rule: "approval-pending-review-shape",
557
+ severity: "error",
558
+ message: `${path}.pendingOwnerReview must be a boolean when approvedBy is "delegate", got ${JSON.stringify(pendingOwnerReview)}.`,
559
+ path: `${path}.pendingOwnerReview`,
560
+ });
561
+ }
562
+
563
+ let expiresAtIsValid = false;
564
+ if (expiresAt !== undefined) {
565
+ if (!isIsoUtcTimestamp(expiresAt)) {
566
+ findings.push({
567
+ rule: "approval-expires-at-shape",
568
+ severity: "error",
569
+ message: `${path}.expiresAt must be an ISO 8601 UTC timestamp when present, got ${JSON.stringify(expiresAt)}.`,
570
+ path: `${path}.expiresAt`,
571
+ });
572
+ } else {
573
+ expiresAtIsValid = true;
574
+ }
575
+ }
576
+
577
+ if (expiresAtIsValid && isIsoUtcTimestamp(approvedAt) && Date.parse(expiresAt as string) <= Date.parse(approvedAt)) {
578
+ findings.push({
579
+ rule: "approval-expires-before-approved",
580
+ severity: "error",
581
+ message: `${path}.expiresAt ${JSON.stringify(expiresAt)} must be strictly after ${path}.approvedAt ${JSON.stringify(approvedAt)}.`,
582
+ path: `${path}.expiresAt`,
583
+ });
584
+ }
585
+
586
+ return findings;
587
+ }
588
+
364
589
  /**
365
590
  * Validates the stronger registry shape used to resolve `CopyRef`s. A plain
366
591
  * `CopyRecord` deliberately remains valid for source scanning and voice
@@ -434,6 +659,7 @@ export function validateCopyRegistryShape(value: unknown): CopyFinding[] {
434
659
  });
435
660
  }
436
661
  findings.push(...validateTranslationShape(entry.translation, `entries.${i}.translation`));
662
+ findings.push(...validateApprovalShape(entry.approval, entry.status, entry.id, `entries.${i}.approval`));
437
663
  });
438
664
  }
439
665
 
@@ -461,6 +687,8 @@ function buildCopyRecord(value: Record<string, unknown>): CopyRecord {
461
687
  context: entry.context as string,
462
688
  placeholders: [...((entry.placeholders as string[] | undefined) ?? [])],
463
689
  factRef: entry.factRef as string | undefined,
690
+ treatment: entry.treatment as string | undefined,
691
+ maxWords: entry.maxWords as number | undefined,
464
692
  };
465
693
  });
466
694
 
@@ -472,11 +700,25 @@ function buildCopyRecord(value: Record<string, unknown>): CopyRecord {
472
700
 
473
701
  function buildCopyRegistry(value: Record<string, unknown>): CopyRegistry {
474
702
  const record = buildCopyRecord(value);
475
- const entries = (value.entries as Array<Record<string, unknown>>).map((entry) => ({
476
- ...record.entries.find((item) => item.id === entry.id)!,
477
- status: entry.status as CopyEntryStatus,
478
- translation: entry.translation as CopyRegistryEntry["translation"],
479
- })) as CopyRegistryEntry[];
703
+ const entries = (value.entries as Array<Record<string, unknown>>).map((entry) => {
704
+ const built: CopyRegistryEntry = {
705
+ ...record.entries.find((item) => item.id === entry.id)!,
706
+ status: entry.status as CopyEntryStatus,
707
+ translation: entry.translation as CopyRegistryEntry["translation"],
708
+ };
709
+ // Only copy `approval` when the input entry actually has one — an
710
+ // `approval: undefined` key would still make `"approval" in entry` true,
711
+ // breaking the "no record at all" case `resolve.ts` and `approval.ts`
712
+ // both depend on. Copied defensively (a fresh `delegate.scope` array) so
713
+ // the parsed registry never aliases the caller's own input object.
714
+ if (entry.approval !== undefined) {
715
+ const approval = entry.approval as CopyRegistryEntry["approval"];
716
+ built.approval = approval?.delegate
717
+ ? { ...approval, delegate: { ...approval.delegate, scope: [...approval.delegate.scope] } }
718
+ : { ...approval! };
719
+ }
720
+ return built;
721
+ });
480
722
 
481
723
  return {
482
724
  ...record,