Skip to main content
Fensu does not check every file the same way. It first sorts your repository into scopes, then applies each scope’s rules using a fixed vocabulary of roles and a domain layout. This page describes that model, which is the shared ground every rule family stands on.
This page describes Fensu’s default model. Every part of it is a rule you can turn off with ignore, narrow with scopes, or replace with a custom rule. The layout below is the shipped starting point, not a fixed requirement.

Scopes

A scope is a set of paths that receives a particular policy. Fensu has three, each declared in configuration: The scanned set is the union of these three. A file that is not in any scope, such as a top-level setup.py or noxfile.py, is simply skipped. There is no “scanned but unclassified” state.
  • roots is required and its entries must not be nested inside one another, so every scanned file belongs to exactly one root. Independent package trees in a monorepo are expressed as multiple roots.
  • tests defaults to ["tests"].
  • tooling defaults to empty.
A file’s position is computed relative to its matching root. For a root of src/my_package, the file src/my_package/config/main/load.py has relative parts ("config", "main", "load.py"), from which Fensu derives its domain, subdomain, and role.

Roles

A role is what a file or directory is for. Fensu recognizes a fixed set of role names and enforces what each may contain and where it may live. The underscore in _helpers/ means private to its owner, not unimportable. A main/ entry importing its own _helpers/ implementation is the intended pattern; another owner reaching into that package is not. The semantic role name remains helpers in configuration, including [roles.helpers] and max_helpers_container_modules. These roles are Fensu’s opinion and are fixed. A role file may hold only what its role permits: a declaration that belongs in models.py may not live elsewhere, and models.py may not hold unrelated code. Ownership follows the role boundary: everything before a module’s first role segment is its owner. Imports within one owner are free (main/ calling its own _helpers/, helpers calling each other, anything using the owner’s classes/). Imports across owners must enter through the owner’s public surface: its role files or role packages (models, types, constants, exceptions, classes) or its main/ entry modules. Another owner’s _helpers/ internals are never importable. For an importable path with no role segment, Fensu infers the containing package as its owner (or the package itself for an __init__.py); direct non-role modules remain internal rather than becoming accidental public surfaces.
Role names are fixed today. User-defined role taxonomies are a planned future addition, not current behavior.

Domains: leaf or branch

Fensu’s default layout for product code is domains built from roles, not a flat top level.
  • The package root (for example src/my_package/) holds only its __init__.py public surface and domain packages. No role files live at the root.
  • A leaf domain holds the actual work directly: main/, _helpers/, classes/, and the role files (models.py, types.py, constants.py, exceptions.py).
  • A branch domain holds only named subdomains, each of which is itself built from the roles.
A domain is one or the other, never both: mixing direct role content with named subdomains is a fault (FFR306). A cohesive single-purpose domain is simply a leaf; it splits into named subdomains only when it grows genuinely independent parts. There is no mandatory subdomain level and no conventional core/ ceremony. Loose non-role modules directly at the domain level fault too (FFR307).

Vocabulary lives with its owner

Fensu has no neutral “shared” or “utils” bucket. Every type lives in the role file of the domain or subdomain that produces it, and other packages import it from there. A type that many places use is not ownerless; it has one producer, and that producer publishes it. If you cannot name the owner, that is a signal to look harder, not to create a junk drawer.

Shared domain prefixes

When sibling domains repeatedly start with the same first underscore-separated token, that token is already acting like an owner. FFR308 asks you to make the ownership explicit:
The rule groups by the first token, not the longest shared string. For example, annotation_export/, annotation_sanitization/, and annotation_validation/ belong under annotation/. The rule applies when at least min_shared_domain_prefix_packages sibling domains share a prefix. The default is 2; set it to 0 to disable the check. Directories without Python files do not count.

Containers: flat or grouped

main/ and _helpers/ are containers. A container has one of two shapes:
  • Flat: modules sit directly in the package, up to a cap (max_helpers_container_modules, default 10, for _helpers/; max_main_container_modules, default 20, for main/).
  • Grouped: every module is grouped into one level of concern subfolders (buckets), each bucket holding modules up to the same cap.
Mixing the two shapes in one container is a fault (FFR301 for _helpers/, FFR302 for main/): once one bucket exists, direct modules may not sit beside it. Bucket nesting is capped by max_role_depth (default 1), so a bucket does not grow buckets of its own. Bucket names must name a concern: a role name (main, helpers, classes, …) as a bucket name faults under the same layout rules, and generic names (util, common, misc, …) fault under FFR204. Grouping is pure navigation, not new ownership: a grouped main/ or _helpers/ tree still belongs to one owner, and modules inside it keep their role’s rules. In particular, the entry-module contract applies to every module under main/, bucketed or not.

Entry modules

A module under a main/ package (directly, or inside a bucket) is an entry module. Its shape rule (FFR401) constrains only the module’s top level:
  • exactly one public function (the entry point);
  • at most two private functions in that file;
  • top level contains only imports and function definitions, nothing else.
That is the whole rule. It is a limit on how many functions live in the entry file itself, not on what those functions may do. The public entry function is free to import and call as much as it needs: helper phases from _helpers/, classes from classes/, other packages’ published surfaces, and anything else it has legitimate access to. The two-private-function cap is not a cap on collaborators; it just says an entry module should not grow its own pile of local glue. When you need more than a couple of local helpers, move them into _helpers/ and call them from there.
Here load_config calls four helper functions and several imported types. That is entirely fine: it is one public function with zero private ones, and all the real work lives in _helpers/. The rule would only fire if the file grew a second public function, a third private function, or a top-level statement that is not an import or a def.

Default thresholds

Several shape rules are governed by numeric thresholds. Fensu ships these defaults, all overridable in configuration: These are measured values from a large, real, self-hosted codebase, presented as documented defaults rather than magic numbers. See Configuration for how to override them globally, per role, or per path.

Tooling layout

Tooling code (declared in tooling) follows the same roles as product code but at one less nesting level, because dev scripts rarely need full domain depth.
  • Direct scripts/*.py files are thin command adapters: one public main(), an optional _parse_args() or _build_parser(), imports, the if __name__ == "__main__" guard, and delegation to an imported main/ entry. scripts/__init__.py is exempt.
  • A tool implementation uses one ownership level then a role:
The role directories permitted directly under a tool are main/, _helpers/, classes/, and rules/. A rules/ module is the home for custom rules; see Custom rules.

Test layout

The tests scope (declared in tests) has its own shape, so a test’s location and form tell you what it exercises and how to read it.
  • Mirrored. Tests live under a scope of unit/, integration/, or e2e/, then mirror the source path they exercise. A test for src/my_package/config/main/ lives at tests/unit/src/my_package/config/main/.
  • Named for behavior. Test files start with test_, and each test is test_given_<state>_when_<action>_then_<outcome>, so the precondition, action, and expectation are readable without opening the body.
  • Dataclass-backed cases. Tests parametrize over a local frozen dataclass rather than tuples or dicts. Each case carries a description and at least one expected_ field, and the test asserts against that field. Case ids come from lambda case: case.description, so a failure names the behavior.
Case dataclasses live in a sibling _test_types.py, and reusable test helpers live in a local helpers.py. See FFT: Tests for the rules that enforce this layout.

Configuration

Declare scopes, override thresholds, and set contracts.

Rule families

The rules that enforce this model.