@piercebarney/whs-eleventy 2026.9.2 → 2026.9.8

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/compliance.js CHANGED
@@ -152,7 +152,9 @@ const CHECKS = {
152
152
  }
153
153
  const protocol =
154
154
  has("CONTENT.md") && /"content-check"/.test(read("package.json") || "")
155
- ? "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"
156
158
  : "no CONTENT.md / content-check — add it if content is agent-edited";
157
159
  return {
158
160
  status: MANUAL,
@@ -412,14 +414,22 @@ const CHECKS = {
412
414
  },
413
415
 
414
416
  privacy: () => {
415
- const p = read("src/privacy.njk") || "";
416
- if (!has("src/privacy.njk")) return { status: FAIL, note: "no privacy page" };
417
- 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)
418
428
  return {
419
429
  status: FAIL,
420
430
  note: "privacy third-party list not rendered from _data/thirdparties.js",
421
431
  };
422
- 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" };
423
433
  },
424
434
 
425
435
  ads: () => {
@@ -654,16 +664,26 @@ const CHECKS = {
654
664
 
655
665
  // ---- standard-version drift -----------------------------------------
656
666
 
657
- // Every chapter slug the CHANGELOG records as changed after `pin`. `### core: a
658
- // · b · c` yields every slug on the line, not just the first; a
659
- // `### stacks/eleventy-netlify.md` heading yields the binding marker.
660
- function changelogSlugs(changelog, pin) {
661
- const slugs = new Set();
662
- let inRange = false;
667
+ // Every CHANGELOG entry (`## <date> [breaking|non-breaking]`) dated after
668
+ // `pin`, with the chapter slugs it touched. `### core: a · b · c` yields
669
+ // every slug on the line, not just the first; a
670
+ // `### stacks/eleventy-netlify.md` heading yields the binding marker. An
671
+ // entry heading with no severity tag (a fixture, or a pre-2026-09-04
672
+ // CHANGELOG snapshot) defaults to non-breaking — the real standard's own
673
+ // CHANGELOG.md always carries the tag (bin/check's 6th check enforces it).
674
+ function changelogEntries(changelog, pin) {
675
+ const entries = [];
676
+ let current = null;
663
677
  for (const line of (changelog || "").split("\n")) {
664
- const dateM = line.match(/^##\s+([0-9]{4}-[0-9]{2}-[0-9]{2})/);
665
- if (dateM) inRange = dateM[1] > pin;
666
- if (!inRange) continue;
678
+ const dateM = line.match(
679
+ /^##\s+([0-9]{4}-[0-9]{2}-[0-9]{2}).*?(?:\[(breaking|non-breaking)\])?\s*$/,
680
+ );
681
+ if (dateM) {
682
+ current = dateM[1] > pin ? { severity: dateM[2] || "non-breaking", slugs: new Set() } : null;
683
+ if (current) entries.push(current);
684
+ continue;
685
+ }
686
+ if (!current) continue;
667
687
  const coreM = line.match(/^###\s+core:\s+(.+)/);
668
688
  if (coreM) {
669
689
  for (const part of coreM[1].split(/[·,]/)) {
@@ -677,15 +697,23 @@ function changelogSlugs(changelog, pin) {
677
697
  // not a literal slug named "all" — the registry has no such slug,
678
698
  // so emitting it as one would print a `MANUAL: re-check #all` row
679
699
  // that looks real but isn't.
680
- slugs.add("(all core chapters)");
700
+ current.slugs.add("(all core chapters)");
681
701
  continue;
682
702
  }
683
703
  const slug = (cleaned.match(/^[a-z0-9-]+/) || [])[0];
684
- if (slug) slugs.add(slug);
704
+ if (slug) current.slugs.add(slug);
685
705
  }
686
706
  }
687
- if (/^###\s+stacks\/eleventy-netlify\.md/.test(line)) slugs.add("(eleventy binding)");
707
+ if (/^###\s+stacks\/eleventy-netlify\.md/.test(line)) current.slugs.add("(eleventy binding)");
688
708
  }
709
+ return entries.map((e) => ({ severity: e.severity, slugs: [...e.slugs] }));
710
+ }
711
+
712
+ // Every chapter slug the CHANGELOG records as changed after `pin`, flattened
713
+ // (severity dropped) — kept for callers that only need the slug set.
714
+ function changelogSlugs(changelog, pin) {
715
+ const slugs = new Set();
716
+ for (const e of changelogEntries(changelog, pin)) for (const s of e.slugs) slugs.add(s);
689
717
  return [...slugs];
690
718
  }
691
719
 
@@ -706,11 +734,22 @@ function versionDrift() {
706
734
  if (pin >= current) return { pin, current, rows: [] };
707
735
 
708
736
  const changelog = read("CHANGELOG.md", STANDARD) || "";
737
+ // Worst-case severity per slug: one breaking touch marks it breaking for
738
+ // good, even if a later (or earlier) entry touching the same slug since
739
+ // the pin was non-breaking.
740
+ const severityBySlug = new Map();
741
+ for (const entry of changelogEntries(changelog, pin)) {
742
+ for (const slug of entry.slugs) {
743
+ if (severityBySlug.get(slug) !== "breaking") severityBySlug.set(slug, entry.severity);
744
+ }
745
+ }
709
746
  return {
710
747
  pin,
711
748
  current,
712
- rows: changelogSlugs(changelog, pin).map(
713
- (s) => `MANUAL: re-check #${s} — changed since the pinned ${pin}`,
749
+ rows: [...severityBySlug.entries()].map(([slug, severity]) =>
750
+ severity === "breaking"
751
+ ? `BREAKING: re-check #${slug} — changed since the pinned ${pin}`
752
+ : `MANUAL: re-check #${slug} — changed since the pinned ${pin}`,
714
753
  ),
715
754
  };
716
755
  }
@@ -741,10 +780,11 @@ function runCompliance() {
741
780
 
742
781
  function summarize({ results, drift }) {
743
782
  const n = (s) => results.filter((r) => r.status === s).length;
783
+ const breakingDrift = drift.rows.filter((r) => r.startsWith("BREAKING:")).length;
744
784
  return {
745
785
  pass: n(PASS),
746
- fail: n(FAIL),
747
- manual: n(MANUAL) + drift.rows.length,
786
+ fail: n(FAIL) + breakingDrift,
787
+ manual: n(MANUAL) + (drift.rows.length - breakingDrift),
748
788
  na: n(NA),
749
789
  };
750
790
  }
@@ -783,7 +823,7 @@ function main() {
783
823
  writeCache({ ...payload, summary: s });
784
824
 
785
825
  if (strict && s.fail) {
786
- console.error("compliance --strict: FAIL chapters present.");
826
+ console.error("compliance --strict: FAIL chapters or BREAKING standard-version drift present.");
787
827
  process.exit(1);
788
828
  }
789
829
  }
@@ -792,4 +832,4 @@ if (require.main === module) main();
792
832
 
793
833
  // CHECKS is exported for the "coverage" test — nothing else should read it as
794
834
  // data (call runCompliance() for results).
795
- module.exports = { runCompliance, summarize, changelogSlugs, CHECKS };
835
+ module.exports = { runCompliance, summarize, changelogSlugs, changelogEntries, 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,7 @@
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
25
  const { asRegexMap } = require("./header-expect.js");
26
26
 
27
27
  const CACHE = path.join(ROOT, ".cache", "doctor.json");
@@ -376,6 +376,26 @@ function main() {
376
376
  }
377
377
 
378
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
+
379
399
  const cr = complianceRegressions();
380
400
  if (!cr.checked) {
381
401
  console.log(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@piercebarney/whs-eleventy",
3
- "version": "2026.9.2",
3
+ "version": "2026.9.8",
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"
@@ -36,8 +36,8 @@
36
36
  "sirv": "^3.0.2"
37
37
  },
38
38
  "devDependencies": {
39
- "@eslint/js": "^9.39.5",
40
- "eslint": "^9.39.5",
39
+ "@eslint/js": "^10.0.1",
40
+ "eslint": "^10.9.1",
41
41
  "globals": "^17.11.0",
42
42
  "prettier": "^3.9.6"
43
43
  }