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.
- package/dist/detector-worker.js +133 -117
- package/package.json +1 -1
- package/src/detector-worker.ts +22 -3
- package/src/duplicate-ledger.ts +211 -2
- package/src/index.ts +127 -13
- package/src/source-aware-index.ts +6 -1
- package/src/tui-notification.ts +8 -2
- package/src/worker-protocol.ts +1 -0
package/dist/detector-worker.js
CHANGED
|
@@ -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
|
|
981
|
-
import * as
|
|
982
|
-
import * as
|
|
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
|
|
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
|
-
|
|
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
|
|
12937
|
+
return path4.join(localAppData, "omp", "duplicate-detector");
|
|
12847
12938
|
}
|
|
12848
|
-
return
|
|
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
|
|
12943
|
+
return path4.join(xdgCacheHome, "omp", "duplicate-detector");
|
|
12853
12944
|
}
|
|
12854
|
-
return
|
|
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
|
|
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 :
|
|
12879
|
-
return
|
|
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 =
|
|
13087
|
-
this.repositoryKey = options.repositoryKey ??
|
|
13088
|
-
this.baseCacheDir = options.cacheDir ?
|
|
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 =
|
|
13102
|
-
if (!
|
|
13103
|
-
|
|
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 ?? (
|
|
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 ?? (
|
|
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 (!
|
|
13391
|
+
if (!fsSync2.existsSync(baseDir))
|
|
13301
13392
|
return;
|
|
13302
|
-
const entries = await
|
|
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
|
|
13306
|
-
await
|
|
13307
|
-
await
|
|
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
package/src/detector-worker.ts
CHANGED
|
@@ -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,
|
package/src/duplicate-ledger.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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 {
|
|
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(
|
|
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
|
-
|
|
703
|
-
|
|
704
|
-
|
|
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
|
|
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
|
-
|
|
802
|
+
isSameSource &&
|
|
798
803
|
targetStartLine === frameStartLine &&
|
|
799
804
|
targetStartCol === frameStartCol
|
|
800
805
|
) {
|
package/src/tui-notification.ts
CHANGED
|
@@ -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
|
|
136
|
-
|
|
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;
|