@domandigital/craft 0.9.0 → 0.11.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.
@@ -0,0 +1,743 @@
1
+ import {
2
+ CATALOGUE,
3
+ CATALOGUE_VERSION,
4
+ applyHouseGate,
5
+ checkCopy,
6
+ extractCopy,
7
+ extractStrings,
8
+ fileKind,
9
+ houseRule,
10
+ scanSource
11
+ } from "./chunk-3NGWXZZE.js";
12
+
13
+ // src/character/config.ts
14
+ var DEFAULT_IGNORE = [".claude", ".agents", ".cursor"];
15
+ var KEYS = /* @__PURE__ */ new Set(["$schema", "ignore", "copyPaths", "severity"]);
16
+ var LEVELS = /* @__PURE__ */ new Set(["warn", "block", "off"]);
17
+ var MIN_BECAUSE = 12;
18
+ var isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
19
+ var isStringArray = (v) => Array.isArray(v) && v.every((s) => typeof s === "string");
20
+ function parseCraftConfig(data, knownTells, source = "craft.config.json") {
21
+ if (!isRecord(data)) throw new Error(`${source}: expected an object`);
22
+ for (const key of Object.keys(data)) if (!KEYS.has(key)) throw new Error(`${source}: unknown key "${key}"`);
23
+ const ignore = data.ignore ?? [];
24
+ if (!isStringArray(ignore)) throw new Error(`${source}: "ignore" must be an array of glob strings`);
25
+ if (data.copyPaths !== void 0 && !isStringArray(data.copyPaths)) throw new Error(`${source}: "copyPaths" must be an array of paths`);
26
+ const severity = {};
27
+ const raw = data.severity ?? {};
28
+ if (!isRecord(raw)) throw new Error(`${source}: "severity" must map a tell id to { level, because }`);
29
+ for (const [tell, value] of Object.entries(raw)) {
30
+ if (!knownTells.has(tell)) throw new Error(`${source}: severity names no tell called "${tell}"`);
31
+ if (!isRecord(value) || !LEVELS.has(value.level)) {
32
+ throw new Error(`${source}: severity.${tell} needs a level of "warn", "block" or "off"`);
33
+ }
34
+ if (typeof value.because !== "string" || value.because.trim().length < MIN_BECAUSE) {
35
+ throw new Error(`${source}: severity.${tell} needs a because of at least a sentence`);
36
+ }
37
+ severity[tell] = { level: value.level, because: value.because };
38
+ }
39
+ return { ignore, severity, ...data.copyPaths ? { copyPaths: data.copyPaths } : {} };
40
+ }
41
+ function globToRegExp(glob) {
42
+ const pattern = glob.replace(/^\.\//, "").replace(/\/$/, "").replace(/\/\*\*$/, "");
43
+ let body = "";
44
+ for (let i = 0; i < pattern.length; i += 1) {
45
+ const c = pattern[i];
46
+ if (c === "*" && pattern[i + 1] === "*") {
47
+ const slash = pattern[i + 2] === "/";
48
+ body += slash ? "(?:.*/)?" : ".*";
49
+ i += slash ? 2 : 1;
50
+ } else if (c === "*") body += "[^/]*";
51
+ else if (c === "?") body += "[^/]";
52
+ else body += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
53
+ }
54
+ const anywhere = !pattern.includes("/");
55
+ return new RegExp(`^${anywhere ? "(?:.*/)?" : ""}${body}(?:/.*)?$`);
56
+ }
57
+ function ignoreMatcher(patterns) {
58
+ const res = patterns.map(globToRegExp);
59
+ return (path) => {
60
+ const p = path.replace(/\\/g, "/").replace(/^\.\//, "");
61
+ return res.some((re) => re.test(p));
62
+ };
63
+ }
64
+ function severityExceptions(config) {
65
+ return Object.entries(config.severity).filter(([, o]) => o.level === "off").map(([tell, o]) => ({ tell, because: `craft.config.json: ${o.because}` }));
66
+ }
67
+ function applySeverity(report, config) {
68
+ const findings = report.findings.map((f) => {
69
+ const o = config.severity[f.tell];
70
+ return o && o.level !== "off" ? { ...f, severity: o.level } : f;
71
+ });
72
+ return { ...report, findings, summary: { ...report.summary, blocking: findings.filter((f) => f.severity === "block").length } };
73
+ }
74
+
75
+ // src/character/json.ts
76
+ var JSON_SCHEMA_VERSION = 1;
77
+ function toJson(data) {
78
+ return JSON.stringify({ schemaVersion: JSON_SCHEMA_VERSION, ...data }, null, 2);
79
+ }
80
+
81
+ // src/character/cli.ts
82
+ import { execFileSync } from "child_process";
83
+ import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "fs";
84
+ import { join, relative, resolve } from "path";
85
+
86
+ // src/character/baseline.ts
87
+ var BASELINE_VERSION = 1;
88
+ function fnv1a(text) {
89
+ let hash = 2166136261;
90
+ for (let i = 0; i < text.length; i += 1) {
91
+ hash ^= text.charCodeAt(i);
92
+ hash = Math.imul(hash, 16777619);
93
+ }
94
+ return (hash >>> 0).toString(16).padStart(8, "0");
95
+ }
96
+ var excerptHash = (f) => fnv1a(f.excerpt.replace(/\s+/g, " ").trim());
97
+ var keyOf = (tell, path, excerpt) => `${tell}\0${path}\0${excerpt}`;
98
+ function findingKey(f) {
99
+ return keyOf(f.tell, f.path, excerptHash(f));
100
+ }
101
+ function createBaseline(report) {
102
+ const counts = /* @__PURE__ */ new Map();
103
+ for (const f of report.findings) {
104
+ const excerpt = excerptHash(f);
105
+ const key = keyOf(f.tell, f.path, excerpt);
106
+ const entry = counts.get(key);
107
+ if (entry) entry.count += 1;
108
+ else counts.set(key, { tell: f.tell, path: f.path, excerpt, count: 1 });
109
+ }
110
+ const entries = [...counts.values()].sort((a, b) => a.path.localeCompare(b.path) || a.tell.localeCompare(b.tell) || a.excerpt.localeCompare(b.excerpt));
111
+ return { version: BASELINE_VERSION, catalogueVersion: report.catalogueVersion, entries };
112
+ }
113
+ function parseBaseline(data, source = "baseline") {
114
+ const b = data;
115
+ if (!b || typeof b !== "object" || b.version !== BASELINE_VERSION || !Array.isArray(b.entries)) {
116
+ throw new Error(`${source}: not a craft baseline (version ${BASELINE_VERSION})`);
117
+ }
118
+ for (const e of b.entries) {
119
+ if (typeof e?.tell !== "string" || typeof e.path !== "string" || typeof e.excerpt !== "string" || !Number.isInteger(e.count) || e.count < 1) {
120
+ throw new Error(`${source}: malformed entry ${JSON.stringify(e)}`);
121
+ }
122
+ }
123
+ return b;
124
+ }
125
+ function recount(report, findings) {
126
+ const byGeneration = { 1: 0, 2: 0, 3: 0 };
127
+ const byTell = {};
128
+ for (const f of findings) {
129
+ byGeneration[f.generation] += 1;
130
+ byTell[f.tell] = (byTell[f.tell] ?? 0) + 1;
131
+ }
132
+ return {
133
+ ...report,
134
+ findings,
135
+ summary: { ...report.summary, findings: findings.length, byGeneration, byTell, blocking: findings.filter((f) => f.severity === "block").length }
136
+ };
137
+ }
138
+ function applyBaseline(report, baseline) {
139
+ const remaining = /* @__PURE__ */ new Map();
140
+ for (const e of baseline.entries) {
141
+ const key = keyOf(e.tell, e.path, e.excerpt);
142
+ remaining.set(key, (remaining.get(key) ?? 0) + e.count);
143
+ }
144
+ const kept = [];
145
+ let baselined = 0;
146
+ for (const f of report.findings) {
147
+ const key = findingKey(f);
148
+ const left = remaining.get(key) ?? 0;
149
+ if (left > 0) {
150
+ remaining.set(key, left - 1);
151
+ baselined += 1;
152
+ } else kept.push(f);
153
+ }
154
+ return { ...recount(report, kept), baselined };
155
+ }
156
+
157
+ // src/character/sarif.ts
158
+ var isUrl = (path) => /^[a-z][a-z0-9+.-]*:\/\//i.test(path);
159
+ function toSarif(report, options) {
160
+ const byId = new Map(options.tells.map((t) => [t.id, t]));
161
+ const ids = [...new Set(report.findings.map((f) => f.tell))].sort();
162
+ const index = new Map(ids.map((id, i) => [id, i]));
163
+ const rules = ids.map((id) => {
164
+ const t = byId.get(id);
165
+ return {
166
+ id,
167
+ name: t?.name ?? id,
168
+ shortDescription: { text: t?.name ?? id },
169
+ ...t ? { fullDescription: { text: t.why }, help: { text: t.fix } } : {}
170
+ };
171
+ });
172
+ const results = report.findings.map((f) => ({
173
+ ruleId: f.tell,
174
+ ruleIndex: index.get(f.tell),
175
+ level: f.severity === "block" ? "error" : "warning",
176
+ message: { text: `${f.message}. ${f.fix}` },
177
+ locations: [
178
+ {
179
+ physicalLocation: {
180
+ artifactLocation: { uri: isUrl(f.path) ? f.path : f.path.replace(/\\/g, "/") },
181
+ ...f.line > 0 ? { region: { startLine: f.line, snippet: { text: f.excerpt } } } : {}
182
+ }
183
+ }
184
+ ],
185
+ partialFingerprints: { "craftFinding/v1": fnv1a(findingKey(f)) }
186
+ }));
187
+ return {
188
+ $schema: "https://json.schemastore.org/sarif-2.1.0.json",
189
+ version: "2.1.0",
190
+ runs: [
191
+ {
192
+ tool: {
193
+ driver: {
194
+ name: "craft",
195
+ version: report.catalogueVersion,
196
+ informationUri: options.informationUri ?? "https://www.npmjs.com/package/@domandigital/craft",
197
+ rules
198
+ }
199
+ },
200
+ results
201
+ }
202
+ ]
203
+ };
204
+ }
205
+
206
+ // src/character/facts.ts
207
+ var MONTH = "(?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:t(?:ember)?)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)";
208
+ var PATTERNS = [
209
+ ["url", /\bhttps?:\/\/[^\s<>"'`)\]]+|\bwww\.[^\s<>"'`)\]]+/gi],
210
+ ["email", /\b[\w.+-]+@[\w-]+(?:\.[\w-]+)+\b/g],
211
+ ["phone", /(?:\+44\s?\(?0?\)?\s?|\b0)\d(?:[\s-]?\d){8,9}\b/g],
212
+ ["postcode", /\b[A-Z]{1,2}\d[A-Z\d]?\s?\d[A-Z]{2}\b/g],
213
+ ["money", /[£$€]\s?\d[\d,]*(?:\.\d{1,2})?(?:\s?(?:k|m|bn)\b)?|\b\d[\d,]*(?:\.\d{1,2})?\s?(?:pounds|GBP)\b/gi],
214
+ ["date", new RegExp(`\\b\\d{1,2}(?:st|nd|rd|th)?\\s+${MONTH}\\b(?:\\s+\\d{4})?|\\b${MONTH}\\s+\\d{1,2}(?:st|nd|rd|th)?\\b(?:,?\\s+\\d{4})?|\\b${MONTH}\\s+\\d{4}\\b|\\b\\d{1,2}\\/\\d{1,2}\\/\\d{2,4}\\b|\\b(?:19|20)\\d{2}\\b`, "g")],
215
+ ["time", /\b\d{1,2}(?:[:.]\d{2})?\s?(?:am|pm)\b|\b\d{1,2}:\d{2}\b/gi],
216
+ ["number", /\b\d[\d,]*(?:\.\d+)?%?|\b\d+(?:\.\d+)?\s?(?:per ?cent)\b/gi]
217
+ ];
218
+ var NAME = /[A-Z][a-zA-Z’'&-]*[a-zA-Z](?:\s+(?:of|and|&|the)?\s*[A-Z][a-zA-Z’'&-]*[a-zA-Z])*/g;
219
+ var normalise = (kind, value) => {
220
+ const v = value.trim();
221
+ if (kind === "phone") return v.replace(/[^\d+]/g, "").replace(/^\+44\(?0?\)?/, "0").replace(/^\+44/, "0");
222
+ if (kind === "money" || kind === "number") return v.replace(/[\s,]/g, "").toLowerCase();
223
+ if (kind === "postcode") return v.replace(/\s/g, "").toUpperCase();
224
+ if (kind === "url") return v.replace(/[.,;:]+$/, "").toLowerCase();
225
+ return v.replace(/\s+/g, " ").toLowerCase();
226
+ };
227
+ function visibleText(file) {
228
+ const prose = /\.(md|markdown|mdx|txt)$/i.test(file.path);
229
+ return (prose ? extractCopy(file) : extractStrings(file)).map((b) => b.text).join("\n");
230
+ }
231
+ function protectedFacts(text) {
232
+ const facts = [];
233
+ let rest = text;
234
+ const mask = (start, length) => {
235
+ rest = rest.slice(0, start) + " ".repeat(length) + rest.slice(start + length);
236
+ };
237
+ for (const [kind, pattern] of PATTERNS) {
238
+ for (const m of [...rest.matchAll(pattern)]) {
239
+ const value = m[0].replace(/[.,;:]+$/, "");
240
+ facts.push({ kind, value, key: `${kind}:${normalise(kind, value)}` });
241
+ mask(m.index ?? 0, m[0].length);
242
+ }
243
+ }
244
+ const lines = rest.split("\n");
245
+ for (const line of lines) {
246
+ if (/^\s{0,3}#{1,6}\s/.test(line)) continue;
247
+ for (const m of line.matchAll(NAME)) {
248
+ const at = m.index ?? 0;
249
+ const before = line.slice(0, at).trimEnd();
250
+ const opensSentence = before === "" || /[.!?:"“(>*-]$/.test(before) || /^\s*(?:[-*+]|\d+\.)$/.test(before);
251
+ const word = m[0];
252
+ const acronym = /^[A-Z]{2,}$/.test(word);
253
+ if (opensSentence && !acronym && !/\s/.test(word)) continue;
254
+ if (word === "I") continue;
255
+ facts.push({ kind: "name", value: word, key: `name:${normalise("name", word)}` });
256
+ }
257
+ }
258
+ return facts;
259
+ }
260
+ var unique = (facts) => [...new Map(facts.map((f) => [f.key, f])).values()];
261
+ function compareFacts(before, after) {
262
+ const a = unique(protectedFacts(before));
263
+ const b = unique(protectedFacts(after));
264
+ const inA = new Set(a.map((f) => f.key));
265
+ const inB = new Set(b.map((f) => f.key));
266
+ return {
267
+ before: a.length,
268
+ after: b.length,
269
+ lost: a.filter((f) => !inB.has(f.key)),
270
+ added: b.filter((f) => !inA.has(f.key))
271
+ };
272
+ }
273
+ function formatComparison(result, beforePath, afterPath) {
274
+ const lines = [`craft copy compare: ${beforePath} -> ${afterPath}`, ` ${result.before} protected facts before, ${result.after} after`];
275
+ if (result.lost.length) {
276
+ lines.push("", ` Lost (${result.lost.length}): restore each, or say why it went`);
277
+ for (const f of result.lost) lines.push(` ${f.kind.padEnd(9)} ${f.value}`);
278
+ }
279
+ if (result.added.length) {
280
+ lines.push("", ` Added (${result.added.length}): each needs a source, or it is invented`);
281
+ for (const f of result.added) lines.push(` ${f.kind.padEnd(9)} ${f.value}`);
282
+ }
283
+ if (!result.lost.length && !result.added.length) lines.push("", " Every fact kept, nothing added.");
284
+ return lines.join("\n");
285
+ }
286
+
287
+ // src/character/claims.ts
288
+ var CLAIM_KINDS = /* @__PURE__ */ new Set(["money", "number", "date"]);
289
+ var PROPER = "[A-Z][\\w&.\u2019'-]*(?:\\s+(?:of|and|&|the|for)?\\s*[A-Z][\\w&.\u2019'-]*)*";
290
+ var NOT_A_SOURCE = /^(?:We|I|It|This|That|These|Those|They|He|She|You|Our|Your|The|A|An|Each|Every|Most|Many|Some|One|Research|Studies|Experts|Data|Analysis|If|When|So|But|And|Or|In|On|At|From|For|By|As|Since|After|Before|Across|Over|Under|Its|Their|External|Source|UK|US|EU|Sometimes|Public|Recent|Industry|Independent|What|How|Why|Who|Where|Businesses|Customers|Clients|Patients|Consumers|People|Users|Online)$/;
291
+ var NOUN = "study|survey|report|research|analysis|benchmark|benchmarks|guidelines|docs|documentation|help centre|help center|pricing page|figures|statistics|case study";
292
+ var OWNED_NOUN = "guide|audit|support|pricing|data|site|website|blog";
293
+ var VERB = "found|finds|reports?|reported|says|said|states?|stated|estimates?|estimated|recommends?|publishes|published|advertises|advertised|shows|showed|claims";
294
+ var ATTRIBUTION = [
295
+ new RegExp(`\\b[Aa]ccording to\\s+(?:the\\s+)?(${PROPER})`, "g"),
296
+ new RegExp(`\\b(?:${NOUN})\\s+(?:by|from|commissioned by)\\s+(?:the\\s+)?(${PROPER})`, "g"),
297
+ // "Akamai’s 2017 retail study", "the 2020 Deloitte study", "Bark’s UK guide", "Wix’s own developer guidelines".
298
+ new RegExp(`\\b(${PROPER})(?:(?:\u2019s|'s)\\s+(?:own\\s+)?(?:\\d{4}\\s+)?(?:[A-Za-z]+\\s+)?(?:${NOUN}|${OWNED_NOUN})|\\s+(?:\\d{4}\\s+)?(?:[a-z]+\\s+)?(?:${NOUN}))\\b`, "g"),
299
+ // "Google also reported", "Shopify UK currently advertises".
300
+ new RegExp(`\\b(${PROPER})\\s+(?:also\\s+|now\\s+|currently\\s+|still\\s+)?(?:${VERB})\\b`, "g"),
301
+ /\((?:[Ss]ources?:\s*|via\s+(?=[A-Z]))([^)]+)\)/g,
302
+ /^\s*(?:source|sources)\s*:\s*(.+)$/gim
303
+ ];
304
+ var LINK = /\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)|\bhttps?:\/\/[^\s<>"')\]]+/g;
305
+ function cleanName(raw) {
306
+ const words = raw.trim().replace(/[.,;:]+$/, "").split(/\s+/);
307
+ while (words.length && NOT_A_SOURCE.test(words[0])) words.shift();
308
+ return words.join(" ").replace(/(?:’s|'s)(?=\s|$).*$/, "").trim();
309
+ }
310
+ var IMPERATIVE = /^(?:Add|Use|Check|Make|Get|Set|Include|Create|Update|List|Keep|Ask|Write|Track|Test|Claim|Build|Link|Submit|Verify|Review|Fix|Remove|Upload|Respond|Reply|Choose|Pick|Start|Run|Open|Install|Connect|Enable|Turn|Put|Give|Send|Book|Call|Read|See|Try|Compare|Aim|Target|Avoid|Post|Share|Mark|Tag)$/;
311
+ var atStart = (sentence, index) => /^[\s*_"“”’(>#|-]*(?:\d{1,3}[.)]\s*)?[\s*_]*(?:[\w ]+:\**\s*)?$/.test(sentence.slice(0, index)) || /\|\s*\**$/.test(sentence.slice(0, index));
312
+ function namedSources(sentence) {
313
+ const found = /* @__PURE__ */ new Set();
314
+ ATTRIBUTION.forEach((pattern, i) => {
315
+ for (const m of sentence.matchAll(pattern)) {
316
+ const raw = m[1].trim();
317
+ const word = !/\s/.test(raw) && !/[’']s$/.test(m[0].split(/\s+/)[0]);
318
+ if (i === 2 && word && atStart(sentence, m.index ?? 0)) continue;
319
+ if (IMPERATIVE.test(raw.split(/\s+/)[0]) && atStart(sentence, m.index ?? 0)) continue;
320
+ const name = cleanName(raw);
321
+ if (!name || name.length < 2 || !/\s/.test(name) && /[a-z]ing$/.test(name)) continue;
322
+ found.add(name);
323
+ }
324
+ });
325
+ for (const m of sentence.matchAll(LINK)) found.add(m[2] ?? m[0]);
326
+ return [...found];
327
+ }
328
+ var FOOTNOTE_REF = /\[\^([\w-]+)\](?!:)/g;
329
+ var FOOTNOTE_DEF = /^[ \t]*\[\^([\w-]+)\]:[ \t]*(.+)$/gm;
330
+ var ASSUMPTION = /^(?:\*\*)?(?:assume|suppose|imagine|say|let's say|for example, say|picture)\b/i;
331
+ var LEAD = /^\s*(?:#{1,6}\s+|>\s*|[-*+]\s+|\d+[.)]\s+)*/;
332
+ function sentences(text) {
333
+ const out = [];
334
+ let start = 0;
335
+ let paragraph = 0;
336
+ const push = (end) => {
337
+ const raw = text.slice(start, end);
338
+ const lead = raw.length - raw.trimStart().length;
339
+ if (raw.trim()) out.push({ text: raw.trim(), offset: start + lead, paragraph });
340
+ };
341
+ for (const m of text.matchAll(/(?<!(?:^|\n)[ \t]*\d{1,3})[.!?](?:\[\^[\w-]+\])*(?=["”’)]?[ \t]+["“(*]*[A-Z0-9£$€])|\n+/g)) {
342
+ const end = (m.index ?? 0) + m[0].length;
343
+ push(end);
344
+ start = end;
345
+ if (/\n\s*\n/.test(m[0]) || /^\s*(?:#|\|)/.test(text.slice(end))) paragraph += 1;
346
+ }
347
+ push(text.length);
348
+ return out;
349
+ }
350
+ var MAX = 180;
351
+ var trim = (s) => s.length > MAX ? `${s.slice(0, MAX - 1).trimEnd()}\u2026` : s;
352
+ function findClaims(files) {
353
+ const claims = [];
354
+ for (const file of files) {
355
+ for (const block of extractCopy(file)) {
356
+ const notes = new Map([...block.text.matchAll(FOOTNOTE_DEF)].map((m) => [m[1], m[2].trim()]));
357
+ let carried;
358
+ for (const s of sentences(block.text)) {
359
+ if (/^\s*#{1,6}\s/.test(s.text) || /^\s*\[\^[\w-]+\]:/.test(s.text)) continue;
360
+ const refs = [...s.text.matchAll(FOOTNOTE_REF)].map((m) => notes.get(m[1]) ?? `footnote ${m[1]}`);
361
+ const text = s.text.replace(FOOTNOTE_REF, "");
362
+ const body = text.replace(LEAD, "");
363
+ const own = [...namedSources(text), ...refs];
364
+ if (own.length) carried = { paragraph: s.paragraph, sources: own };
365
+ if (ASSUMPTION.test(body)) continue;
366
+ const facts = protectedFacts(body).filter((f) => CLAIM_KINDS.has(f.kind));
367
+ if (facts.length === 0 && own.length === 0) continue;
368
+ const nearby = own.length === 0 && carried?.paragraph === s.paragraph ? carried.sources : void 0;
369
+ const at = block.offset + s.offset;
370
+ claims.push({
371
+ path: file.path,
372
+ line: file.text.slice(0, at).split("\n").length,
373
+ sentence: trim(text.replace(/\s+/g, " ")),
374
+ facts,
375
+ sources: own,
376
+ ...nearby ? { nearby } : {}
377
+ });
378
+ }
379
+ }
380
+ }
381
+ return { files: files.length, claims, unsourced: claims.filter((c) => c.sources.length === 0).length };
382
+ }
383
+ function formatClaims(report, label = "craft copy claims") {
384
+ const { claims, unsourced } = report;
385
+ if (claims.length === 0) return `${label}: no prices, figures, dates or named sources in ${report.files} file${report.files === 1 ? "" : "s"}.`;
386
+ const lines = [
387
+ `${label}: ${claims.length} claim${claims.length === 1 ? "" : "s"} to check, ${unsourced} with no source named.`,
388
+ " Check each against its primary source before it ships. Sourced means a source is named, not that it agrees."
389
+ ];
390
+ for (const c of claims) {
391
+ const what = [
392
+ ...c.facts.map((f) => f.value),
393
+ ...c.sources.map((s) => `source: ${s}`),
394
+ ...c.nearby ? [`${c.nearby.join(", ")} named earlier in the paragraph: does it cover this?`] : []
395
+ ].join(" \xB7 ");
396
+ lines.push("", ` ${c.path}:${c.line} ${c.sources.length ? "sourced " : "UNSOURCED"} ${what}`, ` ${c.sentence}`);
397
+ }
398
+ return lines.join("\n");
399
+ }
400
+
401
+ // src/character/format.ts
402
+ function formatReport(report, title) {
403
+ const lines = [];
404
+ let current = "";
405
+ for (const f of report.findings) {
406
+ if (f.path !== current) {
407
+ current = f.path;
408
+ lines.push("", f.path);
409
+ }
410
+ lines.push(` ${String(f.line).padStart(4)} ${f.tell} (gen ${f.generation}, ${f.severity}) ${f.message}`);
411
+ lines.push(` ${f.excerpt}`);
412
+ }
413
+ const fixes = new Map(report.findings.map((f) => [f.tell, f.fix]));
414
+ if (fixes.size > 0) {
415
+ lines.push("", "What to do instead:");
416
+ for (const [tell, fix] of fixes) lines.push(` ${tell}: ${fix}`);
417
+ }
418
+ for (const r of report.rejectedExceptions) {
419
+ lines.push("", `Exception not applied (${r.exception.tell}): ${r.reason}`);
420
+ }
421
+ for (const e of report.excepted) {
422
+ lines.push("", `Excepted: ${e.count} \xD7 ${e.tell}, because ${e.because}`);
423
+ }
424
+ if (report.suppressed.length > 0) {
425
+ lines.push("", `Suppressed: ${report.suppressed.length} marked copy-ok or craft-ok (${report.suppressed.map((s) => `${s.path}:${s.line}`).join(", ")})`);
426
+ }
427
+ const { summary } = report;
428
+ const gens = `gen 1: ${summary.byGeneration[1]}, gen 2: ${summary.byGeneration[2]}, gen 3: ${summary.byGeneration[3]}`;
429
+ lines.push(
430
+ "",
431
+ `${title}: ${summary.findings} finding${summary.findings === 1 ? "" : "s"} in ${summary.files} file${summary.files === 1 ? "" : "s"} (${gens}). Catalogue ${report.catalogueVersion}.`
432
+ );
433
+ return lines.join("\n").replace(/^\n/, "");
434
+ }
435
+
436
+ // src/character/cli.ts
437
+ var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "build", "out", ".next", ".turbo", ".vercel", "coverage", "vendor", ".cache"]);
438
+ var MAX_BYTES = 1e6;
439
+ var NOT_SITE_COPY = /^(?:README|CHANGELOG|CLAUDE|AGENTS|CONTRIBUTING|LICENSE|CODE_OF_CONDUCT|SECURITY)(?:\.[a-z]+)?$/i;
440
+ var HELP = `craft: find the AI look and say what to do instead.
441
+
442
+ Usage
443
+ craft scan [paths...] [--staged] [--json] [--strict] [--direction <file>] [ci options]
444
+ craft copy [paths...] [--gate] [--json] [--strict] [--direction <file>] [ci options]
445
+ craft copy compare <before> <after> [--json]
446
+ craft copy claims <paths...> [--json]
447
+ craft tells list [--json]
448
+ craft tells harvest <null.json | dir>... [--share 0.25] [--json]
449
+ craft snapshot <url> [--out <file>] [--width <px>] [--height <px>]
450
+ craft audit <url | snapshot.json> [--repo <dir>] [--null <null.json>] [--out <file>] [--json] [--strict] [ci options]
451
+ craft audit --pages <sitemap.xml | urls.txt> [--viewport 390,768,1440] [the same]
452
+ craft direction init [--snapshot <file>] [--client <name>] [--out <file>]
453
+ craft direction validate [--snapshot <file>] [--direction <file>] [--json]
454
+ craft direction propose [--snapshot <file>] [--estate <estate.json | dir>] [--out <file>]
455
+ craft null build --brief "<text>" --out <dir> [--runs 20] [--parallel 4] [--model <name>]
456
+ craft estate add <url | snapshot.json> --id <id> [--client <name>] [--estate <file>]
457
+ craft estate compare [<url | snapshot.json | id>] [--null <dir>] [--json] [--strict]
458
+ craft report <url | snapshot.json> [--repo <dir>] [--null <dir>] [--estate <file>] [--direction <file>] [--json] [--strict]
459
+ craft retrofit <url | snapshot.json> [the same] [--out RETROFIT.md]
460
+
461
+ scan Markup, component code and stylesheets (source and compiled CSS).
462
+ --staged reads the git index, for a pre-commit hook.
463
+ copy The prose in Markdown, markup and content files.
464
+ --gate applies the house copy policy: the blocking tier of COPY.md
465
+ fails the run. What copy-check and the pre-commit hook run.
466
+ compare lists the protected facts (prices, numbers, dates, times,
467
+ phones, emails, links, postcodes, names) a rewrite lost or added.
468
+ Exit 1 if any: restore it, source it, or say why.
469
+ claims lists every sentence holding a price, figure, date or named
470
+ source, marked sourced or UNSOURCED, for a person to check against
471
+ the primary source. It cannot tell true from false. Always exit 0.
472
+ tells The catalogue this build judges against. harvest reads null models
473
+ for choices the model keeps making that the catalogue does not know.
474
+ snapshot Render a page in a browser and save what it looks like, as JSON.
475
+ audit Judge a rendered page (live, or a saved snapshot) and fingerprint it.
476
+ --repo also scans that site's source, so one report covers both.
477
+ --null scores how typical it is against a null model (comma-separate
478
+ several to pool them).
479
+ --pages audits every URL in a sitemap (a URL or a file) or a file of
480
+ URLs, one per line. --viewport audits each page at these widths.
481
+ snapshot and audit need Playwright: npm i -D playwright.
482
+ direction The site's art-direction.json: every choice with a reason from the
483
+ client's world. init writes today's choices with empty reasons;
484
+ validate applies the reason rule; propose reads colours off the
485
+ sources' PNG photos and drafts choices for a person to confirm.
486
+ null The counterfactual: about 20 pages \`claude -p\` builds from the brief
487
+ alone, snapshotted and fingerprinted into null.json. Resumable.
488
+ estate The register of shipped sites (estate.json). add fingerprints a site;
489
+ compare lists the nearest, and marks siblings: two sites closer than
490
+ two pages Claude builds for one brief. --strict exits 1 on a sibling.
491
+ report Every signal for one site: tells, typicality, the estate, the reasons.
492
+ A verdict (default, mixed, decided, unproven) and what to do, in order.
493
+ Anything not given is "not measured", and never counts as a pass.
494
+ retrofit The report as a checklist: decide, then change type, colour, shape,
495
+ effects, motion and copy. Never the page grammar.
496
+
497
+ ci options (scan, copy, audit)
498
+ --baseline <file> Report only findings the baseline does not already
499
+ hold. Keyed on tell, path and excerpt, not line.
500
+ --update-baseline Write every current finding to --baseline, exit 0.
501
+ --sarif <file> Also write SARIF 2.1.0, for GitHub code scanning.
502
+ --config <file> craft.config.json, default: the working directory.
503
+
504
+ Exceptions come from art-direction.json in the working directory, or --direction.
505
+ craft.config.json holds ignore globs, copyPaths, and severity changes, each
506
+ with a because. .claude, .agents and .cursor are never walked.
507
+ Every tell ships as warn: exit 1 only on a block, or on any finding with --strict.
508
+ --json output always carries schemaVersion.`;
509
+ var VALUE_FLAGS = {
510
+ "--direction": "direction",
511
+ "--out": "out",
512
+ "--repo": "repo",
513
+ "--width": "width",
514
+ "--height": "height",
515
+ "--snapshot": "snapshot",
516
+ "--estate": "estate",
517
+ "--client": "client",
518
+ "--brief": "brief",
519
+ "--runs": "runs",
520
+ "--parallel": "parallel",
521
+ "--model": "model",
522
+ "--share": "share",
523
+ "--null": "null",
524
+ "--id": "id",
525
+ "--config": "config",
526
+ "--baseline": "baseline",
527
+ "--sarif": "sarif",
528
+ "--pages": "pages",
529
+ "--viewport": "viewport"
530
+ };
531
+ function parseFlags(args) {
532
+ const flags = { positional: [], json: false, strict: false, staged: false, gate: false, updateBaseline: false };
533
+ for (let i = 0; i < args.length; i += 1) {
534
+ const a = args[i];
535
+ if (a === "--json") flags.json = true;
536
+ else if (a === "--strict") flags.strict = true;
537
+ else if (a === "--staged") flags.staged = true;
538
+ else if (a === "--gate") flags.gate = true;
539
+ else if (a === "--update-baseline") flags.updateBaseline = true;
540
+ else if (a in VALUE_FLAGS) {
541
+ const value = args[i + 1];
542
+ i += 1;
543
+ if (!value || value.startsWith("--")) return `${a} needs a value`;
544
+ flags[VALUE_FLAGS[a]] = value;
545
+ } else if (a.startsWith("--")) return `unknown option ${a}`;
546
+ else flags.positional.push(a);
547
+ }
548
+ return flags;
549
+ }
550
+ function walk(root, cwd, into, wanted, skipDir = (_) => false, ignored = (_) => false) {
551
+ const stat = statSync(root);
552
+ if (stat.isFile()) {
553
+ if (stat.size <= MAX_BYTES) into.push({ path: relative(cwd, root) || root, text: readFileSync(root, "utf8") });
554
+ return;
555
+ }
556
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
557
+ const path = join(root, entry.name);
558
+ if (ignored(relative(cwd, path))) continue;
559
+ if (entry.isDirectory()) {
560
+ if (!SKIP_DIRS.has(entry.name) && !skipDir(entry.name)) walk(path, cwd, into, wanted, skipDir, ignored);
561
+ } else if (entry.isFile() && wanted(entry.name)) {
562
+ walk(path, cwd, into, wanted, skipDir, ignored);
563
+ }
564
+ }
565
+ }
566
+ function readPaths(paths, cwd, wanted, skipDir, ignore = []) {
567
+ const files = [];
568
+ const ignored = ignoreMatcher([...DEFAULT_IGNORE, ...ignore]);
569
+ for (const p of paths) {
570
+ const abs = resolve(cwd, p);
571
+ if (!existsSync(abs)) throw new Error(`no such path: ${p}`);
572
+ walk(abs, cwd, files, wanted, skipDir, ignored);
573
+ }
574
+ return files;
575
+ }
576
+ function readStaged(cwd, wanted, ignore) {
577
+ const ignored = ignoreMatcher([...DEFAULT_IGNORE, ...ignore]);
578
+ const names = execFileSync("git", ["diff", "--cached", "--name-only", "--diff-filter=ACMR", "-z"], { cwd, encoding: "utf8" }).split("\0").filter((p) => p && wanted(p) && !ignored(p));
579
+ return names.map((path) => ({
580
+ path,
581
+ // The index, not the working tree: the commit is what gets checked.
582
+ text: execFileSync("git", ["show", `:${path}`], { cwd, encoding: "utf8", maxBuffer: MAX_BYTES * 4 })
583
+ }));
584
+ }
585
+ var NO_CONFIG = { ignore: [], severity: {} };
586
+ function loadConfig(flags, root) {
587
+ const path = resolve(root, flags.config ?? "craft.config.json");
588
+ if (!existsSync(path)) {
589
+ if (flags.config) throw new Error(`no such file: ${flags.config}`);
590
+ return NO_CONFIG;
591
+ }
592
+ return parseCraftConfig(JSON.parse(readFileSync(path, "utf8")), new Set(CATALOGUE.map((t) => t.id)), flags.config ?? "craft.config.json");
593
+ }
594
+ function loadExceptions(flags, cwd) {
595
+ const fromConfig = severityExceptions(loadConfig(flags, cwd));
596
+ const path = resolve(cwd, flags.direction ?? "art-direction.json");
597
+ if (!existsSync(path)) {
598
+ if (flags.direction) throw new Error(`no such file: ${flags.direction}`);
599
+ return fromConfig;
600
+ }
601
+ const data = JSON.parse(readFileSync(path, "utf8"));
602
+ return [...Array.isArray(data.exceptions) ? data.exceptions : [], ...fromConfig];
603
+ }
604
+ function prepareReport(report, flags, io, config) {
605
+ let result = applySeverity(report, config);
606
+ if (flags.updateBaseline) {
607
+ if (!flags.baseline) throw new Error("--update-baseline needs --baseline <file>");
608
+ const baseline = createBaseline(result);
609
+ writeFileSync(resolve(io.cwd, flags.baseline), `${JSON.stringify(baseline, null, 2)}
610
+ `);
611
+ io.out(`craft: baseline of ${result.findings.length} finding${result.findings.length === 1 ? "" : "s"} written to ${flags.baseline}`);
612
+ return null;
613
+ }
614
+ if (flags.baseline) {
615
+ const path = resolve(io.cwd, flags.baseline);
616
+ if (!existsSync(path)) throw new Error(`no such file: ${flags.baseline} (create it with --update-baseline)`);
617
+ result = applyBaseline(result, parseBaseline(JSON.parse(readFileSync(path, "utf8")), flags.baseline));
618
+ }
619
+ if (flags.sarif) {
620
+ writeFileSync(resolve(io.cwd, flags.sarif), `${JSON.stringify(toSarif(result, { tells: CATALOGUE }), null, 2)}
621
+ `);
622
+ }
623
+ return result;
624
+ }
625
+ function finish(report, flags, title, io) {
626
+ io.out(flags.json ? toJson(report) : formatReport(report, title));
627
+ if (!flags.json && report.baselined) {
628
+ io.out(`${report.baselined} known finding${report.baselined === 1 ? "" : "s"} not shown: already in ${flags.baseline}.`);
629
+ }
630
+ if (report.summary.blocking > 0) return 1;
631
+ if (flags.strict && report.summary.findings > 0) return 1;
632
+ return 0;
633
+ }
634
+ var SOURCE_FILE = (p) => ["markup", "script", "css"].includes(fileKind(p)) && !/\.d\.ts$|\.min\.js$/.test(p);
635
+ var COPY_FILE = (p) => (["markup", "prose", "script"].includes(fileKind(p)) || /\.jsonl?$/i.test(p)) && !NOT_SITE_COPY.test(p.split("/").pop() ?? p) && !/(?:^|\/)(?:package(?:-lock)?|tsconfig[\w.-]*|art-direction)\.json$/.test(p);
636
+ var NOT_COPY_DIR = (name) => name.startsWith("_");
637
+ function run(argv, io) {
638
+ const [command, ...rest] = argv;
639
+ if (command === "direction") {
640
+ return import("./cli-U3SZVHSK.js").then((m) => m.runDirection(rest, io));
641
+ }
642
+ if (command === "report" || command === "retrofit") {
643
+ return import("./cli-DBZMVYYV.js").then((m) => m.runReport(command, rest, io));
644
+ }
645
+ if (command === "estate") {
646
+ return import("./cli-VREYWCT4.js").then((m) => m.runEstate(rest, io));
647
+ }
648
+ if (command === "null") {
649
+ return import("./cli-4ESXOLKV.js").then((m) => m.runNull(rest, io));
650
+ }
651
+ if (command === "tells" && rest[0] === "harvest") {
652
+ return import("./cli-4ESXOLKV.js").then((m) => m.runHarvest(rest, io)).catch((error) => {
653
+ io.err(`craft: ${error.message}`);
654
+ return 2;
655
+ });
656
+ }
657
+ if (command === "snapshot" || command === "audit") {
658
+ return import("./cli-T5RD2BTK.js").then((m) => m.runAudit(command, rest, io));
659
+ }
660
+ if (!command || command === "help" || command === "--help" || command === "-h") {
661
+ io.out(HELP);
662
+ return command ? 0 : 2;
663
+ }
664
+ try {
665
+ if (command === "tells") {
666
+ const flags = parseFlags(rest);
667
+ if (typeof flags === "string") throw new Error(flags);
668
+ if (flags.positional[0] !== "list") throw new Error("usage: craft tells list [--json] | craft tells harvest <null.json>...");
669
+ if (flags.json) {
670
+ const data = CATALOGUE.map(({ id, name, generation, surface, severity, why, fix }) => ({
671
+ id,
672
+ name,
673
+ generation,
674
+ surface,
675
+ severity,
676
+ why,
677
+ fix,
678
+ ...surface === "copy" ? { house: houseRule(id, name).tier, houseLabel: houseRule(id, name).label } : {}
679
+ }));
680
+ io.out(toJson({ version: CATALOGUE_VERSION, tells: data }));
681
+ } else {
682
+ io.out(`Catalogue ${CATALOGUE_VERSION}`);
683
+ for (const t of CATALOGUE) io.out(` ${t.id.padEnd(24)} gen ${t.generation} ${t.surface.padEnd(6)} ${t.name}`);
684
+ }
685
+ return 0;
686
+ }
687
+ if (command === "copy" && rest[0] === "compare") {
688
+ const flags = parseFlags(rest.slice(1));
689
+ if (typeof flags === "string") throw new Error(flags);
690
+ if (flags.positional.length !== 2) throw new Error("usage: craft copy compare <before> <after> [--json]");
691
+ const [before, after] = flags.positional.map((p) => {
692
+ const abs = resolve(io.cwd, p);
693
+ if (!existsSync(abs)) throw new Error(`no such path: ${p}`);
694
+ return visibleText({ path: p, text: readFileSync(abs, "utf8") });
695
+ });
696
+ const result = compareFacts(before, after);
697
+ io.out(flags.json ? toJson(result) : formatComparison(result, flags.positional[0], flags.positional[1]));
698
+ return result.lost.length || result.added.length ? 1 : 0;
699
+ }
700
+ if (command === "copy" && rest[0] === "claims") {
701
+ const flags = parseFlags(rest.slice(1));
702
+ if (typeof flags === "string") throw new Error(flags);
703
+ if (flags.positional.length === 0) throw new Error("usage: craft copy claims <paths...> [--json]");
704
+ const report = findClaims(readPaths(flags.positional, io.cwd, COPY_FILE, NOT_COPY_DIR));
705
+ io.out(flags.json ? toJson(report) : formatClaims(report));
706
+ return 0;
707
+ }
708
+ if (command === "scan" || command === "copy") {
709
+ const flags = parseFlags(rest);
710
+ if (typeof flags === "string") throw new Error(flags);
711
+ const wanted = command === "scan" ? SOURCE_FILE : COPY_FILE;
712
+ const config = loadConfig(flags, io.cwd);
713
+ const paths = flags.positional.length > 0 ? flags.positional : command === "copy" ? config.copyPaths ?? [] : ["."];
714
+ if (command === "copy" && paths.length === 0 && !flags.staged) throw new Error("usage: craft copy <paths...> (or copyPaths in craft.config.json)");
715
+ const files = flags.staged ? readStaged(io.cwd, wanted, config.ignore) : readPaths(paths, io.cwd, wanted, command === "copy" ? NOT_COPY_DIR : void 0, config.ignore);
716
+ const exceptions = loadExceptions(flags, io.cwd);
717
+ let report = command === "scan" ? scanSource(files, { exceptions }) : checkCopy(files, { exceptions });
718
+ if (command === "copy" && flags.gate) report = applyHouseGate(report);
719
+ const prepared = prepareReport(report, flags, io, config);
720
+ if (!prepared) return 0;
721
+ return finish(prepared, flags, command === "scan" ? "craft scan" : "craft copy", io);
722
+ }
723
+ throw new Error(`unknown command "${command}". Run craft --help.`);
724
+ } catch (error) {
725
+ io.err(`craft: ${error.message}`);
726
+ return 2;
727
+ }
728
+ }
729
+
730
+ export {
731
+ applySeverity,
732
+ toJson,
733
+ parseFlags,
734
+ readPaths,
735
+ loadConfig,
736
+ loadExceptions,
737
+ prepareReport,
738
+ finish,
739
+ SOURCE_FILE,
740
+ COPY_FILE,
741
+ NOT_COPY_DIR,
742
+ run
743
+ };