forge-dev 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.
forge_core/detector.py ADDED
@@ -0,0 +1,209 @@
1
+ """Project Detector — understands the state of the current directory.
2
+
3
+ Detects whether we're in:
4
+ - An empty directory (greenfield)
5
+ - A directory with docs but no code
6
+ - A directory with existing code
7
+ - A directory already initialized with Forge
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+
14
+ from forge_core.models import ProjectState
15
+
16
+
17
+ # File patterns that indicate different project states
18
+ CODE_EXTENSIONS = {
19
+ ".py", ".js", ".ts", ".jsx", ".tsx", ".cs", ".vb", ".java",
20
+ ".go", ".rs", ".rb", ".php", ".swift", ".kt",
21
+ }
22
+
23
+ DOC_EXTENSIONS = {
24
+ ".md", ".txt", ".pdf", ".docx", ".doc", ".rtf",
25
+ }
26
+
27
+ CONFIG_FILES = {
28
+ "package.json", "pyproject.toml", "requirements.txt", "Cargo.toml",
29
+ "go.mod", "pom.xml", "build.gradle", "Gemfile", "composer.json",
30
+ "Makefile", "Dockerfile", "docker-compose.yml", "docker-compose.yaml",
31
+ }
32
+
33
+ IGNORE_DIRS = {
34
+ ".git", ".forge", "node_modules", "__pycache__", ".venv", "venv",
35
+ ".tox", ".mypy_cache", ".pytest_cache", "dist", "build", ".next",
36
+ }
37
+
38
+
39
+ class ProjectDetection:
40
+ """Result of detecting project state."""
41
+
42
+ def __init__(self, path: Path) -> None:
43
+ self.path = path.resolve()
44
+ self.state: ProjectState = ProjectState.EMPTY
45
+ self.has_forge: bool = False
46
+ self.has_git: bool = False
47
+ self.code_files: list[Path] = []
48
+ self.doc_files: list[Path] = []
49
+ self.config_files: list[Path] = []
50
+ self.detected_stack: dict[str, str] = {}
51
+ self.forge_context_path: Path | None = None
52
+
53
+ @property
54
+ def is_empty(self) -> bool:
55
+ return self.state == ProjectState.EMPTY
56
+
57
+ @property
58
+ def is_greenfield(self) -> bool:
59
+ return self.state in (ProjectState.EMPTY, ProjectState.HAS_DOCS)
60
+
61
+ @property
62
+ def has_existing_code(self) -> bool:
63
+ return self.state == ProjectState.HAS_CODE
64
+
65
+ def summary(self) -> str:
66
+ """Human-readable summary of detection."""
67
+ lines = [f"Project path: {self.path}"]
68
+ lines.append(f"State: {self.state.value}")
69
+
70
+ if self.has_forge:
71
+ lines.append("Forge: Already initialized")
72
+ if self.has_git:
73
+ lines.append("Git: Yes")
74
+ if self.code_files:
75
+ lines.append(f"Code files: {len(self.code_files)}")
76
+ if self.doc_files:
77
+ lines.append(f"Documents: {len(self.doc_files)}")
78
+ if self.config_files:
79
+ lines.append(f"Config files: {[f.name for f in self.config_files]}")
80
+ if self.detected_stack:
81
+ lines.append(f"Detected stack: {self.detected_stack}")
82
+
83
+ return "\n".join(lines)
84
+
85
+
86
+ def detect_project(path: Path | None = None) -> ProjectDetection:
87
+ """Detect the state of a project directory.
88
+
89
+ Args:
90
+ path: Directory to analyze. Defaults to current directory.
91
+
92
+ Returns:
93
+ ProjectDetection with all findings.
94
+ """
95
+ if path is None:
96
+ path = Path.cwd()
97
+ path = path.resolve()
98
+
99
+ detection = ProjectDetection(path)
100
+
101
+ # Check for .forge/
102
+ forge_dir = path / ".forge"
103
+ if forge_dir.exists() and (forge_dir / "context.yaml").exists():
104
+ detection.has_forge = True
105
+ detection.forge_context_path = forge_dir / "context.yaml"
106
+ detection.state = ProjectState.HAS_FORGE
107
+
108
+ # Check for .git/
109
+ detection.has_git = (path / ".git").exists()
110
+
111
+ # Scan files (max 2 levels deep for speed)
112
+ _scan_directory(path, detection, max_depth=3, current_depth=0)
113
+
114
+ # Determine state if not already set by .forge
115
+ if not detection.has_forge:
116
+ if detection.code_files or detection.config_files:
117
+ detection.state = ProjectState.HAS_CODE
118
+ elif detection.doc_files:
119
+ detection.state = ProjectState.HAS_DOCS
120
+ else:
121
+ detection.state = ProjectState.EMPTY
122
+
123
+ # Detect stack from config files
124
+ detection.detected_stack = _detect_stack(path, detection.config_files)
125
+
126
+ return detection
127
+
128
+
129
+ def _scan_directory(
130
+ path: Path, detection: ProjectDetection, max_depth: int, current_depth: int
131
+ ) -> None:
132
+ """Recursively scan directory for relevant files."""
133
+ if current_depth >= max_depth:
134
+ return
135
+
136
+ try:
137
+ entries = sorted(path.iterdir())
138
+ except PermissionError:
139
+ return
140
+
141
+ for entry in entries:
142
+ if entry.name.startswith(".") and entry.name not in (".env.example",):
143
+ continue
144
+ if entry.name in IGNORE_DIRS:
145
+ continue
146
+
147
+ if entry.is_file():
148
+ suffix = entry.suffix.lower()
149
+ if suffix in CODE_EXTENSIONS:
150
+ detection.code_files.append(entry)
151
+ elif suffix in DOC_EXTENSIONS:
152
+ detection.doc_files.append(entry)
153
+ if entry.name in CONFIG_FILES:
154
+ detection.config_files.append(entry)
155
+ elif entry.is_dir():
156
+ _scan_directory(entry, detection, max_depth, current_depth + 1)
157
+
158
+
159
+ def _detect_stack(path: Path, config_files: list[Path]) -> dict[str, str]:
160
+ """Detect the technology stack from config files."""
161
+ stack: dict[str, str] = {}
162
+ config_names = {f.name for f in config_files}
163
+
164
+ # Backend detection
165
+ if "pyproject.toml" in config_names or "requirements.txt" in config_names:
166
+ stack["language"] = "python"
167
+ # Try to detect framework
168
+ for cfg in config_files:
169
+ if cfg.name in ("pyproject.toml", "requirements.txt"):
170
+ try:
171
+ content = cfg.read_text(errors="ignore").lower()
172
+ if "fastapi" in content:
173
+ stack["backend"] = "python/fastapi"
174
+ elif "django" in content:
175
+ stack["backend"] = "python/django"
176
+ elif "flask" in content:
177
+ stack["backend"] = "python/flask"
178
+ except Exception:
179
+ pass
180
+
181
+ if "package.json" in config_names:
182
+ stack.setdefault("language", "node")
183
+ for cfg in config_files:
184
+ if cfg.name == "package.json":
185
+ try:
186
+ content = cfg.read_text(errors="ignore").lower()
187
+ if "express" in content:
188
+ stack["backend"] = "node/express"
189
+ elif "fastify" in content:
190
+ stack["backend"] = "node/fastify"
191
+ elif "nestjs" in content or "@nestjs" in content:
192
+ stack["backend"] = "node/nestjs"
193
+ # Frontend detection
194
+ if '"react"' in content or '"react-dom"' in content:
195
+ stack["frontend"] = "react"
196
+ elif '"next"' in content:
197
+ stack["frontend"] = "nextjs"
198
+ elif '"vue"' in content:
199
+ stack["frontend"] = "vue"
200
+ elif '"svelte"' in content:
201
+ stack["frontend"] = "svelte"
202
+ except Exception:
203
+ pass
204
+
205
+ # Docker detection
206
+ if "Dockerfile" in config_names or "docker-compose.yml" in config_names:
207
+ stack["containerized"] = "true"
208
+
209
+ return stack