@krizaka/config 0.8.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -58
- package/bin/krizaka-ratchet.mjs +4 -1
- package/conformance/account-baseline.js +116 -0
- package/dependency-cruiser.cjs +24 -72
- package/eslint/plugin.js +5 -1
- package/eslint/rules.js +112 -20
- package/eslint/stack.js +18 -6
- package/package.json +19 -25
- package/patterns.js +212 -29
- package/ratchet/ratchet.js +42 -29
- package/bin/krizaka-known-violations.mjs +0 -35
- package/covered-domains.cjs +0 -50
- package/covered-domains.json +0 -40
- package/ratchet/known-violations.js +0 -116
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 stack rules, no locale ternary), the
|
|
3
|
+
"version": "0.9.0",
|
|
4
|
+
"description": "The shared configuration of every Krizaka JavaScript repository: ESLint flat configs (base, Next.js, the four UI rules, the three stack rules, no locale ternary), the dependency-cruiser boundary, tsconfig bases, Prettier, EditorConfig, and the krizaka-ratchet debt counter.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"krizaka",
|
|
7
7
|
"eslint-config",
|
|
@@ -10,14 +10,13 @@
|
|
|
10
10
|
"ratchet",
|
|
11
11
|
"design-system",
|
|
12
12
|
"dependency-cruiser",
|
|
13
|
-
"tanstack"
|
|
14
|
-
"krizaka-first"
|
|
13
|
+
"tanstack"
|
|
15
14
|
],
|
|
16
|
-
"homepage": "https://
|
|
17
|
-
"bugs": "https://github.com/krizaka/krizaka-
|
|
15
|
+
"homepage": "https://ui.krizaka.com/packages/config",
|
|
16
|
+
"bugs": "https://github.com/krizaka/krizaka-ui/issues",
|
|
18
17
|
"repository": {
|
|
19
18
|
"type": "git",
|
|
20
|
-
"url": "git+https://github.com/krizaka/krizaka-
|
|
19
|
+
"url": "git+https://github.com/krizaka/krizaka-ui.git",
|
|
21
20
|
"directory": "packages/config"
|
|
22
21
|
},
|
|
23
22
|
"license": "Apache-2.0",
|
|
@@ -33,9 +32,6 @@
|
|
|
33
32
|
"./eslint/env-names": "./eslint/env-names.js",
|
|
34
33
|
"./eslint/plugin": "./eslint/plugin.js",
|
|
35
34
|
"./dependency-cruiser": "./dependency-cruiser.cjs",
|
|
36
|
-
"./covered-domains": "./covered-domains.cjs",
|
|
37
|
-
"./covered-domains.json": "./covered-domains.json",
|
|
38
|
-
"./known-violations": "./ratchet/known-violations.js",
|
|
39
35
|
"./patterns": "./patterns.js",
|
|
40
36
|
"./ratchet": "./ratchet/ratchet.js",
|
|
41
37
|
"./prettier": "./prettier.config.js",
|
|
@@ -44,21 +40,20 @@
|
|
|
44
40
|
"./tsconfig/next.json": "./tsconfig/next.json",
|
|
45
41
|
"./tsconfig/react-native.json": "./tsconfig/react-native.json",
|
|
46
42
|
"./editorconfig": "./.editorconfig",
|
|
47
|
-
"./package.json": "./package.json"
|
|
43
|
+
"./package.json": "./package.json",
|
|
44
|
+
"./conformance/account-baseline": "./conformance/account-baseline.js"
|
|
48
45
|
},
|
|
49
46
|
"bin": {
|
|
50
|
-
"krizaka-ratchet": "./bin/krizaka-ratchet.mjs"
|
|
51
|
-
"krizaka-known-violations": "./bin/krizaka-known-violations.mjs"
|
|
47
|
+
"krizaka-ratchet": "./bin/krizaka-ratchet.mjs"
|
|
52
48
|
},
|
|
53
49
|
"files": [
|
|
54
50
|
"bin",
|
|
51
|
+
"conformance",
|
|
55
52
|
"eslint",
|
|
56
53
|
"ratchet",
|
|
57
54
|
"tsconfig",
|
|
58
55
|
"patterns.js",
|
|
59
56
|
"dependency-cruiser.cjs",
|
|
60
|
-
"covered-domains.cjs",
|
|
61
|
-
"covered-domains.json",
|
|
62
57
|
"prettier.config.js",
|
|
63
58
|
".editorconfig",
|
|
64
59
|
"README.md",
|
|
@@ -66,13 +61,12 @@
|
|
|
66
61
|
],
|
|
67
62
|
"sideEffects": false,
|
|
68
63
|
"dependencies": {
|
|
69
|
-
"@eslint/js": "^
|
|
70
|
-
"dependency-cruiser": "^18.6.0",
|
|
64
|
+
"@eslint/js": "^10.0.1",
|
|
71
65
|
"eslint-plugin-react-hooks": "^7.1.1",
|
|
72
66
|
"eslint-plugin-simple-import-sort": "^14.0.0",
|
|
73
67
|
"globals": "^17.13.0",
|
|
74
68
|
"tinyglobby": "^0.2.17",
|
|
75
|
-
"typescript-eslint": "^8.
|
|
69
|
+
"typescript-eslint": "^8.72.0"
|
|
76
70
|
},
|
|
77
71
|
"peerDependencies": {
|
|
78
72
|
"eslint": "^9 || ^10",
|
|
@@ -92,14 +86,15 @@
|
|
|
92
86
|
"@size-limit/file": "^14.2.0",
|
|
93
87
|
"@types/estree": "^1.0.8",
|
|
94
88
|
"@types/node": "^22.15.0",
|
|
89
|
+
"dependency-cruiser": "^18.7.0",
|
|
95
90
|
"eslint": "^9.39.0",
|
|
96
|
-
"eslint-config-next": "^16.4.
|
|
97
|
-
"next": "^16.4.
|
|
98
|
-
"prettier": "^3.9.
|
|
91
|
+
"eslint-config-next": "^16.4.2",
|
|
92
|
+
"next": "^16.4.2",
|
|
93
|
+
"prettier": "^3.9.10",
|
|
99
94
|
"publint": "^0.3.25",
|
|
100
95
|
"size-limit": "^14.2.0",
|
|
101
96
|
"typescript": "^5.9.0",
|
|
102
|
-
"vitest": "^
|
|
97
|
+
"vitest": "^5.0.5"
|
|
103
98
|
},
|
|
104
99
|
"engines": {
|
|
105
100
|
"node": ">=22"
|
|
@@ -115,7 +110,7 @@
|
|
|
115
110
|
"ratchet/ratchet.js",
|
|
116
111
|
"patterns.js"
|
|
117
112
|
],
|
|
118
|
-
"limit": "
|
|
113
|
+
"limit": "12.5 kB",
|
|
119
114
|
"gzip": true
|
|
120
115
|
}
|
|
121
116
|
],
|
|
@@ -124,7 +119,6 @@
|
|
|
124
119
|
"typecheck": "tsc --noEmit",
|
|
125
120
|
"test": "vitest run",
|
|
126
121
|
"size": "size-limit",
|
|
127
|
-
"publint": "publint"
|
|
128
|
-
"check": "pnpm run lint && pnpm run test && pnpm run size && pnpm run publint"
|
|
122
|
+
"publint": "publint"
|
|
129
123
|
}
|
|
130
124
|
}
|
package/patterns.js
CHANGED
|
@@ -48,6 +48,149 @@ export const FETCH_CALL_SOURCE = String.raw`\b(?:fetch|krizakaFetch)\s*\(`;
|
|
|
48
48
|
export const EFFECT_NAMES_SOURCE = "^use(Layout)?Effect$";
|
|
49
49
|
export const FETCH_NAMES_SOURCE = "^(fetch|krizakaFetch)$";
|
|
50
50
|
|
|
51
|
+
// ── Effects are for the outside world (Oussama, 2026-10-27: « bug à adresser partout ») ─────────────────────────────
|
|
52
|
+
// Standard: krizaka/.github docs/standards/react-effects.md. Read by the rules `krizaka/no-async-in-effect` and
|
|
53
|
+
// `krizaka/no-derived-state-in-effect` (eslint/rules.js) and by the ratchet, through `effectProblem`.
|
|
54
|
+
|
|
55
|
+
/** What makes an effect load data: an await, `.then(`, `fetch(`, a Tauri `invoke(`, `api.x(`, `fooClient.x(`. */
|
|
56
|
+
export const ASYNC_EFFECT_SOURCE = String.raw`\bawait\b|\.then\s*\(|\b(?:fetch|krizakaFetch|invoke)\s*\(|\bapi\.\w+|\b\w*Client\.\w+\s*\(`;
|
|
57
|
+
|
|
58
|
+
/** A subscription to a system that pushes events: its callback may be async, the cleanup undoes it. */
|
|
59
|
+
export const SUBSCRIBE_EFFECT_SOURCE = String.raw`\b(?:listen|once|on|addEventListener|subscribe|addListener|new\s+(?:WebSocket|EventSource|ResizeObserver|IntersectionObserver|MutationObserver|BroadcastChannel)|\w+\.on|\w+\.on[A-Z]\w*)\s*(?:<[^()]*>)?\s*\(`;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Text with its strings, template literals and comments blanked (same length), so a keyword in a string is not read.
|
|
63
|
+
* @param {string} source
|
|
64
|
+
* @returns {string}
|
|
65
|
+
*/
|
|
66
|
+
export function blankStrings(source) {
|
|
67
|
+
let out = "";
|
|
68
|
+
let i = 0;
|
|
69
|
+
while (i < source.length) {
|
|
70
|
+
const [blanked, next] = blankedAt(source, i);
|
|
71
|
+
out += blanked;
|
|
72
|
+
i = next;
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* What `source` holds at `i` once blanked, and where the text goes on: a string or template literal keeps its quotes, a
|
|
79
|
+
* comment is spaces (newlines kept), any other character is itself.
|
|
80
|
+
* @param {string} source
|
|
81
|
+
* @param {number} i
|
|
82
|
+
* @returns {[string, number]}
|
|
83
|
+
*/
|
|
84
|
+
function blankedAt(source, i) {
|
|
85
|
+
const c = source[i];
|
|
86
|
+
if (c === '"' || c === "'" || c === "`") {
|
|
87
|
+
let j = i + 1;
|
|
88
|
+
for (; j < source.length && source[j] !== c; j += 1) if (source[j] === "\\") j += 1;
|
|
89
|
+
return [c + " ".repeat(Math.max(0, j - i - 1)) + c, j + 1];
|
|
90
|
+
}
|
|
91
|
+
if (c === "/" && source[i + 1] === "/") {
|
|
92
|
+
const end = source.indexOf("\n", i);
|
|
93
|
+
const stop = end < 0 ? source.length : end;
|
|
94
|
+
return [" ".repeat(stop - i), stop];
|
|
95
|
+
}
|
|
96
|
+
if (c === "/" && source[i + 1] === "*") {
|
|
97
|
+
const end = source.indexOf("*/", i + 2);
|
|
98
|
+
const stop = end < 0 ? source.length : end + 2;
|
|
99
|
+
return [source.slice(i, stop).replace(/[^\n]/g, " "), stop];
|
|
100
|
+
}
|
|
101
|
+
return [c ?? "", i + 1];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* An effect callback without its signature (`() =>`, `async () =>`, `function () `, `cb =>`): what is left is its body.
|
|
106
|
+
* Scanned, not matched by a regular expression, so no input makes it slow.
|
|
107
|
+
* @param {string} callback
|
|
108
|
+
* @returns {string}
|
|
109
|
+
*/
|
|
110
|
+
function withoutSignature(callback) {
|
|
111
|
+
let i = 0;
|
|
112
|
+
const skipSpace = () => {
|
|
113
|
+
while (i < callback.length && /\s/.test(/** @type {string} */ (callback[i]))) i += 1;
|
|
114
|
+
};
|
|
115
|
+
skipSpace();
|
|
116
|
+
if (callback.startsWith("async", i) && /\s/.test(callback[i + 5] ?? "")) {
|
|
117
|
+
i += 5;
|
|
118
|
+
skipSpace();
|
|
119
|
+
}
|
|
120
|
+
const isFunction = callback.startsWith("function", i);
|
|
121
|
+
if (isFunction) {
|
|
122
|
+
i += 8;
|
|
123
|
+
while (i < callback.length && callback[i] !== "(") i += 1;
|
|
124
|
+
}
|
|
125
|
+
if (callback[i] === "(") {
|
|
126
|
+
const close = callback.indexOf(")", i);
|
|
127
|
+
if (close < 0) return callback;
|
|
128
|
+
i = close + 1;
|
|
129
|
+
} else if (!isFunction) {
|
|
130
|
+
while (i < callback.length && /\w/.test(/** @type {string} */ (callback[i]))) i += 1;
|
|
131
|
+
}
|
|
132
|
+
const afterParams = i;
|
|
133
|
+
skipSpace();
|
|
134
|
+
if (callback.startsWith("=>", i)) return callback.slice(i + 2);
|
|
135
|
+
return isFunction ? callback.slice(afterParams) : callback;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The callback of an effect call without the dependency array that follows it (`cb, [a, b]`).
|
|
140
|
+
* @param {string} args the balanced arguments of `useEffect(…)`
|
|
141
|
+
* @returns {string}
|
|
142
|
+
*/
|
|
143
|
+
export function withoutDependencyArray(args) {
|
|
144
|
+
let end = args.trimEnd();
|
|
145
|
+
if (end.endsWith(",")) end = end.slice(0, -1).trimEnd();
|
|
146
|
+
if (!end.endsWith("]")) return args;
|
|
147
|
+
const open = end.lastIndexOf("[");
|
|
148
|
+
if (open < 0) return args;
|
|
149
|
+
const before = end.slice(0, open).trimEnd();
|
|
150
|
+
return before.endsWith(",") ? before.slice(0, -1) : args;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Removes every `setX(…)` call (balanced) from a blanked text.
|
|
155
|
+
* @param {string} text
|
|
156
|
+
* @returns {{ rest: string, found: number }}
|
|
157
|
+
*/
|
|
158
|
+
function withoutSetStateCalls(text) {
|
|
159
|
+
let out = "";
|
|
160
|
+
let found = 0;
|
|
161
|
+
const re = /\bset[A-Z]\w*\s*\(/g;
|
|
162
|
+
let last = 0;
|
|
163
|
+
for (let m = re.exec(text); m; m = re.exec(text)) {
|
|
164
|
+
const open = m.index + m[0].length - 1;
|
|
165
|
+
const inner = balancedArguments(text, open);
|
|
166
|
+
out += text.slice(last, m.index);
|
|
167
|
+
last = open + inner.length + 2;
|
|
168
|
+
re.lastIndex = last;
|
|
169
|
+
found += 1;
|
|
170
|
+
}
|
|
171
|
+
return { rest: out + text.slice(last), found };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* What an effect body is wrong about: `"async"` (it loads data — use `useQuery` / `useMutation`) or `"derived"` (its
|
|
176
|
+
* only statements are `setX(…)` calls — compute the value in render, or reset with a `key`), else null.
|
|
177
|
+
* A subscribe-and-cleanup effect (a push source and a `return` that undoes it) is the legitimate use and is never wrong.
|
|
178
|
+
* @param {string} body the text of the effect callback (with or without its braces)
|
|
179
|
+
* @returns {"async" | "derived" | null}
|
|
180
|
+
*/
|
|
181
|
+
export function effectProblem(body) {
|
|
182
|
+
// the ratchet passes the whole callback (`() => { … }`), the ESLint rule its body: strip the signature
|
|
183
|
+
const text = withoutSignature(blankStrings(body));
|
|
184
|
+
// an expression body (`() => session.onChange(…)`) returns its cleanup as well as a block that says `return`
|
|
185
|
+
const returnsCleanup = /\breturn\b/.test(text) || !/^\s*\{/.test(text);
|
|
186
|
+
if (new RegExp(SUBSCRIBE_EFFECT_SOURCE).test(text) && returnsCleanup) return null;
|
|
187
|
+
if (new RegExp(ASYNC_EFFECT_SOURCE).test(text)) return "async";
|
|
188
|
+
const { rest, found } = withoutSetStateCalls(text);
|
|
189
|
+
if (found === 0) return null;
|
|
190
|
+
const leftover = rest.replace(/\b(?:if|else)\b/g, "").replace(/\([^()]*(?:\([^()]*\)[^()]*)*\)/g, "").replace(/[\s;{}]/g, "");
|
|
191
|
+
return leftover === "" ? "derived" : null;
|
|
192
|
+
}
|
|
193
|
+
|
|
51
194
|
/** The headless engines that only enter through a `@krizaka/*` package. */
|
|
52
195
|
export const TANSTACK_SCOPE = "@tanstack/";
|
|
53
196
|
|
|
@@ -102,40 +245,20 @@ export function balancedArguments(source, open) {
|
|
|
102
245
|
let depth = 0;
|
|
103
246
|
for (let i = open; i < source.length; i += 1) {
|
|
104
247
|
const c = source[i];
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
if (
|
|
108
|
-
|
|
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;
|
|
109
257
|
else if (c === ")" && --depth === 0) return source.slice(open + 1, i);
|
|
110
258
|
}
|
|
111
259
|
return source.slice(open + 1);
|
|
112
260
|
}
|
|
113
261
|
|
|
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
|
-
|
|
139
262
|
// ── Selections live in the URL (Oussama, 2026-10-22: « changer les tabs doit changer les routes ») ──────────────────────
|
|
140
263
|
// Read by the ESLint rule krizaka/route-state-selection and by the ratchet.
|
|
141
264
|
|
|
@@ -189,3 +312,63 @@ export function isLocalSelection(tag, before = "") {
|
|
|
189
312
|
if (new RegExp(EPHEMERAL_SOURCE).test(tag) || new RegExp(EPHEMERAL_SOURCE).test(before)) return false;
|
|
190
313
|
return LOCAL_SELECTION_PROPS.some((prop) => new RegExp(`\\b${prop}\\s*=`).test(tag));
|
|
191
314
|
}
|
|
315
|
+
|
|
316
|
+
// ── Every page declares its template (Oussama, 2026-10-22: sessions hand-built pages instead of composing Krizaka UI) ──────
|
|
317
|
+
// Read by the ESLint rule krizaka/page-template and by the ratchet.
|
|
318
|
+
|
|
319
|
+
/** The page templates of @krizaka/ui (ui.krizaka.com/templates): the site's, then the app's; `redirect` is an address kept for old links. */
|
|
320
|
+
export const PAGE_TEMPLATES = Object.freeze([
|
|
321
|
+
"landing",
|
|
322
|
+
"product",
|
|
323
|
+
"how-it-works",
|
|
324
|
+
"gallery",
|
|
325
|
+
"docs-hub",
|
|
326
|
+
"docs-article",
|
|
327
|
+
"component",
|
|
328
|
+
"catalogue",
|
|
329
|
+
"longform",
|
|
330
|
+
"changelog",
|
|
331
|
+
"contact",
|
|
332
|
+
"legal",
|
|
333
|
+
"status",
|
|
334
|
+
"error",
|
|
335
|
+
"pricing",
|
|
336
|
+
"app-dashboard",
|
|
337
|
+
"app-list-detail",
|
|
338
|
+
"app-settings",
|
|
339
|
+
"app-onboarding",
|
|
340
|
+
"app-empty-first-run",
|
|
341
|
+
"app-wizard",
|
|
342
|
+
"redirect",
|
|
343
|
+
]);
|
|
344
|
+
|
|
345
|
+
/** The template that is not one: a page that really composes none, with its reason (the declaration `template: custom: a canvas editor`). */
|
|
346
|
+
export const CUSTOM_TEMPLATE = "custom";
|
|
347
|
+
|
|
348
|
+
/** A page of the Next.js App Router: `app/**\/page.tsx` (or `src/app/…`). */
|
|
349
|
+
export const PAGE_FILE_SOURCE = String.raw`(?:^|[\\/])app[\\/](?:.*[\\/])?page\.[cm]?[jt]sx?$`;
|
|
350
|
+
|
|
351
|
+
/** The declaration, alone on its line, as a line comment or a block comment (the rule's message shows its form). */
|
|
352
|
+
// The reason never starts with the block comment's `*/`: `/* @template: custom: */` declares no reason.
|
|
353
|
+
export const TEMPLATE_TAG_SOURCE = String.raw`^[ \t]*(?://|/\*+)[ \t]*@template:[ \t]*([a-z][a-z-]*)(?:[ \t]*:[ \t]*((?!\*\/)\S[^\n]*?)?)?[ \t]*(?:\*/)?[ \t]*$`;
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* What is wrong with a page's template declaration, or `null` when it is fine.
|
|
357
|
+
* @param {string} source
|
|
358
|
+
* @returns {{ kind: "missing" } | { kind: "unknown", name: string } | { kind: "reason" } | null}
|
|
359
|
+
*/
|
|
360
|
+
export function pageTemplateProblem(source) {
|
|
361
|
+
const tag = new RegExp(TEMPLATE_TAG_SOURCE, "m").exec(source);
|
|
362
|
+
if (!tag) return { kind: "missing" };
|
|
363
|
+
const [, name, reason] = tag;
|
|
364
|
+
if (name === CUSTOM_TEMPLATE) return reason ? null : { kind: "reason" };
|
|
365
|
+
return PAGE_TEMPLATES.includes(name) ? null : { kind: "unknown", name };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* True for the file of a route's page.
|
|
370
|
+
* @param {string} filename
|
|
371
|
+
*/
|
|
372
|
+
export function isPageFile(filename) {
|
|
373
|
+
return new RegExp(PAGE_FILE_SOURCE).test(filename);
|
|
374
|
+
}
|
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 {
|
|
5
|
+
import { relative, resolve } from "node:path";
|
|
6
6
|
|
|
7
7
|
import { glob } from "tinyglobby";
|
|
8
8
|
|
|
@@ -12,15 +12,19 @@ import {
|
|
|
12
12
|
CLASSNAME_TEMPLATE_SOURCE,
|
|
13
13
|
countFormFields,
|
|
14
14
|
EFFECT_CALL_SOURCE,
|
|
15
|
+
effectProblem,
|
|
15
16
|
FETCH_CALL_SOURCE,
|
|
16
17
|
importSource,
|
|
17
18
|
isLocalSelection,
|
|
19
|
+
isPageFile,
|
|
18
20
|
LIGHT_VARIANT_SOURCE,
|
|
21
|
+
pageTemplateProblem,
|
|
19
22
|
paletteSource,
|
|
20
23
|
selectionTags,
|
|
21
24
|
TABLE_OR_FORM_SOURCE,
|
|
22
25
|
TANSTACK_SCOPE,
|
|
23
26
|
USE_CLIENT_SOURCE,
|
|
27
|
+
withoutDependencyArray,
|
|
24
28
|
} from "../patterns.js";
|
|
25
29
|
|
|
26
30
|
export const RATCHET_FILE = "lint-ratchet.json";
|
|
@@ -32,7 +36,7 @@ export const DEFAULT_STACK_GLOBS = Object.freeze(
|
|
|
32
36
|
const IGNORE = ["**/node_modules/**", "**/dist/**", "**/.next/**", "**/storybook-static/**", "**/coverage/**"];
|
|
33
37
|
|
|
34
38
|
/** @typedef {"palette" | "light" | "arbitraryVar" | "classNameTemplate"} UiCounterName */
|
|
35
|
-
/** @typedef {"fetchInClientEffect" | "directTanstackImport" | "localTableOrForm" | "retiredImport" | "routeStateSelection"} StackCounterName */
|
|
39
|
+
/** @typedef {"fetchInClientEffect" | "asyncInEffect" | "derivedStateInEffect" | "directTanstackImport" | "localTableOrForm" | "retiredImport" | "routeStateSelection" | "pageTemplate"} StackCounterName */
|
|
36
40
|
/** @typedef {UiCounterName | StackCounterName} CounterName */
|
|
37
41
|
/** @typedef {Record<CounterName, number>} Counters */
|
|
38
42
|
/**
|
|
@@ -50,10 +54,13 @@ export const UI_COUNTERS = /** @type {const} */ (["palette", "light", "arbitrary
|
|
|
50
54
|
/** The stack counters (the three rules of krizakaStack, and the dependencies to retire), in report order. */
|
|
51
55
|
export const STACK_COUNTERS = /** @type {const} */ ([
|
|
52
56
|
"fetchInClientEffect",
|
|
57
|
+
"asyncInEffect",
|
|
58
|
+
"derivedStateInEffect",
|
|
53
59
|
"directTanstackImport",
|
|
54
60
|
"localTableOrForm",
|
|
55
61
|
"retiredImport",
|
|
56
62
|
"routeStateSelection",
|
|
63
|
+
"pageTemplate",
|
|
57
64
|
]);
|
|
58
65
|
/** Every counter, in report order. */
|
|
59
66
|
export const COUNTERS = /** @type {const} */ ([...UI_COUNTERS, ...STACK_COUNTERS]);
|
|
@@ -91,7 +98,7 @@ export function countSource(source, allow = []) {
|
|
|
91
98
|
* `@tanstack/*`, hand-written tables and forms of more than `maxFormFields` fields, imports of the retired dependencies.
|
|
92
99
|
* @param {string} source
|
|
93
100
|
* @param {{ retire?: readonly string[], maxFormFields?: number }} [options]
|
|
94
|
-
* @returns {Record<
|
|
101
|
+
* @returns {Record<string, number>}
|
|
95
102
|
*/
|
|
96
103
|
export function countStackSource(source, { retire = [], maxFormFields = 1 } = {}) {
|
|
97
104
|
let fetchInClientEffect = 0;
|
|
@@ -101,6 +108,15 @@ export function countStackSource(source, { retire = [], maxFormFields = 1 } = {}
|
|
|
101
108
|
fetchInClientEffect += body.match(new RegExp(FETCH_CALL_SOURCE, "g"))?.length ?? 0;
|
|
102
109
|
}
|
|
103
110
|
}
|
|
111
|
+
// react-effects standard: every effect (client file or not — a Vite/Tauri app has no directive) is read once
|
|
112
|
+
let asyncInEffect = 0;
|
|
113
|
+
let derivedStateInEffect = 0;
|
|
114
|
+
for (const effect of source.matchAll(new RegExp(EFFECT_CALL_SOURCE, "g"))) {
|
|
115
|
+
const body = balancedArguments(source, (effect.index ?? 0) + effect[0].length - 1);
|
|
116
|
+
const problem = effectProblem(withoutDependencyArray(body)); // the dependency array is not the callback
|
|
117
|
+
if (problem === "async") asyncInEffect += 1;
|
|
118
|
+
else if (problem === "derived") derivedStateInEffect += 1;
|
|
119
|
+
}
|
|
104
120
|
let localTableOrForm = 0;
|
|
105
121
|
for (const element of source.matchAll(new RegExp(TABLE_OR_FORM_SOURCE, "g"))) {
|
|
106
122
|
if (element[1] === "table") localTableOrForm += 1;
|
|
@@ -111,6 +127,8 @@ export function countStackSource(source, { retire = [], maxFormFields = 1 } = {}
|
|
|
111
127
|
}
|
|
112
128
|
return {
|
|
113
129
|
fetchInClientEffect,
|
|
130
|
+
asyncInEffect,
|
|
131
|
+
derivedStateInEffect,
|
|
114
132
|
directTanstackImport: source.match(new RegExp(importSource([TANSTACK_SCOPE]), "g"))?.length ?? 0,
|
|
115
133
|
localTableOrForm,
|
|
116
134
|
retiredImport: source.match(new RegExp(importSource(retire), "g"))?.length ?? 0,
|
|
@@ -118,6 +136,15 @@ export function countStackSource(source, { retire = [], maxFormFields = 1 } = {}
|
|
|
118
136
|
};
|
|
119
137
|
}
|
|
120
138
|
|
|
139
|
+
/**
|
|
140
|
+
* 1 when `file` is a page of the app router with no valid template declaration, else 0.
|
|
141
|
+
* @param {string} file
|
|
142
|
+
* @param {string} source
|
|
143
|
+
*/
|
|
144
|
+
export function countPages(file, source) {
|
|
145
|
+
return isPageFile(file) && pageTemplateProblem(source) ? 1 : 0;
|
|
146
|
+
}
|
|
147
|
+
|
|
121
148
|
/**
|
|
122
149
|
* Counts the debts under `root`: the UI counters in the files of `globs`, the stack counters in those of `stackGlobs`.
|
|
123
150
|
* @param {{ root: string, globs?: readonly string[], stackGlobs?: readonly string[], allow?: string[],
|
|
@@ -141,7 +168,8 @@ export async function countRepository({
|
|
|
141
168
|
}
|
|
142
169
|
for (const file of stackFiles) {
|
|
143
170
|
const found = countStackSource(readFileSync(file, "utf8"), { retire, maxFormFields });
|
|
144
|
-
for (const name of STACK_COUNTERS) counters[name] += found[name];
|
|
171
|
+
for (const name of STACK_COUNTERS) counters[name] += found[name] ?? 0;
|
|
172
|
+
counters.pageTemplate += countPages(file, readFileSync(file, "utf8"));
|
|
145
173
|
}
|
|
146
174
|
return { counters, files: files.length, stackFiles: stackFiles.length };
|
|
147
175
|
}
|
|
@@ -190,25 +218,6 @@ export class RatchetError extends Error {}
|
|
|
190
218
|
* @property {boolean} written the ratchet file was created, lowered or completed
|
|
191
219
|
*/
|
|
192
220
|
|
|
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
|
-
|
|
212
221
|
/**
|
|
213
222
|
* Runs the ratchet.
|
|
214
223
|
* - `check` (default): fails when a counter is above its recorded value.
|
|
@@ -220,11 +229,6 @@ function compareCounters(current, recorded) {
|
|
|
220
229
|
*/
|
|
221
230
|
export async function runRatchet({ root, mode = "check", globs, file = RATCHET_FILE }) {
|
|
222
231
|
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
|
-
}
|
|
228
232
|
const exists = existsSync(path);
|
|
229
233
|
if (mode === "init" && exists) throw new RatchetError(`${file} already exists: use --update to lower it.`);
|
|
230
234
|
if (mode !== "init" && !exists) throw new RatchetError(`${file} not found in ${root}: run krizaka-ratchet --init first.`);
|
|
@@ -246,7 +250,16 @@ export async function runRatchet({ root, mode = "check", globs, file = RATCHET_F
|
|
|
246
250
|
/** @type {CounterName[]} */ const added = recordedFile ? COUNTERS.filter((n) => recordedFile.counters[n] === undefined) : [];
|
|
247
251
|
const recorded = /** @type {Counters} */ ({ ...current, ...recordedFile?.counters });
|
|
248
252
|
|
|
249
|
-
|
|
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
|
+
}
|
|
250
263
|
|
|
251
264
|
let written = false;
|
|
252
265
|
if (mode === "init") {
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// krizaka-known-violations: the known violations of dependency-cruiser, which only shrink.
|
|
3
|
-
import { CONFIG_FILE, KNOWN_FILE, runKnownViolations } from "../ratchet/known-violations.js";
|
|
4
|
-
|
|
5
|
-
const USAGE = `Usage: krizaka-known-violations <check|update|init> [--config ${CONFIG_FILE}] [--known ${KNOWN_FILE}] <folder…>
|
|
6
|
-
|
|
7
|
-
check fails on a violation that is not recorded, and on a recorded one that is fixed (lock the gain with update)
|
|
8
|
-
update removes the fixed violations from the file; never adds one
|
|
9
|
-
init writes the file once, for an app that adopts the rules (the adoption pull request)
|
|
10
|
-
|
|
11
|
-
Run it from the app's folder. Beside it, \`depcruise --ignore-known\` ignores the recorded ones.`;
|
|
12
|
-
|
|
13
|
-
const args = process.argv.slice(2);
|
|
14
|
-
const command = args.shift();
|
|
15
|
-
/** @type {{ config?: string, known?: string, targets: string[] }} */
|
|
16
|
-
const options = { targets: [] };
|
|
17
|
-
for (let i = 0; i < args.length; i++) {
|
|
18
|
-
const arg = args[i] ?? "";
|
|
19
|
-
if (arg === "--config") options.config = args[++i];
|
|
20
|
-
else if (arg === "--known") options.known = args[++i];
|
|
21
|
-
else options.targets.push(arg);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
if ((command !== "check" && command !== "update" && command !== "init") || options.targets.length === 0) {
|
|
25
|
-
console.error(USAGE);
|
|
26
|
-
process.exit(2);
|
|
27
|
-
}
|
|
28
|
-
try {
|
|
29
|
-
const { code, lines } = runKnownViolations(command, options);
|
|
30
|
-
for (const line of lines) (code === 0 ? console.log : console.error)(line);
|
|
31
|
-
process.exit(code);
|
|
32
|
-
} catch (error) {
|
|
33
|
-
console.error(error instanceof Error ? error.message : String(error));
|
|
34
|
-
process.exit(2);
|
|
35
|
-
}
|
package/covered-domains.cjs
DELETED
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
// The covered-domains map (covered-domains.json) and what is generated from it: dependency-cruiser patterns, the message
|
|
2
|
-
// that names the Krizaka home, the table of the « Use Krizaka first » page. CommonJS: dependency-cruiser `require`s its
|
|
3
|
-
// configs, and Node imports it from ESM (`import { coveredDomains } from "@krizaka/config/covered-domains"`).
|
|
4
|
-
|
|
5
|
-
const map = require("./covered-domains.json");
|
|
6
|
-
|
|
7
|
-
/** @typedef {{ id: string, title: string, home: string, use: string, refused: string[] }} CoveredDomain */
|
|
8
|
-
|
|
9
|
-
/** The domains, in the order of the map. @type {readonly CoveredDomain[]} */
|
|
10
|
-
const coveredDomains = Object.freeze(map.domains);
|
|
11
|
-
|
|
12
|
-
/** The last thing every message says. */
|
|
13
|
-
const MISSING_FUNCTION = "Missing function? add it to the package first.";
|
|
14
|
-
|
|
15
|
-
/** @param {string} text */
|
|
16
|
-
const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* The dependency-cruiser `to.path` regular expression of a refused entry. It matches the resolved file
|
|
20
|
-
* (`…/node_modules/dayjs/index.js`, also under pnpm's `.pnpm/…/node_modules/`) and the module as written when it could not
|
|
21
|
-
* be resolved (`dayjs`), never a local folder called `dayjs`.
|
|
22
|
-
* `@scope/*` is a scope, `prefix*` a name prefix, anything else a package and its subpaths.
|
|
23
|
-
* @param {string} entry
|
|
24
|
-
*/
|
|
25
|
-
function refusedPattern(entry) {
|
|
26
|
-
const start = "(^|(^|/)node_modules/)";
|
|
27
|
-
if (entry.endsWith("/*")) return `${start}${escapeRegExp(entry.slice(0, -1))}`;
|
|
28
|
-
if (entry.endsWith("*")) return `${start}${escapeRegExp(entry.slice(0, -1))}[^/]*(/|$)`;
|
|
29
|
-
return `${start}${escapeRegExp(entry)}(/|$)`;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** One pattern for a whole domain. @param {CoveredDomain} domain */
|
|
33
|
-
const domainPattern = (domain) => domain.refused.map(refusedPattern).join("|");
|
|
34
|
-
|
|
35
|
-
/** The message of a domain: what is refused, the Krizaka home, and what to do when the function is missing. @param {CoveredDomain} domain */
|
|
36
|
-
const domainMessage = (domain) =>
|
|
37
|
-
`Krizaka first (${domain.id}): this library is refused in an app. Use ${domain.use}. ${MISSING_FUNCTION}`;
|
|
38
|
-
|
|
39
|
-
/** The name of the dependency-cruiser rule of a domain. @param {CoveredDomain | string} domain */
|
|
40
|
-
const ruleName = (domain) => `krizaka-first-${typeof domain === "string" ? domain : domain.id}`;
|
|
41
|
-
|
|
42
|
-
/** The table of the page « Use Krizaka first » (Markdown), generated from the map. */
|
|
43
|
-
function coveredDomainsMarkdown() {
|
|
44
|
-
const rows = coveredDomains.map(
|
|
45
|
-
(domain) => `| ${domain.title} | ${domain.use} | ${domain.refused.map((entry) => `\`${entry}\``).join(", ")} | \`${ruleName(domain)}\` |`,
|
|
46
|
-
);
|
|
47
|
-
return ["| Domain | Use | Refused in an app | Rule |", "| :-- | :-- | :-- | :-- |", ...rows].join("\n");
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
module.exports = { coveredDomains, domainMessage, domainPattern, coveredDomainsMarkdown, MISSING_FUNCTION, refusedPattern, ruleName };
|
package/covered-domains.json
DELETED
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"$comment": "The covered-domains map of « Krizaka first » (krizaka-ui#609, #612; krizaka/.github docs/quality-controls.md §5.1). THE single source: the dependency-cruiser preset generates one `krizaka-first-<id>` rule per domain from it, and the page « Use Krizaka first » of ui.krizaka.com is laid out from it. `refused`: a package name (`dayjs`, also its subpaths), a prefix (`date-fns*`) or a scope (`@tanstack/*`). A Krizaka package (`@krizaka/*`, in node_modules) is the facade and is never checked. Add a domain here, never in a tool's config.",
|
|
3
|
-
"domains": [
|
|
4
|
-
{
|
|
5
|
-
"id": "dates",
|
|
6
|
-
"title": "Dates, times and local days",
|
|
7
|
-
"home": "@krizaka/intl",
|
|
8
|
-
"use": "`@krizaka/intl` (formatting, the local day with `localDay`) or `@krizaka/time` (date arithmetic, a facade created on the first real need: krizaka-ts#76)",
|
|
9
|
-
"refused": ["date-fns*", "@date-fns/*", "dayjs", "luxon", "moment", "moment-timezone"]
|
|
10
|
-
},
|
|
11
|
-
{
|
|
12
|
-
"id": "i18n",
|
|
13
|
-
"title": "Words, languages and messages",
|
|
14
|
-
"home": "@krizaka/i18n",
|
|
15
|
-
"use": "`@krizaka/i18n`",
|
|
16
|
-
"refused": ["i18next*", "react-i18next", "next-intl", "next-i18next", "react-intl", "@formatjs/*", "@lingui/*", "i18n-js", "typesafe-i18n"]
|
|
17
|
-
},
|
|
18
|
-
{
|
|
19
|
-
"id": "query",
|
|
20
|
-
"title": "Data fetching, cache, tables, forms and virtual lists",
|
|
21
|
-
"home": "@krizaka/query",
|
|
22
|
-
"use": "`@krizaka/query` for server state, and `@krizaka/ui` (`data-table`, `form`, `virtual`) or `@krizaka/pacer` for the other TanStack engines",
|
|
23
|
-
"refused": ["@tanstack/*"]
|
|
24
|
-
},
|
|
25
|
-
{
|
|
26
|
-
"id": "icons",
|
|
27
|
-
"title": "Icons",
|
|
28
|
-
"home": "@krizaka/icons",
|
|
29
|
-
"use": "`@krizaka/icons`",
|
|
30
|
-
"refused": ["lucide-react", "react-icons", "@heroicons/*"]
|
|
31
|
-
},
|
|
32
|
-
{
|
|
33
|
-
"id": "ui",
|
|
34
|
-
"title": "Interface primitives",
|
|
35
|
-
"home": "@krizaka/ui",
|
|
36
|
-
"use": "`@krizaka/ui`",
|
|
37
|
-
"refused": ["@radix-ui/*", "radix-ui"]
|
|
38
|
-
}
|
|
39
|
-
]
|
|
40
|
-
}
|