omp-plugin-duplicate-detector 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,536 @@
1
+ /**
2
+ * Coordinator for managing duplicate detector worker lifecycle, RPC messaging,
3
+ * workspace epochs, and fault-tolerant event propagation.
4
+ */
5
+
6
+ import type { IClone } from "@jscpd/core";
7
+ import EventEmitter from "eventemitter3";
8
+ import type {
9
+ CheckAndUpdatePayload,
10
+ CheckSnippetPayload,
11
+ OpenWorkspacePayload,
12
+ ReconcileFileEntry,
13
+ ReconcilePayload,
14
+ RemoveFilePayload,
15
+ ScanPayload,
16
+ UpdateFilePayload,
17
+ WorkerCompletePayload,
18
+ WorkerEventMessage,
19
+ WorkerLateFindingPayload,
20
+ WorkerProgressPayload,
21
+ WorkerRequestMessage,
22
+ WorkerResponseMessage,
23
+ WorkerStatusPayload,
24
+ WorkspaceOptions,
25
+ } from "./worker-protocol";
26
+ import { isWorkerEvent, isWorkerResponse } from "./worker-protocol";
27
+
28
+ export interface DuplicateDetectorConfig {
29
+ minLines?: number;
30
+ minTokens?: number;
31
+ maxLines?: number;
32
+ checkOnMutation?: boolean;
33
+ reminderMode?: "in-band" | "steer" | "none";
34
+ ignorePatterns?: string[];
35
+ ignoreTests?: boolean;
36
+ customTestPatterns?: string[];
37
+ excludeTestPatterns?: string[];
38
+ formatsExts?: Record<string, string[]>;
39
+ configSource?: string;
40
+ maxIndexedFiles?: number;
41
+ }
42
+
43
+ export interface CoordinatorOptions {
44
+ /** Custom worker script URL or path (defaults to the packaged worker bundle) */
45
+ workerUrl?: string | URL;
46
+ /** Timeout in milliseconds for individual RPC requests (default: 30,000ms) */
47
+ requestTimeoutMs?: number;
48
+ /** Whether to automatically attempt to restart worker on unexpected termination (default: true) */
49
+ autoRestart?: boolean;
50
+ /** Maximum consecutive restart attempts (default: 5) */
51
+ maxRestartAttempts?: number;
52
+ /** Base restart backoff in milliseconds (default: 500ms) */
53
+ restartBackoffMs?: number;
54
+ }
55
+
56
+ export interface CoordinatorEvents {
57
+ progress: (payload: WorkerProgressPayload) => void;
58
+ complete: (payload: WorkerCompletePayload) => void;
59
+ lateFinding: (payload: WorkerLateFindingPayload) => void;
60
+ status: (payload: WorkerStatusPayload) => void;
61
+ error: (error: Error) => void;
62
+ }
63
+
64
+ interface PendingRequest {
65
+ resolve: (value: unknown) => void;
66
+ reject: (reason: Error) => void;
67
+ epoch: number;
68
+ type: string;
69
+ timeoutTimer: NodeJS.Timeout | number;
70
+ }
71
+ function computeConfigHash(
72
+ config?: DuplicateDetectorConfig | WorkspaceOptions,
73
+ ): string {
74
+ if (!config) return "default";
75
+ return JSON.stringify({
76
+ minTokens: config.minTokens,
77
+ minLines: config.minLines,
78
+ maxLines: config.maxLines,
79
+ ignorePatterns: (config.ignorePatterns ?? []).slice().sort(),
80
+ ignoreTests: config.ignoreTests,
81
+ customTestPatterns: (config.customTestPatterns ?? []).slice().sort(),
82
+ excludeTestPatterns: (config.excludeTestPatterns ?? []).slice().sort(),
83
+ formatsExts: config.formatsExts,
84
+ maxIndexedFiles: config.maxIndexedFiles,
85
+ });
86
+ }
87
+ function resolveDefaultWorkerUrl(): string {
88
+ try {
89
+ const distFromSrc = new URL("../dist/detector-worker.js", import.meta.url);
90
+ return distFromSrc.href;
91
+ } catch {
92
+ return new URL("../dist/detector-worker.js", import.meta.url).href;
93
+ }
94
+ }
95
+
96
+ /**
97
+ * High-level coordinator managing the background duplicate detector worker thread.
98
+ */
99
+ export class DuplicateDetectorCoordinator extends EventEmitter<CoordinatorEvents> {
100
+ readonly #workerUrl: string | URL;
101
+ readonly #requestTimeoutMs: number;
102
+ readonly #autoRestart: boolean;
103
+ readonly #maxRestartAttempts: number;
104
+ readonly #restartBackoffMs: number;
105
+
106
+ #worker: Worker | null = null;
107
+ #restartTimer: ReturnType<typeof setTimeout> | null = null;
108
+ #nextReqId = 0;
109
+ #epoch = 0;
110
+ #pendingRequests = new Map<string, PendingRequest>();
111
+ #isDisposed = false;
112
+ #restartCount = 0;
113
+ #lastOpenWorkspacePayload: OpenWorkspacePayload | null = null;
114
+ #activeRootDir: string | null = null;
115
+ #activeConfigHash: string | null = null;
116
+ constructor(options: CoordinatorOptions = {}) {
117
+ super();
118
+ this.#workerUrl = options.workerUrl ?? resolveDefaultWorkerUrl();
119
+ this.#requestTimeoutMs = options.requestTimeoutMs ?? 30_000;
120
+ this.#autoRestart = options.autoRestart ?? true;
121
+ this.#maxRestartAttempts = options.maxRestartAttempts ?? 5;
122
+ this.#restartBackoffMs = options.restartBackoffMs ?? 500;
123
+
124
+ this.#initWorker();
125
+ }
126
+
127
+ get epoch(): number {
128
+ return this.#epoch;
129
+ }
130
+
131
+ get isDisposed(): boolean {
132
+ return this.#isDisposed;
133
+ }
134
+
135
+ get isWorkerAlive(): boolean {
136
+ return this.#worker !== null;
137
+ }
138
+
139
+ get activeRootDir(): string | null {
140
+ return this.#activeRootDir;
141
+ }
142
+
143
+ get activeConfigHash(): string | null {
144
+ return this.#activeConfigHash;
145
+ }
146
+
147
+ // ============================================================================
148
+ // Worker Lifecycle & Fault Tolerance
149
+ // ============================================================================
150
+
151
+ #initWorker(): void {
152
+ if (this.#restartTimer !== null) {
153
+ clearTimeout(this.#restartTimer);
154
+ this.#restartTimer = null;
155
+ }
156
+
157
+ if (this.#isDisposed || this.#worker !== null) return;
158
+
159
+ try {
160
+ const worker = new Worker(this.#workerUrl);
161
+
162
+ worker.onmessage = (event: MessageEvent<unknown>) => {
163
+ this.#handleWorkerMessage(event.data);
164
+ };
165
+
166
+ worker.onerror = (event: ErrorEvent) => {
167
+ const error =
168
+ event.error instanceof Error
169
+ ? event.error
170
+ : new Error(event.message || "Worker error occurred");
171
+ this.#handleWorkerCrash(error);
172
+ };
173
+
174
+ this.#worker = worker;
175
+ } catch (err) {
176
+ const error =
177
+ err instanceof Error
178
+ ? err
179
+ : new Error(`Failed to spawn worker: ${String(err)}`);
180
+ this.#handleWorkerCrash(error);
181
+ }
182
+ }
183
+
184
+ #handleWorkerMessage(data: unknown): void {
185
+ if (isWorkerResponse(data)) {
186
+ this.#handleResponse(data);
187
+ } else if (isWorkerEvent(data)) {
188
+ this.#handleEvent(data);
189
+ }
190
+ }
191
+
192
+ #handleResponse(response: WorkerResponseMessage): void {
193
+ const pending = this.#pendingRequests.get(response.id);
194
+ if (!pending) return;
195
+
196
+ clearTimeout(pending.timeoutTimer);
197
+ this.#pendingRequests.delete(response.id);
198
+
199
+ if (response.success) {
200
+ pending.resolve(response.data);
201
+ } else {
202
+ pending.reject(new Error(response.error));
203
+ }
204
+ }
205
+
206
+ #handleEvent(event: WorkerEventMessage): void {
207
+ switch (event.type) {
208
+ case "progress":
209
+ this.emit("progress", event.payload);
210
+ break;
211
+ case "complete":
212
+ this.emit("complete", event.payload);
213
+ break;
214
+ case "lateFinding":
215
+ this.emit("lateFinding", event.payload);
216
+ break;
217
+ case "status":
218
+ this.emit("status", event.payload);
219
+ break;
220
+ }
221
+ }
222
+
223
+ #handleWorkerCrash(error: Error): void {
224
+ this.emit("error", error);
225
+
226
+ // Reject all in-flight pending requests gracefully
227
+ for (const [_id, pending] of this.#pendingRequests.entries()) {
228
+ clearTimeout(pending.timeoutTimer);
229
+ pending.reject(
230
+ new Error(
231
+ `Worker terminated during request ${pending.type}: ${error.message}`,
232
+ ),
233
+ );
234
+ }
235
+ this.#pendingRequests.clear();
236
+
237
+ if (this.#worker) {
238
+ try {
239
+ this.#worker.terminate();
240
+ } catch {}
241
+ this.#worker = null;
242
+ }
243
+ if (this.#restartTimer !== null) {
244
+ clearTimeout(this.#restartTimer);
245
+ this.#restartTimer = null;
246
+ }
247
+
248
+ if (
249
+ !this.#isDisposed &&
250
+ this.#autoRestart &&
251
+ this.#restartCount < this.#maxRestartAttempts
252
+ ) {
253
+ this.#restartCount++;
254
+ const delay = this.#restartBackoffMs * 2 ** (this.#restartCount - 1);
255
+ this.#restartTimer = setTimeout(() => {
256
+ this.#restartTimer = null;
257
+ if (this.#isDisposed || this.#worker !== null) return;
258
+ this.#initWorker();
259
+ // If a workspace was open, automatically re-open on restart
260
+ if (this.#lastOpenWorkspacePayload && this.#worker) {
261
+ this.#sendRequest<void>(
262
+ "openWorkspace",
263
+ this.#lastOpenWorkspacePayload,
264
+ ).catch(() => {});
265
+ }
266
+ }, delay);
267
+ }
268
+ }
269
+
270
+ // ============================================================================
271
+ // RPC Request Dispatcher
272
+ // ============================================================================
273
+
274
+ #sendRequest<T>(
275
+ type: WorkerRequestMessage["type"],
276
+ payload?: unknown,
277
+ ): Promise<T> {
278
+ if (this.#isDisposed) {
279
+ return Promise.reject(
280
+ new Error("DuplicateDetectorCoordinator is disposed"),
281
+ );
282
+ }
283
+
284
+ if (!this.#worker) {
285
+ this.#initWorker();
286
+ if (!this.#worker) {
287
+ return Promise.reject(new Error("Worker is not available"));
288
+ }
289
+ }
290
+
291
+ const { promise, resolve, reject } = Promise.withResolvers<T>();
292
+ const id = `req_${++this.#nextReqId}_${Date.now()}`;
293
+ const currentEpoch = this.#epoch;
294
+
295
+ const timeoutTimer = setTimeout(() => {
296
+ this.#pendingRequests.delete(id);
297
+ reject(
298
+ new Error(
299
+ `Worker request '${type}' (id: ${id}) timed out after ${this.#requestTimeoutMs}ms`,
300
+ ),
301
+ );
302
+ }, this.#requestTimeoutMs);
303
+
304
+ this.#pendingRequests.set(id, {
305
+ resolve: resolve as (value: unknown) => void,
306
+ reject,
307
+ epoch: currentEpoch,
308
+ type,
309
+ timeoutTimer,
310
+ });
311
+
312
+ try {
313
+ this.#worker.postMessage({
314
+ id,
315
+ type,
316
+ payload,
317
+ });
318
+ } catch (err) {
319
+ clearTimeout(timeoutTimer);
320
+ this.#pendingRequests.delete(id);
321
+ reject(
322
+ err instanceof Error
323
+ ? err
324
+ : new Error(`Failed to postMessage: ${String(err)}`),
325
+ );
326
+ }
327
+
328
+ return promise;
329
+ }
330
+
331
+ // ============================================================================
332
+ // Public API
333
+ // ============================================================================
334
+ /**
335
+ * Open a workspace root directory and initiate non-blocking background indexing in the worker.
336
+ * Returns immediately once the worker acknowledges start.
337
+ */
338
+ async openWorkspace(
339
+ rootDir: string,
340
+ config?: DuplicateDetectorConfig | WorkspaceOptions,
341
+ ): Promise<void> {
342
+ const options: WorkspaceOptions = {
343
+ minTokens: config?.minTokens,
344
+ minLines: config?.minLines,
345
+ maxLines: config?.maxLines,
346
+ ignorePatterns: config?.ignorePatterns,
347
+ formatsExts: config?.formatsExts,
348
+ maxIndexedFiles: config?.maxIndexedFiles,
349
+ };
350
+
351
+ const configHash = computeConfigHash(options);
352
+
353
+ // If already active on same root with same config and worker alive, reuse active worker state
354
+ if (
355
+ this.#activeRootDir === rootDir &&
356
+ this.#activeConfigHash === configHash &&
357
+ this.isWorkerAlive &&
358
+ !this.#isDisposed
359
+ ) {
360
+ const payload: OpenWorkspacePayload = { rootDir, options };
361
+ this.#lastOpenWorkspacePayload = payload;
362
+ await this.#sendRequest<{ started: boolean; reused?: boolean }>(
363
+ "openWorkspace",
364
+ payload,
365
+ );
366
+ return;
367
+ }
368
+
369
+ this.#epoch++;
370
+ this.#restartCount = 0;
371
+ this.#activeRootDir = rootDir;
372
+ this.#activeConfigHash = configHash;
373
+
374
+ const payload: OpenWorkspacePayload = { rootDir, options };
375
+ this.#lastOpenWorkspacePayload = payload;
376
+
377
+ await this.#sendRequest<{ started: boolean; reused?: boolean }>(
378
+ "openWorkspace",
379
+ payload,
380
+ );
381
+ }
382
+
383
+ /**
384
+ * Check a snippet or file content for clones against the current index without mutating index state.
385
+ * Fails open by returning an empty array if the worker is unavailable.
386
+ */
387
+ async checkSnippet(
388
+ filePath: string,
389
+ content: string,
390
+ format?: string,
391
+ ): Promise<IClone[]> {
392
+ try {
393
+ const payload: CheckSnippetPayload = { filePath, content, format };
394
+ return await this.#sendRequest<IClone[]>("checkSnippet", payload);
395
+ } catch (err) {
396
+ // Fail open: log and return empty clones rather than crashing agent
397
+ this.emit("error", err instanceof Error ? err : new Error(String(err)));
398
+ return [];
399
+ }
400
+ }
401
+
402
+ /**
403
+ * Check a modified file snippet against the index, update the index for that file,
404
+ * and return detected clones along with baseline completion status.
405
+ *
406
+ * Unlike read-only checks, mutation failures propagate so the extension can tell
407
+ * the user that duplicate detection did not run.
408
+ */
409
+ async checkAndUpdate(
410
+ filePath: string,
411
+ content: string,
412
+ revision?: number,
413
+ format?: string,
414
+ ): Promise<{ clones: IClone[]; isComplete: boolean }> {
415
+ try {
416
+ const payload: CheckAndUpdatePayload = {
417
+ filePath,
418
+ content,
419
+ revision,
420
+ format,
421
+ };
422
+ return await this.#sendRequest<{
423
+ clones: IClone[];
424
+ isComplete: boolean;
425
+ }>("checkAndUpdate", payload);
426
+ } catch (err) {
427
+ const error = err instanceof Error ? err : new Error(String(err));
428
+ this.emit("error", error);
429
+ throw error;
430
+ }
431
+ }
432
+
433
+ /**
434
+ * Update an indexed file with new content.
435
+ */
436
+ async updateFile(
437
+ filePath: string,
438
+ content: string,
439
+ format?: string,
440
+ ): Promise<{ clones: IClone[] }> {
441
+ try {
442
+ const payload: UpdateFilePayload = { filePath, content, format };
443
+ return await this.#sendRequest<{ clones: IClone[] }>(
444
+ "updateFile",
445
+ payload,
446
+ );
447
+ } catch (err) {
448
+ this.emit("error", err instanceof Error ? err : new Error(String(err)));
449
+ return { clones: [] };
450
+ }
451
+ }
452
+
453
+ /**
454
+ * Remove a file from the clone index.
455
+ */
456
+ async removeFile(filePath: string): Promise<void> {
457
+ try {
458
+ const payload: RemoveFilePayload = { filePath };
459
+ await this.#sendRequest<{ removed: boolean }>("removeFile", payload);
460
+ } catch (err) {
461
+ this.emit("error", err instanceof Error ? err : new Error(String(err)));
462
+ }
463
+ }
464
+
465
+ /**
466
+ * Reconcile multiple modified or removed files with the index.
467
+ */
468
+ async reconcile(files: ReconcileFileEntry[]): Promise<void> {
469
+ try {
470
+ const payload: ReconcilePayload = { files };
471
+ await this.#sendRequest<{ reconciledCount: number }>(
472
+ "reconcile",
473
+ payload,
474
+ );
475
+ } catch (err) {
476
+ this.emit("error", err instanceof Error ? err : new Error(String(err)));
477
+ }
478
+ }
479
+
480
+ /**
481
+ * Run an ad-hoc clone scan across the workspace or a specified target path.
482
+ */
483
+ async scan(options?: {
484
+ targetPath?: string;
485
+ options?: WorkspaceOptions;
486
+ }): Promise<IClone[]> {
487
+ try {
488
+ const payload: ScanPayload = {
489
+ targetPath: options?.targetPath,
490
+ options: options?.options,
491
+ };
492
+ return await this.#sendRequest<IClone[]>("scan", payload);
493
+ } catch (err) {
494
+ this.emit("error", err instanceof Error ? err : new Error(String(err)));
495
+ return [];
496
+ }
497
+ }
498
+
499
+ /**
500
+ * Gracefully close the worker thread, clean up all pending requests,
501
+ * and remove all event listeners.
502
+ */
503
+ async dispose(): Promise<void> {
504
+ if (this.#isDisposed) return;
505
+
506
+ if (this.#restartTimer !== null) {
507
+ clearTimeout(this.#restartTimer);
508
+ this.#restartTimer = null;
509
+ }
510
+
511
+ if (this.#worker) {
512
+ try {
513
+ await this.#sendRequest("close").catch(() => {});
514
+ } catch {}
515
+ }
516
+
517
+ for (const [_id, pending] of this.#pendingRequests.entries()) {
518
+ clearTimeout(pending.timeoutTimer);
519
+ pending.reject(new Error("DuplicateDetectorCoordinator was disposed"));
520
+ }
521
+ this.#pendingRequests.clear();
522
+
523
+ if (this.#worker) {
524
+ try {
525
+ this.#worker.terminate();
526
+ } catch {}
527
+ this.#worker = null;
528
+ }
529
+
530
+ this.#isDisposed = true;
531
+ this.#activeRootDir = null;
532
+ this.#activeConfigHash = null;
533
+
534
+ this.removeAllListeners();
535
+ }
536
+ }