codegraph-engine 3.0.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 (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 +859 -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
codegraph/py.typed ADDED
@@ -0,0 +1 @@
1
+ # Marker file for PEP 561.
@@ -0,0 +1,199 @@
1
+ """Qualified Query Expansion for CodeGraph MCP v2.1.
2
+
3
+ Converts raw target terms into precise, explainable search queries.
4
+
5
+ Key rules:
6
+ 1. If a term resolves to a canonical_id, use canonical_id as the search term
7
+ 2. If a term resolves to a qualified_name, use qualified_name
8
+ 3. If a term matches a route, also expand to the handler symbol
9
+ 4. Generic method names MUST be qualified when qualified context exists:
10
+ e.g. "get" → "AdminDashboardView.get" (never bare "get")
11
+ 5. Every expansion is recorded as a QueryExpansion with full provenance
12
+
13
+ Every expansion is source-backed. No fabricated terms.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import sqlite3
18
+ from dataclasses import dataclass
19
+
20
+ from codegraph.target_resolver import TargetResolution, TargetType, resolve_target
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Generic name set — these must never be emitted as bare search terms
24
+ # ---------------------------------------------------------------------------
25
+
26
+ _GENERIC_NAMES: frozenset[str] = frozenset({
27
+ "get", "post", "put", "delete", "patch", "head", "options",
28
+ "run", "call", "start", "stop", "test", "init", "main",
29
+ "handler", "login", "logout", "register", "index", "home",
30
+ "create", "update", "destroy", "show", "list", "detail",
31
+ "load", "save", "close", "open", "reset", "check",
32
+ })
33
+
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Result type
37
+ # ---------------------------------------------------------------------------
38
+
39
+ @dataclass(frozen=True)
40
+ class QueryExpansion:
41
+ original_term: str
42
+ expanded_term: str
43
+ reason: str
44
+ source_symbol: str | None # canonical_id of the source, if any
45
+ qualification_level: str # CANONICAL | QUALIFIED | SHORT | LEXICAL | ROUTE
46
+ confidence: str # HIGH | MEDIUM | LOW
47
+
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # Main expansion function
51
+ # ---------------------------------------------------------------------------
52
+
53
+ def expand_query_terms(
54
+ raw_terms: list[str] | tuple[str, ...],
55
+ con: sqlite3.Connection,
56
+ target_resolutions: dict[str, TargetResolution] | None = None,
57
+ max_expansions: int = 8,
58
+ ) -> list[QueryExpansion]:
59
+ """Expand raw target terms to precise, qualified search queries.
60
+
61
+ Args:
62
+ raw_terms: Terms from task_spec.priority_targets or task_spec.targets.
63
+ con: Active SQLite connection to the indexed repository.
64
+ target_resolutions: Pre-computed TargetResolution map (optional).
65
+ If provided, avoids duplicate resolution work.
66
+ max_expansions: Maximum total expansions to emit.
67
+
68
+ Returns:
69
+ List of QueryExpansion records, deduplicated by expanded_term.
70
+ """
71
+ expansions: list[QueryExpansion] = []
72
+ seen_expanded: set[str] = set()
73
+ resolutions = target_resolutions or {}
74
+
75
+ def _emit(
76
+ original: str,
77
+ expanded: str,
78
+ reason: str,
79
+ source_symbol: str | None,
80
+ level: str,
81
+ confidence: str,
82
+ ) -> None:
83
+ if expanded and expanded not in seen_expanded:
84
+ seen_expanded.add(expanded)
85
+ expansions.append(QueryExpansion(
86
+ original_term=original,
87
+ expanded_term=expanded,
88
+ reason=reason,
89
+ source_symbol=source_symbol,
90
+ qualification_level=level,
91
+ confidence=confidence,
92
+ ))
93
+
94
+ for term in raw_terms:
95
+ if len(expansions) >= max_expansions:
96
+ break
97
+
98
+ clean = term.strip()
99
+ if not clean or len(clean) < 2:
100
+ continue
101
+
102
+ is_generic = clean.lower() in _GENERIC_NAMES
103
+
104
+ # Use pre-computed resolution if available, otherwise resolve fresh
105
+ resolution = resolutions.get(clean) or resolve_target(clean, con)
106
+
107
+ # ── 1. Canonical ID ───────────────────────────────────────────────
108
+ if resolution.canonical_id and resolution.confidence in ("HIGH", "MEDIUM"):
109
+ _emit(
110
+ clean, resolution.canonical_id,
111
+ f"Resolved canonical ID for '{clean}'",
112
+ resolution.canonical_id,
113
+ "CANONICAL", resolution.confidence,
114
+ )
115
+
116
+ # ── 2. Qualified name (if different from canonical_id) ─────────────
117
+ if (
118
+ resolution.qualified_name
119
+ and resolution.qualified_name != resolution.canonical_id
120
+ and resolution.confidence in ("HIGH", "MEDIUM")
121
+ ):
122
+ _emit(
123
+ clean, resolution.qualified_name,
124
+ f"Qualified name resolution for '{clean}'",
125
+ resolution.canonical_id,
126
+ "QUALIFIED", resolution.confidence,
127
+ )
128
+
129
+ # ── 3. Route → handler expansion ──────────────────────────────────
130
+ if resolution.target_type == TargetType.API_ENDPOINT:
131
+ route_rows = con.execute(
132
+ "SELECT handler_name, file_path FROM framework_routes "
133
+ "WHERE route_path=? OR endpoint_id=? LIMIT 3",
134
+ (clean, resolution.canonical_id or clean),
135
+ ).fetchall()
136
+ for rr in route_rows:
137
+ handler = rr["handler_name"]
138
+ if handler:
139
+ _emit(
140
+ clean, handler,
141
+ f"Route '{clean}' handler expansion",
142
+ handler, "ROUTE", "HIGH",
143
+ )
144
+
145
+ # ── 4. Generic names: qualify or suppress ──────────────────────────
146
+ if is_generic:
147
+ # We already emitted the qualified form via resolution above.
148
+ # If resolution didn't find a qualified form, search for one.
149
+ if not resolution.is_resolved():
150
+ # Try to find any symbol whose short name == clean
151
+ qrows = con.execute(
152
+ "SELECT canonical_id, qualified_name, name FROM symbols "
153
+ "WHERE name=? ORDER BY kind, path LIMIT 5",
154
+ (clean,),
155
+ ).fetchall()
156
+ for qr in qrows:
157
+ qname = qr["qualified_name"]
158
+ cid = qr["canonical_id"]
159
+ if qname and "." in qname:
160
+ # Qualified form exists — emit it, not the bare name
161
+ _emit(
162
+ clean, qname,
163
+ f"Generic name '{clean}' qualified to '{qname}'",
164
+ cid, "QUALIFIED", "MEDIUM",
165
+ )
166
+ # Never emit the bare generic name if qualified form exists
167
+ # If still nothing: skip the bare generic name entirely
168
+ continue # do NOT fall through to SHORT expansion
169
+
170
+ # ── 5. Short name fallback (non-generic) ──────────────────────────
171
+ if not resolution.is_resolved():
172
+ # Use bare term only for non-generic identifiers with reasonable length
173
+ if len(clean) >= 3:
174
+ _emit(
175
+ clean, clean,
176
+ f"Lexical short name for unresolved term '{clean}'",
177
+ None, "LEXICAL", "LOW",
178
+ )
179
+ elif clean not in seen_expanded:
180
+ # Term already resolved but short name wasn't emitted separately
181
+ # — only emit if not generic
182
+ _emit(
183
+ clean, clean,
184
+ f"Short name '{clean}'",
185
+ resolution.canonical_id, "SHORT", "MEDIUM",
186
+ )
187
+
188
+ return expansions[:max_expansions]
189
+
190
+
191
+ def get_search_queries(expansions: list[QueryExpansion]) -> list[str]:
192
+ """Extract the deduplicated list of search query strings from expansions."""
193
+ seen: set[str] = set()
194
+ queries: list[str] = []
195
+ for e in expansions:
196
+ if e.expanded_term not in seen:
197
+ seen.add(e.expanded_term)
198
+ queries.append(e.expanded_term)
199
+ return queries
codegraph/ranking.py ADDED
@@ -0,0 +1,408 @@
1
+ """Context ranking engine — rigorous, explainable, normalized codebase relevance.
2
+
3
+ Ranking Hierarchy (without double counting):
4
+ 1. EXACT_CANONICAL match > EXACT_QUALIFIED match > EXACT_SYMBOL match > NAME_MATCH > PATH_MATCH / TEXT_MATCH
5
+ 2. Verified relationships (CALLS with HIGH confidence) > POSSIBLE_CALLS (LOW confidence)
6
+ 3. Graph distance geometric decay
7
+ 4. Intent-specific weight policies (EXPLAIN, DEBUG, MODIFY, REVIEW, TEST, IMPACT, ARCHITECTURE)
8
+ 5. Freshness weighting (STALE and PARTIALLY_STALE penalties)
9
+ 6. Source-type classification (source > config > doc > generated / vendor)
10
+ 7. Duplicate context penalties
11
+ 8. Stable deterministic tie-breaking: (-score, -evidence_prio, -exact_prio, dist, len(file), file)
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from dataclasses import dataclass
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class RankingReason:
21
+ code: str
22
+ label: str
23
+ contribution: float
24
+
25
+
26
+ @dataclass
27
+ class RankedItem:
28
+ file: str
29
+ symbol: str | None
30
+ start_line: int
31
+ end_line: int
32
+ score: float
33
+ reasons: list[RankingReason]
34
+ snippet: str = ""
35
+ token_estimate: int = 0
36
+ canonical_id: str | None = None
37
+
38
+ def as_dict(self) -> dict[str, object]:
39
+ return {
40
+ "file": self.file,
41
+ "symbol": self.symbol,
42
+ "canonical_id": self.canonical_id,
43
+ "start_line": self.start_line,
44
+ "end_line": self.end_line,
45
+ "score": self.score,
46
+ "reasons": [
47
+ {"code": r.code, "label": r.label, "contribution": r.contribution}
48
+ for r in self.reasons
49
+ ],
50
+ "snippet": self.snippet,
51
+ "token_estimate": self.token_estimate,
52
+ }
53
+
54
+ def debug_format(self) -> str:
55
+ out = [f"{self.canonical_id or self.symbol or self.file}"]
56
+ out.append(f"score: {self.score:.2f}")
57
+ pos = [r for r in self.reasons if r.contribution > 0]
58
+ neg = [r for r in self.reasons if r.contribution < 0]
59
+ if pos:
60
+ out.append("\npositive:")
61
+ for r in pos:
62
+ out.append(f"+ {r.label:<24} +{r.contribution:.2f}")
63
+ if neg:
64
+ out.append("\nnegative:")
65
+ for r in neg:
66
+ out.append(f"- {r.label:<24} {r.contribution:.2f}")
67
+ return "\n".join(out)
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class IntentWeights:
72
+ target_bonus: float
73
+ caller_bonus: float
74
+ callee_bonus: float
75
+ test_bonus: float
76
+ entry_point_bonus: float
77
+ distance_decay: float
78
+ recent_bonus: float
79
+
80
+
81
+ _INTENT_WEIGHTS = {
82
+ "explain": IntentWeights(0.30, 0.10, 0.20, 0.05, 0.10, 0.70, 0.05),
83
+ "understand": IntentWeights(0.30, 0.12, 0.18, 0.10, 0.10, 0.70, 0.05),
84
+ "trace": IntentWeights(0.25, 0.15, 0.25, 0.10, 0.25, 0.85, 0.05),
85
+ "debug": IntentWeights(0.20, 0.20, 0.10, 0.15, 0.20, 0.80, 0.15),
86
+ "modify": IntentWeights(0.40, 0.15, 0.15, 0.20, 0.05, 0.60, 0.10),
87
+ "change": IntentWeights(0.35, 0.20, 0.15, 0.20, 0.10, 0.70, 0.10),
88
+ "review": IntentWeights(0.20, 0.10, 0.10, 0.20, 0.05, 0.60, 0.20),
89
+ "test": IntentWeights(0.20, 0.10, 0.05, 0.40, 0.05, 0.50, 0.10),
90
+ "impact": IntentWeights(0.30, 0.30, 0.05, 0.10, 0.10, 0.90, 0.00),
91
+ "architecture": IntentWeights(0.15, 0.05, 0.05, 0.05, 0.30, 0.50, 0.00),
92
+ "default": IntentWeights(0.25, 0.10, 0.10, 0.10, 0.10, 0.70, 0.10),
93
+ }
94
+
95
+ _TEST_PATTERNS = re.compile(
96
+ r"(^|[_/])test[_s]?[_/]|[_/]spec[_/]|test[_s]?\.(py|js|ts|jsx|tsx)$|spec\.(py|js|ts|jsx|tsx)$",
97
+ re.IGNORECASE,
98
+ )
99
+
100
+ _ENTRY_PATTERNS = re.compile(
101
+ r"(^|[_/])(main|app|server|index|wsgi|asgi|manage|run)\.(py|js|ts)$",
102
+ re.IGNORECASE,
103
+ )
104
+
105
+ _VENDOR_PATTERNS = re.compile(
106
+ r"(^|[_/])(node_modules|vendor|\.venv|venv|third_party)[_/]",
107
+ re.IGNORECASE,
108
+ )
109
+
110
+
111
+ def _token_estimate(text: str) -> int:
112
+ return max(1, len(text) // 4)
113
+
114
+
115
+ def rank_candidates(
116
+ candidates: list[dict[str, object]],
117
+ query: str,
118
+ intent: str | None = None,
119
+ target_symbols: set[str] | None = None,
120
+ recent_paths: set[str] | None = None,
121
+ relationship_distance: dict[str, int] | None = None,
122
+ max_results: int = 20,
123
+ allowed_relationships: frozenset[str] | None = None,
124
+ ) -> list[RankedItem]:
125
+ """Deterministically rank candidates returning explainable, normalized scores between 0.0 and 1.0.
126
+
127
+ Args:
128
+ candidates: Raw candidate dicts from search and graph traversal.
129
+ query: The task display string used for lexical matching.
130
+ intent: Task intent string (e.g. "TRACE", "DEBUG", "UNDERSTAND").
131
+ target_symbols: Set of target symbol names/IDs boosted during ranking.
132
+ recent_paths: File paths recently modified (git-aware freshness boost).
133
+ relationship_distance: Pre-computed BFS distances from target symbols.
134
+ max_results: Maximum results to return.
135
+ allowed_relationships: If provided, candidates whose relationship type is
136
+ NOT in this set receive a penalty. From RetrievalPolicy.
137
+ """
138
+ intent_key = (intent or "").lower().strip()
139
+ weights = _INTENT_WEIGHTS.get(intent_key, _INTENT_WEIGHTS["default"])
140
+ target_symbols = target_symbols or set()
141
+ recent_paths = recent_paths or set()
142
+ relationship_distance = relationship_distance or {}
143
+
144
+ # Extract terms: dotted terms and word terms
145
+ dotted_terms = {t.lower() for t in re.findall(r"[A-Za-z_][\w.]*\.[\w.]+", query)}
146
+ word_terms = {t.lower() for t in re.findall(r"[A-Za-z_][\w]*", query) if len(t) > 1}
147
+ for ts in target_symbols:
148
+ word_terms.add(ts.lower().split(".")[-1])
149
+ if "." in ts:
150
+ dotted_terms.add(ts.lower())
151
+
152
+ scored_items: list[RankedItem] = []
153
+
154
+ for c in candidates:
155
+ file_ = str(c.get("file", ""))
156
+ canonical_id = str(c.get("canonical_id") or "") or None
157
+ symbol = c.get("symbol") or c.get("qualified_name")
158
+ symbol_str = str(symbol) if symbol else None
159
+ start = int(str(c.get("start_line", 1)))
160
+ end = int(str(c.get("end_line", start)))
161
+ snippet = str(c.get("snippet", "") or c.get("content", ""))[:1200]
162
+ confidence = str(c.get("confidence", "HIGH")).upper()
163
+ freshness = str(c.get("freshness", "FRESH")).upper()
164
+ relationship = str(c.get("relationship", ""))
165
+ base_search_score = float(str(c.get("score", 0.0)))
166
+
167
+ score = 0.0
168
+ reasons: list[RankingReason] = []
169
+
170
+ # 1. Base Symbol Matching (strictly hierarchical to avoid double counting)
171
+ matched_exact = False
172
+ canon_lower = canonical_id.lower() if canonical_id else ""
173
+ sym_lower = symbol_str.lower() if symbol_str else ""
174
+ short_name = sym_lower.split(".")[-1] if sym_lower else ""
175
+
176
+ # Exact canonical match
177
+ if canon_lower and any(dt == canon_lower or dt in canon_lower for dt in dotted_terms):
178
+ val = weights.target_bonus + 0.20
179
+ score += val
180
+ reasons.append(RankingReason("EXACT_CANONICAL", "Exact canonical symbol match", round(val, 3)))
181
+ matched_exact = True
182
+ # Exact qualified name match
183
+ elif sym_lower and any(dt == sym_lower or sym_lower.endswith(f".{dt}") for dt in dotted_terms):
184
+ val = weights.target_bonus + 0.15
185
+ score += val
186
+ reasons.append(RankingReason("EXACT_QUALIFIED", "Exact qualified symbol match", round(val, 3)))
187
+ matched_exact = True
188
+ # Exact symbol name match
189
+ elif short_name and short_name in word_terms:
190
+ val = weights.target_bonus + 0.10
191
+ score += val
192
+ reasons.append(RankingReason("EXACT_SYMBOL", "Exact target symbol", round(val, 3)))
193
+ matched_exact = True
194
+ # Name relevance
195
+ elif (short_name and any(wt in short_name for wt in word_terms)) or (
196
+ sym_lower and any(wt in sym_lower for wt in word_terms)
197
+ ):
198
+ val = min(0.25, max(0.12, base_search_score))
199
+ score += val
200
+ reasons.append(RankingReason("NAME_MATCH", "Symbol name relevance", round(val, 3)))
201
+ # Path relevance
202
+ elif any(wt in file_.lower() for wt in word_terms):
203
+ val = min(0.20, max(0.10, base_search_score))
204
+ score += val
205
+ reasons.append(RankingReason("PATH_MATCH", "File path relevance", round(val, 3)))
206
+ elif base_search_score > 0:
207
+ val = min(0.30, base_search_score)
208
+ score += val
209
+ reasons.append(RankingReason("TEXT_MATCH", "Lexical text match", round(val, 3)))
210
+
211
+ # 2. Graph Relationship Distance & Direction
212
+ sym_key = canonical_id or symbol_str
213
+ if sym_key and sym_key in relationship_distance:
214
+ dist = relationship_distance[sym_key]
215
+ decay = weights.distance_decay ** dist
216
+ val = 0.20 * decay
217
+ score += val
218
+ reasons.append(RankingReason("GRAPH_DISTANCE", f"Graph distance {dist}", round(val, 3)))
219
+ elif relationship == "CALLS":
220
+ val = weights.callee_bonus
221
+ score += val
222
+ reasons.append(RankingReason("DIRECT_CALLEE", "Direct callee", val))
223
+ elif relationship in ("POSSIBLE_CALLS", "CALLERS", "CALLED_BY"):
224
+ multiplier = 1.0 if confidence in ("HIGH", "MEDIUM") else 0.5
225
+ val = round(weights.caller_bonus * multiplier, 3)
226
+ score += val
227
+ reasons.append(
228
+ RankingReason(
229
+ "DIRECT_CALLER" if multiplier == 1.0 else "POSSIBLE_CALLER",
230
+ "Direct caller" if multiplier == 1.0 else "Possible caller",
231
+ val,
232
+ )
233
+ )
234
+ elif relationship in ("HANDLED_BY", "ROUTE_HANDLER", "ROUTES_TO", "MOUNTS"):
235
+ val = weights.target_bonus + 0.10
236
+ score += val
237
+ reasons.append(RankingReason("ENDPOINT_HANDLER", "Endpoint handler", round(val, 3)))
238
+ elif relationship in ("INJECTS", "PROVIDES", "RESOLVES_DEPENDENCY", "CONFIGURES"):
239
+ val = round(max(weights.callee_bonus, 0.15), 3)
240
+ score += val
241
+ reasons.append(RankingReason("DI_PROVIDER", f"Dependency injection ({relationship})", val))
242
+ elif relationship in ("REGISTERS", "DISPATCHES_TO", "EVENT_LISTENER", "TASK_HANDLER", "COMMAND_HANDLER"):
243
+ val = round(max(weights.callee_bonus, 0.15), 3)
244
+ score += val
245
+ reasons.append(RankingReason("SEMANTIC_DISPATCH", f"Semantic registration ({relationship})", val))
246
+ elif relationship == "DEPENDS_ON_PACKAGE":
247
+ val = 0.15
248
+ score += val
249
+ reasons.append(RankingReason("PACKAGE_DEPENDENCY", "Workspace package dependency", val))
250
+ elif relationship in ("EXTENDS", "IMPLEMENTS"):
251
+ val = 0.10
252
+ score += val
253
+ reasons.append(RankingReason("INHERITANCE", f"Class {relationship.lower()}", val))
254
+
255
+ # 2b. Policy relationship allowlist — penalize relationships not permitted for this intent
256
+ if allowed_relationships and relationship and relationship.upper() not in allowed_relationships:
257
+ if not (relationship.upper().startswith("TESTS") and "TESTS" in allowed_relationships):
258
+ penalty = 0.15
259
+ score -= penalty
260
+ reasons.append(RankingReason(
261
+ "RELATIONSHIP_NOT_ALLOWED",
262
+ f"Relationship '{relationship}' not in policy allowlist",
263
+ -penalty,
264
+ ))
265
+
266
+ # 2c. Package boundary relevance
267
+ pkg_dist_raw = c.get("package_distance")
268
+ if pkg_dist_raw is not None:
269
+ pkg_dist = int(str(pkg_dist_raw))
270
+ if pkg_dist == 0:
271
+ score += 0.08
272
+ reasons.append(RankingReason("PACKAGE_OWNER", "Target owning package", 0.08))
273
+ elif pkg_dist == 1:
274
+ score += 0.04
275
+ reasons.append(RankingReason("DEPENDENCY_PACKAGE", "Direct package dependency", 0.04))
276
+ elif pkg_dist >= 99:
277
+ score -= 0.15
278
+ reasons.append(RankingReason("UNRELATED_PACKAGE", "Unrelated workspace package", -0.15))
279
+
280
+ # 3. Evidence Quality
281
+ if confidence == "HIGH":
282
+ score += 0.10
283
+ reasons.append(RankingReason("EVIDENCE_HIGH", "Verified source evidence", 0.10))
284
+ elif confidence == "MEDIUM":
285
+ score += 0.05
286
+ reasons.append(RankingReason("EVIDENCE_MEDIUM", "Inferred relationship", 0.05))
287
+
288
+ # 4. Freshness
289
+ if freshness == "STALE":
290
+ score -= 0.30
291
+ reasons.append(RankingReason("STALE_SOURCE", "Stale evidence", -0.30))
292
+ elif freshness == "PARTIALLY_STALE":
293
+ score -= 0.10
294
+ reasons.append(RankingReason("PARTIALLY_STALE", "Partially stale evidence", -0.10))
295
+
296
+ # 5. Test Relationship
297
+ is_test = bool(_TEST_PATTERNS.search(file_))
298
+ if is_test and (relationship.startswith("TEST") or matched_exact):
299
+ val = weights.test_bonus
300
+ score += val
301
+ reasons.append(RankingReason("TEST_RELATION", "Related test file", val))
302
+
303
+ # 6. Entry Point & Framework Route
304
+ is_entry = bool(_ENTRY_PATTERNS.search(file_))
305
+ if is_entry:
306
+ val = weights.entry_point_bonus
307
+ score += val
308
+ reasons.append(RankingReason("ENTRY_POINT", "Entry point", val))
309
+
310
+ # 7. Artifact / Vendor / Generated / Bundle Penalty
311
+ from codegraph.indexing.classifier import classify_file
312
+ file_cat = str(c.get("category") or "")
313
+ if not file_cat and file_:
314
+ file_cat = str(classify_file(file_).value)
315
+
316
+ if file_cat in ("BUNDLE", "MINIFIED"):
317
+ score -= 0.40
318
+ reasons.append(RankingReason("BUNDLE_PENALTY", f"artifact_class={file_cat}", -0.40))
319
+ elif file_cat == "BUILD_ARTIFACT":
320
+ score -= 0.40
321
+ reasons.append(RankingReason("BUILD_ARTIFACT_PENALTY", "artifact_class=BUILD_ARTIFACT", -0.40))
322
+ elif file_cat == "GENERATED":
323
+ score -= 0.25
324
+ reasons.append(RankingReason("GENERATED_PENALTY", "artifact_class=GENERATED", -0.25))
325
+ elif file_cat == "VENDOR" or _VENDOR_PATTERNS.search(file_):
326
+ score -= 0.30
327
+ reasons.append(RankingReason("VENDOR_PENALTY", "artifact_class=VENDOR", -0.30))
328
+
329
+ # 8. Recency
330
+ if file_ in recent_paths:
331
+ val = weights.recent_bonus
332
+ score += val
333
+ reasons.append(RankingReason("RECENT_CHANGE", "Recently modified", val))
334
+
335
+ score = max(0.0, min(1.0, score))
336
+
337
+ scored_items.append(
338
+ RankedItem(
339
+ file=file_,
340
+ symbol=symbol_str,
341
+ canonical_id=canonical_id,
342
+ start_line=start,
343
+ end_line=end,
344
+ score=round(score, 3),
345
+ reasons=reasons,
346
+ snippet=snippet,
347
+ token_estimate=_token_estimate(snippet),
348
+ )
349
+ )
350
+
351
+ # 9. Duplicate Context Penalty (deterministic pre-sort ensures stable penalty application)
352
+ def pre_sort_key(item: RankedItem) -> tuple[float, int, str, str]:
353
+ return (-item.score, 0 if item.symbol else 1, item.file, str(item.canonical_id or item.symbol or ""))
354
+
355
+ scored_items.sort(key=pre_sort_key)
356
+ seen_symbols: set[str] = set()
357
+ seen_files: set[str] = set()
358
+
359
+ for item in scored_items:
360
+ if item.score <= 0:
361
+ continue
362
+ penalty = 0.0
363
+ sym_key = item.canonical_id or item.symbol
364
+ if sym_key:
365
+ if sym_key in seen_symbols:
366
+ penalty += 0.20
367
+ seen_symbols.add(sym_key)
368
+ if item.file in seen_files:
369
+ penalty += 0.10
370
+ seen_files.add(item.file)
371
+
372
+ if penalty > 0:
373
+ item.score = max(0.0, round(item.score - penalty, 3))
374
+ item.reasons.append(
375
+ RankingReason("DUPLICATE_PENALTY", "Duplicate context penalty", -penalty)
376
+ )
377
+
378
+ # 10. Stable Tie-Breaking Sort
379
+ def sort_key(item: RankedItem) -> tuple[float, int, int, int, int, str]:
380
+ evidence_prio = 0
381
+ if any(r.code == "EVIDENCE_HIGH" for r in item.reasons):
382
+ evidence_prio = 2
383
+ elif any(r.code == "EVIDENCE_MEDIUM" for r in item.reasons):
384
+ evidence_prio = 1
385
+
386
+ exact_prio = 0
387
+ if any(r.code == "EXACT_CANONICAL" for r in item.reasons):
388
+ exact_prio = 3
389
+ elif any(r.code == "EXACT_QUALIFIED" for r in item.reasons):
390
+ exact_prio = 2
391
+ elif any(r.code == "EXACT_SYMBOL" for r in item.reasons):
392
+ exact_prio = 1
393
+
394
+ dist = 999
395
+ for r in item.reasons:
396
+ if r.code == "GRAPH_DISTANCE":
397
+ try:
398
+ dist = int(r.label.split()[-1])
399
+ except ValueError:
400
+ pass
401
+
402
+ return (-item.score, -evidence_prio, -exact_prio, dist, len(item.file), item.file)
403
+
404
+ scored_items.sort(key=sort_key)
405
+ return scored_items[:max_results]
406
+
407
+
408
+ rank = rank_candidates
@@ -0,0 +1,36 @@
1
+ """Deterministic, syntax-validated, transaction-safe refactoring engine for CodeGraph MCP."""
2
+ from __future__ import annotations
3
+
4
+ from codegraph.refactor.models import (
5
+ RefactorPlan,
6
+ RefactorResult,
7
+ RefactorRisk,
8
+ RefactorStatus,
9
+ TokenReplacementSpan,
10
+ )
11
+ from codegraph.refactor.renamer import (
12
+ execute_safe_rename,
13
+ plan_safe_rename,
14
+ preview_safe_rename,
15
+ )
16
+ from codegraph.refactor.transactions import (
17
+ RefactorLock,
18
+ RefactorLockError,
19
+ commit_refactor_transaction,
20
+ rollback_refactor,
21
+ )
22
+
23
+ __all__ = [
24
+ "RefactorLock",
25
+ "RefactorLockError",
26
+ "RefactorPlan",
27
+ "RefactorResult",
28
+ "RefactorRisk",
29
+ "RefactorStatus",
30
+ "TokenReplacementSpan",
31
+ "commit_refactor_transaction",
32
+ "execute_safe_rename",
33
+ "plan_safe_rename",
34
+ "preview_safe_rename",
35
+ "rollback_refactor",
36
+ ]