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,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