@krizaka/config 0.9.1 → 0.11.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 +26 -0
- package/bin/krizaka-ratchet.mjs +2 -1
- package/bin/krizaka-server-client-boundary.mjs +36 -0
- package/eslint/stack.js +13 -2
- package/package.json +5 -2
- package/ratchet/ratchet.js +40 -5
- package/ratchet/server-client-boundary.js +187 -0
package/README.md
CHANGED
|
@@ -54,6 +54,7 @@ the package.
|
|
|
54
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
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
56
|
| `krizaka-known-violations` (bin) · `@krizaka/config/known-violations` | The known violations of dependency-cruiser, which only shrink, see below. |
|
|
57
|
+
| `krizaka-server-client-boundary` (bin) · `@krizaka/config/server-client-boundary` | Fails when a server-rendered module imports a function from a `"use client"` `@krizaka/*` module (React #441), see below. |
|
|
57
58
|
| `@krizaka/config/tsconfig/*.json` | `base` (strict, ES2022, bundler resolution), `react-library` (+ DOM, `react-jsx`), `next` (+ the Next plugin, `noEmit`, `incremental`), `react-native` (`jsx: react-native`, `customConditions`; with Expo: `"extends": ["expo/tsconfig.base", "@krizaka/config/tsconfig/react-native.json"]`). |
|
|
58
59
|
| `@krizaka/config/prettier` · `.editorconfig` | The formatting. |
|
|
59
60
|
| `krizaka-ratchet` (bin) | The debt counter, see below. |
|
|
@@ -96,6 +97,15 @@ read any file (a Vite/Tauri app has no `"use client"`).
|
|
|
96
97
|
2. **`krizaka/no-derived-state-in-effect`** — an effect whose only statements are `setX(…)` calls: compute the value
|
|
97
98
|
during render, or reset the component with a `key`.
|
|
98
99
|
|
|
100
|
+
### Cognitive complexity (Sonar S3776)
|
|
101
|
+
|
|
102
|
+
On 2026-10-27 every failed quality gate came from Sonar's S3776 (krizaka/.github#319), and only Sonar checked it.
|
|
103
|
+
`krizakaStack()` now carries **`sonarjs/cognitive-complexity` at 15** (`eslint-plugin-sonarjs`; the threshold is never
|
|
104
|
+
raised, the rule never disabled): split the function (early returns, extracted helpers, a lookup table instead of a
|
|
105
|
+
chain of `if`). **Error by default** (`complexity: "error"`); a repository with debt passes `complexity: "warn"` while
|
|
106
|
+
`krizaka-ratchet` (`cognitiveComplexity`) freezes the count of functions over 15 at `--update` time and fails any
|
|
107
|
+
increase. Test files (`*.test.*`, `*.spec.*`, `__tests__/`) are not counted, as Sonar does not scan them.
|
|
108
|
+
|
|
99
109
|
### The three stack rules
|
|
100
110
|
|
|
101
111
|
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
|
|
@@ -170,6 +180,21 @@ stays only for repositories that have not moved yet.
|
|
|
170
180
|
`noLocaleTernary({ files, routing })`: `routing` lists the files allowed to compare locales (default: `proxy.ts`,
|
|
171
181
|
`middleware.ts`, `i18n.ts`, `seo.ts`, `app/[locale]/layout.tsx`, `I18nProvider.tsx`).
|
|
172
182
|
|
|
183
|
+
### Server/client boundary (`krizaka-server-client-boundary`)
|
|
184
|
+
|
|
185
|
+
A Next.js server module that calls a function exported by a `"use client"` module (`readFilterParams()` of
|
|
186
|
+
`@krizaka/ui/filter-bar`) crashes the page with React error #441. Unit tests ignore the directive and `next build` does not
|
|
187
|
+
see it. Every Next.js App Router app runs the guard in its lint:
|
|
188
|
+
|
|
189
|
+
```sh
|
|
190
|
+
krizaka-server-client-boundary # from the app's folder: reads app/ or src/app/ and the "@/*" alias of tsconfig.json
|
|
191
|
+
krizaka-server-client-boundary --root apps/web --app app --scope @krizaka
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
It walks the pages, layouts and routes, then every local module they import, and stops at a `"use client"` file. A lowercase
|
|
195
|
+
value import (a function, a hook, a constant) from a client module of the scope is an offence; components and `import type`
|
|
196
|
+
are fine. Render a component, or move the function to a plain module. `checkBoundary()` is also exported for a test.
|
|
197
|
+
|
|
173
198
|
### Environment variable names (`krizaka/env-name`)
|
|
174
199
|
|
|
175
200
|
Environment variables are named by bounded context (krizaka/.github#46): `<CONTEXT>_<THING>` with CONTEXT one of
|
|
@@ -213,6 +238,7 @@ npx krizaka-ratchet --json # in CI: the same check, machine-readable
|
|
|
213
238
|
| `directTanstackImport` | `from` / `import` / `import(` / `require(` of `"@tanstack/…"` |
|
|
214
239
|
| `localTableOrForm` | `<table`, and `<form` … `</form>` holding more fields than `maxFormFields` (default 1) |
|
|
215
240
|
| `retiredImport` | an import of a dependency listed in `retire` (e.g. `["framer-motion"]`): the way out of a dependency |
|
|
241
|
+
| `cognitiveComplexity` | functions whose `sonarjs/cognitive-complexity` is above 15 (Sonar S3776), counted by running the rule on each source file of `stackGlobs`, tests excluded; the KPI of the cognitive debt, it only goes down |
|
|
216
242
|
| `routeStateSelection` | a Tabs / chip group / choice grid / segmented switch (incl. `RadioGroup.Root variant="segmented"`) / toggle group of a view driven by local state (`value`, `defaultValue`, `onValueChange`) without `routeKey`, and without `// @ephemeral: <reason>`: the URL is the state |
|
|
217
243
|
|
|
218
244
|
The UI counters scan `app/**/*.tsx`, `components/**/*.tsx` and `src/**/*.tsx` by default (`node_modules`, `dist`,
|
package/bin/krizaka-ratchet.mjs
CHANGED
|
@@ -8,7 +8,7 @@ const USAGE = `Usage: krizaka-ratchet [globs…] [--init | --update] [--json] [-
|
|
|
8
8
|
|
|
9
9
|
Counts the UI debts — raw palette colours, light: variants, [var(--…)] utilities, className template strings — in the
|
|
10
10
|
source files (default: app/**/*.tsx components/**/*.tsx src/**/*.tsx), and the stack debts — fetch in a client effect,
|
|
11
|
-
data loaded in an effect, state derived in an effect, direct @tanstack/* imports, hand-written tables and forms, imports of the dependencies listed in "retire" — in every
|
|
11
|
+
data loaded in an effect, state derived in an effect, direct @tanstack/* imports, hand-written tables and forms, functions above Sonar's cognitive complexity of 15 (S3776), imports of the dependencies listed in "retire" — in every
|
|
12
12
|
source file of app, components, src, lib, hooks and features; compares them with lint-ratchet.json.
|
|
13
13
|
|
|
14
14
|
(no flag) fail (exit 1) when a counter is above its recorded value
|
|
@@ -30,6 +30,7 @@ const LABELS = {
|
|
|
30
30
|
localTableOrForm: "hand-written tables / forms",
|
|
31
31
|
retiredImport: "imports of retired deps",
|
|
32
32
|
routeStateSelection: "selections in local state (not the URL)",
|
|
33
|
+
cognitiveComplexity: "functions over complexity 15 (S3776)",
|
|
33
34
|
};
|
|
34
35
|
|
|
35
36
|
let args;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// krizaka-server-client-boundary: no server-rendered module calls a function exported by a "use client" Krizaka module (React #441).
|
|
3
|
+
import { checkBoundary, DEFAULT_SCOPE } from "../ratchet/server-client-boundary.js";
|
|
4
|
+
|
|
5
|
+
const USAGE = `Usage: krizaka-server-client-boundary [--root <app folder>] [--app <app router folder>] [--alias <folder of "@/">] [--scope ${DEFAULT_SCOPE}]
|
|
6
|
+
|
|
7
|
+
Walks what the server renders (pages, layouts, routes and the local modules they import, down to a "use client" file) and fails
|
|
8
|
+
when one imports a function (a lowercase export) from a "use client" module of the scope: the server would call a client
|
|
9
|
+
reference and the page would crash (React error #441). Render a component, or move the function to a plain module.`;
|
|
10
|
+
|
|
11
|
+
const options = {};
|
|
12
|
+
const args = process.argv.slice(2);
|
|
13
|
+
for (let i = 0; i < args.length; i++) {
|
|
14
|
+
const arg = args[i];
|
|
15
|
+
if (arg === "--root") options.root = args[++i];
|
|
16
|
+
else if (arg === "--app") options.appDir = args[++i];
|
|
17
|
+
else if (arg === "--alias") options.alias = args[++i];
|
|
18
|
+
else if (arg === "--scope") options.scope = args[++i];
|
|
19
|
+
else {
|
|
20
|
+
console.error(USAGE);
|
|
21
|
+
process.exit(2);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
const { seen, offences } = checkBoundary(options);
|
|
26
|
+
if (offences.length === 0) {
|
|
27
|
+
console.log(`krizaka-server-client-boundary: ${seen.size} server modules checked, no client function called from the server.`);
|
|
28
|
+
} else {
|
|
29
|
+
console.error(`krizaka-server-client-boundary: ${offences.length} server module(s) import a function from a "use client" module (React #441):`);
|
|
30
|
+
for (const line of offences) console.error(` ${line}`);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
} catch (error) {
|
|
34
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
35
|
+
process.exit(2);
|
|
36
|
+
}
|
package/eslint/stack.js
CHANGED
|
@@ -2,8 +2,13 @@
|
|
|
2
2
|
// @krizaka/ui. The import of TanStack (S11) is a dependency-cruiser rule now, `krizaka-first-query`: one tool, one rule.
|
|
3
3
|
// Warnings: krizaka-ratchet counts the same patterns, so an app adopts them now and brings each count to 0 by the end
|
|
4
4
|
// of its adoption.
|
|
5
|
+
import sonarjs from "eslint-plugin-sonarjs";
|
|
6
|
+
|
|
5
7
|
import { krizakaPlugin } from "./plugin.js";
|
|
6
8
|
|
|
9
|
+
/** Sonar S3776 threshold: a function above it is a quality-gate failure. Never raised. */
|
|
10
|
+
export const COGNITIVE_COMPLEXITY_MAX = 15;
|
|
11
|
+
|
|
7
12
|
/** The rule names, as configured. */
|
|
8
13
|
export const STACK_RULES = Object.freeze([
|
|
9
14
|
"krizaka/no-fetch-in-client-effect",
|
|
@@ -12,13 +17,16 @@ export const STACK_RULES = Object.freeze([
|
|
|
12
17
|
"krizaka/no-local-table-or-form",
|
|
13
18
|
"krizaka/no-family-switcher",
|
|
14
19
|
"krizaka/route-state-selection",
|
|
20
|
+
"sonarjs/cognitive-complexity",
|
|
15
21
|
]);
|
|
16
22
|
|
|
17
23
|
/**
|
|
18
24
|
* Flat config: the stack rules on an app's sources.
|
|
19
|
-
* @param {{ files?: string[], ignores?: string[], severity?: "warn" | "error", effects?: "warn" | "error" | "off", maxFormFields?: number }} [options]
|
|
25
|
+
* @param {{ files?: string[], ignores?: string[], severity?: "warn" | "error", effects?: "warn" | "error" | "off", complexity?: "warn" | "error", maxFormFields?: number }} [options]
|
|
20
26
|
* `effects`: severity of the two effect rules (react-effects standard): `"error"` by default; a repository with debt
|
|
21
27
|
* passes `"warn"` while `krizaka-ratchet` (asyncInEffect, derivedStateInEffect) brings the count to 0.
|
|
28
|
+
* `complexity`: severity of `sonarjs/cognitive-complexity` (Sonar S3776, threshold 15): `"error"` by default; a
|
|
29
|
+
* repository with debt passes `"warn"` while `krizaka-ratchet` (`cognitiveComplexity`) freezes the count and fails any increase.
|
|
22
30
|
* `maxFormFields`: a form with at most this many fields is allowed (default 1 — a search box, a newsletter).
|
|
23
31
|
* @returns {import("eslint").Linter.Config[]}
|
|
24
32
|
*/
|
|
@@ -27,6 +35,7 @@ export function krizakaStack({
|
|
|
27
35
|
ignores = [],
|
|
28
36
|
severity = "warn",
|
|
29
37
|
effects = "error",
|
|
38
|
+
complexity = "error",
|
|
30
39
|
maxFormFields = 1,
|
|
31
40
|
} = {}) {
|
|
32
41
|
return [
|
|
@@ -34,7 +43,7 @@ export function krizakaStack({
|
|
|
34
43
|
name: "krizaka/stack",
|
|
35
44
|
files,
|
|
36
45
|
...(ignores.length > 0 ? { ignores } : {}),
|
|
37
|
-
plugins: { krizaka: krizakaPlugin },
|
|
46
|
+
plugins: { krizaka: krizakaPlugin, sonarjs },
|
|
38
47
|
rules: {
|
|
39
48
|
"krizaka/no-fetch-in-client-effect": severity,
|
|
40
49
|
// useEffect is for the outside world: data is useQuery/useMutation, derived state is computed in render.
|
|
@@ -45,6 +54,8 @@ export function krizakaStack({
|
|
|
45
54
|
"krizaka/no-family-switcher": "error",
|
|
46
55
|
// A selection of a view (tabs, segmented, toggles, chip groups) lives in the URL, not in local state.
|
|
47
56
|
"krizaka/route-state-selection": severity,
|
|
57
|
+
// Sonar S3776 at lint time (krizaka/.github#319): split the function, never raise the threshold.
|
|
58
|
+
"sonarjs/cognitive-complexity": [complexity, COGNITIVE_COMPLEXITY_MAX],
|
|
48
59
|
},
|
|
49
60
|
},
|
|
50
61
|
];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@krizaka/config",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
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",
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"./covered-domains": "./covered-domains.cjs",
|
|
37
37
|
"./covered-domains.json": "./covered-domains.json",
|
|
38
38
|
"./known-violations": "./ratchet/known-violations.js",
|
|
39
|
+
"./server-client-boundary": "./ratchet/server-client-boundary.js",
|
|
39
40
|
"./patterns": "./patterns.js",
|
|
40
41
|
"./ratchet": "./ratchet/ratchet.js",
|
|
41
42
|
"./prettier": "./prettier.config.js",
|
|
@@ -48,7 +49,8 @@
|
|
|
48
49
|
},
|
|
49
50
|
"bin": {
|
|
50
51
|
"krizaka-ratchet": "./bin/krizaka-ratchet.mjs",
|
|
51
|
-
"krizaka-known-violations": "./bin/krizaka-known-violations.mjs"
|
|
52
|
+
"krizaka-known-violations": "./bin/krizaka-known-violations.mjs",
|
|
53
|
+
"krizaka-server-client-boundary": "./bin/krizaka-server-client-boundary.mjs"
|
|
52
54
|
},
|
|
53
55
|
"files": [
|
|
54
56
|
"bin",
|
|
@@ -70,6 +72,7 @@
|
|
|
70
72
|
"dependency-cruiser": "^18.6.0",
|
|
71
73
|
"eslint-plugin-react-hooks": "^7.1.1",
|
|
72
74
|
"eslint-plugin-simple-import-sort": "^14.0.0",
|
|
75
|
+
"eslint-plugin-sonarjs": "^4.2.2",
|
|
73
76
|
"globals": "^17.13.0",
|
|
74
77
|
"tinyglobby": "^0.2.17",
|
|
75
78
|
"typescript-eslint": "^8.71.1"
|
package/ratchet/ratchet.js
CHANGED
|
@@ -2,9 +2,12 @@
|
|
|
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 { isAbsolute, relative, resolve } from "node:path";
|
|
5
|
+
import { extname, isAbsolute, relative, resolve } from "node:path";
|
|
6
6
|
|
|
7
|
+
import { Linter } from "eslint";
|
|
8
|
+
import sonarjs from "eslint-plugin-sonarjs";
|
|
7
9
|
import { glob } from "tinyglobby";
|
|
10
|
+
import tseslint from "typescript-eslint";
|
|
8
11
|
|
|
9
12
|
import {
|
|
10
13
|
ARBITRARY_VAR_SOURCE,
|
|
@@ -34,7 +37,7 @@ export const DEFAULT_STACK_GLOBS = Object.freeze(
|
|
|
34
37
|
const IGNORE = ["**/node_modules/**", "**/dist/**", "**/.next/**", "**/storybook-static/**", "**/coverage/**"];
|
|
35
38
|
|
|
36
39
|
/** @typedef {"palette" | "light" | "arbitraryVar" | "classNameTemplate"} UiCounterName */
|
|
37
|
-
/** @typedef {"fetchInClientEffect" | "asyncInEffect" | "derivedStateInEffect" | "directTanstackImport" | "localTableOrForm" | "retiredImport" | "routeStateSelection"} StackCounterName */
|
|
40
|
+
/** @typedef {"fetchInClientEffect" | "asyncInEffect" | "derivedStateInEffect" | "directTanstackImport" | "localTableOrForm" | "retiredImport" | "routeStateSelection" | "cognitiveComplexity"} StackCounterName */
|
|
38
41
|
/** @typedef {UiCounterName | StackCounterName} CounterName */
|
|
39
42
|
/** @typedef {Record<CounterName, number>} Counters */
|
|
40
43
|
/**
|
|
@@ -58,9 +61,13 @@ export const STACK_COUNTERS = /** @type {const} */ ([
|
|
|
58
61
|
"localTableOrForm",
|
|
59
62
|
"retiredImport",
|
|
60
63
|
"routeStateSelection",
|
|
64
|
+
"cognitiveComplexity",
|
|
61
65
|
]);
|
|
62
66
|
/** Every counter, in report order. */
|
|
63
67
|
export const COUNTERS = /** @type {const} */ ([...UI_COUNTERS, ...STACK_COUNTERS]);
|
|
68
|
+
/** Sonar S3776's threshold (same value as eslint/stack.js COGNITIVE_COMPLEXITY_MAX). Never raised. */
|
|
69
|
+
const COMPLEXITY_MAX = 15;
|
|
70
|
+
const TEST_FILE = /(\.(test|spec)\.[cm]?[jt]sx?$)|(\/__tests__\/)/;
|
|
64
71
|
const zero = () => /** @type {Counters} */ (Object.fromEntries(COUNTERS.map((name) => [name, 0])));
|
|
65
72
|
|
|
66
73
|
/**
|
|
@@ -95,7 +102,7 @@ export function countSource(source, allow = []) {
|
|
|
95
102
|
* `@tanstack/*`, hand-written tables and forms of more than `maxFormFields` fields, imports of the retired dependencies.
|
|
96
103
|
* @param {string} source
|
|
97
104
|
* @param {{ retire?: readonly string[], maxFormFields?: number }} [options]
|
|
98
|
-
* @returns {Record<StackCounterName, number>}
|
|
105
|
+
* @returns {Record<Exclude<StackCounterName, "cognitiveComplexity">, number>} (cognitiveComplexity: countCognitiveComplexity, it needs the file name)
|
|
99
106
|
*/
|
|
100
107
|
export function countStackSource(source, { retire = [], maxFormFields = 1 } = {}) {
|
|
101
108
|
let fetchInClientEffect = 0;
|
|
@@ -133,6 +140,32 @@ export function countStackSource(source, { retire = [], maxFormFields = 1 } = {}
|
|
|
133
140
|
};
|
|
134
141
|
}
|
|
135
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Counts the functions above Sonar's cognitive-complexity threshold (S3776) in one source text, with the same
|
|
145
|
+
* `sonarjs/cognitive-complexity` rule the ESLint preset uses. A file that does not parse counts for 0 (ESLint reports it).
|
|
146
|
+
* @param {string} source
|
|
147
|
+
* @param {string} [filename]
|
|
148
|
+
* @returns {number}
|
|
149
|
+
*/
|
|
150
|
+
export function countCognitiveComplexity(source, filename = "file.tsx") {
|
|
151
|
+
const messages = complexityLinter().verify(source, COMPLEXITY_CONFIG, { filename: `file${extname(filename)}` });
|
|
152
|
+
return messages.filter((/** @type {{ ruleId: string | null }} */ m) => m.ruleId === "sonarjs/cognitive-complexity").length;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** One Linter for the whole run: building it (and the parser) per file made the CLI 10x slower in CI. */
|
|
156
|
+
/** @type {Linter | undefined} */
|
|
157
|
+
let sharedLinter;
|
|
158
|
+
const complexityLinter = () => (sharedLinter ??= new Linter());
|
|
159
|
+
/** @type {any} */
|
|
160
|
+
const COMPLEXITY_CONFIG = [
|
|
161
|
+
{
|
|
162
|
+
files: ["**/*.{js,jsx,mjs,cjs,ts,tsx,mts,cts}"],
|
|
163
|
+
languageOptions: { parser: /** @type {any} */ (tseslint.parser) },
|
|
164
|
+
plugins: { sonarjs },
|
|
165
|
+
rules: { "sonarjs/cognitive-complexity": ["error", COMPLEXITY_MAX] },
|
|
166
|
+
},
|
|
167
|
+
];
|
|
168
|
+
|
|
136
169
|
/**
|
|
137
170
|
* Counts the debts under `root`: the UI counters in the files of `globs`, the stack counters in those of `stackGlobs`.
|
|
138
171
|
* @param {{ root: string, globs?: readonly string[], stackGlobs?: readonly string[], allow?: string[],
|
|
@@ -155,8 +188,10 @@ export async function countRepository({
|
|
|
155
188
|
for (const name of UI_COUNTERS) counters[name] += found[name];
|
|
156
189
|
}
|
|
157
190
|
for (const file of stackFiles) {
|
|
158
|
-
const found = countStackSource(readFileSync(file, "utf8"), { retire, maxFormFields });
|
|
159
|
-
for (const name of STACK_COUNTERS) counters[name] += found[name];
|
|
191
|
+
const found = /** @type {Partial<Counters>} */ (countStackSource(readFileSync(file, "utf8"), { retire, maxFormFields }));
|
|
192
|
+
for (const name of STACK_COUNTERS) counters[name] += found[name] ?? 0;
|
|
193
|
+
// Sonar scans the product code, not its tests
|
|
194
|
+
if (!TEST_FILE.test(file)) counters.cognitiveComplexity += countCognitiveComplexity(readFileSync(file, "utf8"), file);
|
|
160
195
|
}
|
|
161
196
|
return { counters, files: files.length, stackFiles: stackFiles.length };
|
|
162
197
|
}
|
|
@@ -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
|
+
};
|