misterdev 0.2.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 (136) hide show
  1. misterdev/__init__.py +3 -0
  2. misterdev/agent.py +2166 -0
  3. misterdev/agent_helpers.py +194 -0
  4. misterdev/analyzers/__init__.py +0 -0
  5. misterdev/analyzers/project_analyzer/__init__.py +246 -0
  6. misterdev/analyzers/project_analyzer/detection.py +146 -0
  7. misterdev/analyzers/project_analyzer/merge.py +137 -0
  8. misterdev/analyzers/project_analyzer/overview.py +312 -0
  9. misterdev/analyzers/project_analyzer/prompts.py +83 -0
  10. misterdev/cli.py +370 -0
  11. misterdev/config.py +521 -0
  12. misterdev/core/__init__.py +0 -0
  13. misterdev/core/audit.py +88 -0
  14. misterdev/core/config.py +10 -0
  15. misterdev/core/context/__init__.py +0 -0
  16. misterdev/core/context/change_tracker.py +218 -0
  17. misterdev/core/context/contracts/__init__.py +63 -0
  18. misterdev/core/context/contracts/_log.py +9 -0
  19. misterdev/core/context/contracts/_text.py +12 -0
  20. misterdev/core/context/contracts/extraction.py +30 -0
  21. misterdev/core/context/contracts/python_generic.py +42 -0
  22. misterdev/core/context/contracts/registry.py +127 -0
  23. misterdev/core/context/contracts/rust_line.py +225 -0
  24. misterdev/core/context/contracts/rust_tree_sitter.py +141 -0
  25. misterdev/core/context/lsp.py +174 -0
  26. misterdev/core/context/scratchpad.py +111 -0
  27. misterdev/core/context/topography/__init__.py +43 -0
  28. misterdev/core/context/topography/_log.py +10 -0
  29. misterdev/core/context/topography/cache.py +91 -0
  30. misterdev/core/context/topography/engine.py +172 -0
  31. misterdev/core/context/topography/graph.py +675 -0
  32. misterdev/core/context/topography/nodes.py +55 -0
  33. misterdev/core/context/topography/parsers.py +95 -0
  34. misterdev/core/context/topography/syntax.py +54 -0
  35. misterdev/core/economics/__init__.py +0 -0
  36. misterdev/core/economics/context_budget.py +175 -0
  37. misterdev/core/economics/embeddings.py +232 -0
  38. misterdev/core/economics/free_models.py +108 -0
  39. misterdev/core/economics/llm_cache.py +105 -0
  40. misterdev/core/economics/model_catalog.py +79 -0
  41. misterdev/core/economics/model_ledger.py +331 -0
  42. misterdev/core/economics/model_selector.py +281 -0
  43. misterdev/core/execution/__init__.py +0 -0
  44. misterdev/core/execution/bounded.py +50 -0
  45. misterdev/core/execution/container.py +221 -0
  46. misterdev/core/execution/error_classifier.py +366 -0
  47. misterdev/core/execution/error_resolver.py +201 -0
  48. misterdev/core/execution/governance.py +283 -0
  49. misterdev/core/execution/outcomes.py +50 -0
  50. misterdev/core/execution/progress.py +120 -0
  51. misterdev/core/execution/project.py +231 -0
  52. misterdev/core/execution/registry.py +97 -0
  53. misterdev/core/execution/runtime.py +279 -0
  54. misterdev/core/gitcmd.py +39 -0
  55. misterdev/core/integration/__init__.py +0 -0
  56. misterdev/core/integration/mcp.py +368 -0
  57. misterdev/core/integration/mcp_gather.py +186 -0
  58. misterdev/core/models.py +35 -0
  59. misterdev/core/modes.py +184 -0
  60. misterdev/core/planning/__init__.py +0 -0
  61. misterdev/core/planning/advisor.py +89 -0
  62. misterdev/core/planning/assessment.py +135 -0
  63. misterdev/core/planning/decomposer.py +387 -0
  64. misterdev/core/planning/metacognition.py +103 -0
  65. misterdev/core/planning/sovereign.py +308 -0
  66. misterdev/core/planning/targets.py +201 -0
  67. misterdev/core/reporting/__init__.py +0 -0
  68. misterdev/core/reporting/report.py +377 -0
  69. misterdev/core/reporting/report_view.py +151 -0
  70. misterdev/core/task.py +163 -0
  71. misterdev/core/verification/__init__.py +0 -0
  72. misterdev/core/verification/claim_verifier.py +210 -0
  73. misterdev/core/verification/critic.py +324 -0
  74. misterdev/core/verification/gatekeeper/__init__.py +631 -0
  75. misterdev/core/verification/gatekeeper/constants.py +138 -0
  76. misterdev/core/verification/gatekeeper/helpers.py +28 -0
  77. misterdev/core/verification/goal_check.py +219 -0
  78. misterdev/core/verification/independent.py +68 -0
  79. misterdev/core/verification/mutation_gate.py +221 -0
  80. misterdev/core/verification/preflight.py +95 -0
  81. misterdev/core/verification/spec_tests.py +175 -0
  82. misterdev/core/verification/validator.py +495 -0
  83. misterdev/core/verification/vision_verify.py +185 -0
  84. misterdev/core/verification/web_verify.py +408 -0
  85. misterdev/environments/__init__.py +0 -0
  86. misterdev/environments/base_env.py +18 -0
  87. misterdev/environments/container_env.py +87 -0
  88. misterdev/environments/venv_env.py +42 -0
  89. misterdev/llm/__init__.py +0 -0
  90. misterdev/llm/client/__init__.py +152 -0
  91. misterdev/llm/client/base.py +382 -0
  92. misterdev/llm/client/edits.py +70 -0
  93. misterdev/llm/client/embeddings.py +121 -0
  94. misterdev/llm/client/errors.py +134 -0
  95. misterdev/llm/client/providers.py +535 -0
  96. misterdev/llm/client/response.py +24 -0
  97. misterdev/llm/prompt_manager.py +82 -0
  98. misterdev/llm/responses/__init__.py +34 -0
  99. misterdev/llm/responses/apply.py +131 -0
  100. misterdev/llm/responses/json_extract.py +80 -0
  101. misterdev/llm/responses/models.py +43 -0
  102. misterdev/llm/responses/parsing.py +494 -0
  103. misterdev/logging_setup.py +20 -0
  104. misterdev/mcp_server.py +208 -0
  105. misterdev/nl_cli.py +149 -0
  106. misterdev/plugins.py +115 -0
  107. misterdev/py.typed +0 -0
  108. misterdev/task_executors/__init__.py +0 -0
  109. misterdev/task_executors/base_executor.py +10 -0
  110. misterdev/task_executors/markdown_plan_executor/__init__.py +90 -0
  111. misterdev/task_executors/markdown_plan_executor/commands_mixin.py +82 -0
  112. misterdev/task_executors/markdown_plan_executor/context_mixin.py +221 -0
  113. misterdev/task_executors/markdown_plan_executor/critic_spec_mixin.py +174 -0
  114. misterdev/task_executors/markdown_plan_executor/edits_mixin.py +251 -0
  115. misterdev/task_executors/markdown_plan_executor/execute_mixin.py +727 -0
  116. misterdev/task_executors/markdown_plan_executor/gates_mixin.py +203 -0
  117. misterdev/task_executors/markdown_plan_executor/git_mixin.py +219 -0
  118. misterdev/task_executors/markdown_plan_executor/helpers.py +521 -0
  119. misterdev/task_executors/markdown_plan_executor/llm_mixin.py +238 -0
  120. misterdev/task_executors/markdown_plan_executor/results_mixin.py +23 -0
  121. misterdev/tools/__init__.py +19 -0
  122. misterdev/tools/base_tool.py +14 -0
  123. misterdev/tools/command.py +75 -0
  124. misterdev/tools/file_io.py +69 -0
  125. misterdev/tools/formatter.py +26 -0
  126. misterdev/tools/git_tool.py +90 -0
  127. misterdev/utils/__init__.py +0 -0
  128. misterdev/utils/file_utils.py +169 -0
  129. misterdev/utils/process.py +23 -0
  130. misterdev-0.2.0.dist-info/METADATA +326 -0
  131. misterdev-0.2.0.dist-info/RECORD +136 -0
  132. misterdev-0.2.0.dist-info/WHEEL +5 -0
  133. misterdev-0.2.0.dist-info/entry_points.txt +3 -0
  134. misterdev-0.2.0.dist-info/licenses/COMMERCIAL_LICENSE.md +34 -0
  135. misterdev-0.2.0.dist-info/licenses/LICENSE +661 -0
  136. misterdev-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,521 @@
