ts_config: @kuhyx/ts-config, the one lint/TS bar every TypeScript repo extends

Evil Martians' "ten anti-AI-slop moves" as a package: strict tsconfig base
(noUncheckedIndexedAccess, exactOptionalPropertyTypes), type-checked
typescript-eslint strict+stylistic, unicorn/sonarjs/perfectionist, behaviour
rules for tests (expect-expect, no-focused-tests), React (hooks, @eslint-react,
jsx-a11y strict, you-might-not-need-an-effect), Playwright, and layer
boundaries via eslint-plugin-boundaries 7 with the article's default policies.
Knip / jscpd / Stryker bases; Stryker carries no break threshold on purpose.

Plugins are dependencies, not devDependencies: a flat config imports them from
this package's own node_modules, so a consumer installing from the git tag
gets them or the lint cannot run. No build step, so the tag needs no prepare.

test/fixture is a mini repo with one planted violation per rule family;
test/expected.js names the rule that must catch each, and the suite runs the
real CLI over it (in a child process -- v8 coverage merges natively-loaded
and transformed copies of the same module into "uncovered"). 35 tests, 100%
branch coverage on the factories.

eslint/fast.js is the type-free subset for a per-edit hook: 0.52 s cold on
one file, measured against 1.28 s for base() without types -- importing a
plugin is the cost, so it loads none, nor base.js which imports them.

Probed under ESLint 10: jsx-a11y 6.10.2 (peer says <=9) and boundaries 7.2.0
both run; the fixture pins that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnAYyNH1Sm4iJNidfYbrZJ
This commit is contained in:
Krzysztof Rudnicki 2026-09-16 21:40:08 +02:00
parent d531b733b8
commit 128ede438e
42 changed files with 5804 additions and 0 deletions

36
.github/workflows/ts-config-tests.yml vendored Normal file
View File

@ -0,0 +1,36 @@
name: ts_config tests
on:
push:
branches: [main]
paths: ['ts_config/**']
pull_request:
branches: [main]
paths: ['ts_config/**']
jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ts_config
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 12
- uses: actions/setup-node@v4
with:
node-version: "24.21.0"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint (the preset lints itself)
run: pnpm lint
- name: Fixture + factory tests with the 100% coverage gate
run: pnpm coverage

View File

@ -96,3 +96,20 @@ component references (`components.html`, `button.html`). Read
`DOCS-tokens.md` before touching any theme/CSS/style file in any repo. The
`unified-design-system` Claude Code skill is a thin pointer here, not a
duplicate.
## ts_config/
`@kuhyx/ts-config` — the one lint / TypeScript bar every kuhyx TypeScript
repo extends (pnpm only, like `ts_core` and `web_ui`). A strict
`tsconfig.base.json`, ESLint flat-config presets (`defineConfig()` stacking
base / react / vitest / playwright / layer `boundaries()`), a type-free
`eslint/fast` subset for editor hooks, and Knip / jscpd / Stryker bases.
Plain JS, no build step; the git tag is the release. Every plugin is a
`dependency` of the package so consumers list none. A fixture repo with one
planted violation per rule family proves each gate bites.
```jsonc
"devDependencies": {
"@kuhyx/ts-config": "github:kuhyx/utils#ts_config-v0.1.0&path:/ts_config"
}
```

2
ts_config/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
node_modules/
coverage/

View File

@ -0,0 +1,54 @@
# Pre-commit Configuration for ts_config
# Install: pre-commit install && pre-commit install --hook-type pre-push
# Run: pre-commit run --config ts_config/.pre-commit-config.yaml --all-files
# NOTE: this subproject lives inside the ~/src/utils monorepo, whose git root is
# one level up. pre-commit always runs hooks from the git root, so every path
# below is written relative to THAT, not to this directory, and the top-level
# `files` key keeps these hooks off the sibling subprojects. Mirrors
# web_ui/.pre-commit-config.yaml.
files: ^ts_config/
# The fixture exists to violate every rule; formatting hooks must not "fix" it.
exclude: ^ts_config/test/fixture/
default_stages: [pre-commit]
fail_fast: false
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
args: [--markdown-linebreak-ext=md]
- id: end-of-file-fixer
- id: check-yaml
# pnpm 12 writes the lockfile as two YAML documents.
args: [--allow-multiple-documents]
- id: check-json
# tsconfig*.json are JSONC -- TypeScript allows comments.
exclude: ^ts_config/tsconfig.*\.json$
- id: check-merge-conflict
- id: mixed-line-ending
args: [--fix=lf]
- repo: local
hooks:
- id: file-length-cap
name: file length <= 250 lines
entry: bash scripts/check_file_length.sh
language: system
pass_filenames: true
- id: ts-config-lint
name: ts_config eslint (the preset lints itself)
entry: bash -c 'cd ts_config && pnpm lint'
language: system
pass_filenames: false
files: ^ts_config/.*\.(js|mjs|json)$
- id: ts-config-test
name: ts_config fixture + factory tests, 100% coverage
entry: bash -c 'cd ts_config && pnpm coverage'
language: system
pass_filenames: false
stages: [pre-push]

88
ts_config/README.md Normal file
View File

@ -0,0 +1,88 @@
# `@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).
```jsonc
// package.json
"devDependencies": {
"@kuhyx/ts-config": "github:kuhyx/utils#ts_config-v0.1.0&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. 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
```jsonc
// tsconfig.json
{ "extends": "@kuhyx/ts-config/tsconfig", "compilerOptions": { "lib": ["ES2023", "DOM"], "jsx": "react-jsx", "paths": { "@/*": ["./src/*"] } } }
```
```js
// 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.json`: `{ "extends": "@kuhyx/ts-config/knip" }`. `.jscpd.json`: copy
`jscpd.base.json` (jscpd has no extends). `stryker.config.mjs`:
`import { strykerBase } from "@kuhyx/ts-config/stryker"; export default { ...strykerBase };`
## 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`.

View File

@ -0,0 +1,10 @@
// The preset lints itself. Everything here is plain JS, so the type-aware
// layer is off by construction; the fixture is excluded because its whole
// job is to violate.
import { defineConfig } from "./eslint/index.js";
export default defineConfig({
aliasOnly: false,
ignores: ["test/fixture"],
tsconfigRootDir: import.meta.dirname,
});

