@piercebarney/whs-eleventy 2026.9.1 → 2026.9.3

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/lib/_project.js CHANGED
@@ -22,6 +22,20 @@ function tryProjectRequire(rel, fallback = null) {
22
22
  }
23
23
  }
24
24
 
25
+ // The content-arm -> build-arm escalation drawer (core.md#content-model): one
26
+ // `YYYY-MM-DD-<slug>.md` per blocked structural request, triaged and deleted by
27
+ // the build arm. Returns the open notes' filenames (README.md excluded), sorted.
28
+ function openRequests(root = ROOT) {
29
+ try {
30
+ return fs
31
+ .readdirSync(path.join(root, "requests"))
32
+ .filter((f) => f.endsWith(".md") && f.toLowerCase() !== "readme.md")
33
+ .sort();
34
+ } catch {
35
+ return [];
36
+ }
37
+ }
38
+
25
39
  // Locate the standard's text (core.md, CHANGELOG.md) for the drift check:
26
40
  // 1. WHS_STANDARD env var (the authoring repo points this at itself)
27
41
  // 2. the copy bundled into this package at publish time (standard/)
@@ -39,4 +53,4 @@ function resolveStandard() {
39
53
  return null;
40
54
  }
41
55
 
42
- module.exports = { ROOT, projectRequire, tryProjectRequire, resolveStandard };
56
+ module.exports = { ROOT, projectRequire, tryProjectRequire, openRequests, resolveStandard };
package/lib/a11y.js CHANGED
@@ -46,11 +46,19 @@ const getJSON = (url) =>
46
46
  function sitemapPaths() {
47
47
  const xml = fs.readFileSync(path.join(SITE, "sitemap.xml"), "utf8");
48
48
  const paths = [...xml.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => new URL(m[1]).pathname);
49
+ // The audit page and the 404 page are both deliberately excluded from the
50
+ // sitemap (core.md#sitemap-robots) but are still real pages a visitor
51
+ // lands on — the a11y Contract says "every page", not "every sitemap
52
+ // entry", so scan them explicitly.
49
53
  paths.push("/audit/");
54
+ if (fs.existsSync(path.join(SITE, "404.html"))) paths.push("/404.html");
50
55
  return [...new Set(paths)];
51
56
  }
52
57
 
