@krizaka/config 0.9.0 → 0.10.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)](../../LICENSE)
8
+ [![License](https://img.shields.io/badge/license-Apache--2.0-blue)](https://github.com/krizaka/krizaka-ts/blob/main/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 — the TanStack boundary, plus the app's own rules
27
+ // .dependency-cruiser.cjs — « Krizaka first », cycles, unresolvable imports, dev dependencies, plus the app's own rules
28
28
  module.exports = { extends: "@krizaka/config/dependency-cruiser", forbidden: [] };
29
29
  ```
30
30
 
@@ -51,7 +51,10 @@ 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: `krizaka-no-direct-tanstack` (no `@tanstack/*` from an app). |
54
+ | `@krizaka/config/dependency-cruiser` | The shared dependency-cruiser config, generated from the covered-domains map: one `krizaka-first-<domain>` rule per domain (dates, i18n, query, icons, ui), `no-circular`, `not-to-unresolvable`, `not-to-dev-dep`. `.withOptions({ exemptFrom })` for a monorepo whose own packages are the facades. |
55
+ | `@krizaka/config/covered-domains` · `/covered-domains.json` | The covered-domains map (one data file) and its helpers: the message, the patterns, the Markdown table of « Use Krizaka first ». |
56
+ | `krizaka-known-violations` (bin) · `@krizaka/config/known-violations` | The known violations of dependency-cruiser, which only shrink, see below. |
57
+ | `krizaka-server-client-boundary` (bin) · `@krizaka/config/server-client-boundary` | Fails when a server-rendered module imports a function from a `"use client"` `@krizaka/*` module (React #441), see below. |
55
58
  | `@krizaka/config/tsconfig/*.json` | `base` (strict, ES2022, bundler resolution), `react-library` (+ DOM, `react-jsx`), `next` (+ the Next plugin, `noEmit`, `incremental`), `react-native` (`jsx: react-native`, `customConditions`; with Expo: `"extends": ["expo/tsconfig.base", "@krizaka/config/tsconfig/react-native.json"]`). |
56
59
  | `@krizaka/config/prettier` · `.editorconfig` | The formatting. |
57
60
  | `krizaka-ratchet` (bin) | The debt counter, see below. |
@@ -96,15 +99,13 @@ read any file (a Vite/Tauri app has no `"use client"`).
96
99
 
97
100
  ### The three stack rules
98
101
 
99
- From [ADR-001](../../docs/adr/ADR-001-tanstack-under-krizaka-facades.md) — the TanStack engines run headless, behind
102
+ 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
100
103
  `@krizaka/*` facades; a product never imports them. Warnings: `krizaka-ratchet` counts the same patterns, so an app
101
104
  adopts them before it is clean and brings each count to 0 by the end of its adoption.
102
105
 
103
106
  1. **`krizaka/no-fetch-in-client-effect`** — a `fetch` / `krizakaFetch` inside a `useEffect` (or `useLayoutEffect`)
104
107
  of a `"use client"` file: render the first screen in a Server Component, read the rest with `@krizaka/query`.
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
108
+ 2. **`krizaka/no-local-table-or-form`** — a `<table>`, or a `<form>` of more than one field (native fields and the field
108
109
  primitives of `@krizaka/ui`; a hidden or submit `<input>` is not a field): use `DataTable` and `createKrizakaForm`.
109
110
  A search box or a one-field newsletter stays a plain form.
110
111
 
@@ -117,12 +118,74 @@ krizakaStack({
117
118
  });
118
119
  ```
119
120
 
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/*`.
121
+ ### « Krizaka first » — the refused imports (dependency-cruiser)
122
+
123
+ Part of the stack ADR and of krizaka-ui#609, in **one tool**: dependency-cruiser carries every refused import (an import
124
+ is an edge: it catches re-exports and dynamic imports, and the same file holds the layers); ESLint keeps the framework
125
+ rules above. The ESLint rule `krizaka/no-direct-tanstack-import` is retired: `@tanstack/*` is `krizaka-first-query`.
126
+
127
+ The **covered-domains map** is `covered-domains.json`, the single source. Each domain names its Krizaka home and what is
128
+ refused in an app; the preset generates `krizaka-first-<domain>` from it, and the page « Use Krizaka first » of
129
+ ui.krizaka.com is laid out from it (`coveredDomainsMarkdown()`).
130
+
131
+ | Domain | Krizaka home | Refused in an app | Rule |
132
+ | :-- | :-- | :-- | :-- |
133
+ | 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` |
134
+ | Words, languages | `@krizaka/i18n` | `i18next*`, `react-i18next`, `next-intl`, `next-i18next`, `react-intl`, `@formatjs/*`, `@lingui/*`, `i18n-js`, `typesafe-i18n` | `krizaka-first-i18n` |
135
+ | Data fetching, cache, tables, forms, virtual lists | `@krizaka/query`, `@krizaka/ui`, `@krizaka/pacer` | `@tanstack/*` | `krizaka-first-query` |
136
+ | Icons | `@krizaka/icons` | `lucide-react`, `react-icons`, `@heroicons/*` | `krizaka-first-icons` |
137
+ | Interface primitives | `@krizaka/ui` | `@radix-ui/*`, `radix-ui` | `krizaka-first-ui` |
138
+
139
+ An entry is a package (`dayjs`, and its subpaths), a prefix (`date-fns*`) or a scope (`@tanstack/*`). It matches the
140
+ resolved file and the module as written when it cannot be resolved (a package the app does not declare), never a local
141
+ folder of the same name. **A Krizaka package is exempt: it is the facade.** In an app that is the packages in
142
+ `node_modules`; in a monorepo that holds its own Krizaka packages, say where they live:
143
+
144
+ ```js
145
+ // .dependency-cruiser.cjs of a monorepo (krizaka-ui, krizaka-ts)
146
+ module.exports = require("@krizaka/config/dependency-cruiser").withOptions({ exemptFrom: ["(^|/)packages/"] });
147
+ ```
148
+
149
+ Every message names the home and ends « Missing function? add it to the package first. » — a missing function is added
150
+ to the Krizaka package, never worked around with the library. Run it with `depcruise --output-type err-long <folders>`
151
+ to read the message (`err` prints the rule name only). Beside the domains, the preset owes every repository
152
+ `no-circular`, `not-to-unresolvable` (declare the dependency, or fix the path; set `options.tsConfig` for path aliases)
153
+ 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
154
+ workspace that owns the `package.json`.
155
+
156
+ #### Known violations (they only shrink)
157
+
158
+ An app that adopts the rules late records what it already breaks, and the debt only goes down:
159
+
160
+ ```bash
161
+ krizaka-known-violations init src app # once, in the adoption pull request: writes .dependency-cruiser-known-violations.json
162
+ depcruise src app --config .dependency-cruiser.cjs --ignore-known .dependency-cruiser-known-violations.json # lint: new violations fail
163
+ krizaka-known-violations check src app # lint: also fails on a recorded violation that is fixed ("lock the gain")
164
+ krizaka-known-violations update src app # removes the fixed ones; NEVER adds one, and fails while a new one exists
165
+ ```
166
+
167
+ The file is the format of `depcruise-baseline`, so `--ignore-known` reads it. `init` refuses to overwrite it; there is
168
+ no command that records a new violation. It replaces the `directTanstackImport` counter of `krizaka-ratchet`, which
169
+ stays only for repositories that have not moved yet.
122
170
 
123
171
  `noLocaleTernary({ files, routing })`: `routing` lists the files allowed to compare locales (default: `proxy.ts`,
124
172
  `middleware.ts`, `i18n.ts`, `seo.ts`, `app/[locale]/layout.tsx`, `I18nProvider.tsx`).
125
173
 
174
+ ### Server/client boundary (`krizaka-server-client-boundary`)
175
+
176
+ A Next.js server module that calls a function exported by a `"use client"` module (`readFilterParams()` of
177
+ `@krizaka/ui/filter-bar`) crashes the page with React error #441. Unit tests ignore the directive and `next build` does not
178
+ see it. Every Next.js App Router app runs the guard in its lint:
179
+
180
+ ```sh
181
+ krizaka-server-client-boundary # from the app's folder: reads app/ or src/app/ and the "@/*" alias of tsconfig.json
182
+ krizaka-server-client-boundary --root apps/web --app app --scope @krizaka
183
+ ```
184
+
185
+ It walks the pages, layouts and routes, then every local module they import, and stops at a `"use client"` file. A lowercase
186
+ value import (a function, a hook, a constant) from a client module of the scope is an offence; components and `import type`
187
+ are fine. Render a component, or move the function to a plain module. `checkBoundary()` is also exported for a test.
188
+
126
189
  ### Environment variable names (`krizaka/env-name`)
127
190
 
128
191
  Environment variables are named by bounded context (krizaka/.github#46): `<CONTEXT>_<THING>` with CONTEXT one of
@@ -199,9 +262,4 @@ The CI step, after the install:
199
262
 
200
263
  ## License
201
264
 
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.
265
+ [Apache-2.0](https://github.com/krizaka/krizaka-ts/blob/main/LICENSE)
@@ -0,0 +1,35 @@
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
+ }
@@ -30,7 +30,6 @@ const LABELS = {
30
30
  localTableOrForm: "hand-written tables / forms",
31
31
  retiredImport: "imports of retired deps",
32
32
  routeStateSelection: "selections in local state (not the URL)",
33
- pageTemplate: "pages with no template declared",
34
33
  };
35
34
 
36
35
  let args;
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ // krizaka-server-client-boundary: no server-rendered module calls a function exported by a "use client" Krizaka module (React #441).
3
+ import { checkBoundary, DEFAULT_SCOPE } from "../ratchet/server-client-boundary.js";
4
+
5
+ const USAGE = `Usage: krizaka-server-client-boundary [--root <app folder>] [--app <app router folder>] [--alias <folder of "@/">] [--scope ${DEFAULT_SCOPE}]
6
+
7
+ Walks what the server renders (pages, layouts, routes and the local modules they import, down to a "use client" file) and fails
8
+ when one imports a function (a lowercase export) from a "use client" module of the scope: the server would call a client
9
+ reference and the page would crash (React error #441). Render a component, or move the function to a plain module.`;
10
+
11
+ const options = {};
12
+ const args = process.argv.slice(2);
13
+ for (let i = 0; i < args.length; i++) {
14
+ const arg = args[i];
15
+ if (arg === "--root") options.root = args[++i];
16
+ else if (arg === "--app") options.appDir = args[++i];
17
+ else if (arg === "--alias") options.alias = args[++i];
18
+ else if (arg === "--scope") options.scope = args[++i];
19
+ else {
20
+ console.error(USAGE);
21
+ process.exit(2);
22
+ }
23
+ }
24
+ try {
25
+ const { seen, offences } = checkBoundary(options);
26
+ if (offences.length === 0) {
27
+ console.log(`krizaka-server-client-boundary: ${seen.size} server modules checked, no client function called from the server.`);
28
+ } else {
29
+ console.error(`krizaka-server-client-boundary: ${offences.length} server module(s) import a function from a "use client" module (React #441):`);
30
+ for (const line of offences) console.error(` ${line}`);
31
+ process.exit(1);
32
+ }
33
+ } catch (error) {
34
+ console.error(error instanceof Error ? error.message : String(error));
35
+ process.exit(2);
36
+ }
@@ -0,0 +1,50 @@
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 };
@@ -0,0 +1,40 @@
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,31 +1,79 @@
1
- // The shared dependency-cruiser rules of a Krizaka app (the stack ADR, decision S11). An app extends it and adds its own:
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:
2
4
  //
3
5
  // // .dependency-cruiser.cjs
4
6
  // module.exports = { extends: "@krizaka/config/dependency-cruiser", forbidden: [/* the app's rules */] };
