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.
- agent_sdlc-0.1.1/LICENSE +21 -0
- agent_sdlc-0.1.1/NOTICE.md +5 -0
- agent_sdlc-0.1.1/PKG-INFO +56 -0
- agent_sdlc-0.1.1/README.md +44 -0
- agent_sdlc-0.1.1/agent_sdlc.egg-info/PKG-INFO +56 -0
- agent_sdlc-0.1.1/agent_sdlc.egg-info/SOURCES.txt +20 -0
- agent_sdlc-0.1.1/agent_sdlc.egg-info/dependency_links.txt +1 -0
- agent_sdlc-0.1.1/agent_sdlc.egg-info/entry_points.txt +2 -0
- agent_sdlc-0.1.1/agent_sdlc.egg-info/top_level.txt +1 -0
- agent_sdlc-0.1.1/mutation_gate/__init__.py +3 -0
- agent_sdlc-0.1.1/mutation_gate/adversary.py +120 -0
- agent_sdlc-0.1.1/mutation_gate/cli.py +303 -0
- agent_sdlc-0.1.1/mutation_gate/coverage_map.py +299 -0
- agent_sdlc-0.1.1/mutation_gate/model_vv.py +342 -0
- agent_sdlc-0.1.1/mutation_gate/mutants.py +354 -0
- agent_sdlc-0.1.1/mutation_gate/no_comments.py +103 -0
- agent_sdlc-0.1.1/mutation_gate/repo.py +272 -0
- agent_sdlc-0.1.1/mutation_gate/runner.py +271 -0
- agent_sdlc-0.1.1/mutation_gate/token.py +56 -0
- agent_sdlc-0.1.1/mutation_gate/waivers.py +156 -0
- agent_sdlc-0.1.1/pyproject.toml +23 -0
- agent_sdlc-0.1.1/setup.cfg +4 -0
agent_sdlc-0.1.1/LICENSE
ADDED
|
@@ -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,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
|
+
[](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
|
+
[](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
|
+
[](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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mutation_gate
|
|
@@ -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())
|