@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 +80 -21
- package/bin/check-hooks.sh +84 -0
- package/bin/check-personal-data.mjs +270 -0
- package/bin/render-figures.mjs +159 -0
- package/bin/render-report.mjs +47 -46
- package/bin/spec-check.mjs +229 -0
- package/bin/utils.mjs +19 -0
- package/bin/verify-pack.mjs +1 -0
- package/package.json +13 -6
- package/styles/casomo-spec.css +230 -0
- package/styles/casomo-tokens.css +43 -0
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.
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
optional peer.**
|
|
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
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
`--out` only when the PDF is a deliverable the repo actually
|
|
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
|
|
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
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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('&', '&').replaceAll('<', '<');
|
|
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}`);
|