code-review-graph-codeblackwell 2.3.6.post1__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 (74) hide show
  1. code_review_graph/__init__.py +20 -0
  2. code_review_graph/__main__.py +4 -0
  3. code_review_graph/analysis.py +410 -0
  4. code_review_graph/changes.py +409 -0
  5. code_review_graph/cli.py +1255 -0
  6. code_review_graph/communities.py +874 -0
  7. code_review_graph/constants.py +23 -0
  8. code_review_graph/context_savings.py +317 -0
  9. code_review_graph/custom_languages.py +322 -0
  10. code_review_graph/daemon.py +1009 -0
  11. code_review_graph/daemon_cli.py +320 -0
  12. code_review_graph/docs/LLM-OPTIMIZED-REFERENCE.md +71 -0
  13. code_review_graph/embeddings.py +1006 -0
  14. code_review_graph/enrich.py +303 -0
  15. code_review_graph/eval/__init__.py +33 -0
  16. code_review_graph/eval/benchmarks/__init__.py +1 -0
  17. code_review_graph/eval/benchmarks/agent_baseline.py +193 -0
  18. code_review_graph/eval/benchmarks/build_performance.py +60 -0
  19. code_review_graph/eval/benchmarks/flow_completeness.py +36 -0
  20. code_review_graph/eval/benchmarks/impact_accuracy.py +220 -0
  21. code_review_graph/eval/benchmarks/multi_hop_retrieval.py +125 -0
  22. code_review_graph/eval/benchmarks/search_quality.py +59 -0
  23. code_review_graph/eval/benchmarks/token_efficiency.py +143 -0
  24. code_review_graph/eval/configs/code-review-graph.yaml +50 -0
  25. code_review_graph/eval/configs/express.yaml +45 -0
  26. code_review_graph/eval/configs/fastapi.yaml +48 -0
  27. code_review_graph/eval/configs/flask.yaml +50 -0
  28. code_review_graph/eval/configs/gin.yaml +51 -0
  29. code_review_graph/eval/configs/httpx.yaml +48 -0
  30. code_review_graph/eval/reporter.py +301 -0
  31. code_review_graph/eval/runner.py +211 -0
  32. code_review_graph/eval/scorer.py +85 -0
  33. code_review_graph/eval/token_benchmark.py +182 -0
  34. code_review_graph/exports.py +409 -0
  35. code_review_graph/flows.py +698 -0
  36. code_review_graph/graph.py +1427 -0
  37. code_review_graph/graph_diff.py +122 -0
  38. code_review_graph/hints.py +384 -0
  39. code_review_graph/incremental.py +1245 -0
  40. code_review_graph/jedi_resolver.py +303 -0
  41. code_review_graph/main.py +1079 -0
  42. code_review_graph/memory.py +142 -0
  43. code_review_graph/migrations.py +284 -0
  44. code_review_graph/parser.py +6957 -0
  45. code_review_graph/postprocessing.py +134 -0
  46. code_review_graph/prompts.py +159 -0
  47. code_review_graph/refactor.py +852 -0
  48. code_review_graph/registry.py +319 -0
  49. code_review_graph/rescript_resolver.py +206 -0
  50. code_review_graph/search.py +447 -0
  51. code_review_graph/skills.py +1481 -0
  52. code_review_graph/spring_resolver.py +200 -0
  53. code_review_graph/temporal_resolver.py +199 -0
  54. code_review_graph/token_benchmark.py +125 -0
  55. code_review_graph/tools/__init__.py +156 -0
  56. code_review_graph/tools/_common.py +176 -0
  57. code_review_graph/tools/analysis_tools.py +184 -0
  58. code_review_graph/tools/build.py +541 -0
  59. code_review_graph/tools/community_tools.py +246 -0
  60. code_review_graph/tools/context.py +152 -0
  61. code_review_graph/tools/docs.py +274 -0
  62. code_review_graph/tools/flows_tools.py +176 -0
  63. code_review_graph/tools/query.py +692 -0
  64. code_review_graph/tools/refactor_tools.py +168 -0
  65. code_review_graph/tools/registry_tools.py +125 -0
  66. code_review_graph/tools/review.py +477 -0
  67. code_review_graph/tsconfig_resolver.py +257 -0
  68. code_review_graph/visualization.py +2184 -0
  69. code_review_graph/wiki.py +305 -0
  70. code_review_graph_codeblackwell-2.3.6.post1.dist-info/METADATA +718 -0
  71. code_review_graph_codeblackwell-2.3.6.post1.dist-info/RECORD +74 -0
  72. code_review_graph_codeblackwell-2.3.6.post1.dist-info/WHEEL +4 -0
  73. code_review_graph_codeblackwell-2.3.6.post1.dist-info/entry_points.txt +3 -0
  74. code_review_graph_codeblackwell-2.3.6.post1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,319 @@
