@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
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scans declared live-copy trees for claim-shaped and magnitude-shaped prose
|
|
3
|
+
* that is not accounted for in the voice claims register.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
7
|
+
import { join, resolve } from "node:path";
|
|
8
|
+
import { findFoldWallpaperPhrase } from "./fold-wallpaper-gate.js";
|
|
9
|
+
import { checkCopy, type VoiceFinding, type VoiceRecord } from "./voice/index.js";
|
|
10
|
+
|
|
11
|
+
const LIVE_FILE_RE = /\.(tsx?|jsx?|mdx?|md|json)$/i;
|
|
12
|
+
const IGNORE_MARKER_RE = /(?:<!--|\/\*|\{\/\*|\/\/)\s*(?:copy-gate:ignore|voice-gate:ignore)/i;
|
|
13
|
+
const CLAIM_CITATION_RE = /(?:<!--|\/\*|\{\/\*|\/\/)\s*claim:([a-zA-Z][a-zA-Z0-9-]*)/g;
|
|
14
|
+
|
|
15
|
+
const NUMERIC_CLAIM_RE = new RegExp(
|
|
16
|
+
[
|
|
17
|
+
String.raw`[$€£]\s?\d[\d,]*(?:\.\d+)?\s?[kKmMbB]?\b`,
|
|
18
|
+
String.raw`\b\d+(?:\.\d+)?\s?%`,
|
|
19
|
+
String.raw`\b\d+(?:\.\d+)?[xX]\b`,
|
|
20
|
+
String.raw`\b\d{1,3}(?:,\d{3})+\b`,
|
|
21
|
+
String.raw`\b\d+(?:\.\d+)?\s?(?:million|billion|thousand)\b`,
|
|
22
|
+
].join("|"),
|
|
23
|
+
"gi",
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
export interface LiveCopyFinding extends VoiceFinding {
|
|
27
|
+
file: string;
|
|
28
|
+
line: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface LiveCopyScanResult {
|
|
32
|
+
filesScanned: number;
|
|
33
|
+
findings: LiveCopyFinding[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function walkLiveTree(root: string): string[] {
|
|
37
|
+
const files: string[] = [];
|
|
38
|
+
const stack = [resolve(root)];
|
|
39
|
+
while (stack.length > 0) {
|
|
40
|
+
const dir = stack.pop() as string;
|
|
41
|
+
let entries;
|
|
42
|
+
try {
|
|
43
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
44
|
+
} catch {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
for (const entry of entries) {
|
|
48
|
+
const full = join(dir, entry.name);
|
|
49
|
+
if (entry.isDirectory()) {
|
|
50
|
+
if (entry.name === "node_modules" || entry.name === ".git") continue;
|
|
51
|
+
stack.push(full);
|
|
52
|
+
} else if (entry.isFile() && LIVE_FILE_RE.test(entry.name)) {
|
|
53
|
+
files.push(full);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return files;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function extractStringLiterals(text: string): { line: number; value: string }[] {
|
|
61
|
+
const out: { line: number; value: string }[] = [];
|
|
62
|
+
const lines = text.split(/\r?\n/);
|
|
63
|
+
const literalRe = /(["'`])(?:(?!\1)[^\\]|\\.)*\1/g;
|
|
64
|
+
for (let i = 0; i < lines.length; i++) {
|
|
65
|
+
const line = lines[i] as string;
|
|
66
|
+
if (IGNORE_MARKER_RE.test(line)) continue;
|
|
67
|
+
for (const m of line.matchAll(literalRe)) {
|
|
68
|
+
const raw = m[0] as string;
|
|
69
|
+
const value = raw.slice(1, -1);
|
|
70
|
+
if (value.trim().length > 0) out.push({ line: i + 1, value });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function lineHasClaimCitation(line: string): boolean {
|
|
77
|
+
CLAIM_CITATION_RE.lastIndex = 0;
|
|
78
|
+
return CLAIM_CITATION_RE.test(line);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function scanLiveCopyTrees(record: VoiceRecord, roots: string[]): LiveCopyScanResult {
|
|
82
|
+
const findings: LiveCopyFinding[] = [];
|
|
83
|
+
let filesScanned = 0;
|
|
84
|
+
|
|
85
|
+
for (const root of roots) {
|
|
86
|
+
for (const file of walkLiveTree(root)) {
|
|
87
|
+
let text: string;
|
|
88
|
+
try {
|
|
89
|
+
text = readFileSync(file, "utf8");
|
|
90
|
+
} catch {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
filesScanned++;
|
|
94
|
+
const lines = text.split(/\r?\n/);
|
|
95
|
+
for (const { line, value } of extractStringLiterals(text)) {
|
|
96
|
+
const sourceLine = lines[line - 1] ?? "";
|
|
97
|
+
if (lineHasClaimCitation(sourceLine)) continue;
|
|
98
|
+
|
|
99
|
+
const wallpaperPhrase = findFoldWallpaperPhrase(value);
|
|
100
|
+
if (wallpaperPhrase) {
|
|
101
|
+
findings.push({
|
|
102
|
+
rule: "fold-wallpaper",
|
|
103
|
+
severity: "error",
|
|
104
|
+
message: `Fold wallpaper phrase "${wallpaperPhrase}" is generic competitor copy and must not headline the hero.`,
|
|
105
|
+
path: value.slice(0, 80),
|
|
106
|
+
file,
|
|
107
|
+
line,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const voiceReport = checkCopy(record, value);
|
|
112
|
+
for (const f of voiceReport.findings) {
|
|
113
|
+
if (f.rule === "claim:unsupported") {
|
|
114
|
+
findings.push({ ...f, file, line });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (NUMERIC_CLAIM_RE.test(value)) {
|
|
119
|
+
NUMERIC_CLAIM_RE.lastIndex = 0;
|
|
120
|
+
const covered = record.claims.some((claim) => {
|
|
121
|
+
if (!claim.requiresSupport) return true;
|
|
122
|
+
if (claim.factRef) return true;
|
|
123
|
+
const phrases = claim.matchPhrases.length > 0 ? claim.matchPhrases : [claim.text];
|
|
124
|
+
return phrases.some((p) => value.toLowerCase().includes(p.toLowerCase()));
|
|
125
|
+
});
|
|
126
|
+
if (!covered) {
|
|
127
|
+
findings.push({
|
|
128
|
+
rule: "live:magnitude-unmarked",
|
|
129
|
+
severity: "error",
|
|
130
|
+
message: `Magnitude-shaped copy is not covered by a supported claim in the voice register.`,
|
|
131
|
+
path: value.slice(0, 80),
|
|
132
|
+
file,
|
|
133
|
+
line,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return { filesScanned, findings };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function liveTreeExists(path: string): boolean {
|
|
145
|
+
try {
|
|
146
|
+
return statSync(resolve(path)).isDirectory();
|
|
147
|
+
} catch {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The messaging-kit copy kind: a pitch and a boilerplate, each in three
|
|
3
|
+
* lengths, resolved from a `CopyRegistry` under six reserved entry ids.
|
|
4
|
+
*
|
|
5
|
+
* A consumer reuses the same "about us" text in press, footers and email,
|
|
6
|
+
* and needs it to carry the same approval evidence as any other rendered
|
|
7
|
+
* copy. This module is a reserved copy kind, not a second text store or a
|
|
8
|
+
* second approval path: it asks `resolveCopyRef` for each id, so everything
|
|
9
|
+
* that resolver refuses is refused here for the same reason.
|
|
10
|
+
*
|
|
11
|
+
* Invariants:
|
|
12
|
+
*
|
|
13
|
+
* - K1: every `resolveCopyRef` refusal is reported with its own reason,
|
|
14
|
+
* tagged with the field it belongs to.
|
|
15
|
+
* - K2: `"messaging-placeholder"` when an entry declares placeholders or its
|
|
16
|
+
* text is one the resolver would rewrite; `"messaging-blank"` when the
|
|
17
|
+
* resolved text is empty after trimming. These are literal texts, not
|
|
18
|
+
* templates.
|
|
19
|
+
* - K3: `"messaging-ladder-order"` when `countCopyWords` does not strictly
|
|
20
|
+
* increase within a ladder (pitch: one-liner, elevator, paragraph; then
|
|
21
|
+
* boilerplate: short, medium, long). It is reported on the later field,
|
|
22
|
+
* and only between two fields that both resolved.
|
|
23
|
+
* - K4: all six are attempted on every call. `kit` and `resolutions` exist
|
|
24
|
+
* only when `issues` is empty, so there is no partial kit. `options.now`
|
|
25
|
+
* is fixed once per call. Nothing here throws for any input.
|
|
26
|
+
* - K5: no word targets beyond K3. An FAQ and a tagline are out of scope
|
|
27
|
+
* (`site.tagline` is already a reserved site-identity id).
|
|
28
|
+
*
|
|
29
|
+
* No real copy ships here. The ids are reserved names; the words are the
|
|
30
|
+
* consumer's own.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { prepareOptions, resolveField } from "./field-options.js";
|
|
34
|
+
import type { FieldLabels } from "./field-options.js";
|
|
35
|
+
import type { CopyResolveIssue, CopyResolveIssueReason, CopyResolveOptions } from "./resolve.js";
|
|
36
|
+
import { countCopyWords } from "./treatment-word-budget.js";
|
|
37
|
+
import type { CopyRegistry, CopyResolution } from "./types.js";
|
|
38
|
+
|
|
39
|
+
/** Reserved copy id for the shortest pitch. */
|
|
40
|
+
export const MESSAGING_PITCH_ONE_LINER_COPY_ID = "messaging.pitch.one-liner";
|
|
41
|
+
|
|
42
|
+
/** Reserved copy id for the elevator pitch. */
|
|
43
|
+
export const MESSAGING_PITCH_ELEVATOR_COPY_ID = "messaging.pitch.elevator";
|
|
44
|
+
|
|
45
|
+
/** Reserved copy id for the paragraph-length pitch. */
|
|
46
|
+
export const MESSAGING_PITCH_PARAGRAPH_COPY_ID = "messaging.pitch.paragraph";
|
|
47
|
+
|
|
48
|
+
/** Reserved copy id for the short boilerplate. */
|
|
49
|
+
export const MESSAGING_BOILERPLATE_SHORT_COPY_ID = "messaging.boilerplate.short";
|
|
50
|
+
|
|
51
|
+
/** Reserved copy id for the medium boilerplate. */
|
|
52
|
+
export const MESSAGING_BOILERPLATE_MEDIUM_COPY_ID = "messaging.boilerplate.medium";
|
|
53
|
+
|
|
54
|
+
/** Reserved copy id for the long boilerplate. */
|
|
55
|
+
export const MESSAGING_BOILERPLATE_LONG_COPY_ID = "messaging.boilerplate.long";
|
|
56
|
+
|
|
57
|
+
/** All six reserved messaging-kit copy ids: the pitch ladder, then the boilerplate ladder. */
|
|
58
|
+
export const MESSAGING_KIT_COPY_IDS = [
|
|
59
|
+
MESSAGING_PITCH_ONE_LINER_COPY_ID,
|
|
60
|
+
MESSAGING_PITCH_ELEVATOR_COPY_ID,
|
|
61
|
+
MESSAGING_PITCH_PARAGRAPH_COPY_ID,
|
|
62
|
+
MESSAGING_BOILERPLATE_SHORT_COPY_ID,
|
|
63
|
+
MESSAGING_BOILERPLATE_MEDIUM_COPY_ID,
|
|
64
|
+
MESSAGING_BOILERPLATE_LONG_COPY_ID,
|
|
65
|
+
] as const;
|
|
66
|
+
|
|
67
|
+
/** Which entry of the kit an issue is about. */
|
|
68
|
+
export type MessagingKitField =
|
|
69
|
+
| "pitch.oneLiner"
|
|
70
|
+
| "pitch.elevator"
|
|
71
|
+
| "pitch.paragraph"
|
|
72
|
+
| "boilerplate.short"
|
|
73
|
+
| "boilerplate.medium"
|
|
74
|
+
| "boilerplate.long";
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Every reason `resolveMessagingKit` can report: whatever `resolveCopyRef`
|
|
78
|
+
* refuses, plus the three refusals specific to the kit.
|
|
79
|
+
*/
|
|
80
|
+
export type MessagingKitIssueReason =
|
|
81
|
+
| CopyResolveIssueReason
|
|
82
|
+
| "messaging-blank"
|
|
83
|
+
| "messaging-placeholder"
|
|
84
|
+
| "messaging-ladder-order";
|
|
85
|
+
|
|
86
|
+
/** One reason the kit could not be resolved, tagged with its field. */
|
|
87
|
+
export interface MessagingKitIssue extends Omit<CopyResolveIssue, "reason"> {
|
|
88
|
+
reason: MessagingKitIssueReason;
|
|
89
|
+
field: MessagingKitField;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The resolved text of the kit, by ladder. */
|
|
93
|
+
export interface MessagingKit {
|
|
94
|
+
pitch: { oneLiner: string; elevator: string; paragraph: string };
|
|
95
|
+
boilerplate: { short: string; medium: string; long: string };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Each entry's `CopyResolution`, shaped like `MessagingKit`. */
|
|
99
|
+
export interface MessagingKitResolutions {
|
|
100
|
+
pitch: { oneLiner: CopyResolution; elevator: CopyResolution; paragraph: CopyResolution };
|
|
101
|
+
boilerplate: { short: CopyResolution; medium: CopyResolution; long: CopyResolution };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The outcome of `resolveMessagingKit`. `kit` and `resolutions` are present
|
|
106
|
+
* only when `complete` is `true`; `issues` is empty exactly then.
|
|
107
|
+
*/
|
|
108
|
+
export interface MessagingKitResolution {
|
|
109
|
+
complete: boolean;
|
|
110
|
+
kit?: MessagingKit;
|
|
111
|
+
resolutions?: MessagingKitResolutions;
|
|
112
|
+
issues: MessagingKitIssue[];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** `CopyResolveOptions` plus the requested locale, which is a ref field for the underlying resolver. */
|
|
116
|
+
export type MessagingKitOptions = CopyResolveOptions & { locale?: string };
|
|
117
|
+
|
|
118
|
+
const FIELDS: readonly { field: MessagingKitField; id: string; ladder: "pitch" | "boilerplate" }[] = [
|
|
119
|
+
{ field: "pitch.oneLiner", id: MESSAGING_KIT_COPY_IDS[0], ladder: "pitch" },
|
|
120
|
+
{ field: "pitch.elevator", id: MESSAGING_KIT_COPY_IDS[1], ladder: "pitch" },
|
|
121
|
+
{ field: "pitch.paragraph", id: MESSAGING_KIT_COPY_IDS[2], ladder: "pitch" },
|
|
122
|
+
{ field: "boilerplate.short", id: MESSAGING_KIT_COPY_IDS[3], ladder: "boilerplate" },
|
|
123
|
+
{ field: "boilerplate.medium", id: MESSAGING_KIT_COPY_IDS[4], ladder: "boilerplate" },
|
|
124
|
+
{ field: "boilerplate.long", id: MESSAGING_KIT_COPY_IDS[5], ladder: "boilerplate" },
|
|
125
|
+
];
|
|
126
|
+
|
|
127
|
+
const LABELS: FieldLabels<"messaging-placeholder" | "messaging-blank"> = {
|
|
128
|
+
optionsSubject: "Messaging kit",
|
|
129
|
+
entrySubject: (id) => `Messaging kit entry "${id}"`,
|
|
130
|
+
placeholderReason: "messaging-placeholder",
|
|
131
|
+
blankReason: "messaging-blank",
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Resolves the messaging kit from `registry`, under the six ids in
|
|
136
|
+
* `MESSAGING_KIT_COPY_IDS`. All six are attempted on every call and every
|
|
137
|
+
* issue is reported in id order, tagged with its `field`; `kit` and
|
|
138
|
+
* `resolutions` are returned only when there are no issues.
|
|
139
|
+
*
|
|
140
|
+
* `options` are `resolveCopyRef`'s (`target`, `acceptDelegateInProduction`,
|
|
141
|
+
* `now`) plus an optional `locale`, which is forwarded as the ref's locale
|
|
142
|
+
* rather than as an option. Malformed options produce an `"invalid-options"`
|
|
143
|
+
* issue per field. Pure apart from the clock read that `now` defaults to;
|
|
144
|
+
* the registry is not modified and no input makes this throw.
|
|
145
|
+
*/
|
|
146
|
+
export function resolveMessagingKit(
|
|
147
|
+
registry: CopyRegistry | unknown,
|
|
148
|
+
options?: MessagingKitOptions | unknown,
|
|
149
|
+
): MessagingKitResolution {
|
|
150
|
+
const prepared = prepareOptions(options);
|
|
151
|
+
const issues: MessagingKitIssue[] = [];
|
|
152
|
+
const resolved: CopyResolution[] = [];
|
|
153
|
+
const words: (number | undefined)[] = [];
|
|
154
|
+
FIELDS.forEach(({ field, id, ladder }, index) => {
|
|
155
|
+
const outcome = resolveField(registry, field, id, prepared, LABELS);
|
|
156
|
+
issues.push(...outcome.issues);
|
|
157
|
+
const resolution = outcome.resolution;
|
|
158
|
+
if (resolution) resolved[index] = resolution;
|
|
159
|
+
words[index] = resolution ? countCopyWords(resolution.text) : undefined;
|
|
160
|
+
|
|
161
|
+
const previous = index - 1;
|
|
162
|
+
const before = previous >= 0 && FIELDS[previous]!.ladder === ladder ? words[previous] : undefined;
|
|
163
|
+
const current = words[index];
|
|
164
|
+
if (before !== undefined && current !== undefined && !(current > before)) {
|
|
165
|
+
issues.push({
|
|
166
|
+
reason: "messaging-ladder-order",
|
|
167
|
+
field,
|
|
168
|
+
id,
|
|
169
|
+
message: `Messaging kit entry "${id}" has ${current} words, which is not more than the ${before} of "${FIELDS[previous]!.id}".`,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
if (issues.length > 0 || resolved.length !== FIELDS.length) return { complete: false, issues };
|
|
175
|
+
const [oneLiner, elevator, paragraph, short, medium, long] = resolved as [
|
|
176
|
+
CopyResolution,
|
|
177
|
+
CopyResolution,
|
|
178
|
+
CopyResolution,
|
|
179
|
+
CopyResolution,
|
|
180
|
+
CopyResolution,
|
|
181
|
+
CopyResolution,
|
|
182
|
+
];
|
|
183
|
+
return {
|
|
184
|
+
complete: true,
|
|
185
|
+
kit: {
|
|
186
|
+
pitch: { oneLiner: oneLiner.text, elevator: elevator.text, paragraph: paragraph.text },
|
|
187
|
+
boilerplate: { short: short.text, medium: medium.text, long: long.text },
|
|
188
|
+
},
|
|
189
|
+
resolutions: {
|
|
190
|
+
pitch: { oneLiner, elevator, paragraph },
|
|
191
|
+
boilerplate: { short, medium, long },
|
|
192
|
+
},
|
|
193
|
+
issues: [],
|
|
194
|
+
};
|
|
195
|
+
}
|
package/src/passage.ts
CHANGED
|
@@ -37,7 +37,8 @@
|
|
|
37
37
|
*
|
|
38
38
|
* This file ships no example content of anyone's real copy — the same
|
|
39
39
|
* "vocabulary layer, never a source of real words" constraint `types.ts`
|
|
40
|
-
* documents for `CopyEntry`/`CopyRecord
|
|
40
|
+
* documents for `CopyEntry`/`CopyRecord` (its one exemption is the shipped
|
|
41
|
+
* front-door defaults, which this file does not touch). Every fixture in this package's
|
|
41
42
|
* own tests is structural placeholder text, never something a product
|
|
42
43
|
* would actually show a user.
|
|
43
44
|
*
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Whether the bytes of an Advisor plan record authorize copy a delegate
|
|
2
|
+
// approved on production (issue #1586). The plan's optional
|
|
3
|
+
// `delegatedCopyApproval` declares that grant, and an approval binds the plan
|
|
4
|
+
// it grants for: this reads the field only through a latest decision that
|
|
5
|
+
// names the plan's own canonical digest. Two files define what is read, both
|
|
6
|
+
// in the public repository, not shipped in this package: the plan contract,
|
|
7
|
+
// docs/contracts/advisor-plan.json (see its DELEGATED COPY APPROVAL and
|
|
8
|
+
// APPROVAL BINDING sections), and the digest definition,
|
|
9
|
+
// docs/contracts/advisor-plan-digest.md.
|
|
10
|
+
//
|
|
11
|
+
// It is a pure function of the bytes: it reads no file, keeps nothing between
|
|
12
|
+
// calls and never looks at the clock, because a plan approval has no expiry
|
|
13
|
+
// of its own. It takes bytes, not a parsed object, because the digest is
|
|
14
|
+
// defined only over a plan that was read strictly, and an object a caller
|
|
15
|
+
// built can carry getters or proxies that answer validation, the digest and
|
|
16
|
+
// the scope check differently. What it does not prove is who wrote the
|
|
17
|
+
// decision, or where the bytes came from: see this package's README.
|
|
18
|
+
|
|
19
|
+
import { readContractDocument } from "./generated/contract-schema.generated.js";
|
|
20
|
+
import { planViolations } from "./plan-contract.js";
|
|
21
|
+
import { digestOfValidPlan } from "./plan-digest.js";
|
|
22
|
+
import type { PlanDocument } from "./plan-rules.js";
|
|
23
|
+
|
|
24
|
+
/** Why a plan's bytes did not authorize delegate-approved copy on production. */
|
|
25
|
+
export type PlanDelegateCopyRefusal =
|
|
26
|
+
/** The argument is not a `Uint8Array` (a Node `Buffer` is one). */
|
|
27
|
+
| "plan-not-bytes"
|
|
28
|
+
/** The bytes are not strict JSON: not valid UTF-8, a byte order mark, a syntax error, or a key repeated at any depth. */
|
|
29
|
+
| "plan-unreadable"
|
|
30
|
+
/** The plan does not validate against the plan contract, schema and code rules. */
|
|
31
|
+
| "plan-invalid"
|
|
32
|
+
/** The plan does not declare `delegatedCopyApproval`. */
|
|
33
|
+
| "delegated-copy-approval-absent"
|
|
34
|
+
| "no-decisions"
|
|
35
|
+
/** Defence in depth: a valid plan's decision times always parse. */
|
|
36
|
+
| "decision-time-unparseable"
|
|
37
|
+
/** A decision at the latest instant has not chosen "approved". */
|
|
38
|
+
| "latest-decision-not-approved"
|
|
39
|
+
/** A decision at the latest instant is an approval that names no sha256 digest. */
|
|
40
|
+
| "approval-without-subject-digest"
|
|
41
|
+
/** Decisions at the latest instant name different digests. */
|
|
42
|
+
| "latest-decisions-disagree"
|
|
43
|
+
/** The latest approval names a digest other than this plan's own. */
|
|
44
|
+
| "subject-digest-mismatch";
|
|
45
|
+
|
|
46
|
+
/** What `planDelegateCopyAuthority()` decided. Frozen, and carries no plan text. */
|
|
47
|
+
export type PlanDelegateCopyAuthority =
|
|
48
|
+
| {
|
|
49
|
+
readonly authorized: true;
|
|
50
|
+
/** The plan's canonical digest, which the latest approval names. */
|
|
51
|
+
readonly planDigest: string;
|
|
52
|
+
/** The plan's `delegatedCopyApproval.scopes`, copied; absent when the plan declares none, which covers every entry. */
|
|
53
|
+
readonly scopes?: readonly string[];
|
|
54
|
+
}
|
|
55
|
+
| {
|
|
56
|
+
readonly authorized: false;
|
|
57
|
+
readonly refusal: PlanDelegateCopyRefusal;
|
|
58
|
+
/** Only with "plan-invalid": each violation's rule and position, never a value. */
|
|
59
|
+
readonly violations?: readonly { readonly rule: string; readonly path: string }[];
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const SUBJECT_DIGEST = /^sha256:[0-9a-f]{64}$/;
|
|
63
|
+
|
|
64
|
+
function refuse(refusal: PlanDelegateCopyRefusal, violations?: readonly { readonly rule: string; readonly path: string }[]): PlanDelegateCopyAuthority {
|
|
65
|
+
if (violations === undefined) return Object.freeze({ authorized: false, refusal });
|
|
66
|
+
return Object.freeze({ authorized: false, refusal, violations: Object.freeze(violations.map((violation) => Object.freeze({ rule: violation.rule, path: violation.path }))) });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The positions of the decisions made at the latest instant, by `at` and
|
|
71
|
+
* never by array position, in array order. Times are compared as instants
|
|
72
|
+
* (`Date.parse`), so `2026-09-21T00:00:00Z` and `2026-09-21T02:00:00+02:00`
|
|
73
|
+
* tie. Null when there are no decisions, or when any `at` does not parse to a
|
|
74
|
+
* finite time, because then time cannot say which is latest. Exported for
|
|
75
|
+
* tests, not from the package entry point.
|
|
76
|
+
*/
|
|
77
|
+
export function latestDecisionIndices(decisions: readonly { readonly at: string }[]): number[] | null {
|
|
78
|
+
if (decisions.length === 0) return null;
|
|
79
|
+
const times = decisions.map((decision) => Date.parse(decision.at));
|
|
80
|
+
if (!times.every(Number.isFinite)) return null;
|
|
81
|
+
// A reduce, not Math.max(...times): spreading a very long list into arguments throws a RangeError.
|
|
82
|
+
const latest = times.reduce((highest, time) => (time > highest ? time : highest), Number.NEGATIVE_INFINITY);
|
|
83
|
+
const indices: number[] = [];
|
|
84
|
+
times.forEach((time, index) => {
|
|
85
|
+
if (time === latest) indices.push(index);
|
|
86
|
+
});
|
|
87
|
+
return indices;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** True for a real `Uint8Array` (a Node `Buffer` is one), never a `DataView`, another typed array or a proxy. */
|
|
91
|
+
export function isPlanBytes(value: unknown): value is Uint8Array {
|
|
92
|
+
try {
|
|
93
|
+
return value instanceof Uint8Array && ArrayBuffer.isView(value);
|
|
94
|
+
} catch {
|
|
95
|
+
return false; // instanceof throws on a revoked proxy
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Whether the bytes of a plan record authorize delegate-approved copy on
|
|
101
|
+
* production. Checks run in this order and the first that fails refuses:
|
|
102
|
+
*
|
|
103
|
+
* 1. the argument is a `Uint8Array` (`plan-not-bytes`);
|
|
104
|
+
* 2. the bytes are strict JSON (`plan-unreadable`);
|
|
105
|
+
* 3. the plan validates against the plan contract, R1-R12 included
|
|
106
|
+
* (`plan-invalid`, with each violation's rule and position);
|
|
107
|
+
* 4. the plan declares `delegatedCopyApproval` (`delegated-copy-approval-absent`);
|
|
108
|
+
* 5-9. the plan's latest decision, found by time and never by array
|
|
109
|
+
* position, has chosen "approved" and names one sha256 `subjectDigest`.
|
|
110
|
+
* When several decisions share the latest instant, every one is examined:
|
|
111
|
+
* the outcome does not depend on their order;
|
|
112
|
+
* 10. that digest equals the plan's own canonical digest, computed here from
|
|
113
|
+
* the same bytes (`subject-digest-mismatch`). The digest covers
|
|
114
|
+
* `delegatedCopyApproval`, so a declaration added, removed or edited after
|
|
115
|
+
* the approval leaves the plan unapproved for this purpose.
|
|
116
|
+
*
|
|
117
|
+
* Never throws for a bad argument and never echoes plan text. The result and
|
|
118
|
+
* everything in it is frozen, and copied from the bytes, which are copied
|
|
119
|
+
* before they are read.
|
|
120
|
+
*/
|
|
121
|
+
export function planDelegateCopyAuthority(planBytes: Uint8Array | unknown): PlanDelegateCopyAuthority {
|
|
122
|
+
if (!isPlanBytes(planBytes)) return refuse("plan-not-bytes");
|
|
123
|
+
// A copy, so nothing the caller does to its buffer during or after this call changes what is read.
|
|
124
|
+
const bytes = new Uint8Array(planBytes);
|
|
125
|
+
|
|
126
|
+
let value: unknown;
|
|
127
|
+
try {
|
|
128
|
+
value = readContractDocument(bytes);
|
|
129
|
+
} catch {
|
|
130
|
+
return refuse("plan-unreadable");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const violations = planViolations(value);
|
|
134
|
+
if (violations.length > 0) return refuse("plan-invalid", violations);
|
|
135
|
+
const plan = value as PlanDocument;
|
|
136
|
+
|
|
137
|
+
if (!Object.hasOwn(plan, "delegatedCopyApproval")) return refuse("delegated-copy-approval-absent");
|
|
138
|
+
if (plan.decisions.length === 0) return refuse("no-decisions");
|
|
139
|
+
|
|
140
|
+
const latestIndices = latestDecisionIndices(plan.decisions);
|
|
141
|
+
if (latestIndices === null) return refuse("decision-time-unparseable");
|
|
142
|
+
const latest = latestIndices.map((index) => plan.decisions[index]!);
|
|
143
|
+
if (latest.some((decision) => decision.chosen !== "approved")) return refuse("latest-decision-not-approved");
|
|
144
|
+
|
|
145
|
+
const subjects = latest.map((decision) => (Object.hasOwn(decision, "subjectDigest") ? decision.subjectDigest : undefined));
|
|
146
|
+
if (subjects.some((subject) => typeof subject !== "string" || !SUBJECT_DIGEST.test(subject))) return refuse("approval-without-subject-digest");
|
|
147
|
+
if (subjects.some((subject) => subject !== subjects[0])) return refuse("latest-decisions-disagree");
|
|
148
|
+
|
|
149
|
+
let digest: string;
|
|
150
|
+
try {
|
|
151
|
+
digest = digestOfValidPlan(plan as unknown as Readonly<Record<string, unknown>>);
|
|
152
|
+
} catch {
|
|
153
|
+
return refuse("plan-invalid", []);
|
|
154
|
+
}
|
|
155
|
+
if (subjects[0] !== digest) return refuse("subject-digest-mismatch");
|
|
156
|
+
|
|
157
|
+
const declaration = plan.delegatedCopyApproval!;
|
|
158
|
+
if (!Object.hasOwn(declaration, "scopes") || declaration.scopes === undefined) return Object.freeze({ authorized: true, planDigest: digest });
|
|
159
|
+
return Object.freeze({ authorized: true, planDigest: digest, scopes: Object.freeze([...declaration.scopes]) });
|
|
160
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Reads a plan record (issue #1586) against the shared plan contract
|
|
2
|
+
// docs/contracts/advisor-plan.json -- in the public repository, not shipped in
|
|
3
|
+
// this package. This package's build packs that contract into src/generated/
|
|
4
|
+
// as plain data, with a generated copy of the one contract checker every
|
|
5
|
+
// other reader of a plan also uses (packages/advisor/src/contract-schema.ts,
|
|
6
|
+
// likewise in the public repository, not shipped in this package), so
|
|
7
|
+
// Writer validates against the same definition with no runtime dependency on
|
|
8
|
+
// any other package.
|
|
9
|
+
|
|
10
|
+
import { ADVISOR_PLAN_CONTRACT } from "./generated/advisor-plan-contract.generated.js";
|
|
11
|
+
import { formatContractViolation, validateAgainstContract } from "./generated/contract-schema.generated.js";
|
|
12
|
+
import { planRuleViolations } from "./plan-rules.js";
|
|
13
|
+
import type { PlanDocument, PlanRuleId } from "./plan-rules.js";
|
|
14
|
+
|
|
15
|
+
/** One reason a plan is refused: `rule` is "schema" for the contract's keywords, else the code rule's id. */
|
|
16
|
+
export interface PlanViolation {
|
|
17
|
+
readonly rule: "schema" | PlanRuleId;
|
|
18
|
+
/** The field at fault, like `staffing[1].repository`, or "" for the document itself. */
|
|
19
|
+
readonly path: string;
|
|
20
|
+
/** The whole message, starting with `plan` and the path; it never quotes a value. */
|
|
21
|
+
readonly message: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The plan contract refers to no other contract file, so every name is refused. */
|
|
25
|
+
function refuseEveryName(name: string): never {
|
|
26
|
+
throw new Error(`the plan contract refers to no other contract, but the checker asked for ${JSON.stringify(name)}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Every reason `value` is not a valid plan: the plan contract's schema, then,
|
|
31
|
+
* once that passes, its code rules R1-R12. Empty means valid. Never throws
|
|
32
|
+
* for a bad value, and no message echoes a value from the plan.
|
|
33
|
+
*/
|
|
34
|
+
export function planViolations(value: unknown): PlanViolation[] {
|
|
35
|
+
const schema = validateAgainstContract(ADVISOR_PLAN_CONTRACT, value, refuseEveryName);
|
|
36
|
+
if (schema.length > 0) {
|
|
37
|
+
return schema.map((violation) => ({ rule: "schema", path: violation.path, message: formatContractViolation("plan", violation) }));
|
|
38
|
+
}
|
|
39
|
+
// The code rules relate fields to one another, so they run only on a plan whose shape is known good.
|
|
40
|
+
return planRuleViolations(value as PlanDocument).map((violation) => ({
|
|
41
|
+
rule: violation.rule,
|
|
42
|
+
path: violation.path,
|
|
43
|
+
message: `plan.${violation.path} ${violation.message} (rule ${violation.rule})`,
|
|
44
|
+
}));
|
|
45
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// The canonical plan digest (issue #1586), implemented from its one
|
|
2
|
+
// definition, docs/contracts/advisor-plan-digest.md -- in the public repository, not shipped in this package.
|
|
3
|
+
// An approval binds this value, so it names exactly which plan was approved.
|
|
4
|
+
// Every other reader of a plan implements the same definition separately, and
|
|
5
|
+
// each is tested against the same fixture corpus,
|
|
6
|
+
// docs/contracts/advisor-plan-digest.fixture.json (likewise in the public repository, not shipped in this package),
|
|
7
|
+
// so all of them compute identical digests.
|
|
8
|
+
// This is a third, separate reading of RFC 8785: strings go through
|
|
9
|
+
// JSON.stringify, whose output for well-formed text is exactly the escaping
|
|
10
|
+
// RFC 8785 requires, with a lone surrogate refused before it.
|
|
11
|
+
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
13
|
+
import { planViolations } from "./plan-contract.js";
|
|
14
|
+
|
|
15
|
+
/** The top-level plan members the digest leaves out: when the file was written, and the decisions an approval is recorded in. */
|
|
16
|
+
export const PLAN_DIGEST_EXCLUDED_FIELDS: readonly string[] = ["asOf", "decisions"];
|
|
17
|
+
|
|
18
|
+
const LONE_SURROGATE_CHECK = /[\uD800-\uDFFF]/u;
|
|
19
|
+
|
|
20
|
+
/** A string as RFC 8785 writes it. Throws on a string that is not well-formed Unicode. */
|
|
21
|
+
function canonicalString(text: string): string {
|
|
22
|
+
if (LONE_SURROGATE_CHECK.test(text)) throw new TypeError("canonical JSON refuses a string that is not well-formed Unicode (a lone surrogate)");
|
|
23
|
+
return JSON.stringify(text);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* RFC 8785 (JSON Canonicalization Scheme) serialization: no whitespace,
|
|
28
|
+
* object members sorted by UTF-16 code units, strings escaped only where
|
|
29
|
+
* JSON requires, numbers as ECMAScript writes them. Throws on anything JSON
|
|
30
|
+
* cannot carry (undefined, a function, a bigint, a non-finite number, a hole
|
|
31
|
+
* in an array) and on a lone surrogate, rather than silently dropping or
|
|
32
|
+
* repairing it.
|
|
33
|
+
*/
|
|
34
|
+
export function canonicalJson(value: unknown): string {
|
|
35
|
+
if (value === null) return "null";
|
|
36
|
+
switch (typeof value) {
|
|
37
|
+
case "boolean":
|
|
38
|
+
return value ? "true" : "false";
|
|
39
|
+
case "number":
|
|
40
|
+
if (!Number.isFinite(value)) throw new TypeError("canonical JSON refuses a non-finite number");
|
|
41
|
+
return String(value);
|
|
42
|
+
case "string":
|
|
43
|
+
return canonicalString(value);
|
|
44
|
+
case "object": {
|
|
45
|
+
if (Array.isArray(value)) {
|
|
46
|
+
const items: string[] = [];
|
|
47
|
+
for (let index = 0; index < value.length; index += 1) items.push(canonicalJson(value[index]));
|
|
48
|
+
return `[${items.join(",")}]`;
|
|
49
|
+
}
|
|
50
|
+
const record = value as Record<string, unknown>;
|
|
51
|
+
// The default sort compares UTF-16 code units, which is the order RFC 8785 requires.
|
|
52
|
+
const members = Object.keys(record)
|
|
53
|
+
.sort()
|
|
54
|
+
.map((key) => `${canonicalString(key)}:${canonicalJson(record[key])}`);
|
|
55
|
+
return `{${members.join(",")}}`;
|
|
56
|
+
}
|
|
57
|
+
default:
|
|
58
|
+
throw new TypeError(`canonical JSON refuses a value of type ${typeof value}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* `sha256:` and the lowercase hex SHA-256 of the canonical JSON of a plan
|
|
64
|
+
* without `asOf` and `decisions`. Assumes `plan` already passed
|
|
65
|
+
* `planViolations()`: it checks nothing itself.
|
|
66
|
+
*/
|
|
67
|
+
export function digestOfValidPlan(plan: Readonly<Record<string, unknown>>): string {
|
|
68
|
+
const subject: Record<string, unknown> = {};
|
|
69
|
+
for (const key of Object.keys(plan)) if (!PLAN_DIGEST_EXCLUDED_FIELDS.includes(key)) subject[key] = plan[key];
|
|
70
|
+
return `sha256:${createHash("sha256").update(canonicalJson(subject), "utf8").digest("hex")}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The plan digest. Throws when the plan does not validate against the plan
|
|
75
|
+
* contract, rules included: an invalid plan has no digest.
|
|
76
|
+
*/
|
|
77
|
+
export function planDigest(plan: unknown): string {
|
|
78
|
+
const violations = planViolations(plan);
|
|
79
|
+
if (violations.length > 0) throw new TypeError(`an invalid plan has no digest: ${violations.map((violation) => violation.message).join("; ")}`);
|
|
80
|
+
return digestOfValidPlan(plan as Readonly<Record<string, unknown>>);
|
|
81
|
+
}
|