codegraph-engine 3.0.0__2-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 (130) hide show
  1. codegraph/__init__.py +37 -0
  2. codegraph/agent.py +26 -0
  3. codegraph/agent_brain.py +2212 -0
  4. codegraph/agent_capabilities.py +4058 -0
  5. codegraph/agent_rules.py +531 -0
  6. codegraph/api_drift.py +455 -0
  7. codegraph/architecture.py +371 -0
  8. codegraph/async_queue/__init__.py +50 -0
  9. codegraph/async_queue/detectors/__init__.py +48 -0
  10. codegraph/async_queue/detectors/celery.py +160 -0
  11. codegraph/async_queue/detectors/kafka.py +168 -0
  12. codegraph/async_queue/detectors/rabbitmq.py +116 -0
  13. codegraph/async_queue/detectors/redis.py +130 -0
  14. codegraph/async_queue/detectors/sqs.py +135 -0
  15. codegraph/async_queue/models.py +161 -0
  16. codegraph/async_queue/otel_models.py +74 -0
  17. codegraph/async_queue/otel_parser.py +337 -0
  18. codegraph/async_queue/reconciler.py +427 -0
  19. codegraph/async_queue/scanner.py +291 -0
  20. codegraph/async_queue/traversal.py +447 -0
  21. codegraph/audit.py +106 -0
  22. codegraph/binding_resolver.py +616 -0
  23. codegraph/branch_comparison.py +197 -0
  24. codegraph/cache.py +95 -0
  25. codegraph/change_impact.py +547 -0
  26. codegraph/cli.py +2436 -0
  27. codegraph/cli_output.py +205 -0
  28. codegraph/config.py +45 -0
  29. codegraph/constraints.py +238 -0
  30. codegraph/context.py +2182 -0
  31. codegraph/context_freshness.py +351 -0
  32. codegraph/database/__init__.py +72 -0
  33. codegraph/database/extractor.py +3651 -0
  34. codegraph/database/interrogation.py +1494 -0
  35. codegraph/database/models.py +310 -0
  36. codegraph/database/schema_drift.py +862 -0
  37. codegraph/epistemic.py +200 -0
  38. codegraph/errors.py +282 -0
  39. codegraph/evidence/__init__.py +15 -0
  40. codegraph/evidence/citations.py +418 -0
  41. codegraph/evidence_contract.py +486 -0
  42. codegraph/frameworks.py +960 -0
  43. codegraph/frameworks_nestjs.py +502 -0
  44. codegraph/frameworks_nextjs.py +337 -0
  45. codegraph/frameworks_react.py +457 -0
  46. codegraph/freshness.py +335 -0
  47. codegraph/frontend_html.py +109 -0
  48. codegraph/git.py +362 -0
  49. codegraph/git_state.py +545 -0
  50. codegraph/graph/__init__.py +46 -0
  51. codegraph/graph/models.py +56 -0
  52. codegraph/graph/traversal.py +1993 -0
  53. codegraph/indexing/__init__.py +24 -0
  54. codegraph/indexing/classifier.py +564 -0
  55. codegraph/indexing/indexer.py +2578 -0
  56. codegraph/indexing/models.py +427 -0
  57. codegraph/indexing/parser.py +3404 -0
  58. codegraph/indexing/pool.py +136 -0
  59. codegraph/indexing/scanner.py +211 -0
  60. codegraph/indexing/telemetry.py +326 -0
  61. codegraph/indexing/templates.py +418 -0
  62. codegraph/indexing/test_framework.py +116 -0
  63. codegraph/installer.py +1702 -0
  64. codegraph/interrogation.py +2355 -0
  65. codegraph/llm/__init__.py +3 -0
  66. codegraph/llm/base.py +15 -0
  67. codegraph/llm/context.py +20 -0
  68. codegraph/mcp/__init__.py +3 -0
  69. codegraph/mcp/server.py +1476 -0
  70. codegraph/mcp_diagnostics.py +882 -0
  71. codegraph/memory/__init__.py +3 -0
  72. codegraph/memory/store.py +46 -0
  73. codegraph/models.py +310 -0
  74. codegraph/monorepo.py +925 -0
  75. codegraph/observability.py +262 -0
  76. codegraph/optimizer.py +1014 -0
  77. codegraph/planner.py +417 -0
  78. codegraph/process_lifecycle.py +1153 -0
  79. codegraph/py.typed +1 -0
  80. codegraph/query_expansion.py +199 -0
  81. codegraph/ranking.py +408 -0
  82. codegraph/refactor/__init__.py +36 -0
  83. codegraph/refactor/models.py +114 -0
  84. codegraph/refactor/renamer.py +844 -0
  85. codegraph/refactor/transactions.py +414 -0
  86. codegraph/resolver.py +3968 -0
  87. codegraph/resources/__init__.py +46 -0
  88. codegraph/resources/cache.py +117 -0
  89. codegraph/resources/coalescer.py +83 -0
  90. codegraph/resources/debouncer.py +98 -0
  91. codegraph/resources/governor.py +219 -0
  92. codegraph/resources/monitor.py +102 -0
  93. codegraph/resources/policy.py +123 -0
  94. codegraph/retrieval_policy.py +320 -0
  95. codegraph/roblox.py +2174 -0
  96. codegraph/route_composer.py +363 -0
  97. codegraph/route_topology.py +172 -0
  98. codegraph/runtime/__init__.py +51 -0
  99. codegraph/runtime/collector.py +155 -0
  100. codegraph/runtime/db_watcher.py +191 -0
  101. codegraph/runtime/ingestor.py +692 -0
  102. codegraph/runtime/models.py +130 -0
  103. codegraph/runtime/port_inspector.py +304 -0
  104. codegraph/runtime/reconciliation.py +492 -0
  105. codegraph/runtime/runner.py +353 -0
  106. codegraph/runtime/trace_linker.py +175 -0
  107. codegraph/search/__init__.py +25 -0
  108. codegraph/search/hybrid.py +846 -0
  109. codegraph/search/semantic.py +28 -0
  110. codegraph/security/__init__.py +51 -0
  111. codegraph/security/paths.py +189 -0
  112. codegraph/security/redaction.py +633 -0
  113. codegraph/semantic_conflicts.py +270 -0
  114. codegraph/semantic_decorators.py +372 -0
  115. codegraph/structural_diff.py +542 -0
  116. codegraph/styles.py +144 -0
  117. codegraph/symbol_history.py +277 -0
  118. codegraph/target_resolver.py +348 -0
  119. codegraph/task.py +637 -0
  120. codegraph/tool_selection_eval.py +1158 -0
  121. codegraph/tsconfig.py +236 -0
  122. codegraph/ui/__init__.py +6 -0
  123. codegraph/ui/server.py +755 -0
  124. codegraph/watcher.py +323 -0
  125. codegraph_engine-3.0.0.dist-info/METADATA +868 -0
  126. codegraph_engine-3.0.0.dist-info/RECORD +130 -0
  127. codegraph_engine-3.0.0.dist-info/WHEEL +6 -0
  128. codegraph_engine-3.0.0.dist-info/entry_points.txt +2 -0
  129. codegraph_engine-3.0.0.dist-info/licenses/LICENSE +21 -0
  130. codegraph_engine-3.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,846 @@
