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,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
+ }