codecoco 3.5.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codecoco-3.5.1.dist-info/METADATA +278 -0
- codecoco-3.5.1.dist-info/RECORD +16 -0
- codecoco-3.5.1.dist-info/WHEEL +5 -0
- codecoco-3.5.1.dist-info/entry_points.txt +2 -0
- codecoco-3.5.1.dist-info/licenses/LICENSE +22 -0
- codecoco-3.5.1.dist-info/top_level.txt +1 -0
- cognitive_complexity/__init__.py +1 -0
- cognitive_complexity/api.py +174 -0
- cognitive_complexity/autofix.py +238 -0
- cognitive_complexity/cli.py +360 -0
- cognitive_complexity/common_types.py +46 -0
- cognitive_complexity/discovery.py +193 -0
- cognitive_complexity/refactor.py +388 -0
- cognitive_complexity/report.py +103 -0
- cognitive_complexity/utils/__init__.py +0 -0
- cognitive_complexity/utils/ast.py +165 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Safe, formatting-preserving guard-clause flattening for ``--fix``.
|
|
2
|
+
|
|
3
|
+
Only one transform, and only where it is provably behavior-preserving: an ``if``
|
|
4
|
+
with no ``else`` that is the *last* statement of a function body or loop body is
|
|
5
|
+
rewritten into an early ``return``/``continue`` guard, and its body de-indented
|
|
6
|
+
one level. Because the ``if`` is last, returning/continuing early when the
|
|
7
|
+
condition is false changes nothing. Anything that fails the strict preconditions
|
|
8
|
+
in :func:`_is_safe_guard` is left exactly as it was.
|
|
9
|
+
|
|
10
|
+
Edits are made on the source text (not via ``ast.unparse``) so comments and
|
|
11
|
+
formatting in the untouched body survive.
|
|
12
|
+
|
|
13
|
+
Why hand-rolled text surgery and not a CST library (LibCST)? LibCST was
|
|
14
|
+
considered and rejected: its headline win — comment/whitespace preservation — is
|
|
15
|
+
already achieved here for the single transform that exists, and cococo declares
|
|
16
|
+
zero runtime dependencies by design (it is a low-level dependency of other
|
|
17
|
+
pipelines), a posture LibCST's native extension + ``pyyaml`` would break.
|
|
18
|
+
**Reopen trigger:** this calculus holds only because there is exactly one
|
|
19
|
+
provably-safe transform. When a *second* non-trivial rewrite is added, re-run the
|
|
20
|
+
LibCST bake-off — string surgery does not compose across transforms (each one
|
|
21
|
+
re-derives indent units, newline style, and segment boundaries), so at N>=2 the
|
|
22
|
+
decision flips to adopting a CST.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import ast
|
|
28
|
+
import os
|
|
29
|
+
import tempfile
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
from cognitive_complexity.common_types import is_funcdef
|
|
33
|
+
|
|
34
|
+
# The transform is idempotent (a flattened guard is no longer the last statement
|
|
35
|
+
# of its block), so this only caps pathological input; it is never reached in
|
|
36
|
+
# practice.
|
|
37
|
+
_MAX_PASSES = 1000
|
|
38
|
+
|
|
39
|
+
_LOOP_TYPES = (ast.For, ast.AsyncFor, ast.While)
|
|
40
|
+
_BREAKER_TYPES = (
|
|
41
|
+
ast.For,
|
|
42
|
+
ast.AsyncFor,
|
|
43
|
+
ast.While,
|
|
44
|
+
ast.If,
|
|
45
|
+
ast.IfExp,
|
|
46
|
+
ast.ExceptHandler,
|
|
47
|
+
ast.Match,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def fix_source(source: str) -> tuple[str, int]:
|
|
52
|
+
"""Apply every safe guard-clause flattening in ``source``.
|
|
53
|
+
|
|
54
|
+
Returns the rewritten source and the number of guards applied. Raises
|
|
55
|
+
``SyntaxError`` if ``source`` does not parse.
|
|
56
|
+
"""
|
|
57
|
+
fixes = 0
|
|
58
|
+
for _ in range(_MAX_PASSES):
|
|
59
|
+
tree = ast.parse(source)
|
|
60
|
+
target = _find_guard(tree, source)
|
|
61
|
+
if target is None:
|
|
62
|
+
break
|
|
63
|
+
node, keyword = target
|
|
64
|
+
source = _apply_guard(source, node, keyword)
|
|
65
|
+
fixes += 1
|
|
66
|
+
return source, fixes
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _find_guard(tree: ast.AST, source: str) -> tuple[ast.If, str] | None:
|
|
70
|
+
candidates: list[tuple[ast.If, str]] = []
|
|
71
|
+
for block, keyword in _guarded_blocks(tree):
|
|
72
|
+
last = block[-1] if block else None
|
|
73
|
+
if isinstance(last, ast.If) and _is_safe_guard(last, source):
|
|
74
|
+
candidates.append((last, keyword))
|
|
75
|
+
if not candidates:
|
|
76
|
+
return None
|
|
77
|
+
return min(candidates, key=lambda c: c[0].lineno)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _guarded_blocks(tree: ast.AST) -> list[tuple[list[ast.stmt], str]]:
|
|
81
|
+
blocks: list[tuple[list[ast.stmt], str]] = []
|
|
82
|
+
for node in ast.walk(tree):
|
|
83
|
+
if is_funcdef(node):
|
|
84
|
+
blocks.append((node.body, "return"))
|
|
85
|
+
elif isinstance(node, _LOOP_TYPES):
|
|
86
|
+
blocks.append((node.body, "continue"))
|
|
87
|
+
return blocks
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _is_safe_guard(node: ast.If, source: str) -> bool:
|
|
91
|
+
if node.orelse or not node.body:
|
|
92
|
+
return False
|
|
93
|
+
body_first = node.body[0]
|
|
94
|
+
if body_first.lineno <= node.lineno: # single-line `if x: ...`
|
|
95
|
+
return False
|
|
96
|
+
if node.test.lineno != (node.test.end_lineno or node.test.lineno): # multi-line test
|
|
97
|
+
return False
|
|
98
|
+
if not _has_nested_breaker(node.body): # flattening would save nothing
|
|
99
|
+
return False
|
|
100
|
+
if _has_multiline_string(node.body): # blind dedent would corrupt string content
|
|
101
|
+
return False
|
|
102
|
+
return _indent_unit(node, source) > 0
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _has_nested_breaker(body: list[ast.stmt]) -> bool:
|
|
106
|
+
return any(isinstance(inner, _BREAKER_TYPES) for stmt in body for inner in ast.walk(stmt))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _has_multiline_string(body: list[ast.stmt]) -> bool:
|
|
110
|
+
"""True if any string / f-string literal in the body spans multiple lines.
|
|
111
|
+
|
|
112
|
+
Dedenting such a body line-by-line (``_dedent``) would strip leading spaces
|
|
113
|
+
that are *content* of the literal, silently changing its value — so the
|
|
114
|
+
guard is left untouched rather than risk corrupting source.
|
|
115
|
+
"""
|
|
116
|
+
return any(
|
|
117
|
+
isinstance(inner, (ast.Constant, ast.JoinedStr)) and inner.lineno != inner.end_lineno
|
|
118
|
+
for stmt in body
|
|
119
|
+
for inner in ast.walk(stmt)
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _leading_ws(line: str) -> str:
|
|
124
|
+
return line[: len(line) - len(line.lstrip())]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _indent_unit(node: ast.If, source: str) -> int:
|
|
128
|
+
"""Spaces the body sits below the ``if``; 0 if either uses tab indentation."""
|
|
129
|
+
lines = source.splitlines()
|
|
130
|
+
if_ws = _leading_ws(lines[node.lineno - 1])
|
|
131
|
+
body_ws = _leading_ws(lines[node.body[0].lineno - 1])
|
|
132
|
+
if "\t" in if_ws or "\t" in body_ws:
|
|
133
|
+
return 0
|
|
134
|
+
return len(body_ws) - len(if_ws)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _apply_guard(source: str, node: ast.If, keyword: str) -> str:
|
|
138
|
+
lines = source.splitlines(keepends=True)
|
|
139
|
+
header_idx = node.lineno - 1
|
|
140
|
+
end = node.end_lineno or node.lineno
|
|
141
|
+
header = lines[header_idx]
|
|
142
|
+
newline = "\r\n" if header.endswith("\r\n") else "\n"
|
|
143
|
+
if_indent = _leading_ws(header)
|
|
144
|
+
unit = _indent_unit(node, source)
|
|
145
|
+
body_indent = if_indent + " " * unit
|
|
146
|
+
condition = _inverted_condition(node.test, source)
|
|
147
|
+
new_header = f"{if_indent}if {condition}:{newline}{body_indent}{keyword}{newline}"
|
|
148
|
+
|
|
149
|
+
out = lines[:header_idx]
|
|
150
|
+
out.append(new_header)
|
|
151
|
+
out.extend(_dedent(lines[i], unit) for i in range(header_idx + 1, end))
|
|
152
|
+
out.extend(lines[end:])
|
|
153
|
+
return "".join(out)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _dedent(line: str, unit: int) -> str:
|
|
157
|
+
return line[unit:] if line[:unit] == " " * unit else line
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# Membership/identity comparisons whose negation is *guaranteed* to be another
|
|
161
|
+
# comparison by the language spec: ``x not in y`` is defined as ``not (x in y)``
|
|
162
|
+
# and ``x is not y`` as ``not (x is y)``. Ordering and equality operators are
|
|
163
|
+
# deliberately absent — ``not (x < y)`` differs from ``x >= y`` for NaN, and a
|
|
164
|
+
# class may define ``__eq__``/``__ne__`` inconsistently — so those keep the safe
|
|
165
|
+
# ``not (...)`` wrapper rather than risk a behaviour change.
|
|
166
|
+
_NEGATED_CMP: dict[type[ast.cmpop], str] = {
|
|
167
|
+
ast.In: "not in",
|
|
168
|
+
ast.NotIn: "in",
|
|
169
|
+
ast.Is: "is not",
|
|
170
|
+
ast.IsNot: "is",
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
# ``not X`` reassociates when ``X`` binds looser than ``not`` (``not a and b`` is
|
|
174
|
+
# ``(not a) and b``), so these constructs need the wrapping parens; calls, names,
|
|
175
|
+
# and attributes bind tighter and the parens would be redundant noise a formatter
|
|
176
|
+
# strips. ``Compare`` is included because the membership/identity cases that *can*
|
|
177
|
+
# be inverted cleanly are flipped earlier (in :func:`_flip_comparison`) and never
|
|
178
|
+
# reach here — only the ones kept as ``not (...)`` for safety (ordering, equality,
|
|
179
|
+
# chained) fall through, and ``not k in a in b`` without parens would still trip
|
|
180
|
+
# the very ``E713`` lint we are trying to avoid.
|
|
181
|
+
_NEEDS_PARENS = (ast.BoolOp, ast.IfExp, ast.Lambda, ast.NamedExpr, ast.Compare)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _inverted_condition(test: ast.expr, source: str) -> str:
|
|
185
|
+
if isinstance(test, ast.UnaryOp) and isinstance(test.op, ast.Not):
|
|
186
|
+
inner = ast.get_source_segment(source, test.operand)
|
|
187
|
+
if inner is not None:
|
|
188
|
+
return inner
|
|
189
|
+
flipped = _flip_comparison(test, source)
|
|
190
|
+
if flipped is not None:
|
|
191
|
+
return flipped
|
|
192
|
+
segment = ast.get_source_segment(source, test)
|
|
193
|
+
if isinstance(test, _NEEDS_PARENS):
|
|
194
|
+
return f"not ({segment})"
|
|
195
|
+
return f"not {segment}"
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _flip_comparison(test: ast.expr, source: str) -> str | None:
|
|
199
|
+
"""Negate a single membership/identity comparison by flipping its operator.
|
|
200
|
+
|
|
201
|
+
Returns ``None`` for anything that is not a one-operator ``in``/``is``
|
|
202
|
+
comparison — chained comparisons (``a in b in c``), other operators, and
|
|
203
|
+
non-comparisons fall back to the ``not (...)`` wrapper in the caller.
|
|
204
|
+
"""
|
|
205
|
+
if not isinstance(test, ast.Compare) or len(test.ops) != 1:
|
|
206
|
+
return None
|
|
207
|
+
operator = _NEGATED_CMP.get(type(test.ops[0]))
|
|
208
|
+
if operator is None:
|
|
209
|
+
return None
|
|
210
|
+
# Operands of a freshly-parsed comparison always carry position info, so
|
|
211
|
+
# get_source_segment never returns None here — same assumption the caller
|
|
212
|
+
# makes for the whole-test segment.
|
|
213
|
+
left = ast.get_source_segment(source, test.left)
|
|
214
|
+
right = ast.get_source_segment(source, test.comparators[0])
|
|
215
|
+
return f"{left} {operator} {right}"
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def atomic_write(path: Path, data: str) -> None:
|
|
219
|
+
"""Overwrite ``path`` with ``data`` atomically, preserving its file mode.
|
|
220
|
+
|
|
221
|
+
Writes to a temp file in the same directory, fsyncs it, then ``os.replace``s
|
|
222
|
+
it over ``path`` — atomic within a filesystem, so a crash mid-write leaves
|
|
223
|
+
the original file intact rather than truncated/half-written. The temp file is
|
|
224
|
+
removed if anything fails (including on interrupt). ``newline=""`` keeps the
|
|
225
|
+
data's line endings byte-for-byte (the transform may emit ``\\r\\n``).
|
|
226
|
+
"""
|
|
227
|
+
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp")
|
|
228
|
+
tmp_file = Path(tmp)
|
|
229
|
+
try:
|
|
230
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline="") as handle:
|
|
231
|
+
handle.write(data)
|
|
232
|
+
handle.flush()
|
|
233
|
+
os.fsync(handle.fileno())
|
|
234
|
+
tmp_file.chmod(path.stat().st_mode)
|
|
235
|
+
tmp_file.replace(path)
|
|
236
|
+
except BaseException:
|
|
237
|
+
tmp_file.unlink(missing_ok=True)
|
|
238
|
+
raise
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
"""cococo — code cognitive complexity, on the command line.
|
|
2
|
+
|
|
3
|
+
Scores every function and method in the given Python files/directories with
|
|
4
|
+
:func:`cognitive_complexity.api.get_cognitive_complexity` and prints them
|
|
5
|
+
worst-first. With ``--max`` it doubles as a gate: it exits non-zero when any
|
|
6
|
+
function exceeds the ceiling, reporting each offender with concrete refactor
|
|
7
|
+
suggestions.
|
|
8
|
+
|
|
9
|
+
Usage::
|
|
10
|
+
|
|
11
|
+
cococo src/ # list every function, worst first
|
|
12
|
+
cococo src/ --max 20 # gate: fail (with suggestions) above 20
|
|
13
|
+
cococo a.py b.py --min 10 # only show functions scoring >= 10
|
|
14
|
+
cococo src/ --max 20 --json # machine-readable report for a pipeline
|
|
15
|
+
cococo src/ --fix # apply safe guard-clause rewrites in place
|
|
16
|
+
cococo src/ --nested fold # pre-2.0.0 scoring (fold nested defs into parent)
|
|
17
|
+
cococo src/ --max 20 --baseline .cococo.json # ratchet: fail only on regressions
|
|
18
|
+
cococo --explain a.py::Klass.method # break down one function
|
|
19
|
+
cococo --explain a.py:42 # ...by line number
|
|
20
|
+
|
|
21
|
+
Exit codes in gate mode: 0 = within ceiling, 1 = offenders found, 2 = the gate
|
|
22
|
+
could not be trusted (nothing scanned, a file skipped, or a --fix write failed).
|
|
23
|
+
A function can suppress itself from the gate with a ``# cococo: ignore`` comment
|
|
24
|
+
on its ``def`` line.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import argparse
|
|
30
|
+
import json
|
|
31
|
+
import sys
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
from cognitive_complexity.api import Contribution, get_cognitive_complexity_breakdown
|
|
35
|
+
from cognitive_complexity.autofix import atomic_write, fix_source
|
|
36
|
+
from cognitive_complexity.common_types import AnyFuncdef, ScoredFunction, SkippedFile
|
|
37
|
+
from cognitive_complexity.discovery import find_function, iter_python_files, parse_target, scan
|
|
38
|
+
from cognitive_complexity.refactor import suggest_refactors
|
|
39
|
+
from cognitive_complexity.report import build_report, func_key, is_over, to_json
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class BaselineError(Exception):
|
|
43
|
+
"""The baseline file could not be trusted."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _format_breakdown(
|
|
47
|
+
funcdef: AnyFuncdef,
|
|
48
|
+
qualname: str,
|
|
49
|
+
path: Path,
|
|
50
|
+
breakdown: list[Contribution],
|
|
51
|
+
) -> str:
|
|
52
|
+
total = sum(c.points for c in breakdown)
|
|
53
|
+
lines = [f"{qualname} ({path}:{funcdef.lineno}) cognitive complexity = {total}"]
|
|
54
|
+
if not breakdown:
|
|
55
|
+
lines.append(" (no scored constructs — flat function)")
|
|
56
|
+
return "\n".join(lines)
|
|
57
|
+
lines.append(f" {'line':>6} {'pts':>3} {'nest':>4} construct")
|
|
58
|
+
for c in sorted(breakdown, key=lambda c: (c.lineno, -c.points)):
|
|
59
|
+
indent = " " * c.nesting
|
|
60
|
+
if c.nesting_counted and c.nesting:
|
|
61
|
+
note = f"(+{c.points - c.nesting} base, +{c.nesting} nesting)"
|
|
62
|
+
else:
|
|
63
|
+
note = f"(+{c.points})"
|
|
64
|
+
lines.append(f" {c.lineno:>6} {c.points:>3} {c.nesting:>4} {indent}{c.label} {note}")
|
|
65
|
+
return "\n".join(lines)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def explain(target: str, fold_nested: bool = False) -> int:
|
|
69
|
+
"""Print a per-construct cognitive-complexity breakdown for one function."""
|
|
70
|
+
path, qualname, lineno = parse_target(target)
|
|
71
|
+
try:
|
|
72
|
+
funcdef, qual = find_function(path, qualname, lineno, fold_nested)
|
|
73
|
+
except (LookupError, OSError, UnicodeDecodeError, SyntaxError, RecursionError) as exc:
|
|
74
|
+
print(f"cococo: {exc}", file=sys.stderr)
|
|
75
|
+
return 1
|
|
76
|
+
breakdown = get_cognitive_complexity_breakdown(funcdef, fold_nested)
|
|
77
|
+
print(_format_breakdown(funcdef, qual, path, breakdown))
|
|
78
|
+
return 0
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def main(argv: list[str] | None = None) -> int:
|
|
82
|
+
parser = argparse.ArgumentParser(prog="cococo", description=__doc__.splitlines()[0])
|
|
83
|
+
parser.add_argument("paths", nargs="*", help="Python files or directories to scan")
|
|
84
|
+
parser.add_argument(
|
|
85
|
+
"--max",
|
|
86
|
+
type=int,
|
|
87
|
+
default=None,
|
|
88
|
+
help="ceiling: exit non-zero and show only functions above it",
|
|
89
|
+
)
|
|
90
|
+
parser.add_argument(
|
|
91
|
+
"--min", type=int, default=0, help="only list functions scoring at least this much"
|
|
92
|
+
)
|
|
93
|
+
parser.add_argument(
|
|
94
|
+
"--explain",
|
|
95
|
+
metavar="FILE::QUAL",
|
|
96
|
+
default=None,
|
|
97
|
+
help="break down one function: FILE.py::qualname, FILE.py:LINE, or FILE.py",
|
|
98
|
+
)
|
|
99
|
+
parser.add_argument(
|
|
100
|
+
"--json",
|
|
101
|
+
dest="as_json",
|
|
102
|
+
action="store_true",
|
|
103
|
+
help="emit a machine-readable JSON report to stdout (for pipelines)",
|
|
104
|
+
)
|
|
105
|
+
parser.add_argument(
|
|
106
|
+
"--fix",
|
|
107
|
+
action="store_true",
|
|
108
|
+
help="apply safe guard-clause rewrites in place before reporting",
|
|
109
|
+
)
|
|
110
|
+
parser.add_argument(
|
|
111
|
+
"--nested",
|
|
112
|
+
choices=("unit", "fold"),
|
|
113
|
+
default="unit",
|
|
114
|
+
help="score named nested defs as their own units (default) or fold them "
|
|
115
|
+
"into the enclosing function (pre-2.0.0 compatibility)",
|
|
116
|
+
)
|
|
117
|
+
parser.add_argument(
|
|
118
|
+
"--baseline",
|
|
119
|
+
metavar="FILE",
|
|
120
|
+
default=None,
|
|
121
|
+
help="ratchet: record current scores to FILE if missing, else fail only on "
|
|
122
|
+
"functions that regress above their recorded score (requires --max)",
|
|
123
|
+
)
|
|
124
|
+
args = parser.parse_args(argv)
|
|
125
|
+
fold_nested = args.nested == "fold"
|
|
126
|
+
|
|
127
|
+
if args.explain is not None:
|
|
128
|
+
return explain(args.explain, fold_nested)
|
|
129
|
+
if not args.paths:
|
|
130
|
+
parser.error("the following arguments are required: paths (or use --explain)")
|
|
131
|
+
if args.baseline is not None and args.max is None:
|
|
132
|
+
parser.error("--baseline requires --max (the ceiling for code not in the baseline)")
|
|
133
|
+
|
|
134
|
+
fix_failures = _apply_fixes(args.paths) if args.fix else 0
|
|
135
|
+
|
|
136
|
+
functions, skipped, scanned = scan(args.paths, fold_nested)
|
|
137
|
+
try:
|
|
138
|
+
baseline, baseline_root = _baseline_for_scan(args.baseline, functions, skipped)
|
|
139
|
+
except BaselineError as exc:
|
|
140
|
+
print(f"cococo: {exc}", file=sys.stderr)
|
|
141
|
+
return 2
|
|
142
|
+
_warn_unused_ignores(functions, args.max)
|
|
143
|
+
scan_code = _scan_exit_code(
|
|
144
|
+
functions, skipped, scanned, args.max, args.as_json, args.min, baseline, baseline_root
|
|
145
|
+
)
|
|
146
|
+
# A failed --fix write, or a file skipped under a gate, is a hard failure (2)
|
|
147
|
+
# regardless of whether the functions that *did* scan stayed within --max.
|
|
148
|
+
if fix_failures or (skipped and args.max is not None):
|
|
149
|
+
return 2
|
|
150
|
+
return scan_code
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _baseline_for_scan(
|
|
154
|
+
raw_path: str | None,
|
|
155
|
+
functions: list[ScoredFunction],
|
|
156
|
+
skipped: list[SkippedFile],
|
|
157
|
+
) -> tuple[dict[str, int] | None, Path | None]:
|
|
158
|
+
"""Load/create the baseline only when the scan is trusted enough to do so."""
|
|
159
|
+
if raw_path is None:
|
|
160
|
+
return None, None
|
|
161
|
+
path = Path(raw_path)
|
|
162
|
+
root = path.parent
|
|
163
|
+
if not path.exists() and (not functions or skipped):
|
|
164
|
+
return None, root
|
|
165
|
+
return _load_or_create_baseline(path, functions), root
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _load_or_create_baseline(path: Path, functions: list[ScoredFunction]) -> dict[str, int]:
|
|
169
|
+
"""Load the baseline at ``path``, or create it from current scores and pass.
|
|
170
|
+
|
|
171
|
+
A missing file establishes the baseline (every current score recorded) so the
|
|
172
|
+
first run grandfathers the whole codebase; later runs compare against it.
|
|
173
|
+
"""
|
|
174
|
+
if path.exists():
|
|
175
|
+
try:
|
|
176
|
+
loaded = json.loads(path.read_text(encoding="utf-8"))
|
|
177
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
178
|
+
raise BaselineError(f"invalid baseline {path}: {exc}") from exc
|
|
179
|
+
return _validate_baseline(path, loaded)
|
|
180
|
+
baseline = {func_key(f, path.parent): f.score for f in functions}
|
|
181
|
+
try:
|
|
182
|
+
path.write_text(json.dumps(baseline, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
183
|
+
except OSError as exc:
|
|
184
|
+
raise BaselineError(f"invalid baseline {path}: {exc}") from exc
|
|
185
|
+
print(f"cococo: wrote baseline for {len(baseline)} function(s) to {path}", file=sys.stderr)
|
|
186
|
+
return baseline
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _validate_baseline(path: Path, loaded: object) -> dict[str, int]:
|
|
190
|
+
if not isinstance(loaded, dict) or any(
|
|
191
|
+
not isinstance(key, str) or type(value) is not int for key, value in loaded.items()
|
|
192
|
+
):
|
|
193
|
+
raise BaselineError(f"invalid baseline {path}: expected dict[str, int]")
|
|
194
|
+
return loaded
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _warn_unused_ignores(functions: list[ScoredFunction], max_: int | None) -> None:
|
|
198
|
+
"""Warn about ``# cococo: ignore`` directives on functions already within the gate."""
|
|
199
|
+
if max_ is None:
|
|
200
|
+
return
|
|
201
|
+
for f in functions:
|
|
202
|
+
if f.ignored and f.score <= max_:
|
|
203
|
+
print(
|
|
204
|
+
f"cococo: unused '# cococo: ignore' on {f.qualname} "
|
|
205
|
+
f"({f.path}:{f.lineno}) — score {f.score} is within {max_}",
|
|
206
|
+
file=sys.stderr,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _scan_exit_code(
|
|
211
|
+
functions: list[ScoredFunction],
|
|
212
|
+
skipped: list[SkippedFile],
|
|
213
|
+
scanned: int,
|
|
214
|
+
max_: int | None,
|
|
215
|
+
as_json: bool,
|
|
216
|
+
min_: int,
|
|
217
|
+
baseline: dict[str, int] | None,
|
|
218
|
+
baseline_root: Path | None,
|
|
219
|
+
) -> int:
|
|
220
|
+
if as_json:
|
|
221
|
+
return _report_json(functions, skipped, scanned, max_, min_, baseline, baseline_root)
|
|
222
|
+
if not functions:
|
|
223
|
+
return _empty_scan_exit(max_)
|
|
224
|
+
return _report(functions, max_, min_, baseline, baseline_root)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _empty_scan_exit(max_: int | None) -> int:
|
|
228
|
+
"""No functions matched the paths (text mode). Stay honest in gate mode.
|
|
229
|
+
|
|
230
|
+
A ``--max`` gate that scans zero functions (a typo'd path, a renamed dir, a
|
|
231
|
+
glob that expanded to nothing) is a misconfiguration, not a pass: it returns
|
|
232
|
+
a distinct code (2) so CI cannot go green on a gate that gated nothing.
|
|
233
|
+
Without ``--max`` an empty scan is merely informational (exit 0). (The
|
|
234
|
+
``--json`` empty case is handled by :func:`_report_json`, which still emits a
|
|
235
|
+
valid report.)
|
|
236
|
+
"""
|
|
237
|
+
print("cococo: no Python functions found", file=sys.stderr)
|
|
238
|
+
if max_ is not None:
|
|
239
|
+
print("cococo: no functions scanned — check the paths given to the gate", file=sys.stderr)
|
|
240
|
+
return 2
|
|
241
|
+
return 0
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _shown(functions: list[ScoredFunction], max_: int | None, min_: int) -> list[ScoredFunction]:
|
|
245
|
+
threshold = max_ if max_ is not None else min_
|
|
246
|
+
return sorted(
|
|
247
|
+
(f for f in functions if f.score > threshold or (max_ is None and f.score >= min_)),
|
|
248
|
+
key=lambda f: f.score,
|
|
249
|
+
reverse=True,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _apply_fixes(paths: list[str]) -> int:
|
|
254
|
+
"""Rewrite safe guard-clause patterns in place; return the write-failure count.
|
|
255
|
+
|
|
256
|
+
Each changed file is written atomically (a crash can't truncate the
|
|
257
|
+
original), and both read/parse errors and write errors are recorded-and-
|
|
258
|
+
skipped so one bad file never aborts the batch. Per-file outcomes go to
|
|
259
|
+
stderr; the returned count lets the caller fail the exit code when a write
|
|
260
|
+
did not land.
|
|
261
|
+
"""
|
|
262
|
+
changed = 0
|
|
263
|
+
applied = 0
|
|
264
|
+
failed = 0
|
|
265
|
+
for path in iter_python_files(paths):
|
|
266
|
+
try:
|
|
267
|
+
source = path.read_text(encoding="utf-8")
|
|
268
|
+
new_source, count = fix_source(source)
|
|
269
|
+
except (OSError, UnicodeDecodeError, SyntaxError, RecursionError):
|
|
270
|
+
continue
|
|
271
|
+
if not count:
|
|
272
|
+
continue
|
|
273
|
+
try:
|
|
274
|
+
atomic_write(path, new_source)
|
|
275
|
+
except OSError as exc:
|
|
276
|
+
print(f"cococo: FAILED to write {path}: {exc}", file=sys.stderr)
|
|
277
|
+
failed += 1
|
|
278
|
+
continue
|
|
279
|
+
print(f"cococo: fixed {path} ({count} guard(s))", file=sys.stderr)
|
|
280
|
+
changed += 1
|
|
281
|
+
applied += count
|
|
282
|
+
print(
|
|
283
|
+
f"cococo: applied {applied} guard-clause fix(es) across {changed} file(s)",
|
|
284
|
+
file=sys.stderr,
|
|
285
|
+
)
|
|
286
|
+
return failed
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _report(
|
|
290
|
+
functions: list[ScoredFunction],
|
|
291
|
+
max_: int | None,
|
|
292
|
+
min_: int,
|
|
293
|
+
baseline: dict[str, int] | None,
|
|
294
|
+
baseline_root: Path | None,
|
|
295
|
+
) -> int:
|
|
296
|
+
"""Print the scored functions and, when ``max_`` is set, gate on it."""
|
|
297
|
+
for f in _shown(functions, max_, min_):
|
|
298
|
+
print(f"{f.score:4d} {f.path}:{f.lineno} {f.qualname}")
|
|
299
|
+
|
|
300
|
+
if max_ is None:
|
|
301
|
+
return 0
|
|
302
|
+
over = [f for f in functions if is_over(f, max_, baseline, baseline_root)]
|
|
303
|
+
if over:
|
|
304
|
+
_print_gate_failure(over, max_)
|
|
305
|
+
return 1
|
|
306
|
+
print(f"cococo: all {len(functions)} functions within cognitive complexity {max_}")
|
|
307
|
+
return 0
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _report_json(
|
|
311
|
+
functions: list[ScoredFunction],
|
|
312
|
+
skipped: list[SkippedFile],
|
|
313
|
+
scanned: int,
|
|
314
|
+
max_: int | None,
|
|
315
|
+
min_: int,
|
|
316
|
+
baseline: dict[str, int] | None,
|
|
317
|
+
baseline_root: Path | None,
|
|
318
|
+
) -> int:
|
|
319
|
+
report = build_report(
|
|
320
|
+
_shown(functions, max_, min_),
|
|
321
|
+
max_,
|
|
322
|
+
min_,
|
|
323
|
+
skipped,
|
|
324
|
+
scanned,
|
|
325
|
+
baseline,
|
|
326
|
+
baseline_root,
|
|
327
|
+
)
|
|
328
|
+
print(to_json(report))
|
|
329
|
+
if not functions and max_ is not None:
|
|
330
|
+
return 2 # gate scanned nothing — fail loud even in JSON mode
|
|
331
|
+
return 1 if max_ is not None and report["exceeded"] else 0
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _print_gate_failure(over: list[ScoredFunction], max_: int) -> None:
|
|
335
|
+
print(
|
|
336
|
+
f"\ncococo: {len(over)} function(s) exceed cognitive complexity {max_}",
|
|
337
|
+
file=sys.stderr,
|
|
338
|
+
)
|
|
339
|
+
for f in sorted(over, key=lambda f: f.score, reverse=True):
|
|
340
|
+
_print_suggestions(f, max_)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _print_suggestions(f: ScoredFunction, max_: int) -> None:
|
|
344
|
+
suggestions = suggest_refactors(f.funcdef, f.breakdown)
|
|
345
|
+
print(f" {f.path}:{f.lineno} {f.qualname} = {f.score} (>{max_})", file=sys.stderr)
|
|
346
|
+
if not suggestions:
|
|
347
|
+
print(" (no mechanical refactor found; split it by responsibility)", file=sys.stderr)
|
|
348
|
+
return
|
|
349
|
+
for s in suggestions:
|
|
350
|
+
fix = " [--fix]" if s.autofixable else ""
|
|
351
|
+
print(
|
|
352
|
+
f" - {s.title} "
|
|
353
|
+
f"(lines {s.line_start}-{s.line_end}, ~-{s.estimated_reduction} "
|
|
354
|
+
f"-> {s.estimated_complexity_after}){fix}",
|
|
355
|
+
file=sys.stderr,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
if __name__ == "__main__": # pragma: no cover
|
|
360
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import TYPE_CHECKING, NamedTuple, TypeGuard
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from cognitive_complexity.api import Contribution
|
|
9
|
+
|
|
10
|
+
AnyFuncdef = ast.FunctionDef | ast.AsyncFunctionDef
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def is_funcdef(node: ast.AST) -> TypeGuard[AnyFuncdef]:
|
|
14
|
+
"""True if ``node`` is a (possibly async) function definition.
|
|
15
|
+
|
|
16
|
+
One definition of "scorable function unit" for every tree-walker; the
|
|
17
|
+
``TypeGuard`` return preserves type narrowing at the call sites (e.g. reading
|
|
18
|
+
``node.name``) under strict mypy.
|
|
19
|
+
"""
|
|
20
|
+
return isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ScoredFunction(NamedTuple):
|
|
24
|
+
"""A scored function plus the node it was scored from (for breakdowns/fixes).
|
|
25
|
+
|
|
26
|
+
``breakdown`` is the per-construct scoring computed once at discovery time;
|
|
27
|
+
``score`` is its points sum. Carrying it avoids a second walk in the JSON
|
|
28
|
+
report and gate-suggestion paths. ``ignored`` is true when the function's
|
|
29
|
+
``def`` line carries a ``# cococo: ignore`` directive, which excludes it from
|
|
30
|
+
the ``--max`` gate.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
score: int
|
|
34
|
+
path: Path
|
|
35
|
+
lineno: int
|
|
36
|
+
qualname: str
|
|
37
|
+
funcdef: AnyFuncdef
|
|
38
|
+
breakdown: list[Contribution]
|
|
39
|
+
ignored: bool = False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class SkippedFile(NamedTuple):
|
|
43
|
+
"""A file the scanner could not read, parse, or score, with the reason why."""
|
|
44
|
+
|
|
45
|
+
path: Path
|
|
46
|
+
reason: str
|