@krizaka/config 0.0.0-stage → 0.2.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/.editorconfig +17 -0
- package/LICENSE +202 -0
- package/README.md +167 -2
- package/bin/krizaka-ratchet.mjs +94 -0
- package/dependency-cruiser.cjs +31 -0
- package/eslint/index.js +70 -0
- package/eslint/krizaka-ui.js +61 -0
- package/eslint/next.js +18 -0
- package/eslint/no-locale-ternary.js +41 -0
- package/eslint/plugin.js +16 -0
- package/eslint/rules.js +129 -0
- package/eslint/stack.js +40 -0
- package/package.json +117 -3
- package/patterns.js +117 -0
- package/prettier.config.js +15 -0
- package/ratchet/ratchet.js +259 -0
- package/tsconfig/base.json +18 -0
- package/tsconfig/next.json +14 -0
- package/tsconfig/react-library.json +9 -0
- package/tsconfig/react-native.json +12 -0
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// The ratchet: counts the UI and stack debts of a repository and refuses any increase against lint-ratchet.json.
|
|
2
|
+
// Same patterns as the ESLint rules (../patterns.js), applied to the source text: the counter sees every
|
|
3
|
+
// occurrence (a class in a `cn()` argument or a constant too), so it can only be stricter than the rule.
|
|
4
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { relative, resolve } from "node:path";
|
|
6
|
+
|
|
7
|
+
import { glob } from "tinyglobby";
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
ARBITRARY_VAR_SOURCE,
|
|
11
|
+
balancedArguments,
|
|
12
|
+
CLASSNAME_TEMPLATE_SOURCE,
|
|
13
|
+
countFormFields,
|
|
14
|
+
EFFECT_CALL_SOURCE,
|
|
15
|
+
FETCH_CALL_SOURCE,
|
|
16
|
+
importSource,
|
|
17
|
+
LIGHT_VARIANT_SOURCE,
|
|
18
|
+
paletteSource,
|
|
19
|
+
TABLE_OR_FORM_SOURCE,
|
|
20
|
+
TANSTACK_SCOPE,
|
|
21
|
+
USE_CLIENT_SOURCE,
|
|
22
|
+
} from "../patterns.js";
|
|
23
|
+
|
|
24
|
+
export const RATCHET_FILE = "lint-ratchet.json";
|
|
25
|
+
export const DEFAULT_GLOBS = Object.freeze(["app/**/*.tsx", "components/**/*.tsx", "src/**/*.tsx"]);
|
|
26
|
+
/** The stack counters read every source file: data hooks and fetchers live in .ts too. */
|
|
27
|
+
export const DEFAULT_STACK_GLOBS = Object.freeze(
|
|
28
|
+
["app", "components", "src", "lib", "hooks", "features"].map((dir) => `${dir}/**/*.{js,jsx,mjs,ts,tsx,mts}`),
|
|
29
|
+
);
|
|
30
|
+
const IGNORE = ["**/node_modules/**", "**/dist/**", "**/.next/**", "**/storybook-static/**", "**/coverage/**"];
|
|
31
|
+
|
|
32
|
+
/** @typedef {"palette" | "light" | "arbitraryVar" | "classNameTemplate"} UiCounterName */
|
|
33
|
+
/** @typedef {"fetchInClientEffect" | "directTanstackImport" | "localTableOrForm" | "retiredImport"} StackCounterName */
|
|
34
|
+
/** @typedef {UiCounterName | StackCounterName} CounterName */
|
|
35
|
+
/** @typedef {Record<CounterName, number>} Counters */
|
|
36
|
+
/**
|
|
37
|
+
* @typedef {object} RatchetFile
|
|
38
|
+
* @property {string[]} [globs] files of the UI counters
|
|
39
|
+
* @property {string[]} [stackGlobs] files of the stack counters
|
|
40
|
+
* @property {string[]} [allow] palette families tolerated
|
|
41
|
+
* @property {string[]} [retire] dependencies on their way out: their imports are counted by `retiredImport`
|
|
42
|
+
* @property {number} [maxFormFields] a form with at most this many fields is not counted (default 1)
|
|
43
|
+
* @property {Partial<Counters>} counters
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/** The UI counters (the four rules of krizakaUi), in report order. */
|
|
47
|
+
export const UI_COUNTERS = /** @type {const} */ (["palette", "light", "arbitraryVar", "classNameTemplate"]);
|
|
48
|
+
/** The stack counters (the three rules of krizakaStack, and the dependencies to retire), in report order. */
|
|
49
|
+
export const STACK_COUNTERS = /** @type {const} */ ([
|
|
50
|
+
"fetchInClientEffect",
|
|
51
|
+
"directTanstackImport",
|
|
52
|
+
"localTableOrForm",
|
|
53
|
+
"retiredImport",
|
|
54
|
+
]);
|
|
55
|
+
/** Every counter, in report order. */
|
|
56
|
+
export const COUNTERS = /** @type {const} */ ([...UI_COUNTERS, ...STACK_COUNTERS]);
|
|
57
|
+
const zero = () => /** @type {Counters} */ (Object.fromEntries(COUNTERS.map((name) => [name, 0])));
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {string[]} [allow] palette families tolerated (same option as krizakaUi)
|
|
61
|
+
* @returns {Record<UiCounterName, RegExp>}
|
|
62
|
+
*/
|
|
63
|
+
export function counterPatterns(allow = []) {
|
|
64
|
+
return {
|
|
65
|
+
palette: new RegExp(paletteSource(allow), "g"),
|
|
66
|
+
light: new RegExp(LIGHT_VARIANT_SOURCE, "g"),
|
|
67
|
+
arbitraryVar: new RegExp(ARBITRARY_VAR_SOURCE, "g"),
|
|
68
|
+
classNameTemplate: new RegExp(CLASSNAME_TEMPLATE_SOURCE, "g"),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Counts the four UI debts in one source text.
|
|
74
|
+
* @param {string} source
|
|
75
|
+
* @param {string[]} [allow]
|
|
76
|
+
* @returns {Record<UiCounterName, number>}
|
|
77
|
+
*/
|
|
78
|
+
export function countSource(source, allow = []) {
|
|
79
|
+
const patterns = counterPatterns(allow);
|
|
80
|
+
/** @type {Record<UiCounterName, number>} */
|
|
81
|
+
const counters = { palette: 0, light: 0, arbitraryVar: 0, classNameTemplate: 0 };
|
|
82
|
+
for (const name of UI_COUNTERS) counters[name] = source.match(patterns[name])?.length ?? 0;
|
|
83
|
+
return counters;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Counts the stack debts in one source text: fetch calls inside the effects of a `"use client"` file, imports of
|
|
88
|
+
* `@tanstack/*`, hand-written tables and forms of more than `maxFormFields` fields, imports of the retired dependencies.
|
|
89
|
+
* @param {string} source
|
|
90
|
+
* @param {{ retire?: readonly string[], maxFormFields?: number }} [options]
|
|
91
|
+
* @returns {Record<StackCounterName, number>}
|
|
92
|
+
*/
|
|
93
|
+
export function countStackSource(source, { retire = [], maxFormFields = 1 } = {}) {
|
|
94
|
+
let fetchInClientEffect = 0;
|
|
95
|
+
if (new RegExp(USE_CLIENT_SOURCE).test(source)) {
|
|
96
|
+
for (const effect of source.matchAll(new RegExp(EFFECT_CALL_SOURCE, "g"))) {
|
|
97
|
+
const body = balancedArguments(source, (effect.index ?? 0) + effect[0].length - 1);
|
|
98
|
+
fetchInClientEffect += body.match(new RegExp(FETCH_CALL_SOURCE, "g"))?.length ?? 0;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
let localTableOrForm = 0;
|
|
102
|
+
for (const element of source.matchAll(new RegExp(TABLE_OR_FORM_SOURCE, "g"))) {
|
|
103
|
+
if (element[1] === "table") localTableOrForm += 1;
|
|
104
|
+
else {
|
|
105
|
+
const end = source.indexOf("</form>", element.index);
|
|
106
|
+
if (end >= 0 && countFormFields(source.slice(element.index, end)) > maxFormFields) localTableOrForm += 1;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
fetchInClientEffect,
|
|
111
|
+
directTanstackImport: source.match(new RegExp(importSource([TANSTACK_SCOPE]), "g"))?.length ?? 0,
|
|
112
|
+
localTableOrForm,
|
|
113
|
+
retiredImport: source.match(new RegExp(importSource(retire), "g"))?.length ?? 0,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Counts the debts under `root`: the UI counters in the files of `globs`, the stack counters in those of `stackGlobs`.
|
|
119
|
+
* @param {{ root: string, globs?: readonly string[], stackGlobs?: readonly string[], allow?: string[],
|
|
120
|
+
* retire?: readonly string[], maxFormFields?: number }} options
|
|
121
|
+
* @returns {Promise<{ counters: Counters, files: number, stackFiles: number }>}
|
|
122
|
+
*/
|
|
123
|
+
export async function countRepository({
|
|
124
|
+
root,
|
|
125
|
+
globs = DEFAULT_GLOBS,
|
|
126
|
+
stackGlobs = DEFAULT_STACK_GLOBS,
|
|
127
|
+
allow = [],
|
|
128
|
+
retire = [],
|
|
129
|
+
maxFormFields = 1,
|
|
130
|
+
}) {
|
|
131
|
+
const files = await glob([...globs], { cwd: root, ignore: IGNORE, absolute: true });
|
|
132
|
+
const stackFiles = await glob([...stackGlobs], { cwd: root, ignore: IGNORE, absolute: true });
|
|
133
|
+
const counters = zero();
|
|
134
|
+
for (const file of files) {
|
|
135
|
+
const found = countSource(readFileSync(file, "utf8"), allow);
|
|
136
|
+
for (const name of UI_COUNTERS) counters[name] += found[name];
|
|
137
|
+
}
|
|
138
|
+
for (const file of stackFiles) {
|
|
139
|
+
const found = countStackSource(readFileSync(file, "utf8"), { retire, maxFormFields });
|
|
140
|
+
for (const name of STACK_COUNTERS) counters[name] += found[name];
|
|
141
|
+
}
|
|
142
|
+
return { counters, files: files.length, stackFiles: stackFiles.length };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* @param {string} path
|
|
147
|
+
* @returns {RatchetFile}
|
|
148
|
+
*/
|
|
149
|
+
function readRatchet(path) {
|
|
150
|
+
const data = JSON.parse(readFileSync(path, "utf8"));
|
|
151
|
+
for (const name of COUNTERS) {
|
|
152
|
+
const value = data?.counters?.[name];
|
|
153
|
+
// a stack counter may be missing from a file written before it existed: --update records it
|
|
154
|
+
if (value === undefined && STACK_COUNTERS.includes(/** @type {StackCounterName} */ (name))) continue;
|
|
155
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
156
|
+
throw new RatchetError(`${path}: counters.${name} must be a non-negative integer.`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return data;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* @param {string} path
|
|
164
|
+
* @param {RatchetFile} data
|
|
165
|
+
*/
|
|
166
|
+
function writeRatchet(path, data) {
|
|
167
|
+
writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** A usage or file error (exit code 2), as opposed to a counter going up (exit code 1). */
|
|
171
|
+
export class RatchetError extends Error {}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* @typedef {object} RatchetResult
|
|
175
|
+
* @property {boolean} ok no counter above its recorded value
|
|
176
|
+
* @property {"check" | "init" | "update"} mode
|
|
177
|
+
* @property {string} file the ratchet file, relative to root
|
|
178
|
+
* @property {number} files source files scanned by the UI counters
|
|
179
|
+
* @property {number} stackFiles source files scanned by the stack counters
|
|
180
|
+
* @property {string[]} globs
|
|
181
|
+
* @property {string[]} stackGlobs
|
|
182
|
+
* @property {Record<CounterName, { recorded: number, current: number, delta: number }>} counters
|
|
183
|
+
* @property {CounterName[]} increased
|
|
184
|
+
* @property {CounterName[]} decreased
|
|
185
|
+
* @property {CounterName[]} added counters missing from the file (recorded at their current value by --update)
|
|
186
|
+
* @property {boolean} written the ratchet file was created, lowered or completed
|
|
187
|
+
*/
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Runs the ratchet.
|
|
191
|
+
* - `check` (default): fails when a counter is above its recorded value.
|
|
192
|
+
* - `init`: creates the file with the current counters (refuses to overwrite one).
|
|
193
|
+
* - `update`: lowers each recorded value to the current one, never raises it (still fails on an increase), and records
|
|
194
|
+
* the counters the file does not have yet at their current value.
|
|
195
|
+
* @param {{ root: string, mode?: "check" | "init" | "update", globs?: string[], file?: string }} options
|
|
196
|
+
* @returns {Promise<RatchetResult>}
|
|
197
|
+
*/
|
|
198
|
+
export async function runRatchet({ root, mode = "check", globs, file = RATCHET_FILE }) {
|
|
199
|
+
const path = resolve(root, file);
|
|
200
|
+
const exists = existsSync(path);
|
|
201
|
+
if (mode === "init" && exists) throw new RatchetError(`${file} already exists: use --update to lower it.`);
|
|
202
|
+
if (mode !== "init" && !exists) throw new RatchetError(`${file} not found in ${root}: run krizaka-ratchet --init first.`);
|
|
203
|
+
|
|
204
|
+
const recordedFile = exists ? readRatchet(path) : undefined;
|
|
205
|
+
const useGlobs = globs && globs.length > 0 ? globs : (recordedFile?.globs ?? [...DEFAULT_GLOBS]);
|
|
206
|
+
const stackGlobs = recordedFile?.stackGlobs ?? [...DEFAULT_STACK_GLOBS];
|
|
207
|
+
const allow = recordedFile?.allow ?? [];
|
|
208
|
+
const retire = recordedFile?.retire ?? [];
|
|
209
|
+
const maxFormFields = recordedFile?.maxFormFields ?? 1;
|
|
210
|
+
const { counters: current, files, stackFiles } = await countRepository({
|
|
211
|
+
root,
|
|
212
|
+
globs: useGlobs,
|
|
213
|
+
stackGlobs,
|
|
214
|
+
allow,
|
|
215
|
+
retire,
|
|
216
|
+
maxFormFields,
|
|
217
|
+
});
|
|
218
|
+
/** @type {CounterName[]} */ const added = recordedFile ? COUNTERS.filter((n) => recordedFile.counters[n] === undefined) : [];
|
|
219
|
+
const recorded = /** @type {Counters} */ ({ ...current, ...recordedFile?.counters });
|
|
220
|
+
|
|
221
|
+
/** @type {RatchetResult["counters"]} */
|
|
222
|
+
const report = /** @type {any} */ ({});
|
|
223
|
+
/** @type {CounterName[]} */ const increased = [];
|
|
224
|
+
/** @type {CounterName[]} */ const decreased = [];
|
|
225
|
+
for (const name of COUNTERS) {
|
|
226
|
+
const delta = current[name] - recorded[name];
|
|
227
|
+
report[name] = { recorded: recorded[name], current: current[name], delta };
|
|
228
|
+
if (delta > 0) increased.push(name);
|
|
229
|
+
if (delta < 0) decreased.push(name);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
let written = false;
|
|
233
|
+
if (mode === "init") {
|
|
234
|
+
writeRatchet(path, { globs: useGlobs, stackGlobs, ...(allow.length > 0 ? { allow } : {}), counters: current });
|
|
235
|
+
written = true;
|
|
236
|
+
} else if (mode === "update" && (decreased.length > 0 || added.length > 0) && recordedFile) {
|
|
237
|
+
const lowered = zero();
|
|
238
|
+
for (const name of COUNTERS) lowered[name] = Math.min(recorded[name], current[name]);
|
|
239
|
+
writeRatchet(path, { ...recordedFile, stackGlobs: recordedFile.stackGlobs ?? stackGlobs, counters: lowered });
|
|
240
|
+
for (const name of decreased) report[name].recorded = lowered[name];
|
|
241
|
+
written = true;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const shown = relative(root, path);
|
|
245
|
+
return {
|
|
246
|
+
ok: increased.length === 0,
|
|
247
|
+
mode,
|
|
248
|
+
file: shown.startsWith("..") ? path : shown,
|
|
249
|
+
files,
|
|
250
|
+
stackFiles,
|
|
251
|
+
globs: useGlobs,
|
|
252
|
+
stackGlobs,
|
|
253
|
+
counters: report,
|
|
254
|
+
increased,
|
|
255
|
+
decreased,
|
|
256
|
+
added,
|
|
257
|
+
written,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json.schemastore.org/tsconfig",
|
|
3
|
+
"display": "Krizaka — base",
|
|
4
|
+
"compilerOptions": {
|
|
5
|
+
"target": "ES2022",
|
|
6
|
+
"lib": ["ES2022"],
|
|
7
|
+
"module": "ESNext",
|
|
8
|
+
"moduleResolution": "Bundler",
|
|
9
|
+
"moduleDetection": "force",
|
|
10
|
+
"resolveJsonModule": true,
|
|
11
|
+
"strict": true,
|
|
12
|
+
"skipLibCheck": true,
|
|
13
|
+
"isolatedModules": true,
|
|
14
|
+
"esModuleInterop": true,
|
|
15
|
+
"forceConsistentCasingInFileNames": true,
|
|
16
|
+
"noFallthroughCasesInSwitch": true
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json.schemastore.org/tsconfig",
|
|
3
|
+
"display": "Krizaka — Next.js app",
|
|
4
|
+
"extends": "./base.json",
|
|
5
|
+
"compilerOptions": {
|
|
6
|
+
"target": "ES2022",
|
|
7
|
+
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
|
8
|
+
"jsx": "react-jsx",
|
|
9
|
+
"allowJs": true,
|
|
10
|
+
"noEmit": true,
|
|
11
|
+
"incremental": true,
|
|
12
|
+
"plugins": [{ "name": "next" }]
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json.schemastore.org/tsconfig",
|
|
3
|
+
"display": "Krizaka — React Native (Expo)",
|
|
4
|
+
"extends": "./base.json",
|
|
5
|
+
"compilerOptions": {
|
|
6
|
+
"lib": ["DOM", "ESNext"],
|
|
7
|
+
"jsx": "react-native",
|
|
8
|
+
"allowJs": true,
|
|
9
|
+
"noEmit": true,
|
|
10
|
+
"customConditions": ["react-native"]
|
|
11
|
+
}
|
|
12
|
+
}
|