autodevloop 0.1.0__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.
- autodevloop/__init__.py +5 -0
- autodevloop/__main__.py +6 -0
- autodevloop/cli.py +233 -0
- autodevloop/config.py +165 -0
- autodevloop/engine.py +750 -0
- autodevloop/llm.py +259 -0
- autodevloop/prompts.py +342 -0
- autodevloop/py.typed +0 -0
- autodevloop/registry.py +37 -0
- autodevloop/reporting.py +127 -0
- autodevloop/testing.py +119 -0
- autodevloop/util.py +250 -0
- autodevloop/vcs.py +74 -0
- autodevloop/webapp.py +1184 -0
- autodevloop/yaml_compat.py +192 -0
- autodevloop-0.1.0.dist-info/METADATA +332 -0
- autodevloop-0.1.0.dist-info/RECORD +21 -0
- autodevloop-0.1.0.dist-info/WHEEL +5 -0
- autodevloop-0.1.0.dist-info/entry_points.txt +2 -0
- autodevloop-0.1.0.dist-info/licenses/LICENSE +21 -0
- autodevloop-0.1.0.dist-info/top_level.txt +1 -0
autodevloop/reporting.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Human-readable reports: CHANGELOG, FEATURES overview table, final report."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .util import markdown_list, now_text, read_text, write_text
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def write_version_changelog(
|
|
12
|
+
changelog_path: Path,
|
|
13
|
+
version: int,
|
|
14
|
+
plan: dict[str, Any],
|
|
15
|
+
diff: dict[str, list[str]],
|
|
16
|
+
test_result: dict[str, Any],
|
|
17
|
+
review: dict[str, Any],
|
|
18
|
+
phase: str,
|
|
19
|
+
) -> None:
|
|
20
|
+
if not changelog_path.exists():
|
|
21
|
+
write_text(changelog_path, "# Changelog\n\nAll AutoDevLoop-generated versions are recorded here.\n\n")
|
|
22
|
+
|
|
23
|
+
whats_new = review.get("whats_new") or []
|
|
24
|
+
summary = str(review.get("feature_summary") or plan.get("version_goal", "")).strip()
|
|
25
|
+
commands = ", ".join(test_result.get("commands", [])) or "No command"
|
|
26
|
+
lines = [
|
|
27
|
+
f"## v{version} - {now_text()} ({phase} phase)",
|
|
28
|
+
"",
|
|
29
|
+
f"_{summary}_" if summary else "",
|
|
30
|
+
"",
|
|
31
|
+
"### What's new",
|
|
32
|
+
markdown_list(whats_new) if whats_new else markdown_list(diff.get("added", []) + diff.get("changed", [])),
|
|
33
|
+
"",
|
|
34
|
+
"### Files",
|
|
35
|
+
f"- Added: {len(diff.get('added', []))}, Changed: {len(diff.get('changed', []))}, Removed: {len(diff.get('removed', []))}",
|
|
36
|
+
"",
|
|
37
|
+
"### Tests",
|
|
38
|
+
f"- {'PASS' if test_result.get('success') else 'FAIL'}: {commands}",
|
|
39
|
+
"",
|
|
40
|
+
"### Known issues",
|
|
41
|
+
markdown_list(review.get("issues", [])),
|
|
42
|
+
"",
|
|
43
|
+
]
|
|
44
|
+
existing = read_text(changelog_path)
|
|
45
|
+
write_text(changelog_path, existing.rstrip() + "\n\n" + "\n".join(line for line in lines) + "\n")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def write_features_overview(features_path: Path, state: dict[str, Any]) -> None:
|
|
49
|
+
"""The at-a-glance table: every version, its features, and what changed."""
|
|
50
|
+
versions = state.get("versions", [])
|
|
51
|
+
goal_version = state.get("goal_completed_version")
|
|
52
|
+
lines = [
|
|
53
|
+
"# Features Overview",
|
|
54
|
+
"",
|
|
55
|
+
f"**Project:** {state.get('project_name', '')} ",
|
|
56
|
+
f"**Goal:** {state.get('goal', '')}",
|
|
57
|
+
"",
|
|
58
|
+
]
|
|
59
|
+
if goal_version:
|
|
60
|
+
lines.append(f"> Core goal first fully met at **v{goal_version}** (tag `goal-complete`). "
|
|
61
|
+
"Later versions extend the product beyond the original request.")
|
|
62
|
+
lines.append("")
|
|
63
|
+
lines += [
|
|
64
|
+
"| Version | Phase | Score | Tests | Summary | What's new |",
|
|
65
|
+
"|---|---|---|---|---|---|",
|
|
66
|
+
]
|
|
67
|
+
for item in versions:
|
|
68
|
+
v = item.get("version")
|
|
69
|
+
phase = item.get("phase", "build")
|
|
70
|
+
score = item.get("review_score", "?")
|
|
71
|
+
tests = "✅" if item.get("test_result", {}).get("success") else "❌"
|
|
72
|
+
summary = _cell(item.get("feature_summary") or item.get("plan", {}).get("version_goal", ""))
|
|
73
|
+
whats_new = _cell("; ".join(item.get("whats_new", [])[:4]))
|
|
74
|
+
marker = " 🎯" if v == goal_version else ""
|
|
75
|
+
lines.append(f"| v{v}{marker} | {phase} | {score}/100 | {tests} | {summary} | {whats_new} |")
|
|
76
|
+
lines.append("")
|
|
77
|
+
write_text(features_path, "\n".join(lines))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _cell(text: str) -> str:
|
|
81
|
+
flat = " ".join(str(text).split())
|
|
82
|
+
flat = flat.replace("|", "\\|")
|
|
83
|
+
return flat[:160] + ("…" if len(flat) > 160 else "")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def write_final_report(report_path: Path, state: dict[str, Any]) -> None:
|
|
87
|
+
versions = state.get("versions", [])
|
|
88
|
+
scores = [v.get("review_score", 0) for v in versions if isinstance(v.get("review_score", 0), int)]
|
|
89
|
+
avg = round(sum(scores) / len(scores)) if scores else 0
|
|
90
|
+
cost = state.get("cost", {})
|
|
91
|
+
lines = [
|
|
92
|
+
"# AutoDevLoop Final Report",
|
|
93
|
+
"",
|
|
94
|
+
f"**Project:** {state.get('project_name')}",
|
|
95
|
+
f"**Status:** {state.get('status')}",
|
|
96
|
+
f"**Stop reason:** {state.get('stop_reason', 'N/A')}",
|
|
97
|
+
f"**Total versions:** {state.get('current_version')}",
|
|
98
|
+
f"**Goal met at:** {('v' + str(state.get('goal_completed_version'))) if state.get('goal_completed_version') else 'not reached'}",
|
|
99
|
+
f"**Average review score:** {avg}/100",
|
|
100
|
+
f"**Estimated cost:** ${cost.get('cost_usd_total', 0):.4f} "
|
|
101
|
+
f"(in {cost.get('input_tokens', 0)} / out {cost.get('output_tokens', 0)} tokens)",
|
|
102
|
+
"",
|
|
103
|
+
"## Goal",
|
|
104
|
+
str(state.get("goal", "")),
|
|
105
|
+
"",
|
|
106
|
+
"## Version history",
|
|
107
|
+
]
|
|
108
|
+
for item in versions:
|
|
109
|
+
v = item.get("version")
|
|
110
|
+
score = item.get("review_score", "?")
|
|
111
|
+
test = item.get("test_result", {})
|
|
112
|
+
phase = item.get("phase", "build")
|
|
113
|
+
lines.append(
|
|
114
|
+
f"- **v{v}** ({phase}) - score {score}/100 - tests "
|
|
115
|
+
f"{'PASS' if test.get('success') else 'FAIL'} - {_cell(item.get('feature_summary', ''))}"
|
|
116
|
+
)
|
|
117
|
+
lines += [
|
|
118
|
+
"",
|
|
119
|
+
"## Output layout",
|
|
120
|
+
"- Latest working copy: `current/`",
|
|
121
|
+
"- Per-version snapshots: `versions/vN/`",
|
|
122
|
+
"- Git history & tags inside `current/` (if git enabled)",
|
|
123
|
+
"- Changelog: `CHANGELOG.md`",
|
|
124
|
+
"- Features overview: `FEATURES.md`",
|
|
125
|
+
"",
|
|
126
|
+
]
|
|
127
|
+
write_text(report_path, "\n".join(lines))
|
autodevloop/testing.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Built-in test detection and execution (no LLM required)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .util import INTERNAL_DIRS, list_generated_files, load_json, read_text
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def detect_candidates(base_dir: Path) -> list[dict[str, Any]]:
|
|
15
|
+
files = set(list_generated_files(base_dir))
|
|
16
|
+
candidates: list[dict[str, Any]] = []
|
|
17
|
+
|
|
18
|
+
if "package.json" in files:
|
|
19
|
+
package = load_json(base_dir / "package.json", {})
|
|
20
|
+
scripts = package.get("scripts", {}) if isinstance(package, dict) else {}
|
|
21
|
+
if "test" in scripts:
|
|
22
|
+
candidates.append({"name": "npm test", "command": "npm test", "kind": "node"})
|
|
23
|
+
if "build" in scripts:
|
|
24
|
+
candidates.append({"name": "npm run build", "command": "npm run build", "kind": "node"})
|
|
25
|
+
|
|
26
|
+
has_py_tests = any(f.startswith("tests/") and f.endswith(".py") for f in files) or any(
|
|
27
|
+
Path(f).name.startswith("test_") for f in files
|
|
28
|
+
)
|
|
29
|
+
if has_py_tests:
|
|
30
|
+
candidates.append({"name": "pytest", "command": "python -m pytest -q", "kind": "python"})
|
|
31
|
+
candidates.append({"name": "unittest", "command": "python -m unittest discover", "kind": "python"})
|
|
32
|
+
|
|
33
|
+
if "index.html" in files:
|
|
34
|
+
html = read_text(base_dir / "index.html")
|
|
35
|
+
candidates.append({"name": "static html smoke", "command": "__builtin_html_smoke__", "kind": "web"})
|
|
36
|
+
if 'type="module"' in html or "type='module'" in html:
|
|
37
|
+
candidates.append({"name": "module html server smoke", "command": "__builtin_html_server_smoke__", "kind": "web"})
|
|
38
|
+
|
|
39
|
+
if "pyproject.toml" in files or any(f.endswith(".py") for f in files):
|
|
40
|
+
candidates.append({"name": "python compile", "command": "__builtin_python_compile__", "kind": "python"})
|
|
41
|
+
|
|
42
|
+
candidates.append({"name": "file smoke", "command": "__builtin_file_smoke__", "kind": "generic"})
|
|
43
|
+
return candidates
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def run_command(base_dir: Path, command: str, timeout: int, log_path: Path) -> dict[str, Any]:
|
|
47
|
+
builtins = {
|
|
48
|
+
"__builtin_file_smoke__": _file_smoke,
|
|
49
|
+
"__builtin_html_smoke__": lambda d, t: _html_smoke(d, server=False),
|
|
50
|
+
"__builtin_html_server_smoke__": lambda d, t: _html_smoke(d, server=True),
|
|
51
|
+
"__builtin_python_compile__": _python_compile,
|
|
52
|
+
}
|
|
53
|
+
if command in builtins:
|
|
54
|
+
return builtins[command](base_dir, timeout)
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
completed = subprocess.run(
|
|
58
|
+
command, cwd=str(base_dir), shell=True, text=True, encoding="utf-8",
|
|
59
|
+
errors="replace", capture_output=True, timeout=timeout,
|
|
60
|
+
)
|
|
61
|
+
except subprocess.TimeoutExpired:
|
|
62
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
log_path.write_text(f"$ {command}\nTimed out after {timeout}s.\n", encoding="utf-8")
|
|
64
|
+
return {"success": False, "command": command, "returncode": -1, "log": log_path.name}
|
|
65
|
+
|
|
66
|
+
output = (
|
|
67
|
+
f"$ {command}\ncwd={base_dir}\nreturncode={completed.returncode}\n\n"
|
|
68
|
+
f"[stdout]\n{completed.stdout}\n\n[stderr]\n{completed.stderr}\n"
|
|
69
|
+
)
|
|
70
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
log_path.write_text(output, encoding="utf-8")
|
|
72
|
+
return {"success": completed.returncode == 0, "command": command, "returncode": completed.returncode, "log": log_path.name}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _file_smoke(base_dir: Path, _timeout: int) -> dict[str, Any]:
|
|
76
|
+
files = list_generated_files(base_dir)
|
|
77
|
+
success = bool(files)
|
|
78
|
+
return {"success": success, "command": "__builtin_file_smoke__", "returncode": 0 if success else 1, "details": f"{len(files)} files"}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _python_compile(base_dir: Path, timeout: int) -> dict[str, Any]:
|
|
82
|
+
py_files = [
|
|
83
|
+
str(p) for p in base_dir.rglob("*.py")
|
|
84
|
+
if not any(part in INTERNAL_DIRS for part in p.relative_to(base_dir).parts)
|
|
85
|
+
]
|
|
86
|
+
if not py_files:
|
|
87
|
+
return {"success": True, "command": "__builtin_python_compile__", "returncode": 0, "details": "No Python files."}
|
|
88
|
+
completed = subprocess.run(
|
|
89
|
+
[sys.executable, "-m", "py_compile", *py_files],
|
|
90
|
+
cwd=str(base_dir), text=True, encoding="utf-8", errors="replace",
|
|
91
|
+
capture_output=True, timeout=timeout,
|
|
92
|
+
)
|
|
93
|
+
return {
|
|
94
|
+
"success": completed.returncode == 0,
|
|
95
|
+
"command": "__builtin_python_compile__",
|
|
96
|
+
"returncode": completed.returncode,
|
|
97
|
+
"details": (completed.stderr or "").strip()[:300],
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _html_smoke(base_dir: Path, server: bool) -> dict[str, Any]:
|
|
102
|
+
marker = "__builtin_html_server_smoke__" if server else "__builtin_html_smoke__"
|
|
103
|
+
index = base_dir / "index.html"
|
|
104
|
+
if not index.exists():
|
|
105
|
+
return {"success": False, "command": marker, "returncode": 1, "details": "index.html missing"}
|
|
106
|
+
html = read_text(index)
|
|
107
|
+
missing: list[str] = []
|
|
108
|
+
for attr in re.findall(r"""(?:src|href)=["']([^"']+)["']""", html):
|
|
109
|
+
if attr.startswith(("http://", "https://", "data:", "#", "//", "mailto:")):
|
|
110
|
+
continue
|
|
111
|
+
target = base_dir / attr.split("?", 1)[0].lstrip("/")
|
|
112
|
+
if not target.exists():
|
|
113
|
+
missing.append(attr)
|
|
114
|
+
module = 'type="module"' in html or "type='module'" in html
|
|
115
|
+
if missing:
|
|
116
|
+
return {"success": False, "command": marker, "returncode": 1, "details": f"Missing assets: {missing[:5]}"}
|
|
117
|
+
if module and not server:
|
|
118
|
+
return {"success": False, "command": "__builtin_html_smoke__", "returncode": 1, "details": "Uses ES modules; needs a local server."}
|
|
119
|
+
return {"success": True, "command": marker, "returncode": 0, "details": "HTML assets resolved."}
|
autodevloop/util.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""Filesystem, JSON, and text helpers shared across AutoDevLoop modules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime as dt
|
|
6
|
+
import filecmp
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import shutil
|
|
11
|
+
import stat
|
|
12
|
+
import threading
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
APP_DIR = ".autodev"
|
|
17
|
+
CONFIG_FILE = ".autodevloop.yml"
|
|
18
|
+
STATE_FILE = "state.json"
|
|
19
|
+
PROGRESS_FILE = "progress.json"
|
|
20
|
+
STOP_FILE = "STOP"
|
|
21
|
+
|
|
22
|
+
TEXT_SUFFIXES = {
|
|
23
|
+
".css", ".html", ".js", ".json", ".jsx", ".md", ".mjs", ".py",
|
|
24
|
+
".toml", ".ts", ".tsx", ".txt", ".yml", ".yaml", ".vue", ".svelte",
|
|
25
|
+
".go", ".rs", ".java", ".kt", ".rb", ".php", ".c", ".cpp", ".h",
|
|
26
|
+
".sh", ".sql", ".env", ".cfg", ".ini",
|
|
27
|
+
}
|
|
28
|
+
DOC_SUFFIXES = {".md", ".txt", ".rst"}
|
|
29
|
+
INTERNAL_DIRS = {APP_DIR, ".git", "__pycache__", ".pytest_cache", "node_modules", ".venv", "dist", "build"}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def now_text() -> str:
|
|
33
|
+
return dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def ts() -> str:
|
|
37
|
+
return dt.datetime.now().strftime("%H:%M:%S")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def slugify(value: str, fallback: str = "item") -> str:
|
|
41
|
+
cleaned = re.sub(r"[^A-Za-z0-9_-]+", "_", str(value)).strip("_")
|
|
42
|
+
return cleaned or fallback
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def write_text(path: Path, content: str) -> None:
|
|
46
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
path.write_text(content, encoding="utf-8", newline="\n")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def read_text(path: Path, default: str = "") -> str:
|
|
51
|
+
try:
|
|
52
|
+
return path.read_text(encoding="utf-8")
|
|
53
|
+
except (FileNotFoundError, UnicodeDecodeError, OSError):
|
|
54
|
+
return default
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def load_json(path: Path, default: Any) -> Any:
|
|
58
|
+
if not path.exists():
|
|
59
|
+
return default
|
|
60
|
+
try:
|
|
61
|
+
with path.open("r", encoding="utf-8") as fh:
|
|
62
|
+
return json.load(fh)
|
|
63
|
+
except (json.JSONDecodeError, OSError):
|
|
64
|
+
return default
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def json_safe(value: Any) -> Any:
|
|
68
|
+
if isinstance(value, Path):
|
|
69
|
+
return str(value)
|
|
70
|
+
if isinstance(value, dict):
|
|
71
|
+
return {str(k): json_safe(v) for k, v in value.items()}
|
|
72
|
+
if isinstance(value, (list, tuple)):
|
|
73
|
+
return [json_safe(item) for item in value]
|
|
74
|
+
return value
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def save_json(path: Path, data: Any, stamp: bool = True) -> None:
|
|
78
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
if stamp and isinstance(data, dict):
|
|
80
|
+
data["updated_at"] = now_text()
|
|
81
|
+
# Unique temp name so concurrent writers never collide on the same file.
|
|
82
|
+
tmp = path.with_name(f"{path.name}.{os.getpid()}.{threading.get_ident()}.tmp")
|
|
83
|
+
with tmp.open("w", encoding="utf-8", newline="\n") as fh:
|
|
84
|
+
json.dump(json_safe(data), fh, ensure_ascii=False, indent=2)
|
|
85
|
+
fh.write("\n")
|
|
86
|
+
tmp.replace(path)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _chmod_retry(func, path, _exc) -> None:
|
|
90
|
+
"""Clear the read-only bit (common on Windows .git objects) and retry."""
|
|
91
|
+
try:
|
|
92
|
+
os.chmod(path, stat.S_IWRITE)
|
|
93
|
+
func(path)
|
|
94
|
+
except OSError:
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def rmtree_robust(path: Path) -> None:
|
|
99
|
+
if not path.exists():
|
|
100
|
+
return
|
|
101
|
+
try: # Python 3.12+ uses onexc; older uses onerror.
|
|
102
|
+
shutil.rmtree(path, onexc=_chmod_retry)
|
|
103
|
+
except TypeError:
|
|
104
|
+
shutil.rmtree(path, onerror=_chmod_retry)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def safe_rmtree(path: Path, root: Path) -> None:
|
|
108
|
+
resolved = path.resolve()
|
|
109
|
+
root_resolved = root.resolve()
|
|
110
|
+
try:
|
|
111
|
+
resolved.relative_to(root_resolved)
|
|
112
|
+
except ValueError as exc:
|
|
113
|
+
raise RuntimeError(f"refusing to delete outside project: {resolved}") from exc
|
|
114
|
+
rmtree_robust(path)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def restore_working_dir(before_dir: Path, current_dir: Path) -> None:
|
|
118
|
+
"""Restore current_dir's working files from a snapshot, keeping .git intact."""
|
|
119
|
+
if current_dir.exists():
|
|
120
|
+
for item in current_dir.iterdir():
|
|
121
|
+
if item.name in INTERNAL_DIRS:
|
|
122
|
+
continue
|
|
123
|
+
if item.is_dir():
|
|
124
|
+
rmtree_robust(item)
|
|
125
|
+
else:
|
|
126
|
+
try:
|
|
127
|
+
item.unlink()
|
|
128
|
+
except OSError:
|
|
129
|
+
pass
|
|
130
|
+
copy_tree_contents(before_dir, current_dir)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def copy_tree_contents(source: Path, dest: Path) -> None:
|
|
134
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
135
|
+
if not source.exists():
|
|
136
|
+
return
|
|
137
|
+
for item in source.iterdir():
|
|
138
|
+
if item.name in INTERNAL_DIRS:
|
|
139
|
+
continue
|
|
140
|
+
target = dest / item.name
|
|
141
|
+
if item.is_dir():
|
|
142
|
+
shutil.copytree(item, target, dirs_exist_ok=True)
|
|
143
|
+
else:
|
|
144
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
145
|
+
shutil.copy2(item, target)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def list_generated_files(base_dir: Path) -> list[str]:
|
|
149
|
+
if not base_dir.exists():
|
|
150
|
+
return []
|
|
151
|
+
files: list[str] = []
|
|
152
|
+
for path in sorted(base_dir.rglob("*")):
|
|
153
|
+
if path.is_dir():
|
|
154
|
+
continue
|
|
155
|
+
relative = path.relative_to(base_dir)
|
|
156
|
+
if any(part in INTERNAL_DIRS for part in relative.parts):
|
|
157
|
+
continue
|
|
158
|
+
files.append(relative.as_posix())
|
|
159
|
+
return files
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def collect_context(base_dir: Path, max_bytes: int = 70_000) -> str:
|
|
163
|
+
"""Render a file tree plus readable file bodies, recency-prioritised."""
|
|
164
|
+
if not base_dir.exists():
|
|
165
|
+
return "(empty)"
|
|
166
|
+
|
|
167
|
+
tree: list[str] = ["File tree:"]
|
|
168
|
+
files: list[Path] = []
|
|
169
|
+
for path in sorted(base_dir.rglob("*")):
|
|
170
|
+
if path.is_dir():
|
|
171
|
+
continue
|
|
172
|
+
relative = path.relative_to(base_dir)
|
|
173
|
+
if any(part in INTERNAL_DIRS for part in relative.parts):
|
|
174
|
+
continue
|
|
175
|
+
files.append(path)
|
|
176
|
+
tree.append(f"- {relative.as_posix()}")
|
|
177
|
+
|
|
178
|
+
parts = list(tree)
|
|
179
|
+
parts.append("\nReadable files (most recently modified first):")
|
|
180
|
+
used = len("\n".join(parts).encode("utf-8"))
|
|
181
|
+
readable = [p for p in files if p.suffix.lower() in TEXT_SUFFIXES]
|
|
182
|
+
readable.sort(key=lambda p: p.stat().st_mtime if p.exists() else 0, reverse=True)
|
|
183
|
+
for path in readable:
|
|
184
|
+
relative = path.relative_to(base_dir).as_posix()
|
|
185
|
+
block = f'\n<existing_file path="{relative}">\n{read_text(path)}\n</existing_file>\n'
|
|
186
|
+
size = len(block.encode("utf-8"))
|
|
187
|
+
if used + size > max_bytes:
|
|
188
|
+
parts.append("\n(context truncated; older files omitted)")
|
|
189
|
+
break
|
|
190
|
+
parts.append(block)
|
|
191
|
+
used += size
|
|
192
|
+
return "\n".join(parts)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def diff_file_lists(before_dir: Path, after_dir: Path) -> dict[str, list[str]]:
|
|
196
|
+
before = set(list_generated_files(before_dir))
|
|
197
|
+
after = set(list_generated_files(after_dir))
|
|
198
|
+
changed: list[str] = []
|
|
199
|
+
for relative in sorted(before & after):
|
|
200
|
+
left = before_dir / relative
|
|
201
|
+
right = after_dir / relative
|
|
202
|
+
try:
|
|
203
|
+
same = filecmp.cmp(left, right, shallow=False)
|
|
204
|
+
except OSError:
|
|
205
|
+
same = False
|
|
206
|
+
if not same:
|
|
207
|
+
changed.append(relative)
|
|
208
|
+
return {
|
|
209
|
+
"added": sorted(after - before),
|
|
210
|
+
"changed": changed,
|
|
211
|
+
"removed": sorted(before - after),
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def markdown_list(items: list[Any]) -> str:
|
|
216
|
+
values = [str(item).strip() for item in (items or []) if str(item).strip()]
|
|
217
|
+
return "\n".join(f"- {item}" for item in values) if values else "- None"
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def extract_json(raw: str, default: dict[str, Any]) -> dict[str, Any]:
|
|
221
|
+
"""Best-effort JSON extraction from a model response (handles code fences)."""
|
|
222
|
+
text = raw.strip()
|
|
223
|
+
fence = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
|
|
224
|
+
if fence:
|
|
225
|
+
try:
|
|
226
|
+
value = json.loads(fence.group(1))
|
|
227
|
+
if isinstance(value, dict):
|
|
228
|
+
return value
|
|
229
|
+
except json.JSONDecodeError:
|
|
230
|
+
pass
|
|
231
|
+
|
|
232
|
+
try:
|
|
233
|
+
value = json.loads(text)
|
|
234
|
+
if isinstance(value, dict):
|
|
235
|
+
return value
|
|
236
|
+
except json.JSONDecodeError:
|
|
237
|
+
pass
|
|
238
|
+
|
|
239
|
+
decoder = json.JSONDecoder()
|
|
240
|
+
objects: list[tuple[int, dict[str, Any]]] = []
|
|
241
|
+
for idx, char in enumerate(text):
|
|
242
|
+
if char != "{":
|
|
243
|
+
continue
|
|
244
|
+
try:
|
|
245
|
+
value, end = decoder.raw_decode(text[idx:])
|
|
246
|
+
except json.JSONDecodeError:
|
|
247
|
+
continue
|
|
248
|
+
if isinstance(value, dict):
|
|
249
|
+
objects.append((len(json.dumps(value)), value))
|
|
250
|
+
return max(objects, key=lambda item: item[0])[1] if objects else default
|
autodevloop/vcs.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Lightweight git helpers for snapshotting each version inside current/.
|
|
2
|
+
|
|
3
|
+
Git is optional: the versions/ folder copies remain the real backup. When git
|
|
4
|
+
is available we additionally commit and tag every version, and place a special
|
|
5
|
+
tag on the version where the user's goal is first fully met.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
GOAL_TAG = "goal-complete"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def git_available() -> bool:
|
|
18
|
+
return shutil.which("git") is not None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _run(args: list[str], cwd: Path) -> subprocess.CompletedProcess[str]:
|
|
22
|
+
return subprocess.run(
|
|
23
|
+
["git", *args],
|
|
24
|
+
cwd=str(cwd),
|
|
25
|
+
text=True,
|
|
26
|
+
encoding="utf-8",
|
|
27
|
+
errors="replace",
|
|
28
|
+
capture_output=True,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def ensure_repo(cwd: Path) -> bool:
|
|
33
|
+
"""Initialise a git repo in cwd if needed. Returns True if usable."""
|
|
34
|
+
if not git_available():
|
|
35
|
+
return False
|
|
36
|
+
cwd.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
if (cwd / ".git").exists():
|
|
38
|
+
return True
|
|
39
|
+
if _run(["init"], cwd).returncode != 0:
|
|
40
|
+
return False
|
|
41
|
+
# Ensure commits work even without a global identity configured.
|
|
42
|
+
if not _run(["config", "user.name"], cwd).stdout.strip():
|
|
43
|
+
_run(["config", "user.name", "AutoDevLoop"], cwd)
|
|
44
|
+
if not _run(["config", "user.email"], cwd).stdout.strip():
|
|
45
|
+
_run(["config", "user.email", "autodevloop@local"], cwd)
|
|
46
|
+
_run(["config", "commit.gpgsign", "false"], cwd)
|
|
47
|
+
gitignore = cwd / ".gitignore"
|
|
48
|
+
if not gitignore.exists():
|
|
49
|
+
gitignore.write_text("__pycache__/\nnode_modules/\n.venv/\ndist/\nbuild/\n", encoding="utf-8")
|
|
50
|
+
return True
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def commit_all(cwd: Path, message: str) -> str | None:
|
|
54
|
+
if not (cwd / ".git").exists():
|
|
55
|
+
return None
|
|
56
|
+
_run(["add", "-A"], cwd)
|
|
57
|
+
status = _run(["status", "--porcelain"], cwd)
|
|
58
|
+
if not status.stdout.strip():
|
|
59
|
+
# Nothing changed; still allow an (empty) commit so every version has one.
|
|
60
|
+
result = _run(["commit", "--allow-empty", "-m", message], cwd)
|
|
61
|
+
else:
|
|
62
|
+
result = _run(["commit", "-m", message], cwd)
|
|
63
|
+
if result.returncode != 0:
|
|
64
|
+
return None
|
|
65
|
+
rev = _run(["rev-parse", "--short", "HEAD"], cwd)
|
|
66
|
+
return rev.stdout.strip() or None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def tag(cwd: Path, name: str, message: str = "") -> bool:
|
|
70
|
+
if not (cwd / ".git").exists():
|
|
71
|
+
return False
|
|
72
|
+
_run(["tag", "-d", name], cwd) # replace if exists
|
|
73
|
+
result = _run(["tag", "-a", name, "-m", message or name], cwd)
|
|
74
|
+
return result.returncode == 0
|