@evoclock/pi-agentic-driver 0.4.3

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,378 @@
1
+ # SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+
4
+ #!/usr/bin/env python3
5
+ """AST-backed complexity metrics for code-phage.
6
+
7
+ The process accepts one JSON object on stdin and emits one JSON object on
8
+ stdout. It reads no files and writes no files; the JavaScript caller supplies
9
+ source text after enforcing repository containment.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import ast
15
+ import io
16
+ import json
17
+ import tokenize
18
+ import sys
19
+ from dataclasses import dataclass, field
20
+
21
+
22
+ LOGICAL_NODES = (ast.And, ast.Or)
23
+
24
+
25
+ @dataclass
26
+ class Metric:
27
+ name: str
28
+ kind: str
29
+ line: int
30
+ end_line: int = 0
31
+ cyclomatic: int = 1
32
+ cognitive: int = 0
33
+ decisions: list[dict] = field(default_factory=list)
34
+ logical_sequences: list[dict] = field(default_factory=list)
35
+ recursive_calls: int = 0
36
+ max_nesting: int = 0
37
+
38
+ def as_dict(self) -> dict:
39
+ return {
40
+ "name": self.name,
41
+ "kind": self.kind,
42
+ "line": self.line,
43
+ "endLine": self.end_line,
44
+ "cyclomaticComplexity": self.cyclomatic,
45
+ "cognitiveComplexity": self.cognitive,
46
+ "decisionPoints": self.decisions,
47
+ "logicalSequences": self.logical_sequences,
48
+ "recursiveCalls": self.recursive_calls,
49
+ "maxNesting": self.max_nesting,
50
+ }
51
+
52
+
53
+ class ComplexityVisitor(ast.NodeVisitor):
54
+ def __init__(self, metric: Metric, current_name: str, root: ast.AST):
55
+ self.metric = metric
56
+ self.current_name = current_name
57
+ self.root = root
58
+ self.nesting = 0
59
+
60
+ def _decision(self, node: ast.AST, kind: str, cognitive_nesting: int | None = None) -> None:
61
+ nesting = self.nesting if cognitive_nesting is None else cognitive_nesting
62
+ self.metric.cyclomatic += 1
63
+ self.metric.cognitive += 1 + nesting
64
+ self.metric.max_nesting = max(self.metric.max_nesting, self.nesting)
65
+ self.metric.decisions.append({
66
+ "kind": kind,
67
+ "line": getattr(node, "lineno", 0),
68
+ "nesting": self.nesting,
69
+ })
70
+
71
+ def _visit_sequence(self, nodes: list[ast.AST], nesting: int | None = None) -> None:
72
+ previous = self.nesting
73
+ if nesting is not None:
74
+ self.nesting = nesting
75
+ self.metric.max_nesting = max(self.metric.max_nesting, self.nesting)
76
+ for node in nodes:
77
+ self.visit(node)
78
+ self.nesting = previous
79
+
80
+ def visit_If(self, node: ast.If) -> None:
81
+ self._decision(node, "if", 0 if getattr(node, "_code_phage_elif", False) else None)
82
+ self.visit(node.test)
83
+ self._visit_sequence(node.body, self.nesting + 1)
84
+ if not node.orelse:
85
+ return
86
+ if len(node.orelse) == 1 and isinstance(node.orelse[0], ast.If):
87
+ child = node.orelse[0]
88
+ child._code_phage_elif = True
89
+ self.visit(child)
90
+ return
91
+ self.metric.cognitive += 1
92
+ self.metric.decisions.append({"kind": "else", "line": getattr(node.orelse[0], "lineno", 0), "nesting": self.nesting})
93
+ self._visit_sequence(node.orelse, self.nesting + 1)
94
+
95
+ def _visit_loop(self, node: ast.AST, kind: str, fields: tuple[str, ...]) -> None:
96
+ self._decision(node, kind)
97
+ for field_name in fields:
98
+ value = getattr(node, field_name, None)
99
+ if isinstance(value, ast.AST):
100
+ self.visit(value)
101
+ elif isinstance(value, list):
102
+ for child in value:
103
+ if isinstance(child, ast.AST):
104
+ self.visit(child)
105
+ body = getattr(node, "body", [])
106
+ self._visit_sequence(body, self.nesting + 1)
107
+ orelse = getattr(node, "orelse", [])
108
+ if orelse:
109
+ self._visit_sequence(orelse, self.nesting + 1)
110
+
111
+ def visit_For(self, node: ast.For) -> None:
112
+ self._visit_loop(node, "for", ("target", "iter"))
113
+
114
+ def visit_AsyncFor(self, node: ast.AsyncFor) -> None:
115
+ self._visit_loop(node, "async-for", ("target", "iter"))
116
+
117
+ def visit_While(self, node: ast.While) -> None:
118
+ self._decision(node, "while")
119
+ self.visit(node.test)
120
+ self._visit_sequence(node.body, self.nesting + 1)
121
+ if node.orelse:
122
+ self._visit_sequence(node.orelse, self.nesting + 1)
123
+
124
+ def visit_Try(self, node: ast.Try) -> None:
125
+ self._visit_sequence(node.body, self.nesting)
126
+ for handler in node.handlers:
127
+ self._decision(handler, "except")
128
+ self._visit_sequence(handler.body, self.nesting + 1)
129
+ self._visit_sequence(node.orelse, self.nesting)
130
+ self._visit_sequence(node.finalbody, self.nesting)
131
+
132
+ def visit_TryStar(self, node: ast.TryStar) -> None:
133
+ self.visit_Try(node)
134
+
135
+ def visit_Match(self, node: ast.Match) -> None:
136
+ self.metric.cognitive += 1 + self.nesting
137
+ self.metric.max_nesting = max(self.metric.max_nesting, self.nesting)
138
+ self.metric.decisions.append({"kind": "match", "line": getattr(node, "lineno", 0), "nesting": self.nesting})
139
+ self.visit(node.subject)
140
+ for case in node.cases:
141
+ if not _is_wildcard_case(case):
142
+ self.metric.cyclomatic += 1
143
+ self.metric.decisions.append({"kind": "case", "line": getattr(case, "lineno", 0), "nesting": self.nesting + 1})
144
+ self._visit_sequence([case.pattern], self.nesting + 1)
145
+ if case.guard:
146
+ self.visit(case.guard)
147
+ self._visit_sequence(case.body, self.nesting + 1)
148
+
149
+ def visit_IfExp(self, node: ast.IfExp) -> None:
150
+ self._decision(node, "conditional-expression")
151
+ self.visit(node.test)
152
+ self._visit_sequence([node.body, node.orelse], self.nesting + 1)
153
+
154
+ def visit_BoolOp(self, node: ast.BoolOp) -> None:
155
+ operators = [type(node.op).__name__.lower()] * max(0, len(node.values) - 1)
156
+ self.metric.cyclomatic += len(operators)
157
+ if operators:
158
+ self.metric.cognitive += 1
159
+ self.metric.logical_sequences.append({
160
+ "line": getattr(node, "lineno", 0),
161
+ "operators": operators,
162
+ "cognitiveIncrement": 1,
163
+ })
164
+ for value in node.values:
165
+ self.visit(value)
166
+
167
+ def visit_comprehension(self, node: ast.comprehension) -> None:
168
+ self.metric.cyclomatic += 1
169
+ self.metric.cognitive += 1 + self.nesting
170
+ self.metric.max_nesting = max(self.metric.max_nesting, self.nesting)
171
+ self.metric.decisions.append({"kind": "comprehension-for", "line": getattr(node, "lineno", 0), "nesting": self.nesting})
172
+ self.visit(node.target)
173
+ self.visit(node.iter)
174
+ for condition in node.ifs:
175
+ self.metric.cyclomatic += 1
176
+ self.metric.cognitive += 1 + self.nesting
177
+ self.metric.decisions.append({"kind": "comprehension-if", "line": getattr(condition, "lineno", 0), "nesting": self.nesting})
178
+ self.visit(condition)
179
+
180
+ def visit_Call(self, node: ast.Call) -> None:
181
+ if self.current_name != "<module>" and isinstance(node.func, ast.Name) and node.func.id == self.current_name:
182
+ self.metric.cognitive += 1
183
+ self.metric.recursive_calls += 1
184
+ self.generic_visit(node)
185
+
186
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
187
+ if node is not self.root:
188
+ return
189
+ self.generic_visit(node)
190
+
191
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
192
+ if node is not self.root:
193
+ return
194
+ self.generic_visit(node)
195
+
196
+ def visit_Lambda(self, node: ast.Lambda) -> None:
197
+ if node is not self.root:
198
+ return
199
+ self.generic_visit(node)
200
+
201
+ def visit_Break(self, node: ast.Break) -> None:
202
+ self.generic_visit(node)
203
+
204
+ def visit_Continue(self, node: ast.Continue) -> None:
205
+ self.generic_visit(node)
206
+
207
+
208
+ def _is_wildcard_case(case: ast.match_case) -> bool:
209
+ return isinstance(case.pattern, ast.MatchAs) and case.pattern.pattern is None and case.guard is None
210
+
211
+
212
+ def _callables(tree: ast.AST):
213
+ found: list[tuple[ast.AST, str, str, int, int]] = []
214
+
215
+ class Collector(ast.NodeVisitor):
216
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
217
+ found.append((node, node.name, "function", node.lineno, node.end_lineno or node.lineno))
218
+ self.generic_visit(node)
219
+
220
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
221
+ found.append((node, node.name, "async-function", node.lineno, node.end_lineno or node.lineno))
222
+ self.generic_visit(node)
223
+
224
+ def visit_Lambda(self, node: ast.Lambda) -> None:
225
+ found.append((node, "<lambda>", "lambda", node.lineno, node.end_lineno or node.lineno))
226
+ self.generic_visit(node)
227
+
228
+ Collector().visit(tree)
229
+ return found
230
+
231
+
232
+ def _metric_for(root: ast.AST, name: str, kind: str, line: int, end_line: int = 0) -> Metric:
233
+ metric = Metric(name=name, kind=kind, line=line, end_line=end_line)
234
+ ComplexityVisitor(metric, name, root).visit(root)
235
+ return metric
236
+
237
+
238
+ def _parameter_count(node: ast.FunctionDef | ast.AsyncFunctionDef) -> int:
239
+ args = node.args
240
+ return (
241
+ len(getattr(args, "posonlyargs", []))
242
+ + len(args.args)
243
+ + len(args.kwonlyargs)
244
+ + int(args.vararg is not None)
245
+ + int(args.kwarg is not None)
246
+ )
247
+
248
+
249
+ def _static_export_names(node: ast.AST) -> list[str] | None:
250
+ """Return a literal ``__all__`` value, or None when it is dynamic."""
251
+ if not isinstance(node, (ast.List, ast.Tuple, ast.Set)):
252
+ return None
253
+ names = []
254
+ for item in node.elts:
255
+ if not isinstance(item, ast.Constant) or not isinstance(item.value, str):
256
+ return None
257
+ names.append(item.value)
258
+ return names
259
+
260
+
261
+ def _code_facts(tree: ast.Module, purpose: str | None) -> dict:
262
+ """Extract conservative, execution-free facts for structural prior-art checks."""
263
+ functions: dict[str, ast.FunctionDef | ast.AsyncFunctionDef] = {}
264
+ public_names: list[str] = []
265
+ explicit_exports: list[str] | None = None
266
+ dependencies: list[str] = []
267
+
268
+ for node in tree.body:
269
+ if isinstance(node, ast.Import):
270
+ dependencies.extend(alias.name for alias in node.names)
271
+ elif isinstance(node, ast.ImportFrom):
272
+ prefix = "." * node.level
273
+ dependencies.append(prefix + (node.module or ""))
274
+ elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
275
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
276
+ functions[node.name] = node
277
+ if not node.name.startswith("_"):
278
+ public_names.append(node.name)
279
+ elif isinstance(node, (ast.Assign, ast.AnnAssign)):
280
+ targets = node.targets if isinstance(node, ast.Assign) else [node.target]
281
+ for target in targets:
282
+ if not isinstance(target, ast.Name):
283
+ continue
284
+ if target.id == "__all__":
285
+ value = node.value if isinstance(node, ast.Assign) else node.value
286
+ explicit_exports = _static_export_names(value) if value is not None else None
287
+ elif not target.id.startswith("_"):
288
+ public_names.append(target.id)
289
+
290
+ exported = explicit_exports if explicit_exports is not None else public_names
291
+ exported = list(dict.fromkeys(exported))
292
+ exported_functions = [
293
+ {"name": name, "parameterCount": _parameter_count(functions[name])}
294
+ for name in exported
295
+ if name in functions
296
+ ]
297
+ return {
298
+ "exportedSymbols": exported[:100],
299
+ "exportedFunctions": exported_functions[:50],
300
+ "dependencies": list(dict.fromkeys(dependencies))[:50],
301
+ "purpose": purpose,
302
+ }
303
+
304
+
305
+ def _line_counts(source: str) -> dict:
306
+ lines = source.splitlines() or [""]
307
+ comment_lines = set()
308
+ try:
309
+ for token in tokenize.generate_tokens(io.StringIO(source).readline):
310
+ if token.type == tokenize.COMMENT:
311
+ comment_lines.update(range(token.start[0], token.end[0] + 1))
312
+ except (IndentationError, tokenize.TokenError):
313
+ pass
314
+ blank_lines = {index for index, line in enumerate(lines, start=1) if not line.strip()}
315
+ code_lines = len(lines) - len(comment_lines | blank_lines)
316
+ return {
317
+ "lines": len(lines),
318
+ "codeLines": code_lines,
319
+ "commentLines": len(comment_lines - blank_lines),
320
+ "blankLines": len(blank_lines),
321
+ }
322
+
323
+
324
+ def analyze(payload: dict) -> dict:
325
+ source = payload["source"]
326
+ path = payload["path"]
327
+ try:
328
+ tree = ast.parse(source, filename=path, mode="exec")
329
+ except (SyntaxError, ValueError) as error:
330
+ return {
331
+ "status": "parse-error",
332
+ "path": path,
333
+ "language": "python",
334
+ "method": "ast-v1",
335
+ "parser": "python.ast",
336
+ "parseErrors": 1,
337
+ "error": f"Source contains syntax errors; complexity metrics are unavailable: {error.msg if isinstance(error, SyntaxError) else error}",
338
+ **_line_counts(source),
339
+ }
340
+ module = _metric_for(tree, "<module>", "module", 1)
341
+ callables = [_metric_for(node, name, kind, line, end_line).as_dict() for node, name, kind, line, end_line in _callables(tree)]
342
+ all_metrics = [module.as_dict(), *callables]
343
+ dependencies = sum(isinstance(node, (ast.Import, ast.ImportFrom)) for node in ast.walk(tree))
344
+ total_cyclomatic = sum(item["cyclomaticComplexity"] for item in all_metrics)
345
+ total_cognitive = sum(item["cognitiveComplexity"] for item in all_metrics)
346
+ counts = _line_counts(source)
347
+ return {
348
+ "status": "parsed",
349
+ "path": path,
350
+ "language": "python",
351
+ "method": "ast-v1",
352
+ "parser": "python.ast",
353
+ **counts,
354
+ "module": module.as_dict(),
355
+ "callables": callables,
356
+ "codeFacts": _code_facts(tree, ast.get_docstring(tree)),
357
+ "cyclomaticComplexity": max(item["cyclomaticComplexity"] for item in all_metrics),
358
+ "cognitiveComplexity": max(item["cognitiveComplexity"] for item in all_metrics),
359
+ "totalCyclomaticComplexity": total_cyclomatic,
360
+ "totalCognitiveComplexity": total_cognitive,
361
+ "dependencyCount": dependencies,
362
+ "uncertainty": "AST-derived for Python syntax; metric semantics follow the documented code-phage rules and are not a universal quality gate.",
363
+ }
364
+
365
+
366
+ def main() -> int:
367
+ try:
368
+ payload = json.load(sys.stdin)
369
+ result = analyze(payload)
370
+ except Exception as error: # pragma: no cover - defensive process boundary
371
+ result = {"status": "unavailable", "error": str(error)}
372
+ json.dump(result, sys.stdout, separators=(",", ":"))
373
+ sys.stdout.write("\n")
374
+ return 0
375
+
376
+
377
+ if __name__ == "__main__":
378
+ raise SystemExit(main())