@noctcore/eslint-plugin-architecture 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -2
- package/dist/index.cjs +1024 -30
- package/dist/index.d.cts +77 -0
- package/dist/index.d.ts +77 -0
- package/dist/index.js +1024 -30
- package/docs/rules/barrel-purity.md +57 -0
- package/docs/rules/colocated-test-required.md +48 -0
- package/docs/rules/filename-matches-export.md +56 -0
- package/docs/rules/max-import-depth.md +52 -0
- package/docs/rules/single-semantic-module.md +180 -0
- package/package.json +2 -2
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# `noctcore-architecture/barrel-purity`
|
|
2
|
+
|
|
3
|
+
> A barrel (`index.ts` / `index.tsx`) must contain only re-exports — never local declarations, side effects, or default-exported values.
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
A barrel exists to present a folder's public surface. The moment it also *declares* something — a
|
|
8
|
+
const, a function, a type, a default-exported value — or *does* something — a side-effect import, a
|
|
9
|
+
top-level call — the barrel becomes a real module with behavior. Every consumer that imports the
|
|
10
|
+
folder now silently pulls that behavior in, and the folder no longer has a clean, movable boundary.
|
|
11
|
+
|
|
12
|
+
## What it flags
|
|
13
|
+
|
|
14
|
+
Only `index.ts` / `index.tsx` (and the other index extensions) are inspected. Each top-level
|
|
15
|
+
statement that is not a pure re-export is reported.
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
// index.ts
|
|
19
|
+
|
|
20
|
+
export { Card } from './Card'; // ✓ named re-export
|
|
21
|
+
export * from './Card.types'; // ✓ star re-export
|
|
22
|
+
export * as card from './Card'; // ✓ namespace re-export
|
|
23
|
+
export { default as Card } from './Card'; // ✓ default re-export
|
|
24
|
+
import { a } from './a'; // ✓ import that feeds a re-export
|
|
25
|
+
export { a }; // …its matching specifier
|
|
26
|
+
export type { Props } from './Card'; // ✓ type re-export
|
|
27
|
+
export default Card; // ✓ re-export of a binding by name
|
|
28
|
+
|
|
29
|
+
export const helper = 1; // ✗ local declaration
|
|
30
|
+
export function build() {} // ✗ local declaration
|
|
31
|
+
export type T = string; // ✗ local declaration
|
|
32
|
+
export default () => 1; // ✗ a value, not a re-export
|
|
33
|
+
import './styles.css'; // ✗ side-effect import
|
|
34
|
+
console.log('hi'); // ✗ side-effect statement
|
|
35
|
+
const cache = new Map(); // ✗ non-export code
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
An import that carries bindings is allowed because it feeds a re-export; a specifier-less
|
|
39
|
+
`import './x'` is a side effect and is flagged.
|
|
40
|
+
|
|
41
|
+
## Options
|
|
42
|
+
|
|
43
|
+
| Option | Type | Default | Meaning |
|
|
44
|
+
| --- | --- | --- | --- |
|
|
45
|
+
| `allow` | `string[]` | `[]` | Globs (supporting `**`, `*`, `?`) of barrel paths to exempt entirely. |
|
|
46
|
+
|
|
47
|
+
```js
|
|
48
|
+
'noctcore-architecture/barrel-purity': ['error', {
|
|
49
|
+
allow: ['**/legacy/**'],
|
|
50
|
+
}]
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## When not to use it
|
|
54
|
+
|
|
55
|
+
If you deliberately keep small helpers or feature flags inside a package's `index.ts`, this rule will
|
|
56
|
+
fight you. Prefer moving them into a sibling module and re-exporting — but if you cannot, disable the
|
|
57
|
+
rule for those paths via `allow`.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# `noctcore-architecture/colocated-test-required`
|
|
2
|
+
|
|
3
|
+
> A source file matching an `include` glob must have a colocated `*.test.*` / `*.spec.*` sibling on disk. Off until `include` is configured.
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
Some files are risky enough that shipping them untested should be a lint error, not a code-review
|
|
8
|
+
afterthought — hooks, services, reducers, money math. This rule lets you name those globs and then
|
|
9
|
+
requires each matching file to carry its test right next to it, so the test travels with the code and
|
|
10
|
+
is obvious when missing.
|
|
11
|
+
|
|
12
|
+
## What it flags
|
|
13
|
+
|
|
14
|
+
For a file matching `include`, the rule reads the file's directory and looks for a sibling whose name
|
|
15
|
+
is `<stem>.test.<ext>` or `<stem>.spec.<ext>` (any extension). If none exists, it reports.
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
src/hooks/useCart.ts ← include: ['**/use*.ts']
|
|
19
|
+
src/hooks/useCart.test.ts ✓ colocated test present
|
|
20
|
+
src/hooks/useWishlist.ts ✗ no useWishlist.test.* / .spec.* sibling
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
A test file that itself matches `include` is never asked to test itself. Directory listings are
|
|
24
|
+
cached for the lint run, so a folder full of gated files is read once.
|
|
25
|
+
|
|
26
|
+
## Options
|
|
27
|
+
|
|
28
|
+
| Option | Type | Default | Meaning |
|
|
29
|
+
| --- | --- | --- | --- |
|
|
30
|
+
| `include` | `string[]` | `[]` | Globs of source files that must have a colocated test. **Empty = the rule is off.** |
|
|
31
|
+
| `ignore` | `string[]` | `[]` | Globs of files to exempt even when they match `include`. |
|
|
32
|
+
|
|
33
|
+
```js
|
|
34
|
+
'noctcore-architecture/colocated-test-required': ['error', {
|
|
35
|
+
include: ['**/use*.ts', '**/*.service.ts', '**/*.reducer.ts'],
|
|
36
|
+
ignore: ['**/*.d.ts'],
|
|
37
|
+
}]
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## In `recommended`
|
|
41
|
+
|
|
42
|
+
Ships **`'off'`**. There is no universal "everything needs a colocated test" default, so the
|
|
43
|
+
`recommended` preset cannot safely enable it — turn it on with your own `include` globs.
|
|
44
|
+
|
|
45
|
+
## When not to use it
|
|
46
|
+
|
|
47
|
+
If your tests live in a separate `__tests__` tree or a top-level `test/` directory rather than beside
|
|
48
|
+
the source, this colocation check does not model your layout. Leave it off.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# `noctcore-architecture/filename-matches-export`
|
|
2
|
+
|
|
3
|
+
> A file's basename must match its primary export (a default export, or the sole named export).
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
When a module has one clear public export, the filename should announce it. A `helpers.ts` that
|
|
8
|
+
exports a single `formatDate` hides its contents from anyone scanning the file tree, and a rename of
|
|
9
|
+
one without the other leaves the two permanently out of sync. Keeping them aligned makes the tree
|
|
10
|
+
self-describing.
|
|
11
|
+
|
|
12
|
+
## What it flags
|
|
13
|
+
|
|
14
|
+
The rule first resolves the file's **primary export**:
|
|
15
|
+
|
|
16
|
+
1. If the file has a default export with a name (`export default function Foo`, `export default
|
|
17
|
+
class Foo`, `export default Foo`), that name is the primary export. An anonymous default
|
|
18
|
+
(`export default () => …`) has no identifier to compare, so the file is skipped.
|
|
19
|
+
2. Otherwise, if the file has exactly one local named export, that is the primary export.
|
|
20
|
+
3. Otherwise (no primary, or several named exports) the file is skipped.
|
|
21
|
+
|
|
22
|
+
The basename and the identifier are compared **case- and separator-insensitively**, so naming
|
|
23
|
+
conventions never collide:
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
// TaskCard.tsx → export default function TaskCard() {} ✓
|
|
27
|
+
// use-thing.ts → export const useThing = () => {} ✓ (kebab ↔ camel)
|
|
28
|
+
// helpers.ts → export const formatDate = () => {} ✗ (genuine mismatch)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`index` files and files matching an `ignore` glob are always skipped.
|
|
32
|
+
|
|
33
|
+
## Suggestion, not autofix
|
|
34
|
+
|
|
35
|
+
A mismatch offers a **suggestion** to rename the export to the filename (the code-side resolution —
|
|
36
|
+
the other is renaming the file). It is never an autofix: renaming a public identifier is a decision a
|
|
37
|
+
human should confirm. When the basename is not a valid identifier (e.g. `2fa.ts`), the mismatch is
|
|
38
|
+
reported without a suggestion.
|
|
39
|
+
|
|
40
|
+
## Options
|
|
41
|
+
|
|
42
|
+
| Option | Type | Default | Meaning |
|
|
43
|
+
| --- | --- | --- | --- |
|
|
44
|
+
| `ignore` | `string[]` | `[]` | Globs (supporting `**`, `*`, `?`) of files to skip. |
|
|
45
|
+
|
|
46
|
+
```js
|
|
47
|
+
'noctcore-architecture/filename-matches-export': ['error', {
|
|
48
|
+
ignore: ['**/*.stories.tsx', '**/route.ts'],
|
|
49
|
+
}]
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## When not to use it
|
|
53
|
+
|
|
54
|
+
If your files routinely export several unrelated symbols, or you use fixed conventional filenames
|
|
55
|
+
(`route.ts`, `handler.ts`) that will never match their export, add them to `ignore` or leave the rule
|
|
56
|
+
off.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# `noctcore-architecture/max-import-depth`
|
|
2
|
+
|
|
3
|
+
> A relative import may not climb more than `max` parent levels (default 3). Autofixed to a path alias when one is configured.
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
A relative import that climbs several directories (`../../../../shared/log`) is brittle and hard to
|
|
8
|
+
read: it couples the file to its exact position in the tree, so moving either end breaks the path. A
|
|
9
|
+
path alias (`@/shared/log`) is stable and self-locating. This rule caps how far a relative import may
|
|
10
|
+
reach before it must become an alias.
|
|
11
|
+
|
|
12
|
+
## What it flags
|
|
13
|
+
|
|
14
|
+
Any relative specifier whose leading `..` run exceeds `max` is reported, on every source-carrying
|
|
15
|
+
construct: `import`, `import()`, `export … from`, and `export * from`.
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
// max: 3 (default)
|
|
19
|
+
import a from '../../../shared'; // ✓ exactly at the limit
|
|
20
|
+
import b from '../../../../shared/log'; // ✗ climbs 4 levels
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Autofix
|
|
24
|
+
|
|
25
|
+
When `alias` maps a directory-anchor segment to an alias prefix, a too-deep import that resolves
|
|
26
|
+
*through* that anchor is autofixed:
|
|
27
|
+
|
|
28
|
+
```js
|
|
29
|
+
'noctcore-architecture/max-import-depth': ['error', {
|
|
30
|
+
alias: { src: '@' },
|
|
31
|
+
}]
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
// from src/a/b/c/d/deep.ts
|
|
36
|
+
import x from '../../../../shared/log'; // → import x from '@/shared/log';
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
With no matching alias anchor on the resolved path, the violation is **reported without a fix** —
|
|
40
|
+
there is nothing safe to rewrite it to.
|
|
41
|
+
|
|
42
|
+
## Options
|
|
43
|
+
|
|
44
|
+
| Option | Type | Default | Meaning |
|
|
45
|
+
| --- | --- | --- | --- |
|
|
46
|
+
| `max` | `integer` | `3` | Maximum number of `..` parent hops a relative import may take. |
|
|
47
|
+
| `alias` | `Record<string, string>` | `{}` | Map of directory-anchor segment → alias prefix used to autofix, e.g. `{ src: '@' }`. |
|
|
48
|
+
|
|
49
|
+
## When not to use it
|
|
50
|
+
|
|
51
|
+
If you do not use path aliases and genuinely prefer deep relative imports, raise `max` or leave the
|
|
52
|
+
rule off.
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# `noctcore-architecture/single-semantic-module`
|
|
2
|
+
|
|
3
|
+
> Each module exports one semantic concern: types, constants, functions, classes, React components,
|
|
4
|
+
> hooks, schemas or enums. **Ships `off` in `recommended`**: choose the files and the allowed mixes
|
|
5
|
+
> for your codebase, then enable it.
|
|
6
|
+
|
|
7
|
+
Ported from
|
|
8
|
+
[`@boring-stack-pkg/eslint-plugin-module-boundaries`](https://github.com/boringstack-xyz/eslint-plugins/tree/main/eslint-plugin-module-boundaries)
|
|
9
|
+
0.2.0 (MIT). Upstream peers ESLint `8.57.0 || ^9.0.0`; this port runs on ESLint 9 and 10.
|
|
10
|
+
|
|
11
|
+
## Why
|
|
12
|
+
|
|
13
|
+
A file that exports a component, the hook it uses, the constant that configures it and the type of its
|
|
14
|
+
props has four reasons to change and four kinds of consumer. Splitting by concern keeps imports
|
|
15
|
+
honest: a type-only consumer never pulls in runtime code, and a constant never drags a component into
|
|
16
|
+
a server bundle.
|
|
17
|
+
|
|
18
|
+
The rule classifies by **AST shape**, never by filename or suffix:
|
|
19
|
+
|
|
20
|
+
| Category | Shape |
|
|
21
|
+
| --- | --- |
|
|
22
|
+
| `type` | `interface`, `type`, `namespace`, `declare ...` |
|
|
23
|
+
| `enum` | `enum` (or `type`, with `enumCategory: 'type'`) |
|
|
24
|
+
| `constant` | any other top-level value |
|
|
25
|
+
| `function` | function declaration or function-valued `const` |
|
|
26
|
+
| `class` | class declaration or class expression |
|
|
27
|
+
| `react-component` | PascalCase function returning JSX (or typed `FC` / `JSX.Element`) |
|
|
28
|
+
| `hook` | a function named like a hook (`^use[A-Z0-9]`) |
|
|
29
|
+
| `schema` | a call on an identifier imported from `zod`, `yup` or `valibot` |
|
|
30
|
+
|
|
31
|
+
## Examples
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
// ✗ a type and a runtime constant
|
|
35
|
+
export interface User {
|
|
36
|
+
id: string;
|
|
37
|
+
}
|
|
38
|
+
export const DEFAULT_USER: User = { id: 'anonymous' };
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
// ✗ a component and a hook
|
|
43
|
+
export function UserCard() {
|
|
44
|
+
return <div />;
|
|
45
|
+
}
|
|
46
|
+
export function useUserCard() {
|
|
47
|
+
return useQuery(userCardQuery);
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
// ✓ one concern: types only
|
|
53
|
+
export interface User {
|
|
54
|
+
id: string;
|
|
55
|
+
}
|
|
56
|
+
export type UserId = User['id'];
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Private helpers do not count
|
|
60
|
+
|
|
61
|
+
Only the **exported** surface defines a module (`ignorePrivateDeclarations`, default `true`). A
|
|
62
|
+
non-exported render helper beside a component, or a filter constant inside a hook file, serves that
|
|
63
|
+
surface and is not a second concern:
|
|
64
|
+
|
|
65
|
+
```tsx
|
|
66
|
+
// ✓ the helper is private
|
|
67
|
+
function renderBadge(count: number) {
|
|
68
|
+
return <span className="badge">{count}</span>;
|
|
69
|
+
}
|
|
70
|
+
export function Inbox({ unread }: { unread: number }) {
|
|
71
|
+
return <div>{renderBadge(unread)}</div>;
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
// ✓ the filter object is private
|
|
77
|
+
const ACTIVE_FILTER = { status: 'active', archived: false } as const;
|
|
78
|
+
export function useActiveProjects() {
|
|
79
|
+
return useQuery({ queryKey: ['projects', ACTIVE_FILTER] });
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
A declaration exported further down by name (`export { DEFAULT_USER }`, `export default UserCard`)
|
|
84
|
+
is surface all the same.
|
|
85
|
+
|
|
86
|
+
## Options
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
type SemanticCategory =
|
|
90
|
+
| 'type' | 'constant' | 'function' | 'class'
|
|
91
|
+
| 'react-component' | 'hook' | 'schema' | 'enum';
|
|
92
|
+
|
|
93
|
+
type Options = {
|
|
94
|
+
/** Category sets a module may mix. Default []. */
|
|
95
|
+
allow?: SemanticCategory[][];
|
|
96
|
+
/** Whether an enum is its own category or a type. Default 'enum'. */
|
|
97
|
+
enumCategory?: 'enum' | 'type';
|
|
98
|
+
/** List every classified declaration and the reason in the report. Default false. */
|
|
99
|
+
debug?: boolean;
|
|
100
|
+
/** Skip `declare ...` and `declare global` entirely. Default false. */
|
|
101
|
+
ignoreAmbientDeclarations?: boolean;
|
|
102
|
+
/** Classify exported declarations only. Default true. */
|
|
103
|
+
ignorePrivateDeclarations?: boolean;
|
|
104
|
+
/** Libraries whose builders count as `schema`. Default ['zod', 'yup', 'valibot']. */
|
|
105
|
+
schemaLibraries?: Array<'zod' | 'yup' | 'valibot'>;
|
|
106
|
+
/** Default { enabled: true }. */
|
|
107
|
+
reactComponentDetection?: { enabled?: boolean };
|
|
108
|
+
/** Default { enabled: true, namePattern: '^use[A-Z0-9].*' }. */
|
|
109
|
+
hookDetection?: { enabled?: boolean; namePattern?: string };
|
|
110
|
+
};
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### `allow`: a NestJS `.constants.ts`
|
|
114
|
+
|
|
115
|
+
In a NestJS module, `<name>.constants.ts` holds one module's enums, unions and DI tokens. That mixes
|
|
116
|
+
categories **by design**, so allow the mix for those files only:
|
|
117
|
+
|
|
118
|
+
```js
|
|
119
|
+
// eslint.config.js
|
|
120
|
+
export default [
|
|
121
|
+
{
|
|
122
|
+
files: ['apps/api/src/**/*.ts'],
|
|
123
|
+
rules: { 'noctcore-architecture/single-semantic-module': 'error' },
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
files: ['apps/api/src/**/*.constants.ts'],
|
|
127
|
+
rules: {
|
|
128
|
+
'noctcore-architecture/single-semantic-module': [
|
|
129
|
+
'error',
|
|
130
|
+
{ allow: [['constant', 'type', 'enum']] },
|
|
131
|
+
],
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
];
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
// ✓ billing.constants.ts with the config above
|
|
139
|
+
export const BILLING_QUEUE = Symbol('BILLING_QUEUE');
|
|
140
|
+
export const INVOICE_STATUSES = ['draft', 'sent', 'paid'] as const;
|
|
141
|
+
export type InvoiceStatus = (typeof INVOICE_STATUSES)[number];
|
|
142
|
+
export enum BillingEvent {
|
|
143
|
+
Paid = 'billing.paid',
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ✗ still reported: a function is outside the allowed group
|
|
147
|
+
export function isPaid(status: InvoiceStatus) {
|
|
148
|
+
return status === 'paid';
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
An `allow` group must cover **every** detected category; a module whose categories fit inside any one
|
|
153
|
+
group passes.
|
|
154
|
+
|
|
155
|
+
### `enumCategory`
|
|
156
|
+
|
|
157
|
+
```js
|
|
158
|
+
{ enumCategory: 'type' } // `export enum Role {}` beside `export type User = {}` is one concern
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### `ignorePrivateDeclarations: false`
|
|
162
|
+
|
|
163
|
+
Restores upstream's original behaviour, where every top-level declaration counts. Expect it to fight
|
|
164
|
+
every non-trivial file.
|
|
165
|
+
|
|
166
|
+
### `debug`
|
|
167
|
+
|
|
168
|
+
Adds each classified declaration and the reason to the report, which is the quickest way to see why a
|
|
169
|
+
file was flagged:
|
|
170
|
+
|
|
171
|
+
```
|
|
172
|
+
- type: User (TypeScript type-space declaration)
|
|
173
|
+
- constant: DEFAULT_USER (object literal runtime value)
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## When not to use it
|
|
177
|
+
|
|
178
|
+
Barrels and generated files. Re-exports (`export * from`, `export { x } from`) are never classified, so
|
|
179
|
+
a pure barrel passes anyway, but generated clients routinely mix every category; scope the rule away
|
|
180
|
+
from them with `files` / `ignores`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@noctcore/eslint-plugin-architecture",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Framework-agnostic folder-per-component and feature-boundary architecture ESLint rules.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -59,6 +59,6 @@
|
|
|
59
59
|
"@typescript-eslint/rule-tester": "^8.61.1",
|
|
60
60
|
"tsup": "^8.5.1",
|
|
61
61
|
"typescript": "^5.6.0",
|
|
62
|
-
"vitest": "^
|
|
62
|
+
"vitest": "^4"
|
|
63
63
|
}
|
|
64
64
|
}
|