5
7
  //
6
8
  // 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).
7
16
 
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/" },
21
- },
22
- ],
23
- options: {
24
- doNotFollow: { path: "(^|/)node_modules/" },
25
- tsPreCompilationDeps: true,
26
- enhancedResolveOptions: {
27
- exportsFields: ["exports"],
28
- conditionNames: ["import", "require", "node", "default"],
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
+
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
+ },
29
73
  },
30
- },
31
- };
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 });
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, noAsyncInEffect, noDerivedStateInEffect, noDirectTanstackImport, noFamilySwitcher, noFetchInClientEffect, noLightVariant, noLocaleTernaryRule, noLocalTableOrForm, noRawPalette,pageTemplate, routeStateSelection } from "./rules.js";
3
+ import { envName, noAsyncInEffect, noDerivedStateInEffect, noFamilySwitcher, noFetchInClientEffect, noLightVariant, noLocaleTernaryRule, noLocalTableOrForm, noRawPalette,routeStateSelection } from "./rules.js";
4
4
 
5
5
  /** @type {import("eslint").ESLint.Plugin} */
6
6
  export const krizakaPlugin = {
@@ -10,14 +10,12 @@ export const krizakaPlugin = {
10
10
  "no-fetch-in-client-effect": noFetchInClientEffect,
11
11
  "no-async-in-effect": noAsyncInEffect,
12
12
  "no-derived-state-in-effect": noDerivedStateInEffect,
13
- "no-direct-tanstack-import": noDirectTanstackImport,
14
13
  "no-local-table-or-form": noLocalTableOrForm,
15
14
  "no-light-variant": noLightVariant,
16
15
  "no-raw-palette": noRawPalette,
17
16
  "env-name": envName,
18
17
  "no-family-switcher": noFamilySwitcher,
19
18
  "route-state-selection": routeStateSelection,
20
- "page-template": pageTemplate,
21
19
  },
22
20
  };
