codecortex-context-engine 0.1.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. codecortex/__init__.py +3 -0
  2. codecortex/architecture/__init__.py +23 -0
  3. codecortex/architecture/drift.py +182 -0
  4. codecortex/architecture/inference.py +160 -0
  5. codecortex/backends/__init__.py +35 -0
  6. codecortex/backends/base.py +38 -0
  7. codecortex/backends/context.py +118 -0
  8. codecortex/backends/contracts.py +65 -0
  9. codecortex/backends/factory.py +60 -0
  10. codecortex/backends/graph.py +107 -0
  11. codecortex/backends/manager.py +303 -0
  12. codecortex/backends/mcp_client.py +196 -0
  13. codecortex/backends/pool.py +128 -0
  14. codecortex/backends/spec.py +83 -0
  15. codecortex/backends/symbols.py +189 -0
  16. codecortex/benchmark.py +211 -0
  17. codecortex/cli.py +409 -0
  18. codecortex/config.py +27 -0
  19. codecortex/context/__init__.py +13 -0
  20. codecortex/context/budget.py +62 -0
  21. codecortex/context/integrated.py +60 -0
  22. codecortex/context/pipeline.py +223 -0
  23. codecortex/core/__init__.py +1 -0
  24. codecortex/core/contracts.py +45 -0
  25. codecortex/core/errors.py +17 -0
  26. codecortex/core/models.py +69 -0
  27. codecortex/dashboard.py +259 -0
  28. codecortex/editing.py +41 -0
  29. codecortex/engines/__init__.py +5 -0
  30. codecortex/engines/builtin/__init__.py +5 -0
  31. codecortex/engines/builtin/factory.py +26 -0
  32. codecortex/engines/builtin/memory.py +35 -0
  33. codecortex/engines/builtin/repository.py +73 -0
  34. codecortex/engines/builtin/symbols.py +89 -0
  35. codecortex/engines/builtin/validation.py +54 -0
  36. codecortex/engines/registry.py +26 -0
  37. codecortex/entrypoint.py +266 -0
  38. codecortex/evaluation/__init__.py +55 -0
  39. codecortex/evaluation/external.py +265 -0
  40. codecortex/evaluation/production.py +670 -0
  41. codecortex/evaluation/regression.py +188 -0
  42. codecortex/gateway.py +38 -0
  43. codecortex/git_intelligence.py +252 -0
  44. codecortex/indexing/__init__.py +6 -0
  45. codecortex/indexing/graph.py +78 -0
  46. codecortex/indexing/impact.py +127 -0
  47. codecortex/indexing/incremental.py +163 -0
  48. codecortex/indexing/incremental_graph.py +189 -0
  49. codecortex/indexing/indexer.py +172 -0
  50. codecortex/indexing/relationships.py +179 -0
  51. codecortex/indexing/resolution.py +88 -0
  52. codecortex/integrations/__init__.py +5 -0
  53. codecortex/integrations/agents.py +235 -0
  54. codecortex/interfaces/__init__.py +1 -0
  55. codecortex/interfaces/mcp_bridge.py +66 -0
  56. codecortex/languages/__init__.py +5 -0
  57. codecortex/languages/native.py +166 -0
  58. codecortex/languages/registry.py +232 -0
  59. codecortex/mcp/__init__.py +5 -0
  60. codecortex/mcp/extended.py +114 -0
  61. codecortex/mcp/server.py +473 -0
  62. codecortex/memory/__init__.py +11 -0
  63. codecortex/memory/json_store.py +52 -0
  64. codecortex/memory/knowledge.py +193 -0
  65. codecortex/memory/team_store.py +193 -0
  66. codecortex/orchestrator.py +153 -0
  67. codecortex/pr_intelligence.py +214 -0
  68. codecortex/retrieval/__init__.py +16 -0
  69. codecortex/retrieval/hybrid.py +67 -0
  70. codecortex/retrieval/index.py +135 -0
  71. codecortex/retrieval/providers.py +67 -0
  72. codecortex/retrieval/repository.py +94 -0
  73. codecortex/router/__init__.py +5 -0
  74. codecortex/router/router.py +79 -0
  75. codecortex/runtime.py +69 -0
  76. codecortex/setup.py +100 -0
  77. codecortex/symbols/__init__.py +5 -0
  78. codecortex/symbols/providers.py +192 -0
  79. codecortex/telemetry/__init__.py +5 -0
  80. codecortex/telemetry/collector.py +43 -0
  81. codecortex/tracing/__init__.py +9 -0
  82. codecortex/tracing/task_trace.py +235 -0
  83. codecortex/workspace/__init__.py +9 -0
  84. codecortex/workspace/federation.py +173 -0
  85. codecortex_context_engine-0.1.0a1.dist-info/METADATA +381 -0
  86. codecortex_context_engine-0.1.0a1.dist-info/RECORD +90 -0
  87. codecortex_context_engine-0.1.0a1.dist-info/WHEEL +4 -0
  88. codecortex_context_engine-0.1.0a1.dist-info/entry_points.txt +3 -0
  89. codecortex_context_engine-0.1.0a1.dist-info/licenses/LICENSE +201 -0
  90. codecortex_context_engine-0.1.0a1.dist-info/licenses/NOTICE +2 -0
