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.
Files changed (49) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/CONTRIBUTING.md +144 -0
  3. package/LICENSE +21 -0
  4. package/README.md +231 -0
  5. package/SECURITY.md +93 -0
  6. package/docs/automatic-checkpoint.md +235 -0
  7. package/docs/compatibility.md +119 -0
  8. package/docs/effect-architecture.md +128 -0
  9. package/docs/fallow-coexistence.md +120 -0
  10. package/docs/overlay-interaction.md +347 -0
  11. package/docs/release.md +115 -0
  12. package/package.json +86 -0
  13. package/scripts/check-compatibility.mjs +103 -0
  14. package/skills/jscpd/SKILL.md +90 -0
  15. package/src/acknowledgements.ts +268 -0
  16. package/src/automatic.ts +396 -0
  17. package/src/baseline.ts +400 -0
  18. package/src/capability.ts +569 -0
  19. package/src/changed-files.ts +372 -0
  20. package/src/changed.ts +548 -0
  21. package/src/clone-identity.ts +373 -0
  22. package/src/config.ts +414 -0
  23. package/src/contract.ts +39 -0
  24. package/src/dispatch.ts +90 -0
  25. package/src/effect/clock.ts +10 -0
  26. package/src/effect/errors.ts +311 -0
  27. package/src/effect/filesystem.ts +240 -0
  28. package/src/effect/runtime-boundary.ts +25 -0
  29. package/src/effect/runtime-contract.ts +18 -0
  30. package/src/effect/services.ts +131 -0
  31. package/src/extension.ts +708 -0
  32. package/src/fallow.ts +479 -0
  33. package/src/finding-presentation.ts +73 -0
  34. package/src/index.ts +8 -0
  35. package/src/jscpd-report.ts +819 -0
  36. package/src/jscpd.ts +748 -0
  37. package/src/overlay.ts +1166 -0
  38. package/src/parser.ts +189 -0
  39. package/src/path-utils.ts +44 -0
  40. package/src/presentation.ts +232 -0
  41. package/src/process.ts +425 -0
  42. package/src/registry.ts +102 -0
  43. package/src/scan.ts +441 -0
  44. package/src/scheduler.ts +434 -0
  45. package/src/session-state.ts +229 -0
  46. package/src/status.ts +534 -0
  47. package/src/types.ts +334 -0
  48. package/src/value-utils.ts +14 -0
  49. package/src/verification.ts +220 -0
