@casomoltd/tooling 0.28.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,6 +28,10 @@ Shared linting, formatting, commit config, and CLI tools for Casomo Ltd's repos.
28
28
  | `readability` | Measure reading difficulty of page content |
29
29
  | `screenshot` | Capture a dev server page via Playwright — an agent's visual feedback loop |
30
30
  | `build-report` | Compile a Typst client report to PDF with the house template |
31
+ | `render-figures` | Pre-render a design spec's mermaid figures to static SVG |
32
+ | `spec-check` | Check a design spec's anchors, contents, figures and numbering |
33
+ | `check-private-refs` | In a public repo, refuse a private tracker link or a name listed in `check-private-refs.config.mjs` |
34
+ | `check-personal-data` | Refuse an email, National Insurance number, UK mobile number, date of birth or locally denylisted name; per-repo allowances in `check-personal-data.config.mjs` |
31
35
 
32
36
  ## Install
33
37
 
@@ -39,7 +43,7 @@ npm i -D @casomoltd/tooling
39
43
  ```
40
44
 
41
45
  ```json
42
- "@casomoltd/tooling": "^0.20.0"
46
+ "@casomoltd/tooling": "^0.28.0"
43
47
  ```
44
48
 
45
49
  The package is public, so installs need no token or `.npmrc`. The `files`
@@ -52,10 +56,10 @@ declared here — see [External prerequisites](#external-prerequisites).
52
56
 
53
57
  ## External prerequisites
54
58
 
55
- Two bins need something npm cannot sensibly deliver: `screenshot` needs
56
- a Chromium build, `build-report` needs the `typst` CLI and the IBM Plex
57
- fonts. **Neither is declared — not as a dependency, and not as an
58
- optional peer.** Both load their prerequisite lazily and fail with an
59
+ Three bins need something npm cannot sensibly deliver: `screenshot` and
60
+ `render-figures` need a Chromium build through Playwright, and
61
+ `build-report` needs the `typst` CLI and the IBM Plex fonts. **None is
62
+ declared — not as a dependency, and not as an optional peer.** Each loads its prerequisite lazily and fails with an
59
63
  install instruction when it is missing, so a consumer that never runs
60
64
  those bins carries nothing for them.
61
65
 
@@ -98,6 +102,44 @@ repo needs (lint, typecheck, spell, etc.).
98
102
  hooks (e.g. `./bin/check-version.sh`) because it can't resolve
99
103
  its own bin commands via `node_modules/.bin`.
100
104
 
105
+ ### A repo whose toolchain is not npm
106
+
107
+ A Python, Go or C++ repo still gets the commit rules. It carries a **dev-only
108
+ npm layer** that exists to host the hook and nothing else:
109
+
110
+ ```json
111
+ {
112
+ "name": "<repo>-hooks",
113
+ "private": true,
114
+ "description": "Dev-only npm layer: wires the shared commit-msg gate into this repo. Not the project toolchain.",
115
+ "scripts": { "prepare": "husky" },
116
+ "devDependencies": {
117
+ "@casomoltd/tooling": "^0.28.0",
118
+ "@commitlint/cli": "^20.0.0",
119
+ "husky": "^9.1.7"
120
+ }
121
+ }
122
+ ```
123
+
124
+ Plus `commitlint.config.cjs` re-exporting the rules, and `.husky/commit-msg`.
125
+ No `version`, because the repo versions itself with its own tool. Live
126
+ examples: `kallim`, `paperpi`.
127
+
128
+ **Do not wire `pre-commit` or `pre-push` from this package in such a repo.**
129
+ Both run `npm run check`, and `check-gates` reads `package.json` and requires
130
+ eight named npm scripts — a gate a non-npm repo cannot satisfy and should not
131
+ fake. Write a `.husky/pre-push` that calls the real toolchain instead:
132
+
133
+ ```sh
134
+ uv run ruff check && uv run ruff format --check && uv run pyright && uv run pytest
135
+ ```
136
+
137
+ **Why this is documented here.** Undocumented, each non-npm repo solves it
138
+ alone and writes its own shell or Python copy of `no-ai-attribution`, and the
139
+ copies drift — the length limits are what they lose first. One rule, one home.
140
+ If a house gate will not run in your repo, wire the shared one or fix this
141
+ package. Do not write a local equivalent.
142
+
101
143
  ## Markdown / skills lint
102
144
 
103
145
  `bin/skills-lint.config.mjs` is a stock remark config (`remark-frontmatter` +
@@ -168,6 +210,21 @@ Add these scripts to your `package.json`:
168
210
  Screenshots are saved to `.claude/screenshots/`. Set
169
211
  `SCREENSHOT_URL` to override the default `http://localhost:3000`.
