@casomoltd/tooling 0.36.1 → 0.38.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
@@ -8,13 +8,14 @@ Shared linting, formatting, commit config, and CLI tools for Casomo Ltd's repos.
8
8
 
9
9
  | Export | Description |
10
10
  |---|---|
11
- | `@casomoltd/tooling/eslint` | `createBaseConfig()` — ESLint flat config with Next.js, TypeScript, and SonarJS |
12
- | `@casomoltd/tooling/eslint-lib` | ESLint config variant for library packages |
11
+ | `@casomoltd/tooling/eslint` | `createBaseConfig()` — ESLint flat config with Next.js, TypeScript, SonarJS and the house rules ([docs/lint.md](docs/lint.md)) |
12
+ | `@casomoltd/tooling/eslint-lib` | `createLibConfig()` — the same house rules for a TypeScript library ([docs/lint.md](docs/lint.md)) |
13
13
  | `@casomoltd/tooling/prettier` | Prettier config object |
14
14
  | `@casomoltd/tooling/commitlint` | commitlint config with `no-ai-attribution` plugin |
15
15
  | `@casomoltd/tooling/knip` | Knip config for unused exports/dependencies |
16
16
  | `@casomoltd/tooling/jscpd` | Copy-paste detection config |
17
17
  | `@casomoltd/tooling/readability` | `scoreText()` — readability scoring for page content |
18
+ | `@casomoltd/tooling/identity` | `CASOMO_ORGANIZATION`, the schema.org `Organization` every Casomo site names as publisher in its JSON-LD. App code imports it at build time, so a site that installs only production dependencies at runtime lists the package under `dependencies` |
18
19
  | `@casomoltd/tooling/buttondown` | For a repo using the `buttondown` command: the shared email blocks and skins (`createEmailBlocks`, `emailBaseCss`, `composeWebSkin`), `tokenReader()`, `loadSettings()` and `buildPayload()`, and `EMAIL_CHROME_HIDES` and `WEB_CHROME_HIDES` ([docs/buttondown.md](docs/buttondown.md)) |
19
20
 
20
21
  ### Bin commands
@@ -23,9 +24,11 @@ Shared linting, formatting, commit config, and CLI tools for Casomo Ltd's repos.
23
24
  |---|---|
24
25
  | `check-version` | Pre-push guard — rejects push if `package.json` version hasn't changed vs `origin/main` |
