omp-plugin-duplicate-detector 0.1.1 → 0.2.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-plugin-duplicate-detector",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "Code clone and duplication detector plugin for oh-my-pi powered by jscpd",
5
5
  "type": "module",
6
6
  "files": [
@@ -473,15 +473,19 @@ export class DuplicateDetectorCoordinator extends EventEmitter<CoordinatorEvents
473
473
  /**
474
474
  * Reconcile multiple modified or removed files with the index.
475
475
  */
476
- async reconcile(files: ReconcileFileEntry[]): Promise<void> {
476
+ async reconcile(
477
+ files: ReconcileFileEntry[],
478
+ ): Promise<{ reconciledCount: number }> {
477
479
  try {
478
480
  const payload: ReconcilePayload = { files };
479
- await this.#sendRequest<{ reconciledCount: number }>(
481
+ return await this.#sendRequest<{ reconciledCount: number }>(
480
482
  "reconcile",
481
483
  payload,
482
484
  );
483
485
  } catch (err) {
484
- this.emit("error", err instanceof Error ? err : new Error(String(err)));
486
+ const error = err instanceof Error ? err : new Error(String(err));
487
+ this.emit("error", error);
488
+ throw error;
485
489
  }
486
490
  }
487
491
 
@@ -5,10 +5,11 @@
5
5
  */
6
6
 
7
7
  import * as crypto from "node:crypto";
8
+ import * as fsSync from "node:fs";
8
9
  import * as fs from "node:fs/promises";
9
10
  import * as path from "node:path";
10
11
  import type { IClone } from "@jscpd/core";
11
- import { DiskCacheManager } from "./disk-cache";
12
+ import { cleanupLegacyCacheFiles, DiskCacheManager } from "./disk-cache";
12
13
  import {
13
14
  type BaselineStatus,
14
15
  createIgnoreFilter,
@@ -22,6 +23,7 @@ import {
22
23
  MAX_INDEXED_FILES,
23
24
  MAX_TOTAL_SOURCE_BYTES,
24
25
  } from "./jscpd-engine";
26
+ import { canonicalizePath, resolveRepositoryContext } from "./repo-context";
25
27
  import {
26
28
  type SerializedSourceShard,
27
29
  SourceAwareCloneIndex,
@@ -87,24 +89,100 @@ function areOptionsEqual(a?: WorkspaceOptions, b?: WorkspaceOptions): boolean {
87
89
  const bFormats = b.formatsExts ? JSON.stringify(b.formatsExts) : "";
88
90
  return aFormats === bFormats;
89
91
  }
92
+ /**
93
+ * Verifies that source files involved in detected clones exist on disk.
94
+ * If an indexed repository source has been deleted or moved, it is evicted from currentIndex,
95
+ * disk cache, and watchedRevisions, and the stale clone is filtered out.
96
+ *
97
+ * activeFilePath: the file currently being queried or updated in memory (exempt from disk check).
98
+ */
99
+ function filterAndEvictStaleClones(
100
+ clones: IClone[],
101
+ activeFilePath?: string,
102
+ ): IClone[] {
103
+ if (!currentRootDir || !fsSync.existsSync(currentRootDir)) {
104
+ return clones;
105
+ }
106
+
107
+ const validClones: IClone[] = [];
108
+ const evictedSources = new Set<string>();
109
+ const activeRes = activeFilePath ? path.resolve(activeFilePath) : null;
110
+ const activeCan = activeFilePath ? canonicalizePath(activeFilePath) : null;
111
+
112
+ const isActive = (src: string): boolean => {
113
+ if (!activeFilePath) return false;
114
+ if (src === activeFilePath) return true;
115
+ if (activeRes && path.resolve(src) === activeRes) return true;
116
+ if (activeCan && canonicalizePath(src) === activeCan) return true;
117
+ return false;
118
+ };
119
+
120
+ const isStaleSource = (src: string): boolean => {
121
+ if (isActive(src)) return false;
122
+ if (evictedSources.has(src)) return true;
123
+
124
+ const can = canonicalizePath(
125
+ path.isAbsolute(src) ? src : path.resolve(currentRootDir, src),
126
+ );
127
+ const rel = path.relative(currentRootDir, can);
128
+ const isInside = !rel.startsWith("..") && !path.isAbsolute(rel);
129
+ if (isInside && !fsSync.existsSync(can)) {
130
+ evictedSources.add(src);
131
+ currentIndex.removeSource(src);
132
+ watchedRevisions.delete(src);
133
+ watchedRevisions.delete(can);
134
+ watchedRevisions.delete(path.resolve(src));
135
+ if (currentDiskCache) {
136
+ currentDiskCache.deleteByRelPath(rel).catch(() => {});
137
+ }
138
+ return true;
139
+ }
140
+ return false;
141
+ };
142
+
143
+ for (const clone of clones) {
144
+ const srcA = clone.duplicationA.sourceId;
145
+ const srcB = clone.duplicationB.sourceId;
146
+
147
+ if (isStaleSource(srcA) || isStaleSource(srcB)) {
148
+ continue;
149
+ }
150
+
151
+ validClones.push(clone);
152
+ }
90
153
 
154
+ return validClones;
155
+ }
91
156
  function notifyLateFindings(clones: IClone[]): void {
92
157
  for (const clone of clones) {
93
158
  const srcA = clone.duplicationA.sourceId;
94
159
  const srcB = clone.duplicationB.sourceId;
95
160
  const resA = path.resolve(srcA);
96
161
  const resB = path.resolve(srcB);
97
-
98
- const isWatchedA = watchedRevisions.has(srcA) || watchedRevisions.has(resA);
99
- const isWatchedB = watchedRevisions.has(srcB) || watchedRevisions.has(resB);
162
+ const canA = canonicalizePath(srcA);
163
+ const canB = canonicalizePath(srcB);
164
+ const isWatchedA =
165
+ watchedRevisions.has(srcA) ||
166
+ watchedRevisions.has(resA) ||
167
+ watchedRevisions.has(canA);
168
+ const isWatchedB =
169
+ watchedRevisions.has(srcB) ||
170
+ watchedRevisions.has(resB) ||
171
+ watchedRevisions.has(canB);
100
172
 
101
173
  if (isWatchedA || isWatchedB) {
102
174
  if (isWatchedA) {
103
- const entry = watchedRevisions.get(srcA) ?? watchedRevisions.get(resA);
175
+ const entry =
176
+ watchedRevisions.get(srcA) ??
177
+ watchedRevisions.get(resA) ??
178
+ watchedRevisions.get(canA);
104
179
  if (entry) entry.lastKnownCloneCount++;
105
180
  }
106
181
  if (isWatchedB) {
107
- const entry = watchedRevisions.get(srcB) ?? watchedRevisions.get(resB);
182
+ const entry =
183
+ watchedRevisions.get(srcB) ??
184
+ watchedRevisions.get(resB) ??
185
+ watchedRevisions.get(canB);
108
186
  if (entry) entry.lastKnownCloneCount++;
109
187
  }
110
188
  self.postMessage(createLateFindingEvent(clone));
@@ -307,6 +385,10 @@ async function runBaselineIndexing(
307
385
  if (signal.aborted) return { indexedCount, status: "cancelled" };
308
386
 
309
387
  // Ingest items into index and notify late findings
388
+ const shardsToSave: Array<{
389
+ shard: SerializedSourceShard;
390
+ relPath: string;
391
+ }> = [];
310
392
  for (const item of fileItems) {
311
393
  if (!item) continue;
312
394
 
@@ -323,9 +405,10 @@ async function runBaselineIndexing(
323
405
  newClones = currentIndex.hydrateSourceShard(item.cachedShard);
324
406
  if (item.isNewlyTokenized) {
325
407
  if (currentDiskCache && item.contentHash && item.relPath) {
326
- currentDiskCache
327
- .saveShard(item.cachedShard, item.relPath)
328
- .catch(() => {});
408
+ shardsToSave.push({
409
+ shard: item.cachedShard,
410
+ relPath: item.relPath,
411
+ });
329
412
  }
330
413
  } else {
331
414
  cachedCount++;
@@ -338,7 +421,7 @@ async function runBaselineIndexing(
338
421
  item.contentHash,
339
422
  );
340
423
  if (shard) {
341
- currentDiskCache.saveShard(shard, item.relPath).catch(() => {});
424
+ shardsToSave.push({ shard, relPath: item.relPath });
342
425
  }
343
426
  }
344
427
  }
@@ -350,6 +433,10 @@ async function runBaselineIndexing(
350
433
  }
351
434
  }
352
435
 
436
+ if (shardsToSave.length > 0 && currentDiskCache) {
437
+ await currentDiskCache.saveShards(shardsToSave);
438
+ }
439
+
353
440
  const processedCount = Math.min(i + batch.length, totalFiles);
354
441
  const percentage =
355
442
  totalFiles > 0 ? Math.round((processedCount / totalFiles) * 100) : 100;
@@ -543,11 +630,12 @@ async function handleWorkerRequest(msg: WorkerRequestMessage): Promise<void> {
543
630
  switch (msg.type) {
544
631
  case "openWorkspace": {
545
632
  const { rootDir, options } = msg.payload;
633
+ const canonicalRootDir = canonicalizePath(rootDir);
546
634
 
547
- // If rootDir === currentRootDir and options match and baseline is already complete or indexing,
635
+ // If canonicalRootDir === currentRootDir and options match and baseline is already complete or indexing,
548
636
  // avoid total reset; trigger an incremental git status check instead.
549
637
  if (
550
- currentRootDir === rootDir &&
638
+ (currentRootDir === canonicalRootDir || currentRootDir === rootDir) &&
551
639
  areOptionsEqual(currentOptions, options) &&
552
640
  (isBaselineComplete || isBaselineIndexing)
553
641
  ) {
@@ -557,14 +645,14 @@ async function handleWorkerRequest(msg: WorkerRequestMessage): Promise<void> {
557
645
  }
558
646
  activeAbortController = new AbortController();
559
647
  const recResult = await runIncrementalGitReconciliation(
560
- rootDir,
648
+ currentRootDir,
561
649
  options,
562
650
  activeAbortController.signal,
563
651
  );
564
652
  self.postMessage(
565
653
  createSuccessResponse(msg.id, {
566
654
  started: true,
567
- rootDir,
655
+ rootDir: currentRootDir,
568
656
  reused: true,
569
657
  indexedCount: recResult.indexedCount,
570
658
  status: recResult.status,
@@ -574,7 +662,7 @@ async function handleWorkerRequest(msg: WorkerRequestMessage): Promise<void> {
574
662
  self.postMessage(
575
663
  createSuccessResponse(msg.id, {
576
664
  started: true,
577
- rootDir,
665
+ rootDir: currentRootDir,
578
666
  reused: true,
579
667
  indexedCount: currentIndex.stats().sourceCount,
580
668
  status: "complete" as BaselineStatus,
@@ -594,11 +682,17 @@ async function handleWorkerRequest(msg: WorkerRequestMessage): Promise<void> {
594
682
  currentDiskCache = null;
595
683
  }
596
684
 
597
- currentRootDir = rootDir;
685
+ const repoContext = await resolveRepositoryContext(
686
+ rootDir,
687
+ activeAbortController.signal,
688
+ );
689
+ const effectiveRoot = repoContext.workspaceRoot;
690
+ currentRootDir = effectiveRoot;
598
691
  currentOptions = options;
599
692
  currentIndex = new SourceAwareCloneIndex(options);
600
693
  currentDiskCache = new DiskCacheManager({
601
- rootDir,
694
+ rootDir: effectiveRoot,
695
+ repositoryKey: repoContext.repositoryKey,
602
696
  cacheDir: options?.cacheDir,
603
697
  config: options,
604
698
  maxBytes: options?.maxCacheBytes,
@@ -607,41 +701,52 @@ async function handleWorkerRequest(msg: WorkerRequestMessage): Promise<void> {
607
701
  isBaselineIndexing = true;
608
702
  isBaselineComplete = false;
609
703
 
704
+ // Clean up legacy pre-v4 caches in background
705
+ cleanupLegacyCacheFiles(options?.cacheDir).catch(() => {});
706
+
610
707
  self.postMessage(
611
708
  createSuccessResponse(msg.id, {
612
709
  started: true,
613
- rootDir,
710
+ rootDir: effectiveRoot,
614
711
  reused: false,
615
712
  }),
616
713
  );
617
714
 
618
715
  // Run background indexing task (posts complete event when finished)
619
- runBaselineIndexing(rootDir, options, activeAbortController.signal).catch(
620
- () => {},
621
- );
716
+ runBaselineIndexing(
717
+ effectiveRoot,
718
+ options,
719
+ activeAbortController.signal,
720
+ ).catch(() => {});
622
721
  break;
623
722
  }
624
723
 
625
724
  case "checkSnippet": {
626
725
  const { filePath, content, format } = msg.payload;
627
- const clones = currentIndex.checkSnippet(filePath, content, format);
726
+ const rawClones = currentIndex.checkSnippet(filePath, content, format);
727
+ const clones = filterAndEvictStaleClones(rawClones, filePath);
628
728
  self.postMessage(createSuccessResponse(msg.id, clones));
629
729
  break;
630
730
  }
631
731
 
632
732
  case "checkAndUpdate": {
633
733
  const { filePath, content, format, revision = 1 } = msg.payload;
634
- const clones = currentIndex.updateSource(filePath, content, format);
734
+ const rawClones = currentIndex.updateSource(filePath, content, format);
735
+ const canonicalFilePath = canonicalizePath(filePath);
635
736
  const resolvedPath = path.resolve(filePath);
636
737
 
637
738
  cacheSourceShard(filePath, content);
638
739
 
740
+ const clones = filterAndEvictStaleClones(rawClones, filePath);
741
+
639
742
  const fileClones = clones.filter(
640
743
  (c) =>
641
744
  c.duplicationA.sourceId === filePath ||
642
745
  c.duplicationB.sourceId === filePath ||
643
746
  path.resolve(c.duplicationA.sourceId) === resolvedPath ||
644
- path.resolve(c.duplicationB.sourceId) === resolvedPath,
747
+ path.resolve(c.duplicationB.sourceId) === resolvedPath ||
748
+ canonicalizePath(c.duplicationA.sourceId) === canonicalFilePath ||
749
+ canonicalizePath(c.duplicationB.sourceId) === canonicalFilePath,
645
750
  );
646
751
 
647
752
  const watchEntry = {
@@ -650,10 +755,11 @@ async function handleWorkerRequest(msg: WorkerRequestMessage): Promise<void> {
650
755
  };
651
756
  watchedRevisions.set(filePath, watchEntry);
652
757
  watchedRevisions.set(resolvedPath, watchEntry);
758
+ watchedRevisions.set(canonicalFilePath, watchEntry);
653
759
 
654
760
  self.postMessage(
655
761
  createSuccessResponse(msg.id, {
656
- clones,
762
+ clones: fileClones,
657
763
  isComplete: isBaselineComplete,
658
764
  }),
659
765
  );
@@ -662,8 +768,9 @@ async function handleWorkerRequest(msg: WorkerRequestMessage): Promise<void> {
662
768
 
663
769
  case "updateFile": {
664
770
  const { filePath, content, format } = msg.payload;
665
- const clones = currentIndex.updateSource(filePath, content, format);
771
+ const rawClones = currentIndex.updateSource(filePath, content, format);
666
772
  cacheSourceShard(filePath, content);
773
+ const clones = filterAndEvictStaleClones(rawClones, filePath);
667
774
  self.postMessage(createSuccessResponse(msg.id, { clones }));
668
775
  break;
669
776
  }
@@ -671,6 +778,13 @@ async function handleWorkerRequest(msg: WorkerRequestMessage): Promise<void> {
671
778
  case "removeFile": {
672
779
  const { filePath } = msg.payload;
673
780
  currentIndex.removeSource(filePath);
781
+ if (currentDiskCache) {
782
+ const relPath =
783
+ path.isAbsolute(filePath) && currentRootDir
784
+ ? path.relative(currentRootDir, filePath)
785
+ : filePath;
786
+ currentDiskCache.deleteByRelPath(relPath).catch(() => {});
787
+ }
674
788
  self.postMessage(createSuccessResponse(msg.id, { removed: true }));
675
789
  break;
676
790
  }
@@ -693,6 +807,13 @@ async function handleWorkerRequest(msg: WorkerRequestMessage): Promise<void> {
693
807
  )
694
808
  ) {
695
809
  currentIndex.removeSource(fileEntry.filePath);
810
+ if (currentDiskCache) {
811
+ const relPath =
812
+ path.isAbsolute(fileEntry.filePath) && currentRootDir
813
+ ? path.relative(currentRootDir, fileEntry.filePath)
814
+ : fileEntry.filePath;
815
+ currentDiskCache.deleteByRelPath(relPath).catch(() => {});
816
+ }
696
817
  continue;
697
818
  }
698
819
  const stat = await fs.stat(fileEntry.filePath);
@@ -706,8 +827,15 @@ async function handleWorkerRequest(msg: WorkerRequestMessage): Promise<void> {
706
827
  }
707
828
  }
708
829
  } catch {
709
- // If file cannot be read or no longer exists, remove from index
830
+ // If file cannot be read or no longer exists, remove from index and disk cache
710
831
  currentIndex.removeSource(fileEntry.filePath);
832
+ if (currentDiskCache) {
833
+ const relPath =
834
+ path.isAbsolute(fileEntry.filePath) && currentRootDir
835
+ ? path.relative(currentRootDir, fileEntry.filePath)
836
+ : fileEntry.filePath;
837
+ currentDiskCache.deleteByRelPath(relPath).catch(() => {});
838
+ }
711
839
  }
712
840
  }
713
841
 
@@ -764,7 +892,7 @@ async function handleWorkerRequest(msg: WorkerRequestMessage): Promise<void> {
764
892
  clones = currentIndex.getClones();
765
893
  }
766
894
  } else {
767
- clones = currentIndex.getClones();
895
+ clones = filterAndEvictStaleClones(currentIndex.getClones());
768
896
  }
769
897
 
770
898
  self.postMessage(createSuccessResponse(msg.id, clones));