@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 +77 -11
- package/bin/krizaka-ratchet.mjs +17 -6
- package/dependency-cruiser.cjs +31 -0
- package/eslint/env-names.js +27 -0
- package/eslint/index.js +5 -1
- package/eslint/no-locale-ternary.js +3 -30
- package/eslint/plugin.js +17 -0
- package/eslint/rules.js +266 -0
- package/eslint/stack.js +40 -0
- package/package.json +12 -4
- package/patterns.js +84 -0
- package/ratchet/ratchet.js +130 -30
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,
|
|
5
|
-
UI
|
|
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
|
[](https://www.npmjs.com/package/@krizaka/config)
|
|
8
8
|
[](../../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
|
|
80
|
-
the four debts, records the counts in `lint-ratchet.json` at the root, and **fails when a
|
|
81
|
-
migration pull request lowers them; when a count reaches zero, the matching rule becomes a
|
|
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
|
-
|
|
98
|
-
|
|
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
|
-
"
|
|
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
|
|
package/bin/krizaka-ratchet.mjs
CHANGED
|
@@ -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
|
|
10
|
-
source files (default: app/**/*.tsx components/**/*.tsx src/**/*.tsx) and
|
|
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
|
|
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(
|
|
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 (
|
|
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,
|
|
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
|
-
|
|
6
|
+
import { krizakaPlugin } from "./plugin.js";
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
export
|
|
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([
|
package/eslint/plugin.js
ADDED
|
@@ -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;
|
package/eslint/rules.js
ADDED
|
@@ -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
|
+
};
|
package/eslint/stack.js
ADDED
|
@@ -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.
|
|
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": "
|
|
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
|
+
}
|
package/ratchet/ratchet.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// The ratchet: counts the
|
|
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 {
|
|
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"}
|
|
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
|
-
/**
|
|
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
|
|
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<
|
|
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 {
|
|
76
|
+
* @returns {Record<UiCounterName, number>}
|
|
40
77
|
*/
|
|
41
78
|
export function countSource(source, allow = []) {
|
|
42
79
|
const patterns = counterPatterns(allow);
|
|
43
|
-
/** @type {
|
|
80
|
+
/** @type {Record<UiCounterName, number>} */
|
|
44
81
|
const counters = { palette: 0, light: 0, arbitraryVar: 0, classNameTemplate: 0 };
|
|
45
|
-
for (const name of
|
|
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
|
|
51
|
-
*
|
|
52
|
-
* @
|
|
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({
|
|
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
|
-
|
|
57
|
-
const counters =
|
|
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
|
|
136
|
+
for (const name of UI_COUNTERS) counters[name] += found[name];
|
|
61
137
|
}
|
|
62
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
121
|
-
const
|
|
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
|
-
|
|
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
|
}
|