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,372 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from collections import Counter
5
+ from dataclasses import asdict, dataclass, field
6
+ from pathlib import Path
7
+ from typing import Protocol
8
+
9
+ from ai_dev_tools.config import load_settings
10
+ from ai_dev_tools.models.report import Report
11
+ from ai_dev_tools.parsers.registry import ParserRegistry
12
+ from ai_dev_tools.reporters.writer import write_json, write_markdown
13
+ from ai_dev_tools.utils.subprocess import CommandResult
14
+
15
+ ERROR_MARKERS = ("error", "failed", "failure", "traceback", "assertionerror", "exception")
16
+ ANSI_PATTERN = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]")
17
+ PROGRESS_PATTERN = re.compile(r"\r[^\n]*")
18
+ WARNING_PATTERN = re.compile(r"\bwarning\b", re.IGNORECASE)
19
+ PROJECT_FRAME_PATTERN = re.compile(
20
+ r"(?P<file>[A-Za-z0-9_./\\-]+\.(?:py|ts|tsx|js|jsx|java|rs|php)):(?P<line>\d+)(?::(?P<column>\d+))?"
21
+ )
22
+ TEST_COUNT_PATTERN = re.compile(
23
+ r"(?P<count>\d+)[ \t]+(?P<kind>passed|failed|failures?|skipped|errors?|xfailed|xpassed|tests?)",
24
+ re.IGNORECASE,
25
+ )
26
+ PYTEST_FAILURE_PATTERN = re.compile(r"FAILED\s+(?P<test>\S+)(?:\s+-\s+(?P<message>.*))?")
27
+ JEST_VITEST_PATTERN = re.compile(
28
+ r"Tests:\s+(?:(?P<failed>\d+) failed,\s*)?(?:(?P<passed>\d+) passed,\s*)?(?P<total>\d+) total",
29
+ re.IGNORECASE,
30
+ )
31
+ CARGO_PATTERN = re.compile(
32
+ r"test result:\s+\w+\.\s+(?P<passed>\d+) passed;\s+(?P<failed>\d+) failed", re.IGNORECASE
33
+ )
34
+ PHPUNIT_PATTERN = re.compile(
35
+ r"Tests:\s*(?P<total>\d+).*?Assertions:\s*(?P<assertions>\d+)(?:.*?Failures:\s*(?P<failed>\d+))?(?:.*?Errors:\s*(?P<errors>\d+))?",
36
+ re.IGNORECASE,
37
+ )
38
+
39
+ TEST_KINDS = {
40
+ "passed": "passed",
41
+ "failed": "failed",
42
+ "failure": "failed",
43
+ "failures": "failed",
44
+ "skipped": "skipped",
45
+ "error": "errors",
46
+ "errors": "errors",
47
+ "xfailed": "xfailed",
48
+ "xpassed": "xpassed",
49
+ }
50
+
51
+
52
+ @dataclass(frozen=True, slots=True)
53
+ class ProjectFrame:
54
+ file: str
55
+ line: int | None = None
56
+ column: int | None = None
57
+
58
+
59
+ @dataclass(frozen=True, slots=True)
60
+ class FailureDetails:
61
+ message: str
62
+ test: str | None = None
63
+ location: str | None = None
64
+
65
+
66
+ @dataclass(slots=True)
67
+ class ParsedToolResult:
68
+ tool: str
69
+ parser: str
70
+ parser_confidence: str
71
+ status: str
72
+ passed: int | None = None
73
+ failed: int | None = None
74
+ skipped: int | None = None
75
+ errors: int | None = None
76
+ warnings: int | None = None
77
+ tests_total: int | None = None
78
+ duration_seconds: float | None = None
79
+ first_failure: FailureDetails | None = None
80
+ project_frames: list[ProjectFrame] = field(default_factory=list)
81
+ line_count: int = 0
82
+ first_failure_reason: str | None = None
83
+ first_project_frame: str | None = None
84
+ grouped_repeated_messages: list[str] = field(default_factory=list)
85
+
86
+ def to_dict(self) -> dict[str, object]:
87
+ return asdict(self)
88
+
89
+
90
+ class ToolOutputParser(Protocol):
91
+ @property
92
+ def tool_name(self) -> str: ...
93
+
94
+ def can_parse(self, command: CommandResult) -> bool: ...
95
+
96
+ def parse(self, command: CommandResult) -> ParsedToolResult: ...
97
+
98
+
99
+ @dataclass(frozen=True, slots=True)
100
+ class RegexToolParser:
101
+ tool_name: str
102
+ markers: tuple[str, ...]
103
+ confidence: str = "medium"
104
+
105
+ def can_parse(self, command: CommandResult) -> bool:
106
+ text = f"{' '.join(command.command)}\n{command.combined_output}".lower()
107
+ return any(marker in text for marker in self.markers)
108
+
109
+ def parse(self, command: CommandResult) -> ParsedToolResult:
110
+ return _parsed_from_output(self.tool_name, self.tool_name, self.confidence, command)
111
+
112
+
113
+ class GenericParser:
114
+ tool_name = "generic"
115
+
116
+ def can_parse(self, command: CommandResult) -> bool:
117
+ return True
118
+
119
+ def parse(self, command: CommandResult) -> ParsedToolResult:
120
+ return _parsed_from_output("generic", "generic", "low", command)
121
+
122
+
123
+ BUILTIN_PARSERS: tuple[ToolOutputParser, ...] = (
124
+ RegexToolParser("pytest", ("pytest", "failed tests/", "= short test summary info ="), "high"),
125
+ RegexToolParser("ruff", ("ruff", "would reformat"), "high"),
126
+ RegexToolParser("mypy", ("mypy", "success: no issues found", "checked 1 source file"), "high"),
127
+ RegexToolParser("coverage", ("coverage", "fail_under", "cover"), "medium"),
128
+ RegexToolParser("jest", ("jest", "test suites:", "fail src/"), "high"),
129
+ RegexToolParser("vitest", ("vitest", "test files", "duration"), "high"),
130
+ RegexToolParser("eslint", ("eslint", "problems", "no-unused-vars"), "high"),
131
+ RegexToolParser("tsc", ("tsc", "typescript", "ts(", "error ts"), "high"),
132
+ RegexToolParser("npm", ("npm", "npm err!", "npm run"), "medium"),
133
+ RegexToolParser("maven-surefire", ("surefire", "tests run:"), "high"),
134
+ RegexToolParser("maven", ("mvn", "[error] build failure", "[info] build success"), "medium"),
135
+ RegexToolParser("gradle", ("gradle", "build failed", "build successful"), "medium"),
136
+ RegexToolParser("cargo-test", ("cargo test", "test result:"), "high"),
137
+ RegexToolParser("cargo-clippy", ("cargo clippy", "clippy"), "high"),
138
+ RegexToolParser("cargo-fmt", ("cargo fmt", "rustfmt"), "high"),
139
+ RegexToolParser("phpunit", ("phpunit", "there was 1 failure", "phpunit\\"), "high"),
140
+ RegexToolParser("phpstan", ("phpstan", "phpstan.neon"), "high"),
141
+ RegexToolParser("php-cs-fixer", ("php-cs-fixer", "cs fixer"), "high"),
142
+ GenericParser(),
143
+ )
144
+
145
+
146
+ def clean_output(output: str) -> str:
147
+ output = ANSI_PATTERN.sub("", output)
148
+ output = PROGRESS_PATTERN.sub("", output)
149
+ return "\n".join(
150
+ line.rstrip() for line in output.replace("\r\n", "\n").splitlines() if line.strip()
151
+ )
152
+
153
+
154
+ def parse_tool_output(tool: str, output: str, exit_code: int = 0) -> dict[str, object]:
155
+ command = CommandResult(tool.split(), exit_code, output, "", 0.0)
156
+ return parse_command_result(command).to_dict()
157
+
158
+
159
+ PARSER_REGISTRY = ParserRegistry[ParsedToolResult](BUILTIN_PARSERS)
160
+
161
+
162
+ def register_parser(parser: ToolOutputParser, *, replace: bool = False) -> None:
163
+ """Register a parser before built-ins; names must be unique unless replace is true."""
164
+ PARSER_REGISTRY.register(parser, prepend=True, replace=replace)
165
+
166
+
167
+ def unregister_parser(tool_name: str) -> None:
168
+ """Remove a parser by its stable tool name."""
169
+ PARSER_REGISTRY.unregister(tool_name)
170
+
171
+
172
+ def parser_names() -> tuple[str, ...]:
173
+ return PARSER_REGISTRY.names
174
+
175
+
176
+ def parse_command_result(command: CommandResult) -> ParsedToolResult:
177
+ return PARSER_REGISTRY.parse(command)
178
+
179
+
180
+ def summarize_output(output: str) -> dict[str, object]:
181
+ return _summary_parts(clean_output(output))
182
+
183
+
184
+ def parse_test_counts(output: str) -> dict[str, int]:
185
+ counts = {"passed": 0, "failed": 0, "skipped": 0, "errors": 0, "xfailed": 0, "xpassed": 0}
186
+ for match in TEST_COUNT_PATTERN.finditer(output):
187
+ kind = match.group("kind").lower()
188
+ if kind in TEST_KINDS:
189
+ counts[TEST_KINDS[kind]] += int(match.group("count"))
190
+ cargo = CARGO_PATTERN.search(output)
191
+ if cargo:
192
+ counts["passed"] = max(counts["passed"], int(cargo.group("passed")))
193
+ counts["failed"] = max(counts["failed"], int(cargo.group("failed")))
194
+ js = JEST_VITEST_PATTERN.search(output)
195
+ if js:
196
+ counts["passed"] = max(counts["passed"], int(js.group("passed") or 0))
197
+ counts["failed"] = max(counts["failed"], int(js.group("failed") or 0))
198
+ phpunit = PHPUNIT_PATTERN.search(output)
199
+ if phpunit:
200
+ counts["failed"] = max(counts["failed"], int(phpunit.group("failed") or 0))
201
+ counts["errors"] = max(counts["errors"], int(phpunit.group("errors") or 0))
202
+ return counts
203
+
204
+
205
+ def summarize_latest_log(project_root: Path) -> Report:
206
+ settings = load_settings(project_root)
207
+ logs = sorted(
208
+ settings.logs_directory.glob("*.log"), key=lambda p: p.stat().st_mtime, reverse=True
209
+ )
210
+ report = Report(command="logs summarize", project_root=settings.project_root)
211
+ if not logs:
212
+ report.status = "partial"
213
+ report.summary = {
214
+ "message": "No logs found",
215
+ "reason_code": "NO_LOGS",
216
+ "logs_directory": str(settings.logs_directory),
217
+ }
218
+ report.finish()
219
+ write_markdown(report, settings.reports_directory / "logs-summary-latest.md")
220
+ write_json(report, settings.reports_directory / "logs-summary-latest.json")
221
+ return report
222
+ return summarize_log_file(settings.project_root, logs[0])
223
+
224
+
225
+ def summarize_log_file(project_root: Path, log_path: Path, tool: str = "auto") -> Report:
226
+ settings = load_settings(project_root)
227
+ report = Report(command="logs summarize", project_root=settings.project_root)
228
+ path = log_path if log_path.is_absolute() else settings.project_root / log_path
229
+ if not path.exists():
230
+ report.status = "failed"
231
+ report.exit_code = 1
232
+ report.summary = {
233
+ "message": "Log file does not exist",
234
+ "reason_code": "LOG_NOT_FOUND",
235
+ "log": str(path),
236
+ }
237
+ report.finish()
238
+ return report
239
+ size = path.stat().st_size
240
+ if size > 50_000_000:
241
+ report.status = "failed"
242
+ report.exit_code = 1
243
+ report.summary = {
244
+ "message": "Log file exceeds 50 MB limit",
245
+ "reason_code": "LOG_TOO_LARGE",
246
+ "log": str(path),
247
+ "bytes": size,
248
+ }
249
+ report.finish()
250
+ return report
251
+ text = path.read_text(encoding="utf-8", errors="replace")
252
+ command_text = tool if tool != "auto" else path.stem.replace("-", " ")
253
+ parsed = parse_tool_output(command_text, text, 0)
254
+ report.summary = {"log": str(path), "detected_tool": parsed["tool"], **parsed}
255
+ report.finish()
256
+ write_markdown(report, settings.reports_directory / "logs-summary-latest.md")
257
+ write_json(report, settings.reports_directory / "logs-summary-latest.json")
258
+ return report
259
+
260
+
261
+ def _parsed_from_output(
262
+ tool: str, parser: str, confidence: str, command: CommandResult
263
+ ) -> ParsedToolResult:
264
+ cleaned = clean_output(command.combined_output)
265
+ summary = _summary_parts(cleaned)
266
+ failed = _as_int(summary.get("failed")) + _as_int(summary.get("errors"))
267
+ status = "failed" if command.exit_code != 0 or failed else "success"
268
+ first_failure = _first_failure(tool, cleaned, summary)
269
+ return ParsedToolResult(
270
+ tool=tool,
271
+ parser=parser,
272
+ parser_confidence=confidence,
273
+ status=status,
274
+ passed=_as_int(summary.get("passed")),
275
+ failed=_as_int(summary.get("failed")),
276
+ skipped=_as_int(summary.get("skipped")),
277
+ errors=_as_int(summary.get("errors")),
278
+ warnings=_as_int(summary.get("warnings")),
279
+ tests_total=_as_int(summary.get("tests_total")),
280
+ duration_seconds=command.duration_seconds,
281
+ first_failure=first_failure,
282
+ project_frames=_project_frames(cleaned),
283
+ line_count=_as_int(summary.get("line_count")),
284
+ first_failure_reason=_string_or_none(summary.get("first_failure_reason")),
285
+ first_project_frame=_string_or_none(summary.get("first_project_frame")),
286
+ grouped_repeated_messages=_string_list(summary.get("grouped_repeated_messages")),
287
+ )
288
+
289
+
290
+ def _summary_parts(output: str) -> dict[str, object]:
291
+ lines = [line.strip() for line in clean_output(output).splitlines() if line.strip()]
292
+ grouped = Counter(lines)
293
+ errors = [line for line in lines if any(marker in line.lower() for marker in ERROR_MARKERS)]
294
+ frames = _project_frames("\n".join(lines))
295
+ test_counts = parse_test_counts(output)
296
+ tests_total = sum(test_counts.values())
297
+ js = JEST_VITEST_PATTERN.search(output)
298
+ if js and js.group("total"):
299
+ tests_total = max(tests_total, int(js.group("total")))
300
+ phpunit = PHPUNIT_PATTERN.search(output)
301
+ if phpunit and phpunit.group("total"):
302
+ tests_total = max(tests_total, int(phpunit.group("total")))
303
+ return {
304
+ "line_count": len(lines),
305
+ "tests_total": tests_total,
306
+ **test_counts,
307
+ "warnings": len(WARNING_PATTERN.findall(output)),
308
+ "first_failure_reason": errors[0] if errors else None,
309
+ "first_project_frame": _frame_to_location(frames[0]) if frames else None,
310
+ "grouped_repeated_messages": [
311
+ f"{line} x {count}" for line, count in grouped.items() if count > 1
312
+ ][:20],
313
+ "errors": errors[:20],
314
+ }
315
+
316
+
317
+ def _first_failure(tool: str, output: str, summary: dict[str, object]) -> FailureDetails | None:
318
+ match = PYTEST_FAILURE_PATTERN.search(output)
319
+ if match:
320
+ message = match.group("message") or _string_or_none(summary.get("first_failure_reason"))
321
+ return FailureDetails(
322
+ message or f"{tool} failure",
323
+ test=match.group("test"),
324
+ location=_string_or_none(summary.get("first_project_frame")),
325
+ )
326
+ reason = _string_or_none(summary.get("first_failure_reason"))
327
+ if reason:
328
+ return FailureDetails(
329
+ reason,
330
+ test=None,
331
+ location=_string_or_none(summary.get("first_project_frame")),
332
+ )
333
+ return None
334
+
335
+
336
+ def _project_frames(output: str) -> list[ProjectFrame]:
337
+ frames: list[ProjectFrame] = []
338
+ seen: set[tuple[str, int | None, int | None]] = set()
339
+ for match in PROJECT_FRAME_PATTERN.finditer(output):
340
+ frame = ProjectFrame(
341
+ match.group("file"),
342
+ int(match.group("line")) if match.group("line") else None,
343
+ int(match.group("column")) if match.group("column") else None,
344
+ )
345
+ key = (frame.file, frame.line, frame.column)
346
+ if key not in seen:
347
+ frames.append(frame)
348
+ seen.add(key)
349
+ return frames[:10]
350
+
351
+
352
+ def _frame_to_location(frame: ProjectFrame) -> str:
353
+ location = frame.file
354
+ if frame.line is not None:
355
+ location += f":{frame.line}"
356
+ if frame.column is not None:
357
+ location += f":{frame.column}"
358
+ return location
359
+
360
+
361
+ def _as_int(value: object) -> int:
362
+ return value if isinstance(value, int) else 0
363
+
364
+
365
+ def _string_or_none(value: object) -> str | None:
366
+ return value if isinstance(value, str) else None
367
+
368
+
369
+ def _string_list(value: object) -> list[str]:
370
+ if not isinstance(value, list):
371
+ return []
372
+ return [item for item in value if isinstance(item, str)]
@@ -0,0 +1,60 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterable
4
+ from typing import Generic, Protocol, TypeVar
5
+
6
+ from ai_dev_tools.utils.subprocess import CommandResult
7
+
8
+ ParsedResult = TypeVar("ParsedResult")
9
+ ParsedResult_co = TypeVar("ParsedResult_co", covariant=True)
10
+
11
+
12
+ class RegisteredParser(Protocol[ParsedResult_co]):
13
+ @property
14
+ def tool_name(self) -> str: ...
15
+
16
+ def can_parse(self, command: CommandResult) -> bool: ...
17
+
18
+ def parse(self, command: CommandResult) -> ParsedResult_co: ...
19
+
20
+
21
+ class ParserRegistry(Generic[ParsedResult]):
22
+ def __init__(self, parsers: Iterable[RegisteredParser[ParsedResult]] = ()) -> None:
23
+ self._parsers = list(parsers)
24
+
25
+ @property
26
+ def parsers(self) -> tuple[RegisteredParser[ParsedResult], ...]:
27
+ return tuple(self._parsers)
28
+
29
+ @property
30
+ def names(self) -> tuple[str, ...]:
31
+ return tuple(parser.tool_name for parser in self._parsers)
32
+
33
+ def register(
34
+ self,
35
+ parser: RegisteredParser[ParsedResult],
36
+ *,
37
+ prepend: bool = True,
38
+ replace: bool = False,
39
+ ) -> None:
40
+ matching = [item for item in self._parsers if item.tool_name == parser.tool_name]
41
+ if matching and not replace:
42
+ raise ValueError(f"Parser already registered: {parser.tool_name}")
43
+ if matching:
44
+ self._parsers = [item for item in self._parsers if item.tool_name != parser.tool_name]
45
+ if prepend:
46
+ self._parsers.insert(0, parser)
47
+ else:
48
+ self._parsers.append(parser)
49
+
50
+ def unregister(self, tool_name: str) -> None:
51
+ original = len(self._parsers)
52
+ self._parsers = [item for item in self._parsers if item.tool_name != tool_name]
53
+ if len(self._parsers) == original:
54
+ raise KeyError(tool_name)
55
+
56
+ def parse(self, command: CommandResult) -> ParsedResult:
57
+ for parser in self._parsers:
58
+ if parser.can_parse(command):
59
+ return parser.parse(command)
60
+ raise LookupError("No output parser accepted the command")
@@ -0,0 +1 @@
1
+ """Report writers."""
@@ -0,0 +1,161 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from ai_dev_tools.models.report import Report
9
+
10
+ _COLLECTION_KINDS = {
11
+ "artifacts": "artifact",
12
+ "diffs": "diff",
13
+ "executed": "check",
14
+ "issues": "issue",
15
+ "plan": "check",
16
+ "planned_commands": "check",
17
+ "rejected_files": "file",
18
+ "results": "check",
19
+ "selected_checks": "check",
20
+ "selected_files": "file",
21
+ "snippets": "snippet",
22
+ "workspaces": "workspace",
23
+ }
24
+
25
+
26
+ def add_progressive_metadata(payload: dict[str, Any]) -> dict[str, Any]:
27
+ references: list[str] = []
28
+ _annotate(payload.get("issues"), "issue", references)
29
+ _annotate(payload.get("artifacts"), "artifact", references)
30
+ summary = payload.get("summary")
31
+ if isinstance(summary, dict):
32
+ _walk_summary(summary, references)
33
+ metadata = payload.setdefault("metadata", {})
34
+ if isinstance(metadata, dict):
35
+ metadata["progressive"] = {
36
+ "expandable_evidence": len(references),
37
+ "references": references,
38
+ "command_template": "ai-dev explain <evidence-id> --tail 100",
39
+ }
40
+ return payload
41
+
42
+
43
+ def run_explain(project_root: Path, reference: str, tail: int = 100) -> Report:
44
+ root = project_root.resolve()
45
+ report = Report(command=f"explain {reference}", project_root=root)
46
+ match, source = _find_reference(root, reference)
47
+ if match is None:
48
+ report.status = "failed"
49
+ report.exit_code = 1
50
+ report.summary = {
51
+ "message": f"Evidence reference was not found: {reference}",
52
+ "reason_code": "EVIDENCE_NOT_FOUND",
53
+ "searched": [".ai/reports", ".ai/context"],
54
+ }
55
+ return report
56
+ report.summary = {
57
+ "evidence_id": reference,
58
+ "source_report": str(source),
59
+ "evidence": _bounded_evidence(root, match, max(tail, 0)),
60
+ }
61
+ return report
62
+
63
+
64
+ def _walk_summary(value: object, references: list[str], key: str = "") -> None:
65
+ if isinstance(value, dict):
66
+ for child_key, child in value.items():
67
+ if child_key == "progressive":
68
+ continue
69
+ if isinstance(child, list) and child_key in _COLLECTION_KINDS:
70
+ _annotate(child, _COLLECTION_KINDS[child_key], references)
71
+ else:
72
+ _walk_summary(child, references, child_key)
73
+ elif isinstance(value, list):
74
+ for child in value:
75
+ _walk_summary(child, references, key)
76
+
77
+
78
+ def _annotate(value: object, kind: str, references: list[str]) -> None:
79
+ if not isinstance(value, list):
80
+ return
81
+ for index, item in enumerate(value):
82
+ if not isinstance(item, dict):
83
+ continue
84
+ evidence_id = str(item.get("evidence_id") or _evidence_id(kind, item, index))
85
+ item["evidence_id"] = evidence_id
86
+ references.append(evidence_id)
87
+ for child_key, child in item.items():
88
+ if isinstance(child, list) and child_key in _COLLECTION_KINDS:
89
+ _annotate(child, _COLLECTION_KINDS[child_key], references)
90
+
91
+
92
+ def _evidence_id(kind: str, item: dict[str, object], index: int) -> str:
93
+ identity_keys = (
94
+ "path",
95
+ "file",
96
+ "name",
97
+ "signature",
98
+ "failure_signature",
99
+ "code",
100
+ "command",
101
+ "workspace",
102
+ "start_line",
103
+ )
104
+ identity = {key: item[key] for key in identity_keys if item.get(key) not in (None, "", [])}
105
+ if not identity:
106
+ identity = {"index": index, "value": item}
107
+ encoded = json.dumps(identity, sort_keys=True, default=str, separators=(",", ":"))
108
+ digest = hashlib.sha256(f"{kind}:{encoded}".encode()).hexdigest()[:12]
109
+ return f"{kind}:{digest}"
110
+
111
+
112
+ def _find_reference(root: Path, reference: str) -> tuple[dict[str, object] | None, Path | None]:
113
+ candidates = [
114
+ *sorted((root / ".ai" / "reports").glob("*latest.json")),
115
+ *sorted((root / ".ai" / "context").glob("*latest.json")),
116
+ ]
117
+ for path in reversed(candidates):
118
+ try:
119
+ payload = json.loads(path.read_text(encoding="utf-8"))
120
+ except (OSError, json.JSONDecodeError):
121
+ continue
122
+ match = _find_in_value(payload, reference)
123
+ if match is not None:
124
+ return match, path
125
+ return None, None
126
+
127
+
128
+ def _find_in_value(value: object, reference: str) -> dict[str, object] | None:
129
+ if isinstance(value, dict):
130
+ if value.get("evidence_id") == reference:
131
+ return value
132
+ for child in value.values():
133
+ match = _find_in_value(child, reference)
134
+ if match is not None:
135
+ return match
136
+ elif isinstance(value, list):
137
+ for child in value:
138
+ match = _find_in_value(child, reference)
139
+ if match is not None:
140
+ return match
141
+ return None
142
+
143
+
144
+ def _bounded_evidence(root: Path, evidence: dict[str, object], tail: int) -> dict[str, object]:
145
+ result = dict(evidence)
146
+ artifact_path = evidence.get("path")
147
+ if evidence.get("kind") and isinstance(artifact_path, str):
148
+ path = Path(artifact_path)
149
+ path = path if path.is_absolute() else root / path
150
+ try:
151
+ path.resolve().relative_to(root)
152
+ except ValueError:
153
+ return result
154
+ try:
155
+ lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
156
+ except OSError:
157
+ return result
158
+ result["expanded_content"] = lines[-tail:] if tail else []
159
+ result["expanded_line_count"] = min(len(lines), tail)
160
+ result["total_line_count"] = len(lines)
161
+ return result
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from ai_dev_tools.models.report import Artifact, Report
8
+ from ai_dev_tools.security.secrets import mask_text
9
+
10
+
11
+ def write_json(report: Report, path: Path) -> Path:
12
+ path.parent.mkdir(parents=True, exist_ok=True)
13
+ _register_artifact(report, path, "json", "Structured report")
14
+ path.write_text(
15
+ mask_text(json.dumps(report.to_dict(), indent=2, sort_keys=True) + "\n"),
16
+ encoding="utf-8",
17
+ )
18
+ return path
19
+
20
+
21
+ def render_markdown(report: Report) -> str:
22
+ lines = [
23
+ f"# ai-dev {report.command}",
24
+ "",
25
+ f"- Status: `{report.status.upper()}`",
26
+ f"- Duration: `{report.duration_seconds}s`",
27
+ f"- Project: `{report.project_root}`",
28
+ "",
29
+ "## Summary",
30
+ "",
31
+ ]
32
+ lines.extend(_render_value(report.summary))
33
+ if report.issues:
34
+ lines.extend(["", "## Issues", ""])
35
+ for issue in report.issues:
36
+ suffix = f" ({issue.location})" if issue.location else ""
37
+ lines.append(f"- `{issue.severity}` {issue.message}{suffix}")
38
+ return "\n".join(lines).rstrip() + "\n"
39
+
40
+
41
+ def write_markdown(report: Report, path: Path) -> Path:
42
+ path.parent.mkdir(parents=True, exist_ok=True)
43
+ _register_artifact(report, path, "markdown", "Human report")
44
+ path.write_text(mask_text(render_markdown(report)), encoding="utf-8")
45
+ return path
46
+
47
+
48
+ def _register_artifact(report: Report, path: Path, kind: str, description: str) -> None:
49
+ artifact_path = str(path)
50
+ if any(item.path == artifact_path and item.kind == kind for item in report.artifacts):
51
+ return
52
+ report.artifacts.append(Artifact(path=artifact_path, kind=kind, description=description))
53
+
54
+
55
+ def _render_value(value: Any, indent: int = 0) -> list[str]:
56
+ prefix = " " * indent
57
+ if isinstance(value, dict):
58
+ lines: list[str] = []
59
+ for key, item in value.items():
60
+ if isinstance(item, dict | list):
61
+ lines.append(f"{prefix}- {key}:")
62
+ lines.extend(_render_value(item, indent + 1))
63
+ else:
64
+ lines.append(f"{prefix}- {key}: `{item}`")
65
+ return lines
66
+ if isinstance(value, list):
67
+ lines = []
68
+ for item in value:
69
+ if isinstance(item, dict | list):
70
+ lines.extend(_render_value(item, indent))
71
+ else:
72
+ lines.append(f"{prefix}- `{item}`")
73
+ return lines
74
+ return [f"{prefix}- `{value}`"]
@@ -0,0 +1 @@
1
+ """Command runners."""