mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 19:07:17 +01:00
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:
parent
d531b733b8
commit
128ede438e
36
.github/workflows/ts-config-tests.yml
vendored
Normal file
36
.github/workflows/ts-config-tests.yml
vendored
Normal 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
|
||||
17
README.md
17
README.md
@ -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
2
ts_config/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
coverage/
|
||||
54
ts_config/.pre-commit-config.yaml
Normal file
54
ts_config/.pre-commit-config.yaml
Normal 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
88
ts_config/README.md
Normal 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`.
|
||||
10
ts_config/eslint.config.js
Normal file
10
ts_config/eslint.config.js
Normal 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
110
ts_config/eslint/base.js
Normal 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 },
|
||||
},
|
||||
);
|
||||
}
|
||||
111
ts_config/eslint/boundaries.js
Normal file
111
ts_config/eslint/boundaries.js
Normal 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
33
ts_config/eslint/fast.js
Normal 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,
|
||||
);
|
||||
5
ts_config/eslint/ignores.js
Normal file
5
ts_config/eslint/ignores.js
Normal 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"];
|
||||
27
ts_config/eslint/imports.js
Normal file
27
ts_config/eslint/imports.js
Normal 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
53
ts_config/eslint/index.js
Normal 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,
|
||||
];
|
||||
}
|
||||
31
ts_config/eslint/naming.js
Normal file
31
ts_config/eslint/naming.js
Normal 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 } }],
|
||||
},
|
||||
},
|
||||
];
|
||||
69
ts_config/eslint/overrides.js
Normal file
69
ts_config/eslint/overrides.js
Normal 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",
|
||||
},
|
||||
},
|
||||
];
|
||||
22
ts_config/eslint/playwright.js
Normal file
22
ts_config/eslint/playwright.js
Normal 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
48
ts_config/eslint/react.js
vendored
Normal 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,
|
||||
},
|
||||
];
|
||||
30
ts_config/eslint/require.js
Normal file
30
ts_config/eslint/require.js
Normal 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;
|
||||
}
|
||||
59
ts_config/eslint/vitest.js
Normal file
59
ts_config/eslint/vitest.js
Normal 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
18
ts_config/jscpd.base.json
Normal 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
18
ts_config/knip.base.json
Normal 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
71
ts_config/package.json
Normal 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
4458
ts_config/pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load Diff
4
ts_config/pnpm-workspace.yaml
Normal file
4
ts_config/pnpm-workspace.yaml
Normal file
@ -0,0 +1,4 @@
|
||||
allowBuilds:
|
||||
unrs-resolver: set this to true or false
|
||||
minimumReleaseAgeExclude:
|
||||
- eslint-plugin-unicorn@75.0.0
|
||||
18
ts_config/stryker.base.mjs
Normal file
18
ts_config/stryker.base.mjs
Normal 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,
|
||||
};
|
||||
44
ts_config/test/bases.test.js
Normal file
44
ts_config/test/bases.test.js
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
130
ts_config/test/config.test.js
Normal file
130
ts_config/test/config.test.js
Normal 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");
|
||||
});
|
||||
});
|
||||
29
ts_config/test/expected.js
Normal file
29
ts_config/test/expected.js
Normal 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"];
|
||||
64
ts_config/test/fixture.test.js
Normal file
64
ts_config/test/fixture.test.js
Normal 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);
|
||||
}
|
||||
});
|
||||
9
ts_config/test/fixture/e2e/home.spec.ts
Normal file
9
ts_config/test/fixture/e2e/home.spec.ts
Normal 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();
|
||||
});
|
||||
15
ts_config/test/fixture/eslint.config.js
Normal file
15
ts_config/test/fixture/eslint.config.js
Normal 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,
|
||||
});
|
||||
2
ts_config/test/fixture/src/Wrong_Name.ts
Normal file
2
ts_config/test/fixture/src/Wrong_Name.ts
Normal file
@ -0,0 +1,2 @@
|
||||
// Violation: unicorn/filename-case (kebab-case expected).
|
||||
export const wrongName = 1;
|
||||
16
ts_config/test/fixture/src/components/button.tsx
Normal file
16
ts_config/test/fixture/src/components/button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
4
ts_config/test/fixture/src/design-system/theme.ts
Normal file
4
ts_config/test/fixture/src/design-system/theme.ts
Normal file
@ -0,0 +1,4 @@
|
||||
// Violation: design-system must not import services.
|
||||
import { fetchUser } from "@/services/api";
|
||||
|
||||
export const theme = { primary: String(fetchUser("x")) };
|
||||
14
ts_config/test/fixture/src/helper.test.ts
Normal file
14
ts_config/test/fixture/src/helper.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
1
ts_config/test/fixture/src/mocks/user.ts
Normal file
1
ts_config/test/fixture/src/mocks/user.ts
Normal file
@ -0,0 +1 @@
|
||||
export const mockUser = { id: "1", name: "Mock" };
|
||||
7
ts_config/test/fixture/src/pages/home.tsx
Normal file
7
ts_config/test/fixture/src/pages/home.tsx
Normal 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>;
|
||||
}
|
||||
4
ts_config/test/fixture/src/router/routes.ts
Normal file
4
ts_config/test/fixture/src/router/routes.ts
Normal file
@ -0,0 +1,4 @@
|
||||
// Violation: router may only import pages.
|
||||
import { helper } from "@/utils/helper";
|
||||
|
||||
export const routes = [helper("/")];
|
||||
10
ts_config/test/fixture/src/services/api.ts
Normal file
10
ts_config/test/fixture/src/services/api.ts
Normal 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 };
|
||||
}
|
||||
8
ts_config/test/fixture/src/utils/helper.ts
Normal file
8
ts_config/test/fixture/src/utils/helper.ts
Normal 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;
|
||||
}
|
||||
13
ts_config/test/fixture/tsconfig.json
Normal file
13
ts_config/test/fixture/tsconfig.json
Normal 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"]
|
||||
}
|
||||
26
ts_config/tsconfig.base.json
Normal file
26
ts_config/tsconfig.base.json
Normal 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
|
||||
}
|
||||
}
|
||||
16
ts_config/vitest.config.js
Normal file
16
ts_config/vitest.config.js
Normal 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,
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user