@domandigital/craft 0.10.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # @domandigital/craft
2
2
 
3
+ ## 0.11.0
4
+
5
+ ### Minor Changes
6
+
7
+ - e2ea0e8: Phase I: craft in CI.
8
+
9
+ - `craft.config.json`: `ignore` globs, `copyPaths` (where `craft copy` looks when given no path), and `severity` changes per tell. Every severity change needs a `because` of at least a sentence; `off` is applied as an exception, so the report still lists what it silenced. `--config <file>` points elsewhere.
10
+ - `.claude`, `.agents` and `.cursor` are never walked (a path named on the command line is still read).
11
+ - `--baseline <file>` reports only findings the baseline does not hold; `--update-baseline` writes it. Findings are keyed on tell, path and excerpt, not line, and counted.
12
+ - `--sarif <file>` writes SARIF 2.1.0 for GitHub code scanning.
13
+ - Every `--json` output carries `schemaVersion: 1`. The audit JSON adds `pages` and `failed`.
14
+ - `craft audit --pages <sitemap.xml | urls.txt>` and `--viewport 390,768,1440`. A page that will not load is listed as not measured and fails `--strict`.
15
+ - **Behaviour change:** a page that answers with an HTTP error (404, 500) is no longer snapshotted as if it were the page. `snapshotUrls` reports it as an error and `snapshotUrl` throws `HTTP <status>`. Before, a missing page was measured, and reported clean.
16
+ - New exports: `createBaseline`, `applyBaseline`, `parseBaseline`, `findingKey`, `parseCraftConfig`, `applySeverity`, `severityExceptions`, `globToRegExp`, `ignoreMatcher`, `DEFAULT_IGNORE`, `toSarif`, `toJson`, `JSON_SCHEMA_VERSION`.
17
+
18
+ - 3eb1560: CommonJS consumers now get the CommonJS type declarations. The `exports` map put a top-level `types` (the ESM `.d.ts`) ahead of `require`, so TypeScript resolving a `require` under `node16` stopped at the ESM file ("Masquerading as ESM" in @arethetypeswrong/cli). `types` now sits inside `import` and `require` separately. Runtime behaviour is unchanged.
19
+
20
+ Requires Node 22.12 or later (`engines.node`). Node 20 reached end of life on 2026-04-30. Also declares `sideEffects` so bundlers can tree-shake (craft keeps its CSS).
21
+
3
22
  ## 0.10.0
4
23
 
5
24
  ### Minor Changes
package/README.md CHANGED
@@ -94,6 +94,41 @@ reason, in `art-direction.json`:
94
94
  { "exceptions": [{ "tell": "ai-violet", "because": "Violet is on the van, the cards and the fascia." }] }
