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/README.md +117 -0
- package/dist/detector-worker.js +13897 -0
- package/package.json +105 -0
- package/src/config-loader.ts +336 -0
- package/src/coordinator.ts +536 -0
- package/src/detector-worker.ts +828 -0
- package/src/disk-cache.ts +703 -0
- package/src/duplicate-ledger.ts +144 -0
- package/src/index.ts +807 -0
- package/src/jscpd-engine.ts +797 -0
- package/src/project-state.ts +129 -0
- package/src/source-aware-index.ts +919 -0
- package/src/test-detector.ts +337 -0
- package/src/tui-notification.ts +464 -0
- package/src/worker-protocol.ts +330 -0
- package/types/global.d.ts +19 -0
- package/types/jscpd.d.ts +139 -0
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
|
+
}
|