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/fallow.ts
ADDED
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { Context, Effect, Layer, MutableRef } from "effect";
|
|
3
|
+
import { JscpdFileSystem } from "./effect/services.js";
|
|
4
|
+
import { canonicalDirectoryEffect, isPathInside } from "./path-utils.js";
|
|
5
|
+
|
|
6
|
+
const MAX_SIGNAL_FILE_BYTES = 64 * 1024;
|
|
7
|
+
const CONFIG_FILES = [".fallowrc", ".fallowrc.json", ".fallowrc.jsonc", "fallow.toml"] as const;
|
|
8
|
+
const FALLOW_DEPENDENCIES = new Set(["fallow", "pi-fallow"]);
|
|
9
|
+
|
|
10
|
+
export type JscpdFallowCoexistencePolicy = "auto" | "on-demand" | "allow";
|
|
11
|
+
export type JscpdFallowOverlapSignal =
|
|
12
|
+
| "active-pi-fallow-tool"
|
|
13
|
+
| "duplication-config-enabled"
|
|
14
|
+
| "duplication-config-disabled"
|
|
15
|
+
| "duplication-script"
|
|
16
|
+
| "fallow-config-present"
|
|
17
|
+
| "fallow-dependency"
|
|
18
|
+
| "unreadable-signal";
|
|
19
|
+
|
|
20
|
+
export interface JscpdFallowCoexistenceState {
|
|
21
|
+
readonly status: "absent" | "detected" | "ambiguous" | "explicit-allow" | "explicit-on-demand";
|
|
22
|
+
readonly policy: JscpdFallowCoexistencePolicy;
|
|
23
|
+
readonly automaticAllowed: boolean;
|
|
24
|
+
readonly signals: readonly JscpdFallowOverlapSignal[];
|
|
25
|
+
readonly statusText: string;
|
|
26
|
+
readonly notice?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface JscpdFallowCoexistenceContext {
|
|
30
|
+
readonly cwd: string;
|
|
31
|
+
readonly trusted: boolean;
|
|
32
|
+
readonly policy?: JscpdFallowCoexistencePolicy;
|
|
33
|
+
readonly fallowToolAvailable: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface JscpdFallowCoexistenceService {
|
|
37
|
+
evaluateEffect: (
|
|
38
|
+
context: JscpdFallowCoexistenceContext,
|
|
39
|
+
) => Effect.Effect<JscpdFallowCoexistenceState, never, JscpdFileSystem>;
|
|
40
|
+
current(): JscpdFallowCoexistenceState;
|
|
41
|
+
automaticAllowed(): boolean;
|
|
42
|
+
takeNotice(): string | undefined;
|
|
43
|
+
reset(): void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface JscpdFallowWorkflowService {
|
|
47
|
+
readonly evaluate: (
|
|
48
|
+
context: JscpdFallowCoexistenceContext,
|
|
49
|
+
) => Effect.Effect<JscpdFallowCoexistenceState, never, JscpdFileSystem>;
|
|
50
|
+
readonly current: Effect.Effect<JscpdFallowCoexistenceState>;
|
|
51
|
+
readonly takeNotice: Effect.Effect<string | undefined>;
|
|
52
|
+
readonly reset: Effect.Effect<void>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const JscpdFallowWorkflow = Context.GenericTag<JscpdFallowWorkflowService>(
|
|
56
|
+
"pi-jscpd/effect/FallowWorkflow",
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
interface ProjectSignals {
|
|
60
|
+
readonly duplicationConfig?: "enabled" | "disabled";
|
|
61
|
+
readonly script: boolean;
|
|
62
|
+
readonly configPresent: boolean;
|
|
63
|
+
readonly dependency: boolean;
|
|
64
|
+
readonly unreadable: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function createJscpdFallowCoexistenceService(): JscpdFallowCoexistenceService {
|
|
68
|
+
const owner = new FallowWorkflowOwner();
|
|
69
|
+
return {
|
|
70
|
+
evaluateEffect: (context) => owner.evaluateEffect(context),
|
|
71
|
+
current: () => owner.current(),
|
|
72
|
+
automaticAllowed: () => owner.current().automaticAllowed,
|
|
73
|
+
takeNotice: () => owner.takeNotice(),
|
|
74
|
+
reset: () => owner.reset(),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function createJscpdFallowCoexistenceLayer() {
|
|
79
|
+
const owner = new FallowWorkflowOwner();
|
|
80
|
+
return Layer.succeed(JscpdFallowWorkflow, {
|
|
81
|
+
evaluate: (context) => owner.evaluateEffect(context),
|
|
82
|
+
current: Effect.sync(() => owner.current()),
|
|
83
|
+
takeNotice: Effect.sync(() => owner.takeNotice()),
|
|
84
|
+
reset: Effect.sync(() => owner.reset()),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
interface FallowOwnerState {
|
|
89
|
+
readonly generation: number;
|
|
90
|
+
readonly value: JscpdFallowCoexistenceState;
|
|
91
|
+
readonly noticeDelivered: boolean;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
class FallowWorkflowOwner {
|
|
95
|
+
readonly #state = MutableRef.make<FallowOwnerState>({
|
|
96
|
+
generation: 0,
|
|
97
|
+
value: absentState(),
|
|
98
|
+
noticeDelivered: false,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
evaluateEffect(
|
|
102
|
+
context: JscpdFallowCoexistenceContext,
|
|
103
|
+
): Effect.Effect<JscpdFallowCoexistenceState, never, JscpdFileSystem> {
|
|
104
|
+
return Effect.suspend(() => {
|
|
105
|
+
const generation = this.#beginEvaluation();
|
|
106
|
+
return evaluateJscpdFallowCoexistenceEffect(context).pipe(
|
|
107
|
+
Effect.map((value) => this.#commitEvaluation(generation, value)),
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
current(): JscpdFallowCoexistenceState {
|
|
113
|
+
return MutableRef.get(this.#state).value;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
takeNotice(): string | undefined {
|
|
117
|
+
const current = MutableRef.get(this.#state);
|
|
118
|
+
if (current.noticeDelivered || !current.value.notice) return undefined;
|
|
119
|
+
MutableRef.set(this.#state, { ...current, noticeDelivered: true });
|
|
120
|
+
return current.value.notice;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
reset(): void {
|
|
124
|
+
const current = MutableRef.get(this.#state);
|
|
125
|
+
MutableRef.set(this.#state, {
|
|
126
|
+
generation: current.generation + 1,
|
|
127
|
+
value: absentState(),
|
|
128
|
+
noticeDelivered: false,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
#beginEvaluation(): number {
|
|
133
|
+
const current = MutableRef.get(this.#state);
|
|
134
|
+
const generation = current.generation + 1;
|
|
135
|
+
MutableRef.set(this.#state, { ...current, generation });
|
|
136
|
+
return generation;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
#commitEvaluation(
|
|
140
|
+
generation: number,
|
|
141
|
+
value: JscpdFallowCoexistenceState,
|
|
142
|
+
): JscpdFallowCoexistenceState {
|
|
143
|
+
const current = MutableRef.get(this.#state);
|
|
144
|
+
if (current.generation !== generation) return current.value;
|
|
145
|
+
MutableRef.set(this.#state, { ...current, value });
|
|
146
|
+
return value;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function evaluateJscpdFallowCoexistenceEffect(
|
|
151
|
+
context: JscpdFallowCoexistenceContext,
|
|
152
|
+
): Effect.Effect<JscpdFallowCoexistenceState, never, JscpdFileSystem> {
|
|
153
|
+
const policy = context.policy ?? "auto";
|
|
154
|
+
if (policy === "on-demand") return Effect.succeed(explicitOnDemandState());
|
|
155
|
+
if (policy === "allow") return Effect.succeed(explicitAllowState());
|
|
156
|
+
|
|
157
|
+
return Effect.gen(function* () {
|
|
158
|
+
const project = context.trusted
|
|
159
|
+
? yield* canonicalDirectoryEffect(context.cwd).pipe(
|
|
160
|
+
Effect.catchAll(() => Effect.succeed(undefined)),
|
|
161
|
+
)
|
|
162
|
+
: undefined;
|
|
163
|
+
const projectSignals = project
|
|
164
|
+
? yield* inspectProjectSignalsEffect(project)
|
|
165
|
+
: context.trusted
|
|
166
|
+
? unreadableProjectSignals()
|
|
167
|
+
: emptyProjectSignals();
|
|
168
|
+
return coexistenceStateFromSignals(
|
|
169
|
+
projectSignals,
|
|
170
|
+
context.fallowToolAvailable,
|
|
171
|
+
context.trusted,
|
|
172
|
+
policy,
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function coexistenceStateFromSignals(
|
|
178
|
+
projectSignals: ProjectSignals,
|
|
179
|
+
fallowToolAvailable: boolean,
|
|
180
|
+
trusted: boolean,
|
|
181
|
+
policy: JscpdFallowCoexistencePolicy,
|
|
182
|
+
): JscpdFallowCoexistenceState {
|
|
183
|
+
const signals = collectSignals(projectSignals, fallowToolAvailable);
|
|
184
|
+
if (projectSignals.duplicationConfig === "disabled") {
|
|
185
|
+
return frozenState({
|
|
186
|
+
status: "absent",
|
|
187
|
+
policy,
|
|
188
|
+
automaticAllowed: true,
|
|
189
|
+
signals,
|
|
190
|
+
statusText: "Fallow overlap: duplication explicitly disabled in Fallow configuration",
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
if (projectSignals.unreadable) return ambiguousState(policy, signals);
|
|
194
|
+
if (hasActiveDuplication(projectSignals, fallowToolAvailable)) return detectedState(signals);
|
|
195
|
+
if (hasAmbiguousPresence(projectSignals, fallowToolAvailable, trusted)) {
|
|
196
|
+
return ambiguousState(policy, signals);
|
|
197
|
+
}
|
|
198
|
+
return absentState(signals);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function hasActiveDuplication(project: ProjectSignals, fallowToolAvailable: boolean): boolean {
|
|
202
|
+
return (
|
|
203
|
+
project.duplicationConfig === "enabled" ||
|
|
204
|
+
project.script ||
|
|
205
|
+
(fallowToolAvailable && (project.configPresent || project.dependency))
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function hasAmbiguousPresence(
|
|
210
|
+
project: ProjectSignals,
|
|
211
|
+
fallowToolAvailable: boolean,
|
|
212
|
+
trusted: boolean,
|
|
213
|
+
): boolean {
|
|
214
|
+
return !trusted || fallowToolAvailable || project.configPresent || project.dependency;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function ambiguousState(
|
|
218
|
+
policy: JscpdFallowCoexistencePolicy,
|
|
219
|
+
signals: readonly JscpdFallowOverlapSignal[],
|
|
220
|
+
): JscpdFallowCoexistenceState {
|
|
221
|
+
return frozenState({
|
|
222
|
+
status: "ambiguous",
|
|
223
|
+
policy,
|
|
224
|
+
automaticAllowed: true,
|
|
225
|
+
signals,
|
|
226
|
+
statusText: "Fallow overlap: ambiguous; automatic jscpd checks remain enabled",
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function inspectProjectSignalsEffect(
|
|
231
|
+
project: string,
|
|
232
|
+
): Effect.Effect<ProjectSignals, never, JscpdFileSystem> {
|
|
233
|
+
return Effect.all([inspectFallowConfigEffect(project), inspectPackageJsonEffect(project)], {
|
|
234
|
+
concurrency: "unbounded",
|
|
235
|
+
}).pipe(
|
|
236
|
+
Effect.map(([config, packageSignals]) =>
|
|
237
|
+
Object.freeze({
|
|
238
|
+
duplicationConfig: config.duplicationConfig,
|
|
239
|
+
script: packageSignals.script,
|
|
240
|
+
configPresent: config.present,
|
|
241
|
+
dependency: packageSignals.dependency,
|
|
242
|
+
unreadable: config.unreadable || packageSignals.unreadable,
|
|
243
|
+
}),
|
|
244
|
+
),
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function inspectFallowConfigEffect(project: string) {
|
|
249
|
+
return Effect.gen(function* () {
|
|
250
|
+
for (const name of CONFIG_FILES) {
|
|
251
|
+
const file = yield* readBoundedProjectFileEffect(project, name);
|
|
252
|
+
if (file.status === "missing") continue;
|
|
253
|
+
if (file.status !== "bytes") return { present: true, unreadable: true };
|
|
254
|
+
if (name === ".fallowrc.jsonc" || name === "fallow.toml") {
|
|
255
|
+
return { present: true, unreadable: true };
|
|
256
|
+
}
|
|
257
|
+
return inspectJsonFallowConfig(file.value);
|
|
258
|
+
}
|
|
259
|
+
return { present: false, unreadable: false };
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function inspectJsonFallowConfig(bytes: Uint8Array): {
|
|
264
|
+
duplicationConfig?: "enabled" | "disabled";
|
|
265
|
+
present: boolean;
|
|
266
|
+
unreadable: boolean;
|
|
267
|
+
} {
|
|
268
|
+
try {
|
|
269
|
+
const value: unknown = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
270
|
+
if (!isRecord(value)) return { present: true, unreadable: true };
|
|
271
|
+
const duplicates = value.duplicates;
|
|
272
|
+
if (!isRecord(duplicates)) return { present: true, unreadable: false };
|
|
273
|
+
if (duplicates.enabled === false) {
|
|
274
|
+
return { duplicationConfig: "disabled", present: true, unreadable: false };
|
|
275
|
+
}
|
|
276
|
+
if (duplicates.enabled === undefined || duplicates.enabled === true) {
|
|
277
|
+
return { duplicationConfig: "enabled", present: true, unreadable: false };
|
|
278
|
+
}
|
|
279
|
+
return { present: true, unreadable: true };
|
|
280
|
+
} catch {
|
|
281
|
+
return { present: true, unreadable: true };
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function inspectPackageJsonEffect(project: string) {
|
|
286
|
+
return Effect.map(readBoundedProjectFileEffect(project, "package.json"), (file) => {
|
|
287
|
+
if (file.status === "missing") return emptyPackageSignals();
|
|
288
|
+
if (file.status !== "bytes") return unreadablePackageSignals();
|
|
289
|
+
return inspectPackageJsonBytes(file.value);
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function inspectPackageJsonBytes(bytes: Uint8Array) {
|
|
294
|
+
try {
|
|
295
|
+
const value: unknown = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
296
|
+
if (!isRecord(value)) return unreadablePackageSignals();
|
|
297
|
+
return {
|
|
298
|
+
script: hasDuplicationScript(value.scripts),
|
|
299
|
+
dependency: hasFallowDependency(value),
|
|
300
|
+
unreadable: false,
|
|
301
|
+
};
|
|
302
|
+
} catch {
|
|
303
|
+
return unreadablePackageSignals();
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function emptyPackageSignals() {
|
|
308
|
+
return { script: false, dependency: false, unreadable: false };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function unreadablePackageSignals() {
|
|
312
|
+
return { script: false, dependency: false, unreadable: true };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function hasDuplicationScript(value: unknown): boolean {
|
|
316
|
+
if (!isRecord(value)) return false;
|
|
317
|
+
return Object.values(value).some(
|
|
318
|
+
(script) => typeof script === "string" && scriptSegments(script).some(isDuplicationCommand),
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function scriptSegments(script: string): string[] {
|
|
323
|
+
return script.split(/&&|\|\||;/u).map((segment) => segment.trim());
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function isDuplicationCommand(segment: string): boolean {
|
|
327
|
+
const command = segment.match(
|
|
328
|
+
/^(?:(?:npx(?:\s+--yes)?|npm\s+exec(?:\s+--yes)?(?:\s+--)?)\s+)?fallow(?:\s+(.*))?$/u,
|
|
329
|
+
);
|
|
330
|
+
if (!command) return false;
|
|
331
|
+
const argument = command[1]?.trim().split(/\s+/u)[0];
|
|
332
|
+
return !argument || argument.startsWith("-") || isDuplicationSubcommand(argument);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function isDuplicationSubcommand(command: string): boolean {
|
|
336
|
+
return ["dupes", "audit", "all", "check-changed", "review"].includes(command);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function hasFallowDependency(manifest: Record<string, unknown>): boolean {
|
|
340
|
+
return ["dependencies", "devDependencies", "optionalDependencies"].some((field) => {
|
|
341
|
+
const dependencies = manifest[field];
|
|
342
|
+
return (
|
|
343
|
+
isRecord(dependencies) &&
|
|
344
|
+
Object.keys(dependencies).some((name) => FALLOW_DEPENDENCIES.has(name))
|
|
345
|
+
);
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
type BoundedFile =
|
|
350
|
+
| { status: "missing" }
|
|
351
|
+
| { status: "bytes"; value: Uint8Array }
|
|
352
|
+
| { status: "unsafe" | "failed" | "too-large" };
|
|
353
|
+
|
|
354
|
+
function readBoundedProjectFileEffect(
|
|
355
|
+
project: string,
|
|
356
|
+
relativePath: string,
|
|
357
|
+
): Effect.Effect<BoundedFile, never, JscpdFileSystem> {
|
|
358
|
+
return Effect.flatMap(JscpdFileSystem, (filesystem) =>
|
|
359
|
+
filesystem.canonicalize(join(project, relativePath)).pipe(
|
|
360
|
+
Effect.matchEffect({
|
|
361
|
+
onFailure: (error) =>
|
|
362
|
+
Effect.succeed<BoundedFile>(
|
|
363
|
+
error.reason === "missing" ? { status: "missing" } : { status: "failed" },
|
|
364
|
+
),
|
|
365
|
+
onSuccess: (canonical) => {
|
|
366
|
+
if (!isPathInside(project, canonical)) {
|
|
367
|
+
return Effect.succeed<BoundedFile>({ status: "unsafe" });
|
|
368
|
+
}
|
|
369
|
+
return filesystem
|
|
370
|
+
.read({
|
|
371
|
+
path: canonical,
|
|
372
|
+
maxBytes: MAX_SIGNAL_FILE_BYTES,
|
|
373
|
+
regularFileOnly: true,
|
|
374
|
+
noFollow: true,
|
|
375
|
+
limitSubject: "configuration",
|
|
376
|
+
})
|
|
377
|
+
.pipe(
|
|
378
|
+
Effect.match({
|
|
379
|
+
onFailure: (error): BoundedFile =>
|
|
380
|
+
error._tag === "JscpdLimitExceeded"
|
|
381
|
+
? { status: "too-large" }
|
|
382
|
+
: { status: "failed" },
|
|
383
|
+
onSuccess: (value): BoundedFile => ({ status: "bytes", value }),
|
|
384
|
+
}),
|
|
385
|
+
);
|
|
386
|
+
},
|
|
387
|
+
}),
|
|
388
|
+
),
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function collectSignals(
|
|
393
|
+
project: ProjectSignals,
|
|
394
|
+
fallowToolAvailable: boolean,
|
|
395
|
+
): readonly JscpdFallowOverlapSignal[] {
|
|
396
|
+
const signals: JscpdFallowOverlapSignal[] = [];
|
|
397
|
+
if (fallowToolAvailable) signals.push("active-pi-fallow-tool");
|
|
398
|
+
if (project.duplicationConfig === "enabled") signals.push("duplication-config-enabled");
|
|
399
|
+
if (project.duplicationConfig === "disabled") signals.push("duplication-config-disabled");
|
|
400
|
+
if (project.script) signals.push("duplication-script");
|
|
401
|
+
if (project.configPresent && !project.duplicationConfig) signals.push("fallow-config-present");
|
|
402
|
+
if (project.dependency) signals.push("fallow-dependency");
|
|
403
|
+
if (project.unreadable) signals.push("unreadable-signal");
|
|
404
|
+
return Object.freeze(signals);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function detectedState(signals: readonly JscpdFallowOverlapSignal[]): JscpdFallowCoexistenceState {
|
|
408
|
+
return frozenState({
|
|
409
|
+
status: "detected",
|
|
410
|
+
policy: "auto",
|
|
411
|
+
automaticAllowed: false,
|
|
412
|
+
signals,
|
|
413
|
+
statusText: "Fallow overlap: detected; automatic jscpd checks are on demand",
|
|
414
|
+
notice:
|
|
415
|
+
"Fallow duplication analysis appears active. To avoid duplicate warnings, automatic jscpd changed checks are on demand; no configuration was changed. Use /jscpd changed or a scoped /jscpd scan <target>. Set fallowCoexistence to ‘allow’ for both automatic analyzers or ‘on-demand’ to make the choice explicit.",
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function explicitOnDemandState(): JscpdFallowCoexistenceState {
|
|
420
|
+
return frozenState({
|
|
421
|
+
status: "explicit-on-demand",
|
|
422
|
+
policy: "on-demand",
|
|
423
|
+
automaticAllowed: false,
|
|
424
|
+
signals: [],
|
|
425
|
+
statusText: "Fallow coexistence: jscpd automatic checks explicitly on demand",
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function explicitAllowState(): JscpdFallowCoexistenceState {
|
|
430
|
+
return frozenState({
|
|
431
|
+
status: "explicit-allow",
|
|
432
|
+
policy: "allow",
|
|
433
|
+
automaticAllowed: true,
|
|
434
|
+
signals: [],
|
|
435
|
+
statusText: "Fallow coexistence: both automatic analyzers explicitly allowed",
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function absentState(
|
|
440
|
+
signals: readonly JscpdFallowOverlapSignal[] = [],
|
|
441
|
+
): JscpdFallowCoexistenceState {
|
|
442
|
+
return frozenState({
|
|
443
|
+
status: "absent",
|
|
444
|
+
policy: "auto",
|
|
445
|
+
automaticAllowed: true,
|
|
446
|
+
signals,
|
|
447
|
+
statusText: "Fallow overlap: not detected",
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function emptyProjectSignals(): ProjectSignals {
|
|
452
|
+
return Object.freeze({
|
|
453
|
+
script: false,
|
|
454
|
+
configPresent: false,
|
|
455
|
+
dependency: false,
|
|
456
|
+
unreadable: false,
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function unreadableProjectSignals(): ProjectSignals {
|
|
461
|
+
return Object.freeze({
|
|
462
|
+
script: false,
|
|
463
|
+
configPresent: false,
|
|
464
|
+
dependency: false,
|
|
465
|
+
unreadable: true,
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function frozenState(
|
|
470
|
+
state: Omit<JscpdFallowCoexistenceState, "signals"> & {
|
|
471
|
+
signals: readonly JscpdFallowOverlapSignal[];
|
|
472
|
+
},
|
|
473
|
+
): JscpdFallowCoexistenceState {
|
|
474
|
+
return Object.freeze({ ...state, signals: Object.freeze([...state.signals]) });
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
478
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
479
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { JscpdChangedFinding, JscpdPresentedFinding } from "./types.js";
|
|
2
|
+
|
|
3
|
+
const MAX_DISPLAY_PATH_CHARACTERS = 240;
|
|
4
|
+
|
|
5
|
+
export type JscpdDisplayFinding = JscpdChangedFinding | JscpdPresentedFinding;
|
|
6
|
+
export type JscpdFindingScope = "changed" | "project";
|
|
7
|
+
|
|
8
|
+
export interface JscpdDisplayLocation {
|
|
9
|
+
readonly label: "new in this session" | "existing match" | "current location";
|
|
10
|
+
readonly text: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Bound a user-controlled display path by Unicode code points with context at both ends. */
|
|
14
|
+
export function boundedJscpdDisplayPath(path: string): string {
|
|
15
|
+
const characters = Array.from(path);
|
|
16
|
+
if (characters.length <= MAX_DISPLAY_PATH_CHARACTERS) return path;
|
|
17
|
+
const retained = MAX_DISPLAY_PATH_CHARACTERS - 1;
|
|
18
|
+
const beginning = Math.ceil(retained / 2);
|
|
19
|
+
const ending = Math.floor(retained / 2);
|
|
20
|
+
return `${characters.slice(0, beginning).join("")}…${characters.slice(-ending).join("")}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function jscpdFindingLocations(
|
|
24
|
+
finding: JscpdDisplayFinding,
|
|
25
|
+
): readonly [JscpdDisplayLocation, JscpdDisplayLocation] {
|
|
26
|
+
const locations = finding.occurrences.map((occurrence) =>
|
|
27
|
+
Object.freeze({
|
|
28
|
+
label: jscpdOccurrenceLabel(occurrence),
|
|
29
|
+
text: `${occurrence.path}:${occurrence.startLine}-${occurrence.endLine}`,
|
|
30
|
+
}),
|
|
31
|
+
);
|
|
32
|
+
return Object.freeze(locations) as readonly [JscpdDisplayLocation, JscpdDisplayLocation];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function jscpdFindingMetadata(finding: JscpdDisplayFinding): string {
|
|
36
|
+
return `${finding.lines} lines | ${finding.tokens} tokens | ${finding.format}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Shared detail block used by slash/model text and the interactive detail view. */
|
|
40
|
+
export function jscpdFindingDetailLines(
|
|
41
|
+
finding: JscpdDisplayFinding,
|
|
42
|
+
ordinal: number,
|
|
43
|
+
total: number,
|
|
44
|
+
): readonly string[] {
|
|
45
|
+
const [first, second] = jscpdFindingLocations(finding);
|
|
46
|
+
return Object.freeze([
|
|
47
|
+
`Duplicate block ${ordinal} of ${total}`,
|
|
48
|
+
`${first.label}: ${first.text}`,
|
|
49
|
+
`${second.label}: ${second.text}`,
|
|
50
|
+
jscpdFindingMetadata(finding),
|
|
51
|
+
]);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Shared advisory and inspect/refactor/intentional-configuration guidance. */
|
|
55
|
+
export function jscpdFindingGuidance(scope: JscpdFindingScope): readonly string[] {
|
|
56
|
+
const classification =
|
|
57
|
+
scope === "changed"
|
|
58
|
+
? "“new in this session” marks a tracked changed file; “existing match” marks the other current location."
|
|
59
|
+
: "A full-project scan reports two current locations; it does not determine which location is new.";
|
|
60
|
+
return Object.freeze([
|
|
61
|
+
classification,
|
|
62
|
+
"Duplication may be intentional; inspect both locations and surrounding behavior before changing code.",
|
|
63
|
+
"If shared behavior should stay synchronized, refactor through the normal agent flow, run relevant tests, then rescan.",
|
|
64
|
+
"If the duplication is intentional, keep it or update existing jscpd ignore/exclusion configuration through the normal agent flow.",
|
|
65
|
+
]);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function jscpdOccurrenceLabel(
|
|
69
|
+
occurrence: JscpdDisplayFinding["occurrences"][number],
|
|
70
|
+
): JscpdDisplayLocation["label"] {
|
|
71
|
+
if (!("relation" in occurrence)) return "current location";
|
|
72
|
+
return occurrence.relation === "new-session" ? "new in this session" : "existing match";
|
|
73
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { createJscpdManagedRuntime } from "./effect/runtime-boundary.js";
|
|
3
|
+
import { registerJscpdExtension } from "./extension.js";
|
|
4
|
+
|
|
5
|
+
/** Public Pi extension entrypoint. */
|
|
6
|
+
export default function jscpdGuardrail(pi: ExtensionAPI): void {
|
|
7
|
+
registerJscpdExtension(pi, { runtime: createJscpdManagedRuntime() });
|
|
8
|
+
}
|