Function shape¶
These eight rules read the shape of a single function: how many arguments it declares, how deep its control flow runs, how long it is, and whether it validates its own inputs. Each one raises a review lead, not a verdict. The tool reports structure it can see in the syntax tree, and the review question attached to each finding is the thing a human still has to answer.
HS001 many-arguments¶
| Field | Value |
|---|---|
| Severity | WARNING |
| Confidence | 0.80 |
| Weight | 3.0 |
| Tuned by | thresholds.functions.max_arguments, default 3 |
| Review question | Do these values form a request object, reusable configuration, or multiple responsibilities? |
When it fires. The count of operation parameters is strictly greater than
max_arguments. Operation parameters are every declared argument: positional
only, positional or keyword, keyword only, plus *args and **kwargs if
present, each counted as one. Any parameter literally named self or cls is
excluded, wherever it appears in the signature. Defaults do not reduce the
count, so a function with three required arguments and two optional ones counts
five. With the default threshold of 3, four operation parameters is the
smallest signature that fires.
What tunes it. thresholds.functions.max_arguments, default 3. In
pyproject.toml that is [tool.humansays.thresholds.functions], and the CLI
flag is --max-arguments.
Why it matters. A long parameter list is usually several smaller ideas that have not been given a name yet, and callers have to reconstruct the grouping every time they call. Once a signature passes four or five values, argument order becomes something the reader has to remember rather than something the types enforce. See arguments and failure.
# fires: four operation parameters, self excluded
def send(self, recipient, subject, body, retries):
...
# does not fire: three operation parameters
def send(self, recipient, message, retries):
...
HS002 boolean-modes¶
| Field | Value |
|---|---|
| Severity | ADVISORY |
| Confidence | 0.82 |
| Weight | 1.0 |
| Tuned by | Not configurable |
| Review question | Would keyword-only arguments, an enum, or separate operations communicate the modes better? |
When it fires. At least one operation parameter is boolean, and the
function is not an exempt setter. A parameter counts as boolean when its
annotation resolves to bool or builtins.bool, or when its default value is
the constant True or False. Parameters named self or cls are excluded.
There is no count threshold: one boolean parameter is enough. The single
exemption is a function whose name starts with set_ and which declares
exactly one operation parameter, so set_enabled(self, enabled: bool) stays
quiet.
What tunes it. Not configurable. The names recognized as boolean
annotations are fixed in the BOOL_NAMES constant in src/humansays/const.py,
and the self and cls exclusion comes from IMPLICIT_PARAMETERS in the same
file.
Why it matters. A boolean argument at a call site carries no name, so
render(data, True) tells the reader nothing about what was switched on. A
flag that selects behavior usually means the function contains two operations
sharing a body, and splitting them or naming the modes with an enum makes the
choice visible at the call site. See contracts.
# fires: boolean parameter selects a mode
def render(data, pretty=False):
...
# does not fire: the mode is a named value
def render(data, style: Style):
...
HS003 deep-nesting¶
| Field | Value |
|---|---|
| Severity | WARNING |
| Confidence | 0.76 |
| Weight | 3.0 |
| Tuned by | thresholds.functions.max_nesting, default 3, plus class_nesting_bonus, default 1 |
| Review question | Would guard clauses, a state model, or one meaningful extraction clarify the control flow? |
When it fires. The deepest nesting reached in the body is strictly greater
than the limit. For a module-level function the limit is max_nesting. For a
method the limit is max_nesting + class_nesting_bonus, so with the defaults a
plain function fires above depth 3 and a method fires above depth 4.
Depth increases for the body of an if, a for or async for, a while, a
try in all its parts, a with or async with, and each case of a match.
The test or subject expression of a compound statement is visited at the
enclosing depth, so a condition does not add a level by itself. An elif chain
stays flat: the else branch of an if counts as nested only when it is not a
single following if. Nested function definitions, nested classes, and lambdas
are not descended into, so their inner depth belongs to their own analysis
rather than to the enclosing function.
What tunes it. thresholds.functions.max_nesting, default 3, and
thresholds.functions.class_nesting_bonus, default 1. The CLI flags are
--max-nesting and --class-nesting-bonus.
Why it matters. Nesting depth is a direct measure of how much state the reader has to hold to understand the innermost line. At depth four, the conditions guarding a statement are spread across four separate places on the screen. Inverting the conditions into early returns usually removes most of the depth without changing the logic. See structure.
# fires: depth 4 in a module-level function
def settle(orders):
for order in orders:
if order.active:
if order.total:
if order.paid:
record(order)
# does not fire: guard clauses flatten the same logic to depth 2
def settle(orders):
for order in orders:
if not (order.active and order.total and order.paid):
continue
record(order)
HS009 long-function¶
| Field | Value |
|---|---|
| Severity | ADVISORY |
| Confidence | 0.55 |
| Weight | 1.0 |
| Tuned by | thresholds.functions.max_lines, default 50 |
| Review question | Is the function cohesive, or does it mix workflow, decisions, and lower-level mechanics? |
When it fires. The physical span of the definition is strictly greater than
max_lines. The span is end_line - start_line + 1, measured from the def
line to the last line of the body. Decorators sit above the def line and are
not counted. Everything inside the span is counted, including blank lines,
comments, the docstring, and any nested definitions. With the default of 50,
a definition spanning 51 lines is the shortest that fires.
Confidence is the lowest in this group, at 0.55, because length alone is weak
evidence: a long dispatch table and a long tangle of logic look the same to a
line count.
What tunes it. thresholds.functions.max_lines, default 50. The CLI flag
is --max-function-lines.
Why it matters. A function that does not fit on a screen forces the reader to scroll while holding earlier lines in memory, and the parts that could be named separately stay anonymous. The useful question is not whether the function is too long but whether it is doing one thing at one level of abstraction. See structure.
# fires: span from the def line exceeds 50 lines
def build_report(records):
header = compose_header(records)
# ... 55 more lines of body ...
return header
# does not fire: the same work split across named steps
def build_report(records):
header = compose_header(records)
body = compose_body(records)
return join(header, body)
HS014 validated-argument-bundle¶
| Field | Value |
|---|---|
| Severity | WARNING |
| Confidence | 0.88 |
| Weight | 3.0 |
| Tuned by | thresholds.functions.max_arguments, default 3 |
| Review question | Should these arguments and their validation become one request, value, or configuration object? |
When it fires. This rule is checked only after HS001 has fired, so the
function must already declare more operation parameters than max_arguments.
Given that, it fires when at least one of those operation parameters is
validated inside the body.
A parameter counts as validated when the body contains either an assert whose
test expression mentions the parameter name, or an if statement whose test
mentions the parameter name and whose body contains a raise anywhere within
it. Only names referenced directly in the test count; a parameter checked
indirectly through a helper call does not register. The evidence attached to
the finding names each validated parameter and the line where the check
appears.
What tunes it. There is no separate key. The rule inherits
thresholds.functions.max_arguments, default 3, through its dependency on
HS001, and the validation detection itself has no threshold.
Why it matters. When a function both accepts a wide bundle of values and checks their consistency, the bundle already has invariants, which means it already has an identity that no type currently carries. Moving the values and their checks into one object validates once at construction instead of at every call site. See arguments and failure.
# fires: four operation parameters, two of them guarded in the body
def schedule(start, end, retries, channel):
if start > end:
raise ValueError('start must precede end')
assert retries >= 0
return dispatch(start, end, retries, channel)
# does not fire: the bundle and its invariants live in one object
def schedule(window: Window, policy: RetryPolicy):
return dispatch(window, policy)
HS016 lambda-expression¶
| Field | Value |
|---|---|
| Severity | WARNING |
| Confidence | 0.99 |
| Weight | 3.0 |
| Tuned by | Not configurable |
| Review question | What would this expression be named, and would a named function make it testable and reusable? |
When it fires. Every lambda expression in the module fires, once each.
The rule walks the whole tree rather than one function body, so lambdas in
default arguments, class bodies, comprehensions, and decorator arguments are
all found. There is no threshold and no exemption for short lambdas or for
key= callbacks. The finding is attributed to the innermost scope containing
the lambda's line, which is the enclosing function or method when there is one
and <module> otherwise. Confidence is 0.99 because the detection is purely
syntactic: either the token is there or it is not.
What tunes it. Not configurable. The only constant involved is
UNPARSE_LIMIT in src/humansays/const.py, which caps the unparsed source
snippet quoted as evidence at 80 characters, and has no effect on whether the
rule fires.
Why it matters. A lambda cannot be imported, so it cannot be tested on its
own or reused from anywhere else, and the stack trace it appears in shows
<lambda> rather than a name. Naming the expression usually costs one line and
answers the reader's question about what the callback is for. See
testability.
# fires: anonymous callback
active = sorted(users, key=lambda user: user.last_seen)
# does not fire: the same expression, named and importable
def last_seen(user):
return user.last_seen
active = sorted(users, key=last_seen)
HS019 many-branches¶
| Field | Value |
|---|---|
| Severity | WARNING |
| Confidence | 0.74 |
| Weight | 3.0 |
| Tuned by | thresholds.functions.max_branches, default 5 |
| Review question | Do these conditionals encode one decision that belongs in a table, mapping, or polymorphic dispatch? |
When it fires. The branch count for the body is strictly greater than
max_branches. The counter increments once for every if statement reached in
the body, and because an elif is represented as a nested if, each elif
adds one as well. A plain else adds nothing. Nested if statements count
toward the same total as top-level ones.
Loops, try blocks, with blocks, match cases, boolean operators, and
conditional expressions such as a if b else c are not counted. Nested
functions, nested classes, and lambdas are not descended into. With the default
of 5, six if or elif statements is the smallest body that fires.
What tunes it. thresholds.functions.max_branches, default 5. The CLI
flag is --max-branches.
Why it matters. A run of conditionals over the same value is usually one decision written out longhand, and each new case means editing the function again. When the branches map an input to a behavior, a dictionary or a dispatched method turns the decision into data that can be extended and tested without touching control flow. See structure.
# fires: six if/elif statements
def rate(kind):
if kind == 'basic':
return 1
elif kind == 'plus':
return 2
elif kind == 'pro':
return 3
elif kind == 'team':
return 4
elif kind == 'scale':
return 5
elif kind == 'enterprise':
return 6
return 0
# does not fire: the decision is a table
RATES = {'basic': 1, 'plus': 2, 'pro': 3, 'team': 4, 'scale': 5, 'enterprise': 6}
def rate(kind):
return RATES.get(kind, 0)
HS022 dense-function¶
| Field | Value |
|---|---|
| Severity | WARNING |
| Confidence | 0.72 |
| Weight | 3.0 |
| Tuned by | thresholds.functions.max_code_lines, default 65 |
| Review question | How many distinct steps are in here, and which of them has a name already? |
When it fires. The count of executable lines in the definition is strictly
greater than max_code_lines. Counting runs over the same span as HS009, from
the def line to the last body line, but skips three kinds of line: blank
lines, lines whose first non-whitespace character is #, and every line of the
docstring. The signature lines themselves are counted, and so are nested
definitions inside the function. With the default of 65, sixty six counted
lines fires.
HS009 and HS022 measure the same region differently and can both fire on one
function. Because counted lines are a subset of the span, a body with more than
65 counted lines necessarily spans more than 50 physical lines, so under
the shipped defaults HS022 never fires without HS009 also firing. Raising
max_lines above max_code_lines separates them.
What tunes it. thresholds.functions.max_code_lines, default 65. The CLI
flag is --max-code-lines.
Why it matters. Stripping blanks, comments, and the docstring measures the work rather than the presentation, so a function cannot lower the number by deleting the whitespace that made it readable. Density counted this way is a better proxy than raw length for how many distinct steps the reader has to follow. See structure.
# fires: 70 counted lines of code between the signature and the last line
def reconcile(ledger, statements):
"""Blank lines and this docstring are not counted."""
opened = ledger.open()
# ... 68 more executable lines ...
return opened
# does not fire: the steps are extracted and named
def reconcile(ledger, statements):
opened = ledger.open()
matched = match_entries(opened, statements)
return close(ledger, matched)