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/process.ts
ADDED
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
// fallow-ignore-file security-sink -- This bounded shell-free process owner validates command tokens and never constructs a shell string.
|
|
2
|
+
import type { ChildProcessByStdio } from "node:child_process";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import type { Readable } from "node:stream";
|
|
5
|
+
import { Effect, Exit, Layer } from "effect";
|
|
6
|
+
import {
|
|
7
|
+
JscpdInvalidInput,
|
|
8
|
+
JscpdLimitExceeded,
|
|
9
|
+
JscpdOperationTimedOut,
|
|
10
|
+
JscpdProcessFailure,
|
|
11
|
+
} from "./effect/errors.js";
|
|
12
|
+
import {
|
|
13
|
+
JscpdProcess,
|
|
14
|
+
type JscpdProcessRequest,
|
|
15
|
+
type JscpdProcessResult,
|
|
16
|
+
type JscpdProcessRunError,
|
|
17
|
+
} from "./effect/services.js";
|
|
18
|
+
|
|
19
|
+
const DEFAULT_TERMINATION_GRACE_MS = 250;
|
|
20
|
+
const DEFAULT_FORCE_SETTLE_MS = 250;
|
|
21
|
+
const MAX_PROCESS_TIMEOUT_MS = 5 * 60_000;
|
|
22
|
+
const MAX_PROCESS_OUTPUT_BYTES = 1024 * 1024;
|
|
23
|
+
const MAX_TERMINATION_BOUND_MS = 5_000;
|
|
24
|
+
|
|
25
|
+
export type BoundedProcessResult =
|
|
26
|
+
| { status: "completed"; exitCode: number; stdout: Buffer; stderr: Buffer }
|
|
27
|
+
| { status: "not-found" }
|
|
28
|
+
| { status: "cancelled" }
|
|
29
|
+
| { status: "timed-out" }
|
|
30
|
+
| { status: "output-limit" }
|
|
31
|
+
| { status: "invalid-request" }
|
|
32
|
+
| { status: "spawn-failed" };
|
|
33
|
+
|
|
34
|
+
type OwnedChild = ChildProcessByStdio<null, Readable, Readable>;
|
|
35
|
+
|
|
36
|
+
export function createProcessEnvironmentWithPath(path: string): NodeJS.ProcessEnv {
|
|
37
|
+
const environment = { ...process.env };
|
|
38
|
+
for (const key of Object.keys(environment)) {
|
|
39
|
+
if (key.toLowerCase() === "path") delete environment[key];
|
|
40
|
+
}
|
|
41
|
+
environment.PATH = path;
|
|
42
|
+
return environment;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Live shell-free process service. Every child is acquired and finalized by its calling effect. */
|
|
46
|
+
export const JscpdProcessLive = Layer.succeed(JscpdProcess, {
|
|
47
|
+
run: runProcessEffect,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/** Effect-native bounded execution used by analyzer services through the host's process layer. */
|
|
51
|
+
export function runBoundedProcessEffect(
|
|
52
|
+
request: JscpdProcessRequest,
|
|
53
|
+
): Effect.Effect<BoundedProcessResult, never, JscpdProcess> {
|
|
54
|
+
return Effect.flatMap(JscpdProcess, (service) => service.run(request)).pipe(
|
|
55
|
+
Effect.match({ onFailure: boundedProcessFailure, onSuccess: completedBoundedProcessResult }),
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function runProcessEffect(
|
|
60
|
+
request: JscpdProcessRequest,
|
|
61
|
+
): Effect.Effect<JscpdProcessResult, JscpdProcessRunError> {
|
|
62
|
+
if (!isValidRequest(request)) {
|
|
63
|
+
return Effect.fail(new JscpdInvalidInput({ subject: "process-request", reason: "invalid" }));
|
|
64
|
+
}
|
|
65
|
+
return Effect.acquireUseRelease(
|
|
66
|
+
acquireOwnedProcess(request),
|
|
67
|
+
(owned) =>
|
|
68
|
+
Effect.exit(awaitOwnedProcess(owned, request)).pipe(Effect.map((exit) => ({ owned, exit }))),
|
|
69
|
+
releaseOwnedProcess,
|
|
70
|
+
).pipe(
|
|
71
|
+
Effect.flatMap(({ owned, exit }) =>
|
|
72
|
+
owned.terminationUncertain
|
|
73
|
+
? Effect.fail(new JscpdProcessFailure({ stage: owned.stage, reason: "termination" }))
|
|
74
|
+
: effectFromProcessExit(exit),
|
|
75
|
+
),
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function effectFromProcessExit(
|
|
80
|
+
exit: Exit.Exit<JscpdProcessResult, JscpdProcessRunError>,
|
|
81
|
+
): Effect.Effect<JscpdProcessResult, JscpdProcessRunError> {
|
|
82
|
+
return Exit.isSuccess(exit) ? Effect.succeed(exit.value) : Effect.failCause(exit.cause);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function acquireOwnedProcess(
|
|
86
|
+
request: JscpdProcessRequest,
|
|
87
|
+
): Effect.Effect<OwnedProcess, JscpdProcessFailure> {
|
|
88
|
+
return Effect.try({
|
|
89
|
+
try: () =>
|
|
90
|
+
new OwnedProcess(
|
|
91
|
+
spawn(request.executable, [...request.args], {
|
|
92
|
+
cwd: request.cwd,
|
|
93
|
+
detached: process.platform !== "win32",
|
|
94
|
+
env: request.environment,
|
|
95
|
+
shell: false,
|
|
96
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
97
|
+
windowsHide: true,
|
|
98
|
+
}),
|
|
99
|
+
request.stage,
|
|
100
|
+
request.terminationGraceMs ?? DEFAULT_TERMINATION_GRACE_MS,
|
|
101
|
+
request.forceSettleMs ?? DEFAULT_FORCE_SETTLE_MS,
|
|
102
|
+
),
|
|
103
|
+
catch: () => new JscpdProcessFailure({ stage: request.stage, reason: "spawn" }),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function awaitOwnedProcess(
|
|
108
|
+
owned: OwnedProcess,
|
|
109
|
+
request: JscpdProcessRequest,
|
|
110
|
+
): Effect.Effect<JscpdProcessResult, JscpdProcessRunError> {
|
|
111
|
+
const execution = owned.await(request.maxOutputBytes);
|
|
112
|
+
const timeout = Effect.sleep(request.timeoutMs).pipe(
|
|
113
|
+
Effect.flatMap(() => Effect.fail(new JscpdOperationTimedOut({ stage: request.stage }))),
|
|
114
|
+
);
|
|
115
|
+
return Effect.raceFirst(execution, timeout);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function releaseOwnedProcess(owned: OwnedProcess): Effect.Effect<void> {
|
|
119
|
+
return Effect.gen(function* () {
|
|
120
|
+
if (owned.closed) {
|
|
121
|
+
yield* settleRemainingProcessTree(owned);
|
|
122
|
+
owned.detach();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
yield* signalOwnedProcessTree(owned.child, "SIGTERM");
|
|
127
|
+
yield* waitUntilClosedOrDelay(owned, owned.terminationGraceMs);
|
|
128
|
+
if (!owned.closed) {
|
|
129
|
+
yield* signalOwnedProcessTree(owned.child, "SIGKILL");
|
|
130
|
+
yield* waitUntilClosedOrDelay(owned, owned.forceSettleMs);
|
|
131
|
+
}
|
|
132
|
+
yield* settleRemainingProcessTree(owned);
|
|
133
|
+
owned.detach();
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
class OwnedProcess {
|
|
138
|
+
readonly child: OwnedChild;
|
|
139
|
+
readonly stage: "probe" | "scan";
|
|
140
|
+
readonly terminationGraceMs: number;
|
|
141
|
+
readonly forceSettleMs: number;
|
|
142
|
+
terminationUncertain = false;
|
|
143
|
+
#closed = false;
|
|
144
|
+
#detached = false;
|
|
145
|
+
|
|
146
|
+
constructor(
|
|
147
|
+
child: OwnedChild,
|
|
148
|
+
stage: "probe" | "scan",
|
|
149
|
+
terminationGraceMs: number,
|
|
150
|
+
forceSettleMs: number,
|
|
151
|
+
) {
|
|
152
|
+
this.child = child;
|
|
153
|
+
this.stage = stage;
|
|
154
|
+
this.terminationGraceMs = terminationGraceMs;
|
|
155
|
+
this.forceSettleMs = forceSettleMs;
|
|
156
|
+
child.once("close", this.#onClose);
|
|
157
|
+
child.on("error", ignoreChildError);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
get closed(): boolean {
|
|
161
|
+
return this.#closed || this.child.exitCode !== null || this.child.signalCode !== null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
await(maxOutputBytes: number): Effect.Effect<JscpdProcessResult, JscpdProcessRunError> {
|
|
165
|
+
return Effect.async((resume) => {
|
|
166
|
+
const stdout: Buffer[] = [];
|
|
167
|
+
const stderr: Buffer[] = [];
|
|
168
|
+
let outputBytes = 0;
|
|
169
|
+
let settled = false;
|
|
170
|
+
const finish = (effect: Effect.Effect<JscpdProcessResult, JscpdProcessRunError>) => {
|
|
171
|
+
if (settled) return;
|
|
172
|
+
settled = true;
|
|
173
|
+
cleanup();
|
|
174
|
+
resume(effect);
|
|
175
|
+
};
|
|
176
|
+
const capture = (destination: Buffer[], chunk: Buffer | string) => {
|
|
177
|
+
if (settled) return;
|
|
178
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
179
|
+
const remaining = maxOutputBytes - outputBytes;
|
|
180
|
+
if (buffer.length > remaining) {
|
|
181
|
+
if (remaining > 0) destination.push(buffer.subarray(0, remaining));
|
|
182
|
+
finish(Effect.fail(new JscpdLimitExceeded({ subject: "process-output" })));
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
destination.push(buffer);
|
|
186
|
+
outputBytes += buffer.length;
|
|
187
|
+
};
|
|
188
|
+
const onStdout = (chunk: Buffer | string) => capture(stdout, chunk);
|
|
189
|
+
const onStderr = (chunk: Buffer | string) => capture(stderr, chunk);
|
|
190
|
+
const onError = (error: NodeJS.ErrnoException) =>
|
|
191
|
+
finish(
|
|
192
|
+
Effect.fail(
|
|
193
|
+
new JscpdProcessFailure({
|
|
194
|
+
stage: this.stage,
|
|
195
|
+
reason: error.code === "ENOENT" ? "not-found" : "spawn",
|
|
196
|
+
}),
|
|
197
|
+
),
|
|
198
|
+
);
|
|
199
|
+
const onClose = (code: number | null) =>
|
|
200
|
+
finish(
|
|
201
|
+
Effect.succeed({
|
|
202
|
+
exitCode: code ?? 1,
|
|
203
|
+
stdout: Buffer.concat(stdout),
|
|
204
|
+
stderr: Buffer.concat(stderr),
|
|
205
|
+
}),
|
|
206
|
+
);
|
|
207
|
+
const cleanup = () => {
|
|
208
|
+
this.child.stdout.removeListener("data", onStdout);
|
|
209
|
+
this.child.stderr.removeListener("data", onStderr);
|
|
210
|
+
this.child.removeListener("error", onError);
|
|
211
|
+
this.child.removeListener("close", onClose);
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
this.child.stdout.on("data", onStdout);
|
|
215
|
+
this.child.stderr.on("data", onStderr);
|
|
216
|
+
this.child.once("error", onError);
|
|
217
|
+
this.child.once("close", onClose);
|
|
218
|
+
return Effect.sync(cleanup);
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
waitForClose(): Effect.Effect<void> {
|
|
223
|
+
if (this.closed) return Effect.void;
|
|
224
|
+
return Effect.async((resume) => {
|
|
225
|
+
const close = () => resume(Effect.void);
|
|
226
|
+
this.child.once("close", close);
|
|
227
|
+
return Effect.sync(() => this.child.removeListener("close", close));
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
detach(): void {
|
|
232
|
+
if (this.#detached) return;
|
|
233
|
+
this.#detached = true;
|
|
234
|
+
this.child.removeListener("close", this.#onClose);
|
|
235
|
+
this.child.removeListener("error", ignoreChildError);
|
|
236
|
+
if (!this.closed) {
|
|
237
|
+
this.child.once("error", ignoreChildError);
|
|
238
|
+
this.child.stdout.destroy();
|
|
239
|
+
this.child.stderr.destroy();
|
|
240
|
+
this.child.unref();
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
readonly #onClose = (): void => {
|
|
245
|
+
this.#closed = true;
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function waitUntilClosedOrDelay(owned: OwnedProcess, milliseconds: number): Effect.Effect<void> {
|
|
250
|
+
return Effect.raceFirst(
|
|
251
|
+
owned.waitForClose().pipe(Effect.interruptible),
|
|
252
|
+
Effect.sleep(milliseconds).pipe(Effect.interruptible),
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function isValidRequest(request: JscpdProcessRequest): boolean {
|
|
257
|
+
return (
|
|
258
|
+
request !== null &&
|
|
259
|
+
typeof request === "object" &&
|
|
260
|
+
(request.stage === "probe" || request.stage === "scan") &&
|
|
261
|
+
hasValidProcessIdentity(request) &&
|
|
262
|
+
hasValidProcessArguments(request.args) &&
|
|
263
|
+
hasValidProcessBounds(request)
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function hasValidProcessIdentity(request: JscpdProcessRequest): boolean {
|
|
268
|
+
return isSafeProcessToken(request.executable, false) && isSafeProcessToken(request.cwd, false);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function hasValidProcessArguments(args: readonly string[]): boolean {
|
|
272
|
+
return Array.isArray(args) && args.every((token) => isSafeProcessToken(token, true));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function hasValidProcessBounds(request: JscpdProcessRequest): boolean {
|
|
276
|
+
const durationBoundsAreValid =
|
|
277
|
+
isBoundedPositiveInteger(request.timeoutMs, MAX_PROCESS_TIMEOUT_MS) &&
|
|
278
|
+
isOptionalBoundedPositiveInteger(request.terminationGraceMs, MAX_TERMINATION_BOUND_MS) &&
|
|
279
|
+
isOptionalBoundedPositiveInteger(request.forceSettleMs, MAX_TERMINATION_BOUND_MS);
|
|
280
|
+
return (
|
|
281
|
+
durationBoundsAreValid &&
|
|
282
|
+
isBoundedPositiveInteger(request.maxOutputBytes, MAX_PROCESS_OUTPUT_BYTES)
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function isSafeProcessToken(value: string, allowEmpty: boolean): boolean {
|
|
287
|
+
return typeof value === "string" && (allowEmpty || value.length > 0) && !value.includes("\0");
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function isBoundedPositiveInteger(value: number, maximum: number): boolean {
|
|
291
|
+
return Number.isSafeInteger(value) && value > 0 && value <= maximum;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function isOptionalBoundedPositiveInteger(value: number | undefined, maximum: number): boolean {
|
|
295
|
+
return value === undefined || isBoundedPositiveInteger(value, maximum);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function signalOwnedProcessTree(child: OwnedChild, signal: NodeJS.Signals): Effect.Effect<void> {
|
|
299
|
+
if (process.platform === "win32" && child.pid) {
|
|
300
|
+
return signalWindowsProcessTree(child, signal === "SIGKILL");
|
|
301
|
+
}
|
|
302
|
+
return Effect.sync(() => {
|
|
303
|
+
try {
|
|
304
|
+
if (child.pid) process.kill(-child.pid, signal);
|
|
305
|
+
else child.kill(signal);
|
|
306
|
+
} catch {
|
|
307
|
+
// A concurrently exited process is already terminated.
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function settleRemainingProcessTree(owned: OwnedProcess): Effect.Effect<void> {
|
|
313
|
+
return Effect.gen(function* () {
|
|
314
|
+
forceRemainingUnixProcessTree(owned.child);
|
|
315
|
+
if (processStillExists(owned.child)) {
|
|
316
|
+
yield* Effect.sleep(owned.forceSettleMs).pipe(Effect.interruptible);
|
|
317
|
+
}
|
|
318
|
+
owned.terminationUncertain = processStillExists(owned.child);
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function forceRemainingUnixProcessTree(child: OwnedChild): void {
|
|
323
|
+
if (process.platform === "win32" || !child.pid) return;
|
|
324
|
+
try {
|
|
325
|
+
process.kill(-child.pid, 0);
|
|
326
|
+
process.kill(-child.pid, "SIGKILL");
|
|
327
|
+
} catch {
|
|
328
|
+
// A process group with no remaining descendants is already clean.
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function signalWindowsProcessTree(child: OwnedChild, force: boolean): Effect.Effect<void> {
|
|
333
|
+
return Effect.async((resume) => {
|
|
334
|
+
let terminator: ReturnType<typeof spawn>;
|
|
335
|
+
try {
|
|
336
|
+
terminator = spawn("taskkill", ["/PID", String(child.pid), "/T", ...(force ? ["/F"] : [])], {
|
|
337
|
+
shell: false,
|
|
338
|
+
stdio: "ignore",
|
|
339
|
+
windowsHide: true,
|
|
340
|
+
});
|
|
341
|
+
} catch {
|
|
342
|
+
signalChildFallback(child, force);
|
|
343
|
+
resume(Effect.void);
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
const finish = () => {
|
|
347
|
+
cleanup();
|
|
348
|
+
resume(Effect.void);
|
|
349
|
+
};
|
|
350
|
+
const fallback = () => {
|
|
351
|
+
signalChildFallback(child, force);
|
|
352
|
+
finish();
|
|
353
|
+
};
|
|
354
|
+
const cleanup = () => {
|
|
355
|
+
terminator.removeListener("close", finish);
|
|
356
|
+
terminator.removeListener("error", fallback);
|
|
357
|
+
};
|
|
358
|
+
terminator.once("close", finish);
|
|
359
|
+
terminator.once("error", fallback);
|
|
360
|
+
return Effect.sync(() => {
|
|
361
|
+
cleanup();
|
|
362
|
+
if (terminator.exitCode === null && terminator.signalCode === null) {
|
|
363
|
+
try {
|
|
364
|
+
terminator.kill("SIGKILL");
|
|
365
|
+
} catch {
|
|
366
|
+
terminator.unref();
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
}).pipe((effect) =>
|
|
371
|
+
Effect.raceFirst(
|
|
372
|
+
effect.pipe(Effect.interruptible),
|
|
373
|
+
Effect.sleep(250).pipe(Effect.interruptible),
|
|
374
|
+
),
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function signalChildFallback(child: OwnedChild, force: boolean): void {
|
|
379
|
+
try {
|
|
380
|
+
child.kill(force ? "SIGKILL" : "SIGTERM");
|
|
381
|
+
} catch {
|
|
382
|
+
// A concurrently exited process is already terminated.
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function processStillExists(child: OwnedChild): boolean {
|
|
387
|
+
if (!child.pid) return false;
|
|
388
|
+
if (process.platform === "win32" && (child.exitCode !== null || child.signalCode !== null)) {
|
|
389
|
+
return false;
|
|
390
|
+
}
|
|
391
|
+
try {
|
|
392
|
+
process.kill(process.platform === "win32" ? child.pid : -child.pid, 0);
|
|
393
|
+
return true;
|
|
394
|
+
} catch {
|
|
395
|
+
return false;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function completedBoundedProcessResult(result: JscpdProcessResult): BoundedProcessResult {
|
|
400
|
+
return {
|
|
401
|
+
status: "completed",
|
|
402
|
+
exitCode: result.exitCode,
|
|
403
|
+
stdout: Buffer.from(result.stdout),
|
|
404
|
+
stderr: Buffer.from(result.stderr),
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function boundedProcessFailure(error: JscpdProcessRunError): BoundedProcessResult {
|
|
409
|
+
switch (error._tag) {
|
|
410
|
+
case "JscpdProcessFailure":
|
|
411
|
+
return { status: error.reason === "not-found" ? "not-found" : "spawn-failed" };
|
|
412
|
+
case "JscpdOperationCancelled":
|
|
413
|
+
return { status: "cancelled" };
|
|
414
|
+
case "JscpdOperationTimedOut":
|
|
415
|
+
return { status: "timed-out" };
|
|
416
|
+
case "JscpdLimitExceeded":
|
|
417
|
+
return { status: "output-limit" };
|
|
418
|
+
case "JscpdInvalidInput":
|
|
419
|
+
return { status: "invalid-request" };
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function ignoreChildError(): void {
|
|
424
|
+
// A late process error after bounded settlement is deliberately private.
|
|
425
|
+
}
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
interface JscpdCommandSpec {
|
|
2
|
+
name: string;
|
|
3
|
+
description: string;
|
|
4
|
+
argumentHint: string;
|
|
5
|
+
maxArguments: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const JSCPD_MAX_ARGUMENT_LENGTH = 1_024;
|
|
9
|
+
|
|
10
|
+
export const jscpdCommandRegistry = [
|
|
11
|
+
{
|
|
12
|
+
name: "scan",
|
|
13
|
+
description: "Request an explicit duplication scan",
|
|
14
|
+
argumentHint: "[target ...]",
|
|
15
|
+
maxArguments: 32,
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
name: "changed",
|
|
19
|
+
description: "Show unacknowledged new duplication involving session changes",
|
|
20
|
+
argumentHint: "",
|
|
21
|
+
maxArguments: 0,
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
name: "status",
|
|
25
|
+
description: "Show binary, configuration, and last-check status",
|
|
26
|
+
argumentHint: "",
|
|
27
|
+
maxArguments: 0,
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: "off",
|
|
31
|
+
description: "Disable jscpd behavior for the current session",
|
|
32
|
+
argumentHint: "",
|
|
33
|
+
maxArguments: 0,
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: "on",
|
|
37
|
+
description: "Re-enable jscpd behavior for the current session",
|
|
38
|
+
argumentHint: "",
|
|
39
|
+
maxArguments: 0,
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: "help",
|
|
43
|
+
description: "Show jscpd commands and session controls",
|
|
44
|
+
argumentHint: "",
|
|
45
|
+
maxArguments: 0,
|
|
46
|
+
},
|
|
47
|
+
] as const satisfies readonly JscpdCommandSpec[];
|
|
48
|
+
|
|
49
|
+
type RegisteredJscpdCommandSpec = (typeof jscpdCommandRegistry)[number];
|
|
50
|
+
export type JscpdCommand = RegisteredJscpdCommandSpec["name"];
|
|
51
|
+
|
|
52
|
+
export const jscpdCommandNames = jscpdCommandRegistry.map(({ name }) => name);
|
|
53
|
+
|
|
54
|
+
const commandSpecsByName = new Map<JscpdCommand, RegisteredJscpdCommandSpec>(
|
|
55
|
+
jscpdCommandRegistry.map((spec) => [spec.name, spec]),
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
export const jscpdArgumentHint = `[${jscpdCommandRegistry.map(commandUsage).join("|")}]`;
|
|
59
|
+
|
|
60
|
+
const jscpdCommandCompletions = jscpdCommandRegistry.map((spec) => ({
|
|
61
|
+
value: spec.name,
|
|
62
|
+
label: commandUsage(spec),
|
|
63
|
+
description: spec.description,
|
|
64
|
+
}));
|
|
65
|
+
|
|
66
|
+
function commandUsage(spec: RegisteredJscpdCommandSpec): string {
|
|
67
|
+
return spec.argumentHint ? `${spec.name} ${spec.argumentHint}` : spec.name;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function renderJscpdCommandHelp(): string {
|
|
71
|
+
const commands = jscpdCommandRegistry.map(
|
|
72
|
+
(spec) => ` /jscpd ${commandUsage(spec)} — ${spec.description}`,
|
|
73
|
+
);
|
|
74
|
+
return [
|
|
75
|
+
"jscpd commands",
|
|
76
|
+
...commands,
|
|
77
|
+
" /jscpd — open the interactive overview (no implicit scan)",
|
|
78
|
+
"Verification: after normal edits and tests, rerun the same scan or use r in the overlay.",
|
|
79
|
+
"Intentional duplication: update normal jscpd ignore/exclusion policy; pi-jscpd never writes it.",
|
|
80
|
+
].join("\n");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function getJscpdCommandSpec(command: string): RegisteredJscpdCommandSpec | undefined {
|
|
84
|
+
return commandSpecsByName.get(command as JscpdCommand);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function getJscpdArgumentCompletions(prefix: string) {
|
|
88
|
+
const commandPrefix = prefix.trimStart().toLocaleLowerCase();
|
|
89
|
+
if (/\s/.test(commandPrefix)) return null;
|
|
90
|
+
|
|
91
|
+
const matches = jscpdCommandCompletions.filter(({ value }) => value.startsWith(commandPrefix));
|
|
92
|
+
return matches.length > 0 ? matches : null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Root suggestions used when the editor contains exactly `/jscpd ` and no subcommand yet. */
|
|
96
|
+
export function getJscpdRootCommandCompletions() {
|
|
97
|
+
return jscpdCommandRegistry.map((spec) => ({
|
|
98
|
+
value: `jscpd ${spec.name}`,
|
|
99
|
+
label: commandUsage(spec),
|
|
100
|
+
description: spec.description,
|
|
101
|
+
}));
|
|
102
|
+
}
|