@morriconeluca/eslint-plugin-faf 0.1.1 → 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
@@ -1,4 +1,4 @@
1
- # eslint-plugin-faf
1
+ # @morriconeluca/eslint-plugin-faf
2
2
 
3
3
  An ESLint plugin to deterministically enforce the rules and constraints of the **[Fractal Architecture Framework (FAF)](https://github.com/morriconeluca/fractal-architecture-framework)**.
4
4
 
@@ -15,23 +15,32 @@ 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:
21
30
 
22
31
  ```bash
23
- npm install eslint-plugin-faf --save-dev
32
+ npm install @morriconeluca/eslint-plugin-faf --save-dev
24
33
  # or
25
- yarn add eslint-plugin-faf --dev
34
+ yarn add @morriconeluca/eslint-plugin-faf --dev
26
35
  # or
27
- pnpm add eslint-plugin-faf -D
36
+ pnpm add @morriconeluca/eslint-plugin-faf -D
28
37
  ```
29
38
 
30
39
  ---
31
40
 
32
41
  ## Configuration
33
42
 
34
- `eslint-plugin-faf` is configured via the ESLint flat config file (`eslint.config.ts` or `eslint.config.js`). All architectural settings are loaded from the global `settings.faf` object.
43
+ `@morriconeluca/eslint-plugin-faf` is configured via the ESLint flat config file (`eslint.config.ts` or `eslint.config.js`). All architectural settings are loaded from the global `settings.faf` object.
35
44
 
36
45
  ### 1. Integration in `eslint.config.ts`
37
46
 
@@ -40,7 +49,7 @@ For a clean setup, we recommend defining your FAF taxonomy settings in a separat
40
49
  Since FAF is an **all-or-nothing architectural system**, all rules must be active as blocking errors to prevent architectural drift. The canonical way to integrate it is by extending `fafPlugin.configs.recommended` (which pre-configures all rules as `"error"`) and injecting your settings:
41
50
 
42
51
  ```typescript
43
- import fafPlugin from 'eslint-plugin-faf';
52
+ import fafPlugin from '@morriconeluca/eslint-plugin-faf';
44
53
  import fafSettings from './faf.config';
45
54
 
46
55
  export default [
@@ -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,8 +39,12 @@ module.exports = __toCommonJS(main_exports);
39
39
  // package.json
40
40
  var package_default = {
41
41
  name: "@morriconeluca/eslint-plugin-faf",
42
- version: "0.1.1",
42
+ version: "0.3.0",
43
43
  description: "An ESLint plugin for FAF projects.",
44
+ repository: {
45
+ type: "git",
46
+ url: "git+https://github.com/morriconeluca/eslint-plugin-faf.git"
47
+ },
44
48
  main: "./dist/main.cjs",
45
49
  module: "./dist/main.js",
46
50
  types: "./dist/main.d.ts",
@@ -52,7 +56,8 @@ var package_default = {
52
56
  }
53
57
  },
54
58
  publishConfig: {
55
- access: "public"
59
+ access: "public",
60
+ provenance: true
56
61
  },
57
62
  imports: {
58
63
  "#_rules@shared/*": "./src/_rules/_rules@shared/*",
@@ -353,6 +358,9 @@ var rule = {
353
358
  if (catConfig) {
354
359
  const ext = import_path5.default.extname(fileName);
355
360
  const isAllowedAsset = catConfig.allowedExtensions?.includes(ext) ?? false;
361
+ if (!isAllowedAsset && catConfig.allowedExtensions && catConfig.allowedExtensions.length > 0) {
362
+ return;
363
+ }
356
364
  if (!catConfig.allowSingleFiles && !isAllowedAsset) {
357
365
  context.report({
358
366
  message: `Category "${folderName}" cannot contain file nodes directly. File "${fileName}" must be encapsulated inside a Fragment.`,
@@ -472,6 +480,17 @@ function getProjectRoot() {
472
480
 
473
481
  // src/_rules/naming-conventions/naming-conventions.rule.ts
474
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
+ ];
475
494
  var rule3 = {
476
495
  create(context) {
477
496
  return {
@@ -635,15 +654,6 @@ var rule3 = {
635
654
  if (parentType === "invalid-fragment") {
636
655
  return;
637
656
  }
638
- if (fileName === "index.ts" || fileName === "index.js") {
639
- if (parentType !== "fragment") {
640
- context.report({
641
- message: `Access Nodes ("index.ts/index.js") are exclusive to Fragments. Found index file directly inside "${folderName}" (classified as ${parentType}).`,
642
- node
643
- });
644
- }
645
- return;
646
- }
647
657
  const rfConfig = getRootFragmentConfig(relDir, config);
648
658
  if (rfConfig) {
649
659
  const flatRootNodes = rfConfig.rootNodes.flat();
@@ -659,11 +669,34 @@ var rule3 = {
659
669
  return;
660
670
  }
661
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
+ }
662
692
  if (fileName === "README.md" || fileName === "package.json" || fileName === "tsconfig.json") {
663
693
  return;
664
694
  }
665
695
  const role = getFileRole(fileName, config);
666
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.`;
667
700
  if (parentType === "fragment") {
668
701
  const matchedRoute = config.routeHierarchies?.find(
669
702
  (rh) => rh.paths.some((rp) => relDir === rp || relDir.startsWith(rp + "/"))
@@ -677,6 +710,16 @@ var rule3 = {
677
710
  node
678
711
  });
679
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
+ }
680
723
  }