25
26
  | `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 |
26
- | `pre-push` | Husky pre-push hook — runs check, then the version, tag and linear-history guards |
27
+ | `pre-push` | Husky pre-push hook — runs check, then `check:routes` where a repo defines it, then the version, tag and linear-history guards |
28
+ | `check-routes` | Pre-push gate for a Next.js site — starts `next start` against the build `check` made, requests every page in `/sitemap.xml`, and fails if any is not a 200. Run it as `"check:routes": "check-routes"`; [why it runs on push](#quality-gates) |
27
29
  | `pre-commit` | Husky pre-commit hook — runs `npm run check` |
28
- | `commit-msg` | Husky commit-msg hook — runs commitlint |
30
+ | `commit-msg` | Husky commit-msg hook — runs commitlint, then `check-listed-files` |
31
+ | `check-listed-files` | Refuse a commit that changes a file still listed in `eslint-suppressions.json`, unless a `Lint-deferred:` trailer says why ([docs/lint.md](docs/lint.md)) |
29
32
  | `readability` | Measure reading difficulty of page content |
30
33
  | `screenshot` | Capture a dev server page via Playwright — an agent's visual feedback loop |
31
34
  | `build-report` | Compile a Typst client report to PDF with the house template |
@@ -285,13 +288,17 @@ the local hooks are the gate, CI is the backstop.
285
288
 
286
289
  - **pre-commit**: `npm run check` — the repo's full health gate.
287
290
  - **commit-msg**: commitlint (house rules + the AI-attribution ban).
288
- - **pre-push**: `npm run check`, then the version and tag guards.
291
+ - **pre-push**: `npm run check`, then `check:routes` where the repo
292
+ defines it, then the version, tag and linear-history guards.
289
293
 
290
294
  **`check` owns the build.** A repo whose build fails is not
291
295
  healthy, and any assertion that reads build output has to run
292
296
  after the build in the same script — so `build` belongs in
293
297
  `check`, and appears nowhere else. Declaring it in the pre-push
294
298
  gate as well would give it two owners and build twice.
299
+ Exception: `check:routes` reads the build outside `check`. It
300
+ starts a server, which belongs to a push rather than to every
301
+ commit, so the pre-push hook runs it after `check`.
295
302
 
296
303
  ### What every repo's `check` must contain
297
304
 
@@ -9,8 +9,10 @@
9
9
  //
10
10
  // So this does not orchestrate the gates; it checks the composition:
11
11
  // 1. every REQUIRED gate is defined and actually run by `check`
12
- // 2. no gate-shaped script is defined but left unreferenced (a dead gate)
12
+ // 2. no gate-shaped script is defined but left unreferenced (a dead gate),
13
+ // other than a pre-push gate, which the hook runs instead
13
14
  // 3. no exception outlives the problem it was written for
15
+ // 4. no pre-push gate is run by `check`, which every commit pays for
14
16
  //
15
17
  // A repo opts out by declaring a reason in its own package.json. A blank
16
18
  // reason fails, so an opt-out can never be silent:
@@ -38,6 +40,11 @@ export const REQUIRED = [
38
40
  /** Gate-shaped but not mandatory: still dead if defined and never run. */
39
41
  export const OPTIONAL = ["check:readability"];
40
42
 
43
+ /** Gates the pre-push hook runs after `check`, never `check` itself: they
44
+ * start a server against the build, which a commit should not pay for.
45
+ * bin/pre-push.sh runs each by name, so the two lists must match. */
46
+ export const PRE_PUSH = ["check:routes"];
47
+
41
48
  /** A script is a gate only by exact membership or the `check:` prefix — never
42
49
  * by heuristic, so `dev`, `start`, `clean`, `lint:fix`, `test:watch`,
43
50
  * `build:clean` and `indexnow` need no denylist to be ignored. */
@@ -54,45 +61,57 @@ export const referencedGates = (check) =>
54
61
  .filter(Boolean),
55
62
  );
56
63
 
64
+ /** Each required gate that is not defined, or defined but never run. */
65
+ const requiredFailures = (scripts, run) =>
66
+ REQUIRED.flatMap((gate) => {
67
+ if (!(gate in scripts)) return [[gate, `required gate is not defined`]];
68
+ if (!run.has(gate)) {
69
+ return [[gate, `required gate is defined but \`check\` never runs it`]];
70
+ }
71
+ return [];
72
+ });
73
+
74
+ /** Each other gate-shaped script that `check` never runs. `check` itself is
75
+ * the chain, not a step within it, and the hook runs a pre-push gate. */
76
+ const deadGates = (scripts, run) =>
77
+ Object.keys(scripts)
78
+ .filter((n) => isGate(n) && n !== "check" && !REQUIRED.includes(n))
79
+ .filter((gate) => !PRE_PUSH.includes(gate))
80
+ .filter((gate) => !run.has(gate))
81
+ .map((gate) => [gate, `defined but \`check\` never runs it — a dead gate`]);
82
+
83
+ /** Each pre-push gate that `check` runs, so every commit starts a server. */
84
+ const prePushInCheck = (run) =>
85
+ PRE_PUSH.filter((gate) => run.has(gate)).map((gate) => [
86
+ gate,
87
+ `a pre-push gate: \`check\` runs on every commit`,
88
+ ]);
89
+
90
+ /** Sort the failures by their exception: none, a blank reason, or a reason. */
91
+ const byException = (failures, exceptions) => {
92
+ const sorted = {live: [], excused: [], unreasoned: []};
93
+ for (const [gate, reason] of failures) {
94
+ if (!(gate in exceptions)) sorted.live.push([gate, reason]);
95
+ else if (!String(exceptions[gate] ?? "").trim()) sorted.unreasoned.push(gate);
96
+ else sorted.excused.push([gate, exceptions[gate]]);
97
+ }
98
+ return sorted;
99
+ };
100
+
57
101
  export const evaluate = (pkg) => {
58
102
  const scripts = pkg.scripts ?? {};
59
103
  const exceptions = pkg.casomo?.gates ?? {};
60
104
  const run = referencedGates(scripts.check);
61
-
62
- // `check` itself is the chain, not a step within it.
63
- const defined = Object.keys(scripts).filter((n) => isGate(n) && n !== "check");
64
-
65
- const failures = [];
66
- for (const gate of REQUIRED) {
67
- if (!(gate in scripts)) failures.push([gate, `required gate is not defined`]);
68
- else if (!run.has(gate))
69
- failures.push([gate, `required gate is defined but \`check\` never runs it`]);
70
- }
71
- for (const gate of defined) {
72
- if (run.has(gate)) continue;
73
- if (failures.some(([g]) => g === gate)) continue; // already reported above
74
- failures.push([gate, `defined but \`check\` never runs it — a dead gate`]);
75
- }
76
-
77
- const excused = [];
78
- const unreasoned = [];
79
- const live = [];
80
- for (const [gate, reason] of failures) {
81
- if (!(gate in exceptions)) {
82
- live.push([gate, reason]);
83
- } else if (!String(exceptions[gate] ?? "").trim()) {
84
- unreasoned.push(gate);
85
- } else {
86
- excused.push([gate, exceptions[gate]]);
87
- }
88
- }
89
-
105
+ const failures = [
106
+ ...requiredFailures(scripts, run),
107
+ ...deadGates(scripts, run),
108
+ ...prePushInCheck(run),
109
+ ];
90
110
  // An exception whose failure has gone away is the same rot in reverse.
91
111
  const stale = Object.keys(exceptions).filter(
92
112
  (gate) => !failures.some(([g]) => g === gate),
93
113
  );
94
-
95
- return {live, excused, unreasoned, stale};
114
+ return {...byException(failures, exceptions), stale};
96
115
  };
97
116
 
