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/jscpd.ts ADDED
@@ -0,0 +1,748 @@
1
+ import { tmpdir } from "node:os";
2
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
3
+ import { Cause, Context, Effect, Layer } from "effect";
4
+ import { type JscpdFileSystemFailure, JscpdLimitExceeded } from "./effect/errors.js";
5
+ import {
6
+ JscpdFileSystem,
7
+ type JscpdFileSystem as JscpdFileSystemService,
8
+ type JscpdProcess,
9
+ } from "./effect/services.js";
10
+ import { isJscpdReportErrorCode, JSCPD_STRUCTURED_REPORT_FILE_NAME } from "./jscpd-report.js";
11
+ import {
12
+ type BoundedProcessResult,
13
+ createProcessEnvironmentWithPath,
14
+ runBoundedProcessEffect,
15
+ } from "./process.js";
16
+ import type { JscpdReportDecision, JscpdReportErrorCode } from "./types.js";
17
+
18
+ export type { JscpdReportDecision } from "./types.js";
19
+
20
+ const JSCPD_EXECUTION_TIMEOUT_MS = 30_000;
21
+ const JSCPD_MAX_OUTPUT_BYTES = 64 * 1_024;
22
+ const JSCPD_MAX_REPORT_BYTES = 16 * 1_024 * 1_024;
23
+ const JSCPD_REPORT_CONSUMPTION_TIMEOUT_MS = 2_000;
24
+ const JSCPD_WORKSPACE_CLEANUP_TIMEOUT_MS = 2_000;
25
+
26
+ const TEMPORARY_PREFIX = "pi-jscpd-";
27
+ const MAX_ARGUMENT_COUNT = 256;
28
+ const MAX_ARGUMENT_BYTES = 16 * 1_024;
29
+ const MAX_TOTAL_ARGUMENT_BYTES = 64 * 1_024;
30
+ const MAX_PATH_BYTES = 16 * 1_024;
31
+ const MAX_CONFIGURED_TIMEOUT_MS = 5 * 60_000;
32
+ const MAX_CONFIGURED_OUTPUT_BYTES = 1024 * 1024;
33
+ const MAX_CONFIGURED_REPORT_BYTES = 64 * 1024 * 1024;
34
+ const MAX_CONFIGURED_CONSUMPTION_TIMEOUT_MS = 30_000;
35
+
36
+ type JobTermination = "cancelled" | "invalidated" | "disposed";
37
+
38
+ export interface JscpdReportTarget {
39
+ readonly directory: string;
40
+ readonly reportPath: string;
41
+ }
42
+
43
+ export interface JscpdRunRequest<T> {
44
+ /** A capability-resolved executable name or absolute executable path. */
45
+ executable: string;
46
+ /** The explicit project working directory. */
47
+ cwd: string;
48
+ /** A stable PATH used to resolve a command name. Defaults to the current process PATH. */
49
+ path?: string;
50
+ signal?: AbortSignal;
51
+ /** Per-run extension timeout override loaded from trusted configuration. */
52
+ timeoutMs?: number;
53
+ /** Build shell-free CLI tokens around the adapter-owned report directory and fixed file path. */
54
+ createArguments(target: JscpdReportTarget): readonly string[];
55
+ /** Nonzero clone-positive exits accepted only when they also yield an accepted findings report. */
56
+ reportExitCodes?: readonly number[];
57
+ /** Validate and consume bounded report bytes before their temporary directory is removed. */
58
+ consumeReportEffect: (
59
+ report: Uint8Array,
60
+ ) => Effect.Effect<JscpdReportDecision<T>, never, JscpdFileSystem>;
61
+ }
62
+
63
+ export type JscpdRunFailureReason =
64
+ | "invalid-request"
65
+ | "service-disposed"
66
+ | "temporary-directory"
67
+ | "unsafe-temporary-path"
68
+ | "argument-construction"
69
+ | "spawn-failed"
70
+ | "nonzero-exit"
71
+ | "output-limit"
72
+ | "invalid-report"
73
+ | "report-read-failed"
74
+ | "report-too-large"
75
+ | "consumer-failed"
76
+ | "consumer-timed-out"
77
+ | "cleanup-failed"
78
+ | "internal-error";
79
+
80
+ export type JscpdRunResult<T> =
81
+ | { status: "report"; value: T }
82
+ | { status: "no-findings"; value?: T }
83
+ | { status: "no-report" }
84
+ | { status: "cancelled" }
85
+ | { status: "invalidated" }
86
+ | { status: "timed-out"; timeoutMs: number }
87
+ | {
88
+ status: "failed";
89
+ reason: JscpdRunFailureReason;
90
+ exitCode?: number;
91
+ reportError?: JscpdReportErrorCode;
92
+ };
93
+
94
+ interface JscpdEffectService {
95
+ run<T>(
96
+ request: JscpdRunRequest<T>,
97
+ ): Effect.Effect<JscpdRunResult<T>, never, JscpdProcess | JscpdFileSystem>;
98
+ invalidate(): Effect.Effect<void>;
99
+ dispose(): Effect.Effect<void>;
100
+ }
101
+
102
+ export const JscpdAdapter = Context.GenericTag<JscpdEffectService>("pi-jscpd/effect/JscpdAdapter");
103
+
104
+ export interface JscpdService {
105
+ runEffect<T>(
106
+ request: JscpdRunRequest<T>,
107
+ ): Effect.Effect<JscpdRunResult<T>, never, JscpdProcess | JscpdFileSystem>;
108
+ invalidate(): void;
109
+ disposeEffect(): Effect.Effect<void>;
110
+ }
111
+
112
+ export interface JscpdServiceOptions {
113
+ timeoutMs?: number;
114
+ maxOutputBytes?: number;
115
+ maxReportBytes?: number;
116
+ reportConsumptionTimeoutMs?: number;
117
+ /** Primarily for deterministic tests; no directory is created until run is called. */
118
+ temporaryRoot?: string;
119
+ /** Primarily for deterministic cleanup-timeout tests. */
120
+ workspaceCleanupTimeoutMs?: number;
121
+ }
122
+
123
+ interface ResolvedServiceOptions {
124
+ timeoutMs: number;
125
+ maxOutputBytes: number;
126
+ maxReportBytes: number;
127
+ reportConsumptionTimeoutMs: number;
128
+ temporaryRoot: string;
129
+ workspaceCleanupTimeoutMs: number;
130
+ }
131
+
132
+ interface EffectJob {
133
+ readonly request: JscpdRunRequest<unknown>;
134
+ readonly controller: AbortController;
135
+ detachCallerAbort: () => void;
136
+ termination?: JobTermination;
137
+ }
138
+
139
+ interface ReportWorkspace extends JscpdReportTarget {}
140
+
141
+ type WorkspaceResult =
142
+ | { ok: true; workspace: ReportWorkspace; cleanupPath: string }
143
+ | {
144
+ ok: false;
145
+ reason: "temporary-directory" | "unsafe-temporary-path";
146
+ cleanupPath?: string;
147
+ };
148
+
149
+ type ReportBytesResult =
150
+ | { status: "bytes"; bytes: Uint8Array }
151
+ | { status: "no-report" }
152
+ | {
153
+ status: "failed";
154
+ reason: "invalid-report" | "report-read-failed" | "report-too-large";
155
+ };
156
+
157
+ type ConsumptionResult<T> =
158
+ | { status: "completed"; decision: JscpdReportDecision<T> }
159
+ | { status: "cancelled" }
160
+ | { status: "failed" }
161
+ | { status: "timed-out" };
162
+
163
+ export function createJscpdService(options: JscpdServiceOptions = {}): JscpdService {
164
+ return new DefaultJscpdService(resolveServiceOptions(options));
165
+ }
166
+
167
+ /** Scoped Effect service used by later application slices without a Promise facade. */
168
+ export function createJscpdLayer(options: JscpdServiceOptions = {}) {
169
+ return Layer.scoped(
170
+ JscpdAdapter,
171
+ Effect.acquireRelease(
172
+ Effect.sync(() => new DefaultJscpdService(resolveServiceOptions(options))),
173
+ (owner) => owner.disposeEffect(),
174
+ ).pipe(Effect.map(effectServiceFor)),
175
+ );
176
+ }
177
+
178
+ class DefaultJscpdService implements JscpdService {
179
+ readonly #options: ResolvedServiceOptions;
180
+ readonly #semaphore = Effect.unsafeMakeSemaphore(1);
181
+ readonly #jobs = new Set<EffectJob>();
182
+ #disposed = false;
183
+
184
+ constructor(options: ResolvedServiceOptions) {
185
+ this.#options = options;
186
+ }
187
+
188
+ runEffect<T>(
189
+ request: JscpdRunRequest<T>,
190
+ ): Effect.Effect<JscpdRunResult<T>, never, JscpdProcess | JscpdFileSystem> {
191
+ return Effect.suspend(() => {
192
+ if (this.#disposed) return Effect.succeed(serviceDisposedResult());
193
+ if (!isValidRunRequest(request)) {
194
+ return Effect.succeed({ status: "failed", reason: "invalid-request" } as const);
195
+ }
196
+ if (request.signal?.aborted) return Effect.succeed({ status: "cancelled" } as const);
197
+
198
+ const job = this.#createJob(request as JscpdRunRequest<unknown>);
199
+ this.#jobs.add(job);
200
+ return this.#runJobEffect(job).pipe(
201
+ Effect.catchAllCause((cause) =>
202
+ Cause.isInterruptedOnly(cause)
203
+ ? Effect.failCause(cause)
204
+ : Effect.succeed({ status: "failed", reason: "internal-error" } as const),
205
+ ),
206
+ ) as Effect.Effect<JscpdRunResult<T>, never, JscpdProcess | JscpdFileSystem>;
207
+ });
208
+ }
209
+
210
+ #runJobEffect(
211
+ job: EffectJob,
212
+ ): Effect.Effect<JscpdRunResult<unknown>, never, JscpdProcess | JscpdFileSystem> {
213
+ return Effect.acquireUseRelease(
214
+ Effect.succeed(job),
215
+ () =>
216
+ Effect.raceFirst(
217
+ this.#semaphore.withPermits(1)(
218
+ Effect.suspend(() => {
219
+ const lifecycleResult = lifecycleResultFor(job);
220
+ return lifecycleResult ? Effect.succeed(lifecycleResult) : this.#executeEffect(job);
221
+ }),
222
+ ),
223
+ awaitJobTermination(job),
224
+ ),
225
+ () =>
226
+ Effect.sync(() => {
227
+ job.detachCallerAbort();
228
+ this.#jobs.delete(job);
229
+ }),
230
+ );
231
+ }
232
+
233
+ invalidate(): void {
234
+ if (this.#disposed) return;
235
+ for (const job of this.#jobs) this.#terminateJob(job, "invalidated");
236
+ }
237
+
238
+ invalidateEffect(): Effect.Effect<void> {
239
+ return Effect.sync(() => this.invalidate());
240
+ }
241
+
242
+ disposeEffect(): Effect.Effect<void> {
243
+ return Effect.suspend(() => {
244
+ if (!this.#disposed) {
245
+ this.#disposed = true;
246
+ for (const job of this.#jobs) this.#terminateJob(job, "disposed");
247
+ }
248
+ return this.#semaphore.withPermits(1)(Effect.void);
249
+ });
250
+ }
251
+
252
+ #createJob(request: JscpdRunRequest<unknown>): EffectJob {
253
+ const controller = new AbortController();
254
+ const job: EffectJob = {
255
+ request,
256
+ controller,
257
+ detachCallerAbort: () => undefined,
258
+ };
259
+ const cancel = () => this.#terminateJob(job, "cancelled");
260
+ request.signal?.addEventListener("abort", cancel, { once: true });
261
+ job.detachCallerAbort = () => request.signal?.removeEventListener("abort", cancel);
262
+ if (request.signal?.aborted) cancel();
263
+ return job;
264
+ }
265
+
266
+ #terminateJob(job: EffectJob, termination: JobTermination): void {
267
+ if (job.termination) return;
268
+ job.termination = termination;
269
+ job.controller.abort();
270
+ }
271
+
272
+ #executeEffect(
273
+ job: EffectJob,
274
+ ): Effect.Effect<JscpdRunResult<unknown>, never, JscpdProcess | JscpdFileSystem> {
275
+ let cleanupFailed = false;
276
+ return Effect.acquireUseRelease(
277
+ createReportWorkspaceEffect(job.request.cwd, this.#options.temporaryRoot),
278
+ (workspaceResult) =>
279
+ workspaceResult.ok
280
+ ? this.#executeInWorkspaceEffect(job, workspaceResult.workspace)
281
+ : Effect.succeed<JscpdRunResult<unknown>>({
282
+ status: "failed",
283
+ reason: workspaceResult.reason,
284
+ }),
285
+ (workspaceResult) => {
286
+ if (!workspaceResult.cleanupPath) return Effect.void;
287
+ return removeWorkspaceEffect(
288
+ workspaceResult.cleanupPath,
289
+ this.#options.workspaceCleanupTimeoutMs,
290
+ ).pipe(
291
+ Effect.tap((cleaned) =>
292
+ Effect.sync(() => {
293
+ cleanupFailed = !cleaned;
294
+ }),
295
+ ),
296
+ );
297
+ },
298
+ ).pipe(
299
+ Effect.map((result) =>
300
+ cleanupFailed ? { status: "failed", reason: "cleanup-failed" } : result,
301
+ ),
302
+ );
303
+ }
304
+
305
+ #executeInWorkspaceEffect(
306
+ job: EffectJob,
307
+ workspace: ReportWorkspace,
308
+ ): Effect.Effect<JscpdRunResult<unknown>, never, JscpdProcess | JscpdFileSystem> {
309
+ return Effect.gen(this, function* () {
310
+ const lifecycleResult = lifecycleResultFor(job);
311
+ if (lifecycleResult) return lifecycleResult;
312
+
313
+ const args = createValidatedArguments(job.request, workspace);
314
+ if (!args) return { status: "failed", reason: "argument-construction" } as const;
315
+
316
+ const timeoutMs = requestTimeoutMs(job.request.timeoutMs, this.#options.timeoutMs);
317
+ const processResult = yield* runBoundedProcessEffect({
318
+ stage: "scan",
319
+ executable: job.request.executable,
320
+ args,
321
+ cwd: job.request.cwd,
322
+ environment: createProcessEnvironmentWithPath(job.request.path ?? process.env.PATH ?? ""),
323
+ timeoutMs,
324
+ maxOutputBytes: this.#options.maxOutputBytes,
325
+ });
326
+ const afterProcessLifecycle = lifecycleResultFor(job);
327
+ if (afterProcessLifecycle) return afterProcessLifecycle;
328
+
329
+ const processFailure = processFailureResult(
330
+ processResult,
331
+ timeoutMs,
332
+ job.request.reportExitCodes,
333
+ );
334
+ if (processFailure) return processFailure;
335
+ const reportExitCode = deferredReportExitCode(processResult);
336
+
337
+ const report = yield* readBoundedReportEffect(
338
+ workspace.reportPath,
339
+ this.#options.maxReportBytes,
340
+ );
341
+ if (report.status !== "bytes") return reportReadResult(report, reportExitCode);
342
+
343
+ const consumed = yield* consumeReportEffect(
344
+ job.request.consumeReportEffect,
345
+ report.bytes,
346
+ this.#options.reportConsumptionTimeoutMs,
347
+ );
348
+ const afterConsumptionLifecycle = lifecycleResultFor(job);
349
+ if (afterConsumptionLifecycle) return afterConsumptionLifecycle;
350
+ return validateReportExit(consumptionResult(consumed), reportExitCode);
351
+ });
352
+ }
353
+ }
354
+
355
+ function effectServiceFor(owner: DefaultJscpdService): JscpdEffectService {
356
+ return {
357
+ run: (request) => owner.runEffect(request),
358
+ invalidate: () => owner.invalidateEffect(),
359
+ dispose: () => owner.disposeEffect(),
360
+ };
361
+ }
362
+
363
+ function awaitJobTermination(job: EffectJob): Effect.Effect<JscpdRunResult<never>> {
364
+ const current = lifecycleResultFor(job);
365
+ if (current) return Effect.succeed(current);
366
+ return Effect.async((resume) => {
367
+ const terminated = () =>
368
+ resume(Effect.succeed(lifecycleResultFor(job) ?? serviceDisposedResult()));
369
+ job.controller.signal.addEventListener("abort", terminated, { once: true });
370
+ if (job.controller.signal.aborted) terminated();
371
+ return Effect.sync(() => job.controller.signal.removeEventListener("abort", terminated));
372
+ });
373
+ }
374
+
375
+ function resolveServiceOptions(options: JscpdServiceOptions): ResolvedServiceOptions {
376
+ const resolved = {
377
+ timeoutMs: withDefault(options.timeoutMs, JSCPD_EXECUTION_TIMEOUT_MS),
378
+ maxOutputBytes: withDefault(options.maxOutputBytes, JSCPD_MAX_OUTPUT_BYTES),
379
+ maxReportBytes: withDefault(options.maxReportBytes, JSCPD_MAX_REPORT_BYTES),
380
+ reportConsumptionTimeoutMs: withDefault(
381
+ options.reportConsumptionTimeoutMs,
382
+ JSCPD_REPORT_CONSUMPTION_TIMEOUT_MS,
383
+ ),
384
+ temporaryRoot: withDefault(options.temporaryRoot, tmpdir()),
385
+ workspaceCleanupTimeoutMs: withDefault(
386
+ options.workspaceCleanupTimeoutMs,
387
+ JSCPD_WORKSPACE_CLEANUP_TIMEOUT_MS,
388
+ ),
389
+ };
390
+
391
+ assertBoundedOption(resolved.timeoutMs, MAX_CONFIGURED_TIMEOUT_MS);
392
+ assertBoundedOption(resolved.maxOutputBytes, MAX_CONFIGURED_OUTPUT_BYTES);
393
+ assertBoundedOption(resolved.maxReportBytes, MAX_CONFIGURED_REPORT_BYTES);
394
+ assertBoundedOption(resolved.reportConsumptionTimeoutMs, MAX_CONFIGURED_CONSUMPTION_TIMEOUT_MS);
395
+ assertBoundedOption(resolved.workspaceCleanupTimeoutMs, MAX_CONFIGURED_CONSUMPTION_TIMEOUT_MS);
396
+ if (!isSafeAbsolutePath(resolved.temporaryRoot)) throwInvalidServiceOptions();
397
+ return resolved;
398
+ }
399
+
400
+ function withDefault<T>(value: T | undefined, fallback: T): T {
401
+ return value === undefined ? fallback : value;
402
+ }
403
+
404
+ function assertBoundedOption(value: number, maximum: number): void {
405
+ if (!isBoundedPositiveInteger(value, maximum)) {
406
+ throwInvalidServiceOptions();
407
+ }
408
+ }
409
+
410
+ function throwInvalidServiceOptions(): never {
411
+ throw new TypeError("Invalid bounded jscpd service options.");
412
+ }
413
+
414
+ function isValidRunRequest<T>(request: JscpdRunRequest<T>): boolean {
415
+ return (
416
+ request !== null &&
417
+ typeof request === "object" &&
418
+ isSafeBoundedText(request.executable, MAX_PATH_BYTES, false) &&
419
+ isSafeAbsolutePath(request.cwd) &&
420
+ (request.path === undefined || isSafeBoundedText(request.path, MAX_PATH_BYTES, true)) &&
421
+ hasValidRunControls(request) &&
422
+ typeof request.createArguments === "function" &&
423
+ typeof request.consumeReportEffect === "function"
424
+ );
425
+ }
426
+
427
+ function createValidatedArguments(
428
+ request: JscpdRunRequest<unknown>,
429
+ target: JscpdReportTarget,
430
+ ): readonly string[] | undefined {
431
+ let args: readonly string[];
432
+ try {
433
+ args = request.createArguments(Object.freeze({ ...target }));
434
+ } catch {
435
+ return undefined;
436
+ }
437
+ if (!Array.isArray(args) || args.length > MAX_ARGUMENT_COUNT) {
438
+ return undefined;
439
+ }
440
+
441
+ let totalBytes = 0;
442
+ for (const token of args) {
443
+ if (typeof token !== "string" || token.includes("\0")) {
444
+ return undefined;
445
+ }
446
+ const tokenBytes = Buffer.byteLength(token);
447
+ totalBytes += tokenBytes;
448
+ if (tokenBytes > MAX_ARGUMENT_BYTES || totalBytes > MAX_TOTAL_ARGUMENT_BYTES) {
449
+ return undefined;
450
+ }
451
+ }
452
+ return [...args];
453
+ }
454
+
455
+ function createReportWorkspaceEffect(
456
+ cwd: string,
457
+ temporaryRoot: string,
458
+ ): Effect.Effect<WorkspaceResult, never, JscpdFileSystem> {
459
+ return Effect.flatMap(JscpdFileSystem, (filesystem) =>
460
+ filesystem.makeTempDirectory(join(temporaryRoot, TEMPORARY_PREFIX)).pipe(
461
+ Effect.matchEffect({
462
+ onFailure: () =>
463
+ Effect.succeed({ ok: false as const, reason: "temporary-directory" as const }),
464
+ onSuccess: (directory) => validateReportWorkspaceEffect(filesystem, cwd, directory),
465
+ }),
466
+ ),
467
+ );
468
+ }
469
+
470
+ function validateReportWorkspaceEffect(
471
+ filesystem: JscpdFileSystemService,
472
+ cwd: string,
473
+ directory: string,
474
+ ): Effect.Effect<WorkspaceResult> {
475
+ return Effect.all(
476
+ [
477
+ filesystem.canonicalize(directory),
478
+ filesystem.canonicalize(cwd),
479
+ filesystem.metadata(cwd),
480
+ ] as const,
481
+ { concurrency: "unbounded" },
482
+ ).pipe(
483
+ Effect.map(([ownedDirectory, projectDirectory, projectMetadata]) => {
484
+ if (projectMetadata.kind !== "directory" || isPathInside(projectDirectory, ownedDirectory)) {
485
+ return {
486
+ ok: false as const,
487
+ reason: "unsafe-temporary-path" as const,
488
+ cleanupPath: directory,
489
+ };
490
+ }
491
+ const reportPath = resolve(ownedDirectory, JSCPD_STRUCTURED_REPORT_FILE_NAME);
492
+ if (dirname(reportPath) !== ownedDirectory) {
493
+ return {
494
+ ok: false as const,
495
+ reason: "unsafe-temporary-path" as const,
496
+ cleanupPath: directory,
497
+ };
498
+ }
499
+ return {
500
+ ok: true as const,
501
+ workspace: { directory: ownedDirectory, reportPath },
502
+ cleanupPath: ownedDirectory,
503
+ };
504
+ }),
505
+ Effect.catchAll(() =>
506
+ Effect.succeed({
507
+ ok: false as const,
508
+ reason: "temporary-directory" as const,
509
+ cleanupPath: directory,
510
+ }),
511
+ ),
512
+ );
513
+ }
514
+
515
+ function removeWorkspaceEffect(
516
+ directory: string,
517
+ timeoutMs: number,
518
+ ): Effect.Effect<boolean, never, JscpdFileSystem> {
519
+ const cleanup = Effect.flatMap(JscpdFileSystem, (filesystem) =>
520
+ filesystem.remove(directory, true),
521
+ ).pipe(
522
+ Effect.as(true),
523
+ Effect.catchAllCause((cause) =>
524
+ Cause.isInterruptedOnly(cause) ? Effect.interrupt : Effect.succeed(false),
525
+ ),
526
+ Effect.interruptible,
527
+ );
528
+ return cleanup.pipe(
529
+ Effect.timeoutTo({
530
+ duration: timeoutMs,
531
+ onSuccess: (cleaned) => cleaned,
532
+ onTimeout: () => false,
533
+ }),
534
+ );
535
+ }
536
+
537
+ function readBoundedReportEffect(
538
+ reportPath: string,
539
+ maxReportBytes: number,
540
+ ): Effect.Effect<ReportBytesResult, never, JscpdFileSystem> {
541
+ return Effect.flatMap(JscpdFileSystem, (filesystem) =>
542
+ filesystem
543
+ .read({
544
+ path: reportPath,
545
+ maxBytes: maxReportBytes,
546
+ regularFileOnly: true,
547
+ noFollow: true,
548
+ limitSubject: "report",
549
+ })
550
+ .pipe(
551
+ Effect.match({
552
+ onFailure: reportReadFailure,
553
+ onSuccess: (bytes) => ({ status: "bytes" as const, bytes }),
554
+ }),
555
+ ),
556
+ );
557
+ }
558
+
559
+ function reportReadFailure(error: JscpdFileSystemFailure | JscpdLimitExceeded): ReportBytesResult {
560
+ if (error instanceof JscpdLimitExceeded) {
561
+ return { status: "failed", reason: "report-too-large" };
562
+ }
563
+ if (error.reason === "missing") return { status: "no-report" };
564
+ return error.reason === "not-regular" || error.reason === "symlink"
565
+ ? { status: "failed", reason: "invalid-report" }
566
+ : { status: "failed", reason: "report-read-failed" };
567
+ }
568
+
569
+ function consumeReportEffect<T>(
570
+ consumer: JscpdRunRequest<T>["consumeReportEffect"],
571
+ report: Uint8Array,
572
+ timeoutMs: number,
573
+ ): Effect.Effect<ConsumptionResult<T>, never, JscpdFileSystem> {
574
+ return consumer(report).pipe(
575
+ Effect.map(
576
+ (decision): ConsumptionResult<T> =>
577
+ isReportDecision(decision) ? { status: "completed", decision } : { status: "failed" },
578
+ ),
579
+ Effect.timeoutTo({
580
+ duration: timeoutMs,
581
+ onSuccess: (result) => result,
582
+ onTimeout: (): ConsumptionResult<T> => ({ status: "timed-out" }),
583
+ }),
584
+ );
585
+ }
586
+
587
+ function consumptionResult<T>(consumption: ConsumptionResult<T>): JscpdRunResult<T> {
588
+ switch (consumption.status) {
589
+ case "completed":
590
+ return completedConsumptionResult(consumption.decision);
591
+ case "cancelled":
592
+ return { status: "cancelled" };
593
+ case "failed":
594
+ return { status: "failed", reason: "consumer-failed" };
595
+ case "timed-out":
596
+ return { status: "failed", reason: "consumer-timed-out" };
597
+ }
598
+ }
599
+
600
+ function completedConsumptionResult<T>(decision: JscpdReportDecision<T>): JscpdRunResult<T> {
601
+ switch (decision.status) {
602
+ case "accepted":
603
+ return { status: "report", value: decision.value };
604
+ case "no-findings":
605
+ return decision.value === undefined
606
+ ? { status: "no-findings" }
607
+ : { status: "no-findings", value: decision.value };
608
+ case "rejected":
609
+ return {
610
+ status: "failed",
611
+ reason: "invalid-report",
612
+ reportError: decision.reason,
613
+ };
614
+ }
615
+ }
616
+
617
+ function reportReadResult(
618
+ report: Exclude<ReportBytesResult, { status: "bytes" }>,
619
+ reportExitCode: number | undefined,
620
+ ): JscpdRunResult<never> {
621
+ if (report.status === "failed") {
622
+ return { status: "failed", reason: report.reason };
623
+ }
624
+ return reportExitCode === undefined
625
+ ? { status: "no-report" }
626
+ : { status: "failed", reason: "nonzero-exit", exitCode: reportExitCode };
627
+ }
628
+
629
+ function validateReportExit<T>(
630
+ result: JscpdRunResult<T>,
631
+ reportExitCode: number | undefined,
632
+ ): JscpdRunResult<T> {
633
+ return result.status === "no-findings" && reportExitCode !== undefined
634
+ ? { status: "failed", reason: "nonzero-exit", exitCode: reportExitCode }
635
+ : result;
636
+ }
637
+
638
+ function processFailureResult(
639
+ result: BoundedProcessResult,
640
+ timeoutMs: number,
641
+ reportExitCodes: readonly number[] | undefined,
642
+ ): JscpdRunResult<never> | undefined {
643
+ switch (result.status) {
644
+ case "completed":
645
+ return completedProcessFailure(result.exitCode, reportExitCodes);
646
+ case "cancelled":
647
+ return { status: "cancelled" };
648
+ case "timed-out":
649
+ return { status: "timed-out", timeoutMs };
650
+ case "output-limit":
651
+ return { status: "failed", reason: "output-limit" };
652
+ case "invalid-request":
653
+ return { status: "failed", reason: "invalid-request" };
654
+ case "not-found":
655
+ case "spawn-failed":
656
+ return { status: "failed", reason: "spawn-failed" };
657
+ }
658
+ }
659
+
660
+ function completedProcessFailure(
661
+ exitCode: number,
662
+ reportExitCodes: readonly number[] | undefined,
663
+ ): JscpdRunResult<never> | undefined {
664
+ if (exitCode === 0 || reportExitCodes?.includes(exitCode)) {
665
+ return undefined;
666
+ }
667
+ return { status: "failed", reason: "nonzero-exit", exitCode: normalizeExitCode(exitCode) };
668
+ }
669
+
670
+ function deferredReportExitCode(result: BoundedProcessResult): number | undefined {
671
+ return result.status === "completed" && result.exitCode !== 0
672
+ ? normalizeExitCode(result.exitCode)
673
+ : undefined;
674
+ }
675
+
676
+ function lifecycleResultFor(job: EffectJob): JscpdRunResult<never> | undefined {
677
+ switch (job.termination) {
678
+ case "cancelled":
679
+ return { status: "cancelled" };
680
+ case "invalidated":
681
+ return { status: "invalidated" };
682
+ case "disposed":
683
+ return serviceDisposedResult();
684
+ case undefined:
685
+ return undefined;
686
+ }
687
+ }
688
+
689
+ function serviceDisposedResult(): JscpdRunResult<never> {
690
+ return { status: "failed", reason: "service-disposed" };
691
+ }
692
+
693
+ function isReportDecision<T>(value: JscpdReportDecision<T>): value is JscpdReportDecision<T> {
694
+ return (
695
+ value !== null &&
696
+ typeof value === "object" &&
697
+ (value.status === "no-findings" ||
698
+ (value.status === "accepted" && Object.hasOwn(value, "value")) ||
699
+ (value.status === "rejected" && isJscpdReportErrorCode(value.reason)))
700
+ );
701
+ }
702
+
703
+ function requestTimeoutMs(configured: number | undefined, fallback: number): number {
704
+ return configured ?? fallback;
705
+ }
706
+
707
+ function hasValidRunControls(request: JscpdRunRequest<unknown>): boolean {
708
+ return hasValidRunTimeout(request.timeoutMs) && hasValidReportExitCodes(request.reportExitCodes);
709
+ }
710
+
711
+ function hasValidRunTimeout(value: number | undefined): boolean {
712
+ return value === undefined || isBoundedPositiveInteger(value, MAX_CONFIGURED_TIMEOUT_MS);
713
+ }
714
+
715
+ function hasValidReportExitCodes(value: readonly number[] | undefined): boolean {
716
+ return (
717
+ value === undefined ||
718
+ (Array.isArray(value) &&
719
+ value.length <= 8 &&
720
+ value.every((code) => Number.isSafeInteger(code) && code > 0 && code <= 255))
721
+ );
722
+ }
723
+
724
+ function isSafeAbsolutePath(value: string): boolean {
725
+ return isSafeBoundedText(value, MAX_PATH_BYTES, false) && isAbsolute(value);
726
+ }
727
+
728
+ function isSafeBoundedText(value: string, maxBytes: number, allowEmpty: boolean): boolean {
729
+ return (
730
+ typeof value === "string" &&
731
+ (allowEmpty || value.length > 0) &&
732
+ !value.includes("\0") &&
733
+ Buffer.byteLength(value) <= maxBytes
734
+ );
735
+ }
736
+
737
+ function isBoundedPositiveInteger(value: number, maximum: number): boolean {
738
+ return Number.isSafeInteger(value) && value > 0 && value <= maximum;
739
+ }
740
+
741
+ function isPathInside(parent: string, candidate: string): boolean {
742
+ const pathFromParent = relative(parent, candidate);
743
+ return pathFromParent === "" || (!pathFromParent.startsWith("..") && !isAbsolute(pathFromParent));
744
+ }
745
+
746
+ function normalizeExitCode(exitCode: number): number {
747
+ return Number.isSafeInteger(exitCode) ? exitCode : 1;
748
+ }