110
ts_config/eslint/base.js Normal file
View File

@ -0,0 +1,110 @@
// The type-aware baseline every kuhyx TypeScript repo lints against.
//
// typescript-eslint's *type-checked* strict + stylistic presets, so anything
// they flag is an error, not a warning. On top: unicorn (all), sonarjs
// (cognitive complexity / duplicate logic), perfectionist (sorting). Unused
// disable directives are themselves errors, so every `eslint-disable` in a
// consumer must be load-bearing.
//
// Split by concern so no file crosses the 250-line cap: this file wires the
// presets and the type-aware parser; overrides.js holds the house "off" list
// (each with its reason); naming.js and imports.js hold the rules the article
// calls "structural" -- filenames matching exports, imports only through
// aliases.
//
// Deliberately absent:
// - eslint-plugin-react: 7.37.x still calls context.getFilename(), removed
// in ESLint 10, so it crashes the run. @eslint-react covers the ground
// (see the React layer).
//
// TypeScript stays on 6.0.x, not 7.x: typescript-eslint requires
// "typescript >=4.8.4 <6.1.0", and losing every type-aware rule would be a far
// bigger regression than gaining the Go-native compiler. The pin is
// allowlisted in utils/dependency-freshness.allowlist.yaml.
import js from "@eslint/js";
import perfectionist from "eslint-plugin-perfectionist";
import sonarjs from "eslint-plugin-sonarjs";
import unicorn from "eslint-plugin-unicorn";
import globals from "globals";
import tseslint from "typescript-eslint";
import { DEFAULT_IGNORES } from "./ignores.js";
import { imports } from "./imports.js";
import { naming } from "./naming.js";
import { overrides } from "./overrides.js";
import { requireNonEmptyString } from "./require.js";
/** Globs the type-aware rules apply to. */
export const TS_FILES = ["**/*.{ts,tsx,mts,cts}"];
export { DEFAULT_IGNORES } from "./ignores.js";
/**
* The baseline config array.
*
* @param {object} options
* @param {string} options.tsconfigRootDir Consumer's repo root
* (`import.meta.dirname` in its eslint.config.js). Required, because
* pre-commit runs ESLint from the Git root while control-panel and
* dufs-cloud keep their web app one level down -- cwd is not the root.
* @param {string[]} [options.ignores] Extra ignore globs.
* @param {boolean} [options.aliasOnly] Ban `../` parent-relative imports so
* every cross-directory import goes through the repo's `@/` alias. Default
* true; a repo with no alias configured turns it off with a reason.
*/
export function base({ aliasOnly = true, ignores = [], tsconfigRootDir }) {
requireNonEmptyString(tsconfigRootDir, "base() tsconfigRootDir");
return tseslint.config(
{ ignores: [...DEFAULT_IGNORES, ...ignores] },
{ linterOptions: { reportUnusedDisableDirectives: "error" } },
js.configs.recommended,
...tseslint.configs.strictTypeChecked,
...tseslint.configs.stylisticTypeChecked,
unicorn.configs.all,
sonarjs.configs.recommended,
perfectionist.configs["recommended-natural"],
{
files: TS_FILES,
languageOptions: {
parserOptions: { projectService: true, tsconfigRootDir },
},
rules: {
// `type X = {}` and `interface X {}` are interchangeable for the
// shapes this codebase writes; picking one keeps diffs quiet.
"@typescript-eslint/consistent-type-definitions": ["error", "interface"],
// Every exported symbol is read by someone who did not write it, so
// an explicit boundary type is documentation, not ceremony.
"@typescript-eslint/explicit-module-boundary-types": "error",
// `any` and `!` are how a model hides a hole in its reasoning. Both
// are already errors in strictTypeChecked; restating them here makes
// the intent survive a preset upgrade that softens either.
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-non-null-assertion": "error",
// Numbers in template literals are unambiguous and pervasive
// (counts, totals, grades).
"@typescript-eslint/restrict-template-expressions": [
"error",
{ allowNumber: true },
],
},
},
...naming,
...(aliasOnly ? imports : []),
...overrides,
// Plain JS config files get no type-aware linting, and they run under
// Node (eslint.config.js, vitest.config.js, scripts) so its globals are
// known rather than each `process` being an undefined variable.
{
extends: [tseslint.configs.disableTypeChecked],
files: ["**/*.{js,mjs,cjs}"],
languageOptions: { globals: globals.node },
},
);
}

View File