package/src/parser.ts ADDED
@@ -0,0 +1,189 @@
1
+ import {
2
+ getJscpdCommandSpec,
3
+ JSCPD_MAX_ARGUMENT_LENGTH,
4
+ jscpdCommandNames,
5
+ jscpdCommandRegistry,
6
+ } from "./registry.js";
7
+ import type { JscpdInputError, JscpdParseResult, JscpdSlashParseResult } from "./types.js";
8
+
9
+ const MAX_ARGUMENTS = Math.max(...jscpdCommandRegistry.map(({ maxArguments }) => maxArguments));
10
+ const MAX_SLASH_INPUT_LENGTH = (MAX_ARGUMENTS + 1) * (JSCPD_MAX_ARGUMENT_LENGTH + 1);
11
+
12
+ export function parseJscpdCommand(command: unknown, args?: unknown): JscpdParseResult {
13
+ if (typeof command !== "string" || command.length === 0) {
14
+ return invalid("invalid-command", "A jscpd command is required.");
15
+ }
16
+
17
+ const spec = getJscpdCommandSpec(command);
18
+ if (!spec) {
19
+ return invalid(
20
+ "unsupported-command",
21
+ `Unsupported jscpd command. Supported commands: ${jscpdCommandNames.join(", ")}.`,
22
+ );
23
+ }
24
+
25
+ if (args !== undefined && !Array.isArray(args)) {
26
+ return invalid(
27
+ "invalid-arguments",
28
+ "Arguments must be provided as an array of shell-free string tokens.",
29
+ );
30
+ }
31
+
32
+ const tokens = args === undefined ? [] : args;
33
+ if (tokens.length > spec.maxArguments) {
34
+ return invalid(
35
+ "too-many-arguments",
36
+ `${spec.name} accepts at most ${spec.maxArguments} argument tokens.`,
37
+ );
38
+ }
39
+
40
+ for (const token of tokens) {
41
+ if (typeof token !== "string" || token.length === 0 || token.includes("\0")) {
42
+ return invalid(
43
+ "invalid-arguments",
44
+ "Argument tokens must be non-empty strings without null bytes.",
45
+ );
46
+ }
47
+ if (token.length > JSCPD_MAX_ARGUMENT_LENGTH) {
48
+ return invalid(
49
+ "argument-too-long",
50
+ `Argument tokens must not exceed ${JSCPD_MAX_ARGUMENT_LENGTH} characters.`,
51
+ );
52
+ }
53
+ }
54
+
55
+ return { ok: true, invocation: { command: spec.name, args: [...tokens] } };
56
+ }
57
+
58
+ export function parseJscpdSlashArgs(rawArgs: string): JscpdSlashParseResult {
59
+ if (rawArgs.length > MAX_SLASH_INPUT_LENGTH) {
60
+ return invalid("input-too-long", "The /jscpd argument text is too long.");
61
+ }
62
+
63
+ const tokenized = tokenize(rawArgs);
64
+ if (!tokenized.ok) return tokenized;
65
+ if (tokenized.tokens.length === 0) return { ok: true, kind: "bare" };
66
+
67
+ const [command, ...args] = tokenized.tokens;
68
+ const parsed = parseJscpdCommand(command, args);
69
+ if (!parsed.ok) return parsed;
70
+ return { ok: true, kind: "command", invocation: parsed.invocation };
71
+ }
72
+
73
+ interface TokenizeResult {
74
+ ok: true;
75
+ tokens: string[];
76
+ }
77
+
78
+ type TokenQuote = "'" | '"';
79
+
80
+ interface TokenizerState {
81
+ tokens: string[];
82
+ current: string;
83
+ quote: TokenQuote | undefined;
84
+ tokenStarted: boolean;
85
+ }
86
+
87
+ function tokenize(input: string): TokenizeResult | { ok: false; error: JscpdInputError } {
88
+ const state = createTokenizerState();
89
+ for (let index = 0; index < input.length; index += 1) {
90
+ index = consumeTokenCharacter(state, input, index);
91
+ }
92
+
93
+ if (state.quote) return invalid("unclosed-quote", "Unclosed quote in /jscpd arguments.");
94
+ flushToken(state);
95
+ return { ok: true, tokens: state.tokens };
96
+ }
97
+
98
+ function createTokenizerState(): TokenizerState {
99
+ return { tokens: [], current: "", quote: undefined, tokenStarted: false };
100
+ }
101
+
102
+ function consumeTokenCharacter(state: TokenizerState, input: string, index: number): number {
103
+ const char = input[index] ?? "";
104
+ if (state.quote) return consumeQuotedCharacter(state, input, index, char);
105
+ return consumeUnquotedCharacter(state, input, index, char);
106
+ }
107
+
108
+ function consumeQuotedCharacter(
109
+ state: TokenizerState,
110
+ input: string,
111
+ index: number,
112
+ char: string,
113
+ ): number {
114
+ if (char === state.quote) {
115
+ state.quote = undefined;
116
+ return index;
117
+ }
118
+ if (state.quote === '"' && char === "\\") {
119
+ return consumeEscape(state, input, index, isDoubleQuotedEscape);
120
+ }
121
+ state.current += char;
122
+ return index;
123
+ }
124
+
125
+ function consumeUnquotedCharacter(
126
+ state: TokenizerState,
127
+ input: string,
128
+ index: number,
129
+ char: string,
130
+ ): number {
131
+ if (isTokenWhitespace(char)) {
132
+ flushToken(state);
133
+ return index;
134
+ }
135
+
136
+ state.tokenStarted = true;
137
+ if (isQuote(char)) {
138
+ state.quote = char;
139
+ return index;
140
+ }
141
+ if (char === "\\") return consumeEscape(state, input, index, isUnquotedEscape);
142
+ state.current += char;
143
+ return index;
144
+ }
145
+
146
+ function consumeEscape(
147
+ state: TokenizerState,
148
+ input: string,
149
+ index: number,
150
+ canEscape: (char: string) => boolean,
151
+ ): number {
152
+ const next = input[index + 1];
153
+ if (next !== undefined && canEscape(next)) {
154
+ state.current += next;
155
+ return index + 1;
156
+ }
157
+ state.current += "\\";
158
+ return index;
159
+ }
160
+
161
+ function flushToken(state: TokenizerState): void {
162
+ if (!state.tokenStarted) return;
163
+ state.tokens.push(state.current);
164
+ state.current = "";
165
+ state.tokenStarted = false;
166
+ }
167
+
168
+ function isQuote(char: string): char is TokenQuote {
169
+ return char === "'" || char === '"';
170
+ }
171
+
172
+ function isDoubleQuotedEscape(char: string): boolean {
173
+ return char === '"' || char === "\\";
174
+ }
175
+
176
+ function isUnquotedEscape(char: string): boolean {
177
+ return isTokenWhitespace(char) || isQuote(char) || char === "\\";
178
+ }
179
+
180
+ function isTokenWhitespace(char: string): boolean {
181
+ return /\s/.test(char);
182
+ }
183
+
184
+ function invalid(
185
+ code: JscpdInputError["code"],
186
+ message: string,
187
+ ): { ok: false; error: JscpdInputError } {
188
+ return { ok: false, error: { code, message } };
189
+ }
@@ -0,0 +1,44 @@
1
+ import { isAbsolute, relative, sep } from "node:path";
2
+ import { Effect } from "effect";
3
+ import { JscpdFileSystem } from "./effect/services.js";
4
+
5
+ /** Resolve an existing absolute directory through the injected bounded filesystem. */
6
+ export function canonicalDirectoryEffect(cwd: string) {
7
+ if (!isAbsolute(cwd)) return Effect.succeed<string | undefined>(undefined);
8
+ return Effect.flatMap(JscpdFileSystem, (filesystem) =>
9
+ Effect.all([filesystem.canonicalize(cwd), filesystem.metadata(cwd)], {
10
+ concurrency: "unbounded",
11
+ }),
12
+ ).pipe(
13
+ Effect.map(([canonical, metadata]) => (metadata.kind === "directory" ? canonical : undefined)),
14
+ );
15
+ }
16
+
17
+ /** Resolve a canonical directory and collapse filesystem failures at the application edge. */
18
+ export function optionalCanonicalDirectoryEffect(cwd: string) {
19
+ return canonicalDirectoryEffect(cwd).pipe(
20
+ Effect.catchAll(() => Effect.succeed<string | undefined>(undefined)),
21
+ );
22
+ }
23
+
24
+ /** Reject control characters before paths or labels reach filesystem APIs. */
25
+ export function hasControlCharacters(value: string): boolean {
26
+ for (const character of value) {
27
+ const codePoint = character.codePointAt(0) ?? 0;
28
+ if (codePoint <= 0x1f || codePoint === 0x7f) return true;
29
+ }
30
+ return false;
31
+ }
32
+
33
+ /** Test containment without treating a sibling path with the same prefix as a child. */
34
+ export function isPathInside(parent: string, candidate: string): boolean {
35
+ const fromParent = relative(parent, candidate);
36
+ return (
37
+ fromParent === "" ||
38
+ (fromParent !== ".." && !fromParent.startsWith(`..${sep}`) && !isAbsolute(fromParent))
39
+ );
40
+ }
41
+
42
+ export function compareText(left: string, right: string): number {
43
+ return left < right ? -1 : left > right ? 1 : 0;
44
+ }
@@ -0,0 +1,232 @@
1
+ import {
2
+ boundedJscpdDisplayPath,
3
+ jscpdFindingDetailLines,
4
+ jscpdFindingGuidance,
5
+ } from "./finding-presentation.js";
6
+ import type {
7
+ JscpdChangedFinding,
8
+ JscpdChangedResult,
9
+ JscpdCompletedResult,
10
+ JscpdPresentedFinding,
11
+ JscpdScanReport,
12
+ JscpdScanSummary,
13
+ } from "./types.js";
14
+
15
+ const DEFAULT_MAX_PRESENTED_FINDINGS = 10;
16
+ const MAX_CONFIGURED_PRESENTED_FINDINGS = 100;
17
+
18
+ /** Build bounded model and terminal views from one normalized jscpd report. */
19
+ export function presentJscpdScan(
20
+ report: JscpdScanReport,
21
+ configuredMaxFindings = DEFAULT_MAX_PRESENTED_FINDINGS,
22
+ overlayFindingLimit?: number,
23
+ ): JscpdCompletedResult {
24
+ const summary = scanSummary(report);
25
+ const maxFindings = boundedFindingLimit(configuredMaxFindings);
26
+ const overlayLimit = boundedOverlayFindingLimit(overlayFindingLimit);
27
+ const retained = report.clonePairs
28
+ .slice(0, Math.max(maxFindings, overlayLimit ?? 0))
29
+ .map(presentFinding);
30
+ const findings = Object.freeze(retained.slice(0, maxFindings));
31
+ const omittedFindings = Math.max(0, report.clonePairs.length - findings.length);
32
+ const overlayCache = overlayLimit
33
+ ? Object.freeze({
34
+ findings: Object.freeze(retained.slice(0, overlayLimit)),
35
+ omittedFindings: Math.max(0, report.clonePairs.length - overlayLimit),
36
+ })
37
+ : undefined;
38
+ const outcome = findings.length === 0 ? "clean" : "findings";
39
+
40
+ if (outcome === "clean") {
41
+ const text = `jscpd scan clean: 0 duplicate blocks across ${summary.lines} lines and ${summary.tokens} tokens in ${summary.sources} sources.`;
42
+ return {
43
+ status: "completed",
44
+ outcome,
45
+ message: text,
46
+ terminalMessage: text,
47
+ summary,
48
+ findings,
49
+ omittedFindings,
50
+ ...(overlayCache ? { overlayCache } : {}),
51
+ };
52
+ }
53
+
54
+ const headline = `jscpd found ${plural(summary.clones, "duplicate block")}: ${summary.duplicatedLines} duplicated lines (${formatPercentage(summary.percentage)}) and ${summary.duplicatedTokens} duplicated tokens (${formatPercentage(summary.percentageTokens)}) across ${summary.sources} sources.`;
55
+ const findingLines = findings.flatMap((finding, index) =>
56
+ jscpdFindingDetailLines(finding, index + 1, summary.clones),
57
+ );
58
+ const omittedLine =
59
+ omittedFindings > 0
60
+ ? [`${plural(omittedFindings, "additional duplicate block")} omitted by the display limit.`]
61
+ : [];
62
+ const message = [
63
+ headline,
64
+ ...findingLines,
65
+ ...omittedLine,
66
+ ...jscpdFindingGuidance("project"),
67
+ ].join("\n");
68
+
69
+ return {
70
+ status: "completed",
71
+ outcome,
72
+ message,
73
+ terminalMessage: message,
74
+ summary,
75
+ findings,
76
+ omittedFindings,
77
+ ...(overlayCache ? { overlayCache } : {}),
78
+ };
79
+ }
80
+
81
+ /** Present only unacknowledged net-new groups involving session-owned files. */
82
+ export function presentJscpdChanged(
83
+ clonePairs: readonly JscpdScanReport["clonePairs"][number][],
84
+ changedFiles: ReadonlySet<string>,
85
+ configuredMaxFindings = DEFAULT_MAX_PRESENTED_FINDINGS,
86
+ ambiguousFindings = 0,
87
+ overlayFindingLimit?: number,
88
+ ): JscpdChangedResult {
89
+ const maxFindings = boundedFindingLimit(configuredMaxFindings);
90
+ const overlayLimit = boundedOverlayFindingLimit(overlayFindingLimit);
91
+ const retained = clonePairs
92
+ .slice(0, Math.max(maxFindings, overlayLimit ?? 0))
93
+ .map((pair) => presentChangedFinding(pair, changedFiles));
94
+ const findings = Object.freeze(retained.slice(0, maxFindings));
95
+ const omittedFindings = Math.max(0, clonePairs.length - findings.length);
96
+ const overlayCache = overlayLimit
97
+ ? Object.freeze({
98
+ findings: Object.freeze(retained.slice(0, overlayLimit)),
99
+ omittedFindings: Math.max(0, clonePairs.length - overlayLimit),
100
+ })
101
+ : undefined;
102
+ const outcome = findings.length === 0 ? "clean" : "findings";
103
+ const ambiguity =
104
+ ambiguousFindings > 0
105
+ ? ` ${plural(ambiguousFindings, "clone group")} could not be classified conservatively.`
106
+ : "";
107
+ if (outcome === "clean") {
108
+ const message = `jscpd changed: no unacknowledged new duplicate blocks involve session-owned changed files.${ambiguity}`;
109
+ return Object.freeze({
110
+ status: "changed",
111
+ outcome,
112
+ scanPerformed: true,
113
+ message,
114
+ terminalMessage: message,
115
+ findings,
116
+ omittedFindings,
117
+ ambiguousFindings,
118
+ ...(overlayCache ? { overlayCache } : {}),
119
+ });
120
+ }
121
+ const headline = `jscpd changed found ${plural(clonePairs.length, "unacknowledged new duplicate block")} involving session-owned changed files.`;
122
+ const findingLines = findings.flatMap((finding, index) =>
123
+ jscpdFindingDetailLines(finding, index + 1, clonePairs.length),
124
+ );
125
+ const omittedLine =
126
+ omittedFindings > 0
127
+ ? [
128
+ `${plural(omittedFindings, "additional new duplicate block")} omitted by the display limit and not acknowledged.`,
129
+ ]
130
+ : [];
131
+ const message = [
132
+ headline,
133
+ ...findingLines,
134
+ ...omittedLine,
135
+ ambiguity.trim(),
136
+ ...jscpdFindingGuidance("changed"),
137
+ ]
138
+ .filter(Boolean)
139
+ .join("\n");
140
+ return Object.freeze({
141
+ status: "changed",
142
+ outcome,
143
+ scanPerformed: true,
144
+ message,
145
+ terminalMessage: message,
146
+ findings,
147
+ omittedFindings,
148
+ ambiguousFindings,
149
+ ...(overlayCache ? { overlayCache } : {}),
150
+ });
151
+ }
152
+
153
+ function boundedFindingLimit(value: number): number {
154
+ return Number.isSafeInteger(value) && value >= 1 && value <= MAX_CONFIGURED_PRESENTED_FINDINGS
155
+ ? value
156
+ : DEFAULT_MAX_PRESENTED_FINDINGS;
157
+ }
158
+
159
+ function boundedOverlayFindingLimit(value: number | undefined): number | undefined {
160
+ return value !== undefined &&
161
+ Number.isSafeInteger(value) &&
162
+ value >= 1 &&
163
+ value <= MAX_CONFIGURED_PRESENTED_FINDINGS
164
+ ? value
165
+ : undefined;
166
+ }
167
+
168
+ function scanSummary(report: JscpdScanReport): JscpdScanSummary {
169
+ const total = report.statistics.total;
170
+ return Object.freeze({
171
+ clones: total.clones,
172
+ duplicatedLines: total.duplicatedLines,
173
+ duplicatedTokens: total.duplicatedTokens,
174
+ lines: total.lines,
175
+ tokens: total.tokens,
176
+ sources: total.sources,
177
+ percentage: total.percentage,
178
+ percentageTokens: total.percentageTokens,
179
+ });
180
+ }
181
+
182
+ function presentFinding(pair: JscpdScanReport["clonePairs"][number]): JscpdPresentedFinding {
183
+ const [first, second] = pair.occurrences;
184
+ return Object.freeze({
185
+ format: pair.format,
186
+ lines: pair.lines,
187
+ tokens: pair.tokens,
188
+ occurrences: Object.freeze([
189
+ Object.freeze({
190
+ path: boundedJscpdDisplayPath(first.path),
191
+ startLine: first.start.line,
192
+ endLine: first.end.line,
193
+ }),
194
+ Object.freeze({
195
+ path: boundedJscpdDisplayPath(second.path),
196
+ startLine: second.start.line,
197
+ endLine: second.end.line,
198
+ }),
199
+ ]) as readonly [
200
+ JscpdPresentedFinding["occurrences"][0],
201
+ JscpdPresentedFinding["occurrences"][1],
202
+ ],
203
+ });
204
+ }
205
+
206
+ function presentChangedFinding(
207
+ pair: JscpdScanReport["clonePairs"][number],
208
+ changedFiles: ReadonlySet<string>,
209
+ ): JscpdChangedFinding {
210
+ const occurrences = pair.occurrences.map((occurrence) =>
211
+ Object.freeze({
212
+ path: boundedJscpdDisplayPath(occurrence.path),
213
+ startLine: occurrence.start.line,
214
+ endLine: occurrence.end.line,
215
+ relation: changedFiles.has(occurrence.path) ? "new-session" : "existing-match",
216
+ }),
217
+ ) as [JscpdChangedFinding["occurrences"][0], JscpdChangedFinding["occurrences"][1]];
218
+ return Object.freeze({
219
+ format: pair.format,
220
+ lines: pair.lines,
221
+ tokens: pair.tokens,
222
+ occurrences: Object.freeze(occurrences),
223
+ });
224
+ }
225
+
226
+ function plural(count: number, singular: string): string {
227
+ return `${count} ${singular}${count === 1 ? "" : "s"}`;
228
+ }
229
+
230
+ function formatPercentage(value: number): string {
231
+ return `${Number(value.toFixed(2))}%`;
232
+ }