98
117
  const main = () => {
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Refuse a commit that changes a file still listed in the repo's
4
+ * `eslint-suppressions.json`, unless the message says why it is deferred.
5
+ *
6
+ * The house size limits are lint errors, and a file that already broke one
7
+ * when the limit was switched on is listed there instead of failing. The
8
+ * list only shrinks if touching a file is the moment it is tidied: tidy it
9
+ * in its own commit (fix it, then `eslint --prune-suppressions`), and the
10
+ * change that needed it follows. ESLint itself fails while a fixed file
11
+ * keeps its entry, so a tidied file always comes off the list. A commit that
12
+ * has to change a listed file anyway carries a trailer naming why, so the
13
+ * deferral shows in `git log`:
14
+ *
15
+ * Lint-deferred: a one-line hotfix; the tidy follows in its own commit
16
+ *
17
+ * What it reads, and what it assumes:
18
+ *
19
+ * - **The list as staged**, so the commit that tidies a file and drops its
20
+ * entry passes.
21
+ * - **The files staged now**, renames counted under their old path too. An
22
+ * amend is checked on what it newly stages, not on the whole commit.
23
+ * - **One list, at the repo root**, keyed by root-relative path, which is
24
+ * where ESLint writes it when run from the root. A list anywhere else
25
+ * fails the check rather than being silently missed.
26
+ * - **No merge commits:** a merge carries changes each branch already
27
+ * committed and was checked for.
28
+ *
29
+ * Runs from the shared commit-msg hook, the first point the message exists.
30
+ *
31
+ * Usage:
32
+ * check-listed-files <commit-message-file>
33
+ */
34
+ import {execFileSync} from "node:child_process";
35
+ import {readFileSync, realpathSync} from "node:fs";
36
+ import {pathToFileURL} from "node:url";
37
+
38
+ /** ESLint's default suppressions file, at the repo root. */
39
+ export const SUPPRESSIONS_FILE = "eslint-suppressions.json";
40
+ /** The trailer that lets a listed file through, with a reason on the same
41
+ * line. Git reads trailer keys without regard to case. */
42
+ export const DEFERRED_TRAILER = /^Lint-deferred:[ \t]*\S/im;
43
+
44
+ /**
45
+ * The staged files that are listed, each with the rules it is listed for.
46
+ *
47
+ * @param {readonly string[]} staged - repo-relative paths in the commit
48
+ * @param {Record<string, Record<string, {count: number}>>} listed - the
49
+ * staged suppressions file, keyed by repo-relative path
50
+ * @returns {{file: string, rules: string[]}[]}
51
+ */
52
+ export function listedInCommit(staged, listed) {
53
+ return staged
54
+ .filter((file) => Object.hasOwn(listed, file))
55
+ .map((file) => ({file, rules: Object.keys(listed[file])}));
56
+ }
57
+
58
+ /** Whether the message defers the tidy with a reason. */
59
+ export function defers(message) {
60
+ return DEFERRED_TRAILER.test(message);
61
+ }
62
+
63
+ const git = (...args) => execFileSync("git", args,
64
+ {encoding: "utf8", stdio: ["ignore", "pipe", "pipe"]});
65
+
66
+ /** Whether git can resolve a ref or object, without throwing. */
67
+ function resolves(ref) {
68
+ try {
69
+ git("rev-parse", "-q", "--verify", ref);
70
+ return true;
71
+ } catch {
72
+ // Not resolvable is the answer asked for, not a failure.
73
+ return false;
74
+ }
75
+ }
76
+
77
+ /** The repo's suppressions as staged; none where the repo stages no list.
78
+ * A list that is staged but unreadable throws: it is not an empty list. */
79
+ function stagedSuppressions() {
80
+ if (!resolves(`:${SUPPRESSIONS_FILE}`)) return {};
81
+ return JSON.parse(git("show", `:${SUPPRESSIONS_FILE}`));
82
+ }
83
+
84
+ /** Suppressions files tracked anywhere but the repo root. */
85
+ function listsBelowRoot() {
86
+ return git("ls-files", `*${SUPPRESSIONS_FILE}`).split("\n")
87
+ .filter((path) => path && path !== SUPPRESSIONS_FILE);
88
+ }
89
+
90
+ /** The paths the commit touches, a rename under both its old and new path
91
+ * and a deletion under its own. */
92
+ function stagedPaths() {
93
+ return git("diff", "--cached", "--name-only", "--no-renames",
94
+ "--diff-filter=ACMRD").split("\n").filter(Boolean);
95
+ }
96
+
97
+ function main(messageFile) {
98
+ if (resolves("MERGE_HEAD")) return;
99
+ const elsewhere = listsBelowRoot();
100
+ if (elsewhere.length > 0) {
101
+ console.error("check-listed-files: suppressions lists below the repo "
102
+ + `root are not read, so this check cannot hold: ${elsewhere.join(", ")}`);
103
+ process.exit(1);
104
+ }
105
+ const hits = listedInCommit(stagedPaths(), stagedSuppressions());
106
+ if (hits.length === 0) return;
107
+ if (defers(readFileSync(messageFile, "utf8"))) {
108
+ for (const {file} of hits) {
109
+ console.warn(`check-listed-files: ${file} is listed; tidy deferred`);
110
+ }
111
+ return;
112
+ }
113
+ console.error("check-listed-files: this commit changes files listed in "
114
+ + `${SUPPRESSIONS_FILE}. Tidy each in its own commit first (fix it, `
115
+ + "then eslint --prune-suppressions), or add a 'Lint-deferred: <why>' "
116
+ + "trailer:");
117
+ for (const {file, rules} of hits) {
118
+ console.error(` ${file}: ${rules.join(", ")}`);
119
+ }
120
+ process.exit(1);
121
+ }
122
+
123
+ // Run through npm's bin symlink, argv[1] is the link and import.meta.url
124
+ // the real file, so compare real paths.
125
+ if (import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
126
+ main(process.argv[2]);
127
+ }
@@ -0,0 +1,218 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Request every page a built Next.js site lists in its sitemap from
4
+ * `next start` on a local port, and fail if any answers anything but 200.
5
+ *
6
+ * A build can pass while a page still fails at request time: a server
7
+ * component that throws, or a fetch that breaks only when the page renders.
8
+ * The pre-push hook runs this after `npm run check`, against the build
9
+ * `check` has just made; it makes no build of its own. It stays out of
10
+ * `check` because the pre-commit hook runs `check`, and starting a server
11
+ * belongs to a push, not to every commit.
12
+ *
13
+ * It reads the sitemap Next emits: plain `<loc>` elements, in a urlset or a
14
+ * sitemap index one level deep, with `&amp;` the only entity in a URL.
15
+ *
16
+ * Usage, in the site's root after `next build`:
17
+ * check-routes
18
+ *
19
+ * Exit codes:
20
+ * 0 every listed page answered 200
21
+ * 1 a page did not, the sitemap failed or listed none, or the server
22
+ * never started
23
+ */
24
+
25
+ import {spawn} from "node:child_process";
26
+ import {existsSync, realpathSync} from "node:fs";
27
+ import {createServer} from "node:net";
28
+ import {setTimeout as sleep} from "node:timers/promises";
29
+ import {pathToFileURL} from "node:url";
30
+
31
+ const HOST = "127.0.0.1";
32
+ const SITEMAP = "/sitemap.xml";
33
+ // The default distDir; a site that moves it is not supported.
34
+ const BUILD_ID = ".next/BUILD_ID";
35
+ // The site's own `next`, never one `npx` would offer to download.
36
+ const NEXT_BIN = "node_modules/.bin/next";
37
+ const HTTP_OK = 200;
38
+ const READY_POLL_MS = 250;
39
+ const READY_TIMEOUT_MS = 60_000;
40
+ // A page waiting on a dead upstream must fail the push, not hang it.
41
+ const REQUEST_TIMEOUT_MS = 30_000;
42
+ // Enough to keep a few hundred pages quick without starving the server.
43
+ const CONCURRENCY = 8;
44
+
45
+ /** Probe every path, CONCURRENCY at a time; return the ones not 200. */
46
+ export const failingPages = async (base, paths) => {
47
+ const queue = [...paths];
48
+ const failures = [];
49
+ const worker = async () => {
50
+ for (let path = queue.shift(); path !== undefined; path = queue.shift()) {
51
+ const result = await probe(base, path);
52
+ if (result.status !== HTTP_OK) failures.push(result);
53
+ }
54
+ };
55
+ await Promise.all(Array.from({length: CONCURRENCY}, worker));
56
+ return failures;
57
+ };
58
+
59
+ /** The path and query of every `<loc>` in a sitemap or sitemap index. The
60
+ * sitemap names the production host; the check asks the local server. */
61
+ const sitemapPaths = (xml) =>
62
+ [...xml.matchAll(/<loc>([^<]+)<\/loc>/g)].map(([, loc]) => {
63
+ const url = new URL(loc.trim().replaceAll("&amp;", "&"));
64
+ return url.pathname + url.search;
65
+ });
66
+
67
+ /** A sitemap index lists other sitemaps, not pages. */
68
+ const isSitemapIndex = (xml) => /<sitemapindex[\s>]/.test(xml);
69
+
70
+ const message = (err) => (err instanceof Error ? err.message : String(err));
71
+
72
+ const get = (url, init = {}) =>
73
+ fetch(url, {...init, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)});
74
+
75
+ /** A sitemap's text; any answer but 200 is the failure, not a retry. */
76
+ const readSitemap = async (base, path) => {
77
+ const res = await get(base + path);
78
+ if (res.status !== HTTP_OK) throw new Error(`${path} answered ${res.status}`);
79
+ return res.text();
80
+ };
81
+
82
+ /** Every page path, once each, following a sitemap index one level down. */
83
+ const collectPaths = async (base, rootXml) => {
84
+ if (!isSitemapIndex(rootXml)) return [...new Set(sitemapPaths(rootXml))];
85
+ const children = await Promise.all(
86
+ sitemapPaths(rootXml).map((path) => readSitemap(base, path)),
87
+ );
88
+ return [...new Set(children.flatMap(sitemapPaths))];
89
+ };
90
+
91
+ /** One page's status, or the error that stopped the request. A redirect
92
+ * counts as a failure: a sitemap should list where a page lives. */
93
+ const probe = async (base, path) => {
94
+ try {
95
+ const res = await get(base + path, {redirect: "manual"});
96
+ await res.arrayBuffer();
97
+ return {path, status: res.status};
98
+ } catch (err) {
99
+ return {path, error: message(err)};
100
+ }
101
+ };
102
+
103
+ /** A free local port, so a dev server already on 3000 is never in the way. */
104
+ const freePort = () =>
105
+ new Promise((resolve, reject) => {
106
+ const listener = createServer();
107
+ listener.on("error", reject);
108
+ listener.listen(0, HOST, () => {
109
+ const {port} = listener.address();
110
+ listener.close(() => resolve(port));
111
+ });
112
+ });
113
+
114
+ /** `next start` in its own process group, so the stop reaches `next-server`
115
+ * and not only its launcher. `died` rejects the moment the server fails to
116
+ * spawn or exits, so a dead server fails the check now, not at the timeout. */
117
+ const startServer = (port) => {
118
+ const server = spawn(NEXT_BIN, ["start", "-p", String(port), "-H", HOST], {
119
+ stdio: ["ignore", "pipe", "pipe"],
120
+ detached: true,
121
+ });
122
+ const output = [];
123
+ server.stdout.on("data", (d) => output.push(String(d)));
124
+ server.stderr.on("data", (d) => output.push(String(d)));
125
+ const died = new Promise((_, reject) => {
126
+ server.on("error", (err) => reject(new Error(`could not start next: ${err}`)));
127
+ server.on("exit", (code) =>
128
+ reject(new Error(`next start exited (${code})\n${output.join("")}`)),
129
+ );
130
+ });
131
+ // The exit the stop itself causes is expected, so nothing awaits it then.
132
+ died.catch(() => {});
133
+ return {server, output, died};
134
+ };
135
+
136
+ /** Stop the server's process group; one that has already exited is fine. */
137
+ const stopServer = (server) => {
138
+ if (server.pid === undefined) return;
139
+ try {
140
+ process.kill(-server.pid, "SIGTERM");
141
+ } catch (err) {
142
+ if (err?.code !== "ESRCH") throw err;
143
+ }
144
+ };
145
+
146
+ /** The root sitemap once the server answers; keep polling only while the
147
+ * connection is refused. */
148
+ const waitForSitemap = async (base, output) => {
149
+ const deadline = Date.now() + READY_TIMEOUT_MS;
150
+ while (Date.now() < deadline) {
151
+ try {
152
+ return await readSitemap(base, SITEMAP);
153
+ } catch (err) {
154
+ if (!(err instanceof TypeError)) throw err;
155
+ }
156
+ await sleep(READY_POLL_MS);
157
+ }
158
+ throw new Error(`the server never answered ${SITEMAP}\n${output.join("")}`);
159
+ };
160
+
161
+ const report = (count, failures) => {
162
+ if (count === 0) {
163
+ console.error(`✗ check-routes: ${SITEMAP} lists no pages`);
164
+ return 1;
165
+ }
166
+ if (failures.length > 0) {
167
+ console.error(`✗ check-routes: ${failures.length} of ${count} pages failed`);
168
+ for (const {path, status, error} of failures) {
169
+ console.error(` ${status ?? error} ${path}`);
170
+ }
171
+ return 1;
172
+ }
173
+ console.log(`✓ check-routes: ${count} pages answered 200`);
174
+ return 0;
175
+ };
176
+
177
+ /** Start the server, check every page, stop it; the exit code. */
178
+ const run = async () => {
179
+ const port = await freePort();
180
+ const base = `http://${HOST}:${port}`;
181
+ const {server, output, died} = startServer(port);
182
+ try {
183
+ const rootXml = await Promise.race([waitForSitemap(base, output), died]);
184
+ const paths = await collectPaths(base, rootXml);
185
+ return report(paths.length, await failingPages(base, paths));
186
+ } catch (err) {
187
+ console.error(`✗ check-routes: ${message(err)}`);
188
+ return 1;
189
+ } finally {
190
+ stopServer(server);
191
+ }
192
+ };
193
+
194
+ const main = async () => {
195
+ if (!existsSync(BUILD_ID)) {
196
+ console.error(
197
+ `✗ check-routes: no ${BUILD_ID}; run \`next build\` first`
198
+ + " (a custom distDir is not supported)",
199
+ );
200
+ process.exit(1);
201
+ }
202
+ const code = await run();
203
+ // The server's pipes can keep node alive past the stop; the check is done.
204
+ process.exit(code);
205
+ };
206
+
207
+ /** True only when this file is the entry point. npm installs a bin as a
208
+ * symlink, so `process.argv[1]` is the link while `import.meta.url` is the
209
+ * target it resolves to: resolve before comparing, or `main` never runs. */
210
+ const isEntryPoint = () => {
211
+ try {
212
+ return import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
213
+ } catch {
214
+ return false;
215
+ }
216
+ };
217
+
218
+ if (isEntryPoint()) await main();
package/bin/commit-msg.sh CHANGED
@@ -1,2 +1,6 @@
1
1
  #!/bin/sh
