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.
@@ -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