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
package/src/scan.ts ADDED
@@ -0,0 +1,441 @@
1
+ import { isAbsolute, relative, resolve, sep } from "node:path";
2
+ import { Context, Effect, Layer } from "effect";
3
+ import {
4
+ createJscpdExecutionPath,
5
+ type JscpdCapabilityRequest,
6
+ type JscpdCapabilityResult,
7
+ type JscpdCapabilityService,
8
+ } from "./capability.js";
9
+ import { indexJscpdCloneReportEffect } from "./clone-identity.js";
10
+ import { DEFAULT_JSCPD_CONFIG, type JscpdConfig } from "./config.js";
11
+ import { JscpdFileSystem, type JscpdProcess } from "./effect/services.js";
12
+ import type {
13
+ JscpdRunFailureReason,
14
+ JscpdRunRequest,
15
+ JscpdRunResult,
16
+ JscpdService,
17
+ } from "./jscpd.js";
18
+ import { consumeJscpdV5JsonReportEffect, JSCPD_STRUCTURED_REPORTER } from "./jscpd-report.js";
19
+ import { isPathInside, optionalCanonicalDirectoryEffect } from "./path-utils.js";
20
+ import { presentJscpdScan } from "./presentation.js";
21
+ import type {
22
+ JscpdCommandExecutor,
23
+ JscpdExecutionResult,
24
+ JscpdReportErrorCode,
25
+ JscpdScanFailureReason,
26
+ JscpdScanReport,
27
+ JscpdUnavailableResult,
28
+ } from "./types.js";
29
+ import type { JscpdVerificationService } from "./verification.js";
30
+ import {
31
+ compareAndRememberJscpdVerificationEffect,
32
+ jscpdVerificationScopeEffect,
33
+ withJscpdVerification,
34
+ } from "./verification.js";
35
+
36
+ export const JSCPD_CLONE_POSITIVE_EXIT_CODES = [1] as const;
37
+
38
+ export interface JscpdScanExecutorOptions {
39
+ /** Stable PATH override for deterministic tests; normal Pi execution uses the session PATH. */
40
+ path?: string;
41
+ /** Current trusted extension configuration; omitted by isolated adapter tests. */
42
+ config?: () => JscpdConfig;
43
+ /** Ephemeral comparison state for explicit pre/post-refactor scans. */
44
+ verification?: JscpdVerificationService;
45
+ }
46
+
47
+ interface ResolvedScanScopes {
48
+ readonly cwd: string;
49
+ readonly targets: readonly string[];
50
+ }
51
+
52
+ type ScopeResolution =
53
+ | { ok: true; value: ResolvedScanScopes }
54
+ | { ok: false; result: JscpdExecutionResult };
55
+
56
+ interface JscpdScanWorkflowService {
57
+ readonly execute: (
58
+ invocation: Parameters<JscpdCommandExecutor["executeEffect"]>[0],
59
+ context: Parameters<JscpdCommandExecutor["executeEffect"]>[1],
60
+ ) => Effect.Effect<JscpdExecutionResult, never, JscpdFileSystem | JscpdProcess>;
61
+ }
62
+
63
+ export const JscpdScanWorkflow = Context.GenericTag<JscpdScanWorkflowService>(
64
+ "pi-jscpd/effect/ScanWorkflow",
65
+ );
66
+
67
+ /** Connect capability probing, safe scopes, the bounded adapter, strict report parsing, and views. */
68
+ export function createJscpdScanExecutor(
69
+ capabilityService: JscpdCapabilityService,
70
+ service: JscpdService,
71
+ options: JscpdScanExecutorOptions = {},
72
+ ): JscpdCommandExecutor {
73
+ const workflow = scanWorkflowFor(capabilityService, service, options);
74
+ return {
75
+ executeEffect: (invocation, context) => workflow.execute(invocation, context),
76
+ };
77
+ }
78
+
79
+ export function createJscpdScanWorkflowLayer(
80
+ capabilityService: JscpdCapabilityService,
81
+ service: JscpdService,
82
+ options: JscpdScanExecutorOptions = {},
83
+ ) {
84
+ return Layer.succeed(JscpdScanWorkflow, scanWorkflowFor(capabilityService, service, options));
85
+ }
86
+
87
+ function scanWorkflowFor(
88
+ capabilityService: JscpdCapabilityService,
89
+ service: JscpdService,
90
+ options: JscpdScanExecutorOptions,
91
+ ): JscpdScanWorkflowService {
92
+ return {
93
+ execute: (invocation, context) =>
94
+ Effect.suspend(() => {
95
+ const config = options.config?.() ?? DEFAULT_JSCPD_CONFIG;
96
+ if (!config.enabled) return Effect.succeed(disabledResult());
97
+ return Effect.gen(function* () {
98
+ const verificationScope = options.verification
99
+ ? yield* jscpdVerificationScopeEffect(options.verification)
100
+ : undefined;
101
+ const scopes = yield* resolveScanScopesEffect(context.cwd, invocation.args);
102
+ if (!scopes.ok) return scopes.result;
103
+ const capability = yield* capabilityProbeEffect(capabilityService, {
104
+ cwd: scopes.value.cwd,
105
+ path: options.path,
106
+ signal: context.signal,
107
+ });
108
+ if (capability.status !== "available") return capabilityUnavailableResult(capability);
109
+ const scan = yield* adapterRunEffect(service, {
110
+ executable: capability.executable,
111
+ cwd: scopes.value.cwd,
112
+ path: createJscpdExecutionPath(scopes.value.cwd, options.path, capability.source),
113
+ signal: context.signal,
114
+ timeoutMs: options.config ? config.timeoutMs : undefined,
115
+ reportExitCodes: JSCPD_CLONE_POSITIVE_EXIT_CODES,
116
+ createArguments: ({ directory }) =>
117
+ createJscpdScanArguments(directory, scopes.value.targets),
118
+ consumeReportEffect: (bytes) => consumeJscpdV5JsonReportEffect(bytes, scopes.value.cwd),
119
+ });
120
+ return yield* executionResultWithVerificationEffect(
121
+ scan,
122
+ config.maxFindings,
123
+ context.overlayFindingLimit,
124
+ scopes.value,
125
+ options.verification,
126
+ verificationScope,
127
+ );
128
+ });
129
+ }),
130
+ };
131
+ }
132
+
133
+ /** User tokens are scopes only; all reporter controls are extension-owned and precede `--`. */
134
+ export function createJscpdScanArguments(
135
+ reportDirectory: string,
136
+ targets: readonly string[],
137
+ ): readonly string[] {
138
+ return [
139
+ "--reporters",
140
+ JSCPD_STRUCTURED_REPORTER,
141
+ "--output",
142
+ reportDirectory,
143
+ "--absolute",
144
+ "--",
145
+ ...targets,
146
+ ];
147
+ }
148
+
149
+ function resolveScanScopesEffect(
150
+ cwd: string,
151
+ requested: readonly string[],
152
+ ): Effect.Effect<ScopeResolution, never, JscpdFileSystem> {
153
+ if (!isAbsolute(cwd)) return Effect.succeed(unavailableProjectScope());
154
+ return optionalCanonicalDirectoryEffect(cwd).pipe(
155
+ Effect.flatMap((projectDirectory) => {
156
+ if (!projectDirectory) return Effect.succeed(unavailableProjectScope());
157
+ const requestedTargets = requested.length === 0 ? ["."] : requested;
158
+ return resolveScanTargetsEffect(cwd, projectDirectory, requestedTargets);
159
+ }),
160
+ );
161
+ }
162
+
163
+ function resolveScanTargetsEffect(
164
+ cwd: string,
165
+ projectDirectory: string,
166
+ requestedTargets: readonly string[],
167
+ ): Effect.Effect<ScopeResolution, never, JscpdFileSystem> {
168
+ return Effect.gen(function* () {
169
+ const targets: string[] = [];
170
+ const seen = new Set<string>();
171
+ for (const token of requestedTargets) {
172
+ const resolved = yield* resolveScanScopeEffect(cwd, projectDirectory, token);
173
+ if (!resolved.ok) return resolved;
174
+ if (!seen.has(resolved.target)) {
175
+ seen.add(resolved.target);
176
+ targets.push(resolved.target);
177
+ }
178
+ }
179
+ return { ok: true, value: { cwd: projectDirectory, targets } } as const;
180
+ });
181
+ }
182
+
183
+ function resolveScanScopeEffect(
184
+ inputCwd: string,
185
+ projectDirectory: string,
186
+ token: string,
187
+ ): Effect.Effect<
188
+ { ok: true; target: string } | { ok: false; result: JscpdExecutionResult },
189
+ never,
190
+ JscpdFileSystem
191
+ > {
192
+ const lexicalCandidate = resolve(inputCwd, token);
193
+ if (!isPathInside(resolve(inputCwd), lexicalCandidate)) {
194
+ return Effect.succeed(
195
+ pathFailure("unsafe-path", "The requested scan scope is outside the project; no scan ran."),
196
+ );
197
+ }
198
+ return Effect.flatMap(JscpdFileSystem, (filesystem) =>
199
+ filesystem.canonicalize(lexicalCandidate).pipe(
200
+ Effect.matchEffect({
201
+ onFailure: () =>
202
+ Effect.succeed(
203
+ pathFailure(
204
+ "unsupported-path",
205
+ "A requested scan scope does not exist or is not accessible; no scan ran.",
206
+ ),
207
+ ),
208
+ onSuccess: (canonicalCandidate) => {
209
+ if (!isPathInside(projectDirectory, canonicalCandidate)) {
210
+ return Effect.succeed(
211
+ pathFailure(
212
+ "unsafe-path",
213
+ "The requested scan scope resolves outside the project; no scan ran.",
214
+ ),
215
+ );
216
+ }
217
+ return filesystem.metadata(canonicalCandidate).pipe(
218
+ Effect.match({
219
+ onFailure: () =>
220
+ pathFailure(
221
+ "unsupported-path",
222
+ "A requested scan scope does not exist or is not accessible; no scan ran.",
223
+ ),
224
+ onSuccess: (metadata) => {
225
+ if (metadata.kind !== "file" && metadata.kind !== "directory") {
226
+ return pathFailure(
227
+ "unsupported-path",
228
+ "A requested scan scope is not a regular file or directory; no scan ran.",
229
+ );
230
+ }
231
+ const projectRelative = relative(projectDirectory, canonicalCandidate);
232
+ return {
233
+ ok: true,
234
+ target: projectRelative === "" ? "." : toPortablePath(projectRelative),
235
+ } as const;
236
+ },
237
+ }),
238
+ );
239
+ },
240
+ }),
241
+ ),
242
+ );
243
+ }
244
+
245
+ function executionResultWithVerificationEffect(
246
+ scan: JscpdRunResult<JscpdScanReport>,
247
+ maxFindings: number,
248
+ overlayFindingLimit: number | undefined,
249
+ scopes: ResolvedScanScopes,
250
+ service: JscpdVerificationService | undefined,
251
+ expectedScope: number | undefined,
252
+ ): Effect.Effect<JscpdExecutionResult, never, JscpdFileSystem> {
253
+ const result = executionResult(scan, maxFindings, overlayFindingLimit);
254
+ if (!service || expectedScope === undefined || result.status !== "completed") {
255
+ return Effect.succeed(result);
256
+ }
257
+ const report = successfulReport(scan);
258
+ if (!report) return Effect.succeed(result);
259
+ return indexJscpdCloneReportEffect(report, scopes.cwd).pipe(
260
+ Effect.flatMap((snapshot) =>
261
+ compareAndRememberJscpdVerificationEffect(
262
+ service,
263
+ "project",
264
+ JSON.stringify(scopes.targets),
265
+ snapshot,
266
+ expectedScope,
267
+ ),
268
+ ),
269
+ Effect.map((verification) => withJscpdVerification(result, verification)),
270
+ );
271
+ }
272
+
273
+ export function capabilityProbeEffect(
274
+ service: JscpdCapabilityService,
275
+ request: JscpdCapabilityRequest,
276
+ ): Effect.Effect<JscpdCapabilityResult, never, JscpdProcess> {
277
+ return service.probeEffect(request);
278
+ }
279
+
280
+ export function adapterRunEffect<T>(
281
+ service: JscpdService,
282
+ request: JscpdRunRequest<T>,
283
+ ): Effect.Effect<JscpdRunResult<T>, never, JscpdProcess | JscpdFileSystem> {
284
+ return service.runEffect(request);
285
+ }
286
+
287
+ function successfulReport(result: JscpdRunResult<JscpdScanReport>): JscpdScanReport | undefined {
288
+ if (result.status === "report" || result.status === "no-findings") return result.value;
289
+ return undefined;
290
+ }
291
+
292
+ export function executionResult(
293
+ result: JscpdRunResult<JscpdScanReport>,
294
+ maxFindings: number,
295
+ overlayFindingLimit?: number,
296
+ ): JscpdExecutionResult {
297
+ switch (result.status) {
298
+ case "report":
299
+ return presentJscpdScan(result.value, maxFindings, overlayFindingLimit);
300
+ case "no-findings":
301
+ return result.value
302
+ ? presentJscpdScan(result.value, maxFindings, overlayFindingLimit)
303
+ : scanFailure(
304
+ "invalid-report",
305
+ "jscpd produced an invalid structured report; no result was used.",
306
+ );
307
+ case "no-report":
308
+ return scanFailure(
309
+ "missing-report",
310
+ "jscpd did not produce its structured report; no result was used.",
311
+ );
312
+ case "cancelled":
313
+ case "invalidated":
314
+ return scanFailure(
315
+ "scan-cancelled",
316
+ "The jscpd scan was cancelled and its temporary report was removed.",
317
+ );
318
+ case "timed-out":
319
+ return scanFailure("scan-timed-out", "The jscpd scan timed out and was stopped safely.");
320
+ case "failed":
321
+ return adapterFailure(result.reason, result.reportError);
322
+ }
323
+ }
324
+
325
+ function adapterFailure(
326
+ reason: JscpdRunFailureReason,
327
+ reportError?: JscpdReportErrorCode,
328
+ ): JscpdExecutionResult {
329
+ if (reason === "cleanup-failed") {
330
+ return scanFailure(
331
+ "cleanup-failed",
332
+ "The jscpd scan ended, but temporary report cleanup could not be confirmed.",
333
+ );
334
+ }
335
+ if (reason === "invalid-report" || isReportReadFailure(reason)) {
336
+ if (reportError === "malformed-json") {
337
+ return scanFailure(
338
+ "malformed-report",
339
+ "jscpd produced malformed structured JSON; no result was used.",
340
+ );
341
+ }
342
+ if (reportError === "unsupported-reporter") {
343
+ return scanFailure(
344
+ "incompatible-report",
345
+ "jscpd produced an incompatible structured report; v5 JSON is required.",
346
+ );
347
+ }
348
+ return scanFailure(
349
+ "invalid-report",
350
+ "jscpd produced an invalid structured report; no result was used.",
351
+ );
352
+ }
353
+ return scanFailure(
354
+ "process-failed",
355
+ "The jscpd scan process failed safely; child output was not included.",
356
+ );
357
+ }
358
+
359
+ function isReportReadFailure(reason: JscpdRunFailureReason): boolean {
360
+ return (
361
+ reason === "report-read-failed" ||
362
+ reason === "report-too-large" ||
363
+ reason === "consumer-failed" ||
364
+ reason === "consumer-timed-out"
365
+ );
366
+ }
367
+
368
+ function scanFailure(reason: JscpdScanFailureReason, message: string): JscpdExecutionResult {
369
+ return { status: "failed", reason, message };
370
+ }
371
+
372
+ function disabledResult(): JscpdExecutionResult {
373
+ return Object.freeze({
374
+ status: "unavailable",
375
+ reason: "disabled",
376
+ message: "jscpd scanning is disabled for this session. Run /jscpd on to re-enable it.",
377
+ });
378
+ }
379
+
380
+ function unavailableProjectScope(): ScopeResolution {
381
+ return pathFailure(
382
+ "unsupported-path",
383
+ "jscpd scan requires an available project working directory; no scan ran.",
384
+ );
385
+ }
386
+
387
+ function pathFailure(
388
+ reason: Extract<JscpdScanFailureReason, "unsafe-path" | "unsupported-path">,
389
+ message: string,
390
+ ): { ok: false; result: JscpdExecutionResult } {
391
+ return { ok: false, result: scanFailure(reason, message) };
392
+ }
393
+
394
+ export function capabilityUnavailableResult(
395
+ capability: JscpdCapabilityResult,
396
+ ): JscpdUnavailableResult {
397
+ switch (capability.status) {
398
+ case "available":
399
+ throw new Error("Available capability must proceed to scan execution.");
400
+ case "missing":
401
+ return {
402
+ status: "unavailable",
403
+ reason: "missing-binary",
404
+ message:
405
+ "jscpd scan is unavailable because the bundled analyzer could not be resolved; reinstall pi-jscpd.",
406
+ capability,
407
+ };
408
+ case "incompatible":
409
+ return {
410
+ status: "unavailable",
411
+ reason: "incompatible-version",
412
+ message: `jscpd scan requires v5; ${capability.executable} reported v${capability.version}.`,
413
+ capability,
414
+ };
415
+ case "cancelled":
416
+ return {
417
+ status: "unavailable",
418
+ reason: "probe-cancelled",
419
+ message: "The jscpd executable check was cancelled; no scan ran.",
420
+ capability,
421
+ };
422
+ case "timed-out":
423
+ return {
424
+ status: "unavailable",
425
+ reason: "probe-timed-out",
426
+ message: "The jscpd executable check timed out; no scan ran.",
427
+ capability,
428
+ };
429
+ case "failed":
430
+ return {
431
+ status: "unavailable",
432
+ reason: "probe-failed",
433
+ message: "The jscpd executable check failed safely; no scan ran.",
434
+ capability,
435
+ };
436
+ }
437
+ }
438
+
439
+ function toPortablePath(path: string): string {
440
+ return sep === "/" ? path : path.split(sep).join("/");
441
+ }