@krizaka/config 0.8.0 → 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). 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,78 +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 the import to a test.",
61
- from: { pathNot: [NODE_MODULES, DEV_FILES] },
62
- to: { dependencyTypes: ["npm-dev"], dependencyTypesNot: ["type-only"], pathNot: ["node_modules/@types/"] },
63
- },
64
- ],
65
- options: {
66
- doNotFollow: { path: NODE_MODULES },
67
- tsPreCompilationDeps: true,
68
- enhancedResolveOptions: {
69
- exportsFields: ["exports"],
70
- conditionNames: ["import", "require", "node", "default"],
71
- },
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/" },
72
21
  },
73
- };
74
- }
75
-
76
- module.exports = createConfig();
77
- // not enumerable: dependency-cruiser validates the keys of the configuration it loads
78
- 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 = {
@@ -316,3 +392,28 @@ export const noRawPalette = classNameRule(
316
392
  ({ allow = [] }) => new RegExp(paletteSource(allow)),
317
393
  "Raw palette colour: use a role (bg-surface-1, text-fg-secondary, border-border-default, text-accent, text-danger).",
318
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
  ];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@krizaka/config",
3
- "version": "0.8.0",
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.",
3
+ "version": "0.9.0",
4
+ "description": "The shared configuration of every Krizaka JavaScript repository: ESLint flat configs (base, Next.js, the four UI rules, the three stack rules, no locale ternary), the dependency-cruiser boundary, tsconfig bases, Prettier, EditorConfig, and the krizaka-ratchet debt counter.",
5
5
  "keywords": [
6
6
  "krizaka",
7
7
  "eslint-config",
@@ -10,14 +10,13 @@
10
10
  "ratchet",
11
11
  "design-system",
12
12
  "dependency-cruiser",
13
- "tanstack",
14
- "krizaka-first"
13
+ "tanstack"
15
14
  ],
16
- "homepage": "https://ts.krizaka.com/config",
17
- "bugs": "https://github.com/krizaka/krizaka-ts/issues",
15
+ "homepage": "https://ui.krizaka.com/packages/config",
16
+ "bugs": "https://github.com/krizaka/krizaka-ui/issues",
18
17
  "repository": {
19
18
  "type": "git",
20
- "url": "git+https://github.com/krizaka/krizaka-ts.git",
19
+ "url": "git+https://github.com/krizaka/krizaka-ui.git",
21
20
  "directory": "packages/config"
22
21
  },
23
22
  "license": "Apache-2.0",
@@ -33,9 +32,6 @@
33
32
  "./eslint/env-names": "./eslint/env-names.js",
34
33
  "./eslint/plugin": "./eslint/plugin.js",
35
34
  "./dependency-cruiser": "./dependency-cruiser.cjs",
36
- "./covered-domains": "./covered-domains.cjs",
37
- "./covered-domains.json": "./covered-domains.json",
38
- "./known-violations": "./ratchet/known-violations.js",
39
35
  "./patterns": "./patterns.js",
40
36
  "./ratchet": "./ratchet/ratchet.js",
41
37
  "./prettier": "./prettier.config.js",
@@ -44,21 +40,20 @@
44
40
  "./tsconfig/next.json": "./tsconfig/next.json",
45
41
  "./tsconfig/react-native.json": "./tsconfig/react-native.json",
46
42
  "./editorconfig": "./.editorconfig",
47
- "./package.json": "./package.json"
43
+ "./package.json": "./package.json",
44
+ "./conformance/account-baseline": "./conformance/account-baseline.js"
48
45
  },
49
46
  "bin": {
50
- "krizaka-ratchet": "./bin/krizaka-ratchet.mjs",
51
- "krizaka-known-violations": "./bin/krizaka-known-violations.mjs"
47
+ "krizaka-ratchet": "./bin/krizaka-ratchet.mjs"
52
48
  },
53
49
  "files": [
54
50
  "bin",
51
+ "conformance",
55
52
  "eslint",
56
53
  "ratchet",
57
54
  "tsconfig",
58
55
  "patterns.js",
59
56
  "dependency-cruiser.cjs",
60
- "covered-domains.cjs",
61
- "covered-domains.json",
62
57
  "prettier.config.js",
63
58
  ".editorconfig",
64
59
  "README.md",
@@ -66,13 +61,12 @@
66
61
  ],
67
62
  "sideEffects": false,
