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,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)