53
58
  function connect(wsUrl) {
59
+ // The global `WebSocket` is stable only from Node 22 — package.json's
60
+ // `engines.node` reflects that (it does not exist, even unflagged, on
61
+ // Node 20, which this file used to silently assume it did).
54
62
  const ws = new WebSocket(wsUrl);
55
63
  let id = 0;
56
64
  const pending = {};
@@ -125,7 +125,7 @@ function outputHas(urlPath) {
125
125
  return fs.existsSync(asFile + "/index.html") || fs.existsSync(asFile + ".html");
126
126
  }
127
127
 
128
- function checkRef(file, pageUrl, raw, { external = true } = {}) {
128
+ function checkRef(file, pageUrl, raw, { external = true, isHyperlink = false } = {}) {
129
129
  if (!raw) return;
130
130
  const v = raw.trim();
131
131
  if (v === "" || v.startsWith("data:") || v.startsWith("mailto:") || v.startsWith("tel:")) return;
@@ -140,7 +140,14 @@ function checkRef(file, pageUrl, raw, { external = true } = {}) {
140
140
  }
141
141
  if (origin === site.url) {
142
142
  if (!outputHas(new URL(v).pathname)) err(file, `absolute link to a missing page: ${v}`);
143
- } else if (!allowedOrigins.has(origin) && !POLICY_LINK_ORIGINS.has(origin)) {
143
+ } else if (
144
+ !allowedOrigins.has(origin) &&
145
+ // POLICY_LINK_ORIGINS are informational <a> targets that never load a
146
+ // resource or receive visitor data (see its definition above) — that
147
+ // exemption applies only to plain hyperlinks, never to a resource
148
+ // reference (script/link/img/og:image), which does load from the origin.
149
+ !(isHyperlink && POLICY_LINK_ORIGINS.has(origin))
150
+ ) {
144
151
  err(file, `external origin not in thirdparties.js: ${origin}`);
145
152
  }
146
153
  return;
@@ -198,7 +205,7 @@ for (const f of htmlFiles) {
198
205
 
199
206
  root.querySelectorAll("a[href]").forEach((a) => {
200
207
  const href = a.getAttribute("href");
201
- checkRef(f, pageUrl, href);
208
+ checkRef(f, pageUrl, href, { isHyperlink: true });
202
209
  if (/^https?:\/\//i.test(href || "")) {
203
210
  let origin;
204
211
  try {
@@ -242,6 +249,31 @@ for (const f of htmlFiles) {
242
249
  });
243
250
  }
244
251
 
252
+ // ---- JS-level origin scan (core.md#third-parties) --------------------------
253
+ // The HTML walk above only ever sees origins referenced in markup — a runtime
254
+ // browser `fetch()`/`new URL()` call baked into a shipped .js file is
255
+ // invisible to it. This is a best-effort literal-string scan (it cannot see a
256
+ // dynamically-built URL), but it catches the common case: a hardcoded
257
+ // external origin in client JS that was never added to the manifest.
258
+ (function checkJsOrigins() {
259
+ const jsFiles = allFiles.filter((f) => f.endsWith(".js") && !f.endsWith("links-report.json"));
260
+ const ORIGIN_LITERAL = /(?:fetch|new\s+URL)\s*\(\s*[`'"](https?:\/\/[^`'"\s]+)[`'"]/g;
261
+ for (const f of jsFiles) {
262
+ const text = fs.readFileSync(f, "utf8");
263
+ for (const m of text.matchAll(ORIGIN_LITERAL)) {
264
+ let origin;
265
+ try {
266
+ origin = new URL(m[1]).origin;
267
+ } catch {
268
+ continue;
269
+ }
270
+ if (origin !== site.url && !allowedOrigins.has(origin)) {
271
+ err(f, `fetch()/URL() targets an origin not in thirdparties.js: ${origin}`);
272
+ }
273
+ }
274
+ }
275
+ })();
276
+
245
277
  // ---- ads: production-only, ads.txt present (core.md#ads) -------------------
246
278
  (function checkAds() {
247
279
  const ads = tryProjectRequire("src/_data/ads.js", {});
package/lib/compliance.js CHANGED
@@ -16,6 +16,7 @@
16
16
  const fs = require("node:fs");
17
17
  const path = require("node:path");
18
18
  const { execSync } = require("node:child_process");
19
+ const { parse: parseHtml } = require("node-html-parser");
19
20
  const { runRepoChecks } = require("./doctor.js");
20
21
  const { ROOT, resolveStandard } = require("./_project.js");
21
22
 
@@ -86,13 +87,23 @@ const loadDoctor = () => {
86
87
  doc = {};
87
88
  for (const c of runRepoChecks({ preflight: false })) doc[c.id] = c;
88
89
  };
89
- const fromDoctor = (id, extra = "") => {
90
+ // `strict: true` is for chapters whose Contract has no "advisory" reading —
91
+ // doctor's own "warn" severity is right for its own informational printing,
92
+ // but a compliance chapter reporting MANUAL for "no CSP at all" or "wrong
93
+ // Node" hides a real, mechanically-known violation from `--strict`. Only the
94
+ // compliance view remaps warn -> FAIL; doctor.js's own severity is untouched.
95
+ const fromDoctor = (id, extra = "", { strict = false, missingIsFail = false } = {}) => {
90
96
  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
- };
97
+ if (!c) {
98
+ return missingIsFail
99
+ ? {
100
+ status: FAIL,
101
+ note: [`doctor check ${id} could not run`, extra].filter(Boolean).join(" · "),
102
+ }
103
+ : { status: MANUAL, note: `doctor check ${id} not found` };
104
+ }
105
+ const status = c.severity === "ok" ? PASS : c.severity === "blocker" || strict ? FAIL : MANUAL;
106
+ return { status, note: [c.detail, extra].filter(Boolean).join(" · ") };
96
107
  };
97
108
 
98
109
  // ---- per-chapter checks ------------------------------------------------
@@ -115,6 +126,11 @@ const CHECKS = {
115
126
  "runtime-pin": () => {
116
127
  if (/NODE_VERSION/.test(read("netlify.toml") || ""))
117
128
  return { status: FAIL, note: "NODE_VERSION in netlify.toml — use .nvmrc" };
129
+ if (!has(".nvmrc"))
130
+ return { status: FAIL, note: ".nvmrc missing — no single pinned runtime version" };
131
+ // A running-Node/.nvmrc major mismatch is a MANUAL judgment (the developer's
132
+ // local shell may just not have `nvm use`'d yet) — everything else about the
133
+ // pin (single source, present) is mechanically FAIL-able.
118
134
  return fromDoctor("nvmrc-node");
119
135
  },
120
136
 
@@ -136,7 +152,9 @@ const CHECKS = {
136
152
  }
137
153
  const protocol =
138
154
  has("CONTENT.md") && /"content-check"/.test(read("package.json") || "")
139
- ? "agent content-ops protocol present"
155
+ ? has("requests")
156
+ ? "agent content-ops protocol present (CONTENT.md, content-check, requests/)"
157
+ : "CONTENT.md + content-check present, but no requests/ escalation drawer"
140
158
  : "no CONTENT.md / content-check — add it if content is agent-edited";
141
159
  return {
142
160
  status: MANUAL,
@@ -146,7 +164,22 @@ const CHECKS = {
146
164
 
147
165
  rendering: () => {
148
166
  if (!has("src/404.njk")) return { status: FAIL, note: "no src/404.njk" };
149
- return { status: PASS, note: "server-rendered HTML; 404 page present" };
167
+ // A page whose <main> is empty/near-empty in the built output is the
168
+ // classic "content only appears after client JS runs" regression the
169
+ // Contract forbids — a crude but real content-presence check, distinct
170
+ // from merely confirming a 404 template exists.
171
+ const thin = siteHtml().filter((f) => {
172
+ const root = parseHtml(fs.readFileSync(f, "utf8"));
173
+ const main = root.querySelector("main");
174
+ const text = (main ? main.text : root.text || "").replace(/\s+/g, " ").trim();
175
+ return text.length < 40;
176
+ });
177
+ if (thin.length)
178
+ return {
179
+ status: FAIL,
180
+ note: `${thin.length} page(s) with near-empty <main> — content may be client-rendered only`,
181
+ };
182
+ return { status: PASS, note: "server-rendered HTML with real content; 404 page present" };
150
183
  },
151
184
 
152
185
  assets: () => {
@@ -223,20 +256,44 @@ const CHECKS = {
223
256
  },
224
257
 
225
258
  "og-image": () => {
226
- let bad = 0;
259
+ // PNG IHDR: an 8-byte signature, then a 4-byte length + "IHDR" + a
260
+ // 4-byte width + 4-byte height, big-endian, at fixed offsets — no decoder
261
+ // dependency needed to read just the dimensions.
262
+ const pngSize = (rel) => {
263
+ let buf;
264
+ try {
265
+ buf = fs.readFileSync(path.join(ROOT, rel));
266
+ } catch {
267
+ return null;
268
+ }
269
+ if (buf.length < 24 || buf.toString("ascii", 12, 16) !== "IHDR") return null;
270
+ return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
271
+ };
272
+ const bad = [];
227
273
  for (const f of siteHtml()) {
274
+ const rel = path.relative(ROOT, f);
228
275
  const m = fs
229
276
  .readFileSync(f, "utf8")
230
277
  .match(/property="og:image" content="[^"]*?\/og\/([^"]+?)\.png"/);
231
278
  if (!m) {
232
- bad++;
279
+ bad.push(`${rel} (no og:image)`);
233
280
  continue;
234
281
  }
235
- if (!has(`_site/og/${m[1]}.png`)) bad++;
282
+ const cardRel = `_site/og/${m[1]}.png`;
283
+ if (!has(cardRel)) {
284
+ bad.push(`${rel} (${cardRel} missing)`);
285
+ continue;
286
+ }
287
+ const dims = pngSize(cardRel);
288
+ if (!dims || dims.width !== 1200 || dims.height !== 630) {
289
+ bad.push(
290
+ `${rel} (${cardRel} is ${dims ? `${dims.width}x${dims.height}` : "not a PNG"}, not 1200x630)`,
291
+ );
292
+ }
236
293
  }
237
- return bad
238
- ? { status: FAIL, note: `${bad} page(s) with a missing/!1200x630 og:image` }
239
- : { status: PASS, note: "every og:image resolves" };
294
+ return bad.length
295
+ ? { status: FAIL, note: `${bad.length} page(s): ${bad.slice(0, 5).join("; ")}` }
296
+ : { status: PASS, note: "every og:image resolves at 1200x630" };
240
297
  },
241
298
 
242
299
  "structured-data": () => {
@@ -258,11 +315,31 @@ const CHECKS = {
258
315
  };
259
316
  },
260
317
 
261
- noindex: () =>
262
- fromDoctor(
263
- "prod-indexable",
264
- "DEPLOY_TARGET unset => noindex present (verified in check-links)",
265
- ),
318
+ noindex: () => {
319
+ // doctor's `prod-indexable` repo-tier check only *describes* the built
320
+ // output's current noindex state — it does not judge it (that's what
321
+ // `--deploy-preflight` is for). Judging the actual Contract — "a
322
+ // non-production build always carries noindex" — needs its own check
323
+ // here: verify the SOURCE gate exists (independent of whichever build
324
+ // happens to be sitting in _site/), and additionally check the built
325
+ // output when we can tell which target it was built for.
326
+ const layout = read("src/_includes/layout.njk") || "";
327
+ const robots = read("src/robots.njk") || "";
328
+ if (!/build\.isProduction/.test(layout) || !/noindex/i.test(layout))
329
+ return { status: FAIL, note: "layout.njk has no build.isProduction-gated noindex meta" };
330
+ if (!/build\.isProduction/.test(robots))
331
+ return { status: FAIL, note: "robots.njk Disallow is not keyed on build.isProduction" };
332
+ const home = read("_site/index.html");
333
+ if (home !== null && process.env.DEPLOY_TARGET !== "production") {
334
+ const indexable = !/<meta[^>]+name=["']robots["'][^>]+noindex/i.test(home);
335
+ if (indexable)
336
+ return {
337
+ status: FAIL,
338
+ note: "_site/index.html is indexable but DEPLOY_TARGET is not production",
339
+ };
340
+ }
341
+ return { status: PASS, note: "layout.njk + robots.njk gate on build.isProduction" };
342
+ },
266
343
 
267
344
  "brand-source": () => {
268
345
  // Per the standard: grep src/, static/style.css, scripts/og-card.js|icons.js.
@@ -302,8 +379,19 @@ const CHECKS = {
302
379
  return { status: PASS, note: "noindex; show-all toggle; all tabs present" };
303
380
  },
304
381
 
305
- "security-headers": () =>
306
- fromDoctor("security-headers", doc["csp-thirdparties"] ? doc["csp-thirdparties"].detail : ""),
382
+ "security-headers": () => {
383
+ if (!has("netlify.toml"))
384
+ return { status: FAIL, note: "no netlify.toml — no security headers declared" };
385
+ // A missing/incomplete header set, or a CSP that grants an origin outside
386
+ // the declared manifest, are mechanically-known Contract violations, not
387
+ // judgment calls — FAIL them rather than leaving them an invisible-to
388
+ // ---strict MANUAL.
389
+ const headers = fromDoctor("security-headers", "", { strict: true, missingIsFail: true });
390
+ const csp = fromDoctor("csp-thirdparties", "", { strict: true, missingIsFail: true });
391
+ if (headers.status === FAIL) return headers;
392
+ if (csp.status === FAIL) return csp;
393
+ return { status: PASS, note: [headers.note, csp.note].filter(Boolean).join(" · ") };
394
+ },
307
395
 
308
396
  caching: () => {
309
397
  const toml = read("netlify.toml") || "";
@@ -326,14 +414,22 @@ const CHECKS = {
326
414
  },
327
415
 
328
416
  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))
417
+ // The privacy page may be a standalone src/privacy.njk or a pages.json
418
+ // entry rendered by a src/pages/ shell — check the built output, with a
419
+ // source fallback for when _site isn't built yet.
420
+ const built = has("_site/privacy/index.html");
421
+ const source =
422
+ has("src/privacy.njk") ||
423
+ /["']slug["']\s*:\s*["']privacy["']/.test(read("src/content/pages.json") || "");
424
+ if (!built && !source) return { status: FAIL, note: "no privacy page" };
425
+ // The third-party disclosure must be generated from _data/thirdparties.js,
426
+ // never hand-maintained — the loop lives in the privacy shell or an include.
427
+ if (grepSrc(/for\s+t\s+in\s+thirdparties/).length === 0)
332
428
  return {
333
429
  status: FAIL,
334
430
  note: "privacy third-party list not rendered from _data/thirdparties.js",
335
431
  };
336
- return { status: PASS, note: "third-party list rendered from the manifest" };
432
+ return { status: PASS, note: "privacy page present; third-party list from the manifest" };
337
433
  },
338
434
 
339
435
  ads: () => {
@@ -353,7 +449,11 @@ const CHECKS = {
353
449
  };
354
450
  // Real ad CODE (not a bare origin string the audit page shows as data).
355
451
  const AD_CODE = /adsbygoogle\.js|<ins[^>]+adsbygoogle|google-adsense-account/;
356
- const prod = doc["prod-indexable"] && /indexable/.test(doc["prod-indexable"].detail || "");
452
+ // Read the built output directly rather than regex-matching another
453
+ // check's human-readable detail string — the same source check-links.js
454
+ // and the noindex check above use.
455
+ const home = read("_site/index.html");
456
+ const prod = home !== null && !/<meta[^>]+name=["']robots["'][^>]+noindex/i.test(home);
357
457
  const leak = siteHtml().filter((f) => AD_CODE.test(fs.readFileSync(f, "utf8")));
358
458
  if (!prod && leak.length)
359
459
  return { status: FAIL, note: `ad code in ${leak.length} non-production page(s)` };
@@ -371,13 +471,21 @@ const CHECKS = {
371
471
  },
372
472
 
373
473
  forms: () => {
374
- const layout = read("src/_includes/layout.njk") || "";
474
+ // A form can live anywhere in the built output, not only in layout.njk
475
+ // (a contact page's own template, say) — scan every page, and treat "no
476
+ // form anywhere" as N/A rather than a false FAIL on a site that has none.
477
+ const pagesWithForms = siteHtml().filter((f) => /<form[\s>]/i.test(fs.readFileSync(f, "utf8")));
478
+ if (!pagesWithForms.length) return { status: NA, note: "no <form> in the built output" };
479
+ const hasHoneypot = pagesWithForms.some((f) =>
480
+ /netlify-honeypot|name="bot-field"/.test(fs.readFileSync(f, "utf8")),
481
+ );
482
+ if (!hasHoneypot) return { status: FAIL, note: "no honeypot field in the form" };
375
483
  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
484
  if (handlers.length > 1)
379
485
  return { status: FAIL, note: `submit handler in ${handlers.length} files — consolidate` };
380
- return { status: PASS, note: "one handler in site.js; honeypot present" };
486
+ if (handlers.length === 0)
487
+ return { status: FAIL, note: "a form exists but no submit handler was found" };
488
+ return { status: PASS, note: "one handler; honeypot present" };
381
489
  },
382
490
 
383
491
  "domain-tests": () => {
@@ -404,13 +512,18 @@ const CHECKS = {
404
512
  };
405
513
  },
406
514
 
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
- ),
515
+ "the-gate": () => {
516
+ // A gate missing a stage (e.g. a11y quietly dropped from `check`) is a
517
+ // mechanically-known Contract violation — FAIL it, don't bury it in MANUAL.
518
+ const leaked = /doctor|compliance/.test(
519
+ (JSON.parse(read("package.json") || "{}").scripts || {}).check || "",
520
+ );
521
+ if (leaked) return { status: FAIL, note: "doctor/compliance leaked into `npm run check`" };
522
+ return fromDoctor("gate-stages", "doctor + compliance kept out", {
523
+ strict: true,
524
+ missingIsFail: true,
525
+ });
526
+ },
414
527
 
415
528
  compliance: () => {
416
529
  const pkg = JSON.parse(read("package.json") || "{}");
@@ -564,7 +677,20 @@ function changelogSlugs(changelog, pin) {
564
677
  const coreM = line.match(/^###\s+core:\s+(.+)/);
565
678
  if (coreM) {
566
679
  for (const part of coreM[1].split(/[·,]/)) {
567
- const slug = (part.trim().match(/^[a-z0-9-]+/) || [])[0];
680
+ // Strip wrapping punctuation a heading can carry — `(a, b, c)`
681
+ // splits into parts like `(a` and `c)` — before matching, or the
682
+ // leading "(" makes the slug regex miss the first entry entirely.
683
+ const cleaned = part.trim().replace(/^[^a-z0-9]+|[^a-z0-9]+$/gi, "");
684
+ if (!cleaned) continue;
685
+ if (/^all$/i.test(cleaned)) {
686
+ // "### core: all" (the initial-split entry) means every chapter,
687
+ // not a literal slug named "all" — the registry has no such slug,
688
+ // so emitting it as one would print a `MANUAL: re-check #all` row
689
+ // that looks real but isn't.
690
+ slugs.add("(all core chapters)");
691
+ continue;
692
+ }
693
+ const slug = (cleaned.match(/^[a-z0-9-]+/) || [])[0];
568
694
  if (slug) slugs.add(slug);
569
695
  }
570
696
  }
@@ -674,4 +800,6 @@ function main() {
674
800
 
675
801
  if (require.main === module) main();
676
802
 
677
- module.exports = { runCompliance, summarize, changelogSlugs };
803
+ // CHECKS is exported for the "coverage" test — nothing else should read it as
804
+ // data (call runCompliance() for results).
805
+ module.exports = { runCompliance, summarize, changelogSlugs, CHECKS };
@@ -4,42 +4,94 @@
4
4
  //
5
5
  // whs content-check
6
6
  //
7
- // Also warns — does not block — when the working tree has changes outside the
8
- // content set, so a stray code edit doesn't ride along in a `content:` commit.
7
+ // A **staged** file outside the reserved content set is an error — a `content:`
8
+ // commit can't carry a code/structure change. An unstaged stray only warns (it
9
+ // won't be in the commit unless staged). Also reports the open requests/ notes
10
+ // (the content-arm → build-arm escalation channel, core.md#content-model).
9
11
 
10
12
  const { execSync } = require("node:child_process");
13
+ const { openRequests } = require("./_project.js");
11
14
 
12
- // Files an agent following CONTENT.md is expected to touch.
13
- const CONTENT_SET = [/^src\/content\//, /^src\/_data\/nav\.js$/, /^src\/_data\/glossary\.js$/];
15
+ // Files an agent following CONTENT.md may touch — the reserved content set plus
16
+ // its own escalation drawer. Everything else (the *.schema.json editor aids,
17
+ // schema.js, the page shells, the includes, config) is a build-arm change.
18
+ const RESERVED_SET = [
19
+ /^src\/content\/(?!.*\.schema\.json$)[^/]+\.json$/,
20
+ /^src\/_data\/nav\.js$/,
21
+ /^src\/_data\/glossary\.js$/,
22
+ /^requests\//,
23
+ ];
14
24
 
15
- function changedFiles() {
25
+ // One `git status --porcelain` line → { path, staged }. `staged` is true when
26
+ // the index column (X) holds a real status letter — i.e. the change is part of
27
+ // the next commit. Untracked ("??") and worktree-only (" M") are not staged.
28
+ function parsePorcelain(out) {
29
+ return out
30
+ .split("\n")
31
+ .filter(Boolean)
32
+ .map((l) => {
33
+ const x = l[0];
34
+ const rest = l.slice(3);
35
+ const path = rest.includes(" -> ") ? rest.split(" -> ")[1] : rest;
36
+ return { path: path.trim(), staged: x !== " " && x !== "?" };
37
+ });
38
+ }
39
+
40
+ function changedEntries() {
16
41
  try {
17
- const out = execSync("git status --porcelain", { encoding: "utf8" });
18
- return out
19
- .split("\n")
20
- .map((l) => l.slice(3).trim())
21
- .filter(Boolean)
22
- .map((f) => (f.includes(" -> ") ? f.split(" -> ")[1] : f));
42
+ return parsePorcelain(execSync("git status --porcelain", { encoding: "utf8" }));
23
43
  } catch {
24
44
  return [];
25
45
  }
26
46
  }
27
47
 
28
- const stray = changedFiles().filter((f) => !CONTENT_SET.some((re) => re.test(f)));
29
- if (stray.length) {
30
- console.warn("\n⚠ changes outside the content set — these are code changes, not content:");
31
- for (const f of stray) console.warn(` ${f}`);
32
- console.warn(" Commit them separately (a Claude Code task), or confirm they're intentional.\n");
48
+ function strayFiles(paths) {
49
+ return paths.filter((f) => !RESERVED_SET.some((re) => re.test(f)));
33
50
  }
34
51
 
35
- const steps = ["lint", "validate", "build", "links"];
36
- for (const s of steps) {
37
- process.stdout.write(`content-check: npm run ${s}\n`);
38
- try {
39
- execSync(`npm run ${s}`, { stdio: "inherit" });
40
- } catch {
41
- console.error(`\ncontent-check: '${s}' failed — fix it before committing.`);
52
+ function splitStray(entries) {
53
+ return {
54
+ staged: strayFiles(entries.filter((e) => e.staged).map((e) => e.path)),
55
+ unstaged: strayFiles(entries.filter((e) => !e.staged).map((e) => e.path)),
56
+ };
57
+ }
58
+
59
+ function main() {
60
+ const { staged: stagedStray, unstaged: unstagedStray } = splitStray(changedEntries());
61
+
62
+ if (stagedStray.length) {
63
+ console.error("\n✗ staged changes outside the reserved content set (CONTENT.md):");
64
+ for (const f of stagedStray) console.error(` ${f}`);
65
+ console.error(
66
+ "\n A `content:` commit can't carry a code or structure change. Unstage them\n" +
67
+ " (git restore --staged <file>), or file a requests/ note for the build arm.\n",
68
+ );
42
69
  process.exit(1);
43
70
  }
71
+ if (unstagedStray.length) {
72
+ console.warn("\n⚠ uncommitted changes outside the reserved set (not staged):");
73
+ for (const f of unstagedStray) console.warn(` ${f}`);
74
+ console.warn(" They won't ride a `content:` commit unless you stage them.\n");
75
+ }
76
+
77
+ const reqs = openRequests();
78
+ if (reqs.length) {
79
+ console.log(`\nℹ ${reqs.length} open request(s) in requests/ awaiting the build arm.\n`);
80
+ }
81
+
82
+ const steps = ["lint", "validate", "build", "links"];
83
+ for (const s of steps) {
84
+ process.stdout.write(`content-check: npm run ${s}\n`);
85
+ try {
86
+ execSync(`npm run ${s}`, { stdio: "inherit" });
87
+ } catch {
88
+ console.error(`\ncontent-check: '${s}' failed — fix it before committing.`);
89
+ process.exit(1);
90
+ }
91
+ }
92
+ console.log("\ncontent-check: ok — lint · validate · build · links");
44
93
  }
45
- console.log("\ncontent-check: ok — lint · validate · build · links");
94
+
95
+ module.exports = { strayFiles, parsePorcelain, splitStray, RESERVED_SET };
96
+
97
+ if (require.main === module) main();
package/lib/doctor.js CHANGED
@@ -21,7 +21,8 @@
21
21
  const fs = require("node:fs");
22
22
  const path = require("node:path");
23
23
  const { execSync } = require("node:child_process");
24
- const { ROOT, projectRequire } = require("./_project.js");
24
+ const { ROOT, projectRequire, openRequests } = require("./_project.js");
25
+ const { asRegexMap } = require("./header-expect.js");
25
26
 
26
27
  const CACHE = path.join(ROOT, ".cache", "doctor.json");
27
28
 
@@ -30,14 +31,9 @@ const CACHE = path.join(ROOT, ".cache", "doctor.json");
30
31
  const loadSite = () => projectRequire("src/_data/site.js");
31
32
  const loadThirdparties = () => projectRequire("src/_data/thirdparties.js");
32
33
 
33
- // Live values expected on every response — same patterns the /audit/ page uses.
34
- const HEADER_EXPECT = {
35
- "Content-Security-Policy": /default-src 'self'/,
36
- "X-Content-Type-Options": /nosniff/,
37
- "X-Frame-Options": /DENY|SAMEORIGIN/,
38
- "Referrer-Policy": /strict-origin/,
39
- "Permissions-Policy": /geolocation=\(\)/,
40
- };
34
+ // Expected header value patterns — the single source (header-expect.js) also
35
+ // feeds the /audit/ page's live-header check via _data/headerExpect.js.
36
+ const HEADER_EXPECT = asRegexMap();
41
37
 
42
38
  const GATE_STAGES = ["lint", "validate", "test", "build", "links", "a11y"];
43
39
 
@@ -380,6 +376,26 @@ function main() {
380
376
  }
381
377
 
382
378
  if (preflight) {
379
+ // Open content-arm → build-arm requests gate the deploy: a conditional
380
+ // hard stop (core.md#content-model). Zero → ship; one or more → refuse
381
+ // unless --ack-requests says the operator has reviewed them.
382
+ const reqs = openRequests();
383
+ if (reqs.length && !args.includes("--ack-requests")) {
384
+ console.error(
385
+ `\ndoctor: ${reqs.length} open request(s) in requests/ — the content arm is ` +
386
+ `waiting on\nthe build arm. Clear them, or re-run the deploy with ` +
387
+ `--ack-requests to ship anyway:`,
388
+ );
389
+ reqs.forEach((r) => console.error(` - requests/${r}`));
390
+ console.error("\nRefusing to continue.");
391
+ process.exit(1);
392
+ }
393
+ console.log(
394
+ reqs.length
395
+ ? `\nOpen requests: ${reqs.length} — acknowledged (--ack-requests).`
396
+ : "\nOpen requests: 0.",
397
+ );
398
+
383
399
  const cr = complianceRegressions();
384
400
  if (!cr.checked) {
385
401
  console.log(
@@ -0,0 +1,34 @@
1
+ // The baseline security-header value patterns (core.md#security-headers),
2
+ // shared by two runtimes that can't `require()` each other:
3
+ // - doctor.js (Node) — reads them against the netlify.toml *declaration*.
4
+ // - the audit page's static/audit.js (browser) — reads them against the
5
+ // *live* response headers on `/`.
6
+ // Regex patterns are kept as source strings, not RegExp objects, so this one
7
+ // array can cross the Node -> build-data -> browser boundary as JSON (a
8
+ // project's `_data/headerExpect.js` re-exports this; audit.njk embeds it as
9
+ // an inline JSON block; static/audit.js reads that instead of hardcoding its
10
+ // own copy). Changing an expected value here changes both checks at once.
11
+
12
+ const HEADER_EXPECT = [
13
+ ["content-security-policy", "default-src 'self'"],
14
+ ["x-content-type-options", "nosniff"],
15
+ ["x-frame-options", "DENY|SAMEORIGIN"],
16
+ ["referrer-policy", "strict-origin"],
17
+ ["permissions-policy", "geolocation=\\(\\)"],
18
+ ];
19
+
20
+ // Node-side convenience: the same list as compiled RegExp objects, keyed by
21
+ // the doctor.js header-name casing it already uses.
22
+ function asRegexMap() {
23
+ const map = {};
24
+ for (const [key, pattern] of HEADER_EXPECT) {
25
+ const headerName = key
26
+ .split("-")
27
+ .map((w) => w[0].toUpperCase() + w.slice(1))
28
+ .join("-");
29
+ map[headerName] = new RegExp(pattern);
30
+ }
31
+ return map;
32
+ }
33
+
34
+ module.exports = { HEADER_EXPECT, asRegexMap };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@piercebarney/whs-eleventy",
3
- "version": "2026.9.1",
3
+ "version": "2026.9.3",
4
4
  "description": "The web house style's Eleventy + Netlify tooling — the compliance sweep, the infra doctor, the link/CSP integrity check, and the a11y scan, shared by every project on the stack.",
5
5
  "bin": {
6
6
  "whs": "cli.js"
@@ -28,7 +28,7 @@
28
28
  "directory": "packages/whs-eleventy"
29
29
  },
30
30
  "engines": {
31
- "node": ">=20"
31
+ "node": ">=22"
32
32
  },
33
33
  "dependencies": {
34
34
  "axe-core": "^4.13.0",