klapcontext 0.2.1__tar.gz → 0.3.1__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 (44) hide show
  1. {klapcontext-0.2.1/src/klapcontext.egg-info → klapcontext-0.3.1}/PKG-INFO +5 -1
  2. {klapcontext-0.2.1 → klapcontext-0.3.1}/README.md +4 -0
  3. {klapcontext-0.2.1 → klapcontext-0.3.1}/src/klapcontext/__init__.py +1 -1
  4. {klapcontext-0.2.1 → klapcontext-0.3.1}/src/klapcontext/agent/compiler.py +7 -2
  5. {klapcontext-0.2.1 → klapcontext-0.3.1}/src/klapcontext/context_builder.py +24 -2
  6. {klapcontext-0.2.1 → klapcontext-0.3.1}/src/klapcontext/detector.py +20 -7
  7. {klapcontext-0.2.1 → klapcontext-0.3.1}/src/klapcontext/frameworks/laravel.py +50 -9
  8. {klapcontext-0.2.1 → klapcontext-0.3.1}/src/klapcontext/providers/code_intelligence.py +34 -3
  9. {klapcontext-0.2.1 → klapcontext-0.3.1}/src/klapcontext/system.py +22 -12
  10. {klapcontext-0.2.1 → klapcontext-0.3.1/src/klapcontext.egg-info}/PKG-INFO +5 -1
  11. {klapcontext-0.2.1 → klapcontext-0.3.1}/tests/test_klapcontext.py +26 -1
  12. {klapcontext-0.2.1 → klapcontext-0.3.1}/LICENSE +0 -0
  13. {klapcontext-0.2.1 → klapcontext-0.3.1}/pyproject.toml +0 -0
  14. {klapcontext-0.2.1 → klapcontext-0.3.1}/setup.cfg +0 -0
  15. {klapcontext-0.2.1 → klapcontext-0.3.1}/src/klapcontext/agent/__init__.py +0 -0
  16. {klapcontext-0.2.1 → klapcontext-0.3.1}/src/klapcontext/agent/capabilities.py +0 -0
  17. {klapcontext-0.2.1 → klapcontext-0.3.1}/src/klapcontext/agent/impact.py +0 -0
  18. {klapcontext-0.2.1 → klapcontext-0.3.1}/src/klapcontext/agent/intents.py +0 -0
  19. {klapcontext-0.2.1 → klapcontext-0.3.1}/src/klapcontext/agent/models.py +0 -0
  20. {klapcontext-0.2.1 → klapcontext-0.3.1}/src/klapcontext/agent/planner.py +0 -0
  21. {klapcontext-0.2.1 → klapcontext-0.3.1}/src/klapcontext/agent/verification.py +0 -0
  22. {klapcontext-0.2.1 → klapcontext-0.3.1}/src/klapcontext/agent/workflows.py +0 -0
  23. {klapcontext-0.2.1 → klapcontext-0.3.1}/src/klapcontext/agent_context.py +0 -0
  24. {klapcontext-0.2.1 → klapcontext-0.3.1}/src/klapcontext/cli.py +0 -0
  25. {klapcontext-0.2.1 → klapcontext-0.3.1}/src/klapcontext/evidence.py +0 -0
  26. {klapcontext-0.2.1 → klapcontext-0.3.1}/src/klapcontext/frameworks/__init__.py +0 -0
  27. {klapcontext-0.2.1 → klapcontext-0.3.1}/src/klapcontext/frameworks/base.py +0 -0
  28. {klapcontext-0.2.1 → klapcontext-0.3.1}/src/klapcontext/frameworks/fastapi.py +0 -0
  29. {klapcontext-0.2.1 → klapcontext-0.3.1}/src/klapcontext/frameworks/generic_php.py +0 -0
  30. {klapcontext-0.2.1 → klapcontext-0.3.1}/src/klapcontext/frameworks/node.py +0 -0
  31. {klapcontext-0.2.1 → klapcontext-0.3.1}/src/klapcontext/git.py +0 -0
  32. {klapcontext-0.2.1 → klapcontext-0.3.1}/src/klapcontext/graphify.py +0 -0
  33. {klapcontext-0.2.1 → klapcontext-0.3.1}/src/klapcontext/portal.py +0 -0
  34. {klapcontext-0.2.1 → klapcontext-0.3.1}/src/klapcontext/providers/__init__.py +0 -0
  35. {klapcontext-0.2.1 → klapcontext-0.3.1}/src/klapcontext/providers/base.py +0 -0
  36. {klapcontext-0.2.1 → klapcontext-0.3.1}/src/klapcontext/providers/graphify.py +0 -0
  37. {klapcontext-0.2.1 → klapcontext-0.3.1}/src/klapcontext/providers/javascript_intelligence.py +0 -0
  38. {klapcontext-0.2.1 → klapcontext-0.3.1}/src/klapcontext/providers/python_intelligence.py +0 -0
  39. {klapcontext-0.2.1 → klapcontext-0.3.1}/src/klapcontext/semantic.py +0 -0
  40. {klapcontext-0.2.1 → klapcontext-0.3.1}/src/klapcontext.egg-info/SOURCES.txt +0 -0
  41. {klapcontext-0.2.1 → klapcontext-0.3.1}/src/klapcontext.egg-info/dependency_links.txt +0 -0
  42. {klapcontext-0.2.1 → klapcontext-0.3.1}/src/klapcontext.egg-info/entry_points.txt +0 -0
  43. {klapcontext-0.2.1 → klapcontext-0.3.1}/src/klapcontext.egg-info/requires.txt +0 -0
  44. {klapcontext-0.2.1 → klapcontext-0.3.1}/src/klapcontext.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: klapcontext
