klapcontext 0.1.3__tar.gz → 0.2.0__tar.gz
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.
- {klapcontext-0.1.3/src/klapcontext.egg-info → klapcontext-0.2.0}/PKG-INFO +14 -1
- {klapcontext-0.1.3 → klapcontext-0.2.0}/README.md +8 -0
- {klapcontext-0.1.3 → klapcontext-0.2.0}/pyproject.toml +1 -1
- {klapcontext-0.1.3 → klapcontext-0.2.0}/src/klapcontext/__init__.py +1 -1
- klapcontext-0.2.0/src/klapcontext/agent/__init__.py +5 -0
- klapcontext-0.2.0/src/klapcontext/agent/capabilities.py +66 -0
- klapcontext-0.2.0/src/klapcontext/agent/compiler.py +64 -0
- klapcontext-0.2.0/src/klapcontext/agent/impact.py +18 -0
- klapcontext-0.2.0/src/klapcontext/agent/intents.py +39 -0
- klapcontext-0.2.0/src/klapcontext/agent/models.py +21 -0
- klapcontext-0.2.0/src/klapcontext/agent/planner.py +32 -0
- klapcontext-0.2.0/src/klapcontext/agent/verification.py +19 -0
- klapcontext-0.2.0/src/klapcontext/agent/workflows.py +31 -0
- {klapcontext-0.1.3 → klapcontext-0.2.0}/src/klapcontext/cli.py +8 -0
- klapcontext-0.2.0/src/klapcontext/context_builder.py +121 -0
- {klapcontext-0.1.3 → klapcontext-0.2.0}/src/klapcontext/detector.py +22 -2
- klapcontext-0.2.0/src/klapcontext/frameworks/base.py +17 -0
- klapcontext-0.2.0/src/klapcontext/frameworks/fastapi.py +37 -0
- klapcontext-0.2.0/src/klapcontext/frameworks/generic_php.py +32 -0
- {klapcontext-0.1.3 → klapcontext-0.2.0}/src/klapcontext/frameworks/laravel.py +49 -0
- klapcontext-0.2.0/src/klapcontext/frameworks/node.py +32 -0
- klapcontext-0.2.0/src/klapcontext/git.py +60 -0
- klapcontext-0.2.0/src/klapcontext/providers/code_intelligence.py +233 -0
- klapcontext-0.2.0/src/klapcontext/providers/javascript_intelligence.py +38 -0
- klapcontext-0.2.0/src/klapcontext/providers/python_intelligence.py +44 -0
- klapcontext-0.2.0/src/klapcontext/semantic.py +169 -0
- {klapcontext-0.1.3 → klapcontext-0.2.0/src/klapcontext.egg-info}/PKG-INFO +14 -1
- {klapcontext-0.1.3 → klapcontext-0.2.0}/src/klapcontext.egg-info/SOURCES.txt +17 -0
- klapcontext-0.2.0/src/klapcontext.egg-info/requires.txt +11 -0
- klapcontext-0.2.0/tests/test_klapcontext.py +234 -0
- klapcontext-0.1.3/src/klapcontext/context_builder.py +0 -113
- klapcontext-0.1.3/src/klapcontext/git.py +0 -34
- klapcontext-0.1.3/src/klapcontext.egg-info/requires.txt +0 -6
- klapcontext-0.1.3/tests/test_klapcontext.py +0 -89
- {klapcontext-0.1.3 → klapcontext-0.2.0}/LICENSE +0 -0
- {klapcontext-0.1.3 → klapcontext-0.2.0}/setup.cfg +0 -0
- {klapcontext-0.1.3 → klapcontext-0.2.0}/src/klapcontext/agent_context.py +0 -0
- {klapcontext-0.1.3 → klapcontext-0.2.0}/src/klapcontext/evidence.py +0 -0
- {klapcontext-0.1.3 → klapcontext-0.2.0}/src/klapcontext/frameworks/__init__.py +0 -0
- {klapcontext-0.1.3 → klapcontext-0.2.0}/src/klapcontext/graphify.py +0 -0
- {klapcontext-0.1.3 → klapcontext-0.2.0}/src/klapcontext/portal.py +0 -0
- {klapcontext-0.1.3 → klapcontext-0.2.0}/src/klapcontext/providers/__init__.py +0 -0
- {klapcontext-0.1.3 → klapcontext-0.2.0}/src/klapcontext/providers/base.py +0 -0
- {klapcontext-0.1.3 → klapcontext-0.2.0}/src/klapcontext/providers/graphify.py +0 -0
- {klapcontext-0.1.3 → klapcontext-0.2.0}/src/klapcontext/system.py +0 -0
- {klapcontext-0.1.3 → klapcontext-0.2.0}/src/klapcontext.egg-info/dependency_links.txt +0 -0
- {klapcontext-0.1.3 → klapcontext-0.2.0}/src/klapcontext.egg-info/entry_points.txt +0 -0
- {klapcontext-0.1.3 → klapcontext-0.2.0}/src/klapcontext.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: klapcontext
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Make your repository understandable to humans and AI.
|
|
5
5
|
Author: Willy Cordon
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -21,6 +21,11 @@ Requires-Python: >=3.10
|
|
|
21
21
|
Description-Content-Type: text/markdown
|
|
22
22
|
License-File: LICENSE
|
|
23
23
|
Requires-Dist: graphifyy>=0.9.66
|
|
24
|
+
Requires-Dist: tree-sitter<0.26,>=0.24
|
|
25
|
+
Requires-Dist: tree-sitter-php<0.25,>=0.24
|
|
26
|
+
Requires-Dist: tree-sitter-javascript<0.26,>=0.23
|
|
27
|
+
Requires-Dist: tree-sitter-typescript<0.26,>=0.23
|
|
28
|
+
Requires-Dist: mcp<2,>=1.0
|
|
24
29
|
Provides-Extra: dev
|
|
25
30
|
Requires-Dist: build>=1.2; extra == "dev"
|
|
26
31
|
Requires-Dist: pytest>=8; extra == "dev"
|
|
@@ -36,6 +41,14 @@ entrada, flujos, automatizaciones, integraciones y sus incertidumbres.
|
|
|
36
41
|
Graphify funciona como proveedor de análisis técnico; KlapContext es la capa
|
|
37
42
|
que transforma esa evidencia en comprensión para personas y agentes de IA.
|
|
38
43
|
|
|
44
|
+
## Agent Intelligence
|
|
45
|
+
|
|
46
|
+
KlapContext planifica el **mínimo contexto relevante** antes de que un agente
|
|
47
|
+
empiece a cambiar código. Según la intención —entender, modificar, depurar,
|
|
48
|
+
probar, refactorizar o medir impacto— selecciona capacidades y proveedores
|
|
49
|
+
necesarios, sin acoplarse a una herramienta externa ni cargar archivos enteros
|
|
50
|
+
por defecto. La compilación de Task Context llegará en los siguientes sprints.
|
|
51
|
+
|
|
39
52
|
KlapContext convierte un repositorio en contexto de ingeniería reutilizable: un
|
|
40
53
|
portal estático para personas y un briefing compacto para agentes de IA. Usa
|
|
41
54
|
[Graphify](https://github.com/Graphify-Labs/graphify) como motor local de
|
|
@@ -7,6 +7,14 @@ entrada, flujos, automatizaciones, integraciones y sus incertidumbres.
|
|
|
7
7
|
Graphify funciona como proveedor de análisis técnico; KlapContext es la capa
|
|
8
8
|
que transforma esa evidencia en comprensión para personas y agentes de IA.
|
|
9
9
|
|
|
10
|
+
## Agent Intelligence
|
|
11
|
+
|
|
12
|
+
KlapContext planifica el **mínimo contexto relevante** antes de que un agente
|
|
13
|
+
empiece a cambiar código. Según la intención —entender, modificar, depurar,
|
|
14
|
+
probar, refactorizar o medir impacto— selecciona capacidades y proveedores
|
|
15
|
+
necesarios, sin acoplarse a una herramienta externa ni cargar archivos enteros
|
|
16
|
+
por defecto. La compilación de Task Context llegará en los siguientes sprints.
|
|
17
|
+
|
|
10
18
|
KlapContext convierte un repositorio en contexto de ingeniería reutilizable: un
|
|
11
19
|
portal estático para personas y un briefing compacto para agentes de IA. Usa
|
|
12
20
|
[Graphify](https://github.com/Graphify-Labs/graphify) como motor local de
|
|
@@ -13,7 +13,7 @@ license-files = ["LICENSE"]
|
|
|
13
13
|
authors = [{name = "Willy Cordon"}]
|
|
14
14
|
keywords = ["codebase", "developer-tools", "engineering-context", "graphify", "ai-agents"]
|
|
15
15
|
classifiers = ["Development Status :: 3 - Alpha", "Intended Audience :: Developers", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Topic :: Software Development :: Documentation"]
|
|
16
|
-
dependencies = ["graphifyy>=0.9.66"]
|
|
16
|
+
dependencies = ["graphifyy>=0.9.66", "tree-sitter>=0.24,<0.26", "tree-sitter-php>=0.24,<0.25", "tree-sitter-javascript>=0.23,<0.26", "tree-sitter-typescript>=0.23,<0.26", "mcp>=1.0,<2"]
|
|
17
17
|
|
|
18
18
|
[project.optional-dependencies]
|
|
19
19
|
dev = ["build>=1.2", "pytest>=8", "twine>=6"]
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Capabilities are stable; tools that implement them are replaceable."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Capability(str, Enum):
|
|
9
|
+
FRAMEWORK_ANALYSIS = "framework_analysis"
|
|
10
|
+
ENTRY_POINT_DISCOVERY = "entry_point_discovery"
|
|
11
|
+
FLOW_RECONSTRUCTION = "flow_reconstruction"
|
|
12
|
+
DEPENDENCY_GRAPH = "dependency_graph"
|
|
13
|
+
PATH_SEARCH = "path_search"
|
|
14
|
+
TEST_DISCOVERY = "test_discovery"
|
|
15
|
+
INTEGRATION_DISCOVERY = "integration_discovery"
|
|
16
|
+
AUTOMATION_DISCOVERY = "automation_discovery"
|
|
17
|
+
CONFIG_DISCOVERY = "config_discovery"
|
|
18
|
+
SYMBOL_LOOKUP = "symbol_lookup"
|
|
19
|
+
CALL_GRAPH = "call_graph"
|
|
20
|
+
CALLERS = "callers"
|
|
21
|
+
CALLEES = "callees"
|
|
22
|
+
IMPACT_ANALYSIS = "impact_analysis"
|
|
23
|
+
MINIMAL_EDIT_CONTEXT = "minimal_edit_context"
|
|
24
|
+
GIT_HISTORY = "git_history"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class ProviderInfo:
|
|
29
|
+
name: str
|
|
30
|
+
capabilities: frozenset[Capability]
|
|
31
|
+
available: bool
|
|
32
|
+
optional: bool = False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class CapabilityResolution:
|
|
37
|
+
capability: Capability
|
|
38
|
+
provider: str | None
|
|
39
|
+
status: str
|
|
40
|
+
fallbacks: tuple[str, ...] = ()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class CapabilityRouter:
|
|
44
|
+
"""Select an available provider without leaking provider names to callers."""
|
|
45
|
+
def __init__(self, providers: list[ProviderInfo]):
|
|
46
|
+
self.providers = providers
|
|
47
|
+
|
|
48
|
+
def resolve(self, capability: Capability) -> CapabilityResolution:
|
|
49
|
+
candidates = [provider for provider in self.providers if capability in provider.capabilities]
|
|
50
|
+
ready = next((provider for provider in candidates if provider.available), None)
|
|
51
|
+
if ready:
|
|
52
|
+
return CapabilityResolution(capability, ready.name, "READY", tuple(provider.name for provider in candidates if provider.name != ready.name))
|
|
53
|
+
return CapabilityResolution(capability, None, "UNAVAILABLE", tuple(provider.name for provider in candidates))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def default_providers(graphify_ready: bool | None = None) -> list[ProviderInfo]:
|
|
57
|
+
from ..providers.code_intelligence import available as php_code_intelligence_available
|
|
58
|
+
if graphify_ready is None:
|
|
59
|
+
from ..graphify import command_prefix
|
|
60
|
+
graphify_ready = command_prefix() is not None
|
|
61
|
+
return [
|
|
62
|
+
ProviderInfo("klap-native", frozenset({Capability.FRAMEWORK_ANALYSIS, Capability.ENTRY_POINT_DISCOVERY, Capability.FLOW_RECONSTRUCTION, Capability.TEST_DISCOVERY, Capability.INTEGRATION_DISCOVERY, Capability.AUTOMATION_DISCOVERY, Capability.CONFIG_DISCOVERY}), True),
|
|
63
|
+
ProviderInfo("graphify", frozenset({Capability.DEPENDENCY_GRAPH, Capability.PATH_SEARCH, Capability.FLOW_RECONSTRUCTION}), graphify_ready),
|
|
64
|
+
ProviderInfo("php-tree-sitter", frozenset({Capability.SYMBOL_LOOKUP, Capability.CALL_GRAPH, Capability.CALLERS, Capability.CALLEES, Capability.IMPACT_ANALYSIS, Capability.MINIMAL_EDIT_CONTEXT}), php_code_intelligence_available()),
|
|
65
|
+
ProviderInfo("tree-sitter-mcp", frozenset({Capability.SYMBOL_LOOKUP, Capability.CALL_GRAPH, Capability.CALLERS, Capability.CALLEES, Capability.IMPACT_ANALYSIS, Capability.MINIMAL_EDIT_CONTEXT}), False, True),
|
|
66
|
+
]
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Compile a small, explainable agent context from the system model."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .planner import ContextPlanner
|
|
9
|
+
from ..context_builder import build
|
|
10
|
+
from .verification import reconcile, sanitize
|
|
11
|
+
|
|
12
|
+
DETAIL_LIMITS = {"minimal": 3, "standard": 6, "deep": 12}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _terms(query: str, area: str) -> set[str]:
|
|
16
|
+
return {item for item in re.findall(r"[a-zA-Z_]{4,}", f"{query} {area}".casefold()) if item not in {"quiero", "modificar", "cambiar", "sobre", "para"}}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _rank(items: list[dict], terms: set[str], limit: int) -> list[dict]:
|
|
20
|
+
ranked = []
|
|
21
|
+
for original in items:
|
|
22
|
+
text = json.dumps(original, ensure_ascii=False).casefold(); matches = sum(term in text for term in terms)
|
|
23
|
+
if matches or not terms:
|
|
24
|
+
item = dict(original); item["relevance"] = "directa" if matches else "contextual"; item["reason"] = "Coincide con la tarea" if matches else "Contexto estructural cercano"; ranked.append((matches, item))
|
|
25
|
+
return [item for _, item in sorted(ranked, key=lambda pair: pair[0], reverse=True)[:limit]]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def compile_context(root: Path, query: str, *, detail: str = "standard", max_tokens: int = 5000) -> dict:
|
|
29
|
+
plan = ContextPlanner().plan(query, detail=detail, max_tokens=max_tokens)
|
|
30
|
+
context = build(root, {"nodes": []}); model = context["system_model"]; limit = min(DETAIL_LIMITS[detail], max(1, max_tokens // 350)); terms = _terms(query, plan.area)
|
|
31
|
+
entries = _rank(model["entry_points"], terms, limit); flows = _rank(model["main_flows"], terms, limit); files = _rank(model["important_files"], terms, limit)
|
|
32
|
+
read_first = ([{"path": item.get("file"), "reason": "Punto de entrada relacionado", "relevance": item["relevance"]} for item in entries if item.get("file")] + files)[:limit]
|
|
33
|
+
result = {"intent": plan.intent, "confidence": plan.confidence, "query": query, "area": plan.area, "detail": detail, "max_tokens": max_tokens, "entry_points": entries, "flows": flows, "symbols": _rank(model["semantic_model"]["components"], terms, limit), "files": files, "dependencies": _rank(model["dependencies"], terms, limit), "tests": _rank(context["tests"], terms, limit), "read_first": read_first, "evidence": [evidence for group in entries + flows for evidence in group.get("evidence", [])][:limit], "uncertainties": model["unknowns"], "strategy": plan.strategy}
|
|
34
|
+
result["verification"] = reconcile(result["evidence"])
|
|
35
|
+
result = sanitize(result)
|
|
36
|
+
result["estimated_tokens"] = min(max_tokens, len(json.dumps(result, ensure_ascii=False)) // 4)
|
|
37
|
+
result["execution"] = {"capabilities": plan.capabilities, "providers": plan.providers, "files_selected": len(result["read_first"]), "sections_selected": len(plan.sections), "adaptive": True}
|
|
38
|
+
return result
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def system_context(root: Path, *, detail: str = "standard", max_tokens: int = 5000) -> dict:
|
|
42
|
+
return compile_context(root, "understand system", detail=detail, max_tokens=max_tokens)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def area_context(root: Path, area: str, *, detail: str = "standard", max_tokens: int = 5000) -> dict:
|
|
46
|
+
return compile_context(root, f"understand {area}", detail=detail, max_tokens=max_tokens)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def change_context(root: Path, task: str, *, detail: str = "standard", max_tokens: int = 5000) -> dict:
|
|
50
|
+
result = compile_context(root, task, detail=detail, max_tokens=max_tokens)
|
|
51
|
+
result["context_type"] = "CHANGE"; result["risks"] = result["uncertainties"]
|
|
52
|
+
return result
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def debug_context(root: Path, task: str, *, detail: str = "standard", max_tokens: int = 5000) -> dict:
|
|
56
|
+
result = compile_context(root, task, detail=detail, max_tokens=max_tokens)
|
|
57
|
+
result["context_type"] = "DEBUG"; result["investigation_points"] = result["entry_points"] + result["flows"]
|
|
58
|
+
return result
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def compile_test_context(root: Path, task: str, *, detail: str = "standard", max_tokens: int = 5000) -> dict:
|
|
62
|
+
result = compile_context(root, f"test {task}", detail=detail, max_tokens=max_tokens)
|
|
63
|
+
result["context_type"] = "TEST"
|
|
64
|
+
return result
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Explain structural impact; it never predicts that a change will break code."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from ..context_builder import build
|
|
7
|
+
from ..providers.code_intelligence import PhpCodeIntelligenceProvider
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def analyze_impact(root: Path, symbol: str) -> dict:
|
|
11
|
+
structural = PhpCodeIntelligenceProvider(root).impact(symbol)
|
|
12
|
+
model = build(root, {"nodes": []})["system_model"]
|
|
13
|
+
callers = structural["direct_callers"] + [{"source_symbol": item, "confidence": "INFERRED"} for item in structural["indirect_callers"]]
|
|
14
|
+
names = {symbol, *(item["source_symbol"] for item in callers)}
|
|
15
|
+
flows = [item for item in model["main_flows"] if any(any(name.endswith(step["name"]) or step["name"].endswith(name) for name in names) for step in item["steps"])]
|
|
16
|
+
entries = [item for item in model["entry_points"] if any(item.get("handler", "").endswith(name) or name.endswith(item.get("handler", "")) for name in names)]
|
|
17
|
+
tests = sorted(set(structural["related_tests"] + [item["path"] for item in model["important_files"] if "test" in item["path"].casefold()]))
|
|
18
|
+
return {"symbol": symbol, "statement": "Elementos potencialmente afectados según relaciones estructurales; no predice fallas.", "direct_callers": structural["direct_callers"], "indirect_callers": structural["indirect_callers"], "entry_points": entries, "flows": flows, "tests": tests, "configuration": [item for item in model["important_files"] if item["path"].startswith("config/")], "integrations": model["external_systems"], "uncertainties": model["unknowns"]}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Deterministic Spanish/English intent and area detection for planning."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class IntentMatch:
|
|
9
|
+
intent: str
|
|
10
|
+
confidence: float
|
|
11
|
+
area: str
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
RULES = (
|
|
15
|
+
("DEBUG", ("error", "falla", "fallo", "bug", "exception", "500", "debug")),
|
|
16
|
+
("IMPACT", ("impacto", "qué rompe", "que rompe", "qué usa", "que usa", "afecta", "impact")),
|
|
17
|
+
("REFACTOR", ("refactor", "limpiar", "separar", "reestructurar")),
|
|
18
|
+
("TEST", ("test", "prueba", "coverage", "cobertura")),
|
|
19
|
+
("CHANGE", ("modificar", "cambiar", "agregar", "implementar", "añadir", "fix", "update", "edit")),
|
|
20
|
+
("UNDERSTAND", ("qué hace", "que hace", "entender", "explicar", "comprender", "how does", "what does")),
|
|
21
|
+
)
|
|
22
|
+
AREAS = (
|
|
23
|
+
("authentication", ("auth", "autentic", "login", "logout", "jwt", "oauth", "sanctum", "passport", "password")),
|
|
24
|
+
("orders", ("order", "pedido", "orden")),
|
|
25
|
+
("files", ("archivo", "file", "upload", "sftp", "ftp", "storage")),
|
|
26
|
+
("payments", ("payment", "pago", "checkout", "stripe")),
|
|
27
|
+
("notifications", ("notification", "notific", "mail", "email", "sms")),
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def detect(query: str) -> IntentMatch:
|
|
32
|
+
text = query.casefold()
|
|
33
|
+
intent, confidence = "UNDERSTAND", .45
|
|
34
|
+
for candidate, words in RULES:
|
|
35
|
+
if any(word in text for word in words):
|
|
36
|
+
intent, confidence = candidate, .9
|
|
37
|
+
break
|
|
38
|
+
area = next((name for name, words in AREAS if any(word in text for word in words)), "unknown")
|
|
39
|
+
return IntentMatch(intent, confidence, area)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Small serializable model for the future context compiler."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import asdict, dataclass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class AgentContextPlan:
|
|
9
|
+
query: str
|
|
10
|
+
intent: str
|
|
11
|
+
confidence: float
|
|
12
|
+
area: str
|
|
13
|
+
detail: str
|
|
14
|
+
max_tokens: int
|
|
15
|
+
capabilities: list[str]
|
|
16
|
+
providers: list[dict]
|
|
17
|
+
sections: list[str]
|
|
18
|
+
strategy: str
|
|
19
|
+
|
|
20
|
+
def as_dict(self) -> dict:
|
|
21
|
+
return asdict(self)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Choose the smallest evidence plan that can answer an agent's task."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from .capabilities import Capability, CapabilityRouter, default_providers
|
|
5
|
+
from .intents import detect
|
|
6
|
+
from .models import AgentContextPlan
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
PLANS = {
|
|
10
|
+
"UNDERSTAND": ([Capability.FRAMEWORK_ANALYSIS, Capability.ENTRY_POINT_DISCOVERY, Capability.FLOW_RECONSTRUCTION, Capability.INTEGRATION_DISCOVERY, Capability.AUTOMATION_DISCOVERY], ["purpose", "entry_points", "flows", "integrations", "automations", "uncertainties"]),
|
|
11
|
+
"CHANGE": ([Capability.ENTRY_POINT_DISCOVERY, Capability.FLOW_RECONSTRUCTION, Capability.SYMBOL_LOOKUP, Capability.CALLERS, Capability.IMPACT_ANALYSIS, Capability.CONFIG_DISCOVERY, Capability.TEST_DISCOVERY], ["entry_points", "flows", "files", "symbols", "configuration", "tests", "impact", "evidence", "uncertainties"]),
|
|
12
|
+
"DEBUG": ([Capability.ENTRY_POINT_DISCOVERY, Capability.FLOW_RECONSTRUCTION, Capability.INTEGRATION_DISCOVERY, Capability.CONFIG_DISCOVERY, Capability.TEST_DISCOVERY], ["entry_points", "flows", "configuration", "tests", "investigation_points", "evidence", "uncertainties"]),
|
|
13
|
+
"TEST": ([Capability.TEST_DISCOVERY, Capability.ENTRY_POINT_DISCOVERY, Capability.FLOW_RECONSTRUCTION], ["tests", "related_code", "entry_points", "flows", "uncertainties"]),
|
|
14
|
+
"REFACTOR": ([Capability.SYMBOL_LOOKUP, Capability.CALLERS, Capability.CALLEES, Capability.IMPACT_ANALYSIS, Capability.TEST_DISCOVERY], ["symbols", "callers", "callees", "impact", "tests", "evidence"]),
|
|
15
|
+
"IMPACT": ([Capability.SYMBOL_LOOKUP, Capability.CALLERS, Capability.IMPACT_ANALYSIS, Capability.TEST_DISCOVERY], ["symbols", "callers", "impact", "tests", "uncertainties"]),
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ContextPlanner:
|
|
20
|
+
def __init__(self, router: CapabilityRouter | None = None):
|
|
21
|
+
self.router = router or CapabilityRouter(default_providers())
|
|
22
|
+
|
|
23
|
+
def plan(self, query: str, *, detail: str = "standard", max_tokens: int = 5000) -> AgentContextPlan:
|
|
24
|
+
if detail not in {"minimal", "standard", "deep"}:
|
|
25
|
+
raise ValueError("detail must be minimal, standard or deep")
|
|
26
|
+
if max_tokens < 200:
|
|
27
|
+
raise ValueError("max_tokens must be at least 200")
|
|
28
|
+
match = detect(query)
|
|
29
|
+
capabilities, sections = PLANS[match.intent]
|
|
30
|
+
if detail == "minimal": sections = sections[:max(3, len(sections) // 2)]
|
|
31
|
+
resolutions = [self.router.resolve(capability) for capability in capabilities]
|
|
32
|
+
return AgentContextPlan(query, match.intent, match.confidence, match.area, detail, max_tokens, [capability.value for capability in capabilities], [{"capability": resolution.capability.value, "provider": resolution.provider, "status": resolution.status, "fallbacks": list(resolution.fallbacks)} for resolution in resolutions], sections, "Prioriza evidencia directa, luego relaciones de flujo y finalmente contexto periférico hasta agotar el presupuesto.")
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Normalize evidence and prevent secrets from entering agent output."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
SECRET = re.compile(r"(?i)(password|secret|token|api[_-]?key|private[_-]?key)\s*[:=]\s*[^\s,]+")
|
|
7
|
+
|
|
8
|
+
def sanitize(value):
|
|
9
|
+
if isinstance(value, str): return SECRET.sub(lambda match: match.group(1) + "=[REDACTED]", value)
|
|
10
|
+
if isinstance(value, list): return [sanitize(item) for item in value]
|
|
11
|
+
if isinstance(value, dict): return {key: sanitize(item) for key, item in value.items() if key.casefold() not in {"password", "secret", "token", "api_key", "private_key"}}
|
|
12
|
+
return value
|
|
13
|
+
|
|
14
|
+
def reconcile(evidence: list[dict]) -> dict:
|
|
15
|
+
unique, seen = [], set()
|
|
16
|
+
for item in evidence:
|
|
17
|
+
clean = sanitize(item); key = (clean.get("path"), clean.get("line"), clean.get("symbol"), clean.get("reason"))
|
|
18
|
+
if key not in seen: seen.add(key); unique.append(clean)
|
|
19
|
+
return {"evidence": unique, "conflicts": [], "status": "CONFIRMED" if unique else "UNKNOWN"}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Agent workflows combine compact context with Git and impact evidence."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .compiler import change_context
|
|
7
|
+
from .impact import analyze_impact
|
|
8
|
+
from ..git import intelligence
|
|
9
|
+
from ..git import dirty_files, run_git
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def prepare_change(root: Path, task: str, *, detail: str = "standard", max_tokens: int = 5000) -> dict:
|
|
13
|
+
result = change_context(root, task, detail=detail, max_tokens=max_tokens)
|
|
14
|
+
paths = [item.get("path") for item in result["read_first"] if item.get("path")]
|
|
15
|
+
result["git"] = intelligence(root, paths)
|
|
16
|
+
result["workflow"] = "PREPARE_CHANGE"
|
|
17
|
+
return result
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def change_impact(root: Path, symbol: str) -> dict:
|
|
21
|
+
result = analyze_impact(root, symbol); result["git"] = intelligence(root); result["workflow"] = "CHANGE_IMPACT"
|
|
22
|
+
return result
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def verify_change(root: Path) -> dict:
|
|
26
|
+
changed = dirty_files(root)
|
|
27
|
+
diff = run_git(root, "diff", "--name-only") or ""
|
|
28
|
+
changed = sorted(set(changed + [line for line in diff.splitlines() if line]))
|
|
29
|
+
context = prepare_change(root, "verificar cambio", detail="minimal", max_tokens=1200)
|
|
30
|
+
tests = [item for item in context["tests"] if item.get("path")]
|
|
31
|
+
return {"workflow": "VERIFY_CHANGE", "changed_files": changed, "related_tests": tests, "potential_risks": context["uncertainties"], "statement": "Hallazgos verificables del diff actual; no aprueba ni rechaza el cambio automáticamente."}
|
|
@@ -57,6 +57,13 @@ def cmd_agent(args):
|
|
|
57
57
|
print("Graphify MCP (stdio):\npython -m graphify.serve " + str(graph) + "\n\nGeneric configuration:\n{\n \"mcpServers\": {\n \"graphify\": {\n \"command\": \"python\",\n \"args\": [\"-m\", \"graphify.serve\", \"" + str(graph).replace('\\','\\\\') + "\"]\n }\n }\n}")
|
|
58
58
|
return 0
|
|
59
59
|
|
|
60
|
+
def cmd_context(args):
|
|
61
|
+
from .agent.compiler import compile_context
|
|
62
|
+
result=compile_context(root_path(args.path), args.query, detail=args.detail, max_tokens=args.max_tokens)
|
|
63
|
+
if args.json: print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
64
|
+
else: print(f"Intent: {result['intent']}\nÁrea: {result['area']}\nRead first:\n" + "\n".join(f"- {item.get('path', item.get('name'))}: {item['reason']}" for item in result['read_first']))
|
|
65
|
+
return 0
|
|
66
|
+
|
|
60
67
|
def main(argv=None):
|
|
61
68
|
# PowerShell's legacy cp1252 console otherwise raises on the intended status symbols.
|
|
62
69
|
if hasattr(sys.stdout, "reconfigure"):
|
|
@@ -68,6 +75,7 @@ def main(argv=None):
|
|
|
68
75
|
subs=parser.add_subparsers(dest="command", required=True)
|
|
69
76
|
for name, func, help_text in (("init",cmd_init,"Generate engineering context"),("update",cmd_update,"Refresh engineering context"),("status",cmd_status,"Show context freshness"),("open",cmd_open,"Open the human portal"),("agent",cmd_agent,"Show Graphify MCP setup")):
|
|
70
77
|
p=subs.add_parser(name, help=help_text); p.add_argument("path", nargs="?", help="Repository root (defaults to current directory)"); p.set_defaults(func=func)
|
|
78
|
+
p=subs.add_parser("context", help="Compile compact context for an agent task"); p.add_argument("query"); p.add_argument("path", nargs="?", default="."); p.add_argument("--detail", choices=("minimal", "standard", "deep"), default="standard"); p.add_argument("--max-tokens", type=int, default=5000); p.add_argument("--json", action="store_true"); p.set_defaults(func=cmd_context)
|
|
71
79
|
args=parser.parse_args(argv); return args.func(args)
|
|
72
80
|
|
|
73
81
|
if __name__ == "__main__": raise SystemExit(main())
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Build a product-level model from framework-neutral semantic evidence."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .detector import detect_components, detect_project
|
|
8
|
+
from .frameworks.generic_php import GenericPhpAdapter
|
|
9
|
+
from .frameworks.laravel import LaravelAdapter
|
|
10
|
+
from .frameworks.fastapi import FastAPIAdapter
|
|
11
|
+
from .frameworks.node import NodeAdapter
|
|
12
|
+
from .git import commit
|
|
13
|
+
from .semantic import SemanticModel
|
|
14
|
+
from .system import deployment, documents, external_systems, purpose
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _nodes(graph: dict) -> list[dict]:
|
|
18
|
+
return graph.get("nodes", []) if isinstance(graph.get("nodes"), list) else graph.get("graph", {}).get("nodes", [])
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _modules(nodes: list[dict]) -> list[dict]:
|
|
22
|
+
return [{"name": item.get("label") or item.get("name") or item.get("id"), "path": item.get("file") or item.get("path"), "kind": item.get("type", "module"), "description": None, "status": "CONFIRMED", "evidence": []} for item in nodes if (item.get("label") or item.get("name") or item.get("id")) and (item.get("file") or item.get("path"))][:100]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _runtime(points: list[dict], background: list[dict], frameworks: list[str]) -> dict:
|
|
26
|
+
types = {item["type"].casefold() for item in points}
|
|
27
|
+
modes, descriptions = [], []
|
|
28
|
+
if "http" in types: modes.append("http"); descriptions.append("Las solicitudes HTTP ingresan por las rutas detectadas.")
|
|
29
|
+
if "cli" in types: modes.append("cli"); descriptions.append("La aplicación expone comandos ejecutables.")
|
|
30
|
+
if background: modes.append("scheduler"); descriptions.append("La aplicación declara procesamiento en segundo plano.")
|
|
31
|
+
return {"modes": modes, "layers": frameworks, "description": " ".join(descriptions) or None, "status": "CONFIRMED" if descriptions else "UNKNOWN"}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _io(points: list[dict], systems: list[dict], stores: list[dict]) -> tuple[list[dict], list[dict]]:
|
|
35
|
+
inputs, outputs, types = [], [], {item["type"].casefold() for item in points}
|
|
36
|
+
if "http" in types:
|
|
37
|
+
inputs.append({"type": "http_request", "description": "Solicitudes HTTP", "status": "CONFIRMED"}); outputs.append({"type": "http_response", "description": "Respuestas HTTP", "status": "INFERRED"})
|
|
38
|
+
if "cli" in types: inputs.append({"type": "cli", "description": "Invocación por línea de comandos", "status": "CONFIRMED"})
|
|
39
|
+
if stores:
|
|
40
|
+
inputs.append({"type": "datastore", "description": "Base de datos", "status": "INFERRED"}); outputs.append({"type": "datastore", "description": "Escrituras de base de datos", "status": "INFERRED"})
|
|
41
|
+
return inputs, outputs
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _flows(model: dict) -> list[dict]:
|
|
45
|
+
"""Reconstruct compact flows from generic entry points and transitions only."""
|
|
46
|
+
components = {item.get("symbol") or item["name"]: item for item in model["components"]}
|
|
47
|
+
outgoing: dict[str, list[dict]] = {}
|
|
48
|
+
for edge in model["transitions"]: outgoing.setdefault(edge["source"], []).append(edge)
|
|
49
|
+
flows = []
|
|
50
|
+
for entry in model["entry_points"][:10]:
|
|
51
|
+
steps = [{"name": entry["name"], "kind": entry["type"].casefold(), "path": entry.get("file"), "status": entry["status"]}]
|
|
52
|
+
current = entry["name"]
|
|
53
|
+
if entry.get("handler"):
|
|
54
|
+
steps.append({"name": entry["handler"], "kind": "handler", "path": entry.get("file"), "status": entry["status"]}); current = entry["handler"]
|
|
55
|
+
seen = {entry["name"], current}
|
|
56
|
+
for _ in range(4):
|
|
57
|
+
candidates = outgoing.get(current, []) or [item for source, values in outgoing.items() if source.endswith(current) for item in values]
|
|
58
|
+
edge = next((item for item in candidates if item["target"] not in seen), None)
|
|
59
|
+
if not edge: break
|
|
60
|
+
target = edge["target"]; component = components.get(target, {})
|
|
61
|
+
steps.append({"name": target, "kind": edge["type"].casefold(), "path": component.get("file") or edge.get("file"), "status": edge["status"]})
|
|
62
|
+
seen.add(target); current = target
|
|
63
|
+
if len(steps) > 1:
|
|
64
|
+
status = "CONFIRMED" if all(item["status"] == "CONFIRMED" for item in steps) else "INFERRED"
|
|
65
|
+
flows.append({"name": entry["name"], "trigger": entry["type"].casefold(), "entry_point": entry["name"], "steps": steps, "status": status, "confidence": 1.0 if status == "CONFIRMED" else .7, "evidence": entry["evidence"], "inputs": [], "outputs": [], "external_systems": [], "datastores": [], "tests": []})
|
|
66
|
+
return flows
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _interactions(flows: list[dict], systems: list[dict], stores: list[dict]) -> dict:
|
|
70
|
+
nodes, edges = [], []
|
|
71
|
+
def add(name: str, kind: str):
|
|
72
|
+
if not any(item["name"] == name for item in nodes): nodes.append({"name": name, "type": kind})
|
|
73
|
+
for flow in flows:
|
|
74
|
+
previous = None
|
|
75
|
+
for step in flow["steps"]:
|
|
76
|
+
add(step["name"], step["kind"])
|
|
77
|
+
if previous: edges.append({"source": previous, "target": step["name"], "label": "calls"})
|
|
78
|
+
previous = step["name"]
|
|
79
|
+
for store in stores: add(store["name"], "datastore")
|
|
80
|
+
for system in systems: add(system["name"], "external_system")
|
|
81
|
+
return {"nodes": nodes[:30], "edges": edges[:45], "status": "INFERRED" if edges else "UNKNOWN"}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _empty_raw() -> dict:
|
|
85
|
+
return {"routes": [], "commands": [], "scheduled_processes": [], "queue_jobs": [], "events": [], "listeners": [], "components": [], "dependencies": [], "runtime_surfaces": [], "important_files": [], "framework": None}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _semantic(root: Path, stack: dict) -> tuple[SemanticModel, dict]:
|
|
89
|
+
if "Laravel" in stack["frameworks"]:
|
|
90
|
+
model = LaravelAdapter().analyze(root)
|
|
91
|
+
return model, model.metadata["raw"]
|
|
92
|
+
if "FastAPI" in stack["frameworks"]:
|
|
93
|
+
return FastAPIAdapter().analyze(root), _empty_raw()
|
|
94
|
+
for framework in ("Express", "NestJS"):
|
|
95
|
+
if framework in stack["frameworks"]: return NodeAdapter(framework).analyze(root), _empty_raw()
|
|
96
|
+
if "PHP" in stack["languages"]: return GenericPhpAdapter().analyze(root), _empty_raw()
|
|
97
|
+
return SemanticModel(stack["languages"][0] if stack["languages"] else None), _empty_raw()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _analysis_evidence(raw: dict) -> list[dict]:
|
|
101
|
+
seen, result = set(), []
|
|
102
|
+
for group in ("routes", "commands", "scheduled_processes", "queue_jobs", "events", "listeners", "components", "dependencies"):
|
|
103
|
+
for item in raw.get(group, []):
|
|
104
|
+
for evidence in item.get("evidence", []):
|
|
105
|
+
key = (evidence.get("path"), evidence.get("line"), evidence.get("reason"), evidence.get("symbol"))
|
|
106
|
+
if key not in seen: seen.add(key); result.append(evidence)
|
|
107
|
+
return result
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def build(root: Path, graph: dict) -> dict:
|
|
111
|
+
stack, detector_evidence = detect_project(root)
|
|
112
|
+
docs = documents(root); value = purpose(root, docs); systems, stores = external_systems(root); deploy, observability, _ = deployment(root)
|
|
113
|
+
semantic, raw = _semantic(root, stack); model = semantic.as_dict(); model["project_components"] = detect_components(root); points, background = model["entry_points"], model["background_tasks"]
|
|
114
|
+
runtime = _runtime(points, background, stack["frameworks"]); inputs, outputs = _io(points, systems, stores); flows = _flows(model)
|
|
115
|
+
important = raw["important_files"] or ([{"path": "README.md", "role": "Documentación", "reason": "README detectado"}] if (root / "README.md").exists() else [])
|
|
116
|
+
tests = [{"path": item, "reason": "Tests detectados"} for item in ("tests", "test", "phpunit.xml", "pytest.ini") if (root / item).exists()]
|
|
117
|
+
story = " ".join(item for item in (value["text"], runtime["description"], "La aplicación está contenerizada con Docker." if "Docker" in deploy["tools"] else None) if item)
|
|
118
|
+
system = {"purpose": value, "project_story": {"text": story or None, "status": "CONFIRMED" if story else "UNKNOWN"}, "runtime": runtime, "framework": raw["framework"], "runtime_surfaces": raw["runtime_surfaces"], "routes": raw["routes"], "commands": raw["commands"], "scheduled_processes": raw["scheduled_processes"], "queue_jobs": raw["queue_jobs"], "events": raw["events"], "listeners": raw["listeners"], "components": raw["components"], "dependencies": raw["dependencies"], "semantic_model": model, "entry_points": points, "main_flows": flows, "system_interactions": _interactions(flows, systems, stores), "capabilities": _modules(_nodes(graph)), "inputs": inputs, "outputs": outputs, "external_systems": systems, "background_processes": background, "datastores": stores, "deployment": deploy, "observability": observability, "important_files": important, "start_here": [], "unknowns": [item for item, found in (("Propósito de negocio del proyecto", value["text"]), ("Plataforma de despliegue en producción", deploy["tools"])) if not found]}
|
|
119
|
+
technical = {"languages": stack["languages"], "frameworks": stack["frameworks"], "framework": raw["framework"], "dependencies": raw["dependencies"], "graph": {"nodes": len(_nodes(graph)), "edges": len(graph.get("edges", graph.get("graph", {}).get("edges", [])))}, "documents": [{"path": item["path"]} for item in docs], "tests": tests, "infrastructure": stack["infrastructure"]}
|
|
120
|
+
project = {"name": root.name, "type": stack["frameworks"][0] if stack["frameworks"] else None, "root": str(root.resolve()), "generated_at": datetime.now(timezone.utc).isoformat(), "git_commit": commit(root)}
|
|
121
|
+
return {"schema_version": "0.5", "project": project, "technical_model": technical, "system_model": system, "semantic_model": model, "human_context": {"purpose": None, "users": None, "important_processes": [], "notes": []}, "stack": stack, "architecture": {"style": "MVC" if "Laravel" in stack["frameworks"] else None, "status": "INFERRED" if "Laravel" in stack["frameworks"] else "UNKNOWN", "confidence": None, "evidence": []}, "entry_points": points, "modules": system["capabilities"], "flows": flows, "api": raw["routes"], "jobs": background, "datastores": stores, "external_systems": systems, "deployment": deploy, "observability": observability, "tests": tests, "evidence": [item.as_dict() for item in detector_evidence] + value["evidence"] + _analysis_evidence(raw)}
|
|
@@ -18,17 +18,37 @@ def detect_project(root: Path) -> tuple[dict, list[Evidence]]:
|
|
|
18
18
|
try: deps = {**json.loads(composer.read_text(encoding="utf-8")).get("require", {}), **json.loads(composer.read_text(encoding="utf-8")).get("require-dev", {})}
|
|
19
19
|
except json.JSONDecodeError: deps = {}
|
|
20
20
|
if "laravel/framework" in deps: add("frameworks", "Laravel", "composer.json", "laravel/framework dependency detected")
|
|
21
|
+
elif any(root.rglob("*.php")):
|
|
22
|
+
# A standalone PHP repository has no Composer manifest to identify it,
|
|
23
|
+
# but language analysis can still provide a useful generic model.
|
|
24
|
+
first_php = next(root.rglob("*.php"))
|
|
25
|
+
add("languages", "PHP", str(first_php.relative_to(root)).replace("\\", "/"), "PHP source file detected")
|
|
21
26
|
package = root / "package.json"
|
|
22
27
|
if package.exists():
|
|
23
28
|
add("languages", "JavaScript", "package.json", "npm manifest detected")
|
|
24
29
|
try: deps = {**json.loads(package.read_text(encoding="utf-8")).get("dependencies", {}), **json.loads(package.read_text(encoding="utf-8")).get("devDependencies", {})}
|
|
25
30
|
except json.JSONDecodeError: deps = {}
|
|
26
|
-
for name, label in (("next", "Next.js"), ("vue", "Vue"), ("react", "React"), ("express", "Express")):
|
|
31
|
+
for name, label in (("next", "Next.js"), ("vue", "Vue"), ("react", "React"), ("express", "Express"), ("@nestjs/core", "NestJS")):
|
|
27
32
|
if name in deps: add("frameworks", label, "package.json", f"{name} dependency detected")
|
|
28
|
-
if (root / "pyproject.toml").exists() or (root / "requirements.txt").exists():
|
|
33
|
+
if (root / "pyproject.toml").exists() or (root / "requirements.txt").exists():
|
|
34
|
+
python_manifest = root / "pyproject.toml" if (root / "pyproject.toml").exists() else root / "requirements.txt"
|
|
35
|
+
add("languages", "Python", python_manifest.name, "Python manifest detected")
|
|
36
|
+
if "fastapi" in python_manifest.read_text(encoding="utf-8", errors="ignore").casefold(): add("frameworks", "FastAPI", python_manifest.name, "FastAPI dependency detected")
|
|
29
37
|
if (root / "manage.py").exists(): add("frameworks", "Django", "manage.py", "Django entry point detected")
|
|
30
38
|
if list(root.glob("*.csproj")): add("languages", "C#", next(root.glob("*.csproj")).name, ".NET project detected")
|
|
31
39
|
if (root / "Dockerfile").exists(): add("infrastructure", "Docker", "Dockerfile", "Dockerfile detected")
|
|
32
40
|
if list(root.glob("docker-compose*")) or list(root.glob("compose*")): add("infrastructure", "Docker Compose", "docker-compose", "Compose configuration detected")
|
|
33
41
|
if (root / ".github" / "workflows").exists(): add("infrastructure", "GitHub Actions", ".github/workflows", "GitHub Actions workflows detected")
|
|
34
42
|
return stack, evidence
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def detect_components(root: Path) -> list[dict]:
|
|
46
|
+
"""Find independent project boundaries without assuming a monorepo layout."""
|
|
47
|
+
result = []
|
|
48
|
+
for manifest in [*root.rglob("composer.json"), *root.rglob("package.json"), *root.rglob("pyproject.toml"), *root.rglob("requirements.txt")]:
|
|
49
|
+
if any(part in {".git", ".klap", "node_modules", "vendor", ".venv", "venv"} for part in manifest.relative_to(root).parts): continue
|
|
50
|
+
directory = manifest.parent
|
|
51
|
+
if directory == root: continue
|
|
52
|
+
stack, _ = detect_project(directory)
|
|
53
|
+
result.append({"name": directory.name, "path": directory.relative_to(root).as_posix(), "languages": stack["languages"], "frameworks": stack["frameworks"], "manifest": manifest.name, "status": "CONFIRMED"})
|
|
54
|
+
return result
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Contracts for framework-specific enrichment of the generic semantic model."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Protocol
|
|
6
|
+
|
|
7
|
+
from ..semantic import SemanticModel
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FrameworkAdapter(Protocol):
|
|
11
|
+
"""Detect one framework and emit framework-neutral semantic facts."""
|
|
12
|
+
|
|
13
|
+
name: str
|
|
14
|
+
|
|
15
|
+
def detect(self, root: Path) -> bool: ...
|
|
16
|
+
|
|
17
|
+
def analyze(self, root: Path) -> SemanticModel: ...
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""FastAPI conventions translated into the framework-neutral semantic model."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import ast
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from ..evidence import Evidence
|
|
8
|
+
from ..providers.python_intelligence import analyze as analyze_python
|
|
9
|
+
from ..semantic import EntryPoint, ExecutionTransition, SemanticModel
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FastAPIAdapter:
|
|
13
|
+
name = "FastAPI"
|
|
14
|
+
|
|
15
|
+
def detect(self, root: Path) -> bool:
|
|
16
|
+
return any("fastapi" in path.read_text(encoding="utf-8", errors="ignore").casefold() for path in (root / "pyproject.toml", root / "requirements.txt") if path.exists())
|
|
17
|
+
|
|
18
|
+
def analyze(self, root: Path) -> SemanticModel:
|
|
19
|
+
components, transitions = analyze_python(root); entries = []
|
|
20
|
+
for path in root.rglob("*.py"):
|
|
21
|
+
if any(part in {".venv", "venv", ".git", ".klap"} for part in path.relative_to(root).parts): continue
|
|
22
|
+
relative = path.relative_to(root).as_posix()
|
|
23
|
+
try: tree = ast.parse(path.read_text(encoding="utf-8"))
|
|
24
|
+
except (OSError, SyntaxError): continue
|
|
25
|
+
for node in tree.body:
|
|
26
|
+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): continue
|
|
27
|
+
for decorator in node.decorator_list:
|
|
28
|
+
if not isinstance(decorator, ast.Call) or not isinstance(decorator.func, ast.Attribute): continue
|
|
29
|
+
if decorator.func.attr.lower() not in {"get", "post", "put", "patch", "delete", "options"}: continue
|
|
30
|
+
route = decorator.args[0].value if decorator.args and isinstance(decorator.args[0], ast.Constant) else None
|
|
31
|
+
if not isinstance(route, str): continue
|
|
32
|
+
method = decorator.func.attr.upper(); name = f"{method} {route}"
|
|
33
|
+
evidence = [Evidence("code", relative, "Decorador de ruta FastAPI", "CONFIRMED", 1.0, line=node.lineno, symbol=node.name).as_dict()]
|
|
34
|
+
handler = f"{relative.removesuffix('.py').replace('/', '.')}.{node.name}"
|
|
35
|
+
entries.append(EntryPoint("HTTP", name, handler, "HTTP", method, route, relative, node.lineno, "FastAPI", "CONFIRMED", evidence))
|
|
36
|
+
transitions.append(ExecutionTransition(name, handler, "HTTP_ENTRY", relative, node.lineno, "CONFIRMED", "fastapi", evidence))
|
|
37
|
+
return SemanticModel("Python", "FastAPI", "FRAMEWORK", components, entries, transitions)
|