@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/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.10.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 « 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,10 @@
|
|
|
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",
|
|
39
|
+
"./server-client-boundary": "./ratchet/server-client-boundary.js",
|
|
35
40
|
"./patterns": "./patterns.js",
|
|
36
41
|
"./ratchet": "./ratchet/ratchet.js",
|
|
37
42
|
"./prettier": "./prettier.config.js",
|
|
@@ -40,20 +45,22 @@
|
|
|
40
45
|
"./tsconfig/next.json": "./tsconfig/next.json",
|
|
41
46
|
"./tsconfig/react-native.json": "./tsconfig/react-native.json",
|
|
42
47
|
"./editorconfig": "./.editorconfig",
|
|
43
|
-
"./package.json": "./package.json"
|
|
44
|
-
"./conformance/account-baseline": "./conformance/account-baseline.js"
|
|
48
|
+
"./package.json": "./package.json"
|
|
45
49
|
},
|
|
46
50
|
"bin": {
|
|
47
|
-
"krizaka-ratchet": "./bin/krizaka-ratchet.mjs"
|
|
51
|
+
"krizaka-ratchet": "./bin/krizaka-ratchet.mjs",
|
|
52
|
+
"krizaka-known-violations": "./bin/krizaka-known-violations.mjs",
|
|
53
|
+
"krizaka-server-client-boundary": "./bin/krizaka-server-client-boundary.mjs"
|
|
48
54
|
},
|
|
49
55
|
"files": [
|
|
50
56
|
"bin",
|
|
51
|
-
"conformance",
|
|
52
57
|
"eslint",
|
|
53
58
|
"ratchet",
|
|
54
59
|
"tsconfig",
|
|
55
60
|
"patterns.js",
|
|
56
61
|
"dependency-cruiser.cjs",
|
|
62
|
+
"covered-domains.cjs",
|
|
63
|
+
"covered-domains.json",
|
|
57
64
|
"prettier.config.js",
|
|
58
65
|
".editorconfig",
|
|
59
66
|
"README.md",
|
|
@@ -61,12 +68,13 @@
|
|
|
61
68
|
],
|
|
62
69
|
"sideEffects": false,
|
|
63
70
|
"dependencies": {
|
|
64
|
-
"@eslint/js": "^10.0.
|
|
71
|
+
"@eslint/js": "^9.39.0 || ^10.0.0",
|
|
72
|
+
"dependency-cruiser": "^18.6.0",
|
|
65
73
|
"eslint-plugin-react-hooks": "^7.1.1",
|
|
66
74
|
"eslint-plugin-simple-import-sort": "^14.0.0",
|
|
67
75
|
"globals": "^17.13.0",
|
|
68
76
|
"tinyglobby": "^0.2.17",
|
|
69
|
-
"typescript-eslint": "^8.
|
|
77
|
+
"typescript-eslint": "^8.71.1"
|
|
70
78
|
},
|
|
71
79
|
"peerDependencies": {
|
|
72
80
|
"eslint": "^9 || ^10",
|
|
@@ -86,15 +94,14 @@
|
|
|
86
94
|
"@size-limit/file": "^14.2.0",
|
|
87
95
|
"@types/estree": "^1.0.8",
|
|
88
96
|
"@types/node": "^22.15.0",
|
|
89
|
-
"dependency-cruiser": "^18.7.0",
|
|
90
97
|
"eslint": "^9.39.0",
|
|
91
|
-
"eslint-config-next": "^16.4.
|
|
92
|
-
"next": "^16.4.
|
|
93
|
-
"prettier": "^3.9.
|
|
98
|
+
"eslint-config-next": "^16.4.0",
|
|
99
|
+
"next": "^16.4.1",
|
|
100
|
+
"prettier": "^3.9.9",
|
|
94
101
|
"publint": "^0.3.25",
|
|
95
102
|
"size-limit": "^14.2.0",
|
|
96
103
|
"typescript": "^5.9.0",
|
|
97
|
-
"vitest": "^
|
|
104
|
+
"vitest": "^4.1.11"
|
|
98
105
|
},
|
|
99
106
|
"engines": {
|
|
100
107
|
"node": ">=22"
|
|
@@ -119,6 +126,7 @@
|
|
|
119
126
|
"typecheck": "tsc --noEmit",
|
|
120
127
|
"test": "vitest run",
|
|
121
128
|
"size": "size-limit",
|
|
122
|
-
"publint": "publint"
|
|
129
|
+
"publint": "publint",
|
|
130
|
+
"check": "pnpm run lint && pnpm run test && pnpm run size && pnpm run publint"
|
|
123
131
|
}
|
|
124
132
|
}
|
package/patterns.js
CHANGED
|
@@ -245,20 +245,40 @@ export function balancedArguments(source, open) {
|
|
|
245
245
|
let depth = 0;
|
|
246
246
|
for (let i = open; i < source.length; i += 1) {
|
|
247
247
|
const c = source[i];
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
if (i < 0) break;
|
|
253
|
-
} else if (c === "/" && source[i + 1] === "*") {
|
|
254
|
-
i = source.indexOf("*/", i + 2) + 1;
|
|
255
|
-
if (i <= 0) break;
|
|
256
|
-
} else if (c === "(") depth += 1;
|
|
248
|
+
const skipped = skipLiteralOrComment(source, i);
|
|
249
|
+
if (skipped === null) break;
|
|
250
|
+
if (skipped > i) i = skipped;
|
|
251
|
+
else if (c === "(") depth += 1;
|
|
257
252
|
else if (c === ")" && --depth === 0) return source.slice(open + 1, i);
|
|
258
253
|
}
|
|
259
254
|
return source.slice(open + 1);
|
|
260
255
|
}
|
|
261
256
|
|
|
257
|
+
/**
|
|
258
|
+
* Where a string, template or comment that starts at `i` ends (the index of its last character), `i` itself when none starts
|
|
259
|
+
* there, null when a line comment or a block comment never ends.
|
|
260
|
+
* @param {string} source
|
|
261
|
+
* @param {number} i
|
|
262
|
+
* @returns {number | null}
|
|
263
|
+
*/
|
|
264
|
+
function skipLiteralOrComment(source, i) {
|
|
265
|
+
const c = source[i];
|
|
266
|
+
if (c === '"' || c === "'" || c === "`") {
|
|
267
|
+
let end = i + 1;
|
|
268
|
+
while (end < source.length && source[end] !== c) end += source[end] === "\\" ? 2 : 1;
|
|
269
|
+
return end;
|
|
270
|
+
}
|
|
271
|
+
if (c === "/" && source[i + 1] === "/") {
|
|
272
|
+
const end = source.indexOf("\n", i);
|
|
273
|
+
return end < 0 ? null : end;
|
|
274
|
+
}
|
|
275
|
+
if (c === "/" && source[i + 1] === "*") {
|
|
276
|
+
const end = source.indexOf("*/", i + 2);
|
|
277
|
+
return end < 0 ? null : end + 1;
|
|
278
|
+
}
|
|
279
|
+
return i;
|
|
280
|
+
}
|
|
281
|
+
|
|
262
282
|
// ── Selections live in the URL (Oussama, 2026-10-22: « changer les tabs doit changer les routes ») ──────────────────────
|
|
263
283
|
// Read by the ESLint rule krizaka/route-state-selection and by the ratchet.
|
|
264
284
|
|
|
@@ -312,63 +332,3 @@ export function isLocalSelection(tag, before = "") {
|
|
|
312
332
|
if (new RegExp(EPHEMERAL_SOURCE).test(tag) || new RegExp(EPHEMERAL_SOURCE).test(before)) return false;
|
|
313
333
|
return LOCAL_SELECTION_PROPS.some((prop) => new RegExp(`\\b${prop}\\s*=`).test(tag));
|
|
314
334
|
}
|
|
315
|
-
|
|
316
|
-
// ── Every page declares its template (Oussama, 2026-10-22: sessions hand-built pages instead of composing Krizaka UI) ──────
|
|
317
|
-
// Read by the ESLint rule krizaka/page-template and by the ratchet.
|
|
318
|
-
|
|
319
|
-
/** The page templates of @krizaka/ui (ui.krizaka.com/templates): the site's, then the app's; `redirect` is an address kept for old links. */
|
|
320
|
-
export const PAGE_TEMPLATES = Object.freeze([
|
|
321
|
-
"landing",
|
|
322
|
-
"product",
|
|
323
|
-
"how-it-works",
|
|
324
|
-
"gallery",
|
|
325
|
-
"docs-hub",
|
|
326
|
-
"docs-article",
|
|
327
|
-
"component",
|
|
328
|
-
"catalogue",
|
|
329
|
-
"longform",
|
|
330
|
-
"changelog",
|
|
331
|
-
"contact",
|
|
332
|
-
"legal",
|
|
333
|
-
"status",
|
|
334
|
-
"error",
|
|
335
|
-
"pricing",
|
|
336
|
-
"app-dashboard",
|
|
337
|
-
"app-list-detail",
|
|
338
|
-
"app-settings",
|
|
339
|
-
"app-onboarding",
|
|
340
|
-
"app-empty-first-run",
|
|
341
|
-
"app-wizard",
|
|
342
|
-
"redirect",
|
|
343
|
-
]);
|
|
344
|
-
|
|
345
|
-
/** The template that is not one: a page that really composes none, with its reason (the declaration `template: custom: a canvas editor`). */
|
|
346
|
-
export const CUSTOM_TEMPLATE = "custom";
|
|
347
|
-
|
|
348
|
-
/** A page of the Next.js App Router: `app/**\/page.tsx` (or `src/app/…`). */
|
|
349
|
-
export const PAGE_FILE_SOURCE = String.raw`(?:^|[\\/])app[\\/](?:.*[\\/])?page\.[cm]?[jt]sx?$`;
|
|
350
|
-
|
|
351
|
-
/** The declaration, alone on its line, as a line comment or a block comment (the rule's message shows its form). */
|
|
352
|
-
// The reason never starts with the block comment's `*/`: `/* @template: custom: */` declares no reason.
|
|
353
|
-
export const TEMPLATE_TAG_SOURCE = String.raw`^[ \t]*(?://|/\*+)[ \t]*@template:[ \t]*([a-z][a-z-]*)(?:[ \t]*:[ \t]*((?!\*\/)\S[^\n]*?)?)?[ \t]*(?:\*/)?[ \t]*$`;
|
|
354
|
-
|
|
355
|
-
/**
|
|
356
|
-
* What is wrong with a page's template declaration, or `null` when it is fine.
|
|
357
|
-
* @param {string} source
|
|
358
|
-
* @returns {{ kind: "missing" } | { kind: "unknown", name: string } | { kind: "reason" } | null}
|
|
359
|
-
*/
|
|
360
|
-
export function pageTemplateProblem(source) {
|
|
361
|
-
const tag = new RegExp(TEMPLATE_TAG_SOURCE, "m").exec(source);
|
|
362
|
-
if (!tag) return { kind: "missing" };
|
|
363
|
-
const [, name, reason] = tag;
|
|
364
|
-
if (name === CUSTOM_TEMPLATE) return reason ? null : { kind: "reason" };
|
|
365
|
-
return PAGE_TEMPLATES.includes(name) ? null : { kind: "unknown", name };
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
/**
|
|
369
|
-
* True for the file of a route's page.
|
|
370
|
-
* @param {string} filename
|
|
371
|
-
*/
|
|
372
|
-
export function isPageFile(filename) {
|
|
373
|
-
return new RegExp(PAGE_FILE_SOURCE).test(filename);
|
|
374
|
-
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// The known violations of dependency-cruiser, held so that they only shrink (krizaka-ui#612). An app that adopts the
|
|
2
|
+
// import rules late records what it already breaks in `.dependency-cruiser-known-violations.json` (the format of
|
|
3
|
+
// `depcruise-baseline`, read by `depcruise --ignore-known`); this module compares that file with a real run:
|
|
4
|
+
// - a violation that is not recorded is new: refused (`depcruise --ignore-known` refuses it as well);
|
|
5
|
+
// - a recorded violation that is gone is a gain: `update` removes it, `check` refuses to leave it recorded, so the
|
|
6
|
+
// file cannot hide a regression later;
|
|
7
|
+
// - `update` never adds a line. `init` writes the file once, when it does not exist (the adoption pull request).
|
|
8
|
+
import { spawnSync } from "node:child_process";
|
|
9
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { dirname, join, resolve } from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
|
|
13
|
+
export const KNOWN_FILE = ".dependency-cruiser-known-violations.json";
|
|
14
|
+
export const CONFIG_FILE = ".dependency-cruiser.cjs";
|
|
15
|
+
|
|
16
|
+
/** @typedef {{ type?: string, from: string, to: string, rule: { name: string, severity?: string }, cycle?: { name: string }[] }} Violation */
|
|
17
|
+
|
|
18
|
+
/** The identity of a violation, as depcruise matches it: the rule, the two ends, and the cycle's modules. @param {Violation} v */
|
|
19
|
+
export const keyOf = (v) => [v.rule.name, v.from, v.to, ...(v.cycle ?? []).map((step) => step.name)].join("\u0000");
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Compare the recorded violations with the current ones.
|
|
23
|
+
* @param {Violation[]} known
|
|
24
|
+
* @param {Violation[]} current
|
|
25
|
+
*/
|
|
26
|
+
export function compare(known, current) {
|
|
27
|
+
const knownKeys = new Set(known.map(keyOf));
|
|
28
|
+
const currentKeys = new Set(current.map(keyOf));
|
|
29
|
+
return {
|
|
30
|
+
added: current.filter((v) => !knownKeys.has(keyOf(v))),
|
|
31
|
+
gone: known.filter((v) => !currentKeys.has(keyOf(v))),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** What `update` writes: the recorded violations that still happen. Never a new one. @param {Violation[]} known @param {Violation[]} current */
|
|
36
|
+
export const shrink = (known, current) => known.filter((v) => current.some((c) => keyOf(c) === keyOf(v)));
|
|
37
|
+
|
|
38
|
+
/** @param {Violation} v */
|
|
39
|
+
const describe = (v) => `${v.rule.name}: ${v.from} -> ${v.to}${v.cycle ? ` (cycle: ${v.cycle.map((step) => step.name).join(" -> ")})` : ""}`;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The app's own dependency-cruiser first (its version is the one `depcruise` runs), then the one this package depends on.
|
|
43
|
+
* dependency-cruiser is ESM-only and hides its bin from `exports`: walk up `node_modules` from the app, then resolve the entry.
|
|
44
|
+
* @param {string} cwd
|
|
45
|
+
*/
|
|
46
|
+
function resolveDepcruise(cwd) {
|
|
47
|
+
const bin = (/** @type {string} */ root) => join(root, "bin", "dependency-cruiser.mjs");
|
|
48
|
+
for (let dir = resolve(cwd); ; dir = dirname(dir)) {
|
|
49
|
+
const candidate = bin(join(dir, "node_modules", "dependency-cruiser"));
|
|
50
|
+
if (existsSync(candidate)) return candidate;
|
|
51
|
+
if (dirname(dir) === dir) break;
|
|
52
|
+
}
|
|
53
|
+
let root = dirname(fileURLToPath(import.meta.resolve("dependency-cruiser")));
|
|
54
|
+
while (!existsSync(bin(root)) && dirname(root) !== root) root = dirname(root);
|
|
55
|
+
if (existsSync(bin(root))) return bin(root);
|
|
56
|
+
throw new Error("dependency-cruiser is not installed here: add it to the devDependencies.");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Run dependency-cruiser without the known file and read every violation (the `baseline` report).
|
|
61
|
+
* @param {{ cwd: string, config: string, targets: string[] }} options
|
|
62
|
+
* @returns {Violation[]}
|
|
63
|
+
*/
|
|
64
|
+
export function currentViolations({ cwd, config, targets }) {
|
|
65
|
+
const bin = resolveDepcruise(cwd);
|
|
66
|
+
const run = spawnSync(process.execPath, [bin, ...targets, "--config", config, "--output-type", "baseline"], {
|
|
67
|
+
cwd,
|
|
68
|
+
encoding: "utf8",
|
|
69
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
70
|
+
});
|
|
71
|
+
try {
|
|
72
|
+
return JSON.parse(run.stdout);
|
|
73
|
+
} catch {
|
|
74
|
+
throw new Error(`dependency-cruiser failed:\n${run.stderr || run.stdout}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** @param {string} file @returns {Violation[]} */
|
|
79
|
+
const readKnown = (file) => JSON.parse(readFileSync(file, "utf8"));
|
|
80
|
+
/** @param {string} file @param {Violation[]} violations */
|
|
81
|
+
const writeKnown = (file, violations) => writeFileSync(file, `${JSON.stringify(violations, null, 2)}\n`);
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* `check` | `update` | `init`. Returns the exit code and the lines to print.
|
|
85
|
+
* @param {"check" | "update" | "init"} command
|
|
86
|
+
* @param {{ cwd?: string, config?: string, known?: string, targets: string[] }} options
|
|
87
|
+
* @returns {{ code: number, lines: string[] }}
|
|
88
|
+
*/
|
|
89
|
+
export function runKnownViolations(command, { cwd = process.cwd(), config = CONFIG_FILE, known = KNOWN_FILE, targets }) {
|
|
90
|
+
const file = resolve(cwd, known);
|
|
91
|
+
const current = currentViolations({ cwd, config, targets });
|
|
92
|
+
if (command === "init") {
|
|
93
|
+
if (existsSync(file)) return { code: 1, lines: [`${known} exists: the baseline is written once. Use "update" to lower it.`] };
|
|
94
|
+
writeKnown(file, current);
|
|
95
|
+
return { code: 0, lines: [`${known}: ${current.length} known violations recorded. They only go down.`] };
|
|
96
|
+
}
|
|
97
|
+
const recorded = existsSync(file) ? readKnown(file) : [];
|
|
98
|
+
const { added, gone } = compare(recorded, current);
|
|
99
|
+
if (command === "update") {
|
|
100
|
+
writeKnown(file, shrink(recorded, current));
|
|
101
|
+
const lines = [`${known}: ${recorded.length} -> ${recorded.length - gone.length} known violations.`];
|
|
102
|
+
if (added.length > 0) {
|
|
103
|
+
lines.push(`${added.length} new violation(s) are NOT recorded (the baseline never grows). Fix them:`, ...added.map((v) => ` ${describe(v)}`));
|
|
104
|
+
return { code: 1, lines };
|
|
105
|
+
}
|
|
106
|
+
return { code: 0, lines };
|
|
107
|
+
}
|
|
108
|
+
const lines = [
|
|
109
|
+
...added.map((v) => `new: ${describe(v)}`),
|
|
110
|
+
...gone.map((v) => `fixed but still recorded: ${describe(v)}`),
|
|
111
|
+
];
|
|
112
|
+
if (added.length > 0) lines.push("A new violation is never recorded: fix it (missing function? add it to the Krizaka package first).");
|
|
113
|
+
if (gone.length > 0) lines.push(`Lock the gain: krizaka-known-violations update (${gone.length} fewer).`);
|
|
114
|
+
if (lines.length === 0) lines.push(`${known}: ${recorded.length} known violations, none new, none fixed.`);
|
|
115
|
+
return { code: added.length + gone.length > 0 ? 1 : 0, lines };
|
|
116
|
+
}
|
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
|
|
|
@@ -16,9 +16,7 @@ import {
|
|
|
16
16
|
FETCH_CALL_SOURCE,
|
|
17
17
|
importSource,
|
|
18
18
|
isLocalSelection,
|
|
19
|
-
isPageFile,
|
|
20
19
|
LIGHT_VARIANT_SOURCE,
|
|
21
|
-
pageTemplateProblem,
|
|
22
20
|
paletteSource,
|
|
23
21
|
selectionTags,
|
|
24
22
|
TABLE_OR_FORM_SOURCE,
|
|
@@ -36,7 +34,7 @@ export const DEFAULT_STACK_GLOBS = Object.freeze(
|
|
|
36
34
|
const IGNORE = ["**/node_modules/**", "**/dist/**", "**/.next/**", "**/storybook-static/**", "**/coverage/**"];
|
|
37
35
|
|
|
38
36
|
/** @typedef {"palette" | "light" | "arbitraryVar" | "classNameTemplate"} UiCounterName */
|
|
39
|
-
/** @typedef {"fetchInClientEffect" | "asyncInEffect" | "derivedStateInEffect" | "directTanstackImport" | "localTableOrForm" | "retiredImport" | "routeStateSelection"
|
|
37
|
+
/** @typedef {"fetchInClientEffect" | "asyncInEffect" | "derivedStateInEffect" | "directTanstackImport" | "localTableOrForm" | "retiredImport" | "routeStateSelection"} StackCounterName */
|
|
40
38
|
/** @typedef {UiCounterName | StackCounterName} CounterName */
|
|
41
39
|
/** @typedef {Record<CounterName, number>} Counters */
|
|
42
40
|
/**
|
|
@@ -60,7 +58,6 @@ export const STACK_COUNTERS = /** @type {const} */ ([
|
|
|
60
58
|
"localTableOrForm",
|
|
61
59
|
"retiredImport",
|
|
62
60
|
"routeStateSelection",
|
|
63
|
-
"pageTemplate",
|
|
64
61
|
]);
|
|
65
62
|
/** Every counter, in report order. */
|
|
66
63
|
export const COUNTERS = /** @type {const} */ ([...UI_COUNTERS, ...STACK_COUNTERS]);
|
|
@@ -98,7 +95,7 @@ export function countSource(source, allow = []) {
|
|
|
98
95
|
* `@tanstack/*`, hand-written tables and forms of more than `maxFormFields` fields, imports of the retired dependencies.
|
|
99
96
|
* @param {string} source
|
|
100
97
|
* @param {{ retire?: readonly string[], maxFormFields?: number }} [options]
|
|
101
|
-
* @returns {Record<
|
|
98
|
+
* @returns {Record<StackCounterName, number>}
|
|
102
99
|
*/
|
|
103
100
|
export function countStackSource(source, { retire = [], maxFormFields = 1 } = {}) {
|
|
104
101
|
let fetchInClientEffect = 0;
|
|
@@ -136,15 +133,6 @@ export function countStackSource(source, { retire = [], maxFormFields = 1 } = {}
|
|
|
136
133
|
};
|
|
137
134
|
}
|
|
138
135
|
|
|
139
|
-
/**
|
|
140
|
-
* 1 when `file` is a page of the app router with no valid template declaration, else 0.
|
|
141
|
-
* @param {string} file
|
|
142
|
-
* @param {string} source
|
|
143
|
-
*/
|
|
144
|
-
export function countPages(file, source) {
|
|
145
|
-
return isPageFile(file) && pageTemplateProblem(source) ? 1 : 0;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
136
|
/**
|
|
149
137
|
* Counts the debts under `root`: the UI counters in the files of `globs`, the stack counters in those of `stackGlobs`.
|
|
150
138
|
* @param {{ root: string, globs?: readonly string[], stackGlobs?: readonly string[], allow?: string[],
|
|
@@ -168,8 +156,7 @@ export async function countRepository({
|
|
|
168
156
|
}
|
|
169
157
|
for (const file of stackFiles) {
|
|
170
158
|
const found = countStackSource(readFileSync(file, "utf8"), { retire, maxFormFields });
|
|
171
|
-
for (const name of STACK_COUNTERS) counters[name] += found[name]
|
|
172
|
-
counters.pageTemplate += countPages(file, readFileSync(file, "utf8"));
|
|
159
|
+
for (const name of STACK_COUNTERS) counters[name] += found[name];
|
|
173
160
|
}
|
|
174
161
|
return { counters, files: files.length, stackFiles: stackFiles.length };
|
|
175
162
|
}
|
|
@@ -218,6 +205,25 @@ export class RatchetError extends Error {}
|
|
|
218
205
|
* @property {boolean} written the ratchet file was created, lowered or completed
|
|
219
206
|
*/
|
|
220
207
|
|
|
208
|
+
/**
|
|
209
|
+
* Each counter against its recorded value: the report, and the names that went up or down.
|
|
210
|
+
* @param {Counters} current
|
|
211
|
+
* @param {Counters} recorded
|
|
212
|
+
*/
|
|
213
|
+
function compareCounters(current, recorded) {
|
|
214
|
+
/** @type {RatchetResult["counters"]} */
|
|
215
|
+
const report = /** @type {any} */ ({});
|
|
216
|
+
/** @type {CounterName[]} */ const increased = [];
|
|
217
|
+
/** @type {CounterName[]} */ const decreased = [];
|
|
218
|
+
for (const name of COUNTERS) {
|
|
219
|
+
const delta = current[name] - recorded[name];
|
|
220
|
+
report[name] = { recorded: recorded[name], current: current[name], delta };
|
|
221
|
+
if (delta > 0) increased.push(name);
|
|
222
|
+
if (delta < 0) decreased.push(name);
|
|
223
|
+
}
|
|
224
|
+
return { report, increased, decreased };
|
|
225
|
+
}
|
|
226
|
+
|
|
221
227
|
/**
|
|
222
228
|
* Runs the ratchet.
|
|
223
229
|
* - `check` (default): fails when a counter is above its recorded value.
|
|
@@ -229,6 +235,11 @@ export class RatchetError extends Error {}
|
|
|
229
235
|
*/
|
|
230
236
|
export async function runRatchet({ root, mode = "check", globs, file = RATCHET_FILE }) {
|
|
231
237
|
const path = resolve(root, file);
|
|
238
|
+
// the ratchet file is read and written: it stays a .json file inside the repository root (no `../`, no absolute path elsewhere)
|
|
239
|
+
const inside = relative(resolve(root), path);
|
|
240
|
+
if (!path.endsWith(".json") || inside === "" || inside.startsWith("..") || isAbsolute(inside)) {
|
|
241
|
+
throw new RatchetError(`${file}: the ratchet file must be a .json file inside ${root}.`);
|
|
242
|
+
}
|
|
232
243
|
const exists = existsSync(path);
|
|
233
244
|
if (mode === "init" && exists) throw new RatchetError(`${file} already exists: use --update to lower it.`);
|
|
234
245
|
if (mode !== "init" && !exists) throw new RatchetError(`${file} not found in ${root}: run krizaka-ratchet --init first.`);
|
|
@@ -250,16 +261,7 @@ export async function runRatchet({ root, mode = "check", globs, file = RATCHET_F
|
|
|
250
261
|
/** @type {CounterName[]} */ const added = recordedFile ? COUNTERS.filter((n) => recordedFile.counters[n] === undefined) : [];
|
|
251
262
|
const recorded = /** @type {Counters} */ ({ ...current, ...recordedFile?.counters });
|
|
252
263
|
|
|
253
|
-
|
|
254
|
-
const report = /** @type {any} */ ({});
|
|
255
|
-
/** @type {CounterName[]} */ const increased = [];
|
|
256
|
-
/** @type {CounterName[]} */ const decreased = [];
|
|
257
|
-
for (const name of COUNTERS) {
|
|
258
|
-
const delta = current[name] - recorded[name];
|
|
259
|
-
report[name] = { recorded: recorded[name], current: current[name], delta };
|
|
260
|
-
if (delta > 0) increased.push(name);
|
|
261
|
-
if (delta < 0) decreased.push(name);
|
|
262
|
-
}
|
|
264
|
+
const { report, increased, decreased } = compareCounters(current, recorded);
|
|
263
265
|
|
|
264
266
|
let written = false;
|
|
265
267
|
if (mode === "init") {
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// The server/client boundary of a Next.js app (App Router): a module the server renders must not call a function that a
|
|
2
|
+
// `"use client"` module of a Krizaka package exports. Next replaces such an export with a client reference, and calling it on the
|
|
3
|
+
// server crashes the page with React error #441 ("Attempted to call readFilterParams() from the server"). Unit tests never see
|
|
4
|
+
// it (vitest ignores the directive) and `next build` does not either, so this walks what the server renders: the pages, layouts
|
|
5
|
+
// and routes, then every local module they import, stopping at a `"use client"` file.
|
|
6
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
7
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_SCOPE = "@krizaka";
|
|
10
|
+
const ENTRY = /(?:^|[\\/])(?:page|layout|route|error|global-error|loading|not-found|default|template)\.(?:ts|tsx|js|jsx|mjs)$/;
|
|
11
|
+
// `import … from "x"` / `export … from "x"`: the clause is everything up to the first quote (no nested quantifier: linear).
|
|
12
|
+
const STATEMENT = /(?:^|\n)[ \t]*(import|export)\b([^"';]*)["']([^"']+)["']/g;
|
|
13
|
+
const EXTENSIONS = ["", ".ts", ".tsx", ".js", ".jsx", ".mjs"];
|
|
14
|
+
|
|
15
|
+
/** @param {string} dir @returns {string[]} */
|
|
16
|
+
const walk = (dir) =>
|
|
17
|
+
readdirSync(dir).flatMap((name) => {
|
|
18
|
+
const path = join(dir, name);
|
|
19
|
+
if (name === "node_modules" || name === ".next") return [];
|
|
20
|
+
return statSync(path).isDirectory() ? walk(path) : [path];
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
/** @param {string} path */
|
|
24
|
+
/** Does the module start with the `"use client"` directive (comments and blank lines may come first)? Scanned, not matched: linear. @param {string} source */
|
|
25
|
+
export const isClientSource = (source) => {
|
|
26
|
+
let i = 0;
|
|
27
|
+
for (;;) {
|
|
28
|
+
while (i < source.length && /\s/.test(source[i])) i++;
|
|
29
|
+
if (source.startsWith("/*", i)) {
|
|
30
|
+
const end = source.indexOf("*/", i + 2);
|
|
31
|
+
if (end < 0) return false;
|
|
32
|
+
i = end + 2;
|
|
33
|
+
} else if (source.startsWith("//", i)) {
|
|
34
|
+
const end = source.indexOf("\n", i);
|
|
35
|
+
if (end < 0) return false;
|
|
36
|
+
i = end + 1;
|
|
37
|
+
} else break;
|
|
38
|
+
}
|
|
39
|
+
const quote = source[i];
|
|
40
|
+
return (quote === '"' || quote === "'") && source.startsWith("use client", i + 1) && source[i + 11] === quote;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** @param {string} path */
|
|
44
|
+
const isFile = (path) => existsSync(path) && statSync(path).isFile();
|
|
45
|
+
|
|
46
|
+
/** The `"@/*"` alias of tsconfig.json (read as text: comments and trailing commas are fine); `@/` means the root when there is none. */
|
|
47
|
+
/** @param {string} root */
|
|
48
|
+
export const readAlias = (root) => {
|
|
49
|
+
try {
|
|
50
|
+
const text = readFileSync(join(root, "tsconfig.json"), "utf8");
|
|
51
|
+
const key = text.indexOf('"@/*"');
|
|
52
|
+
const open = key < 0 ? -1 : text.indexOf('"', text.indexOf("[", key) + 1);
|
|
53
|
+
const target = open < 0 ? "" : text.slice(open + 1, text.indexOf('"', open + 1));
|
|
54
|
+
if (target) return target.replace(/\/?\*$/, "") || ".";
|
|
55
|
+
} catch {
|
|
56
|
+
// no tsconfig, or one we cannot read: the default stands
|
|
57
|
+
}
|
|
58
|
+
return ".";
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** The first folder of `candidates` that exists under root. */
|
|
62
|
+
/** @param {string} root */
|
|
63
|
+
const findApp = (root) => ["app", "src/app"].map((d) => join(root, d)).find((d) => existsSync(d));
|
|
64
|
+
|
|
65
|
+
/** @param {string} root @param {string} aliasDir @param {string} from @param {string} spec @returns {string | null} */
|
|
66
|
+
const resolveLocal = (root, aliasDir, from, spec) => {
|
|
67
|
+
let base = null;
|
|
68
|
+
if (spec.startsWith("@/")) base = join(root, aliasDir, spec.slice(2));
|
|
69
|
+
else if (spec.startsWith(".")) base = resolve(dirname(from), spec);
|
|
70
|
+
if (!base) return null;
|
|
71
|
+
for (const ext of EXTENSIONS) if (isFile(base + ext)) return base + ext;
|
|
72
|
+
for (const index of ["index.ts", "index.tsx", "index.js"]) if (isFile(join(base, index))) return join(base, index);
|
|
73
|
+
return null;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** @param {string} from @param {string} scope @param {string} name @returns {string | null} */
|
|
77
|
+
const findPackageDir = (from, scope, name) => {
|
|
78
|
+
for (let dir = from; ; dir = dirname(dir)) {
|
|
79
|
+
const candidate = join(dir, "node_modules", scope, name);
|
|
80
|
+
if (existsSync(candidate)) return candidate;
|
|
81
|
+
if (dirname(dir) === dir) return null;
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/** @param {unknown} target @returns {string | null} */
|
|
86
|
+
const pick = (target) => {
|
|
87
|
+
if (typeof target === "string") return target;
|
|
88
|
+
if (!target || typeof target !== "object") return null;
|
|
89
|
+
for (const key of ["import", "module", "default", "node", "require"]) {
|
|
90
|
+
const found = pick(/** @type {Record<string, unknown>} */ (target)[key]);
|
|
91
|
+
if (found) return found;
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** Is the module `spec` of a package a `"use client"` module? Reads the first line of the built entry its `exports` names. */
|
|
97
|
+
/** @param {string} root @param {string} spec */
|
|
98
|
+
export const packageIsClient = (root, spec) => {
|
|
99
|
+
const [scope, name, ...rest] = spec.split("/");
|
|
100
|
+
const dir = findPackageDir(root, scope, name);
|
|
101
|
+
if (!dir) return false;
|
|
102
|
+
let file;
|
|
103
|
+
try {
|
|
104
|
+
const manifest = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
105
|
+
const key = rest.length ? `./${rest.join("/")}` : ".";
|
|
106
|
+
file = pick(manifest.exports?.[key]) ?? (rest.length ? null : manifest.module ?? manifest.main);
|
|
107
|
+
} catch {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
if (!file) return false;
|
|
111
|
+
const entry = join(dir, file);
|
|
112
|
+
return isFile(entry) && isClientSource(readFileSync(entry, "utf8"));
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The lowercase (function-like) names a statement's clause brings in as values; a type-only import is erased and harmless.
|
|
117
|
+
* `clause` is the text between `import`/`export` and the quote of the module, e.g. ` { a, type B } from `.
|
|
118
|
+
* @param {string} clause
|
|
119
|
+
* @returns {string[]}
|
|
120
|
+
*/
|
|
121
|
+
export const functionNames = (clause) => {
|
|
122
|
+
const words = clause.trim().split(/\s+/);
|
|
123
|
+
if (words.at(-1) === "from") words.pop();
|
|
124
|
+
const text = words.join(" ");
|
|
125
|
+
if (text.startsWith("type ") || text.startsWith("type{")) return [];
|
|
126
|
+
const open = text.indexOf("{");
|
|
127
|
+
const close = open < 0 ? -1 : text.indexOf("}", open);
|
|
128
|
+
const names = open < 0 ? [] : bracedNames(text.slice(open + 1, close < 0 ? text.length : close));
|
|
129
|
+
const head = defaultImportName(open < 0 ? text : `${text.slice(0, open)} ${close < 0 ? "" : text.slice(close + 1)}`);
|
|
130
|
+
if (head) names.push(head);
|
|
131
|
+
return names;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/** The function-like names inside the braces of an import clause (`type X` entries are erased). @param {string} braces @returns {string[]} */
|
|
135
|
+
const bracedNames = (braces) =>
|
|
136
|
+
braces
|
|
137
|
+
.split(",")
|
|
138
|
+
.map((part) => part.trim().split(" "))
|
|
139
|
+
.filter((tokens) => tokens[0] && !(tokens[0] === "type" && tokens.length > 1) && /^[a-z_$]/.test(tokens[0]))
|
|
140
|
+
.map((tokens) => tokens[0]);
|
|
141
|
+
|
|
142
|
+
/** The default import of what is left outside the braces, when it is function-like: `import readIt from "@krizaka/x"`. @param {string} rest @returns {string | null} */
|
|
143
|
+
const defaultImportName = (rest) => {
|
|
144
|
+
const head = rest.replaceAll(",", " ").trim().split(" ")[0] ?? "";
|
|
145
|
+
return head && !head.startsWith("*") && /^[a-z_$]/.test(head) ? head : null;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Walk what the server renders. `appDirs` defaults to `app` or `src/app`.
|
|
150
|
+
* Returns the visited files and the offences (`file imports a, b from @krizaka/x ("use client")`).
|
|
151
|
+
*/
|
|
152
|
+
/** @param {{ root?: string, appDir?: string, alias?: string, scope?: string }} [options] @returns {{ seen: Set<string>, offences: string[] }} */
|
|
153
|
+
export const checkBoundary = ({ root = process.cwd(), appDir, alias, scope = DEFAULT_SCOPE } = {}) => {
|
|
154
|
+
const app = appDir ? resolve(root, appDir) : findApp(root);
|
|
155
|
+
if (!app) throw new Error(`No app folder under ${root} (looked for app and src/app); pass --app.`);
|
|
156
|
+
const aliasDir = alias ?? readAlias(root);
|
|
157
|
+
/** @type {Set<string>} */
|
|
158
|
+
const seen = new Set();
|
|
159
|
+
/** @type {string[]} */
|
|
160
|
+
const offences = [];
|
|
161
|
+
const clientCache = new Map();
|
|
162
|
+
/** @param {string} spec */
|
|
163
|
+
const isClient = (spec) => {
|
|
164
|
+
if (!clientCache.has(spec)) clientCache.set(spec, packageIsClient(root, spec));
|
|
165
|
+
return clientCache.get(spec);
|
|
166
|
+
};
|
|
167
|
+
/** @param {string} file */
|
|
168
|
+
const visit = (file) => {
|
|
169
|
+
if (seen.has(file)) return;
|
|
170
|
+
seen.add(file);
|
|
171
|
+
const source = readFileSync(file, "utf8");
|
|
172
|
+
if (isClientSource(source)) return;
|
|
173
|
+
for (const match of source.matchAll(STATEMENT)) {
|
|
174
|
+
const [, , clause, spec] = match;
|
|
175
|
+
if (!clause.trimEnd().endsWith("from")) continue; // `import "x"`, or a string in some other statement
|
|
176
|
+
if (spec.startsWith(`${scope}/`)) {
|
|
177
|
+
const names = functionNames(clause);
|
|
178
|
+
if (names.length && isClient(spec)) offences.push(`${relative(root, file)} imports ${names.join(", ")} from ${spec} ("use client")`);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const local = resolveLocal(root, aliasDir, file, spec);
|
|
182
|
+
if (local && /\.(?:ts|tsx|js|jsx|mjs)$/.test(local)) visit(local);
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
for (const entry of walk(app).filter((f) => ENTRY.test(f) && !/\.(?:test|spec)\./.test(f))) visit(entry);
|
|
186
|
+
return { seen, offences };
|
|
187
|
+
};
|