@neralabs/rerail-pack-complexity
v0.1.4
Published
Rerail rule pack: cyclomatic complexity limits for Python, TypeScript and JavaScript functions.
Keywords
Readme
Complexity opt-in rule pack
Cyclomatic complexity rules for Python, TypeScript and JavaScript, with the decision points of
lizard 1.24.0 and without its tokenizer defects. A switch or match counts once
however many cases it has, as slapkit scores a function with the lower of lizard's standard count and its modified count
(-m), so a flat dispatch table is not a reason to split a function. The rule decides per switch, so a default-only
switch beside a cased one counts 0 where slapkit's whole-function minimum gives it 1 (see "Deviations"). One document per language, rule id
complexity-cyclomatic-<language>. They form the complexity rule pack, which set-up offers for a repository with
Python, TypeScript or JavaScript source outside tests and build output; once a repository adds it, its rules are on
(see docs/rule-packs.md).
Every document has mode: "steer": a rule that runs steers the agent and never blocks.
| Rule | Files |
|---|---|
| complexity-cyclomatic-python | .py |
| complexity-cyclomatic-typescript | .ts, .tsx, .mts, .cts |
| complexity-cyclomatic-javascript | .js, .jsx, .mjs, .cjs |
A function's cyclomatic complexity is 1 plus its decision points. A function fails when that exceeds 6, so 7 or more.
The finding spans the whole function, so under the ai-output scope only functions an edit touched are reported. The
message names the function and its number, as slapkit's advisory does: `grade` has cyclomatic complexity 7, over the
ceiling of 6. Decompose it into smaller functions. (code CYCLOMATIC_COMPLEXITY). A TypeScript or JavaScript function with no name of its own takes the variable, class field or object key it is assigned
to; a callback with none of those is "This function" (the too-complex-anonymous clause). The number comes from the
count's as/offset binding: the rule counts decision points with count.has (stopBy the function boundary, so a
nested function is not entered), binds the count plus 1 as $CCN, and fires at min: 6 decisions.
Test files are out of scope: Python **/test_*.py, **/*_test.py, **/tests/**, **/conftest.py; TypeScript and
JavaScript **/*.test.*, **/*.spec.*, **/__tests__/**. So are dependency, virtual-environment, build-output and
generated directories (node_modules, .venv, dist, build, generated, ...).
What counts
Python, per def (async, decorated and methods included):
if,elif,forandasync for,while,except,finally, eachandand eachor, and eachmatchonce, however manycases it has (the wildcard included);- the
ifof a conditional expression, and everyforandifclause of a comprehension; acaseguard is aniftoo; - not
else,with,assert,tryorreturn.
A nested def is scored on its own and adds nothing to its parent. A lambda is not a function here: its conditions
count toward the enclosing def, as lizard counts them.
TypeScript and JavaScript, per function declaration, function expression, arrow function, method, getter, setter, constructor and generator function:
if(else ifincluded),for,for...in,for...of,while,do...while,catch, eachswitchonce however manycases it has (aswitchwith onlydefaultcounts 0),&&,||,||=,&&=and the?:conditional;- not optional parameters or properties (
a?: T), optional chaining (?.), default parameters,??or??=.
Each function is scored on its own: decisions inside a nested function do not count for the function around it. A class is not scored itself.
Tree-sitter node kinds, from the grammars' node-types.json (tree-sitter-python, tree-sitter-typescript and
tree-sitter-javascript as vendored under native/grammars/):
| Language | Function boundary | Decision |
|---|---|---|
| Python | function_definition | if_statement, elif_clause, for_statement, while_statement, except_clause, finally_clause, boolean_operator (one per operator), conditional_expression, for_in_clause, if_clause, match_statement |
| TypeScript, JavaScript | function_declaration, function_expression, arrow_function, method_definition, generator_function, generator_function_declaration | if_statement, for_statement, for_in_statement (in and of), while_statement, do_statement, catch_clause, switch_statement with a switch_case in its switch_body, ternary_expression, binary_expression with && or \|\|, augmented_assignment_expression with &&= or \|\|= |
Deviations from lizard 1.24.0
Each but the last is a lizard defect found on real code (see "Checked on real code"); the last is a choice in how a switch counts. Each has a corpus case.
??and??=count 0. lizard's tokenizer reads each as two?and counts 2.- A
?inside a regex literal counts 0, and so does?.right after)or](f()?.x,a[0]?.x). lizard reads each as a conditional. - An arrow function whose expression body starts on a later line is scored on its own. lizard adds its conditions to the enclosing function and lists a one-line arrow, scored 1, on the line after.
- A Python def whose return annotation spans several lines, or whose parameter list breaks after a default holding brackets, is scored whole. lizard ends the def inside its signature and scores it 1 (fixed in crapkit #72). A parameter list that only spans several lines is read correctly by both.
?.counts 0 in.jsxfiles too. lizard's JSX reader counts each?.as a conditional; its.jsand.tsreaders do not, and neither does Rerail anywhere.- lizard does not read
.mtsor.ctsfiles at all; the TypeScript rule covers them with the same grammar. - A default-only switch counts 0 beside a cased one. The recorded number is the lower of lizard's standard and
-mcounts over the whole function, and there-m's 1 for a switch with no case survives; the rule counts each switch on its own (defaultOnlyBesideCasedSwitchin the TypeScript and JavaScript corpus).
Checked on real code
The rules were compared with lizard 1.24.0 function by function on released sources (2026-10-07), pairing each lizard function with the Rerail function that starts within two lines of it:
| Repository | Rule | Paired | Same number | The rest |
|---|---|--:|--:|---|
| jinja 3.1.4 src | Python | 747 | 743 | 4 defs with a multi-line return annotation (crapkit #72); collapsing each signature onto one line makes lizard agree with Rerail |
| click 8.1.7 src | Python | 512 | 512 | |
| marshmallow 3.21.3 src | Python | 254 | 254 | |
| express 4.21.0 lib | JavaScript | 153 | 152 | 1 function lizard runs on past its end into the next one |
| axios 1.7.7 lib | JavaScript | 285 | 270 | regex ?, multi-line arrow bodies, lizard ranges |
| zod 3.23.8 src | TypeScript | 440 | 413 | ??, )?., multi-line arrow bodies, lizard ranges |
| hono 4.6.3 src | TypeScript | 728 | 610 | the same, plus conditional types lizard reads as functions |
Python agrees on every function lizard reads correctly. lizard also leaves out one-line stubs (def f(x) -> T: ...),
which Rerail scores 1. Every TypeScript and JavaScript difference inspected traced to one of the deviations above, to
lizard getting a function's range wrong (running past its end, ending it early, or missing a function inside an array
literal), or to lizard reading a type as a function; none was a Rerail miscount. Not every difference was inspected
one by one.
Every file of hono 4.6.3's src parses. Where a file has syntax errors, the functions outside the error regions are
scored and the rest are left unknown, so they never report a guessed number.
Parity corpus
upstream/lizard/fixtures/{python,typescript,javascript}/ holds small source files that cover every counted
decision, a function at exactly 6 and one at 7 for several kinds, nested functions, Python lambdas and comprehensions,
match/case and switch/case, TypeScript optional parameters and properties, every deviation above, files with
only simple functions, and test files that lizard scores over the limit. Rerail wrote them; nothing is copied from
lizard (upstream/lizard/UPSTREAM.md). upstream/lizard/lizard-1.24.0.tsv records lizard's score for every function
(path relative to the corpus, function name as lizard prints it, start line, CCN).
test/engine/code-rule/complexity-rules.test.ts evaluates the three rules over the corpus through the native runtime and checks
that:
- each rule reports exactly the in-scope functions lizard scores above 6, with the recorded number in the message;
- the same counting without the threshold gives every in-scope function the recorded number, apart from the deviations, which the test lists with lizard's number and Rerail's;
- test files produce no finding, even though lizard scores them over the limit.
lizard is never a runtime dependency; it runs only to derive the TSV. The corpus and the TSV are pack test data, not in
git (CONTRIBUTING.md): the corpus is authored and the TSV is derived from it,
and both are the tool-pack's archive in S3, which upstream/lizard/test-data.lock names and npm run fixtures:pull
fetches. The test fails saying so without them.
Regenerating the scores
After changing the corpus, derive the TSV again (needs uv on PATH):
npm run fixtures:derive -- --pack complexityIts recipe runs upstream/lizard/generate.mjs, which runs uvx --from lizard==1.24.0 lizard --csv with and without
-m on upstream/lizard/fixtures/, keeps the lower of the two counts for each function, and rewrites
upstream/lizard/lizard-1.24.0.tsv, which is never committed. Check the changed corpus files into your pull request
with git add -f; a new one needs its row in upstream/lizard/UPSTREAM.md, which is what makes it authored, and
removing a corpus file is removing its row. Before merge a maintainer derives the TSV, away from any credential, and
archives both from a trusted worktree of main (npm run fixtures:push -- --from <ref>;
CONTRIBUTING.md); CI then derives the TSV again and compares it with the
archive (Test data reproduces from the pins).
A new case lizard mis-scores needs an entry in the test's DEVIATIONS list and a line under "Deviations" above.
Rule details
What each rule matches beyond its one-line description: exact matching, edge cases, upstream settings and known divergences.
complexity-cyclomatic-typescript
Conditional types (T extends U ? X : Y) do not count as decision points. The README's "What counts" list for
TypeScript names optional parameters/properties, optional chaining, default parameters, ?? and ??= as not counting,
but does not say conditional types are excluded too (it only mentions lizard reading conditional types as functions
in the hono comparison).