@ -0,0 +1,111 @@
// Layer boundaries: the dependency direction is declared, and the linter
// enforces it.
//
// A model keeps no architectural context between sessions and drifts toward
// the "GitHub average" -- a service that imports a component because that was
// the shortest path. This is the fence. Elements are per repo (they name real
// directories); the default policy set is the article's:
//
// components -> services, utils, types, design-system
// services -> utils, types
// utils / types / config -> types only (no domain logic underneath)
// design-system -> design-system, types (isolated from state, API, domain)
// router -> pages only
// mocks -> importable from tests only
//
// Anything not listed is disallowed, so an element type with no policy can
// import nothing outside itself -- adding a type means writing its policy.
import boundariesPlugin from "eslint-plugin-boundaries";
import { requireNonEmptyArray, requireNonEmptyString } from "./require.js";
/** The article's default policies, keyed by element type. */
export const DEFAULT_ALLOW = {
components: ["services", "utils", "types", "design-system", "components"],
config: ["types"],
"design-system": ["design-system", "types"],
mocks: ["types", "mocks"],
pages: ["components", "services", "utils", "types", "design-system", "pages"],
router: ["pages"],
services: ["utils", "types", "services"],
types: ["types"],
utils: ["utils", "types"],
};
/**
* Element descriptors for a conventional `src/<type>/` layout.
*
* v7 elements are folders: the pattern names the directory itself and every
* file below it belongs to that element. (`src/<type>/*` would make each
* *sub*-folder its own element and leave the files directly inside unknown.)
*
* @param {string[]} types Directory names under `src/` that are layers.
* @param {string} [root] Path prefix the directories live under.
*/
export function folderElements(types, root = "src") {
return types.map((type) => ({ pattern: `${root}/${type}`, type }));
}
/**
* @param {object} options
* @param {string} options.tsconfigRootDir Consumer repo root, so the TS
* resolver finds the `@/` alias in the consumer's tsconfig.
* @param {Array<{type: string, pattern: string}>} options.elements
* The repo's layers; `folderElements([...])` builds the common case.
* @param {Record<string, string[]>} [options.allow] Per-type allow lists.
* Merged over DEFAULT_ALLOW, so a repo overrides one layer, not all.
* @param {string[]} [options.testFiles] Globs that may import mocks.
*/
export function boundaries({
allow = {},
elements,
testFiles = ["**/*.test.*", "**/*.spec.*", "**/test/**"],
tsconfigRootDir,
}) {
requireNonEmptyArray(elements, "boundaries() elements");
requireNonEmptyString(tsconfigRootDir, "boundaries() tsconfigRootDir");
const merged = { ...DEFAULT_ALLOW, ...allow };
const known = new Set(elements.map((element) => element.type));
const policies = Object.entries(merged)
.filter(([from]) => known.has(from))
.map(([from, to]) => {
const anyOf = to.filter((type) => known.has(type));
return { allow: { to: { element: { types: { anyOf } } } }, from: { element: { type: from } } };
});
return [
{
files: ["**/*.{ts,tsx,js,jsx,mts,cts}"],
plugins: { boundaries: boundariesPlugin },
rules: {
"boundaries/dependencies": [
"error",
{
default: "disallow",
policies: [
...policies,
// Tests may reach any layer, mocks included. Mocks appear in no
// production allow list, so a non-test module importing one is
// either dead code or a fixture leaking into the build.
{
allow: { to: { element: { types: { anyOf: [...known] } } } },
from: { file: { categories: "test" } },
},
],
},
],
},
settings: {
// Element patterns are matched relative to root-path, which defaults
// to process.cwd(). pre-commit runs from the Git root and the tests
// from the package dir, so the repo root is passed explicitly or the
// patterns match nothing and every file is "unknown".
"boundaries/elements": elements,
"boundaries/files": [{ category: "test", pattern: testFiles }],
"boundaries/root-path": tsconfigRootDir,
"import/resolver": {
typescript: { alwaysTryTypes: true, project: tsconfigRootDir },
},
},
},
];
}

33
ts_config/eslint/fast.js Normal file
View File

@ -0,0 +1,33 @@
// The editor-hook subset: the rules that catch the model's most common holes
// without type information and without loading the heavy plugins.
//
// Used by the Claude Code PostToolUse hook that reports (never fixes) lint
// findings on the file just edited, against a 1.0 s per-turn budget. The
// full type-aware bar stays in pre-commit; this is the same-session feedback
// the article calls "put the rules in front of the model".
//
// Measured cold on one fixture file (2026-09-16, this machine): bare
// typescript-eslint 0.50 s; +unicorn 0.87 s; +sonarjs 0.75 s; all of
// base() without types 1.28 s. Importing a plugin is the cost, not the
// number of its rules that run, so this file loads none of the three -- nor
// base.js, which imports them (that alone was +0.55 s).
import js from "@eslint/js";
import tseslint from "typescript-eslint";
import { DEFAULT_IGNORES } from "./ignores.js";
import { imports } from "./imports.js";
export default tseslint.config(
{ ignores: DEFAULT_IGNORES },
js.configs.recommended,
...tseslint.configs.strict,
...tseslint.configs.stylistic,
{
rules: {
"@typescript-eslint/consistent-type-definitions": ["error", "interface"],
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-non-null-assertion": "error",
},
},
...imports,
);

View File

@ -0,0 +1,5 @@
// Kept in its own module so fast.js can import it without pulling in
// base.js, whose plugin imports are what make a cold lint slow.
/** Directories no consumer wants linted; a consumer adds its own on top. */
export const DEFAULT_IGNORES = ["dist", "coverage", "node_modules", "build"];

View File

@ -0,0 +1,27 @@
// Structural rules: imports only through aliases.
//
// `../../services/api` encodes the importing file's depth in every consumer;
// move the file and every one of them breaks, and a model that guesses the
// depth wrong produces an import that resolves to the wrong module or to
// nothing. `@/services/api` is location-independent. Same-directory `./x`
// stays allowed: that is a sibling, not a traversal.
/** @type {import("eslint").Linter.Config[]} */
export const imports = [
{
rules: {
"no-restricted-imports": [
"error",
{
patterns: [
{
group: ["../*"],
message:
"Import through the repo alias (`@/…`) instead of a parent-relative path.",
},
],
},
],
},
},
];

53
ts_config/eslint/index.js Normal file
View File

@ -0,0 +1,53 @@
// One call for the common case, the pieces for everything else.
//
// import { defineConfig } from "@kuhyx/ts-config";
// export default defineConfig({
// tsconfigRootDir: import.meta.dirname,
// react: true,
// boundaries: { elements: folderElements(["components", "services"]) },
// });
import { base } from "./base.js";
import { boundaries } from "./boundaries.js";
import { playwrightConfig } from "./playwright.js";
import { react } from "./react.js";
import { vitestConfig } from "./vitest.js";
export { base } from "./base.js";
export { boundaries, DEFAULT_ALLOW, folderElements } from "./boundaries.js";
export { playwrightConfig } from "./playwright.js";
export { react } from "./react.js";
export { vitestConfig } from "./vitest.js";
/**
* @param {object} options
* @param {string} options.tsconfigRootDir `import.meta.dirname` of the
* consumer's eslint.config.js.
* @param {string[]} [options.ignores]
* @param {boolean} [options.aliasOnly]
* @param {boolean} [options.react] Stack the React layer.
* @param {boolean} [options.vitest] Stack the vitest rules (default true).
* @param {boolean} [options.playwright] Stack the Playwright rules.
* @param {object} [options.boundaries] Options for boundaries(); omitted
* means no layer enforcement (a repo that has not declared its layers).
* @param {import("eslint").Linter.Config[]} [options.overrides] Repo-specific
* config objects appended last, so they win. Each needs a reason comment.
*/
export function defineConfig({
aliasOnly,
boundaries: boundariesOptions,
ignores,
overrides = [],
playwright = false,
react: withReact = false,
tsconfigRootDir,
vitest = true,
}) {
return [
...base({ aliasOnly, ignores, tsconfigRootDir }),
...(withReact ? react : []),
...(vitest ? vitestConfig : []),
...(playwright ? playwrightConfig : []),
...(boundariesOptions ? boundaries({ tsconfigRootDir, ...boundariesOptions }) : []),
...overrides,
];
}

