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,797 @@
1
+ import { execFile } from "node:child_process";
2
+ import * as path from "node:path";
3
+ import {
4
+ Detector,
5
+ type IClone,
6
+ type IMapFrame,
7
+ type IOptions,
8
+ type IStore,
9
+ MemoryStore,
10
+ } from "@jscpd/core";
11
+ import { getFormatByFile, Tokenizer } from "@jscpd/tokenizer";
12
+ import ignore from "ignore";
13
+ import { cloneIdentity } from "./duplicate-ledger";
14
+ import { isTestFile, type TestFileDetectorOptions } from "./test-detector";
15
+
16
+ export interface IgnoreFilterOptions extends TestFileDetectorOptions {}
17
+
18
+ export interface JscpdIndexManagerOptions extends TestFileDetectorOptions {
19
+ minTokens?: number;
20
+ minLines?: number;
21
+ maxLines?: number;
22
+ ignorePatterns?: string[];
23
+ formatsExts?: Record<string, string[]>;
24
+ crossFormats?: boolean;
25
+ maxIndexedFiles?: number;
26
+ }
27
+
28
+ export type BaselineStatus =
29
+ | "idle"
30
+ | "complete"
31
+ | "skipped_not_git"
32
+ | "capped_file_count"
33
+ | "capped_source_bytes"
34
+ | "failed"
35
+ | "cancelled"
36
+ | "disabled";
37
+ /** Maximum file size in bytes to tokenize (100 KiB matches jscpd defaults) */
38
+ export const MAX_FILE_SIZE_BYTES = 100 * 1024;
39
+
40
+ /** Hard circuit breaker: Maximum files indexed during baseline initialization */
41
+ export const MAX_INDEXED_FILES = 10000;
42
+
43
+ /** Hard circuit breaker: Maximum total source bytes across all indexed files (64 MiB) */
44
+ export const MAX_TOTAL_SOURCE_BYTES = 64 * 1024 * 1024;
45
+
46
+ /** Maximum tracked Git paths to inspect */
47
+ export const MAX_GIT_PATHS = 10000;
48
+
49
+ /**
50
+ * Common generated, lockfile, minified, and metadata file patterns
51
+ * automatically excluded from code duplication analysis.
52
+ */
53
+ const DEFAULT_NOISE_PATTERNS = [
54
+ "*.lock",
55
+ "*.lockb",
56
+ "*-lock.json",
57
+ "*-lock.yaml",
58
+ "*-lock.yml",
59
+ "*.lock.json",
60
+ "*.min.js",
61
+ "*.min.css",
62
+ "*.min.*",
63
+ "*.bundle.js",
64
+ "*.map",
65
+ "*.generated.*",
66
+ "*.designer.cs",
67
+ "*.designer.vb",
68
+ ".DS_Store",
69
+ "node_modules",
70
+ ];
71
+
72
+ /**
73
+ * Non-code data, documentation, and metadata formats excluded from default code duplicate analysis.
74
+ */
75
+ const DEFAULT_NON_CODE_FORMATS: ReadonlySet<string> = new Set([
76
+ "abnf",
77
+ "apacheconf",
78
+ "arff",
79
+ "asciidoc",
80
+ "bnf",
81
+ "comments",
82
+ "csp",
83
+ "csv",
84
+ "diff",
85
+ "dns-zone-file",
86
+ "dot",
87
+ "ebnf",
88
+ "editorconfig",
89
+ "excel-formula",
90
+ "gedcom",
91
+ "gettext",
92
+ "git",
93
+ "hpkp",
94
+ "hsts",
95
+ "http",
96
+ "ignore",
97
+ "ini",
98
+ "json",
99
+ "json5",
100
+ "keymap",
101
+ "latex",
102
+ "log",
103
+ "markdown",
104
+ "mermaid",
105
+ "nginx",
106
+ "plant-uml",
107
+ "properties",
108
+ "regex",
109
+ "rest",
110
+ "roboconf",
111
+ "shell-session",
112
+ "sparql",
113
+ "tap",
114
+ "textile",
115
+ "toml",
116
+ "turtle",
117
+ "txt",
118
+ "uri",
119
+ "url",
120
+ "wiki",
121
+ "yaml",
122
+ ]);
123
+
124
+ /**
125
+ * Resolves whether a file corresponds to a supported source code format for duplicate detection.
126
+ * Filters out non-code documentation, data, logs, and diff formats by default.
127
+ * If user explicitly maps an extension via formatsExts, user configuration takes precedence.
128
+ */
129
+ export function getSupportedCodeFormat(
130
+ filePath: string,
131
+ formatsExts?: Record<string, string[]>,
132
+ ): string | undefined {
133
+ const format = getFormatByFile(filePath, formatsExts);
134
+ if (!format) {
135
+ return undefined;
136
+ }
137
+
138
+ // If user explicitly configured this format under formatsExts, honor user intent
139
+ if (formatsExts && format in formatsExts) {
140
+ return format;
141
+ }
142
+
143
+ if (DEFAULT_NON_CODE_FORMATS.has(format)) {
144
+ return undefined;
145
+ }
146
+
147
+ // Filter out non-code markup data files (e.g. standalone .svg, .xml, .xsl, and .xslt data files)
148
+ if (format === "markup") {
149
+ const ext = path.extname(filePath).toLowerCase();
150
+ if (ext === ".svg" || ext === ".xml" || ext === ".xsl" || ext === ".xslt") {
151
+ return undefined;
152
+ }
153
+ }
154
+
155
+ return format;
156
+ }
157
+
158
+ /** Standard generated code header comment markers */
159
+ const GENERATED_HEADER_MARKERS = [
160
+ /@generated\b/i,
161
+ /\bauto-generated\b/i,
162
+ /\bautomatically generated\b/i,
163
+ /\bGENERATED BY\b/i,
164
+ /\bDO NOT EDIT\b/i,
165
+ /\bDO NOT MODIFY\b/i,
166
+ /<auto-generated\b/i,
167
+ /<autogenerated\b/i,
168
+ ];
169
+
170
+ export function execGit(
171
+ args: string[],
172
+ cwd: string,
173
+ options: { signal?: AbortSignal; maxBuffer?: number } = {},
174
+ ): Promise<{ stdout: string; stderr: string }> {
175
+ const { promise, resolve, reject } = Promise.withResolvers<{
176
+ stdout: string;
177
+ stderr: string;
178
+ }>();
179
+ execFile(
180
+ "git",
181
+ args,
182
+ {
183
+ cwd,
184
+ signal: options.signal,
185
+ maxBuffer: options.maxBuffer,
186
+ windowsHide: true,
187
+ },
188
+ (err, stdout, stderr) => {
189
+ if (err) return reject(err);
190
+ resolve({ stdout: String(stdout), stderr: String(stderr) });
191
+ },
192
+ );
193
+ return promise;
194
+ }
195
+
196
+ /**
197
+ * Check if file content contains standard code-generation markers in the header.
198
+ */
199
+ export function isGeneratedContent(content: string): boolean {
200
+ const head = content.slice(0, 2048);
201
+ return GENERATED_HEADER_MARKERS.some((pattern) => pattern.test(head));
202
+ }
203
+
204
+ /**
205
+ * Creates a path ignore predicate matching against default noise patterns
206
+ * and provided user/project ignore patterns.
207
+ */
208
+ export function createIgnoreFilter(
209
+ userIgnorePatterns: string[] = [],
210
+ options: IgnoreFilterOptions = {},
211
+ ): (relPath: string) => boolean {
212
+ const ig = ignore().add(DEFAULT_NOISE_PATTERNS).add(userIgnorePatterns);
213
+ const shouldIgnoreTests = options.ignoreTests !== false;
214
+ return (relPath: string) => {
215
+ if (!relPath || typeof relPath !== "string") return false;
216
+ const normalized = relPath.trim().replace(/\\/g, "/").replace(/^\.\//, "");
217
+ if (!normalized || normalized === ".") return false;
218
+ if (
219
+ normalized.startsWith("../") ||
220
+ normalized === ".." ||
221
+ normalized.startsWith("/") ||
222
+ path.isAbsolute(normalized)
223
+ ) {
224
+ return false;
225
+ }
226
+ if (shouldIgnoreTests && isTestFile(normalized, options)) {
227
+ return true;
228
+ }
229
+ try {
230
+ return ig.ignores(normalized);
231
+ } catch {
232
+ return false;
233
+ }
234
+ };
235
+ }
236
+
237
+ /**
238
+ * Check whether a directory is inside a valid Git working tree.
239
+ */
240
+ export async function isInsideGitWorkTree(
241
+ dir: string,
242
+ signal?: AbortSignal,
243
+ ): Promise<boolean> {
244
+ try {
245
+ const { stdout } = await execGit(
246
+ ["rev-parse", "--is-inside-work-tree"],
247
+ dir,
248
+ { signal },
249
+ );
250
+ return stdout.trim() === "true";
251
+ } catch {
252
+ return false;
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Retrieve tracked Git files beneath rootDir (using `git ls-files --cached`).
258
+ * Does NOT enumerate untracked files (`--others`), preventing runaway traversal.
259
+ */
260
+ export async function getTrackedGitFiles(
261
+ rootDir: string,
262
+ options: {
263
+ userIgnorePatterns?: string[];
264
+ ignoreTests?: boolean;
265
+ customTestPatterns?: string[];
266
+ excludeTestPatterns?: string[];
267
+ signal?: AbortSignal;
268
+ maxPaths?: number;
269
+ } = {},
270
+ ): Promise<string[]> {
271
+ if (!(await isInsideGitWorkTree(rootDir, options.signal))) {
272
+ return [];
273
+ }
274
+
275
+ const maxPaths = options.maxPaths ?? MAX_GIT_PATHS;
276
+ const ignoreFilter = createIgnoreFilter(options.userIgnorePatterns ?? [], {
277
+ ignoreTests: options.ignoreTests,
278
+ customTestPatterns: options.customTestPatterns,
279
+ excludeTestPatterns: options.excludeTestPatterns,
280
+ });
281
+
282
+ try {
283
+ const { stdout } = await execGit(
284
+ ["ls-files", "--cached", "-z", "--", "."],
285
+ rootDir,
286
+ {
287
+ signal: options.signal,
288
+ maxBuffer: 16 * 1024 * 1024,
289
+ },
290
+ );
291
+
292
+ const entries = stdout.split("\0");
293
+ const results: string[] = [];
294
+ for (const entry of entries) {
295
+ if (options.signal?.aborted) break;
296
+ const trimmed = entry.trim();
297
+ if (!trimmed) continue;
298
+
299
+ if (results.length >= maxPaths) break;
300
+
301
+ const relPath = trimmed.replace(/\\/g, "/").replace(/^\.\//, "");
302
+ if (
303
+ !relPath ||
304
+ relPath === ".." ||
305
+ relPath.startsWith("../") ||
306
+ relPath.startsWith("/") ||
307
+ path.isAbsolute(relPath)
308
+ ) {
309
+ continue;
310
+ }
311
+ if (ignoreFilter(relPath)) continue;
312
+ results.push(path.resolve(rootDir, trimmed));
313
+ }
314
+ return results;
315
+ } catch {
316
+ return [];
317
+ }
318
+ }
319
+
320
+ /**
321
+ * Isolated overlay store for running non-mutating snippet queries.
322
+ * Prevents virtual query frames from polluting the persistent MemoryStore.
323
+ */
324
+ class IsolatedMemoryStore implements IStore<IMapFrame> {
325
+ #namespace = "";
326
+ readonly #baseValues: Record<string, Record<string, IMapFrame>>;
327
+ readonly #overlayValues: Record<string, Record<string, IMapFrame>> = {};
328
+
329
+ constructor(baseValues: Record<string, Record<string, IMapFrame>>) {
330
+ this.#baseValues = baseValues;
331
+ }
332
+
333
+ namespace(ns: string): void {
334
+ this.#namespace = ns;
335
+ this.#overlayValues[ns] = this.#overlayValues[ns] || {};
336
+ }
337
+
338
+ get(key: string): Promise<IMapFrame> {
339
+ const overlay = this.#overlayValues[this.#namespace];
340
+ if (overlay && key in overlay) {
341
+ return Promise.resolve(overlay[key]!);
342
+ }
343
+ const base = this.#baseValues[this.#namespace];
344
+ if (base && key in base) {
345
+ return Promise.resolve(base[key]!);
346
+ }
347
+ return Promise.reject(new Error("not found"));
348
+ }
349
+
350
+ set(key: string, value: IMapFrame): Promise<IMapFrame> {
351
+ if (!this.#overlayValues[this.#namespace]) {
352
+ this.#overlayValues[this.#namespace] = {};
353
+ }
354
+ this.#overlayValues[this.#namespace]![key] = value;
355
+ return Promise.resolve(value);
356
+ }
357
+
358
+ close(): void {
359
+ // No-op for isolated overlay
360
+ }
361
+ }
362
+
363
+ /**
364
+ * Subclass of MemoryStore that allows extracting the underlying namespace map
365
+ * and evicting all frames for a given source file.
366
+ */
367
+ class ExportableMemoryStore extends MemoryStore<IMapFrame> {
368
+ getNamespaceValues(): Record<string, Record<string, IMapFrame>> {
369
+ return this.values;
370
+ }
371
+
372
+ deleteBySourceId(sourceId: string): void {
373
+ for (const ns of Object.keys(this.values)) {
374
+ const nsMap = this.values[ns];
375
+ if (!nsMap) continue;
376
+ for (const key of Object.keys(nsMap)) {
377
+ const frame = nsMap[key];
378
+ if (frame && frame.sourceId === sourceId) {
379
+ delete nsMap[key];
380
+ }
381
+ }
382
+ }
383
+ }
384
+ }
385
+
386
+ export class JscpdIndexManager {
387
+ readonly #tokenizer: Tokenizer;
388
+ readonly #store: ExportableMemoryStore;
389
+ readonly #options: IOptions & { maxIndexedFiles?: number };
390
+ readonly #managerOptions: JscpdIndexManagerOptions;
391
+ #detector: Detector;
392
+ #indexedFiles = new Set<string>();
393
+ #discoveredClones: IClone[] = [];
394
+ #initialized = false;
395
+ #initPromise: Promise<number> | null = null;
396
+ #mutationQueue: Promise<void> = Promise.resolve();
397
+ #rootDir = "";
398
+ #baselineStatus: BaselineStatus = "idle";
399
+ #totalSourceBytes = 0;
400
+
401
+ constructor(options: JscpdIndexManagerOptions = {}) {
402
+ this.#tokenizer = new Tokenizer();
403
+ this.#store = new ExportableMemoryStore();
404
+ this.#managerOptions = { ...options };
405
+ this.#options = {
406
+ minTokens: options.minTokens ?? 40,
407
+ minLines: options.minLines ?? 5,
408
+ maxLines: options.maxLines ?? 500,
409
+ formatsExts: options.formatsExts,
410
+ maxIndexedFiles: options.maxIndexedFiles,
411
+ };
412
+ this.#detector = new Detector(
413
+ this.#tokenizer,
414
+ this.#store,
415
+ [],
416
+ this.#options,
417
+ );
418
+ }
419
+
420
+ get isInitialized(): boolean {
421
+ return this.#initialized;
422
+ }
423
+
424
+ get indexedCount(): number {
425
+ return this.#indexedFiles.size;
426
+ }
427
+
428
+ get rootDir(): string {
429
+ return this.#rootDir;
430
+ }
431
+
432
+ get baselineStatus(): BaselineStatus {
433
+ return this.#baselineStatus;
434
+ }
435
+
436
+ get totalSourceBytes(): number {
437
+ return this.#totalSourceBytes;
438
+ }
439
+
440
+ get discoveredClones(): IClone[] {
441
+ return this.#discoveredClones.slice();
442
+ }
443
+
444
+ /**
445
+ * Reset all store contents and index state.
446
+ */
447
+ reset(): void {
448
+ this.#store.close();
449
+ this.#indexedFiles.clear();
450
+ this.#discoveredClones = [];
451
+ this.#initialized = false;
452
+ this.#initPromise = null;
453
+ this.#baselineStatus = "idle";
454
+ this.#totalSourceBytes = 0;
455
+ this.#detector = new Detector(
456
+ this.#tokenizer,
457
+ this.#store,
458
+ [],
459
+ this.#options,
460
+ );
461
+ }
462
+
463
+ /**
464
+ * Scan and index the workspace directory into the token store.
465
+ * Only scans Git-tracked files in a valid Git worktree.
466
+ * Coalesces concurrent initialization requests.
467
+ */
468
+ async initialize(
469
+ rootDir: string,
470
+ ignorePatterns?: string[],
471
+ signal?: AbortSignal,
472
+ ): Promise<number> {
473
+ if (this.#initPromise) {
474
+ return this.#initPromise;
475
+ }
476
+
477
+ this.#initPromise = this.#runInitialize(
478
+ rootDir,
479
+ ignorePatterns,
480
+ signal,
481
+ ).finally(() => {
482
+ this.#initPromise = null;
483
+ });
484
+
485
+ return this.#initPromise;
486
+ }
487
+
488
+ async #runInitialize(
489
+ rootDir: string,
490
+ ignorePatterns?: string[],
491
+ signal?: AbortSignal,
492
+ ): Promise<number> {
493
+ this.#rootDir = path.resolve(rootDir);
494
+ this.reset();
495
+
496
+ if (signal?.aborted) {
497
+ this.#baselineStatus = "cancelled";
498
+ this.#initialized = false;
499
+ return 0;
500
+ }
501
+
502
+ const isGit = await isInsideGitWorkTree(this.#rootDir, signal);
503
+ if (!isGit) {
504
+ this.#baselineStatus = "skipped_not_git";
505
+ this.#initialized = true;
506
+ return 0;
507
+ }
508
+
509
+ const userIgnores =
510
+ ignorePatterns && ignorePatterns.length > 0 ? ignorePatterns : [];
511
+ const maxIndexedFiles = this.#options.maxIndexedFiles ?? MAX_INDEXED_FILES;
512
+ const files = await getTrackedGitFiles(this.#rootDir, {
513
+ userIgnorePatterns: userIgnores,
514
+ ignoreTests: this.#managerOptions.ignoreTests,
515
+ customTestPatterns: this.#managerOptions.customTestPatterns,
516
+ excludeTestPatterns: this.#managerOptions.excludeTestPatterns,
517
+ signal,
518
+ maxPaths: Math.max(MAX_GIT_PATHS, maxIndexedFiles),
519
+ });
520
+
521
+ const seenCloneIds = new Set<string>();
522
+ let status: BaselineStatus = "complete";
523
+
524
+ for (const filePath of files) {
525
+ if (signal?.aborted) {
526
+ status = "cancelled";
527
+ break;
528
+ }
529
+
530
+ if (this.#indexedFiles.size >= maxIndexedFiles) {
531
+ status = "capped_file_count";
532
+ break;
533
+ }
534
+
535
+ const relPath = path
536
+ .relative(this.#rootDir, filePath)
537
+ .replace(/\\/g, "/");
538
+ const format = getSupportedCodeFormat(
539
+ filePath,
540
+ this.#options.formatsExts,
541
+ );
542
+ if (!format) continue;
543
+
544
+ try {
545
+ const file = Bun.file(filePath);
546
+ const size = file.size;
547
+ if (size > MAX_FILE_SIZE_BYTES) continue;
548
+
549
+ if (this.#totalSourceBytes + size > MAX_TOTAL_SOURCE_BYTES) {
550
+ status = "capped_source_bytes";
551
+ break;
552
+ }
553
+
554
+ const content = await file.text();
555
+ if (isGeneratedContent(content)) continue;
556
+
557
+ this.#totalSourceBytes += size;
558
+ const clones = await this.#detector.detect(relPath, content, format);
559
+
560
+ this.#indexedFiles.add(relPath);
561
+
562
+ for (const clone of clones) {
563
+ const id = cloneIdentity(clone);
564
+ if (!seenCloneIds.has(id)) {
565
+ seenCloneIds.add(id);
566
+ this.#discoveredClones.push(clone);
567
+ }
568
+ }
569
+ } catch {
570
+ // Skip unreadable files
571
+ }
572
+ }
573
+
574
+ this.#baselineStatus = signal?.aborted ? "cancelled" : status;
575
+ this.#initialized = !signal?.aborted;
576
+ return this.#indexedFiles.size;
577
+ }
578
+
579
+ /**
580
+ * Check a code snippet or modified file against the indexed repository without polluting the persistent store.
581
+ * Returns clones matching against existing repository files or intra-file duplicates.
582
+ */
583
+ async checkSnippet(targetPath: string, content: string): Promise<IClone[]> {
584
+ if (this.#initPromise) {
585
+ await this.#initPromise;
586
+ }
587
+
588
+ const format = getSupportedCodeFormat(
589
+ targetPath,
590
+ this.#options.formatsExts,
591
+ );
592
+ if (!format) return [];
593
+
594
+ const relPath = (
595
+ path.isAbsolute(targetPath) && this.#rootDir
596
+ ? path.relative(this.#rootDir, targetPath)
597
+ : targetPath
598
+ ).replace(/\\/g, "/");
599
+
600
+ const virtualId = `virtual:${relPath}`;
601
+
602
+ // Use an isolated overlay store to avoid polluting the persistent MemoryStore with virtual frames
603
+ const overlayStore = new IsolatedMemoryStore(
604
+ this.#store.getNamespaceValues(),
605
+ );
606
+ const queryDetector = new Detector(
607
+ this.#tokenizer,
608
+ overlayStore,
609
+ [],
610
+ this.#options,
611
+ );
612
+
613
+ const allClones = await queryDetector.detect(virtualId, content, format);
614
+
615
+ // Filter clones:
616
+ const relevantClones: IClone[] = [];
617
+ for (const clone of allClones) {
618
+ const isAQuery = clone.duplicationA.sourceId === virtualId;
619
+ const isBQuery = clone.duplicationB.sourceId === virtualId;
620
+
621
+ if (isAQuery && !isBQuery) {
622
+ if (clone.duplicationB.sourceId !== relPath) {
623
+ relevantClones.push(clone);
624
+ }
625
+ } else if (!isAQuery && isBQuery) {
626
+ if (clone.duplicationA.sourceId !== relPath) {
627
+ relevantClones.push({
628
+ ...clone,
629
+ duplicationA: clone.duplicationB,
630
+ duplicationB: clone.duplicationA,
631
+ });
632
+ }
633
+ } else if (isAQuery && isBQuery) {
634
+ relevantClones.push(clone);
635
+ }
636
+ }
637
+
638
+ return relevantClones;
639
+ }
640
+
641
+ /**
642
+ * Update the index when a file is written or edited (Hot Index).
643
+ * Serialized through a promise queue to prevent namespace race conditions.
644
+ * Evicts stale token frames for the file before re-indexing.
645
+ */
646
+ async updateFile(targetPath: string, content: string): Promise<void> {
647
+ if (this.#initPromise) {
648
+ await this.#initPromise;
649
+ }
650
+
651
+ const mutation = async () => {
652
+ const format = getSupportedCodeFormat(
653
+ targetPath,
654
+ this.#options.formatsExts,
655
+ );
656
+ if (!format) return;
657
+
658
+ const relPath = (
659
+ path.isAbsolute(targetPath) && this.#rootDir
660
+ ? path.relative(this.#rootDir, targetPath)
661
+ : targetPath
662
+ ).replace(/\\/g, "/");
663
+
664
+ // Evict old frames for this source file
665
+ this.#store.deleteBySourceId(relPath);
666
+
667
+ // Re-detect and index new frames
668
+ await this.#detector.detect(relPath, content, format);
669
+ this.#indexedFiles.add(relPath);
670
+ };
671
+
672
+ this.#mutationQueue = this.#mutationQueue.then(mutation, mutation);
673
+ return this.#mutationQueue;
674
+ }
675
+
676
+ /**
677
+ * Format discovered clones into a Markdown report.
678
+ */
679
+ formatReport(
680
+ clones: IClone[] = this.#discoveredClones,
681
+ scanPath = this.#rootDir,
682
+ options?: FormatReportOptions,
683
+ ): string {
684
+ return formatReport(clones, scanPath, {
685
+ indexedCount: this.#indexedFiles.size,
686
+ baselineStatus: this.#baselineStatus,
687
+ maxIndexedFiles: this.#options.maxIndexedFiles ?? MAX_INDEXED_FILES,
688
+ minLines: this.#options.minLines,
689
+ minTokens: this.#options.minTokens,
690
+ ...options,
691
+ });
692
+ }
693
+ }
694
+
695
+ export interface FormatReportOptions {
696
+ indexedCount?: number;
697
+ baselineStatus?: BaselineStatus;
698
+ maxIndexedFiles?: number;
699
+ minLines?: number;
700
+ minTokens?: number;
701
+ maxClones?: number;
702
+ maxBytes?: number;
703
+ artifactId?: string;
704
+ }
705
+
706
+ /**
707
+ * Formats discovered clone clusters into a Markdown report.
708
+ * Supports top-N capping and byte limits with artifact:// recovery links matching oh-my-pi conventions.
709
+ */
710
+ export function formatReport(
711
+ clones: IClone[] = [],
712
+ scanPath = ".",
713
+ options?: FormatReportOptions,
714
+ ): string {
715
+ let report = "# Duplicate Code Report\n\n";
716
+ if (typeof options?.indexedCount === "number") {
717
+ report += `- **Indexed Files**: ${options.indexedCount}\n`;
718
+ }
719
+ report += `- **Scan Target**: \`${scanPath || "."}\`\n`;
720
+ report += `- **Duplicate Clusters Found**: ${clones.length}\n`;
721
+
722
+ if (options?.baselineStatus === "skipped_not_git") {
723
+ report +=
724
+ "- **Baseline Status**: Skipped (Directory is not inside a Git working tree; automatic scan requires Git-tracked files)\n";
725
+ } else if (options?.baselineStatus === "capped_file_count") {
726
+ const maxFiles = (
727
+ options?.maxIndexedFiles ?? MAX_INDEXED_FILES
728
+ ).toLocaleString();
729
+ report += `- **Baseline Status**: Capped at ${maxFiles} files limit\n`;
730
+ } else if (options?.baselineStatus === "capped_source_bytes") {
731
+ report +=
732
+ "- **Baseline Status**: Capped at 64 MiB total source size limit\n";
733
+ }
734
+
735
+ report += "\n";
736
+
737
+ if (clones.length === 0) {
738
+ const minLines = options?.minLines ?? 5;
739
+ const minTokens = options?.minTokens ?? 40;
740
+ report += `No duplicate code blocks found matching threshold (minLines: ${minLines}, minTokens: ${minTokens}).\n`;
741
+ return report;
742
+ }
743
+
744
+ report += "## Detected Clones\n\n";
745
+
746
+ const maxClones = options?.maxClones;
747
+ const maxBytes = options?.maxBytes;
748
+ const artifactId = options?.artifactId;
749
+
750
+ const countToRender =
751
+ typeof maxClones === "number" && maxClones > 0
752
+ ? Math.min(clones.length, maxClones)
753
+ : clones.length;
754
+
755
+ let renderedClones = 0;
756
+
757
+ for (let i = 0; i < countToRender; i++) {
758
+ const clone = clones[i]!;
759
+ const a = clone.duplicationA;
760
+ const b = clone.duplicationB;
761
+ const linesCount = a.end.line - a.start.line + 1;
762
+
763
+ let block = `### Clone #${i + 1} (${linesCount} lines, format: ${clone.format})\n`;
764
+ block += `- **Location A**: \`${a.sourceId}:${a.start.line}-${a.end.line}\`\n`;
765
+ block += `- **Location B**: \`${b.sourceId}:${b.start.line}-${b.end.line}\`\n`;
766
+
767
+ if (a.fragment) {
768
+ block += `\n\`\`\`${clone.format}\n${a.fragment.trim()}\n\`\`\`\n`;
769
+ }
770
+ block += "\n";
771
+
772
+ if (typeof maxBytes === "number" && maxBytes > 0) {
773
+ const estimatedTotal = Buffer.byteLength(report + block, "utf-8") + 512;
774
+ if (renderedClones > 0 && estimatedTotal > maxBytes) {
775
+ break;
776
+ }
777
+ }
778
+
779
+ report += block;
780
+ renderedClones++;
781
+ }
782
+
783
+ const omittedCount = clones.length - renderedClones;
784
+ if (omittedCount > 0) {
785
+ report += `*Showing top ${renderedClones} of ${clones.length} duplicate clusters (${omittedCount} additional cluster${omittedCount === 1 ? "" : "s"} omitted from inline context).*`;
786
+ if (artifactId) {
787
+ report += `\n*Read \`artifact://${artifactId}\` for the complete report with all ${clones.length} duplicate clusters.*`;
788
+ }
789
+ report += "\n\n";
790
+ }
791
+
792
+ if (artifactId) {
793
+ report += `[raw output: artifact://${artifactId}]\n`;
794
+ }
795
+
796
+ return report;
797
+ }