170
212
 
213
+ ## Design specs
214
+
215
+ `spec-check` reads one HTML design spec and reports its mechanical
216
+ faults: an anchor resolving to nothing, a contents entry disagreeing
217
+ with its heading, a figure without a number, an anchor or a caption, a
218
+ figure wider than the card it sits in, a section number typed where the
219
+ stylesheet generates one, and markup the artifact host refuses.
220
+
221
+ `render-figures` rewrites a spec's mermaid figures as static SVG, so the
222
+ page carries no runtime. Each figure keeps its source in an HTML comment
223
+ above it, and a run reads those back, renders them and freezes the
224
+ computed paint inline. It resolves Playwright from the working directory;
225
+ set `RENDER_FIGURES_RESOLVE_FROM` to a project that has it when running
226
+ from somewhere else.
227
+
171
228
  ## Client reports (Typst)
172
229
 
173
230
  `report/` ships the Casomo house style for client-facing PDF reports —
@@ -182,13 +239,13 @@ npx build-report report.typ --watch # recompile on save
182
239
  npx build-report report.typ --open # open the PDF when done
183
240
  ```
184
241
 
185
- The compiled PDF is a build artefact, so it defaults to the same scratch
186
- resolution as the agent reports (see *Report output & `SCRATCH_DIR`*
187
- under Agents): `$SCRATCH_DIR/reports/<name>.pdf`, or
188
- `<repo-root>/scratch/reports/` when `SCRATCH_DIR` is unset — add
189
- `scratch/` to the consumer repo's `.gitignore` (this repo does). Pass
190
- `--out` only when the PDF is a deliverable the repo actually keeps
191
- (e.g. a site's `public/`).
242
+ The compiled PDF is a build artefact and goes to
243
+ `$SCRATCH_DIR/reports/<name>.pdf`, or `<repo-root>/scratch/reports/` when
244
+ **`SCRATCH_DIR`** is unset — add `scratch/` to the consumer repo's
245
+ `.gitignore` (this repo does). A PDF is a file to open rather than a page
246
+ to publish, so it keeps a scratch location where the x-ray reports need
247
+ none. Pass `--out` only when the PDF is a deliverable the repo actually
248
+ keeps (e.g. a site's `public/`).
192
249
 
193
250
  A report imports the template and applies it as a show rule; everything
194
251
  after is the body. From a consumer repo the import goes through
@@ -323,7 +380,7 @@ decision table) that keeps these units composing without overlap.
323
380
  | `/casomoltd:typescript` | TypeScript data modelling and type design |
324
381
  | `/casomoltd:screenshot` | Capture and analyse a dev server page |
325
382
  | `/casomoltd:design-pass` | Map → review → refactor a package (drives `design-xray` + `code-review`) |
326
- | `/casomoltd:draft-design-spec` | Author a browser-reviewable HTML design spec from a brief and iterate on it before writing code (drives `design-xray`) |
383
+ | `/casomoltd:draft-design-spec` | Author a reviewable HTML design spec from a brief, publish it as an Artifact, and iterate on it there before writing code (drives `design-xray`) |
327
384
  | `/casomoltd:page-design` | Structure a content/explainer page for trust — above-the-fold answer, disclosure, palette-only colour (rubric a page-design reviewer preloads) |
328
385
  | `/casomoltd:generate-report` | Scaffold a Typst client report from the house template, compile via `build-report`, verify the PDF |
329
386
 
@@ -409,14 +466,16 @@ where a justification would and otherwise survives an ordinary read-through. The
409
466
  `agents/*.md` changes to it. Not link validity (a markdown link linter), not the
410
467
  corpus reference graph (`docs-xray`), not prose voice (a content reviewer).
411
468
 
412
- **Report output & `SCRATCH_DIR`.** `design-xray` and `docs-xray` persist their
413
- report — the `.md` plus an `.html` rendered by `bin/render-report.mjs` — so it
414
- outlives the run and opens in a browser. Each writes to a gitignored
415
- `<repo-root>/scratch/<agent>/` by default. Set the optional **`SCRATCH_DIR`** env
416
- var to pool every report under one shared location instead (`$SCRATCH_DIR/<agent>/`)
417
- — e.g. a multi-repo workspace collecting reports in one place rather than scattering
418
- them per-repo. Publishing a report to a hosted claude.ai artifact is orchestrator-
419
- only and on explicit request; the agents never do it.
469
+ **Report output.** `design-xray` and `docs-xray` write their report — the `.md`
470
+ plus an `.html` rendered by `bin/render-report.mjs` — into a working directory
471
+ and return both paths, and the caller publishes the `.html`. Each agent's
472
+ *Persist the report* section is the spec for that hand-off.
473
+
474
+ The rendered `.html` is built for that destination: `render-report.mjs` converts
475
+ the markdown in node and ships **no mermaid runtime**, because the artifact host
476
+ renders every `pre.mermaid` itself and a second runtime races it into black
477
+ boxes. A page that builds its own body at load time has no diagram blocks in it
478
+ when the host looks.
420
479
 
421
480
  ## Package distribution
422
481
 
@@ -0,0 +1,84 @@
1
+ #!/bin/sh
2
+ # Is the house commit gate actually active in this repo?
3
+ #
4
+ # Answers one question, and answers it by *behaviour* rather than by presence.
5
+ # A repo can have a commit-msg hook that enforces some of the rules, or an
6
+ # older copy of them, and look wired while letting real violations through —
7
+ # that is exactly what happened: three repos each grew a hand-written copy of
8
+ # `no-ai-attribution`, two of them missing the length limits, and nothing
9
+ # noticed for months because every one of them *had* a hook.
10
+ #
11
+ # So this feeds the hook messages that must fail and checks that they do.
12
+ #
13
+ # check-hooks # this repo
14
+ # check-hooks ~/dev/* # every repo under a directory
15
+ #
16
+ # Exit 0 if every repo checked is gated, 1 otherwise. Language-neutral: sh and
17
+ # git only, like check-linear and check-tags.
18
+ set -e
19
+
20
+ status=0
21
+
22
+ check_one() {
23
+ repo=$1
24
+ name=$(basename "$repo")
25
+
26
+ if [ ! -d "$repo/.git" ]; then
27
+ return 0 # not a repo; silently skip so globs can be sloppy
28
+ fi
29
+
30
+ hooks=$(git -C "$repo" config --get core.hooksPath || echo ".git/hooks")
31
+ case "$hooks" in
32
+ /*) hook="$hooks/commit-msg" ;;
33
+ *) hook="$repo/$hooks/commit-msg" ;;
34
+ esac
35
+
36
+ if [ ! -f "$hook" ]; then
37
+ printf '%-24s NOT GATED (no commit-msg hook)\n' "$name"
38
+ status=1
39
+ return 0
40
+ fi
41
+
42
+ msg=$(mktemp)
43
+ failures=""
44
+
45
+ # Each probe is a message the house rules must reject. Keep the bodies
46
+ # single-line so a copy that only checks the header still fails the first.
47
+ probe() {
48
+ printf '%s' "$2" > "$msg"
49
+ if git -C "$repo" hook run --ignore-missing commit-msg -- "$msg" \
50
+ >/dev/null 2>&1; then
51
+ failures="$failures $1"
52
+ fi
53
+ }
54
+
55
+ probe attribution "Add a thing
56
+
57
+ Co-authored-by: Claude <noreply@anthropic.com>
58
+ "
59
+ probe header-length "Add a thing with a subject line that runs past the fifty character limit
60
+ "
61
+ probe body-length "Add a thing
62
+
63
+ This body line is deliberately longer than the seventy-two character limit.
64
+ "
65
+
66
+ rm -f "$msg"
67
+
68
+ if [ -n "$failures" ]; then
69
+ printf '%-24s PARTIAL (accepts:%s)\n' "$name" "$failures"
70
+ status=1
71
+ else
72
+ printf '%-24s gated\n' "$name"
73
+ fi
74
+ }
75
+
76
+ if [ $# -eq 0 ]; then
77
+ check_one "$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
78
+ else
79
+ for arg in "$@"; do
80
+ check_one "$arg"
81
+ done
82
+ fi
83
+
84
+ exit $status
@@ -0,0 +1,270 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Refuse to let a person's details reach a commit.
4
+ *
5
+ * A repo that works from real people's records keeps them in a working
6
+ * directory outside it, and they reach it the moment someone pastes a
7
+ * statement into a fixture or reuses a real figure as a "specimen". This
8
+ * finds what has a shape:
9
+ *
10
+ * - **Emails, National Insurance numbers, UK mobile numbers and dates of
11
+ * birth** are detectable by shape, and these rules find them.
12
+ * - **Names are not.** No pattern separates a person from a cited author, so
13
+ * names come from a local denylist that is itself never committed: a file
14
+ * listing the people would be the leak it exists to prevent. Without the
15
+ * file the other rules still run, and the summary says name checking is off.
16
+ *
17
+ * Deliberately configurable, because this file ships in a public package and
18
+ * cannot name anybody's domains or reference formats. A consuming repo adds
19
+ * its own in `check-personal-data.config.mjs`:
20
+ *
21
+ * export default {
22
+ * allowEmail: [/@example-org\.com$/i], // addresses that are not people
23
+ * rules: [ // repo-specific identifiers
24
+ * {name: "case reference", re: /\bCASE-\d{4,}\b/g,
25
+ * allow: (m) => m === "CASE-0000"},
26
+ * ],
27
+ * denylist: ".personal-data-denylist", // the default; gitignore it
28
+ * allow: [/\.test\.mjs$/], // paths exempted, with care
29
+ * };
30
+ *
31
+ * Usage:
32
+ * check-personal-data # tracked and untracked-but-not-ignored
33
+ * check-personal-data --staged # staged only
34
+ */
35
+ import {execFileSync} from "node:child_process";
36
+ import {existsSync, readFileSync, realpathSync, statSync} from "node:fs";
37
+ import {pathToFileURL} from "node:url";
38
+ import {loadConfig} from "./utils.mjs";
39
+
40
+ /** Addresses in every repo's own tooling that name no person. */
41
+ const ALLOWED_EMAIL = [
42
+ /@example\.(com|org|net)$/i,
43
+ /@users\.noreply\.github\.com$/i,
44
+ ];
45
+
46
+ /* Files whose content looks like data but never describes a person:
47
+ * lockfiles, generated bundles, fonts, images. */
48
+ const SKIP_DIRS = new Set(["node_modules", ".next", "dist", "coverage", "out"]);
49
+ const SKIP_EXTS = new Set([
50
+ "lock", "woff", "woff2", "ttf", "otf", "png", "jpg", "jpeg", "gif", "webp",
51
+ "ico", "pdf", "zip", "map",
52
+ ]);
53
+
54
+ function skip(file) {
55
+ if (file.split("/").some((part) => SKIP_DIRS.has(part))) return true;
56
+ if (file.endsWith("package-lock.json")) return true;
57
+ const ext = file.slice(file.lastIndexOf(".") + 1).toLowerCase();
58
+ return SKIP_EXTS.has(ext);
59
+ }
60
+
61
+ /** The field names a data shape puts between a label and its value. */
62
+ const FIELD_SUFFIX = /(?:value|val|text|node|display|content)$/;
63
+
64
+ /** A key that names a birth. */
65
+ const BIRTH_KEY = /(?:dob|dateofbirth|birthdate|born)$/;
66
+
67
+ /** The constructor a source file writes a date with. `new Date(1983, 6, 14)`
68
+ * matches neither the printed nor the ISO rule, and the words before it end
69
+ * `newdate` rather than a field name, so both halves would be blind at once
70
+ * without this. */
71
+ const DATE_CALL = /(?:new\s*)?date\($/;
72
+
73
+ /**
74
+ * Whether the words before a date name a birth.
75
+ *
76
+ * A rendered page puts label and value side by side, so the key is last. A
77
+ * data file nests: `{label: "Date of birth", value: {value: "..."}}` gives a
78
+ * lead-in ending `dateofbirthvaluevalue`. The suffixes come off one at a time
79
+ * rather than as a starred alternation, which backtracks super-linearly
80
+ * because `val` is a prefix of `value`; a bounded loop is linear.
81
+ */
82
+ function namesABirth(lead) {
83
+ let rest = lead;
84
+ for (let depth = 0; depth < 4; depth += 1) {
85
+ if (BIRTH_KEY.test(rest)) return true;
86
+ const trimmed = rest.replace(FIELD_SUFFIX, "");
87
+ if (trimmed === rest) return false;
88
+ rest = trimmed;
89
+ }
90
+ return BIRTH_KEY.test(rest);
91
+ }
92
+
93
+ const NON_WORD = /[^a-z0-9]/gi;
94
+
95
+ /** What precedes a date, as a reader sees it: tags dropped, a generous
96
+ * window, because a label and its value are separated by a cell boundary
97
+ * and whatever styling that cell carries. */
98
+ const leadIn = (before) =>
99
+ before.replace(/<[^<>]*>/g, " ").slice(-120).replace(NON_WORD, "")
100
+ .toLowerCase();
101
+
102
+ /** A real address ends in a letters-only top-level domain; without this a
103
+ * package spec such as `postcss@8.4.31` reads as one. */
104
+ const TLD = /\.[A-Za-z]{2,}$/;
105
+
106
+ /** The identity rules every repo gets. */
107
+ export function identityRules(allowEmail = []) {
108
+ const allowed = [...ALLOWED_EMAIL, ...allowEmail];
109
+ return [
110
+ {
111
+ name: "email address",
112
+ // Labels joined by dots, not one class containing the dot: letting `.`
113
+ // sit in the class too makes the parts overlap and backtrack.
114
+ re: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+\b/g,
115
+ allow: (m) => allowed.some((a) => a.test(m)) || !TLD.test(m),
116
+ },
117
+ {
118
+ name: "National Insurance number",
119
+ re: /\b[A-CEGHJ-PR-TW-Z]{2}\s?\d{2}\s?\d{2}\s?\d{2}\s?[A-D]\b/g,
120
+ },
121
+ {
122
+ name: "UK mobile number",
123
+ re: /\b(?:\+44\s?7\d{3}|\(?07\d{3}\)?)\s?\d{3}\s?\d{3}\b/g,
124
+ },
125
+ {
126
+ name: "date of birth",
127
+ // Every ISO date, then the words before it decide: a bare date is a
128
+ // rule's date far more often than a person's.
129
+ re: /\b\d{4}[-/]\d{1,2}[-/]\d{1,2}\b/g,
130
+ // 1 January is the placeholder a redacted fixture writes; flagging it
131
+ // trains everyone to ignore the check.
132
+ allow: (m) => /[-/]0?1[-/]0?1$/.test(m),
133
+ context: (before) => namesABirth(leadIn(before)),
134
+ },
135
+ {
136
+ name: "date of birth",
137
+ // The printed form, "DD Month YYYY", which the ISO rule cannot see.
138
+ re: /\b\d{1,2}\s+(?:January|February|March|April|May|June|July|August|September|October|November|December)\s+(?:19|20)\d{2}\b/g,
139
+ allow: (m) => /^0?1\s+January\s/.test(m),
140
+ context: (before) => namesABirth(leadIn(before)),
141
+ },
142
+ {
143
+ name: "date of birth",
144
+ // The constructed form. Month is zero-based, so the placeholder is
145
+ // month 0, day 1. The key is whatever the constant is called.
146
+ re: /\bnew\s+Date\(\s*(?:19|20)\d{2}\s*,\s*\d{1,2}\s*,\s*\d{1,2}\s*\)/g,
147
+ allow: (m) => /,\s*0\s*,\s*0?1\s*\)$/.test(m),
148
+ context: (before) => namesABirth(leadIn(before))
149
+ || BIRTH_KEY.test(leadIn(before).replace(DATE_CALL, "")),
150
+ },
151
+ ];
152
+ }
153
+
154
+ /** Every finding in one file's text, against the rules and the names. */
155
+ export function findPersonalData(text, rules, names = []) {
156
+ const hits = [];
157
+ const lines = text.split("\n");
158
+ for (const rule of rules) {
159
+ for (const [i, line] of lines.entries()) {
160
+ for (const m of line.matchAll(rule.re)) {
161
+ if (rule.allow?.(m[0])) continue;
162
+ if (rule.context && !rule.context(line.slice(0, m.index))) continue;
163
+ hits.push({line: i + 1, rule: rule.name, text: m[0]});
164
+ }
165
+ }
166
+ }
167
+ for (const name of names) {
168
+ // No `g`: `.test()` on a global regex advances `lastIndex`, and a name
169
+ // on consecutive lines would be reported on every other one.
170
+ const re = new RegExp(
171
+ `\\b${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i");
172
+ for (const [i, line] of lines.entries()) {
173
+ if (re.test(line)) {
174
+ hits.push({line: i + 1, rule: "name on the local denylist", text: name});
175
+ }
176
+ }
177
+ }
178
+ return hits;
179
+ }
180
+
181
+ /* Tracked AND untracked-but-not-ignored: the moment this exists for is the
182
+ * first commit of a new directory, and a tracked-only listing is blind to
183
+ * exactly that. Ignored files are left out on purpose; they are the working
184
+ * directory, where a person's data is supposed to live. */
185
+ function files(stagedOnly) {
186
+ const args = stagedOnly
187
+ ? ["diff", "--cached", "--name-only", "--diff-filter=ACMR"]
188
+ : ["ls-files", "--cached", "--others", "--exclude-standard"];
189
+ return execFileSync("git", args, {encoding: "utf8"})
190
+ .split("\n").filter((f) => f && !skip(f) && existsSync(f)
191
+ && statSync(f).isFile());
192
+ }
193
+
194
+ function denied(path) {
195
+ if (!existsSync(path)) return [];
196
+ return readFileSync(path, "utf8").split("\n")
197
+ .map((l) => l.trim()).filter((l) => l && !l.startsWith("#"));
198
+ }
199
+
200
+ /** Every finding across the files, and every file that could not be read,
201
+ * with the reason. `read` is the reader, so an unreadable file can be
202
+ * tested without making one. */
203
+ export function scan(fileList, read, rules, names) {
204
+ const hits = [];
205
+ const unread = [];
206
+ for (const file of fileList) {
207
+ let text;
208
+ try {
209
+ text = read(file);
210
+ } catch (error) {
211
+ unread.push({file, code: error?.code ?? String(error)});
212
+ continue;
213
+ }
214
+ for (const h of findPersonalData(text, rules, names)) hits.push({file, ...h});
215
+ }
216
+ return {hits, unread};
217
+ }
218
+
219
+ async function main() {
220
+ const mod = await loadConfig("check-personal-data.config.mjs");
221
+ // `loadConfig` returns the module, so the config is its default export.
222
+ const config = mod?.default ?? {};
223
+ const denylist = config.denylist ?? ".personal-data-denylist";
224
+ const rules = [...identityRules(config.allowEmail), ...(config.rules ?? [])];
225
+ const names = denied(denylist);
226
+ const allow = (config.allow ?? []).map((a) =>
227
+ a instanceof RegExp ? a : new RegExp(a));
228
+
229
+ const {hits, unread} = scan(
230
+ files(process.argv.includes("--staged"))
231
+ .filter((file) => !allow.some((a) => a.test(file))),
232
+ (file) => readFileSync(file, "utf8"), rules, names);
233
+
234
+ // A file this could not read is a file it did not check, so the run is
235
+ // incomplete rather than clean, whatever else it found.
236
+ for (const u of unread) console.error(` ${u.file}: could not be read (${u.code})`);
237
+ if (unread.length && !hits.length) {
238
+ console.error(`\ncheck-personal-data: INCOMPLETE — ${unread.length} file(s) `
239
+ + "were not checked.\n");
240
+ process.exit(1);
241
+ }
242
+
243
+ if (hits.length) {
244
+ console.error("\nPersonal data must not be committed.\n");
245
+ for (const h of hits) console.error(` ${h.file}:${h.line} ${h.rule}: ${h.text}`);
246
+ console.error(
247
+ `\n${hits.length} finding(s). Move the data to a working directory `
248
+ + "outside every repository, or replace it with values that could not "
249
+ + "be anyone's.\n");
250
+ process.exit(1);
251
+ }
252
+
253
+ console.log(
254
+ "check-personal-data: clean"
255
+ + (names.length ? ` (${names.length} name(s) on the local denylist)`
256
+ : ` (no ${denylist}; name checking off)`)
257
+ );
258
+ }
259
+
260
+ /** True only when this file is the entry point. npm installs a bin as a
261
+ * symlink, so compare the resolved path, or `main` never runs in a consumer. */
262
+ const isEntryPoint = () => {
263
+ try {
264
+ return import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
265
+ } catch {
266
+ return false;
267
+ }
268
+ };
269
+
270
+ if (isEntryPoint()) main();
@@ -0,0 +1,159 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Re-render a spec's mermaid figures to static inline SVG.
4
+ *
5
+ * The spec embeds rendered SVG rather than a mermaid runtime, because the
6
+ * artifact viewer ships its own runtime that claims every `pre.mermaid` and
7
+ * renames the svg it produces. Mermaid scopes a diagram's fills to the svg's
8
+ * own id, so a rename leaves those rules matching nothing and every shape
9
+ * falls back to the default fill, which is black. Embedding removes both the
10
+ * race and the id dependency.
11
+ *
12
+ * Each figure keeps its source in an HTML comment directly above it. This
13
+ * reads those back, renders them, freezes the computed paint inline, and
14
+ * writes the file in place.
15
+ *
16
+ * node bin/render-figures.mjs <spec.html>
17
+ */
18
+ import { readFileSync, writeFileSync } from 'node:fs';
19
+ import { createRequire } from 'node:module';
20
+ import { resolve } from 'node:path';
21
+ import { FIGURE_WIDTH_BUDGET } from './utils.mjs';
22
+
23
+ /** Playwright is a heavy prerequisite this package deliberately does not
24
+ * declare, because a declared optional peer resolves into every consumer's
25
+ * lockfile. So it is resolved from the calling project rather than from
26
+ * here, and RENDER_FIGURES_RESOLVE_FROM names that project when the working
27
+ * directory is not it. */
28
+ const from = resolve(process.env.RENDER_FIGURES_RESOLVE_FROM || process.cwd());
29
+ let chromium;
30
+ try {
31
+ ({ chromium } = createRequire(`${from}/`)('playwright'));
32
+ } catch {
33
+ throw new Error('playwright is not installed — add it as a devDependency'
34
+ + ' (npm i -D playwright && npx playwright install chromium) in the project'
35
+ + ` you are rendering from, or point RENDER_FIGURES_RESOLVE_FROM at one`
36
+ + ` that has it. Looked in ${from}.`);
37
+ }
38
+
39
+ /** The marker above each figure, as one producer: it is both searched for
40
+ * and written back, so the two must be the same string. */
41
+ const MARKER = ' <!-- figure source, regenerate with bin/render-figures.mjs:';
42
+ const SOURCE = new RegExp(`${MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`
43
+ + '\n([\\s\\S]*?)\n -->\n <div class="figure-card">[\\s\\S]*?<\\/div>\n', 'g');
44
+
45
+ /** Written even when they compute to `none`: an arrowhead that silently gains
46
+ * a fill is the same defect as a box that silently loses one. */
47
+ const PAINT = ['fill', 'stroke', 'stroke-width', 'stroke-dasharray', 'color'];
48
+
49
+ const file = process.argv[2];
50
+ if (!file) throw new Error('usage: render-figures.mjs <spec.html>');
51
+
52
+ const html = readFileSync(file, 'utf8');
53
+ // An HTML comment ends at the first `-->`, so a mermaid arrow written whole
54
+ // would close the comment and truncate the source. The spec writes `- ->`
55
+ // and this puts it back.
56
+ const sources = [...html.matchAll(SOURCE)].map((m) => m[1].replaceAll('- -', '--'));
57
+ // Counted against the figures rather than against zero: one malformed marker
58
+ // leaves that figure holding a stale SVG while the run still reports success.
59
+ const figures = [...html.matchAll(/<div\b[^>]*\bid="fig-\d+"/g)].length;
60
+ if (sources.length !== figures) {
61
+ throw new Error(`${figures} figure(s) in ${file} but ${sources.length} source`
62
+ + ' comment(s); a figure marker is malformed or missing');
63
+ }
64
+
65
+ /** A multi-line node label is what overflows a box: mermaid sizes a
66
+ * foreignObject from the first line and the rest spills below it. The rubric
67
+ * already says keep labels short and put the detail in the legend (M10); this
68
+ * is that rule where it can actually be measured. */
69
+ for (const [i, src] of sources.entries()) {
70
+ const multi = [...src.matchAll(/\[".*?<br\s*\/?>.*?"\]/g)].length;
71
+ if (multi) {
72
+ console.warn(`figure ${i + 1}: ${multi} node label(s) span more than one`
73
+ + ' line and will overflow their box — shorten them, per diagram-rubric M10');
74
+ }
75
+ }
76
+
77
+ const browser = await chromium.launch();
78
+ const page = await browser.newPage({ viewport: { width: 1400, height: 1200 } });
79
+ // The font must be LOADED, not just named: mermaid measures every label to
80
+ // size its box, so rendering against a fallback and publishing against IBM
81
+ // Plex Sans puts wider text into boxes cut for narrower text. Every figure
82
+ // overflows slightly, and it looks like a mermaid bug rather than a missing
83
+ // stylesheet.
84
+ const FONT = 'https://fonts.googleapis.com/css2?family=IBM+Plex+Sans'
85
+ + ':ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap';
86
+ const MERMAID = 'https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js';
87
+ const STACK = '"IBM Plex Sans", ui-sans-serif, system-ui, sans-serif';
88
+ await page.setContent(`<!doctype html><html><head>
89
+ <link rel="stylesheet" href="${FONT}">
90
+ <style>body { font-family: ${STACK}; }</style>
91
+ <script src="${MERMAID}"><\/script>
92
+ </head><body>${sources.map((s) => {
93
+ const escaped = s.replaceAll('&', '&amp;').replaceAll('<', '&lt;');
94
+ return `<pre class="m">${escaped}</pre>`;
95
+ }).join('')}</body></html>`);
96
+ await page.waitForFunction(() => typeof window.mermaid !== 'undefined');
97
+ await page.evaluate(() => document.fonts.load('600 15px "IBM Plex Sans"')
98
+ .then(() => document.fonts.ready));
99
+
100
+ const rendered = await page.evaluate(async (paint) => {
101
+ window.mermaid.initialize({
102
+ startOnLoad: false, theme: 'neutral', securityLevel: 'loose',
103
+ themeVariables: {
104
+ fontSize: '15px',
105
+ fontFamily: '"IBM Plex Sans", ui-sans-serif, system-ui, sans-serif',
106
+ textColor: '#1f2530', primaryTextColor: '#1f2530', nodeTextColor: '#1f2530',
107
+ titleColor: '#1f2530', clusterBkg: '#f7f8fa', clusterBorder: '#b9c0cb',
108
+ lineColor: '#5b6472',
109
+ },
110
+ flowchart: { htmlLabels: true, useMaxWidth: false },
111
+ class: { useMaxWidth: false },
112
+ });
113
+ const out = [];
114
+ const nodes = [...document.querySelectorAll('pre.m')];
115
+ for (const [i, el] of nodes.entries()) {
116
+ const { svg } = await window.mermaid.render(`fig-svg-${i}`, el.textContent);
117
+ const holder = document.createElement('div');
118
+ holder.innerHTML = svg;
119
+ document.body.appendChild(holder);
120
+ const node = holder.querySelector('svg');
121
+ node.querySelectorAll('*').forEach((child) => {
122
+ const cs = getComputedStyle(child);
123
+ paint.forEach((prop) => {
124
+ const v = cs.getPropertyValue(prop);
125
+ if (v) child.style.setProperty(prop, v);
126
+ });
127
+ });
128
+ // Mermaid emits only the deprecated xlink:href on a click-through link.
129
+ // Every current browser still honours it, but SVG2 names the attribute
130
+ // `href`, so write both and let the newer one win.
131
+ node.querySelectorAll('a[*|href]').forEach((a) => {
132
+ const x = a.getAttributeNS('http://www.w3.org/1999/xlink', 'href');
133
+ if (x && !a.getAttribute('href')) a.setAttribute('href', x);
134
+ });
135
+ node.setAttribute('style', 'max-width:100%;height:auto');
136
+ out.push({ svg: node.outerHTML, viewBox: node.getAttribute('viewBox') });
137
+ }
138
+ return out;
139
+ }, PAINT);
140
+ await browser.close();
141
+
142
+ let n = 0;
143
+ const next = html.replace(SOURCE, (whole, src) => {
144
+ const { svg } = rendered[n++];
145
+ return `${MARKER}\n${src}\n -->\n <div class="figure-card">${svg}</div>\n`;
146
+ });
147
+ writeFileSync(file, next);
148
+
149
+ // Producing a measurable figure is this script's job, so one it cannot
150
+ // measure is a failure rather than a figure reported as zero pixels wide.
151
+ rendered.forEach(({ viewBox }, i) => {
152
+ const parsed = /^[-\d.]+\s+[-\d.]+\s+([\d.]+)/.exec(viewBox || '');
153
+ if (!parsed) throw new Error(`figure ${i + 1} rendered without a usable viewBox`);
154
+ const w = Math.round(Number(parsed[1]));
155
+ const over = w > FIGURE_WIDTH_BUDGET
156
+ ? ` ** OVER the ${FIGURE_WIDTH_BUDGET}px budget **` : '';
157
+ console.log(`figure ${i + 1}: ${w}px${over}`);
158
+ });
159
+ console.log(`wrote ${n} figures to ${file}`);