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/.ignore +41 -0
- codeecho/__init__.py +22 -0
- codeecho/__main__.py +289 -0
- codeecho/db.py +289 -0
- codeecho/detector.py +246 -0
- codeecho/extractor.py +139 -0
- codeecho/fingerprint.py +38 -0
- codeecho/logging.ini +28 -0
- codeecho/models.py +52 -0
- codeecho/normalizer.py +519 -0
- codeecho/parser.py +94 -0
- codeecho/reporter/__init__.py +6 -0
- codeecho/reporter/html_reporter.py +212 -0
- codeecho/reporter/json_reporter.py +82 -0
- codeecho/scanner.py +109 -0
- codeecho-1.0.0.dist-info/METADATA +208 -0
- codeecho-1.0.0.dist-info/RECORD +20 -0
- codeecho-1.0.0.dist-info/WHEEL +4 -0
- codeecho-1.0.0.dist-info/entry_points.txt +3 -0
- codeecho-1.0.0.dist-info/licenses/LICENSE +21 -0
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
|
codeecho/fingerprint.py
ADDED
|
@@ -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
|