@hs-x/cli 0.4.2-next.0 → 0.4.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/README.md +7 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +8 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/command-catalog.d.ts +2 -2
- package/dist/command-catalog.d.ts.map +1 -1
- package/dist/command-catalog.js +2 -0
- package/dist/command-catalog.js.map +1 -1
- package/dist/commands/account.d.ts.map +1 -1
- package/dist/commands/account.js +28 -4
- package/dist/commands/account.js.map +1 -1
- package/dist/commands/api.d.ts.map +1 -1
- package/dist/commands/api.js +4 -3
- package/dist/commands/api.js.map +1 -1
- package/dist/commands/completion.d.ts.map +1 -1
- package/dist/commands/completion.js +1 -0
- package/dist/commands/completion.js.map +1 -1
- package/dist/commands/connect.js +5 -5
- package/dist/commands/connect.js.map +1 -1
- package/dist/commands/deploy.d.ts +10 -1
- package/dist/commands/deploy.d.ts.map +1 -1
- package/dist/commands/deploy.js +59 -4
- package/dist/commands/deploy.js.map +1 -1
- package/dist/commands/dev.d.ts.map +1 -1
- package/dist/commands/dev.js +71 -5
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/help-command.d.ts.map +1 -1
- package/dist/commands/help-command.js +181 -0
- package/dist/commands/help-command.js.map +1 -1
- package/dist/commands/migrate.d.ts.map +1 -1
- package/dist/commands/migrate.js +39 -9
- package/dist/commands/migrate.js.map +1 -1
- package/dist/commands/react-health.d.ts +73 -0
- package/dist/commands/react-health.d.ts.map +1 -0
- package/dist/commands/react-health.js +673 -0
- package/dist/commands/react-health.js.map +1 -0
- package/dist/commands/react.d.ts +71 -0
- package/dist/commands/react.d.ts.map +1 -0
- package/dist/commands/react.js +884 -0
- package/dist/commands/react.js.map +1 -0
- package/dist/commands/sync.d.ts +129 -0
- package/dist/commands/sync.d.ts.map +1 -0
- package/dist/commands/sync.js +771 -0
- package/dist/commands/sync.js.map +1 -0
- package/dist/constants.d.ts +1 -1
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +1 -1
- package/dist/constants.js.map +1 -1
- package/dist/control-plane-tenant-provisioning.d.ts.map +1 -1
- package/dist/control-plane-tenant-provisioning.js +9 -0
- package/dist/control-plane-tenant-provisioning.js.map +1 -1
- package/dist/dev/compat-shim.d.ts +36 -0
- package/dist/dev/compat-shim.d.ts.map +1 -1
- package/dist/dev/compat-shim.js +36 -1
- package/dist/dev/compat-shim.js.map +1 -1
- package/dist/dev/component-selection.d.ts +18 -1
- package/dist/dev/component-selection.d.ts.map +1 -1
- package/dist/dev/component-selection.js +53 -5
- package/dist/dev/component-selection.js.map +1 -1
- package/dist/dev/hubspot-logs-poller.d.ts +6 -0
- package/dist/dev/hubspot-logs-poller.d.ts.map +1 -1
- package/dist/dev/hubspot-logs-poller.js +36 -10
- package/dist/dev/hubspot-logs-poller.js.map +1 -1
- package/dist/dev/hubspot-token-repair.d.ts +36 -0
- package/dist/dev/hubspot-token-repair.d.ts.map +1 -0
- package/dist/dev/hubspot-token-repair.js +88 -0
- package/dist/dev/hubspot-token-repair.js.map +1 -0
- package/dist/dev/session-manager.d.ts +19 -0
- package/dist/dev/session-manager.d.ts.map +1 -1
- package/dist/dev/session-manager.js +41 -8
- package/dist/dev/session-manager.js.map +1 -1
- package/dist/errors-registry.d.ts.map +1 -1
- package/dist/errors-registry.js +12 -4
- package/dist/errors-registry.js.map +1 -1
- package/dist/init/templates.d.ts +1 -0
- package/dist/init/templates.d.ts.map +1 -1
- package/dist/init/templates.js +11 -8
- package/dist/init/templates.js.map +1 -1
- package/dist/prompt.d.ts +2 -0
- package/dist/prompt.d.ts.map +1 -1
- package/dist/prompt.js +6 -0
- package/dist/prompt.js.map +1 -1
- package/dist/reporter/style.d.ts.map +1 -1
- package/dist/reporter/style.js +10 -1
- package/dist/reporter/style.js.map +1 -1
- package/package.json +10 -8
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { dirname, extname, join, relative, resolve } from 'node:path';
|
|
6
|
+
import { scanReactProject, } from '@hs-x/react-doctor';
|
|
7
|
+
import { AmbiguousProjectError, diagnose, filterSourceFiles, getDiffInfo, isProjectDiscoveryError, } from 'react-doctor/api';
|
|
8
|
+
export async function scopeHubSpotReactReport(report, options) {
|
|
9
|
+
if (!options.staged && (options.scope ?? (options.ci ? 'changed' : 'full')) === 'full') {
|
|
10
|
+
return report;
|
|
11
|
+
}
|
|
12
|
+
if ('projects' in report) {
|
|
13
|
+
const projects = await Promise.all(report.projects.map((project) => scopeHubSpotProject(project, options)));
|
|
14
|
+
const summary = projects.reduce((total, project) => ({
|
|
15
|
+
errors: total.errors + project.summary.errors,
|
|
16
|
+
reviews: total.reviews + project.summary.reviews,
|
|
17
|
+
recommendations: total.recommendations + project.summary.recommendations,
|
|
18
|
+
warnings: total.warnings + project.summary.warnings,
|
|
19
|
+
}), { errors: 0, reviews: 0, recommendations: 0, warnings: 0 });
|
|
20
|
+
return {
|
|
21
|
+
...report,
|
|
22
|
+
projects,
|
|
23
|
+
filesScanned: projects.reduce((total, project) => total + project.filesScanned, 0),
|
|
24
|
+
summary,
|
|
25
|
+
ok: summary.errors === 0,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
return scopeHubSpotProject(report, options);
|
|
29
|
+
}
|
|
30
|
+
const require = createRequire(import.meta.url);
|
|
31
|
+
const reactDoctorEntry = require.resolve('react-doctor');
|
|
32
|
+
const reactDoctorPackage = resolve(dirname(reactDoctorEntry), '..', 'package.json');
|
|
33
|
+
let reactDoctorVersion;
|
|
34
|
+
export async function scanReactHealth(root, options = {}) {
|
|
35
|
+
const reports = await scanReactHealthProjects([root], options, 1);
|
|
36
|
+
const report = reports[0];
|
|
37
|
+
if (!report)
|
|
38
|
+
throw new Error('React Doctor did not return a project result');
|
|
39
|
+
return report;
|
|
40
|
+
}
|
|
41
|
+
async function buildReactHealthReport(prepared, result, baselineResult) {
|
|
42
|
+
const { root, options, scope } = prepared;
|
|
43
|
+
if (!result.ok) {
|
|
44
|
+
const reason = result.error.message || 'React project was not detected';
|
|
45
|
+
if (isProjectDiscoveryError(result.error))
|
|
46
|
+
return skippedReport(root, reason, options, scope, prepared.baseRef);
|
|
47
|
+
throw new Error(`React Doctor failed: ${reason}`);
|
|
48
|
+
}
|
|
49
|
+
const projectRoot = result.directory;
|
|
50
|
+
const displayRoot = prepared.analysisRoot ?? root;
|
|
51
|
+
let upstreamDiagnostics = result.diagnostics;
|
|
52
|
+
if (scope === 'lines' && prepared.lineRanges) {
|
|
53
|
+
const lineRanges = prepared.lineRanges;
|
|
54
|
+
upstreamDiagnostics = upstreamDiagnostics.filter((diagnostic) => diagnosticTouchesChangedLines(diagnostic, lineRanges));
|
|
55
|
+
}
|
|
56
|
+
if (scope === 'changed' && baselineResult?.ok && prepared.baselineRoot) {
|
|
57
|
+
upstreamDiagnostics = await introducedDiagnostics(upstreamDiagnostics, baselineResult.diagnostics, projectRoot, prepared.baselineRoot);
|
|
58
|
+
}
|
|
59
|
+
const filtered = await filterHubSpotDiagnostics(upstreamDiagnostics, projectRoot);
|
|
60
|
+
const diagnostics = filtered.diagnostics.map((diagnostic) => ({
|
|
61
|
+
ruleId: `${diagnostic.plugin}/${diagnostic.rule}`,
|
|
62
|
+
severity: diagnostic.severity,
|
|
63
|
+
title: diagnostic.title ?? diagnostic.rule,
|
|
64
|
+
message: diagnostic.message,
|
|
65
|
+
suggestion: normalizeUpstreamSuggestion(diagnostic),
|
|
66
|
+
category: diagnostic.category,
|
|
67
|
+
file: relative(displayRoot, resolve(projectRoot, diagnostic.filePath)),
|
|
68
|
+
line: diagnostic.line,
|
|
69
|
+
column: diagnostic.column,
|
|
70
|
+
...(diagnostic.offset !== undefined ? { offset: diagnostic.offset } : {}),
|
|
71
|
+
...(diagnostic.length !== undefined ? { length: diagnostic.length } : {}),
|
|
72
|
+
...(diagnostic.endLine !== undefined ? { endLine: diagnostic.endLine } : {}),
|
|
73
|
+
...(diagnostic.endColumn !== undefined ? { endColumn: diagnostic.endColumn } : {}),
|
|
74
|
+
...(diagnostic.matchByOccurrence ? { matchByOccurrence: diagnostic.matchByOccurrence } : {}),
|
|
75
|
+
...(diagnostic.fileContext ? { fileContext: diagnostic.fileContext } : {}),
|
|
76
|
+
...(diagnostic.suppressionHint ? { suppressionHint: diagnostic.suppressionHint } : {}),
|
|
77
|
+
...(diagnostic.fixGroupId ? { fixGroupId: diagnostic.fixGroupId } : {}),
|
|
78
|
+
...(diagnostic.relatedLocations?.length
|
|
79
|
+
? {
|
|
80
|
+
relatedLocations: diagnostic.relatedLocations.map((location) => ({
|
|
81
|
+
file: relative(displayRoot, resolve(projectRoot, location.filePath)),
|
|
82
|
+
line: location.line,
|
|
83
|
+
column: location.column,
|
|
84
|
+
...(location.offset !== undefined ? { offset: location.offset } : {}),
|
|
85
|
+
...(location.length !== undefined ? { length: location.length } : {}),
|
|
86
|
+
...(location.endLine !== undefined ? { endLine: location.endLine } : {}),
|
|
87
|
+
...(location.endColumn !== undefined ? { endColumn: location.endColumn } : {}),
|
|
88
|
+
message: location.message,
|
|
89
|
+
})),
|
|
90
|
+
}
|
|
91
|
+
: {}),
|
|
92
|
+
...(diagnostic.url ? { url: diagnostic.url } : {}),
|
|
93
|
+
}));
|
|
94
|
+
const errors = diagnostics.filter((diagnostic) => diagnostic.severity === 'error').length;
|
|
95
|
+
const warnings = diagnostics.length - errors;
|
|
96
|
+
return {
|
|
97
|
+
status: 'scanned',
|
|
98
|
+
engineVersion: await loadReactDoctorVersion(),
|
|
99
|
+
root,
|
|
100
|
+
projectRoot,
|
|
101
|
+
filesScanned: result.scannedFileCount ?? result.analyzedFiles?.length ?? 0,
|
|
102
|
+
diagnostics,
|
|
103
|
+
summary: { errors, warnings },
|
|
104
|
+
score: null,
|
|
105
|
+
scoreLabel: null,
|
|
106
|
+
skippedChecks: result.skippedChecks,
|
|
107
|
+
...(result.skippedCheckReasons ? { skippedCheckReasons: result.skippedCheckReasons } : {}),
|
|
108
|
+
suppressedDiagnostics: filtered.suppressions,
|
|
109
|
+
scope,
|
|
110
|
+
...(prepared.baseRef ? { baseRef: prepared.baseRef } : {}),
|
|
111
|
+
staged: options.staged ?? false,
|
|
112
|
+
deep: options.deep ?? false,
|
|
113
|
+
ci: options.ci ?? false,
|
|
114
|
+
elapsedMilliseconds: result.elapsedMilliseconds,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
async function filterHubSpotDiagnostics(diagnostics, projectRoot) {
|
|
118
|
+
const kept = [];
|
|
119
|
+
const suppressionCounts = new Map();
|
|
120
|
+
for (const diagnostic of diagnostics) {
|
|
121
|
+
const reason = await hubSpotSuppressionReason(diagnostic, diagnostics, projectRoot);
|
|
122
|
+
if (!reason) {
|
|
123
|
+
kept.push(diagnostic);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const ruleId = `${diagnostic.plugin}/${diagnostic.rule}`;
|
|
127
|
+
const current = suppressionCounts.get(ruleId);
|
|
128
|
+
suppressionCounts.set(ruleId, { count: (current?.count ?? 0) + 1, reason });
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
diagnostics: kept,
|
|
132
|
+
suppressions: [...suppressionCounts.entries()].map(([ruleId, value]) => ({
|
|
133
|
+
ruleId,
|
|
134
|
+
...value,
|
|
135
|
+
})),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
async function hubSpotSuppressionReason(diagnostic, diagnostics, projectRoot) {
|
|
139
|
+
if (diagnostic.rule === 'no-vulnerable-react-server-components') {
|
|
140
|
+
return 'React Server Components are not part of the HubSpot UI-extension client runtime.';
|
|
141
|
+
}
|
|
142
|
+
if (diagnostic.rule === 'no-unstable-nested-components' &&
|
|
143
|
+
diagnostics.some((candidate) => candidate.plugin === diagnostic.plugin &&
|
|
144
|
+
candidate.rule === 'no-nested-component-definition' &&
|
|
145
|
+
candidate.filePath === diagnostic.filePath &&
|
|
146
|
+
candidate.line === diagnostic.line)) {
|
|
147
|
+
return 'The equivalent no-nested-component-definition finding already describes this location.';
|
|
148
|
+
}
|
|
149
|
+
if (diagnostic.rule === 'exhaustive-deps' &&
|
|
150
|
+
diagnostics.some((candidate) => candidate.plugin === diagnostic.plugin &&
|
|
151
|
+
candidate.rule === 'no-effect-with-fresh-deps' &&
|
|
152
|
+
candidate.filePath === diagnostic.filePath &&
|
|
153
|
+
candidate.line === diagnostic.line)) {
|
|
154
|
+
return 'The more specific no-effect-with-fresh-deps finding already describes this dependency problem.';
|
|
155
|
+
}
|
|
156
|
+
if (diagnostic.rule === 'no-pass-data-to-parent' &&
|
|
157
|
+
diagnostics.some((candidate) => candidate.plugin === diagnostic.plugin &&
|
|
158
|
+
candidate.rule === 'no-pass-live-state-to-parent' &&
|
|
159
|
+
candidate.filePath === diagnostic.filePath &&
|
|
160
|
+
candidate.line === diagnostic.line)) {
|
|
161
|
+
return 'The more specific no-pass-live-state-to-parent finding already describes this parent synchronization problem.';
|
|
162
|
+
}
|
|
163
|
+
if (diagnostic.rule !== 'no-prop-callback-in-render')
|
|
164
|
+
return undefined;
|
|
165
|
+
try {
|
|
166
|
+
const source = await readFile(resolve(projectRoot, diagnostic.filePath), 'utf8');
|
|
167
|
+
const lines = source.split(/\r?\n/);
|
|
168
|
+
const start = Math.max(0, diagnostic.line - 3);
|
|
169
|
+
const end = Math.min(lines.length, diagnostic.line + 2);
|
|
170
|
+
if (/\bonCrmPropertiesUpdate\s*\(/.test(lines.slice(start, end).join('\n'))) {
|
|
171
|
+
return 'HubSpot documents onCrmPropertiesUpdate as a hook-like component-level subscription.';
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
// Preserve the upstream finding when source context cannot be verified.
|
|
176
|
+
}
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
function normalizeUpstreamSuggestion(diagnostic) {
|
|
180
|
+
if (diagnostic.rule === 'rerender-state-only-in-handlers') {
|
|
181
|
+
return 'Confirm the value is not rendered. Remove it when the state and its handler are dead; use a ref only when live handlers still need mutable storage.';
|
|
182
|
+
}
|
|
183
|
+
if (diagnostic.rule === 'rerender-functional-setstate') {
|
|
184
|
+
const setter = /\b(set[A-Z][A-Za-z0-9_$]*)\s*\(/.exec(diagnostic.message)?.[1];
|
|
185
|
+
return setter
|
|
186
|
+
? `Use the functional form of ${setter} so async work and batched updates do not overwrite newer state.`
|
|
187
|
+
: 'Use the functional setter form so async work and batched updates do not overwrite newer state.';
|
|
188
|
+
}
|
|
189
|
+
if (diagnostic.rule === 'js-combine-iterations') {
|
|
190
|
+
return 'If this is a hot or large list, combine the predicates in one readable pass; otherwise keep the clearer code.';
|
|
191
|
+
}
|
|
192
|
+
if (diagnostic.rule === 'js-hoist-intl' && diagnostic.message.includes('Intl.DateTimeFormat')) {
|
|
193
|
+
return diagnostic.help.replaceAll('Intl.NumberFormat', 'Intl.DateTimeFormat');
|
|
194
|
+
}
|
|
195
|
+
return diagnostic.help;
|
|
196
|
+
}
|
|
197
|
+
export async function scanReactHealthProjects(roots, options = {}, concurrency = 2) {
|
|
198
|
+
const prepared = await Promise.all(roots.map((root) => prepareScan(root, options)));
|
|
199
|
+
try {
|
|
200
|
+
const active = prepared.filter((scan) => scan.includePaths?.length !== 0);
|
|
201
|
+
const current = await diagnosePrepared(active, concurrency);
|
|
202
|
+
const currentByRoot = new Map(active.map((scan, index) => [scan.root, current[index]]));
|
|
203
|
+
const baselineScans = prepared.filter((scan) => scan.scope === 'changed' && scan.baselineRoot);
|
|
204
|
+
const baselines = await diagnosePrepared(baselineScans.map((scan) => ({
|
|
205
|
+
...scan,
|
|
206
|
+
root: scan.baselineRoot ?? scan.root,
|
|
207
|
+
})), concurrency);
|
|
208
|
+
const baselineByOriginalRoot = new Map(baselineScans.map((scan, index) => [scan.root, baselines[index]]));
|
|
209
|
+
return Promise.all(prepared.map(async (scan) => {
|
|
210
|
+
if (scan.includePaths?.length === 0)
|
|
211
|
+
return emptyScopedReport(scan);
|
|
212
|
+
const result = currentByRoot.get(scan.root);
|
|
213
|
+
if (!result)
|
|
214
|
+
throw new Error(`React Doctor omitted ${scan.root} from its results`);
|
|
215
|
+
return buildReactHealthReport(scan, result, baselineByOriginalRoot.get(scan.root));
|
|
216
|
+
}));
|
|
217
|
+
}
|
|
218
|
+
finally {
|
|
219
|
+
await Promise.all(prepared.flatMap((scan) => scan.cleanupRoot ? [rm(scan.cleanupRoot, { recursive: true, force: true })] : []));
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
async function prepareScan(root, options) {
|
|
223
|
+
const scope = options.staged ? 'files' : (options.scope ?? (options.ci ? 'changed' : 'full'));
|
|
224
|
+
if (scope === 'full')
|
|
225
|
+
return { root, options, scope };
|
|
226
|
+
if (options.staged) {
|
|
227
|
+
const stagedChanges = await stagedFiles(root);
|
|
228
|
+
const changedFiles = filterSourceFiles(stagedChanges);
|
|
229
|
+
if (changedFiles.length === 0) {
|
|
230
|
+
return { root, options, scope, includePaths: [], baseRef: 'HEAD' };
|
|
231
|
+
}
|
|
232
|
+
const cleanupRoot = await mkdtemp(join(tmpdir(), 'hs-x-react-doctor-staged-'));
|
|
233
|
+
const analysisRoot = await extractStagedSnapshot(root, cleanupRoot, stagedChanges);
|
|
234
|
+
return {
|
|
235
|
+
root,
|
|
236
|
+
analysisRoot,
|
|
237
|
+
options,
|
|
238
|
+
scope,
|
|
239
|
+
includePaths: changedFiles,
|
|
240
|
+
baseRef: 'HEAD',
|
|
241
|
+
cleanupRoot,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
const selection = await getDiffInfo(root, options.base, true);
|
|
245
|
+
const changedFiles = filterSourceFiles(selection?.changedFiles ?? []);
|
|
246
|
+
const baseRef = selection?.diffBaseRef ??
|
|
247
|
+
(selection && 'baseBranch' in selection ? selection.baseBranch : undefined);
|
|
248
|
+
const lineRanges = scope === 'lines'
|
|
249
|
+
? await changedLineRanges(root, changedFiles, baseRef, options.staged)
|
|
250
|
+
: undefined;
|
|
251
|
+
if (scope !== 'changed' || !baseRef || changedFiles.length === 0) {
|
|
252
|
+
return {
|
|
253
|
+
root,
|
|
254
|
+
options,
|
|
255
|
+
scope,
|
|
256
|
+
includePaths: changedFiles,
|
|
257
|
+
...(lineRanges ? { lineRanges } : {}),
|
|
258
|
+
...(baseRef ? { baseRef } : {}),
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
const cleanupRoot = await mkdtemp(join(tmpdir(), 'hs-x-react-doctor-base-'));
|
|
262
|
+
const baselineRoot = await extractGitSnapshot(root, baseRef, join(cleanupRoot, 'project'));
|
|
263
|
+
return {
|
|
264
|
+
root,
|
|
265
|
+
options,
|
|
266
|
+
scope,
|
|
267
|
+
includePaths: changedFiles,
|
|
268
|
+
baseRef,
|
|
269
|
+
baselineRoot,
|
|
270
|
+
cleanupRoot,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
async function scopeHubSpotProject(report, options) {
|
|
274
|
+
const prepared = await prepareScan(report.root, options);
|
|
275
|
+
try {
|
|
276
|
+
if (prepared.includePaths === undefined)
|
|
277
|
+
return report;
|
|
278
|
+
const includedFiles = new Set(prepared.includePaths);
|
|
279
|
+
const sourceReport = prepared.analysisRoot
|
|
280
|
+
? await scanReactProject({ root: prepared.analysisRoot, deep: options.deep ?? false })
|
|
281
|
+
: report;
|
|
282
|
+
let diagnostics = sourceReport.diagnostics.filter((diagnostic) => includedFiles.has(diagnostic.file));
|
|
283
|
+
if (prepared.scope === 'lines' && prepared.lineRanges) {
|
|
284
|
+
diagnostics = diagnostics.filter((diagnostic) => {
|
|
285
|
+
const ranges = prepared.lineRanges?.get(diagnostic.file);
|
|
286
|
+
if (ranges === null)
|
|
287
|
+
return true;
|
|
288
|
+
return (ranges?.some((range) => diagnostic.line >= range.start && diagnostic.line <= range.end) ??
|
|
289
|
+
false);
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
if (prepared.scope === 'changed' && prepared.baselineRoot) {
|
|
293
|
+
const baseline = await scanReactProject({
|
|
294
|
+
root: prepared.baselineRoot,
|
|
295
|
+
deep: options.deep ?? false,
|
|
296
|
+
});
|
|
297
|
+
diagnostics = await introducedHubSpotDiagnostics(diagnostics, baseline.diagnostics.filter((diagnostic) => includedFiles.has(diagnostic.file)), report.root, prepared.baselineRoot);
|
|
298
|
+
}
|
|
299
|
+
const summary = diagnostics.reduce((total, diagnostic) => {
|
|
300
|
+
if (diagnostic.kind === 'error')
|
|
301
|
+
total.errors += 1;
|
|
302
|
+
else if (diagnostic.kind === 'review')
|
|
303
|
+
total.reviews += 1;
|
|
304
|
+
else
|
|
305
|
+
total.recommendations += 1;
|
|
306
|
+
total.warnings = total.reviews + total.recommendations;
|
|
307
|
+
return total;
|
|
308
|
+
}, { errors: 0, reviews: 0, recommendations: 0, warnings: 0 });
|
|
309
|
+
return {
|
|
310
|
+
...report,
|
|
311
|
+
...(sourceReport.projectName ? { projectName: sourceReport.projectName } : {}),
|
|
312
|
+
...(sourceReport.platformVersion ? { platformVersion: sourceReport.platformVersion } : {}),
|
|
313
|
+
filesScanned: prepared.includePaths.length,
|
|
314
|
+
diagnostics,
|
|
315
|
+
summary,
|
|
316
|
+
ok: summary.errors === 0,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
finally {
|
|
320
|
+
if (prepared.cleanupRoot) {
|
|
321
|
+
await rm(prepared.cleanupRoot, { recursive: true, force: true });
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
async function introducedHubSpotDiagnostics(current, baseline, currentRoot, baselineRoot) {
|
|
326
|
+
const baselineCounts = new Map();
|
|
327
|
+
for (const diagnostic of baseline) {
|
|
328
|
+
const key = await hubSpotEvidenceKey(diagnostic, baselineRoot);
|
|
329
|
+
baselineCounts.set(key, (baselineCounts.get(key) ?? 0) + 1);
|
|
330
|
+
}
|
|
331
|
+
const introduced = [];
|
|
332
|
+
for (const diagnostic of current) {
|
|
333
|
+
const key = await hubSpotEvidenceKey(diagnostic, currentRoot);
|
|
334
|
+
const remaining = baselineCounts.get(key) ?? 0;
|
|
335
|
+
if (remaining > 0)
|
|
336
|
+
baselineCounts.set(key, remaining - 1);
|
|
337
|
+
else
|
|
338
|
+
introduced.push(diagnostic);
|
|
339
|
+
}
|
|
340
|
+
return introduced;
|
|
341
|
+
}
|
|
342
|
+
async function hubSpotEvidenceKey(diagnostic, root) {
|
|
343
|
+
return [
|
|
344
|
+
diagnostic.file,
|
|
345
|
+
diagnostic.ruleId,
|
|
346
|
+
diagnostic.message.replace(/\s+/g, ' ').trim(),
|
|
347
|
+
await sourceLine(root, diagnostic.file, diagnostic.line),
|
|
348
|
+
].join('\0');
|
|
349
|
+
}
|
|
350
|
+
async function diagnosePrepared(scans, concurrency) {
|
|
351
|
+
if (scans.length === 0)
|
|
352
|
+
return [];
|
|
353
|
+
const result = await runReactDoctorBatch(scans.map((scan) => ({
|
|
354
|
+
directory: scan.analysisRoot ?? scan.root,
|
|
355
|
+
deadCode: scan.options.deep ?? false,
|
|
356
|
+
...(scan.includePaths ? { includePaths: [...scan.includePaths] } : {}),
|
|
357
|
+
})), concurrency);
|
|
358
|
+
return Promise.all(result.projects.map(async (project, index) => {
|
|
359
|
+
const scan = scans[index];
|
|
360
|
+
if (!scan || project.ok || !(project.error instanceof AmbiguousProjectError)) {
|
|
361
|
+
return project;
|
|
362
|
+
}
|
|
363
|
+
return diagnoseAmbiguousProject(scan, project.error, concurrency);
|
|
364
|
+
}));
|
|
365
|
+
}
|
|
366
|
+
async function runReactDoctorBatch(projects, concurrency) {
|
|
367
|
+
return withOfflineScoreApi(() => diagnose({
|
|
368
|
+
projects,
|
|
369
|
+
config: {
|
|
370
|
+
noScore: true,
|
|
371
|
+
supplyChain: { enabled: false },
|
|
372
|
+
},
|
|
373
|
+
concurrency,
|
|
374
|
+
warnings: true,
|
|
375
|
+
}));
|
|
376
|
+
}
|
|
377
|
+
async function diagnoseAmbiguousProject(scan, error, concurrency) {
|
|
378
|
+
const requestedRoot = scan.analysisRoot ?? scan.root;
|
|
379
|
+
const allCandidates = error.candidates.map((candidate) => resolve(error.directory, candidate));
|
|
380
|
+
const sourceBearing = (await Promise.all(allCandidates.map(async (candidate) => ({
|
|
381
|
+
candidate,
|
|
382
|
+
hasReactSource: await containsReactUiSource(candidate),
|
|
383
|
+
}))))
|
|
384
|
+
.filter((entry) => entry.hasReactSource)
|
|
385
|
+
.map((entry) => entry.candidate);
|
|
386
|
+
const candidates = sourceBearing.length > 0 ? sourceBearing : allCandidates;
|
|
387
|
+
const projects = candidates.flatMap((candidate) => {
|
|
388
|
+
const candidateRelative = relative(requestedRoot, candidate).replaceAll('\\', '/');
|
|
389
|
+
const includePaths = scan.includePaths
|
|
390
|
+
?.map((path) => path.replaceAll('\\', '/'))
|
|
391
|
+
.filter((path) => path === candidateRelative || path.startsWith(`${candidateRelative}/`))
|
|
392
|
+
.map((path) => (path === candidateRelative ? '.' : path.slice(candidateRelative.length + 1)));
|
|
393
|
+
if (scan.includePaths && includePaths?.length === 0)
|
|
394
|
+
return [];
|
|
395
|
+
return [
|
|
396
|
+
{
|
|
397
|
+
directory: candidate,
|
|
398
|
+
deadCode: scan.options.deep ?? false,
|
|
399
|
+
...(includePaths ? { includePaths } : {}),
|
|
400
|
+
},
|
|
401
|
+
];
|
|
402
|
+
});
|
|
403
|
+
if (projects.length === 0)
|
|
404
|
+
return { ok: false, directory: requestedRoot, error };
|
|
405
|
+
const result = await runReactDoctorBatch(projects, concurrency);
|
|
406
|
+
return mergeReactProjectResults(requestedRoot, result.projects, error);
|
|
407
|
+
}
|
|
408
|
+
async function containsReactUiSource(root) {
|
|
409
|
+
const walk = async (directory) => {
|
|
410
|
+
let entries;
|
|
411
|
+
try {
|
|
412
|
+
entries = await readdir(directory, { withFileTypes: true });
|
|
413
|
+
}
|
|
414
|
+
catch {
|
|
415
|
+
return false;
|
|
416
|
+
}
|
|
417
|
+
for (const entry of entries) {
|
|
418
|
+
if (entry.name.startsWith('.'))
|
|
419
|
+
continue;
|
|
420
|
+
const path = join(directory, entry.name);
|
|
421
|
+
if (entry.isDirectory()) {
|
|
422
|
+
if (['build', 'coverage', 'dist', 'node_modules', 'out'].includes(entry.name))
|
|
423
|
+
continue;
|
|
424
|
+
if (await walk(path))
|
|
425
|
+
return true;
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
if (!entry.isFile() || !['.js', '.jsx', '.ts', '.tsx'].includes(extname(entry.name))) {
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
if (entry.name.endsWith('.jsx') || entry.name.endsWith('.tsx'))
|
|
432
|
+
return true;
|
|
433
|
+
try {
|
|
434
|
+
const source = await readFile(path, 'utf8');
|
|
435
|
+
if (/(?:from\s*['"]react['"]|require\(\s*['"]react['"]\s*\)|@hubspot\/ui-extensions|React\.createElement|react\/jsx-runtime)/.test(source)) {
|
|
436
|
+
return true;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
catch {
|
|
440
|
+
// Keep searching when an individual source file cannot be read.
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return false;
|
|
444
|
+
};
|
|
445
|
+
return walk(root);
|
|
446
|
+
}
|
|
447
|
+
function mergeReactProjectResults(root, results, fallbackError) {
|
|
448
|
+
const successful = results.filter((result) => result.ok);
|
|
449
|
+
const first = successful[0];
|
|
450
|
+
if (!first) {
|
|
451
|
+
return results[0] ?? { ok: false, directory: root, error: fallbackError };
|
|
452
|
+
}
|
|
453
|
+
const normalizePath = (projectRoot, path) => relative(root, resolve(projectRoot, path));
|
|
454
|
+
const diagnostics = successful.flatMap((result) => result.diagnostics.map((diagnostic) => ({
|
|
455
|
+
...diagnostic,
|
|
456
|
+
filePath: normalizePath(result.directory, diagnostic.filePath),
|
|
457
|
+
...(diagnostic.relatedLocations?.length
|
|
458
|
+
? {
|
|
459
|
+
relatedLocations: diagnostic.relatedLocations.map((location) => ({
|
|
460
|
+
...location,
|
|
461
|
+
filePath: normalizePath(result.directory, location.filePath),
|
|
462
|
+
})),
|
|
463
|
+
}
|
|
464
|
+
: {}),
|
|
465
|
+
})));
|
|
466
|
+
const skippedCheckReasons = Object.assign({}, ...successful.flatMap((result) => result.skippedCheckReasons ? [result.skippedCheckReasons] : []));
|
|
467
|
+
return {
|
|
468
|
+
ok: true,
|
|
469
|
+
directory: root,
|
|
470
|
+
diagnostics,
|
|
471
|
+
score: null,
|
|
472
|
+
skippedChecks: [...new Set(successful.flatMap((result) => result.skippedChecks))],
|
|
473
|
+
...(Object.keys(skippedCheckReasons).length > 0 ? { skippedCheckReasons } : {}),
|
|
474
|
+
analyzedFiles: successful.flatMap((result) => (result.analyzedFiles ?? []).map((path) => normalizePath(result.directory, path))),
|
|
475
|
+
scannedFileCount: successful.reduce((total, result) => total + (result.scannedFileCount ?? result.analyzedFiles?.length ?? 0), 0),
|
|
476
|
+
project: first.project,
|
|
477
|
+
reactDetected: successful.some((result) => result.reactDetected === true),
|
|
478
|
+
elapsedMilliseconds: successful.reduce((total, result) => total + result.elapsedMilliseconds, 0),
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
async function withOfflineScoreApi(work) {
|
|
482
|
+
const originalFetch = globalThis.fetch;
|
|
483
|
+
// react-doctor/api 0.9.12 does not honor `noScore` on diagnose(). Keep the
|
|
484
|
+
// richer in-process diagnostics without allowing its score upload to leave
|
|
485
|
+
// the machine. Supply-chain requests are separately disabled above.
|
|
486
|
+
globalThis.fetch = ((input, init) => {
|
|
487
|
+
const url = input instanceof Request ? input.url : String(input);
|
|
488
|
+
if (url.startsWith('https://www.react.doctor/api/score')) {
|
|
489
|
+
return Promise.resolve(new Response(JSON.stringify({ score: 100, label: 'offline' }), {
|
|
490
|
+
status: 200,
|
|
491
|
+
headers: { 'content-type': 'application/json' },
|
|
492
|
+
}));
|
|
493
|
+
}
|
|
494
|
+
return originalFetch(input, init);
|
|
495
|
+
});
|
|
496
|
+
try {
|
|
497
|
+
return await work();
|
|
498
|
+
}
|
|
499
|
+
finally {
|
|
500
|
+
globalThis.fetch = originalFetch;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
async function loadReactDoctorVersion() {
|
|
504
|
+
reactDoctorVersion ??= readFile(reactDoctorPackage, 'utf8').then((source) => JSON.parse(source).version);
|
|
505
|
+
return reactDoctorVersion;
|
|
506
|
+
}
|
|
507
|
+
async function stagedFiles(root) {
|
|
508
|
+
const { stdout } = await exec('git', ['diff', '--cached', '--relative', '--name-only', '--diff-filter=ACMR', '-z'], root);
|
|
509
|
+
return stdout.split('\0').filter(Boolean);
|
|
510
|
+
}
|
|
511
|
+
async function changedLineRanges(root, files, baseRef, staged) {
|
|
512
|
+
const ranges = new Map();
|
|
513
|
+
if (files.length === 0)
|
|
514
|
+
return ranges;
|
|
515
|
+
const args = ['diff', '--relative', '--unified=0'];
|
|
516
|
+
if (staged)
|
|
517
|
+
args.push('--cached');
|
|
518
|
+
else if (baseRef)
|
|
519
|
+
args.push(baseRef);
|
|
520
|
+
args.push('--', ...files);
|
|
521
|
+
const { stdout } = await exec('git', args, root);
|
|
522
|
+
let currentFile;
|
|
523
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
524
|
+
if (line.startsWith('+++ b/')) {
|
|
525
|
+
currentFile = line.slice(6);
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
const match = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/.exec(line);
|
|
529
|
+
if (!match || !currentFile)
|
|
530
|
+
continue;
|
|
531
|
+
const start = Number(match[1]);
|
|
532
|
+
const count = match[2] === undefined ? 1 : Number(match[2]);
|
|
533
|
+
if (count === 0)
|
|
534
|
+
continue;
|
|
535
|
+
const current = ranges.get(currentFile) ?? [];
|
|
536
|
+
if (current !== null)
|
|
537
|
+
current.push({ start, end: start + count - 1 });
|
|
538
|
+
ranges.set(currentFile, current);
|
|
539
|
+
}
|
|
540
|
+
const untracked = staged ? new Set() : await untrackedFiles(root);
|
|
541
|
+
for (const file of files) {
|
|
542
|
+
if (untracked.has(file))
|
|
543
|
+
ranges.set(file, null);
|
|
544
|
+
else if (!ranges.has(file))
|
|
545
|
+
ranges.set(file, []);
|
|
546
|
+
}
|
|
547
|
+
return ranges;
|
|
548
|
+
}
|
|
549
|
+
async function untrackedFiles(root) {
|
|
550
|
+
const { stdout } = await exec('git', ['ls-files', '--others', '--exclude-standard', '-z'], root);
|
|
551
|
+
return new Set(stdout.split('\0').filter(Boolean));
|
|
552
|
+
}
|
|
553
|
+
function diagnosticTouchesChangedLines(diagnostic, ranges) {
|
|
554
|
+
const fileRanges = ranges.get(diagnostic.filePath);
|
|
555
|
+
if (fileRanges === null)
|
|
556
|
+
return true;
|
|
557
|
+
if (!fileRanges)
|
|
558
|
+
return false;
|
|
559
|
+
const endLine = diagnostic.endLine ?? diagnostic.line;
|
|
560
|
+
return fileRanges.some((range) => diagnostic.line <= range.end && endLine >= range.start);
|
|
561
|
+
}
|
|
562
|
+
async function introducedDiagnostics(current, baseline, currentRoot, baselineRoot) {
|
|
563
|
+
const baselineCounts = new Map();
|
|
564
|
+
for (const diagnostic of baseline) {
|
|
565
|
+
const key = await diagnosticEvidenceKey(diagnostic, baselineRoot);
|
|
566
|
+
baselineCounts.set(key, (baselineCounts.get(key) ?? 0) + 1);
|
|
567
|
+
}
|
|
568
|
+
const introduced = [];
|
|
569
|
+
for (const diagnostic of current) {
|
|
570
|
+
const key = await diagnosticEvidenceKey(diagnostic, currentRoot);
|
|
571
|
+
const remaining = baselineCounts.get(key) ?? 0;
|
|
572
|
+
if (remaining > 0)
|
|
573
|
+
baselineCounts.set(key, remaining - 1);
|
|
574
|
+
else
|
|
575
|
+
introduced.push(diagnostic);
|
|
576
|
+
}
|
|
577
|
+
return introduced;
|
|
578
|
+
}
|
|
579
|
+
async function diagnosticEvidenceKey(diagnostic, root) {
|
|
580
|
+
// Upstream wording and source locations can carry run-specific detail even
|
|
581
|
+
// when the underlying rule evidence is unchanged. Baseline by stable
|
|
582
|
+
// file/plugin/rule identity; introducedDiagnostics' occurrence counts still
|
|
583
|
+
// expose any net-new finding for that rule in the file.
|
|
584
|
+
const file = relative(root, resolve(root, diagnostic.filePath));
|
|
585
|
+
return [file, diagnostic.plugin, diagnostic.rule].join('\0');
|
|
586
|
+
}
|
|
587
|
+
async function sourceLine(root, file, line) {
|
|
588
|
+
try {
|
|
589
|
+
const source = await readFile(resolve(root, file), 'utf8');
|
|
590
|
+
return (source.split(/\r?\n/)[line - 1] ?? '').replace(/\s+/g, ' ').trim();
|
|
591
|
+
}
|
|
592
|
+
catch {
|
|
593
|
+
return '';
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
async function extractGitSnapshot(root, baseRef, destination) {
|
|
597
|
+
const { stdout } = await exec('git', ['rev-parse', '--show-toplevel'], root);
|
|
598
|
+
const repositoryRoot = stdout.trim();
|
|
599
|
+
const archive = join(dirname(destination), 'base.tar');
|
|
600
|
+
await exec('git', ['archive', '--format=tar', '--output', archive, baseRef], repositoryRoot);
|
|
601
|
+
await mkdir(destination, { recursive: true });
|
|
602
|
+
await exec('tar', ['-xf', archive, '-C', destination], repositoryRoot);
|
|
603
|
+
const projectPath = relative(repositoryRoot, root);
|
|
604
|
+
return projectPath && projectPath !== '.' ? join(destination, projectPath) : destination;
|
|
605
|
+
}
|
|
606
|
+
async function extractStagedSnapshot(root, cleanupRoot, stagedChanges) {
|
|
607
|
+
const { stdout: prefixOutput } = await exec('git', ['rev-parse', '--show-prefix'], root);
|
|
608
|
+
const prefix = prefixOutput.trim();
|
|
609
|
+
let stagedRoot;
|
|
610
|
+
try {
|
|
611
|
+
stagedRoot = await extractGitSnapshot(root, 'HEAD', join(cleanupRoot, 'index'));
|
|
612
|
+
}
|
|
613
|
+
catch {
|
|
614
|
+
stagedRoot = prefix ? join(cleanupRoot, 'index', prefix) : join(cleanupRoot, 'index');
|
|
615
|
+
await mkdir(stagedRoot, { recursive: true });
|
|
616
|
+
}
|
|
617
|
+
for (const file of stagedChanges) {
|
|
618
|
+
const { stdout } = await exec('git', ['show', `:${prefix}${file}`], root);
|
|
619
|
+
const destination = resolve(stagedRoot, file);
|
|
620
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
621
|
+
await writeFile(destination, stdout);
|
|
622
|
+
}
|
|
623
|
+
return stagedRoot;
|
|
624
|
+
}
|
|
625
|
+
function exec(command, args, cwd) {
|
|
626
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
627
|
+
execFile(command, [...args], { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
628
|
+
if (error) {
|
|
629
|
+
rejectPromise(new Error(`${command} ${args.join(' ')} failed: ${stderr || error.message}`));
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
resolvePromise({ stdout, stderr });
|
|
633
|
+
});
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
function emptyScopedReport(scan) {
|
|
637
|
+
return {
|
|
638
|
+
status: 'scanned',
|
|
639
|
+
root: scan.root,
|
|
640
|
+
filesScanned: 0,
|
|
641
|
+
diagnostics: [],
|
|
642
|
+
summary: { errors: 0, warnings: 0 },
|
|
643
|
+
score: null,
|
|
644
|
+
scoreLabel: null,
|
|
645
|
+
skippedChecks: [],
|
|
646
|
+
suppressedDiagnostics: [],
|
|
647
|
+
scope: scan.scope,
|
|
648
|
+
...(scan.baseRef ? { baseRef: scan.baseRef } : {}),
|
|
649
|
+
staged: scan.options.staged ?? false,
|
|
650
|
+
deep: scan.options.deep ?? false,
|
|
651
|
+
ci: scan.options.ci ?? false,
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
function skippedReport(root, reason, options, scope, baseRef) {
|
|
655
|
+
return {
|
|
656
|
+
status: 'skipped',
|
|
657
|
+
root,
|
|
658
|
+
filesScanned: 0,
|
|
659
|
+
diagnostics: [],
|
|
660
|
+
summary: { errors: 0, warnings: 0 },
|
|
661
|
+
score: null,
|
|
662
|
+
scoreLabel: null,
|
|
663
|
+
skippedChecks: [],
|
|
664
|
+
suppressedDiagnostics: [],
|
|
665
|
+
scope,
|
|
666
|
+
...(baseRef ? { baseRef } : {}),
|
|
667
|
+
staged: options.staged ?? false,
|
|
668
|
+
deep: options.deep ?? false,
|
|
669
|
+
ci: options.ci ?? false,
|
|
670
|
+
reason,
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
//# sourceMappingURL=react-health.js.map
|