View File

@ -0,0 +1,31 @@
// Structural rules: a file's name tells you what it exports.
//
// The article's "filenames matching exports" move. Kebab-case files exporting
// PascalCase components is the house convention across every kuhyx web repo;
// a model that invents `UserCard.tsx` next to `user-list.tsx` has not read the
// tree, and the rule says so before review has to.
/** @type {import("eslint").Linter.Config[]} */
export const naming = [
{
rules: {
// Keep the rule, widen its vocabulary. `passesFilters(server, filter)`
// and `matchesQuery(...)` are predicates that read as English; forcing
// them to `isPassingFilters` would be worse prose in service of a prefix
// list. `passes`/`matches` are added, nothing is removed.
"unicorn/consistent-boolean-name": [
"error",
{ prefixes: { matches: true, passes: true } },
],
// Kebab-case files exporting PascalCase components: house convention.
"unicorn/filename-case": ["error", { case: "kebabCase" }],
// Keep the rule, drop one replacement: `props` is React's own word.
// `interface FilterBarProps` is the convention every React codebase and
// every reader expects; `FilterBarProperties` would be unidiomatic in
// service of a generic dictionary. Every other replacement stays on.
"unicorn/name-replacements": ["error", { replacements: { props: false } }],
},
},
];

View File

@ -0,0 +1,69 @@
// House overrides, applied to every file including the consumer's own config.
//
// Each rule below is switched off deliberately: these are the cases where
// "more aggressive" makes the code worse rather than safer. A rule you must
// disable at every use site is worse than no rule at all, because
// reportUnusedDisableDirectives then turns the disables into their own debt.
//
// Anything a single repo needs beyond this list stays in THAT repo's
// eslint.config.js with its own reason comment; it moves here only once three
// repos carry the same override.
/** @type {import("eslint").Linter.Config[]} */
export const overrides = [
{
rules: {
// Directly contradicts unicorn/consistent-class-member-order: one wants
// a private field before the getter that reads it, the other wants the
// getter first, and satisfying either re-triggers the other. unicorn's
// wins because "declare the field before the code that uses it" is a
// reason, whereas alphabetical order within a class is not.
"perfectionist/sort-classes": "off",
// Alphabetises declarations within a file, which actively fights reading
// order: it would scatter `GRADES -> gradeRank -> isTripleA` into
// `compositeRank -> GRADES -> gradeCoverage`. Alphabetical order is not
// narrative order. Every *other* perfectionist rule stays on -- sorting
// imports, union members and object keys destroys no narrative.
"perfectionist/sort-modules": "off",
// Wants JSDoc blocks written without leading `*`, which is neither the
// TypeScript convention nor what editors/typedoc render.
"unicorn/no-asterisk-prefix-in-documentation-comments": "off",
// Flags hand-wrapped comment prose. Comments are wrapped to 80 columns
// on purpose; the rule would force one long line per paragraph.
"unicorn/no-manually-wrapped-comments": "off",
// `null` is load-bearing wherever JSON crosses a boundary: JSON has no
// `undefined`, and "the API sent null" and "the field was absent" are
// different facts that UIs render differently. Collapsing them loses
// information.
"unicorn/no-null": "off",
// `Temporal` does not exist in the runtimes these repos target (Node 26
// reports `typeof Temporal === "undefined"`); the rule is unfollowable
// until it ships. Inputs are ISO-8601 strings, which Date.parse handles
// identically across engines -- the cross-engine inconsistency the rule
// warns about is for non-ISO strings.
"unicorn/prefer-temporal": "off",
// The domain vocabulary is abbreviated on purpose: `os`, `spdx`, `repo`,
// `env`, `rng` are the names the source data and the maths use, and
// expanding them would obscure the mapping.
"unicorn/prevent-abbreviations": "off",
// Rewrites `/** @type {X} */` into a `//` comment, which strips the type
// annotation TypeScript reads from JSDoc in plain-JS files. This package
// and every consumer's config file are plain JS.
"unicorn/single-line-block-comment-style": "off",
// Caps a try block at ONE statement. Unfollowable where the whole point
// is that several steps can throw: getItem (throws in private
// browsing), JSON.parse (throws on junk) and a shape check belong in one
// try because any of them failing means the same thing. The rule has no
// maximum option, so it is all or nothing.
"unicorn/try-complexity": "off",
},
},
];

View File

@ -0,0 +1,22 @@
// End-to-end specs. No kuhyx repo had Playwright on 2026-09-16; the preset
// ships so the first one that does gets the same bar without inventing it.
//
// The rules that matter most for model-written e2e: a conditional inside a
// test (the test passes whichever branch runs), a forgotten `await` on an
// async matcher (the assertion never fires), and `.only`.
import playwright from "eslint-plugin-playwright";
/** @type {import("eslint").Linter.Config[]} */
export const playwrightConfig = [
{
...playwright.configs["flat/recommended"],
files: ["**/e2e/**", "**/*.e2e.{ts,js}", "**/*.pw.{ts,js}"],
rules: {
...playwright.configs["flat/recommended"].rules,
"playwright/missing-playwright-await": "error",
"playwright/no-conditional-in-test": "error",
"playwright/no-focused-test": "error",
"playwright/no-skipped-test": "error",
},
},
];

48
ts_config/eslint/react.js vendored Normal file
View File

