utils/ts_config/eslint/boundaries.js
Krzysztof kuhy Rudnicki 128ede438e 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
2026-09-16 21:40:08 +02:00

112 lines
4.5 KiB
JavaScript

// 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 },
},
},
},
];
}