3
- Version: 0.2.1
3
+ Version: 0.3.1
4
4
  Summary: Make your repository understandable to humans and AI.
5
5
  Author: Willy Cordon
6
6
  License-Expression: Apache-2.0
@@ -91,6 +91,7 @@ tu `.gitignore`.
91
91
  | `klap status [ruta]` | Indica si el contexto está actualizado respecto a Git. |
92
92
  | `klap open [ruta]` | Abre el portal humano estático. |
93
93
  | `klap agent [ruta]` | Muestra la configuración MCP local de Graphify. |
94
+ | `klap context "tarea" [ruta]` | Compila un contexto focalizado reutilizando `.klap/context.json` si existe. |
94
95
  | `klap --version` | Muestra la versión instalada. |
95
96
 
96
97
  ## Salidas
@@ -121,6 +122,9 @@ El workflow de GitHub Actions publica tags `v*` mediante PyPI Trusted
121
122
  Publishing. Antes del primer tag, configurá el publisher de PyPI para este
122
123
  repositorio y workflow. No se necesitan tokens de PyPI en el repositorio.
123
124
 
125
+ El repositorio ya incluye `.github/workflows/publish-pypi.yml`: al publicar un
126
+ tag `vX.Y.Z`, GitHub construye el paquete y lo publica con ese publisher.
127
+
124
128
  ## Licencia
125
129
 
126
130
  Apache-2.0. Ver [LICENSE](LICENSE).
@@ -57,6 +57,7 @@ tu `.gitignore`.
57
57
  | `klap status [ruta]` | Indica si el contexto está actualizado respecto a Git. |
58
58
  | `klap open [ruta]` | Abre el portal humano estático. |
59
59
  | `klap agent [ruta]` | Muestra la configuración MCP local de Graphify. |
60
+ | `klap context "tarea" [ruta]` | Compila un contexto focalizado reutilizando `.klap/context.json` si existe. |
60
61
  | `klap --version` | Muestra la versión instalada. |
61
62
 
62
63
  ## Salidas
@@ -87,6 +88,9 @@ El workflow de GitHub Actions publica tags `v*` mediante PyPI Trusted
87
88
  Publishing. Antes del primer tag, configurá el publisher de PyPI para este
88
89
  repositorio y workflow. No se necesitan tokens de PyPI en el repositorio.
89
90
 
91
+ El repositorio ya incluye `.github/workflows/publish-pypi.yml`: al publicar un
92
+ tag `vX.Y.Z`, GitHub construye el paquete y lo publica con ese publisher.
93
+
90
94
  ## Licencia
91
95
 
92
96
  Apache-2.0. Ver [LICENSE](LICENSE).
@@ -1,3 +1,3 @@
1
1
  """KlapContext local engineering-context generator."""
2
2
 
3
- __version__ = "0.2.1"
3
+ __version__ = "0.3.1"
@@ -27,14 +27,19 @@ def _rank(items: list[dict], terms: set[str], limit: int) -> list[dict]:
27
27
 
28
28
  def compile_context(root: Path, query: str, *, detail: str = "standard", max_tokens: int = 5000) -> dict:
29
29
  plan = ContextPlanner().plan(query, detail=detail, max_tokens=max_tokens)
