AST Anti-Pattern Slayer
1. System Architecture & Prerequisites
- Runtime binary: Python 3.9 or newer (requires
ast.unparse, stable type hints, and ast.TryStar/ast.Match handling). Python 3.11+ is recommended.
- Standard library used (no third-party packages):
ast, argparse, json, sys, pathlib.
- Input surface: a single Python source file (
*.py) with the assignment to execute.
- The module never executes the analyzed code; all analysis is static and import-free, so the input file does not need resolvable imports at analysis time.
- The sample reference implementation
ast_slayer.py doubles as a CLI:
python ast_slayer.py path/to/script.py — prints the JSON report.
python ast_slayer.py path/to/script.py --fix — additionally rewrites the file in place with guard clauses (only when a conservative rewrite is possible).
- JavaScript-style input is supported only conceptually (the same decision-node counting maps onto Mozilla ESTree shapes once the AST is parsed with a JS toolchain such as
acorn/esprima); this skill ships the Python AST pipeline, which is architecture-identical.
2. Input/Output Data Contracts
2.1 CLI input options (JSON Schema)
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "ast_slayer_cli_options",
"type": "object",
"properties": {
"path": { "type": "string", "description": "Path to the Python source file to analyze." },
"fix": { "type": "boolean", "default": false, "description": "Rewrite the file in place when a conservative guard-clause rewrite exists." },
"require-else":{ "type": "boolean", "default": false, "description": "Only rewrite if/else wrappers; never rewrite a bare trailing if into a guard." },
"max-depth": { "type": "integer", "default": 4, "description": "Nesting depth above which blocks are flagged as 'deep-nesting'." },
"json_out": { "type": "string", "description": "Optional path to write the JSON report" }
},
"required": ["path"]
}
2.2 Output artifacts
- JSON report printed on stdout and optionally written to
--json <path>:
file, cyclomatic_complexity, max_nesting_depth, problem_count, and problems (array of {kind, line, col, detail}), plus a fix object when --fix ran.
- Transformed source written back to the analyzed file only when
--fix succeeded and at least one guard rewrite was applied. A failed parse or a failed rewrite leaves the file byte-for-byte untouched.
3. Production Reference Implementation
"""AST Anti-Pattern Slayer.
Static-analysis module that parses a Python source file into an AST and
reports structural anti-patterns:
* cyclomatic complexity v(G) = E - N + 2P, approximated as
(# decision points) + 1. Decision points are ``if``/``elif``, ``while``,
``for``, ``except``, ``with``, conditional expressions, ``and``/``or``
boolean operators, comprehensions and ``match`` cases.
* maximum block nesting depth (functions, classes, if/for/while/with/try/
match/handler bodies each count as one level).
* bare ``except:`` handlers (they swallow BaseException subclasses such as
KeyboardInterrupt and SystemExit).
* dead code: statements located after an unconditional ``return``, ``raise``,
``break`` or ``continue`` inside the same suite.
The ``GuardClauseTransformer`` then rewrites the very common "whole body
wrapped in an if/else" shape into early-return / continue guard clauses.
Fibonacci demo (docstring-level contract)::
def fib(n): # original
if n <= 1:
return n
else:
return fib(n - 1) + fib(n - 2)
def fib(n): # after guard-clause rewrite
if not (n <= 1):
return fib(n - 1) + fib(n - 2)
return n
The rewrite is conservative: it only fires when the ``if`` is the *final*
statement of a function or loop body, so no statement that followed the
``if`` in the original code can change execution order, and the inverse
condition is filled either with the original ``else`` suite or with the
implicit fall-through jump (``return None`` in function context,
``continue`` in loop context) so both branches behave identically.
"""
from __future__ import annotations
import argparse
import ast
import json
import sys
from pathlib import Path
# Block constructs: each nested suite counts as one nesting level.
_BLOCK = (
ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef,
ast.If, ast.For, ast.AsyncFor, ast.While,
ast.With, ast.AsyncWith, ast.Try, ast.TryStar,
ast.Match, ast.ExceptHandler,
)
# Simple decision leaves: +1 branch each.
_DECISION = (
ast.If, ast.While, ast.For, ast.AsyncFor,
ast.ExceptHandler, ast.With, ast.AsyncWith, ast.IfExp,
ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp,
)
_UNCONDITIONAL = (ast.Return, ast.Raise, ast.Break, ast.Continue)
# --------------------------------------------------------------------------
# metrics
# --------------------------------------------------------------------------
def cyclomatic_complexity(tree: ast.AST) -> int:
"""v(G) approximated as (# decision points) + 1 (P == 1 entry)."""
decision_points = 1
for node in ast.walk(tree):
if isinstance(node, ast.Match):
decision_points += len(node.cases)
elif isinstance(node, ast.BoolOp):
decision_points += max(0, len(node.values) - 1)
elif isinstance(node, _DECISION):
decision_points += 1
return decision_points
def max_nesting_depth(tree: ast.AST) -> int:
"""Maximum depth of nested blocks/procedures in the tree."""
deepest = 0
def walk(node: ast.AST, depth: int) -> None:
nonlocal deepest
deepest = max(deepest, depth)
for child in ast.iter_child_nodes(node):
step = 1 if isinstance(child, _BLOCK) else 0
walk(child, depth + step)
walk(tree, 0)
return deepest
def find_bare_excepts(tree: ast.AST) -> list:
hits = []
for node in ast.walk(tree):
if isinstance(node, ast.ExceptHandler) and node.type is None:
hits.append({
"kind": "bare-except",
"line": node.lineno,
"col": node.col_offset,
"detail": "bare except: catches BaseException incl. KeyboardInterrupt",
})
return hits
def _suites(tree: ast.AST):
"""Yield every statement suite in the tree (each exactly once)."""
for node in ast.walk(tree):
if isinstance(node, (ast.If, ast.While, ast.For, ast.AsyncFor,
ast.With, ast.AsyncWith)):
if node.body:
yield node.body
if node.orelse:
yield node.orelse
elif isinstance(node, (ast.Try, ast.TryStar)):
if node.body:
yield node.body
for handler in node.handlers:
if handler.body:
yield handler.body
if node.orelse:
yield node.orelse
if node.finalbody:
yield node.finalbody
elif isinstance(node, ast.Match):
for case in node.cases:
if case.body:
yield case.body
def find_dead_code(tree: ast.AST) -> list:
"""Statements after an unconditional jump in the same suite are dead."""
hits = []
for suite in _suites(tree):
dead = False
for stmt in suite:
if dead:
hits.append({
"kind": "dead-code",
"line": getattr(stmt, "lineno", 0),
"col": getattr(stmt, "col_offset", 0),
"detail": f"unreachable after earlier jump; statement: {type(stmt).__name__}",
})
elif isinstance(stmt, _UNCONDITIONAL):
dead = True
return hits
def deep_nesting(tree: ast.AST, max_depth: int = 4) -> list:
"""Flag every block whose nesting depth exceeds ``max_depth``."""
hits = []
def walk(node: ast.AST, depth: int) -> None:
if depth > max_depth and hasattr(node, "lineno"):
hits.append({
"kind": "deep-nesting",
"line": node.lineno,
"col": getattr(node, "col_offset", 0),
"detail": f"nesting depth {depth} exceeds limit {max_depth}",
})
for child in ast.iter_child_nodes(node):
step = 1 if isinstance(child, _BLOCK) else 0
walk(child, depth + step)
walk(tree, 0)
return hits
# --------------------------------------------------------------------------
# refactoring
# --------------------------------------------------------------------------
class GuardClauseTransformer(ast.NodeTransformer):
"""Rewrite trailing if/else wraps into guard clauses.
Only rewrites an ``If`` when it is the final statement of a function or
loop body (never reorders code that followed it) and only when the
``if`` body is non-empty. The relocated branch is placed under the
inverted condition and an explicit fall-through jump is appended when
the original branch would have fallen off the end of the block, so the
control-flow outcome is preserved exactly.
"""
def __init__(self, require_else: bool = False):
super().__init__()
self.require_else = require_else
self.transforms_applied = 0
# -- dispatch ------------------------------------------------------------
def visit_FunctionDef(self, node: ast.FunctionDef) -> ast.AST:
node.body = self._transform_suite(node.body, context="function")
return node
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> ast.AST:
node.body = self._transform_suite(node.body, context="function")
return node
def _loop_body(self, node: ast.stmt) -> ast.stmt:
node.body = self._transform_suite(node.body, context="loop")
return node
def visit_For(self, node: ast.For) -> ast.AST:
return self._loop_body(node)
def visit_AsyncFor(self, node: ast.AsyncFor) -> ast.AST:
return self._loop_body(node)
def visit_While(self, node: ast.While) -> ast.AST:
return self._loop_body(node)
# -- core ----------------------------------------------------------------
def _transform_suite(self, body: list, context: str) -> list:
transformed = []
for stmt in body:
out = self.visit(stmt)
if out is None:
continue
if isinstance(out, list):
transformed.extend(out)
else:
transformed.append(out)
return self._tail_guard(transformed, context)
def _tail_guard(self, body: list, context: str) -> list:
if not body:
return body
tail = body[-1]
if not isinstance(tail, ast.If) or not tail.body:
return body
if self.require_else and not tail.orelse:
return body
for stmt in body[:-1]:
if isinstance(stmt, _UNCONDITIONAL):
return body # the trailing if itself would be unreachable
return self._rewrite_tail(body, tail, context)
def _rewrite_tail(self, body: list, tail: ast.If, context: str) -> list:
prelude = body[:-1]
jump = ast.Return(value=None) if context == "function" else ast.Continue()
if tail.orelse:
guard_body = list(tail.orelse)
last_else = guard_body[-1]
if not isinstance(last_else, _UNCONDITIONAL):
guard_body.append(jump)
else:
guard_body = [jump]
inverse = ast.UnaryOp(op=ast.Not(), operand=tail.test)
ast.copy_location(inverse, tail.test)
guard = ast.If(test=inverse, body=guard_body, orelse=[])
ast.copy_location(guard, tail)
main_suite = list(tail.body)
self.transforms_applied += 1
return prelude + [guard] + main_suite
# --------------------------------------------------------------------------
# reporting & CLI
# --------------------------------------------------------------------------
def report_tree(tree: ast.AST, path: Path, max_depth: int = 4) -> dict:
problems = []
problems.extend(find_bare_excepts(tree))
problems.extend(find_dead_code(tree))
problems.extend(deep_nesting(tree, max_depth=max_depth))
return {
"file": str(path),
"cyclomatic_complexity": cyclomatic_complexity(tree),
"max_nesting_depth": max_nesting_depth(tree),
"problems": problems,
"problem_count": len(problems),
}
def main(argv=None) -> int:
parser = argparse.ArgumentParser(
prog="ast_slayer",
description="Static AST analysis and conservative guard-clause refactoring.",
)
parser.add_argument("path", help="path to the Python source file")
parser.add_argument("--fix", action="store_true",
help="rewrite the file in place with guard clauses")
parser.add_argument("--require-else", action="store_true",
help="only rewrite if/else wrappers, never a bare trailing if")
parser.add_argument("--max-depth", type=int, default=4,
help="nesting threshold to flag (default 4)")
parser.add_argument("--json", dest="json_out",
help="also write the JSON report to this path")
args = parser.parse_args(argv)
source_path = Path(args.path)
try:
source = source_path.read_text(encoding="utf-8")
except OSError as exc:
print(f"error: cannot read {source_path}: {exc}", file=sys.stderr)
return 2
try:
tree = ast.parse(source, filename=str(source_path))
except SyntaxError as exc:
print(f"error: parse failed {source_path}:{exc.lineno}: {exc.msg}",
file=sys.stderr)
return 2
report = report_tree(tree, source_path, args.max_depth)
if args.fix:
transformer = GuardClauseTransformer(require_else=args.require_else)
try:
new_tree = transformer.visit(tree)
except Exception as exc:
# never let a rewrite corrupt the file
print(f"error: transformation failed, file left untouched: {exc}",
file=sys.stderr)
report["fix"] = {"applied": False, "error": str(exc)}
else:
ast.fix_missing_locations(new_tree)
report["fix"] = {
"applied": transformer.transforms_applied > 0,
"guard_transforms": transformer.transforms_applied,
}
if transformer.transforms_applied:
source_path.write_text(ast.unparse(new_tree) + "\n", encoding="utf-8")
payload = json.dumps(report, indent=2, sort_keys=True)
print(payload)
if args.json_out:
Path(args.json_out).write_text(payload + "\n", encoding="utf-8")
return 1 if report["problem_count"] else 0
if __name__ == "__main__":
sys.exit(main())
4. Execution Protocol & Step-by-Step Workflow
- Verify the runtime:
python --version must report 3.9+.
- Confirm the target file parses on its own compiler:
python -m py_compile <file> (this catches syntax errors before AST analysis).
- Run the report only first:
python ast_slayer.py path/to/file.py --json report.json. Inspect problem_count, cyclomatic_complexity, and max_nesting_depth before changing anything.
- Review the
problems array. Fix dead code and bare excepts by hand — those two are never auto-rewritten by design.
- For
deep-nesting / high-complexity hits, run the conservative refactor: python ast_slayer.py path/to/file.py --fix. The transformer only rewrites a trailing if ... else ... (or bare trailing if) that is the last statement of a function or loop body into a guard clause:
- function body:
if not <cond>: <else-suite>; return None followed by the original main suite;
- loop body:
if not <cond>: <else-suite>; continue followed by the original main suite.
- Re-run the report after the rewrite and diff the complexity / nesting deltas. If
guard_transforms is 0, the file contains no structurally rewritable pattern — do not force it.
- If the modification is intentional, run the project's own test/lint pipeline (e.g.
pytest, ruff check) to confirm behavior is unchanged; the transformer is semantics-preserving but is not a substitute for the test suite.
- Iterate per file. Each run is idempotent: a second
--fix on an already-rewritten file is a no-op because the trailing if is gone.
5. Edge Cases & Error Handling
- Syntax errors:
ast.parse failure aborts with exit code 2 and never touches the file. The agent must treat the file as un-analyzable and report the line/column from the SyntaxError.
- Encoding: read/write are
utf-8. A non-UTF-8 file raises UnicodeDecodeError; catch it (or wrap the read) and fall back to sys.getdefaultencoding() detection before giving up.
- Bare
if rewrite is off by default of the guard pattern policy of this skill's conservative mode: --require-else narrows the rewrite; without it, a bare trailing if that ends a function body is still safe to rewrite because fall-through equals an implicit return None.
- Unreachable trailing
if: if any preceding statement in the suite is an unconditional jump, _tail_guard refuses the rewrite even though find_dead_code will have flagged it — semantics are preserved by not moving code across jumps.
- Nested functions and generators:
ast.Return(None) inside a generator body behaves like StopIteration, i.e. identical to falling off the end, so generator functions remain correct after the rewrite.
break/continue inside try: a trailing if used inside a try block is never in a function/loop tail position (the try body is the tail), so it is never rewritten — conservative by construction.
- Non-printable / unusual unicode and
ast.unparse limitations: ast.unparse may normalize whitespace or reformat expressions (e.g. string quoting) even when only one guard was applied. Document that in the report; if byte-preservation is required, use --require-else and review the diff.
- Failure recovery: every exit path either leaves the original file untouched or has already completed a full atomic write; the CLI prints
error: lines to stderr and returns 2 on recoverable failures. The agent should re-run against a copy (cp file.py file.py.bak) when iterating on source that is under version control.
1---2name: ast-anti-pattern-slayer3description: Parses Python or JavaScript-style source into an AST, computes cyclomatic complexity v(G) as decision count plus one, maximum block nesting depth, bare except clauses and unreachable dead code, then uses a conservative ast.NodeTransformer to rewrite trailing if/else wrappers into semantics-preserving early return and continue guard clauses, emitting a structured JSON diagnostics report with file line and column locations.4---56# AST Anti-Pattern Slayer78## 1. System Architecture & Prerequisites910- Runtime binary: **Python 3.9 or newer** (requires `ast.unparse`, stable type hints, and `ast.TryStar`/`ast.Match` handling). Python 3.11+ is recommended.11- Standard library used (no third-party packages): `ast`, `argparse`, `json`, `sys`, `pathlib`.12- Input surface: a single Python source file (`*.py`) with the assignment to execute.13- The module never executes the analyzed code; all analysis is static and import-free, so the input file does not need resolvable imports at analysis time.14- The sample reference implementation `ast_slayer.py` doubles as a CLI:15 - `python ast_slayer.py path/to/script.py` — prints the JSON report.16 - `python ast_slayer.py path/to/script.py --fix` — additionally rewrites the file in place with guard clauses (only when a conservative rewrite is possible).17- JavaScript-style input is supported only conceptually (the same decision-node counting maps onto Mozilla ESTree shapes once the AST is parsed with a JS toolchain such as `acorn`/`esprima`); this skill ships the Python AST pipeline, which is architecture-identical.1819## 2. Input/Output Data Contracts2021### 2.1 CLI input options (JSON Schema)2223```json24{25 "$schema": "http://json-schema.org/draft-07/schema#",26 "title": "ast_slayer_cli_options",27 "type": "object",28 "properties": {29 "path": { "type": "string", "description": "Path to the Python source file to analyze." },30 "fix": { "type": "boolean", "default": false, "description": "Rewrite the file in place when a conservative guard-clause rewrite exists." },31 "require-else":{ "type": "boolean", "default": false, "description": "Only rewrite if/else wrappers; never rewrite a bare trailing if into a guard." },32 "max-depth": { "type": "integer", "default": 4, "description": "Nesting depth above which blocks are flagged as 'deep-nesting'." },33 "json_out": { "type": "string", "description": "Optional path to write the JSON report" }34 },35 "required": ["path"]36}37```3839### 2.2 Output artifacts4041- **JSON report** printed on stdout and optionally written to `--json <path>`:42 - `file`, `cyclomatic_complexity`, `max_nesting_depth`, `problem_count`, and `problems` (array of `{kind, line, col, detail}`), plus a `fix` object when `--fix` ran.43- **Transformed source** written back to the analyzed file only when `--fix` succeeded and at least one guard rewrite was applied. A failed parse or a failed rewrite leaves the file byte-for-byte untouched.4445## 3. Production Reference Implementation4647```python48"""AST Anti-Pattern Slayer.4950Static-analysis module that parses a Python source file into an AST and51reports structural anti-patterns:5253* cyclomatic complexity v(G) = E - N + 2P, approximated as54 (# decision points) + 1. Decision points are ``if``/``elif``, ``while``,55 ``for``, ``except``, ``with``, conditional expressions, ``and``/``or``56 boolean operators, comprehensions and ``match`` cases.57* maximum block nesting depth (functions, classes, if/for/while/with/try/58 match/handler bodies each count as one level).59* bare ``except:`` handlers (they swallow BaseException subclasses such as60 KeyboardInterrupt and SystemExit).61* dead code: statements located after an unconditional ``return``, ``raise``,62 ``break`` or ``continue`` inside the same suite.6364The ``GuardClauseTransformer`` then rewrites the very common "whole body65wrapped in an if/else" shape into early-return / continue guard clauses.6667Fibonacci demo (docstring-level contract)::6869 def fib(n): # original70 if n <= 1:71 return n72 else:73 return fib(n - 1) + fib(n - 2)7475 def fib(n): # after guard-clause rewrite76 if not (n <= 1):77 return fib(n - 1) + fib(n - 2)78 return n7980The rewrite is conservative: it only fires when the ``if`` is the *final*81statement of a function or loop body, so no statement that followed the82``if`` in the original code can change execution order, and the inverse83condition is filled either with the original ``else`` suite or with the84implicit fall-through jump (``return None`` in function context,85``continue`` in loop context) so both branches behave identically.86"""8788from __future__ import annotations8990import argparse91import ast92import json93import sys94from pathlib import Path9596# Block constructs: each nested suite counts as one nesting level.97_BLOCK = (98 ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef,99 ast.If, ast.For, ast.AsyncFor, ast.While,100 ast.With, ast.AsyncWith, ast.Try, ast.TryStar,101 ast.Match, ast.ExceptHandler,102)103104# Simple decision leaves: +1 branch each.105_DECISION = (106 ast.If, ast.While, ast.For, ast.AsyncFor,107 ast.ExceptHandler, ast.With, ast.AsyncWith, ast.IfExp,108 ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp,109)110111_UNCONDITIONAL = (ast.Return, ast.Raise, ast.Break, ast.Continue)112113114# --------------------------------------------------------------------------115# metrics116# --------------------------------------------------------------------------117def cyclomatic_complexity(tree: ast.AST) -> int:118 """v(G) approximated as (# decision points) + 1 (P == 1 entry)."""119 decision_points = 1120 for node in ast.walk(tree):121 if isinstance(node, ast.Match):122 decision_points += len(node.cases)123 elif isinstance(node, ast.BoolOp):124 decision_points += max(0, len(node.values) - 1)125 elif isinstance(node, _DECISION):126 decision_points += 1127 return decision_points128129130def max_nesting_depth(tree: ast.AST) -> int:131 """Maximum depth of nested blocks/procedures in the tree."""132 deepest = 0133134 def walk(node: ast.AST, depth: int) -> None:135 nonlocal deepest136 deepest = max(deepest, depth)137 for child in ast.iter_child_nodes(node):138 step = 1 if isinstance(child, _BLOCK) else 0139 walk(child, depth + step)140141 walk(tree, 0)142 return deepest143144145def find_bare_excepts(tree: ast.AST) -> list:146 hits = []147 for node in ast.walk(tree):148 if isinstance(node, ast.ExceptHandler) and node.type is None:149 hits.append({150 "kind": "bare-except",151 "line": node.lineno,152 "col": node.col_offset,153 "detail": "bare except: catches BaseException incl. KeyboardInterrupt",154 })155 return hits156157158def _suites(tree: ast.AST):159 """Yield every statement suite in the tree (each exactly once)."""160 for node in ast.walk(tree):161 if isinstance(node, (ast.If, ast.While, ast.For, ast.AsyncFor,162 ast.With, ast.AsyncWith)):163 if node.body:164 yield node.body165 if node.orelse:166 yield node.orelse167 elif isinstance(node, (ast.Try, ast.TryStar)):168 if node.body:169 yield node.body170 for handler in node.handlers:171 if handler.body:172 yield handler.body173 if node.orelse:174 yield node.orelse175 if node.finalbody:176 yield node.finalbody177 elif isinstance(node, ast.Match):178 for case in node.cases:179 if case.body:180 yield case.body181182183def find_dead_code(tree: ast.AST) -> list:184 """Statements after an unconditional jump in the same suite are dead."""185 hits = []186 for suite in _suites(tree):187 dead = False188 for stmt in suite:189 if dead:190 hits.append({191 "kind": "dead-code",192 "line": getattr(stmt, "lineno", 0),193 "col": getattr(stmt, "col_offset", 0),194 "detail": f"unreachable after earlier jump; statement: {type(stmt).__name__}",195 })196 elif isinstance(stmt, _UNCONDITIONAL):197 dead = True198 return hits199200201def deep_nesting(tree: ast.AST, max_depth: int = 4) -> list:202 """Flag every block whose nesting depth exceeds ``max_depth``."""203 hits = []204205 def walk(node: ast.AST, depth: int) -> None:206 if depth > max_depth and hasattr(node, "lineno"):207 hits.append({208 "kind": "deep-nesting",209 "line": node.lineno,210 "col": getattr(node, "col_offset", 0),211 "detail": f"nesting depth {depth} exceeds limit {max_depth}",212 })213 for child in ast.iter_child_nodes(node):214 step = 1 if isinstance(child, _BLOCK) else 0215 walk(child, depth + step)216217 walk(tree, 0)218 return hits219220221# --------------------------------------------------------------------------222# refactoring223# --------------------------------------------------------------------------224class GuardClauseTransformer(ast.NodeTransformer):225 """Rewrite trailing if/else wraps into guard clauses.226227 Only rewrites an ``If`` when it is the final statement of a function or228 loop body (never reorders code that followed it) and only when the229 ``if`` body is non-empty. The relocated branch is placed under the230 inverted condition and an explicit fall-through jump is appended when231 the original branch would have fallen off the end of the block, so the232 control-flow outcome is preserved exactly.233 """234235 def __init__(self, require_else: bool = False):236 super().__init__()237 self.require_else = require_else238 self.transforms_applied = 0239240 # -- dispatch ------------------------------------------------------------241 def visit_FunctionDef(self, node: ast.FunctionDef) -> ast.AST:242 node.body = self._transform_suite(node.body, context="function")243 return node244245 def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> ast.AST:246 node.body = self._transform_suite(node.body, context="function")247 return node248249 def _loop_body(self, node: ast.stmt) -> ast.stmt:250 node.body = self._transform_suite(node.body, context="loop")251 return node252253 def visit_For(self, node: ast.For) -> ast.AST:254 return self._loop_body(node)255256 def visit_AsyncFor(self, node: ast.AsyncFor) -> ast.AST:257 return self._loop_body(node)258259 def visit_While(self, node: ast.While) -> ast.AST:260 return self._loop_body(node)261262 # -- core ----------------------------------------------------------------263 def _transform_suite(self, body: list, context: str) -> list:264 transformed = []265 for stmt in body:266 out = self.visit(stmt)267 if out is None:268 continue269 if isinstance(out, list):270 transformed.extend(out)271 else:272 transformed.append(out)273 return self._tail_guard(transformed, context)274275 def _tail_guard(self, body: list, context: str) -> list:276 if not body:277 return body278 tail = body[-1]279 if not isinstance(tail, ast.If) or not tail.body:280 return body281 if self.require_else and not tail.orelse:282 return body283 for stmt in body[:-1]:284 if isinstance(stmt, _UNCONDITIONAL):285 return body # the trailing if itself would be unreachable286 return self._rewrite_tail(body, tail, context)287288 def _rewrite_tail(self, body: list, tail: ast.If, context: str) -> list:289 prelude = body[:-1]290 jump = ast.Return(value=None) if context == "function" else ast.Continue()291292 if tail.orelse:293 guard_body = list(tail.orelse)294 last_else = guard_body[-1]295 if not isinstance(last_else, _UNCONDITIONAL):296 guard_body.append(jump)297 else:298 guard_body = [jump]299300 inverse = ast.UnaryOp(op=ast.Not(), operand=tail.test)301 ast.copy_location(inverse, tail.test)302 guard = ast.If(test=inverse, body=guard_body, orelse=[])303 ast.copy_location(guard, tail)304305 main_suite = list(tail.body)306 self.transforms_applied += 1307 return prelude + [guard] + main_suite308309310# --------------------------------------------------------------------------311# reporting & CLI312# --------------------------------------------------------------------------313def report_tree(tree: ast.AST, path: Path, max_depth: int = 4) -> dict:314 problems = []315 problems.extend(find_bare_excepts(tree))316 problems.extend(find_dead_code(tree))317 problems.extend(deep_nesting(tree, max_depth=max_depth))318 return {319 "file": str(path),320 "cyclomatic_complexity": cyclomatic_complexity(tree),321 "max_nesting_depth": max_nesting_depth(tree),322 "problems": problems,323 "problem_count": len(problems),324 }325326327def main(argv=None) -> int:328 parser = argparse.ArgumentParser(329 prog="ast_slayer",330 description="Static AST analysis and conservative guard-clause refactoring.",331 )332 parser.add_argument("path", help="path to the Python source file")333 parser.add_argument("--fix", action="store_true",334 help="rewrite the file in place with guard clauses")335 parser.add_argument("--require-else", action="store_true",336 help="only rewrite if/else wrappers, never a bare trailing if")337 parser.add_argument("--max-depth", type=int, default=4,338 help="nesting threshold to flag (default 4)")339 parser.add_argument("--json", dest="json_out",340 help="also write the JSON report to this path")341 args = parser.parse_args(argv)342343 source_path = Path(args.path)344 try:345 source = source_path.read_text(encoding="utf-8")346 except OSError as exc:347 print(f"error: cannot read {source_path}: {exc}", file=sys.stderr)348 return 2349 try:350 tree = ast.parse(source, filename=str(source_path))351 except SyntaxError as exc:352 print(f"error: parse failed {source_path}:{exc.lineno}: {exc.msg}",353 file=sys.stderr)354 return 2355356 report = report_tree(tree, source_path, args.max_depth)357358 if args.fix:359 transformer = GuardClauseTransformer(require_else=args.require_else)360 try:361 new_tree = transformer.visit(tree)362 except Exception as exc:363 # never let a rewrite corrupt the file364 print(f"error: transformation failed, file left untouched: {exc}",365 file=sys.stderr)366 report["fix"] = {"applied": False, "error": str(exc)}367 else:368 ast.fix_missing_locations(new_tree)369 report["fix"] = {370 "applied": transformer.transforms_applied > 0,371 "guard_transforms": transformer.transforms_applied,372 }373 if transformer.transforms_applied:374 source_path.write_text(ast.unparse(new_tree) + "\n", encoding="utf-8")375376 payload = json.dumps(report, indent=2, sort_keys=True)377 print(payload)378 if args.json_out:379 Path(args.json_out).write_text(payload + "\n", encoding="utf-8")380 return 1 if report["problem_count"] else 0381382383if __name__ == "__main__":384 sys.exit(main())385```386387## 4. Execution Protocol & Step-by-Step Workflow3883891. Verify the runtime: `python --version` must report 3.9+.3902. Confirm the target file parses on its own compiler: `python -m py_compile <file>` (this catches syntax errors before AST analysis).3913. Run the report only first: `python ast_slayer.py path/to/file.py --json report.json`. Inspect `problem_count`, `cyclomatic_complexity`, and `max_nesting_depth` before changing anything.3924. Review the `problems` array. Fix **dead code** and **bare excepts** by hand — those two are never auto-rewritten by design.3935. For `deep-nesting` / high-complexity hits, run the conservative refactor: `python ast_slayer.py path/to/file.py --fix`. The transformer only rewrites a trailing `if ... else ...` (or bare trailing `if`) that is the last statement of a function or loop body into a guard clause:394 - function body: `if not <cond>: <else-suite>; return None` followed by the original main suite;395 - loop body: `if not <cond>: <else-suite>; continue` followed by the original main suite.3966. Re-run the report after the rewrite and diff the complexity / nesting deltas. If `guard_transforms` is 0, the file contains no structurally rewritable pattern — do not force it.3977. If the modification is intentional, run the project's own test/lint pipeline (e.g. `pytest`, `ruff check`) to confirm behavior is unchanged; the transformer is semantics-preserving but is not a substitute for the test suite.3988. Iterate per file. Each run is idempotent: a second `--fix` on an already-rewritten file is a no-op because the trailing `if` is gone.399400## 5. Edge Cases & Error Handling401402- **Syntax errors**: `ast.parse` failure aborts with exit code 2 and never touches the file. The agent must treat the file as un-analyzable and report the line/column from the `SyntaxError`.403- **Encoding**: read/write are `utf-8`. A non-UTF-8 file raises `UnicodeDecodeError`; catch it (or wrap the read) and fall back to `sys.getdefaultencoding()` detection before giving up.404- **Bare `if` rewrite is off by default of the guard pattern policy of this skill's conservative mode**: `--require-else` narrows the rewrite; without it, a bare trailing `if` that ends a function body is still safe to rewrite because fall-through equals an implicit `return None`.405- **Unreachable trailing `if`**: if any preceding statement in the suite is an unconditional jump, `_tail_guard` refuses the rewrite even though `find_dead_code` will have flagged it — semantics are preserved by not moving code across jumps.406- **Nested functions and generators**: `ast.Return(None)` inside a generator body behaves like `StopIteration`, i.e. identical to falling off the end, so generator functions remain correct after the rewrite.407- **`break`/`continue` inside `try`**: a trailing `if` used inside a `try` block is never in a function/loop tail position (the `try` body is the tail), so it is never rewritten — conservative by construction.408- **Non-printable / unusual unicode and `ast.unparse` limitations**: `ast.unparse` may normalize whitespace or reformat expressions (e.g. string quoting) even when only one guard was applied. Document that in the report; if byte-preservation is required, use `--require-else` and review the diff.409- **Failure recovery**: every exit path either leaves the original file untouched or has already completed a full atomic write; the CLI prints `error:` lines to stderr and returns 2 on recoverable failures. The agent should re-run against a copy (`cp file.py file.py.bak`) when iterating on source that is under version control.