@casomoltd/tooling 0.28.0 → 0.30.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 under `privateRefs` in `casomo.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 under `personalData` in `casomo.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 under `personalData` in its `casomo.config.mjs`:
20
+ *
21
+ * export default {
22
+ * personalData: {
23
+ * allowEmail: [/@example-org\.com$/i], // addresses that are not people
24
+ * rules: [ // repo-specific identifiers
25
+ * {name: "case reference", re: /\bCASE-\d{4,}\b/g,
26
+ * allow: (m) => m === "CASE-0000"},
27
+ * ],
28
+ * denylist: ".personal-data-denylist", // the default; gitignore it
29
+ * allow: [/\.test\.mjs$/], // paths exempted, with care
30
+ * },
31
+ * };
32
+ *
33
+ * Usage:
34
+ * check-personal-data # tracked and untracked-but-not-ignored
35
+ * check-personal-data --staged # staged only
36
+ */
37
+ import {execFileSync} from "node:child_process";
38
+ import {existsSync, readFileSync, realpathSync, statSync} from "node:fs";
39
+ import {pathToFileURL} from "node:url";
40
+ import {CASOMO_CONFIG_KEYS, loadCasomoConfig} from "./utils.mjs";
41
+
42
+ /** Addresses in every repo's own tooling that name no person. */
43
+ const ALLOWED_EMAIL = [
44
+ /@example\.(com|org|net)$/i,
45
+ /@users\.noreply\.github\.com$/i,
46
+ ];
47
+
48
+ /* Files whose content looks like data but never describes a person:
49
+ * lockfiles, generated bundles, fonts, images. */
50
+ const SKIP_DIRS = new Set(["node_modules", ".next", "dist", "coverage", "out"]);
51
+ const SKIP_EXTS = new Set([
52
+ "lock", "woff", "woff2", "ttf", "otf", "png", "jpg", "jpeg", "gif", "webp",
53
+ "ico", "pdf", "zip", "map",
54
+ ]);
55
+
56
+ function skip(file) {
57
+ if (file.split("/").some((part) => SKIP_DIRS.has(part))) return true;
58
+ if (file.endsWith("package-lock.json")) return true;
59
+ const ext = file.slice(file.lastIndexOf(".") + 1).toLowerCase();
60
+ return SKIP_EXTS.has(ext);
61
+ }
62
+
63
+ /** The field names a data shape puts between a label and its value. */
64
+ const FIELD_SUFFIX = /(?:value|val|text|node|display|content)$/;
65
+
66
+ /** A key that names a birth. */
67
+ const BIRTH_KEY = /(?:dob|dateofbirth|birthdate|born)$/;
68
+
69
+ /** The constructor a source file writes a date with. `new Date(1983, 6, 14)`
70
+ * matches neither the printed nor the ISO rule, and the words before it end
71
+ * `newdate` rather than a field name, so both halves would be blind at once
72
+ * without this. */
73
+ const DATE_CALL = /(?:new\s*)?date\($/;
74
+
75
+ /**
76
+ * Whether the words before a date name a birth.
77
+ *
78
+ * A rendered page puts label and value side by side, so the key is last. A
79
+ * data file nests: `{label: "Date of birth", value: {value: "..."}}` gives a
80
+ * lead-in ending `dateofbirthvaluevalue`. The suffixes come off one at a time
81
+ * rather than as a starred alternation, which backtracks super-linearly
82
+ * because `val` is a prefix of `value`; a bounded loop is linear.
83
+ */
84
+ function namesABirth(lead) {
85
+ let rest = lead;
86
+ for (let depth = 0; depth < 4; depth += 1) {
87
+ if (BIRTH_KEY.test(rest)) return true;
88
+ const trimmed = rest.replace(FIELD_SUFFIX, "");
89
+ if (trimmed === rest) return false;
90
+ rest = trimmed;
91
+ }
92
+ return BIRTH_KEY.test(rest);
93
+ }
94
+
95
+ const NON_WORD = /[^a-z0-9]/gi;
96
+
97
+ /** What precedes a date, as a reader sees it: tags dropped, a generous
98
+ * window, because a label and its value are separated by a cell boundary
99
+ * and whatever styling that cell carries. */
100
+ const leadIn = (before) =>
101
+ before.replace(/<[^<>]*>/g, " ").slice(-120).replace(NON_WORD, "")
102
+ .toLowerCase();
103
+
104
+ /** A real address ends in a letters-only top-level domain; without this a
105
+ * package spec such as `postcss@8.4.31` reads as one. */
106
+ const TLD = /\.[A-Za-z]{2,}$/;
107
+
108
+ /** The identity rules every repo gets. */
109
+ export function identityRules(allowEmail = []) {
110
+ const allowed = [...ALLOWED_EMAIL, ...allowEmail];
111
+ return [
112
+ {
113
+ name: "email address",
114
+ // Labels joined by dots, not one class containing the dot: letting `.`
115
+ // sit in the class too makes the parts overlap and backtrack.
116
+ re: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+\b/g,
117
+ allow: (m) => allowed.some((a) => a.test(m)) || !TLD.test(m),
118
+ },
119
+ {
120
+ name: "National Insurance number",
121
+ re: /\b[A-CEGHJ-PR-TW-Z]{2}\s?\d{2}\s?\d{2}\s?\d{2}\s?[A-D]\b/g,
122
+ },
123
+ {
124
+ name: "UK mobile number",
125
+ re: /\b(?:\+44\s?7\d{3}|\(?07\d{3}\)?)\s?\d{3}\s?\d{3}\b/g,
126
+ },
127
+ {
128
+ name: "date of birth",
129
+ // Every ISO date, then the words before it decide: a bare date is a
130
+ // rule's date far more often than a person's.
131
+ re: /\b\d{4}[-/]\d{1,2}[-/]\d{1,2}\b/g,
132
+ // 1 January is the placeholder a redacted fixture writes; flagging it
133
+ // trains everyone to ignore the check.
134
+ allow: (m) => /[-/]0?1[-/]0?1$/.test(m),
135
+ context: (before) => namesABirth(leadIn(before)),
136
+ },
137
+ {
138
+ name: "date of birth",
139
+ // The printed form, "DD Month YYYY", which the ISO rule cannot see.
140
+ 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,
141
+ allow: (m) => /^0?1\s+January\s/.test(m),
142
+ context: (before) => namesABirth(leadIn(before)),
143
+ },
144
+ {
145
+ name: "date of birth",
146
+ // The constructed form. Month is zero-based, so the placeholder is
147
+ // month 0, day 1. The key is whatever the constant is called.
148
+ re: /\bnew\s+Date\(\s*(?:19|20)\d{2}\s*,\s*\d{1,2}\s*,\s*\d{1,2}\s*\)/g,
149
+ allow: (m) => /,\s*0\s*,\s*0?1\s*\)$/.test(m),
150
+ context: (before) => namesABirth(leadIn(before))
151
+ || BIRTH_KEY.test(leadIn(before).replace(DATE_CALL, "")),
152
+ },
153
+ ];
154
+ }
155
+
156
+ /** Every finding in one file's text, against the rules and the names. */
157
+ export function findPersonalData(text, rules, names = []) {
158
+ const hits = [];
159
+ const lines = text.split("\n");
160
+ for (const rule of rules) {
161
+ for (const [i, line] of lines.entries()) {
162
+ for (const m of line.matchAll(rule.re)) {
163
+ if (rule.allow?.(m[0])) continue;
164
+ if (rule.context && !rule.context(line.slice(0, m.index))) continue;
165
+ hits.push({line: i + 1, rule: rule.name, text: m[0]});
166
+ }
167
+ }
168
+ }
169
+ for (const name of names) {
170
+ // No `g`: `.test()` on a global regex advances `lastIndex`, and a name
171
+ // on consecutive lines would be reported on every other one.
172
+ const re = new RegExp(
173
+ `\\b${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i");
174
+ for (const [i, line] of lines.entries()) {
175
+ if (re.test(line)) {
176
+ hits.push({line: i + 1, rule: "name on the local denylist", text: name});
177
+ }
178
+ }
179
+ }
180
+ return hits;
181
+ }
182
+
183
+ /* Tracked AND untracked-but-not-ignored: the moment this exists for is the
184
+ * first commit of a new directory, and a tracked-only listing is blind to
185
+ * exactly that. Ignored files are left out on purpose; they are the working
186
+ * directory, where a person's data is supposed to live. */
187
+ function files(stagedOnly) {
188
+ const args = stagedOnly
189
+ ? ["diff", "--cached", "--name-only", "--diff-filter=ACMR"]
190
+ : ["ls-files", "--cached", "--others", "--exclude-standard"];
191
+ return execFileSync("git", args, {encoding: "utf8"})
192
+ .split("\n").filter((f) => f && !skip(f) && existsSync(f)
193
+ && statSync(f).isFile());
194
+ }
195
+
196
+ function denied(path) {
197
+ if (!existsSync(path)) return [];
198
+ return readFileSync(path, "utf8").split("\n")
199
+ .map((l) => l.trim()).filter((l) => l && !l.startsWith("#"));
200
+ }
201
+
202
+ /** Every finding across the files, and every file that could not be read,
203
+ * with the reason. `read` is the reader, so an unreadable file can be
204
+ * tested without making one. */
205
+ export function scan(fileList, read, rules, names) {
206
+ const hits = [];
207
+ const unread = [];
208
+ for (const file of fileList) {
209
+ let text;
210
+ try {
211
+ text = read(file);
212
+ } catch (error) {
213
+ unread.push({file, code: error?.code ?? String(error)});
214
+ continue;
215
+ }
216
+ for (const h of findPersonalData(text, rules, names)) hits.push({file, ...h});
217
+ }
218
+ return {hits, unread};
219
+ }
220
+
221
+ async function main() {
222
+ const config = await loadCasomoConfig(CASOMO_CONFIG_KEYS.personalData);
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();
@@ -21,21 +21,24 @@
21
21
  * a public package, so it cannot itself name anybody's private repos,
22
22
  * boards or hosts — that would be the disclosure it exists to prevent.
23
23
  * The defaults cover hosted trackers that are private by default; a
24
- * consuming repo adds its own names in `check-private-refs.config.mjs`:
24
+ * consuming repo adds its own names under `privateRefs` in its
25
+ * `casomo.config.mjs`:
25
26
  *
26
27
  * export default {
27
- * visibility: 'public', // optional; skips the gh lookup
28
- * forbidden: [ // strings or RegExp
29
- * {pattern: /internal\.example\.net/, why: 'internal hostname'},
30
- * ],
31
- * allow: [/CHANGELOG\.md$/], // paths exempted, with care
28
+ * privateRefs: {
29
+ * visibility: 'public', // optional; skips the gh lookup
30
+ * forbidden: [ // strings or RegExp
31
+ * {pattern: /internal\.example\.net/, why: 'internal hostname'},
32
+ * ],
33
+ * allow: [/CHANGELOG\.md$/], // paths exempted, with care
34
+ * },
32
35
  * };
33
36
  */
34
37
 
35
38
  import {execFileSync} from "node:child_process";
36
39
  import fs from "node:fs";
37
40
  import path from "node:path";
38
- import {loadConfig} from "./utils.mjs";
41
+ import {CASOMO_CONFIG_KEYS, loadCasomoConfig} from "./utils.mjs";
39
42
 
40
43
  /**
41
44
  * Hosted trackers whose links are private by default.
@@ -95,11 +98,7 @@ function toMatchers(entries) {
95
98
  }
96
99
 
97
100
  async function main() {
98
- const mod = await loadConfig("check-private-refs.config.mjs");
99
- // `loadConfig` returns the MODULE, so the config is its default
100
- // export. Reading the module object itself silently found no
101
- // patterns and reported a clean repo.
102
- const config = mod?.default ?? {};
101
+ const config = await loadCasomoConfig(CASOMO_CONFIG_KEYS.privateRefs);
103
102
  const visibility = repoVisibility(config.visibility);
104
103
 
105
104
  if (visibility === null) {
@@ -109,7 +108,7 @@ async function main() {
109
108
  console.warn(
110
109
  "check-private-refs: SKIPPED — could not determine repo "
111
110
  + "visibility (is `gh` installed and authenticated?). Set "
112
- + "`visibility` in check-private-refs.config.mjs to be explicit.",
111
+ + "`privateRefs.visibility` in casomo.config.mjs to be explicit.",
113
112
  );
114
113
  return;
115
114
  }