@@ -0,0 +1,188 @@
1
+ """Persistent benchmark history and deterministic regression gates."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import asdict, dataclass
7
+ from datetime import UTC, datetime
8
+ from pathlib import Path
9
+
10
+
11
+ @dataclass(frozen=True, slots=True)
12
+ class BenchmarkSnapshot:
13
+ id: str
14
+ created_at: str
15
+ commit: str | None
16
+ metrics: dict[str, dict[str, float]]
17
+ metadata: dict[str, str]
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class MetricPolicy:
22
+ metric: str
23
+ direction: str
24
+ max_relative_regression: float | None = None
25
+ max_absolute_regression: float | None = None
26
+
27
+ def __post_init__(self) -> None:
28
+ if self.direction not in {"higher", "lower"}:
29
+ raise ValueError("direction must be 'higher' or 'lower'")
30
+
31
+
32
+ @dataclass(frozen=True, slots=True)
33
+ class RegressionViolation:
34
+ strategy: str
35
+ metric: str
36
+ baseline: float
37
+ current: float
38
+ relative_change: float | None
39
+ absolute_change: float
40
+ reason: str
41
+
42
+
43
+ @dataclass(frozen=True, slots=True)
44
+ class GateReport:
45
+ passed: bool
46
+ violations: tuple[RegressionViolation, ...]
47
+ compared_metrics: int
48
+
49
+
50
+ class BenchmarkHistory:
51
+ VERSION = 1
52
+
53
+ def __init__(self, path: Path) -> None:
54
+ self.path = path
55
+
56
+ def append(
57
+ self,
58
+ metrics: dict[str, dict[str, float]],
59
+ commit: str | None = None,
60
+ metadata: dict[str, str] | None = None,
61
+ ) -> BenchmarkSnapshot:
62
+ created = datetime.now(UTC).isoformat()
63
+ snapshot = BenchmarkSnapshot(
64
+ id=f"bench-{created.replace(':', '').replace('+', '-')}",
65
+ created_at=created,
66
+ commit=commit,
67
+ metrics=metrics,
68
+ metadata=metadata or {},
69
+ )
70
+ snapshots = self.load()
71
+ snapshots.append(snapshot)
72
+ self._save(snapshots)
73
+ return snapshot
74
+
75
+ def load(self) -> list[BenchmarkSnapshot]:
76
+ try:
77
+ payload = json.loads(self.path.read_text(encoding="utf-8"))
78
+ except (OSError, json.JSONDecodeError):
79
+ return []
80
+ if payload.get("version") != self.VERSION:
81
+ return []
82
+ result: list[BenchmarkSnapshot] = []
83
+ for item in payload.get("snapshots", []):
84
+ try:
85
+ result.append(
86
+ BenchmarkSnapshot(
87
+ id=str(item["id"]),
88
+ created_at=str(item["created_at"]),
89
+ commit=str(item["commit"]) if item.get("commit") else None,
90
+ metrics={
91
+ str(strategy): {
92
+ str(metric): float(value)
93
+ for metric, value in values.items()
94
+ }
95
+ for strategy, values in item["metrics"].items()
96
+ },
97
+ metadata={str(key): str(value) for key, value in item.get("metadata", {}).items()},
98
+ )
99
+ )
100
+ except (KeyError, TypeError, ValueError):
101
+ continue
102
+ return result
103
+
104
+ def latest(self) -> BenchmarkSnapshot | None:
105
+ snapshots = self.load()
106
+ return snapshots[-1] if snapshots else None
107
+
108
+ def _save(self, snapshots: list[BenchmarkSnapshot]) -> None:
109
+ self.path.parent.mkdir(parents=True, exist_ok=True)
110
+ payload = {
111
+ "version": self.VERSION,
112
+ "snapshots": [asdict(snapshot) for snapshot in snapshots],
113
+ }
114
+ temp = self.path.with_suffix(self.path.suffix + ".tmp")
115
+ temp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
116
+ temp.replace(self.path)
117
+
118
+
119
+ class RegressionGate:
120
+ DEFAULT_POLICIES = (
121
+ MetricPolicy("success_rate", "higher", max_absolute_regression=0.02),
122
+ MetricPolicy("avg_path_recall", "higher", max_absolute_regression=0.03),
123
+ MetricPolicy("avg_symbol_recall", "higher", max_absolute_regression=0.03),
124
+ MetricPolicy("avg_duration_ms", "lower", max_relative_regression=0.20),
125
+ MetricPolicy("avg_context_tokens", "lower", max_relative_regression=0.10),
126
+ MetricPolicy("avg_files_read", "lower", max_relative_regression=0.15),
127
+ MetricPolicy("avg_tool_calls", "lower", max_relative_regression=0.15),
128
+ )
129
+
130
+ def __init__(self, policies: tuple[MetricPolicy, ...] | None = None) -> None:
131
+ self.policies = policies or self.DEFAULT_POLICIES
132
+
133
+ def evaluate(
134
+ self,
135
+ current: BenchmarkSnapshot,
136
+ baseline: BenchmarkSnapshot,
137
+ ) -> GateReport:
138
+ violations: list[RegressionViolation] = []
139
+ compared = 0
140
+ for strategy, current_metrics in current.metrics.items():
141
+ baseline_metrics = baseline.metrics.get(strategy)
142
+ if baseline_metrics is None:
143
+ continue
144
+ for policy in self.policies:
145
+ if policy.metric not in current_metrics or policy.metric not in baseline_metrics:
146
+ continue
147
+ compared += 1
148
+ now = current_metrics[policy.metric]
149
+ before = baseline_metrics[policy.metric]
150
+ absolute = now - before
151
+ relative = None if before == 0 else absolute / abs(before)
152
+ regression = before - now if policy.direction == "higher" else now - before
153
+ if regression <= 0:
154
+ continue
155
+ reasons: list[str] = []
156
+ if (
157
+ policy.max_absolute_regression is not None
158
+ and regression > policy.max_absolute_regression
159
+ ):
160
+ reasons.append(
161
+ f"absolute regression {regression:.4f} > {policy.max_absolute_regression:.4f}"
162
+ )
163
+ relative_regression = None if before == 0 else regression / abs(before)
164
+ if (
165
+ policy.max_relative_regression is not None
166
+ and relative_regression is not None
167
+ and relative_regression > policy.max_relative_regression
168
+ ):
169
+ reasons.append(
170
+ f"relative regression {relative_regression:.2%} > {policy.max_relative_regression:.2%}"
171
+ )
172
+ if reasons:
173
+ violations.append(
174
+ RegressionViolation(
175
+ strategy=strategy,
176
+ metric=policy.metric,
177
+ baseline=before,
178
+ current=now,
179
+ relative_change=relative,
180
+ absolute_change=absolute,
181
+ reason="; ".join(reasons),
182
+ )
183
+ )
184
+ return GateReport(
185
+ passed=not violations,
186
+ violations=tuple(violations),
187
+ compared_metrics=compared,
188
+ )
codecortex/gateway.py ADDED
@@ -0,0 +1,38 @@
1
+ """Stable application gateway for every external interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from codecortex.core.contracts import MemoryStore
6
+ from codecortex.core.models import AgentRequest, ExecutionResult, RoutePlan
7
+ from codecortex.engines import EngineRegistry
8
+ from codecortex.orchestrator import Orchestrator
9
+ from codecortex.router import AdaptiveRouter
10
+
11
+
12
+ class CodeCortexGateway:
13
+ def __init__(
14
+ self,
15
+ router: AdaptiveRouter,
16
+ orchestrator: Orchestrator,
17
+ registry: EngineRegistry,
18
+ memory: MemoryStore,
19
+ ) -> None:
20
+ self.router = router
21
+ self.orchestrator = orchestrator
22
+ self.registry = registry
23
+ self.memory = memory
24
+
25
+ def route(self, query: str, project_root: str = ".") -> RoutePlan:
26
+ return self.router.route(AgentRequest(query=query, project_root=project_root))
27
+
28
+ async def query(self, query: str, project_root: str = ".") -> ExecutionResult:
29
+ return await self.orchestrator.execute(
30
+ AgentRequest(query=query, project_root=project_root)
31
+ )
32
+
33
+ async def remember(self, key: str, value: str, namespace: str = "project") -> None:
34
+ await self.memory.put(namespace, key, value)
35
+
36
+ async def health(self) -> dict[str, bool]:
37
+ health = await self.registry.health()
38
+ return {capability.value: status for capability, status in health.items()}
@@ -0,0 +1,252 @@
1
+ """Repository and symbol history intelligence from local Git metadata."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from collections import Counter
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+
11
+ @dataclass(frozen=True, slots=True)
12
+ class FileActivity:
13
+ path: str
14
+ changes: int
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class CoChange:
19
+ left: str
20
+ right: str
21
+ commits: int
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class AuthorActivity:
26
+ name: str
27
+ email: str
28
+ commits: int
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class GitReport:
33
+ commits: int
34
+ hot_files: tuple[FileActivity, ...]
35
+ co_changes: tuple[CoChange, ...]
36
+ authors: tuple[AuthorActivity, ...]
37
+ recent_files: tuple[str, ...]
38
+
39
+
40
+ @dataclass(frozen=True, slots=True)
41
+ class SymbolCommit:
42
+ sha: str
43
+ author: str
44
+ email: str
45
+ date: str
46
+ subject: str
47
+
48
+
49
+ @dataclass(frozen=True, slots=True)
50
+ class BlameLine:
51
+ line: int
52
+ sha: str
53
+ author: str
54
+ email: str
55
+ timestamp: int | None
56
+ content: str
57
+
58
+
59
+ @dataclass(frozen=True, slots=True)
60
+ class SymbolHistory:
61
+ path: str
62
+ start_line: int
63
+ end_line: int
64
+ commits: tuple[SymbolCommit, ...]
65
+ blame: tuple[BlameLine, ...]
66
+ owners: tuple[AuthorActivity, ...]
67
+
68
+
69
+ class GitIntelligence:
70
+ def __init__(self, root: Path, timeout_seconds: float = 15.0) -> None:
71
+ self.root = root.resolve()
72
+ self.timeout_seconds = timeout_seconds
73
+
74
+ def _git(self, *args: str) -> str:
75
+ try:
76
+ result = subprocess.run(
77
+ ["git", "-C", str(self.root), *args],
78
+ check=False,
79
+ capture_output=True,
80
+ text=True,
81
+ encoding="utf-8",
82
+ errors="replace",
83
+ timeout=self.timeout_seconds,
84
+ )
85
+ except (OSError, subprocess.TimeoutExpired):
86
+ return ""
87
+ return result.stdout if result.returncode == 0 else ""
88
+
89
+ def is_repository(self) -> bool:
90
+ return self._git("rev-parse", "--is-inside-work-tree").strip() == "true"
91
+
92
+ def analyze(self, limit: int = 500) -> GitReport:
93
+ raw = self._git(
94
+ "log",
95
+ f"-n{max(1, limit)}",
96
+ "--date=iso-strict",
97
+ "--format=@@%H|%an|%ae|%ad",
98
+ "--name-only",
99
+ )
100
+ if not raw:
101
+ return GitReport(0, (), (), (), ())
102
+ file_changes: Counter[str] = Counter()
103
+ pair_changes: Counter[tuple[str, str]] = Counter()
104
+ author_changes: Counter[tuple[str, str]] = Counter()
105
+ recent: list[str] = []
106
+ current_files: list[str] = []
107
+ current_author: tuple[str, str] | None = None
108
+ commits = 0
109
+
110
+ def flush() -> None:
111
+ nonlocal current_files, current_author
112
+ unique = sorted(set(current_files))
113
+ file_changes.update(unique)
114
+ for index, left in enumerate(unique):
115
+ for right in unique[index + 1 :]:
116
+ pair_changes[(left, right)] += 1
117
+ if current_author is not None:
118
+ author_changes[current_author] += 1
119
+ current_files = []
120
+ current_author = None
121
+
122
+ for line in raw.splitlines():
123
+ if line.startswith("@@"):
124
+ if commits:
125
+ flush()
126
+ commits += 1
127
+ parts = line[2:].split("|", 3)
128
+ if len(parts) >= 3:
129
+ current_author = (parts[1], parts[2])
130
+ continue
131
+ path = line.strip()
132
+ if path:
133
+ current_files.append(path)
134
+ if path not in recent:
135
+ recent.append(path)
136
+ if commits:
137
+ flush()
138
+ return GitReport(
139
+ commits=commits,
140
+ hot_files=tuple(FileActivity(path, count) for path, count in file_changes.most_common(30)),
141
+ co_changes=tuple(
142
+ CoChange(left, right, count)
143
+ for (left, right), count in pair_changes.most_common(30)
144
+ ),
145
+ authors=tuple(
146
+ AuthorActivity(name, email, count)
147
+ for (name, email), count in author_changes.most_common(20)
148
+ ),
149
+ recent_files=tuple(recent[:50]),
150
+ )
151
+
152
+ def file_history(self, path: str, limit: int = 30) -> list[dict[str, str]]:
153
+ raw = self._git(
154
+ "log",
155
+ f"-n{max(1, limit)}",
156
+ "--date=iso-strict",
157
+ "--format=%H|%an|%ae|%ad|%s",
158
+ "--",
159
+ path,
160
+ )
161
+ result: list[dict[str, str]] = []
162
+ for line in raw.splitlines():
163
+ parts = line.split("|", 4)
164
+ if len(parts) == 5:
165
+ result.append(
166
+ {
167
+ "sha": parts[0],
168
+ "author": parts[1],
169
+ "email": parts[2],
170
+ "date": parts[3],
171
+ "subject": parts[4],
172
+ }
173
+ )
174
+ return result
175
+
176
+ def symbol_history(self, path: str, start_line: int, end_line: int) -> SymbolHistory:
177
+ if start_line < 1 or end_line < start_line:
178
+ raise ValueError("invalid symbol line range")
179
+ commits = self._symbol_commits(path, start_line, end_line)
180
+ blame = self._blame(path, start_line, end_line)
181
+ ownership: Counter[tuple[str, str]] = Counter(
182
+ (line.author, line.email) for line in blame if line.author
183
+ )
184
+ owners = tuple(
185
+ AuthorActivity(name, email, count)
186
+ for (name, email), count in ownership.most_common()
187
+ )
188
+ return SymbolHistory(
189
+ path=path,
190
+ start_line=start_line,
191
+ end_line=end_line,
192
+ commits=tuple(commits),
193
+ blame=tuple(blame),
194
+ owners=owners,
195
+ )
196
+
197
+ def _symbol_commits(self, path: str, start_line: int, end_line: int) -> list[SymbolCommit]:
198
+ raw = self._git(
199
+ "log",
200
+ "--date=iso-strict",
201
+ "--format=@@C@@%H|%an|%ae|%ad|%s",
202
+ "-L",
203
+ f"{start_line},{end_line}:{path}",
204
+ )
205
+ commits: list[SymbolCommit] = []
206
+ seen: set[str] = set()
207
+ for line in raw.splitlines():
208
+ if not line.startswith("@@C@@"):
209
+ continue
210
+ parts = line[5:].split("|", 4)
211
+ if len(parts) != 5 or parts[0] in seen:
212
+ continue
213
+ seen.add(parts[0])
214
+ commits.append(SymbolCommit(*parts))
215
+ return commits
216
+
217
+ def _blame(self, path: str, start_line: int, end_line: int) -> list[BlameLine]:
218
+ raw = self._git(
219
+ "blame",
220
+ "--line-porcelain",
221
+ "-L",
222
+ f"{start_line},{end_line}",
223
+ "--",
224
+ path,
225
+ )
226
+ result: list[BlameLine] = []
227
+ current: dict[str, str] = {}
228
+ current_line = start_line
229
+ for line in raw.splitlines():
230
+ if line.startswith("\t"):
231
+ timestamp = current.get("author-time")
232
+ result.append(
233
+ BlameLine(
234
+ line=current_line,
235
+ sha=current.get("sha", ""),
236
+ author=current.get("author", ""),
237
+ email=current.get("author-mail", "").strip("<>"),
238
+ timestamp=int(timestamp) if timestamp and timestamp.isdigit() else None,
239
+ content=line[1:],
240
+ )
241
+ )
242
+ current_line += 1
243
+ current = {}
244
+ continue
245
+ parts = line.split(" ", 1)
246
+ if len(parts) == 2 and len(parts[0]) >= 7 and all(
247
+ char in "0123456789abcdef^" for char in parts[0].lower()
248
+ ):
249
+ current["sha"] = parts[0].lstrip("^")
250
+ elif len(parts) == 2:
251
+ current[parts[0]] = parts[1]
252
+ return result
@@ -0,0 +1,6 @@
1
+ """Repository indexing and knowledge graph primitives."""
2
+
3
+ from codecortex.indexing.graph import GraphEdge, GraphNode, ProjectGraph
4
+ from codecortex.indexing.indexer import ProjectIndexer
5
+
6
+ __all__ = ["GraphEdge", "GraphNode", "ProjectGraph", "ProjectIndexer"]
@@ -0,0 +1,78 @@
1
+ """Typed project knowledge graph."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from pydantic import BaseModel, Field
10
+
11
+
12
+ class GraphNode(BaseModel):
13
+ id: str
14
+ kind: str
15
+ name: str
16
+ path: str | None = None
17
+ line: int | None = None
18
+ metadata: dict[str, Any] = Field(default_factory=dict)
19
+
20
+
21
+ class GraphEdge(BaseModel):
22
+ source: str
23
+ target: str
24
+ kind: str
25
+ metadata: dict[str, Any] = Field(default_factory=dict)
26
+
27
+
28
+ class ProjectGraph(BaseModel):
29
+ nodes: list[GraphNode] = Field(default_factory=list)
30
+ edges: list[GraphEdge] = Field(default_factory=list)
31
+
32
+ @classmethod
33
+ def load(cls, path: Path) -> ProjectGraph:
34
+ try:
35
+ payload = json.loads(path.read_text(encoding="utf-8"))
36
+ except (OSError, json.JSONDecodeError, ValueError):
37
+ return cls()
38
+ try:
39
+ return cls.model_validate(payload)
40
+ except ValueError:
41
+ return cls()
42
+
43
+ def save(self, path: Path) -> None:
44
+ path.parent.mkdir(parents=True, exist_ok=True)
45
+ temp = path.with_suffix(path.suffix + ".tmp")
46
+ temp.write_text(
47
+ json.dumps(self.model_dump(mode="json"), ensure_ascii=False, indent=2),
48
+ encoding="utf-8",
49
+ )
50
+ temp.replace(path)
51
+
52
+ def search(self, query: str, limit: int = 40) -> list[GraphNode]:
53
+ terms = {
54
+ term.lower().strip(".,:;()[]{}")
55
+ for term in query.split()
56
+ if len(term.strip()) > 2
57
+ }
58
+ scored: list[tuple[int, GraphNode]] = []
59
+ for node in self.nodes:
60
+ name = node.name.lower()
61
+ path = (node.path or "").lower()
62
+ score = sum(
63
+ 5 if term == name else 3 if term in name else 1 if term in path else 0
64
+ for term in terms
65
+ )
66
+ if score:
67
+ scored.append((score, node))
68
+ scored.sort(key=lambda item: (-item[0], item[1].kind, item[1].name))
69
+ return [node for _, node in scored[:limit]]
70
+
71
+ def counts(self) -> dict[str, int]:
72
+ result: dict[str, int] = {}
73
+ for node in self.nodes:
74
+ result[node.kind] = result.get(node.kind, 0) + 1
75
+ return result
76
+
77
+ def nodes_for_path(self, path: str) -> list[GraphNode]:
78
+ return [node for node in self.nodes if node.path == path]
@@ -0,0 +1,127 @@
1
+ """Graph-based change impact analysis."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import defaultdict, deque
6
+ from dataclasses import dataclass
7
+
8
+ from codecortex.indexing.graph import GraphEdge, GraphNode, ProjectGraph
9
+
10
+
11
+ @dataclass(frozen=True, slots=True)
12
+ class ImpactItem:
13
+ node: GraphNode
14
+ depth: int
15
+ via: str
16
+ risk: float
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class ImpactReport:
21
+ target: GraphNode
22
+ direct: tuple[ImpactItem, ...]
23
+ indirect: tuple[ImpactItem, ...]
24
+ affected_tests: tuple[ImpactItem, ...]
25
+ risk_score: float
26
+
27
+ def to_text(self) -> str:
28
+ lines = [
29
+ f"Target: {self.target.kind} {self.target.name}",
30
+ f"Risk score: {self.risk_score:.2f}",
31
+ f"Direct dependencies: {len(self.direct)}",
32
+ f"Indirect dependencies: {len(self.indirect)}",
33
+ f"Affected tests: {len(self.affected_tests)}",
34
+ ]
35
+ ranked = sorted(
36
+ (*self.direct, *self.indirect),
37
+ key=lambda item: (-item.risk, item.depth, item.node.name),
38
+ )[:20]
39
+ if ranked:
40
+ lines.append("\nHighest risk:")
41
+ lines.extend(
42
+ f"- {item.node.name} [{item.via}, depth={item.depth}, risk={item.risk:.2f}]"
43
+ for item in ranked
44
+ )
45
+ return "\n".join(lines)
46
+
47
+
48
+ class ImpactAnalyzer:
49
+ WEIGHTS = {
50
+ "calls": 1.0,
51
+ "inherits": 1.0,
52
+ "implements": 0.9,
53
+ "imports": 0.7,
54
+ "contains": 0.35,
55
+ }
56
+
57
+ def __init__(self, graph: ProjectGraph, max_depth: int = 5) -> None:
58
+ self.graph = graph
59
+ self.max_depth = max_depth
60
+ self._nodes = {node.id: node for node in graph.nodes}
61
+ incoming: dict[str, list[GraphEdge]] = defaultdict(list)
62
+ for edge in graph.edges:
63
+ incoming[edge.target].append(edge)
64
+ self._incoming = incoming
65
+
66
+ def _find_target(self, query: str) -> GraphNode:
67
+ lowered = query.lower()
68
+ exact = [node for node in self.graph.nodes if node.name.lower() == lowered]
69
+ if exact:
70
+ exact.sort(key=lambda node: (node.kind == "file", node.path or ""))
71
+ return exact[0]
72
+ matches = self.graph.search(query, limit=1)
73
+ if not matches:
74
+ raise ValueError(f"No graph node found for: {query}")
75
+ return matches[0]
76
+
77
+ def analyze(self, query: str) -> ImpactReport:
78
+ target = self._find_target(query)
79
+ queue: deque[tuple[str, int, float, str]] = deque()
80
+ for edge in self._incoming.get(target.id, []):
81
+ queue.append((edge.source, 1, self.WEIGHTS.get(edge.kind, 0.5), edge.kind))
82
+
83
+ seen: dict[str, ImpactItem] = {}
84
+ while queue:
85
+ node_id, depth, strength, via = queue.popleft()
86
+ if depth > self.max_depth or node_id == target.id:
87
+ continue
88
+ node = self._nodes.get(node_id)
89
+ if node is None:
90
+ continue
91
+ risk = strength / max(1.0, depth * 0.85)
92
+ existing = seen.get(node_id)
93
+ if existing is not None and existing.risk >= risk:
94
+ continue
95
+ item = ImpactItem(node=node, depth=depth, via=via, risk=min(1.0, risk))
96
+ seen[node_id] = item
97
+ for edge in self._incoming.get(node_id, []):
98
+ weight = self.WEIGHTS.get(edge.kind, 0.5)
99
+ queue.append((edge.source, depth + 1, strength * weight, edge.kind))
100
+
101
+ items = list(seen.values())
102
+ direct = tuple(sorted((item for item in items if item.depth == 1), key=self._sort))
103
+ indirect = tuple(sorted((item for item in items if item.depth > 1), key=self._sort))
104
+ tests = tuple(item for item in items if self._is_test(item.node))
105
+ if items:
106
+ average = sum(item.risk for item in items) / len(items)
107
+ breadth = min(1.0, len(items) / 25)
108
+ risk_score = min(1.0, average * 0.7 + breadth * 0.3)
109
+ else:
110
+ risk_score = 0.0
111
+ return ImpactReport(target, direct, indirect, tests, risk_score)
112
+
113
+ @staticmethod
114
+ def _sort(item: ImpactItem) -> tuple[float, int, str]:
115
+ return (-item.risk, item.depth, item.node.name)
116
+
117
+ @staticmethod
118
+ def _is_test(node: GraphNode) -> bool:
119
+ path = (node.path or "").lower()
120
+ name = node.name.lower()
121
+ return (
122
+ "/test" in f"/{path}"
123
+ or "tests/" in path
124
+ or name.startswith("test_")
125
+ or name.endswith("test")
126
+ or name.endswith("tests")
127
+ )