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