30
- context = build(root, {"nodes": []}); model = context["system_model"]; limit = min(DETAIL_LIMITS[detail], max(1, max_tokens // 350)); terms = _terms(query, plan.area)
30
+ cached = root / ".klap" / "context.json"
31
+ try:
32
+ context = json.loads(cached.read_text(encoding="utf-8")) if cached.exists() else build(root, {"nodes": []})
33
+ except (OSError, json.JSONDecodeError):
34
+ context = build(root, {"nodes": []})
35
+ model = context["system_model"]; limit = min(DETAIL_LIMITS[detail], max(1, max_tokens // 350)); terms = _terms(query, plan.area)
31
36
  entries = _rank(model["entry_points"], terms, limit); flows = _rank(model["main_flows"], terms, limit); files = _rank(model["important_files"], terms, limit)
32
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]
33
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}
34
39
  result["verification"] = reconcile(result["evidence"])
35
40
  result = sanitize(result)
36
41
  result["estimated_tokens"] = min(max_tokens, len(json.dumps(result, ensure_ascii=False)) // 4)
37
- result["execution"] = {"capabilities": plan.capabilities, "providers": plan.providers, "files_selected": len(result["read_first"]), "sections_selected": len(plan.sections), "adaptive": True}
42
+ result["execution"] = {"capabilities": plan.capabilities, "providers": plan.providers, "files_selected": len(result["read_first"]), "sections_selected": len(plan.sections), "adaptive": True, "context_source": "cached" if cached.exists() else "generated"}
38
43
  return result
39
44
 
40
45
 
@@ -18,6 +18,21 @@ def _nodes(graph: dict) -> list[dict]:
18
18
  return graph.get("nodes", []) if isinstance(graph.get("nodes"), list) else graph.get("graph", {}).get("nodes", [])
19
19
 
20
20
 
21
+ def _edges(graph: dict) -> list[dict]:
22
+ """Graphify has emitted both `edges` and `links` across supported versions."""
23
+ for key in ("edges", "links"):
24
+ value = graph.get(key)
25
+ if isinstance(value, list):
26
+ return value
27
+ nested = graph.get("graph", {})
28
+ if isinstance(nested, dict):
29
+ for key in ("edges", "links"):
30
+ value = nested.get(key)
31
+ if isinstance(value, list):
32
+ return value
33
+ return []
34
+
35
+
21
36
  def _modules(nodes: list[dict]) -> list[dict]:
22
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]
23
38
 
@@ -55,7 +70,14 @@ def _flows(model: dict) -> list[dict]:
55
70
  seen = {entry["name"], current}
56
71
  for _ in range(4):
57
72
  candidates = outgoing.get(current, []) or [item for source, values in outgoing.items() if source.endswith(current) for item in values]
58
- edge = next((item for item in candidates if item["target"] not in seen), None)
73
+ candidates = [item for item in candidates if item["target"] not in seen]
74
+ current_class = current.rsplit("::", 1)[0].split("\\")[-1]
75
+ # A same-class call (for example a controller/service delegating to
76
+ # another method) is usually the main continuation of an HTTP flow.
77
+ # Preserve the other calls in the semantic model instead of guessing
78
+ # that they occur after it.
79
+ edge = next((item for item in candidates if item["target"].rsplit("::", 1)[0].split("\\")[-1] == current_class), None)
80
+ edge = edge or next(iter(candidates), None)
59
81
  if not edge: break
60
82
  target = edge["target"]; component = components.get(target, {})
61
83
  steps.append({"name": target, "kind": edge["type"].casefold(), "path": component.get("file") or edge.get("file"), "status": edge["status"]})
@@ -116,6 +138,6 @@ def build(root: Path, graph: dict) -> dict:
116
138
  tests = [{"path": item, "reason": "Tests detectados"} for item in ("tests", "test", "phpunit.xml", "pytest.ini") if (root / item).exists()]
117
139
  story = " ".join(item for item in (value["text"], runtime["description"], "La aplicación está contenerizada con Docker." if "Docker" in deploy["tools"] else None) if item)
118
140
  system = {"purpose": value, "project_story": {"text": story or None, "status": "CONFIRMED" if story else "UNKNOWN"}, "runtime": runtime, "framework": raw["framework"], "runtime_surfaces": raw["runtime_surfaces"], "routes": raw["routes"], "commands": raw["commands"], "scheduled_processes": raw["scheduled_processes"], "queue_jobs": raw["queue_jobs"], "events": raw["events"], "listeners": raw["listeners"], "components": raw["components"], "dependencies": raw["dependencies"], "semantic_model": model, "entry_points": points, "main_flows": flows, "system_interactions": _interactions(flows, systems, stores), "capabilities": _modules(_nodes(graph)), "inputs": inputs, "outputs": outputs, "external_systems": systems, "background_processes": background, "datastores": stores, "deployment": deploy, "observability": observability, "important_files": important, "start_here": [], "unknowns": [item for item, found in (("Propósito de negocio del proyecto", value["text"]), ("Plataforma de despliegue en producción", deploy["tools"])) if not found]}
119
- technical = {"languages": stack["languages"], "frameworks": stack["frameworks"], "framework": raw["framework"], "dependencies": raw["dependencies"], "graph": {"nodes": len(_nodes(graph)), "edges": len(graph.get("edges", graph.get("graph", {}).get("edges", [])))}, "documents": [{"path": item["path"]} for item in docs], "tests": tests, "infrastructure": stack["infrastructure"]}
141
+ 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"]}
120
142
  project = {"name": root.name, "type": stack["frameworks"][0] if stack["frameworks"] else None, "root": str(root.resolve()), "generated_at": datetime.now(timezone.utc).isoformat(), "git_commit": commit(root)}
121
143
  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)}
@@ -1,11 +1,24 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ import fnmatch
5
+ import os
4
6
  from pathlib import Path
5
7
 
6
8
  from .evidence import Evidence
7
9
 
8
10
 
11
+ EXCLUDED = {".git", ".klap", "graphify-out", "node_modules", "vendor", ".venv", "venv", ".test-venv", "build", "dist"}
12
+
13
+
14
+ def _sources(root: Path, pattern: str):
15
+ for directory, children, names in os.walk(root):
16
+ children[:] = [child for child in children if child not in EXCLUDED]
17
+ for name in names:
18
+ if fnmatch.fnmatch(name, pattern):
19
+ yield Path(directory) / name
20
+
21
+
9
22
  def detect_project(root: Path) -> tuple[dict, list[Evidence]]:
10
23
  evidence: list[Evidence] = []
11
24
  stack = {"languages": [], "frameworks": [], "databases": [], "runtime": [], "infrastructure": [], "package_managers": [], "project_type": "unknown"}
@@ -22,10 +35,10 @@ def detect_project(root: Path) -> tuple[dict, list[Evidence]]:
22
35
  if "laravel/lumen-framework" in deps:
23
36
  add("frameworks", "Lumen", "composer.json", "laravel/lumen-framework dependency detected")
24
37
  stack["project_type"] = "backend-api"
25
- elif any(root.rglob("*.php")):
38
+ elif any(_sources(root, "*.php")):
26
39
  # A standalone PHP repository has no Composer manifest to identify it,
27
40
  # but language analysis can still provide a useful generic model.
