@noctcore/eslint-plugin-architecture 0.3.0 → 0.3.2
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/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/docs/rules/barrel-purity.md +20 -19
- package/docs/rules/colocated-test-required.md +1 -1
- package/docs/rules/component-folder-structure.md +7 -1
- package/docs/rules/filename-matches-export.md +24 -4
- package/docs/rules/index-must-reexport-default.md +1 -1
- package/docs/rules/max-import-depth.md +13 -6
- package/docs/rules/no-cross-feature-imports.md +7 -6
- package/docs/rules/single-semantic-module.md +18 -14
- package/package.json +2 -2
package/dist/index.cjs
CHANGED
|
@@ -1385,7 +1385,7 @@ var rules = {
|
|
|
1385
1385
|
|
|
1386
1386
|
// src/index.ts
|
|
1387
1387
|
var NAMESPACE = "noctcore-architecture";
|
|
1388
|
-
var VERSION = "0.3.
|
|
1388
|
+
var VERSION = "0.3.2";
|
|
1389
1389
|
var plugin = {
|
|
1390
1390
|
meta: { name: "@noctcore/eslint-plugin-architecture", version: VERSION },
|
|
1391
1391
|
rules,
|
package/dist/index.js
CHANGED
|
@@ -1347,7 +1347,7 @@ var rules = {
|
|
|
1347
1347
|
|
|
1348
1348
|
// src/index.ts
|
|
1349
1349
|
var NAMESPACE = "noctcore-architecture";
|
|
1350
|
-
var VERSION = "0.3.
|
|
1350
|
+
var VERSION = "0.3.2";
|
|
1351
1351
|
var plugin = {
|
|
1352
1352
|
meta: { name: "@noctcore/eslint-plugin-architecture", version: VERSION },
|
|
1353
1353
|
rules,
|
|
@@ -14,25 +14,26 @@ folder now silently pulls that behavior in, and the folder no longer has a clean
|
|
|
14
14
|
Only `index.ts` / `index.tsx` (and the other index extensions) are inspected. Each top-level
|
|
15
15
|
statement that is not a pure re-export is reported.
|
|
16
16
|
|
|
17
|
-
```ts
|
|
18
|
-
//
|
|
19
|
-
|
|
20
|
-
export
|
|
21
|
-
export
|
|
22
|
-
|
|
23
|
-
export {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
export
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
export
|
|
31
|
-
export
|
|
32
|
-
export
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
17
|
+
```ts good filename=src/components/Card/index.ts
|
|
18
|
+
export { Card } from './Card'; // named re-export
|
|
19
|
+
export * from './Card.types'; // star re-export
|
|
20
|
+
export * as card from './Card'; // namespace re-export
|
|
21
|
+
export { default as CardView } from './Card'; // default re-export
|
|
22
|
+
import { a } from './a'; // import that feeds a re-export
|
|
23
|
+
export { a }; // ...its matching specifier
|
|
24
|
+
export type { Props } from './Card'; // type re-export
|
|
25
|
+
import Card from './Card';
|
|
26
|
+
export default Card; // re-export of a binding by name
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```ts bad reports=7 filename=src/components/Card/index.ts
|
|
30
|
+
export const helper = 1; // local declaration
|
|
31
|
+
export function build() {} // local declaration
|
|
32
|
+
export type T = string; // local declaration
|
|
33
|
+
export default () => 1; // a value, not a re-export
|
|
34
|
+
import './styles.css'; // side-effect import
|
|
35
|
+
console.log('hi'); // side-effect statement
|
|
36
|
+
const cache = new Map(); // non-export code
|
|
36
37
|
```
|
|
37
38
|
|
|
38
39
|
An import that carries bindings is allowed because it feeds a re-export; a specifier-less
|
|
@@ -14,7 +14,7 @@ is obvious when missing.
|
|
|
14
14
|
For a file matching `include`, the rule reads the file's directory and looks for a sibling whose name
|
|
15
15
|
is `<stem>.test.<ext>` or `<stem>.spec.<ext>` (any extension). If none exists, it reports.
|
|
16
16
|
|
|
17
|
-
```
|
|
17
|
+
```text prose reason="the rule checks for a sibling test file on disk"
|
|
18
18
|
src/hooks/useCart.ts ← include: ['**/use*.ts']
|
|
19
19
|
src/hooks/useCart.test.ts ✓ colocated test present
|
|
20
20
|
src/hooks/useWishlist.ts ✗ no useWishlist.test.* / .spec.* sibling
|
|
@@ -9,13 +9,19 @@ In a folder-per-component layout, a component is a folder — not a lone `.tsx`.
|
|
|
9
9
|
`index.ts` barrel always travel with the component, refactors stay local and nothing is quietly
|
|
10
10
|
untested or undocumented. This rule enforces that colocation by construction.
|
|
11
11
|
|
|
12
|
+
A purely presentational component has no logic to extract, and its `.hooks.ts` ends up as an
|
|
13
|
+
`export {};` with a comment. That is the cost of the convention, and it is deliberate: the file
|
|
14
|
+
being present means there is one obvious place for logic to go the day the component grows some,
|
|
15
|
+
and a reviewer never has to ask where it lives. A project that disagrees drops `.hooks.ts` from
|
|
16
|
+
`requiredSiblings` rather than carrying stub files.
|
|
17
|
+
|
|
12
18
|
## What it flags
|
|
13
19
|
|
|
14
20
|
For every **component entry file** — a PascalCase `.tsx` whose basename equals its parent folder
|
|
15
21
|
(`TaskCard/TaskCard.tsx`) — that lives under the configured `componentRoot`, the rule reads the
|
|
16
22
|
component's directory and reports any sibling from the required set that is missing on disk.
|
|
17
23
|
|
|
18
|
-
```
|
|
24
|
+
```text prose reason="the rule checks for sibling files on disk"
|
|
19
25
|
components/board/TaskCard/
|
|
20
26
|
TaskCard.tsx ← entry file (checked)
|
|
21
27
|
TaskCard.hooks.ts ┐
|
|
@@ -22,10 +22,30 @@ The rule first resolves the file's **primary export**:
|
|
|
22
22
|
The basename and the identifier are compared **case- and separator-insensitively**, so naming
|
|
23
23
|
conventions never collide:
|
|
24
24
|
|
|
25
|
-
```ts
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
```ts bad filename=src/utils/helpers.ts
|
|
26
|
+
// genuine mismatch
|
|
27
|
+
export const formatDate = () => {};
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Renaming the file is one fix, renaming the export the other:
|
|
31
|
+
|
|
32
|
+
```ts good filename=src/utils/format-date.ts relocation
|
|
33
|
+
export const formatDate = () => {};
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```ts good filename=src/utils/helpers.ts
|
|
37
|
+
export const helpers = () => {};
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The comparison ignores case and separators:
|
|
41
|
+
|
|
42
|
+
```tsx good filename=src/components/TaskCard.tsx relocation
|
|
43
|
+
export default function TaskCard() {}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
```ts good filename=src/hooks/use-thing.ts relocation
|
|
47
|
+
// kebab-case file, camelCase export
|
|
48
|
+
export const useThing = () => {};
|
|
29
49
|
```
|
|
30
50
|
|
|
31
51
|
`index` files and files matching an `ignore` glob are always skipped.
|
|
@@ -15,7 +15,7 @@ The rule only activates for an `index.ts` that sits next to a `<Folder>.tsx` of
|
|
|
15
15
|
name on disk (`Card/Card.tsx` beside `Card/index.ts`). For those barrels, it reports when the file
|
|
16
16
|
never re-exports the sibling's default export.
|
|
17
17
|
|
|
18
|
-
```ts
|
|
18
|
+
```ts prose reason="the rule only runs when a sibling Card.tsx exists on disk"
|
|
19
19
|
// Card/index.ts
|
|
20
20
|
|
|
21
21
|
export { default as Card } from './Card'; // ✓
|
|
@@ -14,10 +14,13 @@ reach before it must become an alias.
|
|
|
14
14
|
Any relative specifier whose leading `..` run exceeds `max` is reported, on every source-carrying
|
|
15
15
|
construct: `import`, `import()`, `export … from`, and `export * from`.
|
|
16
16
|
|
|
17
|
-
```ts
|
|
17
|
+
```ts bad
|
|
18
18
|
// max: 3 (default)
|
|
19
|
-
import
|
|
20
|
-
|
|
19
|
+
import b from '../../../../shared/log'; // climbs 4 levels
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```ts good
|
|
23
|
+
import a from '../../../shared'; // exactly at the limit
|
|
21
24
|
```
|
|
22
25
|
|
|
23
26
|
## Autofix
|
|
@@ -31,9 +34,13 @@ When `alias` maps a directory-anchor segment to an alias prefix, a too-deep impo
|
|
|
31
34
|
}]
|
|
32
35
|
```
|
|
33
36
|
|
|
34
|
-
```ts
|
|
35
|
-
// from
|
|
36
|
-
import x from '../../../../shared/log';
|
|
37
|
+
```ts bad filename=src/a/b/c/d/deep.ts options={"alias":{"src":"@"}}
|
|
38
|
+
// autofixes to: import x from '@/shared/log';
|
|
39
|
+
import x from '../../../../shared/log';
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
```ts good filename=src/a/b/c/d/deep.ts options={"alias":{"src":"@"}}
|
|
43
|
+
import x from '@/shared/log';
|
|
37
44
|
```
|
|
38
45
|
|
|
39
46
|
With no matching alias anchor on the resolved path, the violation is **reported without a fix** —
|
|
@@ -24,13 +24,14 @@ Both the alias form (`<alias>/<feature>/…`) and relative paths that climb into
|
|
|
24
24
|
to forbid them). Imports of the current feature, of a shared feature, or of non-feature modules are
|
|
25
25
|
always fine.
|
|
26
26
|
|
|
27
|
-
```tsx
|
|
28
|
-
|
|
27
|
+
```tsx good filename=src/components/board/Board/Board.tsx options={"featureRoot":"components","alias":"@/components","sharedFeatures":["ui"]}
|
|
28
|
+
import { Button } from '@/components/ui/Button'; // shared feature
|
|
29
|
+
import { cn } from '@/lib/utils'; // non-feature module
|
|
30
|
+
import { TaskCard } from '../TaskCard/TaskCard'; // same feature
|
|
31
|
+
```
|
|
29
32
|
|
|
30
|
-
|
|
31
|
-
import {
|
|
32
|
-
import { TaskCard } from '../TaskCard/TaskCard'; // ✓ same feature
|
|
33
|
-
import { ProjectCard } from '@/components/projects/…'; // ✗ cross-feature
|
|
33
|
+
```tsx bad filename=src/components/board/Board/Board.tsx options={"featureRoot":"components","alias":"@/components","sharedFeatures":["ui"]}
|
|
34
|
+
import { ProjectCard } from '@/components/projects/ProjectCard'; // cross-feature
|
|
34
35
|
```
|
|
35
36
|
|
|
36
37
|
## Options
|
|
@@ -30,16 +30,16 @@ The rule classifies by **AST shape**, never by filename or suffix:
|
|
|
30
30
|
|
|
31
31
|
## Examples
|
|
32
32
|
|
|
33
|
-
```ts
|
|
34
|
-
//
|
|
33
|
+
```ts bad
|
|
34
|
+
// a type and a runtime constant
|
|
35
35
|
export interface User {
|
|
36
36
|
id: string;
|
|
37
37
|
}
|
|
38
38
|
export const DEFAULT_USER: User = { id: 'anonymous' };
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
-
```
|
|
42
|
-
//
|
|
41
|
+
```tsx bad
|
|
42
|
+
// a component and a hook
|
|
43
43
|
export function UserCard() {
|
|
44
44
|
return <div />;
|
|
45
45
|
}
|
|
@@ -48,8 +48,8 @@ export function useUserCard() {
|
|
|
48
48
|
}
|
|
49
49
|
```
|
|
50
50
|
|
|
51
|
-
```ts
|
|
52
|
-
//
|
|
51
|
+
```ts good
|
|
52
|
+
// one concern: types only
|
|
53
53
|
export interface User {
|
|
54
54
|
id: string;
|
|
55
55
|
}
|
|
@@ -62,8 +62,8 @@ Only the **exported** surface defines a module (`ignorePrivateDeclarations`, def
|
|
|
62
62
|
non-exported render helper beside a component, or a filter constant inside a hook file, serves that
|
|
63
63
|
surface and is not a second concern:
|
|
64
64
|
|
|
65
|
-
```tsx
|
|
66
|
-
//
|
|
65
|
+
```tsx good
|
|
66
|
+
// the helper is private
|
|
67
67
|
function renderBadge(count: number) {
|
|
68
68
|
return <span className="badge">{count}</span>;
|
|
69
69
|
}
|
|
@@ -72,8 +72,8 @@ export function Inbox({ unread }: { unread: number }) {
|
|
|
72
72
|
}
|
|
73
73
|
```
|
|
74
74
|
|
|
75
|
-
```ts
|
|
76
|
-
//
|
|
75
|
+
```ts good
|
|
76
|
+
// the filter object is private
|
|
77
77
|
const ACTIVE_FILTER = { status: 'active', archived: false } as const;
|
|
78
78
|
export function useActiveProjects() {
|
|
79
79
|
return useQuery({ queryKey: ['projects', ACTIVE_FILTER] });
|
|
@@ -85,7 +85,7 @@ is surface all the same.
|
|
|
85
85
|
|
|
86
86
|
## Options
|
|
87
87
|
|
|
88
|
-
```ts
|
|
88
|
+
```ts prose reason="the options type, not a lint example"
|
|
89
89
|
type SemanticCategory =
|
|
90
90
|
| 'type' | 'constant' | 'function' | 'class'
|
|
91
91
|
| 'react-component' | 'hook' | 'schema' | 'enum';
|
|
@@ -134,16 +134,20 @@ export default [
|
|
|
134
134
|
];
|
|
135
135
|
```
|
|
136
136
|
|
|
137
|
-
```ts
|
|
138
|
-
//
|
|
137
|
+
```ts good filename=apps/api/src/billing/billing.constants.ts options={"allow":[["constant","type","enum"]]}
|
|
138
|
+
// billing.constants.ts with the config above
|
|
139
139
|
export const BILLING_QUEUE = Symbol('BILLING_QUEUE');
|
|
140
140
|
export const INVOICE_STATUSES = ['draft', 'sent', 'paid'] as const;
|
|
141
141
|
export type InvoiceStatus = (typeof INVOICE_STATUSES)[number];
|
|
142
142
|
export enum BillingEvent {
|
|
143
143
|
Paid = 'billing.paid',
|
|
144
144
|
}
|
|
145
|
+
```
|
|
145
146
|
|
|
146
|
-
|
|
147
|
+
```ts bad filename=apps/api/src/billing/billing.constants.ts options={"allow":[["constant","type","enum"]]}
|
|
148
|
+
// still reported: a function is outside the allowed group
|
|
149
|
+
export const INVOICE_STATUSES = ['draft', 'sent', 'paid'] as const;
|
|
150
|
+
export type InvoiceStatus = (typeof INVOICE_STATUSES)[number];
|
|
147
151
|
export function isPaid(status: InvoiceStatus) {
|
|
148
152
|
return status === 'paid';
|
|
149
153
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@noctcore/eslint-plugin-architecture",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "Framework-agnostic folder-per-component and feature-boundary architecture ESLint rules.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"test": "vitest run"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@noctcore/eslint-utils": "^0.1.
|
|
48
|
+
"@noctcore/eslint-utils": "^0.1.1",
|
|
49
49
|
"@typescript-eslint/utils": "^8.61.1"
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|