@domandigital/craft 0.10.0 → 0.12.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.
@@ -1,20 +1,210 @@
1
1
  import {
2
2
  CATALOGUE,
3
3
  CATALOGUE_VERSION,
4
+ HOUSE_LOCKED,
4
5
  applyHouseGate,
5
6
  checkCopy,
6
7
  extractCopy,
7
8
  extractStrings,
8
9
  fileKind,
10
+ holdHouseBlocks,
9
11
  houseRule,
10
12
  scanSource
11
- } from "./chunk-3NGWXZZE.js";
13
+ } from "./chunk-AL6MRG6V.js";
14
+
15
+ // src/character/config.ts
16
+ var DEFAULT_IGNORE = [".claude", ".agents", ".cursor"];
17
+ var KEYS = /* @__PURE__ */ new Set(["$schema", "ignore", "copyPaths", "severity"]);
18
+ var LEVELS = /* @__PURE__ */ new Set(["warn", "block", "off"]);
19
+ var MIN_BECAUSE = 12;
20
+ var isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
21
+ var isStringArray = (v) => Array.isArray(v) && v.every((s) => typeof s === "string");
22
+ function parseCraftConfig(data, knownTells, source = "craft.config.json") {
23
+ if (!isRecord(data)) throw new Error(`${source}: expected an object`);
24
+ for (const key of Object.keys(data)) if (!KEYS.has(key)) throw new Error(`${source}: unknown key "${key}"`);
25
+ const ignore = data.ignore ?? [];
26
+ if (!isStringArray(ignore)) throw new Error(`${source}: "ignore" must be an array of glob strings`);
27
+ if (data.copyPaths !== void 0 && !isStringArray(data.copyPaths)) throw new Error(`${source}: "copyPaths" must be an array of paths`);
28
+ const severity = {};
29
+ const raw = data.severity ?? {};
30
+ if (!isRecord(raw)) throw new Error(`${source}: "severity" must map a tell id to { level, because }`);
31
+ for (const [tell, value] of Object.entries(raw)) {
32
+ if (!knownTells.has(tell)) throw new Error(`${source}: severity names no tell called "${tell}"`);
33
+ if (!isRecord(value) || !LEVELS.has(value.level)) {
34
+ throw new Error(`${source}: severity.${tell} needs a level of "warn", "block" or "off"`);
35
+ }
36
+ if (typeof value.because !== "string" || value.because.trim().length < MIN_BECAUSE) {
37
+ throw new Error(`${source}: severity.${tell} needs a because of at least a sentence`);
38
+ }
39
+ severity[tell] = { level: value.level, because: value.because };
40
+ }
41
+ return { ignore, severity, ...data.copyPaths ? { copyPaths: data.copyPaths } : {} };
42
+ }
43
+ function globToRegExp(glob) {
44
+ const pattern = glob.replace(/^\.\//, "").replace(/\/$/, "").replace(/\/\*\*$/, "");
45
+ let body = "";
46
+ for (let i = 0; i < pattern.length; i += 1) {
47
+ const c = pattern[i];
48
+ if (c === "*" && pattern[i + 1] === "*") {
49
+ const slash = pattern[i + 2] === "/";
50
+ body += slash ? "(?:.*/)?" : ".*";
51
+ i += slash ? 2 : 1;
52
+ } else if (c === "*") body += "[^/]*";
53
+ else if (c === "?") body += "[^/]";
54
+ else body += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
55
+ }
56
+ const anywhere = !pattern.includes("/");
57
+ return new RegExp(`^${anywhere ? "(?:.*/)?" : ""}${body}(?:/.*)?$`);
58
+ }
59
+ function ignoreMatcher(patterns) {
60
+ const res = patterns.map(globToRegExp);
61
+ return (path) => {
62
+ const p = path.replace(/\\/g, "/").replace(/^\.\//, "");
63
+ return res.some((re) => re.test(p));
64
+ };
65
+ }
66
+ function severityExceptions(config) {
67
+ return Object.entries(config.severity).filter(([, o]) => o.level === "off").map(([tell, o]) => ({ tell, because: `craft.config.json: ${o.because}` }));
68
+ }
69
+ function applySeverity(report, config) {
70
+ const findings = report.findings.map((f) => {
71
+ const o = config.severity[f.tell];
72
+ return o && o.level !== "off" ? { ...f, severity: o.level } : f;
73
+ });
74
+ return { ...report, findings, summary: { ...report.summary, blocking: findings.filter((f) => f.severity === "block").length } };
75
+ }
76
+
77
+ // src/character/json.ts
78
+ var JSON_SCHEMA_VERSION = 1;
79
+ function toJson(data) {
80
+ return JSON.stringify({ schemaVersion: JSON_SCHEMA_VERSION, ...data }, null, 2);
81
+ }
12
82
 
13
83
  // src/character/cli.ts
14
84
  import { execFileSync } from "child_process";
15
- import { existsSync, readFileSync, readdirSync, statSync } from "fs";
85
+ import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "fs";
16
86
  import { join, relative, resolve } from "path";
17
87
 
88
+ // src/character/baseline.ts
89
+ var BASELINE_VERSION = 1;
90
+ function fnv1a(text) {
91
+ let hash = 2166136261;
92
+ for (let i = 0; i < text.length; i += 1) {
93
+ hash ^= text.charCodeAt(i);
94
+ hash = Math.imul(hash, 16777619);
95
+ }
96
+ return (hash >>> 0).toString(16).padStart(8, "0");
97
+ }
98
+ var excerptHash = (f) => fnv1a(f.excerpt.replace(/\s+/g, " ").trim());
99
+ var keyOf = (tell, path, excerpt) => `${tell}\0${path}\0${excerpt}`;
100
+ function findingKey(f) {
101
+ return keyOf(f.tell, f.path, excerptHash(f));
102
+ }
103
+ function createBaseline(report) {
104
+ const counts = /* @__PURE__ */ new Map();
105
+ for (const f of report.findings) {
106
+ const excerpt = excerptHash(f);
107
+ const key = keyOf(f.tell, f.path, excerpt);
108
+ const entry = counts.get(key);
109
+ if (entry) entry.count += 1;
110
+ else counts.set(key, { tell: f.tell, path: f.path, excerpt, count: 1 });
111
+ }
112
+ const entries = [...counts.values()].sort((a, b) => a.path.localeCompare(b.path) || a.tell.localeCompare(b.tell) || a.excerpt.localeCompare(b.excerpt));
113
+ return { version: BASELINE_VERSION, catalogueVersion: report.catalogueVersion, entries };
114
+ }
115
+ function parseBaseline(data, source = "baseline") {
116
+ const b = data;
117
+ if (!b || typeof b !== "object" || b.version !== BASELINE_VERSION || !Array.isArray(b.entries)) {
118
+ throw new Error(`${source}: not a craft baseline (version ${BASELINE_VERSION})`);
119
+ }
120
+ for (const e of b.entries) {
121
+ if (typeof e?.tell !== "string" || typeof e.path !== "string" || typeof e.excerpt !== "string" || !Number.isInteger(e.count) || e.count < 1) {
122
+ throw new Error(`${source}: malformed entry ${JSON.stringify(e)}`);
123
+ }
124
+ }
125
+ return b;
126
+ }
127
+ function recount(report, findings) {
128
+ const byGeneration = { 1: 0, 2: 0, 3: 0 };
129
+ const byTell = {};
130
+ for (const f of findings) {
131
+ byGeneration[f.generation] += 1;
132
+ byTell[f.tell] = (byTell[f.tell] ?? 0) + 1;
133
+ }
134
+ return {
135
+ ...report,
136
+ findings,
137
+ summary: { ...report.summary, findings: findings.length, byGeneration, byTell, blocking: findings.filter((f) => f.severity === "block").length }
138
+ };
139
+ }
140
+ function applyBaseline(report, baseline) {
141
+ const remaining = /* @__PURE__ */ new Map();
142
+ for (const e of baseline.entries) {
143
+ const key = keyOf(e.tell, e.path, e.excerpt);
144
+ remaining.set(key, (remaining.get(key) ?? 0) + e.count);
145
+ }
146
+ const kept = [];
147
+ let baselined = 0;
148
+ for (const f of report.findings) {
149
+ const key = findingKey(f);
150
+ const left = remaining.get(key) ?? 0;
151
+ if (left > 0) {
152
+ remaining.set(key, left - 1);
153
+ baselined += 1;
154
+ } else kept.push(f);
155
+ }
156
+ return { ...recount(report, kept), baselined };
157
+ }
158
+
159
+ // src/character/sarif.ts
160
+ var isUrl = (path) => /^[a-z][a-z0-9+.-]*:\/\//i.test(path);
161
+ function toSarif(report, options) {
162
+ const byId = new Map(options.tells.map((t) => [t.id, t]));
163
+ const ids = [...new Set(report.findings.map((f) => f.tell))].sort();
164
+ const index = new Map(ids.map((id, i) => [id, i]));
165
+ const rules = ids.map((id) => {
166
+ const t = byId.get(id);
167
+ return {
168
+ id,
169
+ name: t?.name ?? id,
170
+ shortDescription: { text: t?.name ?? id },
171
+ ...t ? { fullDescription: { text: t.why }, help: { text: t.fix } } : {}
172
+ };
173
+ });
174
+ const results = report.findings.map((f) => ({
175
+ ruleId: f.tell,
176
+ ruleIndex: index.get(f.tell),
177
+ level: f.severity === "block" ? "error" : "warning",
178
+ message: { text: `${f.message}. ${f.fix}` },
179
+ locations: [
180
+ {
181
+ physicalLocation: {
182
+ artifactLocation: { uri: isUrl(f.path) ? f.path : f.path.replace(/\\/g, "/") },
183
+ ...f.line > 0 ? { region: { startLine: f.line, snippet: { text: f.excerpt } } } : {}
184
+ }
185
+ }
186
+ ],
187
+ partialFingerprints: { "craftFinding/v1": fnv1a(findingKey(f)) }
188
+ }));
189
+ return {
190
+ $schema: "https://json.schemastore.org/sarif-2.1.0.json",
191
+ version: "2.1.0",
192
+ runs: [
193
+ {
194
+ tool: {
195
+ driver: {
196
+ name: "craft",
197
+ version: report.catalogueVersion,
198
+ informationUri: options.informationUri ?? "https://www.npmjs.com/package/@domandigital/craft",
199
+ rules
200
+ }
201
+ },
202
+ results
203
+ }
204
+ ]
205
+ };
206
+ }
207
+
18
208
  // src/character/facts.ts
19
209
  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
210
  var PATTERNS = [
@@ -252,14 +442,15 @@ var NOT_SITE_COPY = /^(?:README|CHANGELOG|CLAUDE|AGENTS|CONTRIBUTING|LICENSE|COD
252
442
  var HELP = `craft: find the AI look and say what to do instead.
253
443
 
254
444
  Usage
255
- craft scan [paths...] [--staged] [--json] [--strict] [--direction <file>]
256
- craft copy <paths...> [--gate] [--json] [--strict] [--direction <file>]
445
+ craft scan [paths...] [--staged] [--json] [--strict] [--direction <file>] [ci options]
446
+ craft copy [paths...] [--gate] [--json] [--strict] [--direction <file>] [ci options]
257
447
  craft copy compare <before> <after> [--json]
258
448
  craft copy claims <paths...> [--json]
259
449
  craft tells list [--json]
260
450
  craft tells harvest <null.json | dir>... [--share 0.25] [--json]
261
451
  craft snapshot <url> [--out <file>] [--width <px>] [--height <px>]
262
- craft audit <url | snapshot.json> [--repo <dir>] [--null <null.json>] [--out <file>] [--json] [--strict]
452
+ craft audit <url | snapshot.json> [--repo <dir>] [--null <null.json>] [--out <file>] [--json] [--strict] [ci options]
453
+ craft audit --pages <sitemap.xml | urls.txt> [--viewport 390,768,1440] [the same]
263
454
  craft direction init [--snapshot <file>] [--client <name>] [--out <file>]
264
455
  craft direction validate [--snapshot <file>] [--direction <file>] [--json]
265
456
  craft direction propose [--snapshot <file>] [--estate <estate.json | dir>] [--out <file>]
@@ -287,6 +478,8 @@ audit Judge a rendered page (live, or a saved snapshot) and fingerprint it.
287
478
  --repo also scans that site's source, so one report covers both.
288
479
  --null scores how typical it is against a null model (comma-separate
289
480
  several to pool them).
481
+ --pages audits every URL in a sitemap (a URL or a file) or a file of
482
+ URLs, one per line. --viewport audits each page at these widths.
290
483
  snapshot and audit need Playwright: npm i -D playwright.
291
484
  direction The site's art-direction.json: every choice with a reason from the
292
485
  client's world. init writes today's choices with empty reasons;
@@ -303,8 +496,18 @@ report Every signal for one site: tells, typicality, the estate, the reasons.
303
496
  retrofit The report as a checklist: decide, then change type, colour, shape,
304
497
  effects, motion and copy. Never the page grammar.
305
498
 
499
+ ci options (scan, copy, audit)
500
+ --baseline <file> Report only findings the baseline does not already
501
+ hold. Keyed on tell, path and excerpt, not line.
502
+ --update-baseline Write every current finding to --baseline, exit 0.
503
+ --sarif <file> Also write SARIF 2.1.0, for GitHub code scanning.
504
+ --config <file> craft.config.json, default: the working directory.
505
+
306
506
  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.`;
507
+ craft.config.json holds ignore globs, copyPaths, and severity changes, each
508
+ with a because. .claude, .agents and .cursor are never walked.
509
+ Every tell ships as warn: exit 1 only on a block, or on any finding with --strict.
510
+ --json output always carries schemaVersion.`;
308
511
  var VALUE_FLAGS = {
309
512
  "--direction": "direction",
310
513
  "--out": "out",
@@ -320,16 +523,22 @@ var VALUE_FLAGS = {
320
523
  "--model": "model",
321
524
  "--share": "share",
322
525
  "--null": "null",
323
- "--id": "id"
526
+ "--id": "id",
527
+ "--config": "config",
528
+ "--baseline": "baseline",
529
+ "--sarif": "sarif",
530
+ "--pages": "pages",
531
+ "--viewport": "viewport"
324
532
  };
325
533
  function parseFlags(args) {
326
- const flags = { positional: [], json: false, strict: false, staged: false, gate: false };
534
+ const flags = { positional: [], json: false, strict: false, staged: false, gate: false, updateBaseline: false };
327
535
  for (let i = 0; i < args.length; i += 1) {
328
536
  const a = args[i];
329
537
  if (a === "--json") flags.json = true;
330
538
  else if (a === "--strict") flags.strict = true;
331
539
  else if (a === "--staged") flags.staged = true;
332
540
  else if (a === "--gate") flags.gate = true;
541
+ else if (a === "--update-baseline") flags.updateBaseline = true;
333
542
  else if (a in VALUE_FLAGS) {
334
543
  const value = args[i + 1];
335
544
  i += 1;
@@ -340,48 +549,86 @@ function parseFlags(args) {
340
549
  }
341
550
  return flags;
342
551
  }
343
- function walk(root, cwd, into, wanted, skipDir = (_) => false) {
552
+ function walk(root, cwd, into, wanted, skipDir = (_) => false, ignored = (_) => false) {
344
553
  const stat = statSync(root);
345
554
  if (stat.isFile()) {
346
555
  if (stat.size <= MAX_BYTES) into.push({ path: relative(cwd, root) || root, text: readFileSync(root, "utf8") });
347
556
  return;
348
557
  }
349
558
  for (const entry of readdirSync(root, { withFileTypes: true })) {
559
+ const path = join(root, entry.name);
560
+ if (ignored(relative(cwd, path))) continue;
350
561
  if (entry.isDirectory()) {
351
- if (!SKIP_DIRS.has(entry.name) && !skipDir(entry.name)) walk(join(root, entry.name), cwd, into, wanted, skipDir);
562
+ if (!SKIP_DIRS.has(entry.name) && !skipDir(entry.name)) walk(path, cwd, into, wanted, skipDir, ignored);
352
563
  } else if (entry.isFile() && wanted(entry.name)) {
353
- walk(join(root, entry.name), cwd, into, wanted, skipDir);
564
+ walk(path, cwd, into, wanted, skipDir, ignored);
354
565
  }
355
566
  }
356
567
  }
357
- function readPaths(paths, cwd, wanted, skipDir) {
568
+ function readPaths(paths, cwd, wanted, skipDir, ignore = []) {
358
569
  const files = [];
570
+ const ignored = ignoreMatcher([...DEFAULT_IGNORE, ...ignore]);
359
571
  for (const p of paths) {
360
572
  const abs = resolve(cwd, p);
361
573
  if (!existsSync(abs)) throw new Error(`no such path: ${p}`);
362
- walk(abs, cwd, files, wanted, skipDir);
574
+ walk(abs, cwd, files, wanted, skipDir, ignored);
363
575
  }
364
576
  return files;
365
577
  }
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));
578
+ function readStaged(cwd, wanted, ignore) {
579
+ const ignored = ignoreMatcher([...DEFAULT_IGNORE, ...ignore]);
580
+ 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
581
  return names.map((path) => ({
369
582
  path,
370
583
  // The index, not the working tree: the commit is what gets checked.
371
584
  text: execFileSync("git", ["show", `:${path}`], { cwd, encoding: "utf8", maxBuffer: MAX_BYTES * 4 })
372
585
  }));
373
586
  }
587
+ var NO_CONFIG = { ignore: [], severity: {} };
588
+ function loadConfig(flags, root) {
589
+ const path = resolve(root, flags.config ?? "craft.config.json");
590
+ if (!existsSync(path)) {
591
+ if (flags.config) throw new Error(`no such file: ${flags.config}`);
592
+ return NO_CONFIG;
593
+ }
594
+ return parseCraftConfig(JSON.parse(readFileSync(path, "utf8")), new Set(CATALOGUE.map((t) => t.id)), flags.config ?? "craft.config.json");
595
+ }
374
596
  function loadExceptions(flags, cwd) {
597
+ const fromConfig = severityExceptions(loadConfig(flags, cwd));
375
598
  const path = resolve(cwd, flags.direction ?? "art-direction.json");
376
599
  if (!existsSync(path)) {
377
600
  if (flags.direction) throw new Error(`no such file: ${flags.direction}`);
378
- return [];
601
+ return fromConfig;
379
602
  }
380
603
  const data = JSON.parse(readFileSync(path, "utf8"));
381
- return Array.isArray(data.exceptions) ? data.exceptions : [];
604
+ return [...Array.isArray(data.exceptions) ? data.exceptions : [], ...fromConfig];
605
+ }
606
+ function prepareReport(report, flags, io, config) {
607
+ let result = applySeverity(report, config);
608
+ if (flags.updateBaseline) {
609
+ if (!flags.baseline) throw new Error("--update-baseline needs --baseline <file>");
610
+ const baseline = createBaseline(result);
611
+ writeFileSync(resolve(io.cwd, flags.baseline), `${JSON.stringify(baseline, null, 2)}
612
+ `);
613
+ io.out(`craft: baseline of ${result.findings.length} finding${result.findings.length === 1 ? "" : "s"} written to ${flags.baseline}`);
614
+ return null;
615
+ }
616
+ if (flags.baseline) {
617
+ const path = resolve(io.cwd, flags.baseline);
618
+ if (!existsSync(path)) throw new Error(`no such file: ${flags.baseline} (create it with --update-baseline)`);
619
+ result = applyBaseline(result, parseBaseline(JSON.parse(readFileSync(path, "utf8")), flags.baseline));
620
+ }
621
+ if (flags.sarif) {
622
+ writeFileSync(resolve(io.cwd, flags.sarif), `${JSON.stringify(toSarif(result, { tells: CATALOGUE }), null, 2)}
623
+ `);
624
+ }
625
+ return result;
382
626
  }
383
627
  function finish(report, flags, title, io) {
384
- io.out(flags.json ? JSON.stringify(report, null, 2) : formatReport(report, title));
628
+ io.out(flags.json ? toJson(report) : formatReport(report, title));
629
+ if (!flags.json && report.baselined) {
630
+ io.out(`${report.baselined} known finding${report.baselined === 1 ? "" : "s"} not shown: already in ${flags.baseline}.`);
631
+ }
385
632
  if (report.summary.blocking > 0) return 1;
386
633
  if (flags.strict && report.summary.findings > 0) return 1;
387
634
  return 0;
@@ -392,25 +639,25 @@ var NOT_COPY_DIR = (name) => name.startsWith("_");
392
639
  function run(argv, io) {
393
640
  const [command, ...rest] = argv;
394
641
  if (command === "direction") {
395
- return import("./cli-2WBGTBKD.js").then((m) => m.runDirection(rest, io));
642
+ return import("./cli-ZPJIWVEP.js").then((m) => m.runDirection(rest, io));
396
643
  }
397
644
  if (command === "report" || command === "retrofit") {
398
- return import("./cli-ADQQURNR.js").then((m) => m.runReport(command, rest, io));
645
+ return import("./cli-ZB2VJ7CY.js").then((m) => m.runReport(command, rest, io));
399
646
  }
400
647
  if (command === "estate") {
401
- return import("./cli-VQUNNIZM.js").then((m) => m.runEstate(rest, io));
648
+ return import("./cli-2DY3V3SF.js").then((m) => m.runEstate(rest, io));
402
649
  }
403
650
  if (command === "null") {
404
- return import("./cli-PRWTNFVP.js").then((m) => m.runNull(rest, io));
651
+ return import("./cli-C3JLFXMG.js").then((m) => m.runNull(rest, io));
405
652
  }
406
653
  if (command === "tells" && rest[0] === "harvest") {
407
- return import("./cli-PRWTNFVP.js").then((m) => m.runHarvest(rest, io)).catch((error) => {
654
+ return import("./cli-C3JLFXMG.js").then((m) => m.runHarvest(rest, io)).catch((error) => {
408
655
  io.err(`craft: ${error.message}`);
409
656
  return 2;
410
657
  });
411
658
  }
412
659
  if (command === "snapshot" || command === "audit") {
413
- return import("./cli-EK3GSAOW.js").then((m) => m.runAudit(command, rest, io));
660
+ return import("./cli-QM2AQQMF.js").then((m) => m.runAudit(command, rest, io));
414
661
  }
415
662
  if (!command || command === "help" || command === "--help" || command === "-h") {
416
663
  io.out(HELP);
@@ -432,7 +679,7 @@ function run(argv, io) {
432
679
  fix,
433
680
  ...surface === "copy" ? { house: houseRule(id, name).tier, houseLabel: houseRule(id, name).label } : {}
434
681
  }));
435
- io.out(JSON.stringify({ version: CATALOGUE_VERSION, tells: data }, null, 2));
682
+ io.out(toJson({ version: CATALOGUE_VERSION, tells: data }));
436
683
  } else {
437
684
  io.out(`Catalogue ${CATALOGUE_VERSION}`);
438
685
  for (const t of CATALOGUE) io.out(` ${t.id.padEnd(24)} gen ${t.generation} ${t.surface.padEnd(6)} ${t.name}`);
@@ -449,7 +696,7 @@ function run(argv, io) {
449
696
  return visibleText({ path: p, text: readFileSync(abs, "utf8") });
450
697
  });
451
698
  const result = compareFacts(before, after);
452
- io.out(flags.json ? JSON.stringify(result, null, 2) : formatComparison(result, flags.positional[0], flags.positional[1]));
699
+ io.out(flags.json ? toJson(result) : formatComparison(result, flags.positional[0], flags.positional[1]));
453
700
  return result.lost.length || result.added.length ? 1 : 0;
454
701
  }
455
702
  if (command === "copy" && rest[0] === "claims") {
@@ -457,19 +704,35 @@ function run(argv, io) {
457
704
  if (typeof flags === "string") throw new Error(flags);
458
705
  if (flags.positional.length === 0) throw new Error("usage: craft copy claims <paths...> [--json]");
459
706
  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));
707
+ io.out(flags.json ? toJson(report) : formatClaims(report));
461
708
  return 0;
462
709
  }
463
710
  if (command === "scan" || command === "copy") {
464
711
  const flags = parseFlags(rest);
465
712
  if (typeof flags === "string") throw new Error(flags);
466
713
  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);
469
- const exceptions = loadExceptions(flags, io.cwd);
714
+ const config = loadConfig(flags, io.cwd);
715
+ const paths = flags.positional.length > 0 ? flags.positional : command === "copy" ? config.copyPaths ?? [] : ["."];
716
+ if (command === "copy" && paths.length === 0 && !flags.staged) throw new Error("usage: craft copy <paths...> (or copyPaths in craft.config.json)");
717
+ const files = flags.staged ? readStaged(io.cwd, wanted, config.ignore) : readPaths(paths, io.cwd, wanted, command === "copy" ? NOT_COPY_DIR : void 0, config.ignore);
718
+ let exceptions = loadExceptions(flags, io.cwd);
719
+ let effective = config;
720
+ let refused = [];
721
+ if (command === "copy" && flags.gate) {
722
+ const held = holdHouseBlocks(exceptions, config.severity);
723
+ exceptions = held.exceptions;
724
+ effective = { ...config, severity: held.severity };
725
+ const warned = held.refusedSeverity.filter((tell) => config.severity[tell].level === "warn").map((tell) => ({ tell, because: `craft.config.json: ${config.severity[tell].because}` }));
726
+ refused = [...held.refused, ...warned];
727
+ }
470
728
  let report = command === "scan" ? scanSource(files, { exceptions }) : checkCopy(files, { exceptions });
471
- if (command === "copy" && flags.gate) report = applyHouseGate(report);
472
- return finish(report, flags, command === "scan" ? "craft scan" : "craft copy", io);
729
+ if (command === "copy" && flags.gate) {
730
+ report = applyHouseGate(report);
731
+ report = { ...report, rejectedExceptions: [...report.rejectedExceptions, ...refused.map((exception) => ({ exception, reason: HOUSE_LOCKED }))] };
732
+ }
733
+ const prepared = prepareReport(report, flags, io, effective);
734
+ if (!prepared) return 0;
735
+ return finish(prepared, flags, command === "scan" ? "craft scan" : "craft copy", io);
473
736
  }
474
737
  throw new Error(`unknown command "${command}". Run craft --help.`);
475
738
  } catch (error) {
@@ -479,9 +742,13 @@ function run(argv, io) {
479
742
  }
480
743
 
481
744
  export {
745
+ applySeverity,
746
+ toJson,
482
747
  parseFlags,
483
748
  readPaths,
749
+ loadConfig,
484
750
  loadExceptions,
751
+ prepareReport,
485
752
  finish,
486
753
  SOURCE_FILE,
487
754
  COPY_FILE,
@@ -0,0 +1,75 @@
1
+ // src/direction/png.ts
2
+ import { inflateSync } from "zlib";
3
+ function decodePng(buf) {
4
+ const sig = [137, 80, 78, 71, 13, 10, 26, 10];
5
+ if (!sig.every((b, i) => buf[i] === b)) throw new Error("not a PNG");
6
+ const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
7
+ let pos = 8;
8
+ let width = 0;
9
+ let height = 0;
10
+ let depth = 0;
11
+ let colour = 0;
12
+ let interlace = 0;
13
+ const idat = [];
14
+ while (pos < buf.length) {
15
+ const len = view.getUint32(pos);
16
+ const type = String.fromCharCode(...buf.subarray(pos + 4, pos + 8));
17
+ const data = buf.subarray(pos + 8, pos + 8 + len);
18
+ if (type === "IHDR") {
19
+ width = view.getUint32(pos + 8);
20
+ height = view.getUint32(pos + 12);
21
+ depth = data[8];
22
+ colour = data[9];
23
+ interlace = data[12];
24
+ } else if (type === "IDAT") idat.push(data);
25
+ else if (type === "IEND") break;
26
+ pos += 12 + len;
27
+ }
28
+ const channels = { 0: 1, 2: 3, 4: 2, 6: 4 }[colour];
29
+ if (depth !== 8 || !channels || interlace !== 0) throw new Error(`unsupported PNG (depth ${depth}, colour type ${colour}${interlace ? ", interlaced" : ""}); export it as 8-bit RGB`);
30
+ const raw = inflateSync(Buffer.concat(idat.map((d) => Buffer.from(d))));
31
+ const stride = width * channels;
32
+ const out = new Uint8Array(width * height * 4);
33
+ const prev = new Uint8Array(stride);
34
+ const line = new Uint8Array(stride);
35
+ for (let y = 0; y < height; y += 1) {
36
+ const filter = raw[y * (stride + 1)];
37
+ const src = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1));
38
+ for (let x = 0; x < stride; x += 1) {
39
+ const a = x >= channels ? line[x - channels] : 0;
40
+ const b = prev[x];
41
+ const c = x >= channels ? prev[x - channels] : 0;
42
+ let v = src[x];
43
+ if (filter === 1) v += a;
44
+ else if (filter === 2) v += b;
45
+ else if (filter === 3) v += a + b >> 1;
46
+ else if (filter === 4) {
47
+ const p = a + b - c;
48
+ const pa = Math.abs(p - a);
49
+ const pb = Math.abs(p - b);
50
+ const pc = Math.abs(p - c);
51
+ v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
52
+ }
53
+ line[x] = v & 255;
54
+ }
55
+ for (let x = 0; x < width; x += 1) {
56
+ const i = x * channels;
57
+ const o = (y * width + x) * 4;
58
+ if (channels >= 3) {
59
+ out[o] = line[i];
60
+ out[o + 1] = line[i + 1];
61
+ out[o + 2] = line[i + 2];
62
+ out[o + 3] = channels === 4 ? line[i + 3] : 255;
63
+ } else {
64
+ out[o] = out[o + 1] = out[o + 2] = line[i];
65
+ out[o + 3] = channels === 2 ? line[i + 1] : 255;
66
+ }
67
+ }
68
+ prev.set(line);
69
+ }
70
+ return { width, height, rgba: out };
71
+ }
72
+
73
+ export {
74
+ decodePng
75
+ };
@@ -1,10 +1,14 @@
1
1
  import {
2
- parseFlags
3
- } from "./chunk-JKIDWHG3.js";
2
+ readSnapshot
3
+ } from "./chunk-ZKVJ7K6X.js";
4
+ import {
5
+ parseFlags,
6
+ toJson
7
+ } from "./chunk-J3QUROJ5.js";
4
8
  import {
5
9
  fingerprint,
6
10
  fingerprintDistance
7
- } from "./chunk-QMYERCBB.js";
11
+ } from "./chunk-PZ4MF2VV.js";
8
12
  import {
9
13
  CATALOGUE_VERSION,
10
14
  REFLEX_FONTS_1,
@@ -18,7 +22,7 @@ import {
18
22
  isCream,
19
23
  oklchToRgb,
20
24
  scanSource
21
- } from "./chunk-3NGWXZZE.js";
25
+ } from "./chunk-AL6MRG6V.js";
22
26
 
23
27
  // src/null/cli.ts
24
28
  import { spawn } from "child_process";
@@ -352,7 +356,7 @@ async function build(flags, io) {
352
356
  const toShoot = ids.filter((id) => existsSync(join(pages, `${id}.html`)) && !existsSync(join(shots, `${id}.json`)));
353
357
  if (toShoot.length) {
354
358
  io.out(`craft null: snapshotting ${toShoot.length} page${toShoot.length === 1 ? "" : "s"}`);
355
- const { snapshotUrls } = await import("./audit-EGDNWSQB.js");
359
+ const { snapshotUrls } = await import("./audit-5QWFLZ6L.js");
356
360
  const results = await snapshotUrls(toShoot.map((id) => pathToFileURL(join(pages, `${id}.html`)).href));
357
361
  results.forEach((r, i) => {
358
362
  const id = toShoot[i];
@@ -361,7 +365,7 @@ async function build(flags, io) {
361
365
  else failed.push(`${id}: the page would not render: ${r.error}`);
362
366
  });
363
367
  }
364
- const measured = ids.filter((id) => existsSync(join(pages, `${id}.html`)) && existsSync(join(shots, `${id}.json`))).map((id) => measureRun(id, readFileSync(join(pages, `${id}.html`), "utf8"), readJson(join(shots, `${id}.json`))));
368
+ const measured = ids.filter((id) => existsSync(join(pages, `${id}.html`)) && existsSync(join(shots, `${id}.json`))).map((id) => measureRun(id, readFileSync(join(pages, `${id}.html`), "utf8"), readSnapshot(readJson(join(shots, `${id}.json`)), `${id}.json`)));
365
369
  const model = {
366
370
  version: NULL_VERSION,
367
371
  brief: brief.trim(),
@@ -439,7 +443,7 @@ function runHarvest(args, io) {
439
443
  const minShare = flags.share === void 0 ? void 0 : Number(flags.share);
440
444
  if (minShare !== void 0 && !(minShare > 0 && minShare <= 1)) throw new Error("--share needs a number above 0 and at most 1");
441
445
  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));
446
+ io.out(flags.json ? toJson({ briefs: models.map((m) => m.brief), candidates }) : formatHarvest(candidates, models));
443
447
  return 0;
444
448
  }
445
449