gdmutant 0.1.0__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.
- gdmutant/__init__.py +15 -0
- gdmutant/adapters/__init__.py +3 -0
- gdmutant/adapters/gdscript/__init__.py +505 -0
- gdmutant/adapters/gdscript/runner.py +546 -0
- gdmutant/cli.py +1786 -0
- gdmutant/engine/__init__.py +6 -0
- gdmutant/engine/adapter.py +30 -0
- gdmutant/engine/explain.py +466 -0
- gdmutant/engine/htmlreport.py +1465 -0
- gdmutant/engine/loop.py +931 -0
- gdmutant/engine/mutants.py +100 -0
- gdmutant/engine/operators/__init__.py +140 -0
- gdmutant/engine/report.py +318 -0
- gdmutant/engine/runner.py +217 -0
- gdmutant/engine/spans.py +88 -0
- gdmutant/engine/survivor_reference.py +292 -0
- gdmutant/examples/gdmutant-hello-world.gd +6 -0
- gdmutant/py.typed +0 -0
- gdmutant-0.1.0.dist-info/METADATA +184 -0
- gdmutant-0.1.0.dist-info/RECORD +23 -0
- gdmutant-0.1.0.dist-info/WHEEL +4 -0
- gdmutant-0.1.0.dist-info/entry_points.txt +2 -0
- gdmutant-0.1.0.dist-info/licenses/LICENSE +21 -0
gdmutant/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""gdmutant — a language-agnostic mutation-testing tool (GDScript first).
|
|
2
|
+
|
|
3
|
+
See docs/decisions/0001 for the Python/gdtoolkit rationale and README.md for the design goals.
|
|
4
|
+
The v0.1 engine (mutate -> run -> tally -> report) is built: the language-neutral `gdmutant.engine`
|
|
5
|
+
+ the `gdmutant.adapters.gdscript` adapter, run via the `gdmutant run` CLI.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
__version__ = version("gdmutant")
|
|
12
|
+
except PackageNotFoundError: # running from a source tree that isn't installed
|
|
13
|
+
__version__ = "0.0.0"
|
|
14
|
+
|
|
15
|
+
__all__ = ["__version__"]
|
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
"""GDScript adapter — the mutation half (no Godot).
|
|
2
|
+
|
|
3
|
+
Locates mutable tokens with gdtoolkit and turns them into engine `MutationSite`s, generates
|
|
4
|
+
`Mutant`s (via `engine.mutants.generate`), and enforces **NF-5** by re-parsing each mutant.
|
|
5
|
+
|
|
6
|
+
gdtoolkit does not surface tokens inside string literals or comments, and tokenizes compound
|
|
7
|
+
operators (`+=`, `->`, `>=`) atomically (verified with the tokenization spike), so keeping only
|
|
8
|
+
tokens the operator catalog mutates never edits inside a string/comment or half of a compound
|
|
9
|
+
operator. The Godot test runner is a separate concern (Slice 4).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from collections.abc import Iterator
|
|
16
|
+
from dataclasses import dataclass, replace
|
|
17
|
+
from itertools import pairwise
|
|
18
|
+
|
|
19
|
+
from gdtoolkit.parser import parser as _gdparser
|
|
20
|
+
from lark import Token, Tree
|
|
21
|
+
from lark.exceptions import LarkError
|
|
22
|
+
|
|
23
|
+
from gdmutant.engine.adapter import Adapter
|
|
24
|
+
from gdmutant.engine.mutants import Mutant, MutationSite, generate
|
|
25
|
+
from gdmutant.engine.operators import CATALOG, Operator, all_replacements
|
|
26
|
+
from gdmutant.engine.spans import Span, text_at
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _parse(source: str) -> Tree[Token]:
|
|
30
|
+
# gather_metadata attaches spans to Tree *nodes*; the token line/column positions this adapter
|
|
31
|
+
# reads come from lark's lexer regardless. Kept on for any future tree-level use (harmless).
|
|
32
|
+
tree: Tree[Token] = _gdparser.parse(source, gather_metadata=True)
|
|
33
|
+
return tree
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _span_of(tok: Token) -> Span:
|
|
37
|
+
line, col, end_line, end_col = tok.line, tok.column, tok.end_line, tok.end_column
|
|
38
|
+
# lark's lexer always sets token positions; assert non-None only to satisfy the Optional types.
|
|
39
|
+
assert line and col and end_line and end_col # pragma: no cover
|
|
40
|
+
return Span(line, col, end_line, end_col)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
#: The operator id every statement-deletion mutant carries. The generation half lives further down
|
|
44
|
+
#: (`_statement_deletions`); the id is declared up here because `unknown_ignore_operators` needs it
|
|
45
|
+
#: to know that ``ignore[statement-deletion]`` names a real operator.
|
|
46
|
+
STATEMENT_DELETION_ID = "statement-deletion"
|
|
47
|
+
|
|
48
|
+
# The canonical annotation prefix (the spelling used in docs); the regex below is the lenient parse.
|
|
49
|
+
_IGNORE_MARKER = "# gdmutant: ignore"
|
|
50
|
+
|
|
51
|
+
# ``# gdmutant: ignore`` [optional ``[op1, op2]``] [optional reason]. Bare (no brackets) suppresses
|
|
52
|
+
# every operator on the line; ``[ops]`` suppresses only those; trailing text is the reason.
|
|
53
|
+
_IGNORE_RE = re.compile(r"#\s*gdmutant:\s*ignore\s*(?:\[([^\]]*)\])?\s*(.*)$")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class _IgnoreDirective:
|
|
58
|
+
"""A parsed ``# gdmutant: ignore`` annotation. `operators` is ``None`` for a bare marker (all
|
|
59
|
+
operators on the line) or the set of operator ids to suppress; `reason` is the trailing text."""
|
|
60
|
+
|
|
61
|
+
operators: frozenset[str] | None
|
|
62
|
+
reason: str
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _ignore_directives(source: str) -> dict[int, _IgnoreDirective]:
|
|
66
|
+
"""1-based line -> the ``# gdmutant: ignore`` directive on it (a ``# noqa``-style opt-out for
|
|
67
|
+
equivalent/unkillable mutants). Comments aren't tokens, so this scans raw source; lines split on
|
|
68
|
+
``\\n`` only, matching the engine's line counting (spans.py), so numbers align with tokens.
|
|
69
|
+
|
|
70
|
+
``# gdmutant: ignore`` → all operators on the line; ``ignore[comparison, numeric]`` → only them;
|
|
71
|
+
text after the marker/brackets is the human reason (surfaced as the report's ``statusReason``).
|
|
72
|
+
"""
|
|
73
|
+
directives: dict[int, _IgnoreDirective] = {}
|
|
74
|
+
for i, line in enumerate(source.split("\n"), start=1):
|
|
75
|
+
match = _IGNORE_RE.search(line)
|
|
76
|
+
if match is None:
|
|
77
|
+
continue
|
|
78
|
+
ops_group, reason = match.group(1), match.group(2).strip()
|
|
79
|
+
operators = (
|
|
80
|
+
None
|
|
81
|
+
if ops_group is None
|
|
82
|
+
else frozenset(name.strip() for name in ops_group.split(",") if name.strip())
|
|
83
|
+
)
|
|
84
|
+
directives[i] = _IgnoreDirective(operators, reason)
|
|
85
|
+
return directives
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def unknown_ignore_operators(
|
|
89
|
+
source: str, catalog: tuple[Operator, ...] = CATALOG
|
|
90
|
+
) -> list[tuple[int, str]]:
|
|
91
|
+
"""``(line, name)`` for every malformed operator scope in an ignore directive — either a name no
|
|
92
|
+
mutant this adapter generates can carry (a likely typo) or **empty brackets** ``ignore[]``
|
|
93
|
+
(reported with ``name == ""``). Both silently suppress nothing, so the CLI warns; the run is
|
|
94
|
+
never failed.
|
|
95
|
+
|
|
96
|
+
The valid names are `catalog`'s ids **plus** `STATEMENT_DELETION_ID`, which is exactly what
|
|
97
|
+
`_mark_ignored` matches a directive against. Statement deletion is structural rather than a
|
|
98
|
+
token swap, so it lives here instead of the token catalog (see `_statement_deletions`) — and
|
|
99
|
+
validating against the catalog alone told anyone writing the documented, *working*
|
|
100
|
+
``# gdmutant: ignore[statement-deletion]`` that their annotation suppressed nothing.
|
|
101
|
+
"""
|
|
102
|
+
valid = {op.id for op in catalog} | {STATEMENT_DELETION_ID}
|
|
103
|
+
warnings: list[tuple[int, str]] = []
|
|
104
|
+
for line, directive in _ignore_directives(source).items():
|
|
105
|
+
if directive.operators is None:
|
|
106
|
+
continue # a bare marker (no brackets) is well-formed — suppresses the whole line
|
|
107
|
+
if not directive.operators:
|
|
108
|
+
warnings.append((line, "")) # `ignore[]`: empty brackets, matches no operator
|
|
109
|
+
continue
|
|
110
|
+
warnings.extend((line, name) for name in sorted(directive.operators) if name not in valid)
|
|
111
|
+
return warnings
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _string_format_percents(tree: Tree[Token]) -> set[tuple[int | None, int | None]]:
|
|
115
|
+
"""Positions ``(line, column)`` of ``%`` tokens that are the **string-format** operator, which
|
|
116
|
+
the modulo operator must not mutate.
|
|
117
|
+
|
|
118
|
+
``%`` is overloaded in GDScript: arithmetic modulo *and* string formatting (``"fmt" % args``).
|
|
119
|
+
The distinction is the direct **left operand**: a bare string literal means formatting. That has
|
|
120
|
+
to be read from the parse tree, not the flat token stream — ``d["k"] % x`` is genuine modulo,
|
|
121
|
+
but its token immediately before ``%`` is the *index* string ``"k"`` (its left operand is the
|
|
122
|
+
``d[...]`` ``subscr_expr`` subtree, not a string), so a token-adjacency check would wrongly drop
|
|
123
|
+
it. Here each ``%`` in an ``mdr_expr`` (mul/div/remainder) node is skipped only when the node
|
|
124
|
+
child *directly* to its left is a ``string`` node.
|
|
125
|
+
|
|
126
|
+
Also recognised: a **computed string** left operand — a parenthesised ``+``-concatenation with a
|
|
127
|
+
string-literal operand, e.g. ``("Hi " + name) % x``. No type inference: `name`'s runtime type is
|
|
128
|
+
never checked, only the parse-tree shape (a `+`-only ``arith_expr`` with a bare-string operand
|
|
129
|
+
somewhere in it) — a heuristic, not a proof, but this is the *noise* direction (a format ``%``
|
|
130
|
+
wrongly mutated to ``*``/``/`` errors at runtime — an ERROR verdict, never a silently-wrong
|
|
131
|
+
survivor). The risk this function actually guards against is the opposite one: newly suppressing
|
|
132
|
+
a *genuine* modulo site. A `-` anywhere in the parenthesised expression (arithmetic, not
|
|
133
|
+
string-building), or no string literal in it at all, both still rule that out.
|
|
134
|
+
"""
|
|
135
|
+
skip: set[tuple[int | None, int | None]] = set()
|
|
136
|
+
for node in tree.iter_subtrees():
|
|
137
|
+
if node.data != "mdr_expr":
|
|
138
|
+
continue
|
|
139
|
+
for prev, cur in pairwise(node.children):
|
|
140
|
+
if (
|
|
141
|
+
isinstance(cur, Token)
|
|
142
|
+
and cur.value == "%"
|
|
143
|
+
and isinstance(prev, Tree)
|
|
144
|
+
and _is_string_format_operand(prev)
|
|
145
|
+
):
|
|
146
|
+
skip.add((cur.line, cur.column))
|
|
147
|
+
return skip
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _is_string_format_operand(node: Tree[Token]) -> bool:
|
|
151
|
+
"""True if `node` (the direct left operand of a ``%``) is a bare string literal, or a
|
|
152
|
+
parenthesised ``+``-only concatenation containing one (see `_string_format_percents`)."""
|
|
153
|
+
if node.data == "string":
|
|
154
|
+
return True
|
|
155
|
+
if node.data == "par_expr" and len(node.children) == 1:
|
|
156
|
+
inner = node.children[0]
|
|
157
|
+
return isinstance(inner, Tree) and _is_string_concatenation(inner)
|
|
158
|
+
return False
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _is_string_concatenation(node: Tree[Token]) -> bool:
|
|
162
|
+
"""True if `node` is an ``arith_expr`` joined only by ``+`` (never ``-``, which means genuine
|
|
163
|
+
arithmetic) with at least one operand that is itself a string-format operand — a bare literal or
|
|
164
|
+
a nested parenthesised concatenation, so ``("a" + ("b" + c)) % x`` is also recognised."""
|
|
165
|
+
if node.data != "arith_expr":
|
|
166
|
+
return False
|
|
167
|
+
# `arith_expr` is flat and mixed: PLUS/MINUS operator *tokens* interleave with operands that are
|
|
168
|
+
# themselves either Trees (a nested expression, e.g. a string literal) or bare Tokens (a NAME,
|
|
169
|
+
# NUMBER, ...) — so operators must be picked out by token *type*, not by `isinstance(_, Token)`
|
|
170
|
+
# alone, which every bare-token operand also satisfies.
|
|
171
|
+
if any(isinstance(child, Token) and child.type == "MINUS" for child in node.children):
|
|
172
|
+
return False
|
|
173
|
+
operands = [child for child in node.children if isinstance(child, Tree)]
|
|
174
|
+
return any(_is_string_format_operand(operand) for operand in operands)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _string_concatenation_pluses(tree: Tree[Token]) -> set[tuple[int | None, int | None]]:
|
|
178
|
+
"""Positions ``(line, column)`` of ``+`` tokens that are **string concatenation**, which the
|
|
179
|
+
arithmetic operator must not mutate.
|
|
180
|
+
|
|
181
|
+
The catalog's only replacement for ``+`` is ``-`` (`engine.operators.ARITHMETIC`), and
|
|
182
|
+
GDScript's ``String`` defines no ``-``. So the mutant can never measure a test gap: it either
|
|
183
|
+
errors at runtime or sits on a line no test reaches, and in both cases it is reported as a
|
|
184
|
+
survivor that was never a real mutant. Skipping it at generation time is the fix.
|
|
185
|
+
|
|
186
|
+
Recognition reuses `_is_string_concatenation` (the same operand typing `_string_format_percents`
|
|
187
|
+
applies to ``%``): an ``arith_expr`` joined only by ``+`` — a ``-`` anywhere means genuine
|
|
188
|
+
arithmetic — with at least one bare-string operand. A string in a *numeric* position is not one,
|
|
189
|
+
because the check reads the parse tree rather than the token stream: ``"5".to_int() + 3`` has a
|
|
190
|
+
``getattr_call`` operand and ``d["k"] + 1`` a ``subscr_expr``, neither of which is a ``string``
|
|
191
|
+
node, so both stay ordinary arithmetic sites.
|
|
192
|
+
"""
|
|
193
|
+
skip: set[tuple[int | None, int | None]] = set()
|
|
194
|
+
for node in tree.iter_subtrees():
|
|
195
|
+
if not _is_string_concatenation(node):
|
|
196
|
+
continue
|
|
197
|
+
skip.update(
|
|
198
|
+
(child.line, child.column)
|
|
199
|
+
for child in node.children
|
|
200
|
+
if isinstance(child, Token) and child.type == "PLUS"
|
|
201
|
+
)
|
|
202
|
+
return skip
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _is_string_valued(node: Tree[Token]) -> bool:
|
|
206
|
+
"""True if `node` is an expression the **parse tree alone** shows to be a string: a bare
|
|
207
|
+
literal, or a ``+``-only concatenation containing one (parenthesised or not).
|
|
208
|
+
|
|
209
|
+
This is the union of the two operand shapes already recognised in this file — the bare-literal
|
|
210
|
+
/ parenthesised-concatenation pair `_is_string_format_operand` applies to ``%``, plus the
|
|
211
|
+
unbracketed `arith_expr` concatenation `_string_concatenation_pluses` applies to ``+``. No type
|
|
212
|
+
inference: a ``String``-typed *variable* is not recognised, because nothing in the tree says
|
|
213
|
+
it is one.
|
|
214
|
+
"""
|
|
215
|
+
return _is_string_format_operand(node) or _is_string_concatenation(node)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
#: The one compound-assignment token that can appear with a string operand. `COMPOUND_ASSIGN` also
|
|
219
|
+
#: swaps ``-=``/``*=``/``/=``, but GDScript's ``String`` defines none of those, so a source line
|
|
220
|
+
#: spelling them on a string does not compile in the first place and can never reach this skip.
|
|
221
|
+
_STRING_COMPOUND_ASSIGN = "+="
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _string_compound_assigns(tree: Tree[Token]) -> set[tuple[int | None, int | None]]:
|
|
225
|
+
"""Positions ``(line, column)`` of ``+=`` tokens that **append to a string**, which the
|
|
226
|
+
compound-assign operator must not mutate.
|
|
227
|
+
|
|
228
|
+
The catalog's only replacement for ``+=`` is ``-=`` (`engine.operators.COMPOUND_ASSIGN`), and
|
|
229
|
+
GDScript's ``String`` defines no ``-``. So the mutant is not a changed program whose behavior a
|
|
230
|
+
test could disagree with — it is an invalid one, exactly like the ``+``-concatenation case
|
|
231
|
+
`_string_concatenation_pluses` already skips. Leaving it in reports a survivor that was never a
|
|
232
|
+
real mutant, which is the one thing a survivor must never be.
|
|
233
|
+
|
|
234
|
+
This is NF-5's rule reaching a defect NF-5 cannot see. NF-5 drops a mutant whose source no
|
|
235
|
+
longer *parses*, but gdtoolkit's grammar carries no type information — ``s -= "b"`` parses
|
|
236
|
+
perfectly well and is rejected only by Godot, later. Recognising it from operand shape at
|
|
237
|
+
generation time is the same policy applied one level up, not a new one.
|
|
238
|
+
|
|
239
|
+
Recognition is `_is_string_valued` on the assignment's right-hand operand. It deliberately stops
|
|
240
|
+
at what the tree proves. A ``String``-typed variable (``s += other``), a ``StringName``
|
|
241
|
+
(``s += &"a"``) and a format expression (``s += "%s" % x``) are all string-valued at runtime and
|
|
242
|
+
are all left as ordinary sites, because suppressing a *genuine* gap is the costlier error and
|
|
243
|
+
only a literal makes the shape certain.
|
|
244
|
+
"""
|
|
245
|
+
skip: set[tuple[int | None, int | None]] = set()
|
|
246
|
+
for node in tree.iter_subtrees():
|
|
247
|
+
if node.data != "assnmnt_expr":
|
|
248
|
+
continue
|
|
249
|
+
# `assnmnt_expr` is (target, operator token, value); pair the operator with what follows it.
|
|
250
|
+
for cur, following in pairwise(node.children):
|
|
251
|
+
if (
|
|
252
|
+
isinstance(cur, Token)
|
|
253
|
+
and cur.value == _STRING_COMPOUND_ASSIGN
|
|
254
|
+
and isinstance(following, Tree)
|
|
255
|
+
and _is_string_valued(following)
|
|
256
|
+
):
|
|
257
|
+
skip.add((cur.line, cur.column))
|
|
258
|
+
return skip
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
#: gdtoolkit's ``class_var_*`` rules that carry an initializer ``expr``. ``class_var_empty`` and
|
|
262
|
+
#: ``class_var_typed`` declare no initial value, so there is nothing to skip in them.
|
|
263
|
+
_INITIALIZED_CLASS_VAR_NODES = frozenset(
|
|
264
|
+
{"class_var_assigned", "class_var_typed_assgnd", "class_var_inf"}
|
|
265
|
+
)
|
|
266
|
+
#: Nodes whose *evaluation* is observable independently of the value they produce, so a dead store
|
|
267
|
+
#: does not make a mutation inside them inert: a call can have side effects, a subscript can index
|
|
268
|
+
#: out of range, an ``await`` suspends. An initializer containing one keeps all its sites.
|
|
269
|
+
_EFFECTFUL_NODES = frozenset({"standalone_call", "getattr_call", "subscr_expr", "await_expr"})
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _inline_properties(node: Tree[Token]) -> Iterator[tuple[Tree[Token], Tree[Token]]]:
|
|
273
|
+
"""``(declaration, body)`` for each property declared with inline accessors among `node`'s
|
|
274
|
+
direct children.
|
|
275
|
+
|
|
276
|
+
gdtoolkit leaves the declaration's own ``inline_property_body`` node **empty** and hangs the
|
|
277
|
+
accessors off a ``property_body_def`` that is the declaration's *next sibling*, not its child —
|
|
278
|
+
so the two are paired by adjacency. ``static var`` wraps the declaration in an extra
|
|
279
|
+
``static_class_var_stmt``, which is unwrapped here so a static property is treated the same.
|
|
280
|
+
"""
|
|
281
|
+
for current, following in pairwise(node.children):
|
|
282
|
+
if not (isinstance(current, Tree) and isinstance(following, Tree)):
|
|
283
|
+
continue
|
|
284
|
+
if following.data != "property_body_def":
|
|
285
|
+
continue
|
|
286
|
+
# `static var` nests the declaration one level deeper (`static_class_var_stmt`); unwrap it
|
|
287
|
+
# so a static property is read exactly like an ordinary one.
|
|
288
|
+
statement = current.children[0] if current.data == "static_class_var_stmt" else current
|
|
289
|
+
assert isinstance(statement, Tree) # pragma: no cover — grammar: always a class_var_stmt
|
|
290
|
+
declaration = statement.children[0]
|
|
291
|
+
assert isinstance(declaration, Tree) # pragma: no cover — grammar: always a class_var_*
|
|
292
|
+
if declaration.data in _INITIALIZED_CLASS_VAR_NODES:
|
|
293
|
+
yield declaration, following # anything else declares no initial value to skip
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _dead_property_initializers(tree: Tree[Token]) -> set[tuple[int | None, int | None]]:
|
|
297
|
+
"""Positions ``(line, column)`` of every token in a property declaration's initializer whose
|
|
298
|
+
stored value can never be read back — so no mutation of it can change observable behavior.
|
|
299
|
+
|
|
300
|
+
GDScript runs a property's ``set`` on assignment but **not** on the initializer in the
|
|
301
|
+
declaration itself, so that initializer writes the backing field directly. When the property
|
|
302
|
+
also declares a custom ``get``, every read from outside routes through the getter instead, and
|
|
303
|
+
the backing field is reachable only by naming the property *inside* its own accessors (where the
|
|
304
|
+
name means the field, not the getter). So when neither accessor body mentions the property's own
|
|
305
|
+
name, the initial value is written and never read — dead storage, and every mutant on it is
|
|
306
|
+
inert by language rule rather than by any property of the test suite.
|
|
307
|
+
|
|
308
|
+
Shown by the GUT v9.7.1 measurement rather than argued: in ``compare_result.gd`` the numeric
|
|
309
|
+
mutants on the backing field ``var _max_differences = 30`` (line 15) were killed, while the ones
|
|
310
|
+
on ``var max_differences = 30 :`` (line 16) — same literal, the next line — survived.
|
|
311
|
+
|
|
312
|
+
Deliberately narrower than "the property has a custom setter", which would suppress genuine test
|
|
313
|
+
gaps in three shapes that keep all their sites here: a **setter-only** property (no getter, so
|
|
314
|
+
reads still return the initial value), a getter that **names the property itself**
|
|
315
|
+
(``get: return health`` reads exactly the field the initializer wrote), and an initializer
|
|
316
|
+
containing a call, subscript or ``await`` (the stored value is dead, but evaluating the
|
|
317
|
+
expression is not — see `_EFFECTFUL_NODES`).
|
|
318
|
+
"""
|
|
319
|
+
skip: set[tuple[int | None, int | None]] = set()
|
|
320
|
+
for parent in tree.iter_subtrees():
|
|
321
|
+
for declaration, body in _inline_properties(parent):
|
|
322
|
+
name = declaration.children[0] # every `class_var_*` rule opens with the NAME token
|
|
323
|
+
assert isinstance(name, Token) # pragma: no cover — grammar: always a NAME
|
|
324
|
+
(initializer,) = [
|
|
325
|
+
c for c in declaration.children if isinstance(c, Tree) and c.data == "expr"
|
|
326
|
+
]
|
|
327
|
+
if not any(
|
|
328
|
+
isinstance(c, Tree) and c.data == "property_custom_getter" for c in body.children
|
|
329
|
+
):
|
|
330
|
+
continue # no getter: reads still return the backing field the initializer wrote
|
|
331
|
+
if any(tok == name.value for tok in body.scan_values(lambda v: isinstance(v, Token))):
|
|
332
|
+
continue # an accessor names the property, so it can read the backing field
|
|
333
|
+
if any(sub.data in _EFFECTFUL_NODES for sub in initializer.iter_subtrees()):
|
|
334
|
+
continue # evaluating the initializer is observable even though its store is dead
|
|
335
|
+
skip.update(
|
|
336
|
+
(tok.line, tok.column)
|
|
337
|
+
for tok in initializer.scan_values(lambda v: isinstance(v, Token))
|
|
338
|
+
)
|
|
339
|
+
return skip
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def find_sites(source: str, catalog: tuple[Operator, ...] = CATALOG) -> list[MutationSite]:
|
|
343
|
+
"""Every token in `source` that `catalog` can mutate, located via gdtoolkit.
|
|
344
|
+
|
|
345
|
+
Filtering by "does the catalog mutate this value" is sufficient: gdtoolkit never surfaces
|
|
346
|
+
tokens from inside string literals or comments, so this never edits within one. `catalog` is
|
|
347
|
+
threaded through so site selection matches generation (a custom catalog finds its own sites).
|
|
348
|
+
|
|
349
|
+
Four syntactic exclusions drop tokens that cannot yield a meaningful mutant — each one a shape
|
|
350
|
+
the language rules out, never a shape that merely tends to survive:
|
|
351
|
+
|
|
352
|
+
* a ``%`` used as the string-format operator (`_string_format_percents`);
|
|
353
|
+
* a ``+`` that is string concatenation (`_string_concatenation_pluses`);
|
|
354
|
+
* a ``+=`` that appends to a string (`_string_compound_assigns`);
|
|
355
|
+
* a property declaration's initializer whose stored value is unreadable
|
|
356
|
+
(`_dead_property_initializers`).
|
|
357
|
+
|
|
358
|
+
``# gdmutant: ignore`` annotations are **not** filtered here: a suppressed mutant is still
|
|
359
|
+
*generated*, then marked ``ignore_reason`` in `generate_mutants` so it surfaces in the report as
|
|
360
|
+
``Ignored`` (excluded from the score) rather than vanishing (see docs/decisions/0004, 0006).
|
|
361
|
+
"""
|
|
362
|
+
tree = _parse(source)
|
|
363
|
+
skipped = (
|
|
364
|
+
_string_format_percents(tree)
|
|
365
|
+
| _string_concatenation_pluses(tree)
|
|
366
|
+
| _string_compound_assigns(tree)
|
|
367
|
+
| _dead_property_initializers(tree)
|
|
368
|
+
)
|
|
369
|
+
return [
|
|
370
|
+
MutationSite(tok.value, _span_of(tok))
|
|
371
|
+
for tok in tree.scan_values(lambda v: isinstance(v, Token))
|
|
372
|
+
if all_replacements(tok.value, catalog) and (tok.line, tok.column) not in skipped
|
|
373
|
+
]
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _mark_ignored(mutant: Mutant, directives: dict[int, _IgnoreDirective]) -> Mutant:
|
|
377
|
+
"""Return `mutant` tagged with an `ignore_reason` if an ignore directive on its line applies to
|
|
378
|
+
its operator (a bare directive applies to every operator; ``[ops]`` only to the named ones)."""
|
|
379
|
+
directive = directives.get(mutant.span.line)
|
|
380
|
+
if directive is None:
|
|
381
|
+
return mutant
|
|
382
|
+
if directive.operators is not None and mutant.operator_id not in directive.operators:
|
|
383
|
+
return mutant
|
|
384
|
+
return replace(mutant, ignore_reason=directive.reason)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
# Statement-deletion (FG-2.1) — replace a statement with ``pass``. Structural, not a token swap, so
|
|
388
|
+
# it's a separate path from the token catalog (docs/decisions/0007). Deletes expression statements
|
|
389
|
+
# (calls, assignments, ``+=``) and ``return``s. Declarations (``func_var_stmt``) are deferred:
|
|
390
|
+
# deleting one either breaks a later reference or is equivalent (unused) — both noise.
|
|
391
|
+
_DELETABLE_STMT_NODES = frozenset({"expr_stmt", "return_stmt"})
|
|
392
|
+
_FUNCTION_SCOPE_NODES = frozenset({"func_def", "lambda"})
|
|
393
|
+
_SCOPE_HEADER_NODES = frozenset({"func_header", "lambda_header"})
|
|
394
|
+
_STATEMENT_REPLACEMENT = "pass" # STATEMENT_DELETION_ID is declared at the top of the module
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _scope_requires_a_return_value(scope: Tree[Token]) -> bool:
|
|
398
|
+
"""True if `scope` (a ``func_def`` or ``lambda``) declares a **non-void** return type — so Godot
|
|
399
|
+
requires every path to return a value, and deleting a ``return`` may make it not compile.
|
|
400
|
+
|
|
401
|
+
A ``lambda_header`` carries the same optional ``TYPE_HINT`` token as a ``func_header``, so a
|
|
402
|
+
*typed lambda* (``func() -> int: return 9``) is guarded exactly like a typed function: deleting
|
|
403
|
+
its return is the same "not all code paths return a value" Godot error (verified via
|
|
404
|
+
``--check-only``). An untyped lambda or ``-> void`` scope has no such requirement.
|
|
405
|
+
"""
|
|
406
|
+
header = next(
|
|
407
|
+
(c for c in scope.children if isinstance(c, Tree) and c.data in _SCOPE_HEADER_NODES), None
|
|
408
|
+
)
|
|
409
|
+
return header is not None and any(
|
|
410
|
+
isinstance(c, Token) and c.type == "TYPE_HINT" and c.value != "void"
|
|
411
|
+
for c in header.children
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _scope_deletable_statements(scope: Tree[Token]) -> list[Tree[Token]]:
|
|
416
|
+
"""Deletable statement nodes inside `scope`, descending through control-flow blocks but NOT into
|
|
417
|
+
nested function scopes (a lambda's statements belong to the lambda, its own scope)."""
|
|
418
|
+
found: list[Tree[Token]] = []
|
|
419
|
+
|
|
420
|
+
def walk(node: Tree[Token]) -> None:
|
|
421
|
+
for child in node.children:
|
|
422
|
+
if not isinstance(child, Tree) or child.data in _FUNCTION_SCOPE_NODES:
|
|
423
|
+
continue
|
|
424
|
+
if child.data in _DELETABLE_STMT_NODES:
|
|
425
|
+
found.append(child)
|
|
426
|
+
else:
|
|
427
|
+
walk(child)
|
|
428
|
+
|
|
429
|
+
walk(scope)
|
|
430
|
+
return found
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _statement_deletions(path: str, source: str) -> list[Mutant]:
|
|
434
|
+
"""One ``pass``-replacement mutant per deletable single-line statement (FG-2.1).
|
|
435
|
+
|
|
436
|
+
A ``return`` is emitted only when deleting it can't break compilation (docs/decisions/0007): the
|
|
437
|
+
enclosing function is untyped/``void``, **or** the function body's last top-level statement is a
|
|
438
|
+
*different* ``return`` (a guaranteed final return backstops the deletion, so every path still
|
|
439
|
+
returns a value). gdtoolkit has no return-path analysis, so this generation-time guard — not
|
|
440
|
+
NF-5's re-parse — is what keeps deletion mutants loadable in Godot (same pattern as
|
|
441
|
+
`_string_format_percents`). Multi-line statements are skipped (`spans.py` single-line). Mutants
|
|
442
|
+
are returned in document order.
|
|
443
|
+
"""
|
|
444
|
+
mutants: list[Mutant] = []
|
|
445
|
+
for scope in _parse(source).iter_subtrees():
|
|
446
|
+
if scope.data not in _FUNCTION_SCOPE_NODES:
|
|
447
|
+
continue
|
|
448
|
+
typed = _scope_requires_a_return_value(scope)
|
|
449
|
+
body = [
|
|
450
|
+
c for c in scope.children if isinstance(c, Tree) and c.data not in _SCOPE_HEADER_NODES
|
|
451
|
+
]
|
|
452
|
+
last = body[-1] if body else None
|
|
453
|
+
last_is_return = last is not None and last.data == "return_stmt"
|
|
454
|
+
for stmt in _scope_deletable_statements(scope):
|
|
455
|
+
if stmt.data == "return_stmt" and typed and not (last_is_return and stmt is not last):
|
|
456
|
+
continue # deleting it would leave a typed function with no guaranteed return value
|
|
457
|
+
meta = stmt.meta
|
|
458
|
+
if meta.empty or meta.line != meta.end_line:
|
|
459
|
+
continue # no span, or a multi-line statement (spans.py edits a single line only)
|
|
460
|
+
span = Span(meta.line, meta.column, meta.end_line, meta.end_column)
|
|
461
|
+
original = text_at(source, span)
|
|
462
|
+
mutants.append(
|
|
463
|
+
Mutant(path, span, STATEMENT_DELETION_ID, original, _STATEMENT_REPLACEMENT)
|
|
464
|
+
)
|
|
465
|
+
return sorted(mutants, key=lambda m: (m.span.line, m.span.column))
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def generate_mutants(
|
|
469
|
+
path: str, source: str, catalog: tuple[Operator, ...] = CATALOG
|
|
470
|
+
) -> list[Mutant]:
|
|
471
|
+
"""All mutants for `source`, each tagged ``ignore_reason`` if a ``# gdmutant: ignore`` directive
|
|
472
|
+
on its line applies to it; `path` is recorded on each mutant for reporting.
|
|
473
|
+
|
|
474
|
+
Token-swap mutants (catalog) come first, then statement-deletion mutants (appended so existing
|
|
475
|
+
mutant ids/order are unchanged — NF-1)."""
|
|
476
|
+
mutants = generate(path, find_sites(source, catalog), catalog) + _statement_deletions(
|
|
477
|
+
path, source
|
|
478
|
+
)
|
|
479
|
+
directives = _ignore_directives(source)
|
|
480
|
+
if not directives:
|
|
481
|
+
return mutants
|
|
482
|
+
return [_mark_ignored(m, directives) for m in mutants]
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def is_valid_gdscript(source: str) -> bool:
|
|
486
|
+
"""True if `source` parses as GDScript — the NF-5 gate."""
|
|
487
|
+
try:
|
|
488
|
+
_parse(source)
|
|
489
|
+
except LarkError:
|
|
490
|
+
return False
|
|
491
|
+
return True
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def apply_mutant(mutant: Mutant, source: str) -> tuple[str, bool]:
|
|
495
|
+
"""Apply `mutant` to `source`; return ``(mutated_source, is_valid)``.
|
|
496
|
+
|
|
497
|
+
`is_valid` is False when the mutant produces unparseable GDScript, so the engine classifies it
|
|
498
|
+
as invalid and never counts it as "killed" (NF-5).
|
|
499
|
+
"""
|
|
500
|
+
mutated = mutant.apply(source)
|
|
501
|
+
return mutated, is_valid_gdscript(mutated)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
#: The GDScript `Adapter` the engine injects (NF-3) — the two callables above, bundled.
|
|
505
|
+
ADAPTER = Adapter(generate_mutants=generate_mutants, apply_mutant=apply_mutant)
|