ai-dev-cli-tools 0.5.0a1__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 (60) hide show
  1. ai_dev_cli_tools-0.5.0a1.dist-info/METADATA +240 -0
  2. ai_dev_cli_tools-0.5.0a1.dist-info/RECORD +60 -0
  3. ai_dev_cli_tools-0.5.0a1.dist-info/WHEEL +4 -0
  4. ai_dev_cli_tools-0.5.0a1.dist-info/entry_points.txt +2 -0
  5. ai_dev_cli_tools-0.5.0a1.dist-info/licenses/LICENSE +21 -0
  6. ai_dev_tools/__init__.py +3 -0
  7. ai_dev_tools/cache/__init__.py +11 -0
  8. ai_dev_tools/cache/graph.py +136 -0
  9. ai_dev_tools/cache/repository.py +169 -0
  10. ai_dev_tools/cache/validation.py +154 -0
  11. ai_dev_tools/cli.py +387 -0
  12. ai_dev_tools/completion.py +72 -0
  13. ai_dev_tools/config.py +223 -0
  14. ai_dev_tools/context/__init__.py +5 -0
  15. ai_dev_tools/context/builder.py +506 -0
  16. ai_dev_tools/context/incremental.py +107 -0
  17. ai_dev_tools/context/models.py +59 -0
  18. ai_dev_tools/context/profiles.py +49 -0
  19. ai_dev_tools/context/selection.py +270 -0
  20. ai_dev_tools/context/symbols.py +178 -0
  21. ai_dev_tools/detectors/__init__.py +1 -0
  22. ai_dev_tools/detectors/environment.py +125 -0
  23. ai_dev_tools/detectors/project.py +189 -0
  24. ai_dev_tools/detectors/repository_map.py +129 -0
  25. ai_dev_tools/detectors/runtime.py +190 -0
  26. ai_dev_tools/detectors/workspaces.py +228 -0
  27. ai_dev_tools/git/__init__.py +1 -0
  28. ai_dev_tools/git/inspect.py +219 -0
  29. ai_dev_tools/models/__init__.py +1 -0
  30. ai_dev_tools/models/report.py +95 -0
  31. ai_dev_tools/models/workspace.py +48 -0
  32. ai_dev_tools/parsers/__init__.py +1 -0
  33. ai_dev_tools/parsers/logs.py +372 -0
  34. ai_dev_tools/parsers/registry.py +60 -0
  35. ai_dev_tools/reporters/__init__.py +1 -0
  36. ai_dev_tools/reporters/progressive.py +161 -0
  37. ai_dev_tools/reporters/writer.py +74 -0
  38. ai_dev_tools/runners/__init__.py +1 -0
  39. ai_dev_tools/runners/baseline.py +190 -0
  40. ai_dev_tools/runners/bootstrap.py +191 -0
  41. ai_dev_tools/runners/bootstrap_models.py +64 -0
  42. ai_dev_tools/runners/bootstrap_strategies.py +444 -0
  43. ai_dev_tools/runners/cache.py +23 -0
  44. ai_dev_tools/runners/check.py +509 -0
  45. ai_dev_tools/runners/check_checkpoint.py +50 -0
  46. ai_dev_tools/runners/check_models.py +51 -0
  47. ai_dev_tools/runners/check_scheduler.py +94 -0
  48. ai_dev_tools/runners/check_selection.py +267 -0
  49. ai_dev_tools/runners/diagnostics.py +96 -0
  50. ai_dev_tools/runners/feedback.py +193 -0
  51. ai_dev_tools/runners/finish.py +105 -0
  52. ai_dev_tools/runners/focused.py +37 -0
  53. ai_dev_tools/runners/index.py +44 -0
  54. ai_dev_tools/runtime/__init__.py +3 -0
  55. ai_dev_tools/runtime/runner.py +380 -0
  56. ai_dev_tools/runtime/supervisor.py +145 -0
  57. ai_dev_tools/security/__init__.py +1 -0
  58. ai_dev_tools/security/secrets.py +58 -0
  59. ai_dev_tools/utils/__init__.py +1 -0
  60. ai_dev_tools/utils/subprocess.py +74 -0
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(frozen=True, slots=True)
7
+ class ContextProfile:
8
+ name: str
9
+ max_chars: int
10
+ max_files: int
11
+ max_file_chars: int
12
+ max_diff_chars: int
13
+ changed_only: bool = False
14
+ description: str = ""
15
+
16
+
17
+ PROFILES: dict[str, ContextProfile] = {
18
+ "minimal": ContextProfile(
19
+ "minimal", 12_000, 8, 2_500, 4_000, description="Smallest useful agent handoff"
20
+ ),
21
+ "debug": ContextProfile(
22
+ "debug", 60_000, 35, 10_000, 20_000, description="Failures, diffs, and nearby code"
23
+ ),
24
+ "review": ContextProfile(
25
+ "review",
26
+ 40_000,
27
+ 25,
28
+ 6_000,
29
+ 20_000,
30
+ changed_only=True,
31
+ description="Changed files and review evidence",
32
+ ),
33
+ "full": ContextProfile(
34
+ "full", 150_000, 100, 20_000, 50_000, description="Broad repository context"
35
+ ),
36
+ }
37
+
38
+
39
+ def get_context_profile(name: str) -> ContextProfile | None:
40
+ if name == "default":
41
+ return None
42
+ try:
43
+ return PROFILES[name]
44
+ except KeyError as exc:
45
+ raise ValueError(f"Unknown context profile: {name}") from exc
46
+
47
+
48
+ def profile_names() -> tuple[str, ...]:
49
+ return tuple(PROFILES)
@@ -0,0 +1,270 @@
1
+ from __future__ import annotations
2
+
3
+ import fnmatch
4
+ import re
5
+ from pathlib import Path
6
+
7
+ from ai_dev_tools.context.models import ContextOptions, RejectedFile, SelectedFile
8
+ from ai_dev_tools.context.symbols import SymbolSnippet, select_python_symbols
9
+ from ai_dev_tools.detectors.repository_map import BINARY_EXTENSIONS
10
+ from ai_dev_tools.security.secrets import mask_text
11
+
12
+ BLOCKED_NAMES = {".env", ".env.local", ".env.production", ".env.development", ".DS_Store"}
13
+ BLOCKED_SUFFIXES = {".pyc", ".pyo", ".pem", ".key", ".p12", ".pfx"}
14
+ ALWAYS_IGNORE = {
15
+ ".ai/logs",
16
+ ".ai/reports",
17
+ ".ai/context",
18
+ ".git",
19
+ ".mypy_cache",
20
+ ".pytest_cache",
21
+ ".ruff_cache",
22
+ "__pycache__",
23
+ "node_modules",
24
+ ".venv",
25
+ "venv",
26
+ "dist",
27
+ "build",
28
+ }
29
+
30
+
31
+ def _select_candidates(
32
+ root: Path,
33
+ options: ContextOptions,
34
+ changed_files: list[str],
35
+ scan_summary: dict[str, object],
36
+ map_summary: dict[str, object],
37
+ related_tests: list[str],
38
+ ) -> tuple[dict[Path, str], list[RejectedFile]]:
39
+ candidates: dict[Path, str] = {}
40
+ rejected: list[RejectedFile] = []
41
+ for pattern in options.include:
42
+ for path in root.glob(pattern):
43
+ _add_candidate(
44
+ root, path, f"included by pattern: {pattern}", candidates, rejected, options
45
+ )
46
+ for rel in changed_files:
47
+ _add_candidate(root, root / rel, "changed file", candidates, rejected, options)
48
+ for rel in related_tests:
49
+ _add_candidate(root, root / rel, "related affected test", candidates, rejected, options)
50
+ for rel in _object_list(scan_summary.get("entrypoints")):
51
+ if not rel.startswith("script:"):
52
+ _add_candidate(root, root / rel, "detected entrypoint", candidates, rejected, options)
53
+ for key, reason in (
54
+ ("important_files", "important project file"),
55
+ ("tests", "repository test file"),
56
+ ("ci_workflows", "CI workflow"),
57
+ ("documentation", "documentation"),
58
+ ):
59
+ for rel in _object_list(map_summary.get(key)):
60
+ _add_candidate(root, root / rel, reason, candidates, rejected, options)
61
+ return candidates, rejected
62
+
63
+
64
+ def _add_candidate(
65
+ root: Path,
66
+ path: Path,
67
+ reason: str,
68
+ candidates: dict[Path, str],
69
+ rejected: list[RejectedFile],
70
+ options: ContextOptions,
71
+ ) -> None:
72
+ if not path.exists() or not path.is_file():
73
+ return
74
+ try:
75
+ resolved = path.resolve()
76
+ resolved.relative_to(root.resolve())
77
+ except ValueError:
78
+ rejected.append(RejectedFile(str(path), "outside project root", "OUTSIDE_PROJECT_ROOT"))
79
+ return
80
+ rel = _rel(root, path)
81
+ blocked = _blocked_reason(path, rel, options.exclude)
82
+ if blocked:
83
+ rejected.append(RejectedFile(rel, blocked, _rejection_reason_code(blocked)))
84
+ return
85
+ candidates[path] = reason
86
+
87
+
88
+ def _blocked_reason(path: Path, rel: str, excludes: tuple[str, ...]) -> str | None:
89
+ normalized = rel.replace("\\", "/")
90
+ parts = set(Path(normalized).parts)
91
+ if path.name in BLOCKED_NAMES:
92
+ return "environment or secret-bearing file"
93
+ if path.suffix.lower() in BLOCKED_SUFFIXES or path.suffix.lower() in BINARY_EXTENSIONS:
94
+ return "binary or sensitive file type"
95
+ if any(pattern in parts or normalized.startswith(f"{pattern}/") for pattern in ALWAYS_IGNORE):
96
+ return "ignored generated/cache path"
97
+ if any(fnmatch.fnmatch(normalized, pattern.replace("\\", "/")) for pattern in excludes):
98
+ return "excluded by user pattern"
99
+ return None
100
+
101
+
102
+ def _read_selected_files(
103
+ root: Path, paths: list[Path], reasons: dict[Path, str], options: ContextOptions
104
+ ) -> tuple[list[SelectedFile], list[RejectedFile]]:
105
+ selected: list[SelectedFile] = []
106
+ rejected: list[RejectedFile] = []
107
+ for path in paths:
108
+ rel = _rel(root, path)
109
+ try:
110
+ text = path.read_text(encoding="utf-8", errors="replace")
111
+ except OSError as exc:
112
+ rejected.append(RejectedFile(rel, f"unreadable: {exc}", "UNREADABLE_FILE"))
113
+ continue
114
+ masked = mask_text(text)
115
+ symbol_selection = (
116
+ select_python_symbols(text, masked, options.task, options.max_file_chars)
117
+ if path.suffix.lower() == ".py"
118
+ else None
119
+ )
120
+ if symbol_selection is None:
121
+ snippet, truncated = _truncate_text(masked, options.max_file_chars)
122
+ strategy = "file-prefix"
123
+ omitted_content = truncated
124
+ snippets: list[SymbolSnippet] = []
125
+ else:
126
+ snippet = symbol_selection.content
127
+ truncated = symbol_selection.truncated
128
+ strategy = "python-ast"
129
+ omitted_content = symbol_selection.omitted_content
130
+ snippets = symbol_selection.snippets
131
+ selected.append(
132
+ SelectedFile(
133
+ path=rel,
134
+ reason=reasons[path],
135
+ reason_code=_selection_reason_code(reasons[path]),
136
+ chars=len(snippet),
137
+ truncated=truncated,
138
+ content=snippet,
139
+ selection_strategy=strategy,
140
+ omitted_content=omitted_content,
141
+ snippets=snippets,
142
+ )
143
+ )
144
+ return selected, rejected
145
+
146
+
147
+ def _selection_reason_code(reason: str) -> str:
148
+ return {
149
+ "included by user": "USER_INCLUDE",
150
+ "changed file": "CHANGED_FILE",
151
+ "related affected test": "RELATED_TEST",
152
+ "detected entrypoint": "DETECTED_ENTRYPOINT",
153
+ "important project file": "IMPORTANT_FILE",
154
+ "repository test file": "TEST_FILE",
155
+ "CI workflow": "CI_WORKFLOW",
156
+ "documentation": "DOCUMENTATION",
157
+ "Python dependency": "PYTHON_DEPENDENCY",
158
+ "JavaScript/TypeScript dependency": "JS_TS_DEPENDENCY",
159
+ "Rust dependency": "RUST_DEPENDENCY",
160
+ "Java dependency": "JAVA_DEPENDENCY",
161
+ "PHP dependency": "PHP_DEPENDENCY",
162
+ }.get(reason, "SELECTED_FILE")
163
+
164
+
165
+ def _rejection_reason_code(reason: str) -> str:
166
+ return {
167
+ "environment or secret-bearing file": "SENSITIVE_OR_ENV_FILE",
168
+ "binary or sensitive file type": "BINARY_OR_SENSITIVE_TYPE",
169
+ "ignored generated/cache path": "IGNORED_GENERATED_PATH",
170
+ "excluded by user pattern": "USER_EXCLUDED",
171
+ }.get(reason, "REJECTED_FILE")
172
+
173
+
174
+ def _dependency_files(root: Path, candidates: dict[Path, str]) -> dict[Path, str]:
175
+ discovered: dict[Path, str] = {}
176
+ for path in list(candidates):
177
+ rel = _rel(root, path)
178
+ if path.suffix == ".py":
179
+ for module in _python_imports(path):
180
+ for dep in _python_module_paths(root, module):
181
+ if dep.exists():
182
+ discovered[dep] = f"local Python dependency imported by {rel}"
183
+ if path.suffix.lower() in {".js", ".jsx", ".ts", ".tsx"}:
184
+ for dep in _relative_js_imports(path):
185
+ discovered[dep] = f"local JS/TS dependency imported by {rel}"
186
+ if path.suffix == ".rs":
187
+ for dep in _rust_mod_paths(path):
188
+ discovered[dep] = f"local Rust module referenced by {rel}"
189
+ if path.suffix == ".java":
190
+ for dep in _java_same_package_paths(path):
191
+ discovered[dep] = f"nearby Java package file related to {rel}"
192
+ if path.suffix == ".php":
193
+ for dep in _php_nearby_paths(path):
194
+ discovered[dep] = f"nearby PHP file related to {rel}"
195
+ return {path: reason for path, reason in discovered.items() if path.is_file()}
196
+
197
+
198
+ def _python_imports(path: Path) -> set[str]:
199
+ text = path.read_text(encoding="utf-8", errors="replace")
200
+ names: set[str] = set()
201
+ for line in text.splitlines():
202
+ match = re.match(
203
+ r"\s*(?:from\s+([A-Za-z_][\w.]*)\s+import|import\s+([A-Za-z_][\w.]*))",
204
+ line,
205
+ )
206
+ if match:
207
+ names.add(match.group(1) or match.group(2) or "")
208
+ return {name for name in names if name}
209
+
210
+
211
+ def _python_module_paths(root: Path, module: str) -> list[Path]:
212
+ parts = module.split(".")
213
+ candidates = [
214
+ root / Path(*parts).with_suffix(".py"),
215
+ root / "src" / Path(*parts).with_suffix(".py"),
216
+ ]
217
+ candidates.extend(
218
+ [root / Path(*parts) / "__init__.py", root / "src" / Path(*parts) / "__init__.py"]
219
+ )
220
+ return candidates
221
+
222
+
223
+ def _relative_js_imports(path: Path) -> list[Path]:
224
+ text = path.read_text(encoding="utf-8", errors="replace")
225
+ deps: list[Path] = []
226
+ for match in re.finditer(r"(?:from\s+|require\()(['\"])(\.{1,2}/[^'\"]+)\1", text):
227
+ base = (path.parent / match.group(2)).resolve()
228
+ for suffix in ("", ".ts", ".tsx", ".js", ".jsx", "/index.ts", "/index.js"):
229
+ candidate = Path(f"{base}{suffix}")
230
+ if candidate.exists() and candidate.is_file():
231
+ deps.append(candidate)
232
+ break
233
+ return deps
234
+
235
+
236
+ def _rust_mod_paths(path: Path) -> list[Path]:
237
+ text = path.read_text(encoding="utf-8", errors="replace")
238
+ deps: list[Path] = []
239
+ for match in re.finditer(r"^\s*mod\s+([A-Za-z_][\w]*)\s*;", text, re.MULTILINE):
240
+ name = match.group(1)
241
+ deps.extend([path.parent / f"{name}.rs", path.parent / name / "mod.rs"])
242
+ return deps
243
+
244
+
245
+ def _java_same_package_paths(path: Path) -> list[Path]:
246
+ return sorted(path.parent.glob("*.java"))[:5]
247
+
248
+
249
+ def _php_nearby_paths(path: Path) -> list[Path]:
250
+ return sorted(path.parent.glob("*.php"))[:5]
251
+
252
+
253
+ def _truncate_text(text: str, max_chars: int) -> tuple[str, bool]:
254
+ if max_chars < 0:
255
+ max_chars = 0
256
+ if len(text) <= max_chars:
257
+ return text, False
258
+ marker = "\n[TRUNCATED]\n"
259
+ keep = max(max_chars - len(marker), 0)
260
+ return text[:keep] + marker, True
261
+
262
+
263
+ def _object_list(value: object) -> list[str]:
264
+ if not isinstance(value, list):
265
+ return []
266
+ return [str(item) for item in value]
267
+
268
+
269
+ def _rel(root: Path, path: Path) -> str:
270
+ return path.resolve().relative_to(root.resolve()).as_posix()
@@ -0,0 +1,178 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ import re
5
+ from dataclasses import dataclass, field
6
+
7
+
8
+ @dataclass(slots=True)
9
+ class SymbolSnippet:
10
+ name: str
11
+ kind: str
12
+ start_line: int
13
+ end_line: int
14
+ reason: str
15
+ reason_code: str
16
+ referenced_local_symbols: list[str] = field(default_factory=list)
17
+ truncated: bool = False
18
+
19
+
20
+ @dataclass(slots=True)
21
+ class SymbolSelection:
22
+ content: str
23
+ snippets: list[SymbolSnippet]
24
+ omitted_content: bool
25
+ truncated: bool
26
+
27
+
28
+ def select_python_symbols(
29
+ source: str,
30
+ display_source: str,
31
+ task: str,
32
+ max_chars: int,
33
+ ) -> SymbolSelection | None:
34
+ if len(display_source) <= max_chars:
35
+ return None
36
+ try:
37
+ tree = ast.parse(source)
38
+ except SyntaxError:
39
+ return None
40
+
41
+ definitions = [
42
+ node
43
+ for node in tree.body
44
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef))
45
+ ]
46
+ if not definitions:
47
+ return None
48
+
49
+ task_tokens = _task_tokens(task)
50
+ matched = [node for node in definitions if _name_tokens(node.name) & task_tokens]
51
+ candidates = matched or [node for node in definitions if not node.name.startswith("_")]
52
+ if not candidates:
53
+ candidates = definitions
54
+ candidates = sorted(
55
+ candidates,
56
+ key=lambda node: (_symbol_score(node.name, task_tokens), -node.lineno),
57
+ reverse=True,
58
+ )
59
+
60
+ local_names = {node.name for node in definitions}
61
+ source_lines = display_source.splitlines(keepends=True)
62
+ selected: list[SymbolSnippet] = []
63
+ sections: list[str] = []
64
+ remaining = max(max_chars, 0)
65
+
66
+ imports = [node for node in tree.body if isinstance(node, (ast.Import, ast.ImportFrom))]
67
+ if imports and remaining > 0:
68
+ start = min(node.lineno for node in imports)
69
+ end = max(_end_line(node) for node in imports)
70
+ import_budget = min(remaining, max(200, min(1_500, max_chars // 4)))
71
+ unused_import_budget = _append_section(
72
+ sections,
73
+ selected,
74
+ source_lines,
75
+ SymbolSnippet(
76
+ "<imports>",
77
+ "imports",
78
+ start,
79
+ end,
80
+ "imports required by selected symbols",
81
+ "REQUIRED_IMPORTS",
82
+ ),
83
+ import_budget,
84
+ )
85
+ remaining -= import_budget - unused_import_budget
86
+
87
+ for node in candidates:
88
+ if remaining <= 0:
89
+ break
90
+ reason = (
91
+ "symbol name matches task terms"
92
+ if _name_tokens(node.name) & task_tokens
93
+ else "top-level public symbol"
94
+ )
95
+ referenced = sorted(
96
+ {
97
+ child.id
98
+ for child in ast.walk(node)
99
+ if isinstance(child, ast.Name) and child.id in local_names and child.id != node.name
100
+ }
101
+ )
102
+ snippet = SymbolSnippet(
103
+ name=node.name,
104
+ kind=_symbol_kind(node),
105
+ start_line=node.lineno,
106
+ end_line=_end_line(node),
107
+ reason=reason,
108
+ reason_code=(
109
+ "TASK_SYMBOL_MATCH"
110
+ if reason == "symbol name matches task terms"
111
+ else "PUBLIC_SYMBOL"
112
+ ),
113
+ referenced_local_symbols=referenced,
114
+ )
115
+ remaining = _append_section(sections, selected, source_lines, snippet, remaining)
116
+
117
+ if not selected:
118
+ return None
119
+ return SymbolSelection(
120
+ content="\n\n".join(sections).rstrip() + "\n",
121
+ snippets=selected,
122
+ omitted_content=True,
123
+ truncated=any(item.truncated for item in selected),
124
+ )
125
+
126
+
127
+ def _append_section(
128
+ sections: list[str],
129
+ selected: list[SymbolSnippet],
130
+ lines: list[str],
131
+ snippet: SymbolSnippet,
132
+ remaining: int,
133
+ ) -> int:
134
+ header = (
135
+ f"# [{snippet.kind}] {snippet.name} "
136
+ f"(lines {snippet.start_line}-{snippet.end_line}; {snippet.reason})\n"
137
+ )
138
+ body = "".join(lines[snippet.start_line - 1 : snippet.end_line])
139
+ separator_cost = 2 if sections else 0
140
+ available = max(remaining - len(header) - separator_cost, 0)
141
+ if available <= 0:
142
+ return remaining
143
+ if len(body) > available:
144
+ marker = "\n# [SYMBOL TRUNCATED]\n"
145
+ body = body[: max(available - len(marker), 0)] + marker
146
+ snippet.truncated = True
147
+ sections.append(header + body.rstrip())
148
+ selected.append(snippet)
149
+ return max(remaining - len(sections[-1]) - separator_cost, 0)
150
+
151
+
152
+ def _task_tokens(task: str) -> set[str]:
153
+ return {
154
+ token.lower() for token in re.findall(r"[A-Za-z_][A-Za-z0-9_]+", task) if len(token) >= 3
155
+ }
156
+
157
+
158
+ def _name_tokens(name: str) -> set[str]:
159
+ split = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name).lower()
160
+ return {token for token in split.split("_") if token}
161
+
162
+
163
+ def _symbol_score(name: str, task_tokens: set[str]) -> int:
164
+ matches = len(_name_tokens(name) & task_tokens)
165
+ return matches * 100 + (10 if not name.startswith("_") else 0)
166
+
167
+
168
+ def _end_line(node: ast.AST) -> int:
169
+ value = getattr(node, "end_lineno", None)
170
+ return value if isinstance(value, int) else getattr(node, "lineno", 1)
171
+
172
+
173
+ def _symbol_kind(node: ast.AST) -> str:
174
+ if isinstance(node, ast.ClassDef):
175
+ return "class"
176
+ if isinstance(node, ast.AsyncFunctionDef):
177
+ return "async-function"
178
+ return "function"
@@ -0,0 +1 @@
1
+ """Project and environment detectors."""
@@ -0,0 +1,125 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ from ai_dev_tools.config import load_settings
8
+ from ai_dev_tools.detectors.runtime import (
9
+ detect_runtime_requirements,
10
+ evaluate_requirement,
11
+ )
12
+ from ai_dev_tools.models.report import Report
13
+ from ai_dev_tools.reporters.writer import write_json, write_markdown
14
+ from ai_dev_tools.security.secrets import mask_text
15
+ from ai_dev_tools.utils.subprocess import run_command
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class ToolSpec:
20
+ name: str
21
+ executable: str
22
+ version_command: list[str]
23
+ required: bool = False
24
+
25
+
26
+ TOOLS: tuple[ToolSpec, ...] = (
27
+ ToolSpec("git", "git", ["git", "--version"], required=True),
28
+ ToolSpec("python", "python", ["python", "--version"], required=True),
29
+ ToolSpec("uv", "uv", ["uv", "--version"]),
30
+ ToolSpec("poetry", "poetry", ["poetry", "--version"]),
31
+ ToolSpec("node", "node", ["node", "--version"]),
32
+ ToolSpec("npm", "npm", ["npm", "--version"]),
33
+ ToolSpec("pnpm", "pnpm", ["pnpm", "--version"]),
34
+ ToolSpec("yarn", "yarn", ["yarn", "--version"]),
35
+ ToolSpec("java", "java", ["java", "--version"]),
36
+ ToolSpec("maven", "mvn", ["mvn", "--version"]),
37
+ ToolSpec("gradle", "gradle", ["gradle", "--version"]),
38
+ ToolSpec("php", "php", ["php", "--version"]),
39
+ ToolSpec("composer", "composer", ["composer", "--version"]),
40
+ ToolSpec("rust", "rustc", ["rustc", "--version"]),
41
+ ToolSpec("cargo", "cargo", ["cargo", "--version"]),
42
+ ToolSpec("docker", "docker", ["docker", "--version"]),
43
+ ToolSpec("docker_compose", "docker", ["docker", "compose", "version"]),
44
+ ToolSpec("github_cli", "gh", ["gh", "--version"]),
45
+ )
46
+
47
+
48
+ def run_doctor(project_root: Path) -> Report:
49
+ settings = load_settings(project_root)
50
+ report = Report(command="doctor", project_root=settings.project_root)
51
+ tools: dict[str, dict[str, str | bool | None]] = {}
52
+ for spec in TOOLS:
53
+ path = shutil.which(spec.executable)
54
+ if path is None:
55
+ tools[spec.name] = {
56
+ "status": "missing",
57
+ "version": None,
58
+ "path": None,
59
+ "required": spec.required,
60
+ }
61
+ continue
62
+ result = run_command(spec.version_command, project_root, timeout_seconds=20)
63
+ version = (
64
+ mask_text(result.stdout or result.stderr).splitlines()[0]
65
+ if result.combined_output
66
+ else "available"
67
+ )
68
+ status = "ok" if result.exit_code == 0 else "error"
69
+ tools[spec.name] = {
70
+ "status": status,
71
+ "version": version,
72
+ "path": path,
73
+ "required": spec.required,
74
+ }
75
+ requirements = detect_runtime_requirements(settings.project_root)
76
+ runtime_compatibility = [
77
+ evaluate_requirement(
78
+ requirement,
79
+ str(tools.get(requirement.runtime, {}).get("version"))
80
+ if tools.get(requirement.runtime, {}).get("version")
81
+ else None,
82
+ )
83
+ for requirement in requirements
84
+ ]
85
+ incompatible_runtimes = [
86
+ item for item in runtime_compatibility if item["status"] == "incompatible"
87
+ ]
88
+ missing_project_runtimes = [
89
+ item for item in runtime_compatibility if item["status"] == "missing"
90
+ ]
91
+ report.summary = {
92
+ "tools": tools,
93
+ "missing_required": [
94
+ k for k, v in tools.items() if v["required"] and v["status"] == "missing"
95
+ ],
96
+ "missing_optional": [
97
+ k for k, v in tools.items() if not v["required"] and v["status"] == "missing"
98
+ ],
99
+ "errors_required": [
100
+ k for k, v in tools.items() if v["required"] and v["status"] == "error"
101
+ ],
102
+ "runtime_requirements": [item.to_dict() for item in requirements],
103
+ "runtime_compatibility": runtime_compatibility,
104
+ "incompatible_runtimes": incompatible_runtimes,
105
+ "missing_project_runtimes": missing_project_runtimes,
106
+ "errors_optional": [
107
+ k for k, v in tools.items() if not v["required"] and v["status"] == "error"
108
+ ],
109
+ }
110
+ report.status = (
111
+ "failed"
112
+ if (
113
+ report.summary["missing_required"]
114
+ or report.summary["errors_required"]
115
+ or incompatible_runtimes
116
+ or missing_project_runtimes
117
+ )
118
+ else "warning"
119
+ if report.summary["errors_optional"]
120
+ else "success"
121
+ )
122
+ report.finish()
123
+ write_markdown(report, settings.reports_directory / "doctor.md")
124
+ write_json(report, settings.reports_directory / "doctor.json")
125
+ return report