1
+ """Multi-repo registry and connection pool.
2
+
3
+ Manages a registry of multiple repositories at ``~/.code-review-graph/registry.json``
4
+ and provides a connection pool for concurrent access to multiple graph databases.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import logging
11
+ import sqlite3
12
+ import threading
13
+ from collections import OrderedDict
14
+ from pathlib import Path
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ # Default registry path
19
+ _REGISTRY_DIR = Path.home() / ".code-review-graph"
20
+ _REGISTRY_PATH = _REGISTRY_DIR / "registry.json"
21
+
22
+
23
+ class Registry:
24
+ """Manages a JSON-based registry of code-review-graph repositories.
25
+
26
+ Each entry stores the repo path and an optional alias.
27
+ The registry lives at ``~/.code-review-graph/registry.json``.
28
+ """
29
+
30
+ def __init__(self, path: Path | None = None) -> None:
31
+ self._path = path or _REGISTRY_PATH
32
+ self._path.parent.mkdir(parents=True, exist_ok=True)
33
+ self._lock = threading.Lock()
34
+ self._repos: list[dict[str, str]] = []
35
+ self._load()
36
+
37
+ def _load(self) -> None:
38
+ """Load registry from disk."""
39
+ if self._path.exists():
40
+ try:
41
+ data = json.loads(self._path.read_text(encoding="utf-8", errors="replace"))
42
+ self._repos = data.get("repos", [])
43
+ except (json.JSONDecodeError, KeyError, TypeError):
44
+ logger.warning("Invalid registry file, starting fresh: %s", self._path)
45
+ self._repos = []
46
+ else:
47
+ self._repos = []
48
+
49
+ def _save(self) -> None:
50
+ """Write registry to disk."""
51
+ self._path.parent.mkdir(parents=True, exist_ok=True)
52
+ data = {"repos": self._repos}
53
+ self._path.write_text(
54
+ json.dumps(data, indent=2) + "\n", encoding="utf-8"
55
+ )
56
+
57
+ def register(
58
+ self, path: str, alias: str | None = None, data_dir: str | None = None,
59
+ ) -> dict[str, str]:
60
+ """Register a repository path.
61
+
62
+ Validates that the path contains a ``.git`` or ``.code-review-graph``
63
+ directory.
64
+
65
+ Args:
66
+ path: Absolute or relative path to the repository root.
67
+ alias: Optional short alias for the repository.
68
+ data_dir: Optional external directory for graph database.
69
+
70
+ Returns:
71
+ The registered entry dict.
72
+
73
+ Raises:
74
+ ValueError: If the path is not a valid repository.
75
+ """
76
+ resolved = Path(path).resolve()
77
+ if not resolved.is_dir():
78
+ raise ValueError(f"Path is not a directory: {resolved}")
79
+ has_repo_marker = (
80
+ (resolved / ".git").exists()
81
+ or (resolved / ".svn").exists()
82
+ or (resolved / ".code-review-graph").exists()
83
+ )
84
+ if not has_repo_marker:
85
+ raise ValueError(
86
+ f"Path does not look like a repository "
87
+ f"(no .git, .svn, or .code-review-graph): {resolved}"
88
+ )
89
+
90
+ with self._lock:
91
+ # Check for duplicate path
92
+ str_path = str(resolved)
93
+ for entry in self._repos:
94
+ if entry["path"] == str_path:
95
+ # Update alias and/or data_dir if provided
96
+ if alias:
97
+ entry["alias"] = alias
98
+ if data_dir:
99
+ entry["data_dir"] = str(Path(data_dir).resolve())
100
+ self._save()
101
+ return entry
102
+
103
+ new_entry: dict[str, str] = {"path": str_path}
104
+ if alias:
105
+ new_entry["alias"] = alias
106
+ if data_dir:
107
+ new_entry["data_dir"] = str(Path(data_dir).resolve())
108
+ self._repos.append(new_entry)
109
+ self._save()
110
+ return new_entry
111
+
112
+ def unregister(self, path_or_alias: str) -> bool:
113
+ """Remove a repository by path or alias.
114
+
115
+ Args:
116
+ path_or_alias: Either the absolute path or the alias.
117
+
118
+ Returns:
119
+ True if an entry was removed, False otherwise.
120
+ """
121
+ with self._lock:
122
+ resolved = str(Path(path_or_alias).resolve())
123
+ original_len = len(self._repos)
124
+ self._repos = [
125
+ entry for entry in self._repos
126
+ if entry["path"] != resolved
127
+ and entry.get("alias") != path_or_alias
128
+ ]
129
+ if len(self._repos) < original_len:
130
+ self._save()
131
+ return True
132
+ return False
133
+
134
+ def list_repos(self) -> list[dict[str, str]]:
135
+ """Return list of all registered repositories.
136
+
137
+ Returns:
138
+ List of dicts with 'path' and optional 'alias' keys.
139
+ """
140
+ with self._lock:
141
+ return list(self._repos)
142
+
143
+ def find_by_alias(self, alias: str) -> dict[str, str] | None:
144
+ """Look up a repository by its alias.
145
+
146
+ Args:
147
+ alias: The alias to search for.
148
+
149
+ Returns:
150
+ The matching entry, or None.
151
+ """
152
+ with self._lock:
153
+ for entry in self._repos:
154
+ if entry.get("alias") == alias:
155
+ return dict(entry)
156
+ return None
157
+
158
+ def find_by_path(self, path: str) -> dict[str, str] | None:
159
+ """Look up a repository by its path.
160
+
161
+ Args:
162
+ path: The path to search for.
163
+
164
+ Returns:
165
+ The matching entry, or None.
166
+ """
167
+ resolved = str(Path(path).resolve())
168
+ with self._lock:
169
+ for entry in self._repos:
170
+ if entry["path"] == resolved:
171
+ return dict(entry)
172
+ return None
173
+
174
+ def set_data_dir(self, path: str, data_dir: str) -> dict[str, str]:
175
+ """Set the external data directory for a repository.
176
+
177
+ Args:
178
+ path: Repository path (absolute or relative).
179
+ data_dir: External directory path to store graph database.
180
+
181
+ Returns:
182
+ The updated or created registry entry.
183
+ """
184
+ resolved = str(Path(path).resolve())
185
+ data_resolved = str(Path(data_dir).resolve())
186
+
187
+ with self._lock:
188
+ # Check for existing entry
189
+ for entry in self._repos:
190
+ if entry["path"] == resolved:
191
+ entry["data_dir"] = data_resolved
192
+ self._save()
193
+ return dict(entry)
194
+
195
+ # Create new entry if not found
196
+ new_entry = {
197
+ "path": resolved,
198
+ "data_dir": data_resolved
199
+ }
200
+ self._repos.append(new_entry)
201
+ self._save()
202
+ return new_entry
203
+
204
+ def get_data_dir_for_repo(self, path: str) -> str | None:
205
+ """Get the stored data directory for a repository.
206
+
207
+ Args:
208
+ path: Repository path (absolute or relative).
209
+
210
+ Returns:
211
+ The stored data_dir path, or None if not set.
212
+ """
213
+ resolved = str(Path(path).resolve())
214
+ with self._lock:
215
+ for entry in self._repos:
216
+ if entry["path"] == resolved:
217
+ return entry.get("data_dir")
218
+ return None
219
+
220
+
221
+ class ConnectionPool:
222
+ """LRU connection pool for SQLite graph databases.
223
+
224
+ Caches open connections keyed by database path, evicting the least
225
+ recently used connection when the pool is full.
226
+ """
227
+
228
+ def __init__(self, max_size: int = 10) -> None:
229
+ self._max_size = max_size
230
+ self._pool: OrderedDict[str, sqlite3.Connection] = OrderedDict()
231
+ self._lock = threading.Lock()
232
+
233
+ def get(self, db_path: str) -> sqlite3.Connection:
234
+ """Get or create a connection for the given database path.
235
+
236
+ Args:
237
+ db_path: Path to the SQLite database file.
238
+
239
+ Returns:
240
+ An open SQLite connection.
241
+ """
242
+ key = str(Path(db_path).resolve())
243
+ with self._lock:
244
+ if key in self._pool:
245
+ self._pool.move_to_end(key)
246
+ return self._pool[key]
247
+
248
+ # Evict LRU if full
249
+ while len(self._pool) >= self._max_size:
250
+ evict_key, evict_conn = self._pool.popitem(last=False)
251
+ try:
252
+ evict_conn.close()
253
+ except sqlite3.Error:
254
+ logger.debug("Failed to close evicted connection: %s", evict_key)
255
+ logger.debug("Evicted connection: %s", evict_key)
256
+
257
+ conn = sqlite3.connect(
258
+ key, timeout=30, check_same_thread=False,
259
+ isolation_level=None,
260
+ )
261
+ conn.row_factory = sqlite3.Row
262
+ conn.execute("PRAGMA journal_mode=WAL")
263
+ conn.execute("PRAGMA busy_timeout=5000")
264
+ self._pool[key] = conn
265
+ return conn
266
+
267
+ def close_all(self) -> None:
268
+ """Close all connections in the pool."""
269
+ with self._lock:
270
+ for key, conn in self._pool.items():
271
+ try:
272
+ conn.close()
273
+ except sqlite3.Error:
274
+ logger.debug("Failed to close connection: %s", key)
275
+ self._pool.clear()
276
+
277
+ @property
278
+ def size(self) -> int:
279
+ """Current number of open connections."""
280
+ with self._lock:
281
+ return len(self._pool)
282
+
283
+
284
+ def resolve_repo(
285
+ registry: Registry,
286
+ repo: str | None,
287
+ cwd: str | None = None,
288
+ ) -> str | None:
289
+ """Resolve a repo parameter to an absolute path.
290
+
291
+ Resolution order:
292
+ 1. If repo is given, try as alias first.
293
+ 2. If repo is given and not an alias, try as a direct path.
294
+ 3. If repo is None, use cwd.
295
+
296
+ Args:
297
+ registry: The Registry instance.
298
+ repo: Alias or path string, or None.
299
+ cwd: Current working directory fallback.
300
+
301
+ Returns:
302
+ Resolved absolute path string, or None if unresolvable.
303
+ """
304
+ if repo:
305
+ # Try alias first
306
+ entry = registry.find_by_alias(repo)
307
+ if entry:
308
+ return entry["path"]
309
+
310
+ # Try as direct path
311
+ path = Path(repo).resolve()
312
+ if path.is_dir():
313
+ return str(path)
314
+
315
+ # Fall back to CWD
316
+ if cwd:
317
+ return str(Path(cwd).resolve())
318
+
319
+ return None
@@ -0,0 +1,206 @@
1
+ """Post-build pass that resolves ReScript cross-module references.
2
+
3
+ The per-file parser emits CALLS/IMPORTS_FROM edges with bare targets like
4
+ ``LogicUtils.safeParse`` because the parser only sees one file at a time.
5
+ This module runs after ``full_build`` / incremental updates and rewrites
6
+ those targets to canonical qualified names like
7
+ ``<abs-path>/LogicUtils.res::safeParse`` so ``callers_of``,
8
+ ``get_impact_radius`` and ``importers_of`` work correctly across files.
9
+
10
+ Resolutions performed:
11
+ 1. ``Module.fn`` / ``Module.Sub.fn`` CALLS edges → canonical node
12
+ when a ``.res`` / ``.resi`` file with matching basename exists.
13
+ 2. Bare ``fn(...)`` CALLS edges in a file that ``open`` / ``include``\\s
14
+ a module → canonical node in that module's file.
15
+ 3. IMPORTS_FROM edges targeting a module name (open / include / jsx /
16
+ module_alias / external_module) → the target file path, so
17
+ ``importers_of(<path>)`` finds every consuming file.
18
+
19
+ Only the ``target_qualified`` column is updated; source and edge kind are
20
+ preserved. Edges that cannot be resolved are left unchanged.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import logging
27
+ from pathlib import Path
28
+ from typing import TYPE_CHECKING
29
+
30
+ if TYPE_CHECKING:
31
+ from .graph import GraphStore
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+
36
+ def resolve_rescript_cross_module(store: GraphStore) -> dict:
37
+ """Resolve ReScript cross-module targets in the graph store.
38
+
39
+ Safe to call multiple times: already-resolved edges (targets containing
40
+ ``::``) are skipped.
41
+
42
+ Returns a dict with resolution counts for telemetry.
43
+ """
44
+ conn = store._conn # intentional: post-build maintenance pass
45
+
46
+ # Basename (module name) → absolute file path, preferring .res over .resi.
47
+ basename_to_path: dict[str, str] = {}
48
+ rescript_files: set[str] = set()
49
+ for file_path in store.get_all_files():
50
+ p = Path(file_path)
51
+ suffix = p.suffix.lower()
52
+ if suffix not in (".res", ".resi"):
53
+ continue
54
+ rescript_files.add(file_path)
55
+ stem = p.stem
56
+ existing = basename_to_path.get(stem)
57
+ if existing is None or existing.lower().endswith(".resi"):
58
+ # Prefer implementation (.res) over interface (.resi).
59
+ basename_to_path[stem] = file_path
60
+
61
+ if not basename_to_path:
62
+ return {"files_indexed": 0, "calls_resolved": 0, "imports_resolved": 0}
63
+
64
+ # Per-file opens/includes so we can resolve bare calls.
65
+ opens_by_file: dict[str, list[str]] = {}
66
+ imports_rows = conn.execute(
67
+ "SELECT source_qualified, target_qualified, file_path, extra "
68
+ "FROM edges WHERE kind = 'IMPORTS_FROM'"
69
+ ).fetchall()
70
+ for row in imports_rows:
71
+ fp = row["file_path"]
72
+ if fp not in rescript_files:
73
+ continue
74
+ try:
75
+ extra = json.loads(row["extra"] or "{}")
76
+ except (json.JSONDecodeError, TypeError):
77
+ extra = {}
78
+ kind = extra.get("rescript_import_kind")
79
+ if kind in ("open", "include"):
80
+ # Strip nested submodule — root determines file.
81
+ root = row["target_qualified"].split(".", 1)[0]
82
+ opens_by_file.setdefault(fp, []).append(root)
83
+
84
+ # --- 1 + 2. Resolve CALLS edges ---
85
+ call_rows = conn.execute(
86
+ "SELECT id, source_qualified, target_qualified, file_path "
87
+ "FROM edges WHERE kind = 'CALLS'"
88
+ ).fetchall()
89
+
90
+ call_updates: list[tuple[str, int]] = []
91
+ for row in call_rows:
92
+ target = row["target_qualified"]
93
+ if "::" in target:
94
+ continue # already resolved
95
+
96
+ resolved = _resolve_call_target(
97
+ target,
98
+ row["file_path"],
99
+ basename_to_path,
100
+ opens_by_file,
101
+ store,
102
+ )
103
+ if resolved and resolved != target:
104
+ call_updates.append((resolved, row["id"]))
105
+
106
+ # --- 3. Resolve IMPORTS_FROM edge targets to file paths ---
107
+ import_updates: list[tuple[str, int]] = []
108
+ import_rows_full = conn.execute(
109
+ "SELECT id, target_qualified, file_path FROM edges "
110
+ "WHERE kind = 'IMPORTS_FROM'"
111
+ ).fetchall()
112
+ for row in import_rows_full:
113
+ target = row["target_qualified"]
114
+ if target in rescript_files:
115
+ continue # already a file path
116
+ if "/" in target or "\\" in target:
117
+ continue # looks like a path already (e.g. relative JS import)
118
+ root = target.split(".", 1)[0]
119
+ file_target = basename_to_path.get(root)
120
+ if file_target and file_target != target:
121
+ import_updates.append((file_target, row["id"]))
122
+
123
+ cur = conn.cursor()
124
+ for new_target, edge_id in call_updates:
125
+ cur.execute(
126
+ "UPDATE edges SET target_qualified = ? WHERE id = ?",
127
+ (new_target, edge_id),
128
+ )
129
+ for new_target, edge_id in import_updates:
130
+ cur.execute(
131
+ "UPDATE edges SET target_qualified = ? WHERE id = ?",
132
+ (new_target, edge_id),
133
+ )
134
+ conn.commit()
135
+ store._invalidate_cache()
136
+
137
+ result = {
138
+ "files_indexed": len(basename_to_path),
139
+ "calls_resolved": len(call_updates),
140
+ "imports_resolved": len(import_updates),
141
+ }
142
+ logger.info("ReScript cross-module resolution: %s", result)
143
+ return result
144
+
145
+
146
+ def _resolve_call_target(
147
+ target: str,
148
+ file_path: str,
149
+ basename_to_path: dict[str, str],
150
+ opens_by_file: dict[str, list[str]],
151
+ store: GraphStore,
152
+ ) -> str | None:
153
+ """Resolve a CALLS edge's ``target_qualified`` to a canonical qualified
154
+ node name. Returns None when no resolution is possible.
155
+ """
156
+ # Dotted: `Module.fn` or `Module.Sub.fn`.
157
+ if "." in target:
158
+ head, _, rest = target.partition(".")
159
+ target_file = basename_to_path.get(head)
160
+ if target_file is None:
161
+ return None
162
+ candidate = _pick_existing_qualified(target_file, rest, store)
163
+ return candidate
164
+
165
+ # Bare: `fn` — only resolvable via an open/include in the calling file.
166
+ for opened in opens_by_file.get(file_path, []):
167
+ target_file = basename_to_path.get(opened)
168
+ if target_file is None:
169
+ continue
170
+ candidate = f"{target_file}::{target}"
171
+ if store.get_node(candidate) is not None:
172
+ return candidate
173
+ return None
174
+
175
+
176
+ def _pick_existing_qualified(
177
+ target_file: str, rest: str, store: GraphStore,
178
+ ) -> str | None:
179
+ """Given ``LogicUtils.foo.bar``, try ``file::foo.bar`` then
180
+ ``file::Foo.bar`` then ``file::foo``. Return the first one that
181
+ corresponds to an existing node.
182
+ """
183
+ # Direct: rest as the qualified name tail.
184
+ direct = f"{target_file}::{rest}"
185
+ if store.get_node(direct) is not None:
186
+ return direct
187
+
188
+ # Dotted rest like `Sub.fn`: parent_name = Sub, name = fn.
189
+ # _qualify formats it the same way, so `direct` would already match if
190
+ # the node was stored with that exact qualified name.
191
+
192
+ # Some targets include a trailing member-access that isn't part of
193
+ # the qualified node (e.g. `LogicUtils.safeParse.resp` — property on
194
+ # the result). Try peeling from the right.
195
+ parts = rest.split(".")
196
+ while len(parts) > 1:
197
+ parts.pop()
198
+ candidate = f"{target_file}::{'.'.join(parts)}"
199
+ if store.get_node(candidate) is not None:
200
+ return candidate
201
+ # Last resort: top-level `file::name` (first part only).
202
+ first = rest.split(".", 1)[0]
203
+ candidate = f"{target_file}::{first}"
204
+ if store.get_node(candidate) is not None:
205
+ return candidate
206
+ return None