@krizaka/config
v0.18.0
Published
The shared configuration of every Krizaka JavaScript repository: ESLint flat configs (base, Next.js, the four UI rules, the stack rules, no locale ternary), the « Krizaka first » covered-domains map and the dependency-cruiser preset generated from it (ref
Maintainers
Readme
@krizaka/config
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 krizaka-ratchet, the counter that keeps the UI and stack debts from coming back.
Adopt it in three lines
npm install -D @krizaka/config eslint typescript # + eslint-config-next for a Next.js app, prettier if you format// eslint.config.mjs — a Next.js app (a library: `krizakaBase` from "@krizaka/config/eslint")
import { krizakaNext } from "@krizaka/config/eslint/next";
import { krizakaUi } from "@krizaka/config/eslint/krizaka-ui";
import { noLocaleTernary } from "@krizaka/config/eslint/no-locale-ternary";
import { krizakaStack } from "@krizaka/config/eslint/stack";
export default [...krizakaNext, ...krizakaUi(), ...krizakaStack(), ...noLocaleTernary()];// .dependency-cruiser.cjs — « Krizaka first », cycles, unresolvable imports, dev dependencies, plus the app's own rules
module.exports = { extends: "@krizaka/config/dependency-cruiser", forbidden: [] };// tsconfig.json — base · react-library · next · react-native
{ "extends": "@krizaka/config/tsconfig/next.json", "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"] }Prettier: export { default } from "@krizaka/config/prettier"; in prettier.config.js (120 columns, double quotes,
trailing commas), with the scripts "format": "prettier --write \"**/*.{ts,tsx,js,jsx,mjs,cjs,json,css}\"" and
"format:check" (the same with --check) and a .prettierignore for generated files, lockfiles and vendored code. The
krizaka-edit-check hook formats the edited file when the repository has Prettier and this config. EditorConfig: cp node_modules/@krizaka/config/.editorconfig ..
eslint ^9 and typescript ^5 or ^6 are peer dependencies; eslint-config-next (Next.js apps) and prettier are optional
peers. The plugins (typescript-eslint, eslint-plugin-react-hooks, eslint-plugin-simple-import-sort) come with
the package.
What is inside
| Export | What it gives |
| :-------------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| @krizaka/config/eslint | krizakaBase: @eslint/js + typescript-eslint recommended, React hooks, import ordering (a warning, autofixable), ignores of generated output, _-prefixed unused arguments allowed. |
| @krizaka/config/eslint/next | krizakaNext: eslint-config-next (core web vitals + TypeScript, which already carry typescript-eslint, React hooks and import) + the same basics. |
| @krizaka/config/eslint/krizaka-ui | krizakaUi(options?): the four UI rules below. Also krizakaUiRules and krizakaUiRestrictedSyntax(options?). |
| @krizaka/config/eslint/no-locale-ternary | noLocaleTernary(options?): krizaka/no-locale-ternary — no locale === "fr" ? … : … outside routing code. |
| @krizaka/config/eslint/ui-warnings | krizakaUiWarnings(options?): the same two debts as named plugin rules, as warnings — krizaka/no-light-variant (no light:: tokens do the theming) and krizaka/no-raw-palette (no bg-zinc-900, text-violet-400: a role; allow families). krizaka-ratchet counts them (light, palette), so a product adopts them now and brings the counts to 0. |
| @krizaka/config/eslint/stack | krizakaStack(options?): the three stack rules below (warnings). Also STACK_RULES. |
| @krizaka/config/eslint/plugin | krizakaPlugin: the krizaka plugin carrying every rule, for a hand-written config. |
| @krizaka/config/dependency-cruiser | The shared dependency-cruiser config, generated from the covered-domains map: one krizaka-first-<domain> rule per domain (dates, i18n, query, icons, ui), no-circular, not-to-unresolvable, not-to-dev-dep. .withOptions({ exemptFrom }) for a monorepo whose own packages are the facades. |
| @krizaka/config/covered-domains · /covered-domains.json | The covered-domains map (one data file) and its helpers: the message, the patterns, the Markdown table of « Use Krizaka first ». |
| krizaka-known-violations (bin) · @krizaka/config/known-violations | The known violations of dependency-cruiser, which only shrink, see below. |
| krizaka-server-client-boundary (bin) · @krizaka/config/server-client-boundary | Fails when a server-rendered module imports a function from a "use client" @krizaka/* module (React #441), see below. |
| @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"]). |
| @krizaka/config/prettier · .editorconfig | The formatting. |
| krizaka-ratchet (bin) | The debt counter, see below. |
The four UI rules
On every string literal inside a className (in cn(…), a ternary…), with no plugin — no-restricted-syntax
selectors:
- No raw palette colour —
bg-zinc-900,text-violet-400/80: use a role of@krizaka/tailwind(bg-surface-1,text-fg-secondary,border-border-default,text-accent,text-danger). - No
light:— a theme is a set of tokens; a dark island takes.theme-darkon its ancestor. - No arbitrary
[var(--…)]utility — the variable has a utility in@krizaka/tailwind. - No template literal in
className—cn(…)merges the classes and lets the override win.
krizakaUi({
allow: ["emerald"], // palette families tolerated for documented, isolated cases
files: ["**/*.{jsx,tsx}"], // default
ignores: ["app/og/**"], // e.g. generated OG images
severity: "error", // "warn" while the ratchet brings the debt down
});Flat config replaces a rule's options instead of concatenating them: a repository that already sets
no-restricted-syntax merges both lists with krizakaUiRestrictedSyntax(). no-locale-ternary is a plugin rule for
that reason — it composes with anything.
The effect rules (react-effects)
useEffect is for the outside world
(Oussama, 2026-10-27). Two rules of krizakaStack(), errors by default (effects: "error"); a repository with debt
passes effects: "warn" while krizaka-ratchet (asyncInEffect, derivedStateInEffect) brings the count to 0. They
read any file (a Vite/Tauri app has no "use client").
krizaka/no-async-in-effect— an effect that awaits, calls.then(,fetch(,invoke(,api.*or*Client.*: read withuseQuery, write withuseMutation(@krizaka/query), keyed by the selection. A subscribe-and-cleanup effect (listen(,addEventListener(,subscribe(,WebSocket… plus areturn) is never reported.krizaka/no-derived-state-in-effect— an effect whose only statements aresetX(…)calls: compute the value during render, or reset the component with akey.
Cognitive complexity (Sonar S3776)
On 2026-10-27 every failed quality gate came from Sonar's S3776 (krizaka/.github#319), and only Sonar checked it.
krizakaStack() now carries sonarjs/cognitive-complexity at 15 (eslint-plugin-sonarjs; the threshold is never
raised, the rule never disabled): split the function (early returns, extracted helpers, a lookup table instead of a
chain of if). Error by default (complexity: "error"); a repository with debt passes complexity: "warn" while
krizaka-ratchet (cognitiveComplexity) freezes the count of functions over 15 at --update time and fails any
increase. Test files (*.test.*, *.spec.*, __tests__/) are not counted, as Sonar does not scan them.
The Sonar layer (ADR-013)
krizakaStack() also enables the whole eslint-plugin-sonarjs recommended set (SonarCloud's JS/TS engine,
krizaka/.github#333), so Sonar's findings fail at lint time, before push. sonar: "error" by default; a repository with
debt passes sonar: "warn" while krizaka-ratchet freezes each rule's count (a sonar total and one sonarjs/<rule>
counter per rule with a violation) and fails any increase, or the first violation of a rule that had none. Test files are
not part of the layer (Sonar does not scan them). Rules that need type information (S2871 no-alphabetical-sort,
deprecation, different-types-comparison…) fire only when the lint runs with projectService.
Turned off, each for a stated reason (SONAR_OFF in @krizaka/config/eslint/sonar): no-fallthrough, no-labels,
no-empty-character-class, no-invalid-regexp, no-control-regex, no-regex-spaces, no-misleading-character-class,
no-delete-var, no-useless-catch, generator-without-yield, updated-const-var (ESLint core already enforces them),
no-unused-vars, unused-import (@typescript-eslint/no-unused-vars, tsc), no-extra-arguments (tsc),
link-with-target-blank (react/jsx-no-target-blank). cognitive-complexity keeps its own threshold and counter (above).
A rule is never switched off because it is noisy: fix the code or let the ratchet hold the count.
The three stack rules
From ADR-001 — the TanStack engines run headless, behind
@krizaka/* facades; a product never imports them. Warnings: krizaka-ratchet counts the same patterns, so an app
adopts them before it is clean and brings each count to 0 by the end of its adoption.
krizaka/no-fetch-in-client-effect— afetch/krizakaFetchinside auseEffect(oruseLayoutEffect) of a"use client"file: render the first screen in a Server Component, read the rest with@krizaka/query.krizaka/no-local-table-or-form— a<table>, or a<form>of more than one field (native fields and the field primitives of@krizaka/ui; a hidden or submit<input>is not a field): useDataTableandcreateKrizakaForm. A search box or a one-field newsletter stays a plain form.
krizakaStack({
files: ["**/*.{js,jsx,mjs,cjs,ts,tsx,mts,cts}"], // default
ignores: ["packages/**"], // e.g. the facades of a monorepo
severity: "warn", // default; "error" once the app's counts are at 0
maxFormFields: 1, // default
});« Krizaka first » — the refused imports (dependency-cruiser)
Part of the stack ADR and of krizaka-ui#609, in one tool: dependency-cruiser carries every refused import (an import
is an edge: it catches re-exports and dynamic imports, and the same file holds the layers); ESLint keeps the framework
rules above. The ESLint rule krizaka/no-direct-tanstack-import is retired: @tanstack/* is krizaka-first-query.
The covered-domains map is covered-domains.json, the single source. Each domain names its Krizaka home and what is
refused in an app; the preset generates krizaka-first-<domain> from it, and the page « Use Krizaka first » of
ui.krizaka.com is laid out from it (coveredDomainsMarkdown()).
| Domain | Krizaka home | Refused in an app | Rule |
| :------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------- | :-------------------- |
| Dates, times, local days | @krizaka/intl (formatting, localDay), @krizaka/time (arithmetic; a facade created on the first real need, krizaka-ts#76) | date-fns*, @date-fns/*, dayjs, luxon, moment, moment-timezone | krizaka-first-dates |
| Words, languages | @krizaka/i18n | i18next*, react-i18next, next-intl, next-i18next, react-intl, @formatjs/*, @lingui/*, i18n-js, typesafe-i18n | krizaka-first-i18n |
| Data fetching, cache, tables, forms, virtual lists | @krizaka/query, @krizaka/ui, @krizaka/pacer | @tanstack/* | krizaka-first-query |
| Icons | @krizaka/icons | lucide-react, react-icons, @heroicons/* | krizaka-first-icons |
| Interface primitives | @krizaka/ui | @radix-ui/*, radix-ui | krizaka-first-ui |
An entry is a package (dayjs, and its subpaths), a prefix (date-fns*) or a scope (@tanstack/*). It matches the
resolved file and the module as written when it cannot be resolved (a package the app does not declare), never a local
folder of the same name. A Krizaka package is exempt: it is the facade. In an app that is the packages in
node_modules; in a monorepo that holds its own Krizaka packages, say where they live:
// .dependency-cruiser.cjs of a monorepo (krizaka-ui, krizaka-ts)
module.exports = require("@krizaka/config/dependency-cruiser").withOptions({ exemptFrom: ["(^|/)packages/"] });Every message names the home and ends « Missing function? add it to the package first. » — a missing function is added
to the Krizaka package, never worked around with the library. Run it with depcruise --output-type err-long <folders>
to read the message (err prints the rule name only). Beside the domains, the preset owes every repository
no-circular, not-to-unresolvable (declare the dependency, or fix the path; set options.tsConfig for path aliases)
and not-to-dev-dep (tests, stories, scripts and tool configs may import devDependencies; a package that is also a peerDependency is installed by the consumer, so production code may import it). Run depcruise from the
workspace that owns the package.json.
Known violations (they only shrink)
An app that adopts the rules late records what it already breaks, and the debt only goes down:
krizaka-known-violations init src app # once, in the adoption pull request: writes .dependency-cruiser-known-violations.json
depcruise src app --config .dependency-cruiser.cjs --ignore-known .dependency-cruiser-known-violations.json # lint: new violations fail
krizaka-known-violations check src app # lint: also fails on a recorded violation that is fixed ("lock the gain")
krizaka-known-violations update src app # removes the fixed ones; NEVER adds one, and fails while a new one existsThe file is the format of depcruise-baseline, so --ignore-known reads it. init refuses to overwrite it; there is
no command that records a new violation. It replaces the directTanstackImport counter of krizaka-ratchet, which
stays only for repositories that have not moved yet.
noLocaleTernary({ files, routing }): routing lists the files allowed to compare locales (default: proxy.ts,
middleware.ts, i18n.ts, seo.ts, app/[locale]/layout.tsx, I18nProvider.tsx).
Server/client boundary (krizaka-server-client-boundary)
A Next.js server module that calls a function exported by a "use client" module (readFilterParams() of
@krizaka/ui/filter-bar) crashes the page with React error #441. Unit tests ignore the directive and next build does not
see it. Every Next.js App Router app runs the guard in its lint:
krizaka-server-client-boundary # from the app's folder: reads app/ or src/app/ and the "@/*" alias of tsconfig.json
krizaka-server-client-boundary --root apps/web --app app --scope @krizakaIt walks the pages, layouts and routes, then every local module they import, and stops at a "use client" file. A lowercase
value import (a function, a hook, a constant) from a client module of the scope is an offence; components and import type
are fine. Render a component, or move the function to a plain module. checkBoundary() is also exported for a test.
Environment variable names (krizaka/env-name)
Environment variables are named by bounded context (krizaka/.github#46): <CONTEXT>_<THING> with CONTEXT one of
APP_ DATABASE_ AUTH_ MEDIA_ MAIL_ PUSH_ PAYMENTS_ REALTIME_ ABUSE_ OBSERVABILITY_ AI_ OPERATOR_.
The rule — on in krizakaBase and krizakaNext — refuses a name read through process.env.X, process.env["X"] or
import.meta.env.X that starts (after NEXT_PUBLIC_, EXPO_PUBLIC_ or VITE_) with:
- a product: the organisation's name, the repository's own (the first word of its package.json name,
acme-monorepo→ACME_), or one you name — a deployment already says which product it is; - a vendor (
BUNNY_,MAILGUN_,RESEND_,STRIPE_,GOOGLE_,EXPO_,REDIS_…): the vendor is a value (MEDIA_STORAGE_PROVIDER=bunny,MAIL_PROVIDER=mailgun) and its settings sit under the context (MEDIA_STORAGE_API_KEY,MAIL_API_KEY).
The platforms' own names (GITHUB_TOKEN, EAS_BUILD, …) pass. Tune it with
envNames({ products, vendors, allow }) from @krizaka/config/eslint/env-names; envNamePrefixProblem(name) gives
the same verdict to a script (a .env.example or deployment spec check).
The ratchet
A repository with debt cannot turn the rules on in one pull request. The ratchet lets it adopt them now: it counts
the four UI debts and the four stack debts, records the counts in lint-ratchet.json at the root, and fails when a
count goes up. Every migration pull request lowers them; when a count reaches zero, the matching rule becomes a
strict error.
npx krizaka-ratchet --init # once: creates lint-ratchet.json with today's counts — commit it
npx krizaka-ratchet # locally: compares (exit 1 when a count went up)
npx krizaka-ratchet --update # after a migration: lowers the recorded counts (never raises them), records new counters — commit it
npx krizaka-ratchet --json # in CI: the same check, machine-readable| Counter | Pattern (same regex as the ESLint rule) |
| :------------------------ | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| palette | \b(bg\|text\|border\|from\|via\|to\|ring\|shadow\|fill\|stroke)-(zinc\|slate\|…\|indigo)-[0-9] |
| light | \blight: |
| arbitraryVar | \[var\(-- |
| classNameTemplate | className=\{\s*` |
| fetchInClientEffect | in a file opening with "use client": fetch( / krizakaFetch( between the parentheses of a useEffect( / useLayoutEffect( |
| asyncInEffect | an effect (useEffect( / useLayoutEffect(, any file) whose callback awaits or calls .then(, fetch(, invoke(, api.*, *Client.*, and is not a subscribe-and-cleanup |
| derivedStateInEffect | an effect whose only statements are setX(…) calls (optionally under if) |
| directTanstackImport | from / import / import( / require( of "@tanstack/…" |
| localTableOrForm | <table, and <form … </form> holding more fields than maxFormFields (default 1) |
| retiredImport | an import of a dependency listed in retire (e.g. ["framer-motion"]): the way out of a dependency |
| cognitiveComplexity | functions whose sonarjs/cognitive-complexity is above 15 (Sonar S3776), counted by running the rule on each source file of stackGlobs, tests excluded; the KPI of the cognitive debt, it only goes down |
| sonar, sonarjs/<rule> | violations of the Sonar layer (the eslint-plugin-sonarjs recommended set, minus SONAR_OFF and cognitive complexity), tests excluded: a total, and one counter per rule with a violation (an absent rule is 0); the KPI of the Sonar-at-lint debt, it only goes down |
| routeStateSelection | a Tabs / chip group / choice grid / segmented switch (incl. RadioGroup.Root variant="segmented") / toggle group of a view driven by local state (value, defaultValue, onValueChange) without routeKey, and without // @ephemeral: <reason>: the URL is the state |
The UI counters scan app/**/*.tsx, components/**/*.tsx and src/**/*.tsx by default (node_modules, dist,
.next never), or the globs given on the command line; the stack counters scan every source file (js, jsx, mjs,
ts, tsx, mts) of app, components, src, lib, hooks and features (stackGlobs). --init stores both
in the file so that every later run counts the same set. A file written before the stack counters still checks; the
next --update records them at their current value.
The counts apply the regex to the whole source text — a class held in a constant or a cn() argument counts too —
so the ratchet is never looser than the rule. Exit codes: 0 ok, 1 a count went up, 2 usage or file error.
// lint-ratchet.json
{
"globs": ["app/**/*.tsx", "components/**/*.tsx", "src/**/*.tsx"],
"stackGlobs": ["app/**/*.{js,jsx,mjs,ts,tsx,mts}", "…"],
"allow": [], // optional: same families as krizakaUi({ allow })
"retire": ["framer-motion"], // optional: dependencies on their way out
"maxFormFields": 1, // optional: same option as krizakaStack({ maxFormFields })
"counters": {
"palette": 2400,
"light": 1151,
"arbitraryVar": 0,
"classNameTemplate": 128,
"fetchInClientEffect": 41,
"directTanstackImport": 3,
"localTableOrForm": 38,
"retiredImport": 4,
},
}The CI step, after the install:
- run: npx krizaka-ratchet --jsonThe workspace guard (krizaka-workspace-guard)
In a pnpm workspace, a dependency that names another package of the same workspace is written workspace:, never a registry range: a range goes stale once the dependency is versioned (^0.1.0 against 0.4.0), changeset version fails and no Version Packages pull request is opened. Run npx --yes -p @krizaka/config krizaka-workspace-guard from the repository root (it reads pnpm-workspace.yaml); it exits 1 and lists each offending dependency. Peer ranges are a published contract and stay. Rule workspace-guard of the org registry (krizaka/.github#388).
License
Code rules (krizaka/.github#422)
Readable shapes, enforced by lint and frozen by the ratchet. krizakaStack carries them (option codeRules: "error" by default, "warn" for a repository with debt, "off"), or alone: import { krizakaCodeRules } from "@krizaka/config/eslint/code-rules".
curly: all:if,for,while,elsealways take{}(eslint --fixadds them).max-depth: 2andmax-nested-callbacks: 3: above that, extract a named function, or write aflatMap/filter/mappipeline that returns the result.no-consolein runtime code (app,src,lib,components, a package'ssrc; not scripts, tools, configs, tests): logs go through the shared logger.- ast-grep
core:no-sync-fs-in-runtime(fs/promisesandPromise.allinstead ofreadFileSyncand friends; sync stays fine in scripts, tools, configs, tests) andno-hand-written-validation-regex(a date, email, URL, slug, phone or postal code isisIsoDateof@krizaka/time,isEmail,isHttpUrl,isSlug,isE164Phone,isCanadianPostalCodeof @krizaka/time). krizaka-ratchetcounts them per rule (code/curly,code/max-depth,code/max-nested-callbacks,code/no-console; totalcodeRules);krizaka-sg-ratchetcounts the two ast-grep rules. Both only go down.- Constants out of the logic:
no-unjustified-numeric-export(core) already refuses an unjustified numeric export; words belong in messages, numbers in the domain's settings module.
ast-grep pack and the SEO check
One source for the code rules (ADR-013, docs/standards/guards.md): the repositories carry no copy of sg-ratchet.mjs, of the rules or of check-seo-family.mjs.
Rules by set, files in
node_modules/@krizaka/config/ast-grep/<set>:core(sync fs in runtime code, hand-written validation regex, hand-written time arithmetic, ambient clock, env and db in domain code, enum compare,enum, numeric exports,types.tsat a package root),web(hand-made nav, hand-made language switch, hard-coded default media),html(rawinnerHTML). The domain rules readpackages/*/src,apps/*/lib,srcandlib.A repository's
sgconfig.ymlnames the sets it adopts and its own rules (each local rule is a file inrules/with its test inrule-tests/, and a comment saying why it is not shared):ruleDirs: - node_modules/@krizaka/config/ast-grep/core - node_modules/@krizaka/config/ast-grep/web - rules testConfigs: - testDir: rule-tests languageGlobs: Tsx: ["*.ts", "*.mts"]krizaka-sg-ratchetrunsast-grep scan, counts the violations per rule and refuses any increase againstsg-ratchet.json(--updatelowers the counts, records a new rule, never raises one). The rules are those of theruleDirs.krizaka-seo-family --base http://localhost:3000 --host https://www.example.com …is the SEO contract of every site of the family (robots.txt, sitemap, llms.txt, canonical, Open Graph, JSON-LD, noindex); its flags are in the header ofbin/krizaka-seo-family.mjs.The rules' tests are in
ast-grep/rule-testsand run with this package'spnpm test.
