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,373 @@
1
+ import { createHash } from "node:crypto";
2
+ import { isAbsolute, join } from "node:path";
3
+ import { Effect } from "effect";
4
+ import { JscpdFileSystem } from "./effect/services.js";
5
+ import { canonicalDirectoryEffect, isPathInside } from "./path-utils.js";
6
+ import type { JscpdCloneOccurrence, JscpdClonePair, JscpdScanReport } from "./types.js";
7
+
8
+ const MAX_IDENTITY_BLOCK_BYTES = 1024 * 1024;
9
+ const DIGEST_ALGORITHM = "sha256";
10
+
11
+ export type JscpdCloneIdentityIssue =
12
+ | "invalid-project"
13
+ | "malformed-group"
14
+ | "unsafe-path"
15
+ | "missing-source"
16
+ | "invalid-range"
17
+ | "block-too-large"
18
+ | "read-failed";
19
+
20
+ export interface JscpdIndexedCloneGroup {
21
+ readonly clone: JscpdClonePair;
22
+ /** Opaque internal group digest; persisted only behind an explicit identity-version marker. */
23
+ readonly fingerprint?: string;
24
+ /** Opaque occurrence digests in the report's occurrence order. */
25
+ readonly occurrenceFingerprints?: readonly [string, string];
26
+ readonly issue?: JscpdCloneIdentityIssue;
27
+ }
28
+
29
+ export interface JscpdCloneSnapshot {
30
+ readonly status: "accepted" | "partial";
31
+ readonly groups: readonly JscpdIndexedCloneGroup[];
32
+ /** Runtime-malformed groups that could not safely retain a clone value. */
33
+ readonly omittedGroups: number;
34
+ }
35
+
36
+ export interface JscpdAmbiguousCloneGroups {
37
+ readonly reason: "identity-unavailable" | "non-unique-identity";
38
+ readonly baseline: readonly JscpdClonePair[];
39
+ readonly current: readonly JscpdClonePair[];
40
+ }
41
+
42
+ export interface JscpdBaselineComparison {
43
+ readonly existing: readonly JscpdClonePair[];
44
+ readonly new: readonly JscpdClonePair[];
45
+ readonly removed: readonly JscpdClonePair[];
46
+ readonly ambiguous: readonly JscpdAmbiguousCloneGroups[];
47
+ }
48
+
49
+ interface OccurrenceIdentity {
50
+ readonly path: string;
51
+ readonly contentDigest: string;
52
+ }
53
+
54
+ type OccurrenceIdentityResult =
55
+ | { readonly ok: true; readonly value: OccurrenceIdentity }
56
+ | { readonly ok: false; readonly issue: JscpdCloneIdentityIssue };
57
+
58
+ /**
59
+ * Derive content-aware identities immediately while report offsets still address this source tree.
60
+ * Line, column, and byte positions are deliberately excluded from the final group fingerprint.
61
+ */
62
+ export function indexJscpdCloneReportEffect(
63
+ report: JscpdScanReport,
64
+ cwd: string,
65
+ ): Effect.Effect<JscpdCloneSnapshot, never, JscpdFileSystem> {
66
+ const clonePairs = runtimeClonePairs(report);
67
+ if (!clonePairs) return Effect.succeed(partialSnapshot([], 0));
68
+ return canonicalDirectoryEffect(cwd).pipe(
69
+ Effect.catchAll(() => Effect.succeed(undefined)),
70
+ Effect.flatMap((project) =>
71
+ project
72
+ ? Effect.forEach(clonePairs, (clone) => indexCloneGroupEffect(clone, project), {
73
+ concurrency: "unbounded",
74
+ }).pipe(
75
+ Effect.map((groups) =>
76
+ Object.freeze({
77
+ status: groups.some((group) => group.issue) ? "partial" : "accepted",
78
+ groups: Object.freeze(groups),
79
+ omittedGroups: 0,
80
+ }),
81
+ ),
82
+ )
83
+ : Effect.succeed(partialSnapshot([], clonePairs.length)),
84
+ ),
85
+ );
86
+ }
87
+
88
+ /** Compare opaque identities conservatively; duplicate or unavailable identities stay ambiguous. */
89
+ export function compareJscpdCloneSnapshots(
90
+ baseline: JscpdCloneSnapshot,
91
+ current: JscpdCloneSnapshot,
92
+ ): JscpdBaselineComparison {
93
+ const existing: JscpdClonePair[] = [];
94
+ const added: JscpdClonePair[] = [];
95
+ const removed: JscpdClonePair[] = [];
96
+ const ambiguous: JscpdAmbiguousCloneGroups[] = [];
97
+ const baselineByIdentity = groupByFingerprint(baseline.groups, ambiguous, "baseline");
98
+ const currentByIdentity = groupByFingerprint(current.groups, ambiguous, "current");
99
+ const identities = [
100
+ ...new Set([...baselineByIdentity.keys(), ...currentByIdentity.keys()]),
101
+ ].sort();
102
+
103
+ for (const identity of identities) {
104
+ classifyIdentity(
105
+ baselineByIdentity.get(identity) ?? [],
106
+ currentByIdentity.get(identity) ?? [],
107
+ baseline.status === "partial",
108
+ current.status === "partial",
109
+ existing,
110
+ added,
111
+ removed,
112
+ ambiguous,
113
+ );
114
+ }
115
+ if (hasUnrepresentedPartialInput(baseline) || hasUnrepresentedPartialInput(current)) {
116
+ ambiguous.push(freezeAmbiguous("identity-unavailable", [], []));
117
+ }
118
+
119
+ return Object.freeze({
120
+ existing: Object.freeze(existing),
121
+ new: Object.freeze(added),
122
+ removed: Object.freeze(removed),
123
+ ambiguous: Object.freeze(ambiguous),
124
+ });
125
+ }
126
+
127
+ function runtimeClonePairs(report: JscpdScanReport): readonly JscpdClonePair[] | undefined {
128
+ if (!report || typeof report !== "object" || !Array.isArray(report.clonePairs)) return undefined;
129
+ return report.clonePairs;
130
+ }
131
+
132
+ function indexCloneGroupEffect(
133
+ clone: JscpdClonePair,
134
+ project: string,
135
+ ): Effect.Effect<JscpdIndexedCloneGroup, never, JscpdFileSystem> {
136
+ if (!isClonePair(clone)) {
137
+ return Effect.succeed(Object.freeze({ clone, issue: "malformed-group" }));
138
+ }
139
+ return Effect.all(
140
+ [
141
+ occurrenceIdentityEffect(clone.occurrences[0], project),
142
+ occurrenceIdentityEffect(clone.occurrences[1], project),
143
+ ],
144
+ { concurrency: "unbounded" },
145
+ ).pipe(
146
+ Effect.map(([first, second]) => {
147
+ if (!first.ok) return Object.freeze({ clone, issue: first.issue });
148
+ if (!second.ok) return Object.freeze({ clone, issue: second.issue });
149
+ const occurrenceFingerprints = Object.freeze([
150
+ fingerprintOccurrence(first.value),
151
+ fingerprintOccurrence(second.value),
152
+ ] as const);
153
+ const sortedOccurrences = [...occurrenceFingerprints].sort();
154
+ return Object.freeze({
155
+ clone,
156
+ fingerprint: digest(
157
+ JSON.stringify([clone.format, clone.lines, clone.tokens, sortedOccurrences]),
158
+ ),
159
+ occurrenceFingerprints,
160
+ });
161
+ }),
162
+ );
163
+ }
164
+
165
+ function isClonePair(value: unknown): value is JscpdClonePair {
166
+ if (!value || typeof value !== "object") return false;
167
+ const clone = value as Partial<JscpdClonePair>;
168
+ return (
169
+ typeof clone.format === "string" &&
170
+ Number.isSafeInteger(clone.lines) &&
171
+ Number.isSafeInteger(clone.tokens) &&
172
+ Array.isArray(clone.occurrences) &&
173
+ clone.occurrences.length === 2
174
+ );
175
+ }
176
+
177
+ function occurrenceIdentityEffect(
178
+ occurrence: JscpdCloneOccurrence,
179
+ project: string,
180
+ ): Effect.Effect<OccurrenceIdentityResult, never, JscpdFileSystem> {
181
+ if (!isOccurrence(occurrence)) {
182
+ return Effect.succeed({ ok: false, issue: "malformed-group" });
183
+ }
184
+ const candidate = join(project, occurrence.path);
185
+ if (!isPathInside(project, candidate)) return Effect.succeed({ ok: false, issue: "unsafe-path" });
186
+
187
+ return Effect.flatMap(JscpdFileSystem, (filesystem) =>
188
+ filesystem.canonicalize(candidate).pipe(
189
+ Effect.matchEffect({
190
+ onFailure: () => Effect.succeed({ ok: false, issue: "missing-source" } as const),
191
+ onSuccess: (canonical) =>
192
+ occurrenceIdentityFromCanonical(filesystem, occurrence, project, canonical),
193
+ }),
194
+ ),
195
+ );
196
+ }
197
+
198
+ function occurrenceIdentityFromCanonical(
199
+ filesystem: JscpdFileSystem,
200
+ occurrence: JscpdCloneOccurrence,
201
+ project: string,
202
+ canonical: string,
203
+ ): Effect.Effect<OccurrenceIdentityResult> {
204
+ if (!isPathInside(project, canonical)) {
205
+ return Effect.succeed({ ok: false, issue: "unsafe-path" } as const);
206
+ }
207
+ const length = occurrence.end.offset - occurrence.start.offset;
208
+ if (!Number.isSafeInteger(length) || length <= 0) {
209
+ return Effect.succeed({ ok: false, issue: "invalid-range" } as const);
210
+ }
211
+ if (length > MAX_IDENTITY_BLOCK_BYTES) {
212
+ return Effect.succeed({ ok: false, issue: "block-too-large" } as const);
213
+ }
214
+ return filesystem
215
+ .read({
216
+ path: canonical,
217
+ maxBytes: MAX_IDENTITY_BLOCK_BYTES,
218
+ regularFileOnly: true,
219
+ noFollow: true,
220
+ offset: occurrence.start.offset,
221
+ length,
222
+ limitSubject: "report",
223
+ })
224
+ .pipe(
225
+ Effect.match({
226
+ onFailure: () => ({ ok: false, issue: "read-failed" }) as const,
227
+ onSuccess: (content) => ({
228
+ ok: true,
229
+ value: Object.freeze({ path: occurrence.path, contentDigest: digest(content) }),
230
+ }),
231
+ }),
232
+ );
233
+ }
234
+
235
+ function isOccurrence(value: unknown): value is JscpdCloneOccurrence {
236
+ if (!value || typeof value !== "object") return false;
237
+ const occurrence = value as Partial<JscpdCloneOccurrence>;
238
+ if (!isIdentityPath(occurrence.path)) return false;
239
+ if (!hasSafeOffset(occurrence.start) || !hasSafeOffset(occurrence.end)) return false;
240
+ return occurrence.end.offset >= occurrence.start.offset;
241
+ }
242
+
243
+ function isIdentityPath(value: unknown): value is string {
244
+ if (typeof value !== "string" || value.length === 0) return false;
245
+ return !isAbsolute(value) && !value.includes("\\");
246
+ }
247
+
248
+ function hasSafeOffset(
249
+ value: JscpdCloneOccurrence["start"] | undefined,
250
+ ): value is JscpdCloneOccurrence["start"] {
251
+ if (!value || !Number.isSafeInteger(value.offset)) return false;
252
+ return value.offset >= 0;
253
+ }
254
+
255
+ function hasUnrepresentedPartialInput(snapshot: JscpdCloneSnapshot): boolean {
256
+ if (snapshot.status !== "partial") return false;
257
+ return snapshot.omittedGroups > 0 || snapshot.groups.every((group) => !!group.fingerprint);
258
+ }
259
+
260
+ function groupByFingerprint(
261
+ groups: readonly JscpdIndexedCloneGroup[],
262
+ ambiguous: JscpdAmbiguousCloneGroups[],
263
+ side: "baseline" | "current",
264
+ ): Map<string, JscpdClonePair[]> {
265
+ const grouped = new Map<string, JscpdClonePair[]>();
266
+ for (const group of groups) {
267
+ if (!group.fingerprint) {
268
+ ambiguous.push(
269
+ freezeAmbiguous(
270
+ "identity-unavailable",
271
+ side === "baseline" ? [group.clone] : [],
272
+ side === "current" ? [group.clone] : [],
273
+ ),
274
+ );
275
+ continue;
276
+ }
277
+ const matches = grouped.get(group.fingerprint) ?? [];
278
+ matches.push(group.clone);
279
+ grouped.set(group.fingerprint, matches);
280
+ }
281
+ return grouped;
282
+ }
283
+
284
+ function classifyIdentity(
285
+ baseline: readonly JscpdClonePair[],
286
+ current: readonly JscpdClonePair[],
287
+ baselineIncomplete: boolean,
288
+ currentIncomplete: boolean,
289
+ existing: JscpdClonePair[],
290
+ added: JscpdClonePair[],
291
+ removed: JscpdClonePair[],
292
+ ambiguous: JscpdAmbiguousCloneGroups[],
293
+ ): void {
294
+ switch (`${baseline.length}:${current.length}`) {
295
+ case "1:1":
296
+ classifyExisting(baseline, current, existing, ambiguous);
297
+ return;
298
+ case "0:1":
299
+ classifyAdded(baseline, current, baselineIncomplete, added, ambiguous);
300
+ return;
301
+ case "1:0":
302
+ classifyRemoved(baseline, current, currentIncomplete, removed, ambiguous);
303
+ return;
304
+ default:
305
+ ambiguous.push(freezeAmbiguous("non-unique-identity", baseline, current));
306
+ }
307
+ }
308
+
309
+ function classifyExisting(
310
+ baseline: readonly JscpdClonePair[],
311
+ current: readonly JscpdClonePair[],
312
+ existing: JscpdClonePair[],
313
+ ambiguous: JscpdAmbiguousCloneGroups[],
314
+ ): void {
315
+ const group = current[0];
316
+ if (group) existing.push(group);
317
+ else ambiguous.push(freezeAmbiguous("identity-unavailable", baseline, current));
318
+ }
319
+
320
+ function classifyAdded(
321
+ baseline: readonly JscpdClonePair[],
322
+ current: readonly JscpdClonePair[],
323
+ baselineIncomplete: boolean,
324
+ added: JscpdClonePair[],
325
+ ambiguous: JscpdAmbiguousCloneGroups[],
326
+ ): void {
327
+ const group = current[0];
328
+ if (group && !baselineIncomplete) added.push(group);
329
+ else ambiguous.push(freezeAmbiguous("identity-unavailable", baseline, current));
330
+ }
331
+
332
+ function classifyRemoved(
333
+ baseline: readonly JscpdClonePair[],
334
+ current: readonly JscpdClonePair[],
335
+ currentIncomplete: boolean,
336
+ removed: JscpdClonePair[],
337
+ ambiguous: JscpdAmbiguousCloneGroups[],
338
+ ): void {
339
+ const group = baseline[0];
340
+ if (group && !currentIncomplete) removed.push(group);
341
+ else ambiguous.push(freezeAmbiguous("identity-unavailable", baseline, current));
342
+ }
343
+
344
+ function freezeAmbiguous(
345
+ reason: JscpdAmbiguousCloneGroups["reason"],
346
+ baseline: readonly JscpdClonePair[],
347
+ current: readonly JscpdClonePair[],
348
+ ): JscpdAmbiguousCloneGroups {
349
+ return Object.freeze({
350
+ reason,
351
+ baseline: Object.freeze([...baseline]),
352
+ current: Object.freeze([...current]),
353
+ });
354
+ }
355
+
356
+ function partialSnapshot(
357
+ groups: readonly JscpdIndexedCloneGroup[],
358
+ omittedGroups: number,
359
+ ): JscpdCloneSnapshot {
360
+ return Object.freeze({
361
+ status: "partial",
362
+ groups: Object.freeze([...groups]),
363
+ omittedGroups,
364
+ });
365
+ }
366
+
367
+ function fingerprintOccurrence(value: OccurrenceIdentity): string {
368
+ return digest(JSON.stringify([value.path, value.contentDigest]));
369
+ }
370
+
371
+ function digest(value: string | Uint8Array): string {
372
+ return createHash(DIGEST_ALGORITHM).update(value).digest("hex");
373
+ }