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.
- {agent_sdlc-0.4.2/agent_sdlc.egg-info → agent_sdlc-0.5.0}/PKG-INFO +1 -1
- {agent_sdlc-0.4.2 → agent_sdlc-0.5.0/agent_sdlc.egg-info}/PKG-INFO +1 -1
- {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/agent_sdlc.egg-info/SOURCES.txt +1 -0
- {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/mutation_gate/__init__.py +1 -1
- {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/mutation_gate/adversary.py +4 -1
- {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/mutation_gate/cli.py +19 -5
- {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/mutation_gate/coverage_map.py +2 -1
- agent_sdlc-0.5.0/mutation_gate/host_exec.py +189 -0
- {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/mutation_gate/repo.py +2 -0
- {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/mutation_gate/runner.py +3 -2
- {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/mutation_gate/vocabulary_core.toml +1 -1
- {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/mutation_gate/waivers.py +29 -6
- {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/LICENSE +0 -0
- {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/NOTICE.md +0 -0
- {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/README.md +0 -0
- {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/agent_sdlc.egg-info/dependency_links.txt +0 -0
- {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/agent_sdlc.egg-info/entry_points.txt +0 -0
- {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/agent_sdlc.egg-info/requires.txt +0 -0
- {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/agent_sdlc.egg-info/top_level.txt +0 -0
- {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/mutation_gate/commit_msg.py +0 -0
- {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/mutation_gate/diff_discipline.py +0 -0
- {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/mutation_gate/model_vv.py +0 -0
- {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/mutation_gate/mutants.py +0 -0
- {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/mutation_gate/no_comments.py +0 -0
- {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/mutation_gate/no_leaks.py +0 -0
- {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/mutation_gate/no_new_docs.py +0 -0
- {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/mutation_gate/rules.py +0 -0
- {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/mutation_gate/token.py +0 -0
- {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/mutation_gate/vocabulary.py +0 -0
- {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/mutation_gate/vocabulary_check.py +0 -0
- {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/mutation_gate/vocabulary_molds.py +0 -0
- {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/mutation_gate/vocabulary_path.py +0 -0
- {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/mutation_gate/vocabulary_wordnet.py +0 -0
- {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/pyproject.toml +0 -0
- {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/rules/code-clarity.md +0 -0
- {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/rules/diff-discipline.md +0 -0
- {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/rules/model-vv.md +0 -0
- {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/rules/naming.md +0 -0
- {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/rules/testing.md +0 -0
- {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/rules/tickets.md +0 -0
- {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/rules/voice.md +0 -0
- {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/setup.cfg +0 -0
- {agent_sdlc-0.4.2 → agent_sdlc-0.5.0}/tests/test_say_narrate_bin.py +0 -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
|
-
[
|
|
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
|
-
|
|
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 =
|
|
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=
|
|
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=
|
|
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(
|
|
88
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
File without changes
|