@noctcore/eslint-plugin-architecture 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -2
- package/dist/index.cjs +1024 -30
- package/dist/index.d.cts +77 -0
- package/dist/index.d.ts +77 -0
- package/dist/index.js +1024 -30
- package/docs/rules/barrel-purity.md +57 -0
- package/docs/rules/colocated-test-required.md +48 -0
- package/docs/rules/filename-matches-export.md +56 -0
- package/docs/rules/max-import-depth.md +52 -0
- package/docs/rules/single-semantic-module.md +180 -0
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,12 +1,25 @@
|
|
|
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",
|
|
5
|
-
"noctcore-architecture/
|
|
12
|
+
"noctcore-architecture/max-import-depth": "error",
|
|
13
|
+
"noctcore-architecture/no-cross-feature-imports": "error",
|
|
14
|
+
// Ships OFF: which files it governs and which category mixes they may keep
|
|
15
|
+
// (a NestJS `.constants.ts` legitimately holds constants, types and enums) is
|
|
16
|
+
// a per-codebase decision best made from measured counts. Enable it with
|
|
17
|
+
// 'noctcore-architecture/single-semantic-module': ['error', { allow: [['constant', 'type', 'enum']] }]
|
|
18
|
+
"noctcore-architecture/single-semantic-module": "off"
|
|
6
19
|
};
|
|
7
20
|
|
|
8
|
-
// src/rules/
|
|
9
|
-
import
|
|
21
|
+
// src/rules/barrel-purity.ts
|
|
22
|
+
import { AST_NODE_TYPES } from "@typescript-eslint/utils";
|
|
10
23
|
|
|
11
24
|
// src/createRule.ts
|
|
12
25
|
import { makeCreateRule } from "@noctcore/eslint-utils";
|
|
@@ -98,8 +111,154 @@ function readDirSafe(dir) {
|
|
|
98
111
|
}
|
|
99
112
|
}
|
|
100
113
|
|
|
114
|
+
// src/rules/barrel-purity.ts
|
|
115
|
+
var RULE_NAME = "barrel-purity";
|
|
116
|
+
var DEFAULT_ALLOW = [];
|
|
117
|
+
var BARREL_BASENAME = /^index\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/;
|
|
118
|
+
var optionSchema = {
|
|
119
|
+
type: "object",
|
|
120
|
+
additionalProperties: false,
|
|
121
|
+
properties: {
|
|
122
|
+
allow: { type: "array", items: { type: "string" }, uniqueItems: true }
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
function impurityOf(stmt) {
|
|
126
|
+
switch (stmt.type) {
|
|
127
|
+
case AST_NODE_TYPES.ImportDeclaration:
|
|
128
|
+
return stmt.specifiers.length === 0 ? "a side-effect import" : null;
|
|
129
|
+
case AST_NODE_TYPES.ExportAllDeclaration:
|
|
130
|
+
return null;
|
|
131
|
+
case AST_NODE_TYPES.ExportNamedDeclaration:
|
|
132
|
+
if (stmt.source !== null) {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
if (stmt.declaration === null) {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
return "a local declaration";
|
|
139
|
+
case AST_NODE_TYPES.ExportDefaultDeclaration:
|
|
140
|
+
return stmt.declaration.type === AST_NODE_TYPES.Identifier ? null : "a default-exported value";
|
|
141
|
+
case AST_NODE_TYPES.ExpressionStatement:
|
|
142
|
+
return "a side-effect statement";
|
|
143
|
+
default:
|
|
144
|
+
return "non-re-export code";
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
var barrelPurityRule = createRule({
|
|
148
|
+
name: RULE_NAME,
|
|
149
|
+
meta: {
|
|
150
|
+
type: "problem",
|
|
151
|
+
docs: {
|
|
152
|
+
description: "A barrel (`index.ts` / `index.tsx`) must contain only re-exports \u2014 never local declarations, side effects, or default-exported values."
|
|
153
|
+
},
|
|
154
|
+
schema: [optionSchema],
|
|
155
|
+
messages: {
|
|
156
|
+
impureBarrel: "A barrel must contain only re-exports; found {{kind}}. Move it into a sibling module and re-export it from here."
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
defaultOptions: [{ allow: [] }],
|
|
160
|
+
create(context, [options]) {
|
|
161
|
+
const allow = options.allow ?? DEFAULT_ALLOW;
|
|
162
|
+
const filename = context.filename;
|
|
163
|
+
if (!BARREL_BASENAME.test(getBasename(filename)) || isIgnoredPath(filename, allow)) {
|
|
164
|
+
return {};
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
Program(node) {
|
|
168
|
+
for (const stmt of node.body) {
|
|
169
|
+
const kind = impurityOf(stmt);
|
|
170
|
+
if (kind !== null) {
|
|
171
|
+
context.report({ node: stmt, messageId: "impureBarrel", data: { kind } });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// src/rules/colocated-test-required.ts
|
|
180
|
+
import { readdirSync as readdirSync2 } from "fs";
|
|
181
|
+
import path2 from "path";
|
|
182
|
+
var RULE_NAME2 = "colocated-test-required";
|
|
183
|
+
var DEFAULT_INCLUDE = [];
|
|
184
|
+
var DEFAULT_IGNORE = [];
|
|
185
|
+
var TEST_SIBLING = /\.(test|spec)\.[^.]+$/;
|
|
186
|
+
var optionSchema2 = {
|
|
187
|
+
type: "object",
|
|
188
|
+
additionalProperties: false,
|
|
189
|
+
properties: {
|
|
190
|
+
include: { type: "array", items: { type: "string" }, uniqueItems: true },
|
|
191
|
+
ignore: { type: "array", items: { type: "string" }, uniqueItems: true }
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
var dirCache = /* @__PURE__ */ new Map();
|
|
195
|
+
function readDirCached(dir) {
|
|
196
|
+
const cached = dirCache.get(dir);
|
|
197
|
+
if (cached !== void 0) {
|
|
198
|
+
return cached;
|
|
199
|
+
}
|
|
200
|
+
let entries;
|
|
201
|
+
try {
|
|
202
|
+
entries = readdirSync2(dir);
|
|
203
|
+
} catch {
|
|
204
|
+
entries = [];
|
|
205
|
+
}
|
|
206
|
+
dirCache.set(dir, entries);
|
|
207
|
+
return entries;
|
|
208
|
+
}
|
|
209
|
+
function stemOf(basename) {
|
|
210
|
+
const ext = path2.extname(basename);
|
|
211
|
+
return ext === "" ? basename : basename.slice(0, -ext.length);
|
|
212
|
+
}
|
|
213
|
+
function hasColocatedTest(dir, stem) {
|
|
214
|
+
const prefix = `${stem}.`;
|
|
215
|
+
return readDirCached(dir).some(
|
|
216
|
+
(entry) => entry.startsWith(prefix) && TEST_SIBLING.test(entry)
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
var colocatedTestRequiredRule = createRule({
|
|
220
|
+
name: RULE_NAME2,
|
|
221
|
+
meta: {
|
|
222
|
+
type: "problem",
|
|
223
|
+
docs: {
|
|
224
|
+
description: "A source file matching an `include` glob must have a colocated `*.test.*` / `*.spec.*` sibling on disk. Off until `include` is configured."
|
|
225
|
+
},
|
|
226
|
+
schema: [optionSchema2],
|
|
227
|
+
messages: {
|
|
228
|
+
missingTest: "Source file `{{basename}}` has no colocated test. Add a sibling `{{stem}}.test.*` (or `.spec.*`) next to it."
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
defaultOptions: [{ include: [], ignore: [] }],
|
|
232
|
+
create(context, [options]) {
|
|
233
|
+
const include = options.include ?? DEFAULT_INCLUDE;
|
|
234
|
+
const ignore = options.ignore ?? DEFAULT_IGNORE;
|
|
235
|
+
const filename = context.filename;
|
|
236
|
+
if (include.length === 0 || !isIgnoredPath(filename, include)) {
|
|
237
|
+
return {};
|
|
238
|
+
}
|
|
239
|
+
const basename = getBasename(filename);
|
|
240
|
+
if (TEST_SIBLING.test(basename) || isIgnoredPath(filename, ignore)) {
|
|
241
|
+
return {};
|
|
242
|
+
}
|
|
243
|
+
const stem = stemOf(basename);
|
|
244
|
+
const dir = path2.dirname(filename);
|
|
245
|
+
return {
|
|
246
|
+
Program(node) {
|
|
247
|
+
if (!hasColocatedTest(dir, stem)) {
|
|
248
|
+
context.report({
|
|
249
|
+
node,
|
|
250
|
+
messageId: "missingTest",
|
|
251
|
+
data: { basename, stem }
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
|
|
101
259
|
// src/rules/component-folder-structure.ts
|
|
102
|
-
|
|
260
|
+
import path3 from "path";
|
|
261
|
+
var RULE_NAME3 = "component-folder-structure";
|
|
103
262
|
var DEFAULT_COMPONENT_ROOT = "components";
|
|
104
263
|
var DEFAULT_IGNORE_PATHS = ["**/ui/**"];
|
|
105
264
|
var DEFAULT_REQUIRED_SIBLINGS = [
|
|
@@ -112,7 +271,7 @@ var DEFAULT_REQUIRED_SIBLINGS = [
|
|
|
112
271
|
function resolveSibling(template, name) {
|
|
113
272
|
return template.startsWith(".") ? `${name}${template}` : template;
|
|
114
273
|
}
|
|
115
|
-
var
|
|
274
|
+
var optionSchema3 = {
|
|
116
275
|
type: "object",
|
|
117
276
|
additionalProperties: false,
|
|
118
277
|
properties: {
|
|
@@ -122,13 +281,13 @@ var optionSchema = {
|
|
|
122
281
|
}
|
|
123
282
|
};
|
|
124
283
|
var componentFolderStructureRule = createRule({
|
|
125
|
-
name:
|
|
284
|
+
name: RULE_NAME3,
|
|
126
285
|
meta: {
|
|
127
286
|
type: "problem",
|
|
128
287
|
docs: {
|
|
129
288
|
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
289
|
},
|
|
131
|
-
schema: [
|
|
290
|
+
schema: [optionSchema3],
|
|
132
291
|
messages: {
|
|
133
292
|
missingSiblings: "Component `{{name}}` is missing sibling file(s): {{missing}}. Every component folder must carry its hooks, types, stories, test, and index barrel."
|
|
134
293
|
}
|
|
@@ -152,7 +311,7 @@ var componentFolderStructureRule = createRule({
|
|
|
152
311
|
return {};
|
|
153
312
|
}
|
|
154
313
|
const name = getComponentName(filename);
|
|
155
|
-
const dir =
|
|
314
|
+
const dir = path3.dirname(filename);
|
|
156
315
|
const required = siblingTemplates.map((template) => resolveSibling(template, name));
|
|
157
316
|
const present = readDirSafe(dir);
|
|
158
317
|
const missing = required.filter((sibling) => !present.has(sibling));
|
|
@@ -170,12 +329,164 @@ var componentFolderStructureRule = createRule({
|
|
|
170
329
|
}
|
|
171
330
|
});
|
|
172
331
|
|
|
332
|
+
// src/rules/filename-matches-export.ts
|
|
333
|
+
import path4 from "path";
|
|
334
|
+
import { AST_NODE_TYPES as AST_NODE_TYPES2 } from "@typescript-eslint/utils";
|
|
335
|
+
var RULE_NAME4 = "filename-matches-export";
|
|
336
|
+
var DEFAULT_IGNORE2 = [];
|
|
337
|
+
var VALID_IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
338
|
+
var optionSchema4 = {
|
|
339
|
+
type: "object",
|
|
340
|
+
additionalProperties: false,
|
|
341
|
+
properties: {
|
|
342
|
+
ignore: { type: "array", items: { type: "string" }, uniqueItems: true }
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
function stemOf2(filename) {
|
|
346
|
+
const basename = getBasename(filename);
|
|
347
|
+
const ext = path4.extname(basename);
|
|
348
|
+
return ext === "" ? basename : basename.slice(0, -ext.length);
|
|
349
|
+
}
|
|
350
|
+
function normalize(value) {
|
|
351
|
+
return value.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
352
|
+
}
|
|
353
|
+
function namedExportId(decl) {
|
|
354
|
+
if (decl.declaration === null) {
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
const d = decl.declaration;
|
|
358
|
+
switch (d.type) {
|
|
359
|
+
case AST_NODE_TYPES2.FunctionDeclaration:
|
|
360
|
+
case AST_NODE_TYPES2.ClassDeclaration:
|
|
361
|
+
return d.id;
|
|
362
|
+
case AST_NODE_TYPES2.TSTypeAliasDeclaration:
|
|
363
|
+
case AST_NODE_TYPES2.TSInterfaceDeclaration:
|
|
364
|
+
case AST_NODE_TYPES2.TSEnumDeclaration:
|
|
365
|
+
return d.id;
|
|
366
|
+
case AST_NODE_TYPES2.VariableDeclaration: {
|
|
367
|
+
const only = d.declarations.length === 1 ? d.declarations[0] : void 0;
|
|
368
|
+
return only !== void 0 && only.id.type === AST_NODE_TYPES2.Identifier ? only.id : null;
|
|
369
|
+
}
|
|
370
|
+
default:
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
function defaultExportId(decl) {
|
|
375
|
+
const d = decl.declaration;
|
|
376
|
+
if ((d.type === AST_NODE_TYPES2.FunctionDeclaration || d.type === AST_NODE_TYPES2.ClassDeclaration) && d.id !== null) {
|
|
377
|
+
return d.id;
|
|
378
|
+
}
|
|
379
|
+
return d.type === AST_NODE_TYPES2.Identifier ? d : null;
|
|
380
|
+
}
|
|
381
|
+
function resolvePrimary(body) {
|
|
382
|
+
let hasDefault = false;
|
|
383
|
+
let defaultId = null;
|
|
384
|
+
const named = [];
|
|
385
|
+
for (const stmt of body) {
|
|
386
|
+
if (stmt.type === AST_NODE_TYPES2.ExportDefaultDeclaration) {
|
|
387
|
+
hasDefault = true;
|
|
388
|
+
defaultId = defaultExportId(stmt);
|
|
389
|
+
} else if (stmt.type === AST_NODE_TYPES2.ExportNamedDeclaration && stmt.source === null) {
|
|
390
|
+
const id = namedExportId(stmt);
|
|
391
|
+
if (id !== null) {
|
|
392
|
+
named.push(id);
|
|
393
|
+
} else if (stmt.declaration === null) {
|
|
394
|
+
for (const spec of stmt.specifiers) {
|
|
395
|
+
if (spec.exported.type === AST_NODE_TYPES2.Identifier) {
|
|
396
|
+
named.push(spec.exported);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
if (hasDefault) {
|
|
403
|
+
return defaultId === null ? null : { name: defaultId.name, node: defaultId };
|
|
404
|
+
}
|
|
405
|
+
const only = named.length === 1 ? named[0] : void 0;
|
|
406
|
+
return only !== void 0 ? { name: only.name, node: only } : null;
|
|
407
|
+
}
|
|
408
|
+
function moduleVariable(sourceCode, name) {
|
|
409
|
+
const globalScope = sourceCode.scopeManager?.globalScope ?? null;
|
|
410
|
+
if (globalScope === null) {
|
|
411
|
+
return null;
|
|
412
|
+
}
|
|
413
|
+
const moduleScope = globalScope.childScopes[0] ?? globalScope;
|
|
414
|
+
return moduleScope.variables.find((v) => v.name === name) ?? null;
|
|
415
|
+
}
|
|
416
|
+
var IMPORT_DEF_NODES = /* @__PURE__ */ new Set([
|
|
417
|
+
AST_NODE_TYPES2.ImportSpecifier,
|
|
418
|
+
AST_NODE_TYPES2.ImportDefaultSpecifier,
|
|
419
|
+
AST_NODE_TYPES2.ImportNamespaceSpecifier
|
|
420
|
+
]);
|
|
421
|
+
function isImportBinding(variable) {
|
|
422
|
+
return variable.defs.some((def) => IMPORT_DEF_NODES.has(def.node.type));
|
|
423
|
+
}
|
|
424
|
+
var filenameMatchesExportRule = createRule({
|
|
425
|
+
name: RULE_NAME4,
|
|
426
|
+
meta: {
|
|
427
|
+
type: "suggestion",
|
|
428
|
+
hasSuggestions: true,
|
|
429
|
+
docs: {
|
|
430
|
+
description: "A file's basename must match its primary export (a default export, or the sole named export)."
|
|
431
|
+
},
|
|
432
|
+
schema: [optionSchema4],
|
|
433
|
+
messages: {
|
|
434
|
+
filenameMismatch: "File `{{basename}}` exports `{{name}}` as its primary export \u2014 the basename should match it (rename the file to `{{expected}}`, or the export).",
|
|
435
|
+
renameExport: "Rename the export to `{{expected}}` to match the filename."
|
|
436
|
+
}
|
|
437
|
+
},
|
|
438
|
+
defaultOptions: [{ ignore: [] }],
|
|
439
|
+
create(context, [options]) {
|
|
440
|
+
const ignore = options.ignore ?? DEFAULT_IGNORE2;
|
|
441
|
+
const filename = context.filename;
|
|
442
|
+
const stem = stemOf2(filename);
|
|
443
|
+
if (stem === "index" || isIgnoredPath(filename, ignore)) {
|
|
444
|
+
return {};
|
|
445
|
+
}
|
|
446
|
+
return {
|
|
447
|
+
Program(node) {
|
|
448
|
+
const primary = resolvePrimary(node.body);
|
|
449
|
+
if (primary === null || normalize(primary.name) === normalize(stem)) {
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
const basename = getBasename(filename);
|
|
453
|
+
const canRename = VALID_IDENTIFIER.test(stem) && stem !== primary.name;
|
|
454
|
+
context.report({
|
|
455
|
+
node: primary.node,
|
|
456
|
+
messageId: "filenameMismatch",
|
|
457
|
+
data: { basename, name: primary.name, expected: stem },
|
|
458
|
+
suggest: canRename ? [
|
|
459
|
+
{
|
|
460
|
+
messageId: "renameExport",
|
|
461
|
+
data: { expected: stem },
|
|
462
|
+
fix: (fixer) => {
|
|
463
|
+
const variable = moduleVariable(context.sourceCode, primary.name);
|
|
464
|
+
if (variable !== null && !isImportBinding(variable)) {
|
|
465
|
+
const targets = /* @__PURE__ */ new Map();
|
|
466
|
+
for (const id of variable.identifiers) {
|
|
467
|
+
targets.set(id.range[0], id);
|
|
468
|
+
}
|
|
469
|
+
for (const ref of variable.references) {
|
|
470
|
+
targets.set(ref.identifier.range[0], ref.identifier);
|
|
471
|
+
}
|
|
472
|
+
return [...targets.values()].map((id) => fixer.replaceText(id, stem));
|
|
473
|
+
}
|
|
474
|
+
return [fixer.replaceText(primary.node, stem)];
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
] : void 0
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
|
|
173
484
|
// src/rules/index-must-reexport-default.ts
|
|
174
|
-
import
|
|
175
|
-
import { AST_NODE_TYPES } from "@typescript-eslint/utils";
|
|
176
|
-
var
|
|
485
|
+
import path5 from "path";
|
|
486
|
+
import { AST_NODE_TYPES as AST_NODE_TYPES3 } from "@typescript-eslint/utils";
|
|
487
|
+
var RULE_NAME5 = "index-must-reexport-default";
|
|
177
488
|
var DEFAULT_IGNORE_PATHS2 = [];
|
|
178
|
-
var
|
|
489
|
+
var optionSchema5 = {
|
|
179
490
|
type: "object",
|
|
180
491
|
additionalProperties: false,
|
|
181
492
|
properties: {
|
|
@@ -187,17 +498,17 @@ function reexportsDefault(node) {
|
|
|
187
498
|
return false;
|
|
188
499
|
}
|
|
189
500
|
return node.specifiers.some(
|
|
190
|
-
(specifier) => specifier.local.type ===
|
|
501
|
+
(specifier) => specifier.local.type === AST_NODE_TYPES3.Identifier && specifier.local.name === "default"
|
|
191
502
|
);
|
|
192
503
|
}
|
|
193
504
|
var indexMustReexportDefaultRule = createRule({
|
|
194
|
-
name:
|
|
505
|
+
name: RULE_NAME5,
|
|
195
506
|
meta: {
|
|
196
507
|
type: "problem",
|
|
197
508
|
docs: {
|
|
198
509
|
description: "A component folder's `index.ts` must re-export the component default (`export { default as <Name> } from './<Name>'`)."
|
|
199
510
|
},
|
|
200
|
-
schema: [
|
|
511
|
+
schema: [optionSchema5],
|
|
201
512
|
messages: {
|
|
202
513
|
missingDefaultReexport: "`index.ts` must re-export the {{name}} default: `export { default as {{name}} } from './{{name}}'`."
|
|
203
514
|
}
|
|
@@ -209,8 +520,8 @@ var indexMustReexportDefaultRule = createRule({
|
|
|
209
520
|
if (getBasename(filename) !== "index.ts" || isIgnoredPath(filename, ignorePaths)) {
|
|
210
521
|
return {};
|
|
211
522
|
}
|
|
212
|
-
const dir =
|
|
213
|
-
const folderName =
|
|
523
|
+
const dir = path5.dirname(filename);
|
|
524
|
+
const folderName = path5.basename(dir);
|
|
214
525
|
if (!isPascalCase(folderName) || !siblingExists(dir, `${folderName}.tsx`)) {
|
|
215
526
|
return {};
|
|
216
527
|
}
|
|
@@ -234,14 +545,120 @@ var indexMustReexportDefaultRule = createRule({
|
|
|
234
545
|
}
|
|
235
546
|
});
|
|
236
547
|
|
|
548
|
+
// src/rules/max-import-depth.ts
|
|
549
|
+
import path6 from "path";
|
|
550
|
+
import { AST_NODE_TYPES as AST_NODE_TYPES4 } from "@typescript-eslint/utils";
|
|
551
|
+
var RULE_NAME6 = "max-import-depth";
|
|
552
|
+
var DEFAULT_MAX = 3;
|
|
553
|
+
var optionSchema6 = {
|
|
554
|
+
type: "object",
|
|
555
|
+
additionalProperties: false,
|
|
556
|
+
properties: {
|
|
557
|
+
max: { type: "integer", minimum: 0 },
|
|
558
|
+
alias: {
|
|
559
|
+
type: "object",
|
|
560
|
+
additionalProperties: { type: "string" }
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
};
|
|
564
|
+
function climbDepth(source) {
|
|
565
|
+
if (!source.startsWith(".")) {
|
|
566
|
+
return 0;
|
|
567
|
+
}
|
|
568
|
+
let depth = 0;
|
|
569
|
+
for (const segment of source.split("/")) {
|
|
570
|
+
if (segment === "..") {
|
|
571
|
+
depth += 1;
|
|
572
|
+
} else if (segment === ".") {
|
|
573
|
+
continue;
|
|
574
|
+
} else {
|
|
575
|
+
break;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
return depth;
|
|
579
|
+
}
|
|
580
|
+
function aliasRewrite(source, currentFile, alias) {
|
|
581
|
+
const resolved = toPosix(path6.resolve(path6.dirname(currentFile), source));
|
|
582
|
+
for (const [anchor, prefix] of Object.entries(alias)) {
|
|
583
|
+
const marker = `/${anchor}/`;
|
|
584
|
+
const idx = resolved.lastIndexOf(marker);
|
|
585
|
+
if (idx === -1) {
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
const rest = resolved.slice(idx + marker.length);
|
|
589
|
+
if (rest.length === 0) {
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
return `${prefix}/${rest}`;
|
|
593
|
+
}
|
|
594
|
+
return null;
|
|
595
|
+
}
|
|
596
|
+
var maxImportDepthRule = createRule({
|
|
597
|
+
name: RULE_NAME6,
|
|
598
|
+
meta: {
|
|
599
|
+
type: "suggestion",
|
|
600
|
+
fixable: "code",
|
|
601
|
+
docs: {
|
|
602
|
+
description: "A relative import may not climb more than `max` parent levels (default 3). Autofixed to a path alias when one is configured."
|
|
603
|
+
},
|
|
604
|
+
schema: [optionSchema6],
|
|
605
|
+
messages: {
|
|
606
|
+
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."
|
|
607
|
+
}
|
|
608
|
+
},
|
|
609
|
+
defaultOptions: [{ max: DEFAULT_MAX, alias: {} }],
|
|
610
|
+
create(context, [options]) {
|
|
611
|
+
const max = options.max ?? DEFAULT_MAX;
|
|
612
|
+
const alias = options.alias ?? {};
|
|
613
|
+
const filename = context.filename;
|
|
614
|
+
function check(sourceNode) {
|
|
615
|
+
if (sourceNode === null || sourceNode === void 0 || typeof sourceNode.value !== "string") {
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
const source = sourceNode.value;
|
|
619
|
+
const depth = climbDepth(source);
|
|
620
|
+
if (depth <= max) {
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
const rewrite = aliasRewrite(source, filename, alias);
|
|
624
|
+
context.report({
|
|
625
|
+
node: sourceNode,
|
|
626
|
+
messageId: "tooDeep",
|
|
627
|
+
data: { source, depth, max },
|
|
628
|
+
fix: rewrite === null ? void 0 : (fixer) => {
|
|
629
|
+
const quote = sourceNode.raw.charAt(0);
|
|
630
|
+
return fixer.replaceText(sourceNode, `${quote}${rewrite}${quote}`);
|
|
631
|
+
}
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
function literalSource(node) {
|
|
635
|
+
return node !== null && node !== void 0 && node.type === AST_NODE_TYPES4.Literal ? node : null;
|
|
636
|
+
}
|
|
637
|
+
return {
|
|
638
|
+
ImportDeclaration(node) {
|
|
639
|
+
check(node.source);
|
|
640
|
+
},
|
|
641
|
+
ImportExpression(node) {
|
|
642
|
+
check(literalSource(node.source));
|
|
643
|
+
},
|
|
644
|
+
ExportNamedDeclaration(node) {
|
|
645
|
+
check(literalSource(node.source));
|
|
646
|
+
},
|
|
647
|
+
ExportAllDeclaration(node) {
|
|
648
|
+
check(literalSource(node.source));
|
|
649
|
+
}
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
});
|
|
653
|
+
|
|
237
654
|
// src/rules/no-cross-feature-imports.ts
|
|
238
|
-
import
|
|
239
|
-
import { AST_NODE_TYPES as
|
|
240
|
-
var
|
|
655
|
+
import path7 from "path";
|
|
656
|
+
import { AST_NODE_TYPES as AST_NODE_TYPES5 } from "@typescript-eslint/utils";
|
|
657
|
+
var RULE_NAME7 = "no-cross-feature-imports";
|
|
241
658
|
var DEFAULT_FEATURE_ROOT = "components";
|
|
242
659
|
var DEFAULT_ALIAS = "@/components";
|
|
243
660
|
var DEFAULT_SHARED_FEATURES = ["ui"];
|
|
244
|
-
var
|
|
661
|
+
var optionSchema7 = {
|
|
245
662
|
type: "object",
|
|
246
663
|
additionalProperties: false,
|
|
247
664
|
properties: {
|
|
@@ -263,19 +680,19 @@ function resolveTargetFeature(source, currentFile, aliasRe, featureRoot) {
|
|
|
263
680
|
return aliasMatch[1] ?? null;
|
|
264
681
|
}
|
|
265
682
|
if (source.startsWith(".")) {
|
|
266
|
-
const resolved =
|
|
683
|
+
const resolved = path7.resolve(path7.dirname(currentFile), source);
|
|
267
684
|
return getFeatureName(resolved, featureRoot);
|
|
268
685
|
}
|
|
269
686
|
return null;
|
|
270
687
|
}
|
|
271
688
|
var noCrossFeatureImportsRule = createRule({
|
|
272
|
-
name:
|
|
689
|
+
name: RULE_NAME7,
|
|
273
690
|
meta: {
|
|
274
691
|
type: "problem",
|
|
275
692
|
docs: {
|
|
276
693
|
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
694
|
},
|
|
278
|
-
schema: [
|
|
695
|
+
schema: [optionSchema7],
|
|
279
696
|
messages: {
|
|
280
697
|
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
698
|
}
|
|
@@ -318,25 +735,25 @@ var noCrossFeatureImportsRule = createRule({
|
|
|
318
735
|
}
|
|
319
736
|
return {
|
|
320
737
|
ImportDeclaration(node) {
|
|
321
|
-
if (node.source.type ===
|
|
738
|
+
if (node.source.type === AST_NODE_TYPES5.Literal) {
|
|
322
739
|
checkSource(node.source, node.importKind === "type");
|
|
323
740
|
}
|
|
324
741
|
},
|
|
325
742
|
// Dynamic `import()` is runtime by nature — never type-only.
|
|
326
743
|
ImportExpression(node) {
|
|
327
|
-
if (node.source.type ===
|
|
744
|
+
if (node.source.type === AST_NODE_TYPES5.Literal) {
|
|
328
745
|
checkSource(node.source, false);
|
|
329
746
|
}
|
|
330
747
|
},
|
|
331
748
|
// `export { x } from '…'` re-export laundering.
|
|
332
749
|
ExportNamedDeclaration(node) {
|
|
333
|
-
if (node.source !== null && node.source.type ===
|
|
750
|
+
if (node.source !== null && node.source.type === AST_NODE_TYPES5.Literal) {
|
|
334
751
|
checkSource(node.source, node.exportKind === "type");
|
|
335
752
|
}
|
|
336
753
|
},
|
|
337
754
|
// `export * from '…'` re-export laundering.
|
|
338
755
|
ExportAllDeclaration(node) {
|
|
339
|
-
if (node.source.type ===
|
|
756
|
+
if (node.source.type === AST_NODE_TYPES5.Literal) {
|
|
340
757
|
checkSource(node.source, node.exportKind === "type");
|
|
341
758
|
}
|
|
342
759
|
}
|
|
@@ -344,16 +761,593 @@ var noCrossFeatureImportsRule = createRule({
|
|
|
344
761
|
}
|
|
345
762
|
});
|
|
346
763
|
|
|
764
|
+
// src/semantic-module/classify.ts
|
|
765
|
+
import { AST_NODE_TYPES as AST_NODE_TYPES8 } from "@typescript-eslint/utils";
|
|
766
|
+
|
|
767
|
+
// src/semantic-module/ast.ts
|
|
768
|
+
import { AST_NODE_TYPES as AST_NODE_TYPES6 } from "@typescript-eslint/utils";
|
|
769
|
+
function getDeclarationName(node) {
|
|
770
|
+
if ("id" in node) {
|
|
771
|
+
const id = node.id;
|
|
772
|
+
if (isIdentifier(id)) {
|
|
773
|
+
return id.name;
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
return void 0;
|
|
777
|
+
}
|
|
778
|
+
function getVariableDeclaratorName(declarator) {
|
|
779
|
+
return declarator.id.type === AST_NODE_TYPES6.Identifier ? declarator.id.name : void 0;
|
|
780
|
+
}
|
|
781
|
+
function isWrapperExpression(expression) {
|
|
782
|
+
return expression.type === AST_NODE_TYPES6.TSAsExpression || expression.type === AST_NODE_TYPES6.TSTypeAssertion || expression.type === AST_NODE_TYPES6.TSNonNullExpression || expression.type === AST_NODE_TYPES6.TSSatisfiesExpression || expression.type === AST_NODE_TYPES6.TSInstantiationExpression;
|
|
783
|
+
}
|
|
784
|
+
function unwrapExpression(expression) {
|
|
785
|
+
let current = expression;
|
|
786
|
+
while (isWrapperExpression(current)) {
|
|
787
|
+
current = current.expression;
|
|
788
|
+
}
|
|
789
|
+
return current;
|
|
790
|
+
}
|
|
791
|
+
function isAmbientDeclaration(node) {
|
|
792
|
+
if ("declare" in node && node.declare === true) {
|
|
793
|
+
return true;
|
|
794
|
+
}
|
|
795
|
+
return node.type === AST_NODE_TYPES6.TSModuleDeclaration && node.kind === "global";
|
|
796
|
+
}
|
|
797
|
+
function functionReturnsJsx(node) {
|
|
798
|
+
if (node.type === AST_NODE_TYPES6.ArrowFunctionExpression) {
|
|
799
|
+
if (!node.expression && node.body.type === AST_NODE_TYPES6.BlockStatement) {
|
|
800
|
+
return blockReturnsJsx(node.body);
|
|
801
|
+
}
|
|
802
|
+
return containsJsx(node.body);
|
|
803
|
+
}
|
|
804
|
+
return blockReturnsJsx(node.body);
|
|
805
|
+
}
|
|
806
|
+
function blockReturnsJsx(block) {
|
|
807
|
+
return containsNode(block, (node) => {
|
|
808
|
+
if (node.type !== AST_NODE_TYPES6.ReturnStatement || !node.argument) {
|
|
809
|
+
return false;
|
|
810
|
+
}
|
|
811
|
+
return containsJsx(node.argument);
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
function containsJsx(node) {
|
|
815
|
+
return containsNode(
|
|
816
|
+
node,
|
|
817
|
+
(candidate) => candidate.type === AST_NODE_TYPES6.JSXElement || candidate.type === AST_NODE_TYPES6.JSXFragment
|
|
818
|
+
);
|
|
819
|
+
}
|
|
820
|
+
var SKIPPED_KEYS = /* @__PURE__ */ new Set(["parent", "loc", "range", "tokens", "comments"]);
|
|
821
|
+
function containsNode(root, predicate) {
|
|
822
|
+
const stack = [root];
|
|
823
|
+
while (stack.length > 0) {
|
|
824
|
+
const current = stack.pop();
|
|
825
|
+
if (!current) {
|
|
826
|
+
continue;
|
|
827
|
+
}
|
|
828
|
+
if (predicate(current)) {
|
|
829
|
+
return true;
|
|
830
|
+
}
|
|
831
|
+
for (const [key, value] of Object.entries(current)) {
|
|
832
|
+
if (SKIPPED_KEYS.has(key)) {
|
|
833
|
+
continue;
|
|
834
|
+
}
|
|
835
|
+
if (Array.isArray(value)) {
|
|
836
|
+
for (const item of value) {
|
|
837
|
+
if (isNodeLike(item)) {
|
|
838
|
+
stack.push(item);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
continue;
|
|
842
|
+
}
|
|
843
|
+
if (isNodeLike(value)) {
|
|
844
|
+
stack.push(value);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
return false;
|
|
849
|
+
}
|
|
850
|
+
function isNodeLike(value) {
|
|
851
|
+
return typeof value === "object" && value !== null && "type" in value && typeof value.type === "string";
|
|
852
|
+
}
|
|
853
|
+
function isIdentifier(value) {
|
|
854
|
+
return isNodeLike(value) && value.type === AST_NODE_TYPES6.Identifier;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// src/semantic-module/classifiers.ts
|
|
858
|
+
import { AST_NODE_TYPES as AST_NODE_TYPES7 } from "@typescript-eslint/utils";
|
|
859
|
+
function isHookName(name, options) {
|
|
860
|
+
if (!options.hookDetection.enabled || !name) {
|
|
861
|
+
return false;
|
|
862
|
+
}
|
|
863
|
+
return options.hookDetection.namePattern.test(name);
|
|
864
|
+
}
|
|
865
|
+
function isReactComponentName(name) {
|
|
866
|
+
return Boolean(name && /^[A-Z][A-Za-z0-9]*$/u.test(name));
|
|
867
|
+
}
|
|
868
|
+
function isReactComponentFunction(node, name, options, isDefaultExport = false) {
|
|
869
|
+
if (!options.reactComponentDetection.enabled) {
|
|
870
|
+
return false;
|
|
871
|
+
}
|
|
872
|
+
if (!isReactComponentName(name) && !isDefaultExport) {
|
|
873
|
+
return false;
|
|
874
|
+
}
|
|
875
|
+
if (node.returnType && typeReferencesJsxValue(node.returnType.typeAnnotation)) {
|
|
876
|
+
return true;
|
|
877
|
+
}
|
|
878
|
+
return functionReturnsJsx(node);
|
|
879
|
+
}
|
|
880
|
+
function isReactComponentVariable(declarator, options) {
|
|
881
|
+
if (!options.reactComponentDetection.enabled) {
|
|
882
|
+
return false;
|
|
883
|
+
}
|
|
884
|
+
const name = getVariableDeclaratorName(declarator);
|
|
885
|
+
if (!isReactComponentName(name)) {
|
|
886
|
+
return false;
|
|
887
|
+
}
|
|
888
|
+
if (declarator.id.type === AST_NODE_TYPES7.Identifier && declarator.id.typeAnnotation && typeReferencesReactComponent(declarator.id.typeAnnotation.typeAnnotation)) {
|
|
889
|
+
return true;
|
|
890
|
+
}
|
|
891
|
+
if (!declarator.init) {
|
|
892
|
+
return false;
|
|
893
|
+
}
|
|
894
|
+
if (declarator.init.type === AST_NODE_TYPES7.ArrowFunctionExpression || declarator.init.type === AST_NODE_TYPES7.FunctionExpression) {
|
|
895
|
+
return isReactComponentFunction(declarator.init, name, options);
|
|
896
|
+
}
|
|
897
|
+
return containsJsx(declarator.init);
|
|
898
|
+
}
|
|
899
|
+
var REACT_COMPONENT_TYPES = /* @__PURE__ */ new Set([
|
|
900
|
+
"FC",
|
|
901
|
+
"FunctionComponent",
|
|
902
|
+
"React.FC",
|
|
903
|
+
"React.FunctionComponent"
|
|
904
|
+
]);
|
|
905
|
+
var JSX_VALUE_TYPES = /* @__PURE__ */ new Set([
|
|
906
|
+
"JSX.Element",
|
|
907
|
+
"React.ReactElement",
|
|
908
|
+
"React.ReactNode"
|
|
909
|
+
]);
|
|
910
|
+
function typeReferencesReactComponent(node) {
|
|
911
|
+
return containsNode(
|
|
912
|
+
node,
|
|
913
|
+
(candidate) => candidate.type === AST_NODE_TYPES7.TSTypeReference && REACT_COMPONENT_TYPES.has(entityNameToString(candidate.typeName))
|
|
914
|
+
);
|
|
915
|
+
}
|
|
916
|
+
function typeReferencesJsxValue(node) {
|
|
917
|
+
return containsNode(
|
|
918
|
+
node,
|
|
919
|
+
(candidate) => candidate.type === AST_NODE_TYPES7.TSTypeReference && JSX_VALUE_TYPES.has(entityNameToString(candidate.typeName))
|
|
920
|
+
);
|
|
921
|
+
}
|
|
922
|
+
function entityNameToString(entityName) {
|
|
923
|
+
if (entityName.type === AST_NODE_TYPES7.Identifier) {
|
|
924
|
+
return entityName.name;
|
|
925
|
+
}
|
|
926
|
+
if (entityName.type === AST_NODE_TYPES7.TSQualifiedName) {
|
|
927
|
+
return `${entityNameToString(entityName.left)}.${entityName.right.name}`;
|
|
928
|
+
}
|
|
929
|
+
return "this";
|
|
930
|
+
}
|
|
931
|
+
var SCHEMA_LIBRARY_MODULES = {
|
|
932
|
+
zod: ["zod"],
|
|
933
|
+
yup: ["yup"],
|
|
934
|
+
valibot: ["valibot"]
|
|
935
|
+
};
|
|
936
|
+
var SCHEMA_BUILDER_NAMES = /* @__PURE__ */ new Set([
|
|
937
|
+
"array",
|
|
938
|
+
"boolean",
|
|
939
|
+
"date",
|
|
940
|
+
"enum",
|
|
941
|
+
"literal",
|
|
942
|
+
"number",
|
|
943
|
+
"object",
|
|
944
|
+
"record",
|
|
945
|
+
"string",
|
|
946
|
+
"tuple",
|
|
947
|
+
"union"
|
|
948
|
+
]);
|
|
949
|
+
function collectSchemaImportContext(program, options) {
|
|
950
|
+
const namespaceIdentifiers = /* @__PURE__ */ new Set();
|
|
951
|
+
const builderIdentifiers = /* @__PURE__ */ new Set();
|
|
952
|
+
const enabledModules = new Set(
|
|
953
|
+
options.schemaLibraries.flatMap((library) => SCHEMA_LIBRARY_MODULES[library])
|
|
954
|
+
);
|
|
955
|
+
for (const statement of program.body) {
|
|
956
|
+
if (statement.type !== AST_NODE_TYPES7.ImportDeclaration || statement.importKind === "type" || !enabledModules.has(String(statement.source.value))) {
|
|
957
|
+
continue;
|
|
958
|
+
}
|
|
959
|
+
for (const specifier of statement.specifiers) {
|
|
960
|
+
if (specifier.type === AST_NODE_TYPES7.ImportNamespaceSpecifier || specifier.type === AST_NODE_TYPES7.ImportDefaultSpecifier) {
|
|
961
|
+
namespaceIdentifiers.add(specifier.local.name);
|
|
962
|
+
continue;
|
|
963
|
+
}
|
|
964
|
+
if (specifier.importKind === "type") {
|
|
965
|
+
continue;
|
|
966
|
+
}
|
|
967
|
+
const importedName = specifier.imported.type === AST_NODE_TYPES7.Identifier ? specifier.imported.name : String(specifier.imported.value);
|
|
968
|
+
if (importedName === "z") {
|
|
969
|
+
namespaceIdentifiers.add(specifier.local.name);
|
|
970
|
+
}
|
|
971
|
+
if (SCHEMA_BUILDER_NAMES.has(importedName)) {
|
|
972
|
+
builderIdentifiers.add(specifier.local.name);
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
return { namespaceIdentifiers, builderIdentifiers };
|
|
977
|
+
}
|
|
978
|
+
function isSchemaExpression(expression, context) {
|
|
979
|
+
const unwrapped = unwrapExpression(expression);
|
|
980
|
+
if (unwrapped.type !== AST_NODE_TYPES7.CallExpression) {
|
|
981
|
+
return false;
|
|
982
|
+
}
|
|
983
|
+
const rootName = expressionRootIdentifier(unwrapped.callee);
|
|
984
|
+
if (!rootName) {
|
|
985
|
+
return false;
|
|
986
|
+
}
|
|
987
|
+
return context.namespaceIdentifiers.has(rootName) || context.builderIdentifiers.has(rootName);
|
|
988
|
+
}
|
|
989
|
+
function expressionRootIdentifier(node) {
|
|
990
|
+
switch (node.type) {
|
|
991
|
+
case AST_NODE_TYPES7.Identifier:
|
|
992
|
+
return node.name;
|
|
993
|
+
case AST_NODE_TYPES7.MemberExpression:
|
|
994
|
+
return expressionRootIdentifier(node.object);
|
|
995
|
+
case AST_NODE_TYPES7.CallExpression:
|
|
996
|
+
return expressionRootIdentifier(node.callee);
|
|
997
|
+
case AST_NODE_TYPES7.ChainExpression:
|
|
998
|
+
return expressionRootIdentifier(node.expression);
|
|
999
|
+
default:
|
|
1000
|
+
return null;
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
function getConstantReason(expression) {
|
|
1004
|
+
if (!expression) {
|
|
1005
|
+
return "top-level variable declaration without initializer";
|
|
1006
|
+
}
|
|
1007
|
+
switch (unwrapExpression(expression).type) {
|
|
1008
|
+
case AST_NODE_TYPES7.Literal:
|
|
1009
|
+
return "literal runtime value";
|
|
1010
|
+
case AST_NODE_TYPES7.ObjectExpression:
|
|
1011
|
+
return "object literal runtime value";
|
|
1012
|
+
case AST_NODE_TYPES7.ArrayExpression:
|
|
1013
|
+
return "array literal runtime value";
|
|
1014
|
+
case AST_NODE_TYPES7.TemplateLiteral:
|
|
1015
|
+
return "template literal runtime value";
|
|
1016
|
+
case AST_NODE_TYPES7.CallExpression:
|
|
1017
|
+
return "computed top-level runtime value";
|
|
1018
|
+
default:
|
|
1019
|
+
return "top-level runtime value";
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
// src/semantic-module/options.ts
|
|
1024
|
+
var SEMANTIC_CATEGORIES = [
|
|
1025
|
+
"type",
|
|
1026
|
+
"constant",
|
|
1027
|
+
"function",
|
|
1028
|
+
"class",
|
|
1029
|
+
"react-component",
|
|
1030
|
+
"hook",
|
|
1031
|
+
"schema",
|
|
1032
|
+
"enum"
|
|
1033
|
+
];
|
|
1034
|
+
var SCHEMA_LIBRARIES = ["zod", "yup", "valibot"];
|
|
1035
|
+
function sortCategories(categories) {
|
|
1036
|
+
const categorySet = new Set(categories);
|
|
1037
|
+
return SEMANTIC_CATEGORIES.filter((category) => categorySet.has(category));
|
|
1038
|
+
}
|
|
1039
|
+
var DEFAULT_HOOK_NAME_PATTERN = "^use[A-Z0-9].*";
|
|
1040
|
+
var DEFAULT_OPTIONS = {
|
|
1041
|
+
allow: [],
|
|
1042
|
+
enumCategory: "enum",
|
|
1043
|
+
debug: false,
|
|
1044
|
+
ignoreAmbientDeclarations: false,
|
|
1045
|
+
ignorePrivateDeclarations: true,
|
|
1046
|
+
schemaLibraries: SCHEMA_LIBRARIES,
|
|
1047
|
+
reactComponentDetection: { enabled: true },
|
|
1048
|
+
hookDetection: { enabled: true, namePattern: DEFAULT_HOOK_NAME_PATTERN }
|
|
1049
|
+
};
|
|
1050
|
+
function normalizeOptions(options) {
|
|
1051
|
+
return {
|
|
1052
|
+
allow: options.allow ?? DEFAULT_OPTIONS.allow,
|
|
1053
|
+
enumCategory: options.enumCategory ?? DEFAULT_OPTIONS.enumCategory,
|
|
1054
|
+
debug: options.debug ?? DEFAULT_OPTIONS.debug,
|
|
1055
|
+
ignoreAmbientDeclarations: options.ignoreAmbientDeclarations ?? DEFAULT_OPTIONS.ignoreAmbientDeclarations,
|
|
1056
|
+
ignorePrivateDeclarations: options.ignorePrivateDeclarations ?? DEFAULT_OPTIONS.ignorePrivateDeclarations,
|
|
1057
|
+
schemaLibraries: options.schemaLibraries ?? DEFAULT_OPTIONS.schemaLibraries,
|
|
1058
|
+
reactComponentDetection: { enabled: options.reactComponentDetection?.enabled ?? true },
|
|
1059
|
+
hookDetection: {
|
|
1060
|
+
enabled: options.hookDetection?.enabled ?? true,
|
|
1061
|
+
namePattern: compilePattern(options.hookDetection?.namePattern ?? DEFAULT_HOOK_NAME_PATTERN)
|
|
1062
|
+
}
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
1065
|
+
function compilePattern(pattern) {
|
|
1066
|
+
try {
|
|
1067
|
+
return new RegExp(pattern);
|
|
1068
|
+
} catch (error) {
|
|
1069
|
+
throw new Error(
|
|
1070
|
+
`single-semantic-module: hookDetection.namePattern ${JSON.stringify(pattern)} is not a valid regular expression (${String(error)}).`
|
|
1071
|
+
);
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
function isCategorySetAllowed(categories, allow) {
|
|
1075
|
+
if (categories.size <= 1) {
|
|
1076
|
+
return true;
|
|
1077
|
+
}
|
|
1078
|
+
const detected = [...categories];
|
|
1079
|
+
return allow.some((group) => {
|
|
1080
|
+
const allowed = new Set(group);
|
|
1081
|
+
return detected.every((category) => allowed.has(category));
|
|
1082
|
+
});
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
// src/semantic-module/classify.ts
|
|
1086
|
+
function analyzeSemanticModule(program, rawOptions) {
|
|
1087
|
+
const options = normalizeOptions(rawOptions);
|
|
1088
|
+
const context = {
|
|
1089
|
+
options,
|
|
1090
|
+
schemaImports: collectSchemaImportContext(program, options),
|
|
1091
|
+
exportedNames: collectLocallyExportedNames(program)
|
|
1092
|
+
};
|
|
1093
|
+
const classifications = program.body.flatMap(
|
|
1094
|
+
(statement) => classifyTopLevelStatement(statement, context)
|
|
1095
|
+
);
|
|
1096
|
+
return {
|
|
1097
|
+
categories: new Set(classifications.map((classification2) => classification2.category)),
|
|
1098
|
+
classifications,
|
|
1099
|
+
options
|
|
1100
|
+
};
|
|
1101
|
+
}
|
|
1102
|
+
function collectLocallyExportedNames(program) {
|
|
1103
|
+
const names = /* @__PURE__ */ new Set();
|
|
1104
|
+
for (const statement of program.body) {
|
|
1105
|
+
if (statement.type === AST_NODE_TYPES8.ExportNamedDeclaration && statement.source === null && statement.declaration === null) {
|
|
1106
|
+
for (const specifier of statement.specifiers) {
|
|
1107
|
+
if (specifier.local.type === AST_NODE_TYPES8.Identifier) {
|
|
1108
|
+
names.add(specifier.local.name);
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
} else if (statement.type === AST_NODE_TYPES8.ExportDefaultDeclaration && statement.declaration.type === AST_NODE_TYPES8.Identifier) {
|
|
1112
|
+
names.add(statement.declaration.name);
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
return names;
|
|
1116
|
+
}
|
|
1117
|
+
function classifyTopLevelStatement(statement, context) {
|
|
1118
|
+
switch (statement.type) {
|
|
1119
|
+
case AST_NODE_TYPES8.ImportDeclaration:
|
|
1120
|
+
case AST_NODE_TYPES8.EmptyStatement:
|
|
1121
|
+
case AST_NODE_TYPES8.ExportAllDeclaration:
|
|
1122
|
+
return [];
|
|
1123
|
+
case AST_NODE_TYPES8.ExportNamedDeclaration:
|
|
1124
|
+
return statement.declaration ? classifyDeclarationLike(statement.declaration, context) : [];
|
|
1125
|
+
case AST_NODE_TYPES8.ExportDefaultDeclaration:
|
|
1126
|
+
return classifyDeclarationLike(statement.declaration, { ...context, isDefaultExport: true });
|
|
1127
|
+
default:
|
|
1128
|
+
if (!context.options.ignorePrivateDeclarations) {
|
|
1129
|
+
return classifyDeclarationLike(statement, context);
|
|
1130
|
+
}
|
|
1131
|
+
return classifyExportedByName(statement, context);
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
function classifyExportedByName(statement, context) {
|
|
1135
|
+
if (context.exportedNames.size === 0) {
|
|
1136
|
+
return [];
|
|
1137
|
+
}
|
|
1138
|
+
if (statement.type === AST_NODE_TYPES8.VariableDeclaration) {
|
|
1139
|
+
const exported = statement.declarations.filter((declarator) => {
|
|
1140
|
+
const name2 = getVariableDeclaratorName(declarator);
|
|
1141
|
+
return name2 !== void 0 && context.exportedNames.has(name2);
|
|
1142
|
+
});
|
|
1143
|
+
return exported.map((declarator) => classifyVariableDeclarator(declarator, context));
|
|
1144
|
+
}
|
|
1145
|
+
const name = getDeclarationName(statement);
|
|
1146
|
+
return name !== void 0 && context.exportedNames.has(name) ? classifyDeclarationLike(statement, context) : [];
|
|
1147
|
+
}
|
|
1148
|
+
function classifyDeclarationLike(node, context) {
|
|
1149
|
+
if (isAmbientDeclaration(node)) {
|
|
1150
|
+
return context.options.ignoreAmbientDeclarations ? [] : [classification("type", node, getDeclarationName(node), "ambient declaration")];
|
|
1151
|
+
}
|
|
1152
|
+
switch (node.type) {
|
|
1153
|
+
case AST_NODE_TYPES8.TSInterfaceDeclaration:
|
|
1154
|
+
case AST_NODE_TYPES8.TSTypeAliasDeclaration:
|
|
1155
|
+
case AST_NODE_TYPES8.TSModuleDeclaration:
|
|
1156
|
+
return [
|
|
1157
|
+
classification("type", node, getDeclarationName(node), "TypeScript type-space declaration")
|
|
1158
|
+
];
|
|
1159
|
+
case AST_NODE_TYPES8.TSEnumDeclaration:
|
|
1160
|
+
return [
|
|
1161
|
+
classification(
|
|
1162
|
+
context.options.enumCategory,
|
|
1163
|
+
node,
|
|
1164
|
+
getDeclarationName(node),
|
|
1165
|
+
context.options.enumCategory === "type" ? "enum configured as type" : "enum declaration"
|
|
1166
|
+
)
|
|
1167
|
+
];
|
|
1168
|
+
case AST_NODE_TYPES8.ClassDeclaration:
|
|
1169
|
+
return [classification("class", node, getDeclarationName(node), "class declaration")];
|
|
1170
|
+
case AST_NODE_TYPES8.FunctionDeclaration:
|
|
1171
|
+
return [classifyFunction(node, getDeclarationName(node), context, "function declaration")];
|
|
1172
|
+
case AST_NODE_TYPES8.VariableDeclaration:
|
|
1173
|
+
return node.declarations.map((declarator) => classifyVariableDeclarator(declarator, context));
|
|
1174
|
+
case AST_NODE_TYPES8.ArrowFunctionExpression:
|
|
1175
|
+
case AST_NODE_TYPES8.FunctionExpression:
|
|
1176
|
+
return [classifyFunction(node, void 0, context, "function expression")];
|
|
1177
|
+
case AST_NODE_TYPES8.ClassExpression:
|
|
1178
|
+
return [classification("class", node, getDeclarationName(node), "class expression")];
|
|
1179
|
+
case AST_NODE_TYPES8.CallExpression:
|
|
1180
|
+
case AST_NODE_TYPES8.ArrayExpression:
|
|
1181
|
+
case AST_NODE_TYPES8.ObjectExpression:
|
|
1182
|
+
case AST_NODE_TYPES8.Literal:
|
|
1183
|
+
case AST_NODE_TYPES8.TemplateLiteral:
|
|
1184
|
+
return [classifyDefaultExpression(node, context)];
|
|
1185
|
+
case AST_NODE_TYPES8.TSDeclareFunction:
|
|
1186
|
+
return [
|
|
1187
|
+
classification("function", node, getDeclarationName(node), "function overload signature")
|
|
1188
|
+
];
|
|
1189
|
+
default:
|
|
1190
|
+
return [];
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
function classifyFunction(node, name, context, reason) {
|
|
1194
|
+
if (isHookName(name, context.options)) {
|
|
1195
|
+
return classification("hook", node, name, "function name matches hook pattern");
|
|
1196
|
+
}
|
|
1197
|
+
if (isReactComponentFunction(node, name, context.options, context.isDefaultExport === true)) {
|
|
1198
|
+
return classification(
|
|
1199
|
+
"react-component",
|
|
1200
|
+
node,
|
|
1201
|
+
name,
|
|
1202
|
+
`${reason === "function declaration" ? "function component" : "function expression"} returns JSX or React element`
|
|
1203
|
+
);
|
|
1204
|
+
}
|
|
1205
|
+
return classification("function", node, name, reason);
|
|
1206
|
+
}
|
|
1207
|
+
function classifyVariableDeclarator(declarator, context) {
|
|
1208
|
+
const name = getVariableDeclaratorName(declarator);
|
|
1209
|
+
const init = declarator.init ? unwrapExpression(declarator.init) : null;
|
|
1210
|
+
if (init && isSchemaExpression(init, context.schemaImports)) {
|
|
1211
|
+
return classification("schema", declarator, name, "schema builder expression");
|
|
1212
|
+
}
|
|
1213
|
+
if (isReactComponentVariable(declarator, context.options)) {
|
|
1214
|
+
return classification("react-component", declarator, name, "React component variable");
|
|
1215
|
+
}
|
|
1216
|
+
if (isHookName(name, context.options)) {
|
|
1217
|
+
return classification("hook", declarator, name, "variable name matches hook pattern");
|
|
1218
|
+
}
|
|
1219
|
+
if (init?.type === AST_NODE_TYPES8.ArrowFunctionExpression || init?.type === AST_NODE_TYPES8.FunctionExpression) {
|
|
1220
|
+
return classifyFunction(init, name, context, "function expression");
|
|
1221
|
+
}
|
|
1222
|
+
if (init?.type === AST_NODE_TYPES8.ClassExpression) {
|
|
1223
|
+
return classification("class", declarator, name, "class expression");
|
|
1224
|
+
}
|
|
1225
|
+
return classification("constant", declarator, name, getConstantReason(init));
|
|
1226
|
+
}
|
|
1227
|
+
function classifyDefaultExpression(expression, context) {
|
|
1228
|
+
const unwrapped = unwrapExpression(expression);
|
|
1229
|
+
if (isSchemaExpression(unwrapped, context.schemaImports)) {
|
|
1230
|
+
return classification("schema", expression, void 0, "default schema expression");
|
|
1231
|
+
}
|
|
1232
|
+
if (unwrapped.type === AST_NODE_TYPES8.ArrowFunctionExpression || unwrapped.type === AST_NODE_TYPES8.FunctionExpression) {
|
|
1233
|
+
return classifyFunction(unwrapped, void 0, context, "function expression");
|
|
1234
|
+
}
|
|
1235
|
+
if (unwrapped.type === AST_NODE_TYPES8.ClassExpression) {
|
|
1236
|
+
return classification("class", expression, void 0, "default class expression");
|
|
1237
|
+
}
|
|
1238
|
+
return classification("constant", expression, void 0, getConstantReason(unwrapped));
|
|
1239
|
+
}
|
|
1240
|
+
function classification(category, node, declarationName, reason) {
|
|
1241
|
+
return declarationName ? { category, node, reason, declarationName } : { category, node, reason };
|
|
1242
|
+
}
|
|
1243
|
+
function buildMixedCategoriesMessage(classifications, debug) {
|
|
1244
|
+
const categories = sortCategories(classifications.map((entry) => entry.category));
|
|
1245
|
+
const lines = [
|
|
1246
|
+
"Mixed semantic categories detected in module:",
|
|
1247
|
+
...categories.map((category) => `- ${category}`)
|
|
1248
|
+
];
|
|
1249
|
+
if (debug) {
|
|
1250
|
+
lines.push("", "Detected declarations:");
|
|
1251
|
+
for (const entry of classifications) {
|
|
1252
|
+
lines.push(`- ${entry.category}: ${entry.declarationName ?? "<anonymous>"} (${entry.reason})`);
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
lines.push(
|
|
1256
|
+
"",
|
|
1257
|
+
"A module must contain only one semantic concern.",
|
|
1258
|
+
"Move declarations into separate files/modules."
|
|
1259
|
+
);
|
|
1260
|
+
return lines.join("\n");
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
// src/rules/single-semantic-module.ts
|
|
1264
|
+
var RULE_NAME8 = "single-semantic-module";
|
|
1265
|
+
var optionSchema8 = {
|
|
1266
|
+
type: "object",
|
|
1267
|
+
additionalProperties: false,
|
|
1268
|
+
properties: {
|
|
1269
|
+
allow: {
|
|
1270
|
+
type: "array",
|
|
1271
|
+
items: {
|
|
1272
|
+
type: "array",
|
|
1273
|
+
minItems: 2,
|
|
1274
|
+
uniqueItems: true,
|
|
1275
|
+
items: { type: "string", enum: [...SEMANTIC_CATEGORIES] }
|
|
1276
|
+
}
|
|
1277
|
+
},
|
|
1278
|
+
enumCategory: { type: "string", enum: ["enum", "type"] },
|
|
1279
|
+
debug: { type: "boolean" },
|
|
1280
|
+
ignoreAmbientDeclarations: { type: "boolean" },
|
|
1281
|
+
ignorePrivateDeclarations: { type: "boolean" },
|
|
1282
|
+
schemaLibraries: {
|
|
1283
|
+
type: "array",
|
|
1284
|
+
uniqueItems: true,
|
|
1285
|
+
items: { type: "string", enum: [...SCHEMA_LIBRARIES] }
|
|
1286
|
+
},
|
|
1287
|
+
reactComponentDetection: {
|
|
1288
|
+
type: "object",
|
|
1289
|
+
additionalProperties: false,
|
|
1290
|
+
properties: { enabled: { type: "boolean" } }
|
|
1291
|
+
},
|
|
1292
|
+
hookDetection: {
|
|
1293
|
+
type: "object",
|
|
1294
|
+
additionalProperties: false,
|
|
1295
|
+
properties: {
|
|
1296
|
+
enabled: { type: "boolean" },
|
|
1297
|
+
namePattern: { type: "string" }
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
};
|
|
1302
|
+
var singleSemanticModuleRule = createRule({
|
|
1303
|
+
name: RULE_NAME8,
|
|
1304
|
+
meta: {
|
|
1305
|
+
type: "suggestion",
|
|
1306
|
+
docs: {
|
|
1307
|
+
description: "Require each module to export only one semantic concern (types, constants, functions, classes, components, hooks, schemas or enums)."
|
|
1308
|
+
},
|
|
1309
|
+
schema: [optionSchema8],
|
|
1310
|
+
messages: {
|
|
1311
|
+
mixedSemanticCategories: "{{message}}"
|
|
1312
|
+
}
|
|
1313
|
+
},
|
|
1314
|
+
defaultOptions: [DEFAULT_OPTIONS],
|
|
1315
|
+
create(context, [options]) {
|
|
1316
|
+
return {
|
|
1317
|
+
Program(program) {
|
|
1318
|
+
const analysis = analyzeSemanticModule(program, options);
|
|
1319
|
+
if (isCategorySetAllowed(analysis.categories, analysis.options.allow)) {
|
|
1320
|
+
return;
|
|
1321
|
+
}
|
|
1322
|
+
const [first] = analysis.classifications;
|
|
1323
|
+
const reportNode = analysis.classifications.find((entry) => entry.category !== first?.category)?.node ?? program;
|
|
1324
|
+
context.report({
|
|
1325
|
+
node: reportNode,
|
|
1326
|
+
messageId: "mixedSemanticCategories",
|
|
1327
|
+
data: {
|
|
1328
|
+
message: buildMixedCategoriesMessage(analysis.classifications, analysis.options.debug)
|
|
1329
|
+
}
|
|
1330
|
+
});
|
|
1331
|
+
}
|
|
1332
|
+
};
|
|
1333
|
+
}
|
|
1334
|
+
});
|
|
1335
|
+
|
|
347
1336
|
// src/rules/index.ts
|
|
348
1337
|
var rules = {
|
|
1338
|
+
"barrel-purity": barrelPurityRule,
|
|
1339
|
+
"colocated-test-required": colocatedTestRequiredRule,
|
|
349
1340
|
"component-folder-structure": componentFolderStructureRule,
|
|
1341
|
+
"filename-matches-export": filenameMatchesExportRule,
|
|
350
1342
|
"index-must-reexport-default": indexMustReexportDefaultRule,
|
|
351
|
-
"
|
|
1343
|
+
"max-import-depth": maxImportDepthRule,
|
|
1344
|
+
"no-cross-feature-imports": noCrossFeatureImportsRule,
|
|
1345
|
+
"single-semantic-module": singleSemanticModuleRule
|
|
352
1346
|
};
|
|
353
1347
|
|
|
354
1348
|
// src/index.ts
|
|
355
1349
|
var NAMESPACE = "noctcore-architecture";
|
|
356
|
-
var VERSION = "0.
|
|
1350
|
+
var VERSION = "0.3.0";
|
|
357
1351
|
var plugin = {
|
|
358
1352
|
meta: { name: "@noctcore/eslint-plugin-architecture", version: VERSION },
|
|
359
1353
|
rules,
|