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