codegraph-engine 2.3.0__py3-none-any.whl → 3.0.1__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 (118) hide show
  1. codegraph/__init__.py +1 -1
  2. codegraph/__main__.py +7 -0
  3. codegraph/agent_brain.py +253 -2
  4. codegraph/agent_capabilities.py +1348 -67
  5. codegraph/agent_rules.py +2 -1
  6. codegraph/api_drift.py +455 -0
  7. codegraph/architecture_rules.py +123 -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/change_impact.py +320 -110
  22. codegraph/changelog.py +170 -0
  23. codegraph/cli.py +1605 -62
  24. codegraph/cli_output.py +17 -2
  25. codegraph/context.py +135 -24
  26. codegraph/context_freshness.py +2 -0
  27. codegraph/database/__init__.py +14 -0
  28. codegraph/database/extractor.py +712 -17
  29. codegraph/database/interrogation.py +388 -2
  30. codegraph/database/schema_drift.py +862 -0
  31. codegraph/dataflow_propagation.py +118 -0
  32. codegraph/dead_code.py +387 -0
  33. codegraph/epistemic.py +15 -0
  34. codegraph/evidence_contract.py +34 -3
  35. codegraph/federation.py +245 -0
  36. codegraph/frameworks.py +234 -12
  37. codegraph/frameworks_nestjs.py +502 -0
  38. codegraph/frameworks_nextjs.py +337 -0
  39. codegraph/frameworks_react.py +457 -0
  40. codegraph/freshness.py +2 -0
  41. codegraph/frontend_html.py +109 -0
  42. codegraph/git.py +2 -0
  43. codegraph/git_state.py +2 -0
  44. codegraph/graph/traversal.py +150 -7
  45. codegraph/graph_csr.py +218 -0
  46. codegraph/incremental.py +150 -0
  47. codegraph/indexing/__init__.py +7 -1
  48. codegraph/indexing/classifier.py +5 -0
  49. codegraph/indexing/indexer.py +292 -38
  50. codegraph/indexing/languages/__init__.py +1 -0
  51. codegraph/indexing/languages/cpp.py +457 -0
  52. codegraph/indexing/languages/go.py +459 -0
  53. codegraph/indexing/languages/jvm.py +583 -0
  54. codegraph/indexing/languages/rust.py +507 -0
  55. codegraph/indexing/models.py +15 -1
  56. codegraph/indexing/parser.py +385 -9
  57. codegraph/indexing/pool.py +136 -0
  58. codegraph/indexing/protocols.py +220 -0
  59. codegraph/indexing/scanner.py +23 -0
  60. codegraph/indexing/templates.py +418 -0
  61. codegraph/interrogation.py +207 -39
  62. codegraph/lsp.py +318 -0
  63. codegraph/lsp_bridge/__init__.py +16 -0
  64. codegraph/lsp_bridge/bridge.py +102 -0
  65. codegraph/lsp_bridge/client.py +207 -0
  66. codegraph/lsp_bridge/models.py +71 -0
  67. codegraph/mcp/server.py +502 -29
  68. codegraph/memory/trajectory.py +282 -0
  69. codegraph/models.py +7 -0
  70. codegraph/monorepo.py +3 -0
  71. codegraph/multi_agent/__init__.py +13 -0
  72. codegraph/multi_agent/coordinator.py +229 -0
  73. codegraph/multi_agent/models.py +76 -0
  74. codegraph/optimizer.py +42 -5
  75. codegraph/polymorphism.py +95 -0
  76. codegraph/pr_review.py +296 -0
  77. codegraph/process_lifecycle.py +50 -12
  78. codegraph/reachability.py +198 -0
  79. codegraph/reachability_index.py +74 -0
  80. codegraph/refactor/__init__.py +48 -0
  81. codegraph/refactor/models.py +114 -0
  82. codegraph/refactor/patcher.py +348 -0
  83. codegraph/refactor/renamer.py +844 -0
  84. codegraph/refactor/transactions.py +414 -0
  85. codegraph/repair.py +233 -0
  86. codegraph/resolver.py +1357 -161
  87. codegraph/retrieval_policy.py +22 -1
  88. codegraph/roblox.py +2174 -0
  89. codegraph/route_composer.py +7 -0
  90. codegraph/route_topology.py +172 -0
  91. codegraph/runtime/__init__.py +25 -0
  92. codegraph/runtime/collector.py +155 -0
  93. codegraph/runtime/db_watcher.py +191 -0
  94. codegraph/runtime/ingestor.py +30 -10
  95. codegraph/runtime/otel_receiver.py +208 -0
  96. codegraph/runtime/port_inspector.py +304 -0
  97. codegraph/runtime/runner.py +155 -6
  98. codegraph/runtime/trace_linker.py +175 -0
  99. codegraph/sandbox.py +215 -0
  100. codegraph/search/hybrid.py +26 -0
  101. codegraph/security/supply_chain.py +188 -0
  102. codegraph/security/taint.py +421 -0
  103. codegraph/semantic_decorators.py +34 -3
  104. codegraph/structural_diff.py +2 -0
  105. codegraph/styles.py +144 -0
  106. codegraph/symbol_history.py +2 -0
  107. codegraph/test_gen.py +437 -0
  108. codegraph/tsconfig.py +236 -0
  109. codegraph/ui/__init__.py +6 -0
  110. codegraph/ui/server.py +876 -0
  111. codegraph/watcher.py +323 -0
  112. {codegraph_engine-2.3.0.dist-info → codegraph_engine-3.0.1.dist-info}/METADATA +365 -67
  113. codegraph_engine-3.0.1.dist-info/RECORD +164 -0
  114. {codegraph_engine-2.3.0.dist-info → codegraph_engine-3.0.1.dist-info}/WHEEL +1 -1
  115. codegraph_engine-2.3.0.dist-info/RECORD +0 -94
  116. {codegraph_engine-2.3.0.dist-info → codegraph_engine-3.0.1.dist-info}/entry_points.txt +0 -0
  117. {codegraph_engine-2.3.0.dist-info → codegraph_engine-3.0.1.dist-info}/licenses/LICENSE +0 -0
  118. {codegraph_engine-2.3.0.dist-info → codegraph_engine-3.0.1.dist-info}/top_level.txt +0 -0
