klapcontext 0.1.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.
@@ -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,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,79 @@
1
+ # KlapContext
2
+
3
+ Make your repository understandable to humans and AI.
4
+
5
+ KlapContext convierte un repositorio en contexto de ingeniería reutilizable: un
6
+ portal estático para personas y un briefing compacto para agentes de IA. Usa
7
+ [Graphify](https://github.com/Graphify-Labs/graphify) como motor local de
8
+ análisis técnico; KlapContext no reimplementa su grafo ni sus parsers.
9
+
10
+ ```text
11
+ Repositorio → KlapContext → Contexto de ingeniería
12
+ ├── Portal humano
13
+ └── Contexto para agentes
14
+ ```
15
+
16
+ ## Instalación
17
+
18
+ ```bash
19
+ pip install klapcontext
20
+ ```
21
+
22
+ La instalación incluye `graphifyy`, la distribución oficial de Graphify que
23
+ KlapContext necesita para generar el análisis local. Como alternativa para una
24
+ CLI aislada: `pipx install klapcontext`.
25
+
26
+ ## Primer uso
27
+
28
+ ```bash
29
+ cd mi-proyecto
30
+ klap init
31
+ klap open
32
+ ```
33
+
34
+ `klap init` genera el análisis Graphify y escribe artefactos locales bajo
35
+ `.klap/`. KlapContext agrega esa carpeta a `.git/info/exclude`; nunca modifica
36
+ tu `.gitignore`.
37
+
38
+ ## Comandos
39
+
40
+ | Comando | Descripción |
41
+ | --- | --- |
42
+ | `klap init [ruta]` | Genera el contexto inicial. |
43
+ | `klap update [ruta]` | Actualiza Graphify y el contexto. |
44
+ | `klap status [ruta]` | Indica si el contexto está actualizado respecto a Git. |
45
+ | `klap open [ruta]` | Abre el portal humano estático. |
46
+ | `klap agent [ruta]` | Muestra la configuración MCP local de Graphify. |
47
+ | `klap --version` | Muestra la versión instalada. |
48
+
49
+ ## Salidas
50
+
51
+ ```text
52
+ .klap/
53
+ ├── context.json # modelo técnico, de sistema y humano
54
+ ├── agent-context.md # briefing para agentes de IA
55
+ ├── index.html # portal humano, sin servidor
56
+ ├── state.json # metadatos de freshness
57
+ └── graphify/ # grafo, informe y visualizaciones de Graphify
58
+ ```
59
+
60
+ Las afirmaciones incluyen evidencia y estados `CONFIRMED`, `INFERRED` o
61
+ `UNKNOWN`. Si KlapContext no puede determinar algo con confianza, lo expone en
62
+ lugar de inventarlo.
63
+
64
+ ## Desarrollo
65
+
66
+ ```bash
67
+ python -m pip install -e ".[dev]"
68
+ python -m pytest
69
+ ```
70
+
71
+ ## Publicación
72
+
73
+ El workflow de GitHub Actions publica tags `v*` mediante PyPI Trusted
74
+ Publishing. Antes del primer tag, configurá el publisher de PyPI para este
75
+ repositorio y workflow. No se necesitan tokens de PyPI en el repositorio.
76
+
77
+ ## Licencia
78
+
79
+ Apache-2.0. Ver [LICENSE](LICENSE).
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "klapcontext"
7
+ dynamic = ["version"]
8
+ description = "Make your repository understandable to humans and AI."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "Apache-2.0"
12
+ license-files = ["LICENSE"]
13
+ authors = [{name = "Willy Cordon"}]
14
+ keywords = ["codebase", "developer-tools", "engineering-context", "graphify", "ai-agents"]
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"]
17
+
18
+ [project.optional-dependencies]
19
+ dev = ["build>=1.2", "pytest>=8", "twine>=6"]
20
+
21
+ [project.urls]
22
+ Homepage = "https://github.com/willy-cordon/klap_context"
23
+ Repository = "https://github.com/willy-cordon/klap_context"
24
+ Issues = "https://github.com/willy-cordon/klap_context/issues"
25
+
26
+ [project.scripts]
27
+ klap = "klapcontext.cli:main"
28
+
29
+ [tool.setuptools]
30
+ package-dir = {"" = "src"}
31
+
32
+ [tool.setuptools.dynamic]
33
+ version = {attr = "klapcontext.__version__"}
34
+
35
+ [tool.setuptools.packages.find]
36
+ where = ["src"]
37
+
38
+ [tool.pytest.ini_options]
39
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """KlapContext local engineering-context generator."""
2
+
3
+ __version__ = "0.1.0"
@@ -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
@@ -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"]}
@@ -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
@@ -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)
@@ -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")
@@ -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
@@ -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')
@@ -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,20 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/klapcontext/__init__.py
5
+ src/klapcontext/agent_context.py
6
+ src/klapcontext/cli.py
7
+ src/klapcontext/context_builder.py
8
+ src/klapcontext/detector.py
9
+ src/klapcontext/evidence.py
10
+ src/klapcontext/git.py
11
+ src/klapcontext/graphify.py
12
+ src/klapcontext/portal.py
13
+ src/klapcontext/system.py
14
+ src/klapcontext.egg-info/PKG-INFO
15
+ src/klapcontext.egg-info/SOURCES.txt
16
+ src/klapcontext.egg-info/dependency_links.txt
17
+ src/klapcontext.egg-info/entry_points.txt
18
+ src/klapcontext.egg-info/requires.txt
19
+ src/klapcontext.egg-info/top_level.txt
20
+ tests/test_klapcontext.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ klap = klapcontext.cli:main
@@ -0,0 +1,6 @@
1
+ graphifyy>=0.9.66
2
+
3
+ [dev]
4
+ build>=1.2
5
+ pytest>=8
6
+ twine>=6
@@ -0,0 +1 @@
1
+ klapcontext
@@ -0,0 +1,53 @@
1
+ import json
2
+ from pathlib import Path
3
+ from klapcontext.agent_context import render as render_agent
4
+ from klapcontext.context_builder import build
5
+ from klapcontext.detector import detect_project
6
+ from klapcontext.git import exclude_klap
7
+ from klapcontext.portal import render as render_portal
8
+ from klapcontext import cli
9
+
10
+ def fixture_repo(tmp_path):
11
+ (tmp_path/".git"/"info").mkdir(parents=True)
12
+ (tmp_path/"composer.json").write_text(json.dumps({"require":{"laravel/framework":"^11.0"}}))
13
+ (tmp_path/"Dockerfile").write_text("FROM php:8.3")
14
+ (tmp_path/"routes").mkdir(); (tmp_path/"routes"/"api.php").write_text("<?php")
15
+ (tmp_path/"tests").mkdir()
16
+ return tmp_path
17
+
18
+ def test_detector_collects_confirmed_evidence(tmp_path):
19
+ root=fixture_repo(tmp_path); stack, evidence=detect_project(root)
20
+ assert "Laravel" in stack["frameworks"] and "Docker" in stack["infrastructure"]
21
+ assert all(x.status == "CONFIRMED" for x in evidence)
22
+
23
+ def test_context_agent_and_portal(tmp_path):
24
+ root=fixture_repo(tmp_path); context=build(root, {"nodes":[{"name":"UserController","path":"app/Http/UserController.php","type":"controller"}]})
25
+ assert context["schema_version"] == "0.2" and context["architecture"]["status"] == "INFERRED"
26
+ agent=render_agent(context); page=render_portal(context, agent, ["graph.html"])
27
+ assert "Instrucciones para el Agente" in agent and "Copiar contexto" in page and "Abrir grafo técnico" in page
28
+
29
+ def test_exclude_klap_is_idempotent(tmp_path, monkeypatch):
30
+ root=fixture_repo(tmp_path)
31
+ monkeypatch.setattr("klapcontext.git.run_git", lambda root,*args: ".git" if args == ("rev-parse", "--git-dir") else "true")
32
+ exclude_klap(root); exclude_klap(root)
33
+ assert (root/".git/info/exclude").read_text().splitlines().count(".klap/") == 1
34
+
35
+ def test_status_marks_matching_clean_context_current(tmp_path, monkeypatch, capsys):
36
+ (tmp_path/".klap").mkdir()
37
+ (tmp_path/".klap"/"state.json").write_text(json.dumps({"git_commit":"abc"}))
38
+ monkeypatch.setattr(cli, "commit", lambda root: "abc")
39
+ monkeypatch.setattr(cli, "dirty_files", lambda root: [])
40
+ assert cli.cmd_status(type("Args", (), {"path":str(tmp_path)})()) == 0
41
+ assert "CURRENT" in capsys.readouterr().out
42
+
43
+ def test_system_model_reads_docs_routes_scheduler_and_deployment(tmp_path):
44
+ root=fixture_repo(tmp_path)
45
+ (root/"README.md").write_text("# Contact processor\n\nProcesses customer audio through a local API.")
46
+ (root/"routes"/"api.php").write_text("Route::post('/audio', [AudioController::class, 'store']);")
47
+ (root/"routes"/"console.php").write_text("Schedule::command('audio:send')->everyThirtyMinutes();")
48
+ (root/"docker-compose.yml").write_text("services:\n app:\n ports: ['8010:80']")
49
+ context=build(root, {"nodes":[]}); system=context["system_model"]
50
+ assert system["purpose"]["text"] == "Processes customer audio through a local API."
51
+ assert any(item["type"] == "http" for item in system["entry_points"])
52
+ assert system["background_processes"] and "Docker Compose" in system["deployment"]["tools"]
53
+ assert any(item["intent"] == "Entender la API HTTP" for item in system["start_here"])