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
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { Context, Effect, Layer, MutableRef } from "effect";
|
|
5
|
+
import { JscpdFileSystem } from "./effect/services.js";
|
|
6
|
+
import { compareText, hasControlCharacters, isPathInside } from "./path-utils.js";
|
|
7
|
+
|
|
8
|
+
export const MAX_CHANGED_FILES = 1_000;
|
|
9
|
+
export const MAX_CHANGED_FILE_PATH_BYTES = 4_096;
|
|
10
|
+
|
|
11
|
+
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
|
|
12
|
+
const MUTATION_TOOL_NAMES = new Set(["edit", "write"]);
|
|
13
|
+
|
|
14
|
+
export interface JscpdMutationToolResult {
|
|
15
|
+
readonly toolName: unknown;
|
|
16
|
+
readonly input: unknown;
|
|
17
|
+
readonly isError: unknown;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface JscpdChangedFileTracker {
|
|
21
|
+
/** Bind the tracker to one project and restore only its active-branch snapshot. */
|
|
22
|
+
startEffect: (
|
|
23
|
+
cwd: string,
|
|
24
|
+
restored?: readonly string[],
|
|
25
|
+
) => Effect.Effect<void, never, JscpdFileSystem>;
|
|
26
|
+
/** Invalidate pending path work and clear all in-memory session state. */
|
|
27
|
+
reset(): void;
|
|
28
|
+
/** Record one verified built-in tool result. Returns true only for a newly tracked path. */
|
|
29
|
+
recordToolResultEffect(
|
|
30
|
+
event: JscpdMutationToolResult,
|
|
31
|
+
cwd: string,
|
|
32
|
+
): Effect.Effect<boolean, never, JscpdFileSystem>;
|
|
33
|
+
/** Record and return the canonical path even when it was already tracked. */
|
|
34
|
+
recordToolResultPathEffect: (
|
|
35
|
+
event: JscpdMutationToolResult,
|
|
36
|
+
cwd: string,
|
|
37
|
+
) => Effect.Effect<string | undefined, never, JscpdFileSystem>;
|
|
38
|
+
/** Deterministically sorted canonical project-relative paths. */
|
|
39
|
+
files(): readonly string[];
|
|
40
|
+
readonly filesEffect: Effect.Effect<readonly string[]>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface ProjectRoots {
|
|
44
|
+
readonly lexical: string;
|
|
45
|
+
readonly canonical: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface TrackerSnapshot {
|
|
49
|
+
readonly generation: number;
|
|
50
|
+
readonly roots: ProjectRoots;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface ChangedFileState {
|
|
54
|
+
readonly generation: number;
|
|
55
|
+
readonly roots?: ProjectRoots;
|
|
56
|
+
readonly files: ReadonlySet<string>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface JscpdChangedFilesEffectService {
|
|
60
|
+
readonly start: (
|
|
61
|
+
cwd: string,
|
|
62
|
+
restored?: readonly string[],
|
|
63
|
+
) => Effect.Effect<void, never, JscpdFileSystem>;
|
|
64
|
+
readonly reset: Effect.Effect<void>;
|
|
65
|
+
readonly recordToolResult: (
|
|
66
|
+
event: JscpdMutationToolResult,
|
|
67
|
+
cwd: string,
|
|
68
|
+
) => Effect.Effect<boolean, never, JscpdFileSystem>;
|
|
69
|
+
readonly recordToolResultPath: (
|
|
70
|
+
event: JscpdMutationToolResult,
|
|
71
|
+
cwd: string,
|
|
72
|
+
) => Effect.Effect<string | undefined, never, JscpdFileSystem>;
|
|
73
|
+
readonly files: Effect.Effect<readonly string[]>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const JscpdChangedFiles = Context.GenericTag<JscpdChangedFilesEffectService>(
|
|
77
|
+
"pi-jscpd/effect/ChangedFiles",
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Track a bounded, append-only set of files attributed to successful built-in edit/write results.
|
|
82
|
+
* Tool provenance must be verified by the caller; arbitrary result text and shell output are ignored.
|
|
83
|
+
*/
|
|
84
|
+
export function createJscpdChangedFileTracker(): JscpdChangedFileTracker {
|
|
85
|
+
return changedFileTrackerFor(new ChangedFileOwner());
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function createJscpdChangedFilesLayer() {
|
|
89
|
+
const owner = new ChangedFileOwner();
|
|
90
|
+
return Layer.succeed(JscpdChangedFiles, changedFilesEffectServiceFor(owner));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
class ChangedFileOwner {
|
|
94
|
+
readonly #state = MutableRef.make<ChangedFileState>({ generation: 0, files: new Set<string>() });
|
|
95
|
+
|
|
96
|
+
startEffect(
|
|
97
|
+
cwd: string,
|
|
98
|
+
restored: readonly string[] = [],
|
|
99
|
+
): Effect.Effect<void, never, JscpdFileSystem> {
|
|
100
|
+
return Effect.suspend(() => this.startPreparedEffect(cwd, restored));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
startPreparedEffect(
|
|
104
|
+
cwd: string,
|
|
105
|
+
restored: readonly string[] = [],
|
|
106
|
+
): Effect.Effect<void, never, JscpdFileSystem> {
|
|
107
|
+
const generation = this.#beginStart(restored);
|
|
108
|
+
return resolveProjectRootsEffect(cwd).pipe(
|
|
109
|
+
Effect.tap((roots) =>
|
|
110
|
+
Effect.sync(() => {
|
|
111
|
+
const current = MutableRef.get(this.#state);
|
|
112
|
+
if (current.generation === generation && roots) {
|
|
113
|
+
MutableRef.set(this.#state, { ...current, roots });
|
|
114
|
+
}
|
|
115
|
+
}),
|
|
116
|
+
),
|
|
117
|
+
Effect.asVoid,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
reset(): void {
|
|
122
|
+
const current = MutableRef.get(this.#state);
|
|
123
|
+
MutableRef.set(this.#state, {
|
|
124
|
+
generation: current.generation + 1,
|
|
125
|
+
files: new Set<string>(),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
recordEffect(
|
|
130
|
+
event: JscpdMutationToolResult,
|
|
131
|
+
cwd: string,
|
|
132
|
+
): Effect.Effect<
|
|
133
|
+
{ readonly path: string; readonly added: boolean } | undefined,
|
|
134
|
+
never,
|
|
135
|
+
JscpdFileSystem
|
|
136
|
+
> {
|
|
137
|
+
return Effect.suspend(() => {
|
|
138
|
+
const state = MutableRef.get(this.#state);
|
|
139
|
+
const rawPath = successfulMutationPath(event);
|
|
140
|
+
const snapshot = activeTrackerSnapshot(state.generation, state.roots);
|
|
141
|
+
if (!rawPath || !snapshot) return Effect.succeed(undefined);
|
|
142
|
+
return canonicalChangedFileEffect(rawPath, cwd, snapshot.roots).pipe(
|
|
143
|
+
Effect.map((projectPath) => this.#commitMutation(snapshot, projectPath)),
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
files(): readonly string[] {
|
|
149
|
+
return Object.freeze([...MutableRef.get(this.#state).files].sort(compareText));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
#beginStart(restored: readonly string[]): number {
|
|
153
|
+
const current = MutableRef.get(this.#state);
|
|
154
|
+
const generation = current.generation + 1;
|
|
155
|
+
MutableRef.set(this.#state, {
|
|
156
|
+
generation,
|
|
157
|
+
files: new Set(restored.slice(0, MAX_CHANGED_FILES).filter(isSafeChangedFilePath)),
|
|
158
|
+
});
|
|
159
|
+
return generation;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
#commitMutation(
|
|
163
|
+
snapshot: TrackerSnapshot,
|
|
164
|
+
projectPath: string | undefined,
|
|
165
|
+
): { readonly path: string; readonly added: boolean } | undefined {
|
|
166
|
+
const current = MutableRef.get(this.#state);
|
|
167
|
+
if (!projectPath || !isCurrentTrackerSnapshot(snapshot, current.generation, current.roots)) {
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
const alreadyTracked = current.files.has(projectPath);
|
|
171
|
+
const hasCapacity = alreadyTracked || current.files.size < MAX_CHANGED_FILES;
|
|
172
|
+
if (!alreadyTracked && hasCapacity) {
|
|
173
|
+
MutableRef.set(this.#state, { ...current, files: new Set([...current.files, projectPath]) });
|
|
174
|
+
}
|
|
175
|
+
return Object.freeze({ path: projectPath, added: !alreadyTracked && hasCapacity });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function changedFileTrackerFor(owner: ChangedFileOwner): JscpdChangedFileTracker {
|
|
180
|
+
return {
|
|
181
|
+
startEffect: (cwd, restored) => owner.startEffect(cwd, restored),
|
|
182
|
+
reset: () => owner.reset(),
|
|
183
|
+
recordToolResultEffect: (event, cwd) =>
|
|
184
|
+
owner.recordEffect(event, cwd).pipe(Effect.map((result) => result?.added ?? false)),
|
|
185
|
+
recordToolResultPathEffect: (event, cwd) =>
|
|
186
|
+
owner.recordEffect(event, cwd).pipe(Effect.map((result) => result?.path)),
|
|
187
|
+
files: () => owner.files(),
|
|
188
|
+
filesEffect: Effect.sync(() => owner.files()),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function changedFilesEffectServiceFor(owner: ChangedFileOwner): JscpdChangedFilesEffectService {
|
|
193
|
+
return {
|
|
194
|
+
start: (cwd, restored) => owner.startEffect(cwd, restored),
|
|
195
|
+
reset: Effect.sync(() => owner.reset()),
|
|
196
|
+
recordToolResult: (event, cwd) =>
|
|
197
|
+
owner.recordEffect(event, cwd).pipe(Effect.map((result) => result?.added ?? false)),
|
|
198
|
+
recordToolResultPath: (event, cwd) =>
|
|
199
|
+
owner.recordEffect(event, cwd).pipe(Effect.map((result) => result?.path)),
|
|
200
|
+
files: Effect.sync(() => owner.files()),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Validate the portable path shape accepted in persisted session snapshots. */
|
|
205
|
+
export function isSafeChangedFilePath(value: unknown): value is string {
|
|
206
|
+
if (
|
|
207
|
+
typeof value !== "string" ||
|
|
208
|
+
value.length === 0 ||
|
|
209
|
+
Buffer.byteLength(value) > MAX_CHANGED_FILE_PATH_BYTES ||
|
|
210
|
+
value.includes("\\") ||
|
|
211
|
+
hasControlCharacters(value) ||
|
|
212
|
+
isAbsolute(value)
|
|
213
|
+
) {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
const segments = value.split("/");
|
|
217
|
+
return segments.every((segment) => segment.length > 0 && segment !== "." && segment !== "..");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function resolveProjectRootsEffect(
|
|
221
|
+
cwd: string,
|
|
222
|
+
): Effect.Effect<ProjectRoots | undefined, never, JscpdFileSystem> {
|
|
223
|
+
if (!isSafeRawPath(cwd) || !isAbsolute(cwd)) return Effect.succeed(undefined);
|
|
224
|
+
const lexical = resolve(cwd);
|
|
225
|
+
return Effect.flatMap(JscpdFileSystem, (filesystem) =>
|
|
226
|
+
Effect.flatMap(filesystem.canonicalize(lexical), (canonical) =>
|
|
227
|
+
Effect.map(filesystem.metadata(canonical), (metadata) => ({ canonical, metadata })),
|
|
228
|
+
),
|
|
229
|
+
).pipe(
|
|
230
|
+
Effect.match({
|
|
231
|
+
onFailure: () => undefined,
|
|
232
|
+
onSuccess: ({ canonical, metadata }) =>
|
|
233
|
+
metadata.kind === "directory" && isAbsolute(canonical) && isSafeRawPath(canonical)
|
|
234
|
+
? Object.freeze({ lexical, canonical })
|
|
235
|
+
: undefined,
|
|
236
|
+
}),
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function canonicalChangedFileEffect(
|
|
241
|
+
rawPath: string,
|
|
242
|
+
cwd: string,
|
|
243
|
+
roots: ProjectRoots,
|
|
244
|
+
): Effect.Effect<string | undefined, never, JscpdFileSystem> {
|
|
245
|
+
const candidate = lexicalChangedFileCandidate(rawPath, cwd, roots);
|
|
246
|
+
if (!candidate) return Effect.succeed(undefined);
|
|
247
|
+
return canonicalRegularFileEffect(candidate).pipe(
|
|
248
|
+
Effect.map((canonical) =>
|
|
249
|
+
canonical && isPathInside(roots.canonical, canonical)
|
|
250
|
+
? portableProjectPath(roots.canonical, canonical)
|
|
251
|
+
: undefined,
|
|
252
|
+
),
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function lexicalChangedFileCandidate(
|
|
257
|
+
rawPath: string,
|
|
258
|
+
cwd: string,
|
|
259
|
+
roots: ProjectRoots,
|
|
260
|
+
): string | undefined {
|
|
261
|
+
const normalizedPath = safeNormalizedToolPath(rawPath);
|
|
262
|
+
const lexicalCwd = matchingProjectCwd(cwd, roots);
|
|
263
|
+
if (!normalizedPath || !lexicalCwd) return undefined;
|
|
264
|
+
|
|
265
|
+
const candidate = resolve(lexicalCwd, normalizedPath);
|
|
266
|
+
return isInsideEitherProjectRoot(candidate, lexicalCwd, roots.canonical) ? candidate : undefined;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function safeNormalizedToolPath(rawPath: string): string | undefined {
|
|
270
|
+
if (!isSafeRawPath(rawPath)) return undefined;
|
|
271
|
+
try {
|
|
272
|
+
const normalizedPath = normalizePiToolPath(rawPath);
|
|
273
|
+
return isSafeRawPath(normalizedPath) ? normalizedPath : undefined;
|
|
274
|
+
} catch {
|
|
275
|
+
return undefined;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function matchingProjectCwd(cwd: string, roots: ProjectRoots): string | undefined {
|
|
280
|
+
if (!isSafeRawPath(cwd) || !isAbsolute(cwd)) return undefined;
|
|
281
|
+
const lexicalCwd = resolve(cwd);
|
|
282
|
+
return lexicalCwd === roots.lexical || lexicalCwd === roots.canonical ? lexicalCwd : undefined;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function isInsideEitherProjectRoot(
|
|
286
|
+
candidate: string,
|
|
287
|
+
lexicalRoot: string,
|
|
288
|
+
canonicalRoot: string,
|
|
289
|
+
): boolean {
|
|
290
|
+
return isPathInside(lexicalRoot, candidate) || isPathInside(canonicalRoot, candidate);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function canonicalRegularFileEffect(
|
|
294
|
+
candidate: string,
|
|
295
|
+
): Effect.Effect<string | undefined, never, JscpdFileSystem> {
|
|
296
|
+
return Effect.flatMap(JscpdFileSystem, (filesystem) =>
|
|
297
|
+
Effect.flatMap(filesystem.canonicalize(candidate), (canonical) =>
|
|
298
|
+
Effect.map(filesystem.metadata(canonical), (metadata) =>
|
|
299
|
+
metadata.kind === "file" ? canonical : undefined,
|
|
300
|
+
),
|
|
301
|
+
),
|
|
302
|
+
).pipe(Effect.catchAll(() => Effect.succeed(undefined)));
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function portableProjectPath(projectRoot: string, canonical: string): string | undefined {
|
|
306
|
+
const projectRelative = relative(projectRoot, canonical);
|
|
307
|
+
const portable = sep === "/" ? projectRelative : projectRelative.split(sep).join("/");
|
|
308
|
+
return isSafeChangedFilePath(portable) ? portable : undefined;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function normalizePiToolPath(value: string): string {
|
|
312
|
+
let normalized = value.replace(UNICODE_SPACES, " ");
|
|
313
|
+
if (normalized.startsWith("@")) normalized = normalized.slice(1);
|
|
314
|
+
if (process.platform === "win32") normalized = normalizeWindowsShellPath(normalized);
|
|
315
|
+
if (normalized === "~") return homedir();
|
|
316
|
+
if (
|
|
317
|
+
normalized.startsWith("~/") ||
|
|
318
|
+
(process.platform === "win32" && normalized.startsWith("~\\"))
|
|
319
|
+
) {
|
|
320
|
+
normalized = join(homedir(), normalized.slice(2));
|
|
321
|
+
}
|
|
322
|
+
if (/^file:\/\//.test(normalized)) return fileURLToPath(normalized);
|
|
323
|
+
return normalized;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Match Pi's conversion of Git Bash, MSYS, Cygwin, and WSL drive paths on Windows. */
|
|
327
|
+
export function normalizeWindowsShellPath(value: string): string {
|
|
328
|
+
if (!value.startsWith("/") || value.startsWith("//") || value.includes("\\")) return value;
|
|
329
|
+
const match = value.match(/^\/(?:mnt\/|cygdrive\/)?([a-z])(?:\/(.*))?$/i);
|
|
330
|
+
if (!match) return value;
|
|
331
|
+
const suffix = match[2]?.replaceAll("/", "\\");
|
|
332
|
+
return `${match[1]?.toUpperCase()}:\\${suffix ?? ""}`;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function successfulMutationPath(event: JscpdMutationToolResult): string | undefined {
|
|
336
|
+
if (event.isError !== false || !isMutationToolName(event.toolName) || !isRecord(event.input)) {
|
|
337
|
+
return undefined;
|
|
338
|
+
}
|
|
339
|
+
return typeof event.input.path === "string" ? event.input.path : undefined;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function isMutationToolName(value: unknown): value is string {
|
|
343
|
+
return typeof value === "string" && MUTATION_TOOL_NAMES.has(value);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function activeTrackerSnapshot(
|
|
347
|
+
generation: number,
|
|
348
|
+
roots: ProjectRoots | undefined,
|
|
349
|
+
): TrackerSnapshot | undefined {
|
|
350
|
+
return roots ? { generation, roots } : undefined;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function isCurrentTrackerSnapshot(
|
|
354
|
+
snapshot: TrackerSnapshot,
|
|
355
|
+
generation: number,
|
|
356
|
+
roots: ProjectRoots | undefined,
|
|
357
|
+
): boolean {
|
|
358
|
+
return snapshot.generation === generation && snapshot.roots === roots;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function isSafeRawPath(value: unknown): value is string {
|
|
362
|
+
return (
|
|
363
|
+
typeof value === "string" &&
|
|
364
|
+
value.length > 0 &&
|
|
365
|
+
Buffer.byteLength(value) <= MAX_CHANGED_FILE_PATH_BYTES &&
|
|
366
|
+
!hasControlCharacters(value)
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
|
|
371
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
372
|
+
}
|