28
- first_php = next(root.rglob("*.php"))
41
+ first_php = next(_sources(root, "*.php"))
29
42
  add("languages", "PHP", str(first_php.relative_to(root)).replace("\\", "/"), "PHP source file detected")
30
43
  package = root / "package.json"
31
44
  if package.exists():
@@ -35,7 +48,7 @@ def detect_project(root: Path) -> tuple[dict, list[Evidence]]:
35
48
  except json.JSONDecodeError: deps = {}
36
49
  for name, label in (("next", "Next.js"), ("vue", "Vue"), ("react", "React"), ("express", "Express"), ("@nestjs/core", "NestJS")):
37
50
  if name in deps: add("frameworks", label, "package.json", f"{name} dependency detected")
38
- if any(root.rglob("*.ts")) or any(root.rglob("*.tsx")): add("languages", "TypeScript", "package.json", "TypeScript source detected")
51
+ if any(_sources(root, "*.ts")) or any(_sources(root, "*.tsx")): add("languages", "TypeScript", "package.json", "TypeScript source detected")
39
52
  if (root / "pyproject.toml").exists() or (root / "requirements.txt").exists():
40
53
  python_manifest = root / "pyproject.toml" if (root / "pyproject.toml").exists() else root / "requirements.txt"
41
54
  add("languages", "Python", python_manifest.name, "Python manifest detected")
@@ -54,8 +67,8 @@ def detect_project(root: Path) -> tuple[dict, list[Evidence]]:
54
67
  add("languages", "Java", manifest, "Java build manifest detected"); stack["runtime"].append("JVM"); stack["package_managers"].append("maven" if manifest == "pom.xml" else "gradle")
55
68
  # Keep generic analysis useful for small repositories without a manifest.
56
69
  for suffix, language in (("*.py", "Python"), ("*.js", "JavaScript"), ("*.ts", "TypeScript"), ("*.java", "Java"), ("*.go", "Go"), ("*.rs", "Rust")):
57
- if language not in stack["languages"] and any(root.rglob(suffix)):
58
- first = next(root.rglob(suffix)); add("languages", language, first.relative_to(root).as_posix(), f"{language} source file detected")
70
+ if language not in stack["languages"] and any(_sources(root, suffix)):
71
+ first = next(_sources(root, suffix)); add("languages", language, first.relative_to(root).as_posix(), f"{language} source file detected")
59
72
  if any(name in stack["frameworks"] for name in ("Laravel", "Lumen", "FastAPI", "Express", "NestJS", ".NET Web API")): stack["project_type"] = "backend-api"
60
73
  if (root / "Dockerfile").exists(): add("infrastructure", "Docker", "Dockerfile", "Dockerfile detected")
61
74
  if list(root.glob("docker-compose*")) or list(root.glob("compose*")): add("infrastructure", "Docker Compose", "docker-compose", "Compose configuration detected")
@@ -66,8 +79,8 @@ def detect_project(root: Path) -> tuple[dict, list[Evidence]]:
66
79
  def detect_components(root: Path) -> list[dict]:
67
80
  """Find independent project boundaries without assuming a monorepo layout."""
68
81
  result = []
69
- for manifest in [*root.rglob("composer.json"), *root.rglob("package.json"), *root.rglob("pyproject.toml"), *root.rglob("requirements.txt")]:
70
- if any(part in {".git", ".klap", "node_modules", "vendor", ".venv", "venv"} for part in manifest.relative_to(root).parts): continue
82
+ manifests = [path for pattern in ("composer.json", "package.json", "pyproject.toml", "requirements.txt") for path in _sources(root, pattern)]
83
+ for manifest in manifests:
71
84
  directory = manifest.parent
72
85
  if directory == root: continue
73
86
  stack, _ = detect_project(directory)
