@morriconeluca/eslint-plugin-faf 0.2.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 +18 -4
- package/dist/main.cjs +110 -36
- package/dist/main.cjs.map +1 -1
- package/dist/main.js +110 -36
- package/dist/main.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -15,6 +15,15 @@ This plugin is designed for complex TypeScript/JavaScript codebases to ensure **
|
|
|
15
15
|
|
|
16
16
|
---
|
|
17
17
|
|
|
18
|
+
## Companion Tooling
|
|
19
|
+
|
|
20
|
+
`eslint-plugin-faf` does not detect dependency cycles. FAF requires the dependency graph to be a Directed Acyclic Graph, but enforcing that is intentionally left to dedicated tools, configured separately in your project:
|
|
21
|
+
|
|
22
|
+
- [`eslint-plugin-import-x`](https://github.com/un-ts/eslint-plugin-import-x)'s `import-x/no-cycle` rule.
|
|
23
|
+
- [`dependency-cruiser`](https://github.com/sverweij/dependency-cruiser), for cycle detection and other custom dependency fitness functions.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
18
27
|
## Installation
|
|
19
28
|
|
|
20
29
|
Install the plugin along with its peer dependencies:
|
|
@@ -213,8 +222,12 @@ export default {
|
|
|
213
222
|
{
|
|
214
223
|
// Subtree where intermediate folders are classified as organizational Layers,
|
|
215
224
|
// and the terminal Fragment must contain a Master Node with the specified role (e.g. '.api.ts')
|
|
225
|
+
// and a name following the "<method>-<object>" pattern (e.g. 'get-todo')
|
|
216
226
|
paths: ['src/_src@shared/_network/_apis'],
|
|
217
227
|
role: 'api',
|
|
228
|
+
// Optional: restricts which HTTP methods are valid as the Fragment name prefix.
|
|
229
|
+
// Defaults to all standard HTTP methods (get, post, put, patch, delete, head, options, connect, trace).
|
|
230
|
+
httpMethods: ['get', 'post', 'put', 'patch', 'delete'],
|
|
218
231
|
},
|
|
219
232
|
],
|
|
220
233
|
},
|
|
@@ -264,8 +277,9 @@ Verifies folders, files, and roles comply with FAF taxonomy:
|
|
|
264
277
|
|
|
265
278
|
- Enforces `kebab-case` naming for all folders and files in the logical domain.
|
|
266
279
|
- Validates that internal Fragment files share their parent Fragment's name prefix (e.g. files in folder `button/` must be named `button.<role>.<ext>`).
|
|
267
|
-
- Enforces role suffix validation on Fragment Nodes and Logical Nodes.
|
|
268
|
-
-
|
|
280
|
+
- Enforces role suffix validation on Fragment Nodes and Logical Nodes, restricted to a single Role segment per file name.
|
|
281
|
+
- Requires every Fragment to contain at least one Fragment Node (its Master Node), and forbids two Fragment Nodes from sharing the same Role.
|
|
282
|
+
- Assures route terminal Fragments contain the appropriate Master Node (e.g. a `.api.ts` file under `_apis`) and that the Fragment name follows the `<method>-<object>` pattern (e.g. `get-todo`), with the HTTP method restricted to `routeHierarchies[].httpMethods` or, by default, all standard HTTP methods.
|
|
269
283
|
- Enforces FAF logical domain containment rules:
|
|
270
284
|
- Root Fragments can only reside directly in the Root Container or another Root Fragment.
|
|
271
285
|
- Layers cannot reside inside Fragments.
|
|
@@ -289,7 +303,7 @@ Applies **Fragment Encapsulation**:
|
|
|
289
303
|
|
|
290
304
|
Applies **Private Category Encapsulation**:
|
|
291
305
|
|
|
292
|
-
- Restricts consumption of elements inside a Private Category (e.g. `_components/` nested inside a Fragment) to
|
|
306
|
+
- Restricts consumption of elements inside a Private Category (e.g. `_components/` nested inside a Fragment) to the direct child Fragment Nodes of the owning Fragment/Root Fragment, and to sibling nodes within the same immediate sub-domain of the Private Category. Access does not extend to other sub-domains, nor to a Fractal Branch nested inside the Private Category (governed instead by its own encapsulation rule).
|
|
293
307
|
|
|
294
308
|
### 5. `faf/no-fractal-branch-leak`
|
|
295
309
|
|
|
@@ -304,7 +318,7 @@ Applies **Peer Isolation**:
|
|
|
304
318
|
- Prevents sibling files and folders from importing each other unless an explicit hierarchy is configured:
|
|
305
319
|
- **Inside a Fragment**: Flow is governed by the `roles` array order.
|
|
306
320
|
- **Between folders**: Flow is governed by `localHorizontalHierarchies` or `globalHorizontalHierarchies`. If undefined, the linter falls back to mapped roles. Otherwise, peer isolation is strictly enforced.
|
|
307
|
-
- **Root Nodes**: Flow is governed by the defined index order in `rootFragments.rootNodes`.
|
|
321
|
+
- **Root Nodes**: Flow is governed exclusively by the defined index order in `rootFragments.rootNodes`. Root Nodes are exempt from the Role naming convention, so relationships between them never fall back to the `roles` scale: any relationship without a matching `rootNodes` entry is denied.
|
|
308
322
|
|
|
309
323
|
### 7. `faf/category-mutually-exclusive`
|
|
310
324
|
|
package/dist/main.cjs
CHANGED
|
@@ -39,7 +39,7 @@ module.exports = __toCommonJS(main_exports);
|
|
|
39
39
|
// package.json
|
|
40
40
|
var package_default = {
|
|
41
41
|
name: "@morriconeluca/eslint-plugin-faf",
|
|
42
|
-
version: "0.
|
|
42
|
+
version: "0.3.0",
|
|
43
43
|
description: "An ESLint plugin for FAF projects.",
|
|
44
44
|
repository: {
|
|
45
45
|
type: "git",
|
|
@@ -480,6 +480,17 @@ function getProjectRoot() {
|
|
|
480
480
|
|
|
481
481
|
// src/_rules/naming-conventions/naming-conventions.rule.ts
|
|
482
482
|
var KEBAB_CASE_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
483
|
+
var DEFAULT_HTTP_METHODS = [
|
|
484
|
+
"connect",
|
|
485
|
+
"delete",
|
|
486
|
+
"get",
|
|
487
|
+
"head",
|
|
488
|
+
"options",
|
|
489
|
+
"patch",
|
|
490
|
+
"post",
|
|
491
|
+
"put",
|
|
492
|
+
"trace"
|
|
493
|
+
];
|
|
483
494
|
var rule3 = {
|
|
484
495
|
create(context) {
|
|
485
496
|
return {
|
|
@@ -643,15 +654,6 @@ var rule3 = {
|
|
|
643
654
|
if (parentType === "invalid-fragment") {
|
|
644
655
|
return;
|
|
645
656
|
}
|
|
646
|
-
if (fileName === "index.ts" || fileName === "index.js") {
|
|
647
|
-
if (parentType !== "fragment") {
|
|
648
|
-
context.report({
|
|
649
|
-
message: `Access Nodes ("index.ts/index.js") are exclusive to Fragments. Found index file directly inside "${folderName}" (classified as ${parentType}).`,
|
|
650
|
-
node
|
|
651
|
-
});
|
|
652
|
-
}
|
|
653
|
-
return;
|
|
654
|
-
}
|
|
655
657
|
const rfConfig = getRootFragmentConfig(relDir, config);
|
|
656
658
|
if (rfConfig) {
|
|
657
659
|
const flatRootNodes = rfConfig.rootNodes.flat();
|
|
@@ -667,11 +669,34 @@ var rule3 = {
|
|
|
667
669
|
return;
|
|
668
670
|
}
|
|
669
671
|
}
|
|
672
|
+
if (fileName === "index.ts" || fileName === "index.js") {
|
|
673
|
+
if (parentType !== "fragment") {
|
|
674
|
+
context.report({
|
|
675
|
+
message: `Access Nodes ("index.ts/index.js") are exclusive to Fragments. Found index file directly inside "${folderName}" (classified as ${parentType}).`,
|
|
676
|
+
node
|
|
677
|
+
});
|
|
678
|
+
} else {
|
|
679
|
+
const contents = readDirCached(relDir);
|
|
680
|
+
const hasFragmentNode = contents.files.some(
|
|
681
|
+
(f) => f !== "index.ts" && f !== "index.js" && f !== "README.md" && f !== "package.json" && f !== "tsconfig.json"
|
|
682
|
+
);
|
|
683
|
+
if (!hasFragmentNode) {
|
|
684
|
+
context.report({
|
|
685
|
+
message: `Fragment "${folderName}" has no Master Node. Every Fragment must contain at least one Fragment Node establishing its Role.`,
|
|
686
|
+
node
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
670
692
|
if (fileName === "README.md" || fileName === "package.json" || fileName === "tsconfig.json") {
|
|
671
693
|
return;
|
|
672
694
|
}
|
|
673
695
|
const role = getFileRole(fileName, config);
|
|
674
696
|
const ext = import_path7.default.extname(fileName);
|
|
697
|
+
const allRoleNames = new Set(config.roles.flat());
|
|
698
|
+
const roleSegments = fileName.slice(0, fileName.length - ext.length).split(".").slice(1).filter((part) => allRoleNames.has(part));
|
|
699
|
+
const multiRoleMessage = `File "${fileName}" composes multiple Roles ("${roleSegments.join('", "')}") in its name. A Logical Node may declare only one Role; promote the detail that needs its own Role to an autonomous Sub-Fragment.`;
|
|
675
700
|
if (parentType === "fragment") {
|
|
676
701
|
const matchedRoute = config.routeHierarchies?.find(
|
|
677
702
|
(rh) => rh.paths.some((rp) => relDir === rp || relDir.startsWith(rp + "/"))
|
|
@@ -685,6 +710,16 @@ var rule3 = {
|
|
|
685
710
|
node
|
|
686
711
|
});
|
|
687
712
|
}
|
|
713
|
+
const httpMethods = matchedRoute.httpMethods ?? DEFAULT_HTTP_METHODS;
|
|
714
|
+
const routeNameRegex = new RegExp(
|
|
715
|
+
`^(${httpMethods.join("|")})-[a-z0-9]+(-[a-z0-9]+)*$`
|
|
716
|
+
);
|
|
717
|
+
if (!routeNameRegex.test(folderName)) {
|
|
718
|
+
context.report({
|
|
719
|
+
message: `Route terminal Fragment "${folderName}" must follow the "<method>-<object>" pattern with a lowercase HTTP method (${httpMethods.join(", ")}).`,
|
|
720
|
+
node
|
|
721
|
+
});
|
|
722
|
+
}
|
|
688
723
|
}
|
|
689
724
|
let currentParent = import_path7.default.dirname(relDir).replace(/\\/g, "/");
|
|
690
725
|
let categoryConfig = null;
|
|
@@ -723,6 +758,19 @@ var rule3 = {
|
|
|
723
758
|
message: `File "${fileName}" inside Fragment "${folderName}" must have an explicit role suffix (e.g. "${folderName}.style${ext}").`,
|
|
724
759
|
node
|
|
725
760
|
});
|
|
761
|
+
} else if (roleSegments.length > 1) {
|
|
762
|
+
context.report({ message: multiRoleMessage, node });
|
|
763
|
+
} else {
|
|
764
|
+
const contents = readDirCached(relDir);
|
|
765
|
+
const duplicate = contents.files.find(
|
|
766
|
+
(f) => f !== fileName && f !== "index.ts" && f !== "index.js" && getFileRole(f, config) === role
|
|
767
|
+
);
|
|
768
|
+
if (duplicate) {
|
|
769
|
+
context.report({
|
|
770
|
+
message: `Fragment Node "${fileName}" shares Role "${role}" with sibling "${duplicate}". Each Fragment Node must have a unique Role within its Fragment.`,
|
|
771
|
+
node
|
|
772
|
+
});
|
|
773
|
+
}
|
|
726
774
|
}
|
|
727
775
|
}
|
|
728
776
|
if (parentType === "category") {
|
|
@@ -753,6 +801,8 @@ var rule3 = {
|
|
|
753
801
|
message: `Role "${role}" for file "${fileName}" does not match the expected Category role "${catConfig.role}".`,
|
|
754
802
|
node
|
|
755
803
|
});
|
|
804
|
+
} else if (roleSegments.length > 1) {
|
|
805
|
+
context.report({ message: multiRoleMessage, node });
|
|
756
806
|
}
|
|
757
807
|
} else {
|
|
758
808
|
if (role && role !== catConfig.role) {
|
|
@@ -760,6 +810,8 @@ var rule3 = {
|
|
|
760
810
|
message: `Role "${role}" for asset "${fileName}" does not match the expected Category role "${catConfig.role}".`,
|
|
761
811
|
node
|
|
762
812
|
});
|
|
813
|
+
} else if (role && roleSegments.length > 1) {
|
|
814
|
+
context.report({ message: multiRoleMessage, node });
|
|
763
815
|
}
|
|
764
816
|
}
|
|
765
817
|
}
|
|
@@ -829,6 +881,16 @@ function _resolveImportPath(importPath, currentFileRelative, settings) {
|
|
|
829
881
|
}
|
|
830
882
|
return finalizeResolvedPath(relResolved);
|
|
831
883
|
}
|
|
884
|
+
var SCRIPT_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
885
|
+
"cjs",
|
|
886
|
+
"cts",
|
|
887
|
+
"js",
|
|
888
|
+
"jsx",
|
|
889
|
+
"mjs",
|
|
890
|
+
"mts",
|
|
891
|
+
"ts",
|
|
892
|
+
"tsx"
|
|
893
|
+
]);
|
|
832
894
|
function finalizeResolvedPath(relResolved) {
|
|
833
895
|
const resolvedDir = import_path8.default.dirname(relResolved).replace(/\\/g, "/");
|
|
834
896
|
const resolvedBase = import_path8.default.basename(relResolved);
|
|
@@ -842,9 +904,12 @@ function finalizeResolvedPath(relResolved) {
|
|
|
842
904
|
const parentContents = readDirCached(resolvedDir);
|
|
843
905
|
const exactMatch = parentContents.files.find((f) => f === resolvedBase);
|
|
844
906
|
if (!exactMatch) {
|
|
845
|
-
const
|
|
907
|
+
const candidates = parentContents.files.filter(
|
|
846
908
|
(f) => f.startsWith(resolvedBase + ".")
|
|
847
909
|
);
|
|
910
|
+
const matchWithExt = candidates.find(
|
|
911
|
+
(f) => SCRIPT_EXTENSIONS.has(f.slice(resolvedBase.length + 1))
|
|
912
|
+
) ?? candidates[0];
|
|
848
913
|
if (matchWithExt) {
|
|
849
914
|
return (resolvedDir === "." ? "" : resolvedDir + "/") + matchWithExt;
|
|
850
915
|
}
|
|
@@ -913,7 +978,7 @@ var rule4 = {
|
|
|
913
978
|
if (importedFolderType === "fragment") {
|
|
914
979
|
const isAccessNode = importedFile === "index.ts" || importedFile === "index.js";
|
|
915
980
|
if (!isAccessNode) {
|
|
916
|
-
const isInside = relPath === importedDir
|
|
981
|
+
const isInside = import_path9.default.dirname(relPath) === importedDir;
|
|
917
982
|
if (!isInside) {
|
|
918
983
|
context.report({
|
|
919
984
|
message: `Direct import of Fragment internal file "${importedFile}" is forbidden. You must import through its Access Node (index file) in "${importedDir}".`,
|
|
@@ -1020,19 +1085,19 @@ var rule5 = {
|
|
|
1020
1085
|
return;
|
|
1021
1086
|
}
|
|
1022
1087
|
const importedDir = import_path12.default.dirname(resolvedRelPath);
|
|
1023
|
-
const
|
|
1024
|
-
if (
|
|
1025
|
-
const isDescendant = relPath ===
|
|
1088
|
+
const fractalBranchOwner = getFractalBranchOwner(importedDir);
|
|
1089
|
+
if (fractalBranchOwner) {
|
|
1090
|
+
const isDescendant = relPath === fractalBranchOwner || relPath.startsWith(fractalBranchOwner + "/");
|
|
1026
1091
|
if (!isDescendant) {
|
|
1027
1092
|
context.report({
|
|
1028
|
-
message: `Importing from Fractal Branch is forbidden. The imported resource is private to "${
|
|
1093
|
+
message: `Importing from Fractal Branch is forbidden. The imported resource is private to "${fractalBranchOwner}" and its descendants.`,
|
|
1029
1094
|
node
|
|
1030
1095
|
});
|
|
1031
1096
|
} else {
|
|
1032
1097
|
const info = getPrivateCategoryInfo(importedDir, config);
|
|
1033
1098
|
if (info) {
|
|
1034
|
-
const { owner
|
|
1035
|
-
const isPrivateDirectChild = import_path12.default.dirname(relPath) ===
|
|
1099
|
+
const { owner, privateCategoryPath } = info;
|
|
1100
|
+
const isPrivateDirectChild = import_path12.default.dirname(relPath) === owner;
|
|
1036
1101
|
const importerSubDomain = getImmediateSubDomain(
|
|
1037
1102
|
relPath,
|
|
1038
1103
|
privateCategoryPath
|
|
@@ -1042,17 +1107,10 @@ var rule5 = {
|
|
|
1042
1107
|
privateCategoryPath
|
|
1043
1108
|
);
|
|
1044
1109
|
const isInsideSameSubDomain = importerSubDomain !== "" && importerSubDomain === importedSubDomain;
|
|
1045
|
-
|
|
1046
|
-
const fbOwner = getFractalBranchOwner(importedDir);
|
|
1047
|
-
if (fbOwner) {
|
|
1048
|
-
const isFbInsidePrivateCategory = fbOwner === privateCategoryPath || fbOwner.startsWith(privateCategoryPath + "/");
|
|
1049
|
-
if (isFbInsidePrivateCategory) {
|
|
1050
|
-
isFractalBranchAllowed = relPath === fbOwner || relPath.startsWith(fbOwner + "/");
|
|
1051
|
-
}
|
|
1052
|
-
}
|
|
1110
|
+
const isFractalBranchAllowed = fractalBranchOwner === privateCategoryPath || fractalBranchOwner.startsWith(privateCategoryPath + "/");
|
|
1053
1111
|
if (!isPrivateDirectChild && !isInsideSameSubDomain && !isFractalBranchAllowed) {
|
|
1054
1112
|
context.report({
|
|
1055
|
-
message: `Importing from Private Category is forbidden. The imported resource is private to "${
|
|
1113
|
+
message: `Importing from Private Category is forbidden. The imported resource is private to "${owner}".`,
|
|
1056
1114
|
node
|
|
1057
1115
|
});
|
|
1058
1116
|
}
|
|
@@ -1283,6 +1341,10 @@ var rule6 = {
|
|
|
1283
1341
|
return;
|
|
1284
1342
|
}
|
|
1285
1343
|
if (subB === "" && classifyFolder(lca, config) === "fragment") {
|
|
1344
|
+
context.report({
|
|
1345
|
+
message: `Peer separation violation: "${subA}" cannot import directly from the root of Fragment "${lca}". A node nested inside a Private Category or Fractal Branch has no privileged access to its owning Fragment's other direct children; promote the shared resource to a Fractal Branch if the sharing need is genuine.`,
|
|
1346
|
+
node
|
|
1347
|
+
});
|
|
1286
1348
|
return;
|
|
1287
1349
|
}
|
|
1288
1350
|
const rfConfig = getRootFragmentConfig(lca, config);
|
|
@@ -1299,6 +1361,15 @@ var rule6 = {
|
|
|
1299
1361
|
return;
|
|
1300
1362
|
}
|
|
1301
1363
|
}
|
|
1364
|
+
const isSubARootFragment = classifyFolder(import_path13.default.posix.join(lca, subA), config) === "root-fragment";
|
|
1365
|
+
const isSubBRootFragment = classifyFolder(import_path13.default.posix.join(lca, subB), config) === "root-fragment";
|
|
1366
|
+
if (isSubARootFragment || isSubBRootFragment) {
|
|
1367
|
+
context.report({
|
|
1368
|
+
message: `Root Node import violation: "${relPath}" cannot import from "${resolvedRelPath}" because no "rootNodes" relationship is configured under Root Fragment "${lca}". Relationships between Root Nodes must always be explicitly authorized by the architect.`,
|
|
1369
|
+
node
|
|
1370
|
+
});
|
|
1371
|
+
return;
|
|
1372
|
+
}
|
|
1302
1373
|
const { allowed, defined: hasDefinedHierarchy } = resolveHorizontalHierarchy(lca, subA, subB, config);
|
|
1303
1374
|
if (hasDefinedHierarchy) {
|
|
1304
1375
|
if (!allowed) {
|
|
@@ -1378,6 +1449,16 @@ var rule7 = {
|
|
|
1378
1449
|
const info = getPrivateCategoryInfo(importedDir, config);
|
|
1379
1450
|
if (info) {
|
|
1380
1451
|
const { owner, privateCategoryPath } = info;
|
|
1452
|
+
let isFractalBranchInPrivateCategory = false;
|
|
1453
|
+
let isFractalBranchAllowed = false;
|
|
1454
|
+
const fbOwner = getFractalBranchOwner(importedDir);
|
|
1455
|
+
if (fbOwner) {
|
|
1456
|
+
const isFbInsidePrivateCategory = fbOwner === privateCategoryPath || fbOwner.startsWith(privateCategoryPath + "/");
|
|
1457
|
+
if (isFbInsidePrivateCategory) {
|
|
1458
|
+
isFractalBranchInPrivateCategory = true;
|
|
1459
|
+
isFractalBranchAllowed = relPath === fbOwner || relPath.startsWith(fbOwner + "/");
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1381
1462
|
const isDirectChild = import_path14.default.dirname(relPath) === owner;
|
|
1382
1463
|
const importerSubDomain = getImmediateSubDomain(
|
|
1383
1464
|
relPath,
|
|
@@ -1388,15 +1469,8 @@ var rule7 = {
|
|
|
1388
1469
|
privateCategoryPath
|
|
1389
1470
|
);
|
|
1390
1471
|
const isInsideSameSubDomain = importerSubDomain !== "" && importerSubDomain === importedSubDomain;
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
if (fbOwner) {
|
|
1394
|
-
const isFbInsidePrivateCategory = fbOwner === privateCategoryPath || fbOwner.startsWith(privateCategoryPath + "/");
|
|
1395
|
-
if (isFbInsidePrivateCategory) {
|
|
1396
|
-
isFractalBranchAllowed = relPath === fbOwner || relPath.startsWith(fbOwner + "/");
|
|
1397
|
-
}
|
|
1398
|
-
}
|
|
1399
|
-
if (!isDirectChild && !isInsideSameSubDomain && !isFractalBranchAllowed) {
|
|
1472
|
+
const isAllowed = isFractalBranchInPrivateCategory ? isFractalBranchAllowed : isDirectChild || isInsideSameSubDomain;
|
|
1473
|
+
if (!isAllowed) {
|
|
1400
1474
|
context.report({
|
|
1401
1475
|
message: `Importing from Private Category is forbidden. The imported resource is private to "${owner}".`,
|
|
1402
1476
|
node
|