@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 +73 -50
- package/bin/krizaka-ratchet.mjs +10 -3
- package/eslint/sonar.js +61 -0
- package/eslint/stack.js +17 -1
- package/package.json +2 -2
- package/ratchet/ratchet.js +74 -15
package/README.md
CHANGED
|
@@ -42,22 +42,22 @@ the package.
|
|
|
42
42
|
|
|
43
43
|
## What is inside
|
|
44
44
|
|
|
45
|
-
| Export
|
|
46
|
-
|
|
|
47
|
-
| `@krizaka/config/eslint`
|
|
48
|
-
| `@krizaka/config/eslint/next`
|
|
49
|
-
| `@krizaka/config/eslint/krizaka-ui`
|
|
50
|
-
| `@krizaka/config/eslint/no-locale-ternary`
|
|
51
|
-
| `@krizaka/config/eslint/ui-warnings`
|
|
52
|
-
| `@krizaka/config/eslint/stack`
|
|
53
|
-
| `@krizaka/config/eslint/plugin`
|
|
54
|
-
| `@krizaka/config/dependency-cruiser`
|
|
55
|
-
| `@krizaka/config/covered-domains` · `/covered-domains.json`
|
|
56
|
-
| `krizaka-known-violations` (bin) · `@krizaka/config/known-violations`
|
|
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`
|
|
59
|
-
| `@krizaka/config/prettier` · `.editorconfig`
|
|
60
|
-
| `krizaka-ratchet` (bin)
|
|
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"],
|
|
77
|
-
files: ["**/*.{jsx,tsx}"],
|
|
78
|
-
ignores: ["app/og/**"],
|
|
79
|
-
severity: "error",
|
|
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/**"],
|
|
125
|
-
severity: "warn",
|
|
126
|
-
maxFormFields: 1,
|
|
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
|
|
141
|
-
|
|
|
142
|
-
| Dates, times, local days
|
|
143
|
-
| Words, languages
|
|
144
|
-
| Data fetching, cache, tables, forms, virtual lists | `@krizaka/query`, `@krizaka/ui`, `@krizaka/pacer`
|
|
145
|
-
| Icons
|
|
146
|
-
| Interface primitives
|
|
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
|
|
230
|
-
|
|
|
231
|
-
| `palette`
|
|
232
|
-
| `light`
|
|
233
|
-
| `arbitraryVar`
|
|
234
|
-
| `classNameTemplate`
|
|
235
|
-
| `fetchInClientEffect`
|
|
236
|
-
| `asyncInEffect`
|
|
237
|
-
| `derivedStateInEffect`
|
|
238
|
-
| `directTanstackImport`
|
|
239
|
-
| `localTableOrForm`
|
|
240
|
-
| `retiredImport`
|
|
241
|
-
| `cognitiveComplexity`
|
|
242
|
-
| `
|
|
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": [],
|
|
258
|
-
"retire": ["framer-motion"],
|
|
259
|
-
"maxFormFields": 1,
|
|
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,
|
|
262
|
-
"
|
|
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
|
|
package/bin/krizaka-ratchet.mjs
CHANGED
|
@@ -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
|
-
|
|
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.`,
|
package/eslint/sonar.js
ADDED
|
@@ -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.
|
|
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": "
|
|
121
|
+
"limit": "13.5 kB",
|
|
122
122
|
"gzip": true
|
|
123
123
|
}
|
|
124
124
|
],
|
package/ratchet/ratchet.js
CHANGED
|
@@ -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,
|
|
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))
|
|
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<
|
|
237
|
-
* @property {
|
|
238
|
-
* @property {
|
|
239
|
-
* @property {
|
|
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 {
|
|
252
|
-
/** @type {
|
|
253
|
-
for (const name of
|
|
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 {
|
|
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
|
-
|
|
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
|
}
|