@piercebarney/whs-eleventy 2026.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,677 @@
1
+ // Codebase-vs-standard conformance (core.md#compliance). One PASS / FAIL /
2
+ // MANUAL / N/A per chapter of the web house style — the drift-and-completeness
3
+ // sweep that `npm run check` (regressions only) does not do.
4
+ //
5
+ // whs compliance print the grid + summary
6
+ // whs compliance --strict exit 1 if any chapter is FAIL
7
+ //
8
+ // NOT part of `npm run check`: a FAIL is a migration backlog item, not a
9
+ // regression. `_data/infra.js` reads the cache this writes for the /audit/
10
+ // Compliance tab; `bin/deploy` diffs it against .cache/compliance-last.json and
11
+ // warns on any PASS -> FAIL since the last deploy.
12
+ //
13
+ // Reuses doctor.js (repo introspection) and check-links.js's output
14
+ // (_site/links-report.json) rather than re-implementing either.
15
+
16
+ const fs = require("node:fs");
17
+ const path = require("node:path");
18
+ const { execSync } = require("node:child_process");
19
+ const { runRepoChecks } = require("./doctor.js");
20
+ const { ROOT, resolveStandard } = require("./_project.js");
21
+
22
+ const SITE = path.join(ROOT, "_site");
23
+ const CACHE = path.join(ROOT, ".cache", "compliance.json");
24
+ // The standard's text — WHS_STANDARD, this package's bundled copy, or the legacy
25
+ // ~/.claude symlink. null when none of them resolves (drift then reports MANUAL).
26
+ const STANDARD = resolveStandard();
27
+
28
+ const PASS = "PASS";
29
+ const FAIL = "FAIL";
30
+ const MANUAL = "MANUAL";
31
+ const NA = "N/A";
32
+
33
+ function read(rel, base = ROOT) {
34
+ try {
35
+ return fs.readFileSync(path.join(base, rel), "utf8");
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+ const has = (rel, base = ROOT) => fs.existsSync(path.join(base, rel));
41
+
42
+ function walk(dir) {
43
+ const out = [];
44
+ if (!fs.existsSync(dir)) return out;
45
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
46
+ const p = path.join(dir, e.name);
47
+ if (e.isDirectory()) out.push(...walk(p));
48
+ else out.push(p);
49
+ }
50
+ return out;
51
+ }
52
+
53
+ // grep the tracked source tree (not _site, not node_modules).
54
+ function grepSrc(re, exts = [".js", ".njk", ".css", ".json"]) {
55
+ const roots = ["src", "static", "scripts"].map((d) => path.join(ROOT, d));
56
+ const hits = [];
57
+ for (const r of roots) {
58
+ for (const f of walk(r)) {
59
+ if (!exts.includes(path.extname(f))) continue;
60
+ const t = fs.readFileSync(f, "utf8");
61
+ if (re.test(t)) hits.push(path.relative(ROOT, f));
62
+ }
63
+ }
64
+ return hits;
65
+ }
66
+
67
+ // Content pages — excludes the audit tool page (its own <head>, noindex).
68
+ const siteHtml = () =>
69
+ walk(SITE)
70
+ .filter((f) => f.endsWith(".html"))
71
+ .filter((f) => !f.endsWith(path.join("audit", "index.html")));
72
+
73
+ // ---- CLAUDE.md House-style keys -----------------------------------------
74
+
75
+ const claudeMd = read("CLAUDE.md") || "";
76
+ const adsMode = (claudeMd.match(/^-?\s*ads:\s*(\S+)/m) || [])[1] || "none";
77
+ const contentType = (claudeMd.match(/^-?\s*content-type:\s*(\S+)/m) || [])[1] || "tool";
78
+ const aiMode = (claudeMd.match(/^-?\s*ai:\s*(\S+)/m) || [])[1] || "none";
79
+
80
+ // ---- doctor repo checks, indexed --------------------------------------
81
+ // Populated by runCompliance() (not on import — the checks read the project
82
+ // tree, which isn't there when this module is required only for its API).
83
+
84
+ let doc = {};
85
+ const loadDoctor = () => {
86
+ doc = {};
87
+ for (const c of runRepoChecks({ preflight: false })) doc[c.id] = c;
88
+ };
89
+ const fromDoctor = (id, extra = "") => {
90
+ const c = doc[id];
91
+ if (!c) return { status: MANUAL, note: `doctor check ${id} not found` };
92
+ return {
93
+ status: c.severity === "ok" ? PASS : c.severity === "blocker" ? FAIL : MANUAL,
94
+ note: [c.detail, extra].filter(Boolean).join(" · "),
95
+ };
96
+ };
97
+
98
+ // ---- per-chapter checks ------------------------------------------------
99
+ // Each returns { status, note }. Keep them mechanical; hand judgement to MANUAL.
100
+
101
+ const CHECKS = {
102
+ "repo-hygiene": () => {
103
+ const docs = ["README.md", "CLAUDE.md"].filter((f) => has(f));
104
+ if (has("PROJECT.md"))
105
+ return { status: FAIL, note: "PROJECT.md present (should be folded into CLAUDE.md)" };
106
+ if (docs.length < 2) return { status: FAIL, note: "expected README.md + CLAUDE.md" };
107
+ if (!has("package-lock.json")) return { status: FAIL, note: "package-lock.json not committed" };
108
+ const branch = execSync("git rev-parse --abbrev-ref HEAD", {
109
+ cwd: ROOT,
110
+ encoding: "utf8",
111
+ }).trim();
112
+ return { status: PASS, note: `2 docs, lockfile committed, on ${branch}` };
113
+ },
114
+
115
+ "runtime-pin": () => {
116
+ if (/NODE_VERSION/.test(read("netlify.toml") || ""))
117
+ return { status: FAIL, note: "NODE_VERSION in netlify.toml — use .nvmrc" };
118
+ return fromDoctor("nvmrc-node");
119
+ },
120
+
121
+ "config-idiom": () => {
122
+ if (has(".eleventy.js")) return { status: FAIL, note: ".eleventy.js — use eleventy.config.js" };
123
+ if (has("public")) return { status: FAIL, note: "public/ dir — reserved by Eleventy" };
124
+ return { status: PASS, note: "eleventy.config.js; no reserved dir names" };
125
+ },
126
+
127
+ "content-model": () => {
128
+ if (!has("src/content") || !has("src/content/schema.js"))
129
+ return { status: FAIL, note: "src/content/*.json + schema.js expected" };
130
+ if (!/validateAll|readAndValidate/.test(read("eleventy.config.js") || ""))
131
+ return { status: FAIL, note: "eleventy.before does not call the validator" };
132
+ try {
133
+ execSync("npm run validate", { cwd: ROOT, stdio: "ignore" });
134
+ } catch {
135
+ return { status: FAIL, note: "npm run validate exits non-zero" };
136
+ }
137
+ const protocol =
138
+ has("CONTENT.md") && /"content-check"/.test(read("package.json") || "")
139
+ ? "agent content-ops protocol present"
140
+ : "no CONTENT.md / content-check — add it if content is agent-edited";
141
+ return {
142
+ status: MANUAL,
143
+ note: `validator wired + green; ${protocol}; confirm page prose lives in content/*.json, not templates`,
144
+ };
145
+ },
146
+
147
+ rendering: () => {
148
+ if (!has("src/404.njk")) return { status: FAIL, note: "no src/404.njk" };
149
+ return { status: PASS, note: "server-rendered HTML; 404 page present" };
150
+ },
151
+
152
+ assets: () => {
153
+ const toml = read("netlify.toml") || "";
154
+ if (!/for = "\/site\.webmanifest"[\s\S]*?application\/manifest\+json/.test(toml))
155
+ return { status: FAIL, note: "site.webmanifest not served as application/manifest+json" };
156
+ const fontFiles = walk(path.join(ROOT, "static")).filter((f) => /\.(woff2?|ttf|otf)$/.test(f));
157
+ if (fontFiles.length && !grepSrc(/@font-face/, [".css"]).length)
158
+ return { status: FAIL, note: `${fontFiles.length} font file(s) but no @font-face` };
159
+ return {
160
+ status: PASS,
161
+ note: fontFiles.length
162
+ ? "self-hosted fonts with @font-face; manifest content-type set"
163
+ : "self-contained; system font stack; manifest content-type set",
164
+ };
165
+ },
166
+
167
+ styling: () => {
168
+ const shift = grepSrc(/style=(["']).*?\1/, [".njk"]);
169
+ return shift.length
170
+ ? { status: FAIL, note: `inline style= in ${shift.join(", ")}` }
171
+ : { status: PASS, note: "Pico classes + one project stylesheet; no inline style=" };
172
+ },
173
+
174
+ "client-logic": () => {
175
+ // executable inline <script> only — JSON-LD (type="application/ld+json") is data.
176
+ const re =
177
+ /<script(?![^>]*\ssrc=)(?![^>]*type=["'](?:application\/ld\+json|application\/json)["'])[^>]*>[\s\S]*?\S[\s\S]*?<\/script>/i;
178
+ const inline = siteHtml().filter((f) => re.test(fs.readFileSync(f, "utf8")));
179
+ if (inline.length) return { status: FAIL, note: `inline <script> in ${inline.length} page(s)` };
180
+ return {
181
+ status: PASS,
182
+ note: "loaded modules; calc wiring in a typeof-document block; no inline script",
183
+ };
184
+ },
185
+
186
+ theme: () => {
187
+ if (!has("static/theme-init.js")) return { status: FAIL, note: "no static/theme-init.js" };
188
+ if (!/src="\/theme-init\.js"/.test(read("src/_includes/layout.njk") || ""))
189
+ return { status: FAIL, note: "layout.njk does not load /theme-init.js" };
190
+ return { status: PASS, note: "anti-FOUC init is a loaded file" };
191
+ },
192
+
193
+ "seo-urls": () => {
194
+ const readers = grepSrc(/process\.env/, [".js", ".njk"]).filter((f) => f.startsWith("src/"));
195
+ if (readers.length !== 1 || readers[0] !== "src/_data/build.js")
196
+ return {
197
+ status: FAIL,
198
+ note: `process.env readers under src/: ${readers.join(", ") || "none"}`,
199
+ };
200
+ const bad = siteHtml().filter((f) => {
201
+ const m = fs.readFileSync(f, "utf8").match(/<link rel="canonical" href="([^"]+)"/);
202
+ return m && !m[1].startsWith("https://");
203
+ });
204
+ return bad.length
205
+ ? { status: FAIL, note: `${bad.length} non-absolute canonical(s)` }
206
+ : { status: PASS, note: "single process.env reader; canonicals absolute" };
207
+ },
208
+
209
+ "seo-meta": () => {
210
+ const need = [
211
+ 'property="og:title"',
212
+ 'property="og:image"',
213
+ 'name="twitter:card"',
214
+ 'name="twitter:image"',
215
+ ];
216
+ const miss = siteHtml().filter((f) => {
217
+ const t = fs.readFileSync(f, "utf8");
218
+ return need.some((n) => !t.includes(n));
219
+ });
220
+ return miss.length
221
+ ? { status: FAIL, note: `${miss.length} page(s) missing an OG/Twitter tag` }
222
+ : { status: PASS, note: `full OG + Twitter set on ${siteHtml().length} pages` };
223
+ },
224
+
225
+ "og-image": () => {
226
+ let bad = 0;
227
+ for (const f of siteHtml()) {
228
+ const m = fs
229
+ .readFileSync(f, "utf8")
230
+ .match(/property="og:image" content="[^"]*?\/og\/([^"]+?)\.png"/);
231
+ if (!m) {
232
+ bad++;
233
+ continue;
234
+ }
235
+ if (!has(`_site/og/${m[1]}.png`)) bad++;
236
+ }
237
+ return bad
238
+ ? { status: FAIL, note: `${bad} page(s) with a missing/!1200x630 og:image` }
239
+ : { status: PASS, note: "every og:image resolves" };
240
+ },
241
+
242
+ "structured-data": () => {
243
+ const miss = siteHtml().filter(
244
+ (f) => !/application\/ld\+json/.test(fs.readFileSync(f, "utf8")),
245
+ );
246
+ return miss.length
247
+ ? { status: FAIL, note: `${miss.length} page(s) with no JSON-LD` }
248
+ : { status: PASS, note: "≥1 JSON-LD block per page (check-links validates @type)" };
249
+ },
250
+
251
+ "sitemap-robots": () => {
252
+ if (!has("_site/sitemap.xml") || !has("_site/robots.txt"))
253
+ return { status: FAIL, note: "sitemap.xml / robots.txt missing from output" };
254
+ const feed = contentType === "article";
255
+ return {
256
+ status: PASS,
257
+ note: `sitemap + robots present${feed ? "" : "; no feed (content-type: tool)"}`,
258
+ };
259
+ },
260
+
261
+ noindex: () =>
262
+ fromDoctor(
263
+ "prod-indexable",
264
+ "DEPLOY_TARGET unset => noindex present (verified in check-links)",
265
+ ),
266
+
267
+ "brand-source": () => {
268
+ // Per the standard: grep src/, static/style.css, scripts/og-card.js|icons.js.
269
+ // A colour literal, or a `font-family:` that isn't a var()/token reference.
270
+ const targets = ["static/style.css", "scripts/og-card.js", "scripts/icons.js"]
271
+ .concat(walk(path.join(ROOT, "src")).map((f) => path.relative(ROOT, f)))
272
+ .filter(
273
+ (f) =>
274
+ /\.(css|js|njk)$/.test(f) && f !== "src/_data/brand.js" && f !== "src/tokens.css.njk",
275
+ );
276
+ const bad = [];
277
+ for (const rel of targets) {
278
+ const t = read(rel);
279
+ if (!t) continue;
280
+ for (const line of t.split("\n")) {
281
+ if (/^\s*(\/\/|\/\*|\*|#|<!--)/.test(line)) continue;
282
+ if (/#[0-9a-fA-F]{3}(?:[0-9a-fA-F]{3})?\b/.test(line) && !/\{\{/.test(line))
283
+ bad.push(`${rel} (hex)`);
284
+ else if (/font-family\s*:/.test(line) && !/var\(--brand/.test(line))
285
+ bad.push(`${rel} (font-family)`);
286
+ }
287
+ }
288
+ return bad.length
289
+ ? { status: FAIL, note: `literal outside brand.js: ${[...new Set(bad)].join(", ")}` }
290
+ : { status: PASS, note: "no hex/font literal outside _data/brand.js" };
291
+ },
292
+
293
+ "audit-page": () => {
294
+ const a = read("src/audit.njk") || "";
295
+ if (!/noindex/.test(a)) return { status: FAIL, note: "audit page not noindex" };
296
+ if (!/id="show-all"/.test(a)) return { status: FAIL, note: 'no "show all" toggle' };
297
+ const need = ["Brand", "Content &amp; Links", "Infrastructure", "Compliance"];
298
+ if (adsMode !== "none") need.push('id="t-ads"');
299
+ const missing = need.filter((t) => !a.includes(t));
300
+ if (missing.length)
301
+ return { status: FAIL, note: `audit tabs incomplete: missing ${missing.join(", ")}` };
302
+ return { status: PASS, note: "noindex; show-all toggle; all tabs present" };
303
+ },
304
+
305
+ "security-headers": () =>
306
+ fromDoctor("security-headers", doc["csp-thirdparties"] ? doc["csp-thirdparties"].detail : ""),
307
+
308
+ caching: () => {
309
+ const toml = read("netlify.toml") || "";
310
+ const ok = /for = "\/\*\.html"/.test(toml) && /for = "\/\*\.css"/.test(toml);
311
+ return ok
312
+ ? { status: PASS, note: "per-type Cache-Control in netlify.toml" }
313
+ : { status: FAIL, note: "cache headers not per-type" };
314
+ },
315
+
316
+ "third-parties": () => {
317
+ if (!has("src/_data/thirdparties.js"))
318
+ return { status: FAIL, note: "no _data/thirdparties.js" };
319
+ const rep = read("_site/links-report.json");
320
+ return rep
321
+ ? { status: PASS, note: "origin manifest present; check-links found no undeclared origin" }
322
+ : {
323
+ status: MANUAL,
324
+ note: "manifest present; run npm run links to confirm no undeclared origin",
325
+ };
326
+ },
327
+
328
+ privacy: () => {
329
+ const p = read("src/privacy.njk") || "";
330
+ if (!has("src/privacy.njk")) return { status: FAIL, note: "no privacy page" };
331
+ if (!/for\s+t\s+in\s+thirdparties/.test(p))
332
+ return {
333
+ status: FAIL,
334
+ note: "privacy third-party list not rendered from _data/thirdparties.js",
335
+ };
336
+ return { status: PASS, note: "third-party list rendered from the manifest" };
337
+ },
338
+
339
+ ads: () => {
340
+ if (adsMode === "none") return { status: NA, note: "ads: none" };
341
+ if (!has("src/_data/ads.js"))
342
+ return { status: FAIL, note: `ads: ${adsMode} but no _data/ads.js` };
343
+ if (!has("src/ads.txt.njk")) return { status: FAIL, note: "no src/ads.txt.njk" };
344
+ const layout = read("src/_includes/layout.njk") || "";
345
+ const gated =
346
+ /build\.isProduction/.test(layout) &&
347
+ /adsbygoogle\.js/.test(layout) &&
348
+ /fundingchoicesmessages/.test(layout);
349
+ if (!gated)
350
+ return {
351
+ status: FAIL,
352
+ note: "layout.njk: loader + CMP not present or not gated on build.isProduction",
353
+ };
354
+ // Real ad CODE (not a bare origin string the audit page shows as data).
355
+ const AD_CODE = /adsbygoogle\.js|<ins[^>]+adsbygoogle|google-adsense-account/;
356
+ const prod = doc["prod-indexable"] && /indexable/.test(doc["prod-indexable"].detail || "");
357
+ const leak = siteHtml().filter((f) => AD_CODE.test(fs.readFileSync(f, "utf8")));
358
+ if (!prod && leak.length)
359
+ return { status: FAIL, note: `ad code in ${leak.length} non-production page(s)` };
360
+ return {
361
+ status: MANUAL,
362
+ note: "wired; verify on production: decline consent => no ad requests, accept => ads + CLS ok",
363
+ };
364
+ },
365
+
366
+ "internal-links": () => {
367
+ const rep = read("_site/links-report.json");
368
+ return rep
369
+ ? { status: PASS, note: `check-links passed (${JSON.parse(rep).pages} pages)` }
370
+ : { status: MANUAL, note: "run npm run links (not run under --serve)" };
371
+ },
372
+
373
+ forms: () => {
374
+ const layout = read("src/_includes/layout.njk") || "";
375
+ const handlers = grepSrc(/addEventListener\(["']submit["']/, [".js"]);
376
+ if (!/netlify-honeypot|name="bot-field"/.test(layout))
377
+ return { status: FAIL, note: "no honeypot field in the form" };
378
+ if (handlers.length > 1)
379
+ return { status: FAIL, note: `submit handler in ${handlers.length} files — consolidate` };
380
+ return { status: PASS, note: "one handler in site.js; honeypot present" };
381
+ },
382
+
383
+ "domain-tests": () => {
384
+ const mods = walk(path.join(ROOT, "static/calc"))
385
+ .filter((f) => f.endsWith(".js"))
386
+ .map((f) => path.basename(f, ".js"));
387
+ const tests = new Set(
388
+ walk(path.join(ROOT, "test"))
389
+ .filter((f) => f.endsWith(".test.js"))
390
+ .map((f) => path.basename(f, ".test.js")),
391
+ );
392
+ const missing = mods.filter((m) => !tests.has(m) && !tests.has(m.replace(/-/g, "")));
393
+ return missing.length
394
+ ? { status: FAIL, note: `no test for: ${missing.join(", ")}` }
395
+ : { status: PASS, note: `${mods.length} calc modules, each with a test/*.test.js` };
396
+ },
397
+
398
+ a11y: () => {
399
+ if (!/\ba11y\b/.test(JSON.parse(read("package.json") || "{}").scripts?.check || ""))
400
+ return { status: FAIL, note: "no a11y stage in the gate" };
401
+ return {
402
+ status: MANUAL,
403
+ note: "axe scan is in the gate; confirm the checklist items beyond the scan (focus order, live regions)",
404
+ };
405
+ },
406
+
407
+ "the-gate": () =>
408
+ fromDoctor(
409
+ "gate-stages",
410
+ /doctor|compliance/.test(JSON.parse(read("package.json")).scripts.check)
411
+ ? "doctor/compliance leaked into check"
412
+ : "doctor + compliance kept out",
413
+ ),
414
+
415
+ compliance: () => {
416
+ const pkg = JSON.parse(read("package.json") || "{}");
417
+ const wired = /whs compliance|scripts\/compliance\.js/.test(pkg.scripts?.compliance || "");
418
+ const dep =
419
+ (pkg.devDependencies || {})["@piercebarney/whs-eleventy"] ||
420
+ (pkg.dependencies || {})["@piercebarney/whs-eleventy"];
421
+ if (!wired) return { status: FAIL, note: "no `compliance` script in package.json" };
422
+ return {
423
+ status: PASS,
424
+ note: dep
425
+ ? "npm run compliance → whs compliance; wired to /audit/ + bin/deploy"
426
+ : "npm run compliance; wired to /audit/ + bin/deploy",
427
+ };
428
+ },
429
+
430
+ "ci-cd": () => {
431
+ const d = (read("bin/deploy") || "")
432
+ .split("\n")
433
+ .filter((l) => !/^\s*#/.test(l))
434
+ .join("\n");
435
+ if (/\s--force\b|\s--no-verify\b/.test(d))
436
+ return { status: FAIL, note: "bin/deploy has a bypass flag" };
437
+ if (!has(".githooks/pre-push") || !has(".githooks/pre-commit"))
438
+ return { status: FAIL, note: "git hooks not committed" };
439
+ if (!/core\.hooksPath/.test(read("package.json") || ""))
440
+ return { status: FAIL, note: "prepare does not set core.hooksPath" };
441
+ if (!has(".github/workflows/ci.yml")) return { status: FAIL, note: "no ci.yml" };
442
+ // core.hooksPath being live is a property of "npm install has run here", not
443
+ // of the codebase — a fresh clone is still compliant. doctor's hooks-path
444
+ // check surfaces the live state on /audit/ (a warn, not a compliance FAIL).
445
+ return {
446
+ status: PASS,
447
+ note: "hooks committed + prepare wires core.hooksPath; ci.yml present; bin/deploy has no bypass",
448
+ };
449
+ },
450
+
451
+ secrets: () => {
452
+ const gi = read(".gitignore") || "";
453
+ if (!/^\.env$/m.test(gi)) return { status: FAIL, note: ".env not git-ignored" };
454
+ if (!has(".env.example")) return { status: FAIL, note: "no .env.example" };
455
+ const leak = siteHtml().filter((f) =>
456
+ /NETLIFY_AUTH_TOKEN|ghp_|AKIA[0-9A-Z]{16}/.test(fs.readFileSync(f, "utf8")),
457
+ );
458
+ if (leak.length)
459
+ return { status: FAIL, note: `secret-shaped string in ${leak.length} output file(s)` };
460
+ return fromDoctor("env-untracked", ".env.example committed; no secret in _site/");
461
+ },
462
+
463
+ observability: () => ({
464
+ status: MANUAL,
465
+ note: "post-launch: Search Console coverage, Core Web Vitals, form deliverability — see /audit/ manual checklist",
466
+ }),
467
+
468
+ "llm-integration": () => {
469
+ if (aiMode === "none") return { status: NA, note: "ai: none" };
470
+ const fnDir = has("netlify/functions") || has("netlify/edge-functions");
471
+ if (!fnDir)
472
+ return {
473
+ status: FAIL,
474
+ note: `ai: ${aiMode} but no netlify/functions/ — a keyed model call belongs in a Function`,
475
+ };
476
+ const keyInData = grepSrc(/ANTHROPIC_API_KEY|OPENAI_API_KEY|x-api-key/, [".js"]).filter((f) =>
477
+ f.startsWith("src/_data/"),
478
+ );
479
+ if (keyInData.length)
480
+ return {
481
+ status: FAIL,
482
+ note: `provider key referenced in ${keyInData.join(", ")} — move it to the Function env`,
483
+ };
484
+ const disclosure =
485
+ has("src/_includes/ai-disclosure.njk") ||
486
+ grepSrc(/AI-generated|ai-disclosure/, [".njk"]).length;
487
+ if (!disclosure) return { status: FAIL, note: "no visible AI-generated disclosure partial" };
488
+ return {
489
+ status: MANUAL,
490
+ note: "Function proxy wired; verify: key in Function env only, explicit timeout + capped retry + app-side ceiling, per-request cost telemetry, moderation sized to the subject, provider-usage-policy check date recorded",
491
+ };
492
+ },
493
+
494
+ "agent-artifacts": () => ({
495
+ status: NA,
496
+ note: "out of scope for a static site — model-driven code execution + sandboxing + artifact storage need a running app (stacks/phoenix.md)",
497
+ }),
498
+
499
+ "usage-metering": () => ({
500
+ status: NA,
501
+ note: "out of scope for a static site — a per-subject event log + pre-spend enforcement + reconciliation need a datastore (stacks/phoenix.md)",
502
+ }),
503
+
504
+ "generated-asset-freshness": () => {
505
+ const s = read(".claude/settings.json") || "";
506
+ const hook = read(".claude/hooks/og-guard.sh") || "";
507
+ if (!/og-guard\.sh/.test(s))
508
+ return { status: FAIL, note: "no PostToolUse hook in .claude/settings.json" };
509
+ const covers = ["brand.js", "tokens.css.njk", "og-card.js"].every((g) => hook.includes(g));
510
+ return covers
511
+ ? { status: PASS, note: "hook rebuilds on brand.js / tokens.css.njk / og-card.js edits" }
512
+ : { status: FAIL, note: "hook globs don't cover the brand source set" };
513
+ },
514
+
515
+ "beyond-this-standard": () => ({
516
+ status: NA,
517
+ note: "static+forms envelope; documented in CLAUDE.md scope key",
518
+ }),
519
+
520
+ adopting: () => {
521
+ const c = claudeMd.replace(/\s+/g, " ");
522
+ const hasDirective =
523
+ /follows the web house style/.test(c) &&
524
+ /Run `npm run check` before reporting a change done/.test(c);
525
+ const keys = [
526
+ "framework:",
527
+ "css:",
528
+ "scope:",
529
+ "ads:",
530
+ "production-url:",
531
+ "content-type:",
532
+ "standard-version:",
533
+ ].filter((k) => c.includes(k));
534
+ if (!hasDirective)
535
+ return {
536
+ status: FAIL,
537
+ note: "CLAUDE.md House-style section missing the directive paragraph",
538
+ };
539
+ if (keys.length < 7)
540
+ return { status: FAIL, note: `House-style block missing keys (${keys.length}/7)` };
541
+ return { status: PASS, note: "directive + full data block present" };
542
+ },
543
+
544
+ "out-of-scope": () => ({ status: NA, note: "situational list — nothing to verify mechanically" }),
545
+
546
+ "anti-patterns": () => ({
547
+ status: MANUAL,
548
+ note: "read the anti-pattern lists in core.md + the stack doc against recent changes",
549
+ }),
550
+ };
551
+
552
+ // ---- standard-version drift -----------------------------------------
553
+
554
+ // Every chapter slug the CHANGELOG records as changed after `pin`. `### core: a
555
+ // · b · c` yields every slug on the line, not just the first; a
556
+ // `### stacks/eleventy-netlify.md` heading yields the binding marker.
557
+ function changelogSlugs(changelog, pin) {
558
+ const slugs = new Set();
559
+ let inRange = false;
560
+ for (const line of (changelog || "").split("\n")) {
561
+ const dateM = line.match(/^##\s+([0-9]{4}-[0-9]{2}-[0-9]{2})/);
562
+ if (dateM) inRange = dateM[1] > pin;
563
+ if (!inRange) continue;
564
+ const coreM = line.match(/^###\s+core:\s+(.+)/);
565
+ if (coreM) {
566
+ for (const part of coreM[1].split(/[·,]/)) {
567
+ const slug = (part.trim().match(/^[a-z0-9-]+/) || [])[0];
568
+ if (slug) slugs.add(slug);
569
+ }
570
+ }
571
+ if (/^###\s+stacks\/eleventy-netlify\.md/.test(line)) slugs.add("(eleventy binding)");
572
+ }
573
+ return [...slugs];
574
+ }
575
+
576
+ function versionDrift() {
577
+ const pin = (claudeMd.match(/standard-version:\s*([0-9-]+)/) || [])[1];
578
+ if (!STANDARD) {
579
+ return {
580
+ pin,
581
+ current: null,
582
+ rows: [
583
+ "MANUAL: standard text not resolvable — set WHS_STANDARD or install @piercebarney/whs-eleventy to check drift against the pin",
584
+ ],
585
+ };
586
+ }
587
+ const coreMd = read("core.md", STANDARD) || "";
588
+ const current = (coreMd.match(/\*\*Version:\*\*\s*([0-9-]+)/) || [])[1];
589
+ if (!pin || !current) return { pin, current, rows: [] };
590
+ if (pin >= current) return { pin, current, rows: [] };
591
+
592
+ const changelog = read("CHANGELOG.md", STANDARD) || "";
593
+ return {
594
+ pin,
595
+ current,
596
+ rows: changelogSlugs(changelog, pin).map(
597
+ (s) => `MANUAL: re-check #${s} — changed since the pinned ${pin}`,
598
+ ),
599
+ };
600
+ }
601
+
602
+ // ---- run --------------------------------------------------------------
603
+
604
+ function runCompliance() {
605
+ loadDoctor();
606
+ const results = [];
607
+ for (const [slug, fn] of Object.entries(CHECKS)) {
608
+ let r;
609
+ try {
610
+ r = fn();
611
+ } catch (e) {
612
+ r = { status: MANUAL, note: `check threw: ${(e.message || "").split("\n")[0]}` };
613
+ }
614
+ results.push({ slug, ...r });
615
+ }
616
+ const manualList = [
617
+ "page prose is genuinely in content/*.json, not the templates",
618
+ "every content page's `related` list is 2–4 genuinely relevant siblings",
619
+ "the a11y checklist holds beyond the axe scan (focus order, skip link, live regions)",
620
+ "the OG card design still matches the brand after any redesign",
621
+ ];
622
+ const drift = versionDrift();
623
+ return { results, manualList, drift, generatedAt: new Date().toISOString() };
624
+ }
625
+
626
+ function summarize({ results, drift }) {
627
+ const n = (s) => results.filter((r) => r.status === s).length;
628
+ return {
629
+ pass: n(PASS),
630
+ fail: n(FAIL),
631
+ manual: n(MANUAL) + drift.rows.length,
632
+ na: n(NA),
633
+ };
634
+ }
635
+
636
+ function writeCache(payload) {
637
+ try {
638
+ fs.mkdirSync(path.dirname(CACHE), { recursive: true });
639
+ fs.writeFileSync(CACHE, JSON.stringify(payload, null, 2));
640
+ } catch {
641
+ /* .cache read-only in some CI — console output is the source of truth */
642
+ }
643
+ }
644
+
645
+ function main() {
646
+ const strict = process.argv.includes("--strict");
647
+ const payload = runCompliance();
648
+ const s = summarize(payload);
649
+
650
+ console.log("\nCompliance — codebase vs web house style\n");
651
+ for (const r of payload.results) {
652
+ console.log(` ${r.status.padEnd(6)} #${r.slug}${r.note ? ` — ${r.note}` : ""}`);
653
+ }
654
+ if (payload.drift.rows.length) {
655
+ console.log(
656
+ payload.drift.current
657
+ ? `\n standard-version: pinned ${payload.drift.pin}, standard at ${payload.drift.current}`
658
+ : `\n standard-version: pinned ${payload.drift.pin || "(none)"}`,
659
+ );
660
+ for (const row of payload.drift.rows) console.log(` ${row}`);
661
+ }
662
+ console.log("\n Manual review (not mechanically checkable):");
663
+ for (const m of payload.manualList) console.log(` - ${m}`);
664
+
665
+ console.log(`\n${s.pass} pass · ${s.fail} fail · ${s.manual} manual · ${s.na} n/a\n`);
666
+
667
+ writeCache({ ...payload, summary: s });
668
+
669
+ if (strict && s.fail) {
670
+ console.error("compliance --strict: FAIL chapters present.");
671
+ process.exit(1);
672
+ }
673
+ }
674
+
675
+ if (require.main === module) main();
676
+
677
+ module.exports = { runCompliance, summarize, changelogSlugs };