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 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,3 @@
1
+ """Per-language adapters: apply the operator catalog to real source, then run that
2
+ language's test suite. One small module per language; the engine stays neutral.
3
+ """
@@ -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)