@krizaka/config 0.10.0 → 0.11.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 +10 -0
- package/bin/krizaka-ratchet.mjs +2 -1
- package/eslint/stack.js +13 -2
- package/package.json +2 -1
- package/ratchet/ratchet.js +40 -5
package/README.md
CHANGED
|
@@ -97,6 +97,15 @@ read any file (a Vite/Tauri app has no `"use client"`).
|
|
|
97
97
|
2. **`krizaka/no-derived-state-in-effect`** — an effect whose only statements are `setX(…)` calls: compute the value
|
|
98
98
|
during render, or reset the component with a `key`.
|
|
99
99
|
|
|
100
|
+
### Cognitive complexity (Sonar S3776)
|
|
101
|
+
|
|
102
|
+
On 2026-10-27 every failed quality gate came from Sonar's S3776 (krizaka/.github#319), and only Sonar checked it.
|
|
103
|
+
`krizakaStack()` now carries **`sonarjs/cognitive-complexity` at 15** (`eslint-plugin-sonarjs`; the threshold is never
|
|
104
|
+
raised, the rule never disabled): split the function (early returns, extracted helpers, a lookup table instead of a
|
|
105
|
+
chain of `if`). **Error by default** (`complexity: "error"`); a repository with debt passes `complexity: "warn"` while
|
|
106
|
+
`krizaka-ratchet` (`cognitiveComplexity`) freezes the count of functions over 15 at `--update` time and fails any
|
|
107
|
+
increase. Test files (`*.test.*`, `*.spec.*`, `__tests__/`) are not counted, as Sonar does not scan them.
|
|
108
|
+
|
|
100
109
|
### The three stack rules
|
|
101
110
|
|
|
102
111
|
From [ADR-001](https://github.com/krizaka/krizaka-ui/blob/main/docs/adr/ADR-001-tanstack-under-krizaka-facades.md) — the TanStack engines run headless, behind
|
|
@@ -229,6 +238,7 @@ npx krizaka-ratchet --json # in CI: the same check, machine-readable
|
|
|
229
238
|
| `directTanstackImport` | `from` / `import` / `import(` / `require(` of `"@tanstack/…"` |
|
|
230
239
|
| `localTableOrForm` | `<table`, and `<form` … `</form>` holding more fields than `maxFormFields` (default 1) |
|
|
231
240
|
| `retiredImport` | an import of a dependency listed in `retire` (e.g. `["framer-motion"]`): the way out of a dependency |
|
|
241
|
+
| `cognitiveComplexity` | functions whose `sonarjs/cognitive-complexity` is above 15 (Sonar S3776), counted by running the rule on each source file of `stackGlobs`, tests excluded; the KPI of the cognitive debt, it only goes down |
|
|
232
242
|
| `routeStateSelection` | a Tabs / chip group / choice grid / segmented switch (incl. `RadioGroup.Root variant="segmented"`) / toggle group of a view driven by local state (`value`, `defaultValue`, `onValueChange`) without `routeKey`, and without `// @ephemeral: <reason>`: the URL is the state |
|
|
233
243
|
|
|
234
244
|
The UI counters scan `app/**/*.tsx`, `components/**/*.tsx` and `src/**/*.tsx` by default (`node_modules`, `dist`,
|
package/bin/krizaka-ratchet.mjs
CHANGED
|
@@ -8,7 +8,7 @@ const USAGE = `Usage: krizaka-ratchet [globs…] [--init | --update] [--json] [-
|
|
|
8
8
|
|
|
9
9
|
Counts the UI debts — raw palette colours, light: variants, [var(--…)] utilities, className template strings — in the
|
|
10
10
|
source files (default: app/**/*.tsx components/**/*.tsx src/**/*.tsx), and the stack debts — fetch in a client effect,
|
|
11
|
-
data loaded in an effect, state derived in an effect, direct @tanstack/* imports, hand-written tables and forms, imports of the dependencies listed in "retire" — in every
|
|
11
|
+
data loaded in an effect, state derived in an effect, direct @tanstack/* imports, hand-written tables and forms, functions above Sonar's cognitive complexity of 15 (S3776), imports of the dependencies listed in "retire" — in every
|
|
12
12
|
source file of app, components, src, lib, hooks and features; compares them with lint-ratchet.json.
|
|
13
13
|
|
|
14
14
|
(no flag) fail (exit 1) when a counter is above its recorded value
|
|
@@ -30,6 +30,7 @@ const LABELS = {
|
|
|
30
30
|
localTableOrForm: "hand-written tables / forms",
|
|
31
31
|
retiredImport: "imports of retired deps",
|
|
32
32
|
routeStateSelection: "selections in local state (not the URL)",
|
|
33
|
+
cognitiveComplexity: "functions over complexity 15 (S3776)",
|
|
33
34
|
};
|
|
34
35
|
|
|
35
36
|
let args;
|
package/eslint/stack.js
CHANGED
|
@@ -2,8 +2,13 @@
|
|
|
2
2
|
// @krizaka/ui. The import of TanStack (S11) is a dependency-cruiser rule now, `krizaka-first-query`: one tool, one rule.
|
|
3
3
|
// Warnings: krizaka-ratchet counts the same patterns, so an app adopts them now and brings each count to 0 by the end
|
|
4
4
|
// of its adoption.
|
|
5
|
+
import sonarjs from "eslint-plugin-sonarjs";
|
|
6
|
+
|
|
5
7
|
import { krizakaPlugin } from "./plugin.js";
|
|
6
8
|
|
|
9
|
+
/** Sonar S3776 threshold: a function above it is a quality-gate failure. Never raised. */
|
|
10
|
+
export const COGNITIVE_COMPLEXITY_MAX = 15;
|
|
11
|
+
|
|
7
12
|
/** The rule names, as configured. */
|
|
8
13
|
export const STACK_RULES = Object.freeze([
|
|
9
14
|
"krizaka/no-fetch-in-client-effect",
|
|
@@ -12,13 +17,16 @@ export const STACK_RULES = Object.freeze([
|
|
|
12
17
|
"krizaka/no-local-table-or-form",
|
|
13
18
|
"krizaka/no-family-switcher",
|
|
14
19
|
"krizaka/route-state-selection",
|
|
20
|
+
"sonarjs/cognitive-complexity",
|
|
15
21
|
]);
|
|
16
22
|
|
|
17
23
|
/**
|
|
18
24
|
* Flat config: the stack rules on an app's sources.
|
|
19
|
-
* @param {{ files?: string[], ignores?: string[], severity?: "warn" | "error", effects?: "warn" | "error" | "off", maxFormFields?: number }} [options]
|
|
25
|
+
* @param {{ files?: string[], ignores?: string[], severity?: "warn" | "error", effects?: "warn" | "error" | "off", complexity?: "warn" | "error", maxFormFields?: number }} [options]
|
|
20
26
|
* `effects`: severity of the two effect rules (react-effects standard): `"error"` by default; a repository with debt
|
|
21
27
|
* passes `"warn"` while `krizaka-ratchet` (asyncInEffect, derivedStateInEffect) brings the count to 0.
|
|
28
|
+
* `complexity`: severity of `sonarjs/cognitive-complexity` (Sonar S3776, threshold 15): `"error"` by default; a
|
|
29
|
+
* repository with debt passes `"warn"` while `krizaka-ratchet` (`cognitiveComplexity`) freezes the count and fails any increase.
|
|
22
30
|
* `maxFormFields`: a form with at most this many fields is allowed (default 1 — a search box, a newsletter).
|
|
23
31
|
* @returns {import("eslint").Linter.Config[]}
|
|
24
32
|
*/
|
|
@@ -27,6 +35,7 @@ export function krizakaStack({
|
|
|
27
35
|
ignores = [],
|
|
28
36
|
severity = "warn",
|
|
29
37
|
effects = "error",
|
|
38
|
+
complexity = "error",
|
|
30
39
|
maxFormFields = 1,
|
|
31
40
|
} = {}) {
|
|
32
41
|
return [
|
|
@@ -34,7 +43,7 @@ export function krizakaStack({
|
|
|
34
43
|
name: "krizaka/stack",
|
|
35
44
|
files,
|
|
36
45
|
...(ignores.length > 0 ? { ignores } : {}),
|
|
37
|
-
plugins: { krizaka: krizakaPlugin },
|
|
46
|
+
plugins: { krizaka: krizakaPlugin, sonarjs },
|
|
38
47
|
rules: {
|
|
39
48
|
"krizaka/no-fetch-in-client-effect": severity,
|
|
40
49
|
// useEffect is for the outside world: data is useQuery/useMutation, derived state is computed in render.
|
|
@@ -45,6 +54,8 @@ export function krizakaStack({
|
|
|
45
54
|
"krizaka/no-family-switcher": "error",
|
|
46
55
|
// A selection of a view (tabs, segmented, toggles, chip groups) lives in the URL, not in local state.
|
|
47
56
|
"krizaka/route-state-selection": severity,
|
|
57
|
+
// Sonar S3776 at lint time (krizaka/.github#319): split the function, never raise the threshold.
|
|
58
|
+
"sonarjs/cognitive-complexity": [complexity, COGNITIVE_COMPLEXITY_MAX],
|
|
48
59
|
},
|
|
49
60
|
},
|
|
50
61
|
];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@krizaka/config",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "The shared configuration of every Krizaka JavaScript repository: ESLint flat configs (base, Next.js, the four UI rules, the stack rules, no locale ternary), the « Krizaka first » covered-domains map and the dependency-cruiser preset generated from it (refused imports, cycles, unresolvable imports, dev dependencies), tsconfig bases, Prettier, EditorConfig, and the krizaka-ratchet and krizaka-known-violations debt counters.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"krizaka",
|
|
@@ -72,6 +72,7 @@
|
|
|
72
72
|
"dependency-cruiser": "^18.6.0",
|
|
73
73
|
"eslint-plugin-react-hooks": "^7.1.1",
|
|
74
74
|
"eslint-plugin-simple-import-sort": "^14.0.0",
|
|
75
|
+
"eslint-plugin-sonarjs": "^4.2.2",
|
|
75
76
|
"globals": "^17.13.0",
|
|
76
77
|
"tinyglobby": "^0.2.17",
|
|
77
78
|
"typescript-eslint": "^8.71.1"
|
package/ratchet/ratchet.js
CHANGED
|
@@ -2,9 +2,12 @@
|
|
|
2
2
|
// Same patterns as the ESLint rules (../patterns.js), applied to the source text: the counter sees every
|
|
3
3
|
// occurrence (a class in a `cn()` argument or a constant too), so it can only be stricter than the rule.
|
|
4
4
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
-
import { isAbsolute, relative, resolve } from "node:path";
|
|
5
|
+
import { extname, isAbsolute, relative, resolve } from "node:path";
|
|
6
6
|
|
|
7
|
+
import { Linter } from "eslint";
|
|
8
|
+
import sonarjs from "eslint-plugin-sonarjs";
|
|
7
9
|
import { glob } from "tinyglobby";
|
|
10
|
+
import tseslint from "typescript-eslint";
|
|
8
11
|
|
|
9
12
|
import {
|
|
10
13
|
ARBITRARY_VAR_SOURCE,
|
|
@@ -34,7 +37,7 @@ export const DEFAULT_STACK_GLOBS = Object.freeze(
|
|
|
34
37
|
const IGNORE = ["**/node_modules/**", "**/dist/**", "**/.next/**", "**/storybook-static/**", "**/coverage/**"];
|
|
35
38
|
|
|
36
39
|
/** @typedef {"palette" | "light" | "arbitraryVar" | "classNameTemplate"} UiCounterName */
|
|
37
|
-
/** @typedef {"fetchInClientEffect" | "asyncInEffect" | "derivedStateInEffect" | "directTanstackImport" | "localTableOrForm" | "retiredImport" | "routeStateSelection"} StackCounterName */
|
|
40
|
+
/** @typedef {"fetchInClientEffect" | "asyncInEffect" | "derivedStateInEffect" | "directTanstackImport" | "localTableOrForm" | "retiredImport" | "routeStateSelection" | "cognitiveComplexity"} StackCounterName */
|
|
38
41
|
/** @typedef {UiCounterName | StackCounterName} CounterName */
|
|
39
42
|
/** @typedef {Record<CounterName, number>} Counters */
|
|
40
43
|
/**
|
|
@@ -58,9 +61,13 @@ export const STACK_COUNTERS = /** @type {const} */ ([
|
|
|
58
61
|
"localTableOrForm",
|
|
59
62
|
"retiredImport",
|
|
60
63
|
"routeStateSelection",
|
|
64
|
+
"cognitiveComplexity",
|
|
61
65
|
]);
|
|
62
66
|
/** Every counter, in report order. */
|
|
63
67
|
export const COUNTERS = /** @type {const} */ ([...UI_COUNTERS, ...STACK_COUNTERS]);
|
|
68
|
+
/** Sonar S3776's threshold (same value as eslint/stack.js COGNITIVE_COMPLEXITY_MAX). Never raised. */
|
|
69
|
+
const COMPLEXITY_MAX = 15;
|
|
70
|
+
const TEST_FILE = /(\.(test|spec)\.[cm]?[jt]sx?$)|(\/__tests__\/)/;
|
|
64
71
|
const zero = () => /** @type {Counters} */ (Object.fromEntries(COUNTERS.map((name) => [name, 0])));
|
|
65
72
|
|
|
66
73
|
/**
|
|
@@ -95,7 +102,7 @@ export function countSource(source, allow = []) {
|
|
|
95
102
|
* `@tanstack/*`, hand-written tables and forms of more than `maxFormFields` fields, imports of the retired dependencies.
|
|
96
103
|
* @param {string} source
|
|
97
104
|
* @param {{ retire?: readonly string[], maxFormFields?: number }} [options]
|
|
98
|
-
* @returns {Record<StackCounterName, number>}
|
|
105
|
+
* @returns {Record<Exclude<StackCounterName, "cognitiveComplexity">, number>} (cognitiveComplexity: countCognitiveComplexity, it needs the file name)
|
|
99
106
|
*/
|
|
100
107
|
export function countStackSource(source, { retire = [], maxFormFields = 1 } = {}) {
|
|
101
108
|
let fetchInClientEffect = 0;
|
|
@@ -133,6 +140,32 @@ export function countStackSource(source, { retire = [], maxFormFields = 1 } = {}
|
|
|
133
140
|
};
|
|
134
141
|
}
|
|
135
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Counts the functions above Sonar's cognitive-complexity threshold (S3776) in one source text, with the same
|
|
145
|
+
* `sonarjs/cognitive-complexity` rule the ESLint preset uses. A file that does not parse counts for 0 (ESLint reports it).
|
|
146
|
+
* @param {string} source
|
|
147
|
+
* @param {string} [filename]
|
|
148
|
+
* @returns {number}
|
|
149
|
+
*/
|
|
150
|
+
export function countCognitiveComplexity(source, filename = "file.tsx") {
|
|
151
|
+
const messages = complexityLinter().verify(source, COMPLEXITY_CONFIG, { filename: `file${extname(filename)}` });
|
|
152
|
+
return messages.filter((/** @type {{ ruleId: string | null }} */ m) => m.ruleId === "sonarjs/cognitive-complexity").length;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** One Linter for the whole run: building it (and the parser) per file made the CLI 10x slower in CI. */
|
|
156
|
+
/** @type {Linter | undefined} */
|
|
157
|
+
let sharedLinter;
|
|
158
|
+
const complexityLinter = () => (sharedLinter ??= new Linter());
|
|
159
|
+
/** @type {any} */
|
|
160
|
+
const COMPLEXITY_CONFIG = [
|
|
161
|
+
{
|
|
162
|
+
files: ["**/*.{js,jsx,mjs,cjs,ts,tsx,mts,cts}"],
|
|
163
|
+
languageOptions: { parser: /** @type {any} */ (tseslint.parser) },
|
|
164
|
+
plugins: { sonarjs },
|
|
165
|
+
rules: { "sonarjs/cognitive-complexity": ["error", COMPLEXITY_MAX] },
|
|
166
|
+
},
|
|
167
|
+
];
|
|
168
|
+
|
|
136
169
|
/**
|
|
137
170
|
* Counts the debts under `root`: the UI counters in the files of `globs`, the stack counters in those of `stackGlobs`.
|
|
138
171
|
* @param {{ root: string, globs?: readonly string[], stackGlobs?: readonly string[], allow?: string[],
|
|
@@ -155,8 +188,10 @@ export async function countRepository({
|
|
|
155
188
|
for (const name of UI_COUNTERS) counters[name] += found[name];
|
|
156
189
|
}
|
|
157
190
|
for (const file of stackFiles) {
|
|
158
|
-
const found = countStackSource(readFileSync(file, "utf8"), { retire, maxFormFields });
|
|
159
|
-
for (const name of STACK_COUNTERS) counters[name] += found[name];
|
|
191
|
+
const found = /** @type {Partial<Counters>} */ (countStackSource(readFileSync(file, "utf8"), { retire, maxFormFields }));
|
|
192
|
+
for (const name of STACK_COUNTERS) counters[name] += found[name] ?? 0;
|
|
193
|
+
// Sonar scans the product code, not its tests
|
|
194
|
+
if (!TEST_FILE.test(file)) counters.cognitiveComplexity += countCognitiveComplexity(readFileSync(file, "utf8"), file);
|
|
160
195
|
}
|
|
161
196
|
return { counters, files: files.length, stackFiles: stackFiles.length };
|
|
162
197
|
}
|