@noctcore/eslint-plugin-architecture 0.1.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 +51 -0
- package/dist/index.cjs +410 -0
- package/dist/index.d.cts +54 -0
- package/dist/index.d.ts +54 -0
- package/dist/index.js +372 -0
- package/docs/rules/component-folder-structure.md +50 -0
- package/docs/rules/index-must-reexport-default.md +44 -0
- package/docs/rules/no-cross-feature-imports.md +57 -0
- package/package.json +64 -0
package/README.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# @noctcore/eslint-plugin-architecture
|
|
2
|
+
|
|
3
|
+
Framework-agnostic folder-per-component and feature-boundary architecture rules. Flat-config only,
|
|
4
|
+
ESLint 9+.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
bun add -D @noctcore/eslint-plugin-architecture # or npm i -D / pnpm add -D
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Use
|
|
13
|
+
|
|
14
|
+
```js
|
|
15
|
+
// eslint.config.js
|
|
16
|
+
import architecture from '@noctcore/eslint-plugin-architecture';
|
|
17
|
+
|
|
18
|
+
export default [
|
|
19
|
+
architecture.configs.recommended,
|
|
20
|
+
];
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Or wire rules individually:
|
|
24
|
+
|
|
25
|
+
```js
|
|
26
|
+
import architecture from '@noctcore/eslint-plugin-architecture';
|
|
27
|
+
|
|
28
|
+
export default [
|
|
29
|
+
{
|
|
30
|
+
plugins: { 'noctcore-architecture': architecture },
|
|
31
|
+
rules: {
|
|
32
|
+
'noctcore-architecture/component-folder-structure': ['error', { componentRoot: 'components' }],
|
|
33
|
+
'noctcore-architecture/no-cross-feature-imports': ['error', { alias: '@/components' }],
|
|
34
|
+
'noctcore-architecture/index-must-reexport-default': 'error',
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
];
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Every rule anchors on a configurable directory segment (default `components`) rather than an absolute
|
|
41
|
+
path, so it behaves the same whether ESLint runs from the repo root or per-package, on POSIX or
|
|
42
|
+
Windows. Two of the three rules inspect files on disk (sibling sets, barrel siblings), so run ESLint
|
|
43
|
+
against real file paths, not virtual sources.
|
|
44
|
+
|
|
45
|
+
## Rules
|
|
46
|
+
|
|
47
|
+
| Rule | Description | 🔧 |
|
|
48
|
+
| --- | --- | --- |
|
|
49
|
+
| [`component-folder-structure`](./docs/rules/component-folder-structure.md) | A component entry file must ship its full sibling set (hooks, types, story, test, barrel) on disk. | |
|
|
50
|
+
| [`index-must-reexport-default`](./docs/rules/index-must-reexport-default.md) | A component folder's `index.ts` must re-export the sibling default named after the folder. | |
|
|
51
|
+
| [`no-cross-feature-imports`](./docs/rules/no-cross-feature-imports.md) | A file in one feature may not import runtime code from another feature. | |
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/index.ts
|
|
31
|
+
var index_exports = {};
|
|
32
|
+
__export(index_exports, {
|
|
33
|
+
configs: () => configs,
|
|
34
|
+
default: () => index_default,
|
|
35
|
+
rules: () => rules
|
|
36
|
+
});
|
|
37
|
+
module.exports = __toCommonJS(index_exports);
|
|
38
|
+
|
|
39
|
+
// src/configs/recommended.ts
|
|
40
|
+
var recommended = {
|
|
41
|
+
"noctcore-architecture/component-folder-structure": "error",
|
|
42
|
+
"noctcore-architecture/index-must-reexport-default": "error",
|
|
43
|
+
"noctcore-architecture/no-cross-feature-imports": "error"
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// src/rules/component-folder-structure.ts
|
|
47
|
+
var import_node_path2 = __toESM(require("path"), 1);
|
|
48
|
+
|
|
49
|
+
// src/createRule.ts
|
|
50
|
+
var import_eslint_utils = require("@noctcore/eslint-utils");
|
|
51
|
+
var createRule = (0, import_eslint_utils.makeCreateRule)("architecture");
|
|
52
|
+
|
|
53
|
+
// src/utils.ts
|
|
54
|
+
var import_node_fs = require("fs");
|
|
55
|
+
var import_node_path = __toESM(require("path"), 1);
|
|
56
|
+
function toPosix(filename) {
|
|
57
|
+
return filename.split(import_node_path.default.sep).join("/").split("\\").join("/");
|
|
58
|
+
}
|
|
59
|
+
function getBasename(filename) {
|
|
60
|
+
return import_node_path.default.basename(filename);
|
|
61
|
+
}
|
|
62
|
+
function isPascalCase(segment) {
|
|
63
|
+
return /^[A-Z][A-Za-z0-9]*$/.test(segment);
|
|
64
|
+
}
|
|
65
|
+
function isComponentFileName(filename) {
|
|
66
|
+
return /^[A-Z][A-Za-z0-9]*\.tsx$/.test(getBasename(filename));
|
|
67
|
+
}
|
|
68
|
+
function getComponentName(filename) {
|
|
69
|
+
return getBasename(filename).replace(/\.tsx$/, "");
|
|
70
|
+
}
|
|
71
|
+
function isComponentEntryFile(filename) {
|
|
72
|
+
if (!isComponentFileName(filename)) {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
return getComponentName(filename) === import_node_path.default.basename(import_node_path.default.dirname(filename));
|
|
76
|
+
}
|
|
77
|
+
function segmentsAfterRoot(filename, root) {
|
|
78
|
+
const marker = `/${root}/`;
|
|
79
|
+
const posix = toPosix(filename);
|
|
80
|
+
const idx = posix.lastIndexOf(marker);
|
|
81
|
+
if (idx === -1) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
return posix.slice(idx + marker.length).split("/").filter((segment) => segment.length > 0);
|
|
85
|
+
}
|
|
86
|
+
function getFeatureName(filename, root) {
|
|
87
|
+
const segments = segmentsAfterRoot(filename, root);
|
|
88
|
+
if (segments === null || segments.length === 0) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
const feature = segments[0];
|
|
92
|
+
return feature !== void 0 && feature.length > 0 ? feature : null;
|
|
93
|
+
}
|
|
94
|
+
function escapeRegExpLiteral(value) {
|
|
95
|
+
return value.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
96
|
+
}
|
|
97
|
+
function globToRegExp(glob) {
|
|
98
|
+
let out = "";
|
|
99
|
+
for (let i = 0; i < glob.length; i += 1) {
|
|
100
|
+
const char = glob[i];
|
|
101
|
+
if (char === "*") {
|
|
102
|
+
if (glob[i + 1] === "*") {
|
|
103
|
+
if (glob[i + 2] === "/") {
|
|
104
|
+
out += "(?:[^/]*/)*";
|
|
105
|
+
i += 2;
|
|
106
|
+
} else {
|
|
107
|
+
out += ".*";
|
|
108
|
+
i += 1;
|
|
109
|
+
}
|
|
110
|
+
} else {
|
|
111
|
+
out += "[^/]*";
|
|
112
|
+
}
|
|
113
|
+
} else if (char === "?") {
|
|
114
|
+
out += "[^/]";
|
|
115
|
+
} else if (char !== void 0) {
|
|
116
|
+
out += escapeRegExpLiteral(char);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return new RegExp(`^${out}$`);
|
|
120
|
+
}
|
|
121
|
+
function isIgnoredPath(filename, ignorePaths) {
|
|
122
|
+
if (ignorePaths.length === 0) {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
const posix = toPosix(filename);
|
|
126
|
+
return ignorePaths.some((glob) => globToRegExp(glob).test(posix));
|
|
127
|
+
}
|
|
128
|
+
function siblingExists(dir, sibling) {
|
|
129
|
+
return (0, import_node_fs.existsSync)(import_node_path.default.join(dir, sibling));
|
|
130
|
+
}
|
|
131
|
+
function readDirSafe(dir) {
|
|
132
|
+
try {
|
|
133
|
+
return new Set((0, import_node_fs.readdirSync)(dir));
|
|
134
|
+
} catch {
|
|
135
|
+
return /* @__PURE__ */ new Set();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// src/rules/component-folder-structure.ts
|
|
140
|
+
var RULE_NAME = "component-folder-structure";
|
|
141
|
+
var DEFAULT_COMPONENT_ROOT = "components";
|
|
142
|
+
var DEFAULT_IGNORE_PATHS = ["**/ui/**"];
|
|
143
|
+
var DEFAULT_REQUIRED_SIBLINGS = [
|
|
144
|
+
".hooks.ts",
|
|
145
|
+
".types.ts",
|
|
146
|
+
".stories.tsx",
|
|
147
|
+
".test.tsx",
|
|
148
|
+
"index.ts"
|
|
149
|
+
];
|
|
150
|
+
function resolveSibling(template, name) {
|
|
151
|
+
return template.startsWith(".") ? `${name}${template}` : template;
|
|
152
|
+
}
|
|
153
|
+
var optionSchema = {
|
|
154
|
+
type: "object",
|
|
155
|
+
additionalProperties: false,
|
|
156
|
+
properties: {
|
|
157
|
+
componentRoot: { type: "string" },
|
|
158
|
+
requiredSiblings: { type: "array", items: { type: "string" }, uniqueItems: true },
|
|
159
|
+
ignorePaths: { type: "array", items: { type: "string" }, uniqueItems: true }
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
var componentFolderStructureRule = createRule({
|
|
163
|
+
name: RULE_NAME,
|
|
164
|
+
meta: {
|
|
165
|
+
type: "problem",
|
|
166
|
+
docs: {
|
|
167
|
+
description: "A component `<Name>/<Name>.tsx` under `<componentRoot>/<feature>/...` must have its sibling set (`.hooks.ts`, `.types.ts`, `.stories.tsx`, `.test.tsx`, `index.ts`) present on disk."
|
|
168
|
+
},
|
|
169
|
+
schema: [optionSchema],
|
|
170
|
+
messages: {
|
|
171
|
+
missingSiblings: "Component `{{name}}` is missing sibling file(s): {{missing}}. Every component folder must carry its hooks, types, stories, test, and index barrel."
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
defaultOptions: [
|
|
175
|
+
{
|
|
176
|
+
componentRoot: DEFAULT_COMPONENT_ROOT,
|
|
177
|
+
requiredSiblings: [...DEFAULT_REQUIRED_SIBLINGS],
|
|
178
|
+
ignorePaths: [...DEFAULT_IGNORE_PATHS]
|
|
179
|
+
}
|
|
180
|
+
],
|
|
181
|
+
create(context, [options]) {
|
|
182
|
+
const componentRoot = options.componentRoot ?? DEFAULT_COMPONENT_ROOT;
|
|
183
|
+
const ignorePaths = options.ignorePaths ?? DEFAULT_IGNORE_PATHS;
|
|
184
|
+
const siblingTemplates = options.requiredSiblings ?? DEFAULT_REQUIRED_SIBLINGS;
|
|
185
|
+
const filename = context.filename;
|
|
186
|
+
if (!isComponentEntryFile(filename) || isIgnoredPath(filename, ignorePaths)) {
|
|
187
|
+
return {};
|
|
188
|
+
}
|
|
189
|
+
if (getFeatureName(filename, componentRoot) === null) {
|
|
190
|
+
return {};
|
|
191
|
+
}
|
|
192
|
+
const name = getComponentName(filename);
|
|
193
|
+
const dir = import_node_path2.default.dirname(filename);
|
|
194
|
+
const required = siblingTemplates.map((template) => resolveSibling(template, name));
|
|
195
|
+
const present = readDirSafe(dir);
|
|
196
|
+
const missing = required.filter((sibling) => !present.has(sibling));
|
|
197
|
+
return {
|
|
198
|
+
Program(node) {
|
|
199
|
+
if (missing.length > 0) {
|
|
200
|
+
context.report({
|
|
201
|
+
node,
|
|
202
|
+
messageId: "missingSiblings",
|
|
203
|
+
data: { name, missing: missing.join(", ") }
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// src/rules/index-must-reexport-default.ts
|
|
212
|
+
var import_node_path3 = __toESM(require("path"), 1);
|
|
213
|
+
var import_utils2 = require("@typescript-eslint/utils");
|
|
214
|
+
var RULE_NAME2 = "index-must-reexport-default";
|
|
215
|
+
var DEFAULT_IGNORE_PATHS2 = [];
|
|
216
|
+
var optionSchema2 = {
|
|
217
|
+
type: "object",
|
|
218
|
+
additionalProperties: false,
|
|
219
|
+
properties: {
|
|
220
|
+
ignorePaths: { type: "array", items: { type: "string" }, uniqueItems: true }
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
function reexportsDefault(node) {
|
|
224
|
+
if (node.source === null) {
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
return node.specifiers.some(
|
|
228
|
+
(specifier) => specifier.local.type === import_utils2.AST_NODE_TYPES.Identifier && specifier.local.name === "default"
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
var indexMustReexportDefaultRule = createRule({
|
|
232
|
+
name: RULE_NAME2,
|
|
233
|
+
meta: {
|
|
234
|
+
type: "problem",
|
|
235
|
+
docs: {
|
|
236
|
+
description: "A component folder's `index.ts` must re-export the component default (`export { default as <Name> } from './<Name>'`)."
|
|
237
|
+
},
|
|
238
|
+
schema: [optionSchema2],
|
|
239
|
+
messages: {
|
|
240
|
+
missingDefaultReexport: "`index.ts` must re-export the {{name}} default: `export { default as {{name}} } from './{{name}}'`."
|
|
241
|
+
}
|
|
242
|
+
},
|
|
243
|
+
defaultOptions: [{ ignorePaths: [] }],
|
|
244
|
+
create(context, [options]) {
|
|
245
|
+
const ignorePaths = options.ignorePaths ?? DEFAULT_IGNORE_PATHS2;
|
|
246
|
+
const filename = context.filename;
|
|
247
|
+
if (getBasename(filename) !== "index.ts" || isIgnoredPath(filename, ignorePaths)) {
|
|
248
|
+
return {};
|
|
249
|
+
}
|
|
250
|
+
const dir = import_node_path3.default.dirname(filename);
|
|
251
|
+
const folderName = import_node_path3.default.basename(dir);
|
|
252
|
+
if (!isPascalCase(folderName) || !siblingExists(dir, `${folderName}.tsx`)) {
|
|
253
|
+
return {};
|
|
254
|
+
}
|
|
255
|
+
let hasDefaultReexport = false;
|
|
256
|
+
return {
|
|
257
|
+
ExportNamedDeclaration(node) {
|
|
258
|
+
if (reexportsDefault(node)) {
|
|
259
|
+
hasDefaultReexport = true;
|
|
260
|
+
}
|
|
261
|
+
},
|
|
262
|
+
"Program:exit"(node) {
|
|
263
|
+
if (!hasDefaultReexport) {
|
|
264
|
+
context.report({
|
|
265
|
+
node,
|
|
266
|
+
messageId: "missingDefaultReexport",
|
|
267
|
+
data: { name: folderName }
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// src/rules/no-cross-feature-imports.ts
|
|
276
|
+
var import_node_path4 = __toESM(require("path"), 1);
|
|
277
|
+
var import_utils4 = require("@typescript-eslint/utils");
|
|
278
|
+
var RULE_NAME3 = "no-cross-feature-imports";
|
|
279
|
+
var DEFAULT_FEATURE_ROOT = "components";
|
|
280
|
+
var DEFAULT_ALIAS = "@/components";
|
|
281
|
+
var DEFAULT_SHARED_FEATURES = ["ui"];
|
|
282
|
+
var optionSchema3 = {
|
|
283
|
+
type: "object",
|
|
284
|
+
additionalProperties: false,
|
|
285
|
+
properties: {
|
|
286
|
+
featureRoot: { type: "string" },
|
|
287
|
+
alias: { type: "string" },
|
|
288
|
+
sharedFeatures: { type: "array", items: { type: "string" }, uniqueItems: true },
|
|
289
|
+
allowTypeImports: { type: "boolean" }
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
function escapeRegExpLiteral2(value) {
|
|
293
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
294
|
+
}
|
|
295
|
+
function aliasFeatureMatcher(alias) {
|
|
296
|
+
return new RegExp(`^${escapeRegExpLiteral2(alias)}/([^/]+)`);
|
|
297
|
+
}
|
|
298
|
+
function resolveTargetFeature(source, currentFile, aliasRe, featureRoot) {
|
|
299
|
+
const aliasMatch = aliasRe.exec(source);
|
|
300
|
+
if (aliasMatch) {
|
|
301
|
+
return aliasMatch[1] ?? null;
|
|
302
|
+
}
|
|
303
|
+
if (source.startsWith(".")) {
|
|
304
|
+
const resolved = import_node_path4.default.resolve(import_node_path4.default.dirname(currentFile), source);
|
|
305
|
+
return getFeatureName(resolved, featureRoot);
|
|
306
|
+
}
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
var noCrossFeatureImportsRule = createRule({
|
|
310
|
+
name: RULE_NAME3,
|
|
311
|
+
meta: {
|
|
312
|
+
type: "problem",
|
|
313
|
+
docs: {
|
|
314
|
+
description: "A file in one feature may not import runtime code from another feature. Move shared code to a shared module or a shared feature."
|
|
315
|
+
},
|
|
316
|
+
schema: [optionSchema3],
|
|
317
|
+
messages: {
|
|
318
|
+
crossFeatureImport: "Cross-feature import: `{{current}}` may not import runtime code from `{{root}}/{{target}}`. Move shared code to a shared module or a shared feature (e.g. `{{root}}/ui`)."
|
|
319
|
+
}
|
|
320
|
+
},
|
|
321
|
+
defaultOptions: [
|
|
322
|
+
{
|
|
323
|
+
featureRoot: DEFAULT_FEATURE_ROOT,
|
|
324
|
+
alias: DEFAULT_ALIAS,
|
|
325
|
+
sharedFeatures: [...DEFAULT_SHARED_FEATURES],
|
|
326
|
+
allowTypeImports: true
|
|
327
|
+
}
|
|
328
|
+
],
|
|
329
|
+
create(context, [options]) {
|
|
330
|
+
const featureRoot = options.featureRoot ?? DEFAULT_FEATURE_ROOT;
|
|
331
|
+
const alias = options.alias ?? DEFAULT_ALIAS;
|
|
332
|
+
const sharedFeatures = options.sharedFeatures ?? DEFAULT_SHARED_FEATURES;
|
|
333
|
+
const allowTypeImports = options.allowTypeImports ?? true;
|
|
334
|
+
const aliasRe = aliasFeatureMatcher(alias);
|
|
335
|
+
const current = getFeatureName(context.filename, featureRoot);
|
|
336
|
+
if (current === null) {
|
|
337
|
+
return {};
|
|
338
|
+
}
|
|
339
|
+
function checkSource(sourceNode, typeOnly) {
|
|
340
|
+
if (allowTypeImports && typeOnly) {
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
const source = sourceNode.value;
|
|
344
|
+
if (typeof source !== "string") {
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
const target = resolveTargetFeature(source, context.filename, aliasRe, featureRoot);
|
|
348
|
+
if (target === null || target === current || sharedFeatures.includes(target)) {
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
context.report({
|
|
352
|
+
node: sourceNode,
|
|
353
|
+
messageId: "crossFeatureImport",
|
|
354
|
+
data: { current, target, root: featureRoot }
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
return {
|
|
358
|
+
ImportDeclaration(node) {
|
|
359
|
+
if (node.source.type === import_utils4.AST_NODE_TYPES.Literal) {
|
|
360
|
+
checkSource(node.source, node.importKind === "type");
|
|
361
|
+
}
|
|
362
|
+
},
|
|
363
|
+
// Dynamic `import()` is runtime by nature — never type-only.
|
|
364
|
+
ImportExpression(node) {
|
|
365
|
+
if (node.source.type === import_utils4.AST_NODE_TYPES.Literal) {
|
|
366
|
+
checkSource(node.source, false);
|
|
367
|
+
}
|
|
368
|
+
},
|
|
369
|
+
// `export { x } from '…'` re-export laundering.
|
|
370
|
+
ExportNamedDeclaration(node) {
|
|
371
|
+
if (node.source !== null && node.source.type === import_utils4.AST_NODE_TYPES.Literal) {
|
|
372
|
+
checkSource(node.source, node.exportKind === "type");
|
|
373
|
+
}
|
|
374
|
+
},
|
|
375
|
+
// `export * from '…'` re-export laundering.
|
|
376
|
+
ExportAllDeclaration(node) {
|
|
377
|
+
if (node.source.type === import_utils4.AST_NODE_TYPES.Literal) {
|
|
378
|
+
checkSource(node.source, node.exportKind === "type");
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
// src/rules/index.ts
|
|
386
|
+
var rules = {
|
|
387
|
+
"component-folder-structure": componentFolderStructureRule,
|
|
388
|
+
"index-must-reexport-default": indexMustReexportDefaultRule,
|
|
389
|
+
"no-cross-feature-imports": noCrossFeatureImportsRule
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
// src/index.ts
|
|
393
|
+
var NAMESPACE = "noctcore-architecture";
|
|
394
|
+
var VERSION = "0.1.0";
|
|
395
|
+
var plugin = {
|
|
396
|
+
meta: { name: "@noctcore/eslint-plugin-architecture", version: VERSION },
|
|
397
|
+
rules,
|
|
398
|
+
configs: {}
|
|
399
|
+
};
|
|
400
|
+
plugin.configs.recommended = {
|
|
401
|
+
plugins: { [NAMESPACE]: plugin },
|
|
402
|
+
rules: recommended
|
|
403
|
+
};
|
|
404
|
+
var configs = plugin.configs;
|
|
405
|
+
var index_default = plugin;
|
|
406
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
407
|
+
0 && (module.exports = {
|
|
408
|
+
configs,
|
|
409
|
+
rules
|
|
410
|
+
});
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import * as _typescript_eslint_utils_ts_eslint from '@typescript-eslint/utils/ts-eslint';
|
|
2
|
+
|
|
3
|
+
interface NoCrossFeatureImportsOptions {
|
|
4
|
+
readonly featureRoot?: string;
|
|
5
|
+
readonly alias?: string;
|
|
6
|
+
readonly sharedFeatures?: readonly string[];
|
|
7
|
+
readonly allowTypeImports?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface IndexMustReexportDefaultOptions {
|
|
11
|
+
readonly ignorePaths?: readonly string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface ComponentFolderStructureOptions {
|
|
15
|
+
readonly componentRoot?: string;
|
|
16
|
+
readonly requiredSiblings?: readonly string[];
|
|
17
|
+
readonly ignorePaths?: readonly string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Every rule this plugin exposes, keyed by its (unprefixed) rule id. */
|
|
21
|
+
declare const rules: {
|
|
22
|
+
'component-folder-structure': _typescript_eslint_utils_ts_eslint.RuleModule<"missingSiblings", [ComponentFolderStructureOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
23
|
+
name: string;
|
|
24
|
+
};
|
|
25
|
+
'index-must-reexport-default': _typescript_eslint_utils_ts_eslint.RuleModule<"missingDefaultReexport", [IndexMustReexportDefaultOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
26
|
+
name: string;
|
|
27
|
+
};
|
|
28
|
+
'no-cross-feature-imports': _typescript_eslint_utils_ts_eslint.RuleModule<"crossFeatureImport", [NoCrossFeatureImportsOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
29
|
+
name: string;
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
declare const plugin: {
|
|
34
|
+
meta: {
|
|
35
|
+
name: string;
|
|
36
|
+
version: string;
|
|
37
|
+
};
|
|
38
|
+
rules: {
|
|
39
|
+
'component-folder-structure': _typescript_eslint_utils_ts_eslint.RuleModule<"missingSiblings", [ComponentFolderStructureOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
40
|
+
name: string;
|
|
41
|
+
};
|
|
42
|
+
'index-must-reexport-default': _typescript_eslint_utils_ts_eslint.RuleModule<"missingDefaultReexport", [IndexMustReexportDefaultOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
43
|
+
name: string;
|
|
44
|
+
};
|
|
45
|
+
'no-cross-feature-imports': _typescript_eslint_utils_ts_eslint.RuleModule<"crossFeatureImport", [NoCrossFeatureImportsOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
46
|
+
name: string;
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
configs: Record<string, unknown>;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
declare const configs: Record<string, unknown>;
|
|
53
|
+
|
|
54
|
+
export { configs, plugin as default, rules };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import * as _typescript_eslint_utils_ts_eslint from '@typescript-eslint/utils/ts-eslint';
|
|
2
|
+
|
|
3
|
+
interface NoCrossFeatureImportsOptions {
|
|
4
|
+
readonly featureRoot?: string;
|
|
5
|
+
readonly alias?: string;
|
|
6
|
+
readonly sharedFeatures?: readonly string[];
|
|
7
|
+
readonly allowTypeImports?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface IndexMustReexportDefaultOptions {
|
|
11
|
+
readonly ignorePaths?: readonly string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface ComponentFolderStructureOptions {
|
|
15
|
+
readonly componentRoot?: string;
|
|
16
|
+
readonly requiredSiblings?: readonly string[];
|
|
17
|
+
readonly ignorePaths?: readonly string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Every rule this plugin exposes, keyed by its (unprefixed) rule id. */
|
|
21
|
+
declare const rules: {
|
|
22
|
+
'component-folder-structure': _typescript_eslint_utils_ts_eslint.RuleModule<"missingSiblings", [ComponentFolderStructureOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
23
|
+
name: string;
|
|
24
|
+
};
|
|
25
|
+
'index-must-reexport-default': _typescript_eslint_utils_ts_eslint.RuleModule<"missingDefaultReexport", [IndexMustReexportDefaultOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
26
|
+
name: string;
|
|
27
|
+
};
|
|
28
|
+
'no-cross-feature-imports': _typescript_eslint_utils_ts_eslint.RuleModule<"crossFeatureImport", [NoCrossFeatureImportsOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
29
|
+
name: string;
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
declare const plugin: {
|
|
34
|
+
meta: {
|
|
35
|
+
name: string;
|
|
36
|
+
version: string;
|
|
37
|
+
};
|
|
38
|
+
rules: {
|
|
39
|
+
'component-folder-structure': _typescript_eslint_utils_ts_eslint.RuleModule<"missingSiblings", [ComponentFolderStructureOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
40
|
+
name: string;
|
|
41
|
+
};
|
|
42
|
+
'index-must-reexport-default': _typescript_eslint_utils_ts_eslint.RuleModule<"missingDefaultReexport", [IndexMustReexportDefaultOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
43
|
+
name: string;
|
|
44
|
+
};
|
|
45
|
+
'no-cross-feature-imports': _typescript_eslint_utils_ts_eslint.RuleModule<"crossFeatureImport", [NoCrossFeatureImportsOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
46
|
+
name: string;
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
configs: Record<string, unknown>;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
declare const configs: Record<string, unknown>;
|
|
53
|
+
|
|
54
|
+
export { configs, plugin as default, rules };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
// src/configs/recommended.ts
|
|
2
|
+
var recommended = {
|
|
3
|
+
"noctcore-architecture/component-folder-structure": "error",
|
|
4
|
+
"noctcore-architecture/index-must-reexport-default": "error",
|
|
5
|
+
"noctcore-architecture/no-cross-feature-imports": "error"
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
// src/rules/component-folder-structure.ts
|
|
9
|
+
import path2 from "path";
|
|
10
|
+
|
|
11
|
+
// src/createRule.ts
|
|
12
|
+
import { makeCreateRule } from "@noctcore/eslint-utils";
|
|
13
|
+
var createRule = makeCreateRule("architecture");
|
|
14
|
+
|
|
15
|
+
// src/utils.ts
|
|
16
|
+
import { existsSync, readdirSync } from "fs";
|
|
17
|
+
import path from "path";
|
|
18
|
+
function toPosix(filename) {
|
|
19
|
+
return filename.split(path.sep).join("/").split("\\").join("/");
|
|
20
|
+
}
|
|
21
|
+
function getBasename(filename) {
|
|
22
|
+
return path.basename(filename);
|
|
23
|
+
}
|
|
24
|
+
function isPascalCase(segment) {
|
|
25
|
+
return /^[A-Z][A-Za-z0-9]*$/.test(segment);
|
|
26
|
+
}
|
|
27
|
+
function isComponentFileName(filename) {
|
|
28
|
+
return /^[A-Z][A-Za-z0-9]*\.tsx$/.test(getBasename(filename));
|
|
29
|
+
}
|
|
30
|
+
function getComponentName(filename) {
|
|
31
|
+
return getBasename(filename).replace(/\.tsx$/, "");
|
|
32
|
+
}
|
|
33
|
+
function isComponentEntryFile(filename) {
|
|
34
|
+
if (!isComponentFileName(filename)) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
return getComponentName(filename) === path.basename(path.dirname(filename));
|
|
38
|
+
}
|
|
39
|
+
function segmentsAfterRoot(filename, root) {
|
|
40
|
+
const marker = `/${root}/`;
|
|
41
|
+
const posix = toPosix(filename);
|
|
42
|
+
const idx = posix.lastIndexOf(marker);
|
|
43
|
+
if (idx === -1) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
return posix.slice(idx + marker.length).split("/").filter((segment) => segment.length > 0);
|
|
47
|
+
}
|
|
48
|
+
function getFeatureName(filename, root) {
|
|
49
|
+
const segments = segmentsAfterRoot(filename, root);
|
|
50
|
+
if (segments === null || segments.length === 0) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
const feature = segments[0];
|
|
54
|
+
return feature !== void 0 && feature.length > 0 ? feature : null;
|
|
55
|
+
}
|
|
56
|
+
function escapeRegExpLiteral(value) {
|
|
57
|
+
return value.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
58
|
+
}
|
|
59
|
+
function globToRegExp(glob) {
|
|
60
|
+
let out = "";
|
|
61
|
+
for (let i = 0; i < glob.length; i += 1) {
|
|
62
|
+
const char = glob[i];
|
|
63
|
+
if (char === "*") {
|
|
64
|
+
if (glob[i + 1] === "*") {
|
|
65
|
+
if (glob[i + 2] === "/") {
|
|
66
|
+
out += "(?:[^/]*/)*";
|
|
67
|
+
i += 2;
|
|
68
|
+
} else {
|
|
69
|
+
out += ".*";
|
|
70
|
+
i += 1;
|
|
71
|
+
}
|
|
72
|
+
} else {
|
|
73
|
+
out += "[^/]*";
|
|
74
|
+
}
|
|
75
|
+
} else if (char === "?") {
|
|
76
|
+
out += "[^/]";
|
|
77
|
+
} else if (char !== void 0) {
|
|
78
|
+
out += escapeRegExpLiteral(char);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return new RegExp(`^${out}$`);
|
|
82
|
+
}
|
|
83
|
+
function isIgnoredPath(filename, ignorePaths) {
|
|
84
|
+
if (ignorePaths.length === 0) {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
const posix = toPosix(filename);
|
|
88
|
+
return ignorePaths.some((glob) => globToRegExp(glob).test(posix));
|
|
89
|
+
}
|
|
90
|
+
function siblingExists(dir, sibling) {
|
|
91
|
+
return existsSync(path.join(dir, sibling));
|
|
92
|
+
}
|
|
93
|
+
function readDirSafe(dir) {
|
|
94
|
+
try {
|
|
95
|
+
return new Set(readdirSync(dir));
|
|
96
|
+
} catch {
|
|
97
|
+
return /* @__PURE__ */ new Set();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// src/rules/component-folder-structure.ts
|
|
102
|
+
var RULE_NAME = "component-folder-structure";
|
|
103
|
+
var DEFAULT_COMPONENT_ROOT = "components";
|
|
104
|
+
var DEFAULT_IGNORE_PATHS = ["**/ui/**"];
|
|
105
|
+
var DEFAULT_REQUIRED_SIBLINGS = [
|
|
106
|
+
".hooks.ts",
|
|
107
|
+
".types.ts",
|
|
108
|
+
".stories.tsx",
|
|
109
|
+
".test.tsx",
|
|
110
|
+
"index.ts"
|
|
111
|
+
];
|
|
112
|
+
function resolveSibling(template, name) {
|
|
113
|
+
return template.startsWith(".") ? `${name}${template}` : template;
|
|
114
|
+
}
|
|
115
|
+
var optionSchema = {
|
|
116
|
+
type: "object",
|
|
117
|
+
additionalProperties: false,
|
|
118
|
+
properties: {
|
|
119
|
+
componentRoot: { type: "string" },
|
|
120
|
+
requiredSiblings: { type: "array", items: { type: "string" }, uniqueItems: true },
|
|
121
|
+
ignorePaths: { type: "array", items: { type: "string" }, uniqueItems: true }
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
var componentFolderStructureRule = createRule({
|
|
125
|
+
name: RULE_NAME,
|
|
126
|
+
meta: {
|
|
127
|
+
type: "problem",
|
|
128
|
+
docs: {
|
|
129
|
+
description: "A component `<Name>/<Name>.tsx` under `<componentRoot>/<feature>/...` must have its sibling set (`.hooks.ts`, `.types.ts`, `.stories.tsx`, `.test.tsx`, `index.ts`) present on disk."
|
|
130
|
+
},
|
|
131
|
+
schema: [optionSchema],
|
|
132
|
+
messages: {
|
|
133
|
+
missingSiblings: "Component `{{name}}` is missing sibling file(s): {{missing}}. Every component folder must carry its hooks, types, stories, test, and index barrel."
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
defaultOptions: [
|
|
137
|
+
{
|
|
138
|
+
componentRoot: DEFAULT_COMPONENT_ROOT,
|
|
139
|
+
requiredSiblings: [...DEFAULT_REQUIRED_SIBLINGS],
|
|
140
|
+
ignorePaths: [...DEFAULT_IGNORE_PATHS]
|
|
141
|
+
}
|
|
142
|
+
],
|
|
143
|
+
create(context, [options]) {
|
|
144
|
+
const componentRoot = options.componentRoot ?? DEFAULT_COMPONENT_ROOT;
|
|
145
|
+
const ignorePaths = options.ignorePaths ?? DEFAULT_IGNORE_PATHS;
|
|
146
|
+
const siblingTemplates = options.requiredSiblings ?? DEFAULT_REQUIRED_SIBLINGS;
|
|
147
|
+
const filename = context.filename;
|
|
148
|
+
if (!isComponentEntryFile(filename) || isIgnoredPath(filename, ignorePaths)) {
|
|
149
|
+
return {};
|
|
150
|
+
}
|
|
151
|
+
if (getFeatureName(filename, componentRoot) === null) {
|
|
152
|
+
return {};
|
|
153
|
+
}
|
|
154
|
+
const name = getComponentName(filename);
|
|
155
|
+
const dir = path2.dirname(filename);
|
|
156
|
+
const required = siblingTemplates.map((template) => resolveSibling(template, name));
|
|
157
|
+
const present = readDirSafe(dir);
|
|
158
|
+
const missing = required.filter((sibling) => !present.has(sibling));
|
|
159
|
+
return {
|
|
160
|
+
Program(node) {
|
|
161
|
+
if (missing.length > 0) {
|
|
162
|
+
context.report({
|
|
163
|
+
node,
|
|
164
|
+
messageId: "missingSiblings",
|
|
165
|
+
data: { name, missing: missing.join(", ") }
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// src/rules/index-must-reexport-default.ts
|
|
174
|
+
import path3 from "path";
|
|
175
|
+
import { AST_NODE_TYPES } from "@typescript-eslint/utils";
|
|
176
|
+
var RULE_NAME2 = "index-must-reexport-default";
|
|
177
|
+
var DEFAULT_IGNORE_PATHS2 = [];
|
|
178
|
+
var optionSchema2 = {
|
|
179
|
+
type: "object",
|
|
180
|
+
additionalProperties: false,
|
|
181
|
+
properties: {
|
|
182
|
+
ignorePaths: { type: "array", items: { type: "string" }, uniqueItems: true }
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
function reexportsDefault(node) {
|
|
186
|
+
if (node.source === null) {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
return node.specifiers.some(
|
|
190
|
+
(specifier) => specifier.local.type === AST_NODE_TYPES.Identifier && specifier.local.name === "default"
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
var indexMustReexportDefaultRule = createRule({
|
|
194
|
+
name: RULE_NAME2,
|
|
195
|
+
meta: {
|
|
196
|
+
type: "problem",
|
|
197
|
+
docs: {
|
|
198
|
+
description: "A component folder's `index.ts` must re-export the component default (`export { default as <Name> } from './<Name>'`)."
|
|
199
|
+
},
|
|
200
|
+
schema: [optionSchema2],
|
|
201
|
+
messages: {
|
|
202
|
+
missingDefaultReexport: "`index.ts` must re-export the {{name}} default: `export { default as {{name}} } from './{{name}}'`."
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
defaultOptions: [{ ignorePaths: [] }],
|
|
206
|
+
create(context, [options]) {
|
|
207
|
+
const ignorePaths = options.ignorePaths ?? DEFAULT_IGNORE_PATHS2;
|
|
208
|
+
const filename = context.filename;
|
|
209
|
+
if (getBasename(filename) !== "index.ts" || isIgnoredPath(filename, ignorePaths)) {
|
|
210
|
+
return {};
|
|
211
|
+
}
|
|
212
|
+
const dir = path3.dirname(filename);
|
|
213
|
+
const folderName = path3.basename(dir);
|
|
214
|
+
if (!isPascalCase(folderName) || !siblingExists(dir, `${folderName}.tsx`)) {
|
|
215
|
+
return {};
|
|
216
|
+
}
|
|
217
|
+
let hasDefaultReexport = false;
|
|
218
|
+
return {
|
|
219
|
+
ExportNamedDeclaration(node) {
|
|
220
|
+
if (reexportsDefault(node)) {
|
|
221
|
+
hasDefaultReexport = true;
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
"Program:exit"(node) {
|
|
225
|
+
if (!hasDefaultReexport) {
|
|
226
|
+
context.report({
|
|
227
|
+
node,
|
|
228
|
+
messageId: "missingDefaultReexport",
|
|
229
|
+
data: { name: folderName }
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// src/rules/no-cross-feature-imports.ts
|
|
238
|
+
import path4 from "path";
|
|
239
|
+
import { AST_NODE_TYPES as AST_NODE_TYPES2 } from "@typescript-eslint/utils";
|
|
240
|
+
var RULE_NAME3 = "no-cross-feature-imports";
|
|
241
|
+
var DEFAULT_FEATURE_ROOT = "components";
|
|
242
|
+
var DEFAULT_ALIAS = "@/components";
|
|
243
|
+
var DEFAULT_SHARED_FEATURES = ["ui"];
|
|
244
|
+
var optionSchema3 = {
|
|
245
|
+
type: "object",
|
|
246
|
+
additionalProperties: false,
|
|
247
|
+
properties: {
|
|
248
|
+
featureRoot: { type: "string" },
|
|
249
|
+
alias: { type: "string" },
|
|
250
|
+
sharedFeatures: { type: "array", items: { type: "string" }, uniqueItems: true },
|
|
251
|
+
allowTypeImports: { type: "boolean" }
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
function escapeRegExpLiteral2(value) {
|
|
255
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
256
|
+
}
|
|
257
|
+
function aliasFeatureMatcher(alias) {
|
|
258
|
+
return new RegExp(`^${escapeRegExpLiteral2(alias)}/([^/]+)`);
|
|
259
|
+
}
|
|
260
|
+
function resolveTargetFeature(source, currentFile, aliasRe, featureRoot) {
|
|
261
|
+
const aliasMatch = aliasRe.exec(source);
|
|
262
|
+
if (aliasMatch) {
|
|
263
|
+
return aliasMatch[1] ?? null;
|
|
264
|
+
}
|
|
265
|
+
if (source.startsWith(".")) {
|
|
266
|
+
const resolved = path4.resolve(path4.dirname(currentFile), source);
|
|
267
|
+
return getFeatureName(resolved, featureRoot);
|
|
268
|
+
}
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
var noCrossFeatureImportsRule = createRule({
|
|
272
|
+
name: RULE_NAME3,
|
|
273
|
+
meta: {
|
|
274
|
+
type: "problem",
|
|
275
|
+
docs: {
|
|
276
|
+
description: "A file in one feature may not import runtime code from another feature. Move shared code to a shared module or a shared feature."
|
|
277
|
+
},
|
|
278
|
+
schema: [optionSchema3],
|
|
279
|
+
messages: {
|
|
280
|
+
crossFeatureImport: "Cross-feature import: `{{current}}` may not import runtime code from `{{root}}/{{target}}`. Move shared code to a shared module or a shared feature (e.g. `{{root}}/ui`)."
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
defaultOptions: [
|
|
284
|
+
{
|
|
285
|
+
featureRoot: DEFAULT_FEATURE_ROOT,
|
|
286
|
+
alias: DEFAULT_ALIAS,
|
|
287
|
+
sharedFeatures: [...DEFAULT_SHARED_FEATURES],
|
|
288
|
+
allowTypeImports: true
|
|
289
|
+
}
|
|
290
|
+
],
|
|
291
|
+
create(context, [options]) {
|
|
292
|
+
const featureRoot = options.featureRoot ?? DEFAULT_FEATURE_ROOT;
|
|
293
|
+
const alias = options.alias ?? DEFAULT_ALIAS;
|
|
294
|
+
const sharedFeatures = options.sharedFeatures ?? DEFAULT_SHARED_FEATURES;
|
|
295
|
+
const allowTypeImports = options.allowTypeImports ?? true;
|
|
296
|
+
const aliasRe = aliasFeatureMatcher(alias);
|
|
297
|
+
const current = getFeatureName(context.filename, featureRoot);
|
|
298
|
+
if (current === null) {
|
|
299
|
+
return {};
|
|
300
|
+
}
|
|
301
|
+
function checkSource(sourceNode, typeOnly) {
|
|
302
|
+
if (allowTypeImports && typeOnly) {
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
const source = sourceNode.value;
|
|
306
|
+
if (typeof source !== "string") {
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
const target = resolveTargetFeature(source, context.filename, aliasRe, featureRoot);
|
|
310
|
+
if (target === null || target === current || sharedFeatures.includes(target)) {
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
context.report({
|
|
314
|
+
node: sourceNode,
|
|
315
|
+
messageId: "crossFeatureImport",
|
|
316
|
+
data: { current, target, root: featureRoot }
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
return {
|
|
320
|
+
ImportDeclaration(node) {
|
|
321
|
+
if (node.source.type === AST_NODE_TYPES2.Literal) {
|
|
322
|
+
checkSource(node.source, node.importKind === "type");
|
|
323
|
+
}
|
|
324
|
+
},
|
|
325
|
+
// Dynamic `import()` is runtime by nature — never type-only.
|
|
326
|
+
ImportExpression(node) {
|
|
327
|
+
if (node.source.type === AST_NODE_TYPES2.Literal) {
|
|
328
|
+
checkSource(node.source, false);
|
|
329
|
+
}
|
|
330
|
+
},
|
|
331
|
+
// `export { x } from '…'` re-export laundering.
|
|
332
|
+
ExportNamedDeclaration(node) {
|
|
333
|
+
if (node.source !== null && node.source.type === AST_NODE_TYPES2.Literal) {
|
|
334
|
+
checkSource(node.source, node.exportKind === "type");
|
|
335
|
+
}
|
|
336
|
+
},
|
|
337
|
+
// `export * from '…'` re-export laundering.
|
|
338
|
+
ExportAllDeclaration(node) {
|
|
339
|
+
if (node.source.type === AST_NODE_TYPES2.Literal) {
|
|
340
|
+
checkSource(node.source, node.exportKind === "type");
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// src/rules/index.ts
|
|
348
|
+
var rules = {
|
|
349
|
+
"component-folder-structure": componentFolderStructureRule,
|
|
350
|
+
"index-must-reexport-default": indexMustReexportDefaultRule,
|
|
351
|
+
"no-cross-feature-imports": noCrossFeatureImportsRule
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
// src/index.ts
|
|
355
|
+
var NAMESPACE = "noctcore-architecture";
|
|
356
|
+
var VERSION = "0.1.0";
|
|
357
|
+
var plugin = {
|
|
358
|
+
meta: { name: "@noctcore/eslint-plugin-architecture", version: VERSION },
|
|
359
|
+
rules,
|
|
360
|
+
configs: {}
|
|
361
|
+
};
|
|
362
|
+
plugin.configs.recommended = {
|
|
363
|
+
plugins: { [NAMESPACE]: plugin },
|
|
364
|
+
rules: recommended
|
|
365
|
+
};
|
|
366
|
+
var configs = plugin.configs;
|
|
367
|
+
var index_default = plugin;
|
|
368
|
+
export {
|
|
369
|
+
configs,
|
|
370
|
+
index_default as default,
|
|
371
|
+
rules
|
|
372
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# `noctcore-architecture/component-folder-structure`
|
|
2
|
+
|
|
3
|
+
> A component entry file must ship its full sibling set (hooks, types, story, test, barrel) on disk.
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
In a folder-per-component layout, a component is a folder — not a lone `.tsx`. When the logic
|
|
8
|
+
(`.hooks.ts`), the types (`.types.ts`), the story (`.stories.tsx`), the test (`.test.tsx`), and the
|
|
9
|
+
`index.ts` barrel always travel with the component, refactors stay local and nothing is quietly
|
|
10
|
+
untested or undocumented. This rule enforces that colocation by construction.
|
|
11
|
+
|
|
12
|
+
## What it flags
|
|
13
|
+
|
|
14
|
+
For every **component entry file** — a PascalCase `.tsx` whose basename equals its parent folder
|
|
15
|
+
(`TaskCard/TaskCard.tsx`) — that lives under the configured `componentRoot`, the rule reads the
|
|
16
|
+
component's directory and reports any sibling from the required set that is missing on disk.
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
components/board/TaskCard/
|
|
20
|
+
TaskCard.tsx ← entry file (checked)
|
|
21
|
+
TaskCard.hooks.ts ┐
|
|
22
|
+
TaskCard.types.ts │ required siblings
|
|
23
|
+
TaskCard.stories.tsx │ (missing → reported)
|
|
24
|
+
TaskCard.test.tsx │
|
|
25
|
+
index.ts ┘
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Files that are not entry files (`task-card.tsx`, `Group/Widget.tsx`), files outside the
|
|
29
|
+
`componentRoot`, and files matched by `ignorePaths` are never checked.
|
|
30
|
+
|
|
31
|
+
## Options
|
|
32
|
+
|
|
33
|
+
| Option | Type | Default | Meaning |
|
|
34
|
+
| --- | --- | --- | --- |
|
|
35
|
+
| `componentRoot` | `string` | `'components'` | The directory segment the layout is anchored on. |
|
|
36
|
+
| `requiredSiblings` | `string[]` | `['.hooks.ts', '.types.ts', '.stories.tsx', '.test.tsx', 'index.ts']` | The sibling set. An entry starting with `.` is a name-relative suffix (`.hooks.ts` → `<Name>.hooks.ts`); any other entry is a literal filename (`index.ts`). |
|
|
37
|
+
| `ignorePaths` | `string[]` | `['**/ui/**']` | Globs (supporting `**`, `*`, `?`) of paths to skip. |
|
|
38
|
+
|
|
39
|
+
```js
|
|
40
|
+
'noctcore-architecture/component-folder-structure': ['error', {
|
|
41
|
+
componentRoot: 'components',
|
|
42
|
+
requiredSiblings: ['.hooks.ts', '.types.ts', '.stories.tsx', '.test.tsx', 'index.ts'],
|
|
43
|
+
ignorePaths: ['**/ui/**'],
|
|
44
|
+
}]
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## When not to use it
|
|
48
|
+
|
|
49
|
+
If your components are single files rather than folders, or you do not colocate stories/tests with
|
|
50
|
+
components, disable this rule or trim `requiredSiblings` to just the pieces you do colocate.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# `noctcore-architecture/index-must-reexport-default`
|
|
2
|
+
|
|
3
|
+
> A component folder's `index.ts` must re-export the sibling default named after the folder.
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
When a component folder's `index.ts` re-exports the component default, consumers import the folder
|
|
8
|
+
(`import Card from '@/components/Card'`) rather than reaching for the inner file
|
|
9
|
+
(`.../Card/Card`). The barrel becomes the folder's single public entry point, and the internal file
|
|
10
|
+
layout stays free to change.
|
|
11
|
+
|
|
12
|
+
## What it flags
|
|
13
|
+
|
|
14
|
+
The rule only activates for an `index.ts` that sits next to a `<Folder>.tsx` of the same PascalCase
|
|
15
|
+
name on disk (`Card/Card.tsx` beside `Card/index.ts`). For those barrels, it reports when the file
|
|
16
|
+
never re-exports the sibling's default export.
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
// Card/index.ts
|
|
20
|
+
|
|
21
|
+
export { default as Card } from './Card'; // ✓
|
|
22
|
+
export { default } from './Card'; // ✓
|
|
23
|
+
export * from './Card.types'; // ✗ (on its own — no default re-export)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Non-component `index.ts` files — those whose folder is not PascalCase, or that have no
|
|
27
|
+
`<Folder>.tsx` sibling on disk — are left untouched.
|
|
28
|
+
|
|
29
|
+
## Options
|
|
30
|
+
|
|
31
|
+
| Option | Type | Default | Meaning |
|
|
32
|
+
| --- | --- | --- | --- |
|
|
33
|
+
| `ignorePaths` | `string[]` | `[]` | Globs (supporting `**`, `*`, `?`) of paths to skip. |
|
|
34
|
+
|
|
35
|
+
```js
|
|
36
|
+
'noctcore-architecture/index-must-reexport-default': ['error', {
|
|
37
|
+
ignorePaths: ['**/ui/**'],
|
|
38
|
+
}]
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## When not to use it
|
|
42
|
+
|
|
43
|
+
If you do not use barrel files, or you export components as named (not default) exports, this rule
|
|
44
|
+
does not apply.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# `noctcore-architecture/no-cross-feature-imports`
|
|
2
|
+
|
|
3
|
+
> A file in one feature may not import runtime code from another feature.
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
Features are meant to be decoupled: a change inside feature `board` should never be able to ripple
|
|
8
|
+
into feature `projects`. When one feature reaches directly into another's internals, that boundary
|
|
9
|
+
erodes and the two become one tangled unit. Shared code belongs outside the feature root (a `lib`,
|
|
10
|
+
`hooks`, or similar module) or in a designated **shared feature** (default `ui`) that everything is
|
|
11
|
+
allowed to import.
|
|
12
|
+
|
|
13
|
+
## What it flags
|
|
14
|
+
|
|
15
|
+
For a file that lives under `<featureRoot>/<feature>/…`, the rule reports any import whose target
|
|
16
|
+
resolves to a **different** feature under the same root, on every source-carrying construct:
|
|
17
|
+
|
|
18
|
+
- static `import … from '…'`
|
|
19
|
+
- dynamic `import('…')`
|
|
20
|
+
- `export … from '…'` and `export * from '…'` re-export laundering
|
|
21
|
+
|
|
22
|
+
Both the alias form (`<alias>/<feature>/…`) and relative paths that climb into another feature
|
|
23
|
+
(`../../projects/…`) are detected. Type-only imports are allowed by default (flip `allowTypeImports`
|
|
24
|
+
to forbid them). Imports of the current feature, of a shared feature, or of non-feature modules are
|
|
25
|
+
always fine.
|
|
26
|
+
|
|
27
|
+
```tsx
|
|
28
|
+
// in components/board/Board/Board.tsx
|
|
29
|
+
|
|
30
|
+
import { Button } from '@/components/ui/Button'; // ✓ shared feature
|
|
31
|
+
import { cn } from '@/lib/utils'; // ✓ non-feature module
|
|
32
|
+
import { TaskCard } from '../TaskCard/TaskCard'; // ✓ same feature
|
|
33
|
+
import { ProjectCard } from '@/components/projects/…'; // ✗ cross-feature
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Options
|
|
37
|
+
|
|
38
|
+
| Option | Type | Default | Meaning |
|
|
39
|
+
| --- | --- | --- | --- |
|
|
40
|
+
| `featureRoot` | `string` | `'components'` | The directory segment whose immediate children are features. |
|
|
41
|
+
| `alias` | `string` | `'@/components'` | The import-alias prefix that maps onto `featureRoot`. |
|
|
42
|
+
| `sharedFeatures` | `string[]` | `['ui']` | Features every other feature is allowed to import. |
|
|
43
|
+
| `allowTypeImports` | `boolean` | `true` | When `true`, `import type` / `export type` cross-feature imports are permitted. |
|
|
44
|
+
|
|
45
|
+
```js
|
|
46
|
+
'noctcore-architecture/no-cross-feature-imports': ['error', {
|
|
47
|
+
featureRoot: 'components',
|
|
48
|
+
alias: '@/components',
|
|
49
|
+
sharedFeatures: ['ui'],
|
|
50
|
+
allowTypeImports: true,
|
|
51
|
+
}]
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## When not to use it
|
|
55
|
+
|
|
56
|
+
If your app is not organized as sibling features under a single root, or you allow features to
|
|
57
|
+
depend on each other freely, this rule does not apply.
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@noctcore/eslint-plugin-architecture",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Framework-agnostic folder-per-component and feature-boundary architecture ESLint rules.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.cjs",
|
|
8
|
+
"module": "./dist/index.js",
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"import": "./dist/index.js",
|
|
14
|
+
"require": "./dist/index.cjs"
|
|
15
|
+
},
|
|
16
|
+
"./package.json": "./package.json"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"docs",
|
|
21
|
+
"README.md"
|
|
22
|
+
],
|
|
23
|
+
"sideEffects": false,
|
|
24
|
+
"keywords": [
|
|
25
|
+
"eslint",
|
|
26
|
+
"eslintplugin",
|
|
27
|
+
"eslint-plugin",
|
|
28
|
+
"architecture",
|
|
29
|
+
"noctcore"
|
|
30
|
+
],
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public",
|
|
33
|
+
"provenance": true
|
|
34
|
+
},
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/noctcore/eslint-plugins.git",
|
|
38
|
+
"directory": "packages/eslint-plugin-architecture"
|
|
39
|
+
},
|
|
40
|
+
"homepage": "https://github.com/noctcore/eslint-plugins/tree/main/packages/eslint-plugin-architecture",
|
|
41
|
+
"bugs": "https://github.com/noctcore/eslint-plugins/issues",
|
|
42
|
+
"scripts": {
|
|
43
|
+
"build": "tsup src/index.ts --format esm,cjs --dts --clean",
|
|
44
|
+
"typecheck": "tsc --noEmit",
|
|
45
|
+
"test": "vitest run"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@noctcore/eslint-utils": "^0.1.0",
|
|
49
|
+
"@typescript-eslint/utils": "^8.61.1"
|
|
50
|
+
},
|
|
51
|
+
"peerDependencies": {
|
|
52
|
+
"eslint": ">=9.0.0",
|
|
53
|
+
"typescript": ">=5.0.0"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@noctcore/eslint-test-utils": "workspace:*",
|
|
57
|
+
"@types/node": "^22.0.0",
|
|
58
|
+
"@typescript-eslint/parser": "^8.61.1",
|
|
59
|
+
"@typescript-eslint/rule-tester": "^8.61.1",
|
|
60
|
+
"tsup": "^8.5.1",
|
|
61
|
+
"typescript": "^5.6.0",
|
|
62
|
+
"vitest": "^3"
|
|
63
|
+
}
|
|
64
|
+
}
|