agent-sdlc 0.4.2__tar.gz → 0.5.0__tar.gz

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.
Files changed (43) hide show
  1. {agent_sdlc-0.4.2/agent_sdlc.egg-info → agent_sdlc-0.5.0}/PKG-INFO +1 -1
  2. {agent_sdlc-0.4.2 → agent_sdlc-0.5.0/agent_sdlc.egg-info}/PKG-INFO +1 -1
  3. {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/agent_sdlc.egg-info/SOURCES.txt +1 -0
  4. {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/mutation_gate/__init__.py +1 -1
  5. {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/mutation_gate/adversary.py +4 -1
  6. {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/mutation_gate/cli.py +19 -5
  7. {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/mutation_gate/coverage_map.py +2 -1
  8. agent_sdlc-0.5.0/mutation_gate/host_exec.py +189 -0
  9. {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/mutation_gate/repo.py +2 -0
  10. {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/mutation_gate/runner.py +3 -2
  11. {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/mutation_gate/vocabulary_core.toml +1 -1
  12. {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/mutation_gate/waivers.py +29 -6
  13. {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/LICENSE +0 -0
  14. {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/NOTICE.md +0 -0
  15. {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/README.md +0 -0
  16. {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/agent_sdlc.egg-info/dependency_links.txt +0 -0
  17. {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/agent_sdlc.egg-info/entry_points.txt +0 -0
  18. {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/agent_sdlc.egg-info/requires.txt +0 -0
  19. {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/agent_sdlc.egg-info/top_level.txt +0 -0
  20. {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/mutation_gate/commit_msg.py +0 -0
  21. {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/mutation_gate/diff_discipline.py +0 -0
  22. {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/mutation_gate/model_vv.py +0 -0
  23. {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/mutation_gate/mutants.py +0 -0
  24. {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/mutation_gate/no_comments.py +0 -0
  25. {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/mutation_gate/no_leaks.py +0 -0
  26. {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/mutation_gate/no_new_docs.py +0 -0
  27. {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/mutation_gate/rules.py +0 -0
  28. {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/mutation_gate/token.py +0 -0
  29. {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/mutation_gate/vocabulary.py +0 -0
  30. {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/mutation_gate/vocabulary_check.py +0 -0
  31. {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/mutation_gate/vocabulary_molds.py +0 -0
  32. {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/mutation_gate/vocabulary_path.py +0 -0
  33. {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/mutation_gate/vocabulary_wordnet.py +0 -0
  34. {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/pyproject.toml +0 -0
  35. {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/rules/code-clarity.md +0 -0
  36. {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/rules/diff-discipline.md +0 -0
  37. {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/rules/model-vv.md +0 -0
  38. {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/rules/naming.md +0 -0
  39. {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/rules/testing.md +0 -0
  40. {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/rules/tickets.md +0 -0
  41. {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/rules/voice.md +0 -0
  42. {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/setup.cfg +0 -0
  43. {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/tests/test_say_narrate_bin.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-sdlc
3
- Version: 0.4.2
3
+ Version: 0.5.0
4
4
  Summary: Diff-scoped mutation gate for pre-commit and agent Stop hooks
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/PavelGuzenfeld/agent-sdlc
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-sdlc
3
- Version: 0.4.2
3
+ Version: 0.5.0
4
4
  Summary: Diff-scoped mutation gate for pre-commit and agent Stop hooks
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/PavelGuzenfeld/agent-sdlc
@@ -14,6 +14,7 @@ mutation_gate/cli.py
14
14
  mutation_gate/commit_msg.py
15
15
  mutation_gate/coverage_map.py
16
16
  mutation_gate/diff_discipline.py
17
+ mutation_gate/host_exec.py
17
18
  mutation_gate/model_vv.py
18
19
  mutation_gate/mutants.py
19
20
  mutation_gate/no_comments.py
@@ -1,3 +1,3 @@
1
1
  """Diff-scoped mutation gate. Spec: PavelGuzenfeld/dotfiles#9."""
2
2
 
3
- __version__ = "0.4.2"
3
+ __version__ = "0.5.0"
@@ -128,7 +128,10 @@ def run_isolated(name: str, prompt: str, work: Path, extra: tuple[str, ...] = ()
128
128
  if not shutil.which("claude"):
129
129
  return f"{name} skipped: `claude` not on PATH"
130
130
  proc = subprocess.run(
131
- ["claude", "-p", prompt, "--restricted", "--disable-slash-commands", *extra],
131
+ [
132
+ "claude", "-p", prompt, "--restricted", "--disable-slash-commands",
133
+ "--no-session-persistence", *extra,
134
+ ],
132
135
  cwd=work, stdin=subprocess.DEVNULL, capture_output=True, text=True,
133
136
  check=False, timeout=600,
134
137
  )
@@ -59,15 +59,27 @@ def _warn_stale_waivers(repo, rel: str, wvs) -> None:
59
59
  if not any(w.file == rel and w.line and not w.uncovered for w in wvs):
60
60
  return
61
61
  try:
62
- whole = {rel: set(range(1, len((repo.root / rel).read_text().splitlines()) + 1))}
63
- every = mutants.generate(repo.root, whole, repo.config.language)
62
+ every = _every_mutant(repo, rel)
64
63
  except (OSError, GateError, ValueError):
65
64
  return
66
- for w in waivers.stale(wvs, rel, every):
65
+ for w in waivers.stale(wvs, rel, every, _source_lines(repo, rel)):
67
66
  _emit(f" {rel}: waiver at line {w.line} column {w.column} matches no mutant "
68
67
  f"— stale after an edit above it? ({w.old!r} => {w.new!r})")
69
68
 
70
69
 
70
+ def _source_lines(repo, rel: str) -> list[str]:
71
+ return (repo.root / rel).read_text().splitlines()
72
+
73
+
74
+ def _line_text_of(repo, mutant) -> str:
75
+ return waivers.line_text(_source_lines(repo, mutant.file), mutant.line)
76
+
77
+
78
+ def _every_mutant(repo, rel: str) -> list:
79
+ whole = {rel: set(range(1, len(_source_lines(repo, rel)) + 1))}
80
+ return mutants.generate(repo.root, whole, repo.config.language)
81
+
82
+
71
83
  def _warn_stale_report(repo, staged: bool) -> None:
72
84
  """#212: nobody should read an old adversary review as current when the
73
85
  gate skipped it this run."""
@@ -131,6 +143,8 @@ def _gate_file(
131
143
  _emit(f" {rel}: baseline {baseline:.1f}s, per-mutant timeout {timeout:.0f}s ({source})")
132
144
 
133
145
  survivors, blocked, timed_out = [], False, 0
146
+ every = _every_mutant(repo, rel) if any(w.text and w.file == rel for w in wvs) else []
147
+ lines = _source_lines(repo, rel) if every else []
134
148
  total = len(generated)
135
149
  for index, m in enumerate(generated, 1):
136
150
  tests = sorted({t.split("|")[0] for t in cover.get(m.line, [])})
@@ -141,7 +155,7 @@ def _gate_file(
141
155
  if result.verdict in (runner.KILLED, runner.KILLED_TIMEOUT):
142
156
  timed_out += result.verdict == runner.KILLED_TIMEOUT
143
157
  continue
144
- waiver = waivers.waived(wvs, m)
158
+ waiver = waivers.waived(wvs, m, every, lines)
145
159
  if waiver:
146
160
  _emit(f" {rel}:{m.line}:{m.column} {m.old} => {m.new} SURVIVED (waived: {waiver.reason})")
147
161
  continue
@@ -470,7 +484,7 @@ def _run(repo, args, staged: bool, transcript_path: str | None = None) -> int:
470
484
  if all_survivors:
471
485
  _emit("")
472
486
  _emit(f"Write the killing test, or record a waiver in {waivers.path(repo)}:")
473
- _emit(waivers.suggest(all_survivors[0]))
487
+ _emit(waivers.suggest(all_survivors[0], _line_text_of(repo, all_survivors[0])))
474
488
  return 1
475
489
 
476
490
  _emit("mutation-gate: pass")
@@ -9,6 +9,7 @@ from __future__ import annotations
9
9
 
10
10
  import ast
11
11
  import json
12
+ import shlex
12
13
  import sqlite3
13
14
  import time
14
15
  from pathlib import Path, PurePosixPath
@@ -254,7 +255,7 @@ def covering_tests(
254
255
  if not cands:
255
256
  return {}
256
257
  lang_cfg = repo.config.for_language("python")
257
- rels = " ".join(str(p.relative_to(repo.root)) for p in cands)
258
+ rels = shlex.join(str(p.relative_to(repo.root)) for p in cands)
258
259
  cmd = lang_cfg.coverage_command.format(file=_cov_scope(target), tests=rels)
259
260
  # Same suite plus instrumentation, so the baseline cap is the honest bound.
260
261
  run_start = time.time()
@@ -0,0 +1,189 @@
1
+ """PreToolUse Bash hook: host test and build commands run in the repo's `test_image`,
2
+ and a `docker run` that bind-mounts the repo must pass `--user` (#395)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import os
8
+ import re
9
+ import shlex
10
+ import subprocess
11
+ import sys
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+
15
+ from .repo import CONFIG_NAME, Config, GateError
16
+
17
+ CONTAINER_MARKER = Path("/.dockerenv")
18
+ HOST_COMMANDS = frozenset({"python3", "pytest", "make", "cmake", "colcon"})
19
+ USER_FLAG = "--user $(id -u):$(id -g)"
20
+ SEPARATORS = ";&|"
21
+ PWD_PREFIX = re.compile(r"^(\$\{PWD\}|\$PWD|\$\(pwd\)|`pwd`)")
22
+ USER_FLAGS = frozenset({"-u", "--user"})
23
+ VOLUME_FLAGS = frozenset({"-v", "--volume"})
24
+ VALUE_FLAGS = USER_FLAGS | VOLUME_FLAGS | frozenset({
25
+ "--mount", "-e", "--env", "--env-file", "-w", "--workdir", "-p", "--publish", "--name",
26
+ "--entrypoint", "--network", "--net", "-l", "--label", "--platform", "--gpus",
27
+ "--shm-size", "--ipc", "--pid", "-m", "--memory", "--cpus", "--device", "--add-host",
28
+ "--cap-add", "--cap-drop", "--security-opt", "--ulimit", "--tmpfs", "--runtime",
29
+ "-h", "--hostname", "--restart", "--log-driver", "--log-opt", "--cidfile",
30
+ "--group-add", "--expose", "--stop-signal", "--pull", "--userns",
31
+ })
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class Denial:
36
+ reason: str
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class Rewrite:
41
+ command: str
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class Notice:
46
+ reason: str
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class Mount:
51
+ source: str
52
+ target: str
53
+
54
+
55
+ @dataclass
56
+ class DockerRun:
57
+ mounts: list[Mount] = field(default_factory=list)
58
+ has_user: bool = False
59
+ command: list[str] = field(default_factory=list)
60
+
61
+ def segments(command: str) -> list[list[str]]:
62
+ lexer = shlex.shlex(command, posix=True, punctuation_chars=SEPARATORS)
63
+ lexer.whitespace_split = True
64
+ lexer.commenters = ""
65
+ parts: list[list[str]] = [[]]
66
+ for token in lexer:
67
+ if not token.strip(SEPARATORS):
68
+ parts.append([])
69
+ else:
70
+ parts[-1].append(token)
71
+ return [part for part in parts if part]
72
+
73
+
74
+ def parse_docker_run(args: list[str]) -> DockerRun:
75
+ run = DockerRun()
76
+ tokens = iter(args)
77
+ for token in tokens:
78
+ if not token.startswith("-"):
79
+ run.command = list(tokens)
80
+ break
81
+ flag, has_value, value = token.partition("=")
82
+ if not has_value and flag in VALUE_FLAGS:
83
+ value = next(tokens, "")
84
+ if flag in USER_FLAGS:
85
+ run.has_user = True
86
+ elif flag in VOLUME_FLAGS and ":" in value:
87
+ source, _, rest = value.partition(":")
88
+ run.mounts.append(Mount(source, rest.partition(":")[0]))
89
+ elif flag == "--mount":
90
+ options = dict(part.partition("=")[::2] for part in value.split(","))
91
+ if options.get("type") == "bind":
92
+ run.mounts.append(Mount(
93
+ options.get("source") or options.get("src", ""),
94
+ options.get("target") or options.get("dst") or options.get("destination", ""),
95
+ ))
96
+ return run
97
+
98
+
99
+ def host_path(source: str, cwd: Path) -> Path | None:
100
+ source = os.path.expanduser(os.path.expandvars(PWD_PREFIX.sub(lambda _: str(cwd), source)))
101
+ if "$" in source or not source.startswith(("/", ".")):
102
+ return None
103
+ return (cwd / source).resolve()
104
+
105
+
106
+ def git_lines(cwd: Path, *args: str) -> list[str] | None:
107
+ result = subprocess.run(["git", "-C", str(cwd), *args], capture_output=True, text=True)
108
+ return result.stdout.splitlines() if result.returncode == 0 else None
109
+
110
+
111
+ def is_inside(path: Path | None, roots: list[Path]) -> bool:
112
+ return path is not None and any(path == root or root in path.parents for root in roots)
113
+
114
+
115
+ def is_ownership_handback(command: list[str], targets: list[str]) -> bool:
116
+ owners = ("$(id -u):$(id -g)", f"{os.getuid()}:{os.getgid()}")
117
+ return " ".join(command) in {f"chown -R {owner} {target}" for owner in owners for target in targets}
118
+
119
+
120
+ def with_user(command: str) -> str:
121
+ return re.sub(r"\bdocker\s+run\b", lambda _: f"docker run {USER_FLAG}", command)
122
+
123
+
124
+ def is_host_command(tokens: list[str]) -> bool:
125
+ return tokens[0] in HOST_COMMANDS or tokens[:2] == ["pip", "install"]
126
+
127
+
128
+ def decide(command: str, cwd: Path, is_in_container: bool) -> Denial | Rewrite | Notice | None:
129
+ toplevel = git_lines(cwd, "rev-parse", "--show-toplevel")
130
+ if toplevel is None or not (Path(toplevel[0]) / CONFIG_NAME).exists():
131
+ return None
132
+ try:
133
+ config = Config.load(Path(toplevel[0]))
134
+ except (GateError, ValueError):
135
+ return Notice(f"cannot read {Path(toplevel[0]) / CONFIG_NAME}, passing through")
136
+ try:
137
+ parts = segments(command)
138
+ except ValueError:
139
+ return None
140
+ roots = [Path(line.removeprefix("worktree ")).resolve()
141
+ for line in git_lines(cwd, "worktree", "list", "--porcelain") or []
142
+ if line.startswith("worktree ")]
143
+ for part in parts:
144
+ for start in range(len(part)):
145
+ if part[start:start + 2] == ["docker", "run"]:
146
+ run = parse_docker_run(part[start + 2:])
147
+ inside = [m for m in run.mounts if is_inside(host_path(m.source, cwd), roots)]
148
+ if (inside and not run.has_user
149
+ and not is_ownership_handback(run.command, [m.target for m in inside])):
150
+ return Denial(
151
+ f"docker run bind-mounts {inside[0].source} inside the repo without --user, "
152
+ f"which leaves root-owned files. Run instead:\n {with_user(command)}"
153
+ )
154
+ host = next((part[0] for part in parts if is_host_command(part)), None)
155
+ if (host is None or is_in_container or not config.is_test_image_used
156
+ or any(part[0] == "docker" for part in parts)):
157
+ return None
158
+ if not config.test_image:
159
+ return Denial(
160
+ f"`{host}` would run on the host. Set test_image = \"<image>\" in "
161
+ f"{CONFIG_NAME} to run it in that image, or is_test_image_used = false to keep it on the host."
162
+ )
163
+ return Rewrite(
164
+ f'docker run --rm {USER_FLAG} -e HOME=/tmp -v "$PWD:/w" -w /w '
165
+ f"{shlex.quote(config.test_image)} sh -c {shlex.quote(command)}"
166
+ )
167
+
168
+
169
+ def main() -> int:
170
+ payload = json.load(sys.stdin)
171
+ tool_input = payload.get("tool_input") or {}
172
+ cwd = Path(payload.get("cwd") or os.getcwd())
173
+ verdict = decide(tool_input.get("command") or "", cwd, CONTAINER_MARKER.exists())
174
+ if isinstance(verdict, (Denial, Notice)):
175
+ print(f"host-exec: {verdict.reason}", file=sys.stderr)
176
+ if isinstance(verdict, Denial):
177
+ return 2
178
+ if isinstance(verdict, Rewrite):
179
+ json.dump({"hookSpecificOutput": {
180
+ "hookEventName": "PreToolUse",
181
+ "permissionDecision": "allow",
182
+ "permissionDecisionReason": "host-exec: runs in test_image",
183
+ "updatedInput": {**tool_input, "command": verdict.command},
184
+ }}, sys.stdout)
185
+ return 0
186
+
187
+
188
+ if __name__ == "__main__":
189
+ sys.exit(main())
@@ -219,6 +219,8 @@ class Config:
219
219
  own_namespaces: list[str] = field(default_factory=list)
220
220
  doc_allow: list[DocAllow] = field(default_factory=list)
221
221
  banned_names_file: str = ""
222
+ test_image: str = ""
223
+ is_test_image_used: bool = True
222
224
 
223
225
  def for_language(self, language: str) -> LanguageConfig:
224
226
  override = self.languages.get(language)
@@ -10,6 +10,7 @@ from __future__ import annotations
10
10
  import contextlib
11
11
  import os
12
12
  import re
13
+ import shlex
13
14
  import shutil
14
15
  import signal
15
16
  import subprocess
@@ -191,7 +192,7 @@ def _run_tests(
191
192
  """Verdict within `timeout`. Timing out is not-green: a mutant can make the
192
193
  suite hang — `break` to `continue`, or a divide that never settles — and a
193
194
  hung gate is worse than a wrong verdict."""
194
- return run_capped(repo, test_command.format(tests=" ".join(tests)), timeout)
195
+ return run_capped(repo, test_command.format(tests=shlex.join(tests)), timeout)
195
196
 
196
197
 
197
198
  def _looks_like_collection_failure(returncode: int, output: bytes) -> bool:
@@ -235,7 +236,7 @@ def baseline_green(repo: Repo, tests: list[str], test_command: str) -> float:
235
236
  """Seconds the unmutated suite takes. Refuses rather than reporting, because
236
237
  a baseline that is not green makes every later verdict a fabrication."""
237
238
  start = time.monotonic()
238
- command = test_command.format(tests=" ".join(tests))
239
+ command = test_command.format(tests=shlex.join(tests))
239
240
  verdict = _baseline_outcome(repo, command, repo.config.baseline_timeout)
240
241
  if verdict == NO_PASS_MARKER:
241
242
  raise GateError(
@@ -327,7 +327,7 @@ forms = ["plural"]
327
327
 
328
328
  [[concept]]
329
329
  word = "image"
330
- meaning = "a two-dimensional array of pixels"
330
+ meaning = "a two-dimensional array of pixels, or the filesystem snapshot a container runs from"
331
331
  pos = ["noun"]
332
332
  forms = ["plural"]
333
333
 
@@ -32,18 +32,31 @@ class Waiver:
32
32
  check: str = ""
33
33
  test: str = ""
34
34
  ms: int = 0
35
+ text: str = ""
35
36
 
36
- def covers(self, mutant: Mutant) -> bool:
37
+ def covers(self, mutant: Mutant, every: list[Mutant] = (), lines: list[str] = ()) -> bool:
37
38
  if self.check or self.file != mutant.file:
38
39
  return False
39
40
  if self.uncovered:
40
41
  return True
42
+ if self.text:
43
+ return self._covers_by_text(mutant, every, lines)
41
44
  if self.line and self.line != mutant.line:
42
45
  return False
43
46
  if self.column and self.column != mutant.column:
44
47
  return False
45
48
  return self.old == mutant.old and self.new == mutant.new
46
49
 
50
+ def _covers_by_text(self, mutant: Mutant, every: list[Mutant], lines: list[str]) -> bool:
51
+ def same_site(m: Mutant) -> bool:
52
+ return (m.old, m.new, line_text(lines, m.line)) == (self.old, self.new, self.text)
53
+
54
+ if not same_site(mutant):
55
+ return False
56
+ if sum(map(same_site, every)) <= 1:
57
+ return True
58
+ return self.line == mutant.line and (not self.column or self.column == mutant.column)
59
+
47
60
  def covers_finding(self, check: str, file: str, test: str, ms: int, line: int) -> bool:
48
61
  if self.check != check:
49
62
  return False
@@ -56,6 +69,10 @@ class Waiver:
56
69
  return not self.ms or self.ms == ms
57
70
 
58
71
 
72
+ def line_text(lines: list[str], line: int) -> str:
73
+ return lines[line - 1].strip() if 0 < line <= len(lines) else ""
74
+
75
+
59
76
  def _ambiguous(entry: dict) -> bool:
60
77
  """A literal mutant is identified only by its value, so `old = "0"` without a
61
78
  line waives every `0 => 1` in the file — silently covering sites nobody looked
@@ -84,11 +101,15 @@ def load(repo: Repo) -> list[Waiver]:
84
101
  return out
85
102
 
86
103
 
87
- def waived(waivers: list[Waiver], mutant: Mutant) -> Waiver | None:
88
- return next((w for w in waivers if w.covers(mutant)), None)
104
+ def waived(
105
+ waivers: list[Waiver], mutant: Mutant, every: list[Mutant] = (), lines: list[str] = ()
106
+ ) -> Waiver | None:
107
+ return next((w for w in waivers if w.covers(mutant, every, lines)), None)
89
108
 
90
109
 
91
- def stale(waivers: list[Waiver], file: str, every_mutant: list[Mutant]) -> list[Waiver]:
110
+ def stale(
111
+ waivers: list[Waiver], file: str, every_mutant: list[Mutant], lines: list[str] = ()
112
+ ) -> list[Waiver]:
92
113
  """Waivers naming a site in `file` that the generator no longer produces.
93
114
 
94
115
  A waiver is keyed on line and column, so inserting anything above one moves its
@@ -102,7 +123,7 @@ def stale(waivers: list[Waiver], file: str, every_mutant: list[Mutant]) -> list[
102
123
  return [
103
124
  w for w in waivers
104
125
  if w.file == file and w.line and not w.uncovered and not w.check
105
- and not any(w.covers(m) for m in every_mutant)
126
+ and not any(w.covers(m, every_mutant, lines) for m in every_mutant)
106
127
  ]
107
128
 
108
129
 
@@ -128,10 +149,11 @@ def suggest_finding(check: str, file: str, test: str = "", ms: int = 0) -> str:
128
149
  )
129
150
 
130
151
 
131
- def suggest(mutant: Mutant) -> str:
152
+ def suggest(mutant: Mutant, text: str = "") -> str:
132
153
  """Always scoped to the one site: `_ambiguous` refuses a bare literal without
133
154
  a line, and one line can hold two mutants with identical text. Widening is a
134
155
  decision to make by deleting a key."""
156
+ text_key = f'text = """{text}"""\n' if text else ""
135
157
  return (
136
158
  "[[waiver]]\n"
137
159
  f'file = "{mutant.file}"\n'
@@ -139,6 +161,7 @@ def suggest(mutant: Mutant) -> str:
139
161
  f"column = {mutant.column}\n"
140
162
  f'old = """{mutant.old}"""\n'
141
163
  f'new = """{mutant.new}"""\n'
164
+ f"{text_key}"
142
165
  'reason = "REPLACE ME — why no test can or should kill this"\n'
143
166
  )
144
167
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes