@casomoltd/tooling 0.27.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
@@ -21,12 +21,17 @@ Shared linting, formatting, commit config, and CLI tools for Casomo Ltd's repos.
21
21
  | Command | Description |
22
22
  |---|---|
23
23
  | `check-version` | Pre-push guard — rejects push if `package.json` version hasn't changed vs `origin/main` |
24
- | `pre-push` | Husky pre-push hook — runs check, build, then version/tag guards |
24
+ | `check-linear` | Pre-push guard — rejects a push that would put a merge commit on the remote. History is linear; a branch catches up by rebasing. Only what the push adds is judged, so merges already on the remote stay |
25
+ | `pre-push` | Husky pre-push hook — runs check, then the version, tag and linear-history guards |
25
26
  | `pre-commit` | Husky pre-commit hook — runs `npm run check` |
26
27
  | `commit-msg` | Husky commit-msg hook — runs commitlint |
27
28
  | `readability` | Measure reading difficulty of page content |
28
29
  | `screenshot` | Capture a dev server page via Playwright — an agent's visual feedback loop |
29
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` |
30
35
 
31
36
  ## Install
32
37
 
@@ -38,7 +43,7 @@ npm i -D @casomoltd/tooling
38
43
  ```
39
44
 
40
45
  ```json
41
- "@casomoltd/tooling": "^0.20.0"
46
+ "@casomoltd/tooling": "^0.28.0"
42
47
  ```
43
48
 
44
49
  The package is public, so installs need no token or `.npmrc`. The `files`
@@ -51,10 +56,10 @@ declared here — see [External prerequisites](#external-prerequisites).
51
56
 
52
57
  ## External prerequisites
53
58
 
54
- Two bins need something npm cannot sensibly deliver: `screenshot` needs
55
- a Chromium build, `build-report` needs the `typst` CLI and the IBM Plex
56
- fonts. **Neither is declared — not as a dependency, and not as an
57
- 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
58
63
  install instruction when it is missing, so a consumer that never runs
59
64
  those bins carries nothing for them.
60
65
 
@@ -97,6 +102,44 @@ repo needs (lint, typecheck, spell, etc.).
97
102
  hooks (e.g. `./bin/check-version.sh`) because it can't resolve
98
103
  its own bin commands via `node_modules/.bin`.
99
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
+
100
143
  ## Markdown / skills lint
101
144
 
102
145
  `bin/skills-lint.config.mjs` is a stock remark config (`remark-frontmatter` +
@@ -167,6 +210,21 @@ Add these scripts to your `package.json`:
167
210
  Screenshots are saved to `.claude/screenshots/`. Set
168
211
  `SCREENSHOT_URL` to override the default `http://localhost:3000`.
169
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
+
170
228
  ## Client reports (Typst)
171
229
 
172
230
  `report/` ships the Casomo house style for client-facing PDF reports —
@@ -181,13 +239,13 @@ npx build-report report.typ --watch # recompile on save
181
239
  npx build-report report.typ --open # open the PDF when done
182
240
  ```
183
241
 
184
- The compiled PDF is a build artefact, so it defaults to the same scratch
185
- resolution as the agent reports (see *Report output & `SCRATCH_DIR`*
186
- under Agents): `$SCRATCH_DIR/reports/<name>.pdf`, or
187
- `<repo-root>/scratch/reports/` when `SCRATCH_DIR` is unset — add
188
- `scratch/` to the consumer repo's `.gitignore` (this repo does). Pass
189
- `--out` only when the PDF is a deliverable the repo actually keeps
190
- (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/`).
191
249
 
192
250
  A report imports the template and applies it as a show rule; everything
193
251
  after is the body. From a consumer repo the import goes through
@@ -322,7 +380,7 @@ decision table) that keeps these units composing without overlap.
322
380
  | `/casomoltd:typescript` | TypeScript data modelling and type design |
323
381
  | `/casomoltd:screenshot` | Capture and analyse a dev server page |
