bugbout 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. bugbout/__about__.py +1 -0
  2. bugbout/__init__.py +60 -0
  3. bugbout/__main__.py +5 -0
  4. bugbout/agents/__init__.py +26 -0
  5. bugbout/agents/base.py +287 -0
  6. bugbout/agents/claude_code.py +107 -0
  7. bugbout/agents/codex.py +105 -0
  8. bugbout/agents/command.py +83 -0
  9. bugbout/agents/gemini_cli.py +73 -0
  10. bugbout/agents/mini_swe.py +103 -0
  11. bugbout/agents/plugins/bugbout_mini_env.py +27 -0
  12. bugbout/agents/registry.py +63 -0
  13. bugbout/agents/vendors.py +36 -0
  14. bugbout/arena/__init__.py +30 -0
  15. bugbout/arena/briefs.py +176 -0
  16. bugbout/arena/exhibits.py +78 -0
  17. bugbout/arena/judge.py +301 -0
  18. bugbout/arena/matrix.py +189 -0
  19. bugbout/arena/race.py +266 -0
  20. bugbout/bout.py +316 -0
  21. bugbout/cli/__init__.py +5 -0
  22. bugbout/cli/main.py +168 -0
  23. bugbout/config.py +145 -0
  24. bugbout/doctor.py +83 -0
  25. bugbout/errors.py +39 -0
  26. bugbout/home.py +22 -0
  27. bugbout/jsonfile.py +34 -0
  28. bugbout/log.py +30 -0
  29. bugbout/naming.py +12 -0
  30. bugbout/project.py +129 -0
  31. bugbout/py.typed +0 -0
  32. bugbout/report/__init__.py +19 -0
  33. bugbout/report/build.py +218 -0
  34. bugbout/report/markdown.py +161 -0
  35. bugbout/report/model.py +162 -0
  36. bugbout/report/terminal.py +42 -0
  37. bugbout/report/text.py +38 -0
  38. bugbout/sandbox/__init__.py +51 -0
  39. bugbout/sandbox/base.py +60 -0
  40. bugbout/sandbox/bubblewrap.py +80 -0
  41. bugbout/sandbox/seatbelt.py +71 -0
  42. bugbout/scaffold.py +135 -0
  43. bugbout/store.py +125 -0
  44. bugbout/tasks/__init__.py +18 -0
  45. bugbout/tasks/issues.py +149 -0
  46. bugbout/tasks/mining.py +205 -0
  47. bugbout/tasks/oracle.py +187 -0
  48. bugbout/tasks/task.py +60 -0
  49. bugbout/workspace/__init__.py +8 -0
  50. bugbout/workspace/environment.py +225 -0
  51. bugbout/workspace/junit.py +29 -0
  52. bugbout/workspace/patch.py +261 -0
  53. bugbout/workspace/repository.py +139 -0
  54. bugbout/workspace/workspace.py +85 -0
  55. bugbout-0.1.0.dist-info/METADATA +120 -0
  56. bugbout-0.1.0.dist-info/RECORD +59 -0
  57. bugbout-0.1.0.dist-info/WHEEL +4 -0
  58. bugbout-0.1.0.dist-info/entry_points.txt +2 -0
  59. bugbout-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,73 @@
