pi-jscpd 0.1.1 → 0.2.1
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 +19 -1
- package/README.md +14 -1
- package/docs/overlay-interaction.md +15 -0
- package/package.json +1 -1
- package/src/extension.ts +28 -1
- package/src/tool-render.ts +385 -0
- package/src/update-notice.ts +194 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,22 @@ published releases use [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.1] - 2026-09-06
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Added a quiet, TUI-only update warning using one bounded, best-effort npm
|
|
15
|
+
metadata check, with offline and environment-variable opt-outs.
|
|
16
|
+
|
|
17
|
+
## [0.2.0] - 2026-09-06
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- Added Pi-native compact and expanded transcript rendering for `jscpd_run`,
|
|
22
|
+
covering scan targets, status, findings, expected fail-open outcomes, and
|
|
23
|
+
bounded public details without exposing overlay-only or analyzer internals.
|
|
24
|
+
- Added a monthly npm downloads badge to the README.
|
|
25
|
+
|
|
10
26
|
## [0.1.1] - 2026-09-05
|
|
11
27
|
|
|
12
28
|
### Changed
|
|
@@ -107,6 +123,8 @@ published releases use [Semantic Versioning](https://semver.org/).
|
|
|
107
123
|
- Project paths, child output, reports, temporary directories, cancellation,
|
|
108
124
|
configuration trust, and lifecycle cleanup are bounded and fail open.
|
|
109
125
|
|
|
110
|
-
[Unreleased]: https://github.com/revazi/pi-jscpd/compare/v0.
|
|
126
|
+
[Unreleased]: https://github.com/revazi/pi-jscpd/compare/v0.2.1...HEAD
|
|
127
|
+
[0.2.1]: https://github.com/revazi/pi-jscpd/compare/v0.2.0...v0.2.1
|
|
128
|
+
[0.2.0]: https://github.com/revazi/pi-jscpd/compare/v0.1.1...v0.2.0
|
|
111
129
|
[0.1.1]: https://github.com/revazi/pi-jscpd/compare/v0.1.0...v0.1.1
|
|
112
130
|
[0.1.0]: https://github.com/revazi/pi-jscpd/releases/tag/v0.1.0
|
package/README.md
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/revazi/pi-jscpd/actions/workflows/ci.yml)
|
|
4
4
|
[](https://www.npmjs.com/package/pi-jscpd)
|
|
5
|
+
[](https://www.npmjs.com/package/pi-jscpd)
|
|
5
6
|
[](./LICENSE)
|
|
6
7
|
[](https://github.com/revazi/pi-jscpd/issues)
|
|
7
8
|
|
|
@@ -31,6 +32,12 @@ Start Pi in your project and verify the setup:
|
|
|
31
32
|
If no compatible binary is available, the extension stays dormant and Pi
|
|
32
33
|
continues normally.
|
|
33
34
|
|
|
35
|
+
In TUI sessions, the extension performs one best-effort, metadata-only npm check
|
|
36
|
+
and shows a warning only when a newer `pi-jscpd` release is available. The check
|
|
37
|
+
is bounded to 1.5 seconds, never sends project data, and never downloads or
|
|
38
|
+
installs package content. Set `PI_JSCPD_DISABLE_UPDATE_NOTICE=1` (or run Pi
|
|
39
|
+
offline) to disable it.
|
|
40
|
+
|
|
34
41
|
## Usage
|
|
35
42
|
|
|
36
43
|
Run `/jscpd` to open the interactive overview. Opening it shows status only; it
|
|
@@ -57,7 +64,13 @@ Pi can use the same operations through the `jscpd_run` tool:
|
|
|
57
64
|
```
|
|
58
65
|
|
|
59
66
|
Supported tool commands are `scan`, `changed`, `status`, `off`, `on`, and
|
|
60
|
-
`help`. In TUI mode,
|
|
67
|
+
`help`. In TUI mode, tool calls use a compact native transcript view for clean,
|
|
68
|
+
findings, unavailable, timeout, cancellation, status, and session-control
|
|
69
|
+
results. Expanding a result shows only its bounded public terminal presentation;
|
|
70
|
+
it never reveals analyzer output, temporary paths, source fragments, or the
|
|
71
|
+
overlay-only finding cache.
|
|
72
|
+
|
|
73
|
+
In TUI mode, `/jscpd ` provides subcommand autocomplete with labels and
|
|
61
74
|
descriptions; selecting `scan` leaves the editor ready for an optional target.
|
|
62
75
|
|
|
63
76
|
The package also exposes `/skill:jscpd`. Pi advertises only the skill's concise
|
|
@@ -6,6 +6,21 @@ Applies to: bare `/jscpd` only
|
|
|
6
6
|
|
|
7
7
|
Does not change: `/jscpd scan`, `/jscpd changed`, `/jscpd status`, session controls, or `jscpd_run`
|
|
8
8
|
|
|
9
|
+
## Relationship to the tool transcript
|
|
10
|
+
|
|
11
|
+
The `jscpd_run` agent tool has a separate Pi-native transcript renderer. Its
|
|
12
|
+
collapsed call shows the operation, at most three bounded scan targets, any
|
|
13
|
+
additional-target count, and Pi's project working directory. Collapsed results
|
|
14
|
+
summarize clean, findings, unavailable, timeout, cancellation, fail-open,
|
|
15
|
+
status, help, and session-control states without synthetic exit codes or timing
|
|
16
|
+
placeholders. Expanding a result renders only the configured-limit public
|
|
17
|
+
terminal presentation.
|
|
18
|
+
|
|
19
|
+
Transcript rendering is display-only. It never reads source fragments, raw
|
|
20
|
+
analyzer output, temporary report paths, environment values, internal
|
|
21
|
+
fingerprints, or the overlay-only finding cache, and it does not change tool
|
|
22
|
+
content, scans, acknowledgements, persistence, or cancellation.
|
|
23
|
+
|
|
9
24
|
Overlay actions route through the extension's single managed runtime while Pi TUI
|
|
10
25
|
rendering and input remain the host adapter. The implementation preserves every
|
|
11
26
|
interaction below.
|
package/package.json
CHANGED
package/src/extension.ts
CHANGED
|
@@ -68,7 +68,9 @@ import {
|
|
|
68
68
|
type JscpdSessionModeService,
|
|
69
69
|
type JscpdStatusService,
|
|
70
70
|
} from "./status.js";
|
|
71
|
+
import { renderJscpdToolCall, renderJscpdToolResult } from "./tool-render.js";
|
|
71
72
|
import type { JscpdCommandExecutor, JscpdDispatchResult } from "./types.js";
|
|
73
|
+
import { createJscpdUpdateNoticeService, type JscpdUpdateNoticeService } from "./update-notice.js";
|
|
72
74
|
import { createJscpdVerificationService, type JscpdVerificationService } from "./verification.js";
|
|
73
75
|
|
|
74
76
|
type JscpdToolDefinition = ToolDefinition<typeof jscpdRunParams, JscpdDispatchResult>;
|
|
@@ -90,6 +92,7 @@ export interface JscpdExtensionOptions {
|
|
|
90
92
|
overlayLauncher?: JscpdOverlayLauncher;
|
|
91
93
|
verificationService?: JscpdVerificationService;
|
|
92
94
|
fallowCoexistenceService?: JscpdFallowCoexistenceService;
|
|
95
|
+
updateNoticeService?: JscpdUpdateNoticeService;
|
|
93
96
|
}
|
|
94
97
|
|
|
95
98
|
export function registerJscpdExtension(
|
|
@@ -116,6 +119,7 @@ export function registerJscpdExtension(
|
|
|
116
119
|
const configService = options.configService ?? createJscpdConfigService();
|
|
117
120
|
const fallowCoexistence =
|
|
118
121
|
options.fallowCoexistenceService ?? createJscpdFallowCoexistenceService();
|
|
122
|
+
const updateNotice = options.updateNoticeService ?? createJscpdUpdateNoticeService();
|
|
119
123
|
const startOwnedBaseline = (context: JscpdBaselineStartContext): void => {
|
|
120
124
|
const started = startBaselineQuietly(runtime, baselineService, context);
|
|
121
125
|
baselineSettlement = Promise.all([baselineSettlement, started]).then(() => undefined);
|
|
@@ -210,7 +214,10 @@ export function registerJscpdExtension(
|
|
|
210
214
|
);
|
|
211
215
|
|
|
212
216
|
pi.on("session_start", async (_event, ctx) => {
|
|
213
|
-
if (ctx.mode === "tui")
|
|
217
|
+
if (ctx.mode === "tui") {
|
|
218
|
+
installJscpdAutocompleteProvider(ctx.ui);
|
|
219
|
+
scheduleJscpdUpdateNotice(runtime, updateNotice, ctx);
|
|
220
|
+
}
|
|
214
221
|
runtime.runSync(scheduler.resetEffect);
|
|
215
222
|
fallowCoexistence.reset();
|
|
216
223
|
verificationService?.reset();
|
|
@@ -368,6 +375,20 @@ export function registerJscpdExtension(
|
|
|
368
375
|
});
|
|
369
376
|
}
|
|
370
377
|
|
|
378
|
+
function scheduleJscpdUpdateNotice(
|
|
379
|
+
runtime: JscpdEffectRuntime,
|
|
380
|
+
service: JscpdUpdateNoticeService,
|
|
381
|
+
context: ExtensionContext,
|
|
382
|
+
): void {
|
|
383
|
+
void runtime.runPromiseExit(
|
|
384
|
+
service.noticeEffect.pipe(
|
|
385
|
+
Effect.flatMap((message) =>
|
|
386
|
+
message ? Effect.sync(() => context.ui.notify(message, "warning")) : Effect.void,
|
|
387
|
+
),
|
|
388
|
+
),
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
371
392
|
function requestAutomaticCheck(
|
|
372
393
|
runtime: JscpdEffectRuntime,
|
|
373
394
|
pi: ExtensionAPI,
|
|
@@ -585,6 +606,12 @@ export function createJscpdToolDefinition(
|
|
|
585
606
|
details: withoutOverlayCache(result),
|
|
586
607
|
};
|
|
587
608
|
},
|
|
609
|
+
renderCall(args, theme, context) {
|
|
610
|
+
return renderJscpdToolCall(args, theme, context.cwd);
|
|
611
|
+
},
|
|
612
|
+
renderResult(result, options, theme) {
|
|
613
|
+
return renderJscpdToolResult(result, options, theme);
|
|
614
|
+
},
|
|
588
615
|
};
|
|
589
616
|
}
|
|
590
617
|
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { JscpdDispatchResult } from "./types.js";
|
|
4
|
+
|
|
5
|
+
const MAX_CALL_TARGETS = 3;
|
|
6
|
+
const MAX_TARGET_CHARACTERS = 120;
|
|
7
|
+
const MAX_CWD_CHARACTERS = 180;
|
|
8
|
+
const MAX_EXPANDED_CHARACTERS = 100_000;
|
|
9
|
+
const MAX_EXPANDED_LINES = 512;
|
|
10
|
+
const CAPABILITY_LABELS: Readonly<Record<string, string>> = {
|
|
11
|
+
missing: "analyzer missing",
|
|
12
|
+
incompatible: "analyzer incompatible",
|
|
13
|
+
cancelled: "probe cancelled",
|
|
14
|
+
"timed-out": "probe timed out",
|
|
15
|
+
failed: "probe failed",
|
|
16
|
+
};
|
|
17
|
+
const PUBLIC_RESULT_STATUSES = new Set([
|
|
18
|
+
"completed",
|
|
19
|
+
"unavailable",
|
|
20
|
+
"failed",
|
|
21
|
+
"status",
|
|
22
|
+
"control",
|
|
23
|
+
"help",
|
|
24
|
+
"changed",
|
|
25
|
+
"changed-unavailable",
|
|
26
|
+
"invalid",
|
|
27
|
+
"error",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
type ToolCallArgs = {
|
|
31
|
+
readonly command?: unknown;
|
|
32
|
+
readonly args?: unknown;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
type ToolRenderResult = {
|
|
36
|
+
readonly details?: unknown;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
type Tone = "success" | "warning" | "error" | "muted";
|
|
40
|
+
|
|
41
|
+
interface CompactResult {
|
|
42
|
+
readonly tone: Tone;
|
|
43
|
+
readonly headline: string;
|
|
44
|
+
readonly metadata?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type CompactRenderer = (details: JscpdDispatchResult) => CompactResult;
|
|
48
|
+
|
|
49
|
+
const COMPACT_RENDERERS: Readonly<Record<JscpdDispatchResult["status"], CompactRenderer>> = {
|
|
50
|
+
completed: (details) =>
|
|
51
|
+
completedCompact(details as Extract<JscpdDispatchResult, { status: "completed" }>),
|
|
52
|
+
changed: (details) =>
|
|
53
|
+
changedCompact(details as Extract<JscpdDispatchResult, { status: "changed" }>),
|
|
54
|
+
unavailable: (details) =>
|
|
55
|
+
unavailableCompact((details as Extract<JscpdDispatchResult, { status: "unavailable" }>).reason),
|
|
56
|
+
"changed-unavailable": (details) =>
|
|
57
|
+
changedUnavailableCompact(
|
|
58
|
+
(details as Extract<JscpdDispatchResult, { status: "changed-unavailable" }>).reason,
|
|
59
|
+
),
|
|
60
|
+
failed: (details) =>
|
|
61
|
+
failedCompact((details as Extract<JscpdDispatchResult, { status: "failed" }>).reason),
|
|
62
|
+
status: (details) => statusCompact(details as Extract<JscpdDispatchResult, { status: "status" }>),
|
|
63
|
+
control: (details) =>
|
|
64
|
+
controlCompact(details as Extract<JscpdDispatchResult, { status: "control" }>),
|
|
65
|
+
help: () => ({ tone: "muted", headline: "jscpd help" }),
|
|
66
|
+
invalid: (details) => ({
|
|
67
|
+
tone: "error",
|
|
68
|
+
headline: "Invalid jscpd request",
|
|
69
|
+
metadata: reasonLabel((details as Extract<JscpdDispatchResult, { status: "invalid" }>).reason),
|
|
70
|
+
}),
|
|
71
|
+
error: (details) => ({
|
|
72
|
+
tone: "error",
|
|
73
|
+
headline: "jscpd could not run",
|
|
74
|
+
metadata: reasonLabel((details as Extract<JscpdDispatchResult, { status: "error" }>).reason),
|
|
75
|
+
}),
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** Render the bounded jscpd operation, scan targets, and Pi-provided working directory. */
|
|
79
|
+
export function renderJscpdToolCall(args: ToolCallArgs, theme: Theme, cwd: string): Text {
|
|
80
|
+
const command = jscpdCommand(args.command);
|
|
81
|
+
const targets = command === "scan" ? scanTargetSummary(args.args) : undefined;
|
|
82
|
+
const location = boundedInline(displayToken(cwd || "."), MAX_CWD_CHARACTERS);
|
|
83
|
+
const content = [
|
|
84
|
+
theme.fg("toolTitle", theme.bold("jscpd ")),
|
|
85
|
+
theme.fg("accent", command),
|
|
86
|
+
targets ? theme.fg("muted", ` ${targets}`) : "",
|
|
87
|
+
theme.fg("dim", ` in ${location}`),
|
|
88
|
+
].join("");
|
|
89
|
+
return new Text(content, 0, 0);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Render only normalized public details; model content and overlay-only caches are never read. */
|
|
93
|
+
export function renderJscpdToolResult(
|
|
94
|
+
result: ToolRenderResult,
|
|
95
|
+
options: { readonly expanded?: boolean; readonly isPartial?: boolean },
|
|
96
|
+
theme: Theme,
|
|
97
|
+
): Text {
|
|
98
|
+
if (options.isPartial) {
|
|
99
|
+
return new Text(theme.fg("warning", "Running jscpd…"), 0, 0);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const details = publicDetails(result.details);
|
|
103
|
+
if (!details) {
|
|
104
|
+
return new Text(theme.fg("dim", "No jscpd result details"), 0, 0);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const compact = compactResult(details);
|
|
108
|
+
let content = theme.fg(compact.tone, compact.headline);
|
|
109
|
+
if (compact.metadata) content += theme.fg("dim", ` · ${compact.metadata}`);
|
|
110
|
+
|
|
111
|
+
if (options.expanded) {
|
|
112
|
+
const expanded = expandedPublicMessage(details);
|
|
113
|
+
if (expanded) content += `\n${styleExpandedMessage(expanded, theme)}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return new Text(content, 0, 0);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function publicDetails(value: unknown): JscpdDispatchResult | undefined {
|
|
120
|
+
if (!isRecord(value) || typeof value.status !== "string") return undefined;
|
|
121
|
+
return PUBLIC_RESULT_STATUSES.has(value.status)
|
|
122
|
+
? (value as unknown as JscpdDispatchResult)
|
|
123
|
+
: undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function compactResult(details: JscpdDispatchResult): CompactResult {
|
|
127
|
+
return COMPACT_RENDERERS[details.status](details);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function statusCompact(details: Extract<JscpdDispatchResult, { status: "status" }>): CompactResult {
|
|
131
|
+
const mode = details.mode === "enabled" || details.mode === "disabled" ? details.mode : "status";
|
|
132
|
+
return {
|
|
133
|
+
tone: mode === "enabled" ? "success" : "warning",
|
|
134
|
+
headline: mode === "status" ? "jscpd status unavailable" : `jscpd ${mode}`,
|
|
135
|
+
metadata: [capabilityLabel(details.capability), lastCheckLabel(details.lastCheck)].join(" · "),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function controlCompact(
|
|
140
|
+
details: Extract<JscpdDispatchResult, { status: "control" }>,
|
|
141
|
+
): CompactResult {
|
|
142
|
+
const action =
|
|
143
|
+
details.action === "enabled" || details.action === "disabled" ? details.action : undefined;
|
|
144
|
+
return {
|
|
145
|
+
tone: action === "enabled" ? "success" : "warning",
|
|
146
|
+
headline: action ? `jscpd ${action} for this session` : "jscpd session state updated",
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function completedCompact(
|
|
151
|
+
details: Extract<JscpdDispatchResult, { status: "completed" }>,
|
|
152
|
+
): CompactResult {
|
|
153
|
+
if (details.outcome === "clean") {
|
|
154
|
+
const sources = safeCount(details.summary?.sources);
|
|
155
|
+
return {
|
|
156
|
+
tone: "success",
|
|
157
|
+
headline: "No duplicate blocks found",
|
|
158
|
+
metadata: sources === undefined ? undefined : plural(sources, "source"),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const clones = safeCount(details.summary?.clones) ?? findingTotal(details);
|
|
163
|
+
const duplicatedLines = safeCount(details.summary?.duplicatedLines);
|
|
164
|
+
return {
|
|
165
|
+
tone: "warning",
|
|
166
|
+
headline: `${plural(clones, "duplicate block")} found`,
|
|
167
|
+
metadata:
|
|
168
|
+
duplicatedLines === undefined ? undefined : `${plural(duplicatedLines, "duplicated line")}`,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function changedCompact(
|
|
173
|
+
details: Extract<JscpdDispatchResult, { status: "changed" }>,
|
|
174
|
+
): CompactResult {
|
|
175
|
+
if (details.outcome === "clean") {
|
|
176
|
+
return {
|
|
177
|
+
tone: "success",
|
|
178
|
+
headline: details.scanPerformed
|
|
179
|
+
? "No new duplicate blocks found"
|
|
180
|
+
: "No session changes to scan",
|
|
181
|
+
metadata: ambiguityLabel(details.ambiguousFindings),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
tone: "warning",
|
|
187
|
+
headline: `${plural(findingTotal(details), "new duplicate block")} found`,
|
|
188
|
+
metadata: ambiguityLabel(details.ambiguousFindings),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function unavailableCompact(reason: string): CompactResult {
|
|
193
|
+
switch (reason) {
|
|
194
|
+
case "probe-cancelled":
|
|
195
|
+
return { tone: "muted", headline: "jscpd check cancelled" };
|
|
196
|
+
case "probe-timed-out":
|
|
197
|
+
return { tone: "warning", headline: "jscpd check timed out" };
|
|
198
|
+
case "disabled":
|
|
199
|
+
return { tone: "warning", headline: "jscpd is disabled for this session" };
|
|
200
|
+
case "missing-binary":
|
|
201
|
+
return { tone: "warning", headline: "jscpd unavailable", metadata: "analyzer missing" };
|
|
202
|
+
case "incompatible-version":
|
|
203
|
+
return { tone: "warning", headline: "jscpd unavailable", metadata: "incompatible analyzer" };
|
|
204
|
+
default:
|
|
205
|
+
return { tone: "warning", headline: "jscpd unavailable", metadata: reasonLabel(reason) };
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function changedUnavailableCompact(reason: string): CompactResult {
|
|
210
|
+
if (reason === "baseline-cancelled") {
|
|
211
|
+
return { tone: "muted", headline: "Changed check cancelled" };
|
|
212
|
+
}
|
|
213
|
+
if (reason === "baseline-timed-out") {
|
|
214
|
+
return { tone: "warning", headline: "Changed check timed out" };
|
|
215
|
+
}
|
|
216
|
+
return { tone: "warning", headline: "Changed check unavailable", metadata: reasonLabel(reason) };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function failedCompact(reason: string): CompactResult {
|
|
220
|
+
if (reason === "scan-cancelled") return { tone: "muted", headline: "jscpd scan cancelled" };
|
|
221
|
+
if (reason === "scan-timed-out") return { tone: "warning", headline: "jscpd scan timed out" };
|
|
222
|
+
return { tone: "warning", headline: "jscpd scan failed open", metadata: reasonLabel(reason) };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function capabilityLabel(value: unknown): string {
|
|
226
|
+
if (!isRecord(value) || typeof value.status !== "string") return "analyzer state unavailable";
|
|
227
|
+
if (value.status !== "available") {
|
|
228
|
+
return CAPABILITY_LABELS[value.status] ?? "analyzer state unavailable";
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const executable = safeInline(value.executable) ?? "jscpd";
|
|
232
|
+
const version = safeInline(value.version);
|
|
233
|
+
const source =
|
|
234
|
+
value.source === "bundled"
|
|
235
|
+
? "bundled"
|
|
236
|
+
: value.source === "project-or-path"
|
|
237
|
+
? "project/PATH"
|
|
238
|
+
: undefined;
|
|
239
|
+
return [executable, version, source].filter(Boolean).join(" ");
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function lastCheckLabel(value: unknown): string {
|
|
243
|
+
if (!isRecord(value) || typeof value.state !== "string") return "last check unavailable";
|
|
244
|
+
switch (value.state) {
|
|
245
|
+
case "never":
|
|
246
|
+
return "not checked yet";
|
|
247
|
+
case "clean":
|
|
248
|
+
return "last check clean";
|
|
249
|
+
case "findings": {
|
|
250
|
+
const clones = safeCount(value.clones);
|
|
251
|
+
return clones === undefined
|
|
252
|
+
? "last check found duplication"
|
|
253
|
+
: `last check ${plural(clones, "block")}`;
|
|
254
|
+
}
|
|
255
|
+
case "cancelled":
|
|
256
|
+
return "last check cancelled";
|
|
257
|
+
case "failed":
|
|
258
|
+
return "last check failed";
|
|
259
|
+
default:
|
|
260
|
+
return "last check unavailable";
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function expandedPublicMessage(details: JscpdDispatchResult): string | undefined {
|
|
265
|
+
const candidate =
|
|
266
|
+
"terminalMessage" in details && typeof details.terminalMessage === "string"
|
|
267
|
+
? details.terminalMessage
|
|
268
|
+
: typeof details.message === "string"
|
|
269
|
+
? details.message
|
|
270
|
+
: undefined;
|
|
271
|
+
if (!candidate) return undefined;
|
|
272
|
+
return boundedMultiline(candidate);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function styleExpandedMessage(message: string, theme: Theme): string {
|
|
276
|
+
return message
|
|
277
|
+
.split("\n")
|
|
278
|
+
.map((line) => theme.fg("muted", line))
|
|
279
|
+
.join("\n");
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function scanTargetSummary(value: unknown): string | undefined {
|
|
283
|
+
if (!Array.isArray(value) || value.length === 0) return undefined;
|
|
284
|
+
const targets = value.filter((item): item is string => typeof item === "string");
|
|
285
|
+
if (targets.length === 0) return undefined;
|
|
286
|
+
const visible = targets
|
|
287
|
+
.slice(0, MAX_CALL_TARGETS)
|
|
288
|
+
.map((target) => boundedInline(displayToken(target), MAX_TARGET_CHARACTERS));
|
|
289
|
+
if (targets.length > visible.length) visible.push(`… +${targets.length - visible.length}`);
|
|
290
|
+
return visible.join(" ");
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function jscpdCommand(value: unknown): string {
|
|
294
|
+
switch (value) {
|
|
295
|
+
case "scan":
|
|
296
|
+
case "changed":
|
|
297
|
+
case "status":
|
|
298
|
+
case "off":
|
|
299
|
+
case "on":
|
|
300
|
+
case "help":
|
|
301
|
+
return value;
|
|
302
|
+
default:
|
|
303
|
+
return "run";
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function findingTotal(details: {
|
|
308
|
+
readonly findings?: unknown;
|
|
309
|
+
readonly omittedFindings?: unknown;
|
|
310
|
+
}): number {
|
|
311
|
+
const shown = Array.isArray(details.findings) ? details.findings.length : 0;
|
|
312
|
+
return shown + (safeCount(details.omittedFindings) ?? 0);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function ambiguityLabel(value: unknown): string | undefined {
|
|
316
|
+
const count = safeCount(value);
|
|
317
|
+
return count && count > 0 ? plural(count, "unclassified block") : undefined;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function safeCount(value: unknown): number | undefined {
|
|
321
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function safeInline(value: unknown): string | undefined {
|
|
325
|
+
return typeof value === "string" && value.length > 0
|
|
326
|
+
? boundedInline(displayToken(value), MAX_TARGET_CHARACTERS)
|
|
327
|
+
: undefined;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function displayToken(value: string): string {
|
|
331
|
+
const safeValue = escapeControlCharacters(value, false);
|
|
332
|
+
return /^[\p{L}\p{N}_./@:+~-]+$/u.test(safeValue) ? safeValue : JSON.stringify(safeValue);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function boundedInline(value: string, maximum: number): string {
|
|
336
|
+
const characters = Array.from(value);
|
|
337
|
+
if (characters.length <= maximum) return value;
|
|
338
|
+
const retained = maximum - 1;
|
|
339
|
+
const beginning = Math.ceil(retained / 2);
|
|
340
|
+
const ending = Math.floor(retained / 2);
|
|
341
|
+
return `${characters.slice(0, beginning).join("")}…${characters.slice(-ending).join("")}`;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function boundedMultiline(value: string): string {
|
|
345
|
+
const sanitized = escapeControlCharacters(
|
|
346
|
+
value.replaceAll("\r\n", "\n").replaceAll("\r", "\n"),
|
|
347
|
+
true,
|
|
348
|
+
);
|
|
349
|
+
const sourceLines = sanitized.split("\n");
|
|
350
|
+
const lines = sourceLines.slice(0, MAX_EXPANDED_LINES);
|
|
351
|
+
let result = Array.from(lines.join("\n")).slice(0, MAX_EXPANDED_CHARACTERS).join("");
|
|
352
|
+
if (
|
|
353
|
+
sourceLines.length > lines.length ||
|
|
354
|
+
Array.from(lines.join("\n")).length > MAX_EXPANDED_CHARACTERS
|
|
355
|
+
) {
|
|
356
|
+
result += "\n… transcript detail truncated";
|
|
357
|
+
}
|
|
358
|
+
return result;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function escapeControlCharacters(value: string, preserveNewlines: boolean): string {
|
|
362
|
+
return Array.from(value)
|
|
363
|
+
.map((character) => {
|
|
364
|
+
if (preserveNewlines && character === "\n") return character;
|
|
365
|
+
const codePoint = character.codePointAt(0) ?? 0;
|
|
366
|
+
return codePoint < 32 || (codePoint >= 127 && codePoint <= 159)
|
|
367
|
+
? `\\u${codePoint.toString(16).padStart(4, "0")}`
|
|
368
|
+
: character;
|
|
369
|
+
})
|
|
370
|
+
.join("");
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function reasonLabel(value: unknown): string {
|
|
374
|
+
return typeof value === "string" && value.length > 0
|
|
375
|
+
? boundedInline(escapeControlCharacters(value.replaceAll("-", " "), false), 80)
|
|
376
|
+
: "details unavailable";
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function plural(count: number, singular: string): string {
|
|
380
|
+
return `${count} ${singular}${count === 1 ? "" : "s"}`;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
384
|
+
return typeof value === "object" && value !== null;
|
|
385
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { get } from "node:https";
|
|
3
|
+
import { Effect } from "effect";
|
|
4
|
+
|
|
5
|
+
const PACKAGE_NAME = "pi-jscpd";
|
|
6
|
+
const PACKAGE_JSON_URL = new URL("../package.json", import.meta.url);
|
|
7
|
+
const NPM_LATEST_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
|
|
8
|
+
const UPDATE_COMMAND = `pi update npm:${PACKAGE_NAME}`;
|
|
9
|
+
const DISABLE_UPDATE_ENV = "PI_JSCPD_DISABLE_UPDATE_NOTICE";
|
|
10
|
+
const UPDATE_CHECK_ENV = "PI_JSCPD_UPDATE_CHECK";
|
|
11
|
+
const UPDATE_CHECK_TIMEOUT_MS = 1_500;
|
|
12
|
+
const MAX_RESPONSE_BYTES = 16 * 1_024;
|
|
13
|
+
const VERSION_PATTERN =
|
|
14
|
+
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
15
|
+
|
|
16
|
+
interface JscpdUpdateNoticeOptions {
|
|
17
|
+
readonly environment?: Readonly<Record<string, string | undefined>>;
|
|
18
|
+
readonly currentVersionEffect?: Effect.Effect<string | undefined, unknown>;
|
|
19
|
+
readonly latestVersionEffect?: Effect.Effect<string | undefined, unknown>;
|
|
20
|
+
readonly timeoutMs?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface JscpdUpdateNoticeService {
|
|
24
|
+
/** Resolve at most one warning for this extension instance; failures stay silent. */
|
|
25
|
+
readonly noticeEffect: Effect.Effect<string | undefined>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Best-effort npm metadata check. It never downloads or installs package content. */
|
|
29
|
+
export function createJscpdUpdateNoticeService(
|
|
30
|
+
options: JscpdUpdateNoticeOptions = {},
|
|
31
|
+
): JscpdUpdateNoticeService {
|
|
32
|
+
const environment = options.environment ?? process.env;
|
|
33
|
+
let shown = false;
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
noticeEffect: Effect.suspend(() => {
|
|
37
|
+
if (shown || isUpdateCheckDisabled(environment)) return Effect.succeed(undefined);
|
|
38
|
+
shown = true;
|
|
39
|
+
|
|
40
|
+
const currentVersion = recoverLookup(
|
|
41
|
+
options.currentVersionEffect ?? readCurrentVersionEffect(),
|
|
42
|
+
);
|
|
43
|
+
const latestVersion = recoverLookup(
|
|
44
|
+
options.latestVersionEffect ?? fetchLatestVersionEffect(),
|
|
45
|
+
).pipe(
|
|
46
|
+
Effect.timeoutTo({
|
|
47
|
+
duration: options.timeoutMs ?? UPDATE_CHECK_TIMEOUT_MS,
|
|
48
|
+
onSuccess: (version) => version,
|
|
49
|
+
onTimeout: () => undefined,
|
|
50
|
+
}),
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
return currentVersion.pipe(
|
|
54
|
+
Effect.flatMap((current) =>
|
|
55
|
+
current
|
|
56
|
+
? latestVersion.pipe(Effect.map((latest) => buildUpdateNotice(current, latest)))
|
|
57
|
+
: Effect.succeed(undefined),
|
|
58
|
+
),
|
|
59
|
+
);
|
|
60
|
+
}),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function readCurrentVersionEffect(): Effect.Effect<string | undefined, Error> {
|
|
65
|
+
return Effect.tryPromise({
|
|
66
|
+
try: () => readFile(PACKAGE_JSON_URL, "utf8"),
|
|
67
|
+
catch: () => new Error("Unable to read package metadata."),
|
|
68
|
+
}).pipe(Effect.map(parsePackageVersion));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function parsePackageVersion(source: string): string | undefined {
|
|
72
|
+
try {
|
|
73
|
+
const value = JSON.parse(source) as { version?: unknown };
|
|
74
|
+
return typeof value.version === "string" && VERSION_PATTERN.test(value.version)
|
|
75
|
+
? value.version
|
|
76
|
+
: undefined;
|
|
77
|
+
} catch {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function fetchLatestVersionEffect(): Effect.Effect<string | undefined, Error> {
|
|
83
|
+
return Effect.async((resume) => {
|
|
84
|
+
let settled = false;
|
|
85
|
+
const settle = (effect: Effect.Effect<string | undefined, Error>): void => {
|
|
86
|
+
if (settled) return;
|
|
87
|
+
settled = true;
|
|
88
|
+
resume(effect);
|
|
89
|
+
};
|
|
90
|
+
const request = get(NPM_LATEST_URL, { headers: { accept: "application/json" } }, (response) => {
|
|
91
|
+
if (response.statusCode !== 200) {
|
|
92
|
+
response.resume();
|
|
93
|
+
settle(Effect.succeed(undefined));
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const declaredLength = Number(response.headers["content-length"]);
|
|
98
|
+
if (Number.isFinite(declaredLength) && declaredLength > MAX_RESPONSE_BYTES) {
|
|
99
|
+
response.destroy();
|
|
100
|
+
settle(Effect.succeed(undefined));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const chunks: Uint8Array[] = [];
|
|
105
|
+
let total = 0;
|
|
106
|
+
response.on("data", (value: Buffer) => {
|
|
107
|
+
total += value.byteLength;
|
|
108
|
+
if (total > MAX_RESPONSE_BYTES) {
|
|
109
|
+
response.destroy();
|
|
110
|
+
settle(Effect.succeed(undefined));
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
chunks.push(value);
|
|
114
|
+
});
|
|
115
|
+
response.once("end", () => {
|
|
116
|
+
settle(Effect.succeed(parseLatestVersionBytes(chunks, total)));
|
|
117
|
+
});
|
|
118
|
+
response.once("error", () => {
|
|
119
|
+
settle(Effect.fail(new Error("Unable to read npm package metadata.")));
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
request.once("error", () => {
|
|
123
|
+
settle(Effect.fail(new Error("Unable to check npm package metadata.")));
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
return Effect.sync(() => {
|
|
127
|
+
settled = true;
|
|
128
|
+
request.destroy();
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function parseLatestVersionBytes(chunks: readonly Uint8Array[], total: number): string | undefined {
|
|
134
|
+
try {
|
|
135
|
+
const bytes = new Uint8Array(total);
|
|
136
|
+
let offset = 0;
|
|
137
|
+
for (const chunk of chunks) {
|
|
138
|
+
bytes.set(chunk, offset);
|
|
139
|
+
offset += chunk.byteLength;
|
|
140
|
+
}
|
|
141
|
+
const source = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
142
|
+
const value = JSON.parse(source) as { version?: unknown };
|
|
143
|
+
return typeof value.version === "string" && VERSION_PATTERN.test(value.version)
|
|
144
|
+
? value.version
|
|
145
|
+
: undefined;
|
|
146
|
+
} catch {
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function recoverLookup(
|
|
152
|
+
effect: Effect.Effect<string | undefined, unknown>,
|
|
153
|
+
): Effect.Effect<string | undefined> {
|
|
154
|
+
return effect.pipe(Effect.catchAll(() => Effect.succeed(undefined)));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function buildUpdateNotice(
|
|
158
|
+
currentVersion: string,
|
|
159
|
+
latestVersion: string | undefined,
|
|
160
|
+
): string | undefined {
|
|
161
|
+
if (!latestVersion || compareVersions(latestVersion, currentVersion) <= 0) return undefined;
|
|
162
|
+
return `${PACKAGE_NAME} ${latestVersion} is available (you have ${currentVersion}). Update: ${UPDATE_COMMAND}`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function compareVersions(left: string, right: string): number {
|
|
166
|
+
const leftMatch = VERSION_PATTERN.exec(left);
|
|
167
|
+
const rightMatch = VERSION_PATTERN.exec(right);
|
|
168
|
+
if (!leftMatch || !rightMatch) return 0;
|
|
169
|
+
|
|
170
|
+
for (let index = 1; index <= 3; index += 1) {
|
|
171
|
+
const difference = Number(leftMatch[index]) - Number(rightMatch[index]);
|
|
172
|
+
if (difference !== 0) return Math.sign(difference);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const leftPrerelease = left.includes("-");
|
|
176
|
+
const rightPrerelease = right.includes("-");
|
|
177
|
+
if (leftPrerelease === rightPrerelease) return 0;
|
|
178
|
+
return leftPrerelease ? -1 : 1;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function isUpdateCheckDisabled(environment: Readonly<Record<string, string | undefined>>): boolean {
|
|
182
|
+
const offline = environment.PI_OFFLINE;
|
|
183
|
+
if (offline !== undefined && !isFalseLike(offline)) return true;
|
|
184
|
+
|
|
185
|
+
const disabled = environment[DISABLE_UPDATE_ENV];
|
|
186
|
+
if (disabled !== undefined) return !isFalseLike(disabled);
|
|
187
|
+
|
|
188
|
+
const enabled = environment[UPDATE_CHECK_ENV];
|
|
189
|
+
return enabled !== undefined && isFalseLike(enabled);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function isFalseLike(value: string): boolean {
|
|
193
|
+
return ["0", "false", "off", "no"].includes(value.toLocaleLowerCase());
|
|
194
|
+
}
|