95
95
  ```
96
96
 
97
+ ### In CI
98
+
99
+ ```bash
100
+ npx craft scan --baseline craft-baseline.json --update-baseline # once: adopt today's findings
101
+ npx craft scan --baseline craft-baseline.json --strict --sarif craft.sarif
102
+ npx craft audit --pages https://example.com/sitemap.xml --viewport 390,1440
103
+ ```
104
+
105
+ - `--baseline` reports only findings the baseline does not already hold. A
106
+ finding is keyed on its tell, path and excerpt, never its line, so an edit
107
+ above a known finding does not make it new. The output says how many known
108
+ findings it left out.
109
+ - `--sarif <file>` writes SARIF 2.1.0 for GitHub code scanning. Findings on a
110
+ rendered page carry the URL, not a file, so code scanning does not show them.
111
+ - `--json` output always carries `schemaVersion` (currently 1).
112
+ - `--pages` audits every URL in a sitemap (or a sitemap index, one level deep)
113
+ or a file of URLs; `--viewport` audits each at those widths. A page that
114
+ will not load is listed as not measured, and fails `--strict`.
115
+
116
+ `craft.config.json` in the repo root holds what craft should know about the
117
+ repo. `.claude`, `.agents` and `.cursor` are never walked, config or not.
118
+
119
+ ```json
120
+ {
121
+ "ignore": ["docs/archive/**", "*.stories.tsx"],
122
+ "copyPaths": ["content", "src/app"],
123
+ "severity": {
124
+ "em-dash": { "level": "block", "because": "This client's style guide bans them outright." }
125
+ }
126
+ }
127
+ ```
128
+
129
+ A severity change needs a `because`, like an exception. `off` is applied as an
130
+ exception, so the report lists what it silenced.
131
+
97
132
  ### Copy
98
133
 
99
134
  [COPY.md](./COPY.md) is the house copy standard: the blocking tier, the review
@@ -6,7 +6,7 @@ import {
6
6
  snapshotPage,
7
7
  snapshotUrl,
8
8
  snapshotUrls
9
- } from "./chunk-TSKRBIOC.js";
9
+ } from "./chunk-JP75P2DU.js";
10
10
  import "./chunk-QMYERCBB.js";
11
11
  import "./chunk-3NGWXZZE.js";
12
12
  export {
package/dist/audit.cjs CHANGED
@@ -3270,7 +3270,9 @@ async function snapshotUrls(urls, options = {}) {
3270
3270
  for (const url of urls) {
3271
3271
  const page = await browser.newPage({ viewport: options.viewport ?? { width: 1440, height: 900 } });
3272
3272
  try {
3273
- await page.goto(url, { waitUntil: "domcontentloaded", timeout: options.timeoutMs ?? 45e3 });
3273
+ const response = await page.goto(url, { waitUntil: "domcontentloaded", timeout: options.timeoutMs ?? 45e3 });
3274
+ const status = typeof response?.status === "function" ? response.status() : 200;
3275
+ if (status >= 400) throw new Error(`HTTP ${status}`);
3274
3276
  out.push({ url, snapshot: await snapshotPage(page, url, options) });
3275
3277
  } catch (error) {
3276
3278
  out.push({ url, error: error.message });
package/dist/audit.js CHANGED
@@ -3238,7 +3238,9 @@ async function snapshotUrls(urls, options = {}) {
3238
3238
  for (const url of urls) {
3239
3239
  const page = await browser.newPage({ viewport: options.viewport ?? { width: 1440, height: 900 } });
3240
3240
  try {
3241
- await page.goto(url, { waitUntil: "domcontentloaded", timeout: options.timeoutMs ?? 45e3 });
3241
+ const response = await page.goto(url, { waitUntil: "domcontentloaded", timeout: options.timeoutMs ?? 45e3 });
3242
+ const status = typeof response?.status === "function" ? response.status() : 200;
3243
+ if (status >= 400) throw new Error(`HTTP ${status}`);
3242
3244
  out.push({ url, snapshot: await snapshotPage(page, url, options) });
3243
3245
  } catch (error) {
3244
3246
  out.push({ url, error: error.message });
@@ -10,11 +10,199 @@ import {
10
10
  scanSource
11
11
  } from "./chunk-3NGWXZZE.js";
12
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
+
13
81
  // src/character/cli.ts
14
82
  import { execFileSync } from "child_process";
15
- import { existsSync, readFileSync, readdirSync, statSync } from "fs";
83
+ import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "fs";
16
84
  import { join, relative, resolve } from "path";
17
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
+
18
206
  // src/character/facts.ts
19
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)?)";
20
208
  var PATTERNS = [
@@ -252,14 +440,15 @@ var NOT_SITE_COPY = /^(?:README|CHANGELOG|CLAUDE|AGENTS|CONTRIBUTING|LICENSE|COD
252
440
  var HELP = `craft: find the AI look and say what to do instead.
253
441
 
254
442
  Usage
255
- craft scan [paths...] [--staged] [--json] [--strict] [--direction <file>]
256
- craft copy <paths...> [--gate] [--json] [--strict] [--direction <file>]
443
+ craft scan [paths...] [--staged] [--json] [--strict] [--direction <file>] [ci options]
444
+ craft copy [paths...] [--gate] [--json] [--strict] [--direction <file>] [ci options]
257
445
  craft copy compare <before> <after> [--json]
258
446
  craft copy claims <paths...> [--json]
259
447
  craft tells list [--json]
260
448
  craft tells harvest <null.json | dir>... [--share 0.25] [--json]
261
449
  craft snapshot <url> [--out <file>] [--width <px>] [--height <px>]
