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,495 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
import re
|
|
3
|
+
import subprocess
|
|
4
|
+
from typing import Callable, Dict, List, Optional, Tuple
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from misterdev.core.planning.assessment import HealthCheck
|
|
8
|
+
from misterdev.core.gitcmd import run_git
|
|
9
|
+
from misterdev.logging_setup import setup_logger
|
|
10
|
+
from misterdev.utils.process import kill_process_group
|
|
11
|
+
|
|
12
|
+
logger = setup_logger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# ----------------------------------------------------------------
|
|
16
|
+
# Build/Test/Lint Validation (Phase 5 from /build skill)
|
|
17
|
+
# ----------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ValidationResult:
|
|
21
|
+
def __init__(self):
|
|
22
|
+
self.build_ok: bool = False
|
|
23
|
+
self.build_output: str = ""
|
|
24
|
+
self.tests_ok: bool = False
|
|
25
|
+
self.test_output: str = ""
|
|
26
|
+
self.lint_ok: bool = False
|
|
27
|
+
self.lint_output: str = ""
|
|
28
|
+
self.diff_stats: str = ""
|
|
29
|
+
self.issues: list[str] = []
|
|
30
|
+
# Whether each gate actually executed a command. A gate that did not
|
|
31
|
+
# run must not be reported as "OK" (that hides untested code).
|
|
32
|
+
self.build_ran: bool = True
|
|
33
|
+
self.tests_ran: bool = True
|
|
34
|
+
self.lint_ran: bool = True
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def passed(self) -> bool:
|
|
38
|
+
# A gate that was absent is non-blocking (build-only / test-only projects
|
|
39
|
+
# are valid), but if NOTHING ran at all then nothing was actually
|
|
40
|
+
# verified — don't report that as passed (mirrors why summary() shows
|
|
41
|
+
# SKIP instead of OK).
|
|
42
|
+
ran = self.build_ran or self.tests_ran or self.lint_ran
|
|
43
|
+
return ran and self.build_ok and self.tests_ok and not self.issues
|
|
44
|
+
|
|
45
|
+
def _status(self, ran: bool, ok: bool, warn: bool = False) -> str:
|
|
46
|
+
if not ran:
|
|
47
|
+
return "SKIP"
|
|
48
|
+
if ok:
|
|
49
|
+
return "OK"
|
|
50
|
+
return "WARN" if warn else "FAIL"
|
|
51
|
+
|
|
52
|
+
def summary(self) -> str:
|
|
53
|
+
parts = [
|
|
54
|
+
f"build={self._status(self.build_ran, self.build_ok)}",
|
|
55
|
+
f"tests={self._status(self.tests_ran, self.tests_ok)}",
|
|
56
|
+
f"lint={self._status(self.lint_ran, self.lint_ok, warn=True)}",
|
|
57
|
+
]
|
|
58
|
+
if self.issues:
|
|
59
|
+
parts.append(f"issues={len(self.issues)}")
|
|
60
|
+
return " | ".join(parts)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _run_cmd(
|
|
64
|
+
cmd: str,
|
|
65
|
+
cwd: Path,
|
|
66
|
+
env_activate: Optional[str] = None,
|
|
67
|
+
timeout: int = 180,
|
|
68
|
+
runner: Optional[Callable[[str, int], Tuple[bool, str]]] = None,
|
|
69
|
+
policy: Optional[object] = None,
|
|
70
|
+
audit: Optional[object] = None,
|
|
71
|
+
) -> Tuple[bool, str]:
|
|
72
|
+
# Governance gate (opt-in): when a policy is supplied AND it refuses the
|
|
73
|
+
# command, return a refusal without executing. Both args default to None, so
|
|
74
|
+
# with no policy/audit this is byte-identical to the prior behavior. The
|
|
75
|
+
# classifier sees the raw command, before the host activation prefix.
|
|
76
|
+
if policy is not None:
|
|
77
|
+
try:
|
|
78
|
+
decision = policy.authorize(cmd)
|
|
79
|
+
except Exception: # a policy bug must never break the command seam
|
|
80
|
+
decision = None
|
|
81
|
+
if decision is not None and not decision.allowed:
|
|
82
|
+
msg = f"Command refused by governance: {decision.reason}"
|
|
83
|
+
logger.warning(f"{msg}: {cmd}")
|
|
84
|
+
return False, msg
|
|
85
|
+
# When a runner is supplied (e.g. a container engine), the command executes
|
|
86
|
+
# through it instead of the local subprocess. Activation prefixes are
|
|
87
|
+
# host-venv concepts, so they are only applied to local execution.
|
|
88
|
+
if runner is not None:
|
|
89
|
+
ok, output = runner(cmd, timeout)
|
|
90
|
+
_audit_command(audit, cmd, ok, cwd)
|
|
91
|
+
return ok, output
|
|
92
|
+
if env_activate:
|
|
93
|
+
full_cmd = f"{env_activate} && {cmd}"
|
|
94
|
+
else:
|
|
95
|
+
full_cmd = cmd
|
|
96
|
+
try:
|
|
97
|
+
# start_new_session puts the command in its own process group so a
|
|
98
|
+
# timeout can SIGKILL the WHOLE tree (build/test grandchildren like rustc
|
|
99
|
+
# or pytest workers), not just the shell — otherwise they outlive the
|
|
100
|
+
# gate and hold the target/ lock. errors="replace" keeps a stray non-UTF8
|
|
101
|
+
# byte in tool output from raising and being misreported as a failure.
|
|
102
|
+
proc = subprocess.Popen(
|
|
103
|
+
full_cmd,
|
|
104
|
+
shell=True,
|
|
105
|
+
cwd=cwd,
|
|
106
|
+
stdout=subprocess.PIPE,
|
|
107
|
+
stderr=subprocess.PIPE,
|
|
108
|
+
text=True,
|
|
109
|
+
errors="replace",
|
|
110
|
+
start_new_session=True,
|
|
111
|
+
)
|
|
112
|
+
try:
|
|
113
|
+
out, err = proc.communicate(timeout=timeout)
|
|
114
|
+
except subprocess.TimeoutExpired:
|
|
115
|
+
kill_process_group(proc)
|
|
116
|
+
# Bound the reap: a grandchild that escaped the process group (a
|
|
117
|
+
# daemonizing server) holds the inherited pipe, so an unbounded
|
|
118
|
+
# communicate() would hang the gate after the timeout already fired.
|
|
119
|
+
try:
|
|
120
|
+
proc.communicate(timeout=5)
|
|
121
|
+
except subprocess.TimeoutExpired:
|
|
122
|
+
pass
|
|
123
|
+
_audit_command(audit, cmd, False, cwd)
|
|
124
|
+
return False, f"Command timed out after {timeout}s: {full_cmd}"
|
|
125
|
+
output = out
|
|
126
|
+
if err:
|
|
127
|
+
output += "\n" + err
|
|
128
|
+
ok = proc.returncode == 0
|
|
129
|
+
_audit_command(audit, cmd, ok, cwd)
|
|
130
|
+
return ok, output
|
|
131
|
+
except Exception as e:
|
|
132
|
+
_audit_command(audit, cmd, False, cwd)
|
|
133
|
+
return False, f"Command failed: {e}"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _audit_command(audit: Optional[object], cmd: str, ok: bool, cwd: Path) -> None:
|
|
137
|
+
"""Record a command event if an audit trail is wired. Never raises."""
|
|
138
|
+
if audit is None:
|
|
139
|
+
return
|
|
140
|
+
try:
|
|
141
|
+
audit.record_command(cmd, ok=ok, cwd=str(cwd))
|
|
142
|
+
except Exception as e: # audit is observability; it must never break execution
|
|
143
|
+
# Swallow so a broken audit sink can't fail a command, but leave a trace
|
|
144
|
+
# so silently-vanishing audit records are diagnosable.
|
|
145
|
+
logger.debug(f"Audit record_command failed (ignored): {e}")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def run_validation(
|
|
149
|
+
project_path: Path,
|
|
150
|
+
build_command: Optional[str],
|
|
151
|
+
test_command: Optional[str],
|
|
152
|
+
lint_command: Optional[str],
|
|
153
|
+
env_activate: Optional[str] = None,
|
|
154
|
+
timeout: int = 180,
|
|
155
|
+
) -> ValidationResult:
|
|
156
|
+
"""Run full validation suite (Phase 5a-5d from /build)."""
|
|
157
|
+
result = ValidationResult()
|
|
158
|
+
|
|
159
|
+
if build_command:
|
|
160
|
+
logger.info(f"Validation: running build command: {build_command}")
|
|
161
|
+
result.build_ok, result.build_output = _run_cmd(
|
|
162
|
+
build_command,
|
|
163
|
+
project_path,
|
|
164
|
+
env_activate,
|
|
165
|
+
timeout,
|
|
166
|
+
)
|
|
167
|
+
if not result.build_ok:
|
|
168
|
+
result.issues.append("Build failed during validation")
|
|
169
|
+
else:
|
|
170
|
+
result.build_ok = True
|
|
171
|
+
result.build_ran = False
|
|
172
|
+
|
|
173
|
+
if test_command:
|
|
174
|
+
logger.info(f"Validation: running test command: {test_command}")
|
|
175
|
+
result.tests_ok, result.test_output = _run_cmd(
|
|
176
|
+
test_command,
|
|
177
|
+
project_path,
|
|
178
|
+
env_activate,
|
|
179
|
+
timeout,
|
|
180
|
+
)
|
|
181
|
+
if not result.tests_ok:
|
|
182
|
+
result.issues.append("Tests failed during validation")
|
|
183
|
+
else:
|
|
184
|
+
result.tests_ok = True
|
|
185
|
+
result.tests_ran = False
|
|
186
|
+
|
|
187
|
+
if lint_command:
|
|
188
|
+
logger.info(f"Validation: running lint command: {lint_command}")
|
|
189
|
+
result.lint_ok, result.lint_output = _run_cmd(
|
|
190
|
+
lint_command,
|
|
191
|
+
project_path,
|
|
192
|
+
env_activate,
|
|
193
|
+
timeout,
|
|
194
|
+
)
|
|
195
|
+
if not result.lint_ok:
|
|
196
|
+
result.issues.append("Lint warnings found during validation")
|
|
197
|
+
else:
|
|
198
|
+
result.lint_ok = True
|
|
199
|
+
result.lint_ran = False
|
|
200
|
+
|
|
201
|
+
proc = run_git("git diff --stat", project_path)
|
|
202
|
+
result.diff_stats = proc.stdout.strip() if proc else "(unable to get diff stats)"
|
|
203
|
+
|
|
204
|
+
return result
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def run_health_check(
|
|
208
|
+
project_path: Path,
|
|
209
|
+
build_command: Optional[str],
|
|
210
|
+
test_command: Optional[str],
|
|
211
|
+
lint_command: Optional[str],
|
|
212
|
+
env_activate: Optional[str] = None,
|
|
213
|
+
timeout: int = 120,
|
|
214
|
+
build_timeout: Optional[int] = None,
|
|
215
|
+
test_timeout: Optional[int] = None,
|
|
216
|
+
lint_timeout: Optional[int] = None,
|
|
217
|
+
) -> HealthCheck:
|
|
218
|
+
"""Run health check and return structured result for assessment.
|
|
219
|
+
|
|
220
|
+
Per-command timeouts override the shared ``timeout`` so a slow compiler or
|
|
221
|
+
linter (e.g. cargo with ort/tokenizers, clippy over a workspace) isn't
|
|
222
|
+
falsely reported as failing when it merely exceeds the default.
|
|
223
|
+
"""
|
|
224
|
+
health = HealthCheck()
|
|
225
|
+
bt = build_timeout if build_timeout is not None else timeout
|
|
226
|
+
tt = test_timeout if test_timeout is not None else timeout
|
|
227
|
+
lt = lint_timeout if lint_timeout is not None else tt
|
|
228
|
+
|
|
229
|
+
# A command that is absent is "not applicable", not "failing" — leaving the
|
|
230
|
+
# field at its False default would make a no-build-step (or no-lint) project
|
|
231
|
+
# read as build=FAIL and mislead the analyzers. Mirror run_validation, which
|
|
232
|
+
# treats an absent command as a pass.
|
|
233
|
+
if build_command:
|
|
234
|
+
health.builds, health.build_output = _run_cmd(
|
|
235
|
+
build_command,
|
|
236
|
+
project_path,
|
|
237
|
+
env_activate,
|
|
238
|
+
bt,
|
|
239
|
+
)
|
|
240
|
+
else:
|
|
241
|
+
health.builds = True
|
|
242
|
+
|
|
243
|
+
if test_command:
|
|
244
|
+
health.tests_pass, health.test_output = _run_cmd(
|
|
245
|
+
test_command,
|
|
246
|
+
project_path,
|
|
247
|
+
env_activate,
|
|
248
|
+
tt,
|
|
249
|
+
)
|
|
250
|
+
health.test_count, health.test_failures = _parse_test_counts(health.test_output)
|
|
251
|
+
else:
|
|
252
|
+
health.tests_pass = True
|
|
253
|
+
|
|
254
|
+
if lint_command:
|
|
255
|
+
health.lint_clean, health.lint_output = _run_cmd(
|
|
256
|
+
lint_command,
|
|
257
|
+
project_path,
|
|
258
|
+
env_activate,
|
|
259
|
+
lt,
|
|
260
|
+
)
|
|
261
|
+
else:
|
|
262
|
+
health.lint_clean = True
|
|
263
|
+
|
|
264
|
+
return health
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _parse_test_counts(output: str) -> Tuple[int, int]:
|
|
268
|
+
"""Extract (total, failures) from common test-runner output.
|
|
269
|
+
|
|
270
|
+
Without this, test_count stays 0 and assessment.summary() renders
|
|
271
|
+
'tests=none' even on a fully-passing suite, which misleads the analyzers
|
|
272
|
+
and recommender into believing no tests exist.
|
|
273
|
+
"""
|
|
274
|
+
passed = failed = 0
|
|
275
|
+
# pytest: "N passed", "N failed", "N error(s)", "N skipped". Sum EVERY match
|
|
276
|
+
# so a multi-package run (more than one summary line) isn't undercounted to
|
|
277
|
+
# just the first block.
|
|
278
|
+
for kind, target in (
|
|
279
|
+
("passed", "p"),
|
|
280
|
+
("failed", "f"),
|
|
281
|
+
("error", "f"),
|
|
282
|
+
("errors", "f"),
|
|
283
|
+
):
|
|
284
|
+
for n in re.findall(rf"(\d+) {kind}\b", output):
|
|
285
|
+
if target == "p":
|
|
286
|
+
passed += int(n)
|
|
287
|
+
else:
|
|
288
|
+
failed += int(n)
|
|
289
|
+
if passed or failed:
|
|
290
|
+
return passed + failed, failed
|
|
291
|
+
# cargo: "test result: ok. N passed; M failed" — one line per crate in a
|
|
292
|
+
# workspace run, so sum them all rather than counting only the first crate.
|
|
293
|
+
cargo = re.findall(r"test result:.*?(\d+) passed; (\d+) failed", output)
|
|
294
|
+
if cargo:
|
|
295
|
+
p = sum(int(a) for a, _ in cargo)
|
|
296
|
+
f = sum(int(b) for _, b in cargo)
|
|
297
|
+
return p + f, f
|
|
298
|
+
# swift test / XCTest: "Executed N tests, with M failures"
|
|
299
|
+
m = re.search(r"Executed (\d+) tests?, with (\d+) failure", output)
|
|
300
|
+
if m:
|
|
301
|
+
total, f = int(m.group(1)), int(m.group(2))
|
|
302
|
+
return total, f
|
|
303
|
+
# ctest: "X% tests passed, M tests failed out of N"
|
|
304
|
+
m = re.search(r"tests passed,\s*(\d+) tests? failed out of (\d+)", output)
|
|
305
|
+
if m:
|
|
306
|
+
f, total = int(m.group(1)), int(m.group(2))
|
|
307
|
+
return total, f
|
|
308
|
+
# dotnet test (VSTest): "Failed: N, Passed: M, Skipped: K, Total: T"
|
|
309
|
+
fm = re.search(r"Failed:\s*(\d+)", output)
|
|
310
|
+
tm = re.search(r"Total:\s*(\d+)", output)
|
|
311
|
+
if fm and tm:
|
|
312
|
+
return int(tm.group(1)), int(fm.group(1))
|
|
313
|
+
# dotnet test (alt): "Total tests: T. Passed: M. Failed: N."
|
|
314
|
+
m = re.search(r"Total tests:\s*(\d+)\..*?Failed:\s*(\d+)", output, re.DOTALL)
|
|
315
|
+
if m:
|
|
316
|
+
return int(m.group(1)), int(m.group(2))
|
|
317
|
+
# node --test: "ℹ tests N" / "ℹ fail K" (default reporter) or the TAP
|
|
318
|
+
# equivalent "# tests N" / "# fail K".
|
|
319
|
+
tm = re.search(r"(?:ℹ|#)\s*tests\s+(\d+)", output)
|
|
320
|
+
fm = re.search(r"(?:ℹ|#)\s*fail\s+(\d+)", output)
|
|
321
|
+
if tm and fm:
|
|
322
|
+
return int(tm.group(1)), int(fm.group(1))
|
|
323
|
+
return 0, 0
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
# ----------------------------------------------------------------
|
|
327
|
+
# Code Quality Validators
|
|
328
|
+
# ----------------------------------------------------------------
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
class CodeValidator:
|
|
332
|
+
"""Validates code syntax using AST parsing and structural checks."""
|
|
333
|
+
|
|
334
|
+
@staticmethod
|
|
335
|
+
def validate_code(
|
|
336
|
+
content: str, language: str = "python"
|
|
337
|
+
) -> Tuple[bool, Optional[str]]:
|
|
338
|
+
lang = language.lower()
|
|
339
|
+
if lang in ["python", "py"]:
|
|
340
|
+
try:
|
|
341
|
+
ast.parse(content)
|
|
342
|
+
return True, None
|
|
343
|
+
except SyntaxError as e:
|
|
344
|
+
error_msg = f"Syntax error at line {e.lineno}, col {e.offset}: {e.msg}"
|
|
345
|
+
return False, error_msg
|
|
346
|
+
# Shell syntax (command substitution $(), arithmetic (()), here-docs)
|
|
347
|
+
# uses parentheses in ways the delimiter matcher misreports. Skip the
|
|
348
|
+
# bracket check for shell rather than emit false "unclosed delimiter".
|
|
349
|
+
if lang in ["shell", "sh", "bash", "zsh"]:
|
|
350
|
+
return True, None
|
|
351
|
+
# Real parse-based check for languages with a trustworthy grammar. It
|
|
352
|
+
# understands strings/comments (so braces in a literal don't false-trip)
|
|
353
|
+
# and catches actual syntax errors that brace-balancing misses.
|
|
354
|
+
try:
|
|
355
|
+
from misterdev.core.context.topography import check_syntax
|
|
356
|
+
|
|
357
|
+
result = check_syntax(content, lang)
|
|
358
|
+
except ImportError:
|
|
359
|
+
result = None
|
|
360
|
+
if result is not None:
|
|
361
|
+
return result
|
|
362
|
+
return CodeValidator._basic_integrity_check(content)
|
|
363
|
+
|
|
364
|
+
@staticmethod
|
|
365
|
+
def _basic_integrity_check(content: str) -> Tuple[bool, Optional[str]]:
|
|
366
|
+
delimiters = {"(": ")", "[": "]", "{": "}"}
|
|
367
|
+
stack = []
|
|
368
|
+
for i, char in enumerate(content):
|
|
369
|
+
if char in delimiters:
|
|
370
|
+
stack.append((char, i))
|
|
371
|
+
elif char in delimiters.values():
|
|
372
|
+
if not stack:
|
|
373
|
+
return False, f"Unmatched closing delimiter '{char}' at index {i}"
|
|
374
|
+
top, _ = stack.pop()
|
|
375
|
+
if delimiters[top] != char:
|
|
376
|
+
return False, f"Mismatched delimiter '{char}' at index {i}"
|
|
377
|
+
if stack:
|
|
378
|
+
top, idx = stack[0]
|
|
379
|
+
return False, f"Unclosed delimiter '{top}' at index {idx}"
|
|
380
|
+
return True, None
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
class CertaintyScorer:
|
|
384
|
+
"""Heuristic-based LLM certainty analyzer."""
|
|
385
|
+
|
|
386
|
+
HEDGES = [
|
|
387
|
+
"maybe",
|
|
388
|
+
"perhaps",
|
|
389
|
+
"might",
|
|
390
|
+
"could be",
|
|
391
|
+
"not sure",
|
|
392
|
+
"i think",
|
|
393
|
+
"possibly",
|
|
394
|
+
"unclear",
|
|
395
|
+
"hard to say",
|
|
396
|
+
"it depends",
|
|
397
|
+
"arguably",
|
|
398
|
+
"i'm not certain",
|
|
399
|
+
"one possibility",
|
|
400
|
+
]
|
|
401
|
+
|
|
402
|
+
ASSERTIONS = [
|
|
403
|
+
"verified",
|
|
404
|
+
"confirmed",
|
|
405
|
+
"optimal",
|
|
406
|
+
"correct",
|
|
407
|
+
"proven",
|
|
408
|
+
"tests pass",
|
|
409
|
+
"all tests",
|
|
410
|
+
"successfully",
|
|
411
|
+
"no issues",
|
|
412
|
+
"the solution is",
|
|
413
|
+
"this fixes",
|
|
414
|
+
"this resolves",
|
|
415
|
+
"implemented",
|
|
416
|
+
"works",
|
|
417
|
+
"complete",
|
|
418
|
+
"done",
|
|
419
|
+
]
|
|
420
|
+
|
|
421
|
+
@staticmethod
|
|
422
|
+
def compute_score(content: str) -> float:
|
|
423
|
+
lower_content = content.lower()
|
|
424
|
+
score = 0.5
|
|
425
|
+
hedge_count = sum(
|
|
426
|
+
1 for hedge in CertaintyScorer.HEDGES if hedge in lower_content
|
|
427
|
+
)
|
|
428
|
+
score -= min(hedge_count * 0.1, 0.4)
|
|
429
|
+
assertion_count = sum(
|
|
430
|
+
1 for a in CertaintyScorer.ASSERTIONS if a in lower_content
|
|
431
|
+
)
|
|
432
|
+
score += min(assertion_count * 0.1, 0.4)
|
|
433
|
+
if "```" in content:
|
|
434
|
+
score += 0.1
|
|
435
|
+
if len(content.split()) < 20:
|
|
436
|
+
score -= 0.1
|
|
437
|
+
return max(0.0, min(1.0, score))
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
class StallDetector:
|
|
441
|
+
"""Detects oscillation or lack of progress in task execution history.
|
|
442
|
+
|
|
443
|
+
Uses token-based Jaccard Similarity to detect 'semantic' stalling where
|
|
444
|
+
edits are nearly identical but not byte-perfect matches.
|
|
445
|
+
"""
|
|
446
|
+
|
|
447
|
+
def __init__(self, history_limit: int = 5, similarity_threshold: float = 0.95):
|
|
448
|
+
self.history_limit = history_limit
|
|
449
|
+
self.similarity_threshold = similarity_threshold
|
|
450
|
+
self._history: List[str] = []
|
|
451
|
+
|
|
452
|
+
def push_edit(self, edits: Dict[str, str]) -> float:
|
|
453
|
+
"""Records an edit and returns a stall risk score [0.0 - 1.0]."""
|
|
454
|
+
sorted_keys = sorted(edits.keys())
|
|
455
|
+
current_content = " ".join(f"{k} {edits[k]}" for k in sorted_keys)
|
|
456
|
+
current_tokens = _tokenize(current_content)
|
|
457
|
+
|
|
458
|
+
if not current_tokens:
|
|
459
|
+
return 0.0
|
|
460
|
+
|
|
461
|
+
max_similarity = 0.0
|
|
462
|
+
for prev_content in self._history:
|
|
463
|
+
prev_tokens = _tokenize(prev_content)
|
|
464
|
+
intersection = len(current_tokens & prev_tokens)
|
|
465
|
+
union = len(current_tokens | prev_tokens)
|
|
466
|
+
similarity = intersection / union if union > 0 else 0.0
|
|
467
|
+
max_similarity = max(max_similarity, similarity)
|
|
468
|
+
|
|
469
|
+
self._history.append(current_content)
|
|
470
|
+
if len(self._history) > self.history_limit:
|
|
471
|
+
self._history.pop(0)
|
|
472
|
+
|
|
473
|
+
if max_similarity > self.similarity_threshold:
|
|
474
|
+
return 0.8 + (
|
|
475
|
+
0.2
|
|
476
|
+
* (max_similarity - self.similarity_threshold)
|
|
477
|
+
/ (1.0 - self.similarity_threshold)
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
return max_similarity * 0.5
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _tokenize(text: str) -> set:
|
|
484
|
+
"""Extract word tokens from text without regex."""
|
|
485
|
+
tokens = set()
|
|
486
|
+
current = []
|
|
487
|
+
for char in text.lower():
|
|
488
|
+
if char.isalnum() or char == "_":
|
|
489
|
+
current.append(char)
|
|
490
|
+
elif current:
|
|
491
|
+
tokens.add("".join(current))
|
|
492
|
+
current = []
|
|
493
|
+
if current:
|
|
494
|
+
tokens.add("".join(current))
|
|
495
|
+
return tokens
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Optional vision verification gate.
|
|
2
|
+
|
|
3
|
+
Some acceptance criteria are visual ("the dashboard shows a chart", "the layout
|
|
4
|
+
is not broken") and cannot be asserted by a DOM/text check. This gate takes a
|
|
5
|
+
screenshot path and asks a vision-language model a yes/no question — "does this
|
|
6
|
+
image satisfy: <assert>?" — capturing the model's verdict and reason as
|
|
7
|
+
evidence.
|
|
8
|
+
|
|
9
|
+
It mirrors :mod:`misterdev.core.execution.runtime`: strictly opt-in (off
|
|
10
|
+
unless ``runtime.vision`` is configured), best-effort, and run in a daemon worker
|
|
11
|
+
thread with a hard timeout so a slow or unreachable model can NEVER block the
|
|
12
|
+
build. Absent config, no model/network, or a timeout is a SKIP (no opinion), not
|
|
13
|
+
a failure; only a model that affirmatively denies the assertion is a RED, and
|
|
14
|
+
only an affirmation is a GREEN.
|
|
15
|
+
|
|
16
|
+
Unlike the web gate (which captures objective browser evidence), this gate's
|
|
17
|
+
signal IS a model judgment — so it asserts a concrete image (real pixels the
|
|
18
|
+
build produced) rather than letting the model self-report on code it wrote.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import base64
|
|
22
|
+
import re
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Callable, Optional
|
|
25
|
+
|
|
26
|
+
from misterdev.core.execution.bounded import run_bounded
|
|
27
|
+
from misterdev.core.execution.outcomes import (
|
|
28
|
+
GREEN,
|
|
29
|
+
RED,
|
|
30
|
+
SKIP,
|
|
31
|
+
GateOutcome,
|
|
32
|
+
)
|
|
33
|
+
from misterdev.logging_setup import setup_logger
|
|
34
|
+
|
|
35
|
+
logger = setup_logger(__name__)
|
|
36
|
+
|
|
37
|
+
# Outcome constants. SKIP means "no opinion" (no config, no model/network, or
|
|
38
|
+
# timeout) and must never be treated as a pass/fail signal by callers.
|
|
39
|
+
_PROMPT = (
|
|
40
|
+
"You are a strict visual acceptance checker. Look at the attached screenshot "
|
|
41
|
+
"and answer ONLY whether it satisfies this requirement:\n\n"
|
|
42
|
+
"{assertion}\n\n"
|
|
43
|
+
"Reply with 'YES' or 'NO' on the first line, then a one-sentence reason."
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# A vision call takes the prompt text and the base64-encoded PNG and returns the
|
|
47
|
+
# model's text verdict. Injected in tests; defaulted to the project client path.
|
|
48
|
+
VlmCall = Callable[[str, str], str]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class VisionResult(GateOutcome):
|
|
52
|
+
"""Outcome of a vision check. ``status`` is SKIP/GREEN/RED; ``verdict`` is
|
|
53
|
+
the raw model text (evidence); ``reason`` explains a SKIP/RED."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, status: str, verdict: str = "", reason: str = ""):
|
|
56
|
+
super().__init__(status, reason)
|
|
57
|
+
self.verdict = verdict
|
|
58
|
+
|
|
59
|
+
def __repr__(self) -> str:
|
|
60
|
+
return f"VisionResult(status={self.status!r}, reason={self.reason!r})"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def run_vision_gate(
|
|
64
|
+
project_root: Path,
|
|
65
|
+
vision_config: Optional[dict],
|
|
66
|
+
vlm_call: Optional[VlmCall] = None,
|
|
67
|
+
llm_client=None,
|
|
68
|
+
) -> VisionResult:
|
|
69
|
+
"""Run the vision gate described by ``vision_config``.
|
|
70
|
+
|
|
71
|
+
``vision_config`` keys:
|
|
72
|
+
- ``capture`` (required): path to the screenshot to evaluate, relative to
|
|
73
|
+
``project_root`` or absolute.
|
|
74
|
+
- ``assert`` (required): the requirement the image must satisfy.
|
|
75
|
+
- ``model`` (optional): vision model id; forwarded to the client.
|
|
76
|
+
- ``timeout`` (optional, default 60): hard ceiling for the whole run.
|
|
77
|
+
|
|
78
|
+
The model call is performed by ``vlm_call`` when supplied (the test seam);
|
|
79
|
+
otherwise a call is built from ``llm_client`` if one is provided. With
|
|
80
|
+
neither a callable nor a client, the gate SKIPs (no model/network). SKIP also
|
|
81
|
+
on absent config, a missing capture file, or the hard timeout (never blocks).
|
|
82
|
+
"""
|
|
83
|
+
if not vision_config or not vision_config.get("capture"):
|
|
84
|
+
return VisionResult(SKIP, reason="no runtime.vision config")
|
|
85
|
+
assertion = vision_config.get("assert")
|
|
86
|
+
if not assertion:
|
|
87
|
+
return VisionResult(SKIP, reason="no assert in runtime.vision config")
|
|
88
|
+
|
|
89
|
+
capture = Path(vision_config["capture"])
|
|
90
|
+
if not capture.is_absolute():
|
|
91
|
+
capture = project_root / capture
|
|
92
|
+
if not capture.is_file():
|
|
93
|
+
return VisionResult(SKIP, reason=f"capture file not found: {capture}")
|
|
94
|
+
|
|
95
|
+
model = vision_config.get("model")
|
|
96
|
+
timeout = float(vision_config.get("timeout", 60))
|
|
97
|
+
|
|
98
|
+
call = vlm_call or _default_vlm_call(llm_client, model)
|
|
99
|
+
if call is None:
|
|
100
|
+
return VisionResult(SKIP, reason="no vision model available")
|
|
101
|
+
|
|
102
|
+
def _work() -> VisionResult:
|
|
103
|
+
try:
|
|
104
|
+
return _verify(capture, assertion, call)
|
|
105
|
+
except Exception as e: # any model/IO failure is non-fatal -> skip
|
|
106
|
+
logger.debug(f"Vision verify gate unavailable: {e}")
|
|
107
|
+
return VisionResult(SKIP, reason=f"error: {e}")
|
|
108
|
+
|
|
109
|
+
return run_bounded(
|
|
110
|
+
_work, timeout, VisionResult(SKIP, reason="timed out"), "Vision verify gate"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _verify(capture: Path, assertion: str, call: VlmCall) -> VisionResult:
|
|
115
|
+
"""Encode the screenshot, ask the model, parse the YES/NO verdict."""
|
|
116
|
+
image_b64 = base64.b64encode(capture.read_bytes()).decode("ascii")
|
|
117
|
+
prompt = _PROMPT.format(assertion=assertion)
|
|
118
|
+
verdict = call(prompt, image_b64) or ""
|
|
119
|
+
decision = _parse_verdict(verdict)
|
|
120
|
+
if decision is True:
|
|
121
|
+
return VisionResult(GREEN, verdict=verdict)
|
|
122
|
+
if decision is False:
|
|
123
|
+
return VisionResult(RED, verdict=verdict, reason=verdict.strip())
|
|
124
|
+
# Model gave no parseable yes/no -> no opinion, not a failure.
|
|
125
|
+
return VisionResult(SKIP, verdict=verdict, reason="unparseable verdict")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _parse_verdict(text: str) -> Optional[bool]:
|
|
129
|
+
"""True for an affirmation, False for a denial, None when unparseable.
|
|
130
|
+
|
|
131
|
+
Matches a leading YES/NO token (the prompt asks for it on the first line) to
|
|
132
|
+
avoid being fooled by the word appearing inside the reason sentence.
|
|
133
|
+
"""
|
|
134
|
+
if not text:
|
|
135
|
+
return None
|
|
136
|
+
head = text.strip().splitlines()[0] if text.strip() else ""
|
|
137
|
+
if re.match(r"^\s*(yes|true|pass|affirm)\b", head, re.IGNORECASE):
|
|
138
|
+
return True
|
|
139
|
+
if re.match(r"^\s*(no|false|fail|deny)\b", head, re.IGNORECASE):
|
|
140
|
+
return False
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _default_vlm_call(llm_client, model: Optional[str]) -> Optional[VlmCall]:
|
|
145
|
+
"""Build a vision call from the project's LLM client, or None if unusable.
|
|
146
|
+
|
|
147
|
+
Sends a multimodal message (text + a base64 PNG image) and returns the
|
|
148
|
+
model's text. Prefers the client's first-class ``chat_multimodal`` method;
|
|
149
|
+
falls back to driving the raw OpenAI-compatible ``.client`` SDK directly for
|
|
150
|
+
clients that predate it. Kept tolerant of client shape so an absent/limited
|
|
151
|
+
client degrades to SKIP rather than raising. No network is touched until the
|
|
152
|
+
returned callable is actually invoked inside the worker thread.
|
|
153
|
+
"""
|
|
154
|
+
if llm_client is None:
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
def _call(prompt: str, image_b64: str) -> str:
|
|
158
|
+
multimodal = getattr(llm_client, "chat_multimodal", None)
|
|
159
|
+
if callable(multimodal):
|
|
160
|
+
return multimodal(prompt, image_b64, model)
|
|
161
|
+
# Fallback: drive the raw OpenAI-compatible SDK client directly.
|
|
162
|
+
# with_model is a context manager (not a client factory), so we must not
|
|
163
|
+
# rebind through it; the explicit ``model=`` below selects the vision
|
|
164
|
+
# model.
|
|
165
|
+
raw = getattr(llm_client, "client", None)
|
|
166
|
+
if raw is None or not hasattr(raw, "chat"):
|
|
167
|
+
raise RuntimeError("client does not expose a multimodal chat endpoint")
|
|
168
|
+
resp = raw.chat.completions.create(
|
|
169
|
+
model=model or getattr(llm_client, "model", None),
|
|
170
|
+
messages=[
|
|
171
|
+
{
|
|
172
|
+
"role": "user",
|
|
173
|
+
"content": [
|
|
174
|
+
{"type": "text", "text": prompt},
|
|
175
|
+
{
|
|
176
|
+
"type": "image_url",
|
|
177
|
+
"image_url": {"url": f"data:image/png;base64,{image_b64}"},
|
|
178
|
+
},
|
|
179
|
+
],
|
|
180
|
+
}
|
|
181
|
+
],
|
|
182
|
+
)
|
|
183
|
+
return resp.choices[0].message.content or ""
|
|
184
|
+
|
|
185
|
+
return _call
|