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,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."""
|