@krizaka/config 0.8.1 → 0.9.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
@@ -5,7 +5,7 @@ rules, the three stack rules, no locale ternary), the dependency-cruiser boundar
5
5
  EditorConfig — and `krizaka-ratchet`, the counter that keeps the UI and stack debts from coming back.
6
6
 
7
7
  [![npm](https://img.shields.io/npm/v/@krizaka/config?color=3b82f6&label=npm)](https://www.npmjs.com/package/@krizaka/config)
8
- [![License](https://img.shields.io/badge/license-Apache--2.0-blue)](https://github.com/krizaka/krizaka-ts/blob/main/LICENSE)
8
+ [![License](https://img.shields.io/badge/license-Apache--2.0-blue)](../../LICENSE)
9
9
 
10
10
  ## Adopt it in three lines
11
11
 
@@ -24,7 +24,7 @@ export default [...krizakaNext, ...krizakaUi(), ...krizakaStack(), ...noLocaleTe
24
24
  ```
25
25
 
26
26
  ```js
27
- // .dependency-cruiser.cjs — « Krizaka first », cycles, unresolvable imports, dev dependencies, plus the app's own rules
27
+ // .dependency-cruiser.cjs — the TanStack boundary, plus the app's own rules
28
28
  module.exports = { extends: "@krizaka/config/dependency-cruiser", forbidden: [] };
29
29
  ```
30
30
 
@@ -51,9 +51,7 @@ the package.
51
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
52
  | `@krizaka/config/eslint/stack` | `krizakaStack(options?)`: the three stack rules below (warnings). Also `STACK_RULES`. |
53
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. |
54
+ | `@krizaka/config/dependency-cruiser` | The shared dependency-cruiser config: `krizaka-no-direct-tanstack` (no `@tanstack/*` from an app). |
57
55
  | `@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"]`). |
58
56
  | `@krizaka/config/prettier` · `.editorconfig` | The formatting. |
59
57
  | `krizaka-ratchet` (bin) | The debt counter, see below. |
@@ -83,15 +81,30 @@ Flat config replaces a rule's options instead of concatenating them: a repositor
83
81
  `no-restricted-syntax` merges both lists with `krizakaUiRestrictedSyntax()`. `no-locale-ternary` is a plugin rule for
84
82
  that reason — it composes with anything.
85
83
 
86
- ### The stack rules
84
+ ### The effect rules (react-effects)
87
85
 
88
- 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
86
+ [`useEffect` is for the outside world](https://github.com/krizaka/.github/blob/main/docs/standards/react-effects.md)
87
+ (Oussama, 2026-10-27). Two rules of `krizakaStack()`, **errors by default** (`effects: "error"`); a repository with debt
88
+ passes `effects: "warn"` while `krizaka-ratchet` (`asyncInEffect`, `derivedStateInEffect`) brings the count to 0. They
89
+ read any file (a Vite/Tauri app has no `"use client"`).
90
+
91
+ 1. **`krizaka/no-async-in-effect`** — an effect that awaits, calls `.then(`, `fetch(`, `invoke(`, `api.*` or `*Client.*`:
92
+ read with `useQuery`, write with `useMutation` (`@krizaka/query`), keyed by the selection. A subscribe-and-cleanup
93
+ effect (`listen(`, `addEventListener(`, `subscribe(`, `WebSocket`… plus a `return`) is never reported.
94
+ 2. **`krizaka/no-derived-state-in-effect`** — an effect whose only statements are `setX(…)` calls: compute the value
95
+ during render, or reset the component with a `key`.
96
+
97
+ ### The three stack rules
98
+
99
+ From [ADR-001](../../docs/adr/ADR-001-tanstack-under-krizaka-facades.md) — the TanStack engines run headless, behind
89
100
  `@krizaka/*` facades; a product never imports them. Warnings: `krizaka-ratchet` counts the same patterns, so an app
90
101
  adopts them before it is clean and brings each count to 0 by the end of its adoption.
91
102
 
92
103
  1. **`krizaka/no-fetch-in-client-effect`** — a `fetch` / `krizakaFetch` inside a `useEffect` (or `useLayoutEffect`)
93
104
  of a `"use client"` file: render the first screen in a Server Component, read the rest with `@krizaka/query`.
94
- 2. **`krizaka/no-local-table-or-form`** — a `<table>`, or a `<form>` of more than one field (native fields and the field
105
+ 2. **`krizaka/no-direct-tanstack-import`** — an import of `@tanstack/*` (static, dynamic, `require`, re-export): use
106
+ the facade (`@krizaka/query`, `@krizaka/ui/data-table`, `@krizaka/ui/form`, `@krizaka/ui/virtual`, `@krizaka/pacer`).
107
+ 3. **`krizaka/no-local-table-or-form`** — a `<table>`, or a `<form>` of more than one field (native fields and the field
95
108
  primitives of `@krizaka/ui`; a hidden or submit `<input>` is not a field): use `DataTable` and `createKrizakaForm`.
96
109
  A search box or a one-field newsletter stays a plain form.
97
110
 
@@ -104,55 +117,8 @@ krizakaStack({
104
117
  });
105
118
  ```
106
119
 
107
- ### « Krizaka first » — the refused imports (dependency-cruiser)
108
-
109
- Part of the stack ADR and of krizaka-ui#609, in **one tool**: dependency-cruiser carries every refused import (an import
110
- is an edge: it catches re-exports and dynamic imports, and the same file holds the layers); ESLint keeps the framework
111
- rules above. The ESLint rule `krizaka/no-direct-tanstack-import` is retired: `@tanstack/*` is `krizaka-first-query`.
112
-
113
- The **covered-domains map** is `covered-domains.json`, the single source. Each domain names its Krizaka home and what is
114
- refused in an app; the preset generates `krizaka-first-<domain>` from it, and the page « Use Krizaka first » of
115
- ui.krizaka.com is laid out from it (`coveredDomainsMarkdown()`).
116
-
117
- | Domain | Krizaka home | Refused in an app | Rule |
118
- | :-- | :-- | :-- | :-- |
119
- | 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` |
120
- | Words, languages | `@krizaka/i18n` | `i18next*`, `react-i18next`, `next-intl`, `next-i18next`, `react-intl`, `@formatjs/*`, `@lingui/*`, `i18n-js`, `typesafe-i18n` | `krizaka-first-i18n` |
121
- | Data fetching, cache, tables, forms, virtual lists | `@krizaka/query`, `@krizaka/ui`, `@krizaka/pacer` | `@tanstack/*` | `krizaka-first-query` |
122
- | Icons | `@krizaka/icons` | `lucide-react`, `react-icons`, `@heroicons/*` | `krizaka-first-icons` |
123
- | Interface primitives | `@krizaka/ui` | `@radix-ui/*`, `radix-ui` | `krizaka-first-ui` |
124
-
125
- An entry is a package (`dayjs`, and its subpaths), a prefix (`date-fns*`) or a scope (`@tanstack/*`). It matches the
126
- resolved file and the module as written when it cannot be resolved (a package the app does not declare), never a local
127
- folder of the same name. **A Krizaka package is exempt: it is the facade.** In an app that is the packages in
128
- `node_modules`; in a monorepo that holds its own Krizaka packages, say where they live:
129
-
130
- ```js
131
- // .dependency-cruiser.cjs of a monorepo (krizaka-ui, krizaka-ts)
132
- module.exports = require("@krizaka/config/dependency-cruiser").withOptions({ exemptFrom: ["(^|/)packages/"] });
133
- ```
134
-
135
- Every message names the home and ends « Missing function? add it to the package first. » — a missing function is added
136
- to the Krizaka package, never worked around with the library. Run it with `depcruise --output-type err-long <folders>`
137
- to read the message (`err` prints the rule name only). Beside the domains, the preset owes every repository
138
- `no-circular`, `not-to-unresolvable` (declare the dependency, or fix the path; set `options.tsConfig` for path aliases)
139
- and `not-to-dev-dep` (tests, stories, scripts and tool configs may import devDependencies; a package that is also a `peerDependency` is installed by the consumer, so production code may import it). Run `depcruise` from the
140
- workspace that owns the `package.json`.
141
-
142
- #### Known violations (they only shrink)
143
-
144
- An app that adopts the rules late records what it already breaks, and the debt only goes down:
145
-
146
- ```bash
147
- krizaka-known-violations init src app # once, in the adoption pull request: writes .dependency-cruiser-known-violations.json
148
- depcruise src app --config .dependency-cruiser.cjs --ignore-known .dependency-cruiser-known-violations.json # lint: new violations fail
149
- krizaka-known-violations check src app # lint: also fails on a recorded violation that is fixed ("lock the gain")
150
- krizaka-known-violations update src app # removes the fixed ones; NEVER adds one, and fails while a new one exists
151
- ```
152
-
153
- The file is the format of `depcruise-baseline`, so `--ignore-known` reads it. `init` refuses to overwrite it; there is
154
- no command that records a new violation. It replaces the `directTanstackImport` counter of `krizaka-ratchet`, which
155
- stays only for repositories that have not moved yet.
120
+ The dependency-cruiser config forbids `@tanstack/*` from an app's own files, resolved or not (a package installed
121
+ only as a dependency of a facade is caught too), and allows it from `node_modules/@krizaka/*`.
156
122
 
157
123
  `noLocaleTernary({ files, routing })`: `routing` lists the files allowed to compare locales (default: `proxy.ts`,
158
124
  `middleware.ts`, `i18n.ts`, `seo.ts`, `app/[locale]/layout.tsx`, `I18nProvider.tsx`).
@@ -195,6 +161,8 @@ npx krizaka-ratchet --json # in CI: the same check, machine-readable
195
161
  | `arbitraryVar` | `\[var\(--` |
196
162
  | `classNameTemplate` | `` className=\{\s*` `` |
197
163
  | `fetchInClientEffect` | in a file opening with `"use client"`: `fetch(` / `krizakaFetch(` between the parentheses of a `useEffect(` / `useLayoutEffect(` |
164
+ | `asyncInEffect` | an effect (`useEffect(` / `useLayoutEffect(`, any file) whose callback awaits or calls `.then(`, `fetch(`, `invoke(`, `api.*`, `*Client.*`, and is not a subscribe-and-cleanup |
165
+ | `derivedStateInEffect` | an effect whose only statements are `setX(…)` calls (optionally under `if`) |
198
166
  | `directTanstackImport` | `from` / `import` / `import(` / `require(` of `"@tanstack/…"` |
199
167
  | `localTableOrForm` | `<table`, and `<form` … `</form>` holding more fields than `maxFormFields` (default 1) |
200
168
  | `retiredImport` | an import of a dependency listed in `retire` (e.g. `["framer-motion"]`): the way out of a dependency |
@@ -231,4 +199,9 @@ The CI step, after the install:
231
199
 
232
200
  ## License
233
201
 
234
- [Apache-2.0](https://github.com/krizaka/krizaka-ts/blob/main/LICENSE)
202
+ [Apache-2.0](../../LICENSE)
203
+
204
+ ## Page templates and the account baseline
205
+
206
+ - **`krizaka/page-template`** (in `krizakaStack`, a warning counted by `krizaka-ratchet` as `pageTemplate`): every page of the App Router declares the Krizaka UI template it composes, as a comment at the top of `page.tsx` (`// @template: app-settings`). The templates are `PAGE_TEMPLATES` (the site's, `app-dashboard`, `app-list-detail`, `app-settings`, `app-onboarding`, `app-empty-first-run`, `app-wizard`, `redirect`). A page that fits none says why: `// @template: custom: <reason>`.
207
+ - **`@krizaka/config/conformance/account-baseline`**: the account baseline every app owes (profile with avatar, name and verified e-mail; preferences; security; billing where the product sells; notifications where it sends; my data). `checkAccountBaseline({ routes, features, visit })` returns the problems — each section owes controls (an avatar picker or an upload, a name field, a password field or a session button, a portal link, a switch per message kind, an export and a deletion), so a heading-only placeholder fails; `playwrightVisit(page, base)` is the `visit` of a signed-in Playwright page, counting the roles plus password and file inputs. Run it in each app's CI.
@@ -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,10 +24,13 @@ 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",
30
32
  routeStateSelection: "selections in local state (not the URL)",
33
+ pageTemplate: "pages with no template declared",
31
34
  };
32
35
 
33
36
  let args;
@@ -0,0 +1,116 @@
1
+ // The account baseline every Krizaka app owes (Oussama, 2026-10-22: « Avatar, etc. Ça doit être systématique »): the same
2
+ // sections, from the `app-settings` template and the account-settings blocks of @krizaka/ui, on web and native. An app runs
3
+ // `checkAccountBaseline` in its CI, signed in as a persona; it fails when a section is missing or renders empty.
4
+
5
+ /** The sections of the account area. */
6
+ export const AccountSection = Object.freeze({
7
+ Profile: "profile",
8
+ Preferences: "preferences",
9
+ Security: "security",
10
+ Billing: "billing",
11
+ Notifications: "notifications",
12
+ Data: "data",
13
+ });
14
+
15
+ /**
16
+ * The controls a visit observes on a page, by what they let a person do. `password` and `file` are counted apart: a
17
+ * password field has no `textbox` role, and an upload is a file input, not a radiogroup.
18
+ * @typedef {"radiogroup" | "textbox" | "password" | "file" | "switch" | "checkbox" | "button" | "link"} Control
19
+ */
20
+
21
+ /**
22
+ * What each section owes, and when. `needs` is the product feature that makes it required (a product that does not sell has
23
+ * no billing section; one that sends nothing has no notifications). `owes` lists what must be there, each item a set of
24
+ * alternatives (`[{ radiogroup: 1 }, { file: 1 }]`: an avatar picker or an upload): a heading alone never passes.
25
+ * @type {readonly { id: string, needs: "billing" | "notifications" | null, owes: readonly { what: string, anyOf: readonly Partial<Record<Control, number>>[] }[], why: string }[]}
26
+ */
27
+ export const ACCOUNT_BASELINE = Object.freeze([
28
+ {
29
+ id: AccountSection.Profile,
30
+ needs: null,
31
+ owes: [
32
+ { what: "an avatar (a picker or an upload)", anyOf: [{ radiogroup: 1 }, { file: 1 }] },
33
+ { what: "a display name field", anyOf: [{ textbox: 1 }] },
34
+ ],
35
+ why: "avatar (a picker, or an upload), display name, e-mail with its verified status",
36
+ },
37
+ { id: AccountSection.Preferences, needs: null, owes: [{ what: "a language or theme choice", anyOf: [{ radiogroup: 1 }] }], why: "language and theme" },
38
+ {
39
+ id: AccountSection.Security,
40
+ needs: null,
41
+ owes: [{ what: "a password change or the active sessions", anyOf: [{ password: 1 }, { button: 1 }] }],
42
+ why: "password change, active sessions",
43
+ },
44
+ { id: AccountSection.Billing, needs: "billing", owes: [{ what: "the way to the billing portal", anyOf: [{ link: 1 }, { button: 1 }] }], why: "the subscription and the billing portal, where the product sells" },
45
+ {
46
+ id: AccountSection.Notifications,
47
+ needs: "notifications",
48
+ owes: [{ what: "a setting per kind of message", anyOf: [{ switch: 1 }, { checkbox: 1 }] }],
49
+ why: "what the product sends, where it sends anything",
50
+ },
51
+ {
52
+ id: AccountSection.Data,
53
+ needs: null,
54
+ owes: [{ what: "an export and an account deletion", anyOf: [{ button: 2 }, { button: 1, link: 1 }, { link: 2 }] }],
55
+ why: "my data: export, and account deletion with confirmation (Loi 25)",
56
+ },
57
+ ]);
58
+
59
+ /**
60
+ * @typedef {object} Visited what a visit of one section found
61
+ * @property {number} status the HTTP status after redirects
62
+ * @property {readonly string[]} headings the text of the page's headings
63
+ * @property {Partial<Record<Control, number>>} controls how many of each control the page shows (a missing one is 0)
64
+ */
65
+
66
+ /** @param {Partial<Record<string, number>>} controls @param {Partial<Record<string, number>>} wanted */
67
+ const meets = (controls, wanted) => Object.entries(wanted).every(([control, count]) => (controls[control] ?? 0) >= (count ?? 0));
68
+
69
+ /** @param {Partial<Record<string, number>>} wanted */
70
+ const spell = (wanted) => Object.entries(wanted).map(([control, count]) => `${count} ${control}`).join(" + ");
71
+
72
+ /**
73
+ * Checks the account baseline of an app.
74
+ * @param {{ routes: Partial<Record<string, string>>, features?: { billing?: boolean, notifications?: boolean }, visit: (path: string) => Promise<Visited> }} options
75
+ * `routes`: the route of each section; `visit`: signs in as a persona and opens a route (a Playwright page, or a fetch of the rendered HTML).
76
+ * @returns {Promise<string[]>} one line per problem; empty when the app conforms
77
+ */
78
+ export async function checkAccountBaseline({ routes, features = {}, visit }) {
79
+ /** @type {string[]} */
80
+ const problems = [];
81
+ for (const section of ACCOUNT_BASELINE) {
82
+ if (section.needs && !features[section.needs]) continue;
83
+ const path = routes[section.id];
84
+ if (!path) {
85
+ problems.push(`${section.id}: no route (${section.why})`);
86
+ continue;
87
+ }
88
+ const page = await visit(path);
89
+ if (page.status !== 200) problems.push(`${section.id} ${path}: answers ${page.status}`);
90
+ else if (page.headings.length === 0) problems.push(`${section.id} ${path}: renders no heading`);
91
+ else
92
+ for (const item of section.owes)
93
+ if (!item.anyOf.some((wanted) => meets(page.controls, wanted)))
94
+ problems.push(`${section.id} ${path}: needs ${item.what} (${item.anyOf.map(spell).join(" or ")})`);
95
+ }
96
+ return problems;
97
+ }
98
+
99
+ /**
100
+ * The `visit` of a Playwright page: opens `base + path` and reads the headings and the controls.
101
+ * @param {any} page a Playwright page, already signed in as the persona
102
+ * @param {string} base
103
+ * @returns {(path: string) => Promise<Visited>}
104
+ */
105
+ export function playwrightVisit(page, base) {
106
+ return async (path) => {
107
+ const response = await page.goto(`${base.replace(/\/$/, "")}${path}`, { waitUntil: "networkidle" });
108
+ /** @type {Partial<Record<Control, number>>} */
109
+ const controls = {};
110
+ for (const role of /** @type {const} */ (["radiogroup", "textbox", "switch", "checkbox", "button", "link"])) controls[role] = await page.getByRole(role).count();
111
+ // No implicit role: a password field is not a textbox, a file input is a (hidden) input behind its button.
112
+ controls.password = await page.locator('input[type="password"]').count();
113
+ controls.file = await page.locator('input[type="file"]').count();
114
+ return { status: response?.status() ?? 0, headings: await page.getByRole("heading").allInnerTexts(), controls };
115
+ };
116
+ }
@@ -1,79 +1,31 @@
1
- // The shared dependency-cruiser rules of a Krizaka app (the stack ADR, decision S11; « Krizaka first », krizaka-ui#609 and
2
- // #612). dependency-cruiser carries the IMPORT rules of the family, ESLint the framework ones (krizaka/.github
3
- // docs/quality-controls.md §4, §7). An app extends it and adds its own:
1
+ // The shared dependency-cruiser rules of a Krizaka app (the stack ADR, decision S11). An app extends it and adds its own:
4
2
  //
5
3
  // // .dependency-cruiser.cjs
6
4
  // module.exports = { extends: "@krizaka/config/dependency-cruiser", forbidden: [/* the app's rules */] };
7
5
  //
8
6
  // dependency-cruiser concatenates `forbidden` and merges `options`. CommonJS: dependency-cruiser requires its configs.
9
- //
10
- // - `krizaka-first-<domain>`: one rule per domain of covered-domains.json (dates, i18n, query, icons, ui): an app never
11
- // imports the library a Krizaka package owns; the message names the Krizaka home. A Krizaka package in node_modules is
12
- // the facade and is never checked. A monorepo whose own packages are the facades says where they live:
13
- // `require("@krizaka/config/dependency-cruiser").withOptions({ exemptFrom: ["(^|/)packages/"] })`.
14
- // - `no-circular`, `not-to-unresolvable`, `not-to-dev-dep`: the three every repository owes.
15
- // - The known violations of an app that adopts the rules late: see README, « Known violations » (krizaka-known-violations).
16
-
17
- const { coveredDomains, domainMessage, domainPattern, ruleName } = require("./covered-domains.cjs");
18
-
19
- const NODE_MODULES = "(^|/)node_modules/";
20
- /** Files that may import a dev dependency: tests, stories, scripts and the tools' own configuration. */
21
- const DEV_FILES =
22
- "(^|/)(tests?|__tests__|__mocks__|e2e|scripts|\\.storybook|stories)/|[.](spec|test|stories|config)[.](js|mjs|cjs|jsx|ts|mts|cts|tsx)$|(^|/)\\.[^/]+rc[.](js|cjs|mjs|ts)$|(^|/)\\.dependency-cruiser[.]cjs$";
23
7
 
24
- /**
25
- * The preset, for an app whose own sources are everything outside node_modules.
26
- * @param {{ exemptFrom?: string[] }} [options] `exemptFrom`: path patterns of the Krizaka packages that live in this repository
27
- * (they are the facades: they wrap the library, so they may import it).
28
- * @returns {{ forbidden: Record<string, unknown>[], options: Record<string, unknown> }} an `IConfiguration` of dependency-cruiser
29
- */
30
- function createConfig({ exemptFrom = [] } = {}) {
31
- const exempt = [NODE_MODULES, ...exemptFrom];
32
- return {
33
- forbidden: [
34
- ...coveredDomains.map((domain) => ({
35
- name: ruleName(domain),
36
- severity: "error",
37
- comment: domainMessage(domain),
38
- // the app's own sources; the @krizaka/* packages in node_modules are where the library belongs
39
- from: { pathNot: exempt },
40
- // resolved (node_modules/…) or not (a package the app does not declare)
41
- to: { path: domainPattern(domain) },
42
- })),
43
- {
44
- name: "no-circular",
45
- severity: "error",
46
- comment: "A cycle makes the order of loading a matter of luck and blocks tree shaking: move what both sides need to a third module.",
47
- from: {},
48
- to: { circular: true },
49
- },
50
- {
51
- name: "not-to-unresolvable",
52
- severity: "error",
53
- comment: "The import points to nothing: a missing file, a missing dependency or a ghost dependency that only works by hoisting. Declare it in package.json or fix the path.",
54
- from: {},
55
- to: { couldNotResolve: true },
56
- },
57
- {
58
- name: "not-to-dev-dep",
59
- severity: "error",
60
- comment: "Production code imports a devDependency: it is not installed in production. Move the package to `dependencies` (or `peerDependencies`, for a library whose consumer installs it), or the import to a test.",
61
- from: { pathNot: [NODE_MODULES, DEV_FILES] },
62
- // a package that is also a peer dependency (the library declares it for its consumer, and keeps a copy to test with) is installed where it is used
63
- to: { dependencyTypes: ["npm-dev"], dependencyTypesNot: ["type-only", "npm-peer"], pathNot: ["node_modules/@types/"] },
64
- },
65
- ],
66
- options: {
67
- doNotFollow: { path: NODE_MODULES },
68
- tsPreCompilationDeps: true,
69
- enhancedResolveOptions: {
70
- exportsFields: ["exports"],
71
- conditionNames: ["import", "require", "node", "default"],
72
- },
8
+ /** @type {import("dependency-cruiser").IConfiguration} */
9
+ module.exports = {
10
+ forbidden: [
11
+ {
12
+ name: "krizaka-no-direct-tanstack",
13
+ severity: "error",
14
+ comment:
15
+ "@tanstack/* enters an app only through its @krizaka facade (@krizaka/query, @krizaka/ui/data-table, @krizaka/ui/form, " +
16
+ "@krizaka/ui/virtual, @krizaka/pacer…): the facade's version is the contract, a TanStack break is fixed in one place.",
17
+ // the app's own sources; the @krizaka/* packages in node_modules are where TanStack belongs
18
+ from: { pathNot: "(^|/)node_modules/" },
19
+ // resolved (node_modules/…/@tanstack/…) or not (a transitive package the app does not declare)
20
+ to: { path: "(^|/)@tanstack/" },
73
21
  },
74
- };
75
- }
76
-
77
- module.exports = createConfig();
78
- // not enumerable: dependency-cruiser validates the keys of the configuration it loads
79
- Object.defineProperty(module.exports, "withOptions", { value: createConfig, enumerable: false });
22
+ ],
23
+ options: {
24
+ doNotFollow: { path: "(^|/)node_modules/" },
25
+ tsPreCompilationDeps: true,
26
+ enhancedResolveOptions: {
27
+ exportsFields: ["exports"],
28
+ conditionNames: ["import", "require", "node", "default"],
29
+ },
30
+ },
31
+ };
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, noDirectTanstackImport, noFamilySwitcher, noFetchInClientEffect, noLightVariant, noLocaleTernaryRule, noLocalTableOrForm, noRawPalette,pageTemplate, routeStateSelection } from "./rules.js";
4
4
 
5
5
  /** @type {import("eslint").ESLint.Plugin} */
6
6
  export const krizakaPlugin = {
@@ -8,12 +8,16 @@ 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,
13
+ "no-direct-tanstack-import": noDirectTanstackImport,
11
14
  "no-local-table-or-form": noLocalTableOrForm,
12
15
  "no-light-variant": noLightVariant,
13
16
  "no-raw-palette": noRawPalette,
14
17
  "env-name": envName,
15
18
  "no-family-switcher": noFamilySwitcher,
16
19
  "route-state-selection": routeStateSelection,
20
+ "page-template": pageTemplate,
17
21
  },
18
22
  };
19
23
 
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, isPageFile, LIGHT_VARIANT_SOURCE, PAGE_TEMPLATES, pageTemplateProblem, paletteSource, TANSTACK_SCOPE } from "../patterns.js";
7
7
 
8
8
  /** @typedef {import("eslint").Rule.RuleModule} RuleModule */
9
9
  /** @typedef {import("estree").Node} Node */
@@ -70,6 +70,82 @@ 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
+
119
+ /** S11: `@tanstack/*` is imported by the `@krizaka/*` packages only — the version is the contract. */
120
+ /** @type {RuleModule} */
121
+ export const noDirectTanstackImport = {
122
+ meta: {
123
+ type: "problem",
124
+ docs: { description: "Forbid importing @tanstack/* outside the @krizaka/* packages." },
125
+ schema: [],
126
+ messages: {
127
+ tanstack:
128
+ "{{source}} is imported through its @krizaka facade (@krizaka/query, @krizaka/ui/data-table, @krizaka/ui/form, @krizaka/ui/virtual, @krizaka/pacer…), never directly.",
129
+ },
130
+ },
131
+ create(context) {
132
+ /** @param {Node | null | undefined} source */
133
+ const check = (source) => {
134
+ if (source?.type === "Literal" && typeof source.value === "string" && source.value.startsWith(TANSTACK_SCOPE)) {
135
+ context.report({ node: source, messageId: "tanstack", data: { source: source.value } });
136
+ }
137
+ };
138
+ return {
139
+ ImportDeclaration: (node) => check(node.source),
140
+ ExportNamedDeclaration: (node) => check(node.source),
141
+ ExportAllDeclaration: (node) => check(node.source),
142
+ ImportExpression: (node) => check(node.source),
143
+ "CallExpression[callee.name='require']": (/** @type {import("estree").CallExpression} */ node) =>
144
+ check(/** @type {Node} */ (node.arguments[0])),
145
+ };
146
+ },
147
+ };
148
+
73
149
  /** Oussama, 2026-10-22: « changer les tabs doit changer les routes » — a selection of a page lives in the URL. */
74
150
  /** @type {RuleModule} */
75
151
  export const routeStateSelection = {
@@ -169,7 +245,16 @@ export function ownProductPrefix(cwd) {
169
245
  for (let dir = cwd; ; dir = dirname(dir)) {
170
246
  const file = join(dir, "package.json");
171
247
  if (existsSync(file)) {
172
- found = productPrefixOf(file);
248
+ try {
249
+ const name = String(JSON.parse(readFileSync(file, "utf8")).name ?? "");
250
+ // A product names its packages `<product>-<part>` (acme-monorepo, @org/acme-admin); a one-word package
251
+ // (@org/realtime, @org/ui) is a building block, not a product, and a context word is never one.
252
+ const words = name.replace(/^@[^/]+\//, "").split(/[-_.]/).filter(Boolean);
253
+ const word = words.length > 1 ? words[0].toUpperCase() : "";
254
+ found = /^[A-Z][A-Z0-9]+$/.test(word) && !ENV_CONTEXT_WORDS.includes(word) ? word : null;
255
+ } catch {
256
+ found = null;
257
+ }
173
258
  break;
174
259
  }
175
260
  if (dirname(dir) === dir) break;
@@ -178,24 +263,6 @@ export function ownProductPrefix(cwd) {
178
263
  return found;
179
264
  }
180
265
 
181
- /**
182
- * The product word of one package.json (see `ownProductPrefix`); null when it cannot be read or is not a product.
183
- * @param {string} file
184
- * @returns {string | null}
185
- */
186
- function productPrefixOf(file) {
187
- try {
188
- const name = String(JSON.parse(readFileSync(file, "utf8")).name ?? "");
189
- // A product names its packages `<product>-<part>` (acme-monorepo, @org/acme-admin); a one-word package
190
- // (@org/realtime, @org/ui) is a building block, not a product, and a context word is never one.
191
- const words = name.replace(/^@[^/]+\//, "").split(/[-_.]/).filter(Boolean);
192
- const word = words.length > 1 ? words[0].toUpperCase() : "";
193
- return /^[A-Z][A-Z0-9]+$/.test(word) && !ENV_CONTEXT_WORDS.includes(word) ? word : null;
194
- } catch {
195
- return null;
196
- }
197
- }
198
-
199
266
  /** Vendors: a vendor is a value (`MEDIA_STORAGE_PROVIDER=bunny`, `MAIL_PROVIDER=mailgun`), never a name. */
200
267
  export const ENV_VENDOR_PREFIXES = Object.freeze([
201
268
  "BUNNY", "MAILGUN", "RESEND", "SENDGRID", "POSTMARK", "STRIPE", "CCBILL", "SEGPAY", "NOWPAYMENTS", "PAYPAL",
@@ -325,3 +392,28 @@ export const noRawPalette = classNameRule(
325
392
  ({ allow = [] }) => new RegExp(paletteSource(allow)),
326
393
  "Raw palette colour: use a role (bg-surface-1, text-fg-secondary, border-border-default, text-accent, text-danger).",
327
394
  );
395
+
396
+ /** Oussama, 2026-10-22: pages are composed from Krizaka UI's templates, never hand-built. */
397
+ /** @type {RuleModule} */
398
+ export const pageTemplate = {
399
+ meta: {
400
+ type: "problem",
401
+ docs: { description: "Every page of the app router declares the Krizaka UI template it composes: `// @template: app-settings`." },
402
+ schema: [],
403
+ messages: {
404
+ missing: "This page declares no template. Compose one of the page templates of @krizaka/ui (ui.krizaka.com/templates) and say so with `// @template: <name>` at the top of the file. A page that really fits none says why: `// @template: custom: <reason>`.",
405
+ unknown: "Unknown template « {{ name }} ». The templates are: {{ list }}, or `custom: <reason>`.",
406
+ reason: "`// @template: custom` needs its reason: `// @template: custom: a canvas editor, no template fits`.",
407
+ },
408
+ },
409
+ create(context) {
410
+ if (!isPageFile(context.filename)) return {};
411
+ return {
412
+ Program: (/** @type {Node} */ node) => {
413
+ const problem = pageTemplateProblem(context.sourceCode.text);
414
+ if (!problem) return;
415
+ context.report({ node, loc: { line: 1, column: 0 }, messageId: problem.kind, data: { name: "name" in problem ? problem.name : "", list: PAGE_TEMPLATES.join(", ") } });
416
+ },
417
+ };
418
+ },
419
+ };
package/eslint/stack.js CHANGED
@@ -1,20 +1,25 @@
1
- // The stack rules (the stack ADR, decisions S1–S3): server state through @krizaka/query, tables and forms through
2
- // @krizaka/ui. The import of TanStack (S11) is a dependency-cruiser rule now, `krizaka-first-query`: one tool, one rule.
3
- // Warnings: krizaka-ratchet counts the same patterns, so an app adopts them now and brings each count to 0 by the end
4
- // of its adoption.
1
+ // The stack rules (the stack ADR, decisions S1–S3 and S11): server state through @krizaka/query, tables and forms
2
+ // through @krizaka/ui, TanStack only behind a @krizaka facade. Warnings: krizaka-ratchet counts the same patterns, so an
3
+ // app adopts them now and brings each count to 0 by the end of its adoption.
5
4
  import { krizakaPlugin } from "./plugin.js";
6
5
 
7
6
  /** The rule names, as configured. */
8
7
  export const STACK_RULES = Object.freeze([
9
8
  "krizaka/no-fetch-in-client-effect",
9
+ "krizaka/no-async-in-effect",
10
+ "krizaka/no-derived-state-in-effect",
11
+ "krizaka/no-direct-tanstack-import",
10
12
  "krizaka/no-local-table-or-form",
11
13
  "krizaka/no-family-switcher",
12
14
  "krizaka/route-state-selection",
15
+ "krizaka/page-template",
13
16
  ]);
14
17
 
15
18
  /**
16
- * Flat config: the stack rules on an app's sources.
17
- * @param {{ files?: string[], ignores?: string[], severity?: "warn" | "error", maxFormFields?: number }} [options]
19
+ * Flat config: the three stack rules on an app's sources.
20
+ * @param {{ files?: string[], ignores?: string[], severity?: "warn" | "error", effects?: "warn" | "error" | "off", maxFormFields?: number }} [options]
21
+ * `effects`: severity of the two effect rules (react-effects standard): `"error"` by default; a repository with debt
22
+ * passes `"warn"` while `krizaka-ratchet` (asyncInEffect, derivedStateInEffect) brings the count to 0.
18
23
  * `maxFormFields`: a form with at most this many fields is allowed (default 1 — a search box, a newsletter).
19
24
  * @returns {import("eslint").Linter.Config[]}
20
25
  */
@@ -22,6 +27,7 @@ export function krizakaStack({
22
27
  files = ["**/*.{js,jsx,mjs,cjs,ts,tsx,mts,cts}"],
23
28
  ignores = [],
24
29
  severity = "warn",
30
+ effects = "error",
25
31
  maxFormFields = 1,
26
32
  } = {}) {
27
33
  return [
@@ -32,11 +38,17 @@ export function krizakaStack({
32
38
  plugins: { krizaka: krizakaPlugin },
33
39
  rules: {
34
40
  "krizaka/no-fetch-in-client-effect": severity,
41
+ // useEffect is for the outside world: data is useQuery/useMutation, derived state is computed in render.
42
+ "krizaka/no-async-in-effect": effects,
43
+ "krizaka/no-derived-state-in-effect": effects,
44
+ "krizaka/no-direct-tanstack-import": severity,
35
45
  "krizaka/no-local-table-or-form": [severity, { maxFormFields }],
36
46
  // Never a warning: the family switcher is gone from every site (krizaka.com is the only way back).
37
47
  "krizaka/no-family-switcher": "error",
38
48
  // A selection of a view (tabs, segmented, toggles, chip groups) lives in the URL, not in local state.
39
49
  "krizaka/route-state-selection": severity,
50
+ // Every page of the app router composes a Krizaka UI template and says which (// @template: app-settings).
51
+ "krizaka/page-template": severity,
40
52
  },
41
53
  },
42
54
  ];