Coverage for src/domain/validation/helpers.py: 18%

1"""Shared helper functions for validation runners.

2

3 These utilities are used by SpecValidationRunner and related modules.

4"""

5

6 from __future__ import annotations

7

8 import json

9 import shutil

10 from pathlib import Path

11 from typing import TYPE_CHECKING

12

13 if TYPE_CHECKING:

14 from src.core.protocols import CommandRunnerPort

15

16

17 def tail(text: str, max_chars: int = 800, max_lines: int = 20) -> str:

18 """Truncate text to last N lines and M characters.

19

20 Args:

21 text: The text to truncate.

22 max_chars: Maximum number of characters to keep.

23 max_lines: Maximum number of lines to keep.

24

25 Returns:

26 The truncated text.

27 """

28 if not text:

29 return ""

30 lines = text.splitlines()

31 if len(lines) > max_lines:

32 lines = lines[-max_lines:]

33 clipped = "\n".join(lines)

34 if len(clipped) > max_chars:

35 return clipped[-max_chars:]

36 return clipped

37

38

39 def decode_timeout_output(data: bytes | str | None) -> str:

40 """Decode TimeoutExpired stdout/stderr which may be bytes, str, or None.

41

42 Args:

43 data: The output data from TimeoutExpired exception.

44

45 Returns:

46 Decoded and truncated string.

47 """

48 if data is None:

49 return ""

50 if isinstance(data, str):

51 return tail(data)

52 return tail(data.decode())

53

54

55 def format_step_output(

56 stdout_tail: str,

57 stderr_tail: str,

58 max_chars: int = 300,

59 max_lines: int = 6,

60) -> str:

61 """Format step output for error messages.

62

63 Prefers stderr over stdout when both are available.

64

65 Args:

66 stdout_tail: Truncated stdout.

67 stderr_tail: Truncated stderr.

68 max_chars: Maximum characters for output.

69 max_lines: Maximum lines for output.

70

71 Returns:

72 Formatted output string.

73 """

74 parts = []

75 if stderr_tail:

76 parts.append(

77 f"stderr: {tail(stderr_tail, max_chars=max_chars, max_lines=max_lines)}"

78 )

79 if stdout_tail and not stderr_tail:

80 parts.append(

81 f"stdout: {tail(stdout_tail, max_chars=max_chars, max_lines=max_lines)}"

82 )

83 return " | ".join(parts)

84

85

86 def check_e2e_prereqs(env: dict[str, str]) -> str | None:

87 """Check prerequisites for E2E validation.

88

89 Args:

90 env: Environment variables (unused, kept for API compatibility).

91

92 Returns:

93 Error message if prereqs not met, None otherwise.

94 """

95 if not shutil.which("mala"):

96 return "E2E prereq missing: mala CLI not found in PATH"

97 if not shutil.which("bd"):

98 return "E2E prereq missing: bd CLI not found in PATH"

99 return None

100

101

102 def _generate_fixture_programmatically(repo_path: Path) -> None:

103 """Generate E2E fixture files programmatically.

104

105 This is a fallback when the fixture template directory is not available

106 (e.g., in installed packages where only src/ is included).

107

108 Args:

109 repo_path: Path to create the fixture repository in.

110 """

111 # Create src/app.py with a bug (subtracts instead of adds)

112 src_dir = repo_path / "src"

113 src_dir.mkdir(parents=True, exist_ok=True)

114 (src_dir / "app.py").write_text(

115 "def add(a: int, b: int) -> int:\n return a - b\n"

116 )

117

118 # Create tests/test_app.py

119 tests_dir = repo_path / "tests"

120 tests_dir.mkdir(parents=True, exist_ok=True)

121 (tests_dir / "test_app.py").write_text(

122 """import pytest

123

124 from app import add

125

126

127@pytest.mark.unit

128 def test_add():

129 assert add(2, 2) == 4

130"""

131 )

132

133 # Create pyproject.toml

134 (repo_path / "pyproject.toml").write_text(

135 """[project]

136 name = "mala-e2e-fixture"

137 version = "0.0.0"

138 description = "Fixture repo for mala e2e validation"

139 requires-python = ">=3.11"

140 dependencies = []

141

142[project.optional-dependencies]

143 dev = ["pytest>=8.0.0", "pytest-cov>=4.1.0", "pytest-xdist>=3.8.0"]

144

145[tool.pytest.ini_options]

146 pythonpath = ["src"]

147 markers = [

148 "unit: fast, isolated tests (default)",

149 "integration: tests that exercise multiple components",

150]

151

152[dependency-groups]

153 dev = [

154 "pytest>=9.0.2",

155 "pytest-cov>=6.0.0",

156 "pytest-xdist>=3.8.0",

157]

158"""

159 )

