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.
package/package.json ADDED
@@ -0,0 +1,105 @@
1
+ {
2
+ "name": "omp-plugin-duplicate-detector",
3
+ "version": "0.1.0",
4
+ "description": "Code clone and duplication detector plugin for oh-my-pi powered by jscpd",
5
+ "type": "module",
6
+ "files": [
7
+ "src",
8
+ "dist",
9
+ "types",
10
+ "README.md"
11
+ ],
12
+ "omp": {
13
+ "name": "duplicate-detector",
14
+ "description": "Detect duplicate and clone code blocks across your codebase powered by jscpd",
15
+ "extensions": [
16
+ "./src/index.ts"
17
+ ],
18
+ "settings": {
19
+ "minLines": {
20
+ "type": "number",
21
+ "description": "Minimum consecutive lines required to report a duplicate block",
22
+ "default": 5
23
+ },
24
+ "minTokens": {
25
+ "type": "number",
26
+ "description": "Minimum token count threshold for code clone detection",
27
+ "default": 40
28
+ },
29
+ "checkOnMutation": {
30
+ "type": "boolean",
31
+ "description": "Automatically check newly written/edited code for duplicates",
32
+ "default": true
33
+ },
34
+ "reminderMode": {
35
+ "type": "enum",
36
+ "enum": [
37
+ "steer",
38
+ "in-band",
39
+ "none"
40
+ ],
41
+ "description": "Feedback style: steer (live TTSR warning card & steer message), in-band (<system-reminder> on tool_result), or none",
42
+ "default": "steer"
43
+ },
44
+ "ignorePatterns": {
45
+ "type": "string",
46
+ "description": "Additional comma-separated glob patterns to ignore during duplicate detection",
47
+ "default": ""
48
+ },
49
+ "ignoreTests": {
50
+ "type": "boolean",
51
+ "description": "Automatically ignore test files, test directories, mocks, and fixtures across all programming languages",
52
+ "default": true
53
+ },
54
+ "maxIndexedFiles": {
55
+ "type": "number",
56
+ "description": "Maximum number of source code files to index during baseline initialization",
57
+ "default": 1e4
58
+ }
59
+ }
60
+ },
61
+ "scripts": {
62
+ "build:worker": "bun build ./src/detector-worker.ts --target=bun --outfile=./dist/detector-worker.js",
63
+ "test": "bun test ./test",
64
+ "typecheck": "tsc --noEmit -p tsconfig.json",
65
+ "format": "biome format --write .",
66
+ "lint": "biome lint .",
67
+ "knip": "knip",
68
+ "check": "biome check --write . && knip",
69
+ "prepare": "bun run build:worker"
70
+ },
71
+ "keywords": [
72
+ "oh-my-pi",
73
+ "omp",
74
+ "plugin",
75
+ "omp-plugin",
76
+ "omp-extension",
77
+ "duplicate-detector",
78
+ "jscpd",
79
+ "code-clone",
80
+ "refactoring"
81
+ ],
82
+ "license": "MIT",
83
+ "dependencies": {
84
+ "@jscpd/core": "^4.2.5",
85
+ "@jscpd/tokenizer": "^4.2.6",
86
+ "eventemitter3": "^5.0.4",
87
+ "ignore": "^7.0.6",
88
+ "json5": "^2.2.3",
89
+ "yaml": "^2.9.0"
90
+ },
91
+ "devDependencies": {
92
+ "@biomejs/biome": "latest",
93
+ "@oh-my-pi/pi-coding-agent": "latest",
94
+ "bun-types": "latest",
95
+ "husky": "^9.1.7",
96
+ "knip": "^6.32.2",
97
+ "lint-staged": "^17.3.0",
98
+ "typescript": "^7"
99
+ },
100
+ "lint-staged": {
101
+ "*.{ts,js,json,jsonc,md}": [
102
+ "biome check --write --no-errors-on-unmatched"
103
+ ]
104
+ }
105
+ }
@@ -0,0 +1,336 @@
1
+ import * as path from "node:path";
2
+ import JSON5 from "json5";
3
+ import YAML from "yaml";
4
+
5
+ export interface JscpdProjectConfig {
6
+ sourcePath?: string;
7
+ sourceType?: "file" | "package.json";
8
+ minLines?: number;
9
+ maxLines?: number;
10
+ minTokens?: number;
11
+ threshold?: number;
12
+ ignore?: string[];
13
+ ignoreTests?: boolean;
14
+ customTestPatterns?: string[];
15
+ excludeTestPatterns?: string[];
16
+ formatsExts?: Record<string, string[]>;
17
+ format?: string[];
18
+ mode?: string;
19
+ crossFormats?: boolean;
20
+ gitignore?: boolean;
21
+ maxIndexedFiles?: number;
22
+ raw?: Record<string, unknown>;
23
+ }
24
+
25
+ /** Standard configuration file candidates in priority order */
26
+ const JSCPD_CONFIG_CANDIDATES = [
27
+ ".jscpd.json",
28
+ ".jscpd.rc.json",
29
+ ".jscpd.rc",
30
+ ".jscpd.rc.yaml",
31
+ ".jscpd.rc.yml",
32
+ ".jscpd.yaml",
33
+ ".jscpd.yml",
34
+ path.join(".config", ".jscpd.json"),
35
+ path.join(".config", "jscpd.json"),
36
+ "package.json",
37
+ ];
38
+
39
+ /**
40
+ * Parses JSON5 (supporting comments, trailing commas, unquoted keys).
41
+ */
42
+ export function parseJsonConfig(content: string): unknown {
43
+ const trimmed = content.trim();
44
+ if (!trimmed) return null;
45
+ return JSON5.parse(trimmed);
46
+ }
47
+
48
+ /**
49
+ * Parses YAML using the standard yaml library.
50
+ */
51
+ export function parseYamlConfig(content: string): unknown {
52
+ const trimmed = content.trim();
53
+ if (!trimmed) return null;
54
+ return YAML.parse(trimmed);
55
+ }
56
+
57
+ /**
58
+ * Normalizes raw configuration dictionary (camelCase, kebab-case, or alternative jscpd keys)
59
+ * into a typed JscpdProjectConfig object.
60
+ */
61
+ export function normalizeJscpdConfig(
62
+ raw: Record<string, unknown>,
63
+ sourcePath?: string,
64
+ sourceType: "file" | "package.json" = "file",
65
+ ): JscpdProjectConfig {
66
+ const config: JscpdProjectConfig = {
67
+ sourcePath,
68
+ sourceType,
69
+ raw,
70
+ };
71
+
72
+ // minLines / min-lines / min_lines
73
+ const rawMinLines = raw.minLines ?? raw["min-lines"] ?? raw.min_lines;
74
+ if (typeof rawMinLines === "number" && !Number.isNaN(rawMinLines)) {
75
+ config.minLines = Math.max(1, Math.floor(rawMinLines));
76
+ } else if (typeof rawMinLines === "string") {
77
+ const parsed = Number.parseInt(rawMinLines, 10);
78
+ if (!Number.isNaN(parsed)) config.minLines = Math.max(1, parsed);
79
+ }
80
+
81
+ // maxLines / max-lines / max_lines
82
+ const rawMaxLines = raw.maxLines ?? raw["max-lines"] ?? raw.max_lines;
83
+ if (typeof rawMaxLines === "number" && !Number.isNaN(rawMaxLines)) {
84
+ config.maxLines = Math.max(1, Math.floor(rawMaxLines));
85
+ } else if (typeof rawMaxLines === "string") {
86
+ const parsed = Number.parseInt(rawMaxLines, 10);
87
+ if (!Number.isNaN(parsed)) config.maxLines = Math.max(1, parsed);
88
+ }
89
+
90
+ // minTokens / min-tokens / min_tokens / tokens
91
+ const rawMinTokens =
92
+ raw.minTokens ?? raw["min-tokens"] ?? raw.min_tokens ?? raw.tokens;
93
+ if (typeof rawMinTokens === "number" && !Number.isNaN(rawMinTokens)) {
94
+ config.minTokens = Math.max(1, Math.floor(rawMinTokens));
95
+ } else if (typeof rawMinTokens === "string") {
96
+ const parsed = Number.parseInt(rawMinTokens, 10);
97
+ if (!Number.isNaN(parsed)) config.minTokens = Math.max(1, parsed);
98
+ }
99
+
100
+ // maxIndexedFiles / max-indexed-files / max_indexed_files
101
+ const rawMaxIndexedFiles =
102
+ raw.maxIndexedFiles ?? raw["max-indexed-files"] ?? raw.max_indexed_files;
103
+ if (
104
+ typeof rawMaxIndexedFiles === "number" &&
105
+ !Number.isNaN(rawMaxIndexedFiles)
106
+ ) {
107
+ config.maxIndexedFiles = Math.max(1, Math.floor(rawMaxIndexedFiles));
108
+ } else if (typeof rawMaxIndexedFiles === "string") {
109
+ const parsed = Number.parseInt(rawMaxIndexedFiles, 10);
110
+ if (!Number.isNaN(parsed)) config.maxIndexedFiles = Math.max(1, parsed);
111
+ }
112
+
113
+ // threshold
114
+ const rawThreshold = raw.threshold;
115
+ if (typeof rawThreshold === "number" && !Number.isNaN(rawThreshold)) {
116
+ config.threshold = rawThreshold;
117
+ } else if (typeof rawThreshold === "string") {
118
+ const parsed = Number.parseFloat(rawThreshold);
119
+ if (!Number.isNaN(parsed)) config.threshold = parsed;
120
+ }
121
+
122
+ // ignore / ignorePatterns / ignore-patterns / ignore-pattern
123
+ const rawIgnore =
124
+ raw.ignore ??
125
+ raw.ignorePatterns ??
126
+ raw["ignore-patterns"] ??
127
+ raw["ignore-pattern"];
128
+ if (Array.isArray(rawIgnore)) {
129
+ config.ignore = rawIgnore
130
+ .filter(
131
+ (item): item is string | number => item !== null && item !== undefined,
132
+ )
133
+ .map(String)
134
+ .map((s) => s.trim())
135
+ .filter((s) => s.length > 0 && s !== "null" && s !== "undefined");
136
+ } else if (typeof rawIgnore === "string") {
137
+ config.ignore = rawIgnore
138
+ .split(",")
139
+ .map((s) => s.trim())
140
+ .filter((s) => s.length > 0);
141
+ }
142
+
143
+ // formatsExts / formats-exts / formats_exts
144
+ const rawFormatsExts =
145
+ raw.formatsExts ?? raw["formats-exts"] ?? raw.formats_exts;
146
+ if (
147
+ rawFormatsExts &&
148
+ typeof rawFormatsExts === "object" &&
149
+ !Array.isArray(rawFormatsExts)
150
+ ) {
151
+ const formatted: Record<string, string[]> = {};
152
+ for (const [fmt, exts] of Object.entries(rawFormatsExts)) {
153
+ if (Array.isArray(exts)) {
154
+ formatted[fmt] = exts
155
+ .filter((e): e is string | number => e !== null && e !== undefined)
156
+ .map(String)
157
+ .map((e) => e.replace(/^\./, ""));
158
+ } else if (typeof exts === "string") {
159
+ formatted[fmt] = exts
160
+ .split(",")
161
+ .map((e) => e.trim().replace(/^\./, ""))
162
+ .filter(Boolean);
163
+ }
164
+ }
165
+ if (Object.keys(formatted).length > 0) {
166
+ config.formatsExts = formatted;
167
+ }
168
+ }
169
+
170
+ // format / formats
171
+ const rawFormat = raw.format ?? raw.formats;
172
+ if (Array.isArray(rawFormat)) {
173
+ config.format = rawFormat.filter(Boolean).map(String).filter(Boolean);
174
+ } else if (typeof rawFormat === "string") {
175
+ config.format = rawFormat
176
+ .split(",")
177
+ .map((s) => s.trim())
178
+ .filter(Boolean);
179
+ }
180
+
181
+ // mode
182
+ if (typeof raw.mode === "string") {
183
+ config.mode = raw.mode;
184
+ }
185
+
186
+ // crossFormats / cross-formats / cross_formats
187
+ const rawCross =
188
+ raw.crossFormats ?? raw["cross-formats"] ?? raw.cross_formats;
189
+ if (typeof rawCross === "boolean") {
190
+ config.crossFormats = rawCross;
191
+ }
192
+ // ignoreTests / ignore-tests / ignore_tests
193
+ const rawIgnoreTests =
194
+ raw.ignoreTests ?? raw["ignore-tests"] ?? raw.ignore_tests;
195
+ if (typeof rawIgnoreTests === "boolean") {
196
+ config.ignoreTests = rawIgnoreTests;
197
+ } else if (typeof rawIgnoreTests === "string") {
198
+ if (rawIgnoreTests.toLowerCase() === "false") config.ignoreTests = false;
199
+ else if (rawIgnoreTests.toLowerCase() === "true") config.ignoreTests = true;
200
+ }
201
+
202
+ // customTestPatterns / custom-test-patterns / testPatterns / test-patterns
203
+ const rawCustomTest =
204
+ raw.customTestPatterns ??
205
+ raw["custom-test-patterns"] ??
206
+ raw.custom_test_patterns ??
207
+ raw.testPatterns ??
208
+ raw["test-patterns"] ??
209
+ raw.test_patterns;
210
+ if (Array.isArray(rawCustomTest)) {
211
+ config.customTestPatterns = rawCustomTest
212
+ .filter(
213
+ (item): item is string | number => item !== null && item !== undefined,
214
+ )
215
+ .map(String)
216
+ .map((s) => s.trim())
217
+ .filter((s) => s.length > 0 && s !== "null" && s !== "undefined");
218
+ } else if (typeof rawCustomTest === "string") {
219
+ config.customTestPatterns = rawCustomTest
220
+ .split(",")
221
+ .map((s) => s.trim())
222
+ .filter((s) => s.length > 0);
223
+ }
224
+
225
+ // excludeTestPatterns / exclude-test-patterns / exclude_test_patterns
226
+ const rawExcludeTest =
227
+ raw.excludeTestPatterns ??
228
+ raw["exclude-test-patterns"] ??
229
+ raw.exclude_test_patterns;
230
+ if (Array.isArray(rawExcludeTest)) {
231
+ config.excludeTestPatterns = rawExcludeTest
232
+ .filter(
233
+ (item): item is string | number => item !== null && item !== undefined,
234
+ )
235
+ .map(String)
236
+ .map((s) => s.trim())
237
+ .filter((s) => s.length > 0 && s !== "null" && s !== "undefined");
238
+ } else if (typeof rawExcludeTest === "string") {
239
+ config.excludeTestPatterns = rawExcludeTest
240
+ .split(",")
241
+ .map((s) => s.trim())
242
+ .filter((s) => s.length > 0);
243
+ }
244
+
245
+ // gitignore
246
+ const rawGitignore = raw.gitignore;
247
+ if (typeof rawGitignore === "boolean") {
248
+ config.gitignore = rawGitignore;
249
+ }
250
+
251
+ return config;
252
+ }
253
+
254
+ /**
255
+ * Asynchronously locates and loads the jscpd configuration from a target project root.
256
+ * Searches candidate config files in standard precedence order.
257
+ * Returns normalized JscpdProjectConfig or null if no config found.
258
+ */
259
+ export async function findProjectJscpdConfig(
260
+ rootDir: string,
261
+ ): Promise<JscpdProjectConfig | null> {
262
+ for (const candidate of JSCPD_CONFIG_CANDIDATES) {
263
+ const fullPath = path.isAbsolute(candidate)
264
+ ? candidate
265
+ : path.join(rootDir, candidate);
266
+
267
+ try {
268
+ const file = Bun.file(fullPath);
269
+ if (!(await file.exists())) continue;
270
+
271
+ const content = await file.text();
272
+ const basename = path.basename(fullPath);
273
+
274
+ if (basename === "package.json") {
275
+ const pkg = JSON5.parse(content) as Record<string, unknown>;
276
+ if (
277
+ pkg &&
278
+ typeof pkg === "object" &&
279
+ !Array.isArray(pkg) &&
280
+ "jscpd" in pkg &&
281
+ pkg.jscpd &&
282
+ typeof pkg.jscpd === "object" &&
283
+ !Array.isArray(pkg.jscpd)
284
+ ) {
285
+ return normalizeJscpdConfig(
286
+ pkg.jscpd as Record<string, unknown>,
287
+ fullPath,
288
+ "package.json",
289
+ );
290
+ }
291
+ continue;
292
+ }
293
+
294
+ if (basename.endsWith(".yaml") || basename.endsWith(".yml")) {
295
+ const parsed = YAML.parse(content);
296
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
297
+ return normalizeJscpdConfig(
298
+ parsed as Record<string, unknown>,
299
+ fullPath,
300
+ "file",
301
+ );
302
+ }
303
+ continue;
304
+ }
305
+
306
+ // JSON or .jscpd.rc (try JSON5 first, fallback to YAML)
307
+ try {
308
+ const parsed = JSON5.parse(content);
309
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
310
+ return normalizeJscpdConfig(
311
+ parsed as Record<string, unknown>,
312
+ fullPath,
313
+ "file",
314
+ );
315
+ }
316
+ } catch {
317
+ const yamlParsed = YAML.parse(content);
318
+ if (
319
+ yamlParsed &&
320
+ typeof yamlParsed === "object" &&
321
+ !Array.isArray(yamlParsed)
322
+ ) {
323
+ return normalizeJscpdConfig(
324
+ yamlParsed as Record<string, unknown>,
325
+ fullPath,
326
+ "file",
327
+ );
328
+ }
329
+ }
330
+ } catch {
331
+ // Ignore corrupt or unreadable candidate, continue probing
332
+ }
333
+ }
334
+
335
+ return null;
336
+ }