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