Files
aza/AzA march 2026/tools/stack_probe.py
2026-03-25 22:03:39 +01:00

265 lines
7.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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())