@krizaka/config 0.9.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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,9 @@ 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. |
55
57
  | `@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
58
  | `@krizaka/config/prettier` · `.editorconfig` | The formatting. |
57
59
  | `krizaka-ratchet` (bin) | The debt counter, see below. |
@@ -96,15 +98,13 @@ read any file (a Vite/Tauri app has no `"use client"`).
96
98
 
97
99
  ### The three stack rules
98
100
 
99
- From [ADR-001](../../docs/adr/ADR-001-tanstack-under-krizaka-facades.md) — the TanStack engines run headless, behind
101
+ From [ADR-001](https://github.com/krizaka/krizaka-ui/blob/main/docs/adr/ADR-001-tanstack-under-krizaka-facades.md) — the TanStack engines run headless, behind
100
102
  `@krizaka/*` facades; a product never imports them. Warnings: `krizaka-ratchet` counts the same patterns, so an app
101
103
  adopts them before it is clean and brings each count to 0 by the end of its adoption.
102
104
 
103
105
  1. **`krizaka/no-fetch-in-client-effect`** — a `fetch` / `krizakaFetch` inside a `useEffect` (or `useLayoutEffect`)
104
106
  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
107
+ 2. **`krizaka/no-local-table-or-form`** — a `<table>`, or a `<form>` of more than one field (native fields and the field
108
108
  primitives of `@krizaka/ui`; a hidden or submit `<input>` is not a field): use `DataTable` and `createKrizakaForm`.
109
109
  A search box or a one-field newsletter stays a plain form.
110
110
 
@@ -117,8 +117,55 @@ krizakaStack({
117
117
  });
118
118
  ```
119
119
 
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/*`.
120
+ ### « Krizaka first » — the refused imports (dependency-cruiser)
121
+
122
+ Part of the stack ADR and of krizaka-ui#609, in **one tool**: dependency-cruiser carries every refused import (an import
123
+ is an edge: it catches re-exports and dynamic imports, and the same file holds the layers); ESLint keeps the framework
124
+ rules above. The ESLint rule `krizaka/no-direct-tanstack-import` is retired: `@tanstack/*` is `krizaka-first-query`.
125
+
126
+ The **covered-domains map** is `covered-domains.json`, the single source. Each domain names its Krizaka home and what is
127
+ refused in an app; the preset generates `krizaka-first-<domain>` from it, and the page « Use Krizaka first » of
128
+ ui.krizaka.com is laid out from it (`coveredDomainsMarkdown()`).
129
+
130
+ | Domain | Krizaka home | Refused in an app | Rule |
131
+ | :-- | :-- | :-- | :-- |
132
+ | 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` |
133
+ | Words, languages | `@krizaka/i18n` | `i18next*`, `react-i18next`, `next-intl`, `next-i18next`, `react-intl`, `@formatjs/*`, `@lingui/*`, `i18n-js`, `typesafe-i18n` | `krizaka-first-i18n` |
134
+ | Data fetching, cache, tables, forms, virtual lists | `@krizaka/query`, `@krizaka/ui`, `@krizaka/pacer` | `@tanstack/*` | `krizaka-first-query` |
135
+ | Icons | `@krizaka/icons` | `lucide-react`, `react-icons`, `@heroicons/*` | `krizaka-first-icons` |
136
+ | Interface primitives | `@krizaka/ui` | `@radix-ui/*`, `radix-ui` | `krizaka-first-ui` |
137
+
138
+ An entry is a package (`dayjs`, and its subpaths), a prefix (`date-fns*`) or a scope (`@tanstack/*`). It matches the
139
+ resolved file and the module as written when it cannot be resolved (a package the app does not declare), never a local
140
+ folder of the same name. **A Krizaka package is exempt: it is the facade.** In an app that is the packages in
141
+ `node_modules`; in a monorepo that holds its own Krizaka packages, say where they live:
142
+
143
+ ```js
144
+ // .dependency-cruiser.cjs of a monorepo (krizaka-ui, krizaka-ts)
145
+ module.exports = require("@krizaka/config/dependency-cruiser").withOptions({ exemptFrom: ["(^|/)packages/"] });
146
+ ```
147
+
148
+ Every message names the home and ends « Missing function? add it to the package first. » — a missing function is added
149
+ to the Krizaka package, never worked around with the library. Run it with `depcruise --output-type err-long <folders>`
150
+ to read the message (`err` prints the rule name only). Beside the domains, the preset owes every repository
151
+ `no-circular`, `not-to-unresolvable` (declare the dependency, or fix the path; set `options.tsConfig` for path aliases)
152
+ 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
153
+ workspace that owns the `package.json`.
154
+
155
+ #### Known violations (they only shrink)
156
+
157
+ An app that adopts the rules late records what it already breaks, and the debt only goes down:
158
+
159
+ ```bash
160
+ krizaka-known-violations init src app # once, in the adoption pull request: writes .dependency-cruiser-known-violations.json
161
+ depcruise src app --config .dependency-cruiser.cjs --ignore-known .dependency-cruiser-known-violations.json # lint: new violations fail
162
+ krizaka-known-violations check src app # lint: also fails on a recorded violation that is fixed ("lock the gain")
163
+ krizaka-known-violations update src app # removes the fixed ones; NEVER adds one, and fails while a new one exists
164
+ ```
165
+
166
+ The file is the format of `depcruise-baseline`, so `--ignore-known` reads it. `init` refuses to overwrite it; there is
167
+ no command that records a new violation. It replaces the `directTanstackImport` counter of `krizaka-ratchet`, which
168
+ stays only for repositories that have not moved yet.
122
169
 
123
170
  `noLocaleTernary({ files, routing })`: `routing` lists the files allowed to compare locales (default: `proxy.ts`,
124
171
  `middleware.ts`, `i18n.ts`, `seo.ts`, `app/[locale]/layout.tsx`, `I18nProvider.tsx`).
@@ -199,9 +246,4 @@ The CI step, after the install:
199
246
 
200
247
  ## License
201
248
 
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.
249
+ [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,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
  ];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@krizaka/config",
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.",
3
+ "version": "0.9.1",
4
+ "description": "The shared configuration of every Krizaka JavaScript repository: ESLint flat configs (base, Next.js, the four UI rules, the stack rules, no locale ternary), the « Krizaka first » covered-domains map and the dependency-cruiser preset generated from it (refused imports, cycles, unresolvable imports, dev dependencies), tsconfig bases, Prettier, EditorConfig, and the krizaka-ratchet and krizaka-known-violations debt counters.",
5
5
  "keywords": [
6
6
  "krizaka",
7
7
  "eslint-config",
@@ -10,13 +10,14 @@
10
10
  "ratchet",
11
11
  "design-system",
12
12
  "dependency-cruiser",
13
- "tanstack"
13
+ "tanstack",
14
+ "krizaka-first"
14
15
  ],
15
- "homepage": "https://ui.krizaka.com/packages/config",
16
- "bugs": "https://github.com/krizaka/krizaka-ui/issues",
16
+ "homepage": "https://ts.krizaka.com/config",
17
+ "bugs": "https://github.com/krizaka/krizaka-ts/issues",
17
18
  "repository": {
18
19
  "type": "git",
19
- "url": "git+https://github.com/krizaka/krizaka-ui.git",
20
+ "url": "git+https://github.com/krizaka/krizaka-ts.git",
20
21
  "directory": "packages/config"
21
22
  },
22
23
  "license": "Apache-2.0",
@@ -32,6 +33,9 @@
32
33
  "./eslint/env-names": "./eslint/env-names.js",
33
34
  "./eslint/plugin": "./eslint/plugin.js",
34
35
  "./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",
35
39
  "./patterns": "./patterns.js",
36
40
  "./ratchet": "./ratchet/ratchet.js",
37
41
  "./prettier": "./prettier.config.js",
@@ -40,20 +44,21 @@
40
44
  "./tsconfig/next.json": "./tsconfig/next.json",
41
45
  "./tsconfig/react-native.json": "./tsconfig/react-native.json",
42
46
  "./editorconfig": "./.editorconfig",
43
- "./package.json": "./package.json",
44
- "./conformance/account-baseline": "./conformance/account-baseline.js"
47
+ "./package.json": "./package.json"
45
48
  },
46
49
  "bin": {
47
- "krizaka-ratchet": "./bin/krizaka-ratchet.mjs"
50
+ "krizaka-ratchet": "./bin/krizaka-ratchet.mjs",
51
+ "krizaka-known-violations": "./bin/krizaka-known-violations.mjs"
48
52
  },
49
53
  "files": [
50
54
  "bin",
51
- "conformance",
52
55
  "eslint",
53
56
  "ratchet",
54
57
  "tsconfig",
55
58
  "patterns.js",
56
59
  "dependency-cruiser.cjs",
60
+ "covered-domains.cjs",
61
+ "covered-domains.json",
57
62
  "prettier.config.js",
58
63
  ".editorconfig",
59
64
  "README.md",
@@ -61,12 +66,13 @@
61
66
  ],
62
67
  "sideEffects": false,
63
68
  "dependencies": {
64
- "@eslint/js": "^10.0.1",
69
+ "@eslint/js": "^9.39.0 || ^10.0.0",
70
+ "dependency-cruiser": "^18.6.0",
65
71
  "eslint-plugin-react-hooks": "^7.1.1",
66
72
  "eslint-plugin-simple-import-sort": "^14.0.0",
67
73
  "globals": "^17.13.0",
68
74
  "tinyglobby": "^0.2.17",
69
- "typescript-eslint": "^8.72.0"
75
+ "typescript-eslint": "^8.71.1"
70
76
  },
71
77
  "peerDependencies": {
72
78
  "eslint": "^9 || ^10",
@@ -86,15 +92,14 @@
86
92
  "@size-limit/file": "^14.2.0",
87
93
  "@types/estree": "^1.0.8",
88
94
  "@types/node": "^22.15.0",
89
- "dependency-cruiser": "^18.7.0",
90
95
  "eslint": "^9.39.0",
91
- "eslint-config-next": "^16.4.2",
92
- "next": "^16.4.2",
93
- "prettier": "^3.9.10",
96
+ "eslint-config-next": "^16.4.0",
97
+ "next": "^16.4.1",
98
+ "prettier": "^3.9.9",
94
99
  "publint": "^0.3.25",
95
100
  "size-limit": "^14.2.0",
96
101
  "typescript": "^5.9.0",
97
- "vitest": "^5.0.5"
102
+ "vitest": "^4.1.11"
98
103
  },
99
104
  "engines": {
100
105
  "node": ">=22"
@@ -119,6 +124,7 @@
119
124
  "typecheck": "tsc --noEmit",
120
125
  "test": "vitest run",
121
126
  "size": "size-limit",
122
- "publint": "publint"
127
+ "publint": "publint",
128
+ "check": "pnpm run lint && pnpm run test && pnpm run size && pnpm run publint"
123
129
  }
124
130
  }
package/patterns.js CHANGED
@@ -245,20 +245,40 @@ export function balancedArguments(source, open) {
245
245
  let depth = 0;
246
246
  for (let i = open; i < source.length; i += 1) {
247
247
  const c = source[i];
248
- if (c === '"' || c === "'" || c === "`") {
249
- for (i += 1; i < source.length && source[i] !== c; i += 1) if (source[i] === "\\") i += 1;
250
- } else if (c === "/" && source[i + 1] === "/") {
251
- i = source.indexOf("\n", i);
252
- if (i < 0) break;
253
- } else if (c === "/" && source[i + 1] === "*") {
254
- i = source.indexOf("*/", i + 2) + 1;
255
- if (i <= 0) break;
256
- } else if (c === "(") depth += 1;
248
+ const skipped = skipLiteralOrComment(source, i);
249
+ if (skipped === null) break;
250
+ if (skipped > i) i = skipped;
251
+ else if (c === "(") depth += 1;
257
252
  else if (c === ")" && --depth === 0) return source.slice(open + 1, i);