1
+ """Google's Gemini CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import override
8
+
9
+ from bugbout.agents.base import CodingAgent, Invocation, Session, read_jsonl
10
+ from bugbout.sandbox import Confinement
11
+
12
+ # Workspace settings, not the user's: no web tools, nothing reported home.
13
+ _SETTINGS = {
14
+ "tools": {"exclude": ["web_fetch", "google_web_search"]},
15
+ "privacy": {"usageStatisticsEnabled": False},
16
+ "telemetry": {"enabled": False},
17
+ }
18
+
19
+
20
+ class GeminiCli(CodingAgent):
21
+ """``gemini --prompt`` in auto-approve mode, inside bugbout's sandbox.
22
+
23
+ Web tools are excluded through workspace settings. The whole process runs in
24
+ bugbout's sandbox: it reaches the Gemini API, but writes only in the
25
+ checkout, its logs and Gemini's own state directory.
26
+ """
27
+
28
+ harness = "gemini-cli"
29
+ executable = "gemini"
30
+
31
+ @override
32
+ def prepare(self, workdir: Path) -> None:
33
+ folder = workdir / ".gemini" # never part of a measured patch
34
+ folder.mkdir(exist_ok=True)
35
+ (folder / "settings.json").write_text(json.dumps(_SETTINGS, indent=2), encoding="utf-8")
36
+
37
+ @override
38
+ def invocation(self, session: Session) -> Invocation:
39
+ argv = [
40
+ self.command(),
41
+ "--prompt", session.prompt,
42
+ *(["--model", self.model] if self.model else []),
43
+ "--approval-mode", "yolo",
44
+ "--output-format", "stream-json",
45
+ "--skip-trust",
46
+ ] # fmt: skip
47
+ state = Path.home() / ".gemini"
48
+ confinement = Confinement(writable=(session.workdir, session.logdir, state), network=True)
49
+ # Gemini CLI would otherwise start a sandbox of its own inside bugbout's.
50
+ return Invocation(session.sandbox.wrap(argv, confinement), env={"GEMINI_SANDBOX": "false"})
51
+
52
+ @override
53
+ def parse(self, logdir: Path) -> tuple[str, dict[str, int]]:
54
+ parts: list[str] = []
55
+ usage: dict[str, int] = {}
56
+ for event in read_jsonl(logdir / "stdout.log"):
57
+ if event.get("type") == "message" and event.get("role") == "assistant":
58
+ parts.append(str(event.get("content", "")))
59
+ if event.get("type") != "result":
60
+ continue
61
+ if isinstance(stats := event.get("stats"), dict):
62
+ usage = {k: v for k, v in stats.items() if isinstance(v, int)}
63
+ if isinstance(error := event.get("error"), dict) and not parts:
64
+ parts.append(str(error.get("message", ""))) # why it stopped, as Gemini put it
65
+ return "".join(parts).strip()[-4000:], usage
66
+
67
+ @override
68
+ def describe(self) -> dict[str, str]:
69
+ return {
70
+ **super().describe(),
71
+ "sandbox": "bugbout's sandbox: writes only in the checkout and ~/.gemini",
72
+ "configuration": "workspace settings; web_fetch and google_web_search excluded",
73
+ }
@@ -0,0 +1,103 @@
1
+ """mini-swe-agent, the SWE-bench team's minimal agent, on any LiteLLM model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ from typing import Unpack, override
9
+
10
+ from bugbout.agents.base import CodingAgent, Identity, Invocation, Session
11
+ from bugbout.errors import ConfigError
12
+ from bugbout.sandbox import Confinement
13
+
14
+ PLUGINS = Path(__file__).resolve().parent / "plugins"
15
+ SANDBOX_PREFIX_ENV = "BUGBOUT_SANDBOX_PREFIX"
16
+
17
+
18
+ class MiniSweAgent(CodingAgent):
19
+ """``mini`` with an environment class that runs every command in bugbout's sandbox.
20
+
21
+ mini-swe-agent runs shell commands directly on the host, so bugbout supplies
22
+ its own environment (``plugins/bugbout_mini_env.py``): each command gets no
23
+ network and may write only in the checkout. Model calls stay in the agent's
24
+ own process, outside the sandbox.
25
+
26
+ Text actions - one bash block per reply - suit local models whose server
27
+ cannot parse tool calls reliably.
28
+ """
29
+
30
+ harness = "mini-swe-agent"
31
+ executable = "mini"
32
+
33
+ def __init__(
34
+ self,
35
+ model: str,
36
+ *,
37
+ text_actions: bool = False,
38
+ step_limit: int = 75,
39
+ model_kwargs: dict[str, str] | None = None,
40
+ **identity: Unpack[Identity],
41
+ ) -> None:
42
+ """``model`` is a LiteLLM model name, such as ``ollama/qwen3-coder:30b``."""
43
+ if not model:
44
+ raise ConfigError("mini-swe-agent needs a model: a LiteLLM name such as ollama/qwen3")
45
+ provider = model.split("/", 1)[0] if "/" in model else self.harness
46
+ identity.setdefault("quota", provider)
47
+ super().__init__(model, **identity)
48
+ self.text_actions = text_actions
49
+ self.step_limit = step_limit
50
+ self.model_kwargs = model_kwargs or {}
51
+
52
+ @override
53
+ def invocation(self, session: Session) -> Invocation:
54
+ settings = [f"agent.step_limit={self.step_limit}", f"environment.cwd={session.workdir}"]
55
+ settings += [
56
+ f"model.model_kwargs.{key}={value}" for key, value in self.model_kwargs.items()
57
+ ]
58
+ argv = [
59
+ self.command(),
60
+ "--model", self.model,
61
+ "--task", session.prompt,
62
+ "--yolo",
63
+ "--exit-immediately",
64
+ "--cost-limit", "0",
65
+ "--environment-class", "bugbout_mini_env.SandboxedEnvironment",
66
+ *(["--model-class", "litellm_textbased"] if self.text_actions else []),
67
+ "-c", "mini_textbased.yaml" if self.text_actions else "mini.yaml",
68
+ *[arg for setting in settings for arg in ("-c", setting)],
69
+ "--output", str(session.logdir / "trajectory.json"),
70
+ ] # fmt: skip
71
+ confinement = Confinement(writable=(session.workdir, session.logdir))
72
+ inherited = os.environ.get("PYTHONPATH")
73
+ env = {
74
+ "PYTHONPATH": os.pathsep.join(filter(None, [str(PLUGINS), inherited])),
75
+ SANDBOX_PREFIX_ENV: json.dumps(session.sandbox.prefix(confinement)),
76
+ "MSWEA_COST_TRACKING": "ignore_errors",
77
+ "MSWEA_CONFIGURED": "true",
78
+ "LITELLM_LOCAL_MODEL_COST_MAP": "True",
79
+ }
80
+ return Invocation(argv, env=env)
81
+
82
+ @override
83
+ def parse(self, logdir: Path) -> tuple[str, dict[str, int]]:
84
+ try:
85
+ data = json.loads((logdir / "trajectory.json").read_text(encoding="utf-8"))
86
+ except (OSError, json.JSONDecodeError):
87
+ return "", {}
88
+ said = [m.get("content") for m in data.get("messages", []) if m.get("role") == "assistant"]
89
+ last = next((s for s in reversed(said) if isinstance(s, str) and s.strip()), "")
90
+ info = data.get("info", {})
91
+ usage = {"steps": len(said)}
92
+ if isinstance(stats := info.get("model_stats"), dict):
93
+ usage.update({k: v for k, v in stats.items() if isinstance(v, int)})
94
+ return f"[{info.get('exit_status', '?')}] {last.strip()[-3000:]}", usage
95
+
96
+ @override
97
+ def describe(self) -> dict[str, str]:
98
+ return {
99
+ **super().describe(),
100
+ "actions": "text (one bash block per reply)" if self.text_actions else "tool calls",
101
+ "sandbox": "bugbout's sandbox per command: no network, writes only in the checkout",
102
+ "configuration": f"stock mini config, step limit {self.step_limit}",
103
+ }
@@ -0,0 +1,27 @@
1
+ """A mini-swe-agent environment that runs every command inside bugbout's sandbox.
2
+
3
+ Loaded by mini-swe-agent's own interpreter (through ``PYTHONPATH``), not by
4
+ bugbout's, so it depends on nothing but mini-swe-agent and the standard library.
5
+ bugbout passes the sandbox as a ready-made argv prefix in an environment variable.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import shlex
13
+ from typing import Any
14
+
15
+ from minisweagent.environments.local import LocalEnvironment # type: ignore[import-not-found]
16
+
17
+ SANDBOX_PREFIX_ENV = "BUGBOUT_SANDBOX_PREFIX"
18
+
19
+
20
+ class SandboxedEnvironment(LocalEnvironment): # type: ignore[misc]
21
+ def execute(self, action: dict[str, Any], cwd: str = "", *, timeout: int | None = None) -> Any:
22
+ if SANDBOX_PREFIX_ENV not in os.environ:
23
+ raise RuntimeError(f"{SANDBOX_PREFIX_ENV} is not set; refusing to run unsandboxed")
24
+ prefix: list[str] = json.loads(os.environ[SANDBOX_PREFIX_ENV])
25
+ command = str(action.get("command", ""))
26
+ wrapped = shlex.join([*prefix, "/bin/bash", "-c", command]) if prefix else command
27
+ return super().execute({**action, "command": wrapped}, cwd, timeout=timeout)
@@ -0,0 +1,63 @@
1
+ """Agents by the names configuration files use.
2
+
3
+ Other packages can add agents without touching bugbout, by declaring an entry
4
+ point in the ``bugbout.agents`` group that names a :class:`CodingAgent` subclass::
5
+
6
+ [project.entry-points."bugbout.agents"]
7
+ my-agent = "my_package.agent:MyAgent"
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from importlib.metadata import entry_points
13
+ from typing import Any
14
+
15
+ from bugbout.agents.base import CodingAgent
16
+ from bugbout.agents.claude_code import ClaudeCode
17
+ from bugbout.agents.codex import Codex
18
+ from bugbout.agents.command import CommandAgent
19
+ from bugbout.agents.gemini_cli import GeminiCli
20
+ from bugbout.agents.mini_swe import MiniSweAgent
21
+ from bugbout.errors import ConfigError
22
+ from bugbout.log import get_logger
23
+
24
+ log = get_logger(__name__)
25
+
26
+ ENTRY_POINT_GROUP = "bugbout.agents"
27
+ BUILTIN: dict[str, type[CodingAgent]] = {
28
+ "codex": Codex,
29
+ "claude-code": ClaudeCode,
30
+ "gemini-cli": GeminiCli,
31
+ "mini-swe-agent": MiniSweAgent,
32
+ "command": CommandAgent,
33
+ }
34
+
35
+
36
+ def agent_types() -> dict[str, type[CodingAgent]]:
37
+ """Every agent type by name: the built-in ones, then any that plugins provide."""
38
+ found = dict(BUILTIN)
39
+ for entry in entry_points(group=ENTRY_POINT_GROUP):
40
+ if entry.name in found:
41
+ log.warning("Ignoring plugin agent %r: the name is taken", entry.name)
42
+ continue
43
+ try:
44
+ loaded = entry.load()
45
+ except Exception as exc: # a broken plugin must not take bugbout down with it
46
+ log.warning("Could not load plugin agent %r: %s", entry.name, exc)
47
+ continue
48
+ if isinstance(loaded, type) and issubclass(loaded, CodingAgent):
49
+ found[entry.name] = loaded
50
+ else:
51
+ log.warning("Ignoring plugin agent %r: not a CodingAgent subclass", entry.name)
52
+ return found
53
+
54
+
55
+ def create_agent(kind: str, **options: Any) -> CodingAgent:
56
+ """An agent of type ``kind``, configured with ``options``."""
57
+ types = agent_types()
58
+ if kind not in types:
59
+ raise ConfigError(f"unknown agent type {kind!r}; available: {', '.join(sorted(types))}")
60
+ try:
61
+ return types[kind](**options)
62
+ except TypeError as exc:
63
+ raise ConfigError(f"invalid options for a {kind} agent: {exc}") from exc
@@ -0,0 +1,36 @@
1
+ """Which vendor made the model behind a name.
2
+
3
+ Independence is the judge's central rule - no vendor's evidence may vouch for its
4
+ own models - so the mapping from a name to a vendor lives in exactly one place.
5
+ Names are matched by marker, in order: ``codex/gpt-5.1`` and
6
+ ``mini-swe-agent/ollama/gpt-oss:20b`` are both OpenAI models, whatever runs them.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+
13
+ _MARKERS: tuple[tuple[re.Pattern[str], str], ...] = tuple(
14
+ (re.compile(pattern), vendor)
15
+ for pattern, vendor in (
16
+ (r"claude", "anthropic"),
17
+ (r"gemini|gemma", "google"),
18
+ (r"qwen", "alibaba"),
19
+ (r"devstral|codestral|magistral|mistral", "mistral"),
20
+ (r"deepseek", "deepseek"),
21
+ (r"llama", "meta"),
22
+ (r"grok", "xai"),
23
+ (r"kimi|moonshot", "moonshot"),
24
+ (r"glm", "zhipu"),
25
+ (r"gpt|codex|(?<![a-z0-9])o[1-9](?![0-9])", "openai"),
26
+ )
27
+ )
28
+
29
+
30
+ def vendor_of(name: str) -> str:
31
+ """The vendor whose model ``name`` refers to; an unknown name is its own vendor."""
32
+ lowered = name.lower()
33
+ for marker, vendor in _MARKERS:
34
+ if marker.search(lowered):
35
+ return vendor
36
+ return lowered.split("/")[0]
@@ -0,0 +1,30 @@
1
+ """The contest itself: briefs, the race, cross-examination and the judge."""
2
+
3
+ from bugbout.arena.briefs import ASSIGNMENTS, FIX, Assignment, Brief, Fix, Reproduce, Specify
4
+ from bugbout.arena.exhibits import Exhibit, exhibit_of
5
+ from bugbout.arena.judge import DEFAULT_POLICY, POLICIES, Judge, JudgePolicy, Standing, Verdict
6
+ from bugbout.arena.matrix import BASE, CrossExaminer, Matrix
7
+ from bugbout.arena.race import Attempt, Race
8
+
9
+ __all__ = [
10
+ "ASSIGNMENTS",
11
+ "BASE",
12
+ "DEFAULT_POLICY",
13
+ "FIX",
14
+ "POLICIES",
15
+ "Assignment",
16
+ "Attempt",
17
+ "Brief",
18
+ "CrossExaminer",
19
+ "Exhibit",
20
+ "Fix",
21
+ "Judge",
22
+ "JudgePolicy",
23
+ "Matrix",
24
+ "Race",
25
+ "Reproduce",
26
+ "Specify",
27
+ "Standing",
28
+ "Verdict",
29
+ "exhibit_of",
30
+ ]
@@ -0,0 +1,176 @@
1
+ """What an agent is asked to do with a task.
2
+
3
+ The same coding agent can play two roles, and only the brief differs. As a
4
+ **contestant** it fixes the issue and adds a regression test. As an
5
+ **examiner** it must not touch the source at all: it writes tests that capture
6
+ the issue and checks that they fail on the unfixed code for the reason the
7
+ issue describes. An examiner never sees a contestant's patch - its tests are
8
+ evidence about the requirement, not about anyone's fix.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from abc import ABC, abstractmethod
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import ClassVar, Literal
17
+
18
+ Role = Literal["contestant", "examiner"]
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class Brief:
23
+ """Everything an assignment's prompt is rendered from."""
24
+
25
+ repo: str
26
+ title: str
27
+ statement: str
28
+ source_root: str
29
+ test_root: str
30
+ runner: Path
31
+
32
+
33
+ class Assignment(ABC):
34
+ """A role an agent can be given, expressed as the prompt it receives."""
35
+
36
+ name: ClassVar[str]
37
+ role: ClassVar[Role]
38
+ test_file: ClassVar[str]
39
+ """The new test file the agent is asked to write, inside the test directory."""
40
+
41
+ @property
42
+ @abstractmethod
43
+ def template(self) -> str: ...
44
+
45
+ def prompt(self, brief: Brief) -> str:
46
+ return self.template.format(
47
+ repo=brief.repo,
48
+ title=brief.title,
49
+ statement=brief.statement.strip(),
50
+ source_root=brief.source_root,
51
+ test_root=brief.test_root,
52
+ test_file=self.test_file,
53
+ runner=brief.runner,
54
+ )
55
+
56
+
57
+ class Fix(Assignment):
58
+ """Contestant: resolve the issue, and prove it with a test."""
59
+
60
+ name = "fix"
61
+ role = "contestant"
62
+ test_file = "test_issue_regression.py"
63
+
64
+ @property
65
+ def template(self) -> str:
66
+ return """\
67
+ You are resolving an issue in the {repo} repository. Your working directory is a checkout of \
68
+ the repository at a commit where the issue below is still open. There is no git history.
69
+
70
+ <issue>
71
+ # {title}
72
+
73
+ {statement}
74
+ </issue>
75
+
76
+ Do the following:
77
+ 1. Fix the issue by editing the library's source code (under `{source_root}/`).
78
+ 2. Write a regression test for this issue in a NEW file, `{test_root}/{test_file}`. \
79
+ It must fail on the code as it is now and pass once your fix is in place. Do not modify or \
80
+ delete existing tests.
81
+ 3. Run the tests to check your work with this exact command, which points Python at this \
82
+ checkout:
83
+ {runner} {test_root}/{test_file}
84
+ Pass any other test file, or a -k expression, the same way.
85
+
86
+ Work only inside this directory, and do not download the project or look it up online. When \
87
+ you are finished, reply with a short summary of what you changed and why.
88
+ """
89
+
90
+
91
+ class Reproduce(Assignment):
92
+ """Examiner: write failing tests that capture the issue, and fix nothing."""
93
+
94
+ name = "reproduce"
95
+ role = "examiner"
96
+ test_file = "test_issue_reproduction.py"
97
+
98
+ @property
99
+ def template(self) -> str:
100
+ return """\
101
+ You are writing the regression test for an issue in the {repo} repository, BEFORE anyone has \
102
+ fixed it. Your working directory is a checkout of the repository at a commit where the issue \
103
+ below is still open. There is no git history.
104
+
105
+ <issue>
106
+ # {title}
107
+
108
+ {statement}
109
+ </issue>
110
+
111
+ Your job is to capture the issue as a test - not to fix it:
112
+ 1. Do NOT modify the library's source code (under `{source_root}/`) or any existing test.
113
+ 2. Write pytest tests in a NEW file, `{test_root}/{test_file}`, that exercise exactly the \
114
+ behaviour the issue asks for, through the library's public API. Cover each concrete case the \
115
+ issue mentions or clearly implies. Do not test anything the issue does not ask to change.
116
+ 3. Run them with this exact command, which points Python at this checkout:
117
+ {runner} {test_root}/{test_file}
118
+ They MUST fail on the current code, and each failure must be caused by the bug the issue \
119
+ describes - not by a typo, a wrong import, a misremembered API or a mistaken expectation. Read \
120
+ the source until you are sure what correct behaviour is, then make each test assert exactly that.
121
+
122
+ Work only inside this directory, and do not download the project or look it up online. When \
123
+ you are finished, reply with a short summary of what each test checks.
124
+ """
125
+
126
+
127
+ class Specify(Assignment):
128
+ """Examiner: write the tests a maintainer would demand of any fix - and fix nothing.
129
+
130
+ A reproduction checks the case the issue reports, and a partial fix passes
131
+ it. This brief asks for the general rule behind the issue, related inputs a
132
+ complete fix must handle, and behaviour a careless fix might break.
133
+ """
134
+
135
+ name = "specify"
136
+ role = "examiner"
137
+ test_file = "test_issue_specification.py"
138
+
139
+ @property
140
+ def template(self) -> str:
141
+ return """\
142
+ You are the maintainer of the {repo} repository who will review fixes for the issue below. \
143
+ Before anyone has fixed it, write the regression tests you would require a correct fix to pass. \
144
+ Your working directory is a checkout at a commit where the issue is still open. There is no git \
145
+ history.
146
+
147
+ <issue>
148
+ # {title}
149
+
150
+ {statement}
151
+ </issue>
152
+
153
+ 1. Do NOT modify the library's source code (under `{source_root}/`) or any existing test.
154
+ 2. Read the relevant source and the existing tests until you understand the behaviour the \
155
+ library intends. Work out the general rule the issue implies - not just its literal example.
156
+ 3. Write pytest tests in a NEW file, `{test_root}/{test_file}`, in three groups:
157
+ a. the case the issue reports;
158
+ b. the same rule on closely related inputs a complete fix must also handle - other types \
159
+ or values the same code path accepts, boundaries, the sibling branch of the same logic;
160
+ c. nearby behaviour the issue does not ask to change, which a careless fix might break.
161
+ 4. Run them with this exact command, which points Python at this checkout:
162
+ {runner} {test_root}/{test_file}
163
+ Every test in groups a and b MUST fail on the current code, because of the bug. If a related \
164
+ input already works, it is not part of the bug: move it to group c or drop it. Every test in \
165
+ group c MUST pass on the current code. Each failure must come from the bug - never from a typo, \
166
+ a wrong import, a misremembered API or a mistaken expectation. Check each expectation against \
167
+ the source and the existing tests before you keep it.
168
+
169
+ Work only inside this directory, and do not download the project or look it up online. When \
170
+ you are finished, reply with a short summary of the rule you tested and why.
171
+ """
172
+
173
+
174
+ ASSIGNMENTS: dict[str, Assignment] = {a.name: a for a in (Fix(), Reproduce(), Specify())}
175
+ FIX = ASSIGNMENTS["fix"]
176
+ EXAMINER_ASSIGNMENTS = tuple(a.name for a in ASSIGNMENTS.values() if a.role == "examiner")
@@ -0,0 +1,78 @@
1
+ """Exhibits: test files offered as evidence about a task.
2
+
3
+ Two kinds of author write them. An **examiner** wrote its tests from the issue
4
+ alone and never saw a patch. A **contestant** wrote a regression test for its
5
+ own fix; run against everyone else's fix, that test is evidence too.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import asdict, dataclass
11
+ from typing import Any, Literal, get_args
12
+
13
+ from bugbout.arena.briefs import ASSIGNMENTS
14
+ from bugbout.arena.race import Attempt
15
+ from bugbout.tasks.task import is_test_module
16
+
17
+ Kind = Literal["examiner", "contestant"]
18
+ KINDS: frozenset[Kind] = frozenset(get_args(Kind))
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class Exhibit:
23
+ task_id: str
24
+ author: str
25
+ vendor: str
26
+ kind: Kind
27
+ test_code: str
28
+ note: str = ""
29
+ """Anything the author did that it was not asked to, such as editing the source."""
30
+
31
+ error: str = ""
32
+ """Why there is no test, when there is none."""
33
+
34
+ @property
35
+ def usable(self) -> bool:
36
+ return bool(self.test_code.strip())
37
+
38
+ def to_dict(self) -> dict[str, Any]:
39
+ return asdict(self)
40
+
41
+ @classmethod
42
+ def from_dict(cls, data: dict[str, Any]) -> Exhibit:
43
+ if data.get("kind") not in KINDS:
44
+ raise ValueError(f"unknown exhibit kind {data.get('kind')!r}")
45
+ return cls(**{k: v for k, v in data.items() if k in set(cls.__slots__)})
46
+
47
+
48
+ def author_of(attempt: Attempt) -> str:
49
+ """Who an attempt's tests are attributed to: a contestant by name, an examiner by role too."""
50
+ if ASSIGNMENTS[attempt.assignment].role == "contestant":
51
+ return attempt.agent
52
+ return f"{attempt.assignment}/{attempt.agent}"
53
+
54
+
55
+ def exhibit_of(attempt: Attempt, test_root: str) -> Exhibit:
56
+ """The test file an attempt created - the one its brief asked for, if it wrote that one."""
57
+ assignment = ASSIGNMENTS[attempt.assignment]
58
+ kind: Kind = "contestant" if assignment.role == "contestant" else "examiner"
59
+ root = test_root.strip("/")
60
+ created = attempt.patch.only(under=root, keep=True).created()
61
+ wanted = f"{root}/{assignment.test_file}"
62
+ written = (
63
+ wanted if wanted in created else next((p for p in sorted(created) if is_test_module(p)), "")
64
+ )
65
+ notes = []
66
+ if written and written != wanted:
67
+ notes.append(f"tests written to {written} instead of {wanted}")
68
+ if kind == "examiner" and attempt.patch.only(under=root, keep=False).files:
69
+ notes.append("edited the source, which was discarded")
70
+ return Exhibit(
71
+ task_id=attempt.task_id,
72
+ author=author_of(attempt),
73
+ vendor=attempt.vendor,
74
+ kind=kind,
75
+ test_code=created.get(written, ""),
76
+ note="; ".join(notes),
77
+ error="" if written else f"no test written ({attempt.status})",
78
+ )