pi-jscpd 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.
Files changed (49) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/CONTRIBUTING.md +144 -0
  3. package/LICENSE +21 -0
  4. package/README.md +231 -0
  5. package/SECURITY.md +93 -0
  6. package/docs/automatic-checkpoint.md +235 -0
  7. package/docs/compatibility.md +119 -0
  8. package/docs/effect-architecture.md +128 -0
  9. package/docs/fallow-coexistence.md +120 -0
  10. package/docs/overlay-interaction.md +347 -0
  11. package/docs/release.md +115 -0
  12. package/package.json +86 -0
  13. package/scripts/check-compatibility.mjs +103 -0
  14. package/skills/jscpd/SKILL.md +90 -0
  15. package/src/acknowledgements.ts +268 -0
  16. package/src/automatic.ts +396 -0
  17. package/src/baseline.ts +400 -0
  18. package/src/capability.ts +569 -0
  19. package/src/changed-files.ts +372 -0
  20. package/src/changed.ts +548 -0
  21. package/src/clone-identity.ts +373 -0
  22. package/src/config.ts +414 -0
  23. package/src/contract.ts +39 -0
  24. package/src/dispatch.ts +90 -0
  25. package/src/effect/clock.ts +10 -0
  26. package/src/effect/errors.ts +311 -0
  27. package/src/effect/filesystem.ts +240 -0
  28. package/src/effect/runtime-boundary.ts +25 -0
  29. package/src/effect/runtime-contract.ts +18 -0
  30. package/src/effect/services.ts +131 -0
  31. package/src/extension.ts +708 -0
  32. package/src/fallow.ts +479 -0
  33. package/src/finding-presentation.ts +73 -0
  34. package/src/index.ts +8 -0
  35. package/src/jscpd-report.ts +819 -0
  36. package/src/jscpd.ts +748 -0
  37. package/src/overlay.ts +1166 -0
  38. package/src/parser.ts +189 -0
  39. package/src/path-utils.ts +44 -0
  40. package/src/presentation.ts +232 -0
  41. package/src/process.ts +425 -0
  42. package/src/registry.ts +102 -0
  43. package/src/scan.ts +441 -0
  44. package/src/scheduler.ts +434 -0
  45. package/src/session-state.ts +229 -0
  46. package/src/status.ts +534 -0
  47. package/src/types.ts +334 -0
  48. package/src/value-utils.ts +14 -0
  49. package/src/verification.ts +220 -0
