#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Project stack probe (backend focus). Usage: python tools/stack_probe.py What it does: - Scans common manifest files (pyproject.toml, requirements*.txt, Pipfile, package.json, etc.) - Greps for framework imports/usages in source files (FastAPI/Flask/Django, Express/Nest, etc.) - Prints a short, actionable summary. """ from __future__ import annotations import json import os import re import sys from pathlib import Path from typing import Dict, List, Optional, Tuple ROOT = Path(__file__).resolve().parents[1] def _read_text(p: Path, max_bytes: int = 512_000) -> str: try: data = p.read_bytes() if len(data) > max_bytes: data = data[:max_bytes] return data.decode("utf-8", errors="ignore") except Exception: return "" def _exists(*parts: str) -> Optional[Path]: p = ROOT.joinpath(*parts) return p if p.exists() else None def _collect_manifests() -> List[Path]: candidates: List[Path] = [] for name in [ "pyproject.toml", "requirements.txt", "requirements-dev.txt", "requirements.in", "Pipfile", "poetry.lock", "setup.cfg", "package.json", "pnpm-lock.yaml", "yarn.lock", "package-lock.json", "composer.json", "go.mod", "Cargo.toml", ]: p = _exists(name) if p: candidates.append(p) return candidates def _load_package_json(p: Path) -> Dict: try: return json.loads(_read_text(p)) except Exception: return {} def _scan_deps_from_text(text: str) -> List[str]: """Extracts 'likely dependency tokens' from common python/lock formats.""" tokens = set() # requirements style for line in text.splitlines(): line = line.strip() if not line or line.startswith("#") or line.startswith("-"): continue # package==1.2.3, package>=, package[extra] m = re.match(r"^([A-Za-z0-9_.\-]+)", line) if m: tokens.add(m.group(1).lower()) return sorted(tokens) def _scan_source_signatures() -> Dict[str, List[Path]]: """ Grep a small number of files for telltale imports/usages. We keep it cheap: limit file count and size. """ patterns: Dict[str, re.Pattern] = { # Python "fastapi": re.compile(r"\bfrom\s+fastapi\b|\bFastAPI\s*\(", re.I), "flask": re.compile(r"\bfrom\s+flask\b|\bFlask\s*\(", re.I), "django": re.compile(r"\bdjango\b|\bmanage\.py\b", re.I), "starlette": re.compile(r"\bstarlette\b", re.I), "uvicorn": re.compile(r"\buvicorn\b", re.I), "gunicorn": re.compile(r"\bgunicorn\b", re.I), # Node "express": re.compile(r"\brequire\(['\"]express['\"]\)|\bfrom\s+['\"]express['\"]|\bexpress\(\)", re.I), "nestjs": re.compile(r"@nestjs/", re.I), "koa": re.compile(r"\bkoa\b", re.I), "hapi": re.compile(r"@hapi/|\bhapi\b", re.I), } hits: Dict[str, List[Path]] = {k: [] for k in patterns.keys()} # Pick likely source roots roots = [] for d in ["backend", "server", "api", "src", "app"]: p = _exists(d) if p and p.is_dir(): roots.append(p) if not roots: roots = [ROOT] exts = {".py", ".js", ".ts", ".mjs", ".cjs"} max_files = 1200 max_file_bytes = 256_000 scanned = 0 for base in roots: for p in base.rglob("*"): if scanned >= max_files: break if not p.is_file(): continue if p.suffix.lower() not in exts: continue # skip huge files / node_modules / venv if any(part in {"node_modules", ".venv", "venv", "__pycache__", "dist", "build"} for part in p.parts): continue try: if p.stat().st_size > max_file_bytes: continue except Exception: continue text = _read_text(p, max_bytes=max_file_bytes) scanned += 1 for key, pat in patterns.items(): if pat.search(text): hits[key].append(p.relative_to(ROOT)) # prune empties hits = {k: v for k, v in hits.items() if v} return hits def _infer_stack(manifests: List[Path], dep_tokens: List[str], pkg: Dict, sig_hits: Dict[str, List[Path]]) -> Tuple[str, List[str]]: notes: List[str] = [] # Node inference node_deps = set() if pkg: for section in ("dependencies", "devDependencies", "peerDependencies"): node_deps.update((pkg.get(section) or {}).keys()) node_deps = {d.lower() for d in node_deps} if node_deps: notes.append(f"Node deps found: {', '.join(sorted(list(node_deps))[:12])}" + (" …" if len(node_deps) > 12 else "")) # Python inference py_deps = set(dep_tokens) if py_deps: notes.append(f"Python deps found: {', '.join(sorted(list(py_deps))[:12])}" + (" …" if len(py_deps) > 12 else "")) # Priority: signatures > deps def has_sig(k: str) -> bool: return k in sig_hits # Decide primary backend framework if has_sig("fastapi") or "fastapi" in py_deps: stack = "Python / FastAPI" if "uvicorn" in py_deps or has_sig("uvicorn"): notes.append("Likely ASGI server: uvicorn") return stack, notes if has_sig("flask") or "flask" in py_deps: stack = "Python / Flask" return stack, notes if has_sig("django") or "django" in py_deps: stack = "Python / Django" return stack, notes if has_sig("nestjs") or any(d.startswith("@nestjs/") for d in node_deps): return "Node / NestJS", notes if has_sig("express") or "express" in node_deps: return "Node / Express", notes if has_sig("koa") or "koa" in node_deps: return "Node / Koa", notes if has_sig("hapi") or any(d.startswith("@hapi/") for d in node_deps): return "Node / hapi", notes # Fallback if pkg and not py_deps: return "Node (framework unknown)", notes if py_deps and not pkg: return "Python (framework unknown)", notes if pkg and py_deps: return "Mixed (Node + Python) – backend unclear", notes return "Unknown", notes def main() -> int: print("=== STACK PROBE ===") print(f"Root: {ROOT}") manifests = _collect_manifests() if manifests: print("\nManifests found:") for p in manifests: print(f" - {p.relative_to(ROOT)}") else: print("\nManifests found: (none)") # Parse deps dep_tokens: List[str] = [] pkg: Dict = {} py_sources: List[Path] = [] for p in manifests: if p.name == "package.json": pkg = _load_package_json(p) elif p.suffix in {".toml", ".cfg"} or p.name.startswith("requirements") or p.name in {"Pipfile"}: py_sources.append(p) for p in py_sources: dep_tokens.extend(_scan_deps_from_text(_read_text(p))) dep_tokens = sorted(set(dep_tokens)) sig_hits = _scan_source_signatures() stack, notes = _infer_stack(manifests, dep_tokens, pkg, sig_hits) print("\n--- Result ---") print(f"Likely backend stack: {stack}") if sig_hits: print("\nFramework signatures (files):") for k, files in sorted(sig_hits.items()): sample = ", ".join(str(f) for f in files[:5]) more = " …" if len(files) > 5 else "" print(f" - {k}: {sample}{more}") if notes: print("\nNotes:") for n in notes: print(f" - {n}") print("\nNext:") print(" - Paste the 'Likely backend stack' line back into chat.") return 0 if __name__ == "__main__": raise SystemExit(main())