@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 +19 -0
- package/README.md +35 -0
- package/dist/{audit-EGDNWSQB.js → audit-QIPXL7E4.js} +1 -1
- package/dist/audit.cjs +3 -1
- package/dist/audit.js +3 -1
- package/dist/{chunk-JKIDWHG3.js → chunk-2AJB27BJ.js} +282 -29
- package/dist/{chunk-APO4BT2F.js → chunk-3OZQ4C5R.js} +5 -4
- package/dist/{chunk-2NQFIDLE.js → chunk-HOHHQB4N.js} +7 -6
- package/dist/{chunk-TSKRBIOC.js → chunk-JP75P2DU.js} +3 -1
- package/dist/chunk-MSQ57PJU.js +206 -0
- package/dist/{cli-PRWTNFVP.js → cli-4ESXOLKV.js} +2 -2
- package/dist/{cli-ADQQURNR.js → cli-DBZMVYYV.js} +17 -11
- package/dist/{cli-EK3GSAOW.js → cli-T5RD2BTK.js} +6 -4
- package/dist/{cli-2WBGTBKD.js → cli-U3SZVHSK.js} +4 -3
- package/dist/{cli-VQUNNIZM.js → cli-VREYWCT4.js} +3 -3
- package/dist/cli.js +1 -1
- package/dist/index.cjs +216 -0
- package/dist/index.d.cts +185 -1
- package/dist/index.d.ts +185 -1
- package/dist/index.js +202 -0
- package/package.json +13 -6
- package/dist/chunk-U2JTFQKO.js +0 -112
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
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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 ?
|
|
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-
|
|
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-
|
|
643
|
+
return import("./cli-DBZMVYYV.js").then((m) => m.runReport(command, rest, io));
|
|
399
644
|
}
|
|
400
645
|
if (command === "estate") {
|
|
401
|
-
return import("./cli-
|
|
646
|
+
return import("./cli-VREYWCT4.js").then((m) => m.runEstate(rest, io));
|
|
402
647
|
}
|
|
403
648
|
if (command === "null") {
|
|
404
|
-
return import("./cli-
|
|
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-
|
|
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-
|
|
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(
|
|
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 ?
|
|
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 ?
|
|
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
|
-
|
|
468
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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 ?
|
|
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-
|
|
6
|
+
} from "./chunk-3OZQ4C5R.js";
|
|
7
7
|
import {
|
|
8
|
-
parseFlags
|
|
9
|
-
|
|
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-
|
|
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 ?
|
|
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 ?
|
|
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 });
|