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.
- codecortex/__init__.py +3 -0
- codecortex/architecture/__init__.py +23 -0
- codecortex/architecture/drift.py +182 -0
- codecortex/architecture/inference.py +160 -0
- codecortex/backends/__init__.py +35 -0
- codecortex/backends/base.py +38 -0
- codecortex/backends/context.py +118 -0
- codecortex/backends/contracts.py +65 -0
- codecortex/backends/factory.py +60 -0
- codecortex/backends/graph.py +107 -0
- codecortex/backends/manager.py +303 -0
- codecortex/backends/mcp_client.py +196 -0
- codecortex/backends/pool.py +128 -0
- codecortex/backends/spec.py +83 -0
- codecortex/backends/symbols.py +189 -0
- codecortex/benchmark.py +211 -0
- codecortex/cli.py +409 -0
- codecortex/config.py +27 -0
- codecortex/context/__init__.py +13 -0
- codecortex/context/budget.py +62 -0
- codecortex/context/integrated.py +60 -0
- codecortex/context/pipeline.py +223 -0
- codecortex/core/__init__.py +1 -0
- codecortex/core/contracts.py +45 -0
- codecortex/core/errors.py +17 -0
- codecortex/core/models.py +69 -0
- codecortex/dashboard.py +259 -0
- codecortex/editing.py +41 -0
- codecortex/engines/__init__.py +5 -0
- codecortex/engines/builtin/__init__.py +5 -0
- codecortex/engines/builtin/factory.py +26 -0
- codecortex/engines/builtin/memory.py +35 -0
- codecortex/engines/builtin/repository.py +73 -0
- codecortex/engines/builtin/symbols.py +89 -0
- codecortex/engines/builtin/validation.py +54 -0
- codecortex/engines/registry.py +26 -0
- codecortex/entrypoint.py +266 -0
- codecortex/evaluation/__init__.py +55 -0
- codecortex/evaluation/external.py +265 -0
- codecortex/evaluation/production.py +670 -0
- codecortex/evaluation/regression.py +188 -0
- codecortex/gateway.py +38 -0
- codecortex/git_intelligence.py +252 -0
- codecortex/indexing/__init__.py +6 -0
- codecortex/indexing/graph.py +78 -0
- codecortex/indexing/impact.py +127 -0
- codecortex/indexing/incremental.py +163 -0
- codecortex/indexing/incremental_graph.py +189 -0
- codecortex/indexing/indexer.py +172 -0
- codecortex/indexing/relationships.py +179 -0
- codecortex/indexing/resolution.py +88 -0
- codecortex/integrations/__init__.py +5 -0
- codecortex/integrations/agents.py +235 -0
- codecortex/interfaces/__init__.py +1 -0
- codecortex/interfaces/mcp_bridge.py +66 -0
- codecortex/languages/__init__.py +5 -0
- codecortex/languages/native.py +166 -0
- codecortex/languages/registry.py +232 -0
- codecortex/mcp/__init__.py +5 -0
- codecortex/mcp/extended.py +114 -0
- codecortex/mcp/server.py +473 -0
- codecortex/memory/__init__.py +11 -0
- codecortex/memory/json_store.py +52 -0
- codecortex/memory/knowledge.py +193 -0
- codecortex/memory/team_store.py +193 -0
- codecortex/orchestrator.py +153 -0
- codecortex/pr_intelligence.py +214 -0
- codecortex/retrieval/__init__.py +16 -0
- codecortex/retrieval/hybrid.py +67 -0
- codecortex/retrieval/index.py +135 -0
- codecortex/retrieval/providers.py +67 -0
- codecortex/retrieval/repository.py +94 -0
- codecortex/router/__init__.py +5 -0
- codecortex/router/router.py +79 -0
- codecortex/runtime.py +69 -0
- codecortex/setup.py +100 -0
- codecortex/symbols/__init__.py +5 -0
- codecortex/symbols/providers.py +192 -0
- codecortex/telemetry/__init__.py +5 -0
- codecortex/telemetry/collector.py +43 -0
- codecortex/tracing/__init__.py +9 -0
- codecortex/tracing/task_trace.py +235 -0
- codecortex/workspace/__init__.py +9 -0
- codecortex/workspace/federation.py +173 -0
- codecortex_context_engine-0.1.0a1.dist-info/METADATA +381 -0
- codecortex_context_engine-0.1.0a1.dist-info/RECORD +90 -0
- codecortex_context_engine-0.1.0a1.dist-info/WHEEL +4 -0
- codecortex_context_engine-0.1.0a1.dist-info/entry_points.txt +3 -0
- codecortex_context_engine-0.1.0a1.dist-info/licenses/LICENSE +201 -0
- 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,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
|
+
)
|