@piercebarney/whs-eleventy 2026.9.1 → 2026.9.2

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/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
 
@@ -146,7 +162,22 @@ const CHECKS = {
146
162
 
147
163
  rendering: () => {
148
164
  if (!has("src/404.njk")) return { status: FAIL, note: "no src/404.njk" };
149
- return { status: PASS, note: "server-rendered HTML; 404 page present" };
165
+ // A page whose <main> is empty/near-empty in the built output is the
166
+ // classic "content only appears after client JS runs" regression the
167
+ // Contract forbids — a crude but real content-presence check, distinct
168
+ // from merely confirming a 404 template exists.
169
+ const thin = siteHtml().filter((f) => {
170
+ const root = parseHtml(fs.readFileSync(f, "utf8"));
171
+ const main = root.querySelector("main");
172
+ const text = (main ? main.text : root.text || "").replace(/\s+/g, " ").trim();
173
+ return text.length < 40;
174
+ });
175
+ if (thin.length)
176
+ return {
177
+ status: FAIL,
178
+ note: `${thin.length} page(s) with near-empty <main> — content may be client-rendered only`,
179
+ };
180
+ return { status: PASS, note: "server-rendered HTML with real content; 404 page present" };
150
181
  },
151
182
 
152
183
  assets: () => {
@@ -223,20 +254,44 @@ const CHECKS = {
223
254
  },
224
255
 
225
256
  "og-image": () => {
226
- let bad = 0;
257
+ // PNG IHDR: an 8-byte signature, then a 4-byte length + "IHDR" + a
258
+ // 4-byte width + 4-byte height, big-endian, at fixed offsets — no decoder
259
+ // dependency needed to read just the dimensions.
260
+ const pngSize = (rel) => {
261
+ let buf;
262
+ try {
263
+ buf = fs.readFileSync(path.join(ROOT, rel));
264
+ } catch {
265
+ return null;
266
+ }
267
+ if (buf.length < 24 || buf.toString("ascii", 12, 16) !== "IHDR") return null;
268
+ return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
269
+ };
270
+ const bad = [];
227
271
  for (const f of siteHtml()) {
272
+ const rel = path.relative(ROOT, f);
228
273
  const m = fs
229
274
  .readFileSync(f, "utf8")
230
275
  .match(/property="og:image" content="[^"]*?\/og\/([^"]+?)\.png"/);
231
276
  if (!m) {
232
- bad++;
277
+ bad.push(`${rel} (no og:image)`);
233
278
  continue;
234
279
  }
235
- if (!has(`_site/og/${m[1]}.png`)) bad++;
280
+ const cardRel = `_site/og/${m[1]}.png`;
281
+ if (!has(cardRel)) {
282
+ bad.push(`${rel} (${cardRel} missing)`);
283
+ continue;
284
+ }
285
+ const dims = pngSize(cardRel);
286
+ if (!dims || dims.width !== 1200 || dims.height !== 630) {
287
+ bad.push(
288
+ `${rel} (${cardRel} is ${dims ? `${dims.width}x${dims.height}` : "not a PNG"}, not 1200x630)`,
289
+ );
290
+ }
236
291
  }
237
- return bad
238
- ? { status: FAIL, note: `${bad} page(s) with a missing/!1200x630 og:image` }
239
- : { status: PASS, note: "every og:image resolves" };
292
+ return bad.length
293
+ ? { status: FAIL, note: `${bad.length} page(s): ${bad.slice(0, 5).join("; ")}` }
294
+ : { status: PASS, note: "every og:image resolves at 1200x630" };
240
295
  },
241
296
 
242
297
  "structured-data": () => {
@@ -258,11 +313,31 @@ const CHECKS = {
258
313
  };
259
314
  },
260
315
 
261
- noindex: () =>
262
- fromDoctor(
263
- "prod-indexable",
264
- "DEPLOY_TARGET unset => noindex present (verified in check-links)",
265
- ),
316
+ noindex: () => {
317
+ // doctor's `prod-indexable` repo-tier check only *describes* the built
318
+ // output's current noindex state — it does not judge it (that's what
319
+ // `--deploy-preflight` is for). Judging the actual Contract — "a
320
+ // non-production build always carries noindex" — needs its own check
321
+ // here: verify the SOURCE gate exists (independent of whichever build
322
+ // happens to be sitting in _site/), and additionally check the built
323
+ // output when we can tell which target it was built for.
324
+ const layout = read("src/_includes/layout.njk") || "";
325
+ const robots = read("src/robots.njk") || "";
326
+ if (!/build\.isProduction/.test(layout) || !/noindex/i.test(layout))
327
+ return { status: FAIL, note: "layout.njk has no build.isProduction-gated noindex meta" };
328
+ if (!/build\.isProduction/.test(robots))
329
+ return { status: FAIL, note: "robots.njk Disallow is not keyed on build.isProduction" };
330
+ const home = read("_site/index.html");
331
+ if (home !== null && process.env.DEPLOY_TARGET !== "production") {
332
+ const indexable = !/<meta[^>]+name=["']robots["'][^>]+noindex/i.test(home);
333
+ if (indexable)
334
+ return {
335
+ status: FAIL,
336
+ note: "_site/index.html is indexable but DEPLOY_TARGET is not production",
337
+ };
338
+ }
339
+ return { status: PASS, note: "layout.njk + robots.njk gate on build.isProduction" };
340
+ },
266
341
 
267
342
  "brand-source": () => {
268
343
  // Per the standard: grep src/, static/style.css, scripts/og-card.js|icons.js.
@@ -302,8 +377,19 @@ const CHECKS = {
302
377
  return { status: PASS, note: "noindex; show-all toggle; all tabs present" };
303
378
  },
304
379
 
305
- "security-headers": () =>
306
- fromDoctor("security-headers", doc["csp-thirdparties"] ? doc["csp-thirdparties"].detail : ""),
380
+ "security-headers": () => {
381
+ if (!has("netlify.toml"))
382
+ return { status: FAIL, note: "no netlify.toml — no security headers declared" };
383
+ // A missing/incomplete header set, or a CSP that grants an origin outside
384
+ // the declared manifest, are mechanically-known Contract violations, not
385
+ // judgment calls — FAIL them rather than leaving them an invisible-to
386
+ // ---strict MANUAL.
387
+ const headers = fromDoctor("security-headers", "", { strict: true, missingIsFail: true });
388
+ const csp = fromDoctor("csp-thirdparties", "", { strict: true, missingIsFail: true });
389
+ if (headers.status === FAIL) return headers;
390
+ if (csp.status === FAIL) return csp;
391
+ return { status: PASS, note: [headers.note, csp.note].filter(Boolean).join(" · ") };
392
+ },
307
393
 
308
394
  caching: () => {
309
395
  const toml = read("netlify.toml") || "";
@@ -353,7 +439,11 @@ const CHECKS = {
353
439
  };
354
440
  // Real ad CODE (not a bare origin string the audit page shows as data).
355
441
  const AD_CODE = /adsbygoogle\.js|<ins[^>]+adsbygoogle|google-adsense-account/;
356
- const prod = doc["prod-indexable"] && /indexable/.test(doc["prod-indexable"].detail || "");
442
+ // Read the built output directly rather than regex-matching another
443
+ // check's human-readable detail string — the same source check-links.js
444
+ // and the noindex check above use.
445
+ const home = read("_site/index.html");
446
+ const prod = home !== null && !/<meta[^>]+name=["']robots["'][^>]+noindex/i.test(home);
357
447
  const leak = siteHtml().filter((f) => AD_CODE.test(fs.readFileSync(f, "utf8")));
358
448
  if (!prod && leak.length)
359
449
  return { status: FAIL, note: `ad code in ${leak.length} non-production page(s)` };
@@ -371,13 +461,21 @@ const CHECKS = {
371
461
  },
372
462
 
373
463
  forms: () => {
374
- const layout = read("src/_includes/layout.njk") || "";
464
+ // A form can live anywhere in the built output, not only in layout.njk
465
+ // (a contact page's own template, say) — scan every page, and treat "no
466
+ // form anywhere" as N/A rather than a false FAIL on a site that has none.
467
+ const pagesWithForms = siteHtml().filter((f) => /<form[\s>]/i.test(fs.readFileSync(f, "utf8")));
468
+ if (!pagesWithForms.length) return { status: NA, note: "no <form> in the built output" };
469
+ const hasHoneypot = pagesWithForms.some((f) =>
470
+ /netlify-honeypot|name="bot-field"/.test(fs.readFileSync(f, "utf8")),
471
+ );
472
+ if (!hasHoneypot) return { status: FAIL, note: "no honeypot field in the form" };
375
473
  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
474
  if (handlers.length > 1)
379
475
  return { status: FAIL, note: `submit handler in ${handlers.length} files — consolidate` };
380
- return { status: PASS, note: "one handler in site.js; honeypot present" };
476
+ if (handlers.length === 0)
477
+ return { status: FAIL, note: "a form exists but no submit handler was found" };
478
+ return { status: PASS, note: "one handler; honeypot present" };
381
479
  },
382
480
 
383
481
  "domain-tests": () => {
@@ -404,13 +502,18 @@ const CHECKS = {
404
502
  };
405
503
  },
406
504
 
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
- ),
505
+ "the-gate": () => {
506
+ // A gate missing a stage (e.g. a11y quietly dropped from `check`) is a
507
+ // mechanically-known Contract violation — FAIL it, don't bury it in MANUAL.
508
+ const leaked = /doctor|compliance/.test(
509
+ (JSON.parse(read("package.json") || "{}").scripts || {}).check || "",
510
+ );
511
+ if (leaked) return { status: FAIL, note: "doctor/compliance leaked into `npm run check`" };
512
+ return fromDoctor("gate-stages", "doctor + compliance kept out", {
513
+ strict: true,
514
+ missingIsFail: true,
515
+ });
516
+ },
414
517
 
415
518
  compliance: () => {
416
519
  const pkg = JSON.parse(read("package.json") || "{}");
@@ -564,7 +667,20 @@ function changelogSlugs(changelog, pin) {
564
667
  const coreM = line.match(/^###\s+core:\s+(.+)/);
565
668
  if (coreM) {
566
669
  for (const part of coreM[1].split(/[·,]/)) {
567
- const slug = (part.trim().match(/^[a-z0-9-]+/) || [])[0];
670
+ // Strip wrapping punctuation a heading can carry — `(a, b, c)`
671
+ // splits into parts like `(a` and `c)` — before matching, or the
672
+ // leading "(" makes the slug regex miss the first entry entirely.
673
+ const cleaned = part.trim().replace(/^[^a-z0-9]+|[^a-z0-9]+$/gi, "");
674
+ if (!cleaned) continue;
675
+ if (/^all$/i.test(cleaned)) {
676
+ // "### core: all" (the initial-split entry) means every chapter,
677
+ // not a literal slug named "all" — the registry has no such slug,
678
+ // so emitting it as one would print a `MANUAL: re-check #all` row
679
+ // that looks real but isn't.
680
+ slugs.add("(all core chapters)");
681
+ continue;
682
+ }
683
+ const slug = (cleaned.match(/^[a-z0-9-]+/) || [])[0];
568
684
  if (slug) slugs.add(slug);
569
685
  }
570
686
  }
@@ -674,4 +790,6 @@ function main() {
674
790
 
675
791
  if (require.main === module) main();
676
792
 
677
- module.exports = { runCompliance, summarize, changelogSlugs };
793
+ // CHECKS is exported for the "coverage" test — nothing else should read it as
794
+ // data (call runCompliance() for results).
795
+ module.exports = { runCompliance, summarize, changelogSlugs, CHECKS };
package/lib/doctor.js CHANGED
@@ -22,6 +22,7 @@ const fs = require("node:fs");
22
22
  const path = require("node:path");
23
23
  const { execSync } = require("node:child_process");
24
24
  const { ROOT, projectRequire } = 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
 
@@ -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.2",
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",
@@ -8,6 +8,154 @@ Format: `## <date>` → `### core: <slug>` / `### <stack>` entries.
8
8
 
9
9
  ---
10
10
 
11
+ ## 2026-09-02 — closing the gap between the compliance grid and the Contracts it checks
12
+
13
+ A pass through every chapter's actual enforcement, prompted by an outside
14
+ evaluation that traced specific checks to specific failure modes they
15
+ couldn't catch. Nothing here is a new chapter — it's the existing ones made to
16
+ actually fail when their Contract is violated, plus the doc-accuracy and
17
+ consistency defects the same pass turned up.
18
+
19
+ ### core: noindex
20
+
21
+ - **The header-backstop layer (spec 2) is honestly scoped.** It no longer
22
+ reads as a universal "bonus" — the wording now says an impl doc states
23
+ plainly whether the layer applies to that stack's deploy model at all, since
24
+ a platform's per-context header config may not reach every deploy path (a
25
+ manually-uploaded artifact bypassing the platform's normal build pipeline,
26
+ for one).
27
+
28
+ ### core: adopting
29
+
30
+ - The `standard-version` pin's example comment changes from `# optional pin`
31
+ to `# recommended — enables standard-version drift tracking (#compliance)`
32
+ — `#compliance`'s own check already treats it as required; the doc
33
+ contradicted the code.
34
+
35
+ ### docs — drift-guard registry sync
36
+
37
+ - `core.md`'s Appendix **shared-concept drift guard** list now matches
38
+ `CLAUDE.md`'s (it had drifted to two different lists): adds `compliance`,
39
+ `ads`, `llm-integration` to `core.md`'s Appendix and the `README.md` copy.
40
+ All three now list the same ten slugs. (Not written as `### core: <slug>`
41
+ above — the Appendix is a registry, not a chapter, and `changelogSlugs()`
42
+ would otherwise mis-parse "registry" as a literal chapter slug.)
43
+
44
+ ### stacks/eleventy-netlify.md · phoenix.md · sveltekit.md
45
+
46
+ - **`repo-hygiene` · `runtime-pin` · `config-idiom` now have real mirrored
47
+ sections in all three files.** `core.md` has said "see your stack's impl
48
+ doc, same slug" for these three chapters from the start; no stack file ever
49
+ actually carried that heading — the mechanism lived under differently-named
50
+ impl-only sections instead. Each new section is a one-line Core-contract
51
+ pointer at the existing impl-only content, not a restatement. `bin/check`'s
52
+ second check (below) now actually depends on this.
53
+
54
+ ### stacks/eleventy-netlify.md
55
+
56
+ - **`#noindex`'s header-backstop layer is not attempted on this stack**, and
57
+ says why: Netlify's own docs are explicit that `[[headers]]` — and so any
58
+ `[[context.<name>.headers]]` block — is not context-aware; a netlify.toml
59
+ header declaration is global for every deploy regardless of context. The
60
+ documented workaround needs Netlify's build system to run, which `#ci-cd`
61
+ here deliberately disables. `templates/eleventy-netlify/netlify.toml` drops
62
+ the `[[context.deploy-preview.headers]]` / `[[context.branch-deploy.headers]]`
63
+ blocks it used to ship — they TOML-parsed cleanly but did nothing.
64
+ - **`#caching`'s example now matches what the template actually ships** (a
65
+ per-file-type `/*.html` / `/*.css` / `/*.js` revalidate rule) instead of a
66
+ fingerprint-variant example that neither matched the shipped `netlify.toml`
67
+ nor satisfied `whs compliance`'s own `#caching` check. Also notes the
68
+ `/*.html` glob's directory-permalink matching isn't precisely documented by
69
+ Netlify (a real user report exists of `.html` not matching as expected) —
70
+ verify live rather than assume; `/audit/`'s live-header panel now shows the
71
+ actual `Cache-Control` value for exactly this.
72
+
73
+ ### stacks/phoenix.md
74
+
75
+ - **Two fabricated APIs, corrected against the real source.** `Plug.CSP` and
76
+ a `content_security_policy` endpoint option don't exist (verified against
77
+ the real `plug`/`phoenix` packages) — the CSP-nonce section now describes
78
+ the actual mechanism (a custom plug assigning a per-request nonce before
79
+ `put_secure_browser_headers/2` builds the header). `Plug.Rewrite` doesn't
80
+ exist either — the redirect section now uses the real, verified API
81
+ (`conn |> put_status(:moved_permanently) |> redirect(to: …)`, since
82
+ `redirect/2` takes its status from `conn.status`, not a `:status` option).
83
+ - **`TODO` — no reference implementation exists** banners added to
84
+ `internal-links`, `a11y`, `the-gate`, and `compliance` — these presented
85
+ `mix links.check` / `mix a11y` / `mix whs.check` as shipped tooling; none of
86
+ it exists yet. `the-gate`'s `mix check` snippet now says explicitly which 2
87
+ of its 10 steps are placeholders vs. real off-the-shelf deps.
88
+
89
+ ### tooling
90
+
91
+ - **`packages/whs-eleventy` — the compliance checks now actually fail.**
92
+ `#noindex` was a structurally unconditional `PASS` in the non-preflight
93
+ path (`doctor.js`'s `prod-indexable` check always set `ok: true` there); it
94
+ now verifies the `build.isProduction` gate exists in `layout.njk` /
95
+ `robots.njk` and, when the built output is available, that it matches. A
96
+ missing/incomplete CSP or a dropped gate stage previously degraded to an
97
+ invisible `MANUAL`; `#security-headers` and `#the-gate` now FAIL on those.
98
+ `#og-image` now reads the built card's actual PNG dimensions (no new
99
+ dependency — a fixed-offset IHDR read) instead of only checking the file
100
+ exists. `#rendering` now checks built pages for near-empty `<main>` content,
101
+ not only that `404.njk` exists. `#forms` returns `N/A` on a site with no
102
+ form instead of a false `FAIL`, and now scans the whole built output rather
103
+ than only `layout.njk`. `#ads`'s production-detection reads the built
104
+ output directly instead of regexing another check's human-readable string.
105
+ - **`check-links.js`:** the informational-link-only exemption
106
+ (`POLICY_LINK_ORIGINS`) no longer applies to `script`/`link`/`img`
107
+ references — only plain `<a href>`. A new best-effort scan of shipped `.js`
108
+ output catches a hardcoded `fetch()`/`new URL()` call to an origin outside
109
+ the manifest, which the HTML-only walk could never see.
110
+ - **`a11y.js`:** `engines.node` is now `>=22` (verified against real Node
111
+ 20/22 installs — the global `WebSocket` this file uses doesn't exist on 20,
112
+ contradicting the previous `>=20`). The scan now also covers `/404.html`,
113
+ deliberately excluded from the sitemap but still a real page.
114
+ - **`changelogSlugs()`:** fixed dropping the first entry of a
115
+ parenthesis-wrapped multi-slug heading (`### core: (a, b, c)`), and no
116
+ longer emits a bogus literal `#all` slug for `### core: all` — that's now a
117
+ `(all core chapters)` sentinel.
118
+ - **New shared module `lib/header-expect.js`** — the baseline security-header
119
+ patterns `doctor.js` and the `/audit/` page's live-header check each used to
120
+ hardcode independently now come from one source; the template bakes it into
121
+ the audit page's existing data block at build time.
122
+ - **New fixture-based test coverage** (`test/compliance-checks.test.js`) for
123
+ the eight chapters above — a reusable throwaway-project harness, PASS and
124
+ FAIL cases each.
125
+ - **`bin/check`'s second check redesigned.** It used to hash each stack
126
+ file's *intersection* with core's slug set and compare hashes across
127
+ files — green even when a chapter was mirrored in *none* of them (exactly
128
+ the `repo-hygiene`/`runtime-pin`/`config-idiom` gap above). It now diffs
129
+ each **active** stack (`index.json` `status: active`) directly against
130
+ core's full slug set. A draft/parked stack is deliberately exempt from full
131
+ mirroring — check #1 still holds it to "any `core.md#` reference it cites
132
+ must resolve" — so the standard gaining a chapter no longer forces an edit
133
+ in a stack nothing is built on yet.
134
+ - The third check's restated-principle grep is widened (still a heuristic,
135
+ now says so). `bin/set-version` and `bin/check`'s version-agreement check
136
+ now also cover `README.md`'s `standard-version` example, which had drifted
137
+ stale and untracked.
138
+
139
+ ### templates/eleventy-netlify
140
+
141
+ - **The `draft` content flag is now wired**, matching the spec
142
+ `stacks/eleventy-netlify.md#content-model` has always stated: an optional
143
+ boolean in `guides.schema.json` + `schema.js`, and `guides.11tydata.js`'s
144
+ `eleventyComputed` sets `permalink: false` + `eleventyExcludeFromCollections:
145
+ true` only when `draft && build.isProduction`.
146
+ - **`bin/init`** now warns loudly (and adds a numbered follow-up step) when a
147
+ brief's `css:` answer doesn't match what the template actually ships
148
+ (`pico-classes`) — previously the key was recorded in `CLAUDE.md` as if
149
+ applied, with nothing behind it. The tooling pin it writes is `>=X.Y.Z`, not
150
+ `^X.Y.Z` — the package's major digit is the release year, not a
151
+ breaking-change signal, so a caret range silently stopped matching every
152
+ January 1st.
153
+ - `.claude/settings.json`'s hook matcher and `og-guard.sh`'s watched-file
154
+ globs now match what `stacks/eleventy-netlify.md` documents (`MultiEdit`,
155
+ `static/fonts/*`).
156
+
157
+ ---
158
+
11
159
  ## 2026-09-01 — the shared tooling package (#compliance mechanism, eleventy)
12
160
 
13
161
  ### stacks/eleventy-netlify.md
@@ -37,8 +185,9 @@ Format: `## <date>` → `### core: <slug>` / `### <stack>` entries.
37
185
  - **`.github/workflows/publish.yml`** publishes `@piercebarney/whs-eleventy` to
38
186
  npm when a `YYYY-MM-DD` version tag is pushed — it lints + tests the package,
39
187
  checks the tag date (dotted) matches `package.json`, and publishes unless that
40
- version is already up. Needs the `@piercebarney` npm scope and an `NPM_TOKEN`
41
- repo secret. `--provenance` is omitted (needs a public source repo).
188
+ version is already up. Auth is npm **Trusted Publishing** (OIDC) — no token,
189
+ no secret, one-time `Trusted Publisher` setup on the package's npm settings.
190
+ Provenance is auto-skipped for the private source repo.
42
191
  - `standard.yml` gains a `package` job (prettier + eslint + `node:test`); the
43
192
  `templates` job installs the file: package's own tree and points
44
193
  `WHS_STANDARD` at the checkout so drift is checked against the live standard.
package/standard/core.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Web project house style — CORE (stack-agnostic)
2
2
 
3
- **Version:** 2026-09-01 · **Status:** active
3
+ **Version:** 2026-09-02 · **Status:** active
4
4
 
5
5
  This is the stack-agnostic contract every web project follows, regardless of
6
6
  framework, host, or CSS system. It says **what** must be true, with concrete
@@ -412,7 +412,11 @@ by three layers in order of reliance.
412
412
  non-production build.
413
413
  2. **Header backstop:** the host's response headers add `X-Robots-Tag: noindex`
414
414
  for preview/branch contexts where the platform supports it. Treated as a
415
- bonus — not relied on.
415
+ bonus — never relied on, and never assumed present without checking: a
416
+ platform's per-context header config may not apply to every deploy path
417
+ (e.g. a manually-uploaded artifact bypassing that platform's normal build
418
+ pipeline) — the impl doc states plainly whether this layer actually
419
+ applies to the stack's deploy model, or is a no-op there.
416
420
  3. **Canonical:** every page's canonical points at the production origin, so an
417
421
  indexed stray still redirects search engines to production.
418
422
 
@@ -1333,6 +1337,11 @@ governs it and follow that chapter — the slugs are the index (a `<script>` →
1333
1337
  - A conflict between the current code and a chapter is a **migration gap** —
1334
1338
  surface it; don't quietly fix it inside an unrelated task and don't quietly
1335
1339
  route around it.
1340
+ - A defect in the house style *itself* — a `#compliance` or gate check that
1341
+ misfires, two chapters that contradict, a **Contract** that is wrong or
1342
+ unclear, a recurring need it is silent on — is reported back to the standard,
1343
+ not left in this session's transcript: a dated note in the standard repo's
1344
+ `feedback/` inbox (or the same note handed to a person to drop there).
1336
1345
  - Never silently diverge from a **Contract**. A deliberate deviation is
1337
1346
  recorded in this section with its reason.
1338
1347
  - Where the house style is silent, do what is idiomatic for the stack.
@@ -1350,7 +1359,7 @@ governs it and follow that chapter — the slugs are the index (a `<script>` →
1350
1359
  - production-url: https://example.com
1351
1360
  - content-type: tool # tool | article (article => feeds)
1352
1361
  - publishing-rate: ~5 pages/week
1353
- - standard-version: 2026-09-01 # optional pin
1362
+ - standard-version: 2026-09-02 # recommended — enables standard-version drift tracking (#compliance)
1354
1363
  ```
1355
1364
 
1356
1365
  If the section is absent, the assistant's first action is to run the flow above
@@ -1363,6 +1372,18 @@ worked incrementally (the impl doc's migration path is the ordered version of
1363
1372
  that backlog). The project is not expected to be all-`PASS` on day one; it is
1364
1373
  expected to know exactly where it isn't.
1365
1374
 
1375
+ **Feedback to the standard.** Adopting the house style is what surfaces its
1376
+ bugs — a check that misfires, a `core.md` ↔ impl-doc contradiction, a Contract
1377
+ that doesn't survive a real codebase, a need it never anticipated. That signal
1378
+ belongs to the standard, not only to the project session that hit it: fixes
1379
+ flow standard → template → project, and this is the one path back. The
1380
+ mechanism is deliberately minimal — a dated note in the standard repo's
1381
+ `feedback/` inbox (what is wrong · which project · `standard-version`);
1382
+ `feedback/README.md` has the shape. A session that can't reach the standard's
1383
+ tree, or a content-only session, hands a person the same note to file. Triage
1384
+ empties the inbox into `CHANGELOG` changes or a one-line `wontfix`. This is
1385
+ separate from the project's own state and backlog.
1386
+
1366
1387
  **Change checklist (stack-agnostic)** — run before merging to `main`, and again
1367
1388
  before `bin/deploy`. (On a multi-committer project this is also the PR-review
1368
1389
  checklist.)
@@ -1463,6 +1484,9 @@ Each impl doc keeps its own stack-specific out-of-scope list.
1463
1484
  unknown what's actually left or what regressed.
1464
1485
  - A `standard-version` pin left behind the standard's current version with no
1465
1486
  `CHANGELOG`-delta re-check.
1487
+ - A defect in the standard found while adopting it — a check that misfires, a
1488
+ chapter contradiction, an unclear Contract — worked around silently or left in
1489
+ a session transcript instead of dropped in the standard's `feedback/` inbox.
1466
1490
  - A permalink changed with no `301` from the old path.
1467
1491
  - A default branch other than `main`.
1468
1492
  - Deploying by pushing to a branch instead of the sanctioned gated command.
@@ -1489,5 +1513,6 @@ mirrored chapter; impl-only chapters take slugs that appear nowhere here.
1489
1513
  `adopting` · `out-of-scope` · `anti-patterns`
1490
1514
 
1491
1515
  **Shared-concept drift guard** — defined only here; an impl doc may state the
1492
- *how*, never restate the *what*: `the-gate`, `ci-cd`, `third-parties`,
1493
- `brand-source`, `noindex`, `content-model`, `beyond-this-standard`.
1516
+ *how*, never restate the *what*: `the-gate`, `compliance`, `ci-cd`,
1517
+ `third-parties`, `brand-source`, `noindex`, `content-model`,
1518
+ `beyond-this-standard`, `ads`, `llm-integration`.