2
+ # Stop at the first check that fails, so a later one cannot mask it.
3
+ set -e
2
4
  npx --no -- commitlint --edit "$1"
5
+ # Resolve the npm bin symlink so the check is found beside this script.
6
+ node "$(dirname "$(realpath "$0")")/check-listed-files.mjs" "$1"
package/bin/pre-push.sh CHANGED
@@ -14,7 +14,12 @@ export PRE_PUSH_REFS
14
14
  # owners and run it twice wherever `check` builds. A repo that needs a build
15
15
  # gate adds `npm run build` to its own `check`; one that produces no artifact
16
16
  # simply doesn't.
17
+ #
18
+ # `check:routes` runs after `check`, not in it: see the README, "Quality
19
+ # gates". Repos without it skip it. Every gate in PRE_PUSH
20
+ # (bin/check-gates.mjs) must be run here.
17
21
  npm run check \
22
+ && npm run --if-present check:routes \
18
23
  && echo "$PRE_PUSH_REFS" | check-version \
19
24
  && echo "$PRE_PUSH_REFS" | check-tags \
20
25
  && echo "$PRE_PUSH_REFS" | check-linear
@@ -4,7 +4,8 @@
4
4
  // the allowlist and never look like a secret. Defence-in-depth on top of
5
5
  // package.json "files" — this fails CI if that allowlist is ever loosened
