oct
Loading...
Searching...
No Matches
engine.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/mutation/engine.py
4
5"""
6Purpose
7-------
8Enumerate and apply deterministic source mutants over the pinned v1 operator set.
9
10Responsibilities
11----------------
12- Derive every mutant of a module purely from its syntax tree, in source order.
13- Give each mutant a stable ``relpath:line:col:op-code`` identity.
14- Render a readable before/after preview for each mutant without touching disk.
15- Apply exactly one named mutant to a source string and return the new text.
16
17Diagnostics
18-----------
19Domain: OCT-MUTATION
20Levels:
21 L2 - lifecycle
22 L3 - enumeration details
23 L4 - deep tracing
24
25Contracts
26---------
27- The same source text always yields the same mutants with the same ids.
28- Enumeration is read-only; nothing in this module writes to the filesystem.
29- A mutant never reproduces the original source, so no enumerated mutant is a
30 guaranteed survivor by construction.
31- ``SyntaxError`` propagates so callers report an unparsable target honestly.
32
33Dependencies
34------------
35- ast (syntax tree, mutation, and source rendering)
36- tokenize (declared source encodings)
37"""
38
39# OCT-LINT: disable-file=dbg_usage reason="Plan 227 P1 pins the engine as stdlib-only, so importing oc_diagnostics is forbidden"
40# OCT-LINT: disable-file=dbg_import reason="Plan 227 P1 pins the engine as stdlib-only"
41
42from __future__ import annotations
43
44import ast
45import copy
46import tokenize
47from dataclasses import asdict, dataclass
48from pathlib import Path
49from typing import Callable
50
51
52class MutantNotFoundError(LookupError):
53 """A named mutant id does not exist in the given source."""
54
55
56# The pinned v1 operator set (plan 227 P2). Growth of these tables is future,
57# evidence-driven work: string mutation and statement deletion are excluded
58# deliberately because both generate more noise than signal.
59_COMPARE_SWAPS: dict[type[ast.cmpop], tuple[type[ast.cmpop], str]] = {
60 ast.Eq: (ast.NotEq, "EQ_NE"),
61 ast.NotEq: (ast.Eq, "NE_EQ"),
62 ast.Lt: (ast.GtE, "LT_GE"),
63 ast.GtE: (ast.Lt, "GE_LT"),
64 ast.Gt: (ast.LtE, "GT_LE"),
65 ast.LtE: (ast.Gt, "LE_GT"),
66}
67
68_BINOP_SWAPS: dict[type[ast.operator], tuple[type[ast.operator], str]] = {
69 ast.Add: (ast.Sub, "ADD_SUB"),
70 ast.Sub: (ast.Add, "SUB_ADD"),
71 ast.Mult: (ast.FloorDiv, "MUL_FLOORDIV"),
72 ast.FloorDiv: (ast.Mult, "FLOORDIV_MUL"),
73}
74
75_BOOLOP_SWAPS: dict[type[ast.boolop], tuple[type[ast.boolop], str]] = {
76 ast.And: (ast.Or, "AND_OR"),
77 ast.Or: (ast.And, "OR_AND"),
78}
79
80
81@dataclass(frozen=True, slots=True)
82class Mutant:
83 """One enumerated mutant with its stable identity and readable preview."""
84
85 mutant_id: str
86 file: str
87 lineno: int
88 col_offset: int
89 op_code: str
90 before: str
91 after: str
92 line_text: str
93
94 def to_dict(self) -> dict[str, str | int]:
95 """Return a JSON-ready representation of every field."""
96 return asdict(self)
97
98
99@dataclass(slots=True)
100class _Site:
101 """An enumerated mutant bound to the in-tree edit that produces it."""
102
103 mutant: Mutant
104 apply: Callable[[], None]
105
106
107class _SiteCollector(ast.NodeVisitor):
108 """Walk a tree once, recording every v1 mutation site it contains.
109
110 The collector records an ``apply`` closure per site instead of a second
111 matching pass, so enumeration and application share one definition of
112 where a mutant lives. Previews are rendered from throwaway copies, which
113 keeps the visited tree pristine until a caller deliberately applies a site.
114 """
115
116 def __init__(self, relpath: str, lines: list[str]) -> None:
117 self.relpath = relpath
118 self.lines = lines
119 self.sites: list[_Site] = []
120
121 def visit_Compare(self, node: ast.Compare) -> None:
122 """Record one swap per comparison operator in a (possibly chained) test.
123
124 The anchor is the operand that FOLLOWS the operator rather than the
125 ``Compare`` node itself: in ``a < b < c`` both operators share the
126 node's coordinate, and two sites at one coordinate with one op-code
127 would produce colliding ids.
128 """
129 for index, operator in enumerate(node.ops):
130 swap = _COMPARE_SWAPS.get(type(operator))
131 if swap is None:
132 continue
133 replacement, op_code = swap
134 anchor = node.comparators[index]
135 self._record(
136 op_code=op_code,
137 lineno=anchor.lineno,
138 col_offset=anchor.col_offset,
139 node=node,
140 mutate_copy=lambda preview, i=index, r=replacement: (
141 preview.ops.__setitem__(i, r())
142 ),
143 apply_in_place=lambda i=index, r=replacement: (
144 node.ops.__setitem__(i, r())
145 ),
146 )
147 self.generic_visit(node)
148
149 def visit_BoolOp(self, node: ast.BoolOp) -> None:
150 """Record the ``and``/``or`` swap for one boolean chain."""
151 swap = _BOOLOP_SWAPS.get(type(node.op))
152 if swap is not None:
153 replacement, op_code = swap
154 self._record(
155 op_code=op_code,
156 lineno=node.lineno,
157 col_offset=node.col_offset,
158 node=node,
159 mutate_copy=lambda preview, r=replacement: setattr(
160 preview, "op", r()
161 ),
162 apply_in_place=lambda r=replacement: setattr(node, "op", r()),
163 )
164 self.generic_visit(node)
165
166 def visit_BinOp(self, node: ast.BinOp) -> None:
167 """Record an arithmetic swap, anchored on the right-hand operand.
168
169 ``a + b + c`` parses as a left-nested pair of ``BinOp`` nodes that
170 share a column, so the right operand is the only coordinate that
171 distinguishes the two additions.
172 """
173 swap = _BINOP_SWAPS.get(type(node.op))
174 if swap is not None:
175 replacement, op_code = swap
176 self._record(
177 op_code=op_code,
178 lineno=node.right.lineno,
179 col_offset=node.right.col_offset,
180 node=node,
181 mutate_copy=lambda preview, r=replacement: setattr(
182 preview, "op", r()
183 ),
184 apply_in_place=lambda r=replacement: setattr(node, "op", r()),
185 )
186 self.generic_visit(node)
187
188 def visit_Constant(self, node: ast.Constant) -> None:
189 """Record numeric tweaks, excluding booleans, strings, and complex values.
190
191 ``n -> 0`` is skipped when ``n`` is already zero: that mutant would be
192 byte-identical to the original and would therefore survive every test
193 suite, inflating the survivor list with a finding no test could ever
194 kill.
195 """
196 value = node.value
197 if isinstance(value, bool) or not isinstance(value, (int, float)):
198 self.generic_visit(node)
199 return
200 self._record(
201 op_code="NUM_INCR",
202 lineno=node.lineno,
203 col_offset=node.col_offset,
204 node=node,
205 mutate_copy=lambda preview: setattr(preview, "value", value + 1),
206 apply_in_place=lambda: setattr(node, "value", value + 1),
207 )
208 if value != 0:
209 # Zero keeps the literal's own type, so a float constant is
210 # mutated to ``0.0`` rather than silently changing type as well.
211 zero = type(value)(0)
212 self._record(
213 op_code="NUM_ZERO",
214 lineno=node.lineno,
215 col_offset=node.col_offset,
216 node=node,
217 mutate_copy=lambda preview: setattr(preview, "value", zero),
218 apply_in_place=lambda: setattr(node, "value", zero),
219 )
220 self.generic_visit(node)
221
222 def visit_If(self, node: ast.If) -> None:
223 """Record a ``not`` insertion on an ``if`` (or ``elif``) test."""
224 self._record_negation(node, "NOT_IF")
225 self.generic_visit(node)
226
227 def visit_While(self, node: ast.While) -> None:
228 """Record a ``not`` insertion on a ``while`` test."""
229 self._record_negation(node, "NOT_WHILE")
230 self.generic_visit(node)
231
232 def _record_negation(self, node: ast.If | ast.While, op_code: str) -> None:
233 """Record the negation site unless the test is already negated.
234
235 ``if not not value:`` has exactly the truth value of ``if value:``, so
236 negating an existing ``not`` yields an equivalent mutant that can never
237 be killed. Excluding it keeps the survivor list honest.
238 """
239 test = node.test
240 if isinstance(test, ast.UnaryOp) and isinstance(test.op, ast.Not):
241 return
242 rendered = ast.unparse(test)
243 self.sites.append(
244 _Site(
245 mutant=self._build_mutant(
246 op_code=op_code,
247 lineno=test.lineno,
248 col_offset=test.col_offset,
249 before=rendered,
250 after=f"not ({rendered})",
251 ),
252 apply=lambda: setattr(
253 node,
254 "test",
255 ast.fix_missing_locations(
256 ast.copy_location(
257 ast.UnaryOp(op=ast.Not(), operand=node.test), node.test
258 )
259 ),
260 ),
261 )
262 )
263
265 self,
266 *,
267 op_code: str,
268 lineno: int,
269 col_offset: int,
270 node: ast.expr,
271 mutate_copy: Callable[[ast.expr], None],
272 apply_in_place: Callable[[], None],
273 ) -> None:
274 """Append one site, rendering its preview from a throwaway copy."""
275 preview = copy.deepcopy(node)
276 mutate_copy(preview)
277 self.sites.append(
278 _Site(
279 mutant=self._build_mutant(
280 op_code=op_code,
281 lineno=lineno,
282 col_offset=col_offset,
283 before=ast.unparse(node),
284 after=ast.unparse(preview),
285 ),
286 apply=apply_in_place,
287 )
288 )
289
291 self,
292 *,
293 op_code: str,
294 lineno: int,
295 col_offset: int,
296 before: str,
297 after: str,
298 ) -> Mutant:
299 """Assemble one mutant record with its stable id and source line."""
300 line_text = self.lines[lineno - 1].strip() if lineno <= len(self.lines) else ""
301 return Mutant(
302 mutant_id=f"{self.relpath}:{lineno}:{col_offset}:{op_code}",
303 file=self.relpath,
304 lineno=lineno,
305 col_offset=col_offset,
306 op_code=op_code,
307 before=before,
308 after=after,
309 line_text=line_text,
310 )
311
312
314 source: str, relpath: str
315) -> tuple[ast.Module, list[_Site]]:
316 """Return the parsed tree alongside the sites that edit it in place."""
317 tree = ast.parse(source, filename=relpath)
318 collector = _SiteCollector(relpath, source.splitlines())
319 collector.visit(tree)
320 sites = sorted(
321 collector.sites,
322 key=lambda site: (
323 site.mutant.lineno,
324 site.mutant.col_offset,
325 site.mutant.op_code,
326 ),
327 )
328 return tree, sites
329
330
331def enumerate_source(source: str, relpath: str) -> list[Mutant]:
332 """Return every v1 mutant of ``source`` in deterministic source order."""
333 return [site.mutant for site in _collect_tree_and_sites(source, relpath)[1]]
334
335
336def enumerate_file(path: str | Path, root: str | Path | None = None) -> list[Mutant]:
337 """Return every v1 mutant of one file, identified relative to ``root``."""
338 resolved = Path(path).resolve()
339 with tokenize.open(resolved) as source_file:
340 source = source_file.read()
341 return enumerate_source(source, relative_id(resolved, root))
342
343
344def relative_id(path: str | Path, root: str | Path | None) -> str:
345 """Return the forward-slash relative path used inside mutant ids.
346
347 Ids are anchored to ``root`` so they stay stable when the same tree is
348 checked out at a different absolute location; a target outside ``root``
349 falls back to its bare filename rather than leaking an absolute path.
350 """
351 resolved = Path(path).resolve()
352 if root is None:
353 return resolved.name
354 try:
355 return resolved.relative_to(Path(root).resolve()).as_posix()
356 except ValueError:
357 return resolved.name
358
359
360def apply_mutant(source: str, mutant_id: str, relpath: str) -> str:
361 """Return ``source`` with exactly the named mutant applied.
362
363 The result is re-rendered by :func:`ast.unparse`, so comments and original
364 formatting are not preserved. That is safe only because the runner writes
365 this text transiently and always restores the pristine bytes afterwards.
366 """
367 tree, sites = _collect_tree_and_sites(source, relpath)
368 for site in sites:
369 if site.mutant.mutant_id == mutant_id:
370 site.apply()
371 return ast.unparse(ast.fix_missing_locations(tree))
372 raise MutantNotFoundError(f"no mutant with id {mutant_id!r} in {relpath}")
None visit_While(self, ast.While node)
Definition engine.py:227
None visit_Constant(self, ast.Constant node)
Definition engine.py:188
None __init__(self, str relpath, list[str] lines)
Definition engine.py:116
None _record_negation(self, ast.If|ast.While node, str op_code)
Definition engine.py:232
None visit_BoolOp(self, ast.BoolOp node)
Definition engine.py:149
Mutant _build_mutant(self, *, str op_code, int lineno, int col_offset, str before, str after)
Definition engine.py:298
None visit_BinOp(self, ast.BinOp node)
Definition engine.py:166
None visit_If(self, ast.If node)
Definition engine.py:222
None _record(self, *, str op_code, int lineno, int col_offset, ast.expr node, Callable[[ast.expr], None] mutate_copy, Callable[[], None] apply_in_place)
Definition engine.py:273
None visit_Compare(self, ast.Compare node)
Definition engine.py:121
dict[str, str|int] to_dict(self)
Definition engine.py:94
list[Mutant] enumerate_file(str|Path path, str|Path|None root=None)
Definition engine.py:336
list[Mutant] enumerate_source(str source, str relpath)
Definition engine.py:331
str relative_id(str|Path path, str|Path|None root)
Definition engine.py:344
str apply_mutant(str source, str mutant_id, str relpath)
Definition engine.py:360
tuple[ast.Module, list[_Site]] _collect_tree_and_sites(str source, str relpath)
Definition engine.py:315