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,228 @@
1
+ from __future__ import annotations
2
+
3
+ import glob
4
+ import json
5
+ import os
6
+ import re
7
+ import tomllib
8
+ from pathlib import Path
9
+
10
+ from ai_dev_tools.detectors.runtime import detect_runtime_requirements
11
+ from ai_dev_tools.models.workspace import Workspace
12
+
13
+ _IGNORED = {".git", ".ai", ".venv", "node_modules", "dist", "build", "target", "vendor"}
14
+
15
+
16
+ def detect_workspaces(root: Path) -> list[Workspace]:
17
+ candidates: dict[Path, set[str]] = {root.resolve(): set()}
18
+ _add_manifest_roots(root, candidates)
19
+ _add_declared_workspaces(root, candidates)
20
+ workspaces = [_workspace(root, path, sources) for path, sources in candidates.items()]
21
+ result = [workspace for workspace in workspaces if workspace is not None]
22
+ return sorted(result, key=lambda item: (item.root.count("/"), item.root, item.workspace_id))
23
+
24
+
25
+ def owning_workspace(workspaces: list[Workspace], relative_path: str) -> Workspace | None:
26
+ owners = [workspace for workspace in workspaces if workspace.owns(relative_path)]
27
+ return max(owners, key=lambda item: len(item.root), default=None)
28
+
29
+
30
+ def _add_manifest_roots(root: Path, candidates: dict[Path, set[str]]) -> None:
31
+ manifests = {
32
+ "pyproject.toml",
33
+ "package.json",
34
+ "Cargo.toml",
35
+ "pom.xml",
36
+ "build.gradle",
37
+ "build.gradle.kts",
38
+ "composer.json",
39
+ }
40
+ for current, directories, files in os.walk(root):
41
+ current_path = Path(current)
42
+ directories[:] = sorted(
43
+ name
44
+ for name in directories
45
+ if name not in _IGNORED
46
+ and not (current_path.name in {"test", "tests"} and name == "fixtures")
47
+ )
48
+ for name in sorted(manifests & set(files)):
49
+ path = current_path / name
50
+ resolved = path.parent.resolve()
51
+ candidates.setdefault(resolved, set()).add(_rel(root, path))
52
+
53
+
54
+ def _add_declared_workspaces(root: Path, candidates: dict[Path, set[str]]) -> None:
55
+ package = _json(root / "package.json")
56
+ raw_workspaces = package.get("workspaces", [])
57
+ if isinstance(raw_workspaces, dict):
58
+ raw_workspaces = raw_workspaces.get("packages", [])
59
+ if isinstance(raw_workspaces, list):
60
+ for pattern in raw_workspaces:
61
+ if isinstance(pattern, str):
62
+ _add_glob(root, pattern, "package.json#workspaces", candidates)
63
+
64
+ pnpm = _text(root / "pnpm-workspace.yaml")
65
+ for pattern in re.findall(r"^\s*-\s*['\"]?([^'\"#]+)", pnpm, re.MULTILINE):
66
+ _add_glob(root, pattern.strip(), "pnpm-workspace.yaml", candidates)
67
+
68
+ cargo = _toml(root / "Cargo.toml")
69
+ cargo_workspace = cargo.get("workspace", {})
70
+ if isinstance(cargo_workspace, dict) and isinstance(cargo_workspace.get("members"), list):
71
+ for pattern in cargo_workspace["members"]:
72
+ if isinstance(pattern, str):
73
+ _add_glob(root, pattern, "Cargo.toml#workspace", candidates)
74
+
75
+ pom = _text(root / "pom.xml")
76
+ for module in re.findall(r"<module>\s*([^<]+)\s*</module>", pom):
77
+ _add_root(root, module.strip(), "pom.xml#modules", candidates)
78
+
79
+ gradle = _text(root / "settings.gradle") + "\n" + _text(root / "settings.gradle.kts")
80
+ for declaration in re.findall(r"\binclude\s*\(?\s*([^\n\r\)]+)", gradle):
81
+ for module in re.findall(r"['\"]:([^'\"]+)['\"]", declaration):
82
+ _add_root(root, module.replace(":", "/"), "Gradle settings", candidates)
83
+
84
+
85
+ def _add_glob(root: Path, pattern: str, source: str, candidates: dict[Path, set[str]]) -> None:
86
+ normalized = pattern.replace("\\", "/").rstrip("/")
87
+ for value in glob.glob(str(root / normalized)):
88
+ path = Path(value)
89
+ if path.is_dir() and not _ignored(root, path):
90
+ candidates.setdefault(path.resolve(), set()).add(source)
91
+
92
+
93
+ def _add_root(root: Path, relative: str, source: str, candidates: dict[Path, set[str]]) -> None:
94
+ path = (root / relative).resolve()
95
+ try:
96
+ path.relative_to(root.resolve())
97
+ except ValueError:
98
+ return
99
+ if path.is_dir() and not _ignored(root, path):
100
+ candidates.setdefault(path, set()).add(source)
101
+
102
+
103
+ def _workspace(root: Path, path: Path, sources: set[str]) -> Workspace | None:
104
+ configs = [
105
+ name
106
+ for name in (
107
+ "pyproject.toml",
108
+ "requirements.txt",
109
+ "package.json",
110
+ "pnpm-lock.yaml",
111
+ "yarn.lock",
112
+ "package-lock.json",
113
+ "Cargo.toml",
114
+ "pom.xml",
115
+ "build.gradle",
116
+ "build.gradle.kts",
117
+ "composer.json",
118
+ )
119
+ if (path / name).exists()
120
+ ]
121
+ if not configs and path != root.resolve():
122
+ return None
123
+ technologies: list[str] = []
124
+ if any(name in configs for name in ("pyproject.toml", "requirements.txt")):
125
+ technologies.append("python")
126
+ if "package.json" in configs:
127
+ technologies.append("node")
128
+ if "Cargo.toml" in configs:
129
+ technologies.append("rust")
130
+ if any(name in configs for name in ("pom.xml", "build.gradle", "build.gradle.kts")):
131
+ technologies.append("java")
132
+ if "composer.json" in configs:
133
+ technologies.append("php")
134
+ kind = "mixed" if len(technologies) > 1 else technologies[0] if technologies else "root"
135
+ relative = _rel(root, path)
136
+ package_manager = _package_manager(configs, path)
137
+ commands = _commands(path, technologies, package_manager)
138
+ config_files = tuple(sorted({*configs, *sources}))
139
+ workspace_id = relative.replace("/", ":") if relative else "root"
140
+ return Workspace(
141
+ workspace_id=workspace_id,
142
+ root=relative,
143
+ kind=kind,
144
+ technologies=tuple(technologies),
145
+ package_manager=package_manager,
146
+ config_files=config_files,
147
+ commands=commands,
148
+ runtime_requirements=tuple(detect_runtime_requirements(path)),
149
+ )
150
+
151
+
152
+ def _commands(path: Path, technologies: list[str], package_manager: str | None) -> dict[str, str]:
153
+ commands: dict[str, str] = {}
154
+ if "python" in technologies:
155
+ commands["test"] = "python -m pytest"
156
+ if "node" in technologies and package_manager:
157
+ package = _json(path / "package.json")
158
+ scripts = package.get("scripts", {})
159
+ if isinstance(scripts, dict):
160
+ for name in ("test", "lint", "typecheck", "build"):
161
+ if isinstance(scripts.get(name), str):
162
+ commands[name] = f"{package_manager} run {name}"
163
+ if package_manager == "cargo":
164
+ commands["test"] = "cargo test"
165
+ if package_manager == "maven":
166
+ commands["test"] = "mvn test"
167
+ if package_manager == "gradle":
168
+ commands["test"] = "gradle test"
169
+ if package_manager == "composer":
170
+ commands.setdefault("install", "composer install --no-interaction")
171
+ return commands
172
+
173
+
174
+ def _package_manager(configs: list[str], path: Path) -> str | None:
175
+ if "pnpm-lock.yaml" in configs:
176
+ return "pnpm"
177
+ if "yarn.lock" in configs:
178
+ return "yarn"
179
+ if "package-lock.json" in configs or "package.json" in configs:
180
+ return "npm"
181
+ if "Cargo.toml" in configs:
182
+ return "cargo"
183
+ if "pom.xml" in configs:
184
+ return "maven"
185
+ if "build.gradle" in configs or "build.gradle.kts" in configs:
186
+ return "gradle"
187
+ if "composer.json" in configs:
188
+ return "composer"
189
+ if "pyproject.toml" in configs or "requirements.txt" in configs:
190
+ return "python"
191
+ return None
192
+
193
+
194
+ def _ignored(root: Path, path: Path) -> bool:
195
+ try:
196
+ relative = path.resolve().relative_to(root.resolve())
197
+ except ValueError:
198
+ return True
199
+ return any(part in _IGNORED for part in relative.parts)
200
+
201
+
202
+ def _rel(root: Path, path: Path) -> str:
203
+ value = path.resolve().relative_to(root.resolve()).as_posix()
204
+ return "" if value == "." else value
205
+
206
+
207
+ def _text(path: Path) -> str:
208
+ try:
209
+ return path.read_text(encoding="utf-8", errors="replace")
210
+ except OSError:
211
+ return ""
212
+
213
+
214
+ def _json(path: Path) -> dict[str, object]:
215
+ try:
216
+ value = json.loads(path.read_text(encoding="utf-8"))
217
+ return value if isinstance(value, dict) else {}
218
+ except (OSError, json.JSONDecodeError):
219
+ return {}
220
+
221
+
222
+ def _toml(path: Path) -> dict[str, object]:
223
+ try:
224
+ with path.open("rb") as handle:
225
+ value = tomllib.load(handle)
226
+ return value if isinstance(value, dict) else {}
227
+ except (OSError, tomllib.TOMLDecodeError):
228
+ return {}
@@ -0,0 +1 @@
1
+ """Git inspection helpers."""
@@ -0,0 +1,219 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from pathlib import Path
5
+
6
+ from ai_dev_tools.config import load_settings
7
+ from ai_dev_tools.models.report import Report
8
+ from ai_dev_tools.reporters.writer import write_json, write_markdown
9
+ from ai_dev_tools.security.secrets import scan_paths_for_secrets
10
+ from ai_dev_tools.utils.subprocess import run_command
11
+
12
+ CONFLICT_CODES = {"UU", "AA", "DD", "AU", "UA", "DU", "UD"}
13
+
14
+
15
+ def inspect_git(project_root: Path, detailed: bool = False) -> Report:
16
+ settings = load_settings(project_root)
17
+ report = Report(
18
+ command="git inspect" if detailed else "git status", project_root=settings.project_root
19
+ )
20
+ inside = run_command(["git", "rev-parse", "--is-inside-work-tree"], settings.project_root, 20)
21
+ if inside.exit_code != 0:
22
+ report.status = "warning"
23
+ report.summary = {"state": "NOT_A_GIT_REPOSITORY"}
24
+ return report
25
+
26
+ branch = _text(["git", "branch", "--show-current"], settings.project_root) or None
27
+ upstream = _upstream(settings.project_root)
28
+ porcelain = _text(["git", "status", "--porcelain=v1", "--branch"], settings.project_root)
29
+ ahead_count, behind_count = _ahead_behind_counts(porcelain)
30
+ staged_entries = _name_status(
31
+ ["git", "diff", "--cached", "--name-status", "-z"], settings.project_root
32
+ )
33
+ unstaged_entries = _name_status(["git", "diff", "--name-status", "-z"], settings.project_root)
34
+ untracked_files = _nul_output(
35
+ ["git", "ls-files", "--others", "--exclude-standard", "-z"], settings.project_root
36
+ )
37
+ conflicted_files = _conflicted_files(porcelain)
38
+ staged_files = _entry_paths(staged_entries)
39
+ unstaged_files = _entry_paths(unstaged_entries)
40
+ changed = sorted({*staged_files, *unstaged_files, *untracked_files, *conflicted_files})
41
+ states = _states(
42
+ porcelain=porcelain,
43
+ upstream=upstream,
44
+ detached=branch is None,
45
+ has_changes=bool(changed),
46
+ has_conflicts=bool(conflicted_files),
47
+ )
48
+ summary: dict[str, object] = {
49
+ "state": states[0],
50
+ "states": states,
51
+ "branch": branch,
52
+ "upstream": upstream,
53
+ "ahead": ahead_count,
54
+ "behind": behind_count,
55
+ "diverged": ahead_count > 0 and behind_count > 0,
56
+ "detached_head": branch is None,
57
+ "changed_files": changed,
58
+ "staged_files": staged_files,
59
+ "unstaged_files": unstaged_files,
60
+ "untracked_files": untracked_files,
61
+ "conflicted_files": conflicted_files,
62
+ "conflicts": conflicted_files,
63
+ "renamed_files": [
64
+ entry
65
+ for entry in [*staged_entries, *unstaged_entries]
66
+ if entry["status"].startswith("R")
67
+ ],
68
+ "deleted_files": [
69
+ entry["path"]
70
+ for entry in [*staged_entries, *unstaged_entries]
71
+ if entry["status"].startswith("D")
72
+ ],
73
+ "stash_count": len(
74
+ [
75
+ line
76
+ for line in _text(["git", "stash", "list"], settings.project_root).splitlines()
77
+ if line
78
+ ]
79
+ ),
80
+ }
81
+ if detailed:
82
+ upstream_diff_bytes = (
83
+ _diff_bytes(settings.project_root, ["git", "diff", upstream + "...HEAD"])
84
+ if upstream
85
+ else 0
86
+ )
87
+ scan_paths = [settings.project_root / item for item in changed]
88
+ summary.update(
89
+ {
90
+ "recent_commits": _text(
91
+ ["git", "log", "--oneline", "-5"], settings.project_root
92
+ ).splitlines(),
93
+ "diff_stat": _text(["git", "diff", "--stat"], settings.project_root),
94
+ "diff_size_bytes": _diff_bytes(settings.project_root, ["git", "diff"]),
95
+ "unstaged_diff_bytes": _diff_bytes(settings.project_root, ["git", "diff"]),
96
+ "staged_diff_bytes": _diff_bytes(
97
+ settings.project_root, ["git", "diff", "--cached"]
98
+ ),
99
+ "upstream_diff_bytes": upstream_diff_bytes,
100
+ "large_changed_files": _large_files(settings.project_root, changed),
101
+ "secret_findings": [
102
+ finding.masked_dict()
103
+ for finding in scan_paths_for_secrets(settings.project_root, scan_paths)
104
+ ],
105
+ }
106
+ )
107
+ report.summary = summary
108
+ report.status = (
109
+ "warning"
110
+ if any(s in states for s in ("DIRTY", "CONFLICT", "DIVERGED", "DETACHED_HEAD"))
111
+ else "success"
112
+ )
113
+ report.finish()
114
+ suffix = "inspect" if detailed else "status"
115
+ write_markdown(report, settings.reports_directory / f"git-{suffix}.md")
116
+ write_json(report, settings.reports_directory / f"git-{suffix}.json")
117
+ return report
118
+
119
+
120
+ def _upstream(root: Path) -> str | None:
121
+ value = _text(["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], root)
122
+ return value or None
123
+
124
+
125
+ def _text(command: list[str], root: Path) -> str:
126
+ result = run_command(command, root, 30)
127
+ return result.stdout.strip() if result.exit_code == 0 else ""
128
+
129
+
130
+ def _nul_output(command: list[str], root: Path) -> list[str]:
131
+ result = run_command(command, root, 30)
132
+ if result.exit_code != 0:
133
+ return []
134
+ return [item for item in result.stdout.split("\0") if item]
135
+
136
+
137
+ def _name_status(command: list[str], root: Path) -> list[dict[str, str]]:
138
+ items = _nul_output(command, root)
139
+ entries: list[dict[str, str]] = []
140
+ index = 0
141
+ while index < len(items):
142
+ status = items[index]
143
+ index += 1
144
+ if status.startswith("R") or status.startswith("C"):
145
+ if index + 1 > len(items):
146
+ break
147
+ old_path = items[index]
148
+ new_path = items[index + 1]
149
+ index += 2
150
+ entries.append({"status": status, "path": new_path, "old_path": old_path})
151
+ continue
152
+ if index > len(items):
153
+ break
154
+ path = items[index]
155
+ index += 1
156
+ entries.append({"status": status, "path": path})
157
+ return entries
158
+
159
+
160
+ def _entry_paths(entries: list[dict[str, str]]) -> list[str]:
161
+ return sorted({entry["path"] for entry in entries})
162
+
163
+
164
+ def _ahead_behind_counts(porcelain: str) -> tuple[int, int]:
165
+ header = porcelain.splitlines()[0] if porcelain else ""
166
+ ahead = re.search(r"ahead (\d+)", header)
167
+ behind = re.search(r"behind (\d+)", header)
168
+ return (int(ahead.group(1)) if ahead else 0, int(behind.group(1)) if behind else 0)
169
+
170
+
171
+ def _states(
172
+ porcelain: str,
173
+ upstream: str | None,
174
+ detached: bool,
175
+ has_changes: bool,
176
+ has_conflicts: bool,
177
+ ) -> list[str]:
178
+ states: list[str] = []
179
+ if detached:
180
+ states.append("DETACHED_HEAD")
181
+ if upstream is None:
182
+ states.append("NO_UPSTREAM")
183
+ ahead_count, behind_count = _ahead_behind_counts(porcelain)
184
+ if ahead_count and behind_count:
185
+ states.append("DIVERGED")
186
+ elif ahead_count:
187
+ states.append("AHEAD")
188
+ elif behind_count:
189
+ states.append("BEHIND")
190
+ if has_conflicts:
191
+ states.append("CONFLICT")
192
+ if has_changes:
193
+ states.append("DIRTY")
194
+ if not states:
195
+ states.append("UP_TO_DATE")
196
+ return states
197
+
198
+
199
+ def _conflicted_files(porcelain: str) -> list[str]:
200
+ return sorted(
201
+ line[3:]
202
+ for line in porcelain.splitlines()[1:]
203
+ if len(line) > 3 and line[:2] in CONFLICT_CODES
204
+ )
205
+
206
+
207
+ def _diff_bytes(root: Path, command: list[str]) -> int:
208
+ result = run_command(command, root, 60)
209
+ return len(result.stdout.encode("utf-8")) if result.exit_code == 0 else 0
210
+
211
+
212
+ def _large_files(root: Path, files: list[str]) -> list[str]:
213
+ return [
214
+ item
215
+ for item in files
216
+ if (root / item).exists()
217
+ and (root / item).is_file()
218
+ and (root / item).stat().st_size > 1_000_000
219
+ ]
@@ -0,0 +1 @@
1
+ """Shared data models."""
@@ -0,0 +1,95 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass, field
4
+ from datetime import UTC, datetime
5
+ from pathlib import Path
6
+ from typing import Any, Literal
7
+
8
+ from ai_dev_tools import __version__
9
+
10
+ Status = Literal[
11
+ "success",
12
+ "failed",
13
+ "partial",
14
+ "warning",
15
+ "not_implemented",
16
+ "invalid_configuration",
17
+ "environment_error",
18
+ "blocked",
19
+ ]
20
+ Severity = Literal["info", "warning", "error", "critical"]
21
+
22
+
23
+ def utc_now() -> datetime:
24
+ return datetime.now(UTC)
25
+
26
+
27
+ @dataclass(slots=True)
28
+ class Artifact:
29
+ path: str
30
+ kind: str
31
+ description: str
32
+
33
+
34
+ @dataclass(slots=True)
35
+ class Issue:
36
+ severity: Severity | str
37
+ message: str
38
+ location: str | None = None
39
+ code: str | None = None
40
+ tool: str | None = None
41
+ file: str | None = None
42
+ line: int | None = None
43
+ column: int | None = None
44
+ masked: bool = False
45
+
46
+
47
+ @dataclass(slots=True)
48
+ class Report:
49
+ command: str
50
+ project_root: Path
51
+ status: Status = "success"
52
+ started_at: datetime = field(default_factory=utc_now)
53
+ finished_at: datetime | None = None
54
+ summary: dict[str, Any] = field(default_factory=dict)
55
+ issues: list[Issue] = field(default_factory=list)
56
+ artifacts: list[Artifact] = field(default_factory=list)
57
+ schema_version: str = "1.1"
58
+ tool_version: str = __version__
59
+ exit_code: int = 0
60
+ metadata: dict[str, Any] = field(default_factory=dict)
61
+
62
+ def finish(self, status: Status | None = None) -> Report:
63
+ if status is not None:
64
+ self.status = status
65
+ self.finished_at = utc_now()
66
+ if self.exit_code == 0 and self.status not in {"success", "partial", "warning"}:
67
+ self.exit_code = 1
68
+ return self
69
+
70
+ @property
71
+ def duration_seconds(self) -> float:
72
+ finished = self.finished_at or utc_now()
73
+ return round((finished - self.started_at).total_seconds(), 3)
74
+
75
+ def to_dict(self) -> dict[str, Any]:
76
+ from ai_dev_tools.reporters.progressive import add_progressive_metadata
77
+
78
+ finished = self.finished_at or utc_now()
79
+ status = "partial" if self.status == "warning" else self.status
80
+ payload = {
81
+ "schema_version": self.schema_version,
82
+ "tool_version": self.tool_version,
83
+ "command": self.command,
84
+ "status": status,
85
+ "exit_code": self.exit_code,
86
+ "started_at": self.started_at.isoformat(),
87
+ "finished_at": finished.isoformat(),
88
+ "duration_seconds": self.duration_seconds,
89
+ "project_root": str(self.project_root),
90
+ "summary": self.summary,
91
+ "issues": [asdict(issue) for issue in self.issues],
92
+ "artifacts": [asdict(artifact) for artifact in self.artifacts],
93
+ "metadata": dict(self.metadata),
94
+ }
95
+ return add_progressive_metadata(payload)
@@ -0,0 +1,48 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass, field
4
+
5
+
6
+ @dataclass(frozen=True, slots=True)
7
+ class RuntimeRequirement:
8
+ runtime: str
9
+ constraint: str
10
+ source: str
11
+
12
+ def to_dict(self) -> dict[str, str]:
13
+ return asdict(self)
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class Workspace:
18
+ workspace_id: str
19
+ root: str
20
+ kind: str
21
+ technologies: tuple[str, ...]
22
+ package_manager: str | None = None
23
+ config_files: tuple[str, ...] = ()
24
+ commands: dict[str, str] = field(default_factory=dict)
25
+ runtime_requirements: tuple[RuntimeRequirement, ...] = ()
26
+
27
+ def owns(self, relative_path: str) -> bool:
28
+ normalized = relative_path.replace("\\", "/").strip("/")
29
+ workspace_root = self.root.replace("\\", "/").strip("/")
30
+ return (
31
+ not workspace_root
32
+ or normalized == workspace_root
33
+ or normalized.startswith(workspace_root + "/")
34
+ )
35
+
36
+ def to_dict(self) -> dict[str, object]:
37
+ return {
38
+ "id": self.workspace_id,
39
+ "root": self.root,
40
+ "kind": self.kind,
41
+ "technologies": list(self.technologies),
42
+ "package_manager": self.package_manager,
43
+ "config_files": list(self.config_files),
44
+ "commands": dict(self.commands),
45
+ "runtime_requirements": [
46
+ requirement.to_dict() for requirement in self.runtime_requirements
47
+ ],
48
+ }
@@ -0,0 +1 @@
1
+ """Output parsers."""