681
724
  let currentParent = import_path7.default.dirname(relDir).replace(/\\/g, "/");
682
725
  let categoryConfig = null;
@@ -715,6 +758,19 @@ var rule3 = {
715
758
  message: `File "${fileName}" inside Fragment "${folderName}" must have an explicit role suffix (e.g. "${folderName}.style${ext}").`,
716
759
  node
717
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
+ }
718
774
  }
719
775
  }
720
776
  if (parentType === "category") {
@@ -745,6 +801,8 @@ var rule3 = {
745
801
  message: `Role "${role}" for file "${fileName}" does not match the expected Category role "${catConfig.role}".`,
746
802
  node
747
803
  });
804
+ } else if (roleSegments.length > 1) {
805
+ context.report({ message: multiRoleMessage, node });
748
806
  }
749
807
  } else {
750
808
  if (role && role !== catConfig.role) {
@@ -752,6 +810,8 @@ var rule3 = {
752
810
  message: `Role "${role}" for asset "${fileName}" does not match the expected Category role "${catConfig.role}".`,
753
811
  node
754
812
  });
813
+ } else if (role && roleSegments.length > 1) {
814
+ context.report({ message: multiRoleMessage, node });
755
815
  }
756
816
  }
757
817
  }
@@ -821,6 +881,16 @@ function _resolveImportPath(importPath, currentFileRelative, settings) {
821
881
  }
822
882
  return finalizeResolvedPath(relResolved);
