agent-sdlc 0.1.1__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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pavel Guzenfeld
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,5 @@
1
+ # Third-party notices
2
+
3
+ `skills/writing-for-agents/` is vendored from https://github.com/mattpocock/skills
4
+ under its own MIT licence. The upstream commit and copyright notice are in
5
+ `skills/writing-for-agents/CREDITS.md`.
@@ -0,0 +1,56 @@
1
+ Metadata-Version: 2.4
2
+ Name: agent-sdlc
3
+ Version: 0.1.1
4
+ Summary: Diff-scoped mutation gate for pre-commit and agent Stop hooks
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://github.com/PavelGuzenfeld/agent-sdlc
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ License-File: NOTICE.md
11
+ Dynamic: license-file
12
+
13
+ # agent-sdlc
14
+
15
+ [![CI](https://github.com/PavelGuzenfeld/agent-sdlc/actions/workflows/ci.yml/badge.svg)](https://github.com/PavelGuzenfeld/agent-sdlc/actions/workflows/ci.yml)
16
+
17
+ A software development lifecycle shipped as agent config: rules, skills, slash
18
+ commands and a diff-scoped mutation gate, installable into Claude Code and
19
+ Codex.
20
+
21
+ ## Install
22
+
23
+ `./install.sh --target claude|codex|all [--deps|--deps=say]` symlinks
24
+ the directories into `~/.claude` and `~/.codex`, renders `commands/` as Codex
25
+ skills, merges the hooks from `settings.example.json` and, with `--deps`,
26
+ installs the tooling (`--deps=say` adds the Kokoro TTS stack). A second run
27
+ changes nothing. It installs `ast-grep-cli` via pipx, which puts an `sg`
28
+ shim on `$PATH`; if `~/.local/bin` precedes `/usr/bin`, it shadows the
29
+ system `sg` (execute as a different group).
30
+
31
+ The `mutation-gate` CLI alone can be installed with `pip install agent-sdlc`,
32
+ or from a tagged [release](https://github.com/PavelGuzenfeld/agent-sdlc/releases)'s
33
+ wheel: `pip install <release .whl URL>`.
34
+
35
+ Plugin install: `.claude-plugin/plugin.json`, `.codex-plugin/plugin.json` and
36
+ `.agents/plugins/marketplace.json` ship at the repo root.
37
+
38
+ - Claude Code: `claude --plugin-dir /path/to/agent-sdlc`
39
+ - Codex: add this repo as a marketplace source; its
40
+ `.agents/plugins/marketplace.json` lists one plugin entry with
41
+ `source.path` set to the repo root.
42
+
43
+ ## What's inside
44
+
45
+ `skills/ commands/ rules/ bin/ mutation_gate/` at the root, no agent home
46
+ baked in. CI runs `scripts/no-leaks.sh` on every PR — it flags emails, RFC1918
47
+ addresses, user-at-host references, `/home/<user>/` paths and non-personal
48
+ `ghcr.io/` paths, and prints only `file:line`.
49
+
50
+ ## Docs
51
+
52
+ The SDLC, the gate, debugging, reporting, rules and skills, in depth:
53
+ <https://pavelguzenfeld.com/agent-sdlc/>.
54
+
55
+ MIT, see [LICENSE](LICENSE) and [NOTICE.md](NOTICE.md) for one vendored
56
+ third-party skill. Contributions: [CONTRIBUTING.md](CONTRIBUTING.md).
@@ -0,0 +1,44 @@
1
+ # agent-sdlc
2
+
3
+ [![CI](https://github.com/PavelGuzenfeld/agent-sdlc/actions/workflows/ci.yml/badge.svg)](https://github.com/PavelGuzenfeld/agent-sdlc/actions/workflows/ci.yml)
4
+
5
+ A software development lifecycle shipped as agent config: rules, skills, slash
6
+ commands and a diff-scoped mutation gate, installable into Claude Code and
7
+ Codex.
8
+
9
+ ## Install
10
+
11
+ `./install.sh --target claude|codex|all [--deps|--deps=say]` symlinks
12
+ the directories into `~/.claude` and `~/.codex`, renders `commands/` as Codex
13
+ skills, merges the hooks from `settings.example.json` and, with `--deps`,
14
+ installs the tooling (`--deps=say` adds the Kokoro TTS stack). A second run
15
+ changes nothing. It installs `ast-grep-cli` via pipx, which puts an `sg`
16
+ shim on `$PATH`; if `~/.local/bin` precedes `/usr/bin`, it shadows the
17
+ system `sg` (execute as a different group).
18
+
19
+ The `mutation-gate` CLI alone can be installed with `pip install agent-sdlc`,
20
+ or from a tagged [release](https://github.com/PavelGuzenfeld/agent-sdlc/releases)'s
21
+ wheel: `pip install <release .whl URL>`.
22
+
23
+ Plugin install: `.claude-plugin/plugin.json`, `.codex-plugin/plugin.json` and
24
+ `.agents/plugins/marketplace.json` ship at the repo root.
25
+
26
+ - Claude Code: `claude --plugin-dir /path/to/agent-sdlc`
27
+ - Codex: add this repo as a marketplace source; its
28
+ `.agents/plugins/marketplace.json` lists one plugin entry with
29
+ `source.path` set to the repo root.
30
+
31
+ ## What's inside
32
+
33
+ `skills/ commands/ rules/ bin/ mutation_gate/` at the root, no agent home
34
+ baked in. CI runs `scripts/no-leaks.sh` on every PR — it flags emails, RFC1918
35
+ addresses, user-at-host references, `/home/<user>/` paths and non-personal
36
+ `ghcr.io/` paths, and prints only `file:line`.
37
+
38
+ ## Docs
39
+
40
+ The SDLC, the gate, debugging, reporting, rules and skills, in depth:
41
+ <https://pavelguzenfeld.com/agent-sdlc/>.
42
+
43
+ MIT, see [LICENSE](LICENSE) and [NOTICE.md](NOTICE.md) for one vendored
44
+ third-party skill. Contributions: [CONTRIBUTING.md](CONTRIBUTING.md).
@@ -0,0 +1,56 @@
1
+ Metadata-Version: 2.4
2
+ Name: agent-sdlc
3
+ Version: 0.1.1
4
+ Summary: Diff-scoped mutation gate for pre-commit and agent Stop hooks
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://github.com/PavelGuzenfeld/agent-sdlc
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ License-File: NOTICE.md
11
+ Dynamic: license-file
12
+
13
+ # agent-sdlc
14
+
15
+ [![CI](https://github.com/PavelGuzenfeld/agent-sdlc/actions/workflows/ci.yml/badge.svg)](https://github.com/PavelGuzenfeld/agent-sdlc/actions/workflows/ci.yml)
16
+
17
+ A software development lifecycle shipped as agent config: rules, skills, slash
18
+ commands and a diff-scoped mutation gate, installable into Claude Code and
19
+ Codex.
20
+
21
+ ## Install
22
+
23
+ `./install.sh --target claude|codex|all [--deps|--deps=say]` symlinks
24
+ the directories into `~/.claude` and `~/.codex`, renders `commands/` as Codex
25
+ skills, merges the hooks from `settings.example.json` and, with `--deps`,
26
+ installs the tooling (`--deps=say` adds the Kokoro TTS stack). A second run
27
+ changes nothing. It installs `ast-grep-cli` via pipx, which puts an `sg`
28
+ shim on `$PATH`; if `~/.local/bin` precedes `/usr/bin`, it shadows the
29
+ system `sg` (execute as a different group).
30
+
31
+ The `mutation-gate` CLI alone can be installed with `pip install agent-sdlc`,
32
+ or from a tagged [release](https://github.com/PavelGuzenfeld/agent-sdlc/releases)'s
33
+ wheel: `pip install <release .whl URL>`.
34
+
35
+ Plugin install: `.claude-plugin/plugin.json`, `.codex-plugin/plugin.json` and
36
+ `.agents/plugins/marketplace.json` ship at the repo root.
37
+
38
+ - Claude Code: `claude --plugin-dir /path/to/agent-sdlc`
39
+ - Codex: add this repo as a marketplace source; its
40
+ `.agents/plugins/marketplace.json` lists one plugin entry with
41
+ `source.path` set to the repo root.
42
+
43
+ ## What's inside
44
+
45
+ `skills/ commands/ rules/ bin/ mutation_gate/` at the root, no agent home
46
+ baked in. CI runs `scripts/no-leaks.sh` on every PR — it flags emails, RFC1918
47
+ addresses, user-at-host references, `/home/<user>/` paths and non-personal
48
+ `ghcr.io/` paths, and prints only `file:line`.
49
+
50
+ ## Docs
51
+
52
+ The SDLC, the gate, debugging, reporting, rules and skills, in depth:
53
+ <https://pavelguzenfeld.com/agent-sdlc/>.
54
+
55
+ MIT, see [LICENSE](LICENSE) and [NOTICE.md](NOTICE.md) for one vendored
56
+ third-party skill. Contributions: [CONTRIBUTING.md](CONTRIBUTING.md).
@@ -0,0 +1,20 @@
1
+ LICENSE
2
+ NOTICE.md
3
+ README.md
4
+ pyproject.toml
5
+ agent_sdlc.egg-info/PKG-INFO
6
+ agent_sdlc.egg-info/SOURCES.txt
7
+ agent_sdlc.egg-info/dependency_links.txt
8
+ agent_sdlc.egg-info/entry_points.txt
9
+ agent_sdlc.egg-info/top_level.txt
10
+ mutation_gate/__init__.py
11
+ mutation_gate/adversary.py
12
+ mutation_gate/cli.py
13
+ mutation_gate/coverage_map.py
14
+ mutation_gate/model_vv.py
15
+ mutation_gate/mutants.py
16
+ mutation_gate/no_comments.py
17
+ mutation_gate/repo.py
18
+ mutation_gate/runner.py
19
+ mutation_gate/token.py
20
+ mutation_gate/waivers.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mutation-gate = mutation_gate.cli:main
@@ -0,0 +1 @@
1
+ mutation_gate
@@ -0,0 +1,3 @@
1
+ """Diff-scoped mutation gate. Spec: PavelGuzenfeld/dotfiles#9."""
2
+
3
+ __version__ = "0.1.1"
@@ -0,0 +1,120 @@
1
+ """The isolated adversary pass (decisions 14, 15, 20, 23, 24).
2
+
3
+ Fires when the gate goes green — green is exactly when nothing else is looking.
4
+ The export carries intent and tests but never the implementation, so the review
5
+ cannot be anchored by what the code happens to do. It reports; it never blocks.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ import shutil
12
+ import subprocess
13
+ import tempfile
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+
17
+ from .repo import Repo, git
18
+
19
+ BRANCH_ISSUE_RE = re.compile(r"(?:^|/)(\d+)(?:-|$)")
20
+
21
+ PROMPT = """You are reviewing a test suite in isolation. You have the stated intent
22
+ and the tests. You deliberately do NOT have the implementation — do not ask for it
23
+ and do not speculate about it.
24
+
25
+ One question: do these tests pin the behaviour the intent requires?
26
+
27
+ Report only:
28
+ - requirements in the intent that NO test asserts
29
+ - tests that assert something the intent does not ask for
30
+ - assertions loose enough to pass on behaviour the intent forbids (a tolerance,
31
+ a one-sided bound, an "is not None" where a value is specified)
32
+
33
+ Be specific and cite test names. If the tests fully cover the intent, say so in
34
+ one line. Do not suggest refactors or style changes.
35
+ """
36
+
37
+
38
+ @dataclass
39
+ class Intent:
40
+ source: str
41
+ text: str
42
+
43
+
44
+ def resolve_intent(repo: Repo, user_prompt: str | None) -> Intent | None:
45
+ """Decision 24, amended in dotfiles#75: explicit intent beats an inferred
46
+ ticket, and the ticket is read off the branch. No commit message describes
47
+ the change being gated — COMMIT_EDITMSG during pre-commit and `git log -1`
48
+ are both the previous commit. The agent's own summary is never used."""
49
+ if user_prompt:
50
+ return Intent("user prompt", user_prompt)
51
+ number = _branch_issue(repo)
52
+ if number:
53
+ body = _issue_body(repo, number)
54
+ if body:
55
+ return Intent(f"issue #{number}", body)
56
+ return None
57
+
58
+
59
+ def _branch_issue(repo: Repo) -> str:
60
+ """Matches `NN-slug` and `type/NN-slug` only, so `fix/utf-8-decode` yields
61
+ nothing: an unrelated ticket is worse intent than no ticket."""
62
+ try:
63
+ branch = git("rev-parse", "--abbrev-ref", "HEAD", cwd=repo.root).strip()
64
+ except Exception:
65
+ return ""
66
+ match = BRANCH_ISSUE_RE.search(branch)
67
+ return match.group(1) if match else ""
68
+
69
+
70
+ def _issue_body(repo: Repo, number: str) -> str:
71
+ proc = subprocess.run(
72
+ ["gh", "issue", "view", number, "-R", _slug(repo), "--json", "title,body",
73
+ "-q", ".title + \"\\n\\n\" + .body"],
74
+ capture_output=True, text=True, check=False,
75
+ )
76
+ return proc.stdout.strip() if proc.returncode == 0 else ""
77
+
78
+
79
+ def _slug(repo: Repo) -> str:
80
+ m = re.search(r"[:/]([^/:]+/[^/]+?)(?:\.git)?$", repo.origin)
81
+ return m.group(1) if m else ""
82
+
83
+
84
+ def build_export(tests: list[Path], intent: Intent, dest: Path) -> None:
85
+ """Tests plus intent, no implementation and no .git."""
86
+ dest.mkdir(parents=True, exist_ok=True)
87
+ (dest / "INTENT.md").write_text(f"# Intent (from {intent.source})\n\n{intent.text}\n")
88
+ tests_dir = dest / "tests"
89
+ tests_dir.mkdir(exist_ok=True)
90
+ for t in tests:
91
+ shutil.copyfile(t, tests_dir / t.name)
92
+
93
+
94
+ def run(tests: list[Path], intent: Intent | None, results_note: str) -> str:
95
+ """Returns the adversary's findings, or a one-line reason it did not run."""
96
+ if intent is None:
97
+ return (
98
+ "adversary skipped: no linked ticket and no user prompt available; "
99
+ "mutation results stand alone\n" + results_note
100
+ )
101
+ with tempfile.TemporaryDirectory(prefix="mutation-gate-adv-") as tmp:
102
+ work = Path(tmp)
103
+ build_export(tests, intent, work)
104
+ findings = run_isolated("adversary", PROMPT, work)
105
+ return f"intent: {intent.source}\n\n{findings}"
106
+
107
+
108
+ def run_isolated(name: str, prompt: str, work: Path, extra: tuple[str, ...] = ()) -> str:
109
+ """`claude -p` in `work` with no rules, skills or memory; stdin closed so it
110
+ cannot wait on a terminal. Returns findings or the one-line reason it did not run."""
111
+ if not shutil.which("claude"):
112
+ return f"{name} skipped: `claude` not on PATH"
113
+ proc = subprocess.run(
114
+ ["claude", "-p", prompt, "--restricted", "--disable-slash-commands", *extra],
115
+ cwd=work, stdin=subprocess.DEVNULL, capture_output=True, text=True,
116
+ check=False, timeout=600,
117
+ )
118
+ if proc.returncode != 0:
119
+ return f"{name} failed to run: {proc.stderr.strip()[:200]}"
120
+ return proc.stdout.strip()
@@ -0,0 +1,303 @@
1
+ """Entry point. Two modes: --staged for pre-commit, --worktree for the Stop hook.
2
+
3
+ Exit 0 = pass, 1 = blocked, 2 = refused (misconfiguration; nothing was measured).
4
+ A refusal is never a pass — the failure mode of a broken harness is false
5
+ reassurance. Exception: a lock held by a concurrent run exits 0 for
6
+ --worktree only — the Stop hook is best-effort, and a live gate on the same
7
+ repo is a reason to skip, not a refusal.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ from . import adversary, coverage_map, model_vv, mutants, no_comments, runner, token, waivers
18
+ from .repo import CACHE_ROOT, CONFIG_NAME, GateError, discover, skip_reason
19
+
20
+
21
+ TIMEOUT_FACTOR = 6.0
22
+
23
+
24
+ def _emit(line: str = "") -> None:
25
+ print(line, file=sys.stderr)
26
+
27
+
28
+ def _write_report(repo, name: str, text: str) -> Path:
29
+ """A green pre-commit hook only surfaces its output on failure (#62), so the
30
+ adversary/blind-pass reports need a durable home besides the terminal."""
31
+ path = CACHE_ROOT / repo.key / "reports" / f"{name}.md"
32
+ path.parent.mkdir(parents=True, exist_ok=True)
33
+ path.write_text(text)
34
+ return path
35
+
36
+
37
+ def _warn_stale_waivers(repo, rel: str, wvs) -> None:
38
+ """Say when a recorded decision stopped applying. Reports, never blocks: a stale
39
+ waiver already fails safe, since its mutant comes back as a survivor. What it does
40
+ not do without this is say why (dotfiles#88).
41
+
42
+ Best-effort by construction. It costs a second generator pass over the whole file,
43
+ and anything that goes wrong in it must leave the gate's verdict untouched — a
44
+ warning that can fail a run is worse than no warning.
45
+ """
46
+ if not any(w.file == rel and w.line and not w.uncovered for w in wvs):
47
+ return
48
+ try:
49
+ whole = {rel: set(range(1, len((repo.root / rel).read_text().splitlines()) + 1))}
50
+ every = mutants.generate(repo.root, whole, repo.config.language)
51
+ except (OSError, GateError, ValueError):
52
+ return
53
+ for w in waivers.stale(wvs, rel, every):
54
+ _emit(f" {rel}: waiver at line {w.line} column {w.column} matches no mutant "
55
+ f"— stale after an edit above it? ({w.old!r} => {w.new!r})")
56
+
57
+
58
+ def _gate_file(
59
+ repo, rel: str, lines: set[int], wvs, on_demand: bool = False
60
+ ) -> tuple[bool, list, list]:
61
+ """Returns (blocked, survivors, candidate test paths) for one changed file."""
62
+ cands = coverage_map.candidates(repo, rel)
63
+ source_blob = coverage_map.blob_hashes(repo, [repo.root / rel])
64
+ fp = token.fingerprint(source_blob[0] if source_blob else "",
65
+ coverage_map.blob_hashes(repo, cands))
66
+
67
+ # --file is the acceptance run: whoever asked for the whole file measured
68
+ # wants it measured, token or not (#24).
69
+ if not on_demand and token.is_valid(repo, rel, fp):
70
+ _emit(f" {rel}: already gated by the other hook (token valid)")
71
+ return False, [], cands
72
+
73
+ if not cands:
74
+ waiver = waivers.uncovered_waived(wvs, rel)
75
+ if waiver:
76
+ _emit(f" {rel}: no covering tests — waived ({waiver.reason})")
77
+ token.write(repo, rel, fp, "uncovered-waived")
78
+ return False, [], []
79
+ _emit(f" {rel}: BLOCKED — no covering tests")
80
+ _emit(" add to " + str(waivers.path(repo)) + ":")
81
+ _emit(" " + waivers.suggest_uncovered(rel).replace("\n", "\n "))
82
+ return True, [], []
83
+
84
+ language = mutants.language_of(rel) or repo.config.language
85
+ lang_cfg = repo.config.for_language(language)
86
+
87
+ generated = mutants.generate(repo.root, {rel: lines}, repo.config.language)
88
+ _warn_stale_waivers(repo, rel, wvs)
89
+ if not generated:
90
+ _emit(f" {rel}: no mutable sites on the changed lines")
91
+ token.write(repo, rel, fp, "no-mutants")
92
+ return False, [], cands
93
+
94
+ cover = coverage_map.covering_tests(repo, rel, cands, fp)
95
+ all_tests = [str(p.relative_to(repo.root)) for p in cands]
96
+
97
+ baseline = runner.baseline_green(repo, all_tests, lang_cfg.test_command)
98
+ configured = repo.config.mutant_timeout
99
+ timeout = configured if configured else max(30.0, baseline * TIMEOUT_FACTOR)
100
+ source = "configured" if configured else "from baseline"
101
+ _emit(f" {rel}: baseline {baseline:.1f}s, per-mutant timeout {timeout:.0f}s ({source})")
102
+
103
+ survivors, blocked, timed_out = [], False, 0
104
+ total = len(generated)
105
+ for index, m in enumerate(generated, 1):
106
+ tests = sorted({t.split("|")[0] for t in cover.get(m.line, [])})
107
+ selected = all_tests if not tests else tests
108
+ # Progress on every mutant: a silent run is indistinguishable from a hang.
109
+ _emit(f" [{index}/{total}] {rel}:{m.line}:{m.column} {m.old} => {m.new}")
110
+ result = runner.classify(repo, m, selected, lang_cfg.test_command, timeout)
111
+ if result.verdict in (runner.KILLED, runner.KILLED_TIMEOUT):
112
+ timed_out += result.verdict == runner.KILLED_TIMEOUT
113
+ continue
114
+ waiver = waivers.waived(wvs, m)
115
+ if waiver:
116
+ _emit(f" {rel}:{m.line}:{m.column} {m.old} => {m.new} SURVIVED (waived: {waiver.reason})")
117
+ continue
118
+ survivors.append(m)
119
+ blocked = True
120
+ if timed_out:
121
+ # Otherwise the score reads as a clean sweep and says nothing about what
122
+ # produced it: a suite that hangs and a build that is merely slow time
123
+ # out identically, and both are counted killed.
124
+ _emit(f" {rel}: {timed_out}/{total} mutant(s) timed out and were counted "
125
+ f"KILLED — set mutant_timeout in {CONFIG_NAME} if the run is only slow")
126
+ if not blocked:
127
+ token.write(repo, rel, fp, "pass")
128
+ return blocked, survivors, cands
129
+
130
+
131
+ def _stop_hook_cwd() -> Path | None:
132
+ """The Stop hook's own process cwd is the session's launch directory, not
133
+ wherever a Bash `cd` took the shell (#68 item 1) — the real one is the
134
+ `cwd` field of the hook's JSON payload on stdin."""
135
+ if sys.stdin.isatty():
136
+ return None
137
+ try:
138
+ cwd = json.loads(sys.stdin.read()).get("cwd")
139
+ except (json.JSONDecodeError, ValueError, AttributeError):
140
+ return None
141
+ return Path(cwd) if cwd else None
142
+
143
+
144
+ def main(argv: list[str] | None = None) -> int:
145
+ parser = argparse.ArgumentParser(prog="mutation-gate")
146
+ parser.add_argument("--staged", action="store_true", help="gate the index (pre-commit)")
147
+ parser.add_argument("--worktree", action="store_true", help="gate the working tree")
148
+ parser.add_argument("--user-prompt", default=None, help="intent fallback for the adversary")
149
+ parser.add_argument("--no-adversary", action="store_true")
150
+ parser.add_argument("--file", default=None,
151
+ help="gate every line of one file (acceptance / on-demand)")
152
+ parser.add_argument("--dry-run", action="store_true",
153
+ help="print candidates and mutants; run no tests")
154
+ parser.add_argument("files", nargs="*", help="ignored; pre-commit passes filenames")
155
+ args = parser.parse_args(argv)
156
+ staged = args.staged or not args.worktree
157
+
158
+ try:
159
+ repo = discover(_stop_hook_cwd() if args.worktree else None)
160
+ except GateError as exc:
161
+ if "not a git repository" in str(exc):
162
+ _emit("mutation-gate skipped: not a git repository")
163
+ return 0
164
+ _emit(f"mutation-gate refused: {exc}")
165
+ return 2
166
+
167
+ reason = skip_reason(repo)
168
+ if reason:
169
+ _emit(f"mutation-gate skipped: {reason}")
170
+ return 0
171
+
172
+ # Lock before guard_clean_start: recovery restores the *other* run's backup.
173
+ try:
174
+ lock = runner.repo_lock(repo)
175
+ lock.__enter__()
176
+ except GateError as exc:
177
+ if not staged:
178
+ _emit(f"mutation-gate skipped: {exc}")
179
+ return 0
180
+ _emit(f"mutation-gate refused: {exc}")
181
+ return 2
182
+ try:
183
+ return _run(repo, args, staged)
184
+ finally:
185
+ lock.__exit__(None, None, None)
186
+
187
+
188
+ def _run(repo, args, staged: bool) -> int:
189
+ try:
190
+ mutants.require_ast_grep()
191
+ runner.guard_clean_start(repo)
192
+ wvs = waivers.load(repo)
193
+ except (GateError, ValueError) as exc:
194
+ _emit(f"mutation-gate refused: {exc}")
195
+ return 2
196
+
197
+ if args.file:
198
+ target = (repo.root / args.file)
199
+ all_changed = {args.file: set(range(1, len(target.read_text().splitlines()) + 1))}
200
+ else:
201
+ all_changed = mutants.changed_lines(repo.root, staged)
202
+
203
+ # Before mutants: nothing here runs tests, so a missing spec fails in
204
+ # milliseconds instead of after a baseline run (#56 decision 10).
205
+ try:
206
+ findings = model_vv.check(repo, all_changed, wvs, staged)
207
+ except GateError as exc:
208
+ _emit(f"mutation-gate refused: {exc}")
209
+ return 2
210
+ if findings:
211
+ _emit(f"BLOCKED: model-vv — {len(findings)} finding(s).")
212
+ for f in findings:
213
+ _emit(f" {f.check:<12}{f.file}: {f.detail}")
214
+ _emit("")
215
+ _emit(model_vv.suggest(repo, findings[0]))
216
+ return 1
217
+
218
+ if repo.config.no_comments:
219
+ comments = no_comments.check(repo, all_changed, wvs, staged)
220
+ if comments:
221
+ _emit(f"BLOCKED: no-comments — {len(comments)} added comment line(s).")
222
+ for c in comments:
223
+ _emit(f" {c.file}:{c.line}: {c.text}")
224
+ _emit("")
225
+ _emit(no_comments.suggest(repo, comments[0]))
226
+ return 1
227
+
228
+ if args.file:
229
+ changed = all_changed
230
+ else:
231
+ changed = {}
232
+ for f, lines in all_changed.items():
233
+ if not mutants.language_of(f) or repo.is_test(f):
234
+ continue
235
+ prefix = _excluded(repo, f)
236
+ if prefix:
237
+ _emit(f" {f}: not gated — {CONFIG_NAME} exclude_paths {prefix!r}")
238
+ continue
239
+ changed[f] = lines
240
+ if not changed:
241
+ _emit("mutation-gate: no gated source files in this change")
242
+ return 0
243
+
244
+ if args.dry_run:
245
+ for rel, lines in sorted(changed.items()):
246
+ cands = coverage_map.candidates(repo, rel)
247
+ gen = mutants.generate(repo.root, {rel: lines}, repo.config.language)
248
+ _emit(f"{rel}: {len(cands)} candidate test file(s), {len(gen)} mutant(s)")
249
+ _warn_stale_waivers(repo, rel, wvs)
250
+ for c in cands:
251
+ _emit(f" test {c.relative_to(repo.root)}")
252
+ for m in gen:
253
+ _emit(f" mut {m.line}:{m.column}: {m.old} => {m.new}")
254
+ return 0
255
+
256
+ _emit(f"mutation-gate: {len(changed)} changed source file(s)")
257
+ all_survivors, all_cands, blocked = [], [], False
258
+ try:
259
+ for rel, lines in sorted(changed.items()):
260
+ file_blocked, survivors, cands = _gate_file(
261
+ repo, rel, lines, wvs, on_demand=bool(args.file)
262
+ )
263
+ blocked |= file_blocked
264
+ all_survivors.extend(survivors)
265
+ all_cands.extend(cands)
266
+ except GateError as exc:
267
+ _emit(f"mutation-gate refused: {exc}")
268
+ return 2
269
+
270
+ if blocked:
271
+ _emit("")
272
+ _emit(f"BLOCKED: {len(all_survivors)} mutant(s) survived with no waiver.")
273
+ for m in all_survivors:
274
+ _emit(f" {m.ident}")
275
+ if all_survivors:
276
+ _emit("")
277
+ _emit(f"Write the killing test, or record a waiver in {waivers.path(repo)}:")
278
+ _emit(waivers.suggest(all_survivors[0]))
279
+ return 1
280
+
281
+ _emit("mutation-gate: pass")
282
+ if not args.no_adversary and all_cands:
283
+ intent = adversary.resolve_intent(repo, args.user_prompt)
284
+ findings = adversary.run(sorted(set(all_cands)), intent, "")
285
+ path = _write_report(repo, "adversary", findings)
286
+ _emit("")
287
+ _emit(f"── adversary (isolated; reports only, never blocks; saved to {path}) ──")
288
+ _emit(findings)
289
+ if not args.no_adversary and model_vv.model_changed(repo, all_changed):
290
+ findings = model_vv.blind_pass(repo)
291
+ path = _write_report(repo, "blind-pass", findings)
292
+ _emit("")
293
+ _emit(f"── blind pass (code only, no spec; reports only, never blocks; saved to {path}) ──")
294
+ _emit(findings)
295
+ return 0
296
+
297
+
298
+ def _excluded(repo, rel: str) -> str | None:
299
+ return next((p for p in repo.config.exclude_paths if rel.startswith(p)), None)
300
+
301
+
302
+ if __name__ == "__main__":
303
+ sys.exit(main())