@@ -35,13 +35,35 @@ const fs = require('fs');
35
35
 
36
36
  const logPath = {escaped_log_path};
37
37
 
38
+ let _cg_buffer = [];
39
+ let _cg_last_flush = Date.now();
40
+ const _CG_BATCH_SIZE = 50;
41
+ const _CG_FLUSH_INTERVAL_MS = 1500;
42
+
43
+ function _cg_flush_buffer() {{
44
+ if (_cg_buffer.length === 0) return;
45
+ const content = _cg_buffer.join('');
46
+ _cg_buffer = [];
47
+ _cg_last_flush = Date.now();
48
+ try {{
49
+ fs.appendFileSync(logPath, content);
50
+ }} catch (_) {{}}
51
+ }}
52
+
38
53
  function emitEvent(event) {{
39
54
  try {{
40
- fs.appendFileSync(logPath, JSON.stringify(event) + '\\n');
55
+ _cg_buffer.push(JSON.stringify(event) + '\\n');
56
+ if (_cg_buffer.length >= _CG_BATCH_SIZE || (Date.now() - _cg_last_flush) >= _CG_FLUSH_INTERVAL_MS) {{
57
+ _cg_flush_buffer();
58
+ }}
41
59
  }} catch (_) {{}}
42
60
  }}
43
61
 
