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.
- misterdev/__init__.py +3 -0
- misterdev/agent.py +2166 -0
- misterdev/agent_helpers.py +194 -0
- misterdev/analyzers/__init__.py +0 -0
- misterdev/analyzers/project_analyzer/__init__.py +246 -0
- misterdev/analyzers/project_analyzer/detection.py +146 -0
- misterdev/analyzers/project_analyzer/merge.py +137 -0
- misterdev/analyzers/project_analyzer/overview.py +312 -0
- misterdev/analyzers/project_analyzer/prompts.py +83 -0
- misterdev/cli.py +370 -0
- misterdev/config.py +521 -0
- misterdev/core/__init__.py +0 -0
- misterdev/core/audit.py +88 -0
- misterdev/core/config.py +10 -0
- misterdev/core/context/__init__.py +0 -0
- misterdev/core/context/change_tracker.py +218 -0
- misterdev/core/context/contracts/__init__.py +63 -0
- misterdev/core/context/contracts/_log.py +9 -0
- misterdev/core/context/contracts/_text.py +12 -0
- misterdev/core/context/contracts/extraction.py +30 -0
- misterdev/core/context/contracts/python_generic.py +42 -0
- misterdev/core/context/contracts/registry.py +127 -0
- misterdev/core/context/contracts/rust_line.py +225 -0
- misterdev/core/context/contracts/rust_tree_sitter.py +141 -0
- misterdev/core/context/lsp.py +174 -0
- misterdev/core/context/scratchpad.py +111 -0
- misterdev/core/context/topography/__init__.py +43 -0
- misterdev/core/context/topography/_log.py +10 -0
- misterdev/core/context/topography/cache.py +91 -0
- misterdev/core/context/topography/engine.py +172 -0
- misterdev/core/context/topography/graph.py +675 -0
- misterdev/core/context/topography/nodes.py +55 -0
- misterdev/core/context/topography/parsers.py +95 -0
- misterdev/core/context/topography/syntax.py +54 -0
- misterdev/core/economics/__init__.py +0 -0
- misterdev/core/economics/context_budget.py +175 -0
- misterdev/core/economics/embeddings.py +232 -0
- misterdev/core/economics/free_models.py +108 -0
- misterdev/core/economics/llm_cache.py +105 -0
- misterdev/core/economics/model_catalog.py +79 -0
- misterdev/core/economics/model_ledger.py +331 -0
- misterdev/core/economics/model_selector.py +281 -0
- misterdev/core/execution/__init__.py +0 -0
- misterdev/core/execution/bounded.py +50 -0
- misterdev/core/execution/container.py +221 -0
- misterdev/core/execution/error_classifier.py +366 -0
- misterdev/core/execution/error_resolver.py +201 -0
- misterdev/core/execution/governance.py +283 -0
- misterdev/core/execution/outcomes.py +50 -0
- misterdev/core/execution/progress.py +120 -0
- misterdev/core/execution/project.py +231 -0
- misterdev/core/execution/registry.py +97 -0
- misterdev/core/execution/runtime.py +279 -0
- misterdev/core/gitcmd.py +39 -0
- misterdev/core/integration/__init__.py +0 -0
- misterdev/core/integration/mcp.py +368 -0
- misterdev/core/integration/mcp_gather.py +186 -0
- misterdev/core/models.py +35 -0
- misterdev/core/modes.py +184 -0
- misterdev/core/planning/__init__.py +0 -0
- misterdev/core/planning/advisor.py +89 -0
- misterdev/core/planning/assessment.py +135 -0
- misterdev/core/planning/decomposer.py +387 -0
- misterdev/core/planning/metacognition.py +103 -0
- misterdev/core/planning/sovereign.py +308 -0
- misterdev/core/planning/targets.py +201 -0
- misterdev/core/reporting/__init__.py +0 -0
- misterdev/core/reporting/report.py +377 -0
- misterdev/core/reporting/report_view.py +151 -0
- misterdev/core/task.py +163 -0
- misterdev/core/verification/__init__.py +0 -0
- misterdev/core/verification/claim_verifier.py +210 -0
- misterdev/core/verification/critic.py +324 -0
- misterdev/core/verification/gatekeeper/__init__.py +631 -0
- misterdev/core/verification/gatekeeper/constants.py +138 -0
- misterdev/core/verification/gatekeeper/helpers.py +28 -0
- misterdev/core/verification/goal_check.py +219 -0
- misterdev/core/verification/independent.py +68 -0
- misterdev/core/verification/mutation_gate.py +221 -0
- misterdev/core/verification/preflight.py +95 -0
- misterdev/core/verification/spec_tests.py +175 -0
- misterdev/core/verification/validator.py +495 -0
- misterdev/core/verification/vision_verify.py +185 -0
- misterdev/core/verification/web_verify.py +408 -0
- misterdev/environments/__init__.py +0 -0
- misterdev/environments/base_env.py +18 -0
- misterdev/environments/container_env.py +87 -0
- misterdev/environments/venv_env.py +42 -0
- misterdev/llm/__init__.py +0 -0
- misterdev/llm/client/__init__.py +152 -0
- misterdev/llm/client/base.py +382 -0
- misterdev/llm/client/edits.py +70 -0
- misterdev/llm/client/embeddings.py +121 -0
- misterdev/llm/client/errors.py +134 -0
- misterdev/llm/client/providers.py +535 -0
- misterdev/llm/client/response.py +24 -0
- misterdev/llm/prompt_manager.py +82 -0
- misterdev/llm/responses/__init__.py +34 -0
- misterdev/llm/responses/apply.py +131 -0
- misterdev/llm/responses/json_extract.py +80 -0
- misterdev/llm/responses/models.py +43 -0
- misterdev/llm/responses/parsing.py +494 -0
- misterdev/logging_setup.py +20 -0
- misterdev/mcp_server.py +208 -0
- misterdev/nl_cli.py +149 -0
- misterdev/plugins.py +115 -0
- misterdev/py.typed +0 -0
- misterdev/task_executors/__init__.py +0 -0
- misterdev/task_executors/base_executor.py +10 -0
- misterdev/task_executors/markdown_plan_executor/__init__.py +90 -0
- misterdev/task_executors/markdown_plan_executor/commands_mixin.py +82 -0
- misterdev/task_executors/markdown_plan_executor/context_mixin.py +221 -0
- misterdev/task_executors/markdown_plan_executor/critic_spec_mixin.py +174 -0
- misterdev/task_executors/markdown_plan_executor/edits_mixin.py +251 -0
- misterdev/task_executors/markdown_plan_executor/execute_mixin.py +727 -0
- misterdev/task_executors/markdown_plan_executor/gates_mixin.py +203 -0
- misterdev/task_executors/markdown_plan_executor/git_mixin.py +219 -0
- misterdev/task_executors/markdown_plan_executor/helpers.py +521 -0
- misterdev/task_executors/markdown_plan_executor/llm_mixin.py +238 -0
- misterdev/task_executors/markdown_plan_executor/results_mixin.py +23 -0
- misterdev/tools/__init__.py +19 -0
- misterdev/tools/base_tool.py +14 -0
- misterdev/tools/command.py +75 -0
- misterdev/tools/file_io.py +69 -0
- misterdev/tools/formatter.py +26 -0
- misterdev/tools/git_tool.py +90 -0
- misterdev/utils/__init__.py +0 -0
- misterdev/utils/file_utils.py +169 -0
- misterdev/utils/process.py +23 -0
- misterdev-0.2.0.dist-info/METADATA +326 -0
- misterdev-0.2.0.dist-info/RECORD +136 -0
- misterdev-0.2.0.dist-info/WHEEL +5 -0
- misterdev-0.2.0.dist-info/entry_points.txt +3 -0
- misterdev-0.2.0.dist-info/licenses/COMMERCIAL_LICENSE.md +34 -0
- misterdev-0.2.0.dist-info/licenses/LICENSE +661 -0
- 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
|