generate-tech-stack-mcp 0.2.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.
@@ -0,0 +1,1090 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ generate-tech-stack — project analyzer
4
+ Scans a project directory, detects all tools/libraries, and writes TECH_STACK.html.
5
+
6
+ Usage:
7
+ python3 analyze.py [project_dir] [output_file]
8
+ """
9
+
10
+ import datetime
11
+ import json
12
+ import re
13
+ import sys
14
+ from pathlib import Path
15
+
16
+
17
+ # ── Category metadata ─────────────────────────────────────────────────────────
18
+
19
+ CATEGORY_META = {
20
+ "language": {"icon": "🐍", "label": "Language & Runtime", "color": "green"},
21
+ "web": {"icon": "🌐", "label": "Web / API Framework", "color": "purple"},
22
+ "database": {"icon": "🗄️", "label": "Database / Storage", "color": "green"},
23
+ "ai_sdk": {"icon": "🧠", "label": "AI Guardrail SDKs", "color": "blue"},
24
+ "nlp": {"icon": "📝", "label": "NLP / ML", "color": "teal"},
25
+ "testing": {"icon": "🧪", "label": "Testing", "color": "yellow"},
26
+ "observability": {"icon": "📊", "label": "Observability", "color": "teal"},
27
+ "security": {"icon": "🔐", "label": "Security / Auth", "color": "rose"},
28
+ "infra": {"icon": "🚀", "label": "Infrastructure / Deploy", "color": "orange"},
29
+ "frontend": {"icon": "🖥️", "label": "Frontend / Dashboard", "color": "gray"},
30
+ "messaging": {"icon": "📬", "label": "Messaging / Comms", "color": "blue"},
31
+ "devtools": {"icon": "🛠️", "label": "Dev Tools", "color": "gray"},
32
+ "other": {"icon": "📦", "label": "Other Libraries", "color": "gray"},
33
+ }
34
+
35
+ # icon_bg, dot_color, title_color
36
+ COLOR_CSS = {
37
+ "green": ("#14532d", "#16a34a", "#4ade80"),
38
+ "purple": ("#2e1065", "#7c3aed", "#a78bfa"),
39
+ "blue": ("#1e3a5f", "#2563eb", "#60a5fa"),
40
+ "yellow": ("#422006", "#d97706", "#fbbf24"),
41
+ "teal": ("#134e4a", "#0d9488", "#2dd4bf"),
42
+ "rose": ("#4c0519", "#e11d48", "#fb7185"),
43
+ "orange": ("#431407", "#ea580c", "#fb923c"),
44
+ "gray": ("#1e293b", "#6b7280", "#9ca3af"),
45
+ }
46
+
47
+ # badge bg, badge fg
48
+ BADGE_CSS = {
49
+ "pip": ("#1e293b", "#94a3b8"),
50
+ "dep": ("#14532d", "#86efac"),
51
+ "devDep": ("#1e1b4b", "#a5b4fc"),
52
+ "optional": ("#1e293b", "#64748b"),
53
+ "core": ("#14532d", "#86efac"),
54
+ "lang": ("#431407", "#fdba74"),
55
+ "deploy": ("#3b1f0f", "#fdba74"),
56
+ "prod": ("#1e293b", "#64748b"),
57
+ "ci": ("#1e1b4b", "#a5b4fc"),
58
+ "tool": ("#1e293b", "#94a3b8"),
59
+ }
60
+
61
+ # Proper display names for pip package names
62
+ DISPLAY_NAMES = {
63
+ "fastapi": "FastAPI",
64
+ "flask": "Flask",
65
+ "django": "Django",
66
+ "starlette": "Starlette",
67
+ "uvicorn": "Uvicorn",
68
+ "gunicorn": "Gunicorn",
69
+ "httpx": "httpx",
70
+ "aiohttp": "aiohttp",
71
+ "requests": "Requests",
72
+ "pydantic": "Pydantic",
73
+ "aiofiles": "aiofiles",
74
+ "tornado": "Tornado",
75
+ "sanic": "Sanic",
76
+ "sqlalchemy": "SQLAlchemy",
77
+ "alembic": "Alembic",
78
+ "psycopg2": "psycopg2",
79
+ "psycopg2-binary": "PostgreSQL (psycopg2)",
80
+ "psycopg": "psycopg3",
81
+ "pymysql": "PyMySQL",
82
+ "motor": "Motor",
83
+ "pymongo": "PyMongo",
84
+ "redis": "Redis",
85
+ "aioredis": "aioredis",
86
+ "elasticsearch": "Elasticsearch",
87
+ "tortoise-orm": "Tortoise ORM",
88
+ "peewee": "Peewee",
89
+ "databases": "databases",
90
+ "asyncpg": "asyncpg",
91
+ "guardrails-ai": "GuardrailsAI",
92
+ "guardrails_ai": "GuardrailsAI",
93
+ "nemoguardrails": "NVIDIA NeMo",
94
+ "presidio-analyzer": "Presidio Analyzer",
95
+ "presidio-anonymizer": "Presidio Anonymizer",
96
+ "openai": "OpenAI",
97
+ "anthropic": "Anthropic",
98
+ "transformers": "Transformers",
99
+ "torch": "PyTorch",
100
+ "tensorflow": "TensorFlow",
101
+ "keras": "Keras",
102
+ "langchain": "LangChain",
103
+ "langchain-core": "LangChain Core",
104
+ "langchain-community": "LangChain Community",
105
+ "llama-index": "LlamaIndex",
106
+ "llama_index": "LlamaIndex",
107
+ "spacy": "spaCy",
108
+ "nltk": "NLTK",
109
+ "sentence-transformers": "Sentence Transformers",
110
+ "scikit-learn": "scikit-learn",
111
+ "numpy": "NumPy",
112
+ "pandas": "Pandas",
113
+ "cohere": "Cohere",
114
+ "google-generativeai": "Google Gemini",
115
+ "mistralai": "Mistral AI",
116
+ "tiktoken": "tiktoken",
117
+ "chromadb": "ChromaDB",
118
+ "pinecone-client": "Pinecone",
119
+ "weaviate-client": "Weaviate",
120
+ "qdrant-client": "Qdrant",
121
+ "faiss-cpu": "FAISS",
122
+ "pytest": "pytest",
123
+ "pytest-asyncio": "pytest-asyncio",
124
+ "pytest-cov": "pytest-cov",
125
+ "pytest-mock": "pytest-mock",
126
+ "hypothesis": "Hypothesis",
127
+ "factory-boy": "factory-boy",
128
+ "faker": "Faker",
129
+ "locust": "Locust",
130
+ "coverage": "Coverage",
131
+ "prometheus-client": "Prometheus Client",
132
+ "python-json-logger": "python-json-logger",
133
+ "opentelemetry-api": "OpenTelemetry API",
134
+ "opentelemetry-sdk": "OpenTelemetry SDK",
135
+ "sentry-sdk": "Sentry",
136
+ "datadog": "Datadog",
137
+ "elastic-apm": "Elastic APM",
138
+ "loguru": "Loguru",
139
+ "cryptography": "Cryptography",
140
+ "pyjwt": "PyJWT",
141
+ "passlib": "Passlib",
142
+ "python-jose": "python-jose",
143
+ "bcrypt": "bcrypt",
144
+ "paramiko": "Paramiko",
145
+ "authlib": "Authlib",
146
+ "celery": "Celery",
147
+ "dramatiq": "Dramatiq",
148
+ "boto3": "Boto3 (AWS)",
149
+ "google-cloud-storage": "GCS",
150
+ "azure-storage-blob": "Azure Blob",
151
+ "kubernetes": "Kubernetes",
152
+ "pika": "Pika (RabbitMQ)",
153
+ "kafka-python": "kafka-python",
154
+ "aiokafka": "aiokafka",
155
+ "pyyaml": "PyYAML",
156
+ "toml": "TOML",
157
+ "click": "Click",
158
+ "typer": "Typer",
159
+ "rich": "Rich",
160
+ "jinja2": "Jinja2",
161
+ "python-dotenv": "python-dotenv",
162
+ "pillow": "Pillow",
163
+ "websockets": "websockets",
164
+ }
165
+
166
+
167
+ # ── Package maps ──────────────────────────────────────────────────────────────
168
+
169
+ PYTHON_MAP = {
170
+ # Web
171
+ "fastapi": ("web", "REST API framework"),
172
+ "flask": ("web", "Micro web framework"),
173
+ "django": ("web", "Full-stack web framework"),
174
+ "starlette": ("web", "ASGI toolkit"),
175
+ "uvicorn": ("web", "ASGI server"),
176
+ "gunicorn": ("web", "WSGI HTTP server"),
177
+ "httpx": ("web", "Async HTTP client"),
178
+ "aiohttp": ("web", "Async HTTP client/server"),
179
+ "requests": ("web", "HTTP client"),
180
+ "pydantic": ("web", "Data validation"),
181
+ "aiofiles": ("web", "Async file I/O"),
182
+ "tornado": ("web", "Non-blocking web framework"),
183
+ "sanic": ("web", "Async web framework"),
184
+ # Database
185
+ "sqlalchemy": ("database", "ORM / query builder"),
186
+ "alembic": ("database", "DB migrations"),
187
+ "psycopg2": ("database", "PostgreSQL adapter"),
188
+ "psycopg2-binary": ("database", "PostgreSQL adapter"),
189
+ "psycopg": ("database", "PostgreSQL adapter v3"),
190
+ "pymysql": ("database", "MySQL adapter"),
191
+ "motor": ("database", "Async MongoDB driver"),
192
+ "pymongo": ("database", "MongoDB driver"),
193
+ "redis": ("database", "Redis client"),
194
+ "aioredis": ("database", "Async Redis client"),
195
+ "elasticsearch": ("database", "Elasticsearch client"),
196
+ "tortoise-orm": ("database", "Async ORM"),
197
+ "peewee": ("database", "Simple ORM"),
198
+ "databases": ("database", "Async DB queries"),
199
+ "asyncpg": ("database", "Async PostgreSQL driver"),
200
+ # AI / ML guardrail SDKs
201
+ "guardrails-ai": ("ai_sdk", "Composable AI validators"),
202
+ "guardrails_ai": ("ai_sdk", "Composable AI validators"),
203
+ "nemoguardrails": ("ai_sdk", "Colang state machine"),
204
+ "presidio-analyzer": ("ai_sdk", "PII detection"),
205
+ "presidio-anonymizer": ("nlp", "PII redaction engine"),
206
+ "openai": ("ai_sdk", "OpenAI API client"),
207
+ "anthropic": ("ai_sdk", "Anthropic API client"),
208
+ "transformers": ("ai_sdk", "Hugging Face Transformers"),
209
+ "torch": ("ai_sdk", "PyTorch"),
210
+ "tensorflow": ("ai_sdk", "TensorFlow"),
211
+ "keras": ("ai_sdk", "Deep learning API"),
212
+ "langchain": ("ai_sdk", "LLM orchestration"),
213
+ "langchain-core": ("ai_sdk", "LangChain core"),
214
+ "langchain-community": ("ai_sdk", "LangChain integrations"),
215
+ "llama-index": ("ai_sdk", "LlamaIndex RAG"),
216
+ "llama_index": ("ai_sdk", "LlamaIndex RAG"),
217
+ "spacy": ("nlp", "NLP pipeline"),
218
+ "nltk": ("nlp", "Natural language toolkit"),
219
+ "sentence-transformers": ("nlp", "Sentence embeddings"),
220
+ "scikit-learn": ("ai_sdk", "Machine learning"),
221
+ "numpy": ("ai_sdk", "Numerical computing"),
222
+ "pandas": ("ai_sdk", "Data manipulation"),
223
+ "cohere": ("ai_sdk", "Cohere API client"),
224
+ "google-generativeai": ("ai_sdk", "Google Gemini client"),
225
+ "mistralai": ("ai_sdk", "Mistral API client"),
226
+ "tiktoken": ("ai_sdk", "OpenAI tokenizer"),
227
+ "chromadb": ("database", "Vector database"),
228
+ "pinecone-client": ("database", "Pinecone vector DB"),
229
+ "weaviate-client": ("database", "Weaviate vector DB"),
230
+ "qdrant-client": ("database", "Qdrant vector DB"),
231
+ "faiss-cpu": ("ai_sdk", "FAISS similarity search"),
232
+ # Testing
233
+ "pytest": ("testing", "Test runner"),
234
+ "pytest-asyncio": ("testing", "Async test support"),
235
+ "pytest-cov": ("testing", "Coverage reporting"),
236
+ "pytest-mock": ("testing", "Mock helpers"),
237
+ "hypothesis": ("testing", "Property-based testing"),
238
+ "factory-boy": ("testing", "Test fixtures"),
239
+ "faker": ("testing", "Fake data generator"),
240
+ "locust": ("testing", "Load testing"),
241
+ "coverage": ("testing", "Code coverage"),
242
+ # Observability
243
+ "prometheus-client": ("observability", "Prometheus metrics"),
244
+ "python-json-logger": ("observability", "Structured JSON logging"),
245
+ "opentelemetry-api": ("observability", "OpenTelemetry tracing"),
246
+ "opentelemetry-sdk": ("observability", "OpenTelemetry SDK"),
247
+ "sentry-sdk": ("observability", "Error tracking"),
248
+ "datadog": ("observability", "Datadog APM"),
249
+ "elastic-apm": ("observability", "Elastic APM"),
250
+ "loguru": ("observability", "Modern logging"),
251
+ # Security
252
+ "cryptography": ("security", "Cryptographic primitives"),
253
+ "pyjwt": ("security", "JSON Web Tokens"),
254
+ "passlib": ("security", "Password hashing"),
255
+ "python-jose": ("security", "JOSE / JWT"),
256
+ "bcrypt": ("security", "bcrypt hashing"),
257
+ "paramiko": ("security", "SSH client"),
258
+ "authlib": ("security", "OAuth / OIDC"),
259
+ # Infra / messaging
260
+ "celery": ("infra", "Distributed task queue"),
261
+ "dramatiq": ("infra", "Task queue"),
262
+ "boto3": ("infra", "AWS SDK"),
263
+ "google-cloud-storage": ("infra", "GCS client"),
264
+ "azure-storage-blob": ("infra", "Azure Blob Storage"),
265
+ "kubernetes": ("infra", "Kubernetes client"),
266
+ "pika": ("messaging", "RabbitMQ client"),
267
+ "kafka-python": ("messaging", "Kafka client"),
268
+ "aiokafka": ("messaging", "Async Kafka client"),
269
+ # Other
270
+ "pyyaml": ("other", "YAML parser"),
271
+ "toml": ("other", "TOML parser"),
272
+ "click": ("other", "CLI framework"),
273
+ "typer": ("other", "CLI framework"),
274
+ "rich": ("other", "Terminal formatting"),
275
+ "jinja2": ("other", "Template engine"),
276
+ "python-dotenv": ("other", "Env var loader"),
277
+ "pillow": ("other", "Image processing"),
278
+ "websockets": ("other", "WebSocket library"),
279
+ }
280
+
281
+ NODE_MAP = {
282
+ "express": ("web", "Web framework"),
283
+ "fastify": ("web", "Fast web framework"),
284
+ "koa": ("web", "Middleware framework"),
285
+ "next": ("frontend", "React framework"),
286
+ "nuxt": ("frontend", "Vue framework"),
287
+ "remix": ("frontend", "Full-stack React"),
288
+ "axios": ("web", "HTTP client"),
289
+ "node-fetch": ("web", "Fetch API for Node"),
290
+ "react": ("frontend", "UI component library"),
291
+ "react-dom": ("frontend", "React DOM renderer"),
292
+ "react-scripts": ("frontend", "CRA build toolchain"),
293
+ "vue": ("frontend", "Progressive UI framework"),
294
+ "svelte": ("frontend", "Compiled UI framework"),
295
+ "vite": ("devtools", "Build tool"),
296
+ "webpack": ("devtools", "Module bundler"),
297
+ "tailwindcss": ("frontend", "Utility CSS framework"),
298
+ "recharts": ("frontend", "Chart components"),
299
+ "chart.js": ("frontend", "Canvas charts"),
300
+ "d3": ("frontend", "Data visualisation"),
301
+ "lucide-react": ("frontend", "Icon library"),
302
+ "framer-motion": ("frontend", "Animation library"),
303
+ "prisma": ("database", "Type-safe ORM"),
304
+ "mongoose": ("database", "MongoDB ODM"),
305
+ "pg": ("database", "PostgreSQL client"),
306
+ "mysql2": ("database", "MySQL client"),
307
+ "redis": ("database", "Redis client"),
308
+ "ioredis": ("database", "Redis client"),
309
+ "sequelize": ("database", "Multi-dialect ORM"),
310
+ "knex": ("database", "Query builder"),
311
+ "typeorm": ("database", "TypeScript ORM"),
312
+ "drizzle-orm": ("database", "TypeScript ORM"),
313
+ "jest": ("testing", "Test runner"),
314
+ "vitest": ("testing", "Vite-native test runner"),
315
+ "mocha": ("testing", "Test framework"),
316
+ "chai": ("testing", "Assertion library"),
317
+ "cypress": ("testing", "E2E testing"),
318
+ "playwright": ("testing", "Browser automation"),
319
+ "supertest": ("testing", "HTTP assertion"),
320
+ "openai": ("ai_sdk", "OpenAI API client"),
321
+ "@anthropic-ai/sdk": ("ai_sdk", "Anthropic API client"),
322
+ "langchain": ("ai_sdk", "LLM orchestration"),
323
+ "@langchain/core": ("ai_sdk", "LangChain core"),
324
+ "jsonwebtoken": ("security", "JSON Web Tokens"),
325
+ "bcrypt": ("security", "Password hashing"),
326
+ "passport": ("security", "Auth middleware"),
327
+ "helmet": ("security", "HTTP security headers"),
328
+ "express-rate-limit": ("security", "Rate limiting"),
329
+ "pino": ("observability", "Structured logger"),
330
+ "winston": ("observability", "Logging library"),
331
+ "morgan": ("observability", "HTTP request logger"),
332
+ "bull": ("infra", "Job queue"),
333
+ "bullmq": ("infra", "Job queue"),
334
+ "socket.io": ("messaging", "WebSocket server"),
335
+ "ws": ("messaging", "WebSocket library"),
336
+ "aws-sdk": ("infra", "AWS SDK"),
337
+ "@aws-sdk/client-s3": ("infra", "AWS S3 client"),
338
+ "dotenv": ("other", "Env var loader"),
339
+ "zod": ("other", "Schema validation"),
340
+ "typescript": ("devtools", "TypeScript compiler"),
341
+ "eslint": ("devtools", "Linter"),
342
+ "prettier": ("devtools", "Code formatter"),
343
+ "esbuild": ("devtools", "Fast bundler"),
344
+ "turbo": ("devtools", "Monorepo build tool"),
345
+ }
346
+
347
+ # Packages detected via importlib.find_spec() or import_module() in source files
348
+ FIND_SPEC_MAP = {
349
+ "guardrails": ("ai_sdk", "GuardrailsAI", "Composable AI validators"),
350
+ "nemoguardrails": ("ai_sdk", "NVIDIA NeMo", "Colang state machine"),
351
+ "presidio_analyzer": ("ai_sdk", "Presidio Analyzer", "PII detection"),
352
+ "presidio": ("ai_sdk", "Presidio Analyzer", "PII analysis"),
353
+ "spacy": ("nlp", "spaCy", "NLP pipeline"),
354
+ "transformers": ("ai_sdk", "Transformers", "Hugging Face Transformers"),
355
+ "torch": ("ai_sdk", "PyTorch", "Deep learning"),
356
+ "openai": ("ai_sdk", "OpenAI", "OpenAI API client"),
357
+ "anthropic": ("ai_sdk", "Anthropic", "Anthropic API client"),
358
+ "langchain": ("ai_sdk", "LangChain", "LLM orchestration"),
359
+ }
360
+
361
+
362
+ # ── Scanners ──────────────────────────────────────────────────────────────────
363
+
364
+ # Directories that contain third-party or generated code, never the project's own
365
+ # stack. Scanning them would report every installed dependency's dependencies
366
+ # (e.g. a local venv or vendored site-packages would add dozens of false tools).
367
+ EXCLUDED_DIRS = frozenset({
368
+ ".git", ".hg", ".svn",
369
+ "node_modules", "bower_components",
370
+ "site-packages", "dist-packages",
371
+ "venv", ".venv", "env", "virtualenv",
372
+ "__pycache__", ".mypy_cache", ".pytest_cache", ".ruff_cache", ".tox", ".nox",
373
+ "dist", "build", "target", ".next", ".nuxt", ".output",
374
+ "vendor", "third_party", ".terraform",
375
+ "htmlcov", "coverage", ".cache",
376
+ })
377
+
378
+
379
+ def _iter_files(root: Path, pattern: str):
380
+ """rglob that skips vendored/venv/build directories."""
381
+ for p in root.rglob(pattern):
382
+ parents = p.relative_to(root).parts[:-1]
383
+ if any(d in EXCLUDED_DIRS or d.endswith(".egg-info") for d in parents):
384
+ continue
385
+ yield p
386
+
387
+
388
+ def _normalize(name: str) -> str:
389
+ return name.lower().replace("-", "").replace("_", "").replace(" ", "")
390
+
391
+
392
+ def _add_tool(tools: dict, cat: str, name: str, desc: str, badge: str) -> None:
393
+ """Add a tool to the category, skipping if a normalized-equivalent entry exists."""
394
+ tools.setdefault(cat, [])
395
+ norm = _normalize(name)
396
+ if any(_normalize(t["name"]) == norm for t in tools[cat]):
397
+ return
398
+ tools[cat].append({"name": name, "desc": desc, "badge": badge})
399
+
400
+
401
+ def scan_python(root: Path) -> dict:
402
+ tools: dict = {}
403
+ # Root manifests + one level of subdirectories (monorepos: backend/, api/, …)
404
+ req_candidates = [
405
+ root / f for f in ["requirements.txt", "requirements-dev.txt", "requirements/base.txt"]
406
+ ] + [p for p in root.glob("*/requirements.txt") if p.parent.name not in EXCLUDED_DIRS]
407
+ for fpath in req_candidates:
408
+ if not fpath.exists():
409
+ continue
410
+ for line in fpath.read_text().splitlines():
411
+ line = line.strip()
412
+ if not line:
413
+ continue
414
+
415
+ if line.startswith("#"):
416
+ # Detect commented-out package lines: # package>=version
417
+ m = re.match(r'^#\s*([\w][\w\-\.]*)\s*[>=<!\[]', line)
418
+ if m:
419
+ pkg = m.group(1).lower()
420
+ if pkg in PYTHON_MAP:
421
+ cat, desc = PYTHON_MAP[pkg]
422
+ display = DISPLAY_NAMES.get(pkg, pkg)
423
+ _add_tool(tools, cat, display, desc, "optional")
424
+ # Detect PostgreSQL from psycopg2 mentioned in comments
425
+ if "psycopg2" in line.lower() and not any(
426
+ _normalize(t["name"]) == "postgresql" for t in tools.get("database", [])
427
+ ):
428
+ _add_tool(tools, "database", "PostgreSQL", "Production database", "optional")
429
+ continue
430
+
431
+ pkg = re.split(r"[>=<!;\[#]", line)[0].strip().lower()
432
+ if pkg in PYTHON_MAP:
433
+ cat, desc = PYTHON_MAP[pkg]
434
+ display = DISPLAY_NAMES.get(pkg, pkg)
435
+ _add_tool(tools, cat, display, desc, "pip")
436
+
437
+ pp_candidates = [root / "pyproject.toml"] + [
438
+ p for p in root.glob("*/pyproject.toml") if p.parent.name not in EXCLUDED_DIRS
439
+ ]
440
+ for pp in pp_candidates:
441
+ if not pp.exists():
442
+ continue
443
+ # Capture the package name at the start of each quoted string so pinned
444
+ # entries like "spacy>=3.0.0" or "guardrails-ai[extra]>=0.5" also match.
445
+ for pkg in re.findall(r'["\']([A-Za-z0-9][A-Za-z0-9._-]*)', pp.read_text()):
446
+ pkg = pkg.lower()
447
+ if pkg in PYTHON_MAP:
448
+ cat, desc = PYTHON_MAP[pkg]
449
+ display = DISPLAY_NAMES.get(pkg, pkg)
450
+ _add_tool(tools, cat, display, desc, "pip")
451
+ return tools
452
+
453
+
454
+ def scan_node(root: Path) -> dict:
455
+ tools: dict = {}
456
+ # Scan root package.json + one level of subdirectories (monorepos / frontend dirs)
457
+ pj_candidates = [root / "package.json"] + [
458
+ p for p in root.glob("*/package.json") if p.parent.name not in EXCLUDED_DIRS
459
+ ]
460
+ for pj in pj_candidates:
461
+ if not pj.exists():
462
+ continue
463
+ try:
464
+ data = json.loads(pj.read_text())
465
+ except Exception:
466
+ continue
467
+ all_deps: dict = {**data.get("dependencies", {}), **data.get("devDependencies", {})}
468
+ for pkg in all_deps:
469
+ key = pkg.lower()
470
+ if key in NODE_MAP:
471
+ cat, desc = NODE_MAP[key]
472
+ badge = "devDep" if pkg in data.get("devDependencies", {}) else "dep"
473
+ _add_tool(tools, cat, pkg, desc, badge)
474
+ return tools
475
+
476
+
477
+ def scan_python_source(root: Path) -> dict:
478
+ """Detect optional/dynamic SDKs by scanning Python source for find_spec() calls and backend enums."""
479
+ tools: dict = {}
480
+
481
+ find_spec_re = re.compile(r'find_spec\(["\'](\w+)["\']')
482
+ import_mod_re = re.compile(r'import_module\(["\'](\w+)["\']')
483
+ lakera_re = re.compile(r'lakera\.ai|["\']lakera["\']|LAKERA\s*=')
484
+ ga_guard_re = re.compile(r'GA_GUARD|["\']ga_guard["\']')
485
+ presidio_anon_re = re.compile(r'presidio_anonymizer|presidio-anonymizer')
486
+
487
+ seen_mods: set = set()
488
+ has_lakera = False
489
+ has_ga_guard = False
490
+ has_pres_anon = False
491
+
492
+ for py_file in _iter_files(root, "*.py"):
493
+ try:
494
+ content = py_file.read_text(errors="ignore")
495
+ except Exception:
496
+ continue
497
+ for m in find_spec_re.finditer(content):
498
+ seen_mods.add(m.group(1))
499
+ for m in import_mod_re.finditer(content):
500
+ seen_mods.add(m.group(1))
501
+ if lakera_re.search(content):
502
+ has_lakera = True
503
+ if ga_guard_re.search(content):
504
+ has_ga_guard = True
505
+ if presidio_anon_re.search(content):
506
+ has_pres_anon = True
507
+
508
+ for mod in seen_mods:
509
+ if mod in FIND_SPEC_MAP:
510
+ cat, name, desc = FIND_SPEC_MAP[mod]
511
+ _add_tool(tools, cat, name, desc, "optional")
512
+
513
+ if has_lakera:
514
+ _add_tool(tools, "ai_sdk", "Lakera Guard", "Cloud REST prompt-injection API", "optional")
515
+ if has_ga_guard:
516
+ _add_tool(tools, "ai_sdk", "GA Guard", "Adversarial content detection", "optional")
517
+ if has_pres_anon:
518
+ # Only add if not already captured from commented requirements in any category
519
+ already = any(
520
+ _normalize(t["name"]) == "presidioanonymizer"
521
+ for items in tools.values() for t in items
522
+ )
523
+ if not already:
524
+ _add_tool(tools, "nlp", "Presidio Anonymizer", "PII redaction engine", "optional")
525
+
526
+ return tools
527
+
528
+
529
+ def detect_languages(root: Path) -> list:
530
+ langs = []
531
+ seen: set = set()
532
+ checks = [
533
+ ("requirements.txt", "Python", "Primary language"),
534
+ ("pyproject.toml", "Python", "Primary language"),
535
+ ("package.json", "JavaScript / TypeScript", "Primary language"),
536
+ ("go.mod", "Go", "Primary language"),
537
+ ("Cargo.toml", "Rust", "Primary language"),
538
+ ("pom.xml", "Java (Maven)", "Primary language"),
539
+ ("build.gradle", "Java / Kotlin", "Primary language"),
540
+ ("composer.json", "PHP", "Primary language"),
541
+ ("Gemfile", "Ruby", "Primary language"),
542
+ ]
543
+ for fname, lang, desc in checks:
544
+ if (root / fname).exists() and lang not in seen:
545
+ seen.add(lang)
546
+ langs.append({"name": lang, "desc": desc, "badge": "lang"})
547
+ if lang == "Python":
548
+ langs.append({"name": "pip / venv", "desc": "Package management", "badge": "lang"})
549
+ if (root / "tsconfig.json").exists() or list(root.glob("src/**/*.ts")):
550
+ if "TypeScript" not in seen:
551
+ langs.append({"name": "TypeScript", "desc": "Type-safe JS", "badge": "lang"})
552
+ return langs
553
+
554
+
555
+ def detect_databases_from_files(root: Path) -> list:
556
+ """Detect database systems from docker-compose, env files, and deployment configs."""
557
+ found: list = []
558
+ seen: set = set()
559
+
560
+ def add(name, desc, badge):
561
+ norm = _normalize(name)
562
+ if norm not in seen:
563
+ seen.add(norm)
564
+ found.append({"name": name, "desc": desc, "badge": badge})
565
+
566
+ config_files = (
567
+ ["docker-compose.yml", "docker-compose.yaml", ".env", ".env.example", ".env.sample"]
568
+ + [f.name for f in root.glob("*.env")]
569
+ )
570
+ for fname in config_files:
571
+ fpath = root / fname
572
+ if not fpath.exists():
573
+ continue
574
+ try:
575
+ content = fpath.read_text(errors="ignore").lower()
576
+ except Exception:
577
+ continue
578
+ if "sqlite://" in content:
579
+ add("SQLite", "Embedded / dev database", "core")
580
+ if any(x in content for x in ["postgres://", "postgresql://", "image: postgres", "image: postgresql"]):
581
+ add("PostgreSQL", "Production database", "prod")
582
+ if any(x in content for x in ["redis://", "image: redis"]):
583
+ add("Redis", "Cache / distributed rate limiting", "optional")
584
+ if "mongodb://" in content or "image: mongo" in content:
585
+ add("MongoDB", "Document store", "optional")
586
+ if "mysql://" in content or "image: mysql" in content:
587
+ add("MySQL", "Relational database", "optional")
588
+
589
+ return found
590
+
591
+
592
+ def detect_infra(root: Path) -> list:
593
+ infra = []
594
+ if (root / "Dockerfile").exists():
595
+ infra.append({"name": "Docker", "desc": "Container image", "badge": "deploy"})
596
+ for name in ["docker-compose.yml", "docker-compose.yaml"]:
597
+ if (root / name).exists():
598
+ infra.append({"name": "Docker Compose", "desc": "Multi-service stack", "badge": "deploy"})
599
+ break
600
+ yamls = [f.name for f in _iter_files(root, "*.yaml")] + [f.name for f in _iter_files(root, "*.yml")]
601
+ if any("k8s" in y or "kubernetes" in y or "deployment" in y for y in yamls):
602
+ infra.append({"name": "Kubernetes", "desc": "Container orchestration", "badge": "prod"})
603
+ if (root / ".github" / "workflows").exists():
604
+ infra.append({"name": "GitHub Actions", "desc": "CI/CD pipeline", "badge": "ci"})
605
+ if (root / ".gitlab-ci.yml").exists():
606
+ infra.append({"name": "GitLab CI", "desc": "CI/CD pipeline", "badge": "ci"})
607
+ if (root / "nginx.conf").exists() or (root / "nginx").is_dir():
608
+ infra.append({"name": "nginx", "desc": "Reverse proxy / TLS", "badge": "prod"})
609
+ if (root / "Caddyfile").exists():
610
+ infra.append({"name": "Caddy", "desc": "Automatic HTTPS proxy", "badge": "prod"})
611
+ if (root / "alembic.ini").exists():
612
+ infra.append({"name": "Alembic Migrations", "desc": "DB schema version control", "badge": "tool"})
613
+ return infra
614
+
615
+
616
+ def collect(root: Path) -> dict:
617
+ tools: dict = {}
618
+
619
+ langs = detect_languages(root)
620
+ if langs:
621
+ tools["language"] = langs
622
+
623
+ # Requirements / package.json
624
+ for cat, items in scan_python(root).items():
625
+ tools.setdefault(cat, []).extend(items)
626
+ for cat, items in scan_node(root).items():
627
+ tools.setdefault(cat, []).extend(items)
628
+
629
+ # Source-based detection (runs after requirements so it can fill gaps)
630
+ for cat, items in scan_python_source(root).items():
631
+ for item in items:
632
+ _add_tool(tools, cat, item["name"], item["desc"], item["badge"])
633
+
634
+ # Database systems from deployment config
635
+ db_tools = detect_databases_from_files(root)
636
+ for t in db_tools:
637
+ _add_tool(tools, "database", t["name"], t["desc"], t["badge"])
638
+
639
+ # Infra
640
+ infra = detect_infra(root)
641
+ if infra:
642
+ tools.setdefault("infra", []).extend(infra)
643
+
644
+ # Final dedup within each category by normalized name
645
+ for cat in tools:
646
+ seen: set = set()
647
+ uniq = []
648
+ for t in tools[cat]:
649
+ key = _normalize(t["name"])
650
+ if key not in seen:
651
+ seen.add(key)
652
+ uniq.append(t)
653
+ tools[cat] = uniq
654
+
655
+ return tools
656
+
657
+
658
+ # ── Architecture diagram ──────────────────────────────────────────────────────
659
+
660
+ def build_arch_html(tools: dict) -> str:
661
+ web_items = [t["name"] for t in tools.get("web", [])[:4]]
662
+ ai_items = [t["name"] for t in tools.get("ai_sdk", [])[:6]]
663
+ nlp_items = [t["name"] for t in tools.get("nlp", [])[:3]]
664
+ db_items = [t["name"] for t in tools.get("database", [])[:3]]
665
+ obs_items = [t["name"] for t in tools.get("observability", [])[:2]]
666
+ fe_items = [t["name"] for t in tools.get("frontend", [])[:2]]
667
+ has_sec = bool(tools.get("security"))
668
+
669
+ S = 'style="font-size:.58rem;font-weight:700;text-transform:uppercase;letter-spacing:.12em;color:#334155;text-align:center;padding:2px 0;margin-bottom:6px"'
670
+
671
+ def lbl(txt):
672
+ return f'<div {S}>{txt}</div>'
673
+
674
+ def arrow():
675
+ return '<div style="color:#1e3a5f;font-size:1.3rem;text-align:center;padding:4px 0;line-height:1">&#8595;</div>'
676
+
677
+ rows = []
678
+
679
+ # Layer 1 — Consumer
680
+ app_label = "Your LLM Application" if (ai_items or nlp_items) else "Your Application"
681
+ app_sub = "Chatbot / Agent / RAG Pipeline" if (ai_items or nlp_items) else "Client / Consumer"
682
+ rows.append(lbl("CONSUMER"))
683
+ rows.append(
684
+ '<div class="arch-row">'
685
+ '<div class="arch-box" style="background:#1e1b4b;border-color:#4338ca;color:#a5b4fc;min-width:360px">'
686
+ f'{app_label}'
687
+ f'<div style="font-size:.65rem;opacity:.6;margin-top:4px;font-weight:400">{app_sub}</div>'
688
+ '</div></div>'
689
+ )
690
+ rows.append(arrow())
691
+
692
+ # Layer 2 — API / Middleware
693
+ if web_items:
694
+ label = " &nbsp;&middot;&nbsp; ".join(web_items)
695
+ sub_parts: list = []
696
+ if has_sec:
697
+ sub_parts.append("API Key Auth")
698
+ sub_parts += ["Rate Limiting", "CORS Middleware"]
699
+ rows.append(lbl("API / MIDDLEWARE"))
700
+ rows.append(
701
+ '<div class="arch-row">'
702
+ '<div class="arch-box" style="background:#14532d;border-color:#16a34a;color:#86efac;min-width:360px">'
703
+ f'{label}'
704
+ f'<div style="font-size:.65rem;opacity:.6;margin-top:4px;font-weight:400">'
705
+ f'{" &nbsp;&middot;&nbsp; ".join(sub_parts)}</div>'
706
+ '</div></div>'
707
+ )
708
+ rows.append(arrow())
709
+
710
+ # Layer 3 — AI Guardrails + NLP
711
+ all_ai = ai_items + nlp_items
712
+ if all_ai:
713
+ chips = "".join(
714
+ f'<span style="background:#162744;border:1px solid rgba(37,99,235,.4);border-radius:6px;'
715
+ f'padding:4px 11px;font-size:.72rem;font-weight:600;color:#93c5fd">{n}</span>'
716
+ for n in all_ai
717
+ )
718
+ rows.append(lbl("AI GUARDRAILS &amp; NLP"))
719
+ rows.append(
720
+ '<div class="arch-row">'
721
+ '<div class="arch-box" style="background:#1a2d4a;border-color:#2563eb;color:#60a5fa;'
722
+ 'min-width:360px;max-width:560px;width:100%">'
723
+ f'<div style="display:flex;flex-wrap:wrap;gap:6px;justify-content:center;margin-bottom:8px">{chips}</div>'
724
+ '<div style="font-size:.6rem;color:#475569">'
725
+ 'Optional &mdash; loaded at runtime via importlib.find_spec()</div>'
726
+ '</div></div>'
727
+ )
728
+ rows.append(arrow())
729
+
730
+ # Layer 4 — Data · Observability · Frontend
731
+ bottom: list = []
732
+ if db_items:
733
+ inner = "".join(
734
+ f'<div style="font-size:.72rem;font-weight:500;padding:2px 0">{n}</div>'
735
+ for n in db_items
736
+ )
737
+ bottom.append(
738
+ '<div class="arch-box" style="background:#1c1917;border-color:#78350f;'
739
+ 'color:#fbbf24;flex:1;min-width:130px">'
740
+ '<div style="font-size:.58rem;text-transform:uppercase;letter-spacing:.1em;'
741
+ 'opacity:.5;margin-bottom:6px">Storage</div>'
742
+ + inner + '</div>'
743
+ )
744
+ if obs_items:
745
+ inner = "".join(
746
+ f'<div style="font-size:.72rem;font-weight:500;padding:2px 0">{n}</div>'
747
+ for n in obs_items
748
+ )
749
+ bottom.append(
750
+ '<div class="arch-box" style="background:#134e4a;border-color:#0d9488;'
751
+ 'color:#5eead4;flex:1;min-width:130px">'
752
+ '<div style="font-size:.58rem;text-transform:uppercase;letter-spacing:.1em;'
753
+ 'opacity:.5;margin-bottom:6px">Observability</div>'
754
+ + inner + '</div>'
755
+ )
756
+ if fe_items:
757
+ inner = "".join(
758
+ f'<div style="font-size:.72rem;font-weight:500;padding:2px 0">{n}</div>'
759
+ for n in fe_items
760
+ )
761
+ bottom.append(
762
+ '<div class="arch-box" style="background:#1e293b;border-color:#475569;'
763
+ 'color:#94a3b8;flex:1;min-width:130px">'
764
+ '<div style="font-size:.58rem;text-transform:uppercase;letter-spacing:.1em;'
765
+ 'opacity:.5;margin-bottom:6px">Dashboard</div>'
766
+ + inner + '</div>'
767
+ )
768
+ if bottom:
769
+ rows.append(lbl("DATA &nbsp;&middot;&nbsp; OBSERVABILITY &nbsp;&middot;&nbsp; FRONTEND"))
770
+ rows.append(
771
+ f'<div class="arch-row" style="align-items:stretch;gap:12px">{"".join(bottom)}</div>'
772
+ )
773
+
774
+ return "\n ".join(rows)
775
+
776
+
777
+ # ── HTML renderer ─────────────────────────────────────────────────────────────
778
+
779
+ def render_html(tools: dict, project_name: str) -> str:
780
+ total = sum(len(v) for v in tools.values())
781
+ cat_count = len(tools)
782
+ max_count = max((len(v) for v in tools.values()), default=1)
783
+ ai_count = len(tools.get("ai_sdk", []))
784
+ db_count = len(tools.get("database", []))
785
+ today = datetime.date.today().strftime("%B %d, %Y")
786
+
787
+ arch_html = build_arch_html(tools)
788
+
789
+ # ── Bar chart ──
790
+ bars_html = ""
791
+ for cat, items in tools.items():
792
+ if not items:
793
+ continue
794
+ meta = CATEGORY_META.get(cat, CATEGORY_META["other"])
795
+ _, dot_color, _ = COLOR_CSS.get(meta["color"], COLOR_CSS["gray"])
796
+ pct = round(len(items) / max_count * 100)
797
+ bars_html += (
798
+ '<div class="bar-row">'
799
+ f'<div class="bar-lbl"><span class="bar-icon">{meta["icon"]}</span>{meta["label"]}</div>'
800
+ f'<div class="bar-outer"><div class="bar-inner" style="width:{pct}%;background:{dot_color}"></div></div>'
801
+ f'<span class="bar-num" style="color:{dot_color}">{len(items)}</span>'
802
+ '</div>'
803
+ )
804
+
805
+ # ── Cards ──
806
+ cards_html = ""
807
+ for cat, items in tools.items():
808
+ if not items:
809
+ continue
810
+ meta = CATEGORY_META.get(cat, CATEGORY_META["other"])
811
+ icon_bg, dot_color, title_color = COLOR_CSS.get(meta["color"], COLOR_CSS["gray"])
812
+ plural = "tools" if len(items) != 1 else "tool"
813
+
814
+ tool_rows = ""
815
+ for t in items:
816
+ bg, fg = BADGE_CSS.get(t.get("badge", "tool"), BADGE_CSS["tool"])
817
+ tool_rows += (
818
+ '<div class="tool">'
819
+ f'<span class="dot" style="background:{dot_color}"></span>'
820
+ f'<span class="tool-name">{t["name"]}</span>'
821
+ f'<span class="tool-desc">{t["desc"]}</span>'
822
+ f'<span class="badge" style="background:{bg};color:{fg}">{t.get("badge", "")}</span>'
823
+ '</div>'
824
+ )
825
+
826
+ cards_html += (
827
+ f'<div class="card" style="--c:{dot_color};--c-bg:{icon_bg}">'
828
+ '<div class="card-top">'
829
+ f'<span class="card-icon" style="background:{icon_bg}">{meta["icon"]}</span>'
830
+ '<div>'
831
+ f'<div class="card-title" style="color:{title_color}">{meta["label"]}</div>'
832
+ f'<div class="card-sub">{len(items)} {plural}</div>'
833
+ '</div>'
834
+ '</div>'
835
+ f'<div class="tool-list">{tool_rows}</div>'
836
+ '</div>'
837
+ )
838
+
839
+ # ── Stat cards ──
840
+ stats_html = (
841
+ '<div class="stat-card">'
842
+ f'<div class="stat-num">{total}</div>'
843
+ '<div class="stat-lbl">Total Tools</div>'
844
+ '</div>'
845
+ '<div class="stat-card">'
846
+ f'<div class="stat-num">{cat_count}</div>'
847
+ '<div class="stat-lbl">Categories</div>'
848
+ '</div>'
849
+ )
850
+ if ai_count:
851
+ stats_html += (
852
+ '<div class="stat-card stat-accent">'
853
+ f'<div class="stat-num">{ai_count}</div>'
854
+ '<div class="stat-lbl">AI Guardrails</div>'
855
+ '</div>'
856
+ )
857
+ if db_count:
858
+ stats_html += (
859
+ '<div class="stat-card">'
860
+ f'<div class="stat-num">{db_count}</div>'
861
+ '<div class="stat-lbl">Data Stores</div>'
862
+ '</div>'
863
+ )
864
+
865
+ # CSS as a plain string (no f-string) so CSS braces don't need escaping
866
+ css = (
867
+ "*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}"
868
+ ":root{"
869
+ "--bg:#0f172a;--bg2:#111827;--bg3:#161e2e;--bg4:#1a2535;"
870
+ "--border:#1e293b;--border2:#263347;"
871
+ "--text:#f1f5f9;--muted:#64748b;--dim:#334155;"
872
+ "--green:#22c55e;"
873
+ "--mono:'JetBrains Mono',monospace;"
874
+ "--sans:'IBM Plex Sans',system-ui,sans-serif;"
875
+ "}"
876
+ "html{scroll-behavior:smooth}"
877
+ "body{font-family:var(--sans);background:var(--bg);color:var(--text);"
878
+ "line-height:1.6;-webkit-font-smoothing:antialiased}"
879
+ ".header{"
880
+ "padding:80px 24px 56px;text-align:center;position:relative;overflow:hidden;"
881
+ "border-bottom:1px solid var(--border);}"
882
+ ".header::before{"
883
+ "content:'';position:absolute;inset:0;"
884
+ "background:radial-gradient(ellipse 90% 60% at 50% -10%,"
885
+ "rgba(34,197,94,.07) 0%,transparent 70%);pointer-events:none;}"
886
+ ".header-badge{"
887
+ "display:inline-flex;align-items:center;gap:7px;"
888
+ "background:rgba(34,197,94,.08);border:1px solid rgba(34,197,94,.2);"
889
+ "border-radius:999px;padding:5px 14px;margin-bottom:24px;"
890
+ "font-size:.7rem;font-weight:600;letter-spacing:.1em;"
891
+ "text-transform:uppercase;color:#22c55e;}"
892
+ ".badge-dot{"
893
+ "width:6px;height:6px;background:#22c55e;border-radius:50%;"
894
+ "animation:pulse 2s ease-in-out infinite;}"
895
+ "@keyframes pulse{"
896
+ "0%,100%{box-shadow:0 0 0 0 rgba(34,197,94,.5)}"
897
+ "50%{box-shadow:0 0 0 5px rgba(34,197,94,0)}}"
898
+ "h1{"
899
+ "font-size:clamp(1.8rem,4.5vw,3rem);font-weight:700;letter-spacing:-.03em;"
900
+ "color:#f8fafc;margin-bottom:10px;line-height:1.15;}"
901
+ ".header-sub{color:var(--muted);font-size:.9rem;font-weight:400}"
902
+ ".stats-wrap{"
903
+ "display:flex;flex-wrap:wrap;justify-content:center;gap:14px;"
904
+ "max-width:820px;margin:0 auto;padding:40px 24px 60px;}"
905
+ ".stat-card{"
906
+ "flex:1;min-width:140px;max-width:190px;"
907
+ "background:var(--bg3);border:1px solid var(--border);"
908
+ "border-radius:14px;padding:20px 16px;text-align:center;"
909
+ "transition:border-color .2s,transform .2s;}"
910
+ ".stat-card:hover{border-color:var(--border2);transform:translateY(-2px)}"
911
+ ".stat-card.stat-accent{background:rgba(34,197,94,.05);border-color:rgba(34,197,94,.25)}"
912
+ ".stat-card.stat-accent .stat-num{color:#22c55e}"
913
+ ".stat-num{font-family:var(--mono);font-size:2.4rem;font-weight:600;"
914
+ "color:#f8fafc;line-height:1}"
915
+ ".stat-lbl{font-size:.65rem;color:var(--muted);text-transform:uppercase;"
916
+ "letter-spacing:.1em;margin-top:6px}"
917
+ ".section{max-width:1200px;margin:0 auto;padding:0 24px 72px}"
918
+ ".sec-hdr{display:flex;align-items:center;gap:14px;margin-bottom:32px}"
919
+ ".sec-title{font-size:.65rem;font-weight:700;text-transform:uppercase;"
920
+ "letter-spacing:.15em;color:var(--muted);white-space:nowrap}"
921
+ ".sec-line{flex:1;height:1px;background:var(--border)}"
922
+ ".arch{display:flex;flex-direction:column;align-items:center;gap:0;"
923
+ "max-width:900px;margin:0 auto}"
924
+ ".arch-row{display:flex;align-items:center;justify-content:center;"
925
+ "gap:10px;flex-wrap:wrap}"
926
+ ".arch-box{"
927
+ "border-radius:10px;padding:13px 20px;text-align:center;font-size:.82rem;"
928
+ "font-weight:600;border:1px solid;min-width:120px;"
929
+ "transition:transform .2s,box-shadow .2s;}"
930
+ ".arch-box:hover{transform:translateY(-2px);box-shadow:0 6px 20px rgba(0,0,0,.4)}"
931
+ ".chart-wrap{"
932
+ "background:var(--bg3);border:1px solid var(--border);"
933
+ "border-radius:16px;padding:28px 28px 20px;max-width:720px;margin:0 auto;}"
934
+ ".bar-row{display:flex;align-items:center;gap:14px;margin-bottom:13px}"
935
+ ".bar-row:last-child{margin-bottom:0}"
936
+ ".bar-lbl{display:flex;align-items:center;gap:8px;font-size:.78rem;"
937
+ "color:#94a3b8;width:225px;flex-shrink:0;white-space:nowrap;"
938
+ "overflow:hidden;text-overflow:ellipsis;}"
939
+ ".bar-icon{font-size:.9rem}"
940
+ ".bar-outer{flex:1;height:10px;background:#0a0f1a;border-radius:999px;overflow:hidden}"
941
+ ".bar-inner{height:10px;border-radius:999px;transition:width .6s ease}"
942
+ ".bar-num{font-family:var(--mono);font-size:.75rem;font-weight:600;"
943
+ "width:20px;text-align:right;flex-shrink:0}"
944
+ ".cards-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:20px}"
945
+ ".card{"
946
+ "background:var(--bg3);border:1px solid var(--border);"
947
+ "border-left:3px solid var(--c,#475569);border-radius:14px;"
948
+ "overflow:hidden;transition:border-color .2s,transform .2s,box-shadow .2s;}"
949
+ ".card:hover{"
950
+ "border-color:var(--c,#475569);transform:translateY(-3px);"
951
+ "box-shadow:0 8px 28px rgba(0,0,0,.35);}"
952
+ ".card-top{"
953
+ "display:flex;align-items:center;gap:12px;padding:18px 20px 14px;"
954
+ "border-bottom:1px solid var(--border);}"
955
+ ".card-icon{"
956
+ "width:36px;height:36px;border-radius:9px;display:flex;"
957
+ "align-items:center;justify-content:center;font-size:1.1rem;flex-shrink:0;}"
958
+ ".card-title{font-size:.72rem;font-weight:700;text-transform:uppercase;"
959
+ "letter-spacing:.09em}"
960
+ ".card-sub{font-size:.65rem;color:var(--muted);margin-top:2px}"
961
+ ".tool-list{display:flex;flex-direction:column;gap:1px;padding:8px}"
962
+ ".tool{display:flex;align-items:center;gap:9px;padding:8px 12px;"
963
+ "border-radius:8px;transition:background .15s;}"
964
+ ".tool:hover{background:rgba(255,255,255,.03)}"
965
+ ".dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}"
966
+ ".tool-name{font-family:var(--mono);font-size:.76rem;font-weight:500;"
967
+ "color:#e2e8f0;flex:1;min-width:0}"
968
+ ".tool-desc{font-size:.7rem;color:var(--muted);flex:2;min-width:0}"
969
+ ".badge{font-size:.58rem;font-weight:700;padding:2px 8px;border-radius:999px;"
970
+ "flex-shrink:0;white-space:nowrap;font-family:var(--mono);letter-spacing:.02em;}"
971
+ ".legend-wrap{"
972
+ "background:var(--bg3);border:1px solid var(--border);"
973
+ "border-radius:14px;padding:20px 24px;max-width:800px;margin:0 auto;}"
974
+ ".legend-title{font-size:.62rem;font-weight:700;text-transform:uppercase;"
975
+ "letter-spacing:.12em;color:var(--muted);margin-bottom:14px;}"
976
+ ".legend-grid{display:flex;flex-wrap:wrap;gap:10px 24px}"
977
+ ".legend-item{display:flex;align-items:center;gap:8px;font-size:.75rem;color:#94a3b8}"
978
+ "footer{text-align:center;padding:28px 24px 40px;font-size:.68rem;"
979
+ "color:var(--dim);border-top:1px solid var(--border);margin-top:24px;}"
980
+ "@media(max-width:640px){"
981
+ ".bar-lbl{width:140px}"
982
+ ".tool-desc{display:none}"
983
+ ".cards-grid{grid-template-columns:1fr}"
984
+ "h1{font-size:1.8rem}}"
985
+ )
986
+
987
+ return f"""<!DOCTYPE html>
988
+ <html lang="en">
989
+ <head>
990
+ <meta charset="UTF-8">
991
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
992
+ <title>{project_name} — Tech Stack</title>
993
+ <link rel="preconnect" href="https://fonts.googleapis.com">
994
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
995
+ <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
996
+ <style>{css}</style>
997
+ </head>
998
+ <body>
999
+
1000
+ <header class="header">
1001
+ <div class="header-badge"><span class="badge-dot"></span>Auto-generated</div>
1002
+ <h1>{project_name}</h1>
1003
+ <p class="header-sub">Complete Technology Stack &nbsp;&middot;&nbsp; {total} tools across {cat_count} categories &nbsp;&middot;&nbsp; {today}</p>
1004
+ </header>
1005
+
1006
+ <div class="stats-wrap">
1007
+ {stats_html}
1008
+ </div>
1009
+
1010
+ <div class="section">
1011
+ <div class="sec-hdr">
1012
+ <span class="sec-title">System Architecture</span>
1013
+ <div class="sec-line"></div>
1014
+ <span class="sec-title" style="color:#334155">how it fits together</span>
1015
+ </div>
1016
+ <div class="arch">
1017
+ {arch_html}
1018
+ </div>
1019
+ </div>
1020
+
1021
+ <div class="section">
1022
+ <div class="sec-hdr">
1023
+ <span class="sec-title">By the Numbers</span>
1024
+ <div class="sec-line"></div>
1025
+ </div>
1026
+ <div class="chart-wrap">
1027
+ {bars_html}
1028
+ </div>
1029
+ </div>
1030
+
1031
+ <div class="section">
1032
+ <div class="sec-hdr">
1033
+ <span class="sec-title">Full Stack</span>
1034
+ <div class="sec-line"></div>
1035
+ <span class="sec-title" style="color:#334155">{total} tools detected</span>
1036
+ </div>
1037
+ <div class="cards-grid">
1038
+ {cards_html}
1039
+ </div>
1040
+ </div>
1041
+
1042
+ <div class="section">
1043
+ <div class="legend-wrap">
1044
+ <div class="legend-title">Badge Reference</div>
1045
+ <div class="legend-grid">
1046
+ <div class="legend-item"><span class="badge" style="background:#1e293b;color:#94a3b8">pip</span>Installed via requirements.txt</div>
1047
+ <div class="legend-item"><span class="badge" style="background:#1e293b;color:#64748b">optional</span>Detected in source (install separately)</div>
1048
+ <div class="legend-item"><span class="badge" style="background:#14532d;color:#86efac">dep</span>npm dependency</div>
1049
+ <div class="legend-item"><span class="badge" style="background:#1e1b4b;color:#a5b4fc">devDep</span>npm dev dependency</div>
1050
+ <div class="legend-item"><span class="badge" style="background:#14532d;color:#86efac">core</span>Always present (embedded)</div>
1051
+ <div class="legend-item"><span class="badge" style="background:#431407;color:#fdba74">lang</span>Language / runtime</div>
1052
+ <div class="legend-item"><span class="badge" style="background:#3b1f0f;color:#fdba74">deploy</span>Deployment artifact</div>
1053
+ <div class="legend-item"><span class="badge" style="background:#1e1b4b;color:#a5b4fc">ci</span>CI/CD integration</div>
1054
+ </div>
1055
+ </div>
1056
+ </div>
1057
+
1058
+ <footer>
1059
+ {project_name} &nbsp;&middot;&nbsp; {total} tools in {cat_count} categories &nbsp;&middot;&nbsp; {today} &nbsp;&middot;&nbsp; generate-tech-stack
1060
+ </footer>
1061
+
1062
+ </body>
1063
+ </html>"""
1064
+
1065
+
1066
+ # ── Entry point ───────────────────────────────────────────────────────────────
1067
+
1068
+ def main():
1069
+ root = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(".")
1070
+ output = Path(sys.argv[2]) if len(sys.argv) > 2 else root / "TECH_STACK.html"
1071
+
1072
+ project_name = root.resolve().name.replace("-", " ").replace("_", " ").title()
1073
+ tools = collect(root)
1074
+
1075
+ if not tools:
1076
+ print("No recognizable dependency files found.", file=sys.stderr)
1077
+ sys.exit(1)
1078
+
1079
+ output.write_text(render_html(tools, project_name))
1080
+
1081
+ total = sum(len(v) for v in tools.values())
1082
+ print(f"Written: {output}")
1083
+ print(f"Detected {total} tools in {len(tools)} categories")
1084
+ for cat, items in tools.items():
1085
+ meta = CATEGORY_META.get(cat, CATEGORY_META["other"])
1086
+ print(f" {meta['icon']} {meta['label']}: {', '.join(t['name'] for t in items)}")
1087
+
1088
+
1089
+ if __name__ == "__main__":
1090
+ main()