klapcontext 0.3.3__tar.gz → 0.4.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.
Files changed (46) hide show
  1. {klapcontext-0.3.3/src/klapcontext.egg-info → klapcontext-0.4.0}/PKG-INFO +9 -1
  2. {klapcontext-0.3.3 → klapcontext-0.4.0}/README.md +8 -0
  3. {klapcontext-0.3.3 → klapcontext-0.4.0}/src/klapcontext/__init__.py +1 -1
  4. {klapcontext-0.3.3 → klapcontext-0.4.0}/src/klapcontext/agent/compiler.py +5 -2
  5. {klapcontext-0.3.3 → klapcontext-0.4.0}/src/klapcontext/agent_context.py +6 -0
  6. {klapcontext-0.3.3 → klapcontext-0.4.0}/src/klapcontext/cli.py +5 -2
  7. {klapcontext-0.3.3 → klapcontext-0.4.0}/src/klapcontext/context_builder.py +8 -2
  8. {klapcontext-0.3.3 → klapcontext-0.4.0}/src/klapcontext/detector.py +26 -16
  9. klapcontext-0.4.0/src/klapcontext/exploration.py +118 -0
  10. {klapcontext-0.3.3 → klapcontext-0.4.0}/src/klapcontext/frameworks/laravel.py +4 -1
  11. {klapcontext-0.3.3 → klapcontext-0.4.0}/src/klapcontext/portal.py +33 -1
  12. {klapcontext-0.3.3 → klapcontext-0.4.0/src/klapcontext.egg-info}/PKG-INFO +9 -1
  13. {klapcontext-0.3.3 → klapcontext-0.4.0}/src/klapcontext.egg-info/SOURCES.txt +2 -0
  14. klapcontext-0.4.0/tests/test_exploration.py +68 -0
  15. {klapcontext-0.3.3 → klapcontext-0.4.0}/LICENSE +0 -0
  16. {klapcontext-0.3.3 → klapcontext-0.4.0}/pyproject.toml +0 -0
  17. {klapcontext-0.3.3 → klapcontext-0.4.0}/setup.cfg +0 -0
  18. {klapcontext-0.3.3 → klapcontext-0.4.0}/src/klapcontext/agent/__init__.py +0 -0
  19. {klapcontext-0.3.3 → klapcontext-0.4.0}/src/klapcontext/agent/capabilities.py +0 -0
  20. {klapcontext-0.3.3 → klapcontext-0.4.0}/src/klapcontext/agent/impact.py +0 -0
  21. {klapcontext-0.3.3 → klapcontext-0.4.0}/src/klapcontext/agent/intents.py +0 -0
  22. {klapcontext-0.3.3 → klapcontext-0.4.0}/src/klapcontext/agent/models.py +0 -0
  23. {klapcontext-0.3.3 → klapcontext-0.4.0}/src/klapcontext/agent/planner.py +0 -0
  24. {klapcontext-0.3.3 → klapcontext-0.4.0}/src/klapcontext/agent/verification.py +0 -0
  25. {klapcontext-0.3.3 → klapcontext-0.4.0}/src/klapcontext/agent/workflows.py +0 -0
  26. {klapcontext-0.3.3 → klapcontext-0.4.0}/src/klapcontext/evidence.py +0 -0
  27. {klapcontext-0.3.3 → klapcontext-0.4.0}/src/klapcontext/frameworks/__init__.py +0 -0
  28. {klapcontext-0.3.3 → klapcontext-0.4.0}/src/klapcontext/frameworks/base.py +0 -0
  29. {klapcontext-0.3.3 → klapcontext-0.4.0}/src/klapcontext/frameworks/fastapi.py +0 -0
  30. {klapcontext-0.3.3 → klapcontext-0.4.0}/src/klapcontext/frameworks/generic_php.py +0 -0
  31. {klapcontext-0.3.3 → klapcontext-0.4.0}/src/klapcontext/frameworks/node.py +0 -0
  32. {klapcontext-0.3.3 → klapcontext-0.4.0}/src/klapcontext/git.py +0 -0
  33. {klapcontext-0.3.3 → klapcontext-0.4.0}/src/klapcontext/graphify.py +0 -0
  34. {klapcontext-0.3.3 → klapcontext-0.4.0}/src/klapcontext/providers/__init__.py +0 -0
  35. {klapcontext-0.3.3 → klapcontext-0.4.0}/src/klapcontext/providers/base.py +0 -0
  36. {klapcontext-0.3.3 → klapcontext-0.4.0}/src/klapcontext/providers/code_intelligence.py +0 -0
  37. {klapcontext-0.3.3 → klapcontext-0.4.0}/src/klapcontext/providers/graphify.py +0 -0
  38. {klapcontext-0.3.3 → klapcontext-0.4.0}/src/klapcontext/providers/javascript_intelligence.py +0 -0
  39. {klapcontext-0.3.3 → klapcontext-0.4.0}/src/klapcontext/providers/python_intelligence.py +0 -0
  40. {klapcontext-0.3.3 → klapcontext-0.4.0}/src/klapcontext/semantic.py +0 -0
  41. {klapcontext-0.3.3 → klapcontext-0.4.0}/src/klapcontext/system.py +0 -0
  42. {klapcontext-0.3.3 → klapcontext-0.4.0}/src/klapcontext.egg-info/dependency_links.txt +0 -0
  43. {klapcontext-0.3.3 → klapcontext-0.4.0}/src/klapcontext.egg-info/entry_points.txt +0 -0
  44. {klapcontext-0.3.3 → klapcontext-0.4.0}/src/klapcontext.egg-info/requires.txt +0 -0
  45. {klapcontext-0.3.3 → klapcontext-0.4.0}/src/klapcontext.egg-info/top_level.txt +0 -0
  46. {klapcontext-0.3.3 → klapcontext-0.4.0}/tests/test_klapcontext.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: klapcontext
3
- Version: 0.3.3
3
+ Version: 0.4.0
4
4
  Summary: Make your repository understandable to humans and AI.
5
5
  Author: Willy Cordon
6
6
  License-Expression: Apache-2.0
@@ -82,6 +82,14 @@ klap open
82
82
  `.klap/`. KlapContext agrega esa carpeta a `.git/info/exclude`; nunca modifica
83
83
  tu `.gitignore`.
84
84
 
85
+ El portal incluye un árbol navegable de archivos y símbolos extraídos del grafo,
86
+ recorridos de llamadas y cambios recientes de Git. El contexto del agente y
87
+ `klap context` usan las mismas rutas y símbolos. El indicador de cobertura cuenta
88
+ archivos de código con al menos un nodo en Graphify: **no mide exhaustividad de
89
+ funciones ni garantiza que se hayan resuelto rutas dinámicas**. Los recorridos
90
+ son caminos del grafo, no afirmaciones de que una ruta HTTP los ejecuta; sus
91
+ ramas pueden requerir inspección del código original.
92
+
85
93
  ## Comandos
86
94
 
87
95
  | Comando | Descripción |
@@ -48,6 +48,14 @@ klap open
48
48
  `.klap/`. KlapContext agrega esa carpeta a `.git/info/exclude`; nunca modifica
49
49
  tu `.gitignore`.
50
50
 
51
+ El portal incluye un árbol navegable de archivos y símbolos extraídos del grafo,
52
+ recorridos de llamadas y cambios recientes de Git. El contexto del agente y
53
+ `klap context` usan las mismas rutas y símbolos. El indicador de cobertura cuenta
54
+ archivos de código con al menos un nodo en Graphify: **no mide exhaustividad de
55
+ funciones ni garantiza que se hayan resuelto rutas dinámicas**. Los recorridos
56
+ son caminos del grafo, no afirmaciones de que una ruta HTTP los ejecuta; sus
57
+ ramas pueden requerir inspección del código original.
58
+
51
59
  ## Comandos
52
60
 
53
61
  | Comando | Descripción |
@@ -1,3 +1,3 @@
1
1
  """KlapContext local engineering-context generator."""
2
2
 
