omp-plugin-duplicate-detector 0.2.3 → 0.3.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.
@@ -977,11 +977,11 @@ import * as path5 from "path";
977
977
 
978
978
  // src/disk-cache.ts
979
979
  import { Database } from "bun:sqlite";
980
- import * as crypto from "crypto";
981
- import * as fsSync from "fs";
982
- import * as fs from "fs/promises";
980
+ import * as crypto2 from "crypto";
981
+ import * as fsSync2 from "fs";
982
+ import * as fs2 from "fs/promises";
983
983
  import * as os from "os";
984
- import * as path3 from "path";
984
+ import * as path4 from "path";
985
985
  import * as zlib from "zlib";
986
986
 
987
987
  // node_modules/eventemitter3/index.mjs
@@ -12292,6 +12292,95 @@ async function getTrackedGitFiles(rootDir, options = {}) {
12292
12292
  }
12293
12293
  }
12294
12294
 
12295
+ // src/repo-context.ts
12296
+ import * as crypto from "crypto";
12297
+ import * as fsSync from "fs";
12298
+ import * as fs from "fs/promises";
12299
+ import * as path3 from "path";
12300
+ function canonicalizePath(targetPath) {
12301
+ const resolved = path3.resolve(targetPath);
12302
+ let current = resolved;
12303
+ let suffix = "";
12304
+ while (current && current !== path3.dirname(current)) {
12305
+ try {
12306
+ if (fsSync.existsSync(current)) {
12307
+ const real = fsSync.realpathSync(current);
12308
+ return suffix ? path3.join(real, suffix) : real;
12309
+ }
12310
+ } catch {}
12311
+ suffix = suffix ? path3.join(path3.basename(current), suffix) : path3.basename(current);
12312
+ current = path3.dirname(current);
12313
+ }
12314
+ return resolved;
12315
+ }
12316
+ function isOmpWorktreePath(targetPath) {
12317
+ const normalized = targetPath.replace(/\\/g, "/");
12318
+ return normalized.includes("/.omp/wt/") || normalized.includes("/.omp/worktrees/");
12319
+ }
12320
+ async function resolveRepositoryContext(cwd, signal) {
12321
+ const canonicalCwd = canonicalizePath(cwd);
12322
+ try {
12323
+ const { stdout } = await execGit([
12324
+ "rev-parse",
12325
+ "--path-format=absolute",
12326
+ "--show-toplevel",
12327
+ "--git-dir",
12328
+ "--git-common-dir"
12329
+ ], canonicalCwd, { signal });
12330
+ const lines = stdout.split(`
12331
+ `).map((l) => l.trim()).filter(Boolean);
12332
+ const resolveEntry = (val) => {
12333
+ if (!val)
12334
+ return "";
12335
+ return path3.isAbsolute(val) ? val : path3.resolve(canonicalCwd, val);
12336
+ };
12337
+ const workspaceRoot = canonicalizePath(resolveEntry(lines[0]));
12338
+ const gitDir = canonicalizePath(resolveEntry(lines[1] || path3.join(workspaceRoot, ".git")));
12339
+ const commonGitDir = canonicalizePath(resolveEntry(lines[2] || lines[1] || path3.join(workspaceRoot, ".git")));
12340
+ let repositoryObjectDir = path3.join(commonGitDir, "objects");
12341
+ const isOmpIsolation = isOmpWorktreePath(workspaceRoot) || isOmpWorktreePath(canonicalCwd);
12342
+ if (isOmpIsolation && gitDir === commonGitDir) {
12343
+ const alternatesFile = path3.join(gitDir, "objects", "info", "alternates");
12344
+ try {
12345
+ if (fsSync.existsSync(alternatesFile)) {
12346
+ const content = await fs.readFile(alternatesFile, "utf-8");
12347
+ const altLines = content.split(`
12348
+ `).map((l) => l.trim()).filter((l) => l && !l.startsWith("#"));
12349
+ for (const line of altLines) {
12350
+ const resolvedAlt = path3.isAbsolute(line) ? line : path3.resolve(path3.join(gitDir, "objects"), line);
12351
+ const canonicalAlt = canonicalizePath(resolvedAlt);
12352
+ if (fsSync.existsSync(canonicalAlt)) {
12353
+ repositoryObjectDir = canonicalAlt;
12354
+ break;
12355
+ }
12356
+ }
12357
+ }
12358
+ } catch {}
12359
+ }
12360
+ repositoryObjectDir = canonicalizePath(repositoryObjectDir);
12361
+ const repositoryKey = crypto.createHash("sha256").update(`git-object-dir\x00${repositoryObjectDir}`).digest("hex").slice(0, 16);
12362
+ return {
12363
+ workspaceRoot,
12364
+ isGit: true,
12365
+ gitDir,
12366
+ commonGitDir,
12367
+ repositoryObjectDir,
12368
+ repositoryKey,
12369
+ isOmpIsolation
12370
+ };
12371
+ } catch {
12372
+ const repositoryObjectDir = path3.join(canonicalCwd, ".non-git");
12373
+ const repositoryKey = crypto.createHash("sha256").update(`directory\x00${canonicalCwd}`).digest("hex").slice(0, 16);
12374
+ return {
12375
+ workspaceRoot: canonicalCwd,
12376
+ isGit: false,
12377
+ repositoryObjectDir,
12378
+ repositoryKey,
12379
+ isOmpIsolation: false
12380
+ };
12381
+ }
12382
+ }
12383
+
12295
12384
  // src/source-aware-index.ts