62
+ setInterval(_cg_flush_buffer, _CG_FLUSH_INTERVAL_MS);
63
+ process.on('exit', _cg_flush_buffer);
64
+
44
65
  function hookServer(serverProto) {{
66
+
45
67
  const origEmit = serverProto.emit;
46
68
  serverProto.emit = function(event, req, res) {{
47
69
  if (event === 'request' && req && res) {{
@@ -87,23 +109,64 @@ process.on('uncaughtExceptionMonitor', (err) => {{
87
109
 
88
110
 
89
111
  def _generate_python_interceptor(output_jsonl: Path) -> str:
90
- """Generate lightweight Python WSGI/ASGI/FastAPI request interceptor."""
112
+ """Generate lightweight Python WSGI/ASGI/FastAPI request interceptor with in-memory buffering."""
91
113
  escaped_log_path = repr(str(output_jsonl))
92
114
  return f"""# CodeGraph Python Runtime Interceptor
93
- import sys
115
+ import atexit
94
116
  import json
117
+ import os
118
+ import sys
119
+ import threading
95
120
  import time
96
121
 
97
122
  LOG_PATH = {escaped_log_path}
98
123
 
124
+ _cg_buffer = []
125
+ _cg_lock = threading.Lock()
126
+ _cg_last_flush = time.time()
127
+ _cg_batch_size = 50
128
+ _cg_flush_interval = 1.5
129
+
130
+ def _cg_flush():
131
+ global _cg_last_flush
132
+ lines = None
133
+ with _cg_lock:
134
+ if _cg_buffer:
135
+ lines = "".join(_cg_buffer)
136
+ _cg_buffer.clear()
137
+ _cg_last_flush = time.time()
138
+ if lines:
139
+ try:
140
+ with open(LOG_PATH, "a", encoding="utf-8") as f:
141
+ f.write(lines)
142
+ except Exception:
143
+ pass
144
+
99
145
  def emit_event(event):
100
146
  try:
101
- with open(LOG_PATH, "a", encoding="utf-8") as f:
102
- f.write(json.dumps(event) + "\\n")
147
+ line = json.dumps(event) + "\\n"
148
+ should_flush = False
149
+ with _cg_lock:
150
+ _cg_buffer.append(line)
151
+ if len(_cg_buffer) >= _cg_batch_size or (time.time() - _cg_last_flush) >= _cg_flush_interval:
152
+ should_flush = True
153
+ if should_flush:
154
+ _cg_flush()
103
155
  except Exception:
104
156
  pass
105
157
 
106
- # Intercept uncaught exceptions
158
+ def _cg_bg_flusher():
159
+ while True:
160
+ time.sleep(_cg_flush_interval)
161
+ if _cg_buffer:
162
+ _cg_flush()
163
+
164
+ _cg_flusher_thread = threading.Thread(target=_cg_bg_flusher, daemon=True)
165
+ _cg_flusher_thread.start()
166
+ atexit.register(_cg_flush)
167
+
168
+
169
+ # 1. Intercept uncaught exceptions
107
170
  _orig_excepthook = sys.excepthook
108
171
  def _cg_excepthook(exc_type, exc_value, exc_traceback):
109
172
  emit_event({{
@@ -118,6 +181,92 @@ def _cg_excepthook(exc_type, exc_value, exc_traceback):
118
181
  return _orig_excepthook(exc_type, exc_value, exc_traceback)
119
182
 
120
183
  sys.excepthook = _cg_excepthook
184
+
185
+ # 2. Intercept SQLite query executions
186
+ try:
187
+ import sqlite3
188
+ _orig_connect = sqlite3.connect
189
+
190
+ def _cg_connect(*args, **kwargs):
191
+ con = _orig_connect(*args, **kwargs)
192
+ def _sql_trace(statement):
193
+ if statement and isinstance(statement, str) and statement.strip():
194
+ emit_event({{
195
+ "trace_id": "tr_sql_" + str(int(time.time())),
196
+ "span_id": "sp_sql_" + str(id(con)),
197
+ "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
198
+ "sql": statement,
199
+ "duration_ms": 1.0,
200
+ "service_name": "python_dev_server",
201
+ "source_format": "otel",
202
+ }})
203
+ try:
204
+ con.set_trace_callback(_sql_trace)
205
+ except Exception:
206
+ pass
207
+ return con
208
+
209
+ sqlite3.connect = _cg_connect
210
+ except Exception:
211
+ pass
212
+
213
+
214
+ # 3. Intercept SQLAlchemy engine query executions if imported
215
+ try:
216
+ import sqlalchemy.event
217
+ import sqlalchemy.engine
218
+
219
+ @sqlalchemy.event.listens_for(sqlalchemy.engine.Engine, "after_cursor_execute")
220
+ def _cg_after_cursor_execute(conn, cursor, statement, parameters, context, executemany):
221
+ if statement and str(statement).strip():
222
+ emit_event({{
223
+ "trace_id": "tr_sql_" + str(int(time.time())),
224
+ "span_id": "sp_sql_" + str(id(cursor)),
225
+ "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
226
+ "sql": str(statement),
227
+ "duration_ms": 1.0,
228
+ "service_name": "python_dev_server",
229
+ "source_format": "otel",
230
+ }})
231
+ except Exception:
232
+ pass
233
+
234
+ # 4. Intercept Werkzeug/Flask dev server requests
235
+ try:
236
+ import werkzeug.serving
237
+ _orig_run_simple = werkzeug.serving.run_simple
238
+
239
+ def _cg_run_simple(hostname, port, application, *args, **kwargs):
240
+ def _wrapped_app(environ, start_response):
241
+ path = environ.get("PATH_INFO", "/")
242
+ method = environ.get("REQUEST_METHOD", "GET")
243
+ status_box = [200]
244
+ def _wrapped_sr(status, headers, exc_info=None):
245
+ try:
246
+ status_box[0] = int(status.split()[0])
247
+ except Exception:
248
+ pass
249
+ return start_response(status, headers, exc_info)
250
+ st = time.time()
251
+ try:
252
+ return application(environ, _wrapped_sr)
253
+ finally:
254
+ emit_event({{
255
+ "trace_id": "tr_http_" + str(int(time.time())),
256
+ "span_id": "sp_http_" + str(int(time.time())),
257
+ "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
258
+ "http_method": method,
259
+ "route": path,
260
+ "status_code": status_box[0],
261
+ "duration_ms": (time.time() - st) * 1000.0,
262
+ "service_name": "python_dev_server",
263
+ "source_format": "otel",
264
+ }})
265
+ return _orig_run_simple(hostname, port, _wrapped_app, *args, **kwargs)
266
+
267
+ werkzeug.serving.run_simple = _cg_run_simple
268
+ except Exception:
269
+ pass
121
270
  """
122
271
 
123
272
 
@@ -0,0 +1,175 @@
1
+ """Cross-Service & Multi-Localhost Distributed Trace Linker.
2
+
3
+ Stitches W3C distributed traces across multiple localhost ports:
4
+ - Frontend (e.g. Next.js on localhost:3000)
5
+ - Backend API (e.g. FastAPI / NestJS on localhost:8000)
6
+ - Database mutation (e.g. PostgreSQL on localhost:5432)
7
+
8
+ Attributes every step to:
9
+ 1. Participating localhost service & port.
10
+ 2. Static AST symbol / route handler.
11
+ 3. Actual latency and HTTP status code.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import sqlite3
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class TraceSpanNode:
23
+ span_id: str
24
+ parent_span_id: str
25
+ service_name: str
26
+ port: int | None
27
+ route_path: str
28
+ handler_symbol: str
29
+ db_operation: str
30
+ db_table: str
31
+ status_code: int | None
32
+ duration_ms: float
33
+ children: tuple[TraceSpanNode, ...] = ()
34
+
35
+ def as_dict(self) -> dict[str, Any]:
36
+ return {
37
+ "span_id": self.span_id,
38
+ "parent_span_id": self.parent_span_id,
39
+ "service_name": self.service_name,
40
+ "port": self.port,
41
+ "route_path": self.route_path,
42
+ "handler_symbol": self.handler_symbol,
43
+ "db_operation": self.db_operation,
44
+ "db_table": self.db_table,
45
+ "status_code": self.status_code,
46
+ "duration_ms": self.duration_ms,
47
+ "children": [c.as_dict() for c in self.children],
48
+ }
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class DistributedTraceReport:
53
+ trace_id: str
54
+ total_duration_ms: float
55
+ services_involved: tuple[dict[str, Any], ...]
56
+ root_spans: tuple[TraceSpanNode, ...]
57
+ db_queries_count: int
58
+ has_errors: bool
59
+ summary: str
60
+
61
+ def as_dict(self) -> dict[str, Any]:
62
+ return {
63
+ "trace_id": self.trace_id,
64
+ "total_duration_ms": self.total_duration_ms,
65
+ "services_involved": list(self.services_involved),
66
+ "root_spans": [s.as_dict() for s in self.root_spans],
67
+ "db_queries_count": self.db_queries_count,
68
+ "has_errors": self.has_errors,
69
+ "summary": self.summary,
70
+ }
71
+
72
+
73
+ def link_distributed_trace(
74
+ con: sqlite3.Connection,
75
+ repo_root: Path,
76
+ trace_id: str,
77
+ ) -> DistributedTraceReport | None:
78
+ """Reconstruct multi-service distributed execution graph for a given trace_id."""
79
+ clean_tid = trace_id.strip()
80
+ if not clean_tid:
81
+ return None
82
+
83
+ rows = con.execute(
84
+ """SELECT event_id, trace_id, span_id, parent_span_id, http_method, route_path,
85
+ handler_symbol, service_name, db_operation, db_table, status_code,
86
+ duration_ms
87
+ FROM runtime_observations
88
+ WHERE trace_id = ?
89
+ ORDER BY duration_ms DESC""",
90
+ (clean_tid,),
91
+ ).fetchall()
92
+
93
+ if not rows:
94
+ return None
95
+
96
+ # Group spans
97
+ spans_by_id: dict[str, dict[str, Any]] = {}
98
+ children_by_parent: dict[str, list[str]] = {}
99
+ services_set: set[str] = set()
100
+ db_queries = 0
101
+ has_err = False
102
+ total_time = 0.0
103
+
104
+ for r in rows:
105
+ sid = str(r["span_id"] or r["event_id"])
106
+ pid = str(r["parent_span_id"] or "")
107
+ s_name = str(r["service_name"] or "unknown_service")
108
+ services_set.add(s_name)
109
+ dur = float(r["duration_ms"] or 0.0)
110
+ total_time = max(total_time, dur)
111
+ sc = r["status_code"]
112
+ if sc and int(sc) >= 400:
113
+ has_err = True
114
+ if r["db_operation"]:
115
+ db_queries += 1
116
+
117
+ spans_by_id[sid] = {
118
+ "span_id": sid,
119
+ "parent_span_id": pid,
120
+ "service_name": s_name,
121
+ "port": None,
122
+ "route_path": str(r["route_path"] or ""),
123
+ "handler_symbol": str(r["handler_symbol"] or ""),
124
+ "db_operation": str(r["db_operation"] or ""),
125
+ "db_table": str(r["db_table"] or ""),
126
+ "status_code": sc,
127
+ "duration_ms": dur,
128
+ }
129
+ if pid not in children_by_parent:
130
+ children_by_parent[pid] = []
131
+ children_by_parent[pid].append(sid)
132
+
133
+ # Build tree
134
+ def _build_node(sid: str) -> TraceSpanNode:
135
+ data = spans_by_id[sid]
136
+ child_sids = children_by_parent.get(sid, [])
137
+ children = tuple(_build_node(cid) for cid in child_sids if cid in spans_by_id)
138
+ return TraceSpanNode(
139
+ span_id=data["span_id"],
140
+ parent_span_id=data["parent_span_id"],
141
+ service_name=data["service_name"],
142
+ port=data["port"],
143
+ route_path=data["route_path"],
144
+ handler_symbol=data["handler_symbol"],
145
+ db_operation=data["db_operation"],
146
+ db_table=data["db_table"],
147
+ status_code=data["status_code"],
148
+ duration_ms=data["duration_ms"],
149
+ children=children,
150
+ )
151
+
152
+ # Root spans: parent_span_id is empty or not in spans_by_id
153
+ roots: list[TraceSpanNode] = []
154
+ for sid, data in spans_by_id.items():
155
+ pid = data["parent_span_id"]
156
+ if not pid or pid not in spans_by_id:
157
+ roots.append(_build_node(sid))
158
+
159
+ # Resolve services
160
+ services_list: list[dict[str, Any]] = [{"name": s} for s in sorted(services_set)]
161
+
162
+ summary = (
163
+ f"Trace {clean_tid[:8]}: {len(rows)} spans across {len(services_set)} service(s), "
164
+ f"{db_queries} DB queries, total duration {total_time:.1f}ms."
165
+ )
166
+
167
+ return DistributedTraceReport(
168
+ trace_id=clean_tid,
169
+ total_duration_ms=total_time,
170
+ services_involved=tuple(services_list),
171
+ root_spans=tuple(roots),
172
+ db_queries_count=db_queries,
173
+ has_errors=has_err,
174
+ summary=summary,
175
+ )
codegraph/sandbox.py ADDED
@@ -0,0 +1,215 @@
1
+ """In-Memory Speculative "What-If" Execution Sandbox for CodeGraph.
2
+
3
+ Enables AI agents to project virtual AST branches in RAM in <5ms without modifying disk:
4
+ - Applies unified diffs or in-memory file content substitutions
5
+ - Parses affected files into virtual AST nodes (Python, TS, Go, Rust, Java, C++)
6
+ - Computes virtual caller contract breaks, parameter mismatches, and route mutations
7
+ - Validates blast radius before writing code to disk
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import difflib
12
+ from dataclasses import asdict, dataclass, field
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from codegraph.indexing.parser import parse
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class VirtualContractBreak:
21
+ """A breaking change to an existing symbol contract detected in speculative branch."""
22
+
23
+ symbol: str
24
+ break_type: str # PARAMETER_REMOVED | TYPE_MISMATCH | RETURN_TYPE_CHANGED | SYMBOL_DELETED
25
+ file_path: str
26
+ line: int
27
+ impacted_callers_count: int
28
+ message: str
29
+
30
+ def as_dict(self) -> dict[str, Any]:
31
+ return asdict(self)
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class VirtualRouteMutation:
36
+ """A mutation to an API endpoint detected in speculative branch."""
37
+
38
+ http_method: str
39
+ old_route: str
40
+ new_route: str
41
+ file_path: str
42
+ mutation_type: str # ROUTE_ADDED | ROUTE_MODIFIED | ROUTE_REMOVED
43
+
44
+ def as_dict(self) -> dict[str, Any]:
45
+ return asdict(self)
46
+
47
+
48
+ @dataclass
49
+ class SpeculativeEvaluationReport:
50
+ """Complete evaluation report of an in-memory speculative patch."""
51
+
52
+ is_safe: bool
53
+ modified_files: list[str] = field(default_factory=list)
54
+ contract_breaks: list[VirtualContractBreak] = field(default_factory=list)
55
+ route_mutations: list[VirtualRouteMutation] = field(default_factory=list)
56
+ affected_caller_count: int = 0
57
+ diff_summary: str = ""
58
+ evaluation_time_ms: float = 0.0
59
+
60
+ def as_dict(self) -> dict[str, Any]:
61
+ return {
62
+ "is_safe": self.is_safe,
63
+ "modified_files": self.modified_files,
64
+ "contract_breaks": [b.as_dict() for b in self.contract_breaks],
65
+ "route_mutations": [m.as_dict() for m in self.route_mutations],
66
+ "affected_caller_count": self.affected_caller_count,
67
+ "diff_summary": self.diff_summary,
68
+ "evaluation_time_ms": round(self.evaluation_time_ms, 2),
69
+ }
70
+
71
+
72
+ def _infer_language(file_path: str) -> str:
73
+ ext = Path(file_path).suffix.lower()
74
+ mapping = {
75
+ ".py": "python",
76
+ ".ts": "typescript",
77
+ ".tsx": "typescript",
78
+ ".js": "javascript",
79
+ ".jsx": "javascript",
80
+ ".go": "go",
81
+ ".rs": "rust",
82
+ ".java": "java",
83
+ ".kt": "kotlin",
84
+ ".cpp": "cpp",
85
+ ".cc": "cpp",
86
+ ".h": "c_header",
87
+ ".hpp": "cpp_header",
88
+ }
89
+ return mapping.get(ext, "python")
90
+
91
+
92
+ def evaluate_speculative_patch(
93
+ repository: Path,
94
+ file_patches: dict[str, str], # relative_path -> new_content
95
+ con: Any = None,
96
+ ) -> SpeculativeEvaluationReport:
97
+ """Evaluate candidate file contents in memory without modifying the repository on disk."""
98
+ import time
99
+ t0 = time.perf_counter()
100
+ repo = repository.resolve()
101
+
102
+ contract_breaks: list[VirtualContractBreak] = []
103
+ route_mutations: list[VirtualRouteMutation] = []
104
+ modified_files = list(file_patches.keys())
105
+ affected_callers_total = 0
106
+ diff_lines: list[str] = []
107
+
108
+ for rel_path, new_content in file_patches.items():
109
+ disk_file = repo / rel_path
110
+ old_content = disk_file.read_text(encoding="utf-8", errors="replace") if disk_file.is_file() else ""
111
+ lang = _infer_language(rel_path)
112
+
113
+ # Compute unified diff summary
114
+ diff = list(difflib.unified_diff(
115
+ old_content.splitlines(),
116
+ new_content.splitlines(),
117
+ fromfile=f"a/{rel_path}",
118
+ tofile=f"b/{rel_path}",
119
+ lineterm="",
120
+ ))
121
+ if diff:
122
+ diff_lines.extend(diff[:20])
123
+
124
+ # Parse baseline vs speculative in memory
125
+ old_res = parse(old_content, lang, rel_path) if old_content else None
126
+ new_res = parse(new_content, lang, rel_path)
127
+
128
+ old_syms = {s.name: s for s in old_res.symbols} if old_res else {}
129
+ new_syms = {s.name: s for s in new_res.symbols}
130
+
131
+ # 1. Detect Deleted Symbols & Modified Signatures
132
+ for name, old_s in old_syms.items():
133
+ if name not in new_syms:
134
+ # Symbol deleted
135
+ callers_count = 0
136
+ if con is not None:
137
+ try:
138
+ cur = con.execute("SELECT COUNT(*) FROM calls WHERE callee = ?;", (name,))
139
+ callers_count = cur.fetchone()[0]
140
+ except Exception:
141
+ pass
142
+ affected_callers_total += callers_count
143
+ contract_breaks.append(
144
+ VirtualContractBreak(
145
+ symbol=name,
146
+ break_type="SYMBOL_DELETED",
147
+ file_path=rel_path,
148
+ line=old_s.start_line,
149
+ impacted_callers_count=callers_count,
150
+ message=f"Symbol '{name}' was deleted but has {callers_count} downstream callers.",
151
+ )
152
+ )
153
+ else:
154
+ new_s = new_syms[name]
155
+ # Check parameter count reductions
156
+ if (old_s.parameter_count or 0) > (new_s.parameter_count or 0):
157
+ callers_count = 0
158
+ if con is not None:
159
+ try:
160
+ cur = con.execute("SELECT COUNT(*) FROM calls WHERE callee = ?;", (name,))
161
+ callers_count = cur.fetchone()[0]
162
+ except Exception:
163
+ pass
164
+ affected_callers_total += callers_count
165
+ contract_breaks.append(
166
+ VirtualContractBreak(
167
+ symbol=name,
168
+ break_type="PARAMETER_REMOVED",
169
+ file_path=rel_path,
170
+ line=new_s.start_line,
171
+ impacted_callers_count=callers_count,
172
+ message=f"Parameter count reduced from {old_s.parameter_count} to {new_s.parameter_count} on '{name}'.",
173
+ )
174
+ )
175
+
176
+ # 2. Detect Route Mutations
177
+ old_routes = {f"{r.http_method} {r.route_path}": r for r in old_res.routes} if old_res else {}
178
+ new_routes = {f"{r.http_method} {r.route_path}": r for r in new_res.routes}
179
+
180
+ for key, r in new_routes.items():
181
+ if key not in old_routes:
182
+ route_mutations.append(
183
+ VirtualRouteMutation(
184
+ http_method=r.http_method,
185
+ old_route="",
186
+ new_route=r.route_path,
187
+ file_path=rel_path,
188
+ mutation_type="ROUTE_ADDED",
189
+ )
190
+ )
191
+
192
+ for key, r in old_routes.items():
193
+ if key not in new_routes:
194
+ route_mutations.append(
195
+ VirtualRouteMutation(
196
+ http_method=r.http_method,
197
+ old_route=r.route_path,
198
+ new_route="",
199
+ file_path=rel_path,
200
+ mutation_type="ROUTE_REMOVED",
201
+ )
202
+ )
203
+
204
+ is_safe = len(contract_breaks) == 0
205
+ elapsed = (time.perf_counter() - t0) * 1000.0
206
+
207
+ return SpeculativeEvaluationReport(
208
+ is_safe=is_safe,
209
+ modified_files=modified_files,
210
+ contract_breaks=contract_breaks,
211
+ route_mutations=route_mutations,
212
+ affected_caller_count=affected_callers_total,
213
+ diff_summary="\n".join(diff_lines),
214
+ evaluation_time_ms=elapsed,
215
+ )
@@ -623,6 +623,11 @@ def search_code(
623
623
 
624
624
  config_exts = {".json", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf"}
625
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]
626
631
 
627
632
  for item in raw_hits:
628
633
  rel_path = item.path or item.file
@@ -659,6 +664,27 @@ def search_code(
659
664
  d["match_type"] = "case_insensitive"
660
665
  else:
661
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)
662
688
  filtered.append(d)
663
689
  if len(filtered) >= effective_limit:
664
690
  break