klapcontext 0.1.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.
- klapcontext/__init__.py +3 -0
- klapcontext/agent_context.py +13 -0
- klapcontext/cli.py +71 -0
- klapcontext/context_builder.py +105 -0
- klapcontext/detector.py +34 -0
- klapcontext/evidence.py +11 -0
- klapcontext/git.py +34 -0
- klapcontext/graphify.py +52 -0
- klapcontext/portal.py +26 -0
- klapcontext/system.py +123 -0
- klapcontext-0.1.0.dist-info/METADATA +108 -0
- klapcontext-0.1.0.dist-info/RECORD +16 -0
- klapcontext-0.1.0.dist-info/WHEEL +5 -0
- klapcontext-0.1.0.dist-info/entry_points.txt +2 -0
- klapcontext-0.1.0.dist-info/licenses/LICENSE +44 -0
- klapcontext-0.1.0.dist-info/top_level.txt +1 -0
klapcontext/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
def listado(items: list[dict], key: str = "name") -> str:
|
|
5
|
+
return "\n".join(f"- {item.get(key) or item.get('path')}" for item in items) if items else "Sin evidencia disponible."
|
|
6
|
+
def flujo(flow: dict) -> str:
|
|
7
|
+
return f"- **{flow['name']}** ({flow['status'].lower()}): " + " → ".join(step["name"] for step in flow["steps"])
|
|
8
|
+
def render(context: dict) -> str:
|
|
9
|
+
system=context.get("system_model", {}); purpose=system.get("purpose", {}); story=system.get("project_story", {}); runtime=system.get("runtime", {})
|
|
10
|
+
lines=["# KlapContext — Contexto del Proyecto", "", "## Propósito del Proyecto", purpose.get("text") or "No fue posible determinar el propósito del proyecto con confianza.", "", "## Cómo Funciona el Sistema", story.get("text") or "No fue posible determinar el comportamiento del sistema con confianza.", "", "## Modelo de Ejecución", runtime.get("description") or "Modelo de ejecución desconocido.", "", "## Puntos de Entrada", listado(system.get("entry_points", [])), "", "## Flujos Principales", "\n".join(flujo(x) for x in system.get("main_flows", [])) or "No se detectaron flujos principales con confianza.", "", "## Capacidades Principales", listado(system.get("capabilities", [])), "", "## Entradas", listado(system.get("inputs", []), "description"), "", "## Salidas", listado(system.get("outputs", []), "description"), "", "## Sistemas Externos", listado(system.get("external_systems", [])), "", "## Almacenes de Datos", listado(system.get("datastores", [])), "", "## Procesos en Segundo Plano", listado(system.get("background_processes", [])), "", "## Despliegue", listado(system.get("deployment", {}).get("files", []), "path"), "", "## Observabilidad", listado(system.get("observability", {}).get("tools", [])), "", "## Pruebas", listado(context.get("tests", []), "path"), "", "## Por Dónde Empezar", "\n".join(f"- **{x['intent']}**: {', '.join(x['paths'])}" for x in system.get("start_here", [])) or "No se detectaron recomendaciones de onboarding con confianza.", "", "## Archivos Importantes", "\n".join(f"- `{x['path']}` — {x['role']}" for x in system.get("important_files", [])) or "No se detectaron archivos importantes.", "", "## Arquitectura Técnica", f"{context['architecture'].get('style') or 'Desconocida'} ({context['architecture'].get('status','UNKNOWN').lower()})", "", "## Incertidumbres", "\n".join(f"- {x}" for x in system.get("unknowns", [])) or "No hay incertidumbres relevantes registradas.", "", "## Instrucciones para el Agente", "1. Lee este contexto antes de explorar ampliamente el repositorio.", "2. Usa Graphify MCP para preguntas de dependencias y rutas de llamadas.", "3. Consulta paths o vecinos relevantes antes de abrir muchos archivos.", "4. Lee el código real antes de modificarlo.", "5. Trata el contexto inferido como guía, no como verdad absoluta.", "6. Prefiere los tests y puntos de entrada relevantes identificados por KlapContext."]
|
|
11
|
+
text="\n".join(lines).rstrip()+"\n"; return text+f"\nTokens estimados: {max(1, len(text.split()) * 4 // 3)}\n"
|
|
12
|
+
def write(context: dict, output: Path) -> str:
|
|
13
|
+
text=render(context); output.write_text(text, encoding="utf-8"); return text
|
klapcontext/cli.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import argparse, json, os, sys, webbrowser
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from . import __version__
|
|
6
|
+
from .agent_context import write as write_agent
|
|
7
|
+
from .context_builder import build
|
|
8
|
+
from .git import commit, dirty_files, exclude_klap, is_repository
|
|
9
|
+
from .graphify import GraphifyError, copy_outputs, generate, load_graph, version
|
|
10
|
+
from .portal import write as write_portal
|
|
11
|
+
|
|
12
|
+
def root_path(value: str | None) -> Path: return Path(value or os.getcwd()).resolve()
|
|
13
|
+
|
|
14
|
+
def generate_all(root: Path, update: bool=False) -> dict:
|
|
15
|
+
if not is_repository(root): raise RuntimeError(f"Not a Git repository: {root}")
|
|
16
|
+
exclude_klap(root); klap=root/".klap"; klap.mkdir(exist_ok=True)
|
|
17
|
+
graph_path=generate(root, update); files=copy_outputs(root, klap/"graphify")
|
|
18
|
+
context=build(root, load_graph(graph_path)); (klap/"context.json").write_text(json.dumps(context, indent=2)+"\n", encoding="utf-8")
|
|
19
|
+
agent=write_agent(context, klap/"agent-context.md"); write_portal(context, agent, files, klap/"index.html")
|
|
20
|
+
state={"schema_version":"0.1","generated_at":datetime.now(timezone.utc).isoformat(),"git_commit":commit(root),"graphify_version":version(),"klap_version":__version__}
|
|
21
|
+
(klap/"state.json").write_text(json.dumps(state, indent=2)+"\n", encoding="utf-8")
|
|
22
|
+
return context
|
|
23
|
+
|
|
24
|
+
def cmd_init(args):
|
|
25
|
+
root=root_path(args.path); print("KlapContext\n")
|
|
26
|
+
try:
|
|
27
|
+
context=generate_all(root)
|
|
28
|
+
except (RuntimeError, GraphifyError) as e:
|
|
29
|
+
print(f"✗ {e}", file=sys.stderr); return 1
|
|
30
|
+
print("✓ Git repository detected\n✓ Graphify available\n✓ Graph generated\n✓ Engineering context generated\n✓ Agent context generated\n✓ Human portal generated")
|
|
31
|
+
print(f"\nProject: {context['project']['name']}\nStack: {' / '.join(sum((v for v in context['stack'].values()), [])) or 'unknown'}\nContext: CURRENT\n\nHuman portal:\n.klap/index.html\n\nAgent context:\n.klap/agent-context.md")
|
|
32
|
+
return 0
|
|
33
|
+
|
|
34
|
+
def cmd_update(args):
|
|
35
|
+
try: generate_all(root_path(args.path), update=True)
|
|
36
|
+
except (RuntimeError, GraphifyError) as e: print(f"✗ {e}", file=sys.stderr); return 1
|
|
37
|
+
print("✓ KlapContext updated"); return 0
|
|
38
|
+
|
|
39
|
+
def cmd_status(args):
|
|
40
|
+
root=root_path(args.path); state_file=root/".klap"/"state.json"
|
|
41
|
+
if not state_file.exists(): print("⚠ No KlapContext found. Run: klap init"); return 1
|
|
42
|
+
state=json.loads(state_file.read_text(encoding="utf-8")); current=commit(root); dirty=dirty_files(root)
|
|
43
|
+
fresh=state.get("git_commit")==current and not dirty
|
|
44
|
+
print(f"Context commit: {(state.get('git_commit') or 'none')[:12]}\nCurrent commit: {(current or 'none')[:12]}\nUncommitted changes: {len(dirty)} files\n\nStatus: {'✓ CURRENT' if fresh else '⚠ STALE'}")
|
|
45
|
+
return 0 if fresh else 2
|
|
46
|
+
|
|
47
|
+
def cmd_open(args):
|
|
48
|
+
page=root_path(args.path)/".klap"/"index.html"
|
|
49
|
+
if not page.exists(): print("⚠ No human portal found. Run: klap init", file=sys.stderr); return 1
|
|
50
|
+
webbrowser.open(page.as_uri()); print(f"Opened {page}"); return 0
|
|
51
|
+
|
|
52
|
+
def cmd_agent(args):
|
|
53
|
+
graph=root_path(args.path)/".klap"/"graphify"/"graph.json"
|
|
54
|
+
if not graph.exists(): print("⚠ No Graphify graph found. Run: klap init", file=sys.stderr); return 1
|
|
55
|
+
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}")
|
|
56
|
+
return 0
|
|
57
|
+
|
|
58
|
+
def main(argv=None):
|
|
59
|
+
# PowerShell's legacy cp1252 console otherwise raises on the intended status symbols.
|
|
60
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
61
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
62
|
+
if hasattr(sys.stderr, "reconfigure"):
|
|
63
|
+
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
|
64
|
+
parser=argparse.ArgumentParser(prog="klap", description="Make your repository understandable to humans and AI.")
|
|
65
|
+
parser.add_argument("--version", action="version", version=__version__)
|
|
66
|
+
subs=parser.add_subparsers(dest="command", required=True)
|
|
67
|
+
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")):
|
|
68
|
+
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)
|
|
69
|
+
args=parser.parse_args(argv); return args.func(args)
|
|
70
|
+
|
|
71
|
+
if __name__ == "__main__": raise SystemExit(main())
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .detector import detect_project
|
|
8
|
+
from .evidence import Evidence
|
|
9
|
+
from .git import commit
|
|
10
|
+
from .system import deployment, documents, entry_points, external_systems, purpose
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _nodes(graph: dict) -> list[dict]:
|
|
14
|
+
return graph.get("nodes", []) if isinstance(graph.get("nodes"), list) else graph.get("graph", {}).get("nodes", [])
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _modules(nodes: list[dict]) -> list[dict]:
|
|
18
|
+
result=[]
|
|
19
|
+
for node in nodes:
|
|
20
|
+
name=node.get("label") or node.get("name") or node.get("id"); path=node.get("file") or node.get("path")
|
|
21
|
+
if name and path:
|
|
22
|
+
kind=node.get("type", "module")
|
|
23
|
+
result.append({"name":name,"path":path,"kind":kind,"description":f"Contains {str(kind).lower()} code related to {name}.","status":"CONFIRMED","evidence":[Evidence("graphify",str(path),"Graphify node", "CONFIRMED",1.0).as_dict()]})
|
|
24
|
+
return result[:100]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _runtime(points: list[dict], background: list[dict], framework: list[str]) -> dict:
|
|
28
|
+
modes=[]; sentences=[]
|
|
29
|
+
if any(p["type"] == "http" for p in points): modes.append("http"); sentences.append("Las solicitudes HTTP ingresan a través de declaraciones de rutas detectadas.")
|
|
30
|
+
if any(p["type"] == "cli" for p in points): modes.append("cli"); sentences.append("El repositorio expone puntos de entrada ejecutables por línea de comandos.")
|
|
31
|
+
if background: modes.append("scheduler"); sentences.append("La aplicación declara procesos programados en segundo plano.")
|
|
32
|
+
if framework: sentences.append(f"La capa de framework detectada es {', '.join(framework)}.")
|
|
33
|
+
return {"modes":modes,"layers":[],"description":" ".join(sentences) if sentences else None,"status":"CONFIRMED" if sentences else "UNKNOWN"}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _io(points: list[dict], systems: list[dict], stores: list[dict]) -> tuple[list[dict], list[dict]]:
|
|
37
|
+
inputs=[]; outputs=[]
|
|
38
|
+
if any(p["type"]=="http" for p in points): inputs.append({"type":"http_request","description":"Solicitudes HTTP","status":"CONFIRMED"}) ; outputs.append({"type":"http_response","description":"Respuestas HTTP","status":"INFERRED"})
|
|
39
|
+
if any(p["type"]=="cli" for p in points): inputs.append({"type":"cli","description":"Invocación por línea de comandos","status":"CONFIRMED"})
|
|
40
|
+
if stores: inputs.append({"type":"datastore","description":"Lecturas o escrituras de base de datos","status":"INFERRED"}); outputs.append({"type":"datastore","description":"Escrituras de base de datos","status":"INFERRED"})
|
|
41
|
+
for system in systems:
|
|
42
|
+
if system["type"] == "http_api":
|
|
43
|
+
outputs.append({"type":"external_api","description":system["name"],"status":"CONFIRMED"})
|
|
44
|
+
return inputs, outputs
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _flows(points: list[dict], modules: list[dict], systems: list[dict], stores: list[dict]) -> list[dict]:
|
|
48
|
+
flows=[]; targets=[m for m in modules if any(x in str(m["kind"]).lower() for x in ("controller","command","service"))]
|
|
49
|
+
for point in points:
|
|
50
|
+
if point["type"] not in {"http","scheduled"}: continue
|
|
51
|
+
steps=[{"name":point["name"],"kind":"trigger","path":point.get("path")}]
|
|
52
|
+
if point.get("target"): steps.append({"name":point["target"],"kind":"target","path":point.get("path")})
|
|
53
|
+
for module in targets[:2]:
|
|
54
|
+
if module["name"] != point.get("target"): steps.append({"name":module["name"],"kind":module["kind"],"path":module["path"]})
|
|
55
|
+
if stores: steps.append({"name":stores[0]["name"],"kind":"datastore","path":None})
|
|
56
|
+
elif systems: steps.append({"name":systems[0]["name"],"kind":"external_system","path":None})
|
|
57
|
+
if len(steps) > 1:
|
|
58
|
+
flows.append({"name":point["name"],"trigger":point["type"],"entry_point":point["name"],"steps":steps,"inputs":[],"outputs":[],"external_systems":[x["name"] for x in systems],"datastores":[x["name"] for x in stores],"tests":[],"status":"INFERRED","confidence":None,"evidence":point["evidence"]})
|
|
59
|
+
return flows[:3]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _important(root: Path, points: list[dict], deploy: dict, docs: list[dict]) -> list[dict]:
|
|
63
|
+
result=[]
|
|
64
|
+
for filename, role in (("pyproject.toml","Manifiesto de dependencias"),("package.json","Manifiesto de dependencias"),("composer.json","Manifiesto de dependencias"),("Dockerfile","Build de contenedor"),("docker-compose.yml","Topología de contenedores"),("README.md","Documentación del proyecto")):
|
|
65
|
+
if (root/filename).exists(): result.append({"path":filename,"role":role,"reason":f"{role} detected"})
|
|
66
|
+
for point in points:
|
|
67
|
+
if point["path"] not in {x["path"] for x in result}: result.append({"path":point["path"],"role":f"{point['type']} entry points","reason":"Contains detected entry point"})
|
|
68
|
+
return result[:20]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _start_here(points: list[dict], background: list[dict], modules: list[dict], systems: list[dict], important: list[dict]) -> list[dict]:
|
|
72
|
+
result=[]
|
|
73
|
+
http=[p for p in points if p["type"]=="http"]
|
|
74
|
+
if http: result.append({"intent":"Entender la API HTTP","paths":sorted({p["path"] for p in http}),"reason":"Contiene puntos de entrada HTTP confirmados"})
|
|
75
|
+
if background: result.append({"intent":"Entender procesos en segundo plano","paths":sorted({p["path"] for p in background}),"reason":"Contiene procesos programados"})
|
|
76
|
+
if systems: result.append({"intent":"Entender integraciones","paths":sorted({p for s in systems for p in s["configuration"]}),"reason":"Contiene configuración o uso de sistemas externos"})
|
|
77
|
+
manifests=[x["path"] for x in important if x["role"]=="Dependency manifest"]
|
|
78
|
+
if manifests: result.append({"intent":"Entender la configuración del proyecto","paths":manifests,"reason":"Manifiestos de dependencias"})
|
|
79
|
+
return result
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _story(purpose_value: dict, runtime: dict, systems: list[dict], stores: list[dict], deploy: dict) -> dict:
|
|
83
|
+
sentences=[]
|
|
84
|
+
if purpose_value["text"]: sentences.append(purpose_value["text"].rstrip("." ) + ".")
|
|
85
|
+
if runtime["description"]: sentences.append(runtime["description"])
|
|
86
|
+
if systems: sentences.append("Los sistemas externos detectados incluyen " + ", ".join(x["name"] for x in systems) + ".")
|
|
87
|
+
if stores: sentences.append("Los almacenes de datos detectados incluyen " + ", ".join(x["name"] for x in stores) + ".")
|
|
88
|
+
if "Docker" in deploy["tools"]: sentences.append("La aplicación está contenerizada con Docker.")
|
|
89
|
+
return {"text":" ".join(sentences) if sentences else None,"status":"CONFIRMED" if sentences else "UNKNOWN"}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def build(root: Path, graph: dict) -> dict:
|
|
93
|
+
stack, stack_evidence=detect_project(root); docs=documents(root); purpose_value=purpose(root, docs)
|
|
94
|
+
points, background=entry_points(root); systems, stores=external_systems(root); deploy, observability, _=deployment(root)
|
|
95
|
+
modules=_modules(_nodes(graph)); runtime=_runtime(points, background, stack["frameworks"]); inputs, outputs=_io(points, systems, stores)
|
|
96
|
+
flows=_flows(points, modules, systems, stores); important=_important(root, points, deploy, docs); start=_start_here(points, background, modules, systems, important)
|
|
97
|
+
architecture={"style":None,"status":"UNKNOWN","confidence":None,"evidence":[]}
|
|
98
|
+
if "Laravel" in stack["frameworks"]: architecture={"style":"MVC","status":"INFERRED","confidence":None,"evidence":[Evidence("file","composer.json","Laravel convention suggests MVC","INFERRED",.8).as_dict()]}
|
|
99
|
+
tests=[{"path":path,"reason":"Test configuration or directory detected"} for path in ("tests","test","phpunit.xml","pytest.ini") if (root/path).exists()]
|
|
100
|
+
name=root.name
|
|
101
|
+
technical={"languages":stack["languages"],"frameworks":stack["frameworks"],"dependencies":[],"graph":{"nodes":len(_nodes(graph)),"edges":len(graph.get("edges", graph.get("graph",{}).get("edges",[])))},"documents":[{"path":d["path"]} for d in docs],"tests":tests,"infrastructure":stack["infrastructure"]}
|
|
102
|
+
system={"purpose":purpose_value,"project_story":_story(purpose_value,runtime,systems,stores,deploy),"runtime":runtime,"entry_points":points,"main_flows":flows,"capabilities":modules,"inputs":inputs,"outputs":outputs,"external_systems":systems,"background_processes":background,"datastores":stores,"deployment":deploy,"observability":observability,"important_files":important,"start_here":start,"unknowns":[label for label,value in (("Propósito de negocio del proyecto",purpose_value["text"]),("Modelo de ejecución",runtime["description"]),("Plataforma de despliegue en producción",deploy["tools"])) if not value]}
|
|
103
|
+
project={"name":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)}
|
|
104
|
+
# Keep v0.1 top-level fields for consumers while promoting the separated model.
|
|
105
|
+
return {"schema_version":"0.2","project":project,"technical_model":technical,"system_model":system,"human_context":{"purpose":None,"users":None,"important_processes":[],"notes":[]},"stack":stack,"architecture":architecture,"entry_points":points,"modules":modules,"flows":flows,"api":[],"jobs":background,"datastores":stores,"external_systems":systems,"deployment":deploy,"observability":observability,"tests":tests,"evidence":[x.as_dict() for x in stack_evidence]+purpose_value["evidence"]}
|
klapcontext/detector.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .evidence import Evidence
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def detect_project(root: Path) -> tuple[dict, list[Evidence]]:
|
|
10
|
+
evidence: list[Evidence] = []
|
|
11
|
+
stack = {"languages": [], "frameworks": [], "databases": [], "runtime": [], "infrastructure": []}
|
|
12
|
+
def add(bucket: str, value: str, path: str, reason: str) -> None:
|
|
13
|
+
if value not in stack[bucket]: stack[bucket].append(value)
|
|
14
|
+
evidence.append(Evidence("file", path, reason, "CONFIRMED", 1.0))
|
|
15
|
+
composer = root / "composer.json"
|
|
16
|
+
if composer.exists():
|
|
17
|
+
add("languages", "PHP", "composer.json", "Composer manifest detected")
|
|
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
|
+
except json.JSONDecodeError: deps = {}
|
|
20
|
+
if "laravel/framework" in deps: add("frameworks", "Laravel", "composer.json", "laravel/framework dependency detected")
|
|
21
|
+
package = root / "package.json"
|
|
22
|
+
if package.exists():
|
|
23
|
+
add("languages", "JavaScript", "package.json", "npm manifest detected")
|
|
24
|
+
try: deps = {**json.loads(package.read_text(encoding="utf-8")).get("dependencies", {}), **json.loads(package.read_text(encoding="utf-8")).get("devDependencies", {})}
|
|
25
|
+
except json.JSONDecodeError: deps = {}
|
|
26
|
+
for name, label in (("next", "Next.js"), ("vue", "Vue"), ("react", "React"), ("express", "Express")):
|
|
27
|
+
if name in deps: add("frameworks", label, "package.json", f"{name} dependency detected")
|
|
28
|
+
if (root / "pyproject.toml").exists() or (root / "requirements.txt").exists(): add("languages", "Python", "pyproject.toml" if (root / "pyproject.toml").exists() else "requirements.txt", "Python manifest detected")
|
|
29
|
+
if (root / "manage.py").exists(): add("frameworks", "Django", "manage.py", "Django entry point detected")
|
|
30
|
+
if list(root.glob("*.csproj")): add("languages", "C#", next(root.glob("*.csproj")).name, ".NET project detected")
|
|
31
|
+
if (root / "Dockerfile").exists(): add("infrastructure", "Docker", "Dockerfile", "Dockerfile detected")
|
|
32
|
+
if list(root.glob("docker-compose*")) or list(root.glob("compose*")): add("infrastructure", "Docker Compose", "docker-compose", "Compose configuration detected")
|
|
33
|
+
if (root / ".github" / "workflows").exists(): add("infrastructure", "GitHub Actions", ".github/workflows", "GitHub Actions workflows detected")
|
|
34
|
+
return stack, evidence
|
klapcontext/evidence.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from dataclasses import asdict, dataclass
|
|
3
|
+
|
|
4
|
+
@dataclass(frozen=True)
|
|
5
|
+
class Evidence:
|
|
6
|
+
source_type: str
|
|
7
|
+
path: str
|
|
8
|
+
reason: str
|
|
9
|
+
status: str = "CONFIRMED"
|
|
10
|
+
confidence: float = 1.0
|
|
11
|
+
def as_dict(self): return asdict(self)
|
klapcontext/git.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def run_git(root: Path, *args: str) -> str | None:
|
|
8
|
+
result = subprocess.run(["git", *args], cwd=root, text=True, capture_output=True)
|
|
9
|
+
return result.stdout.strip() if result.returncode == 0 else None
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def is_repository(root: Path) -> bool:
|
|
13
|
+
return run_git(root, "rev-parse", "--is-inside-work-tree") == "true"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def commit(root: Path) -> str | None:
|
|
17
|
+
return run_git(root, "rev-parse", "HEAD")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def dirty_files(root: Path) -> list[str]:
|
|
21
|
+
output = run_git(root, "status", "--porcelain") or ""
|
|
22
|
+
return [line[3:] for line in output.splitlines() if line]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def exclude_klap(root: Path) -> None:
|
|
26
|
+
git_dir = run_git(root, "rev-parse", "--git-dir")
|
|
27
|
+
if not git_dir:
|
|
28
|
+
raise RuntimeError("Not a Git repository")
|
|
29
|
+
exclude = (root / git_dir / "info" / "exclude").resolve()
|
|
30
|
+
exclude.parent.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
existing = exclude.read_text(encoding="utf-8") if exclude.exists() else ""
|
|
32
|
+
additions = [entry for entry in (".klap/", "graphify-out/") if entry not in {line.strip() for line in existing.splitlines()}]
|
|
33
|
+
if additions:
|
|
34
|
+
exclude.write_text(existing.rstrip() + ("\n" if existing.strip() else "") + "\n".join(additions) + "\n", encoding="utf-8")
|
klapcontext/graphify.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import importlib.metadata, json, shutil, subprocess, sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
class GraphifyError(RuntimeError): pass
|
|
6
|
+
|
|
7
|
+
def executable() -> str | None: return shutil.which("graphify")
|
|
8
|
+
|
|
9
|
+
def command_prefix() -> list[str] | None:
|
|
10
|
+
"""Use the console script when present; PyPI's current package also supports python -m graphify."""
|
|
11
|
+
if executable(): return [executable()]
|
|
12
|
+
try:
|
|
13
|
+
import graphify # noqa: F401
|
|
14
|
+
return [sys.executable, "-m", "graphify"]
|
|
15
|
+
except ImportError: return None
|
|
16
|
+
|
|
17
|
+
def version() -> str | None:
|
|
18
|
+
try: return importlib.metadata.version("graphifyy")
|
|
19
|
+
except importlib.metadata.PackageNotFoundError: return None
|
|
20
|
+
|
|
21
|
+
def generate(root: Path, update: bool = False) -> Path:
|
|
22
|
+
prefix = command_prefix()
|
|
23
|
+
if not prefix: raise GraphifyError("Graphify is required. Install it with: pipx install graphifyy")
|
|
24
|
+
# Graphify owns parsing/incrementality; --code-only keeps KlapContext's base fully local/no-LLM.
|
|
25
|
+
command = prefix + (["update", str(root)] if update else ["extract", str(root), "--code-only"])
|
|
26
|
+
result = subprocess.run(command, cwd=root, text=True, capture_output=True)
|
|
27
|
+
# Some current Windows Graphify builds can abort in `update`; retain its
|
|
28
|
+
# incremental fast path but recover with the documented deterministic scan.
|
|
29
|
+
if update and result.returncode != 0:
|
|
30
|
+
result = subprocess.run(prefix + ["extract", str(root), "--code-only"], cwd=root, text=True, capture_output=True)
|
|
31
|
+
graph = root / "graphify-out" / "graph.json"
|
|
32
|
+
if result.returncode != 0 or not graph.exists():
|
|
33
|
+
message = result.stderr.strip() or result.stdout.strip() or "Graphify did not create graph.json"
|
|
34
|
+
raise GraphifyError(message)
|
|
35
|
+
# Visualization exporters are Graphify's implementation, not a KlapContext reimplementation.
|
|
36
|
+
for export in (["export", "html", "--graph", str(graph)], ["export", "callflow-html", str(graph)]):
|
|
37
|
+
subprocess.run(prefix + export, cwd=root, text=True, capture_output=True)
|
|
38
|
+
return graph
|
|
39
|
+
|
|
40
|
+
def load_graph(graph: Path) -> dict:
|
|
41
|
+
try: return json.loads(graph.read_text(encoding="utf-8"))
|
|
42
|
+
except (OSError, json.JSONDecodeError): return {}
|
|
43
|
+
|
|
44
|
+
def copy_outputs(root: Path, destination: Path) -> list[str]:
|
|
45
|
+
import shutil
|
|
46
|
+
source = root / "graphify-out"; destination.mkdir(parents=True, exist_ok=True); copied=[]
|
|
47
|
+
for name in ("graph.json", "graph.html", "GRAPH_REPORT.md"):
|
|
48
|
+
if (source/name).exists(): shutil.copy2(source/name, destination/name); copied.append(name)
|
|
49
|
+
callflows = list(source.glob("*-callflow.html")) + list(source.glob("callflow.html"))
|
|
50
|
+
if callflows:
|
|
51
|
+
shutil.copy2(callflows[0], destination/"callflow.html"); copied.append("callflow.html")
|
|
52
|
+
return copied
|
klapcontext/portal.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import html, json
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
def e(x): return html.escape(str(x))
|
|
6
|
+
def rows(items, key="name"):
|
|
7
|
+
return "".join(f"<li><i></i><code>{e(x.get(key) or x.get('path'))}</code><small>{e(x.get('type') or x.get('role') or '')}</small></li>" for x in items)
|
|
8
|
+
def panel(title, content, tone=""):
|
|
9
|
+
return f"<section class='panel {tone}'><header><h2>{title}</h2></header>{content}</section>" if content else ""
|
|
10
|
+
|
|
11
|
+
def render(context: dict, agent_text: str, graphify_files: list[str]) -> str:
|
|
12
|
+
p=context["project"]; s=context.get("system_model", {}); stack=[v for values in context["stack"].values() for v in values]
|
|
13
|
+
purpose=s.get("purpose",{}).get("text") or "No fue posible determinar el propósito del proyecto con confianza."
|
|
14
|
+
story=s.get("project_story",{}).get("text") or s.get("runtime",{}).get("description") or "No fue posible determinar cómo funciona el sistema con confianza."
|
|
15
|
+
points=s.get("entry_points",[]); flows=s.get("main_flows",[]); unknowns=s.get("unknowns",[])
|
|
16
|
+
tags="".join(f"<span>{e(x)}</span>" for x in stack) or "<span>Stack desconocido</span>"
|
|
17
|
+
flow_html="".join(f"<article class='flow'><b>{e(x['name'])}</b><p>{' <em>→</em> '.join(e(step['name']) for step in x['steps'])}</p></article>" for x in flows) or "<p class='empty'>No se detectaron flujos principales con confianza.</p>"
|
|
18
|
+
start="".join(f"<li><b>{e(x['intent'])}</b><span>{e(' · '.join(x['paths']))}</span></li>" for x in s.get("start_here",[])) or "<li class='empty'>No se detectaron recomendaciones de onboarding.</li>"
|
|
19
|
+
evidence="".join(f"<li><i></i><div><code>{e(x['path'])}</code><p>{e(x['reason'])}</p></div><b>{e(x['status'])}</b></li>" for x in context.get("evidence",[]))
|
|
20
|
+
exploration="".join(f"<a href='graphify/{name}'>{label}<b>↗</b></a>" for name,label in (("graph.html","Abrir grafo técnico"),("callflow.html","Abrir call flows"),("GRAPH_REPORT.md","Abrir informe técnico")) if name in graphify_files)
|
|
21
|
+
body=[panel("Qué es este proyecto",f"<p class='purpose'>{e(purpose)}</p><p class='story'>{e(story)}</p>","intro"),panel("Flujos principales",f"<div class='flows'>{flow_html}</div>"),panel("Puntos de entrada",f"<ul class='list'>{rows(points)}</ul>" if points else "<p class='empty'>No se detectaron puntos de entrada.</p>"),panel("Capacidades",f"<ul class='list'>{rows(s.get('capabilities',[]))}</ul>" if s.get('capabilities') else ""),panel("Entradas y salidas",f"<div class='duo'><div><label>ENTRADAS</label><ul class='list'>{rows(s.get('inputs',[]),'description')}</ul></div><div><label>SALIDAS</label><ul class='list'>{rows(s.get('outputs',[]),'description')}</ul></div></div>" if s.get('inputs') or s.get('outputs') else ""),panel("Sistemas externos",f"<ul class='list'>{rows(s.get('external_systems',[]))}</ul>" if s.get('external_systems') else ""),panel("Procesos en segundo plano",f"<ul class='list'>{rows(s.get('background_processes',[]))}</ul>" if s.get('background_processes') else ""),panel("Despliegue",f"<ul class='list'>{rows(s.get('deployment',{}).get('files',[]),'path')}</ul>" if s.get('deployment',{}).get('files') else ""),panel("Por dónde empezar",f"<ul class='start'>{start}</ul>"),panel("Lo que no pudimos determinar","<ul class='list'>"+"".join(f"<li><i></i><code>{e(x)}</code></li>" for x in unknowns)+"</ul>" if unknowns else ""),panel("Evidencia",f"<ul class='evidence'>{evidence}</ul>" if evidence else ""),panel("Exploración técnica",f"<div class='links'>{exploration}</div>" if exploration else "")]
|
|
22
|
+
agent=json.dumps(agent_text).replace("</","<\\/")
|
|
23
|
+
return f"""<!doctype html><html lang='es'><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'><title>KlapContext · {e(p['name'])}</title><style>
|
|
24
|
+
@import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Manrope:wght@400;500;600;700;800&display=swap');:root{{--bg:#080d19;--bar:#10182b;--card:#111b30;--line:#20324e;--ink:#edf4ff;--muted:#8596b4;--cyan:#16dbc3;--violet:#8476ff;--amber:#ffb84c}}*{{box-sizing:border-box}}body{{margin:0;background:var(--bg);color:var(--ink);font:14px/1.55 Manrope,sans-serif;background-image:linear-gradient(#ffffff07 1px,transparent 1px),linear-gradient(90deg,#ffffff07 1px,transparent 1px);background-size:32px 32px}}.top{{height:58px;display:flex;align-items:center;gap:15px;padding:0 22px;border-bottom:1px solid var(--line);background:#0b1222ee;position:sticky;top:0;z-index:5}}.logo{{display:flex;gap:8px;align-items:center;font-weight:800;letter-spacing:.08em;font-size:12px}}.logo b{{display:grid;place-items:center;width:26px;height:26px;border-radius:7px;background:linear-gradient(135deg,var(--cyan),var(--violet));color:#081323}}.repo{{padding:6px 10px;border-radius:5px;background:#141f36;color:#ced9f1;font:11px 'DM Mono'}}.top .state{{margin-left:auto;color:var(--cyan);font:10px 'DM Mono'}}.top button{{border:1px solid #35d7c780;border-radius:6px;background:#12bfae;color:#041a1c;padding:7px 10px;font:700 11px Manrope;cursor:pointer}}.app{{max-width:1440px;margin:auto;display:grid;grid-template-columns:210px minmax(0,1fr) 290px;min-height:calc(100vh - 58px)}}.side{{padding:18px 14px;border-right:1px solid var(--line);background:#0a1120cc}}.score{{padding:15px;border:1px solid var(--line);border-left:3px solid var(--cyan);border-radius:8px;background:var(--bar)}}.score b{{display:block;font-size:25px;letter-spacing:-.07em}}.score span{{color:var(--muted);font:10px 'DM Mono'}}.side h3{{margin:25px 8px 8px;color:var(--cyan);font:10px 'DM Mono';letter-spacing:.12em}}.side a{{display:block;padding:7px 8px;color:var(--muted);font-size:12px;text-decoration:none;border-radius:5px}}.side a:hover{{color:var(--ink);background:#182440}}main{{padding:20px;min-width:0}}.hero{{padding:27px 29px;border:1px solid var(--line);border-radius:11px;background:linear-gradient(120deg,#17264a,#111a30);position:relative;overflow:hidden}}.hero:after{{content:'';position:absolute;right:-80px;top:-115px;width:280px;height:280px;border:1px solid #16dbc344;border-radius:50%;box-shadow:0 0 0 35px #16dbc308,0 0 0 70px #8476ff08}}.eyebrow,label{{color:var(--cyan);font:10px 'DM Mono';letter-spacing:.13em}}h1{{position:relative;margin:7px 0;font-size:clamp(30px,4vw,47px);letter-spacing:-.06em;line-height:1.05}}.hero p{{position:relative;margin:0;color:#b8c6e0;max-width:650px}}.tags{{position:relative;margin-top:18px;display:flex;gap:7px;flex-wrap:wrap}}.tags span{{font:10px 'DM Mono';padding:4px 7px;border:1px solid #476189;border-radius:4px}}.stats{{display:grid;grid-template-columns:repeat(3,1fr);gap:9px;margin:13px 0}}.stat{{padding:11px;border:1px solid var(--line);border-radius:7px;background:#0c1527}}.stat b{{display:block;font-size:20px}}.stat span{{font-size:10px;color:var(--muted)}}.panels{{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}}.panel{{padding:18px;border:1px solid var(--line);border-radius:9px;background:#101a2edb}}.panel.intro,.panel:nth-last-child(-n+3){{grid-column:1/-1}}header{{display:flex;justify-content:space-between;margin-bottom:11px}}h2{{margin:0;font-size:14px}}.purpose{{margin:0;color:#e4ecfa;font-size:16px}}.story{{margin:9px 0 0;color:var(--muted)}}.list,.evidence,.start{{list-style:none;margin:0;padding:0}}.list li{{display:grid;grid-template-columns:8px 1fr auto;align-items:center;gap:7px;padding:7px 0;border-bottom:1px solid #20304a}}.list li:last-child{{border:0}}i{{display:block;width:5px;height:5px;border-radius:50%;background:var(--violet);box-shadow:0 0 8px var(--violet)}}code{{font:11px 'DM Mono';color:#d9e5ff;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}small{{font:10px 'DM Mono';color:var(--muted)}}.empty{{color:var(--muted);font-size:12px}}.flows{{display:grid;gap:8px}}.flow{{padding:11px;border:1px solid #28425d;border-radius:6px;background:#091323}}.flow b{{font-size:12px}}.flow p{{margin:5px 0 0;font:11px/1.75 'DM Mono';color:#c8d6f1}}.flow em{{color:var(--cyan);font-style:normal}}.duo{{display:grid;grid-template-columns:1fr 1fr;gap:18px}}.start li{{padding:8px 0;border-bottom:1px solid var(--line)}}.start b{{display:block;font-size:12px}}.start span{{color:var(--muted);font:10px 'DM Mono'}}.evidence li{{display:flex;gap:8px;padding:8px 0;border-bottom:1px solid var(--line)}}.evidence p{{margin:1px 0;color:var(--muted);font-size:10px}}.evidence li>b{{margin-left:auto;color:var(--cyan);font:10px 'DM Mono'}}.links{{display:flex;gap:8px;flex-wrap:wrap}}.links a{{border:1px solid #2f5d7c;border-radius:5px;padding:8px 10px;text-decoration:none;font:11px 'DM Mono';background:#0b1426}}.links a:hover{{border-color:var(--cyan)}}.right{{padding:20px 14px;border-left:1px solid var(--line);background:#0a1120cc}}.right h3{{font-size:12px;margin:0 0 11px}}.callout{{padding:14px;border:1px solid #6859bb;border-radius:8px;background:linear-gradient(145deg,#28205a,#101b33)}}.callout p{{color:#bac6e0;font-size:12px}}.callout button{{width:100%;border:0;border-radius:5px;padding:9px;background:var(--cyan);font-weight:800;cursor:pointer}}.right .note{{margin-top:14px;padding:12px;border-left:2px solid var(--amber);background:#171827;color:var(--muted);font-size:11px}}@media(max-width:1000px){{.app{{grid-template-columns:185px 1fr}}.right{{display:none}}}}@media(max-width:680px){{.app{{display:block}}.side{{display:none}}main{{padding:12px}}.panels,.duo{{grid-template-columns:1fr}}.panel{{grid-column:auto!important}}.top .repo{{display:none}}.hero{{padding:23px}}}}
|
|
25
|
+
</style><body><nav class='top'><div class='logo'><b>K</b>KLAPCONTEXT</div><span class='repo'>{e(p['name'])}</span><span class='state'>● {'ACTUAL' if p.get('git_commit') else 'SIN COMMIT'}</span><button onclick='copyContext(this)'>Copiar contexto</button></nav><div class='app'><aside class='side'><div class='score'><b>{len(points)}<small> / {len(context.get('evidence',[]))}</small></b><span>PUNTOS / EVIDENCIAS</span></div><h3>NAVEGACIÓN</h3><a href='#sistema'>Sistema</a><a href='#flujos'>Flujos</a><a href='#entradas'>Entradas</a><a href='#evidencia'>Evidencia</a><a href='#tecnico'>Graphify</a></aside><main><section class='hero'><div class='eyebrow'>COMPRENDER EL SISTEMA</div><h1>{e(p['name'])}</h1><p>Un mapa de ingeniería basado en evidencia para personas y agentes de IA.</p><div class='tags'>{tags}</div></section><div class='stats'><div class='stat'><b>{len(points)}</b><span>puntos de entrada</span></div><div class='stat'><b>{len(flows)}</b><span>flujos principales</span></div><div class='stat'><b>{len(context.get('evidence',[]))}</b><span>hechos confirmados</span></div></div><div class='panels'>{''.join(body)}</div></main><aside class='right'><h3>CONTEXTO PARA AGENTE</h3><section class='callout'><strong>Llevate el mapa</strong><p>Un briefing compacto con propósito, flujos, entry points e incertidumbres.</p><button onclick='copyContext(this)'>Copiar contexto</button></section><div class='note'>KlapContext distingue hechos confirmados de inferencias. Si no hay evidencia, lo indica explícitamente.</div></aside></div><script>const agentContext={agent};function copyContext(b){{navigator.clipboard.writeText(agentContext).then(()=>{{const old=b.textContent;b.textContent='Copiado';setTimeout(()=>b.textContent=old,1500)}})}}</script></body></html>"""
|
|
26
|
+
def write(context: dict, agent_text: str, graphify_files: list[str], output: Path) -> None: output.write_text(render(context,agent_text,graphify_files),encoding='utf-8')
|
klapcontext/system.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Deterministic system-understanding heuristics, all backed by repository evidence."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .evidence import Evidence
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
TEXT_LIMIT = 180_000
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _read(path: Path) -> str:
|
|
15
|
+
try: return path.read_text(encoding="utf-8", errors="replace")[:TEXT_LIMIT]
|
|
16
|
+
except OSError: return ""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _ev(path: str, reason: str, status: str = "CONFIRMED", confidence: float = 1.0) -> dict:
|
|
20
|
+
return Evidence("file", path, reason, status, confidence).as_dict()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def documents(root: Path) -> list[dict]:
|
|
24
|
+
candidates = [*root.glob("README*"), *root.glob("CONTRIBUTING*"), *root.glob("Makefile")]
|
|
25
|
+
for folder in ("docs", "doc", "ADR", "architecture"):
|
|
26
|
+
directory = root / folder
|
|
27
|
+
if directory.is_dir(): candidates.extend(p for p in directory.rglob("*.md") if p.is_file())
|
|
28
|
+
seen, result = set(), []
|
|
29
|
+
for path in candidates:
|
|
30
|
+
relative = str(path.relative_to(root))
|
|
31
|
+
if relative in seen or path.stat().st_size > TEXT_LIMIT: continue
|
|
32
|
+
seen.add(relative)
|
|
33
|
+
result.append({"path": relative, "content": _read(path)})
|
|
34
|
+
return result
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def purpose(root: Path, docs: list[dict]) -> dict:
|
|
38
|
+
def localize(text: str) -> str:
|
|
39
|
+
# Product metadata can be English even when this local context is emitted in Spanish.
|
|
40
|
+
return "Hace que tu repositorio sea comprensible para humanos e IA." if text.strip() == "Make your repository understandable to humans and AI." else text
|
|
41
|
+
package = root / "package.json"; pyproject = root / "pyproject.toml"
|
|
42
|
+
if package.exists():
|
|
43
|
+
try:
|
|
44
|
+
description = json.loads(_read(package)).get("description")
|
|
45
|
+
if description: return {"text": localize(description), "status": "CONFIRMED", "evidence": [_ev("package.json", "Package description")]}
|
|
46
|
+
except json.JSONDecodeError: pass
|
|
47
|
+
if pyproject.exists():
|
|
48
|
+
match = re.search(r'^description\s*=\s*["\'](.+?)["\']', _read(pyproject), re.M)
|
|
49
|
+
if match: return {"text": localize(match.group(1)), "status": "CONFIRMED", "evidence": [_ev("pyproject.toml", "Project description")]}
|
|
50
|
+
for doc in docs:
|
|
51
|
+
if Path(doc["path"]).name.lower().startswith("readme"):
|
|
52
|
+
paragraphs = [re.sub(r"\s+", " ", p).strip() for p in re.split(r"\n\s*\n", doc["content"])]
|
|
53
|
+
for paragraph in paragraphs:
|
|
54
|
+
if paragraph and not paragraph.startswith("#") and len(paragraph) >= 30 and not paragraph.startswith("```"):
|
|
55
|
+
return {"text": paragraph[:400], "status": "CONFIRMED", "evidence": [_ev(doc["path"], "README project description")]}
|
|
56
|
+
return {"text": None, "status": "UNKNOWN", "evidence": []}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def entry_points(root: Path) -> tuple[list[dict], list[dict]]:
|
|
60
|
+
points, background = [], []
|
|
61
|
+
def add(kind, name, path, target=None, schedule=None, reason="Application entry point detected"):
|
|
62
|
+
evidence=[_ev(path, reason)]
|
|
63
|
+
point={"type":kind,"name":name,"path":path,"target":target,"schedule":schedule,"status":"CONFIRMED","evidence":evidence}
|
|
64
|
+
points.append(point); return point
|
|
65
|
+
# Laravel routes and scheduler.
|
|
66
|
+
for route_file in (root / "routes").glob("*.php") if (root / "routes").is_dir() else []:
|
|
67
|
+
text=_read(route_file); rel=str(route_file.relative_to(root))
|
|
68
|
+
for method, uri, target in re.findall(r"Route::(get|post|put|patch|delete|any)\s*\(\s*['\"]([^'\"]+)['\"]\s*,\s*([^\)]+)", text, re.I):
|
|
69
|
+
add("http", f"{method.upper()} /{uri.lstrip('/')}", rel, target.strip(), reason="Laravel route declaration")
|
|
70
|
+
for rel in ("routes/console.php", "app/Console/Kernel.php"):
|
|
71
|
+
if not (root/rel).exists(): continue
|
|
72
|
+
text=_read(root/rel)
|
|
73
|
+
for command, frequency in re.findall(r"(?:command|job)\s*\(\s*['\"]([^'\"]+)['\"]\s*\).*?->(every\w+|daily|hourly|weekly)\s*\(", text, re.S):
|
|
74
|
+
point=add("scheduled", command, rel, schedule=frequency, reason="Laravel scheduler declaration")
|
|
75
|
+
background.append({"name":command,"type":"scheduled_command","schedule":frequency,"path":rel,"status":"CONFIRMED","evidence":point["evidence"]})
|
|
76
|
+
# Python HTTP decorators and CLI bootstrap.
|
|
77
|
+
for path in root.rglob("*.py"):
|
|
78
|
+
if any(part in {".git", ".klap", "graphify-out", "venv", ".venv"} for part in path.parts) or path.name == "system.py": continue
|
|
79
|
+
text=_read(path); rel=str(path.relative_to(root))
|
|
80
|
+
for method, uri, function in re.findall(r"@\w+\.(get|post|put|patch|delete)\s*\(\s*['\"]([^'\"]+)['\"]\s*\)\s*\n\s*(?:async\s+)?def\s+(\w+)", text, re.I):
|
|
81
|
+
add("http", f"{method.upper()} {uri}", rel, function, reason="Python web route decorator")
|
|
82
|
+
if "if __name__ == \"__main__\"" in text or "if __name__ == '__main__'" in text:
|
|
83
|
+
add("cli", path.stem, rel, reason="Python executable module")
|
|
84
|
+
# Express route declarations.
|
|
85
|
+
for path in root.rglob("*.js"):
|
|
86
|
+
if any(part in {"node_modules", ".git", ".klap", "graphify-out"} for part in path.parts): continue
|
|
87
|
+
rel=str(path.relative_to(root)); text=_read(path)
|
|
88
|
+
for method, uri in re.findall(r"\.(get|post|put|patch|delete)\s*\(\s*['\"]([^'\"]+)", text, re.I): add("http", f"{method.upper()} {uri}", rel, reason="JavaScript HTTP route declaration")
|
|
89
|
+
return points[:50], background[:30]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def external_systems(root: Path) -> tuple[list[dict], list[dict]]:
|
|
93
|
+
found, stores = [], []
|
|
94
|
+
patterns = [(r"\bredis\b", "Redis", "redis"), (r"\brabbitmq\b|\bamqp\b", "RabbitMQ", "message broker"), (r"\bkafka\b", "Kafka", "message broker"), (r"\bsentry\b", "Sentry", "error tracking"), (r"\bsmtp\b|\bmailgun\b", "SMTP", "email"), (r"\bsftp\b", "SFTP", "sftp"), (r"\bftp\b", "FTP", "ftp")]
|
|
95
|
+
store_patterns = [(r"\bmysql\b", "MySQL"), (r"\bpostgres(?:ql)?\b", "PostgreSQL"), (r"\bsqlsrv\b|sql server", "SQL Server"), (r"\bsqlite\b", "SQLite"), (r"\bmongodb\b", "MongoDB")]
|
|
96
|
+
candidates=[p for p in root.rglob("*") if p.is_file() and p.suffix.lower() in {".json", ".toml", ".yml", ".yaml", ".php", ".py", ".js", ".ts", ".env"}]
|
|
97
|
+
for path in candidates[:800]:
|
|
98
|
+
if any(part in {".git", ".klap", "graphify-out", "node_modules", "vendor", ".pytest_cache", "tests"} for part in path.parts) or path.name in {"system.py", "portal.py"}: continue
|
|
99
|
+
text=_read(path); rel=str(path.relative_to(root))
|
|
100
|
+
for pattern,name,kind in patterns:
|
|
101
|
+
if re.search(pattern, text, re.I) and not any(x["name"] == name for x in found): found.append({"name":name,"type":kind,"purpose":None,"used_by":[],"configuration":[rel],"status":"CONFIRMED","evidence":[_ev(rel, f"{name} configuration or usage detected")]})
|
|
102
|
+
for pattern,name in store_patterns:
|
|
103
|
+
if re.search(pattern, text, re.I) and not any(x["name"] == name for x in stores): stores.append({"name":name,"connection":None,"used_by":[],"role":None,"status":"CONFIRMED","evidence":[_ev(rel, f"{name} configuration or usage detected")]})
|
|
104
|
+
for host in re.findall(r"https?://([a-zA-Z0-9.-]+)", text):
|
|
105
|
+
if host not in {"localhost", "example.com"} and not any(x["name"] == host for x in found): found.append({"name":host,"type":"http_api","purpose":None,"used_by":[],"configuration":[rel],"status":"CONFIRMED","evidence":[_ev(rel, "External HTTP host configured or referenced")]})
|
|
106
|
+
return found, stores
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def deployment(root: Path) -> tuple[dict, dict, list[dict]]:
|
|
110
|
+
files=[]; tools=[]; ports=[]; observability=[]
|
|
111
|
+
for name, label in (("Dockerfile","Docker"),("docker-compose.yml","Docker Compose"),("docker-compose.yaml","Docker Compose"),("compose.yml","Docker Compose"),("compose.yaml","Docker Compose"),("Jenkinsfile","Jenkins")):
|
|
112
|
+
if (root/name).exists(): files.append({"path":name,"role":label,"reason":f"{label} configuration"}); tools.append(label)
|
|
113
|
+
if (root/".github/workflows").is_dir(): files.append({"path":".github/workflows","role":"CI workflows","reason":"GitHub Actions workflow directory"}); tools.append("GitHub Actions")
|
|
114
|
+
for item in files:
|
|
115
|
+
text=_read(root/item["path"]) if (root/item["path"]).is_file() else ""
|
|
116
|
+
ports.extend(re.findall(r"(?:EXPOSE\s+|['\"]?)(\d{2,5})(?::\d{2,5})?", text))
|
|
117
|
+
for path in root.rglob("*"):
|
|
118
|
+
if path.is_file() and path.suffix.lower() in {".py", ".php", ".js", ".ts", ".json", ".yml", ".yaml"}:
|
|
119
|
+
if any(part in {".git", ".klap", "graphify-out", "node_modules", "vendor", ".pytest_cache", "tests"} for part in path.parts) or path.name == "system.py": continue
|
|
120
|
+
text=_read(path); rel=str(path.relative_to(root))
|
|
121
|
+
for term,name in (("prometheus","Prometheus"),("opentelemetry","OpenTelemetry"),("sentry","Sentry"),("/health","Health endpoint"),("/metrics","Metrics endpoint")):
|
|
122
|
+
if term in text.lower() and not any(x["name"]==name for x in observability): observability.append({"name":name,"path":rel,"status":"CONFIRMED","evidence":[_ev(rel, f"{name} reference detected")]})
|
|
123
|
+
return {"tools":tools,"ports":sorted(set(ports)),"files":files,"status":"CONFIRMED" if files else "UNKNOWN"}, {"tools":observability,"status":"CONFIRMED" if observability else "UNKNOWN"}, files
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: klapcontext
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Make your repository understandable to humans and AI.
|
|
5
|
+
Author: Willy Cordon
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/willy-cordon/klap_context
|
|
8
|
+
Project-URL: Repository, https://github.com/willy-cordon/klap_context
|
|
9
|
+
Project-URL: Issues, https://github.com/willy-cordon/klap_context/issues
|
|
10
|
+
Keywords: codebase,developer-tools,engineering-context,graphify,ai-agents
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Software Development :: Documentation
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: graphifyy>=0.9.66
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: build>=1.2; extra == "dev"
|
|
26
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
27
|
+
Requires-Dist: twine>=6; extra == "dev"
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# KlapContext
|
|
31
|
+
|
|
32
|
+
Make your repository understandable to humans and AI.
|
|
33
|
+
|
|
34
|
+
KlapContext convierte un repositorio en contexto de ingeniería reutilizable: un
|
|
35
|
+
portal estático para personas y un briefing compacto para agentes de IA. Usa
|
|
36
|
+
[Graphify](https://github.com/Graphify-Labs/graphify) como motor local de
|
|
37
|
+
análisis técnico; KlapContext no reimplementa su grafo ni sus parsers.
|
|
38
|
+
|
|
39
|
+
```text
|
|
40
|
+
Repositorio → KlapContext → Contexto de ingeniería
|
|
41
|
+
├── Portal humano
|
|
42
|
+
└── Contexto para agentes
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Instalación
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install klapcontext
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
La instalación incluye `graphifyy`, la distribución oficial de Graphify que
|
|
52
|
+
KlapContext necesita para generar el análisis local. Como alternativa para una
|
|
53
|
+
CLI aislada: `pipx install klapcontext`.
|
|
54
|
+
|
|
55
|
+
## Primer uso
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
cd mi-proyecto
|
|
59
|
+
klap init
|
|
60
|
+
klap open
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`klap init` genera el análisis Graphify y escribe artefactos locales bajo
|
|
64
|
+
`.klap/`. KlapContext agrega esa carpeta a `.git/info/exclude`; nunca modifica
|
|
65
|
+
tu `.gitignore`.
|
|
66
|
+
|
|
67
|
+
## Comandos
|
|
68
|
+
|
|
69
|
+
| Comando | Descripción |
|
|
70
|
+
| --- | --- |
|
|
71
|
+
| `klap init [ruta]` | Genera el contexto inicial. |
|
|
72
|
+
| `klap update [ruta]` | Actualiza Graphify y el contexto. |
|
|
73
|
+
| `klap status [ruta]` | Indica si el contexto está actualizado respecto a Git. |
|
|
74
|
+
| `klap open [ruta]` | Abre el portal humano estático. |
|
|
75
|
+
| `klap agent [ruta]` | Muestra la configuración MCP local de Graphify. |
|
|
76
|
+
| `klap --version` | Muestra la versión instalada. |
|
|
77
|
+
|
|
78
|
+
## Salidas
|
|
79
|
+
|
|
80
|
+
```text
|
|
81
|
+
.klap/
|
|
82
|
+
├── context.json # modelo técnico, de sistema y humano
|
|
83
|
+
├── agent-context.md # briefing para agentes de IA
|
|
84
|
+
├── index.html # portal humano, sin servidor
|
|
85
|
+
├── state.json # metadatos de freshness
|
|
86
|
+
└── graphify/ # grafo, informe y visualizaciones de Graphify
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Las afirmaciones incluyen evidencia y estados `CONFIRMED`, `INFERRED` o
|
|
90
|
+
`UNKNOWN`. Si KlapContext no puede determinar algo con confianza, lo expone en
|
|
91
|
+
lugar de inventarlo.
|
|
92
|
+
|
|
93
|
+
## Desarrollo
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
python -m pip install -e ".[dev]"
|
|
97
|
+
python -m pytest
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Publicación
|
|
101
|
+
|
|
102
|
+
El workflow de GitHub Actions publica tags `v*` mediante PyPI Trusted
|
|
103
|
+
Publishing. Antes del primer tag, configurá el publisher de PyPI para este
|
|
104
|
+
repositorio y workflow. No se necesitan tokens de PyPI en el repositorio.
|
|
105
|
+
|
|
106
|
+
## Licencia
|
|
107
|
+
|
|
108
|
+
Apache-2.0. Ver [LICENSE](LICENSE).
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
klapcontext/__init__.py,sha256=lmpnjhiFxa8bT8sMbxk-lpSZKxQsBJLXv3fzeeEGeq0,78
|
|
2
|
+
klapcontext/agent_context.py,sha256=AGhiYIqSmhMCv5SrGTyYDe9Wq9A_0Q6MMvx7hbiEMzE,3220
|
|
3
|
+
klapcontext/cli.py,sha256=hqWaZYutDAY8WT7-PzgEQfTxrD_cFuFhrKy6ijSClXY,4670
|
|
4
|
+
klapcontext/context_builder.py,sha256=4IwvY3CT_RnC1iAjSbx0hUbQIKTa0PZ5vRnEKnhRSOU,9214
|
|
5
|
+
klapcontext/detector.py,sha256=s5VP0VfQajfpO4zaPcEyaEkC2xc0tJth3_3TuKgRaI8,2469
|
|
6
|
+
klapcontext/evidence.py,sha256=ZO1qGR4eUDx31Wn6ZsASe07fnlcV8lzrQbTcK40SNpw,270
|
|
7
|
+
klapcontext/git.py,sha256=BuajarSRAWZb383S3kpP8_TL09C8BMdUrLO-WgPXVyE,1290
|
|
8
|
+
klapcontext/graphify.py,sha256=BbTKH3ksOv4viA8n3w7A2GdNfhqvGRUVzvd7pNBasNM,2748
|
|
9
|
+
klapcontext/portal.py,sha256=SDkaHwhD69_D2g4bGH3u9y_m0wTQ4qbt4jDGu-tSWwQ,11413
|
|
10
|
+
klapcontext/system.py,sha256=9v2Ul5h_SxoqzGPsLgpIUiNigS4udSAc7EW-rjdoedM,9105
|
|
11
|
+
klapcontext-0.1.0.dist-info/licenses/LICENSE,sha256=bdx-haFm5cIbYsK36lLuYZRfHszAAIypiFqI3_FByus,2136
|
|
12
|
+
klapcontext-0.1.0.dist-info/METADATA,sha256=xrxgFln28d7cd8AjK1biO6AWZhK4I8xliPgM40gJFW0,3583
|
|
13
|
+
klapcontext-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
14
|
+
klapcontext-0.1.0.dist-info/entry_points.txt,sha256=B2ohRcgILMfN9mfuVmAV8qal_FwYRzdHcl_BPMUbG2A,46
|
|
15
|
+
klapcontext-0.1.0.dist-info/top_level.txt,sha256=uUwX3KXYUrk4eiKaWJjTqrKaXgHVuXGsqthLFVjRubA,12
|
|
16
|
+
klapcontext-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
+
|
|
7
|
+
1. Definitions.
|
|
8
|
+
|
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
10
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
11
|
+
|
|
12
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
13
|
+
this License, each contributor hereby grants to You a perpetual,
|
|
14
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
15
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
16
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
17
|
+
Work and such Derivative Works in Source or Object form.
|
|
18
|
+
|
|
19
|
+
3. Grant of Patent License. Subject to the terms and conditions of this
|
|
20
|
+
License, each contributor hereby grants to You a perpetual,
|
|
21
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
22
|
+
patent license to make, have made, use, offer to sell, sell, import,
|
|
23
|
+
and otherwise transfer the Work.
|
|
24
|
+
|
|
25
|
+
4. Redistribution. You may reproduce and distribute copies subject to
|
|
26
|
+
the conditions in the Apache License, Version 2.0.
|
|
27
|
+
|
|
28
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
29
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
30
|
+
shall be under the terms and conditions of this License.
|
|
31
|
+
|
|
32
|
+
6. Trademarks. This License does not grant permission to use trade names,
|
|
33
|
+
trademarks, service marks, or product names of the Licensor.
|
|
34
|
+
|
|
35
|
+
7. Disclaimer of Warranty. Unless required by applicable law or agreed
|
|
36
|
+
to in writing, Licensor provides the Work on an "AS IS" BASIS,
|
|
37
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND.
|
|
38
|
+
|
|
39
|
+
8. Limitation of Liability. In no event and under no legal theory shall
|
|
40
|
+
any contributor be liable for damages arising from this License.
|
|
41
|
+
|
|
42
|
+
9. Accepting Warranty or Additional Liability. While redistributing the
|
|
43
|
+
Work, You may choose to offer support, warranty, indemnity, or
|
|
44
|
+
liability obligations, but only on Your own behalf.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
klapcontext
|