258
253
  }
259
254
  return source.slice(open + 1);
260
255
  }
261
256
 
257
+ /**
258
+ * Where a string, template or comment that starts at `i` ends (the index of its last character), `i` itself when none starts
259
+ * there, null when a line comment or a block comment never ends.
260
+ * @param {string} source
261
+ * @param {number} i
262
+ * @returns {number | null}
263
+ */
264
+ function skipLiteralOrComment(source, i) {
265
+ const c = source[i];
266
+ if (c === '"' || c === "'" || c === "`") {
267
+ let end = i + 1;
268
+ while (end < source.length && source[end] !== c) end += source[end] === "\\" ? 2 : 1;
269
+ return end;
270
+ }
271
+ if (c === "/" && source[i + 1] === "/") {
272
+ const end = source.indexOf("\n", i);
273
+ return end < 0 ? null : end;
274
+ }
275
+ if (c === "/" && source[i + 1] === "*") {
276
+ const end = source.indexOf("*/", i + 2);
277
+ return end < 0 ? null : end + 1;
278
+ }
279
+ return i;
280
+ }
281
+
262
282
  // ── Selections live in the URL (Oussama, 2026-10-22: « changer les tabs doit changer les routes ») ──────────────────────
263
283
  // Read by the ESLint rule krizaka/route-state-selection and by the ratchet.
