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,94 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from time import monotonic
|
|
7
|
+
|
|
8
|
+
from ai_dev_tools.runners.check_models import CheckTask
|
|
9
|
+
from ai_dev_tools.utils.subprocess import CommandResult
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, slots=True)
|
|
13
|
+
class ScheduleResult:
|
|
14
|
+
tasks: list[CheckTask]
|
|
15
|
+
results: list[CommandResult]
|
|
16
|
+
cancelled: list[CheckTask]
|
|
17
|
+
wall_seconds: float
|
|
18
|
+
aggregate_seconds: float
|
|
19
|
+
time_to_first_failure_seconds: float | None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def schedule_checks(
|
|
23
|
+
tasks: list[CheckTask],
|
|
24
|
+
jobs: int,
|
|
25
|
+
policy: str,
|
|
26
|
+
execute: Callable[[CheckTask], CommandResult],
|
|
27
|
+
) -> ScheduleResult:
|
|
28
|
+
started = monotonic()
|
|
29
|
+
completed_tasks: list[CheckTask] = []
|
|
30
|
+
results: list[CommandResult] = []
|
|
31
|
+
cancelled: list[CheckTask] = []
|
|
32
|
+
first_failure_at: float | None = None
|
|
33
|
+
groups = [tasks] if policy == "complete" else _feedback_groups(tasks)
|
|
34
|
+
for group_index, group in enumerate(groups):
|
|
35
|
+
group_results = _execute_group(group, jobs, execute)
|
|
36
|
+
completed_tasks.extend(group)
|
|
37
|
+
results.extend(group_results)
|
|
38
|
+
if first_failure_at is None and any(result.exit_code != 0 for result in group_results):
|
|
39
|
+
first_failure_at = monotonic() - started
|
|
40
|
+
blocking = any(
|
|
41
|
+
task.required and result.exit_code != 0
|
|
42
|
+
for task, result in zip(group, group_results, strict=True)
|
|
43
|
+
)
|
|
44
|
+
if policy == "feedback-first" and blocking:
|
|
45
|
+
cancelled = [task for remaining in groups[group_index + 1 :] for task in remaining]
|
|
46
|
+
break
|
|
47
|
+
wall = monotonic() - started
|
|
48
|
+
return ScheduleResult(
|
|
49
|
+
completed_tasks,
|
|
50
|
+
results,
|
|
51
|
+
cancelled,
|
|
52
|
+
round(wall, 3),
|
|
53
|
+
round(sum(result.duration_seconds for result in results), 3),
|
|
54
|
+
round(first_failure_at, 3) if first_failure_at is not None else None,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def schedule_graph(tasks: list[CheckTask], policy: str) -> dict[str, object]:
|
|
59
|
+
waves = _feedback_groups(tasks) if policy == "feedback-first" else [tasks]
|
|
60
|
+
return {
|
|
61
|
+
"policy": policy,
|
|
62
|
+
"waves": [
|
|
63
|
+
{
|
|
64
|
+
"index": index,
|
|
65
|
+
"cost": wave[0].cost if wave else "none",
|
|
66
|
+
"checks": [task.name for task in wave],
|
|
67
|
+
"workspaces": sorted({task.workspace for task in wave}),
|
|
68
|
+
}
|
|
69
|
+
for index, wave in enumerate(waves)
|
|
70
|
+
],
|
|
71
|
+
"fail_fast": policy == "feedback-first",
|
|
72
|
+
"deterministic_order": True,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _feedback_groups(tasks: list[CheckTask]) -> list[list[CheckTask]]:
|
|
77
|
+
costs = ("fast", "medium", "slow")
|
|
78
|
+
return [
|
|
79
|
+
[task for task in tasks if task.cost == cost]
|
|
80
|
+
for cost in costs
|
|
81
|
+
if any(task.cost == cost for task in tasks)
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _execute_group(
|
|
86
|
+
tasks: list[CheckTask],
|
|
87
|
+
jobs: int,
|
|
88
|
+
execute: Callable[[CheckTask], CommandResult],
|
|
89
|
+
) -> list[CommandResult]:
|
|
90
|
+
workers = max(1, min(jobs, len(tasks)))
|
|
91
|
+
if workers == 1:
|
|
92
|
+
return [execute(task) for task in tasks]
|
|
93
|
+
with ThreadPoolExecutor(max_workers=workers) as executor:
|
|
94
|
+
return list(executor.map(execute, tasks))
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import fnmatch
|
|
4
|
+
import sys
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from ai_dev_tools.cache.graph import related_tests as graph_related_tests
|
|
9
|
+
from ai_dev_tools.cache.repository import read_repository_index
|
|
10
|
+
from ai_dev_tools.config import Settings
|
|
11
|
+
from ai_dev_tools.runners.check_models import ChangedSelection, ChangedStrategy, CheckTask
|
|
12
|
+
from ai_dev_tools.utils.subprocess import CommandResult, run_command
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def select_changed_checks(
|
|
16
|
+
settings: Settings,
|
|
17
|
+
plan: list[CheckTask],
|
|
18
|
+
collector: Callable[[Path], list[str]] | None = None,
|
|
19
|
+
) -> ChangedSelection:
|
|
20
|
+
changed_files = (collector or collect_changed_files)(settings.project_root)
|
|
21
|
+
if not changed_files:
|
|
22
|
+
return ChangedSelection("no_changes", "high", [], [], [], "No changed files detected.")
|
|
23
|
+
config_reason = _configuration_change_reason(changed_files)
|
|
24
|
+
if config_reason:
|
|
25
|
+
return ChangedSelection(
|
|
26
|
+
"configuration_change",
|
|
27
|
+
"low",
|
|
28
|
+
changed_files,
|
|
29
|
+
[],
|
|
30
|
+
[],
|
|
31
|
+
config_reason,
|
|
32
|
+
)
|
|
33
|
+
direct_changed_tests = [path for path in changed_files if _is_test_path(Path(path))]
|
|
34
|
+
if direct_changed_tests:
|
|
35
|
+
return ChangedSelection(
|
|
36
|
+
"changed_test_direct",
|
|
37
|
+
"high",
|
|
38
|
+
changed_files,
|
|
39
|
+
sorted(direct_changed_tests),
|
|
40
|
+
_commands_for_selected_tests(plan, sorted(direct_changed_tests)),
|
|
41
|
+
None,
|
|
42
|
+
)
|
|
43
|
+
configured_tests = _configured_changed_tests(settings, changed_files)
|
|
44
|
+
if configured_tests:
|
|
45
|
+
return ChangedSelection(
|
|
46
|
+
"configured_mapping",
|
|
47
|
+
"high",
|
|
48
|
+
changed_files,
|
|
49
|
+
configured_tests,
|
|
50
|
+
_commands_for_selected_tests(plan, configured_tests),
|
|
51
|
+
None,
|
|
52
|
+
)
|
|
53
|
+
selected_tests = infer_tests_for_changed_files(settings.project_root, changed_files)
|
|
54
|
+
if selected_tests:
|
|
55
|
+
strategy: ChangedStrategy = "direct_test_match"
|
|
56
|
+
return ChangedSelection(
|
|
57
|
+
strategy,
|
|
58
|
+
"medium",
|
|
59
|
+
changed_files,
|
|
60
|
+
selected_tests,
|
|
61
|
+
_commands_for_selected_tests(plan, selected_tests),
|
|
62
|
+
None,
|
|
63
|
+
)
|
|
64
|
+
return ChangedSelection(
|
|
65
|
+
"broad_fallback",
|
|
66
|
+
"low",
|
|
67
|
+
changed_files,
|
|
68
|
+
[],
|
|
69
|
+
[],
|
|
70
|
+
"Changed files were detected, but no reliable test dependency map is available yet.",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def collect_changed_files(
|
|
75
|
+
root: Path,
|
|
76
|
+
command_runner: Callable[[list[str], Path, int], CommandResult] = run_command,
|
|
77
|
+
) -> list[str]:
|
|
78
|
+
seen: dict[str, None] = {}
|
|
79
|
+
commands = [
|
|
80
|
+
["git", "diff", "--name-only", "-z"],
|
|
81
|
+
["git", "diff", "--cached", "--name-only", "-z"],
|
|
82
|
+
["git", "ls-files", "--others", "--exclude-standard", "-z"],
|
|
83
|
+
]
|
|
84
|
+
upstream = command_runner(
|
|
85
|
+
["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], root, 30
|
|
86
|
+
)
|
|
87
|
+
if upstream.exit_code == 0 and upstream.stdout.strip():
|
|
88
|
+
commands.append(["git", "diff", "--name-only", "-z", upstream.stdout.strip() + "...HEAD"])
|
|
89
|
+
for command in commands:
|
|
90
|
+
result = command_runner(command, root, 30)
|
|
91
|
+
if result.exit_code != 0:
|
|
92
|
+
continue
|
|
93
|
+
for item in _split_nul(result.stdout):
|
|
94
|
+
if item:
|
|
95
|
+
seen[item] = None
|
|
96
|
+
return sorted(seen)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def infer_tests_for_changed_files(root: Path, changed_files: list[str]) -> list[str]:
|
|
100
|
+
selected: dict[str, None] = {}
|
|
101
|
+
index = read_repository_index(root)
|
|
102
|
+
for rel in graph_related_tests(index.get("graph"), changed_files):
|
|
103
|
+
_add_existing(root, selected, rel)
|
|
104
|
+
for rel in changed_files:
|
|
105
|
+
path = Path(rel)
|
|
106
|
+
if _is_test_path(path):
|
|
107
|
+
_add_existing(root, selected, rel)
|
|
108
|
+
continue
|
|
109
|
+
candidates = _candidate_tests_for_source(path)
|
|
110
|
+
for candidate in candidates:
|
|
111
|
+
_add_existing(root, selected, candidate)
|
|
112
|
+
for importer in _python_importing_tests(root, path):
|
|
113
|
+
selected[importer] = None
|
|
114
|
+
return sorted(selected)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _configuration_change_reason(changed_files: list[str]) -> str | None:
|
|
118
|
+
broad_files = {
|
|
119
|
+
"pyproject.toml",
|
|
120
|
+
"pytest.ini",
|
|
121
|
+
"tox.ini",
|
|
122
|
+
"noxfile.py",
|
|
123
|
+
"package.json",
|
|
124
|
+
"pnpm-workspace.yaml",
|
|
125
|
+
"jest.config.js",
|
|
126
|
+
"jest.config.ts",
|
|
127
|
+
"vitest.config.js",
|
|
128
|
+
"vitest.config.ts",
|
|
129
|
+
"pom.xml",
|
|
130
|
+
"build.gradle",
|
|
131
|
+
"build.gradle.kts",
|
|
132
|
+
"settings.gradle",
|
|
133
|
+
"settings.gradle.kts",
|
|
134
|
+
"Cargo.toml",
|
|
135
|
+
"Cargo.lock",
|
|
136
|
+
"composer.json",
|
|
137
|
+
"phpunit.xml",
|
|
138
|
+
}
|
|
139
|
+
for rel in changed_files:
|
|
140
|
+
path = Path(rel)
|
|
141
|
+
normalized = path.as_posix()
|
|
142
|
+
if path.name == "conftest.py" or "fixtures" in path.parts:
|
|
143
|
+
return f"Shared test fixture changed: {rel}"
|
|
144
|
+
if normalized in broad_files or path.name in broad_files:
|
|
145
|
+
return f"Project or test configuration changed: {rel}"
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _candidate_tests_for_source(path: Path) -> list[str]:
|
|
150
|
+
stem = path.stem
|
|
151
|
+
suffix = path.suffix.lower()
|
|
152
|
+
parts = list(path.parts)
|
|
153
|
+
candidates: list[str] = []
|
|
154
|
+
if suffix == ".py":
|
|
155
|
+
relative_parts = parts[1:] if parts and parts[0] in {"src", "lib"} else parts
|
|
156
|
+
if relative_parts:
|
|
157
|
+
relative_parts[-1] = f"test_{stem}.py"
|
|
158
|
+
candidates.append(str(Path("tests", *relative_parts)))
|
|
159
|
+
candidates.append(str(Path("tests", f"test_{stem}.py")))
|
|
160
|
+
if suffix in {".js", ".jsx", ".ts", ".tsx"}:
|
|
161
|
+
candidates.extend(
|
|
162
|
+
[
|
|
163
|
+
str(path.with_name(f"{stem}.test{suffix}")),
|
|
164
|
+
str(path.with_name(f"{stem}.spec{suffix}")),
|
|
165
|
+
str(Path("tests", f"{stem}.test{suffix}")),
|
|
166
|
+
str(Path("tests", f"{stem}.spec{suffix}")),
|
|
167
|
+
]
|
|
168
|
+
)
|
|
169
|
+
if suffix == ".java" and parts[:3] == ["src", "main", "java"]:
|
|
170
|
+
test_parts = ["src", "test", "java", *parts[3:]]
|
|
171
|
+
test_parts[-1] = f"{stem}Test.java"
|
|
172
|
+
candidates.append(str(Path(*test_parts)))
|
|
173
|
+
if suffix == ".php":
|
|
174
|
+
rel_parts = parts[1:] if parts and parts[0] == "src" else parts
|
|
175
|
+
if rel_parts:
|
|
176
|
+
rel_parts[-1] = f"{stem}Test.php"
|
|
177
|
+
candidates.append(str(Path("tests", *rel_parts)))
|
|
178
|
+
return candidates
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _python_importing_tests(root: Path, source_path: Path) -> list[str]:
|
|
182
|
+
if source_path.suffix.lower() != ".py" or not (root / "tests").exists():
|
|
183
|
+
return []
|
|
184
|
+
module = ".".join(source_path.with_suffix("").parts)
|
|
185
|
+
if module.startswith("src."):
|
|
186
|
+
module = module[4:]
|
|
187
|
+
needle_options = {
|
|
188
|
+
f"import {module}",
|
|
189
|
+
f"from {module} import",
|
|
190
|
+
f"from {module.rsplit('.', 1)[0]} import" if "." in module else "",
|
|
191
|
+
}
|
|
192
|
+
matches: list[str] = []
|
|
193
|
+
for test_path in (root / "tests").rglob("test_*.py"):
|
|
194
|
+
text = test_path.read_text(encoding="utf-8", errors="replace")
|
|
195
|
+
if any(needle and needle in text for needle in needle_options):
|
|
196
|
+
matches.append(str(test_path.relative_to(root)))
|
|
197
|
+
return matches
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _configured_changed_tests(settings: Settings, changed_files: list[str]) -> list[str]:
|
|
201
|
+
selected: dict[str, None] = {}
|
|
202
|
+
for source_pattern, test_patterns in settings.changed_tests.items():
|
|
203
|
+
if not any(
|
|
204
|
+
fnmatch.fnmatch(path.replace("\\", "/"), source_pattern) for path in changed_files
|
|
205
|
+
):
|
|
206
|
+
continue
|
|
207
|
+
for pattern in test_patterns:
|
|
208
|
+
for match in _matching_test_files(settings.project_root, pattern):
|
|
209
|
+
selected[str(match.relative_to(settings.project_root))] = None
|
|
210
|
+
return sorted(selected)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _matching_test_files(root: Path, pattern: str) -> list[Path]:
|
|
214
|
+
files: dict[Path, None] = {}
|
|
215
|
+
for match in root.glob(pattern):
|
|
216
|
+
if match.is_file():
|
|
217
|
+
files[match] = None
|
|
218
|
+
elif match.is_dir():
|
|
219
|
+
for child in match.rglob("*"):
|
|
220
|
+
if child.is_file():
|
|
221
|
+
files[child] = None
|
|
222
|
+
return sorted(files)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _commands_for_selected_tests(
|
|
226
|
+
plan: list[CheckTask], selected_tests: list[str]
|
|
227
|
+
) -> list[list[str]]:
|
|
228
|
+
commands: list[list[str]] = []
|
|
229
|
+
if not selected_tests:
|
|
230
|
+
return commands
|
|
231
|
+
if any(path.endswith(".py") for path in selected_tests):
|
|
232
|
+
commands.append([sys.executable, "-m", "pytest", *selected_tests])
|
|
233
|
+
if any(path.endswith((".js", ".jsx", ".ts", ".tsx")) for path in selected_tests):
|
|
234
|
+
npm_test = next((task.command for task in plan if task.name == "npm test"), ["npm", "test"])
|
|
235
|
+
commands.append(npm_test)
|
|
236
|
+
if any(path.endswith(".java") for path in selected_tests):
|
|
237
|
+
maven = next((task.command for task in plan if task.name == "maven test"), None)
|
|
238
|
+
gradle = next((task.command for task in plan if task.name == "gradle test"), None)
|
|
239
|
+
commands.append(maven or gradle or ["mvn", "test"])
|
|
240
|
+
if any(path.endswith(".php") for path in selected_tests):
|
|
241
|
+
commands.append(
|
|
242
|
+
next(
|
|
243
|
+
(task.command for task in plan if task.name == "composer test"),
|
|
244
|
+
["composer", "test"],
|
|
245
|
+
)
|
|
246
|
+
)
|
|
247
|
+
return commands
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _is_test_path(path: Path) -> bool:
|
|
251
|
+
text = path.as_posix().lower()
|
|
252
|
+
return (
|
|
253
|
+
"/tests/" in f"/{text}"
|
|
254
|
+
or ".test." in text
|
|
255
|
+
or ".spec." in text
|
|
256
|
+
or path.name.startswith("test_")
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _add_existing(root: Path, selected: dict[str, None], rel: str) -> None:
|
|
261
|
+
path = root / rel
|
|
262
|
+
if path.exists() and path.is_file():
|
|
263
|
+
selected[str(path.relative_to(root))] = None
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _split_nul(output: str) -> list[str]:
|
|
267
|
+
return [item for item in output.split("\0") if item]
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import math
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from ai_dev_tools.cache.repository import read_repository_index
|
|
8
|
+
from ai_dev_tools.cache.validation import validation_cache_stats
|
|
9
|
+
from ai_dev_tools.config import load_settings
|
|
10
|
+
from ai_dev_tools.models.report import Report
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def run_diagnostics(project_root: Path) -> Report:
|
|
14
|
+
settings = load_settings(project_root)
|
|
15
|
+
root = settings.project_root
|
|
16
|
+
report = Report(command="diagnostics", project_root=root)
|
|
17
|
+
config_path = root / ".ai-dev-tools.toml"
|
|
18
|
+
report.summary = {
|
|
19
|
+
"cache": validation_cache_stats(root),
|
|
20
|
+
"repository_index": _index_summary(read_repository_index(root)),
|
|
21
|
+
"reports": _directory_stats(settings.reports_directory),
|
|
22
|
+
"logs": _directory_stats(settings.logs_directory),
|
|
23
|
+
"configuration": {
|
|
24
|
+
"source": str(config_path) if config_path.exists() else "built-in defaults",
|
|
25
|
+
"warnings": settings.warnings,
|
|
26
|
+
"reports_directory": str(settings.reports_directory),
|
|
27
|
+
"logs_directory": str(settings.logs_directory),
|
|
28
|
+
},
|
|
29
|
+
"efficiency_metrics": _efficiency_metrics(root),
|
|
30
|
+
"privacy": "local-only; no repository contents or metrics are transmitted",
|
|
31
|
+
}
|
|
32
|
+
return report
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _directory_stats(directory: Path) -> dict[str, object]:
|
|
36
|
+
files = [path for path in directory.rglob("*") if path.is_file()] if directory.exists() else []
|
|
37
|
+
sizes = [_size(path) for path in files]
|
|
38
|
+
ordered = sorted(files, key=_mtime)
|
|
39
|
+
return {
|
|
40
|
+
"files": len(files),
|
|
41
|
+
"bytes": sum(sizes),
|
|
42
|
+
"oldest": str(ordered[0]) if ordered else None,
|
|
43
|
+
"newest": str(ordered[-1]) if ordered else None,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _index_summary(index: dict[str, object]) -> dict[str, object]:
|
|
48
|
+
summary = index.get("summary")
|
|
49
|
+
return {
|
|
50
|
+
"available": bool(index),
|
|
51
|
+
"schema_version": index.get("schema_version"),
|
|
52
|
+
**(summary if isinstance(summary, dict) else {}),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _efficiency_metrics(root: Path) -> dict[str, int]:
|
|
57
|
+
context_path = root / ".ai" / "context" / "context-latest.json"
|
|
58
|
+
try:
|
|
59
|
+
context = json.loads(context_path.read_text(encoding="utf-8"))
|
|
60
|
+
except (OSError, json.JSONDecodeError):
|
|
61
|
+
context = {}
|
|
62
|
+
summary = context.get("summary", {}) if isinstance(context, dict) else {}
|
|
63
|
+
incremental = summary.get("incremental", {}) if isinstance(summary, dict) else {}
|
|
64
|
+
reused = incremental.get("reused_files", []) if isinstance(incremental, dict) else []
|
|
65
|
+
reused_paths = {str(item) for item in reused} if isinstance(reused, list) else set()
|
|
66
|
+
index = read_repository_index(root)
|
|
67
|
+
entries = index.get("entries", [])
|
|
68
|
+
reused_bytes = 0
|
|
69
|
+
if isinstance(entries, list):
|
|
70
|
+
for entry in entries:
|
|
71
|
+
if isinstance(entry, dict) and entry.get("path") in reused_paths:
|
|
72
|
+
size = entry.get("size")
|
|
73
|
+
reused_bytes += size if isinstance(size, int) else 0
|
|
74
|
+
budget = summary.get("budget", {}) if isinstance(summary, dict) else {}
|
|
75
|
+
used_chars = budget.get("used_chars", 0) if isinstance(budget, dict) else 0
|
|
76
|
+
chars = used_chars if isinstance(used_chars, int) else 0
|
|
77
|
+
return {
|
|
78
|
+
"latest_context_chars": chars,
|
|
79
|
+
"latest_context_token_estimate": math.ceil(chars / 4),
|
|
80
|
+
"reused_context_bytes": reused_bytes,
|
|
81
|
+
"estimated_tokens_avoided": math.ceil(reused_bytes / 4),
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _size(path: Path) -> int:
|
|
86
|
+
try:
|
|
87
|
+
return path.stat().st_size
|
|
88
|
+
except OSError:
|
|
89
|
+
return 0
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _mtime(path: Path) -> int:
|
|
93
|
+
try:
|
|
94
|
+
return path.stat().st_mtime_ns
|
|
95
|
+
except OSError:
|
|
96
|
+
return 0
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from ai_dev_tools.config import load_settings
|
|
10
|
+
from ai_dev_tools.context import ContextOptions, build_context
|
|
11
|
+
from ai_dev_tools.git.inspect import inspect_git
|
|
12
|
+
from ai_dev_tools.models.report import Artifact, Report
|
|
13
|
+
from ai_dev_tools.runners.check import run_check
|
|
14
|
+
from ai_dev_tools.security.secrets import mask_text
|
|
15
|
+
|
|
16
|
+
SESSION_SCHEMA_VERSION = "1"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class FeedbackOptions:
|
|
21
|
+
task: str = ""
|
|
22
|
+
explain: bool = False
|
|
23
|
+
jobs: int = 4
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def run_feedback(project_root: Path, options: FeedbackOptions) -> Report:
|
|
27
|
+
settings = load_settings(project_root)
|
|
28
|
+
root = settings.project_root
|
|
29
|
+
report = Report(command="feedback", project_root=root)
|
|
30
|
+
timings: dict[str, float] = {}
|
|
31
|
+
|
|
32
|
+
started = time.monotonic()
|
|
33
|
+
git_report = inspect_git(root, detailed=True)
|
|
34
|
+
timings["git_inspect"] = _elapsed(started)
|
|
35
|
+
|
|
36
|
+
started = time.monotonic()
|
|
37
|
+
check_report = run_check(
|
|
38
|
+
root,
|
|
39
|
+
mode="changed",
|
|
40
|
+
explain=options.explain,
|
|
41
|
+
jobs=options.jobs,
|
|
42
|
+
policy="feedback-first",
|
|
43
|
+
resume=True,
|
|
44
|
+
)
|
|
45
|
+
timings["validation"] = _elapsed(started)
|
|
46
|
+
|
|
47
|
+
started = time.monotonic()
|
|
48
|
+
context_report = build_context(
|
|
49
|
+
root,
|
|
50
|
+
ContextOptions(
|
|
51
|
+
task=options.task,
|
|
52
|
+
profile="minimal",
|
|
53
|
+
incremental=not options.explain,
|
|
54
|
+
explain=options.explain,
|
|
55
|
+
),
|
|
56
|
+
)
|
|
57
|
+
timings["context"] = _elapsed(started)
|
|
58
|
+
|
|
59
|
+
changed = [str(item) for item in git_report.summary.get("changed_files", [])]
|
|
60
|
+
failures = _failure_signatures(check_report)
|
|
61
|
+
blocking_codes = [
|
|
62
|
+
str(issue.code or "VALIDATION_FAILED")
|
|
63
|
+
for issue in check_report.issues
|
|
64
|
+
if issue.severity in {"error", "critical"}
|
|
65
|
+
]
|
|
66
|
+
if check_report.status == "failed" and not blocking_codes:
|
|
67
|
+
blocking_codes.append("VALIDATION_FAILED")
|
|
68
|
+
ready = check_report.status in {"success", "partial"} and not blocking_codes
|
|
69
|
+
report.status = "success" if ready else "failed"
|
|
70
|
+
report.summary = {
|
|
71
|
+
"agent_protocol_version": "1",
|
|
72
|
+
"decision": {
|
|
73
|
+
"ready": ready,
|
|
74
|
+
"status": report.status,
|
|
75
|
+
"confidence": _confidence(check_report),
|
|
76
|
+
"blocking_reason_codes": sorted(set(blocking_codes)),
|
|
77
|
+
},
|
|
78
|
+
"changes": {
|
|
79
|
+
"files": changed,
|
|
80
|
+
"count": len(changed),
|
|
81
|
+
"git_states": git_report.summary.get("states", []),
|
|
82
|
+
},
|
|
83
|
+
"validation": {
|
|
84
|
+
"status": check_report.status,
|
|
85
|
+
"checks_total": check_report.summary.get("checks_total", 0),
|
|
86
|
+
"checks_failed": check_report.summary.get("checks_failed", 0),
|
|
87
|
+
"first_failure": check_report.summary.get("first_failure"),
|
|
88
|
+
"failure_signatures": failures,
|
|
89
|
+
"results": check_report.summary.get("results", []),
|
|
90
|
+
"execution": check_report.summary.get("execution", {}),
|
|
91
|
+
},
|
|
92
|
+
"context": {
|
|
93
|
+
"status": context_report.status,
|
|
94
|
+
"selected_files": context_report.summary.get("selected_files", []),
|
|
95
|
+
"incremental": context_report.summary.get("incremental", {}),
|
|
96
|
+
"budget": context_report.summary.get("budget", {}),
|
|
97
|
+
},
|
|
98
|
+
"performance": {
|
|
99
|
+
"stages_seconds": timings,
|
|
100
|
+
"total_seconds": round(sum(timings.values()), 3),
|
|
101
|
+
},
|
|
102
|
+
"expand": [
|
|
103
|
+
{"kind": artifact.kind, "path": artifact.path}
|
|
104
|
+
for artifact in [*check_report.artifacts, *context_report.artifacts]
|
|
105
|
+
],
|
|
106
|
+
}
|
|
107
|
+
session_path = _write_session(
|
|
108
|
+
root,
|
|
109
|
+
{
|
|
110
|
+
"schema_version": SESSION_SCHEMA_VERSION,
|
|
111
|
+
"task": options.task,
|
|
112
|
+
"changed_files": changed,
|
|
113
|
+
"validation_status": check_report.status,
|
|
114
|
+
"failure_signatures": failures,
|
|
115
|
+
"context_incremental": context_report.summary.get("incremental", {}),
|
|
116
|
+
"performance": timings,
|
|
117
|
+
},
|
|
118
|
+
)
|
|
119
|
+
report.artifacts.append(Artifact(str(session_path), "session", "Local agent session state"))
|
|
120
|
+
report.artifacts.extend(_unique_artifacts([*check_report.artifacts, *context_report.artifacts]))
|
|
121
|
+
return report
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def run_session_status(project_root: Path) -> Report:
|
|
125
|
+
root = project_root.resolve()
|
|
126
|
+
report = Report(command="session status", project_root=root)
|
|
127
|
+
try:
|
|
128
|
+
payload = json.loads(_session_path(root).read_text(encoding="utf-8"))
|
|
129
|
+
except (OSError, json.JSONDecodeError):
|
|
130
|
+
report.status = "partial"
|
|
131
|
+
report.summary = {
|
|
132
|
+
"message": "No valid local session state exists.",
|
|
133
|
+
"reason_code": "SESSION_MISSING",
|
|
134
|
+
}
|
|
135
|
+
return report
|
|
136
|
+
if not isinstance(payload, dict) or payload.get("schema_version") != SESSION_SCHEMA_VERSION:
|
|
137
|
+
report.status = "partial"
|
|
138
|
+
report.summary = {
|
|
139
|
+
"message": "Local session state uses an unsupported schema.",
|
|
140
|
+
"reason_code": "SESSION_SCHEMA_UNSUPPORTED",
|
|
141
|
+
}
|
|
142
|
+
return report
|
|
143
|
+
report.summary = payload
|
|
144
|
+
report.artifacts.append(
|
|
145
|
+
Artifact(str(_session_path(root)), "session", "Local agent session state")
|
|
146
|
+
)
|
|
147
|
+
return report
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _failure_signatures(report: Report) -> list[str]:
|
|
151
|
+
results = report.summary.get("results")
|
|
152
|
+
if not isinstance(results, list):
|
|
153
|
+
return []
|
|
154
|
+
return sorted(
|
|
155
|
+
{
|
|
156
|
+
str(item["failure_signature"])
|
|
157
|
+
for item in results
|
|
158
|
+
if isinstance(item, dict) and item.get("failure_signature")
|
|
159
|
+
}
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _confidence(report: Report) -> str:
|
|
164
|
+
changed = report.summary.get("changed_analysis")
|
|
165
|
+
if isinstance(changed, dict) and isinstance(changed.get("confidence"), str):
|
|
166
|
+
return str(changed["confidence"])
|
|
167
|
+
return "medium" if report.status == "partial" else "high"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _write_session(root: Path, payload: dict[str, object]) -> Path:
|
|
171
|
+
path = _session_path(root)
|
|
172
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
173
|
+
temporary = path.with_suffix(".tmp")
|
|
174
|
+
temporary.write_text(
|
|
175
|
+
mask_text(json.dumps(payload, indent=2, sort_keys=True) + "\n"), encoding="utf-8"
|
|
176
|
+
)
|
|
177
|
+
os.replace(temporary, path)
|
|
178
|
+
return path
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _session_path(root: Path) -> Path:
|
|
182
|
+
return root / ".ai" / "cache" / "session.json"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _elapsed(started: float) -> float:
|
|
186
|
+
return round(time.monotonic() - started, 3)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _unique_artifacts(artifacts: list[Artifact]) -> list[Artifact]:
|
|
190
|
+
unique: dict[tuple[str, str], Artifact] = {}
|
|
191
|
+
for artifact in artifacts:
|
|
192
|
+
unique[(artifact.path, artifact.kind)] = artifact
|
|
193
|
+
return list(unique.values())
|