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