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/config.ts
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import { isAbsolute, join, relative } from "node:path";
|
|
2
|
+
import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Context, Data, Effect, Layer } from "effect";
|
|
4
|
+
import { JscpdFileSystem } from "./effect/services.js";
|
|
5
|
+
import { canonicalDirectoryEffect } from "./path-utils.js";
|
|
6
|
+
|
|
7
|
+
const PROJECT_CONFIG_FILE_NAME = "jscpd-guardrail.json";
|
|
8
|
+
const LOCAL_CONFIG_FILE_NAME = "jscpd-guardrail.local.json";
|
|
9
|
+
const MAX_CONFIG_BYTES = 64 * 1_024;
|
|
10
|
+
const MIN_TIMEOUT_MS = 100;
|
|
11
|
+
const MAX_TIMEOUT_MS = 5 * 60_000;
|
|
12
|
+
const MAX_PRESENTED_FINDINGS = 100;
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_JSCPD_CONFIG: JscpdConfig = Object.freeze({
|
|
15
|
+
enabled: true,
|
|
16
|
+
timeoutMs: 30_000,
|
|
17
|
+
maxFindings: 10,
|
|
18
|
+
fallowCoexistence: "auto",
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export interface JscpdConfig {
|
|
22
|
+
readonly enabled: boolean;
|
|
23
|
+
readonly timeoutMs: number;
|
|
24
|
+
readonly maxFindings: number;
|
|
25
|
+
readonly fallowCoexistence?: "auto" | "on-demand" | "allow";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type JscpdConfigSource = "defaults" | "project" | "local";
|
|
29
|
+
|
|
30
|
+
export type JscpdConfigDiagnosticCode =
|
|
31
|
+
| "invalid-project"
|
|
32
|
+
| "unsafe-file"
|
|
33
|
+
| "read-failed"
|
|
34
|
+
| "file-too-large"
|
|
35
|
+
| "malformed-json"
|
|
36
|
+
| "invalid-top-level"
|
|
37
|
+
| "unknown-field"
|
|
38
|
+
| "invalid-value";
|
|
39
|
+
|
|
40
|
+
export interface JscpdConfigDiagnostic {
|
|
41
|
+
readonly source: Exclude<JscpdConfigSource, "defaults">;
|
|
42
|
+
readonly code: JscpdConfigDiagnosticCode;
|
|
43
|
+
readonly message: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface JscpdConfigLoadResult {
|
|
47
|
+
readonly config: JscpdConfig;
|
|
48
|
+
/** Lowest-to-highest precedence; local values override project values. */
|
|
49
|
+
readonly sources: readonly JscpdConfigSource[];
|
|
50
|
+
readonly diagnostics: readonly JscpdConfigDiagnostic[];
|
|
51
|
+
readonly trusted: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface JscpdConfigLoadContext {
|
|
55
|
+
readonly cwd: string;
|
|
56
|
+
readonly trusted: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface JscpdConfigService {
|
|
60
|
+
loadEffect: (
|
|
61
|
+
context: JscpdConfigLoadContext,
|
|
62
|
+
) => Effect.Effect<JscpdConfigLoadResult, never, JscpdFileSystem>;
|
|
63
|
+
current(): JscpdConfigLoadResult;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface JscpdConfigEffectService {
|
|
67
|
+
readonly load: (
|
|
68
|
+
context: JscpdConfigLoadContext,
|
|
69
|
+
) => Effect.Effect<JscpdConfigLoadResult, never, JscpdFileSystem>;
|
|
70
|
+
readonly current: Effect.Effect<JscpdConfigLoadResult>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const JscpdConfiguration = Context.GenericTag<JscpdConfigEffectService>(
|
|
74
|
+
"pi-jscpd/effect/Configuration",
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
interface ConfigPatch {
|
|
78
|
+
enabled?: boolean;
|
|
79
|
+
timeoutMs?: number;
|
|
80
|
+
maxFindings?: number;
|
|
81
|
+
fallowCoexistence?: "auto" | "on-demand" | "allow";
|
|
82
|
+
}
|
|
83
|
+
type ConfigFileSource = Exclude<JscpdConfigSource, "defaults">;
|
|
84
|
+
|
|
85
|
+
type ConfigFileResult =
|
|
86
|
+
| { status: "missing" }
|
|
87
|
+
| { status: "valid"; patch: ConfigPatch }
|
|
88
|
+
| { status: "invalid"; diagnostic: JscpdConfigDiagnostic };
|
|
89
|
+
|
|
90
|
+
class ConfigDecodeFailure extends Data.TaggedError("ConfigDecodeFailure")<{
|
|
91
|
+
readonly diagnostic: JscpdConfigDiagnostic;
|
|
92
|
+
}> {}
|
|
93
|
+
|
|
94
|
+
const SOURCE_FILES: readonly [ConfigFileSource, string][] = [
|
|
95
|
+
["project", PROJECT_CONFIG_FILE_NAME],
|
|
96
|
+
["local", LOCAL_CONFIG_FILE_NAME],
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
export function createJscpdConfigService(): JscpdConfigService {
|
|
100
|
+
const owner = new DefaultJscpdConfigService();
|
|
101
|
+
return {
|
|
102
|
+
loadEffect: (context) => owner.loadEffect(context),
|
|
103
|
+
current: () => owner.currentValue(),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function createJscpdConfigLayer() {
|
|
108
|
+
const owner = new DefaultJscpdConfigService();
|
|
109
|
+
return Layer.succeed(JscpdConfiguration, {
|
|
110
|
+
load: (context) => owner.loadEffect(context),
|
|
111
|
+
current: Effect.sync(() => owner.currentValue()),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
class DefaultJscpdConfigService {
|
|
116
|
+
#loaded = defaultLoadResult(false);
|
|
117
|
+
|
|
118
|
+
loadEffect(
|
|
119
|
+
context: JscpdConfigLoadContext,
|
|
120
|
+
): Effect.Effect<JscpdConfigLoadResult, never, JscpdFileSystem> {
|
|
121
|
+
return loadJscpdConfigEffect(context).pipe(
|
|
122
|
+
Effect.tap((loaded) =>
|
|
123
|
+
Effect.sync(() => {
|
|
124
|
+
this.#loaded = loaded;
|
|
125
|
+
}),
|
|
126
|
+
),
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
currentValue(): JscpdConfigLoadResult {
|
|
131
|
+
return this.#loaded;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function loadJscpdConfigEffect(
|
|
136
|
+
context: JscpdConfigLoadContext,
|
|
137
|
+
): Effect.Effect<JscpdConfigLoadResult, never, JscpdFileSystem> {
|
|
138
|
+
if (!context.trusted) return Effect.succeed(defaultLoadResult(false));
|
|
139
|
+
return Effect.gen(function* () {
|
|
140
|
+
const projectDirectory = yield* canonicalDirectoryEffect(context.cwd).pipe(
|
|
141
|
+
Effect.catchAll(() => Effect.succeed(undefined)),
|
|
142
|
+
);
|
|
143
|
+
if (!projectDirectory) return invalidProjectResult();
|
|
144
|
+
|
|
145
|
+
const config: JscpdConfig = { ...DEFAULT_JSCPD_CONFIG };
|
|
146
|
+
const sources: JscpdConfigSource[] = ["defaults"];
|
|
147
|
+
const diagnostics: JscpdConfigDiagnostic[] = [];
|
|
148
|
+
for (const [source, fileName] of SOURCE_FILES) {
|
|
149
|
+
const result = yield* loadConfigFileEffect(projectDirectory, source, fileName);
|
|
150
|
+
if (result.status === "valid") {
|
|
151
|
+
Object.assign(config, result.patch);
|
|
152
|
+
sources.push(source);
|
|
153
|
+
} else if (result.status === "invalid") {
|
|
154
|
+
diagnostics.push(result.diagnostic);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return Object.freeze({
|
|
158
|
+
config: Object.freeze(config),
|
|
159
|
+
sources: Object.freeze(sources),
|
|
160
|
+
diagnostics: Object.freeze(diagnostics),
|
|
161
|
+
trusted: true,
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function loadConfigFileEffect(
|
|
167
|
+
projectDirectory: string,
|
|
168
|
+
source: ConfigFileSource,
|
|
169
|
+
fileName: string,
|
|
170
|
+
): Effect.Effect<ConfigFileResult, never, JscpdFileSystem> {
|
|
171
|
+
return Effect.flatMap(JscpdFileSystem, (filesystem) => {
|
|
172
|
+
const configuredPath = join(projectDirectory, CONFIG_DIR_NAME, fileName);
|
|
173
|
+
return filesystem.canonicalize(configuredPath).pipe(
|
|
174
|
+
Effect.matchEffect({
|
|
175
|
+
onFailure: (error) =>
|
|
176
|
+
Effect.succeed(
|
|
177
|
+
error.reason === "missing"
|
|
178
|
+
? ({ status: "missing" } as const)
|
|
179
|
+
: unreadableConfigFile(source),
|
|
180
|
+
),
|
|
181
|
+
onSuccess: (canonicalPath) =>
|
|
182
|
+
isPathInside(projectDirectory, canonicalPath)
|
|
183
|
+
? readConfigFileEffect(filesystem, canonicalPath, source)
|
|
184
|
+
: Effect.succeed(
|
|
185
|
+
invalidFile(
|
|
186
|
+
source,
|
|
187
|
+
"unsafe-file",
|
|
188
|
+
`${sourceLabel(source)} resolves outside the project and was ignored.`,
|
|
189
|
+
),
|
|
190
|
+
),
|
|
191
|
+
}),
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function readConfigFileEffect(
|
|
197
|
+
filesystem: JscpdFileSystem,
|
|
198
|
+
path: string,
|
|
199
|
+
source: ConfigFileSource,
|
|
200
|
+
): Effect.Effect<ConfigFileResult> {
|
|
201
|
+
return filesystem
|
|
202
|
+
.read({
|
|
203
|
+
path,
|
|
204
|
+
maxBytes: MAX_CONFIG_BYTES,
|
|
205
|
+
regularFileOnly: true,
|
|
206
|
+
noFollow: true,
|
|
207
|
+
limitSubject: "configuration",
|
|
208
|
+
})
|
|
209
|
+
.pipe(
|
|
210
|
+
Effect.matchEffect({
|
|
211
|
+
onFailure: (error) =>
|
|
212
|
+
Effect.succeed(
|
|
213
|
+
error._tag === "JscpdLimitExceeded"
|
|
214
|
+
? invalidFile(
|
|
215
|
+
source,
|
|
216
|
+
"file-too-large",
|
|
217
|
+
`${sourceLabel(source)} exceeds the 64 KiB limit and was ignored.`,
|
|
218
|
+
)
|
|
219
|
+
: unreadableConfigFile(source),
|
|
220
|
+
),
|
|
221
|
+
onSuccess: (bytes) =>
|
|
222
|
+
decodeConfigFileEffect(bytes, source).pipe(
|
|
223
|
+
Effect.match({
|
|
224
|
+
onFailure: (error): ConfigFileResult => ({
|
|
225
|
+
status: "invalid",
|
|
226
|
+
diagnostic: error.diagnostic,
|
|
227
|
+
}),
|
|
228
|
+
onSuccess: (patch): ConfigFileResult => ({ status: "valid", patch }),
|
|
229
|
+
}),
|
|
230
|
+
),
|
|
231
|
+
}),
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function invalidProjectResult(): JscpdConfigLoadResult {
|
|
236
|
+
return Object.freeze({
|
|
237
|
+
config: DEFAULT_JSCPD_CONFIG,
|
|
238
|
+
sources: Object.freeze(["defaults"] as const),
|
|
239
|
+
diagnostics: Object.freeze([
|
|
240
|
+
diagnostic(
|
|
241
|
+
"project",
|
|
242
|
+
"invalid-project",
|
|
243
|
+
"jscpd configuration was not loaded because the project directory is unavailable.",
|
|
244
|
+
),
|
|
245
|
+
]),
|
|
246
|
+
trusted: true,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function unreadableConfigFile(source: ConfigFileSource): ConfigFileResult {
|
|
251
|
+
return invalidFile(
|
|
252
|
+
source,
|
|
253
|
+
"read-failed",
|
|
254
|
+
`${sourceLabel(source)} could not be read and was ignored.`,
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function decodeConfigFileEffect(
|
|
259
|
+
bytes: Uint8Array,
|
|
260
|
+
source: ConfigFileSource,
|
|
261
|
+
): Effect.Effect<ConfigPatch, ConfigDecodeFailure> {
|
|
262
|
+
const decoded = parseConfigFile(bytes, source);
|
|
263
|
+
return decoded.status === "valid"
|
|
264
|
+
? Effect.succeed(decoded.patch)
|
|
265
|
+
: Effect.fail(new ConfigDecodeFailure({ diagnostic: decoded.diagnostic }));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function parseConfigFile(
|
|
269
|
+
bytes: Uint8Array,
|
|
270
|
+
source: ConfigFileSource,
|
|
271
|
+
): Exclude<ConfigFileResult, { status: "missing" }> {
|
|
272
|
+
let value: unknown;
|
|
273
|
+
try {
|
|
274
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
275
|
+
value = JSON.parse(text);
|
|
276
|
+
} catch {
|
|
277
|
+
return invalidFile(
|
|
278
|
+
source,
|
|
279
|
+
"malformed-json",
|
|
280
|
+
`${sourceLabel(source)} contains malformed JSON and was ignored.`,
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (!isRecord(value)) {
|
|
285
|
+
return invalidFile(
|
|
286
|
+
source,
|
|
287
|
+
"invalid-top-level",
|
|
288
|
+
`${sourceLabel(source)} must contain a JSON object and was ignored.`,
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const unknown = Object.keys(value).filter((field) => !isKnownConfigField(field));
|
|
293
|
+
if (unknown.length > 0) {
|
|
294
|
+
return invalidFile(
|
|
295
|
+
source,
|
|
296
|
+
"unknown-field",
|
|
297
|
+
`${sourceLabel(source)} contains unknown ${plural(unknown.length, "setting")}: ${boundedFieldList(unknown)}. The file was ignored.`,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const invalid = knownFields().filter(
|
|
302
|
+
(field) => Object.hasOwn(value, field) && !isValidConfigValue(field, value[field]),
|
|
303
|
+
);
|
|
304
|
+
if (invalid.length > 0) {
|
|
305
|
+
return invalidFile(
|
|
306
|
+
source,
|
|
307
|
+
"invalid-value",
|
|
308
|
+
`${sourceLabel(source)} has invalid ${plural(invalid.length, "value")} for ${boundedFieldList(invalid)}. The file was ignored.`,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return { status: "valid", patch: configPatch(value) };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function configPatch(value: Record<string, unknown>): ConfigPatch {
|
|
316
|
+
const patch: ConfigPatch = {};
|
|
317
|
+
if (typeof value.enabled === "boolean") patch.enabled = value.enabled;
|
|
318
|
+
if (typeof value.timeoutMs === "number") patch.timeoutMs = value.timeoutMs;
|
|
319
|
+
if (typeof value.maxFindings === "number") patch.maxFindings = value.maxFindings;
|
|
320
|
+
if (
|
|
321
|
+
value.fallowCoexistence === "auto" ||
|
|
322
|
+
value.fallowCoexistence === "on-demand" ||
|
|
323
|
+
value.fallowCoexistence === "allow"
|
|
324
|
+
) {
|
|
325
|
+
patch.fallowCoexistence = value.fallowCoexistence;
|
|
326
|
+
}
|
|
327
|
+
return Object.freeze(patch);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function isValidConfigValue(field: keyof JscpdConfig, value: unknown): boolean {
|
|
331
|
+
switch (field) {
|
|
332
|
+
case "enabled":
|
|
333
|
+
return typeof value === "boolean";
|
|
334
|
+
case "timeoutMs":
|
|
335
|
+
return isBoundedInteger(value, MIN_TIMEOUT_MS, MAX_TIMEOUT_MS);
|
|
336
|
+
case "maxFindings":
|
|
337
|
+
return isBoundedInteger(value, 1, MAX_PRESENTED_FINDINGS);
|
|
338
|
+
case "fallowCoexistence":
|
|
339
|
+
return value === "auto" || value === "on-demand" || value === "allow";
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function knownFields(): readonly (keyof JscpdConfig)[] {
|
|
344
|
+
return ["enabled", "timeoutMs", "maxFindings", "fallowCoexistence"];
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function isKnownConfigField(field: string): field is keyof JscpdConfig {
|
|
348
|
+
return (
|
|
349
|
+
field === "enabled" ||
|
|
350
|
+
field === "timeoutMs" ||
|
|
351
|
+
field === "maxFindings" ||
|
|
352
|
+
field === "fallowCoexistence"
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function isBoundedInteger(value: unknown, minimum: number, maximum: number): value is number {
|
|
357
|
+
return (
|
|
358
|
+
Number.isSafeInteger(value) && (value as number) >= minimum && (value as number) <= maximum
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
363
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function invalidFile(
|
|
367
|
+
source: ConfigFileSource,
|
|
368
|
+
code: JscpdConfigDiagnosticCode,
|
|
369
|
+
message: string,
|
|
370
|
+
): Extract<ConfigFileResult, { status: "invalid" }> {
|
|
371
|
+
return { status: "invalid", diagnostic: diagnostic(source, code, message) };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function diagnostic(
|
|
375
|
+
source: ConfigFileSource,
|
|
376
|
+
code: JscpdConfigDiagnosticCode,
|
|
377
|
+
message: string,
|
|
378
|
+
): JscpdConfigDiagnostic {
|
|
379
|
+
return Object.freeze({ source, code, message });
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function defaultLoadResult(trusted: boolean): JscpdConfigLoadResult {
|
|
383
|
+
return Object.freeze({
|
|
384
|
+
config: DEFAULT_JSCPD_CONFIG,
|
|
385
|
+
sources: Object.freeze(["defaults"] as const),
|
|
386
|
+
diagnostics: Object.freeze([]),
|
|
387
|
+
trusted,
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function sourceLabel(source: ConfigFileSource): string {
|
|
392
|
+
return source === "project" ? "Project jscpd configuration" : "Local jscpd configuration";
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function boundedFieldList(fields: readonly string[]): string {
|
|
396
|
+
const shown = fields.slice(0, 5).map((field) => JSON.stringify(boundedField(field)));
|
|
397
|
+
return fields.length > shown.length
|
|
398
|
+
? `${shown.join(", ")} and ${fields.length - shown.length} more`
|
|
399
|
+
: shown.join(", ");
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function boundedField(field: string): string {
|
|
403
|
+
const characters = Array.from(field);
|
|
404
|
+
return characters.length <= 80 ? field : `${characters.slice(0, 79).join("")}…`;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function plural(count: number, singular: string): string {
|
|
408
|
+
return `${singular}${count === 1 ? "" : "s"}`;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function isPathInside(parent: string, candidate: string): boolean {
|
|
412
|
+
const pathFromParent = relative(parent, candidate);
|
|
413
|
+
return pathFromParent === "" || (!pathFromParent.startsWith("..") && !isAbsolute(pathFromParent));
|
|
414
|
+
}
|
package/src/contract.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import { JSCPD_MAX_ARGUMENT_LENGTH, jscpdCommandNames, jscpdCommandRegistry } from "./registry.js";
|
|
4
|
+
|
|
5
|
+
const maxArguments = Math.max(
|
|
6
|
+
...jscpdCommandRegistry.map(({ maxArguments: commandMaximum }) => commandMaximum),
|
|
7
|
+
);
|
|
8
|
+
|
|
9
|
+
export const jscpdRunParams = Type.Object(
|
|
10
|
+
{
|
|
11
|
+
command: StringEnum(jscpdCommandNames, {
|
|
12
|
+
description: "The jscpd operation to request.",
|
|
13
|
+
}),
|
|
14
|
+
args: Type.Optional(
|
|
15
|
+
Type.Array(
|
|
16
|
+
Type.String({
|
|
17
|
+
minLength: 1,
|
|
18
|
+
maxLength: JSCPD_MAX_ARGUMENT_LENGTH,
|
|
19
|
+
description: "One scan path scope. Other commands accept no arguments.",
|
|
20
|
+
}),
|
|
21
|
+
{
|
|
22
|
+
maxItems: maxArguments,
|
|
23
|
+
description:
|
|
24
|
+
"Optional in-project scan scopes; omit for a full scan or argument-free command.",
|
|
25
|
+
},
|
|
26
|
+
),
|
|
27
|
+
),
|
|
28
|
+
},
|
|
29
|
+
{ additionalProperties: false },
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
export const jscpdToolContract = {
|
|
33
|
+
name: "jscpd_run",
|
|
34
|
+
label: "jscpd",
|
|
35
|
+
description:
|
|
36
|
+
"Run a local read-only jscpd v5 scan, show new session duplication, or inspect bounded status.",
|
|
37
|
+
promptSnippet: "Scan for duplicate blocks or show new session duplication with jscpd",
|
|
38
|
+
parameters: jscpdRunParams,
|
|
39
|
+
} as const;
|
package/src/dispatch.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { Cause, Effect } from "effect";
|
|
2
|
+
import type { JscpdEffectRuntime, JscpdRuntimeRequirements } from "./effect/runtime-boundary.js";
|
|
3
|
+
import { parseJscpdCommand } from "./parser.js";
|
|
4
|
+
import type { JscpdCommandExecutor, JscpdDispatchResult, JscpdExecutionContext } from "./types.js";
|
|
5
|
+
|
|
6
|
+
const EXECUTION_FAILED_MESSAGE = "The jscpd request failed without interrupting the Pi session.";
|
|
7
|
+
|
|
8
|
+
function dispatchJscpdCommandEffect(
|
|
9
|
+
command: unknown,
|
|
10
|
+
args: unknown,
|
|
11
|
+
context: JscpdExecutionContext,
|
|
12
|
+
executor: JscpdCommandExecutor,
|
|
13
|
+
): Effect.Effect<JscpdDispatchResult, never, JscpdRuntimeRequirements> {
|
|
14
|
+
return Effect.suspend(() => {
|
|
15
|
+
const parsed = parseJscpdCommand(command, args);
|
|
16
|
+
if (!parsed.ok) {
|
|
17
|
+
return Effect.succeed({
|
|
18
|
+
status: "invalid" as const,
|
|
19
|
+
reason: parsed.error.code,
|
|
20
|
+
message: parsed.error.message,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
const executed = interruptOnSignal(
|
|
24
|
+
Effect.suspend(() => executor.executeEffect(parsed.invocation, context)),
|
|
25
|
+
context.signal,
|
|
26
|
+
parsed.invocation.command,
|
|
27
|
+
);
|
|
28
|
+
return executed.pipe(Effect.catchAllCause(() => Effect.succeed(executionFailedResult())));
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function dispatchJscpdCommand(
|
|
33
|
+
command: unknown,
|
|
34
|
+
args: unknown,
|
|
35
|
+
context: JscpdExecutionContext,
|
|
36
|
+
executor: JscpdCommandExecutor,
|
|
37
|
+
runtime: JscpdEffectRuntime,
|
|
38
|
+
): Promise<JscpdDispatchResult> {
|
|
39
|
+
return runtime.runPromise(dispatchJscpdCommandEffect(command, args, context, executor));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function interruptOnSignal<R extends JscpdRuntimeRequirements>(
|
|
43
|
+
effect: Effect.Effect<JscpdDispatchResult, never, R>,
|
|
44
|
+
signal: AbortSignal | undefined,
|
|
45
|
+
command: "scan" | "changed" | "status" | "off" | "on" | "help",
|
|
46
|
+
): Effect.Effect<JscpdDispatchResult, never, R> {
|
|
47
|
+
if (!signal) return effect;
|
|
48
|
+
const interrupted = Effect.async<never>((resume) => {
|
|
49
|
+
const abort = () => resume(Effect.interrupt);
|
|
50
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
51
|
+
if (signal.aborted) abort();
|
|
52
|
+
return Effect.sync(() => signal.removeEventListener("abort", abort));
|
|
53
|
+
});
|
|
54
|
+
return Effect.raceFirst(effect, interrupted).pipe(
|
|
55
|
+
Effect.catchAllCause((cause) =>
|
|
56
|
+
Cause.isInterruptedOnly(cause)
|
|
57
|
+
? Effect.succeed(commandCancellationResult(command))
|
|
58
|
+
: Effect.failCause(cause),
|
|
59
|
+
),
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function commandCancellationResult(
|
|
64
|
+
command: "scan" | "changed" | "status" | "off" | "on" | "help",
|
|
65
|
+
): JscpdDispatchResult {
|
|
66
|
+
if (command === "scan") {
|
|
67
|
+
return {
|
|
68
|
+
status: "failed",
|
|
69
|
+
reason: "scan-cancelled",
|
|
70
|
+
message: "The jscpd scan was cancelled and its temporary report was removed.",
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
if (command === "changed") {
|
|
74
|
+
return {
|
|
75
|
+
status: "changed-unavailable",
|
|
76
|
+
reason: "baseline-cancelled",
|
|
77
|
+
message:
|
|
78
|
+
"The changed-duplication check was cancelled by a session branch transition; no findings were acknowledged.",
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
return executionFailedResult();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function executionFailedResult(): JscpdDispatchResult {
|
|
85
|
+
return {
|
|
86
|
+
status: "error",
|
|
87
|
+
reason: "execution-failed",
|
|
88
|
+
message: EXECUTION_FAILED_MESSAGE,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Clock, Layer } from "effect";
|
|
2
|
+
import { JscpdClock } from "./services.js";
|
|
3
|
+
|
|
4
|
+
/** Live scheduling clock; tests replace this layer without patching global timers. */
|
|
5
|
+
export const jscpdClockLive: JscpdClock = Object.freeze({
|
|
6
|
+
now: Clock.currentTimeMillis,
|
|
7
|
+
sleep: (milliseconds: number) => Clock.sleep(milliseconds),
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
export const JscpdClockLive = Layer.succeed(JscpdClock, jscpdClockLive);
|