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,194 @@
1
+ import time
2
+ from typing import Optional
3
+
4
+ from rich.console import Console
5
+
6
+ from misterdev.analyzers.project_analyzer import has_test_files
7
+ from misterdev.config import get_setting
8
+ from misterdev.logging_setup import setup_logger
9
+
10
+ logger = setup_logger(__name__)
11
+ console = Console()
12
+
13
+
14
+ class _WorktreeProjectView:
15
+ """A Project facade that overrides only `path` (for worktree execution).
16
+
17
+ Everything else (config, llm_client, topography, tools, env) delegates to
18
+ the base project, so the executor reads shared context but writes, builds,
19
+ and commits inside the worktree.
20
+ """
21
+
22
+ def __init__(self, base, path):
23
+ self._base = base
24
+ self.path = path
25
+
26
+ def __getattr__(self, name):
27
+ return getattr(self._base, name)
28
+
29
+
30
+ class ProgressReporter:
31
+ """Lightweight wave/task progress logger for long runs."""
32
+
33
+ def __init__(self, total_tasks: int):
34
+ self.total = total_tasks
35
+ self.completed = 0
36
+ self.failed = 0
37
+ self.current_wave = 0
38
+ self.start_time = time.time()
39
+ self._task_start: Optional[float] = None
40
+
41
+ def start_wave(self, wave_num: int, task_ids: list[str]):
42
+ self.current_wave = wave_num
43
+ logger.info(f"=== Wave {wave_num} === [{', '.join(task_ids)}]")
44
+
45
+ def start_task(self, task_id: str, title: str):
46
+ self._task_start = time.time()
47
+ logger.info(
48
+ f"[{self.completed + self.failed}/{self.total}] Starting {task_id}: {title}"
49
+ )
50
+
51
+ def end_task(self, task_id: str, success: bool):
52
+ elapsed = time.time() - self._task_start if self._task_start else 0
53
+ if success:
54
+ self.completed += 1
55
+ logger.info(
56
+ f"[{self.completed + self.failed}/{self.total}] {task_id} DONE ({elapsed:.0f}s)"
57
+ )
58
+ else:
59
+ self.failed += 1
60
+ logger.warning(
61
+ f"[{self.completed + self.failed}/{self.total}] {task_id} FAILED ({elapsed:.0f}s)"
62
+ )
63
+
64
+ def summary(self):
65
+ total_time = time.time() - self.start_time
66
+ logger.info(
67
+ f"=== Complete: {self.completed} done, {self.failed} failed, {total_time:.0f}s total ==="
68
+ )
69
+
70
+
71
+ def _combine_commands(*cmds: Optional[str]) -> Optional[str]:
72
+ """Join shell commands with ``&&`` (each parenthesised), or None if all empty.
73
+
74
+ Used to run the visible suite and the golden suite as one pass/fail check
75
+ so the existing single-command gate/bisect logic enforces both.
76
+ """
77
+ present = [c for c in cmds if c]
78
+ if not present:
79
+ return None
80
+ return " && ".join(f"({c})" for c in present)
81
+
82
+
83
+ def _budget_exhausted(client) -> bool:
84
+ """True when the client reports a non-positive remaining budget.
85
+
86
+ Defensive: a non-numeric ``budget_remaining`` (e.g. a test double) is not
87
+ treated as exhausted.
88
+ """
89
+ remaining = getattr(client, "budget_remaining", None)
90
+ return (
91
+ isinstance(remaining, (int, float))
92
+ and not isinstance(remaining, bool)
93
+ and remaining <= 0
94
+ )
95
+
96
+
97
+ def _apply_budget_ceiling(client, flag_budget: float) -> None:
98
+ """Set the client budget to the tighter of its config budget and the flag.
99
+
100
+ The project.yaml budget is already on the client; both it and the --budget
101
+ flag are ceilings, so the minimum wins and a config cap is never silently
102
+ overridden by the CLI default. Falls back to the flag when the current
103
+ budget isn't numeric (e.g. a test double).
104
+ """
105
+ current = getattr(client, "_budget", None)
106
+ if isinstance(current, (int, float)) and not isinstance(current, bool):
107
+ client._budget = min(current, flag_budget)
108
+ else:
109
+ client._budget = flag_budget
110
+
111
+
112
+ def _warn_if_baseline_broken(assessment, report) -> None:
113
+ """Loudly surface a failing baseline build before any work begins.
114
+
115
+ A red baseline silently disables the integration gate (it needs a green
116
+ baseline to detect regressions), so the run would execute largely ungated.
117
+ Make that visible and record it rather than letting it pass quietly.
118
+ """
119
+ health = assessment.health
120
+ if health.builds:
121
+ return
122
+ head = (health.build_output or "").strip()[:600]
123
+ msg = (
124
+ "Baseline build FAILS — the integration gate disables itself without a "
125
+ "green baseline, so this run will be largely ungated. Fix the build first "
126
+ "(consider debug mode). Build error:\n" + (head or "(no output captured)")
127
+ )
128
+ logger.error(msg)
129
+ console.print(f"[red]Baseline build is failing.[/] {head[:200]}")
130
+ report.key_decisions.append(
131
+ "WARNING: baseline build was failing at start; gates degraded for this run"
132
+ )
133
+
134
+
135
+ def _warn_if_no_test_gate(assessment, project, report) -> None:
136
+ """Loudly surface that a run will proceed with NO test gate while the project
137
+ has a test suite — the safety hole that let an ungated run rewrite existing
138
+ tests while reporting build OK. Detection covers most layouts; this catches
139
+ the residual case where a suite exists but no command was resolved.
140
+ """
141
+ if assessment.structure.test_command:
142
+ return
143
+ # Multi-target repos gate per sub-project, so an empty top-level command is
144
+ # not "no gate": declared targets with a build/test command (or auto-target
145
+ # discovery) provide the protection. Don't cry wolf in that case.
146
+ cfg = getattr(project, "config", {}) or {}
147
+ targets = cfg.get("targets") or []
148
+ # `targets` is an open, non-schema-validated config list, so a malformed
149
+ # project.yaml could hold non-dict entries; guard so this advisory warning
150
+ # never aborts the build with an AttributeError.
151
+ if any(
152
+ isinstance(t, dict) and (t.get("test_command") or t.get("build_command"))
153
+ for t in targets
154
+ ):
155
+ return
156
+ if get_setting(cfg, "orchestrator", "auto_targets"):
157
+ return
158
+ if not has_test_files(project.path):
159
+ return
160
+ msg = (
161
+ "No test command was detected, but this project HAS test files. The run "
162
+ "will proceed with only a build/syntax gate, so existing tests will NOT "
163
+ "protect against regressions and edits to them will not be caught. Set "
164
+ "`test_command` in project.yaml to enable the test gate."
165
+ )
166
+ logger.warning(msg)
167
+ console.print(f"[yellow]No test gate:[/] {msg}")
168
+ report.degraded_subsystems.append(
169
+ "No test gate: test files exist but no test command was detected"
170
+ )
171
+
172
+
173
+ def _check_golden_config(config) -> None:
174
+ """Warn when the golden suite is half-configured (a silent integrity hole).
175
+
176
+ The two halves are independent: ``golden_paths`` protects+conceals the
177
+ files, ``golden_command`` enforces them as a gate. Configuring one without
178
+ the other silently drops a guarantee, so surface it loudly.
179
+ """
180
+ orch = config.get("orchestrator", {})
181
+ paths = orch.get("golden_paths") or []
182
+ command = orch.get("golden_command")
183
+ if command and not paths:
184
+ logger.warning(
185
+ "golden_command is set but golden_paths is empty: golden tests are "
186
+ "enforced as a gate but NOT protected from edits; the model could "
187
+ "weaken them. Set golden_paths to the same files."
188
+ )
189
+ if paths and not command:
190
+ logger.warning(
191
+ "golden_paths is set but golden_command is empty: golden files are "
192
+ "protected and hidden but never run as a gate. Set golden_command "
193
+ "to enforce them."
194
+ )
File without changes
@@ -0,0 +1,246 @@
1
+ """Project analyzer ported from /build Phase 1.
2
+
3
+ Uses LLM to analyze project structure, completeness, and context,
4
+ then merges results into a ProjectAssessment. In /build these run
5
+ as 3 parallel Claude sub-agents; here they are 3 sequential LLM
6
+ calls (or concurrent via threading if desired).
7
+ """
8
+
9
+ import json
10
+ from concurrent.futures import ThreadPoolExecutor, as_completed
11
+ from pathlib import Path
12
+ from typing import Optional
13
+
14
+ from misterdev.core.planning.assessment import ProjectAssessment
15
+ from misterdev.core.verification.validator import run_health_check
16
+ from misterdev.llm.client import BaseLLMClient
17
+ from misterdev.logging_setup import setup_logger
18
+
19
+ from .detection import (
20
+ detect_build_command,
21
+ detect_test_command,
22
+ has_test_files,
23
+ _file_mentions,
24
+ _has_node_tests,
25
+ _has_python_tests,
26
+ _json_has_test_script,
27
+ )
28
+ from .merge import (
29
+ _as_int,
30
+ _as_str_list,
31
+ _health_ground_truth,
32
+ _merge_completeness,
33
+ _merge_context,
34
+ _merge_debt_risk,
35
+ _merge_structure,
36
+ )
37
+ from .overview import (
38
+ _IGNORE_DIRS,
39
+ _INTENT_KEYWORDS,
40
+ _OVERVIEW_CODE_EXTS,
41
+ _get_file_listing,
42
+ _get_git_log,
43
+ _get_source_overview,
44
+ _leading_doc,
45
+ _read_config_files,
46
+ _read_docs,
47
+ _read_file_safe,
48
+ _walk_limited,
49
+ )
50
+ from .prompts import (
51
+ COMPLETENESS_PROMPT,
52
+ CONTEXT_PROMPT,
53
+ DEBT_RISK_PROMPT,
54
+ STRUCTURE_PROMPT,
55
+ )
56
+
57
+ logger = setup_logger(__name__)
58
+
59
+
60
+ def analyze_project(
61
+ project_path: Path,
62
+ llm_client: BaseLLMClient,
63
+ build_command: Optional[str] = None,
64
+ test_command: Optional[str] = None,
65
+ lint_command: Optional[str] = None,
66
+ env_activate: Optional[str] = None,
67
+ parallel: bool = True,
68
+ build_timeout: Optional[int] = None,
69
+ test_timeout: Optional[int] = None,
70
+ lint_timeout: Optional[int] = None,
71
+ project_outline: Optional[str] = None,
72
+ ) -> ProjectAssessment:
73
+ """Run all Phase 1 analyses and merge into a ProjectAssessment.
74
+
75
+ ``project_outline``, when supplied, is the project's already-built symbol
76
+ outline (its TopographyEngine graph); passing it avoids parsing a second
77
+ throwaway symbol graph just for the source overview.
78
+ """
79
+ assessment = ProjectAssessment()
80
+
81
+ # Gather raw project info for prompts
82
+ file_listing = _get_file_listing(project_path)
83
+ config_contents = _read_config_files(project_path)
84
+ docs = _read_docs(project_path)
85
+ source_overview = _get_source_overview(project_path, outline=project_outline)
86
+ readme = _read_file_safe(project_path / "README.md")
87
+ claude_md = _read_file_safe(project_path / "CLAUDE.md")
88
+ git_log = _get_git_log(project_path)
89
+
90
+ # Run the health check FIRST, using reliable config + deterministic
91
+ # detection (not LLM-guessed commands), so the completeness analyzer is
92
+ # grounded in what actually builds and passes — otherwise it reads the
93
+ # from-scratch docs and hallucinates that implemented features are missing.
94
+ bc = build_command or detect_build_command(project_path)
95
+ tc = test_command or detect_test_command(project_path)
96
+ logger.info(
97
+ "Running health check (build + tests) to ground the analysis; "
98
+ "this can take a few minutes on a large project..."
99
+ )
100
+ assessment.health = run_health_check(
101
+ project_path,
102
+ bc,
103
+ tc,
104
+ lint_command,
105
+ env_activate=env_activate,
106
+ build_timeout=build_timeout,
107
+ test_timeout=test_timeout,
108
+ lint_timeout=lint_timeout,
109
+ )
110
+ assessment.structure.build_command = bc
111
+ assessment.structure.test_command = tc
112
+ health_ground = _health_ground_truth(assessment.health)
113
+
114
+ def analyze_structure():
115
+ prompt = STRUCTURE_PROMPT.format(
116
+ file_listing=file_listing,
117
+ config_contents=config_contents,
118
+ )
119
+ return _call_llm_json(llm_client, prompt, "project structure analyzer")
120
+
121
+ def analyze_completeness():
122
+ prompt = COMPLETENESS_PROMPT.format(
123
+ docs=docs,
124
+ source_overview=source_overview,
125
+ health_ground=health_ground,
126
+ )
127
+ return _call_llm_json(llm_client, prompt, "completeness analyzer")
128
+
129
+ def analyze_context():
130
+ prompt = CONTEXT_PROMPT.format(
131
+ readme=readme,
132
+ config=claude_md or config_contents,
133
+ git_log=git_log,
134
+ )
135
+ return _call_llm_json(llm_client, prompt, "context analyzer")
136
+
137
+ def analyze_debt_risk(current_summary: str):
138
+ prompt = DEBT_RISK_PROMPT.format(
139
+ assessment_summary=current_summary,
140
+ source_overview=source_overview,
141
+ )
142
+ return _call_llm_json(llm_client, prompt, "debt and risk analyzer")
143
+
144
+ # Phase 1a-1c: run analyses (parallel or sequential)
145
+ results = {}
146
+ if parallel:
147
+ with ThreadPoolExecutor(max_workers=4) as pool:
148
+ futures = {
149
+ pool.submit(analyze_structure): "structure",
150
+ pool.submit(analyze_completeness): "completeness",
151
+ pool.submit(analyze_context): "context",
152
+ }
153
+ for future in as_completed(futures):
154
+ key = futures[future]
155
+ try:
156
+ results[key] = future.result()
157
+ except Exception as e:
158
+ logger.error(f"Analysis failed for {key}: {e}")
159
+ results[key] = {}
160
+
161
+ # Merge preliminary results to feed into debt/risk analyzer
162
+ _merge_structure(assessment, results.get("structure", {}))
163
+ _merge_completeness(assessment, results.get("completeness", {}))
164
+ _merge_context(assessment, results.get("context", {}))
165
+
166
+ future_debt = pool.submit(analyze_debt_risk, assessment.summary())
167
+ results["debt_risk"] = future_debt.result()
168
+ else:
169
+ results["structure"] = analyze_structure()
170
+ results["completeness"] = analyze_completeness()
171
+ results["context"] = analyze_context()
172
+ _merge_structure(assessment, results.get("structure", {}))
173
+ _merge_completeness(assessment, results.get("completeness", {}))
174
+ _merge_context(assessment, results.get("context", {}))
175
+ results["debt_risk"] = analyze_debt_risk(assessment.summary())
176
+
177
+ # Phase 1d: merge remaining into assessment
178
+ _merge_debt_risk(assessment, results.get("debt_risk", {}))
179
+
180
+ # Health already ran (before the analyzers, to ground them). Re-assert the
181
+ # deterministic commands in case the structure analyzer overwrote them with
182
+ # nulls during merge, since downstream safety gates read them.
183
+ if not assessment.structure.test_command:
184
+ assessment.structure.test_command = tc
185
+ if not assessment.structure.build_command:
186
+ assessment.structure.build_command = bc
187
+
188
+ logger.info(f"Assessment complete: {assessment.summary()}")
189
+ return assessment
190
+
191
+
192
+ def _call_llm_json(llm_client: BaseLLMClient, prompt: str, role: str) -> dict:
193
+ """Call LLM and parse JSON response."""
194
+ logger.info(f"Running {role}...")
195
+ try:
196
+ response = llm_client.generate_code(
197
+ prompt, f"You are a {role}. Return only valid JSON."
198
+ )
199
+ text = response.strip()
200
+ if text.startswith("```"):
201
+ lines = text.split("\n")
202
+ lines = [ln for ln in lines if not ln.strip().startswith("```")]
203
+ text = "\n".join(lines)
204
+ return json.loads(text)
205
+ except json.JSONDecodeError:
206
+ logger.warning(f"{role} returned non-JSON response")
207
+ return {}
208
+ except Exception as e:
209
+ logger.error(f"{role} failed: {e}")
210
+ return {}
211
+
212
+
213
+ __all__ = [
214
+ "STRUCTURE_PROMPT",
215
+ "COMPLETENESS_PROMPT",
216
+ "CONTEXT_PROMPT",
217
+ "DEBT_RISK_PROMPT",
218
+ "analyze_project",
219
+ "_call_llm_json",
220
+ "detect_test_command",
221
+ "detect_build_command",
222
+ "has_test_files",
223
+ "_has_python_tests",
224
+ "_has_node_tests",
225
+ "_file_mentions",
226
+ "_json_has_test_script",
227
+ "_health_ground_truth",
228
+ "_as_str_list",
229
+ "_as_int",
230
+ "_merge_structure",
231
+ "_merge_completeness",
232
+ "_merge_context",
233
+ "_merge_debt_risk",
234
+ "_IGNORE_DIRS",
235
+ "_OVERVIEW_CODE_EXTS",
236
+ "_INTENT_KEYWORDS",
237
+ "_walk_limited",
238
+ "_get_file_listing",
239
+ "_read_config_files",
240
+ "_read_docs",
241
+ "_leading_doc",
242
+ "_get_source_overview",
243
+ "_get_git_log",
244
+ "_read_file_safe",
245
+ "run_health_check",
246
+ ]
@@ -0,0 +1,146 @@
1
+ """Deterministic project-marker detection of build/test commands."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+
8
+ def detect_test_command(project_path: Path) -> Optional[str]:
9
+ """Detect a test command from project markers, independent of the LLM.
10
+
11
+ Returns a runnable command string, or None if no recognized test setup is
12
+ found. Used as a fallback when the structure analyzer leaves test_command
13
+ null, which would otherwise leave the suite un-run and the health check
14
+ blind (reporting tests=none on a project with a passing suite).
15
+ """
16
+ p = project_path
17
+ # Python FIRST, but only on a genuine Python signal — a bare ``tests/`` dir
18
+ # is NOT pytest (Rust uses tests/ for integration tests, Node for its own
19
+ # runner), so it must not shadow the language-specific runners below.
20
+ if _has_python_tests(p):
21
+ return "uv run pytest -q" if (p / "uv.lock").exists() else "pytest -q"
22
+ if (p / "Cargo.toml").exists():
23
+ return "cargo test"
24
+ # Node: an explicit `test` script wins; otherwise the built-in node test
25
+ # runner over a discoverable *.test.* suite (the case that, when missed, left
26
+ # a real suite ungated and silently rewritten).
27
+ if (p / "package.json").exists():
28
+ if _json_has_test_script(p / "package.json"):
29
+ return "npm test"
30
+ if _has_node_tests(p):
31
+ return "node --test"
32
+ if (p / "Package.swift").exists():
33
+ return "swift test"
34
+ # .NET: a solution runs every test project; a bare test csproj runs itself.
35
+ if any(p.glob("*.sln")) or any(p.glob("*[Tt]est*.csproj")):
36
+ return "dotnet test"
37
+ # GTK/meson tests run against a configured build dir; cmake/C++ via ctest.
38
+ if (p / "meson.build").exists():
39
+ return "meson test -C build"
40
+ if (p / "CMakeLists.txt").exists():
41
+ return "ctest --test-dir build --output-on-failure"
42
+ if (p / "go.mod").exists():
43
+ return "go test ./..."
44
+ return None
45
+
46
+
47
+ def _has_python_tests(p: Path) -> bool:
48
+ """True when ``p`` looks like a Python project with a pytest-runnable suite.
49
+
50
+ Explicit pytest config is definitive. A ``tests/`` directory only counts when
51
+ it holds Python test files OR the project is clearly Python (uv.lock /
52
+ pyproject.toml / setup.py / setup.cfg) — so a Node or Rust project that merely
53
+ uses ``tests/`` for its own framework is never misrouted to pytest.
54
+ """
55
+ if (
56
+ (p / "pytest.ini").exists()
57
+ or (p / "conftest.py").exists()
58
+ or _file_mentions(p / "pyproject.toml", "pytest")
59
+ or _file_mentions(p / "setup.cfg", "pytest")
60
+ ):
61
+ return True
62
+ is_python_project = (
63
+ (p / "uv.lock").exists()
64
+ or (p / "pyproject.toml").exists()
65
+ or (p / "setup.py").exists()
66
+ or (p / "setup.cfg").exists()
67
+ )
68
+ for d in (p / "tests", p / "test"):
69
+ if d.is_dir():
70
+ if any(d.rglob("test_*.py")) or any(d.rglob("*_test.py")):
71
+ return True
72
+ if is_python_project:
73
+ return True
74
+ return False
75
+
76
+
77
+ def _has_node_tests(p: Path) -> bool:
78
+ """True when a ``test/``/``tests/`` dir holds node-runner test files.
79
+
80
+ Looks only for the unambiguous ``*.test.{js,mjs,cjs}`` naming the built-in
81
+ ``node --test`` runner discovers, and only inside conventional test dirs so
82
+ ``node_modules`` is never scanned.
83
+ """
84
+ for d in (p / "test", p / "tests"):
85
+ if d.is_dir():
86
+ for pat in ("*.test.js", "*.test.mjs", "*.test.cjs"):
87
+ if any(d.rglob(pat)):
88
+ return True
89
+ return False
90
+
91
+
92
+ def has_test_files(project_path: Path) -> bool:
93
+ """True when the project appears to contain a test suite of any kind.
94
+
95
+ Used to warn when a build is about to run with no test gate even though tests
96
+ exist — the safety hole behind a real run that rewrote an ungated suite.
97
+ """
98
+ p = project_path
99
+ if _has_python_tests(p) or _has_node_tests(p):
100
+ return True
101
+ for d in (p / "tests", p / "test"):
102
+ if d.is_dir() and any(d.rglob("*")):
103
+ return True
104
+ return False
105
+
106
+
107
+ def detect_build_command(project_path: Path) -> Optional[str]:
108
+ """Detect a build/compile-check command from project markers."""
109
+ p = project_path
110
+ if (p / "Cargo.toml").exists():
111
+ return "cargo build"
112
+ if (p / "package.json").exists():
113
+ # Prefer a `typecheck` script: for a TS project it's the fast, deterministic
114
+ # gate (tsc --noEmit), whereas `build` often runs heavy bundling/wasm.
115
+ if _json_has_test_script(p / "package.json", key="typecheck"):
116
+ return "npm run typecheck"
117
+ if _json_has_test_script(p / "package.json", key="build"):
118
+ return "npm run build"
119
+ if (p / "Package.swift").exists():
120
+ return "swift build"
121
+ if any(p.glob("*.sln")) or any(p.glob("*.csproj")):
122
+ return "dotnet build"
123
+ if (p / "meson.build").exists():
124
+ return "meson compile -C build"
125
+ if (p / "CMakeLists.txt").exists():
126
+ return "cmake --build build"
127
+ if (p / "Makefile").exists() or (p / "makefile").exists():
128
+ return "make"
129
+ if (p / "pyproject.toml").exists() or (p / "setup.py").exists():
130
+ return "python -m compileall -q ."
131
+ return None
132
+
133
+
134
+ def _file_mentions(path: Path, needle: str) -> bool:
135
+ try:
136
+ return needle in path.read_text(encoding="utf-8")
137
+ except OSError:
138
+ return False
139
+
140
+
141
+ def _json_has_test_script(path: Path, key: str = "test") -> bool:
142
+ try:
143
+ data = json.loads(path.read_text(encoding="utf-8"))
144
+ except (OSError, json.JSONDecodeError):
145
+ return False
146
+ return bool(data.get("scripts", {}).get(key))