pytest-fsd 0.2.0__py3-none-any.whl

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.
Files changed (48) hide show
  1. pytest_fsd/__init__.py +84 -0
  2. pytest_fsd/_lib/__init__.py +1 -0
  3. pytest_fsd/_lib/ast_utils.py +54 -0
  4. pytest_fsd/_lib/fs_utils.py +31 -0
  5. pytest_fsd/_lib/violations.py +22 -0
  6. pytest_fsd/config.py +62 -0
  7. pytest_fsd/py.typed +0 -0
  8. pytest_fsd/rules/__init__.py +1 -0
  9. pytest_fsd/rules/ambiguous_slice_names/README.md +29 -0
  10. pytest_fsd/rules/ambiguous_slice_names/__init__.py +70 -0
  11. pytest_fsd/rules/excessive_slicing/README.md +41 -0
  12. pytest_fsd/rules/excessive_slicing/__init__.py +47 -0
  13. pytest_fsd/rules/forbidden_imports/README.md +37 -0
  14. pytest_fsd/rules/forbidden_imports/__init__.py +61 -0
  15. pytest_fsd/rules/import_locality/README.md +44 -0
  16. pytest_fsd/rules/inconsistent_naming/README.md +34 -0
  17. pytest_fsd/rules/insignificant_slice/README.md +26 -0
  18. pytest_fsd/rules/no_cross_imports/README.md +30 -0
  19. pytest_fsd/rules/no_cross_imports/__init__.py +57 -0
  20. pytest_fsd/rules/no_file_segments/README.md +39 -0
  21. pytest_fsd/rules/no_file_segments/__init__.py +72 -0
  22. pytest_fsd/rules/no_layer_public_api/README.md +30 -0
  23. pytest_fsd/rules/no_layer_public_api/__init__.py +41 -0
  24. pytest_fsd/rules/no_processes/README.md +24 -0
  25. pytest_fsd/rules/no_public_api_sidestep/README.md +31 -0
  26. pytest_fsd/rules/no_public_api_sidestep/__init__.py +154 -0
  27. pytest_fsd/rules/no_reserved_folder_names/README.md +37 -0
  28. pytest_fsd/rules/no_reserved_folder_names/__init__.py +71 -0
  29. pytest_fsd/rules/no_segmentless_slices/README.md +32 -0
  30. pytest_fsd/rules/no_segmentless_slices/__init__.py +62 -0
  31. pytest_fsd/rules/no_segments_on_sliced_layers/README.md +29 -0
  32. pytest_fsd/rules/no_segments_on_sliced_layers/__init__.py +46 -0
  33. pytest_fsd/rules/no_ui_in_app/README.md +38 -0
  34. pytest_fsd/rules/no_ui_in_app/__init__.py +87 -0
  35. pytest_fsd/rules/public_api/README.md +34 -0
  36. pytest_fsd/rules/public_api/__init__.py +65 -0
  37. pytest_fsd/rules/repetitive_naming/README.md +31 -0
  38. pytest_fsd/rules/repetitive_naming/__init__.py +57 -0
  39. pytest_fsd/rules/segments_by_purpose/README.md +38 -0
  40. pytest_fsd/rules/segments_by_purpose/__init__.py +112 -0
  41. pytest_fsd/rules/shared_lib_grouping/README.md +46 -0
  42. pytest_fsd/rules/shared_lib_grouping/__init__.py +47 -0
  43. pytest_fsd/rules/typo_in_layer_name/README.md +29 -0
  44. pytest_fsd/rules/unknown_folders_in_root/README.md +35 -0
  45. pytest_fsd/rules/unknown_folders_in_root/__init__.py +50 -0
  46. pytest_fsd-0.2.0.dist-info/METADATA +171 -0
  47. pytest_fsd-0.2.0.dist-info/RECORD +48 -0
  48. pytest_fsd-0.2.0.dist-info/WHEEL +4 -0