823
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
+ ]);
824
894
  function finalizeResolvedPath(relResolved) {
825
895
  const resolvedDir = import_path8.default.dirname(relResolved).replace(/\\/g, "/");
826
896
  const resolvedBase = import_path8.default.basename(relResolved);
@@ -834,9 +904,12 @@ function finalizeResolvedPath(relResolved) {
834
904
  const parentContents = readDirCached(resolvedDir);
835
905
  const exactMatch = parentContents.files.find((f) => f === resolvedBase);
836
906
  if (!exactMatch) {
837
- const matchWithExt = parentContents.files.find(
907
+ const candidates = parentContents.files.filter(
838
908
  (f) => f.startsWith(resolvedBase + ".")
839
909
  );
910
+ const matchWithExt = candidates.find(
911
+ (f) => SCRIPT_EXTENSIONS.has(f.slice(resolvedBase.length + 1))
912
+ ) ?? candidates[0];
840
913
  if (matchWithExt) {
841
914
  return (resolvedDir === "." ? "" : resolvedDir + "/") + matchWithExt;
842
915
  }
@@ -905,7 +978,7 @@ var rule4 = {
905
978
  if (importedFolderType === "fragment") {
906
979
  const isAccessNode = importedFile === "index.ts" || importedFile === "index.js";
907
980
  if (!isAccessNode) {
908
- const isInside = relPath === importedDir || relPath.startsWith(importedDir + "/");
981
+ const isInside = import_path9.default.dirname(relPath) === importedDir;
909
982
  if (!isInside) {
910
983
  context.report({
911
984
  message: `Direct import of Fragment internal file "${importedFile}" is forbidden. You must import through its Access Node (index file) in "${importedDir}".`,
@@ -940,43 +1013,56 @@ var rule4 = {
940
1013
  var no_direct_fragment_import_rule_default = rule4;
941
1014
 
942
1015
  // src/_rules/no-fractal-branch-leak/no-fractal-branch-leak.rule.ts
943
- var import_path11 = __toESM(require("path"), 1);
1016
+ var import_path12 = __toESM(require("path"), 1);
944
1017
 
945
- // src/_rules/_rules@shared/_utils/_systems/get-private-category-owner/get-private-category-owner.util.ts
1018
+ // src/_rules/_rules@shared/_utils/_primitives/get-fractal-branch-owner/get-fractal-branch-owner.util.ts
946
1019
  var import_path10 = __toESM(require("path"), 1);
947
- function getPrivateCategoryOwner(relPath, config) {
1020
+ function getFractalBranchOwner(relPath) {
948
1021
  let current = relPath;
949
1022
  while (current && current !== "." && current !== "/") {
1023
+ const folderName = import_path10.default.basename(current);
1024
+ if (folderName.startsWith("_") && folderName.includes("@shared")) {
1025
+ return import_path10.default.dirname(current).replace(/\\/g, "/");
1026
+ }
950
1027
  const parent = import_path10.default.dirname(current).replace(/\\/g, "/");
951
1028
  if (parent === current) {
952
1029
  break;
953
1030
  }
954
- const currentType = classifyFolder(current, config);
955
- const parentType = classifyFolder(parent, config);
956
- if (currentType === "category" && (parentType === "fragment" || parentType === "root-fragment")) {
957
- return parent;
958
- }
959
1031
  current = parent;
960
1032
  }
961
1033
  return null;
962
1034
  }
963
1035
 
964
- // src/_rules/no-fractal-branch-leak/no-fractal-branch-leak.rule.ts
965
- function getFractalBranchOwner(relPath) {
1036
+ // src/_rules/_rules@shared/_utils/_primitives/get-immediate-sub-domain/get-immediate-sub-domain.util.ts
1037
+ function getImmediateSubDomain(relPath, privateCategoryPath) {
1038
+ if (relPath !== privateCategoryPath && !relPath.startsWith(privateCategoryPath + "/")) {
1039
+ return "";
1040
+ }
1041
+ const relative = relPath.substring(privateCategoryPath.length + 1);
1042
+ const firstSegment = relative.split("/")[0];
1043
+ return firstSegment ? privateCategoryPath + "/" + firstSegment : privateCategoryPath;
1044
+ }
1045
+
1046
+ // src/_rules/_rules@shared/_utils/_systems/get-private-category-info/get-private-category-info.util.ts
1047
+ var import_path11 = __toESM(require("path"), 1);
1048
+ function getPrivateCategoryInfo(relPath, config) {
966
1049
  let current = relPath;
967
1050
  while (current && current !== "." && current !== "/") {
968
- const folderName = import_path11.default.basename(current);
969
- if (folderName.startsWith("_") && folderName.includes("@shared")) {
970
- return import_path11.default.dirname(current).replace(/\\/g, "/");
971
- }
972
1051
  const parent = import_path11.default.dirname(current).replace(/\\/g, "/");
973
1052
  if (parent === current) {
974
1053
  break;
975
1054
  }
1055
+ const currentType = classifyFolder(current, config);
1056
+ const parentType = classifyFolder(parent, config);
1057
+ if (currentType === "category" && (parentType === "fragment" || parentType === "root-fragment")) {
1058
+ return { owner: parent, privateCategoryPath: current };
1059
+ }
976
1060
  current = parent;
977
1061
  }
978
1062
  return null;
979
1063
  }
1064
+
1065
+ // src/_rules/no-fractal-branch-leak/no-fractal-branch-leak.rule.ts
980
1066
  var rule5 = {
981
1067
  create(context) {
982
1068
  const absPath = context.filename;
@@ -998,22 +1084,33 @@ var rule5 = {
998
1084
  if (!resolvedRelPath) {
999
1085
  return;
1000
1086
  }
1001
- const importedDir = import_path11.default.dirname(resolvedRelPath);
1002
- const owner = getFractalBranchOwner(importedDir);
1003
- if (owner) {
1004
- const isDescendant = relPath === owner || relPath.startsWith(owner + "/");
1087
+ const importedDir = import_path12.default.dirname(resolvedRelPath);
1088
+ const fractalBranchOwner = getFractalBranchOwner(importedDir);
1089
+ if (fractalBranchOwner) {
1090
+ const isDescendant = relPath === fractalBranchOwner || relPath.startsWith(fractalBranchOwner + "/");
1005
1091
  if (!isDescendant) {
1006
1092
  context.report({
1007
- 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.`,
1008
1094
  node
1009
1095
  });
1010
1096
  } else {
1011
- const privateOwner = getPrivateCategoryOwner(importedDir, config);
1012
- if (privateOwner) {
1013
- const isPrivateDescendant = relPath === privateOwner || relPath.startsWith(privateOwner + "/");
1014
- if (!isPrivateDescendant) {
1097
+ const info = getPrivateCategoryInfo(importedDir, config);
1098
+ if (info) {
1099
+ const { owner, privateCategoryPath } = info;
1100
+ const isPrivateDirectChild = import_path12.default.dirname(relPath) === owner;
1101
+ const importerSubDomain = getImmediateSubDomain(
1102
+ relPath,
1103
+ privateCategoryPath
1104
+ );
1105
+ const importedSubDomain = getImmediateSubDomain(
1106
+ resolvedRelPath,
1107
+ privateCategoryPath
1108
+ );
1109
+ const isInsideSameSubDomain = importerSubDomain !== "" && importerSubDomain === importedSubDomain;
1110
+ const isFractalBranchAllowed = fractalBranchOwner === privateCategoryPath || fractalBranchOwner.startsWith(privateCategoryPath + "/");
1111
+ if (!isPrivateDirectChild && !isInsideSameSubDomain && !isFractalBranchAllowed) {
1015
1112
  context.report({
1016
- message: `Importing from Private Category is forbidden. The imported resource is private to "${privateOwner}".`,
1113
+ message: `Importing from Private Category is forbidden. The imported resource is private to "${owner}".`,
1017
1114
  node
1018
1115
  });
1019
1116
  }
@@ -1046,7 +1143,7 @@ var rule5 = {
1046
1143
  var no_fractal_branch_leak_rule_default = rule5;
1047
1144
 
1048
1145
  // src/_rules/no-peer-dependency/no-peer-dependency.rule.ts
1049
- var import_path12 = __toESM(require("path"), 1);
1146
+ var import_path13 = __toESM(require("path"), 1);
1050
1147
 
1051
1148
  // src/_rules/_rules@shared/_utils/_primitives/find-tree-config-including-excluded/find-tree-config-including-excluded.util.ts
1052
1149
  function findTreeConfigIncludingExcluded(relPath, settings) {
@@ -1124,7 +1221,7 @@ function resolveHorizontalHierarchy(lcaPath, subA, subB, config) {
1124
1221
 
1125
1222
  // src/_rules/no-peer-dependency/no-peer-dependency.rule.ts
1126
1223
  function getDirectoryRole(dirPath, config) {
1127
- const folderName = import_path12.default.basename(dirPath);
1224
+ const folderName = import_path13.default.basename(dirPath);
1128
1225
  if (folderName.startsWith("_")) {
1129
1226
  return getRoleFromFolderName(folderName, config);
1130
1227
  }
@@ -1142,7 +1239,7 @@ function getRoleFromFolderName(name, config) {
1142
1239
  return catConfig ? catConfig.role ?? null : null;
1143
1240
  }
1144
1241
  function getRootNodeIndex(relPath, rfConfig, lcaPath) {
1145
- const relToLca = import_path12.default.relative(lcaPath, relPath).replace(/\\/g, "/");
1242
+ const relToLca = import_path13.default.relative(lcaPath, relPath).replace(/\\/g, "/");
1146
1243
  for (let i = 0; i < rfConfig.rootNodes.length; i++) {
1147
1244
  const group = rfConfig.rootNodes[i];
1148
1245
  if (group && group.includes(relToLca)) {
@@ -1154,7 +1251,7 @@ function getRootNodeIndex(relPath, rfConfig, lcaPath) {
1154
1251
  function isInsidePrivateCategoryOfLca(relPathB, lcaPath, config) {
1155
1252
  let current = relPathB;
1156
1253
  while (current && current !== "." && current !== "/" && current !== lcaPath) {
1157
- const parent = import_path12.default.dirname(current).replace(/\\/g, "/");
1254
+ const parent = import_path13.default.dirname(current).replace(/\\/g, "/");
1158
1255
  if (parent === current) {
1159
1256
  break;
1160
1257
  }
@@ -1179,7 +1276,7 @@ var rule6 = {
1179
1276
  if (!config) {
1180
1277
  return {};
1181
1278
  }
1182
- const currentDir = import_path12.default.dirname(relPath);
1279
+ const currentDir = import_path13.default.dirname(relPath);
1183
1280
  const isCurrentFileExcluded = config.excludes?.some(
1184
1281
  (exc) => relPath === exc || relPath.startsWith(exc + "/")
1185
1282
  ) ?? false;
@@ -1212,10 +1309,10 @@ var rule6 = {
1212
1309
  if (isCurrentFileExcluded || isImportedFileExcluded) {
1213
1310
  return;
1214
1311
  }
1215
- const importedDir = import_path12.default.dirname(resolvedRelPath);
1312
+ const importedDir = import_path13.default.dirname(resolvedRelPath);
1216
1313
  if (currentDir === importedDir) {
1217
- const fileA = import_path12.default.basename(relPath);
1218
- const fileB = import_path12.default.basename(resolvedRelPath);
1314
+ const fileA = import_path13.default.basename(relPath);
1315
+ const fileB = import_path13.default.basename(resolvedRelPath);
1219
1316
  const roleA = getFileRole(fileA, config);
1220
1317
  const roleB = getFileRole(fileB, config);
1221
1318
  if (roleA && roleB) {
@@ -1244,6 +1341,10 @@ var rule6 = {
1244
1341
  return;
1245
1342
  }
1246
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
+ });
1247
1348
  return;
1248
1349
  }
1249
1350
  const rfConfig = getRootFragmentConfig(lca, config);
@@ -1253,13 +1354,22 @@ var rule6 = {
1253
1354
  if (idxRootA !== -1 && idxRootB !== -1) {
1254
1355
  if (idxRootB >= idxRootA) {
1255
1356
  context.report({
1256
- message: `Root Node import violation: "${import_path12.default.basename(relPath)}" cannot import from "${import_path12.default.basename(resolvedRelPath)}" under Root Fragment "${lca}".`,
1357
+ message: `Root Node import violation: "${import_path13.default.basename(relPath)}" cannot import from "${import_path13.default.basename(resolvedRelPath)}" under Root Fragment "${lca}".`,
1257
1358
  node
1258
1359
  });
1259
1360
  }
1260
1361
  return;
1261
1362
  }
1262
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
+ }
1263
1373
  const { allowed, defined: hasDefinedHierarchy } = resolveHorizontalHierarchy(lca, subA, subB, config);
1264
1374
  if (hasDefinedHierarchy) {
1265
1375
  if (!allowed) {
@@ -1269,8 +1379,8 @@ var rule6 = {
1269
1379
  });
1270
1380
  }
1271
1381
  } else {
1272
- const roleA = getDirectoryRole(import_path12.default.posix.join(lca, subA), config);
1273
- const roleB = getDirectoryRole(import_path12.default.posix.join(lca, subB), config);
1382
+ const roleA = getDirectoryRole(import_path13.default.posix.join(lca, subA), config);
1383
+ const roleB = getDirectoryRole(import_path13.default.posix.join(lca, subB), config);
1274
1384
  const idxA = roleA ? getRoleHierarchyIndex(roleA, config) : -1;
1275
1385
  const idxB = roleB ? getRoleHierarchyIndex(roleB, config) : -1;
1276
1386
  if (idxA !== -1 && idxB !== -1) {
@@ -1313,7 +1423,7 @@ var rule6 = {
1313
1423
  var no_peer_dependency_rule_default = rule6;
1314
1424
 
1315
1425
  // src/_rules/no-private-category-leak/no-private-category-leak.rule.ts
1316
- var import_path13 = __toESM(require("path"), 1);
1426
+ var import_path14 = __toESM(require("path"), 1);
1317
1427
  var rule7 = {
1318
1428
  create(context) {
1319
1429
  const absPath = context.filename;
@@ -1335,11 +1445,32 @@ var rule7 = {
1335
1445
  if (!resolvedRelPath) {
1336
1446
  return;
1337
1447
  }
1338
- const importedDir = import_path13.default.dirname(resolvedRelPath);
1339
- const owner = getPrivateCategoryOwner(importedDir, config);
1340
- if (owner) {
1341
- const isDescendant = relPath === owner || relPath.startsWith(owner + "/");
1342
- if (!isDescendant) {
1448
+ const importedDir = import_path14.default.dirname(resolvedRelPath);
1449
+ const info = getPrivateCategoryInfo(importedDir, config);
1450
+ if (info) {
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
+ }
1462
+ const isDirectChild = import_path14.default.dirname(relPath) === owner;
1463
+ const importerSubDomain = getImmediateSubDomain(
1464
+ relPath,
1465
+ privateCategoryPath
1466
+ );
1467
+ const importedSubDomain = getImmediateSubDomain(
1468
+ resolvedRelPath,
1469
+ privateCategoryPath
1470
+ );
1471
+ const isInsideSameSubDomain = importerSubDomain !== "" && importerSubDomain === importedSubDomain;
1472
+ const isAllowed = isFractalBranchInPrivateCategory ? isFractalBranchAllowed : isDirectChild || isInsideSameSubDomain;
1473
+ if (!isAllowed) {
1343
1474
  context.report({
1344
1475
  message: `Importing from Private Category is forbidden. The imported resource is private to "${owner}".`,
1345
1476
  node