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,308 @@
1
+ """Sovereign-tier orchestration capabilities.
2
+
3
+ Inspired by June 2026 arXiv research:
4
+ - Agentic Engineering: Code as transient resource (arXiv:2606.05608)
5
+ - Long-Horizon Reasoning: AB-MCTS (Sakana AI / Marlin, June 2026)
6
+ - Discordance-Aware Reasoning (CyberDrift, arXiv:2606.15101)
7
+ """
8
+
9
+ import json
10
+ import shutil
11
+ import subprocess
12
+ import sys
13
+ import uuid
14
+ from datetime import datetime, timezone
15
+ from pathlib import Path
16
+ from typing import Dict, List, Tuple
17
+
18
+ from misterdev.logging_setup import setup_logger
19
+ from misterdev.llm.client import BaseLLMClient
20
+ from misterdev.llm.responses import extract_json_array
21
+ from misterdev.utils.file_utils import safe_ref_slug
22
+
23
+ logger = setup_logger(__name__)
24
+
25
+
26
+ class EphemeralCodeManager:
27
+ """Manages code as a transient, instrumental resource.
28
+
29
+ In 'Agentic Engineering', code is often generated just to solve a specific
30
+ reasoning step and can be discarded once its goal is achieved.
31
+ """
32
+
33
+ def __init__(self, project_path: Path):
34
+ self.session_id = uuid.uuid4().hex[:8]
35
+ self.ephemeral_dir = (
36
+ project_path / ".orchestrator" / "ephemeral" / self.session_id
37
+ )
38
+ self.ephemeral_dir.mkdir(parents=True, exist_ok=True)
39
+
40
+ def run_ephemeral_script(self, code: str, name: str = "temp") -> Tuple[bool, str]:
41
+ """Executes a transient script and returns its output."""
42
+ # Sanitize the (LLM-supplied) name: a '/' or other path char would turn
43
+ # the filename into a non-existent subdir and raise on write, crashing
44
+ # the whole build from this best-effort probe step.
45
+ safe = safe_ref_slug(name, fallback="temp")
46
+ script_path = self.ephemeral_dir / f"{safe}_{uuid.uuid4().hex[:4]}.py"
47
+ logger.info(f"Executing ephemeral logic: {script_path.name}")
48
+ try:
49
+ self.ephemeral_dir.mkdir(parents=True, exist_ok=True)
50
+ script_path.write_text(code, encoding="utf-8")
51
+ res = subprocess.run(
52
+ [sys.executable, str(script_path)],
53
+ capture_output=True,
54
+ text=True,
55
+ timeout=60,
56
+ )
57
+ output = res.stdout
58
+ if res.stderr:
59
+ output += f"\nERR: {res.stderr}"
60
+ return res.returncode == 0, output
61
+ except subprocess.TimeoutExpired:
62
+ return False, "Ephemeral script timed out after 60s"
63
+ except Exception as e:
64
+ return False, str(e)
65
+
66
+ def cleanup(self):
67
+ """Discards all transient resources from this session."""
68
+ if self.ephemeral_dir.exists():
69
+ shutil.rmtree(self.ephemeral_dir, ignore_errors=True)
70
+ logger.info(f"Cleaned up ephemeral session {self.session_id}")
71
+
72
+ def __enter__(self) -> "EphemeralCodeManager":
73
+ return self
74
+
75
+ def __exit__(self, exc_type, exc_val, exc_tb) -> bool:
76
+ self.cleanup()
77
+ return False
78
+
79
+
80
+ class ABMCTSPlanner:
81
+ """Adaptive Branching Monte Carlo Tree Search for task planning.
82
+
83
+ Explores multiple reasoning branches to find the optimal execution path.
84
+ Skips branching for trivial/small tasks to save LLM calls.
85
+ """
86
+
87
+ def __init__(self, llm_client: BaseLLMClient):
88
+ self.llm = llm_client
89
+
90
+ def branch_and_evaluate(self, task: str, context: str, branches: int = 3) -> str:
91
+ """Simulates multiple implementation paths and selects the winner.
92
+
93
+ Returns the original task unchanged if branching is not worthwhile.
94
+ """
95
+ # Skip branching for short specs (trivial work)
96
+ if len(task.split()) < 50:
97
+ logger.info("AB-MCTS: Skipping branching (spec too short to benefit)")
98
+ return task
99
+
100
+ logger.info(f"AB-MCTS: Branching (n={branches})...")
101
+
102
+ simulations = []
103
+ for i in range(branches):
104
+ prompt = (
105
+ f"Simulation Path {i + 1}: Propose a unique implementation strategy "
106
+ f"for this task.\nContext: {context}\nTask: {task}"
107
+ )
108
+ strategy = self.llm.generate_code(
109
+ prompt, "You are a competitive systems architect."
110
+ )
111
+ simulations.append(strategy)
112
+
113
+ # Self-Evaluation / Discordance-Aware Selection
114
+ numbered = "\n".join(
115
+ f"--- PATH {i + 1} ---\n{s}" for i, s in enumerate(simulations)
116
+ )
117
+ eval_prompt = (
118
+ f"Evaluate these {branches} competing implementation strategies.\n"
119
+ f"Select the one with the highest 'Verifiability' and lowest 'Regression Risk'.\n\n"
120
+ f"Strategies:\n{numbered}\n\n"
121
+ f"Return ONLY the content of the selected strategy."
122
+ )
123
+ logger.info("AB-MCTS: Evaluating branches...")
124
+ return self.llm.generate_code(
125
+ eval_prompt, "You are a discordance-aware evaluator."
126
+ )
127
+
128
+
129
+ class ProbeGenerator:
130
+ """Synthesizes empirical fact-finding probes with high-rigor reflection.
131
+
132
+ Identifies 'assumptions' in the spec and generates scripts to verify them
133
+ using Python's 'inspect', 'ast', and 'dir' modules for reflective analysis.
134
+ """
135
+
136
+ def __init__(self, llm_client: BaseLLMClient):
137
+ self.llm = llm_client
138
+
139
+ def generate_probes(
140
+ self, spec: str, assessment_summary: str
141
+ ) -> List[Dict[str, str]]:
142
+ """Analyzes spec and generates a list of {name, purpose, script} probes."""
143
+ prompt = f"""Analyze this technical spec and project assessment.
144
+ Identify 1-3 critical 'assumptions' or 'unknowns' about the live codebase or environment.
145
+
146
+ Spec: {spec}
147
+ Assessment: {assessment_summary}
148
+
149
+ For each unknown, synthesize a transient Python 'Probe' script that uses REFLECTIVE techniques:
150
+ - Use 'inspect.signature()' to verify function/method arguments.
151
+ - Use 'inspect.getsource()' to see the actual logic.
152
+ - Use 'dir()' and 'getattr()' to probe runtime objects.
153
+ - Use 'ast.parse()' on local files to check structural definitions.
154
+
155
+ The script MUST print its findings clearly to stdout.
156
+
157
+ Return a JSON array: [{{"name": "...", "purpose": "...", "script": "..."}}]
158
+ Return ONLY the JSON array.
159
+ """
160
+ logger.info("Generating empirical probes...")
161
+ try:
162
+ response = self.llm.generate_code(
163
+ prompt, "You are a senior empirical researcher."
164
+ )
165
+ return extract_json_array(response)
166
+ except Exception as e:
167
+ logger.error(f"Failed to generate probes: {e}")
168
+
169
+ return []
170
+
171
+
172
+ class ToolSynthesizer:
173
+ """Synthesizes project-local helper tools on-the-fly."""
174
+
175
+ def __init__(self, project_path: Path):
176
+ self.tools_dir = project_path / ".orchestrator" / "synthesized_tools"
177
+ self.tools_dir.mkdir(parents=True, exist_ok=True)
178
+
179
+ def synthesize_tool(
180
+ self, name: str, purpose: str, llm_client: BaseLLMClient
181
+ ) -> str:
182
+ """Asks LLM to generate a Python script for a specific purpose."""
183
+ prompt = (
184
+ f"Synthesize a standalone Python script to serve as a local tool.\n"
185
+ f"Purpose: {purpose}\nTool Name: {name}\n\n"
186
+ f"Requirements:\n"
187
+ f"- Single file, standard library or project-available dependencies.\n"
188
+ f"- Executable as 'python tool.py'.\n"
189
+ f"- Return ONLY the code in a ```python code block."
190
+ )
191
+ logger.info(f"Synthesizing tool: {name}")
192
+ response = llm_client.generate_code(prompt, "You are a senior tools engineer.")
193
+
194
+ code = _extract_code_block(response)
195
+ tool_path = self.tools_dir / f"{safe_ref_slug(name, fallback='tool')}.py"
196
+ tool_path.write_text(code, encoding="utf-8")
197
+ logger.info(f"Tool {name} saved to {tool_path}")
198
+ return str(tool_path)
199
+
200
+
201
+ class StrategyOptimizer:
202
+ """Optimizes agentic workflows via strategy simulation.
203
+
204
+ Caches strategy per task category to avoid redundant LLM calls.
205
+ """
206
+
207
+ STRATEGIES = {
208
+ "surgical": "Search-then-Edit (Fast, minimal context)",
209
+ "iterative": "Standard Try-Test-Fix Loop (Thorough)",
210
+ "architectural": "Spec-first Redesign (High impact)",
211
+ "agentic": "Sovereign Agentic Engineering (Transient code, multi-agent)",
212
+ }
213
+
214
+ def __init__(self):
215
+ self._cache: Dict[str, str] = {}
216
+
217
+ def select_best_strategy(
218
+ self,
219
+ task_description: str,
220
+ task_category: str,
221
+ project_summary: str,
222
+ llm_client: BaseLLMClient,
223
+ ) -> str:
224
+ """Select strategy, using cache for repeated categories."""
225
+ if task_category in self._cache:
226
+ cached = self._cache[task_category]
227
+ logger.info(f"Strategy (cached for {task_category}): {cached.upper()}")
228
+ return cached
229
+
230
+ strategy_list = "\n".join(
231
+ f"{i + 1}. {k}: {v}" for i, (k, v) in enumerate(self.STRATEGIES.items())
232
+ )
233
+ prompt = (
234
+ f"Select the most efficient execution strategy.\n\n"
235
+ f"Project: {project_summary}\n"
236
+ f"Task category: {task_category}\n"
237
+ f"Task: {task_description}\n\n"
238
+ f"Available Strategies:\n{strategy_list}\n\n"
239
+ f"Return ONLY the key (surgical, iterative, architectural, or agentic)."
240
+ )
241
+ best = (
242
+ llm_client.generate_code(prompt, "You are a workflow optimizer.")
243
+ .strip()
244
+ .lower()
245
+ )
246
+ if best not in self.STRATEGIES:
247
+ best = "iterative"
248
+
249
+ self._cache[task_category] = best
250
+ logger.info(f"Strategy selected for {task_category}: {best.upper()}")
251
+ return best
252
+
253
+
254
+ class RealTimeAligner:
255
+ """Ensures multi-step alignment via a 'Shared Certified Repository' pattern."""
256
+
257
+ def __init__(self, project_path: Path):
258
+ self.cert_file = project_path / ".orchestrator" / "consensus.json"
259
+ self.cert_file.parent.mkdir(parents=True, exist_ok=True)
260
+ self._load()
261
+
262
+ def _load(self):
263
+ if self.cert_file.exists():
264
+ try:
265
+ self.data = json.loads(self.cert_file.read_text(encoding="utf-8"))
266
+ except (json.JSONDecodeError, OSError):
267
+ self.data = {"invariants": [], "decisions": []}
268
+ else:
269
+ self.data = {"invariants": [], "decisions": []}
270
+
271
+ def certify_decision(self, decision: str, rationale: str):
272
+ """Records a certified project decision to keep future tasks aligned."""
273
+ self.data["decisions"].append(
274
+ {
275
+ "timestamp": datetime.now(timezone.utc).isoformat(),
276
+ "decision": decision,
277
+ "rationale": rationale,
278
+ }
279
+ )
280
+ self.cert_file.write_text(json.dumps(self.data, indent=2), encoding="utf-8")
281
+ logger.info(f"Certified Decision: {decision}")
282
+
283
+ def get_consensus_context(self) -> str:
284
+ """Returns the current project consensus as a prompt string."""
285
+ if not self.data["decisions"]:
286
+ return "No prior decisions recorded."
287
+
288
+ lines = ["## Project Consensus (Certified)"]
289
+ for d in self.data["decisions"]:
290
+ lines.append(f"- {d['decision']} (Rationale: {d['rationale']})")
291
+ return "\n".join(lines)
292
+
293
+
294
+ def _extract_code_block(response: str) -> str:
295
+ """Extract the first code block from an LLM response."""
296
+ lines = response.split("\n")
297
+ in_block = False
298
+ code_lines = []
299
+ for line in lines:
300
+ if line.strip().startswith("```") and not in_block:
301
+ in_block = True
302
+ # Skip the opening fence line
303
+ continue
304
+ if line.strip().startswith("```") and in_block:
305
+ break
306
+ if in_block:
307
+ code_lines.append(line)
308
+ return "\n".join(code_lines) if code_lines else response.strip()
@@ -0,0 +1,201 @@
1
+ """Multi-target (polyglot) gate routing.
2
+
3
+ A monorepo can hold several sub-projects in different languages — e.g. a Rust
4
+ core plus a TypeScript web client and a Swift app — each with its OWN build/test/
5
+ lint toolchain. The orchestrator otherwise assumes ONE gate for the whole repo,
6
+ so a task editing the web client would be (mis)gated with the Rust commands.
7
+
8
+ A ``targets`` list in project.yaml declares those sub-projects::
9
+
10
+ targets:
11
+ - name: core
12
+ path: emathy-core
13
+ build_command: "cargo build -p emathy-core"
14
+ test_command: "cargo test -p emathy-core --lib"
15
+ - name: web
16
+ path: clients/web
17
+ build_command: "npm run typecheck"
18
+
19
+ For each task, :func:`select_target` picks the target that owns the task's files,
20
+ and the executor gates that task with THAT target's commands. With no ``targets``
21
+ declared (or no match), everything falls back to the top-level commands, so the
22
+ single-target path is byte-identical to before.
23
+ """
24
+
25
+ from pathlib import Path
26
+ from typing import Any, Dict, List, Optional
27
+
28
+ # Directories that never contain a sub-project worth gating (vendored deps, build
29
+ # output, VCS), so discovery skips them.
30
+ _SKIP_DIRS = {
31
+ "node_modules",
32
+ "target",
33
+ "dist",
34
+ "build",
35
+ "pkg",
36
+ "vendor",
37
+ "Pods",
38
+ ".git",
39
+ ".venv",
40
+ "venv",
41
+ "__pycache__",
42
+ ".next",
43
+ ".gradle",
44
+ ".cargo",
45
+ }
46
+
47
+ # A directory holding any of these is the root of a sub-project (one toolchain).
48
+ _BUILD_MARKERS = (
49
+ "Cargo.toml",
50
+ "package.json",
51
+ "Package.swift",
52
+ "go.mod",
53
+ "meson.build",
54
+ "build.gradle",
55
+ "build.gradle.kts",
56
+ "pyproject.toml",
57
+ "CMakeLists.txt",
58
+ )
59
+
60
+
61
+ def _has_marker(d: Path) -> bool:
62
+ if any((d / m).exists() for m in _BUILD_MARKERS):
63
+ return True
64
+ return any(d.glob("*.csproj")) or any(d.glob("*.sln"))
65
+
66
+
67
+ def _plugin_target_for(d: Path):
68
+ """First registered target plugin whose markers match ``d``, or None.
69
+
70
+ A target plugin is any object exposing ``markers`` (filenames or globs) and
71
+ ``commands(dir) -> dict`` (build/test/lint/typecheck keys), so a third party
72
+ can add a language/build system the built-in markers don't cover. See
73
+ ``misterdev.plugins.TARGETS``.
74
+ """
75
+ from misterdev.plugins import TARGETS
76
+
77
+ for name in TARGETS.names():
78
+ target = TARGETS.get(name)
79
+ for marker in getattr(target, "markers", ()):
80
+ if any(ch in marker for ch in "*?[") and any(d.glob(marker)):
81
+ return target
82
+ if (d / marker).exists():
83
+ return target
84
+ return None
85
+
86
+
87
+ def discover_targets(project_path: str, max_depth: int = 3) -> List[Dict[str, Any]]:
88
+ """Auto-detect sub-projects (targets) in a polyglot monorepo.
89
+
90
+ Walks the tree and records the SHALLOWEST build marker in each subtree (a
91
+ Cargo workspace at ``rust/`` is one target, not one per crate — discovery does
92
+ not descend past a found marker). The repo root itself is never a target (it
93
+ is the top-level fallback). Build/test commands are detected per sub-project.
94
+
95
+ Conservative on purpose: returns [] unless at least TWO distinct sub-projects
96
+ are found, so a normal single-project repo is never turned into "targets" and
97
+ behavior is unchanged. Commands are best-effort — explicit ``targets`` in
98
+ project.yaml override and tune them.
99
+ """
100
+ from misterdev.analyzers.project_analyzer import (
101
+ detect_build_command,
102
+ detect_test_command,
103
+ )
104
+
105
+ root = Path(project_path)
106
+ targets: List[Dict[str, Any]] = []
107
+
108
+ def scan(d: Path, depth: int, rel: str) -> None:
109
+ if depth > max_depth:
110
+ return
111
+ if d.name in _SKIP_DIRS or (rel and d.name.startswith(".")):
112
+ return
113
+ plugin = _plugin_target_for(d) if rel else None
114
+ if rel and (_has_marker(d) or plugin is not None):
115
+ if plugin is not None:
116
+ cmds = plugin.commands(d) or {}
117
+ build = cmds.get("build_command")
118
+ test = cmds.get("test_command")
119
+ else:
120
+ cmds = {}
121
+ build = detect_build_command(d)
122
+ test = detect_test_command(d)
123
+ if build or test:
124
+ target = {
125
+ "name": rel.replace("/", "-"),
126
+ "path": rel,
127
+ "build_command": build,
128
+ "test_command": test,
129
+ }
130
+ for extra in ("lint_command", "typecheck_command"):
131
+ if cmds.get(extra):
132
+ target[extra] = cmds[extra]
133
+ targets.append(target)
134
+ return # do not descend into a found sub-project
135
+ try:
136
+ children = sorted(c for c in d.iterdir() if c.is_dir())
137
+ except OSError:
138
+ return
139
+ for child in children:
140
+ scan(child, depth + 1, f"{rel}/{child.name}" if rel else child.name)
141
+
142
+ scan(root, 0, "")
143
+ return targets if len(targets) >= 2 else []
144
+
145
+
146
+ def _norm(path: str) -> str:
147
+ return (path or "").strip().strip("/")
148
+
149
+
150
+ def _owns(target_path: str, file_path: str) -> bool:
151
+ """True when ``file_path`` lives under ``target_path`` (or equals it)."""
152
+ tp = _norm(target_path)
153
+ fp = _norm(file_path)
154
+ if not tp:
155
+ return False
156
+ return fp == tp or fp.startswith(tp + "/")
157
+
158
+
159
+ def select_target(
160
+ targets: List[Dict[str, Any]], file_paths: List[str]
161
+ ) -> Optional[Dict[str, Any]]:
162
+ """Pick the declared target that owns the most of ``file_paths``, or None.
163
+
164
+ Ties break toward the more specific (longer) target path, so a nested target
165
+ (``clients/web/sub``) wins over its parent. Returns None when there are no
166
+ targets, no files, or no target owns any file — the caller then uses the
167
+ top-level commands.
168
+ """
169
+ if not targets or not file_paths:
170
+ return None
171
+ best: Optional[Dict[str, Any]] = None
172
+ best_count = 0
173
+ best_len = -1
174
+ for t in targets:
175
+ tp = _norm(t.get("path", ""))
176
+ if not tp:
177
+ continue
178
+ count = sum(1 for f in file_paths if _owns(tp, f))
179
+ if count == 0:
180
+ continue
181
+ if count > best_count or (count == best_count and len(tp) > best_len):
182
+ best, best_count, best_len = t, count, len(tp)
183
+ return best
184
+
185
+
186
+ _GATE_KEYS = ("build_command", "test_command", "lint_command", "typecheck_command")
187
+
188
+
189
+ def target_commands(
190
+ target: Optional[Dict[str, Any]], config: Dict[str, Any]
191
+ ) -> Dict[str, Optional[str]]:
192
+ """Resolve the effective build/test/lint/typecheck commands for a task.
193
+
194
+ A MATCHED target is self-contained: only the commands it declares apply, and
195
+ any it omits are skipped (None) — NOT inherited from the top-level, which is
196
+ usually a different toolchain (inheriting ``cargo test`` onto a web task would
197
+ be meaningless). With NO matched target, the top-level commands are used
198
+ unchanged (the single-target path).
199
+ """
200
+ source = target if target is not None else config
201
+ return {key: source.get(key) for key in _GATE_KEYS}
File without changes