262
- craft audit <url | snapshot.json> [--repo <dir>] [--null <null.json>] [--out <file>] [--json] [--strict]
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]
263
452
  craft direction init [--snapshot <file>] [--client <name>] [--out <file>]
264
453
  craft direction validate [--snapshot <file>] [--direction <file>] [--json]
265
454
  craft direction propose [--snapshot <file>] [--estate <estate.json | dir>] [--out <file>]
@@ -287,6 +476,8 @@ audit Judge a rendered page (live, or a saved snapshot) and fingerprint it.
287
476
  --repo also scans that site's source, so one report covers both.
288
477
  --null scores how typical it is against a null model (comma-separate
289
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.
290
481
  snapshot and audit need Playwright: npm i -D playwright.
291
482
  direction The site's art-direction.json: every choice with a reason from the
292
483
  client's world. init writes today's choices with empty reasons;
@@ -303,8 +494,18 @@ report Every signal for one site: tells, typicality, the estate, the reasons.
303
494
  retrofit The report as a checklist: decide, then change type, colour, shape,
304
495
  effects, motion and copy. Never the page grammar.
305
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
+
306
504
  Exceptions come from art-direction.json in the working directory, or --direction.
307
- Every tell ships as warn: exit 1 only on a block, or on any finding with --strict.`;
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.`;
308
509
  var VALUE_FLAGS = {
309
510
  "--direction": "direction",
310
511
  "--out": "out",
@@ -320,16 +521,22 @@ var VALUE_FLAGS = {
320
521
  "--model": "model",
321
522
  "--share": "share",
322
523
  "--null": "null",
323
- "--id": "id"
524
+ "--id": "id",
525
+ "--config": "config",
526
+ "--baseline": "baseline",
527
+ "--sarif": "sarif",
528
+ "--pages": "pages",
529
+ "--viewport": "viewport"
324
530
  };
325
531
  function parseFlags(args) {
326
- const flags = { positional: [], json: false, strict: false, staged: false, gate: false };
532
+ const flags = { positional: [], json: false, strict: false, staged: false, gate: false, updateBaseline: false };
327
533
  for (let i = 0; i < args.length; i += 1) {
328
534
  const a = args[i];
329
535
  if (a === "--json") flags.json = true;
330
536
  else if (a === "--strict") flags.strict = true;
331
537
  else if (a === "--staged") flags.staged = true;
332
538
  else if (a === "--gate") flags.gate = true;
539
+ else if (a === "--update-baseline") flags.updateBaseline = true;
333
540
  else if (a in VALUE_FLAGS) {
334
541
  const value = args[i + 1];
335
542
  i += 1;
@@ -340,48 +547,86 @@ function parseFlags(args) {
340
547
  }
341
548
  return flags;
342
549
  }
343
- function walk(root, cwd, into, wanted, skipDir = (_) => false) {
550
+ function walk(root, cwd, into, wanted, skipDir = (_) => false, ignored = (_) => false) {
344
551
  const stat = statSync(root);
345
552
  if (stat.isFile()) {
346
553
  if (stat.size <= MAX_BYTES) into.push({ path: relative(cwd, root) || root, text: readFileSync(root, "utf8") });
347
554
  return;
348
555
  }
349
556
  for (const entry of readdirSync(root, { withFileTypes: true })) {
557
+ const path = join(root, entry.name);
558
+ if (ignored(relative(cwd, path))) continue;
350
559
  if (entry.isDirectory()) {
351
- if (!SKIP_DIRS.has(entry.name) && !skipDir(entry.name)) walk(join(root, entry.name), cwd, into, wanted, skipDir);
560
+ if (!SKIP_DIRS.has(entry.name) && !skipDir(entry.name)) walk(path, cwd, into, wanted, skipDir, ignored);
352
561
  } else if (entry.isFile() && wanted(entry.name)) {
353
- walk(join(root, entry.name), cwd, into, wanted, skipDir);
562
+ walk(path, cwd, into, wanted, skipDir, ignored);
354
563
  }
355
564
  }
356
565
  }
357
- function readPaths(paths, cwd, wanted, skipDir) {
566
+ function readPaths(paths, cwd, wanted, skipDir, ignore = []) {
358
567
  const files = [];
568
+ const ignored = ignoreMatcher([...DEFAULT_IGNORE, ...ignore]);
359
569
  for (const p of paths) {
360
570
  const abs = resolve(cwd, p);
361
571
  if (!existsSync(abs)) throw new Error(`no such path: ${p}`);
362
- walk(abs, cwd, files, wanted, skipDir);
572
+ walk(abs, cwd, files, wanted, skipDir, ignored);
363
573
  }
364
574
  return files;
365
575
  }
366
- function readStaged(cwd, wanted) {
367
- const names = execFileSync("git", ["diff", "--cached", "--name-only", "--diff-filter=ACMR", "-z"], { cwd, encoding: "utf8" }).split("\0").filter((p) => p && wanted(p));
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));
368
579
  return names.map((path) => ({
369
580
  path,
370
581
  // The index, not the working tree: the commit is what gets checked.
371
582
  text: execFileSync("git", ["show", `:${path}`], { cwd, encoding: "utf8", maxBuffer: MAX_BYTES * 4 })
372
583
  }));
373
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
+ }
374
594
  function loadExceptions(flags, cwd) {
595
+ const fromConfig = severityExceptions(loadConfig(flags, cwd));
375
596
  const path = resolve(cwd, flags.direction ?? "art-direction.json");
376
597
  if (!existsSync(path)) {
377
598
  if (flags.direction) throw new Error(`no such file: ${flags.direction}`);
378
- return [];
599
+ return fromConfig;
379
600
  }
380
601
  const data = JSON.parse(readFileSync(path, "utf8"));
381
- return Array.isArray(data.exceptions) ? data.exceptions : [];
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;
382
624
  }
383
625
  function finish(report, flags, title, io) {
384
- io.out(flags.json ? JSON.stringify(report, null, 2) : formatReport(report, title));
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
+ }
385
630
  if (report.summary.blocking > 0) return 1;
386
631
  if (flags.strict && report.summary.findings > 0) return 1;
387
632
  return 0;
@@ -392,25 +637,25 @@ var NOT_COPY_DIR = (name) => name.startsWith("_");
392
637
  function run(argv, io) {
393
638
  const [command, ...rest] = argv;
394
639
  if (command === "direction") {
395
- return import("./cli-2WBGTBKD.js").then((m) => m.runDirection(rest, io));
640
+ return import("./cli-U3SZVHSK.js").then((m) => m.runDirection(rest, io));
396
641
  }
397
642
  if (command === "report" || command === "retrofit") {
398
- return import("./cli-ADQQURNR.js").then((m) => m.runReport(command, rest, io));
643
+ return import("./cli-DBZMVYYV.js").then((m) => m.runReport(command, rest, io));
399
644
  }
400
645
  if (command === "estate") {
401
- return import("./cli-VQUNNIZM.js").then((m) => m.runEstate(rest, io));
646
+ return import("./cli-VREYWCT4.js").then((m) => m.runEstate(rest, io));
402
647
  }
403
648
  if (command === "null") {
404
- return import("./cli-PRWTNFVP.js").then((m) => m.runNull(rest, io));
649
+ return import("./cli-4ESXOLKV.js").then((m) => m.runNull(rest, io));
405
650
  }
406
651
  if (command === "tells" && rest[0] === "harvest") {
407
- return import("./cli-PRWTNFVP.js").then((m) => m.runHarvest(rest, io)).catch((error) => {
652
+ return import("./cli-4ESXOLKV.js").then((m) => m.runHarvest(rest, io)).catch((error) => {
408
653
  io.err(`craft: ${error.message}`);
409
654
  return 2;
410
655
  });
411
656
  }
412
657
  if (command === "snapshot" || command === "audit") {
413
- return import("./cli-EK3GSAOW.js").then((m) => m.runAudit(command, rest, io));
658
+ return import("./cli-T5RD2BTK.js").then((m) => m.runAudit(command, rest, io));
414
659
  }
415
660
  if (!command || command === "help" || command === "--help" || command === "-h") {
416
661
  io.out(HELP);
@@ -432,7 +677,7 @@ function run(argv, io) {
432
677
  fix,
433
678
  ...surface === "copy" ? { house: houseRule(id, name).tier, houseLabel: houseRule(id, name).label } : {}
434
679
  }));
435
- io.out(JSON.stringify({ version: CATALOGUE_VERSION, tells: data }, null, 2));
680
+ io.out(toJson({ version: CATALOGUE_VERSION, tells: data }));
436
681
  } else {
437
682
  io.out(`Catalogue ${CATALOGUE_VERSION}`);
438
683
  for (const t of CATALOGUE) io.out(` ${t.id.padEnd(24)} gen ${t.generation} ${t.surface.padEnd(6)} ${t.name}`);
@@ -449,7 +694,7 @@ function run(argv, io) {
449
694
  return visibleText({ path: p, text: readFileSync(abs, "utf8") });
450
695
  });
451
696
  const result = compareFacts(before, after);
452
- io.out(flags.json ? JSON.stringify(result, null, 2) : formatComparison(result, flags.positional[0], flags.positional[1]));
697
+ io.out(flags.json ? toJson(result) : formatComparison(result, flags.positional[0], flags.positional[1]));
453
698
  return result.lost.length || result.added.length ? 1 : 0;
454
699
  }
455
700
  if (command === "copy" && rest[0] === "claims") {
@@ -457,19 +702,23 @@ function run(argv, io) {
457
702
  if (typeof flags === "string") throw new Error(flags);
458
703
  if (flags.positional.length === 0) throw new Error("usage: craft copy claims <paths...> [--json]");
459
704
  const report = findClaims(readPaths(flags.positional, io.cwd, COPY_FILE, NOT_COPY_DIR));
460
- io.out(flags.json ? JSON.stringify(report, null, 2) : formatClaims(report));
705
+ io.out(flags.json ? toJson(report) : formatClaims(report));
461
706
  return 0;
462
707
  }
463
708
  if (command === "scan" || command === "copy") {
464
709
  const flags = parseFlags(rest);
465
710
  if (typeof flags === "string") throw new Error(flags);
466
711
  const wanted = command === "scan" ? SOURCE_FILE : COPY_FILE;
467
- if (command === "copy" && flags.positional.length === 0 && !flags.staged) throw new Error("usage: craft copy <paths...>");
468
- const files = flags.staged ? readStaged(io.cwd, wanted) : readPaths(flags.positional.length > 0 ? flags.positional : ["."], io.cwd, wanted, command === "copy" ? NOT_COPY_DIR : void 0);
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);
469
716
  const exceptions = loadExceptions(flags, io.cwd);
470
717
  let report = command === "scan" ? scanSource(files, { exceptions }) : checkCopy(files, { exceptions });
471
718
  if (command === "copy" && flags.gate) report = applyHouseGate(report);
472
- return finish(report, flags, command === "scan" ? "craft scan" : "craft copy", io);
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);
473
722
  }
474
723
  throw new Error(`unknown command "${command}". Run craft --help.`);
475
724
  } catch (error) {
@@ -479,9 +728,13 @@ function run(argv, io) {
479
728
  }
480
729
 
481
730
  export {
731
+ applySeverity,
732
+ toJson,
482
733
  parseFlags,
483
734
  readPaths,
735
+ loadConfig,
484
736
  loadExceptions,
737
+ prepareReport,
485
738
  finish,
486
739
  SOURCE_FILE,
487
740
  COPY_FILE,
@@ -1,6 +1,7 @@
1
1
  import {
2
- parseFlags
3
- } from "./chunk-JKIDWHG3.js";
2
+ parseFlags,
3
+ toJson
4
+ } from "./chunk-2AJB27BJ.js";
4
5
  import {
5
6
  fingerprint,
6
7
  fingerprintDistance
@@ -352,7 +353,7 @@ async function build(flags, io) {
352
353
  const toShoot = ids.filter((id) => existsSync(join(pages, `${id}.html`)) && !existsSync(join(shots, `${id}.json`)));
353
354
  if (toShoot.length) {
354
355
  io.out(`craft null: snapshotting ${toShoot.length} page${toShoot.length === 1 ? "" : "s"}`);
355
- const { snapshotUrls } = await import("./audit-EGDNWSQB.js");
356
+ const { snapshotUrls } = await import("./audit-QIPXL7E4.js");
356
357
  const results = await snapshotUrls(toShoot.map((id) => pathToFileURL(join(pages, `${id}.html`)).href));
357
358
  results.forEach((r, i) => {
358
359
  const id = toShoot[i];
@@ -439,7 +440,7 @@ function runHarvest(args, io) {
439
440
  const minShare = flags.share === void 0 ? void 0 : Number(flags.share);
440
441
  if (minShare !== void 0 && !(minShare > 0 && minShare <= 1)) throw new Error("--share needs a number above 0 and at most 1");
441
442
  const candidates = harvest(models, { minShare, copyTell });
442
- io.out(flags.json ? JSON.stringify({ briefs: models.map((m) => m.brief), candidates }, null, 2) : formatHarvest(candidates, models));
443
+ io.out(flags.json ? toJson({ briefs: models.map((m) => m.brief), candidates }) : formatHarvest(candidates, models));
443
444
  return 0;
444
445
  }
445
446
 
@@ -3,10 +3,11 @@ import {
3
3
  hueName,
4
4
  loadNull,
5
5
  shapeName
6
- } from "./chunk-APO4BT2F.js";
6
+ } from "./chunk-3OZQ4C5R.js";
7
7
  import {
8
- parseFlags
9
- } from "./chunk-JKIDWHG3.js";
8
+ parseFlags,
9
+ toJson
10
+ } from "./chunk-2AJB27BJ.js";
10
11
  import {
11
12
  fingerprint,
12
13
  fingerprintDistance
@@ -92,7 +93,7 @@ async function snapshotOf(target, cwd) {
92
93
  return snap;
93
94
  }
94
95
  if (!/^https?:\/\//.test(target)) throw new Error(`${target} is neither a URL, a snapshot file, nor a site in the register`);
95
- const { snapshotUrl } = await import("./audit-EGDNWSQB.js");
96
+ const { snapshotUrl } = await import("./audit-QIPXL7E4.js");
96
97
  return snapshotUrl(target);
97
98
  }
98
99
  var line = (distance, sibling, shared) => `${distance.toFixed(2)}${sibling ? " SIBLING" : ""}${shared.length ? ` shares ${shared.join(", ")}` : ""}`;
@@ -137,13 +138,13 @@ async function runEstate(args, io) {
137
138
  if (sub === "compare") {
138
139
  if (!target) {
139
140
  const pairs = estatePairs(register, at);
140
- io.out(flags.json ? JSON.stringify({ siblingAt: at, pairs }, null, 2) : formatPairs(pairs, at, register.sites.length));
141
+ io.out(flags.json ? toJson({ siblingAt: at, pairs }) : formatPairs(pairs, at, register.sites.length));
141
142
  return flags.strict && pairs.some((p) => p.sibling) ? 1 : 0;
142
143
  }
143
144
  const known = register.sites.find((s) => s.id === target);
144
145
  const fp = known ? known.fingerprint : fingerprint(await snapshotOf(target, io.cwd));
145
146
  const matches = compareToEstate(fp, register, { exclude: known?.id, siblingAt: at });
146
- io.out(flags.json ? JSON.stringify({ siblingAt: at, matches }, null, 2) : formatMatches(known?.id ?? target, matches, at));
147
+ io.out(flags.json ? toJson({ siblingAt: at, matches }) : formatMatches(known?.id ?? target, matches, at));
147
148
  return flags.strict && matches.some((m) => m.sibling) ? 1 : 0;
148
149
  }
149
150
  throw new Error(USAGE);
@@ -390,7 +390,9 @@ async function snapshotUrls(urls, options = {}) {
390
390
  for (const url of urls) {
391
391
  const page = await browser.newPage({ viewport: options.viewport ?? { width: 1440, height: 900 } });
392
392
  try {
393
- await page.goto(url, { waitUntil: "domcontentloaded", timeout: options.timeoutMs ?? 45e3 });
393
+ const response = await page.goto(url, { waitUntil: "domcontentloaded", timeout: options.timeoutMs ?? 45e3 });
394
+ const status = typeof response?.status === "function" ? response.status() : 200;
395
+ if (status >= 400) throw new Error(`HTTP ${status}`);
394
396
  out.push({ url, snapshot: await snapshotPage(page, url, options) });
395
397
  } catch (error) {
396
398
  out.push({ url, error: error.message });