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,163 @@
|
|
|
1
|
+
"""Persistent incremental repository index."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import time
|
|
9
|
+
from dataclasses import asdict, dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
_EXCLUDED = {
|
|
14
|
+
".git",
|
|
15
|
+
".codecortex",
|
|
16
|
+
".venv",
|
|
17
|
+
"venv",
|
|
18
|
+
"node_modules",
|
|
19
|
+
"dist",
|
|
20
|
+
"build",
|
|
21
|
+
"__pycache__",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class FileState:
|
|
27
|
+
digest: str
|
|
28
|
+
size: int
|
|
29
|
+
mtime_ns: int
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class IndexStats:
|
|
34
|
+
tracked: int
|
|
35
|
+
added: tuple[str, ...]
|
|
36
|
+
changed: tuple[str, ...]
|
|
37
|
+
removed: tuple[str, ...]
|
|
38
|
+
unchanged: int
|
|
39
|
+
duration_ms: float
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def dirty(self) -> bool:
|
|
43
|
+
return bool(self.added or self.changed or self.removed)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class IncrementalIndex:
|
|
47
|
+
"""Track repository files by content hash and persist the result locally."""
|
|
48
|
+
|
|
49
|
+
VERSION = 1
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
root: Path,
|
|
54
|
+
state_path: Path | None = None,
|
|
55
|
+
max_file_bytes: int = 4 * 1024 * 1024,
|
|
56
|
+
) -> None:
|
|
57
|
+
self.root = root.resolve()
|
|
58
|
+
self.state_path = state_path or self.root / ".codecortex" / "index" / "manifest.json"
|
|
59
|
+
self.max_file_bytes = max_file_bytes
|
|
60
|
+
|
|
61
|
+
def _iter_files(self) -> list[Path]:
|
|
62
|
+
files: list[Path] = []
|
|
63
|
+
for path in self.root.rglob("*"):
|
|
64
|
+
if not path.is_file():
|
|
65
|
+
continue
|
|
66
|
+
relative = path.relative_to(self.root)
|
|
67
|
+
if any(part in _EXCLUDED for part in relative.parts):
|
|
68
|
+
continue
|
|
69
|
+
try:
|
|
70
|
+
if path.stat().st_size > self.max_file_bytes:
|
|
71
|
+
continue
|
|
72
|
+
except OSError:
|
|
73
|
+
continue
|
|
74
|
+
files.append(path)
|
|
75
|
+
files.sort(key=lambda item: item.relative_to(self.root).as_posix())
|
|
76
|
+
return files
|
|
77
|
+
|
|
78
|
+
@staticmethod
|
|
79
|
+
def _digest(path: Path) -> str:
|
|
80
|
+
hasher = hashlib.blake2b(digest_size=20)
|
|
81
|
+
with path.open("rb") as handle:
|
|
82
|
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
83
|
+
hasher.update(chunk)
|
|
84
|
+
return hasher.hexdigest()
|
|
85
|
+
|
|
86
|
+
def _load(self) -> dict[str, FileState]:
|
|
87
|
+
try:
|
|
88
|
+
payload = json.loads(self.state_path.read_text(encoding="utf-8"))
|
|
89
|
+
except (OSError, json.JSONDecodeError):
|
|
90
|
+
return {}
|
|
91
|
+
if payload.get("version") != self.VERSION:
|
|
92
|
+
return {}
|
|
93
|
+
result: dict[str, FileState] = {}
|
|
94
|
+
for name, value in payload.get("files", {}).items():
|
|
95
|
+
try:
|
|
96
|
+
result[name] = FileState(
|
|
97
|
+
digest=str(value["digest"]),
|
|
98
|
+
size=int(value["size"]),
|
|
99
|
+
mtime_ns=int(value["mtime_ns"]),
|
|
100
|
+
)
|
|
101
|
+
except (KeyError, TypeError, ValueError):
|
|
102
|
+
continue
|
|
103
|
+
return result
|
|
104
|
+
|
|
105
|
+
def _save(self, files: dict[str, FileState]) -> None:
|
|
106
|
+
self.state_path.parent.mkdir(parents=True, exist_ok=True)
|
|
107
|
+
payload: dict[str, Any] = {
|
|
108
|
+
"version": self.VERSION,
|
|
109
|
+
"root": str(self.root),
|
|
110
|
+
"files": {name: asdict(state) for name, state in sorted(files.items())},
|
|
111
|
+
}
|
|
112
|
+
temp_path = self.state_path.with_suffix(".tmp")
|
|
113
|
+
temp_path.write_text(
|
|
114
|
+
json.dumps(payload, ensure_ascii=False, indent=2),
|
|
115
|
+
encoding="utf-8",
|
|
116
|
+
)
|
|
117
|
+
os.replace(temp_path, self.state_path)
|
|
118
|
+
|
|
119
|
+
def refresh(self) -> IndexStats:
|
|
120
|
+
started = time.perf_counter()
|
|
121
|
+
previous = self._load()
|
|
122
|
+
current: dict[str, FileState] = {}
|
|
123
|
+
added: list[str] = []
|
|
124
|
+
changed: list[str] = []
|
|
125
|
+
unchanged = 0
|
|
126
|
+
|
|
127
|
+
for path in self._iter_files():
|
|
128
|
+
relative = path.relative_to(self.root).as_posix()
|
|
129
|
+
try:
|
|
130
|
+
stat = path.stat()
|
|
131
|
+
except OSError:
|
|
132
|
+
continue
|
|
133
|
+
old = previous.get(relative)
|
|
134
|
+
if old and old.size == stat.st_size and old.mtime_ns == stat.st_mtime_ns:
|
|
135
|
+
current[relative] = old
|
|
136
|
+
unchanged += 1
|
|
137
|
+
continue
|
|
138
|
+
try:
|
|
139
|
+
state = FileState(
|
|
140
|
+
digest=self._digest(path),
|
|
141
|
+
size=stat.st_size,
|
|
142
|
+
mtime_ns=stat.st_mtime_ns,
|
|
143
|
+
)
|
|
144
|
+
except OSError:
|
|
145
|
+
continue
|
|
146
|
+
current[relative] = state
|
|
147
|
+
if old is None:
|
|
148
|
+
added.append(relative)
|
|
149
|
+
elif old.digest != state.digest:
|
|
150
|
+
changed.append(relative)
|
|
151
|
+
else:
|
|
152
|
+
unchanged += 1
|
|
153
|
+
|
|
154
|
+
removed = sorted(set(previous) - set(current))
|
|
155
|
+
self._save(current)
|
|
156
|
+
return IndexStats(
|
|
157
|
+
tracked=len(current),
|
|
158
|
+
added=tuple(added),
|
|
159
|
+
changed=tuple(changed),
|
|
160
|
+
removed=tuple(removed),
|
|
161
|
+
unchanged=unchanged,
|
|
162
|
+
duration_ms=(time.perf_counter() - started) * 1000,
|
|
163
|
+
)
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Incremental graph maintenance without full repository rebuilds."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from codecortex.indexing.graph import GraphEdge, GraphNode, ProjectGraph
|
|
9
|
+
from codecortex.indexing.incremental import IncrementalIndex, IndexStats
|
|
10
|
+
from codecortex.indexing.indexer import ProjectIndexer
|
|
11
|
+
from codecortex.indexing.relationships import RelationshipExtractor
|
|
12
|
+
from codecortex.indexing.resolution import CrossFileResolver
|
|
13
|
+
from codecortex.symbols import SymbolProviderRegistry
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class GraphUpdateStats:
|
|
18
|
+
index: IndexStats
|
|
19
|
+
full_rebuild: bool
|
|
20
|
+
nodes_before: int
|
|
21
|
+
nodes_after: int
|
|
22
|
+
edges_before: int
|
|
23
|
+
edges_after: int
|
|
24
|
+
files_reparsed: int
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class IncrementalGraphIndex:
|
|
28
|
+
"""Persist and patch graph fragments for files reported dirty by the manifest."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, root: Path, graph_path: Path | None = None) -> None:
|
|
31
|
+
self.root = root.resolve()
|
|
32
|
+
self.graph_path = graph_path or self.root / ".codecortex" / "index" / "graph.json"
|
|
33
|
+
self.manifest = IncrementalIndex(self.root)
|
|
34
|
+
self.symbols = SymbolProviderRegistry()
|
|
35
|
+
self.relationships = RelationshipExtractor()
|
|
36
|
+
self.resolver = CrossFileResolver()
|
|
37
|
+
|
|
38
|
+
def refresh(self) -> tuple[ProjectGraph, GraphUpdateStats]:
|
|
39
|
+
index_stats = self.manifest.refresh()
|
|
40
|
+
previous = ProjectGraph.load(self.graph_path)
|
|
41
|
+
before_nodes = len(previous.nodes)
|
|
42
|
+
before_edges = len(previous.edges)
|
|
43
|
+
if not previous.nodes:
|
|
44
|
+
graph = ProjectIndexer(self.root).build()
|
|
45
|
+
graph.save(self.graph_path)
|
|
46
|
+
return graph, GraphUpdateStats(
|
|
47
|
+
index=index_stats,
|
|
48
|
+
full_rebuild=True,
|
|
49
|
+
nodes_before=before_nodes,
|
|
50
|
+
nodes_after=len(graph.nodes),
|
|
51
|
+
edges_before=before_edges,
|
|
52
|
+
edges_after=len(graph.edges),
|
|
53
|
+
files_reparsed=index_stats.tracked,
|
|
54
|
+
)
|
|
55
|
+
dirty = set(index_stats.added) | set(index_stats.changed) | set(index_stats.removed)
|
|
56
|
+
if not dirty:
|
|
57
|
+
return previous, GraphUpdateStats(
|
|
58
|
+
index=index_stats,
|
|
59
|
+
full_rebuild=False,
|
|
60
|
+
nodes_before=before_nodes,
|
|
61
|
+
nodes_after=before_nodes,
|
|
62
|
+
edges_before=before_edges,
|
|
63
|
+
edges_after=before_edges,
|
|
64
|
+
files_reparsed=0,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
removed_ids = {node.id for node in previous.nodes if node.path in dirty}
|
|
68
|
+
retained_nodes = [node for node in previous.nodes if node.id not in removed_ids]
|
|
69
|
+
retained_edges = [
|
|
70
|
+
edge
|
|
71
|
+
for edge in previous.edges
|
|
72
|
+
if edge.source not in removed_ids and edge.target not in removed_ids
|
|
73
|
+
]
|
|
74
|
+
new_nodes, new_edges = self._fragments(sorted(set(index_stats.added) | set(index_stats.changed)), retained_nodes)
|
|
75
|
+
node_map = {node.id: node for node in retained_nodes}
|
|
76
|
+
node_map.update({node.id: node for node in new_nodes})
|
|
77
|
+
edge_map = {
|
|
78
|
+
(edge.source, edge.target, edge.kind): edge
|
|
79
|
+
for edge in retained_edges + new_edges
|
|
80
|
+
if edge.source != edge.target
|
|
81
|
+
}
|
|
82
|
+
graph = ProjectGraph(nodes=list(node_map.values()), edges=list(edge_map.values()))
|
|
83
|
+
graph.save(self.graph_path)
|
|
84
|
+
return graph, GraphUpdateStats(
|
|
85
|
+
index=index_stats,
|
|
86
|
+
full_rebuild=False,
|
|
87
|
+
nodes_before=before_nodes,
|
|
88
|
+
nodes_after=len(graph.nodes),
|
|
89
|
+
edges_before=before_edges,
|
|
90
|
+
edges_after=len(graph.edges),
|
|
91
|
+
files_reparsed=len(index_stats.added) + len(index_stats.changed),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def _fragments(
|
|
95
|
+
self,
|
|
96
|
+
paths: list[str],
|
|
97
|
+
retained_nodes: list[GraphNode],
|
|
98
|
+
) -> tuple[list[GraphNode], list[GraphEdge]]:
|
|
99
|
+
nodes: list[GraphNode] = []
|
|
100
|
+
edges: list[GraphEdge] = []
|
|
101
|
+
names: dict[str, list[GraphNode]] = {}
|
|
102
|
+
for node in retained_nodes:
|
|
103
|
+
if node.kind not in {"file", "module", "reference"}:
|
|
104
|
+
names.setdefault(node.name, []).append(node)
|
|
105
|
+
sources: dict[str, str] = {}
|
|
106
|
+
local_symbols: dict[tuple[str, str], str] = {}
|
|
107
|
+
|
|
108
|
+
for relative in paths:
|
|
109
|
+
path = self.root / relative
|
|
110
|
+
if not path.is_file():
|
|
111
|
+
continue
|
|
112
|
+
file_id = f"file:{relative}"
|
|
113
|
+
nodes.append(
|
|
114
|
+
GraphNode(
|
|
115
|
+
id=file_id,
|
|
116
|
+
kind="file",
|
|
117
|
+
name=path.name,
|
|
118
|
+
path=relative,
|
|
119
|
+
metadata={"extension": path.suffix.lower()},
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
if not self.symbols.supports(path):
|
|
123
|
+
continue
|
|
124
|
+
try:
|
|
125
|
+
source = path.read_text(encoding="utf-8")
|
|
126
|
+
except (OSError, UnicodeDecodeError):
|
|
127
|
+
continue
|
|
128
|
+
sources[relative] = source
|
|
129
|
+
for symbol in self.symbols.extract(path, source):
|
|
130
|
+
if symbol.kind in {"import", "export"}:
|
|
131
|
+
continue
|
|
132
|
+
node = GraphNode(
|
|
133
|
+
id=f"symbol:{relative}:{symbol.line}:{symbol.kind}:{symbol.name}",
|
|
134
|
+
kind=symbol.kind,
|
|
135
|
+
name=symbol.name,
|
|
136
|
+
path=relative,
|
|
137
|
+
line=symbol.line,
|
|
138
|
+
metadata={"language": symbol.language, "container": symbol.container},
|
|
139
|
+
)
|
|
140
|
+
nodes.append(node)
|
|
141
|
+
names.setdefault(node.name, []).append(node)
|
|
142
|
+
local_symbols[(relative, node.name)] = node.id
|
|
143
|
+
edges.extend(
|
|
144
|
+
[
|
|
145
|
+
GraphEdge(source=file_id, target=node.id, kind="contains"),
|
|
146
|
+
GraphEdge(source=file_id, target=node.id, kind="defines"),
|
|
147
|
+
]
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
existing_ids = {node.id for node in retained_nodes + nodes}
|
|
151
|
+
for relative, source in sources.items():
|
|
152
|
+
file_id = f"file:{relative}"
|
|
153
|
+
path = self.root / relative
|
|
154
|
+
for relation in self.relationships.extract(path, source):
|
|
155
|
+
source_id = local_symbols.get((relative, relation.source_symbol or ""), file_id)
|
|
156
|
+
resolution = self.resolver.resolve(
|
|
157
|
+
relation.target,
|
|
158
|
+
relative,
|
|
159
|
+
names.get(relation.target, []),
|
|
160
|
+
relation.kind,
|
|
161
|
+
)
|
|
162
|
+
if resolution.target_id:
|
|
163
|
+
target_id = resolution.target_id
|
|
164
|
+
metadata: dict[str, object] = {
|
|
165
|
+
"resolution_confidence": round(resolution.confidence, 4),
|
|
166
|
+
"ambiguity": round(resolution.ambiguity, 4),
|
|
167
|
+
"candidate_count": len(resolution.candidates),
|
|
168
|
+
}
|
|
169
|
+
else:
|
|
170
|
+
prefix = "module" if relation.kind == "imports" else "reference"
|
|
171
|
+
target_id = f"{prefix}:{relation.target}"
|
|
172
|
+
metadata = {
|
|
173
|
+
"resolution_confidence": 0.0,
|
|
174
|
+
"ambiguity": 1.0,
|
|
175
|
+
"candidate_count": 0,
|
|
176
|
+
}
|
|
177
|
+
if target_id not in existing_ids:
|
|
178
|
+
existing_ids.add(target_id)
|
|
179
|
+
nodes.append(GraphNode(id=target_id, kind=prefix, name=relation.target))
|
|
180
|
+
metadata["line"] = relation.line
|
|
181
|
+
edges.append(
|
|
182
|
+
GraphEdge(
|
|
183
|
+
source=source_id,
|
|
184
|
+
target=target_id,
|
|
185
|
+
kind=relation.kind,
|
|
186
|
+
metadata=metadata,
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
return nodes, edges
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Build the repository knowledge graph."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from codecortex.indexing.graph import GraphEdge, GraphNode, ProjectGraph
|
|
8
|
+
from codecortex.indexing.relationships import RelationshipExtractor
|
|
9
|
+
from codecortex.indexing.resolution import CrossFileResolver
|
|
10
|
+
from codecortex.symbols import SymbolProviderRegistry
|
|
11
|
+
|
|
12
|
+
_EXCLUDED = {
|
|
13
|
+
".git",
|
|
14
|
+
".codecortex",
|
|
15
|
+
".venv",
|
|
16
|
+
"venv",
|
|
17
|
+
"node_modules",
|
|
18
|
+
"dist",
|
|
19
|
+
"build",
|
|
20
|
+
"__pycache__",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ProjectIndexer:
|
|
25
|
+
def __init__(self, root: Path, max_files: int = 5_000) -> None:
|
|
26
|
+
self.root = root.resolve()
|
|
27
|
+
self.max_files = max_files
|
|
28
|
+
self.symbols = SymbolProviderRegistry()
|
|
29
|
+
self.relationships = RelationshipExtractor()
|
|
30
|
+
self.resolver = CrossFileResolver()
|
|
31
|
+
|
|
32
|
+
def _files(self) -> list[Path]:
|
|
33
|
+
files: list[Path] = []
|
|
34
|
+
for path in self.root.rglob("*"):
|
|
35
|
+
if len(files) >= self.max_files:
|
|
36
|
+
break
|
|
37
|
+
if not path.is_file():
|
|
38
|
+
continue
|
|
39
|
+
relative = path.relative_to(self.root)
|
|
40
|
+
if any(part in _EXCLUDED for part in relative.parts):
|
|
41
|
+
continue
|
|
42
|
+
files.append(path)
|
|
43
|
+
return sorted(files)
|
|
44
|
+
|
|
45
|
+
def build(self) -> ProjectGraph:
|
|
46
|
+
files = self._files()
|
|
47
|
+
nodes: list[GraphNode] = []
|
|
48
|
+
edges: list[GraphEdge] = []
|
|
49
|
+
node_ids: set[str] = set()
|
|
50
|
+
names: dict[str, list[GraphNode]] = {}
|
|
51
|
+
file_sources: dict[Path, str] = {}
|
|
52
|
+
symbol_for_file: dict[tuple[str, str], str] = {}
|
|
53
|
+
|
|
54
|
+
for path in files:
|
|
55
|
+
relative = path.relative_to(self.root)
|
|
56
|
+
file_id = f"file:{relative.as_posix()}"
|
|
57
|
+
self._node(
|
|
58
|
+
nodes,
|
|
59
|
+
node_ids,
|
|
60
|
+
GraphNode(
|
|
61
|
+
id=file_id,
|
|
62
|
+
kind="file",
|
|
63
|
+
name=relative.name,
|
|
64
|
+
path=relative.as_posix(),
|
|
65
|
+
metadata={"extension": relative.suffix.lower()},
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
if not self.symbols.supports(path):
|
|
69
|
+
continue
|
|
70
|
+
try:
|
|
71
|
+
source = path.read_text(encoding="utf-8")
|
|
72
|
+
except (OSError, UnicodeDecodeError):
|
|
73
|
+
continue
|
|
74
|
+
file_sources[path] = source
|
|
75
|
+
for symbol in self.symbols.extract(path, source):
|
|
76
|
+
if symbol.kind in {"import", "export"}:
|
|
77
|
+
continue
|
|
78
|
+
symbol_id = f"symbol:{relative.as_posix()}:{symbol.line}:{symbol.kind}:{symbol.name}"
|
|
79
|
+
node = GraphNode(
|
|
80
|
+
id=symbol_id,
|
|
81
|
+
kind=symbol.kind,
|
|
82
|
+
name=symbol.name,
|
|
83
|
+
path=relative.as_posix(),
|
|
84
|
+
line=symbol.line,
|
|
85
|
+
metadata={
|
|
86
|
+
"language": symbol.language,
|
|
87
|
+
"container": symbol.container,
|
|
88
|
+
},
|
|
89
|
+
)
|
|
90
|
+
self._node(nodes, node_ids, node)
|
|
91
|
+
edges.append(GraphEdge(source=file_id, target=symbol_id, kind="contains"))
|
|
92
|
+
edges.append(GraphEdge(source=file_id, target=symbol_id, kind="defines"))
|
|
93
|
+
names.setdefault(symbol.name, []).append(node)
|
|
94
|
+
symbol_for_file[(relative.as_posix(), symbol.name)] = symbol_id
|
|
95
|
+
|
|
96
|
+
for path, source in file_sources.items():
|
|
97
|
+
relative = path.relative_to(self.root)
|
|
98
|
+
source_path = relative.as_posix()
|
|
99
|
+
file_id = f"file:{source_path}"
|
|
100
|
+
for relation in self.relationships.extract(path, source):
|
|
101
|
+
source_id = file_id
|
|
102
|
+
if relation.source_symbol:
|
|
103
|
+
source_id = symbol_for_file.get(
|
|
104
|
+
(source_path, relation.source_symbol),
|
|
105
|
+
file_id,
|
|
106
|
+
)
|
|
107
|
+
target_id, metadata = self._resolve_target(
|
|
108
|
+
relation.target,
|
|
109
|
+
relation.kind,
|
|
110
|
+
source_path,
|
|
111
|
+
names,
|
|
112
|
+
nodes,
|
|
113
|
+
node_ids,
|
|
114
|
+
)
|
|
115
|
+
metadata["line"] = relation.line
|
|
116
|
+
edges.append(
|
|
117
|
+
GraphEdge(
|
|
118
|
+
source=source_id,
|
|
119
|
+
target=target_id,
|
|
120
|
+
kind=relation.kind,
|
|
121
|
+
metadata=metadata,
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
unique_edges = {
|
|
126
|
+
(edge.source, edge.target, edge.kind): edge
|
|
127
|
+
for edge in edges
|
|
128
|
+
if edge.source != edge.target
|
|
129
|
+
}
|
|
130
|
+
return ProjectGraph(nodes=nodes, edges=list(unique_edges.values()))
|
|
131
|
+
|
|
132
|
+
@staticmethod
|
|
133
|
+
def _node(nodes: list[GraphNode], node_ids: set[str], node: GraphNode) -> None:
|
|
134
|
+
if node.id not in node_ids:
|
|
135
|
+
node_ids.add(node.id)
|
|
136
|
+
nodes.append(node)
|
|
137
|
+
|
|
138
|
+
def _resolve_target(
|
|
139
|
+
self,
|
|
140
|
+
target: str,
|
|
141
|
+
kind: str,
|
|
142
|
+
source_path: str,
|
|
143
|
+
names: dict[str, list[GraphNode]],
|
|
144
|
+
nodes: list[GraphNode],
|
|
145
|
+
node_ids: set[str],
|
|
146
|
+
) -> tuple[str, dict[str, object]]:
|
|
147
|
+
candidates = names.get(target, [])
|
|
148
|
+
result = self.resolver.resolve(target, source_path, candidates, kind)
|
|
149
|
+
if result.target_id is not None:
|
|
150
|
+
return result.target_id, {
|
|
151
|
+
"resolution_confidence": round(result.confidence, 4),
|
|
152
|
+
"ambiguity": round(result.ambiguity, 4),
|
|
153
|
+
"candidate_count": len(result.candidates),
|
|
154
|
+
"candidates": [
|
|
155
|
+
{
|
|
156
|
+
"id": item.node_id,
|
|
157
|
+
"score": round(item.score, 4),
|
|
158
|
+
"reasons": list(item.reasons),
|
|
159
|
+
}
|
|
160
|
+
for item in result.candidates
|
|
161
|
+
],
|
|
162
|
+
}
|
|
163
|
+
prefix = "module" if kind == "imports" else "reference"
|
|
164
|
+
target_id = f"{prefix}:{target}"
|
|
165
|
+
if target_id not in node_ids:
|
|
166
|
+
node_ids.add(target_id)
|
|
167
|
+
nodes.append(GraphNode(id=target_id, kind=prefix, name=target))
|
|
168
|
+
return target_id, {
|
|
169
|
+
"resolution_confidence": 0.0,
|
|
170
|
+
"ambiguity": 1.0,
|
|
171
|
+
"candidate_count": 0,
|
|
172
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Extract dependency and call relationships from source files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class Relationship:
|
|
13
|
+
kind: str
|
|
14
|
+
target: str
|
|
15
|
+
line: int
|
|
16
|
+
source_symbol: str | None = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class RelationshipExtractor:
|
|
20
|
+
_CALL_RE = re.compile(r"\b([A-Za-z_$][\w$]*)\s*\(")
|
|
21
|
+
_JS_IMPORT_RE = re.compile(
|
|
22
|
+
r"^\s*(?:import\s+(?:[^\n]+?\s+from\s+)?|require\s*\()[\"']([^\"']+)",
|
|
23
|
+
re.MULTILINE,
|
|
24
|
+
)
|
|
25
|
+
_GO_IMPORT_RE = re.compile(r"^\s*import\s+[\"']([^\"']+)[\"']", re.MULTILINE)
|
|
26
|
+
_RUST_USE_RE = re.compile(r"^\s*use\s+([^;]+);", re.MULTILINE)
|
|
27
|
+
_JVM_IMPORT_RE = re.compile(r"^\s*import\s+([A-Za-z_][\w.*]+)\s*;", re.MULTILINE)
|
|
28
|
+
_C_INCLUDE_RE = re.compile(r"^\s*#\s*include\s*[<\"]([^>\"]+)[>\"]", re.MULTILINE)
|
|
29
|
+
_PHP_USE_RE = re.compile(r"^\s*use\s+([^;]+);", re.MULTILINE)
|
|
30
|
+
_RUBY_REQUIRE_RE = re.compile(
|
|
31
|
+
r"^\s*require(?:_relative)?\s+[\"']([^\"']+)[\"']",
|
|
32
|
+
re.MULTILINE,
|
|
33
|
+
)
|
|
34
|
+
_EXTENDS_RE = re.compile(
|
|
35
|
+
r"\bclass\s+([A-Za-z_$]\w*)\s+extends\s+([A-Za-z_$]\w*)",
|
|
36
|
+
re.MULTILINE,
|
|
37
|
+
)
|
|
38
|
+
_IMPLEMENTS_RE = re.compile(
|
|
39
|
+
r"\bclass\s+([A-Za-z_$]\w*)[^\n{]*\bimplements\s+([A-Za-z_$][\w$.,\s]*)",
|
|
40
|
+
re.MULTILINE,
|
|
41
|
+
)
|
|
42
|
+
_CALL_KEYWORDS = {
|
|
43
|
+
"if",
|
|
44
|
+
"for",
|
|
45
|
+
"while",
|
|
46
|
+
"switch",
|
|
47
|
+
"catch",
|
|
48
|
+
"return",
|
|
49
|
+
"sizeof",
|
|
50
|
+
"typeof",
|
|
51
|
+
"function",
|
|
52
|
+
"def",
|
|
53
|
+
"class",
|
|
54
|
+
"new",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
def extract(self, path: Path, source: str) -> list[Relationship]:
|
|
58
|
+
if path.suffix.lower() == ".py":
|
|
59
|
+
return self._python(source)
|
|
60
|
+
result = self._imports(path, source)
|
|
61
|
+
result.extend(self._inheritance(source))
|
|
62
|
+
result.extend(self._calls(source))
|
|
63
|
+
return self._dedupe(result)
|
|
64
|
+
|
|
65
|
+
@staticmethod
|
|
66
|
+
def _python(source: str) -> list[Relationship]:
|
|
67
|
+
try:
|
|
68
|
+
tree = ast.parse(source)
|
|
69
|
+
except SyntaxError:
|
|
70
|
+
return []
|
|
71
|
+
result: list[Relationship] = []
|
|
72
|
+
containers: list[str] = []
|
|
73
|
+
|
|
74
|
+
class Visitor(ast.NodeVisitor):
|
|
75
|
+
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
76
|
+
for base in node.bases:
|
|
77
|
+
name = RelationshipExtractor._python_name(base)
|
|
78
|
+
if name:
|
|
79
|
+
result.append(Relationship("inherits", name, node.lineno, node.name))
|
|
80
|
+
containers.append(node.name)
|
|
81
|
+
self.generic_visit(node)
|
|
82
|
+
containers.pop()
|
|
83
|
+
|
|
84
|
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
85
|
+
containers.append(node.name)
|
|
86
|
+
self.generic_visit(node)
|
|
87
|
+
containers.pop()
|
|
88
|
+
|
|
89
|
+
visit_AsyncFunctionDef = visit_FunctionDef
|
|
90
|
+
|
|
91
|
+
def visit_Import(self, node: ast.Import) -> None:
|
|
92
|
+
for alias in node.names:
|
|
93
|
+
result.append(Relationship("imports", alias.name, node.lineno))
|
|
94
|
+
|
|
95
|
+
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
|
96
|
+
if node.module:
|
|
97
|
+
result.append(Relationship("imports", node.module, node.lineno))
|
|
98
|
+
|
|
99
|
+
def visit_Call(self, node: ast.Call) -> None:
|
|
100
|
+
name = RelationshipExtractor._python_name(node.func)
|
|
101
|
+
if name:
|
|
102
|
+
result.append(
|
|
103
|
+
Relationship(
|
|
104
|
+
"calls",
|
|
105
|
+
name.split(".")[-1],
|
|
106
|
+
node.lineno,
|
|
107
|
+
containers[-1] if containers else None,
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
self.generic_visit(node)
|
|
111
|
+
|
|
112
|
+
Visitor().visit(tree)
|
|
113
|
+
return RelationshipExtractor._dedupe(result)
|
|
114
|
+
|
|
115
|
+
@staticmethod
|
|
116
|
+
def _python_name(node: ast.AST) -> str | None:
|
|
117
|
+
if isinstance(node, ast.Name):
|
|
118
|
+
return node.id
|
|
119
|
+
if isinstance(node, ast.Attribute):
|
|
120
|
+
base = RelationshipExtractor._python_name(node.value)
|
|
121
|
+
return f"{base}.{node.attr}" if base else node.attr
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
def _imports(self, path: Path, source: str) -> list[Relationship]:
|
|
125
|
+
suffix = path.suffix.lower()
|
|
126
|
+
pattern: re.Pattern[str] | None = None
|
|
127
|
+
if suffix in {".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts"}:
|
|
128
|
+
pattern = self._JS_IMPORT_RE
|
|
129
|
+
elif suffix == ".go":
|
|
130
|
+
pattern = self._GO_IMPORT_RE
|
|
131
|
+
elif suffix == ".rs":
|
|
132
|
+
pattern = self._RUST_USE_RE
|
|
133
|
+
elif suffix in {".java", ".cs"}:
|
|
134
|
+
pattern = self._JVM_IMPORT_RE
|
|
135
|
+
elif suffix in {".c", ".h", ".cc", ".cpp", ".cxx", ".hpp"}:
|
|
136
|
+
pattern = self._C_INCLUDE_RE
|
|
137
|
+
elif suffix == ".php":
|
|
138
|
+
pattern = self._PHP_USE_RE
|
|
139
|
+
elif suffix == ".rb":
|
|
140
|
+
pattern = self._RUBY_REQUIRE_RE
|
|
141
|
+
if pattern is None:
|
|
142
|
+
return []
|
|
143
|
+
return [
|
|
144
|
+
Relationship("imports", match.group(1).strip(), source.count("\n", 0, match.start()) + 1)
|
|
145
|
+
for match in pattern.finditer(source)
|
|
146
|
+
]
|
|
147
|
+
|
|
148
|
+
def _inheritance(self, source: str) -> list[Relationship]:
|
|
149
|
+
result = [
|
|
150
|
+
Relationship("inherits", match.group(2), source.count("\n", 0, match.start()) + 1, match.group(1))
|
|
151
|
+
for match in self._EXTENDS_RE.finditer(source)
|
|
152
|
+
]
|
|
153
|
+
for match in self._IMPLEMENTS_RE.finditer(source):
|
|
154
|
+
line = source.count("\n", 0, match.start()) + 1
|
|
155
|
+
for target in match.group(2).split(","):
|
|
156
|
+
target = target.strip()
|
|
157
|
+
if target:
|
|
158
|
+
result.append(Relationship("implements", target, line, match.group(1)))
|
|
159
|
+
return result
|
|
160
|
+
|
|
161
|
+
def _calls(self, source: str) -> list[Relationship]:
|
|
162
|
+
result: list[Relationship] = []
|
|
163
|
+
for match in self._CALL_RE.finditer(source):
|
|
164
|
+
name = match.group(1)
|
|
165
|
+
if name in self._CALL_KEYWORDS:
|
|
166
|
+
continue
|
|
167
|
+
result.append(Relationship("calls", name, source.count("\n", 0, match.start()) + 1))
|
|
168
|
+
return result
|
|
169
|
+
|
|
170
|
+
@staticmethod
|
|
171
|
+
def _dedupe(items: list[Relationship]) -> list[Relationship]:
|
|
172
|
+
seen: set[tuple[str, str, int, str | None]] = set()
|
|
173
|
+
result: list[Relationship] = []
|
|
174
|
+
for item in items:
|
|
175
|
+
key = (item.kind, item.target, item.line, item.source_symbol)
|
|
176
|
+
if key not in seen:
|
|
177
|
+
seen.add(key)
|
|
178
|
+
result.append(item)
|
|
179
|
+
return result
|