@@ -28,7 +28,7 @@ def _e(root: Path, path: Path, offset: int, reason: str, *, symbol: str | None =
28
28
 
29
29
  def _category(package: str) -> str:
30
30
  package = package.lower()
31
- if package == "laravel/framework": return "framework"
31
+ if package in {"laravel/framework", "laravel/lumen-framework"}: return "framework"
32
32
  if any(token in package for token in ("mysql", "postgres", "mongodb", "redis", "doctrine/dbal")): return "database"
33
33
  if any(token in package for token in ("queue", "horizon", "rabbit", "kafka")): return "queue"
34
34
  if any(token in package for token in ("jwt", "sanctum", "passport", "auth")): return "authentication"
@@ -42,18 +42,48 @@ def _category(package: str) -> str:
42
42
  def _composer(root: Path) -> tuple[list[dict], dict]:
43
43
  composer = root / "composer.json"
44
44
  if not composer.exists():
45
- return [], {"laravel": None, "php": None}
45
+ return [], {"framework": None, "package": None, "version": None, "php": None}
46
46
  try:
47
47
  data = json.loads(_read(composer))
48
48
  except json.JSONDecodeError:
49
- return [], {"laravel": None, "php": None}
49
+ return [], {"framework": None, "package": None, "version": None, "php": None}
50
50
  dependencies = []
51
51
  for scope, key in (("runtime", "require"), ("development", "require-dev")):
52
52
  for package, version in data.get(key, {}).items():
53
53
  evidence = Evidence("manifest", "composer.json", f"Dependencia Composer ({scope})", "CONFIRMED", 1.0, symbol=package).as_dict()
54
54
  dependencies.append({"package": package, "version": version, "scope": scope, "category": _category(package), "status": "CONFIRMED", "evidence": [evidence]})
55
55
  requirements = data.get("require", {})
56
- return dependencies, {"laravel": requirements.get("laravel/framework"), "php": requirements.get("php")}
56
+ package = "laravel/lumen-framework" if "laravel/lumen-framework" in requirements else "laravel/framework" if "laravel/framework" in requirements else None
57
+ return dependencies, {"framework": "Lumen" if package == "laravel/lumen-framework" else "Laravel" if package else None, "package": package, "version": requirements.get(package) if package else None, "php": requirements.get("php")}
58
+
59
+
60
+ def _group_spans(text: str) -> list[tuple[int, int, str, list[str]]]:
61
+ """Return Lumen/Laravel route-group bodies with their inherited metadata."""
62
+ pattern = re.compile(r"(?:\$router|\$app|Route)->group\s*\(\s*\[(.*?)\]\s*,\s*function\s*\([^)]*\)\s*(?:use\s*\([^)]*\)\s*)?\{", re.S)
63
+ groups = []
64
+ for match in pattern.finditer(text):
65
+ opening = match.end() - 1
66
+ depth, closing = 0, None
67
+ for index in range(opening, len(text)):
68
+ if text[index] == "{": depth += 1
69
+ elif text[index] == "}":
70
+ depth -= 1
71
+ if depth == 0:
72
+ closing = index
73
+ break
74
+ if closing is None:
75
+ continue
76
+ options = match.group(1)
77
+ prefix_match = re.search(r"['\"]prefix['\"]\s*=>\s*['\"]([^'\"]*)['\"]", options)
78
+ middleware_match = re.search(r"['\"]middleware['\"]\s*=>\s*(\[[^\]]*\]|['\"][^'\"]*['\"])", options, re.S)
79
+ middleware = re.findall(r"['\"]([^'\"]+)['\"]", middleware_match.group(1)) if middleware_match else []
80
+ groups.append((opening, closing, prefix_match.group(1) if prefix_match else "", middleware))
81
+ return groups
82
+
83
+
84
+ def _comment_spans(text: str) -> list[tuple[int, int]]:
85
+ """Offsets covered by PHP line or block comments, preserving source lines."""
86
+ return [(match.start(), match.end()) for match in re.finditer(r"//[^\n]*|/\*.*?\*/", text, re.S)]
57
87
 
58
88
 
59
89
  def _routes(root: Path) -> tuple[list[dict], list[dict]]:
@@ -66,14 +96,23 @@ def _routes(root: Path) -> tuple[list[dict], list[dict]]:
66
96
  schedule_pattern = re.compile(r"Schedule::(?:command|job)\s*\(\s*['\"]([^'\"]+)['\"]\s*\).*?->([A-Za-z]\w*)\s*\(", re.S)
67
97
  for file in directory.glob("*.php"):
68
98
  text = _read(file)
99
+ groups = _group_spans(text)
100
+ comments = _comment_spans(text)
69
101
  for match in route_pattern.finditer(text):
102
+ if any(start <= match.start() < end for start, end in comments):
103
+ continue
70
104
  method, uri, target = match.groups()
71
105
  handler = _symbol(target)
72
106
  if not handler:
73
107
  callback = re.search(r"['\"]([A-Za-z_]\w*)@([A-Za-z_]\w*)['\"]", target)
74
108
  handler = f"{callback.group(1)}::{callback.group(2)}" if callback else None
75
- name = f"{method.upper()} /{uri.lstrip('/')}"
76
- routes.append({"type": "http", "method": method.upper(), "uri": "/" + uri.lstrip("/"), "name": name, "path": str(file.relative_to(root)), "source": str(file.relative_to(root)), "line": text.count("\n", 0, match.start()) + 1, "target": handler, "handler": handler, "middleware": [], "status": "CONFIRMED", "evidence": _e(root, file, match.start(), "Declaración de ruta Laravel", symbol=name)})
109
+ active = [group for group in groups if group[0] < match.start() < group[1]]
110
+ prefix = "/".join(group[2].strip("/") for group in active if group[2])
111
+ middleware = [name for group in active for name in group[3]]
112
+ full_uri = "/" + "/".join(part for part in (prefix, uri.strip("/")) if part)
113
+ name = f"{method.upper()} {full_uri}"
114
+ framework = "Lumen" if "lumen-framework" in _read(root / "composer.json") else "Laravel"
115
+ routes.append({"type": "http", "method": method.upper(), "uri": full_uri, "name": name, "path": str(file.relative_to(root)), "source": str(file.relative_to(root)), "line": text.count("\n", 0, match.start()) + 1, "target": handler, "handler": handler, "middleware": middleware, "status": "CONFIRMED", "evidence": _e(root, file, match.start(), f"Declaración de ruta {framework}", symbol=name)})
77
116
  for match in resource_pattern.finditer(text):
78
117
  resource, controller = match.groups()
79
118
  name = f"RESOURCE /{resource.lstrip('/')}"
@@ -140,7 +179,9 @@ def analyze(root: Path) -> dict:
140
179
  for kind, label, values in (("http", "HTTP", routes), ("cli", "CLI", commands), ("scheduler", "Scheduler", scheduled), ("queue", "Queue", jobs), ("event", "Eventos", events), ("listener", "Listeners", listeners)):
141
180
  if values:
142
181
  surfaces.append({"type": kind, "label": label, "count": len(values), "items": values})
143
- return {"framework": {"name": "Laravel", "version": versions["laravel"], "php_version": versions["php"], "status": "CONFIRMED", "evidence": [Evidence("manifest", "composer.json", "laravel/framework declarado", "CONFIRMED", 1.0, symbol="laravel/framework").as_dict()]}, "routes": routes, "commands": commands, "scheduled_processes": scheduled, "queue_jobs": jobs, "events": events, "listeners": listeners, "components": components, "dependencies": dependencies, "runtime_surfaces": surfaces, "important_files": important}
182
+ framework = versions["framework"] or "Laravel"
183
+ package = versions["package"] or "laravel/framework"
184
+ return {"framework": {"name": framework, "version": versions["version"], "php_version": versions["php"], "status": "CONFIRMED", "evidence": [Evidence("manifest", "composer.json", f"{package} declarado", "CONFIRMED", 1.0, symbol=package).as_dict()]}, "routes": routes, "commands": commands, "scheduled_processes": scheduled, "queue_jobs": jobs, "events": events, "listeners": listeners, "components": components, "dependencies": dependencies, "runtime_surfaces": surfaces, "important_files": important}
144
185
 
145
186
 
146
187
  class LaravelAdapter:
@@ -157,7 +198,7 @@ class LaravelAdapter:
157
198
  entries = []
158
199
  transitions = []
159
200
  for route in raw["routes"]:
160
- entries.append(EntryPoint("HTTP", route["name"], route.get("target"), "HTTP", route.get("method"), route.get("uri"), route["path"], route["line"], "Laravel", route["status"], route["evidence"]))
201
+ entries.append(EntryPoint("HTTP", route["name"], route.get("target"), "HTTP", route.get("method"), route.get("uri"), route["path"], route["line"], raw["framework"]["name"], route["status"], route["evidence"]))
161
202
  if route.get("target"):
162
203
  transitions.append(ExecutionTransition(route["name"], route["target"], "HTTP_ENTRY", route["path"], route["line"], route["status"], "laravel", route["evidence"]))
163
204
  for command in raw["commands"]:
@@ -187,4 +228,4 @@ class LaravelAdapter:
187
228
  events = [Event(item["name"], item["path"], item["status"], item["evidence"]) for item in raw["events"]]
188
229
  handlers = [EventHandler(item["name"], file=item["path"], status=item["status"], evidence=item["evidence"]) for item in raw["listeners"]]
189
230
  dependencies = [Dependency(item["package"], item["version"], item["category"], item["status"], item["evidence"]) for item in raw["dependencies"]]
190
- return SemanticModel("PHP", "Laravel", "FRAMEWORK", components, entries, transitions, background_tasks=background, events=events, event_handlers=handlers, dependencies=dependencies, metadata={"raw": raw})
231
+ return SemanticModel("PHP", raw["framework"]["name"], "FRAMEWORK", components, entries, transitions, background_tasks=background, events=events, event_handlers=handlers, dependencies=dependencies, metadata={"raw": raw})
@@ -3,12 +3,14 @@ from __future__ import annotations
3
3
 
4
4
  import hashlib
5
5
  import json
6
+ import os
7
+ import re
6
8
  from dataclasses import asdict, dataclass, field
7
9
  from pathlib import Path
8
10
 
9
11
 
10
12
  EXCLUDED = {".git", ".klap", "vendor", "node_modules", "build", "dist", ".venv", "venv", ".test-venv"}
11
- INDEX_VERSION = 3
13
+ INDEX_VERSION = 4
12
14
 
13
15
 
14
16
  @dataclass(frozen=True)
@@ -80,7 +82,11 @@ class PhpCodeIntelligenceProvider:
80
82
  self._index: dict | None = None
81
83
 
82
84
  def _files(self) -> list[Path]:
83
- return sorted(path for path in self.root.rglob("*.php") if not any(part in EXCLUDED for part in path.relative_to(self.root).parts))
85
+ files = []
86
+ for directory, children, names in os.walk(self.root):
87
+ children[:] = [child for child in children if child not in EXCLUDED]
88
+ files.extend(Path(directory) / name for name in names if name.endswith(".php"))
89
+ return sorted(files)
84
90
 
85
91
  def _fingerprint(self, files: list[Path]) -> str:
86
92
  value = "|".join(f"{path.relative_to(self.root)}:{path.stat().st_mtime_ns}:{path.stat().st_size}" for path in files)
@@ -133,6 +139,7 @@ class PhpCodeIntelligenceProvider:
133
139
  for target in [part.strip() for part in _text(child, source).replace("implements", "").split(",")]:
134
140
  relations.append(asdict(CodeRelation(qualified, target, "IMPLEMENTS", relative, _line(source, child.start_byte), "CONFIRMED")))
135
141
  properties = self._properties(declaration, source)
142
+ properties.update(self._constructor_properties(declaration, source))
136
143
  for method in (node for node in _descendants(declaration) if node.type == "method_declaration"):
137
144
  method_name = _field_text(method, "name", source)
138
145
  if not method_name:
@@ -161,6 +168,28 @@ class PhpCodeIntelligenceProvider:
161
168
  values[variable.lstrip("$")] = type_name.split("\\")[-1]
162
169
  return values
163
170
 
171
+ def _constructor_properties(self, declaration, source: bytes) -> dict[str, str]:
172
+ """Map classic constructor injection (``$this->x = $x``) to its type.
173
+
174
+ Lumen and older Laravel applications commonly use this form instead of
175
+ PHP 8 property promotion. It is deliberately conservative: only a
176
+ typed constructor parameter assigned directly to a ``$this`` property
177
+ is resolved.
178
+ """
179
+ text = _text(declaration, source)
180
+ constructor = re.search(r"function\s+__construct\s*\((.*?)\)\s*\{(.*?)\}", text, re.S)
181
+ if not constructor:
182
+ return {}
183
+ parameters = {
184
+ variable: type_name.split("\\")[-1]
185
+ for type_name, variable in re.findall(r"(?:public|protected|private)?\s*([A-Za-z_]\w*(?:\\[A-Za-z_]\w*)*)\s+\$(\w+)", constructor.group(1))
186
+ }
187
+ return {
188
+ property_name: parameters[variable]
189
+ for property_name, variable in re.findall(r"\$this->(\w+)\s*=\s*\$(\w+)\s*;", constructor.group(2))
190
+ if variable in parameters
191
+ }
192
+
164
193
  def _calls(self, method, source: bytes, source_id: str, file: str, properties: dict[str, str]) -> list[dict]:
165
194
  relations = []
166
195
  for node in _descendants(method):
@@ -171,7 +200,9 @@ class PhpCodeIntelligenceProvider:
171
200
  name = _field_text(node, "name", source)
172
201
  object_node = node.child_by_field_name("object")
173
202
  object_text = _text(object_node, source) if object_node else ""
174
- if object_text.startswith("$this->"):
203
+ if object_text == "$this":
204
+ target = f"{source_id.rsplit('::', 1)[0]}::{name}"
205
+ elif object_text.startswith("$this->"):
175
206
  service = object_text.split("->", 1)[1]
176
207
  target = f"{properties.get(service, service)}::{name}"
177
208
  elif object_text.startswith("$"):
@@ -2,6 +2,7 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  import json
5
+ import os
5
6
  import re
6
7
  from pathlib import Path
7
8
 
@@ -9,6 +10,7 @@ from .evidence import Evidence
9
10
 
10
11
 
11
12
  TEXT_LIMIT = 180_000
13
+ EXCLUDED = {".git", ".klap", "graphify-out", "node_modules", "vendor", ".venv", "venv", ".pytest_cache", ".test-venv", "build", "dist"}
12
14
 
13
15
 
14
16
  def _read(path: Path) -> str:
@@ -16,6 +18,16 @@ def _read(path: Path) -> str:
16
18
  except OSError: return ""
17
19
 
18
20
 
21
+ def _source_files(root: Path, suffixes: set[str]):
22
+ """Walk source files without first descending into dependency trees."""
23
+ for directory, children, names in os.walk(root):
24
+ children[:] = [child for child in children if child not in EXCLUDED]
25
+ for name in names:
26
+ path = Path(directory) / name
27
+ if path.suffix.lower() in suffixes:
28
+ yield path
29
+
30
+
19
31
  def _ev(path: str, reason: str, status: str = "CONFIRMED", confidence: float = 1.0) -> dict:
20
32
  return Evidence("file", path, reason, status, confidence).as_dict()
21
33
 
@@ -74,16 +86,15 @@ def entry_points(root: Path) -> tuple[list[dict], list[dict]]:
74
86
  point=add("scheduled", command, rel, schedule=frequency, reason="Laravel scheduler declaration")
75
87
  background.append({"name":command,"type":"scheduled_command","schedule":frequency,"path":rel,"status":"CONFIRMED","evidence":point["evidence"]})
76
88
  # Python HTTP decorators and CLI bootstrap.
77
- for path in root.rglob("*.py"):
78
- if any(part in {".git", ".klap", "graphify-out", "venv", ".venv", ".test-venv", "build", "dist"} for part in path.parts) or path.name == "system.py": continue
89
+ for path in _source_files(root, {".py"}):
90
+ if path.name == "system.py": continue
79
91
  text=_read(path); rel=str(path.relative_to(root))
80
92
  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
93
  add("http", f"{method.upper()} {uri}", rel, function, reason="Python web route decorator")
82
94
  if "if __name__ == \"__main__\"" in text or "if __name__ == '__main__'" in text:
83
95
  add("cli", path.stem, rel, reason="Python executable module")
84
96
  # Express route declarations.
85
- for path in root.rglob("*.js"):
86
- if any(part in {"node_modules", ".git", ".klap", "graphify-out", ".test-venv", "build", "dist"} for part in path.parts): continue
97
+ for path in _source_files(root, {".js"}):
87
98
  rel=str(path.relative_to(root)); text=_read(path)
88
99
  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
100
  return points[:50], background[:30]
@@ -93,9 +104,9 @@ def external_systems(root: Path) -> tuple[list[dict], list[dict]]:
93
104
  found, stores = [], []
94
105
  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
106
  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"}]
107
+ candidates = list(_source_files(root, {".json", ".toml", ".yml", ".yaml", ".php", ".py", ".js", ".ts", ".env"}))
97
108
  for path in candidates[:800]:
98
- if any(part in {".git", ".klap", "graphify-out", "node_modules", "vendor", ".pytest_cache", "tests", ".test-venv", "build", "dist", "frameworks"} for part in path.parts) or path.name in {"system.py", "portal.py", "pyproject.toml"}: continue
109
+ if "tests" in path.relative_to(root).parts or "frameworks" in path.relative_to(root).parts or path.name in {"system.py", "portal.py", "pyproject.toml"}: continue
99
110
  text=_read(path); rel=str(path.relative_to(root))
100
111
  for pattern,name,kind in patterns:
101
112
  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")]})
@@ -114,10 +125,9 @@ def deployment(root: Path) -> tuple[dict, dict, list[dict]]:
114
125
  for item in files:
115
126
  text=_read(root/item["path"]) if (root/item["path"]).is_file() else ""
116
127
  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", ".test-venv", "build", "dist"} for part in path.parts) or path.name in {"system.py", "laravel.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")]})
128
+ for path in _source_files(root, {".py", ".php", ".js", ".ts", ".json", ".yml", ".yaml"}):
129
+ if "tests" in path.relative_to(root).parts or path.name in {"system.py", "laravel.py"}: continue
130
+ text=_read(path); rel=str(path.relative_to(root))
131
+ for term,name in (("prometheus","Prometheus"),("opentelemetry","OpenTelemetry"),("sentry","Sentry"),("/health","Health endpoint"),("/metrics","Metrics endpoint")):
132
+ 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
133
  return {"tools":tools,"ports":sorted(set(ports)),"files":files,"status":"CONFIRMED" if files else "UNKNOWN"}, {"tools":observability,"status":"CONFIRMED" if observability else "UNKNOWN"}, files
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: klapcontext
3
- Version: 0.2.1
3
+ Version: 0.3.1
4
4
  Summary: Make your repository understandable to humans and AI.
5
5
  Author: Willy Cordon
6
6
  License-Expression: Apache-2.0
@@ -91,6 +91,7 @@ tu `.gitignore`.
91
91
  | `klap status [ruta]` | Indica si el contexto está actualizado respecto a Git. |
92
92
  | `klap open [ruta]` | Abre el portal humano estático. |
93
93
  | `klap agent [ruta]` | Muestra la configuración MCP local de Graphify. |
94
+ | `klap context "tarea" [ruta]` | Compila un contexto focalizado reutilizando `.klap/context.json` si existe. |
94
95
  | `klap --version` | Muestra la versión instalada. |
95
96
 
96
97
  ## Salidas
@@ -121,6 +122,9 @@ El workflow de GitHub Actions publica tags `v*` mediante PyPI Trusted
121
122
  Publishing. Antes del primer tag, configurá el publisher de PyPI para este
122
123
  repositorio y workflow. No se necesitan tokens de PyPI en el repositorio.
123
124
 
125
+ El repositorio ya incluye `.github/workflows/publish-pypi.yml`: al publicar un
126
+ tag `vX.Y.Z`, GitHub construye el paquete y lo publica con ese publisher.
127
+
124
128
  ## Licencia
125
129
 
126
130
  Apache-2.0. Ver [LICENSE](LICENSE).
@@ -50,7 +50,17 @@ def test_lumen_detection_and_route_adapter():
50
50
  stack, _ = detect_project(root)
51
51
  assert "Lumen" in stack["frameworks"]
52
52
  context = build(root, {"nodes": []})
53
- assert context["system_model"]["routes"][0]["name"] == "POST /api/v1/call-vtex"
53
+ route = context["system_model"]["routes"][0]
54
+ assert route["name"] == "POST /api/v1/call-vtex"
55
+ assert len(context["system_model"]["routes"]) == 1
56
+ assert route["middleware"] == ["apikey"]
57
+ assert context["system_model"]["framework"]["name"] == "Lumen"
58
+ flow = context["system_model"]["main_flows"][0]
59
+ assert [step["name"].split("\\")[-1] for step in flow["steps"]] == [
60
+ "POST /api/v1/call-vtex", "ProcessRequestController::callVtex",
61
+ "ProcessRequestService::validateRequest", "ProcessRequestService::callVtex",
62
+ "VtexService::searchOrderVtex", "VtexService::getOrderDetails",
63
+ ]
54
64
 
55
65
 
56
66
  def test_portal_has_dashboard_and_real_views(tmp_path):
@@ -223,6 +233,21 @@ def test_context_compiler_ranks_compact_task_context():
223
233
  assert minimal["execution"]["adaptive"] and minimal["execution"]["files_selected"] <= deep["execution"]["files_selected"]
224
234
 
225
235
 
236
+ def test_context_compiler_reuses_generated_context(tmp_path, monkeypatch):
237
+ root = fixture_repo(tmp_path)
238
+ (root / ".klap").mkdir()
239
+ cached = build(root, {"nodes": []})
240
+ (root / ".klap" / "context.json").write_text(json.dumps(cached))
241
+ monkeypatch.setattr("klapcontext.agent.compiler.build", lambda *_: (_ for _ in ()).throw(AssertionError("should not rebuild")))
242
+ result = compile_context(root, "entender sistema")
243
+ assert result["execution"]["context_source"] == "cached"
244
+
245
+
246
+ def test_graph_links_are_counted_like_edges(tmp_path):
247
+ context = build(fixture_repo(tmp_path), {"nodes": [], "links": [{"source": "a", "target": "b"}]})
248
+ assert context["technical_model"]["graph"]["edges"] == 1
249
+
250
+
226
251
  def test_specialized_contexts_keep_the_compact_compiler_contract():
227
252
  root = Path(__file__).parent / "fixtures" / "laravel_semantic"
228
253
  assert change_context(root, "modificar orders")["context_type"] == "CHANGE"
File without changes
File without changes
File without changes