160

161 # Create mala.yaml

162 (repo_path / "mala.yaml").write_text(

163 """preset: python-uv

164

165 run_level_commands:

166 test: "uv run pytest --cov=src --cov-report=xml:coverage.xml --cov-fail-under=0 -o cache_dir=/tmp/pytest-${AGENT_ID:-default}"

167

168 coverage:

169 format: xml

170 file: coverage.xml

171 threshold: 0

172"""

173 )

174

175

176 def write_fixture_repo(repo_path: Path) -> None:

177 """Create a minimal fixture repository for E2E testing.

178

179 Creates a simple Python project with a failing test that the

180 implementer agent needs to fix. Uses src/ layout for compatibility

181 with mala's coverage checking (--cov=src).

182

183 Fixture files are sourced from tests/fixtures/e2e-fixture if available,

184 otherwise generated programmatically (for installed packages).

185

186 Args:

187 repo_path: Path to create the fixture repository in.

188 """

189 fixture_root = (

190 Path(__file__).resolve().parents[3] / "tests" / "fixtures" / "e2e-fixture"

191 )

192 if fixture_root.exists():

193 shutil.copytree(fixture_root, repo_path, dirs_exist_ok=True)

194 else:

195 # Fallback to programmatic generation when fixture template is not available

196 # (e.g., in installed packages where only src/ is included in the wheel)

197 _generate_fixture_programmatically(repo_path)

198

199

200 def init_fixture_repo(

201 repo_path: Path,

202 command_runner: CommandRunnerPort,

203) -> str | None:

204 """Initialize a fixture repository with git and beads.

205

206 Args:

207 repo_path: Path to the fixture repository.

208 command_runner: CommandRunnerPort for running commands.

209

210 Returns:

211 Error message if initialization failed, None on success.

212 """

213 runner = command_runner

214

215 for cmd in (

216 ["git", "init"],

217 ["git", "config", "user.email", "mala-e2e@example.com"],

218 ["git", "config", "user.name", "Mala E2E"],

219 ["git", "add", "."],

220 ["git", "commit", "-m", "initial"],

221 ["bd", "init"],

222 ["bd", "create", "Fix failing add() test", "-p", "1"],

223 ):

224 result = runner.run(cmd, cwd=repo_path)

225 if not result.ok:

226 stderr = result.stderr.strip()

227 reason = (

228 f"E2E fixture setup failed: {' '.join(cmd)} (exit {result.returncode})"

229 )

230 if stderr:

231 reason = f"{reason}: {tail(stderr)}"

232 return reason

233 return None

234

235

236 def get_ready_issue_id(

237 repo_path: Path,

238 command_runner: CommandRunnerPort,

239) -> str | None:

240 """Get the first ready issue ID from a repository.

241

242 Args:

243 repo_path: Path to the repository.

244 command_runner: CommandRunnerPort for running commands.

245

246 Returns:

247 Issue ID if found, None otherwise.

248 """

249 runner = command_runner

250

251 result = runner.run(["bd", "ready", "--json"], cwd=repo_path)

252 if not result.ok:

253 return None

254 try:

255 issues = json.loads(result.stdout)

256 except json.JSONDecodeError:

257 return None

258 for issue in issues:

259 issue_id = issue.get("id")

260 if isinstance(issue_id, str):

261 return issue_id

262 return None

263

264

265 def annotate_issue(

266 repo_path: Path,

267 issue_id: str,

268 command_runner: CommandRunnerPort,

269) -> None:

270 """Add test plan notes to an issue.

271

272 Args:

273 repo_path: Path to the repository.

274 issue_id: Issue ID to annotate.

275 command_runner: CommandRunnerPort for running commands.

276 """

277 runner = command_runner

278

279 notes = "\n".join(

280 [

281 "Context:",

282 "- Tests are failing for add() in app.py",

283 "",

284 "Acceptance Criteria:",

285 "- Fix add() so tests pass",

286 "- Run full validation suite",

287 "",

288 "Test Plan:",

289 "- uv run pytest",

290 ]

291 )

292 runner.run(["bd", "update", issue_id, "--notes", notes], cwd=repo_path)