@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.
- package/README.md +647 -6
- 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/approval-bypass.d.ts +120 -0
- package/dist/approval-bypass.d.ts.map +1 -0
- package/dist/approval-bypass.js +2318 -0
- package/dist/approval-bypass.js.map +1 -0
- package/dist/approval.d.ts +62 -0
- package/dist/approval.d.ts.map +1 -0
- package/dist/approval.js +421 -0
- package/dist/approval.js.map +1 -0
- package/dist/approve-cli.d.ts +82 -0
- package/dist/approve-cli.d.ts.map +1 -0
- package/dist/approve-cli.js +430 -0
- package/dist/approve-cli.js.map +1 -0
- package/dist/cli.d.ts +15 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +264 -22
- package/dist/cli.js.map +1 -1
- package/dist/field-options.d.ts +59 -0
- package/dist/field-options.d.ts.map +1 -0
- package/dist/field-options.js +109 -0
- package/dist/field-options.js.map +1 -0
- 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/front-door.check.d.ts +2 -0
- package/dist/front-door.check.d.ts.map +1 -0
- package/dist/front-door.check.js +8 -0
- package/dist/front-door.check.js.map +1 -0
- package/dist/front-door.d.ts +82 -0
- package/dist/front-door.d.ts.map +1 -0
- package/dist/front-door.js +271 -0
- package/dist/front-door.js.map +1 -0
- package/dist/generated/advisor-plan-contract.generated.d.ts +3 -0
- package/dist/generated/advisor-plan-contract.generated.d.ts.map +1 -0
- package/dist/generated/advisor-plan-contract.generated.js +430 -0
- package/dist/generated/advisor-plan-contract.generated.js.map +1 -0
- package/dist/generated/contract-schema.generated.d.ts +97 -0
- package/dist/generated/contract-schema.generated.d.ts.map +1 -0
- package/dist/generated/contract-schema.generated.js +496 -0
- package/dist/generated/contract-schema.generated.js.map +1 -0
- package/dist/index.d.ts +18 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +26 -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/messaging-kit.d.ts +113 -0
- package/dist/messaging-kit.d.ts.map +1 -0
- package/dist/messaging-kit.js +121 -0
- package/dist/messaging-kit.js.map +1 -0
- package/dist/passage.d.ts +2 -1
- package/dist/passage.d.ts.map +1 -1
- package/dist/passage.js +2 -1
- package/dist/passage.js.map +1 -1
- package/dist/plan-authority.d.ts +73 -0
- package/dist/plan-authority.d.ts.map +1 -0
- package/dist/plan-authority.js +126 -0
- package/dist/plan-authority.js.map +1 -0
- package/dist/plan-contract.d.ts +16 -0
- package/dist/plan-contract.d.ts.map +1 -0
- package/dist/plan-contract.js +33 -0
- package/dist/plan-contract.js.map +1 -0
- package/dist/plan-digest.d.ts +23 -0
- package/dist/plan-digest.d.ts.map +1 -0
- package/dist/plan-digest.js +82 -0
- package/dist/plan-digest.js.map +1 -0
- package/dist/plan-rules.d.ts +54 -0
- package/dist/plan-rules.d.ts.map +1 -0
- package/dist/plan-rules.js +145 -0
- package/dist/plan-rules.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/resolve.d.ts +40 -6
- package/dist/resolve.d.ts.map +1 -1
- package/dist/resolve.js +144 -18
- package/dist/resolve.js.map +1 -1
- package/dist/scan.d.ts +32 -0
- package/dist/scan.d.ts.map +1 -1
- package/dist/scan.js +67 -6
- package/dist/scan.js.map +1 -1
- package/dist/schema.d.ts +1 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +231 -5
- package/dist/schema.js.map +1 -1
- package/dist/site-identity.d.ts +88 -0
- package/dist/site-identity.d.ts.map +1 -0
- package/dist/site-identity.js +82 -0
- package/dist/site-identity.js.map +1 -0
- 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 +76 -7
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +9 -7
- package/dist/types.js.map +1 -1
- package/fit-signals.json +11 -0
- package/package.json +201 -11
- package/skill/SKILL.md +49 -0
- package/src/addressability.ts +186 -4
- package/src/approval-bypass.ts +2263 -0
- package/src/approval.ts +491 -0
- package/src/approve-cli.ts +474 -0
- package/src/cli.ts +274 -22
- package/src/field-options.ts +134 -0
- package/src/fold-wallpaper-gate.ts +29 -0
- package/src/front-door.check.ts +10 -0
- package/src/front-door.ts +312 -0
- package/src/generated/advisor-plan-contract.generated.ts +430 -0
- package/src/generated/contract-schema.generated.ts +520 -0
- package/src/index.ts +107 -1
- package/src/live-copy-gate.ts +150 -0
- package/src/messaging-kit.ts +195 -0
- package/src/passage.ts +2 -1
- package/src/plan-authority.ts +160 -0
- package/src/plan-contract.ts +45 -0
- package/src/plan-digest.ts +81 -0
- package/src/plan-rules.ts +179 -0
- package/src/render-registry-parity.ts +65 -0
- package/src/resolve.ts +185 -19
- package/src/scan.ts +97 -6
- package/src/schema.ts +247 -5
- package/src/site-identity.ts +125 -0
- package/src/treatment-word-budget.ts +100 -0
- package/src/types.ts +83 -7
- package/templates/front-door.en.json +426 -0
- 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 === "`")
|
|
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)
|
|
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
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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,
|