12296
12385
  class CompactSourceFrame {
12297
12386
  id;
@@ -12673,6 +12762,7 @@ class SourceAwareCloneIndex {
12673
12762
  const detectedClones = [];
12674
12763
  const { insertFrames = false } = options;
12675
12764
  let activeClones = new Map;
12765
+ const canonicalSourceId = canonicalizePath(sourceId);
12676
12766
  for (const frame of frames) {
12677
12767
  const frameStartLine = "startLine" in frame ? frame.startLine : frame.start.loc?.start.line ?? frame.start.line ?? 1;
12678
12768
  const frameStartCol = "startCol" in frame ? frame.startCol : frame.start.loc?.start.column ?? frame.start.column ?? 1;
@@ -12720,7 +12810,8 @@ class SourceAwareCloneIndex {
12720
12810
  const targetEndCol = targetFrame.endCol;
12721
12811
  const targetEndPos = targetFrame.endPos;
12722
12812
  const targetEndRange = targetFrame.endRange;
12723
- if (targetFrame.sourceId === sourceId && targetStartLine === frameStartLine && targetStartCol === frameStartCol) {
12813
+ const isSameSource = targetFrame.sourceId === sourceId || canonicalizePath(targetFrame.sourceId) === canonicalSourceId;
12814
+ if (isSameSource && targetStartLine === frameStartLine && targetStartCol === frameStartCol) {
12724
12815
  continue;
12725
12816
  }
12726
12817
  const offsetKey = `${targetFrame.sourceId}:${targetStartRange - frameStartRange}`;
@@ -12843,15 +12934,15 @@ function getDefaultCacheDir() {
12843
12934
  if (process.platform === "win32") {
12844
12935
  const localAppData = process.env.LOCALAPPDATA;
12845
12936
  if (localAppData) {
12846
- return path3.join(localAppData, "omp", "duplicate-detector");
12937
+ return path4.join(localAppData, "omp", "duplicate-detector");
12847
12938
  }
12848
- return path3.join(os.homedir(), "AppData", "Local", "omp", "duplicate-detector");
12939
+ return path4.join(os.homedir(), "AppData", "Local", "omp", "duplicate-detector");
12849
12940
  }
12850
12941
  const xdgCacheHome = process.env.XDG_CACHE_HOME;
12851
12942
  if (xdgCacheHome) {
12852
- return path3.join(xdgCacheHome, "omp", "duplicate-detector");
12943
+ return path4.join(xdgCacheHome, "omp", "duplicate-detector");
12853
12944
  }
12854
- return path3.join(os.homedir(), ".cache", "omp", "duplicate-detector");
12945
+ return path4.join(os.homedir(), ".cache", "omp", "duplicate-detector");
12855
12946
  }
12856
12947
  function computeConfigFingerprint(config) {
12857
12948
  if (!config)
@@ -12871,12 +12962,12 @@ function computeConfigFingerprint(config) {
12871
12962
  crossFormats: config.crossFormats ?? false,
12872
12963
  formatsExts: sortedFormats
12873
12964
  };
12874
- return crypto.createHash("sha256").update(JSON.stringify(canonical)).digest("hex").slice(0, 16);
12965
+ return crypto2.createHash("sha256").update(JSON.stringify(canonical)).digest("hex").slice(0, 16);
12875
12966
  }
12876
12967
  function computeWorkspaceCachePath(baseDir, repositoryKeyOrRootDir, configFingerprint) {
12877
12968
  const isKey = /^[0-9a-f]{16}$/i.test(repositoryKeyOrRootDir);
12878
- const repoKey = isKey ? repositoryKeyOrRootDir : crypto.createHash("sha256").update(path3.resolve(repositoryKeyOrRootDir)).digest("hex").slice(0, 16);
12879
- return path3.join(baseDir, `${repoKey}_${configFingerprint}_v${CACHE_FORMAT_VERSION}.sqlite`);
12969
+ const repoKey = isKey ? repositoryKeyOrRootDir : crypto2.createHash("sha256").update(path4.resolve(repositoryKeyOrRootDir)).digest("hex").slice(0, 16);
12970
+ return path4.join(baseDir, `${repoKey}_${configFingerprint}_v${CACHE_FORMAT_VERSION}.sqlite`);
12880
12971
  }
12881
12972
  var CACHE_FORMAT_MAGIC = "DUP3";
12882
12973
  var CACHE_FORMAT_VERSION = 5;
@@ -13083,9 +13174,9 @@ class DiskCacheManager {
13083
13174
  #deleteAllStmt = null;
13084
13175
  #closed = false;
13085
13176
  constructor(options) {
13086
- this.rootDir = path3.resolve(options.rootDir);
13087
- this.repositoryKey = options.repositoryKey ?? crypto.createHash("sha256").update(this.rootDir).digest("hex").slice(0, 16);
13088
- this.baseCacheDir = options.cacheDir ? path3.resolve(options.cacheDir) : getDefaultCacheDir();
13177
+ this.rootDir = path4.resolve(options.rootDir);
13178
+ this.repositoryKey = options.repositoryKey ?? crypto2.createHash("sha256").update(this.rootDir).digest("hex").slice(0, 16);
13179
+ this.baseCacheDir = options.cacheDir ? path4.resolve(options.cacheDir) : getDefaultCacheDir();
13089
13180
  this.configFingerprint = computeConfigFingerprint(options.config);
13090
13181
  this.dbPath = computeWorkspaceCachePath(this.baseCacheDir, this.repositoryKey, this.configFingerprint);
13091
13182
  this.workspaceCacheDir = this.baseCacheDir;
@@ -13098,9 +13189,9 @@ class DiskCacheManager {
13098
13189
  return this.#db;
13099
13190
  let db = null;
13100
13191
  try {
13101
- const dir = path3.dirname(this.dbPath);
13102
- if (!fsSync.existsSync(dir)) {
13103
- fsSync.mkdirSync(dir, { recursive: true });
13192
+ const dir = path4.dirname(this.dbPath);
13193
+ if (!fsSync2.existsSync(dir)) {
13194
+ fsSync2.mkdirSync(dir, { recursive: true });
13104
13195
  }
13105
13196
  db = new Database(this.dbPath, { create: true });
13106
13197
  db.exec("PRAGMA busy_timeout = 2000;");
@@ -13179,7 +13270,7 @@ class DiskCacheManager {
13179
13270
  const db = this.#getDb();
13180
13271
  if (!db || !this.#saveStmt)
13181
13272
  return;
13182
- const targetRelPath = relPath ?? (path3.isAbsolute(shard.sourceId) ? path3.relative(this.rootDir, shard.sourceId) : shard.sourceId);
13273
+ const targetRelPath = relPath ?? (path4.isAbsolute(shard.sourceId) ? path4.relative(this.rootDir, shard.sourceId) : shard.sourceId);
13183
13274
  const normalizedRelPath = targetRelPath.replace(/\\/g, "/");
13184
13275
  const payload = packBinaryShard(shard);
13185
13276
  this.#saveStmt.run(normalizedRelPath, shard.contentHash, payload, Date.now());
@@ -13215,7 +13306,7 @@ class DiskCacheManager {
13215
13306
  const now = Date.now();
13216
13307
  const prepared = [];
13217
13308
  for (const item of items) {
13218
- const targetRelPath = item.relPath ?? (path3.isAbsolute(item.shard.sourceId) ? path3.relative(root, item.shard.sourceId) : item.shard.sourceId);
13309
+ const targetRelPath = item.relPath ?? (path4.isAbsolute(item.shard.sourceId) ? path4.relative(root, item.shard.sourceId) : item.shard.sourceId);
13219
13310
  const normalizedRelPath = targetRelPath.replace(/\\/g, "/");
13220
13311
  const payload = packBinaryShard(item.shard);
13221
13312
  prepared.push({
@@ -13297,108 +13388,19 @@ class DiskCacheManager {
13297
13388
  async function cleanupLegacyCacheFiles(customCacheDir) {
13298
13389
  const baseDir = customCacheDir ?? getDefaultCacheDir();
13299
13390
  try {
13300
- if (!fsSync.existsSync(baseDir))
13391
+ if (!fsSync2.existsSync(baseDir))
13301
13392
  return;
13302
- const entries = await fs.readdir(baseDir);
13393
+ const entries = await fs2.readdir(baseDir);
13303
13394
  for (const entry of entries) {
13304
13395
  if (entry.endsWith(".sqlite") && !entry.includes(`_v${CACHE_FORMAT_VERSION}.sqlite`)) {
13305
- await fs.unlink(path3.join(baseDir, entry)).catch(() => {});
13306
- await fs.unlink(path3.join(baseDir, `${entry}-wal`)).catch(() => {});
13307
- await fs.unlink(path3.join(baseDir, `${entry}-shm`)).catch(() => {});
13396
+ await fs2.unlink(path4.join(baseDir, entry)).catch(() => {});
13397
+ await fs2.unlink(path4.join(baseDir, `${entry}-wal`)).catch(() => {});
13398
+ await fs2.unlink(path4.join(baseDir, `${entry}-shm`)).catch(() => {});
13308
13399
  }
13309
13400
  }
13310
13401
  } catch {}
13311
13402
  }
13312
13403
 
13313
- // src/repo-context.ts
13314
- import * as crypto2 from "crypto";
13315
- import * as fsSync2 from "fs";
13316
- import * as fs2 from "fs/promises";
13317
- import * as path4 from "path";
13318
- function canonicalizePath(targetPath) {
13319
- const resolved = path4.resolve(targetPath);
13320
- let current = resolved;
13321
- let suffix = "";
13322
- while (current && current !== path4.dirname(current)) {
13323
- try {
13324
- if (fsSync2.existsSync(current)) {
13325
- const real = fsSync2.realpathSync(current);
13326
- return suffix ? path4.join(real, suffix) : real;
13327
- }
13328
- } catch {}
13329
- suffix = suffix ? path4.join(path4.basename(current), suffix) : path4.basename(current);
13330
- current = path4.dirname(current);
13331
- }
13332
- return resolved;
13333
- }
13334
- function isOmpWorktreePath(targetPath) {
13335
- const normalized = targetPath.replace(/\\/g, "/");
13336
- return normalized.includes("/.omp/wt/") || normalized.includes("/.omp/worktrees/");
13337
- }
13338
- async function resolveRepositoryContext(cwd, signal) {
13339
- const canonicalCwd = canonicalizePath(cwd);
13340
- try {
13341
- const { stdout } = await execGit([
13342
- "rev-parse",
13343
- "--path-format=absolute",
13344
- "--show-toplevel",
13345
- "--git-dir",
13346
- "--git-common-dir"
13347
- ], canonicalCwd, { signal });
13348
- const lines = stdout.split(`
13349
- `).map((l) => l.trim()).filter(Boolean);
13350
- const resolveEntry = (val) => {
13351
- if (!val)
13352
- return "";
13353
- return path4.isAbsolute(val) ? val : path4.resolve(canonicalCwd, val);
13354
- };
13355
- const workspaceRoot = canonicalizePath(resolveEntry(lines[0]));
13356
- const gitDir = canonicalizePath(resolveEntry(lines[1] || path4.join(workspaceRoot, ".git")));
13357
- const commonGitDir = canonicalizePath(resolveEntry(lines[2] || lines[1] || path4.join(workspaceRoot, ".git")));
13358
- let repositoryObjectDir = path4.join(commonGitDir, "objects");
13359
- const isOmpIsolation = isOmpWorktreePath(workspaceRoot) || isOmpWorktreePath(canonicalCwd);
13360
- if (isOmpIsolation && gitDir === commonGitDir) {
13361
- const alternatesFile = path4.join(gitDir, "objects", "info", "alternates");
13362
- try {
13363
- if (fsSync2.existsSync(alternatesFile)) {
13364
- const content = await fs2.readFile(alternatesFile, "utf-8");
13365
- const altLines = content.split(`
13366
- `).map((l) => l.trim()).filter((l) => l && !l.startsWith("#"));
13367
- for (const line of altLines) {
13368
- const resolvedAlt = path4.isAbsolute(line) ? line : path4.resolve(path4.join(gitDir, "objects"), line);
13369
- const canonicalAlt = canonicalizePath(resolvedAlt);
13370
- if (fsSync2.existsSync(canonicalAlt)) {
13371
- repositoryObjectDir = canonicalAlt;
13372
- break;
13373
- }
13374
- }
13375
- }
13376
- } catch {}
13377
- }
13378
- repositoryObjectDir = canonicalizePath(repositoryObjectDir);
13379
- const repositoryKey = crypto2.createHash("sha256").update(`git-object-dir\x00${repositoryObjectDir}`).digest("hex").slice(0, 16);
13380
- return {
13381
- workspaceRoot,
13382
- isGit: true,
13383
- gitDir,
13384
- commonGitDir,
13385
- repositoryObjectDir,
13386
- repositoryKey,
13387
- isOmpIsolation
13388
- };
13389
- } catch {
13390
- const repositoryObjectDir = path4.join(canonicalCwd, ".non-git");
13391
- const repositoryKey = crypto2.createHash("sha256").update(`directory\x00${canonicalCwd}`).digest("hex").slice(0, 16);
13392
- return {
13393
- workspaceRoot: canonicalCwd,
13394
- isGit: false,
13395
- repositoryObjectDir,
13396
- repositoryKey,
13397
- isOmpIsolation: false
13398
- };
13399
- }
13400
- }
13401
-
13402
13404
  // src/worker-protocol.ts
13403
13405
  var REQUEST_TYPES = {
13404
13406
  openWorkspace: true,
@@ -13765,7 +13767,8 @@ async function runBaselineIndexing(rootDir, options, signal) {
13765
13767
  totalSourceBytes,
13766
13768
  cloneCount: currentIndex.clones.length,
13767
13769
  durationMs: Date.now() - startTime,
13768
- status: baselineStatus
13770
+ status: baselineStatus,
13771
+ baselineClones: currentIndex.getClones()
13769
13772
  }));
13770
13773
  self.postMessage(createStatusEvent("ready", "Baseline indexing complete"));
13771
13774
  return { indexedCount, status: baselineStatus };
@@ -13908,6 +13911,15 @@ async function handleWorkerRequest(msg) {
13908
13911
  }
13909
13912
  activeAbortController = new AbortController;
13910
13913
  const recResult = await runIncrementalGitReconciliation(currentRootDir, options, activeAbortController.signal);
13914
+ self.postMessage(createCompleteEvent({
13915
+ indexedCount: recResult.indexedCount,
13916
+ cachedCount: 0,
13917
+ totalSourceBytes: 0,
13918
+ cloneCount: currentIndex.clones.length,
13919
+ durationMs: 0,
13920
+ status: recResult.status,
13921
+ baselineClones: currentIndex.getClones()
13922
+ }));
13911
13923
  self.postMessage(createSuccessResponse(msg.id, {
13912
13924
  started: true,
13913
13925
  rootDir: currentRootDir,
@@ -13967,12 +13979,16 @@ async function handleWorkerRequest(msg) {
13967
13979
  }
13968
13980
  case "checkAndUpdate": {
13969
13981
  const { filePath, content, format, revision = 1 } = msg.payload;
13970
- const rawClones = currentIndex.updateSource(filePath, content, format);
13971
13982
  const canonicalFilePath = canonicalizePath(filePath);
13972
13983
  const resolvedPath = path5.resolve(filePath);
13984
+ currentIndex.removeSource(canonicalFilePath);
13985
+ currentIndex.removeSource(resolvedPath);
13986
+ currentIndex.removeSource(filePath);
13987
+ const rawClones = currentIndex.addSource(filePath, content, format);
13988
+ cacheSourceShard(canonicalFilePath, content);
13973
13989
  cacheSourceShard(filePath, content);
13974
13990
  const clones = filterAndEvictStaleClones(rawClones, filePath);
13975
- const fileClones = clones.filter((c) => c.duplicationA.sourceId === filePath || c.duplicationB.sourceId === filePath || path5.resolve(c.duplicationA.sourceId) === resolvedPath || path5.resolve(c.duplicationB.sourceId) === resolvedPath || canonicalizePath(c.duplicationA.sourceId) === canonicalFilePath || canonicalizePath(c.duplicationB.sourceId) === canonicalFilePath);
13991
+ const fileClones = clones.filter((c) => c.duplicationA.sourceId === filePath || c.duplicationB.sourceId === filePath || c.duplicationA.sourceId === canonicalFilePath || c.duplicationB.sourceId === canonicalFilePath || path5.resolve(c.duplicationA.sourceId) === resolvedPath || path5.resolve(c.duplicationB.sourceId) === resolvedPath || canonicalizePath(c.duplicationA.sourceId) === canonicalFilePath || canonicalizePath(c.duplicationB.sourceId) === canonicalFilePath);
13976
13992
  const watchEntry = {
13977
13993
  revision,
13978
13994
  lastKnownCloneCount: fileClones.length
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-plugin-duplicate-detector",
3
- "version": "0.2.3",
3
+ "version": "0.3.0",
4
4
  "description": "Code clone and duplication detector plugin for oh-my-pi powered by jscpd",
5
5
  "type": "module",
6
6
  "files": [
@@ -466,6 +466,7 @@ async function runBaselineIndexing(
466
466
  cloneCount: currentIndex.clones.length,
467
467
  durationMs: Date.now() - startTime,
468
468
  status: baselineStatus,
469
+ baselineClones: currentIndex.getClones(),
469
470
  }),
470
471
  );
471
472
  self.postMessage(
@@ -649,6 +650,17 @@ async function handleWorkerRequest(msg: WorkerRequestMessage): Promise<void> {
649
650
  options,
650
651
  activeAbortController.signal,
651
652
  );
653
+ self.postMessage(
654
+ createCompleteEvent({
655
+ indexedCount: recResult.indexedCount,
656
+ cachedCount: 0,
657
+ totalSourceBytes: 0,
658
+ cloneCount: currentIndex.clones.length,
659
+ durationMs: 0,
660
+ status: recResult.status,
661
+ baselineClones: currentIndex.getClones(),
662
+ }),
663
+ );
652
664
  self.postMessage(
653
665
  createSuccessResponse(msg.id, {
654
666
  started: true,
@@ -731,24 +743,31 @@ async function handleWorkerRequest(msg: WorkerRequestMessage): Promise<void> {
731
743
 
732
744
  case "checkAndUpdate": {
733
745
  const { filePath, content, format, revision = 1 } = msg.payload;
734
- const rawClones = currentIndex.updateSource(filePath, content, format);
735
746
  const canonicalFilePath = canonicalizePath(filePath);
736
747
  const resolvedPath = path.resolve(filePath);
737
748
 
749
+ // Remove all path variations to ensure no duplicate self-match across symlinks
750
+ currentIndex.removeSource(canonicalFilePath);
751
+ currentIndex.removeSource(resolvedPath);
752
+ currentIndex.removeSource(filePath);
753
+
754
+ const rawClones = currentIndex.addSource(filePath, content, format);
755
+
756
+ cacheSourceShard(canonicalFilePath, content);
738
757
  cacheSourceShard(filePath, content);
739
758
 
740
759
  const clones = filterAndEvictStaleClones(rawClones, filePath);
741
-
742
760
  const fileClones = clones.filter(
743
761
  (c) =>
744
762
  c.duplicationA.sourceId === filePath ||
745
763
  c.duplicationB.sourceId === filePath ||
764
+ c.duplicationA.sourceId === canonicalFilePath ||
765
+ c.duplicationB.sourceId === canonicalFilePath ||
746
766
  path.resolve(c.duplicationA.sourceId) === resolvedPath ||
747
767
  path.resolve(c.duplicationB.sourceId) === resolvedPath ||
748
768
  canonicalizePath(c.duplicationA.sourceId) === canonicalFilePath ||
749
769
  canonicalizePath(c.duplicationB.sourceId) === canonicalFilePath,
750
770
  );
751
-
752
771
  const watchEntry = {
753
772
  revision,
754
773
  lastKnownCloneCount: fileClones.length,
@@ -1,6 +1,144 @@
1
1
  import type { IClone } from "@jscpd/core";
2
2
  import { toDisplayPath } from "./tui-notification";
3
3
 
4
+ export interface LineRange {
5
+ start: number;
6
+ end: number;
7
+ }
8
+
9
+ /**
10
+ * Computes 1-indexed changed/added line ranges in newText relative to oldText.
11
+ * If oldText is null or undefined (e.g. newly created file), returns the full range of newText.
12
+ */
13
+ export function computeChangedLineRanges(
14
+ oldText: string | null | undefined,
15
+ newText: string,
16
+ ): LineRange[] {
17
+ if (oldText == null) {
18
+ const lines = newText.split(/\r?\n/);
19
+ return [{ start: 1, end: Math.max(1, lines.length) }];
20
+ }
21
+ if (oldText === newText || newText.length === 0) {
22
+ return [];
23
+ }
24
+
25
+ const oldLines = oldText.split(/\r?\n/);
26
+ const newLines = newText.split(/\r?\n/);
27
+
28
+ if (oldLines.length === 0) {
29
+ return [{ start: 1, end: Math.max(1, newLines.length) }];
30
+ }
31
+ if (newLines.length === 0) {
32
+ return [];
33
+ }
34
+
35
+ // Trim common prefix
36
+ let prefix = 0;
37
+ while (
38
+ prefix < oldLines.length &&
39
+ prefix < newLines.length &&
40
+ oldLines[prefix] === newLines[prefix]
41
+ ) {
42
+ prefix++;
43
+ }
44
+
45
+ // Trim common suffix
46
+ let oldSuffix = oldLines.length - 1;
47
+ let newSuffix = newLines.length - 1;
48
+ while (
49
+ oldSuffix >= prefix &&
50
+ newSuffix >= prefix &&
51
+ oldLines[oldSuffix] === newLines[newSuffix]
52
+ ) {
53
+ oldSuffix--;
54
+ newSuffix--;
55
+ }
56
+
57
+ const oldTrimmed = oldLines.slice(prefix, oldSuffix + 1);
58
+ const newTrimmed = newLines.slice(prefix, newSuffix + 1);
59
+
60
+ // Pure deletions: lines were removed, but no new lines were introduced
61
+ if (newTrimmed.length === 0) {
62
+ return [];
63
+ }
64
+
65
+ // Compute LCS on the trimmed core if reasonably bounded (up to 500k cells)
66
+ if (
67
+ oldTrimmed.length > 0 &&
68
+ newTrimmed.length > 0 &&
69
+ oldTrimmed.length * newTrimmed.length <= 500000
70
+ ) {
71
+ const m = oldTrimmed.length;
72
+ const n = newTrimmed.length;
73
+ const dp = Array.from({ length: m + 1 }, () => new Int32Array(n + 1));
74
+ for (let i = 1; i <= m; i++) {
75
+ for (let j = 1; j <= n; j++) {
76
+ if (oldTrimmed[i - 1] === newTrimmed[j - 1]) {
77
+ dp[i]![j] = dp[i - 1]![j - 1]! + 1;
78
+ } else {
79
+ dp[i]![j] = Math.max(dp[i - 1]![j]!, dp[i]![j - 1]!);
80
+ }
81
+ }
82
+ }
83
+
84
+ const matchedInNew = new Uint8Array(n);
85
+ let i = m;
86
+ let j = n;
87
+ while (i > 0 && j > 0) {
88
+ if (oldTrimmed[i - 1] === newTrimmed[j - 1]) {
89
+ matchedInNew[j - 1] = 1;
90
+ i--;
91
+ j--;
92
+ } else if (dp[i - 1]![j]! >= dp[i]![j - 1]!) {
93
+ i--;
94
+ } else {
95
+ j--;
96
+ }
97
+ }
98
+
99
+ const ranges: LineRange[] = [];
100
+ let rangeStart = -1;
101
+ for (let idx = 0; idx < n; idx++) {
102
+ if (!matchedInNew[idx]) {
103
+ if (rangeStart === -1) rangeStart = prefix + idx + 1;
104
+ } else {
105
+ if (rangeStart !== -1) {
106
+ ranges.push({ start: rangeStart, end: prefix + idx });
107
+ rangeStart = -1;
108
+ }
109
+ }
110
+ }
111
+ if (rangeStart !== -1) {
112
+ ranges.push({ start: rangeStart, end: prefix + n });
113
+ }
114
+
115
+ return ranges;
116
+ }
117
+
118
+ if (prefix <= newSuffix) {
119
+ return [{ start: prefix + 1, end: newSuffix + 1 }];
120
+ }
121
+ return [];
122
+ }
123
+
124
+ /**
125
+ * Checks whether a clone's target mutation span intersects any of the changed line ranges.
126
+ */
127
+ export function cloneIntersectsRanges(
128
+ clone: IClone,
129
+ ranges: LineRange[],
130
+ side: "duplicationA" | "duplicationB" = "duplicationA",
131
+ ): boolean {
132
+ if (ranges.length === 0) return false;
133
+ const span =
134
+ side === "duplicationB" ? clone.duplicationB : clone.duplicationA;
135
+ const start = span.start.line;
136
+ const end = span.end.line;
137
+ return ranges.some(
138
+ (range) => Math.max(start, range.start) <= Math.min(end, range.end),
139
+ );
140
+ }
141
+
4
142
  /**
5
143
  * Unique identifier for a duplicate match.
6
144
  */
@@ -31,21 +169,88 @@ function extractLineRange(
31
169
  */
32
170
  export class DuplicateLedger {
33
171
  readonly #seen = new Map<string, Set<string>>();
172
+ readonly #baseline = new Map<string, Set<string>>();
173
+
174
+ #addBaseline(key: string, id: string): void {
175
+ if (!key) return;
176
+ let set = this.#baseline.get(key);
177
+ if (!set) {
178
+ set = new Set<string>();
179
+ this.#baseline.set(key, set);
180
+ }
181
+ set.add(id);
182
+ }
183
+
184
+ /**
185
+ * Seed pre-existing baseline clones from workspace indexing so that legacy repository
186
+ * duplications are treated as pre-existing and not flagged as newly introduced duplicates.
187
+ */
188
+ seedBaseline(clones: IClone[], basePath?: string): void {
189
+ for (const clone of clones) {
190
+ const a = clone.duplicationA;
191
+ const b = clone.duplicationB;
192
+ const lenA = a.end.line - a.start.line + 1;
193
+ const lenB = b.end.line - b.start.line + 1;
194
+
195
+ const relA = toDisplayPath(a.sourceId, basePath);
196
+ const relB = toDisplayPath(b.sourceId, basePath);
197
+
198
+ const idA_raw = `${b.sourceId}:${b.start.line}-${b.end.line}::${a.sourceId}::${lenA}`;
199
+ const idA_rel = `${relB}:${b.start.line}-${b.end.line}::${relA}::${lenA}`;
200
+
201
+ const idB_raw = `${a.sourceId}:${a.start.line}-${a.end.line}::${b.sourceId}::${lenB}`;
202
+ const idB_rel = `${relA}:${a.start.line}-${a.end.line}::${relB}::${lenB}`;
203
+
204
+ // Register bidirectional identities under both files' raw and display keys
205
+ const allIds = [idA_raw, idA_rel, idB_raw, idB_rel];
206
+ const allKeys = [relA, a.sourceId, relB, b.sourceId];
207
+
208
+ for (const key of allKeys) {
209
+ for (const id of allIds) {
210
+ this.#addBaseline(key, id);
211
+ }
212
+ }
213
+ }
214
+ }
34
215
 
35
216
  /**
36
217
  * Filter a list of clones, returning only those not yet seen for the target file.
37
218
  * Updates the ledger with newly seen identities.
38
219
  */
39
- filterFreshClones(filePath: string, clones: IClone[]): IClone[] {
220
+ filterFreshClones(
221
+ filePath: string,
222
+ clones: IClone[],
223
+ basePath?: string,
224
+ ): IClone[] {
225
+ const displayPath = toDisplayPath(filePath, basePath);
40
226
  const previous = this.#seen.get(filePath);
227
+ const prevDisplay = this.#seen.get(displayPath);
228
+ const baseline = this.#baseline.get(filePath);
229
+ const baseDisplay = this.#baseline.get(displayPath);
41
230
  const fresh: IClone[] = [];
42
231
  const currentIdentities = new Set<string>();
43
232
 
44
233
  for (const clone of clones) {
45
234
  const id = cloneIdentity(clone);
235
+ const a = clone.duplicationA;
236
+ const b = clone.duplicationB;
237
+ const len = a.end.line - a.start.line + 1;
238
+ const relId = `${toDisplayPath(b.sourceId, basePath)}:${b.start.line}-${b.end.line}::${toDisplayPath(a.sourceId, basePath)}::${len}`;
239
+
46
240
  if (!currentIdentities.has(id)) {
47
241
  currentIdentities.add(id);
48
- if (!previous?.has(id)) {
242
+ currentIdentities.add(relId);
243
+ const isBaseline =
244
+ baseline?.has(id) ||
245
+ baseline?.has(relId) ||
246
+ baseDisplay?.has(id) ||
247
+ baseDisplay?.has(relId);
248
+ const isPrevious =
249
+ previous?.has(id) ||
250
+ previous?.has(relId) ||
251
+ prevDisplay?.has(id) ||
252
+ prevDisplay?.has(relId);
253
+ if (!isBaseline && !isPrevious) {
49
254
  fresh.push(clone);
50
255
  }
51
256
  }
@@ -53,8 +258,10 @@ export class DuplicateLedger {
53
258
 
54
259
  if (currentIdentities.size === 0) {
55
260
  this.#seen.delete(filePath);
261
+ this.#seen.delete(displayPath);
56
262
  } else {
57
263
  this.#seen.set(filePath, currentIdentities);
264
+ this.#seen.set(displayPath, currentIdentities);
58
265
  }
59
266
 
60
267
  return fresh;
@@ -139,8 +346,10 @@ export class DuplicateLedger {
139
346
  clear(filePath?: string): void {
140
347
  if (filePath) {
141
348
  this.#seen.delete(filePath);
349
+ this.#baseline.delete(filePath);
142
350
  } else {
143
351
  this.#seen.clear();
352
+ this.#baseline.clear();
144
353
  }
145
354
  }
146
355
  }
package/src/index.ts CHANGED
@@ -9,7 +9,12 @@ import {
9
9
  type JscpdProjectConfig,
10
10
  } from "./config-loader";
11
11
  import { DuplicateDetectorCoordinator } from "./coordinator";
12
- import { DuplicateLedger } from "./duplicate-ledger";
12
+ import {
13
+ cloneIntersectsRanges,
14
+ computeChangedLineRanges,
15
+ DuplicateLedger,
16
+ type LineRange,
17
+ } from "./duplicate-ledger";
13
18
  import {
14
19
  type BaselineStatus,
15
20
  createIgnoreFilter,
@@ -231,6 +236,21 @@ function extractSettingsObject(
231
236
  return undefined;
232
237
  }
233
238
 
239
+ function extractPathFromToolInput(input: unknown): string | undefined {
240
+ if (!input || typeof input !== "object") return undefined;
241
+ const obj = input as Record<string, unknown>;
242
+ if (typeof obj.path === "string" && obj.path) {
243
+ return obj.path;
244
+ }
245
+ if (typeof obj.input === "string" && obj.input) {
246
+ const match = obj.input.match(/^\[([^#\]\r\n]+)(?:#[a-zA-Z0-9_-]+)?\]/m);
247
+ if (match?.[1]) {
248
+ return match[1].trim();
249
+ }
250
+ }
251
+ return undefined;
252
+ }
253
+
234
254
  export function formatBaselineMessage(
235
255
  status: BaselineStatus,
236
256
  count: number,
@@ -337,6 +357,8 @@ export default function duplicateDetectorExtension(pi: ExtensionAPI): void {
337
357
  let lastCtx: ExtensionContext | undefined;
338
358
  const ledger = new DuplicateLedger();
339
359
  const fileRevisions = new Map<string, number>();
360
+ const fileChangedRanges = new Map<string, LineRange[]>();
361
+ const preMutationContent = new Map<string, string | null>();
340
362
  const coordinator = new DuplicateDetectorCoordinator();
341
363
  let lastKnownHead: string | null = null;
342
364
  let workerFailureNotified = false;
@@ -474,6 +496,9 @@ export default function duplicateDetectorExtension(pi: ExtensionAPI): void {
474
496
  notifyWorkerFailure(payload.error ?? "Baseline indexing failed");
475
497
  return;
476
498
  }
499
+ if (payload.baselineClones && payload.baselineClones.length > 0) {
500
+ ledger.seedBaseline(payload.baselineClones, currentCwd);
501
+ }
477
502
  workerFailureNotified = false;
478
503
  notifyBaselineStatus(
479
504
  pi,
@@ -527,8 +552,25 @@ export default function duplicateDetectorExtension(pi: ExtensionAPI): void {
527
552
  : undefined;
528
553
 
529
554
  if (!targetRel) return;
555
+ const targetRanges = fileChangedRanges.get(targetRel);
556
+ if (targetRanges) {
557
+ const isIntraFile = relA === relB;
558
+ const intersects = isIntraFile
559
+ ? cloneIntersectsRanges(clone, targetRanges, "duplicationA") ||
560
+ cloneIntersectsRanges(clone, targetRanges, "duplicationB")
561
+ : cloneIntersectsRanges(
562
+ clone,
563
+ targetRanges,
564
+ targetRel === relB ? "duplicationB" : "duplicationA",
565
+ );
566
+ if (!intersects) return;
567
+ }
530
568
 
531
- const freshClones = ledger.filterFreshClones(targetRel, [clone]);
569
+ const freshClones = ledger.filterFreshClones(
570
+ targetRel,
571
+ [clone],
572
+ currentCwd,
573
+ );
532
574
  if (freshClones.length > 0) {
533
575
  const fullPath = path.isAbsolute(targetRel)
534
576
  ? targetRel
@@ -573,6 +615,8 @@ export default function duplicateDetectorExtension(pi: ExtensionAPI): void {
573
615
  pi.on("session_switch", async (event, ctx) => {
574
616
  ledger.clear();
575
617
  fileRevisions.clear();
618
+ fileChangedRanges.clear();
619
+ preMutationContent.clear();
576
620
  workerFailureNotified = false;
577
621
  lastKnownHead = null;
578
622
  if (ctx?.cwd) {
@@ -615,6 +659,8 @@ export default function duplicateDetectorExtension(pi: ExtensionAPI): void {
615
659
  pi.on("session_branch", async () => {
616
660
  ledger.clear();
617
661
  fileRevisions.clear();
662
+ fileChangedRanges.clear();
663
+ preMutationContent.clear();
618
664
  });
619
665
 
620
666
  pi.on("session_shutdown", async () => {
@@ -687,9 +733,44 @@ export default function duplicateDetectorExtension(pi: ExtensionAPI): void {
687
733
  });
688
734
 
689
735
  // Intercept write and edit tool executions to detect clones in newly added/modified code
736
+ // Pre-capture file content before write or edit mutation so diff calculation on tool_result
737
+ // knows exactly which line ranges were newly introduced vs pre-existing.
738
+ pi.on("tool_call", async (event, ctx) => {
739
+ if (event.toolName !== "write" && event.toolName !== "edit") return;
740
+ const rawPath = extractPathFromToolInput(event.input);
741
+ if (!rawPath || rawPath.includes("://")) return;
742
+ const fullPath = path.isAbsolute(rawPath)
743
+ ? path.resolve(rawPath)
744
+ : path.resolve(ctx.cwd, rawPath);
745
+ try {
746
+ const file = Bun.file(fullPath);
747
+ if (await file.exists()) {
748
+ preMutationContent.set(fullPath, await file.text());
749
+ } else {
750
+ preMutationContent.set(fullPath, null);
751
+ }
752
+ } catch {
753
+ preMutationContent.set(fullPath, null);
754
+ }
755
+ });
756
+
690
757
  pi.on(
691
758
  "tool_result",
692
759
  async (event, ctx): Promise<ToolResultEventResult | void> => {
760
+ let capturedOldContent: string | null | undefined;
761
+ let resolvedFullPath: string | undefined;
762
+
763
+ if (event.toolName === "write" || event.toolName === "edit") {
764
+ const rawPath = extractPathFromToolInput(event.input);
765
+ if (rawPath && !rawPath.includes("://") && ctx?.cwd) {
766
+ resolvedFullPath = path.isAbsolute(rawPath)
767
+ ? path.resolve(rawPath)
768
+ : path.resolve(ctx.cwd, rawPath);
769
+ capturedOldContent = preMutationContent.get(resolvedFullPath);
770
+ preMutationContent.delete(resolvedFullPath);
771
+ }
772
+ }
773
+
693
774
  if (event.isError) return;
694
775
  if (!isEnabledForProject) return;
695
776
  if (!config.checkOnMutation) return;
@@ -699,15 +780,9 @@ export default function duplicateDetectorExtension(pi: ExtensionAPI): void {
699
780
  return;
700
781
  }
701
782
  if (event.toolName !== "write" && event.toolName !== "edit") return;
702
- const input = event.input as { path?: string };
703
- const rawPath = input?.path;
704
- if (!rawPath || typeof rawPath !== "string") return;
705
- // Skip internal protocol URLs (e.g. xd://, local://)
706
- if (rawPath.includes("://")) return;
707
-
708
- const fullPath = path.isAbsolute(rawPath)
709
- ? path.resolve(rawPath)
710
- : path.resolve(ctx.cwd, rawPath);
783
+ if (!resolvedFullPath) return;
784
+
785
+ const fullPath = resolvedFullPath;
711
786
  const relPath = path.relative(ctx.cwd, fullPath);
712
787
  const normalizedRelPath = relPath.replace(/\\/g, "/");
713
788
 
@@ -720,7 +795,6 @@ export default function duplicateDetectorExtension(pi: ExtensionAPI): void {
720
795
  ) {
721
796
  return;
722
797
  }
723
-
724
798
  try {
725
799
  // Skip ignored files (matching ignore patterns, noise files, or test files)
726
800
  const ignoreFilter = createIgnoreFilter(config.ignorePatterns, {
@@ -737,6 +811,27 @@ export default function duplicateDetectorExtension(pi: ExtensionAPI): void {
737
811
  // Skip generated files
738
812
  if (isGeneratedContent(content)) return;
739
813
 
814
+ let oldContent = capturedOldContent;
815
+ if (oldContent === undefined && ctx?.cwd) {
816
+ try {
817
+ const isGit = await isInsideGitWorkTree(ctx.cwd);
818
+ if (isGit) {
819
+ const { stdout } = await execGit(
820
+ ["show", `HEAD:${normalizedRelPath}`],
821
+ ctx.cwd,
822
+ );
823
+ oldContent = stdout;
824
+ } else {
825
+ oldContent = null;
826
+ }
827
+ } catch {
828
+ oldContent = null;
829
+ }
830
+ }
831
+
832
+ const changedRanges = computeChangedLineRanges(oldContent, content);
833
+ fileChangedRanges.set(normalizedRelPath, changedRanges);
834
+
740
835
  const revision = (fileRevisions.get(normalizedRelPath) ?? 0) + 1;
741
836
  fileRevisions.set(normalizedRelPath, revision);
742
837
 
@@ -745,7 +840,24 @@ export default function duplicateDetectorExtension(pi: ExtensionAPI): void {
745
840
  content,
746
841
  revision,
747
842
  );
748
- const freshClones = ledger.filterFreshClones(normalizedRelPath, clones);
843
+ const mutatingClones = clones.filter((c) => {
844
+ if (cloneIntersectsRanges(c, changedRanges, "duplicationA")) {
845
+ return true;
846
+ }
847
+ const isIntraFile =
848
+ c.duplicationA.sourceId === c.duplicationB.sourceId ||
849
+ path.resolve(c.duplicationA.sourceId) ===
850
+ path.resolve(c.duplicationB.sourceId);
851
+ return (
852
+ isIntraFile &&
853
+ cloneIntersectsRanges(c, changedRanges, "duplicationB")
854
+ );
855
+ });
856
+ const freshClones = ledger.filterFreshClones(
857
+ normalizedRelPath,
858
+ mutatingClones,
859
+ currentCwd,
860
+ );
749
861
 
750
862
  if (freshClones.length > 0) {
751
863
  const fullReminder = ledger.formatReminder(
@@ -872,6 +984,8 @@ export default function duplicateDetectorExtension(pi: ExtensionAPI): void {
872
984
  isEnabledForProject = false;
873
985
  ledger.clear();
874
986
  fileRevisions.clear();
987
+ fileChangedRanges.clear();
988
+ preMutationContent.clear();
875
989
  ctx.ui.notify("Duplicate detector disabled for this project.", "info");
876
990
  notifyBaselineStatus(pi, ctx as ExtensionContext, "disabled", 0);
877
991
  } else if (action === "status" || action === "") {
@@ -14,6 +14,7 @@ import {
14
14
  } from "@jscpd/core";
15
15
  import { Tokenizer } from "@jscpd/tokenizer";
16
16
  import { getSupportedCodeFormat } from "./jscpd-engine";
17
+ import { canonicalizePath } from "./repo-context";
17
18
  export class CompactSourceFrame {
18
19
  constructor(
19
20
  public readonly id: string,
@@ -689,6 +690,7 @@ export class SourceAwareCloneIndex {
689
690
  const { insertFrames = false } = options;
690
691
  let activeClones = new Map<string, ActiveCloneCandidate>();
691
692
 
693
+ const canonicalSourceId = canonicalizePath(sourceId);
692
694
  for (const frame of frames) {
693
695
  const frameStartLine =
694
696
  "startLine" in frame
@@ -793,8 +795,11 @@ export class SourceAwareCloneIndex {
793
795
  const targetEndRange = targetFrame.endRange;
794
796
 
795
797
  // Disallow exact self-match at identical line/column position
798
+ const isSameSource =
799
+ targetFrame.sourceId === sourceId ||
800
+ canonicalizePath(targetFrame.sourceId) === canonicalSourceId;
796
801
  if (
797
- targetFrame.sourceId === sourceId &&
802
+ isSameSource &&
798
803
  targetStartLine === frameStartLine &&
799
804
  targetStartCol === frameStartCol
800
805
  ) {
@@ -132,8 +132,14 @@ export function toDisplayPath(sourceId: string, basePath?: string): string {
132
132
  (typeof process !== "undefined" && process.cwd ? process.cwd() : "")
133
133
  )?.replace(/\\/g, "/");
134
134
 
135
- if (root && clean.startsWith(root)) {
136
- clean = clean.slice(root.length);
135
+ if (root) {
136
+ if (clean.startsWith(root)) {
137
+ clean = clean.slice(root.length);
138
+ } else if (clean.startsWith(`/private${root}`)) {
139
+ clean = clean.slice(`/private${root}`.length);
140
+ } else if (root.startsWith("/private") && clean.startsWith(root.slice(8))) {
141
+ clean = clean.slice(root.slice(8).length);
142
+ }
137
143
  if (clean.startsWith("/")) clean = clean.slice(1);
138
144
  }
139
145
  return clean || sourceId;
@@ -180,6 +180,7 @@ export interface WorkerCompletePayload {
180
180
  durationMs: number;
181
181
  status?: BaselineStatus;
182
182
  error?: string;
183
+ baselineClones?: IClone[];
183
184
  }
184
185
 
185
186
  export interface WorkerCompleteEvent {