File size: 7,064 Bytes
5ef3875 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 | """Repository understanding: what kind of project is this, cheaply.
Only marker/config files are inspected; the agent reads source on demand.
"""
from __future__ import annotations
import json
import platform
from dataclasses import dataclass, field
from pathlib import Path
from ycode.tools import git
INSTRUCTION_FILES = ("YCODE.md", ".ycode/YCODE.md")
MAX_INSTRUCTIONS_CHARS = 20000
# marker file -> project type
_MARKERS: list[tuple[str, str]] = [
("package.json", "Node.js"),
("tsconfig.json", "TypeScript"),
("pyproject.toml", "Python"),
("setup.py", "Python"),
("requirements.txt", "Python"),
("Pipfile", "Python"),
("pom.xml", "Java"),
("build.gradle", "Java"),
("build.gradle.kts", "Kotlin"),
("Cargo.toml", "Rust"),
("go.mod", "Go"),
("CMakeLists.txt", "C/C++"),
("Makefile", "Make"),
("pubspec.yaml", "Dart"),
("Gemfile", "Ruby"),
("composer.json", "PHP"),
("deno.json", "Deno"),
]
_INTERESTING_FILES = (
"package.json", "tsconfig.json", "pyproject.toml", "setup.py", "setup.cfg", "requirements.txt",
"Pipfile", "pom.xml", "build.gradle", "build.gradle.kts", "Cargo.toml", "go.mod", "CMakeLists.txt",
"Makefile", "pubspec.yaml", "Gemfile", "composer.json", "pytest.ini", "tox.ini", "jest.config.js",
"jest.config.ts", "vitest.config.ts", "vite.config.ts", "next.config.js", "next.config.mjs",
"next.config.ts", "nuxt.config.ts", "angular.json", ".eslintrc.json", "eslint.config.js",
"Dockerfile", "docker-compose.yml", "README.md",
)
@dataclass
class ProjectContext:
root: Path
os_name: str
is_git_repo: bool
branch: str | None
project_types: list[str] = field(default_factory=list)
key_files: list[str] = field(default_factory=list)
package_manager: str | None = None
scripts: dict[str, str] = field(default_factory=dict)
verification_hints: list[str] = field(default_factory=list)
top_level: list[str] = field(default_factory=list)
instructions: str | None = None
instructions_path: str | None = None
def summary(self) -> str:
lines = [
f"Project root: {self.root}",
f"Operating system: {self.os_name}",
f"Git repository: {'yes' if self.is_git_repo else 'no'}"
+ (f" (branch: {self.branch})" if self.branch else ""),
f"Detected project type(s): {', '.join(self.project_types) or 'unknown'}",
]
if self.package_manager:
lines.append(f"Package manager: {self.package_manager}")
if self.key_files:
lines.append(f"Key config files: {', '.join(self.key_files)}")
if self.scripts:
scripts = ", ".join(f"{k}: `{v}`" for k, v in list(self.scripts.items())[:15])
lines.append(f"package.json scripts: {scripts}")
if self.verification_hints:
lines.append(f"Likely verification commands: {', '.join(self.verification_hints)}")
if self.top_level:
lines.append(f"Top-level entries: {', '.join(self.top_level[:60])}")
return "\n".join(lines)
def _node_details(root: Path, ctx: ProjectContext) -> None:
try:
data = json.loads((root / "package.json").read_text(encoding="utf-8"))
except (OSError, ValueError):
return
deps = {**(data.get("dependencies") or {}), **(data.get("devDependencies") or {})}
for dep, label in (("next", "Next.js"), ("react", "React"), ("vue", "Vue"), ("svelte", "Svelte"),
("@angular/core", "Angular"), ("express", "Express"), ("typescript", "TypeScript")):
if dep in deps and label not in ctx.project_types:
ctx.project_types.append(label)
if "TypeScript" not in ctx.project_types and "Node.js" in ctx.project_types:
ctx.project_types.append("JavaScript")
scripts = data.get("scripts") or {}
if isinstance(scripts, dict):
ctx.scripts = {str(k): str(v) for k, v in scripts.items()}
if (root / "pnpm-lock.yaml").exists():
ctx.package_manager = "pnpm"
elif (root / "yarn.lock").exists():
ctx.package_manager = "yarn"
elif (root / "bun.lockb").exists() or (root / "bun.lock").exists():
ctx.package_manager = "bun"
else:
ctx.package_manager = "npm"
runner = ctx.package_manager
for name in ("test", "typecheck", "lint", "build"):
if name in ctx.scripts:
ctx.verification_hints.append(f"{runner} test" if name == "test" else f"{runner} run {name}")
def detect_project(root: Path, ignore_dirs: tuple[str, ...] = ()) -> ProjectContext:
root = root.resolve()
is_repo = git.is_git_repo(root)
ctx = ProjectContext(
root=root,
os_name=f"{platform.system()} {platform.release()}".strip(),
is_git_repo=is_repo,
branch=git.current_branch(root) if is_repo else None,
)
for marker, label in _MARKERS:
if (root / marker).exists() and label not in ctx.project_types:
ctx.project_types.append(label)
if "Dart" in ctx.project_types:
try:
if "flutter" in (root / "pubspec.yaml").read_text(encoding="utf-8"):
ctx.project_types.append("Flutter")
except OSError:
pass
ctx.key_files = [f for f in _INTERESTING_FILES if (root / f).exists()]
if "Node.js" in ctx.project_types:
_node_details(root, ctx)
if "Python" in ctx.project_types:
has_tests = (root / "tests").is_dir() or (root / "test").is_dir()
if has_tests or (root / "pytest.ini").exists():
ctx.verification_hints.append("pytest")
if "Rust" in ctx.project_types:
ctx.verification_hints += ["cargo build", "cargo test"]
if "Go" in ctx.project_types:
ctx.verification_hints += ["go build ./...", "go test ./..."]
if "Java" in ctx.project_types:
ctx.verification_hints.append("mvn test" if (root / "pom.xml").exists() else "./gradlew test")
if "Flutter" in ctx.project_types:
ctx.verification_hints += ["flutter analyze", "flutter test"]
if "C/C++" in ctx.project_types:
ctx.verification_hints.append("cmake --build build")
ignore = set(ignore_dirs)
try:
entries = sorted(root.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower()))
ctx.top_level = [e.name + ("/" if e.is_dir() else "") for e in entries if e.name not in ignore]
except OSError:
pass
ctx.instructions, ctx.instructions_path = load_instructions(root)
return ctx
def load_instructions(root: Path) -> tuple[str | None, str | None]:
"""Load YCODE.md project instructions, if present."""
for name in INSTRUCTION_FILES:
path = root / name
if path.is_file():
try:
text = path.read_text(encoding="utf-8", errors="replace").strip()
except OSError:
continue
if len(text) > MAX_INSTRUCTIONS_CHARS:
text = text[:MAX_INSTRUCTIONS_CHARS] + "\n\n[YCODE.md truncated]"
return (text or None), name
return None, None
|