@ -0,0 +1,48 @@
// React layer, stacked on base().
//
// Four plugins, each covering ground the others do not:
// - react-hooks: the rules of hooks + exhaustive deps, plus React 19's
// compiler-derived checks (`recommended` in 7.x).
// - @eslint-react: component/JSX correctness, type-checked variant, and the
// replacement for eslint-plugin-react (which crashes under ESLint 10).
// - react-refresh: a file that mixes a component with a non-component
// export silently breaks Vite HMR for the whole module.
// - react-you-might-not-need-an-effect: the article names "an effect that
// derives state from props" as the single most frequent AI edit. The
// fixture's Button does exactly that, and this is the rule that sees it.
// - jsx-a11y: accessibility is "the first thing to go" when a model
// optimises for how the screen looks. Its 6.10.2 peer range stops at
// ESLint ^9, but under 10 it runs cleanly (verified by the fixture test),
// so the `strict` preset is on.
import eslintReact from "@eslint-react/eslint-plugin";
import jsxA11y from "eslint-plugin-jsx-a11y";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import youMightNotNeedAnEffect from "eslint-plugin-react-you-might-not-need-an-effect";
import { TS_FILES } from "./base.js";
/** @type {import("eslint").Linter.Config[]} */
export const react = [
{
...eslintReact.configs["recommended-type-checked"],
files: TS_FILES,
},
{
files: TS_FILES,
plugins: { "react-hooks": reactHooks },
rules: reactHooks.configs.recommended.rules,
},
{
files: ["**/*.tsx", "**/*.jsx"],
...jsxA11y.flatConfigs.strict,
},
{
files: TS_FILES,
...youMightNotNeedAnEffect.configs.recommended,
},
{
files: ["**/*.tsx", "**/*.jsx"],
...reactRefresh.configs.vite,
},
];

View File

@ -0,0 +1,30 @@
// Argument guards for the factories. A preset that silently accepts a missing
// repo root lints with the wrong project and reports nothing -- the worst
// failure mode for a gate -- so the factories refuse loudly instead.
/**
* @param {unknown} value
* @param {string} what Name used in the error message.
* @returns {string}
*/
export function requireNonEmptyString(value, what) {
const isPresent = typeof value === "string" && value !== "";
if (!isPresent) {
throw new TypeError(`@kuhyx/ts-config: ${what} is required (pass import.meta.dirname)`);
}
return value;
}
/**
* @template T
* @param {unknown} value
* @param {string} what
* @returns {T[]}
*/
export function requireNonEmptyArray(value, what) {
const isPresent = Array.isArray(value) && value.length > 0;
if (!isPresent) {
throw new TypeError(`@kuhyx/ts-config: ${what} needs a non-empty list`);
}
return value;
}

View File

@ -0,0 +1,59 @@
// Test files: the rules that catch a test which cannot fail, plus the
// relaxations that stop the strict presets fighting mocks and fixtures.
//
// `expect-expect` is the article's "tests with no assertions" -- a model
// writes `it("works", () => { render(<X/>) })` and it passes forever.
// `no-focused-tests` is the forgotten `.only` that silently skips the suite.
import vitest from "@vitest/eslint-plugin";
/** Globs vitest rules apply to. */
export const TEST_FILES = [
"**/*.test.{ts,tsx,js,jsx}",
"**/*.spec.{ts,tsx,js,jsx}",
"**/test/**",
"**/__tests__/**",
"**/__fixtures__/**",
];
const RULES = {
...vitest.configs.recommended.rules,
"vitest/expect-expect": "error",
"vitest/no-disabled-tests": "error",
"vitest/no-focused-tests": "error",
// vitest's `expect(value, message)` is how a loop of assertions names the
// case that failed; the rule's default of one argument would ban it.
"vitest/valid-expect": ["error", { maxArgs: 2 }],
};
/** @type {import("eslint").Linter.Config[]} */
export const vitestConfig = [
{
files: TEST_FILES,
plugins: { vitest },
rules: RULES,
},
// `typecheck` makes valid-title & co. read the TS type of a test's
// arguments; it needs parser services, which only the TS files have.
{
files: ["**/*.{ts,tsx,mts,cts}"],
settings: { vitest: { typecheck: true } },
},
{
files: TEST_FILES,
rules: {
"@typescript-eslint/no-empty-function": "off",
// Tests reach for `!` on queries that cannot return null in a passing
// test; asserting them again would only add noise.
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/require-await": "off",
"@typescript-eslint/unbound-method": "off",
"sonarjs/no-duplicate-string": "off",
// Test helpers live beside the tests that use them; hoisting every
// fixture factory to module scope to satisfy a scoping rule would
// scatter each test's setup away from the test.
"unicorn/consistent-function-scoping": "off",
// A no-op stub is the point of a stub.
"unicorn/no-empty-file": "off",
},
},
];

18
ts_config/jscpd.base.json Normal file
View File

@ -0,0 +1,18 @@
{
"absolute": true,
"exitCode": 1,
"format": ["typescript", "tsx", "javascript"],
"gitignore": true,
"ignore": [
"**/node_modules/**",
"**/dist/**",
"**/coverage/**",
"**/*.test.*",
"**/*.spec.*",
"**/__fixtures__/**"
],
"minLines": 10,
"minTokens": 70,
"reporters": ["consoleFull"],
"threshold": 0
}

18
ts_config/knip.base.json Normal file
View File

@ -0,0 +1,18 @@
{
"$schema": "https://unpkg.com/knip@6/schema.json",
"ignore": ["**/dist/**", "**/coverage/**", "**/*.d.ts"],
"ignoreDependencies": ["@kuhyx/ts-config"],
"ignoreExportsUsedInFile": true,
"rules": {
"binaries": "error",
"dependencies": "error",
"devDependencies": "error",
"duplicates": "error",
"enumMembers": "error",
"exports": "error",
"files": "error",
"types": "error",
"unlisted": "error",
"unresolved": "error"
}
}

71
ts_config/package.json Normal file
View File