68
63
  "dependencies": {
69
- "@eslint/js": "^9.39.0 || ^10.0.0",
70
- "dependency-cruiser": "^18.6.0",
64
+ "@eslint/js": "^10.0.1",
71
65
  "eslint-plugin-react-hooks": "^7.1.1",
72
66
  "eslint-plugin-simple-import-sort": "^14.0.0",
73
67
  "globals": "^17.13.0",
74
68
  "tinyglobby": "^0.2.17",
75
- "typescript-eslint": "^8.71.1"
69
+ "typescript-eslint": "^8.72.0"
76
70
  },
77
71
  "peerDependencies": {
78
72
  "eslint": "^9 || ^10",
@@ -92,14 +86,15 @@
92
86
  "@size-limit/file": "^14.2.0",
93
87
  "@types/estree": "^1.0.8",
94
88
  "@types/node": "^22.15.0",
89
+ "dependency-cruiser": "^18.7.0",
95
90
  "eslint": "^9.39.0",
96
- "eslint-config-next": "^16.4.0",
97
- "next": "^16.4.1",
98
- "prettier": "^3.9.9",
91
+ "eslint-config-next": "^16.4.2",
92
+ "next": "^16.4.2",
93
+ "prettier": "^3.9.10",
99
94
  "publint": "^0.3.25",
100
95
  "size-limit": "^14.2.0",
101
96
  "typescript": "^5.9.0",
102
- "vitest": "^4.1.11"
97
+ "vitest": "^5.0.5"
103
98
  },
104
99
  "engines": {
105
100
  "node": ">=22"
@@ -115,7 +110,7 @@
115
110
  "ratchet/ratchet.js",
116
111
  "patterns.js"
117
112
  ],
118
- "limit": "9 kB",
113
+ "limit": "12.5 kB",
119
114
  "gzip": true
120
115
  }
121
116
  ],
@@ -124,7 +119,6 @@
124
119
  "typecheck": "tsc --noEmit",
125
120
  "test": "vitest run",
126
121
  "size": "size-limit",
127
- "publint": "publint",
128
- "check": "pnpm run lint && pnpm run test && pnpm run size && pnpm run publint"
122
+ "publint": "publint"
129
123
  }
130
124
  }
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
 
@@ -169,3 +312,63 @@ export function isLocalSelection(tag, before = "") {
169
312
  if (new RegExp(EPHEMERAL_SOURCE).test(tag) || new RegExp(EPHEMERAL_SOURCE).test(before)) return false;
170
313
  return LOCAL_SELECTION_PROPS.some((prop) => new RegExp(`\\b${prop}\\s*=`).test(tag));
171
314
  }
315
+
316
+ // ── Every page declares its template (Oussama, 2026-10-22: sessions hand-built pages instead of composing Krizaka UI) ──────
317
+ // Read by the ESLint rule krizaka/page-template and by the ratchet.
318
+
319
+ /** The page templates of @krizaka/ui (ui.krizaka.com/templates): the site's, then the app's; `redirect` is an address kept for old links. */
320
+ export const PAGE_TEMPLATES = Object.freeze([
321
+ "landing",
322
+ "product",
323
+ "how-it-works",
324
+ "gallery",
325
+ "docs-hub",
326
+ "docs-article",
327
+ "component",
328
+ "catalogue",
329
+ "longform",
330
+ "changelog",
331
+ "contact",
332
+ "legal",
333
+ "status",
334
+ "error",
335
+ "pricing",
336
+ "app-dashboard",
337
+ "app-list-detail",
338
+ "app-settings",
339
+ "app-onboarding",
340
+ "app-empty-first-run",
341
+ "app-wizard",
342
+ "redirect",
343
+ ]);
344
+
345
+ /** The template that is not one: a page that really composes none, with its reason (the declaration `template: custom: a canvas editor`). */
346
+ export const CUSTOM_TEMPLATE = "custom";
347
+
348
+ /** A page of the Next.js App Router: `app/**\/page.tsx` (or `src/app/…`). */
349
+ export const PAGE_FILE_SOURCE = String.raw`(?:^|[\\/])app[\\/](?:.*[\\/])?page\.[cm]?[jt]sx?$`;
350
+
351
+ /** The declaration, alone on its line, as a line comment or a block comment (the rule's message shows its form). */
352
+ // The reason never starts with the block comment's `*/`: `/* @template: custom: */` declares no reason.
353
+ export const TEMPLATE_TAG_SOURCE = String.raw`^[ \t]*(?://|/\*+)[ \t]*@template:[ \t]*([a-z][a-z-]*)(?:[ \t]*:[ \t]*((?!\*\/)\S[^\n]*?)?)?[ \t]*(?:\*/)?[ \t]*$`;
354
+
355
+ /**
356
+ * What is wrong with a page's template declaration, or `null` when it is fine.
357
+ * @param {string} source
358
+ * @returns {{ kind: "missing" } | { kind: "unknown", name: string } | { kind: "reason" } | null}
359
+ */
360
+ export function pageTemplateProblem(source) {
361
+ const tag = new RegExp(TEMPLATE_TAG_SOURCE, "m").exec(source);
362
+ if (!tag) return { kind: "missing" };
363
+ const [, name, reason] = tag;
364
+ if (name === CUSTOM_TEMPLATE) return reason ? null : { kind: "reason" };
365
+ return PAGE_TEMPLATES.includes(name) ? null : { kind: "unknown", name };
366
+ }
367
+
368
+ /**
369
+ * True for the file of a route's page.
370
+ * @param {string} filename
371
+ */
372
+ export function isPageFile(filename) {
373
+ return new RegExp(PAGE_FILE_SOURCE).test(filename);
374
+ }
@@ -12,15 +12,19 @@ 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,
19
+ isPageFile,
18
20
  LIGHT_VARIANT_SOURCE,
