clone-alert 0.3.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/LICENSE +21 -0
- package/README.md +243 -0
- package/dist/angular.d.ts +23 -0
- package/dist/angular.js +296 -0
- package/dist/cli.d.ts +14 -0
- package/dist/cli.js +407 -0
- package/dist/core.d.ts +98 -0
- package/dist/core.js +442 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +153 -0
- package/dist/svelte.d.ts +4 -0
- package/dist/svelte.js +287 -0
- package/dist/tokenizers.d.ts +53 -0
- package/dist/tokenizers.js +392 -0
- package/dist/vue.d.ts +4 -0
- package/dist/vue.js +189 -0
- package/package.json +108 -0
- package/scripts/compare-pmd-cpd.mjs +565 -0
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { pathToFileURL } from 'node:url';
|
|
8
|
+
|
|
9
|
+
const HELP = `Usage: npm run compare:pmd -- <path> [options]
|
|
10
|
+
|
|
11
|
+
Compare PMD CPD and clone-alert on the same source tree.
|
|
12
|
+
|
|
13
|
+
PMD CPD parses a single language per run, so all three tools are kept on the
|
|
14
|
+
same pure file set: extensions/formats default from --language, and any
|
|
15
|
+
non-parseable extensions (tsx, jsx, vue, ...) are dropped for a fair compare.
|
|
16
|
+
|
|
17
|
+
Options:
|
|
18
|
+
--minimum-tokens <n> Minimum duplicated token span. Default: 50.
|
|
19
|
+
--extensions <ext[,ext...]> Extensions for clone-alert scan. Default: derived
|
|
20
|
+
from --language (ts for typescript, js for
|
|
21
|
+
ecmascript). Non-parseable extensions are dropped.
|
|
22
|
+
--language <name> PMD CPD language. Default: typescript.
|
|
23
|
+
--jscpd-formats <names> jscpd formats. Default: derived from --language.
|
|
24
|
+
--repo-name <name> Repository label; stores reports under bench/results/<name>/<timestamp>.
|
|
25
|
+
--bench-dir <path> Benchmark root used with --repo-name. Default: bench.
|
|
26
|
+
--out-dir <path> Directory for reports. Default: OS temp dir, or bench/results/<repo>/<timestamp>.
|
|
27
|
+
--pmd <command> PMD executable. Default: pmd.
|
|
28
|
+
--jscpd <command> jscpd v5/Rust executable. Default: jscpd from PATH.
|
|
29
|
+
-h, --help Show this help.
|
|
30
|
+
`;
|
|
31
|
+
|
|
32
|
+
function main(argv) {
|
|
33
|
+
const options = parseArgs(argv);
|
|
34
|
+
if (options.help) {
|
|
35
|
+
process.stdout.write(HELP);
|
|
36
|
+
return 0;
|
|
37
|
+
}
|
|
38
|
+
if (!options.inputPath) {
|
|
39
|
+
process.stderr.write('compare-pmd-cpd: missing input path\n');
|
|
40
|
+
process.stderr.write(HELP);
|
|
41
|
+
return 2;
|
|
42
|
+
}
|
|
43
|
+
if (!existsSync(options.inputPath)) {
|
|
44
|
+
process.stderr.write(`compare-pmd-cpd: path does not exist: ${options.inputPath}\n`);
|
|
45
|
+
return 2;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
mkdirSync(options.outDir, { recursive: true });
|
|
49
|
+
const pmdReport = path.join(options.outDir, 'pmd-cpd.xml');
|
|
50
|
+
const cloneReport = path.join(options.outDir, 'clone-alert.xml');
|
|
51
|
+
const jscpdOutDir = path.join(options.outDir, 'jscpd');
|
|
52
|
+
mkdirSync(jscpdOutDir, { recursive: true });
|
|
53
|
+
|
|
54
|
+
const ignoreFlags = [];
|
|
55
|
+
if (options.ignoreIdentifiers) ignoreFlags.push('--ignore-identifiers');
|
|
56
|
+
if (options.ignoreLiterals) ignoreFlags.push('--ignore-literals');
|
|
57
|
+
|
|
58
|
+
const performance = {};
|
|
59
|
+
performance.pmd = runMeasured(
|
|
60
|
+
'pmd',
|
|
61
|
+
options.pmd,
|
|
62
|
+
[
|
|
63
|
+
'cpd',
|
|
64
|
+
'--language',
|
|
65
|
+
options.language,
|
|
66
|
+
'--minimum-tokens',
|
|
67
|
+
String(options.minimumTokens),
|
|
68
|
+
'--dir',
|
|
69
|
+
options.inputPath,
|
|
70
|
+
'--exclude',
|
|
71
|
+
'**/node_modules/**',
|
|
72
|
+
'--exclude',
|
|
73
|
+
'**/dist/**',
|
|
74
|
+
'--exclude',
|
|
75
|
+
'**/.git/**',
|
|
76
|
+
...ignoreFlags,
|
|
77
|
+
'--format',
|
|
78
|
+
'xml',
|
|
79
|
+
'--report-file',
|
|
80
|
+
pmdReport,
|
|
81
|
+
'--no-fail-on-violation',
|
|
82
|
+
'--no-fail-on-error',
|
|
83
|
+
]
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
performance.cloneAlert = runMeasured(
|
|
87
|
+
'clone-alert',
|
|
88
|
+
process.execPath,
|
|
89
|
+
[
|
|
90
|
+
path.join(process.cwd(), 'dist', 'cli.js'),
|
|
91
|
+
'--minimum-tokens',
|
|
92
|
+
String(options.minimumTokens),
|
|
93
|
+
'--files',
|
|
94
|
+
options.inputPath,
|
|
95
|
+
'--extensions',
|
|
96
|
+
options.extensions,
|
|
97
|
+
'--exclude',
|
|
98
|
+
'**/node_modules/**',
|
|
99
|
+
'--exclude',
|
|
100
|
+
'**/dist/**',
|
|
101
|
+
'--exclude',
|
|
102
|
+
'**/.git/**',
|
|
103
|
+
...ignoreFlags,
|
|
104
|
+
'--format',
|
|
105
|
+
'xml',
|
|
106
|
+
],
|
|
107
|
+
{ stdoutFile: cloneReport }
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
performance.jscpd = runMeasured(
|
|
111
|
+
'jscpd',
|
|
112
|
+
options.jscpd,
|
|
113
|
+
[
|
|
114
|
+
'--min-tokens',
|
|
115
|
+
String(options.minimumTokens),
|
|
116
|
+
'--min-lines',
|
|
117
|
+
'1',
|
|
118
|
+
'--max-lines',
|
|
119
|
+
'1000000',
|
|
120
|
+
'--max-size',
|
|
121
|
+
'100mb',
|
|
122
|
+
'--format',
|
|
123
|
+
options.jscpdFormats,
|
|
124
|
+
'--reporters',
|
|
125
|
+
'json',
|
|
126
|
+
'--output',
|
|
127
|
+
jscpdOutDir,
|
|
128
|
+
'--ignore',
|
|
129
|
+
'**/node_modules/**,**/dist/**,**/.git/**',
|
|
130
|
+
'--absolute',
|
|
131
|
+
'--silent',
|
|
132
|
+
options.inputPath,
|
|
133
|
+
]
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
const pmd = parseReport(pmdReport);
|
|
137
|
+
const clone = parseReport(cloneReport);
|
|
138
|
+
const jscpdReport = findJscpdReport(jscpdOutDir);
|
|
139
|
+
const jscpd = parseJscpdReport(jscpdReport);
|
|
140
|
+
const summary = compareReports(pmd, clone, jscpd);
|
|
141
|
+
const output = {
|
|
142
|
+
repoName: options.repoName || null,
|
|
143
|
+
inputPath: options.inputPath,
|
|
144
|
+
reports: { pmd: pmdReport, cloneAlert: cloneReport, jscpd: jscpdReport },
|
|
145
|
+
performance,
|
|
146
|
+
...summary,
|
|
147
|
+
};
|
|
148
|
+
const summaryReport = path.join(options.outDir, 'summary.json');
|
|
149
|
+
writeFileSync(summaryReport, `${JSON.stringify({ ...output, reports: { ...output.reports, summary: summaryReport } }, null, 2)}\n`);
|
|
150
|
+
|
|
151
|
+
process.stdout.write(`${JSON.stringify({ ...output, reports: { ...output.reports, summary: summaryReport } }, null, 2)}\n`);
|
|
152
|
+
return 0;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// PMD CPD runs one --language per pass. Each profile pins the matching pure
|
|
156
|
+
// file extension for clone-alert and the jscpd format so all three tools scan
|
|
157
|
+
// an identical corpus. Framework variants (.tsx/.jsx/...) are out of scope:
|
|
158
|
+
// PMD's typescript/ecmascript lexers cannot parse them.
|
|
159
|
+
const LANGUAGE_PROFILES = {
|
|
160
|
+
typescript: { extensions: 'ts', jscpdFormats: 'typescript', pure: ['ts'] },
|
|
161
|
+
ecmascript: { extensions: 'js', jscpdFormats: 'javascript', pure: ['js'] },
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
function parseArgs(argv) {
|
|
165
|
+
const options = {
|
|
166
|
+
inputPath: '',
|
|
167
|
+
minimumTokens: 50,
|
|
168
|
+
extensions: '',
|
|
169
|
+
language: 'typescript',
|
|
170
|
+
jscpdFormats: '',
|
|
171
|
+
repoName: '',
|
|
172
|
+
benchDir: path.resolve('bench'),
|
|
173
|
+
outDir: '',
|
|
174
|
+
pmd: 'pmd',
|
|
175
|
+
jscpd: 'jscpd',
|
|
176
|
+
ignoreIdentifiers: false,
|
|
177
|
+
ignoreLiterals: false,
|
|
178
|
+
help: false,
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
for (let index = 0; index < argv.length; index++) {
|
|
182
|
+
const arg = argv[index];
|
|
183
|
+
if (arg === '-h' || arg === '--help') {
|
|
184
|
+
options.help = true;
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (arg === '--minimum-tokens') {
|
|
188
|
+
options.minimumTokens = parsePositiveInteger(requireValue(argv, ++index, arg), arg);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (arg === '--extensions') {
|
|
192
|
+
options.extensions = requireValue(argv, ++index, arg);
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (arg === '--language') {
|
|
196
|
+
options.language = requireValue(argv, ++index, arg);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (arg === '--jscpd-formats') {
|
|
200
|
+
options.jscpdFormats = requireValue(argv, ++index, arg);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (arg === '--repo-name') {
|
|
204
|
+
options.repoName = sanitizeRepoName(requireValue(argv, ++index, arg));
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (arg === '--bench-dir') {
|
|
208
|
+
options.benchDir = path.resolve(requireValue(argv, ++index, arg));
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (arg === '--out-dir') {
|
|
212
|
+
options.outDir = path.resolve(requireValue(argv, ++index, arg));
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (arg === '--pmd') {
|
|
216
|
+
options.pmd = requireValue(argv, ++index, arg);
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (arg === '--jscpd') {
|
|
220
|
+
options.jscpd = requireValue(argv, ++index, arg);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (arg === '--ignore-identifiers') {
|
|
224
|
+
options.ignoreIdentifiers = true;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (arg === '--ignore-literals') {
|
|
228
|
+
options.ignoreLiterals = true;
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
if (arg.startsWith('-')) {
|
|
232
|
+
throw new Error(`unknown option: ${arg}`);
|
|
233
|
+
}
|
|
234
|
+
options.inputPath = path.resolve(arg);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const profile = LANGUAGE_PROFILES[options.language];
|
|
238
|
+
if (!options.extensions) {
|
|
239
|
+
options.extensions = profile ? profile.extensions : 'ts';
|
|
240
|
+
}
|
|
241
|
+
if (!options.jscpdFormats) {
|
|
242
|
+
options.jscpdFormats = profile ? profile.jscpdFormats : 'typescript';
|
|
243
|
+
}
|
|
244
|
+
if (profile) {
|
|
245
|
+
options.extensions = keepPureExtensions(options.extensions, profile.pure, options.language);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (!options.outDir) {
|
|
249
|
+
options.outDir = options.repoName
|
|
250
|
+
? path.join(options.benchDir, 'results', options.repoName, timestampForPath())
|
|
251
|
+
: path.join(tmpdir(), `clone-alert-pmd-compare-${process.pid}`);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return options;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Keep only the extensions PMD's chosen language can actually lex, so PMD,
|
|
258
|
+
// clone-alert and jscpd compare the exact same files. Dropped extensions are
|
|
259
|
+
// reported to stderr; falls back to the language's pure set if nothing is left.
|
|
260
|
+
function keepPureExtensions(extensions, pure, language) {
|
|
261
|
+
const wanted = extensions
|
|
262
|
+
.split(',')
|
|
263
|
+
.map((ext) => ext.trim().replace(/^\./, ''))
|
|
264
|
+
.filter(Boolean);
|
|
265
|
+
const kept = wanted.filter((ext) => pure.includes(ext));
|
|
266
|
+
const dropped = wanted.filter((ext) => !pure.includes(ext));
|
|
267
|
+
if (dropped.length > 0) {
|
|
268
|
+
const droppedList = dropped.map((ext) => `.${ext}`).join(', ');
|
|
269
|
+
const pureList = pure.map((ext) => `.${ext}`).join(', ');
|
|
270
|
+
process.stderr.write(
|
|
271
|
+
`compare-pmd-cpd: PMD --language ${language} cannot parse ${droppedList}; comparing pure ${pureList} only\n`
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
return (kept.length > 0 ? kept : pure).join(',');
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function sanitizeRepoName(value) {
|
|
278
|
+
return value.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'repo';
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function timestampForPath() {
|
|
282
|
+
return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z').replace(/[:]/g, '').replace('T', '-');
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function requireValue(argv, index, option) {
|
|
286
|
+
const value = argv[index];
|
|
287
|
+
if (!value || value.startsWith('-')) {
|
|
288
|
+
throw new Error(`${option} requires a value`);
|
|
289
|
+
}
|
|
290
|
+
return value;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function parsePositiveInteger(value, option) {
|
|
294
|
+
const parsed = Number(value);
|
|
295
|
+
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
296
|
+
throw new Error(`${option} must be a positive integer`);
|
|
297
|
+
}
|
|
298
|
+
return parsed;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function parseReport(filePath) {
|
|
302
|
+
const xml = readFileSync(filePath, 'utf8');
|
|
303
|
+
const duplicates = [...xml.matchAll(/<duplication\b([^>]*)>([\s\S]*?)<\/duplication>/g)].map((match) => {
|
|
304
|
+
const attrs = readAttributes(match[1]);
|
|
305
|
+
const files = [...match[2].matchAll(/<file\b([\s\S]*?)\/>/g)].map((file) => {
|
|
306
|
+
const fileAttrs = readAttributes(file[1]);
|
|
307
|
+
return {
|
|
308
|
+
path: normalizeReportPath(decodeXml(fileAttrs.path ?? '')),
|
|
309
|
+
line: Number(fileAttrs.line ?? 0),
|
|
310
|
+
};
|
|
311
|
+
});
|
|
312
|
+
return {
|
|
313
|
+
lines: Number(attrs.lines ?? 0),
|
|
314
|
+
tokens: Number(attrs.tokens ?? 0),
|
|
315
|
+
files,
|
|
316
|
+
};
|
|
317
|
+
});
|
|
318
|
+
return { duplicates };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function findJscpdReport(outDir) {
|
|
322
|
+
const report = path.join(outDir, 'jscpd-report.json');
|
|
323
|
+
if (!existsSync(report)) {
|
|
324
|
+
throw new Error(`jscpd report was not created: ${report}`);
|
|
325
|
+
}
|
|
326
|
+
return report;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function parseJscpdReport(filePath) {
|
|
330
|
+
const data = JSON.parse(readFileSync(filePath, 'utf8'));
|
|
331
|
+
const duplicates = (Array.isArray(data.duplicates) ? data.duplicates : []).map((duplicate) => ({
|
|
332
|
+
lines: Number(duplicate.lines ?? 0),
|
|
333
|
+
tokens: Number(duplicate.tokens ?? 0),
|
|
334
|
+
files: [duplicate.firstFile, duplicate.secondFile]
|
|
335
|
+
.filter(Boolean)
|
|
336
|
+
.map((file) => ({
|
|
337
|
+
path: normalizeReportPath(file.name ?? ''),
|
|
338
|
+
line: Number(file.startLoc?.line ?? file.start ?? 0),
|
|
339
|
+
})),
|
|
340
|
+
}));
|
|
341
|
+
return { duplicates, statistics: data.statistics ?? data.statistic ?? null };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function normalizeReportPath(filePath) {
|
|
345
|
+
if (!filePath) return '';
|
|
346
|
+
try {
|
|
347
|
+
return realpathSync.native(filePath);
|
|
348
|
+
} catch {
|
|
349
|
+
return filePath;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function readAttributes(source) {
|
|
354
|
+
return Object.fromEntries([...source.matchAll(/(\w+)="([^"]*)"/g)].map((match) => [match[1], match[2]]));
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function decodeXml(value) {
|
|
358
|
+
return value.replace(/"/g, '"').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export function compareReports(pmd, clone, jscpd) {
|
|
362
|
+
const pmdExact = new Set(pmd.duplicates.map(exactStartKey));
|
|
363
|
+
const cloneExact = new Set(clone.duplicates.map(exactStartKey));
|
|
364
|
+
const pmdFileSets = new Set(pmd.duplicates.map(fileSetKey));
|
|
365
|
+
const cloneFileSets = new Set(clone.duplicates.map(fileSetKey));
|
|
366
|
+
|
|
367
|
+
return {
|
|
368
|
+
pmd: summarize(pmd.duplicates),
|
|
369
|
+
cloneAlert: summarize(clone.duplicates),
|
|
370
|
+
jscpd: summarizeJscpd(jscpd),
|
|
371
|
+
exactStartOverlap: countOverlap(pmdExact, cloneExact),
|
|
372
|
+
fileSetOverlap: countOverlap(pmdFileSets, cloneFileSets),
|
|
373
|
+
pairOverlap: {
|
|
374
|
+
cloneAlert: comparePairOverlap(pmd.duplicates, clone.duplicates),
|
|
375
|
+
jscpd: comparePairOverlap(pmd.duplicates, jscpd.duplicates),
|
|
376
|
+
},
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function summarize(duplicates) {
|
|
381
|
+
return {
|
|
382
|
+
duplications: duplicates.length,
|
|
383
|
+
occurrences: duplicates.reduce((sum, duplicate) => sum + duplicate.files.length, 0),
|
|
384
|
+
uniqueFiles: new Set(duplicates.flatMap((duplicate) => duplicate.files.map((file) => file.path))).size,
|
|
385
|
+
maxTokens: Math.max(0, ...duplicates.map((duplicate) => duplicate.tokens)),
|
|
386
|
+
maxLines: Math.max(0, ...duplicates.map((duplicate) => duplicate.lines)),
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function summarizeJscpd(report) {
|
|
391
|
+
const total = report.statistics?.total ?? {};
|
|
392
|
+
return {
|
|
393
|
+
...summarize(report.duplicates),
|
|
394
|
+
totalLines: Number(total.lines ?? 0),
|
|
395
|
+
totalTokens: Number(total.tokens ?? 0),
|
|
396
|
+
duplicatedLines: Number(total.duplicatedLines ?? 0),
|
|
397
|
+
duplicatedTokens: Number(total.duplicatedTokens ?? 0),
|
|
398
|
+
percentage: Number(total.percentage ?? 0),
|
|
399
|
+
percentageTokens: Number(total.percentageTokens ?? 0),
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function exactStartKey(duplicate) {
|
|
404
|
+
return duplicate.files.map((file) => `${file.path}:${file.line}`).sort().join('|');
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function fileSetKey(duplicate) {
|
|
408
|
+
return duplicate.files.map((file) => file.path).sort().join('|');
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function countOverlap(left, right) {
|
|
412
|
+
let count = 0;
|
|
413
|
+
for (const key of left) {
|
|
414
|
+
if (right.has(key)) count++;
|
|
415
|
+
}
|
|
416
|
+
return count;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function comparePairOverlap(pmdDuplicates, candidateDuplicates) {
|
|
420
|
+
const candidateExactPairs = new Set();
|
|
421
|
+
const candidateFilePairs = new Set();
|
|
422
|
+
forEachPair(candidateDuplicates, (left, right) => {
|
|
423
|
+
candidateExactPairs.add(pairKey(left, right, exactOccurrenceKey));
|
|
424
|
+
candidateFilePairs.add(pairKey(left, right, filePathKey));
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
let pmdPairs = 0;
|
|
428
|
+
const exactPairOverlap = new Set();
|
|
429
|
+
const filePairOverlap = new Set();
|
|
430
|
+
forEachPair(pmdDuplicates, (left, right) => {
|
|
431
|
+
pmdPairs++;
|
|
432
|
+
const exact = pairKey(left, right, exactOccurrenceKey);
|
|
433
|
+
if (candidateExactPairs.has(exact)) {
|
|
434
|
+
exactPairOverlap.add(exact);
|
|
435
|
+
}
|
|
436
|
+
const files = pairKey(left, right, filePathKey);
|
|
437
|
+
if (candidateFilePairs.has(files)) {
|
|
438
|
+
filePairOverlap.add(files);
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
return {
|
|
443
|
+
pmdExactPairs: pmdPairs,
|
|
444
|
+
candidateExactPairs: candidateExactPairs.size,
|
|
445
|
+
exactPairOverlap: exactPairOverlap.size,
|
|
446
|
+
pmdFilePairs: pmdPairs,
|
|
447
|
+
candidateFilePairs: candidateFilePairs.size,
|
|
448
|
+
filePairOverlap: filePairOverlap.size,
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function forEachPair(duplicates, visit) {
|
|
453
|
+
for (const duplicate of duplicates) {
|
|
454
|
+
for (let left = 0; left < duplicate.files.length; left++) {
|
|
455
|
+
for (let right = left + 1; right < duplicate.files.length; right++) {
|
|
456
|
+
visit(duplicate.files[left], duplicate.files[right]);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function pairKey(left, right, fileKey) {
|
|
463
|
+
return [fileKey(left), fileKey(right)].sort().join('|');
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function exactOccurrenceKey(file) {
|
|
467
|
+
return `${file.path}:${file.line}`;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function filePathKey(file) {
|
|
471
|
+
return file.path;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function runMeasured(label, command, args, options = {}) {
|
|
475
|
+
const time = timeCommand(command, args);
|
|
476
|
+
let result = spawnForMeasurement(time.command, time.args, options);
|
|
477
|
+
if (result.status !== 0 && isTimeResourceDenied(result.stderr) && (time.command !== command || time.args !== args)) {
|
|
478
|
+
result = spawnForMeasurement(command, args, options);
|
|
479
|
+
if (result.status === 0) {
|
|
480
|
+
return {
|
|
481
|
+
elapsedMs: Math.round(result.elapsedMs),
|
|
482
|
+
maxRssBytes: null,
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
if (result.error) {
|
|
488
|
+
throw new Error(`${label} failed to start: ${result.error.message}`);
|
|
489
|
+
}
|
|
490
|
+
if (result.status !== 0) {
|
|
491
|
+
throw new Error(`${label} exited with ${result.status}${stderrTail(result.stderr)}`);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
return {
|
|
495
|
+
elapsedMs: Math.round(result.elapsedMs),
|
|
496
|
+
maxRssBytes: parseMaxRssBytes(result.stderr),
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function spawnForMeasurement(command, args, options) {
|
|
501
|
+
const stdoutFd = options.stdoutFile ? openSync(options.stdoutFile, 'w') : undefined;
|
|
502
|
+
const started = process.hrtime.bigint();
|
|
503
|
+
let result;
|
|
504
|
+
try {
|
|
505
|
+
result = spawnSync(command, args, {
|
|
506
|
+
encoding: 'utf8',
|
|
507
|
+
stdio: ['ignore', stdoutFd ?? 'pipe', 'pipe'],
|
|
508
|
+
});
|
|
509
|
+
} finally {
|
|
510
|
+
if (stdoutFd !== undefined) {
|
|
511
|
+
closeSync(stdoutFd);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
const elapsedMs = Number(process.hrtime.bigint() - started) / 1_000_000;
|
|
515
|
+
|
|
516
|
+
return {
|
|
517
|
+
...result,
|
|
518
|
+
elapsedMs,
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function timeCommand(command, args) {
|
|
523
|
+
if (process.platform === 'darwin' && existsSync('/usr/bin/time')) {
|
|
524
|
+
return { command: '/usr/bin/time', args: ['-l', command, ...args] };
|
|
525
|
+
}
|
|
526
|
+
if (existsSync('/usr/bin/time')) {
|
|
527
|
+
return { command: '/usr/bin/time', args: ['-v', command, ...args] };
|
|
528
|
+
}
|
|
529
|
+
return { command, args };
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function parseMaxRssBytes(stderr) {
|
|
533
|
+
const mac = stderr.match(/^\s*(\d+)\s+maximum resident set size/m);
|
|
534
|
+
if (mac) {
|
|
535
|
+
return Number(mac[1]);
|
|
536
|
+
}
|
|
537
|
+
const gnu = stderr.match(/Maximum resident set size \(kbytes\):\s*(\d+)/);
|
|
538
|
+
if (gnu) {
|
|
539
|
+
return Number(gnu[1]) * 1024;
|
|
540
|
+
}
|
|
541
|
+
return null;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function isTimeResourceDenied(stderr) {
|
|
545
|
+
return stderr.includes('sysctl kern.clockrate: Operation not permitted');
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function stderrTail(stderr) {
|
|
549
|
+
const lines = stderr.trim().split(/\r?\n/).filter(Boolean);
|
|
550
|
+
return lines.length ? `: ${lines.slice(-8).join('\n')}` : '';
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (isCliEntrypoint()) {
|
|
554
|
+
try {
|
|
555
|
+
const status = await main(process.argv.slice(2));
|
|
556
|
+
process.exitCode = status;
|
|
557
|
+
} catch (error) {
|
|
558
|
+
process.stderr.write(`compare-pmd-cpd: ${error.message}\n`);
|
|
559
|
+
process.exitCode = 2;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function isCliEntrypoint() {
|
|
564
|
+
return process.argv[1] ? import.meta.url === pathToFileURL(process.argv[1]).href : false;
|
|
565
|
+
}
|