@jesdi/skills 0.0.30 → 0.0.31
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.
- package/README.md +12 -6
- package/package.json +2 -2
- package/skills/implement-spec/ARCHITECTURE.md +113 -0
- package/skills/implement-spec/README.md +214 -0
- package/skills/implement-spec/SKILL.md +18 -70
- package/skills/implement-spec/driver.py +580 -0
- package/skills/implement-spec/driver_agents.py +145 -0
- package/skills/implement-spec/driver_answers.py +79 -0
- package/skills/implement-spec/driver_decisions.py +152 -0
- package/skills/implement-spec/driver_final.py +505 -0
- package/skills/implement-spec/driver_fixloop.py +170 -0
- package/skills/implement-spec/driver_gate.py +47 -0
- package/skills/implement-spec/driver_graph.py +42 -0
- package/skills/implement-spec/driver_merge.py +105 -0
- package/skills/implement-spec/driver_report.py +115 -0
- package/skills/implement-spec/driver_review.py +126 -0
- package/skills/implement-spec/driver_review_page.py +51 -0
- package/skills/implement-spec/driver_run.py +262 -0
- package/skills/implement-spec/driver_slots.py +64 -0
- package/skills/implement-spec/driver_state.py +105 -0
- package/skills/implement-spec/driver_summary.py +40 -0
- package/skills/implement-spec/driver_tickets.py +132 -0
- package/skills/implement-spec/driver_tidy.py +57 -0
- package/skills/implement-spec/models.json +10 -0
- package/skills/implement-spec/prompts/conflict.md +32 -0
- package/skills/implement-spec/prompts/correction.md +4 -0
- package/skills/implement-spec/prompts/correctness.md +34 -0
- package/skills/implement-spec/prompts/final-fixer.md +36 -0
- package/skills/implement-spec/prompts/final-reviewer.md +36 -0
- package/skills/implement-spec/prompts/final-scoped.md +1 -0
- package/skills/implement-spec/prompts/fixer.md +35 -0
- package/skills/implement-spec/prompts/implementer.md +33 -0
- package/skills/implement-spec/prompts/interrupted.md +1 -0
- package/skills/implement-spec/prompts/moved.md +2 -0
- package/skills/implement-spec/prompts/overruled.md +5 -0
- package/skills/implement-spec/prompts/pr-body.md +19 -0
- package/skills/implement-spec/prompts/rebase-conflict.md +33 -0
- package/skills/implement-spec/prompts/rereview.md +3 -0
- package/skills/implement-spec/prompts/rerun.md +2 -0
- package/skills/implement-spec/prompts/reviewer.md +34 -0
- package/skills/implement-spec/prompts/stands.md +2 -0
- package/skills/implement-spec/prompts/test-writer.md +34 -0
- package/skills/implement-spec/prompts/timeout-restored.md +2 -0
- package/skills/implement-spec/prompts/timeout-shared.md +2 -0
- package/skills/implement-spec/prompts/timeout.md +2 -0
- package/skills/implement-spec/run.sh +101 -0
- package/skills/implement-spec/tests/conftest.py +38 -0
- package/skills/implement-spec/tests/test_agents.py +358 -0
- package/skills/implement-spec/tests/test_amendments.py +117 -0
- package/skills/implement-spec/tests/test_cli.py +220 -0
- package/skills/implement-spec/tests/test_decisions.py +434 -0
- package/skills/implement-spec/tests/test_driver.py +767 -0
- package/skills/implement-spec/tests/test_final.py +442 -0
- package/skills/implement-spec/tests/test_fixloop.py +523 -0
- package/skills/implement-spec/tests/test_graph.py +32 -0
- package/skills/implement-spec/tests/test_landing.py +355 -0
- package/skills/implement-spec/tests/test_merge.py +144 -0
- package/skills/implement-spec/tests/test_models.py +47 -0
- package/skills/implement-spec/tests/test_report.py +162 -0
- package/skills/implement-spec/tests/test_review.py +338 -0
- package/skills/implement-spec/tests/test_review_page.py +67 -0
- package/skills/implement-spec/tests/test_slots.py +32 -0
- package/skills/implement-spec/tests/test_state.py +68 -0
- package/skills/implement-spec/tests/test_stops.py +272 -0
- package/skills/implement-spec/tests/test_summary.py +28 -0
- package/skills/implement-spec/tests/test_tickets.py +72 -0
- package/skills/implement-spec/tests/test_tidy.py +582 -0
- package/skills/implement-spec/tests/test_walk.py +457 -0
- package/skills/review-page/template.html +1 -1
- package/skills/to-tickets/SKILL.md +19 -2
- package/skills-manifest.json +7 -7
- package/skills/implement-spec/history.md +0 -52
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Driver acceptance tests, through `sh run.sh` in a throwaway fixture repository."""
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
SKILL = Path(__file__).resolve().parents[1]
|
|
12
|
+
RUN = SKILL / "run.sh"
|
|
13
|
+
GIT = ["git", "-c", "user.name=t", "-c", "user.email=t@example.com", "-c", "commit.gpgsign=false"]
|
|
14
|
+
TOOLS = ("sh", "uname", "dirname", "readlink", "git", "env", "mktemp", "curl", "tar", "gzip", "mv", "rm", "mkdir",
|
|
15
|
+
"sha256sum", "shasum", "cat", "chmod", "ln", "date", "basename")
|
|
16
|
+
ROLES = ("explorer", "test-writer", "implementer", "reviewer", "correctness", "fixer", "final-reviewer", "conflict")
|
|
17
|
+
NONE = "None (can start immediately)"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def ticket(number, title, blocked=NONE, *, seam="`export_csv()`", touches="`demo/export.py: export_csv`",
|
|
21
|
+
criteria=("It works",), risk=None):
|
|
22
|
+
lines = [f"# {number}: {title}" if title else "", "", "**What to build:** Something.", "",
|
|
23
|
+
f"**Blocked by:** {blocked}", "", f"**Seam:** {seam}", ""]
|
|
24
|
+
if touches:
|
|
25
|
+
lines += [f"**Touches:** {touches}", ""]
|
|
26
|
+
if risk:
|
|
27
|
+
lines += [f"**Risk:** {risk}", ""]
|
|
28
|
+
return "\n".join(lines + [f"- [ ] {c}" for c in criteria] + [""])
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def three(**changes):
|
|
32
|
+
files = {
|
|
33
|
+
"01-export-rows.md": ticket("01", "Export rows", criteria=("Rows export", "Header exports")),
|
|
34
|
+
"02-quote-fields.md": ticket("02", "Quote fields", "01", risk="persistence"),
|
|
35
|
+
"03-cli-flag.md": ticket("03", "CLI flag", seam="`demo export` CLI", touches="`demo/cli.py: main`"),
|
|
36
|
+
}
|
|
37
|
+
files.update(changes)
|
|
38
|
+
return files
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def git(root, *args):
|
|
42
|
+
return subprocess.run([*GIT, *args], cwd=root, check=True, capture_output=True, text=True).stdout
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def snapshot(root):
|
|
46
|
+
"""Every file under root (but .git) with its bytes, plus the branch list."""
|
|
47
|
+
files = {p.relative_to(root).as_posix(): p.read_bytes()
|
|
48
|
+
for p in sorted(root.rglob("*")) if p.is_file() and ".git" not in p.relative_to(root).parts}
|
|
49
|
+
return files, git(root, "branch", "--list")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def make_repo(root, files, *, ignore_agent=True, commit_agent=False):
|
|
53
|
+
root.mkdir()
|
|
54
|
+
git(root, "init", "-q", "-b", "main")
|
|
55
|
+
(root / "demo").mkdir()
|
|
56
|
+
(root / "demo/export.py").write_text("def export_csv():\n return ''\n")
|
|
57
|
+
(root / ".gitignore").write_text(".agent/\n" if ignore_agent else ".crap/\n")
|
|
58
|
+
git(root, "add", "-A")
|
|
59
|
+
git(root, "commit", "-q", "-m", "init")
|
|
60
|
+
git(root, "checkout", "-q", "-b", "feat/csv")
|
|
61
|
+
tickets = root / ".agent/tickets"
|
|
62
|
+
tickets.mkdir(parents=True)
|
|
63
|
+
for name, text in files.items():
|
|
64
|
+
(tickets / name).write_text(text)
|
|
65
|
+
if commit_agent:
|
|
66
|
+
git(root, "add", "-f", ".agent")
|
|
67
|
+
git(root, "commit", "-q", "-m", "tickets")
|
|
68
|
+
return root
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@pytest.fixture
|
|
72
|
+
def env(tmp_path):
|
|
73
|
+
"""A PATH holding only basic tools plus fake claude and codex that log any launch."""
|
|
74
|
+
bin_dir = tmp_path / "bin"
|
|
75
|
+
bin_dir.mkdir()
|
|
76
|
+
for name in TOOLS:
|
|
77
|
+
if shutil.which(name):
|
|
78
|
+
(bin_dir / name).symlink_to(shutil.which(name))
|
|
79
|
+
for cli in ("claude", "codex"):
|
|
80
|
+
(bin_dir / cli).write_text(f'#!/bin/sh\necho {cli} >> "{tmp_path}/launched"\nexit 1\n')
|
|
81
|
+
(bin_dir / cli).chmod(0o755)
|
|
82
|
+
return dict(os.environ, PATH=str(bin_dir)), tmp_path
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def models_file(tmp_path, **overrides):
|
|
86
|
+
entries = {role: ["anthropic/claude-opus-5@medium"] for role in ROLES}
|
|
87
|
+
entries["correctness"] = ["openai/gpt-sol@medium"]
|
|
88
|
+
entries.update(overrides)
|
|
89
|
+
path = tmp_path / "models.json"
|
|
90
|
+
path.write_text(json.dumps(entries))
|
|
91
|
+
return path
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def drive(env, repo, *args, script=RUN):
|
|
95
|
+
env_vars, _ = env
|
|
96
|
+
args = args or ("--tickets", ".agent/tickets", "--gate", "make check", "--dry-run")
|
|
97
|
+
return subprocess.run(["sh", str(script), *args], cwd=repo, env=env_vars,
|
|
98
|
+
text=True, capture_output=True, timeout=300)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def assert_untouched(proc, repo, before, tmp_path):
|
|
102
|
+
assert snapshot(repo) == before
|
|
103
|
+
assert not (tmp_path / "launched").exists()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_missing_touches_names_file_and_seam_line_and_creates_nothing(env, tmp_path):
|
|
107
|
+
files = three(**{"02-quote-fields.md": ticket("02", "Quote fields", "01", touches=None)})
|
|
108
|
+
repo = make_repo(tmp_path / "demo", files)
|
|
109
|
+
before = snapshot(repo)
|
|
110
|
+
seam_line = files["02-quote-fields.md"].splitlines().index("**Seam:** `export_csv()`") + 1
|
|
111
|
+
proc = drive(env, repo)
|
|
112
|
+
assert proc.returncode == 3, proc.stderr
|
|
113
|
+
(line,) = [l for l in proc.stdout.splitlines() if "missing Touches" in l]
|
|
114
|
+
assert line.startswith("02-quote-fields.md") and re.search(rf"\b{seam_line}\b", line)
|
|
115
|
+
assert_untouched(proc, repo, before, tmp_path)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_every_error_is_listed_in_one_run(env, tmp_path):
|
|
119
|
+
files = three(**{"01-export-rows.md": ticket("01", None), "03-cli-flag.md": ticket("03", "CLI flag", criteria=())})
|
|
120
|
+
repo = make_repo(tmp_path / "demo", files)
|
|
121
|
+
proc = drive(env, repo)
|
|
122
|
+
assert proc.returncode == 3, proc.stderr
|
|
123
|
+
lines = proc.stdout.splitlines()
|
|
124
|
+
assert any("01-export-rows.md" in l and "missing title" in l for l in lines)
|
|
125
|
+
assert any("03-cli-flag.md" in l and "missing criterion" in l for l in lines)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_single_ticket_without_touches_is_accepted(env, tmp_path):
|
|
129
|
+
repo = make_repo(tmp_path / "demo", {"01-export-rows.md": ticket("01", "Export rows", touches=None)})
|
|
130
|
+
proc = drive(env, repo)
|
|
131
|
+
assert proc.returncode == 0, proc.stdout + proc.stderr
|
|
132
|
+
assert "max frontier width: 1" in proc.stdout
|
|
133
|
+
assert "missing" not in proc.stdout
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_refactor_seam_is_accepted_with_no_criterion(env, tmp_path):
|
|
137
|
+
files = {"01-export-rows.md": ticket("01", "Export rows", seam="none — refactor", criteria=())}
|
|
138
|
+
repo = make_repo(tmp_path / "demo", files)
|
|
139
|
+
proc = drive(env, repo)
|
|
140
|
+
assert proc.returncode == 0, proc.stdout + proc.stderr
|
|
141
|
+
assert "max frontier width: 1" in proc.stdout
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_blocked_by_cycle_prints_one_error_naming_both_files(env, tmp_path):
|
|
145
|
+
files = three(**{"01-export-rows.md": ticket("01", "Export rows", "03"),
|
|
146
|
+
"03-cli-flag.md": ticket("03", "CLI flag", "01", touches="`demo/cli.py: main`")})
|
|
147
|
+
repo = make_repo(tmp_path / "demo", files)
|
|
148
|
+
proc = drive(env, repo)
|
|
149
|
+
assert proc.returncode == 3, proc.stderr
|
|
150
|
+
both = [l for l in proc.stdout.splitlines() if "01-export-rows.md" in l and "03-cli-flag.md" in l]
|
|
151
|
+
assert len(both) == 1
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_blocked_by_unknown_number_names_file_and_number(env, tmp_path):
|
|
155
|
+
files = three(**{"02-quote-fields.md": ticket("02", "Quote fields", "07")})
|
|
156
|
+
repo = make_repo(tmp_path / "demo", files)
|
|
157
|
+
proc = drive(env, repo)
|
|
158
|
+
assert proc.returncode == 3, proc.stderr
|
|
159
|
+
assert any("02-quote-fields.md" in l and "07" in l for l in proc.stdout.splitlines())
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@pytest.mark.parametrize("files, code", [
|
|
163
|
+
(three(**{"02-quote-fields.md": ticket("02", "Quote fields", "07")}), 3),
|
|
164
|
+
(three(), 0),
|
|
165
|
+
])
|
|
166
|
+
def test_driver_never_modifies_a_ticket_file(env, tmp_path, files, code):
|
|
167
|
+
repo = make_repo(tmp_path / "demo", files)
|
|
168
|
+
before = {p.name: p.read_bytes() for p in (repo / ".agent/tickets").iterdir()}
|
|
169
|
+
proc = drive(env, repo)
|
|
170
|
+
assert proc.returncode == code, proc.stdout + proc.stderr
|
|
171
|
+
assert {p.name: p.read_bytes() for p in (repo / ".agent/tickets").iterdir()} == before
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_run_without_gate_exits_3_naming_the_flag(env, tmp_path):
|
|
175
|
+
repo = make_repo(tmp_path / "demo", three())
|
|
176
|
+
proc = drive(env, repo, "--tickets", ".agent/tickets", "--dry-run")
|
|
177
|
+
assert proc.returncode == 3, proc.stderr
|
|
178
|
+
assert "--gate" in proc.stdout
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_agent_dir_not_ignored_exits_3(env, tmp_path):
|
|
182
|
+
repo = make_repo(tmp_path / "demo", three(), ignore_agent=False, commit_agent=True)
|
|
183
|
+
proc = drive(env, repo)
|
|
184
|
+
assert proc.returncode == 3, proc.stderr
|
|
185
|
+
assert ".agent is not gitignored" in proc.stdout
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def test_dirty_worktree_exits_3_naming_the_paths(env, tmp_path):
|
|
189
|
+
repo = make_repo(tmp_path / "demo", three())
|
|
190
|
+
(repo / "demo/export.py").write_text("def export_csv():\n return 'edited'\n")
|
|
191
|
+
(repo / "notes.txt").write_text("untracked\n")
|
|
192
|
+
proc = drive(env, repo)
|
|
193
|
+
assert proc.returncode == 3, proc.stderr
|
|
194
|
+
assert "demo/export.py" in proc.stdout and "notes.txt" in proc.stdout
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def test_models_file_naming_a_provider_without_its_cli_exits_3(env, tmp_path):
|
|
198
|
+
(tmp_path / "bin/codex").unlink()
|
|
199
|
+
repo = make_repo(tmp_path / "demo", three())
|
|
200
|
+
models = models_file(tmp_path, implementer=["openai/gpt-sol@medium"])
|
|
201
|
+
proc = drive(env, repo, "--tickets", ".agent/tickets", "--gate", "make check", "--models", str(models), "--dry-run")
|
|
202
|
+
assert proc.returncode == 3, proc.stderr
|
|
203
|
+
assert any("implementer" in l and "codex" in l for l in proc.stdout.splitlines())
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@pytest.mark.parametrize("link", ["directory", "file"])
|
|
207
|
+
def test_valid_set_prints_frontier_width_and_dry_run_creates_nothing(env, tmp_path, link):
|
|
208
|
+
repo = make_repo(tmp_path / "demo", three())
|
|
209
|
+
script = RUN
|
|
210
|
+
if link == "directory": # an install through ~/.my-skills links the whole skill directory
|
|
211
|
+
(tmp_path / "skills link").symlink_to(SKILL)
|
|
212
|
+
script = tmp_path / "skills link/run.sh"
|
|
213
|
+
elif link == "file":
|
|
214
|
+
(tmp_path / "run link.sh").symlink_to(RUN)
|
|
215
|
+
script = tmp_path / "run link.sh"
|
|
216
|
+
before = snapshot(repo)
|
|
217
|
+
proc = drive(env, repo, "--tickets", ".agent/tickets", "--gate", "make check", "--dry-run", script=script)
|
|
218
|
+
assert proc.returncode == 0, proc.stdout + proc.stderr
|
|
219
|
+
assert "max frontier width: 2" in proc.stdout
|
|
220
|
+
assert_untouched(proc, repo, before, tmp_path)
|
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
"""In-process tests of the human stops: the scenarios of test_stops.py without the launcher, so coverage sees the
|
|
2
|
+
driver, plus the cases that only a direct call reaches (the answers file as input from outside, the question ids,
|
|
3
|
+
a template that is not there)."""
|
|
4
|
+
import json
|
|
5
|
+
import shutil
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
import driver_answers
|
|
10
|
+
import driver_decisions
|
|
11
|
+
import driver_review
|
|
12
|
+
import driver_review_page
|
|
13
|
+
from test_cli import git
|
|
14
|
+
from test_fixloop import Inside, inside # noqa: F401 (the autouse fixture puts the rig's CLIs on PATH)
|
|
15
|
+
from test_tidy import Inside as Pushing, tools # noqa: F401 (an unattended run pushes: the rig with an origin and a fake gh)
|
|
16
|
+
from test_review import ONE, finding, fix, review
|
|
17
|
+
from test_stops import CONTRA, NEW_TEST, Q1, UNATTENDED, blocked, correction, decisions, stage, steps, write_answers
|
|
18
|
+
from test_walk import impl, plan, tw
|
|
19
|
+
|
|
20
|
+
PAGE = ".agent/implement-review.html"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def stopped(tmp_path, *extra, then=(impl("01"),)):
|
|
24
|
+
return Inside(tmp_path, ONE).run(steps(blocked(), *then), *extra)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def doc(*questions, run="run-1"):
|
|
28
|
+
"""decisions.json of a run whose state.json holds `questions`."""
|
|
29
|
+
return driver_decisions.document({"revision": run, "questions": list(questions)})
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def asked(kind="locked-test", ticket="01", subject="tests/test_a.py::test_x", before=(), stop=1):
|
|
33
|
+
return driver_decisions.question(kind, ticket, subject, ["report.json", "ledger.md:3"], list(before), stop)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------- the stop and its answer
|
|
37
|
+
|
|
38
|
+
def test_a_stop_prints_the_question_and_an_answer_is_applied_once(tmp_path, capsys):
|
|
39
|
+
rig = stopped(tmp_path)
|
|
40
|
+
out = capsys.readouterr().out
|
|
41
|
+
assert rig.code == 2 and f"Question {Q1}: Ticket 01: the implementer reports" in out
|
|
42
|
+
assert " overrule-implementer (recommended): " in out and "--answer <question id>=<option id>" in out
|
|
43
|
+
(q,) = rig.state()["run"]["questions"]
|
|
44
|
+
assert (q["ticket"], q["kind"], q["subject"], q["stop"], q["answer"]) == (
|
|
45
|
+
"01", "locked-test", "tests/test_01.py::test_x", 1, None)
|
|
46
|
+
assert q["evidence"][0] == ".agent/implement/01/implementer-0.json" and rig.state()["run"]["stops"] == 1
|
|
47
|
+
assert rig.led("Ticket 01: the operator decides on 1 question of kind locked-test in .agent/implement/decisions.json.")
|
|
48
|
+
|
|
49
|
+
rig.run(steps(blocked(), impl("01")), "--answer", f"{Q1}=overrule-test")
|
|
50
|
+
assert rig.code == 0, rig.ledger()
|
|
51
|
+
assert len(rig.led("Ruling: 01 locked test overruled: the test-writer corrects tests/test_01.py::test_x")) == 1
|
|
52
|
+
prompt = rig.prompt("test-writer-0")
|
|
53
|
+
assert "This call is a correction after a ruling" in prompt and "read `concerns` in .agent/implement/01/implementer-0.json" in prompt
|
|
54
|
+
assert "ruling" not in rig.prompt("implementer-0") and prompt.rstrip().endswith("one ref store.")
|
|
55
|
+
entry = rig.state()["tickets"]["01"]
|
|
56
|
+
assert (entry["phase"], entry["note"], entry["locked"]) == ("merged", "", ["tests/test_01.py"])
|
|
57
|
+
assert rig.state()["run"]["questions"][0]["answer"] == "overrule-test"
|
|
58
|
+
assert decisions(rig)["questions"] == [] and not (rig.repo / PAGE).exists()
|
|
59
|
+
assert "Question" not in capsys.readouterr().out
|
|
60
|
+
|
|
61
|
+
# The question is closed: its answer cannot be given again, and the old answers file is history.
|
|
62
|
+
rig.run({}, "--answer", f"{Q1}=overrule-implementer")
|
|
63
|
+
assert rig.code == 3 and f"--answer {Q1}=overrule-implementer: no open question {Q1}" in capsys.readouterr().out
|
|
64
|
+
rig.run({})
|
|
65
|
+
assert rig.code == 0 and not rig.led("ignored") and len(rig.led("Ruling")) == 1
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_overrule_implementer_runs_the_implementer_again_with_the_ruling(tmp_path, capsys):
|
|
69
|
+
rig = stopped(tmp_path)
|
|
70
|
+
rig.run(steps(blocked(), impl("01")), "--answer", f"{Q1}=nope")
|
|
71
|
+
assert rig.code == 3 and f"--answer {Q1}=nope: {Q1} offers overrule-test, overrule-implementer" in capsys.readouterr().out
|
|
72
|
+
assert not (rig.repo / driver_answers.FILE).exists()
|
|
73
|
+
rig.run(steps(blocked(), impl("01")), "--answer", f"{Q1}=overrule-implementer")
|
|
74
|
+
assert rig.code == 0, rig.ledger()
|
|
75
|
+
assert rig.led("Ruling: 01 locked test stands: the implementer is overruled on tests/test_01.py::test_x")
|
|
76
|
+
assert len(rig.calls("01", "test-writer")) == 1 and len(rig.calls("01", "implementer")) == 2
|
|
77
|
+
assert "the operator ruled that the locked tests stand as they are" in rig.prompt("implementer-0")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_an_answer_needs_a_question_and_an_option(tmp_path, capsys):
|
|
81
|
+
with pytest.raises(SystemExit) as usage:
|
|
82
|
+
stopped(tmp_path, "--answer", "no-equals-sign")
|
|
83
|
+
assert usage.value.code == 3 and "--answer" in capsys.readouterr().out
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_a_second_stop_counts_on_and_its_page_holds_only_the_open_question(tmp_path, capsys):
|
|
87
|
+
rig = stopped(tmp_path, then=(blocked(),))
|
|
88
|
+
rig.run(steps(blocked(), blocked()), "--answer", f"{Q1}=overrule-test")
|
|
89
|
+
assert rig.code == 2 and [q["id"] for q in decisions(rig)["questions"]] == ["01-locked-test-2"]
|
|
90
|
+
html = (rig.repo / PAGE).read_text()
|
|
91
|
+
assert 'data-q="01-locked-test-2"' in html and Q1 not in html and rig.state()["run"]["stops"] == 2
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ---------------------------------------------------------------- what a stop keeps for the rerun
|
|
95
|
+
|
|
96
|
+
def test_an_answers_file_of_an_earlier_run_is_not_applied_to_the_same_question_of_a_new_run(tmp_path, capsys):
|
|
97
|
+
rig = stopped(tmp_path)
|
|
98
|
+
old = decisions(rig)
|
|
99
|
+
write_answers(rig, {Q1: "overrule-test"})
|
|
100
|
+
# The operator starts again: the run directory goes, the answers file is outside it and stays.
|
|
101
|
+
shutil.rmtree(rig.repo / ".agent/implement")
|
|
102
|
+
git(rig.repo, "branch", "-D", "ticket/01")
|
|
103
|
+
again = steps(blocked(), blocked(), impl("01"))
|
|
104
|
+
rig.run(again)
|
|
105
|
+
new = decisions(rig)
|
|
106
|
+
assert rig.code == 2 and new["questions"] == old["questions"], "the same question, word for word"
|
|
107
|
+
assert new["run"] == rig.state()["run"]["revision"] != old["run"] and new["revision"] != old["revision"]
|
|
108
|
+
rig.run(again)
|
|
109
|
+
assert rig.code == 2 and rig.led("is ignored: its revision") and not rig.led("Ruling")
|
|
110
|
+
assert len(rig.calls("01", "implementer")) == 2, "no agent ran on the old answer"
|
|
111
|
+
assert doc(asked(), run="a")["revision"] != doc(asked(), run="b")["revision"]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def partial():
|
|
115
|
+
"""An implementer that commits a part of its work, then reports the contradicted locked test."""
|
|
116
|
+
step = {**blocked(), "files": {"demo/export.py": "def partial():\n return 1\n"}, "commit": "feat: 01 partial work"}
|
|
117
|
+
step["report"] = {**step["report"], "commits": ["$SHA"], "files": ["demo/export.py"]}
|
|
118
|
+
return step
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@pytest.mark.parametrize("option, role", [("overrule-implementer", "implementer"), ("overrule-test", "test-writer")])
|
|
122
|
+
def test_after_a_ruling_the_ticket_goes_on_from_the_commits_of_the_blocked_implementer(tmp_path, capsys, option, role):
|
|
123
|
+
rig = Inside(tmp_path, ONE).run(steps(partial(), impl("01")))
|
|
124
|
+
stop = rig.calls("01", "implementer")[0]["sha"]
|
|
125
|
+
assert rig.code == 2 and rig.state()["tickets"]["01"]["tip"] == stop
|
|
126
|
+
rig.run(steps(partial(), impl("01")), "--answer", f"{Q1}={option}")
|
|
127
|
+
assert rig.code == 0, rig.ledger()
|
|
128
|
+
after = rig.calls("01", role)[1]
|
|
129
|
+
assert after["head"] == stop and "feat: 01 partial work" in after["log"].splitlines()
|
|
130
|
+
# After overrule-test the implementer starts from the correction, which is on top of its own commit.
|
|
131
|
+
assert "feat: 01 partial work" in rig.calls("01", "implementer")[1]["log"].splitlines()
|
|
132
|
+
assert rig.state()["tickets"]["01"]["locked"] == ["tests/test_01.py"]
|
|
133
|
+
assert "feat: 01 partial work" in rig.pr_subjects() and "feat: 01 rows" in rig.pr_subjects()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_a_stop_with_uncommitted_work_keeps_its_slot_and_takes_the_answer(tmp_path, capsys):
|
|
137
|
+
dirty = {**blocked(), "files": {"demo/export.py": "half = 1\n"}}
|
|
138
|
+
rig = Pushing(tmp_path, ONE).run(steps(dirty, impl("01")), *UNATTENDED)
|
|
139
|
+
assert rig.code == 2 and git(rig.repo, "status", "--porcelain") == " M demo/export.py\n"
|
|
140
|
+
rig.run(steps(dirty, impl("01")), *UNATTENDED)
|
|
141
|
+
assert rig.code == 2 and len(rig.calls()) == 2, "the stop repeats: the work in the slot is no error"
|
|
142
|
+
assert (rig.repo / "demo/export.py").read_text() == "half = 1\n"
|
|
143
|
+
write_answers(rig, {Q1: "overrule-implementer"})
|
|
144
|
+
rig.run(steps(dirty, impl("01")), *UNATTENDED)
|
|
145
|
+
assert rig.code == 0, rig.ledger()
|
|
146
|
+
second = rig.calls("01", "implementer")[1]
|
|
147
|
+
assert second["status"] == "M demo/export.py" and second["branch"] == "ticket/01", "the work is in the tree of the rerun"
|
|
148
|
+
prompt = (rig.folder() / "implementer-0.prompt.md").read_text()
|
|
149
|
+
assert "the locked tests stand as they are" in prompt and "uncommitted work" in prompt
|
|
150
|
+
assert "feat: 01 rows" in rig.pr_subjects() and not git(rig.repo, "status", "--porcelain")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# ---------------------------------------------------------------- the correction after overrule-test
|
|
154
|
+
|
|
155
|
+
OTHER = "tests/test_01b.py"
|
|
156
|
+
OTHER_TEST = "def test_y():\n assert 1 == 'y'\n"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def two_locked():
|
|
160
|
+
"""A test-writer that locks two files; the contradiction is about the first one."""
|
|
161
|
+
step = tw("01")
|
|
162
|
+
step["files"][OTHER] = OTHER_TEST
|
|
163
|
+
step["report"] = {**step["report"], "locked": ["tests/test_01.py", OTHER], "files": ["tests/test_01.py", OTHER]}
|
|
164
|
+
return step
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def restored(path):
|
|
168
|
+
return f"Ticket 01: {path} was not the locked version at the correction: the driver restored it in the tree"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
TWO_TESTS = "def test_x():\n assert 01 == 'x'\n\n\ndef test_y():\n assert 1 == 'y'\n"
|
|
172
|
+
ONLY_X = TWO_TESTS.split("\n\n\n")[0] + "\n"
|
|
173
|
+
# The edit of an agent: the one line of test_x, in place, in the file as the tree has it.
|
|
174
|
+
EDIT = "--- a/tests/test_01.py\n+++ b/tests/test_01.py\n@@ -2 +2 @@\n- assert 01 == 'x'\n+ assert '01' == '01'\n"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@pytest.mark.parametrize("committed", [True, False])
|
|
178
|
+
def test_the_correction_starts_from_the_locked_version_of_a_file_the_implementer_changed(tmp_path, capsys, committed):
|
|
179
|
+
writer = {**tw("01"), "files": {"tests/test_01.py": TWO_TESTS}}
|
|
180
|
+
deletes = {**(partial() if committed else blocked()), "files": {"tests/test_01.py": ONLY_X}}
|
|
181
|
+
rig = Inside(tmp_path, ONE)
|
|
182
|
+
(rig.rig / "edit.patch").write_text(EDIT)
|
|
183
|
+
edits = {**correction(), "files": {}, "git": [["apply", "--unidiff-zero", str(rig.rig / "edit.patch")]]}
|
|
184
|
+
scenario = plan(**{"01/test-writer": [writer, edits], "01/implementer": [deletes, impl("01")]})
|
|
185
|
+
rig.run(scenario)
|
|
186
|
+
assert rig.code == 2, rig.ledger()
|
|
187
|
+
rig.run(scenario, "--answer", f"{Q1}=overrule-test")
|
|
188
|
+
assert rig.code == 0, rig.ledger()
|
|
189
|
+
assert git(rig.repo, "show", "feat/csv:tests/test_01.py") == TWO_TESTS.replace("01 == 'x'", "'01' == '01'")
|
|
190
|
+
assert len(rig.led(restored("tests/test_01.py"))) == 1 and not rig.led("locked file changed")
|
|
191
|
+
assert rig.state()["tickets"]["01"]["held"] == {}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def test_a_locked_file_the_correction_changes_without_listing_it_is_still_a_finding(tmp_path, capsys):
|
|
195
|
+
gutted = partial()
|
|
196
|
+
gutted["files"] = {**gutted["files"], OTHER: "# gutted\n"}
|
|
197
|
+
# The driver restored the other file before the correction; the correction guts it again and commits it unlisted.
|
|
198
|
+
guts = {**correction(), "files": {"tests/test_01.py": NEW_TEST, OTHER: "# gutted again\n"}}
|
|
199
|
+
restore = fix("01", 1, files={"demo/export.py": "x = 1\n", OTHER: OTHER_TEST})
|
|
200
|
+
scenario = plan(**{"01/test-writer": [two_locked(), guts], "01/implementer": [gutted, impl("01")],
|
|
201
|
+
"01/fixer": [restore]})
|
|
202
|
+
rig = Inside(tmp_path, ONE).run(scenario)
|
|
203
|
+
first = rig.calls("01", "test-writer")[0]["sha"]
|
|
204
|
+
rig.run(scenario, "--answer", f"{Q1}=overrule-test")
|
|
205
|
+
assert rig.code == 0, rig.ledger()
|
|
206
|
+
assert rig.led(restored(OTHER)) and not rig.led(restored("tests/test_01.py"))
|
|
207
|
+
(line,) = rig.led(f"locked file changed: {OTHER}")
|
|
208
|
+
assert "critical" in line and len(rig.calls("01", "fixer")) == 1
|
|
209
|
+
entry = rig.state()["tickets"]["01"]
|
|
210
|
+
assert entry["held"] == {OTHER: first} and entry["test_writer_sha"] == rig.calls("01", "test-writer")[1]["sha"]
|
|
211
|
+
assert git(rig.repo, "show", f"feat/csv:{OTHER}") == OTHER_TEST and git(rig.repo, "show", "feat/csv:tests/test_01.py") == NEW_TEST
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def test_a_correction_that_commits_more_than_the_files_it_lists_is_rejected(tmp_path, capsys):
|
|
215
|
+
dirty = {**blocked(), "files": {"demo/export.py": "half = 1\n", OTHER: "# gutted\n"}}
|
|
216
|
+
# The correction commits with `git add -A`, so the implementer's work in the tree goes into its commit. The
|
|
217
|
+
# locked file it gutted is back at the locked version before the correction, so only the other file is a fault.
|
|
218
|
+
scenario = plan(**{"01/test-writer": [two_locked(), correction(), correction()], "01/implementer": [dirty, impl("01")]})
|
|
219
|
+
rig = Inside(tmp_path, ONE).run(scenario)
|
|
220
|
+
rig.run(scenario, "--answer", f"{Q1}=overrule-test")
|
|
221
|
+
assert rig.code == 1, rig.ledger()
|
|
222
|
+
assert rig.led("Ticket 01: BLOCKED at the test-writer: a correction may change only the files it lists in "
|
|
223
|
+
"`locked`: its commits also change demo/export.py.")
|
|
224
|
+
assert rig.led(restored(OTHER)) and git(rig.repo, "show", f"ticket/01:{OTHER}") == OTHER_TEST
|
|
225
|
+
assert len(rig.calls("01", "test-writer")) == 3 and len(rig.calls("01", "implementer")) == 1, "one rerun, no implementer"
|
|
226
|
+
assert "its commits also change demo/export.py" in rig.prompt("test-writer-0")
|
|
227
|
+
assert "feat: 01 rows" not in rig.pr_subjects() and rig.state()["tickets"]["01"]["held"] == {}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def test_a_correction_of_its_own_file_leaves_the_work_in_the_tree_to_the_implementer(tmp_path, capsys):
|
|
231
|
+
dirty = {**blocked(), "files": {"demo/export.py": "half = 1\n"}}
|
|
232
|
+
# This correction commits only its test file: the implementer's file is out of the reach of `git add -A`.
|
|
233
|
+
only = {**correction(), "git_after": [["update-index", "--assume-unchanged", "demo/export.py"]]}
|
|
234
|
+
goes_on = {**impl("01"), "files": {}, "git": [["update-index", "--no-assume-unchanged", "demo/export.py"]]}
|
|
235
|
+
scenario = plan(**{"01/test-writer": [two_locked(), only], "01/implementer": [dirty, goes_on]})
|
|
236
|
+
rig = Inside(tmp_path, ONE).run(scenario)
|
|
237
|
+
rig.run(scenario, "--answer", f"{Q1}=overrule-test")
|
|
238
|
+
assert rig.code == 0, rig.ledger()
|
|
239
|
+
fixed = rig.calls("01", "test-writer")[1]["sha"]
|
|
240
|
+
assert git(rig.repo, "show", "--name-only", "--format=", fixed).split() == ["tests/test_01.py"]
|
|
241
|
+
assert git(rig.repo, "show", "feat/csv:demo/export.py") == "half = 1\n", "the implementer committed its own work"
|
|
242
|
+
entry = rig.state()["tickets"]["01"]
|
|
243
|
+
assert entry["held"] == {} and entry["locked"] == ["tests/test_01.py", OTHER] and not rig.led("locked file changed")
|
|
244
|
+
assert git(rig.repo, "show", f"feat/csv:{OTHER}") == OTHER_TEST
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def test_a_locked_file_keeps_its_old_reference_until_a_test_writer_lists_it(repo, tmp_path):
|
|
248
|
+
def commit(name, text):
|
|
249
|
+
(tmp_path / name).write_text(text)
|
|
250
|
+
return git(tmp_path, "add", "-A") + git(tmp_path, "commit", "-q", "-m", name) + git(tmp_path, "rev-parse", "HEAD").strip()
|
|
251
|
+
|
|
252
|
+
repo()
|
|
253
|
+
commit("a.py", "1\n"), commit("c.py", "1\n")
|
|
254
|
+
old = commit("b.py", "1\n")
|
|
255
|
+
commit("b.py", "the implementer changed it\n")
|
|
256
|
+
tip = commit("a.py", "the correction\n")
|
|
257
|
+
first = driver_review.written(".", {"test_writer_sha": None, "locked": [], "held": {}}, ["a.py", "b.py", "c.py"], old)
|
|
258
|
+
assert first == {"test_writer_sha": old, "locked": ["a.py", "b.py", "c.py"], "held": {}}
|
|
259
|
+
after = driver_review.written(".", first, ["a.py"], tip)
|
|
260
|
+
assert after == {"test_writer_sha": tip, "locked": ["a.py", "b.py", "c.py"], "held": {"b.py": old}}
|
|
261
|
+
(finding,) = driver_review.locked_findings(".", after)
|
|
262
|
+
assert (finding["title"], finding["evidence"]) == ("locked file changed: b.py", f"git diff {old}..HEAD -- b.py")
|
|
263
|
+
assert driver_review.written(".", after, ["b.py"], tip)["held"] == {}, "the test-writer corrected it: a new reference"
|
|
264
|
+
# A diff that git cannot make holds each other file, and then the check of the review fails in the open.
|
|
265
|
+
lost = driver_review.written(".", {**first, "test_writer_sha": "0" * 40}, ["a.py"], tip)
|
|
266
|
+
assert lost["held"] == {"b.py": "0" * 40, "c.py": "0" * 40}
|
|
267
|
+
assert [f["title"].split(":")[0] for f in driver_review.locked_findings(".", lost)] == ["locked file check failed"]
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
# ---------------------------------------------------------------- open findings after round 4
|
|
271
|
+
|
|
272
|
+
def test_round_4_findings_wait_for_every_answer_before_the_ticket_merges(tmp_path, capsys):
|
|
273
|
+
rig = Inside(tmp_path, ONE)
|
|
274
|
+
bad = review("01", [finding("STUCK-A", line=98), finding("STUCK-B", line=99)])
|
|
275
|
+
rig.run(plan(**{"01/reviewer": [bad] * 5, "01/fixer": [fix("01", n) for n in (1, 2, 3, 4)]}))
|
|
276
|
+
out = capsys.readouterr().out
|
|
277
|
+
one, two = rig.state()["run"]["questions"]
|
|
278
|
+
assert rig.code == 1 and (one["id"], two["id"]) == ("01-finding-1", "01-finding-2") and one["stop"] == two["stop"]
|
|
279
|
+
assert one["subject"] == "STUCK-A (demo/export.py:98)" and one["evidence"][0] == ".agent/implement/01/findings-4.json"
|
|
280
|
+
assert (rig.repo / one["evidence"][0]).exists() and "Question 01-finding-2: " in out and "split (recommended)" in out
|
|
281
|
+
|
|
282
|
+
write_answers(rig, {"01-finding-1": "accept", "01-finding-1.note": "known limit", "01-finding-2.note": "?"})
|
|
283
|
+
rig.run({})
|
|
284
|
+
assert rig.code == 1 and rig.state()["tickets"]["01"]["phase"] == "blocked"
|
|
285
|
+
assert rig.led("Ruling: 01 finding closed as it is (accept): STUCK-A (demo/export.py:98). Note of the operator: known limit")
|
|
286
|
+
assert [q["id"] for q in decisions(rig)["questions"]] == ["01-finding-2"]
|
|
287
|
+
# The file was written for the two questions: for the one that is left it is stale.
|
|
288
|
+
rig.run({})
|
|
289
|
+
assert rig.code == 1 and len(rig.led("Ruling")) == 1 and rig.led("is ignored: its revision")
|
|
290
|
+
rig.run({}, "--answer", "01-finding-2=accept")
|
|
291
|
+
assert rig.code == 0 and rig.state()["tickets"]["01"]["phase"] == "merged" and len(rig.calls("01", "fixer")) == 4
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def test_one_answer_that_keeps_the_ticket_blocked_wins_over_an_accept():
|
|
295
|
+
one = asked("finding", subject="A")
|
|
296
|
+
two = asked("finding", subject="B", before=[one])
|
|
297
|
+
other = asked("finding", ticket="02", subject="C", before=[one, two], stop=2)
|
|
298
|
+
rulings, moved = driver_decisions.apply([one, two, other], {one["id"]: "accept", two["id"]: "redesign",
|
|
299
|
+
other["id"]: "accept", "09-finding-1": "accept"})
|
|
300
|
+
assert moved == {"02": {"phase": "merge"}} and len(rulings) == 3
|
|
301
|
+
assert rulings[1] == "Ruling: 01 stays BLOCKED, the design changes first (redesign): B."
|
|
302
|
+
assert driver_decisions.apply([one, two, other], {one["id"]: "split"}) == ([], {}), "an answer is applied once"
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
# ---------------------------------------------------------------- unattended
|
|
306
|
+
|
|
307
|
+
def test_unattended_writes_the_stage_file_and_prints_no_question(tmp_path, capsys):
|
|
308
|
+
rig = Pushing(tmp_path, ONE).run(steps(blocked(), impl("01")), *UNATTENDED)
|
|
309
|
+
assert rig.code == 2 and "Question" not in rig.out
|
|
310
|
+
assert stage(rig) == {"stage": "implement", "status": "awaiting-review", "artifact": PAGE, "open_questions": 1}
|
|
311
|
+
write_answers(rig, {Q1: "overrule-test"})
|
|
312
|
+
rig.run(steps(blocked(), impl("01")), *UNATTENDED)
|
|
313
|
+
assert rig.code == 0 and stage(rig) == {"stage": "implement", "status": "done"}, "no stage file of the earlier run stays"
|
|
314
|
+
assert (rig.repo / "tests/test_01.py").read_text() == NEW_TEST
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def test_unattended_names_the_blocked_tickets_and_needs_a_stage_path_to_write(tmp_path):
|
|
318
|
+
rig = Pushing(tmp_path, ONE, gate={"red": ["ticket/01"]})
|
|
319
|
+
rig.run(plan(), "--unattended")
|
|
320
|
+
assert rig.code == 1 and not (rig.repo / ".agent/stage.json").exists()
|
|
321
|
+
rig.run(plan(), "--unattended", "--stage-json", ".agent/out/stage.json")
|
|
322
|
+
assert json.loads((rig.repo / ".agent/out/stage.json").read_text()) == {
|
|
323
|
+
"stage": "implement", "status": "blocked", "blocked": ["01"]}
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
# ---------------------------------------------------------------- the page
|
|
327
|
+
|
|
328
|
+
def test_a_stop_without_the_review_page_skill_says_so_and_the_next_run_writes_the_page(tmp_path, monkeypatch, capsys):
|
|
329
|
+
template = driver_review_page.TEMPLATE
|
|
330
|
+
monkeypatch.setattr(driver_review_page, "TEMPLATE", tmp_path / "skills/review-page/template.html")
|
|
331
|
+
rig = stopped(tmp_path)
|
|
332
|
+
out = capsys.readouterr().out
|
|
333
|
+
assert rig.code == 3 and f"cannot write {PAGE}" in out and "skills/review-page/template.html" in out
|
|
334
|
+
assert "install the review-page skill next to implement-spec" in out and decisions(rig)["questions"]
|
|
335
|
+
rig.run({})
|
|
336
|
+
assert rig.code == 3 and not rig.calls("01", "reviewer"), "the same error, before any agent"
|
|
337
|
+
monkeypatch.setattr(driver_review_page, "TEMPLATE", template)
|
|
338
|
+
rig.run({})
|
|
339
|
+
assert rig.code == 2 and f'data-q="{Q1}"' in (rig.repo / PAGE).read_text()
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def test_a_template_of_another_shape_is_an_error_and_every_text_of_a_question_is_escaped(tmp_path, monkeypatch):
|
|
343
|
+
q = asked(subject='<img onerror="x">')
|
|
344
|
+
q["evidence"], q["options"][0]["description"] = ["<i>.json"], "a & b"
|
|
345
|
+
html = driver_review_page.render(doc(q))
|
|
346
|
+
assert "<img" not in html and "<img onerror="x">" in html and "<code><i>.json</code>" in html
|
|
347
|
+
assert "a & b" in html and "<h1>1 open question of the implementation</h1>" in html
|
|
348
|
+
monkeypatch.setattr(driver_review_page, "TEMPLATE", tmp_path / "template.html")
|
|
349
|
+
(tmp_path / "template.html").write_text(html.replace("<!-- slot: track -->", ""))
|
|
350
|
+
with pytest.raises(ValueError, match="has 0 matches"):
|
|
351
|
+
driver_review_page.render(doc(q))
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
# ---------------------------------------------------------------- questions
|
|
355
|
+
|
|
356
|
+
def test_only_an_implementer_blocked_on_the_contradiction_concern_is_a_stop():
|
|
357
|
+
report = {"status": "BLOCKED", "concerns": [CONTRA, "other", "locked test contradicts spec: b::c", driver_decisions.CONTRADICTS]}
|
|
358
|
+
assert driver_decisions.contradicted("implementer", report) == "tests/test_01.py::test_x; b::c"
|
|
359
|
+
assert driver_decisions.contradicted("fixer", report) == ""
|
|
360
|
+
assert driver_decisions.contradicted("implementer", {**report, "status": "DONE"}) == ""
|
|
361
|
+
assert driver_decisions.contradicted("implementer", {}) == "", "a rejected report is no report"
|
|
362
|
+
assert driver_decisions.contradicted("implementer", {"status": "BLOCKED", "concerns": [driver_decisions.CONTRADICTS]}) == ""
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def test_question_ids_count_per_ticket_and_kind_over_the_run_and_only_open_questions_are_published():
|
|
366
|
+
one = asked()
|
|
367
|
+
two, other, found = asked(before=[one]), asked(ticket="02", before=[one]), asked("finding", before=[one])
|
|
368
|
+
assert [q["id"] for q in (one, two, other, found)] == ["01-locked-test-1", "01-locked-test-2", "02-locked-test-1", "01-finding-1"]
|
|
369
|
+
both = doc(one, two)
|
|
370
|
+
assert set(both["questions"][0]) == set(driver_decisions.PUBLIC) and len(both["questions"]) == 2
|
|
371
|
+
one["answer"] = "overrule-test"
|
|
372
|
+
left = doc(one, two)
|
|
373
|
+
assert [q["id"] for q in left["questions"]] == ["01-locked-test-2"] and left["revision"] != both["revision"]
|
|
374
|
+
assert driver_decisions.parked([one, two]) and not driver_decisions.parked([one, found])
|
|
375
|
+
assert driver_decisions.text([], "answers.json", "page.html") == ""
|
|
376
|
+
for kind in driver_decisions.KINDS.values():
|
|
377
|
+
assert 2 <= len(kind.options) <= 4 and kind.recommended in kind.options
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
# ---------------------------------------------------------------- the answers file
|
|
381
|
+
|
|
382
|
+
@pytest.fixture
|
|
383
|
+
def one(tmp_path, monkeypatch):
|
|
384
|
+
"""The decisions file of one open question, in a directory where the answers file goes."""
|
|
385
|
+
monkeypatch.chdir(tmp_path)
|
|
386
|
+
(tmp_path / ".agent").mkdir()
|
|
387
|
+
return doc(asked())
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def answers(document, **fields):
|
|
391
|
+
body = {"v": 1, "stage": "implement", "submitted": "approve", "revision": document["revision"],
|
|
392
|
+
"answers": {Q1: "overrule-test"}, **fields}
|
|
393
|
+
driver_answers.FILE.write_text(json.dumps(body))
|
|
394
|
+
return driver_answers.read(document)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
@pytest.mark.parametrize("fields, why", [
|
|
398
|
+
({"revision": "0" * 64}, "its revision 000000000000 is not the revision"),
|
|
399
|
+
({"revision": None}, "its revision None is not the revision"),
|
|
400
|
+
({"submitted": None}, "it is a draft: `submitted` is not set"),
|
|
401
|
+
({"stage": "plan"}, "it is not a version 1 file of the implement stage"),
|
|
402
|
+
({"v": 2}, "it is not a version 1 file of the implement stage"),
|
|
403
|
+
({"answers": ["overrule-test"]}, "it is not an object with an `answers` object")])
|
|
404
|
+
def test_a_file_that_is_not_a_submission_for_this_revision_gives_no_answer(one, fields, why):
|
|
405
|
+
found, (ignored,) = answers(one, **fields)
|
|
406
|
+
assert found == {} and ignored.startswith(f"The answers file .agent/implement-answers.json is ignored: {why}")
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
@pytest.mark.parametrize("content", ["{not json", "[]", '"text"'])
|
|
410
|
+
def test_a_file_that_is_not_a_json_object_gives_no_answer(one, content):
|
|
411
|
+
driver_answers.FILE.write_text(content)
|
|
412
|
+
found, (ignored,) = driver_answers.read(one)
|
|
413
|
+
assert found == {} and "is ignored: it is not" in ignored
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def test_each_answer_is_checked_on_its_own_and_a_note_goes_with_its_question(one):
|
|
417
|
+
found, ignored = answers(one, answers={Q1: "overrule-test", f"{Q1}.note": "see ADR 3", "track": "standard",
|
|
418
|
+
"02-finding-1.note": "x"})
|
|
419
|
+
assert found == {Q1: "overrule-test", f"{Q1}.note": "see ADR 3"}
|
|
420
|
+
assert ignored == ["The answer for track in .agent/implement-answers.json is ignored: there is no open question track.",
|
|
421
|
+
"The answer for 02-finding-1.note in .agent/implement-answers.json is ignored: there is no open "
|
|
422
|
+
"question 02-finding-1."]
|
|
423
|
+
found, (ignored,) = answers(one, answers={Q1: ["overrule-test"], f"{Q1}.note": 5})
|
|
424
|
+
assert found == {} and ignored.endswith("the question offers overrule-test, overrule-implementer.")
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def test_no_open_question_or_no_file_reads_nothing_and_given_writes_only_valid_answers(one):
|
|
428
|
+
assert driver_answers.read(one) == ({}, [])
|
|
429
|
+
assert driver_answers.given([], one) == [] and not driver_answers.FILE.exists()
|
|
430
|
+
assert driver_answers.given([(Q1, "overrule-test"), ("zz", "accept")], one) == ["--answer zz=accept: no open question zz"]
|
|
431
|
+
assert not driver_answers.FILE.exists()
|
|
432
|
+
assert driver_answers.given([(Q1, "overrule-test")], one) == []
|
|
433
|
+
assert driver_answers.read(one) == ({Q1: "overrule-test"}, [])
|
|
434
|
+
assert driver_answers.read(doc()) == ({}, []), "with no open question the file is history"
|