@krizaka/config 0.11.0 → 0.12.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
@@ -42,22 +42,22 @@ the package.
42
42
 
43
43
  ## What is inside
44
44
 
45
- | Export | What it gives |
46
- | :-- | :-- |
47
- | `@krizaka/config/eslint` | `krizakaBase`: `@eslint/js` + `typescript-eslint` recommended, React hooks, import ordering (a warning, autofixable), ignores of generated output, `_`-prefixed unused arguments allowed. |
48
- | `@krizaka/config/eslint/next` | `krizakaNext`: `eslint-config-next` (core web vitals + TypeScript, which already carry typescript-eslint, React hooks and import) + the same basics. |
49
- | `@krizaka/config/eslint/krizaka-ui` | `krizakaUi(options?)`: the four UI rules below. Also `krizakaUiRules` and `krizakaUiRestrictedSyntax(options?)`. |
50
- | `@krizaka/config/eslint/no-locale-ternary` | `noLocaleTernary(options?)`: `krizaka/no-locale-ternary` — no `locale === "fr" ? … : …` outside routing code. |
51
- | `@krizaka/config/eslint/ui-warnings` | `krizakaUiWarnings(options?)`: the same two debts as named plugin rules, as warnings — `krizaka/no-light-variant` (no `light:`: tokens do the theming) and `krizaka/no-raw-palette` (no `bg-zinc-900`, `text-violet-400`: a role; `allow` families). `krizaka-ratchet` counts them (`light`, `palette`), so a product adopts them now and brings the counts to 0. |
52
- | `@krizaka/config/eslint/stack` | `krizakaStack(options?)`: the three stack rules below (warnings). Also `STACK_RULES`. |
53
- | `@krizaka/config/eslint/plugin` | `krizakaPlugin`: the `krizaka` plugin carrying every rule, for a hand-written config. |
54
- | `@krizaka/config/dependency-cruiser` | The shared dependency-cruiser config, generated from the covered-domains map: one `krizaka-first-<domain>` rule per domain (dates, i18n, query, icons, ui), `no-circular`, `not-to-unresolvable`, `not-to-dev-dep`. `.withOptions({ exemptFrom })` for a monorepo whose own packages are the facades. |
55
- | `@krizaka/config/covered-domains` · `/covered-domains.json` | The covered-domains map (one data file) and its helpers: the message, the patterns, the Markdown table of « Use Krizaka first ». |
56
- | `krizaka-known-violations` (bin) · `@krizaka/config/known-violations` | The known violations of dependency-cruiser, which only shrink, see below. |
57
- | `krizaka-server-client-boundary` (bin) · `@krizaka/config/server-client-boundary` | Fails when a server-rendered module imports a function from a `"use client"` `@krizaka/*` module (React #441), see below. |
58
- | `@krizaka/config/tsconfig/*.json` | `base` (strict, ES2022, bundler resolution), `react-library` (+ DOM, `react-jsx`), `next` (+ the Next plugin, `noEmit`, `incremental`), `react-native` (`jsx: react-native`, `customConditions`; with Expo: `"extends": ["expo/tsconfig.base", "@krizaka/config/tsconfig/react-native.json"]`). |
59
- | `@krizaka/config/prettier` · `.editorconfig` | The formatting. |
60
- | `krizaka-ratchet` (bin) | The debt counter, see below. |
45
+ | Export | What it gives |
46
+ | :-------------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
47
+ | `@krizaka/config/eslint` | `krizakaBase`: `@eslint/js` + `typescript-eslint` recommended, React hooks, import ordering (a warning, autofixable), ignores of generated output, `_`-prefixed unused arguments allowed. |
48
+ | `@krizaka/config/eslint/next` | `krizakaNext`: `eslint-config-next` (core web vitals + TypeScript, which already carry typescript-eslint, React hooks and import) + the same basics. |
49
+ | `@krizaka/config/eslint/krizaka-ui` | `krizakaUi(options?)`: the four UI rules below. Also `krizakaUiRules` and `krizakaUiRestrictedSyntax(options?)`. |
50
+ | `@krizaka/config/eslint/no-locale-ternary` | `noLocaleTernary(options?)`: `krizaka/no-locale-ternary` — no `locale === "fr" ? … : …` outside routing code. |
51
+ | `@krizaka/config/eslint/ui-warnings` | `krizakaUiWarnings(options?)`: the same two debts as named plugin rules, as warnings — `krizaka/no-light-variant` (no `light:`: tokens do the theming) and `krizaka/no-raw-palette` (no `bg-zinc-900`, `text-violet-400`: a role; `allow` families). `krizaka-ratchet` counts them (`light`, `palette`), so a product adopts them now and brings the counts to 0. |
52
+ | `@krizaka/config/eslint/stack` | `krizakaStack(options?)`: the three stack rules below (warnings). Also `STACK_RULES`. |
53
+ | `@krizaka/config/eslint/plugin` | `krizakaPlugin`: the `krizaka` plugin carrying every rule, for a hand-written config. |
54
+ | `@krizaka/config/dependency-cruiser` | The shared dependency-cruiser config, generated from the covered-domains map: one `krizaka-first-<domain>` rule per domain (dates, i18n, query, icons, ui), `no-circular`, `not-to-unresolvable`, `not-to-dev-dep`. `.withOptions({ exemptFrom })` for a monorepo whose own packages are the facades. |
55
+ | `@krizaka/config/covered-domains` · `/covered-domains.json` | The covered-domains map (one data file) and its helpers: the message, the patterns, the Markdown table of « Use Krizaka first ». |
56
+ | `krizaka-known-violations` (bin) · `@krizaka/config/known-violations` | The known violations of dependency-cruiser, which only shrink, see below. |
57
+ | `krizaka-server-client-boundary` (bin) · `@krizaka/config/server-client-boundary` | Fails when a server-rendered module imports a function from a `"use client"` `@krizaka/*` module (React #441), see below. |
58
+ | `@krizaka/config/tsconfig/*.json` | `base` (strict, ES2022, bundler resolution), `react-library` (+ DOM, `react-jsx`), `next` (+ the Next plugin, `noEmit`, `incremental`), `react-native` (`jsx: react-native`, `customConditions`; with Expo: `"extends": ["expo/tsconfig.base", "@krizaka/config/tsconfig/react-native.json"]`). |
59
+ | `@krizaka/config/prettier` · `.editorconfig` | The formatting. |
60
+ | `krizaka-ratchet` (bin) | The debt counter, see below. |
61
61
 
62
62
  ### The four UI rules
63
63
 
@@ -73,10 +73,10 @@ selectors:
73
73
 
74
74
  ```js
75
75
  krizakaUi({
76
- allow: ["emerald"], // palette families tolerated for documented, isolated cases
77
- files: ["**/*.{jsx,tsx}"], // default
78
- ignores: ["app/og/**"], // e.g. generated OG images
79
- severity: "error", // "warn" while the ratchet brings the debt down
76
+ allow: ["emerald"], // palette families tolerated for documented, isolated cases
77
+ files: ["**/*.{jsx,tsx}"], // default
78
+ ignores: ["app/og/**"], // e.g. generated OG images
79
+ severity: "error", // "warn" while the ratchet brings the debt down
80
80
  });
81
81
  ```
82
82
 
@@ -106,6 +106,22 @@ chain of `if`). **Error by default** (`complexity: "error"`); a repository with
106
106
  `krizaka-ratchet` (`cognitiveComplexity`) freezes the count of functions over 15 at `--update` time and fails any
107
107
  increase. Test files (`*.test.*`, `*.spec.*`, `__tests__/`) are not counted, as Sonar does not scan them.
108
108
 
109
+ ### The Sonar layer (ADR-013)
110
+
111
+ `krizakaStack()` also enables the whole **`eslint-plugin-sonarjs` recommended set** (SonarCloud's JS/TS engine,
112
+ krizaka/.github#333), so Sonar's findings fail at lint time, before push. `sonar: "error"` by default; a repository with
113
+ debt passes `sonar: "warn"` while `krizaka-ratchet` freezes each rule's count (a `sonar` total and one `sonarjs/<rule>`
114
+ counter per rule with a violation) and fails any increase, or the first violation of a rule that had none. Test files are
115
+ not part of the layer (Sonar does not scan them). Rules that need type information (S2871 `no-alphabetical-sort`,
116
+ `deprecation`, `different-types-comparison`…) fire only when the lint runs with `projectService`.
117
+
118
+ Turned off, each for a stated reason (`SONAR_OFF` in `@krizaka/config/eslint/sonar`): `no-fallthrough`, `no-labels`,
119
+ `no-empty-character-class`, `no-invalid-regexp`, `no-control-regex`, `no-regex-spaces`, `no-misleading-character-class`,
120
+ `no-delete-var`, `no-useless-catch`, `generator-without-yield`, `updated-const-var` (ESLint core already enforces them),
121
+ `no-unused-vars`, `unused-import` (`@typescript-eslint/no-unused-vars`, tsc), `no-extra-arguments` (tsc),
122
+ `link-with-target-blank` (`react/jsx-no-target-blank`). `cognitive-complexity` keeps its own threshold and counter (above).
123
+ A rule is never switched off because it is noisy: fix the code or let the ratchet hold the count.
124
+
109
125
  ### The three stack rules
110
126
 
111
127
  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
@@ -121,9 +137,9 @@ adopts them before it is clean and brings each count to 0 by the end of its adop
121
137
  ```js
122
138
  krizakaStack({
123
139
  files: ["**/*.{js,jsx,mjs,cjs,ts,tsx,mts,cts}"], // default
124
- ignores: ["packages/**"], // e.g. the facades of a monorepo
125
- severity: "warn", // default; "error" once the app's counts are at 0
126
- maxFormFields: 1, // default
140
+ ignores: ["packages/**"], // e.g. the facades of a monorepo
141
+ severity: "warn", // default; "error" once the app's counts are at 0
142
+ maxFormFields: 1, // default
127
143
  });
128
144
  ```
129
145
 
@@ -137,13 +153,13 @@ The **covered-domains map** is `covered-domains.json`, the single source. Each d
137
153
  refused in an app; the preset generates `krizaka-first-<domain>` from it, and the page « Use Krizaka first » of
138
154
  ui.krizaka.com is laid out from it (`coveredDomainsMarkdown()`).
139
155
 
140
- | Domain | Krizaka home | Refused in an app | Rule |
141
- | :-- | :-- | :-- | :-- |
142
- | Dates, times, local days | `@krizaka/intl` (formatting, `localDay`), `@krizaka/time` (arithmetic; a facade created on the first real need, krizaka-ts#76) | `date-fns*`, `@date-fns/*`, `dayjs`, `luxon`, `moment`, `moment-timezone` | `krizaka-first-dates` |
143
- | Words, languages | `@krizaka/i18n` | `i18next*`, `react-i18next`, `next-intl`, `next-i18next`, `react-intl`, `@formatjs/*`, `@lingui/*`, `i18n-js`, `typesafe-i18n` | `krizaka-first-i18n` |
144
- | Data fetching, cache, tables, forms, virtual lists | `@krizaka/query`, `@krizaka/ui`, `@krizaka/pacer` | `@tanstack/*` | `krizaka-first-query` |
145
- | Icons | `@krizaka/icons` | `lucide-react`, `react-icons`, `@heroicons/*` | `krizaka-first-icons` |
146
- | Interface primitives | `@krizaka/ui` | `@radix-ui/*`, `radix-ui` | `krizaka-first-ui` |
156
+ | Domain | Krizaka home | Refused in an app | Rule |
157
+ | :------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------- | :-------------------- |
158
+ | Dates, times, local days | `@krizaka/intl` (formatting, `localDay`), `@krizaka/time` (arithmetic; a facade created on the first real need, krizaka-ts#76) | `date-fns*`, `@date-fns/*`, `dayjs`, `luxon`, `moment`, `moment-timezone` | `krizaka-first-dates` |
159
+ | Words, languages | `@krizaka/i18n` | `i18next*`, `react-i18next`, `next-intl`, `next-i18next`, `react-intl`, `@formatjs/*`, `@lingui/*`, `i18n-js`, `typesafe-i18n` | `krizaka-first-i18n` |
160
+ | Data fetching, cache, tables, forms, virtual lists | `@krizaka/query`, `@krizaka/ui`, `@krizaka/pacer` | `@tanstack/*` | `krizaka-first-query` |
161
+ | Icons | `@krizaka/icons` | `lucide-react`, `react-icons`, `@heroicons/*` | `krizaka-first-icons` |
162
+ | Interface primitives | `@krizaka/ui` | `@radix-ui/*`, `radix-ui` | `krizaka-first-ui` |
147
163
 
148
164
  An entry is a package (`dayjs`, and its subpaths), a prefix (`date-fns*`) or a scope (`@tanstack/*`). It matches the
149
165
  resolved file and the module as written when it cannot be resolved (a package the app does not declare), never a local
@@ -226,20 +242,21 @@ npx krizaka-ratchet --update # after a migration: lowers the recorded counts
226
242
  npx krizaka-ratchet --json # in CI: the same check, machine-readable
227
243
  ```
228
244
 
229
- | Counter | Pattern (same regex as the ESLint rule) |
230
- | :-- | :-- |
231
- | `palette` | `\b(bg\|text\|border\|from\|via\|to\|ring\|shadow\|fill\|stroke)-(zinc\|slate\|…\|indigo)-[0-9]` |
232
- | `light` | `\blight:` |
233
- | `arbitraryVar` | `\[var\(--` |
234
- | `classNameTemplate` | `` className=\{\s*` `` |
235
- | `fetchInClientEffect` | in a file opening with `"use client"`: `fetch(` / `krizakaFetch(` between the parentheses of a `useEffect(` / `useLayoutEffect(` |
236
- | `asyncInEffect` | an effect (`useEffect(` / `useLayoutEffect(`, any file) whose callback awaits or calls `.then(`, `fetch(`, `invoke(`, `api.*`, `*Client.*`, and is not a subscribe-and-cleanup |
237
- | `derivedStateInEffect` | an effect whose only statements are `setX(…)` calls (optionally under `if`) |
238
- | `directTanstackImport` | `from` / `import` / `import(` / `require(` of `"@tanstack/…"` |
239
- | `localTableOrForm` | `<table`, and `<form` … `</form>` holding more fields than `maxFormFields` (default 1) |
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 |
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 |
245
+ | Counter | Pattern (same regex as the ESLint rule) |
246
+ | :------------------------ | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
247
+ | `palette` | `\b(bg\|text\|border\|from\|via\|to\|ring\|shadow\|fill\|stroke)-(zinc\|slate\|…\|indigo)-[0-9]` |
248
+ | `light` | `\blight:` |
249
+ | `arbitraryVar` | `\[var\(--` |
250
+ | `classNameTemplate` | `` className=\{\s*` `` |
251
+ | `fetchInClientEffect` | in a file opening with `"use client"`: `fetch(` / `krizakaFetch(` between the parentheses of a `useEffect(` / `useLayoutEffect(` |
252
+ | `asyncInEffect` | an effect (`useEffect(` / `useLayoutEffect(`, any file) whose callback awaits or calls `.then(`, `fetch(`, `invoke(`, `api.*`, `*Client.*`, and is not a subscribe-and-cleanup |
253
+ | `derivedStateInEffect` | an effect whose only statements are `setX(…)` calls (optionally under `if`) |
254
+ | `directTanstackImport` | `from` / `import` / `import(` / `require(` of `"@tanstack/…"` |
255
+ | `localTableOrForm` | `<table`, and `<form` … `</form>` holding more fields than `maxFormFields` (default 1) |
256
+ | `retiredImport` | an import of a dependency listed in `retire` (e.g. `["framer-motion"]`): the way out of a dependency |
257
+ | `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 |
258
+ | `sonar`, `sonarjs/<rule>` | violations of the Sonar layer (the `eslint-plugin-sonarjs` recommended set, minus `SONAR_OFF` and cognitive complexity), tests excluded: a total, and one counter per rule with a violation (an absent rule is 0); the KPI of the Sonar-at-lint debt, it only goes down |
259
+ | `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 |
243
260
 
244
261
  The UI counters scan `app/**/*.tsx`, `components/**/*.tsx` and `src/**/*.tsx` by default (`node_modules`, `dist`,
245
262
  `.next` never), or the globs given on the command line; the stack counters scan every source file (`js`, `jsx`, `mjs`,
@@ -254,13 +271,19 @@ so the ratchet is never looser than the rule. Exit codes: `0` ok, `1` a count we
254
271
  {
255
272
  "globs": ["app/**/*.tsx", "components/**/*.tsx", "src/**/*.tsx"],
256
273
  "stackGlobs": ["app/**/*.{js,jsx,mjs,ts,tsx,mts}", "…"],
257
- "allow": [], // optional: same families as krizakaUi({ allow })
258
- "retire": ["framer-motion"], // optional: dependencies on their way out
259
- "maxFormFields": 1, // optional: same option as krizakaStack({ maxFormFields })
274
+ "allow": [], // optional: same families as krizakaUi({ allow })
275
+ "retire": ["framer-motion"], // optional: dependencies on their way out
276
+ "maxFormFields": 1, // optional: same option as krizakaStack({ maxFormFields })
260
277
  "counters": {
261
- "palette": 2400, "light": 1151, "arbitraryVar": 0, "classNameTemplate": 128,
262
- "fetchInClientEffect": 41, "directTanstackImport": 3, "localTableOrForm": 38, "retiredImport": 4
263
- }
278
+ "palette": 2400,
279
+ "light": 1151,
280
+ "arbitraryVar": 0,
281
+ "classNameTemplate": 128,
282
+ "fetchInClientEffect": 41,
283
+ "directTanstackImport": 3,
284
+ "localTableOrForm": 38,
285
+ "retiredImport": 4,
286
+ },
264
287
  }
265
288
  ```
266
289
 
@@ -13,11 +13,12 @@ source file of app, components, src, lib, hooks and features; compares them with
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)`;
20
20
 
21
+ /** @type {Record<string, string>} */
21
22
  const LABELS = {
22
23
  palette: "raw palette colours",
23
24
  light: "light: variants",
@@ -31,6 +32,7 @@ const LABELS = {
31
32
  retiredImport: "imports of retired deps",
32
33
  routeStateSelection: "selections in local state (not the URL)",
33
34
  cognitiveComplexity: "functions over complexity 15 (S3776)",
35
+ sonar: "Sonar rules (sonarjs, total)",
34
36
  };
35
37
 
36
38
  let args;
@@ -72,16 +74,21 @@ try {
72
74
  console.log(JSON.stringify(result, null, 2));
73
75
  } else {
74
76
  console.log(`krizaka-ratchet — ${result.files} files for the UI, ${result.stackFiles} for the stack`);
75
- for (const name of COUNTERS) {
77
+ // the named counters, then one line per Sonar rule that has a violation or a recorded count
78
+ const lines = [...COUNTERS, ...Object.keys(result.counters).filter((name) => name.startsWith("sonarjs/"))];
79
+ for (const name of lines) {
76
80
  const { recorded, current, delta } = result.counters[name];
77
81
  const mark = delta > 0 ? "✗" : delta < 0 ? "↓" : "=";
78
82
  const sign = delta > 0 ? `+${delta}` : `${delta}`;
79
- console.log(` ${mark} ${LABELS[name].padEnd(28)} ${String(current).padStart(6)} (recorded ${recorded}, ${sign})`);
83
+ console.log(` ${mark} ${(LABELS[name] ?? name).padEnd(28)} ${String(current).padStart(6)} (recorded ${recorded}, ${sign})`);
80
84
  }
81
85
  if (result.mode === "init") console.log(`Created ${result.file}.`);
82
86
  else if (result.written) console.log(`${result.decreased.length > 0 ? "Lowered" : "Completed"} ${result.file}: commit it.`);
83
87
  else if (result.decreased.length > 0) console.log(`Debt went down: run krizaka-ratchet --update and commit ${result.file}.`);
84
88
  else if (result.added.length > 0) console.log(`New counters (${result.added.join(", ")}): run krizaka-ratchet --update and commit ${result.file}.`);
89
+ if (result.unmeasured.length > 0) {
90
+ console.log(` ! not measured by this version, kept as recorded: ${result.unmeasured.join(", ")}`);
91
+ }
85
92
  if (!result.ok) {
86
93
  console.error(
87
94
  `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.`,
@@ -0,0 +1,61 @@
1
+ // The Sonar layer (ADR-013, krizaka/.github#333): the whole `eslint-plugin-sonarjs` recommended set, the same engine as
2
+ // SonarCloud's JS/TS rules, so a Sonar finding fails at lint time, before push. Under `krizaka-ratchet` (one counter per
3
+ // rule): the existing violations of a repository freeze, a new one fails.
4
+ import sonarjs from "eslint-plugin-sonarjs";
5
+
6
+ /** Sonar scans product code, not its tests: the layer and the ratchet counters skip these files. */
7
+ export const SONAR_TEST_FILES = Object.freeze(["**/*.{test,spec}.{js,jsx,mjs,cjs,ts,tsx,mts,cts}", "**/__tests__/**"]);
8
+ /** Counted separately (`cognitiveComplexity`, threshold 15 in stack.js): never twice. */
9
+ export const SONAR_OWN_COUNTER = "sonarjs/cognitive-complexity";
10
+
11
+ /**
12
+ * Rules of the recommended set that we turn off, each with its reason. Only a rule that another ESLint rule already
13
+ * enforces, or that contradicts our standards, goes here — never a rule that is merely noisy.
14
+ * @type {Readonly<Record<string, string>>}
15
+ */
16
+ export const SONAR_OFF = Object.freeze({
17
+ "sonarjs/no-fallthrough": "duplicate of ESLint core no-fallthrough (@eslint/js recommended)",
18
+ "sonarjs/no-labels": "duplicate of ESLint core no-labels / no-unused-labels",
19
+ "sonarjs/no-unused-vars": "duplicate of @typescript-eslint/no-unused-vars (krizakaBase, `_` prefix convention)",
20
+ "sonarjs/unused-import": "duplicate of @typescript-eslint/no-unused-vars and tsc --noUnusedLocals",
21
+ "sonarjs/no-extra-arguments": "duplicate of the TypeScript compiler (TS2554)",
22
+ "sonarjs/no-empty-character-class": "duplicate of ESLint core no-empty-character-class",
23
+ "sonarjs/no-invalid-regexp": "duplicate of ESLint core no-invalid-regexp",
24
+ "sonarjs/no-control-regex": "duplicate of ESLint core no-control-regex",
25
+ "sonarjs/no-regex-spaces": "duplicate of ESLint core no-regex-spaces",
26
+ "sonarjs/no-misleading-character-class": "duplicate of ESLint core no-misleading-character-class",
27
+ "sonarjs/no-delete-var": "duplicate of ESLint core no-delete-var",
28
+ "sonarjs/no-useless-catch": "duplicate of ESLint core no-useless-catch",
29
+ "sonarjs/updated-const-var": "duplicate of ESLint core no-const-assign and the TypeScript compiler",
30
+ "sonarjs/generator-without-yield": "duplicate of ESLint core require-yield",
31
+ "sonarjs/link-with-target-blank": "duplicate of react/jsx-no-target-blank (eslint-config-next)",
32
+ });
33
+
34
+ /** @type {Record<string, import("eslint").Linter.RuleEntry>} */
35
+ const recommended = /** @type {any} */ (sonarjs.configs).recommended.rules;
36
+
37
+ /**
38
+ * The rules of the layer: every rule the recommended set enables, minus the explicit offs and the cognitive-complexity
39
+ * counter. Name -> its options as shipped by the plugin (severity applied by `sonarRules`).
40
+ * @type {ReadonlyMap<string, unknown[]>}
41
+ */
42
+ export const SONAR_RULES = new Map(
43
+ Object.entries(recommended)
44
+ .filter(([name]) => !(name in SONAR_OFF) && name !== SONAR_OWN_COUNTER)
45
+ .flatMap(([name, entry]) => {
46
+ const [level, ...options] = Array.isArray(entry) ? entry : [entry];
47
+ return level === "off" || level === 0 ? [] : [[name, options]];
48
+ }),
49
+ );
50
+
51
+ /**
52
+ * The `rules` object of the layer at one severity.
53
+ * @param {"error" | "warn"} severity
54
+ * @returns {Record<string, import("eslint").Linter.RuleEntry>}
55
+ */
56
+ export function sonarRules(severity) {
57
+ return Object.fromEntries([
58
+ ...[...SONAR_RULES].map(([name, options]) => [name, options.length > 0 ? [severity, ...options] : severity]),
59
+ ...Object.keys(SONAR_OFF).map((name) => [name, "off"]),
60
+ ]);
61
+ }
package/eslint/stack.js CHANGED
@@ -5,6 +5,7 @@
5
5
  import sonarjs from "eslint-plugin-sonarjs";
6
6
 
7
7
  import { krizakaPlugin } from "./plugin.js";
8
+ import { SONAR_TEST_FILES, sonarRules } from "./sonar.js";
8
9
 
9
10
  /** Sonar S3776 threshold: a function above it is a quality-gate failure. Never raised. */
10
11
  export const COGNITIVE_COMPLEXITY_MAX = 15;
@@ -22,11 +23,14 @@ export const STACK_RULES = Object.freeze([
22
23
 
23
24
  /**
24
25
  * Flat config: the stack rules on an app's sources.
25
- * @param {{ files?: string[], ignores?: string[], severity?: "warn" | "error", effects?: "warn" | "error" | "off", complexity?: "warn" | "error", maxFormFields?: number }} [options]
26
+ * @param {{ files?: string[], ignores?: string[], severity?: "warn" | "error", effects?: "warn" | "error" | "off", complexity?: "warn" | "error", sonar?: "warn" | "error" | "off", maxFormFields?: number }} [options]
26
27
  * `effects`: severity of the two effect rules (react-effects standard): `"error"` by default; a repository with debt
27
28
  * passes `"warn"` while `krizaka-ratchet` (asyncInEffect, derivedStateInEffect) brings the count to 0.
28
29
  * `complexity`: severity of `sonarjs/cognitive-complexity` (Sonar S3776, threshold 15): `"error"` by default; a
29
30
  * repository with debt passes `"warn"` while `krizaka-ratchet` (`cognitiveComplexity`) freezes the count and fails any increase.
31
+ * `sonar`: severity of the Sonar layer (the `eslint-plugin-sonarjs` recommended set, minus `SONAR_OFF`; ADR-013): `"error"` by
32
+ * default; a repository with debt passes `"warn"` while `krizaka-ratchet` (`sonarjs/<rule>` counters) freezes each rule's
33
+ * count and fails any increase. Test files are not part of it (Sonar does not scan them).
30
34
  * `maxFormFields`: a form with at most this many fields is allowed (default 1 — a search box, a newsletter).
31
35
  * @returns {import("eslint").Linter.Config[]}
32
36
  */
@@ -36,6 +40,7 @@ export function krizakaStack({
36
40
  severity = "warn",
37
41
  effects = "error",
38
42
  complexity = "error",
43
+ sonar = "error",
39
44
  maxFormFields = 1,
40
45
  } = {}) {
41
46
  return [
@@ -58,6 +63,17 @@ export function krizakaStack({
58
63
  "sonarjs/cognitive-complexity": [complexity, COGNITIVE_COMPLEXITY_MAX],
59
64
  },
60
65
  },
66
+ ...(sonar === "off"
67
+ ? []
68
+ : [
69
+ {
70
+ name: "krizaka/sonar",
71
+ files,
72
+ ignores: [...ignores, ...SONAR_TEST_FILES],
73
+ plugins: { sonarjs },
74
+ rules: sonarRules(sonar),
75
+ },
76
+ ]),
61
77
  ];
62
78
  }
63
79
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@krizaka/config",
3
- "version": "0.11.0",
3
+ "version": "0.12.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",
@@ -118,7 +118,7 @@
118
118
  "ratchet/ratchet.js",
119
119
  "patterns.js"
120
120
  ],
121
- "limit": "12.5 kB",
121
+ "limit": "13.5 kB",
122
122
  "gzip": true
123
123
  }
124
124
  ],
@@ -9,6 +9,7 @@ import sonarjs from "eslint-plugin-sonarjs";
9
9
  import { glob } from "tinyglobby";
10
10
  import tseslint from "typescript-eslint";
11
11
 
12
+ import { SONAR_RULES } from "../eslint/sonar.js";
12
13
  import {
13
14
  ARBITRARY_VAR_SOURCE,
14
15
  balancedArguments,
@@ -37,9 +38,9 @@ export const DEFAULT_STACK_GLOBS = Object.freeze(
37
38
  const IGNORE = ["**/node_modules/**", "**/dist/**", "**/.next/**", "**/storybook-static/**", "**/coverage/**"];
38
39
 
39
40
  /** @typedef {"palette" | "light" | "arbitraryVar" | "classNameTemplate"} UiCounterName */
40
- /** @typedef {"fetchInClientEffect" | "asyncInEffect" | "derivedStateInEffect" | "directTanstackImport" | "localTableOrForm" | "retiredImport" | "routeStateSelection" | "cognitiveComplexity"} StackCounterName */
41
+ /** @typedef {"fetchInClientEffect" | "asyncInEffect" | "derivedStateInEffect" | "directTanstackImport" | "localTableOrForm" | "retiredImport" | "routeStateSelection" | "cognitiveComplexity" | "sonar"} StackCounterName */
41
42
  /** @typedef {UiCounterName | StackCounterName} CounterName */
42
- /** @typedef {Record<CounterName, number>} Counters */
43
+ /** @typedef {Record<CounterName, number> & Record<string, number>} Counters (plus one `sonarjs/<rule>` counter per Sonar rule with a violation) */
43
44
  /**
44
45
  * @typedef {object} RatchetFile
45
46
  * @property {string[]} [globs] files of the UI counters
@@ -62,12 +63,16 @@ export const STACK_COUNTERS = /** @type {const} */ ([
62
63
  "retiredImport",
63
64
  "routeStateSelection",
64
65
  "cognitiveComplexity",
66
+ "sonar",
65
67
  ]);
66
68
  /** Every counter, in report order. */
67
69
  export const COUNTERS = /** @type {const} */ ([...UI_COUNTERS, ...STACK_COUNTERS]);
68
70
  /** Sonar S3776's threshold (same value as eslint/stack.js COGNITIVE_COMPLEXITY_MAX). Never raised. */
69
71
  const COMPLEXITY_MAX = 15;
70
72
  const TEST_FILE = /(\.(test|spec)\.[cm]?[jt]sx?$)|(\/__tests__\/)/;
73
+ /** Prefix of the per-rule Sonar counters (ADR-013): `sonarjs/no-nested-conditional`. */
74
+ export const SONAR_PREFIX = "sonarjs/";
75
+ const isSonarCounter = (/** @type {string} */ name) => name.startsWith(SONAR_PREFIX);
71
76
  const zero = () => /** @type {Counters} */ (Object.fromEntries(COUNTERS.map((name) => [name, 0])));
72
77
 
73
78
  /**
@@ -102,7 +107,7 @@ export function countSource(source, allow = []) {
102
107
  * `@tanstack/*`, hand-written tables and forms of more than `maxFormFields` fields, imports of the retired dependencies.
103
108
  * @param {string} source
104
109
  * @param {{ retire?: readonly string[], maxFormFields?: number }} [options]
105
- * @returns {Record<Exclude<StackCounterName, "cognitiveComplexity">, number>} (cognitiveComplexity: countCognitiveComplexity, it needs the file name)
110
+ * @returns {Record<Exclude<StackCounterName, "cognitiveComplexity" | "sonar">, number>} (cognitiveComplexity and sonar: countCognitiveComplexity and countSonar, they need the file name)
106
111
  */
107
112
  export function countStackSource(source, { retire = [], maxFormFields = 1 } = {}) {
108
113
  let fetchInClientEffect = 0;
@@ -152,6 +157,28 @@ export function countCognitiveComplexity(source, filename = "file.tsx") {
152
157
  return messages.filter((/** @type {{ ruleId: string | null }} */ m) => m.ruleId === "sonarjs/cognitive-complexity").length;
153
158
  }
154
159
 
160
+ /**
161
+ * Counts the violations of the Sonar layer (the `eslint-plugin-sonarjs` recommended set, see eslint/sonar.js) in one
162
+ * source text, per rule: `{ "sonarjs/no-nested-conditional": 2 }`. A file that does not parse counts for nothing.
163
+ * @param {string} source
164
+ * @param {string} [filename]
165
+ * @returns {Record<string, number>}
166
+ */
167
+ export function countSonar(source, filename = "file.tsx") {
168
+ /** @type {Record<string, number>} */
169
+ const found = {};
170
+ let messages;
171
+ try {
172
+ messages = complexityLinter().verify(source, SONAR_CONFIG, { filename: `file${extname(filename)}` });
173
+ } catch {
174
+ return found; // a rule that cannot read the file: ESLint reports it, the ratchet does not guess
175
+ }
176
+ for (const { ruleId } of /** @type {{ ruleId: string | null }[]} */ (messages)) {
177
+ if (ruleId && SONAR_RULES.has(ruleId)) found[ruleId] = (found[ruleId] ?? 0) + 1;
178
+ }
179
+ return found;
180
+ }
181
+
155
182
  /** One Linter for the whole run: building it (and the parser) per file made the CLI 10x slower in CI. */
156
183
  /** @type {Linter | undefined} */
157
184
  let sharedLinter;
@@ -166,6 +193,16 @@ const COMPLEXITY_CONFIG = [
166
193
  },
167
194
  ];
168
195
 
196
+ /** @type {any} */
197
+ const SONAR_CONFIG = [
198
+ {
199
+ files: ["**/*.{js,jsx,mjs,cjs,ts,tsx,mts,cts}"],
200
+ languageOptions: { parser: /** @type {any} */ (tseslint.parser) },
201
+ plugins: { sonarjs },
202
+ rules: Object.fromEntries([...SONAR_RULES].map(([name, options]) => [name, ["error", ...options]])),
203
+ },
204
+ ];
205
+
169
206
  /**
170
207
  * Counts the debts under `root`: the UI counters in the files of `globs`, the stack counters in those of `stackGlobs`.
171
208
  * @param {{ root: string, globs?: readonly string[], stackGlobs?: readonly string[], allow?: string[],
@@ -191,7 +228,13 @@ export async function countRepository({
191
228
  const found = /** @type {Partial<Counters>} */ (countStackSource(readFileSync(file, "utf8"), { retire, maxFormFields }));
192
229
  for (const name of STACK_COUNTERS) counters[name] += found[name] ?? 0;
193
230
  // Sonar scans the product code, not its tests
194
- if (!TEST_FILE.test(file)) counters.cognitiveComplexity += countCognitiveComplexity(readFileSync(file, "utf8"), file);
231
+ if (!TEST_FILE.test(file)) {
232
+ counters.cognitiveComplexity += countCognitiveComplexity(readFileSync(file, "utf8"), file);
233
+ for (const [rule, n] of Object.entries(countSonar(readFileSync(file, "utf8"), file))) {
234
+ counters[rule] = (counters[rule] ?? 0) + n;
235
+ counters.sonar += n;
236
+ }
237
+ }
195
238
  }
196
239
  return { counters, files: files.length, stackFiles: stackFiles.length };
197
240
  }
@@ -233,10 +276,11 @@ export class RatchetError extends Error {}
233
276
  * @property {number} stackFiles source files scanned by the stack counters
234
277
  * @property {string[]} globs
235
278
  * @property {string[]} stackGlobs
236
- * @property {Record<CounterName, { recorded: number, current: number, delta: number }>} counters
237
- * @property {CounterName[]} increased
238
- * @property {CounterName[]} decreased
239
- * @property {CounterName[]} added counters missing from the file (recorded at their current value by --update)
279
+ * @property {Record<string, { recorded: number, current: number, delta: number }>} counters (the named counters, and a `sonarjs/<rule>` one per Sonar rule with a violation)
280
+ * @property {string[]} increased
281
+ * @property {string[]} decreased
282
+ * @property {string[]} added counters missing from the file (recorded at their current value by --update)
283
+ * @property {string[]} unmeasured counters of the file this version does not measure: kept as recorded by --update, never dropped
240
284
  * @property {boolean} written the ratchet file was created, lowered or completed
241
285
  */
242
286
 
@@ -246,13 +290,14 @@ export class RatchetError extends Error {}
246
290
  * @param {Counters} recorded
247
291
  */
248
292
  function compareCounters(current, recorded) {
293
+ const names = [...COUNTERS, ...new Set([...Object.keys(current), ...Object.keys(recorded)].filter(isSonarCounter))];
249
294
  /** @type {RatchetResult["counters"]} */
250
295
  const report = /** @type {any} */ ({});
251
- /** @type {CounterName[]} */ const increased = [];
252
- /** @type {CounterName[]} */ const decreased = [];
253
- for (const name of COUNTERS) {
254
- const delta = current[name] - recorded[name];
255
- report[name] = { recorded: recorded[name], current: current[name], delta };
296
+ /** @type {string[]} */ const increased = [];
297
+ /** @type {string[]} */ const decreased = [];
298
+ for (const name of names) {
299
+ const delta = (current[name] ?? 0) - (recorded[name] ?? 0);
300
+ report[name] = { recorded: recorded[name] ?? 0, current: current[name] ?? 0, delta };
256
301
  if (delta > 0) increased.push(name);
257
302
  if (delta < 0) decreased.push(name);
258
303
  }
@@ -293,8 +338,14 @@ export async function runRatchet({ root, mode = "check", globs, file = RATCHET_F
293
338
  retire,
294
339
  maxFormFields,
295
340
  });
296
- /** @type {CounterName[]} */ const added = recordedFile ? COUNTERS.filter((n) => recordedFile.counters[n] === undefined) : [];
341
+ /** @type {string[]} */ const added = recordedFile ? COUNTERS.filter((n) => recordedFile.counters[n] === undefined) : [];
297
342
  const recorded = /** @type {Counters} */ ({ ...current, ...recordedFile?.counters });
343
+ // once the Sonar layer is recorded (`sonar`), a rule with no recorded counter is recorded at 0: a first violation fails
344
+ if (recordedFile?.counters.sonar !== undefined) {
345
+ for (const name of Object.keys(current)) if (isSonarCounter(name) && recordedFile.counters[name] === undefined) recorded[name] = 0;
346
+ }
347
+ /** @type {string[]} */
348
+ const unmeasured = Object.keys(recordedFile?.counters ?? {}).filter((n) => !isSonarCounter(n) && !COUNTERS.includes(/** @type {CounterName} */ (n)));
298
349
 
299
350
  const { report, increased, decreased } = compareCounters(current, recorded);
300
351
 
@@ -305,7 +356,14 @@ export async function runRatchet({ root, mode = "check", globs, file = RATCHET_F
305
356
  } else if (mode === "update" && (decreased.length > 0 || added.length > 0) && recordedFile) {
306
357
  const lowered = zero();
307
358
  for (const name of COUNTERS) lowered[name] = Math.min(recorded[name], current[name]);
308
- writeRatchet(path, { ...recordedFile, stackGlobs: recordedFile.stackGlobs ?? stackGlobs, counters: lowered });
359
+ // one counter per Sonar rule, only those still above 0 (an absent one is 0)
360
+ for (const name of Object.keys(report).filter(isSonarCounter)) {
361
+ const value = Math.min(recorded[name] ?? 0, current[name] ?? 0);
362
+ if (value > 0) lowered[name] = value;
363
+ }
364
+ // a counter this version does not measure (a repository's own, or a newer preset's) is kept as recorded, never dropped
365
+ const counters = { ...lowered, ...Object.fromEntries(unmeasured.map((name) => [name, /** @type {Record<string, number>} */ (recordedFile?.counters)?.[name]])) };
366
+ writeRatchet(path, { ...recordedFile, stackGlobs: recordedFile.stackGlobs ?? stackGlobs, counters });
309
367
  for (const name of decreased) report[name].recorded = lowered[name];
310
368
  written = true;
311
369
  }
@@ -323,6 +381,7 @@ export async function runRatchet({ root, mode = "check", globs, file = RATCHET_F
323
381
  increased,
324
382
  decreased,
325
383
  added,
384
+ unmeasured,
326
385
  written,
327
386
  };
328
387
  }