devflow-toolkit 0.3.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.
Files changed (63) hide show
  1. devflow/__init__.py +0 -0
  2. devflow/adapters/__init__.py +47 -0
  3. devflow/adapters/agents.py +49 -0
  4. devflow/adapters/base.py +81 -0
  5. devflow/adapters/claude_code.py +120 -0
  6. devflow/adapters/codex.py +111 -0
  7. devflow/adapters/detector.py +118 -0
  8. devflow/adapters/instructions.py +263 -0
  9. devflow/adapters/kiro.py +211 -0
  10. devflow/adapters/opencode.py +237 -0
  11. devflow/adapters/registry.py +41 -0
  12. devflow/cli.py +39 -0
  13. devflow/commands/__init__.py +6 -0
  14. devflow/commands/approve.py +98 -0
  15. devflow/commands/harness.py +98 -0
  16. devflow/commands/init.py +544 -0
  17. devflow/commands/spec.py +514 -0
  18. devflow/commands/status.py +156 -0
  19. devflow/core/__init__.py +15 -0
  20. devflow/core/architecture.py +469 -0
  21. devflow/core/artifacts/__init__.py +29 -0
  22. devflow/core/artifacts/errors.py +19 -0
  23. devflow/core/artifacts/models.py +82 -0
  24. devflow/core/artifacts/store.py +130 -0
  25. devflow/core/clock.py +67 -0
  26. devflow/core/config.py +706 -0
  27. devflow/core/constitution.py +166 -0
  28. devflow/core/discovery.py +1304 -0
  29. devflow/core/ports.py +159 -0
  30. devflow/core/profile_manager.py +311 -0
  31. devflow/core/project.py +533 -0
  32. devflow/core/spec_workflow.py +932 -0
  33. devflow/core/specification.py +2443 -0
  34. devflow/core/state/__init__.py +75 -0
  35. devflow/core/state/errors.py +39 -0
  36. devflow/core/state/events.py +725 -0
  37. devflow/core/state/locator.py +116 -0
  38. devflow/core/state/lock.py +69 -0
  39. devflow/core/state/manager.py +960 -0
  40. devflow/core/state/models.py +164 -0
  41. devflow/core/state/transitions.py +114 -0
  42. devflow/models/__init__.py +6 -0
  43. devflow/models/profile.py +63 -0
  44. devflow/models/project.py +47 -0
  45. devflow/profiles/ai-rag/constitution/ai-rag-standards.md +49 -0
  46. devflow/profiles/ai-rag/profile.yaml +28 -0
  47. devflow/profiles/base/constitution/architecture.md +58 -0
  48. devflow/profiles/base/constitution/code-standards.md +85 -0
  49. devflow/profiles/base/constitution/constitution.md +85 -0
  50. devflow/profiles/base/profile.yaml +15 -0
  51. devflow/profiles/django-backend/constitution/django-standards.md +64 -0
  52. devflow/profiles/django-backend/profile.yaml +24 -0
  53. devflow/profiles/fastapi-backend/constitution/fastapi-standards.md +59 -0
  54. devflow/profiles/fastapi-backend/profile.yaml +23 -0
  55. devflow/prompts/__init__.py +200 -0
  56. devflow/prompts/discovery.md +119 -0
  57. devflow/prompts/spec.greenfield.md +149 -0
  58. devflow/prompts/spec.md +116 -0
  59. devflow_toolkit-0.3.0.dist-info/METADATA +628 -0
  60. devflow_toolkit-0.3.0.dist-info/RECORD +63 -0
  61. devflow_toolkit-0.3.0.dist-info/WHEEL +4 -0
  62. devflow_toolkit-0.3.0.dist-info/entry_points.txt +2 -0
  63. devflow_toolkit-0.3.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,533 @@
