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