@@ -0,0 +1,819 @@
1
+ import { isAbsolute, relative, resolve, sep } from "node:path";
2
+ import { Data, Effect } from "effect";
3
+ import { JscpdFileSystem } from "./effect/services.js";
4
+ import {
5
+ canonicalDirectoryEffect,
6
+ compareText,
7
+ hasControlCharacters,
8
+ isPathInside,
9
+ } from "./path-utils.js";
10
+ import type {
11
+ JscpdCloneOccurrence,
12
+ JscpdClonePair,
13
+ JscpdFormatStatistics,
14
+ JscpdReportDecision,
15
+ JscpdReportErrorCode,
16
+ JscpdScanReport,
17
+ JscpdScanStatistics,
18
+ JscpdSourceLocation,
19
+ JscpdStatisticsRow,
20
+ } from "./types.js";
21
+
22
+ /** Fixed by jscpd v5's JSON reporter; CLI argument construction belongs to issue #12. */
23
+ export const JSCPD_STRUCTURED_REPORTER = "json";
24
+ export const JSCPD_STRUCTURED_REPORT_FILE_NAME = "jscpd-report.json";
25
+
26
+ const MAX_REPORT_BYTES = 16 * 1_024 * 1_024;
27
+ const MAX_CLONE_PAIRS = 1_000;
28
+ const MAX_FORMATS = 256;
29
+ const MAX_PATH_BYTES = 4_096;
30
+ const MAX_FORMAT_BYTES = 128;
31
+ const MAX_DATE_BYTES = 128;
32
+ const MAX_U32 = 0xffff_ffff;
33
+ const MAX_JSON_DEPTH = 64;
34
+ const MAX_JSON_KEYS = 100_000;
35
+ const MAX_JSON_KEYS_PER_OBJECT = 2_048;
36
+ const PATH_RESOLUTION_CONCURRENCY = 16;
37
+
38
+ const REPORT_ERROR_CODES = new Set<JscpdReportErrorCode>([
39
+ "malformed-json",
40
+ "unsupported-reporter",
41
+ "invalid-top-level",
42
+ "invalid-duplicates",
43
+ "invalid-statistics",
44
+ "invalid-location",
45
+ "unsafe-path",
46
+ "limit-exceeded",
47
+ "duplicate-key",
48
+ "ambiguous-path",
49
+ "ambiguous-duplicate",
50
+ ]);
51
+
52
+ interface ReporterPathCandidates {
53
+ readonly key: string;
54
+ readonly exact: string;
55
+ readonly embeddedBase?: string;
56
+ }
57
+
58
+ interface UnresolvedOccurrence {
59
+ readonly reporterPath: string;
60
+ readonly start: JscpdSourceLocation;
61
+ readonly end: JscpdSourceLocation;
62
+ }
63
+
64
+ interface UnresolvedClonePair {
65
+ readonly format: string;
66
+ readonly lines: number;
67
+ readonly tokens: number;
68
+ readonly occurrences: readonly [UnresolvedOccurrence, UnresolvedOccurrence];
69
+ }
70
+
71
+ interface PreparedClonePair extends Omit<UnresolvedClonePair, "occurrences"> {
72
+ readonly occurrences: readonly [
73
+ UnresolvedOccurrence & { readonly candidatePath: ReporterPathCandidates },
74
+ UnresolvedOccurrence & { readonly candidatePath: ReporterPathCandidates },
75
+ ];
76
+ }
77
+
78
+ interface JsonObjectContainer {
79
+ readonly kind: "object";
80
+ readonly keys: Set<string>;
81
+ }
82
+
83
+ interface JsonArrayContainer {
84
+ readonly kind: "array";
85
+ }
86
+
87
+ type JsonContainer = JsonObjectContainer | JsonArrayContainer;
88
+
89
+ interface JsonDuplicateKeyScan {
90
+ readonly containers: JsonContainer[];
91
+ keyCount: number;
92
+ }
93
+
94
+ interface ParsedReport {
95
+ readonly statistics: JscpdScanStatistics;
96
+ readonly clonePairs: readonly UnresolvedClonePair[];
97
+ }
98
+
99
+ class ReportValidationError extends Data.TaggedError("ReportValidationError")<{
100
+ readonly code: JscpdReportErrorCode;
101
+ }> {}
102
+
103
+ /**
104
+ * Validate and normalize bounded jscpd v5 JSON bytes for the adapter's Effect consumer boundary.
105
+ * Rejections are intentionally body-free; unexpected defects are normalized by the adapter.
106
+ */
107
+ export function consumeJscpdV5JsonReportEffect(
108
+ bytes: Uint8Array,
109
+ cwd: string,
110
+ ): Effect.Effect<JscpdReportDecision<JscpdScanReport>, never, JscpdFileSystem> {
111
+ return Effect.flatMap(
112
+ validationAttempt(() => parseReportBytes(bytes)),
113
+ (parsed) => normalizeReportPathsEffect(parsed, cwd),
114
+ ).pipe(
115
+ Effect.map((report) =>
116
+ report.clonePairs.length === 0
117
+ ? ({ status: "no-findings", value: report } as const)
118
+ : ({ status: "accepted", value: report } as const),
119
+ ),
120
+ Effect.catchTag("ReportValidationError", (error) =>
121
+ Effect.succeed({ status: "rejected", reason: error.code } as const),
122
+ ),
123
+ );
124
+ }
125
+
126
+ export function isJscpdReportErrorCode(value: unknown): value is JscpdReportErrorCode {
127
+ return typeof value === "string" && REPORT_ERROR_CODES.has(value as JscpdReportErrorCode);
128
+ }
129
+
130
+ function parseReportBytes(bytes: Uint8Array): ParsedReport {
131
+ if (!(bytes instanceof Uint8Array) || bytes.byteLength === 0) {
132
+ fail("malformed-json");
133
+ }
134
+ if (bytes.byteLength > MAX_REPORT_BYTES) {
135
+ fail("limit-exceeded");
136
+ }
137
+
138
+ let text: string;
139
+ let value: unknown;
140
+ try {
141
+ text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
142
+ value = JSON.parse(text) as unknown;
143
+ } catch {
144
+ fail("malformed-json");
145
+ }
146
+ rejectDuplicateJsonKeys(text);
147
+
148
+ const topLevel = requireRecord(value, "invalid-top-level");
149
+ rejectUnsupportedReporter(topLevel);
150
+ const duplicates = requireArray(
151
+ requiredProperty(topLevel, "duplicates", "invalid-top-level"),
152
+ "invalid-duplicates",
153
+ );
154
+ if (duplicates.length > MAX_CLONE_PAIRS) {
155
+ fail("limit-exceeded");
156
+ }
157
+
158
+ const clonePairs = duplicates.map(parseClonePair);
159
+ const statistics = parseStatistics(requiredProperty(topLevel, "statistics", "invalid-top-level"));
160
+ validateCloneStatistics(clonePairs, statistics);
161
+ return { statistics, clonePairs };
162
+ }
163
+
164
+ function rejectDuplicateJsonKeys(text: string): void {
165
+ const scan: JsonDuplicateKeyScan = { containers: [], keyCount: 0 };
166
+ for (let index = 0; index < text.length; index += 1) {
167
+ const character = text[index];
168
+ if (character === '"') {
169
+ index = inspectJsonString(text, index, scan);
170
+ } else {
171
+ updateJsonContainers(character, scan.containers);
172
+ }
173
+ }
174
+ }
175
+
176
+ function updateJsonContainers(character: string | undefined, containers: JsonContainer[]): void {
177
+ switch (character) {
178
+ case "{":
179
+ containers.push({ kind: "object", keys: new Set() });
180
+ enforceJsonDepth(containers.length);
181
+ break;
182
+ case "[":
183
+ containers.push({ kind: "array" });
184
+ enforceJsonDepth(containers.length);
185
+ break;
186
+ case "}":
187
+ case "]":
188
+ containers.pop();
189
+ break;
190
+ }
191
+ }
192
+
193
+ function inspectJsonString(text: string, start: number, scan: JsonDuplicateKeyScan): number {
194
+ const stringEnd = findJsonStringEnd(text, start);
195
+ if (stringEnd === undefined) {
196
+ return text.length;
197
+ }
198
+ const container = scan.containers.at(-1);
199
+ if (nextJsonToken(text, stringEnd + 1) !== ":" || container?.kind !== "object") {
200
+ return stringEnd;
201
+ }
202
+
203
+ const key = JSON.parse(text.slice(start, stringEnd + 1)) as unknown;
204
+ if (typeof key !== "string") {
205
+ fail("malformed-json");
206
+ }
207
+ registerJsonKey(key, container, scan);
208
+ return stringEnd;
209
+ }
210
+
211
+ function registerJsonKey(
212
+ key: string,
213
+ container: JsonObjectContainer,
214
+ scan: JsonDuplicateKeyScan,
215
+ ): void {
216
+ if (container.keys.has(key)) {
217
+ fail("duplicate-key");
218
+ }
219
+ scan.keyCount += 1;
220
+ if (scan.keyCount > MAX_JSON_KEYS || container.keys.size >= MAX_JSON_KEYS_PER_OBJECT) {
221
+ fail("limit-exceeded");
222
+ }
223
+ container.keys.add(key);
224
+ }
225
+
226
+ function findJsonStringEnd(text: string, start: number): number | undefined {
227
+ for (let index = start + 1; index < text.length; index += 1) {
228
+ if (text[index] === "\\") {
229
+ index += 1;
230
+ continue;
231
+ }
232
+ if (text[index] === '"') {
233
+ return index;
234
+ }
235
+ }
236
+ return undefined;
237
+ }
238
+
239
+ function nextJsonToken(text: string, start: number): string | undefined {
240
+ for (let index = start; index < text.length; index += 1) {
241
+ const character = text[index];
242
+ if (character !== " " && character !== "\t" && character !== "\n" && character !== "\r") {
243
+ return character;
244
+ }
245
+ }
246
+ return undefined;
247
+ }
248
+
249
+ function enforceJsonDepth(depth: number): void {
250
+ if (depth > MAX_JSON_DEPTH) {
251
+ fail("limit-exceeded");
252
+ }
253
+ }
254
+
255
+ function rejectUnsupportedReporter(topLevel: Readonly<Record<string, unknown>>): void {
256
+ if (
257
+ Object.hasOwn(topLevel, "statistic") ||
258
+ Object.hasOwn(topLevel, "runs") ||
259
+ Object.hasOwn(topLevel, "$schema") ||
260
+ Object.hasOwn(topLevel, "clones")
261
+ ) {
262
+ fail("unsupported-reporter");
263
+ }
264
+ }
265
+
266
+ function parseClonePair(value: unknown): UnresolvedClonePair {
267
+ const duplicate = requireRecord(value, "invalid-duplicates");
268
+ const format = requireText(
269
+ requiredProperty(duplicate, "format", "invalid-duplicates"),
270
+ MAX_FORMAT_BYTES,
271
+ "invalid-duplicates",
272
+ );
273
+ const lines = requirePositiveU32(
274
+ requiredProperty(duplicate, "lines", "invalid-duplicates"),
275
+ "invalid-duplicates",
276
+ );
277
+ if (typeof requiredProperty(duplicate, "fragment", "invalid-duplicates") !== "string") {
278
+ fail("invalid-duplicates");
279
+ }
280
+ if (Object.hasOwn(duplicate, "isNew") && typeof duplicate.isNew !== "boolean") {
281
+ fail("invalid-duplicates");
282
+ }
283
+ const tokens = requirePositiveU32(
284
+ requiredProperty(duplicate, "tokens", "invalid-duplicates"),
285
+ "invalid-duplicates",
286
+ );
287
+ const first = parseOccurrence(requiredProperty(duplicate, "firstFile", "invalid-duplicates"));
288
+ const second = parseOccurrence(requiredProperty(duplicate, "secondFile", "invalid-duplicates"));
289
+
290
+ // jscpd defines `lines` from the first occurrence. Token-equivalent code in
291
+ // the second occurrence can span fewer physical lines because of formatting.
292
+ if (lines !== first.end.line - first.start.line + 1) {
293
+ fail("invalid-location");
294
+ }
295
+ return { format, lines, tokens, occurrences: [first, second] };
296
+ }
297
+
298
+ function parseOccurrence(value: unknown): UnresolvedOccurrence {
299
+ const occurrence = requireRecord(value, "invalid-duplicates");
300
+ const reporterPath = requireText(
301
+ requiredProperty(occurrence, "name", "invalid-duplicates"),
302
+ MAX_PATH_BYTES,
303
+ "unsafe-path",
304
+ );
305
+ const startLine = requirePositiveU32(
306
+ requiredProperty(occurrence, "start", "invalid-location"),
307
+ "invalid-location",
308
+ );
309
+ const endLine = requirePositiveU32(
310
+ requiredProperty(occurrence, "end", "invalid-location"),
311
+ "invalid-location",
312
+ );
313
+ const start = parseLocation(requiredProperty(occurrence, "startLoc", "invalid-location"));
314
+ const end = parseLocation(requiredProperty(occurrence, "endLoc", "invalid-location"));
315
+
316
+ if (
317
+ start.line !== startLine ||
318
+ end.line !== endLine ||
319
+ !locationComesBefore(start, end) ||
320
+ start.offset >= end.offset
321
+ ) {
322
+ fail("invalid-location");
323
+ }
324
+ return { reporterPath, start, end };
325
+ }
326
+
327
+ function parseLocation(value: unknown): JscpdSourceLocation {
328
+ const location = requireRecord(value, "invalid-location");
329
+ return Object.freeze({
330
+ line: requirePositiveU32(
331
+ requiredProperty(location, "line", "invalid-location"),
332
+ "invalid-location",
333
+ ),
334
+ column: requireU32(
335
+ requiredProperty(location, "column", "invalid-location"),
336
+ "invalid-location",
337
+ ),
338
+ offset: requireU32(
339
+ requiredProperty(location, "position", "invalid-location"),
340
+ "invalid-location",
341
+ ),
342
+ });
343
+ }
344
+
345
+ function locationComesBefore(start: JscpdSourceLocation, end: JscpdSourceLocation): boolean {
346
+ return start.line < end.line || (start.line === end.line && start.column < end.column);
347
+ }
348
+
349
+ function parseStatistics(value: unknown): JscpdScanStatistics {
350
+ const statistics = requireRecord(value, "invalid-statistics");
351
+ requireText(
352
+ requiredProperty(statistics, "detectionDate", "invalid-statistics"),
353
+ MAX_DATE_BYTES,
354
+ "invalid-statistics",
355
+ );
356
+ const total = parseStatisticsRow(requiredProperty(statistics, "total", "invalid-statistics"));
357
+ const formatsRecord = requireRecord(
358
+ requiredProperty(statistics, "formats", "invalid-statistics"),
359
+ "invalid-statistics",
360
+ );
361
+ const formatEntries = Object.entries(formatsRecord);
362
+ if (formatEntries.length > MAX_FORMATS) {
363
+ fail("limit-exceeded");
364
+ }
365
+
366
+ const formats = formatEntries
367
+ .map(([format, row]): JscpdFormatStatistics => {
368
+ const normalizedFormat = requireText(format, MAX_FORMAT_BYTES, "invalid-statistics");
369
+ return Object.freeze({ format: normalizedFormat, ...parseStatisticsRow(row) });
370
+ })
371
+ .sort((left, right) => compareText(left.format, right.format));
372
+ return Object.freeze({ total, formats: Object.freeze(formats) });
373
+ }
374
+
375
+ function parseStatisticsRow(value: unknown): JscpdStatisticsRow {
376
+ const row = requireRecord(value, "invalid-statistics");
377
+ const normalized: JscpdStatisticsRow = {
378
+ lines: requireCount(requiredProperty(row, "lines", "invalid-statistics")),
379
+ tokens: requireCount(requiredProperty(row, "tokens", "invalid-statistics")),
380
+ sources: requireCount(requiredProperty(row, "sources", "invalid-statistics")),
381
+ clones: requireCount(requiredProperty(row, "clones", "invalid-statistics")),
382
+ duplicatedLines: requireCount(requiredProperty(row, "duplicatedLines", "invalid-statistics")),
383
+ duplicatedTokens: requireCount(requiredProperty(row, "duplicatedTokens", "invalid-statistics")),
384
+ percentage: requirePercentage(requiredProperty(row, "percentage", "invalid-statistics")),
385
+ percentageTokens: requirePercentage(
386
+ requiredProperty(row, "percentageTokens", "invalid-statistics"),
387
+ ),
388
+ newDuplicatedLines: requireCount(
389
+ requiredProperty(row, "newDuplicatedLines", "invalid-statistics"),
390
+ ),
391
+ newClones: requireCount(requiredProperty(row, "newClones", "invalid-statistics")),
392
+ };
393
+ if (
394
+ normalized.newClones > normalized.clones ||
395
+ normalized.newDuplicatedLines > normalized.duplicatedLines
396
+ ) {
397
+ fail("invalid-statistics");
398
+ }
399
+ return Object.freeze(normalized);
400
+ }
401
+
402
+ function validateCloneStatistics(
403
+ clonePairs: readonly UnresolvedClonePair[],
404
+ statistics: JscpdScanStatistics,
405
+ ): void {
406
+ if (statistics.total.clones !== clonePairs.length) {
407
+ fail("invalid-statistics");
408
+ }
409
+
410
+ const clonesByFormat = countClonesByFormat(clonePairs);
411
+ validateReportedCloneFormats(clonesByFormat, statistics.formats);
412
+ validateStatisticsFormatRows(clonesByFormat, statistics.formats);
413
+ }
414
+
415
+ function countClonesByFormat(
416
+ clonePairs: readonly UnresolvedClonePair[],
417
+ ): ReadonlyMap<string, number> {
418
+ const clonesByFormat = new Map<string, number>();
419
+ for (const pair of clonePairs) {
420
+ clonesByFormat.set(pair.format, (clonesByFormat.get(pair.format) ?? 0) + 1);
421
+ }
422
+ return clonesByFormat;
423
+ }
424
+
425
+ function validateReportedCloneFormats(
426
+ clonesByFormat: ReadonlyMap<string, number>,
427
+ formats: readonly JscpdFormatStatistics[],
428
+ ): void {
429
+ for (const [format, cloneCount] of clonesByFormat) {
430
+ const formatStatistics = formats.find((row) => row.format === format);
431
+ if (formatStatistics?.clones !== cloneCount) {
432
+ fail("invalid-statistics");
433
+ }
434
+ }
435
+ }
436
+
437
+ function validateStatisticsFormatRows(
438
+ clonesByFormat: ReadonlyMap<string, number>,
439
+ formats: readonly JscpdFormatStatistics[],
440
+ ): void {
441
+ for (const row of formats) {
442
+ if (row.clones !== (clonesByFormat.get(row.format) ?? 0)) {
443
+ fail("invalid-statistics");
444
+ }
445
+ }
446
+ }
447
+
448
+ function normalizeReportPathsEffect(
449
+ parsed: ParsedReport,
450
+ cwd: string,
451
+ ): Effect.Effect<JscpdScanReport, ReportValidationError, JscpdFileSystem> {
452
+ return Effect.gen(function* () {
453
+ const projectDirectory = yield* resolveProjectDirectoryEffect(cwd);
454
+ const candidatePairs = yield* validationAttempt(() =>
455
+ parsed.clonePairs.map((pair): PreparedClonePair => {
456
+ const [first, second] = pair.occurrences;
457
+ return {
458
+ ...pair,
459
+ occurrences: [
460
+ {
461
+ ...first,
462
+ candidatePath: prepareCandidatePath(
463
+ first.reporterPath,
464
+ pair.format,
465
+ projectDirectory,
466
+ ),
467
+ },
468
+ {
469
+ ...second,
470
+ candidatePath: prepareCandidatePath(
471
+ second.reporterPath,
472
+ pair.format,
473
+ projectDirectory,
474
+ ),
475
+ },
476
+ ] as const,
477
+ };
478
+ }),
479
+ );
480
+ const candidatePaths = new Map<string, ReporterPathCandidates>();
481
+ for (const { occurrences } of candidatePairs) {
482
+ for (const { candidatePath } of occurrences) {
483
+ candidatePaths.set(candidatePath.key, candidatePath);
484
+ }
485
+ }
486
+ const normalizedPaths = yield* resolveCandidatePathsEffect(
487
+ [...candidatePaths.values()],
488
+ projectDirectory,
489
+ );
490
+ return yield* validationAttempt(() =>
491
+ finalizeNormalizedReport(parsed, candidatePairs, normalizedPaths),
492
+ );
493
+ });
494
+ }
495
+
496
+ function finalizeNormalizedReport(
497
+ parsed: ParsedReport,
498
+ candidatePairs: readonly PreparedClonePair[],
499
+ normalizedPaths: ReadonlyMap<string, string>,
500
+ ): JscpdScanReport {
501
+ const seenPairs = new Set<string>();
502
+ const clonePairs = candidatePairs.map((pair): JscpdClonePair => {
503
+ const occurrences = pair.occurrences
504
+ .map(
505
+ ({ candidatePath, start, end }): JscpdCloneOccurrence =>
506
+ Object.freeze({
507
+ path: requiredNormalizedPath(normalizedPaths, candidatePath.key),
508
+ start,
509
+ end,
510
+ }),
511
+ )
512
+ .sort((left, right) => compareText(occurrenceKey(left), occurrenceKey(right))) as [
513
+ JscpdCloneOccurrence,
514
+ JscpdCloneOccurrence,
515
+ ];
516
+ if (occurrenceKey(occurrences[0]) === occurrenceKey(occurrences[1])) {
517
+ fail("ambiguous-duplicate");
518
+ }
519
+ const normalizedPair = Object.freeze({
520
+ format: pair.format,
521
+ lines: pair.lines,
522
+ tokens: pair.tokens,
523
+ occurrences: Object.freeze(occurrences),
524
+ });
525
+ const key = clonePairKey(normalizedPair);
526
+ if (seenPairs.has(key)) {
527
+ fail("ambiguous-duplicate");
528
+ }
529
+ seenPairs.add(key);
530
+ return normalizedPair;
531
+ });
532
+ clonePairs.sort((left, right) => compareText(clonePairSortKey(left), clonePairSortKey(right)));
533
+
534
+ return Object.freeze({
535
+ statistics: parsed.statistics,
536
+ clonePairs: Object.freeze(clonePairs),
537
+ });
538
+ }
539
+
540
+ function resolveProjectDirectoryEffect(
541
+ cwd: string,
542
+ ): Effect.Effect<string, ReportValidationError, JscpdFileSystem> {
543
+ if (!isSafePathText(cwd) || !isAbsolute(cwd)) return validationFailure("unsafe-path");
544
+ return canonicalDirectoryEffect(cwd).pipe(
545
+ Effect.mapError(() => new ReportValidationError({ code: "unsafe-path" })),
546
+ Effect.flatMap((canonical) =>
547
+ canonical && isSafePathText(canonical) && isAbsolute(canonical)
548
+ ? Effect.succeed(canonical)
549
+ : validationFailure("unsafe-path"),
550
+ ),
551
+ );
552
+ }
553
+
554
+ function prepareCandidatePath(
555
+ reporterPath: string,
556
+ format: string,
557
+ cwd: string,
558
+ ): ReporterPathCandidates {
559
+ if (!isSafePathText(reporterPath) || reporterPath.startsWith("file:")) {
560
+ fail("unsafe-path");
561
+ }
562
+ if (
563
+ process.platform !== "win32" &&
564
+ (reporterPath.includes("\\") || isWindowsDrivePath(reporterPath))
565
+ ) {
566
+ fail("unsafe-path");
567
+ }
568
+
569
+ const exact = resolveSafeCandidatePath(reporterPath, cwd);
570
+ const embeddedPath = embeddedFormatBasePath(reporterPath, format);
571
+ const embeddedBase =
572
+ embeddedPath === undefined ? undefined : resolveSafeCandidatePath(embeddedPath, cwd);
573
+ return Object.freeze({
574
+ key: JSON.stringify([exact, embeddedBase ?? null]),
575
+ exact,
576
+ ...(embeddedBase === undefined ? {} : { embeddedBase }),
577
+ });
578
+ }
579
+
580
+ function resolveSafeCandidatePath(path: string, cwd: string): string {
581
+ const pathIsAbsolute = isAbsolute(path);
582
+ const candidatePath = resolve(cwd, path);
583
+ if (
584
+ Buffer.byteLength(candidatePath) > MAX_PATH_BYTES ||
585
+ (!pathIsAbsolute && !isPathInside(cwd, candidatePath))
586
+ ) {
587
+ fail("unsafe-path");
588
+ }
589
+ return candidatePath;
590
+ }
591
+
592
+ function isWindowsDrivePath(path: string): boolean {
593
+ const drive = path.codePointAt(0);
594
+ return (
595
+ drive !== undefined &&
596
+ ((drive >= 0x41 && drive <= 0x5a) || (drive >= 0x61 && drive <= 0x7a)) &&
597
+ path[1] === ":" &&
598
+ (path[2] === "/" || path[2] === "\\")
599
+ );
600
+ }
601
+
602
+ function embeddedFormatBasePath(path: string, format: string): string | undefined {
603
+ const suffix = `:${format}`;
604
+ if (!path.endsWith(suffix) || path.length === suffix.length) {
605
+ return undefined;
606
+ }
607
+ const candidate = path.slice(0, -suffix.length);
608
+ const finalCharacter = candidate.at(-1);
609
+ return finalCharacter === "/" || finalCharacter === "\\" ? undefined : candidate;
610
+ }
611
+
612
+ function resolveCandidatePathsEffect(
613
+ candidatePaths: readonly ReporterPathCandidates[],
614
+ cwd: string,
615
+ ): Effect.Effect<ReadonlyMap<string, string>, ReportValidationError, JscpdFileSystem> {
616
+ return Effect.forEach(
617
+ candidatePaths,
618
+ (candidate) =>
619
+ Effect.map(
620
+ canonicalProjectPathEffect(candidate, cwd),
621
+ (path) => [candidate.key, path] as const,
622
+ ),
623
+ { concurrency: PATH_RESOLUTION_CONCURRENCY },
624
+ ).pipe(Effect.map((entries) => new Map(entries)));
625
+ }
626
+
627
+ function canonicalProjectPathEffect(
628
+ candidate: ReporterPathCandidates,
629
+ cwd: string,
630
+ ): Effect.Effect<string, ReportValidationError, JscpdFileSystem> {
631
+ const paths =
632
+ candidate.embeddedBase === undefined
633
+ ? [candidate.exact]
634
+ : [candidate.exact, candidate.embeddedBase];
635
+ return Effect.forEach(paths, (path) => resolveRegularProjectFileEffect(path, cwd), {
636
+ concurrency: "unbounded",
637
+ }).pipe(
638
+ Effect.flatMap((resolved) => {
639
+ const projectPaths = [...new Set(resolved.filter((path) => path !== undefined))];
640
+ if (projectPaths.length === 0) return validationFailure("unsafe-path");
641
+ if (projectPaths.length > 1) return validationFailure("ambiguous-path");
642
+ return Effect.succeed(projectPaths[0] as string);
643
+ }),
644
+ );
645
+ }
646
+
647
+ function resolveRegularProjectFileEffect(
648
+ candidate: string,
649
+ cwd: string,
650
+ ): Effect.Effect<string | undefined, ReportValidationError, JscpdFileSystem> {
651
+ return Effect.flatMap(JscpdFileSystem, (filesystem) =>
652
+ Effect.flatMap(filesystem.canonicalize(candidate), (canonical) =>
653
+ Effect.map(filesystem.metadata(canonical), (metadata) => ({ canonical, metadata })),
654
+ ),
655
+ ).pipe(
656
+ Effect.catchTag("JscpdFileSystemFailure", (error) =>
657
+ error.reason === "missing" ? Effect.succeed(undefined) : validationFailure("unsafe-path"),
658
+ ),
659
+ Effect.flatMap((resolved) => {
660
+ if (resolved?.metadata.kind !== "file") return Effect.succeed(undefined);
661
+ if (!isPathInside(cwd, resolved.canonical)) return validationFailure("unsafe-path");
662
+ const projectRelative = relative(cwd, resolved.canonical);
663
+ return isSafeProjectRelativePath(projectRelative)
664
+ ? Effect.succeed(projectRelative.split(sep).join("/"))
665
+ : validationFailure("unsafe-path");
666
+ }),
667
+ );
668
+ }
669
+
670
+ function isSafeProjectRelativePath(path: string): boolean {
671
+ return isSafePathText(path) && !isAbsolute(path) && path !== ".." && !path.startsWith(`..${sep}`);
672
+ }
673
+
674
+ function requiredNormalizedPath(paths: ReadonlyMap<string, string>, candidate: string): string {
675
+ const normalized = paths.get(candidate);
676
+ if (!normalized) {
677
+ throw new Error("Internal normalized report path invariant failed.");
678
+ }
679
+ return normalized;
680
+ }
681
+
682
+ function clonePairKey(pair: JscpdClonePair): string {
683
+ return JSON.stringify([
684
+ pair.format,
685
+ occurrenceKey(pair.occurrences[0]),
686
+ occurrenceKey(pair.occurrences[1]),
687
+ ]);
688
+ }
689
+
690
+ function clonePairSortKey(pair: JscpdClonePair): string {
691
+ return JSON.stringify([
692
+ pair.format,
693
+ occurrenceKey(pair.occurrences[0]),
694
+ occurrenceKey(pair.occurrences[1]),
695
+ pair.lines,
696
+ pair.tokens,
697
+ ]);
698
+ }
699
+
700
+ function occurrenceKey(occurrence: JscpdCloneOccurrence): string {
701
+ return JSON.stringify([
702
+ occurrence.path,
703
+ occurrence.start.line,
704
+ occurrence.start.column,
705
+ occurrence.start.offset,
706
+ occurrence.end.line,
707
+ occurrence.end.column,
708
+ occurrence.end.offset,
709
+ ]);
710
+ }
711
+
712
+ function requiredProperty(
713
+ record: Readonly<Record<string, unknown>>,
714
+ property: string,
715
+ code: JscpdReportErrorCode,
716
+ ): unknown {
717
+ if (!Object.hasOwn(record, property)) {
718
+ fail(code);
719
+ }
720
+ return record[property];
721
+ }
722
+
723
+ function requireRecord(
724
+ value: unknown,
725
+ code: JscpdReportErrorCode,
726
+ ): Readonly<Record<string, unknown>> {
727
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
728
+ fail(code);
729
+ }
730
+ return value as Readonly<Record<string, unknown>>;
731
+ }
732
+
733
+ function requireArray(value: unknown, code: JscpdReportErrorCode): readonly unknown[] {
734
+ if (!Array.isArray(value)) {
735
+ fail(code);
736
+ }
737
+ return value;
738
+ }
739
+
740
+ function requireText(value: unknown, maxBytes: number, code: JscpdReportErrorCode): string {
741
+ if (
742
+ typeof value !== "string" ||
743
+ value.length === 0 ||
744
+ hasControlCharacters(value) ||
745
+ Buffer.byteLength(value) > maxBytes
746
+ ) {
747
+ fail(code);
748
+ }
749
+ return value;
750
+ }
751
+
752
+ function isSafePathText(value: unknown): value is string {
753
+ return (
754
+ typeof value === "string" &&
755
+ value.length > 0 &&
756
+ !hasControlCharacters(value) &&
757
+ Buffer.byteLength(value) <= MAX_PATH_BYTES
758
+ );
759
+ }
760
+
761
+ function requireCount(value: unknown): number {
762
+ if (!Number.isSafeInteger(value) || (value as number) < 0 || Object.is(value, -0)) {
763
+ fail("invalid-statistics");
764
+ }
765
+ return value as number;
766
+ }
767
+
768
+ function requirePositiveU32(value: unknown, code: JscpdReportErrorCode): number {
769
+ const number = requireU32(value, code);
770
+ if (number === 0) {
771
+ fail(code);
772
+ }
773
+ return number;
774
+ }
775
+
776
+ function requireU32(value: unknown, code: JscpdReportErrorCode): number {
777
+ if (
778
+ !Number.isSafeInteger(value) ||
779
+ (value as number) < 0 ||
780
+ (value as number) > MAX_U32 ||
781
+ Object.is(value, -0)
782
+ ) {
783
+ fail(code);
784
+ }
785
+ return value as number;
786
+ }
787
+
788
+ function requirePercentage(value: unknown): number {
789
+ if (
790
+ typeof value !== "number" ||
791
+ !Number.isFinite(value) ||
792
+ value < 0 ||
793
+ value > 100 ||
794
+ Object.is(value, -0)
795
+ ) {
796
+ fail("invalid-statistics");
797
+ }
798
+ return value;
799
+ }
800
+
801
+ function validationAttempt<A>(evaluate: () => A): Effect.Effect<A, ReportValidationError> {
802
+ return Effect.try({
803
+ try: evaluate,
804
+ catch: (error) => {
805
+ if (error instanceof ReportValidationError) return error;
806
+ throw error;
807
+ },
808
+ });
809
+ }
810
+
811
+ function validationFailure(
812
+ code: JscpdReportErrorCode,
813
+ ): Effect.Effect<never, ReportValidationError> {
814
+ return Effect.fail(new ReportValidationError({ code }));
815
+ }
816
+
817
+ function fail(code: JscpdReportErrorCode): never {
818
+ throw new ReportValidationError({ code });
819
+ }