@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.
@@ -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.1.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": "^3"
62
+ "vitest": "^4"
63
63
  }
64
64
  }