Class design¶
Five rules read the shape of a class: how its methods share state, how much
state it owns, whether the attribute names hide a smaller object, whether the
class is a namespace for functions, and how many parents it has. Every finding
is a review lead, a question to answer during review, not a verdict about the
code. Version 0.1.0a1. These rules are evaluated for classes defined at
module level, in src/humansays/analysis/rules.py.
HS008 low-class-cohesion¶
| Field | Value |
|---|---|
| Severity | ADVISORY |
| Confidence | 0.65 |
| Weight | 1.0 |
| Tuned by | Not configurable (COHESION_METHOD_MINIMUM, COHESION_FIELD_MINIMUM) |
| Review question | Do these clusters represent independently changing responsibilities that should have separate owners? |
When it fires. For every method, humansays collects the self attributes it
reads and the ones it writes, then subtracts any name that is also a method of
the class or is called as self.name(...), so bound methods reached through
self do not count as fields. A method is eligible for the cohesion check when
it is not __init__, is not a trivial accessor (a single-statement body that
only returns or only assigns a self attribute), and touches at least one
field. The rule then requires at least COHESION_METHOD_MINIMUM eligible
methods and at least COHESION_FIELD_MINIMUM distinct fields across them,
which are 4 and 3. Both gates must pass before anything is computed further.
Cohesion itself is a connected-components walk: two methods are joined when they share at least one field, and the relation is transitive, so a third method sharing a field with either pulls into the same component. The finding is recorded when the walk produces two or more components, and the evidence lists each component's methods with the fields they use.
What tunes it. Nothing in [tool.humansays]. Both minimums are fixed
constants in src/humansays/const.py: COHESION_METHOD_MINIMUM = 4 and
COHESION_FIELD_MINIMUM = 3.
Why it matters. Disconnected field clusters mean the class has two groups of methods that never touch the same data. Each group can usually be moved, tested and changed without the other, which is the working definition of a separate responsibility. See structure.
Fires: two components, {add_row, summary} over rows/total and
{connect, send} over smtp_host/smtp_port.
class Report:
def __init__(self, host, port):
self.rows = []
self.total = 0
self.smtp_host = host
self.smtp_port = port
def add_row(self, row):
self.rows.append(row)
self.total += 1
def summary(self):
return f'{self.total} rows recorded'
def connect(self):
return open_session(self.smtp_host, self.smtp_port)
def send(self, body):
return self.connect().deliver(self.smtp_host, body)
Does not fire: delivery moved out, so every remaining method reaches the same field cluster and the walk finds one component.
class Report:
def __init__(self, mailer):
self.rows = []
self.total = 0
self.mailer = mailer
def add_row(self, row):
self.rows.append(row)
self.total += 1
def summary(self):
return f'{self.total} rows recorded'
def publish(self):
return self.mailer.deliver(self.summary(), self.rows)
HS012 many-class-attributes¶
| Field | Value |
|---|---|
| Severity | ADVISORY |
| Confidence | 0.72 |
| Weight | 1.0 |
| Tuned by | thresholds.classes.max_attributes (default 6) |
| Review question | Do subsets of this state have separate invariants, lifetimes, or reasons to change? |
When it fires. The rule counts the class state surface, which is the union of two sets. The first is what the class body declares:
- Every string entry in a
__slots__assignment. - Every annotated assignment whose target is a plain name, unless the
annotation is
ClassVarortyping.ClassVar(theCLASS_VAR_NAMESset). - Every plain assignment to a name target, unless the name is all uppercase
(treated as a constant) or the assigned value is a bare name matching a
method defined in the same class body (a method alias such as
render = to_html).
The second is every attribute written through self anywhere in the class's
methods, so self.x = ... in __init__ or in any other method counts even
when nothing is declared in the class body. Deletions through self count as
writes too. The finding is recorded when the size of that union is strictly
greater than max_attributes, and the evidence lists the attribute names
sorted.
What tunes it. max_attributes under [tool.humansays.thresholds.classes],
default 6. Seven attributes fire at the default.
Why it matters. Each attribute is one more thing a reader has to hold while judging any method, and one more thing a test has to set up. When subsets of the state have different lifetimes or invariants, they usually want to be separate objects with separate construction rules. See state and effects.
Fires: seven attributes on the state surface, five declared and two written
only through self.
class Job:
name: str
queue: str
retries: int = 0
timeout: float = 30.0
payload: dict | None = None
def start(self, clock):
self.started_at = clock.now()
self.worker_id = clock.worker
Does not fire: six attributes. MAX_RETRIES is uppercase, LOGGER is
uppercase, kind is annotated ClassVar, and run = start is a method alias,
so none of the four is counted.
from typing import ClassVar
class Job:
MAX_RETRIES = 5
LOGGER = get_logger(__name__)
kind: ClassVar[str] = 'batch'
name: str
queue: str
retries: int = 0
timeout: float = 30.0
payload: dict | None = None
def start(self, clock):
self.started_at = clock.now()
run = start
HS013 attribute-prefix-cluster¶
| Field | Value |
|---|---|
| Severity | WARNING |
| Confidence | 0.84 |
| Weight | 3.0 |
| Tuned by | thresholds.classes.max_attributes (default 6) gates it; the cluster size is not configurable (CLUSTER_MINIMUM) |
| Review question | Does each prefix identify a cohesive value object or component hidden inside this class? |
When it fires. HS013 is evaluated only after HS012 has already fired for the
same class, so a class at or under max_attributes is never checked for
clusters. Given the same attribute set, each name is stripped of leading
underscores and split at its first remaining underscore. A name with no
underscore after stripping contributes nothing. The part before that underscore
is the prefix, and it is discarded when it appears in NON_STRUCTURAL_PREFIXES:
can, did, does, has, is, self, should, was, will. Names are
grouped by surviving prefix, and a group becomes a cluster only when it holds at
least CLUSTER_MINIMUM distinct names, which is 3. The finding is recorded when
at least one cluster survives, and the evidence names each prefix with its
members.
What tunes it. There is no cluster-specific key. The gate is
max_attributes under [tool.humansays.thresholds.classes], default 6. The
required group size is the fixed constant CLUSTER_MINIMUM = 3 in
src/humansays/const.py, and the excluded prefixes are the fixed
NON_STRUCTURAL_PREFIXES set in the same file.
Why it matters. A repeated prefix is a name the author already chose for a
grouping that has no type yet. Three smtp_* fields are an SMTP configuration
that has been flattened into the enclosing class, and giving it a type moves its
invariants and its construction into one place. See
structure.
Fires: eight attributes, and smtp_host, smtp_port, smtp_user form one
cluster of three.
class Mailer:
def __init__(self, config):
self.smtp_host = config.host
self.smtp_port = config.port
self.smtp_user = config.user
self.template_dir = config.templates
self.retries = 3
self.timeout = 30.0
self.queue = []
self.sent = 0
Does not fire for HS013: nine attributes, so HS012 reports, but no prefix
reaches three names. is_ready and has_queue are stripped as non-structural
prefixes, and smtp now holds two names.
class Mailer:
def __init__(self, config):
self.smtp = SmtpEndpoint(config.host, config.port, config.user)
self.smtp_user = config.user
self.template_dir = config.templates
self.retries = 3
self.timeout = 30.0
self.queue = []
self.sent = 0
self.is_ready = False
self.has_queue = False
HS015 static-method¶
| Field | Value |
|---|---|
| Severity | WARNING |
| Confidence | 0.99 |
| Weight | 3.0 |
| Tuned by | Not configurable (STATIC_DECORATOR) |
| Review question | The method can reach neither instance nor class state, so what does class scope buy over a module-level function? |
When it fires. Every function defined directly in a class body is checked
for its decorator names, resolved through their dotted form, with any call form
such as @decorator(...) reduced to the decorated name. The finding is
recorded when one of those names is exactly staticmethod. Nothing about the
body matters, and there is no threshold, so a single @staticmethod reports.
The high confidence reflects that the condition is a syntactic fact rather than
a heuristic.
What tunes it. Nothing in [tool.humansays]. The matched name is the fixed
constant STATIC_DECORATOR = 'staticmethod' in
src/humansays/analysis/python_ast.py.
Why it matters. A static method receives neither self nor cls, so the
class is acting as a namespace rather than as a type. Moving the function to
module level makes it importable and callable on its own, which usually makes
it easier to test in isolation. See
testability.
Fires.
class Invoice:
def __init__(self, lines):
self.lines = lines
@staticmethod
def format_currency(amount):
return f'${amount:,.2f}'
def render(self):
return [Invoice.format_currency(line) for line in self.lines]
Does not fire: the same function at module level.
def format_currency(amount):
return f'${amount:,.2f}'
class Invoice:
def __init__(self, lines):
self.lines = lines
def render(self):
return [format_currency(line) for line in self.lines]
HS018 many-base-classes¶
| Field | Value |
|---|---|
| Severity | WARNING |
| Confidence | 0.78 |
| Weight | 3.0 |
| Tuned by | thresholds.classes.max_base_classes (default 1) |
| Review question | Is this composition, mixin layering, or an inheritance chain that hides the real collaborators? |
When it fires. The rule reads the base expressions listed in the class
statement, rendering each as its dotted name where it has one and as a source
snippet otherwise, so pkg.mod.Base and a subscripted generic base both count
as one entry. Keyword arguments in the class header, such as metaclass=, are
not bases and are not counted. The finding is recorded when the number of base
expressions is strictly greater than max_base_classes, and the evidence lists
the rendered base names.
What tunes it. max_base_classes under
[tool.humansays.thresholds.classes], default 1. Two bases fire at the
default. Setting it to 0 reports any subclass at all.
Why it matters. With more than one parent, the method resolution order decides which implementation runs, and that order is a property of the whole inheritance graph rather than of the file in front of the reader. Holding a collaborator as an attribute keeps the dispatch visible at the call site. See structure.
Fires: two bases.
class Report(JsonSerializable, TimestampMixin):
def __init__(self, rows):
self.rows = rows
Does not fire: one base, with the second capability held as a collaborator.
class Report(JsonSerializable):
def __init__(self, rows, clock):
self.rows = rows
self.clock = clock
def stamped(self):
return self.clock.now()