@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.
@@ -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,9 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/tsconfig",
3
+ "display": "Krizaka — React library",
4
+ "extends": "./base.json",
5
+ "compilerOptions": {
6
+ "lib": ["DOM", "DOM.Iterable", "ES2022"],
7
+ "jsx": "react-jsx"
8
+ }
9
+ }
@@ -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
+ }