devagent-ai 0.3.1__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.
- agent/__init__.py +5 -0
- agent/llm.py +6 -0
- agent/loop.py +16 -0
- agent/memory.py +5 -0
- agent/prompts.py +3 -0
- agent/tools.py +6 -0
- devagent/__init__.py +3 -0
- devagent/__main__.py +4 -0
- devagent/artifacts.py +46 -0
- devagent/cli.py +164 -0
- devagent/config.py +72 -0
- devagent/discovery.py +504 -0
- devagent/evaluation.py +388 -0
- devagent/memory.py +51 -0
- devagent/models.py +251 -0
- devagent/orchestrator.py +887 -0
- devagent/providers.py +344 -0
- devagent/report.py +52 -0
- devagent/retrieval.py +478 -0
- devagent/safety.py +131 -0
- devagent/state_machine.py +45 -0
- devagent/tasking.py +83 -0
- devagent/workspace.py +199 -0
- devagent/worktree.py +151 -0
- devagent_ai-0.3.1.dist-info/METADATA +415 -0
- devagent_ai-0.3.1.dist-info/RECORD +31 -0
- devagent_ai-0.3.1.dist-info/WHEEL +5 -0
- devagent_ai-0.3.1.dist-info/entry_points.txt +2 -0
- devagent_ai-0.3.1.dist-info/licenses/LICENSE +21 -0
- devagent_ai-0.3.1.dist-info/licenses/NOTICE +10 -0
- devagent_ai-0.3.1.dist-info/top_level.txt +2 -0
devagent/discovery.py
ADDED
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shlex
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
from collections import Counter
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Iterable
|
|
15
|
+
|
|
16
|
+
from devagent.models import Capability, CapabilityProvenance, Component, RepositoryFact, RepositoryModel
|
|
17
|
+
from devagent.safety import SKIP_DIRECTORIES, is_secret_path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
_LANGUAGE_EXTENSIONS = {
|
|
21
|
+
".py": "python",
|
|
22
|
+
".js": "javascript",
|
|
23
|
+
".jsx": "javascript",
|
|
24
|
+
".ts": "typescript",
|
|
25
|
+
".tsx": "typescript",
|
|
26
|
+
".go": "go",
|
|
27
|
+
".rs": "rust",
|
|
28
|
+
".java": "java",
|
|
29
|
+
".kt": "kotlin",
|
|
30
|
+
".c": "c",
|
|
31
|
+
".cc": "c++",
|
|
32
|
+
".cpp": "c++",
|
|
33
|
+
".h": "c/c++",
|
|
34
|
+
".cs": "c#",
|
|
35
|
+
".rb": "ruby",
|
|
36
|
+
".php": "php",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
_MANIFESTS = {
|
|
40
|
+
"pyproject.toml",
|
|
41
|
+
"requirements.txt",
|
|
42
|
+
"setup.py",
|
|
43
|
+
"pytest.ini",
|
|
44
|
+
"package.json",
|
|
45
|
+
"tsconfig.json",
|
|
46
|
+
"cargo.toml",
|
|
47
|
+
"go.mod",
|
|
48
|
+
"pom.xml",
|
|
49
|
+
"build.gradle",
|
|
50
|
+
"gradlew",
|
|
51
|
+
"cmakelists.txt",
|
|
52
|
+
"makefile",
|
|
53
|
+
"meson.build",
|
|
54
|
+
"dockerfile",
|
|
55
|
+
"jenkinsfile",
|
|
56
|
+
".gitlab-ci.yml",
|
|
57
|
+
"azure-pipelines.yml",
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _fingerprint(path: Path) -> str:
|
|
62
|
+
digest = hashlib.sha256()
|
|
63
|
+
with path.open("rb") as handle:
|
|
64
|
+
while chunk := handle.read(65536):
|
|
65
|
+
digest.update(chunk)
|
|
66
|
+
return digest.hexdigest()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _walk(root: Path, limit: int = 12_000) -> list[Path]:
|
|
70
|
+
files: list[Path] = []
|
|
71
|
+
for path in root.rglob("*"):
|
|
72
|
+
relative = path.relative_to(root)
|
|
73
|
+
if any(part in SKIP_DIRECTORIES for part in relative.parts):
|
|
74
|
+
continue
|
|
75
|
+
try:
|
|
76
|
+
resolved = path.resolve()
|
|
77
|
+
resolved_relative = resolved.relative_to(root)
|
|
78
|
+
except (OSError, ValueError):
|
|
79
|
+
continue
|
|
80
|
+
if (
|
|
81
|
+
resolved.is_file()
|
|
82
|
+
and not is_secret_path(relative)
|
|
83
|
+
and not is_secret_path(resolved_relative)
|
|
84
|
+
):
|
|
85
|
+
files.append(path)
|
|
86
|
+
if len(files) >= limit:
|
|
87
|
+
break
|
|
88
|
+
return files
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _git(root: Path, *args: str) -> str | None:
|
|
92
|
+
try:
|
|
93
|
+
completed = subprocess.run(
|
|
94
|
+
["git", *args], cwd=root, capture_output=True, text=True, timeout=15, check=False
|
|
95
|
+
)
|
|
96
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
97
|
+
return None
|
|
98
|
+
return completed.stdout.strip() if completed.returncode == 0 else None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _is_safe_generated_untracked_path(path: str) -> bool:
|
|
102
|
+
parts = Path(path).parts
|
|
103
|
+
return (
|
|
104
|
+
path == ".devagent"
|
|
105
|
+
or path.startswith(".devagent/")
|
|
106
|
+
or "__pycache__" in parts
|
|
107
|
+
or ".pytest_cache" in parts
|
|
108
|
+
or path.endswith(".pyc")
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _package_capabilities(path: Path, relative: str) -> tuple[list[str], list[Capability]]:
|
|
113
|
+
frameworks: list[str] = []
|
|
114
|
+
capabilities: list[Capability] = []
|
|
115
|
+
try:
|
|
116
|
+
package = json.loads(path.read_text(encoding="utf-8"))
|
|
117
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
|
118
|
+
return frameworks, capabilities
|
|
119
|
+
dependencies = {**package.get("dependencies", {}), **package.get("devDependencies", {})}
|
|
120
|
+
for name, framework in (("react", "react"), ("next", "next.js"), ("vite", "vite"), ("vitest", "vitest"), ("jest", "jest"), ("playwright", "playwright"), ("cypress", "cypress")):
|
|
121
|
+
if name in dependencies:
|
|
122
|
+
frameworks.append(framework)
|
|
123
|
+
scripts = package.get("scripts", {})
|
|
124
|
+
preferred = (
|
|
125
|
+
("test:unit", "test", False),
|
|
126
|
+
("test", "test", False),
|
|
127
|
+
("test:e2e", "integration", True),
|
|
128
|
+
("lint", "lint", True),
|
|
129
|
+
("typecheck", "typecheck", True),
|
|
130
|
+
("build", "build", True),
|
|
131
|
+
)
|
|
132
|
+
component = Path(relative).parent.as_posix()
|
|
133
|
+
for script, kind, broad in preferred:
|
|
134
|
+
if script in scripts:
|
|
135
|
+
command = ("npm", "run", script) if component == "." else ("npm", "--prefix", component, "run", script)
|
|
136
|
+
capabilities.append(Capability(kind, command, relative, component, broad))
|
|
137
|
+
return sorted(set(frameworks)), capabilities
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _manifest_capabilities(path: Path, root: Path) -> tuple[list[str], list[Capability]]:
|
|
141
|
+
relative = path.relative_to(root).as_posix()
|
|
142
|
+
name = path.name.lower()
|
|
143
|
+
frameworks: list[str] = []
|
|
144
|
+
capabilities: list[Capability] = []
|
|
145
|
+
component = path.parent.relative_to(root).as_posix() or "."
|
|
146
|
+
if name == "package.json":
|
|
147
|
+
return _package_capabilities(path, relative)
|
|
148
|
+
if name in {"pyproject.toml", "pytest.ini"}:
|
|
149
|
+
content = path.read_text(encoding="utf-8", errors="ignore").lower()
|
|
150
|
+
if "pytest" in content:
|
|
151
|
+
frameworks.append("pytest")
|
|
152
|
+
command = ("python", "-m", "pytest", "-q") if component == "." else ("python", "-m", "pytest", "-q", component)
|
|
153
|
+
capabilities.append(Capability("test", command, relative, component, False))
|
|
154
|
+
if "ruff" in content:
|
|
155
|
+
capabilities.append(Capability("lint", ("python", "-m", "ruff", "check", "."), relative, component, True))
|
|
156
|
+
if "mypy" in content:
|
|
157
|
+
capabilities.append(Capability("typecheck", ("python", "-m", "mypy", "."), relative, component, True))
|
|
158
|
+
elif name == "requirements.txt":
|
|
159
|
+
content = path.read_text(encoding="utf-8", errors="ignore").lower()
|
|
160
|
+
if "pytest" in content:
|
|
161
|
+
frameworks.append("pytest")
|
|
162
|
+
command = ("python", "-m", "pytest", "-q") if component == "." else ("python", "-m", "pytest", "-q", component)
|
|
163
|
+
capabilities.append(Capability("test", command, relative, component, False))
|
|
164
|
+
elif name == "cargo.toml":
|
|
165
|
+
manifest_args = () if component == "." else ("--manifest-path", relative)
|
|
166
|
+
capabilities.extend((Capability("test", ("cargo", "test", *manifest_args), relative, component), Capability("build", ("cargo", "check", *manifest_args), relative, component, True)))
|
|
167
|
+
elif name == "go.mod":
|
|
168
|
+
command = ("go", "test", "./...") if component == "." else ("go", "-C", component, "test", "./...")
|
|
169
|
+
capabilities.append(Capability("test", command, relative, component))
|
|
170
|
+
elif name == "pom.xml":
|
|
171
|
+
command = ("mvn", "test") if component == "." else ("mvn", "-f", relative, "test")
|
|
172
|
+
capabilities.append(Capability("test", command, relative, component))
|
|
173
|
+
elif name in {"build.gradle", "gradlew"}:
|
|
174
|
+
executable = "./gradlew" if component == "." and (path.parent / "gradlew").exists() else (f"./{component}/gradlew" if (path.parent / "gradlew").exists() else "gradle")
|
|
175
|
+
command = (executable, "test") if component == "." else (executable, "-p", component, "test")
|
|
176
|
+
capabilities.append(Capability("test", command, relative, component))
|
|
177
|
+
elif name == "cmakelists.txt":
|
|
178
|
+
build_dir = "build" if component == "." else f"{component}/build"
|
|
179
|
+
capabilities.append(Capability("test", ("ctest", "--test-dir", build_dir), relative, component))
|
|
180
|
+
elif name == "makefile":
|
|
181
|
+
content = path.read_text(encoding="utf-8", errors="ignore")
|
|
182
|
+
if any(line.startswith("test:") for line in content.splitlines()):
|
|
183
|
+
command = ("make", "test") if component == "." else ("make", "-C", component, "test")
|
|
184
|
+
capabilities.append(Capability("test", command, relative, component))
|
|
185
|
+
return frameworks, capabilities
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _ci_capabilities(path: Path, root: Path) -> list[Capability]:
|
|
189
|
+
"""Extract simple argv commands from CI run/command lines as high-value evidence."""
|
|
190
|
+
relative = path.relative_to(root).as_posix()
|
|
191
|
+
commands: list[Capability] = []
|
|
192
|
+
allowed = {"python", "python3", "pytest", "npm", "pnpm", "yarn", "go", "cargo", "mvn", "gradle", "./gradlew", "make", "ctest", "dotnet", "ruff", "mypy"}
|
|
193
|
+
for raw_line in path.read_text(encoding="utf-8", errors="ignore").splitlines():
|
|
194
|
+
stripped = raw_line.strip()
|
|
195
|
+
value = ""
|
|
196
|
+
if stripped.startswith("run:"):
|
|
197
|
+
value = stripped[4:].strip()
|
|
198
|
+
elif stripped.startswith("- run:"):
|
|
199
|
+
value = stripped[6:].strip()
|
|
200
|
+
elif stripped.startswith("command:"):
|
|
201
|
+
value = stripped[8:].strip()
|
|
202
|
+
if not value or value in {"|", ">"}:
|
|
203
|
+
continue
|
|
204
|
+
try:
|
|
205
|
+
argv = tuple(shlex.split(value))
|
|
206
|
+
except ValueError:
|
|
207
|
+
continue
|
|
208
|
+
if not argv or argv[0] not in allowed or any(token in argv for token in ("|", "&&", ";")):
|
|
209
|
+
continue
|
|
210
|
+
lowered = " ".join(argv).lower()
|
|
211
|
+
if "lint" in lowered:
|
|
212
|
+
kind, broad = "lint", True
|
|
213
|
+
elif any(term in lowered for term in ("build", "compile", "check")) and "test" not in lowered:
|
|
214
|
+
kind, broad = "build", True
|
|
215
|
+
elif any(term in lowered for term in ("e2e", "integration")):
|
|
216
|
+
kind, broad = "integration", True
|
|
217
|
+
elif "test" in lowered or "pytest" in lowered or "ctest" in lowered:
|
|
218
|
+
kind, broad = "test", False
|
|
219
|
+
else:
|
|
220
|
+
continue
|
|
221
|
+
commands.append(Capability(kind, argv, relative, ".", broad))
|
|
222
|
+
return commands
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _test_locations(files: Iterable[Path], root: Path, component: Path) -> list[str]:
|
|
226
|
+
locations: set[str] = set()
|
|
227
|
+
for path in files:
|
|
228
|
+
try:
|
|
229
|
+
relative_component = path.relative_to(component)
|
|
230
|
+
except ValueError:
|
|
231
|
+
continue
|
|
232
|
+
parts = relative_component.parts
|
|
233
|
+
if any(part.lower() in {"test", "tests", "spec", "specs", "__tests__"} for part in parts):
|
|
234
|
+
locations.add(path.parent.relative_to(root).as_posix())
|
|
235
|
+
elif path.name.startswith("test_") or ".test." in path.name or ".spec." in path.name:
|
|
236
|
+
locations.add(path.parent.relative_to(root).as_posix() or ".")
|
|
237
|
+
return sorted(locations)[:30]
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _python_test_files(files: Iterable[Path], component: Path) -> list[Path]:
|
|
241
|
+
candidates: list[Path] = []
|
|
242
|
+
for path in files:
|
|
243
|
+
if path.suffix.casefold() != ".py":
|
|
244
|
+
continue
|
|
245
|
+
try:
|
|
246
|
+
relative = path.relative_to(component)
|
|
247
|
+
except ValueError:
|
|
248
|
+
continue
|
|
249
|
+
lowered_parts = tuple(part.casefold() for part in relative.parts)
|
|
250
|
+
name = path.name.casefold()
|
|
251
|
+
if (
|
|
252
|
+
any(part in {"test", "tests"} for part in lowered_parts)
|
|
253
|
+
or name.startswith("test_")
|
|
254
|
+
or name.endswith("_test.py")
|
|
255
|
+
):
|
|
256
|
+
candidates.append(path)
|
|
257
|
+
return sorted(candidates)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _probe_environment(home: Path) -> dict[str, str]:
|
|
261
|
+
environment = {
|
|
262
|
+
key: value
|
|
263
|
+
for key, value in os.environ.items()
|
|
264
|
+
if key in {"PATH", "LANG", "LC_ALL", "TERM", "TMPDIR", "VIRTUAL_ENV", "PYTHONPATH", "SYSTEMROOT", "WINDIR"}
|
|
265
|
+
}
|
|
266
|
+
environment.update(
|
|
267
|
+
{
|
|
268
|
+
"HOME": str(home),
|
|
269
|
+
"CI": "true",
|
|
270
|
+
"PYTHONDONTWRITEBYTECODE": "1",
|
|
271
|
+
"PYTHONNOUSERSITE": "1",
|
|
272
|
+
"PYTEST_DISABLE_PLUGIN_AUTOLOAD": "1",
|
|
273
|
+
}
|
|
274
|
+
)
|
|
275
|
+
return environment
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _run_probe(root: Path, argv: tuple[str, ...]) -> subprocess.CompletedProcess[str] | None:
|
|
279
|
+
try:
|
|
280
|
+
with tempfile.TemporaryDirectory(prefix="devagent-probe-") as temporary:
|
|
281
|
+
return subprocess.run(
|
|
282
|
+
argv,
|
|
283
|
+
cwd=root,
|
|
284
|
+
env=_probe_environment(Path(temporary)),
|
|
285
|
+
capture_output=True,
|
|
286
|
+
text=True,
|
|
287
|
+
timeout=30,
|
|
288
|
+
check=False,
|
|
289
|
+
)
|
|
290
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
291
|
+
return None
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _pytest_collected_count(output: str) -> int:
|
|
295
|
+
matches = re.findall(r"(\d+)\s+tests?\s+collected", output, re.IGNORECASE)
|
|
296
|
+
if matches:
|
|
297
|
+
return int(matches[-1])
|
|
298
|
+
node_ids = [line for line in output.splitlines() if "::" in line and not line.lstrip().startswith(("<", "="))]
|
|
299
|
+
return len(node_ids)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _uses_unittest_conventions(paths: Iterable[Path]) -> bool:
|
|
303
|
+
for path in paths:
|
|
304
|
+
try:
|
|
305
|
+
text = path.read_text(encoding="utf-8")
|
|
306
|
+
except (OSError, UnicodeDecodeError):
|
|
307
|
+
continue
|
|
308
|
+
if re.search(r"(?:from\s+unittest\s+import|import\s+unittest|unittest\.TestCase)", text):
|
|
309
|
+
return True
|
|
310
|
+
return False
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _probe_python_test_capability(
|
|
314
|
+
root: Path, component: str, test_files: list[Path]
|
|
315
|
+
) -> tuple[Capability | None, list[str]]:
|
|
316
|
+
if not test_files:
|
|
317
|
+
return None, []
|
|
318
|
+
relative_tests = [path.relative_to(root).as_posix() for path in test_files]
|
|
319
|
+
target = () if component == "." else (component,)
|
|
320
|
+
diagnostics = [f"pytest candidate detected from {len(test_files)} Python test file(s)"]
|
|
321
|
+
pytest_probe = (
|
|
322
|
+
sys.executable,
|
|
323
|
+
"-m",
|
|
324
|
+
"pytest",
|
|
325
|
+
"--collect-only",
|
|
326
|
+
"-q",
|
|
327
|
+
"-p",
|
|
328
|
+
"no:cacheprovider",
|
|
329
|
+
*target,
|
|
330
|
+
)
|
|
331
|
+
completed = _run_probe(root, pytest_probe)
|
|
332
|
+
collected = _pytest_collected_count(
|
|
333
|
+
f"{completed.stdout}\n{completed.stderr}" if completed is not None else ""
|
|
334
|
+
)
|
|
335
|
+
if completed is not None and completed.returncode == 0 and collected > 0:
|
|
336
|
+
command = ("python", "-m", "pytest", "-q", *target)
|
|
337
|
+
detail = (
|
|
338
|
+
f"probed from Python test files and successful pytest collection ({collected} collected)"
|
|
339
|
+
)
|
|
340
|
+
diagnostics.append(
|
|
341
|
+
f"pytest collection probe: PASS; tests collected: {collected}; capability promoted"
|
|
342
|
+
)
|
|
343
|
+
return (
|
|
344
|
+
Capability(
|
|
345
|
+
"test",
|
|
346
|
+
command,
|
|
347
|
+
relative_tests[0],
|
|
348
|
+
component,
|
|
349
|
+
False,
|
|
350
|
+
CapabilityProvenance.PROBED,
|
|
351
|
+
detail,
|
|
352
|
+
collected,
|
|
353
|
+
),
|
|
354
|
+
diagnostics,
|
|
355
|
+
)
|
|
356
|
+
result = "unavailable" if completed is None else f"exit {completed.returncode}"
|
|
357
|
+
diagnostics.append(f"pytest collection probe: not promoted ({result})")
|
|
358
|
+
if not _uses_unittest_conventions(test_files):
|
|
359
|
+
return None, diagnostics
|
|
360
|
+
|
|
361
|
+
diagnostics.append("unittest candidate detected from explicit unittest conventions")
|
|
362
|
+
unittest_command = (
|
|
363
|
+
("python", "-m", "unittest", "discover")
|
|
364
|
+
if component == "."
|
|
365
|
+
else ("python", "-m", "unittest", "discover", "-s", component)
|
|
366
|
+
)
|
|
367
|
+
unittest_probe = (
|
|
368
|
+
sys.executable,
|
|
369
|
+
"-m",
|
|
370
|
+
"unittest",
|
|
371
|
+
"discover",
|
|
372
|
+
"-v",
|
|
373
|
+
*(("-s", component) if component != "." else ()),
|
|
374
|
+
)
|
|
375
|
+
completed = _run_probe(root, unittest_probe)
|
|
376
|
+
output = f"{completed.stdout}\n{completed.stderr}" if completed is not None else ""
|
|
377
|
+
match = re.search(r"Ran\s+(\d+)\s+tests?", output)
|
|
378
|
+
discovered = int(match.group(1)) if match else 0
|
|
379
|
+
if completed is not None and completed.returncode in {0, 1} and discovered > 0:
|
|
380
|
+
detail = (
|
|
381
|
+
f"probed from unittest conventions and local discovery ({discovered} discovered)"
|
|
382
|
+
)
|
|
383
|
+
diagnostics.append(
|
|
384
|
+
f"unittest discovery probe: PASS; tests discovered: {discovered}; capability promoted"
|
|
385
|
+
)
|
|
386
|
+
return (
|
|
387
|
+
Capability(
|
|
388
|
+
"test",
|
|
389
|
+
unittest_command,
|
|
390
|
+
relative_tests[0],
|
|
391
|
+
component,
|
|
392
|
+
False,
|
|
393
|
+
CapabilityProvenance.PROBED,
|
|
394
|
+
detail,
|
|
395
|
+
discovered,
|
|
396
|
+
),
|
|
397
|
+
diagnostics,
|
|
398
|
+
)
|
|
399
|
+
diagnostics.append("unittest discovery probe: not promoted")
|
|
400
|
+
return None, diagnostics
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def discover_repository(root: Path | str, *, probe_capabilities: bool = True) -> RepositoryModel:
|
|
404
|
+
root_path = Path(root).expanduser().resolve()
|
|
405
|
+
files = _walk(root_path)
|
|
406
|
+
ci_files = [
|
|
407
|
+
path
|
|
408
|
+
for path in files
|
|
409
|
+
if path.relative_to(root_path).as_posix().startswith(".github/workflows/")
|
|
410
|
+
or path.name.lower() in {"jenkinsfile", ".gitlab-ci.yml", "azure-pipelines.yml"}
|
|
411
|
+
]
|
|
412
|
+
manifests = [path for path in files if path.name.lower() in _MANIFESTS or path.suffix.lower() in {".sln", ".csproj"}]
|
|
413
|
+
component_paths = {root_path}
|
|
414
|
+
for manifest in manifests:
|
|
415
|
+
if manifest.parent != root_path and manifest not in ci_files:
|
|
416
|
+
component_paths.add(manifest.parent)
|
|
417
|
+
components: list[Component] = []
|
|
418
|
+
fact_sources: dict[str, set[str]] = {}
|
|
419
|
+
capability_diagnostics: list[str] = []
|
|
420
|
+
|
|
421
|
+
for component_path in sorted(component_paths):
|
|
422
|
+
owned_files = [path for path in files if path == component_path or component_path in path.parents]
|
|
423
|
+
language_counts = Counter(_LANGUAGE_EXTENSIONS[path.suffix.lower()] for path in owned_files if path.suffix.lower() in _LANGUAGE_EXTENSIONS)
|
|
424
|
+
component_manifests = [path for path in manifests if path.parent == component_path]
|
|
425
|
+
frameworks: list[str] = []
|
|
426
|
+
capabilities: list[Capability] = []
|
|
427
|
+
for manifest in component_manifests:
|
|
428
|
+
detected_frameworks, detected_capabilities = _manifest_capabilities(manifest, root_path)
|
|
429
|
+
frameworks.extend(detected_frameworks)
|
|
430
|
+
capabilities.extend(detected_capabilities)
|
|
431
|
+
if component_path == root_path:
|
|
432
|
+
for ci_file in ci_files:
|
|
433
|
+
capabilities.extend(_ci_capabilities(ci_file, root_path))
|
|
434
|
+
relative_component = component_path.relative_to(root_path).as_posix() or "."
|
|
435
|
+
if probe_capabilities and not any(capability.kind == "test" and capability.trusted for capability in capabilities):
|
|
436
|
+
probed, diagnostics = _probe_python_test_capability(
|
|
437
|
+
root_path,
|
|
438
|
+
relative_component,
|
|
439
|
+
_python_test_files(owned_files, component_path),
|
|
440
|
+
)
|
|
441
|
+
capability_diagnostics.extend(
|
|
442
|
+
f"{relative_component}: {diagnostic}" for diagnostic in diagnostics
|
|
443
|
+
)
|
|
444
|
+
if probed is not None:
|
|
445
|
+
capabilities.append(probed)
|
|
446
|
+
frameworks.append("pytest" if "pytest" in probed.command else "unittest")
|
|
447
|
+
component = Component(
|
|
448
|
+
path=relative_component,
|
|
449
|
+
languages=[name for name, _ in language_counts.most_common()],
|
|
450
|
+
frameworks=sorted(set(frameworks)),
|
|
451
|
+
manifests=[path.relative_to(root_path).as_posix() for path in component_manifests],
|
|
452
|
+
test_locations=_test_locations(owned_files, root_path, component_path),
|
|
453
|
+
capabilities=list(dict.fromkeys(capabilities)),
|
|
454
|
+
)
|
|
455
|
+
components.append(component)
|
|
456
|
+
for language in component.languages:
|
|
457
|
+
fact_sources.setdefault(f"Component {relative_component} uses {language}", set()).update(component.manifests or [relative_component])
|
|
458
|
+
for capability in component.capabilities:
|
|
459
|
+
fact_sources.setdefault(f"{capability.kind.title()} command: {' '.join(capability.command)}", set()).add(capability.source)
|
|
460
|
+
|
|
461
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
462
|
+
facts: list[RepositoryFact] = []
|
|
463
|
+
for statement, sources in sorted(fact_sources.items()):
|
|
464
|
+
fingerprints = {}
|
|
465
|
+
for source in sources:
|
|
466
|
+
source_path = root_path / source
|
|
467
|
+
if source_path.is_file():
|
|
468
|
+
fingerprints[source] = _fingerprint(source_path)
|
|
469
|
+
facts.append(RepositoryFact(statement, 1.0, tuple(sorted(sources)), fingerprints, now))
|
|
470
|
+
|
|
471
|
+
tracked_dirty = {
|
|
472
|
+
path
|
|
473
|
+
for command in (("diff", "--name-only"), ("diff", "--cached", "--name-only"))
|
|
474
|
+
for path in (_git(root_path, *command) or "").splitlines()
|
|
475
|
+
if path
|
|
476
|
+
}
|
|
477
|
+
untracked_dirty = {
|
|
478
|
+
path
|
|
479
|
+
for path in (
|
|
480
|
+
_git(root_path, "ls-files", "--others", "--exclude-standard") or ""
|
|
481
|
+
).splitlines()
|
|
482
|
+
if path and not _is_safe_generated_untracked_path(path)
|
|
483
|
+
}
|
|
484
|
+
return RepositoryModel(
|
|
485
|
+
root=str(root_path),
|
|
486
|
+
kind="monorepo" if len(component_paths) > 1 else "single-component",
|
|
487
|
+
components=components,
|
|
488
|
+
facts=facts,
|
|
489
|
+
git_branch=_git(root_path, "branch", "--show-current"),
|
|
490
|
+
git_head=_git(root_path, "rev-parse", "HEAD"),
|
|
491
|
+
dirty_files=sorted(tracked_dirty | untracked_dirty),
|
|
492
|
+
inventory_file_count=len(files),
|
|
493
|
+
capability_diagnostics=capability_diagnostics,
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def facts_are_current(root: Path | str, facts: Iterable[RepositoryFact]) -> bool:
|
|
498
|
+
root_path = Path(root).resolve()
|
|
499
|
+
for fact in facts:
|
|
500
|
+
for relative, expected in fact.fingerprints.items():
|
|
501
|
+
path = root_path / relative
|
|
502
|
+
if not path.is_file() or _fingerprint(path) != expected:
|
|
503
|
+
return False
|
|
504
|
+
return True
|