@goshenkata/dryscan-core 1.0.0

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.
@@ -0,0 +1,335 @@
1
+ import crypto from "node:crypto";
2
+ import Parser from "tree-sitter";
3
+ import Java from "tree-sitter-java";
4
+ import { LanguageExtractor } from "./LanguageExtractor";
5
+ import { IndexUnit, IndexUnitType } from "../types";
6
+ import { indexConfig } from "../config/indexConfig";
7
+ import { DryConfig } from "../types";
8
+ import { configStore } from "../config/configStore";
9
+ import { BLOCK_HASH_ALGO } from "../const";
10
+
11
+ export class JavaExtractor implements LanguageExtractor {
12
+ readonly id = "java";
13
+ readonly exts = [".java"];
14
+
15
+ private parser: Parser;
16
+ private readonly repoPath: string;
17
+ private config?: DryConfig;
18
+
19
+ constructor(repoPath: string) {
20
+ this.repoPath = repoPath;
21
+ this.parser = new Parser();
22
+ this.parser.setLanguage(Java);
23
+ }
24
+
25
+ supports(filePath: string): boolean {
26
+ const lower = filePath.toLowerCase();
27
+ return this.exts.some((ext) => lower.endsWith(ext));
28
+ }
29
+
30
+ async extractFromText(fileRelPath: string, source: string): Promise<IndexUnit[]> {
31
+ if (!source.trim()) return [];
32
+
33
+ this.config = await configStore.get(this.repoPath);
34
+
35
+ const tree = this.parser.parse(source);
36
+ const units: IndexUnit[] = [];
37
+
38
+ const visit = (node: Parser.SyntaxNode, currentClass?: IndexUnit) => {
39
+ if (this.isClassNode(node)) {
40
+ const className = this.getClassName(node, source) || "<anonymous>";
41
+ if (this.isDtoClass(node, source, className)) {
42
+ return;
43
+ }
44
+ const startLine = node.startPosition.row;
45
+ const endLine = node.endPosition.row;
46
+ const classLength = endLine - startLine;
47
+ const skipClass = this.shouldSkip(IndexUnitType.CLASS, className, classLength);
48
+ const classId = this.buildId(IndexUnitType.CLASS, className, startLine, endLine);
49
+ const code = this.stripComments(this.stripClassBody(node, source));
50
+ const classUnit: IndexUnit = {
51
+ id: classId,
52
+ name: className,
53
+ filePath: fileRelPath,
54
+ startLine,
55
+ endLine,
56
+ code,
57
+ unitType: IndexUnitType.CLASS,
58
+ children: [],
59
+ };
60
+ if (!skipClass) {
61
+ units.push(classUnit);
62
+ }
63
+
64
+ for (let i = 0; i < node.namedChildCount; i++) {
65
+ const child = node.namedChild(i);
66
+ if (child) visit(child, skipClass ? undefined : classUnit);
67
+ }
68
+ return;
69
+ }
70
+
71
+ if (this.isFunctionNode(node)) {
72
+ const fnUnit = this.buildFunctionUnit(node, source, fileRelPath, currentClass);
73
+ const fnLength = fnUnit.endLine - fnUnit.startLine;
74
+ const bodyNode = this.getFunctionBody(node);
75
+ const skipFunction = this.shouldSkip(IndexUnitType.FUNCTION, fnUnit.name, fnLength);
76
+
77
+ if (skipFunction) {
78
+ return;
79
+ }
80
+
81
+ units.push(fnUnit);
82
+
83
+ if (bodyNode) {
84
+ const blocks = this.extractBlocks(bodyNode, source, fileRelPath, fnUnit);
85
+ units.push(...blocks);
86
+ }
87
+ }
88
+
89
+ for (let i = 0; i < node.namedChildCount; i++) {
90
+ const child = node.namedChild(i);
91
+ if (child) visit(child, currentClass);
92
+ }
93
+ };
94
+
95
+ visit(tree.rootNode);
96
+
97
+ return units;
98
+ }
99
+
100
+ unitLabel(unit: IndexUnit): string | null {
101
+ if (unit.unitType === IndexUnitType.CLASS) return unit.filePath;
102
+ if (unit.unitType === IndexUnitType.FUNCTION) return this.canonicalFunctionSignature(unit);
103
+ if (unit.unitType === IndexUnitType.BLOCK) return this.normalizedBlockHash(unit);
104
+ return unit.name;
105
+ }
106
+
107
+ private isClassNode(node: Parser.SyntaxNode): boolean {
108
+ return node.type === "class_declaration";
109
+ }
110
+
111
+ private getClassName(node: Parser.SyntaxNode, source: string): string | null {
112
+ const nameNode = node.childForFieldName?.("name");
113
+ return nameNode ? source.slice(nameNode.startIndex, nameNode.endIndex) : null;
114
+ }
115
+
116
+ private isFunctionNode(node: Parser.SyntaxNode): boolean {
117
+ return node.type === "method_declaration" || node.type === "constructor_declaration";
118
+ }
119
+
120
+ private getFunctionName(node: Parser.SyntaxNode, source: string, parentClass?: IndexUnit): string | null {
121
+ const nameNode = node.childForFieldName?.("name");
122
+ const nameText = nameNode ? source.slice(nameNode.startIndex, nameNode.endIndex) : "<anonymous>";
123
+ return parentClass ? `${parentClass.name}.${nameText}` : nameText;
124
+ }
125
+
126
+ private getFunctionBody(node: Parser.SyntaxNode): Parser.SyntaxNode | null {
127
+ return node.childForFieldName?.("body") ?? null;
128
+ }
129
+
130
+ private isBlockNode(node: Parser.SyntaxNode): boolean {
131
+ return node.type === "block";
132
+ }
133
+
134
+ private getMethodBodiesForClass(node: Parser.SyntaxNode): Parser.SyntaxNode[] {
135
+ const bodies: Parser.SyntaxNode[] = [];
136
+ const classBody = node.children.find(child => child.type === "class_body");
137
+ if (!classBody) return bodies;
138
+
139
+ for (let i = 0; i < classBody.namedChildCount; i++) {
140
+ const child = classBody.namedChild(i);
141
+ if (!child) continue;
142
+ if (child.type === "method_declaration" || child.type === "constructor_declaration") {
143
+ const body = child.childForFieldName?.("body");
144
+ if (body) bodies.push(body);
145
+ }
146
+ }
147
+ return bodies;
148
+ }
149
+
150
+ private canonicalFunctionSignature(unit: IndexUnit): string {
151
+ const arity = this.extractArity(unit.code);
152
+ return `${unit.name}(arity:${arity})`;
153
+ }
154
+
155
+ private normalizedBlockHash(unit: IndexUnit): string {
156
+ const normalized = this.normalizeCode(unit.code);
157
+ return crypto.createHash(BLOCK_HASH_ALGO).update(normalized).digest("hex");
158
+ }
159
+
160
+ private shouldSkip(unitType: IndexUnitType, name: string, lineCount: number): boolean {
161
+ if (!this.config) {
162
+ throw new Error("Config not loaded before skip evaluation");
163
+ }
164
+ const config = this.config;
165
+ const minLines = unitType === IndexUnitType.BLOCK
166
+ ? Math.max(indexConfig.blockMinLines, config.minBlockLines ?? 0)
167
+ : config.minLines;
168
+ const belowMin = minLines > 0 && lineCount < minLines;
169
+ const trivial = unitType === IndexUnitType.FUNCTION && this.isTrivialFunction(name);
170
+ return belowMin || trivial;
171
+ }
172
+
173
+ private isTrivialFunction(fullName: string): boolean {
174
+ const simpleName = fullName.split(".").pop() || fullName;
175
+ const isGetter = /^(get|is)[A-Z]/.test(simpleName);
176
+ const isSetter = /^set[A-Z]/.test(simpleName);
177
+ return isGetter || isSetter;
178
+ }
179
+
180
+ private isDtoClass(node: Parser.SyntaxNode, source: string, className: string): boolean {
181
+ const classBody = node.children.find((child) => child.type === "class_body");
182
+ if (!classBody) return false;
183
+
184
+ let hasField = false;
185
+
186
+ for (let i = 0; i < classBody.namedChildCount; i++) {
187
+ const child = classBody.namedChild(i);
188
+ if (!child) continue;
189
+
190
+ if (child.type === "field_declaration") {
191
+ hasField = true;
192
+ continue;
193
+ }
194
+
195
+ if (child.type.includes("annotation")) {
196
+ continue;
197
+ }
198
+
199
+ if (child.type === "method_declaration" || child.type === "constructor_declaration") {
200
+ const simpleName = this.getSimpleFunctionName(child, source);
201
+ const fullName = `${className}.${simpleName}`;
202
+ if (!this.isTrivialFunction(fullName)) {
203
+ return false;
204
+ }
205
+ continue;
206
+ }
207
+
208
+ return false;
209
+ }
210
+
211
+ return hasField;
212
+ }
213
+
214
+ private getSimpleFunctionName(node: Parser.SyntaxNode, source: string): string {
215
+ const nameNode = node.childForFieldName?.("name");
216
+ return nameNode ? source.slice(nameNode.startIndex, nameNode.endIndex) : "<anonymous>";
217
+ }
218
+
219
+ private buildFunctionUnit(
220
+ node: Parser.SyntaxNode,
221
+ source: string,
222
+ file: string,
223
+ parentClass?: IndexUnit
224
+ ): IndexUnit {
225
+ const name = this.getFunctionName(node, source, parentClass) || "<anonymous>";
226
+ const startLine = node.startPosition.row;
227
+ const endLine = node.endPosition.row;
228
+ const id = this.buildId(IndexUnitType.FUNCTION, name, startLine, endLine);
229
+ const unit: IndexUnit = {
230
+ id,
231
+ name,
232
+ filePath: file,
233
+ startLine,
234
+ endLine,
235
+ code: this.stripComments(source.slice(node.startIndex, node.endIndex)),
236
+ unitType: IndexUnitType.FUNCTION,
237
+ parentId: parentClass?.id,
238
+ parent: parentClass,
239
+ };
240
+ if (parentClass) {
241
+ parentClass.children = parentClass.children || [];
242
+ parentClass.children.push(unit);
243
+ }
244
+ return unit;
245
+ }
246
+
247
+ private extractBlocks(
248
+ bodyNode: Parser.SyntaxNode,
249
+ source: string,
250
+ file: string,
251
+ parentFunction: IndexUnit
252
+ ): IndexUnit[] {
253
+ const blocks: IndexUnit[] = [];
254
+
255
+ const visit = (n: Parser.SyntaxNode) => {
256
+ if (this.isBlockNode(n)) {
257
+ const startLine = n.startPosition.row;
258
+ const endLine = n.endPosition.row;
259
+ const lineCount = endLine - startLine;
260
+ if (this.shouldSkip(IndexUnitType.BLOCK, parentFunction.name, lineCount)) {
261
+ return;
262
+ }
263
+ if (lineCount >= indexConfig.blockMinLines) {
264
+ const id = this.buildId(IndexUnitType.BLOCK, parentFunction.name, startLine, endLine);
265
+ const blockUnit: IndexUnit = {
266
+ id,
267
+ name: parentFunction.name,
268
+ filePath: file,
269
+ startLine,
270
+ endLine,
271
+ code: this.stripComments(source.slice(n.startIndex, n.endIndex)),
272
+ unitType: IndexUnitType.BLOCK,
273
+ parentId: parentFunction.id,
274
+ parent: parentFunction,
275
+ };
276
+ parentFunction.children = parentFunction.children || [];
277
+ parentFunction.children.push(blockUnit);
278
+ blocks.push(blockUnit);
279
+ }
280
+ }
281
+
282
+ for (let i = 0; i < n.namedChildCount; i++) {
283
+ const child = n.namedChild(i);
284
+ if (child) visit(child);
285
+ }
286
+ };
287
+
288
+ visit(bodyNode);
289
+ return blocks;
290
+ }
291
+
292
+ private stripClassBody(node: Parser.SyntaxNode, source: string): string {
293
+ const classStart = node.startIndex;
294
+ let code = source.slice(classStart, node.endIndex);
295
+
296
+ const methodBodies: Array<{ start: number; end: number }> = [];
297
+ const candidates = this.getMethodBodiesForClass(node);
298
+
299
+ for (const body of candidates) {
300
+ methodBodies.push({ start: body.startIndex - classStart, end: body.endIndex - classStart });
301
+ }
302
+
303
+ methodBodies.sort((a, b) => b.start - a.start);
304
+ for (const body of methodBodies) {
305
+ code = code.slice(0, body.start) + " { }" + code.slice(body.end);
306
+ }
307
+
308
+ return code;
309
+ }
310
+
311
+ private buildId(type: IndexUnitType, name: string, startLine: number, endLine: number): string {
312
+ return `${type}:${name}:${startLine}-${endLine}`;
313
+ }
314
+
315
+ private extractArity(code: string): number {
316
+ const match = code.match(/^[^{]*?\(([^)]*)\)/s);
317
+ if (!match) return 0;
318
+ const params = match[1]
319
+ .split(",")
320
+ .map((p) => p.trim())
321
+ .filter(Boolean);
322
+ return params.length;
323
+ }
324
+
325
+ private normalizeCode(code: string): string {
326
+ const withoutBlockComments = code.replace(/\/\*[\s\S]*?\*\//g, "");
327
+ const withoutLineComments = withoutBlockComments.replace(/\/\/[^\n\r]*/g, "");
328
+ return withoutLineComments.replace(/\s+/g, "");
329
+ }
330
+
331
+ private stripComments(code: string): string {
332
+ const withoutBlockComments = code.replace(/\/\*[\s\S]*?\*\//g, (match) => match.replace(/[^\n\r]/g, ""));
333
+ return withoutBlockComments.replace(/\/\/[^\n\r]*/g, "");
334
+ }
335
+ }
package/src/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ // Public surface: keep minimal API for consumers
2
+ export { DryScan } from './DryScan';
3
+ export { configStore } from './config/configStore';
4
+ export {
5
+ DuplicateGroup,
6
+ DuplicationScore,
7
+ DuplicateReport,
8
+ DryConfig,
9
+ } from './types';
@@ -0,0 +1,274 @@
1
+ import debug from "debug";
2
+ import shortUuid from "short-uuid";
3
+ import { cosineSimilarity } from "@langchain/core/utils/math";
4
+ import { DryScanServiceDeps } from "./types";
5
+ import { DuplicateAnalysisResult, DuplicateGroup, DuplicationScore, IndexUnit, IndexUnitType } from "../types";
6
+ import { indexConfig } from "../config/indexConfig";
7
+ import { DryConfig } from "../types";
8
+ import { DuplicationCache } from "./DuplicationCache";
9
+
10
+ const log = debug("DryScan:DuplicateService");
11
+
12
+ export class DuplicateService {
13
+ private config?: DryConfig;
14
+ private readonly cache = DuplicationCache.getInstance();
15
+
16
+ constructor(private readonly deps: DryScanServiceDeps) {}
17
+
18
+ async findDuplicates(config: DryConfig): Promise<DuplicateAnalysisResult> {
19
+ this.config = config;
20
+ const allUnits = await this.deps.db.getAllUnits();
21
+ if (allUnits.length < 2) {
22
+ const score = this.computeDuplicationScore([], allUnits);
23
+ return { duplicates: [], score };
24
+ }
25
+
26
+ const thresholds = this.resolveThresholds(config.threshold);
27
+ const duplicates = this.computeDuplicates(allUnits, thresholds);
28
+ const filteredDuplicates = duplicates.filter((group) => !this.isGroupExcluded(group));
29
+ log("Found %d duplicate groups", filteredDuplicates.length);
30
+
31
+ // Update cache asynchronously; no need to block the main flow.
32
+ this.cache.update(filteredDuplicates).catch((err) => log("Cache update failed: %O", err));
33
+
34
+ const score = this.computeDuplicationScore(filteredDuplicates, allUnits);
35
+ return { duplicates: filteredDuplicates, score };
36
+ }
37
+
38
+ private resolveThresholds(functionThreshold?: number): { function: number; block: number; class: number } {
39
+ const defaults = indexConfig.thresholds;
40
+ const clamp = (value: number) => Math.min(1, Math.max(0, value));
41
+
42
+ const base = functionThreshold ?? defaults.function;
43
+ const blockOffset = defaults.block - defaults.function;
44
+ const classOffset = defaults.class - defaults.function;
45
+
46
+ const functionThresholdValue = clamp(base);
47
+ return {
48
+ function: functionThresholdValue,
49
+ block: clamp(functionThresholdValue + blockOffset),
50
+ class: clamp(functionThresholdValue + classOffset),
51
+ };
52
+ }
53
+
54
+ private computeDuplicates(
55
+ units: IndexUnit[],
56
+ thresholds: { function: number; block: number; class: number }
57
+ ): DuplicateGroup[] {
58
+ const duplicates: DuplicateGroup[] = [];
59
+ const byType = new Map<IndexUnitType, IndexUnit[]>();
60
+
61
+ for (const unit of units) {
62
+ const list = byType.get(unit.unitType) ?? [];
63
+ list.push(unit);
64
+ byType.set(unit.unitType, list);
65
+ }
66
+
67
+ for (const [type, typedUnits] of byType.entries()) {
68
+ const threshold = this.getThreshold(type, thresholds);
69
+
70
+ for (let i = 0; i < typedUnits.length; i++) {
71
+ for (let j = i + 1; j < typedUnits.length; j++) {
72
+ const left = typedUnits[i];
73
+ const right = typedUnits[j];
74
+
75
+ if (this.shouldSkipComparison(left, right)) continue;
76
+
77
+ const cached = this.cache.get(left.id, right.id, left.filePath, right.filePath);
78
+ let similarity: number | null = null;
79
+
80
+ if (cached !== null) {
81
+ similarity = cached;
82
+ } else {
83
+ if (!left.embedding || !right.embedding) continue;
84
+ similarity = this.computeWeightedSimilarity(left, right);
85
+ }
86
+
87
+ if (similarity === null) continue;
88
+
89
+ if (similarity >= threshold) {
90
+ const exclusionString = this.deps.pairing.pairKeyForUnits(left, right);
91
+ if (!exclusionString) continue;
92
+
93
+ duplicates.push({
94
+ id: `${left.id}::${right.id}`,
95
+ similarity,
96
+ shortId: shortUuid.generate(),
97
+ exclusionString,
98
+ left: {
99
+ id: left.id,
100
+ name: left.name,
101
+ filePath: left.filePath,
102
+ startLine: left.startLine,
103
+ endLine: left.endLine,
104
+ code: left.code,
105
+ unitType: left.unitType,
106
+ },
107
+ right: {
108
+ id: right.id,
109
+ name: right.name,
110
+ filePath: right.filePath,
111
+ startLine: right.startLine,
112
+ endLine: right.endLine,
113
+ code: right.code,
114
+ unitType: right.unitType,
115
+ },
116
+ });
117
+ }
118
+ }
119
+ }
120
+ }
121
+
122
+ return duplicates.sort((a, b) => b.similarity - a.similarity);
123
+ }
124
+
125
+ private isGroupExcluded(group: DuplicateGroup): boolean {
126
+ const config = this.config;
127
+ if (!config || !config.excludedPairs || config.excludedPairs.length === 0) return false;
128
+ const key = this.deps.pairing.pairKeyForUnits(group.left, group.right);
129
+ if (!key) return false;
130
+ const actual = this.deps.pairing.parsePairKey(key);
131
+ if (!actual) return false;
132
+ return config.excludedPairs.some((entry) => {
133
+ const parsed = this.deps.pairing.parsePairKey(entry);
134
+ return parsed ? this.deps.pairing.pairKeyMatches(actual, parsed) : false;
135
+ });
136
+ }
137
+
138
+ private getThreshold(type: IndexUnitType, thresholds: { function: number; block: number; class: number }): number {
139
+ if (type === IndexUnitType.CLASS) return thresholds.class;
140
+ if (type === IndexUnitType.BLOCK) return thresholds.block;
141
+ return thresholds.function;
142
+ }
143
+
144
+ private computeWeightedSimilarity(left: IndexUnit, right: IndexUnit): number {
145
+ const selfSimilarity = this.similarityWithFallback(left, right);
146
+
147
+ if (left.unitType === IndexUnitType.CLASS) {
148
+ return selfSimilarity * indexConfig.weights.class.self;
149
+ }
150
+
151
+ if (left.unitType === IndexUnitType.FUNCTION) {
152
+ const parentClassSimilarity = this.parentSimilarity(left, right, IndexUnitType.CLASS);
153
+ const weights = indexConfig.weights.function;
154
+ return (weights.self * selfSimilarity) + (weights.parentClass * parentClassSimilarity);
155
+ }
156
+
157
+ const weights = indexConfig.weights.block;
158
+ const parentFuncSim = this.parentSimilarity(left, right, IndexUnitType.FUNCTION);
159
+ const parentClassSim = this.parentSimilarity(left, right, IndexUnitType.CLASS);
160
+ return (
161
+ weights.self * selfSimilarity +
162
+ weights.parentFunction * parentFuncSim +
163
+ weights.parentClass * parentClassSim
164
+ );
165
+ }
166
+
167
+ private parentSimilarity(left: IndexUnit, right: IndexUnit, targetType: IndexUnitType): number {
168
+ const leftParent = this.findParentOfType(left, targetType);
169
+ const rightParent = this.findParentOfType(right, targetType);
170
+ if (!leftParent || !rightParent) return 0;
171
+ return this.similarityWithFallback(leftParent, rightParent);
172
+ }
173
+
174
+ private similarityWithFallback(left: IndexUnit, right: IndexUnit): number {
175
+ const leftHasEmbedding = this.hasVector(left);
176
+ const rightHasEmbedding = this.hasVector(right);
177
+
178
+ if (leftHasEmbedding && rightHasEmbedding) {
179
+ return cosineSimilarity([left.embedding as number[]], [right.embedding as number[]])[0][0];
180
+ }
181
+
182
+ return this.childSimilarity(left, right);
183
+ }
184
+
185
+ private childSimilarity(left: IndexUnit, right: IndexUnit): number {
186
+ const leftChildren = left.children ?? [];
187
+ const rightChildren = right.children ?? [];
188
+ if (leftChildren.length === 0 || rightChildren.length === 0) return 0;
189
+
190
+ let best = 0;
191
+ for (const lChild of leftChildren) {
192
+ for (const rChild of rightChildren) {
193
+ if (lChild.unitType !== rChild.unitType) continue;
194
+ const sim = this.similarityWithFallback(lChild, rChild);
195
+ if (sim > best) best = sim;
196
+ }
197
+ }
198
+ return best;
199
+ }
200
+
201
+ private hasVector(unit: IndexUnit): boolean {
202
+ return Array.isArray(unit.embedding) && unit.embedding.length > 0;
203
+ }
204
+
205
+ private shouldSkipComparison(left: IndexUnit, right: IndexUnit): boolean {
206
+ if (left.unitType !== IndexUnitType.BLOCK || right.unitType !== IndexUnitType.BLOCK) {
207
+ return false;
208
+ }
209
+
210
+ if (left.filePath !== right.filePath) {
211
+ return false;
212
+ }
213
+
214
+ const leftContainsRight = left.startLine <= right.startLine && left.endLine >= right.endLine;
215
+ const rightContainsLeft = right.startLine <= left.startLine && right.endLine >= left.endLine;
216
+ return leftContainsRight || rightContainsLeft;
217
+ }
218
+
219
+ private findParentOfType(unit: IndexUnit, targetType: IndexUnitType): IndexUnit | null {
220
+ let current: IndexUnit | undefined | null = unit.parent;
221
+ while (current) {
222
+ if (current.unitType === targetType) return current;
223
+ current = current.parent;
224
+ }
225
+ return null;
226
+ }
227
+
228
+ private computeDuplicationScore(duplicates: DuplicateGroup[], allUnits: IndexUnit[]): DuplicationScore {
229
+ const totalLines = this.calculateTotalLines(allUnits);
230
+
231
+ if (totalLines === 0 || duplicates.length === 0) {
232
+ return {
233
+ score: 0,
234
+ grade: "Excellent",
235
+ totalLines,
236
+ duplicateLines: 0,
237
+ duplicateGroups: 0,
238
+ };
239
+ }
240
+
241
+ const weightedDuplicateLines = duplicates.reduce((sum, group) => {
242
+ const leftLines = group.left.endLine - group.left.startLine + 1;
243
+ const rightLines = group.right.endLine - group.right.startLine + 1;
244
+ const avgLines = (leftLines + rightLines) / 2;
245
+ return sum + group.similarity * avgLines;
246
+ }, 0);
247
+
248
+ const score = (weightedDuplicateLines / totalLines) * 100;
249
+ const grade = this.getScoreGrade(score);
250
+
251
+ return {
252
+ score,
253
+ grade,
254
+ totalLines,
255
+ duplicateLines: Math.round(weightedDuplicateLines),
256
+ duplicateGroups: duplicates.length,
257
+ };
258
+ }
259
+
260
+ private calculateTotalLines(units: IndexUnit[]): number {
261
+ return units.reduce((sum, unit) => {
262
+ const lines = unit.endLine - unit.startLine + 1;
263
+ return sum + lines;
264
+ }, 0);
265
+ }
266
+
267
+ private getScoreGrade(score: number): DuplicationScore["grade"] {
268
+ if (score < 5) return "Excellent";
269
+ if (score < 15) return "Good";
270
+ if (score < 30) return "Fair";
271
+ if (score < 50) return "Poor";
272
+ return "Critical";
273
+ }
274
+ }