@ -0,0 +1,71 @@
{
"name": "@kuhyx/ts-config",
"version": "0.1.0",
"private": false,
"type": "module",
"description": "The one lint/TypeScript bar every kuhyx TypeScript repo extends: a strict tsconfig base, ESLint flat-config presets (base, react, vitest, playwright, layer boundaries, a fast editor subset), and Knip / jscpd / Stryker bases. Plain JS, no build step -- consumable straight from a git tag.",
"license": "MIT",
"engines": {
"node": ">=24",
"pnpm": ">=10"
},
"packageManager": "pnpm@12.4.2",
"files": [
"eslint",
"tsconfig.base.json",
"knip.base.json",
"jscpd.base.json",
"stryker.base.mjs",
"README.md"
],
"exports": {
".": "./eslint/index.js",
"./eslint": "./eslint/index.js",
"./eslint/base": "./eslint/base.js",
"./eslint/react": "./eslint/react.js",
"./eslint/vitest": "./eslint/vitest.js",
"./eslint/playwright": "./eslint/playwright.js",
"./eslint/boundaries": "./eslint/boundaries.js",
"./eslint/fast": "./eslint/fast.js",
"./tsconfig": "./tsconfig.base.json",
"./knip": "./knip.base.json",
"./jscpd": "./jscpd.base.json",
"./stryker": "./stryker.base.mjs"
},
"scripts": {
"lint": "eslint .",
"test": "vitest run",
"coverage": "vitest run --coverage"
},
"dependencies": {
"@eslint-react/eslint-plugin": "5.19.1",
"@eslint/js": "10.0.1",
"@vitest/eslint-plugin": "1.6.27",
"eslint-import-resolver-typescript": "4.4.5",
"eslint-plugin-boundaries": "7.2.0",
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-perfectionist": "5.11.1",
"eslint-plugin-playwright": "2.12.0",
"eslint-plugin-react-hooks": "7.1.1",
"eslint-plugin-react-refresh": "0.5.7",
"eslint-plugin-react-you-might-not-need-an-effect": "1.0.2",
"eslint-plugin-sonarjs": "4.2.1",
"eslint-plugin-unicorn": "75.0.0",
"globals": "17.12.0"
},
"peerDependencies": {
"eslint": ">=10",
"typescript": ">=6",
"typescript-eslint": ">=8.70.0"
},
"devDependencies": {
"@playwright/test": "1.63.0",
"@types/react": "19.3.0",
"@vitest/coverage-v8": "5.0.1",
"eslint": "10.10.0",
"react": "19.3.0",
"typescript": "6.0.3",
"typescript-eslint": "8.70.0",
"vitest": "5.0.1"
}
}

4458
ts_config/pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,4 @@
allowBuilds:
unrs-resolver: set this to true or false
minimumReleaseAgeExclude:
- eslint-plugin-unicorn@75.0.0

View File

@ -0,0 +1,18 @@
// Mutation testing base. Stryker flips `<` to `<=`, empties arrays, inverts
// conditions and swaps return values; a test that still passes was not
// testing behaviour.
//
// No `break` threshold on purpose -- the article's one hard rule: treat the
// score as a detector, never as a KPI. The weekly workflow reports it; nothing
// fails on it.
/** @type {import("@stryker-mutator/api/core").PartialStrykerOptions} */
export const strykerBase = {
coverageAnalysis: "perTest",
ignoreStatic: true,
incremental: true,
mutate: ["src/**/*.ts", "src/**/*.tsx", "!src/**/*.test.*", "!src/**/*.spec.*", "!src/**/*.d.ts"],
reporters: ["clear-text", "progress", "json", "html"],
testRunner: "vitest",
thresholds: { break: null, high: 80, low: 60 },
timeoutMS: 60_000,
};

View File

@ -0,0 +1,44 @@
// The JSON/MJS bases are data, but data a consumer's tool parses: a typo in
// one is a broken lint/knip/jscpd run in every repo at once.
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { strykerBase } from "../stryker.base.mjs";
const ROOT = path.join(import.meta.dirname, "..");
describe("bases", () => {
it("stryker never breaks the build on score", () => {
expect(strykerBase.thresholds.break).toBeNull();
expect(strykerBase.testRunner).toBe("vitest");
expect(strykerBase.mutate).toContain("!src/**/*.test.*");
});
it("tsconfig base carries the two flags the article names", () => {
const raw = fs.readFileSync(path.join(ROOT, "tsconfig.base.json"), "utf8");
// JSONC: strip the comment lines before parsing.
const json = JSON.parse(raw.split("\n").filter((line) => !line.trimStart().startsWith("//")).join("\n"));
expect(json.compilerOptions.strict).toBe(true);
expect(json.compilerOptions.noUncheckedIndexedAccess).toBe(true);
expect(json.compilerOptions.exactOptionalPropertyTypes).toBe(true);
expect(json.compilerOptions.outDir).toBeUndefined();
});
it("knip and jscpd bases are valid JSON with exit-1 semantics", () => {
const knip = JSON.parse(fs.readFileSync(path.join(ROOT, "knip.base.json"), "utf8"));
expect(Object.values(knip.rules).every((level) => level === "error")).toBe(true);
const jscpd = JSON.parse(fs.readFileSync(path.join(ROOT, "jscpd.base.json"), "utf8"));
expect(jscpd.exitCode).toBe(1);
expect(jscpd.threshold).toBe(0);
});
it("every export in package.json points at a shipped file", () => {
const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8"));
for (const target of Object.values(manifest.exports)) {
expect(fs.existsSync(path.join(ROOT, target)), target).toBe(true);
const [, top] = target.split("/", 2);
expect(manifest.files, `${top} missing from "files"`).toContain(top);
}
});
});

View File

