@krizaka/config 0.7.0 → 0.8.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 +58 -11
- package/bin/krizaka-known-violations.mjs +35 -0
- package/covered-domains.cjs +50 -0
- package/covered-domains.json +40 -0
- package/dependency-cruiser.cjs +72 -24
- package/eslint/plugin.js +1 -2
- package/eslint/rules.js +20 -41
- package/eslint/stack.js +5 -6
- package/package.json +18 -10
- package/patterns.js +29 -9
- package/ratchet/known-violations.js +116 -0
- package/ratchet/ratchet.js +26 -11
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,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-
|
|
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. |
|
|
@@ -81,17 +83,15 @@ Flat config replaces a rule's options instead of concatenating them: a repositor
|
|
|
81
83
|
`no-restricted-syntax` merges both lists with `krizakaUiRestrictedSyntax()`. `no-locale-ternary` is a plugin rule for
|
|
82
84
|
that reason — it composes with anything.
|
|
83
85
|
|
|
84
|
-
### The
|
|
86
|
+
### The stack rules
|
|
85
87
|
|
|
86
|
-
From [ADR-001](
|
|
88
|
+
From [ADR-001](https://github.com/krizaka/krizaka-ui/blob/main/docs/adr/ADR-001-tanstack-under-krizaka-facades.md) — the TanStack engines run headless, behind
|
|
87
89
|
`@krizaka/*` facades; a product never imports them. Warnings: `krizaka-ratchet` counts the same patterns, so an app
|
|
88
90
|
adopts them before it is clean and brings each count to 0 by the end of its adoption.
|
|
89
91
|
|
|
90
92
|
1. **`krizaka/no-fetch-in-client-effect`** — a `fetch` / `krizakaFetch` inside a `useEffect` (or `useLayoutEffect`)
|
|
91
93
|
of a `"use client"` file: render the first screen in a Server Component, read the rest with `@krizaka/query`.
|
|
92
|
-
2. **`krizaka/no-
|
|
93
|
-
the facade (`@krizaka/query`, `@krizaka/ui/data-table`, `@krizaka/ui/form`, `@krizaka/ui/virtual`, `@krizaka/pacer`).
|
|
94
|
-
3. **`krizaka/no-local-table-or-form`** — a `<table>`, or a `<form>` of more than one field (native fields and the field
|
|
94
|
+
2. **`krizaka/no-local-table-or-form`** — a `<table>`, or a `<form>` of more than one field (native fields and the field
|
|
95
95
|
primitives of `@krizaka/ui`; a hidden or submit `<input>` is not a field): use `DataTable` and `createKrizakaForm`.
|
|
96
96
|
A search box or a one-field newsletter stays a plain form.
|
|
97
97
|
|
|
@@ -104,8 +104,55 @@ krizakaStack({
|
|
|
104
104
|
});
|
|
105
105
|
```
|
|
106
106
|
|
|
107
|
-
|
|
108
|
-
|
|
107
|
+
### « Krizaka first » — the refused imports (dependency-cruiser)
|
|
108
|
+
|
|
109
|
+
Part of the stack ADR and of krizaka-ui#609, in **one tool**: dependency-cruiser carries every refused import (an import
|
|
110
|
+
is an edge: it catches re-exports and dynamic imports, and the same file holds the layers); ESLint keeps the framework
|
|
111
|
+
rules above. The ESLint rule `krizaka/no-direct-tanstack-import` is retired: `@tanstack/*` is `krizaka-first-query`.
|
|
112
|
+
|
|
113
|
+
The **covered-domains map** is `covered-domains.json`, the single source. Each domain names its Krizaka home and what is
|
|
114
|
+
refused in an app; the preset generates `krizaka-first-<domain>` from it, and the page « Use Krizaka first » of
|
|
115
|
+
ui.krizaka.com is laid out from it (`coveredDomainsMarkdown()`).
|
|
116
|
+
|
|
117
|
+
| Domain | Krizaka home | Refused in an app | Rule |
|
|
118
|
+
| :-- | :-- | :-- | :-- |
|
|
119
|
+
| Dates, times, local days | `@krizaka/intl` (formatting, `localDay`), `@krizaka/time` (arithmetic; a facade created on the first real need, krizaka-ts#76) | `date-fns*`, `@date-fns/*`, `dayjs`, `luxon`, `moment`, `moment-timezone` | `krizaka-first-dates` |
|
|
120
|
+
| Words, languages | `@krizaka/i18n` | `i18next*`, `react-i18next`, `next-intl`, `next-i18next`, `react-intl`, `@formatjs/*`, `@lingui/*`, `i18n-js`, `typesafe-i18n` | `krizaka-first-i18n` |
|
|
121
|
+
| Data fetching, cache, tables, forms, virtual lists | `@krizaka/query`, `@krizaka/ui`, `@krizaka/pacer` | `@tanstack/*` | `krizaka-first-query` |
|
|
122
|
+
| Icons | `@krizaka/icons` | `lucide-react`, `react-icons`, `@heroicons/*` | `krizaka-first-icons` |
|
|
123
|
+
| Interface primitives | `@krizaka/ui` | `@radix-ui/*`, `radix-ui` | `krizaka-first-ui` |
|
|
124
|
+
|
|
125
|
+
An entry is a package (`dayjs`, and its subpaths), a prefix (`date-fns*`) or a scope (`@tanstack/*`). It matches the
|
|
126
|
+
resolved file and the module as written when it cannot be resolved (a package the app does not declare), never a local
|
|
127
|
+
folder of the same name. **A Krizaka package is exempt: it is the facade.** In an app that is the packages in
|
|
128
|
+
`node_modules`; in a monorepo that holds its own Krizaka packages, say where they live:
|
|
129
|
+
|
|
130
|
+
```js
|
|
131
|
+
// .dependency-cruiser.cjs of a monorepo (krizaka-ui, krizaka-ts)
|
|
132
|
+
module.exports = require("@krizaka/config/dependency-cruiser").withOptions({ exemptFrom: ["(^|/)packages/"] });
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Every message names the home and ends « Missing function? add it to the package first. » — a missing function is added
|
|
136
|
+
to the Krizaka package, never worked around with the library. Run it with `depcruise --output-type err-long <folders>`
|
|
137
|
+
to read the message (`err` prints the rule name only). Beside the domains, the preset owes every repository
|
|
138
|
+
`no-circular`, `not-to-unresolvable` (declare the dependency, or fix the path; set `options.tsConfig` for path aliases)
|
|
139
|
+
and `not-to-dev-dep` (tests, stories, scripts and tool configs may import devDependencies; a package that is also a `peerDependency` is installed by the consumer, so production code may import it). Run `depcruise` from the
|
|
140
|
+
workspace that owns the `package.json`.
|
|
141
|
+
|
|
142
|
+
#### Known violations (they only shrink)
|
|
143
|
+
|
|
144
|
+
An app that adopts the rules late records what it already breaks, and the debt only goes down:
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
krizaka-known-violations init src app # once, in the adoption pull request: writes .dependency-cruiser-known-violations.json
|
|
148
|
+
depcruise src app --config .dependency-cruiser.cjs --ignore-known .dependency-cruiser-known-violations.json # lint: new violations fail
|
|
149
|
+
krizaka-known-violations check src app # lint: also fails on a recorded violation that is fixed ("lock the gain")
|
|
150
|
+
krizaka-known-violations update src app # removes the fixed ones; NEVER adds one, and fails while a new one exists
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
The file is the format of `depcruise-baseline`, so `--ignore-known` reads it. `init` refuses to overwrite it; there is
|
|
154
|
+
no command that records a new violation. It replaces the `directTanstackImport` counter of `krizaka-ratchet`, which
|
|
155
|
+
stays only for repositories that have not moved yet.
|
|
109
156
|
|
|
110
157
|
`noLocaleTernary({ files, routing })`: `routing` lists the files allowed to compare locales (default: `proxy.ts`,
|
|
111
158
|
`middleware.ts`, `i18n.ts`, `seo.ts`, `app/[locale]/layout.tsx`, `I18nProvider.tsx`).
|
|
@@ -184,4 +231,4 @@ The CI step, after the install:
|
|
|
184
231
|
|
|
185
232
|
## License
|
|
186
233
|
|
|
187
|
-
[Apache-2.0](
|
|
234
|
+
[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
|
+
}
|
|
@@ -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,
|
|
3
|
+
import { envName, noFamilySwitcher, noFetchInClientEffect, noLightVariant, noLocaleTernaryRule, noLocalTableOrForm, noRawPalette,routeStateSelection } from "./rules.js";
|
|
4
4
|
|
|
5
5
|
/** @type {import("eslint").ESLint.Plugin} */
|
|
6
6
|
export const krizakaPlugin = {
|
|
@@ -8,7 +8,6 @@ export const krizakaPlugin = {
|
|
|
8
8
|
rules: {
|
|
9
9
|
"no-locale-ternary": noLocaleTernaryRule,
|
|
10
10
|
"no-fetch-in-client-effect": noFetchInClientEffect,
|
|
11
|
-
"no-direct-tanstack-import": noDirectTanstackImport,
|
|
12
11
|
"no-local-table-or-form": noLocalTableOrForm,
|
|
13
12
|
"no-light-variant": noLightVariant,
|
|
14
13
|
"no-raw-palette": noRawPalette,
|
package/eslint/rules.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { existsSync, readFileSync } from "node:fs";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
|
|
6
|
-
import { countFormFields, EFFECT_NAMES_SOURCE, FETCH_NAMES_SOURCE, isLocalSelection, LIGHT_VARIANT_SOURCE, paletteSource
|
|
6
|
+
import { countFormFields, EFFECT_NAMES_SOURCE, 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 */
|
|
@@ -70,36 +70,6 @@ export const noFetchInClientEffect = {
|
|
|
70
70
|
},
|
|
71
71
|
};
|
|
72
72
|
|
|
73
|
-
/** S11: `@tanstack/*` is imported by the `@krizaka/*` packages only — the version is the contract. */
|
|
74
|
-
/** @type {RuleModule} */
|
|
75
|
-
export const noDirectTanstackImport = {
|
|
76
|
-
meta: {
|
|
77
|
-
type: "problem",
|
|
78
|
-
docs: { description: "Forbid importing @tanstack/* outside the @krizaka/* packages." },
|
|
79
|
-
schema: [],
|
|
80
|
-
messages: {
|
|
81
|
-
tanstack:
|
|
82
|
-
"{{source}} is imported through its @krizaka facade (@krizaka/query, @krizaka/ui/data-table, @krizaka/ui/form, @krizaka/ui/virtual, @krizaka/pacer…), never directly.",
|
|
83
|
-
},
|
|
84
|
-
},
|
|
85
|
-
create(context) {
|
|
86
|
-
/** @param {Node | null | undefined} source */
|
|
87
|
-
const check = (source) => {
|
|
88
|
-
if (source?.type === "Literal" && typeof source.value === "string" && source.value.startsWith(TANSTACK_SCOPE)) {
|
|
89
|
-
context.report({ node: source, messageId: "tanstack", data: { source: source.value } });
|
|
90
|
-
}
|
|
91
|
-
};
|
|
92
|
-
return {
|
|
93
|
-
ImportDeclaration: (node) => check(node.source),
|
|
94
|
-
ExportNamedDeclaration: (node) => check(node.source),
|
|
95
|
-
ExportAllDeclaration: (node) => check(node.source),
|
|
96
|
-
ImportExpression: (node) => check(node.source),
|
|
97
|
-
"CallExpression[callee.name='require']": (/** @type {import("estree").CallExpression} */ node) =>
|
|
98
|
-
check(/** @type {Node} */ (node.arguments[0])),
|
|
99
|
-
};
|
|
100
|
-
},
|
|
101
|
-
};
|
|
102
|
-
|
|
103
73
|
/** Oussama, 2026-10-22: « changer les tabs doit changer les routes » — a selection of a page lives in the URL. */
|
|
104
74
|
/** @type {RuleModule} */
|
|
105
75
|
export const routeStateSelection = {
|
|
@@ -199,16 +169,7 @@ export function ownProductPrefix(cwd) {
|
|
|
199
169
|
for (let dir = cwd; ; dir = dirname(dir)) {
|
|
200
170
|
const file = join(dir, "package.json");
|
|
201
171
|
if (existsSync(file)) {
|
|
202
|
-
|
|
203
|
-
const name = String(JSON.parse(readFileSync(file, "utf8")).name ?? "");
|
|
204
|
-
// A product names its packages `<product>-<part>` (acme-monorepo, @org/acme-admin); a one-word package
|
|
205
|
-
// (@org/realtime, @org/ui) is a building block, not a product, and a context word is never one.
|
|
206
|
-
const words = name.replace(/^@[^/]+\//, "").split(/[-_.]/).filter(Boolean);
|
|
207
|
-
const word = words.length > 1 ? words[0].toUpperCase() : "";
|
|
208
|
-
found = /^[A-Z][A-Z0-9]+$/.test(word) && !ENV_CONTEXT_WORDS.includes(word) ? word : null;
|
|
209
|
-
} catch {
|
|
210
|
-
found = null;
|
|
211
|
-
}
|
|
172
|
+
found = productPrefixOf(file);
|
|
212
173
|
break;
|
|
213
174
|
}
|
|
214
175
|
if (dirname(dir) === dir) break;
|
|
@@ -217,6 +178,24 @@ export function ownProductPrefix(cwd) {
|
|
|
217
178
|
return found;
|
|
218
179
|
}
|
|
219
180
|
|
|
181
|
+
/**
|
|
182
|
+
* The product word of one package.json (see `ownProductPrefix`); null when it cannot be read or is not a product.
|
|
183
|
+
* @param {string} file
|
|
184
|
+
* @returns {string | null}
|
|
185
|
+
*/
|
|
186
|
+
function productPrefixOf(file) {
|
|
187
|
+
try {
|
|
188
|
+
const name = String(JSON.parse(readFileSync(file, "utf8")).name ?? "");
|
|
189
|
+
// A product names its packages `<product>-<part>` (acme-monorepo, @org/acme-admin); a one-word package
|
|
190
|
+
// (@org/realtime, @org/ui) is a building block, not a product, and a context word is never one.
|
|
191
|
+
const words = name.replace(/^@[^/]+\//, "").split(/[-_.]/).filter(Boolean);
|
|
192
|
+
const word = words.length > 1 ? words[0].toUpperCase() : "";
|
|
193
|
+
return /^[A-Z][A-Z0-9]+$/.test(word) && !ENV_CONTEXT_WORDS.includes(word) ? word : null;
|
|
194
|
+
} catch {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
220
199
|
/** Vendors: a vendor is a value (`MEDIA_STORAGE_PROVIDER=bunny`, `MAIL_PROVIDER=mailgun`), never a name. */
|
|
221
200
|
export const ENV_VENDOR_PREFIXES = Object.freeze([
|
|
222
201
|
"BUNNY", "MAILGUN", "RESEND", "SENDGRID", "POSTMARK", "STRIPE", "CCBILL", "SEGPAY", "NOWPAYMENTS", "PAYPAL",
|
package/eslint/stack.js
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
|
-
// The
|
|
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. */
|
|
7
8
|
export const STACK_RULES = Object.freeze([
|
|
8
9
|
"krizaka/no-fetch-in-client-effect",
|
|
9
|
-
"krizaka/no-direct-tanstack-import",
|
|
10
10
|
"krizaka/no-local-table-or-form",
|
|
11
11
|
"krizaka/no-family-switcher",
|
|
12
12
|
"krizaka/route-state-selection",
|
|
13
13
|
]);
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
|
-
* Flat config: the
|
|
16
|
+
* Flat config: the stack rules on an app's sources.
|
|
17
17
|
* @param {{ files?: string[], ignores?: string[], severity?: "warn" | "error", maxFormFields?: number }} [options]
|
|
18
18
|
* `maxFormFields`: a form with at most this many fields is allowed (default 1 — a search box, a newsletter).
|
|
19
19
|
* @returns {import("eslint").Linter.Config[]}
|
|
@@ -32,7 +32,6 @@ export function krizakaStack({
|
|
|
32
32
|
plugins: { krizaka: krizakaPlugin },
|
|
33
33
|
rules: {
|
|
34
34
|
"krizaka/no-fetch-in-client-effect": severity,
|
|
35
|
-
"krizaka/no-direct-tanstack-import": severity,
|
|
36
35
|
"krizaka/no-local-table-or-form": [severity, { maxFormFields }],
|
|
37
36
|
// Never a warning: the family switcher is gone from every site (krizaka.com is the only way back).
|
|
38
37
|
"krizaka/no-family-switcher": "error",
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@krizaka/config",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "The shared configuration of every Krizaka JavaScript repository: ESLint flat configs (base, Next.js, the four UI rules, the
|
|
3
|
+
"version": "0.8.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://
|
|
16
|
-
"bugs": "https://github.com/krizaka/krizaka-
|
|
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-
|
|
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",
|
|
@@ -43,7 +47,8 @@
|
|
|
43
47
|
"./package.json": "./package.json"
|
|
44
48
|
},
|
|
45
49
|
"bin": {
|
|
46
|
-
"krizaka-ratchet": "./bin/krizaka-ratchet.mjs"
|
|
50
|
+
"krizaka-ratchet": "./bin/krizaka-ratchet.mjs",
|
|
51
|
+
"krizaka-known-violations": "./bin/krizaka-known-violations.mjs"
|
|
47
52
|
},
|
|
48
53
|
"files": [
|
|
49
54
|
"bin",
|
|
@@ -52,6 +57,8 @@
|
|
|
52
57
|
"tsconfig",
|
|
53
58
|
"patterns.js",
|
|
54
59
|
"dependency-cruiser.cjs",
|
|
60
|
+
"covered-domains.cjs",
|
|
61
|
+
"covered-domains.json",
|
|
55
62
|
"prettier.config.js",
|
|
56
63
|
".editorconfig",
|
|
57
64
|
"README.md",
|
|
@@ -60,6 +67,7 @@
|
|
|
60
67
|
"sideEffects": false,
|
|
61
68
|
"dependencies": {
|
|
62
69
|
"@eslint/js": "^9.39.0 || ^10.0.0",
|
|
70
|
+
"dependency-cruiser": "^18.6.0",
|
|
63
71
|
"eslint-plugin-react-hooks": "^7.1.1",
|
|
64
72
|
"eslint-plugin-simple-import-sort": "^14.0.0",
|
|
65
73
|
"globals": "^17.13.0",
|
|
@@ -84,10 +92,9 @@
|
|
|
84
92
|
"@size-limit/file": "^14.2.0",
|
|
85
93
|
"@types/estree": "^1.0.8",
|
|
86
94
|
"@types/node": "^22.15.0",
|
|
87
|
-
"dependency-cruiser": "^18.6.0",
|
|
88
95
|
"eslint": "^9.39.0",
|
|
89
96
|
"eslint-config-next": "^16.4.0",
|
|
90
|
-
"next": "^16.4.
|
|
97
|
+
"next": "^16.4.1",
|
|
91
98
|
"prettier": "^3.9.9",
|
|
92
99
|
"publint": "^0.3.25",
|
|
93
100
|
"size-limit": "^14.2.0",
|
|
@@ -117,6 +124,7 @@
|
|
|
117
124
|
"typecheck": "tsc --noEmit",
|
|
118
125
|
"test": "vitest run",
|
|
119
126
|
"size": "size-limit",
|
|
120
|
-
"publint": "publint"
|
|
127
|
+
"publint": "publint",
|
|
128
|
+
"check": "pnpm run lint && pnpm run test && pnpm run size && pnpm run publint"
|
|
121
129
|
}
|
|
122
130
|
}
|
package/patterns.js
CHANGED
|
@@ -102,20 +102,40 @@ export function balancedArguments(source, open) {
|
|
|
102
102
|
let depth = 0;
|
|
103
103
|
for (let i = open; i < source.length; i += 1) {
|
|
104
104
|
const c = source[i];
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
if (i < 0) break;
|
|
110
|
-
} else if (c === "/" && source[i + 1] === "*") {
|
|
111
|
-
i = source.indexOf("*/", i + 2) + 1;
|
|
112
|
-
if (i <= 0) break;
|
|
113
|
-
} else if (c === "(") depth += 1;
|
|
105
|
+
const skipped = skipLiteralOrComment(source, i);
|
|
106
|
+
if (skipped === null) break;
|
|
107
|
+
if (skipped > i) i = skipped;
|
|
108
|
+
else if (c === "(") depth += 1;
|
|
114
109
|
else if (c === ")" && --depth === 0) return source.slice(open + 1, i);
|
|
115
110
|
}
|
|
116
111
|
return source.slice(open + 1);
|
|
117
112
|
}
|
|
118
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Where a string, template or comment that starts at `i` ends (the index of its last character), `i` itself when none starts
|
|
116
|
+
* there, null when a line comment or a block comment never ends.
|
|
117
|
+
* @param {string} source
|
|
118
|
+
* @param {number} i
|
|
119
|
+
* @returns {number | null}
|
|
120
|
+
*/
|
|
121
|
+
function skipLiteralOrComment(source, i) {
|
|
122
|
+
const c = source[i];
|
|
123
|
+
if (c === '"' || c === "'" || c === "`") {
|
|
124
|
+
let end = i + 1;
|
|
125
|
+
while (end < source.length && source[end] !== c) end += source[end] === "\\" ? 2 : 1;
|
|
126
|
+
return end;
|
|
127
|
+
}
|
|
128
|
+
if (c === "/" && source[i + 1] === "/") {
|
|
129
|
+
const end = source.indexOf("\n", i);
|
|
130
|
+
return end < 0 ? null : end;
|
|
131
|
+
}
|
|
132
|
+
if (c === "/" && source[i + 1] === "*") {
|
|
133
|
+
const end = source.indexOf("*/", i + 2);
|
|
134
|
+
return end < 0 ? null : end + 1;
|
|
135
|
+
}
|
|
136
|
+
return i;
|
|
137
|
+
}
|
|
138
|
+
|
|
119
139
|
// ── Selections live in the URL (Oussama, 2026-10-22: « changer les tabs doit changer les routes ») ──────────────────────
|
|
120
140
|
// Read by the ESLint rule krizaka/route-state-selection and by the ratchet.
|
|
121
141
|
|
|
@@ -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
|
+
}
|
package/ratchet/ratchet.js
CHANGED
|
@@ -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
|
|
|
@@ -190,6 +190,25 @@ export class RatchetError extends Error {}
|
|
|
190
190
|
* @property {boolean} written the ratchet file was created, lowered or completed
|
|
191
191
|
*/
|
|
192
192
|
|
|
193
|
+
/**
|
|
194
|
+
* Each counter against its recorded value: the report, and the names that went up or down.
|
|
195
|
+
* @param {Counters} current
|
|
196
|
+
* @param {Counters} recorded
|
|
197
|
+
*/
|
|
198
|
+
function compareCounters(current, recorded) {
|
|
199
|
+
/** @type {RatchetResult["counters"]} */
|
|
200
|
+
const report = /** @type {any} */ ({});
|
|
201
|
+
/** @type {CounterName[]} */ const increased = [];
|
|
202
|
+
/** @type {CounterName[]} */ const decreased = [];
|
|
203
|
+
for (const name of COUNTERS) {
|
|
204
|
+
const delta = current[name] - recorded[name];
|
|
205
|
+
report[name] = { recorded: recorded[name], current: current[name], delta };
|
|
206
|
+
if (delta > 0) increased.push(name);
|
|
207
|
+
if (delta < 0) decreased.push(name);
|
|
208
|
+
}
|
|
209
|
+
return { report, increased, decreased };
|
|
210
|
+
}
|
|
211
|
+
|
|
193
212
|
/**
|
|
194
213
|
* Runs the ratchet.
|
|
195
214
|
* - `check` (default): fails when a counter is above its recorded value.
|
|
@@ -201,6 +220,11 @@ export class RatchetError extends Error {}
|
|
|
201
220
|
*/
|
|
202
221
|
export async function runRatchet({ root, mode = "check", globs, file = RATCHET_FILE }) {
|
|
203
222
|
const path = resolve(root, file);
|
|
223
|
+
// the ratchet file is read and written: it stays a .json file inside the repository root (no `../`, no absolute path elsewhere)
|
|
224
|
+
const inside = relative(resolve(root), path);
|
|
225
|
+
if (!path.endsWith(".json") || inside === "" || inside.startsWith("..") || isAbsolute(inside)) {
|
|
226
|
+
throw new RatchetError(`${file}: the ratchet file must be a .json file inside ${root}.`);
|
|
227
|
+
}
|
|
204
228
|
const exists = existsSync(path);
|
|
205
229
|
if (mode === "init" && exists) throw new RatchetError(`${file} already exists: use --update to lower it.`);
|
|
206
230
|
if (mode !== "init" && !exists) throw new RatchetError(`${file} not found in ${root}: run krizaka-ratchet --init first.`);
|
|
@@ -222,16 +246,7 @@ export async function runRatchet({ root, mode = "check", globs, file = RATCHET_F
|
|
|
222
246
|
/** @type {CounterName[]} */ const added = recordedFile ? COUNTERS.filter((n) => recordedFile.counters[n] === undefined) : [];
|
|
223
247
|
const recorded = /** @type {Counters} */ ({ ...current, ...recordedFile?.counters });
|
|
224
248
|
|
|
225
|
-
|
|
226
|
-
const report = /** @type {any} */ ({});
|
|
227
|
-
/** @type {CounterName[]} */ const increased = [];
|
|
228
|
-
/** @type {CounterName[]} */ const decreased = [];
|
|
229
|
-
for (const name of COUNTERS) {
|
|
230
|
-
const delta = current[name] - recorded[name];
|
|
231
|
-
report[name] = { recorded: recorded[name], current: current[name], delta };
|
|
232
|
-
if (delta > 0) increased.push(name);
|
|
233
|
-
if (delta < 0) decreased.push(name);
|
|
234
|
-
}
|
|
249
|
+
const { report, increased, decreased } = compareCounters(current, recorded);
|
|
235
250
|
|
|
236
251
|
let written = false;
|
|
237
252
|
if (mode === "init") {
|