pytest_fsd/__init__.py ADDED
@@ -0,0 +1,84 @@
1
+ # src/pytest_fsd/__init__.py
2
+ """
3
+ pytest-fsd: FSD Architecture Validation for Python Projects.
4
+
5
+ Provides validate_fsd_architecture() — a single facade function
6
+ that runs all FSD rules configured in pyproject.toml.
7
+ """
8
+
9
+ from typing import List
10
+
11
+ from ._lib.violations import Violation
12
+ from .config import load_config
13
+ from .rules import (
14
+ ambiguous_slice_names,
15
+ forbidden_imports,
16
+ no_cross_imports,
17
+ no_layer_public_api,
18
+ no_public_api_sidestep,
19
+ no_segmentless_slices,
20
+ no_segments_on_sliced_layers,
21
+ no_ui_in_app,
22
+ public_api,
23
+ repetitive_naming,
24
+ segments_by_purpose,
25
+ unknown_folders_in_root,
26
+ )
27
+ from .rules import (
28
+ excessive_slicing,
29
+ no_file_segments,
30
+ no_reserved_folder_names,
31
+ shared_lib_grouping,
32
+ )
33
+
34
+ # Базовые правила — всегда активны
35
+ CORE_RULES = [
36
+ forbidden_imports,
37
+ no_cross_imports,
38
+ no_public_api_sidestep,
39
+ no_layer_public_api,
40
+ no_ui_in_app,
41
+ repetitive_naming,
42
+ no_segmentless_slices,
43
+ segments_by_purpose,
44
+ ambiguous_slice_names,
45
+ no_segments_on_sliced_layers,
46
+ public_api,
47
+ unknown_folders_in_root,
48
+ ]
49
+
50
+ # Дополнительные правила — включаются через extra_rules в pyproject.toml
51
+ EXTRA_RULES = {
52
+ "excessive-slicing": excessive_slicing,
53
+ "shared-lib-grouping": shared_lib_grouping,
54
+ "no-file-segments": no_file_segments,
55
+ "no-reserved-folder-names": no_reserved_folder_names,
56
+ }
57
+
58
+
59
+ def validate_fsd_architecture(project_root: str = ".") -> None:
60
+ """
61
+ Validate the project architecture against FSD rules.
62
+
63
+ Reads configuration from pyproject.toml -> [tool.pytest_fsd].
64
+ Runs all core rules + any extra_rules specified in config.
65
+ Raises AssertionError if any violations are found.
66
+ """
67
+ config = load_config(project_root)
68
+ all_violations: List[Violation] = []
69
+
70
+ # Запускаем базовые правила
71
+ for rule_module in CORE_RULES:
72
+ all_violations.extend(rule_module.check(config, project_root))
73
+
74
+ # Запускаем дополнительные правила, если они включены в конфиге
75
+ for extra_rule_name in config.extra_rules:
76
+ rule_module = EXTRA_RULES.get(extra_rule_name)
77
+ if rule_module is not None:
78
+ all_violations.extend(rule_module.check(config, project_root))
79
+
80
+ if all_violations:
81
+ error_msg = "FSD Architecture Validation failed:\n"
82
+ for v in all_violations:
83
+ error_msg += v.format() + "\n"
84
+ raise AssertionError(error_msg)
@@ -0,0 +1 @@
1
+ """Shared utilities for pytest-fsd rules."""
@@ -0,0 +1,54 @@
1
+ # src/pytest_fsd/_lib/ast_utils.py
2
+ """Shared AST parsing utilities used by multiple rules."""
3
+
4
+ import ast
5
+ from typing import List, Tuple, Optional
6
+
7
+
8
+ def get_imports_from_file(
9
+ filepath: str,
10
+ ) -> List[Tuple[int, Optional[str], int, List[str]]]:
11
+ """Return list of (line_number, full_module_path, level, imported_names).
12
+
13
+ Level represents relative imports:
14
+ 0 = absolute import (e.g. `import foo` or `from foo import bar`)
15
+ 1 = current directory (`from . import bar` -> module=None, level=1)
16
+ 2 = parent directory (`from ..foo import bar` -> module='foo', level=2)
17
+ """
18
+ imports = []
19
+ try:
20
+ with open(filepath, "r", encoding="utf-8") as f:
21
+ tree = ast.parse(f.read(), filename=filepath)
22
+ except Exception:
23
+ return []
24
+
25
+ for node in ast.walk(tree):
26
+ if isinstance(node, ast.Import):
27
+ for alias in node.names:
28
+ imports.append((node.lineno, alias.name, 0, [alias.name]))
29
+ elif isinstance(node, ast.ImportFrom):
30
+ names = [alias.name for alias in node.names]
31
+ imports.append((node.lineno, node.module, node.level, names))
32
+
33
+ return imports
34
+
35
+
36
+ def get_exported_names(filepath: str) -> Optional[List[str]]:
37
+ """Return the list of names exported in __all__ if defined, or None if not defined."""
38
+ try:
39
+ with open(filepath, "r", encoding="utf-8") as f:
40
+ tree = ast.parse(f.read(), filename=filepath)
41
+ except Exception:
42
+ return None
43
+
44
+ for node in tree.body:
45
+ if isinstance(node, ast.Assign):
46
+ for target in node.targets:
47
+ if isinstance(target, ast.Name) and target.id == "__all__":
48
+ if isinstance(node.value, (ast.List, ast.Tuple)):
49
+ return [
50
+ elt.value
51
+ for elt in node.value.elts
52
+ if getattr(elt, "value", None) is not None
53
+ ]
54
+ return None
@@ -0,0 +1,31 @@
1
+ # src/pytest_fsd/_lib/fs_utils.py
2
+ """Shared filesystem utilities used by multiple rules."""
3
+
4
+ import os
5
+ from typing import List
6
+
7
+ from ..config import FsdConfig
8
+
9
+ # Стандартные сегменты FSD для Python-проектов
10
+ STANDARD_SEGMENTS = ("ui", "model", "api", "lib", "config")
11
+
12
+
13
+ def get_sliced_layers(config: FsdConfig) -> List[str]:
14
+ """Return layers that contain slices (excluding app and shared)."""
15
+ return [
16
+ layer_name
17
+ for layer_name in config.layers
18
+ if layer_name not in ("app", "shared")
19
+ ]
20
+
21
+
22
+ def get_slices(layer: str, base_path: str) -> List[str]:
23
+ """Return list of slice directory names inside a given layer."""
24
+ path = os.path.join(base_path, layer)
25
+ if not os.path.isdir(path):
26
+ return []
27
+ return [
28
+ d
29
+ for d in os.listdir(path)
30
+ if os.path.isdir(os.path.join(path, d)) and not d.startswith("_")
31
+ ]
@@ -0,0 +1,22 @@
1
+ # src/pytest_fsd/_lib/violations.py
2
+ """Unified violation dataclass for all FSD rules."""
3
+
4
+ from dataclasses import dataclass
5
+ from typing import Optional
6
+
7
+
8
+ @dataclass
9
+ class Violation:
10
+ """Represents a single FSD architecture violation."""
11
+
12
+ rule: str
13
+ file_path: str
14
+ message: str
15
+ line_number: Optional[int] = None
16
+
17
+ def format(self) -> str:
18
+ """Format the violation as a human-readable string."""
19
+ location = self.file_path
20
+ if self.line_number is not None:
21
+ location += f":{self.line_number}"
22
+ return f" - [{self.rule}] {location} -> {self.message}"
pytest_fsd/config.py ADDED
@@ -0,0 +1,62 @@
1
+ import os
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import List
5
+
6
+ try:
7
+ import tomllib
8
+ except ModuleNotFoundError:
9
+ import tomli as tomllib
10
+
11
+
12
+ @dataclass
13
+ class FsdConfig:
14
+ """Configuration for pytest-fsd, read from [tool.pytest_fsd] in pyproject.toml."""
15
+
16
+ base_path: str = "src"
17
+ layers: List[str] = field(
18
+ default_factory=lambda: [
19
+ "app",
20
+ "pages",
21
+ "widgets",
22
+ "features",
23
+ "entities",
24
+ "shared",
25
+ ]
26
+ )
27
+ ignore_paths: List[str] = field(default_factory=list)
28
+ extra_rules: List[str] = field(default_factory=list)
29
+
30
+
31
+ # Доступные дополнительные правила
32
+ AVAILABLE_EXTRA_RULES = {
33
+ "excessive-slicing",
34
+ "shared-lib-grouping",
35
+ "no-file-segments",
36
+ "no-reserved-folder-names",
37
+ }
38
+
39
+
40
+ def load_config(root_dir: str) -> FsdConfig:
41
+ """Загружает настройки [tool.pytest_fsd] из pyproject.toml."""
42
+ pyproject_path = os.path.join(root_dir, "pyproject.toml")
43
+
44
+ if not os.path.exists(pyproject_path):
45
+ return FsdConfig()
46
+
47
+ with open(pyproject_path, "rb") as f:
48
+ data = tomllib.load(f)
49
+
50
+ tool_config = data.get("tool", {}).get("pytest_fsd", {})
51
+
52
+ config = FsdConfig()
53
+ if "base_path" in tool_config:
54
+ config.base_path = tool_config["base_path"]
55
+ if "layers" in tool_config:
56
+ config.layers = tool_config["layers"]
57
+ if "ignore_paths" in tool_config:
58
+ config.ignore_paths = tool_config["ignore_paths"]
59
+ if "extra_rules" in tool_config:
60
+ config.extra_rules = tool_config["extra_rules"]
61
+
62
+ return config
pytest_fsd/py.typed ADDED
File without changes
@@ -0,0 +1 @@
1
+ """Rules registry for pytest-fsd."""
@@ -0,0 +1,29 @@
1
+ # `ambiguous-slice-names`
2
+
3
+ Forbid slice names that match segment names in the `shared` layer. For example, if `shared/i18n` exists, having `features/i18n` is confusing.
4
+
5
+ ## Examples
6
+
7
+ ✅ Pass:
8
+
9
+ ```
10
+ 📂 shared/
11
+ 📂 ui/
12
+ 📂 i18n/
13
+ 📂 features/
14
+ 📂 auth/ # OK: "auth" is not a shared segment
15
+ ```
16
+
17
+ ❌ Fail:
18
+
19
+ ```
20
+ 📂 shared/
21
+ 📂 ui/
22
+ 📂 i18n/
23
+ 📂 features/
24
+ 📂 i18n/ # ❌ same name as shared/i18n
25
+ ```
26
+
27
+ ## Rationale
28
+
29
+ When there is a segment in `shared` with the same name as a slice in another layer, it becomes ambiguous where new code should be written and obscures the search for existing code.
@@ -0,0 +1,70 @@
1
+ # src/pytest_fsd/rules/ambiguous_slice_names/__init__.py
2
+ """
3
+ [Rule: ambiguous-slice-names]
4
+ Slice names should not match segment names in the shared layer.
5
+ For example, if shared/i18n exists, having features/i18n is confusing.
6
+ """
7
+
8
+ import os
9
+ from typing import Dict, List
10
+
11
+ from ..._lib.fs_utils import get_sliced_layers
12
+ from ..._lib.violations import Violation
13
+ from ...config import FsdConfig
14
+
15
+ RULE_NAME = "ambiguous-slice-names"
16
+
17
+
18
+ def check(config: FsdConfig, project_root: str) -> List[Violation]:
19
+ """Verify that slice names don't collide with shared segment names."""
20
+ violations = []
21
+ base_dir = os.path.join(project_root, config.base_path)
22
+
23
+ # Собираем имена сегментов из shared
24
+ shared_path = os.path.join(base_dir, "shared")
25
+ if not os.path.isdir(shared_path):
26
+ return violations
27
+
28
+ # Собираем имена компонентов (слайсов) внутри сегментов shared
29
+ # Например: shared/ui/button -> регистрируем 'button' (внутри сегмента 'ui')
30
+ shared_slices: Dict[str, str] = {}
31
+ for segment in os.listdir(shared_path):
32
+ segment_path = os.path.join(shared_path, segment)
33
+ if not os.path.isdir(segment_path) or segment.startswith("_"):
34
+ continue
35
+
36
+ for shared_slice in os.listdir(segment_path):
37
+ if os.path.isdir(
38
+ os.path.join(segment_path, shared_slice)
39
+ ) and not shared_slice.startswith("_"):
40
+ shared_slices[shared_slice] = segment
41
+
42
+ if not shared_slices:
43
+ return violations
44
+
45
+ # Проверяем слайсы во всех слоях
46
+ sliced_layers = get_sliced_layers(config)
47
+
48
+ for layer in sliced_layers:
49
+ layer_path = os.path.join(base_dir, layer)
50
+ if not os.path.isdir(layer_path):
51
+ continue
52
+
53
+ for slice_name in os.listdir(layer_path):
54
+ slice_path = os.path.join(layer_path, slice_name)
55
+ if not os.path.isdir(slice_path) or slice_name.startswith("_"):
56
+ continue
57
+
58
+ if slice_name in shared_slices:
59
+ conflict_segment = shared_slices[slice_name]
60
+ violations.append(
61
+ Violation(
62
+ rule=RULE_NAME,
63
+ file_path=slice_path,
64
+ message=f"Slice '{slice_name}' in layer '{layer}' has the same name as "
65
+ f"component 'shared/{conflict_segment}/{slice_name}'. This creates ambiguity about "
66
+ f"where new code should be placed.",
67
+ )
68
+ )
69
+
70
+ return violations
@@ -0,0 +1,41 @@
1
+ # `excessive-slicing`
2
+
3
+ > ⚡ **Optional rule.** Enable via `extra_rules = ["excessive-slicing"]` in `pyproject.toml`.
4
+
5
+ Forbid having too many ungrouped slices in a single layer.
6
+
7
+ **Threshold: 20 slices per layer** (as per Steiger defaults).
8
+
9
+ ## Configuration
10
+
11
+ ```toml
12
+ [tool.pytest_fsd]
13
+ extra_rules = ["excessive-slicing"]
14
+ ```
15
+
16
+ ## Examples
17
+
18
+ ✅ Pass:
19
+
20
+ ```text
21
+ 📂 features/
22
+ 📂 auth/
23
+ 📂 profile/
24
+ 📂 settings/
25
+ # ... up to 20 slices is fine
26
+ ```
27
+
28
+ ❌ Fail:
29
+
30
+ ```text
31
+ 📂 features/ # ❌ 21+ slices
32
+ 📂 comments/
33
+ 📂 posts/
34
+ 📂 users/
35
+ 📂 cars/
36
+ 📂 ... (21+ directories)
37
+ ```
38
+
39
+ ## Rationale
40
+
41
+ Having too many slices in a layer makes it harder to discover features in a project and promotes excessive decomposition. Consider grouping related slices into nested folders.
@@ -0,0 +1,47 @@
1
+ # src/pytest_fsd/rules/excessive_slicing/__init__.py
2
+ """
3
+ [Rule: excessive-slicing] (optional)
4
+ Forbid having too many slices in a single layer.
5
+ Default threshold: 20 slices per layer.
6
+ """
7
+
8
+ import os
9
+ from typing import List
10
+
11
+ from ..._lib.fs_utils import get_sliced_layers
12
+ from ..._lib.violations import Violation
13
+ from ...config import FsdConfig
14
+
15
+ RULE_NAME = "excessive-slicing"
16
+ DEFAULT_THRESHOLD = 20
17
+
18
+
19
+ def check(config: FsdConfig, project_root: str) -> List[Violation]:
20
+ """Verify that no layer contains too many slices."""
21
+ violations = []
22
+ base_dir = os.path.join(project_root, config.base_path)
23
+ sliced_layers = get_sliced_layers(config)
24
+
25
+ for layer in sliced_layers:
26
+ layer_path = os.path.join(base_dir, layer)
27
+ if not os.path.isdir(layer_path):
28
+ continue
29
+
30
+ slices = [
31
+ d
32
+ for d in os.listdir(layer_path)
33
+ if os.path.isdir(os.path.join(layer_path, d)) and not d.startswith("_")
34
+ ]
35
+
36
+ if len(slices) > DEFAULT_THRESHOLD:
37
+ violations.append(
38
+ Violation(
39
+ rule=RULE_NAME,
40
+ file_path=layer_path,
41
+ message=f"Layer '{layer}' contains {len(slices)} slices "
42
+ f"(threshold: {DEFAULT_THRESHOLD}). Too many slices make navigation "
43
+ f"difficult. Consider grouping related slices.",
44
+ )
45
+ )
46
+
47
+ return violations
@@ -0,0 +1,37 @@
1
+ # `forbidden-imports`
2
+
3
+ Forbids imports from higher layers. This is in accordance with the FSD import rule on layers:
4
+
5
+ > A module in a slice can only import other slices when they are located on layers strictly below.
6
+
7
+ This rule maps to Steiger's `no-higher-level-imports`.
8
+
9
+ ## How it works
10
+
11
+ Uses `pytest-archon` to dynamically verify that modules in each layer do not import from any layer above them in the configured hierarchy.
12
+
13
+ For example, with default layers `["app", "windows", "widgets", "features", "entities", "shared"]`:
14
+
15
+ - `features` can import from `entities` and `shared`
16
+ - `features` **cannot** import from `widgets`, `windows`, or `app`
17
+
18
+ ## Examples
19
+
20
+ ✅ Pass:
21
+
22
+ ```python
23
+ # src/features/auth/model/handler.py
24
+ from src.entities.user import User # OK: entities is below features
25
+ from src.shared.lib.hash import hash_pw # OK: shared is below features
26
+ ```
27
+
28
+ ❌ Fail:
29
+
30
+ ```python
31
+ # src/features/auth/model/handler.py
32
+ from src.pages.home import HomePage # VIOLATION: pages is above features
33
+ ```
34
+
35
+ ## Rationale
36
+
37
+ This is one of the main rules of Feature-Sliced Design. It ensures low coupling and predictability during refactoring.
@@ -0,0 +1,61 @@
1
+ # src/pytest_fsd/rules/forbidden_imports/__init__.py
2
+ """
3
+ [Rule: forbidden-imports / no-higher-level-imports]
4
+ Layers can only import from layers below them in the FSD hierarchy.
5
+ Uses pytest-archon for dynamic runtime checking.
6
+ """
7
+
8
+ import os
9
+ from typing import List
10
+
11
+ from pytest_archon import archrule
12
+
13
+ from ..._lib.violations import Violation
14
+ from ...config import FsdConfig
15
+
16
+ RULE_NAME = "forbidden-imports"
17
+
18
+
19
+ def check(config: FsdConfig, project_root: str) -> List[Violation]:
20
+ """Verify that each layer only imports from layers below it."""
21
+ violations = []
22
+ base_path = config.base_path
23
+ layers = config.layers
24
+
25
+ for i, current_layer in enumerate(layers):
26
+ layer_path = os.path.join(project_root, base_path, current_layer)
27
+ if not os.path.isdir(layer_path):
28
+ continue
29
+
30
+ # Проверяем наличие .py файлов в слое
31
+ has_python_files = False
32
+ for _, _, files in os.walk(layer_path):
33
+ if any(f.endswith(".py") and f != "__init__.py" for f in files):
34
+ has_python_files = True
35
+ break
36
+
37
+ if not has_python_files:
38
+ continue
39
+
40
+ forbidden_layers = layers[:i]
41
+ if forbidden_layers:
42
+ forbidden_modules = [f"{base_path}.{layer}.*" for layer in forbidden_layers]
43
+ try:
44
+ (
45
+ archrule(
46
+ f"[{current_layer.upper()}] must not import layers above it"
47
+ )
48
+ .match(f"{base_path}.{current_layer}.*")
49
+ .should_not_import(*forbidden_modules)
50
+ .check(base_path)
51
+ )
52
+ except BaseException as e:
53
+ violations.append(
54
+ Violation(
55
+ rule=RULE_NAME,
56
+ file_path=current_layer,
57
+ message=str(e).strip(),
58
+ )
59
+ )
60
+
61
+ return violations
@@ -0,0 +1,44 @@
1
+ # `import-locality`
2
+
3
+ > 🔶 **Covered by Ruff.** This rule is NOT part of `pytest-fsd` core checks, but is fully enforceable via Ruff's `TID` (flake8-tidy-imports) plugin.
4
+
5
+ Require that imports **within the same slice** be relative, and imports **from another slice** be absolute.
6
+
7
+ ## Ruff Configuration
8
+
9
+ ```toml
10
+ [tool.ruff.lint]
11
+ select = ["TID"] # flake8-tidy-imports
12
+
13
+ [tool.ruff.lint.flake8-tidy-imports]
14
+ ban-relative-imports = "parents" # Запрет relative imports из родительских пакетов
15
+ ```
16
+
17
+ ### What `TID` covers
18
+
19
+ | Rule | Description |
20
+ | -------- | ------------------------------------------------ |
21
+ | `TID251` | Banned imports (configurable) |
22
+ | `TID252` | Relative imports from parent packages are banned |
23
+
24
+ ## How it maps to FSD
25
+
26
+ ```python
27
+ # src/entities/user/ui/avatar.py
28
+
29
+ # ✅ Relative import within the same slice — OK
30
+ from .styles import avatar_styles
31
+
32
+ # ✅ Absolute import from another slice — OK
33
+ from src.shared.ui import Button
34
+
35
+ # ❌ Absolute self-import — wasteful
36
+ from src.entities.user.ui.styles import avatar_styles
37
+
38
+ # ❌ Relative import from another slice — fragile
39
+ from ...shared.ui import Button
40
+ ```
41
+
42
+ ## Rationale
43
+
44
+ Imports between slices should be absolute to stay stable during refactors. Imports within a slice should be relative to keep them short and because the public API should not need to expose internal modules.
@@ -0,0 +1,34 @@
1
+ # `inconsistent-naming`
2
+
3
+ > 🔶 **Covered by Ruff.** This rule is NOT part of `pytest-fsd` core checks, but is fully enforceable via Ruff's `N` (pep8-naming) plugin.
4
+
5
+ Ensure that all modules and directories follow consistent naming conventions (`snake_case` for Python).
6
+
7
+ In Steiger, this rule specifically checks consistency of **pluralization** in the `entities` layer (e.g., all entities should be either singular or plural, not mixed). In Python, `Ruff` handles the broader naming consistency problem via PEP 8 conventions.
8
+
9
+ ## Ruff Configuration
10
+
11
+ ```toml
12
+ [tool.ruff.lint]
13
+ select = ["N"] # pep8-naming — enforces snake_case for modules, variables, functions
14
+ ```
15
+
16
+ ### What `N` covers
17
+
18
+ | Rule | Description |
19
+ | ------ | ----------------------------------------------- |
20
+ | `N801` | Class names should use CapitalCase |
21
+ | `N802` | Function names should be lowercase |
22
+ | `N806` | Variable names should be lowercase |
23
+ | `N815` | Variable in class scope should not be camelCase |
24
+ | `N999` | Invalid module name |
25
+
26
+ ## Steiger Equivalent
27
+
28
+ ```text
29
+ 📂 entities/
30
+ 📂 users/ # Plural
31
+ 📂 post/ # ❌ Singular — inconsistent with "users"
32
+ ```
33
+
34
+ In Python FSD, use `snake_case` everywhere and pick one pluralization style for your entities layer. Ruff enforces the `snake_case` part; pluralization consistency is a team convention enforced via code review.
@@ -0,0 +1,26 @@
1
+ # `insignificant-slice`
2
+
3
+ > 🟡 **Manual review only.** This rule requires full import graph analysis which is beyond the scope of `pytest-fsd`.
4
+
5
+ Detects slices that have no references (unused) or only one reference (can be merged into the layer above).
6
+
7
+ ## Why not automated?
8
+
9
+ This rule requires building a complete import graph of the project and counting how many other modules reference each slice. This is a complex analysis that:
10
+
11
+ 1. Requires resolving all import paths (including dynamic imports, `__init__.py` re-exports)
12
+ 2. Cannot be done with simple AST parsing — needs full module resolution
13
+ 3. Is potentially slow for large projects
14
+
15
+ ## How to check manually
16
+
17
+ 1. Use your IDE's "Find Usages" feature on a slice's `__init__.py`
18
+ 2. If a slice has 0 imports from other modules — it's dead code, remove it
19
+ 3. If a slice has exactly 1 import — consider merging it into the importing layer
20
+
21
+ ## Steiger Equivalent
22
+
23
+ - Slices with **no references** → remove or archive
24
+ - Slices with **one reference** → merge into the importing module
25
+ - **Exception**: pages are allowed to have just 1 reference (from app/routing)
26
+ - **Exception**: slices only used in `app` layer don't count (app shouldn't have UI)