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,221 @@
|
|
|
1
|
+
"""Code-context assembly and MCP awareness/gathering."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import List, Optional
|
|
5
|
+
|
|
6
|
+
from misterdev.core.integration.mcp_gather import gather_context
|
|
7
|
+
from misterdev.core.models import Task
|
|
8
|
+
from misterdev.core.execution.project import Project
|
|
9
|
+
from misterdev.config import get_setting
|
|
10
|
+
|
|
11
|
+
from .helpers import logger, _relevant_line_ranges, _window_lines
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ContextMixin:
|
|
15
|
+
def _mcp_gather(self, project: Project, task: Task) -> str:
|
|
16
|
+
"""Run the bounded agentic MCP gathering loop, or "" when off.
|
|
17
|
+
|
|
18
|
+
Additive and behind ``orchestrator.mcp_tool_use`` (off by default): when
|
|
19
|
+
the flag is off, no MCP manager is configured, or discovery found no
|
|
20
|
+
tools, this returns "" so the task context — and the entire edit path —
|
|
21
|
+
is byte-for-byte unchanged from the no-MCP build. When on, the model may
|
|
22
|
+
request up to ``orchestrator.mcp_max_tool_rounds`` bounded MCP tool calls
|
|
23
|
+
(each via the timeout-guarded, never-raising ``MCPManager.call_tool``)
|
|
24
|
+
whose results are gathered into a context block. Never raises into the
|
|
25
|
+
build; any failure degrades to whatever was gathered so far.
|
|
26
|
+
"""
|
|
27
|
+
if not get_setting(project.config, "orchestrator", "mcp_tool_use"):
|
|
28
|
+
return ""
|
|
29
|
+
mcp = getattr(project, "mcp", None)
|
|
30
|
+
local_tools = self._gather_safe_tools(project)
|
|
31
|
+
if mcp is None and not local_tools:
|
|
32
|
+
return ""
|
|
33
|
+
max_rounds = get_setting(project.config, "orchestrator", "mcp_max_tool_rounds")
|
|
34
|
+
|
|
35
|
+
def _ask(prompt: str) -> Optional[str]:
|
|
36
|
+
return project.llm_client.generate_code(prompt, "")
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
return gather_context(
|
|
40
|
+
mcp,
|
|
41
|
+
_ask,
|
|
42
|
+
task_description=task.description,
|
|
43
|
+
max_rounds=max_rounds,
|
|
44
|
+
local_tools=local_tools,
|
|
45
|
+
)
|
|
46
|
+
except Exception as e: # gathering is best-effort; never sink the build
|
|
47
|
+
logger.warning(f"MCP tool-gathering skipped (error: {e}).")
|
|
48
|
+
return ""
|
|
49
|
+
|
|
50
|
+
def _gather_safe_tools(self, project: Project) -> dict:
|
|
51
|
+
"""Configured tools that opt into the gathering loop, as
|
|
52
|
+
``{name: (description, call)}``.
|
|
53
|
+
|
|
54
|
+
A tool participates only if it is in ``project.config['tools']`` (operator
|
|
55
|
+
opted in) AND its class sets ``gather_safe = True`` — so a mutating tool
|
|
56
|
+
(command, file_io write/delete) is never exposed to this read-only
|
|
57
|
+
context-gathering pass; a plugin ships a read-only tool by declaring the
|
|
58
|
+
flag. Off by default: no built-in tool is gather-safe, so behaviour is
|
|
59
|
+
unchanged unless a gather-safe tool is configured.
|
|
60
|
+
"""
|
|
61
|
+
import misterdev.tools # noqa: F401 - registers built-in tools
|
|
62
|
+
from misterdev.plugins import TOOLS
|
|
63
|
+
|
|
64
|
+
local: dict = {}
|
|
65
|
+
for tc in project.config.get("tools") or []:
|
|
66
|
+
tool_cls = TOOLS.get(tc.get("type"))
|
|
67
|
+
if tool_cls is None or not getattr(tool_cls, "gather_safe", False):
|
|
68
|
+
continue
|
|
69
|
+
instance = tool_cls(tc)
|
|
70
|
+
name = tc.get("name") or tc.get("type")
|
|
71
|
+
desc = getattr(tool_cls, "gather_description", f"{tc.get('type')} tool")
|
|
72
|
+
|
|
73
|
+
def _call(args, _instance=instance):
|
|
74
|
+
ok, out = _instance.execute(project, **(args or {}))
|
|
75
|
+
return str(out) if ok else None
|
|
76
|
+
|
|
77
|
+
local[name] = (desc, _call)
|
|
78
|
+
return local
|
|
79
|
+
|
|
80
|
+
def _mcp_awareness(self, project: Project) -> str:
|
|
81
|
+
"""Render the available-MCP-tools section, or "" when off / no tools.
|
|
82
|
+
|
|
83
|
+
Additive and behind ``orchestrator.mcp_enabled``: when the flag is off,
|
|
84
|
+
no MCP manager is configured, or discovery found nothing, this returns
|
|
85
|
+
an empty string so the task context is byte-for-byte unchanged from the
|
|
86
|
+
no-MCP build. It only informs the model the tools exist (awareness); it
|
|
87
|
+
does not enable the model to call them — that agentic loop is a separate,
|
|
88
|
+
out-of-scope phase that would drive ``project.mcp.call_tool``.
|
|
89
|
+
"""
|
|
90
|
+
if not get_setting(project.config, "orchestrator", "mcp_enabled"):
|
|
91
|
+
return ""
|
|
92
|
+
mcp = getattr(project, "mcp", None)
|
|
93
|
+
if mcp is None:
|
|
94
|
+
return ""
|
|
95
|
+
described = mcp.describe_tools()
|
|
96
|
+
if not described:
|
|
97
|
+
return ""
|
|
98
|
+
return (
|
|
99
|
+
"\n\n## Available MCP tools (informational)\n"
|
|
100
|
+
"These external tools exist in this environment. You cannot invoke "
|
|
101
|
+
"them directly in your edits; they are listed so you understand what "
|
|
102
|
+
"capabilities are available.\n" + described
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def _get_code_context(
|
|
106
|
+
self,
|
|
107
|
+
project: Project,
|
|
108
|
+
target_files: List[str],
|
|
109
|
+
context_files: List[str],
|
|
110
|
+
max_lines: int = 500,
|
|
111
|
+
task: Optional[Task] = None,
|
|
112
|
+
) -> str:
|
|
113
|
+
context = ""
|
|
114
|
+
topo = getattr(project, "topography", None)
|
|
115
|
+
threshold = (
|
|
116
|
+
get_setting(project.config, "orchestrator", "large_file_line_threshold")
|
|
117
|
+
or 800
|
|
118
|
+
)
|
|
119
|
+
if target_files:
|
|
120
|
+
context += "### Files to Modify/Create\n"
|
|
121
|
+
for file_path in target_files:
|
|
122
|
+
context += self._render_target_file(
|
|
123
|
+
project, topo, file_path, task, threshold
|
|
124
|
+
)
|
|
125
|
+
if context_files:
|
|
126
|
+
context += "\n### Reference/Context Files (Read-Only)\n"
|
|
127
|
+
for file_path in context_files:
|
|
128
|
+
context += self._read_file_for_context(
|
|
129
|
+
project.path / file_path, file_path, max_lines
|
|
130
|
+
)
|
|
131
|
+
return context
|
|
132
|
+
|
|
133
|
+
def _fully_shown_target_files(
|
|
134
|
+
self, project: Project, target_files: List[str]
|
|
135
|
+
) -> set:
|
|
136
|
+
"""Target files small enough to be sent verbatim IN FULL by code_context.
|
|
137
|
+
|
|
138
|
+
These (existing files at or under the large-file threshold) have their
|
|
139
|
+
complete text in code_context, so topo can drop their own symbols to
|
|
140
|
+
avoid duplicating the same code across two sections. A large (windowed)
|
|
141
|
+
file is NOT included: its out-of-window symbols are still useful in topo.
|
|
142
|
+
"""
|
|
143
|
+
threshold = (
|
|
144
|
+
get_setting(project.config, "orchestrator", "large_file_line_threshold")
|
|
145
|
+
or 800
|
|
146
|
+
)
|
|
147
|
+
shown = set()
|
|
148
|
+
for file_path in target_files:
|
|
149
|
+
full = project.path / file_path
|
|
150
|
+
try:
|
|
151
|
+
if full.exists() and full.stat().st_size:
|
|
152
|
+
line_count = full.read_text(encoding="utf-8").count("\n") + 1
|
|
153
|
+
if line_count <= threshold:
|
|
154
|
+
shown.add(file_path)
|
|
155
|
+
except (UnicodeDecodeError, OSError):
|
|
156
|
+
continue
|
|
157
|
+
return shown
|
|
158
|
+
|
|
159
|
+
def _read_file_for_context(
|
|
160
|
+
self, full_path: Path, rel_path: str, max_lines: Optional[int]
|
|
161
|
+
) -> str:
|
|
162
|
+
if not full_path.exists():
|
|
163
|
+
return f"\n# File: {rel_path} (Does not exist yet)\n"
|
|
164
|
+
try:
|
|
165
|
+
content = full_path.read_text(encoding="utf-8")
|
|
166
|
+
except (UnicodeDecodeError, OSError):
|
|
167
|
+
return f"\n# File: {rel_path} (binary or unreadable, skipped)\n"
|
|
168
|
+
lines = content.splitlines()
|
|
169
|
+
if max_lines is not None and len(lines) > max_lines:
|
|
170
|
+
content = (
|
|
171
|
+
"\n".join(lines[:max_lines])
|
|
172
|
+
+ f"\n... ({len(lines)} lines total, truncated)"
|
|
173
|
+
)
|
|
174
|
+
return f"\n# File: {rel_path}\n{content}\n"
|
|
175
|
+
|
|
176
|
+
def _render_target_file(
|
|
177
|
+
self,
|
|
178
|
+
project: Project,
|
|
179
|
+
topo,
|
|
180
|
+
file_path: str,
|
|
181
|
+
task: Optional[Task],
|
|
182
|
+
threshold: int,
|
|
183
|
+
) -> str:
|
|
184
|
+
"""Render one target file for the edit prompt.
|
|
185
|
+
|
|
186
|
+
Small files are sent in full. Large files are sent as a symbol outline
|
|
187
|
+
plus verbatim windows of the task-relevant symbols (with elision markers
|
|
188
|
+
for the rest), so the model navigates via the outline and edits the
|
|
189
|
+
relevant regions exactly while context scales with the edit, not the
|
|
190
|
+
file. SEARCH/REPLACE still applies against the full on-disk content, so
|
|
191
|
+
windowing only narrows what the model reads, never what it can change.
|
|
192
|
+
"""
|
|
193
|
+
full_path = project.path / file_path
|
|
194
|
+
outline = topo.get_file_outline(file_path) if topo is not None else ""
|
|
195
|
+
header = (
|
|
196
|
+
f"\n# Outline of {file_path} (symbol: line range):\n{outline}\n"
|
|
197
|
+
if outline
|
|
198
|
+
else ""
|
|
199
|
+
)
|
|
200
|
+
if not full_path.exists():
|
|
201
|
+
return header + f"\n# File: {file_path} (Does not exist yet)\n"
|
|
202
|
+
try:
|
|
203
|
+
content = full_path.read_text(encoding="utf-8")
|
|
204
|
+
except (UnicodeDecodeError, OSError):
|
|
205
|
+
return header + f"\n# File: {file_path} (binary or unreadable, skipped)\n"
|
|
206
|
+
|
|
207
|
+
lines = content.split("\n")
|
|
208
|
+
if topo is None or len(lines) <= threshold:
|
|
209
|
+
return header + f"\n# File: {file_path}\n{content}\n"
|
|
210
|
+
|
|
211
|
+
symbols = topo.get_file_symbols(file_path)
|
|
212
|
+
keep = _relevant_line_ranges(symbols, task, len(lines))
|
|
213
|
+
if keep is None:
|
|
214
|
+
# No symbol matched the task: never strand the model, send it all.
|
|
215
|
+
return header + f"\n# File: {file_path}\n{content}\n"
|
|
216
|
+
body = _window_lines(lines, keep)
|
|
217
|
+
return (
|
|
218
|
+
header
|
|
219
|
+
+ f"\n# File: {file_path} (large file: windowed to relevant symbols; "
|
|
220
|
+
"use the outline above to locate anything not shown)\n" + f"{body}\n"
|
|
221
|
+
)
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Adversarial edit critic and spec-as-tests generation/execution."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import shlex
|
|
5
|
+
from typing import Dict, List, Optional, Tuple
|
|
6
|
+
|
|
7
|
+
from misterdev.core.models import Task
|
|
8
|
+
from misterdev.core.execution.project import Project
|
|
9
|
+
from misterdev.config import get_setting
|
|
10
|
+
|
|
11
|
+
from .helpers import logger
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CriticSpecMixin:
|
|
15
|
+
def _critic_enabled_for(self, project: Project, task: Task) -> bool:
|
|
16
|
+
"""Whether the adversarial critic runs for this task.
|
|
17
|
+
|
|
18
|
+
``adversarial_critic`` True/False forces it; "auto" (default) enables it
|
|
19
|
+
only for the cross-cutting categories in ``critic_auto_categories`` —
|
|
20
|
+
where symptom-fixes, incomplete refactors, and duplication cluster — so
|
|
21
|
+
the review runs out of the box on risky tasks without an extra call on
|
|
22
|
+
every trivial one.
|
|
23
|
+
"""
|
|
24
|
+
setting = get_setting(project.config, "orchestrator", "adversarial_critic")
|
|
25
|
+
if isinstance(setting, str) and setting.strip().lower() == "auto":
|
|
26
|
+
categories = get_setting(
|
|
27
|
+
project.config, "orchestrator", "critic_auto_categories"
|
|
28
|
+
)
|
|
29
|
+
return getattr(task, "category", "") in (categories or [])
|
|
30
|
+
return bool(setting)
|
|
31
|
+
|
|
32
|
+
def _run_edit_critic(self, project: Project, task: Task, edits: Dict[str, str]):
|
|
33
|
+
"""Run the independent adversarial critic over a candidate edit.
|
|
34
|
+
|
|
35
|
+
Reads the (optional) independent ``critic.model`` and timeout from config
|
|
36
|
+
and delegates to the never-raising, timeout-bounded gate. Returns a
|
|
37
|
+
:class:`~misterdev.core.verification.critic.CritiqueVerdict`; a SKIP
|
|
38
|
+
(no client, unparseable, timeout) is treated by the caller as "proceed".
|
|
39
|
+
"""
|
|
40
|
+
from misterdev.core.verification.critic import run_edit_critic
|
|
41
|
+
|
|
42
|
+
critic_cfg = project.config.get("critic") or {}
|
|
43
|
+
timeout = get_setting(project.config, "orchestrator", "critic_timeout")
|
|
44
|
+
return run_edit_critic(
|
|
45
|
+
task.description,
|
|
46
|
+
task.acceptance_criteria,
|
|
47
|
+
edits,
|
|
48
|
+
llm_client=project.llm_client,
|
|
49
|
+
critic_model=critic_cfg.get("model"),
|
|
50
|
+
candidate_diffs=self._critic_diffs(project, edits),
|
|
51
|
+
panel=critic_cfg.get("panel", 1),
|
|
52
|
+
timeout=timeout,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
@staticmethod
|
|
56
|
+
def _critic_diffs(project: Project, edits: Dict[str, str]) -> Dict[str, str]:
|
|
57
|
+
"""Unified diff of each candidate edit vs its current on-disk content.
|
|
58
|
+
|
|
59
|
+
Lets the critic review WHAT CHANGED (with a little context) instead of
|
|
60
|
+
whole files — sharper and far smaller for a small edit to a large file.
|
|
61
|
+
A new file (no original) diffs against empty, i.e. all-additions. Reading
|
|
62
|
+
the original is best-effort: an unreadable file falls back to empty.
|
|
63
|
+
"""
|
|
64
|
+
import difflib
|
|
65
|
+
|
|
66
|
+
diffs: Dict[str, str] = {}
|
|
67
|
+
for path, new_content in edits.items():
|
|
68
|
+
fp = project.path / path
|
|
69
|
+
try:
|
|
70
|
+
original = fp.read_text(encoding="utf-8") if fp.exists() else ""
|
|
71
|
+
except OSError:
|
|
72
|
+
original = ""
|
|
73
|
+
diff = "".join(
|
|
74
|
+
difflib.unified_diff(
|
|
75
|
+
original.splitlines(keepends=True),
|
|
76
|
+
(new_content or "").splitlines(keepends=True),
|
|
77
|
+
fromfile=f"a/{path}",
|
|
78
|
+
tofile=f"b/{path}",
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
diffs[path] = diff or "(no textual change)"
|
|
82
|
+
return diffs
|
|
83
|
+
|
|
84
|
+
def _maybe_generate_spec_test(self, project: Project, task: Task) -> Optional[str]:
|
|
85
|
+
"""Generate + write a failing spec test for the task, or return None.
|
|
86
|
+
|
|
87
|
+
Off unless ``orchestrator.spec_as_tests`` and the task has acceptance
|
|
88
|
+
criteria. The test is written under ``.orchestrator/spec_tests/`` — NOT
|
|
89
|
+
the project's own test directory — so it is never collected by the
|
|
90
|
+
project suite and so cannot flip the integration-gate baseline red. Run
|
|
91
|
+
scoped later by :meth:`_run_spec_test`. Best-effort: any failure (no
|
|
92
|
+
client, model error, unwritable path) yields None.
|
|
93
|
+
"""
|
|
94
|
+
if not get_setting(project.config, "orchestrator", "spec_as_tests"):
|
|
95
|
+
return None
|
|
96
|
+
if not getattr(task, "acceptance_criteria", ""):
|
|
97
|
+
return None
|
|
98
|
+
from misterdev.core.verification.spec_tests import (
|
|
99
|
+
generate_spec_test,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
language = (project.config.get("language") or "python").lower()
|
|
103
|
+
try:
|
|
104
|
+
source = generate_spec_test(task, project.llm_client, language=language)
|
|
105
|
+
except Exception as e: # generation is best-effort
|
|
106
|
+
logger.debug(f"Spec-test generation skipped: {e}")
|
|
107
|
+
return None
|
|
108
|
+
if not source:
|
|
109
|
+
return None
|
|
110
|
+
ext = {
|
|
111
|
+
"python": ".py",
|
|
112
|
+
"javascript": ".test.js",
|
|
113
|
+
"typescript": ".test.ts",
|
|
114
|
+
}.get(language, ".txt")
|
|
115
|
+
safe_id = re.sub(r"[^A-Za-z0-9_]", "_", str(getattr(task, "id", "task")))
|
|
116
|
+
path = project.path / ".orchestrator" / "spec_tests" / f"spec_{safe_id}{ext}"
|
|
117
|
+
try:
|
|
118
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
path.write_text(source, encoding="utf-8")
|
|
120
|
+
except OSError as e:
|
|
121
|
+
logger.debug(f"Spec-test write skipped: {e}")
|
|
122
|
+
return None
|
|
123
|
+
logger.info(f"Spec-as-test written (run scoped after gates): {path}")
|
|
124
|
+
return str(path)
|
|
125
|
+
|
|
126
|
+
def _run_spec_test(
|
|
127
|
+
self, project: Project, spec_path: Optional[str], timeout: int
|
|
128
|
+
) -> Tuple[str, str]:
|
|
129
|
+
"""Run the generated spec test scoped to its file. Returns (status, detail).
|
|
130
|
+
|
|
131
|
+
``status`` is ``green`` (passes — the spec is satisfied), ``red`` (still
|
|
132
|
+
fails), or ``skip`` (no spec test, or no scoped runner for this project's
|
|
133
|
+
language — we only run a single file for pytest/jest-style suites). Never
|
|
134
|
+
raises.
|
|
135
|
+
"""
|
|
136
|
+
if not spec_path:
|
|
137
|
+
return "skip", ""
|
|
138
|
+
test_cmd = project.config.get("test_command") or ""
|
|
139
|
+
if "pytest" in test_cmd or spec_path.endswith(".py"):
|
|
140
|
+
runner = f"pytest -q {shlex.quote(spec_path)}"
|
|
141
|
+
elif "jest" in test_cmd:
|
|
142
|
+
runner = f"jest {shlex.quote(spec_path)}"
|
|
143
|
+
elif "vitest" in test_cmd:
|
|
144
|
+
runner = f"npx --yes vitest run {shlex.quote(spec_path)}"
|
|
145
|
+
elif "node --test" in test_cmd or spec_path.endswith(
|
|
146
|
+
(".test.ts", ".test.js", ".test.mjs")
|
|
147
|
+
):
|
|
148
|
+
# Node's built-in runner strips TS at runtime, so a generated
|
|
149
|
+
# `.test.ts` spec runs with no extra toolchain — this is what makes
|
|
150
|
+
# TDD spec-as-tests work for a typecheck-only frontend target.
|
|
151
|
+
runner = f"node --test {shlex.quote(spec_path)}"
|
|
152
|
+
else:
|
|
153
|
+
return "skip", "no scoped spec-test runner for this project"
|
|
154
|
+
try:
|
|
155
|
+
ok, out = self._run_command(project, runner, timeout=timeout)
|
|
156
|
+
except Exception as e: # a runner failure must not sink the task
|
|
157
|
+
logger.debug(f"Spec-test run skipped: {e}")
|
|
158
|
+
return "skip", ""
|
|
159
|
+
return ("green" if ok else "red"), out
|
|
160
|
+
|
|
161
|
+
@staticmethod
|
|
162
|
+
def _build_critic_error_context(objections: List[str]) -> str:
|
|
163
|
+
"""Format critic objections into the same retry context shape as a gate.
|
|
164
|
+
|
|
165
|
+
The next attempt sees the concrete problems an independent reviewer found
|
|
166
|
+
in the rejected change and is told to address each before resubmitting.
|
|
167
|
+
"""
|
|
168
|
+
listed = "\n".join(f"- {o}" for o in objections)
|
|
169
|
+
return (
|
|
170
|
+
"An independent reviewer rejected your previous change BEFORE it was "
|
|
171
|
+
"applied. Each objection below is a concrete defect — address every "
|
|
172
|
+
"one in your next attempt, then resubmit the corrected edit:\n"
|
|
173
|
+
f"{listed}"
|
|
174
|
+
)
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""Edit path validation, test-tamper detection, and edit application."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Dict, Optional, Tuple
|
|
6
|
+
|
|
7
|
+
from misterdev.core.models import Task
|
|
8
|
+
from misterdev.core.execution.project import Project
|
|
9
|
+
from misterdev.llm.responses import (
|
|
10
|
+
EditConflictError,
|
|
11
|
+
LLMResponseParser,
|
|
12
|
+
apply_search_replace,
|
|
13
|
+
)
|
|
14
|
+
from misterdev.config import get_setting
|
|
15
|
+
from misterdev.utils.file_utils import write_file
|
|
16
|
+
|
|
17
|
+
from .helpers import (
|
|
18
|
+
logger,
|
|
19
|
+
_is_golden_path,
|
|
20
|
+
_is_test_file,
|
|
21
|
+
_diagnose_py_tampering,
|
|
22
|
+
_diagnose_tampering,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class EditsMixin:
|
|
27
|
+
def _validate_edit_paths(
|
|
28
|
+
self, project: Project, task: Task, edits: Dict[str, str]
|
|
29
|
+
) -> Dict[str, str]:
|
|
30
|
+
"""Reject hallucinated or out-of-scope edits before they touch disk.
|
|
31
|
+
|
|
32
|
+
Drops edits that escape the project root (absolute paths, ``..``
|
|
33
|
+
traversal) or that would create empty files, and warns when the LLM
|
|
34
|
+
touches files outside the task's declared scope. This is what prevents
|
|
35
|
+
a misrouted edit from clobbering files outside the project.
|
|
36
|
+
"""
|
|
37
|
+
project_root = project.path.resolve()
|
|
38
|
+
expected = set(task.files_to_modify + task.files_to_create)
|
|
39
|
+
golden_paths = get_setting(project.config, "orchestrator", "golden_paths")
|
|
40
|
+
valid: Dict[str, str] = {}
|
|
41
|
+
for path, content in edits.items():
|
|
42
|
+
if ".." in Path(path).parts or Path(path).is_absolute():
|
|
43
|
+
logger.error(f"Rejected edit with unsafe path: {path}")
|
|
44
|
+
continue
|
|
45
|
+
if _is_golden_path(path, golden_paths):
|
|
46
|
+
logger.error(f"Rejected edit to protected golden file: {path}")
|
|
47
|
+
continue
|
|
48
|
+
full = (project.path / path).resolve()
|
|
49
|
+
try:
|
|
50
|
+
inside = full.is_relative_to(project_root)
|
|
51
|
+
except ValueError:
|
|
52
|
+
inside = False
|
|
53
|
+
if not inside:
|
|
54
|
+
logger.error(f"Rejected edit to path outside project root: {path}")
|
|
55
|
+
continue
|
|
56
|
+
if not content.strip():
|
|
57
|
+
logger.warning(f"Rejected empty-content edit: {path}")
|
|
58
|
+
continue
|
|
59
|
+
if expected and path not in expected:
|
|
60
|
+
logger.warning(f"LLM modified file outside task scope: {path}")
|
|
61
|
+
valid[path] = content
|
|
62
|
+
return valid
|
|
63
|
+
|
|
64
|
+
def _detect_test_tampering(
|
|
65
|
+
self, project: Project, edits: Dict[str, str]
|
|
66
|
+
) -> Optional[str]:
|
|
67
|
+
"""Reject edits that weaken existing test files (deterministic gate).
|
|
68
|
+
|
|
69
|
+
For each edited TEST file that already exists on disk, compares the
|
|
70
|
+
current (pre-edit) content against the proposed content. An edit that
|
|
71
|
+
reduces tests/assertions or adds skip markers is tampering: the test
|
|
72
|
+
gate must not be satisfied by gutting the gate. New test files and
|
|
73
|
+
purely additive edits pass. Set ``orchestrator.allow_test_edits`` to
|
|
74
|
+
skip the check (escape hatch); it defaults to off (check enforced).
|
|
75
|
+
Must be called BEFORE ``_apply_edits`` so the on-disk content still
|
|
76
|
+
reflects the pre-edit state.
|
|
77
|
+
"""
|
|
78
|
+
if get_setting(project.config, "orchestrator", "allow_test_edits"):
|
|
79
|
+
return None
|
|
80
|
+
reasons = []
|
|
81
|
+
for file_path, content in edits.items():
|
|
82
|
+
if not _is_test_file(file_path):
|
|
83
|
+
continue
|
|
84
|
+
full_path = project.path / file_path
|
|
85
|
+
if not full_path.exists():
|
|
86
|
+
continue
|
|
87
|
+
try:
|
|
88
|
+
before = full_path.read_text(encoding="utf-8")
|
|
89
|
+
except (UnicodeDecodeError, OSError):
|
|
90
|
+
continue
|
|
91
|
+
# Python uses precise assertion-survival diagnosis (resistant to
|
|
92
|
+
# rename/move/split/merge); other languages fall back to the
|
|
93
|
+
# cross-language regex totals, which can't parse structure.
|
|
94
|
+
if file_path.endswith((".py", ".pyi")):
|
|
95
|
+
reason = _diagnose_py_tampering(before, content)
|
|
96
|
+
else:
|
|
97
|
+
reason = _diagnose_tampering(before, content)
|
|
98
|
+
if reason:
|
|
99
|
+
reasons.append(f"{file_path} ({reason})")
|
|
100
|
+
return "; ".join(reasons) if reasons else None
|
|
101
|
+
|
|
102
|
+
def _detect_dangling_references(
|
|
103
|
+
self, project: Project, edits: Dict[str, str]
|
|
104
|
+
) -> Optional[str]:
|
|
105
|
+
"""Reject an edit that removes/renames a symbol but leaves its callers.
|
|
106
|
+
|
|
107
|
+
The whack-a-mole failure: an edit deletes or renames a symbol yet leaves
|
|
108
|
+
references in files it didn't touch, so the build fails one missed site
|
|
109
|
+
at a time until attempts run out. This catches it deterministically
|
|
110
|
+
BEFORE a build cycle: for every graph symbol defined in an edited file
|
|
111
|
+
whose name no longer appears in that file's new content (removed or
|
|
112
|
+
renamed), flag any caller — in a file NOT part of this edit — that still
|
|
113
|
+
references the old name on disk. Returns a description of the dangling
|
|
114
|
+
sites, or None when the change is complete. Graph-driven, so a coincidental
|
|
115
|
+
name match without a real reference edge is never flagged.
|
|
116
|
+
"""
|
|
117
|
+
topo = getattr(project, "topography", None)
|
|
118
|
+
graph = getattr(topo, "graph", None)
|
|
119
|
+
symbols = getattr(graph, "symbols", None)
|
|
120
|
+
if not symbols:
|
|
121
|
+
return None
|
|
122
|
+
edited = set(edits.keys())
|
|
123
|
+
file_cache: Dict[str, str] = {}
|
|
124
|
+
dangling: list[str] = []
|
|
125
|
+
for sym in symbols.values():
|
|
126
|
+
if sym.file_path not in edited or not sym.incoming_calls:
|
|
127
|
+
continue
|
|
128
|
+
word = re.compile(rf"\b{re.escape(sym.name)}\b")
|
|
129
|
+
if word.search(edits.get(sym.file_path, "")):
|
|
130
|
+
continue # symbol still defined in the edited file -> not removed
|
|
131
|
+
for caller_key in sym.incoming_calls:
|
|
132
|
+
caller = symbols.get(caller_key)
|
|
133
|
+
if caller is None or caller.file_path in edited:
|
|
134
|
+
continue # caller lives in a file this edit already changes
|
|
135
|
+
content = file_cache.get(caller.file_path)
|
|
136
|
+
if content is None:
|
|
137
|
+
try:
|
|
138
|
+
content = (project.path / caller.file_path).read_text(
|
|
139
|
+
encoding="utf-8"
|
|
140
|
+
)
|
|
141
|
+
except (OSError, UnicodeDecodeError):
|
|
142
|
+
content = ""
|
|
143
|
+
file_cache[caller.file_path] = content
|
|
144
|
+
if word.search(content):
|
|
145
|
+
dangling.append(
|
|
146
|
+
f"`{sym.name}` still referenced in "
|
|
147
|
+
f"{caller.file_path}:{caller.start_line}"
|
|
148
|
+
)
|
|
149
|
+
if dangling:
|
|
150
|
+
return "; ".join(sorted(set(dangling))[:40])
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
def _resolve_edits(
|
|
154
|
+
self, project: Project, llm_response: str
|
|
155
|
+
) -> Tuple[Dict[str, str], Optional[str]]:
|
|
156
|
+
"""Turn the LLM response into ``{path: full_content}`` edits.
|
|
157
|
+
|
|
158
|
+
Prefers surgical SEARCH/REPLACE hunks: each is applied against the
|
|
159
|
+
current on-disk file so the model never has to reproduce a large file
|
|
160
|
+
in full (the whole-file path truncates past the output-token limit).
|
|
161
|
+
The resolved full content then flows through the same path/syntax/
|
|
162
|
+
tamper gates as before. Returns ``(edits, error)``; a non-empty error
|
|
163
|
+
means a hunk did not apply and the attempt should retry rather than
|
|
164
|
+
write a partial file. Falls back to the whole-file parser when the
|
|
165
|
+
response contains no SEARCH/REPLACE markers.
|
|
166
|
+
"""
|
|
167
|
+
sr_edits = LLMResponseParser.parse_search_replace_blocks(llm_response)
|
|
168
|
+
if not sr_edits:
|
|
169
|
+
return LLMResponseParser.parse_file_edits(llm_response), None
|
|
170
|
+
by_path: Dict[str, list] = {}
|
|
171
|
+
for edit in sr_edits:
|
|
172
|
+
by_path.setdefault(edit.path, []).append(edit)
|
|
173
|
+
resolved: Dict[str, str] = {}
|
|
174
|
+
for path, hunks in by_path.items():
|
|
175
|
+
full_path = project.path / path
|
|
176
|
+
try:
|
|
177
|
+
original = (
|
|
178
|
+
full_path.read_text(encoding="utf-8") if full_path.exists() else ""
|
|
179
|
+
)
|
|
180
|
+
except (UnicodeDecodeError, OSError) as exc:
|
|
181
|
+
return {}, f"{path}: could not read file to apply edit ({exc})"
|
|
182
|
+
try:
|
|
183
|
+
resolved[path] = apply_search_replace(original, hunks)
|
|
184
|
+
except EditConflictError as exc:
|
|
185
|
+
return {}, str(exc)
|
|
186
|
+
return resolved, None
|
|
187
|
+
|
|
188
|
+
def _apply_edits(self, project: Project, edits: Dict[str, str]):
|
|
189
|
+
"""Apply a batch of full-file edits atomically.
|
|
190
|
+
|
|
191
|
+
Writes are all-or-nothing: the pre-edit content of every file is
|
|
192
|
+
snapshotted first, and if any write raises, the already-written files
|
|
193
|
+
are rolled back to their snapshot (and newly-created files removed)
|
|
194
|
+
before the error propagates. Without this, a failure on file N left
|
|
195
|
+
files 1..N-1 written — a partial, inconsistent tree that only a
|
|
196
|
+
``BudgetExceededError`` revert would clean up. Reuses ``write_file`` for
|
|
197
|
+
the writes rather than introducing a second write path.
|
|
198
|
+
"""
|
|
199
|
+
snapshots: Dict[Path, Optional[str]] = {}
|
|
200
|
+
for file_path in edits:
|
|
201
|
+
full_path = project.path / file_path
|
|
202
|
+
try:
|
|
203
|
+
snapshots[full_path] = (
|
|
204
|
+
full_path.read_text(encoding="utf-8")
|
|
205
|
+
if full_path.exists()
|
|
206
|
+
else None
|
|
207
|
+
)
|
|
208
|
+
except (UnicodeDecodeError, OSError):
|
|
209
|
+
# Unreadable pre-image (binary/permission): treat as "cannot
|
|
210
|
+
# safely roll back", so leave it out of the snapshot and let a
|
|
211
|
+
# failed write on it surface as-is.
|
|
212
|
+
snapshots[full_path] = None
|
|
213
|
+
written: list[Path] = []
|
|
214
|
+
try:
|
|
215
|
+
for file_path, content in edits.items():
|
|
216
|
+
full_path = project.path / file_path
|
|
217
|
+
write_file(full_path, content)
|
|
218
|
+
written.append(full_path)
|
|
219
|
+
except Exception:
|
|
220
|
+
for full_path in written:
|
|
221
|
+
original = snapshots.get(full_path)
|
|
222
|
+
try:
|
|
223
|
+
if original is None:
|
|
224
|
+
full_path.unlink(missing_ok=True)
|
|
225
|
+
else:
|
|
226
|
+
write_file(full_path, original)
|
|
227
|
+
except OSError as restore_err:
|
|
228
|
+
logger.error(
|
|
229
|
+
f"Failed to roll back {full_path} after a partial edit: "
|
|
230
|
+
f"{restore_err}"
|
|
231
|
+
)
|
|
232
|
+
raise
|
|
233
|
+
|
|
234
|
+
def _run_formatters(self, project: Project, files):
|
|
235
|
+
# Per-file formatters substitute {path}; project-wide formatters run
|
|
236
|
+
# once. Pass each file only to formatters whose command templates use
|
|
237
|
+
# {path}; otherwise invoke the formatter a single time.
|
|
238
|
+
file_list = list(files)
|
|
239
|
+
for tool_name, tool in project.tool_manager.tools.items():
|
|
240
|
+
if getattr(tool, "type", None) != "formatter":
|
|
241
|
+
continue
|
|
242
|
+
template = (
|
|
243
|
+
getattr(tool, "config", {}).get("command", "")
|
|
244
|
+
if hasattr(tool, "config")
|
|
245
|
+
else ""
|
|
246
|
+
)
|
|
247
|
+
if "{path}" in template:
|
|
248
|
+
for file_path in file_list:
|
|
249
|
+
tool.execute(project, file_path=file_path)
|
|
250
|
+
else:
|
|
251
|
+
tool.execute(project)
|