codegraph-nav 0.1.0__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 (41) hide show
  1. codegraph_nav/__init__.py +194 -0
  2. codegraph_nav/ast_grep_analyzer.py +448 -0
  3. codegraph_nav/cli.py +223 -0
  4. codegraph_nav/code_navigator.py +1328 -0
  5. codegraph_nav/code_search.py +1009 -0
  6. codegraph_nav/colors.py +209 -0
  7. codegraph_nav/completions.py +354 -0
  8. codegraph_nav/dart_analyzer.py +301 -0
  9. codegraph_nav/dependency_graph.py +814 -0
  10. codegraph_nav/domain/__init__.py +20 -0
  11. codegraph_nav/domain/routes.py +337 -0
  12. codegraph_nav/domain/schemas.py +229 -0
  13. codegraph_nav/domain/tags.py +87 -0
  14. codegraph_nav/exporters.py +563 -0
  15. codegraph_nav/go_analyzer.py +273 -0
  16. codegraph_nav/graph/__init__.py +72 -0
  17. codegraph_nav/graph/builder.py +409 -0
  18. codegraph_nav/graph/communities.py +402 -0
  19. codegraph_nav/graph/flows.py +311 -0
  20. codegraph_nav/graph/query.py +380 -0
  21. codegraph_nav/graph/schema.py +266 -0
  22. codegraph_nav/graph/search.py +257 -0
  23. codegraph_nav/graph/store.py +517 -0
  24. codegraph_nav/hints.py +195 -0
  25. codegraph_nav/import_resolver.py +891 -0
  26. codegraph_nav/js_ts_analyzer.py +564 -0
  27. codegraph_nav/line_reader.py +664 -0
  28. codegraph_nav/mcp/__init__.py +39 -0
  29. codegraph_nav/mcp/__main__.py +5 -0
  30. codegraph_nav/mcp/server.py +2228 -0
  31. codegraph_nav/py.typed +2 -0
  32. codegraph_nav/ruby_analyzer.py +259 -0
  33. codegraph_nav/rust_analyzer.py +379 -0
  34. codegraph_nav/token_efficient_renderer.py +743 -0
  35. codegraph_nav/watcher.py +382 -0
  36. codegraph_nav-0.1.0.dist-info/METADATA +487 -0
  37. codegraph_nav-0.1.0.dist-info/RECORD +41 -0
  38. codegraph_nav-0.1.0.dist-info/WHEEL +5 -0
  39. codegraph_nav-0.1.0.dist-info/entry_points.txt +4 -0
  40. codegraph_nav-0.1.0.dist-info/licenses/LICENSE +21 -0
  41. codegraph_nav-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,257 @@