21
+ pageTemplateProblem,
19
22
  paletteSource,
20
23
  selectionTags,
21
24
  TABLE_OR_FORM_SOURCE,
22
25
  TANSTACK_SCOPE,
23
26
  USE_CLIENT_SOURCE,
27
+ withoutDependencyArray,
24
28
  } from "../patterns.js";
25
29
 
26
30
  export const RATCHET_FILE = "lint-ratchet.json";
@@ -32,7 +36,7 @@ export const DEFAULT_STACK_GLOBS = Object.freeze(
32
36
  const IGNORE = ["**/node_modules/**", "**/dist/**", "**/.next/**", "**/storybook-static/**", "**/coverage/**"];
33
37
 
34
38
  /** @typedef {"palette" | "light" | "arbitraryVar" | "classNameTemplate"} UiCounterName */
35
- /** @typedef {"fetchInClientEffect" | "directTanstackImport" | "localTableOrForm" | "retiredImport" | "routeStateSelection"} StackCounterName */
39
+ /** @typedef {"fetchInClientEffect" | "asyncInEffect" | "derivedStateInEffect" | "directTanstackImport" | "localTableOrForm" | "retiredImport" | "routeStateSelection" | "pageTemplate"} StackCounterName */
36
40
  /** @typedef {UiCounterName | StackCounterName} CounterName */
37
41
  /** @typedef {Record<CounterName, number>} Counters */
38
42
  /**
@@ -50,10 +54,13 @@ export const UI_COUNTERS = /** @type {const} */ (["palette", "light", "arbitrary
50
54
  /** The stack counters (the three rules of krizakaStack, and the dependencies to retire), in report order. */
51
55
  export const STACK_COUNTERS = /** @type {const} */ ([
52
56
  "fetchInClientEffect",
57
+ "asyncInEffect",
58
+ "derivedStateInEffect",
53
59
  "directTanstackImport",
54
60
  "localTableOrForm",
55
61
  "retiredImport",
56
62
  "routeStateSelection",
63
+ "pageTemplate",
57
64
  ]);
58
65
  /** Every counter, in report order. */
59
66
  export const COUNTERS = /** @type {const} */ ([...UI_COUNTERS, ...STACK_COUNTERS]);
@@ -91,7 +98,7 @@ export function countSource(source, allow = []) {
91
98
  * `@tanstack/*`, hand-written tables and forms of more than `maxFormFields` fields, imports of the retired dependencies.
92
99
  * @param {string} source
93
100
  * @param {{ retire?: readonly string[], maxFormFields?: number }} [options]
94
- * @returns {Record<StackCounterName, number>}
101
+ * @returns {Record<string, number>}
95
102
  */
96
103
  export function countStackSource(source, { retire = [], maxFormFields = 1 } = {}) {
97
104
  let fetchInClientEffect = 0;
@@ -101,6 +108,15 @@ export function countStackSource(source, { retire = [], maxFormFields = 1 } = {}
101
108
  fetchInClientEffect += body.match(new RegExp(FETCH_CALL_SOURCE, "g"))?.length ?? 0;
102
109
  }
103
110
  }
111
+ // react-effects standard: every effect (client file or not — a Vite/Tauri app has no directive) is read once
112
+ let asyncInEffect = 0;
113
+ let derivedStateInEffect = 0;
114
+ for (const effect of source.matchAll(new RegExp(EFFECT_CALL_SOURCE, "g"))) {
115
+ const body = balancedArguments(source, (effect.index ?? 0) + effect[0].length - 1);
116
+ const problem = effectProblem(withoutDependencyArray(body)); // the dependency array is not the callback
117
+ if (problem === "async") asyncInEffect += 1;
118
+ else if (problem === "derived") derivedStateInEffect += 1;
119
+ }
104
120
  let localTableOrForm = 0;
105
121
  for (const element of source.matchAll(new RegExp(TABLE_OR_FORM_SOURCE, "g"))) {
106
122
  if (element[1] === "table") localTableOrForm += 1;
@@ -111,6 +127,8 @@ export function countStackSource(source, { retire = [], maxFormFields = 1 } = {}
111
127
  }
112
128
  return {
113
129
  fetchInClientEffect,
130
+ asyncInEffect,
131
+ derivedStateInEffect,
114
132
  directTanstackImport: source.match(new RegExp(importSource([TANSTACK_SCOPE]), "g"))?.length ?? 0,
115
133
  localTableOrForm,
116
134
  retiredImport: source.match(new RegExp(importSource(retire), "g"))?.length ?? 0,
@@ -118,6 +136,15 @@ export function countStackSource(source, { retire = [], maxFormFields = 1 } = {}
118
136
  };
119
137
  }
120
138
 
139
+ /**
140
+ * 1 when `file` is a page of the app router with no valid template declaration, else 0.
141
+ * @param {string} file
142
+ * @param {string} source
143
+ */
144
+ export function countPages(file, source) {
145
+ return isPageFile(file) && pageTemplateProblem(source) ? 1 : 0;
146
+ }
147
+
121
148
  /**
122
149
  * Counts the debts under `root`: the UI counters in the files of `globs`, the stack counters in those of `stackGlobs`.
123
150
  * @param {{ root: string, globs?: readonly string[], stackGlobs?: readonly string[], allow?: string[],
@@ -141,7 +168,8 @@ export async function countRepository({
141
168
  }
142
169
  for (const file of stackFiles) {
143
170
  const found = countStackSource(readFileSync(file, "utf8"), { retire, maxFormFields });
144
- for (const name of STACK_COUNTERS) counters[name] += found[name];
171
+ for (const name of STACK_COUNTERS) counters[name] += found[name] ?? 0;
172
+ counters.pageTemplate += countPages(file, readFileSync(file, "utf8"));
145
173
  }
146
174
  return { counters, files: files.length, stackFiles: stackFiles.length };
147
175
  }
@@ -1,35 +0,0 @@
1
- #!/usr/bin/env node
2
- // krizaka-known-violations: the known violations of dependency-cruiser, which only shrink.
3
- import { CONFIG_FILE, KNOWN_FILE, runKnownViolations } from "../ratchet/known-violations.js";
4
-
5
- const USAGE = `Usage: krizaka-known-violations <check|update|init> [--config ${CONFIG_FILE}] [--known ${KNOWN_FILE}] <folder…>
6
-
7
- check fails on a violation that is not recorded, and on a recorded one that is fixed (lock the gain with update)
8
- update removes the fixed violations from the file; never adds one
9
- init writes the file once, for an app that adopts the rules (the adoption pull request)
10
-
11
- Run it from the app's folder. Beside it, \`depcruise --ignore-known\` ignores the recorded ones.`;
12
-
13
- const args = process.argv.slice(2);
14
- const command = args.shift();
15
- /** @type {{ config?: string, known?: string, targets: string[] }} */
16
- const options = { targets: [] };
17
- for (let i = 0; i < args.length; i++) {
18
- const arg = args[i] ?? "";
19
- if (arg === "--config") options.config = args[++i];
20
- else if (arg === "--known") options.known = args[++i];
21
- else options.targets.push(arg);
22
- }
23
-
24
- if ((command !== "check" && command !== "update" && command !== "init") || options.targets.length === 0) {
25
- console.error(USAGE);
26
- process.exit(2);
27
- }
28
- try {
29
- const { code, lines } = runKnownViolations(command, options);
30
- for (const line of lines) (code === 0 ? console.log : console.error)(line);
31
- process.exit(code);
32
- } catch (error) {
33
- console.error(error instanceof Error ? error.message : String(error));
34
- process.exit(2);
35
- }
@@ -1,50 +0,0 @@
1
- // The covered-domains map (covered-domains.json) and what is generated from it: dependency-cruiser patterns, the message
2
- // that names the Krizaka home, the table of the « Use Krizaka first » page. CommonJS: dependency-cruiser `require`s its
3
- // configs, and Node imports it from ESM (`import { coveredDomains } from "@krizaka/config/covered-domains"`).
4
-
5
- const map = require("./covered-domains.json");
6
-
7
- /** @typedef {{ id: string, title: string, home: string, use: string, refused: string[] }} CoveredDomain */
8
-
9
- /** The domains, in the order of the map. @type {readonly CoveredDomain[]} */
10
- const coveredDomains = Object.freeze(map.domains);
11
-
12
- /** The last thing every message says. */
13
- const MISSING_FUNCTION = "Missing function? add it to the package first.";
14
-
15
- /** @param {string} text */
16
- const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
17
-
18
- /**
19
- * The dependency-cruiser `to.path` regular expression of a refused entry. It matches the resolved file
20
- * (`…/node_modules/dayjs/index.js`, also under pnpm's `.pnpm/…/node_modules/`) and the module as written when it could not
21
- * be resolved (`dayjs`), never a local folder called `dayjs`.
22
- * `@scope/*` is a scope, `prefix*` a name prefix, anything else a package and its subpaths.
23
- * @param {string} entry
24
- */
25
- function refusedPattern(entry) {
26
- const start = "(^|(^|/)node_modules/)";
27
- if (entry.endsWith("/*")) return `${start}${escapeRegExp(entry.slice(0, -1))}`;
28
- if (entry.endsWith("*")) return `${start}${escapeRegExp(entry.slice(0, -1))}[^/]*(/|$)`;
29
- return `${start}${escapeRegExp(entry)}(/|$)`;
30
- }
31
-
32
- /** One pattern for a whole domain. @param {CoveredDomain} domain */
33
- const domainPattern = (domain) => domain.refused.map(refusedPattern).join("|");
34
-
35
- /** The message of a domain: what is refused, the Krizaka home, and what to do when the function is missing. @param {CoveredDomain} domain */
36
- const domainMessage = (domain) =>
37
- `Krizaka first (${domain.id}): this library is refused in an app. Use ${domain.use}. ${MISSING_FUNCTION}`;
38
-
39
- /** The name of the dependency-cruiser rule of a domain. @param {CoveredDomain | string} domain */
40
- const ruleName = (domain) => `krizaka-first-${typeof domain === "string" ? domain : domain.id}`;
41
-
42
- /** The table of the page « Use Krizaka first » (Markdown), generated from the map. */
43
- function coveredDomainsMarkdown() {
44
- const rows = coveredDomains.map(
45
- (domain) => `| ${domain.title} | ${domain.use} | ${domain.refused.map((entry) => `\`${entry}\``).join(", ")} | \`${ruleName(domain)}\` |`,
46
- );
47
- return ["| Domain | Use | Refused in an app | Rule |", "| :-- | :-- | :-- | :-- |", ...rows].join("\n");
48
- }
49
-
50
- module.exports = { coveredDomains, domainMessage, domainPattern, coveredDomainsMarkdown, MISSING_FUNCTION, refusedPattern, ruleName };
@@ -1,40 +0,0 @@
1
- {
2
- "$comment": "The covered-domains map of « Krizaka first » (krizaka-ui#609, #612; krizaka/.github docs/quality-controls.md §5.1). THE single source: the dependency-cruiser preset generates one `krizaka-first-<id>` rule per domain from it, and the page « Use Krizaka first » of ui.krizaka.com is laid out from it. `refused`: a package name (`dayjs`, also its subpaths), a prefix (`date-fns*`) or a scope (`@tanstack/*`). A Krizaka package (`@krizaka/*`, in node_modules) is the facade and is never checked. Add a domain here, never in a tool's config.",
3
- "domains": [
4
- {
5
- "id": "dates",
6
- "title": "Dates, times and local days",
7
- "home": "@krizaka/intl",
8
- "use": "`@krizaka/intl` (formatting, the local day with `localDay`) or `@krizaka/time` (date arithmetic, a facade created on the first real need: krizaka-ts#76)",
9
- "refused": ["date-fns*", "@date-fns/*", "dayjs", "luxon", "moment", "moment-timezone"]
10
- },
11
- {
12
- "id": "i18n",
13
- "title": "Words, languages and messages",
14
- "home": "@krizaka/i18n",
15
- "use": "`@krizaka/i18n`",
16
- "refused": ["i18next*", "react-i18next", "next-intl", "next-i18next", "react-intl", "@formatjs/*", "@lingui/*", "i18n-js", "typesafe-i18n"]
17
- },
18
- {
19
- "id": "query",
20
- "title": "Data fetching, cache, tables, forms and virtual lists",
21
- "home": "@krizaka/query",
22
- "use": "`@krizaka/query` for server state, and `@krizaka/ui` (`data-table`, `form`, `virtual`) or `@krizaka/pacer` for the other TanStack engines",
23
- "refused": ["@tanstack/*"]
24
- },
25
- {
26
- "id": "icons",
27
- "title": "Icons",
28
- "home": "@krizaka/icons",
29
- "use": "`@krizaka/icons`",
30
- "refused": ["lucide-react", "react-icons", "@heroicons/*"]
31
- },
32
- {
33
- "id": "ui",
34
- "title": "Interface primitives",
35
- "home": "@krizaka/ui",
36
- "use": "`@krizaka/ui`",
37
- "refused": ["@radix-ui/*", "radix-ui"]
38
- }
39
- ]
40
- }
@@ -1,116 +0,0 @@
1
- // The known violations of dependency-cruiser, held so that they only shrink (krizaka-ui#612). An app that adopts the
2
- // import rules late records what it already breaks in `.dependency-cruiser-known-violations.json` (the format of
3
- // `depcruise-baseline`, read by `depcruise --ignore-known`); this module compares that file with a real run:
4
- // - a violation that is not recorded is new: refused (`depcruise --ignore-known` refuses it as well);
5
- // - a recorded violation that is gone is a gain: `update` removes it, `check` refuses to leave it recorded, so the
6
- // file cannot hide a regression later;
7
- // - `update` never adds a line. `init` writes the file once, when it does not exist (the adoption pull request).
8
- import { spawnSync } from "node:child_process";
9
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
10
- import { dirname, join, resolve } from "node:path";
11
- import { fileURLToPath } from "node:url";
12
-
13
- export const KNOWN_FILE = ".dependency-cruiser-known-violations.json";
14
- export const CONFIG_FILE = ".dependency-cruiser.cjs";
15
-
16
- /** @typedef {{ type?: string, from: string, to: string, rule: { name: string, severity?: string }, cycle?: { name: string }[] }} Violation */
17
-
18
- /** The identity of a violation, as depcruise matches it: the rule, the two ends, and the cycle's modules. @param {Violation} v */
19
- export const keyOf = (v) => [v.rule.name, v.from, v.to, ...(v.cycle ?? []).map((step) => step.name)].join("\u0000");
20
-
21
- /**
22
- * Compare the recorded violations with the current ones.
23
- * @param {Violation[]} known
24
- * @param {Violation[]} current
25
- */
26
- export function compare(known, current) {
27
- const knownKeys = new Set(known.map(keyOf));
28
- const currentKeys = new Set(current.map(keyOf));
29
- return {
30
- added: current.filter((v) => !knownKeys.has(keyOf(v))),
31
- gone: known.filter((v) => !currentKeys.has(keyOf(v))),
32
- };
33
- }
34
-
35
- /** What `update` writes: the recorded violations that still happen. Never a new one. @param {Violation[]} known @param {Violation[]} current */
36
- export const shrink = (known, current) => known.filter((v) => current.some((c) => keyOf(c) === keyOf(v)));
37
-
38
- /** @param {Violation} v */
39
- const describe = (v) => `${v.rule.name}: ${v.from} -> ${v.to}${v.cycle ? ` (cycle: ${v.cycle.map((step) => step.name).join(" -> ")})` : ""}`;
40
-
41
- /**
42
- * The app's own dependency-cruiser first (its version is the one `depcruise` runs), then the one this package depends on.
43
- * dependency-cruiser is ESM-only and hides its bin from `exports`: walk up `node_modules` from the app, then resolve the entry.
44
- * @param {string} cwd
45
- */
46
- function resolveDepcruise(cwd) {
47
- const bin = (/** @type {string} */ root) => join(root, "bin", "dependency-cruiser.mjs");
48
- for (let dir = resolve(cwd); ; dir = dirname(dir)) {
49
- const candidate = bin(join(dir, "node_modules", "dependency-cruiser"));
50
- if (existsSync(candidate)) return candidate;
51
- if (dirname(dir) === dir) break;
52
- }
53
- let root = dirname(fileURLToPath(import.meta.resolve("dependency-cruiser")));
54
- while (!existsSync(bin(root)) && dirname(root) !== root) root = dirname(root);
55
- if (existsSync(bin(root))) return bin(root);
56
- throw new Error("dependency-cruiser is not installed here: add it to the devDependencies.");
57
- }
58
-
59
- /**
60
- * Run dependency-cruiser without the known file and read every violation (the `baseline` report).
61
- * @param {{ cwd: string, config: string, targets: string[] }} options
62
- * @returns {Violation[]}
63
- */
64
- export function currentViolations({ cwd, config, targets }) {
65
- const bin = resolveDepcruise(cwd);
66
- const run = spawnSync(process.execPath, [bin, ...targets, "--config", config, "--output-type", "baseline"], {
67
- cwd,
68
- encoding: "utf8",
69
- maxBuffer: 256 * 1024 * 1024,
70
- });
71
- try {
72
- return JSON.parse(run.stdout);
73
- } catch {
74
- throw new Error(`dependency-cruiser failed:\n${run.stderr || run.stdout}`);
75
- }
76
- }
77
-
78
- /** @param {string} file @returns {Violation[]} */
79
- const readKnown = (file) => JSON.parse(readFileSync(file, "utf8"));
80
- /** @param {string} file @param {Violation[]} violations */
81
- const writeKnown = (file, violations) => writeFileSync(file, `${JSON.stringify(violations, null, 2)}\n`);
82
-
83
- /**
84
- * `check` | `update` | `init`. Returns the exit code and the lines to print.
85
- * @param {"check" | "update" | "init"} command
86
- * @param {{ cwd?: string, config?: string, known?: string, targets: string[] }} options
87
- * @returns {{ code: number, lines: string[] }}
88
- */
89
- export function runKnownViolations(command, { cwd = process.cwd(), config = CONFIG_FILE, known = KNOWN_FILE, targets }) {
90
- const file = resolve(cwd, known);
91
- const current = currentViolations({ cwd, config, targets });
92
- if (command === "init") {
93
- if (existsSync(file)) return { code: 1, lines: [`${known} exists: the baseline is written once. Use "update" to lower it.`] };
94
- writeKnown(file, current);
95
- return { code: 0, lines: [`${known}: ${current.length} known violations recorded. They only go down.`] };
96
- }
97
- const recorded = existsSync(file) ? readKnown(file) : [];
98
- const { added, gone } = compare(recorded, current);
99
- if (command === "update") {
100
- writeKnown(file, shrink(recorded, current));
101
- const lines = [`${known}: ${recorded.length} -> ${recorded.length - gone.length} known violations.`];
102
- if (added.length > 0) {
103
- lines.push(`${added.length} new violation(s) are NOT recorded (the baseline never grows). Fix them:`, ...added.map((v) => ` ${describe(v)}`));
104
- return { code: 1, lines };
105
- }
106
- return { code: 0, lines };
107
- }
108
- const lines = [
109
- ...added.map((v) => `new: ${describe(v)}`),
110
- ...gone.map((v) => `fixed but still recorded: ${describe(v)}`),
111
- ];
112
- if (added.length > 0) lines.push("A new violation is never recorded: fix it (missing function? add it to the Krizaka package first).");
113
- if (gone.length > 0) lines.push(`Lock the gain: krizaka-known-violations update (${gone.length} fewer).`);
114
- if (lines.length === 0) lines.push(`${known}: ${recorded.length} known violations, none new, none fixed.`);
115
- return { code: added.length + gone.length > 0 ? 1 : 0, lines };
116
- }