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,189 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import tomllib
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from ai_dev_tools.config import load_settings
|
|
9
|
+
from ai_dev_tools.detectors.runtime import detect_runtime_requirements
|
|
10
|
+
from ai_dev_tools.detectors.workspaces import detect_workspaces
|
|
11
|
+
from ai_dev_tools.models.report import Report
|
|
12
|
+
from ai_dev_tools.reporters.writer import write_json, write_markdown
|
|
13
|
+
|
|
14
|
+
SIGNALS = {
|
|
15
|
+
"pyproject.toml": "python",
|
|
16
|
+
"requirements.txt": "python",
|
|
17
|
+
"Pipfile": "python",
|
|
18
|
+
"package.json": "javascript",
|
|
19
|
+
"pnpm-lock.yaml": "javascript",
|
|
20
|
+
"yarn.lock": "javascript",
|
|
21
|
+
"pom.xml": "java",
|
|
22
|
+
"build.gradle": "java",
|
|
23
|
+
"build.gradle.kts": "java",
|
|
24
|
+
"Cargo.toml": "rust",
|
|
25
|
+
"composer.json": "php",
|
|
26
|
+
"Dockerfile": "docker",
|
|
27
|
+
"compose.yaml": "docker",
|
|
28
|
+
"docker-compose.yml": "docker",
|
|
29
|
+
"Makefile": "make",
|
|
30
|
+
}
|
|
31
|
+
FRAMEWORK_HINTS = {
|
|
32
|
+
"django": "Django",
|
|
33
|
+
"flask": "Flask",
|
|
34
|
+
"fastapi": "FastAPI",
|
|
35
|
+
"react": "React",
|
|
36
|
+
"next": "Next.js",
|
|
37
|
+
"vue": "Vue",
|
|
38
|
+
"svelte": "Svelte",
|
|
39
|
+
"express": "Express",
|
|
40
|
+
"spring-boot": "Spring Boot",
|
|
41
|
+
"laravel": "Laravel",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def scan_project(project_root: Path) -> Report:
|
|
46
|
+
settings = load_settings(project_root)
|
|
47
|
+
report = Report(command="scan", project_root=settings.project_root)
|
|
48
|
+
files = {path.name: path for path in settings.project_root.iterdir() if path.is_file()}
|
|
49
|
+
scripts = _detect_scripts(settings.project_root)
|
|
50
|
+
dependencies = _dependency_names(settings.project_root)
|
|
51
|
+
workspaces = detect_workspaces(settings.project_root)
|
|
52
|
+
runtime_requirements = detect_runtime_requirements(settings.project_root)
|
|
53
|
+
report.summary = {
|
|
54
|
+
"project_name": settings.project_name or settings.project_root.name,
|
|
55
|
+
"languages": sorted({language for name, language in SIGNALS.items() if name in files}),
|
|
56
|
+
"frameworks": sorted(
|
|
57
|
+
{label for dep, label in FRAMEWORK_HINTS.items() if dep in dependencies}
|
|
58
|
+
),
|
|
59
|
+
"package_managers": _detect_package_managers(files),
|
|
60
|
+
"entrypoints": _entrypoints(settings.project_root, scripts),
|
|
61
|
+
"tests": _matching_scripts(scripts, ("test", "pytest", "phpunit")),
|
|
62
|
+
"lint": _matching_scripts(scripts, ("lint", "ruff", "eslint", "checkstyle", "clippy")),
|
|
63
|
+
"formatter": _matching_scripts(
|
|
64
|
+
scripts, ("format", "fmt", "black", "prettier", "php-cs-fixer")
|
|
65
|
+
),
|
|
66
|
+
"type_checker": _matching_scripts(scripts, ("type", "mypy", "tsc", "phpstan")),
|
|
67
|
+
"run": _matching_scripts(scripts, ("start", "dev", "serve", "run")),
|
|
68
|
+
"ci": sorted(
|
|
69
|
+
str(path.relative_to(settings.project_root))
|
|
70
|
+
for path in (settings.project_root / ".github" / "workflows").glob("*.y*ml")
|
|
71
|
+
),
|
|
72
|
+
"docker": any(
|
|
73
|
+
name in files for name in ("Dockerfile", "compose.yaml", "docker-compose.yml")
|
|
74
|
+
),
|
|
75
|
+
"env_example_variables": _env_examples(settings.project_root),
|
|
76
|
+
"config_files": sorted(name for name in files if name in SIGNALS or name.startswith(".")),
|
|
77
|
+
"config_warnings": settings.warnings,
|
|
78
|
+
"runtime_requirements": [requirement.to_dict() for requirement in runtime_requirements],
|
|
79
|
+
"workspaces": [workspace.to_dict() for workspace in workspaces],
|
|
80
|
+
"workspace_count": len(workspaces),
|
|
81
|
+
}
|
|
82
|
+
report.finish()
|
|
83
|
+
write_markdown(report, settings.reports_directory / "project-scan.md")
|
|
84
|
+
write_json(report, settings.reports_directory / "project-scan.json")
|
|
85
|
+
return report
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _detect_package_managers(files: dict[str, Path]) -> list[str]:
|
|
89
|
+
pairs = [
|
|
90
|
+
("pyproject.toml", "pip/pyproject"),
|
|
91
|
+
("requirements.txt", "pip"),
|
|
92
|
+
("package.json", "npm"),
|
|
93
|
+
("pnpm-lock.yaml", "pnpm"),
|
|
94
|
+
("yarn.lock", "yarn"),
|
|
95
|
+
("pom.xml", "maven"),
|
|
96
|
+
("Cargo.toml", "cargo"),
|
|
97
|
+
("composer.json", "composer"),
|
|
98
|
+
]
|
|
99
|
+
managers = [manager for filename, manager in pairs if filename in files]
|
|
100
|
+
if "build.gradle" in files or "build.gradle.kts" in files:
|
|
101
|
+
managers.append("gradle")
|
|
102
|
+
return managers
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _detect_scripts(root: Path) -> dict[str, str]:
|
|
106
|
+
scripts: dict[str, str] = {}
|
|
107
|
+
package_json = root / "package.json"
|
|
108
|
+
if package_json.exists():
|
|
109
|
+
try:
|
|
110
|
+
package = json.loads(package_json.read_text(encoding="utf-8"))
|
|
111
|
+
scripts.update({f"npm:{k}": str(v) for k, v in package.get("scripts", {}).items()})
|
|
112
|
+
except json.JSONDecodeError:
|
|
113
|
+
pass
|
|
114
|
+
pyproject = root / "pyproject.toml"
|
|
115
|
+
if pyproject.exists():
|
|
116
|
+
try:
|
|
117
|
+
data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
|
|
118
|
+
scripts.update(
|
|
119
|
+
{
|
|
120
|
+
f"python:{k}": str(v)
|
|
121
|
+
for k, v in data.get("project", {}).get("scripts", {}).items()
|
|
122
|
+
}
|
|
123
|
+
)
|
|
124
|
+
except tomllib.TOMLDecodeError:
|
|
125
|
+
pass
|
|
126
|
+
makefile = root / "Makefile"
|
|
127
|
+
if makefile.exists():
|
|
128
|
+
for line in makefile.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
129
|
+
match = re.match(r"^([A-Za-z0-9_.-]+):", line)
|
|
130
|
+
if match:
|
|
131
|
+
scripts[f"make:{match.group(1)}"] = f"make {match.group(1)}"
|
|
132
|
+
return scripts
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _dependency_names(root: Path) -> set[str]:
|
|
136
|
+
names: set[str] = set()
|
|
137
|
+
package_json = root / "package.json"
|
|
138
|
+
if package_json.exists():
|
|
139
|
+
try:
|
|
140
|
+
package = json.loads(package_json.read_text(encoding="utf-8"))
|
|
141
|
+
names.update(package.get("dependencies", {}))
|
|
142
|
+
names.update(package.get("devDependencies", {}))
|
|
143
|
+
except json.JSONDecodeError:
|
|
144
|
+
pass
|
|
145
|
+
pyproject = root / "pyproject.toml"
|
|
146
|
+
if pyproject.exists():
|
|
147
|
+
try:
|
|
148
|
+
data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
|
|
149
|
+
for dep in data.get("project", {}).get("dependencies", []):
|
|
150
|
+
names.add(str(dep).split("[", 1)[0].split("=", 1)[0].lower())
|
|
151
|
+
for deps in data.get("project", {}).get("optional-dependencies", {}).values():
|
|
152
|
+
for dep in deps:
|
|
153
|
+
names.add(str(dep).split("[", 1)[0].split("=", 1)[0].lower())
|
|
154
|
+
except tomllib.TOMLDecodeError:
|
|
155
|
+
pass
|
|
156
|
+
return names
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _env_examples(root: Path) -> list[str]:
|
|
160
|
+
variables: set[str] = set()
|
|
161
|
+
for path in root.glob(".env*"):
|
|
162
|
+
if path.name == ".env":
|
|
163
|
+
continue
|
|
164
|
+
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
165
|
+
if "=" in line and not line.lstrip().startswith("#"):
|
|
166
|
+
variables.add(line.split("=", 1)[0].strip())
|
|
167
|
+
return sorted(variables)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _matching_scripts(scripts: dict[str, str], needles: tuple[str, ...]) -> dict[str, str]:
|
|
171
|
+
return {
|
|
172
|
+
key: value
|
|
173
|
+
for key, value in scripts.items()
|
|
174
|
+
if any(n in f"{key} {value}".lower() for n in needles)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _entrypoints(root: Path, scripts: dict[str, str]) -> list[str]:
|
|
179
|
+
found = [
|
|
180
|
+
name
|
|
181
|
+
for name in ("main.py", "app.py", "src/main.py", "index.js", "src/index.ts", "src/main.rs")
|
|
182
|
+
if (root / name).exists()
|
|
183
|
+
]
|
|
184
|
+
found.extend(
|
|
185
|
+
f"script:{name}"
|
|
186
|
+
for name in scripts
|
|
187
|
+
if any(token in name for token in ("start", "dev", "run"))
|
|
188
|
+
)
|
|
189
|
+
return sorted(found)
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import fnmatch
|
|
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
|
+
|
|
10
|
+
IMPORTANT_NAMES = {
|
|
11
|
+
"pyproject.toml",
|
|
12
|
+
"package.json",
|
|
13
|
+
"Cargo.toml",
|
|
14
|
+
"composer.json",
|
|
15
|
+
"pom.xml",
|
|
16
|
+
"build.gradle",
|
|
17
|
+
"Dockerfile",
|
|
18
|
+
"compose.yaml",
|
|
19
|
+
"docker-compose.yml",
|
|
20
|
+
"Makefile",
|
|
21
|
+
"README.md",
|
|
22
|
+
"LICENSE",
|
|
23
|
+
}
|
|
24
|
+
BINARY_EXTENSIONS = {
|
|
25
|
+
".png",
|
|
26
|
+
".jpg",
|
|
27
|
+
".jpeg",
|
|
28
|
+
".gif",
|
|
29
|
+
".ico",
|
|
30
|
+
".pdf",
|
|
31
|
+
".zip",
|
|
32
|
+
".exe",
|
|
33
|
+
".dll",
|
|
34
|
+
".so",
|
|
35
|
+
".dylib",
|
|
36
|
+
}
|
|
37
|
+
GENERATED_PATTERNS = ("*.min.js", "*.lock", "package-lock.json", "coverage.xml")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def map_repository(project_root: Path, max_files: int = 500, max_depth: int = 6) -> Report:
|
|
41
|
+
settings = load_settings(project_root)
|
|
42
|
+
ignores = settings.ignore_paths | _gitignore_patterns(settings.project_root)
|
|
43
|
+
files: list[Path] = []
|
|
44
|
+
dirs: set[str] = set()
|
|
45
|
+
generated: list[str] = []
|
|
46
|
+
for path in settings.project_root.rglob("*"):
|
|
47
|
+
rel = path.relative_to(settings.project_root)
|
|
48
|
+
if len(rel.parts) > max_depth or _ignored(rel, ignores) or _is_binary(path):
|
|
49
|
+
continue
|
|
50
|
+
if path.is_dir():
|
|
51
|
+
dirs.add(str(rel))
|
|
52
|
+
continue
|
|
53
|
+
files.append(path)
|
|
54
|
+
if any(fnmatch.fnmatch(path.name, pattern) for pattern in GENERATED_PATTERNS):
|
|
55
|
+
generated.append(str(rel))
|
|
56
|
+
important = [
|
|
57
|
+
str(p.relative_to(settings.project_root))
|
|
58
|
+
for p in files
|
|
59
|
+
if p.name in IMPORTANT_NAMES or ".github/workflows" in p.as_posix()
|
|
60
|
+
]
|
|
61
|
+
tests = [
|
|
62
|
+
str(p.relative_to(settings.project_root))
|
|
63
|
+
for p in files
|
|
64
|
+
if "test" in p.name.lower() or "tests" in p.parts
|
|
65
|
+
]
|
|
66
|
+
docs = [
|
|
67
|
+
str(p.relative_to(settings.project_root))
|
|
68
|
+
for p in files
|
|
69
|
+
if p.suffix.lower() in {".md", ".rst"} or "docs" in p.parts
|
|
70
|
+
]
|
|
71
|
+
report = Report(command="map", project_root=settings.project_root)
|
|
72
|
+
truncated = len(files) > max_files
|
|
73
|
+
report.summary = {
|
|
74
|
+
"directories": sorted(dirs)[:max_files],
|
|
75
|
+
"important_files": sorted(important)[:max_files],
|
|
76
|
+
"tests": sorted(tests)[:max_files],
|
|
77
|
+
"ci_workflows": sorted(
|
|
78
|
+
p for p in important if p.replace("\\", "/").startswith(".github/workflows/")
|
|
79
|
+
),
|
|
80
|
+
"documentation": sorted(docs)[:max_files],
|
|
81
|
+
"generated_or_lock_files": sorted(generated)[:max_files],
|
|
82
|
+
"omitted_patterns": sorted(ignores),
|
|
83
|
+
"file_count_scanned": len(files),
|
|
84
|
+
"max_files": max_files,
|
|
85
|
+
"max_depth": max_depth,
|
|
86
|
+
"truncated": truncated,
|
|
87
|
+
"large_files": _large_files(settings.project_root, files),
|
|
88
|
+
}
|
|
89
|
+
report.finish()
|
|
90
|
+
write_markdown(report, settings.reports_directory / "repository-map.md")
|
|
91
|
+
write_json(report, settings.reports_directory / "repository-map.json")
|
|
92
|
+
return report
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _gitignore_patterns(root: Path) -> set[str]:
|
|
96
|
+
path = root / ".gitignore"
|
|
97
|
+
if not path.exists():
|
|
98
|
+
return set()
|
|
99
|
+
return {
|
|
100
|
+
line.strip().rstrip("/")
|
|
101
|
+
for line in path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
102
|
+
if line.strip() and not line.startswith("#")
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _ignored(rel: Path, patterns: set[str]) -> bool:
|
|
107
|
+
parts = set(rel.parts)
|
|
108
|
+
text = rel.as_posix()
|
|
109
|
+
normalized_patterns = {pattern.replace("\\", "/") for pattern in patterns}
|
|
110
|
+
return any(
|
|
111
|
+
pattern in parts
|
|
112
|
+
or text == pattern
|
|
113
|
+
or text.startswith(f"{pattern}/")
|
|
114
|
+
or fnmatch.fnmatch(text, pattern)
|
|
115
|
+
or fnmatch.fnmatch(rel.name, pattern)
|
|
116
|
+
for pattern in normalized_patterns
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _is_binary(path: Path) -> bool:
|
|
121
|
+
return path.is_file() and path.suffix.lower() in BINARY_EXTENSIONS
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _large_files(root: Path, files: list[Path]) -> list[str]:
|
|
125
|
+
return [
|
|
126
|
+
str(path.relative_to(root))
|
|
127
|
+
for path in files
|
|
128
|
+
if path.exists() and path.is_file() and path.stat().st_size > 1_000_000
|
|
129
|
+
][:50]
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import tomllib
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from ai_dev_tools.models.workspace import RuntimeRequirement
|
|
9
|
+
|
|
10
|
+
_VERSION = re.compile(r"\d+(?:\.\d+){0,3}")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def detect_runtime_requirements(root: Path) -> list[RuntimeRequirement]:
|
|
14
|
+
requirements: list[RuntimeRequirement] = []
|
|
15
|
+
pyproject = _toml(root / "pyproject.toml")
|
|
16
|
+
project = pyproject.get("project", {})
|
|
17
|
+
if isinstance(project, dict) and isinstance(project.get("requires-python"), str):
|
|
18
|
+
requirements.append(
|
|
19
|
+
RuntimeRequirement("python", project["requires-python"], "pyproject.toml")
|
|
20
|
+
)
|
|
21
|
+
python_version = _first_line(root / ".python-version")
|
|
22
|
+
if python_version:
|
|
23
|
+
requirements.append(RuntimeRequirement("python", python_version, ".python-version"))
|
|
24
|
+
|
|
25
|
+
package = _json(root / "package.json")
|
|
26
|
+
engines = package.get("engines", {})
|
|
27
|
+
if isinstance(engines, dict) and isinstance(engines.get("node"), str):
|
|
28
|
+
requirements.append(RuntimeRequirement("node", engines["node"], "package.json"))
|
|
29
|
+
nvmrc = _first_line(root / ".nvmrc")
|
|
30
|
+
if nvmrc:
|
|
31
|
+
requirements.append(RuntimeRequirement("node", nvmrc, ".nvmrc"))
|
|
32
|
+
|
|
33
|
+
rust_toolchain = _toml(root / "rust-toolchain.toml")
|
|
34
|
+
toolchain = rust_toolchain.get("toolchain", {})
|
|
35
|
+
if isinstance(toolchain, dict) and isinstance(toolchain.get("channel"), str):
|
|
36
|
+
requirements.append(RuntimeRequirement("rust", toolchain["channel"], "rust-toolchain.toml"))
|
|
37
|
+
elif channel := _first_line(root / "rust-toolchain"):
|
|
38
|
+
requirements.append(RuntimeRequirement("rust", channel, "rust-toolchain"))
|
|
39
|
+
|
|
40
|
+
composer = _json(root / "composer.json")
|
|
41
|
+
composer_require = composer.get("require", {})
|
|
42
|
+
if isinstance(composer_require, dict) and isinstance(composer_require.get("php"), str):
|
|
43
|
+
requirements.append(RuntimeRequirement("php", composer_require["php"], "composer.json"))
|
|
44
|
+
|
|
45
|
+
pom_text = _text(root / "pom.xml")
|
|
46
|
+
java_constraint = _first_match(
|
|
47
|
+
pom_text,
|
|
48
|
+
(
|
|
49
|
+
r"<maven\.compiler\.release>\s*([^<]+)",
|
|
50
|
+
r"<maven\.compiler\.source>\s*([^<]+)",
|
|
51
|
+
r"<java\.version>\s*([^<]+)",
|
|
52
|
+
),
|
|
53
|
+
)
|
|
54
|
+
if java_constraint:
|
|
55
|
+
requirements.append(RuntimeRequirement("java", java_constraint, "pom.xml"))
|
|
56
|
+
|
|
57
|
+
gradle_text = _text(root / "build.gradle") + "\n" + _text(root / "build.gradle.kts")
|
|
58
|
+
gradle_java = _first_match(
|
|
59
|
+
gradle_text,
|
|
60
|
+
(
|
|
61
|
+
r"(?:sourceCompatibility|languageVersion)\s*[=:]?\s*(?:JavaVersion\.VERSION_)?([0-9_\.]+)",
|
|
62
|
+
),
|
|
63
|
+
)
|
|
64
|
+
if gradle_java:
|
|
65
|
+
requirements.append(
|
|
66
|
+
RuntimeRequirement("java", gradle_java.replace("_", "."), "Gradle configuration")
|
|
67
|
+
)
|
|
68
|
+
return _deduplicate(requirements)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def evaluate_requirement(
|
|
72
|
+
requirement: RuntimeRequirement, detected_version: str | None
|
|
73
|
+
) -> dict[str, str | None]:
|
|
74
|
+
if detected_version is None:
|
|
75
|
+
return {
|
|
76
|
+
**requirement.to_dict(),
|
|
77
|
+
"detected": None,
|
|
78
|
+
"status": "missing",
|
|
79
|
+
}
|
|
80
|
+
detected = _version_tuple(detected_version)
|
|
81
|
+
if detected is None:
|
|
82
|
+
return {
|
|
83
|
+
**requirement.to_dict(),
|
|
84
|
+
"detected": detected_version,
|
|
85
|
+
"status": "unknown",
|
|
86
|
+
}
|
|
87
|
+
compatible = _matches_constraint(detected, requirement.constraint)
|
|
88
|
+
return {
|
|
89
|
+
**requirement.to_dict(),
|
|
90
|
+
"detected": detected_version,
|
|
91
|
+
"status": "compatible" if compatible else "incompatible",
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _matches_constraint(version: tuple[int, ...], constraint: str) -> bool:
|
|
96
|
+
normalized = constraint.strip().lower().removeprefix("v")
|
|
97
|
+
if normalized in {"stable", "nightly", "beta", "*", "latest"}:
|
|
98
|
+
return True
|
|
99
|
+
alternatives = [item.strip() for item in normalized.split("||")]
|
|
100
|
+
return any(_matches_all(version, alternative) for alternative in alternatives)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _matches_all(version: tuple[int, ...], constraint: str) -> bool:
|
|
104
|
+
parts = [item for item in re.split(r"\s*,\s*|\s+", constraint) if item]
|
|
105
|
+
if not parts:
|
|
106
|
+
return True
|
|
107
|
+
for part in parts:
|
|
108
|
+
match = re.match(r"(>=|<=|==|=|>|<|\^|~)?\s*v?(\d+(?:\.\d+){0,3})", part)
|
|
109
|
+
if match is None:
|
|
110
|
+
continue
|
|
111
|
+
operator = match.group(1) or "=="
|
|
112
|
+
expected = _version_tuple(match.group(2))
|
|
113
|
+
if expected is None:
|
|
114
|
+
continue
|
|
115
|
+
left, right = _padded(version, expected)
|
|
116
|
+
if operator in {"=", "=="} and left[: len(expected)] != right[: len(expected)]:
|
|
117
|
+
return False
|
|
118
|
+
if operator == ">=" and left < right:
|
|
119
|
+
return False
|
|
120
|
+
if operator == "<=" and left > right:
|
|
121
|
+
return False
|
|
122
|
+
if operator == ">" and left <= right:
|
|
123
|
+
return False
|
|
124
|
+
if operator == "<" and left >= right:
|
|
125
|
+
return False
|
|
126
|
+
if operator == "^":
|
|
127
|
+
upper = (right[0] + 1, *([0] * (len(right) - 1)))
|
|
128
|
+
if left < right or left >= upper:
|
|
129
|
+
return False
|
|
130
|
+
if operator == "~":
|
|
131
|
+
upper = (right[0], (right[1] if len(right) > 1 else 0) + 1, 0, 0)
|
|
132
|
+
upper = upper[: len(right)]
|
|
133
|
+
if left < right or left >= upper:
|
|
134
|
+
return False
|
|
135
|
+
return True
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _padded(
|
|
139
|
+
left: tuple[int, ...], right: tuple[int, ...]
|
|
140
|
+
) -> tuple[tuple[int, ...], tuple[int, ...]]:
|
|
141
|
+
size = max(len(left), len(right), 3)
|
|
142
|
+
return left + (0,) * (size - len(left)), right + (0,) * (size - len(right))
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _version_tuple(value: str) -> tuple[int, ...] | None:
|
|
146
|
+
match = _VERSION.search(value)
|
|
147
|
+
return tuple(int(item) for item in match.group(0).split(".")) if match else None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _deduplicate(items: list[RuntimeRequirement]) -> list[RuntimeRequirement]:
|
|
151
|
+
found: dict[tuple[str, str, str], RuntimeRequirement] = {}
|
|
152
|
+
for item in items:
|
|
153
|
+
found[(item.runtime, item.constraint, item.source)] = item
|
|
154
|
+
return list(found.values())
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _first_match(text: str, patterns: tuple[str, ...]) -> str | None:
|
|
158
|
+
for pattern in patterns:
|
|
159
|
+
if match := re.search(pattern, text):
|
|
160
|
+
return match.group(1).strip()
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _first_line(path: Path) -> str | None:
|
|
165
|
+
text = _text(path).strip()
|
|
166
|
+
return text.splitlines()[0].strip() if text else None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _text(path: Path) -> str:
|
|
170
|
+
try:
|
|
171
|
+
return path.read_text(encoding="utf-8", errors="replace")
|
|
172
|
+
except OSError:
|
|
173
|
+
return ""
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _toml(path: Path) -> dict[str, object]:
|
|
177
|
+
try:
|
|
178
|
+
with path.open("rb") as handle:
|
|
179
|
+
value = tomllib.load(handle)
|
|
180
|
+
return value if isinstance(value, dict) else {}
|
|
181
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
182
|
+
return {}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _json(path: Path) -> dict[str, object]:
|
|
186
|
+
try:
|
|
187
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
188
|
+
return value if isinstance(value, dict) else {}
|
|
189
|
+
except (OSError, json.JSONDecodeError):
|
|
190
|
+
return {}
|