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,631 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Callable, List, Optional, Dict, Tuple, TYPE_CHECKING
|
|
3
|
+
|
|
4
|
+
from misterdev.logging_setup import setup_logger
|
|
5
|
+
from misterdev.core.planning.assessment import HealthCheck
|
|
6
|
+
from misterdev.core.gitcmd import run_git
|
|
7
|
+
from misterdev.core.verification.validator import _run_cmd
|
|
8
|
+
|
|
9
|
+
from .constants import (
|
|
10
|
+
BANNED_MARKERS,
|
|
11
|
+
SECRET_PATTERNS,
|
|
12
|
+
SECRET_REGEXES,
|
|
13
|
+
ASSIGNMENT_SECRET_KEYS,
|
|
14
|
+
ENV_REFERENCE_MARKERS,
|
|
15
|
+
SKIP_DIRS,
|
|
16
|
+
CODE_EXTENSIONS,
|
|
17
|
+
SECRET_SCAN_EXTENSIONS,
|
|
18
|
+
SECRET_SCAN_FILENAMES,
|
|
19
|
+
ENV_LITERAL_EXTENSIONS,
|
|
20
|
+
)
|
|
21
|
+
from .helpers import _path_in_scope, _read_capped
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from misterdev.core.execution.container import ContainerEngine
|
|
25
|
+
|
|
26
|
+
logger = setup_logger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class GateKeeper:
|
|
30
|
+
"""Implements the gate sequence for project validation.
|
|
31
|
+
|
|
32
|
+
Gates:
|
|
33
|
+
G1: Build compiles
|
|
34
|
+
G2: Lint passes
|
|
35
|
+
G3: Tests pass
|
|
36
|
+
G3.5: Golden suite (model-blind, immutable; if configured)
|
|
37
|
+
G3.6: Mutation-score gate (optional; suite must kill injected faults)
|
|
38
|
+
G4: Type check (if available)
|
|
39
|
+
G4.5: LSP semantic diagnostics (optional)
|
|
40
|
+
G4.6: Runtime smoke (optional)
|
|
41
|
+
G4.7: Web verification, headless browser (optional)
|
|
42
|
+
G4.8: Vision verification, VLM judgment (optional)
|
|
43
|
+
G5: Completeness scan (no banned markers in source)
|
|
44
|
+
G6: Secrets scan (no leaked credentials, incl. config/env files)
|
|
45
|
+
G9: Diff hygiene (no debug artifacts in staged + unstaged changes)
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
project_path: Path,
|
|
51
|
+
env_activate: Optional[str] = None,
|
|
52
|
+
build_timeout: int = 180,
|
|
53
|
+
test_timeout: int = 180,
|
|
54
|
+
lint_timeout: Optional[int] = None,
|
|
55
|
+
lsp_diagnostics: bool = False,
|
|
56
|
+
lsp_language: Optional[str] = None,
|
|
57
|
+
lsp_timeout: int = 30,
|
|
58
|
+
container: Optional["ContainerEngine"] = None,
|
|
59
|
+
mutation_gate: bool = False,
|
|
60
|
+
mutation_config: Optional[Dict] = None,
|
|
61
|
+
runtime_smoke: bool = False,
|
|
62
|
+
runtime_config: Optional[Dict] = None,
|
|
63
|
+
web_verify: bool = False,
|
|
64
|
+
vision_verify: bool = False,
|
|
65
|
+
vision_client=None,
|
|
66
|
+
):
|
|
67
|
+
self.project_path = Path(project_path)
|
|
68
|
+
self.env_activate = env_activate
|
|
69
|
+
# Optional container engine: when present and usable, gate commands
|
|
70
|
+
# (build/lint/test/golden/typecheck) run inside it instead of locally.
|
|
71
|
+
# Git stays host-side (never routed here). None -> run locally as before.
|
|
72
|
+
self.container = container if container and container.is_available() else None
|
|
73
|
+
# Optional mutation-score gate (off by default). mutation_config carries
|
|
74
|
+
# the top-level `mutation` mapping (command/min_score/timeout). Like the
|
|
75
|
+
# runtime gates it is timeout-bounded and SKIP-on-unparseable so it can
|
|
76
|
+
# never block a build except on a parsed score below the configured floor.
|
|
77
|
+
self.mutation_gate = mutation_gate
|
|
78
|
+
self.mutation_config = mutation_config or {}
|
|
79
|
+
# Honor the project's configured timeouts so a slow compiler or linter
|
|
80
|
+
# isn't falsely failed by the gate the way the analyzer once was.
|
|
81
|
+
self.build_timeout = build_timeout
|
|
82
|
+
self.test_timeout = test_timeout
|
|
83
|
+
self.lint_timeout = lint_timeout if lint_timeout is not None else test_timeout
|
|
84
|
+
self.lsp_diagnostics = lsp_diagnostics
|
|
85
|
+
self.lsp_language = lsp_language
|
|
86
|
+
self.lsp_timeout = lsp_timeout
|
|
87
|
+
# Optional runtime smoke gate (off by default). runtime_config carries
|
|
88
|
+
# the top-level `runtime` mapping; the smoke spec lives under its
|
|
89
|
+
# `smoke` key. Timeout-bounded so it can never block the build.
|
|
90
|
+
self.runtime_smoke = runtime_smoke
|
|
91
|
+
self.runtime_config = runtime_config or {}
|
|
92
|
+
# Optional web (headless-browser) and vision (VLM) verification gates,
|
|
93
|
+
# both off by default. Their specs live under the top-level `runtime`
|
|
94
|
+
# mapping (`runtime.web`, `runtime.vision`). Like the smoke gate they are
|
|
95
|
+
# daemon-threaded and timeout-bounded so they can never block the build;
|
|
96
|
+
# a SKIP (no/incomplete config, missing dep, timeout) never fails.
|
|
97
|
+
self.web_verify = web_verify
|
|
98
|
+
self.vision_verify = vision_verify
|
|
99
|
+
self.vision_client = vision_client
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def _runner(self) -> Optional[Callable[[str, int], Tuple[bool, str]]]:
|
|
103
|
+
"""The command runner for gate commands: the container's ``run`` when a
|
|
104
|
+
usable engine is attached, else ``None`` (local execution)."""
|
|
105
|
+
return self.container.run if self.container else None
|
|
106
|
+
|
|
107
|
+
def run_gates(
|
|
108
|
+
self, commands: Dict[str, Optional[str]]
|
|
109
|
+
) -> Tuple[bool, List[str], HealthCheck]:
|
|
110
|
+
"""Run the gate sequence. Returns (success, issues, final_health)."""
|
|
111
|
+
issues: List[str] = []
|
|
112
|
+
health = HealthCheck()
|
|
113
|
+
|
|
114
|
+
# G1: Build
|
|
115
|
+
build_cmd = commands.get("build_command")
|
|
116
|
+
if build_cmd:
|
|
117
|
+
success, output = _run_cmd(
|
|
118
|
+
build_cmd,
|
|
119
|
+
self.project_path,
|
|
120
|
+
self.env_activate,
|
|
121
|
+
timeout=self.build_timeout,
|
|
122
|
+
runner=self._runner,
|
|
123
|
+
)
|
|
124
|
+
health.builds = success
|
|
125
|
+
health.build_output = output
|
|
126
|
+
if not success:
|
|
127
|
+
issues.append("G1: Build failed")
|
|
128
|
+
return False, issues, health
|
|
129
|
+
else:
|
|
130
|
+
health.builds = True
|
|
131
|
+
|
|
132
|
+
# G2: Lint
|
|
133
|
+
lint_cmd = commands.get("lint_command")
|
|
134
|
+
if lint_cmd:
|
|
135
|
+
success, output = _run_cmd(
|
|
136
|
+
lint_cmd,
|
|
137
|
+
self.project_path,
|
|
138
|
+
self.env_activate,
|
|
139
|
+
timeout=self.lint_timeout,
|
|
140
|
+
runner=self._runner,
|
|
141
|
+
)
|
|
142
|
+
health.lint_clean = success
|
|
143
|
+
health.lint_output = output
|
|
144
|
+
if not success:
|
|
145
|
+
issues.append("G2: Lint warnings or errors")
|
|
146
|
+
else:
|
|
147
|
+
health.lint_clean = True
|
|
148
|
+
|
|
149
|
+
# G3: Tests
|
|
150
|
+
test_cmd = commands.get("test_command")
|
|
151
|
+
if test_cmd:
|
|
152
|
+
success, output = _run_cmd(
|
|
153
|
+
test_cmd,
|
|
154
|
+
self.project_path,
|
|
155
|
+
self.env_activate,
|
|
156
|
+
timeout=self.test_timeout,
|
|
157
|
+
runner=self._runner,
|
|
158
|
+
)
|
|
159
|
+
health.tests_pass = success
|
|
160
|
+
health.test_output = output
|
|
161
|
+
if not success:
|
|
162
|
+
issues.append("G3: Tests failed")
|
|
163
|
+
return False, issues, health
|
|
164
|
+
else:
|
|
165
|
+
health.tests_pass = True
|
|
166
|
+
|
|
167
|
+
# G3.5: Golden suite. Tests the model never sees and cannot edit, so a
|
|
168
|
+
# gamed visible suite can't hide a regression. Blocking like G1/G3.
|
|
169
|
+
golden_cmd = commands.get("golden_command")
|
|
170
|
+
if golden_cmd:
|
|
171
|
+
success, output = _run_cmd(
|
|
172
|
+
golden_cmd,
|
|
173
|
+
self.project_path,
|
|
174
|
+
self.env_activate,
|
|
175
|
+
timeout=self.test_timeout,
|
|
176
|
+
runner=self._runner,
|
|
177
|
+
)
|
|
178
|
+
if not success:
|
|
179
|
+
issues.append("G3.5: Golden suite failed")
|
|
180
|
+
health.tests_pass = False
|
|
181
|
+
# Golden tests are model-blind by design; their failure output
|
|
182
|
+
# must stay blind too. Otherwise the convergence fix-spec
|
|
183
|
+
# (_build_fix_spec) would feed golden assertions/values back to
|
|
184
|
+
# the model, letting it target the very tests it can't see. Keep
|
|
185
|
+
# the detail in the log (human-debuggable) and expose only a
|
|
186
|
+
# generic signal in the model-facing health output.
|
|
187
|
+
logger.warning(
|
|
188
|
+
"G3.5 golden suite failed; output withheld from the model "
|
|
189
|
+
"(model-blind). Detail:\n" + (output or "")[:2000]
|
|
190
|
+
)
|
|
191
|
+
health.test_output = (
|
|
192
|
+
"Golden suite failed (details withheld — the golden suite is "
|
|
193
|
+
"model-blind; see orchestrator logs)."
|
|
194
|
+
)
|
|
195
|
+
return False, issues, health
|
|
196
|
+
|
|
197
|
+
# G3.6: Mutation-score gate (optional, off by default). Runs the project's
|
|
198
|
+
# configured mutation command and asserts the parsed score meets a floor —
|
|
199
|
+
# catching a suite that passes but kills few injected faults. Best-effort
|
|
200
|
+
# and timeout-bounded: a SKIP (no config, unparseable score, timeout)
|
|
201
|
+
# never fails; only a parsed score below the floor is a RED that blocks.
|
|
202
|
+
if self.mutation_gate:
|
|
203
|
+
from misterdev.core.verification.mutation_gate import (
|
|
204
|
+
run_mutation_gate,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
mutation = run_mutation_gate(
|
|
208
|
+
self.project_path, self.mutation_config, runner=self._runner
|
|
209
|
+
)
|
|
210
|
+
if mutation.status == "red":
|
|
211
|
+
issues.append(
|
|
212
|
+
f"G3.6: Mutation score gate failed ({mutation.reason or 'no detail'})"
|
|
213
|
+
)
|
|
214
|
+
health.tests_pass = False
|
|
215
|
+
if mutation.evidence:
|
|
216
|
+
health.test_output = mutation.evidence
|
|
217
|
+
return False, issues, health
|
|
218
|
+
|
|
219
|
+
# G4: Type check (optional). Blocking like G1/G3: a configured
|
|
220
|
+
# typecheck command that fails short-circuits the gate so broken types
|
|
221
|
+
# can't slip through. When none is configured, skip with no penalty.
|
|
222
|
+
typecheck_cmd = commands.get("typecheck_command")
|
|
223
|
+
if typecheck_cmd:
|
|
224
|
+
success, _output = _run_cmd(
|
|
225
|
+
typecheck_cmd,
|
|
226
|
+
self.project_path,
|
|
227
|
+
self.env_activate,
|
|
228
|
+
timeout=self.test_timeout,
|
|
229
|
+
runner=self._runner,
|
|
230
|
+
)
|
|
231
|
+
if not success:
|
|
232
|
+
issues.append("G4: Type check failed")
|
|
233
|
+
return False, issues, health
|
|
234
|
+
|
|
235
|
+
# G4.5: LSP semantic diagnostics (optional, off by default). Catches
|
|
236
|
+
# errors a syntax check misses; best-effort and timeout-bounded, so a
|
|
237
|
+
# None result (unsupported/slow/unavailable) is a skip, not a failure.
|
|
238
|
+
if self.lsp_diagnostics and self.lsp_language:
|
|
239
|
+
from misterdev.core.context.lsp import (
|
|
240
|
+
collect_diagnostics,
|
|
241
|
+
find_source_files,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
files = find_source_files(self.project_path, self.lsp_language)
|
|
245
|
+
diags = collect_diagnostics(
|
|
246
|
+
self.project_path, self.lsp_language, files, self.lsp_timeout
|
|
247
|
+
)
|
|
248
|
+
if diags:
|
|
249
|
+
for d in diags[:5]:
|
|
250
|
+
issues.append(
|
|
251
|
+
f"G4.5: LSP error {d['file']}:{d['line']}: {d['message'][:80]}"
|
|
252
|
+
)
|
|
253
|
+
return False, issues, health
|
|
254
|
+
|
|
255
|
+
# G4.6: Runtime smoke gate (optional, off by default). Launches the
|
|
256
|
+
# built artifact and asserts it responds. Best-effort and
|
|
257
|
+
# timeout-bounded: a SKIP (no/incomplete config, timeout) never fails;
|
|
258
|
+
# only a RED (non-zero exit or missing expectation) blocks the build.
|
|
259
|
+
if self.runtime_smoke:
|
|
260
|
+
from misterdev.core.execution.runtime import run_smoke_gate
|
|
261
|
+
|
|
262
|
+
smoke = run_smoke_gate(self.project_path, self.runtime_config.get("smoke"))
|
|
263
|
+
if smoke.status == "red":
|
|
264
|
+
issues.append(
|
|
265
|
+
f"G4.6: Runtime smoke failed ({smoke.reason or 'no detail'})"
|
|
266
|
+
)
|
|
267
|
+
health.tests_pass = False
|
|
268
|
+
if smoke.evidence:
|
|
269
|
+
health.test_output = smoke.evidence
|
|
270
|
+
return False, issues, health
|
|
271
|
+
|
|
272
|
+
# G4.7: Web verification gate (optional, off by default). Drives a real
|
|
273
|
+
# headless browser against the running web artifact and runs declarative
|
|
274
|
+
# checks (DOM/text presence, no console errors, axe, screenshot diff).
|
|
275
|
+
# Best-effort and timeout-bounded: a SKIP (no/incomplete config, no
|
|
276
|
+
# Playwright/browser, timeout) never fails; only a RED (a failed check)
|
|
277
|
+
# blocks the build. Real screenshot evidence is captured.
|
|
278
|
+
web_evidence: Optional[str] = None
|
|
279
|
+
if self.web_verify:
|
|
280
|
+
from misterdev.core.verification.web_verify import (
|
|
281
|
+
run_web_gate,
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
web = run_web_gate(self.project_path, self.runtime_config.get("web"))
|
|
285
|
+
# The captured screenshot doubles as the vision gate's input below, so
|
|
286
|
+
# the two gates compose: web renders + captures, vision judges it.
|
|
287
|
+
web_evidence = web.evidence or None
|
|
288
|
+
if web.status == "red":
|
|
289
|
+
issues.append(f"G4.7: Web verify failed ({web.reason or 'no detail'})")
|
|
290
|
+
health.tests_pass = False
|
|
291
|
+
if web.evidence:
|
|
292
|
+
health.test_output = f"web evidence: {web.evidence}"
|
|
293
|
+
return False, issues, health
|
|
294
|
+
|
|
295
|
+
# G4.8: Vision verification gate (optional, off by default). Asks a
|
|
296
|
+
# vision model whether a captured screenshot satisfies a stated visual
|
|
297
|
+
# requirement. Best-effort and timeout-bounded: a SKIP (no/incomplete
|
|
298
|
+
# config, no model/network, unparseable verdict, timeout) never fails;
|
|
299
|
+
# only a RED (the model denies the assertion) blocks the build.
|
|
300
|
+
if self.vision_verify:
|
|
301
|
+
from misterdev.core.verification.vision_verify import (
|
|
302
|
+
run_vision_gate,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
# Default the screenshot to the web gate's freshly captured evidence
|
|
306
|
+
# when the vision config doesn't name its own ``capture``, so enabling
|
|
307
|
+
# both gates "just works" without duplicating the path. An explicit
|
|
308
|
+
# capture in config still wins.
|
|
309
|
+
vision_config = dict(self.runtime_config.get("vision") or {})
|
|
310
|
+
if not vision_config.get("capture") and web_evidence:
|
|
311
|
+
vision_config["capture"] = web_evidence
|
|
312
|
+
vision = run_vision_gate(
|
|
313
|
+
self.project_path,
|
|
314
|
+
vision_config or None,
|
|
315
|
+
llm_client=self.vision_client,
|
|
316
|
+
)
|
|
317
|
+
if vision.status == "red":
|
|
318
|
+
issues.append(
|
|
319
|
+
f"G4.8: Vision verify failed ({vision.reason or 'no detail'})"
|
|
320
|
+
)
|
|
321
|
+
health.tests_pass = False
|
|
322
|
+
return False, issues, health
|
|
323
|
+
|
|
324
|
+
# G5: Completeness scan
|
|
325
|
+
banned_found = self._scan_banned_markers()
|
|
326
|
+
if banned_found:
|
|
327
|
+
issues.append(f"G5: Banned markers found: {', '.join(banned_found)}")
|
|
328
|
+
|
|
329
|
+
# G6: Secrets scan
|
|
330
|
+
secrets_found = self._scan_secrets()
|
|
331
|
+
if secrets_found:
|
|
332
|
+
issues.append(f"G6: Possible secrets in {len(secrets_found)} file(s)")
|
|
333
|
+
for path in secrets_found[:5]:
|
|
334
|
+
logger.warning(f"G6: Possible secret in {path}")
|
|
335
|
+
|
|
336
|
+
# G9: Diff hygiene
|
|
337
|
+
diff_issues = self._check_diff_hygiene()
|
|
338
|
+
if diff_issues:
|
|
339
|
+
issues.extend(diff_issues)
|
|
340
|
+
|
|
341
|
+
# Plugin gates: third-party gates registered on misterdev.plugins.GATES
|
|
342
|
+
# run after the built-ins and can only ADD blocking issues (a RED
|
|
343
|
+
# outcome), never remove one.
|
|
344
|
+
issues.extend(self._run_plugin_gates(commands))
|
|
345
|
+
|
|
346
|
+
return len(issues) == 0, issues, health
|
|
347
|
+
|
|
348
|
+
def _run_plugin_gates(self, commands: Dict[str, Optional[str]]) -> List[str]:
|
|
349
|
+
"""Run each registered plugin gate; collect a blocking issue per RED.
|
|
350
|
+
|
|
351
|
+
A gate that raises or returns nothing is skipped (best-effort, like the
|
|
352
|
+
optional built-in gates) so a third-party gate can never break the build
|
|
353
|
+
pipeline itself — only fail it deliberately with a RED outcome.
|
|
354
|
+
"""
|
|
355
|
+
from misterdev.plugins import GATES
|
|
356
|
+
from misterdev.core.execution.outcomes import GateContext, RED
|
|
357
|
+
|
|
358
|
+
ctx = GateContext(self.project_path, commands, self.env_activate)
|
|
359
|
+
found: List[str] = []
|
|
360
|
+
for name in GATES.names():
|
|
361
|
+
gate = GATES.get(name)
|
|
362
|
+
try:
|
|
363
|
+
outcome = gate(ctx)
|
|
364
|
+
except Exception as e:
|
|
365
|
+
logger.warning(f"Plugin gate {name!r} raised, skipping: {e}")
|
|
366
|
+
continue
|
|
367
|
+
if outcome is not None and outcome.status == RED:
|
|
368
|
+
found.append(f"G-plugin[{name}]: {outcome.reason or 'failed'}")
|
|
369
|
+
return found
|
|
370
|
+
|
|
371
|
+
def _scan_banned_markers(self) -> List[str]:
|
|
372
|
+
"""G5: Scan ADDED lines for banned markers (TODO!, FIXME, HACK, etc.).
|
|
373
|
+
|
|
374
|
+
Only flags markers this build introduced. In a git repo we look at the
|
|
375
|
+
added lines of the diff (staged + unstaged vs HEAD); a pre-existing
|
|
376
|
+
marker in an untouched region of a modified file is left alone. Outside
|
|
377
|
+
a git repo there is no diff to scope to, so we fall back to scanning the
|
|
378
|
+
whole tree (the previous behavior) rather than skipping the gate.
|
|
379
|
+
"""
|
|
380
|
+
added = self._iter_diff_added_lines()
|
|
381
|
+
if added is None:
|
|
382
|
+
return self._scan_banned_markers_whole_tree()
|
|
383
|
+
found = set()
|
|
384
|
+
for path, line in added:
|
|
385
|
+
if not _path_in_scope(path, CODE_EXTENSIONS):
|
|
386
|
+
continue
|
|
387
|
+
for marker in BANNED_MARKERS:
|
|
388
|
+
if marker in line:
|
|
389
|
+
found.add(marker)
|
|
390
|
+
return sorted(found)
|
|
391
|
+
|
|
392
|
+
def _scan_banned_markers_whole_tree(self) -> List[str]:
|
|
393
|
+
"""Non-git fallback for G5: scan every source file."""
|
|
394
|
+
found = set()
|
|
395
|
+
for path in self._iter_source_files():
|
|
396
|
+
content = _read_capped(path)
|
|
397
|
+
if content is None:
|
|
398
|
+
continue
|
|
399
|
+
for marker in BANNED_MARKERS:
|
|
400
|
+
if marker in content:
|
|
401
|
+
found.add(marker)
|
|
402
|
+
return sorted(found)
|
|
403
|
+
|
|
404
|
+
def _scan_secrets(self) -> List[str]:
|
|
405
|
+
"""G6: Scan ADDED lines for patterns that look like leaked secrets.
|
|
406
|
+
|
|
407
|
+
Like G5, this is scoped to the diff in a git repo so only secrets this
|
|
408
|
+
build introduced are flagged, and falls back to a whole-tree scan when
|
|
409
|
+
not in a git repo. Returns the changed file paths that contain a secret.
|
|
410
|
+
"""
|
|
411
|
+
added = self._iter_diff_added_lines()
|
|
412
|
+
if added is None:
|
|
413
|
+
return self._scan_secrets_whole_tree()
|
|
414
|
+
# Group added lines per file, then reuse the existing content check so
|
|
415
|
+
# the multi-line / per-line secret detection stays identical.
|
|
416
|
+
by_file: Dict[str, List[str]] = {}
|
|
417
|
+
for path, line in added:
|
|
418
|
+
if not _path_in_scope(path, SECRET_SCAN_EXTENSIONS, SECRET_SCAN_FILENAMES):
|
|
419
|
+
continue
|
|
420
|
+
by_file.setdefault(path, []).append(line)
|
|
421
|
+
flagged = []
|
|
422
|
+
for path, lines in by_file.items():
|
|
423
|
+
if self._content_has_secret(
|
|
424
|
+
"\n".join(lines), is_env_file=self._is_env_literal_file(path)
|
|
425
|
+
):
|
|
426
|
+
flagged.append(path)
|
|
427
|
+
return flagged
|
|
428
|
+
|
|
429
|
+
def _scan_secrets_whole_tree(self) -> List[str]:
|
|
430
|
+
"""Non-git fallback for G6: scan every source and config/env file."""
|
|
431
|
+
flagged = []
|
|
432
|
+
for path in self._iter_source_files(
|
|
433
|
+
SECRET_SCAN_EXTENSIONS, SECRET_SCAN_FILENAMES
|
|
434
|
+
):
|
|
435
|
+
content = _read_capped(path)
|
|
436
|
+
if content is None:
|
|
437
|
+
continue
|
|
438
|
+
if self._content_has_secret(
|
|
439
|
+
content, is_env_file=self._is_env_literal_file(path)
|
|
440
|
+
):
|
|
441
|
+
flagged.append(str(path.relative_to(self.project_path)))
|
|
442
|
+
return flagged
|
|
443
|
+
|
|
444
|
+
def _iter_diff_added_lines(self) -> Optional[List[Tuple[str, str]]]:
|
|
445
|
+
"""Return ``(file_path, added_line)`` pairs from the git diff.
|
|
446
|
+
|
|
447
|
+
Covers both staged and unstaged changes against HEAD (the union of
|
|
448
|
+
``git diff --cached`` and ``git diff``), so any line this build added is
|
|
449
|
+
seen regardless of whether it was staged yet. Returns ``None`` when the
|
|
450
|
+
project is not a git repo, signalling callers to fall back to a
|
|
451
|
+
whole-tree scan. All added lines (``+``) are included regardless of file
|
|
452
|
+
type; the diff header lines (``+++``) are skipped. Callers apply their
|
|
453
|
+
own per-gate scope (code-only for G5/G9, code+config for G6).
|
|
454
|
+
"""
|
|
455
|
+
check = run_git("git rev-parse --is-inside-work-tree", self.project_path)
|
|
456
|
+
if check is None or check.returncode != 0 or check.stdout.strip() != "true":
|
|
457
|
+
return None
|
|
458
|
+
added: List[Tuple[str, str]] = []
|
|
459
|
+
for cmd in (
|
|
460
|
+
"git diff --cached --diff-filter=ACMR -U0",
|
|
461
|
+
"git diff --diff-filter=ACMR -U0",
|
|
462
|
+
):
|
|
463
|
+
proc = run_git(cmd, self.project_path)
|
|
464
|
+
if proc is None:
|
|
465
|
+
continue
|
|
466
|
+
current_file = ""
|
|
467
|
+
for line in proc.stdout.splitlines():
|
|
468
|
+
if line.startswith("+++ b/"):
|
|
469
|
+
current_file = line[len("+++ b/") :]
|
|
470
|
+
continue
|
|
471
|
+
if line.startswith("+++") or not line.startswith("+"):
|
|
472
|
+
continue
|
|
473
|
+
added.append((current_file, line[1:]))
|
|
474
|
+
# A brand-new file is untracked and never appears in `git diff`, yet its
|
|
475
|
+
# every line was introduced by this build. Enumerate untracked files
|
|
476
|
+
# (honoring .gitignore via --exclude-standard, so build artifacts are
|
|
477
|
+
# skipped) and treat their whole content as added lines.
|
|
478
|
+
others = run_git("git ls-files --others --exclude-standard", self.project_path)
|
|
479
|
+
if others is not None and others.returncode == 0:
|
|
480
|
+
for rel in others.stdout.splitlines():
|
|
481
|
+
rel = rel.strip()
|
|
482
|
+
if not rel:
|
|
483
|
+
continue
|
|
484
|
+
content = _read_capped(self.project_path / rel)
|
|
485
|
+
if content is None:
|
|
486
|
+
continue
|
|
487
|
+
for line in content.splitlines():
|
|
488
|
+
added.append((rel, line))
|
|
489
|
+
return added
|
|
490
|
+
|
|
491
|
+
@staticmethod
|
|
492
|
+
def _is_env_literal_file(path) -> bool:
|
|
493
|
+
"""True when ``path`` is an env/ini-style config file where ``KEY=value``
|
|
494
|
+
is a literal assignment (so an unquoted value may be a real secret)."""
|
|
495
|
+
p = Path(path)
|
|
496
|
+
return p.suffix in ENV_LITERAL_EXTENSIONS or p.name in SECRET_SCAN_FILENAMES
|
|
497
|
+
|
|
498
|
+
@staticmethod
|
|
499
|
+
def _content_has_secret(content: str, is_env_file: bool = False) -> bool:
|
|
500
|
+
for pattern in SECRET_PATTERNS:
|
|
501
|
+
if pattern in content:
|
|
502
|
+
return True
|
|
503
|
+
for rx in SECRET_REGEXES:
|
|
504
|
+
if rx.search(content):
|
|
505
|
+
return True
|
|
506
|
+
for line in content.splitlines():
|
|
507
|
+
if GateKeeper._is_secret_assignment(line):
|
|
508
|
+
return True
|
|
509
|
+
if is_env_file and GateKeeper._is_unquoted_env_secret(line):
|
|
510
|
+
return True
|
|
511
|
+
return False
|
|
512
|
+
|
|
513
|
+
@staticmethod
|
|
514
|
+
def _is_unquoted_env_secret(line: str) -> bool:
|
|
515
|
+
"""True for an UNQUOTED credential value in an env/ini-style config file.
|
|
516
|
+
|
|
517
|
+
Only used for ENV_LITERAL_EXTENSIONS files, where ``KEY=value`` is a
|
|
518
|
+
literal — never on source code, where an unquoted RHS is a variable
|
|
519
|
+
reference (``token = get_token()``) and flagging it would block real code.
|
|
520
|
+
The value must look like an actual secret: long, no whitespace, no
|
|
521
|
+
variable/template prefix, and mixing letters with digits — so ordinary
|
|
522
|
+
config (ports, hosts, booleans, ``${VAR}`` refs, plain words) is ignored.
|
|
523
|
+
"""
|
|
524
|
+
stripped = line.lstrip()
|
|
525
|
+
if not stripped or stripped[0] in ("#", ";"): # comment
|
|
526
|
+
return False
|
|
527
|
+
if "=" not in stripped:
|
|
528
|
+
return False
|
|
529
|
+
key_part, _, value = stripped.partition("=")
|
|
530
|
+
if not any(k in key_part.lower() for k in ASSIGNMENT_SECRET_KEYS):
|
|
531
|
+
return False
|
|
532
|
+
value = value.strip()
|
|
533
|
+
# Quoted literals are handled by _is_secret_assignment; here we want bare
|
|
534
|
+
# values only, and never a variable/template reference.
|
|
535
|
+
if (
|
|
536
|
+
not value
|
|
537
|
+
or value[0] in ('"', "'")
|
|
538
|
+
or value.startswith(("${", "$", "%", "{{"))
|
|
539
|
+
):
|
|
540
|
+
return False
|
|
541
|
+
if len(value) < 16 or any(c.isspace() for c in value) or "://" in value:
|
|
542
|
+
return False
|
|
543
|
+
return any(c.isalpha() for c in value) and any(c.isdigit() for c in value)
|
|
544
|
+
|
|
545
|
+
@staticmethod
|
|
546
|
+
def _is_secret_assignment(line: str) -> bool:
|
|
547
|
+
"""True only for a credential key assigned a concrete quoted literal.
|
|
548
|
+
|
|
549
|
+
Handles both ``key = "..."`` (source/ini/env) and ``key: "..."``
|
|
550
|
+
(YAML/JSON) forms. Skips variable/env references (``token = get_token()``,
|
|
551
|
+
``api_key = os.environ[...]``) and type annotations (``token: String``)
|
|
552
|
+
that would otherwise produce false positives on ordinary source, because
|
|
553
|
+
the value must be a quoted literal of >=6 chars that is not a ``${...}``
|
|
554
|
+
interpolation or an env reference.
|
|
555
|
+
"""
|
|
556
|
+
for sep in ("=", ":"):
|
|
557
|
+
if sep not in line:
|
|
558
|
+
continue
|
|
559
|
+
key_part, _, value = line.partition(sep)
|
|
560
|
+
if not any(k in key_part.lower() for k in ASSIGNMENT_SECRET_KEYS):
|
|
561
|
+
continue
|
|
562
|
+
value = value.strip()
|
|
563
|
+
for quote in ('"', "'"):
|
|
564
|
+
if value.startswith(quote):
|
|
565
|
+
literal = value[1:].split(quote, 1)[0]
|
|
566
|
+
low = literal.lower()
|
|
567
|
+
if (
|
|
568
|
+
len(literal) >= 6
|
|
569
|
+
and not literal.startswith("${")
|
|
570
|
+
and not any(m in low for m in ENV_REFERENCE_MARKERS)
|
|
571
|
+
):
|
|
572
|
+
return True
|
|
573
|
+
return False
|
|
574
|
+
|
|
575
|
+
def _check_diff_hygiene(self) -> List[str]:
|
|
576
|
+
"""G9: Check added lines (staged + unstaged) for debug artifacts.
|
|
577
|
+
|
|
578
|
+
Scoped to the same union diff as G5/G6 (``_iter_diff_added_lines``) so an
|
|
579
|
+
unstaged debug line is caught, not just a staged one. Code files only —
|
|
580
|
+
a ``print(`` in a config/doc file is not an artifact. Each distinct
|
|
581
|
+
marker is reported once even if it appears on several lines.
|
|
582
|
+
"""
|
|
583
|
+
added = self._iter_diff_added_lines()
|
|
584
|
+
if added is None:
|
|
585
|
+
return []
|
|
586
|
+
|
|
587
|
+
debug_markers = (
|
|
588
|
+
"console.log(",
|
|
589
|
+
"print(",
|
|
590
|
+
"println!",
|
|
591
|
+
"dbg!(",
|
|
592
|
+
"debugger",
|
|
593
|
+
"binding.pry",
|
|
594
|
+
"import pdb",
|
|
595
|
+
)
|
|
596
|
+
found: List[str] = []
|
|
597
|
+
for path, line in added:
|
|
598
|
+
if not _path_in_scope(path, CODE_EXTENSIONS):
|
|
599
|
+
continue
|
|
600
|
+
for marker in debug_markers:
|
|
601
|
+
if marker in line and marker not in found:
|
|
602
|
+
found.append(marker)
|
|
603
|
+
return [f"G9: Debug artifact in diff: {marker}" for marker in found]
|
|
604
|
+
|
|
605
|
+
def _iter_source_files(
|
|
606
|
+
self,
|
|
607
|
+
extensions: frozenset = CODE_EXTENSIONS,
|
|
608
|
+
filenames: frozenset = frozenset(),
|
|
609
|
+
):
|
|
610
|
+
"""Yield in-scope file paths, skipping excluded directories.
|
|
611
|
+
|
|
612
|
+
Defaults to source-code files; pass a wider ``extensions``/``filenames``
|
|
613
|
+
scope (e.g. for the G6 secret scan) to include config/env files.
|
|
614
|
+
"""
|
|
615
|
+
stack = [self.project_path]
|
|
616
|
+
while stack:
|
|
617
|
+
directory = stack.pop()
|
|
618
|
+
try:
|
|
619
|
+
entries = sorted(directory.iterdir())
|
|
620
|
+
except OSError:
|
|
621
|
+
continue
|
|
622
|
+
for entry in entries:
|
|
623
|
+
if entry.name in SKIP_DIRS:
|
|
624
|
+
continue
|
|
625
|
+
# Do not follow directory symlinks: a link back to an ancestor
|
|
626
|
+
# would make this walk loop forever, and a link outside the repo
|
|
627
|
+
# would scan unrelated files. Real subdirectories only.
|
|
628
|
+
if entry.is_dir() and not entry.is_symlink():
|
|
629
|
+
stack.append(entry)
|
|
630
|
+
elif entry.suffix in extensions or entry.name in filenames:
|
|
631
|
+
yield entry
|