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,509 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
from dataclasses import replace
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from ai_dev_tools.cache.repository import update_repository_index
|
|
12
|
+
from ai_dev_tools.cache.validation import (
|
|
13
|
+
load_validation_result,
|
|
14
|
+
store_validation_result,
|
|
15
|
+
validation_cache_key,
|
|
16
|
+
)
|
|
17
|
+
from ai_dev_tools.config import Settings, load_settings
|
|
18
|
+
from ai_dev_tools.detectors.runtime import detect_runtime_requirements
|
|
19
|
+
from ai_dev_tools.detectors.workspaces import detect_workspaces
|
|
20
|
+
from ai_dev_tools.models.report import Artifact, Report
|
|
21
|
+
from ai_dev_tools.parsers.logs import parse_tool_output
|
|
22
|
+
from ai_dev_tools.reporters.writer import write_json, write_markdown
|
|
23
|
+
from ai_dev_tools.runners import check_selection as _selection
|
|
24
|
+
from ai_dev_tools.runners.check_checkpoint import load_resume_keys, write_checkpoint
|
|
25
|
+
from ai_dev_tools.runners.check_models import (
|
|
26
|
+
ChangedSelection as ChangedSelection,
|
|
27
|
+
)
|
|
28
|
+
from ai_dev_tools.runners.check_models import (
|
|
29
|
+
CheckCategory,
|
|
30
|
+
CheckCost,
|
|
31
|
+
)
|
|
32
|
+
from ai_dev_tools.runners.check_models import (
|
|
33
|
+
CheckTask as CheckTask,
|
|
34
|
+
)
|
|
35
|
+
from ai_dev_tools.runners.check_scheduler import schedule_checks, schedule_graph
|
|
36
|
+
from ai_dev_tools.runners.focused import focused_rerun
|
|
37
|
+
from ai_dev_tools.security.secrets import mask_text
|
|
38
|
+
from ai_dev_tools.utils.subprocess import CommandResult, run_command, split_command
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def collect_changed_files(root: Path) -> list[str]:
|
|
42
|
+
return _selection.collect_changed_files(root, run_command)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def infer_tests_for_changed_files(root: Path, changed_files: list[str]) -> list[str]:
|
|
46
|
+
return _selection.infer_tests_for_changed_files(root, changed_files)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def select_changed_checks(settings: Settings, plan: list[CheckTask]) -> ChangedSelection:
|
|
50
|
+
return _selection.select_changed_checks(settings, plan, collect_changed_files)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def run_check(
|
|
54
|
+
project_root: Path,
|
|
55
|
+
mode: str = "fast",
|
|
56
|
+
explain: bool = False,
|
|
57
|
+
jobs: int = 1,
|
|
58
|
+
use_cache: bool = True,
|
|
59
|
+
policy: str = "complete",
|
|
60
|
+
resume: bool = False,
|
|
61
|
+
) -> Report:
|
|
62
|
+
settings = load_settings(project_root)
|
|
63
|
+
plan = build_validation_plan(settings)
|
|
64
|
+
changed_selection = select_changed_checks(settings, plan) if mode == "changed" else None
|
|
65
|
+
tasks = _tasks_for_mode(plan, mode, changed_selection)
|
|
66
|
+
command = f"check --mode {mode}" + (" --explain" if explain else "")
|
|
67
|
+
report = Report(command=command, project_root=settings.project_root)
|
|
68
|
+
if explain:
|
|
69
|
+
report.summary = {
|
|
70
|
+
"mode": mode,
|
|
71
|
+
"plan": [task.to_dict() for task in plan],
|
|
72
|
+
"selected_checks": [task.to_dict() for task in tasks],
|
|
73
|
+
"changed_analysis": changed_selection.to_dict() if changed_selection else None,
|
|
74
|
+
"explain_only": True,
|
|
75
|
+
"schedule": schedule_graph(tasks, policy),
|
|
76
|
+
"resume_requested": resume,
|
|
77
|
+
}
|
|
78
|
+
_add_runtime_summary(report, settings.project_root)
|
|
79
|
+
report.finish()
|
|
80
|
+
_write_check_reports(report, f"{mode}-explain")
|
|
81
|
+
return report
|
|
82
|
+
if not tasks:
|
|
83
|
+
report.status = "partial"
|
|
84
|
+
report.summary = {
|
|
85
|
+
"mode": mode,
|
|
86
|
+
"message": "No configured checks detected",
|
|
87
|
+
"reason_code": "NO_CHECKS_DETECTED",
|
|
88
|
+
"plan": [task.to_dict() for task in plan],
|
|
89
|
+
}
|
|
90
|
+
if changed_selection is not None:
|
|
91
|
+
report.summary["changed_analysis"] = changed_selection.to_dict()
|
|
92
|
+
_add_runtime_summary(report, settings.project_root)
|
|
93
|
+
report.finish()
|
|
94
|
+
_write_check_reports(report, mode)
|
|
95
|
+
return report
|
|
96
|
+
|
|
97
|
+
index = update_repository_index(settings.project_root)
|
|
98
|
+
entries = index.get("entries", [])
|
|
99
|
+
selected_tasks = tasks
|
|
100
|
+
cache_enabled = use_cache or resume
|
|
101
|
+
task_keys = [
|
|
102
|
+
validation_cache_key(entries, task.command, task.workspace) for task in selected_tasks
|
|
103
|
+
]
|
|
104
|
+
resume_keys = load_resume_keys(settings.project_root, set(task_keys)) if resume else set()
|
|
105
|
+
worker_count = max(1, min(jobs, len(selected_tasks)))
|
|
106
|
+
scheduled = schedule_checks(
|
|
107
|
+
selected_tasks,
|
|
108
|
+
worker_count,
|
|
109
|
+
policy,
|
|
110
|
+
lambda task: _run_logged(
|
|
111
|
+
task,
|
|
112
|
+
settings.project_root,
|
|
113
|
+
settings.logs_directory,
|
|
114
|
+
entries,
|
|
115
|
+
cache_enabled,
|
|
116
|
+
),
|
|
117
|
+
)
|
|
118
|
+
tasks = scheduled.tasks
|
|
119
|
+
results = scheduled.results
|
|
120
|
+
|
|
121
|
+
failed = [result for result in results if result.exit_code != 0]
|
|
122
|
+
report.status = "failed" if failed else "success"
|
|
123
|
+
report.summary = _summary_for_results(mode, plan, tasks, results, changed_selection)
|
|
124
|
+
report.summary["selected_checks"] = [task.to_dict() for task in selected_tasks]
|
|
125
|
+
result_rows = report.summary.get("results", [])
|
|
126
|
+
if isinstance(result_rows, list):
|
|
127
|
+
for task, result, row in zip(tasks, results, result_rows, strict=True):
|
|
128
|
+
if not isinstance(row, dict):
|
|
129
|
+
continue
|
|
130
|
+
key = validation_cache_key(entries, task.command, task.workspace)
|
|
131
|
+
row["reuse"] = (
|
|
132
|
+
"resumed"
|
|
133
|
+
if result.cached and key in resume_keys
|
|
134
|
+
else "cached"
|
|
135
|
+
if result.cached
|
|
136
|
+
else "executed"
|
|
137
|
+
)
|
|
138
|
+
successful_keys = [
|
|
139
|
+
validation_cache_key(entries, task.command, task.workspace)
|
|
140
|
+
for task, result in zip(tasks, results, strict=True)
|
|
141
|
+
if result.exit_code == 0
|
|
142
|
+
]
|
|
143
|
+
checkpoint_path = write_checkpoint(
|
|
144
|
+
settings.project_root,
|
|
145
|
+
mode=mode,
|
|
146
|
+
task_keys=task_keys,
|
|
147
|
+
successful_keys=successful_keys,
|
|
148
|
+
cancelled=len(scheduled.cancelled),
|
|
149
|
+
)
|
|
150
|
+
report.artifacts.append(Artifact(str(checkpoint_path), "checkpoint", "Validation resume state"))
|
|
151
|
+
index_summary = index.get("summary", {})
|
|
152
|
+
report.summary["execution"] = {
|
|
153
|
+
"jobs": worker_count,
|
|
154
|
+
"parallel": worker_count > 1,
|
|
155
|
+
"policy": policy,
|
|
156
|
+
"schedule": schedule_graph(selected_tasks, policy),
|
|
157
|
+
"cancelled": [task.to_dict() for task in scheduled.cancelled],
|
|
158
|
+
"cache_enabled": cache_enabled,
|
|
159
|
+
"cache_hits": sum(result.cached for result in results),
|
|
160
|
+
"resumed": sum(
|
|
161
|
+
result.cached
|
|
162
|
+
and validation_cache_key(entries, task.command, task.workspace) in resume_keys
|
|
163
|
+
for task, result in zip(tasks, results, strict=True)
|
|
164
|
+
),
|
|
165
|
+
"wall_seconds": scheduled.wall_seconds,
|
|
166
|
+
"aggregate_subprocess_seconds": scheduled.aggregate_seconds,
|
|
167
|
+
"time_to_first_failure_seconds": scheduled.time_to_first_failure_seconds,
|
|
168
|
+
"index": index_summary,
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
_add_runtime_summary(report, settings.project_root)
|
|
172
|
+
report.finish()
|
|
173
|
+
_write_check_reports(report, mode)
|
|
174
|
+
return report
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def build_validation_plan(
|
|
178
|
+
settings: Settings, *, include_workspaces: bool = True
|
|
179
|
+
) -> list[CheckTask]:
|
|
180
|
+
if settings.commands:
|
|
181
|
+
return _configured_plan(settings.commands)
|
|
182
|
+
root = settings.project_root
|
|
183
|
+
tasks: list[CheckTask] = []
|
|
184
|
+
if (root / "pyproject.toml").exists() or (root / "requirements.txt").exists():
|
|
185
|
+
text = (
|
|
186
|
+
(root / "pyproject.toml").read_text(encoding="utf-8", errors="replace")
|
|
187
|
+
if (root / "pyproject.toml").exists()
|
|
188
|
+
else ""
|
|
189
|
+
)
|
|
190
|
+
if "ruff" in text:
|
|
191
|
+
tasks.append(
|
|
192
|
+
CheckTask(
|
|
193
|
+
"ruff", "lint", [sys.executable, "-m", "ruff", "check", "."], "fast", "detected"
|
|
194
|
+
)
|
|
195
|
+
)
|
|
196
|
+
if "mypy" in text:
|
|
197
|
+
tasks.append(
|
|
198
|
+
CheckTask(
|
|
199
|
+
"mypy",
|
|
200
|
+
"typecheck",
|
|
201
|
+
[sys.executable, "-m", "mypy", "src", "tests"],
|
|
202
|
+
"medium",
|
|
203
|
+
"detected",
|
|
204
|
+
)
|
|
205
|
+
)
|
|
206
|
+
if "black" in text:
|
|
207
|
+
tasks.append(
|
|
208
|
+
CheckTask(
|
|
209
|
+
"black",
|
|
210
|
+
"format",
|
|
211
|
+
[sys.executable, "-m", "black", "--check", "."],
|
|
212
|
+
"fast",
|
|
213
|
+
"detected",
|
|
214
|
+
)
|
|
215
|
+
)
|
|
216
|
+
if (root / "tests").exists():
|
|
217
|
+
tasks.append(
|
|
218
|
+
CheckTask(
|
|
219
|
+
"pytest", "unit_tests", [sys.executable, "-m", "pytest"], "medium", "detected"
|
|
220
|
+
)
|
|
221
|
+
)
|
|
222
|
+
if (root / "package.json").exists():
|
|
223
|
+
package = (root / "package.json").read_text(encoding="utf-8", errors="replace")
|
|
224
|
+
if "eslint" in package:
|
|
225
|
+
tasks.append(
|
|
226
|
+
CheckTask("npm lint", "lint", ["npm", "run", "lint"], "medium", "detected")
|
|
227
|
+
)
|
|
228
|
+
if "typescript" in package or "tsc" in package:
|
|
229
|
+
tasks.append(
|
|
230
|
+
CheckTask(
|
|
231
|
+
"npm typecheck", "typecheck", ["npm", "run", "typecheck"], "medium", "detected"
|
|
232
|
+
)
|
|
233
|
+
)
|
|
234
|
+
if '"test"' in package:
|
|
235
|
+
tasks.append(CheckTask("npm test", "unit_tests", ["npm", "test"], "medium", "detected"))
|
|
236
|
+
if '"build"' in package:
|
|
237
|
+
tasks.append(
|
|
238
|
+
CheckTask(
|
|
239
|
+
"npm build",
|
|
240
|
+
"build",
|
|
241
|
+
["npm", "run", "build"],
|
|
242
|
+
"slow",
|
|
243
|
+
"detected",
|
|
244
|
+
required=False,
|
|
245
|
+
)
|
|
246
|
+
)
|
|
247
|
+
if (root / "Cargo.toml").exists():
|
|
248
|
+
tasks.extend(
|
|
249
|
+
[
|
|
250
|
+
CheckTask("cargo fmt", "format", ["cargo", "fmt", "--check"], "fast", "detected"),
|
|
251
|
+
CheckTask("cargo clippy", "lint", ["cargo", "clippy"], "medium", "detected"),
|
|
252
|
+
CheckTask("cargo test", "unit_tests", ["cargo", "test"], "medium", "detected"),
|
|
253
|
+
]
|
|
254
|
+
)
|
|
255
|
+
if (root / "pom.xml").exists():
|
|
256
|
+
tasks.append(CheckTask("maven test", "unit_tests", ["mvn", "test"], "slow", "detected"))
|
|
257
|
+
if (root / "build.gradle").exists() or (root / "build.gradle.kts").exists():
|
|
258
|
+
tasks.append(CheckTask("gradle test", "unit_tests", ["gradle", "test"], "slow", "detected"))
|
|
259
|
+
if (root / "composer.json").exists():
|
|
260
|
+
tasks.append(
|
|
261
|
+
CheckTask("composer test", "unit_tests", ["composer", "test"], "medium", "detected")
|
|
262
|
+
)
|
|
263
|
+
if include_workspaces:
|
|
264
|
+
for workspace in detect_workspaces(root):
|
|
265
|
+
if not workspace.root:
|
|
266
|
+
continue
|
|
267
|
+
workspace_root = root / workspace.root
|
|
268
|
+
child_settings = load_settings(workspace_root)
|
|
269
|
+
child_tasks = build_validation_plan(child_settings, include_workspaces=False)
|
|
270
|
+
tasks.extend(replace(task, workspace=workspace.root) for task in child_tasks)
|
|
271
|
+
return sorted(_deduplicate_tasks(tasks), key=_task_order)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _configured_plan(commands: dict[str, str]) -> list[CheckTask]:
|
|
275
|
+
mapping: list[tuple[str, CheckCategory, CheckCost]] = [
|
|
276
|
+
("format", "format", "fast"),
|
|
277
|
+
("lint", "lint", "fast"),
|
|
278
|
+
("typecheck", "typecheck", "medium"),
|
|
279
|
+
("test", "unit_tests", "medium"),
|
|
280
|
+
("integration_test", "integration_tests", "slow"),
|
|
281
|
+
("build", "build", "slow"),
|
|
282
|
+
]
|
|
283
|
+
tasks = [
|
|
284
|
+
CheckTask(key, category, split_command(commands[key]), cost, "configured")
|
|
285
|
+
for key, category, cost in mapping
|
|
286
|
+
if key in commands
|
|
287
|
+
]
|
|
288
|
+
return sorted(tasks, key=_task_order)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _tasks_for_mode(
|
|
292
|
+
plan: list[CheckTask], mode: str, changed: ChangedSelection | None
|
|
293
|
+
) -> list[CheckTask]:
|
|
294
|
+
if mode == "full":
|
|
295
|
+
return plan
|
|
296
|
+
if mode == "fast":
|
|
297
|
+
fast_tasks = [
|
|
298
|
+
task for task in plan if task.cost == "fast" or task.category in {"lint", "typecheck"}
|
|
299
|
+
]
|
|
300
|
+
return fast_tasks or [task for task in plan if task.category == "unit_tests"][:1]
|
|
301
|
+
broad_strategies = {"no_changes", "configuration_change"}
|
|
302
|
+
if changed is None or changed.strategy in broad_strategies:
|
|
303
|
+
return [
|
|
304
|
+
task for task in plan if task.category in {"format", "lint", "typecheck", "unit_tests"}
|
|
305
|
+
]
|
|
306
|
+
relevant_plan = _tasks_for_changed_workspaces(plan, changed.changed_files)
|
|
307
|
+
if changed.strategy == "broad_fallback":
|
|
308
|
+
return [
|
|
309
|
+
task
|
|
310
|
+
for task in relevant_plan
|
|
311
|
+
if task.category in {"format", "lint", "typecheck", "unit_tests"}
|
|
312
|
+
]
|
|
313
|
+
selected_commands = {tuple(command) for command in changed.selected_commands}
|
|
314
|
+
selected_tasks = [
|
|
315
|
+
task for task in relevant_plan if task.category in {"format", "lint", "typecheck"}
|
|
316
|
+
]
|
|
317
|
+
for command in selected_commands:
|
|
318
|
+
workspace = _workspace_for_command(relevant_plan, list(command))
|
|
319
|
+
selected_tasks.append(
|
|
320
|
+
CheckTask(
|
|
321
|
+
"affected tests",
|
|
322
|
+
"unit_tests",
|
|
323
|
+
_command_relative_to_workspace(list(command), workspace),
|
|
324
|
+
"fast",
|
|
325
|
+
"detected",
|
|
326
|
+
workspace=workspace,
|
|
327
|
+
)
|
|
328
|
+
)
|
|
329
|
+
return sorted(selected_tasks, key=_task_order)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _run_logged(
|
|
333
|
+
task: CheckTask,
|
|
334
|
+
root: Path,
|
|
335
|
+
logs_dir: Path,
|
|
336
|
+
index_entries: object,
|
|
337
|
+
use_cache: bool,
|
|
338
|
+
) -> CommandResult:
|
|
339
|
+
logs_dir.mkdir(parents=True, exist_ok=True)
|
|
340
|
+
log_path = logs_dir / f"check-{datetime.now().strftime('%Y%m%d-%H%M%S-%f')}.log"
|
|
341
|
+
working_directory = root / task.workspace if task.workspace else root
|
|
342
|
+
cache_key = validation_cache_key(index_entries, task.command, task.workspace)
|
|
343
|
+
result = load_validation_result(root, cache_key, task.command) if use_cache else None
|
|
344
|
+
if result is None:
|
|
345
|
+
result = run_command(task.command, working_directory)
|
|
346
|
+
result.stdout = mask_text(result.stdout)
|
|
347
|
+
result.stderr = mask_text(result.stderr)
|
|
348
|
+
if use_cache:
|
|
349
|
+
store_validation_result(root, cache_key, result)
|
|
350
|
+
log_path.write_text(result.combined_output + "\n", encoding="utf-8")
|
|
351
|
+
result.stdout += f"\nFULL_LOG: {log_path}"
|
|
352
|
+
return result
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _summary_for_results(
|
|
356
|
+
mode: str,
|
|
357
|
+
plan: list[CheckTask],
|
|
358
|
+
tasks: list[CheckTask],
|
|
359
|
+
results: list[CommandResult],
|
|
360
|
+
changed_selection: ChangedSelection | None,
|
|
361
|
+
) -> dict[str, object]:
|
|
362
|
+
result_summaries = [
|
|
363
|
+
_result_summary(task, result) for task, result in zip(tasks, results, strict=True)
|
|
364
|
+
]
|
|
365
|
+
failed_results = [item for item in result_summaries if item["exit_code"] != 0]
|
|
366
|
+
first_failure = next(
|
|
367
|
+
(item.get("first_failure") for item in failed_results if item.get("first_failure")), None
|
|
368
|
+
)
|
|
369
|
+
summary: dict[str, object] = {
|
|
370
|
+
"mode": mode,
|
|
371
|
+
"checks_total": len(result_summaries),
|
|
372
|
+
"checks_passed": len(result_summaries) - len(failed_results),
|
|
373
|
+
"checks_failed": len(failed_results),
|
|
374
|
+
"tests_total": sum(_int_value(item.get("tests_total")) for item in result_summaries),
|
|
375
|
+
"tests_passed": sum(_int_value(item.get("passed")) for item in result_summaries),
|
|
376
|
+
"tests_failed": sum(_int_value(item.get("failed")) for item in result_summaries),
|
|
377
|
+
"tests_skipped": sum(_int_value(item.get("skipped")) for item in result_summaries),
|
|
378
|
+
"first_failure": first_failure,
|
|
379
|
+
"plan": [task.to_dict() for task in plan],
|
|
380
|
+
"selected_checks": [task.to_dict() for task in tasks],
|
|
381
|
+
"results": result_summaries,
|
|
382
|
+
}
|
|
383
|
+
if changed_selection is not None:
|
|
384
|
+
summary["changed_analysis"] = changed_selection.to_dict()
|
|
385
|
+
return summary
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _int_value(value: object) -> int:
|
|
389
|
+
return value if isinstance(value, int) else 0
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _result_summary(task: CheckTask, result: CommandResult) -> dict[str, object]:
|
|
393
|
+
tool_label = " ".join(task.command)
|
|
394
|
+
parsed = parse_tool_output(tool_label, result.combined_output)
|
|
395
|
+
failure_signature = _failure_signature(task, parsed) if result.exit_code != 0 else None
|
|
396
|
+
return {
|
|
397
|
+
"name": task.name,
|
|
398
|
+
"category": task.category,
|
|
399
|
+
"workspace": task.workspace,
|
|
400
|
+
"command": " ".join(result.command),
|
|
401
|
+
"exit_code": result.exit_code,
|
|
402
|
+
"duration_seconds": result.duration_seconds,
|
|
403
|
+
"timed_out": result.timed_out,
|
|
404
|
+
"cached": result.cached,
|
|
405
|
+
"full_log": _full_log_from_output(result.stdout),
|
|
406
|
+
"failure_signature": failure_signature,
|
|
407
|
+
"focused_rerun": focused_rerun(task, parsed) if failure_signature else None,
|
|
408
|
+
**parsed,
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _failure_signature(task: CheckTask, parsed: dict[str, object]) -> str:
|
|
413
|
+
failure = parsed.get("first_failure")
|
|
414
|
+
canonical = {
|
|
415
|
+
"tool": parsed.get("parser"),
|
|
416
|
+
"workspace": task.workspace,
|
|
417
|
+
"category": task.category,
|
|
418
|
+
"failure": failure,
|
|
419
|
+
}
|
|
420
|
+
text = json.dumps(canonical, sort_keys=True, ensure_ascii=True).lower()
|
|
421
|
+
text = re.sub(r"\b\d+\b", "#", text)
|
|
422
|
+
digest = hashlib.sha256(text.encode("utf-8")).hexdigest()[:16]
|
|
423
|
+
return f"failure:{digest}"
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _task_order(task: CheckTask) -> tuple[int, str, str]:
|
|
427
|
+
category_order = {
|
|
428
|
+
"format": 0,
|
|
429
|
+
"lint": 1,
|
|
430
|
+
"typecheck": 2,
|
|
431
|
+
"unit_tests": 3,
|
|
432
|
+
"integration_tests": 4,
|
|
433
|
+
"build": 5,
|
|
434
|
+
}
|
|
435
|
+
return (category_order[task.category], task.workspace, task.name)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _tasks_for_changed_workspaces(
|
|
439
|
+
plan: list[CheckTask], changed_files: list[str]
|
|
440
|
+
) -> list[CheckTask]:
|
|
441
|
+
workspace_roots = sorted(
|
|
442
|
+
{task.workspace for task in plan if task.workspace},
|
|
443
|
+
key=len,
|
|
444
|
+
reverse=True,
|
|
445
|
+
)
|
|
446
|
+
selected: set[str] = set()
|
|
447
|
+
for path in changed_files:
|
|
448
|
+
normalized = path.replace(chr(92), "/").strip("/")
|
|
449
|
+
owner = next(
|
|
450
|
+
(
|
|
451
|
+
root
|
|
452
|
+
for root in workspace_roots
|
|
453
|
+
if normalized == root or normalized.startswith(root + "/")
|
|
454
|
+
),
|
|
455
|
+
"",
|
|
456
|
+
)
|
|
457
|
+
selected.add(owner)
|
|
458
|
+
return [task for task in plan if task.workspace in selected]
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _workspace_for_command(plan: list[CheckTask], command: list[str]) -> str:
|
|
462
|
+
normalized_arguments = [item.replace(chr(92), "/") for item in command]
|
|
463
|
+
roots = sorted({task.workspace for task in plan if task.workspace}, key=len, reverse=True)
|
|
464
|
+
return next(
|
|
465
|
+
(
|
|
466
|
+
root
|
|
467
|
+
for root in roots
|
|
468
|
+
if any(argument.startswith(root + "/") for argument in normalized_arguments)
|
|
469
|
+
),
|
|
470
|
+
"",
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _command_relative_to_workspace(command: list[str], workspace: str) -> list[str]:
|
|
475
|
+
if not workspace:
|
|
476
|
+
return command
|
|
477
|
+
prefix = workspace.replace(chr(92), "/").rstrip("/") + "/"
|
|
478
|
+
return [
|
|
479
|
+
item.replace(chr(92), "/")[len(prefix) :]
|
|
480
|
+
if item.replace(chr(92), "/").startswith(prefix)
|
|
481
|
+
else item
|
|
482
|
+
for item in command
|
|
483
|
+
]
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _deduplicate_tasks(tasks: list[CheckTask]) -> list[CheckTask]:
|
|
487
|
+
unique: dict[tuple[str, str, tuple[str, ...]], CheckTask] = {}
|
|
488
|
+
for task in tasks:
|
|
489
|
+
unique[(task.workspace, task.category, tuple(task.command))] = task
|
|
490
|
+
return list(unique.values())
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _full_log_from_output(output: str) -> str | None:
|
|
494
|
+
for line in output.splitlines():
|
|
495
|
+
if line.startswith("FULL_LOG:"):
|
|
496
|
+
return line.split(":", 1)[1].strip()
|
|
497
|
+
return None
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _add_runtime_summary(report: Report, root: Path) -> None:
|
|
501
|
+
report.summary["runtime_requirements"] = [
|
|
502
|
+
item.to_dict() for item in detect_runtime_requirements(root)
|
|
503
|
+
]
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _write_check_reports(report: Report, mode: str) -> None:
|
|
507
|
+
settings = load_settings(report.project_root)
|
|
508
|
+
write_markdown(report, settings.reports_directory / f"check-{mode}-latest.md")
|
|
509
|
+
write_json(report, settings.reports_directory / f"check-{mode}-latest.json")
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
CHECKPOINT_SCHEMA_VERSION = "1"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def load_resume_keys(root: Path, current_keys: set[str]) -> set[str]:
|
|
12
|
+
try:
|
|
13
|
+
payload = json.loads(_checkpoint_path(root).read_text(encoding="utf-8"))
|
|
14
|
+
except (OSError, json.JSONDecodeError):
|
|
15
|
+
return set()
|
|
16
|
+
if not isinstance(payload, dict) or payload.get("schema_version") != CHECKPOINT_SCHEMA_VERSION:
|
|
17
|
+
return set()
|
|
18
|
+
completed = payload.get("completed_keys")
|
|
19
|
+
if not isinstance(completed, list):
|
|
20
|
+
return set()
|
|
21
|
+
return {key for key in completed if isinstance(key, str) and key in current_keys}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def write_checkpoint(
|
|
25
|
+
root: Path,
|
|
26
|
+
*,
|
|
27
|
+
mode: str,
|
|
28
|
+
task_keys: list[str],
|
|
29
|
+
successful_keys: list[str],
|
|
30
|
+
cancelled: int,
|
|
31
|
+
) -> Path:
|
|
32
|
+
path = _checkpoint_path(root)
|
|
33
|
+
payload = {
|
|
34
|
+
"schema_version": CHECKPOINT_SCHEMA_VERSION,
|
|
35
|
+
"mode": mode,
|
|
36
|
+
"task_keys": task_keys,
|
|
37
|
+
"completed_keys": successful_keys,
|
|
38
|
+
"cancelled": cancelled,
|
|
39
|
+
"complete": len(successful_keys) == len(task_keys),
|
|
40
|
+
"updated_at": datetime.now(UTC).isoformat(),
|
|
41
|
+
}
|
|
42
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
temporary = path.with_suffix(".tmp")
|
|
44
|
+
temporary.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
45
|
+
os.replace(temporary, path)
|
|
46
|
+
return path
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _checkpoint_path(root: Path) -> Path:
|
|
50
|
+
return root.resolve() / ".ai" / "cache" / "check-checkpoint.json"
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict, dataclass
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
CheckCategory = Literal["format", "lint", "typecheck", "unit_tests", "integration_tests", "build"]
|
|
7
|
+
CheckCost = Literal["fast", "medium", "slow"]
|
|
8
|
+
CheckSource = Literal["detected", "configured"]
|
|
9
|
+
ChangedStrategy = Literal[
|
|
10
|
+
"changed_test_direct",
|
|
11
|
+
"direct_test_match",
|
|
12
|
+
"module_match",
|
|
13
|
+
"package_match",
|
|
14
|
+
"workspace_match",
|
|
15
|
+
"configured_mapping",
|
|
16
|
+
"configuration_change",
|
|
17
|
+
"broad_fallback",
|
|
18
|
+
"no_changes",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class CheckTask:
|
|
24
|
+
name: str
|
|
25
|
+
category: CheckCategory
|
|
26
|
+
command: list[str]
|
|
27
|
+
cost: CheckCost
|
|
28
|
+
source: CheckSource
|
|
29
|
+
required: bool = True
|
|
30
|
+
workspace: str = ""
|
|
31
|
+
|
|
32
|
+
def to_dict(self) -> dict[str, object]:
|
|
33
|
+
return asdict(self)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True, slots=True)
|
|
37
|
+
class ChangedSelection:
|
|
38
|
+
strategy: ChangedStrategy
|
|
39
|
+
confidence: str
|
|
40
|
+
changed_files: list[str]
|
|
41
|
+
selected_tests: list[str]
|
|
42
|
+
selected_commands: list[list[str]]
|
|
43
|
+
fallback_reason: str | None = None
|
|
44
|
+
|
|
45
|
+
def to_dict(self) -> dict[str, object]:
|
|
46
|
+
return {
|
|
47
|
+
**asdict(self),
|
|
48
|
+
"fallback_reason_code": (
|
|
49
|
+
f"CHANGED_{self.strategy.upper()}" if self.fallback_reason else None
|
|
50
|
+
),
|
|
51
|
+
}
|