Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|---|---|---|
| .. | ||
| eslint | ||
| scripts | ||
| test | ||
| .gitignore | ||
| .pre-commit-config.yaml | ||
| eslint.config.js | ||
| jscpd.base.json | ||
| knip.base.json | ||
| package.json | ||
| pnpm-lock.yaml | ||
| pnpm-workspace.yaml | ||
| README.md | ||
| stryker.base.mjs | ||
| tsconfig.base.json | ||
| vitest.config.js | ||
@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-a11y6.10.2 declares peer ESLint ≤ 9. It runs cleanly under 10 (the fixture provesalt-textandclick-events-have-key-events); if a release starts crashing, the React layer is where it comes out.eslint-plugin-reactis deliberately absent (callscontext.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.