no-mistakes 0.18.0 → 0.20.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/index.d.ts CHANGED
@@ -12,6 +12,7 @@ import type {
12
12
  ProjectOptions,
13
13
  QueueReport,
14
14
  ReactComponentFacts,
15
+ ReactUsagesReport,
15
16
  ReactViolation,
16
17
  ServerRoutesReport,
17
18
  SymbolsOptions,
@@ -55,5 +56,8 @@ export function serverRouteEdges(options?: ProjectOptions): Promise<GraphEdge[]>
55
56
  export function serverRouteRelated(options: ProjectOptions): Promise<GraphEdge[]>;
56
57
  export function reactAnalyze(options?: ProjectOptions): Promise<ReactComponentFacts[]>;
57
58
  export function reactCheck(options?: ProjectOptions): Promise<ReactViolation[]>;
59
+ export function reactUsages(
60
+ options: ProjectOptions & { target: string },
61
+ ): Promise<ReactUsagesReport>;
58
62
  export function lockfileDiff(options: LockfileDiffOptions): Promise<LockfileDiffEntry[]>;
59
63
  export function version(): Promise<string>;
package/index.js CHANGED
@@ -114,6 +114,10 @@ async function reactCheck(options) {
114
114
  return callJson(native.reactCheckJson, options);
115
115
  }
116
116
 
117
+ async function reactUsages(options) {
118
+ return callJson(native.reactUsagesJson, options);
119
+ }
120
+
117
121
  async function lockfileDiff(options) {
118
122
  return callJson(native.lockfileDiffJson, options);
119
123
  }
@@ -139,6 +143,7 @@ module.exports = {
139
143
  queueRelated,
140
144
  reactAnalyze,
141
145
  reactCheck,
146
+ reactUsages,
142
147
  related,
143
148
  serverRouteEdges,
144
149
  serverRouteList,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "no-mistakes",
3
- "version": "0.18.0",
3
+ "version": "0.20.0",
4
4
  "description": "Static codebase analysis tools for TS/JS dependencies, dependents, and symbols",
5
5
  "license": "MIT",
6
6
  "repository": {
package/report-types.d.ts CHANGED
@@ -138,3 +138,22 @@ export interface ReactViolation {
138
138
  rule: string;
139
139
  detail?: string;
140
140
  }
141
+
142
+ export interface ReactCallsite {
143
+ file: string;
144
+ line: number;
145
+ component: string;
146
+ props: string[];
147
+ hasSpread: boolean;
148
+ }
149
+
150
+ export interface ReactUsagesReport {
151
+ target: { file: string; symbol?: string };
152
+ callsites: ReactCallsite[];
153
+ /** Story files importing the target. Omitted when `props`/`tests`-only `include`. */
154
+ stories?: string[];
155
+ /** Test files importing the target. */
156
+ tests?: string[];
157
+ /** Exported prop type/interface names declared in the target file. */
158
+ propTypes?: string[];
159
+ }
@@ -7,23 +7,29 @@ const https = require("node:https");
7
7
  const { pipeline } = require("node:stream/promises");
8
8
  const { fileURLToPath } = require("node:url");
9
9
 
10
+ const { HttpError, withRetry } = require("./retry");
11
+
10
12
  const DOWNLOAD_TIMEOUT_MS = 30_000;
11
13
 
12
- function download(url, destination, redirects = 0, validateUrl = () => {}) {
14
+ function download(url, destination, redirects = 0, validateUrl = () => {}, retryOptions) {
13
15
  validateUrl(url);
14
16
  if (isFileUrl(url)) {
15
17
  return copyFile(fileURLToPath(url), destination);
16
18
  }
17
19
 
18
- return request(
19
- url,
20
- async (response) => {
21
- await pipeline(response, createWriteStream(destination));
22
- },
23
- redirects,
24
- { http, https },
25
- DOWNLOAD_TIMEOUT_MS,
26
- validateUrl,
20
+ return withRetry(
21
+ () =>
22
+ request(
23
+ url,
24
+ async (response) => {
25
+ await pipeline(response, createWriteStream(destination));
26
+ },
27
+ redirects,
28
+ { http, https },
29
+ DOWNLOAD_TIMEOUT_MS,
30
+ validateUrl,
31
+ ),
32
+ { ...retryOptions, describe: () => `download of ${url}` },
27
33
  );
28
34
  }
29
35
 
@@ -67,7 +73,7 @@ function request(
67
73
 
68
74
  if (response.statusCode !== 200) {
69
75
  response.resume();
70
- reject(new Error(`Download failed for ${url}: HTTP ${response.statusCode}`));
76
+ reject(new HttpError(url, response.statusCode));
71
77
  return;
72
78
  }
73
79
 
@@ -75,7 +81,9 @@ function request(
75
81
  });
76
82
 
77
83
  req.setTimeout(timeoutMs, () => {
78
- req.destroy(new Error(`Download timed out after ${timeoutMs}ms: ${url}`));
84
+ const timeoutError = new Error(`Download timed out after ${timeoutMs}ms: ${url}`);
85
+ timeoutError.retryable = true;
86
+ req.destroy(timeoutError);
79
87
  });
80
88
  req.on("error", reject);
81
89
  });
@@ -85,32 +93,37 @@ function isRedirectStatus(statusCode) {
85
93
  return [301, 302, 303, 307, 308].includes(statusCode);
86
94
  }
87
95
 
88
- async function fetchText(url, validateUrl = () => {}) {
96
+ async function fetchText(url, validateUrl = () => {}, retryOptions) {
89
97
  validateUrl(url);
90
98
 
91
99
  if (isFileUrl(url)) {
92
100
  return readFile(fileURLToPath(url), "utf8");
93
101
  }
94
- const chunks = [];
95
- let totalLength = 0;
96
102
  const MAX_LENGTH = 1024 * 1024;
97
- await request(
98
- url,
99
- async (response) => {
100
- for await (const chunk of response) {
101
- totalLength += chunk.length;
102
- if (totalLength > MAX_LENGTH) {
103
- throw new Error(`Response exceeded maximum size of ${MAX_LENGTH} bytes`);
104
- }
105
- chunks.push(chunk);
106
- }
103
+ return withRetry(
104
+ async () => {
105
+ const chunks = [];
106
+ let totalLength = 0;
107
+ await request(
108
+ url,
109
+ async (response) => {
110
+ for await (const chunk of response) {
111
+ totalLength += chunk.length;
112
+ if (totalLength > MAX_LENGTH) {
113
+ throw new Error(`Response exceeded maximum size of ${MAX_LENGTH} bytes`);
114
+ }
115
+ chunks.push(chunk);
116
+ }
117
+ },
118
+ 0,
119
+ { http, https },
120
+ DOWNLOAD_TIMEOUT_MS,
121
+ validateUrl,
122
+ );
123
+ return Buffer.concat(chunks).toString("utf8");
107
124
  },
108
- 0,
109
- { http, https },
110
- DOWNLOAD_TIMEOUT_MS,
111
- validateUrl,
125
+ { ...retryOptions, describe: () => `fetch of ${url}` },
112
126
  );
113
- return Buffer.concat(chunks).toString("utf8");
114
127
  }
115
128
 
116
129
  function isFileUrl(url) {
@@ -4,10 +4,12 @@ const assets = require("./assets");
4
4
  const download = require("./download");
5
5
  const installer = require("./installer");
6
6
  const platform = require("./platform");
7
+ const retry = require("./retry");
7
8
 
8
9
  module.exports = {
9
10
  ...assets,
10
11
  ...download,
11
12
  ...installer,
12
13
  ...platform,
14
+ ...retry,
13
15
  };
@@ -0,0 +1,94 @@
1
+ "use strict";
2
+
3
+ const DEFAULT_MAX_ATTEMPTS = 4;
4
+ const DEFAULT_RETRY_BASE_MS = 500;
5
+ const RETRY_MAX_DELAY_MS = 4_000;
6
+ const RETRYABLE_NETWORK_CODES = new Set([
7
+ "ECONNRESET",
8
+ "ECONNREFUSED",
9
+ "ETIMEDOUT",
10
+ "EAI_AGAIN",
11
+ "ENETUNREACH",
12
+ "EPIPE",
13
+ "ERR_STREAM_PREMATURE_CLOSE",
14
+ ]);
15
+
16
+ class HttpError extends Error {
17
+ constructor(url, statusCode) {
18
+ super(`Download failed for ${url}: HTTP ${statusCode}`);
19
+ this.name = "HttpError";
20
+ this.statusCode = statusCode;
21
+ this.retryable = isRetryableStatus(statusCode);
22
+ }
23
+ }
24
+
25
+ function isRetryableStatus(statusCode) {
26
+ if (typeof statusCode !== "number") return false;
27
+ if (statusCode >= 500 && statusCode < 600) return true;
28
+ return statusCode === 408 || statusCode === 429;
29
+ }
30
+
31
+ function isRetryableError(error) {
32
+ if (!error) return false;
33
+ if (error.retryable === true) return true;
34
+ if (error.code && RETRYABLE_NETWORK_CODES.has(error.code)) return true;
35
+ return false;
36
+ }
37
+
38
+ function sleep(ms) {
39
+ return new Promise((resolve) => setTimeout(resolve, ms));
40
+ }
41
+
42
+ function parsePositiveInt(value, fallback) {
43
+ if (value === undefined || value === null || value === "") return fallback;
44
+ const n = Number(value);
45
+ if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1) return fallback;
46
+ return n;
47
+ }
48
+
49
+ function computeBackoffMs(attempt, baseDelayMs, random = Math.random) {
50
+ const exponential = baseDelayMs * 2 ** (attempt - 1);
51
+ const capped = Math.min(exponential, RETRY_MAX_DELAY_MS);
52
+ return Math.floor(random() * capped);
53
+ }
54
+
55
+ async function withRetry(fn, options = {}) {
56
+ const maxAttempts = parsePositiveInt(
57
+ options.maxAttempts ?? process.env.NO_MISTAKES_DOWNLOAD_MAX_ATTEMPTS,
58
+ DEFAULT_MAX_ATTEMPTS,
59
+ );
60
+ const baseDelayMs = parsePositiveInt(
61
+ options.baseDelayMs ?? process.env.NO_MISTAKES_DOWNLOAD_RETRY_BASE_MS,
62
+ DEFAULT_RETRY_BASE_MS,
63
+ );
64
+ const delay = options.delay ?? sleep;
65
+ const random = options.random ?? Math.random;
66
+ const logger = options.logger ?? console;
67
+ const describe = options.describe ?? (() => "download");
68
+
69
+ let attempt = 0;
70
+ for (;;) {
71
+ attempt += 1;
72
+ try {
73
+ return await fn();
74
+ } catch (error) {
75
+ if (attempt >= maxAttempts || !isRetryableError(error)) {
76
+ throw error;
77
+ }
78
+ const waitMs = computeBackoffMs(attempt, baseDelayMs, random);
79
+ logger.warn(
80
+ `no-mistakes: retrying ${describe()} after ${error.message} (attempt ${attempt + 1}/${maxAttempts}, waiting ~${waitMs}ms)`,
81
+ );
82
+ await delay(waitMs);
83
+ }
84
+ }
85
+ }
86
+
87
+ module.exports = {
88
+ HttpError,
89
+ computeBackoffMs,
90
+ isRetryableError,
91
+ isRetryableStatus,
92
+ parsePositiveInt,
93
+ withRetry,
94
+ };
@@ -114,6 +114,10 @@ export interface ProjectOptions {
114
114
  depth?: number;
115
115
  assertNoFetch?: boolean;
116
116
  direction?: "deps" | "dependents" | "both";
117
+ /** `reactUsages` target component (`path` or `path#Symbol`). */
118
+ target?: string;
119
+ /** `reactUsages` `--include` spec: comma-separated `stories,tests,props`. */
120
+ include?: string;
117
121
  }
118
122
 
119
123
  type BatchedProjectOptions = Omit<ProjectOptions, "root" | "tsconfig" | "config">;