@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 +12 -5
- package/bin/check-gates.mjs +51 -32
- package/bin/check-listed-files.mjs +127 -0
- package/bin/check-routes.mjs +218 -0
- package/bin/commit-msg.sh +4 -0
- package/bin/pre-push.sh +5 -0
- package/bin/verify-pack.mjs +15 -5
- package/eslint/house-rules.mjs +102 -0
- package/eslint/index.mjs +5 -14
- package/eslint/lib.mjs +13 -11
- package/identity/index.d.mts +14 -0
- package/identity/index.mjs +6 -0
- package/identity/organization.mjs +24 -0
- package/package.json +20 -9
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
|
|
12
|
-
| `@casomoltd/tooling/eslint-lib` |
|
|
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
|
|
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
|
|
package/bin/check-gates.mjs
CHANGED
|
@@ -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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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 `&` 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("&", "&"));
|
|
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
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
|
package/bin/verify-pack.mjs
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
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(
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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.
|
|
4
|
-
"description": "Casomo's shared dev config: ESLint, Prettier, commitlint, knip & jscpd presets
|
|
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
|
-
"
|
|
27
|
-
"
|
|
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": {
|