@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,884 @@
|
|
|
1
|
+
import { relative } from 'node:path';
|
|
2
|
+
import * as Args from '@effect/cli/Args';
|
|
3
|
+
import * as Command from '@effect/cli/Command';
|
|
4
|
+
import * as Options from '@effect/cli/Options';
|
|
5
|
+
import { REACT_DOCTOR_COVERAGE, REACT_DOCTOR_RULE_COVERAGE, REACT_DOCTOR_RULE_METADATA, STUDIO_RULE_CATALOG, explainReactDoctorRule, scanReactProject, scanReactReview, scanReactWorkspace, } from '@hs-x/react-doctor';
|
|
6
|
+
import { Effect, Option } from 'effect';
|
|
7
|
+
import { cliError, exitWith } from '../cli-error.js';
|
|
8
|
+
import { jsonOption, runHandler } from '../cli/kit.js';
|
|
9
|
+
import { currentCliOutputFlags } from '../output-context.js';
|
|
10
|
+
import { Cwd } from '../services/cwd.js';
|
|
11
|
+
import { Reporter } from '../services/reporter.js';
|
|
12
|
+
import { scanReactHealth, scanReactHealthProjects, scopeHubSpotReactReport, } from './react-health.js';
|
|
13
|
+
const strictOption = Options.boolean('strict').pipe(Options.withDefault(false));
|
|
14
|
+
const recursiveOption = Options.boolean('recursive').pipe(Options.withDefault(false));
|
|
15
|
+
const hubspotOnlyOption = Options.boolean('hubspot-only').pipe(Options.withDefault(false));
|
|
16
|
+
const scopeOption = Options.choice('scope', ['full', 'files', 'changed', 'lines']).pipe(Options.optional);
|
|
17
|
+
const baseOption = Options.text('base').pipe(Options.optional);
|
|
18
|
+
const deepOption = Options.boolean('deep').pipe(Options.withDefault(false));
|
|
19
|
+
const stagedOption = Options.boolean('staged').pipe(Options.withDefault(false));
|
|
20
|
+
const ciOption = Options.boolean('ci').pipe(Options.withDefault(false));
|
|
21
|
+
const reactDoctorBaseCmd = Command.make('doctor', {
|
|
22
|
+
hubspotOnly: hubspotOnlyOption,
|
|
23
|
+
base: baseOption,
|
|
24
|
+
ci: ciOption,
|
|
25
|
+
deep: deepOption,
|
|
26
|
+
json: jsonOption,
|
|
27
|
+
recursive: recursiveOption,
|
|
28
|
+
scope: scopeOption,
|
|
29
|
+
staged: stagedOption,
|
|
30
|
+
strict: strictOption,
|
|
31
|
+
}, (opts) => {
|
|
32
|
+
const verbose = currentCliOutputFlags().verbose;
|
|
33
|
+
return runHandler('react doctor', { json: opts.json, verbose }, reactDoctorBody({ ...opts, verbose }));
|
|
34
|
+
});
|
|
35
|
+
const whyLocationArg = Args.text({ name: 'file:line' });
|
|
36
|
+
export const reactDoctorWhyCmd = Command.make('why', { json: jsonOption, location: whyLocationArg }, (opts) => runHandler('react doctor why', { json: opts.json }, reactDoctorWhyBody(opts.location, opts.json)));
|
|
37
|
+
export const reactDoctorCmd = reactDoctorBaseCmd.pipe(Command.withSubcommands([reactDoctorWhyCmd]));
|
|
38
|
+
const reactRulesBaseCmd = Command.make('rules', { json: jsonOption }, (opts) => runHandler('react rules', { json: opts.json }, Effect.gen(function* () {
|
|
39
|
+
const reporter = yield* Reporter;
|
|
40
|
+
const coverageById = new Map(REACT_DOCTOR_RULE_COVERAGE.map((coverage) => [coverage.id, coverage.status]));
|
|
41
|
+
const rules = STUDIO_RULE_CATALOG.map((rule) => ({
|
|
42
|
+
...rule,
|
|
43
|
+
status: coverageById.get(rule.id) ?? 'pending',
|
|
44
|
+
...(rule.id in REACT_DOCTOR_RULE_METADATA
|
|
45
|
+
? {
|
|
46
|
+
findingKind: REACT_DOCTOR_RULE_METADATA[rule.id].kind,
|
|
47
|
+
platform: REACT_DOCTOR_RULE_METADATA[rule.id].platform,
|
|
48
|
+
}
|
|
49
|
+
: {}),
|
|
50
|
+
}));
|
|
51
|
+
if (opts.json) {
|
|
52
|
+
yield* reporter.data({ coverage: REACT_DOCTOR_COVERAGE, rules });
|
|
53
|
+
yield* reporter.done();
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
yield* reporter.header('React Doctor rule coverage');
|
|
57
|
+
yield* reporter.rows([
|
|
58
|
+
{
|
|
59
|
+
key: 'implemented',
|
|
60
|
+
value: `${REACT_DOCTOR_COVERAGE.implemented} deterministic React checks`,
|
|
61
|
+
status: 'ok',
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
key: 'heuristic',
|
|
65
|
+
value: `${REACT_DOCTOR_COVERAGE.heuristic} context-sensitive rules`,
|
|
66
|
+
status: 'warn',
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
key: 'studio-only',
|
|
70
|
+
value: `${REACT_DOCTOR_COVERAGE.studioOnly} JSON-spec/runtime rules`,
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
key: 'pending',
|
|
74
|
+
value: `${REACT_DOCTOR_COVERAGE.pending} deterministic candidates`,
|
|
75
|
+
status: REACT_DOCTOR_COVERAGE.pending > 0 ? 'warn' : 'ok',
|
|
76
|
+
},
|
|
77
|
+
]);
|
|
78
|
+
yield* reporter.info(`Studio catalog: ${REACT_DOCTOR_COVERAGE.catalogRules} rules`);
|
|
79
|
+
yield* reporter.info(`Source SHA-256: ${REACT_DOCTOR_COVERAGE.sourceSha256}`);
|
|
80
|
+
yield* reporter.done('Rule coverage loaded', 0, {
|
|
81
|
+
ok: REACT_DOCTOR_COVERAGE.implemented,
|
|
82
|
+
warn: REACT_DOCTOR_COVERAGE.heuristic + REACT_DOCTOR_COVERAGE.pending,
|
|
83
|
+
});
|
|
84
|
+
})));
|
|
85
|
+
const ruleIdArg = Args.text({ name: 'rule-id' });
|
|
86
|
+
export const reactRulesExplainCmd = Command.make('explain', { json: jsonOption, ruleId: ruleIdArg }, (opts) => runHandler('react rules explain', { json: opts.json }, Effect.gen(function* () {
|
|
87
|
+
const reporter = yield* Reporter;
|
|
88
|
+
const explanation = yield* Effect.try({
|
|
89
|
+
try: () => explainReactDoctorRule(opts.ruleId.toUpperCase()),
|
|
90
|
+
catch: (cause) => cliError('HSX_E_INPUT_INVALID', cause instanceof Error ? cause.message : String(cause)),
|
|
91
|
+
});
|
|
92
|
+
if (opts.json) {
|
|
93
|
+
yield* reporter.data(explanation);
|
|
94
|
+
yield* reporter.done();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
yield* reporter.header(`${explanation.rule.id} · ${explanation.rule.category}`);
|
|
98
|
+
yield* reporter.rows([
|
|
99
|
+
{
|
|
100
|
+
key: explanation.enforcement,
|
|
101
|
+
value: explanation.rule.rule,
|
|
102
|
+
...(explanation.enforcement === 'enforced'
|
|
103
|
+
? { status: 'ok' }
|
|
104
|
+
: explanation.enforcement === 'unimplemented'
|
|
105
|
+
? { status: 'warn' }
|
|
106
|
+
: {}),
|
|
107
|
+
detail: `${explanation.rule.severity} · ${explanation.status}`,
|
|
108
|
+
hint: `Source: ${explanation.rule.source}`,
|
|
109
|
+
},
|
|
110
|
+
]);
|
|
111
|
+
for (const source of explanation.sourceDocuments) {
|
|
112
|
+
yield* reporter.info(`${source.name}${source.scope.length > 0 ? ` · scope: ${source.scope.join(', ')}` : ''}`);
|
|
113
|
+
}
|
|
114
|
+
yield* reporter.info(explanation.agent.instruction);
|
|
115
|
+
yield* reporter.info('Use --json to include the complete versioned source guidance.');
|
|
116
|
+
yield* reporter.done('Rule explanation loaded');
|
|
117
|
+
})));
|
|
118
|
+
export const reactRulesCmd = reactRulesBaseCmd.pipe(Command.withSubcommands([reactRulesExplainCmd]));
|
|
119
|
+
export const reactReviewCmd = Command.make('review', { deep: deepOption, json: jsonOption }, (opts) => runHandler('react review', { json: opts.json }, Effect.gen(function* () {
|
|
120
|
+
const root = yield* Cwd;
|
|
121
|
+
const reporter = yield* Reporter;
|
|
122
|
+
const packet = yield* Effect.tryPromise({
|
|
123
|
+
try: () => scanReactReview({ root, deep: opts.deep }),
|
|
124
|
+
catch: (cause) => cliError('HSX_E_INTERNAL', cause instanceof Error ? cause.message : `React review failed: ${String(cause)}`),
|
|
125
|
+
});
|
|
126
|
+
if (opts.json) {
|
|
127
|
+
yield* reporter.data(packet);
|
|
128
|
+
yield* reporter.done();
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
yield* reporter.header(packet.projectName ?? 'HubSpot React review');
|
|
132
|
+
yield* reporter.rows([
|
|
133
|
+
{
|
|
134
|
+
key: 'deterministic',
|
|
135
|
+
value: `${packet.deterministic.diagnostics.length} finding${packet.deterministic.diagnostics.length === 1 ? '' : 's'}`,
|
|
136
|
+
status: packet.deterministic.summary.errors > 0 ? 'fail' : 'ok',
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
key: 'agent-review',
|
|
140
|
+
value: `${packet.candidates.length} contextual candidate${packet.candidates.length === 1 ? '' : 's'}`,
|
|
141
|
+
status: packet.candidates.length > 0 ? 'warn' : 'ok',
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
key: 'reference',
|
|
145
|
+
value: `${packet.referenceRules.length} project-relevant heuristic rules`,
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
key: 'inventory',
|
|
149
|
+
value: `${packet.inventory.components.length} HubSpot/HS-UIX components · ${packet.filesScanned} files`,
|
|
150
|
+
},
|
|
151
|
+
]);
|
|
152
|
+
for (const candidate of packet.candidates.slice(0, 12)) {
|
|
153
|
+
yield* reporter.info(`${candidate.ruleId} · ${candidate.question} · ${candidate.evidence.length} evidence location${candidate.evidence.length === 1 ? '' : 's'}`);
|
|
154
|
+
}
|
|
155
|
+
if (packet.candidates.length > 12) {
|
|
156
|
+
yield* reporter.info(`${packet.candidates.length - 12} more candidates are available in --json.`);
|
|
157
|
+
}
|
|
158
|
+
yield* reporter.info('Candidates never affect the doctor exit code. An agent must return violation, compliant, or needs-context with evidence.');
|
|
159
|
+
yield* reporter.done('Review packet ready', packet.deterministic.summary.errors > 0 ? 1 : 0, {
|
|
160
|
+
ok: packet.deterministic.diagnostics.length,
|
|
161
|
+
warn: packet.candidates.length,
|
|
162
|
+
});
|
|
163
|
+
})));
|
|
164
|
+
export const reactCmd = Command.make('react').pipe(Command.withSubcommands([reactDoctorCmd, reactReviewCmd, reactRulesCmd]));
|
|
165
|
+
function reactDoctorBody(opts) {
|
|
166
|
+
return Effect.gen(function* () {
|
|
167
|
+
const root = yield* Cwd;
|
|
168
|
+
const reporter = yield* Reporter;
|
|
169
|
+
const scope = Option.getOrUndefined(opts.scope);
|
|
170
|
+
const base = Option.getOrUndefined(opts.base);
|
|
171
|
+
const reactHealthOptions = {
|
|
172
|
+
...(scope ? { scope } : {}),
|
|
173
|
+
...(base ? { base } : {}),
|
|
174
|
+
deep: opts.deep,
|
|
175
|
+
staged: opts.staged,
|
|
176
|
+
ci: opts.ci,
|
|
177
|
+
};
|
|
178
|
+
const unscopedReport = yield* Effect.tryPromise({
|
|
179
|
+
try: () => opts.recursive
|
|
180
|
+
? scanReactWorkspace({ root, deep: opts.deep })
|
|
181
|
+
: scanReactProject({ root, deep: opts.deep }),
|
|
182
|
+
catch: (cause) => cliError('HSX_E_INTERNAL', cause instanceof Error ? cause.message : `React Doctor failed: ${String(cause)}`),
|
|
183
|
+
});
|
|
184
|
+
const report = yield* Effect.tryPromise({
|
|
185
|
+
try: () => scopeHubSpotReactReport(unscopedReport, reactHealthOptions),
|
|
186
|
+
catch: (cause) => cliError('HSX_E_INTERNAL', cause instanceof Error ? cause.message : `React scope failed: ${String(cause)}`),
|
|
187
|
+
});
|
|
188
|
+
const reactHealth = opts.hubspotOnly
|
|
189
|
+
? []
|
|
190
|
+
: yield* Effect.tryPromise({
|
|
191
|
+
try: () => 'projects' in report
|
|
192
|
+
? scanReactHealthProjects(report.projects.map((project) => project.root), reactHealthOptions)
|
|
193
|
+
: scanReactHealth(report.root, reactHealthOptions).then((health) => [health]),
|
|
194
|
+
catch: (cause) => cliError('HSX_E_INTERNAL', cause instanceof Error
|
|
195
|
+
? cause.message
|
|
196
|
+
: `Upstream React Doctor failed: ${String(cause)}`),
|
|
197
|
+
});
|
|
198
|
+
const combinedSummary = combineSummary(report, reactHealth);
|
|
199
|
+
const exitCode = combinedSummary.errors > 0 || (opts.strict && combinedSummary.warnings > 0) ? 1 : 0;
|
|
200
|
+
if (opts.json) {
|
|
201
|
+
yield* reporter.data({
|
|
202
|
+
...report,
|
|
203
|
+
reactHealth: {
|
|
204
|
+
enabled: !opts.hubspotOnly,
|
|
205
|
+
projects: reactHealth,
|
|
206
|
+
summary: summarizeReactHealth(reactHealth),
|
|
207
|
+
},
|
|
208
|
+
combinedSummary,
|
|
209
|
+
mode: {
|
|
210
|
+
scope: scope ?? (opts.staged ? 'files' : opts.ci ? 'changed' : 'full'),
|
|
211
|
+
...(base ? { base } : {}),
|
|
212
|
+
staged: opts.staged,
|
|
213
|
+
deep: opts.deep,
|
|
214
|
+
ci: opts.ci,
|
|
215
|
+
},
|
|
216
|
+
strict: opts.strict,
|
|
217
|
+
});
|
|
218
|
+
yield* reporter.done(undefined, exitCode);
|
|
219
|
+
if (exitCode !== 0)
|
|
220
|
+
return yield* Effect.fail(exitWith(exitCode));
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if ('projects' in report) {
|
|
224
|
+
yield* renderWorkspaceReport(reporter, report, reactHealth, exitCode, opts.verbose);
|
|
225
|
+
if (exitCode !== 0)
|
|
226
|
+
return yield* Effect.fail(exitWith(exitCode));
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
yield* renderProjectReport(reporter, report, reactHealth[0], exitCode, opts.verbose);
|
|
230
|
+
if (exitCode !== 0)
|
|
231
|
+
return yield* Effect.fail(exitWith(exitCode));
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
function reactDoctorWhyBody(input, json) {
|
|
235
|
+
return Effect.gen(function* () {
|
|
236
|
+
const root = yield* Cwd;
|
|
237
|
+
const reporter = yield* Reporter;
|
|
238
|
+
const location = yield* Effect.try({
|
|
239
|
+
try: () => parseWhyLocation(input, root),
|
|
240
|
+
catch: (cause) => cliError('HSX_E_INPUT_INVALID', cause instanceof Error ? cause.message : String(cause)),
|
|
241
|
+
});
|
|
242
|
+
const hubspot = yield* Effect.tryPromise({
|
|
243
|
+
try: () => scanReactProject({ root }),
|
|
244
|
+
catch: (cause) => cliError('HSX_E_INTERNAL', cause instanceof Error ? cause.message : String(cause)),
|
|
245
|
+
});
|
|
246
|
+
const react = yield* Effect.tryPromise({
|
|
247
|
+
try: () => scanReactHealth(root),
|
|
248
|
+
catch: (cause) => cliError('HSX_E_INTERNAL', cause instanceof Error ? cause.message : String(cause)),
|
|
249
|
+
});
|
|
250
|
+
const explanations = [
|
|
251
|
+
...hubspot.diagnostics
|
|
252
|
+
.filter((diagnostic) => diagnosticAtLocation(diagnostic, location))
|
|
253
|
+
.map((diagnostic) => {
|
|
254
|
+
const metadata = REACT_DOCTOR_RULE_METADATA[diagnostic.ruleId];
|
|
255
|
+
return {
|
|
256
|
+
engine: 'hubspot-uix',
|
|
257
|
+
ruleId: diagnostic.ruleId,
|
|
258
|
+
kind: diagnostic.kind,
|
|
259
|
+
severity: diagnostic.severity,
|
|
260
|
+
message: diagnostic.message,
|
|
261
|
+
guidance: diagnostic.suggestion,
|
|
262
|
+
source: diagnostic.source,
|
|
263
|
+
platform: metadata.platform,
|
|
264
|
+
projectPlatform: hubspot.platformVersion ?? null,
|
|
265
|
+
location: `${diagnostic.file}:${diagnostic.line}:${diagnostic.column}`,
|
|
266
|
+
};
|
|
267
|
+
}),
|
|
268
|
+
...react.diagnostics
|
|
269
|
+
.filter((diagnostic) => diagnosticAtLocation(diagnostic, location))
|
|
270
|
+
.map((diagnostic) => ({
|
|
271
|
+
engine: 'react-doctor',
|
|
272
|
+
ruleId: diagnostic.ruleId,
|
|
273
|
+
kind: diagnostic.severity === 'error' ? 'error' : 'review',
|
|
274
|
+
severity: diagnostic.severity,
|
|
275
|
+
message: diagnostic.message,
|
|
276
|
+
guidance: diagnostic.suggestion,
|
|
277
|
+
category: diagnostic.category,
|
|
278
|
+
suppressionHint: diagnostic.suppressionHint ?? null,
|
|
279
|
+
relatedLocations: diagnostic.relatedLocations ?? [],
|
|
280
|
+
fixGroupId: diagnostic.fixGroupId ?? null,
|
|
281
|
+
url: diagnostic.url ?? null,
|
|
282
|
+
location: `${diagnostic.file}:${diagnostic.line}:${diagnostic.column}`,
|
|
283
|
+
})),
|
|
284
|
+
];
|
|
285
|
+
if (json) {
|
|
286
|
+
yield* reporter.data({
|
|
287
|
+
query: location,
|
|
288
|
+
explanations,
|
|
289
|
+
suppressedPatterns: explanations.length === 0 ? react.suppressedDiagnostics : [],
|
|
290
|
+
});
|
|
291
|
+
yield* reporter.done();
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
yield* reporter.header(`Why · ${location.file}:${location.line}`);
|
|
295
|
+
if (explanations.length === 0) {
|
|
296
|
+
yield* reporter.info('No active finding starts at this location.');
|
|
297
|
+
if (react.suppressedDiagnostics.length > 0) {
|
|
298
|
+
yield* reporter.info(`${react.suppressedDiagnostics.length} upstream rule pattern${react.suppressedDiagnostics.length === 1 ? ' was' : 's were'} suppressed by HubSpot runtime exceptions; use --json to inspect them.`);
|
|
299
|
+
}
|
|
300
|
+
yield* reporter.done('No active finding at this location', 0, { warn: 1 });
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
for (const explanation of explanations) {
|
|
304
|
+
yield* reporter.rows([
|
|
305
|
+
{
|
|
306
|
+
key: explanation.ruleId,
|
|
307
|
+
value: explanation.message,
|
|
308
|
+
status: explanation.severity === 'error' ? 'fail' : 'warn',
|
|
309
|
+
detail: explanation.engine === 'hubspot-uix' ? 'HubSpot UIX' : explanation.category,
|
|
310
|
+
hint: [
|
|
311
|
+
explanation.guidance,
|
|
312
|
+
...(explanation.engine === 'hubspot-uix'
|
|
313
|
+
? [
|
|
314
|
+
`Source: ${explanation.source}`,
|
|
315
|
+
...(explanation.projectPlatform
|
|
316
|
+
? [`Project platform: ${explanation.projectPlatform}`]
|
|
317
|
+
: []),
|
|
318
|
+
]
|
|
319
|
+
: [
|
|
320
|
+
...(explanation.suppressionHint
|
|
321
|
+
? [`Suppress: ${explanation.suppressionHint}`]
|
|
322
|
+
: []),
|
|
323
|
+
...(explanation.url ? [explanation.url] : []),
|
|
324
|
+
]),
|
|
325
|
+
].join(' · '),
|
|
326
|
+
},
|
|
327
|
+
]);
|
|
328
|
+
}
|
|
329
|
+
yield* reporter.done(`${explanations.length} explanation${explanations.length === 1 ? '' : 's'}`);
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
function parseWhyLocation(input, root) {
|
|
333
|
+
const match = /^(.+?):(\d+)(?::(\d+))?$/.exec(input.trim());
|
|
334
|
+
if (!match?.[1] || !match[2]) {
|
|
335
|
+
throw new Error('Expected a source location like src/Card.tsx:42 or src/Card.tsx:42:7.');
|
|
336
|
+
}
|
|
337
|
+
const requested = match[1];
|
|
338
|
+
const file = requested.startsWith('/') ? relative(root, requested) : requested;
|
|
339
|
+
return {
|
|
340
|
+
file: file.replaceAll('\\', '/').replace(/^\.\//, ''),
|
|
341
|
+
line: Number(match[2]),
|
|
342
|
+
...(match[3] ? { column: Number(match[3]) } : {}),
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
function diagnosticAtLocation(diagnostic, location) {
|
|
346
|
+
const file = diagnostic.file.replaceAll('\\', '/').replace(/^\.\//, '');
|
|
347
|
+
return (file === location.file &&
|
|
348
|
+
diagnostic.line === location.line &&
|
|
349
|
+
(location.column === undefined || diagnostic.column === location.column));
|
|
350
|
+
}
|
|
351
|
+
function renderProjectReport(reporter, report, reactHealth, exitCode, verbose) {
|
|
352
|
+
return Effect.gen(function* () {
|
|
353
|
+
yield* reporter.header(report.projectName ?? 'HubSpot React project');
|
|
354
|
+
const hubspotDiagnostics = report.diagnostics.map((diagnostic) => hubspotDisplayDiagnostic(diagnostic));
|
|
355
|
+
const displaySummary = combineFindingSummaries(summarizeDisplayDiagnostics(hubspotDiagnostics), summarizeDisplayDiagnostics(reactHealth?.diagnostics.map((diagnostic) => reactDisplayDiagnostic(diagnostic)) ?? []));
|
|
356
|
+
const platform = report.platformVersion ? ` · platform ${report.platformVersion}` : '';
|
|
357
|
+
const hubspotScan = yield* reporter.step(`HubSpot UIX · ${report.filesScanned} source files${platform}`);
|
|
358
|
+
if (report.filesScanned === 0) {
|
|
359
|
+
yield* hubspotScan.warn('no HubSpot React source found');
|
|
360
|
+
}
|
|
361
|
+
else if (report.diagnostics.length === 0) {
|
|
362
|
+
yield* hubspotScan.ok(`no issues detected by ${report.rulesEvaluated} of ${report.coverage.catalogRules} catalog rules`);
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
const summary = findingSummary(summarizeDisplayDiagnostics(hubspotDiagnostics));
|
|
366
|
+
if (report.summary.errors > 0)
|
|
367
|
+
yield* hubspotScan.fail(summary);
|
|
368
|
+
else
|
|
369
|
+
yield* hubspotScan.warn(summary);
|
|
370
|
+
yield* renderDiagnosticGroups(reporter, hubspotDiagnostics, verbose);
|
|
371
|
+
}
|
|
372
|
+
if (reactHealth)
|
|
373
|
+
yield* renderReactHealthReport(reporter, reactHealth, verbose);
|
|
374
|
+
else
|
|
375
|
+
yield* reporter.info('Generic React health disabled by --hubspot-only.');
|
|
376
|
+
yield* renderDoctorNotes(reporter, report, reactHealth ? [reactHealth] : [], verbose);
|
|
377
|
+
yield* reporter.done(resultSummary(exitCode, displaySummary), exitCode);
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
function renderWorkspaceReport(reporter, report, reactHealth, exitCode, verbose) {
|
|
381
|
+
return Effect.gen(function* () {
|
|
382
|
+
yield* reporter.header('HubSpot React workspace');
|
|
383
|
+
const hubspotDiagnostics = [];
|
|
384
|
+
for (const project of report.projects) {
|
|
385
|
+
for (const diagnostic of project.diagnostics) {
|
|
386
|
+
hubspotDiagnostics.push(hubspotDisplayDiagnostic(diagnostic, `${project.projectName ?? project.root}${project.platformVersion ? ` · ${project.platformVersion}` : ''}`));
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
const projectByRoot = new Map(report.projects.map((project) => [project.root, project.projectName ?? project.root]));
|
|
390
|
+
const reactDiagnostics = reactHealth.flatMap((health) => health.diagnostics.map((diagnostic) => reactDisplayDiagnostic(diagnostic, projectByRoot.get(health.root) ?? health.root)));
|
|
391
|
+
const displaySummary = combineFindingSummaries(summarizeDisplayDiagnostics(hubspotDiagnostics), summarizeDisplayDiagnostics(reactDiagnostics));
|
|
392
|
+
const hubspotScan = yield* reporter.step(`HubSpot UIX · ${report.projectsScanned} projects · ${report.filesScanned} source files`);
|
|
393
|
+
if (report.projectsScanned === 0) {
|
|
394
|
+
yield* hubspotScan.warn('no hsproject.json projects found');
|
|
395
|
+
yield* reporter.done('Nothing to scan', 0, { warn: 1 });
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
if (report.summary.errors === 0 && report.summary.warnings === 0) {
|
|
399
|
+
yield* hubspotScan.ok(`no issues detected by ${report.rulesEvaluated} of ${report.coverage.catalogRules} catalog rules`);
|
|
400
|
+
}
|
|
401
|
+
else {
|
|
402
|
+
const summary = findingSummary(summarizeDisplayDiagnostics(hubspotDiagnostics));
|
|
403
|
+
if (report.summary.errors > 0)
|
|
404
|
+
yield* hubspotScan.fail(summary);
|
|
405
|
+
else
|
|
406
|
+
yield* hubspotScan.warn(summary);
|
|
407
|
+
yield* renderDiagnosticGroups(reporter, hubspotDiagnostics, verbose);
|
|
408
|
+
}
|
|
409
|
+
if (reactHealth.length > 0) {
|
|
410
|
+
yield* renderReactHealthWorkspace(reporter, report, reactHealth, verbose);
|
|
411
|
+
}
|
|
412
|
+
else
|
|
413
|
+
yield* reporter.info('Generic React health disabled by --hubspot-only.');
|
|
414
|
+
yield* renderDoctorNotes(reporter, report, reactHealth, verbose);
|
|
415
|
+
yield* reporter.done(resultSummary(exitCode, displaySummary), exitCode);
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
function renderReactHealthReport(reporter, report, verbose) {
|
|
419
|
+
return Effect.gen(function* () {
|
|
420
|
+
const scan = yield* reporter.step(`React health · ${report.filesScanned} React files${reactHealthModeSuffix(report)}`);
|
|
421
|
+
if (report.status === 'skipped') {
|
|
422
|
+
yield* scan.warn(`skipped · ${report.reason ?? 'React project not detected'}`);
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
const diagnostics = report.diagnostics.map((diagnostic) => reactDisplayDiagnostic(diagnostic));
|
|
426
|
+
const displaySummary = summarizeDisplayDiagnostics(diagnostics);
|
|
427
|
+
if (report.diagnostics.length === 0)
|
|
428
|
+
yield* scan.ok('no upstream issues');
|
|
429
|
+
else if (report.summary.errors > 0)
|
|
430
|
+
yield* scan.fail(findingSummary(displaySummary));
|
|
431
|
+
else
|
|
432
|
+
yield* scan.warn(findingSummary(displaySummary));
|
|
433
|
+
if (report.diagnostics.length > 0) {
|
|
434
|
+
yield* renderDiagnosticGroups(reporter, diagnostics, verbose);
|
|
435
|
+
}
|
|
436
|
+
if (verbose)
|
|
437
|
+
yield* renderReactSuppressions(reporter, report);
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
function renderReactHealthWorkspace(reporter, hubspot, reports, verbose) {
|
|
441
|
+
return Effect.gen(function* () {
|
|
442
|
+
const summary = summarizeReactHealth(reports);
|
|
443
|
+
const scanned = reports.filter((report) => report.status === 'scanned');
|
|
444
|
+
const skipped = reports.length - scanned.length;
|
|
445
|
+
const files = scanned.reduce((total, report) => total + report.filesScanned, 0);
|
|
446
|
+
const scan = yield* reporter.step(`React health · ${scanned.length} projects · ${files} React files${scanned[0] ? reactHealthModeSuffix(scanned[0]) : ''}`);
|
|
447
|
+
const projectByRoot = new Map(hubspot.projects.map((project) => [project.root, project.projectName ?? project.root]));
|
|
448
|
+
const diagnostics = reports.flatMap((report) => report.diagnostics.map((diagnostic) => reactDisplayDiagnostic(diagnostic, projectByRoot.get(report.root) ?? report.root)));
|
|
449
|
+
const displaySummary = summarizeDisplayDiagnostics(diagnostics);
|
|
450
|
+
if (scanned.length === 0)
|
|
451
|
+
yield* scan.warn(`all ${skipped} projects skipped`);
|
|
452
|
+
else if (summary.errors > 0)
|
|
453
|
+
yield* scan.fail(findingSummary(displaySummary));
|
|
454
|
+
else if (summary.warnings > 0)
|
|
455
|
+
yield* scan.warn(findingSummary(displaySummary));
|
|
456
|
+
else
|
|
457
|
+
yield* scan.ok(skipped > 0 ? `no issues · ${skipped} skipped` : 'no upstream issues');
|
|
458
|
+
if (diagnostics.length > 0) {
|
|
459
|
+
yield* renderDiagnosticGroups(reporter, diagnostics, verbose);
|
|
460
|
+
}
|
|
461
|
+
if (verbose) {
|
|
462
|
+
for (const report of reports)
|
|
463
|
+
yield* renderReactSuppressions(reporter, report);
|
|
464
|
+
}
|
|
465
|
+
if (skipped > 0)
|
|
466
|
+
yield* reporter.info(`React health skipped ${skipped} non-React projects.`);
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
function reactHealthModeSuffix(report) {
|
|
470
|
+
const modes = [];
|
|
471
|
+
if (report.staged)
|
|
472
|
+
modes.push('staged');
|
|
473
|
+
else if (report.scope !== 'full') {
|
|
474
|
+
modes.push(report.baseRef ? `${report.scope} vs ${report.baseRef}` : report.scope);
|
|
475
|
+
}
|
|
476
|
+
if (report.deep)
|
|
477
|
+
modes.push('deep');
|
|
478
|
+
if (report.ci)
|
|
479
|
+
modes.push('CI');
|
|
480
|
+
return modes.length > 0 ? ` · ${modes.join(' · ')}` : '';
|
|
481
|
+
}
|
|
482
|
+
function summarizeReactHealth(reports) {
|
|
483
|
+
return reports.reduce((summary, report) => ({
|
|
484
|
+
errors: summary.errors + report.summary.errors,
|
|
485
|
+
reviews: summary.reviews + report.summary.warnings,
|
|
486
|
+
recommendations: summary.recommendations,
|
|
487
|
+
warnings: summary.warnings + report.summary.warnings,
|
|
488
|
+
}), { errors: 0, reviews: 0, recommendations: 0, warnings: 0 });
|
|
489
|
+
}
|
|
490
|
+
function combineFindingSummaries(left, right) {
|
|
491
|
+
return {
|
|
492
|
+
errors: left.errors + right.errors,
|
|
493
|
+
reviews: left.reviews + right.reviews,
|
|
494
|
+
recommendations: left.recommendations + right.recommendations,
|
|
495
|
+
warnings: left.warnings + right.warnings,
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
function renderReactSuppressions(reporter, report) {
|
|
499
|
+
return Effect.gen(function* () {
|
|
500
|
+
for (const suppression of report.suppressedDiagnostics) {
|
|
501
|
+
yield* reporter.info(`Suppressed ${suppression.count} ${suppression.ruleId}: ${suppression.reason}`);
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
function combineSummary(report, reactHealth) {
|
|
506
|
+
const react = summarizeReactHealth(reactHealth);
|
|
507
|
+
const hubspot = report.summary;
|
|
508
|
+
return {
|
|
509
|
+
errors: hubspot.errors + react.errors,
|
|
510
|
+
reviews: hubspot.reviews + react.reviews,
|
|
511
|
+
recommendations: hubspot.recommendations + react.recommendations,
|
|
512
|
+
warnings: hubspot.warnings + react.warnings,
|
|
513
|
+
hubspot,
|
|
514
|
+
react,
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
function findingSummary(summary) {
|
|
518
|
+
const findings = [];
|
|
519
|
+
if (summary.errors > 0)
|
|
520
|
+
findings.push(`${summary.errors} error${summary.errors === 1 ? '' : 's'}`);
|
|
521
|
+
if (summary.reviews > 0) {
|
|
522
|
+
findings.push(`${summary.reviews} review${summary.reviews === 1 ? '' : 's'}`);
|
|
523
|
+
}
|
|
524
|
+
if (summary.recommendations > 0) {
|
|
525
|
+
findings.push(`${summary.recommendations} recommendation${summary.recommendations === 1 ? '' : 's'}`);
|
|
526
|
+
}
|
|
527
|
+
return findings.length > 0 ? findings.join(' · ') : 'no issues';
|
|
528
|
+
}
|
|
529
|
+
function resultSummary(exitCode, summary) {
|
|
530
|
+
const outcome = exitCode !== 0
|
|
531
|
+
? 'Scan failed'
|
|
532
|
+
: summary.reviews > 0
|
|
533
|
+
? 'Scan passed with review items'
|
|
534
|
+
: summary.recommendations > 0
|
|
535
|
+
? 'Scan passed with HS-UIX recommendations'
|
|
536
|
+
: 'Scan passed';
|
|
537
|
+
const findings = findingSummary(summary);
|
|
538
|
+
return findings === 'no issues' ? outcome : `${findings} · ${outcome}`;
|
|
539
|
+
}
|
|
540
|
+
function summarizeDisplayDiagnostics(diagnostics) {
|
|
541
|
+
const clusters = new Map();
|
|
542
|
+
for (const diagnostic of addDiagnosticRelations(diagnostics)) {
|
|
543
|
+
clusters.set(diagnosticFamilyKey(diagnostic, false), diagnostic.kind);
|
|
544
|
+
}
|
|
545
|
+
let errors = 0;
|
|
546
|
+
let reviews = 0;
|
|
547
|
+
let recommendations = 0;
|
|
548
|
+
for (const kind of clusters.values()) {
|
|
549
|
+
if (kind === 'error')
|
|
550
|
+
errors += 1;
|
|
551
|
+
else if (kind === 'review')
|
|
552
|
+
reviews += 1;
|
|
553
|
+
else
|
|
554
|
+
recommendations += 1;
|
|
555
|
+
}
|
|
556
|
+
return {
|
|
557
|
+
errors,
|
|
558
|
+
reviews,
|
|
559
|
+
recommendations,
|
|
560
|
+
warnings: reviews + recommendations,
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
function reactDisplayDiagnostic(diagnostic, project) {
|
|
564
|
+
return {
|
|
565
|
+
group: `${project ? `${project} · ` : ''}${diagnostic.file}`,
|
|
566
|
+
ruleId: diagnostic.ruleId.replace(/^react-doctor\//, ''),
|
|
567
|
+
severity: diagnostic.severity,
|
|
568
|
+
kind: diagnostic.severity === 'error' ? 'error' : 'review',
|
|
569
|
+
compactValue: normalizeDiagnosticText(diagnostic.title),
|
|
570
|
+
verboseValue: `${normalizeDiagnosticText(diagnostic.title)}: ${normalizeDiagnosticText(diagnostic.message)}`,
|
|
571
|
+
location: `${diagnostic.line}:${diagnostic.column}`,
|
|
572
|
+
context: diagnostic.category,
|
|
573
|
+
verboseHint: [
|
|
574
|
+
`${diagnostic.severity === 'error' ? 'Fix' : 'Review'}: ${normalizeDiagnosticText(diagnostic.suggestion)}`,
|
|
575
|
+
...(diagnostic.suppressionHint
|
|
576
|
+
? [`Suppress: ${normalizeDiagnosticText(diagnostic.suppressionHint)}`]
|
|
577
|
+
: []),
|
|
578
|
+
...(diagnostic.url ? [diagnostic.url] : []),
|
|
579
|
+
].join(' · '),
|
|
580
|
+
...(diagnostic.fixGroupId ? { fixGroupId: diagnostic.fixGroupId } : {}),
|
|
581
|
+
...(diagnostic.relatedLocations?.length
|
|
582
|
+
? {
|
|
583
|
+
relatedLocations: diagnostic.relatedLocations.map((location) => `${location.file}:${location.line}:${location.column} (${normalizeDiagnosticText(location.message)})`),
|
|
584
|
+
}
|
|
585
|
+
: {}),
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
function hubspotDisplayDiagnostic(diagnostic, project) {
|
|
589
|
+
return {
|
|
590
|
+
group: `${project ? `${project} · ` : ''}${diagnostic.file}`,
|
|
591
|
+
ruleId: diagnostic.ruleId,
|
|
592
|
+
severity: diagnostic.severity,
|
|
593
|
+
kind: diagnostic.kind,
|
|
594
|
+
compactValue: normalizeDiagnosticText(diagnostic.suggestion),
|
|
595
|
+
verboseValue: normalizeDiagnosticText(diagnostic.message),
|
|
596
|
+
location: `${diagnostic.line}:${diagnostic.column}`,
|
|
597
|
+
verboseHint: `${diagnostic.kind === 'error' ? 'Fix' : diagnostic.kind === 'review' ? 'Review' : 'Recommendation'}: ${normalizeDiagnosticText(diagnostic.suggestion)} · Source: ${diagnostic.source}`,
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
function renderDiagnosticGroups(reporter, diagnostics, verbose) {
|
|
601
|
+
return Effect.gen(function* () {
|
|
602
|
+
const relatedDiagnostics = addDiagnosticRelations(diagnostics);
|
|
603
|
+
const useFamilyView = !verbose && diagnosticLocationCount(relatedDiagnostics) > 24;
|
|
604
|
+
for (const section of FINDING_SECTIONS) {
|
|
605
|
+
const sectionDiagnostics = relatedDiagnostics.filter((diagnostic) => diagnostic.kind === section.kind);
|
|
606
|
+
if (sectionDiagnostics.length === 0)
|
|
607
|
+
continue;
|
|
608
|
+
const findingCount = new Set(sectionDiagnostics.map((diagnostic) => diagnosticFamilyKey(diagnostic, false))).size;
|
|
609
|
+
const locationCount = diagnosticLocationCount(sectionDiagnostics);
|
|
610
|
+
const findingLabel = useFamilyView
|
|
611
|
+
? `${findingCount} fix ${findingCount === 1 ? 'family' : 'families'} · ${locationCount} locations`
|
|
612
|
+
: findingCount === locationCount
|
|
613
|
+
? `${findingCount} finding${findingCount === 1 ? '' : 's'}`
|
|
614
|
+
: `${findingCount} finding${findingCount === 1 ? '' : 's'} · ${locationCount} locations`;
|
|
615
|
+
yield* reporter.rows([{ key: section.label, value: findingLabel }]);
|
|
616
|
+
if (useFamilyView)
|
|
617
|
+
yield* reporter.rows(diagnosticFamilyRows(sectionDiagnostics));
|
|
618
|
+
else
|
|
619
|
+
yield* renderFileGroups(reporter, sectionDiagnostics, verbose);
|
|
620
|
+
}
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
function addDiagnosticRelations(diagnostics) {
|
|
624
|
+
return diagnostics.map((diagnostic) => {
|
|
625
|
+
if (diagnostic.ruleId !== 'TBL-01' && diagnostic.ruleId !== 'TBL-08') {
|
|
626
|
+
return diagnostic;
|
|
627
|
+
}
|
|
628
|
+
const context = [diagnostic.context, 'if keeping raw Table'].filter(Boolean).join(' · ');
|
|
629
|
+
return { ...diagnostic, context };
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
const FINDING_SECTIONS = [
|
|
633
|
+
{ kind: 'error', label: 'Errors' },
|
|
634
|
+
{ kind: 'review', label: 'Review' },
|
|
635
|
+
{ kind: 'recommendation', label: 'HS-UIX recommendations' },
|
|
636
|
+
];
|
|
637
|
+
function renderFileGroups(reporter, diagnostics, verbose) {
|
|
638
|
+
return Effect.gen(function* () {
|
|
639
|
+
const byGroup = new Map();
|
|
640
|
+
for (const diagnostic of diagnostics) {
|
|
641
|
+
const group = byGroup.get(diagnostic.group) ?? [];
|
|
642
|
+
group.push(diagnostic);
|
|
643
|
+
byGroup.set(diagnostic.group, group);
|
|
644
|
+
}
|
|
645
|
+
const groups = [...byGroup.entries()].sort(([leftLabel], [rightLabel]) => leftLabel.localeCompare(rightLabel));
|
|
646
|
+
for (const [group, findings] of groups) {
|
|
647
|
+
// A blank-key row keeps the file on the same rail as its findings while
|
|
648
|
+
// leaving status glyphs and rule IDs to carry the actionable hierarchy.
|
|
649
|
+
yield* reporter.rows([{ key: '', value: group }]);
|
|
650
|
+
const sorted = [...findings].sort((left, right) => {
|
|
651
|
+
if (left.severity !== right.severity)
|
|
652
|
+
return left.severity === 'error' ? -1 : 1;
|
|
653
|
+
return left.location.localeCompare(right.location, undefined, {
|
|
654
|
+
numeric: true,
|
|
655
|
+
});
|
|
656
|
+
});
|
|
657
|
+
yield* reporter.rows(diagnosticRows(sorted, verbose));
|
|
658
|
+
}
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
function diagnosticRows(diagnostics, verbose) {
|
|
662
|
+
const combined = new Map();
|
|
663
|
+
for (const diagnostic of diagnostics) {
|
|
664
|
+
const key = diagnosticClusterKey(diagnostic, verbose);
|
|
665
|
+
const existing = combined.get(key);
|
|
666
|
+
const locations = [diagnostic.location, ...(diagnostic.relatedLocations ?? [])];
|
|
667
|
+
if (existing)
|
|
668
|
+
existing.locations.push(...locations);
|
|
669
|
+
else
|
|
670
|
+
combined.set(key, { diagnostic, locations });
|
|
671
|
+
}
|
|
672
|
+
return [...combined.values()].map(({ diagnostic, locations }) => ({
|
|
673
|
+
key: diagnostic.ruleId,
|
|
674
|
+
value: verbose ? diagnostic.verboseValue : diagnostic.compactValue,
|
|
675
|
+
status: diagnostic.severity === 'error' ? 'fail' : 'warn',
|
|
676
|
+
detail: diagnosticDetail(locations, diagnostic.context),
|
|
677
|
+
...(verbose ? { hint: diagnostic.verboseHint } : {}),
|
|
678
|
+
}));
|
|
679
|
+
}
|
|
680
|
+
function diagnosticClusterKey(diagnostic, verbose) {
|
|
681
|
+
if (diagnostic.fixGroupId) {
|
|
682
|
+
return [diagnostic.group, diagnostic.severity, diagnostic.fixGroupId].join('\0');
|
|
683
|
+
}
|
|
684
|
+
return [
|
|
685
|
+
diagnostic.group,
|
|
686
|
+
diagnostic.severity,
|
|
687
|
+
diagnostic.ruleId,
|
|
688
|
+
verbose ? diagnostic.verboseValue : diagnostic.compactValue,
|
|
689
|
+
diagnostic.context ?? '',
|
|
690
|
+
].join('\0');
|
|
691
|
+
}
|
|
692
|
+
function diagnosticFamilyKey(diagnostic, verbose) {
|
|
693
|
+
if (diagnostic.fixGroupId) {
|
|
694
|
+
return [diagnostic.severity, diagnostic.fixGroupId].join('\0');
|
|
695
|
+
}
|
|
696
|
+
return [
|
|
697
|
+
diagnostic.severity,
|
|
698
|
+
diagnostic.ruleId,
|
|
699
|
+
verbose ? diagnostic.verboseValue : diagnostic.compactValue,
|
|
700
|
+
diagnostic.context ?? '',
|
|
701
|
+
].join('\0');
|
|
702
|
+
}
|
|
703
|
+
function diagnosticFamilyRows(diagnostics) {
|
|
704
|
+
const families = new Map();
|
|
705
|
+
for (const diagnostic of diagnostics) {
|
|
706
|
+
const key = diagnosticFamilyKey(diagnostic, false);
|
|
707
|
+
const family = families.get(key) ?? [];
|
|
708
|
+
family.push(diagnostic);
|
|
709
|
+
families.set(key, family);
|
|
710
|
+
}
|
|
711
|
+
return [...families.values()]
|
|
712
|
+
.sort((left, right) => {
|
|
713
|
+
const leftDiagnostic = left[0];
|
|
714
|
+
const rightDiagnostic = right[0];
|
|
715
|
+
if (!leftDiagnostic || !rightDiagnostic)
|
|
716
|
+
return 0;
|
|
717
|
+
if (leftDiagnostic.severity !== rightDiagnostic.severity) {
|
|
718
|
+
return leftDiagnostic.severity === 'error' ? -1 : 1;
|
|
719
|
+
}
|
|
720
|
+
return leftDiagnostic.ruleId.localeCompare(rightDiagnostic.ruleId);
|
|
721
|
+
})
|
|
722
|
+
.flatMap((family) => {
|
|
723
|
+
const diagnostic = family[0];
|
|
724
|
+
if (!diagnostic)
|
|
725
|
+
return [];
|
|
726
|
+
return [
|
|
727
|
+
{
|
|
728
|
+
key: diagnostic.ruleId,
|
|
729
|
+
value: diagnostic.compactValue,
|
|
730
|
+
status: diagnostic.severity === 'error' ? 'fail' : 'warn',
|
|
731
|
+
detail: diagnosticFamilyDetail(family, diagnostic.context),
|
|
732
|
+
},
|
|
733
|
+
];
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
function diagnosticFamilyDetail(diagnostics, context) {
|
|
737
|
+
const locationsByFile = new Map();
|
|
738
|
+
for (const diagnostic of diagnostics) {
|
|
739
|
+
const locations = locationsByFile.get(diagnostic.group) ?? [];
|
|
740
|
+
locations.push(diagnostic.location, ...(diagnostic.relatedLocations ?? []));
|
|
741
|
+
locationsByFile.set(diagnostic.group, locations);
|
|
742
|
+
}
|
|
743
|
+
const entries = [...locationsByFile.entries()];
|
|
744
|
+
const previews = entries.slice(0, 2).map(([file, locations]) => {
|
|
745
|
+
const shown = locations.slice(0, 3);
|
|
746
|
+
const remaining = locations.length - shown.length;
|
|
747
|
+
return `${file} ${shown.join(', ')}${remaining > 0 ? ` +${remaining}` : ''}`;
|
|
748
|
+
});
|
|
749
|
+
const remainingFiles = entries.length - previews.length;
|
|
750
|
+
const locationCount = diagnosticLocationCount(diagnostics);
|
|
751
|
+
return [
|
|
752
|
+
`${entries.length} file${entries.length === 1 ? '' : 's'} · ${locationCount} location${locationCount === 1 ? '' : 's'}`,
|
|
753
|
+
...previews,
|
|
754
|
+
...(remainingFiles > 0 ? [`+${remainingFiles} files`] : []),
|
|
755
|
+
...(context ? [context] : []),
|
|
756
|
+
].join(' · ');
|
|
757
|
+
}
|
|
758
|
+
function diagnosticLocationCount(diagnostics) {
|
|
759
|
+
return diagnostics.reduce((total, diagnostic) => total + 1 + (diagnostic.relatedLocations?.length ?? 0), 0);
|
|
760
|
+
}
|
|
761
|
+
function diagnosticDetail(locations, context) {
|
|
762
|
+
return [locations.join(', '), ...(context ? [context] : [])].join(' · ');
|
|
763
|
+
}
|
|
764
|
+
function normalizeDiagnosticText(text) {
|
|
765
|
+
return text.replace(/\s+/g, ' ').trim();
|
|
766
|
+
}
|
|
767
|
+
function renderDoctorNotes(reporter, report, reactHealth, verbose) {
|
|
768
|
+
return Effect.gen(function* () {
|
|
769
|
+
const reactSummary = summarizeReactHealth(reactHealth);
|
|
770
|
+
const hasFindings = report.summary.errors +
|
|
771
|
+
report.summary.warnings +
|
|
772
|
+
reactSummary.errors +
|
|
773
|
+
reactSummary.warnings >
|
|
774
|
+
0;
|
|
775
|
+
if (!verbose) {
|
|
776
|
+
if (hasFindings)
|
|
777
|
+
yield* reporter.info('Run again with --verbose for explanations and guidance.');
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
const versions = [
|
|
781
|
+
...new Set(reactHealth
|
|
782
|
+
.map((health) => health.engineVersion)
|
|
783
|
+
.filter((version) => version !== undefined)),
|
|
784
|
+
];
|
|
785
|
+
if (versions.length > 0) {
|
|
786
|
+
yield* reporter.info(`Upstream React Doctor v${versions.join(', ')} · offline score and telemetry disabled`);
|
|
787
|
+
}
|
|
788
|
+
yield* reporter.info('HubSpot rule IDs map to the shared HS-UIX Studio standards.');
|
|
789
|
+
yield* reporter.info(coverageText(report));
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
function coverageText(report) {
|
|
793
|
+
const { coverage } = report;
|
|
794
|
+
return `Coverage: ${coverage.implemented} implemented · ${coverage.heuristic} heuristic · ${coverage.studioOnly} Studio-only · ${coverage.pending} pending`;
|
|
795
|
+
}
|
|
796
|
+
export function reactHelpText() {
|
|
797
|
+
return `hs-x react
|
|
798
|
+
|
|
799
|
+
Inspect HubSpot React UI extensions with the shared HS-UIX standards.
|
|
800
|
+
|
|
801
|
+
Commands:
|
|
802
|
+
hs-x react doctor [options] Scan generic React and HubSpot UIX health
|
|
803
|
+
hs-x react doctor why <file:line> Explain why a finding fired
|
|
804
|
+
hs-x react review [--deep] [--json] Build an agent-facing contextual review packet
|
|
805
|
+
hs-x react rules [--json] Show catalog coverage and rule status
|
|
806
|
+
hs-x react rules explain <rule-id> Explain one rule with versioned source guidance
|
|
807
|
+
`;
|
|
808
|
+
}
|
|
809
|
+
export function reactDoctorHelpText() {
|
|
810
|
+
return `hs-x react doctor
|
|
811
|
+
|
|
812
|
+
Run generic React health checks plus HubSpot UI-extension checks without an
|
|
813
|
+
HS-X login, Cloudflare account, network connection, or project migration.
|
|
814
|
+
Generic checks include hooks, memoization, correctness, performance, and code
|
|
815
|
+
quality. HubSpot findings use stable rule IDs from the shared HS-UIX Studio catalog.
|
|
816
|
+
Platform/runtime errors fail by default; contextual findings appear under Review,
|
|
817
|
+
and library adoption guidance appears under HS-UIX recommendations.
|
|
818
|
+
|
|
819
|
+
Flags:
|
|
820
|
+
--cwd <path> Project directory (defaults to current working directory)
|
|
821
|
+
--recursive Discover every nested hsproject.json project
|
|
822
|
+
--scope <mode> full, files, changed, or lines (default: full)
|
|
823
|
+
--base <ref> Git base ref for files, changed, or lines scope
|
|
824
|
+
--staged Scan only files staged for commit
|
|
825
|
+
--ci Scan only findings introduced against the Git base
|
|
826
|
+
--deep Include archived source plus dead files, exports, dependencies, and cycles
|
|
827
|
+
--strict Fail on Review and HS-UIX recommendations too
|
|
828
|
+
--hubspot-only Skip generic React health checks
|
|
829
|
+
--verbose Show full explanations and guidance
|
|
830
|
+
--json Emit structured JSON output
|
|
831
|
+
`;
|
|
832
|
+
}
|
|
833
|
+
export function reactDoctorWhyHelpText() {
|
|
834
|
+
return `hs-x react doctor why <file:line>
|
|
835
|
+
|
|
836
|
+
Explain every active HubSpot UIX and generic React finding that starts at a
|
|
837
|
+
source location. HubSpot explanations include the shared source, platform gate,
|
|
838
|
+
and project platform. Generic explanations include suppression guidance,
|
|
839
|
+
related locations, fix-group identity, and the upstream rule reference.
|
|
840
|
+
|
|
841
|
+
Examples:
|
|
842
|
+
hs-x react doctor why src/app/cards/Card.tsx:42
|
|
843
|
+
hs-x react doctor why src/app/cards/Card.tsx:42:7 --json
|
|
844
|
+
|
|
845
|
+
Flags:
|
|
846
|
+
--cwd <path> Project directory (defaults to current working directory)
|
|
847
|
+
--json Emit structured JSON output
|
|
848
|
+
`;
|
|
849
|
+
}
|
|
850
|
+
export function reactRulesHelpText() {
|
|
851
|
+
return `hs-x react rules
|
|
852
|
+
|
|
853
|
+
Show how every rule in the shared HS-UIX Studio catalog is handled by the
|
|
854
|
+
React Doctor: implemented, heuristic, Studio-only, or pending.
|
|
855
|
+
|
|
856
|
+
Flags:
|
|
857
|
+
--json Emit all catalog rules and coverage statuses
|
|
858
|
+
`;
|
|
859
|
+
}
|
|
860
|
+
export function reactRulesExplainHelpText() {
|
|
861
|
+
return `hs-x react rules explain <rule-id>
|
|
862
|
+
|
|
863
|
+
Explain one shared Studio rule, how React Doctor enforces it, the allowed agent
|
|
864
|
+
verdicts, and the complete versioned source guidance in JSON mode.
|
|
865
|
+
|
|
866
|
+
Flags:
|
|
867
|
+
--json Include the complete source documents and agent contract
|
|
868
|
+
`;
|
|
869
|
+
}
|
|
870
|
+
export function reactReviewHelpText() {
|
|
871
|
+
return `hs-x react review
|
|
872
|
+
|
|
873
|
+
Build a project-aware review packet for contextual HS-UIX rules. The packet
|
|
874
|
+
contains deterministic findings, component evidence, relevant heuristic
|
|
875
|
+
candidates, and the versioned Studio source documents an agent must evaluate.
|
|
876
|
+
Candidates never affect the React Doctor exit code.
|
|
877
|
+
|
|
878
|
+
Flags:
|
|
879
|
+
--cwd <path> Project directory (defaults to current working directory)
|
|
880
|
+
--deep Include conventional archive and archived source trees
|
|
881
|
+
--json Emit the complete agent review packet
|
|
882
|
+
`;
|
|
883
|
+
}
|
|
884
|
+
//# sourceMappingURL=react.js.map
|