k-cli-for-devs 1.0.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.
- k_cli/__init__.py +77 -0
- k_cli/agents/__init__.py +0 -0
- k_cli/agents/adversarial_swarm.py +338 -0
- k_cli/agents/agent_core.py +255 -0
- k_cli/agents/background_daemon.py +141 -0
- k_cli/agents/orchestrator.py +376 -0
- k_cli/agents/persona.py +649 -0
- k_cli/agents/scaffold_engine.py +121 -0
- k_cli/agents/strands_agent.py +832 -0
- k_cli/agents/subagents.py +1496 -0
- k_cli/cli.py +3297 -0
- k_cli/core/__init__.py +0 -0
- k_cli/core/airgap.py +95 -0
- k_cli/core/credentials.py +548 -0
- k_cli/core/intent_sensor.py +177 -0
- k_cli/core/llm_driver.py +1028 -0
- k_cli/core/model_manager.py +1109 -0
- k_cli/core/models_hub.py +913 -0
- k_cli/core/prompting.py +41 -0
- k_cli/core/sdk.py +322 -0
- k_cli/core/session.py +826 -0
- k_cli/core/smart_router.py +230 -0
- k_cli/core/storage_manager.py +176 -0
- k_cli/core/viewport_engine.py +117 -0
- k_cli/demo/demo_runner.py +579 -0
- k_cli/git/__init__.py +0 -0
- k_cli/git/ai_bisect.py +208 -0
- k_cli/git/conflict_resolver.py +1039 -0
- k_cli/git/git_guard.py +417 -0
- k_cli/git/patcher.py +1175 -0
- k_cli/git/repo_map.py +1780 -0
- k_cli/git/smart_git.py +928 -0
- k_cli/git/verifier.py +969 -0
- k_cli/github/__init__.py +0 -0
- k_cli/github/dedup_engine.py +787 -0
- k_cli/github/github_client.py +1702 -0
- k_cli/github/github_engine.py +641 -0
- k_cli/github/local_hub.py +209 -0
- k_cli/github/pr_watcher.py +129 -0
- k_cli/github/trending.py +205 -0
- k_cli/tools/__init__.py +0 -0
- k_cli/tools/audit.py +79 -0
- k_cli/tools/chaos_immunity.py +377 -0
- k_cli/tools/codebase_qa.py +106 -0
- k_cli/tools/command_runner.py +256 -0
- k_cli/tools/diagram_generator.py +547 -0
- k_cli/tools/doc_retriever.py +1332 -0
- k_cli/tools/feature.py +105 -0
- k_cli/tools/ghost_daemon.py +122 -0
- k_cli/tools/incident_triage.py +1365 -0
- k_cli/tools/mcp_client.py +1846 -0
- k_cli/tools/repo_gardener.py +142 -0
- k_cli/tools/rules.py +109 -0
- k_cli/tools/security.py +52 -0
- k_cli/tools/security_healer.py +999 -0
- k_cli/tools/synapse_graph.py +155 -0
- k_cli/tui/__init__.py +0 -0
- k_cli/tui/diff_viewer.py +223 -0
- k_cli/tui/tui.py +1145 -0
- k_cli/tui/tui_animations.py +648 -0
- k_cli/tui/tui_app.py +2788 -0
- k_cli/ui/__init__.py +10 -0
- k_cli/ui/simple_repl.py +315 -0
- k_cli/web/__init__.py +7 -0
- k_cli/web/server.py +624 -0
- k_cli/web/static/app.js +830 -0
- k_cli/web/static/index.html +495 -0
- k_cli/web/static/monitor.html +189 -0
- k_cli/web/static/style.css +838 -0
- k_cli_for_devs-1.0.0.dist-info/METADATA +461 -0
- k_cli_for_devs-1.0.0.dist-info/RECORD +75 -0
- k_cli_for_devs-1.0.0.dist-info/WHEEL +5 -0
- k_cli_for_devs-1.0.0.dist-info/entry_points.txt +2 -0
- k_cli_for_devs-1.0.0.dist-info/licenses/LICENSE +21 -0
- k_cli_for_devs-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,787 @@
|
|
|
1
|
+
"""
|
|
2
|
+
dedup_engine.py - Intelligent Repository & Request Deduplication Engine for K-CLI
|
|
3
|
+
|
|
4
|
+
Features:
|
|
5
|
+
1. Multi-tier semantic and lexical similarity analysis (BM25, Jaccard, token overlap, string distance, stemming, prefix matching).
|
|
6
|
+
2. Deep AST symbol extraction and indexing powered by RepoMap.
|
|
7
|
+
3. Git commit history scanning (commit messages, diff stats, issue/PR references).
|
|
8
|
+
4. Code snippet and symbol duplicate detection with precise line ranges.
|
|
9
|
+
5. Structured DedupMatch results with confidence scores and explainability.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import difflib
|
|
15
|
+
import math
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import subprocess
|
|
19
|
+
import textwrap
|
|
20
|
+
from collections import Counter, defaultdict
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any, Dict, List, Optional, Set, Tuple, Union
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
from k_cli.git.repo_map import RepoMap
|
|
27
|
+
except ModuleNotFoundError:
|
|
28
|
+
try:
|
|
29
|
+
from repo_map import RepoMap
|
|
30
|
+
except ModuleNotFoundError:
|
|
31
|
+
RepoMap = None # type: ignore
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class DedupMatch:
|
|
36
|
+
"""Structured result of a deduplication query check."""
|
|
37
|
+
is_duplicate: bool
|
|
38
|
+
confidence: float # Score from 0.0 to 1.0
|
|
39
|
+
existing_commit: Optional[str] = None # Commit hash if matched in git history
|
|
40
|
+
file_path: Optional[str] = None # File path if matched in codebase
|
|
41
|
+
line_range: Optional[Tuple[int, int]] = None # (start_line, end_line) in file
|
|
42
|
+
explanation: str = "" # Human-readable rationale
|
|
43
|
+
match_type: str = "none" # "commit", "symbol", "issue_pr", "snippet", "none"
|
|
44
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
45
|
+
|
|
46
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
47
|
+
"""Serializes DedupMatch to dictionary."""
|
|
48
|
+
return {
|
|
49
|
+
"is_duplicate": self.is_duplicate,
|
|
50
|
+
"confidence": round(self.confidence, 4),
|
|
51
|
+
"existing_commit": self.existing_commit,
|
|
52
|
+
"file_path": self.file_path,
|
|
53
|
+
"line_range": list(self.line_range) if self.line_range else None,
|
|
54
|
+
"explanation": self.explanation,
|
|
55
|
+
"match_type": self.match_type,
|
|
56
|
+
"metadata": self.metadata,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class CommitRecord:
|
|
62
|
+
"""Structured git commit record."""
|
|
63
|
+
commit_hash: str
|
|
64
|
+
short_hash: str
|
|
65
|
+
author: str
|
|
66
|
+
date: str
|
|
67
|
+
subject: str
|
|
68
|
+
body: str
|
|
69
|
+
files: List[str] = field(default_factory=list)
|
|
70
|
+
diff_stat: str = ""
|
|
71
|
+
issue_refs: List[str] = field(default_factory=list)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class SymbolRecord:
|
|
76
|
+
"""Structured AST symbol record."""
|
|
77
|
+
name: str
|
|
78
|
+
type: str # "class", "function", "method", "struct", "interface", etc.
|
|
79
|
+
file_path: str
|
|
80
|
+
rel_path: str
|
|
81
|
+
line_number: int
|
|
82
|
+
end_lineno: int
|
|
83
|
+
signature: str
|
|
84
|
+
docstring: Optional[str] = None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class SimilarityScorer:
|
|
88
|
+
"""
|
|
89
|
+
Computes lexical, statistical (BM25), and set-based similarity metrics
|
|
90
|
+
between search queries, commit records, and source code tokens.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
STOP_WORDS: Set[str] = {
|
|
94
|
+
"a", "an", "the", "and", "or", "in", "on", "at", "to", "for", "of", "with",
|
|
95
|
+
"by", "from", "is", "are", "was", "were", "be", "been", "being", "have", "has",
|
|
96
|
+
"had", "do", "does", "did", "can", "could", "should", "would", "will", "this",
|
|
97
|
+
"that", "these", "those", "it", "its", "as", "if", "each", "all", "both",
|
|
98
|
+
"into", "through", "during", "before", "after", "above", "below", "up", "down",
|
|
99
|
+
"create", "implement", "add", "build", "write", "make", "new",
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
@classmethod
|
|
103
|
+
def stem_token(cls, token: str) -> str:
|
|
104
|
+
"""Lightweight stemmer for common suffixes and domain prefixes in code."""
|
|
105
|
+
t = token.lower()
|
|
106
|
+
if t.startswith("auth"):
|
|
107
|
+
return "auth"
|
|
108
|
+
if t.startswith("calc"):
|
|
109
|
+
return "calc"
|
|
110
|
+
if t.startswith("valid"):
|
|
111
|
+
return "valid"
|
|
112
|
+
if t.startswith("config"):
|
|
113
|
+
return "config"
|
|
114
|
+
if t.startswith("init"):
|
|
115
|
+
return "init"
|
|
116
|
+
|
|
117
|
+
if len(t) > 5:
|
|
118
|
+
for suffix in ("ation", "izing", "ising", "ator", "tion", "ment", "ness", "able", "ible"):
|
|
119
|
+
if t.endswith(suffix):
|
|
120
|
+
return t[:-len(suffix)]
|
|
121
|
+
if len(t) > 4:
|
|
122
|
+
for suffix in ("ing", "ies", "ied", "ers", "est", "ant", "ent"):
|
|
123
|
+
if t.endswith(suffix):
|
|
124
|
+
return t[:-len(suffix)]
|
|
125
|
+
if len(t) > 3:
|
|
126
|
+
for suffix in ("ed", "er", "es", "ly"):
|
|
127
|
+
if t.endswith(suffix):
|
|
128
|
+
return t[:-len(suffix)]
|
|
129
|
+
if t.endswith("s") and not t.endswith("ss"):
|
|
130
|
+
return t[:-1]
|
|
131
|
+
return t
|
|
132
|
+
|
|
133
|
+
@classmethod
|
|
134
|
+
def tokenize(cls, text: str, stem: bool = False) -> List[str]:
|
|
135
|
+
"""
|
|
136
|
+
Code-aware tokenization:
|
|
137
|
+
- Splits acronyms & camelCase (`JWTTokenHandler` -> `jwt`, `token`, `handler`)
|
|
138
|
+
- Splits snake_case and kebab-case (`calculate_total_score` -> `calculate`, `total`, `score`)
|
|
139
|
+
- Normalizes to lowercase and removes punctuation and stop words.
|
|
140
|
+
"""
|
|
141
|
+
if not text:
|
|
142
|
+
return []
|
|
143
|
+
|
|
144
|
+
s1 = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1 \2", text)
|
|
145
|
+
s2 = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", s1)
|
|
146
|
+
raw_tokens = re.findall(r"[A-Za-z0-9]+", s2.lower())
|
|
147
|
+
|
|
148
|
+
tokens: List[str] = []
|
|
149
|
+
for t in raw_tokens:
|
|
150
|
+
if len(t) > 1 and t not in cls.STOP_WORDS:
|
|
151
|
+
tokens.append(cls.stem_token(t) if stem else t)
|
|
152
|
+
return tokens
|
|
153
|
+
|
|
154
|
+
@classmethod
|
|
155
|
+
def _match_tokens(cls, t1: str, t2: str) -> bool:
|
|
156
|
+
"""Returns True if tokens match directly, via stemming, or via common code prefixes."""
|
|
157
|
+
if t1 == t2:
|
|
158
|
+
return True
|
|
159
|
+
st1 = cls.stem_token(t1)
|
|
160
|
+
st2 = cls.stem_token(t2)
|
|
161
|
+
if st1 == st2:
|
|
162
|
+
return True
|
|
163
|
+
if len(t1) >= 4 and len(t2) >= 4:
|
|
164
|
+
if t1.startswith(t2) or t2.startswith(t1):
|
|
165
|
+
return True
|
|
166
|
+
return False
|
|
167
|
+
|
|
168
|
+
@classmethod
|
|
169
|
+
def jaccard_similarity(cls, tokens1: List[str], tokens2: List[str]) -> float:
|
|
170
|
+
"""Calculates Jaccard set similarity between two token lists with stemming support."""
|
|
171
|
+
s1 = {cls.stem_token(t) for t in tokens1}
|
|
172
|
+
s2 = {cls.stem_token(t) for t in tokens2}
|
|
173
|
+
if not s1 or not s2:
|
|
174
|
+
return 0.0
|
|
175
|
+
intersection = sum(1 for t1 in s1 if any(cls._match_tokens(t1, t2) for t2 in s2))
|
|
176
|
+
union = len(s1 | s2)
|
|
177
|
+
return intersection / union if union > 0 else 0.0
|
|
178
|
+
|
|
179
|
+
@classmethod
|
|
180
|
+
def token_overlap(cls, query_tokens: List[str], doc_tokens: List[str]) -> float:
|
|
181
|
+
"""Calculates query token containment / overlap ratio in document with stemming."""
|
|
182
|
+
if not query_tokens:
|
|
183
|
+
return 0.0
|
|
184
|
+
q_set = {cls.stem_token(t) for t in query_tokens}
|
|
185
|
+
d_set = {cls.stem_token(t) for t in doc_tokens}
|
|
186
|
+
overlap = sum(1 for q in q_set if any(cls._match_tokens(q, d) for d in d_set))
|
|
187
|
+
return overlap / len(q_set) if q_set else 0.0
|
|
188
|
+
|
|
189
|
+
@classmethod
|
|
190
|
+
def bm25_score(
|
|
191
|
+
cls,
|
|
192
|
+
query_tokens: List[str],
|
|
193
|
+
doc_tokens: List[str],
|
|
194
|
+
corpus_doc_freq: Dict[str, int],
|
|
195
|
+
total_docs: int,
|
|
196
|
+
avg_doc_len: float,
|
|
197
|
+
k1: float = 1.5,
|
|
198
|
+
b: float = 0.75,
|
|
199
|
+
) -> float:
|
|
200
|
+
"""
|
|
201
|
+
Calculates normalized BM25 score in range [0.0, 1.0].
|
|
202
|
+
"""
|
|
203
|
+
if not query_tokens or not doc_tokens:
|
|
204
|
+
return 0.0
|
|
205
|
+
|
|
206
|
+
q_stemmed = [cls.stem_token(t) for t in query_tokens]
|
|
207
|
+
d_stemmed = [cls.stem_token(t) for t in doc_tokens]
|
|
208
|
+
|
|
209
|
+
doc_len = len(d_stemmed)
|
|
210
|
+
doc_counts = Counter(d_stemmed)
|
|
211
|
+
score = 0.0
|
|
212
|
+
|
|
213
|
+
matched_terms = 0
|
|
214
|
+
total_q_terms = len(set(q_stemmed))
|
|
215
|
+
|
|
216
|
+
for q in set(q_stemmed):
|
|
217
|
+
df = corpus_doc_freq.get(q, 1)
|
|
218
|
+
idf = math.log((total_docs - df + 0.5) / (df + 0.5) + 1.0)
|
|
219
|
+
if idf < 0:
|
|
220
|
+
idf = 0.1
|
|
221
|
+
|
|
222
|
+
tf = sum(count for term, count in doc_counts.items() if cls._match_tokens(q, term))
|
|
223
|
+
if tf > 0:
|
|
224
|
+
matched_terms += 1
|
|
225
|
+
numerator = tf * (k1 + 1.0)
|
|
226
|
+
denominator = tf + k1 * (1.0 - b + b * (doc_len / (avg_doc_len or 1.0)))
|
|
227
|
+
term_score = (numerator / denominator)
|
|
228
|
+
score += idf * term_score
|
|
229
|
+
|
|
230
|
+
coverage = matched_terms / total_q_terms if total_q_terms > 0 else 0.0
|
|
231
|
+
saturation = (score / (total_q_terms * (k1 + 1.0))) if total_q_terms > 0 else 0.0
|
|
232
|
+
normalized = 0.6 * coverage + 0.4 * min(1.0, saturation * 1.5)
|
|
233
|
+
return min(1.0, max(0.0, normalized))
|
|
234
|
+
|
|
235
|
+
@classmethod
|
|
236
|
+
def string_similarity(cls, str1: str, str2: str) -> float:
|
|
237
|
+
"""Calculates SequenceMatcher string similarity ratio."""
|
|
238
|
+
return difflib.SequenceMatcher(None, str1.lower().strip(), str2.lower().strip()).ratio()
|
|
239
|
+
|
|
240
|
+
@classmethod
|
|
241
|
+
def composite_similarity(
|
|
242
|
+
cls,
|
|
243
|
+
query: str,
|
|
244
|
+
target_text: str,
|
|
245
|
+
corpus_doc_freq: Optional[Dict[str, int]] = None,
|
|
246
|
+
total_docs: int = 1,
|
|
247
|
+
avg_doc_len: float = 20.0,
|
|
248
|
+
) -> float:
|
|
249
|
+
"""
|
|
250
|
+
Calculates weighted composite similarity score combining BM25,
|
|
251
|
+
Jaccard similarity, query token overlap, and string distance.
|
|
252
|
+
"""
|
|
253
|
+
q_tokens = cls.tokenize(query)
|
|
254
|
+
t_tokens = cls.tokenize(target_text)
|
|
255
|
+
|
|
256
|
+
if not q_tokens or not t_tokens:
|
|
257
|
+
return 0.0
|
|
258
|
+
|
|
259
|
+
jaccard = cls.jaccard_similarity(q_tokens, t_tokens)
|
|
260
|
+
overlap = cls.token_overlap(q_tokens, t_tokens)
|
|
261
|
+
str_sim = cls.string_similarity(query, target_text)
|
|
262
|
+
|
|
263
|
+
bm25 = 0.0
|
|
264
|
+
if corpus_doc_freq and total_docs > 0:
|
|
265
|
+
bm25 = cls.bm25_score(
|
|
266
|
+
query_tokens=q_tokens,
|
|
267
|
+
doc_tokens=t_tokens,
|
|
268
|
+
corpus_doc_freq=corpus_doc_freq,
|
|
269
|
+
total_docs=total_docs,
|
|
270
|
+
avg_doc_len=avg_doc_len,
|
|
271
|
+
)
|
|
272
|
+
else:
|
|
273
|
+
bm25 = overlap
|
|
274
|
+
|
|
275
|
+
# Composite score
|
|
276
|
+
composite = 0.40 * overlap + 0.30 * bm25 + 0.20 * jaccard + 0.10 * str_sim
|
|
277
|
+
return min(1.0, max(0.0, composite))
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
class DedupEngine:
|
|
281
|
+
"""
|
|
282
|
+
Repository and Request Deduplication Engine for K-CLI.
|
|
283
|
+
|
|
284
|
+
Identifies duplicate tasks, redundant code additions, existing AST symbols,
|
|
285
|
+
and past git commits to prevent duplicative work across large projects.
|
|
286
|
+
"""
|
|
287
|
+
|
|
288
|
+
def __init__(
|
|
289
|
+
self,
|
|
290
|
+
repo_path: str = ".",
|
|
291
|
+
duplicate_threshold: float = 0.65,
|
|
292
|
+
git_depth: int = 50,
|
|
293
|
+
):
|
|
294
|
+
"""
|
|
295
|
+
Initializes DedupEngine.
|
|
296
|
+
|
|
297
|
+
Args:
|
|
298
|
+
repo_path: Target repository path.
|
|
299
|
+
duplicate_threshold: Confidence score threshold (0.0 to 1.0) to mark as duplicate.
|
|
300
|
+
git_depth: Max commit history depth to scan by default.
|
|
301
|
+
"""
|
|
302
|
+
self.repo_path = Path(repo_path).resolve()
|
|
303
|
+
self.duplicate_threshold = duplicate_threshold
|
|
304
|
+
self.git_depth = git_depth
|
|
305
|
+
self._repo_map: Optional[Any] = None
|
|
306
|
+
self._cached_symbols: Optional[List[SymbolRecord]] = None
|
|
307
|
+
self._cached_commits: Optional[List[CommitRecord]] = None
|
|
308
|
+
|
|
309
|
+
def get_repo_map(self) -> Any:
|
|
310
|
+
"""Lazy loads RepoMap instance."""
|
|
311
|
+
if self._repo_map is None and RepoMap is not None:
|
|
312
|
+
self._repo_map = RepoMap(root_dir=str(self.repo_path))
|
|
313
|
+
return self._repo_map
|
|
314
|
+
|
|
315
|
+
# =========================================================================
|
|
316
|
+
# Git History Indexing & Extraction
|
|
317
|
+
# =========================================================================
|
|
318
|
+
|
|
319
|
+
def get_git_commits(self, depth: Optional[int] = None) -> List[CommitRecord]:
|
|
320
|
+
"""
|
|
321
|
+
Extracts recent commit records, messages, diff stats, and issue references.
|
|
322
|
+
|
|
323
|
+
Args:
|
|
324
|
+
depth: Max number of commits to retrieve.
|
|
325
|
+
|
|
326
|
+
Returns:
|
|
327
|
+
List of CommitRecord objects.
|
|
328
|
+
"""
|
|
329
|
+
if self._cached_commits is not None and (depth is None or len(self._cached_commits) >= depth):
|
|
330
|
+
return self._cached_commits[:depth] if depth else self._cached_commits
|
|
331
|
+
|
|
332
|
+
max_depth = depth or self.git_depth
|
|
333
|
+
commits: List[CommitRecord] = []
|
|
334
|
+
|
|
335
|
+
if not self.repo_path.exists() or not (self.repo_path / ".git").exists():
|
|
336
|
+
return []
|
|
337
|
+
|
|
338
|
+
sep_field = "%x1f"
|
|
339
|
+
sep_record = "%x1e"
|
|
340
|
+
format_str = f"COMMIT_START{sep_field}%H{sep_field}%h{sep_field}%an{sep_field}%ad{sep_field}%s{sep_field}%b{sep_record}"
|
|
341
|
+
|
|
342
|
+
cmd = [
|
|
343
|
+
"git",
|
|
344
|
+
"log",
|
|
345
|
+
f"-n{max_depth}",
|
|
346
|
+
f"--pretty=format:{format_str}",
|
|
347
|
+
"--stat",
|
|
348
|
+
]
|
|
349
|
+
|
|
350
|
+
try:
|
|
351
|
+
res = subprocess.run(
|
|
352
|
+
cmd,
|
|
353
|
+
cwd=str(self.repo_path),
|
|
354
|
+
capture_output=True,
|
|
355
|
+
text=True,
|
|
356
|
+
errors="ignore",
|
|
357
|
+
)
|
|
358
|
+
if res.returncode != 0 or not res.stdout.strip():
|
|
359
|
+
return []
|
|
360
|
+
|
|
361
|
+
raw_entries = res.stdout.split("COMMIT_START\x1f")
|
|
362
|
+
issue_regex = re.compile(r"\b(?:closes?|closed|fixes?|fixed|resolves?|resolved|pr|issue|pull\s+request)\s*#?(\d+)\b", re.IGNORECASE)
|
|
363
|
+
|
|
364
|
+
for entry in raw_entries:
|
|
365
|
+
if not entry.strip():
|
|
366
|
+
continue
|
|
367
|
+
|
|
368
|
+
parts = entry.split("\x1e", 1)
|
|
369
|
+
meta_part = parts[0]
|
|
370
|
+
stat_part = parts[1] if len(parts) > 1 else ""
|
|
371
|
+
|
|
372
|
+
fields = meta_part.split("\x1f")
|
|
373
|
+
if len(fields) < 5:
|
|
374
|
+
continue
|
|
375
|
+
|
|
376
|
+
commit_hash = fields[0].strip()
|
|
377
|
+
short_hash = fields[1].strip()
|
|
378
|
+
author = fields[2].strip()
|
|
379
|
+
date = fields[3].strip()
|
|
380
|
+
subject = fields[4].strip()
|
|
381
|
+
body = fields[5].strip() if len(fields) > 5 else ""
|
|
382
|
+
|
|
383
|
+
# Extract modified files from stat
|
|
384
|
+
files_changed: List[str] = []
|
|
385
|
+
for line in stat_part.splitlines():
|
|
386
|
+
if "|" in line:
|
|
387
|
+
file_token = line.split("|")[0].strip()
|
|
388
|
+
if file_token:
|
|
389
|
+
files_changed.append(file_token)
|
|
390
|
+
|
|
391
|
+
# Extract issue references
|
|
392
|
+
full_msg = f"{subject}\n{body}"
|
|
393
|
+
issue_refs: List[str] = []
|
|
394
|
+
for match in issue_regex.finditer(full_msg):
|
|
395
|
+
issue_refs.append(f"#{match.group(1)}")
|
|
396
|
+
|
|
397
|
+
record = CommitRecord(
|
|
398
|
+
commit_hash=commit_hash,
|
|
399
|
+
short_hash=short_hash,
|
|
400
|
+
author=author,
|
|
401
|
+
date=date,
|
|
402
|
+
subject=subject,
|
|
403
|
+
body=body,
|
|
404
|
+
files=files_changed,
|
|
405
|
+
diff_stat=stat_part.strip(),
|
|
406
|
+
issue_refs=sorted(set(issue_refs)),
|
|
407
|
+
)
|
|
408
|
+
commits.append(record)
|
|
409
|
+
|
|
410
|
+
except Exception:
|
|
411
|
+
return []
|
|
412
|
+
|
|
413
|
+
self._cached_commits = commits
|
|
414
|
+
return commits
|
|
415
|
+
|
|
416
|
+
# =========================================================================
|
|
417
|
+
# AST Symbol Table Extraction & Indexing
|
|
418
|
+
# =========================================================================
|
|
419
|
+
|
|
420
|
+
def get_ast_symbols(self) -> List[SymbolRecord]:
|
|
421
|
+
"""
|
|
422
|
+
Extracts all AST symbol records across supported source files in the repository.
|
|
423
|
+
|
|
424
|
+
Returns:
|
|
425
|
+
List of SymbolRecord objects.
|
|
426
|
+
"""
|
|
427
|
+
if self._cached_symbols is not None:
|
|
428
|
+
return self._cached_symbols
|
|
429
|
+
|
|
430
|
+
rm = self.get_repo_map()
|
|
431
|
+
symbols: List[SymbolRecord] = []
|
|
432
|
+
|
|
433
|
+
if rm is None:
|
|
434
|
+
return []
|
|
435
|
+
|
|
436
|
+
try:
|
|
437
|
+
files = rm.scan_workspace_files()
|
|
438
|
+
for fpath in files:
|
|
439
|
+
try:
|
|
440
|
+
rel_p = rm._resolve_relative_path(fpath)
|
|
441
|
+
raw_symbols = rm.extract_symbols(fpath)
|
|
442
|
+
for sym in raw_symbols:
|
|
443
|
+
name = sym.get("name", "")
|
|
444
|
+
sym_type = sym.get("type", "symbol")
|
|
445
|
+
lineno = sym.get("lineno", sym.get("line_number", 1))
|
|
446
|
+
end_lineno = sym.get("end_lineno", lineno)
|
|
447
|
+
signature = sym.get("signature", name)
|
|
448
|
+
docstring = sym.get("docstring")
|
|
449
|
+
|
|
450
|
+
symbols.append(SymbolRecord(
|
|
451
|
+
name=name,
|
|
452
|
+
type=sym_type,
|
|
453
|
+
file_path=fpath,
|
|
454
|
+
rel_path=rel_p,
|
|
455
|
+
line_number=lineno,
|
|
456
|
+
end_lineno=end_lineno,
|
|
457
|
+
signature=signature,
|
|
458
|
+
docstring=docstring,
|
|
459
|
+
))
|
|
460
|
+
except Exception:
|
|
461
|
+
continue
|
|
462
|
+
except Exception:
|
|
463
|
+
return []
|
|
464
|
+
|
|
465
|
+
self._cached_symbols = symbols
|
|
466
|
+
return symbols
|
|
467
|
+
|
|
468
|
+
# =========================================================================
|
|
469
|
+
# Similarity Scans
|
|
470
|
+
# =========================================================================
|
|
471
|
+
|
|
472
|
+
def scan_git_history(
|
|
473
|
+
self,
|
|
474
|
+
query: str,
|
|
475
|
+
repo_path: Optional[str] = None,
|
|
476
|
+
git_depth: int = 50,
|
|
477
|
+
) -> List[Tuple[float, CommitRecord]]:
|
|
478
|
+
"""
|
|
479
|
+
Scans git commit history for matching commits based on query.
|
|
480
|
+
|
|
481
|
+
Returns:
|
|
482
|
+
Sorted list of (similarity_score, CommitRecord) tuples in descending score order.
|
|
483
|
+
"""
|
|
484
|
+
commits = self.get_git_commits(depth=git_depth)
|
|
485
|
+
if not commits or not query.strip():
|
|
486
|
+
return []
|
|
487
|
+
|
|
488
|
+
doc_tokens_list = []
|
|
489
|
+
corpus_df: Dict[str, int] = defaultdict(int)
|
|
490
|
+
|
|
491
|
+
for c in commits:
|
|
492
|
+
doc_text = f"{c.subject} {c.body} {' '.join(c.files)} {' '.join(c.issue_refs)}"
|
|
493
|
+
toks = SimilarityScorer.tokenize(doc_text, stem=True)
|
|
494
|
+
doc_tokens_list.append(toks)
|
|
495
|
+
for t in set(toks):
|
|
496
|
+
corpus_df[t] += 1
|
|
497
|
+
|
|
498
|
+
total_docs = len(commits)
|
|
499
|
+
avg_len = sum(len(t) for t in doc_tokens_list) / (total_docs or 1)
|
|
500
|
+
|
|
501
|
+
results: List[Tuple[float, CommitRecord]] = []
|
|
502
|
+
for i, c in enumerate(commits):
|
|
503
|
+
doc_text = f"{c.subject} {c.body} {' '.join(c.files)} {' '.join(c.issue_refs)}"
|
|
504
|
+
sim = SimilarityScorer.composite_similarity(
|
|
505
|
+
query=query,
|
|
506
|
+
target_text=doc_text,
|
|
507
|
+
corpus_doc_freq=corpus_df,
|
|
508
|
+
total_docs=total_docs,
|
|
509
|
+
avg_doc_len=avg_len,
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
# Direct subject match boost
|
|
513
|
+
subj_sim = SimilarityScorer.string_similarity(query, c.subject)
|
|
514
|
+
q_tokens = set(SimilarityScorer.tokenize(query, stem=True))
|
|
515
|
+
s_tokens = set(SimilarityScorer.tokenize(c.subject, stem=True))
|
|
516
|
+
if q_tokens and s_tokens and len(q_tokens & s_tokens) / len(q_tokens) >= 0.7:
|
|
517
|
+
sim = max(sim, 0.88)
|
|
518
|
+
elif subj_sim > 0.6:
|
|
519
|
+
sim = max(sim, subj_sim)
|
|
520
|
+
|
|
521
|
+
if sim > 0.15:
|
|
522
|
+
results.append((sim, c))
|
|
523
|
+
|
|
524
|
+
results.sort(key=lambda x: x[0], reverse=True)
|
|
525
|
+
return results
|
|
526
|
+
|
|
527
|
+
def scan_ast_symbols(
|
|
528
|
+
self,
|
|
529
|
+
query: str,
|
|
530
|
+
repo_path: Optional[str] = None,
|
|
531
|
+
) -> List[Tuple[float, SymbolRecord]]:
|
|
532
|
+
"""
|
|
533
|
+
Scans codebase AST symbols (classes, functions, methods, structs) for matches.
|
|
534
|
+
|
|
535
|
+
Returns:
|
|
536
|
+
Sorted list of (similarity_score, SymbolRecord) tuples in descending score order.
|
|
537
|
+
"""
|
|
538
|
+
symbols = self.get_ast_symbols()
|
|
539
|
+
if not symbols or not query.strip():
|
|
540
|
+
return []
|
|
541
|
+
|
|
542
|
+
doc_tokens_list = []
|
|
543
|
+
corpus_df: Dict[str, int] = defaultdict(int)
|
|
544
|
+
|
|
545
|
+
for sym in symbols:
|
|
546
|
+
doc_text = f"{sym.name} {sym.type} {sym.signature} {sym.docstring or ''} {os.path.basename(sym.rel_path)}"
|
|
547
|
+
toks = SimilarityScorer.tokenize(doc_text, stem=True)
|
|
548
|
+
doc_tokens_list.append(toks)
|
|
549
|
+
for t in set(toks):
|
|
550
|
+
corpus_df[t] += 1
|
|
551
|
+
|
|
552
|
+
total_docs = len(symbols)
|
|
553
|
+
avg_len = sum(len(t) for t in doc_tokens_list) / (total_docs or 1)
|
|
554
|
+
|
|
555
|
+
results: List[Tuple[float, SymbolRecord]] = []
|
|
556
|
+
for i, sym in enumerate(symbols):
|
|
557
|
+
doc_text = f"{sym.name} {sym.type} {sym.signature} {sym.docstring or ''} {os.path.basename(sym.rel_path)}"
|
|
558
|
+
sim = SimilarityScorer.composite_similarity(
|
|
559
|
+
query=query,
|
|
560
|
+
target_text=doc_text,
|
|
561
|
+
corpus_doc_freq=corpus_df,
|
|
562
|
+
total_docs=total_docs,
|
|
563
|
+
avg_doc_len=avg_len,
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
# Symbol name containment boost:
|
|
567
|
+
name_tokens = set(SimilarityScorer.tokenize(sym.name, stem=True))
|
|
568
|
+
query_tokens = set(SimilarityScorer.tokenize(query, stem=True))
|
|
569
|
+
if query_tokens and name_tokens:
|
|
570
|
+
name_overlap = sum(1 for n in name_tokens if any(SimilarityScorer._match_tokens(n, q) for q in query_tokens)) / len(name_tokens)
|
|
571
|
+
if name_overlap >= 0.8:
|
|
572
|
+
sim = max(sim, 0.90)
|
|
573
|
+
elif name_overlap >= 0.5:
|
|
574
|
+
sim = max(sim, 0.75)
|
|
575
|
+
|
|
576
|
+
name_sim = SimilarityScorer.string_similarity(query, sym.name)
|
|
577
|
+
if name_sim > 0.7:
|
|
578
|
+
sim = max(sim, name_sim)
|
|
579
|
+
|
|
580
|
+
if sim > 0.15:
|
|
581
|
+
results.append((sim, sym))
|
|
582
|
+
|
|
583
|
+
results.sort(key=lambda x: x[0], reverse=True)
|
|
584
|
+
return results
|
|
585
|
+
|
|
586
|
+
# =========================================================================
|
|
587
|
+
# Primary Deduplication Interface
|
|
588
|
+
# =========================================================================
|
|
589
|
+
|
|
590
|
+
def scan_for_duplicate(
|
|
591
|
+
self,
|
|
592
|
+
query: str,
|
|
593
|
+
repo_path: str = ".",
|
|
594
|
+
git_depth: int = 50,
|
|
595
|
+
) -> Optional[DedupMatch]:
|
|
596
|
+
"""
|
|
597
|
+
Scans repository commits and codebase AST symbol tables for duplicates.
|
|
598
|
+
|
|
599
|
+
Args:
|
|
600
|
+
query: User request, feature description, or code prompt.
|
|
601
|
+
repo_path: Repository workspace path.
|
|
602
|
+
git_depth: Commit depth to inspect.
|
|
603
|
+
|
|
604
|
+
Returns:
|
|
605
|
+
DedupMatch object with duplicate status, confidence score, and explanation.
|
|
606
|
+
"""
|
|
607
|
+
clean_query = query.strip()
|
|
608
|
+
if not clean_query:
|
|
609
|
+
return DedupMatch(
|
|
610
|
+
is_duplicate=False,
|
|
611
|
+
confidence=0.0,
|
|
612
|
+
explanation="Empty query provided.",
|
|
613
|
+
match_type="none",
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
# 1. Scan Git commits
|
|
617
|
+
commit_matches = self.scan_git_history(query=clean_query, repo_path=repo_path, git_depth=git_depth)
|
|
618
|
+
top_commit: Optional[Tuple[float, CommitRecord]] = commit_matches[0] if commit_matches else None
|
|
619
|
+
|
|
620
|
+
# 2. Scan AST symbols
|
|
621
|
+
symbol_matches = self.scan_ast_symbols(query=clean_query, repo_path=repo_path)
|
|
622
|
+
top_symbol: Optional[Tuple[float, SymbolRecord]] = symbol_matches[0] if symbol_matches else None
|
|
623
|
+
|
|
624
|
+
commit_score = top_commit[0] if top_commit else 0.0
|
|
625
|
+
symbol_score = top_symbol[0] if top_symbol else 0.0
|
|
626
|
+
|
|
627
|
+
if symbol_score >= commit_score and top_symbol is not None:
|
|
628
|
+
score, sym = top_symbol
|
|
629
|
+
is_dup = score >= self.duplicate_threshold
|
|
630
|
+
expl = (
|
|
631
|
+
f"Matched existing {sym.type} `{sym.name}` in `{sym.rel_path}` (lines {sym.line_number}-{sym.end_lineno}) "
|
|
632
|
+
f"with {score:.1%} confidence."
|
|
633
|
+
)
|
|
634
|
+
return DedupMatch(
|
|
635
|
+
is_duplicate=is_dup,
|
|
636
|
+
confidence=score,
|
|
637
|
+
existing_commit=None,
|
|
638
|
+
file_path=sym.file_path,
|
|
639
|
+
line_range=(sym.line_number, sym.end_lineno),
|
|
640
|
+
explanation=expl,
|
|
641
|
+
match_type="symbol",
|
|
642
|
+
metadata={
|
|
643
|
+
"symbol_name": sym.name,
|
|
644
|
+
"symbol_type": sym.type,
|
|
645
|
+
"signature": sym.signature,
|
|
646
|
+
"rel_path": sym.rel_path,
|
|
647
|
+
},
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
elif top_commit is not None:
|
|
651
|
+
score, c = top_commit
|
|
652
|
+
is_dup = score >= self.duplicate_threshold
|
|
653
|
+
first_file = c.files[0] if c.files else None
|
|
654
|
+
issue_str = f" referencing {', '.join(c.issue_refs)}" if c.issue_refs else ""
|
|
655
|
+
expl = (
|
|
656
|
+
f"Matched existing commit {c.short_hash} ('{c.subject}'){issue_str} "
|
|
657
|
+
f"with {score:.1%} confidence."
|
|
658
|
+
)
|
|
659
|
+
return DedupMatch(
|
|
660
|
+
is_duplicate=is_dup,
|
|
661
|
+
confidence=score,
|
|
662
|
+
existing_commit=c.commit_hash,
|
|
663
|
+
file_path=str(self.repo_path / first_file) if first_file else None,
|
|
664
|
+
line_range=None,
|
|
665
|
+
explanation=expl,
|
|
666
|
+
match_type="commit",
|
|
667
|
+
metadata={
|
|
668
|
+
"commit_hash": c.commit_hash,
|
|
669
|
+
"short_hash": c.short_hash,
|
|
670
|
+
"subject": c.subject,
|
|
671
|
+
"author": c.author,
|
|
672
|
+
"date": c.date,
|
|
673
|
+
"files": c.files,
|
|
674
|
+
"issue_refs": c.issue_refs,
|
|
675
|
+
},
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
return DedupMatch(
|
|
679
|
+
is_duplicate=False,
|
|
680
|
+
confidence=0.0,
|
|
681
|
+
explanation="No matching existing commits or AST symbols found in repository.",
|
|
682
|
+
match_type="none",
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
# =========================================================================
|
|
686
|
+
# Additional Duplicate Helpers
|
|
687
|
+
# =========================================================================
|
|
688
|
+
|
|
689
|
+
def find_duplicate_symbols(
|
|
690
|
+
self,
|
|
691
|
+
symbol_name: str,
|
|
692
|
+
repo_path: str = ".",
|
|
693
|
+
) -> List[Dict[str, Any]]:
|
|
694
|
+
"""
|
|
695
|
+
Finds existing AST symbols with identical or highly similar names.
|
|
696
|
+
|
|
697
|
+
Args:
|
|
698
|
+
symbol_name: Target symbol identifier name.
|
|
699
|
+
repo_path: Workspace directory.
|
|
700
|
+
|
|
701
|
+
Returns:
|
|
702
|
+
List of matching symbol detail dictionaries.
|
|
703
|
+
"""
|
|
704
|
+
matches = self.scan_ast_symbols(query=symbol_name, repo_path=repo_path)
|
|
705
|
+
output: List[Dict[str, Any]] = []
|
|
706
|
+
|
|
707
|
+
for score, sym in matches:
|
|
708
|
+
if score >= 0.5:
|
|
709
|
+
output.append({
|
|
710
|
+
"name": sym.name,
|
|
711
|
+
"type": sym.type,
|
|
712
|
+
"file_path": sym.file_path,
|
|
713
|
+
"rel_path": sym.rel_path,
|
|
714
|
+
"line_number": sym.line_number,
|
|
715
|
+
"end_lineno": sym.end_lineno,
|
|
716
|
+
"signature": sym.signature,
|
|
717
|
+
"confidence": round(score, 4),
|
|
718
|
+
})
|
|
719
|
+
return output
|
|
720
|
+
|
|
721
|
+
def find_duplicate_code_snippets(
|
|
722
|
+
self,
|
|
723
|
+
code_snippet: str,
|
|
724
|
+
repo_path: str = ".",
|
|
725
|
+
threshold: float = 0.7,
|
|
726
|
+
) -> List[Dict[str, Any]]:
|
|
727
|
+
"""
|
|
728
|
+
Finds source files in repository containing duplicate or near-identical code blocks.
|
|
729
|
+
|
|
730
|
+
Args:
|
|
731
|
+
code_snippet: Target code snippet to search for.
|
|
732
|
+
repo_path: Repository path.
|
|
733
|
+
threshold: Minimum token similarity threshold.
|
|
734
|
+
|
|
735
|
+
Returns:
|
|
736
|
+
List of match dictionaries with file path and similarity score.
|
|
737
|
+
"""
|
|
738
|
+
clean_snippet = code_snippet.strip()
|
|
739
|
+
if not clean_snippet:
|
|
740
|
+
return []
|
|
741
|
+
|
|
742
|
+
rm = self.get_repo_map()
|
|
743
|
+
if rm is None:
|
|
744
|
+
return []
|
|
745
|
+
|
|
746
|
+
snippet_tokens = SimilarityScorer.tokenize(clean_snippet)
|
|
747
|
+
if not snippet_tokens:
|
|
748
|
+
return []
|
|
749
|
+
|
|
750
|
+
dedented_snippet = textwrap.dedent(clean_snippet)
|
|
751
|
+
matches: List[Dict[str, Any]] = []
|
|
752
|
+
files = rm.scan_workspace_files()
|
|
753
|
+
|
|
754
|
+
for fpath in files:
|
|
755
|
+
try:
|
|
756
|
+
content = Path(fpath).read_text(encoding="utf-8", errors="ignore")
|
|
757
|
+
file_tokens = SimilarityScorer.tokenize(content)
|
|
758
|
+
if not file_tokens:
|
|
759
|
+
continue
|
|
760
|
+
|
|
761
|
+
overlap = SimilarityScorer.token_overlap(snippet_tokens, file_tokens)
|
|
762
|
+
jaccard = SimilarityScorer.jaccard_similarity(snippet_tokens, file_tokens)
|
|
763
|
+
score = 0.6 * overlap + 0.4 * jaccard
|
|
764
|
+
|
|
765
|
+
is_exact = (
|
|
766
|
+
clean_snippet in content
|
|
767
|
+
or dedented_snippet in textwrap.dedent(content)
|
|
768
|
+
or overlap == 1.0
|
|
769
|
+
)
|
|
770
|
+
|
|
771
|
+
if score >= threshold or is_exact:
|
|
772
|
+
confidence = 1.0 if is_exact else score
|
|
773
|
+
matches.append({
|
|
774
|
+
"file_path": fpath,
|
|
775
|
+
"rel_path": rm._resolve_relative_path(fpath),
|
|
776
|
+
"confidence": round(confidence, 4),
|
|
777
|
+
"exact_match": is_exact,
|
|
778
|
+
})
|
|
779
|
+
except Exception:
|
|
780
|
+
continue
|
|
781
|
+
|
|
782
|
+
matches.sort(key=lambda x: x["confidence"], reverse=True)
|
|
783
|
+
return matches
|
|
784
|
+
|
|
785
|
+
def calculate_similarity(self, text1: str, text2: str) -> float:
|
|
786
|
+
"""Utility shortcut for calculating composite similarity between two text strings."""
|
|
787
|
+
return SimilarityScorer.composite_similarity(text1, text2)
|