@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 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
- - Assures route terminal Fragments contain the appropriate Master Node (e.g. a `.api.ts` file under `_apis`).
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 files within the parent Fragment or its sub-fragments.
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.2.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 matchWithExt = parentContents.files.find(
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 || relPath.startsWith(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 owner = getFractalBranchOwner(importedDir);
1024
- if (owner) {
1025
- const isDescendant = relPath === owner || relPath.startsWith(owner + "/");
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 "${owner}" and its descendants.`,
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: owner2, privateCategoryPath } = info;
1035
- const isPrivateDirectChild = import_path12.default.dirname(relPath) === owner2;
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
- let isFractalBranchAllowed = false;
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 "${owner2}".`,
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
- let isFractalBranchAllowed = false;
1392
- const fbOwner = getFractalBranchOwner(importedDir);
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