Ansel 0.0
A darktable fork - bloat + design vision
Loading...
Searching...
No Matches
clone_detect.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""Token-level clone detection between two source trees.
3
4Answers "how much code do these two codebases actually share?" in a way a line diff
5cannot. A line diff calls a reindented line changed, a reflowed argument list changed,
6and a renamed local variable changed. On a fork that has restyled its tree - Ansel
7converted 245 headers from `#pragma once` to include guards, so not one file is
8byte-identical to darktable - line comparison understates sharing badly.
9
10This works on TOKENS instead, using winnowing (Schleimer, Wilkerson & Aiken 2003), the
11algorithm behind MOSS:
12
13 1. tokenize, discarding whitespace and comments entirely
14 2. hash every k-gram of consecutive tokens
15 3. in each window of w consecutive hashes keep the minimum
16
17Step 3 is what makes it work. Selecting fingerprints by a property of the hashes rather
18than by position means the same code selects the same fingerprints wherever it sits in a
19file, so insertions and deletions elsewhere do not shift the match. It guarantees
20detecting any shared run of at least k + w - 1 tokens, while storing only about 1/w of
21the hashes.
22
23Two normalisations are reported, because they answer different questions:
24
25 strict identifiers kept. "Is this the same code?" Copy-paste with renaming
26 counts as different.
27 normalised identifiers, numbers and strings replaced by placeholders, keywords and
28 punctuation kept. "Is this the same code shape?" Catches a function
29 carried across and renamed, which for a fork is still inherited code.
30
31Usage:
32 python3 tools/clone_detect.py --a /path/to/tree-a --b /path/to/tree-b -o clones.json
33"""
34
35import argparse
36import json
37import os
38import re
39import sys
40from collections import defaultdict, deque
41
42SOURCE_SUFFIXES = (".c", ".cc", ".cpp", ".cxx", ".h", ".hpp", ".m", ".mm")
43EXCLUDED_DIR_PARTS = ("/external/", "/tests/integration/", "/image_test/samples/",
44 "/apps/ansel-chart/", "/doxygen-awesome-css/", "/.git/")
45
46# Order matters: comments and literals must be recognised before the operator rule,
47# or a '/' would be tokenised on its own and the comment body treated as code.
48TOKEN_RE = re.compile(r"""
49 (?P<ws>\s+)
50 | (?P<line_comment>//[^\n]*)
51 | (?P<block_comment>/\*.*?\*/)
52 | (?P<string>"(?:\\.|[^"\\])*")
53 | (?P<char>'(?:\\.|[^'\\])*')
54 | (?P<number>\.?\d[0-9A-Za-z_.]*(?:[eEpP][+-][0-9]+)?)
55 | (?P<ident>[A-Za-z_][A-Za-z0-9_]*)
56 | (?P<op>[^\sA-Za-z0-9_])
57""", re.VERBOSE | re.DOTALL)
58
59C_KEYWORDS = frozenset("""
60auto break case char const continue default do double else enum extern float for goto
61if inline int long register restrict return short signed sizeof static struct switch
62typedef union unsigned void volatile while _Bool _Complex _Atomic _Generic
63class namespace template typename public private protected virtual new delete this
64operator try catch throw using bool true false nullptr constexpr static_cast
65reinterpret_cast const_cast dynamic_cast
66""".split())
67
68MOD = (1 << 61) - 1 # Mersenne prime: cheap modular arithmetic, few collisions
69BASE = 1000003
70
71
72def is_excluded(path):
73 p = "/" + path.replace(os.sep, "/").lstrip("/")
74 return any(part in p for part in EXCLUDED_DIR_PARTS)
75
76
77def tokenize(text, normalise):
78 """Token strings, with comments and whitespace dropped."""
79 out = []
80 for m in TOKEN_RE.finditer(text):
81 kind = m.lastgroup
82 if kind in ("ws", "line_comment", "block_comment"):
83 continue
84 value = m.group()
85 if normalise:
86 if kind == "ident":
87 value = value if value in C_KEYWORDS else "\x01"
88 elif kind == "number":
89 value = "\x02"
90 elif kind in ("string", "char"):
91 value = "\x03"
92 out.append(value)
93 return out
94
95
96def fingerprints(tokens, k, w):
97 """Winnowed fingerprints of a token list, as a set of hashes.
98
99 The k-gram hashes are produced with a rolling polynomial hash, then winnowed with a
100 monotonic deque so the whole pass is linear rather than O(n*w).
101 """
102 n = len(tokens)
103 if n < k:
104 return set()
105 ids = [hash(t) & 0xFFFFFFFF for t in tokens]
106
107 high = pow(BASE, k - 1, MOD)
108 h = 0
109 for i in range(k):
110 h = (h * BASE + ids[i]) % MOD
111 hashes = [h]
112 for i in range(k, n):
113 h = ((h - ids[i - k] * high) * BASE + ids[i]) % MOD
114 hashes.append(h)
115
116 if w <= 1:
117 return set(hashes)
118 picked = set()
119 dq = deque() # indices, hashes increasing
120 for i, hv in enumerate(hashes):
121 while dq and hashes[dq[-1]] >= hv:
122 dq.pop()
123 dq.append(i)
124 while dq[0] <= i - w:
125 dq.popleft()
126 if i >= w - 1:
127 picked.add(hashes[dq[0]])
128 return picked
129
130
131def scan(root, k, w, normalise):
132 """Fingerprint every production file under root."""
133 root = os.path.abspath(root)
134 per_file, corpus = {}, set()
135 tokens_total = 0
136 for dirpath, dirnames, filenames in os.walk(root):
137 rel_dir = "/" + os.path.relpath(dirpath, root).replace(os.sep, "/") + "/"
138 if is_excluded(rel_dir):
139 dirnames[:] = []
140 continue
141 for name in sorted(filenames):
142 if not name.lower().endswith(SOURCE_SUFFIXES):
143 continue
144 full = os.path.join(dirpath, name)
145 rel = os.path.relpath(full, root).replace(os.sep, "/")
146 try:
147 with open(full, encoding="utf-8", errors="replace") as fh:
148 text = fh.read()
149 except OSError:
150 continue
151 toks = tokenize(text, normalise)
152 tokens_total += len(toks)
153 fp = fingerprints(toks, k, w)
154 if fp:
155 per_file[rel] = fp
156 corpus |= fp
157 return per_file, corpus, tokens_total
158
159
160def compare(name_a, a_files, a_corpus, name_b, b_files, b_corpus, a_tokens, b_tokens):
161 shared = a_corpus & b_corpus
162 per_file = []
163 for rel, fp in a_files.items():
164 if not fp:
165 continue
166 hit = len(fp & b_corpus)
167 per_file.append({"file": rel, "fingerprints": len(fp),
168 "shared": hit, "share": round(100.0 * hit / len(fp), 1)})
169 per_file.sort(key=lambda r: (-r["share"], -r["fingerprints"]))
170 buckets = defaultdict(int)
171 for r in per_file:
172 if r["share"] >= 90:
173 buckets[">=90%"] += 1
174 elif r["share"] >= 50:
175 buckets["50-90%"] += 1
176 elif r["share"] >= 10:
177 buckets["10-50%"] += 1
178 else:
179 buckets["<10%"] += 1
180 return {
181 "a": name_a, "b": name_b,
182 "a_files": len(a_files), "b_files": len(b_files),
183 "a_tokens": a_tokens, "b_tokens": b_tokens,
184 "a_fingerprints": len(a_corpus), "b_fingerprints": len(b_corpus),
185 "shared_fingerprints": len(shared),
186 "share_of_a": round(100.0 * len(shared) / max(1, len(a_corpus)), 1),
187 "share_of_b": round(100.0 * len(shared) / max(1, len(b_corpus)), 1),
188 "buckets": dict(buckets),
189 "most_shared": per_file[:25],
190 "least_shared": [r for r in per_file if r["fingerprints"] >= 40][-25:],
191 }
192
193
194def main():
195 ap = argparse.ArgumentParser(description=__doc__,
196 formatter_class=argparse.RawDescriptionHelpFormatter)
197 ap.add_argument("--a", required=True, help="first source tree")
198 ap.add_argument("--b", required=True, help="second source tree")
199 ap.add_argument("--name-a", default="a")
200 ap.add_argument("--name-b", default="b")
201 ap.add_argument("-k", type=int, default=20, help="k-gram size in tokens")
202 ap.add_argument("-w", type=int, default=12, help="winnowing window")
203 ap.add_argument("-o", "--out", default="clones.json")
204 args = ap.parse_args()
205
206 report = {"k": args.k, "w": args.w,
207 "guaranteed_match_length": args.k + args.w - 1}
208 for mode in ("strict", "normalised"):
209 norm = mode == "normalised"
210 sys.stderr.write("clone_detect: scanning %s (%s)\n" % (args.name_a, mode))
211 af, ac, at = scan(args.a, args.k, args.w, norm)
212 sys.stderr.write("clone_detect: scanning %s (%s)\n" % (args.name_b, mode))
213 bf, bc, bt = scan(args.b, args.k, args.w, norm)
214 report[mode] = compare(args.name_a, af, ac, args.name_b, bf, bc, at, bt)
215 r = report[mode]
216 sys.stderr.write("clone_detect: %s - %.1f%% of %s shared with %s\n"
217 % (mode, r["share_of_a"], args.name_a, args.name_b))
218
219 with open(args.out, "w", encoding="utf-8") as fh:
220 json.dump(report, fh, indent=1)
221 sys.stderr.write("clone_detect: wrote %s\n" % args.out)
222 return 0
223
224
225if __name__ == "__main__":
226 sys.exit(main())
const float max
tokenize(text, normalise)
scan(root, k, w, normalise)
compare(name_a, a_files, a_corpus, name_b, b_files, b_corpus, a_tokens, b_tokens)
fingerprints(tokens, k, w)
is_excluded(path)