@casomoltd/tooling 0.36.1 → 0.37.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,8 +8,8 @@ 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 |
@@ -25,7 +25,8 @@ Shared linting, formatting, commit config, and CLI tools for Casomo Ltd's repos.
25
25
  | `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
26
  | `pre-push` | Husky pre-push hook — runs check, then the version, tag and linear-history guards |
27
27
  | `pre-commit` | Husky pre-commit hook — runs `npm run check` |
28
- | `commit-msg` | Husky commit-msg hook — runs commitlint |
28
+ | `commit-msg` | Husky commit-msg hook — runs commitlint, then `check-listed-files` |
29
+ | `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
30
  | `readability` | Measure reading difficulty of page content |
30
31
  | `screenshot` | Capture a dev server page via Playwright — an agent's visual feedback loop |
31
32
  | `build-report` | Compile a Typst client report to PDF with the house template |
@@ -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
+ }
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"
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@casomoltd/tooling",
3
- "version": "0.36.1",
3
+ "version": "0.37.0",
4
4
  "description": "Casomo's shared dev config: ESLint, Prettier, commitlint, knip & jscpd presets and dev CLIs (git hooks, readability, screenshots, design specs)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -22,9 +22,10 @@
22
22
  "!**/*.test.mjs"
23
23
  ],
24
24
  "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",
25
+ "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",
26
+ "lint": "eslint .",
26
27
  "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",
28
+ "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/casomo-config.test.mjs && node bin/buttondown.test.mjs && node commitlint/index.test.mjs && node readability/index.test.mjs",
28
29
  "lint:md": "remark --frail --quiet --no-stdout --rc-path bin/skills-lint.config.mjs skills agents docs",
29
30
  "build": "echo 'no-op: source-only repo, nothing to build'",
30
31
  "prepare": "husky || true",
@@ -33,7 +34,6 @@
33
34
  },
34
35
  "casomo": {
35
36
  "gates": {
36
- "lint": "publishes the shared eslint config but has none of its own yet",
37
37
  "knip": "no knip config for this repo's mixed .mts/.mjs layout yet",
38
38
  "jscpd": "no jscpd config for this repo yet",
39
39
  "spell": "no shared cspell config exists yet",
@@ -58,6 +58,7 @@
58
58
  "buttondown": "./bin/buttondown.mjs",
59
59
  "check-gates": "./bin/check-gates.mjs",
60
60
  "check-hooks": "./bin/check-hooks.sh",
61
+ "check-listed-files": "./bin/check-listed-files.mjs",
61
62
  "check-linear": "./bin/check-linear.sh",
62
63
  "check-personal-data": "./bin/check-personal-data.mjs",
63
64
  "check-private-refs": "./bin/check-private-refs.mjs",
@@ -80,7 +81,7 @@
80
81
  },
81
82
  "peerDependencies": {
82
83
  "@commitlint/cli": ">=20",
83
- "eslint": ">=9",
84
+ "eslint": ">=9.24",
84
85
  "eslint-config-next": ">=16",
85
86
  "eslint-plugin-sonarjs": ">=3",
86
87
  "jscpd": ">=4",
@@ -89,12 +90,15 @@
89
90
  },
90
91
  "devDependencies": {
91
92
  "@types/node": "^26.0.0",
93
+ "eslint": "^9.39.5",
94
+ "eslint-plugin-sonarjs": "^4.2.2",
92
95
  "husky": "^9.1.7",
93
96
  "remark-cli": "^12.0.1",
94
97
  "remark-frontmatter": "^5.0.0",
95
98
  "remark-lint-frontmatter-schema": "^3.15.4",
96
99
  "remark-validate-links": "^13.1.0",
97
- "typescript": "^6.0.3"
100
+ "typescript": "^6.0.3",
101
+ "typescript-eslint": "^8.72.0"
98
102
  },
99
103
  "overrides": {
100
104
  "remark-lint-frontmatter-schema": {