@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 +73 -15
- package/bin/krizaka-known-violations.mjs +35 -0
- package/bin/krizaka-ratchet.mjs +0 -1
- package/bin/krizaka-server-client-boundary.mjs +36 -0
- package/covered-domains.cjs +50 -0
- package/covered-domains.json +40 -0
- package/dependency-cruiser.cjs +72 -24
- package/eslint/plugin.js +1 -3
- package/eslint/rules.js +20 -66
- package/eslint/stack.js +5 -9
- package/package.json +26 -18
- package/patterns.js +29 -69
- package/ratchet/known-violations.js +116 -0
- package/ratchet/ratchet.js +29 -27
- package/ratchet/server-client-boundary.js +187 -0
- package/conformance/account-baseline.js +0 -116
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
|
[](https://www.npmjs.com/package/@krizaka/config)
|
|
8
|
-
[](
|
|
8
|
+
[](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 —
|
|
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-
|
|
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](
|
|
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-
|
|
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
|
-
|
|
121
|
-
|
|
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](
|
|
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
|
+
}
|
package/bin/krizaka-ratchet.mjs
CHANGED
|
@@ -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
|
+
}
|
package/dependency-cruiser.cjs
CHANGED
|
@@ -1,31 +1,79 @@
|
|
|
1
|
-
// The shared dependency-cruiser rules of a Krizaka app (the stack ADR, decision S11
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
]
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
|
2
|
-
//
|
|
3
|
-
// app adopts them now and brings each count to 0 by the end
|
|
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
|
|
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
|
];
|