eslint-plugin-code-policy
v0.2.2
Published
Architectural linting for TypeScript · enforce atomic files, explicit public APIs, clean runtime boundaries, and view/logic separation.
Maintainers
Readme
Installation
Requirements
- ESLint
>=9.0.0(flat config, including v10) - TypeScript
^5.4.0 - Node.js
>=20.19
# npm
npm install --save-dev eslint-plugin-code-policy
# pnpm
pnpm add --save-dev eslint-plugin-code-policy
# yarn
yarn add --dev eslint-plugin-code-policyUsage
Flat config (eslint.config.mjs)
import codePolicy from 'eslint-plugin-code-policy'
export default [codePolicy.configs.recommended]Choosing a preset
| Preset | Import path | Best for |
| ------------- | -------------------------------- | ---------------------- |
| recommended | codePolicy.configs.recommended | Any TypeScript project |
| strict | codePolicy.configs.strict | Maximum enforcement |
| react | codePolicy.configs.react | React (Vite, CRA, …) |
| next | codePolicy.configs.next | Next.js App Router |
// Next.js example
import codePolicy from 'eslint-plugin-code-policy'
export default [codePolicy.configs.next]Manual rule configuration
If you prefer to cherry-pick rules:
import codePolicy from 'eslint-plugin-code-policy'
export default [
{
plugins: { 'code-policy': codePolicy },
rules: {
'code-policy/atomic-file': 'error',
'code-policy/no-inline-types': 'error',
'code-policy/public-api-imports': 'error',
'code-policy/no-cross-module-deep-imports': 'error',
'code-policy/view-logic-separation': 'error',
},
},
]Rules
code-policy/atomic-file
Enforce exactly one top-level declaration per file.
Each file must export exactly one top-level unit — a function, class, constant, or type. This is the foundation of hyper-modular architecture: if a file has two things, one of them belongs in a new file.
❌ Incorrect
// ❌ src/UserUtils.ts — two exports in one file
export function formatUserName(user: User) {
return `${user.firstName} ${user.lastName}`
}
export function getUserAge(user: User) {
return new Date().getFullYear() - user.birthYear
}✅ Correct
// ✅ src/formatUserName.ts
export function formatUserName(user: User) {
return `${user.firstName} ${user.lastName}`
}// ✅ src/getUserAge.ts
export function getUserAge(user: User) {
return new Date().getFullYear() - user.birthYear
}Exemptions (automatically skipped)
*.config.ts/*.config.js/*.config.mjsindex.ts/index.tsx/index.js(barrel files)*.d.ts(ambient declaration files)- Next.js special files:
page.tsx,layout.tsx,route.ts, etc. — reserved exports likeGET,POST,metadataare not counted.
code-policy/no-inline-types
Enforce that type aliases and interfaces live in their own files.
Type declarations that appear alongside implementation code create hidden coupling and violate the single responsibility principle. Every type or interface must be in a dedicated file, ideally inside a types/ directory.
❌ Incorrect
// ❌ Mixed type + implementation in one file
type UserRole = 'admin' | 'member' | 'guest'
export function canEdit(role: UserRole) {
return role === 'admin'
}✅ Correct
// ✅ src/types/UserRole.ts
export type UserRole = 'admin' | 'member' | 'guest'// ✅ src/canEdit.ts
import type { UserRole } from '@/types/UserRole'
export function canEdit(role: UserRole) {
return role === 'admin'
}Exemptions
- Files inside
types/ortypes/**directories *.d.tsfiles- "Pure type files" — files whose entire body consists only of
import+type/interfacedeclarations
code-policy/public-api-imports
Prevent importing directly from internal module subpaths.
When consuming a package or module, you must import from its public API (the root / index), not from a deep internal path. Deep imports couple you to internal implementation details.
❌ Incorrect
// ❌ Bypassing the public API
import { Button } from '@myorg/ui/src/components/Button'
import { formatDate } from '@myorg/utils/src/date/formatDate'✅ Correct
// ✅ Always import through the public surface
import { Button } from '@myorg/ui'
import { formatDate } from '@myorg/utils'Options
{
'code-policy/public-api-imports': ['error', {
bannedSubpaths: ['/src/'] // default
}]
}| Option | Type | Default | Description |
| ---------------- | ---------- | ----------- | ------------------------------------------- |
| bannedSubpaths | string[] | ['/src/'] | Segments that signal a deep internal import |
code-policy/no-cross-module-deep-imports
Prevent relative imports that bypass another module's public API within a monorepo.
In a monorepo, relative paths like ../../core/src/utils/helper skip the core module's public API entirely. This rule detects that pattern by counting ../ traversal depth and checking for internal directory names in the descent.
❌ Incorrect
// ❌ packages/ui/src/Button.tsx
import { helper } from '../../core/src/utils/helper'✅ Correct
// ✅ Import through the published public API
import { helper } from '@myorg/core'Options
{
'code-policy/no-cross-module-deep-imports': ['error', {
minParentTraversals: 2, // how many `../` levels before checking
internalDirs: ['src'] // dirs that signal internal code
}]
}| Option | Type | Default | Description |
| --------------------- | ---------- | --------- | ------------------------------------------------ |
| minParentTraversals | number | 2 | Minimum ../ segments before the rule activates |
| internalDirs | string[] | ['src'] | Directory names that indicate internal code |
code-policy/view-logic-separation
Prevent state, effects, and inline handlers inside React view components.
React view components (.tsx files) are responsible for rendering only. State management, side effects, and event handler logic must live in a dedicated custom hook. This enforces a clean view/controller split.
❌ Incorrect
// ❌ src/UserCard.tsx — logic inside a view
export function UserCard({ userId }: UserCardProps) {
const [user, setUser] = useState<User | null>(null)
useEffect(() => {
fetchUser(userId).then(setUser)
}, [userId])
const handleDelete = () => {
deleteUser(userId)
}
return <div onClick={handleDelete}>{user?.name}</div>
}✅ Correct
// ✅ src/useUserCard.ts
export function useUserCard(userId: string) {
const [user, setUser] = useState<User | null>(null)
useEffect(() => {
fetchUser(userId).then(setUser)
}, [userId])
const handleDelete = () => deleteUser(userId)
return { user, handleDelete }
}// ✅ src/UserCard.tsx — pure view
import { useUserCard } from './useUserCard'
export function UserCard({ userId }: UserCardProps) {
const { user, handleDelete } = useUserCard(userId)
return <div onClick={handleDelete}>{user?.name}</div>
}What triggers this rule (inside .tsx files)
- Calling React hooks:
useState,useEffect,useReducer,useCallback,useMemo,useRef, and more - Declaring inline functions/handlers directly inside a view component body
Shareable Configs Reference
recommended
Enables all five rules as errors. Best starting point for any TypeScript project.
// Rules enabled:
'code-policy/atomic-file': 'error'
'code-policy/no-inline-types': 'error'
'code-policy/view-logic-separation': 'error'
'code-policy/public-api-imports': 'error'
'code-policy/no-cross-module-deep-imports': 'error'strict
Extends recommended. Intended for projects that want zero tolerance for architectural deviation. Reserved for additional strictness overrides in future versions.
react
Extends recommended with React-specific adjustments.
next
Extends recommended. Correctly handles Next.js App Router special files (page.tsx, layout.tsx, route.ts, etc.) and reserved exports (metadata, GET, POST, …), preventing false positives.
Migrating from Legacy Config
This plugin only supports the ESLint flat config format (ESLint v9+). If you're still on the legacy .eslintrc format, migrate using the official ESLint migration guide before installing this plugin.
FAQ
Q: Why do I get errors on my index.ts barrel files?
index.ts files are automatically exempted from the atomic-file rule because barrel files by design re-export multiple things.
Q: How do I exempt a specific file from a rule?
Use ESLint's standard inline disable comment:
// eslint-disable-next-line code-policy/atomic-fileOr add file overrides in your eslint.config.mjs:
{
files: ['src/legacy/**'],
rules: {
'code-policy/atomic-file': 'off',
},
}Q: Does this work with JavaScript (non-TypeScript) projects?
The rules are language-agnostic at the ESLint AST level. TypeScript-specific nodes are handled gracefully. You can use the plugin on .js files, though some rules (like no-inline-types) are most meaningful in TypeScript codebases.
