pi-jscpd 0.2.0 → 0.2.2

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/src/extension.ts CHANGED
@@ -70,6 +70,7 @@ import {
70
70
  } from "./status.js";
71
71
  import { renderJscpdToolCall, renderJscpdToolResult } from "./tool-render.js";
72
72
  import type { JscpdCommandExecutor, JscpdDispatchResult } from "./types.js";
73
+ import { createJscpdUpdateNoticeService, type JscpdUpdateNoticeService } from "./update-notice.js";
73
74
  import { createJscpdVerificationService, type JscpdVerificationService } from "./verification.js";
74
75
 
75
76
  type JscpdToolDefinition = ToolDefinition<typeof jscpdRunParams, JscpdDispatchResult>;
@@ -91,6 +92,7 @@ export interface JscpdExtensionOptions {
91
92
  overlayLauncher?: JscpdOverlayLauncher;
92
93
  verificationService?: JscpdVerificationService;
93
94
  fallowCoexistenceService?: JscpdFallowCoexistenceService;
95
+ updateNoticeService?: JscpdUpdateNoticeService;
94
96
  }
95
97
 
96
98
  export function registerJscpdExtension(
@@ -117,6 +119,7 @@ export function registerJscpdExtension(
117
119
  const configService = options.configService ?? createJscpdConfigService();
118
120
  const fallowCoexistence =
119
121
  options.fallowCoexistenceService ?? createJscpdFallowCoexistenceService();
122
+ const updateNotice = options.updateNoticeService ?? createJscpdUpdateNoticeService();
120
123
  const startOwnedBaseline = (context: JscpdBaselineStartContext): void => {
121
124
  const started = startBaselineQuietly(runtime, baselineService, context);
122
125
  baselineSettlement = Promise.all([baselineSettlement, started]).then(() => undefined);
@@ -211,7 +214,10 @@ export function registerJscpdExtension(
211
214
  );
212
215
 
213
216
  pi.on("session_start", async (_event, ctx) => {
214
- if (ctx.mode === "tui") installJscpdAutocompleteProvider(ctx.ui);
217
+ if (ctx.mode === "tui") {
218
+ installJscpdAutocompleteProvider(ctx.ui);
219
+ scheduleJscpdUpdateNotice(runtime, updateNotice, ctx);
220
+ }
215
221
  runtime.runSync(scheduler.resetEffect);
216
222
  fallowCoexistence.reset();
217
223
  verificationService?.reset();
@@ -369,6 +375,20 @@ export function registerJscpdExtension(
369
375
  });
370
376
  }
371
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
+
372
392
  function requestAutomaticCheck(
373
393
  runtime: JscpdEffectRuntime,
374
394
  pi: ExtensionAPI,
@@ -24,14 +24,17 @@ export const JSCPD_STRUCTURED_REPORTER = "json";
24
24
  export const JSCPD_STRUCTURED_REPORT_FILE_NAME = "jscpd-report.json";
25
25
 
26
26
  const MAX_REPORT_BYTES = 16 * 1_024 * 1_024;
27
- const MAX_CLONE_PAIRS = 1_000;
27
+ /** Retained, path-resolved pairs kept in session memory. jscpd totals may be larger. */
28
+ export const JSCPD_MAX_DECODED_CLONE_PAIRS = 1_000;
29
+ const MAX_CLONE_PAIRS = JSCPD_MAX_DECODED_CLONE_PAIRS;
28
30
  const MAX_FORMATS = 256;
29
31
  const MAX_PATH_BYTES = 4_096;
30
32
  const MAX_FORMAT_BYTES = 128;
31
33
  const MAX_DATE_BYTES = 128;
32
34
  const MAX_U32 = 0xffff_ffff;
33
35
  const MAX_JSON_DEPTH = 64;
34
- const MAX_JSON_KEYS = 100_000;
36
+ /** Secondary to the 16 MiB report cap; large trees have many small JSON keys. */
37
+ const MAX_JSON_KEYS = 1_000_000;
35
38
  const MAX_JSON_KEYS_PER_OBJECT = 2_048;
36
39
  const PATH_RESOLUTION_CONCURRENCY = 16;
37
40
 
@@ -151,13 +154,14 @@ function parseReportBytes(bytes: Uint8Array): ParsedReport {
151
154
  requiredProperty(topLevel, "duplicates", "invalid-top-level"),
152
155
  "invalid-duplicates",
153
156
  );
154
- if (duplicates.length > MAX_CLONE_PAIRS) {
155
- fail("limit-exceeded");
156
- }
157
-
158
- const clonePairs = duplicates.map(parseClonePair);
159
157
  const statistics = parseStatistics(requiredProperty(topLevel, "statistics", "invalid-top-level"));
160
- validateCloneStatistics(clonePairs, statistics);
158
+ if (statistics.total.clones !== duplicates.length) {
159
+ fail("invalid-statistics");
160
+ }
161
+ const truncated = duplicates.length > MAX_CLONE_PAIRS;
162
+ const retained = truncated ? duplicates.slice(0, MAX_CLONE_PAIRS) : duplicates;
163
+ const clonePairs = retained.map(parseClonePair);
164
+ validateCloneStatistics(clonePairs, statistics, truncated);
161
165
  return { statistics, clonePairs };
162
166
  }
163
167
 
@@ -402,7 +406,14 @@ function parseStatisticsRow(value: unknown): JscpdStatisticsRow {
402
406
  function validateCloneStatistics(
403
407
  clonePairs: readonly UnresolvedClonePair[],
404
408
  statistics: JscpdScanStatistics,
409
+ truncated: boolean,
405
410
  ): void {
411
+ if (truncated) {
412
+ if (clonePairs.length !== MAX_CLONE_PAIRS || statistics.total.clones < clonePairs.length) {
413
+ fail("invalid-statistics");
414
+ }
415
+ return;
416
+ }
406
417
  if (statistics.total.clones !== clonePairs.length) {
407
418
  fail("invalid-statistics");
408
419
  }
@@ -28,11 +28,11 @@ export function presentJscpdScan(
28
28
  .slice(0, Math.max(maxFindings, overlayLimit ?? 0))
29
29
  .map(presentFinding);
30
30
  const findings = Object.freeze(retained.slice(0, maxFindings));
31
- const omittedFindings = Math.max(0, report.clonePairs.length - findings.length);
31
+ const omittedFindings = Math.max(0, summary.clones - findings.length);
32
32
  const overlayCache = overlayLimit
33
33
  ? Object.freeze({
34
34
  findings: Object.freeze(retained.slice(0, overlayLimit)),
35
- omittedFindings: Math.max(0, report.clonePairs.length - overlayLimit),
35
+ omittedFindings: Math.max(0, summary.clones - overlayLimit),
36
36
  })
37
37
  : undefined;
38
38
  const outcome = findings.length === 0 ? "clean" : "findings";
@@ -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
+ }