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,464 @@
1
+ /**
2
+ * TUI notification component for duplicate detector warnings and reports.
3
+ * Styled to visually match the TTSR (Time-Traveling Stream Rules) notification in oh-my-pi.
4
+ *
5
+ * Message Renderers:
6
+ * - 'duplicate-detector-warning': Real-time mutation alerts queued when agent edits introduce duplicate code.
7
+ * - 'duplicate-detector-report': Interactive report queued by `/duplicates` into the session transcript with
8
+ * `triggerTurn: false` (displays full report card without prompting an immediate LLM turn).
9
+ * - 'duplicate-detector-status': Ready/capped indexing status lines in the session feed.
10
+ */
11
+ interface CloneDuplicationSpan {
12
+ sourceId: string;
13
+ start: { line: number; column?: number };
14
+ end: { line: number; column?: number };
15
+ fragment?: string;
16
+ }
17
+
18
+ export interface CloneItem {
19
+ duplicationA: CloneDuplicationSpan;
20
+ duplicationB: CloneDuplicationSpan;
21
+ format?: string;
22
+ }
23
+
24
+ export interface DuplicateNotificationData {
25
+ filePath?: string;
26
+ clones?: CloneItem[];
27
+ content?: string;
28
+ title?: string;
29
+ artifactId?: string;
30
+ }
31
+
32
+ export interface DuplicateStatusData {
33
+ status?: string;
34
+ count?: number;
35
+ cachedCount?: number;
36
+ content?: string;
37
+ }
38
+
39
+ interface ThemeIcons {
40
+ warning?: string;
41
+ rewind?: string;
42
+ package?: string;
43
+ [key: string]: string | undefined;
44
+ }
45
+
46
+ export interface ThemeLike {
47
+ fg(color: string, text: string): string;
48
+ bg(color: string, text: string): string;
49
+ bold(text: string): string;
50
+ italic(text: string): string;
51
+ inverse(text: string): string;
52
+ icon: ThemeIcons;
53
+ }
54
+
55
+ const MAX_COLLAPSED_CLONES = 4;
56
+
57
+ /**
58
+ * Parse structured clone info from system reminder XML or raw duplicate text if data object is absent.
59
+ */
60
+ export function parseClonesFromText(text: string): {
61
+ filePath: string;
62
+ clones: CloneItem[];
63
+ artifactId?: string;
64
+ } {
65
+ let filePath = "";
66
+ const fileMatch = text.match(/file="([^"]+)"/) || text.match(/in '([^']+)'/);
67
+ if (fileMatch) {
68
+ filePath = fileMatch[1] ?? "";
69
+ }
70
+
71
+ const clones: CloneItem[] = [];
72
+ const duplicateRegex =
73
+ /### Duplicate #\d+ \((\d+) lines, format: ([^)]+)\)[\s\S]*?- Current change: `([^`]+?):(\d+)-(\d+)`[\s\S]*?- Pre-existing copy: `([^`]+?):(\d+)-(\d+)`([\s\S]*?)(?=(?:### Duplicate #|<\/system-reminder>|$))/g;
74
+ let match: RegExpExecArray | null;
75
+ while ((match = duplicateRegex.exec(text)) !== null) {
76
+ const [
77
+ ,
78
+ _lines,
79
+ format,
80
+ fileA,
81
+ startA,
82
+ endA,
83
+ fileB,
84
+ startB,
85
+ endB,
86
+ rawSnippet,
87
+ ] = match;
88
+ let snippet = "";
89
+ if (rawSnippet) {
90
+ const codeBlockMatch = rawSnippet.match(/```[a-z0-9_-]*\n([\s\S]*?)```/i);
91
+ snippet = codeBlockMatch
92
+ ? (codeBlockMatch[1] ?? "").trim()
93
+ : rawSnippet.trim();
94
+ }
95
+
96
+ const startLineA = Number.parseInt(startA || "1", 10) || 1;
97
+ const endLineA = Number.parseInt(endA || "1", 10) || startLineA;
98
+ const startLineB = Number.parseInt(startB || "1", 10) || 1;
99
+ const endLineB = Number.parseInt(endB || "1", 10) || startLineB;
100
+
101
+ clones.push({
102
+ format: format?.trim() || "text",
103
+ duplicationA: {
104
+ sourceId: fileA?.trim() || filePath,
105
+ start: { line: startLineA },
106
+ end: { line: endLineA },
107
+ fragment: snippet,
108
+ },
109
+ duplicationB: {
110
+ sourceId: fileB?.trim() || "",
111
+ start: { line: startLineB },
112
+ end: { line: endLineB },
113
+ },
114
+ });
115
+ }
116
+ const artifactMatch =
117
+ text.match(/\[raw output: artifact:\/\/([\w.-]+)\]/) ||
118
+ text.match(/artifact:\/\/([\w.-]+)/);
119
+ const artifactId = artifactMatch ? artifactMatch[1] : undefined;
120
+
121
+ return { filePath, clones, artifactId };
122
+ }
123
+ /**
124
+ * Convert a sourceId (possibly virtual or absolute) to a clean relative display path.
125
+ */
126
+ export function toDisplayPath(sourceId: string, basePath?: string): string {
127
+ let clean = (sourceId || "").replace(/^virtual:/, "");
128
+ clean = clean.replace(/\\/g, "/");
129
+
130
+ const root = (
131
+ basePath ||
132
+ (typeof process !== "undefined" && process.cwd ? process.cwd() : "")
133
+ )?.replace(/\\/g, "/");
134
+
135
+ if (root && clean.startsWith(root)) {
136
+ clean = clean.slice(root.length);
137
+ if (clean.startsWith("/")) clean = clean.slice(1);
138
+ }
139
+ return clean || sourceId;
140
+ }
141
+
142
+ /**
143
+ * Strips ANSI escape sequences for length calculation.
144
+ */
145
+ export function stripAnsi(text: string): string {
146
+ return text.replace(/\x1b\[[0-9;]*m/g, "");
147
+ }
148
+
149
+ /**
150
+ * Truncates a string to a visible character width, preserving ANSI sequences and styling.
151
+ */
152
+ export function truncateVisible(text: string, maxWidth: number): string {
153
+ if (maxWidth <= 0) return "";
154
+ const ansiRegex = /\x1b\[[0-9;]*m/g;
155
+ let visibleLen = 0;
156
+ let result = "";
157
+ let lastIndex = 0;
158
+ let match: RegExpExecArray | null;
159
+
160
+ while ((match = ansiRegex.exec(text)) !== null) {
161
+ const segment = text.slice(lastIndex, match.index);
162
+ for (const char of segment) {
163
+ if (visibleLen >= maxWidth) break;
164
+ result += char;
165
+ visibleLen++;
166
+ }
167
+ result += match[0];
168
+ lastIndex = match.index + match[0].length;
169
+ if (visibleLen >= maxWidth) break;
170
+ }
171
+
172
+ if (visibleLen < maxWidth && lastIndex < text.length) {
173
+ const remaining = text.slice(lastIndex);
174
+ for (const char of remaining) {
175
+ if (visibleLen >= maxWidth) break;
176
+ result += char;
177
+ visibleLen++;
178
+ }
179
+ }
180
+
181
+ return result;
182
+ }
183
+
184
+ /**
185
+ * Pad or truncate a line to the exact target width taking visual width and ANSI sequences into account.
186
+ */
187
+ function padLine(text: string, width: number): string {
188
+ const visibleLen = stripAnsi(text).length;
189
+ if (visibleLen === width) return text;
190
+ if (visibleLen < width) return text + " ".repeat(width - visibleLen);
191
+ return truncateVisible(text, width);
192
+ }
193
+ /**
194
+ * TUI Component that renders duplicate detection notifications and reports in TTSR style.
195
+ * Used for both real-time mutation alerts and on-demand `/duplicates` scan reports queued into the transcript.
196
+ */
197
+ export class DuplicateNotificationComponent {
198
+ readonly #data: DuplicateNotificationData;
199
+ #expanded = false;
200
+ readonly #theme: ThemeLike;
201
+
202
+ constructor(
203
+ data: DuplicateNotificationData,
204
+ expanded = false,
205
+ theme?: ThemeLike,
206
+ ) {
207
+ this.#data = data;
208
+ this.#expanded = expanded;
209
+ this.#theme = {
210
+ fg:
211
+ typeof theme?.fg === "function"
212
+ ? theme.fg.bind(theme)
213
+ : (_color, t) => t,
214
+ bg:
215
+ typeof theme?.bg === "function"
216
+ ? theme.bg.bind(theme)
217
+ : (_color, t) => t,
218
+ bold:
219
+ typeof theme?.bold === "function"
220
+ ? theme.bold.bind(theme)
221
+ : (t) => `\x1b[1m${t}\x1b[22m`,
222
+ italic:
223
+ typeof theme?.italic === "function"
224
+ ? theme.italic.bind(theme)
225
+ : (t) => `\x1b[3m${t}\x1b[23m`,
226
+ inverse:
227
+ typeof theme?.inverse === "function"
228
+ ? theme.inverse.bind(theme)
229
+ : (t) => `\x1b[7m${t}\x1b[27m`,
230
+ icon: {
231
+ warning: theme?.icon?.warning ?? "",
232
+ rewind: theme?.icon?.rewind ?? "",
233
+ ...(theme?.icon ?? {}),
234
+ },
235
+ };
236
+ }
237
+ setExpanded(expanded: boolean): void {
238
+ this.#expanded = expanded;
239
+ }
240
+
241
+ isExpanded(): boolean {
242
+ return this.#expanded;
243
+ }
244
+
245
+ render(width = 80): readonly string[] {
246
+ const targetWidth =
247
+ typeof width === "number" && !Number.isNaN(width)
248
+ ? Math.max(30, width - 4)
249
+ : 76;
250
+ const maxInnerWidth = Math.max(20, targetWidth - 2);
251
+ const theme = this.#theme;
252
+ const lines: string[] = [];
253
+
254
+ const warnIcon = theme.icon?.warning ?? "";
255
+ const rewindIcon = theme.icon?.rewind ?? "";
256
+ const warnPrefix = warnIcon ? `${warnIcon} ` : "";
257
+ const rewindSuffix = rewindIcon ? ` ${rewindIcon}` : "";
258
+
259
+ let rawFilePath = this.#data.filePath || "";
260
+ let clones = this.#data.clones || [];
261
+ let artifactId = this.#data.artifactId;
262
+
263
+ if (
264
+ (!rawFilePath || clones.length === 0 || !artifactId) &&
265
+ this.#data.content
266
+ ) {
267
+ const parsed = parseClonesFromText(this.#data.content);
268
+ if (!rawFilePath) rawFilePath = parsed.filePath;
269
+ if (clones.length === 0) clones = parsed.clones;
270
+ if (!artifactId) artifactId = parsed.artifactId;
271
+ }
272
+ const filePath = toDisplayPath(rawFilePath);
273
+
274
+ // Header
275
+ let header: string;
276
+ if (clones.length <= 1) {
277
+ const target = filePath ? theme.bold(filePath) : "Code Duplication";
278
+ header = `${warnPrefix}Duplicate detected: ${target}${rewindSuffix}`;
279
+ } else {
280
+ const target = filePath ? theme.bold(filePath) : "workspace";
281
+ header = `${warnPrefix}${clones.length} duplicate blocks detected: ${target}${rewindSuffix}`;
282
+ }
283
+
284
+ lines.push(header);
285
+ lines.push(""); // Inner spacer
286
+
287
+ // Content
288
+ if (clones.length === 0) {
289
+ const rawDesc = (
290
+ this.#data.content || "Duplicated code found in recent changes."
291
+ ).trim();
292
+ const snippetLines = rawDesc.split("\n");
293
+ if (!this.#expanded && snippetLines.length > 2) {
294
+ lines.push(theme.italic(`${snippetLines.slice(0, 2).join(" ")}…`));
295
+ lines.push(theme.italic(" (ctrl+o to expand)"));
296
+ } else {
297
+ lines.push(theme.italic(rawDesc));
298
+ }
299
+ } else if (clones.length === 1) {
300
+ const clone = clones[0]!;
301
+ const a = clone.duplicationA;
302
+ const b = clone.duplicationB;
303
+ const linesCount = a.end.line - a.start.line + 1;
304
+ const srcA = toDisplayPath(a.sourceId);
305
+ const srcB = toDisplayPath(b.sourceId);
306
+ const singleLoc = `• ${srcA}:${a.start.line}-${a.end.line} ↔ ${srcB}:${b.start.line}-${b.end.line} (${linesCount} lines)`;
307
+ if (stripAnsi(singleLoc).length <= maxInnerWidth) {
308
+ lines.push(singleLoc);
309
+ } else {
310
+ lines.push(`• ${srcA}:${a.start.line}-${a.end.line}`);
311
+ lines.push(
312
+ ` ↔ ${srcB}:${b.start.line}-${b.end.line} (${linesCount} lines)`,
313
+ );
314
+ }
315
+ const snippet = a.fragment?.trim();
316
+ if (snippet) {
317
+ const snippetLines = snippet.split(/\r?\n/);
318
+ if (!this.#expanded) {
319
+ for (const sLine of snippetLines.slice(0, 2)) {
320
+ lines.push(theme.italic(` ${sLine}`));
321
+ }
322
+ if (snippetLines.length > 2) {
323
+ lines.push(theme.italic(" … (ctrl+o to expand)"));
324
+ }
325
+ } else {
326
+ lines.push("");
327
+ for (const sLine of snippetLines) {
328
+ lines.push(theme.italic(` ${sLine}`));
329
+ }
330
+ }
331
+ }
332
+ } else {
333
+ // Multi-clone display
334
+ const visible = this.#expanded
335
+ ? clones
336
+ : clones.slice(0, MAX_COLLAPSED_CLONES);
337
+ for (let i = 0; i < visible.length; i++) {
338
+ const clone = visible[i]!;
339
+ const a = clone.duplicationA;
340
+ const b = clone.duplicationB;
341
+ const linesCount = a.end.line - a.start.line + 1;
342
+ const srcA = toDisplayPath(a.sourceId);
343
+ const srcB = toDisplayPath(b.sourceId);
344
+ const loc = `• ${srcA}:${a.start.line}-${a.end.line} ↔ ${srcB}:${b.start.line}-${b.end.line} (${linesCount} lines)`;
345
+ if (stripAnsi(loc).length <= maxInnerWidth) {
346
+ lines.push(loc);
347
+ } else {
348
+ lines.push(`• ${srcA}:${a.start.line}-${a.end.line}`);
349
+ lines.push(
350
+ ` ↔ ${srcB}:${b.start.line}-${b.end.line} (${linesCount} lines)`,
351
+ );
352
+ }
353
+ if (this.#expanded && a.fragment?.trim()) {
354
+ const snippetLines = a.fragment.trim().split(/\r?\n/);
355
+ for (const sLine of snippetLines.slice(0, 5)) {
356
+ lines.push(theme.italic(` ${sLine}`));
357
+ }
358
+ if (snippetLines.length > 5) {
359
+ lines.push(theme.italic(` … +${snippetLines.length - 5} lines`));
360
+ }
361
+ }
362
+ }
363
+
364
+ const hidden = clones.length - visible.length;
365
+ if (hidden > 0) {
366
+ const artifactNote = artifactId ? ` • artifact://${artifactId}` : "";
367
+ lines.push(
368
+ theme.italic(`… +${hidden} more (ctrl+o to expand${artifactNote})`),
369
+ );
370
+ } else if (!this.#expanded && clones.length > 0) {
371
+ const artifactNote = artifactId ? ` • artifact://${artifactId}` : "";
372
+ lines.push(theme.italic(` (ctrl+o to expand${artifactNote})`));
373
+ }
374
+
375
+ if (this.#expanded && artifactId) {
376
+ lines.push("");
377
+ lines.push(theme.italic(`Full report: artifact://${artifactId}`));
378
+ }
379
+ }
380
+ // Defensive flattening: ensure no element in lines contains embedded newlines or raw tabs
381
+ const flatLines: string[] = [];
382
+ for (const line of lines) {
383
+ const sanitized = line.replace(/\t/g, " ");
384
+ if (sanitized.includes("\n")) {
385
+ flatLines.push(...sanitized.split(/\r?\n/));
386
+ } else {
387
+ flatLines.push(sanitized);
388
+ }
389
+ }
390
+ const paddedLines = flatLines.map((line) => {
391
+ const contentWithPadding = ` ${line}`;
392
+ const padded = padLine(contentWithPadding, targetWidth);
393
+ return theme.inverse(theme.fg("warning", padded));
394
+ });
395
+
396
+ // Add top/bottom empty line with inverse warning background for box effect
397
+ const emptyBoxLine = theme.inverse(
398
+ theme.fg("warning", " ".repeat(targetWidth)),
399
+ );
400
+ const boxed = [emptyBoxLine, ...paddedLines, emptyBoxLine];
401
+
402
+ // Leading spacer line above the box (matching TtsrNotificationComponent Spacer(1))
403
+ return ["", ...boxed];
404
+ }
405
+ }
406
+
407
+ /**
408
+ * TUI Component that renders minimal duplicate detector status notifications in the transcript.
409
+ */
410
+ export class DuplicateStatusComponent {
411
+ readonly #data: DuplicateStatusData;
412
+ readonly #theme: ThemeLike;
413
+
414
+ constructor(data: DuplicateStatusData, theme?: ThemeLike) {
415
+ this.#data = data;
416
+ this.#theme = {
417
+ fg:
418
+ typeof theme?.fg === "function"
419
+ ? theme.fg.bind(theme)
420
+ : (_color, t) => t,
421
+ bg:
422
+ typeof theme?.bg === "function"
423
+ ? theme.bg.bind(theme)
424
+ : (_color, t) => t,
425
+ bold:
426
+ typeof theme?.bold === "function"
427
+ ? theme.bold.bind(theme)
428
+ : (t) => `\x1b[1m${t}\x1b[22m`,
429
+ italic:
430
+ typeof theme?.italic === "function"
431
+ ? theme.italic.bind(theme)
432
+ : (t) => `\x1b[3m${t}\x1b[23m`,
433
+ inverse:
434
+ typeof theme?.inverse === "function"
435
+ ? theme.inverse.bind(theme)
436
+ : (t) => `\x1b[7m${t}\x1b[27m`,
437
+ icon: {
438
+ warning: theme?.icon?.warning ?? "",
439
+ ...(theme?.icon ?? {}),
440
+ },
441
+ };
442
+ }
443
+
444
+ render(_width = 80): readonly string[] {
445
+ const theme = this.#theme;
446
+ const text = (this.#data.content || "").trim();
447
+ if (!text) return [];
448
+
449
+ const isWarning =
450
+ this.#data.status === "capped_file_count" ||
451
+ this.#data.status === "capped_source_bytes";
452
+
453
+ const prefix = isWarning
454
+ ? theme.icon?.warning
455
+ ? `${theme.icon.warning} `
456
+ : "[!] "
457
+ : "";
458
+ const coloredText = isWarning
459
+ ? theme.fg("warning", `${prefix}${text}`)
460
+ : theme.italic(theme.fg("muted", text));
461
+
462
+ return [coloredText];
463
+ }
464
+ }