omp-plugin-duplicate-detector 0.1.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,919 @@
1
+ /**
2
+ * Source-aware clone index with multi-contributor frame promotion and fast O(frames) deletion.
3
+ * Correctly maintains multi-source clone tracking so deleting one contributor does not
4
+ * wipe out shared code hashes for remaining sources.
5
+ */
6
+
7
+ import {
8
+ getDefaultOptions,
9
+ type IClone,
10
+ type IMapFrame,
11
+ type IOptions,
12
+ type ITokenMap,
13
+ mild,
14
+ } from "@jscpd/core";
15
+ import { Tokenizer } from "@jscpd/tokenizer";
16
+ import { getSupportedCodeFormat } from "./jscpd-engine";
17
+ export class CompactSourceFrame {
18
+ constructor(
19
+ public readonly id: string,
20
+ public readonly sourceId: string,
21
+ public readonly startLine: number,
22
+ public readonly startCol: number,
23
+ public readonly startPos: number,
24
+ public readonly startRange: number,
25
+ public readonly endLine: number,
26
+ public readonly endCol: number,
27
+ public readonly endPos: number,
28
+ public readonly endRange: number,
29
+ ) {}
30
+
31
+ get start() {
32
+ return {
33
+ line: this.startLine,
34
+ column: this.startCol,
35
+ position: this.startPos,
36
+ range: [this.startRange, this.startRange] as [number, number],
37
+ loc: {
38
+ start: {
39
+ line: this.startLine,
40
+ column: this.startCol,
41
+ position: this.startPos,
42
+ },
43
+ end: {
44
+ line: this.startLine,
45
+ column: this.startCol,
46
+ position: this.startPos,
47
+ },
48
+ },
49
+ };
50
+ }
51
+
52
+ get end() {
53
+ return {
54
+ line: this.endLine,
55
+ column: this.endCol,
56
+ position: this.endPos,
57
+ range: [this.endRange, this.endRange] as [number, number],
58
+ loc: {
59
+ start: {
60
+ line: this.endLine,
61
+ column: this.endCol,
62
+ position: this.endPos,
63
+ },
64
+ end: {
65
+ line: this.endLine,
66
+ column: this.endCol,
67
+ position: this.endPos,
68
+ },
69
+ },
70
+ };
71
+ }
72
+ }
73
+
74
+ export type SourceFrame = IMapFrame | CompactSourceFrame;
75
+
76
+ export interface SerializedToken {
77
+ hash: string;
78
+ line: number;
79
+ column: number;
80
+ position: number;
81
+ range: [number, number];
82
+ }
83
+
84
+ export interface SerializedSourceShard {
85
+ version: number;
86
+ sourceId: string;
87
+ contentHash: string;
88
+ format: string;
89
+ size: number;
90
+ lines: number;
91
+ tokenCount: number;
92
+ minTokens?: number;
93
+ updatedAt?: number;
94
+ tokens?: SerializedToken[];
95
+ frames: SourceFrame[];
96
+ }
97
+
98
+ export const fastTokenHash = (val: string): string =>
99
+ Bun.hash(val).toString(16).padStart(20, "0");
100
+
101
+ /**
102
+ * Reconstructs sliding window SourceFrames from a pre-tokenized token sequence.
103
+ */
104
+ export function reconstructFramesFromTokens(
105
+ tokens: SerializedToken[],
106
+ sourceId: string,
107
+ minTokens: number,
108
+ hashFunction: (val: string) => string = fastTokenHash,
109
+ ): CompactSourceFrame[] {
110
+ const tokenCount = tokens.length;
111
+ const frameCount = Math.max(0, tokenCount - minTokens);
112
+ const hashMap = tokens.map((t) => t.hash).join("");
113
+ const TOKEN_HASH_LEN = 20;
114
+
115
+ const frames: CompactSourceFrame[] = new Array(frameCount);
116
+ for (let i = 0; i < frameCount; i++) {
117
+ const windowSub = hashMap.substring(
118
+ i * TOKEN_HASH_LEN,
119
+ (i + minTokens) * TOKEN_HASH_LEN,
120
+ );
121
+ const windowHash = hashFunction(windowSub).substring(0, TOKEN_HASH_LEN);
122
+ const startTok = tokens[i]!;
123
+ const endTok = tokens[i + minTokens]!;
124
+
125
+ frames[i] = new CompactSourceFrame(
126
+ windowHash,
127
+ sourceId,
128
+ startTok.line,
129
+ startTok.column,
130
+ startTok.position,
131
+ startTok.range[0],
132
+ endTok.line,
133
+ endTok.column,
134
+ endTok.position,
135
+ endTok.range[1],
136
+ );
137
+ }
138
+ return frames;
139
+ }
140
+
141
+ /**
142
+ * Tokenize a source file into a SerializedSourceShard without modifying index state.
143
+ */
144
+ export function tokenizeSource(
145
+ sourceId: string,
146
+ content: string,
147
+ contentHash = "",
148
+ options: SourceAwareIndexOptions = {},
149
+ ): SerializedSourceShard | null {
150
+ const index = new SourceAwareCloneIndex(options);
151
+ return index.tokenizeSource(sourceId, content, contentHash);
152
+ }
153
+ export interface SourceMeta {
154
+ sourceId: string;
155
+ format: string;
156
+ size: number;
157
+ lines: number;
158
+ tokenCount: number;
159
+ updatedAt: number;
160
+ }
161
+
162
+ export interface SourceAwareIndexOptions {
163
+ minTokens?: number;
164
+ minLines?: number;
165
+ maxLines?: number;
166
+ formatsExts?: Record<string, string[]>;
167
+ crossFormats?: boolean;
168
+ hashFunction?: (val: string) => string;
169
+ }
170
+
171
+ export interface IndexStats {
172
+ sourceCount: number;
173
+ hashCount: number;
174
+ totalTokens: number;
175
+ }
176
+
177
+ interface ActiveCloneCandidate {
178
+ clone: IClone;
179
+ targetFrame: SourceFrame;
180
+ lastSourceEndRange: number;
181
+ lastTargetEndRange: number;
182
+ }
183
+
184
+ interface TokenMapsResult {
185
+ format: string;
186
+ maps: ITokenMap[];
187
+ }
188
+
189
+ /**
190
+ * High-performance, source-aware clone index.
191
+ * Stores token frames by hash, promoting from single frames to arrays when multiple
192
+ * sources or locations share a token sequence.
193
+ */
194
+ export class SourceAwareCloneIndex {
195
+ readonly framesByHash = new Map<
196
+ string,
197
+ CompactSourceFrame | CompactSourceFrame[]
198
+ >();
199
+ readonly hashesBySource = new Map<string, string[]>();
200
+ readonly sources = new Map<string, SourceMeta>();
201
+ readonly #tokensBySource = new Map<string, SerializedToken[]>();
202
+ clones: IClone[] = [];
203
+ readonly #tokenizer: Tokenizer;
204
+ readonly #options: IOptions;
205
+ readonly #minTokens: number;
206
+ readonly #minLines: number;
207
+ readonly #maxLines: number;
208
+ readonly #formatsExts: Record<string, string[]>;
209
+ readonly #hashFunction: (val: string) => string;
210
+
211
+ constructor(options: SourceAwareIndexOptions = {}) {
212
+ this.#tokenizer = new Tokenizer();
213
+ this.#minTokens = options.minTokens ?? 40;
214
+ this.#minLines = options.minLines ?? 5;
215
+ this.#maxLines = options.maxLines ?? 500;
216
+ this.#formatsExts = options.formatsExts ?? {};
217
+ this.#hashFunction = options.hashFunction ?? fastTokenHash;
218
+
219
+ const baseDefaults = getDefaultOptions();
220
+ this.#options = {
221
+ ...baseDefaults,
222
+ mode: baseDefaults.mode || mild,
223
+ minTokens: this.#minTokens,
224
+ minLines: this.#minLines,
225
+ maxLines: this.#maxLines,
226
+ formatsExts: this.#formatsExts,
227
+ hashFunction: this.#hashFunction,
228
+ };
229
+ }
230
+
231
+ get minTokens(): number {
232
+ return this.#minTokens;
233
+ }
234
+
235
+ get minLines(): number {
236
+ return this.#minLines;
237
+ }
238
+
239
+ get maxLines(): number {
240
+ return this.#maxLines;
241
+ }
242
+
243
+ get hashFunction(): (val: string) => string {
244
+ return this.#hashFunction;
245
+ }
246
+
247
+ get discoveredClones(): IClone[] {
248
+ return this.clones.slice();
249
+ }
250
+
251
+ /**
252
+ * Returns true if the index has indexed the specified sourceId.
253
+ */
254
+ hasSource(sourceId: string): boolean {
255
+ return this.sources.has(sourceId);
256
+ }
257
+
258
+ /**
259
+ * Get metadata for an indexed source.
260
+ */
261
+ getSource(sourceId: string): SourceMeta | undefined {
262
+ return this.sources.get(sourceId);
263
+ }
264
+
265
+ /**
266
+ * Return a snapshot of all discovered clones.
267
+ */
268
+ getClones(): IClone[] {
269
+ return this.clones.slice();
270
+ }
271
+
272
+ /**
273
+ * Returns index statistics.
274
+ */
275
+ stats(): IndexStats {
276
+ let totalTokens = 0;
277
+ for (const meta of this.sources.values()) {
278
+ totalTokens += meta.tokenCount;
279
+ }
280
+ return {
281
+ sourceCount: this.sources.size,
282
+ hashCount: this.framesByHash.size,
283
+ totalTokens,
284
+ };
285
+ }
286
+
287
+ /**
288
+ * Clear all index state and discovered clones.
289
+ */
290
+ reset(): void {
291
+ this.framesByHash.clear();
292
+ this.hashesBySource.clear();
293
+ this.sources.clear();
294
+ this.#tokensBySource.clear();
295
+ this.clones = [];
296
+ }
297
+
298
+ /**
299
+ * Add or replace a source file in the clone index.
300
+ * Returns newly detected clones matching against existing indexed files.
301
+ */
302
+ addSource(sourceId: string, content: string, format?: string): IClone[] {
303
+ if (this.sources.has(sourceId)) {
304
+ this.removeSource(sourceId);
305
+ }
306
+
307
+ const shard = this.tokenizeSource(sourceId, content, "", format);
308
+ if (!shard) {
309
+ return [];
310
+ }
311
+ return this.hydrateSourceShard(shard);
312
+ }
313
+
314
+ /**
315
+ * Export pre-tokenized frames and source metadata as a serialized shard.
316
+ */
317
+ exportSourceShard(
318
+ sourceId: string,
319
+ contentHash: string,
320
+ ): SerializedSourceShard | null {
321
+ const meta = this.sources.get(sourceId);
322
+ const hashes = this.hashesBySource.get(sourceId);
323
+ if (!meta || !hashes) {
324
+ return null;
325
+ }
326
+
327
+ const minTokens = this.#minTokens;
328
+ const hashFn = this.#hashFunction;
329
+ const tokens = this.#tokensBySource.get(sourceId);
330
+ let memoizedFrames: CompactSourceFrame[] | null = null;
331
+
332
+ const getFrames = (): CompactSourceFrame[] => {
333
+ if (!memoizedFrames) {
334
+ if (tokens && tokens.length > 0) {
335
+ memoizedFrames = reconstructFramesFromTokens(
336
+ tokens,
337
+ meta.sourceId,
338
+ minTokens,
339
+ hashFn,
340
+ );
341
+ } else {
342
+ const frames: CompactSourceFrame[] = [];
343
+ for (const hash of hashes) {
344
+ const entry = this.framesByHash.get(hash);
345
+ if (!entry) continue;
346
+ if (Array.isArray(entry)) {
347
+ const match = entry.find((f) => f.sourceId === sourceId);
348
+ if (match) frames.push(match);
349
+ } else if (entry.sourceId === sourceId) {
350
+ frames.push(entry);
351
+ }
352
+ }
353
+ memoizedFrames = frames;
354
+ }
355
+ }
356
+ return memoizedFrames ?? [];
357
+ };
358
+
359
+ return {
360
+ version: 1,
361
+ sourceId: meta.sourceId,
362
+ contentHash,
363
+ format: meta.format,
364
+ size: meta.size,
365
+ lines: meta.lines,
366
+ tokenCount: meta.tokenCount,
367
+ minTokens,
368
+ updatedAt: meta.updatedAt,
369
+ tokens,
370
+ get frames(): CompactSourceFrame[] {
371
+ return getFrames();
372
+ },
373
+ };
374
+ }
375
+
376
+ /**
377
+ * Tokenize a source file into a SerializedSourceShard without modifying index state.
378
+ * Can be run concurrently in parallel batches.
379
+ */
380
+ tokenizeSource(
381
+ sourceId: string,
382
+ content: string,
383
+ contentHash = "",
384
+ format?: string,
385
+ ): SerializedSourceShard | null {
386
+ const resolvedFormat =
387
+ format ?? getSupportedCodeFormat(sourceId, this.#formatsExts);
388
+ if (!resolvedFormat) {
389
+ return null;
390
+ }
391
+
392
+ let maps: ITokenMap[];
393
+ try {
394
+ maps = this.#tokenizer.generateMaps(
395
+ sourceId,
396
+ content,
397
+ resolvedFormat,
398
+ this.#options,
399
+ );
400
+ } catch {
401
+ return null;
402
+ }
403
+
404
+ if (!maps || maps.length === 0) {
405
+ return null;
406
+ }
407
+
408
+ const sourceTokens: SerializedToken[] = [];
409
+ const TOKEN_HASH_LEN = 20;
410
+
411
+ for (const tokenMap of maps) {
412
+ const mapTokens = (tokenMap as unknown as { tokens?: unknown[] }).tokens;
413
+ const hashMap = (tokenMap as unknown as { hashMap?: string }).hashMap;
414
+
415
+ if (Array.isArray(mapTokens) && typeof hashMap === "string") {
416
+ for (let i = 0; i < mapTokens.length; i++) {
417
+ const t = mapTokens[i] as {
418
+ line?: number;
419
+ column?: number;
420
+ position?: number;
421
+ range?: [number, number];
422
+ loc?: { start: { line: number; column: number; position: number } };
423
+ };
424
+ const hash = hashMap.substring(
425
+ i * TOKEN_HASH_LEN,
426
+ (i + 1) * TOKEN_HASH_LEN,
427
+ );
428
+ sourceTokens.push({
429
+ hash,
430
+ line: t.loc?.start.line ?? t.line ?? 1,
431
+ column: t.loc?.start.column ?? t.column ?? 1,
432
+ position: t.loc?.start.position ?? t.position ?? i,
433
+ range: t.range ? [t.range[0], t.range[1]] : [0, 0],
434
+ });
435
+ }
436
+ }
437
+ }
438
+
439
+ const sourceFrames = reconstructFramesFromTokens(
440
+ sourceTokens,
441
+ sourceId,
442
+ this.#minTokens,
443
+ this.#hashFunction,
444
+ );
445
+
446
+ let totalTokens = 0;
447
+ let totalLines = 0;
448
+ for (const map of maps) {
449
+ totalTokens += map.getTokensCount();
450
+ totalLines += map.getLinesCount();
451
+ }
452
+
453
+ return {
454
+ version: 1,
455
+ sourceId,
456
+ contentHash,
457
+ format: resolvedFormat,
458
+ size: content.length,
459
+ lines: totalLines || content.split(/\r?\n/).length,
460
+ tokenCount: totalTokens,
461
+ minTokens: this.#minTokens,
462
+ updatedAt: Date.now(),
463
+ tokens: sourceTokens,
464
+ frames: sourceFrames,
465
+ };
466
+ }
467
+ /**
468
+ * Hydrate a pre-tokenized shard directly into framesByHash, hashesBySource,
469
+ * and sources without re-running Tokenizer.
470
+ */
471
+ hydrateSourceShard(shard: SerializedSourceShard): IClone[] {
472
+ const { sourceId, format, size, lines, tokenCount } = shard;
473
+
474
+ if (this.sources.has(sourceId)) {
475
+ this.removeSource(sourceId);
476
+ }
477
+
478
+ let normalizedFrames: CompactSourceFrame[];
479
+ const shardTokens = shard.tokens;
480
+
481
+ if (shard.frames && shard.frames.length > 0) {
482
+ normalizedFrames = shard.frames.map((f) => {
483
+ if (f instanceof CompactSourceFrame && f.sourceId === sourceId) {
484
+ return f;
485
+ }
486
+ const startLine =
487
+ "startLine" in f
488
+ ? f.startLine
489
+ : (f.start.loc?.start.line ?? f.start.line ?? 1);
490
+ const startCol =
491
+ "startCol" in f
492
+ ? f.startCol
493
+ : (f.start.loc?.start.column ?? f.start.column ?? 1);
494
+ const startPos =
495
+ "startPos" in f
496
+ ? f.startPos
497
+ : (f.start.loc?.start.position ?? f.start.position ?? 0);
498
+ const startRange =
499
+ "startRange" in f
500
+ ? f.startRange
501
+ : f.start.range
502
+ ? f.start.range[0]
503
+ : 0;
504
+ const endLine =
505
+ "endLine" in f
506
+ ? f.endLine
507
+ : (f.end.loc?.end.line ?? f.end.line ?? startLine);
508
+ const endCol =
509
+ "endCol" in f
510
+ ? f.endCol
511
+ : (f.end.loc?.end.column ?? f.end.column ?? startCol);
512
+ const endPos =
513
+ "endPos" in f
514
+ ? f.endPos
515
+ : (f.end.loc?.end.position ?? f.end.position ?? 0);
516
+ const endRange =
517
+ "endRange" in f ? f.endRange : f.end.range ? f.end.range[1] : 0;
518
+
519
+ return new CompactSourceFrame(
520
+ f.id,
521
+ sourceId,
522
+ startLine,
523
+ startCol,
524
+ startPos,
525
+ startRange,
526
+ endLine,
527
+ endCol,
528
+ endPos,
529
+ endRange,
530
+ );
531
+ });
532
+ } else if (shardTokens && shardTokens.length > 0) {
533
+ normalizedFrames = reconstructFramesFromTokens(
534
+ shardTokens,
535
+ sourceId,
536
+ this.#minTokens,
537
+ this.#hashFunction,
538
+ );
539
+ } else {
540
+ normalizedFrames = [];
541
+ }
542
+
543
+ const hashes: string[] = new Array(normalizedFrames.length);
544
+ for (let i = 0; i < normalizedFrames.length; i++) {
545
+ hashes[i] = normalizedFrames[i]!.id;
546
+ }
547
+
548
+ const newClones = this.#detectClonesFromFrames(
549
+ normalizedFrames,
550
+ sourceId,
551
+ format,
552
+ {
553
+ insertFrames: true,
554
+ },
555
+ );
556
+
557
+ this.hashesBySource.set(sourceId, hashes);
558
+ if (shardTokens && shardTokens.length > 0) {
559
+ this.#tokensBySource.set(sourceId, shardTokens);
560
+ }
561
+ this.sources.set(sourceId, {
562
+ sourceId,
563
+ format,
564
+ size,
565
+ lines,
566
+ tokenCount,
567
+ updatedAt: shard.updatedAt ?? Date.now(),
568
+ });
569
+ this.clones.push(...newClones);
570
+ return newClones;
571
+ }
572
+
573
+ /**
574
+ * Remove a source from the index.
575
+ * Performs fast O(frames) multi-contributor cleanup:
576
+ * - If frame is an array, removes only the frame matching sourceId.
577
+ * - If remaining is 1 frame, unwraps to a single frame so alternate sources remain searchable.
578
+ * - If frame was single and belonged to sourceId, deletes the hash entry.
579
+ */
580
+ removeSource(sourceId: string): void {
581
+ if (!this.sources.has(sourceId)) {
582
+ return;
583
+ }
584
+
585
+ const hashes = this.hashesBySource.get(sourceId);
586
+ if (hashes) {
587
+ for (const hash of hashes) {
588
+ const entry = this.framesByHash.get(hash);
589
+ if (!entry) {
590
+ continue;
591
+ }
592
+
593
+ if (Array.isArray(entry)) {
594
+ const remaining = entry.filter((f) => f.sourceId !== sourceId);
595
+ if (remaining.length === 0) {
596
+ this.framesByHash.delete(hash);
597
+ } else if (remaining.length === 1) {
598
+ this.framesByHash.set(hash, remaining[0]!);
599
+ } else {
600
+ this.framesByHash.set(hash, remaining);
601
+ }
602
+ } else if (entry.sourceId === sourceId) {
603
+ this.framesByHash.delete(hash);
604
+ }
605
+ }
606
+ this.hashesBySource.delete(sourceId);
607
+ }
608
+
609
+ this.#tokensBySource.delete(sourceId);
610
+ this.sources.delete(sourceId);
611
+ this.clones = this.clones.filter(
612
+ (c) =>
613
+ c.duplicationA.sourceId !== sourceId &&
614
+ c.duplicationB.sourceId !== sourceId,
615
+ );
616
+ }
617
+ /**
618
+ * Update an existing source with modified content.
619
+ */
620
+ updateSource(sourceId: string, content: string, format?: string): IClone[] {
621
+ this.removeSource(sourceId);
622
+ return this.addSource(sourceId, content, format);
623
+ }
624
+
625
+ /**
626
+ * Check a code snippet or modified file against the index without mutating state.
627
+ * Returns duplicate clones matching against indexed repository files.
628
+ */
629
+ checkSnippet(sourceId: string, content: string, format?: string): IClone[] {
630
+ const tokenData = this.#generateTokenMaps(sourceId, content, format);
631
+ if (!tokenData) {
632
+ return [];
633
+ }
634
+
635
+ const shard = this.tokenizeSource(sourceId, content, "", format);
636
+ if (!shard) {
637
+ return [];
638
+ }
639
+
640
+ return this.#detectClonesFromFrames(shard.frames, sourceId, shard.format, {
641
+ insertFrames: false,
642
+ });
643
+ }
644
+
645
+ #generateTokenMaps(
646
+ sourceId: string,
647
+ content: string,
648
+ format?: string,
649
+ ): TokenMapsResult | null {
650
+ const resolvedFormat =
651
+ format ?? getSupportedCodeFormat(sourceId, this.#formatsExts);
652
+ if (!resolvedFormat) {
653
+ return null;
654
+ }
655
+
656
+ try {
657
+ const maps = this.#tokenizer.generateMaps(
658
+ sourceId,
659
+ content,
660
+ resolvedFormat,
661
+ this.#options,
662
+ );
663
+ return maps && maps.length > 0 ? { format: resolvedFormat, maps } : null;
664
+ } catch {
665
+ return null;
666
+ }
667
+ }
668
+
669
+ #detectClonesFromFrames(
670
+ frames: SourceFrame[],
671
+ sourceId: string,
672
+ format: string,
673
+ options: { insertFrames?: boolean } = {},
674
+ ): IClone[] {
675
+ const detectedClones: IClone[] = [];
676
+ const { insertFrames = false } = options;
677
+ let activeClones = new Map<string, ActiveCloneCandidate>();
678
+
679
+ for (const frame of frames) {
680
+ const frameStartLine =
681
+ "startLine" in frame
682
+ ? frame.startLine
683
+ : (frame.start.loc?.start.line ?? frame.start.line ?? 1);
684
+ const frameStartCol =
685
+ "startCol" in frame
686
+ ? frame.startCol
687
+ : (frame.start.loc?.start.column ?? frame.start.column ?? 1);
688
+ const frameStartPos =
689
+ "startPos" in frame
690
+ ? frame.startPos
691
+ : (frame.start.loc?.start.position ?? frame.start.position ?? 0);
692
+ const frameStartRange =
693
+ "startRange" in frame
694
+ ? frame.startRange
695
+ : frame.start.range
696
+ ? frame.start.range[0]
697
+ : 0;
698
+ const frameEndLine =
699
+ "endLine" in frame
700
+ ? frame.endLine
701
+ : (frame.end.loc?.end.line ?? frame.end.line ?? frameStartLine);
702
+ const frameEndCol =
703
+ "endCol" in frame
704
+ ? frame.endCol
705
+ : (frame.end.loc?.end.column ?? frame.end.column ?? frameStartCol);
706
+ const frameEndPos =
707
+ "endPos" in frame
708
+ ? frame.endPos
709
+ : (frame.end.loc?.end.position ?? frame.end.position ?? 0);
710
+ const frameEndRange =
711
+ "endRange" in frame
712
+ ? frame.endRange
713
+ : frame.end.range
714
+ ? frame.end.range[1]
715
+ : 0;
716
+
717
+ const normalizedFrame: CompactSourceFrame =
718
+ frame instanceof CompactSourceFrame && frame.sourceId === sourceId
719
+ ? frame
720
+ : new CompactSourceFrame(
721
+ frame.id,
722
+ sourceId,
723
+ frameStartLine,
724
+ frameStartCol,
725
+ frameStartPos,
726
+ frameStartRange,
727
+ frameEndLine,
728
+ frameEndCol,
729
+ frameEndPos,
730
+ frameEndRange,
731
+ );
732
+ const frameHash = normalizedFrame.id;
733
+
734
+ const candidates = this.framesByHash.get(frameHash);
735
+
736
+ // Fast-path bypass when there are no candidate frames and no active clones extending
737
+ if (!candidates && activeClones.size === 0) {
738
+ if (insertFrames) {
739
+ this.#insertFrame(frameHash, normalizedFrame);
740
+ }
741
+ continue;
742
+ }
743
+
744
+ const matchedFrames: CompactSourceFrame[] = candidates
745
+ ? Array.isArray(candidates)
746
+ ? candidates
747
+ : [candidates]
748
+ : [];
749
+
750
+ if (matchedFrames.length === 0 && activeClones.size === 0) {
751
+ if (insertFrames) {
752
+ this.#insertFrame(frameHash, normalizedFrame);
753
+ }
754
+ continue;
755
+ }
756
+
757
+ if (matchedFrames.length === 0) {
758
+ for (const active of activeClones.values()) {
759
+ if (this.#validateClone(active.clone)) {
760
+ detectedClones.push(active.clone);
761
+ }
762
+ }
763
+ activeClones.clear();
764
+ if (insertFrames) {
765
+ this.#insertFrame(frameHash, normalizedFrame);
766
+ }
767
+ continue;
768
+ }
769
+
770
+ const nextActiveClones = new Map<string, ActiveCloneCandidate>();
771
+
772
+ for (const targetFrame of matchedFrames) {
773
+ const targetStartLine = targetFrame.startLine;
774
+ const targetStartCol = targetFrame.startCol;
775
+ const targetStartPos = targetFrame.startPos;
776
+ const targetStartRange = targetFrame.startRange;
777
+ const targetEndLine = targetFrame.endLine;
778
+ const targetEndCol = targetFrame.endCol;
779
+ const targetEndPos = targetFrame.endPos;
780
+ const targetEndRange = targetFrame.endRange;
781
+
782
+ // Disallow exact self-match at identical line/column position
783
+ if (
784
+ targetFrame.sourceId === sourceId &&
785
+ targetStartLine === frameStartLine &&
786
+ targetStartCol === frameStartCol
787
+ ) {
788
+ continue;
789
+ }
790
+
791
+ const offsetKey = `${targetFrame.sourceId}:${targetStartRange - frameStartRange}`;
792
+
793
+ if (activeClones.has(offsetKey)) {
794
+ const candidate = activeClones.get(offsetKey)!;
795
+ if (candidate.clone.duplicationA.range) {
796
+ candidate.clone.duplicationA.range[1] = frameEndRange;
797
+ }
798
+ candidate.clone.duplicationA.end = {
799
+ line: frameEndLine,
800
+ column: frameEndCol,
801
+ position: frameEndPos,
802
+ };
803
+ if (candidate.clone.duplicationB.range) {
804
+ candidate.clone.duplicationB.range[1] = targetEndRange;
805
+ }
806
+ candidate.clone.duplicationB.end = {
807
+ line: targetEndLine,
808
+ column: targetEndCol,
809
+ position: targetEndPos,
810
+ };
811
+ candidate.lastSourceEndRange = frameEndRange;
812
+ candidate.lastTargetEndRange = targetEndRange;
813
+ nextActiveClones.set(offsetKey, candidate);
814
+ } else {
815
+ const clone: IClone = {
816
+ format,
817
+ foundDate: Date.now(),
818
+ duplicationA: {
819
+ sourceId,
820
+ start: {
821
+ line: frameStartLine,
822
+ column: frameStartCol,
823
+ position: frameStartPos,
824
+ },
825
+ end: {
826
+ line: frameEndLine,
827
+ column: frameEndCol,
828
+ position: frameEndPos,
829
+ },
830
+ range: [frameStartRange, frameEndRange],
831
+ },
832
+ duplicationB: {
833
+ sourceId: targetFrame.sourceId,
834
+ start: {
835
+ line: targetStartLine,
836
+ column: targetStartCol,
837
+ position: targetStartPos,
838
+ },
839
+ end: {
840
+ line: targetEndLine,
841
+ column: targetEndCol,
842
+ position: targetEndPos,
843
+ },
844
+ range: [targetStartRange, targetEndRange],
845
+ },
846
+ };
847
+
848
+ nextActiveClones.set(offsetKey, {
849
+ clone,
850
+ targetFrame,
851
+ lastSourceEndRange: frameEndRange,
852
+ lastTargetEndRange: targetEndRange,
853
+ });
854
+ }
855
+ }
856
+
857
+ if (activeClones.size > 0) {
858
+ for (const [key, active] of activeClones.entries()) {
859
+ if (!nextActiveClones.has(key)) {
860
+ if (this.#validateClone(active.clone)) {
861
+ detectedClones.push(active.clone);
862
+ }
863
+ }
864
+ }
865
+ }
866
+
867
+ activeClones = nextActiveClones;
868
+
869
+ if (insertFrames) {
870
+ this.#insertFrame(frameHash, normalizedFrame);
871
+ }
872
+ }
873
+
874
+ for (const active of activeClones.values()) {
875
+ if (this.#validateClone(active.clone)) {
876
+ detectedClones.push(active.clone);
877
+ }
878
+ }
879
+
880
+ return detectedClones;
881
+ }
882
+
883
+ #insertFrame(hash: string, frame: CompactSourceFrame): void {
884
+ const existing = this.framesByHash.get(hash);
885
+ if (!existing) {
886
+ this.framesByHash.set(hash, frame);
887
+ return;
888
+ }
889
+
890
+ if (Array.isArray(existing)) {
891
+ const alreadyPresent = existing.some(
892
+ (f) =>
893
+ f.sourceId === frame.sourceId && f.startRange === frame.startRange,
894
+ );
895
+ if (!alreadyPresent) {
896
+ existing.push(frame);
897
+ }
898
+ } else {
899
+ if (
900
+ existing.sourceId !== frame.sourceId ||
901
+ existing.startRange !== frame.startRange
902
+ ) {
903
+ this.framesByHash.set(hash, [existing, frame]);
904
+ }
905
+ }
906
+ }
907
+
908
+ #validateClone(clone: IClone): boolean {
909
+ const lines =
910
+ clone.duplicationA.end.line - clone.duplicationA.start.line + 1;
911
+ if (lines < this.#minLines) {
912
+ return false;
913
+ }
914
+ if (this.#maxLines && lines > this.#maxLines) {
915
+ return false;
916
+ }
917
+ return true;
918
+ }
919
+ }