3
- __version__ = "0.3.3"
3
+ __version__ = "0.4.0"
@@ -34,8 +34,11 @@ def compile_context(root: Path, query: str, *, detail: str = "standard", max_tok
34
34
  context = build(root, {"nodes": []})
35
35
  model = context["system_model"]; limit = min(DETAIL_LIMITS[detail], max(1, max_tokens // 350)); terms = _terms(query, plan.area)
36
36
  entries = _rank(model["entry_points"], terms, limit); flows = _rank(model["main_flows"], terms, limit); files = _rank(model["important_files"], terms, limit)
37
- read_first = ([{"path": item.get("file"), "reason": "Punto de entrada relacionado", "relevance": item["relevance"]} for item in entries if item.get("file")] + files)[:limit]
38
- result = {"intent": plan.intent, "confidence": plan.confidence, "query": query, "area": plan.area, "detail": detail, "max_tokens": max_tokens, "entry_points": entries, "flows": flows, "symbols": _rank(model["semantic_model"]["components"], terms, limit), "files": files, "dependencies": _rank(model["dependencies"], terms, limit), "tests": _rank(context["tests"], terms, limit), "read_first": read_first, "evidence": [evidence for group in entries + flows for evidence in group.get("evidence", [])][:limit], "uncertainties": model["unknowns"], "strategy": plan.strategy}
37
+ exploration = model.get("exploration", {})
38
+ graph_files = _rank(exploration.get("files", []), terms, limit)
39
+ graph_paths = _rank(exploration.get("call_paths", []), terms, limit)
40
+ read_first = ([{"path": item.get("file"), "reason": "Punto de entrada relacionado", "relevance": item["relevance"]} for item in entries if item.get("file")] + [{"path": item["path"], "reason": "Archivo o símbolo relacionado en el grafo", "relevance": item["relevance"]} for item in graph_files] + files)[:limit]
41
+ result = {"intent": plan.intent, "confidence": plan.confidence, "query": query, "area": plan.area, "detail": detail, "max_tokens": max_tokens, "entry_points": entries, "flows": flows, "graph_call_paths": graph_paths, "symbols": _rank(model["semantic_model"]["components"], terms, limit), "files": files + graph_files, "coverage": exploration.get("coverage", {}), "recent_changes": _rank(model.get("recent_changes", []), terms, min(limit, 3)), "dependencies": _rank(model["dependencies"], terms, limit), "tests": _rank(context["tests"], terms, limit), "read_first": read_first, "evidence": [evidence for group in entries + flows for evidence in group.get("evidence", [])][:limit], "uncertainties": model["unknowns"], "strategy": plan.strategy}
39
42
  result["verification"] = reconcile(result["evidence"])
40
43
  result = sanitize(result)
41
44
  result["estimated_tokens"] = min(max_tokens, len(json.dumps(result, ensure_ascii=False)) // 4)
@@ -5,9 +5,15 @@ def items(values, key="name"):
5
5
  return "\n".join(f"- {value.get(key) or value.get('path')}" for value in values) if values else "Sin evidencia disponible."
6
6
  def render(context: dict) -> str:
7
7
  s=context.get("system_model",{}); purpose=s.get("purpose",{}).get("text") or "No fue posible determinar el propósito con confianza."
8
+ exploration=s.get("exploration",{}); coverage=exploration.get("coverage",{})
8
9
  sections=[("Propósito del Proyecto",purpose),("Cómo Funciona el Sistema",s.get("project_story",{}).get("text") or "No fue posible determinarlo con confianza."),("Superficies de Ejecución","\n".join(f"- {x['label']}: {x['count']}" for x in s.get("runtime_surfaces",[])) or "Sin superficies específicas detectadas."),("Rutas",items(s.get("routes",[]))),("Procesos Programados",items(s.get("scheduled_processes",[]))),("Jobs de Cola",items(s.get("queue_jobs",[]))),("Flujos Principales","\n".join(f"- **{x['name']}**: " + " → ".join(step["name"] for step in x["steps"]) for x in s.get("main_flows",[])) or "No se detectaron flujos principales con confianza."),("Dependencias Importantes","\n".join(f"- {x['package']} {x['version']} ({x['scope']})" for x in s.get("dependencies",[])[:12]) or "Sin dependencias específicas detectadas."),("Sistemas Externos",items(s.get("external_systems",[]))),("Almacenes de Datos",items(s.get("datastores",[]))),("Por Dónde Empezar","\n".join(f"- **{x['intent']}**: {', '.join(x['paths'])}" for x in s.get("start_here",[])) or "Sin recomendaciones detectadas."),("Incertidumbres","\n".join(f"- {x}" for x in s.get("unknowns",[])) or "Sin incertidumbres relevantes registradas.")]
9
10
  lines=["# KlapContext — Contexto del Proyecto"]
10
11
  for title,body in sections: lines.extend(["",f"## {title}",body])
12
+ lines.extend(["", "## Cobertura de la exploración", f"- Archivos de código con nodos: {coverage.get('code_files_with_graph_nodes', 0)}/{coverage.get('eligible_code_files', 0)}; estado: {coverage.get('status', 'UNKNOWN')}.", "- Un archivo representado en el grafo no implica que todas sus funciones estén resueltas.", "", "## Estructura y recorridos de llamadas", "- Consulta `.klap/context.json` → `system_model.exploration.files` para localizar archivos y símbolos por ruta."])
13
+ for path in exploration.get("call_paths", [])[:8]:
14
+ lines.append("- " + " → ".join(f"{step['name']} ({step['file']})" for step in path["steps"]) + " [recorrido del grafo; entrada no confirmada]")
15
+ lines.extend(["", "## Cambios recientes en Git"])
16
+ lines.extend(f"- {change['date']} {change['commit'][:10]}: {change['subject']}" for change in s.get("recent_changes", [])[:6])
11
17
  lines.extend(["","## Instrucciones para el Agente","1. Lee este contexto antes de explorar ampliamente el repositorio.","2. Usa Graphify MCP para 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."])
12
18
  text="\n".join(lines)+"\n"; return text+f"\nTokens estimados: {len(text.split()) * 4 // 3}\n"
13
19
  def write(context: dict, output: Path) -> str:
@@ -30,14 +30,17 @@ def cmd_init(args):
30
30
  except (RuntimeError, GraphifyError) as e:
31
31
  print(f"✗ {e}", file=sys.stderr); return 1
32
32
  print("✓ Git repository detected\n✓ Graphify available\n✓ Graph generated\n✓ Engineering context generated\n✓ Agent context generated\n✓ Human portal generated")
33
+ coverage=context["system_model"]["exploration"]["coverage"]
34
+ print(f"Coverage: {coverage['code_files_with_graph_nodes']}/{coverage['eligible_code_files']} code files represented in graph ({coverage['status']}); graph nodes do not guarantee complete symbol analysis")
33
35
  stack_items = [item for values in context["stack"].values() if isinstance(values, list) for item in values]
34
36
  print(f"\nProject: {context['project']['name']}\nStack: {' / '.join(stack_items) or 'unknown'}\nContext: CURRENT\n\nHuman portal:\n.klap/index.html\n\nAgent context:\n.klap/agent-context.md")
35
37
  return 0
36
38
 
37
39
  def cmd_update(args):
38
- try: generate_all(root_path(args.path), update=True)
40
+ try: context=generate_all(root_path(args.path), update=True)
39
41
  except (RuntimeError, GraphifyError) as e: print(f"✗ {e}", file=sys.stderr); return 1
40
- print("✓ KlapContext updated"); return 0
42
+ coverage=context["system_model"]["exploration"]["coverage"]
43
+ print(f"✓ KlapContext updated; graph coverage: {coverage['code_files_with_graph_nodes']}/{coverage['eligible_code_files']} code files ({coverage['status']})"); return 0
41
44
 
42
45
  def cmd_status(args):
43
46
  root=root_path(args.path); state_file=root/".klap"/"state.json"
@@ -9,7 +9,8 @@ from .frameworks.generic_php import GenericPhpAdapter
9
9
  from .frameworks.laravel import LaravelAdapter
10
10
  from .frameworks.fastapi import FastAPIAdapter
11
11
  from .frameworks.node import NodeAdapter
12
- from .git import commit
12
+ from .git import commit, recent_changes
13
+ from .exploration import inspect
13
14
  from .semantic import SemanticModel
14
15
  from .system import deployment, documents, external_systems, purpose
15
16
 
@@ -34,7 +35,7 @@ def _edges(graph: dict) -> list[dict]:
34
35
 
35
36
 
36
37
  def _modules(nodes: list[dict]) -> list[dict]:
37
- return [{"name": item.get("label") or item.get("name") or item.get("id"), "path": item.get("file") or item.get("path"), "kind": item.get("type", "module"), "description": None, "status": "CONFIRMED", "evidence": []} for item in nodes if (item.get("label") or item.get("name") or item.get("id")) and (item.get("file") or item.get("path"))][:100]
38
+ return [{"name": item.get("label") or item.get("name") or item.get("id"), "path": item.get("source_file") or item.get("file") or item.get("path"), "kind": item.get("type", "module"), "description": None, "status": "CONFIRMED", "evidence": []} for item in nodes if isinstance(item, dict) and (item.get("label") or item.get("name") or item.get("id")) and (item.get("source_file") or item.get("file") or item.get("path"))][:100]
38
39
 
39
40
 
40
41
  def _runtime(points: list[dict], background: list[dict], frameworks: list[str]) -> dict:
@@ -133,11 +134,16 @@ def build(root: Path, graph: dict) -> dict:
133
134
  stack, detector_evidence = detect_project(root)
134
135
  docs = documents(root); value = purpose(root, docs); systems, stores = external_systems(root); deploy, observability, _ = deployment(root)
135
136
  semantic, raw = _semantic(root, stack); model = semantic.as_dict(); model["project_components"] = detect_components(root); points, background = model["entry_points"], model["background_tasks"]
137
+ exploration = inspect(root, graph, model)
136
138
  runtime = _runtime(points, background, stack["frameworks"]); inputs, outputs = _io(points, systems, stores); flows = _flows(model)
137
139
  important = raw["important_files"] or ([{"path": "README.md", "role": "Documentación", "reason": "README detectado"}] if (root / "README.md").exists() else [])
138
140
  tests = [{"path": item, "reason": "Tests detectados"} for item in ("tests", "test", "phpunit.xml", "pytest.ini") if (root / item).exists()]
139
141
  story = " ".join(item for item in (value["text"], runtime["description"], "La aplicación está contenerizada con Docker." if "Docker" in deploy["tools"] else None) if item)
140
142
  system = {"purpose": value, "project_story": {"text": story or None, "status": "CONFIRMED" if story else "UNKNOWN"}, "runtime": runtime, "framework": raw["framework"], "runtime_surfaces": raw["runtime_surfaces"], "routes": raw["routes"], "commands": raw["commands"], "scheduled_processes": raw["scheduled_processes"], "queue_jobs": raw["queue_jobs"], "events": raw["events"], "listeners": raw["listeners"], "components": raw["components"], "dependencies": raw["dependencies"], "semantic_model": model, "entry_points": points, "main_flows": flows, "system_interactions": _interactions(flows, systems, stores), "capabilities": _modules(_nodes(graph)), "inputs": inputs, "outputs": outputs, "external_systems": systems, "background_processes": background, "datastores": stores, "deployment": deploy, "observability": observability, "important_files": important, "start_here": [], "unknowns": [item for item, found in (("Propósito de negocio del proyecto", value["text"]), ("Plataforma de despliegue en producción", deploy["tools"])) if not found]}
143
+ system["exploration"] = exploration
144
+ system["recent_changes"] = recent_changes(root, limit=8)
145
+ if exploration["coverage"]["status"] == "PARTIAL":
146
+ system["unknowns"].append("Cobertura parcial del grafo: algunos archivos de código no tienen nodos; consultar el código original.")
141
147
  technical = {"languages": stack["languages"], "frameworks": stack["frameworks"], "framework": raw["framework"], "dependencies": raw["dependencies"], "graph": {"nodes": len(_nodes(graph)), "edges": len(_edges(graph))}, "documents": [{"path": item["path"]} for item in docs], "tests": tests, "infrastructure": stack["infrastructure"]}
142
148
  project = {"name": root.name, "type": stack["frameworks"][0] if stack["frameworks"] else None, "root": str(root.resolve()), "generated_at": datetime.now(timezone.utc).isoformat(), "git_commit": commit(root)}
143
149
  return {"schema_version": "0.5", "project": project, "technical_model": technical, "system_model": system, "semantic_model": model, "human_context": {"purpose": None, "users": None, "important_processes": [], "notes": []}, "stack": stack, "architecture": {"style": "MVC" if "Laravel" in stack["frameworks"] else None, "status": "INFERRED" if "Laravel" in stack["frameworks"] else "UNKNOWN", "confidence": None, "evidence": []}, "entry_points": points, "modules": system["capabilities"], "flows": flows, "api": raw["routes"], "jobs": background, "datastores": stores, "external_systems": systems, "deployment": deploy, "observability": observability, "tests": tests, "evidence": [item.as_dict() for item in detector_evidence] + value["evidence"] + _analysis_evidence(raw)}
@@ -26,14 +26,17 @@ def detect_project(root: Path) -> tuple[dict, list[Evidence]]:
26
26
  if value not in stack[bucket]: stack[bucket].append(value)
27
27
  evidence.append(Evidence("file", path, reason, "CONFIRMED", 1.0))
28
28
  composer = root / "composer.json"
29
+ if not composer.exists():
30
+ composer = next(_sources(root, "composer.json"), composer)
29
31
  if composer.exists():
30
- add("languages", "PHP", "composer.json", "Composer manifest detected")
32
+ composer_path = composer.relative_to(root).as_posix()
33
+ add("languages", "PHP", composer_path, "Composer manifest detected")
31
34
  stack["runtime"].append("PHP"); stack["package_managers"].append("composer")
32
35
  try: deps = {**json.loads(composer.read_text(encoding="utf-8")).get("require", {}), **json.loads(composer.read_text(encoding="utf-8")).get("require-dev", {})}
33
36
  except json.JSONDecodeError: deps = {}
34
- if "laravel/framework" in deps: add("frameworks", "Laravel", "composer.json", "laravel/framework dependency detected")
37
+ if "laravel/framework" in deps: add("frameworks", "Laravel", composer_path, "laravel/framework dependency detected")
35
38
  if "laravel/lumen-framework" in deps:
36
- add("frameworks", "Lumen", "composer.json", "laravel/lumen-framework dependency detected")
39
+ add("frameworks", "Lumen", composer_path, "laravel/lumen-framework dependency detected")
37
40
  stack["project_type"] = "backend-api"
38
41
  elif any(_sources(root, "*.php")):
39
42
  # A standalone PHP repository has no Composer manifest to identify it,
@@ -41,23 +44,30 @@ def detect_project(root: Path) -> tuple[dict, list[Evidence]]:
41
44
  first_php = next(_sources(root, "*.php"))
42
45
  add("languages", "PHP", str(first_php.relative_to(root)).replace("\\", "/"), "PHP source file detected")
43
46
  package = root / "package.json"
47
+ if not package.exists():
48
+ package = next(_sources(root, "package.json"), package)
44
49
  if package.exists():
45
- add("languages", "JavaScript", "package.json", "npm manifest detected")
50
+ package_path = package.relative_to(root).as_posix()
51
+ add("languages", "JavaScript", package_path, "npm manifest detected")
46
52
  stack["runtime"].append("Node.js"); stack["package_managers"].append("npm")
47
53
  try: deps = {**json.loads(package.read_text(encoding="utf-8")).get("dependencies", {}), **json.loads(package.read_text(encoding="utf-8")).get("devDependencies", {})}
48
54
  except json.JSONDecodeError: deps = {}
49
55
  for name, label in (("next", "Next.js"), ("vue", "Vue"), ("react", "React"), ("express", "Express"), ("@nestjs/core", "NestJS")):
50
- if name in deps: add("frameworks", label, "package.json", f"{name} dependency detected")
51
- if any(_sources(root, "*.ts")) or any(_sources(root, "*.tsx")): add("languages", "TypeScript", "package.json", "TypeScript source detected")
52
- if (root / "pyproject.toml").exists() or (root / "requirements.txt").exists():
53
- python_manifest = root / "pyproject.toml" if (root / "pyproject.toml").exists() else root / "requirements.txt"
54
- add("languages", "Python", python_manifest.name, "Python manifest detected")
56
+ if name in deps: add("frameworks", label, package_path, f"{name} dependency detected")
57
+ if any(_sources(root, "*.ts")) or any(_sources(root, "*.tsx")): add("languages", "TypeScript", package_path, "TypeScript source detected")
58
+ python_manifest = next((path for pattern in ("pyproject.toml", "requirements.txt") if (path := next(_sources(root, pattern), None))), None)
59
+ if python_manifest:
60
+ python_path = python_manifest.relative_to(root).as_posix()
61
+ add("languages", "Python", python_path, "Python manifest detected")
55
62
  stack["runtime"].append("Python"); stack["package_managers"].append("pip")
56
- if "fastapi" in python_manifest.read_text(encoding="utf-8", errors="ignore").casefold(): add("frameworks", "FastAPI", python_manifest.name, "FastAPI dependency detected")
63
+ if "fastapi" in python_manifest.read_text(encoding="utf-8", errors="ignore").casefold(): add("frameworks", "FastAPI", python_path, "FastAPI dependency detected")
57
64
  if (root / "manage.py").exists(): add("frameworks", "Django", "manage.py", "Django entry point detected")
58
- if list(root.glob("*.csproj")):
59
- add("languages", "C#", next(root.glob("*.csproj")).name, ".NET project detected"); stack["runtime"].append(".NET"); stack["package_managers"].append("NuGet")
60
- if (root / "Program.cs").exists(): add("frameworks", ".NET Web API", "Program.cs", "ASP.NET entry point detected")
65
+ projects = list(_sources(root, "*.csproj"))
66
+ if projects:
67
+ project = projects[0]; relative = project.relative_to(root).as_posix()
68
+ add("languages", "C#", relative, ".NET project detected"); stack["runtime"].append(".NET"); stack["package_managers"].append("NuGet")
69
+ if "Microsoft.NET.Sdk.Web" in project.read_text(encoding="utf-8", errors="ignore"):
70
+ add("frameworks", "ASP.NET", relative, "Web SDK detected")
61
71
  if (root / "go.mod").exists():
62
72
  add("languages", "Go", "go.mod", "Go module detected"); stack["runtime"].append("Go"); stack["package_managers"].append("go modules")
63
73
  if (root / "Cargo.toml").exists():
@@ -66,10 +76,10 @@ def detect_project(root: Path) -> tuple[dict, list[Evidence]]:
66
76
  manifest = "pom.xml" if (root / "pom.xml").exists() else "build.gradle" if (root / "build.gradle").exists() else "build.gradle.kts"
67
77
  add("languages", "Java", manifest, "Java build manifest detected"); stack["runtime"].append("JVM"); stack["package_managers"].append("maven" if manifest == "pom.xml" else "gradle")
68
78
  # Keep generic analysis useful for small repositories without a manifest.
69
- for suffix, language in (("*.py", "Python"), ("*.js", "JavaScript"), ("*.ts", "TypeScript"), ("*.java", "Java"), ("*.go", "Go"), ("*.rs", "Rust")):
79
+ for suffix, language in (("*.py", "Python"), ("*.cs", "C#"), ("*.js", "JavaScript"), ("*.ts", "TypeScript"), ("*.java", "Java"), ("*.go", "Go"), ("*.rs", "Rust")):
70
80
  if language not in stack["languages"] and any(_sources(root, suffix)):
71
81
  first = next(_sources(root, suffix)); add("languages", language, first.relative_to(root).as_posix(), f"{language} source file detected")
72
- if any(name in stack["frameworks"] for name in ("Laravel", "Lumen", "FastAPI", "Express", "NestJS", ".NET Web API")): stack["project_type"] = "backend-api"
82
+ if any(name in stack["frameworks"] for name in ("Laravel", "Lumen", "FastAPI", "Express", "NestJS", "ASP.NET")): stack["project_type"] = "backend-api"
73
83
  if (root / "Dockerfile").exists(): add("infrastructure", "Docker", "Dockerfile", "Dockerfile detected")
74
84
  if list(root.glob("docker-compose*")) or list(root.glob("compose*")): add("infrastructure", "Docker Compose", "docker-compose", "Compose configuration detected")
75
85
  if (root / ".github" / "workflows").exists(): add("infrastructure", "GitHub Actions", ".github/workflows", "GitHub Actions workflows detected")
@@ -79,7 +89,7 @@ def detect_project(root: Path) -> tuple[dict, list[Evidence]]:
79
89
  def detect_components(root: Path) -> list[dict]:
80
90
  """Find independent project boundaries without assuming a monorepo layout."""
81
91
  result = []
82
- manifests = [path for pattern in ("composer.json", "package.json", "pyproject.toml", "requirements.txt") for path in _sources(root, pattern)]
92
+ manifests = [path for pattern in ("composer.json", "package.json", "pyproject.toml", "requirements.txt", "*.csproj", "go.mod", "Cargo.toml", "pom.xml") for path in _sources(root, pattern)]
83
93
  for manifest in manifests:
84
94
  directory = manifest.parent
85
95
  if directory == root: continue
@@ -0,0 +1,118 @@
1
+ """Language-neutral, evidence-based navigation of a Graphify code graph."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ from collections import defaultdict
6
+ from pathlib import Path
7
+
8
+ EXCLUDED = {".git", ".klap", "graphify-out", "node_modules", "vendor", ".venv", "venv", "build", "dist", "__pycache__"}
9
+ CODE_EXTENSIONS = {".py", ".php", ".cs", ".js", ".jsx", ".ts", ".tsx", ".java", ".go", ".rs", ".rb", ".kt", ".vue", ".swift", ".cpp", ".c", ".h"}
10
+ CALL_RELATIONS = {"calls", "invokes", "dispatches", "calls_method"}
11
+ MEMBER_RELATIONS = {"method", "contains", "defines", "has_method", "member"}
12
+
13
+
14
+ def _path(root: Path, value: object) -> str | None:
15
+ if not isinstance(value, str) or not value.strip():
16
+ return None
17
+ raw = value.replace("\\", "/")
18
+ candidate = Path(raw)
19
+ try:
20
+ relative = candidate.resolve().relative_to(root.resolve()) if candidate.is_absolute() else candidate
21
+ except (ValueError, OSError):
22
+ return None
23
+ if ".." in relative.parts or any(part in EXCLUDED for part in relative.parts):
24
+ return None
25
+ return relative.as_posix()
26
+
27
+
28
+ def _edges(graph: dict) -> list[dict]:
29
+ nested = graph.get("graph", {})
30
+ for container in (graph, nested if isinstance(nested, dict) else {}):
31
+ for key in ("links", "edges"):
32
+ if isinstance(container.get(key), list):
33
+ return container[key]
34
+ return []
35
+
36
+
37
+ def inspect(root: Path, graph: dict, semantic: dict, *, max_files: int = 10000) -> dict:
38
+ """Keep source paths and graph relationships intact, with explicit limits."""
39
+ nodes = graph.get("nodes", [])
40
+ if not isinstance(nodes, list):
41
+ nodes = []
42
+ by_id = {str(n["id"]): n for n in nodes if isinstance(n, dict) and n.get("id") is not None}
43
+ files: dict[str, dict] = {}
44
+ for directory, children, names in os.walk(root):
45
+ children[:] = sorted(c for c in children if c not in EXCLUDED)
46
+ for name in sorted(names):
47
+ path = _path(root, str(Path(directory) / name))
48
+ if path:
49
+ files[path] = {"path": path, "symbols": [], "analyzed": False}
50
+ if len(files) >= max_files:
51
+ break
52
+ if len(files) >= max_files:
53
+ break
54
+ eligible = {path for path in files if Path(path).suffix.lower() in CODE_EXTENSIONS}
55
+ analyzed: set[str] = set()
56
+ for node in nodes:
57
+ if not isinstance(node, dict):
58
+ continue
59
+ path = _path(root, node.get("source_file") or node.get("file") or node.get("path"))
60
+ if not path or path not in files:
61
+ continue
62
+ analyzed.add(path)
63
+ symbol = {"id": str(node.get("id", "")), "name": str(node.get("label") or node.get("name") or node.get("id")),
64
+ "kind": str(node.get("type") or "symbol"), "line": node.get("source_location") or node.get("line"), "source": "graphify"}
65
+ files[path]["symbols"].append(symbol)
66
+ for component in semantic.get("components", []):
67
+ path = _path(root, component.get("file"))
68
+ if path in files and not files[path]["symbols"]:
69
+ files[path]["symbols"].append({"id": component.get("symbol") or component["name"], "name": component["name"], "kind": component.get("type", "symbol"), "line": None, "source": "klap"})
70
+ for path, item in files.items():
71
+ item["analyzed"] = path in analyzed
72
+
73
+ graph_edges = _edges(graph)
74
+ members: dict[str, list[dict]] = defaultdict(list)
75
+ calls: dict[str, list[dict]] = defaultdict(list)
76
+ incoming: set[str] = set()
77
+ for edge in graph_edges:
78
+ if not isinstance(edge, dict):
79
+ continue
80
+ source, target = str(edge.get("source", edge.get("from", ""))), str(edge.get("target", edge.get("to", "")))
81
+ if source not in by_id or target not in by_id:
82
+ continue
83
+ relation = str(edge.get("relation") or edge.get("type") or "").lower()
84
+ if relation in MEMBER_RELATIONS:
85
+ members[source].append({"id": target, "name": str(by_id[target].get("label") or target), "relation": relation})
86
+ if relation in CALL_RELATIONS:
87
+ calls[source].append({"id": target, "relation": relation, "confidence": edge.get("confidence", "UNKNOWN")})
88
+ incoming.add(target)
89
+ for file in files.values():
90
+ for symbol in file["symbols"]:
91
+ symbol["members"] = members.get(symbol["id"], [])[:100]
92
+
93
+ # These are graph-backed call paths, not claims that an HTTP request invokes them.
94
+ paths = []
95
+ seeds = [key for key in calls if key not in incoming and _path(root, by_id[key].get("source_file")) in eligible]
96
+ for key in sorted(seeds, key=lambda k: str(by_id[k].get("label", k))):
97
+ chain, visited, current = [], set(), key
98
+ while current in by_id and current not in visited and len(chain) < 6:
99
+ visited.add(current)
100
+ node = by_id[current]
101
+ chain.append({"name": str(node.get("label") or current), "file": _path(root, node.get("source_file")), "id": current})
102
+ neighbors = calls.get(current, [])
103
+ if not neighbors:
104
+ break
105
+ current = neighbors[0]["id"]
106
+ if len(chain) > 1:
107
+ paths.append({"name": chain[0]["name"], "steps": chain, "status": "INFERRED", "source": "graphify",
108
+ "note": "Recorrido de llamadas del grafo; no confirma un punto de entrada ni el orden de otras ramas.",
109
+ "branches": sum(max(0, len(calls.get(step["id"], [])) - 1) for step in chain)})
110
+ if len(paths) >= 40:
111
+ break
112
+ return {"files": list(files.values()), "call_paths": paths,
113
+ "coverage": {"files_listed": len(files), "eligible_code_files": len(eligible),
114
+ "code_files_with_graph_nodes": len(eligible & analyzed),
115
+ "graph_nodes": len(nodes), "graph_edges": len(graph_edges),
116
+ "truncated": len(files) >= max_files,
117
+ "status": "PARTIAL" if eligible - analyzed or len(files) >= max_files else "REPRESENTED",
118
+ "note": "Un nodo en un archivo indica presencia en el grafo, no análisis exhaustivo de todas sus funciones."}}
@@ -19,7 +19,10 @@ def _read(path: Path) -> str:
19
19
 
20
20
  def _symbol(target: str) -> str | None:
21
21
  match = re.search(r"(?:\[\s*)?([A-Za-z_]\w*(?:\\[A-Za-z_]\w*)*)::class\s*,\s*['\"](\w+)['\"]", target)
22
- return f"{match.group(1).split('\\')[-1]}::{match.group(2)}" if match else None
22
+ if not match:
23
+ return None
24
+ class_name = match.group(1).rsplit("\\", 1)[-1]
25
+ return f"{class_name}::{match.group(2)}"
23
26
 
24
27
 
25
28
  def _e(root: Path, path: Path, offset: int, reason: str, *, symbol: str | None = None, status: str = "CONFIRMED") -> list[dict]:
@@ -60,6 +60,29 @@ def section(title: str, content: str, view: str, icon: str) -> str:
60
60
  return f"<section class='view' data-view='{view}' aria-label='{e(title)}'><div class='view-title'><span>{icon}</span><div><h2>{e(title)}</h2><p>Información extraída del repositorio y respaldada por evidencia.</p></div></div>{content}</section>"
61
61
 
62
62
 
63
+ def file_tree(files: list[dict]) -> str:
64
+ tree: dict = {}
65
+ for item in files:
66
+ cursor = tree
67
+ parts = item["path"].split("/")
68
+ for part in parts[:-1]:
69
+ cursor = cursor.setdefault(part, {})
70
+ cursor[parts[-1]] = item
71
+
72
+ def branch(directory: dict) -> str:
73
+ rows = []
74
+ for name, value in sorted(directory.items(), key=lambda pair: ("path" in pair[1], pair[0].lower())):
75
+ if "path" not in value:
76
+ rows.append(f"<details class='folder'><summary>▸ {e(name)}</summary>{branch(value)}</details>")
77
+ continue
78
+ symbols = value.get("symbols", [])
79
+ details = "".join(f"<li><b>{e(symbol['name'])}</b> <small>{e(symbol['kind'])} · {e(symbol['source'])}</small>" +
80
+ ("<ul>" + "".join(f"<li>{e(member['name'])}</li>" for member in symbol.get("members", [])) + "</ul>" if symbol.get("members") else "") + "</li>" for symbol in symbols[:100])
81
+ rows.append(f"<details class='source-file'><summary>{e(name)} <small>{len(symbols)} símbolos · {'grafo' if value['analyzed'] else 'sin nodos del grafo'}</small></summary><p>{e(value['path'])}</p><ul>{details or '<li>Sin símbolos detectados.</li>'}</ul></details>")
82
+ return "".join(rows)
83
+ return branch(tree) or empty("No se encontraron archivos.")
84
+
85
+
63
86
  def render(context: dict, agent_text: str, graphify_files: list[str]) -> str:
64
87
  project = context["project"]
65
88
  system = context.get("system_model", {})
@@ -72,6 +95,11 @@ def render(context: dict, agent_text: str, graphify_files: list[str]) -> str:
72
95
  framework = system.get("framework") or {}
73
96
  purpose = system.get("purpose", {}).get("text") or "No pudimos determinar el propósito con suficiente confianza."
74
97
  story = system.get("project_story", {}).get("text") or system.get("runtime", {}).get("description") or "Todavía no hay una narrativa técnica verificable."
98
+ exploration = system.get("exploration", {})
99
+ coverage = exploration.get("coverage", {})
100
+ graph_paths = exploration.get("call_paths", [])
101
+ call_cards = "".join(f"<article class='flow-card'><b>{e(path['name'])}</b><p>{' <em>→</em> '.join(e(step['name']) for step in path['steps'])}</p><small>{e(path['note'])} Ramas adicionales: {path['branches']}.</small></article>" for path in graph_paths)
102
+ recent = "".join(f"<li><strong>{e(change['subject'])}</strong><small>{e(change['date'])} · {e(change['commit'][:10])}</small></li>" for change in system.get("recent_changes", []))
75
103
  state = "ACTUAL" if project.get("git_commit") else "SIN COMMIT"
76
104
  tags = "".join(f"<span>{e(item)}</span>" for item in stack[:6])
77
105
  if framework.get("version"):
@@ -127,12 +155,16 @@ def render(context: dict, agent_text: str, graphify_files: list[str]) -> str:
127
155
  section("Entradas", f"<div class='detail-grid'><article class='detail-card'><h3>Puntos de entrada</h3><ul class='detail-list'>{item_rows(points, 'intent')}</ul></article><article class='detail-card'><h3>Entradas y salidas</h3><div class='columns'><div><label>ENTRADAS</label><ul class='detail-list'>{item_rows(system.get('inputs', []), 'description')}</ul></div><div><label>SALIDAS</label><ul class='detail-list'>{item_rows(system.get('outputs', []), 'description')}</ul></div></div></article></div>", "entradas", "⇢"),
128
156
  section("Evidencia", f"<article class='detail-card evidence-card'><h3>Hechos que sostienen el mapa</h3><ul class='detail-list evidence-list'>{evidence_rows}</ul></article><article class='detail-card unknown-card'><h3>Lo que todavía no sabemos</h3><ul class='detail-list'>{item_rows([{'name': item, 'type': 'requiere validación'} for item in unknowns])}</ul></article>", "evidencia", "◈"),
129
157
  section("Exploración técnica", f"<div class='tech-hero'><h3>Profundizá en el análisis del código</h3><p>{e(story)}</p></div><div class='graph-links'>{graph_links}</div><div class='detail-grid'><article class='detail-card'><h3>Dependencias</h3><ul class='detail-list'>{item_rows(system.get('dependencies', []), 'package')}</ul></article><article class='detail-card'><h3>Procesos en segundo plano</h3><ul class='detail-list'>{item_rows(system.get('background_processes', []))}</ul></article></div>", "tecnico", "⌬"),
158
+ section("Archivos y símbolos", f"<div class='tech-hero'><h3>Explorá el proyecto</h3><p>{coverage.get('code_files_with_graph_nodes', 0)} de {coverage.get('eligible_code_files', 0)} archivos de código tienen nodos en el grafo. Estado: {e(coverage.get('status', 'UNKNOWN'))}. Un nodo no garantiza cobertura completa.</p></div><input id='file-search' type='search' placeholder='Buscar archivo o símbolo' aria-label='Buscar archivo o símbolo'><div class='file-tree'>{file_tree(exploration.get('files', []))}</div>", "archivos", "▤"),
159
+ section("Recorridos del grafo", f"<p class='empty'>Estas rutas muestran llamadas conectadas en Graphify. No implican que sean entradas HTTP ni describen todas las ramas.</p><div class='flow-grid'>{call_cards or empty('No hay recorridos de llamadas verificables en el grafo.')}</div>", "recorridos", "⌁"),
160
+ section("Historial Git", f"<article class='detail-card'><h3>Cambios recientes</h3><p>Los mensajes describen cambios registrados; el código actual sigue siendo la fuente para comprender el comportamiento.</p><ul class='detail-list'>{recent or empty('No hay historial disponible.')}</ul></article>", "historial", "◷"),
130
161
  ]
131
162
  agent = json.dumps(agent_text).replace("</", "<\\/")
132
163
  return f"""<!doctype html><html lang='es'><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'><title>KlapContext · {e(project['name'])}</title>
133
164
  <style>
134
165
  :root{{--bg:#07111f;--panel:#0c1a2d;--panel-2:#0e2036;--line:#1b3650;--ink:#edf7ff;--muted:#92a7be;--cyan:#16e0c4;--blue:#3d9cff;--violet:#976dff;--amber:#ffb648}}*{{box-sizing:border-box}}body{{margin:0;min-height:100vh;color:var(--ink);font:14px/1.45 Inter,ui-sans-serif,system-ui,sans-serif;background:radial-gradient(900px 520px at 52% -130px,#153d5c70,transparent 70%),var(--bg)}}button,select{{font:inherit}}.top{{height:56px;display:flex;align-items:center;gap:14px;padding:0 18px;border-bottom:1px solid var(--line);background:#081422e8;backdrop-filter:blur(16px)}}.brand{{display:flex;align-items:center;gap:9px;font-weight:800;font-size:13px}}.logo{{display:grid;place-items:center;width:28px;height:28px;border-radius:7px;color:#051525;background:linear-gradient(135deg,var(--cyan),var(--violet));box-shadow:0 0 18px #16e0c455}}.top-title{{display:flex;flex-direction:column;border-left:1px solid var(--line);padding-left:14px}}.top-title b{{font-size:12px}}.top-title small{{color:var(--muted);font-size:10px}}.top-state{{margin-left:auto;color:var(--cyan);font:10px ui-monospace,monospace}}.repo{{padding:6px 9px;border:1px solid var(--line);border-radius:6px;color:#c5d8ed;font:10px ui-monospace,monospace}}.shell{{display:grid;grid-template-columns:174px minmax(0,1fr) 260px;min-height:calc(100vh - 56px)}}.side{{padding:14px 10px;border-right:1px solid var(--line);background:#081422aa}}.side button{{display:flex;width:100%;align-items:center;gap:9px;padding:10px;border:0;border-radius:6px;background:transparent;color:var(--muted);text-align:left;cursor:pointer;font-size:12px}}.side button:hover,.side button.active{{background:linear-gradient(90deg,#0c514d88,#0c243b);color:var(--ink);box-shadow:inset 2px 0 var(--cyan)}}.side .nav-icon{{color:var(--cyan);font-size:16px;width:19px;text-align:center}}.side-footer{{margin-top:28px;padding-top:16px;border-top:1px solid var(--line)}}main{{padding:16px;min-width:0;max-width:1220px;width:100%;margin:auto}}.view{{display:none;animation:in .28s ease}}.view.active{{display:block}}@keyframes in{{from{{opacity:.35;transform:translateY(6px)}}to{{opacity:1;transform:none}}}}.view-title{{display:none}}.overview-head{{display:flex;justify-content:space-between;gap:24px;padding:5px 4px 15px}}.eyebrow,label{{color:var(--cyan);font:10px ui-monospace,monospace;letter-spacing:.13em}}h1{{margin:3px 0 4px;font-size:25px;letter-spacing:-.045em}}h2,h3,p{{margin-top:0}}.overview-head p{{max-width:760px;margin-bottom:9px;color:#c6d7e7;font-size:12px}}.overview-state{{display:flex;align-items:flex-end;flex-direction:column;gap:5px;white-space:nowrap}}.live{{color:var(--cyan);font:10px ui-monospace,monospace}}.overview-state small{{color:var(--muted);font-size:10px}}.tags{{display:flex;gap:5px;flex-wrap:wrap}}.tags span{{padding:3px 6px;border:1px solid #29506b;border-radius:4px;color:#aac0d8;font:9px ui-monospace,monospace}}.map-card,.compact-panel,.detail-card,.flow-card,.trace-box{{border:1px solid var(--line);border-radius:9px;background:linear-gradient(135deg,#0d2037e8,#091727e8);box-shadow:0 18px 35px #00000018}}header{{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:13px 14px;border-bottom:1px solid #1a334c}}header>div{{display:flex;align-items:center;gap:9px}}header h2{{margin:0;font-size:13px}}header p{{margin:2px 0 0;color:var(--muted);font-size:10px}}.icon{{display:grid;place-items:center;width:28px;height:28px;border-radius:6px;color:var(--cyan);background:#0a3b4277;font-size:17px}}.outline,.text-button{{border:1px solid #24658a;border-radius:6px;background:#0a1a2d;color:var(--cyan);padding:7px 9px;font-size:10px;font-weight:700;cursor:pointer}}.text-button{{border:0;background:transparent;padding:0}}.map-shell{{padding:9px 12px 10px;background:radial-gradient(circle at 50% 42%,#123e5570,transparent 60%)}}.map-legend{{display:flex;justify-content:space-between;color:var(--muted);font:9px ui-monospace,monospace}}.map-legend i{{display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--cyan);margin-right:4px}}.system-map{{display:block;width:100%;height:min(33vh,280px)}}.map-links path{{fill:none;stroke:#22e2cf9c;stroke-width:1.5;filter:drop-shadow(0 0 3px #20e3d055)}}.map-node rect{{fill:#102b49;stroke:#2f83b4;stroke-width:1}}.map-node text{{fill:#e7f5ff;font:10px ui-monospace,monospace;text-anchor:middle}}.map-node .map-kind{{fill:#84a3bd;font-size:8px}}.map-empty{{padding:58px;text-align:center;color:var(--muted)}}.quick-grid{{display:grid;grid-template-columns:repeat(4,1fr);gap:9px;margin:10px 0}}.quick-grid article{{min-height:85px;padding:11px;border:1px solid var(--line);border-radius:7px;background:#0b192b}}.quick-grid b{{display:block;color:#f2f8ff;font-size:24px;letter-spacing:-.06em}}.quick-grid span{{display:block;font-size:10px;font-weight:700}}.quick-grid small{{display:block;margin-top:4px;color:var(--muted);font-size:9px}}.quick-grid .attention b{{color:var(--amber)}}.dashboard-bottom,.detail-grid{{display:grid;grid-template-columns:1fr 1fr;gap:10px}}.compact-panel header{{padding:11px 13px}}.compact-panel h2{{font-size:12px}}.surfaces{{display:grid;grid-template-columns:repeat(4,1fr);gap:7px;padding:10px}}.surface{{padding:8px;border-radius:5px;background:#091628;border:1px solid #1c3a54}}.surface b{{display:block;color:var(--cyan);font-size:18px}}.surface span{{color:var(--muted);font-size:9px}}.start-list,.detail-list{{list-style:none;margin:0;padding:8px 13px}}.start-list li{{padding:5px 0;border-bottom:1px solid #1a324a}}.start-list li:last-child,.detail-list li:last-child{{border:0}}.start-list b,.detail-list strong{{display:block;font-size:11px}}.start-list small,.detail-list small{{display:block;color:var(--muted);font-size:9px}}.empty{{padding:8px 13px;color:var(--muted);font-size:11px}}.right{{padding:16px 11px;border-left:1px solid var(--line);background:#081422aa}}.agent-card,.insight-card{{padding:14px;margin-bottom:10px;border:1px solid var(--line);border-radius:8px;background:linear-gradient(145deg,#122a47,#0b182b)}}.agent-card{{border-color:#317b8d}}.agent-card h3,.insight-card h3{{margin-bottom:7px;font-size:12px}}.agent-card p,.insight-card p{{color:#b4c5d7;font-size:10px}}.copy{{width:100%;border:0;border-radius:5px;padding:9px;background:linear-gradient(90deg,var(--cyan),#46d1e7);color:#03202a;font-weight:800;cursor:pointer;font-size:11px}}.insight-card ul{{list-style:none;margin:0;padding:0}}.insight-card li{{padding:7px 0;border-bottom:1px solid #22405a;color:#c0d2e4;font-size:10px}}.insight-card li:last-child{{border:0}}.view-title{{margin:0 0 13px;align-items:center;gap:9px}}.view-title>span{{display:grid;place-items:center;width:31px;height:31px;border-radius:7px;background:#0a3b4277;color:var(--cyan);font-size:19px}}.view-title h2{{margin:0;font-size:18px}}.view-title p{{margin:2px 0 0;color:var(--muted);font-size:10px}}.flow-grid{{display:grid;grid-template-columns:repeat(2,1fr);gap:10px}}.flow-card{{padding:15px}}.flow-card b{{font-size:13px}}.flow-card p{{margin:8px 0 0;color:#b9cce0;font:10px/1.8 ui-monospace,monospace}}em{{color:var(--cyan);font-style:normal}}.trace-box{{margin-top:10px;padding:14px}}select{{display:block;margin:7px 0;padding:8px;border:1px solid var(--line);border-radius:5px;background:#081727;color:var(--ink);font-size:11px}}#flow-trace{{color:var(--cyan);font:11px/1.7 ui-monospace,monospace}}.detail-card{{padding:15px}}.detail-card h3{{font-size:13px}}.detail-list{{padding:0}}.detail-list li{{display:flex;gap:8px;align-items:flex-start;padding:8px 0;border-bottom:1px solid #1a324a}}.dot{{display:block;flex:none;width:6px;height:6px;margin-top:5px;border-radius:50%;background:var(--violet);box-shadow:0 0 7px var(--violet)}}.evidence-list mark{{margin-left:auto;border-radius:3px;padding:2px 4px;background:#10493f;color:var(--cyan);font:8px ui-monospace,monospace}}.unknown-card{{margin-top:10px}}.columns{{display:grid;grid-template-columns:1fr 1fr;gap:18px}}.tech-hero{{padding:19px;border:1px solid #2f4968;border-radius:9px;background:linear-gradient(120deg,#172d50,#10192f)}}.tech-hero h3{{margin-bottom:6px;font-size:16px}}.tech-hero p{{margin:0;color:#b4c7dc;font-size:12px}}.graph-links{{display:flex;gap:10px;margin:10px 0}}.graph-links a{{flex:1;padding:13px;border:1px solid #2c5875;border-radius:7px;background:#0b1a2d;color:#d9efff;text-decoration:none;font-size:11px;font-weight:700}}.graph-links a span{{float:right;color:var(--cyan)}}@media(max-width:1050px){{.shell{{grid-template-columns:160px minmax(0,1fr)}}.right{{display:none}}}}@media(max-width:720px){{.shell{{display:block}}.side{{display:flex;overflow:auto;padding:8px;border-bottom:1px solid var(--line)}}.side button{{min-width:max-content}}.side-footer{{display:none}}.top-title{{display:none}}.repo{{display:none}}main{{padding:10px}}.overview-head{{display:block}}.overview-state{{margin-top:10px;align-items:flex-start}}.quick-grid{{grid-template-columns:repeat(2,1fr)}}.dashboard-bottom,.detail-grid,.flow-grid,.columns{{grid-template-columns:1fr}}.system-map{{width:680px;max-width:none}}.map-shell{{overflow:auto}}.surfaces{{grid-template-columns:repeat(2,1fr)}}}}
135
- </style></head><body><nav class='top'><div class='brand'><span class='logo'>K</span>klap_context</div><div class='top-title'><b>Comprender el sistema</b><small>Explorá componentes, flujos y evidencia de tu repositorio.</small></div><span class='top-state'>● {state}</span><span class='repo'>{e(project['name'])}</span></nav><div class='shell'><aside class='side'><button class='active' data-go='sistema'><span class='nav-icon'>⌘</span>Sistema</button><button data-go='flujos'><span class='nav-icon'>⌁</span>Flujos</button><button data-go='entradas'><span class='nav-icon'>⇢</span>Entradas</button><button data-go='evidencia'><span class='nav-icon'>◈</span>Evidencias</button><button data-go='tecnico'><span class='nav-icon'>⌬</span>Graphify</button><div class='side-footer'><button data-go='evidencia'><span class='nav-icon'>?</span>Ayuda y confianza</button></div></aside><main>{''.join(views)}</main><aside class='right'><section class='agent-card'><h3>✦ Acciones para agente</h3><p>Generá un briefing listo para usar y profundizá luego en el sistema.</p><button class='copy' onclick='copyContext(this)'>▣ Copiar contexto</button></section><section class='insight-card'><h3>💡 Hallazgos principales <span>{min(len(evidence), 9)}</span></h3><ul><li>{len(points)} punto(s) de entrada detectado(s).</li><li>{len(flows)} flujo(s) principal(es) disponible(s).</li><li>{len(evidence)} hecho(s) con respaldo en el código.</li></ul></section><section class='insight-card'><h3>⚡ Siguiente paso</h3><p>Usá las vistas laterales para investigar un área, sin perder el panorama inicial.</p></section></aside></div><script>const agentContext={agent};const views=[...document.querySelectorAll('.view')];const nav=[...document.querySelectorAll('[data-go]')];function go(name){{views.forEach(view=>view.classList.toggle('active',view.dataset.view===name));nav.forEach(button=>button.classList.toggle('active',button.dataset.go===name));history.replaceState(null,'','#'+name);window.scrollTo({{top:0,behavior:'smooth'}})}}nav.forEach(button=>button.addEventListener('click',()=>go(button.dataset.go)));go(location.hash.slice(1)||'sistema');function copyContext(button){{navigator.clipboard.writeText(agentContext).then(()=>{{const old=button.textContent;button.textContent='✓ Contexto copiado';setTimeout(()=>button.textContent=old,1500)}})}}const paths={traces};const selector=document.querySelector('#flow-select');if(selector){{const trace=document.querySelector('#flow-trace');const show=()=>trace.textContent=paths[selector.value].join(' → ');selector.addEventListener('change',show);show();}}</script></body></html>"""
166
+ .file-tree{{max-height:70vh;overflow:auto;margin-top:12px;padding:14px;border:1px solid var(--line);border-radius:9px;background:var(--panel)}}.file-tree details{{padding:3px 4px 3px 14px;border-left:1px solid var(--line)}}.file-tree summary{{cursor:pointer;padding:5px;color:var(--ink)}}.file-tree small{{color:var(--muted)}}.source-file ul{{padding-left:22px}}.source-file li{{padding:3px}}#file-search{{width:100%;padding:11px;margin-top:12px;background:var(--panel);border:1px solid var(--line);border-radius:7px;color:var(--ink)}}
167
+ </style></head><body><nav class='top'><div class='brand'><span class='logo'>K</span>klap_context</div><div class='top-title'><b>Comprender el sistema</b><small>Explorá componentes, flujos y evidencia de tu repositorio.</small></div><span class='top-state'>● {state}</span><span class='repo'>{e(project['name'])}</span></nav><div class='shell'><aside class='side'><button class='active' data-go='sistema'><span class='nav-icon'>⌘</span>Sistema</button><button data-go='flujos'><span class='nav-icon'>⌁</span>Flujos</button><button data-go='entradas'><span class='nav-icon'>⇢</span>Entradas</button><button data-go='evidencia'><span class='nav-icon'>◈</span>Evidencias</button><button data-go='archivos'><span class='nav-icon'>▤</span>Archivos</button><button data-go='recorridos'><span class='nav-icon'>⌁</span>Recorridos</button><button data-go='historial'><span class='nav-icon'>◷</span>Git</button><button data-go='tecnico'><span class='nav-icon'>⌬</span>Graphify</button><div class='side-footer'><button data-go='evidencia'><span class='nav-icon'>?</span>Ayuda y confianza</button></div></aside><main>{''.join(views)}</main><aside class='right'><section class='agent-card'><h3>✦ Acciones para agente</h3><p>Generá un briefing listo para usar y profundizá luego en el sistema.</p><button class='copy' onclick='copyContext(this)'>▣ Copiar contexto</button></section><section class='insight-card'><h3>💡 Hallazgos principales <span>{min(len(evidence), 9)}</span></h3><ul><li>{len(points)} punto(s) de entrada detectado(s).</li><li>{len(flows)} flujo(s) principal(es) disponible(s).</li><li>{len(evidence)} hecho(s) con respaldo en el código.</li></ul></section><section class='insight-card'><h3>⚡ Siguiente paso</h3><p>Usá las vistas laterales para investigar un área, sin perder el panorama inicial.</p></section></aside></div><script>const agentContext={agent};const views=[...document.querySelectorAll('.view')];const nav=[...document.querySelectorAll('[data-go]')];function go(name){{views.forEach(view=>view.classList.toggle('active',view.dataset.view===name));nav.forEach(button=>button.classList.toggle('active',button.dataset.go===name));history.replaceState(null,'','#'+name);window.scrollTo({{top:0,behavior:'smooth'}})}}nav.forEach(button=>button.addEventListener('click',()=>go(button.dataset.go)));go(location.hash.slice(1)||'sistema');function copyContext(button){{navigator.clipboard.writeText(agentContext).then(()=>{{const old=button.textContent;button.textContent='✓ Contexto copiado';setTimeout(()=>button.textContent=old,1500)}})}}const paths={traces};const search=document.querySelector('#file-search');if(search)search.addEventListener('input',()=>{{const term=search.value.toLocaleLowerCase();document.querySelectorAll('.source-file').forEach(file=>{{file.hidden=!file.textContent.toLocaleLowerCase().includes(term)}});document.querySelectorAll('.folder').forEach(folder=>{{folder.open=!!term}})}});const selector=document.querySelector('#flow-select');if(selector){{const trace=document.querySelector('#flow-trace');const show=()=>trace.textContent=paths[selector.value].join(' → ');selector.addEventListener('change',show);show();}}</script></body></html>"""
136
168
 
137
169
 
138
170
  def write(context: dict, agent_text: str, graphify_files: list[str], output: Path) -> None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: klapcontext
3
- Version: 0.3.3
3
+ Version: 0.4.0
4
4
  Summary: Make your repository understandable to humans and AI.
5
5
  Author: Willy Cordon
6
6
  License-Expression: Apache-2.0
@@ -82,6 +82,14 @@ klap open
82
82
  `.klap/`. KlapContext agrega esa carpeta a `.git/info/exclude`; nunca modifica
83
83
  tu `.gitignore`.
84
84
 
85
+ El portal incluye un árbol navegable de archivos y símbolos extraídos del grafo,
86
+ recorridos de llamadas y cambios recientes de Git. El contexto del agente y
87
+ `klap context` usan las mismas rutas y símbolos. El indicador de cobertura cuenta
88
+ archivos de código con al menos un nodo en Graphify: **no mide exhaustividad de
89
+ funciones ni garantiza que se hayan resuelto rutas dinámicas**. Los recorridos
90
+ son caminos del grafo, no afirmaciones de que una ruta HTTP los ejecuta; sus
91
+ ramas pueden requerir inspección del código original.
92
+
85
93
  ## Comandos
86
94
 
87
95
  | Comando | Descripción |
@@ -7,6 +7,7 @@ src/klapcontext/cli.py
7
7
  src/klapcontext/context_builder.py
8
8
  src/klapcontext/detector.py
9
9
  src/klapcontext/evidence.py
10
+ src/klapcontext/exploration.py
10
11
  src/klapcontext/git.py
11
12
  src/klapcontext/graphify.py
12
13
  src/klapcontext/portal.py
@@ -39,4 +40,5 @@ src/klapcontext/providers/code_intelligence.py
39
40
  src/klapcontext/providers/graphify.py
40
41
  src/klapcontext/providers/javascript_intelligence.py
41
42
  src/klapcontext/providers/python_intelligence.py
43
+ tests/test_exploration.py
42
44
  tests/test_klapcontext.py
@@ -0,0 +1,68 @@
1
+ """End-to-end checks for the graph-to-context navigation boundary."""
2
+ from __future__ import annotations
3
+
4
+ import sys
5
+ import unittest
6
+ from pathlib import Path
7
+ from tempfile import TemporaryDirectory
8
+
9
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
10
+
11
+ from klapcontext.agent.compiler import compile_context
12
+ from klapcontext.agent_context import render as agent_render
13
+ from klapcontext.context_builder import build
14
+ from klapcontext.detector import detect_project
15
+ from klapcontext.portal import render as portal_render
16
+
17
+
18
+ class GraphExplorationTests(unittest.TestCase):
19
+ def test_csharp_graph_reaches_agent_and_portal(self):
20
+ with TemporaryDirectory() as directory:
21
+ root = Path(directory)
22
+ controller = root / "src" / "InfractionsController.cs"
23
+ controller.parent.mkdir()
24
+ controller.write_text("class InfractionsController {}")
25
+ graph = {"nodes": [
26
+ {"id": "controller", "label": "InfractionsController", "source_file": "src/InfractionsController.cs", "type": "class"},
27
+ {"id": "list", "label": "List", "source_file": "src/InfractionsController.cs", "type": "method"},
28
+ {"id": "service", "label": "FindInfractions", "source_file": "src/InfractionsController.cs", "type": "method"},
29
+ ], "links": [{"source": "controller", "target": "list", "relation": "method"},
30
+ {"source": "list", "target": "service", "relation": "calls", "confidence": "EXTRACTED"}]}
31
+ context = build(root, graph)
32
+ files = context["system_model"]["exploration"]["files"]
33
+ self.assertEqual(files[0]["symbols"][0]["members"][0]["name"], "List")
34
+ self.assertEqual(context["system_model"]["exploration"]["call_paths"][0]["steps"][1]["name"], "FindInfractions")
35
+ page = portal_render(context, agent_render(context), [])
36
+ self.assertIn("InfractionsController.cs", page)
37
+ self.assertIn("FindInfractions", page)
38
+ self.assertIn("Archivos y símbolos", page)
39
+ cache = root / ".klap"
40
+ cache.mkdir()
41
+ import json
42
+ (cache / "context.json").write_text(json.dumps(context))
43
+ task = compile_context(root, "InfractionsController", max_tokens=1000)
44
+ self.assertEqual(task["read_first"][0]["path"], "src/InfractionsController.cs")
45
+
46
+ def test_partial_coverage_does_not_claim_full_scan(self):
47
+ with TemporaryDirectory() as directory:
48
+ root = Path(directory)
49
+ (root / "app.py").write_text("def run(): pass")
50
+ context = build(root, {"nodes": [], "links": []})
51
+ coverage = context["system_model"]["exploration"]["coverage"]
52
+ self.assertEqual(coverage["status"], "PARTIAL")
53
+ self.assertEqual(coverage["eligible_code_files"], 1)
54
+ self.assertIn("Cobertura parcial", " ".join(context["system_model"]["unknowns"]))
55
+
56
+ def test_nested_manifests_detect_web_stacks(self):
57
+ with TemporaryDirectory() as directory:
58
+ root = Path(directory)
59
+ (root / "api").mkdir()
60
+ (root / "api" / "service.csproj").write_text('<Project Sdk="Microsoft.NET.Sdk.Web" />')
61
+ stack, evidence = detect_project(root)
62
+ self.assertIn("C#", stack["languages"])
63
+ self.assertIn("ASP.NET", stack["frameworks"])
64
+ self.assertEqual(evidence[0].path, "api/service.csproj")
65
+
66
+
67
+ if __name__ == "__main__":
68
+ unittest.main()
File without changes
File without changes
File without changes