@krizaka/config 0.10.0 → 0.11.1

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
@@ -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`,
@@ -8,12 +8,12 @@ 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
15
15
  --init create lint-ratchet.json with the current counters
16
- --update lower the recorded counters to the current values (never raises them), record the new ones
16
+ --update lower the recorded counters to the current values (never raises them), record the new ones, keep the counters it does not know
17
17
  --json machine-readable report on stdout (for CI)
18
18
  --root repository root (default: current directory)
19
19
  --file ratchet file name (default: lint-ratchet.json)`;
@@ -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;
@@ -81,6 +82,9 @@ try {
81
82
  else if (result.written) console.log(`${result.decreased.length > 0 ? "Lowered" : "Completed"} ${result.file}: commit it.`);
82
83
  else if (result.decreased.length > 0) console.log(`Debt went down: run krizaka-ratchet --update and commit ${result.file}.`);
83
84
  else if (result.added.length > 0) console.log(`New counters (${result.added.join(", ")}): run krizaka-ratchet --update and commit ${result.file}.`);
85
+ if (result.unmeasured.length > 0) {
86
+ console.log(` ! not measured by this version, kept as recorded: ${result.unmeasured.join(", ")}`);
87
+ }
84
88
  if (!result.ok) {
85
89
  console.error(
86
90
  `Debt went up (${result.increased.join(", ")}): roles of @krizaka/tailwind and cn() for the UI; @krizaka/query, @krizaka/ui/data-table and @krizaka/ui/form for the stack.`,
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.10.0",
3
+ "version": "0.11.1",
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"
@@ -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
  }
@@ -202,6 +237,7 @@ export class RatchetError extends Error {}
202
237
  * @property {CounterName[]} increased
203
238
  * @property {CounterName[]} decreased
204
239
  * @property {CounterName[]} added counters missing from the file (recorded at their current value by --update)
240
+ * @property {string[]} unmeasured counters of the file this version does not measure: kept as recorded by --update, never dropped
205
241
  * @property {boolean} written the ratchet file was created, lowered or completed
206
242
  */
207
243
 
@@ -260,6 +296,8 @@ export async function runRatchet({ root, mode = "check", globs, file = RATCHET_F
260
296
  });
261
297
  /** @type {CounterName[]} */ const added = recordedFile ? COUNTERS.filter((n) => recordedFile.counters[n] === undefined) : [];
262
298
  const recorded = /** @type {Counters} */ ({ ...current, ...recordedFile?.counters });
299
+ /** @type {string[]} */
300
+ const unmeasured = Object.keys(recordedFile?.counters ?? {}).filter((n) => !COUNTERS.includes(/** @type {CounterName} */ (n)));
263
301
 
264
302
  const { report, increased, decreased } = compareCounters(current, recorded);
265
303
 
@@ -270,7 +308,9 @@ export async function runRatchet({ root, mode = "check", globs, file = RATCHET_F
270
308
  } else if (mode === "update" && (decreased.length > 0 || added.length > 0) && recordedFile) {
271
309
  const lowered = zero();
272
310
  for (const name of COUNTERS) lowered[name] = Math.min(recorded[name], current[name]);
273
- writeRatchet(path, { ...recordedFile, stackGlobs: recordedFile.stackGlobs ?? stackGlobs, counters: lowered });
311
+ // a counter this version does not measure (a repository's own, or a newer preset's) is kept as recorded, never dropped
312
+ const counters = { ...lowered, ...Object.fromEntries(unmeasured.map((name) => [name, /** @type {Record<string, number>} */ (recordedFile?.counters)?.[name]])) };
313
+ writeRatchet(path, { ...recordedFile, stackGlobs: recordedFile.stackGlobs ?? stackGlobs, counters });
274
314
  for (const name of decreased) report[name].recorded = lowered[name];
275
315
  written = true;
276
316
  }
@@ -288,6 +328,7 @@ export async function runRatchet({ root, mode = "check", globs, file = RATCHET_F
288
328
  increased,
289
329
  decreased,
290
330
  added,
331
+ unmeasured,
291
332
  written,
292
333
  };
293
334
  }