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,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