State and boundaries¶
Four rules that ask who owns a piece of state, how long it lives, and how many kinds of outside world one function reaches into. Every finding here is a review lead, not a verdict: the scanner reads syntax, so it can point at a shape and ask a question about it, but it cannot decide whether the shape is right for your design. Background on the reasoning behind these rules lives in state and effects and testability.
HS004 shared-mutable-state¶
| Field | Value |
|---|---|
| Severity | WARNING |
| Confidence | 0.95 |
| Weight | 3.0 |
| Tuned by | Not configurable |
| Review question | Is the lifetime intentional, who owns mutation, and can tests isolate this state? |
When it fires. The scanner walks two scopes: the statements directly in the
module body, and the statements directly in each class body. Function bodies and
nested statements are not scanned, so a mutable local inside a function is never
flagged. Within those two scopes it looks at Assign and AnnAssign statements
that have a value and whose target is a plain name, then asks whether the
assigned expression builds something mutable.
An expression counts as mutable in two cases. First, if it is one of six literal
forms: a dict display, a list display, a set display, a dict comprehension, a
list comprehension, or a set comprehension. Tuple and frozenset literals do not
count. Second, if it is a call whose function is a dotted name that resolves to
one of six constructors: bytearray, dict, list, set,
collections.defaultdict, collections.deque. Resolution uses the aliases
collected from the module's top level imports, and only the leading segment of
the dotted name is substituted. So from collections import deque as ring makes
ring() resolve to collections.deque and fire, while a local rebinding inside
a function does not participate. Names are not filtered by case, so an uppercase
module constant assigned a dict is flagged the same as a lowercase one.
Why it matters. A mutable object bound at module or class scope outlives every call that touches it, and its contents at any moment depend on the order in which unrelated code ran. That is the state most awkward to isolate in a test, because each test either inherits what the last one left behind or has to reach in and reset it. The finding does not say the binding is wrong: caches, registries and interning tables are all legitimate. It says the lifetime is now a design decision worth naming, along with which code is allowed to write to it.
Fires:
CACHE = {}
class Registry:
handlers = []
Does not fire:
CACHE = ()
class Registry:
handlers = frozenset()
HS006 multiple-mutation-owners¶
| Field | Value |
|---|---|
| Severity | WARNING |
| Confidence | 0.70 |
| Weight | 3.0 |
| Tuned by | Not configurable |
| Review question | Are mutation authority, transaction boundaries, and partial-failure behavior clear? |
When it fires. While walking one function body, the scanner attributes each
apparent mutation to an owner and fires once the count of distinct owners reaches
MUTATION_OWNER_MINIMUM, which is 3 in const.py. Nested functions, nested
classes and lambdas are not descended into, so the count belongs to one body.
An owner is the root name of the mutated expression, and only three kinds of root
qualify: self, a name declared as a parameter of the enclosing function, or a
name bound at module level. A purely local variable is never an owner, which is
what keeps ordinary scratch work out of the count. Subscripts and attribute
chains resolve to their root, so journal.entries[0] and journal.total both
count as the single owner journal.
Two kinds of statement register a mutation. Assignment and deletion, meaning
Assign, AnnAssign, AugAssign and Delete targets. And method calls whose
attribute name appears in the mutating-method vocabulary, provided the receiver
resolves to an owner. That vocabulary is derived at runtime in factories.py
rather than hardcoded: for each mutable type it takes the public callables the
type has and its immutable counterpart lacks, comparing bytearray against
bytes, dict against a mapping proxy, list against tuple, set against
frozenset, and deque against tuple, then removes copy and fromkeys.
The result is names like append, extend, insert, pop, remove, sort,
update, setdefault, add, discard, clear and the deque-specific
appendleft, popleft, rotate. Because the match is on the method name alone,
a call to .update() on an object that is not a dict still registers.
Why it matters. A function writing to three independent owners is the point where partial failure becomes hard to reason about: if the third write raises, the first two have already happened, and nothing in the signature tells a caller that. It is also the shape that makes a function hard to test in pieces, since each owner has to be constructed and inspected separately. The review question worth answering is which of the three owners this function actually has authority over, and whether the others belong behind a call it makes rather than a write it performs.
Fires:
class Ledger:
def post(self, entry, journal, audit):
self.balance += entry.amount
journal.append(entry)
audit.add(entry.id)
Does not fire:
class Ledger:
def post(self, entry, journal):
posted = [*journal, entry]
self.balance += entry.amount
return posted, entry.id
HS007 mixed-boundaries¶
| Field | Value |
|---|---|
| Severity | WARNING |
| Confidence | 0.65 |
| Weight | 3.0 |
| Tuned by | Not configurable |
| Review question | Should one function coordinate this many standard-library boundary categories directly? |
When it fires. As it walks a function body the scanner resolves every dotted
attribute reference and every call target through the module's import aliases,
then checks the resolved name against four categories of standard-library module
listed in BOUNDARY_MODULES:
| Category | Modules |
|---|---|
database |
sqlite3 |
filesystem |
os, pathlib, shutil, tempfile |
network |
ftplib, http.client, smtplib, socket, urllib.request |
process |
multiprocessing, subprocess |
A name matches if it equals a listed module or begins with that module plus a
dot, so pathlib.Path.write_text lands in filesystem. The rule fires when the
number of distinct categories touched reaches BOUNDARY_MINIMUM, which is 3 in
const.py. Since there are only four categories, three is close to the ceiling.
The list is deliberately limited to the standard library, so a database reached
through a third-party driver contributes nothing.
This is a syntactic reach, not an observation of behavior. A resolved reference
to subprocess.run is evidence that the function may start a process, and it
sits on a line the report will cite, but the scanner never runs the code and
cannot say whether the call is taken, guarded, or dead. Read the finding as
"three kinds of outside world are named in one body", not as proof that three
kinds of side effect occur.
Why it matters. Each category needs different failure handling, different test doubles and different permissions to exercise. A function that names three of them directly usually has no seam where a test can substitute one, so testing any part of it means having all three available. Splitting along those lines tends to be the cheapest way to make the workflow testable without changing what it does.
Fires:
import os
import socket
import subprocess
def deploy(path, command):
os.makedirs(path)
host = socket.gethostname()
subprocess.run(command, check=True)
return host
Does not fire:
import os
def deploy(path, host, runner):
os.makedirs(path)
return runner.reload(host)
HS017 long-file¶
| Field | Value |
|---|---|
| Severity | WARNING |
| Confidence | 0.60 |
| Weight | 3.0 |
| Tuned by | thresholds.modules.max_lines, default 500 |
| Review question | Does this file hold one subject, or have several modules been accumulated into one namespace? |
When it fires. This is the one module-level rule of the four, and it is
emitted from module_scale_findings in analysis/python_ast.py rather than from
the ruleset evaluator that produces the other three. It compares the total number
of source lines in the file, taken by splitting the source text, against the
configured module threshold, and reports when the count is strictly greater.
Every physical line counts, including blanks, comments and the module docstring.
The finding is attached to the symbol <module> and spans line 1 to the last
line, and the configured threshold is included in the evidence so a report is
readable without the config file next to it.
What tunes it. The [tool.humansays.thresholds.modules] table:
[tool.humansays.thresholds.modules]
max_lines = 500
The CLI flag --max-file-lines sets the same value. It must be at least 1.
Why it matters. File length is the weakest of the four signals, which is why its confidence is the lowest in this group. A long file is not itself a problem; a long file that has quietly become three subjects sharing one namespace is, because nothing in the import graph records that the three parts are separate and nothing stops them growing into each other. The threshold exists to prompt that check at a size where the answer is still easy to see.
Example. A worked fixture is impractical here, since the smallest file that
fires under the default configuration is 501 lines. To see it, either point the
scanner at any file over 500 lines, or lower the threshold in a scratch config
and run against a small file: with max_lines = 5, a six line file reports one
long-file finding at <module>, with evidence reading
configured threshold: 5.