codeecho 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.
codeecho/detector.py ADDED
@@ -0,0 +1,246 @@
1
+ """
2
+ Clone detection engine: Type-1 (exact), Type-2 (renamed identifiers), Type-3 (near-duplicate).
3
+
4
+ Detection strategy
5
+ ------------------
6
+ * **Type-1** – fragments sharing the same ``raw_hash`` are exact clones.
7
+ * **Type-2** – fragments sharing the same ``normalized_hash`` (but different ``raw_hash``) are
8
+ structural clones with renamed identifiers / literals.
9
+ * **Type-3** – remaining fragments compared pairwise using Jaccard similarity on their token
10
+ sets; pairs above *threshold* are grouped with a union-find algorithm.
11
+
12
+ :author: Ron Webb
13
+ :since: 1.0.0
14
+ """
15
+
16
+ import logging
17
+ import uuid
18
+ from collections import defaultdict
19
+
20
+ from .db import SessionDB
21
+ from .models import CloneGroup, Fragment
22
+
23
+ _logger = logging.getLogger("codeecho.detector")
24
+
25
+ _MAX_SIZE_RATIO: float = 1.3
26
+
27
+
28
+ # ── Union-Find ──────────────────────────────────────────────────────────────
29
+
30
+
31
+ class _UnionFind:
32
+ """Lightweight union-find (disjoint-set) for merging Type-3 candidate pairs."""
33
+
34
+ def __init__(self) -> None:
35
+ self._parent: dict[str, str] = {}
36
+
37
+ def find(self, node: str) -> str:
38
+ """Return the representative of *node*'s set (with path compression)."""
39
+ if node not in self._parent:
40
+ self._parent[node] = node
41
+ if self._parent[node] != node:
42
+ self._parent[node] = self.find(self._parent[node])
43
+ return self._parent[node]
44
+
45
+ def union(self, node_a: str, node_b: str) -> None:
46
+ """Merge the sets containing *node_a* and *node_b*."""
47
+ root_a, root_b = self.find(node_a), self.find(node_b)
48
+ if root_a != root_b:
49
+ self._parent[root_a] = root_b
50
+
51
+ def groups(self, members: list[str]) -> dict[str, list[str]]:
52
+ """Return a mapping ``{representative: [member_ids]}`` for *members*."""
53
+ result: dict[str, list[str]] = defaultdict(list)
54
+ for item in members:
55
+ result[self.find(item)].append(item)
56
+ return {k: v for k, v in result.items() if len(v) >= 2}
57
+
58
+
59
+ # ── Helpers ──────────────────────────────────────────────────────────────────
60
+
61
+
62
+ def _jaccard(tokens_a: list[str], tokens_b: list[str]) -> float:
63
+ """Return the Jaccard similarity between two token lists (treated as sets)."""
64
+ set_a = set(tokens_a)
65
+ set_b = set(tokens_b)
66
+ union = set_a | set_b
67
+ if not union:
68
+ return 0.0
69
+ return len(set_a & set_b) / len(union)
70
+
71
+
72
+ def _make_group(
73
+ session_id: str,
74
+ clone_type: int,
75
+ fragment_ids: list[str],
76
+ rep_hash: str | None = None,
77
+ similarity: float | None = None,
78
+ ) -> CloneGroup:
79
+ return CloneGroup(
80
+ group_id=str(uuid.uuid4()),
81
+ session_id=session_id,
82
+ clone_type=clone_type,
83
+ representative_hash=rep_hash,
84
+ similarity_score=similarity,
85
+ member_fragment_ids=fragment_ids,
86
+ )
87
+
88
+
89
+ # ── Detection passes ─────────────────────────────────────────────────────────
90
+
91
+
92
+ def _detect_type1(
93
+ fragments: list[Fragment], session_id: str
94
+ ) -> tuple[list[CloneGroup], set[str]]:
95
+ """Group fragments by raw hash. Return groups and the set of assigned fragment IDs."""
96
+ by_hash: dict[str, list[str]] = defaultdict(list)
97
+ for frag in fragments:
98
+ if frag.raw_hash:
99
+ by_hash[frag.raw_hash].append(frag.fragment_id)
100
+
101
+ groups: list[CloneGroup] = []
102
+ assigned: set[str] = set()
103
+ for raw_hash, ids in by_hash.items():
104
+ if len(ids) >= 2:
105
+ groups.append(_make_group(session_id, 1, ids, rep_hash=raw_hash))
106
+ assigned.update(ids)
107
+ _logger.debug("Type-1: %d clone groups found.", len(groups))
108
+ return groups, assigned
109
+
110
+
111
+ def _detect_type2(
112
+ fragments: list[Fragment], assigned: set[str], session_id: str
113
+ ) -> tuple[list[CloneGroup], set[str]]:
114
+ """Group unassigned fragments by normalised hash."""
115
+ by_norm: dict[str, list[str]] = defaultdict(list)
116
+ for frag in fragments:
117
+ if frag.fragment_id not in assigned and frag.normalized_hash:
118
+ by_norm[frag.normalized_hash].append(frag.fragment_id)
119
+
120
+ groups: list[CloneGroup] = []
121
+ new_assigned: set[str] = set()
122
+ for norm_hash, ids in by_norm.items():
123
+ if len(ids) >= 2:
124
+ groups.append(_make_group(session_id, 2, ids, rep_hash=norm_hash))
125
+ new_assigned.update(ids)
126
+ _logger.debug("Type-2: %d clone groups found.", len(groups))
127
+ return groups, new_assigned
128
+
129
+
130
+ def _compare_pair(
131
+ frag_a: Fragment,
132
+ frag_b: Fragment,
133
+ threshold: float,
134
+ union_find: "_UnionFind",
135
+ pair_scores: dict[tuple[str, str], float],
136
+ ) -> None:
137
+ """Compare a single fragment pair and register a union if similarity >= threshold."""
138
+ count_a, count_b = frag_a.token_count, frag_b.token_count
139
+ if (
140
+ count_a == 0
141
+ or count_b == 0
142
+ or count_b / count_a > _MAX_SIZE_RATIO
143
+ or count_a / count_b > _MAX_SIZE_RATIO
144
+ ):
145
+ return
146
+ score = _jaccard(frag_a.token_sequence, frag_b.token_sequence)
147
+ if score >= threshold:
148
+ union_find.union(frag_a.fragment_id, frag_b.fragment_id)
149
+ pair_scores[(frag_a.fragment_id, frag_b.fragment_id)] = score
150
+
151
+
152
+ def _detect_type3(
153
+ fragments: list[Fragment],
154
+ assigned: set[str],
155
+ session_id: str,
156
+ threshold: float,
157
+ ) -> list[CloneGroup]:
158
+ """Pairwise Jaccard comparison for remaining fragments; clusters via union-find."""
159
+ candidates = [
160
+ f for f in fragments if f.fragment_id not in assigned and f.token_count > 0
161
+ ]
162
+ union_find = _UnionFind()
163
+ pair_scores: dict[tuple[str, str], float] = {}
164
+
165
+ for idx_a in range(len(candidates)): # pylint: disable=consider-using-enumerate
166
+ for idx_b in range(idx_a + 1, len(candidates)):
167
+ _compare_pair(
168
+ candidates[idx_a], candidates[idx_b], threshold, union_find, pair_scores
169
+ )
170
+
171
+ all_ids = [f.fragment_id for f in candidates]
172
+ raw_groups = union_find.groups(all_ids)
173
+
174
+ groups: list[CloneGroup] = []
175
+ for member_ids in raw_groups.values():
176
+ # Use the minimum pairwise similarity as a conservative group score
177
+ sim = _group_min_similarity(member_ids, pair_scores)
178
+ groups.append(_make_group(session_id, 3, member_ids, similarity=sim))
179
+
180
+ _logger.debug("Type-3: %d clone groups found.", len(groups))
181
+ return groups
182
+
183
+
184
+ def _group_min_similarity(
185
+ member_ids: list[str], pair_scores: dict[tuple[str, str], float]
186
+ ) -> float | None:
187
+ """Return the minimum pairwise similarity score among *member_ids*."""
188
+ scores: list[float] = []
189
+ for idx in range(len(member_ids)): # pylint: disable=consider-using-enumerate
190
+ for jdx in range(idx + 1, len(member_ids)):
191
+ pair = (member_ids[idx], member_ids[jdx])
192
+ score = pair_scores.get(pair)
193
+ if score is None:
194
+ score = pair_scores.get((pair[1], pair[0]))
195
+ if score is not None:
196
+ scores.append(score)
197
+ return min(scores) if scores else None
198
+
199
+
200
+ # ── Public API ───────────────────────────────────────────────────────────────
201
+
202
+
203
+ def detect(
204
+ session_db: SessionDB,
205
+ session_id: str,
206
+ detect_types: set[int],
207
+ threshold: float,
208
+ ) -> tuple[int, int, int]:
209
+ """Run all requested detection passes and persist results to *db*.
210
+
211
+ Args:
212
+ session_db: Open :class:`~codeecho.db.SessionDB` context.
213
+ session_id: Current scan session UUID.
214
+ detect_types: Set of clone types to detect (any subset of ``{1, 2, 3}``).
215
+ threshold: Jaccard similarity threshold for Type-3 detection.
216
+
217
+ Returns:
218
+ ``(type1_count, type2_count, type3_count)`` group counts.
219
+ """
220
+ fragments = session_db.get_fragments(session_id)
221
+ _logger.debug("Detecting clones in %d fragments.", len(fragments))
222
+
223
+ assigned: set[str] = set()
224
+ cnt1 = cnt2 = cnt3 = 0
225
+
226
+ if 1 in detect_types:
227
+ groups1, assigned1 = _detect_type1(fragments, session_id)
228
+ for group in groups1:
229
+ session_db.insert_clone_group(group)
230
+ assigned.update(assigned1)
231
+ cnt1 = len(groups1)
232
+
233
+ if 2 in detect_types:
234
+ groups2, assigned2 = _detect_type2(fragments, assigned, session_id)
235
+ for group in groups2:
236
+ session_db.insert_clone_group(group)
237
+ assigned.update(assigned2)
238
+ cnt2 = len(groups2)
239
+
240
+ if 3 in detect_types:
241
+ groups3 = _detect_type3(fragments, assigned, session_id, threshold)
242
+ for group in groups3:
243
+ session_db.insert_clone_group(group)
244
+ cnt3 = len(groups3)
245
+
246
+ return cnt1, cnt2, cnt3
codeecho/extractor.py ADDED
@@ -0,0 +1,139 @@
1
+ """
2
+ Fragment extractor: uses Tree-sitter queries to locate functions, classes, and file blocks.
3
+
4
+ For each matched AST node, the extractor builds a :class:`~codeecho.models.Fragment` with
5
+ raw and normalised token sequences, source text, and line numbers.
6
+
7
+ :author: Ron Webb
8
+ :since: 1.0.0
9
+ """
10
+
11
+ import logging
12
+ import uuid
13
+ from bisect import bisect_left
14
+ from pathlib import Path
15
+
16
+ from tree_sitter import Language, Query, QueryCursor, Tree
17
+
18
+ from . import normalizer
19
+ from .models import Fragment
20
+ from .parser import get_language
21
+
22
+ _logger = logging.getLogger("codeecho.extractor")
23
+
24
+ # ── Per-language fragment queries ──────────────────────────────────────────────
25
+ # Patterns are keyed by (language_name, fragment_type).
26
+ # Multiple node types are expressed as alternation: [(a) (b)] @fragment
27
+ _QUERIES: dict[tuple[str, str], str] = {
28
+ ("Python", "function"): "(function_definition) @fragment",
29
+ ("Python", "class"): "(class_definition) @fragment",
30
+ ("Python", "file"): "(module) @fragment",
31
+ (
32
+ "JavaScript",
33
+ "function",
34
+ ): "[(function_declaration) (function_expression) (arrow_function)] @fragment",
35
+ ("JavaScript", "class"): "(class_declaration) @fragment",
36
+ ("JavaScript", "file"): "(program) @fragment",
37
+ (
38
+ "TypeScript",
39
+ "function",
40
+ ): "[(function_declaration) (function_expression) (arrow_function)] @fragment",
41
+ ("TypeScript", "class"): "(class_declaration) @fragment",
42
+ ("TypeScript", "file"): "(program) @fragment",
43
+ ("Java", "function"): "[(method_declaration) (constructor_declaration)] @fragment",
44
+ ("Java", "class"): "(class_declaration) @fragment",
45
+ ("Java", "file"): "(program) @fragment",
46
+ ("Gosu", "function"): "[(method_declaration) (constructor_declaration)] @fragment",
47
+ ("Gosu", "class"): "(class_declaration) @fragment",
48
+ ("Gosu", "file"): "(program) @fragment",
49
+ ("Go", "function"): "[(function_declaration) (method_declaration)] @fragment",
50
+ ("Go", "class"): "(type_declaration) @fragment",
51
+ ("Go", "file"): "(source_file) @fragment",
52
+ }
53
+
54
+ # NOTE: Query objects are NOT cached — tree-sitter 0.26 Query instances carry internal
55
+ # cursor state that becomes stale when reused across different Tree objects.
56
+ _FRAGMENT_TYPES: tuple[str, ...] = ("function", "class", "file")
57
+
58
+
59
+ def _get_query(language_name: str, fragment_type: str, lang: Language) -> Query | None:
60
+ """Return a fresh :class:`tree_sitter.Query` for *language_name* / *fragment_type*."""
61
+ pattern = _QUERIES.get((language_name, fragment_type))
62
+ if pattern is None:
63
+ return None
64
+ try:
65
+ return Query(lang, pattern)
66
+ except Exception as exc: # pylint: disable=broad-exception-caught
67
+ _logger.warning(
68
+ "Failed to compile query %s/%s: %s", language_name, fragment_type, exc
69
+ )
70
+ return None
71
+
72
+
73
+ def extract_fragments( # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals
74
+ tree: Tree,
75
+ source_bytes: bytes,
76
+ file_path: Path,
77
+ language_name: str,
78
+ session_id: str,
79
+ min_tokens: int = 10,
80
+ ) -> list[Fragment]:
81
+ """Extract all fragment types from *tree* and return a list of populated :class:`Fragment` objects.
82
+
83
+ :param tree: Parsed tree-sitter tree for the file.
84
+ :param source_bytes: Raw UTF-8 source bytes of the file.
85
+ :param file_path: Absolute path of the source file.
86
+ :param language_name: Language name (e.g. ``"Python"``).
87
+ :param session_id: UUID of the current scan session.
88
+ :param min_tokens: Fragments with fewer raw tokens are discarded.
89
+ :returns: List of fully-populated :class:`Fragment` objects.
90
+ """
91
+ lang = get_language(language_name)
92
+ if lang is None:
93
+ return []
94
+
95
+ fragments: list[Fragment] = []
96
+ # Pre-compute sorted list of newline byte offsets for O(log n) line lookups.
97
+ newline_offsets: list[int] = [
98
+ i for i, b in enumerate(source_bytes) if b == ord(b"\n")
99
+ ]
100
+ for ftype in _FRAGMENT_TYPES:
101
+ query = _get_query(language_name, ftype, lang)
102
+ if query is None:
103
+ continue
104
+ cursor = QueryCursor(query)
105
+ captures = cursor.captures(tree.root_node)
106
+ # Eagerly extract ONLY byte ranges from nodes before cursor is freed.
107
+ # Do NOT access node.start_point / node.end_point — tree-sitter 0.26
108
+ # returns corrupted row values for some captured nodes.
109
+ node_ranges: list[tuple[int, int]] = [
110
+ (n.start_byte, n.end_byte) for n in captures.get("fragment", [])
111
+ ]
112
+ del cursor, captures # free tree-sitter objects before processing
113
+ for start_b, end_b in node_ranges:
114
+ src_text = source_bytes[start_b:end_b].decode("utf-8", errors="replace")
115
+ raw_tokens, norm_tokens = normalizer.tokenise_and_normalise(
116
+ src_text, language_name
117
+ )
118
+ if len(raw_tokens) < min_tokens:
119
+ continue
120
+ start_ln = bisect_left(newline_offsets, start_b) + 1
121
+ end_ln = bisect_left(newline_offsets, end_b) + 1
122
+ fragments.append(
123
+ Fragment(
124
+ fragment_id=str(uuid.uuid4()),
125
+ session_id=session_id,
126
+ file_path=str(file_path),
127
+ language=language_name,
128
+ fragment_type=ftype,
129
+ start_line=start_ln,
130
+ end_line=end_ln,
131
+ token_count=len(raw_tokens),
132
+ token_sequence=raw_tokens,
133
+ normalized_tokens=norm_tokens,
134
+ source_text=src_text,
135
+ )
136
+ )
137
+
138
+ _logger.debug("Extracted %d fragments from %s", len(fragments), file_path.name)
139
+ return fragments
@@ -0,0 +1,38 @@
1
+ """
2
+ SHA-256 fingerprinting of raw and normalised token sequences.
3
+
4
+ :author: Ron Webb
5
+ :since: 1.0.0
6
+ """
7
+
8
+ import hashlib
9
+ import logging
10
+
11
+ from .models import Fragment
12
+
13
+ _logger = logging.getLogger("codeecho.fingerprint")
14
+
15
+ _SEP: str = "|"
16
+
17
+
18
+ def hash_fragment(fragment: Fragment) -> None:
19
+ """Compute and set ``raw_hash`` and ``normalized_hash`` on *fragment* in place.
20
+
21
+ :param fragment: A :class:`~codeecho.models.Fragment` whose ``token_sequence``
22
+ and ``normalized_tokens`` are already populated.
23
+ """
24
+ fragment.raw_hash = _sha256(_SEP.join(fragment.token_sequence))
25
+ fragment.normalized_hash = _sha256(_SEP.join(fragment.normalized_tokens))
26
+ fragment.token_count = len(fragment.token_sequence)
27
+
28
+
29
+ def hash_all(fragments: list[Fragment]) -> None:
30
+ """Apply :func:`hash_fragment` to every item in *fragments*."""
31
+ for frag in fragments:
32
+ hash_fragment(frag)
33
+ _logger.debug("Hashed %d fragments.", len(fragments))
34
+
35
+
36
+ def _sha256(text: str) -> str:
37
+ """Return the hex-encoded SHA-256 digest of *text*."""
38
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
codeecho/logging.ini ADDED
@@ -0,0 +1,28 @@
1
+ [loggers]
2
+ keys=root
3
+
4
+ [handlers]
5
+ keys=consoleHandler,fileHandler
6
+
7
+ [formatters]
8
+ keys=logFormatter,consoleFormatter
9
+
10
+ [logger_root]
11
+ level=INFO
12
+ handlers=consoleHandler,fileHandler
13
+
14
+ [handler_consoleHandler]
15
+ class=StreamHandler
16
+ formatter=consoleFormatter
17
+ args=(sys.stderr,)
18
+
19
+ [handler_fileHandler]
20
+ class=FileHandler
21
+ formatter=logFormatter
22
+ args=('codeecho.log', 'a')
23
+
24
+ [formatter_logFormatter]
25
+ format=%(asctime)s [%(levelname)s] %(name)s - %(message)s
26
+
27
+ [formatter_consoleFormatter]
28
+ format=%(asctime)s - %(name)s - %(levelname)s - %(message)s
codeecho/models.py ADDED
@@ -0,0 +1,52 @@
1
+ """
2
+ Data models for the codeecho duplicate detection pipeline.
3
+
4
+ :author: Ron Webb
5
+ :since: 1.0.0
6
+ """
7
+
8
+ from dataclasses import dataclass, field
9
+
10
+
11
+ @dataclass(slots=True)
12
+ class Fragment: # pylint: disable=too-many-instance-attributes
13
+ """Represents a code fragment (function, class, or file block) extracted from a source file."""
14
+
15
+ fragment_id: str
16
+ session_id: str
17
+ file_path: str
18
+ language: str
19
+ fragment_type: str
20
+ start_line: int
21
+ end_line: int
22
+ token_count: int = 0
23
+ raw_hash: str | None = None
24
+ normalized_hash: str | None = None
25
+ token_sequence: list[str] = field(default_factory=list, repr=False)
26
+ normalized_tokens: list[str] = field(default_factory=list, repr=False)
27
+ source_text: str = ""
28
+
29
+
30
+ @dataclass
31
+ class CloneGroup:
32
+ """Represents a cluster of code fragments that are clones of each other."""
33
+
34
+ group_id: str
35
+ session_id: str
36
+ clone_type: int
37
+ representative_hash: str | None = None
38
+ similarity_score: float | None = None
39
+ member_fragment_ids: list[str] = field(default_factory=list)
40
+
41
+
42
+ @dataclass
43
+ class ScanResult:
44
+ """Summary statistics for a completed duplicate detection scan."""
45
+
46
+ session_id: str
47
+ scan_path: str
48
+ files_scanned: int
49
+ fragments_extracted: int
50
+ type1_groups: int
51
+ type2_groups: int
52
+ type3_groups: int