6
6
  // or a secret-bearing file sneaks in. The tooling repo is public, so the
7
- // published/installed surface must stay configs + CLI only.
7
+ // published/installed surface must stay configs, CLIs and the build-time
8
+ // data sites import, and nothing private.
8
9
  import {execSync} from "node:child_process";
9
10
 
10
11
  // Dirs/files a consumer is allowed to receive. npm always adds
@@ -20,12 +21,21 @@ const ALLOWED_PREFIXES = [
20
21
  "report/",
21
22
  "styles/",
22
23
  "buttondown/",
24
+ "identity/",
23
25
  ];
24
26
  const ALLOWED_FILES = ["README.md", "package.json", "LICENSE", "LICENSE.md"];
25
27
 
26
- // Anything matching these must never ship, even from an allowed dir.
27
- const SECRET_PATTERN =
28
- /(^|\/)(\.env(\..*)?|.*settings\.local\.json|.*\.pem|id_[rd]sa|.*\.key|\.npmrc)$/i;
28
+ // A file whose name alone marks it as a secret must never ship, even from an
29
+ // allowed dir: an env file, local settings, a key, or an npm token file.
30
+ const SECRET_NAMES = new Set([".env", ".npmrc", "id_rsa", "id_dsa"]);
31
+ const SECRET_ENDINGS = [".pem", ".key", "settings.local.json"];
32
+
33
+ function looksSecret(path) {
34
+ const name = path.slice(path.lastIndexOf("/") + 1).toLowerCase();
35
+ return SECRET_NAMES.has(name)
36
+ || name.startsWith(".env.")
37
+ || SECRET_ENDINGS.some((ending) => name.endsWith(ending));
38
+ }
29
39
 
