@noctcore/eslint-plugin-architecture 0.1.0 → 0.2.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 +8 -2
- package/dist/index.cjs +444 -28
- package/dist/index.d.cts +42 -0
- package/dist/index.d.ts +42 -0
- package/dist/index.js +444 -28
- 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/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
// src/configs/recommended.ts
|
|
2
2
|
var recommended = {
|
|
3
|
+
"noctcore-architecture/barrel-purity": "error",
|
|
4
|
+
// Ships OFF: this rule does nothing until you name the source globs that must
|
|
5
|
+
// be tested (there is no universal "everything needs a test" default). Enable
|
|
6
|
+
// it with your own `include`, e.g.
|
|
7
|
+
// 'noctcore-architecture/colocated-test-required': ['error', { include: ['**/use*.ts', '**/*.service.ts'] }]
|
|
8
|
+
"noctcore-architecture/colocated-test-required": "off",
|
|
3
9
|
"noctcore-architecture/component-folder-structure": "error",
|
|
10
|
+
"noctcore-architecture/filename-matches-export": "error",
|
|
4
11
|
"noctcore-architecture/index-must-reexport-default": "error",
|
|
12
|
+
"noctcore-architecture/max-import-depth": "error",
|
|
5
13
|
"noctcore-architecture/no-cross-feature-imports": "error"
|
|
6
14
|
};
|
|
7
15
|
|
|
8
|
-
// src/rules/
|
|
9
|
-
import
|
|
16
|
+
// src/rules/barrel-purity.ts
|
|
17
|
+
import { AST_NODE_TYPES } from "@typescript-eslint/utils";
|
|
10
18
|
|
|
11
19
|
// src/createRule.ts
|
|
12
20
|
import { makeCreateRule } from "@noctcore/eslint-utils";
|
|
@@ -98,8 +106,154 @@ function readDirSafe(dir) {
|
|
|
98
106
|
}
|
|
99
107
|
}
|
|
100
108
|
|
|
109
|
+
// src/rules/barrel-purity.ts
|
|
110
|
+
var RULE_NAME = "barrel-purity";
|
|
111
|
+
var DEFAULT_ALLOW = [];
|
|
112
|
+
var BARREL_BASENAME = /^index\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/;
|
|
113
|
+
var optionSchema = {
|
|
114
|
+
type: "object",
|
|
115
|
+
additionalProperties: false,
|
|
116
|
+
properties: {
|
|
117
|
+
allow: { type: "array", items: { type: "string" }, uniqueItems: true }
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
function impurityOf(stmt) {
|
|
121
|
+
switch (stmt.type) {
|
|
122
|
+
case AST_NODE_TYPES.ImportDeclaration:
|
|
123
|
+
return stmt.specifiers.length === 0 ? "a side-effect import" : null;
|
|
124
|
+
case AST_NODE_TYPES.ExportAllDeclaration:
|
|
125
|
+
return null;
|
|
126
|
+
case AST_NODE_TYPES.ExportNamedDeclaration:
|
|
127
|
+
if (stmt.source !== null) {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
if (stmt.declaration === null) {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
return "a local declaration";
|
|
134
|
+
case AST_NODE_TYPES.ExportDefaultDeclaration:
|
|
135
|
+
return stmt.declaration.type === AST_NODE_TYPES.Identifier ? null : "a default-exported value";
|
|
136
|
+
case AST_NODE_TYPES.ExpressionStatement:
|
|
137
|
+
return "a side-effect statement";
|
|
138
|
+
default:
|
|
139
|
+
return "non-re-export code";
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
var barrelPurityRule = createRule({
|
|
143
|
+
name: RULE_NAME,
|
|
144
|
+
meta: {
|
|
145
|
+
type: "problem",
|
|
146
|
+
docs: {
|
|
147
|
+
description: "A barrel (`index.ts` / `index.tsx`) must contain only re-exports \u2014 never local declarations, side effects, or default-exported values."
|
|
148
|
+
},
|
|
149
|
+
schema: [optionSchema],
|
|
150
|
+
messages: {
|
|
151
|
+
impureBarrel: "A barrel must contain only re-exports; found {{kind}}. Move it into a sibling module and re-export it from here."
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
defaultOptions: [{ allow: [] }],
|
|
155
|
+
create(context, [options]) {
|
|
156
|
+
const allow = options.allow ?? DEFAULT_ALLOW;
|
|
157
|
+
const filename = context.filename;
|
|
158
|
+
if (!BARREL_BASENAME.test(getBasename(filename)) || isIgnoredPath(filename, allow)) {
|
|
159
|
+
return {};
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
Program(node) {
|
|
163
|
+
for (const stmt of node.body) {
|
|
164
|
+
const kind = impurityOf(stmt);
|
|
165
|
+
if (kind !== null) {
|
|
166
|
+
context.report({ node: stmt, messageId: "impureBarrel", data: { kind } });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// src/rules/colocated-test-required.ts
|
|
175
|
+
import { readdirSync as readdirSync2 } from "fs";
|
|
176
|
+
import path2 from "path";
|
|
177
|
+
var RULE_NAME2 = "colocated-test-required";
|
|
178
|
+
var DEFAULT_INCLUDE = [];
|
|
179
|
+
var DEFAULT_IGNORE = [];
|
|
180
|
+
var TEST_SIBLING = /\.(test|spec)\.[^.]+$/;
|
|
181
|
+
var optionSchema2 = {
|
|
182
|
+
type: "object",
|
|
183
|
+
additionalProperties: false,
|
|
184
|
+
properties: {
|
|
185
|
+
include: { type: "array", items: { type: "string" }, uniqueItems: true },
|
|
186
|
+
ignore: { type: "array", items: { type: "string" }, uniqueItems: true }
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
var dirCache = /* @__PURE__ */ new Map();
|
|
190
|
+
function readDirCached(dir) {
|
|
191
|
+
const cached = dirCache.get(dir);
|
|
192
|
+
if (cached !== void 0) {
|
|
193
|
+
return cached;
|
|
194
|
+
}
|
|
195
|
+
let entries;
|
|
196
|
+
try {
|
|
197
|
+
entries = readdirSync2(dir);
|
|
198
|
+
} catch {
|
|
199
|
+
entries = [];
|
|
200
|
+
}
|
|
201
|
+
dirCache.set(dir, entries);
|
|
202
|
+
return entries;
|
|
203
|
+
}
|
|
204
|
+
function stemOf(basename) {
|
|
205
|
+
const ext = path2.extname(basename);
|
|
206
|
+
return ext === "" ? basename : basename.slice(0, -ext.length);
|
|
207
|
+
}
|
|
208
|
+
function hasColocatedTest(dir, stem) {
|
|
209
|
+
const prefix = `${stem}.`;
|
|
210
|
+
return readDirCached(dir).some(
|
|
211
|
+
(entry) => entry.startsWith(prefix) && TEST_SIBLING.test(entry)
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
var colocatedTestRequiredRule = createRule({
|
|
215
|
+
name: RULE_NAME2,
|
|
216
|
+
meta: {
|
|
217
|
+
type: "problem",
|
|
218
|
+
docs: {
|
|
219
|
+
description: "A source file matching an `include` glob must have a colocated `*.test.*` / `*.spec.*` sibling on disk. Off until `include` is configured."
|
|
220
|
+
},
|
|
221
|
+
schema: [optionSchema2],
|
|
222
|
+
messages: {
|
|
223
|
+
missingTest: "Source file `{{basename}}` has no colocated test. Add a sibling `{{stem}}.test.*` (or `.spec.*`) next to it."
|
|
224
|
+
}
|
|
225
|
+
},
|
|
226
|
+
defaultOptions: [{ include: [], ignore: [] }],
|
|
227
|
+
create(context, [options]) {
|
|
228
|
+
const include = options.include ?? DEFAULT_INCLUDE;
|
|
229
|
+
const ignore = options.ignore ?? DEFAULT_IGNORE;
|
|
230
|
+
const filename = context.filename;
|
|
231
|
+
if (include.length === 0 || !isIgnoredPath(filename, include)) {
|
|
232
|
+
return {};
|
|
233
|
+
}
|
|
234
|
+
const basename = getBasename(filename);
|
|
235
|
+
if (TEST_SIBLING.test(basename) || isIgnoredPath(filename, ignore)) {
|
|
236
|
+
return {};
|
|
237
|
+
}
|
|
238
|
+
const stem = stemOf(basename);
|
|
239
|
+
const dir = path2.dirname(filename);
|
|
240
|
+
return {
|
|
241
|
+
Program(node) {
|
|
242
|
+
if (!hasColocatedTest(dir, stem)) {
|
|
243
|
+
context.report({
|
|
244
|
+
node,
|
|
245
|
+
messageId: "missingTest",
|
|
246
|
+
data: { basename, stem }
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
|
|
101
254
|
// src/rules/component-folder-structure.ts
|
|
102
|
-
|
|
255
|
+
import path3 from "path";
|
|
256
|
+
var RULE_NAME3 = "component-folder-structure";
|
|
103
257
|
var DEFAULT_COMPONENT_ROOT = "components";
|
|
104
258
|
var DEFAULT_IGNORE_PATHS = ["**/ui/**"];
|
|
105
259
|
var DEFAULT_REQUIRED_SIBLINGS = [
|
|
@@ -112,7 +266,7 @@ var DEFAULT_REQUIRED_SIBLINGS = [
|
|
|
112
266
|
function resolveSibling(template, name) {
|
|
113
267
|
return template.startsWith(".") ? `${name}${template}` : template;
|
|
114
268
|
}
|
|
115
|
-
var
|
|
269
|
+
var optionSchema3 = {
|
|
116
270
|
type: "object",
|
|
117
271
|
additionalProperties: false,
|
|
118
272
|
properties: {
|
|
@@ -122,13 +276,13 @@ var optionSchema = {
|
|
|
122
276
|
}
|
|
123
277
|
};
|
|
124
278
|
var componentFolderStructureRule = createRule({
|
|
125
|
-
name:
|
|
279
|
+
name: RULE_NAME3,
|
|
126
280
|
meta: {
|
|
127
281
|
type: "problem",
|
|
128
282
|
docs: {
|
|
129
283
|
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
284
|
},
|
|
131
|
-
schema: [
|
|
285
|
+
schema: [optionSchema3],
|
|
132
286
|
messages: {
|
|
133
287
|
missingSiblings: "Component `{{name}}` is missing sibling file(s): {{missing}}. Every component folder must carry its hooks, types, stories, test, and index barrel."
|
|
134
288
|
}
|
|
@@ -152,7 +306,7 @@ var componentFolderStructureRule = createRule({
|
|
|
152
306
|
return {};
|
|
153
307
|
}
|
|
154
308
|
const name = getComponentName(filename);
|
|
155
|
-
const dir =
|
|
309
|
+
const dir = path3.dirname(filename);
|
|
156
310
|
const required = siblingTemplates.map((template) => resolveSibling(template, name));
|
|
157
311
|
const present = readDirSafe(dir);
|
|
158
312
|
const missing = required.filter((sibling) => !present.has(sibling));
|
|
@@ -170,12 +324,164 @@ var componentFolderStructureRule = createRule({
|
|
|
170
324
|
}
|
|
171
325
|
});
|
|
172
326
|
|
|
327
|
+
// src/rules/filename-matches-export.ts
|
|
328
|
+
import path4 from "path";
|
|
329
|
+
import { AST_NODE_TYPES as AST_NODE_TYPES2 } from "@typescript-eslint/utils";
|
|
330
|
+
var RULE_NAME4 = "filename-matches-export";
|
|
331
|
+
var DEFAULT_IGNORE2 = [];
|
|
332
|
+
var VALID_IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
333
|
+
var optionSchema4 = {
|
|
334
|
+
type: "object",
|
|
335
|
+
additionalProperties: false,
|
|
336
|
+
properties: {
|
|
337
|
+
ignore: { type: "array", items: { type: "string" }, uniqueItems: true }
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
function stemOf2(filename) {
|
|
341
|
+
const basename = getBasename(filename);
|
|
342
|
+
const ext = path4.extname(basename);
|
|
343
|
+
return ext === "" ? basename : basename.slice(0, -ext.length);
|
|
344
|
+
}
|
|
345
|
+
function normalize(value) {
|
|
346
|
+
return value.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
347
|
+
}
|
|
348
|
+
function namedExportId(decl) {
|
|
349
|
+
if (decl.declaration === null) {
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
const d = decl.declaration;
|
|
353
|
+
switch (d.type) {
|
|
354
|
+
case AST_NODE_TYPES2.FunctionDeclaration:
|
|
355
|
+
case AST_NODE_TYPES2.ClassDeclaration:
|
|
356
|
+
return d.id;
|
|
357
|
+
case AST_NODE_TYPES2.TSTypeAliasDeclaration:
|
|
358
|
+
case AST_NODE_TYPES2.TSInterfaceDeclaration:
|
|
359
|
+
case AST_NODE_TYPES2.TSEnumDeclaration:
|
|
360
|
+
return d.id;
|
|
361
|
+
case AST_NODE_TYPES2.VariableDeclaration: {
|
|
362
|
+
const only = d.declarations.length === 1 ? d.declarations[0] : void 0;
|
|
363
|
+
return only !== void 0 && only.id.type === AST_NODE_TYPES2.Identifier ? only.id : null;
|
|
364
|
+
}
|
|
365
|
+
default:
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
function defaultExportId(decl) {
|
|
370
|
+
const d = decl.declaration;
|
|
371
|
+
if ((d.type === AST_NODE_TYPES2.FunctionDeclaration || d.type === AST_NODE_TYPES2.ClassDeclaration) && d.id !== null) {
|
|
372
|
+
return d.id;
|
|
373
|
+
}
|
|
374
|
+
return d.type === AST_NODE_TYPES2.Identifier ? d : null;
|
|
375
|
+
}
|
|
376
|
+
function resolvePrimary(body) {
|
|
377
|
+
let hasDefault = false;
|
|
378
|
+
let defaultId = null;
|
|
379
|
+
const named = [];
|
|
380
|
+
for (const stmt of body) {
|
|
381
|
+
if (stmt.type === AST_NODE_TYPES2.ExportDefaultDeclaration) {
|
|
382
|
+
hasDefault = true;
|
|
383
|
+
defaultId = defaultExportId(stmt);
|
|
384
|
+
} else if (stmt.type === AST_NODE_TYPES2.ExportNamedDeclaration && stmt.source === null) {
|
|
385
|
+
const id = namedExportId(stmt);
|
|
386
|
+
if (id !== null) {
|
|
387
|
+
named.push(id);
|
|
388
|
+
} else if (stmt.declaration === null) {
|
|
389
|
+
for (const spec of stmt.specifiers) {
|
|
390
|
+
if (spec.exported.type === AST_NODE_TYPES2.Identifier) {
|
|
391
|
+
named.push(spec.exported);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
if (hasDefault) {
|
|
398
|
+
return defaultId === null ? null : { name: defaultId.name, node: defaultId };
|
|
399
|
+
}
|
|
400
|
+
const only = named.length === 1 ? named[0] : void 0;
|
|
401
|
+
return only !== void 0 ? { name: only.name, node: only } : null;
|
|
402
|
+
}
|
|
403
|
+
function moduleVariable(sourceCode, name) {
|
|
404
|
+
const globalScope = sourceCode.scopeManager?.globalScope ?? null;
|
|
405
|
+
if (globalScope === null) {
|
|
406
|
+
return null;
|
|
407
|
+
}
|
|
408
|
+
const moduleScope = globalScope.childScopes[0] ?? globalScope;
|
|
409
|
+
return moduleScope.variables.find((v) => v.name === name) ?? null;
|
|
410
|
+
}
|
|
411
|
+
var IMPORT_DEF_NODES = /* @__PURE__ */ new Set([
|
|
412
|
+
AST_NODE_TYPES2.ImportSpecifier,
|
|
413
|
+
AST_NODE_TYPES2.ImportDefaultSpecifier,
|
|
414
|
+
AST_NODE_TYPES2.ImportNamespaceSpecifier
|
|
415
|
+
]);
|
|
416
|
+
function isImportBinding(variable) {
|
|
417
|
+
return variable.defs.some((def) => IMPORT_DEF_NODES.has(def.node.type));
|
|
418
|
+
}
|
|
419
|
+
var filenameMatchesExportRule = createRule({
|
|
420
|
+
name: RULE_NAME4,
|
|
421
|
+
meta: {
|
|
422
|
+
type: "suggestion",
|
|
423
|
+
hasSuggestions: true,
|
|
424
|
+
docs: {
|
|
425
|
+
description: "A file's basename must match its primary export (a default export, or the sole named export)."
|
|
426
|
+
},
|
|
427
|
+
schema: [optionSchema4],
|
|
428
|
+
messages: {
|
|
429
|
+
filenameMismatch: "File `{{basename}}` exports `{{name}}` as its primary export \u2014 the basename should match it (rename the file to `{{expected}}`, or the export).",
|
|
430
|
+
renameExport: "Rename the export to `{{expected}}` to match the filename."
|
|
431
|
+
}
|
|
432
|
+
},
|
|
433
|
+
defaultOptions: [{ ignore: [] }],
|
|
434
|
+
create(context, [options]) {
|
|
435
|
+
const ignore = options.ignore ?? DEFAULT_IGNORE2;
|
|
436
|
+
const filename = context.filename;
|
|
437
|
+
const stem = stemOf2(filename);
|
|
438
|
+
if (stem === "index" || isIgnoredPath(filename, ignore)) {
|
|
439
|
+
return {};
|
|
440
|
+
}
|
|
441
|
+
return {
|
|
442
|
+
Program(node) {
|
|
443
|
+
const primary = resolvePrimary(node.body);
|
|
444
|
+
if (primary === null || normalize(primary.name) === normalize(stem)) {
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
const basename = getBasename(filename);
|
|
448
|
+
const canRename = VALID_IDENTIFIER.test(stem) && stem !== primary.name;
|
|
449
|
+
context.report({
|
|
450
|
+
node: primary.node,
|
|
451
|
+
messageId: "filenameMismatch",
|
|
452
|
+
data: { basename, name: primary.name, expected: stem },
|
|
453
|
+
suggest: canRename ? [
|
|
454
|
+
{
|
|
455
|
+
messageId: "renameExport",
|
|
456
|
+
data: { expected: stem },
|
|
457
|
+
fix: (fixer) => {
|
|
458
|
+
const variable = moduleVariable(context.sourceCode, primary.name);
|
|
459
|
+
if (variable !== null && !isImportBinding(variable)) {
|
|
460
|
+
const targets = /* @__PURE__ */ new Map();
|
|
461
|
+
for (const id of variable.identifiers) {
|
|
462
|
+
targets.set(id.range[0], id);
|
|
463
|
+
}
|
|
464
|
+
for (const ref of variable.references) {
|
|
465
|
+
targets.set(ref.identifier.range[0], ref.identifier);
|
|
466
|
+
}
|
|
467
|
+
return [...targets.values()].map((id) => fixer.replaceText(id, stem));
|
|
468
|
+
}
|
|
469
|
+
return [fixer.replaceText(primary.node, stem)];
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
] : void 0
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
|
|
173
479
|
// src/rules/index-must-reexport-default.ts
|
|
174
|
-
import
|
|
175
|
-
import { AST_NODE_TYPES } from "@typescript-eslint/utils";
|
|
176
|
-
var
|
|
480
|
+
import path5 from "path";
|
|
481
|
+
import { AST_NODE_TYPES as AST_NODE_TYPES3 } from "@typescript-eslint/utils";
|
|
482
|
+
var RULE_NAME5 = "index-must-reexport-default";
|
|
177
483
|
var DEFAULT_IGNORE_PATHS2 = [];
|
|
178
|
-
var
|
|
484
|
+
var optionSchema5 = {
|
|
179
485
|
type: "object",
|
|
180
486
|
additionalProperties: false,
|
|
181
487
|
properties: {
|
|
@@ -187,17 +493,17 @@ function reexportsDefault(node) {
|
|
|
187
493
|
return false;
|
|
188
494
|
}
|
|
189
495
|
return node.specifiers.some(
|
|
190
|
-
(specifier) => specifier.local.type ===
|
|
496
|
+
(specifier) => specifier.local.type === AST_NODE_TYPES3.Identifier && specifier.local.name === "default"
|
|
191
497
|
);
|
|
192
498
|
}
|
|
193
499
|
var indexMustReexportDefaultRule = createRule({
|
|
194
|
-
name:
|
|
500
|
+
name: RULE_NAME5,
|
|
195
501
|
meta: {
|
|
196
502
|
type: "problem",
|
|
197
503
|
docs: {
|
|
198
504
|
description: "A component folder's `index.ts` must re-export the component default (`export { default as <Name> } from './<Name>'`)."
|
|
199
505
|
},
|
|
200
|
-
schema: [
|
|
506
|
+
schema: [optionSchema5],
|
|
201
507
|
messages: {
|
|
202
508
|
missingDefaultReexport: "`index.ts` must re-export the {{name}} default: `export { default as {{name}} } from './{{name}}'`."
|
|
203
509
|
}
|
|
@@ -209,8 +515,8 @@ var indexMustReexportDefaultRule = createRule({
|
|
|
209
515
|
if (getBasename(filename) !== "index.ts" || isIgnoredPath(filename, ignorePaths)) {
|
|
210
516
|
return {};
|
|
211
517
|
}
|
|
212
|
-
const dir =
|
|
213
|
-
const folderName =
|
|
518
|
+
const dir = path5.dirname(filename);
|
|
519
|
+
const folderName = path5.basename(dir);
|
|
214
520
|
if (!isPascalCase(folderName) || !siblingExists(dir, `${folderName}.tsx`)) {
|
|
215
521
|
return {};
|
|
216
522
|
}
|
|
@@ -234,14 +540,120 @@ var indexMustReexportDefaultRule = createRule({
|
|
|
234
540
|
}
|
|
235
541
|
});
|
|
236
542
|
|
|
543
|
+
// src/rules/max-import-depth.ts
|
|
544
|
+
import path6 from "path";
|
|
545
|
+
import { AST_NODE_TYPES as AST_NODE_TYPES4 } from "@typescript-eslint/utils";
|
|
546
|
+
var RULE_NAME6 = "max-import-depth";
|
|
547
|
+
var DEFAULT_MAX = 3;
|
|
548
|
+
var optionSchema6 = {
|
|
549
|
+
type: "object",
|
|
550
|
+
additionalProperties: false,
|
|
551
|
+
properties: {
|
|
552
|
+
max: { type: "integer", minimum: 0 },
|
|
553
|
+
alias: {
|
|
554
|
+
type: "object",
|
|
555
|
+
additionalProperties: { type: "string" }
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
};
|
|
559
|
+
function climbDepth(source) {
|
|
560
|
+
if (!source.startsWith(".")) {
|
|
561
|
+
return 0;
|
|
562
|
+
}
|
|
563
|
+
let depth = 0;
|
|
564
|
+
for (const segment of source.split("/")) {
|
|
565
|
+
if (segment === "..") {
|
|
566
|
+
depth += 1;
|
|
567
|
+
} else if (segment === ".") {
|
|
568
|
+
continue;
|
|
569
|
+
} else {
|
|
570
|
+
break;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
return depth;
|
|
574
|
+
}
|
|
575
|
+
function aliasRewrite(source, currentFile, alias) {
|
|
576
|
+
const resolved = toPosix(path6.resolve(path6.dirname(currentFile), source));
|
|
577
|
+
for (const [anchor, prefix] of Object.entries(alias)) {
|
|
578
|
+
const marker = `/${anchor}/`;
|
|
579
|
+
const idx = resolved.lastIndexOf(marker);
|
|
580
|
+
if (idx === -1) {
|
|
581
|
+
continue;
|
|
582
|
+
}
|
|
583
|
+
const rest = resolved.slice(idx + marker.length);
|
|
584
|
+
if (rest.length === 0) {
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
return `${prefix}/${rest}`;
|
|
588
|
+
}
|
|
589
|
+
return null;
|
|
590
|
+
}
|
|
591
|
+
var maxImportDepthRule = createRule({
|
|
592
|
+
name: RULE_NAME6,
|
|
593
|
+
meta: {
|
|
594
|
+
type: "suggestion",
|
|
595
|
+
fixable: "code",
|
|
596
|
+
docs: {
|
|
597
|
+
description: "A relative import may not climb more than `max` parent levels (default 3). Autofixed to a path alias when one is configured."
|
|
598
|
+
},
|
|
599
|
+
schema: [optionSchema6],
|
|
600
|
+
messages: {
|
|
601
|
+
tooDeep: "Relative import `{{source}}` climbs {{depth}} levels \u2014 over the limit of {{max}}. Use a path alias instead of reaching this far up the tree."
|
|
602
|
+
}
|
|
603
|
+
},
|
|
604
|
+
defaultOptions: [{ max: DEFAULT_MAX, alias: {} }],
|
|
605
|
+
create(context, [options]) {
|
|
606
|
+
const max = options.max ?? DEFAULT_MAX;
|
|
607
|
+
const alias = options.alias ?? {};
|
|
608
|
+
const filename = context.filename;
|
|
609
|
+
function check(sourceNode) {
|
|
610
|
+
if (sourceNode === null || sourceNode === void 0 || typeof sourceNode.value !== "string") {
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
const source = sourceNode.value;
|
|
614
|
+
const depth = climbDepth(source);
|
|
615
|
+
if (depth <= max) {
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
const rewrite = aliasRewrite(source, filename, alias);
|
|
619
|
+
context.report({
|
|
620
|
+
node: sourceNode,
|
|
621
|
+
messageId: "tooDeep",
|
|
622
|
+
data: { source, depth, max },
|
|
623
|
+
fix: rewrite === null ? void 0 : (fixer) => {
|
|
624
|
+
const quote = sourceNode.raw.charAt(0);
|
|
625
|
+
return fixer.replaceText(sourceNode, `${quote}${rewrite}${quote}`);
|
|
626
|
+
}
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
function literalSource(node) {
|
|
630
|
+
return node !== null && node !== void 0 && node.type === AST_NODE_TYPES4.Literal ? node : null;
|
|
631
|
+
}
|
|
632
|
+
return {
|
|
633
|
+
ImportDeclaration(node) {
|
|
634
|
+
check(node.source);
|
|
635
|
+
},
|
|
636
|
+
ImportExpression(node) {
|
|
637
|
+
check(literalSource(node.source));
|
|
638
|
+
},
|
|
639
|
+
ExportNamedDeclaration(node) {
|
|
640
|
+
check(literalSource(node.source));
|
|
641
|
+
},
|
|
642
|
+
ExportAllDeclaration(node) {
|
|
643
|
+
check(literalSource(node.source));
|
|
644
|
+
}
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
});
|
|
648
|
+
|
|
237
649
|
// src/rules/no-cross-feature-imports.ts
|
|
238
|
-
import
|
|
239
|
-
import { AST_NODE_TYPES as
|
|
240
|
-
var
|
|
650
|
+
import path7 from "path";
|
|
651
|
+
import { AST_NODE_TYPES as AST_NODE_TYPES5 } from "@typescript-eslint/utils";
|
|
652
|
+
var RULE_NAME7 = "no-cross-feature-imports";
|
|
241
653
|
var DEFAULT_FEATURE_ROOT = "components";
|
|
242
654
|
var DEFAULT_ALIAS = "@/components";
|
|
243
655
|
var DEFAULT_SHARED_FEATURES = ["ui"];
|
|
244
|
-
var
|
|
656
|
+
var optionSchema7 = {
|
|
245
657
|
type: "object",
|
|
246
658
|
additionalProperties: false,
|
|
247
659
|
properties: {
|
|
@@ -263,19 +675,19 @@ function resolveTargetFeature(source, currentFile, aliasRe, featureRoot) {
|
|
|
263
675
|
return aliasMatch[1] ?? null;
|
|
264
676
|
}
|
|
265
677
|
if (source.startsWith(".")) {
|
|
266
|
-
const resolved =
|
|
678
|
+
const resolved = path7.resolve(path7.dirname(currentFile), source);
|
|
267
679
|
return getFeatureName(resolved, featureRoot);
|
|
268
680
|
}
|
|
269
681
|
return null;
|
|
270
682
|
}
|
|
271
683
|
var noCrossFeatureImportsRule = createRule({
|
|
272
|
-
name:
|
|
684
|
+
name: RULE_NAME7,
|
|
273
685
|
meta: {
|
|
274
686
|
type: "problem",
|
|
275
687
|
docs: {
|
|
276
688
|
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
689
|
},
|
|
278
|
-
schema: [
|
|
690
|
+
schema: [optionSchema7],
|
|
279
691
|
messages: {
|
|
280
692
|
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
693
|
}
|
|
@@ -318,25 +730,25 @@ var noCrossFeatureImportsRule = createRule({
|
|
|
318
730
|
}
|
|
319
731
|
return {
|
|
320
732
|
ImportDeclaration(node) {
|
|
321
|
-
if (node.source.type ===
|
|
733
|
+
if (node.source.type === AST_NODE_TYPES5.Literal) {
|
|
322
734
|
checkSource(node.source, node.importKind === "type");
|
|
323
735
|
}
|
|
324
736
|
},
|
|
325
737
|
// Dynamic `import()` is runtime by nature — never type-only.
|
|
326
738
|
ImportExpression(node) {
|
|
327
|
-
if (node.source.type ===
|
|
739
|
+
if (node.source.type === AST_NODE_TYPES5.Literal) {
|
|
328
740
|
checkSource(node.source, false);
|
|
329
741
|
}
|
|
330
742
|
},
|
|
331
743
|
// `export { x } from '…'` re-export laundering.
|
|
332
744
|
ExportNamedDeclaration(node) {
|
|
333
|
-
if (node.source !== null && node.source.type ===
|
|
745
|
+
if (node.source !== null && node.source.type === AST_NODE_TYPES5.Literal) {
|
|
334
746
|
checkSource(node.source, node.exportKind === "type");
|
|
335
747
|
}
|
|
336
748
|
},
|
|
337
749
|
// `export * from '…'` re-export laundering.
|
|
338
750
|
ExportAllDeclaration(node) {
|
|
339
|
-
if (node.source.type ===
|
|
751
|
+
if (node.source.type === AST_NODE_TYPES5.Literal) {
|
|
340
752
|
checkSource(node.source, node.exportKind === "type");
|
|
341
753
|
}
|
|
342
754
|
}
|
|
@@ -346,14 +758,18 @@ var noCrossFeatureImportsRule = createRule({
|
|
|
346
758
|
|
|
347
759
|
// src/rules/index.ts
|
|
348
760
|
var rules = {
|
|
761
|
+
"barrel-purity": barrelPurityRule,
|
|
762
|
+
"colocated-test-required": colocatedTestRequiredRule,
|
|
349
763
|
"component-folder-structure": componentFolderStructureRule,
|
|
764
|
+
"filename-matches-export": filenameMatchesExportRule,
|
|
350
765
|
"index-must-reexport-default": indexMustReexportDefaultRule,
|
|
766
|
+
"max-import-depth": maxImportDepthRule,
|
|
351
767
|
"no-cross-feature-imports": noCrossFeatureImportsRule
|
|
352
768
|
};
|
|
353
769
|
|
|
354
770
|
// src/index.ts
|
|
355
771
|
var NAMESPACE = "noctcore-architecture";
|
|
356
|
-
var VERSION = "0.
|
|
772
|
+
var VERSION = "0.2.0";
|
|
357
773
|
var plugin = {
|
|
358
774
|
meta: { name: "@noctcore/eslint-plugin-architecture", version: VERSION },
|
|
359
775
|
rules,
|
|
@@ -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`.
|