324
382
  | `/casomoltd:design-pass` | Map → review → refactor a package (drives `design-xray` + `code-review`) |
325
- | `/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`) |
326
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) |
327
385
  | `/casomoltd:generate-report` | Scaffold a Typst client report from the house template, compile via `build-report`, verify the PDF |
328
386
 
@@ -408,14 +466,16 @@ where a justification would and otherwise survives an ordinary read-through. The
408
466
  `agents/*.md` changes to it. Not link validity (a markdown link linter), not the
409
467
  corpus reference graph (`docs-xray`), not prose voice (a content reviewer).
410
468
 
411
- **Report output & `SCRATCH_DIR`.** `design-xray` and `docs-xray` persist their
412
- report — the `.md` plus an `.html` rendered by `bin/render-report.mjs` — so it
413
- outlives the run and opens in a browser. Each writes to a gitignored
414
- `<repo-root>/scratch/<agent>/` by default. Set the optional **`SCRATCH_DIR`** env
415
- var to pool every report under one shared location instead (`$SCRATCH_DIR/<agent>/`)
416
- — e.g. a multi-repo workspace collecting reports in one place rather than scattering
417
- them per-repo. Publishing a report to a hosted claude.ai artifact is orchestrator-
418
- 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.
419
479
 
420
480
  ## Package distribution
421
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,47 @@
1
+ #!/bin/sh
2
+ # Refuse a push that would put a merge commit on the remote.
3
+ #
4
+ # History is linear here: a branch catches up by rebasing, never by merging.
5
+ # A merge commit costs the thing a linear history is kept for — `git log` on a
6
+ # file stops being the list of changes to it, `git bisect` has two parents to
7
+ # choose between, and a revert has to name which side it means.
8
+ #
9
+ # The forge refuses merges made through its own UI; this refuses the other
10
+ # route, which is a local `git merge` followed by an ordinary push. Both are
11
+ # needed: neither covers the other.
12
+ #
13
+ # Only what the push would ADD is judged. A merge already on the remote stays
14
+ # where it is, because rewriting shared history to satisfy a new rule costs
15
+ # more than the rule is worth.
16
+
17
+ STATUS=0
18
+
19
+ while read -r _ LOCAL_SHA REMOTE_REF REMOTE_SHA; do
20
+ # A deletion pushes the zero sha and adds nothing.
21
+ case "$LOCAL_SHA" in
22
+ *[!0]*) ;;
23
+ *) continue ;;
24
+ esac
25
+
26
+ case "$REMOTE_SHA" in
27
+ # A new branch: everything on it that no remote already has.
28
+ *[!0]*) RANGE="$REMOTE_SHA..$LOCAL_SHA" ;;
29
+ *) RANGE="$LOCAL_SHA --not --remotes" ;;
30
+ esac
31
+
32
+ # shellcheck disable=SC2086 # RANGE carries its own arguments.
33
+ MERGES=$(git rev-list --merges $RANGE 2>/dev/null)
34
+
35
+ if [ -n "$MERGES" ]; then
36
+ COUNT=$(printf '%s\n' "$MERGES" | wc -l | tr -d ' ')
37
+ echo "$REMOTE_REF would gain $COUNT merge commit(s):"
38
+ printf '%s\n' "$MERGES" | while read -r SHA; do
39
+ echo " $(git log --format='%h %s' -1 "$SHA")"
40
+ done
41
+ echo "History is linear here. Rebase instead:"
42
+ echo " git rebase main"
43
+ STATUS=1
44
+ fi
45
+ done
46
+
47
+ 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();
package/bin/pre-push.sh CHANGED
@@ -16,4 +16,5 @@ export PRE_PUSH_REFS
16
16
  # simply doesn't.
17
17
  npm run check \
18
18
  && echo "$PRE_PUSH_REFS" | check-version \
19
- && echo "$PRE_PUSH_REFS" | check-tags
19
+ && echo "$PRE_PUSH_REFS" | check-tags \
20
+ && echo "$PRE_PUSH_REFS" | check-linear