oc_chat
Loading...
Searching...
No Matches
static_scan.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/testing/static_scan.py
4
5"""
6The §20.2 static AST guard for selected test files (Plan 184).
7
8Purpose
9-------
10Implement the stand-in's static contract subset (spec-10 §10/§20.2):
11raw ``os.chdir`` calls (rule ``cwd_chdir``) and the four raw
12environment-mutation forms §6.4 names — subscript assignment, ``del``,
13``os.environ.update(...)``, ``os.putenv(...)`` — (rule
14``env_mutation``), both error severity; plus the advisory literal-path
15owned-write pre-check (rule ``owned_writes_outside_tmp``).
16
17Responsibilities
18----------------
19- ``scan_files`` — parse each selected test file and return §11-shaped
20 violation entries (``nodeid`` is ``None`` for static findings; the
21 line number is carried in the message).
22- The approved-container exemption: calls inside a function decorated
23 with ``@pytest.fixture`` (bare or called form) are exempt — the
24 stand-in's reading of §6.1/§6.4 "approved fixtures"; v1.0 refines
25 this with config-declared fixture inventories.
26
27Diagnostics
28-----------
29Domain: OC_TESTING
30Levels:
31 L2 — lifecycle events
32 L3 — semantic details
33 L4 — deep tracing
34
35Contracts
36---------
37- The scan covers exactly the four §6.4-named env forms; other mapping
38 mutators (``pop``, ``setdefault``, ``clear``) are v1.0 scope.
39- String/docstring mentions never match (AST, not grep).
40- Advisory findings never drive an exit code (§11: only error-severity
41 entries produce exit 2).
42
43Dependencies
44------------
45- oc_diagnostics (structured debug logging via _dbg)
46- oc_chat.testing.report (violation constructor)
47"""
48from __future__ import annotations
49
50import ast
51from collections.abc import Iterable
52from pathlib import Path
53
54from oc_diagnostics import _dbg
55
56from oc_chat.testing.report import make_violation
57
58_WRITE_MODES = ("w", "a", "x")
59
60
61def _is_fixture_decorated(node: ast.AST) -> bool:
62 """True when *node* is a function decorated with pytest.fixture."""
63 if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
64 return False
65 for decorator in node.decorator_list:
66 target = decorator.func if isinstance(decorator, ast.Call) else decorator
67 if isinstance(target, ast.Attribute) and target.attr == "fixture":
68 return True
69 if isinstance(target, ast.Name) and target.id == "fixture":
70 return True
71 return False
72
73
74def _fixture_spans(tree: ast.Module) -> list[tuple[int, int]]:
75 """Line spans of every fixture-decorated function (approved
76 containers)."""
77 spans: list[tuple[int, int]] = []
78 for node in ast.walk(tree):
79 if _is_fixture_decorated(node):
80 end = getattr(node, "end_lineno", node.lineno)
81 spans.append((node.lineno, end))
82 return spans
83
84
85def _in_spans(lineno: int, spans: list[tuple[int, int]]) -> bool:
86 return any(start <= lineno <= end for start, end in spans)
87
88
89def _is_os_environ(node: ast.AST) -> bool:
90 return (
91 isinstance(node, ast.Attribute)
92 and node.attr == "environ"
93 and isinstance(node.value, ast.Name)
94 and node.value.id == "os"
95 )
96
97
98def _is_string_literal(node: ast.AST) -> bool:
99 return isinstance(node, ast.Constant) and isinstance(node.value, str)
100
101
102def _scan_tree(tree: ast.Module, file_str: str) -> list[dict]:
103 func = "_scan_tree"
104 spans = _fixture_spans(tree)
105 findings: list[dict] = []
106
107 def _error(rule: str, lineno: int, detail: str) -> None:
108 findings.append(make_violation(
109 rule, file_str, None, "error",
110 f"{detail} (line {lineno}); use monkeypatch or an approved "
111 "fixture (spec-10 section 6)",
112 ))
113
114 def _advisory(lineno: int, detail: str) -> None:
115 findings.append(make_violation(
116 "owned_writes_outside_tmp", file_str, None, "advisory",
117 f"{detail} (line {lineno}); prefer tmp_path-derived targets "
118 "(spec-10 section 6.2 advisory pre-check)",
119 ))
120
121 for node in ast.walk(tree):
122 lineno = getattr(node, "lineno", None)
123 if lineno is None or _in_spans(lineno, spans):
124 continue
125
126 if isinstance(node, ast.Call):
127 callee = node.func
128 # os.chdir(...)
129 if (
130 isinstance(callee, ast.Attribute)
131 and callee.attr == "chdir"
132 and isinstance(callee.value, ast.Name)
133 and callee.value.id == "os"
134 ):
135 _error("cwd_chdir", lineno, "raw os.chdir call")
136 # os.environ.update(...)
137 elif (
138 isinstance(callee, ast.Attribute)
139 and callee.attr == "update"
140 and _is_os_environ(callee.value)
141 ):
142 _error("env_mutation", lineno, "raw os.environ.update call")
143 # os.putenv(...)
144 elif (
145 isinstance(callee, ast.Attribute)
146 and callee.attr == "putenv"
147 and isinstance(callee.value, ast.Name)
148 and callee.value.id == "os"
149 ):
150 _error("env_mutation", lineno, "raw os.putenv call")
151 # open("literal", "w"/"a"/"x")
152 elif (
153 isinstance(callee, ast.Name)
154 and callee.id == "open"
155 and node.args
156 and _is_string_literal(node.args[0])
157 ):
158 mode = None
159 if len(node.args) >= 2 and _is_string_literal(node.args[1]):
160 mode = node.args[1].value
161 for kw in node.keywords:
162 if kw.arg == "mode" and _is_string_literal(kw.value):
163 mode = kw.value.value
164 if mode and any(ch in mode for ch in _WRITE_MODES):
165 _advisory(lineno, "literal-path write via open()")
166 # Path("literal").write_text(...) / .write_bytes(...)
167 elif (
168 isinstance(callee, ast.Attribute)
169 and callee.attr in ("write_text", "write_bytes")
170 and isinstance(callee.value, ast.Call)
171 and isinstance(callee.value.func, ast.Name)
172 and callee.value.func.id == "Path"
173 and callee.value.args
174 and _is_string_literal(callee.value.args[0])
175 ):
176 _advisory(lineno, "literal-path write via Path().write_*")
177 elif isinstance(node, ast.Assign):
178 for target in node.targets:
179 if (
180 isinstance(target, ast.Subscript)
181 and _is_os_environ(target.value)
182 ):
183 _error("env_mutation", lineno,
184 "raw os.environ[...] assignment")
185 elif isinstance(node, ast.AugAssign):
186 if (
187 isinstance(node.target, ast.Subscript)
188 and _is_os_environ(node.target.value)
189 ):
190 _error("env_mutation", lineno,
191 "raw os.environ[...] augmented assignment")
192 elif isinstance(node, ast.Delete):
193 for target in node.targets:
194 if (
195 isinstance(target, ast.Subscript)
196 and _is_os_environ(target.value)
197 ):
198 _error("env_mutation", lineno,
199 "raw del os.environ[...]")
200
201 _dbg("OC_TESTING", func, f"{file_str}: {len(findings)} finding(s)", 4)
202 return findings
203
204
205def scan_files(files: Iterable[Path | str]) -> list[dict]:
206 """Run the static guard over *files*; return §11 violation entries.
207
208 Unparseable files are skipped here — pytest collection reports the
209 syntax error itself as ``COLLECTION_ERROR`` (section 4 case 1), so
210 the scan never double-reports it.
211 """
212 func = "scan_files"
213 findings: list[dict] = []
214 for entry in files:
215 path = Path(entry)
216 try:
217 tree = ast.parse(path.read_text(encoding="utf-8"))
218 except (OSError, SyntaxError) as exc:
219 _dbg("OC_TESTING", func, f"skip unparseable {path}: {exc}", 2)
220 continue
221 findings.extend(_scan_tree(tree, str(path)))
222 _dbg("OC_TESTING", func, f"total findings: {len(findings)}", 3)
223 return findings
224
225
226_dbg("OC_TESTING", "module", "loaded", 4)
227
228__all__ = ["scan_files"]
list[tuple[int, int]] _fixture_spans(ast.Module tree)
bool _in_spans(int lineno, list[tuple[int, int]] spans)
list[dict] _scan_tree(ast.Module tree, str file_str)
bool _is_string_literal(ast.AST node)
bool _is_fixture_decorated(ast.AST node)
list[dict] scan_files(Iterable[Path|str] files)
bool _is_os_environ(ast.AST node)