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.
- package/CHANGELOG.md +99 -0
- package/CONTRIBUTING.md +144 -0
- package/LICENSE +21 -0
- package/README.md +231 -0
- package/SECURITY.md +93 -0
- package/docs/automatic-checkpoint.md +235 -0
- package/docs/compatibility.md +119 -0
- package/docs/effect-architecture.md +128 -0
- package/docs/fallow-coexistence.md +120 -0
- package/docs/overlay-interaction.md +347 -0
- package/docs/release.md +115 -0
- package/package.json +86 -0
- package/scripts/check-compatibility.mjs +103 -0
- package/skills/jscpd/SKILL.md +90 -0
- package/src/acknowledgements.ts +268 -0
- package/src/automatic.ts +396 -0
- package/src/baseline.ts +400 -0
- package/src/capability.ts +569 -0
- package/src/changed-files.ts +372 -0
- package/src/changed.ts +548 -0
- package/src/clone-identity.ts +373 -0
- package/src/config.ts +414 -0
- package/src/contract.ts +39 -0
- package/src/dispatch.ts +90 -0
- package/src/effect/clock.ts +10 -0
- package/src/effect/errors.ts +311 -0
- package/src/effect/filesystem.ts +240 -0
- package/src/effect/runtime-boundary.ts +25 -0
- package/src/effect/runtime-contract.ts +18 -0
- package/src/effect/services.ts +131 -0
- package/src/extension.ts +708 -0
- package/src/fallow.ts +479 -0
- package/src/finding-presentation.ts +73 -0
- package/src/index.ts +8 -0
- package/src/jscpd-report.ts +819 -0
- package/src/jscpd.ts +748 -0
- package/src/overlay.ts +1166 -0
- package/src/parser.ts +189 -0
- package/src/path-utils.ts +44 -0
- package/src/presentation.ts +232 -0
- package/src/process.ts +425 -0
- package/src/registry.ts +102 -0
- package/src/scan.ts +441 -0
- package/src/scheduler.ts +434 -0
- package/src/session-state.ts +229 -0
- package/src/status.ts +534 -0
- package/src/types.ts +334 -0
- package/src/value-utils.ts +14 -0
- package/src/verification.ts +220 -0
package/src/extension.ts
ADDED
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionContext,
|
|
4
|
+
RegisteredCommand,
|
|
5
|
+
ToolDefinition,
|
|
6
|
+
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { Effect } from "effect";
|
|
8
|
+
import { createJscpdAcknowledgementTracker } from "./acknowledgements.js";
|
|
9
|
+
import {
|
|
10
|
+
boundedJscpdAutomaticFindingLimit,
|
|
11
|
+
createJscpdAutomaticAcknowledgementTransaction,
|
|
12
|
+
createJscpdAutomaticCheck,
|
|
13
|
+
createJscpdAutomaticResultEffectActions,
|
|
14
|
+
handleJscpdAutomaticResultEffect,
|
|
15
|
+
JSCPD_AUTOMATIC_MESSAGE_TYPE,
|
|
16
|
+
JSCPD_AUTOMATIC_STATUS_KEY,
|
|
17
|
+
type JscpdAutomaticAcknowledgementTransaction,
|
|
18
|
+
type JscpdAutomaticCheck,
|
|
19
|
+
type JscpdAutomaticResultActions,
|
|
20
|
+
} from "./automatic.js";
|
|
21
|
+
import {
|
|
22
|
+
createJscpdBaselineService,
|
|
23
|
+
type JscpdBaselineService,
|
|
24
|
+
type JscpdBaselineStartContext,
|
|
25
|
+
} from "./baseline.js";
|
|
26
|
+
import { createJscpdCapabilityService, type JscpdCapabilityService } from "./capability.js";
|
|
27
|
+
import { createJscpdChangedExecutor } from "./changed.js";
|
|
28
|
+
import { createJscpdChangedFileTracker, type JscpdChangedFileTracker } from "./changed-files.js";
|
|
29
|
+
import { createJscpdConfigService, type JscpdConfigService } from "./config.js";
|
|
30
|
+
import { type jscpdRunParams, jscpdToolContract } from "./contract.js";
|
|
31
|
+
import { dispatchJscpdCommand } from "./dispatch.js";
|
|
32
|
+
import {
|
|
33
|
+
createJscpdManagedRuntime,
|
|
34
|
+
type JscpdEffectRuntime,
|
|
35
|
+
makeEffectScope,
|
|
36
|
+
} from "./effect/runtime-boundary.js";
|
|
37
|
+
import {
|
|
38
|
+
createJscpdFallowCoexistenceService,
|
|
39
|
+
type JscpdFallowCoexistenceService,
|
|
40
|
+
} from "./fallow.js";
|
|
41
|
+
import { createJscpdService, type JscpdService } from "./jscpd.js";
|
|
42
|
+
import {
|
|
43
|
+
createJscpdOverlayLauncher,
|
|
44
|
+
type JscpdOverlayExecutor,
|
|
45
|
+
type JscpdOverlayLauncher,
|
|
46
|
+
} from "./overlay.js";
|
|
47
|
+
import { parseJscpdSlashArgs } from "./parser.js";
|
|
48
|
+
import {
|
|
49
|
+
getJscpdArgumentCompletions,
|
|
50
|
+
getJscpdRootCommandCompletions,
|
|
51
|
+
jscpdArgumentHint,
|
|
52
|
+
} from "./registry.js";
|
|
53
|
+
import { createJscpdScanExecutor } from "./scan.js";
|
|
54
|
+
import {
|
|
55
|
+
createJscpdScanScheduler,
|
|
56
|
+
createJscpdScheduledExecutor,
|
|
57
|
+
type JscpdScanScheduler,
|
|
58
|
+
} from "./scheduler.js";
|
|
59
|
+
import {
|
|
60
|
+
JSCPD_SESSION_STATE_TYPE,
|
|
61
|
+
restoreJscpdSessionState,
|
|
62
|
+
snapshotJscpdSessionState,
|
|
63
|
+
} from "./session-state.js";
|
|
64
|
+
import {
|
|
65
|
+
createJscpdSessionModeService,
|
|
66
|
+
createJscpdStatusAwareExecutor,
|
|
67
|
+
createJscpdStatusService,
|
|
68
|
+
type JscpdSessionModeService,
|
|
69
|
+
type JscpdStatusService,
|
|
70
|
+
} from "./status.js";
|
|
71
|
+
import type { JscpdCommandExecutor, JscpdDispatchResult } from "./types.js";
|
|
72
|
+
import { createJscpdVerificationService, type JscpdVerificationService } from "./verification.js";
|
|
73
|
+
|
|
74
|
+
type JscpdToolDefinition = ToolDefinition<typeof jscpdRunParams, JscpdDispatchResult>;
|
|
75
|
+
|
|
76
|
+
type JscpdSlashCommandDefinition = Omit<RegisteredCommand, "name" | "sourceInfo"> & {
|
|
77
|
+
argumentHint: string;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export interface JscpdExtensionOptions {
|
|
81
|
+
/** Isolated host seam for runtime lifecycle tests; production creates one managed runtime. */
|
|
82
|
+
runtime?: JscpdEffectRuntime;
|
|
83
|
+
executor?: JscpdCommandExecutor;
|
|
84
|
+
capabilityService?: JscpdCapabilityService;
|
|
85
|
+
adapterService?: JscpdService;
|
|
86
|
+
configService?: JscpdConfigService;
|
|
87
|
+
baselineService?: JscpdBaselineService;
|
|
88
|
+
scheduler?: JscpdScanScheduler;
|
|
89
|
+
automaticCheck?: JscpdAutomaticCheck;
|
|
90
|
+
overlayLauncher?: JscpdOverlayLauncher;
|
|
91
|
+
verificationService?: JscpdVerificationService;
|
|
92
|
+
fallowCoexistenceService?: JscpdFallowCoexistenceService;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function registerJscpdExtension(
|
|
96
|
+
pi: ExtensionAPI,
|
|
97
|
+
options: JscpdExtensionOptions = {},
|
|
98
|
+
): void {
|
|
99
|
+
const runtime = options.runtime ?? createJscpdManagedRuntime();
|
|
100
|
+
let capabilityService = options.capabilityService;
|
|
101
|
+
let executor = options.executor;
|
|
102
|
+
let statusService: JscpdStatusService | undefined;
|
|
103
|
+
let sessionMode: JscpdSessionModeService | undefined;
|
|
104
|
+
let baselineService = options.baselineService;
|
|
105
|
+
let automaticCheck = options.automaticCheck;
|
|
106
|
+
let verificationService = options.verificationService;
|
|
107
|
+
let automaticAcknowledgements: JscpdAutomaticAcknowledgementTransaction | undefined;
|
|
108
|
+
let baselineContext: JscpdBaselineStartContext | undefined;
|
|
109
|
+
let baselineSettlement = Promise.resolve();
|
|
110
|
+
let shutdownPromise: Promise<void> | undefined;
|
|
111
|
+
let persistSessionState = () => {};
|
|
112
|
+
const changedFiles = createJscpdChangedFileTracker();
|
|
113
|
+
const acknowledgements = createJscpdAcknowledgementTracker();
|
|
114
|
+
const scheduler = options.scheduler ?? createJscpdScanScheduler(makeEffectScope(runtime));
|
|
115
|
+
const adapterService = options.adapterService ?? createJscpdService();
|
|
116
|
+
const configService = options.configService ?? createJscpdConfigService();
|
|
117
|
+
const fallowCoexistence =
|
|
118
|
+
options.fallowCoexistenceService ?? createJscpdFallowCoexistenceService();
|
|
119
|
+
const startOwnedBaseline = (context: JscpdBaselineStartContext): void => {
|
|
120
|
+
const started = startBaselineQuietly(runtime, baselineService, context);
|
|
121
|
+
baselineSettlement = Promise.all([baselineSettlement, started]).then(() => undefined);
|
|
122
|
+
};
|
|
123
|
+
if (!executor) {
|
|
124
|
+
capabilityService ??= createJscpdCapabilityService();
|
|
125
|
+
verificationService ??= createJscpdVerificationService();
|
|
126
|
+
sessionMode = createJscpdSessionModeService();
|
|
127
|
+
const scanExecutor = createJscpdScanExecutor(capabilityService, adapterService, {
|
|
128
|
+
config: () => ({
|
|
129
|
+
...configService.current().config,
|
|
130
|
+
enabled: sessionMode?.isEnabled() ?? true,
|
|
131
|
+
}),
|
|
132
|
+
verification: verificationService,
|
|
133
|
+
});
|
|
134
|
+
statusService = createJscpdStatusService(
|
|
135
|
+
capabilityService,
|
|
136
|
+
configService,
|
|
137
|
+
sessionMode,
|
|
138
|
+
fallowCoexistence,
|
|
139
|
+
);
|
|
140
|
+
baselineService ??= createJscpdBaselineService(capabilityService, adapterService);
|
|
141
|
+
persistSessionState = () => {
|
|
142
|
+
if (!sessionMode || !statusService) return;
|
|
143
|
+
try {
|
|
144
|
+
pi.appendEntry(
|
|
145
|
+
JSCPD_SESSION_STATE_TYPE,
|
|
146
|
+
snapshotJscpdSessionState(sessionMode, statusService, changedFiles, acknowledgements),
|
|
147
|
+
);
|
|
148
|
+
} catch {
|
|
149
|
+
// Pi session persistence is advisory and must not break scans or controls.
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
const changedOptions = {
|
|
153
|
+
config: () => ({
|
|
154
|
+
...configService.current().config,
|
|
155
|
+
enabled: sessionMode?.isEnabled() ?? true,
|
|
156
|
+
}),
|
|
157
|
+
verification: verificationService,
|
|
158
|
+
};
|
|
159
|
+
const changedExecutor = createJscpdChangedExecutor(
|
|
160
|
+
capabilityService,
|
|
161
|
+
adapterService,
|
|
162
|
+
baselineService,
|
|
163
|
+
changedFiles,
|
|
164
|
+
acknowledgements,
|
|
165
|
+
changedOptions,
|
|
166
|
+
);
|
|
167
|
+
automaticAcknowledgements = createJscpdAutomaticAcknowledgementTransaction(acknowledgements);
|
|
168
|
+
automaticCheck ??= createJscpdAutomaticCheck(
|
|
169
|
+
createJscpdChangedExecutor(
|
|
170
|
+
capabilityService,
|
|
171
|
+
adapterService,
|
|
172
|
+
baselineService,
|
|
173
|
+
changedFiles,
|
|
174
|
+
automaticAcknowledgements.tracker,
|
|
175
|
+
{
|
|
176
|
+
config: () => {
|
|
177
|
+
const config = changedOptions.config();
|
|
178
|
+
return {
|
|
179
|
+
...config,
|
|
180
|
+
maxFindings: boundedJscpdAutomaticFindingLimit(config.maxFindings),
|
|
181
|
+
};
|
|
182
|
+
},
|
|
183
|
+
prioritizeFindings: true,
|
|
184
|
+
},
|
|
185
|
+
),
|
|
186
|
+
{ beforeRun: Effect.sync(automaticAcknowledgements.discard) },
|
|
187
|
+
);
|
|
188
|
+
executor = createJscpdStatusAwareExecutor(
|
|
189
|
+
scanExecutor,
|
|
190
|
+
statusService,
|
|
191
|
+
sessionMode,
|
|
192
|
+
() => {
|
|
193
|
+
persistSessionState();
|
|
194
|
+
synchronizeBaselineMode(baselineService, baselineContext, sessionMode, startOwnedBaseline);
|
|
195
|
+
},
|
|
196
|
+
changedExecutor,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
executor = createJscpdScheduledExecutor(executor, scheduler);
|
|
200
|
+
const overlayLauncher =
|
|
201
|
+
options.overlayLauncher ??
|
|
202
|
+
createJscpdOverlayLauncher(overlayExecutor(executor, runtime), {
|
|
203
|
+
changedFileCount: () => changedFiles.files().length,
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
pi.registerTool(createJscpdToolDefinition(executor, runtime));
|
|
207
|
+
pi.registerCommand(
|
|
208
|
+
"jscpd",
|
|
209
|
+
createJscpdSlashCommandDefinition(executor, overlayLauncher, runtime),
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
213
|
+
if (ctx.mode === "tui") installJscpdAutocompleteProvider(ctx.ui);
|
|
214
|
+
runtime.runSync(scheduler.resetEffect);
|
|
215
|
+
fallowCoexistence.reset();
|
|
216
|
+
verificationService?.reset();
|
|
217
|
+
baselineService?.invalidate();
|
|
218
|
+
capabilityService?.invalidate();
|
|
219
|
+
adapterService.invalidate();
|
|
220
|
+
await baselineSettlement;
|
|
221
|
+
const trusted = ctx.isProjectTrusted();
|
|
222
|
+
const loaded = await runtime.runPromise(configService.loadEffect({ cwd: ctx.cwd, trusted }));
|
|
223
|
+
await runtime.runPromise(
|
|
224
|
+
fallowCoexistence.evaluateEffect({
|
|
225
|
+
cwd: ctx.cwd,
|
|
226
|
+
trusted,
|
|
227
|
+
policy: loaded.config.fallowCoexistence,
|
|
228
|
+
fallowToolAvailable: hasFallowTool(pi),
|
|
229
|
+
}),
|
|
230
|
+
);
|
|
231
|
+
await restoreSessionState(
|
|
232
|
+
runtime,
|
|
233
|
+
ctx.sessionManager.getBranch(),
|
|
234
|
+
ctx.cwd,
|
|
235
|
+
loaded.config.enabled,
|
|
236
|
+
changedFiles,
|
|
237
|
+
acknowledgements,
|
|
238
|
+
sessionMode,
|
|
239
|
+
statusService,
|
|
240
|
+
);
|
|
241
|
+
baselineContext = createBaselineContext(
|
|
242
|
+
ctx.cwd,
|
|
243
|
+
loaded.config.timeoutMs,
|
|
244
|
+
changedFiles,
|
|
245
|
+
sessionMode,
|
|
246
|
+
);
|
|
247
|
+
startOwnedBaseline(baselineContext);
|
|
248
|
+
if (ctx.hasUI) {
|
|
249
|
+
safeSetStatus(ctx.ui, undefined);
|
|
250
|
+
for (const diagnostic of loaded.diagnostics) {
|
|
251
|
+
ctx.ui.notify(diagnostic.message, "warning");
|
|
252
|
+
}
|
|
253
|
+
const overlapNotice = fallowCoexistence.takeNotice();
|
|
254
|
+
if (overlapNotice) ctx.ui.notify(overlapNotice, "info");
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
pi.on("session_tree", async (_event, ctx) => {
|
|
258
|
+
runtime.runSync(scheduler.resetEffect);
|
|
259
|
+
verificationService?.reset();
|
|
260
|
+
baselineService?.invalidate();
|
|
261
|
+
adapterService.invalidate();
|
|
262
|
+
await baselineSettlement;
|
|
263
|
+
const config = configService.current().config;
|
|
264
|
+
await runtime.runPromise(
|
|
265
|
+
fallowCoexistence.evaluateEffect({
|
|
266
|
+
cwd: ctx.cwd,
|
|
267
|
+
trusted: configService.current().trusted,
|
|
268
|
+
policy: config.fallowCoexistence,
|
|
269
|
+
fallowToolAvailable: hasFallowTool(pi),
|
|
270
|
+
}),
|
|
271
|
+
);
|
|
272
|
+
await restoreSessionState(
|
|
273
|
+
runtime,
|
|
274
|
+
ctx.sessionManager.getBranch(),
|
|
275
|
+
ctx.cwd,
|
|
276
|
+
config.enabled,
|
|
277
|
+
changedFiles,
|
|
278
|
+
acknowledgements,
|
|
279
|
+
sessionMode,
|
|
280
|
+
statusService,
|
|
281
|
+
);
|
|
282
|
+
baselineContext = createBaselineContext(ctx.cwd, config.timeoutMs, changedFiles, sessionMode);
|
|
283
|
+
startOwnedBaseline(baselineContext);
|
|
284
|
+
if (ctx.hasUI) {
|
|
285
|
+
safeSetStatus(ctx.ui, undefined);
|
|
286
|
+
const overlapNotice = fallowCoexistence.takeNotice();
|
|
287
|
+
if (overlapNotice) ctx.ui.notify(overlapNotice, "info");
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
pi.on("session_before_switch", async () => {
|
|
291
|
+
runtime.runSync(scheduler.resetEffect);
|
|
292
|
+
fallowCoexistence.reset();
|
|
293
|
+
verificationService?.reset();
|
|
294
|
+
baselineContext = undefined;
|
|
295
|
+
baselineService?.invalidate();
|
|
296
|
+
changedFiles.reset();
|
|
297
|
+
acknowledgements.reset();
|
|
298
|
+
capabilityService?.invalidate();
|
|
299
|
+
adapterService.invalidate();
|
|
300
|
+
await baselineSettlement;
|
|
301
|
+
});
|
|
302
|
+
pi.on("before_agent_start", (_event, ctx) => {
|
|
303
|
+
runtime.runSync(scheduler.cancelAutomaticEffect);
|
|
304
|
+
const snapshot = runtime.runSync(scheduler.snapshotEffect);
|
|
305
|
+
if (ctx.hasUI && snapshot.changedGeneration > snapshot.attemptedGeneration) {
|
|
306
|
+
safeSetStatus(ctx.ui, pendingAutomaticStatus(fallowCoexistence));
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
pi.on("tool_call", async (event) => {
|
|
310
|
+
if (!isBuiltInMutationTool(pi, event.toolName)) return;
|
|
311
|
+
if (baselineService) await runtime.runPromise(baselineService.waitEffect);
|
|
312
|
+
});
|
|
313
|
+
pi.on("tool_result", async (event, ctx) => {
|
|
314
|
+
if (!isBuiltInMutationTool(pi, event.toolName)) return;
|
|
315
|
+
try {
|
|
316
|
+
const previouslyTracked = new Set(changedFiles.files());
|
|
317
|
+
const path = await runtime.runPromise(
|
|
318
|
+
changedFiles.recordToolResultPathEffect(event, ctx.cwd),
|
|
319
|
+
);
|
|
320
|
+
if (path) {
|
|
321
|
+
runtime.runSync(scheduler.markChangedEffect);
|
|
322
|
+
if (ctx.hasUI) safeSetStatus(ctx.ui, pendingAutomaticStatus(fallowCoexistence));
|
|
323
|
+
const acknowledgementChanged = acknowledgements.invalidatePaths([path]);
|
|
324
|
+
baselineContext = baselineContext
|
|
325
|
+
? Object.freeze({ ...baselineContext, hasPriorChanges: true })
|
|
326
|
+
: undefined;
|
|
327
|
+
if (!previouslyTracked.has(path) || acknowledgementChanged) persistSessionState();
|
|
328
|
+
}
|
|
329
|
+
} catch {
|
|
330
|
+
// Changed-file attribution is advisory and must not affect tool completion.
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
pi.on("agent_settled", (_event, ctx) => {
|
|
334
|
+
if (
|
|
335
|
+
!automaticCheck ||
|
|
336
|
+
!fallowCoexistence.automaticAllowed() ||
|
|
337
|
+
!ctx.isIdle() ||
|
|
338
|
+
ctx.hasPendingMessages()
|
|
339
|
+
)
|
|
340
|
+
return;
|
|
341
|
+
requestAutomaticCheck(
|
|
342
|
+
runtime,
|
|
343
|
+
pi,
|
|
344
|
+
ctx,
|
|
345
|
+
scheduler,
|
|
346
|
+
automaticCheck,
|
|
347
|
+
automaticAcknowledgements,
|
|
348
|
+
statusService,
|
|
349
|
+
persistSessionState,
|
|
350
|
+
);
|
|
351
|
+
});
|
|
352
|
+
pi.on("session_shutdown", () => {
|
|
353
|
+
baselineContext = undefined;
|
|
354
|
+
fallowCoexistence.reset();
|
|
355
|
+
verificationService?.reset();
|
|
356
|
+
baselineService?.invalidate();
|
|
357
|
+
shutdownPromise ??= Promise.resolve().then(async () => {
|
|
358
|
+
try {
|
|
359
|
+
await baselineSettlement;
|
|
360
|
+
await runtime.runPromise(scheduler.disposeEffect);
|
|
361
|
+
capabilityService?.dispose();
|
|
362
|
+
await runtime.runPromise(adapterService.disposeEffect());
|
|
363
|
+
} finally {
|
|
364
|
+
await runtime.dispose();
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
return shutdownPromise;
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function requestAutomaticCheck(
|
|
372
|
+
runtime: JscpdEffectRuntime,
|
|
373
|
+
pi: ExtensionAPI,
|
|
374
|
+
ctx: ExtensionContext,
|
|
375
|
+
scheduler: JscpdScanScheduler,
|
|
376
|
+
automaticCheck: JscpdAutomaticCheck,
|
|
377
|
+
acknowledgements: JscpdAutomaticAcknowledgementTransaction | undefined,
|
|
378
|
+
status: JscpdStatusService | undefined,
|
|
379
|
+
persist: () => void,
|
|
380
|
+
): void {
|
|
381
|
+
const cwd = ctx.cwd;
|
|
382
|
+
runtime.runSync(
|
|
383
|
+
scheduler.scheduleAutomaticEffect(({ signal, isCurrent }) => {
|
|
384
|
+
const actions = createAutomaticDeliveryActions(
|
|
385
|
+
pi,
|
|
386
|
+
ctx,
|
|
387
|
+
isCurrent,
|
|
388
|
+
acknowledgements,
|
|
389
|
+
status,
|
|
390
|
+
persist,
|
|
391
|
+
);
|
|
392
|
+
return Effect.sync(() => setAutomaticCheckingStatus(ctx, isCurrent)).pipe(
|
|
393
|
+
Effect.zipRight(
|
|
394
|
+
automaticCheck.runEffect({
|
|
395
|
+
cwd,
|
|
396
|
+
signal,
|
|
397
|
+
isCurrent,
|
|
398
|
+
onResult: actions
|
|
399
|
+
? (result) =>
|
|
400
|
+
handleJscpdAutomaticResultEffect(
|
|
401
|
+
result,
|
|
402
|
+
createJscpdAutomaticResultEffectActions(actions),
|
|
403
|
+
)
|
|
404
|
+
: undefined,
|
|
405
|
+
}),
|
|
406
|
+
),
|
|
407
|
+
Effect.tap((disposition) =>
|
|
408
|
+
Effect.sync(() => restoreDeferredAutomaticStatus(ctx, isCurrent, disposition)),
|
|
409
|
+
),
|
|
410
|
+
);
|
|
411
|
+
}),
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function createAutomaticDeliveryActions(
|
|
416
|
+
pi: ExtensionAPI,
|
|
417
|
+
ctx: ExtensionContext,
|
|
418
|
+
isCurrent: () => boolean,
|
|
419
|
+
acknowledgements: JscpdAutomaticAcknowledgementTransaction | undefined,
|
|
420
|
+
status: JscpdStatusService | undefined,
|
|
421
|
+
persist: () => void,
|
|
422
|
+
): JscpdAutomaticResultActions | undefined {
|
|
423
|
+
if (!acknowledgements || !status) return undefined;
|
|
424
|
+
return {
|
|
425
|
+
isCurrent,
|
|
426
|
+
isIdle: () => ctx.isIdle(),
|
|
427
|
+
hasPendingMessages: () => ctx.hasPendingMessages(),
|
|
428
|
+
acknowledgements,
|
|
429
|
+
sendFinding(content, details) {
|
|
430
|
+
pi.sendMessage(
|
|
431
|
+
{
|
|
432
|
+
customType: JSCPD_AUTOMATIC_MESSAGE_TYPE,
|
|
433
|
+
content,
|
|
434
|
+
display: ctx.hasUI,
|
|
435
|
+
details,
|
|
436
|
+
},
|
|
437
|
+
{ triggerTurn: false },
|
|
438
|
+
);
|
|
439
|
+
},
|
|
440
|
+
record: (result) => status.record(result),
|
|
441
|
+
persist,
|
|
442
|
+
setStatus: ctx.hasUI ? (text) => safeSetStatus(ctx.ui, text) : undefined,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function setAutomaticCheckingStatus(ctx: ExtensionContext, isCurrent: () => boolean): void {
|
|
447
|
+
if (ctx.hasUI && isCurrent() && ctx.isIdle() && !ctx.hasPendingMessages()) {
|
|
448
|
+
safeSetStatus(ctx.ui, "jscpd: checking changes…");
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function restoreDeferredAutomaticStatus(
|
|
453
|
+
ctx: ExtensionContext,
|
|
454
|
+
isCurrent: () => boolean,
|
|
455
|
+
disposition: "attempted" | "deferred",
|
|
456
|
+
): void {
|
|
457
|
+
if (
|
|
458
|
+
disposition === "deferred" &&
|
|
459
|
+
ctx.hasUI &&
|
|
460
|
+
isCurrent() &&
|
|
461
|
+
ctx.isIdle() &&
|
|
462
|
+
!ctx.hasPendingMessages()
|
|
463
|
+
) {
|
|
464
|
+
safeSetStatus(ctx.ui, "jscpd: changes pending");
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function safeSetStatus(
|
|
469
|
+
ui: { setStatus(key: string, text: string | undefined): void },
|
|
470
|
+
text: string | undefined,
|
|
471
|
+
): void {
|
|
472
|
+
try {
|
|
473
|
+
ui.setStatus(JSCPD_AUTOMATIC_STATUS_KEY, text);
|
|
474
|
+
} catch {
|
|
475
|
+
// Footer status is optional and must not affect scans or lifecycle cleanup.
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function createBaselineContext(
|
|
480
|
+
cwd: string,
|
|
481
|
+
timeoutMs: number,
|
|
482
|
+
changedFiles: JscpdChangedFileTracker,
|
|
483
|
+
sessionMode?: JscpdSessionModeService,
|
|
484
|
+
): JscpdBaselineStartContext {
|
|
485
|
+
return Object.freeze({
|
|
486
|
+
cwd,
|
|
487
|
+
enabled: sessionMode?.isEnabled() ?? true,
|
|
488
|
+
timeoutMs,
|
|
489
|
+
hasPriorChanges: changedFiles.files().length > 0,
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function startBaselineQuietly(
|
|
494
|
+
runtime: JscpdEffectRuntime,
|
|
495
|
+
baselineService: JscpdBaselineService | undefined,
|
|
496
|
+
context: JscpdBaselineStartContext,
|
|
497
|
+
): Promise<void> {
|
|
498
|
+
if (!baselineService) return Promise.resolve();
|
|
499
|
+
return runtime.runPromiseExit(baselineService.startEffect(context)).then(() => undefined);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function synchronizeBaselineMode(
|
|
503
|
+
baselineService: JscpdBaselineService | undefined,
|
|
504
|
+
context: JscpdBaselineStartContext | undefined,
|
|
505
|
+
sessionMode: JscpdSessionModeService | undefined,
|
|
506
|
+
start: (context: JscpdBaselineStartContext) => void,
|
|
507
|
+
): void {
|
|
508
|
+
if (!baselineService || !context || !sessionMode) return;
|
|
509
|
+
if (!sessionMode.isEnabled()) {
|
|
510
|
+
baselineService.disable();
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
const current = baselineService.current();
|
|
514
|
+
if (
|
|
515
|
+
current.status === "unstarted" ||
|
|
516
|
+
(current.status === "unavailable" && current.reason === "disabled")
|
|
517
|
+
) {
|
|
518
|
+
start({
|
|
519
|
+
...context,
|
|
520
|
+
enabled: true,
|
|
521
|
+
hasPriorChanges: context.hasPriorChanges,
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
async function restoreSessionState(
|
|
527
|
+
runtime: JscpdEffectRuntime,
|
|
528
|
+
activeBranch: readonly unknown[],
|
|
529
|
+
cwd: string,
|
|
530
|
+
configuredEnabled: boolean,
|
|
531
|
+
changedFiles: JscpdChangedFileTracker,
|
|
532
|
+
acknowledgements: ReturnType<typeof createJscpdAcknowledgementTracker>,
|
|
533
|
+
sessionMode?: JscpdSessionModeService,
|
|
534
|
+
statusService?: JscpdStatusService,
|
|
535
|
+
): Promise<void> {
|
|
536
|
+
const restored = restoreJscpdSessionState(activeBranch);
|
|
537
|
+
await runtime.runPromise(changedFiles.startEffect(cwd, restored?.changedFiles));
|
|
538
|
+
acknowledgements.restore(restored?.acknowledgements);
|
|
539
|
+
if (!sessionMode || !statusService) return;
|
|
540
|
+
sessionMode.restore(configuredEnabled, restored?.modeOverride);
|
|
541
|
+
statusService.restore(restored?.lastCheck);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function hasFallowTool(pi: ExtensionAPI): boolean {
|
|
545
|
+
try {
|
|
546
|
+
return pi.getAllTools().some(({ name }) => name === "fallow_run");
|
|
547
|
+
} catch {
|
|
548
|
+
return false;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function pendingAutomaticStatus(coexistence: JscpdFallowCoexistenceService): string {
|
|
553
|
+
if (coexistence.automaticAllowed()) return "jscpd: changes pending";
|
|
554
|
+
return coexistence.current().status === "detected"
|
|
555
|
+
? "jscpd: on demand (Fallow overlap)"
|
|
556
|
+
: "jscpd: automatic checks on demand";
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function isBuiltInMutationTool(pi: ExtensionAPI, toolName: string): boolean {
|
|
560
|
+
if (toolName !== "edit" && toolName !== "write") return false;
|
|
561
|
+
try {
|
|
562
|
+
const tool = pi.getAllTools().find((candidate) => candidate.name === toolName);
|
|
563
|
+
return tool?.sourceInfo.source === "builtin";
|
|
564
|
+
} catch {
|
|
565
|
+
return false;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
export function createJscpdToolDefinition(
|
|
570
|
+
executor: JscpdCommandExecutor,
|
|
571
|
+
runtime: JscpdEffectRuntime,
|
|
572
|
+
): JscpdToolDefinition {
|
|
573
|
+
return {
|
|
574
|
+
...jscpdToolContract,
|
|
575
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
576
|
+
const result = await dispatchJscpdCommand(
|
|
577
|
+
params.command,
|
|
578
|
+
params.args,
|
|
579
|
+
{ cwd: ctx.cwd, signal: signal ?? ctx.signal },
|
|
580
|
+
executor,
|
|
581
|
+
runtime,
|
|
582
|
+
);
|
|
583
|
+
return {
|
|
584
|
+
content: [{ type: "text", text: result.message }],
|
|
585
|
+
details: withoutOverlayCache(result),
|
|
586
|
+
};
|
|
587
|
+
},
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
export function createJscpdSlashCommandDefinition(
|
|
592
|
+
executor: JscpdCommandExecutor,
|
|
593
|
+
overlayLauncher: JscpdOverlayLauncher | undefined = undefined,
|
|
594
|
+
runtime: JscpdEffectRuntime,
|
|
595
|
+
): JscpdSlashCommandDefinition {
|
|
596
|
+
return {
|
|
597
|
+
description: "Open the jscpd overview or run an explicit subcommand.",
|
|
598
|
+
argumentHint: jscpdArgumentHint,
|
|
599
|
+
getArgumentCompletions: getJscpdArgumentCompletions,
|
|
600
|
+
async handler(rawArgs, ctx) {
|
|
601
|
+
const parsed = parseJscpdSlashArgs(rawArgs);
|
|
602
|
+
if (!parsed.ok) {
|
|
603
|
+
ctx.ui.notify(parsed.error.message, "error");
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
if (parsed.kind === "bare") {
|
|
607
|
+
try {
|
|
608
|
+
await (
|
|
609
|
+
overlayLauncher ?? createJscpdOverlayLauncher(overlayExecutor(executor, runtime))
|
|
610
|
+
).open(ctx);
|
|
611
|
+
} catch {
|
|
612
|
+
ctx.ui.notify(
|
|
613
|
+
"The jscpd overview could not open; explicit subcommands remain available.",
|
|
614
|
+
"warning",
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const result = await dispatchJscpdCommand(
|
|
621
|
+
parsed.invocation.command,
|
|
622
|
+
parsed.invocation.args,
|
|
623
|
+
{ cwd: ctx.cwd, signal: ctx.signal },
|
|
624
|
+
executor,
|
|
625
|
+
runtime,
|
|
626
|
+
);
|
|
627
|
+
ctx.ui.notify(terminalResultMessage(result), resultNotificationLevel(result));
|
|
628
|
+
},
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
export function installJscpdAutocompleteProvider(
|
|
633
|
+
ui: Pick<ExtensionContext["ui"], "addAutocompleteProvider">,
|
|
634
|
+
): void {
|
|
635
|
+
ui.addAutocompleteProvider((current) => ({
|
|
636
|
+
async getSuggestions(lines, cursorLine, cursorCol, options) {
|
|
637
|
+
const prefix = jscpdSlashPrefix(lines, cursorLine, cursorCol);
|
|
638
|
+
if (/^\/jscpd\s+$/u.test(prefix)) {
|
|
639
|
+
return { prefix, items: getJscpdRootCommandCompletions() };
|
|
640
|
+
}
|
|
641
|
+
return current.getSuggestions(lines, cursorLine, cursorCol, options);
|
|
642
|
+
},
|
|
643
|
+
applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
|
|
644
|
+
return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
|
|
645
|
+
},
|
|
646
|
+
shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
|
|
647
|
+
const prefix = jscpdSlashPrefix(lines, cursorLine, cursorCol);
|
|
648
|
+
if (/^\/jscpd\s+$/u.test(prefix)) return true;
|
|
649
|
+
return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true;
|
|
650
|
+
},
|
|
651
|
+
}));
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function jscpdSlashPrefix(lines: string[], cursorLine: number, cursorCol: number): string {
|
|
655
|
+
const beforeCursor = (lines[cursorLine] ?? "").slice(0, cursorCol);
|
|
656
|
+
const slashIndex = beforeCursor.lastIndexOf("/");
|
|
657
|
+
return slashIndex >= 0 ? beforeCursor.slice(slashIndex) : "";
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function overlayExecutor(
|
|
661
|
+
executor: JscpdCommandExecutor,
|
|
662
|
+
runtime: JscpdEffectRuntime,
|
|
663
|
+
): JscpdOverlayExecutor {
|
|
664
|
+
return {
|
|
665
|
+
execute: (invocation, context) =>
|
|
666
|
+
runtime.runPromise(executor.executeEffect(invocation, context)),
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function withoutOverlayCache(result: JscpdDispatchResult): JscpdDispatchResult {
|
|
671
|
+
if (
|
|
672
|
+
(result.status === "changed" || result.status === "completed") &&
|
|
673
|
+
result.overlayCache !== undefined
|
|
674
|
+
) {
|
|
675
|
+
const { overlayCache: _overlayCache, ...publicResult } = result;
|
|
676
|
+
return publicResult;
|
|
677
|
+
}
|
|
678
|
+
return result;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function terminalResultMessage(result: JscpdDispatchResult): string {
|
|
682
|
+
switch (result.status) {
|
|
683
|
+
case "completed":
|
|
684
|
+
case "status":
|
|
685
|
+
case "control":
|
|
686
|
+
case "help":
|
|
687
|
+
case "changed":
|
|
688
|
+
return result.terminalMessage;
|
|
689
|
+
default:
|
|
690
|
+
return result.message;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function resultNotificationLevel(result: JscpdDispatchResult): "info" | "warning" | "error" {
|
|
695
|
+
switch (result.status) {
|
|
696
|
+
case "completed":
|
|
697
|
+
case "status":
|
|
698
|
+
case "control":
|
|
699
|
+
case "help":
|
|
700
|
+
case "changed":
|
|
701
|
+
return "info";
|
|
702
|
+
case "invalid":
|
|
703
|
+
case "error":
|
|
704
|
+
return "error";
|
|
705
|
+
default:
|
|
706
|
+
return "warning";
|
|
707
|
+
}
|
|
708
|
+
}
|