1
+ """Project inspection and technology detection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ import tomllib
9
+ from pathlib import Path
10
+ from typing import Any, Iterator
11
+
12
+ from devflow.models.project import Project
13
+
14
+ SKIP_DIRECTORIES = {
15
+ ".git",
16
+ ".venv",
17
+ "venv",
18
+ "env",
19
+ "node_modules",
20
+ "__pycache__",
21
+ ".mypy_cache",
22
+ ".pytest_cache",
23
+ ".ruff_cache",
24
+ ".tox",
25
+ ".nox",
26
+ "dist",
27
+ "build",
28
+ "coverage",
29
+ "htmlcov",
30
+ }
31
+
32
+ SOURCE_EXTENSIONS = {
33
+ ".py",
34
+ ".js",
35
+ ".jsx",
36
+ ".ts",
37
+ ".tsx",
38
+ ".vue",
39
+ ".svelte",
40
+ ".go",
41
+ ".rs",
42
+ ".php",
43
+ ".rb",
44
+ ".java",
45
+ ".kt",
46
+ }
47
+
48
+ FRAMEWORK_DEPENDENCIES = {
49
+ "django": "Django",
50
+ "djangorestframework": "Django REST Framework",
51
+ "fastapi": "FastAPI",
52
+ "flask": "Flask",
53
+ "starlette": "Starlette",
54
+ "express": "Express",
55
+ "nestjs": "NestJS",
56
+ "react": "React",
57
+ "vue": "Vue",
58
+ "svelte": "Svelte",
59
+ "next": "Next.js",
60
+ "langchain": "LangChain",
61
+ "llama-index": "LlamaIndex",
62
+ "chromadb": "ChromaDB",
63
+ "faiss-cpu": "FAISS",
64
+ "sentence-transformers": "Sentence Transformers",
65
+ "openai": "OpenAI",
66
+ "anthropic": "Anthropic",
67
+ }
68
+
69
+ AI_DEPENDENCIES = {
70
+ "langchain",
71
+ "llama-index",
72
+ "chromadb",
73
+ "faiss-cpu",
74
+ "sentence-transformers",
75
+ "pgvector",
76
+ "qdrant-client",
77
+ "weaviate-client",
78
+ "pinecone-client",
79
+ }
80
+
81
+ FRONTEND_FRAMEWORKS = {
82
+ "React",
83
+ "Vue",
84
+ "Svelte",
85
+ "Next.js",
86
+ }
87
+
88
+ BACKEND_FRAMEWORKS = {
89
+ "Django",
90
+ "Django REST Framework",
91
+ "FastAPI",
92
+ "Flask",
93
+ "Starlette",
94
+ "Express",
95
+ "NestJS",
96
+ }
97
+
98
+ DATABASE_DEPENDENCIES = {
99
+ "postgresql": {
100
+ "psycopg",
101
+ "psycopg2",
102
+ "asyncpg",
103
+ "pg8000",
104
+ },
105
+ "mysql": {
106
+ "mysqlclient",
107
+ "pymysql",
108
+ "aiomysql",
109
+ },
110
+ "mongodb": {
111
+ "pymongo",
112
+ "motor",
113
+ "beanie",
114
+ },
115
+ "sqlite": {
116
+ "aiosqlite",
117
+ },
118
+ }
119
+
120
+ _PROJECT_MARKERS = (
121
+ ".devflow/config.yaml",
122
+ "pyproject.toml",
123
+ "manage.py",
124
+ "package.json",
125
+ ".git",
126
+ "go.mod",
127
+ "Cargo.toml",
128
+ )
129
+
130
+
131
+ def canonicalize_dependency(name: str) -> str:
132
+ """Normalize a package name for comparisons."""
133
+ return re.sub(r"[-_.]+", "-", name.strip().lower())
134
+
135
+
136
+ def _dependency_name(requirement: str) -> str | None:
137
+ """Extract a package name from a requirements.txt entry."""
138
+ value = requirement.strip()
139
+ if not value or value.startswith("#"):
140
+ return None
141
+ if value.startswith("-"):
142
+ return None
143
+
144
+ value = value.split(";", maxsplit=1)[0].strip()
145
+ if not value:
146
+ return None
147
+
148
+ if " @ " in value:
149
+ value = value.split(" @ ", maxsplit=1)[0].strip()
150
+
151
+ match = re.match(r"([A-Za-z0-9][A-Za-z0-9._-]*)", value)
152
+ if not match:
153
+ return None
154
+ return canonicalize_dependency(match.group(1))
155
+
156
+
157
+ def _is_symlink_escape(path: Path, root: Path) -> bool:
158
+ """Return whether a path is a symlink or resolves outside the root."""
159
+ if path.is_symlink():
160
+ return True
161
+ try:
162
+ path.resolve().relative_to(root.resolve())
163
+ except ValueError:
164
+ return True
165
+ return False
166
+
167
+
168
+ def iter_project_files(
169
+ root: Path,
170
+ extensions: set[str] | None = None,
171
+ max_files: int = 5000,
172
+ ) -> Iterator[Path]:
173
+ """Yield project files while skipping generated and vendor directories."""
174
+ resolved_root = root.resolve()
175
+ count = 0
176
+ for dirpath, dirnames, filenames in os.walk(resolved_root):
177
+ dirnames[:] = sorted(
178
+ name
179
+ for name in dirnames
180
+ if name not in SKIP_DIRECTORIES
181
+ and not name.startswith(".")
182
+ and not (Path(dirpath) / name).is_symlink()
183
+ )
184
+
185
+ for filename in sorted(filenames):
186
+ if filename.startswith("."):
187
+ continue
188
+ path = Path(dirpath) / filename
189
+ if _is_symlink_escape(path, resolved_root):
190
+ continue
191
+ if extensions is not None and path.suffix not in extensions:
192
+ continue
193
+ yield path
194
+ count += 1
195
+ if count >= max_files:
196
+ return
197
+
198
+
199
+ class ProjectDetector:
200
+ """Inspect a project and detect its stack and applicable profiles."""
201
+
202
+ def find_root(self, start: Path) -> Path:
203
+ """Find the nearest likely project root from a starting path."""
204
+ current = start.resolve()
205
+ if current.is_file():
206
+ current = current.parent
207
+
208
+ for candidate in (current, *current.parents):
209
+ if any(
210
+ (candidate / marker).exists()
211
+ for marker in _PROJECT_MARKERS
212
+ ):
213
+ return candidate
214
+ return current
215
+
216
+ def detect(self, root: Path) -> Project:
217
+ """Inspect a project and return its detected context."""
218
+ resolved = root.resolve()
219
+ if not resolved.exists():
220
+ msg = f"Project path does not exist: {resolved}"
221
+ raise FileNotFoundError(msg)
222
+ if not resolved.is_dir():
223
+ msg = f"Project path is not a directory: {resolved}"
224
+ raise NotADirectoryError(msg)
225
+
226
+ pyproject, pyproject_name = self._read_pyproject(resolved)
227
+ package_json = self._read_package_json(resolved)
228
+
229
+ dependencies: set[str] = set()
230
+ manifests: list[str] = []
231
+
232
+ if pyproject is not None:
233
+ manifests.append("pyproject.toml")
234
+ dependencies.update(self._pyproject_dependencies(pyproject))
235
+
236
+ if package_json is not None:
237
+ manifests.append("package.json")
238
+ dependencies.update(self._package_dependencies(package_json))
239
+
240
+ requirements = self._requirements_files(resolved)
241
+ for path in requirements:
242
+ manifests.append(path.relative_to(resolved).as_posix())
243
+ dependencies.update(self._read_requirements(path))
244
+
245
+ source_files = [
246
+ path.relative_to(resolved).as_posix()
247
+ for path in iter_project_files(
248
+ resolved,
249
+ extensions=SOURCE_EXTENSIONS,
250
+ )
251
+ ]
252
+
253
+ languages = self._detect_languages(
254
+ resolved,
255
+ dependencies,
256
+ source_files,
257
+ )
258
+ frameworks = self._detect_frameworks(dependencies)
259
+ databases = self._detect_databases(dependencies)
260
+ project_type = self._detect_project_type(frameworks)
261
+
262
+ name = pyproject_name
263
+ if not name and package_json is not None:
264
+ value = package_json.get("name")
265
+ if isinstance(value, str) and value.strip():
266
+ name = value.strip()
267
+ if not name:
268
+ name = resolved.name
269
+
270
+ return Project(
271
+ root=resolved,
272
+ name=name,
273
+ project_type=project_type,
274
+ languages=languages,
275
+ frameworks=frameworks,
276
+ databases=databases,
277
+ dependencies=dependencies,
278
+ manifests=manifests,
279
+ source_files=source_files,
280
+ )
281
+
282
+ def _project_file(self, root: Path, name: str) -> Path | None:
283
+ """Return a contained project file, ignoring symlinks and escapes."""
284
+ path = root / name
285
+ if not path.is_file() or _is_symlink_escape(path, root):
286
+ return None
287
+ return path
288
+
289
+ def _read_pyproject(
290
+ self,
291
+ root: Path,
292
+ ) -> tuple[dict[str, Any] | None, str | None]:
293
+ path = self._project_file(root, "pyproject.toml")
294
+ if path is None:
295
+ return None, None
296
+
297
+ try:
298
+ with path.open("rb") as handle:
299
+ data = tomllib.load(handle)
300
+ except (OSError, tomllib.TOMLDecodeError):
301
+ return None, None
302
+
303
+ project = data.get("project")
304
+ name = None
305
+ if isinstance(project, dict):
306
+ value = project.get("name")
307
+ if isinstance(value, str) and value.strip():
308
+ name = value.strip()
309
+
310
+ return data, name
311
+
312
+ def _read_package_json(
313
+ self,
314
+ root: Path,
315
+ ) -> dict[str, Any] | None:
316
+ path = self._project_file(root, "package.json")
317
+ if path is None:
318
+ return None
319
+
320
+ try:
321
+ with path.open(encoding="utf-8") as handle:
322
+ data = json.load(handle)
323
+ except (OSError, json.JSONDecodeError):
324
+ return None
325
+ return data if isinstance(data, dict) else None
326
+
327
+ def _pyproject_dependencies(
328
+ self,
329
+ data: dict[str, Any],
330
+ ) -> set[str]:
331
+ dependencies: set[str] = set()
332
+ project = data.get("project")
333
+ if isinstance(project, dict):
334
+ dependencies.update(
335
+ self._parse_requirements(project.get("dependencies"))
336
+ )
337
+
338
+ optional = project.get("optional-dependencies")
339
+ if isinstance(optional, dict):
340
+ for values in optional.values():
341
+ dependencies.update(self._parse_requirements(values))
342
+
343
+ groups = data.get("dependency-groups")
344
+ if isinstance(groups, dict):
345
+ for values in groups.values():
346
+ dependencies.update(self._parse_requirements(values))
347
+
348
+ tool = data.get("tool")
349
+ poetry = tool.get("poetry") if isinstance(tool, dict) else None
350
+ if isinstance(poetry, dict):
351
+ poetry_dependencies = poetry.get("dependencies")
352
+ if isinstance(poetry_dependencies, dict):
353
+ for name in poetry_dependencies:
354
+ if name.lower() != "python":
355
+ dependencies.add(canonicalize_dependency(name))
356
+
357
+ poetry_groups = poetry.get("group")
358
+ if isinstance(poetry_groups, dict):
359
+ for group in poetry_groups.values():
360
+ if not isinstance(group, dict):
361
+ continue
362
+ values = group.get("dependencies")
363
+ if isinstance(values, dict):
364
+ for name in values:
365
+ dependencies.add(canonicalize_dependency(name))
366
+
367
+ return dependencies
368
+
369
+ def _package_dependencies(
370
+ self,
371
+ data: dict[str, Any],
372
+ ) -> set[str]:
373
+ dependencies: set[str] = set()
374
+ for section in (
375
+ "dependencies",
376
+ "devDependencies",
377
+ "peerDependencies",
378
+ "optionalDependencies",
379
+ ):
380
+ values = data.get(section)
381
+ if isinstance(values, dict):
382
+ for name in values:
383
+ dependencies.add(canonicalize_dependency(name))
384
+ return dependencies
385
+
386
+ def _requirements_files(self, root: Path) -> list[Path]:
387
+ candidates = list(root.glob("requirements*.txt"))
388
+ requirements_dir = root / "requirements"
389
+ if requirements_dir.is_dir() and not requirements_dir.is_symlink():
390
+ candidates.extend(requirements_dir.glob("*.txt"))
391
+
392
+ paths = {
393
+ path
394
+ for path in candidates
395
+ if path.is_file() and not _is_symlink_escape(path, root)
396
+ }
397
+ return sorted(paths)
398
+
399
+ def _read_requirements(self, path: Path) -> set[str]:
400
+ try:
401
+ lines = path.read_text(encoding="utf-8").splitlines()
402
+ except OSError:
403
+ return set()
404
+ return self._parse_requirements(lines)
405
+
406
+ def _parse_requirements(self, values: Any) -> set[str]:
407
+ if not isinstance(values, list):
408
+ return set()
409
+ dependencies = set()
410
+ for value in values:
411
+ if not isinstance(value, str):
412
+ continue
413
+ name = _dependency_name(value)
414
+ if name:
415
+ dependencies.add(name)
416
+ return dependencies
417
+
418
+ def _detect_languages(
419
+ self,
420
+ root: Path,
421
+ dependencies: set[str],
422
+ source_files: list[str],
423
+ ) -> list[str]:
424
+ languages: list[str] = []
425
+ suffixes = {Path(name).suffix for name in source_files}
426
+
427
+ has_python = (
428
+ ".py" in suffixes
429
+ or "python" in dependencies
430
+ or any(
431
+ self._project_file(root, marker) is not None
432
+ for marker in ("manage.py", "requirements.txt")
433
+ )
434
+ )
435
+ if has_python:
436
+ languages.append("python")
437
+
438
+ has_package_json = self._project_file(root, "package.json") is not None
439
+ has_typescript = (
440
+ ".ts" in suffixes
441
+ or ".tsx" in suffixes
442
+ or "typescript" in dependencies
443
+ )
444
+ if has_package_json or ".js" in suffixes or ".jsx" in suffixes:
445
+ languages.append("javascript")
446
+ if has_typescript:
447
+ languages.append("typescript")
448
+
449
+ if self._project_file(root, "go.mod") is not None or ".go" in suffixes:
450
+ languages.append("go")
451
+ if self._project_file(root, "Cargo.toml") is not None or ".rs" in suffixes:
452
+ languages.append("rust")
453
+ if (
454
+ self._project_file(root, "composer.json") is not None
455
+ or ".php" in suffixes
456
+ ):
457
+ languages.append("php")
458
+ if self._project_file(root, "Gemfile") is not None or ".rb" in suffixes:
459
+ languages.append("ruby")
460
+ if ".java" in suffixes:
461
+ languages.append("java")
462
+
463
+ return languages
464
+
465
+ def _detect_frameworks(
466
+ self,
467
+ dependencies: set[str],
468
+ ) -> list[str]:
469
+ frameworks: list[str] = []
470
+ for dependency, label in FRAMEWORK_DEPENDENCIES.items():
471
+ if self._has_dependency(dependencies, dependency):
472
+ frameworks.append(label)
473
+ return frameworks
474
+
475
+ def _detect_databases(
476
+ self,
477
+ dependencies: set[str],
478
+ ) -> list[str]:
479
+ databases: list[str] = []
480
+ for label, names in DATABASE_DEPENDENCIES.items():
481
+ if any(
482
+ self._has_dependency(dependencies, name)
483
+ for name in names
484
+ ):
485
+ databases.append(label)
486
+
487
+ if "chromadb" in dependencies:
488
+ if "vector-database" not in databases:
489
+ databases.append("vector-database")
490
+ return databases
491
+
492
+ def _detect_project_type(self, frameworks: list[str]) -> str:
493
+ frontend = any(
494
+ framework in FRONTEND_FRAMEWORKS
495
+ for framework in frameworks
496
+ )
497
+ backend = any(
498
+ framework in BACKEND_FRAMEWORKS
499
+ for framework in frameworks
500
+ )
501
+ ai = any(
502
+ framework
503
+ in {
504
+ "LangChain",
505
+ "LlamaIndex",
506
+ "ChromaDB",
507
+ "FAISS",
508
+ "Sentence Transformers",
509
+ }
510
+ for framework in frameworks
511
+ )
512
+
513
+ if ai:
514
+ return "ai"
515
+ if frontend and backend:
516
+ return "fullstack"
517
+ if backend:
518
+ return "backend"
519
+ if frontend:
520
+ return "frontend"
521
+ return "unknown"
522
+
523
+ def _has_dependency(
524
+ self,
525
+ dependencies: set[str],
526
+ name: str,
527
+ ) -> bool:
528
+ canonical = canonicalize_dependency(name)
529
+ return any(
530
+ dependency == canonical
531
+ or dependency.startswith(f"{canonical}-")
532
+ for dependency in dependencies
533
+ )