@krizaka/config 0.1.0 → 0.3.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 CHANGED
@@ -1,8 +1,8 @@
1
1
  # @krizaka/config
2
2
 
3
3
  **The shared configuration of every Krizaka JavaScript repository.** ESLint flat configs (base, Next.js, the four UI
4
- rules, no locale ternary), tsconfig bases, Prettier, EditorConfig — and `krizaka-ratchet`, the counter that keeps the
5
- UI debt from coming back.
4
+ rules, the three stack rules, no locale ternary), the dependency-cruiser boundary, tsconfig bases, Prettier,
5
+ EditorConfig — and `krizaka-ratchet`, the counter that keeps the UI and stack debts from coming back.
6
6
 
7
7
  [![npm](https://img.shields.io/npm/v/@krizaka/config?color=3b82f6&label=npm)](https://www.npmjs.com/package/@krizaka/config)
8
8
  [![License](https://img.shields.io/badge/license-Apache--2.0-blue)](../../LICENSE)
@@ -18,8 +18,14 @@ npm install -D @krizaka/config eslint typescript # + eslint-config-next for
18
18
  import { krizakaNext } from "@krizaka/config/eslint/next";
19
19
  import { krizakaUi } from "@krizaka/config/eslint/krizaka-ui";
20
20
  import { noLocaleTernary } from "@krizaka/config/eslint/no-locale-ternary";
21
+ import { krizakaStack } from "@krizaka/config/eslint/stack";
21
22
 
22
- export default [...krizakaNext, ...krizakaUi(), ...noLocaleTernary()];
23
+ export default [...krizakaNext, ...krizakaUi(), ...krizakaStack(), ...noLocaleTernary()];
24
+ ```
25
+
26
+ ```js
27
+ // .dependency-cruiser.cjs — the TanStack boundary, plus the app's own rules
28
+ module.exports = { extends: "@krizaka/config/dependency-cruiser", forbidden: [] };
23
29
  ```
24
30
 
25
31
  ```jsonc
@@ -42,6 +48,9 @@ the package.
42
48
  | `@krizaka/config/eslint/next` | `krizakaNext`: `eslint-config-next` (core web vitals + TypeScript, which already carry typescript-eslint, React hooks and import) + the same basics. |
43
49
  | `@krizaka/config/eslint/krizaka-ui` | `krizakaUi(options?)`: the four UI rules below. Also `krizakaUiRules` and `krizakaUiRestrictedSyntax(options?)`. |
44
50
  | `@krizaka/config/eslint/no-locale-ternary` | `noLocaleTernary(options?)`: `krizaka/no-locale-ternary` — no `locale === "fr" ? … : …` outside routing code. |
51
+ | `@krizaka/config/eslint/stack` | `krizakaStack(options?)`: the three stack rules below (warnings). Also `STACK_RULES`. |
52
+ | `@krizaka/config/eslint/plugin` | `krizakaPlugin`: the `krizaka` plugin carrying every rule, for a hand-written config. |
53
+ | `@krizaka/config/dependency-cruiser` | The shared dependency-cruiser config: `krizaka-no-direct-tanstack` (no `@tanstack/*` from an app). |
45
54
  | `@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"]`). |
46
55
  | `@krizaka/config/prettier` · `.editorconfig` | The formatting. |
47
56
  | `krizaka-ratchet` (bin) | The debt counter, see below. |
@@ -71,19 +80,63 @@ Flat config replaces a rule's options instead of concatenating them: a repositor
71
80
  `no-restricted-syntax` merges both lists with `krizakaUiRestrictedSyntax()`. `no-locale-ternary` is a plugin rule for
72
81
  that reason — it composes with anything.
73
82
 
83
+ ### The three stack rules
84
+
85
+ From [ADR-001](../../docs/adr/ADR-001-tanstack-under-krizaka-facades.md) — the TanStack engines run headless, behind
86
+ `@krizaka/*` facades; a product never imports them. Warnings: `krizaka-ratchet` counts the same patterns, so an app
87
+ adopts them before it is clean and brings each count to 0 by the end of its adoption.
88
+
89
+ 1. **`krizaka/no-fetch-in-client-effect`** — a `fetch` / `krizakaFetch` inside a `useEffect` (or `useLayoutEffect`)
90
+ of a `"use client"` file: render the first screen in a Server Component, read the rest with `@krizaka/query`.
91
+ 2. **`krizaka/no-direct-tanstack-import`** — an import of `@tanstack/*` (static, dynamic, `require`, re-export): use
92
+ the facade (`@krizaka/query`, `@krizaka/ui/data-table`, `@krizaka/ui/form`, `@krizaka/ui/virtual`, `@krizaka/pacer`).
93
+ 3. **`krizaka/no-local-table-or-form`** — a `<table>`, or a `<form>` of more than one field (native fields and the field
94
+ primitives of `@krizaka/ui`; a hidden or submit `<input>` is not a field): use `DataTable` and `createKrizakaForm`.
95
+ A search box or a one-field newsletter stays a plain form.
96
+
97
+ ```js
98
+ krizakaStack({
99
+ files: ["**/*.{js,jsx,mjs,cjs,ts,tsx,mts,cts}"], // default
100
+ ignores: ["packages/**"], // e.g. the facades of a monorepo
101
+ severity: "warn", // default; "error" once the app's counts are at 0
102
+ maxFormFields: 1, // default
103
+ });
104
+ ```
105
+
106
+ The dependency-cruiser config forbids `@tanstack/*` from an app's own files, resolved or not (a package installed
107
+ only as a dependency of a facade is caught too), and allows it from `node_modules/@krizaka/*`.
108
+
74
109
  `noLocaleTernary({ files, routing })`: `routing` lists the files allowed to compare locales (default: `proxy.ts`,
75
110
  `middleware.ts`, `i18n.ts`, `seo.ts`, `app/[locale]/layout.tsx`, `I18nProvider.tsx`).
76
111
 
112
+ ### Environment variable names (`krizaka/env-name`)
113
+
114
+ Environment variables are named by bounded context (krizaka/.github#46): `<CONTEXT>_<THING>` with CONTEXT one of
115
+ `APP_` `DATABASE_` `AUTH_` `MEDIA_` `MAIL_` `PUSH_` `PAYMENTS_` `REALTIME_` `ABUSE_` `OBSERVABILITY_` `AI_` `OPERATOR_`.
116
+ The rule — on in `krizakaBase` and `krizakaNext` — refuses a name read through `process.env.X`, `process.env["X"]` or
117
+ `import.meta.env.X` that starts (after `NEXT_PUBLIC_`, `EXPO_PUBLIC_` or `VITE_`) with:
118
+
119
+ - a **product**: the organisation's name, the repository's own (the first word of its package.json name,
120
+ `acme-monorepo` → `ACME_`), or one you name — a deployment already says which product it is;
121
+ - a **vendor** (`BUNNY_`, `MAILGUN_`, `RESEND_`, `STRIPE_`, `GOOGLE_`, `EXPO_`, `REDIS_`, `VERCEL_`…): the vendor is a
122
+ value (`MEDIA_STORAGE_PROVIDER=bunny`, `MAIL_PROVIDER=mailgun`) and its settings sit under the context
123
+ (`MEDIA_STORAGE_API_KEY`, `MAIL_API_KEY`).
124
+
125
+ The platforms' own names (`GITHUB_TOKEN`, `VERCEL_URL`, `EAS_BUILD`, …) pass. Tune it with
126
+ `envNames({ products, vendors, allow })` from `@krizaka/config/eslint/env-names`; `envNamePrefixProblem(name)` gives
127
+ the same verdict to a script (a `.env.example` or deployment spec check).
128
+
77
129
  ## The ratchet
78
130
 
79
- A repository with debt cannot turn the UI rules on in one pull request. The ratchet lets it adopt them now: it counts
80
- the four debts, records the counts in `lint-ratchet.json` at the root, and **fails when a count goes up**. Every
81
- migration pull request lowers them; when a count reaches zero, the matching rule becomes a strict `error`.
131
+ A repository with debt cannot turn the rules on in one pull request. The ratchet lets it adopt them now: it counts
132
+ the four UI debts and the four stack debts, records the counts in `lint-ratchet.json` at the root, and **fails when a
133
+ count goes up**. Every migration pull request lowers them; when a count reaches zero, the matching rule becomes a
134
+ strict `error`.
82
135
 
83
136
  ```bash
84
137
  npx krizaka-ratchet --init # once: creates lint-ratchet.json with today's counts — commit it
85
138
  npx krizaka-ratchet # locally: compares (exit 1 when a count went up)
86
- npx krizaka-ratchet --update # after a migration: lowers the recorded counts (never raises them) — commit it
139
+ npx krizaka-ratchet --update # after a migration: lowers the recorded counts (never raises them), records new counters — commit it
87
140
  npx krizaka-ratchet --json # in CI: the same check, machine-readable
88
141
  ```
89
142
 
@@ -93,9 +146,16 @@ npx krizaka-ratchet --json # in CI: the same check, machine-readable
93
146
  | `light` | `\blight:` |
94
147
  | `arbitraryVar` | `\[var\(--` |
95
148
  | `classNameTemplate` | `` className=\{\s*` `` |
96
-
97
- It scans `app/**/*.tsx`, `components/**/*.tsx` and `src/**/*.tsx` by default (`node_modules`, `dist`, `.next` never),
98
- or the globs given on the command line; `--init` stores them in the file so that every later run counts the same set.
149
+ | `fetchInClientEffect` | in a file opening with `"use client"`: `fetch(` / `krizakaFetch(` between the parentheses of a `useEffect(` / `useLayoutEffect(` |
150
+ | `directTanstackImport` | `from` / `import` / `import(` / `require(` of `"@tanstack/…"` |
151
+ | `localTableOrForm` | `<table`, and `<form` … `</form>` holding more fields than `maxFormFields` (default 1) |
152
+ | `retiredImport` | an import of a dependency listed in `retire` (e.g. `["framer-motion"]`): the way out of a dependency |
153
+
154
+ The UI counters scan `app/**/*.tsx`, `components/**/*.tsx` and `src/**/*.tsx` by default (`node_modules`, `dist`,
155
+ `.next` never), or the globs given on the command line; the stack counters scan every source file (`js`, `jsx`, `mjs`,
156
+ `ts`, `tsx`, `mts`) of `app`, `components`, `src`, `lib`, `hooks` and `features` (`stackGlobs`). `--init` stores both
157
+ in the file so that every later run counts the same set. A file written before the stack counters still checks; the
158
+ next `--update` records them at their current value.
99
159
  The counts apply the regex to the whole source text — a class held in a constant or a `cn()` argument counts too —
100
160
  so the ratchet is never looser than the rule. Exit codes: `0` ok, `1` a count went up, `2` usage or file error.
101
161
 
@@ -103,8 +163,14 @@ so the ratchet is never looser than the rule. Exit codes: `0` ok, `1` a count we
103
163
  // lint-ratchet.json
104
164
  {
105
165
  "globs": ["app/**/*.tsx", "components/**/*.tsx", "src/**/*.tsx"],
166
+ "stackGlobs": ["app/**/*.{js,jsx,mjs,ts,tsx,mts}", "…"],
106
167
  "allow": [], // optional: same families as krizakaUi({ allow })
107
- "counters": { "palette": 2400, "light": 1151, "arbitraryVar": 0, "classNameTemplate": 128 }
168
+ "retire": ["framer-motion"], // optional: dependencies on their way out
169
+ "maxFormFields": 1, // optional: same option as krizakaStack({ maxFormFields })
170
+ "counters": {
171
+ "palette": 2400, "light": 1151, "arbitraryVar": 0, "classNameTemplate": 128,
172
+ "fetchInClientEffect": 41, "directTanstackImport": 3, "localTableOrForm": 38, "retiredImport": 4
173
+ }
108
174
  }
109
175
  ```
110
176
 
@@ -6,12 +6,14 @@ import { COUNTERS, RatchetError, runRatchet } from "../ratchet/ratchet.js";
6
6
 
7
7
  const USAGE = `Usage: krizaka-ratchet [globs…] [--init | --update] [--json] [--root <dir>] [--file <name>]
8
8
 
9
- Counts raw palette colours, light: variants, [var(--…)] utilities and className template strings in the
10
- source files (default: app/**/*.tsx components/**/*.tsx src/**/*.tsx) and compares them with lint-ratchet.json.
9
+ Counts the UI debts — raw palette colours, light: variants, [var(--…)] utilities, className template strings — in the
10
+ source files (default: app/**/*.tsx components/**/*.tsx src/**/*.tsx), and the stack debts — fetch in a client effect,
11
+ direct @tanstack/* imports, hand-written tables and forms, imports of the dependencies listed in "retire" — in every
12
+ source file of app, components, src, lib, hooks and features; compares them with lint-ratchet.json.
11
13
 
12
14
  (no flag) fail (exit 1) when a counter is above its recorded value
13
15
  --init create lint-ratchet.json with the current counters
14
- --update lower the recorded counters to the current values (never raises them)
16
+ --update lower the recorded counters to the current values (never raises them), record the new ones
15
17
  --json machine-readable report on stdout (for CI)
16
18
  --root repository root (default: current directory)
17
19
  --file ratchet file name (default: lint-ratchet.json)`;
@@ -21,6 +23,10 @@ const LABELS = {
21
23
  light: "light: variants",
22
24
  arbitraryVar: "[var(--…)] utilities",
23
25
  classNameTemplate: "className template strings",
26
+ fetchInClientEffect: "fetch in a client effect",
27
+ directTanstackImport: "direct @tanstack/* imports",
28
+ localTableOrForm: "hand-written tables / forms",
29
+ retiredImport: "imports of retired deps",
24
30
  };
25
31
 
26
32
  let args;
@@ -61,7 +67,7 @@ try {
61
67
  if (values.json) {
62
68
  console.log(JSON.stringify(result, null, 2));
63
69
  } else {
64
- console.log(`krizaka-ratchet — ${result.files} files (${result.globs.join(", ")})`);
70
+ console.log(`krizaka-ratchet — ${result.files} files for the UI, ${result.stackFiles} for the stack`);
65
71
  for (const name of COUNTERS) {
66
72
  const { recorded, current, delta } = result.counters[name];
67
73
  const mark = delta > 0 ? "✗" : delta < 0 ? "↓" : "=";
@@ -69,9 +75,14 @@ try {
69
75
  console.log(` ${mark} ${LABELS[name].padEnd(28)} ${String(current).padStart(6)} (recorded ${recorded}, ${sign})`);
70
76
  }
71
77
  if (result.mode === "init") console.log(`Created ${result.file}.`);
72
- else if (result.written) console.log(`Lowered ${result.file}: commit it.`);
78
+ else if (result.written) console.log(`${result.decreased.length > 0 ? "Lowered" : "Completed"} ${result.file}: commit it.`);
73
79
  else if (result.decreased.length > 0) console.log(`Debt went down: run krizaka-ratchet --update and commit ${result.file}.`);
74
- if (!result.ok) console.error(`Debt went up (${result.increased.join(", ")}): use the roles of @krizaka/tailwind and cn().`);
80
+ else if (result.added.length > 0) console.log(`New counters (${result.added.join(", ")}): run krizaka-ratchet --update and commit ${result.file}.`);
81
+ if (!result.ok) {
82
+ console.error(
83
+ `Debt went up (${result.increased.join(", ")}): roles of @krizaka/tailwind and cn() for the UI; @krizaka/query, @krizaka/ui/data-table and @krizaka/ui/form for the stack.`,
84
+ );
85
+ }
75
86
  }
76
87
  process.exit(result.ok ? 0 : 1);
77
88
  } catch (error) {
@@ -0,0 +1,31 @@
1
+ // The shared dependency-cruiser rules of a Krizaka app (the stack ADR, decision S11). An app extends it and adds its own:
2
+ //
3
+ // // .dependency-cruiser.cjs
4
+ // module.exports = { extends: "@krizaka/config/dependency-cruiser", forbidden: [/* the app's rules */] };
5
+ //
6
+ // dependency-cruiser concatenates `forbidden` and merges `options`. CommonJS: dependency-cruiser requires its configs.
7
+
8
+ /** @type {import("dependency-cruiser").IConfiguration} */
9
+ module.exports = {
10
+ forbidden: [
11
+ {
12
+ name: "krizaka-no-direct-tanstack",
13
+ severity: "error",
14
+ comment:
15
+ "@tanstack/* enters an app only through its @krizaka facade (@krizaka/query, @krizaka/ui/data-table, @krizaka/ui/form, " +
16
+ "@krizaka/ui/virtual, @krizaka/pacer…): the facade's version is the contract, a TanStack break is fixed in one place.",
17
+ // the app's own sources; the @krizaka/* packages in node_modules are where TanStack belongs
18
+ from: { pathNot: "(^|/)node_modules/" },
19
+ // resolved (node_modules/…/@tanstack/…) or not (a transitive package the app does not declare)
20
+ to: { path: "(^|/)@tanstack/" },
21
+ },
22
+ ],
23
+ options: {
24
+ doNotFollow: { path: "(^|/)node_modules/" },
25
+ tsPreCompilationDeps: true,
26
+ enhancedResolveOptions: {
27
+ exportsFields: ["exports"],
28
+ conditionNames: ["import", "require", "node", "default"],
29
+ },
30
+ },
31
+ };
@@ -0,0 +1,27 @@
1
+ // Environment variables are named by bounded context (krizaka/.github#46): `<CONTEXT>_<THING>`, never after a product
2
+ // (the repository's own, from its package.json, or one a config names) nor a vendor (BUNNY_…, MAILGUN_…) — a vendor is a value (MEDIA_STORAGE_PROVIDER=bunny). The rule
3
+ // `krizaka/env-name` reads `process.env.X`, `process.env["X"]` and `import.meta.env.X`; krizakaBase turns it on.
4
+
5
+ import { krizakaPlugin } from "./plugin.js";
6
+
7
+ export { krizakaPlugin } from "./plugin.js";
8
+ export { ENV_PLATFORM_NAMES, ENV_PRODUCT_PREFIXES, ENV_VENDOR_PREFIXES, envNamePrefixProblem, ownProductPrefix } from "./rules.js";
9
+
10
+ /**
11
+ * Flat config: `krizaka/env-name` on the given files.
12
+ * @param {{ files?: string[], products?: string[], vendors?: string[], allow?: string[] }} [options]
13
+ * `products`, `vendors`: extra refused prefixes; `allow`: extra names a platform sets.
14
+ * @returns {import("eslint").Linter.Config[]}
15
+ */
16
+ export function envNames({ files = ["**/*.{js,jsx,mjs,cjs,ts,tsx,mts,cts}"], products = [], vendors = [], allow = [] } = {}) {
17
+ return [
18
+ {
19
+ name: "krizaka/env-name",
20
+ files,
21
+ plugins: { krizaka: krizakaPlugin },
22
+ rules: { "krizaka/env-name": ["error", { products, vendors, allow }] },
23
+ },
24
+ ];
25
+ }
26
+
27
+ export default envNames;
package/eslint/index.js CHANGED
@@ -1,11 +1,13 @@
1
1
  // The base flat config of every Krizaka JavaScript repository: @eslint/js and typescript-eslint recommended,
2
- // the React hooks rules, and a light import ordering (autofixable, a warning).
2
+ // the React hooks rules, a light import ordering (autofixable, a warning) and env names by bounded context.
3
3
  import js from "@eslint/js";
4
4
  import reactHooks from "eslint-plugin-react-hooks";
5
5
  import simpleImportSort from "eslint-plugin-simple-import-sort";
6
6
  import globals from "globals";
7
7
  import tseslint from "typescript-eslint";
8
8
 
9
+ import { envNames } from "./env-names.js";
10
+
9
11
  /** Generated or vendored output, never linted. */
10
12
  export const KRIZAKA_IGNORES = Object.freeze([
11
13
  "**/node_modules/**",
@@ -43,6 +45,8 @@ export const krizakaCommon = [
43
45
  "simple-import-sort/exports": "warn",
44
46
  },
45
47
  },
48
+ // Environment variables named by bounded context, never a product or a vendor (krizaka/.github#46).
49
+ ...envNames(),
46
50
  ];
47
51
 
48
52
  /**
@@ -3,37 +3,10 @@
3
3
  // shipped as a plugin rule (`krizaka/no-locale-ternary`) so it never collides with a repository's own
4
4
  // `no-restricted-syntax` options.
5
5
 
6
- const MESSAGE = "Put the text in the message catalogues (messages/<locale>.json) and read it from the dictionary (t.…).";
6
+ import { krizakaPlugin } from "./plugin.js";
7
7
 
8
- /** The selectors, also usable in `no-restricted-syntax`. */
9
- export const LOCALE_TERNARY_SELECTORS = Object.freeze([
10
- "ConditionalExpression[test.type='BinaryExpression'][test.right.value=/^(fr|en)$/]",
11
- "ConditionalExpression > Identifier.test[name=/^(isFr|isEn|fr)$/]",
12
- ]);
13
-
14
- /** @type {import("eslint").Rule.RuleModule} */
15
- const rule = {
16
- meta: {
17
- type: "problem",
18
- docs: { description: "Forbid choosing a text with a locale ternary outside routing code." },
19
- schema: [],
20
- messages: { localeTernary: MESSAGE },
21
- },
22
- create(context) {
23
- /** @type {Record<string, (node: import("estree").Node) => void>} */
24
- const visitors = {};
25
- for (const selector of LOCALE_TERNARY_SELECTORS) {
26
- visitors[selector] = (node) => context.report({ node, messageId: "localeTernary" });
27
- }
28
- return visitors;
29
- },
30
- };
31
-
32
- /** The plugin carrying the rule. */
33
- export const krizakaPlugin = {
34
- meta: { name: "@krizaka/config" },
35
- rules: { "no-locale-ternary": rule },
36
- };
8
+ export { krizakaPlugin } from "./plugin.js";
9
+ export { LOCALE_TERNARY_SELECTORS } from "./rules.js";
37
10
 
38
11
  /** Routing files that legitimately compare locales (the krizaka.com layout, a common convention). */
39
12
  export const DEFAULT_ROUTING_FILES = Object.freeze([
@@ -0,0 +1,17 @@
1
+ // The `krizaka` ESLint plugin: one object carrying every rule, so that configs registering it compose (flat config
2
+ // refuses two different objects under the same plugin name).
3
+ import { envName, noDirectTanstackImport, noFetchInClientEffect, noLocaleTernaryRule, noLocalTableOrForm } from "./rules.js";
4
+
5
+ /** @type {import("eslint").ESLint.Plugin} */
6
+ export const krizakaPlugin = {
7
+ meta: { name: "@krizaka/config" },
8
+ rules: {
9
+ "no-locale-ternary": noLocaleTernaryRule,
10
+ "no-fetch-in-client-effect": noFetchInClientEffect,
11
+ "no-direct-tanstack-import": noDirectTanstackImport,
12
+ "no-local-table-or-form": noLocalTableOrForm,
13
+ "env-name": envName,
14
+ },
15
+ };
16
+
17
+ export default krizakaPlugin;
@@ -0,0 +1,266 @@
1
+ // The rule modules of the `krizaka` ESLint plugin (eslint/plugin.js). Patterns come from ../patterns.js, which the
2
+ // ratchet reads too: a rule and its counter never diverge.
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+
6
+ import { countFormFields, EFFECT_NAMES_SOURCE, FETCH_NAMES_SOURCE, TANSTACK_SCOPE } from "../patterns.js";
7
+
8
+ /** @typedef {import("eslint").Rule.RuleModule} RuleModule */
9
+ /** @typedef {import("estree").Node} Node */
10
+
11
+ /** The selectors of `no-locale-ternary`, also usable in `no-restricted-syntax`. */
12
+ export const LOCALE_TERNARY_SELECTORS = Object.freeze([
13
+ "ConditionalExpression[test.type='BinaryExpression'][test.right.value=/^(fr|en)$/]",
14
+ "ConditionalExpression > Identifier.test[name=/^(isFr|isEn|fr)$/]",
15
+ ]);
16
+
17
+ /** @type {RuleModule} */
18
+ export const noLocaleTernaryRule = {
19
+ meta: {
20
+ type: "problem",
21
+ docs: { description: "Forbid choosing a text with a locale ternary outside routing code." },
22
+ schema: [],
23
+ messages: {
24
+ localeTernary:
25
+ "Put the text in the message catalogues (messages/<locale>.json) and read it from the dictionary (t.…).",
26
+ },
27
+ },
28
+ create(context) {
29
+ /** @type {Record<string, (node: Node) => void>} */
30
+ const visitors = {};
31
+ for (const selector of LOCALE_TERNARY_SELECTORS) {
32
+ visitors[selector] = (node) => context.report({ node, messageId: "localeTernary" });
33
+ }
34
+ return visitors;
35
+ },
36
+ };
37
+
38
+ /**
39
+ * True when the file starts with a `"use client"` directive.
40
+ * @param {import("eslint").Rule.RuleContext} context
41
+ */
42
+ function isClientFile(context) {
43
+ for (const statement of context.sourceCode.ast.body) {
44
+ if (statement.type !== "ExpressionStatement" || !("directive" in statement)) return false;
45
+ if (statement.directive === "use client") return true;
46
+ }
47
+ return false;
48
+ }
49
+
50
+ const callee = (/** @type {string} */ names) => `:matches([callee.name=/${names}/], [callee.property.name=/${names}/])`;
51
+
52
+ /** S1: the first screen is a Server Component, the interaction is Query — never a fetch in a client effect. */
53
+ /** @type {RuleModule} */
54
+ export const noFetchInClientEffect = {
55
+ meta: {
56
+ type: "suggestion",
57
+ docs: { description: "Forbid a fetch (or krizakaFetch) inside a useEffect of a \"use client\" file." },
58
+ schema: [],
59
+ messages: {
60
+ fetchInEffect:
61
+ "A fetch in a client effect: render the first screen in a Server Component and read the rest with @krizaka/query (useQuery / useMutation).",
62
+ },
63
+ },
64
+ create(context) {
65
+ if (!isClientFile(context)) return {};
66
+ return {
67
+ [`CallExpression${callee(EFFECT_NAMES_SOURCE)} CallExpression${callee(FETCH_NAMES_SOURCE)}`]: (/** @type {Node} */ node) =>
68
+ context.report({ node, messageId: "fetchInEffect" }),
69
+ };
70
+ },
71
+ };
72
+
73
+ /** S11: `@tanstack/*` is imported by the `@krizaka/*` packages only — the version is the contract. */
74
+ /** @type {RuleModule} */
75
+ export const noDirectTanstackImport = {
76
+ meta: {
77
+ type: "problem",
78
+ docs: { description: "Forbid importing @tanstack/* outside the @krizaka/* packages." },
79
+ schema: [],
80
+ messages: {
81
+ tanstack:
82
+ "{{source}} is imported through its @krizaka facade (@krizaka/query, @krizaka/ui/data-table, @krizaka/ui/form, @krizaka/ui/virtual, @krizaka/pacer…), never directly.",
83
+ },
84
+ },
85
+ create(context) {
86
+ /** @param {Node | null | undefined} source */
87
+ const check = (source) => {
88
+ if (source?.type === "Literal" && typeof source.value === "string" && source.value.startsWith(TANSTACK_SCOPE)) {
89
+ context.report({ node: source, messageId: "tanstack", data: { source: source.value } });
90
+ }
91
+ };
92
+ return {
93
+ ImportDeclaration: (node) => check(node.source),
94
+ ExportNamedDeclaration: (node) => check(node.source),
95
+ ExportAllDeclaration: (node) => check(node.source),
96
+ ImportExpression: (node) => check(node.source),
97
+ "CallExpression[callee.name='require']": (/** @type {import("estree").CallExpression} */ node) =>
98
+ check(/** @type {Node} */ (node.arguments[0])),
99
+ };
100
+ },
101
+ };
102
+
103
+ /** S2, S3: a table is `@krizaka/ui/data-table`, a form is `@krizaka/ui/form` — except a form of a single field. */
104
+ /** @type {RuleModule} */
105
+ export const noLocalTableOrForm = {
106
+ meta: {
107
+ type: "suggestion",
108
+ docs: { description: "Forbid a hand-written <table> or <form> in an app (single-field forms excepted)." },
109
+ schema: [
110
+ {
111
+ type: "object",
112
+ properties: { maxFormFields: { type: "integer", minimum: 0 } },
113
+ additionalProperties: false,
114
+ },
115
+ ],
116
+ messages: {
117
+ table: "A hand-written <table>: use DataTable from @krizaka/ui/data-table.",
118
+ form: "A hand-written <form> of {{fields}} fields: use createKrizakaForm from @krizaka/ui/form.",
119
+ },
120
+ },
121
+ create(context) {
122
+ const maxFormFields = context.options[0]?.maxFormFields ?? 1;
123
+ return {
124
+ /** @param {any} node JSXOpeningElement */
125
+ "JSXOpeningElement[name.type='JSXIdentifier']:matches([name.name='table'], [name.name='form'])"(node) {
126
+ if (node.name.name === "table") return context.report({ node, messageId: "table" });
127
+ const fields = countFormFields(context.sourceCode.getText(node.parent));
128
+ if (fields > maxFormFields) context.report({ node, messageId: "form", data: { fields: String(fields) } });
129
+ },
130
+ };
131
+ },
132
+ };
133
+
134
+ /**
135
+ * The organisation's name is never a prefix; a repository's own product is found from its package.json (below) and a
136
+ * config may name more (`products`): a deployment already says which product it is (krizaka/.github#46).
137
+ */
138
+ export const ENV_PRODUCT_PREFIXES = Object.freeze(["KRIZAKA"]);
139
+
140
+ /** The bounded contexts a name starts with (krizaka/.github#46). */
141
+ export const ENV_CONTEXT_WORDS = Object.freeze(["APP", "DATABASE", "AUTH", "MEDIA", "MAIL", "PUSH", "PAYMENTS", "REALTIME", "ABUSE", "OBSERVABILITY", "AI", "OPERATOR"]);
142
+
143
+ /** @type {Map<string, string | null>} */
144
+ const ownProductCache = new Map();
145
+
146
+ /**
147
+ * The product a repository is: the first word of the nearest package.json's name, scope removed
148
+ * (`acme-monorepo` → ACME, `@org/acme-admin` → ACME); null for a one-word name (`@org/realtime`) or a context word.
149
+ * @param {string} cwd
150
+ */
151
+ export function ownProductPrefix(cwd) {
152
+ if (ownProductCache.has(cwd)) return ownProductCache.get(cwd) ?? null;
153
+ let found = null;
154
+ for (let dir = cwd; ; dir = dirname(dir)) {
155
+ const file = join(dir, "package.json");
156
+ if (existsSync(file)) {
157
+ try {
158
+ const name = String(JSON.parse(readFileSync(file, "utf8")).name ?? "");
159
+ // A product names its packages `<product>-<part>` (acme-monorepo, @org/acme-admin); a one-word package
160
+ // (@org/realtime, @org/ui) is a building block, not a product, and a context word is never one.
161
+ const words = name.replace(/^@[^/]+\//, "").split(/[-_.]/).filter(Boolean);
162
+ const word = words.length > 1 ? words[0].toUpperCase() : "";
163
+ found = /^[A-Z][A-Z0-9]+$/.test(word) && !ENV_CONTEXT_WORDS.includes(word) ? word : null;
164
+ } catch {
165
+ found = null;
166
+ }
167
+ break;
168
+ }
169
+ if (dirname(dir) === dir) break;
170
+ }
171
+ ownProductCache.set(cwd, found);
172
+ return found;
173
+ }
174
+
175
+ /** Vendors: a vendor is a value (`MEDIA_STORAGE_PROVIDER=bunny`, `MAIL_PROVIDER=mailgun`), never a name. */
176
+ export const ENV_VENDOR_PREFIXES = Object.freeze([
177
+ "BUNNY", "MAILGUN", "RESEND", "SENDGRID", "POSTMARK", "STRIPE", "CCBILL", "SEGPAY", "NOWPAYMENTS", "PAYPAL",
178
+ "TURNSTILE", "CLOUDFLARE", "VERCEL", "DIGITALOCEAN", "DO", "AWS", "GCP", "AZURE", "GOOGLE", "FACEBOOK", "APPLE",
179
+ "GITHUB", "GH", "EXPO", "FIREBASE", "FCM", "APNS", "REDIS", "VALKEY", "OPENAI", "ANTHROPIC", "OLLAMA", "SENTRY",
180
+ "TWILIO", "SUPABASE", "MAPBOX",
181
+ ]);
182
+
183
+ /** Names the platforms set themselves (GitHub Actions, Vercel, EAS): read, never chosen. */
184
+ export const ENV_PLATFORM_NAMES = Object.freeze([
185
+ "GITHUB_ACTIONS", "GITHUB_TOKEN", "GITHUB_SHA", "GITHUB_REF", "GITHUB_REF_NAME", "GITHUB_REPOSITORY", "GITHUB_RUN_ID",
186
+ "GITHUB_RUN_NUMBER", "GITHUB_WORKSPACE", "GITHUB_OUTPUT", "GITHUB_STEP_SUMMARY", "GITHUB_EVENT_NAME", "GITHUB_SERVER_URL",
187
+ "VERCEL", "VERCEL_ENV", "VERCEL_URL", "VERCEL_GIT_COMMIT_SHA", "EAS_BUILD", "EAS_BUILD_PLATFORM", "EAS_BUILD_PROFILE",
188
+ "EXPO_TOKEN",
189
+ ]);
190
+
191
+ /** The framework prefixes a client-exposed value needs; the name after them follows the same rule. */
192
+ const PUBLIC_PREFIX = /^(?:NEXT_PUBLIC_|EXPO_PUBLIC_|VITE_)/;
193
+
194
+ /**
195
+ * The prefix that makes an environment variable name refused, or null. Exported for scripts and tests.
196
+ * @param {string} name
197
+ * @param {{ products?: readonly string[], vendors?: readonly string[], allow?: readonly string[] }} [options]
198
+ * @returns {{ prefix: string, kind: "product" | "vendor" } | null}
199
+ */
200
+ export function envNamePrefixProblem(name, { products = ENV_PRODUCT_PREFIXES, vendors = ENV_VENDOR_PREFIXES, allow = ENV_PLATFORM_NAMES } = {}) {
201
+ if (allow.includes(name)) return null;
202
+ const bare = name.replace(PUBLIC_PREFIX, "");
203
+ const head = bare.split("_")[0] ?? "";
204
+ if (bare === head) return null; // one word (PORT, CI): no prefix to judge
205
+ if (products.includes(head)) return { prefix: head, kind: "product" };
206
+ if (vendors.includes(head)) return { prefix: head, kind: "vendor" };
207
+ return null;
208
+ }
209
+
210
+ /** The name read by `process.env.X`, `process.env["X"]` or `import.meta.env.X`, or null. */
211
+ function envNameOf(/** @type {any} */ node) {
212
+ const object = node.object;
213
+ const isProcessEnv = object?.type === "MemberExpression" && object.object?.type === "Identifier" && object.object.name === "process" && !object.computed && object.property?.name === "env";
214
+ const isImportMetaEnv = object?.type === "MemberExpression" && object.object?.type === "MetaProperty" && !object.computed && object.property?.name === "env";
215
+ if (!isProcessEnv && !isImportMetaEnv) return null;
216
+ if (!node.computed && node.property?.type === "Identifier") return node.property.name;
217
+ if (node.computed && node.property?.type === "Literal" && typeof node.property.value === "string") return node.property.value;
218
+ return null;
219
+ }
220
+
221
+ /**
222
+ * Environment variables are named by bounded context — `<CONTEXT>_<THING>` (APP_, DATABASE_, AUTH_, MEDIA_, MAIL_,
223
+ * PUSH_, PAYMENTS_, REALTIME_, ABUSE_, OBSERVABILITY_, AI_, OPERATOR_) — never after a product or a vendor
224
+ * (krizaka/.github#46). The products refused: the organisation's name and the repository's own (its package.json). Options:
225
+ * `products`, `vendors` (extra prefixes), `allow` (extra names a platform sets).
226
+ * @type {RuleModule}
227
+ */
228
+ export const envName = {
229
+ meta: {
230
+ type: "problem",
231
+ docs: { description: "Forbid environment variable names prefixed with a product or a vendor (krizaka/.github#46)." },
232
+ schema: [
233
+ {
234
+ type: "object",
235
+ properties: {
236
+ products: { type: "array", items: { type: "string" } },
237
+ vendors: { type: "array", items: { type: "string" } },
238
+ allow: { type: "array", items: { type: "string" } },
239
+ },
240
+ additionalProperties: false,
241
+ },
242
+ ],
243
+ messages: {
244
+ product:
245
+ "{{name}} is named after a product ({{prefix}}_): name it by its bounded context, <CONTEXT>_<THING> (APP_, DATABASE_, AUTH_, MEDIA_, MAIL_, PUSH_, PAYMENTS_, REALTIME_, ABUSE_, OBSERVABILITY_, AI_, OPERATOR_).",
246
+ vendor:
247
+ "{{name}} is named after a vendor ({{prefix}}_): the vendor is a value (<CONTEXT>_PROVIDER={{lower}}) and its settings sit under the context (e.g. MEDIA_STORAGE_API_KEY, MAIL_API_KEY).",
248
+ },
249
+ },
250
+ create(context) {
251
+ const options = context.options[0] ?? {};
252
+ const own = ownProductPrefix(context.cwd);
253
+ const products = [...ENV_PRODUCT_PREFIXES, ...(own && !ENV_VENDOR_PREFIXES.includes(own) ? [own] : []), ...(options.products ?? [])];
254
+ const vendors = [...ENV_VENDOR_PREFIXES, ...(options.vendors ?? [])];
255
+ const allow = [...ENV_PLATFORM_NAMES, ...(options.allow ?? [])];
256
+ return {
257
+ /** @param {any} node */
258
+ MemberExpression(node) {
259
+ const name = envNameOf(node);
260
+ if (!name) return;
261
+ const problem = envNamePrefixProblem(name, { products, vendors, allow });
262
+ if (problem) context.report({ node, messageId: problem.kind, data: { name, prefix: problem.prefix, lower: problem.prefix.toLowerCase() } });
263
+ },
264
+ };
265
+ },
266
+ };
@@ -0,0 +1,40 @@
1
+ // The three stack rules (the stack ADR, decisions S1–S3 and S11): server state through @krizaka/query, tables and forms
2
+ // through @krizaka/ui, TanStack only behind a @krizaka facade. Warnings: krizaka-ratchet counts the same patterns, so an
3
+ // app adopts them now and brings each count to 0 by the end of its adoption.
4
+ import { krizakaPlugin } from "./plugin.js";
5
+
6
+ /** The rule names, as configured. */
7
+ export const STACK_RULES = Object.freeze([
8
+ "krizaka/no-fetch-in-client-effect",
9
+ "krizaka/no-direct-tanstack-import",
10
+ "krizaka/no-local-table-or-form",
11
+ ]);
12
+
13
+ /**
14
+ * Flat config: the three stack rules on an app's sources.
15
+ * @param {{ files?: string[], ignores?: string[], severity?: "warn" | "error", maxFormFields?: number }} [options]
16
+ * `maxFormFields`: a form with at most this many fields is allowed (default 1 — a search box, a newsletter).
17
+ * @returns {import("eslint").Linter.Config[]}
18
+ */
19
+ export function krizakaStack({
20
+ files = ["**/*.{js,jsx,mjs,cjs,ts,tsx,mts,cts}"],
21
+ ignores = [],
22
+ severity = "warn",
23
+ maxFormFields = 1,
24
+ } = {}) {
25
+ return [
26
+ {
27
+ name: "krizaka/stack",
28
+ files,
29
+ ...(ignores.length > 0 ? { ignores } : {}),
30
+ plugins: { krizaka: krizakaPlugin },
31
+ rules: {
32
+ "krizaka/no-fetch-in-client-effect": severity,
33
+ "krizaka/no-direct-tanstack-import": severity,
34
+ "krizaka/no-local-table-or-form": [severity, { maxFormFields }],
35
+ },
36
+ },
37
+ ];
38
+ }
39
+
40
+ export default krizakaStack;
package/package.json CHANGED
@@ -1,14 +1,16 @@
1
1
  {
2
2
  "name": "@krizaka/config",
3
- "version": "0.1.0",
4
- "description": "The shared configuration of every Krizaka JavaScript repository: ESLint flat configs (base, Next.js, the four UI rules, no locale ternary), tsconfig bases, Prettier, EditorConfig, and the krizaka-ratchet debt counter.",
3
+ "version": "0.3.0",
4
+ "description": "The shared configuration of every Krizaka JavaScript repository: ESLint flat configs (base, Next.js, the four UI rules, the three stack rules, no locale ternary), the dependency-cruiser boundary, tsconfig bases, Prettier, EditorConfig, and the krizaka-ratchet debt counter.",
5
5
  "keywords": [
6
6
  "krizaka",
7
7
  "eslint-config",
8
8
  "tsconfig",
9
9
  "prettier-config",
10
10
  "ratchet",
11
- "design-system"
11
+ "design-system",
12
+ "dependency-cruiser",
13
+ "tanstack"
12
14
  ],
13
15
  "homepage": "https://github.com/krizaka/krizaka-ui/tree/main/packages/config#readme",
14
16
  "bugs": "https://github.com/krizaka/krizaka-ui/issues",
@@ -25,6 +27,10 @@
25
27
  "./eslint/next": "./eslint/next.js",
26
28
  "./eslint/krizaka-ui": "./eslint/krizaka-ui.js",
27
29
  "./eslint/no-locale-ternary": "./eslint/no-locale-ternary.js",
30
+ "./eslint/stack": "./eslint/stack.js",
31
+ "./eslint/env-names": "./eslint/env-names.js",
32
+ "./eslint/plugin": "./eslint/plugin.js",
33
+ "./dependency-cruiser": "./dependency-cruiser.cjs",
28
34
  "./patterns": "./patterns.js",
29
35
  "./ratchet": "./ratchet/ratchet.js",
30
36
  "./prettier": "./prettier.config.js",
@@ -44,6 +50,7 @@
44
50
  "ratchet",
45
51
  "tsconfig",
46
52
  "patterns.js",
53
+ "dependency-cruiser.cjs",
47
54
  "prettier.config.js",
48
55
  ".editorconfig",
49
56
  "README.md",
@@ -76,6 +83,7 @@
76
83
  "@size-limit/file": "^14.2.0",
77
84
  "@types/estree": "^1.0.8",
78
85
  "@types/node": "^22.15.0",
86
+ "dependency-cruiser": "^18.6.0",
79
87
  "eslint": "^9.39.0",
80
88
  "eslint-config-next": "^16.4.0",
81
89
  "next": "^16.4.0",
@@ -99,7 +107,7 @@
99
107
  "ratchet/ratchet.js",
100
108
  "patterns.js"
101
109
  ],
102
- "limit": "5 kB",
110
+ "limit": "8 kB",
103
111
  "gzip": true
104
112
  }
105
113
  ],
package/patterns.js CHANGED
@@ -31,3 +31,87 @@ export const ARBITRARY_VAR_SOURCE = "\\[var\\(--";
31
31
 
32
32
  /** `className={`…${x}`}` — the ratchet's text approximation of the TemplateLiteral rule. */
33
33
  export const CLASSNAME_TEMPLATE_SOURCE = "className=\\{\\s*`";
34
+
35
+ // ── The stack debts (decisions S1–S3, S11 of the stack ADR) ──────────────────────────────────────────────────────
36
+ // Read by the ESLint rules (eslint/stack.js) and by the ratchet, like the UI patterns above.
37
+
38
+ /** A `"use client"` directive heading the file (after comments). */
39
+ export const USE_CLIENT_SOURCE = String.raw`^(?:\s|\/\/[^\n]*\n|\/\*[\s\S]*?\*\/)*["']use client["']`;
40
+
41
+ /** The opening of an effect call: `useEffect(`, `React.useLayoutEffect(`. */
42
+ export const EFFECT_CALL_SOURCE = String.raw`\buse(?:Layout)?Effect\s*\(`;
43
+
44
+ /** A fetch call: `fetch(`, `window.fetch(`, `krizakaFetch(` — never `refetch(`. */
45
+ export const FETCH_CALL_SOURCE = String.raw`\b(?:fetch|krizakaFetch)\s*\(`;
46
+
47
+ /** The names the ESLint rule matches (callee identifier or member property). */
48
+ export const EFFECT_NAMES_SOURCE = "^use(Layout)?Effect$";
49
+ export const FETCH_NAMES_SOURCE = "^(fetch|krizakaFetch)$";
50
+
51
+ /** The headless engines that only enter through a `@krizaka/*` package. */
52
+ export const TANSTACK_SCOPE = "@tanstack/";
53
+
54
+ /**
55
+ * An import of one of the given packages (or scopes ending in `/`): `from "x"`, `import "x"`, `import("x")`,
56
+ * `require("x")`, `export … from "x"`.
57
+ * @param {readonly string[]} specifiers package names (`framer-motion`) or scope prefixes (`@tanstack/`)
58
+ * @returns {string} regex source
59
+ */
60
+ export function importSource(specifiers) {
61
+ if (specifiers.length === 0) return "(?!)"; // nothing listed: matches nothing
62
+ const escape = (/** @type {string} */ s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
63
+ const names = specifiers.map((s) => (s.endsWith("/") ? `${escape(s)}[^"']+` : `${escape(s)}(?:/[^"']*)?`));
64
+ return String.raw`(?:\bfrom|\bimport|\brequire)\s*(?:\(\s*)?["'](?:${names.join("|")})["']`;
65
+ }
66
+
67
+ /** A `<table` or `<form` element written in an app (JSX or HTML). */
68
+ export const TABLE_OR_FORM_SOURCE = String.raw`<(table|form)(?=[\s>/])`;
69
+
70
+ /** The elements that hold a value in a form: native fields and the field primitives of `@krizaka/ui`. */
71
+ export const FORM_FIELD_TAGS = Object.freeze([
72
+ "input", "textarea", "select",
73
+ "Input", "Textarea", "Select", "Combobox", "DatePicker", "DateRangePicker", "TagInput", "Checkbox", "Switch",
74
+ "RadioGroup", "Slider", "SearchField", "Dropzone", "FileUpload", "OtpInput",
75
+ ]);
76
+
77
+ /** `<input type="hidden|submit|button|reset|image">` holds no value the user types. */
78
+ const NOT_A_FIELD = /\btype=(?:\{\s?)?["'](?:hidden|submit|button|reset|image)["']/;
79
+
80
+ /**
81
+ * The value-holding fields inside a form's source text.
82
+ * @param {string} text
83
+ * @returns {number}
84
+ */
85
+ export function countFormFields(text) {
86
+ const tags = new RegExp(String.raw`<(${FORM_FIELD_TAGS.join("|")})(?=[\s/>])([^>]*)`, "g");
87
+ let fields = 0;
88
+ for (const [, tag, attributes] of text.matchAll(tags)) {
89
+ if (tag === "input" && NOT_A_FIELD.test(attributes ?? "")) continue;
90
+ fields += 1;
91
+ }
92
+ return fields;
93
+ }
94
+
95
+ /**
96
+ * The text between the parenthesis that opens at `open` and the one that closes it, skipping strings and comments.
97
+ * @param {string} source
98
+ * @param {number} open index of the `(`
99
+ * @returns {string}
100
+ */
101
+ export function balancedArguments(source, open) {
102
+ let depth = 0;
103
+ for (let i = open; i < source.length; i += 1) {
104
+ const c = source[i];
105
+ if (c === '"' || c === "'" || c === "`") {
106
+ for (i += 1; i < source.length && source[i] !== c; i += 1) if (source[i] === "\\") i += 1;
107
+ } else if (c === "/" && source[i + 1] === "/") {
108
+ i = source.indexOf("\n", i);
109
+ if (i < 0) break;
110
+ } else if (c === "/" && source[i + 1] === "*") {
111
+ i = source.indexOf("*/", i + 2) + 1;
112
+ if (i <= 0) break;
113
+ } else if (c === "(") depth += 1;
114
+ else if (c === ")" && --depth === 0) return source.slice(open + 1, i);
115
+ }
116
+ return source.slice(open + 1);
117
+ }
@@ -1,4 +1,4 @@
1
- // The ratchet: counts the four UI debts in a repository and refuses any increase against lint-ratchet.json.
1
+ // The ratchet: counts the UI and stack debts of a repository and refuses any increase against lint-ratchet.json.
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";
@@ -6,22 +6,59 @@ import { relative, resolve } from "node:path";
6
6
 
7
7
  import { glob } from "tinyglobby";
8
8
 
9
- import { ARBITRARY_VAR_SOURCE, CLASSNAME_TEMPLATE_SOURCE, LIGHT_VARIANT_SOURCE, paletteSource } from "../patterns.js";
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";
10
23
 
11
24
  export const RATCHET_FILE = "lint-ratchet.json";
12
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
+ );
13
30
  const IGNORE = ["**/node_modules/**", "**/dist/**", "**/.next/**", "**/storybook-static/**", "**/coverage/**"];
14
31
 
15
- /** @typedef {"palette" | "light" | "arbitraryVar" | "classNameTemplate"} CounterName */
32
+ /** @typedef {"palette" | "light" | "arbitraryVar" | "classNameTemplate"} UiCounterName */
33
+ /** @typedef {"fetchInClientEffect" | "directTanstackImport" | "localTableOrForm" | "retiredImport"} StackCounterName */
34
+ /** @typedef {UiCounterName | StackCounterName} CounterName */
16
35
  /** @typedef {Record<CounterName, number>} Counters */
17
- /** @typedef {{ globs?: string[], allow?: string[], counters: Counters }} RatchetFile */
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
+ */
18
45
 
19
- /** The counters, in report order. */
20
- export const COUNTERS = /** @type {const} */ (["palette", "light", "arbitraryVar", "classNameTemplate"]);
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])));
21
58
 
22
59
  /**
23
60
  * @param {string[]} [allow] palette families tolerated (same option as krizakaUi)
24
- * @returns {Record<CounterName, RegExp>}
61
+ * @returns {Record<UiCounterName, RegExp>}
25
62
  */
26
63
  export function counterPatterns(allow = []) {
27
64
  return {
@@ -33,33 +70,76 @@ export function counterPatterns(allow = []) {
33
70
  }
34
71
 
35
72
  /**
36
- * Counts the four debts in one source text.
73
+ * Counts the four UI debts in one source text.
37
74
  * @param {string} source
38
75
  * @param {string[]} [allow]
39
- * @returns {Counters}
76
+ * @returns {Record<UiCounterName, number>}
40
77
  */
41
78
  export function countSource(source, allow = []) {
42
79
  const patterns = counterPatterns(allow);
43
- /** @type {Counters} */
80
+ /** @type {Record<UiCounterName, number>} */
44
81
  const counters = { palette: 0, light: 0, arbitraryVar: 0, classNameTemplate: 0 };
45
- for (const name of COUNTERS) counters[name] = source.match(patterns[name])?.length ?? 0;
82
+ for (const name of UI_COUNTERS) counters[name] = source.match(patterns[name])?.length ?? 0;
46
83
  return counters;
47
84
  }
48
85
 
49
86
  /**
50
- * Counts the debts in every file matched by the globs under `root`.
51
- * @param {{ root: string, globs?: readonly string[], allow?: string[] }} options
52
- * @returns {Promise<{ counters: Counters, files: number }>}
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 }>}
53
122
  */
54
- export async function countRepository({ root, globs = DEFAULT_GLOBS, allow = [] }) {
123
+ export async function countRepository({
124
+ root,
125
+ globs = DEFAULT_GLOBS,
126
+ stackGlobs = DEFAULT_STACK_GLOBS,
127
+ allow = [],
128
+ retire = [],
129
+ maxFormFields = 1,
130
+ }) {
55
131
  const files = await glob([...globs], { cwd: root, ignore: IGNORE, absolute: true });
56
- /** @type {Counters} */
57
- const counters = { palette: 0, light: 0, arbitraryVar: 0, classNameTemplate: 0 };
132
+ const stackFiles = await glob([...stackGlobs], { cwd: root, ignore: IGNORE, absolute: true });
133
+ const counters = zero();
58
134
  for (const file of files) {
59
135
  const found = countSource(readFileSync(file, "utf8"), allow);
60
- for (const name of COUNTERS) counters[name] += found[name];
136
+ for (const name of UI_COUNTERS) counters[name] += found[name];
61
137
  }
62
- return { counters, files: files.length };
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 };
63
143
  }
64
144
 
65
145
  /**
@@ -69,7 +149,10 @@ export async function countRepository({ root, globs = DEFAULT_GLOBS, allow = []
69
149
  function readRatchet(path) {
70
150
  const data = JSON.parse(readFileSync(path, "utf8"));
71
151
  for (const name of COUNTERS) {
72
- if (!Number.isInteger(data?.counters?.[name]) || data.counters[name] < 0) {
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) {
73
156
  throw new RatchetError(`${path}: counters.${name} must be a non-negative integer.`);
74
157
  }
75
158
  }
@@ -92,19 +175,23 @@ export class RatchetError extends Error {}
92
175
  * @property {boolean} ok no counter above its recorded value
93
176
  * @property {"check" | "init" | "update"} mode
94
177
  * @property {string} file the ratchet file, relative to root
95
- * @property {number} files source files scanned
178
+ * @property {number} files source files scanned by the UI counters
179
+ * @property {number} stackFiles source files scanned by the stack counters
96
180
  * @property {string[]} globs
181
+ * @property {string[]} stackGlobs
97
182
  * @property {Record<CounterName, { recorded: number, current: number, delta: number }>} counters
98
183
  * @property {CounterName[]} increased
99
184
  * @property {CounterName[]} decreased
100
- * @property {boolean} written the ratchet file was created or lowered
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
101
187
  */
102
188
 
103
189
  /**
104
190
  * Runs the ratchet.
105
191
  * - `check` (default): fails when a counter is above its recorded value.
106
192
  * - `init`: creates the file with the current counters (refuses to overwrite one).
107
- * - `update`: lowers each recorded value to the current one, never raises it (still fails on an increase).
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.
108
195
  * @param {{ root: string, mode?: "check" | "init" | "update", globs?: string[], file?: string }} options
109
196
  * @returns {Promise<RatchetResult>}
110
197
  */
@@ -116,9 +203,20 @@ export async function runRatchet({ root, mode = "check", globs, file = RATCHET_F
116
203
 
117
204
  const recordedFile = exists ? readRatchet(path) : undefined;
118
205
  const useGlobs = globs && globs.length > 0 ? globs : (recordedFile?.globs ?? [...DEFAULT_GLOBS]);
206
+ const stackGlobs = recordedFile?.stackGlobs ?? [...DEFAULT_STACK_GLOBS];
119
207
  const allow = recordedFile?.allow ?? [];
120
- const { counters: current, files } = await countRepository({ root, globs: useGlobs, allow });
121
- const recorded = recordedFile?.counters ?? current;
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 });
122
220
 
123
221
  /** @type {RatchetResult["counters"]} */
124
222
  const report = /** @type {any} */ ({});
@@ -133,13 +231,12 @@ export async function runRatchet({ root, mode = "check", globs, file = RATCHET_F
133
231
 
134
232
  let written = false;
135
233
  if (mode === "init") {
136
- writeRatchet(path, { globs: useGlobs, ...(allow.length > 0 ? { allow } : {}), counters: current });
234
+ writeRatchet(path, { globs: useGlobs, stackGlobs, ...(allow.length > 0 ? { allow } : {}), counters: current });
137
235
  written = true;
138
- } else if (mode === "update" && decreased.length > 0 && recordedFile) {
139
- /** @type {Counters} */
140
- const lowered = { ...recordedFile.counters };
236
+ } else if (mode === "update" && (decreased.length > 0 || added.length > 0) && recordedFile) {
237
+ const lowered = zero();
141
238
  for (const name of COUNTERS) lowered[name] = Math.min(recorded[name], current[name]);
142
- writeRatchet(path, { ...recordedFile, counters: lowered });
239
+ writeRatchet(path, { ...recordedFile, stackGlobs: recordedFile.stackGlobs ?? stackGlobs, counters: lowered });
143
240
  for (const name of decreased) report[name].recorded = lowered[name];
144
241
  written = true;
145
242
  }
@@ -150,10 +247,13 @@ export async function runRatchet({ root, mode = "check", globs, file = RATCHET_F
150
247
  mode,
151
248
  file: shown.startsWith("..") ? path : shown,
152
249
  files,
250
+ stackFiles,
153
251
  globs: useGlobs,
252
+ stackGlobs,
154
253
  counters: report,
155
254
  increased,
156
255
  decreased,
256
+ added,
157
257
  written,
158
258
  };
159
259
  }