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.
- ai_dev_cli_tools-0.5.0a1.dist-info/METADATA +240 -0
- ai_dev_cli_tools-0.5.0a1.dist-info/RECORD +60 -0
- ai_dev_cli_tools-0.5.0a1.dist-info/WHEEL +4 -0
- ai_dev_cli_tools-0.5.0a1.dist-info/entry_points.txt +2 -0
- ai_dev_cli_tools-0.5.0a1.dist-info/licenses/LICENSE +21 -0
- ai_dev_tools/__init__.py +3 -0
- ai_dev_tools/cache/__init__.py +11 -0
- ai_dev_tools/cache/graph.py +136 -0
- ai_dev_tools/cache/repository.py +169 -0
- ai_dev_tools/cache/validation.py +154 -0
- ai_dev_tools/cli.py +387 -0
- ai_dev_tools/completion.py +72 -0
- ai_dev_tools/config.py +223 -0
- ai_dev_tools/context/__init__.py +5 -0
- ai_dev_tools/context/builder.py +506 -0
- ai_dev_tools/context/incremental.py +107 -0
- ai_dev_tools/context/models.py +59 -0
- ai_dev_tools/context/profiles.py +49 -0
- ai_dev_tools/context/selection.py +270 -0
- ai_dev_tools/context/symbols.py +178 -0
- ai_dev_tools/detectors/__init__.py +1 -0
- ai_dev_tools/detectors/environment.py +125 -0
- ai_dev_tools/detectors/project.py +189 -0
- ai_dev_tools/detectors/repository_map.py +129 -0
- ai_dev_tools/detectors/runtime.py +190 -0
- ai_dev_tools/detectors/workspaces.py +228 -0
- ai_dev_tools/git/__init__.py +1 -0
- ai_dev_tools/git/inspect.py +219 -0
- ai_dev_tools/models/__init__.py +1 -0
- ai_dev_tools/models/report.py +95 -0
- ai_dev_tools/models/workspace.py +48 -0
- ai_dev_tools/parsers/__init__.py +1 -0
- ai_dev_tools/parsers/logs.py +372 -0
- ai_dev_tools/parsers/registry.py +60 -0
- ai_dev_tools/reporters/__init__.py +1 -0
- ai_dev_tools/reporters/progressive.py +161 -0
- ai_dev_tools/reporters/writer.py +74 -0
- ai_dev_tools/runners/__init__.py +1 -0
- ai_dev_tools/runners/baseline.py +190 -0
- ai_dev_tools/runners/bootstrap.py +191 -0
- ai_dev_tools/runners/bootstrap_models.py +64 -0
- ai_dev_tools/runners/bootstrap_strategies.py +444 -0
- ai_dev_tools/runners/cache.py +23 -0
- ai_dev_tools/runners/check.py +509 -0
- ai_dev_tools/runners/check_checkpoint.py +50 -0
- ai_dev_tools/runners/check_models.py +51 -0
- ai_dev_tools/runners/check_scheduler.py +94 -0
- ai_dev_tools/runners/check_selection.py +267 -0
- ai_dev_tools/runners/diagnostics.py +96 -0
- ai_dev_tools/runners/feedback.py +193 -0
- ai_dev_tools/runners/finish.py +105 -0
- ai_dev_tools/runners/focused.py +37 -0
- ai_dev_tools/runners/index.py +44 -0
- ai_dev_tools/runtime/__init__.py +3 -0
- ai_dev_tools/runtime/runner.py +380 -0
- ai_dev_tools/runtime/supervisor.py +145 -0
- ai_dev_tools/security/__init__.py +1 -0
- ai_dev_tools/security/secrets.py +58 -0
- ai_dev_tools/utils/__init__.py +1 -0
- 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
|