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,193 @@
|
|
|
1
|
+
"""Function discovery and scoring: path walking, AST traversal, qualname construction.
|
|
2
|
+
|
|
3
|
+
Library-level entry points for enumerating and scoring Python functions from
|
|
4
|
+
files and directories, without the CLI presentation layer.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import ast
|
|
10
|
+
import io
|
|
11
|
+
import sys
|
|
12
|
+
import tokenize
|
|
13
|
+
from collections.abc import Iterator
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from cognitive_complexity.api import get_cognitive_complexity_breakdown
|
|
17
|
+
from cognitive_complexity.common_types import AnyFuncdef, ScoredFunction, SkippedFile, is_funcdef
|
|
18
|
+
|
|
19
|
+
_IGNORE_DIRECTIVE = "cococo: ignore"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def iter_python_files(paths: list[str]) -> Iterator[Path]:
|
|
23
|
+
for raw in paths:
|
|
24
|
+
path = Path(raw)
|
|
25
|
+
if path.is_dir():
|
|
26
|
+
yield from sorted(path.rglob("*.py"))
|
|
27
|
+
elif path.suffix == ".py":
|
|
28
|
+
yield path
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _collect(
|
|
32
|
+
node: ast.AST,
|
|
33
|
+
qualifier: str,
|
|
34
|
+
out: list[tuple[AnyFuncdef, str]],
|
|
35
|
+
fold_nested: bool = False,
|
|
36
|
+
) -> None:
|
|
37
|
+
"""Discover the functions to score under ``node``.
|
|
38
|
+
|
|
39
|
+
``qualifier`` is the enclosing-name prefix threaded down the recursion: a
|
|
40
|
+
class extends it with ``Klass.`` and a named def extends it with
|
|
41
|
+
``name.<locals>.`` before recursing, so nested defs report as
|
|
42
|
+
``outer.<locals>.inner`` and method-local defs keep the class
|
|
43
|
+
(``Klass.method.<locals>.inner``). By default nested functions are their own
|
|
44
|
+
units (the recursion descends into them). In fold mode (pre-2.0.0 compat)
|
|
45
|
+
nested defs are *not* listed separately — they fold into the enclosing
|
|
46
|
+
function's score — so the recursion does not descend into them.
|
|
47
|
+
"""
|
|
48
|
+
for child in ast.iter_child_nodes(node):
|
|
49
|
+
if is_funcdef(child):
|
|
50
|
+
qualname = f"{qualifier}{child.name}"
|
|
51
|
+
out.append((child, qualname))
|
|
52
|
+
if not fold_nested:
|
|
53
|
+
_collect(child, f"{qualname}.<locals>.", out, fold_nested)
|
|
54
|
+
elif isinstance(child, ast.ClassDef):
|
|
55
|
+
_collect(child, f"{qualifier}{child.name}.", out, fold_nested)
|
|
56
|
+
else:
|
|
57
|
+
_collect(child, qualifier, out, fold_nested)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def scan(
|
|
61
|
+
paths: list[str], fold_nested: bool = False
|
|
62
|
+
) -> tuple[list[ScoredFunction], list[SkippedFile], int]:
|
|
63
|
+
"""Score every function under ``paths``; also return skipped files and scan count.
|
|
64
|
+
|
|
65
|
+
A file that cannot be read, parsed, or scored is reported on stderr and
|
|
66
|
+
recorded as skipped — never silently dropped — so the caller can fail a
|
|
67
|
+
``--max`` gate and the JSON report can expose coverage, rather than launder a
|
|
68
|
+
partial scan as clean. ``files_scanned`` counts the files that parsed.
|
|
69
|
+
"""
|
|
70
|
+
files = list(iter_python_files(paths))
|
|
71
|
+
outcomes = [_score_or_skip(path, fold_nested) for path in files]
|
|
72
|
+
results = [func for scored, _ in outcomes for func in scored]
|
|
73
|
+
skipped = [info for _, info in outcomes if info is not None]
|
|
74
|
+
return results, skipped, len(files) - len(skipped)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _score_or_skip(
|
|
78
|
+
path: Path, fold_nested: bool
|
|
79
|
+
) -> tuple[list[ScoredFunction], SkippedFile | None]:
|
|
80
|
+
"""Score one file, or report+record it as skipped on any unscoreable failure.
|
|
81
|
+
|
|
82
|
+
Returns ``(scored, None)`` on success or ``([], SkippedFile)`` on failure.
|
|
83
|
+
Catches read/parse errors and ``RecursionError`` from a pathologically deep
|
|
84
|
+
AST (a crafted subscript chain, a huge ``elif`` ladder) so one bad file is
|
|
85
|
+
skipped loudly rather than aborting the whole run.
|
|
86
|
+
"""
|
|
87
|
+
try:
|
|
88
|
+
return _score_file(path, fold_nested), None
|
|
89
|
+
except (OSError, UnicodeDecodeError, SyntaxError, RecursionError) as exc:
|
|
90
|
+
reason = f"{type(exc).__name__}: {exc}"
|
|
91
|
+
print(f"cococo: skipped {path}: {reason}", file=sys.stderr)
|
|
92
|
+
return [], SkippedFile(path, reason)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _parse_file(path: Path) -> tuple[str, ast.Module]:
|
|
96
|
+
"""Read ``path`` as UTF-8 and parse it, returning ``(source, tree)``.
|
|
97
|
+
|
|
98
|
+
The single read+parse site shared by the scanner and ``--explain``; it raises
|
|
99
|
+
``OSError`` / ``UnicodeDecodeError`` / ``SyntaxError`` for the caller to map
|
|
100
|
+
to a skip or a clean message — one policy, not three drifting copies. The
|
|
101
|
+
source text comes back too so the scanner can read ``# cococo: ignore``
|
|
102
|
+
directives (comments are not in the AST).
|
|
103
|
+
"""
|
|
104
|
+
source = path.read_text(encoding="utf-8")
|
|
105
|
+
return source, ast.parse(source, filename=str(path))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _ignored_lines(source: str) -> set[int]:
|
|
109
|
+
"""Line numbers carrying a ``# cococo: ignore`` comment.
|
|
110
|
+
|
|
111
|
+
``source`` has already parsed cleanly (the caller parsed it first), so
|
|
112
|
+
tokenizing it raises nothing; only real comment tokens are matched, so the
|
|
113
|
+
directive text appearing inside a string literal does not count.
|
|
114
|
+
"""
|
|
115
|
+
return {
|
|
116
|
+
tok.start[0]
|
|
117
|
+
for tok in tokenize.generate_tokens(io.StringIO(source).readline)
|
|
118
|
+
if tok.type == tokenize.COMMENT and _IGNORE_DIRECTIVE in tok.string
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _score_file(path: Path, fold_nested: bool) -> list[ScoredFunction]:
|
|
123
|
+
"""Parse and score every function in one file (raises on read/parse/score failure)."""
|
|
124
|
+
source, tree = _parse_file(path)
|
|
125
|
+
ignore = _ignored_lines(source)
|
|
126
|
+
funcs: list[tuple[AnyFuncdef, str]] = []
|
|
127
|
+
_collect(tree, "", funcs, fold_nested)
|
|
128
|
+
return [_score_one(funcdef, qualname, path, fold_nested, ignore) for funcdef, qualname in funcs]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _score_one(
|
|
132
|
+
funcdef: AnyFuncdef, qualname: str, path: Path, fold_nested: bool, ignore: set[int]
|
|
133
|
+
) -> ScoredFunction:
|
|
134
|
+
# Compute the breakdown once and carry it on the result; the JSON report and
|
|
135
|
+
# gate-suggestion paths read ``.breakdown`` instead of re-walking the tree
|
|
136
|
+
# (the scalar score is just its points sum).
|
|
137
|
+
breakdown = get_cognitive_complexity_breakdown(funcdef, fold_nested)
|
|
138
|
+
score = sum(c.points for c in breakdown)
|
|
139
|
+
return ScoredFunction(
|
|
140
|
+
score, path, funcdef.lineno, qualname, funcdef, breakdown, funcdef.lineno in ignore
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def scored_functions(paths: list[str], fold_nested: bool = False) -> list[ScoredFunction]:
|
|
145
|
+
"""Score every function found under ``paths``, keeping its AST node."""
|
|
146
|
+
return scan(paths, fold_nested)[0]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def parse_target(target: str) -> tuple[Path, str | None, int | None]:
|
|
150
|
+
"""Split ``file.py::qualname`` / ``file.py:lineno`` / ``file.py`` into parts.
|
|
151
|
+
|
|
152
|
+
Returns ``(path, qualname, lineno)`` with exactly one of qualname/lineno set
|
|
153
|
+
(or both ``None`` to mean "the only function in the file").
|
|
154
|
+
"""
|
|
155
|
+
if "::" in target:
|
|
156
|
+
raw, _, qual = target.partition("::")
|
|
157
|
+
return Path(raw), qual, None
|
|
158
|
+
head, sep, tail = target.rpartition(":")
|
|
159
|
+
if sep and tail.isdigit() and head.endswith(".py"):
|
|
160
|
+
return Path(head), None, int(tail)
|
|
161
|
+
return Path(target), None, None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def find_function(
|
|
165
|
+
path: Path,
|
|
166
|
+
qualname: str | None,
|
|
167
|
+
lineno: int | None,
|
|
168
|
+
fold_nested: bool = False,
|
|
169
|
+
) -> tuple[AnyFuncdef, str]:
|
|
170
|
+
"""Locate one function in ``path`` by qualname or line number.
|
|
171
|
+
|
|
172
|
+
With neither selector, the file must contain exactly one function.
|
|
173
|
+
"""
|
|
174
|
+
_, tree = _parse_file(path)
|
|
175
|
+
funcs: list[tuple[AnyFuncdef, str]] = []
|
|
176
|
+
_collect(tree, "", funcs, fold_nested)
|
|
177
|
+
if not funcs:
|
|
178
|
+
raise LookupError(f"no functions found in {path}")
|
|
179
|
+
if qualname is not None:
|
|
180
|
+
matches = [f for f in funcs if f[1] == qualname]
|
|
181
|
+
if not matches:
|
|
182
|
+
known = ", ".join(sorted(q for _, q in funcs))
|
|
183
|
+
raise LookupError(f"no function {qualname!r} in {path}; found: {known}")
|
|
184
|
+
return matches[0]
|
|
185
|
+
if lineno is not None:
|
|
186
|
+
matches = [f for f in funcs if f[0].lineno == lineno]
|
|
187
|
+
if not matches:
|
|
188
|
+
raise LookupError(f"no function defined on line {lineno} of {path}")
|
|
189
|
+
return matches[0]
|
|
190
|
+
if len(funcs) != 1:
|
|
191
|
+
known = ", ".join(sorted(q for _, q in funcs))
|
|
192
|
+
raise LookupError(f"{path} has {len(funcs)} functions; name one (file.py::qual): {known}")
|
|
193
|
+
return funcs[0]
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
"""Heuristic refactor suggestions derived from the complexity breakdown.
|
|
2
|
+
|
|
3
|
+
Clean-room: the region model, thresholds, and reduction estimates here are our
|
|
4
|
+
own. Each suggestion reads the same per-construct :class:`Contribution` data the
|
|
5
|
+
scorer emits, so its estimated drop stays consistent with the reported score,
|
|
6
|
+
and names one concrete, mechanical refactor. Designed to be actionable for a
|
|
7
|
+
human or an agent reading a failing ``--max`` gate.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import ast
|
|
13
|
+
from typing import NamedTuple
|
|
14
|
+
|
|
15
|
+
from cognitive_complexity.api import Contribution
|
|
16
|
+
from cognitive_complexity.common_types import AnyFuncdef, is_funcdef
|
|
17
|
+
|
|
18
|
+
# Heuristic thresholds (ours, not part of Campbell's metric). Tuned to surface
|
|
19
|
+
# only refactors that meaningfully cut the score; adjust here, in one place.
|
|
20
|
+
_MIN_REDUCTION = 2 # ignore suggestions that barely move the score
|
|
21
|
+
_MAX_SUGGESTIONS = 3 # keep the report focused on the biggest wins
|
|
22
|
+
_EXTRACT_MIN_POINTS = 6 # a block heavy enough to pull into its own helper
|
|
23
|
+
_EXTRACT_MIN_LINES = 5
|
|
24
|
+
_MAX_COUPLING = 4 # max number of variables crossing the boundary before extraction is harmful
|
|
25
|
+
_DISPATCH_MIN_ARMS = 3 # elif arms before "split into a dispatch table"
|
|
26
|
+
_DISPATCH_MIN_CASES = 4 # match cases, ditto
|
|
27
|
+
_PREDICATE_MIN_POINTS = 2 # a boolean expression worth naming
|
|
28
|
+
|
|
29
|
+
_REGION_TYPES = (
|
|
30
|
+
ast.If,
|
|
31
|
+
ast.For,
|
|
32
|
+
ast.AsyncFor,
|
|
33
|
+
ast.While,
|
|
34
|
+
ast.Try,
|
|
35
|
+
ast.With,
|
|
36
|
+
ast.AsyncWith,
|
|
37
|
+
ast.Match,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
_STEPS: dict[str, tuple[str, ...]] = {
|
|
41
|
+
"guard_clause": (
|
|
42
|
+
"Invert the condition and return/continue early when it fails.",
|
|
43
|
+
"De-indent the main path one level.",
|
|
44
|
+
"Repeat for any further nested guards.",
|
|
45
|
+
),
|
|
46
|
+
"extract_helper": (
|
|
47
|
+
"Move this block into a small named helper.",
|
|
48
|
+
"Pass the values it reads as parameters.",
|
|
49
|
+
"Return what the caller needs.",
|
|
50
|
+
),
|
|
51
|
+
"split_dispatcher": (
|
|
52
|
+
"Map each case to a named handler function.",
|
|
53
|
+
"Replace the chain with a dispatch dict (or a thin delegating match).",
|
|
54
|
+
"Keep this function a shallow orchestrator.",
|
|
55
|
+
),
|
|
56
|
+
"extract_predicate": (
|
|
57
|
+
"Move this boolean expression into a well-named predicate function.",
|
|
58
|
+
"Call the predicate in the condition.",
|
|
59
|
+
"Keep the control flow here focused on branching.",
|
|
60
|
+
),
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
_TITLES: dict[str, str] = {
|
|
64
|
+
"guard_clause": "Flatten nested block with a guard clause",
|
|
65
|
+
"extract_helper": "Extract this block into a helper function",
|
|
66
|
+
"split_dispatcher": "Replace the branch ladder with a dispatch table",
|
|
67
|
+
"extract_predicate": "Name this complex condition as a predicate",
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Suggestion(NamedTuple):
|
|
72
|
+
"""One concrete, mechanical refactor for a too-complex function.
|
|
73
|
+
|
|
74
|
+
``estimated_reduction`` is how many points the score should drop if applied;
|
|
75
|
+
``estimated_complexity_after`` is the function total minus that. ``kind`` is a
|
|
76
|
+
stable machine id (see :data:`_TITLES`); ``autofixable`` flags the kinds the
|
|
77
|
+
``--fix`` rewriter can apply on its own.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
kind: str
|
|
81
|
+
title: str
|
|
82
|
+
line_start: int
|
|
83
|
+
line_end: int
|
|
84
|
+
estimated_reduction: int
|
|
85
|
+
estimated_complexity_after: int
|
|
86
|
+
autofixable: bool
|
|
87
|
+
steps: tuple[str, ...]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def suggest_refactors(funcdef: AnyFuncdef, breakdown: list[Contribution]) -> list[Suggestion]:
|
|
91
|
+
"""Up to a few high-value refactors for ``funcdef``, biggest reduction first."""
|
|
92
|
+
total = sum(c.points for c in breakdown)
|
|
93
|
+
candidates: list[Suggestion] = []
|
|
94
|
+
candidates += _guard_suggestions(funcdef, breakdown, total)
|
|
95
|
+
candidates += _predicate_suggestions(funcdef, breakdown, total)
|
|
96
|
+
for region in _iter_regions(funcdef):
|
|
97
|
+
candidates += _region_suggestions(funcdef, region, breakdown, total)
|
|
98
|
+
return _select(candidates)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _make(kind: str, start: int, end: int, reduction: int, total: int) -> Suggestion:
|
|
102
|
+
return Suggestion(
|
|
103
|
+
kind=kind,
|
|
104
|
+
title=_TITLES[kind],
|
|
105
|
+
line_start=start,
|
|
106
|
+
line_end=end,
|
|
107
|
+
estimated_reduction=reduction,
|
|
108
|
+
estimated_complexity_after=max(0, total - reduction),
|
|
109
|
+
autofixable=kind == "guard_clause",
|
|
110
|
+
steps=_STEPS[kind],
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _iter_regions(node: ast.AST) -> list[ast.stmt]:
|
|
115
|
+
"""Control-flow regions in the function, not descending into nested defs.
|
|
116
|
+
|
|
117
|
+
Nested ``def``/``async def`` are independent units, so their inner control
|
|
118
|
+
flow is left out of this function's region list.
|
|
119
|
+
"""
|
|
120
|
+
regions: list[ast.stmt] = []
|
|
121
|
+
for child in ast.iter_child_nodes(node):
|
|
122
|
+
if is_funcdef(child):
|
|
123
|
+
continue
|
|
124
|
+
if isinstance(child, _REGION_TYPES):
|
|
125
|
+
regions.append(child)
|
|
126
|
+
regions.extend(_iter_regions(child))
|
|
127
|
+
return regions
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _subtree_points(breakdown: list[Contribution], start: int, end: int) -> int:
|
|
131
|
+
return sum(c.points for c in breakdown if start <= c.lineno <= end)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _guard_suggestions(
|
|
135
|
+
funcdef: AnyFuncdef, breakdown: list[Contribution], total: int
|
|
136
|
+
) -> list[Suggestion]:
|
|
137
|
+
out: list[Suggestion] = []
|
|
138
|
+
for node in _iter_regions(funcdef):
|
|
139
|
+
if not isinstance(node, ast.If) or node.orelse or not node.body:
|
|
140
|
+
continue
|
|
141
|
+
body_start = node.body[0].lineno
|
|
142
|
+
end = node.end_lineno or body_start
|
|
143
|
+
saved = sum(
|
|
144
|
+
1
|
|
145
|
+
for c in breakdown
|
|
146
|
+
if body_start <= c.lineno <= end and c.nesting_counted and c.lineno != node.lineno
|
|
147
|
+
)
|
|
148
|
+
if saved:
|
|
149
|
+
out.append(_make("guard_clause", node.lineno, end, saved, total))
|
|
150
|
+
return out
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _region_suggestions(
|
|
154
|
+
funcdef: AnyFuncdef, region: ast.stmt, breakdown: list[Contribution], total: int
|
|
155
|
+
) -> list[Suggestion]:
|
|
156
|
+
start = region.lineno
|
|
157
|
+
end = region.end_lineno or start
|
|
158
|
+
out: list[Suggestion] = []
|
|
159
|
+
points = _subtree_points(breakdown, start, end)
|
|
160
|
+
# ``points < total`` keeps us from advising "extract the whole function" when
|
|
161
|
+
# one region spans the entire body; that region carries every point.
|
|
162
|
+
big_enough = points >= _EXTRACT_MIN_POINTS and (end - start + 1) >= _EXTRACT_MIN_LINES
|
|
163
|
+
if (
|
|
164
|
+
big_enough
|
|
165
|
+
and points < total
|
|
166
|
+
and _is_extractable_region(region)
|
|
167
|
+
and _analyze_coupling(funcdef, region) <= _MAX_COUPLING
|
|
168
|
+
):
|
|
169
|
+
out.append(_make("extract_helper", start, end, points, total))
|
|
170
|
+
dispatch = _dispatch_reduction(region)
|
|
171
|
+
if dispatch:
|
|
172
|
+
out.append(_make("split_dispatcher", start, end, dispatch, total))
|
|
173
|
+
return out
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _is_extractable_region(region: ast.stmt) -> bool:
|
|
177
|
+
"""Whether a region is a reasonable candidate for a plain helper extraction."""
|
|
178
|
+
if any(
|
|
179
|
+
isinstance(node, (ast.Break, ast.Continue, ast.Return, ast.Yield, ast.YieldFrom))
|
|
180
|
+
for node in ast.walk(region)
|
|
181
|
+
):
|
|
182
|
+
return False
|
|
183
|
+
return _attribute_mutation_count(region) <= _MAX_COUPLING
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _attribute_mutation_count(region: ast.stmt) -> int:
|
|
187
|
+
attrs: set[str] = set()
|
|
188
|
+
for node in ast.walk(region):
|
|
189
|
+
for target in _mutation_targets(node):
|
|
190
|
+
attrs.update(_stored_attribute_keys(target))
|
|
191
|
+
return len(attrs)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _mutation_targets(node: ast.AST) -> list[ast.AST]:
|
|
195
|
+
if isinstance(node, ast.Assign):
|
|
196
|
+
return list(node.targets)
|
|
197
|
+
if isinstance(node, ast.AnnAssign | ast.AugAssign | ast.NamedExpr):
|
|
198
|
+
return [node.target]
|
|
199
|
+
return []
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _stored_attribute_keys(node: ast.AST) -> set[str]:
|
|
203
|
+
if isinstance(node, ast.Attribute):
|
|
204
|
+
return {ast.unparse(node)}
|
|
205
|
+
if isinstance(node, ast.Tuple | ast.List):
|
|
206
|
+
return {key for item in node.elts for key in _stored_attribute_keys(item)}
|
|
207
|
+
return set()
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _analyze_coupling(funcdef: AnyFuncdef, region: ast.stmt) -> int:
|
|
211
|
+
defined_before = {node.arg for node in ast.walk(funcdef.args) if isinstance(node, ast.arg)}
|
|
212
|
+
used_after: set[str] = set()
|
|
213
|
+
region_loads: set[str] = set()
|
|
214
|
+
region_stores: set[str] = set()
|
|
215
|
+
buckets = {
|
|
216
|
+
"defined_before": defined_before,
|
|
217
|
+
"used_after": used_after,
|
|
218
|
+
"region_loads": region_loads,
|
|
219
|
+
"region_stores": region_stores,
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
start = region.lineno
|
|
223
|
+
end = region.end_lineno or start
|
|
224
|
+
|
|
225
|
+
for node in ast.walk(funcdef):
|
|
226
|
+
role = _name_coupling_role(node, start, end)
|
|
227
|
+
if role is not None:
|
|
228
|
+
bucket, name = role
|
|
229
|
+
buckets[bucket].add(name)
|
|
230
|
+
|
|
231
|
+
inputs = region_loads & defined_before
|
|
232
|
+
outputs = region_stores & used_after
|
|
233
|
+
return len(inputs) + len(outputs)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _name_coupling_role(node: ast.AST, start: int, end: int) -> tuple[str, str] | None:
|
|
237
|
+
if not isinstance(node, ast.Name):
|
|
238
|
+
return None
|
|
239
|
+
lineno = node.lineno
|
|
240
|
+
if lineno < start and isinstance(node.ctx, ast.Store):
|
|
241
|
+
return "defined_before", node.id
|
|
242
|
+
if lineno > end and isinstance(node.ctx, ast.Load):
|
|
243
|
+
return "used_after", node.id
|
|
244
|
+
if start <= lineno <= end and isinstance(node.ctx, ast.Load):
|
|
245
|
+
return "region_loads", node.id
|
|
246
|
+
if start <= lineno <= end and isinstance(node.ctx, ast.Store):
|
|
247
|
+
return "region_stores", node.id
|
|
248
|
+
return None
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _dispatch_reduction(region: ast.stmt) -> int:
|
|
252
|
+
if isinstance(region, ast.If):
|
|
253
|
+
arms = _elif_arms(region) if _is_simple_equality_chain(region) else 0
|
|
254
|
+
return arms if arms >= _DISPATCH_MIN_ARMS else 0
|
|
255
|
+
if isinstance(region, ast.Match):
|
|
256
|
+
if _has_structural_patterns(region):
|
|
257
|
+
return 0
|
|
258
|
+
cases = len(region.cases)
|
|
259
|
+
return cases - 1 if cases >= _DISPATCH_MIN_CASES else 0
|
|
260
|
+
return 0
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _is_simple_equality_chain(node: ast.If) -> bool:
|
|
264
|
+
tests = _if_chain_tests(node)
|
|
265
|
+
subject: str | None = None
|
|
266
|
+
for test in tests:
|
|
267
|
+
parsed = _simple_equality_test(test)
|
|
268
|
+
if parsed is None:
|
|
269
|
+
return False
|
|
270
|
+
current_subject, _key = parsed
|
|
271
|
+
if subject is None:
|
|
272
|
+
subject = current_subject
|
|
273
|
+
elif current_subject != subject:
|
|
274
|
+
return False
|
|
275
|
+
return subject is not None
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _if_chain_tests(node: ast.If) -> list[ast.expr]:
|
|
279
|
+
tests = [node.test]
|
|
280
|
+
current = node
|
|
281
|
+
while len(current.orelse) == 1 and isinstance(current.orelse[0], ast.If):
|
|
282
|
+
current = current.orelse[0]
|
|
283
|
+
tests.append(current.test)
|
|
284
|
+
return tests
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _simple_equality_test(test: ast.expr) -> tuple[str, object] | None:
|
|
288
|
+
if not (
|
|
289
|
+
isinstance(test, ast.Compare)
|
|
290
|
+
and len(test.ops) == 1
|
|
291
|
+
and isinstance(test.ops[0], ast.Eq)
|
|
292
|
+
and len(test.comparators) == 1
|
|
293
|
+
):
|
|
294
|
+
return None
|
|
295
|
+
left = _dispatch_subject(test.left)
|
|
296
|
+
right = _dispatch_subject(test.comparators[0])
|
|
297
|
+
left_key = _dispatch_key(test.left)
|
|
298
|
+
right_key = _dispatch_key(test.comparators[0])
|
|
299
|
+
if left is not None and right_key is not None:
|
|
300
|
+
return left, right_key
|
|
301
|
+
if right is not None and left_key is not None:
|
|
302
|
+
return right, left_key
|
|
303
|
+
return None
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _dispatch_subject(node: ast.AST) -> str | None:
|
|
307
|
+
if isinstance(node, ast.Name | ast.Attribute | ast.Subscript):
|
|
308
|
+
return ast.unparse(node)
|
|
309
|
+
return None
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _dispatch_key(node: ast.AST) -> object | None:
|
|
313
|
+
if isinstance(node, ast.Constant) and isinstance(
|
|
314
|
+
node.value, (str, int, float, bool, bytes, type(None))
|
|
315
|
+
):
|
|
316
|
+
return node.value
|
|
317
|
+
return None
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _has_structural_patterns(node: ast.Match) -> bool:
|
|
321
|
+
for case in node.cases:
|
|
322
|
+
if case.guard is not None:
|
|
323
|
+
return True
|
|
324
|
+
if not _is_simple_pattern(case.pattern):
|
|
325
|
+
return True
|
|
326
|
+
return False
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _is_simple_pattern(pattern: ast.pattern) -> bool:
|
|
330
|
+
if isinstance(pattern, (ast.MatchValue, ast.MatchSingleton)):
|
|
331
|
+
return True
|
|
332
|
+
if isinstance(pattern, ast.MatchAs) and pattern.pattern is None:
|
|
333
|
+
return True
|
|
334
|
+
if isinstance(pattern, ast.MatchOr):
|
|
335
|
+
return all(_is_simple_pattern(p) for p in pattern.patterns)
|
|
336
|
+
return False
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _elif_arms(node: ast.If) -> int:
|
|
340
|
+
arms = 0
|
|
341
|
+
current = node
|
|
342
|
+
while len(current.orelse) == 1 and isinstance(current.orelse[0], ast.If):
|
|
343
|
+
arms += 1
|
|
344
|
+
current = current.orelse[0]
|
|
345
|
+
return arms
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _predicate_suggestions(
|
|
349
|
+
funcdef: AnyFuncdef, breakdown: list[Contribution], total: int
|
|
350
|
+
) -> list[Suggestion]:
|
|
351
|
+
unsafe_lines = _predicate_unsafe_lines(funcdef)
|
|
352
|
+
return [
|
|
353
|
+
_make("extract_predicate", c.lineno, c.lineno, c.points, total)
|
|
354
|
+
for c in breakdown
|
|
355
|
+
if c.label == "bool-op"
|
|
356
|
+
and c.points >= _PREDICATE_MIN_POINTS
|
|
357
|
+
and c.lineno not in unsafe_lines
|
|
358
|
+
]
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _predicate_unsafe_lines(funcdef: AnyFuncdef) -> set[int]:
|
|
362
|
+
return {
|
|
363
|
+
node.lineno
|
|
364
|
+
for node in ast.walk(funcdef)
|
|
365
|
+
if isinstance(node, ast.BoolOp)
|
|
366
|
+
and any(isinstance(child, ast.NamedExpr) for child in ast.walk(node))
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _select(candidates: list[Suggestion]) -> list[Suggestion]:
|
|
371
|
+
good = [c for c in candidates if c.estimated_reduction >= _MIN_REDUCTION]
|
|
372
|
+
good.sort(key=lambda c: (-c.estimated_reduction, c.line_start))
|
|
373
|
+
out: list[Suggestion] = []
|
|
374
|
+
for candidate in good:
|
|
375
|
+
if any(_contains(s, candidate) for s in out):
|
|
376
|
+
continue
|
|
377
|
+
out.append(candidate)
|
|
378
|
+
if len(out) == _MAX_SUGGESTIONS:
|
|
379
|
+
break
|
|
380
|
+
return out
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _contains(outer: Suggestion, inner: Suggestion) -> bool:
|
|
384
|
+
return (
|
|
385
|
+
outer.kind == inner.kind
|
|
386
|
+
and outer.line_start <= inner.line_start
|
|
387
|
+
and outer.line_end >= inner.line_end
|
|
388
|
+
)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Machine-readable JSON report, so cococo can sit in a pipeline.
|
|
2
|
+
|
|
3
|
+
Each shown function is emitted with its score, the per-construct breakdown, and
|
|
4
|
+
the refactor suggestions. The shape is stable and flat enough to filter with
|
|
5
|
+
``jq`` or feed to an agent.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from cognitive_complexity.common_types import ScoredFunction, SkippedFile
|
|
14
|
+
from cognitive_complexity.refactor import suggest_refactors
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _path_key(path: Path, root: Path | None) -> str:
|
|
18
|
+
if root is None:
|
|
19
|
+
return str(path)
|
|
20
|
+
resolved_path = path.resolve()
|
|
21
|
+
resolved_root = root.resolve()
|
|
22
|
+
try:
|
|
23
|
+
return resolved_path.relative_to(resolved_root).as_posix()
|
|
24
|
+
except ValueError:
|
|
25
|
+
return resolved_path.as_posix()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def func_key(func: ScoredFunction, root: Path | None = None) -> str:
|
|
29
|
+
"""Stable identity for a function across runs, used as the baseline key."""
|
|
30
|
+
return f"{_path_key(func.path, root)}::{func.qualname}"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def is_over(
|
|
34
|
+
func: ScoredFunction,
|
|
35
|
+
max_: int | None,
|
|
36
|
+
baseline: dict[str, int] | None,
|
|
37
|
+
baseline_root: Path | None = None,
|
|
38
|
+
) -> bool:
|
|
39
|
+
"""Whether ``func`` fails the gate: over ``--max``, not ignored, not grandfathered.
|
|
40
|
+
|
|
41
|
+
With a ``baseline`` the effective ceiling for a recorded function is the
|
|
42
|
+
higher of ``--max`` and its baseline score, so a known offender passes at its
|
|
43
|
+
recorded score and fails only when it regresses above it; a function absent
|
|
44
|
+
from the baseline is gated at ``--max`` like any new code.
|
|
45
|
+
"""
|
|
46
|
+
if max_ is None or func.ignored:
|
|
47
|
+
return False
|
|
48
|
+
if baseline is None:
|
|
49
|
+
ceiling = max_
|
|
50
|
+
else:
|
|
51
|
+
recorded = baseline.get(func_key(func, baseline_root), baseline.get(func_key(func), max_))
|
|
52
|
+
ceiling = max(max_, recorded)
|
|
53
|
+
return func.score > ceiling
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def build_report(
|
|
57
|
+
funcs: list[ScoredFunction],
|
|
58
|
+
max_: int | None,
|
|
59
|
+
min_: int,
|
|
60
|
+
skipped: list[SkippedFile],
|
|
61
|
+
files_scanned: int,
|
|
62
|
+
baseline: dict[str, int] | None = None,
|
|
63
|
+
baseline_root: Path | None = None,
|
|
64
|
+
) -> dict[str, object]:
|
|
65
|
+
"""Assemble the JSON-able report for the already-filtered ``funcs``.
|
|
66
|
+
|
|
67
|
+
``files_scanned`` and ``skipped`` make scan coverage explicit so a consumer
|
|
68
|
+
can tell a clean scan from a partial one: ``"exceeded": 0`` over a tree where
|
|
69
|
+
files failed to parse is no longer indistinguishable from a genuinely clean
|
|
70
|
+
tree. ``over``/``exceeded`` honor ``# cococo: ignore`` and the baseline.
|
|
71
|
+
"""
|
|
72
|
+
entries = [_func_entry(func, max_, baseline, baseline_root) for func in funcs]
|
|
73
|
+
return {
|
|
74
|
+
"max": max_,
|
|
75
|
+
"min": min_,
|
|
76
|
+
"functions": entries,
|
|
77
|
+
"exceeded": sum(1 for entry in entries if entry["over"]),
|
|
78
|
+
"files_scanned": files_scanned,
|
|
79
|
+
"skipped": [{"path": str(s.path), "reason": s.reason} for s in skipped],
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _func_entry(
|
|
84
|
+
func: ScoredFunction,
|
|
85
|
+
max_: int | None,
|
|
86
|
+
baseline: dict[str, int] | None,
|
|
87
|
+
baseline_root: Path | None,
|
|
88
|
+
) -> dict[str, object]:
|
|
89
|
+
breakdown = func.breakdown
|
|
90
|
+
suggestions = suggest_refactors(func.funcdef, breakdown)
|
|
91
|
+
return {
|
|
92
|
+
"path": str(func.path),
|
|
93
|
+
"lineno": func.lineno,
|
|
94
|
+
"qualname": func.qualname,
|
|
95
|
+
"complexity": func.score,
|
|
96
|
+
"over": is_over(func, max_, baseline, baseline_root),
|
|
97
|
+
"breakdown": [c._asdict() for c in breakdown],
|
|
98
|
+
"suggestions": [s._asdict() for s in suggestions],
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def to_json(report: dict[str, object]) -> str:
|
|
103
|
+
return json.dumps(report, indent=2)
|
|
File without changes
|