@casomoltd/tooling 0.23.1 → 0.23.3
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 +40 -9
- package/bin/check-gates.mjs +132 -0
- package/bin/pre-push.sh +10 -8
- package/bin/verify-pack.mjs +10 -1
- package/package.json +18 -5
package/README.md
CHANGED
|
@@ -104,7 +104,7 @@ its own bin commands via `node_modules/.bin`.
|
|
|
104
104
|
Claude Code skills/agents/docs tree: every `SKILL.md` / `agents/*.md` has
|
|
105
105
|
parseable YAML frontmatter with `name` + `description`, and every relative link
|
|
106
106
|
and `#anchor` resolves. tooling runs it over its own `skills/` in `npm run
|
|
107
|
-
check` (`lint:
|
|
107
|
+
check` (`lint:md`) — catching the breakage a rename or hand-edit leaves that
|
|
108
108
|
Claude Code's loader silently swallows.
|
|
109
109
|
|
|
110
110
|
To gate a consumer repo's docs (link/anchor integrity) at its own pre-commit,
|
|
@@ -218,15 +218,46 @@ fails loud on the former and warns on the latter.
|
|
|
218
218
|
|
|
219
219
|
## Quality gates
|
|
220
220
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
221
|
+
Quality gates run locally via git hooks, so code is
|
|
222
|
+
deployment-ready by the time it reaches the remote. GitHub
|
|
223
|
+
Actions re-runs `check` on push and publishes on a version tag;
|
|
224
|
+
the local hooks are the gate, CI is the backstop.
|
|
224
225
|
|
|
225
|
-
- **pre-commit**:
|
|
226
|
-
- **
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
226
|
+
- **pre-commit**: `npm run check` — the repo's full health gate.
|
|
227
|
+
- **commit-msg**: commitlint (house rules + the AI-attribution ban).
|
|
228
|
+
- **pre-push**: `npm run check`, then the version and tag guards.
|
|
229
|
+
|
|
230
|
+
**`check` owns the build.** A repo whose build fails is not
|
|
231
|
+
healthy, and any assertion that reads build output has to run
|
|
232
|
+
after the build in the same script — so `build` belongs in
|
|
233
|
+
`check`, and appears nowhere else. Declaring it in the pre-push
|
|
234
|
+
gate as well would give it two owners and build twice.
|
|
235
|
+
|
|
236
|
+
### What every repo's `check` must contain
|
|
237
|
+
|
|
238
|
+
`check` is a hand-written, ordered chain — the repos are
|
|
239
|
+
heterogeneous and some orderings are load-bearing — but its
|
|
240
|
+
**composition is enforced**, not remembered. Run `check-gates`
|
|
241
|
+
as its first step:
|
|
242
|
+
|
|
243
|
+
```json
|
|
244
|
+
"check": "check-gates && npm run lint && npm run typecheck && ..."
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
It fails if a required gate is missing, if one is defined but
|
|
248
|
+
never run (a dead gate), or if an exception outlives the problem
|
|
249
|
+
it was written for. Required: `lint`, `typecheck`, `test`,
|
|
250
|
+
`knip`, `jscpd`, `lint:md`, `spell`, `build`.
|
|
251
|
+
|
|
252
|
+
A repo opts out by declaring a **reason** — a blank one fails, so
|
|
253
|
+
an opt-out is never silent, and every exception is printed on
|
|
254
|
+
each run:
|
|
255
|
+
|
|
256
|
+
```json
|
|
257
|
+
"casomo": {
|
|
258
|
+
"gates": { "test": "content site — no suite yet" }
|
|
259
|
+
}
|
|
260
|
+
```
|
|
230
261
|
|
|
231
262
|
## Usage
|
|
232
263
|
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Assert that a repo's `check` script is COMPOSED correctly, before it runs.
|
|
3
|
+
//
|
|
4
|
+
// Each repo hand-writes `check` as an ordered `&&` chain, which is worth
|
|
5
|
+
// keeping: the repos are heterogeneous and some chains carry a real ordering
|
|
6
|
+
// constraint (build must precede any assertion that reads build output). What
|
|
7
|
+
// is not worth keeping is that chain being unverified — a gate can be dropped,
|
|
8
|
+
// or defined and never wired in, and nothing says a word.
|
|
9
|
+
//
|
|
10
|
+
// So this does not orchestrate the gates; it checks the composition:
|
|
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)
|
|
13
|
+
// 3. no exception outlives the problem it was written for
|
|
14
|
+
//
|
|
15
|
+
// A repo opts out by declaring a reason in its own package.json. A blank
|
|
16
|
+
// reason fails, so an opt-out can never be silent:
|
|
17
|
+
//
|
|
18
|
+
// "casomo": { "gates": { "test": "content site — no suite yet" } }
|
|
19
|
+
//
|
|
20
|
+
// Declared exceptions are printed on every run: excusing a gap must not be the
|
|
21
|
+
// same thing as hiding it.
|
|
22
|
+
import {readFileSync} from "node:fs";
|
|
23
|
+
import {join} from "node:path";
|
|
24
|
+
|
|
25
|
+
/** Gates every repo must define and run, absent a declared exception. */
|
|
26
|
+
export const REQUIRED = [
|
|
27
|
+
"lint",
|
|
28
|
+
"typecheck",
|
|
29
|
+
"test",
|
|
30
|
+
"knip",
|
|
31
|
+
"jscpd",
|
|
32
|
+
"lint:md",
|
|
33
|
+
"spell",
|
|
34
|
+
"build",
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
/** Gate-shaped but not mandatory: still dead if defined and never run. */
|
|
38
|
+
export const OPTIONAL = ["check:readability"];
|
|
39
|
+
|
|
40
|
+
/** A script is a gate only by exact membership or the `check:` prefix — never
|
|
41
|
+
* by heuristic, so `dev`, `start`, `clean`, `lint:fix`, `test:watch`,
|
|
42
|
+
* `build:clean` and `indexnow` need no denylist to be ignored. */
|
|
43
|
+
export const isGate = (name) =>
|
|
44
|
+
REQUIRED.includes(name) || OPTIONAL.includes(name) || name.startsWith("check:");
|
|
45
|
+
|
|
46
|
+
/** Gates `check` actually invokes. Segments are matched whole so that a repo
|
|
47
|
+
* running only `lint:md` never reads as also running `lint`. */
|
|
48
|
+
export const referencedGates = (check) =>
|
|
49
|
+
new Set(
|
|
50
|
+
String(check ?? "")
|
|
51
|
+
.split("&&")
|
|
52
|
+
.map((segment) => segment.trim().match(/^npm run ([\w:.-]+)/)?.[1])
|
|
53
|
+
.filter(Boolean),
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
export const evaluate = (pkg) => {
|
|
57
|
+
const scripts = pkg.scripts ?? {};
|
|
58
|
+
const exceptions = pkg.casomo?.gates ?? {};
|
|
59
|
+
const run = referencedGates(scripts.check);
|
|
60
|
+
|
|
61
|
+
// `check` itself is the chain, not a step within it.
|
|
62
|
+
const defined = Object.keys(scripts).filter((n) => isGate(n) && n !== "check");
|
|
63
|
+
|
|
64
|
+
const failures = [];
|
|
65
|
+
for (const gate of REQUIRED) {
|
|
66
|
+
if (!(gate in scripts)) failures.push([gate, `required gate is not defined`]);
|
|
67
|
+
else if (!run.has(gate))
|
|
68
|
+
failures.push([gate, `required gate is defined but \`check\` never runs it`]);
|
|
69
|
+
}
|
|
70
|
+
for (const gate of defined) {
|
|
71
|
+
if (run.has(gate)) continue;
|
|
72
|
+
if (failures.some(([g]) => g === gate)) continue; // already reported above
|
|
73
|
+
failures.push([gate, `defined but \`check\` never runs it — a dead gate`]);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const excused = [];
|
|
77
|
+
const unreasoned = [];
|
|
78
|
+
const live = [];
|
|
79
|
+
for (const [gate, reason] of failures) {
|
|
80
|
+
if (!(gate in exceptions)) {
|
|
81
|
+
live.push([gate, reason]);
|
|
82
|
+
} else if (!String(exceptions[gate] ?? "").trim()) {
|
|
83
|
+
unreasoned.push(gate);
|
|
84
|
+
} else {
|
|
85
|
+
excused.push([gate, exceptions[gate]]);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// An exception whose failure has gone away is the same rot in reverse.
|
|
90
|
+
const stale = Object.keys(exceptions).filter(
|
|
91
|
+
(gate) => !failures.some(([g]) => g === gate),
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
return {live, excused, unreasoned, stale};
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const main = () => {
|
|
98
|
+
const dir = process.cwd();
|
|
99
|
+
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
100
|
+
const {live, excused, unreasoned, stale} = evaluate(pkg);
|
|
101
|
+
|
|
102
|
+
for (const [gate, reason] of excused) {
|
|
103
|
+
console.log(`check-gates: skipping \`${gate}\` — ${reason}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const problems = [
|
|
107
|
+
...live.map(([gate, reason]) => `${gate}: ${reason}`),
|
|
108
|
+
...unreasoned.map(
|
|
109
|
+
(gate) => `${gate}: exception declared with no reason — say why, or fix it`,
|
|
110
|
+
),
|
|
111
|
+
...stale.map(
|
|
112
|
+
(gate) => `${gate}: exception is no longer needed — the gate passes, drop it`,
|
|
113
|
+
),
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
if (problems.length) {
|
|
117
|
+
console.error(
|
|
118
|
+
`✖ check-gates: ${problems.length} problem(s)\n ` +
|
|
119
|
+
problems.join("\n ") +
|
|
120
|
+
`\n\nDeclare an exception in package.json to opt out, with a reason:\n` +
|
|
121
|
+
` "casomo": { "gates": { "<gate>": "why this repo does not run it" } }`,
|
|
122
|
+
);
|
|
123
|
+
process.exit(1);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
console.log(
|
|
127
|
+
`check-gates OK — ${REQUIRED.length} required gates` +
|
|
128
|
+
(excused.length ? `, ${excused.length} excused` : ""),
|
|
129
|
+
);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
if (import.meta.url === `file://${process.argv[1]}`) main();
|
package/bin/pre-push.sh
CHANGED
|
@@ -4,14 +4,16 @@
|
|
|
4
4
|
PRE_PUSH_REFS=$(cat)
|
|
5
5
|
export PRE_PUSH_REFS
|
|
6
6
|
|
|
7
|
-
# Uniform pre-push for every repo:
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
# build
|
|
13
|
-
#
|
|
7
|
+
# Uniform pre-push for every repo: the repo's own health gate, then the
|
|
8
|
+
# release-protocol gates.
|
|
9
|
+
#
|
|
10
|
+
# Building belongs to `check`, not here. A repo whose build fails is not
|
|
11
|
+
# healthy, and any assertion that reads build output (SSR, page meta, sitemap)
|
|
12
|
+
# has to run after the build inside that same script — so `check` is where the
|
|
13
|
+
# step already has to live. Declaring it here as well would give the build two
|
|
14
|
+
# owners and run it twice wherever `check` builds. A repo that needs a build
|
|
15
|
+
# gate adds `npm run build` to its own `check`; one that produces no artifact
|
|
16
|
+
# simply doesn't.
|
|
14
17
|
npm run check \
|
|
15
|
-
&& npm run build \
|
|
16
18
|
&& echo "$PRE_PUSH_REFS" | check-version \
|
|
17
19
|
&& echo "$PRE_PUSH_REFS" | check-tags
|
package/bin/verify-pack.mjs
CHANGED
|
@@ -25,6 +25,11 @@ const ALLOWED_FILES = ["README.md", "package.json", "LICENSE", "LICENSE.md"];
|
|
|
25
25
|
const SECRET_PATTERN =
|
|
26
26
|
/(^|\/)(\.env(\..*)?|.*settings\.local\.json|.*\.pem|id_[rd]sa|.*\.key|\.npmrc)$/i;
|
|
27
27
|
|
|
28
|
+
// Tests live beside what they test, which puts them inside an allowed dir —
|
|
29
|
+
// so the allowlist alone would ship them. Consumers get the tool, not its
|
|
30
|
+
// test suite.
|
|
31
|
+
const TEST_PATTERN = /\.test\.[cm]?[jt]s$/i;
|
|
32
|
+
|
|
28
33
|
const raw = execSync("npm pack --dry-run --json", {encoding: "utf8"});
|
|
29
34
|
const files = JSON.parse(raw)[0].files.map((entry) => entry.path);
|
|
30
35
|
|
|
@@ -34,14 +39,18 @@ const escaped = files.filter(
|
|
|
34
39
|
!ALLOWED_PREFIXES.some((prefix) => f.startsWith(prefix)),
|
|
35
40
|
);
|
|
36
41
|
const secrets = files.filter((f) => SECRET_PATTERN.test(f));
|
|
42
|
+
const tests = files.filter((f) => TEST_PATTERN.test(f));
|
|
37
43
|
|
|
38
|
-
if (escaped.length || secrets.length) {
|
|
44
|
+
if (escaped.length || secrets.length || tests.length) {
|
|
39
45
|
if (escaped.length) {
|
|
40
46
|
console.error("✖ files outside the allowlist:\n " + escaped.join("\n "));
|
|
41
47
|
}
|
|
42
48
|
if (secrets.length) {
|
|
43
49
|
console.error("✖ secret-like files in package:\n " + secrets.join("\n "));
|
|
44
50
|
}
|
|
51
|
+
if (tests.length) {
|
|
52
|
+
console.error("✖ test files in package:\n " + tests.join("\n "));
|
|
53
|
+
}
|
|
45
54
|
process.exit(1);
|
|
46
55
|
}
|
|
47
56
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@casomoltd/tooling",
|
|
3
|
-
"version": "0.23.
|
|
3
|
+
"version": "0.23.3",
|
|
4
4
|
"description": "Casomo's shared dev config: ESLint, Prettier, commitlint, knip & jscpd presets and dev CLIs (git hooks, readability, screenshots)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -16,14 +16,26 @@
|
|
|
16
16
|
"jscpd/",
|
|
17
17
|
"readability/",
|
|
18
18
|
"report/",
|
|
19
|
-
"README.md"
|
|
19
|
+
"README.md",
|
|
20
|
+
"!**/*.test.mjs"
|
|
20
21
|
],
|
|
21
22
|
"scripts": {
|
|
22
|
-
"check": "node bin/verify-pack.mjs &&
|
|
23
|
-
"
|
|
24
|
-
"
|
|
23
|
+
"check": "node bin/check-gates.mjs && node bin/verify-pack.mjs && npm run typecheck && npm run test && npm run lint:md",
|
|
24
|
+
"typecheck": "tsc --noEmit -p hooks/tsconfig.json",
|
|
25
|
+
"test": "node hooks/test.mts && node bin/check-gates.test.mjs",
|
|
26
|
+
"lint:md": "remark --frail --quiet --no-stdout --rc-path bin/skills-lint.config.mjs skills docs",
|
|
27
|
+
"build": "echo 'no-op: source-only repo, nothing to build'",
|
|
25
28
|
"prepare": "husky || true"
|
|
26
29
|
},
|
|
30
|
+
"casomo": {
|
|
31
|
+
"gates": {
|
|
32
|
+
"lint": "publishes the shared eslint config but has none of its own yet",
|
|
33
|
+
"knip": "no knip config for this repo's mixed .mts/.mjs layout yet",
|
|
34
|
+
"jscpd": "no jscpd config for this repo yet",
|
|
35
|
+
"spell": "no shared cspell config exists yet",
|
|
36
|
+
"build": "source-only repo — build is a deliberate no-op, nothing to verify"
|
|
37
|
+
}
|
|
38
|
+
},
|
|
27
39
|
"exports": {
|
|
28
40
|
"./eslint": "./eslint/index.mjs",
|
|
29
41
|
"./eslint-lib": "./eslint/lib.mjs",
|
|
@@ -35,6 +47,7 @@
|
|
|
35
47
|
},
|
|
36
48
|
"bin": {
|
|
37
49
|
"build-report": "./bin/build-report.mjs",
|
|
50
|
+
"check-gates": "./bin/check-gates.mjs",
|
|
38
51
|
"check-tags": "./bin/check-tags.sh",
|
|
39
52
|
"check-version": "./bin/check-version.sh",
|
|
40
53
|
"commit-msg": "./bin/commit-msg.sh",
|