30
40
  // Tests live beside what they test, which puts them inside an allowed dir —
31
41
  // so the allowlist alone would ship them. Consumers get the tool, not its
@@ -40,7 +50,7 @@ const escaped = files.filter(
40
50
  !ALLOWED_FILES.includes(f) &&
41
51
  !ALLOWED_PREFIXES.some((prefix) => f.startsWith(prefix)),
42
52
  );
43
- const secrets = files.filter((f) => SECRET_PATTERN.test(f));
53
+ const secrets = files.filter(looksSecret);
44
54
  const tests = files.filter((f) => TEST_PATTERN.test(f));
45
55
 
46
56
  if (escaped.length || secrets.length || tests.length) {
@@ -0,0 +1,102 @@
1
+ /**
2
+ * The house rules every repo's ESLint config shares: line length; the size
3
+ * of a function, its parameter list, a file, its branching and nesting;
4
+ * named numbers; no non-null assertions; and exhaustive switches. Both the
5
+ * Next config (`index.mjs`) and the library config (`lib.mjs`) spread these
6
+ * in, so each rule is stated once. Every rule is on unless a reason is
7
+ * written beside the exception.
8
+ *
9
+ * They are errors, never warnings: a warning nobody has to act on is how a
10
+ * module grows past what anyone can read. A file that already breaks one is
11
+ * recorded in the repo's `eslint-suppressions.json` (`eslint
12
+ * --suppress-all`), and `check-listed-files` fails a commit that touches it
13
+ * until it is tidied, unless the message carries a `Lint-deferred:` reason.
14
+ * A repo whose files are transcribed reference data (published tables,
15
+ * cited at the data) exempts those paths from
16
+ * `@typescript-eslint/no-magic-numbers` in its own config, saying why.
17
+ * What each rule is for, and every exemption, is in `docs/lint.md`.
18
+ */
19
+
20
+ /** Lines a function may run to, blank lines and comments not counted:
21
+ * ESLint's default. */
22
+ const FUNCTION_LINES = 50;
23
+ /** Parameters a function may take; past this, pass a value object.
24
+ * Proposed here; no external source (ESLint's default is 3). */
25
+ const PARAMS = 4;
26
+ /** Lines a file may run to, blank lines and comments not counted.
27
+ * Proposed here; no external source (ESLint's default is 300). */
28
+ const FILE_LINES = 400;
29
+ /** Cyclomatic complexity a function may reach. Proposed here; no external
30
+ * source (ESLint's default is 20). */
31
+ const COMPLEXITY = 10;
32
+
33
+ /** How deep blocks may nest before the code should be flattened.
34
+ * Proposed here; no external source (ESLint's default is 4). */
35
+ const DEPTH = 3;
36
+
37
+ const SKIP_BLANKS_AND_COMMENTS = { skipBlankLines: true, skipComments: true };
38
+
39
+ /** The rules, as one flat-config block for every file. */
40
+ export const HOUSE_RULES = {
41
+ rules: {
42
+ // URLs and regex literals are the deviations from 88: neither can be
43
+ // broken without changing it. No other long line is allowed, so strings
44
+ // and template literals are not exempt.
45
+ 'max-len': [
46
+ 'error',
47
+ { code: 88, ignoreUrls: true, ignoreRegExpLiterals: true },
48
+ ],
49
+ 'max-lines-per-function': [
50
+ 'error', { max: FUNCTION_LINES, ...SKIP_BLANKS_AND_COMMENTS },
51
+ ],
52
+ // The TypeScript version does not count a `this:` type annotation.
53
+ 'max-params': 'off',
54
+ '@typescript-eslint/max-params': ['error', { max: PARAMS }],
55
+ 'max-lines': ['error', { max: FILE_LINES, ...SKIP_BLANKS_AND_COMMENTS }],
56
+ complexity: ['error', COMPLEXITY],
57
+ 'max-depth': ['error', DEPTH],
58
+ // A number in code is named, so the reader sees what it is. Counting
59
+ // and indexing numbers, enums and type positions are not magic, nor a
60
+ // parameter's default or a readonly class property, which already name
61
+ // the number they hold. The TypeScript version replaces the base rule.
62
+ 'no-magic-numbers': 'off',
63
+ '@typescript-eslint/no-magic-numbers': ['error', {
64
+ ignore: [-1, 0, 1],
65
+ ignoreArrayIndexes: true,
66
+ ignoreDefaultValues: true,
67
+ ignoreEnums: true,
68
+ ignoreNumericLiteralTypes: true,
69
+ ignoreReadonlyClassProperties: true,
70
+ ignoreTypeIndexes: true,
71
+ }],
72
+ // A `!` tells the compiler a value exists without checking that it does.
73
+ '@typescript-eslint/no-non-null-assertion': 'error',
74
+ },
75
+ };
76
+
77
+ /** Rules that need the type checker, for TypeScript files only. A switch
78
+ * over a union names every member, so a new member is a lint error at each
79
+ * switch rather than a silent fall-through. Type information also brings on the
80
+ * type-aware rules in sonarjs's recommended set, which are silent
81
+ * without it. */
82
+ export const TYPED_RULES = {
83
+ files: ['**/*.ts', '**/*.tsx', '**/*.mts'],
84
+ // A declaration file holds no code to switch on.
85
+ ignores: ['**/*.d.ts', '**/*.d.mts'],
86
+ languageOptions: { parserOptions: { projectService: true } },
87
+ rules: { '@typescript-eslint/switch-exhaustiveness-check': 'error' },
88
+ };
89
+
90
+ /** A test file's cases often sit in one top-level function by design (a
91
+ * `describe` block, an assert script), and its expected figures are
92
+ * literals by design, so function length and magic numbers are not held
93
+ * against test files; every other limit is. */
94
+ export const TEST_FILES_EXEMPT = {
95
+ files: [
96
+ '**/*.test.*', '**/*.spec.*', '**/tests/**', '**/test/**', '**/__tests__/**',
97
+ ],
98
+ rules: {
99
+ 'max-lines-per-function': 'off',
100
+ '@typescript-eslint/no-magic-numbers': 'off',
101
+ },
102
+ };
package/eslint/index.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ import {HOUSE_RULES, TEST_FILES_EXEMPT, TYPED_RULES} from "./house-rules.mjs";
2
+
1
3
  /**
2
4
  * Creates the shared ESLint base config.
3
5
  *
@@ -33,19 +35,8 @@ export function createBaseConfig({
33
35
  "@typescript-eslint/no-require-imports": "off",
34
36
  },
35
37
  },
36
- {
37
- rules: {
38
- "max-len": [
39
- "error",
40
- {
41
- code: 88,
42
- ignoreUrls: true,
43
- ignoreStrings: true,
44
- ignoreTemplateLiterals: true,
45
- ignoreRegExpLiterals: true,
46
- },
47
- ],
48
- },
49
- },
38
+ HOUSE_RULES,
39
+ TYPED_RULES,
40
+ TEST_FILES_EXEMPT,
50
41
  ]);
51
42
  }
package/eslint/lib.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ import {HOUSE_RULES, TEST_FILES_EXEMPT, TYPED_RULES} from './house-rules.mjs';
2
+
1
3
  /**
2
4
  * Creates a shared ESLint config for TypeScript libraries
3
5
  * (non-Next.js projects).
@@ -22,19 +24,19 @@ export function createLibConfig({tseslint, sonarjs}) {
22
24
  '@typescript-eslint/no-require-imports': 'off',
23
25
  },
24
26
  },
27
+ HOUSE_RULES,
28
+ TYPED_RULES,
25
29
  {
26
- rules: {
27
- 'max-len': [
28
- 'error',
29
- {
30
- code: 88,
31
- ignoreUrls: true,
32
- ignoreStrings: true,
33
- ignoreTemplateLiterals: true,
34
- ignoreRegExpLiterals: true,
35
- },
36
- ],
30
+ // A library's tsconfig includes only its sources and tests, so a root
31
+ // config file (vitest.config.ts) sits outside every project.
32
+ files: TYPED_RULES.files,
33
+ ignores: TYPED_RULES.ignores,
34
+ languageOptions: {
35
+ parserOptions: {
36
+ projectService: {allowDefaultProject: ['*.config.ts', '*.config.mts']},
37
+ },
37
38
  },
38
39
  },
40
+ TEST_FILES_EXEMPT,
39
41
  );
40
42
  }
@@ -0,0 +1,14 @@
1
+ /** Casomo Ltd as a schema.org `Organization`, for a page's JSON-LD.
2
+ *
3
+ * Every field is read-only because the value is frozen: spread it into an
4
+ * untyped object literal, or type a JSON-LD field that holds it as
5
+ * `readonly string[]`, never as `string[]`. */
6
+ export interface CasomoOrganization {
7
+ readonly "@type": "Organization";
8
+ readonly "@id": string;
9
+ readonly name: string;
10
+ readonly url: string;
11
+ readonly sameAs: readonly string[];
12
+ }
13
+
14
+ export declare const CASOMO_ORGANIZATION: CasomoOrganization;
@@ -0,0 +1,6 @@
1
+ /**
2
+ * The public face of the identity entry: what a site imports. The value
3
+ * lives in organization.mjs, where the typecheck can read it against the
4
+ * declaration here; beside a .d.mts, TypeScript would skip it.
5
+ */
6
+ export {CASOMO_ORGANIZATION} from "./organization.mjs";
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Casomo Ltd as search engines should see it: the one schema.org
3
+ * `Organization` every Casomo site names in its JSON-LD. Each site imports
4
+ * this rather than typing its own, so no two sites can disagree about who
5
+ * publishes them, and `sameAs` ties them all to the same profiles.
6
+ *
7
+ * Public facts only: the company's name and the addresses its pages already
8
+ * print. A person, such as an article's author, stays with the site.
9
+ */
10
+ const SITE = "https://casomoltd.com";
11
+
12
+ /** @type {import("./index.d.mts").CasomoOrganization} */
13
+ export const CASOMO_ORGANIZATION = Object.freeze({
14
+ "@type": "Organization",
15
+ // One id across every site, so a crawler merges them into one entity.
16
+ "@id": `${SITE}/#organization`,
17
+ name: "Casomo Ltd",
18
+ url: SITE,
19
+ sameAs: Object.freeze([
20
+ "https://casomo.co.uk",
21
+ "https://www.facebook.com/casomouk",
22
+ "https://www.linkedin.com/company/111967567/",
23
+ ]),
24
+ });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@casomoltd/tooling",
3
- "version": "0.36.1",
4
- "description": "Casomo's shared dev config: ESLint, Prettier, commitlint, knip & jscpd presets and dev CLIs (git hooks, readability, screenshots, design specs)",
3
+ "version": "0.38.0",
4
+ "description": "Casomo's shared dev config: ESLint, Prettier, commitlint, knip & jscpd presets, dev CLIs (git hooks, readability, screenshots, design specs), and the build-time data and helpers sites import (the Buttondown engine, the shared Organization)",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -18,13 +18,16 @@
18
18
  "report/",
19
19
  "styles/",
20
20
  "buttondown/",
21
+ "identity/",
21
22
  "README.md",
22
- "!**/*.test.mjs"
23
+ "!**/*.test.mjs",
24
+ "!identity/tsconfig.json"
23
25
  ],