1
+ """Module-level helpers and constants for the markdown plan executor.
2
+
3
+ Moved verbatim out of the original ``markdown_plan_executor.py`` so the
4
+ executor mixins and the package ``__init__`` can share them. Pure code
5
+ movement: behaviour is identical.
6
+ """
7
+
8
+ import ast
9
+ import re
10
+ from collections import Counter
11
+ from pathlib import Path
12
+ from typing import List, Optional, Tuple
13
+
14
+ from misterdev.logging_setup import setup_logger
15
+ from misterdev.utils.file_utils import is_golden_path
16
+
17
+ # Historical private alias re-exported from this module's namespace; the
18
+ # executor and tests import ``_is_golden_path`` from here.
19
+ _is_golden_path = is_golden_path
20
+
21
+ # ``__package__`` of this submodule is the package path that the original
22
+ # single-file module had as ``__name__``, so the logger name is unchanged.
23
+ logger = setup_logger(__package__)
24
+ # Appended to every code-generation prompt. The model edits large files by
25
+ # emitting only the changed regions as anchored SEARCH/REPLACE hunks instead of
26
+ # rewriting the whole file (which truncates past the output-token limit). The
27
+ # parser (responses.parse_search_replace_blocks) recognizes exactly this shape;
28
+ # whole-file blocks remain valid for short new files with no markers.
29
+ EDIT_FORMAT_INSTRUCTIONS = """
30
+
31
+ ## Output format (required)
32
+ Edit existing files with anchored SEARCH/REPLACE blocks. Do NOT reprint the
33
+ whole file. For each change, put the file path on the fence line, then one or
34
+ more blocks:
35
+
36
+ ```<lang>:<path>
37
+ <<<<<<< SEARCH
38
+ <exact lines copied verbatim from the current file, whitespace included>
39
+ =======
40
+ <the replacement lines>
41
+ >>>>>>> REPLACE
42
+ ```
43
+
44
+ Rules:
45
+ - The SEARCH text must match the current file exactly and identify exactly one
46
+ location; include enough surrounding lines to make it unique.
47
+ - COPY the SEARCH lines verbatim from the file shown in Code Context — do not
48
+ retype from memory, reformat, or guess; a single wrong character fails the edit.
49
+ - Use several small blocks rather than one large one.
50
+ - To ADD code to an existing file (a new function, import, or export), anchor on
51
+ a real adjacent line you can see in the file — e.g. an existing function near
52
+ where the new code belongs — and put that anchor verbatim in BOTH SEARCH and
53
+ REPLACE, with your new code added in REPLACE. Never anchor on a line you have
54
+ not seen in the file.
55
+ - To create a NEW file, use a single block with an empty SEARCH section and the
56
+ full file contents in the REPLACE section.
57
+ """
58
+
59
+ # Used after anchored SEARCH/REPLACE edits repeatedly fail to APPLY (the model's
60
+ # SEARCH text doesn't match the file). A full-file rewrite needs no anchoring, so
61
+ # it always applies — converting a no-progress stall into an applied edit the
62
+ # real gates can then give feedback on.
63
+ FULL_FILE_FALLBACK_INSTRUCTIONS = """
64
+
65
+ ## Output format (required)
66
+ Your anchored SEARCH/REPLACE edits failed to apply because the SEARCH text did
67
+ not match the file. STOP using SEARCH/REPLACE. Instead output the COMPLETE,
68
+ updated content of each file you change, as a single fenced code block per file
69
+ with the file path on the fence line:
70
+
71
+ ```<lang>:<path>
72
+ <the entire file, from the first line to the last, with your changes integrated>
73
+ ```
74
+
75
+ Reproduce the whole file verbatim except for your intended change. Do not omit,
76
+ summarize, or elide any existing code.
77
+ """
78
+
79
+ # Maps file extensions to language identifiers for syntax validation and
80
+ # contract extraction. Unknown extensions fall back to "text".
81
+ _LANG_MAP = {
82
+ ".py": "python",
83
+ ".pyi": "python",
84
+ ".js": "javascript",
85
+ ".jsx": "javascript",
86
+ ".ts": "typescript",
87
+ ".tsx": "typescript",
88
+ ".rs": "rust",
89
+ ".go": "go",
90
+ ".java": "java",
91
+ ".c": "c",
92
+ ".h": "c",
93
+ ".cpp": "cpp",
94
+ ".hpp": "cpp",
95
+ ".cc": "cpp",
96
+ ".cxx": "cpp",
97
+ ".hh": "cpp",
98
+ ".cs": "csharp",
99
+ ".swift": "swift",
100
+ ".kt": "kotlin",
101
+ ".rb": "ruby",
102
+ ".php": "php",
103
+ ".sh": "shell",
104
+ ".bash": "shell",
105
+ }
106
+
107
+
108
+ def _relevant_line_ranges(symbols, task, n_lines: int):
109
+ """Line ranges to show in full for a large target file.
110
+
111
+ Picks symbols whose name appears in the task text, pads each by a few
112
+ lines, and always includes the file head (imports/uses). Returns merged
113
+ 0-based inclusive ranges, or None when nothing matched so the caller can
114
+ fall back to sending the whole file.
115
+ """
116
+ head_lines = 30
117
+ margin = 3
118
+ text = ""
119
+ if task is not None:
120
+ text = f"{task.description or ''} {task.acceptance_criteria or ''}"
121
+ # Whole-token match (case-insensitive): a symbol is relevant only when its
122
+ # name appears as a word in the task, not as a substring — otherwise short
123
+ # names like "Loc" match inside unrelated words and pull in the whole file.
124
+ tokens = {t.lower() for t in re.findall(r"[A-Za-z_][A-Za-z0-9_]*", text)}
125
+ relevant = [
126
+ s for s in symbols if s.name and len(s.name) >= 3 and s.name.lower() in tokens
127
+ ]
128
+ if not relevant:
129
+ return None
130
+ ranges = [
131
+ (max(0, s.start_line - margin), min(n_lines - 1, s.end_line + margin))
132
+ for s in relevant
133
+ ]
134
+ ranges.append((0, min(head_lines - 1, n_lines - 1)))
135
+ return _merge_ranges(ranges)
136
+
137
+
138
+ def _merge_ranges(ranges):
139
+ """Merge overlapping/adjacent (start, end) inclusive ranges, sorted."""
140
+ merged = []
141
+ for start, end in sorted(ranges):
142
+ if merged and start <= merged[-1][1] + 1:
143
+ merged[-1] = (merged[-1][0], max(merged[-1][1], end))
144
+ else:
145
+ merged.append((start, end))
146
+ return merged
147
+
148
+
149
+ def _window_lines(lines: List[str], ranges) -> str:
150
+ """Render only ``ranges`` of ``lines`` verbatim, with elision markers between.
151
+
152
+ Kept spans are emitted unchanged so SEARCH/REPLACE can anchor against them;
153
+ gaps become a marker that points back to the outline.
154
+ """
155
+ out = []
156
+ prev_end = -1
157
+ for start, end in ranges:
158
+ if start > prev_end + 1:
159
+ gap = start - (prev_end + 1)
160
+ out.append(
161
+ f"... [{gap} lines elided: L{prev_end + 2}-L{start} — see outline] ..."
162
+ )
163
+ out.extend(lines[start : end + 1])
164
+ prev_end = end
165
+ if prev_end < len(lines) - 1:
166
+ gap = len(lines) - 1 - prev_end
167
+ out.append(
168
+ f"... [{gap} lines elided: L{prev_end + 2}-L{len(lines)} — see outline] ..."
169
+ )
170
+ return "\n".join(out)
171
+
172
+
173
+ def _bisect_first_failing(n: int, passes_at) -> int:
174
+ """Binary-search [0, n) for the first index where passes_at(i) is False.
175
+
176
+ Assumes a monotonic pass->fail boundary (all-pass prefix, then failures).
177
+ Returns n-1 if nothing fails; callers should re-check that index.
178
+ """
179
+ lo, hi = 0, n - 1
180
+ while lo < hi:
181
+ mid = (lo + hi) // 2
182
+ if passes_at(mid):
183
+ lo = mid + 1
184
+ else:
185
+ hi = mid
186
+ return lo
187
+
188
+
189
+ # finish_reason values that mean the model hit its output-token limit and the
190
+ # response is incomplete. Anthropic reports "max_tokens"; OpenAI-compatible
191
+ # providers report "length". Compared case-insensitively.
192
+ _TRUNCATED_FINISH_REASONS = frozenset({"length", "max_tokens"})
193
+
194
+
195
+ def _is_truncated(finish_reason: Optional[str]) -> bool:
196
+ """True when ``finish_reason`` indicates the model ran out of output tokens."""
197
+ return bool(finish_reason) and finish_reason.lower() in _TRUNCATED_FINISH_REASONS
198
+
199
+
200
+ def _detect_language(file_path: str) -> str:
201
+ """Detect a source language from a file path's extension.
202
+
203
+ Returns "text" for extensions with no known language mapping so callers
204
+ fall back to language-agnostic validation rather than guessing.
205
+ """
206
+ ext = Path(file_path).suffix.lower()
207
+ return _LANG_MAP.get(ext, "text")
208
+
209
+
210
+ # Path patterns that mark a file as holding tests, covering the languages in
211
+ # _LANG_MAP. Used to decide which edits get the tamper-resistance check.
212
+ _TEST_FILE_PATTERNS = (
213
+ re.compile(r"(^|/)test_[^/]+\.py$"),
214
+ re.compile(r"_test\.py$"),
215
+ re.compile(r"(^|/)conftest\.py$"),
216
+ re.compile(r"\.test\.(js|jsx|ts|tsx)$"),
217
+ re.compile(r"\.spec\.(js|jsx|ts|tsx)$"),
218
+ re.compile(r"_test\.go$"),
219
+ re.compile(r"_test\.(rs|rb)$"),
220
+ re.compile(r"(^|/)test_[^/]+\.(rb|c|cpp|cc)$"),
221
+ re.compile(r"Test[^/]*\.java$"),
222
+ # Swift XCTest, Kotlin/JUnit, C# (xUnit/NUnit/MSTest) test files.
223
+ re.compile(r"Tests?\.(swift|kt|cs)$"),
224
+ re.compile(r"(^|/)[Tt]ests?/"),
225
+ )
226
+
227
+ # Skip/ignore markers per language. Their COUNT must not grow across an edit:
228
+ # more skips is the cheapest way to make a suite "pass" by not running it.
229
+ _SKIP_PATTERNS = (
230
+ re.compile(
231
+ r"@(?:pytest\.mark\.skip|pytest\.mark\.skipif|unittest\.skip"
232
+ r"|unittest\.SkipTest|skip|skipif)\b"
233
+ ),
234
+ re.compile(r"\b(?:it|describe|test|context)\.skip\b"),
235
+ re.compile(r"\bxit\b|\bxdescribe\b"),
236
+ re.compile(r"#\[ignore\b"),
237
+ re.compile(r"\bt\.Skip(?:Now)?\b|\bt\.SkipNow\b"),
238
+ re.compile(r"@(?:Ignore|Disabled)\b"),
239
+ re.compile(r"\.skip\s*\(|\.todo\s*\("),
240
+ # Swift XCTest skip; C# xUnit/NUnit/MSTest skip+ignore.
241
+ re.compile(r"\bXCTSkip\b|\btry\s+XCTSkip"),
242
+ re.compile(r"\[Ignore\b|\bSkip\s*=\s*[\"']"),
243
+ )
244
+
245
+ # Things that count as a "test" definition per language. We compare the total
246
+ # across all patterns rather than per-pattern so an edit can't dodge the check
247
+ # by converting one form of test to another.
248
+ _TEST_DEF_PATTERNS = (
249
+ re.compile(r"(?m)^\s*def\s+test\w*\s*\("),
250
+ re.compile(r"\b(?:it|test)\s*\(\s*['\"`]"),
251
+ re.compile(r"#\[test\]"),
252
+ re.compile(r"(?m)^\s*func\s+Test\w*\s*\("),
253
+ re.compile(r"@Test\b"),
254
+ # Swift XCTest methods (func testFoo) and C# attribute-based tests.
255
+ re.compile(r"(?m)^\s*func\s+test\w*\s*\("),
256
+ re.compile(r"\[(?:Fact|Theory|Test|TestMethod)\b"),
257
+ )
258
+
259
+ # Assertion-ish forms. Weakening a test often keeps the function but guts its
260
+ # checks, so a drop in assertion count is just as suspicious as a dropped test.
261
+ _ASSERT_PATTERNS = (
262
+ re.compile(r"(?m)^\s*assert\b"),
263
+ re.compile(r"\bself\.assert\w+\s*\("),
264
+ re.compile(r"\bexpect\s*\("),
265
+ re.compile(r"\bassert(?:_eq|_ne|_matches)?!\s*\("),
266
+ re.compile(r"\bpytest\.raises\b|\bassertRaises\b"),
267
+ )
268
+
269
+ # Trivially-true assertions: a cheap way to keep the assertion COUNT steady
270
+ # while removing the actual check ("assert True", "expect(true).toBe(true)").
271
+ # An increase in these across an edit is treated as weakening, cross-language.
272
+ _TAUTOLOGY_PATTERNS = (
273
+ re.compile(r"(?m)^\s*assert\s+(?:True|1)\s*(?:,|$)"),
274
+ re.compile(r"\bself\.assertTrue\s*\(\s*True\s*\)"),
275
+ re.compile(r"\bself\.assertFalse\s*\(\s*False\s*\)"),
276
+ re.compile(r"\bassert!\s*\(\s*true\s*\)"),
277
+ re.compile(r"\bexpect\s*\(\s*true\s*\)\s*\.\s*to(?:Be|Equal)\s*\(\s*true\s*\)"),
278
+ re.compile(r"\bassert\s+1\s*===?\s*1\b"),
279
+ )
280
+
281
+ # In-body skip calls (vs the decorator forms in _SKIP_PATTERNS): inserting one
282
+ # of these short-circuits a test at runtime while leaving it visibly defined.
283
+ _INBODY_SKIP_PATTERNS = (
284
+ re.compile(r"\bpytest\.skip\s*\("),
285
+ re.compile(r"\bself\.skipTest\s*\("),
286
+ re.compile(r"\bunittest\.SkipTest\b"),
287
+ re.compile(r"\bt\.Skip(?:Now)?\s*\("),
288
+ )
289
+
290
+ # Fraction of the build budget that must remain before the LLM acceptance judge
291
+ # (default-on) is allowed to spend; below it, free-text criteria pass for free.
292
+ JUDGE_MIN_BUDGET_FRACTION = 0.1
293
+
294
+
295
+ def _is_test_file(file_path: str) -> bool:
296
+ """True if the path names a test file in one of the supported languages."""
297
+ norm = file_path.replace("\\", "/")
298
+ return any(p.search(norm) for p in _TEST_FILE_PATTERNS)
299
+
300
+
301
+ # Known test/build runners that mark the START of a runnable acceptance command.
302
+ # We only treat acceptance_criteria as a command when one of these verbs appears,
303
+ # so free-text criteria ("the login form rejects empty passwords") are never
304
+ # mis-parsed into a command. Ordered/anchored so multi-word runners match before
305
+ # their first word (e.g. "python -m pytest" before bare "python").
306
+ _ACCEPTANCE_RUNNERS = (
307
+ # Allow an absolute/relative interpreter path (e.g. a venv's python) before
308
+ # "-m pytest", so a venv-pinned acceptance command is recognized too.
309
+ r"(?:[\w./-]*/)?python3?\s+-m\s+pytest",
310
+ r"pytest",
311
+ r"cargo\s+test",
312
+ r"cargo\s+build",
313
+ r"cargo\s+check",
314
+ r"cargo\s+clippy",
315
+ r"go\s+test",
316
+ r"go\s+build",
317
+ r"npm\s+test",
318
+ r"npm\s+run\s+\S+",
319
+ r"npx\s+\S+",
320
+ r"yarn\s+\S+",
321
+ r"pnpm\s+\S+",
322
+ r"make\s+\S+",
323
+ r"make",
324
+ r"ruff(?:\s+\S+)?",
325
+ r"mypy",
326
+ r"pyright",
327
+ r"tsc",
328
+ r"jest",
329
+ r"vitest",
330
+ r"tox",
331
+ r"phpunit",
332
+ r"rspec",
333
+ r"gradle\s+\S+",
334
+ r"\./gradlew\s+\S+",
335
+ r"mvn\s+\S+",
336
+ )
337
+
338
+ # A runnable command starts at a known runner and runs to the end of the line (or
339
+ # a sentence-terminating boundary). Anything before the runner (e.g. "Verify that
340
+ # ") is dropped. The trailing tail is trimmed by _extract_acceptance_command.
341
+ _ACCEPTANCE_COMMAND_RE = re.compile(
342
+ r"(?P<cmd>(?:" + "|".join(_ACCEPTANCE_RUNNERS) + r")[^\n]*)",
343
+ re.IGNORECASE,
344
+ )
345
+
346
+ # Trailing prose that commonly follows a quoted command and is not part of it,
347
+ # e.g. "pytest tests/test_auth.py passes". Stripped from the extracted command.
348
+ _ACCEPTANCE_TAIL_RE = re.compile(
349
+ r"\s+(?:passes?|succeeds?|should\s+pass|must\s+pass|exits?\s+0|"
350
+ r"returns?\s+0|is\s+green|all\s+green|cleanly|without\s+errors?)\b.*$",
351
+ re.IGNORECASE,
352
+ )
353
+
354
+
355
+ def _extract_acceptance_command(criteria: str) -> Optional[str]:
356
+ """Extract a single runnable command from an acceptance-criteria string.
357
+
358
+ Conservative: returns a command only when the text begins (after optional
359
+ lead-in prose) with a known test/build runner. Trailing prose like
360
+ "... passes" is trimmed so the runner sees just the command. Returns None
361
+ for free-text criteria so un-parseable sentences never fail a task.
362
+ """
363
+ if not criteria:
364
+ return None
365
+ # Prefer a command fenced in backticks if present, but still require it to
366
+ # start with a known runner so prose in backticks isn't run blindly.
367
+ for candidate in re.findall(r"`([^`]+)`", criteria):
368
+ m = _ACCEPTANCE_COMMAND_RE.match(candidate.strip())
369
+ if m:
370
+ return _ACCEPTANCE_TAIL_RE.sub("", m.group("cmd")).strip()
371
+ m = _ACCEPTANCE_COMMAND_RE.search(criteria)
372
+ if not m:
373
+ return None
374
+ cmd = m.group("cmd").strip()
375
+ # Cut at the first sentence boundary so a trailing English sentence on the
376
+ # same line doesn't get fed to the shell.
377
+ cmd = re.split(r"(?<=\S)[.;]\s+[A-Z]", cmd, maxsplit=1)[0].strip()
378
+ cmd = _ACCEPTANCE_TAIL_RE.sub("", cmd).strip()
379
+ return cmd or None
380
+
381
+
382
+ def _test_metrics(content: str) -> Tuple[int, int, int]:
383
+ """Cheap structural metrics for a test file: (tests, asserts, skips).
384
+
385
+ Deterministic regex counts only, no parsing. Used to compare a test file
386
+ before vs after an edit; growth is fine, shrinkage/more-skips is tamper.
387
+ """
388
+ tests = sum(len(p.findall(content)) for p in _TEST_DEF_PATTERNS)
389
+ asserts = sum(len(p.findall(content)) for p in _ASSERT_PATTERNS)
390
+ skips = sum(
391
+ len(p.findall(content)) for p in (*_SKIP_PATTERNS, *_INBODY_SKIP_PATTERNS)
392
+ )
393
+ return tests, asserts, skips
394
+
395
+
396
+ def _count_tautologies(content: str) -> int:
397
+ """Count trivially-true assertions (regex, cross-language)."""
398
+ return sum(len(p.findall(content)) for p in _TAUTOLOGY_PATTERNS)
399
+
400
+
401
+ def _diagnose_tampering(before: str, after: str) -> Optional[str]:
402
+ """Return a reason string if `after` weakens the test file, else None.
403
+
404
+ Tampering = fewer tests, fewer assertions, more skip markers (decorator or
405
+ in-body), or more trivially-true assertions. Pure additions (new
406
+ tests/assertions) are allowed and return None.
407
+ """
408
+ bt, ba, bs = _test_metrics(before)
409
+ at, aa, as_ = _test_metrics(after)
410
+ reasons = []
411
+ if at < bt:
412
+ reasons.append(f"test count dropped {bt}->{at}")
413
+ if aa < ba:
414
+ reasons.append(f"assertion count dropped {ba}->{aa}")
415
+ if as_ > bs:
416
+ reasons.append(f"skip/ignore markers increased {bs}->{as_}")
417
+ btaut, ataut = _count_tautologies(before), _count_tautologies(after)
418
+ if ataut > btaut:
419
+ reasons.append(f"trivially-true assertions increased {btaut}->{ataut}")
420
+ return "; ".join(reasons) if reasons else None
421
+
422
+
423
+ def _ast_call_name(func: ast.AST) -> Optional[str]:
424
+ """Best-effort dotted-call leaf name: ``self.assertEqual`` -> assertEqual."""
425
+ if isinstance(func, ast.Attribute):
426
+ return func.attr
427
+ if isinstance(func, ast.Name):
428
+ return func.id
429
+ return None
430
+
431
+
432
+ def _assertion_key(node: ast.AST) -> Optional[str]:
433
+ """A structure-based identity for one assertion, or None if it isn't one.
434
+
435
+ Keys on what the assertion checks (the AST of the condition / call args),
436
+ not on its position or enclosing function, so the same check has the same
437
+ key wherever it lives.
438
+ """
439
+ if isinstance(node, ast.Assert):
440
+ return "assert:" + ast.dump(node.test)
441
+ if isinstance(node, ast.Call):
442
+ name = _ast_call_name(node.func)
443
+ if name and (name.startswith("assert") or name in ("expect", "raises")):
444
+ args = ",".join(ast.dump(a) for a in node.args)
445
+ return f"{name}:{args}"
446
+ return None
447
+
448
+
449
+ def _py_assertion_multiset(content: str) -> Optional["Counter"]:
450
+ """Structural multiset of every assertion inside Python ``test*`` functions.
451
+
452
+ Because assertions are keyed by structure (see ``_assertion_key``) rather
453
+ than by their enclosing test's name or order, renaming, moving, reordering,
454
+ splitting, or merging tests leaves the multiset unchanged. Only an assertion
455
+ that disappears without an identical one reappearing is a real loss of
456
+ coverage. Returns None when the content does not parse.
457
+ """
458
+ try:
459
+ tree = ast.parse(content)
460
+ except SyntaxError:
461
+ return None
462
+ counts: Counter = Counter()
463
+ for node in ast.walk(tree):
464
+ if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
465
+ continue
466
+ if not node.name.startswith("test"):
467
+ continue
468
+ for sub in ast.walk(node):
469
+ key = _assertion_key(sub)
470
+ if key:
471
+ counts[key] += 1
472
+ return counts
473
+
474
+
475
+ def _parametrize_count(content: str) -> int:
476
+ """Count ``parametrize``-style decorators (a legit way to reshape asserts)."""
477
+ try:
478
+ tree = ast.parse(content)
479
+ except SyntaxError:
480
+ return 0
481
+ total = 0
482
+ for node in ast.walk(tree):
483
+ if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
484
+ continue
485
+ for dec in node.decorator_list:
486
+ target = dec.func if isinstance(dec, ast.Call) else dec
487
+ if _ast_call_name(target) == "parametrize":
488
+ total += 1
489
+ return total
490
+
491
+
492
+ def _py_skip_count(content: str) -> int:
493
+ return sum(
494
+ len(p.findall(content)) for p in (*_SKIP_PATTERNS, *_INBODY_SKIP_PATTERNS)
495
+ )
496
+
497
+
498
+ def _diagnose_py_tampering(before: str, after: str) -> Optional[str]:
499
+ """Python tamper diagnosis keyed on assertion survival, not test identity.
500
+
501
+ Flags only genuine weakening: assertions that vanish without an identical
502
+ check reappearing anywhere, an increase in trivially-true assertions, or
503
+ more skip markers. Renames, moves, reorders, splits, and merges all
504
+ preserve the assertion multiset and pass. Parametrization legitimately
505
+ reshapes assertions, so a net loss is not held against an edit that adds a
506
+ parametrize decorator. Returns None when either revision fails to parse.
507
+ """
508
+ before_asserts = _py_assertion_multiset(before)
509
+ after_asserts = _py_assertion_multiset(after)
510
+ if before_asserts is None or after_asserts is None:
511
+ return None
512
+ reasons = []
513
+ if _parametrize_count(after) <= _parametrize_count(before):
514
+ lost = sum((before_asserts - after_asserts).values())
515
+ if lost:
516
+ reasons.append(f"{lost} assertion(s) removed or weakened")
517
+ if _count_tautologies(after) > _count_tautologies(before):
518
+ reasons.append("trivially-true assertions increased")
519
+ if _py_skip_count(after) > _py_skip_count(before):
520
+ reasons.append("skip markers increased")
521
+ return "; ".join(reasons) if reasons else None