@ -0,0 +1,130 @@
// The factories: what each option adds, and what a bad call refuses.
import { describe, expect, it } from "vitest";
import { base, DEFAULT_IGNORES } from "../eslint/base.js";
import { boundaries, DEFAULT_ALLOW, folderElements } from "../eslint/boundaries.js";
import fast from "../eslint/fast.js";
import { defineConfig } from "../eslint/index.js";
const ROOT = import.meta.dirname;
/** Rule ids that appear anywhere in a flat config array. */
function ruleIds(config) {
return new Set(config.flatMap((entry) => Object.keys(entry.rules ?? {})));
}
/** Plugin names registered anywhere in a flat config array. */
function pluginNames(config) {
return new Set(config.flatMap((entry) => Object.keys(entry.plugins ?? {})));
}
describe("base()", () => {
it("refuses to run without a tsconfigRootDir", () => {
expect(() => base({})).toThrow(/tsconfigRootDir/);
expect(() => base({ tsconfigRootDir: "" })).toThrow(/tsconfigRootDir/);
});
it("prepends the default ignores and keeps the caller's", () => {
const [ignores] = base({ ignores: ["public"], tsconfigRootDir: ROOT });
expect(ignores.ignores).toEqual([...DEFAULT_IGNORES, "public"]);
});
it("wires the type-aware parser at the given root", () => {
const entry = base({ tsconfigRootDir: ROOT }).find(
(candidate) => candidate.languageOptions?.parserOptions?.projectService === true,
);
expect(entry?.languageOptions.parserOptions.tsconfigRootDir).toBe(ROOT);
});
it("drops the alias-only import ban when asked", () => {
expect(ruleIds(base({ tsconfigRootDir: ROOT }))).toContain("no-restricted-imports");
expect(ruleIds(base({ aliasOnly: false, tsconfigRootDir: ROOT }))).not.toContain(
"no-restricted-imports",
);
});
});
describe("boundaries()", () => {
it("refuses an empty element list", () => {
expect(() => boundaries({ elements: [], tsconfigRootDir: ROOT })).toThrow(/elements/);
expect(() => boundaries({ tsconfigRootDir: ROOT })).toThrow(/elements/);
});
it("refuses to run without a tsconfigRootDir", () => {
expect(() => boundaries({ elements: folderElements(["utils"]) })).toThrow(/tsconfigRootDir/);
});
it("emits a policy only for element types the repo declared", () => {
const [entry] = boundaries({
elements: folderElements(["components", "services"]),
tsconfigRootDir: ROOT,
});
const { policies } = entry.rules["boundaries/dependencies"][1];
const froms = policies.filter((p) => p.from.element).map((p) => p.from.element.type);
expect(froms.toSorted((a, b) => a.localeCompare(b))).toEqual(["components", "services"]);
const components = policies.find((p) => p.from.element?.type === "components");
// utils/types are not declared here, so they are filtered out of the
// allow list rather than left dangling.
expect(components.allow.to.element.types.anyOf).toEqual(["services", "components"]);
});
it("merges a repo's allow override over one layer only", () => {
const [entry] = boundaries({
allow: { services: ["components"] },
elements: folderElements(["components", "services"]),
tsconfigRootDir: ROOT,
});
const { policies } = entry.rules["boundaries/dependencies"][1];
const services = policies.find((p) => p.from.element?.type === "services");
expect(services.allow.to.element.types.anyOf).toEqual(["components"]);
expect(DEFAULT_ALLOW.services).toEqual(["utils", "types", "services"]);
});
it("points the TS resolver at the repo root and marks tests", () => {
const [entry] = boundaries({
elements: folderElements(["utils"], "lib"),
testFiles: ["**/*.spec.ts"],
tsconfigRootDir: ROOT,
});
expect(entry.settings["boundaries/elements"]).toEqual([{ pattern: "lib/utils", type: "utils" }]);
expect(entry.settings["boundaries/files"]).toEqual([{ category: "test", pattern: ["**/*.spec.ts"] }]);
expect(entry.settings["import/resolver"].typescript.project).toBe(ROOT);
expect(entry.settings["boundaries/root-path"]).toBe(ROOT);
});
});
describe("defineConfig()", () => {
it("stacks nothing optional by default except vitest", () => {
const plugins = pluginNames(defineConfig({ tsconfigRootDir: ROOT }));
expect(plugins).toContain("vitest");
expect(plugins).not.toContain("boundaries");
expect(plugins).not.toContain("react-hooks");
expect(plugins).not.toContain("playwright");
});
it("stacks every layer when asked and appends overrides last", () => {
const override = { rules: { "unicorn/no-null": "error" } };
const config = defineConfig({
boundaries: { elements: folderElements(["components"]) },
overrides: [override],
playwright: true,
react: true,
tsconfigRootDir: ROOT,
vitest: false,
});
const plugins = pluginNames(config);
for (const name of ["boundaries", "react-hooks", "playwright", "jsx-a11y"]) {
expect(plugins).toContain(name);
}
expect(plugins).not.toContain("vitest");
expect(config.at(-1)).toBe(override);
});
});
describe("fast", () => {
it("is type-free: no projectService anywhere", () => {
const typeAware = fast.some((entry) => entry.languageOptions?.parserOptions?.projectService);
expect(typeAware).toBe(false);
expect(ruleIds(fast)).toContain("@typescript-eslint/no-explicit-any");
});
});

View File

@ -0,0 +1,29 @@
// One deliberate violation per rule family in test/fixture, and the rule id
// that must catch it. A rule missing from this table is a rule the preset
// does not prove; a fixture file missing from it is a plant nobody checks.
export const EXPECTED = {
"e2e/home.spec.ts": [
"playwright/missing-playwright-await",
"playwright/no-conditional-in-test",
],
"src/components/button.tsx": [
"jsx-a11y/alt-text",
"jsx-a11y/click-events-have-key-events",
"react-hooks/set-state-in-effect",
"react-you-might-not-need-an-effect/no-derived-state",
],
"src/design-system/theme.ts": ["boundaries/dependencies"],
"src/helper.test.ts": ["vitest/expect-expect", "vitest/no-focused-tests"],
"src/pages/home.tsx": ["boundaries/dependencies"],
"src/router/routes.ts": ["boundaries/dependencies"],
"src/services/api.ts": [
"@typescript-eslint/no-explicit-any",
"@typescript-eslint/no-non-null-assertion",
"boundaries/dependencies",
"no-restricted-imports",
],
"src/Wrong_Name.ts": ["unicorn/filename-case"],
};
/** Files that must lint clean: proves the presets do not fire on good code. */
export const CLEAN = ["src/mocks/user.ts", "src/utils/helper.ts"];

View File

