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,434 @@
1
+ import { Cause, Context, Deferred, Effect, Exit, Fiber, Layer, MutableRef, Scope } from "effect";
2
+ import { JscpdClockLive, jscpdClockLive } from "./effect/clock.js";
3
+ import type { JscpdWorkflowRequirements as JscpdRuntimeRequirements } from "./effect/services.js";
4
+ import { type JscpdClock, JscpdClock as JscpdClockTag } from "./effect/services.js";
5
+ import type { JscpdCommandExecutor, JscpdExecutionResult } from "./types.js";
6
+
7
+ export interface JscpdAutomaticScanContext {
8
+ readonly generation: number;
9
+ readonly signal: AbortSignal;
10
+ /** True only while this run still owns the latest mutation in the active lifecycle scope. */
11
+ readonly isCurrent: () => boolean;
12
+ }
13
+
14
+ export type JscpdAutomaticScanDisposition = "attempted" | "deferred";
15
+
16
+ export type JscpdAutomaticScanEffectTask<R = never, E = never> = (
17
+ context: JscpdAutomaticScanContext,
18
+ ) => Effect.Effect<JscpdAutomaticScanDisposition, E, R>;
19
+
20
+ export type JscpdExplicitRunResult<T> =
21
+ | { readonly status: "completed"; readonly value: T }
22
+ | { readonly status: "closed" };
23
+
24
+ export interface JscpdScanSchedulerSnapshot {
25
+ readonly changedGeneration: number;
26
+ readonly attemptedGeneration: number;
27
+ readonly automatic: "idle" | "pending" | "active";
28
+ readonly closed: boolean;
29
+ }
30
+
31
+ export interface JscpdScanScheduler {
32
+ readonly markChangedEffect: Effect.Effect<number>;
33
+ readonly scheduleAutomaticEffect: <R, E>(
34
+ task: JscpdAutomaticScanEffectTask<R, E>,
35
+ ) => Effect.Effect<boolean, never, R>;
36
+ readonly runExplicitEffect: <T, E, R>(
37
+ task: Effect.Effect<T, E, R>,
38
+ ) => Effect.Effect<JscpdExplicitRunResult<T>, E, R>;
39
+ readonly cancelAutomaticEffect: Effect.Effect<void>;
40
+ readonly resetEffect: Effect.Effect<void>;
41
+ readonly disposeEffect: Effect.Effect<void>;
42
+ readonly snapshotEffect: Effect.Effect<JscpdScanSchedulerSnapshot>;
43
+ }
44
+
45
+ interface JscpdScanSchedulerEffectService {
46
+ readonly markChanged: Effect.Effect<number>;
47
+ readonly requestAutomatic: <R, E>(
48
+ task: JscpdAutomaticScanEffectTask<R, E>,
49
+ ) => Effect.Effect<boolean, never, R>;
50
+ readonly runExplicit: <T, E, R>(
51
+ task: Effect.Effect<T, E, R>,
52
+ ) => Effect.Effect<JscpdExplicitRunResult<T>, E, R>;
53
+ readonly cancelAutomatic: Effect.Effect<void>;
54
+ readonly reset: Effect.Effect<void>;
55
+ readonly dispose: Effect.Effect<void>;
56
+ readonly snapshot: Effect.Effect<JscpdScanSchedulerSnapshot>;
57
+ }
58
+
59
+ export const JscpdScanScheduling = Context.GenericTag<JscpdScanSchedulerEffectService>(
60
+ "pi-jscpd/effect/ScanScheduling",
61
+ );
62
+
63
+ interface PendingAutomaticScan {
64
+ readonly epoch: number;
65
+ readonly generation: number;
66
+ readonly task: JscpdAutomaticScanEffectTask;
67
+ }
68
+
69
+ interface ActiveAutomaticScan extends PendingAutomaticScan {
70
+ readonly controller: AbortController;
71
+ readonly fiber: Fiber.RuntimeFiber<JscpdAutomaticScanDisposition, never>;
72
+ }
73
+
74
+ interface SchedulerState {
75
+ readonly epoch: number;
76
+ readonly changedGeneration: number;
77
+ readonly attemptedGeneration: number;
78
+ readonly pending?: PendingAutomaticScan;
79
+ readonly active?: ActiveAutomaticScan;
80
+ readonly startQueued: boolean;
81
+ readonly closed: boolean;
82
+ }
83
+
84
+ const INITIAL_SCHEDULER_STATE: SchedulerState = Object.freeze({
85
+ epoch: 0,
86
+ changedGeneration: 0,
87
+ attemptedGeneration: 0,
88
+ startQueued: false,
89
+ closed: false,
90
+ });
91
+
92
+ /**
93
+ * Effect owns automatic scheduling and cancellation. Scan processes remain serialized by the
94
+ * jscpd adapter. The queue is bounded to one active and one latest-generation pending request.
95
+ */
96
+ export function createJscpdScanScheduler(
97
+ scope: Scope.CloseableScope,
98
+ clock: JscpdClock = jscpdClockLive,
99
+ ): JscpdScanScheduler {
100
+ const service = scanSchedulerEffectServiceFor(new ScanSchedulerOwner(scope, clock));
101
+ return {
102
+ markChangedEffect: service.markChanged,
103
+ scheduleAutomaticEffect: service.requestAutomatic,
104
+ runExplicitEffect: service.runExplicit,
105
+ cancelAutomaticEffect: service.cancelAutomatic,
106
+ resetEffect: service.reset,
107
+ disposeEffect: service.dispose.pipe(Effect.zipRight(Scope.close(scope, Exit.void))),
108
+ snapshotEffect: service.snapshot,
109
+ };
110
+ }
111
+
112
+ /** Build a scheduler whose background fibers are children of the extension's managed scope. */
113
+ export function createJscpdScanSchedulerLayer(clockLayer = JscpdClockLive) {
114
+ return Layer.scoped(
115
+ JscpdScanScheduling,
116
+ Effect.gen(function* () {
117
+ const scope = yield* Effect.scope;
118
+ const clock = yield* JscpdClockTag;
119
+ const owner = new ScanSchedulerOwner(scope, clock);
120
+ yield* Effect.addFinalizer(() => owner.disposeEffect());
121
+ return scanSchedulerEffectServiceFor(owner);
122
+ }),
123
+ ).pipe(Layer.provide(clockLayer));
124
+ }
125
+
126
+ class ScanSchedulerOwner {
127
+ readonly #scope: Scope.Scope;
128
+ readonly #clock: JscpdClock;
129
+ readonly #state = MutableRef.make<SchedulerState>(INITIAL_SCHEDULER_STATE);
130
+
131
+ constructor(scope: Scope.Scope, clock: JscpdClock) {
132
+ this.#scope = scope;
133
+ this.#clock = clock;
134
+ }
135
+
136
+ markChangedEffect(): Effect.Effect<number> {
137
+ return Effect.suspend(() => {
138
+ const current = MutableRef.get(this.#state);
139
+ if (current.closed) return Effect.succeed(current.changedGeneration);
140
+ if (current.changedGeneration < Number.MAX_SAFE_INTEGER) {
141
+ const changedGeneration = current.changedGeneration + 1;
142
+ MutableRef.set(this.#state, { ...current, changedGeneration });
143
+ return Effect.succeed(changedGeneration);
144
+ }
145
+ return this.cancelAutomaticEffect().pipe(
146
+ Effect.tap(() =>
147
+ Effect.sync(() => {
148
+ const reset = MutableRef.get(this.#state);
149
+ MutableRef.set(this.#state, {
150
+ ...reset,
151
+ changedGeneration: 1,
152
+ attemptedGeneration: 0,
153
+ });
154
+ }),
155
+ ),
156
+ Effect.as(1),
157
+ );
158
+ });
159
+ }
160
+
161
+ requestAutomaticEffect<R, E>(
162
+ task: JscpdAutomaticScanEffectTask<R, E>,
163
+ ): Effect.Effect<boolean, never, R> {
164
+ return Effect.context<R>().pipe(
165
+ Effect.flatMap((context) =>
166
+ Effect.suspend(() => {
167
+ const current = MutableRef.get(this.#state);
168
+ if (current.closed || current.changedGeneration <= current.attemptedGeneration) {
169
+ return Effect.succeed(false);
170
+ }
171
+ const generation = current.changedGeneration;
172
+ if (hasCurrentRequestFor(current, generation)) return Effect.succeed(false);
173
+ const preparedTask: JscpdAutomaticScanEffectTask = (scanContext) =>
174
+ task(scanContext).pipe(
175
+ Effect.provide(context),
176
+ Effect.catchAllCause((cause) =>
177
+ Cause.isInterruptedOnly(cause)
178
+ ? Effect.interrupt
179
+ : Effect.succeed("attempted" as const),
180
+ ),
181
+ );
182
+ MutableRef.set(this.#state, {
183
+ ...current,
184
+ pending: Object.freeze({ epoch: current.epoch, generation, task: preparedTask }),
185
+ });
186
+ return this.queueStartEffect().pipe(Effect.as(true));
187
+ }),
188
+ ),
189
+ );
190
+ }
191
+
192
+ runExplicitEffect<T, E, R>(
193
+ task: Effect.Effect<T, E, R>,
194
+ ): Effect.Effect<JscpdExplicitRunResult<T>, E, R> {
195
+ return Effect.suspend(() => {
196
+ if (MutableRef.get(this.#state).closed) return Effect.succeed(closedExplicitResult());
197
+ return this.cancelAutomaticEffect().pipe(
198
+ Effect.flatMap(() =>
199
+ MutableRef.get(this.#state).closed
200
+ ? Effect.succeed(closedExplicitResult())
201
+ : task.pipe(
202
+ Effect.map((value) => Object.freeze({ status: "completed" as const, value })),
203
+ ),
204
+ ),
205
+ );
206
+ });
207
+ }
208
+
209
+ cancelAutomaticEffect(): Effect.Effect<void> {
210
+ return Effect.suspend(() => {
211
+ const current = MutableRef.get(this.#state);
212
+ current.active?.controller.abort();
213
+ MutableRef.set(this.#state, {
214
+ ...current,
215
+ epoch: current.epoch + 1,
216
+ pending: undefined,
217
+ });
218
+ return current.active ? Fiber.interruptFork(current.active.fiber) : Effect.void;
219
+ });
220
+ }
221
+
222
+ resetEffect(): Effect.Effect<void> {
223
+ return Effect.suspend(() => {
224
+ if (MutableRef.get(this.#state).closed) return Effect.void;
225
+ return this.cancelAutomaticEffect().pipe(
226
+ Effect.tap(() =>
227
+ Effect.sync(() => {
228
+ const current = MutableRef.get(this.#state);
229
+ MutableRef.set(this.#state, {
230
+ ...current,
231
+ changedGeneration: 0,
232
+ attemptedGeneration: 0,
233
+ });
234
+ }),
235
+ ),
236
+ );
237
+ });
238
+ }
239
+
240
+ disposeEffect(): Effect.Effect<void> {
241
+ return Effect.suspend(() => {
242
+ const current = MutableRef.get(this.#state);
243
+ if (current.closed && !current.active) return Effect.void;
244
+ current.active?.controller.abort();
245
+ MutableRef.set(this.#state, {
246
+ ...current,
247
+ epoch: current.epoch + 1,
248
+ pending: undefined,
249
+ closed: true,
250
+ });
251
+ return current.active
252
+ ? Fiber.interrupt(current.active.fiber).pipe(Effect.asVoid)
253
+ : Effect.void;
254
+ });
255
+ }
256
+
257
+ snapshot(): JscpdScanSchedulerSnapshot {
258
+ const state = MutableRef.get(this.#state);
259
+ return Object.freeze({
260
+ changedGeneration: state.changedGeneration,
261
+ attemptedGeneration: state.attemptedGeneration,
262
+ automatic: state.active ? "active" : state.pending ? "pending" : "idle",
263
+ closed: state.closed,
264
+ });
265
+ }
266
+
267
+ private queueStartEffect(): Effect.Effect<void> {
268
+ return Effect.suspend(() => {
269
+ const current = MutableRef.get(this.#state);
270
+ if (current.startQueued || current.closed || current.active || !current.pending) {
271
+ return Effect.void;
272
+ }
273
+ MutableRef.set(this.#state, { ...current, startQueued: true });
274
+ return Effect.forkIn(
275
+ this.#clock.sleep(0).pipe(Effect.zipRight(this.startPendingEffect())),
276
+ this.#scope,
277
+ ).pipe(Effect.asVoid);
278
+ });
279
+ }
280
+
281
+ private startPendingEffect(): Effect.Effect<void> {
282
+ return Effect.suspend(() => {
283
+ const current = MutableRef.get(this.#state);
284
+ MutableRef.set(this.#state, { ...current, startQueued: false });
285
+ const candidate = current.pending;
286
+ if (current.closed || current.active || !candidate) return Effect.void;
287
+ MutableRef.set(this.#state, { ...MutableRef.get(this.#state), pending: undefined });
288
+ if (
289
+ candidate.epoch !== current.epoch ||
290
+ candidate.generation <= current.attemptedGeneration ||
291
+ candidate.generation > current.changedGeneration
292
+ ) {
293
+ return this.queueStartEffect();
294
+ }
295
+ return this.launchAutomaticEffect(candidate);
296
+ });
297
+ }
298
+
299
+ private launchAutomaticEffect(candidate: PendingAutomaticScan): Effect.Effect<void> {
300
+ return Effect.gen(this, function* () {
301
+ const controller = new AbortController();
302
+ const gate = yield* Deferred.make<void>();
303
+ const program = Deferred.await(gate).pipe(
304
+ Effect.zipRight(this.runAutomaticTaskEffect(candidate, controller)),
305
+ );
306
+ const fiber = yield* Effect.forkIn(program, this.#scope);
307
+ const current = MutableRef.get(this.#state);
308
+ if (current.closed || current.epoch !== candidate.epoch) {
309
+ controller.abort();
310
+ yield* Deferred.succeed(gate, undefined);
311
+ yield* Fiber.interruptFork(fiber);
312
+ return;
313
+ }
314
+ MutableRef.set(this.#state, {
315
+ ...current,
316
+ active: Object.freeze({ ...candidate, controller, fiber }),
317
+ });
318
+ yield* Deferred.succeed(gate, undefined);
319
+ });
320
+ }
321
+
322
+ private runAutomaticTaskEffect(
323
+ candidate: PendingAutomaticScan,
324
+ controller: AbortController,
325
+ ): Effect.Effect<JscpdAutomaticScanDisposition> {
326
+ const context = Object.freeze({
327
+ generation: candidate.generation,
328
+ signal: controller.signal,
329
+ isCurrent: () => this.isCurrent(candidate, controller),
330
+ });
331
+ return Effect.suspend(() => candidate.task(context)).pipe(
332
+ Effect.catchAllCause((cause) =>
333
+ Cause.isInterruptedOnly(cause)
334
+ ? Effect.failCause(cause)
335
+ : Effect.succeed("attempted" as const),
336
+ ),
337
+ Effect.onInterrupt(() => Effect.sync(() => controller.abort())),
338
+ Effect.onExit((exit) => this.finishAutomaticEffect(candidate, controller, exit)),
339
+ );
340
+ }
341
+
342
+ private finishAutomaticEffect(
343
+ candidate: PendingAutomaticScan,
344
+ controller: AbortController,
345
+ exit: Exit.Exit<JscpdAutomaticScanDisposition, never>,
346
+ ): Effect.Effect<void> {
347
+ return Effect.suspend(() => {
348
+ const current = MutableRef.get(this.#state);
349
+ if (current.active?.controller !== controller) return Effect.void;
350
+ const disposition = Exit.isSuccess(exit) ? exit.value : undefined;
351
+ const attemptedGeneration =
352
+ disposition === "attempted" &&
353
+ !current.closed &&
354
+ candidate.epoch === current.epoch &&
355
+ !controller.signal.aborted
356
+ ? Math.max(current.attemptedGeneration, candidate.generation)
357
+ : current.attemptedGeneration;
358
+ MutableRef.set(this.#state, {
359
+ ...current,
360
+ active: undefined,
361
+ attemptedGeneration,
362
+ });
363
+ return this.queueStartEffect();
364
+ });
365
+ }
366
+
367
+ private isCurrent(candidate: PendingAutomaticScan, controller: AbortController): boolean {
368
+ const state = MutableRef.get(this.#state);
369
+ return (
370
+ !state.closed &&
371
+ !controller.signal.aborted &&
372
+ candidate.epoch === state.epoch &&
373
+ candidate.generation === state.changedGeneration
374
+ );
375
+ }
376
+ }
377
+
378
+ function hasCurrentRequestFor(state: SchedulerState, generation: number): boolean {
379
+ return (
380
+ (state.active?.epoch === state.epoch && state.active.generation >= generation) ||
381
+ (state.pending?.epoch === state.epoch && state.pending.generation >= generation)
382
+ );
383
+ }
384
+
385
+ function scanSchedulerEffectServiceFor(owner: ScanSchedulerOwner): JscpdScanSchedulerEffectService {
386
+ return {
387
+ markChanged: Effect.suspend(() => owner.markChangedEffect()),
388
+ requestAutomatic: (task) => owner.requestAutomaticEffect(task),
389
+ runExplicit: (task) => owner.runExplicitEffect(task),
390
+ cancelAutomatic: Effect.suspend(() => owner.cancelAutomaticEffect()),
391
+ reset: Effect.suspend(() => owner.resetEffect()),
392
+ dispose: Effect.suspend(() => owner.disposeEffect()),
393
+ snapshot: Effect.sync(() => owner.snapshot()),
394
+ };
395
+ }
396
+
397
+ function closedExplicitResult<T>(): JscpdExplicitRunResult<T> {
398
+ return Object.freeze({ status: "closed" });
399
+ }
400
+
401
+ /** Cancel scheduler-owned automatic work before explicit scan operations. */
402
+ export function createJscpdScheduledExecutor(
403
+ executor: JscpdCommandExecutor,
404
+ scheduler: JscpdScanScheduler,
405
+ ): JscpdCommandExecutor {
406
+ const executeEffect = (
407
+ invocation: Parameters<JscpdCommandExecutor["executeEffect"]>[0],
408
+ context: Parameters<JscpdCommandExecutor["executeEffect"]>[1],
409
+ ): Effect.Effect<JscpdExecutionResult, never, JscpdRuntimeRequirements> => {
410
+ const cancel = invocation.command === "off" ? scheduler.cancelAutomaticEffect : Effect.void;
411
+ const execute = executor.executeEffect(invocation, context);
412
+ if (invocation.command !== "scan" && invocation.command !== "changed") {
413
+ return cancel.pipe(Effect.zipRight(execute));
414
+ }
415
+ const scheduled = scheduler.runExplicitEffect(execute);
416
+ return cancel.pipe(
417
+ Effect.zipRight(scheduled),
418
+ Effect.map((result) =>
419
+ result.status === "completed" ? result.value : schedulerClosedResult(),
420
+ ),
421
+ );
422
+ };
423
+ return {
424
+ executeEffect,
425
+ };
426
+ }
427
+
428
+ function schedulerClosedResult(): JscpdExecutionResult {
429
+ return Object.freeze({
430
+ status: "failed",
431
+ reason: "scan-cancelled",
432
+ message: "The jscpd scan was cancelled because the session is shutting down.",
433
+ });
434
+ }
@@ -0,0 +1,229 @@
1
+ import { Effect } from "effect";
2
+ import {
3
+ emptyJscpdAcknowledgements,
4
+ type JscpdAcknowledgementTracker,
5
+ type JscpdPersistedAcknowledgements,
6
+ parseJscpdAcknowledgements,
7
+ snapshotJscpdAcknowledgements,
8
+ } from "./acknowledgements.js";
9
+ import {
10
+ isSafeChangedFilePath,
11
+ type JscpdChangedFileTracker,
12
+ MAX_CHANGED_FILES,
13
+ } from "./changed-files.js";
14
+ import { JscpdPiPort } from "./effect/services.js";
15
+ import type { JscpdSessionModeService, JscpdStatusService } from "./status.js";
16
+ import type { JscpdLastCheck, JscpdScanFailureReason, JscpdUnavailableReason } from "./types.js";
17
+ import { hasExactKeys, isRecord } from "./value-utils.js";
18
+
19
+ export const JSCPD_SESSION_STATE_TYPE = "pi-jscpd/session-state";
20
+ export const JSCPD_SESSION_STATE_VERSION = 3;
21
+
22
+ export type JscpdSessionModeOverride = "enabled" | "disabled" | null;
23
+
24
+ export interface JscpdPersistedSessionState {
25
+ readonly version: typeof JSCPD_SESSION_STATE_VERSION;
26
+ readonly modeOverride: JscpdSessionModeOverride;
27
+ readonly lastCheck: JscpdLastCheck;
28
+ readonly changedFiles: readonly string[];
29
+ readonly acknowledgements: JscpdPersistedAcknowledgements;
30
+ }
31
+
32
+ const SCAN_FAILURE_REASONS = new Set<JscpdScanFailureReason>([
33
+ "unsafe-path",
34
+ "unsupported-path",
35
+ "scan-cancelled",
36
+ "scan-timed-out",
37
+ "process-failed",
38
+ "missing-report",
39
+ "malformed-report",
40
+ "incompatible-report",
41
+ "invalid-report",
42
+ "cleanup-failed",
43
+ ]);
44
+
45
+ const UNAVAILABLE_REASONS = new Set<JscpdUnavailableReason>([
46
+ "disabled",
47
+ "missing-binary",
48
+ "incompatible-version",
49
+ "probe-cancelled",
50
+ "probe-timed-out",
51
+ "probe-failed",
52
+ ]);
53
+
54
+ const MAX_RESTORED_CLONES = 1_000;
55
+
56
+ /** Build the only extension state written to Pi's branch-local custom entries. */
57
+ export function snapshotJscpdSessionState(
58
+ mode: JscpdSessionModeService,
59
+ status: JscpdStatusService,
60
+ changedFiles: JscpdChangedFileTracker,
61
+ acknowledgements: JscpdAcknowledgementTracker,
62
+ ): JscpdPersistedSessionState {
63
+ return Object.freeze({
64
+ version: JSCPD_SESSION_STATE_VERSION,
65
+ modeOverride: mode.override(),
66
+ lastCheck: status.lastCheck(),
67
+ changedFiles: Object.freeze([...changedFiles.files()]),
68
+ acknowledgements: snapshotJscpdAcknowledgements(acknowledgements),
69
+ });
70
+ }
71
+
72
+ /** Persist one already-bounded snapshot through the injected branch-local Pi port. */
73
+ export function persistJscpdSessionStateEffect(state: JscpdPersistedSessionState) {
74
+ return Effect.flatMap(JscpdPiPort, (pi) =>
75
+ pi.appendSessionEntry(JSCPD_SESSION_STATE_TYPE, state),
76
+ );
77
+ }
78
+
79
+ /**
80
+ * Restore only the latest pi-jscpd entry on the supplied active branch.
81
+ * A malformed or unsupported latest entry resets state instead of reviving an older snapshot.
82
+ */
83
+ export function restoreJscpdSessionState(
84
+ activeBranch: readonly unknown[],
85
+ ): JscpdPersistedSessionState | undefined {
86
+ for (let index = activeBranch.length - 1; index >= 0; index -= 1) {
87
+ const entry = activeBranch[index];
88
+ if (!isJscpdCustomEntry(entry)) continue;
89
+ return parsePersistedState(entry.data);
90
+ }
91
+ return undefined;
92
+ }
93
+
94
+ function isJscpdCustomEntry(
95
+ value: unknown,
96
+ ): value is { type: "custom"; customType: typeof JSCPD_SESSION_STATE_TYPE; data: unknown } {
97
+ if (!isRecord(value)) return false;
98
+ return value.type === "custom" && value.customType === JSCPD_SESSION_STATE_TYPE;
99
+ }
100
+
101
+ function parsePersistedState(value: unknown): JscpdPersistedSessionState | undefined {
102
+ if (!isRecord(value)) return undefined;
103
+ if (value.version === 1) return migrateVersionOneState(value);
104
+ if (value.version === 2) return migrateVersionTwoState(value);
105
+ if (value.version !== JSCPD_SESSION_STATE_VERSION) return undefined;
106
+ if (
107
+ !hasExactKeys(value, [
108
+ "version",
109
+ "modeOverride",
110
+ "lastCheck",
111
+ "changedFiles",
112
+ "acknowledgements",
113
+ ])
114
+ ) {
115
+ return undefined;
116
+ }
117
+ return parseStateFields(
118
+ value.modeOverride,
119
+ value.lastCheck,
120
+ value.changedFiles,
121
+ value.acknowledgements,
122
+ );
123
+ }
124
+
125
+ /** Preserve pre-M3 session controls/status while starting changed-file attribution empty. */
126
+ function migrateVersionOneState(value: unknown): JscpdPersistedSessionState | undefined {
127
+ if (!hasExactKeys(value, ["version", "modeOverride", "lastCheck"]) || value.version !== 1) {
128
+ return undefined;
129
+ }
130
+ return parseStateFields(value.modeOverride, value.lastCheck, [], emptyJscpdAcknowledgements());
131
+ }
132
+
133
+ /** Preserve M3 changed-file attribution while starting acknowledgement identity v1 empty. */
134
+ function migrateVersionTwoState(value: unknown): JscpdPersistedSessionState | undefined {
135
+ if (
136
+ !hasExactKeys(value, ["version", "modeOverride", "lastCheck", "changedFiles"]) ||
137
+ value.version !== 2
138
+ ) {
139
+ return undefined;
140
+ }
141
+ return parseStateFields(
142
+ value.modeOverride,
143
+ value.lastCheck,
144
+ value.changedFiles,
145
+ emptyJscpdAcknowledgements(),
146
+ );
147
+ }
148
+
149
+ function parseStateFields(
150
+ modeOverride: unknown,
151
+ lastCheckValue: unknown,
152
+ changedFilesValue: unknown,
153
+ acknowledgementsValue: unknown,
154
+ ): JscpdPersistedSessionState | undefined {
155
+ if (modeOverride !== null && modeOverride !== "enabled" && modeOverride !== "disabled") {
156
+ return undefined;
157
+ }
158
+ const lastCheck = parseLastCheck(lastCheckValue);
159
+ const changedFiles = parseChangedFiles(changedFilesValue);
160
+ const acknowledgements = parseJscpdAcknowledgements(acknowledgementsValue);
161
+ if (!lastCheck || !changedFiles || !acknowledgements) return undefined;
162
+ return Object.freeze({
163
+ version: JSCPD_SESSION_STATE_VERSION,
164
+ modeOverride,
165
+ lastCheck,
166
+ changedFiles,
167
+ acknowledgements,
168
+ });
169
+ }
170
+
171
+ function parseChangedFiles(value: unknown): readonly string[] | undefined {
172
+ if (!Array.isArray(value) || value.length > MAX_CHANGED_FILES) return undefined;
173
+ const files: string[] = [];
174
+ const seen = new Set<string>();
175
+ for (const path of value) {
176
+ if (!isSafeChangedFilePath(path) || seen.has(path)) return undefined;
177
+ seen.add(path);
178
+ files.push(path);
179
+ }
180
+ return Object.freeze(files);
181
+ }
182
+
183
+ function parseLastCheck(value: unknown): JscpdLastCheck | undefined {
184
+ if (!isRecord(value)) return undefined;
185
+ switch (value.state) {
186
+ case "never":
187
+ case "clean":
188
+ case "cancelled":
189
+ return parseSimpleLastCheck(value, value.state);
190
+ case "findings":
191
+ return parseFindingsLastCheck(value);
192
+ case "failed":
193
+ return parseFailedLastCheck(value);
194
+ default:
195
+ return undefined;
196
+ }
197
+ }
198
+
199
+ function parseSimpleLastCheck(
200
+ value: unknown,
201
+ state: "never" | "clean" | "cancelled",
202
+ ): JscpdLastCheck | undefined {
203
+ if (!hasExactKeys(value, ["state"])) return undefined;
204
+ return Object.freeze({ state });
205
+ }
206
+
207
+ function parseFindingsLastCheck(value: unknown): JscpdLastCheck | undefined {
208
+ if (!hasExactKeys(value, ["state", "clones"])) return undefined;
209
+ if (!isRestorableCloneCount(value.clones)) return undefined;
210
+ return Object.freeze({ state: "findings", clones: value.clones });
211
+ }
212
+
213
+ function parseFailedLastCheck(value: unknown): JscpdLastCheck | undefined {
214
+ if (!hasExactKeys(value, ["state", "reason"])) return undefined;
215
+ if (!isFailureReason(value.reason)) return undefined;
216
+ return Object.freeze({ state: "failed", reason: value.reason });
217
+ }
218
+
219
+ function isRestorableCloneCount(value: unknown): value is number {
220
+ return Number.isSafeInteger(value) && Number(value) >= 0 && Number(value) <= MAX_RESTORED_CLONES;
221
+ }
222
+
223
+ function isFailureReason(value: unknown): value is JscpdScanFailureReason | JscpdUnavailableReason {
224
+ if (typeof value !== "string") return false;
225
+ return (
226
+ SCAN_FAILURE_REASONS.has(value as JscpdScanFailureReason) ||
227
+ UNAVAILABLE_REASONS.has(value as JscpdUnavailableReason)
228
+ );
229
+ }