utils/ts_config/README.md
Krzysztof kuhy Rudnicki 6c71d531bb repo contract + coverage_gaps gates, test_changed, dependency bumps
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 16:27:28 +02:00

5.9 KiB

@kuhyx/ts-config

The one lint / TypeScript bar every kuhyx TypeScript repo extends. Plain JS and JSON, no build step: a git tag is the release.

Born 2026-09-16 from Evil Martians' Ten anti-AI-slop moves for frontend projects: every rule here is a deterministic gate that decides with an exit code what a reviewer would otherwise have to notice in a model-written diff. Before this package the ten repos carried hand-copied eslint.config.js files in three different strictness tiers.

Install

pnpm only (npm cannot install a subdirectory of a git repo).

// package.json
"devDependencies": {
  "@kuhyx/ts-config": "github:kuhyx/utils#ts_config-v0.1.2&path:/ts_config",
  "eslint": "10.10.0",
  "typescript": "6.0.3",
  "typescript-eslint": "8.70.0"
}

The three peers are the consumer's; every plugin is a dependency of this package so a consumer never lists one. Inside this monorepo ts_core and web_ui use link:../ts_config instead (they track HEAD; their CI installs this directory first because link: installs nothing inside it) — a deliberate split, the same as crdt-sync's tag-pinned library / editable apps. Bump every consumer to the same typescript-eslint -- two copies in one resolution tree are two plugin instances, and flat config rejects the second.

Use

// tsconfig.json
{ "extends": "@kuhyx/ts-config/tsconfig", "compilerOptions": { "lib": ["ES2023", "DOM"], "jsx": "react-jsx", "paths": { "@/*": ["./src/*"] } } }
// eslint.config.js
import { defineConfig, folderElements } from "@kuhyx/ts-config";

export default defineConfig({
  tsconfigRootDir: import.meta.dirname,
  react: true,
  boundaries: { elements: folderElements(["components", "services", "utils", "types"]) },
  // Repo-specific overrides go last and each carries the reason it exists.
  overrides: [],
});

Knip has no extends (its schema rejects the key), so knip.ts spreads the base and adds the repo's entries:

import type { KnipConfig } from "knip";
import base from "@kuhyx/ts-config/knip" with { type: "json" };
type KnipSettings = Exclude<KnipConfig, (...parameters: never) => unknown>;
const config: KnipSettings = { ...(base as KnipSettings), entry: ["scripts/*.mjs"] };
export default config;

.jscpd.json: copy jscpd.base.json (jscpd has no extends either). stryker.config.mjs: import { strykerBase } from "@kuhyx/ts-config/stryker"; export default { ...strykerBase };

Adopting the alias rule in an existing repo

base() bans parent-relative imports, so a repo with dozens of ../ paths needs them rewritten before the rule can go on. That is a script, not a session's worth of hand edits:

node ~/src/utils/ts_config/scripts/alias-imports.mjs <repo-root> [src-dir] [extra-dir...]

Every import / export … from / import() specifier under src/ that starts with ../ and resolves inside src/ becomes @/<path from src>; ./sibling, package imports and a ../ that leaves src/ are left alone (the last one is the lint report's to surface). Extensions are carried over untouched. Idempotent. Extra directories (tests, scripts) are walked too and aliased against src. The repo still needs the alias itself: paths in tsconfig (see above) and resolve.alias in vite.config, which vitest reads. test/alias-imports.test.js runs it over a fixture tree.

What is in it

Export Content
./tsconfig strict, noUncheckedIndexedAccess, exactOptionalPropertyTypes and the rest of the strictness block. No output options.
defineConfig() base + optional react / vitest (default on) / playwright / boundaries + overrides.
base() typescript-eslint strict+stylistic type-checked, unicorn (all), sonarjs, perfectionist; no-explicit-any, no-non-null-assertion, explicit-module-boundary-types; kebab-case filenames; alias-only imports (aliasOnly: false to drop); the house "off" list, each with its reason (overrides.js).
react react-hooks, @eslint-react (type-checked), react-refresh, jsx-a11y strict, react-you-might-not-need-an-effect.
vitestConfig expect-expect, no-focused-tests, no-disabled-tests, valid-expect + the test-file relaxations.
playwrightConfig conditionals in tests, missing await, .only.
boundaries() eslint-plugin-boundaries with the article's default policies (components→services→utils; router→pages; design-system isolated; mocks test-only). folderElements([...]) builds the element list for a src/<layer>/ tree; allow: { layer: [...] } overrides one layer.
./eslint/fast Type-free subset for the editor hook: 0.52 s cold on one file (base without types: 1.28 s). Report-only use.
./knip, ./jscpd, ./stryker Dead code, duplicate code, mutation testing bases. Stryker has no break threshold on purpose: a detector, not a KPI.

What the tests prove

test/fixture/ is a mini repo with one planted violation per rule family (any, !, .only, a test with no assertion, a parent-relative import, services→components, router→utils, design-system→services, a page importing a mock, a derived-state effect, an img without alt, a conditional in an e2e test, a Wrong_Name.ts). test/expected.js names the rule that must catch each; the suite runs the real CLI over the fixture and fails if a plant is missed or a clean file is flagged. pnpm coverage holds the factories at 100% branches.

Known limits

  • eslint-plugin-jsx-a11y 6.10.2 declares peer ESLint ≤ 9. It runs cleanly under 10 (the fixture proves alt-text and click-events-have-key-events); if a release starts crashing, the React layer is where it comes out.
  • eslint-plugin-react is deliberately absent (calls context.getFilename(), removed in ESLint 10). @eslint-react covers it.
  • TypeScript stays on 6.0.x: typescript-eslint caps at <6.1.0. Allowlisted in ../dependency-freshness.allowlist.yaml.