utils/ts_config
2026-09-26 16:50:22 +02:00
..
eslint ts_config 0.1.2: React-layer carve-outs for JSX arrow returns and *Ref names, knip base trimmed, fromBase64 off 2026-09-16 22:53:21 +02:00
scripts ts_config 0.1.1: alias-imports codemod rewrites ../ imports under src/ to @/ 2026-09-16 22:27:50 +02:00
test ts_config 0.1.2: React-layer carve-outs for JSX arrow returns and *Ref names, knip base trimmed, fromBase64 off 2026-09-16 22:53:21 +02:00
.gitignore ts_config: @kuhyx/ts-config, the one lint/TS bar every TypeScript repo extends 2026-09-16 21:40:08 +02:00
.pre-commit-config.yaml ts_config: @kuhyx/ts-config, the one lint/TS bar every TypeScript repo extends 2026-09-16 21:40:08 +02:00
eslint.config.js ts_config: @kuhyx/ts-config, the one lint/TS bar every TypeScript repo extends 2026-09-16 21:40:08 +02:00
jscpd.base.json ts_config: @kuhyx/ts-config, the one lint/TS bar every TypeScript repo extends 2026-09-16 21:40:08 +02:00
knip.base.json ts_config 0.1.2: React-layer carve-outs for JSX arrow returns and *Ref names, knip base trimmed, fromBase64 off 2026-09-16 22:53:21 +02:00
package.json Take today's stable bumps: pylint 4.0.9, ruff 0.16.9, vitest 5.0.2, jsdom 30.1.1, eslint-react 5.20.8, perfectionist 5.12.1, pnpm 12.6.0 2026-09-26 16:50:22 +02:00
pnpm-lock.yaml Take today's stable bumps: pylint 4.0.9, ruff 0.16.9, vitest 5.0.2, jsdom 30.1.1, eslint-react 5.20.8, perfectionist 5.12.1, pnpm 12.6.0 2026-09-26 16:50:22 +02:00
pnpm-workspace.yaml ts_config: project option, entry-point barrel and className carve-outs 2026-09-16 21:45:59 +02:00
README.md ts_config 0.1.2: React-layer carve-outs for JSX arrow returns and *Ref names, knip base trimmed, fromBase64 off 2026-09-16 22:53:21 +02:00
stryker.base.mjs ts_config: @kuhyx/ts-config, the one lint/TS bar every TypeScript repo extends 2026-09-16 21:40:08 +02:00
tsconfig.base.json ts_config: @kuhyx/ts-config, the one lint/TS bar every TypeScript repo extends 2026-09-16 21:40:08 +02:00
vitest.config.js ts_config 0.1.1: alias-imports codemod rewrites ../ imports under src/ to @/ 2026-09-16 22:27:50 +02:00

@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]

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. 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.