24
26
  "scripts": {
25
- "check": "node bin/check-gates.mjs && node bin/verify-pack.mjs && npm run check:private-refs && npm run check:personal-data && npm run typecheck && npm run test && npm run lint:md",
26
- "typecheck": "tsc --noEmit -p hooks/tsconfig.json",
27
- "test": "node hooks/test.mts && node bin/check-gates.test.mjs && node bin/check-personal-data.test.mjs && node bin/casomo-config.test.mjs && node bin/buttondown.test.mjs && node commitlint/index.test.mjs && node readability/index.test.mjs",
27
+ "check": "node bin/check-gates.mjs && node bin/verify-pack.mjs && npm run check:private-refs && npm run check:personal-data && npm run lint && npm run typecheck && npm run test && npm run lint:md",
28
+ "lint": "eslint .",
29
+ "typecheck": "tsc --noEmit -p hooks/tsconfig.json && tsc --noEmit -p identity/tsconfig.json",
30
+ "test": "node hooks/test.mts && node bin/check-gates.test.mjs && node bin/check-listed-files.test.mjs && node bin/check-personal-data.test.mjs && node bin/check-routes.test.mjs && node bin/casomo-config.test.mjs && node bin/buttondown.test.mjs && node commitlint/index.test.mjs && node readability/index.test.mjs && node identity/index.test.mjs",
28
31
  "lint:md": "remark --frail --quiet --no-stdout --rc-path bin/skills-lint.config.mjs skills agents docs",
29
32
  "build": "echo 'no-op: source-only repo, nothing to build'",
30
33
  "prepare": "husky || true",
@@ -33,7 +36,6 @@
33
36
  },
