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