pcina 0.1.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.
- pcina/__init__.py +12 -0
- pcina/analyzer.py +485 -0
- pcina/budget.py +66 -0
- pcina/cli.py +246 -0
- pcina/config.py +55 -0
- pcina/diff.py +82 -0
- pcina/files.py +50 -0
- pcina/finding.py +54 -0
- pcina/metrics/__init__.py +35 -0
- pcina/metrics/clever.py +36 -0
- pcina/metrics/complexity.py +39 -0
- pcina/metrics/deadcode.py +98 -0
- pcina/metrics/hardcode.py +37 -0
- pcina/metrics/nesting.py +40 -0
- pcina/metrics/paranoia.py +52 -0
- pcina/metrics/size.py +57 -0
- pcina/metrics/yagni.py +35 -0
- pcina/outputs/__init__.py +1 -0
- pcina/outputs/json_out.py +59 -0
- pcina/outputs/md.py +76 -0
- pcina/outputs/term.py +116 -0
- pcina/scoring.py +135 -0
- pcina-0.1.1.dist-info/METADATA +156 -0
- pcina-0.1.1.dist-info/RECORD +27 -0
- pcina-0.1.1.dist-info/WHEEL +4 -0
- pcina-0.1.1.dist-info/entry_points.txt +2 -0
- pcina-0.1.1.dist-info/licenses/LICENSE +21 -0
pcina/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""pcina - Python Code-pattern Investigator & Nonsense Analyzer.
|
|
2
|
+
|
|
3
|
+
Counts code patterns grouped per section (module/class/function) to surface
|
|
4
|
+
AI-generated bloat ("neuroslop"): paranoid raise chains, deep nesting, magic
|
|
5
|
+
values, dead code and speculative abstraction. Pure stdlib, advisory findings,
|
|
6
|
+
pylint-style rating + per-aspect scores. See docs/adr/ for the specs.
|
|
7
|
+
|
|
8
|
+
Scores guide review; they never prove correctness or quality.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
__version__ = "0.1.0"
|
|
12
|
+
SCHEMA = "pcina/1"
|
pcina/analyzer.py
ADDED
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
"""Core AST machinery: parse files, collect per-function records, cross-file references."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import concurrent.futures
|
|
5
|
+
import dataclasses
|
|
6
|
+
from collections import Counter
|
|
7
|
+
|
|
8
|
+
from pcina import files
|
|
9
|
+
|
|
10
|
+
# Names that mark trust boundaries: validation here is legitimate (ADR-001 section 3.6).
|
|
11
|
+
BOUNDARY_NAMES = ("main", "cli", "run", "parse_args", "load_config", "setup", "handle", "serve")
|
|
12
|
+
DECORATOR_HINTS = ("app", "router", "command", "cli", "click", "pytest", "fixture", "property")
|
|
13
|
+
|
|
14
|
+
# Tunable metric thresholds (ADR-002); named to keep the walker free of magic numbers.
|
|
15
|
+
TERNARY_COGNITIVE_COST = 2
|
|
16
|
+
BOOLOP_CHAIN_LIMIT = 3
|
|
17
|
+
FSTRING_FIELD_LIMIT = 3
|
|
18
|
+
DEEP_NESTING_LEVEL = 2
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def is_boundary(fn_node, filename):
|
|
22
|
+
"""True when a function sits at a trust boundary, so paranoid checks are fine."""
|
|
23
|
+
is_dunder = fn_node.name.startswith("__") and fn_node.name.endswith("__")
|
|
24
|
+
if is_dunder or fn_node.name in BOUNDARY_NAMES:
|
|
25
|
+
return True
|
|
26
|
+
if fn_node.decorator_list:
|
|
27
|
+
return True
|
|
28
|
+
return "__init__.py" in str(filename) or fn_node.name.startswith("_")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclasses.dataclass
|
|
32
|
+
class Fn:
|
|
33
|
+
"""Raw stats about one function (excluding nested definitions)."""
|
|
34
|
+
|
|
35
|
+
qualname: str
|
|
36
|
+
start: int
|
|
37
|
+
end: int
|
|
38
|
+
params: int
|
|
39
|
+
docstring: bool
|
|
40
|
+
is_boundary: bool
|
|
41
|
+
cc: int = 1
|
|
42
|
+
cognitive: int = 0
|
|
43
|
+
max_nesting: int = 0
|
|
44
|
+
n_stmts: int = 0
|
|
45
|
+
raises: list = dataclasses.field(default_factory=list)
|
|
46
|
+
guard_raises: list = dataclasses.field(default_factory=list)
|
|
47
|
+
try_excepts: int = 0
|
|
48
|
+
broad_excepts: list = dataclasses.field(default_factory=list)
|
|
49
|
+
ternary_chains: list = dataclasses.field(default_factory=list)
|
|
50
|
+
boolop_chains: list = dataclasses.field(default_factory=list)
|
|
51
|
+
magic_numbers: list = dataclasses.field(default_factory=list)
|
|
52
|
+
urls: list = dataclasses.field(default_factory=list)
|
|
53
|
+
else_after_return: list = dataclasses.field(default_factory=list)
|
|
54
|
+
guard_opportunities: list = dataclasses.field(default_factory=list)
|
|
55
|
+
shadowed: list = dataclasses.field(default_factory=list)
|
|
56
|
+
big_fstrings: list = dataclasses.field(default_factory=list)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclasses.dataclass
|
|
60
|
+
class Cls:
|
|
61
|
+
"""Raw stats about one class."""
|
|
62
|
+
|
|
63
|
+
qualname: str
|
|
64
|
+
start: int
|
|
65
|
+
end: int
|
|
66
|
+
docstring: bool
|
|
67
|
+
n_methods: int
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclasses.dataclass
|
|
71
|
+
class FileReport:
|
|
72
|
+
"""Everything collected from one parsed file."""
|
|
73
|
+
|
|
74
|
+
path: str
|
|
75
|
+
lines: list
|
|
76
|
+
module_docstring: bool
|
|
77
|
+
fns: list
|
|
78
|
+
classes: list
|
|
79
|
+
imports: list # (name, line) - imported names usable at module level
|
|
80
|
+
module_defs: list # (name, line, kind) - module-level functions/classes/constants
|
|
81
|
+
reference_names: Counter = dataclasses.field(default_factory=Counter)
|
|
82
|
+
lloc: int = 0
|
|
83
|
+
has_main_guard: bool = False
|
|
84
|
+
shadowed: list = dataclasses.field(default_factory=list) # (line, local, module)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class _FnWalker(ast.NodeVisitor):
|
|
88
|
+
"""Collect raw per-function stats, attributing nothing to nested functions."""
|
|
89
|
+
|
|
90
|
+
def __init__(self, fn, record):
|
|
91
|
+
self.fn = fn
|
|
92
|
+
self.r = record
|
|
93
|
+
self.nesting = 0
|
|
94
|
+
self.prenesting = 0
|
|
95
|
+
|
|
96
|
+
def visit_FunctionDef(self, node):
|
|
97
|
+
if node is self.fn:
|
|
98
|
+
for stmt in node.body:
|
|
99
|
+
self.visit(stmt)
|
|
100
|
+
# nested defs belong to their own records, not this one
|
|
101
|
+
|
|
102
|
+
visit_AsyncFunctionDef = visit_FunctionDef
|
|
103
|
+
|
|
104
|
+
def _walk_all(self, node):
|
|
105
|
+
for child in ast.iter_child_nodes(node):
|
|
106
|
+
self.visit(child)
|
|
107
|
+
|
|
108
|
+
def visit_If(self, node):
|
|
109
|
+
self.r.cc += 1
|
|
110
|
+
self.r.cognitive += 1 + self.nesting
|
|
111
|
+
self.nesting += 1
|
|
112
|
+
for stmt in node.body:
|
|
113
|
+
self.visit(stmt)
|
|
114
|
+
has_exit = any(isinstance(s, (ast.Return, ast.Raise, ast.Continue, ast.Break))
|
|
115
|
+
for s in node.body)
|
|
116
|
+
if node.orelse and has_exit and len(node.orelse) == 1:
|
|
117
|
+
self.r.else_after_return.append(node.orelse[0].lineno)
|
|
118
|
+
if self.nesting >= DEEP_NESTING_LEVEL and any(isinstance(s, ast.If) for s in node.body):
|
|
119
|
+
self.r.guard_opportunities.append(node.lineno)
|
|
120
|
+
if any(isinstance(s, ast.Raise) for s in node.body):
|
|
121
|
+
self.r.guard_raises.append(node.lineno)
|
|
122
|
+
for stmt in node.orelse:
|
|
123
|
+
self.visit(stmt)
|
|
124
|
+
self.nesting -= 1
|
|
125
|
+
self.r.max_nesting = max(self.r.max_nesting, self.nesting + 1)
|
|
126
|
+
self.visit(node.test)
|
|
127
|
+
|
|
128
|
+
def visit_For(self, node):
|
|
129
|
+
self.r.cc += 1
|
|
130
|
+
self.r.cognitive += 1 + self.nesting
|
|
131
|
+
self.nesting += 1
|
|
132
|
+
for stmt in node.body:
|
|
133
|
+
self.visit(stmt)
|
|
134
|
+
for stmt in node.orelse:
|
|
135
|
+
self.visit(stmt)
|
|
136
|
+
self.nesting -= 1
|
|
137
|
+
self.r.max_nesting = max(self.r.max_nesting, self.nesting + 1)
|
|
138
|
+
|
|
139
|
+
visit_AsyncFor = visit_For
|
|
140
|
+
visit_While = visit_For
|
|
141
|
+
|
|
142
|
+
def visit_Try(self, node):
|
|
143
|
+
# body handled via generic walk below; each handler is a decision
|
|
144
|
+
for stmt in node.body:
|
|
145
|
+
self.visit(stmt)
|
|
146
|
+
for handler in node.handlers:
|
|
147
|
+
self.r.cc += 1
|
|
148
|
+
self.r.cognitive += 1
|
|
149
|
+
self.r.try_excepts += 1
|
|
150
|
+
name = ""
|
|
151
|
+
if isinstance(handler.type, ast.Name):
|
|
152
|
+
name = handler.type.id
|
|
153
|
+
if isinstance(handler.type, ast.Attribute):
|
|
154
|
+
name = handler.type.attr
|
|
155
|
+
if handler.type is None or name in ("Exception", "BaseException"):
|
|
156
|
+
self.r.broad_excepts.append(handler.lineno)
|
|
157
|
+
for stmt in handler.body:
|
|
158
|
+
self.visit(stmt)
|
|
159
|
+
if node.finalbody:
|
|
160
|
+
for stmt in node.finalbody:
|
|
161
|
+
self.visit(stmt)
|
|
162
|
+
|
|
163
|
+
def visit_Raise(self, node):
|
|
164
|
+
self.r.raises.append(node.lineno)
|
|
165
|
+
self._walk_all(node)
|
|
166
|
+
|
|
167
|
+
def visit_Assert(self, node):
|
|
168
|
+
self.r.cc += 1
|
|
169
|
+
self._walk_all(node)
|
|
170
|
+
|
|
171
|
+
def visit_BoolOp(self, node):
|
|
172
|
+
if len(node.values) > BOOLOP_CHAIN_LIMIT:
|
|
173
|
+
self.r.boolop_chains.append((node.lineno, len(node.values)))
|
|
174
|
+
self.r.cc += 1
|
|
175
|
+
self._walk_all(node)
|
|
176
|
+
|
|
177
|
+
def visit_IfExp(self, node):
|
|
178
|
+
self.r.cc += 1
|
|
179
|
+
self.r.cognitive += TERNARY_COGNITIVE_COST
|
|
180
|
+
if isinstance(node.body, ast.IfExp) or isinstance(node.orelse, ast.IfExp):
|
|
181
|
+
self.r.ternary_chains.append(node.lineno)
|
|
182
|
+
self._walk_all(node)
|
|
183
|
+
|
|
184
|
+
def visit_JoinedStr(self, node):
|
|
185
|
+
formed = [v for v in node.values if isinstance(v, ast.FormattedValue)]
|
|
186
|
+
if len(formed) > FSTRING_FIELD_LIMIT:
|
|
187
|
+
self.r.big_fstrings.append(node.lineno)
|
|
188
|
+
self._walk_all(node)
|
|
189
|
+
|
|
190
|
+
def visit_Constant(self, node):
|
|
191
|
+
magic = _magic_literal(node.value)
|
|
192
|
+
if magic:
|
|
193
|
+
self.r.magic_numbers.append((node.lineno, node.value))
|
|
194
|
+
if isinstance(node.value, str) and _looks_like_url(node.value):
|
|
195
|
+
self.r.urls.append((node.lineno, node.value))
|
|
196
|
+
ast.NodeVisitor.generic_visit(self, node)
|
|
197
|
+
|
|
198
|
+
def generic_visit(self, node):
|
|
199
|
+
# comprehension generators and with add decisions
|
|
200
|
+
if isinstance(node, ast.comprehension):
|
|
201
|
+
self.r.cc += 1
|
|
202
|
+
if isinstance(node, ast.withitem):
|
|
203
|
+
self.r.cc += 1
|
|
204
|
+
ast.NodeVisitor.generic_visit(self, node)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _magic_literal(value, allowed=(0, 1, 2, -1, 0.0, 1.0, 2.0)):
|
|
208
|
+
"""A bare literal that looks like a magic value (ADR-002 metric def)."""
|
|
209
|
+
if isinstance(value, bool) or isinstance(value, (int, float)):
|
|
210
|
+
if value in allowed or (isinstance(value, float) and float(value).is_integer() and int(value) in allowed):
|
|
211
|
+
return False
|
|
212
|
+
if abs(value) in (10, 100, 1000): # conventional scales stay anonymous
|
|
213
|
+
return False
|
|
214
|
+
return True
|
|
215
|
+
return False
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _looks_like_url(text):
|
|
219
|
+
return text.startswith(("http://", "https://", "ftp://"))
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _n_params(fn):
|
|
223
|
+
total = len(fn.args.posonlyargs) + len(fn.args.args) + len(fn.args.kwonlyargs)
|
|
224
|
+
if fn.args.vararg:
|
|
225
|
+
total += 1
|
|
226
|
+
if fn.args.kwarg:
|
|
227
|
+
total += 1
|
|
228
|
+
return total
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class _FileWalker(ast.NodeVisitor):
|
|
232
|
+
"""Top-level walk: module defs, imports, main guard, function records."""
|
|
233
|
+
|
|
234
|
+
def __init__(self, path, lines):
|
|
235
|
+
self.report = FileReport(path=path, lines=lines, module_docstring=False,
|
|
236
|
+
fns=[], classes=[], imports=[], module_defs=[])
|
|
237
|
+
self.prefix = ()
|
|
238
|
+
|
|
239
|
+
def visit_Module(self, node):
|
|
240
|
+
self.report.module_docstring = bool(ast.get_docstring(node))
|
|
241
|
+
self.report.has_main_guard = any(_is_main_guard(stmt) for stmt in node.body)
|
|
242
|
+
self.report.lloc = _count_statements(node)
|
|
243
|
+
for stmt in node.body:
|
|
244
|
+
if (isinstance(stmt, ast.Assign)
|
|
245
|
+
and any(isinstance(t, ast.Name) and t.id == "__all__" for t in stmt.targets)):
|
|
246
|
+
self._record_exports(stmt.value)
|
|
247
|
+
self.visit(stmt)
|
|
248
|
+
|
|
249
|
+
def _record_exports(self, value):
|
|
250
|
+
"""Count `__all__` entries as references: they are a re-export contract."""
|
|
251
|
+
if isinstance(value, (ast.Tuple, ast.List)):
|
|
252
|
+
for item in value.elts:
|
|
253
|
+
if isinstance(item, ast.Constant) and isinstance(item.value, str):
|
|
254
|
+
self.report.reference_names[item.value] += 1
|
|
255
|
+
elif isinstance(value, ast.Constant) and isinstance(value.value, str):
|
|
256
|
+
self.report.reference_names[value.value] += 1
|
|
257
|
+
|
|
258
|
+
def _register_fn(self, node):
|
|
259
|
+
qual = ".".join(self.prefix + (node.name,))
|
|
260
|
+
record = Fn(qualname=qual, start=node.lineno, end=getattr(node, "end_lineno", node.lineno),
|
|
261
|
+
params=_n_params(node), docstring=bool(ast.get_docstring(node)),
|
|
262
|
+
is_boundary=is_boundary(node, self.report.path))
|
|
263
|
+
walker = _FnWalker(node, record)
|
|
264
|
+
for stmt in node.body:
|
|
265
|
+
walker.visit(stmt)
|
|
266
|
+
self.report.fns.append(record)
|
|
267
|
+
self._record_decorators(node.decorator_list)
|
|
268
|
+
self._record_annotations(node)
|
|
269
|
+
if not self.prefix:
|
|
270
|
+
# only TOP-LEVEL defs are module definitions (contract: module_defs)
|
|
271
|
+
self.report.module_defs.append((node.name, node.lineno, "function"))
|
|
272
|
+
saved, self.prefix = self.prefix, self.prefix + (node.name,)
|
|
273
|
+
for stmt in node.body:
|
|
274
|
+
self.visit(stmt) # register nested fns/classes too
|
|
275
|
+
self.prefix = saved
|
|
276
|
+
|
|
277
|
+
def _record_decorators(self, decorator_list):
|
|
278
|
+
"""Decorated names are real references (e.g. @dataclass keeps an import alive)."""
|
|
279
|
+
for name in _decorator_names(decorator_list):
|
|
280
|
+
self.report.reference_names[name] += 1
|
|
281
|
+
|
|
282
|
+
def _record_expr_refs(self, expr):
|
|
283
|
+
"""Every Name in an expression subtree is a use (class bases, metaclass)."""
|
|
284
|
+
for sub in _expr_name_ids(expr):
|
|
285
|
+
self.report.reference_names[sub] += 1
|
|
286
|
+
|
|
287
|
+
def _record_annotations(self, fn_node):
|
|
288
|
+
"""Annotation-only uses (typing imports) are real references (Vulture #216)."""
|
|
289
|
+
for name in _annotation_names(fn_node):
|
|
290
|
+
self.report.reference_names[name] += 1
|
|
291
|
+
|
|
292
|
+
def visit_FunctionDef(self, node):
|
|
293
|
+
self._register_fn(node)
|
|
294
|
+
|
|
295
|
+
visit_AsyncFunctionDef = visit_FunctionDef
|
|
296
|
+
|
|
297
|
+
def visit_ClassDef(self, node):
|
|
298
|
+
qual = ".".join(self.prefix + (node.name,))
|
|
299
|
+
methods = sum(1 for s in node.body
|
|
300
|
+
if isinstance(s, (ast.FunctionDef, ast.AsyncFunctionDef)))
|
|
301
|
+
self.report.classes.append(Cls(qualname=qual, start=node.lineno,
|
|
302
|
+
end=getattr(node, "end_lineno", node.lineno),
|
|
303
|
+
docstring=bool(ast.get_docstring(node)),
|
|
304
|
+
n_methods=methods))
|
|
305
|
+
self._record_decorators(node.decorator_list)
|
|
306
|
+
for base in node.bases:
|
|
307
|
+
self._record_expr_refs(base)
|
|
308
|
+
for kw in node.keywords:
|
|
309
|
+
if kw.arg == "metaclass":
|
|
310
|
+
self._record_expr_refs(kw.value)
|
|
311
|
+
if not self.prefix:
|
|
312
|
+
self.report.module_defs.append((node.name, node.lineno, "class"))
|
|
313
|
+
saved, self.prefix = self.prefix, self.prefix + (node.name,)
|
|
314
|
+
for stmt in node.body:
|
|
315
|
+
self.visit(stmt)
|
|
316
|
+
self.prefix = saved
|
|
317
|
+
|
|
318
|
+
def visit_Import(self, node):
|
|
319
|
+
for alias in node.names:
|
|
320
|
+
bound = alias.asname or alias.name.split(".")[0]
|
|
321
|
+
self.report.imports.append((bound, node.lineno))
|
|
322
|
+
if alias.asname and alias.asname == alias.name.split(".")[0]:
|
|
323
|
+
# `import x as x` is an explicit re-export marker (Ruff F401 rule).
|
|
324
|
+
self.report.reference_names[bound] += 1
|
|
325
|
+
|
|
326
|
+
def visit_ImportFrom(self, node):
|
|
327
|
+
for alias in node.names:
|
|
328
|
+
if alias.name == "*":
|
|
329
|
+
continue
|
|
330
|
+
self.report.imports.append((alias.asname or alias.name, node.lineno))
|
|
331
|
+
if alias.asname == alias.name:
|
|
332
|
+
# `from x import y as y` is an explicit re-export marker.
|
|
333
|
+
self.report.reference_names[alias.name] += 1
|
|
334
|
+
|
|
335
|
+
def visit_Name(self, node):
|
|
336
|
+
if isinstance(node.ctx, ast.Load):
|
|
337
|
+
self.report.reference_names[node.id] += 1
|
|
338
|
+
ast.NodeVisitor.generic_visit(self, node)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _annotation_names(fn_node):
|
|
342
|
+
"""Name ids appearing in parameter/return annotations (uses are references)."""
|
|
343
|
+
args = fn_node.args
|
|
344
|
+
for group in (args.posonlyargs, args.args, args.kwonlyargs):
|
|
345
|
+
for arg in group:
|
|
346
|
+
yield from _expr_name_ids(arg.annotation)
|
|
347
|
+
if args.vararg and args.vararg.annotation:
|
|
348
|
+
yield from _expr_name_ids(args.vararg.annotation)
|
|
349
|
+
if args.kwarg and args.kwarg.annotation:
|
|
350
|
+
yield from _expr_name_ids(args.kwarg.annotation)
|
|
351
|
+
if fn_node.returns:
|
|
352
|
+
yield from _expr_name_ids(fn_node.returns)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _expr_name_ids(expr):
|
|
356
|
+
"""All Name ids inside an arbitrary expression subtree (Load context)."""
|
|
357
|
+
if expr is None:
|
|
358
|
+
return
|
|
359
|
+
for sub in ast.walk(expr):
|
|
360
|
+
if isinstance(sub, ast.Name):
|
|
361
|
+
yield sub.id
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _decorator_names(decorator_list):
|
|
365
|
+
"""Name ids referenced by decorators: @deco, @pkg.deco, @deco(...)."""
|
|
366
|
+
for deco in decorator_list:
|
|
367
|
+
target = deco.func if isinstance(deco, ast.Call) else deco
|
|
368
|
+
while isinstance(target, ast.Attribute):
|
|
369
|
+
target = target.value
|
|
370
|
+
if isinstance(target, ast.Name):
|
|
371
|
+
yield target.id
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _is_main_guard(stmt):
|
|
375
|
+
"""True for the `if __name__ == "__main__":` idiom."""
|
|
376
|
+
if not isinstance(stmt, ast.If) or not isinstance(stmt.test, ast.Compare):
|
|
377
|
+
return False
|
|
378
|
+
left = stmt.test.left
|
|
379
|
+
return isinstance(left, ast.Name) and left.id == "__name__"
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _count_statements(tree):
|
|
383
|
+
"""LLOC: count of ast.stmt nodes, excluding def/class signature statements.
|
|
384
|
+
|
|
385
|
+
The definition line itself is not executable logic, but the bodies of
|
|
386
|
+
functions and classes are counted (ADR-002 metric definition).
|
|
387
|
+
"""
|
|
388
|
+
defs = (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
|
|
389
|
+
return sum(1 for node in ast.walk(tree)
|
|
390
|
+
if isinstance(node, ast.stmt) and not isinstance(node, defs))
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _iter_scope(fn):
|
|
394
|
+
"""Yield nodes belonging to fn's own scope, skipping nested def bodies."""
|
|
395
|
+
stack = list(fn.body)
|
|
396
|
+
while stack:
|
|
397
|
+
node = stack.pop()
|
|
398
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
399
|
+
yield node # its name binds here, but its body is another scope
|
|
400
|
+
continue
|
|
401
|
+
yield node
|
|
402
|
+
stack.extend(ast.iter_child_nodes(node))
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _find_shadowed(tree, report):
|
|
406
|
+
"""Local names that silently mask a module import (real defect class, error)."""
|
|
407
|
+
imported = {name for name, _ in report.imports}
|
|
408
|
+
if not imported:
|
|
409
|
+
return
|
|
410
|
+
records = {fn.start: fn for fn in report.fns}
|
|
411
|
+
|
|
412
|
+
def local_names(fn):
|
|
413
|
+
names = set()
|
|
414
|
+
for node in _iter_scope(fn):
|
|
415
|
+
if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store):
|
|
416
|
+
names.add(node.id)
|
|
417
|
+
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
418
|
+
names.add(node.name)
|
|
419
|
+
names.update(_all_args(node.args))
|
|
420
|
+
elif isinstance(node, ast.Lambda):
|
|
421
|
+
names.update(_all_args(node.args))
|
|
422
|
+
elif isinstance(node, ast.comprehension) and isinstance(node.target, ast.Name):
|
|
423
|
+
names.add(node.target.id)
|
|
424
|
+
return names
|
|
425
|
+
|
|
426
|
+
for fn in _iter_fndefs(tree):
|
|
427
|
+
hits = sorted(local_names(fn) & imported)
|
|
428
|
+
for name in hits:
|
|
429
|
+
entry = (fn.lineno, name, name)
|
|
430
|
+
report.shadowed.append(entry)
|
|
431
|
+
record = records.get(fn.lineno)
|
|
432
|
+
if record is not None:
|
|
433
|
+
record.shadowed.append(entry)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _all_args(args):
|
|
437
|
+
out = []
|
|
438
|
+
for group in ("posonlyargs", "args", "kwonlyargs"):
|
|
439
|
+
out.extend(a.arg for a in getattr(args, group, []))
|
|
440
|
+
return out
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _iter_fndefs(tree):
|
|
444
|
+
for node in ast.walk(tree):
|
|
445
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
446
|
+
yield node
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def parse_file(path):
|
|
450
|
+
"""Parse one .py file and return its FileReport, or None on syntax error."""
|
|
451
|
+
lines = files.read_lines(path)
|
|
452
|
+
try:
|
|
453
|
+
tree = ast.parse("\n".join(lines), filename=str(path))
|
|
454
|
+
except SyntaxError:
|
|
455
|
+
return None
|
|
456
|
+
walker = _FileWalker(str(path), lines)
|
|
457
|
+
walker.visit(tree)
|
|
458
|
+
_find_shadowed(tree, walker.report)
|
|
459
|
+
return walker.report
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
@dataclasses.dataclass
|
|
463
|
+
class Project:
|
|
464
|
+
"""Aggregated view over all analyzed files."""
|
|
465
|
+
|
|
466
|
+
reports: list
|
|
467
|
+
name_refs: Counter = dataclasses.field(default_factory=Counter)
|
|
468
|
+
loc: int = 0
|
|
469
|
+
lloc: int = 0
|
|
470
|
+
|
|
471
|
+
def build(self):
|
|
472
|
+
"""Second pass: cross-file reference counts and totals."""
|
|
473
|
+
for report in self.reports:
|
|
474
|
+
self.name_refs.update(report.reference_names)
|
|
475
|
+
self.loc += sum(1 for ln in report.lines if ln.strip())
|
|
476
|
+
self.lloc += report.lloc
|
|
477
|
+
return self
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def analyze(paths, exclude_patterns):
|
|
481
|
+
"""Discover, parse and aggregate every file under paths. Returns Project."""
|
|
482
|
+
file_paths = list(files.discover(paths, exclude_patterns))
|
|
483
|
+
with concurrent.futures.ProcessPoolExecutor() as pool:
|
|
484
|
+
reports = [r for r in pool.map(parse_file, file_paths) if r is not None]
|
|
485
|
+
return Project(reports=reports).build()
|
pcina/budget.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Budget gate checks: `pcina check` fails only on these, never on the score (ADR-001)."""
|
|
2
|
+
|
|
3
|
+
from pcina.config import DEFAULT_BUDGETS
|
|
4
|
+
from pcina.finding import BudgetViolation
|
|
5
|
+
|
|
6
|
+
# Scaling for the project-wide raise-rate budget: raises per 100 LLOC.
|
|
7
|
+
PER_100 = 100
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _limit(budgets, key):
|
|
11
|
+
"""Budget value with a fallback to the documented default."""
|
|
12
|
+
return budgets.get(key, DEFAULT_BUDGETS[key])
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _fn_violations(report, fn, budgets):
|
|
16
|
+
"""Per-function budget breaches, each anchored at the offending fn."""
|
|
17
|
+
out = []
|
|
18
|
+
max_cc = _limit(budgets, "max_cc")
|
|
19
|
+
if fn.cc > max_cc:
|
|
20
|
+
out.append(BudgetViolation("max_cc", f"cc={fn.cc} (limit {max_cc})",
|
|
21
|
+
report.path, fn.start))
|
|
22
|
+
max_nesting = _limit(budgets, "max_nesting")
|
|
23
|
+
if fn.max_nesting > max_nesting:
|
|
24
|
+
out.append(BudgetViolation("max_nesting",
|
|
25
|
+
f"nesting={fn.max_nesting} (limit {max_nesting})",
|
|
26
|
+
report.path, fn.start))
|
|
27
|
+
max_fn_loc = _limit(budgets, "max_fn_loc")
|
|
28
|
+
span = fn.end - fn.start
|
|
29
|
+
if span > max_fn_loc:
|
|
30
|
+
out.append(BudgetViolation("max_fn_loc",
|
|
31
|
+
f"span={span} lines (limit {max_fn_loc})",
|
|
32
|
+
report.path, fn.start))
|
|
33
|
+
max_params = _limit(budgets, "max_params")
|
|
34
|
+
if fn.params > max_params:
|
|
35
|
+
out.append(BudgetViolation("max_params",
|
|
36
|
+
f"params={fn.params} (limit {max_params})",
|
|
37
|
+
report.path, fn.start))
|
|
38
|
+
return out
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _project_violations(project, budgets):
|
|
42
|
+
"""Project-wide gates: raise density and the bare-except policy."""
|
|
43
|
+
out = []
|
|
44
|
+
raises = sum(len(fn.raises) for report in project.reports for fn in report.fns)
|
|
45
|
+
max_raises = _limit(budgets, "max_raises_per_100")
|
|
46
|
+
rate = raises * PER_100 / max(project.lloc, 1)
|
|
47
|
+
if rate > max_raises:
|
|
48
|
+
out.append(BudgetViolation(
|
|
49
|
+
"max_raises_per_100",
|
|
50
|
+
f"{rate:.1f} raises per 100 LLOC (limit {max_raises})"))
|
|
51
|
+
if _limit(budgets, "bare_except") == "error":
|
|
52
|
+
bare = sum(len(fn.broad_excepts)
|
|
53
|
+
for report in project.reports for fn in report.fns)
|
|
54
|
+
if bare:
|
|
55
|
+
out.append(BudgetViolation("bare_except", f"{bare} broad/bare except(s)"))
|
|
56
|
+
return out
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def violations(project, findings, budgets):
|
|
60
|
+
"""All budget violations for the project against the given budgets dict."""
|
|
61
|
+
out = []
|
|
62
|
+
for report in project.reports:
|
|
63
|
+
for fn in report.fns:
|
|
64
|
+
out.extend(_fn_violations(report, fn, budgets))
|
|
65
|
+
out.extend(_project_violations(project, budgets))
|
|
66
|
+
return out
|