1
+ """Lexical search using SQLite FTS5.
2
+
3
+ Retrieval is lexical + path/symbol ranking. No semantic embeddings.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ import re
9
+ import sqlite3
10
+ from dataclasses import asdict, dataclass
11
+ from pathlib import Path
12
+
13
+ from codegraph.errors import SecurityError
14
+ from codegraph.indexing.classifier import (
15
+ FileCategory,
16
+ classify_file,
17
+ is_ignored_by_codegraphignore,
18
+ load_codegraphignore,
19
+ )
20
+ from codegraph.indexing.scanner import (
21
+ IGNORED_DIRS,
22
+ _gitignore_patterns,
23
+ _ignored_by_gitignore,
24
+ is_binary,
25
+ )
26
+ from codegraph.security import (
27
+ DEFAULT_MAX_READ_BYTES,
28
+ contains_private_key,
29
+ is_sensitive_path,
30
+ redact_secrets,
31
+ safe_path,
32
+ )
33
+
34
+ _READABLE_TEXT_EXTENSIONS: frozenset[str] = frozenset({
35
+ ".py",
36
+ ".pyi",
37
+ ".html",
38
+ ".htm",
39
+ ".jinja",
40
+ ".jinja2",
41
+ ".j2",
42
+ ".ejs",
43
+ ".hbs",
44
+ ".svelte",
45
+ ".vue",
46
+ ".js",
47
+ ".jsx",
48
+ ".ts",
49
+ ".tsx",
50
+ ".mjs",
51
+ ".cjs",
52
+ ".css",
53
+ ".scss",
54
+ ".less",
55
+ ".json",
56
+ ".yaml",
57
+ ".yml",
58
+ ".toml",
59
+ ".ini",
60
+ ".cfg",
61
+ ".conf",
62
+ ".xml",
63
+ ".md",
64
+ ".rst",
65
+ ".txt",
66
+ ".sql",
67
+ ".prisma",
68
+ ".sh",
69
+ ".bash",
70
+ ".zsh",
71
+ ".graphql",
72
+ ".proto",
73
+ })
74
+
75
+ _READABLE_TEXT_FILENAMES: frozenset[str] = frozenset({
76
+ "dockerfile",
77
+ "makefile",
78
+ "procfile",
79
+ "gemfile",
80
+ "cmakelists.txt",
81
+ "requirements.txt",
82
+ })
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class SearchResult:
87
+ file: str
88
+ symbol: str | None
89
+ start_line: int
90
+ end_line: int
91
+ score: float
92
+ reason: str
93
+ snippet: str
94
+ path: str = ""
95
+ line: int = 0
96
+ matched_text: str = ""
97
+ category: str = "SOURCE"
98
+ file_category: str = "SOURCE"
99
+
100
+ def __post_init__(self) -> None:
101
+ if not self.path:
102
+ object.__setattr__(self, "path", self.file)
103
+ if not self.line:
104
+ object.__setattr__(self, "line", self.start_line)
105
+ if not self.matched_text and self.snippet:
106
+ lines = [ln.strip() for ln in self.snippet.splitlines() if ln.strip()]
107
+ object.__setattr__(self, "matched_text", (lines[0][:300] if lines else ""))
108
+ if not self.file_category:
109
+ object.__setattr__(self, "file_category", self.category)
110
+
111
+ def as_dict(self) -> dict[str, object]:
112
+ return asdict(self)
113
+
114
+
115
+ # FTS5 reserved words that cannot appear as bare terms
116
+ _FTS5_RESERVED = frozenset({
117
+ "AND", "OR", "NOT",
118
+ })
119
+
120
+ # FTS5 special characters to strip from terms
121
+ _FTS5_SPECIAL = re.compile(r"[^\w]") # keep only word chars inside terms
122
+
123
+
124
+ def _safe_fts5_terms(query: str) -> list[str]:
125
+ """Extract word-like tokens safe for FTS5 MATCH expressions.
126
+
127
+ - Splits on non-alphanumeric characters (dots, dashes, etc.)
128
+ - Drops single-character tokens
129
+ - Drops FTS5 reserved words
130
+ - Returns deduplicated list preserving order
131
+ """
132
+ # Split on anything that is not alphanumeric or underscore
133
+ raw_tokens = re.split(r"[^A-Za-z0-9_]+", query)
134
+ seen: set[str] = set()
135
+ result: list[str] = []
136
+ for tok in raw_tokens:
137
+ tok = tok.strip()
138
+ if len(tok) < 2:
139
+ continue
140
+ if tok.upper() in _FTS5_RESERVED:
141
+ continue
142
+ if tok not in seen:
143
+ seen.add(tok)
144
+ result.append(tok)
145
+ return result
146
+
147
+
148
+ def _resolve_repository_from_con(
149
+ con: sqlite3.Connection,
150
+ repository: Path | None = None,
151
+ ) -> Path | None:
152
+ """Resolve the repository root path from explicit argument, metadata table, or database file path."""
153
+ if repository is not None:
154
+ try:
155
+ resolved = repository.resolve(strict=True)
156
+ if resolved.is_dir():
157
+ return resolved
158
+ except OSError:
159
+ pass
160
+ try:
161
+ row = con.execute("SELECT value FROM metadata WHERE key='repository'").fetchone()
162
+ if row and row[0]:
163
+ cand = Path(str(row[0])).resolve(strict=True)
164
+ if cand.is_dir():
165
+ return cand
166
+ except Exception:
167
+ pass
168
+ try:
169
+ for r in con.execute("PRAGMA database_list").fetchall():
170
+ if r[1] == "main" and r[2]:
171
+ db_path = Path(str(r[2])).resolve(strict=True)
172
+ if db_path.parent.is_dir():
173
+ return db_path.parent
174
+ except Exception:
175
+ pass
176
+ return None
177
+
178
+
179
+ def _find_best_line_in_chunk(
180
+ content: str,
181
+ chunk_start_line: int,
182
+ clean_query: str,
183
+ terms: list[str],
184
+ ) -> tuple[int, str, bool]:
185
+ """Locate the specific 1-indexed line and matched text within a chunk."""
186
+ lines = content.splitlines()
187
+ if not lines:
188
+ return chunk_start_line, "", False
189
+
190
+ clean_q_lower = clean_query.lower()
191
+ if clean_query:
192
+ for idx, line in enumerate(lines):
193
+ if clean_query in line:
194
+ return chunk_start_line + idx, line.strip()[:300], True
195
+ for idx, line in enumerate(lines):
196
+ if clean_q_lower in line.lower():
197
+ return chunk_start_line + idx, line.strip()[:300], True
198
+
199
+ if terms:
200
+ best_idx = 0
201
+ best_hits = 0
202
+ terms_lower = [t.lower() for t in terms]
203
+ for idx, line in enumerate(lines):
204
+ ll = line.lower()
205
+ hits = sum(1 for t in terms_lower if t in ll)
206
+ if hits > best_hits:
207
+ best_hits = hits
208
+ best_idx = idx
209
+ if best_hits > 0:
210
+ return chunk_start_line + best_idx, lines[best_idx].strip()[:300], False
211
+
212
+ return chunk_start_line, lines[0].strip()[:300], False
213
+
214
+
215
+ def _search_repository_text_files(
216
+ con: sqlite3.Connection,
217
+ repo_root: Path,
218
+ query: str,
219
+ terms: list[str],
220
+ excluded_categories: set[str],
221
+ include_vendor: bool = False,
222
+ include_build_artifacts: bool = False,
223
+ max_file_bytes: int = DEFAULT_MAX_READ_BYTES,
224
+ ) -> list[SearchResult]:
225
+ """Scan readable repository text files (.py, .html, .jinja, .js, .ts, .css, .json, .yaml, .md, etc.)
226
+ for literal and lexical matches without polluting the semantic graph.
227
+ """
228
+ clean_q = query.strip()
229
+ if not clean_q:
230
+ return []
231
+ clean_q_lower = clean_q.lower()
232
+ terms_lower = [t.lower() for t in terms]
233
+
234
+ gitignore = _gitignore_patterns(repo_root)
235
+ codegraphignore = load_codegraphignore(repo_root)
236
+
237
+ # Preload symbol spans by path so hits inside indexed files can optionally attach enclosing symbol name
238
+ symbols_by_path: dict[str, list[tuple[int, int, str]]] = {}
239
+ try:
240
+ for srow in con.execute(
241
+ "SELECT path, start_line, end_line, qualified_name FROM symbols ORDER BY path, start_line"
242
+ ).fetchall():
243
+ symbols_by_path.setdefault(str(srow["path"]), []).append(
244
+ (int(srow["start_line"]), int(srow["end_line"]), str(srow["qualified_name"]))
245
+ )
246
+ except Exception:
247
+ pass
248
+
249
+ skip_dirs = set(IGNORED_DIRS)
250
+ if include_vendor:
251
+ skip_dirs -= {"node_modules", "vendor", ".venv", "venv"}
252
+ if include_build_artifacts:
253
+ skip_dirs -= {"dist", "build"}
254
+
255
+ hits: list[SearchResult] = []
256
+
257
+ for directory, dirs, files in os.walk(repo_root, followlinks=False):
258
+ dirs[:] = sorted(
259
+ d for d in dirs
260
+ if d not in skip_dirs and not d.startswith(".codegraph")
261
+ )
262
+ for filename in sorted(files):
263
+ if filename.startswith(".codegraph"):
264
+ continue
265
+ absolute = Path(directory) / filename
266
+ try:
267
+ rel_unresolved = absolute.relative_to(repo_root)
268
+ except ValueError:
269
+ continue
270
+
271
+ try:
272
+ resolved = safe_path(repo_root, rel_unresolved)
273
+ except SecurityError:
274
+ continue
275
+
276
+ try:
277
+ relative = resolved.relative_to(repo_root)
278
+ except ValueError:
279
+ continue
280
+
281
+ rel_posix = relative.as_posix()
282
+ if is_sensitive_path(relative):
283
+ continue
284
+
285
+ suffix_lower = relative.suffix.lower()
286
+ name_lower = relative.name.lower()
287
+ if suffix_lower not in _READABLE_TEXT_EXTENSIONS and name_lower not in _READABLE_TEXT_FILENAMES:
288
+ continue
289
+
290
+ try:
291
+ if not resolved.is_file():
292
+ continue
293
+ st_size = resolved.stat().st_size
294
+ if st_size == 0 or st_size > max_file_bytes:
295
+ continue
296
+ if _ignored_by_gitignore(relative, gitignore) or is_ignored_by_codegraphignore(
297
+ relative, codegraphignore
298
+ ):
299
+ continue
300
+ if is_binary(resolved):
301
+ continue
302
+ raw_content = resolved.read_text(encoding="utf-8", errors="replace")
303
+ except OSError:
304
+ continue
305
+
306
+ if contains_private_key(raw_content):
307
+ continue
308
+
309
+ content = redact_secrets(raw_content)
310
+ cat_enum = classify_file(relative, content)
311
+ cat_str = cat_enum.value
312
+ if cat_str in excluded_categories:
313
+ continue
314
+ if cat_enum == FileCategory.BINARY:
315
+ continue
316
+
317
+ content_lower = content.lower()
318
+ has_substring = clean_q_lower in content_lower
319
+ has_all_terms = bool(terms_lower and len(terms_lower) >= 2 and all(t in content_lower for t in terms_lower))
320
+ if not has_substring and not has_all_terms:
321
+ continue
322
+
323
+ lines = content.splitlines()
324
+ path_bonus = 0.05 if (
325
+ clean_q_lower in rel_posix.lower()
326
+ or any(t in rel_posix.lower() for t in terms_lower)
327
+ ) else 0.0
328
+
329
+ file_spans = symbols_by_path.get(rel_posix, [])
330
+ file_match_count = 0
331
+
332
+ for idx, raw_line in enumerate(lines):
333
+ line_lower = raw_line.lower()
334
+ if clean_q in raw_line:
335
+ base_score = 0.92
336
+ reason = "exact literal match"
337
+ elif clean_q_lower in line_lower:
338
+ base_score = 0.88
339
+ reason = "literal match"
340
+ elif terms_lower and len(terms_lower) >= 2 and all(t in line_lower for t in terms_lower):
341
+ base_score = 0.78
342
+ reason = "lexical line match"
343
+ else:
344
+ continue
345
+
346
+ line_no = idx + 1
347
+ enclosing_sym: str | None = None
348
+ for s_start, s_end, s_qname in file_spans:
349
+ if s_start <= line_no <= s_end:
350
+ enclosing_sym = s_qname
351
+
352
+ sym_bonus = 0.03 if (
353
+ enclosing_sym
354
+ and (
355
+ clean_q_lower in enclosing_sym.lower()
356
+ or any(t in enclosing_sym.lower() for t in terms_lower)
357
+ )
358
+ ) else 0.0
359
+ if sym_bonus > 0:
360
+ reason += ", symbol match"
361
+ if path_bonus > 0:
362
+ reason += ", path match"
363
+
364
+ score = round(min(1.0, base_score + path_bonus + sym_bonus), 2)
365
+ win_start = max(1, line_no - 2)
366
+ win_end = min(len(lines), line_no + 2)
367
+ snippet = redact_secrets("\n".join(lines[win_start - 1 : win_end])[:900])
368
+ matched_text = redact_secrets(raw_line.strip()[:300])
369
+
370
+ hits.append(
371
+ SearchResult(
372
+ file=rel_posix,
373
+ symbol=enclosing_sym,
374
+ start_line=win_start,
375
+ end_line=win_end,
376
+ score=score,
377
+ reason=reason,
378
+ snippet=snippet,
379
+ path=rel_posix,
380
+ line=line_no,
381
+ matched_text=matched_text,
382
+ category=cat_str,
383
+ file_category=cat_str,
384
+ )
385
+ )
386
+ file_match_count += 1
387
+ if file_match_count >= 5:
388
+ break
389
+
390
+ return hits
391
+
392
+
393
+ def search(
394
+ con: sqlite3.Connection,
395
+ query: str,
396
+ top_k: int = 10,
397
+ include_generated: bool = False,
398
+ include_vendor: bool = False,
399
+ include_bundles: bool = False,
400
+ include_build_artifacts: bool = False,
401
+ repository: Path | None = None,
402
+ include_text_files: bool = True,
403
+ ) -> list[SearchResult]:
404
+ clean_query = query.strip()
405
+ if not clean_query:
406
+ return []
407
+
408
+ terms = _safe_fts5_terms(clean_query)
409
+
410
+ # Determine excluded categories
411
+ excluded: list[str] = ["BINARY"]
412
+ if not include_generated:
413
+ excluded.append("GENERATED")
414
+ if not include_vendor:
415
+ excluded.append("VENDOR")
416
+ if not include_bundles:
417
+ excluded.extend(["BUNDLE", "MINIFIED"])
418
+ if not include_build_artifacts:
419
+ excluded.append("BUILD_ARTIFACT")
420
+
421
+ rows: list[sqlite3.Row] = []
422
+ if terms:
423
+ clauses = " OR ".join(terms)
424
+ try:
425
+ if not excluded:
426
+ rows = con.execute(
427
+ "SELECT c.path, c.symbol, c.start_line, c.end_line, c.content, "
428
+ "f.category AS category, bm25(chunks_fts) AS rank "
429
+ "FROM chunks_fts "
430
+ "JOIN chunks c ON c.id = chunks_fts.rowid "
431
+ "LEFT JOIN files f ON f.path = c.path "
432
+ "WHERE chunks_fts MATCH ? ORDER BY rank LIMIT ?",
433
+ (clauses, top_k * 3),
434
+ ).fetchall()
435
+ else:
436
+ placeholders = ",".join("?" for _ in excluded)
437
+ rows = con.execute(
438
+ "SELECT c.path, c.symbol, c.start_line, c.end_line, c.content, "
439
+ "f.category AS category, bm25(chunks_fts) AS rank "
440
+ "FROM chunks_fts "
441
+ "JOIN chunks c ON c.id = chunks_fts.rowid "
442
+ "LEFT JOIN files f ON f.path = c.path "
443
+ f"WHERE chunks_fts MATCH ? AND (f.category IS NULL OR f.category NOT IN ({placeholders})) "
444
+ "ORDER BY rank LIMIT ?",
445
+ (clauses, *excluded, top_k * 3),
446
+ ).fetchall()
447
+ except sqlite3.OperationalError:
448
+ try:
449
+ rows = con.execute(
450
+ "SELECT c.path, c.symbol, c.start_line, c.end_line, c.content, "
451
+ "bm25(chunks_fts) AS rank "
452
+ "FROM chunks_fts "
453
+ "JOIN chunks c ON c.id = chunks_fts.rowid "
454
+ "WHERE chunks_fts MATCH ? ORDER BY rank LIMIT ?",
455
+ (clauses, top_k * 3),
456
+ ).fetchall()
457
+ except sqlite3.OperationalError:
458
+ rows = []
459
+
460
+ results: list[SearchResult] = []
461
+ for row in rows:
462
+ rel_path = str(row["path"])
463
+ if is_sensitive_path(rel_path):
464
+ continue
465
+ path_bonus = 1.0 if any(t.lower() in rel_path.lower() for t in terms) else 0.0
466
+ symbol_bonus = (
467
+ 1.0
468
+ if row["symbol"] and any(t.lower() in str(row["symbol"]).lower() for t in terms)
469
+ else 0.0
470
+ )
471
+ # Exact symbol match (highest bonus)
472
+ exact_bonus = (
473
+ 0.5
474
+ if row["symbol"] and any(
475
+ t.lower() == str(row["symbol"]).lower()
476
+ or t.lower() == str(row["symbol"]).split(".")[-1].lower()
477
+ for t in terms
478
+ )
479
+ else 0.0
480
+ )
481
+ content_str = redact_secrets(str(row["content"]))
482
+ if (
483
+ clean_query.lower() not in content_str.lower()
484
+ and not any(t.lower() in content_str.lower() for t in terms)
485
+ and not path_bonus
486
+ and not symbol_bonus
487
+ ):
488
+ continue
489
+ start_ln = int(row["start_line"])
490
+ end_ln = int(row["end_line"])
491
+ hit_line, matched_text, has_literal = _find_best_line_in_chunk(
492
+ content_str, start_ln, clean_query, terms
493
+ )
494
+ matched_text = redact_secrets(matched_text)
495
+ literal_bonus = 0.15 if has_literal else 0.0
496
+ score = round(
497
+ min(1.0, 0.3 + path_bonus * 0.15 + symbol_bonus * 0.2 + exact_bonus * 0.35 + literal_bonus),
498
+ 2,
499
+ )
500
+ reason = "exact literal match" if (clean_query in content_str) else (
501
+ "literal match" if has_literal else "lexical match"
502
+ )
503
+ if symbol_bonus:
504
+ reason += ", symbol match"
505
+ if path_bonus:
506
+ reason += ", path match"
507
+ snippet = redact_secrets(content_str[:900])
508
+ row_keys = row.keys() if hasattr(row, "keys") else []
509
+ cat_val = str(row["category"]) if ("category" in row_keys and row["category"]) else classify_file(rel_path).value
510
+ results.append(
511
+ SearchResult(
512
+ file=rel_path,
513
+ symbol=row["symbol"],
514
+ start_line=start_ln,
515
+ end_line=end_ln,
516
+ score=score,
517
+ reason=reason,
518
+ snippet=snippet,
519
+ path=rel_path,
520
+ line=hit_line,
521
+ matched_text=matched_text,
522
+ category=cat_val,
523
+ file_category=cat_val,
524
+ )
525
+ )
526
+
527
+ if include_text_files:
528
+ repo_root = _resolve_repository_from_con(con, repository=repository)
529
+ if repo_root is not None:
530
+ text_hits = _search_repository_text_files(
531
+ con=con,
532
+ repo_root=repo_root,
533
+ query=clean_query,
534
+ terms=terms,
535
+ excluded_categories=set(excluded),
536
+ include_vendor=include_vendor,
537
+ include_build_artifacts=include_build_artifacts,
538
+ )
539
+ for th in text_hits:
540
+ covered = False
541
+ for idx, existing in enumerate(results):
542
+ if (
543
+ existing.file == th.file
544
+ and existing.start_line <= th.line <= existing.end_line
545
+ ):
546
+ covered = True
547
+ if th.score > existing.score:
548
+ results[idx] = SearchResult(
549
+ file=existing.file,
550
+ symbol=existing.symbol or th.symbol,
551
+ start_line=existing.start_line,
552
+ end_line=existing.end_line,
553
+ score=th.score,
554
+ reason=th.reason,
555
+ snippet=existing.snippet,
556
+ path=existing.path,
557
+ line=th.line,
558
+ matched_text=th.matched_text,
559
+ category=existing.category,
560
+ file_category=existing.file_category,
561
+ )
562
+ break
563
+ if not covered:
564
+ results.append(th)
565
+
566
+ # Deterministic ordering: (-score, path, line, start_line)
567
+ results.sort(key=lambda r: (-r.score, r.path, r.line, r.start_line))
568
+ return results[:top_k]
569
+
570
+
571
+ def search_lexical(con: sqlite3.Connection, query: str, top_k: int = 10) -> list[SearchResult]:
572
+ """Lexical FTS5 search (wrapper around search)."""
573
+ return search(con, query, top_k=top_k)
574
+
575
+
576
+ def search_code(
577
+ con: sqlite3.Connection,
578
+ query: str,
579
+ repo_path: Path | None = None,
580
+ repository: Path | None = None,
581
+ path_filter: str | None = None,
582
+ file_types: list[str] | tuple[str, ...] | None = None,
583
+ include_tests: bool = True,
584
+ include_configs: bool = True,
585
+ max_results: int = 20,
586
+ top_k: int | None = None,
587
+ include_generated: bool = False,
588
+ include_vendor: bool = False,
589
+ include_bundles: bool = False,
590
+ include_build_artifacts: bool = False,
591
+ ) -> list[dict[str, object]]:
592
+ """Deterministic repository text search across code, templates, UI files, configs, and docs.
593
+
594
+ Never creates or emits semantic graph relationships.
595
+ """
596
+ clean_query = query.strip()
597
+ if not clean_query:
598
+ return []
599
+
600
+ effective_limit = top_k if top_k is not None else max_results
601
+ if effective_limit <= 0:
602
+ return []
603
+
604
+ effective_repo = repo_path if repo_path is not None else repository
605
+ raw_hits = search(
606
+ con,
607
+ clean_query,
608
+ top_k=max(effective_limit * 5, 100),
609
+ include_generated=include_generated,
610
+ include_vendor=include_vendor,
611
+ include_bundles=include_bundles,
612
+ include_build_artifacts=include_build_artifacts,
613
+ repository=effective_repo,
614
+ include_text_files=True,
615
+ )
616
+
617
+ norm_path_filter = path_filter.strip().lower() if path_filter and path_filter.strip() else None
618
+ norm_types: set[str] | None = None
619
+ if file_types:
620
+ cleaned_types = {ft.strip().lower().lstrip(".") for ft in file_types if ft and ft.strip()}
621
+ if cleaned_types:
622
+ norm_types = cleaned_types
623
+
624
+ config_exts = {".json", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf"}
625
+ filtered: list[dict[str, object]] = []
626
+ seen_file_lines: set[tuple[str, int]] = set()
627
+ files_with_exact_matches: set[str] = set()
628
+
629
+ query_terms = _safe_fts5_terms(clean_query)
630
+ query_terms_lower = [t.lower() for t in query_terms]
631
+
632
+ for item in raw_hits:
633
+ rel_path = item.path or item.file
634
+ if is_sensitive_path(rel_path):
635
+ continue
636
+ p_obj = Path(rel_path)
637
+ ext_no_dot = p_obj.suffix.lower().lstrip(".")
638
+ cat_upper = (item.category or item.file_category or "SOURCE").upper()
639
+
640
+ if norm_path_filter and norm_path_filter not in rel_path.lower():
641
+ continue
642
+ if norm_types is not None and ext_no_dot not in norm_types:
643
+ continue
644
+ if not include_tests:
645
+ is_test_file = (
646
+ cat_upper == "TEST"
647
+ or "tests" in p_obj.parts
648
+ or p_obj.name.startswith("test_")
649
+ or p_obj.name.endswith("_test.py")
650
+ )
651
+ if is_test_file:
652
+ continue
653
+ if not include_configs:
654
+ is_config_file = cat_upper == "CONFIG" or p_obj.suffix.lower() in config_exts
655
+ if is_config_file:
656
+ continue
657
+
658
+ d = item.as_dict()
659
+ # Add match_type for schema clarity
660
+ reason_str = str(d.get("reason", ""))
661
+ if "exact literal" in reason_str:
662
+ d["match_type"] = "exact"
663
+ elif "literal" in reason_str:
664
+ d["match_type"] = "case_insensitive"
665
+ else:
666
+ d["match_type"] = "token"
667
+
668
+ hit_line = item.line
669
+ file_line_key = (rel_path, hit_line)
670
+ if file_line_key in seen_file_lines:
671
+ continue
672
+
673
+ # If multi-word query and this hit is only a token match:
674
+ # verify that all query terms appear in the snippet or matched text
675
+ if len(query_terms_lower) >= 2 and d["match_type"] == "token":
676
+ haystack = (str(d.get("snippet", "")) + " " + str(d.get("matched_text", ""))).lower()
677
+ if not all(t in haystack for t in query_terms_lower):
678
+ continue
679
+
680
+ # If a file already had an exact/case-insensitive match, discard subsequent loose token matches
681
+ if rel_path in files_with_exact_matches and d["match_type"] == "token":
682
+ continue
683
+
684
+ if d["match_type"] in ("exact", "case_insensitive"):
685
+ files_with_exact_matches.add(rel_path)
686
+
687
+ seen_file_lines.add(file_line_key)
688
+ filtered.append(d)
689
+ if len(filtered) >= effective_limit:
690
+ break
691
+
692
+ return filtered
693
+
694
+
695
+
696
+ def _fetch_snippet(
697
+ con: sqlite3.Connection, path: str, start: int, end: int, symbol: str | None = None
698
+ ) -> str:
699
+ try:
700
+ row = con.execute(
701
+ "SELECT content FROM chunks WHERE path=? AND start_line<=? AND end_line>=? LIMIT 1",
702
+ (path, start, end),
703
+ ).fetchone()
704
+ if row and row["content"]:
705
+ return redact_secrets(str(row["content"])[:900])
706
+ if symbol:
707
+ row = con.execute(
708
+ "SELECT content FROM chunks WHERE symbol=? LIMIT 1", (symbol,)
709
+ ).fetchone()
710
+ if row and row["content"]:
711
+ return redact_secrets(str(row["content"])[:900])
712
+ except sqlite3.OperationalError:
713
+ pass
714
+ return ""
715
+
716
+
717
+ def search_exact_canonical_id(
718
+ con: sqlite3.Connection, canonical_id: str
719
+ ) -> list[SearchResult]:
720
+ """Look up symbol by exact canonical ID."""
721
+ clean = canonical_id.strip()
722
+ if not clean:
723
+ return []
724
+ try:
725
+ rows = con.execute(
726
+ "SELECT canonical_id, qualified_name, path, start_line, end_line "
727
+ "FROM symbols WHERE canonical_id=? ORDER BY path, start_line",
728
+ (clean,),
729
+ ).fetchall()
730
+ except sqlite3.OperationalError:
731
+ return []
732
+
733
+ results: list[SearchResult] = []
734
+ for r in rows:
735
+ snippet = _fetch_snippet(con, r["path"], r["start_line"], r["end_line"], r["qualified_name"])
736
+ results.append(
737
+ SearchResult(
738
+ file=r["path"],
739
+ symbol=r["qualified_name"] or r["canonical_id"],
740
+ start_line=r["start_line"],
741
+ end_line=r["end_line"],
742
+ score=1.0,
743
+ reason="exact canonical id match",
744
+ snippet=snippet,
745
+ )
746
+ )
747
+ return results
748
+
749
+
750
+ def search_exact_qualified(
751
+ con: sqlite3.Connection, qualified_name: str
752
+ ) -> list[SearchResult]:
753
+ """Look up symbol by exact qualified name."""
754
+ clean = qualified_name.strip()
755
+ if not clean:
756
+ return []
757
+ try:
758
+ rows = con.execute(
759
+ "SELECT canonical_id, qualified_name, path, start_line, end_line "
760
+ "FROM symbols WHERE qualified_name=? OR canonical_id=? ORDER BY path, start_line",
761
+ (clean, clean),
762
+ ).fetchall()
763
+ except sqlite3.OperationalError:
764
+ return []
765
+
766
+ results: list[SearchResult] = []
767
+ for r in rows:
768
+ snippet = _fetch_snippet(con, r["path"], r["start_line"], r["end_line"], r["qualified_name"])
769
+ results.append(
770
+ SearchResult(
771
+ file=r["path"],
772
+ symbol=r["qualified_name"],
773
+ start_line=r["start_line"],
774
+ end_line=r["end_line"],
775
+ score=0.95,
776
+ reason="exact qualified match",
777
+ snippet=snippet,
778
+ )
779
+ )
780
+ return results
781
+
782
+
783
+ def search_exact_symbol(
784
+ con: sqlite3.Connection, name: str
785
+ ) -> list[SearchResult]:
786
+ """Look up symbol by short identifier name."""
787
+ clean = name.strip()
788
+ if not clean:
789
+ return []
790
+ try:
791
+ rows = con.execute(
792
+ "SELECT canonical_id, qualified_name, path, start_line, end_line "
793
+ "FROM symbols WHERE name=? ORDER BY path, start_line",
794
+ (clean,),
795
+ ).fetchall()
796
+ except sqlite3.OperationalError:
797
+ return []
798
+
799
+ results: list[SearchResult] = []
800
+ for r in rows:
801
+ snippet = _fetch_snippet(con, r["path"], r["start_line"], r["end_line"], r["qualified_name"])
802
+ results.append(
803
+ SearchResult(
804
+ file=r["path"],
805
+ symbol=r["qualified_name"],
806
+ start_line=r["start_line"],
807
+ end_line=r["end_line"],
808
+ score=0.90,
809
+ reason="exact symbol match",
810
+ snippet=snippet,
811
+ )
812
+ )
813
+ return results
814
+
815
+
816
+ def search_route(
817
+ con: sqlite3.Connection, route_or_endpoint: str
818
+ ) -> list[SearchResult]:
819
+ """Look up framework route by path or endpoint ID."""
820
+ clean = route_or_endpoint.strip()
821
+ if not clean:
822
+ return []
823
+ try:
824
+ rows = con.execute(
825
+ "SELECT route_path, http_method, handler_name, file_path, line, endpoint_id, evidence "
826
+ "FROM framework_routes WHERE route_path=? OR endpoint_id=? ORDER BY file_path, line",
827
+ (clean, clean),
828
+ ).fetchall()
829
+ except sqlite3.OperationalError:
830
+ return []
831
+
832
+ results: list[SearchResult] = []
833
+ for r in rows:
834
+ snippet = _fetch_snippet(con, r["file_path"], r["line"], r["line"], r["handler_name"])
835
+ results.append(
836
+ SearchResult(
837
+ file=r["file_path"],
838
+ symbol=r["handler_name"] or r["route_path"],
839
+ start_line=r["line"],
840
+ end_line=r["line"],
841
+ score=0.95,
842
+ reason=f"route match: {r['http_method']} {r['route_path']}",
843
+ snippet=snippet,
844
+ )
845
+ )
846
+ return results