264
284
 
@@ -312,63 +332,3 @@ export function isLocalSelection(tag, before = "") {
312
332
  if (new RegExp(EPHEMERAL_SOURCE).test(tag) || new RegExp(EPHEMERAL_SOURCE).test(before)) return false;
313
333
  return LOCAL_SELECTION_PROPS.some((prop) => new RegExp(`\\b${prop}\\s*=`).test(tag));
314
334
  }
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
- }
@@ -0,0 +1,116 @@
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
+ }
@@ -2,7 +2,7 @@
2
2
  // Same patterns as the ESLint rules (../patterns.js), applied to the source text: the counter sees every
3
3
  // occurrence (a class in a `cn()` argument or a constant too), so it can only be stricter than the rule.
4
4
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
5
- import { relative, resolve } from "node:path";
5
+ import { isAbsolute, relative, resolve } from "node:path";
6
6
 
7
7
  import { glob } from "tinyglobby";
8
8
 
@@ -16,9 +16,7 @@ import {
16
16
  FETCH_CALL_SOURCE,
17
17
  importSource,
18
18
  isLocalSelection,
19
- isPageFile,
20
19
  LIGHT_VARIANT_SOURCE,
21
- pageTemplateProblem,
22
20
  paletteSource,
23
21
  selectionTags,
24
22
  TABLE_OR_FORM_SOURCE,
@@ -36,7 +34,7 @@ export const DEFAULT_STACK_GLOBS = Object.freeze(
36
34
  const IGNORE = ["**/node_modules/**", "**/dist/**", "**/.next/**", "**/storybook-static/**", "**/coverage/**"];
37
35
 
38
36
  /** @typedef {"palette" | "light" | "arbitraryVar" | "classNameTemplate"} UiCounterName */
39
- /** @typedef {"fetchInClientEffect" | "asyncInEffect" | "derivedStateInEffect" | "directTanstackImport" | "localTableOrForm" | "retiredImport" | "routeStateSelection" | "pageTemplate"} StackCounterName */
37
+ /** @typedef {"fetchInClientEffect" | "asyncInEffect" | "derivedStateInEffect" | "directTanstackImport" | "localTableOrForm" | "retiredImport" | "routeStateSelection"} StackCounterName */
40
38
  /** @typedef {UiCounterName | StackCounterName} CounterName */
41
39
  /** @typedef {Record<CounterName, number>} Counters */
42
40
  /**
@@ -60,7 +58,6 @@ export const STACK_COUNTERS = /** @type {const} */ ([
60
58
  "localTableOrForm",
61
59
  "retiredImport",
62
60
  "routeStateSelection",
63
- "pageTemplate",
64
61
  ]);
65
62
  /** Every counter, in report order. */
66
63
  export const COUNTERS = /** @type {const} */ ([...UI_COUNTERS, ...STACK_COUNTERS]);
@@ -98,7 +95,7 @@ export function countSource(source, allow = []) {
98
95
  * `@tanstack/*`, hand-written tables and forms of more than `maxFormFields` fields, imports of the retired dependencies.
99
96
  * @param {string} source
100
97
  * @param {{ retire?: readonly string[], maxFormFields?: number }} [options]
101
- * @returns {Record<string, number>}
98
+ * @returns {Record<StackCounterName, number>}
102
99
  */
103
100
  export function countStackSource(source, { retire = [], maxFormFields = 1 } = {}) {
104
101
  let fetchInClientEffect = 0;
@@ -136,15 +133,6 @@ export function countStackSource(source, { retire = [], maxFormFields = 1 } = {}
136
133
  };
137
134
  }
138
135
 
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
-
148
136
  /**
149
137
  * Counts the debts under `root`: the UI counters in the files of `globs`, the stack counters in those of `stackGlobs`.
150
138
  * @param {{ root: string, globs?: readonly string[], stackGlobs?: readonly string[], allow?: string[],
@@ -168,8 +156,7 @@ export async function countRepository({
168
156
  }
169
157
  for (const file of stackFiles) {
170
158
  const found = countStackSource(readFileSync(file, "utf8"), { retire, maxFormFields });
171
- for (const name of STACK_COUNTERS) counters[name] += found[name] ?? 0;
172
- counters.pageTemplate += countPages(file, readFileSync(file, "utf8"));
159
+ for (const name of STACK_COUNTERS) counters[name] += found[name];
173
160
  }
174
161
  return { counters, files: files.length, stackFiles: stackFiles.length };
175
162
  }
@@ -218,6 +205,25 @@ export class RatchetError extends Error {}
218
205
  * @property {boolean} written the ratchet file was created, lowered or completed
219
206
  */
220
207
 
208
+ /**
209
+ * Each counter against its recorded value: the report, and the names that went up or down.
210
+ * @param {Counters} current
211
+ * @param {Counters} recorded
212
+ */
213
+ function compareCounters(current, recorded) {
214
+ /** @type {RatchetResult["counters"]} */
215
+ const report = /** @type {any} */ ({});
216
+ /** @type {CounterName[]} */ const increased = [];
217
+ /** @type {CounterName[]} */ const decreased = [];
218
+ for (const name of COUNTERS) {
219
+ const delta = current[name] - recorded[name];
220
+ report[name] = { recorded: recorded[name], current: current[name], delta };
221
+ if (delta > 0) increased.push(name);
222
+ if (delta < 0) decreased.push(name);
223
+ }
224
+ return { report, increased, decreased };
225
+ }
226
+
221
227
  /**
222
228
  * Runs the ratchet.
223
229
  * - `check` (default): fails when a counter is above its recorded value.
@@ -229,6 +235,11 @@ export class RatchetError extends Error {}
229
235
  */
230
236
  export async function runRatchet({ root, mode = "check", globs, file = RATCHET_FILE }) {
231
237
  const path = resolve(root, file);
238
+ // the ratchet file is read and written: it stays a .json file inside the repository root (no `../`, no absolute path elsewhere)
239
+ const inside = relative(resolve(root), path);
240
+ if (!path.endsWith(".json") || inside === "" || inside.startsWith("..") || isAbsolute(inside)) {
241
+ throw new RatchetError(`${file}: the ratchet file must be a .json file inside ${root}.`);
242
+ }
232
243
  const exists = existsSync(path);
233
244
  if (mode === "init" && exists) throw new RatchetError(`${file} already exists: use --update to lower it.`);
234
245
  if (mode !== "init" && !exists) throw new RatchetError(`${file} not found in ${root}: run krizaka-ratchet --init first.`);
@@ -250,16 +261,7 @@ export async function runRatchet({ root, mode = "check", globs, file = RATCHET_F
250
261
  /** @type {CounterName[]} */ const added = recordedFile ? COUNTERS.filter((n) => recordedFile.counters[n] === undefined) : [];
251
262
  const recorded = /** @type {Counters} */ ({ ...current, ...recordedFile?.counters });
252
263
 
253
- /** @type {RatchetResult["counters"]} */
254
- const report = /** @type {any} */ ({});
255
- /** @type {CounterName[]} */ const increased = [];
256
- /** @type {CounterName[]} */ const decreased = [];
257
- for (const name of COUNTERS) {
258
- const delta = current[name] - recorded[name];
259
- report[name] = { recorded: recorded[name], current: current[name], delta };
260
- if (delta > 0) increased.push(name);
261
- if (delta < 0) decreased.push(name);
262
- }
264
+ const { report, increased, decreased } = compareCounters(current, recorded);
263
265
 
264
266
  let written = false;
265
267
  if (mode === "init") {
@@ -1,116 +0,0 @@
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
- }