@krizaka/config 0.8.1 → 0.9.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
@@ -83,7 +83,20 @@ Flat config replaces a rule's options instead of concatenating them: a repositor
83
83
  `no-restricted-syntax` merges both lists with `krizakaUiRestrictedSyntax()`. `no-locale-ternary` is a plugin rule for
84
84
  that reason — it composes with anything.
85
85
 
86
- ### The stack rules
86
+ ### The effect rules (react-effects)
87
+
88
+ [`useEffect` is for the outside world](https://github.com/krizaka/.github/blob/main/docs/standards/react-effects.md)
89
+ (Oussama, 2026-10-27). Two rules of `krizakaStack()`, **errors by default** (`effects: "error"`); a repository with debt
90
+ passes `effects: "warn"` while `krizaka-ratchet` (`asyncInEffect`, `derivedStateInEffect`) brings the count to 0. They
91
+ read any file (a Vite/Tauri app has no `"use client"`).
92
+
93
+ 1. **`krizaka/no-async-in-effect`** — an effect that awaits, calls `.then(`, `fetch(`, `invoke(`, `api.*` or `*Client.*`:
94
+ read with `useQuery`, write with `useMutation` (`@krizaka/query`), keyed by the selection. A subscribe-and-cleanup
95
+ effect (`listen(`, `addEventListener(`, `subscribe(`, `WebSocket`… plus a `return`) is never reported.
96
+ 2. **`krizaka/no-derived-state-in-effect`** — an effect whose only statements are `setX(…)` calls: compute the value
97
+ during render, or reset the component with a `key`.
98
+
99
+ ### The three stack rules
87
100
 
88
101
  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
89
102
  `@krizaka/*` facades; a product never imports them. Warnings: `krizaka-ratchet` counts the same patterns, so an app
@@ -195,6 +208,8 @@ npx krizaka-ratchet --json # in CI: the same check, machine-readable
195
208
  | `arbitraryVar` | `\[var\(--` |
196
209
  | `classNameTemplate` | `` className=\{\s*` `` |
197
210
  | `fetchInClientEffect` | in a file opening with `"use client"`: `fetch(` / `krizakaFetch(` between the parentheses of a `useEffect(` / `useLayoutEffect(` |
211
+ | `asyncInEffect` | an effect (`useEffect(` / `useLayoutEffect(`, any file) whose callback awaits or calls `.then(`, `fetch(`, `invoke(`, `api.*`, `*Client.*`, and is not a subscribe-and-cleanup |
212
+ | `derivedStateInEffect` | an effect whose only statements are `setX(…)` calls (optionally under `if`) |
198
213
  | `directTanstackImport` | `from` / `import` / `import(` / `require(` of `"@tanstack/…"` |
199
214
  | `localTableOrForm` | `<table`, and `<form` … `</form>` holding more fields than `maxFormFields` (default 1) |
200
215
  | `retiredImport` | an import of a dependency listed in `retire` (e.g. `["framer-motion"]`): the way out of a dependency |
@@ -8,7 +8,7 @@ const USAGE = `Usage: krizaka-ratchet [globs…] [--init | --update] [--json] [-
8
8
 
9
9
  Counts the UI debts — raw palette colours, light: variants, [var(--…)] utilities, className template strings — in the
10
10
  source files (default: app/**/*.tsx components/**/*.tsx src/**/*.tsx), and the stack debts — fetch in a client effect,
11
- 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, 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
@@ -24,6 +24,8 @@ const LABELS = {
24
24
  arbitraryVar: "[var(--…)] utilities",
25
25
  classNameTemplate: "className template strings",
26
26
  fetchInClientEffect: "fetch in a client effect",
27
+ asyncInEffect: "data loaded in an effect (useQuery instead)",
28
+ derivedStateInEffect: "state derived in an effect (render instead)",
27
29
  directTanstackImport: "direct @tanstack/* imports",
28
30
  localTableOrForm: "hand-written tables / forms",
29
31
  retiredImport: "imports of retired deps",
package/eslint/plugin.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // The `krizaka` ESLint plugin: one object carrying every rule, so that configs registering it compose (flat config
2
2
  // refuses two different objects under the same plugin name).
3
- import { envName, noFamilySwitcher, noFetchInClientEffect, noLightVariant, noLocaleTernaryRule, noLocalTableOrForm, noRawPalette,routeStateSelection } from "./rules.js";
3
+ import { envName, noAsyncInEffect, noDerivedStateInEffect, noFamilySwitcher, noFetchInClientEffect, noLightVariant, noLocaleTernaryRule, noLocalTableOrForm, noRawPalette,routeStateSelection } from "./rules.js";
4
4
 
5
5
  /** @type {import("eslint").ESLint.Plugin} */
6
6
  export const krizakaPlugin = {
@@ -8,6 +8,8 @@ export const krizakaPlugin = {
8
8
  rules: {
9
9
  "no-locale-ternary": noLocaleTernaryRule,
10
10
  "no-fetch-in-client-effect": noFetchInClientEffect,
11
+ "no-async-in-effect": noAsyncInEffect,
12
+ "no-derived-state-in-effect": noDerivedStateInEffect,
11
13
  "no-local-table-or-form": noLocalTableOrForm,
12
14
  "no-light-variant": noLightVariant,
13
15
  "no-raw-palette": noRawPalette,
package/eslint/rules.js CHANGED
@@ -3,7 +3,7 @@
3
3
  import { existsSync, readFileSync } from "node:fs";
4
4
  import { dirname, join } from "node:path";
5
5
 
6
- import { countFormFields, EFFECT_NAMES_SOURCE, FETCH_NAMES_SOURCE, isLocalSelection, LIGHT_VARIANT_SOURCE, paletteSource } from "../patterns.js";
6
+ import { countFormFields, EFFECT_NAMES_SOURCE, effectProblem, FETCH_NAMES_SOURCE, isLocalSelection, LIGHT_VARIANT_SOURCE, paletteSource } from "../patterns.js";
7
7
 
8
8
  /** @typedef {import("eslint").Rule.RuleModule} RuleModule */
9
9
  /** @typedef {import("estree").Node} Node */
@@ -70,6 +70,52 @@ export const noFetchInClientEffect = {
70
70
  },
71
71
  };
72
72
 
73
+ /**
74
+ * Reports the effects (`useEffect` / `useLayoutEffect`) whose callback `effectProblem` says is `kind`.
75
+ * @param {import("eslint").Rule.RuleContext} context
76
+ * @param {"async" | "derived"} kind
77
+ * @param {string} messageId
78
+ */
79
+ function effectVisitor(context, kind, messageId) {
80
+ return {
81
+ [`CallExpression${callee(EFFECT_NAMES_SOURCE)}`]: (/** @type {import("estree").CallExpression} */ node) => {
82
+ const fn = node.arguments[0];
83
+ if (fn?.type !== "ArrowFunctionExpression" && fn?.type !== "FunctionExpression") return;
84
+ if (effectProblem(context.sourceCode.getText(fn.body)) === kind) context.report({ node, messageId });
85
+ },
86
+ };
87
+ }
88
+
89
+ /** Oussama, 2026-10-27: async data is `useQuery` / `useMutation`, never an effect (standard: react-effects). */
90
+ /** @type {RuleModule} */
91
+ export const noAsyncInEffect = {
92
+ meta: {
93
+ type: "problem",
94
+ docs: { description: "Forbid loading data (await, .then, fetch, invoke, api.*, *Client.*) in a useEffect that is not a subscription." },
95
+ schema: [],
96
+ messages: {
97
+ asyncInEffect:
98
+ "Data is not loaded in an effect: read it with useQuery and write it with useMutation (@krizaka/query), keyed by the selection so a stale answer is dropped. An effect is for a subscription (with its cleanup) or an object URL. See krizaka/.github docs/standards/react-effects.md.",
99
+ },
100
+ },
101
+ create: (context) => effectVisitor(context, "async", "asyncInEffect"),
102
+ };
103
+
104
+ /** Oussama, 2026-10-27: syncing React state is computed in render, never an effect (standard: react-effects). */
105
+ /** @type {RuleModule} */
106
+ export const noDerivedStateInEffect = {
107
+ meta: {
108
+ type: "problem",
109
+ docs: { description: "Forbid a useEffect whose only statements are setState calls (derive the value in render, or reset with a key)." },
110
+ schema: [],
111
+ messages: {
112
+ derivedState:
113
+ "An effect that only sets state from props or state renders twice: compute the value during render, or reset the component with a `key`. See krizaka/.github docs/standards/react-effects.md.",
114
+ },
115
+ },
116
+ create: (context) => effectVisitor(context, "derived", "derivedState"),
117
+ };
118
+
73
119
  /** Oussama, 2026-10-22: « changer les tabs doit changer les routes » — a selection of a page lives in the URL. */
74
120
  /** @type {RuleModule} */
75
121
  export const routeStateSelection = {
package/eslint/stack.js CHANGED
@@ -7,6 +7,8 @@ import { krizakaPlugin } from "./plugin.js";
7
7
  /** The rule names, as configured. */
8
8
  export const STACK_RULES = Object.freeze([
9
9
  "krizaka/no-fetch-in-client-effect",
10
+ "krizaka/no-async-in-effect",
11
+ "krizaka/no-derived-state-in-effect",
10
12
  "krizaka/no-local-table-or-form",
11
13
  "krizaka/no-family-switcher",
12
14
  "krizaka/route-state-selection",
@@ -14,7 +16,9 @@ export const STACK_RULES = Object.freeze([
14
16
 
15
17
  /**
16
18
  * Flat config: the stack rules on an app's sources.
17
- * @param {{ files?: string[], ignores?: string[], severity?: "warn" | "error", maxFormFields?: number }} [options]
19
+ * @param {{ files?: string[], ignores?: string[], severity?: "warn" | "error", effects?: "warn" | "error" | "off", maxFormFields?: number }} [options]
20
+ * `effects`: severity of the two effect rules (react-effects standard): `"error"` by default; a repository with debt
21
+ * passes `"warn"` while `krizaka-ratchet` (asyncInEffect, derivedStateInEffect) brings the count to 0.
18
22
  * `maxFormFields`: a form with at most this many fields is allowed (default 1 — a search box, a newsletter).
19
23
  * @returns {import("eslint").Linter.Config[]}
20
24
  */
@@ -22,6 +26,7 @@ export function krizakaStack({
22
26
  files = ["**/*.{js,jsx,mjs,cjs,ts,tsx,mts,cts}"],
23
27
  ignores = [],
24
28
  severity = "warn",
29
+ effects = "error",
25
30
  maxFormFields = 1,
26
31
  } = {}) {
27
32
  return [
@@ -32,6 +37,9 @@ export function krizakaStack({
32
37
  plugins: { krizaka: krizakaPlugin },
33
38
  rules: {
34
39
  "krizaka/no-fetch-in-client-effect": severity,
40
+ // useEffect is for the outside world: data is useQuery/useMutation, derived state is computed in render.
41
+ "krizaka/no-async-in-effect": effects,
42
+ "krizaka/no-derived-state-in-effect": effects,
35
43
  "krizaka/no-local-table-or-form": [severity, { maxFormFields }],
36
44
  // Never a warning: the family switcher is gone from every site (krizaka.com is the only way back).
37
45
  "krizaka/no-family-switcher": "error",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@krizaka/config",
3
- "version": "0.8.1",
3
+ "version": "0.9.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",
@@ -115,7 +115,7 @@
115
115
  "ratchet/ratchet.js",
116
116
  "patterns.js"
117
117
  ],
118
- "limit": "9 kB",
118
+ "limit": "12.5 kB",
119
119
  "gzip": true
120
120
  }
121
121
  ],
package/patterns.js CHANGED
@@ -48,6 +48,149 @@ export const FETCH_CALL_SOURCE = String.raw`\b(?:fetch|krizakaFetch)\s*\(`;
48
48
  export const EFFECT_NAMES_SOURCE = "^use(Layout)?Effect$";
49
49
  export const FETCH_NAMES_SOURCE = "^(fetch|krizakaFetch)$";
50
50
 
51
+ // ── Effects are for the outside world (Oussama, 2026-10-27: « bug à adresser partout ») ─────────────────────────────
52
+ // Standard: krizaka/.github docs/standards/react-effects.md. Read by the rules `krizaka/no-async-in-effect` and
53
+ // `krizaka/no-derived-state-in-effect` (eslint/rules.js) and by the ratchet, through `effectProblem`.
54
+
55
+ /** What makes an effect load data: an await, `.then(`, `fetch(`, a Tauri `invoke(`, `api.x(`, `fooClient.x(`. */
56
+ export const ASYNC_EFFECT_SOURCE = String.raw`\bawait\b|\.then\s*\(|\b(?:fetch|krizakaFetch|invoke)\s*\(|\bapi\.\w+|\b\w*Client\.\w+\s*\(`;
57
+
58
+ /** A subscription to a system that pushes events: its callback may be async, the cleanup undoes it. */
59
+ export const SUBSCRIBE_EFFECT_SOURCE = String.raw`\b(?:listen|once|on|addEventListener|subscribe|addListener|new\s+(?:WebSocket|EventSource|ResizeObserver|IntersectionObserver|MutationObserver|BroadcastChannel)|\w+\.on|\w+\.on[A-Z]\w*)\s*(?:<[^()]*>)?\s*\(`;
60
+
61
+ /**
62
+ * Text with its strings, template literals and comments blanked (same length), so a keyword in a string is not read.
63
+ * @param {string} source
64
+ * @returns {string}
65
+ */
66
+ export function blankStrings(source) {
67
+ let out = "";
68
+ let i = 0;
69
+ while (i < source.length) {
70
+ const [blanked, next] = blankedAt(source, i);
71
+ out += blanked;
72
+ i = next;
73
+ }
74
+ return out;
75
+ }
76
+
77
+ /**
78
+ * What `source` holds at `i` once blanked, and where the text goes on: a string or template literal keeps its quotes, a
79
+ * comment is spaces (newlines kept), any other character is itself.
80
+ * @param {string} source
81
+ * @param {number} i
82
+ * @returns {[string, number]}
83
+ */
84
+ function blankedAt(source, i) {
85
+ const c = source[i];
86
+ if (c === '"' || c === "'" || c === "`") {
87
+ let j = i + 1;
88
+ for (; j < source.length && source[j] !== c; j += 1) if (source[j] === "\\") j += 1;
89
+ return [c + " ".repeat(Math.max(0, j - i - 1)) + c, j + 1];
90
+ }
91
+ if (c === "/" && source[i + 1] === "/") {
92
+ const end = source.indexOf("\n", i);
93
+ const stop = end < 0 ? source.length : end;
94
+ return [" ".repeat(stop - i), stop];
95
+ }
96
+ if (c === "/" && source[i + 1] === "*") {
97
+ const end = source.indexOf("*/", i + 2);
98
+ const stop = end < 0 ? source.length : end + 2;
99
+ return [source.slice(i, stop).replace(/[^\n]/g, " "), stop];
100
+ }
101
+ return [c ?? "", i + 1];
102
+ }
103
+
104
+ /**
105
+ * An effect callback without its signature (`() =>`, `async () =>`, `function () `, `cb =>`): what is left is its body.
106
+ * Scanned, not matched by a regular expression, so no input makes it slow.
107
+ * @param {string} callback
108
+ * @returns {string}
109
+ */
110
+ function withoutSignature(callback) {
111
+ let i = 0;
112
+ const skipSpace = () => {
113
+ while (i < callback.length && /\s/.test(/** @type {string} */ (callback[i]))) i += 1;
114
+ };
115
+ skipSpace();
116
+ if (callback.startsWith("async", i) && /\s/.test(callback[i + 5] ?? "")) {
117
+ i += 5;
118
+ skipSpace();
119
+ }
120
+ const isFunction = callback.startsWith("function", i);
121
+ if (isFunction) {
122
+ i += 8;
123
+ while (i < callback.length && callback[i] !== "(") i += 1;
124
+ }
125
+ if (callback[i] === "(") {
126
+ const close = callback.indexOf(")", i);
127
+ if (close < 0) return callback;
128
+ i = close + 1;
129
+ } else if (!isFunction) {
130
+ while (i < callback.length && /\w/.test(/** @type {string} */ (callback[i]))) i += 1;
131
+ }
132
+ const afterParams = i;
133
+ skipSpace();
134
+ if (callback.startsWith("=>", i)) return callback.slice(i + 2);
135
+ return isFunction ? callback.slice(afterParams) : callback;
136
+ }
137
+
138
+ /**
139
+ * The callback of an effect call without the dependency array that follows it (`cb, [a, b]`).
140
+ * @param {string} args the balanced arguments of `useEffect(…)`
141
+ * @returns {string}
142
+ */
143
+ export function withoutDependencyArray(args) {
144
+ let end = args.trimEnd();
145
+ if (end.endsWith(",")) end = end.slice(0, -1).trimEnd();
146
+ if (!end.endsWith("]")) return args;
147
+ const open = end.lastIndexOf("[");
148
+ if (open < 0) return args;
149
+ const before = end.slice(0, open).trimEnd();
150
+ return before.endsWith(",") ? before.slice(0, -1) : args;
151
+ }
152
+
153
+ /**
154
+ * Removes every `setX(…)` call (balanced) from a blanked text.
155
+ * @param {string} text
156
+ * @returns {{ rest: string, found: number }}
157
+ */
158
+ function withoutSetStateCalls(text) {
159
+ let out = "";
160
+ let found = 0;
161
+ const re = /\bset[A-Z]\w*\s*\(/g;
162
+ let last = 0;
163
+ for (let m = re.exec(text); m; m = re.exec(text)) {
164
+ const open = m.index + m[0].length - 1;
165
+ const inner = balancedArguments(text, open);
166
+ out += text.slice(last, m.index);
167
+ last = open + inner.length + 2;
168
+ re.lastIndex = last;
169
+ found += 1;
170
+ }
171
+ return { rest: out + text.slice(last), found };
172
+ }
173
+
174
+ /**
175
+ * What an effect body is wrong about: `"async"` (it loads data — use `useQuery` / `useMutation`) or `"derived"` (its
176
+ * only statements are `setX(…)` calls — compute the value in render, or reset with a `key`), else null.
177
+ * A subscribe-and-cleanup effect (a push source and a `return` that undoes it) is the legitimate use and is never wrong.
178
+ * @param {string} body the text of the effect callback (with or without its braces)
179
+ * @returns {"async" | "derived" | null}
180
+ */
181
+ export function effectProblem(body) {
182
+ // the ratchet passes the whole callback (`() => { … }`), the ESLint rule its body: strip the signature
183
+ const text = withoutSignature(blankStrings(body));
184
+ // an expression body (`() => session.onChange(…)`) returns its cleanup as well as a block that says `return`
185
+ const returnsCleanup = /\breturn\b/.test(text) || !/^\s*\{/.test(text);
186
+ if (new RegExp(SUBSCRIBE_EFFECT_SOURCE).test(text) && returnsCleanup) return null;
187
+ if (new RegExp(ASYNC_EFFECT_SOURCE).test(text)) return "async";
188
+ const { rest, found } = withoutSetStateCalls(text);
189
+ if (found === 0) return null;
190
+ const leftover = rest.replace(/\b(?:if|else)\b/g, "").replace(/\([^()]*(?:\([^()]*\)[^()]*)*\)/g, "").replace(/[\s;{}]/g, "");
191
+ return leftover === "" ? "derived" : null;
192
+ }
193
+
51
194
  /** The headless engines that only enter through a `@krizaka/*` package. */
52
195
  export const TANSTACK_SCOPE = "@tanstack/";
53
196
 
@@ -12,6 +12,7 @@ import {
12
12
  CLASSNAME_TEMPLATE_SOURCE,
13
13
  countFormFields,
14
14
  EFFECT_CALL_SOURCE,
15
+ effectProblem,
15
16
  FETCH_CALL_SOURCE,
16
17
  importSource,
17
18
  isLocalSelection,
@@ -21,6 +22,7 @@ import {
21
22
  TABLE_OR_FORM_SOURCE,
22
23
  TANSTACK_SCOPE,
23
24
  USE_CLIENT_SOURCE,
25
+ withoutDependencyArray,
24
26
  } from "../patterns.js";
25
27
 
26
28
  export const RATCHET_FILE = "lint-ratchet.json";
@@ -32,7 +34,7 @@ export const DEFAULT_STACK_GLOBS = Object.freeze(
32
34
  const IGNORE = ["**/node_modules/**", "**/dist/**", "**/.next/**", "**/storybook-static/**", "**/coverage/**"];
33
35
 
34
36
  /** @typedef {"palette" | "light" | "arbitraryVar" | "classNameTemplate"} UiCounterName */
35
- /** @typedef {"fetchInClientEffect" | "directTanstackImport" | "localTableOrForm" | "retiredImport" | "routeStateSelection"} StackCounterName */
37
+ /** @typedef {"fetchInClientEffect" | "asyncInEffect" | "derivedStateInEffect" | "directTanstackImport" | "localTableOrForm" | "retiredImport" | "routeStateSelection"} StackCounterName */
36
38
  /** @typedef {UiCounterName | StackCounterName} CounterName */
37
39
  /** @typedef {Record<CounterName, number>} Counters */
38
40
  /**
@@ -50,6 +52,8 @@ export const UI_COUNTERS = /** @type {const} */ (["palette", "light", "arbitrary
50
52
  /** The stack counters (the three rules of krizakaStack, and the dependencies to retire), in report order. */
51
53
  export const STACK_COUNTERS = /** @type {const} */ ([
52
54
  "fetchInClientEffect",
55
+ "asyncInEffect",
56
+ "derivedStateInEffect",
53
57
  "directTanstackImport",
54
58
  "localTableOrForm",
55
59
  "retiredImport",
@@ -101,6 +105,15 @@ export function countStackSource(source, { retire = [], maxFormFields = 1 } = {}
101
105
  fetchInClientEffect += body.match(new RegExp(FETCH_CALL_SOURCE, "g"))?.length ?? 0;
102
106
  }
103
107
  }
108
+ // react-effects standard: every effect (client file or not — a Vite/Tauri app has no directive) is read once
109
+ let asyncInEffect = 0;
110
+ let derivedStateInEffect = 0;
111
+ for (const effect of source.matchAll(new RegExp(EFFECT_CALL_SOURCE, "g"))) {
112
+ const body = balancedArguments(source, (effect.index ?? 0) + effect[0].length - 1);
113
+ const problem = effectProblem(withoutDependencyArray(body)); // the dependency array is not the callback
114
+ if (problem === "async") asyncInEffect += 1;
115
+ else if (problem === "derived") derivedStateInEffect += 1;
116
+ }
104
117
  let localTableOrForm = 0;
105
118
  for (const element of source.matchAll(new RegExp(TABLE_OR_FORM_SOURCE, "g"))) {
106
119
  if (element[1] === "table") localTableOrForm += 1;
@@ -111,6 +124,8 @@ export function countStackSource(source, { retire = [], maxFormFields = 1 } = {}
111
124
  }
112
125
  return {
113
126
  fetchInClientEffect,
127
+ asyncInEffect,
128
+ derivedStateInEffect,
114
129
  directTanstackImport: source.match(new RegExp(importSource([TANSTACK_SCOPE]), "g"))?.length ?? 0,
115
130
  localTableOrForm,
116
131
  retiredImport: source.match(new RegExp(importSource(retire), "g"))?.length ?? 0,