@ -0,0 +1,64 @@
// Runs the real preset over the fixture mini-repo and checks that every
// planted violation is caught by the rule the table names, and that the
// clean files stay clean.
//
// The lint runs as the CLI in a child process, exactly as a consumer's
// pre-commit hook runs it. It also keeps the preset modules out of this
// process: v8 coverage merges the natively-loaded copies ESLint imports with
// the vitest-transformed ones the unit tests import, and the merge reports
// executed lines as uncovered.
import { execFileSync } from "node:child_process";
import path from "node:path";
import { beforeAll, describe, expect, it } from "vitest";
import { CLEAN, EXPECTED } from "./expected.js";
const FIXTURE = path.join(import.meta.dirname, "fixture");
const ESLINT_BIN = path.join(import.meta.dirname, "..", "node_modules", "eslint", "bin", "eslint.js");
/** @type {Map<string, Set<string>>} relative path -> rule ids reported */
const found = new Map();
beforeAll(() => {
let stdout;
try {
stdout = execFileSync(process.execPath, [ESLINT_BIN, ".", "--format", "json"], {
cwd: FIXTURE,
encoding: "utf8",
});
} catch (error) {
// Planted violations make ESLint exit 1; the JSON report is still on stdout.
stdout = error.stdout;
}
const results = JSON.parse(stdout.slice(stdout.indexOf("[")));
for (const result of results) {
const relative = path.relative(FIXTURE, result.filePath);
found.set(relative, new Set(result.messages.map((m) => m.ruleId)));
}
}, 120_000);
describe("planted violations", () => {
for (const [file, rules] of Object.entries(EXPECTED)) {
for (const rule of rules) {
it(`${file}: ${rule}`, () => {
expect([...(found.get(file) ?? [])]).toContain(rule);
});
}
}
});
describe("clean files", () => {
for (const file of CLEAN) {
it(`${file} reports nothing`, () => {
expect([...(found.get(file) ?? [])]).toEqual([]);
});
}
});
it("every fixture file is accounted for", () => {
const expectedFiles = new Set(Object.keys(EXPECTED));
const known = expectedFiles.union(new Set(CLEAN));
for (const file of found.keys()) {
expect(known.has(file), `unlisted fixture file: ${file}`).toBe(true);
}
});

View File

@ -0,0 +1,9 @@
import { expect, test } from "@playwright/test";
// Violations: playwright no-conditional-in-test, missing-playwright-await.
test("home", async ({ page }) => {
if (Math.random() > 0.5) {
await page.goto("/");
}
expect(page.locator("p")).toBeVisible();
});

View File

@ -0,0 +1,15 @@
import { defineConfig, folderElements } from "../../eslint/index.js";
export default defineConfig({
boundaries: {
elements: folderElements([
"components", "services", "utils", "pages", "router", "mocks", "design-system",
]),
},
// The fixture reaches its preset by relative path; a consumer imports the
// package name, so the alias-only rule never sees this line there.
ignores: ["eslint.config.js"],
playwright: true,
react: true,
tsconfigRootDir: import.meta.dirname,
});

View File

@ -0,0 +1,2 @@
// Violation: unicorn/filename-case (kebab-case expected).
export const wrongName = 1;

View File

@ -0,0 +1,16 @@
// Violations: you-might-not-need-an-effect (derived state via effect),
// jsx-a11y (img without alt, click on non-interactive div).
import { useEffect, useState } from "react";
export function Button({ label }: { label: string }): React.JSX.Element {
const [upper, setUpper] = useState("");
useEffect(() => {
setUpper(label.toUpperCase());
}, [label]);
return (
<div onClick={() => undefined}>
<img src="x.png" />
{upper}
</div>
);
}

View File

@ -0,0 +1,4 @@
// Violation: design-system must not import services.
import { fetchUser } from "@/services/api";
export const theme = { primary: String(fetchUser("x")) };

View File

@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import { helper } from "./utils/helper";
import { mockUser } from "./mocks/user";
describe("helper", () => {
// Violations: no-focused-tests, expect-expect.
it.only("trims", () => {
expect(helper(" a ")).toBe("a");
});
it("has no assertion", () => {
helper(mockUser.name);
});
});

View File

@ -0,0 +1 @@
export const mockUser = { id: "1", name: "Mock" };

View File

@ -0,0 +1,7 @@
// Violation: a production page importing a mock (boundaries).
import { mockUser } from "@/mocks/user";
import { fetchUser } from "@/services/api";
export function Home(): React.JSX.Element {
return <p>{String(fetchUser(mockUser.id))}</p>;
}

View File

@ -0,0 +1,4 @@
// Violation: router may only import pages.
import { helper } from "@/utils/helper";
export const routes = [helper("/")];

View File

@ -0,0 +1,10 @@
// Violations: no-explicit-any, no-non-null-assertion, parent-relative import,
// services importing from components (boundaries).
import { Button } from "../components/button";
import { helper } from "@/utils/helper";
export function fetchUser(id: string): any {
const cache: Record<string, string | undefined> = {};
const name = cache[id]!;
return { id, name, label: helper(name), button: Button };
}

View File

@ -0,0 +1,8 @@
export function helper(value: string): string {
return value.trim();
}
// Violation for Knip: never imported anywhere.
export function deadExport(): number {
return 1;
}

View File

@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2023", "DOM"],
"jsx": "react-jsx",
"module": "ESNext",
"moduleResolution": "bundler",
"noEmit": true,
"types": [],
"paths": { "@/*": ["./src/*"] }
},
"include": ["src", "e2e"]
}

View File

@ -0,0 +1,26 @@
{
// The strictness every kuhyx TypeScript repo compiles under. Nothing here
// is about output: lib / jsx / module / outDir / paths stay in the repo's
// own tsconfig, which `extends` this one.
//
// The two flags the article singles out are the two that make a model
// state its assumptions: noUncheckedIndexedAccess turns `arr[0]` into
// "element or undefined", exactOptionalPropertyTypes stops `undefined`
// being smuggled into an optional property.
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"useUnknownInCatchVariables": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"erasableSyntaxOnly": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
}
}

View File

@ -0,0 +1,16 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
coverage: {
include: ["eslint/**/*.js", "stryker.base.mjs"],
provider: "v8",
// The same bar as ts_core and web_ui: the package every repo's lint
// runs through does not get to be the least-tested code in the tree.
thresholds: { branches: 100, functions: 100, lines: 100, statements: 100 },
},
environment: "node",
include: ["test/**/*.test.js"],
testTimeout: 120_000,
},
});