@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 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:skills`) — catching the breakage a rename or hand-edit leaves that
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
- There is no CI — all quality gates run locally via git hooks.
222
- Code should be deployment-ready by the time it's pushed to
223
- the remote.
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**: Runs commitlint only (fast).
226
- - **pre-push**: Runs the full suite — `npm run check`,
227
- `npm run build`, then version and tag guards. This ensures
228
- lint, typecheck, spelling, and version bumps are all verified
229
- before code reaches the remote.
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: check, build, then the version/tag gates.
8
- # `npm run build` must exist in EVERY repo so this line never has to special-case
9
- # one. Where a repo produces no build output (a source-only repo like tooling,
10
- # consumed as a git-dep), define build as a no-op (`"build": "echo ..."`) rather
11
- # than omitting it — a missing script would abort the push with "Missing script:
12
- # build", which once made it ambiguous whether a source-only repo was even
13
- # pushable. No-op keeps the step honest and uniform.
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
@@ -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.1",
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 && tsc --noEmit -p hooks/tsconfig.json && node hooks/test.mts && npm run lint:skills",
23
- "lint:skills": "remark --frail --quiet --no-stdout --rc-path bin/skills-lint.config.mjs skills docs",
24
- "build": "echo 'no-op: source-only repo (consumed as a git-dep), nothing to build'",
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",