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