23
21
 
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, effectProblem, FETCH_NAMES_SOURCE, isLocalSelection, isPageFile, LIGHT_VARIANT_SOURCE, PAGE_TEMPLATES, pageTemplateProblem, paletteSource, TANSTACK_SCOPE } from "../patterns.js";
6
+ import { countFormFields, EFFECT_NAMES_SOURCE, effectProblem, FETCH_NAMES_SOURCE, isLocalSelection, LIGHT_VARIANT_SOURCE, paletteSource } from "../patterns.js";
7
7
 
8
8
  /** @typedef {import("eslint").Rule.RuleModule} RuleModule */
9
9
  /** @typedef {import("estree").Node} Node */
@@ -116,36 +116,6 @@ export const noDerivedStateInEffect = {
116
116
  create: (context) => effectVisitor(context, "derived", "derivedState"),
117
117
  };
118
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
-
149
119
  /** Oussama, 2026-10-22: « changer les tabs doit changer les routes » — a selection of a page lives in the URL. */
150
120
  /** @type {RuleModule} */
151
121
  export const routeStateSelection = {
@@ -245,16 +215,7 @@ export function ownProductPrefix(cwd) {
245
215
  for (let dir = cwd; ; dir = dirname(dir)) {
246
216
  const file = join(dir, "package.json");
247
217
  if (existsSync(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
- }
218
+ found = productPrefixOf(file);
258
219
  break;
259
220
  }
260
221
  if (dirname(dir) === dir) break;
@@ -263,6 +224,24 @@ export function ownProductPrefix(cwd) {
263
224
  return found;
264
225
  }
265
226
 
227
+ /**
228
+ * The product word of one package.json (see `ownProductPrefix`); null when it cannot be read or is not a product.
229
+ * @param {string} file
230
+ * @returns {string | null}
231
+ */
232
+ function productPrefixOf(file) {
233
+ try {
234
+ const name = String(JSON.parse(readFileSync(file, "utf8")).name ?? "");
235
+ // A product names its packages `<product>-<part>` (acme-monorepo, @org/acme-admin); a one-word package
236
+ // (@org/realtime, @org/ui) is a building block, not a product, and a context word is never one.
237
+ const words = name.replace(/^@[^/]+\//, "").split(/[-_.]/).filter(Boolean);
238
+ const word = words.length > 1 ? words[0].toUpperCase() : "";
239
+ return /^[A-Z][A-Z0-9]+$/.test(word) && !ENV_CONTEXT_WORDS.includes(word) ? word : null;
240
+ } catch {
241
+ return null;
242
+ }
243
+ }
244
+
266
245
  /** Vendors: a vendor is a value (`MEDIA_STORAGE_PROVIDER=bunny`, `MAIL_PROVIDER=mailgun`), never a name. */
267
246
  export const ENV_VENDOR_PREFIXES = Object.freeze([
268
247
  "BUNNY", "MAILGUN", "RESEND", "SENDGRID", "POSTMARK", "STRIPE", "CCBILL", "SEGPAY", "NOWPAYMENTS", "PAYPAL",
@@ -392,28 +371,3 @@ export const noRawPalette = classNameRule(
392
371
  ({ allow = [] }) => new RegExp(paletteSource(allow)),
393
372
  "Raw palette colour: use a role (bg-surface-1, text-fg-secondary, border-border-default, text-accent, text-danger).",
394
373
  );
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,6 +1,7 @@
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.
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.
4
5
  import { krizakaPlugin } from "./plugin.js";
5
6
 
6
7
  /** The rule names, as configured. */
@@ -8,15 +9,13 @@ export const STACK_RULES = Object.freeze([
8
9
  "krizaka/no-fetch-in-client-effect",
9
10
  "krizaka/no-async-in-effect",
10
11
  "krizaka/no-derived-state-in-effect",
11
- "krizaka/no-direct-tanstack-import",
12
12
  "krizaka/no-local-table-or-form",
13
13
  "krizaka/no-family-switcher",
14
14
  "krizaka/route-state-selection",
15
- "krizaka/page-template",
16
15
  ]);
17
16
 
18
17
  /**
19
- * Flat config: the three stack rules on an app's sources.
18
+ * Flat config: the stack rules on an app's sources.
20
19
  * @param {{ files?: string[], ignores?: string[], severity?: "warn" | "error", effects?: "warn" | "error" | "off", maxFormFields?: number }} [options]
21
20
  * `effects`: severity of the two effect rules (react-effects standard): `"error"` by default; a repository with debt
22
21
  * passes `"warn"` while `krizaka-ratchet` (asyncInEffect, derivedStateInEffect) brings the count to 0.
@@ -41,14 +40,11 @@ export function krizakaStack({
41
40
  // useEffect is for the outside world: data is useQuery/useMutation, derived state is computed in render.
42
41
  "krizaka/no-async-in-effect": effects,
43
42
  "krizaka/no-derived-state-in-effect": effects,
44
- "krizaka/no-direct-tanstack-import": severity,
45
43
  "krizaka/no-local-table-or-form": [severity, { maxFormFields }],
46
44
  // Never a warning: the family switcher is gone from every site (krizaka.com is the only way back).
47
45
  "krizaka/no-family-switcher": "error",
48
46
  // A selection of a view (tabs, segmented, toggles, chip groups) lives in the URL, not in local state.
49
47
  "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,
52
48
  },
53
49
  },
54
50
  ];