265 lines
7.7 KiB
Python
265 lines
7.7 KiB
Python
|
|
#!/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())
|
|||
|
|
|