34
37
  "casomo": {
35
38
  "gates": {
36
- "lint": "publishes the shared eslint config but has none of its own yet",
37
39
  "knip": "no knip config for this repo's mixed .mts/.mjs layout yet",
38
40
  "jscpd": "no jscpd config for this repo yet",
39
41
  "spell": "no shared cspell config exists yet",
@@ -51,6 +53,10 @@
51
53
  "./buttondown": {
52
54
  "types": "./buttondown/index.d.mts",
53
55
  "default": "./buttondown/index.mjs"
56
+ },
57
+ "./identity": {
58
+ "types": "./identity/index.d.mts",
59
+ "default": "./identity/index.mjs"
54
60
  }
55
61
  },
56
62
  "bin": {
@@ -58,9 +64,11 @@
58
64
  "buttondown": "./bin/buttondown.mjs",
59
65
  "check-gates": "./bin/check-gates.mjs",
60
66
  "check-hooks": "./bin/check-hooks.sh",
67
+ "check-listed-files": "./bin/check-listed-files.mjs",
61
68
  "check-linear": "./bin/check-linear.sh",
62
69
  "check-personal-data": "./bin/check-personal-data.mjs",
63
70
  "check-private-refs": "./bin/check-private-refs.mjs",
71
+ "check-routes": "./bin/check-routes.mjs",
64
72
  "check-tags": "./bin/check-tags.sh",
65
73
  "check-version": "./bin/check-version.sh",
66
74
  "commit-msg": "./bin/commit-msg.sh",
@@ -80,7 +88,7 @@
80
88
  },
81
89
  "peerDependencies": {
82
90
  "@commitlint/cli": ">=20",
83
- "eslint": ">=9",
91
+ "eslint": ">=9.24",
84
92
  "eslint-config-next": ">=16",
85
93
  "eslint-plugin-sonarjs": ">=3",
86
94
  "jscpd": ">=4",
@@ -89,12 +97,15 @@
89
97
  },
90
98
  "devDependencies": {
91
99
  "@types/node": "^26.0.0",
100
+ "eslint": "^9.39.5",
101
+ "eslint-plugin-sonarjs": "^4.2.2",
92
102
  "husky": "^9.1.7",
93
103
  "remark-cli": "^12.0.1",
94
104
  "remark-frontmatter": "^5.0.0",
95
105
  "remark-lint-frontmatter-schema": "^3.15.4",
96
106
  "remark-validate-links": "^13.1.0",
97
- "typescript": "^6.0.3"
107
+ "typescript": "^6.0.3",
108
+ "typescript-eslint": "^8.72.0"
98
109
  },
99
110
  "overrides": {
100
111
  "remark-lint-frontmatter-schema": {