8Provide shared analysis, ordering, and rendering for command adapters.
12- Discover Python files while excluding environment and build trees.
13- Record every parse failure instead of silently omitting files.
14- Produce stable worst-first CC or CRAP ordering.
15- Render full JSON data and compact ASCII human output.
16- Expose the shared relative-path display that gate output and baseline
17 identities are built from.
23 L2 - analysis lifecycle
24 L3 - file and result details
29- JSON results are never truncated by ``--top``.
30- CC-only rows explicitly identify coverage as not measured.
31- The service measures and never gates.
35- oct.complexity.coverage (optional coverage mapping)
36- oct.complexity.engine (complexity records)
37- oct.complexity.metrics (CRAP formula)
43from __future__
import annotations
47from dataclasses
import asdict, dataclass
48from pathlib
import Path
49from typing
import Iterable, Sequence
56_EXCLUDED_DIRECTORY_NAMES = frozenset(
77@dataclass(frozen=True, slots=True)
79 """One source file that could not be parsed or read."""
86 def to_dict(self) -> dict[str, str | int | None]:
87 """Return a JSON-ready failure record."""
91@dataclass(frozen=True, slots=True)
93 """One complete result row, with nullable P2 fields in CC-only mode."""
100 coverage: float |
None
102 covered_statements: int |
None
103 statement_count: int |
None
106 def to_dict(self) -> dict[str, str | int | float | None]:
107 """Return every stable field for JSON output."""
111@dataclass(frozen=True, slots=True)
113 """All measurements and all parse failures for one command invocation."""
115 functions: tuple[FunctionResult, ...]
116 parse_errors: tuple[ParseFailure, ...]
117 coverage_message: str |
None
120 """Return full, untruncated machine-readable output."""
122 "functions": [item.to_dict()
for item
in self.
functions],
123 "parse_errors": [item.to_dict()
for item
in self.
parse_errors],
129 paths: Sequence[str | Path],
130 coverage_data: str | Path |
None =
None,
132 """Analyze Python paths and optionally enrich every record with coverage."""
133 records: list[FunctionComplexity] = []
134 parse_errors: list[ParseFailure] = []
137 records.extend(analyze_file(filename))
138 except SyntaxError
as exc:
147 except (OSError, UnicodeError)
as exc:
156 if coverage_data
is None:
159 results.sort(key=_complexity_sort_key)
163 for item
in map_coverage(records, coverage_data)
165 coverage_message =
None
166 results.sort(key=_crap_sort_key)
167 parse_errors.sort(key=
lambda item: (item.file.casefold(), item.lineno
or 0))
169 functions=tuple(results),
170 parse_errors=tuple(parse_errors),
171 coverage_message=coverage_message,
176 """Return unique Python files under paths with build/cache trees excluded."""
177 discovered: dict[str, Path] = {}
178 for raw_path
in paths:
179 path = Path(raw_path).resolve()
180 if not path.exists():
181 raise FileNotFoundError(f
"path not found: {path}")
182 candidates: Iterable[Path]
184 candidates = (path,)
if path.suffix.casefold() ==
".py" else ()
186 candidates = path.rglob(
"*.py")
187 for candidate
in candidates:
188 resolved = candidate.resolve()
192 return sorted(discovered.values(), key=
lambda item: str(item).casefold())
196 """Return whether a discovered path crosses an excluded directory."""
198 relative_parts = path.relative_to(scan_root).parts[:-1]
200 relative_parts = path.parts[:-1]
201 return any(part
in _EXCLUDED_DIRECTORY_NAMES
for part
in relative_parts)
205 """Convert an engine record into an explicit CC-only result."""
208 qualname=record.qualname,
209 lineno=record.lineno,
210 end_lineno=record.end_lineno,
211 complexity=record.complexity,
213 coverage_status=
"not-measured",
214 covered_statements=
None,
215 statement_count=
None,
221 """Convert a covered record and calculate its CRAP score."""
224 qualname=item.qualname,
226 end_lineno=item.end_lineno,
227 complexity=item.complexity,
228 coverage=item.coverage,
229 coverage_status=item.coverage_status,
230 covered_statements=item.covered_statements,
231 statement_count=item.statement_count,
232 crap=calculate_crap(item.complexity, item.coverage),
237 """Sort CC-only rows worst-first with deterministic tie breaking."""
240 item.file.casefold(),
247 """Sort covered rows by CRAP worst-first, then complexity."""
251 item.file.casefold(),
258 """Explain exactly why CRAP columns are absent in CC-only mode."""
259 if importlib.util.find_spec(
"coverage")
is None:
261 "Coverage unavailable: coverage.py is not installed; "
262 "CRAP columns are absent."
265 "Coverage unavailable: no --coverage-data was provided; "
266 "CRAP columns are absent."
270def render_human(report: AnalysisReport, top: int |
None =
None) -> str:
271 """Render a compact worst-first ASCII report for terminal users."""
272 shown = report.functions[:top]
if top
is not None else report.functions
273 lines: list[str] = []
274 if report.coverage_message:
275 lines.append(report.coverage_message)
276 if shown
and shown[0].coverage
is not None:
277 lines.append(
" CC Coverage CRAP Status Function Span")
280 f
"{item.complexity:>3} {item.coverage * 100:>7.1f}% "
281 f
"{item.crap:>9.2f} {item.coverage_status:<14} "
282 f
"{item.qualname} {_span(item)}"
285 lines.append(
" CC Function Span")
288 f
"{item.complexity:>3} {item.qualname} {_span(item)}"
290 lines.append(f
"Showing {len(shown)} of {len(report.functions)} functions.")
291 if report.parse_errors:
292 lines.append(
"Parse errors:")
293 for error
in report.parse_errors:
294 location = f
":{error.lineno}" if error.lineno
is not None else ""
295 lines.append(f
" {error.file}{location}: {error.error}")
296 return "\n".join(lines)
299def _span(item: FunctionResult) -> str:
300 """Return one source span suitable for terminal output."""
301 return f
"{item.file}:{item.lineno}-{item.end_lineno}"
305 """Return a deterministic filesystem identity key."""
306 return os.path.normcase(str(path))
310 """Return *file* relative to *root* with forward separators.
312 Gate output and baseline identities have to survive being read on another
313 machine, so the separator is normalised and the path is made relative
314 whenever it lies under *root*. A file outside *root* (a different drive,
315 a temporary fixture) keeps its absolute POSIX spelling rather than
316 growing a misleading chain of parent segments.
318 resolved = Path(file).resolve()
320 return resolved.relative_to(Path(root).resolve()).as_posix()
322 return resolved.as_posix()
326 """Return the stable ``relpath::qualname`` identity of one function.
328 A renamed or moved function is honestly a new function under this
329 identity, which is the point: the ratchet grandfathers recorded debt,
330 not the name it happened to carry.
332 return f
"{relative_display_path(file, root)}::{qualname}"
dict[str, object] to_dict(self)
dict[str, str|int|float|None] to_dict(self)
dict[str, str|int|None] to_dict(self)
AnalysisReport analyze_paths(Sequence[str|Path] paths, str|Path|None coverage_data=None)
str relative_display_path(str|Path file, str|Path root)
tuple[object,...] _crap_sort_key(FunctionResult item)
str _coverage_absence_message()
FunctionResult _without_coverage(FunctionComplexity record)
FunctionResult _with_coverage(CoveredFunction item)
bool is_excluded_path(Path path, Path scan_root)
str function_identity(str|Path file, str qualname, str|Path root)
list[Path] discover_python_files(Sequence[str|Path] paths)
str _span(FunctionResult item)
str render_human(AnalysisReport report, int|None top=None)
tuple[object,...] _complexity_sort_key(FunctionResult item)
str _normalized_path(Path path)