1
+ """Hybrid search — FTS5 + fuzzy + RRF fusion.
2
+
3
+ Combines four ranked lists via Reciprocal Rank Fusion (RRF):
4
+ A: Exact name match (score=1.0)
5
+ B: Fuzzy name match (existing CodeSearcher)
6
+ C: FTS5 BM25 over name/qualified_name/file_path/signature
7
+ D: (Reserved for Phase C concept/tag match)
8
+
9
+ Gracefully degrades if FTS5 is not compiled into SQLite.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ import sqlite3
16
+
17
+ from .store import GraphStore
18
+
19
+ # ==============================================================================
20
+ # FTS5 Index Management
21
+ # ==============================================================================
22
+
23
+
24
+ def rebuild_fts_index(conn: sqlite3.Connection) -> int:
25
+ """Rebuild FTS5 full-text index from nodes table.
26
+
27
+ Returns number of indexed rows, or -1 if FTS5 unavailable.
28
+ """
29
+ try:
30
+ conn.execute("DROP TABLE IF EXISTS nodes_fts")
31
+ conn.execute("""CREATE VIRTUAL TABLE nodes_fts USING fts5(
32
+ name, qualified_name, file_path, signature,
33
+ tokenize='porter unicode61'
34
+ )""")
35
+ conn.execute("""INSERT INTO nodes_fts(rowid, name, qualified_name, file_path, signature)
36
+ SELECT id, name, qualified_name, file_path, COALESCE(signature, '')
37
+ FROM nodes""")
38
+ conn.commit()
39
+ count: int = conn.execute("SELECT COUNT(*) FROM nodes_fts").fetchone()[0]
40
+ return count
41
+ except sqlite3.OperationalError:
42
+ return -1
43
+
44
+
45
+ # ==============================================================================
46
+ # FTS5 Search
47
+ # ==============================================================================
48
+
49
+
50
+ def fts_search(
51
+ conn: sqlite3.Connection,
52
+ query: str,
53
+ limit: int = 50,
54
+ ) -> list[tuple[int, float]]:
55
+ """BM25 search via FTS5. Returns [(node_id, score)].
56
+
57
+ Query is wrapped in quotes to prevent FTS5 operator injection.
58
+ """
59
+ # Sanitize: wrap in quotes, escape internal quotes
60
+ safe_query = '"' + query.replace('"', '""') + '"'
61
+ try:
62
+ rows = conn.execute(
63
+ "SELECT rowid, rank FROM nodes_fts WHERE nodes_fts MATCH ? " "ORDER BY rank LIMIT ?",
64
+ (safe_query, limit),
65
+ ).fetchall()
66
+ # FTS5 rank is negative (lower = better); negate for sorting
67
+ return [(row[0], -row[1]) for row in rows]
68
+ except sqlite3.OperationalError:
69
+ return []
70
+
71
+
72
+ # ==============================================================================
73
+ # Reciprocal Rank Fusion (RRF)
74
+ # ==============================================================================
75
+
76
+
77
+ def rrf_merge(
78
+ *result_lists: list[tuple[int, float]],
79
+ k: int = 60,
80
+ ) -> list[tuple[int, float]]:
81
+ """Merge multiple ranked lists via Reciprocal Rank Fusion.
82
+
83
+ RRF score for an item = sum(1 / (k + rank + 1)) across all lists
84
+ where 'rank' is 0-indexed position in each list.
85
+
86
+ Args:
87
+ *result_lists: Each list is [(item_id, score)], ordered by score desc.
88
+ k: Smoothing constant (default 60, higher = rank differences matter less).
89
+
90
+ Returns:
91
+ Merged list of (item_id, rrf_score), sorted by rrf_score desc.
92
+ """
93
+ scores: dict[int, float] = {}
94
+ for result_list in result_lists:
95
+ for rank, (item_id, _score) in enumerate(result_list):
96
+ scores[item_id] = scores.get(item_id, 0.0) + 1.0 / (k + rank + 1)
97
+ merged = sorted(scores.items(), key=lambda x: x[1], reverse=True)
98
+ return merged
99
+
100
+
101
+ # ==============================================================================
102
+ # Query Kind Boosting
103
+ # ==============================================================================
104
+
105
+
106
+ def detect_query_boosts(query: str) -> dict[str, float]:
107
+ """Detect query patterns and return kind boosts.
108
+
109
+ - PascalCase → boost Class 1.5x
110
+ - snake_case → boost Function 1.5x
111
+ - Contains "/" → boost file path matches 2.0x
112
+ """
113
+ boosts: dict[str, float] = {}
114
+ q = query.strip()
115
+
116
+ # PascalCase: starts with upper, has lower
117
+ if re.match(r"^[A-Z][a-z]", q) and not q.isupper():
118
+ boosts["Class"] = 1.5
119
+ boosts["Method"] = 1.2
120
+
121
+ # snake_case: has underscore and letters
122
+ if "_" in q and re.search(r"[a-zA-Z]", q):
123
+ boosts["Function"] = 1.5
124
+
125
+ # File path pattern
126
+ if "/" in q or "." in q:
127
+ boosts["_file_path"] = 2.0
128
+
129
+ return boosts
130
+
131
+
132
+ # ==============================================================================
133
+ # Hybrid Search
134
+ # ==============================================================================
135
+
136
+
137
+ def hybrid_search(
138
+ store: GraphStore,
139
+ query: str,
140
+ limit: int = 20,
141
+ k: int = 60,
142
+ ) -> list[dict]:
143
+ """Hybrid search combining FTS5 and name matching via RRF.
144
+
145
+ Lists fused:
146
+ A: Exact name match (from DB)
147
+ B: Prefix/contains name match (from DB)
148
+ C: FTS5 BM25 (if available)
149
+
150
+ Returns list of dicts: {id, name, qualified_name, file_path, kind, lines, score}.
151
+ """
152
+ conn = store.conn
153
+ query_lower = query.lower().strip()
154
+ boosts = detect_query_boosts(query)
155
+
156
+ # List A: Exact name match
157
+ exact_rows = conn.execute(
158
+ "SELECT id, name, kind FROM nodes WHERE LOWER(name) = ? LIMIT ?",
159
+ (query_lower, limit),
160
+ ).fetchall()
161
+ list_a = [(row[0], 1.0) for row in exact_rows]
162
+
163
+ # List B: Contains/prefix match
164
+ like_pattern = f"%{query_lower}%"
165
+ contains_rows = conn.execute(
166
+ "SELECT id, name, kind FROM nodes WHERE LOWER(name) LIKE ? " "AND LOWER(name) != ? LIMIT ?",
167
+ (like_pattern, query_lower, limit * 2),
168
+ ).fetchall()
169
+ list_b = []
170
+ for row in contains_rows:
171
+ name_lower = row[1].lower()
172
+ if name_lower.startswith(query_lower):
173
+ score = 0.8
174
+ elif query_lower in name_lower:
175
+ score = 0.5 + len(query_lower) / len(name_lower) * 0.3
176
+ else:
177
+ score = 0.3
178
+ list_b.append((row[0], score))
179
+ list_b.sort(key=lambda x: x[1], reverse=True)
180
+
181
+ # List C: FTS5 (if available)
182
+ list_c = []
183
+ if store.fts_available:
184
+ list_c = fts_search(conn, query, limit=limit * 2)
185
+
186
+ # Apply kind boosts
187
+ if boosts:
188
+ for lst in [list_a, list_b, list_c]:
189
+ boosted = []
190
+ for node_id, score in lst:
191
+ row = conn.execute(
192
+ "SELECT kind, file_path FROM nodes WHERE id = ?", (node_id,)
193
+ ).fetchone()
194
+ if row:
195
+ kind = row[0]
196
+ boost = boosts.get(kind, 1.0)
197
+ if "_file_path" in boosts and query_lower in (row[1] or "").lower():
198
+ boost *= boosts["_file_path"]
199
+ boosted.append((node_id, score * boost))
200
+ else:
201
+ boosted.append((node_id, score))
202
+ lst.clear()
203
+ lst.extend(boosted)
204
+ lst.sort(key=lambda x: x[1], reverse=True)
205
+
206
+ # RRF merge
207
+ lists_to_merge = [l for l in [list_a, list_b, list_c] if l]
208
+ if not lists_to_merge:
209
+ return []
210
+
211
+ merged = rrf_merge(*lists_to_merge, k=k)
212
+
213
+ # Fetch full node info for top results
214
+ results = []
215
+ for node_id, rrf_score in merged[:limit]:
216
+ node = store.get_node_by_id(node_id)
217
+ if node:
218
+ results.append(
219
+ {
220
+ "id": node["id"],
221
+ "name": node["name"],
222
+ "qualified_name": node["qualified_name"],
223
+ "file_path": node["file_path"],
224
+ "kind": node["kind"],
225
+ "line_start": node["line_start"],
226
+ "line_end": node["line_end"],
227
+ "signature": node["signature"],
228
+ "score": round(rrf_score, 6),
229
+ }
230
+ )
231
+
232
+ return results
233
+
234
+
235
+ def format_search_results_minimal(results: list[dict], limit: int = 20) -> str:
236
+ """Format hybrid search results in compact format."""
237
+ if not results:
238
+ return "No matching symbols found."
239
+
240
+ type_abbr = {
241
+ "Function": "fn",
242
+ "Class": "cls",
243
+ "Method": "mth",
244
+ "Variable": "var",
245
+ "File": "file",
246
+ }
247
+
248
+ lines = [f"Found {len(results)} matches:"]
249
+ for r in results[:limit]:
250
+ abbr = type_abbr.get(r["kind"], r["kind"][:3])
251
+ end = r["line_end"] or r["line_start"] or "?"
252
+ lines.append(f"{r['file_path']}:L{r['line_start']}-{end} [{abbr}] {r['name']}")
253
+
254
+ if len(results) > limit:
255
+ lines.append(f"... +{len(results) - limit} more")
256
+
257
+ return "\n".join(lines)