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,408 @@
|
|
|
1
|
+
"""Optional headless-browser web verification gate.
|
|
2
|
+
|
|
3
|
+
A passing build/test suite proves units behave, but not that the assembled web
|
|
4
|
+
artifact actually renders, is free of console errors, meets accessibility
|
|
5
|
+
baselines, or looks like its approved screenshot. This gate drives a real
|
|
6
|
+
headless browser (Playwright, sync API), optionally starting a dev server first,
|
|
7
|
+
loads a URL, runs a list of declarative ``checks`` against the live page, and
|
|
8
|
+
captures a screenshot as REAL evidence (never an LLM self-report).
|
|
9
|
+
|
|
10
|
+
It mirrors :mod:`misterdev.core.execution.runtime`: strictly opt-in (off
|
|
11
|
+
unless ``runtime.web`` is configured), best-effort, and run in a daemon worker
|
|
12
|
+
thread with a hard timeout so a hung browser or server can NEVER block the build.
|
|
13
|
+
Absent config, a missing Playwright/browser dependency, or a timeout is a SKIP
|
|
14
|
+
(no opinion), not a failure; only a check that genuinely fails (missing element,
|
|
15
|
+
missing text, a console error, an accessibility violation, or a screenshot diff
|
|
16
|
+
beyond threshold) is a RED.
|
|
17
|
+
|
|
18
|
+
``checks`` is a list of strings drawn from:
|
|
19
|
+
- ``dom:<selector>`` element matching the CSS selector must be present
|
|
20
|
+
- ``text:<substring>`` the substring must appear in the page text
|
|
21
|
+
- ``no-console-errors`` no ``console.error`` / page error may fire
|
|
22
|
+
- ``axe`` inject axe-core; fail on any accessibility violation
|
|
23
|
+
- ``screenshot`` capture and pixel-diff against a stored baseline;
|
|
24
|
+
with no baseline, the current shot is seeded and the
|
|
25
|
+
check is treated as seeded (SKIP-like), not RED.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import subprocess
|
|
29
|
+
import time
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import List, Optional, Tuple
|
|
32
|
+
|
|
33
|
+
from misterdev.core.execution.bounded import run_bounded
|
|
34
|
+
from misterdev.core.execution.outcomes import (
|
|
35
|
+
GREEN,
|
|
36
|
+
RED,
|
|
37
|
+
SKIP,
|
|
38
|
+
GateOutcome,
|
|
39
|
+
)
|
|
40
|
+
from misterdev.logging_setup import setup_logger
|
|
41
|
+
|
|
42
|
+
logger = setup_logger(__name__)
|
|
43
|
+
|
|
44
|
+
# Outcome constants. SKIP means "no opinion" (no config, missing dependency, or
|
|
45
|
+
# timeout) and must never be treated as a pass/fail signal by callers.
|
|
46
|
+
# Filename of the captured evidence screenshot, written under ``baseline_dir``
|
|
47
|
+
# (or a temp dir) so a human can inspect what the gate actually saw.
|
|
48
|
+
_EVIDENCE_NAME = "web_verify_evidence.png"
|
|
49
|
+
_BASELINE_NAME = "web_verify_baseline.png"
|
|
50
|
+
|
|
51
|
+
# Default fraction of differing bytes above which a screenshot check fails. Kept
|
|
52
|
+
# generous: this is a coarse regression tripwire, not a precise visual diff.
|
|
53
|
+
_DEFAULT_SCREENSHOT_THRESHOLD = 0.02
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class WebResult(GateOutcome):
|
|
57
|
+
"""Outcome of a web verification run. ``status`` is SKIP/GREEN/RED;
|
|
58
|
+
``evidence`` is the path to the captured screenshot (or ""); ``checks`` is
|
|
59
|
+
the per-check outcome list; ``reason`` explains a SKIP/RED."""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
status: str,
|
|
64
|
+
evidence: str = "",
|
|
65
|
+
checks: Optional[List[Tuple[str, str]]] = None,
|
|
66
|
+
reason: str = "",
|
|
67
|
+
):
|
|
68
|
+
super().__init__(status, reason)
|
|
69
|
+
self.evidence = evidence
|
|
70
|
+
self.checks = checks or []
|
|
71
|
+
|
|
72
|
+
def __repr__(self) -> str:
|
|
73
|
+
return f"WebResult(status={self.status!r}, reason={self.reason!r})"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def run_web_gate(
|
|
77
|
+
project_root: Path,
|
|
78
|
+
web_config: Optional[dict],
|
|
79
|
+
runner=None,
|
|
80
|
+
) -> WebResult:
|
|
81
|
+
"""Run the web gate described by ``web_config``.
|
|
82
|
+
|
|
83
|
+
``web_config`` keys:
|
|
84
|
+
- ``url`` (required): the page to load (http(s):// or file://).
|
|
85
|
+
- ``serve`` (optional): command to start a dev server before loading.
|
|
86
|
+
- ``ready`` (optional): substring of the server's stdout that signals
|
|
87
|
+
readiness; without it we just wait briefly for the port.
|
|
88
|
+
- ``checks`` (optional): list of check strings (see module docstring).
|
|
89
|
+
- ``baseline_dir`` (optional): where baseline/evidence images live;
|
|
90
|
+
defaults to ``<project_root>/.orchestrator``.
|
|
91
|
+
- ``threshold`` (optional): screenshot diff fraction (default 0.02).
|
|
92
|
+
- ``timeout`` (optional, default 60): hard ceiling for the whole run.
|
|
93
|
+
|
|
94
|
+
Returns a :class:`WebResult`. SKIP when there is no config / no ``url``
|
|
95
|
+
(feature off), when Playwright or its browser is unavailable, or when the
|
|
96
|
+
hard timeout fires (never blocks). ``runner`` is accepted for symmetry with
|
|
97
|
+
the gate seam; the browser runs on the host where the dev server's ports
|
|
98
|
+
live, so it is unused today.
|
|
99
|
+
"""
|
|
100
|
+
if not web_config or not web_config.get("url"):
|
|
101
|
+
return WebResult(SKIP, reason="no runtime.web config")
|
|
102
|
+
|
|
103
|
+
timeout = float(web_config.get("timeout", 60))
|
|
104
|
+
|
|
105
|
+
def _work() -> WebResult:
|
|
106
|
+
try:
|
|
107
|
+
return _verify(project_root, web_config, timeout)
|
|
108
|
+
except Exception as e: # any browser/server/IO failure is non-fatal
|
|
109
|
+
logger.debug(f"Web verify gate unavailable: {e}")
|
|
110
|
+
return WebResult(SKIP, reason=f"error: {e}")
|
|
111
|
+
|
|
112
|
+
# A small margin over the inner timeout so a clean inner teardown is
|
|
113
|
+
# preferred, but the outer bound still guarantees we return.
|
|
114
|
+
return run_bounded(
|
|
115
|
+
_work, timeout + 5, WebResult(SKIP, reason="timed out"), "Web verify gate"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _playwright_sync():
|
|
120
|
+
"""Import Playwright's sync API, or return None if it is unavailable.
|
|
121
|
+
|
|
122
|
+
Kept in a dedicated function so it can be monkeypatched in tests and so an
|
|
123
|
+
absent dependency degrades to SKIP instead of raising.
|
|
124
|
+
"""
|
|
125
|
+
try:
|
|
126
|
+
from playwright.sync_api import sync_playwright
|
|
127
|
+
|
|
128
|
+
return sync_playwright
|
|
129
|
+
except Exception as e: # not installed / broken install -> skip
|
|
130
|
+
logger.debug(f"Playwright unavailable: {e}")
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _verify(project_root: Path, web_config: dict, timeout: float) -> WebResult:
|
|
135
|
+
"""Optionally start a server, drive the browser, run checks, tear down."""
|
|
136
|
+
sync_playwright = _playwright_sync()
|
|
137
|
+
if sync_playwright is None:
|
|
138
|
+
return WebResult(SKIP, reason="playwright not installed")
|
|
139
|
+
|
|
140
|
+
url = web_config["url"]
|
|
141
|
+
serve = web_config.get("serve")
|
|
142
|
+
ready = web_config.get("ready")
|
|
143
|
+
checks = list(web_config.get("checks") or [])
|
|
144
|
+
threshold = float(web_config.get("threshold", _DEFAULT_SCREENSHOT_THRESHOLD))
|
|
145
|
+
baseline_dir = Path(
|
|
146
|
+
web_config.get("baseline_dir") or (project_root / ".orchestrator")
|
|
147
|
+
)
|
|
148
|
+
deadline = time.monotonic() + timeout
|
|
149
|
+
|
|
150
|
+
proc = None
|
|
151
|
+
try:
|
|
152
|
+
if serve:
|
|
153
|
+
proc = _start_server(project_root, serve, ready, deadline)
|
|
154
|
+
return _drive_browser(
|
|
155
|
+
sync_playwright, url, checks, baseline_dir, threshold, deadline
|
|
156
|
+
)
|
|
157
|
+
finally:
|
|
158
|
+
_terminate(proc)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _start_server(
|
|
162
|
+
project_root: Path, serve: str, ready: Optional[str], deadline: float
|
|
163
|
+
) -> subprocess.Popen:
|
|
164
|
+
"""Launch the dev server and wait until it signals readiness (or briefly)."""
|
|
165
|
+
proc = subprocess.Popen(
|
|
166
|
+
serve,
|
|
167
|
+
shell=True,
|
|
168
|
+
cwd=str(project_root),
|
|
169
|
+
stdout=subprocess.PIPE,
|
|
170
|
+
stderr=subprocess.STDOUT,
|
|
171
|
+
text=True,
|
|
172
|
+
bufsize=1,
|
|
173
|
+
)
|
|
174
|
+
if ready:
|
|
175
|
+
captured: List[str] = []
|
|
176
|
+
while time.monotonic() < deadline:
|
|
177
|
+
if "".join(captured).find(ready) != -1:
|
|
178
|
+
break
|
|
179
|
+
if proc.poll() is not None:
|
|
180
|
+
break
|
|
181
|
+
if proc.stdout is not None:
|
|
182
|
+
line = proc.stdout.readline()
|
|
183
|
+
if line:
|
|
184
|
+
captured.append(line)
|
|
185
|
+
else:
|
|
186
|
+
# No readiness signal: give the server a brief moment to bind its port.
|
|
187
|
+
time.sleep(min(2.0, max(0.0, deadline - time.monotonic())))
|
|
188
|
+
return proc
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _drive_browser(
|
|
192
|
+
sync_playwright,
|
|
193
|
+
url: str,
|
|
194
|
+
checks: List[str],
|
|
195
|
+
baseline_dir: Path,
|
|
196
|
+
threshold: float,
|
|
197
|
+
deadline: float,
|
|
198
|
+
) -> WebResult:
|
|
199
|
+
"""Load ``url`` headless, run ``checks``, always capture a screenshot."""
|
|
200
|
+
nav_timeout_ms = max(1, int((deadline - time.monotonic()) * 1000))
|
|
201
|
+
console_errors: List[str] = []
|
|
202
|
+
results: List[Tuple[str, str]] = []
|
|
203
|
+
failures: List[str] = []
|
|
204
|
+
|
|
205
|
+
with sync_playwright() as pw:
|
|
206
|
+
browser = pw.chromium.launch(headless=True)
|
|
207
|
+
try:
|
|
208
|
+
page = browser.new_page()
|
|
209
|
+
page.on("console", lambda msg: _record_console(msg, console_errors))
|
|
210
|
+
page.on("pageerror", lambda err: console_errors.append(str(err)))
|
|
211
|
+
page.goto(url, timeout=nav_timeout_ms)
|
|
212
|
+
|
|
213
|
+
evidence = _capture_evidence(page, baseline_dir)
|
|
214
|
+
|
|
215
|
+
for check in checks:
|
|
216
|
+
ok, detail = _run_check(
|
|
217
|
+
page, check, console_errors, baseline_dir, threshold
|
|
218
|
+
)
|
|
219
|
+
results.append((check, detail))
|
|
220
|
+
if ok is False:
|
|
221
|
+
failures.append(f"{check}: {detail}")
|
|
222
|
+
finally:
|
|
223
|
+
browser.close()
|
|
224
|
+
|
|
225
|
+
if failures:
|
|
226
|
+
return WebResult(
|
|
227
|
+
RED, evidence=evidence, checks=results, reason="; ".join(failures)
|
|
228
|
+
)
|
|
229
|
+
return WebResult(GREEN, evidence=evidence, checks=results)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _record_console(msg, sink: List[str]) -> None:
|
|
233
|
+
"""Append console.error messages to ``sink`` (best-effort across versions)."""
|
|
234
|
+
try:
|
|
235
|
+
msg_type = msg.type if isinstance(msg.type, str) else msg.type()
|
|
236
|
+
except Exception:
|
|
237
|
+
msg_type = getattr(msg, "type", "")
|
|
238
|
+
if msg_type == "error":
|
|
239
|
+
try:
|
|
240
|
+
sink.append(msg.text if isinstance(msg.text, str) else msg.text())
|
|
241
|
+
except Exception:
|
|
242
|
+
sink.append("console error")
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _capture_evidence(page, baseline_dir: Path) -> str:
|
|
246
|
+
"""Write a full-page screenshot under ``baseline_dir`` and return its path."""
|
|
247
|
+
try:
|
|
248
|
+
baseline_dir.mkdir(parents=True, exist_ok=True)
|
|
249
|
+
path = baseline_dir / _EVIDENCE_NAME
|
|
250
|
+
page.screenshot(path=str(path), full_page=True)
|
|
251
|
+
return str(path)
|
|
252
|
+
except Exception as e:
|
|
253
|
+
logger.debug(f"Could not capture web evidence screenshot: {e}")
|
|
254
|
+
return ""
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _run_check(
|
|
258
|
+
page,
|
|
259
|
+
check: str,
|
|
260
|
+
console_errors: List[str],
|
|
261
|
+
baseline_dir: Path,
|
|
262
|
+
threshold: float,
|
|
263
|
+
) -> Tuple[Optional[bool], str]:
|
|
264
|
+
"""Evaluate a single check string. Returns ``(ok, detail)`` where ``ok`` is
|
|
265
|
+
True/False, or None for a seeded screenshot (treated as non-failing)."""
|
|
266
|
+
if check.startswith("dom:"):
|
|
267
|
+
selector = check[len("dom:") :]
|
|
268
|
+
try:
|
|
269
|
+
present = page.query_selector(selector) is not None
|
|
270
|
+
except Exception as e:
|
|
271
|
+
return False, f"selector error: {e}"
|
|
272
|
+
return (present, "present" if present else "element not found")
|
|
273
|
+
|
|
274
|
+
if check.startswith("text:"):
|
|
275
|
+
needle = check[len("text:") :]
|
|
276
|
+
# Match against the rendered, visible body text so a substring that only
|
|
277
|
+
# occurs inside a tag name or attribute value does not count as present.
|
|
278
|
+
# Fall back to the raw HTML only if the rendered text can't be read.
|
|
279
|
+
try:
|
|
280
|
+
content = page.inner_text("body")
|
|
281
|
+
except Exception:
|
|
282
|
+
try:
|
|
283
|
+
content = page.content()
|
|
284
|
+
except Exception as e:
|
|
285
|
+
return False, f"content error: {e}"
|
|
286
|
+
found = needle in content
|
|
287
|
+
return (found, "found" if found else "substring not found")
|
|
288
|
+
|
|
289
|
+
if check == "no-console-errors":
|
|
290
|
+
if console_errors:
|
|
291
|
+
return False, f"{len(console_errors)} console error(s): {console_errors[0]}"
|
|
292
|
+
return True, "none"
|
|
293
|
+
|
|
294
|
+
if check == "axe":
|
|
295
|
+
return _run_axe(page)
|
|
296
|
+
|
|
297
|
+
if check == "screenshot":
|
|
298
|
+
return _run_screenshot_diff(page, baseline_dir, threshold)
|
|
299
|
+
|
|
300
|
+
return False, "unknown check"
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _run_axe(page) -> Tuple[bool, str]:
|
|
304
|
+
"""Inject axe-core and run it; fail on any accessibility violation."""
|
|
305
|
+
try:
|
|
306
|
+
page.add_script_tag(
|
|
307
|
+
url="https://cdnjs.cloudflare.com/ajax/libs/axe-core/4.10.2/axe.min.js"
|
|
308
|
+
)
|
|
309
|
+
result = page.evaluate(
|
|
310
|
+
"async () => { const r = await axe.run(); "
|
|
311
|
+
"return r.violations.map(v => v.id); }"
|
|
312
|
+
)
|
|
313
|
+
except Exception as e:
|
|
314
|
+
# axe-core could not be loaded/run (offline, CSP). No opinion -> pass the
|
|
315
|
+
# individual check rather than failing the build on infrastructure.
|
|
316
|
+
logger.debug(f"axe-core unavailable: {e}")
|
|
317
|
+
return True, "axe unavailable (skipped)"
|
|
318
|
+
violations = list(result or [])
|
|
319
|
+
if violations:
|
|
320
|
+
return False, f"{len(violations)} violation(s): {', '.join(violations[:5])}"
|
|
321
|
+
return True, "no violations"
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _run_screenshot_diff(
|
|
325
|
+
page, baseline_dir: Path, threshold: float
|
|
326
|
+
) -> Tuple[Optional[bool], str]:
|
|
327
|
+
"""Pixel-diff the current screenshot against a stored baseline.
|
|
328
|
+
|
|
329
|
+
With no baseline, seed it and return None (seeded, not a failure). Otherwise
|
|
330
|
+
fail when the differing fraction exceeds ``threshold``.
|
|
331
|
+
"""
|
|
332
|
+
try:
|
|
333
|
+
baseline_dir.mkdir(parents=True, exist_ok=True)
|
|
334
|
+
baseline = baseline_dir / _BASELINE_NAME
|
|
335
|
+
current_bytes = page.screenshot(full_page=True)
|
|
336
|
+
if not baseline.exists():
|
|
337
|
+
baseline.write_bytes(current_bytes)
|
|
338
|
+
return None, "baseline seeded"
|
|
339
|
+
baseline_bytes = baseline.read_bytes()
|
|
340
|
+
diff = _image_diff_fraction(baseline_bytes, current_bytes)
|
|
341
|
+
if diff > threshold:
|
|
342
|
+
return False, f"diff {diff:.3f} > threshold {threshold:.3f}"
|
|
343
|
+
return True, f"diff {diff:.3f} <= threshold {threshold:.3f}"
|
|
344
|
+
except Exception as e:
|
|
345
|
+
logger.debug(f"Screenshot diff unavailable: {e}")
|
|
346
|
+
return True, "screenshot diff unavailable (skipped)"
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _image_diff_fraction(a: bytes, b: bytes) -> float:
|
|
350
|
+
"""Fraction of differing pixels between two PNG byte strings.
|
|
351
|
+
|
|
352
|
+
Prefers a per-pixel comparison via Pillow (size-normalized). Falls back to a
|
|
353
|
+
byte-level comparison when Pillow is absent, so the check degrades rather
|
|
354
|
+
than crashing.
|
|
355
|
+
"""
|
|
356
|
+
try:
|
|
357
|
+
import io
|
|
358
|
+
|
|
359
|
+
from PIL import Image
|
|
360
|
+
|
|
361
|
+
img_a = Image.open(io.BytesIO(a)).convert("RGB")
|
|
362
|
+
img_b = Image.open(io.BytesIO(b)).convert("RGB")
|
|
363
|
+
if img_a.size != img_b.size:
|
|
364
|
+
img_b = img_b.resize(img_a.size)
|
|
365
|
+
px_a = img_a.load()
|
|
366
|
+
px_b = img_b.load()
|
|
367
|
+
width, height = img_a.size
|
|
368
|
+
if width == 0 or height == 0:
|
|
369
|
+
return 0.0
|
|
370
|
+
differing = 0
|
|
371
|
+
for y in range(height):
|
|
372
|
+
for x in range(width):
|
|
373
|
+
if px_a[x, y] != px_b[x, y]:
|
|
374
|
+
differing += 1
|
|
375
|
+
return differing / float(width * height)
|
|
376
|
+
except Exception as e:
|
|
377
|
+
logger.debug(f"Pillow diff unavailable, byte-comparing: {e}")
|
|
378
|
+
return _byte_diff_fraction(a, b)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _byte_diff_fraction(a: bytes, b: bytes) -> float:
|
|
382
|
+
"""Fraction of differing bytes between two byte strings (length-normalized)."""
|
|
383
|
+
if not a and not b:
|
|
384
|
+
return 0.0
|
|
385
|
+
longer = max(len(a), len(b))
|
|
386
|
+
if longer == 0:
|
|
387
|
+
return 0.0
|
|
388
|
+
shorter = min(len(a), len(b))
|
|
389
|
+
differing = abs(len(a) - len(b))
|
|
390
|
+
for i in range(shorter):
|
|
391
|
+
if a[i] != b[i]:
|
|
392
|
+
differing += 1
|
|
393
|
+
return differing / float(longer)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _terminate(proc: Optional[subprocess.Popen]) -> None:
|
|
397
|
+
"""Best-effort teardown of the dev server: terminate, then kill if needed."""
|
|
398
|
+
if proc is None or proc.poll() is not None:
|
|
399
|
+
return
|
|
400
|
+
try:
|
|
401
|
+
proc.terminate()
|
|
402
|
+
proc.wait(timeout=3)
|
|
403
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
404
|
+
try:
|
|
405
|
+
proc.kill()
|
|
406
|
+
proc.wait(timeout=3)
|
|
407
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
408
|
+
logger.debug("Web dev server did not exit after kill.")
|
|
File without changes
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class BaseEnvironmentManager(ABC):
|
|
6
|
+
def __init__(self, config: dict, project_path: str | Path):
|
|
7
|
+
self.config = config
|
|
8
|
+
self.project_path = Path(project_path)
|
|
9
|
+
|
|
10
|
+
@abstractmethod
|
|
11
|
+
def setup(self) -> bool:
|
|
12
|
+
"""Sets up the environment."""
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
@abstractmethod
|
|
16
|
+
def activate_command(self) -> str:
|
|
17
|
+
"""Returns the command snippet to activate the environment."""
|
|
18
|
+
pass
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Container-backed build environment (opt-in).
|
|
2
|
+
|
|
3
|
+
Selected by ``environment.type: docker`` (or ``container``). Provides a
|
|
4
|
+
:class:`~misterdev.core.execution.container.ContainerEngine` configured for
|
|
5
|
+
the project so gate commands run inside a pinned image. Strictly best-effort:
|
|
6
|
+
if no OCI engine is reachable, :meth:`engine` returns ``None`` and the caller
|
|
7
|
+
runs gates locally exactly as before — the environment never blocks a build.
|
|
8
|
+
|
|
9
|
+
The image comes from ``environment.image`` when set, otherwise auto-detected
|
|
10
|
+
from the project ``language``. ``environment.engine`` may pin a preferred engine
|
|
11
|
+
(podman/docker/nerdctl/colima); by default detection prefers rootless-first.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
from misterdev.core.execution.container import (
|
|
18
|
+
ContainerEngine,
|
|
19
|
+
detect_engine,
|
|
20
|
+
image_for_language,
|
|
21
|
+
)
|
|
22
|
+
from misterdev.environments.base_env import BaseEnvironmentManager
|
|
23
|
+
from misterdev.logging_setup import setup_logger
|
|
24
|
+
|
|
25
|
+
logger = setup_logger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ContainerEnvironmentManager(BaseEnvironmentManager):
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
config: dict,
|
|
32
|
+
project_path: str | Path,
|
|
33
|
+
language: str = "",
|
|
34
|
+
network: Optional[str] = None,
|
|
35
|
+
):
|
|
36
|
+
super().__init__(config, project_path)
|
|
37
|
+
self.language = language or config.get("language", "")
|
|
38
|
+
self.image = config.get("image") or image_for_language(self.language)
|
|
39
|
+
self.preferred_engine = config.get("engine")
|
|
40
|
+
self.mount_path = config.get("mount_path", "/workspace")
|
|
41
|
+
# Container egress control from governance.network ("none" disables the
|
|
42
|
+
# container network). None leaves the engine default unchanged.
|
|
43
|
+
self.network = network
|
|
44
|
+
# Optional resource caps for the throwaway container; unset -> no flag.
|
|
45
|
+
self.memory = config.get("memory")
|
|
46
|
+
self.cpus = config.get("cpus")
|
|
47
|
+
self.pids_limit = config.get("pids_limit")
|
|
48
|
+
# Optional sandbox hardening; unset -> no flag (off path unchanged).
|
|
49
|
+
self.cap_drop = config.get("cap_drop")
|
|
50
|
+
self.security_opt = config.get("security_opt")
|
|
51
|
+
self._engine: Optional[ContainerEngine] = None
|
|
52
|
+
|
|
53
|
+
def setup(self) -> bool:
|
|
54
|
+
"""Detect a usable OCI engine. Returns True when one is available, False
|
|
55
|
+
otherwise (caller falls back to local execution). Never raises."""
|
|
56
|
+
engine_name = detect_engine(self.preferred_engine)
|
|
57
|
+
if engine_name is None:
|
|
58
|
+
logger.info(
|
|
59
|
+
"No container engine available; gate commands will run locally."
|
|
60
|
+
)
|
|
61
|
+
self._engine = None
|
|
62
|
+
return False
|
|
63
|
+
logger.info(
|
|
64
|
+
f"Using container engine '{engine_name}' with image '{self.image}'."
|
|
65
|
+
)
|
|
66
|
+
self._engine = ContainerEngine(
|
|
67
|
+
engine_name,
|
|
68
|
+
self.image,
|
|
69
|
+
self.project_path,
|
|
70
|
+
self.mount_path,
|
|
71
|
+
network=self.network,
|
|
72
|
+
memory=self.memory,
|
|
73
|
+
cpus=self.cpus,
|
|
74
|
+
pids_limit=self.pids_limit,
|
|
75
|
+
cap_drop=self.cap_drop,
|
|
76
|
+
security_opt=self.security_opt,
|
|
77
|
+
)
|
|
78
|
+
return True
|
|
79
|
+
|
|
80
|
+
def engine(self) -> Optional[ContainerEngine]:
|
|
81
|
+
"""The detected engine (after :meth:`setup`), or ``None`` if none."""
|
|
82
|
+
return self._engine
|
|
83
|
+
|
|
84
|
+
def activate_command(self) -> str:
|
|
85
|
+
"""No host-side activation: the image is the toolchain. The empty prefix
|
|
86
|
+
means local fallback commands run unmodified."""
|
|
87
|
+
return ""
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from misterdev.environments.base_env import BaseEnvironmentManager
|
|
5
|
+
from misterdev.logging_setup import setup_logger
|
|
6
|
+
|
|
7
|
+
logger = setup_logger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class VenvEnvironmentManager(BaseEnvironmentManager):
|
|
11
|
+
def __init__(self, config: dict, project_path: str | Path):
|
|
12
|
+
super().__init__(config, project_path)
|
|
13
|
+
self.root_dir = self.config.get("root_dir", ".venv")
|
|
14
|
+
self.venv_path = self.project_path / self.root_dir
|
|
15
|
+
|
|
16
|
+
def setup(self) -> bool:
|
|
17
|
+
"""Sets up the venv if it doesn't exist."""
|
|
18
|
+
if self.venv_path.exists():
|
|
19
|
+
logger.info(f"Venv already exists at {self.venv_path}")
|
|
20
|
+
return True
|
|
21
|
+
|
|
22
|
+
logger.info(f"Setting up venv at {self.venv_path}")
|
|
23
|
+
setup_commands = self.config.get(
|
|
24
|
+
"setup_commands", [f"python -m venv {self.root_dir}"]
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
for cmd_template in setup_commands:
|
|
28
|
+
command = cmd_template.format(root_dir=self.root_dir)
|
|
29
|
+
logger.info(f"Running env setup command: {command}")
|
|
30
|
+
try:
|
|
31
|
+
subprocess.run(command, shell=True, cwd=self.project_path, check=True)
|
|
32
|
+
except subprocess.CalledProcessError as e:
|
|
33
|
+
logger.error(f"Failed to setup environment: {e}")
|
|
34
|
+
return False
|
|
35
|
+
return True
|
|
36
|
+
|
|
37
|
+
def activate_command(self) -> str:
|
|
38
|
+
"""Returns the activation command."""
|
|
39
|
+
activate_script = self.config.get(
|
|
40
|
+
"activate_script", "source {root_dir}/bin/activate"
|
|
41
|
+
)
|
|
42
|
+
return activate_script.format(root_dir=self.root_dir)
|
|
File without changes
|